clearotron 0.2.0 → 0.3.0-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/.env.example +52 -0
- package/INSTALL.md +9 -7
- package/README.md +2 -1
- package/bin/example.mjs +97 -32
- package/bin/onboard.mjs +58 -20
- package/bin/start.mjs +7 -0
- package/bin/stop.mjs +65 -3
- package/build-info.json +2 -2
- package/docs/RELEASES.md +6 -4
- package/docs/architecture/04-configuration-reference.md +1 -1
- package/driver/CHANGELOG.md +51 -0
- package/driver/ask-ledger.mjs +69 -1
- package/driver/declination-call.mjs +32 -0
- package/driver/driver.config.mjs +20 -0
- package/driver/engine/mcp/recording-server.mjs +4 -0
- package/driver/gateway.mjs +8 -3
- package/driver/knockout-assess-record.mjs +5 -1
- package/driver/package.json +1 -1
- package/driver/pipeline.mjs +123 -3
- package/driver/predelivery-lint.mjs +23 -5
- package/driver/publish/knockout.mjs +12 -6
- package/driver/publish/render-knockout.mjs +133 -23
- package/driver/publish/report-data.mjs +13 -3
- package/driver/publish/seed-pool.mjs +24 -9
- package/driver/record-carry.mjs +139 -0
- package/driver/reference-score.mjs +53 -3
- package/driver/reference-strip-signatures.mjs +68 -0
- package/driver/register-digest-record.mjs +31 -1
- package/driver/repairs.mjs +1 -1
- package/driver/result-noun-fields.mjs +7 -0
- package/driver/skills/knockout-assess/SKILL.md +10 -4
- package/driver/stages-knockout.mjs +1 -1
- package/driver/stages.mjs +1 -1
- package/driver/suite-census.json +108 -30
- package/driver/unit-inventory.mjs +47 -0
- package/driver/unit-state-verdict.mjs +8 -8
- package/driver/verify-knockout.mjs +9 -1
- package/driver/verify.mjs +2 -2
- package/driver/whatif-memo-run.mjs +45 -4
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/lib/brief.mjs +15 -0
- package/mcp-server/lib/driver.mjs +6 -0
- package/mcp-server/lib/knockout.mjs +435 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +10 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +69 -4
- package/package.json +1 -1
- package/portal-ui/package.json +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +10 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/ai-page-render-check.mjs +2 -1
- package/scripts/clearances-render-check.mjs +2 -1
- package/scripts/drain-preflight.mjs +2 -2
- package/scripts/env-audit.mjs +20 -0
- package/scripts/freeze-example-run.mjs +3 -3
- package/scripts/headless-page.mjs +274 -0
- package/scripts/home-render-check.mjs +2 -1
- package/scripts/live-surface-check.mjs +86 -17
- package/scripts/mint-reference-strip-backlog.mjs +41 -0
- package/scripts/release-await-cut.mjs +95 -7
- package/scripts/release-version-pr-checks.mjs +25 -1
- package/scripts/render-check.mjs +61 -2
- package/scripts/report-frame-check.mjs +12 -0
- package/scripts/report-screenshot.mjs +62 -2
- package/scripts/revisit-render-check.mjs +3 -2
- package/scripts/score.mjs +14 -0
- package/shared/access-audience.mjs +215 -0
- package/shared/tracked-files.mjs +31 -0
- package/scripts/deploy-test.sh +0 -309
|
@@ -21,10 +21,20 @@
|
|
|
21
21
|
// ~60 lines of duplicated document shell, and it is the right price: the alternative is exporting the
|
|
22
22
|
// shell out of the frozen file, which is a bigger break than the duplication saves.
|
|
23
23
|
//
|
|
24
|
-
// ONE report. There is no client variant and no internal variant
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
//
|
|
24
|
+
// ONE report. There is no client variant and no internal variant, and that is
|
|
25
|
+
// still the rule — it is what removes the whole class of "the wrong link got sent".
|
|
26
|
+
//
|
|
27
|
+
// WHAT CHANGED, 2026-09-07 (owner ruling, tracker issue 274): the one report now CARRIES the reviewer's
|
|
28
|
+
// notes rather than routing them to the audit workbook alone. The earlier reading of "one report" was
|
|
29
|
+
// that internal working material is simply not part of it; the ruling is that there is one report and the
|
|
30
|
+
// person who ran Clearotron reads it, so holding material back "just adds confusion for where data is
|
|
31
|
+
// lost". Six notes written for the reviewing lawyer on a delivered run reached the spreadsheet and
|
|
32
|
+
// nothing else, which is the measurement behind the ruling.
|
|
33
|
+
//
|
|
34
|
+
// So: the notes render, LABELLED, in report.css's existing `.internal` purple convention (see
|
|
35
|
+
// reviewerNotesBlock) — never merged into the client-voiced body. The workbook keeps them as well; this
|
|
36
|
+
// added a surface and moved none. The model's `registerEstimate` is NOT covered by the ruling and stays
|
|
37
|
+
// off the page.
|
|
28
38
|
import { readFileSync } from 'node:fs';
|
|
29
39
|
import { join, dirname } from 'node:path';
|
|
30
40
|
import { fileURLToPath } from 'node:url';
|
|
@@ -176,6 +186,14 @@ const KO_CSS = `
|
|
|
176
186
|
.ko-findev{margin:0;font-size:12.5px;line-height:1.6;word-break:break-word}
|
|
177
187
|
.ko-count{font-family:var(--mono);font-size:13px;color:var(--ink);margin:0}
|
|
178
188
|
.ko-degraded{font-size:13px;color:var(--med-tx,#82550A);margin:8px 0 0;font-style:italic}
|
|
189
|
+
/* The reviewer-notes legend. NOT .ko-legend — that name is taken by the framework attribution row
|
|
190
|
+
above, and reusing it would restyle the caption. It names the purple convention report.css already
|
|
191
|
+
draws for .internal, so the colour is stated once and never re-specified. */
|
|
192
|
+
.ko-refnote{margin:0;padding:11px 24px 0;font-size:12px;color:#6a2b6e;font-style:italic}
|
|
193
|
+
/* The notes sit inside a mark's column, so the shared .internal block's bullets keep the column's
|
|
194
|
+
own list indent and do not fight the paragraph above them. */
|
|
195
|
+
.ko-row .internal .ko-bul{margin:0 0 4px}
|
|
196
|
+
.ko-row .internal .ko-bul:last-child{margin-bottom:0}
|
|
179
197
|
.ko-counts table{width:100%;border-collapse:collapse;font-size:14px}
|
|
180
198
|
.ko-counts th{text-align:left;font-size:10.5px;letter-spacing:.06em;text-transform:uppercase;color:var(--faint);
|
|
181
199
|
font-weight:700;padding:14px 24px 8px;border-bottom:1px solid var(--line)}
|
|
@@ -728,9 +746,13 @@ function registerCardViews(mark, framework, registerRecords) {
|
|
|
728
746
|
* is gone; what tells the two apart on the page is where the finding came from, which is the vocabulary
|
|
729
747
|
* the clearance reports already use for exactly this.
|
|
730
748
|
*
|
|
731
|
-
*
|
|
732
|
-
* here
|
|
733
|
-
*
|
|
749
|
+
* THE BAND CHIP IS DRAWN ONLY WHEN THE RATER SENT ONE, and that condition is the whole rule. The old note
|
|
750
|
+
* here said "no band chip and no `bandStop`, ever: a rung of the run's ladder would be a rating nobody
|
|
751
|
+
* performed" — which was true for as long as nothing could carry the rater's rating of an individual
|
|
752
|
+
* filing. `registerReads[].band` now can (tracker issue 274), so the objection is answered where it was
|
|
753
|
+
* raised: with a band, the rating WAS performed, by the seat, in the framework's own words, and the chip
|
|
754
|
+
* states it. With no band the old rule stands unchanged — --faint rail, no chip, nothing claimed — and
|
|
755
|
+
* that is still what every archived run renders.
|
|
734
756
|
*
|
|
735
757
|
* THE LINE UNDER THE STATEMENT DESCRIBES THE CARD, NOT THE RATING. It used to deny
|
|
736
758
|
* that the rating turned on this filing, which this function has no way to know and which a delivered
|
|
@@ -759,24 +781,56 @@ function sourceChips(v) {
|
|
|
759
781
|
return `${commonLaw ? '<span class="src cl">Common law</span>' : ''}${register ? '<span class="src reg">Register</span>' : ''}`;
|
|
760
782
|
}
|
|
761
783
|
|
|
762
|
-
|
|
784
|
+
// ── THE REVIEWER'S NOTES, ON THE REPORT ─────────────────────────────────────────────────
|
|
785
|
+
//
|
|
786
|
+
// OWNER RULING, 2026-09-07: there is one report and the person who ran Clearotron reads it — keeping
|
|
787
|
+
// this material off the page "just adds confusion for where data is lost". This replaces the earlier
|
|
788
|
+
// split under which the notes went to the audit workbook alone (the header of this file and
|
|
789
|
+
// publish/knockout.mjs carried that rule; both now say what replaced it). The workbook keeps them too:
|
|
790
|
+
// this adds a surface, it does not move one.
|
|
791
|
+
//
|
|
792
|
+
// LABELLED, NEVER MERGED. They render in the established `.internal` purple convention report.css
|
|
793
|
+
// already defines — the same one the clearance report uses for this material — so a reader can see whose
|
|
794
|
+
// voice a line is in. Merging them into the client-voiced body would make the reviewer's asides read as
|
|
795
|
+
// findings about the mark, which is the one way this ruling could produce a worse document.
|
|
796
|
+
const REVIEWER_NOTES_LEGEND = 'Information for your reference/context that likely does not need to be shared with the business is shown in purple.';
|
|
797
|
+
|
|
798
|
+
function reviewerNotesBlock(m) {
|
|
799
|
+
const notes = (Array.isArray(m?.purpleNotes) ? m.purpleNotes : [])
|
|
800
|
+
.map((n) => String(n ?? '').trim()).filter(Boolean);
|
|
801
|
+
if (!notes.length) return '';
|
|
802
|
+
return `<div class="internal">
|
|
803
|
+
<span class="tag">For your reference</span>
|
|
804
|
+
${notes.map((n) => `<p class="ko-bul">${inlineMd(n)}</p>`).join('')}
|
|
805
|
+
</div>`;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
function registerFindingBlock(v, markIndex, reads = null, framework = null) {
|
|
763
809
|
const r = v.record;
|
|
764
810
|
// — THE RATER'S OWN READ OF THIS FILING, when it recorded one. `registerReads` is
|
|
765
811
|
// joined to the record store by the validator, so a row that reaches here names a filing this run
|
|
766
812
|
// actually holds. With no row the neutral line stands, and the neutral line is TRUE: it describes the
|
|
767
813
|
// card. That is why the field can be optional without the page ever asserting something false.
|
|
768
|
-
const
|
|
769
|
-
.map((x) => ({ id: String(x?.recordId ?? '').trim(), text: String(x?.read ?? '').trim() }))
|
|
770
|
-
.find((x) => x.id && x.id === String(r?.recordId ?? '').trim())
|
|
814
|
+
const row = (Array.isArray(reads) ? reads : [])
|
|
815
|
+
.map((x) => ({ id: String(x?.recordId ?? '').trim(), text: String(x?.read ?? '').trim(), band: String(x?.band ?? '').trim() }))
|
|
816
|
+
.find((x) => x.id && x.id === String(r?.recordId ?? '').trim());
|
|
817
|
+
const read = row?.text;
|
|
818
|
+
// The band rides the SAME row as the read and is independently optional: a read with no band prints
|
|
819
|
+
// exactly as it did before this field existed. The validator has already checked the word against the
|
|
820
|
+
// frozen ladder, so `bandStop` resolves a real stop rather than falling through to a default that would
|
|
821
|
+
// colour an unknown word as though it were rated.
|
|
822
|
+
const band = row?.band || null;
|
|
823
|
+
const stop = band ? bandStop(framework, band) : null;
|
|
771
824
|
const meta = [r.owner ? esc(r.owner) : 'proprietor not stated', r.territory ? esc(r.territory) : null]
|
|
772
825
|
.filter(Boolean).join(' · ');
|
|
773
826
|
const receipt = isHttpUrl(r.url) ? linkOrText(r.url) : esc(r.recordId ?? 'no record address supplied');
|
|
774
827
|
return `<div class="card ko-find" data-ko-mark="${Number(markIndex)}" data-ko-ord="${Number(v.ordinal)}">
|
|
775
828
|
<div class="top">
|
|
776
|
-
<div class="rail" style="background:var(--faint)"></div>
|
|
829
|
+
<div class="rail" style="background:var(${stop ?? '--faint'})"></div>
|
|
777
830
|
<div class="body">
|
|
778
831
|
<div class="cardhead">
|
|
779
|
-
<span class="fnum">${esc(v.ref)}</span><span class="who">${esc(r.mark ?? 'Unnamed filing')}</span
|
|
832
|
+
<span class="fnum">${esc(v.ref)}</span><span class="who">${esc(r.mark ?? 'Unnamed filing')}</span>${
|
|
833
|
+
band ? `<span class="ko-findband" style="background:var(${stop})">${esc(band)}</span>` : ''}<span class="src reg">Register</span>
|
|
780
834
|
</div>
|
|
781
835
|
<p class="ko-findmeta">${meta}</p>
|
|
782
836
|
<p class="ko-findnet">${esc(v.statement)}</p>
|
|
@@ -925,7 +979,7 @@ function analysisSection(marks, framework, { registerCounts = null, probeRan = f
|
|
|
925
979
|
// under compareKnockoutBlockingPower, so appending them is the ladder's own rule applied to a row
|
|
926
980
|
// that has no rung, not a new one invented for the register.
|
|
927
981
|
const reg = registerCardViews(m, framework, registerRecords);
|
|
928
|
-
const regBlocks = reg.cards.map((v) => registerFindingBlock(v, markIndex, m?.registerReads)).join('');
|
|
982
|
+
const regBlocks = reg.cards.map((v) => registerFindingBlock(v, markIndex, m?.registerReads, framework)).join('');
|
|
929
983
|
const overflow = reg.promoted > reg.cards.length
|
|
930
984
|
? `<p class="ko-bul">${esc(`${reg.promoted - reg.cards.length} further filing${reg.promoted - reg.cards.length === 1 ? '' : 's'} for this name met the same test — every one of them is in the filings section below.`)}</p>`
|
|
931
985
|
: '';
|
|
@@ -950,11 +1004,17 @@ function analysisSection(marks, framework, { registerCounts = null, probeRan = f
|
|
|
950
1004
|
<div>
|
|
951
1005
|
${body || '<p class="ko-bul">No adverse signals recorded for this name on this screen.</p>'}
|
|
952
1006
|
${m.degraded ? `<p class="ko-degraded">${esc(DEGRADED_NOTE)}</p>` : ''}
|
|
1007
|
+
${reviewerNotesBlock(m)}
|
|
953
1008
|
<p class="ko-reg">${esc(registerLine(m, registerCounts, probeRan, registerRecords, reg.cards))}</p>
|
|
954
1009
|
</div>
|
|
955
1010
|
</div>`;
|
|
956
1011
|
}).join('');
|
|
957
|
-
|
|
1012
|
+
// The legend rides the panel and only when a note is actually on it — a standing sentence explaining a
|
|
1013
|
+
// colour no reader can see would be the report describing a convention it did not use.
|
|
1014
|
+
const anyNotes = marks.some((m) => (Array.isArray(m?.purpleNotes) ? m.purpleNotes : [])
|
|
1015
|
+
.some((n) => String(n ?? '').trim()));
|
|
1016
|
+
const legend = anyNotes ? `<p class="ko-refnote">${esc(REVIEWER_NOTES_LEGEND)}</p>` : '';
|
|
1017
|
+
return `<div class="panel ko-glance">${legend}${cards}</div>`;
|
|
958
1018
|
}
|
|
959
1019
|
|
|
960
1020
|
// The method line names EXACTLY what ran, and its wording is doctrine — with counts on the page, "register
|
|
@@ -1324,6 +1384,39 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
|
|
|
1324
1384
|
classesDriving: m.classesDriving ?? [],
|
|
1325
1385
|
degraded: Boolean(m.degraded),
|
|
1326
1386
|
points: m.bullets ?? [],
|
|
1387
|
+
// ── THE ASSESSMENT THE RUN ALREADY WROTE (tracker issue 274) ──────────────────────────────
|
|
1388
|
+
//
|
|
1389
|
+
// Seven keys the assess stage writes into knockout-findings.json reached this file as nothing.
|
|
1390
|
+
// They were not withheld by a rule — no code decided against them; this projection is an explicit
|
|
1391
|
+
// whitelist and nobody had added them, so an assistant drafting from this file could not see the
|
|
1392
|
+
// reasoning behind a rating it was drafting about.
|
|
1393
|
+
//
|
|
1394
|
+
// `assessment` is the sharpest case: publish substitutes it into `batch.executiveSummary` ONLY on
|
|
1395
|
+
// a multi-mark batch, so on a single-mark run — the common knockout — the mark's own opening read
|
|
1396
|
+
// was written, validated, and then dropped by both surfaces at once.
|
|
1397
|
+
//
|
|
1398
|
+
// `purpleNotes` is the reviewer's notes, and it is here by the owner's ruling of 2026-09-07: one
|
|
1399
|
+
// report, read by the person who ran Clearotron, because keeping material off it "just adds
|
|
1400
|
+
// confusion for where data is lost". They are LABELLED on the page rather than merged into the
|
|
1401
|
+
// client-voiced body — see reviewerNotesBlock — so a reader can tell whose voice they are in.
|
|
1402
|
+
assessment: (() => { const t = String(m.assessment ?? '').trim(); return t || null; })(),
|
|
1403
|
+
counterFactors: m.counterFactors ?? [],
|
|
1404
|
+
mitigation: (() => { const t = String(m.mitigation ?? '').trim(); return t || null; })(),
|
|
1405
|
+
// `null`, not `false`, when the rater said nothing: a crowded field is a mitigant the reasoning
|
|
1406
|
+
// turns on, and an unstated one must not read as a stated "no".
|
|
1407
|
+
crowdedField: typeof m.crowdedField === 'boolean' ? m.crowdedField : null,
|
|
1408
|
+
reviewerNotes: m.purpleNotes ?? [],
|
|
1409
|
+
// The proof-of-search rows — the same defensibility record list_searches answers from.
|
|
1410
|
+
negatives: (m.negatives ?? []).map((n) => ({
|
|
1411
|
+
term: n?.term ?? null, source: n?.source ?? null, note: n?.note ?? null,
|
|
1412
|
+
})),
|
|
1413
|
+
// The rater's read of a filing it weighed, keyed by the record id the card prints, so a consumer
|
|
1414
|
+
// can join a read to its filing without matching on prose.
|
|
1415
|
+
registerReads: (m.registerReads ?? []).map((x) => ({
|
|
1416
|
+
recordId: x?.recordId ?? null,
|
|
1417
|
+
read: x?.read ?? null,
|
|
1418
|
+
band: String(x?.band ?? '').trim() || null,
|
|
1419
|
+
})),
|
|
1327
1420
|
// — the typed finding, ranked, with the reference the report prints. The old key was
|
|
1328
1421
|
// `evidence[]` and it was `(m.findings ?? []).filter((f) => f?.url)`: a typed finding has no
|
|
1329
1422
|
// `url`, so this file — the one the assistant drafts client mail from — silently carried zero
|
|
@@ -1345,14 +1438,31 @@ export function knockoutReportData(findings, framework, { runId, codename, overa
|
|
|
1345
1438
|
// A promoted filing the brief omitted would be the report and the brief disagreeing about
|
|
1346
1439
|
// what the run found.
|
|
1347
1440
|
//
|
|
1348
|
-
// `
|
|
1349
|
-
//
|
|
1350
|
-
//
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1441
|
+
// `shape: 'register'` is the load-bearing field: a consumer can tell a weighed conflict from a
|
|
1442
|
+
// pointed-at filing without parsing prose.
|
|
1443
|
+
//
|
|
1444
|
+
// `band` AND `basis` NOW CARRY THE RATER'S READ WHEN IT SENT ONE (tracker issue 274). Before
|
|
1445
|
+
// this, both were constants — `band: null` and the not-weighed sentence — on every filing of
|
|
1446
|
+
// every run, including runs where the seat had written a full read of that exact filing and the
|
|
1447
|
+
// HTML card was already printing it. The page and this file disagreed, and this file is the one
|
|
1448
|
+
// the MCP brief answers from, so the surface a reader asks "what did you make of that
|
|
1449
|
+
// registration?" was the surface that had dropped the answer.
|
|
1450
|
+
//
|
|
1451
|
+
// The unmatched case is unchanged and must stay that way: no row for this recordId ⇒ `band:
|
|
1452
|
+
// null` and the not-weighed line, which describes the card and claims nothing.
|
|
1453
|
+
...registerCardViews(m, framework, registerRecords).cards.map((v) => {
|
|
1454
|
+
const row = (Array.isArray(m?.registerReads) ? m.registerReads : [])
|
|
1455
|
+
.find((x) => String(x?.recordId ?? '').trim() && String(x?.recordId ?? '').trim() === String(v.record?.recordId ?? '').trim());
|
|
1456
|
+
const read = String(row?.read ?? '').trim() || null;
|
|
1457
|
+
const band = String(row?.band ?? '').trim() || null;
|
|
1458
|
+
return {
|
|
1459
|
+
ref: v.ref, ordinal: v.ordinal, name: v.record.mark, owner: v.record.owner, band,
|
|
1460
|
+
type: 'Register filing',
|
|
1461
|
+
net: read ? v.statement : `${v.statement} It was ${NOT_WEIGHED}.`,
|
|
1462
|
+
basis: read ?? NOT_WEIGHED,
|
|
1463
|
+
evidence: v.evidence, shape: 'register',
|
|
1464
|
+
};
|
|
1465
|
+
}),
|
|
1356
1466
|
],
|
|
1357
1467
|
registerCounts: counted?.counts
|
|
1358
1468
|
? {
|
|
@@ -4,9 +4,19 @@
|
|
|
4
4
|
// report-data.json since the one-report collapse; the clearance lane's native-render branch existed in
|
|
5
5
|
// the portal with NO producer anywhere (B3's "the aspirational path") — this is that producer.
|
|
6
6
|
//
|
|
7
|
-
// Two readers, one file (the knockout doctrine,
|
|
8
|
-
// it, and the portal's native-render path reads it instead of iframing baked HTML.
|
|
9
|
-
//
|
|
7
|
+
// Two readers, one file (the knockout doctrine, as it stood): the assistant drafts client-facing mail
|
|
8
|
+
// from it, and the portal's native-render path reads it instead of iframing baked HTML.
|
|
9
|
+
//
|
|
10
|
+
// THE TWO LANES DIVERGED ON 2026-09-07 AND THIS FILE DID NOT MOVE. The owner ruled (tracker issue 274)
|
|
11
|
+
// that the KNOCKOUT report carries the reviewer's notes, on the ground that one report is read by the
|
|
12
|
+
// person who ran Clearotron and holding material back confuses where data went; render-knockout.mjs
|
|
13
|
+
// projects them under `reviewerNotes`. That ruling was about those notes on that lane. It did not touch
|
|
14
|
+
// what this producer strips — withdrawn findings, verification receipts, `::p::` tails, `[internal]`
|
|
15
|
+
// labels and engine-internal narration are all still cut here, and the guard test still polices them.
|
|
16
|
+
// Do not read the knockout change as licence to relax this list; if the ruling is meant to extend, it
|
|
17
|
+
// extends by being asked, not by inference.
|
|
18
|
+
//
|
|
19
|
+
// It remains the CLIENT CUT by construction, not by serve-time stripping:
|
|
10
20
|
// • only LIVE findings (a withdrawn finding renders nowhere — it does not exist here either);
|
|
11
21
|
// • no verification receipts (use_check / own_rights / bears_on — those are the reviewer's evidence
|
|
12
22
|
// trail and live in the audit workbook), no withdrawn_reason, no quarantine records;
|
|
@@ -105,12 +105,20 @@ export function frozenSamples(examplesDir) {
|
|
|
105
105
|
*/
|
|
106
106
|
export async function seedPool({ pool, examplesDir, republish }) {
|
|
107
107
|
const existing = poolRunIds(pool);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
// ── A POOL THAT ALREADY HOLDS SOMETHING IS TOPPED UP, NOT SKIPPED (tracker issue 277) ──────────────
|
|
109
|
+
//
|
|
110
|
+
// This used to return early on any non-empty pool. That was invisible while `demo/` shipped one child:
|
|
111
|
+
// seeding one and seeding all were the same act. When the other three landed, every box seeded before
|
|
112
|
+
// that day kept its single demo through every upgrade, because the pool was no longer empty and this
|
|
113
|
+
// returned without looking at what the package now carried.
|
|
114
|
+
//
|
|
115
|
+
// DEMOS ARE PACKAGE CONTENT, NOT USER DATA, so the package's set is the one that should be there. What
|
|
116
|
+
// is already published is left exactly as it is — this adds what is missing and removes nothing, so a
|
|
117
|
+
// pool holding a run this package does not ship keeps it.
|
|
118
|
+
//
|
|
119
|
+
// AND IT IS ONLY EVER A DEMO POOL. `bin/start.mjs` calls this inside its `--demo` branch alone; a real
|
|
120
|
+
// install is told its archive is empty and pointed at the demo command. Nothing here can put an example
|
|
121
|
+
// into a pool holding a customer's work, and that gate is the reason this can top up safely at all.
|
|
114
122
|
const { samples, problems } = frozenSamples(examplesDir);
|
|
115
123
|
if (!samples.length) {
|
|
116
124
|
// The empty archive is now the SYMPTOM of something, and this is where the something is named.
|
|
@@ -118,11 +126,13 @@ export async function seedPool({ pool, examplesDir, republish }) {
|
|
|
118
126
|
}
|
|
119
127
|
|
|
120
128
|
const seeded = [];
|
|
129
|
+
const already = [];
|
|
121
130
|
const failures = [];
|
|
122
131
|
for (const s of samples) {
|
|
123
|
-
//
|
|
124
|
-
// but a sample list carrying the same runId twice would otherwise overwrite silently.
|
|
132
|
+
// A sample list carrying the same runId twice would otherwise overwrite silently.
|
|
125
133
|
if (seeded.includes(s.meta.runId)) { failures.push(`${s.name}: runId ${s.meta.runId} appears twice under ${examplesDir}`); continue; }
|
|
134
|
+
// Already published: left alone, and NAMED. Silence here is what the old early return produced.
|
|
135
|
+
if (existing.includes(s.meta.runId)) { already.push(s.meta.runId); continue; }
|
|
126
136
|
try {
|
|
127
137
|
// poolUrl "" for the same reason the demo passes it: the report's link block addresses a
|
|
128
138
|
// deployment that serves the pool at a public URL, and this one is served from this machine.
|
|
@@ -133,5 +143,10 @@ export async function seedPool({ pool, examplesDir, republish }) {
|
|
|
133
143
|
failures.push(`${s.name}: ${String(e?.message ?? e)}`);
|
|
134
144
|
}
|
|
135
145
|
}
|
|
136
|
-
|
|
146
|
+
// `skipped` still carries a sentence when there was nothing to add, because "seeded 0" and "seeded 0
|
|
147
|
+
// BECAUSE all four were already here" are the same number and different facts.
|
|
148
|
+
const skipped = !seeded.length && already.length
|
|
149
|
+
? `the pool already holds all ${already.length} example(s) this package ships`
|
|
150
|
+
: null;
|
|
151
|
+
return { seeded, already, skipped, problems: [...problems, ...failures] };
|
|
137
152
|
}
|
package/driver/record-carry.mjs
CHANGED
|
@@ -825,3 +825,142 @@ export function silentlyLostFindings({ reconciliation = null, carryRows = null,
|
|
|
825
825
|
population_empty: false, cross_checked: crossChecked,
|
|
826
826
|
checked: ended.length, matched: seen.length, lost };
|
|
827
827
|
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Positions the DIGEST ended as findings that did not reach the findings, dropped WITH a stated reason.
|
|
831
|
+
*
|
|
832
|
+
* ── THE SIBLING'S BLIND SPOT, AND IT IS THE ONE THAT REACHED A CLIENT (tracker issue 248) ──────────
|
|
833
|
+
*
|
|
834
|
+
* `silentlyLostFindings` above is correct and must not be widened to cover this. Its population is
|
|
835
|
+
* `step-silent` — a finding-ending followed by silence — and its own header records why that boundary
|
|
836
|
+
* exists: silent drops are the norm (690 of 741 records on the evidence run), so a rule flagging them
|
|
837
|
+
* broadly would flag almost everything, and the defect it targets is the CONJUNCTION of silence after a
|
|
838
|
+
* finding-ending.
|
|
839
|
+
*
|
|
840
|
+
* It also anticipated this gap in writing: "nine divergences from a digest finding-ending, every one of
|
|
841
|
+
* them `step-stated`". Nine of the shape nothing checked.
|
|
842
|
+
*
|
|
843
|
+
* MEASURED ON A DELIVERED R2 RUN, 2026-09-06. The sibling ran and reported
|
|
844
|
+
* `{checked:5, matched:5, lost:0}` — correctly. On that same delivery two marks from the lawyer's final
|
|
845
|
+
* list, `OSLER DELPHI` and `DELFITY`, one rated HIGH, are absent from `findings.json`. They were dropped
|
|
846
|
+
* WITH a reason, so they sat outside the sibling's population by design:
|
|
847
|
+
*
|
|
848
|
+
* IMMATERIAL ask:recall:recall-osler-delphi: … — OSLER DELPHI / Osler Diagnostics Limited is
|
|
849
|
+
* already reasoned on the incumbent sheet in register-findings.md.
|
|
850
|
+
*
|
|
851
|
+
* WHY THE STATED CASE IS THE MORE DANGEROUS ONE. A silent drop leaves a hole. A stated drop leaves a
|
|
852
|
+
* SENTENCE, and the sentence reads as diligence. On that one delivery `doubt-closure.md` carries 92
|
|
853
|
+
* recall asks and 66 rulings of IMMATERIAL. A drop with a reason nobody verifies is not accounted for;
|
|
854
|
+
* it is unexamined with a paper trail.
|
|
855
|
+
*
|
|
856
|
+
* `step-structural` is deliberately NOT in this population: it is a mechanical screen verdict
|
|
857
|
+
* (`the in-line record screen returned "…"`), not a judgment sentence a reader would take on trust.
|
|
858
|
+
* `absent` belongs to the sibling's family, not this one.
|
|
859
|
+
*
|
|
860
|
+
* WHAT THIS DOES NOT DECIDE. Whether any given stated reason is RIGHT. That is a change to what the
|
|
861
|
+
* client receives and is the owner's call; this makes the class visible, which is worth having whichever
|
|
862
|
+
* way that lands, because today nobody would know the closures happened.
|
|
863
|
+
*
|
|
864
|
+
* Same contract as the sibling, deliberately: `computable:false` with a named reason rather than a clean
|
|
865
|
+
* `[]`; `population_empty` as its own state; `cross_checked` so a caller can tell "could not look" from
|
|
866
|
+
* "looked and found nothing"; and `matched` returned so a caller can insist the join actually joined —
|
|
867
|
+
* that field exists because a case-sensitive URI join once matched zero rows on every run and read as
|
|
868
|
+
* zero divergences. PURE.
|
|
869
|
+
*/
|
|
870
|
+
export function statedDivergenceFindings({ reconciliation = null, carryRows = null, digestFindingUris = null } = {}) {
|
|
871
|
+
const no = (reason, crossChecked = false) => ({ computable: false, reason, population_empty: false,
|
|
872
|
+
cross_checked: crossChecked, checked: 0, matched: 0, diverged: [] });
|
|
873
|
+
if (!reconciliation || reconciliation.computable !== true) {
|
|
874
|
+
return no("no computable recall-reconciliation — the digest's own endings are the population and there is none");
|
|
875
|
+
}
|
|
876
|
+
if (!Array.isArray(carryRows)) {
|
|
877
|
+
return no("no record-carry rows — the knockout lane writes none, so this join cannot look at that product");
|
|
878
|
+
}
|
|
879
|
+
// ── THE POPULATION IS THE CARRY ROWS, NOT THE RECONCILIATION (corrected 2026-09-07) ─────────────
|
|
880
|
+
//
|
|
881
|
+
// The first cut of this function gated on the reconciliation's finding-ended positions, mirroring the
|
|
882
|
+
// sibling. That inherited the sibling's BLIND SPOT along with its shape, and the check was inert on
|
|
883
|
+
// the very delivery it was written for. Replayed against that delivery:
|
|
884
|
+
//
|
|
885
|
+
// silentlyLostFindings checked=5 matched=5 lost=0
|
|
886
|
+
// statedDivergenceFindings checked=5 matched=5 diverged=0 ← should have named two marks
|
|
887
|
+
//
|
|
888
|
+
// The reconciliation names five finding-ended positions and they are five OTHER marks — DELPHIS
|
|
889
|
+
// bioenergetische Kosmetik, DELPHIC HSE, DELPHI, DELPHIN & EMERENCE, DELPHI DIAGNOSTICS. The two that
|
|
890
|
+
// were lost sit in the CARRY rows and the reconciliation never mentions them:
|
|
891
|
+
//
|
|
892
|
+
// OSLER DELPHI reach=placed stopped_at=digest reason_source=step-stated reason=digest:reasoned-negative
|
|
893
|
+
// DELFITY reach=placed stopped_at=digest reason_source=step-stated reason=digest:reasoned-negative
|
|
894
|
+
//
|
|
895
|
+
// The unit arms all passed because their fixtures put the mark in BOTH populations, which the real run
|
|
896
|
+
// does not. That is the lesson worth keeping: a fixture that satisfies two joins at once cannot tell
|
|
897
|
+
// you the joins disagree.
|
|
898
|
+
//
|
|
899
|
+
// So the carry rows are the population — they are where a stated drop is RECORDED — and the
|
|
900
|
+
// reconciliation is demoted to optional corroboration. Measured on that delivery, the correct
|
|
901
|
+
// population is 70 rows over 34 distinct marks, all `digest:reasoned-negative`, and it contains both.
|
|
902
|
+
const arrived = new Set(["finding", "findings-surface"]);
|
|
903
|
+
const population = carryRows.filter((r) => r?.uri
|
|
904
|
+
&& r.reason_source === "step-stated" // the sibling owns step-silent; step-structural is mechanical
|
|
905
|
+
&& !arrived.has(r.reach)); // arrived, or arrived somewhere visible, is not a divergence
|
|
906
|
+
|
|
907
|
+
// Kept for corroboration only. `ended` no longer gates anything; where the reconciliation DOES name a
|
|
908
|
+
// position it agrees with, that is recorded on the row so a reader can weigh it.
|
|
909
|
+
const ended = [];
|
|
910
|
+
for (const bucket of ["top_slice", "residual"]) {
|
|
911
|
+
for (const row of reconciliation[bucket] ?? []) {
|
|
912
|
+
if (row?.ending !== "finding") continue;
|
|
913
|
+
for (const uri of row.position_records ?? []) ended.push({ uri: lc(uri), mark: row.mark_text ?? null });
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
const endedUris = new Set(ended.map((e) => e.uri));
|
|
917
|
+
const byUri = new Map();
|
|
918
|
+
for (const r of carryRows) if (r?.uri) byUri.set(lc(r.uri), r);
|
|
919
|
+
|
|
920
|
+
// The disjoint-population guard, for the sibling's reason: overlap is the signal, a shortfall is not.
|
|
921
|
+
if (Array.isArray(digestFindingUris) && digestFindingUris.length && ended.length) {
|
|
922
|
+
const digest = new Set(digestFindingUris.map(lc));
|
|
923
|
+
if (!ended.some((e) => digest.has(e.uri))) {
|
|
924
|
+
return no(`the reconciliation's ${ended.length} finding-ended position(s) share NOTHING with the `
|
|
925
|
+
+ `${digest.size} finding row(s) the digest's own typed calls recorded — the two populations are `
|
|
926
|
+
+ "disjoint, so this join is examining a different set and its answer cannot be trusted", true);
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
// A ZERO POPULATION IS ITS OWN STATE. No stated drop recorded is a real answer on a healthy run, and
|
|
930
|
+
// it must not be reported in the same shape as "there were some and none diverged".
|
|
931
|
+
if (!population.length) {
|
|
932
|
+
return { computable: true, reason: "no record-carry row records a stated drop on this run — there is "
|
|
933
|
+
+ "no population here, which is the healthy answer and not a comparison that found nothing",
|
|
934
|
+
population_empty: true, cross_checked: false, checked: 0, matched: 0, diverged: [] };
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
const diverged = [];
|
|
938
|
+
for (const row of population) {
|
|
939
|
+
const e = { uri: lc(row.uri), mark: row.mark ?? null };
|
|
940
|
+
// NAME THE ARTIFACT THE REASON POINTS AT. The defect this check exists for is an absence discharged
|
|
941
|
+
// by the WRONG artifact — "already reasoned in register-findings.md" answers a question nobody asked,
|
|
942
|
+
// because the ask was about the findings. Surfacing the cited artifact is what lets a reader see the
|
|
943
|
+
// substitution rather than read the sentence as diligence.
|
|
944
|
+
const reason = row.reason ?? null;
|
|
945
|
+
const cites = typeof reason === "string" ? (reason.match(/[a-z0-9._-]+\.(?:md|json)\b/gi) ?? []) : [];
|
|
946
|
+
diverged.push({ uri: e.uri, mark: e.mark ?? row.mark ?? null, reach: row.reach ?? null,
|
|
947
|
+
stopped_at: row.stopped_at ?? null, reason,
|
|
948
|
+
cites_artifact: cites.length ? [...new Set(cites.map(String))] : null,
|
|
949
|
+
// CORROBORATION, NOT A GATE. The reconciliation naming this position is worth a reader knowing;
|
|
950
|
+
// its SILENCE is not evidence of anything, which is exactly what the first cut got wrong.
|
|
951
|
+
reconciliation_agrees: endedUris.has(e.uri),
|
|
952
|
+
why: "this position was dropped with a stated reason and never reached the findings, and the reason "
|
|
953
|
+
+ "given points at a different artifact than the one the absence is about" });
|
|
954
|
+
}
|
|
955
|
+
const crossChecked = Array.isArray(digestFindingUris) && digestFindingUris.length > 0;
|
|
956
|
+
return { computable: true,
|
|
957
|
+
reason: crossChecked ? null
|
|
958
|
+
: "no cross-check was possible — this run recorded no typed digest finding rows, so the "
|
|
959
|
+
+ "population was not verified against an independent one",
|
|
960
|
+
population_empty: false, cross_checked: crossChecked,
|
|
961
|
+
// `checked` is the population this check actually walked, and `matched` how many of them the
|
|
962
|
+
// reconciliation ALSO named. On the delivery this was written for those are 70 and 0 — which is the
|
|
963
|
+
// whole point: a `matched` of zero used to mean "report nothing" and now means "the reconciliation
|
|
964
|
+
// saw none of them", a fact about the reconciliation rather than about the run.
|
|
965
|
+
checked: population.length, matched: diverged.filter((d) => d.reconciliation_agrees).length, diverged };
|
|
966
|
+
}
|
|
@@ -667,14 +667,64 @@ export function scoreRecall({ reference, findings = [], retrieved = [], scopeCla
|
|
|
667
667
|
// what it saw. Auto-promoting a collision to `found` would be the scorer manufacturing recall from
|
|
668
668
|
// its own confusion, which is the defect one layer up from the one being fixed. score.mjs prints
|
|
669
669
|
// these; a reader adjudicates.
|
|
670
|
+
// ── THE PREDICATE WAS THE OWNER, AND THE HEADING ABOVE SAYS RECORD (tracker issue 249) ──────────
|
|
671
|
+
//
|
|
672
|
+
// The only condition used to be `ownersMatch`. That implements a different class from the one the
|
|
673
|
+
// paragraph above states, and the justification — "they cannot both be true" — does not hold for any
|
|
674
|
+
// proprietor with more than one mark. A large filer can perfectly well have one mark the run withheld
|
|
675
|
+
// and a DIFFERENT mark, not in the reference, that it surfaced. Both rows are true.
|
|
676
|
+
//
|
|
677
|
+
// It fired on a delivered R2 run: `<large filer>: reference "DELFITY" is withheld, surfaced "DELPHINA"
|
|
678
|
+
// is noise`. Different marks, different records, one proprietor that files a great many. And because
|
|
679
|
+
// score.mjs prints a collision as "do not read the recall numbers above", ONE such proprietor
|
|
680
|
+
// suppressed the whole run's recall measurement — a real 88% → 63% movement went unquoted on the
|
|
681
|
+
// regression issue because of a warning that was spurious.
|
|
682
|
+
//
|
|
683
|
+
// WHY NOT A RECORD JOIN, WHICH IS WHAT THE ISSUE ASKED FOR. Measured on the delivered artifact: a
|
|
684
|
+
// finding carries `{band, disposition, mark, meters, net, ordinal, owner, quadrant, source, …}` and
|
|
685
|
+
// NO record identity — `source.resolved_link` is empty on every row. Noise rows are built from
|
|
686
|
+
// findings, so there is nothing on that side to join a record URI to. Stating it here so the next
|
|
687
|
+
// reader does not re-derive it: the acceptance criterion is unmeetable on this side until a finding
|
|
688
|
+
// carries its record, and that is a change to the findings contract, not to the scorer.
|
|
689
|
+
//
|
|
690
|
+
// WHY NOT `matchesReference`, WHICH IS THE FILE'S OWN MARK MATCHER. It would make this check VACUOUS.
|
|
691
|
+
// The noise loop above already skips any finding that matches a reference entry, so by construction
|
|
692
|
+
// no noise row matches one — the collision would always be empty. This check exists precisely to
|
|
693
|
+
// catch the case where THE MATCHER DISAGREED WITH ITSELF, so it cannot be built out of the matcher.
|
|
694
|
+
//
|
|
695
|
+
// So the predicate is deliberately weaker than the matcher and stronger than the owner: same owner
|
|
696
|
+
// AND one mark contained in the other once normalised. That is the shape of the case this check was
|
|
697
|
+
// built for — `DELPHI GENETICS` in LOST beside `DG DELPHI GENETICS` in NOISE — and it is not the
|
|
698
|
+
// shape of `DELFITY` beside `DELPHINA`.
|
|
699
|
+
const collisionKey = (s) => String(s ?? "").normalize("NFKC").toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
700
|
+
// A FLOOR, because containment on a short string matches everything. `DEL` inside `DELPHINA` is not
|
|
701
|
+
// evidence of a shared record; four characters is the shortest reference mark shape worth trusting
|
|
702
|
+
// here, and a pair below it drops to the advisory rather than being dropped entirely.
|
|
703
|
+
const CONTAIN_FLOOR = 4;
|
|
670
704
|
buckets.collisions = [];
|
|
705
|
+
buckets.ownerEchoes = [];
|
|
671
706
|
for (const bucket of ["lost", "withheld"]) {
|
|
672
707
|
for (const e of buckets[bucket]) {
|
|
673
708
|
for (const n of buckets.noise) {
|
|
674
709
|
if (!ownersMatch(e.owner, n.owner)) continue;
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
710
|
+
const a = collisionKey(e.mark ?? e.name);
|
|
711
|
+
const b = collisionKey(n.mark);
|
|
712
|
+
const row = { bucket, entry: e.mark ?? e.name ?? null, noise: n.mark,
|
|
713
|
+
owner: ownerName(n.owner) ?? ownerName(e.owner) };
|
|
714
|
+
const sameRecord = a && b
|
|
715
|
+
&& Math.min(a.length, b.length) >= CONTAIN_FLOOR
|
|
716
|
+
&& (a === b || a.includes(b) || b.includes(a));
|
|
717
|
+
if (sameRecord) {
|
|
718
|
+
buckets.collisions.push({ ...row,
|
|
719
|
+
why: `the reference entry is reported ${bucket} while a surfaced record of the SAME owner whose `
|
|
720
|
+
+ "mark contains or is contained by it is reported noise — these are one record in two buckets" });
|
|
721
|
+
} else {
|
|
722
|
+
// REPORTABLE, NEVER SUPPRESSING. A reader may still want to see that a proprietor appears on
|
|
723
|
+
// both sides; what they must not be told is that the recall numbers are unreadable.
|
|
724
|
+
buckets.ownerEchoes.push({ ...row,
|
|
725
|
+
why: `same proprietor on both sides with different marks — not a contradiction: a filer may hold `
|
|
726
|
+
+ `a reference mark this run ${bucket} and another, outside the reference, that it surfaced` });
|
|
727
|
+
}
|
|
678
728
|
}
|
|
679
729
|
}
|
|
680
730
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
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
|
+
// THE TWO SIGNATURES THE REFERENCE STRIP LEFT BEHIND (tracker issue 185).
|
|
5
|
+
//
|
|
6
|
+
// The strip's job was to remove internal references from this repository before it went public, and it
|
|
7
|
+
// did that. Where the reference was the SUBJECT of the sentence, it took the subject with it:
|
|
8
|
+
//
|
|
9
|
+
// "[ref]'s design ruling — above any fold, only a statement…" became "'s design ruling — …"
|
|
10
|
+
// "renders the pre-[ref] section" became "renders the pre- section"
|
|
11
|
+
//
|
|
12
|
+
// Neither construction occurs in written English, which is the whole reason they can be counted rather
|
|
13
|
+
// than judged. A reader of a public repository meets them as sentences that do not finish, and one of
|
|
14
|
+
// them is in user-facing configuration documentation rather than in a comment.
|
|
15
|
+
//
|
|
16
|
+
// WHAT THIS MODULE IS AND IS NOT. It finds them. It does not repair them: the repair is per-sentence and
|
|
17
|
+
// needs somebody reading the surrounding code, because "the pre- section" means "the section as it was
|
|
18
|
+
// before the findings contract changed" and only that reader can say so. The finding is mechanical and
|
|
19
|
+
// costs nothing, so it is the part that ships as a check.
|
|
20
|
+
export const SIGNATURES = [
|
|
21
|
+
{
|
|
22
|
+
name: "a comment beginning with a bare possessive",
|
|
23
|
+
// Anchored at the comment leader, so `it's` and `the run's` mid-sentence are untouched — only the
|
|
24
|
+
// case where the possessive has nothing in front of it to possess.
|
|
25
|
+
re: /^\s*(?:\/\/|#|\*)\s*'s\b/,
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "`pre- ` followed by a lowercase word",
|
|
29
|
+
// The hyphen is left dangling by a stripped number. `pre-flight` and `pre-delivery` do not match:
|
|
30
|
+
// the strip's residue always leaves whitespace after the hyphen.
|
|
31
|
+
re: /\bpre-\s+[a-z]/,
|
|
32
|
+
},
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
// THE RULE'S OWN DEFINITION IS THE ONLY EXEMPTION, and it is named rather than pattern-matched.
|
|
36
|
+
//
|
|
37
|
+
// These three files QUOTE the residue in order to define it: the specimens in the arm, the examples in
|
|
38
|
+
// the header above. Scanning them counts the definition as an instance, which puts the guard's own text
|
|
39
|
+
// into the backlog it polices and makes every re-mint grow the number it exists to shrink. Caught by a
|
|
40
|
+
// plant, not by review — they were untracked when the table was first minted, so `git ls-files` did not
|
|
41
|
+
// list them and the census read a tree that did not include them yet.
|
|
42
|
+
export const RULE_DEFINITIONS = [
|
|
43
|
+
"driver/reference-strip-signatures.mjs",
|
|
44
|
+
"driver/test/the-reference-strip-left-sentences-unfinished.test.mjs",
|
|
45
|
+
"scripts/mint-reference-strip-backlog.mjs",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
/** Files worth scanning: prose-bearing, tracked, not generated, and not this rule's own definition. */
|
|
49
|
+
export const isScannable = (f) =>
|
|
50
|
+
/\.(mjs|md|yml|ts)$/.test(f) && !f.startsWith("portal-ui/dist/") && !RULE_DEFINITIONS.includes(f);
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Count both signatures per file across `files`.
|
|
54
|
+
* `read` is injected so an arm can drive this over a synthetic tree — a census helper that can only
|
|
55
|
+
* read the real repository cannot be planted, and a plant is the only thing that proves it still counts.
|
|
56
|
+
*/
|
|
57
|
+
export function censusOf(root, files, read) {
|
|
58
|
+
const out = {};
|
|
59
|
+
let total = 0;
|
|
60
|
+
for (const f of files.filter(isScannable)) {
|
|
61
|
+
let text;
|
|
62
|
+
try { text = read(f); } catch { continue; } // binary or unreadable — nothing to count
|
|
63
|
+
const counts = SIGNATURES.map((s) => text.split("\n").filter((l) => s.re.test(l)).length);
|
|
64
|
+
if (counts.some((n) => n > 0)) { out[f] = counts; total += counts.reduce((a, b) => a + b, 0); }
|
|
65
|
+
}
|
|
66
|
+
// Sorted, so a re-mint produces a reviewable diff instead of a reordered file.
|
|
67
|
+
return { total, files: Object.fromEntries(Object.entries(out).sort(([a], [b]) => a < b ? -1 : 1)) };
|
|
68
|
+
}
|