Files
zernie__vigiles/scripts/check-text-sources.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

75 lines
3.1 KiB
JavaScript

#!/usr/bin/env node
/**
* check-text-sources — a tracked SOURCE file must be text, i.e. must not contain
* a NUL byte.
*
* WHY THIS IS A GATE AND NOT A STYLE NOTE. Git decides "binary" per blob by
* looking for a NUL in the first 8000 bytes. A blob it calls binary gets no line
* diff and — the part that costs real time — NO THREE-WAY MERGE: every concurrent
* branch that touches it conflicts, and the conflict cannot be resolved by
* picking hunks, because there are no hunks.
*
* Measured on 2026-08-18: `src/scan-files.test.ts` carried two RAW NUL bytes
* inside a template literal — a separator someone typed instead of escaping. Two
* branches were declared "conflicting with each other" and parked for a day.
* After replacing those two bytes with the escape, BOTH merged into main with
* ZERO conflicts. The conflict was entirely an artifact of the byte.
*
* The rest of this codebase already writes the escape (skill-contract.ts,
* scan-core.ts, coverage-artifact.ts, coverage-probe.ts — eight sites), so this
* gate does not impose a convention; it stops one file from leaving it.
*
* NOT a `.gitattributes text` marker: forcing the attribute would restore diff
* and merge while leaving the byte in the source, which hides the defect instead
* of removing it. The byte is the thing that is wrong.
*/
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
const EXT = /\.(?:ts|tsx|mts|cts|js|jsx|mjs|cjs|json|md|yml|yaml|sh|css|html)$/;
const tracked = execFileSync("git", ["ls-files", "-z"], { encoding: "utf8" })
// The separator is a NUL, written as an ESCAPE: this file is subject to the
// rule it enforces, and a literal one here would make it binary.
.split("\u0000")
.filter((p) => p !== "" && EXT.test(p));
// A scan that returns nothing is either "nothing to check" or "I looked in the
// wrong place", and only the code can tell them apart. Here it is always the
// latter: this repository always tracks source files.
if (tracked.length === 0) {
console.error(
"check-text-sources: scanned 0 files — refusing to pass vacuously.",
);
process.exit(1);
}
const bad = [];
for (const p of tracked) {
// `git ls-files` reads the INDEX, so a file deleted in the worktree but not yet
// staged is still listed. Without this, the gate dies with an ENOENT stack
// trace mid-refactor and reads as "the checker is broken" rather than "you have
// an unstaged deletion" — observed 2026-08-21, deleting an api-surface report.
if (!existsSync(p)) continue;
const buf = readFileSync(p);
const at = buf.indexOf(0);
if (at !== -1) {
let count = 0;
for (const b of buf) if (b === 0) count++;
bad.push({ p, at, count });
}
}
if (bad.length > 0) {
for (const b of bad) {
console.error(
`✗ ${b.p}: ${b.count} raw NUL byte(s), first at offset ${b.at}.\n` +
` Git treats this file as BINARY: no diff, and no three-way merge.\n` +
` Write the escape instead — \\u0000 in a string is the same value.`,
);
}
process.exit(1);
}
console.log(`check-text-sources: ${tracked.length} source files, none binary`);