|
| 1 | +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * #17069 — the three authoring commands judge the SAME STACK, on the ADR-0130 |
| 5 | + * D4 / option-B shape. |
| 6 | + * |
| 7 | + * `authoring-rule-command-parity.test.ts` next door proves the three commands |
| 8 | + * reach the same verdict about the same rule TABLE. This file proves the other |
| 9 | + * half of the same promise, one layer earlier: that they hand that table the |
| 10 | + * same INPUT. A command can run every rule in the registry and still certify a |
| 11 | + * project clean — if what it hands the registry is an empty stack. |
| 12 | + * |
| 13 | + * ## The defect this file pins |
| 14 | + * |
| 15 | + * A project whose definitions live only in `packages[]` — the ADR-0130 D4 |
| 16 | + * artifact shape, no collections at the top level — was judged by `os validate` |
| 17 | + * and `os lint` as if it declared NOTHING. `compile.ts` folds the packages back |
| 18 | + * in through `authoringRuleUnionStack` before it runs the table; neither |
| 19 | + * sibling command imported that helper, so each ran 44 rules over `{}`. |
| 20 | + * |
| 21 | + * Measured through the real binaries on this card's repro, before the fix: |
| 22 | + * |
| 23 | + * os validate EXIT=0 ✓ Validation passed |
| 24 | + * os lint EXIT=0 (no finding of any severity) |
| 25 | + * os build EXIT=1 object-reference-unknown |
| 26 | + * |
| 27 | + * ⭐ The dangling `ob_nowhere` is only the PROBE that makes the blindness |
| 28 | + * visible. The same silence covered every author-time rule, because the input |
| 29 | + * was empty — which is why the fix is the shared fold and not a rule. |
| 30 | + * |
| 31 | + * ## Why this is the p1 direction of the #4409 class |
| 32 | + * |
| 33 | + * #4409 was `os build` publishing what the other two refuse. This is the |
| 34 | + * mirror, and it is worse: `os validate` is the fast inner-loop check an author |
| 35 | + * runs BEFORE shipping, so its clean bill of health on a stack it read nothing |
| 36 | + * of is the strongest false assurance the three commands can give. |
| 37 | + * `content/docs/deployment/validating-metadata.mdx` states the parity as a |
| 38 | + * promise — *"anything that can fail a build fails `os lint` too"* — and on |
| 39 | + * this stack shape the promise was false in the loudest direction. |
| 40 | + * |
| 41 | + * ## Why it is spawned, and why BOTH fixtures are here |
| 42 | + * |
| 43 | + * Spawned because the exit CODE is the contract a CI pipeline reads, and only a |
| 44 | + * real process produces one; `os validate`'s rule run is an expression inside |
| 45 | + * the oclif command body, so there is no exported seam a probe could call |
| 46 | + * instead (the same reason the option-B acceptance pin could not reach it). |
| 47 | + * |
| 48 | + * The CLEAN fixture is not decoration. A fold wired in backwards — or a rule |
| 49 | + * that fires on any `packages[]` at all — would satisfy the failing case alone. |
| 50 | + * The pair asserts what the card actually claims: that the option-B stack is |
| 51 | + * READ, not that it is rejected. |
| 52 | + */ |
| 53 | + |
| 54 | +import { describe, expect, it } from 'vitest'; |
| 55 | +import { execFileSync } from 'node:child_process'; |
| 56 | +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; |
| 57 | +import { tmpdir } from 'node:os'; |
| 58 | +import { join, resolve } from 'node:path'; |
| 59 | +import { fileURLToPath } from 'node:url'; |
| 60 | +import { childEnv } from './helpers/serve-process.js'; |
| 61 | + |
| 62 | +const HERE = resolve(fileURLToPath(import.meta.url), '..'); |
| 63 | +const CLI = resolve(HERE, '../bin/run-dev.js'); |
| 64 | + |
| 65 | +/** The three commands the #4409 registry holds to one bar. */ |
| 66 | +const AUTHORING_COMMANDS = ['validate', 'lint', 'build'] as const; |
| 67 | + |
| 68 | +/** The rule the probe trips, and the path it reports it at. */ |
| 69 | +const RULE = 'object-reference-unknown'; |
| 70 | +const RULE_PATH = 'objects[0].fields.ghost.reference'; |
| 71 | + |
| 72 | +/** |
| 73 | + * The card's repro verbatim: no top-level `objects`, one `packages[]` entry |
| 74 | + * carrying an object whose `ghost` lookup points at an object that does not |
| 75 | + * exist. Every collection this project declares lives inside `packages[]`. |
| 76 | + */ |
| 77 | +const optionBStack = (reference: string): Record<string, unknown> => ({ |
| 78 | + manifest: { id: 'com.example.ob', name: 'ob', version: '1.0.0', type: 'app', namespace: 'ob' }, |
| 79 | + packages: [ |
| 80 | + { |
| 81 | + manifest: { |
| 82 | + id: 'com.example.ob', |
| 83 | + name: 'ob', |
| 84 | + version: '1.0.0', |
| 85 | + type: 'app', |
| 86 | + namespace: 'ob', |
| 87 | + objects: [ |
| 88 | + { |
| 89 | + name: 'ob_order', |
| 90 | + label: 'Order', |
| 91 | + sharingModel: 'private', |
| 92 | + fields: { |
| 93 | + number: { type: 'text', label: 'Number' }, |
| 94 | + ghost: { type: 'lookup', label: 'Ghost', reference }, |
| 95 | + }, |
| 96 | + }, |
| 97 | + ], |
| 98 | + }, |
| 99 | + }, |
| 100 | + ], |
| 101 | +}); |
| 102 | + |
| 103 | +interface Run { |
| 104 | + code: number; |
| 105 | + output: string; |
| 106 | +} |
| 107 | + |
| 108 | +/** |
| 109 | + * One authoring command over one option-B project, as a shell sees it. |
| 110 | + * |
| 111 | + * A plain literal config with no imports, so it resolves with no `node_modules` |
| 112 | + * next to it — the `authoring-rule-command-parity.test.ts` pattern. |
| 113 | + */ |
| 114 | +function runCommand(command: string, stack: Record<string, unknown>): Run { |
| 115 | + const dir = mkdtempSync(join(tmpdir(), 'os-union-fold-')); |
| 116 | + try { |
| 117 | + writeFileSync(join(dir, 'objectstack.config.mjs'), `export default ${JSON.stringify(stack, null, 2)};\n`); |
| 118 | + try { |
| 119 | + const stdout = execFileSync(process.execPath, [CLI, command], { |
| 120 | + cwd: dir, |
| 121 | + encoding: 'utf8', |
| 122 | + stdio: 'pipe', |
| 123 | + // Every spawned child under this directory declares its environment at |
| 124 | + // the call site (#11595). |
| 125 | + env: childEnv({ NO_COLOR: '1' }), |
| 126 | + }); |
| 127 | + return { code: 0, output: String(stdout) }; |
| 128 | + } catch (error: any) { |
| 129 | + return { code: error.status ?? 1, output: `${error.stdout ?? ''}${error.stderr ?? ''}` }; |
| 130 | + } |
| 131 | + } finally { |
| 132 | + rmSync(dir, { recursive: true, force: true }); |
| 133 | + } |
| 134 | +} |
| 135 | + |
| 136 | +describe('#17069 — os validate and os lint judge the option-B stack, not an empty one', () => { |
| 137 | + it.each(AUTHORING_COMMANDS)( |
| 138 | + 'os %s refuses a packages[]-only project whose lookup target does not exist', |
| 139 | + (command) => { |
| 140 | + const run = runCommand(command, optionBStack('ob_nowhere')); |
| 141 | + expect( |
| 142 | + run.code, |
| 143 | + `os ${command} exited ${run.code} on a project whose definitions live only in packages[]. ` + |
| 144 | + `Before #17069 os validate and os lint both exited 0 here — they handed the author-time ` + |
| 145 | + `rule table an EMPTY stack, so all 44 rules reported nothing and the project was certified ` + |
| 146 | + `clean without being read. Hand the table authoringRuleUnionStack(...) as compile.ts does.` + |
| 147 | + `\n--- output ---\n${run.output}`, |
| 148 | + ).toBe(1); |
| 149 | + expect(run.output, `os ${command} refused the stack without naming the rule`).toContain(RULE); |
| 150 | + expect( |
| 151 | + run.output, |
| 152 | + `os ${command} named ${RULE} at a different path than the other doors — the three commands ` + |
| 153 | + `must report one finding one way`, |
| 154 | + ).toContain(RULE_PATH); |
| 155 | + }, |
| 156 | + 180_000, |
| 157 | + ); |
| 158 | + |
| 159 | + it.each(AUTHORING_COMMANDS)( |
| 160 | + 'control: os %s passes the identical option-B project once the lookup resolves', |
| 161 | + (command) => { |
| 162 | + const run = runCommand(command, optionBStack('ob_order')); |
| 163 | + expect( |
| 164 | + run.code, |
| 165 | + `os ${command} exited ${run.code} on a CLEAN option-B project. The fold folds packages[] ` + |
| 166 | + `back in as the rule table's INPUT — it does not make a packages[]-only project fail. ` + |
| 167 | + `Without this control the case above would pass on a command that refuses every ` + |
| 168 | + `multi-package stack.\n--- output ---\n${run.output}`, |
| 169 | + ).toBe(0); |
| 170 | + expect(run.output, `os ${command} reported ${RULE} on a stack whose lookup resolves`).not.toContain(RULE); |
| 171 | + }, |
| 172 | + 180_000, |
| 173 | + ); |
| 174 | +}); |
0 commit comments