clearotron 0.3.1 → 0.3.2-beta.0
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.
- package/CONTRIBUTING.md +30 -1
- package/README.md +1 -0
- package/build-info.json +2 -2
- package/docs/writing-rules.md +208 -0
- package/docs/writing-standard.md +85 -0
- package/driver/CHANGELOG.md +6 -0
- package/driver/contract-e3-backlog.mjs +3 -3
- package/driver/contract-vocabulary.mjs +15 -14
- package/driver/gateway.mjs +33 -17
- package/driver/package.json +1 -1
- package/driver/publish/render-knockout.mjs +6 -26
- package/driver/publish/render.mjs +25 -5
- package/driver/suite-census.json +24 -0
- package/driver/verify.mjs +22 -3
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/package.json +1 -1
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/mint-writing-standard-backlog.mjs +82 -0
- package/scripts/writing-standard-check.mjs +122 -0
- package/shared/says-something-new.mjs +62 -0
- package/shared/writing-standard-caveats.json +14 -0
- package/shared/writing-standard-classes.mjs +526 -0
|
@@ -1661,6 +1661,8 @@ const COV_STATE = {
|
|
|
1661
1661
|
* carries EVERY significant word of the directive it names — a near-match keeps both.
|
|
1662
1662
|
*/
|
|
1663
1663
|
const FOLLOW_UP_PREFIX = 'Follow-up / ';
|
|
1664
|
+
// `<axis> / <what was swept>` — the shape unitLabel composes for a plan-derived coverage unit.
|
|
1665
|
+
const AXIS_LABELLED = /\s\/\s/;
|
|
1664
1666
|
const COV_STOPWORDS = new Set(['the', 'a', 'an', 'as', 'for', 'of', 'in', 'on', 'and', 'or', 'to', 'is',
|
|
1665
1667
|
'was', 'it', 'its', 'this', 'that', 'with', 'by', 'at', 'be', 'been', 'run', 'search', 'searched']);
|
|
1666
1668
|
const covWords = (t) => new Set(String(t || '').toLowerCase().match(/[a-z0-9]+/g)?.filter((w) => !COV_STOPWORDS.has(w)) ?? []);
|
|
@@ -1689,13 +1691,31 @@ function dedupeFollowUps(coverage) {
|
|
|
1689
1691
|
// it. That is exactly the failure this function's header calls the one worth avoiding, and the
|
|
1690
1692
|
// header was right while the code was not.
|
|
1691
1693
|
//
|
|
1692
|
-
//
|
|
1693
|
-
//
|
|
1694
|
-
//
|
|
1695
|
-
//
|
|
1696
|
-
//
|
|
1694
|
+
// THE LIMITATION ABOVE STOPPED BEING HYPOTHETICAL, so it is a rule now rather than a paragraph.
|
|
1695
|
+
//
|
|
1696
|
+
// It read: "it is word containment, so two genuinely different OPEN searches sharing every word of a
|
|
1697
|
+
// short directive would still collapse to one." Measured on a delivered report — 33 coverage entries,
|
|
1698
|
+
// 31 rendered cells. Two open park rows with one- and two-word directives were erased by unrelated
|
|
1699
|
+
// rows that merely mentioned those words. The client read a coverage section that never named two
|
|
1700
|
+
// slices the run had deliberately disclosed, which is the failure this header calls the one worth
|
|
1701
|
+
// avoiding, reached by the route the header predicted.
|
|
1702
|
+
//
|
|
1703
|
+
// THE DISCRIMINATOR IS THE AXIS LABEL, and it is the identity the paragraph above said was missing.
|
|
1704
|
+
// A row whose area carries one — `<axis> / <what was swept>`, the shape `unitLabel` composes — is a
|
|
1705
|
+
// PLAN-DERIVED COVERAGE UNIT. It is not the model restating a deferred slice; it is a different unit
|
|
1706
|
+
// that happens to contain the same word. Only the model's own free-text row can BE a restatement,
|
|
1707
|
+
// and that is exactly the dolphin row this function was built for: "the English word DOLPHIN as a
|
|
1708
|
+
// dedicated exact search", no axis, no separator. So an axis-labelled row may no longer stand in for
|
|
1709
|
+
// a composed follow-up, however many words it shares.
|
|
1710
|
+
//
|
|
1711
|
+
// EVIDENCE, STATED ONE-SIDED BECAUSE IT IS. Every row containing either erased directive was
|
|
1712
|
+
// axis-labelled `register`, so the measured run supports the half that stops suppression. It cannot
|
|
1713
|
+
// support the other half: that run has no open row WITHOUT an axis label, so nothing in it exercises
|
|
1714
|
+
// "a free-text row still suppresses". The witness for that half is the dolphin incident alone, which
|
|
1715
|
+
// is a real delivered page but a single one — and the arm below is what keeps it honest.
|
|
1697
1716
|
return !written.some((w) => {
|
|
1698
1717
|
if (COV_STATE[w?.state]?.cls === 'ok') return false; // a searched-and-clean row reports the opposite
|
|
1718
|
+
if (AXIS_LABELLED.test(String(w?.area || ''))) return false; // a plan unit is not a restatement
|
|
1699
1719
|
const theirs = covWords(w.area);
|
|
1700
1720
|
return [...directive].every((word) => theirs.has(word));
|
|
1701
1721
|
});
|
package/driver/suite-census.json
CHANGED
|
@@ -651,6 +651,12 @@
|
|
|
651
651
|
"skips": 0,
|
|
652
652
|
"todos": 0
|
|
653
653
|
},
|
|
654
|
+
"a-refusal-with-no-near-neighbour-does-not-claim-the-search-never-ran.test.mjs": {
|
|
655
|
+
"tests": 4,
|
|
656
|
+
"asserts": 15,
|
|
657
|
+
"skips": 0,
|
|
658
|
+
"todos": 0
|
|
659
|
+
},
|
|
654
660
|
"a-refused-mcp-call-is-not-a-call-nobody-made.test.mjs": {
|
|
655
661
|
"tests": 7,
|
|
656
662
|
"asserts": 19,
|
|
@@ -765,6 +771,12 @@
|
|
|
765
771
|
"skips": 0,
|
|
766
772
|
"todos": 0
|
|
767
773
|
},
|
|
774
|
+
"a-short-named-open-slice-still-reaches-the-page.test.mjs": {
|
|
775
|
+
"tests": 5,
|
|
776
|
+
"asserts": 6,
|
|
777
|
+
"skips": 0,
|
|
778
|
+
"todos": 0
|
|
779
|
+
},
|
|
768
780
|
"a-signal-immune-fixture-is-reaped-by-its-owner.test.mjs": {
|
|
769
781
|
"tests": 10,
|
|
770
782
|
"asserts": 20,
|
|
@@ -5139,6 +5151,18 @@
|
|
|
5139
5151
|
"skips": 0,
|
|
5140
5152
|
"todos": 0
|
|
5141
5153
|
},
|
|
5154
|
+
"the-writing-standard-backlog-is-a-floor.test.mjs": {
|
|
5155
|
+
"tests": 3,
|
|
5156
|
+
"asserts": 8,
|
|
5157
|
+
"skips": 2,
|
|
5158
|
+
"todos": 0
|
|
5159
|
+
},
|
|
5160
|
+
"the-writing-standard-check-refuses-five-classes.test.mjs": {
|
|
5161
|
+
"tests": 14,
|
|
5162
|
+
"asserts": 53,
|
|
5163
|
+
"skips": 0,
|
|
5164
|
+
"todos": 0
|
|
5165
|
+
},
|
|
5142
5166
|
"the-xcheck-cap-counts-queries.test.mjs": {
|
|
5143
5167
|
"tests": 9,
|
|
5144
5168
|
"asserts": 25,
|
package/driver/verify.mjs
CHANGED
|
@@ -368,14 +368,21 @@ function commonLawMeaningSeat(p, c) {
|
|
|
368
368
|
const recordedQ = new Set(recordedRaw.map(queryKey));
|
|
369
369
|
const dropped = dictated.filter((q) => !recordedQ.has(queryKey(q)));
|
|
370
370
|
if (dropped.length) {
|
|
371
|
-
// ── THE REFUSAL SAYS
|
|
371
|
+
// ── THE REFUSAL SAYS WHAT IT CAN SEE, AND STOPS SHORT OF WHAT IT CANNOT ───────────────────────
|
|
372
372
|
//
|
|
373
|
-
// ABSENT: no recorded query resembles it, so the search was not run and the seat must run it.
|
|
374
373
|
// UNMATCHED: something close IS recorded, so the search ran and the two spellings disagree beyond
|
|
375
374
|
// what the key folds — a re-ordering, a translation, a truncation, a query the provider chose for
|
|
376
375
|
// itself. Telling the seat to "re-run the missing query" in that case asks for the one thing that
|
|
377
376
|
// cannot help, and that is what turned one attempt into four on a production clearance.
|
|
378
377
|
//
|
|
378
|
+
// NO RESEMBLANCE: nothing recorded looks like it. This used to be reported as ABSENT — "the search
|
|
379
|
+
// was not run and the seat must run it" — and that is a claim the gate has no way to make. A query
|
|
380
|
+
// recorded under a translation, a transliteration, or the seat's own rewording resembles nothing and
|
|
381
|
+
// is not absent; the seat was then told to re-run a search that had already happened, which is the
|
|
382
|
+
// same loop, one wording-distance further out. The two states are genuinely indistinguishable from
|
|
383
|
+
// here and always will be: there is no identity to join on, which is why this comparison exists at
|
|
384
|
+
// all. So the label names the observation and the remedy carries BOTH repairs, cheap either way.
|
|
385
|
+
//
|
|
379
386
|
// NO THRESHOLD DECIDES ANYTHING (owner's ruling). The nearest recorded query is shown so a person or
|
|
380
387
|
// a seat can SEE the difference in one attempt; it never makes the gate pass. A similarity score
|
|
381
388
|
// that could pass this gate would be a score that can hide a skipped query, which is what the gate
|
|
@@ -394,11 +401,23 @@ function commonLawMeaningSeat(p, c) {
|
|
|
394
401
|
// every query. Below half the words in common, say nothing rather than point at a red herring.
|
|
395
402
|
return bestScore >= 0.5 ? best : null;
|
|
396
403
|
};
|
|
404
|
+
// THE SECOND LABEL SAYS WHAT THIS GATE KNOWS, WHICH IS LESS THAN IT USED TO CLAIM.
|
|
405
|
+
//
|
|
406
|
+
// It read `[absent from the ledger]`, and that is an assertion the gate cannot make. No near
|
|
407
|
+
// neighbour means no RECORDED query resembles this one — not that the search never ran. A query that
|
|
408
|
+
// ran and was recorded under a translation, a transliteration, a re-ordering, or the seat's own
|
|
409
|
+
// rewording clears no overlap threshold, and was then told to re-run a search that had already
|
|
410
|
+
// happened. That is the loop this whole gate was filed to break, narrowed but not closed: it needs a
|
|
411
|
+
// large wording difference now rather than a single apostrophe, and it is still reachable.
|
|
412
|
+
//
|
|
413
|
+
// The gate cannot tell the two apart and is not being asked to. There is no identity to join on —
|
|
414
|
+
// that is the entire reason the dictated-versus-recorded comparison exists. So the label states the
|
|
415
|
+
// observation, the remedy carries both cases, and no threshold decides which one a seat is told.
|
|
397
416
|
const parts = dropped.slice(0, 3).map((q) => {
|
|
398
417
|
const n = nearest(q);
|
|
399
418
|
return n
|
|
400
419
|
? `${abbrev(q, 40)} [unmatched; nearest recorded: ${abbrev(n, 40)}]`
|
|
401
|
-
: `${abbrev(q, 40)} [
|
|
420
|
+
: `${abbrev(q, 40)} [no recorded query resembles this one]`;
|
|
402
421
|
});
|
|
403
422
|
return fail(`connotation_query_unrecorded:${parts.join(",")}${dropped.length > 3 ? ` (+${dropped.length - 3} more)` : ""}`);
|
|
404
423
|
}
|
package/mcp-server/CHANGELOG.md
CHANGED
package/mcp-server/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trademark-artifacts-mcp",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2-beta.0",
|
|
4
4
|
"license": "AGPL-3.0-only",
|
|
5
5
|
"private": true,
|
|
6
6
|
"description": "MCP server to interrogate clearotron trademark-clearance runs — list/read artifacts, trace the full decision flow, telemetry/cost, coverage, single-run search, and a gated single-step what-if. Imports the clearotron-driver read-only; touches no driver/template/deploy files.",
|
package/package.json
CHANGED
package/portal-ui/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "portal-ui",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.
|
|
5
|
+
"version": "0.3.2-beta.0",
|
|
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": {
|
|
@@ -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
|
+
}
|