From 4700c1c5787994152428ca77dae1f3008cbd94ab Mon Sep 17 00:00:00 2001 From: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Date: Fri, 31 Jul 2026 19:40:15 +0800 Subject: [PATCH] test(cli): pin that all three commands run the reference-integrity suite (#4384) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #4394 removed the instance — `validateReadonlyFlowWrites` was hand-wired into `os validate` and `os compile` and never into `os lint`, so an `error`-level gate left `os lint` returning clean for stacks `os compile` refuses. It did not remove the failure MODE. A rule wired into two commands out of three produces no failing assertion anywhere: every command's own tests pass, the rule's unit tests pass, and the only symptom is the three commands disagreeing about the same stack. That is how the last one survived from #3425 until #4394, with the suite's own header naming it "the standing proof" of the drift the suite exists to end — a comment doing a test's job. The invariant worth pinning is `os lint` ⊇ `os compile`'s gate set: lint is the cheap pre-flight, compile is the gate, and a green lint followed by a red compile makes the pre-flight worthless — worse than worthless for an agent, whose remaining options are re-verifying everything or learning to distrust the signal. The guard asserts each command calls `validateReferenceIntegrity` and that none imports a suite member directly — the import is what a second call site starts with, so catching it closes the window between adding the import and adding the call. It scans source rather than spying, for the same reason lazy-deps.test.ts scans src: vitest inlines imports through its transform, so a module-cache probe cannot prove which symbols a command file actually reaches for. A third assertion keeps the other two from passing vacuously if the suite were emptied or its export renamed. Verified to FAIL on a reintroduced direct import in compile.ts, not merely to pass today. Test-only; no runtime or behaviour change. Co-Authored-By: Claude Opus 5 --- .../reference-integrity-wiring-guard.md | 24 ++++++ .../reference-integrity-wiring.test.ts | 82 +++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 .changeset/reference-integrity-wiring-guard.md create mode 100644 packages/cli/src/commands/reference-integrity-wiring.test.ts diff --git a/.changeset/reference-integrity-wiring-guard.md b/.changeset/reference-integrity-wiring-guard.md new file mode 100644 index 0000000000..df917d7aef --- /dev/null +++ b/.changeset/reference-integrity-wiring-guard.md @@ -0,0 +1,24 @@ +--- +--- + +Test-only: a structural wiring guard so the drift #4394 just fixed cannot come +back silently (#4384). + +#4394 removed the *instance* — `validateReadonlyFlowWrites` had been hand-wired +into `os validate` and `os compile` and never into `os lint`, so an `error`-level +gate left `os lint` passing stacks `os compile` refuses. It did not remove the +*failure mode*: nothing stops the next rule from being wired into two commands +out of three, and that defect produces no failing assertion anywhere — each +command's tests pass, the rule's unit tests pass, and the only symptom is the +three commands disagreeing about one stack. That is how the last one survived +from #3425 until #4394. + +`reference-integrity-wiring.test.ts` asserts each of the three commands calls +`validateReferenceIntegrity`, and that none of them imports a suite member +directly — the import being what a second call site starts with. It scans source +rather than spying, for the reason `lazy-deps.test.ts` does: vitest inlines +imports, so a module-cache probe cannot prove which symbols a command file +actually reaches for. Verified to FAIL on a reintroduced direct import, not +merely to pass today. + +Releases nothing. diff --git a/packages/cli/src/commands/reference-integrity-wiring.test.ts b/packages/cli/src/commands/reference-integrity-wiring.test.ts new file mode 100644 index 0000000000..0037dc0898 --- /dev/null +++ b/packages/cli/src/commands/reference-integrity-wiring.test.ts @@ -0,0 +1,82 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Structural wiring guard for the suite's whole reason to exist (#3583 §5 D5). +// +// ## Why a test and not a comment +// +// The defect this catches is not a wrong result — it is a MISSING CALL, and a +// missing call produces no failing assertion anywhere. Every command's own +// tests pass, the rule's unit tests pass, and the only symptom is that +// `os validate`, `os lint` and `os compile` disagree about the same stack. +// +// That is not hypothetical. `validateReadonlyFlowWrites` was hand-wired into +// `validate` and `compile` and never into `lint` from #3425 until #4394 — and +// because it GATES (`flow-update-readonly-field` is an `error`), `os lint` +// spent that whole time returning clean for stacks `os compile` refuses. The +// suite's own header had been naming it as the standing proof of the drift the +// suite exists to end, which is a comment doing a test's job. #4394 removed +// that instance; this file removes the failure MODE, so the next rule cannot +// repeat it silently (#4384). +// +// ## The invariant +// +// `os lint` ⊇ `os compile`'s gate set. `lint` is the cheap pre-flight and +// `compile` is the gate; a green `lint` followed by a red `compile` makes the +// pre-flight worthless — and for an agent it is worse than worthless, because +// the remaining options are re-verifying everything (slow) or learning to +// distrust the signal (dangerous). +// +// ## Why it scans source +// +// vitest inlines imports through its transform, so a spy on `@objectstack/lint` +// cannot prove which symbols a command file actually reaches for — the same +// reason `lazy-deps.test.ts` scans `src/` rather than probing a module cache. +// Behavioural coverage of what the suite CONTAINS lives in +// `@objectstack/lint`'s `reference-integrity-suite.test.ts`; this file guards +// only the seam between that suite and the three call sites. + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, it, expect } from 'vitest'; +import { REFERENCE_INTEGRITY_RULES } from '@objectstack/lint'; + +const commandsDir = dirname(fileURLToPath(import.meta.url)); + +/** The three commands that must hold a stack to the same author-time bar. */ +const AUTHORING_COMMANDS = ['validate.ts', 'lint.ts', 'compile.ts'] as const; + +const sourceOf = (file: string) => readFileSync(join(commandsDir, file), 'utf8'); + +describe('reference-integrity wiring (#3583 §5 D5, #4384)', () => { + it.each(AUTHORING_COMMANDS)('%s runs the suite', (file) => { + expect(sourceOf(file)).toMatch(/\bvalidateReferenceIntegrity\s*\(/); + }); + + // The regression itself. A second, per-rule call site is how a rule ends up + // on two commands out of three, and it always starts with importing that rule + // by name — so the import is what this asserts on. Catching the call instead + // would miss the window between adding the import and adding the second call. + it.each(AUTHORING_COMMANDS)('%s does not import a suite member directly', (file) => { + const source = sourceOf(file); + const reached = REFERENCE_INTEGRITY_RULES.map((r) => r.name).filter((name) => + new RegExp( + String.raw`^\s*import\s[^;]*\b${name}\b[^;]*from\s*['"]@objectstack/lint['"]`, + 'm', + ).test(source), + ); + expect( + reached, + `${file} imports suite member(s) directly — run them via validateReferenceIntegrity instead, ` + + `or all three commands will drift the way validateReadonlyFlowWrites did (#4394)`, + ).toEqual([]); + }); + + // Guards the guard: were the suite ever emptied or the export renamed, the + // assertions above would pass vacuously while checking nothing. + it('the suite is non-empty, so the assertions above are not vacuous', () => { + expect(REFERENCE_INTEGRITY_RULES.length).toBeGreaterThan(0); + // The rule whose absence from `os lint` is the reason this file exists. + expect(REFERENCE_INTEGRITY_RULES.map((r) => r.name)).toContain('validateReadonlyFlowWrites'); + }); +});