Files
zernie__vigiles/scripts/check.mjs
T
zernie 900dc7e06a refactor(spec)!: name things for what they are; retire the vigiles/experimental subpath (#169)
Three exported symbols were named for something other than what they are, and
two mechanisms were doing one job.

RENAMES (old names kept one major, @deprecated, removed one major AFTER this one):
  claude()          → instructionFile()     compiles CLAUDE.md AND AGENTS.md, so a
                                            name from one harness was never right
  instructions``    → prose``               builds a prose FRAGMENT; the plural read
                                            as "the instruction file", which is what
                                            instructionFile() builds
  agent()           → experimental_agent()  the shape is not settled, and the tag was
                                            never there (git log -S empty)
  SkillSpec.result: → postcondition:        `result` already meant the subagent output
                                            contract one screen down; the doc comment
                                            had to spend a line disambiguating them

The alias window is not politeness. Measured: the previous rename shipped with
ZERO overlap (18.1.1 old names only, 19.0.0 new only). A container rollback put
the old node_modules under new sources, PreToolUse stopped loading, and a
PreToolUse that does not load denies EVERY Bash command — including the one that
fixes it. One window per major makes that pair unrepresentable.

SUBPATH RETIRED. vigiles/experimental is gone; its members moved to the door
their feature already uses. Of two mechanisms for one promise the subpath was the
weaker: it marks the import line, out of view by the time anyone reads the call.
Measured on the only user-facing example — with the prefix aliased away at
import, the marker survived at 0 of 5 call sites. So the prefix stays, the advice
to alias it away became an explicit prohibition, and the six define* entry points
are DECLARED prefixed rather than gaining it at re-export.

That de-aliasing is what unblocked moving the naming check into ESLint, and the
ORDER was load-bearing: measured before it, dropping the public/internal
exemption produced 14 findings, all from the re-export indirection. Measured
after, across all of src/ with no exemption: 22 tagged declarations, 0
unprefixed. The rule turns on silent.

EXPORT CLEANUP. vigiles/linting export * → curated, 103 → 79 symbols; its header
claimed to be curated with an `export *` one line below, and claimed the builders
were also at the package root (191 exports there, zero matches). ClaudeTool and
HookEvent deleted — zero references, and both had drifted off the dialect they
claimed to mirror; HookEvent listed 5 events, two of which do not exist, against
the 31 the vendor documents.

DOCS. experimental.md deleted and distributed per feature: the skill half into
skills.md §Status, the hook half into a new compiled-hooks.md §Status, and the
emit half — 111 of its 177 lines, a topic doc wearing a stability page's clothes
— into docs/emit-channel.md, which gains the "which channel when" comparison it
never had. The one cross-cutting sentence went to the README.

TWO GATES WERE GREEN AND WRONG, both found by this work:
  - api:check reported "verified for 11 entries — no drift" while reading
    dist/experimental.d.ts from a PREVIOUS build (tsc does not clean dist/) for a
    source file that no longer existed. Now asserted against package.json
    exports, with a mutation proving exit 1 on a phantom entry.
  - check-export-prefixes caught MY error: the R3 services first landed on
    vigiles/eval, where `paid_` means "this call can bill you" — and
    startServices takes a ContainerRuntime and calls no model. Naming it
    paid_experimental_startServices would have redefined paid_ as "expensive in
    some sense". They went to the package root.

SEVENTEEN review findings landed on this branch, all real. Two mechanical
closures came out of them: src/example-imports.test.ts resolves every relative
import under examples/ instead of matching a string, and the naming rule now
ENUMERATES the three export paths it visits rather than describing its coverage
in the abstract. One attempt is recorded as REFUTED: teaching the doc gate to
flag our own exports as free variables measured 66 findings across 93 blocks, so
that gate's original scoping was right.

BREAKING CHANGE: vigiles/experimental is removed. Its exports are unchanged in
name and behaviour and are now reached from `vigiles` (the emit channel, the R3
disposable-service tier). Every renamed symbol keeps a working @deprecated alias
for this major.
2026-08-21 07:54:51 +05:00

169 lines
6.1 KiB
JavaScript

#!/usr/bin/env node
/**
* check.mjs — every command CI's `check` job runs, runnable in one line locally.
*
* WHY THIS EXISTS, and it is not "convenience". On 2026-08-16 three PRs went to
* `main` red. The author had run a five-command list from memory; the `check` job
* runs thirteen. The one that failed — `tsc -p test/types/tsconfig.json` — sits in
* a SEPARATE tsconfig excluded from the root one, so the root type-check could not
* see it, and three `@ts-expect-error` assertions had silently stopped failing.
*
* The fix is not a longer list to remember. It is that CI now INVOKES this file
* (`run: npm run check`) instead of restating the commands, so there is one list
* and it cannot drift from itself. That is the pre-commit / just / mise pattern:
* the task definition is the source of truth and CI calls it.
*
* WHAT STAYS IN ci.yml, deliberately: the two `uses: ./` steps that dogfood this
* repo's own composite Action (`command: lint`, `command: eval-check`). They
* exercise the ACTION WRAPPER, which has no shell equivalent — running
* `vigiles lint` here would test the CLI, which the `cli-lint` entry below
* already does, and would prove nothing about the wrapper. They are the only
* things in that job this file does not cover, and that is a property of what
* they test, not an omission.
*
* THE STAGES ARE LOAD-BEARING, not decoration. Some of these commands WRITE the
* files the others READ — `generate types` rewrites `.vigiles/generated.d.ts`,
* `api-extractor` rewrites `api-surface/*.api.md`, `build` rewrites `dist/`.
* Running a writer beside a reader is a race whose outcome depends on timing, so
* writers are serialized and only the read-only group runs in parallel.
*
* On failure it names the command that failed. That is the one thing the old
* per-step CI names bought, and it is cheap to keep.
*
* node scripts/check.mjs # everything
* node scripts/check.mjs --list # print the commands, run nothing
*/
import { spawn } from "node:child_process";
import { cpus } from "node:os";
/** Stage 1 — everything downstream reads `dist/`. */
const BUILD = { name: "build", cmd: "npm run build" };
/** Stage 2 — read-only. Safe to run together; none of these writes a tracked file. */
const PARALLEL = [
{ name: "types:root", cmd: "npx tsc --noEmit" },
{ name: "types:test", cmd: "npx tsc --noEmit -p test/types/tsconfig.json" },
{ name: "types:site", cmd: "npx tsc --noEmit -p site/tsconfig.json" },
{ name: "jest", cmd: "npx jest" },
{ name: "cli-lint", cmd: "node dist/cli.js lint" },
{ name: "lint", cmd: "npm run lint" },
{ name: "format", cmd: "npm run fmt:check" },
{
name: "docs-imports",
cmd: "node scripts/check-doc-imports.mjs . docs README.md",
},
{ name: "export-prefixes", cmd: "node scripts/check-export-prefixes.mjs ." },
// The `@experimental` NAMING half of this used to live here too. It is now the
// ESLint rule `local/experimental-name`, so it runs inside the `lint` stage
// above rather than as its own — see the rule's header for why the move was
// possible only after the re-export aliasing went away.
{ name: "internal-tag", cmd: "node scripts/check-internal-tag.mjs" },
{ name: "typedoc", cmd: "npx typedoc" },
{
name: "corpus-guards",
cmd: "node bench/corpus/verify.mjs && node bench/corpus/verify-headroom.mjs",
},
{ name: "text-sources", cmd: "node scripts/check-text-sources.mjs" },
{
name: "rule-enforcer",
cmd: "npm ci --prefix rule-enforcer && node rule-enforcer/gate.js",
},
];
/** Stage 3 — these WRITE. Serialized after the readers have finished. */
const WRITERS = [
{ name: "api-surface", cmd: "node scripts/api-extractor.mjs" },
{
name: "generated-types",
cmd: "node dist/cli.js generate types && npx tsc --noEmit",
},
{
name: "generated-types:committed",
cmd: "git diff --exit-code .vigiles/generated.d.ts",
},
];
const ALL = [BUILD, ...PARALLEL, ...WRITERS];
if (process.argv.includes("--list")) {
for (const s of ALL) console.log(`${s.name.padEnd(26)} ${s.cmd}`);
process.exit(0);
}
function run({ name, cmd }) {
return new Promise((resolve) => {
const started = Date.now();
const p = spawn(cmd, { shell: true, stdio: ["ignore", "pipe", "pipe"] });
let out = "";
p.stdout.on("data", (d) => (out += d));
p.stderr.on("data", (d) => (out += d));
p.on("close", (code) =>
resolve({ name, cmd, code, out, ms: Date.now() - started }),
);
});
}
/** Bounded concurrency — 13 `tsc`/`jest` processes at once thrashes a laptop. */
async function pool(items, limit) {
const results = [];
let next = 0;
const workers = Array.from(
{ length: Math.min(limit, items.length) },
async () => {
for (;;) {
const i = next++;
if (i >= items.length) return;
const r = await run(items[i]);
results[i] = r;
console.log(
`${r.code === 0 ? "✓" : "✗"} ${r.name} (${(r.ms / 1000).toFixed(1)}s)`,
);
}
},
);
await Promise.all(workers);
return results;
}
const failures = [];
const t0 = Date.now();
const build = await run(BUILD);
console.log(
`${build.code === 0 ? "✓" : "✗"} ${build.name} (${(build.ms / 1000).toFixed(1)}s)`,
);
if (build.code !== 0) {
// Nothing downstream can be trusted without dist/, so stop rather than emit
// twelve derived failures that all say the same thing.
console.error(`\n${build.out}\n✗ build failed — the rest cannot run.`);
process.exit(1);
}
failures.push(
...(await pool(PARALLEL, Math.max(2, Math.min(6, cpus().length - 1)))).filter(
(r) => r.code !== 0,
),
);
for (const w of WRITERS) {
const r = await run(w);
console.log(
`${r.code === 0 ? "✓" : "✗"} ${r.name} (${(r.ms / 1000).toFixed(1)}s)`,
);
if (r.code !== 0) failures.push(r);
}
console.log(
`\n${ALL.length - failures.length}/${ALL.length} passed in ${((Date.now() - t0) / 1000).toFixed(0)}s`,
);
if (failures.length) {
for (const f of failures) {
console.error(
`\n──────── ✗ ${f.name} ────────\n$ ${f.cmd}\n${f.out.trimEnd()}`,
);
}
console.error(`\nFAILED: ${failures.map((f) => f.name).join(", ")}`);
process.exit(1);
}