mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
94af23a5b7
Closes #173, #174, #175, #176 and #170. #174 — decideHook read exit 2, `decision` and `permissionDecision` but not the harness's halt-the-turn field, so `assertBlocksDisasters` reported a guard that stopped every disaster as blocking none. The field is read from the PORT, not hard-coded: `continue` is documented for Claude Code and unverified for Codex. The issue's adjacent suggestion was deliberately NOT implemented — adding the halt to `hook-block-ineffective` as a block-attempt signal would fire on hooks that WORK, since a halt is not event-scoped. It reads as a suppressor instead, fixing a real false alarm. A mutation implementing the original suggestion literally is pinned by a test that fails on it. #173 — compile printed its errors, exited 1, and wrote a hash-valid CLAUDE.md anyway, so `lint` went green over refs it had just called dead. Nothing is written on a failed compile now. #176 — eight small defects, including `audit --out` outside the repo appending unreachable `../../..` entries to .gitignore (a documented read-only report editing a tracked file), an unknown command exiting 1 against a documented 2, and STABILITY.md promising the removed `scan` verb. #175 + #176.2 — three missing capabilities, each opt-in and additive: `{surface}` in testGlobs, `--single`, and `rulesDir` so `.claude/rules/**` stops being invisible. #170 — one enumeration of export forms written against the grammar, both naming checks driven off it, and `check-internal-tag.mjs` asking the TypeScript compiler instead of a regex. It immediately found the issue's open case, 13 tagged types the regex had never seen, and a live gap in the ESLint rule. BREAKING CHANGE: `HookRunResult` gains a required `haltsTurn`, so code that CONSTRUCTS one (a test fake) must set it. Reading a result is unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
199 lines
8.9 KiB
JavaScript
199 lines
8.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-internal-tag.mjs — `@internal` on a symbol we EXPORT is a contradiction.
|
|
*
|
|
* One sentence: if a declaration carries the `@internal` TSDoc tag and appears in
|
|
* a committed api-extractor report, say so — the tag claims it is not part of the
|
|
* API while the exports map ships it.
|
|
*
|
|
* WHY IT IS A SCRIPT AND NOT A LINT RULE, unlike its former other half. This
|
|
* question is **cross-file by nature**: whether a symbol is public is decided by
|
|
* `src/*.ts` barrels and the `exports` map in `package.json`, not by the file the
|
|
* declaration lives in. ESLint sees one file at a time, so a rule could only
|
|
* guess. The api reports are the answer already computed and committed, and
|
|
* `npm run api:check` already keeps them honest.
|
|
*
|
|
* 🔴 THIS FILE USED TO DO TWO JOBS (it was `check-experimental-naming.mjs`). The
|
|
* other one — an `@experimental` declaration must be NAMED `experimental_*` — is
|
|
* now `local/experimental-name` in `eslint-rules/`. It moved because it turned
|
|
* out not to need any of the machinery here: it needed the api reports only to
|
|
* EXEMPT internal symbols, and that exemption contradicted the rule's own
|
|
* rationale ("the reader who trusts the name never opens the doc" — an internal
|
|
* reader is a reader). Dropping the exemption left jsdoc + name, both in one
|
|
* file, and ESLint parses both properly instead of by regex.
|
|
*
|
|
* That split is worth stating plainly because the two checks LOOK like one
|
|
* check — both read TSDoc tags, both are about the stability vocabulary — and
|
|
* they are not. One is about a NAME, decidable where the name is written. The
|
|
* other is about an EXPORT GRAPH, decidable only from outside the file. Keeping
|
|
* them together meant the local half inherited the global half's dependencies.
|
|
*
|
|
* WHAT IT SUBTRACTS: the ability to call something "internal" in TSDoc while
|
|
* shipping it from a public subpath.
|
|
*
|
|
* Usage: node scripts/check-internal-tag.mjs [root]
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
import { join, relative, resolve } from "node:path";
|
|
|
|
import { taggedDeclarations } from "./lib/tagged-declarations.mjs";
|
|
|
|
const ROOT = resolve(process.argv[2] ?? ".");
|
|
const PREFIX = "experimental_";
|
|
function walk(dir, out = []) {
|
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
if (["node_modules", ".git", "dist"].includes(e.name)) continue;
|
|
const p = join(dir, e.name);
|
|
if (e.isDirectory()) walk(p, out);
|
|
else if (e.name.endsWith(".ts") && !e.name.endsWith(".test.ts"))
|
|
out.push(p);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Every symbol name that appears as an export in a committed api report. */
|
|
function publicSymbols() {
|
|
const dir = join(ROOT, "api-surface");
|
|
const names = new Map(); // name -> [report basenames]
|
|
let reports = [];
|
|
try {
|
|
reports = readdirSync(dir).filter((f) => f.endsWith(".api.md"));
|
|
} catch {
|
|
return { names, reports };
|
|
}
|
|
const DECL =
|
|
/^export\s+(?:default\s+)?(?:declare\s+)?(?:abstract\s+)?(?:function|const|let|var|class|interface|type|enum)\s+([A-Za-z0-9_$]+)/;
|
|
for (const f of reports) {
|
|
for (const line of readFileSync(join(dir, f), "utf8").split("\n")) {
|
|
const m = DECL.exec(line.trim());
|
|
if (m) {
|
|
const list = names.get(m[1]) ?? [];
|
|
list.push(f.replace(/^vigiles-|\.api\.md$/g, ""));
|
|
names.set(m[1], list);
|
|
}
|
|
}
|
|
}
|
|
return { names, reports };
|
|
}
|
|
|
|
/**
|
|
* Declarations tagged `@experimental`. Two deliberate exclusions:
|
|
*
|
|
* - a tag on a `@module` block documents the FILE, not one symbol — otherwise
|
|
* every export of `services.ts` is reported for a tag never about it;
|
|
* - `type` / `interface` are OUT OF SCOPE. The prefix is a signal about a
|
|
* runtime API you CALL, and the convention it codifies only ever applied to
|
|
* callables — measured on the surface that existed before this gate:
|
|
* `vigiles/experimental` exported `experimental_emitTool`,
|
|
* `experimental_parseEmitted`, `experimental_assertEmittedOk`,
|
|
* `experimental_startServices`, `experimental_withServices` (all functions)
|
|
* alongside plain `ServiceSpec`, `ServiceHandle`, `ContainerRuntime`. Pulling
|
|
* types in would have opened with 6 cosmetic renames against 1 real finding,
|
|
* and a gate that arrives 86% noise is muted within the day.
|
|
*/
|
|
|
|
/**
|
|
* Every tagged declaration in one file, ASKED OF THE PARSER.
|
|
*
|
|
* 🔴 THIS USED TO BE REGEXES, and that is the finding, not a detail. A doc-block
|
|
* scanner paired with a `VALUE_DECL` shape-matcher had been extended four times,
|
|
* each after a reviewer named a form it did not know, and it still missed
|
|
* `const x = …; export { x }` — the case #170 carried open. Every extension
|
|
* looked complete and none was, because a shape-matcher recognizes only shapes
|
|
* somebody remembered to write down. `scripts/lib/tagged-declarations.mjs` asks
|
|
* the TypeScript compiler instead, so the whole class stops being expressible;
|
|
* the forms it is expected to handle are enumerated in
|
|
* `scripts/lib/export-forms.mjs` and asserted in the test beside this file.
|
|
*
|
|
* The `@internal` over `@experimental` precedence is unchanged and still
|
|
* load-bearing: the reporting loop below acts only on `@internal`, so recording
|
|
* the weaker tag for a both-tagged symbol swallowed it silently while the run
|
|
* still printed `checked: 1` — a counter that counts a symbol it then ignores is
|
|
* worse than one that misses it, because the number reads as coverage.
|
|
* On the merits too: `@experimental` says "this may change", `@internal` says
|
|
* "this is not API at all", and the stronger claim is the contradiction worth
|
|
* reporting.
|
|
*/
|
|
function taggedExperimental(file) {
|
|
const src = readFileSync(file, "utf8");
|
|
return taggedDeclarations(src, ["internal", "experimental"]).map((d) => ({
|
|
name: d.name,
|
|
line: d.line,
|
|
kind: d.kind,
|
|
tag: d.tags.includes("internal") ? "@internal" : "@experimental",
|
|
}));
|
|
}
|
|
|
|
const { names: pub, reports } = publicSymbols();
|
|
if (reports.length === 0) {
|
|
console.error(
|
|
"check-internal-tag: no api-surface/*.api.md reports found — " +
|
|
"run `npm run api:check` first. Refusing to pass vacuously.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
|
|
let checked = 0;
|
|
let findings = 0;
|
|
let skippedTypes = 0;
|
|
for (const file of walk(join(ROOT, "src"))) {
|
|
for (const d of taggedExperimental(file)) {
|
|
const doors = pub.get(d.name);
|
|
// Not exported from any subpath: the tag is a note to maintainers and
|
|
// promises nothing to anyone. Both tags are fine there, unchecked.
|
|
if (!doors) continue;
|
|
|
|
// 🔴 TYPES ARE OUT OF SCOPE, and this is a DECISION, not an oversight —
|
|
// it has to be stated because until the parser landed it was neither.
|
|
// The old regex matched `function|const|let|var|class` and therefore could
|
|
// not see a type at all; the moment it could, seven exported-and-@internal
|
|
// types appeared at once, every one of them deliberate. They are typed-
|
|
// composition machinery (`Supplies`, `Pipeline`, `Handoff`, …) exported
|
|
// ONLY so a user's inference resolves — nobody writes those names, and
|
|
// `@internal` is the truthful way to say so. Failing the build over them
|
|
// would force either a meaningless rename or dropping a true tag.
|
|
//
|
|
// It also matches the sibling rule: `local/experimental-name` excludes
|
|
// types for the same reason — the convention is about CALL SITES, and a
|
|
// type annotation is not one.
|
|
//
|
|
// Counted and REPORTED rather than dropped: this file's own history is a
|
|
// counter that counted a symbol it then ignored, and the number read as
|
|
// coverage. A silent skip here would repeat it exactly.
|
|
if (d.kind === "type") {
|
|
skippedTypes++;
|
|
continue;
|
|
}
|
|
checked++;
|
|
|
|
// `vigiles.api.md` is the root subpath: label it `vigiles`, not `vigiles/vigiles`.
|
|
const where = [...new Set(doors)]
|
|
.map((d) => (d === "vigiles" ? "vigiles" : `vigiles/${d}`))
|
|
.join(", ");
|
|
// `@internal` on a symbol that IS exported is a contradiction: the tag says
|
|
// "not part of the API" while the exports map ships it. Say which half to
|
|
// change rather than guessing — retracting the export and renaming are both
|
|
// legitimate, and only the author knows which was meant.
|
|
if (d.tag === "@internal") {
|
|
findings++;
|
|
console.log(
|
|
`${relative(ROOT, file)}:${d.line} \`${d.name}\` is tagged @internal but IS exported ` +
|
|
`(${where}). @internal answers "is it public", not "is it stable" — an exported symbol ` +
|
|
`is public whatever the tag says. Either stop exporting it, or mark it @experimental ` +
|
|
`and rename it \`${d.name.startsWith(PREFIX) ? d.name : PREFIX + d.name}\`.`,
|
|
);
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`\npublic @experimental/@internal VALUE declarations checked: ${checked} (across ${reports.length} api reports)`,
|
|
);
|
|
if (skippedTypes > 0)
|
|
console.log(
|
|
`types skipped (out of scope, see the comment above): ${skippedTypes}`,
|
|
);
|
|
console.log(`findings: ${findings}`);
|
|
process.exit(findings ? 1 : 0);
|