mirror of
https://github.com/zernie/vigiles.git
synced 2026-09-14 20:53:57 +08:00
900dc7e06a
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.
148 lines
6.2 KiB
JavaScript
148 lines
6.2 KiB
JavaScript
/**
|
|
* API Extractor driver — the public-API surface gate.
|
|
*
|
|
* Runs Microsoft API Extractor over EVERY public entry point in package.json
|
|
* `exports` and writes a committed surface report per entry under `api-surface/`. Two
|
|
* modes:
|
|
*
|
|
* node scripts/api-extractor.mjs --local # regenerate the reports (after an
|
|
* # intentional API change)
|
|
* node scripts/api-extractor.mjs # CI gate: FAIL if the live surface
|
|
* # drifts from the committed report
|
|
*
|
|
* The committed `api-surface/*.api.md` files are the contract — a PR that adds/removes/
|
|
* changes a public export shows up as a report diff, so the surface can't
|
|
* silently regrow (the reason this exists; see research/roadmap.md). The
|
|
* `temp/*.api.json` doc models feed `npm run docs:api` (API Documenter →
|
|
* api-reference/, a gitignored derived artifact). Pure Node, no per-entry configs.
|
|
*/
|
|
import { Extractor, ExtractorConfig } from "@microsoft/api-extractor";
|
|
import path from "node:path";
|
|
import { readFileSync } from "node:fs";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
// One report per public entry point, keyed by the report name; `dts` is the
|
|
// built type-entry the export maps to. Every subpath now resolves to its own
|
|
// module — the old aliasing (`.` + `./spec` on one file, `./e2e` on
|
|
// `./integration`) is gone, so the map here is 1:1 with package.json `exports`.
|
|
const ENTRIES = [
|
|
{ name: "vigiles", dts: "dist/test.d.ts" }, // "." — the free testing surface
|
|
{ name: "vigiles-spec", dts: "dist/core/spec.d.ts" }, // "./spec"
|
|
{ name: "vigiles-eval", dts: "dist/eval-surface.d.ts" }, // "./eval" — spends money
|
|
{ name: "vigiles-linting", dts: "dist/linting.d.ts" },
|
|
{ name: "vigiles-hook", dts: "dist/hook.d.ts" },
|
|
{ name: "vigiles-claude-code", dts: "dist/claude-code.d.ts" },
|
|
{ name: "vigiles-codex", dts: "dist/codex.d.ts" },
|
|
{ name: "vigiles-adapter", dts: "dist/adapter.d.ts" },
|
|
{ name: "vigiles-vitest", dts: "dist/vitest.d.mts" },
|
|
{ name: "vigiles-jest", dts: "dist/jest.d.ts" },
|
|
];
|
|
|
|
// 🔴 THIS LIST IS HAND-MAINTAINED, so it can drift from `package.json` exports —
|
|
// and a drift in one direction is INVISIBLE without this assertion. Measured
|
|
// 2026-08-21: the `./experimental` subpath was deleted and its entry left here,
|
|
// and the gate reported "API surface verified for 11 entries — no drift",
|
|
// because `tsc` does not clean `dist/` and the previous build's
|
|
// `dist/experimental.d.ts` was still on disk. A green gate reading a file no
|
|
// build produces any more is the exact failure shape this repo keeps finding in
|
|
// other people's harnesses.
|
|
{
|
|
const pkg = JSON.parse(readFileSync(path.join(root, "package.json"), "utf8"));
|
|
const subpaths = Object.keys(pkg.exports).sort();
|
|
const covered = ENTRIES.map((e) =>
|
|
e.name === "vigiles" ? "." : "./" + e.name.replace(/^vigiles-/, ""),
|
|
).sort();
|
|
const missing = subpaths.filter((s) => !covered.includes(s));
|
|
const extra = covered.filter((c) => !subpaths.includes(c));
|
|
if (missing.length || extra.length) {
|
|
console.error(
|
|
"api-extractor: ENTRIES is out of step with package.json exports.\n" +
|
|
(missing.length
|
|
? ` exported but UNREPORTED: ${missing.join(", ")}\n`
|
|
: "") +
|
|
(extra.length
|
|
? ` reported but NOT exported: ${extra.join(", ")}\n`
|
|
: "") +
|
|
" Fix ENTRIES in this file, and delete any orphaned api-surface/*.api.md.",
|
|
);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const localBuild = process.argv.includes("--local");
|
|
|
|
/** Build an in-memory API Extractor config for one entry (no per-entry JSON). */
|
|
function configFor(entry) {
|
|
return ExtractorConfig.prepare({
|
|
configObject: {
|
|
projectFolder: root,
|
|
mainEntryPointFilePath: path.join(root, entry.dts),
|
|
compiler: { tsconfigFilePath: path.join(root, "tsconfig.json") },
|
|
apiReport: {
|
|
enabled: true,
|
|
reportFolder: path.join(root, "api-surface"),
|
|
reportFileName: `${entry.name}.api.md`,
|
|
reportTempFolder: path.join(root, "temp"),
|
|
},
|
|
docModel: {
|
|
enabled: true,
|
|
apiJsonFilePath: path.join(root, "temp", `${entry.name}.api.json`),
|
|
},
|
|
dtsRollup: { enabled: false },
|
|
tsdocMetadata: { enabled: false },
|
|
// The repo's TSDoc comments predate API Extractor; their `>`/backtick/brace
|
|
// quirks are non-fatal noise here. Silence parser + release-tag chatter so
|
|
// the gate reports only real SURFACE changes.
|
|
messages: {
|
|
compilerMessageReporting: { default: { logLevel: "warning" } },
|
|
extractorMessageReporting: {
|
|
default: { logLevel: "none" },
|
|
"ae-missing-release-tag": { logLevel: "none" },
|
|
},
|
|
tsdocMessageReporting: { default: { logLevel: "none" } },
|
|
},
|
|
},
|
|
// A virtual config path — the file need not exist; only its directory is used
|
|
// to resolve the (already-absolute) tokens above.
|
|
configObjectFullPath: path.join(
|
|
root,
|
|
"config",
|
|
`api-extractor.${entry.name}.json`,
|
|
),
|
|
packageJsonFullPath: path.join(root, "package.json"),
|
|
// Give each entry a DISTINCT package identity (every export otherwise reports
|
|
// as "vigiles", which collides when API Documenter merges the doc models into
|
|
// one reference site). The name is cosmetic — it only labels the report/page.
|
|
packageJson: { name: entry.name, version: "1.0.0" },
|
|
});
|
|
}
|
|
|
|
let failed = 0;
|
|
for (const entry of ENTRIES) {
|
|
const result = Extractor.invoke(configFor(entry), {
|
|
localBuild,
|
|
showVerboseMessages: false,
|
|
});
|
|
if (!result.succeeded) {
|
|
failed += 1;
|
|
if (!localBuild && result.apiReportChanged) {
|
|
console.error(
|
|
`✗ ${entry.name}: public API surface changed vs api-surface/${entry.name}.api.md — ` +
|
|
`run \`npm run api:report\`, review the diff, and commit it if intended.`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (failed > 0) {
|
|
console.error(`\nAPI Extractor: ${String(failed)} entr(y/ies) failed.`);
|
|
process.exit(1);
|
|
}
|
|
console.log(
|
|
localBuild
|
|
? `API surface reports regenerated for ${String(ENTRIES.length)} entries (api-surface/*.api.md).`
|
|
: `API surface verified for ${String(ENTRIES.length)} entries — no drift.`,
|
|
);
|