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.
166 lines
6.5 KiB
TypeScript
166 lines
6.5 KiB
TypeScript
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { execFileSync } from "node:child_process";
|
|
import { describe, it, expect, afterAll } from "vitest";
|
|
|
|
/**
|
|
* Tests for `scripts/check-internal-tag.mjs` — the gate that reports a symbol
|
|
* tagged `@internal` while the exports map ships it.
|
|
*
|
|
* 🔴 EVERY CASE HAS BOTH HALVES: it fires on a planted defect AND it is silent
|
|
* on the legitimate input next door. A gate whose success looks like silence
|
|
* cannot be noticed broken, and this repository has shipped several checks that
|
|
* were dead on arrival — each found by accident, not by review.
|
|
*
|
|
* The fixture builds an api-extractor report on purpose: this check is
|
|
* cross-file BY NATURE, because "is it public" is answered by the barrels and
|
|
* the exports map, not by the file the declaration sits in. That is the whole
|
|
* reason it stayed a script when its former other half — `@experimental` must
|
|
* be NAMED `experimental_*` — became the ESLint rule `local/experimental-name`
|
|
* (tested in `eslint-rules/experimental-name.test.ts`). Cases about NAMING
|
|
* moved there; do not re-add them here, or the split silently reverts.
|
|
*/
|
|
|
|
const REPO = resolve(import.meta.dirname, "..");
|
|
const SCRIPT = join(REPO, "scripts", "check-internal-tag.mjs");
|
|
const roots: string[] = [];
|
|
|
|
/**
|
|
* A throwaway project with one api-extractor report and one source file.
|
|
* `report` lists what is PUBLIC; `source` is what carries the tags.
|
|
*/
|
|
function fixture(source: string, report = "export function widget(): void;") {
|
|
const root = mkdtempSync(join(tmpdir(), "internal-tag-"));
|
|
roots.push(root);
|
|
mkdirSync(join(root, "src"), { recursive: true });
|
|
mkdirSync(join(root, "api-surface"), { recursive: true });
|
|
writeFileSync(join(root, "src", "thing.ts"), source);
|
|
writeFileSync(
|
|
join(root, "api-surface", "demo.api.md"),
|
|
"## API Report\n\n```ts\n" + report + "\n```\n",
|
|
);
|
|
return root;
|
|
}
|
|
|
|
function run(root: string) {
|
|
try {
|
|
return {
|
|
code: 0,
|
|
out: execFileSync("node", [SCRIPT, root], { encoding: "utf8" }),
|
|
};
|
|
} catch (e) {
|
|
const err = e as { status: number; stdout: string; stderr: string };
|
|
return { code: err.status, out: (err.stdout ?? "") + (err.stderr ?? "") };
|
|
}
|
|
}
|
|
|
|
afterAll(() => {
|
|
for (const r of roots) rmSync(r, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("check-internal-tag", () => {
|
|
it("FIRES on an @internal function that IS exported — a contradiction", () => {
|
|
// The tag says "not part of the API" while the exports map ships it. Six of
|
|
// these existed on the real corpus (`pipe`, `pipeStep`, `needs`, `start`,
|
|
// `andThen`, `effect`), all reachable from `vigiles` and `vigiles/linting`.
|
|
const { code, out } = run(
|
|
fixture("/**\n * @internal\n */\nexport function widget(): void {}\n"),
|
|
);
|
|
expect(code).not.toBe(0);
|
|
expect(out).toContain("tagged @internal but IS exported");
|
|
});
|
|
|
|
it("is SILENT on an @internal function that is NOT exported — the correct use", () => {
|
|
const { code, out } = run(
|
|
fixture(
|
|
"/**\n * @internal\n */\nexport function helper(): void {}\n",
|
|
"export function somethingElse(): void;",
|
|
),
|
|
);
|
|
expect(code).toBe(0);
|
|
expect(out).toContain("checked: 0");
|
|
});
|
|
|
|
it("never suggests a DOUBLED prefix on an already-prefixed name", () => {
|
|
// An `@internal` + already-prefixed symbol must be told to change its TAG,
|
|
// not to become `experimental_experimental_x`.
|
|
const { out } = run(
|
|
fixture(
|
|
"/**\n * @internal\n */\nexport function experimental_widget(): void {}\n",
|
|
"export function experimental_widget(): void;",
|
|
),
|
|
);
|
|
expect(out).not.toContain("experimental_experimental_");
|
|
});
|
|
|
|
it("FIRES when BOTH tags are present — @internal is the stronger claim", () => {
|
|
// 🔴 A REGRESSION INTRODUCED BY THE SPLIT, found by a reviewer. The tag field
|
|
// was recorded as `experimental ? "@experimental" : "@internal"`, and after
|
|
// the naming half moved to ESLint the reporting loop acts only on
|
|
// `@internal` — so a both-tagged export was recorded as `@experimental` and
|
|
// then silently ignored. Worse than a miss: the run still printed
|
|
// `checked: 1`, so the counter read as coverage.
|
|
const { code, out } = run(
|
|
fixture(
|
|
"/**\n * @experimental\n * @internal\n */\nexport function widget(): void {}\n",
|
|
),
|
|
);
|
|
expect(code).not.toBe(0);
|
|
expect(out).toContain("tagged @internal but IS exported");
|
|
});
|
|
|
|
it("FIRES on an @internal DEFAULT export — an ordinary public API shape", () => {
|
|
// Both matchers omitted the `default` modifier, so the source side saw no
|
|
// declaration and the api-report side saw no public name. The two absences
|
|
// cancelled into a clean `findings: 0`.
|
|
const { code, out } = run(
|
|
fixture(
|
|
"/**\n * @internal\n */\nexport default function widget(): void {}\n",
|
|
"export default function widget(): void;",
|
|
),
|
|
);
|
|
expect(code).not.toBe(0);
|
|
expect(out).toContain("tagged @internal but IS exported");
|
|
});
|
|
|
|
it("is SILENT on an @internal default export that is NOT public", () => {
|
|
const { code, out } = run(
|
|
fixture(
|
|
"/**\n * @internal\n */\nexport default function helper(): void {}\n",
|
|
"export function somethingElse(): void;",
|
|
),
|
|
);
|
|
expect(code).toBe(0);
|
|
expect(out).toContain("checked: 0");
|
|
});
|
|
|
|
// ── the failure mode that would make it green and dead ──
|
|
|
|
it("REFUSES to pass when there are no api reports to read", () => {
|
|
// Without this, running it anywhere the reports are missing exits 0 and
|
|
// looks identical to a clean run — the exact shape of a check that is
|
|
// silently doing nothing.
|
|
const root = mkdtempSync(join(tmpdir(), "internal-tag-empty-"));
|
|
roots.push(root);
|
|
mkdirSync(join(root, "src"), { recursive: true });
|
|
writeFileSync(
|
|
join(root, "src", "thing.ts"),
|
|
"/**\n * @experimental\n */\nexport function widget(): void {}\n",
|
|
);
|
|
const { code, out } = run(root);
|
|
expect(code).not.toBe(0);
|
|
expect(out).toContain("Refusing to pass vacuously");
|
|
});
|
|
|
|
it("counts what it checked, so a run that inspected nothing is VISIBLE", () => {
|
|
const { out } = run(
|
|
fixture(
|
|
"/**\n * @experimental\n */\nexport function experimental_widget(): void {}\n",
|
|
"export function experimental_widget(): void;",
|
|
),
|
|
);
|
|
expect(out).toMatch(/declarations checked: [1-9]/);
|
|
});
|
|
});
|