Files
zernie__vigiles/scripts/check-export-prefixes.mjs
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

165 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
/**
* check-export-prefixes.mjs — a subpath that carries a WARNING in its name must
* carry it on every symbol behind it too.
*
* ONE quarantine subpath in this package pairs a path with a name prefix:
*
* ./eval → every runtime export starts with `paid_`
*
* There were two. `./experimental` was deleted 2026-08-21 on the argument this
* header already made — that of the pair, only the NAME is present where the
* reader is — so the `experimental_` prefix now stands alone, enforced per
* declaration by the ESLint rule `local/experimental-name` rather than per
* subpath by this script. Nothing was relaxed: the prefix is checked in MORE
* places than before, since it no longer depends on which door a symbol left by.
*
* 🔴 That deletion is also how this check earned its own bug report. Moving R3
* onto `./eval` looked reasonable and this gate rejected it: `paid_` means "this
* call can bill you", and `experimental_startServices` takes a
* `ContainerRuntime` and starts containers — it calls no model. The tempting fix
* was `paid_experimental_startServices`, which would have quietly redefined
* `paid_` as "expensive in some sense" and turned the prefix back into
* decoration. R3 went to the package root instead. A quarantine prefix is only
* worth having while it is narrow enough to be false somewhere.
*
* The reason the prefix exists at all is that the import path warns ONCE, at the
* top of a file, and the name warns EVERY time, at the call site. Reading
* `await judged(trace, "did it refuse?")` on line 140, the import line is long
* out of view; `await paid_judged(...)` still says what it costs.
*
* A convention held only by prose decays the moment someone adds one export in a
* hurry — and it HAD decayed: `makeDockerRuntime` sat unprefixed among six
* `experimental_` siblings on the old `./experimental` until this check was
* written. One missing prefix is worse than none at all, because the six that
* are prefixed teach the reader that an unprefixed name means "safe".
*
* TYPES ARE EXEMPT, BY DESIGN, NOT BY OVERSIGHT. The prefix warns about calling
* something. A type is never called and can never bill or misbehave, so
* `paid_EvalReport` would be noise on a symbol that cannot do the thing being
* warned about — and the report types are deliberately re-exported from BOTH
* `vigiles` and `vigiles/eval`, so prefixing them would make the free barrel
* carry `paid_` names. This script reads the built `.d.ts` with the TypeScript
* compiler rather than grepping the source precisely so that it can tell a value
* from a type instead of guessing.
*
* Exit code 1 on any violation — this FAILS, it does not nudge. A quarantine
* convention that only prints a suggestion is the prose it was meant to replace.
*
* node scripts/check-export-prefixes.mjs [repoRoot]
*/
import { readFileSync } from "node:fs";
import { join, resolve } from "node:path";
import { createRequire } from "node:module";
/** The contract: one built type-entry, one required prefix on every VALUE export. */
export const SURFACES = [
{ subpath: "./eval", dts: "dist/eval-surface.d.ts", prefix: "paid_" },
];
/**
* Report every value export of `surfaces` whose name lacks the required prefix.
* Exported (rather than inlined into the CLI tail) so the test can drive it over
* a fixture package instead of only over this repo — a check that can only be
* run on a clean tree cannot be shown to FIRE, and a check nobody has seen fire
* is indistinguishable from a dead one.
*/
export function findPrefixViolations(root, surfaces = SURFACES) {
const req = createRequire(join(root, "package.json"));
const ts = req("typescript");
const pkg = JSON.parse(readFileSync(join(root, "package.json"), "utf8"));
const out = [];
for (const s of surfaces) {
// The exports map is the authority on which subpaths are public: a surface
// listed here but absent from package.json is a stale contract, not a pass.
const mapped = pkg.exports?.[s.subpath];
if (mapped === undefined) {
out.push({
subpath: s.subpath,
symbol: "(the subpath itself)",
why: `not present in package.json "exports" — this check is stale`,
});
continue;
}
const entry = join(root, s.dts);
const program = ts.createProgram([entry], {
target: ts.ScriptTarget.ES2022,
module: ts.ModuleKind.Node16,
moduleResolution: ts.ModuleResolutionKind.Node16,
skipLibCheck: true,
noEmit: true,
});
const sf = program.getSourceFile(entry);
if (!sf) {
out.push({
subpath: s.subpath,
symbol: "(the barrel itself)",
why: `${s.dts} not found — run \`npm run build\` first`,
});
continue;
}
const checker = program.getTypeChecker();
const moduleSymbol = checker.getSymbolAtLocation(sf);
const exports = moduleSymbol
? checker.getExportsOfModule(moduleSymbol)
: [];
for (const sym of exports) {
const name = sym.getName();
if (!isValueExport(ts, checker, sym)) continue; // types are exempt — see header
if (name.startsWith(s.prefix)) continue;
out.push({
subpath: s.subpath,
symbol: name,
why: `runtime export on "${s.subpath}" must be named \`${s.prefix}${name}\``,
});
}
}
return out;
}
/**
* Is this export something a caller can CALL or read at runtime (as opposed to a
* pure type)? An aliased re-export (`export { x as paid_x } from …`) reports the
* alias flag, so resolve it before asking — otherwise every renamed export, i.e.
* every export on `./eval`, would be misread as a type and the check would pass
* vacuously on the exact surface it exists to police.
*/
function isValueExport(ts, checker, sym) {
let s = sym;
if (s.getFlags() & ts.SymbolFlags.Alias) {
try {
s = checker.getAliasedSymbol(s);
} catch {
/* unresolvable alias: fall through and judge by the local flags */
}
}
return Boolean(s.getFlags() & ts.SymbolFlags.Value);
}
// --- CLI ---------------------------------------------------------------------
// `import.meta.main` is not available on the Node this repo targets, so compare
// argv[1] instead; the test imports this module and must not trip the exit.
const invokedDirectly =
process.argv[1] && resolve(process.argv[1]) === resolve(import.meta.filename);
if (invokedDirectly) {
const root = resolve(process.argv[2] ?? ".");
const violations = findPrefixViolations(root);
for (const v of violations) {
console.error(`✗ ${v.subpath}: ${v.symbol} — ${v.why}`);
}
const surfaces = SURFACES.map((s) => `${s.subpath} (${s.prefix}*)`).join(
", ",
);
if (violations.length > 0) {
console.error(
`\n${String(violations.length)} export(s) break the quarantine naming contract.`,
);
process.exit(1);
}
console.log(`export prefixes verified: ${surfaces} — no violations.`);
}