Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,23 @@ that graduates deletes its ledger entry in the same PR.
`typecheck` script advertises — a green gate over source nothing read, which is the
#4311 defect itself. The ratchet's `TESTS_COVERED` invariant fails on any new exclusion;
the packages that already had one carry a measured `TEST_DEBT` entry and graduate by
dropping the exclusion.
dropping the exclusion — or, when the build config must keep the exclusion (ci.yml gates
that no test file reaches the published artifact), by adding a **sibling
`tsconfig.test.json` and naming it in the `typecheck` script**, which is what
`packages/spec` does since #5286. The sibling may carry its own *module* semantics to
match how vitest executes the files (`module: esnext`, `moduleResolution: bundler`) —
never its own *strictness*: `strict` and friends are inherited, untouched.

**A `@ts-expect-error` in a file no tsc program compiles is a phantom check** — the
`PINS_CHECKED` invariant of the same ratchet, repo-wide. `@ts-expect-error` is the
"tsc is the best sweeper" channel the spec-property-retirement playbook leans on: the
directive is meant to go red the day a removed key comes back. Outside a program it
evaluates never, and *deleting the directive leaves every gate just as green* — which is
how spec's 17 retirement pins across 5 files were found (#5286). Before writing one,
check the file is compiled. `packages/spec` additionally holds its test-layer residue in
a per-file, exactly-measured, shrink-only ledger (`packages/spec/test-typecheck-debt.json`,
`pnpm --filter @objectstack/spec gen:test-typecheck-debt`): a file not listed there may
have no type errors at all.

One trap worth knowing before you read any of these counts: under `moduleResolution:
NodeNext` a relative import missing its `.js` extension does not resolve, every symbol it
Expand Down
4 changes: 3 additions & 1 deletion packages/spec/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@
"check:react-blocks": "tsx scripts/build-react-blocks-contract.ts --check",
"check:react-declaration-parity": "tsx scripts/check-react-blocks-declaration-parity.ts",
"check:skill-examples": "tsx scripts/check-skill-examples.ts",
"typecheck": "tsc --noEmit"
"check:test-typecheck": "tsx scripts/check-test-typecheck.mts --self-test && tsx scripts/check-test-typecheck.mts --project tsconfig.test.json",
"gen:test-typecheck-debt": "tsx scripts/check-test-typecheck.mts --update --project tsconfig.test.json",
"typecheck": "tsc --noEmit && pnpm check:test-typecheck"
},
"keywords": [
"objectstack",
Expand Down
81 changes: 81 additions & 0 deletions packages/spec/scripts/check-generated-ledger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Pins the `check:generated` LEDGER against package.json — the reconciliation
// that decides whether every `check:`/`gen:` script in this package is actually
// covered by a gate, or has quietly dropped out of coverage.
//
// WHY IT IS WORTH A TEST AND NOT JUST A CI STEP. The reconciliation has been
// dormant or unsatisfied three times now, and each time the cost was a CI lap
// rather than a local one:
//
// • #4177 and #4232 landed unclassified scripts while nothing in CI ran the
// reconciliation at all, so `main` carried a wrapper that exited red before
// running a single gate.
// • #4291 fixed that by wiring `--reconcile-only` into lint.yml's unfiltered
// required job — which is exactly where #5286's own first push then died,
// because the two scripts it added (`check:test-typecheck`,
// `gen:test-typecheck-debt`) were in neither ledger bucket. `tsc` passed;
// the step after it did not.
//
// So the negative direction of this reconciliation is not hypothetical — it is
// the reason this file exists, observed in production twice. What was missing
// was a signal BEFORE the push: `pnpm --filter @objectstack/spec test` did not
// read the ledger, so a script added in one file and unclassified in another was
// invisible until a runner said so ten minutes later. This closes that gap.
//
// It runs the real script in place: `--reconcile-only` reads package.json and
// the ledger arrays and exits — no gates, no build, no writes, sub-second — so
// there is nothing to sandbox and no way for it to differ from what CI runs.

import { describe, it, expect } from 'vitest';
import { spawnSync } from 'node:child_process';
import { createRequire } from 'node:module';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const SPEC = path.resolve(HERE, '..');

function runReconcile(): { status: number | null; output: string } {
const require = createRequire(import.meta.url);
const tsx = require.resolve('tsx/cli');
const result = spawnSync(process.execPath, [tsx, path.join(HERE, 'check-generated.ts'), '--reconcile-only'], {
cwd: SPEC,
encoding: 'utf8',
maxBuffer: 16 * 1024 * 1024,
});
if (result.error) throw result.error;
return { status: result.status, output: `${result.stdout ?? ''}${result.stderr ?? ''}` };
}

describe('check:generated --reconcile-only', () => {
const scripts: Record<string, string> = JSON.parse(fs.readFileSync(path.join(SPEC, 'package.json'), 'utf8')).scripts;
const checks = Object.keys(scripts).filter((n) => n.startsWith('check:'));
const gens = Object.keys(scripts).filter((n) => n.startsWith('gen:'));

it('classifies every check:/gen: script this package declares', () => {
const { status, output } = runReconcile();
// The failure text is the useful part when this goes red: it names the
// unclassified script and asks the classifying question.
expect(output).not.toMatch(/is in neither GATED nor NO_GENERATOR/);
expect(output).not.toMatch(/it is not in UNGATED_GENERATORS/);
expect(status, output).toBe(0);
});

it('reports the same script counts package.json actually declares', () => {
// Derived from package.json rather than hardcoded, so adding a gate does not
// churn this test — only FAILING to classify one does.
const { output } = runReconcile();
expect(output).toContain(`${checks.length} check: + ${gens.length} gen: scripts`);
expect(output).toContain('all classified');
});

it('covers the test-layer typecheck gate and its writer (#5286)', () => {
// The specific pair that failed CI on this branch. Named here so a later
// change that drops either script also has to come back through this file.
expect(scripts['check:test-typecheck']).toBeDefined();
expect(scripts['gen:test-typecheck-debt']).toBeDefined();
expect(runReconcile().status).toBe(0);
});
});
84 changes: 78 additions & 6 deletions packages/spec/scripts/check-generated.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
* never saw, so a real semantic change lands silently inside a mechanical diff.
* What is worth automating is the *diagnosis* — which artifacts are stale, and
* the exact command for each. `--fix` then regenerates **only** the ones this run
* proved stale, and says so.
* proved stale, and says so — minus the `ratchet` entries, whose gate has already
* answered a question `--fix` would otherwise have to guess (see GATED below).
*
* Usage:
* pnpm --filter @objectstack/spec check:generated # report every stale artifact
Expand All @@ -41,8 +42,18 @@ const pkgRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
* The gates that verify a checked-in artifact against its source, with the
* generator that rewrites each. Order is the cheapest-first order a human would
* want the answers in, not CI's.
*
* `ratchet` marks the entries whose artifact is a DIRECTIONAL debt ledger rather
* than a descriptive snapshot of the source. For those, "stale" is ambiguous —
* see the `--fix` loop, which refuses to guess.
*/
const GATED: ReadonlyArray<{ check: string; gen: string; artifact: string; readsDist?: true }> = [
const GATED: ReadonlyArray<{
check: string;
gen: string;
artifact: string;
readsDist?: true;
ratchet?: true;
}> = [
{ check: 'check:spec-changes', gen: 'gen:spec-changes', artifact: 'spec-changes.json' },
{ check: 'check:upgrade-guide', gen: 'gen:upgrade-guide', artifact: 'docs/protocol-upgrade-guide.md' },
{ check: 'check:skill-docs', gen: 'gen:skill-docs', artifact: 'skill docs (from SKILL.md frontmatter)' },
Expand All @@ -69,6 +80,31 @@ const GATED: ReadonlyArray<{ check: string; gen: string; artifact: string; reads
gen: 'gen:strictness-ledger',
artifact: 'docs/audits/2026-07-unknown-key-strictness-ledger.counts.md',
},
// GATED by the definition above — it compares a checked-in artifact
// (test-typecheck-debt.json) against what `tsc -p tsconfig.test.json` measures
// right now, and `gen:test-typecheck-debt` is that artifact's writer. It is NOT
// a source audit: there is a real file to regenerate, so NO_GENERATOR would be
// a false classification, and UNGATED_GENERATORS ("nothing verifies this
// output") would be false in the other direction.
//
// What it is, that nothing above it is, is a DIRECTIONAL ratchet — hence
// `ratchet`. The other artifacts here are pure functions of the source, so
// regenerating is always the right answer. This one records DEBT, and its four
// verdicts split two ways: "the debt shrank" and "the file graduated" are
// re-record, while "the debt grew" and "an unledgered file has errors" are fix
// the code (#5286). `--fix` regenerates without reading which one it got, so
// for this entry it refuses instead — the merge that brought three new spec
// test files into this very branch is the live shape of the risk: had any of
// them carried errors, a reflexive `--fix` would have ledgered them silently.
//
// Cost: this is the only gate here that runs a full tsc program (~30s over
// src/**/*.test.ts), so it goes last in the cheapest-first order above.
{
check: 'check:test-typecheck',
gen: 'gen:test-typecheck-debt',
artifact: 'test-typecheck-debt.json',
ratchet: true,
},
];

/**
Expand Down Expand Up @@ -237,18 +273,54 @@ if (!stale.length) {
}

console.log(`\n✗ ${stale.length} of ${GATED.length} artifact(s) stale:\n`);
for (const s of stale) console.log(` ${s.artifact}\n pnpm --filter @objectstack/spec ${s.gen}`);
for (const s of stale) {
console.log(` ${s.artifact}\n pnpm --filter @objectstack/spec ${s.gen}` +
(s.ratchet ? ` ← only if ${s.check} asked you to RE-RECORD; --fix will not run this one` : ''));
}

const autoFixable = stale.filter((s) => !s.ratchet);

if (!fix) {
console.log(`\nRegenerate exactly these:\n ` +
stale.map((s) => `pnpm --filter @objectstack/spec ${s.gen}`).join(' && ') +
`\n\nOr re-run with --fix to do it now (only the ${stale.length} proved stale — never the whole set).`);
stale.map((s) => `pnpm --filter @objectstack/spec ${s.gen}`).join(' && '));
console.log(
autoFixable.length
? `\nOr re-run with --fix to do it now (only the ${autoFixable.length} proved stale — never the whole set` +
(autoFixable.length < stale.length
? `, and never the ${stale.length - autoFixable.length} ratchet(s) above: read their verdict first).`
: `).`)
: `\n--fix will not do this for you: every stale artifact above is a directional ratchet, ` +
`and its gate already said which direction it moved.`,
);
process.exit(1);
}

console.log(`\n--fix: regenerating the ${stale.length} stale artifact(s). Review the diff before committing.\n`);
console.log(
`\n--fix: regenerating ${autoFixable.length} of the ${stale.length} stale artifact(s)` +
(autoFixable.length < stale.length ? ` — the rest are ratchets, refused below` : '') +
`. Review the diff before committing.\n`,
);
let failed = 0;
for (const s of stale) {
// A ratchet's gate has already answered the question --fix would have to guess:
// it names, per file, whether the debt grew (fix the code) or shrank (re-record
// the number). Regenerating on the first reading launders new debt in as a
// mechanical diff — the same "admit it via the fix command" hazard that keeps
// dual-source-exports.baseline.json out of GATED entirely (#4446). That ledger
// can stay hand-edited because it holds a handful of rows; this one holds 79
// files, so it ships a generator and puts the refusal here instead.
if (s.ratchet) {
failed++;
console.log(` ✗ ${s.gen} — REFUSED`);
console.error(
` ${s.artifact} is a directional debt ledger, not a snapshot of the source.\n`
+ ` "the debt shrank — re-record it" and "the debt grew — fix the new errors" both\n`
+ ` reach --fix as one stale artifact, and only ${s.check} knows which it was.\n`
+ ` Read its per-file verdict; if re-recording is what it asked for, run:\n`
+ ` pnpm --filter @objectstack/spec ${s.gen}`,
);
continue;
}
// The `readsDist` warning above is advice a reader can ignore; here it must
// become a refusal. `gen:api-surface` on a stale dist does not fail — it
// writes a plausible surface with every export added since the last build
Expand Down
Loading
Loading