Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 14 additions & 4 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -158,10 +158,15 @@ jobs:
# which is what #260 hit. `corpus:fetch` pulls the snapshot pinned by
# scripts/corpus-pin.json and verifies its sha256.
#
# What is a gate here and what is not: the FETCH gates — a hash mismatch is a
# hard failure, or the pin is decorative. The census figures do NOT gate; they
# go to the run summary as a trend. Asserting them against the committed
# fixture is #260's job, and it needs the reachability this job provides.
# What gates here (#260): the FETCH — a hash mismatch is a hard failure, or
# the pin is decorative — and the VALUE census against the committed
# `corpus` block of test/fixtures/explain/values.json, which is the
# reachability this job was built to provide. The genre census still only
# reports: it has no committed fixture to be asserted against.
#
# The other half of #260 needs no corpus and is not here: the README figures
# are a generated projection of that same fixture, gated by
# `explain:value-census:readme:check` inside `lint:ci`.
corpus:
name: Corpus census
needs: [checks]
Expand Down Expand Up @@ -209,6 +214,11 @@ jobs:
bun run explain:value-census 2>&1
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# After the summary, so a drifted run still publishes its figures — the
# numbers are what tells you whether the drift is an intended emission
# change or a regression.
- name: Value census matches the committed fixture
run: bun run explain:value-census:check

chr-smoke:
name: CHR smoke (stable)
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ documenting cross-cutting shifts that affect contributors and consumers.
and never on a PR; both now fall back to the pinned snapshot and announce the
source and hash they used. A new CI job gates the fetch and posts both
censuses to the run summary (#186).
- **The `explain` value census is drift-gated, not just re-derivable.** Its
figures live in three places — the census itself, the `corpus` block of
`test/fixtures/explain/values.json`, and prose in `commands/explain/README.md`
— and #256 shipped with the README still quoting pre-change numbers, caught by
review rather than CI. Each link now has a gate keyed to the data it needs:
`bun run explain:value-census:check` re-runs the census against the committed
fixture in the CI corpus job, while the README paragraph became a generated
projection of that fixture (`bun run explain:value-census:readme`) checked by
`bun run explain:value-census:readme:check` in `lint:ci` and in `bun test`,
needing no corpus at all. The generated prose reports the invariants as
counters rather than asserting they hold, so regenerating after a regression
states it instead of reading as reassurance (#260).

## 0.1.3 — 2026-07-06

Expand Down
28 changes: 19 additions & 9 deletions commands/explain/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -779,16 +779,26 @@ and its phase is named below.
on the seconds fallback rather than encoding a chain that depends on a failed
address attempt.

<!-- BEGIN GENERATED value-census — regenerate with `bun run explain:value-census:readme` -->
The corpus census is re-derivable with `bun run explain:value-census` and
covers 948 source scripts. Every one of the 13,168 emitted values in a
statement the strict argument lexer ALSO read has identical half-open byte
spans and decoded text (0 contradictions), while the prefix-safe scan retains
6,540 values across 569 statements whose strict REST reading abstains. Of
19,708 emitted occurrences, 5,636 are array members (529 keyed, 1,147 nested
inside another member) and 813 are arrays; the corpus contains no
source-literal `id` example. All spans were in bounds, every member named a
container that exists, and every member sat strictly inside it. The strict
lexer remains all-or-nothing; only non-authoritative hints use the wider view.
covers 948 source scripts. The figures below are generated from
`test/fixtures/explain/values.json` → `corpus` by
`bun run explain:value-census:readme` and gated against it by
`bun run explain:value-census:readme:check`; the fixture itself is gated
against a fresh corpus run by `bun run explain:value-census:check`. Of the
13,168 emitted values in a statement the strict argument lexer ALSO read, 0
disagree with it on half-open byte span or decoded text, while the
prefix-safe scan retains a further 6,540 values across 569 statements whose
strict REST reading abstains. Of 19,708 emitted occurrences, 5,636 are array
members (529 keyed, 1,147 nested inside another member) and 813 are arrays;
the corpus holds no source-literal `id` example. The three structural
counters — spans addressing bytes outside their own source, members naming a
container that does not exist, members escaping the container they name —
read 0, 0 and 0, and each must stay 0.
<!-- END GENERATED value-census -->

The strict lexer remains all-or-nothing; only non-authoritative hints use the
wider view.

### Offline comment placement (#245)

Expand Down
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"lint": "bun run lint:biome && bun run lint:typecheck",
"lint:git:commit": "bun run lint",
"lint:git:push": "bun run lint:ci && bun run test && bun run build",
"lint:ci": "bun run lint && bun run docs:cli:check && bun run lint:markdownlint && bun run lint:cspell && bun run lint:secretlint",
"lint:ci": "bun run lint && bun run docs:cli:check && bun run explain:value-census:readme:check && bun run lint:markdownlint && bun run lint:cspell && bun run lint:secretlint",
"lint:cspell": "cspell lint . --gitignore",
"lint:cspell:all": "cspell lint . --no-gitignore",
"lint:markdownlint": "markdownlint-cli2 \"**/*.md\" \"!.scratch/**\"",
Expand Down Expand Up @@ -98,6 +98,9 @@
"explain:menus": "bun run scripts/gen-explain-menus.ts",
"explain:menus:check": "bun run scripts/gen-explain-menus.ts --check",
"explain:value-census": "bun run scripts/explain-value-census.ts",
"explain:value-census:check": "bun run scripts/explain-value-census.ts --check",
"explain:value-census:readme": "bun run scripts/explain-value-census.ts --readme",
"explain:value-census:readme:check": "bun run scripts/explain-value-census.ts --readme --check",
"security": "bun run lint:secretlint && bun run lint:markdownlint"
},
"type": "module",
Expand Down
213 changes: 210 additions & 3 deletions scripts/explain-value-census.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,40 @@
* census is (`scripts/explain-corpus-census.ts`).
*
* ```
* bun run explain:value-census # markdown
* bun run explain:value-census --json # the fixture's `corpus` block
* bun run explain:value-census --db PATH # override the corpus.sqlite location
* bun run explain:value-census # markdown
* bun run explain:value-census --json # the fixture's `corpus` block
* bun run explain:value-census --db PATH # override the corpus.sqlite location
* bun run explain:value-census:check # gate: fresh census vs the fixture
* bun run explain:value-census:readme # rewrite the README block from the fixture
* bun run explain:value-census:readme:check # gate: README block vs the fixture
* ```
*
* The corpus is not in this repo. A sibling `lsp-routeros-ts` checkout is used
* when present, otherwise the snapshot pinned by `bun run corpus:fetch`; the
* source and its sha256 are announced on stderr. See `corpus-fetch.ts` (#186).
*
* ## Two gates, because the figures live in three places (#260)
*
* Committing this script made the figures **re-derivable**; it did not make
* them **checked**. #256 changed the emission, regenerated the fixture and the
* unit assertions, and shipped with the README prose still quoting the
* pre-change numbers — three review comments found that, not CI.
*
* The chain is corpus → fixture → README, and each link now has its own gate,
* separated by what data it needs:
*
* - `--check` re-runs the census and asserts it against
* `test/fixtures/explain/values.json` → `corpus`. It needs the corpus, so it
* runs in ci.yaml's `corpus` job (the one #186 gave `corpus:fetch`
* reachability for) and never in the offline `lint:ci`.
* - `--readme --check` asserts the README paragraph against that same fixture
* block. The README is now a GENERATED projection of it, so the drift that
* actually happened is caught with no corpus at all — that one is in
* `lint:ci` and in `bun test`.
*
* The fixture is the hinge: it is the one copy a human writes, and both gates
* point at it rather than at each other.
*
* ## What each figure means
*
* - **strictComparableAnchors** — emitted values in statements the STRICT REST
Expand All @@ -43,6 +68,8 @@
*/

import { Database } from "bun:sqlite";
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { lexArguments, lexValueAnchors } from "../src/explain/args.ts";
import { valueShapeHints } from "../src/explain/values.ts";
import { resolveVerbs } from "../src/explain/verbsplit.ts";
Expand Down Expand Up @@ -216,7 +243,171 @@ function flag(args: readonly string[], name: string): string | undefined {
return at < 0 ? undefined : args[at + 1];
}

const README_PATH = join(
import.meta.dir,
"..",
"commands",
"explain",
"README.md",
);
const FIXTURE_PATH = join(
import.meta.dir,
"..",
"test",
"fixtures",
"explain",
"values.json",
);

/**
* The generated paragraph sits inside a list item, so both markers and every
* rendered line carry the item's two-space continuation indent.
*/
const BLOCK_INDENT = " ";
const BLOCK_BEGIN = `${BLOCK_INDENT}<!-- BEGIN GENERATED value-census — regenerate with \`bun run explain:value-census:readme\` -->`;
const BLOCK_END = `${BLOCK_INDENT}<!-- END GENERATED value-census -->`;
const WRAP_COLUMNS = 78;

/**
* Greedy word wrap that never breaks inside a `code span`.
*
* A token is a maximal run of non-space characters in which a whole
* backtick-delimited span counts as one character, so `` `bun run x` `` and any
* punctuation glued to it stay on one line. CommonMark would in fact fold a
Comment thread
mobileskyfi marked this conversation as resolved.
Outdated
* newline inside a code span into a space, but a command the reader may want to
* copy should not be split across lines in the source either.
*/
function wrap(text: string): string[] {
const lines: string[] = [];
let line = BLOCK_INDENT;
for (const word of text.match(/(?:`[^`]*`|\S)+/g) ?? []) {
if (line !== BLOCK_INDENT && line.length + 1 + word.length > WRAP_COLUMNS) {
lines.push(line);
line = BLOCK_INDENT;
}
line += line === BLOCK_INDENT ? word : ` ${word}`;
}
if (line !== BLOCK_INDENT) lines.push(line);
return lines;
}

const count = (value: number): string => value.toLocaleString("en-US");

/**
* Render the README's census paragraph from a census result.
*
* Every sentence stays true at any value: the invariants are reported as
* counters rather than asserted as held, so a regenerated block states a
* regression instead of quietly contradicting itself.
*/
export function renderReadmeBlock(result: ValueCensus): string[] {
const idCount = result.shapeCounts["id"] ?? 0;
const idClause =
idCount === 0
? "the corpus holds no source-literal `id` example"
: `${count(idCount)} are \`id\``;
return wrap(
`The corpus census is re-derivable with \`bun run explain:value-census\` and ` +
`covers ${count(result.sourceScripts)} source scripts. The figures below are generated from ` +
"`test/fixtures/explain/values.json` → `corpus` by " +
"`bun run explain:value-census:readme` and gated against it by " +
"`bun run explain:value-census:readme:check`; the fixture itself is gated " +
"against a fresh corpus run by `bun run explain:value-census:check`. Of the " +
`${count(result.strictComparableAnchors)} emitted values in a statement the strict argument lexer ` +
`ALSO read, ${count(result.boundaryContradictions)} disagree with it on half-open byte span or ` +
`decoded text, while the prefix-safe scan retains a further ${count(result.recoveredPrefixAnchors)} ` +
`values across ${count(result.unreadStatementsWithAnchors)} statements whose strict REST reading ` +
`abstains. Of ${count(result.valueOccurrences)} emitted occurrences, ` +
`${count(result.elementOccurrences)} are array members (${count(result.keyedElements)} keyed, ` +
`${count(result.nestedElements)} nested inside another member) and ` +
`${count(result.shapeCounts["array"] ?? 0)} are arrays; ${idClause}. The three structural ` +
"counters — spans addressing bytes outside their own source, members " +
"naming a container that does not exist, members escaping the container " +
`they name — read ${count(result.invalidSpans)}, ${count(result.danglingParents)} and ` +
`${count(result.containmentBreaks)}, and each must stay 0.`,
);
}

/** The `corpus` block of the values fixture, which the README block projects. */
function readFixtureCensus(): ValueCensus {
const fixture = JSON.parse(readFileSync(FIXTURE_PATH, "utf8")) as {
corpus?: ValueCensus;
};
if (fixture.corpus === undefined)
throw new Error(`${FIXTURE_PATH} has no \`corpus\` block`);
return fixture.corpus;
}

/**
* Compare a fresh census against the committed fixture, figure by figure.
*
* `censusCommand` is the fixture's own provenance note, not a measurement, so
* it is excluded here; `explain-values.test.ts` pins it instead.
*/
export function diffAgainstFixture(
fresh: ValueCensus,
pinned: ValueCensus,
): string[] {
const measured = fresh as unknown as Record<string, unknown>;
const committed = pinned as unknown as Record<string, unknown>;
const render = (value: unknown): string =>
typeof value === "number" ? String(value) : JSON.stringify(value);
const drift: string[] = [];
for (const key of Object.keys(measured)) {
const a = render(measured[key]);
const b = render(committed[key]);
if (a !== b) drift.push(`${key}: fixture ${b}, measured ${a}`);
}
Comment thread
mobileskyfi marked this conversation as resolved.
// A figure the fixture carries and the census no longer emits is drift too:
// silently keeping it would leave the README quoting a retired definition.
for (const key of Object.keys(committed)) {
if (key in measured || key === "censusCommand") continue;
drift.push(`${key}: in the fixture, not measured`);
}
return drift;
}

/**
* Rewrite or verify the README's generated block. Returns a process exit code.
*/
export function runReadme(check: boolean): number {
const rendered = renderReadmeBlock(readFixtureCensus());
const readme = readFileSync(README_PATH, "utf8");
const lines = readme.split("\n");
const begin = lines.indexOf(BLOCK_BEGIN);
const end = lines.indexOf(BLOCK_END);
if (begin < 0 || end < begin) {
console.error(
`::error title=explain value census::commands/explain/README.md is missing the generated value-census block markers`,
);
return 1;
}
const current = lines.slice(begin + 1, end);
if (current.join("\n") === rendered.join("\n")) {
if (!check) console.error("value-census README block already current");
return 0;
}
if (check) {
console.error(
"::error title=explain value census::commands/explain/README.md no longer matches " +
"test/fixtures/explain/values.json → corpus. Run `bun run explain:value-census:readme`.",
);
console.error(`--- README\n${current.join("\n")}`);
console.error(`+++ fixture\n${rendered.join("\n")}`);
return 1;
}
writeFileSync(
README_PATH,
[...lines.slice(0, begin + 1), ...rendered, ...lines.slice(end)].join("\n"),
);
console.error("rewrote the value-census block in commands/explain/README.md");
return 0;
}

export async function main(args: readonly string[]): Promise<number> {
// Before any corpus resolution: the doc gate reads the fixture, so it must
// run in CI and from a bare clone, where `corpus.sqlite` does not exist.
if (args.includes("--readme")) return runReadme(args.includes("--check"));
const resolution = resolveCorpusDb(flag(args, "--db"));
// All of this goes to stderr, not stdout: `--json` is piped into the fixture.
// The warning is emitted before the reachability check because a corrupt
Expand All @@ -242,6 +433,22 @@ export async function main(args: readonly string[]): Promise<number> {
db.close();
}
const result = census(scripts);
if (args.includes("--check")) {
const drift = diffAgainstFixture(result, readFixtureCensus());
if (drift.length > 0) {
console.error(
"::error title=explain value census::the census no longer matches " +
"test/fixtures/explain/values.json → corpus. Repin with " +
"`bun run explain:value-census --json`, then " +
"`bun run explain:value-census:readme`, and update the assertions in " +
"test/unit/explain-values.test.ts.",
);
for (const line of drift) console.error(` ${line}`);
return 1;
}
console.error("value census matches the committed fixture");
return 0;
}
// Written rather than logged: `console.log` followed by `process.exit` can
// truncate a piped stdout in Bun, and this output is normally piped into a
// file or `jq`.
Expand Down
Loading
Loading