clearotron 0.3.1 → 0.3.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/CONTRIBUTING.md +30 -1
  2. package/README.md +1 -0
  3. package/build-info.json +2 -2
  4. package/docs/writing-rules.md +208 -0
  5. package/docs/writing-standard.md +85 -0
  6. package/driver/CHANGELOG.md +13 -0
  7. package/driver/contract-e3-backlog.mjs +4 -4
  8. package/driver/contract-vocabulary.mjs +15 -14
  9. package/driver/gateway.mjs +33 -17
  10. package/driver/package.json +1 -1
  11. package/driver/partial-payload-baseline.json +1 -1
  12. package/driver/pipeline.mjs +3 -3
  13. package/driver/portal-families.mjs +1 -1
  14. package/driver/predelivery-lint.mjs +23 -1
  15. package/driver/publish/index.mjs +24 -1
  16. package/driver/publish/render-knockout.mjs +6 -26
  17. package/driver/publish/render.mjs +25 -5
  18. package/driver/publish/report-data.mjs +2 -1
  19. package/driver/publish/search-depth.mjs +178 -0
  20. package/driver/suite-census.json +38 -2
  21. package/driver/terminal-clamp.mjs +41 -0
  22. package/driver/verify.mjs +22 -3
  23. package/mcp-server/CHANGELOG.md +8 -0
  24. package/mcp-server/package.json +1 -1
  25. package/package.json +1 -1
  26. package/portal-ui/dist/assets/{index-ChIQsMYp.js → index-BsbasHjM.js} +3350 -3288
  27. package/portal-ui/dist/assets/{index-DBIs21e4.css → index-DNQpLYZF.css} +17 -1
  28. package/portal-ui/dist/index.html +2 -2
  29. package/portal-ui/package.json +1 -1
  30. package/providers/oauth-mcp-bridge/CHANGELOG.md +8 -0
  31. package/providers/oauth-mcp-bridge/package.json +1 -1
  32. package/scripts/freeze-example-run.mjs +27 -27
  33. package/scripts/mint-writing-standard-backlog.mjs +82 -0
  34. package/scripts/writing-standard-check.mjs +122 -0
  35. package/shared/says-something-new.mjs +62 -0
  36. package/shared/writing-standard-caveats.json +14 -0
  37. package/shared/writing-standard-classes.mjs +526 -0
@@ -1960,8 +1960,20 @@ input.filter {
1960
1960
  color: var(--text-strong); white-space: nowrap;
1961
1961
  }
1962
1962
  .home2-band-label.faint { color: var(--text-faint); }
1963
- .home2-band-count { font-size: 11.5px; color: var(--text-faint); }
1963
+ /* THE SAME SIZE AND COLOUR AS THE CAPACITY LINE OPPOSITE, because the two are one sentence read across
1964
+ the band: what is happening on the left, how much can happen at once on the right. It carried `mono`
1965
+ while it was a single total, which read as a machine's counter rather than as a statement. `nowrap`
1966
+ because it is now a breakdown — "2 running · 1 paused · 2 queued" — and breaking it mid-clause puts
1967
+ a number on its own line. */
1968
+ .home2-band-count { font-size: 11.5px; color: var(--text-faint); white-space: nowrap; }
1964
1969
  .home2-band-rule { height: 1px; background: var(--border-hairline); }
1970
+
1971
+ /* A RUNNING RUN AS A ROW. The queue's grid, with the two columns a running row has and a waiting one
1972
+ does not. Declared as a modifier rather than a new grid so the narrow-screen rule that hides columns
1973
+ keeps one definition for both. */
1974
+ .home2-runrows { display: flex; flex-direction: column; gap: 1px; margin-bottom: 18px; }
1975
+ .home2-runrow .home2-qstep { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
1976
+ .home2-runrow .home2-qreason { font-size: 11.5px; color: var(--text-muted); white-space: nowrap; }
1965
1977
  .home2-band-note {
1966
1978
  font-size: 11.5px; color: var(--text-faint); text-align: right;
1967
1979
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
@@ -2045,6 +2057,10 @@ input.filter {
2045
2057
  display: flex; align-items: center; gap: 10px;
2046
2058
  }
2047
2059
  .home2-reason { flex: 1; min-width: 0; font-size: 12.5px; color: var(--text-muted); }
2060
+ /* QUIETER THAN THE ELAPSED TIME IT FOLLOWS. The elapsed line is this run's own fact; the quote beside it
2061
+ is the standing one for its pipeline, and a reader who wants to know how long this has been going
2062
+ should meet that first. Same line, one step back in the hierarchy. */
2063
+ .home2-expect { font-size: 12.5px; color: var(--text-faint); white-space: nowrap; }
2048
2064
  .home2-reason.paused { color: var(--tone-medium); }
2049
2065
  .home2-reason.failed { color: var(--tone-high); }
2050
2066
  .home2-stop, .home2-cancel, .home2-ack {
@@ -49,8 +49,8 @@
49
49
  -->
50
50
  <link rel="preconnect" href="https://api.fontshare.com" crossorigin />
51
51
  <link href="https://api.fontshare.com/v2/css?f[]=satoshi@400,500,700,900&display=swap" rel="stylesheet" />
52
- <script type="module" crossorigin src="/portal/assets/index-ChIQsMYp.js"></script>
53
- <link rel="stylesheet" crossorigin href="/portal/assets/index-DBIs21e4.css">
52
+ <script type="module" crossorigin src="/portal/assets/index-BsbasHjM.js"></script>
53
+ <link rel="stylesheet" crossorigin href="/portal/assets/index-DNQpLYZF.css">
54
54
  </head>
55
55
  <body>
56
56
  <div id="root"></div>
@@ -2,7 +2,7 @@
2
2
  "name": "portal-ui",
3
3
  "private": true,
4
4
  "type": "module",
5
- "version": "0.3.1",
5
+ "version": "0.3.2-beta.1",
6
6
  "license": "AGPL-3.0-only",
7
7
  "description": "The unified trademark portal UI. One address, one login: who you are decides what you see. Built as a static bundle, served by driver/portal-service.mjs — the browser never reaches profile-service or recipe-service.",
8
8
  "engines": {
@@ -1,5 +1,13 @@
1
1
  # trademark-oauth-mcp-bridge
2
2
 
3
+ ## 0.3.2-beta.1
4
+
5
+ No changes in this release.
6
+
7
+ ## 0.3.2-beta.0
8
+
9
+ No changes in this release.
10
+
3
11
  ## 0.3.1
4
12
 
5
13
  No changes in this release.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "trademark-oauth-mcp-bridge",
3
- "version": "0.3.1",
3
+ "version": "0.3.2-beta.1",
4
4
  "license": "AGPL-3.0-only",
5
5
  "private": true,
6
6
  "description": "OAuth 2.1 MCP stdio bridge used by the engine's case-law gather stage (courtlistener / legaldatahunter).",
@@ -29,7 +29,7 @@
29
29
  // _driver/run.jsonl the event log
30
30
  // _driver/stage-inputs/ what each stage was handed
31
31
  // _history/ pre-reopen snapshots
32
- // Dropping the telemetry drops `meta.tokens` (driver/publish/index.mjs:971 — the only consumer of
32
+ // Dropping the telemetry drops `meta.tokens` (driver/publish/index.mjs:1136 rollupTokens — the only consumer of
33
33
  // rollupTokens). That is the one difference step 5 is told to expect, and it says so out loud rather than
34
34
  // normalising it away in silence.
35
35
  //
@@ -55,26 +55,26 @@ const REPO = join(dirname(fileURLToPath(import.meta.url)), "..");
55
55
  // Every entry cites the read that puts it here. `required` means publish cannot render without it.
56
56
  const FROZEN_FILES = [
57
57
  // publish/index.mjs — parseReport(reportMd), the one mandatory input
58
- { path: "report.md", required: true, why: "publish/index.mjs:649 parseReport" },
59
- { path: "audit.md", why: "publish/index.mjs:835 audit workbook source" },
60
- { path: "findings.json", why: "publish/index.mjs:555 the per-finding machine contract" },
61
- { path: "status.json", why: "publish/index.mjs:730,905 machine ledger note + markName" },
62
- { path: "case-law-findings.md", why: "publish/index.mjs:660 case-law section" },
63
- { path: "common-law-grid.json", why: "publish/index.mjs:788 common-law coverage" },
58
+ { path: "report.md", required: true, why: "publish/index.mjs:650 parseReport" },
59
+ { path: "audit.md", why: "publish/index.mjs:1022 auditMd, the audit workbook source" },
60
+ { path: "findings.json", why: "publish/index.mjs:715 readStore, the per-finding machine contract" },
61
+ { path: "status.json", why: "publish/index.mjs:913 machineLedgerNote + markName" },
62
+ { path: "case-law-findings.md", why: "publish/index.mjs:849 clPath, the case-law section" },
63
+ { path: "common-law-grid.json", why: "publish/index.mjs:973 commonLawJoinedTerms, common-law coverage" },
64
64
  // publish/index.mjs — the _driver sidecars it reads by name
65
- { path: "_driver/receipts.json", why: "publish/index.mjs:592" },
66
- { path: "_driver/senior-rights.json", why: "publish/index.mjs:599" },
67
- { path: "_driver/verdict.json", why: "publish/index.mjs:604" },
68
- { path: "_driver/framework.json", why: "publish/index.mjs:608 the bands the run was rated under" },
69
- { path: "_driver/register-plan.json", why: "publish/index.mjs:634" },
70
- { path: "_driver/instructed-scope.json", why: "publish/index.mjs:641 fallback for register-plan" },
71
- { path: "_driver/enforcer-signals.json", why: "publish/index.mjs:672" },
72
- { path: "_driver/predelivery-lint.json", why: "publish/index.mjs:699,713" },
73
- { path: "_driver/escalation-state.json", why: "publish/index.mjs:714" },
74
- { path: "_driver/reasoning-integrity.json", why: "publish/index.mjs:715" },
75
- { path: "_driver/corrections-state.json", why: "publish/index.mjs:716" },
76
- { path: "_driver/search-policy.json", why: "publish/index.mjs:768,806 level + stage label" },
77
- { path: "_driver/profile.json", why: "publish/index.mjs:920 + report-registry.mjs:42 customer key" },
65
+ { path: "_driver/receipts.json", why: "publish/index.mjs:761 fetchReceipts" },
66
+ { path: "_driver/senior-rights.json", why: "publish/index.mjs:787 seniorRights" },
67
+ { path: "_driver/verdict.json", why: "publish/index.mjs:792 verdictInfo" },
68
+ { path: "_driver/framework.json", why: "publish/index.mjs, the frozen band vocabulary the run was rated under" },
69
+ { path: "_driver/register-plan.json", why: "publish/index.mjs:820 scopeBasis" },
70
+ { path: "_driver/instructed-scope.json", why: "publish/index.mjs:821 searchedJurisdictions, the fallback for register-plan" },
71
+ { path: "_driver/enforcer-signals.json", why: "publish/index.mjs:861 esPath" },
72
+ { path: "_driver/predelivery-lint.json", why: "publish/index.mjs:170 lintSink" },
73
+ { path: "_driver/escalation-state.json", why: "publish/index.mjs:171 escSink" },
74
+ { path: "_driver/reasoning-integrity.json", why: "publish/index.mjs:898 integritySink" },
75
+ { path: "_driver/corrections-state.json", why: "publish/index.mjs:172 correctionsSink" },
76
+ { path: "_driver/search-policy.json", why: "publish/index.mjs:955 searchPolicy, level + stage label" },
77
+ { path: "_driver/profile.json", why: "publish/index.mjs reads the frozen profile; report-registry.mjs:42 republishRun, customer key" },
78
78
  ];
79
79
 
80
80
  // ── THE KNOCKOUT LANE IS A DIFFERENT WORKSPACE, AND report.md IS NOT IN IT ─
@@ -91,22 +91,22 @@ const KNOCKOUT_FILES = [
91
91
  { path: "knockout-assessment.md", why: "the merged prose the lane writes (gateway.mjs:184)" },
92
92
  { path: "knockout-frame.md", why: "the batch scope note (gateway.mjs:65)" },
93
93
  { path: "email-body.md", why: "the delivery prose the lane writes beside the assessment" },
94
- { path: "status.json", why: "publish/index.mjs:730,905 machine ledger note + markName" },
95
- { path: "audit.md", why: "publish/index.mjs:835 audit workbook source" },
94
+ { path: "status.json", why: "publish/index.mjs:913 machineLedgerNote + markName" },
95
+ { path: "audit.md", why: "publish/index.mjs:1022 auditMd, the audit workbook source" },
96
96
  // The _driver sidecars publishKnockout reads by name. framework.json is REQUIRED and says so at its
97
97
  // call site: a knockout re-rendered under today's bands would silently restate its verdict.
98
98
  { path: "_driver/framework.json", required: true, why: "report-registry.mjs:68 the bands it was rated under" },
99
99
  { path: "_driver/search-policy.json", why: "report-registry.mjs:71 level + stage label" },
100
100
  { path: "_driver/profile.json", why: "report-registry.mjs:72 customer key and the delivery overlay" },
101
- { path: "_driver/verdict.json", why: "publish/index.mjs:604" },
102
- { path: "_driver/receipts.json", why: "publish/index.mjs:592" },
101
+ { path: "_driver/verdict.json", why: "publish/index.mjs:792 verdictInfo" },
102
+ { path: "_driver/receipts.json", why: "publish/index.mjs:761 fetchReceipts" },
103
103
  // THE COUNT SIDECARS, AND THE PROOF IS WHAT FOUND THEM. Without register-counts.json the republished
104
104
  // meta carries `registerCounts: undefined` where the source carried the provider, the taken-at stamp
105
105
  // and the per-mark counts — so the workbook's Register column and every counted figure in the
106
106
  // knockout report render empty (publish/knockout.mjs:140-155). Named by stages-knockout.mjs:32,41.
107
107
  { path: "_driver/register-counts.json", why: "publish/knockout.mjs:140-155 counted figures + the Register column" },
108
108
  { path: "_driver/register-records.json", why: "stages-knockout.mjs:41 the terms behind the close-variation axis" },
109
- { path: "_driver/instructed-scope.json", why: "publish/index.mjs:641 fallback for register-plan" },
109
+ { path: "_driver/instructed-scope.json", why: "publish/index.mjs:821 searchedJurisdictions, the fallback for register-plan" },
110
110
  ];
111
111
 
112
112
  /** The allowlist for a template. One place, so a new template cannot half-exist. */
@@ -161,8 +161,8 @@ const SCRUB = [
161
161
  // what varies and why, and step 5 prints them — a normalisation nobody can see is a normalisation that
162
162
  // hides the next real difference.
163
163
  const VOLATILE = [
164
- { id: "issued", re: /\d{4}-\d{2}-\d{2} · \d{2}:\d{2} [A-Z]{2,5}/g, sub: "<issued>", why: "publish/index.mjs:520 generation stamp, firm locale" },
165
- { id: "iso-timestamp", re: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, sub: "<ts>", why: "publish/index.mjs:665 asOf / :1067 issuedAt" },
164
+ { id: "issued", re: /\d{4}-\d{2}-\d{2} · \d{2}:\d{2} [A-Z]{2,5}/g, sub: "<issued>", why: "publish/index.mjs, the generation stamp in the firm locale" },
165
+ { id: "iso-timestamp", re: /\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z/g, sub: "<ts>", why: "publish/index.mjs:666 asOf" },
166
166
  ];
167
167
 
168
168
  // ── REWRITES — what is CHANGED on the way out, as opposed to what is refused ───────────────────────
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // RE-MINT THE WRITING-STANDARD BACKLOG — the per-file, per-class count that may only go down.
6
+ //
7
+ // node scripts/mint-writing-standard-backlog.mjs # report the delta, change nothing
8
+ // node scripts/mint-writing-standard-backlog.mjs --check # ... and exit 1 if the file is out of date
9
+ // node scripts/mint-writing-standard-backlog.mjs --apply # write it
10
+ //
11
+ // THE ONLY REASON TO RUN `--apply` IS THAT THE NUMBER WENT DOWN. Nothing here refuses to write a higher
12
+ // one — a table that could not record growth would be unable to describe a tree somebody widened a class
13
+ // over — but the floor arm refuses the growth itself, and it reads the committed file rather than this
14
+ // script's output. So an author who mints upward has recorded the regression rather than absorbed it.
15
+ // The two halves are deliberately not the same program.
16
+ //
17
+ // WHAT THE STANDING POPULATION ACTUALLY IS, so nobody reads the number as noise: nine of it is the
18
+ // knockout's scope block and the clearance renderer's coverage paragraph — the sentences the writing
19
+ // standard quotes as what not to write, still on the page. Two are a reviewer-only marker that reaches
20
+ // the delivered HTML. One is a screen whose only heading is the mark its run was ordered for. One is an
21
+ // environment name passed as an argument, which this check reads as printed text because a string
22
+ // literal is the only site it can see; that one is a false positive and is recorded rather than
23
+ // special-cased, because a special case for it would be a hole the size of every string argument.
24
+ import { readFileSync, writeFileSync } from "node:fs";
25
+ import { join, dirname } from "node:path";
26
+ import { fileURLToPath } from "node:url";
27
+ import { CLASSES, censusOf } from "../shared/writing-standard-classes.mjs";
28
+ import { publishedOf } from "../shared/reference-guard-classes.mjs";
29
+ import { trackedFiles, skipReason } from "../shared/tracked-files.mjs";
30
+ import { isEntrypoint } from "../shared/is-entrypoint.mjs";
31
+
32
+ const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
33
+ const FIXTURE = join(ROOT, "driver/test/fixtures/writing-standard-backlog.json");
34
+ const GUARD = "writing-standard-backlog";
35
+
36
+ /** The tree's current census, as the fixture records it. */
37
+ export function mint() {
38
+ const tracked = trackedFiles(GUARD, { root: ROOT });
39
+ if (tracked === null) return null;
40
+ // THE SAME POPULATION THE FLOOR READS, from the same helper. A mint over the index and a floor over
41
+ // HEAD would disagree under the overlay, and `--check` would report a difference that is only the two
42
+ // instruments asking different questions.
43
+ const p = publishedOf(tracked, ROOT);
44
+ if (p.error) { console.error(`mint-writing-standard-backlog: ${p.error}`); process.exit(2); }
45
+ if (p.laid) console.log(`mint-writing-standard-backlog: ${p.laid} tracked path(s) are not in HEAD — laid over this checkout, not published in it, and not counted`);
46
+ const c = censusOf(p.files, (f) => readFileSync(join(ROOT, f), "utf8"));
47
+ return { classes: CLASSES.map((x) => x.id), total: c.total, files: c.files };
48
+ }
49
+
50
+ /** Per-class totals, for a reader who wants to know WHICH number moved. */
51
+ export const byClass = (table) =>
52
+ CLASSES.map((c, i) => [c.id, Object.values(table.files).reduce((a, v) => a + v[i], 0)]);
53
+
54
+ function main() {
55
+ const apply = process.argv.includes("--apply");
56
+ const check = process.argv.includes("--check");
57
+
58
+ const now = mint();
59
+ // A COULD-NOT-LOOK EXITS 2, never 0. Outside a checkout there is no corpus, and a mint that wrote an
60
+ // empty table here would replace the whole backlog with nothing and call it a repair.
61
+ if (now === null) { console.error(`mint-writing-standard-backlog: ${skipReason(GUARD)}`); process.exit(2); }
62
+
63
+ console.log(`writing-standard residue: ${now.total} hit(s) across ${Object.keys(now.files).length} file(s)`);
64
+ for (const [id, n] of byClass(now)) console.log(` ${String(n).padStart(5)} ${id}`);
65
+
66
+ let was = null;
67
+ try { was = JSON.parse(readFileSync(FIXTURE, "utf8")); } catch { /* first mint */ }
68
+
69
+ const next = JSON.stringify(now, null, 2) + "\n";
70
+ const same = was && JSON.stringify(was, null, 2) + "\n" === next;
71
+ if (same) { console.log("the backlog is current"); return; }
72
+
73
+ if (was) {
74
+ const delta = now.total - was.total;
75
+ console.log(`\ntotal ${was.total} → ${now.total} (${delta >= 0 ? "+" : ""}${delta})`);
76
+ }
77
+ if (apply) { writeFileSync(FIXTURE, next); console.log("written"); return; }
78
+ console.log("\nre-run with --apply to write it");
79
+ if (check) process.exit(1);
80
+ }
81
+
82
+ if (isEntrypoint(import.meta.url)) main();
@@ -0,0 +1,122 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
4
+ //
5
+ // WHAT THIS CHANGE ADDS TO A CUSTOMER SURFACE IS REFUSED. What is already here is not this check's business.
6
+ //
7
+ // node scripts/writing-standard-check.mjs [--base <ref>]
8
+ //
9
+ // The standard is `docs/writing-standard.md`, and the prose rules under it are `docs/writing-rules.md`.
10
+ // This refuses the five classes a machine can judge; the classes and the reasoning behind each site rule
11
+ // are in `shared/writing-standard-classes.mjs`, next to the table, because that is where the next person
12
+ // changing one will be looking.
13
+ //
14
+ // ── WHY THIS IS DIFF-SHAPED AND NOT A SWEEP ──────────────────────────────────────────────────────
15
+ //
16
+ // Three of the five classes have a standing population in this tree — the knockout's scope block and the
17
+ // clearance renderer's coverage caveat are the very sentences the standard quotes as what not to write,
18
+ // and they are still on the page. A guard that refused them all would refuse every pull request from its
19
+ // first day, and a guard everybody bypasses protects nothing. So this one asks the smaller question that
20
+ // has a clean answer: did THIS change add another.
21
+ //
22
+ // The standing population is not thereby accepted. It is counted, per file and per class, in
23
+ // `driver/test/fixtures/writing-standard-backlog.json`, and the floor beside it refuses any file that
24
+ // grows. Between the two the number can only fall.
25
+ //
26
+ // ── TWO SHAPES OF CLASS, AND WHY THE SECOND CANNOT BE A LINE READ ───────────────────────────────
27
+ //
28
+ // `engineering-identifier`, `internal-marker` and `known-caveat` are properties of text, so an added line
29
+ // carries them. `eyebrow-heading` and `restating-lede` are not: "this screen writes its own page heading"
30
+ // is a property of a FILE, and "this lede restates its title" needs the two lines at once. Adding a lede
31
+ // under an existing title adds one line and creates the offence; a line-only reading would miss it.
32
+ //
33
+ // So both shapes are evaluated against the file as it stands at HEAD, and the DIFF decides only whether
34
+ // this change is answerable for what it finds: a line class must have its line in the added set; a block
35
+ // class must have its file in the changed set. That is a deliberate widening of "reads the lines a change
36
+ // adds", and it is the only way the last two classes mean anything.
37
+ //
38
+ // ── AN EMPTY DIFF IS A LEGITIMATE PASS, AND IT IS PRINTED AS ONE ────────────────────────────────
39
+ //
40
+ // A pull request can touch only files this check does not read. The count is printed either way, so a
41
+ // reader can tell "looked and found nothing" from "had nothing to look at".
42
+ import { execFileSync } from "node:child_process";
43
+ import { readFileSync } from "node:fs";
44
+ import { CLASSES, fileOffences, isExempt } from "../shared/writing-standard-classes.mjs";
45
+
46
+ const baseArg = () => {
47
+ const i = process.argv.indexOf("--base");
48
+ return i === -1 ? null : process.argv[i + 1];
49
+ };
50
+
51
+ /**
52
+ * The added lines of a diff, as `{ path, lines: Set<number> }` per file.
53
+ *
54
+ * `--unified=0` so nothing but genuinely added text is read: with context lines a neighbouring sentence
55
+ * would be reported as though this change wrote it, and a guard that blames the wrong line is one people
56
+ * learn to ignore. The hunk header carries the new-side start and count, which is what maps an added line
57
+ * back to its number in the file at HEAD.
58
+ */
59
+ export function addedByFile(diffText) {
60
+ const out = new Map();
61
+ let path = null;
62
+ let nextLine = 0;
63
+ for (const line of diffText.split("\n")) {
64
+ if (line.startsWith("+++ b/")) { path = line.slice(6); if (!out.has(path)) out.set(path, new Set()); continue; }
65
+ if (line.startsWith("+++ ") || line.startsWith("--- ")) continue;
66
+ const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
67
+ if (hunk) { nextLine = Number(hunk[1]); continue; }
68
+ if (line.startsWith("+") && path) { out.get(path).add(nextLine); nextLine++; continue; }
69
+ if (line.startsWith("-")) continue;
70
+ nextLine++;
71
+ }
72
+ return out;
73
+ }
74
+
75
+ function main() {
76
+ const base = baseArg() || "origin/main";
77
+ let diff;
78
+ try {
79
+ diff = execFileSync("git", ["diff", "--unified=0", `${base}...HEAD`], { encoding: "utf8", maxBuffer: 1 << 28 });
80
+ } catch (e) {
81
+ console.error(`writing-standard-check: cannot diff against ${base}: ${e.message.split("\n")[0]}`);
82
+ process.exit(2);
83
+ }
84
+
85
+ const added = addedByFile(diff);
86
+ const lineClasses = new Set(["engineering-identifier", "internal-marker", "known-caveat"]);
87
+ const hits = [];
88
+ let read = 0;
89
+
90
+ for (const [path, lines] of added) {
91
+ if (isExempt(path)) continue;
92
+ let text;
93
+ try { text = readFileSync(path, "utf8"); } catch { continue; } // deleted in this change
94
+ read += lines.size;
95
+ for (const o of fileOffences(path, text)) {
96
+ // A LINE CLASS ANSWERS FOR ITS LINE; A BLOCK CLASS ANSWERS FOR ITS FILE. Holding a block class to
97
+ // the added-line test would excuse the case it exists for — a lede added under a title that was
98
+ // already there, where the offending pair is one new line and one old one.
99
+ if (lineClasses.has(o.id) && !lines.has(o.line)) continue;
100
+ hits.push({ path, ...o });
101
+ }
102
+ }
103
+
104
+ console.log(`writing-standard-check: read ${read} added line(s) in ${added.size} file(s) against ${base}`);
105
+ if (!hits.length) return;
106
+
107
+ // GROUPED BY CLASS, because the remedy is per class and a flat list makes the reader derive it five
108
+ // times. Each heading is said once, then the lines it applies to.
109
+ console.error(`\n${hits.length} line(s) added that a customer surface must not carry:\n`);
110
+ for (const { id, why } of CLASSES) {
111
+ const mine = hits.filter((h) => h.id === id);
112
+ if (!mine.length) continue;
113
+ console.error(` ${id} — ${why}`);
114
+ for (const h of mine) console.error(` ${h.path}:${h.line}\n ${h.token}`);
115
+ console.error("");
116
+ }
117
+ console.error(" The standard is docs/writing-standard.md. It never rewrites: a rewritten sentence is a");
118
+ console.error(" sentence nobody reviewed, and a report is the one document a reader may trust literally.\n");
119
+ process.exit(1);
120
+ }
121
+
122
+ if (import.meta.url === `file://${process.argv[1]}`) main();
@@ -0,0 +1,62 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ // Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
3
+ //
4
+ // DOES THIS SENTENCE ASSERT ANYTHING ITS REFERENCE DOES NOT?
5
+ //
6
+ // One question, two callers. The knockout renderer asks it of a model-written caveat against the scope
7
+ // block the page already prints, and drops the caveat when the answer is no. The writing-standard check
8
+ // asks it of a page's lede against that page's title, and refuses the lede when the answer is no. The
9
+ // two are the same rule — "this line adds nothing to the line above it" — and they were one function
10
+ // private to the renderer until the check needed it.
11
+ //
12
+ // LIFTED RATHER THAN COPIED, AND THAT IS THE WHOLE POINT. Two definitions of one rule is one definition
13
+ // and one imitation of it, and the imitation is whichever the reader did not run. A copy here would
14
+ // drift the first time either caller tuned its stopword list, and the drift would be invisible: both
15
+ // sides would keep passing their own tests.
16
+ //
17
+ // ── THE STEM MUST BE IDEMPOTENT ON THE SINGULAR ─────────────────────────────────────────────────
18
+ //
19
+ // Carried over from the renderer with the defect it had already been repaired for, stated here so it is
20
+ // not reintroduced. An earlier form stripped a trailing "es" — which turned "gives" into "giv" while
21
+ // leaving "give" alone, so the two never matched and every caveat read as new. The fold has to map the
22
+ // plural onto the singular AND leave the singular where it is. Strip one trailing "s" and nothing else.
23
+
24
+ /**
25
+ * Words that carry no claim, so their presence or absence says nothing about what a sentence asserts.
26
+ *
27
+ * Exported because a caller comparing two short strings — a title against a lede — can be left with no
28
+ * content words at all, and that case needs naming rather than inferring from a false return.
29
+ */
30
+ export const STOPWORDS = new Set(['a', 'an', 'and', 'are', 'as', 'at', 'be', 'been', 'but', 'by', 'can', 'do',
31
+ 'does', 'each', 'for', 'from', 'has', 'have', 'here', 'in', 'is', 'it', 'its', 'no', 'not', 'of', 'on',
32
+ 'or', 'that', 'the', 'their', 'them', 'there', 'these', 'they', 'this', 'to', 'up', 'was', 'we', 'were',
33
+ 'what', 'when', 'which', 'will', 'with', 'you', 'your']);
34
+
35
+ /** The words in a string that carry a claim: longer than two characters, and not a stopword. */
36
+ export const contentWords = (s) => String(s ?? '').toLowerCase().replace(/[^a-z0-9\s-]/g, ' ')
37
+ .split(/\s+/).filter((w) => w.length > 2 && !STOPWORDS.has(w));
38
+
39
+ /** Fold plural onto singular. Idempotent on the singular, which is the property that makes it work. */
40
+ export const stem = (w) => w.replace(/ies$/, 'y').replace(/s$/, '');
41
+
42
+ /**
43
+ * Does `line` assert anything `reference` does not already assert?
44
+ *
45
+ * TRUE unless every content word in the line is already in the reference. An empty line has nothing to
46
+ * say and returns false; a line with one unfamiliar word is kept. Singular and plural are folded, so
47
+ * "conclusion" does not read as new beside "conclusions".
48
+ *
49
+ * THE TEST IS SUBSET, NOT SIMILARITY, and the difference is the whole safety argument for the renderer's
50
+ * use of it: a caveat is dropped only when EVERY content word in it already appears in the reference. A
51
+ * caveat making any new claim survives.
52
+ *
53
+ * @param {string} line the sentence being judged
54
+ * @param {string} reference the text it is judged against
55
+ * @returns {boolean}
56
+ */
57
+ export function saysSomethingNew(line, reference) {
58
+ const known = new Set(contentWords(reference).map(stem));
59
+ const words = contentWords(line);
60
+ if (!words.length) return false;
61
+ return words.some((w) => !known.has(stem(w)));
62
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "note": "The caveat sentences the writing standard forbids, held as data rather than in shared/writing-standard-classes.mjs. A class list that reprints its own specimens counts itself, and that module is read by the census that counts them. Matching is case-insensitive and whitespace-flattened, over a file's joined printed text, so a sentence built by string concatenation across source lines is still one sentence. Keep each entry long enough to be unambiguous: a fragment short enough to occur in ordinary prose will match ordinary prose.",
3
+ "sentences": [
4
+ "What it is not. A clearance search.",
5
+ "We drew no register conclusions and give no filing advice.",
6
+ "A name that passes here is not clear",
7
+ "A count is not a conflict",
8
+ "No coverage record was produced for this run.",
9
+ "This section normally lists what each search covered",
10
+ "Ask us before relying on it",
11
+ "the Japan adapter was unavailable this session",
12
+ "Source routing attempted"
13
+ ]
14
+ }