clearotron 0.3.2-beta.8 → 0.3.2-beta.9
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 +34 -3
- package/CONTRIBUTING.md +8 -8
- package/INSTALL.md +6 -6
- package/SECURITY.md +3 -3
- package/bin/brandowner.mjs +3 -3
- package/bin/framework-preflight.mjs +1 -1
- package/bin/start.mjs +18 -4
- package/build-info.json +2 -2
- package/docs/DELIVERY.md +2 -1
- package/docs/INTAKE.md +1 -1
- package/docs/ONBOARDING.md +1 -1
- package/docs/architecture/03-run-lifecycle.md +6 -6
- package/docs/architecture/04-configuration-reference.md +6 -3
- package/docs/architecture/05-config-governance.md +6 -1
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/architecture/06-operations-runbook.md +3 -3
- package/docs/architecture/08-development-guide.md +6 -6
- package/docs/configuration.md +5 -5
- package/docs/decisions/0003-credential-model.md +1 -1
- package/docs/writing-standard.md +4 -0
- package/driver/CHANGELOG.md +48 -0
- package/driver/README.md +3 -3
- package/driver/binding-layers.mjs +1 -1
- package/driver/citation-census.json +3 -3
- package/driver/{prelim-variants-record.mjs → clearance-variants-record.mjs} +24 -24
- package/driver/common-law-receipts.mjs +2 -2
- package/driver/company-bundle.mjs +3 -3
- package/driver/compose-read.mjs +8 -14
- package/driver/consumption-ledger.mjs +2 -2
- package/driver/contract-arm2-baseline.json +2 -3
- package/driver/contract-dictation-registry.mjs +19 -19
- package/driver/contract-e3-backlog.mjs +19 -19
- package/driver/contract-e3-baseline.json +14 -14
- package/driver/contract-vocabulary.mjs +24 -17
- package/driver/deliver-trigger.sh +16 -16
- package/driver/demo-container.mjs +3 -3
- package/driver/dev-portal.mjs +3 -3
- package/driver/disposition-call.mjs +1 -1
- package/driver/doubt-ledger.mjs +2 -2
- package/driver/drainer-identity.mjs +34 -8
- package/driver/driver.config.mjs +95 -45
- package/driver/engine/mcp/README.md +1 -1
- package/driver/engine/mcp/dispositions-server.mjs +3 -3
- package/driver/engine/mcp/gather-config.mjs +9 -9
- package/driver/engine/mcp/perplexity-server.mjs +2 -2
- package/driver/engine/mcp/recording-server.mjs +5 -5
- package/driver/enqueue-schema.mjs +6 -2
- package/driver/findings-model.mjs +5 -2
- package/driver/flag-snapshot.mjs +6 -3
- package/driver/form-neighbourhood.mjs +54 -7
- package/driver/framework.mjs +4 -4
- package/driver/gateway.mjs +12 -6
- package/driver/jx-lanes.mjs +2 -2
- package/driver/jx-units.mjs +1 -1
- package/driver/jx.mjs +30 -2
- package/driver/knockout-review-record.mjs +56 -4
- package/driver/known-conflicts.mjs +1 -1
- package/driver/named-band.mjs +1 -33
- package/driver/ordinary-words.mjs +51 -0
- package/driver/outbox-backoff.mjs +31 -16
- package/driver/package.json +1 -1
- package/driver/partial-payload-baseline.json +2 -2
- package/driver/phase0.mjs +3 -3
- package/driver/pipeline-knockout.mjs +5 -5
- package/driver/pipeline.mjs +206 -68
- package/driver/placement-form.mjs +77 -1
- package/driver/placement-model.mjs +1 -1
- package/driver/portal-report.mjs +92 -5
- package/driver/portal-service.mjs +34 -8
- package/driver/portal-upstream.mjs +1 -1
- package/driver/preserve-merge.mjs +3 -3
- package/driver/product-rows.mjs +2 -2
- package/driver/products.mjs +1 -1
- package/driver/profiles/README.md +3 -3
- package/driver/profiles/demo-brand-owner.json +2 -2
- package/driver/profiles.mjs +55 -17
- package/driver/progress.mjs +18 -8
- package/driver/provider-usage.mjs +8 -8
- package/driver/publish/index.mjs +110 -5
- package/driver/publish/knockout.mjs +29 -4
- package/driver/publish/pool-admin.mjs +1 -1
- package/driver/publish/publish-inputs.mjs +18 -2
- package/driver/publish/render-knockout.mjs +115 -24
- package/driver/publish/render.mjs +153 -34
- package/driver/publish/search-depth.mjs +133 -4
- package/driver/publish/templates/report.css +60 -3
- package/driver/publish/xlsx.mjs +8 -1
- package/driver/queue-order.mjs +2 -2
- package/driver/recording-agreement.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/register-count.mjs +50 -5
- package/driver/register-coverage.mjs +67 -0
- package/driver/register-grant-vocabulary.mjs +1 -1
- package/driver/register-plan.mjs +19 -2
- package/driver/registry-fidelity.mjs +3 -3
- package/driver/repair-composers.mjs +1 -1
- package/driver/repair-contract.mjs +1 -1
- package/driver/replay-archive.mjs +6 -6
- package/driver/report-overview-record.mjs +2 -2
- package/driver/run-requirements.mjs +3 -3
- package/driver/runner.mjs +2 -2
- package/driver/scope-facts.mjs +20 -5
- package/driver/scope-ledger.mjs +5 -5
- package/driver/search-policy.mjs +22 -12
- package/driver/skills/README.md +15 -15
- package/driver/skills/blind-frame/SKILL.md +2 -2
- package/driver/skills/case-law-citation/SKILL.md +4 -4
- package/driver/skills/case-law-citation/sources/eurlex.md +1 -1
- package/driver/skills/{prelim-common-law → clearance-common-law}/SKILL.md +22 -22
- package/driver/skills/{prelim-common-law → clearance-common-law}/perplexity-prompts.md +1 -1
- package/driver/skills/{prelim-register → clearance-register}/SKILL.md +10 -10
- package/driver/skills/{prelim-register → clearance-register}/digest.md +2 -2
- package/driver/skills/{prelim-register → clearance-register}/providers/README.md +1 -1
- package/driver/skills/{prelim-register → clearance-register}/providers/clarivate.md +37 -35
- package/driver/skills/{prelim-register → clearance-register}/providers/corsearch.md +20 -11
- package/driver/skills/{prelim-register → clearance-register}/providers/signa.md +5 -5
- package/driver/skills/{prelim-register → clearance-register}/register-recipes.md +3 -3
- package/driver/skills/{prelim-register → clearance-register}/status-rules.md +2 -2
- package/driver/skills/{prelim-register → clearance-register}/stealth-filer-indicators.md +1 -1
- package/driver/skills/{prelim-register → clearance-register}/unit.md +2 -2
- package/driver/skills/{prelim-search → clearance-search}/SKILL.md +31 -31
- package/driver/skills/{prelim-search → clearance-search}/delivery-contract.md +1 -1
- package/driver/skills/{prelim-search → clearance-search}/phase2-execution.md +18 -18
- package/driver/skills/{prelim-search → clearance-search}/synthesis-rules.md +7 -7
- package/driver/skills/{prelim-variants → clearance-variants}/SKILL.md +18 -18
- package/driver/skills/{prelim-variants → clearance-variants}/transliteration-scripts.md +5 -5
- package/driver/skills/frame-diff/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +10 -7
- package/driver/skills/matter-frame/SKILL.md +3 -3
- package/driver/skills/narrative-refutation/SKILL.md +9 -9
- package/driver/skills/placement-inquiry/SKILL.md +5 -5
- package/driver/stage-context.mjs +1 -1
- package/driver/stages-knockout.mjs +4 -4
- package/driver/stages.mjs +53 -53
- package/driver/status-snapshot.mjs +2 -2
- package/driver/suite-census.json +218 -92
- package/driver/surface-exit-verdict.mjs +58 -0
- package/driver/systemd/README.md +2 -2
- package/driver/systemd/clearotron-worker.service +1 -1
- package/driver/terminal-clamp.mjs +2 -0
- package/driver/usage-ledger.mjs +1 -1
- package/driver/variant-manifest-model.mjs +4 -4
- package/driver/verify-knockout.mjs +27 -0
- package/driver/verify.mjs +67 -6
- package/driver/whatif-queue.mjs +1 -1
- package/driver/wordlists/en.txt +63906 -0
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/README.md +1 -1
- package/mcp-server/lib/README.md +1 -1
- package/mcp-server/lib/options.mjs +8 -7
- package/mcp-server/lib/plan.mjs +18 -2
- package/mcp-server/lib/runs.mjs +1 -1
- package/mcp-server/lib/usage.mjs +3 -3
- package/mcp-server/lib/whatif.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/mcp-server/server.mjs +3 -0
- package/package.json +12 -11
- package/portal-ui/dist/assets/{index-CVOIvdhc.css → index-CtvwLCti.css} +207 -3
- package/portal-ui/dist/assets/{index-6jzO9HiX.js → index-EVaSo5-g.js} +1459 -482
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/README.md +1 -1
- package/providers/_shared/enumerate.mjs +6 -6
- package/providers/_shared/execute-plan.mjs +3 -3
- package/providers/_shared/ledger.mjs +119 -5
- package/providers/_shared/provider-text.mjs +2 -2
- package/providers/_shared/screen.mjs +2 -2
- package/providers/_shared/script-form.mjs +3 -3
- package/providers/_shared/territory-codes.mjs +23 -3
- package/providers/clarivate/README.md +1 -1
- package/providers/clarivate/src/capabilities.js +12 -12
- package/providers/clarivate/src/core.js +37 -43
- package/providers/corsearch/README.md +1 -1
- package/providers/corsearch/src/capabilities.js +5 -5
- package/providers/corsearch/src/core.js +3 -3
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/perplexity/src/core.js +1 -1
- package/providers/signa/README.md +1 -1
- package/providers/signa/src/capabilities.js +42 -49
- package/providers/signa/src/core.js +106 -29
- package/providers/uspto-local/src/sync.js +1 -1
- package/scripts/README.md +1 -0
- package/scripts/ask-ai-render-check.mjs +127 -1
- package/scripts/authority-boundary-probe.mjs +4 -4
- package/scripts/backfill-started-at.mjs +2 -2
- package/scripts/census-merge-driver.mjs +33 -2
- package/scripts/citation-anchor-report.mjs +181 -0
- package/scripts/dead-names.mjs +1 -1
- package/scripts/deprecate-below.mjs +66 -8
- package/scripts/drain-preflight.mjs +1 -1
- package/scripts/e2e.mjs +174 -0
- package/scripts/env-audit.mjs +27 -0
- package/scripts/env-classify.mjs +20 -2
- package/scripts/freeze-example-run.mjs +20 -10
- package/scripts/live-surface-check.mjs +124 -41
- package/scripts/markdown-link-check.mjs +1 -1
- package/scripts/merge-shape-check.mjs +242 -0
- package/scripts/mint-names-in-force.mjs +5 -5
- package/scripts/mint-offered-territories.mjs +72 -0
- package/scripts/mint-public-residue.mjs +2 -2
- package/scripts/mint-reference-strip-backlog.mjs +2 -2
- package/scripts/mint-suite-census.mjs +75 -2
- package/scripts/mint-writing-standard-backlog.mjs +2 -2
- package/scripts/purge-runs.mjs +7 -7
- package/scripts/reconcile-runs.mjs +2 -2
- package/scripts/release-approve-parked.mjs +20 -2
- package/scripts/release-await-cut.mjs +120 -1
- package/scripts/release-note-required.mjs +76 -8
- package/scripts/report-header-render-check.mjs +164 -0
- package/shared/brand.mjs +27 -0
- package/shared/connect-clients.mjs +39 -11
- package/shared/env-aliases.mjs +1 -1
- package/shared/identifier-scan.mjs +65 -9
- package/shared/identifier-sentinels.mjs +22 -0
- package/shared/names-in-force.mjs +3 -1
- package/shared/offered-territories.json +738 -0
- package/shared/pre-rename-spellings.mjs +53 -0
- package/shared/reference-guard-classes.mjs +40 -2
- package/shared/stdio-connect.mjs +39 -4
- package/shared/tree-commit.mjs +48 -0
- /package/driver/skills/{prelim-register → clearance-register}/providers/euipo.md +0 -0
- /package/driver/skills/{prelim-register → clearance-register}/providers/free-tier.md +0 -0
- /package/driver/skills/{prelim-register → clearance-register}/providers/uspto-local.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/field-doctrine-pharma.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/firm-wide-reasoning.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/report-prose.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.manifest.json +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-demo.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.manifest.json +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework-triage.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework.manifest.json +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/risk-framework.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/template-formatting.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/templates/email/generic.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/templates/search-request-form.html +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/worked-examples-demo.md +0 -0
- /package/driver/skills/{prelim-search → clearance-search}/worked-examples.md +0 -0
|
@@ -67,6 +67,23 @@
|
|
|
67
67
|
// the releases page is built from `.changeset/*.md`, and a line in a commit message reaches no reader. A
|
|
68
68
|
// line may instead NAME the note that answers for it, `Release-note: .changeset/<name>.md`, and that is read
|
|
69
69
|
// as an answer, provided the range adds that note.
|
|
70
|
+
//
|
|
71
|
+
// ── A NOTE THE RANGE WITHDRAWS, AND SAYS WHY, IS AN ANSWER ────────────────────────────────────────────
|
|
72
|
+
//
|
|
73
|
+
// A ruling took a beta-only change back out, and the commit that removed it removed the note that
|
|
74
|
+
// announced it too, so the stable changelog would not promise a ceiling the stable release does not have.
|
|
75
|
+
// That deletion is right, and it left two earlier commits naming a note the range no longer carries:
|
|
76
|
+
// refused, and unfixable, because the only fix the check offered was editing commits already pushed.
|
|
77
|
+
//
|
|
78
|
+
// So a note that a LATER commit in the same range DELETES counts as consumed for the commits that added
|
|
79
|
+
// or named it — provided the deleting commit says why, in its own `Release-note: none — <reason>` or in a
|
|
80
|
+
// note of its own. The reason is asked of the DELETING commit because it is the newest of the two, the
|
|
81
|
+
// only one that can still be amended, and the one that knows why the reader now hears nothing. Said, not
|
|
82
|
+
// silent: every commit excused this way is printed with the sha that withdrew its note and that reason.
|
|
83
|
+
//
|
|
84
|
+
// A deletion that gives no reason still refuses, and names the deleting commit as where the reason goes.
|
|
85
|
+
// The move a pre-release makes — `.changeset/<name>.md` deleted and `.changeset/pre/<name>.md` added in
|
|
86
|
+
// one commit — is not a withdrawal: the note still reaches a reader, and a version commit is that shape.
|
|
70
87
|
import { execFileSync } from "node:child_process";
|
|
71
88
|
import { readFileSync } from "node:fs";
|
|
72
89
|
import { join, dirname } from "node:path";
|
|
@@ -143,12 +160,14 @@ const shipped = (paths, files) => paths.filter((p) => !NEVER_A_NOTE.some((re) =>
|
|
|
143
160
|
* PURE. What each commit in a range owes, decided per commit (see the header).
|
|
144
161
|
*
|
|
145
162
|
* @param {object} o
|
|
146
|
-
* @param {Array<{sha:string, subject:string, message:string, paths:string[], added?:string[]}>} o.commits
|
|
147
|
-
* oldest first; `added`
|
|
163
|
+
* @param {Array<{sha:string, subject:string, message:string, paths:string[], added?:string[], deleted?:string[]}>} o.commits
|
|
164
|
+
* non-merge, oldest first; `added` and `deleted` are the paths the commit adds and removes, when the caller read them
|
|
148
165
|
* @param {string[]} o.files the package's own `files` list
|
|
149
166
|
* @param {string[]|null} [o.atHead] every path the head carries under `.changeset/`, when the caller read it
|
|
150
167
|
* @returns {{visible:string[], notes:string[], declined:Array<{sha:string, subject:string, reason:string}>,
|
|
151
|
-
*
|
|
168
|
+
* withdrawals:Array<{sha:string, subject:string, note:string, by:string, reason:string}>,
|
|
169
|
+
* owed:Array<{sha:string, subject:string, why:"no-note"|"prose"|"bare-none"|"names-a-missing-note"|"note-withdrawn-unsaid",
|
|
170
|
+
* paths?:string[], text?:string, by?:string}>}}
|
|
152
171
|
*/
|
|
153
172
|
export function commitVerdicts({ commits = [], files = [], atHead = null } = {}) {
|
|
154
173
|
// `added` and `atHead` are what the command reads from git; a caller that passes neither keeps the older
|
|
@@ -172,8 +191,34 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
|
|
|
172
191
|
const notes = [...new Set(commits.flatMap(notesOf))];
|
|
173
192
|
const noteNames = new Set(notes.map((p) => p.split("/").pop()));
|
|
174
193
|
const visible = [...new Set(commits.flatMap((c) => shipped(c.paths, files)))];
|
|
175
|
-
const
|
|
176
|
-
|
|
194
|
+
const nameOf = (p) => p.split("/").pop();
|
|
195
|
+
// WHICH NOTES THIS RANGE TAKES BACK, AND WHETHER IT SAYS WHY. A commit that deletes a note path and
|
|
196
|
+
// adds one of the same name is MOVING it — what a pre-release cut does to every note it consumes — and
|
|
197
|
+
// a move is not a withdrawal. A name the head still carries was never withdrawn at all.
|
|
198
|
+
//
|
|
199
|
+
// THE DELETION THAT ANSWERS IS THE LAST ONE, because a note can go and come back. This note was
|
|
200
|
+
// written, consumed into `.changeset/pre/` by a beta cut, brought back at its old path by a merge of a
|
|
201
|
+
// main the cut had already taken it from, dropped again as a duplicate, and only then removed under the
|
|
202
|
+
// ruling. Taking the first deletion would have printed the duplicate sweep's reason — "removes
|
|
203
|
+
// duplicates of notes that already shipped" — against a commit the ruling is what excuses. Measured on
|
|
204
|
+
// the range, 2026-09-18.
|
|
205
|
+
const withdrawn = new Map();
|
|
206
|
+
commits.forEach((c, at) => {
|
|
207
|
+
const readded = new Set((c.added ?? []).filter(isNotePath).map(nameOf));
|
|
208
|
+
for (const p of (c.deleted ?? []).filter(isNotePath)) {
|
|
209
|
+
const name = nameOf(p);
|
|
210
|
+
if (readded.has(name)) continue;
|
|
211
|
+
const own = notesOf(c).map(nameOf);
|
|
212
|
+
const reason = NO_NOTE.exec(String(c.message ?? ""))?.groups?.reason
|
|
213
|
+
?? (own.length ? `the note ${own[0]} it wrote in its place` : null);
|
|
214
|
+
withdrawn.set(name, { sha: c.sha, subject: c.subject, reason, at });
|
|
215
|
+
}
|
|
216
|
+
for (const name of readded) withdrawn.delete(name); // a later copy puts the note back in the range
|
|
217
|
+
});
|
|
218
|
+
// AND A NAME THE HEAD STILL CARRIES WAS NEVER WITHDRAWN AT ALL: the release assembles what the head has.
|
|
219
|
+
if (presentNames) for (const name of [...withdrawn.keys()]) if (presentNames.has(name)) withdrawn.delete(name);
|
|
220
|
+
const declined = [], owed = [], withdrawals = [];
|
|
221
|
+
for (const [i, c] of commits.entries()) {
|
|
177
222
|
const ships = shipped(c.paths, files);
|
|
178
223
|
if (!ships.length) continue; // nothing a reader could see in this commit
|
|
179
224
|
const addsNote = notesOf(c).length > 0;
|
|
@@ -189,13 +234,24 @@ export function commitVerdicts({ commits = [], files = [], atHead = null } = {})
|
|
|
189
234
|
continue;
|
|
190
235
|
}
|
|
191
236
|
if (addsNote) continue; // it carries its own note
|
|
237
|
+
// THE NOTE THIS COMMIT ANSWERED WITH, TAKEN BACK BY A LATER ONE. Only a later commit: a name deleted
|
|
238
|
+
// before this commit named it was never this commit's answer.
|
|
239
|
+
const addedNames = [...new Set((c.added ?? c.paths).filter(isNotePath).map(nameOf))];
|
|
240
|
+
const takenBack = [...new Set([...addedNames, ...named])]
|
|
241
|
+
.map((n) => [n, withdrawn.get(n)]).filter(([, w]) => w && w.at > i);
|
|
242
|
+
const said = takenBack.find(([, w]) => w.reason);
|
|
243
|
+
if (said) { withdrawals.push({ ...at, note: said[0], by: said[1].sha, reason: said[1].reason }); continue; }
|
|
244
|
+
if (takenBack.length) {
|
|
245
|
+
owed.push({ ...at, why: "note-withdrawn-unsaid", text: takenBack[0][0], by: takenBack[0][1].sha });
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
192
248
|
const missing = named.filter((n) => !noteNames.has(n));
|
|
193
249
|
if (missing.length) { owed.push({ ...at, why: "names-a-missing-note", text: missing[0] }); continue; }
|
|
194
250
|
if (named.length) continue; // it names a note the range adds
|
|
195
251
|
if (notes.length) continue; // a note in the range answers for it
|
|
196
252
|
owed.push({ ...at, why: "no-note", paths: ships });
|
|
197
253
|
}
|
|
198
|
-
return { visible, notes, declined, owed };
|
|
254
|
+
return { visible, notes, declined, withdrawals, owed };
|
|
199
255
|
}
|
|
200
256
|
|
|
201
257
|
const argAfter = (flag) => {
|
|
@@ -232,6 +288,7 @@ function main() {
|
|
|
232
288
|
message: git("log", "-1", "--format=%B", sha),
|
|
233
289
|
paths: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", sha).split("\n").filter(Boolean),
|
|
234
290
|
added: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "--diff-filter=A", sha).split("\n").filter(Boolean),
|
|
291
|
+
deleted: git("diff-tree", "--no-commit-id", "--name-only", "-r", "--root", "--diff-filter=D", sha).split("\n").filter(Boolean),
|
|
235
292
|
}));
|
|
236
293
|
atHead = git("ls-tree", "-r", "--name-only", head, "--", ".changeset/").split("\n").filter(Boolean);
|
|
237
294
|
} catch (e) {
|
|
@@ -245,13 +302,19 @@ function main() {
|
|
|
245
302
|
catch (e) { console.error(`release-note-required: cannot read the shipped file list: ${e.message}`); process.exit(2); }
|
|
246
303
|
if (!files.length) { console.error("release-note-required: package.json names no shipped files, so this cannot look"); process.exit(2); }
|
|
247
304
|
|
|
248
|
-
const { visible, notes, declined, owed } = commitVerdicts({ commits, files, atHead });
|
|
305
|
+
const { visible, notes, declined, withdrawals, owed } = commitVerdicts({ commits, files, atHead });
|
|
249
306
|
console.log(`release-note-required: ${changed.length} changed file(s) against ${base}`
|
|
250
307
|
+ `${head === "HEAD" ? "" : ` (head ${head})`}; `
|
|
251
|
-
+ `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`
|
|
308
|
+
+ `${visible.length} ship as code; ${notes.length} release note(s) in the range; ${commits.length} commit(s) read`
|
|
309
|
+
+ `${withdrawals.length ? `; ${withdrawals.length} answered by a note the range withdrew` : ""}`);
|
|
252
310
|
// PER COMMIT, SAID AS PER COMMIT. "no note, declared on purpose" read as a verdict on the range, and beside
|
|
253
311
|
// a range that carries a note it told a reader skimming the output that none went out. Found in review.
|
|
254
312
|
for (const d of declined) console.log(` ${d.sha.slice(0, 7)} declines a note of its own, on purpose: ${d.reason}`);
|
|
313
|
+
// AN EXCUSED COMMIT IS PRINTED, WITH THE SHA THAT EXCUSED IT. A pass this check reached by reading
|
|
314
|
+
// another commit's decision is one a person may want to disagree with, and they cannot if it is silent.
|
|
315
|
+
for (const w of withdrawals) {
|
|
316
|
+
console.log(` ${w.sha.slice(0, 7)} answered with ${w.note}, which ${w.by.slice(0, 7)} withdrew: ${w.reason}`);
|
|
317
|
+
}
|
|
255
318
|
if (!owed.length) return;
|
|
256
319
|
|
|
257
320
|
console.error(`\n${owed.length} commit(s) that ship as code owe a release note:\n`);
|
|
@@ -267,6 +330,11 @@ function main() {
|
|
|
267
330
|
} else if (o.why === "bare-none") {
|
|
268
331
|
console.error(" says `Release-note: none` and gives no reason. The reason is the whole point of the"
|
|
269
332
|
+ "\n line — it is what a later reader uses to tell a considered decision from a skipped step.");
|
|
333
|
+
} else if (o.why === "note-withdrawn-unsaid") {
|
|
334
|
+
console.error(` answered with ${o.text}, which ${o.by.slice(0, 7)} deletes in this range without saying why.`
|
|
335
|
+
+ "\n A note the head does not carry reaches no reader. If the change it described went out of the"
|
|
336
|
+
+ "\n release too, say that in THAT commit — `Release-note: none — <why>` — which is the newest of"
|
|
337
|
+
+ "\n the two and the one that can still be amended.");
|
|
270
338
|
} else {
|
|
271
339
|
console.error(` names ${o.text} as its note, and this range adds no note by that name.`);
|
|
272
340
|
}
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
// report-header-render-check.mjs — the delivered document's own header, measured in a browser.
|
|
4
|
+
//
|
|
5
|
+
// node scripts/report-header-render-check.mjs [--keep]
|
|
6
|
+
//
|
|
7
|
+
// Two properties, both of which a reader met as defects on 2026-09-18 and neither of which any unit test
|
|
8
|
+
// can see, because every one of them asserts on the HTML STRING and both of these are layout:
|
|
9
|
+
//
|
|
10
|
+
// 1. ONE HEADER. The section breadcrumb is a row INSIDE `.rep-stickyhead`, and when the page is
|
|
11
|
+
// scrolled the header is still at the top of the viewport with the breadcrumb inside it. It used to
|
|
12
|
+
// be a sibling pinned at `top:var(--tb-h,52px)` — a variable this product sets nowhere — so it sat
|
|
13
|
+
// at a guessed offset, showed content through the slit between the two bars, and slid under the
|
|
14
|
+
// header whenever the guess was wrong.
|
|
15
|
+
// 2. THE BAND PILL CLEARS ITS HEADING. The pill hangs above the ladder and the heading sits above the
|
|
16
|
+
// pill, and at the LEFTMOST band the pill is drawn flush left — directly under "Overall risk". They
|
|
17
|
+
// overlapped, which is what a reader meets on a knockout whose band is the bottom rung.
|
|
18
|
+
//
|
|
19
|
+
// Driven at every rung of the ladder, not only the one a fixture happens to carry: the overlap is a
|
|
20
|
+
// property of the leftmost position, so a check that renders one band proves nothing about the band the
|
|
21
|
+
// next report lands on.
|
|
22
|
+
import { spawn } from 'node:child_process'
|
|
23
|
+
import { createServer } from 'node:http'
|
|
24
|
+
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'
|
|
25
|
+
import { join } from 'node:path'
|
|
26
|
+
import { tmpdir } from 'node:os'
|
|
27
|
+
import { reapOnExit } from '../shared/reap-on-exit.mjs'
|
|
28
|
+
import { browserRun } from '../shared/browser-temp-root.mjs'
|
|
29
|
+
import { renderKnockoutHtml } from '../driver/publish/render-knockout.mjs'
|
|
30
|
+
|
|
31
|
+
const keep = process.argv.includes('--keep')
|
|
32
|
+
const WIDTH = 1280
|
|
33
|
+
|
|
34
|
+
// The ladder the house triage framework ships, so the rungs are the product's own and not invented here.
|
|
35
|
+
const FRAMEWORK = {
|
|
36
|
+
framework_key: 'house-triage',
|
|
37
|
+
title: 'House triage',
|
|
38
|
+
bands: [{ label: 'Blocking', tone: 'severe' }, { label: 'Medium', tone: 'medium' },
|
|
39
|
+
{ label: 'Manageable', tone: 'low' }, { label: 'Low', tone: 'minimal' }],
|
|
40
|
+
}
|
|
41
|
+
const BANDS = FRAMEWORK.bands.map((b) => b.label)
|
|
42
|
+
|
|
43
|
+
// A mark with the STRUCTURE the gauge card needs — a basis and factors are what make `readBlock` draw the
|
|
44
|
+
// card at all. The words are fixture words; nothing here is a finding.
|
|
45
|
+
const markAt = (band) => ({
|
|
46
|
+
name: 'EXAMPLEMARK',
|
|
47
|
+
rating: band,
|
|
48
|
+
classes: [9],
|
|
49
|
+
basis: 'One identical name in the same trade, and two that contain it.',
|
|
50
|
+
factors: ['An identical name is registered in class 9.', 'The trade is the same.'],
|
|
51
|
+
counterFactors: ['The register counts do not say who is using what.'],
|
|
52
|
+
mitigation: 'A narrower specification would move this.',
|
|
53
|
+
assessment: '## What the screen found\n\nOne identical name.\n\n## What happens next\n\nTake it to clearance.',
|
|
54
|
+
findings: [],
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
const docFor = (band) => renderKnockoutHtml(
|
|
58
|
+
{ marks: [markAt(band)], batch: { executiveSummary: 'One name screened.' } },
|
|
59
|
+
FRAMEWORK,
|
|
60
|
+
{ runId: 'fixture', overall: band, identity: { identity: 'Knockout search' }, issuedDate: '2026-09-18' },
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
const dir = mkdtempSync(join(tmpdir(), 'report-header-check-'))
|
|
64
|
+
for (const band of BANDS) writeFileSync(join(dir, `${band.toLowerCase()}.html`), docFor(band))
|
|
65
|
+
|
|
66
|
+
const server = createServer((req, res) => {
|
|
67
|
+
const name = decodeURIComponent((req.url ?? '/').split('?')[0]).replace(/^\//, '')
|
|
68
|
+
try {
|
|
69
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
|
|
70
|
+
res.end(docFor(BANDS.find((b) => b.toLowerCase() === name.replace(/\.html$/, '')) ?? BANDS[0]))
|
|
71
|
+
} catch { res.writeHead(404); res.end('no') }
|
|
72
|
+
})
|
|
73
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r))
|
|
74
|
+
const origin = `http://127.0.0.1:${server.address().port}`
|
|
75
|
+
|
|
76
|
+
const { profile, env: chromeEnv, keep: keepRoot } = browserRun('report-header-check-')
|
|
77
|
+
if (keep) keepRoot()
|
|
78
|
+
const chrome = spawn('google-chrome', [
|
|
79
|
+
'--headless=new', '--disable-gpu', '--no-sandbox', '--hide-scrollbars',
|
|
80
|
+
'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1',
|
|
81
|
+
`--user-data-dir=${profile}`, `--window-size=${WIDTH},900`, '--remote-debugging-port=0', 'about:blank',
|
|
82
|
+
], { stdio: ['ignore', 'ignore', 'pipe'], detached: true, env: chromeEnv })
|
|
83
|
+
reapOnExit(chrome)
|
|
84
|
+
|
|
85
|
+
const wsUrl = await new Promise((resolve, reject) => {
|
|
86
|
+
let devtools = ''
|
|
87
|
+
const t = setTimeout(() => reject(new Error(`chrome reported no devtools endpoint in 60s:\n${devtools || '(nothing)'}`)), 60000)
|
|
88
|
+
chrome.stderr.on('data', (c) => { devtools += c; const m = devtools.match(/ws:\/\/[^\s]+/); if (m) { clearTimeout(t); resolve(m[0]) } })
|
|
89
|
+
})
|
|
90
|
+
const ws = new WebSocket(wsUrl)
|
|
91
|
+
let id = 0
|
|
92
|
+
const pending = new Map()
|
|
93
|
+
ws.addEventListener('message', (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { pending.get(m.id)(m); pending.delete(m.id) } })
|
|
94
|
+
await new Promise((r) => ws.addEventListener('open', r))
|
|
95
|
+
const send = (method, params = {}) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, method, params })) })
|
|
96
|
+
const { result: targets } = await send('Target.getTargets')
|
|
97
|
+
const page = targets.targetInfos.find((t) => t.type === 'page')
|
|
98
|
+
const { result: sess } = await send('Target.attachToTarget', { targetId: page.targetId, flatten: true })
|
|
99
|
+
const sessionId = sess.sessionId
|
|
100
|
+
const cmd = (method, params) => new Promise((r) => { const i = ++id; pending.set(i, r); ws.send(JSON.stringify({ id: i, sessionId, method, params })) })
|
|
101
|
+
const value = async (expr) => (await cmd('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true })).result?.result?.value ?? null
|
|
102
|
+
|
|
103
|
+
// MEASURED OFF RECTANGLES, never off a class name: a rule that is present and overridden and a rule that
|
|
104
|
+
// works look identical to a selector, and the whole subject here is which one is true.
|
|
105
|
+
const PROBE = `(async () => {
|
|
106
|
+
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
|
|
107
|
+
window.scrollTo(0, 0); await sleep(60);
|
|
108
|
+
const head = document.querySelector('.rep-stickyhead');
|
|
109
|
+
const strip = document.querySelector('nav.strip');
|
|
110
|
+
const label = document.querySelector('.ko-gauge .label');
|
|
111
|
+
const pill = document.querySelector('.kpill');
|
|
112
|
+
if (!head || !strip) return { drawn: false, head: !!head, strip: !!strip };
|
|
113
|
+
const r = (el) => { const b = el.getBoundingClientRect(); return { top: b.top, bottom: b.bottom, left: b.left, height: b.height }; };
|
|
114
|
+
const out = {
|
|
115
|
+
drawn: true,
|
|
116
|
+
inside: head.contains(strip),
|
|
117
|
+
labelled: (strip.textContent || '').replace(/\\s+/g, ' ').trim(),
|
|
118
|
+
pill: pill ? r(pill) : null,
|
|
119
|
+
label: label ? r(label) : null,
|
|
120
|
+
pillText: pill ? (pill.textContent || '').trim() : null,
|
|
121
|
+
};
|
|
122
|
+
// Scroll well past the header and read again: what stays is what a reader keeps.
|
|
123
|
+
window.scrollTo(0, 1200); await sleep(120);
|
|
124
|
+
out.scrolled = { head: r(head), strip: r(strip), scrollY: window.scrollY };
|
|
125
|
+
return out;
|
|
126
|
+
})()`
|
|
127
|
+
|
|
128
|
+
const fail = []
|
|
129
|
+
const ok = []
|
|
130
|
+
for (const band of BANDS) {
|
|
131
|
+
await cmd('Page.navigate', { url: `${origin}/${band.toLowerCase()}.html` })
|
|
132
|
+
await new Promise((r) => setTimeout(r, 700))
|
|
133
|
+
const m = await value(PROBE)
|
|
134
|
+
const where = `band ${band}`
|
|
135
|
+
if (!m || !m.drawn) { fail.push(`${where}: the document drew no header or no breadcrumb (${JSON.stringify(m)})`); continue }
|
|
136
|
+
|
|
137
|
+
// 1 — one header
|
|
138
|
+
if (!m.inside) fail.push(`${where}: the breadcrumb is not inside .rep-stickyhead`)
|
|
139
|
+
else ok.push(`${where}: breadcrumb inside the header — "${m.labelled}"`)
|
|
140
|
+
|
|
141
|
+
const s = m.scrolled
|
|
142
|
+
if (!(s.scrollY > 300)) fail.push(`${where}: the page did not scroll (scrollY ${s.scrollY}) — the pin was not tested`)
|
|
143
|
+
else if (Math.abs(s.head.top) > 1) fail.push(`${where}: the header did not pin (top ${s.head.top.toFixed(1)} at scrollY ${s.scrollY})`)
|
|
144
|
+
else if (s.strip.top < s.head.top - 1 || s.strip.bottom > s.head.bottom + 1)
|
|
145
|
+
fail.push(`${where}: the breadcrumb left the header under scroll (strip ${s.strip.top.toFixed(1)}–${s.strip.bottom.toFixed(1)}, header ${s.head.top.toFixed(1)}–${s.head.bottom.toFixed(1)})`)
|
|
146
|
+
else ok.push(`${where}: header pinned at 0 with the breadcrumb inside it at scrollY ${s.scrollY}`)
|
|
147
|
+
|
|
148
|
+
// 2 — the band pill clears its heading
|
|
149
|
+
if (!m.pill || !m.label) fail.push(`${where}: the band card drew no pill or no heading`)
|
|
150
|
+
else if (m.pill.top < m.label.bottom)
|
|
151
|
+
fail.push(`${where}: the "${m.pillText}" pill overlaps the heading by ${(m.label.bottom - m.pill.top).toFixed(1)}px`)
|
|
152
|
+
else ok.push(`${where}: the "${m.pillText}" pill clears the heading by ${(m.pill.top - m.label.bottom).toFixed(1)}px`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const line of ok) console.log(` ok ${line}`)
|
|
156
|
+
for (const line of fail) console.log(` FAIL ${line}`)
|
|
157
|
+
try { ws.close() } catch { /* the browser is going anyway */ }
|
|
158
|
+
try { process.kill(-chrome.pid, 'SIGKILL') } catch { /* already gone */ }
|
|
159
|
+
server.close()
|
|
160
|
+
if (!keep) rmSync(dir, { recursive: true, force: true })
|
|
161
|
+
console.log(fail.length
|
|
162
|
+
? `\nreport-header-render-check: ${fail.length} measurement(s) failed.`
|
|
163
|
+
: `\nreport-header-render-check: ${ok.length} measurements, all clear.`)
|
|
164
|
+
process.exit(fail.length ? 1 : 0)
|
package/shared/brand.mjs
CHANGED
|
@@ -174,6 +174,33 @@ export const ADMINISTRATOR_CONTACT = administratorContact(process.env.CLEAROTRON
|
|
|
174
174
|
// at all. Absent still means the default, never nothing.
|
|
175
175
|
export const CONF_DEFAULT = 'Privileged & Confidential';
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* THE APPROVED BOARDS' SECTION STRIP, filtered against the document it will sit in.
|
|
179
|
+
*
|
|
180
|
+
* Both report renderers draw one and each has its own board: the clearance kinds five entries, the
|
|
181
|
+
* knockout four, and the knockout's third points at the filings section while reading "Also considered".
|
|
182
|
+
* So the ENTRIES belong to each board and the RULE belongs here, because the rule is the part that is
|
|
183
|
+
* easy to get wrong the same way twice.
|
|
184
|
+
*
|
|
185
|
+
* The rule: a board is a specimen where every section exists, and a renderer draws several of them only
|
|
186
|
+
* when there is something to draw. A strip emitted whole would point a reader at anchors that resolve to
|
|
187
|
+
* nothing, which is worse than the missing strip it replaces. So it is composed from the FINISHED html by
|
|
188
|
+
* asking which anchors are in it — the document decides, and a section that stops being drawn takes its
|
|
189
|
+
* own entry with it without anyone remembering to.
|
|
190
|
+
*
|
|
191
|
+
* ONE ENTRY IS NOT A NAVIGATION. A strip pointing only at the top of the page is a control that does
|
|
192
|
+
* nothing, so below two entries it is not drawn at all.
|
|
193
|
+
*
|
|
194
|
+
* @param {string} html the assembled document
|
|
195
|
+
* @param {ReadonlyArray<readonly [string, string]>} entries [anchor id, label], in the board's order
|
|
196
|
+
*/
|
|
197
|
+
export function sectionStrip(html, entries) {
|
|
198
|
+
const live = (entries ?? []).filter(([id]) => String(html).includes(`id="${id}"`));
|
|
199
|
+
if (live.length < 2) return '';
|
|
200
|
+
return `<nav class="strip no-print">${live.map(([id, label], i) =>
|
|
201
|
+
`<a href="#${id}" data-sec="${id}"${i === 0 ? ' class="now"' : ''}><i></i>${label}</a>`).join('')}</nav>`;
|
|
202
|
+
}
|
|
203
|
+
|
|
177
204
|
export function confPosture(delivery) {
|
|
178
205
|
const p = delivery == null ? null : delivery.privileged;
|
|
179
206
|
return p === false ? '' : CONF_DEFAULT;
|
|
@@ -398,15 +398,40 @@ export const leadRouteFor = (id) => {
|
|
|
398
398
|
* invitation to paste it inside WSL, where it does not work. Somebody took the invitation from Claude
|
|
399
399
|
* Code inside a distribution and got CONNECTION_CLOSED (measured 2026-09-16 on 0.3.2-beta.1).
|
|
400
400
|
*
|
|
401
|
-
*
|
|
402
|
-
*
|
|
403
|
-
*
|
|
404
|
-
*
|
|
401
|
+
* AND NOW IT SAYS ONLY THAT THE INSTALL IS IN WSL, because the rows below it say the rest.
|
|
402
|
+
*
|
|
403
|
+
* While there was one launcher, this step carried which side it was for and that the other side could
|
|
404
|
+
* not use it. Both sides are now offered, each headed with the side it is for, and those two sentences
|
|
405
|
+
* turned false where they stand: the reader is told an assistant inside the distribution cannot connect,
|
|
406
|
+
* directly above the row that connects it. What is left is the clause that is still true and still
|
|
407
|
+
* needed — the rows name Linux paths and the reader has to know why. The rest is DELETED rather than
|
|
408
|
+
* reworded, because the headings already say it and nothing here is ours to write.
|
|
405
409
|
*/
|
|
406
|
-
export const WSL_STEP = "This install runs inside WSL
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
+
export const WSL_STEP = "This install runs inside WSL.";
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* ONE ROW PER SIDE, where a launcher has two.
|
|
414
|
+
*
|
|
415
|
+
* `stdioConnectFor` answers a WSL install with both launchers under `variants`, headed in the product's
|
|
416
|
+
* own words. Each becomes a row of its own here, carrying its side's heading as the row's line and its
|
|
417
|
+
* side's command as the row's copy — so the reader sees which command is theirs before they press it,
|
|
418
|
+
* and does not paste the Windows one into a terminal already inside the distribution.
|
|
419
|
+
*
|
|
420
|
+
* The heading takes the row's line rather than a slot of its own: every surface that draws a step draws
|
|
421
|
+
* that line already — the page above the copy block, the terminal beside the number — so both sides get
|
|
422
|
+
* the headings from one author and neither needs new markup to show them.
|
|
423
|
+
*
|
|
424
|
+
* Anywhere else this is the identity. One launcher stays one row, with the line its author wrote.
|
|
425
|
+
*/
|
|
426
|
+
const splitSides = (step) => {
|
|
427
|
+
const variants = step.copy?.stdio?.variants;
|
|
428
|
+
if (!variants) return [step];
|
|
429
|
+
return variants.map((v) => ({
|
|
430
|
+
...step,
|
|
431
|
+
text: v.heading,
|
|
432
|
+
copy: { ...step.copy, text: v.text, stdio: { ...step.copy.stdio, text: v.text, variants: null } },
|
|
433
|
+
}));
|
|
434
|
+
};
|
|
410
435
|
|
|
411
436
|
export function whatItNeeds(client, have = {}, route = client?.lead) {
|
|
412
437
|
if (!client) return null;
|
|
@@ -494,10 +519,13 @@ export function whatItNeeds(client, have = {}, route = client?.lead) {
|
|
|
494
519
|
// ON WSL, "THIS COMPUTER" IS THE LINUX INSIDE WINDOWS. The line names WSL paths, so it runs in the WSL
|
|
495
520
|
// terminal where Clearotron is installed. Pasted into PowerShell it fails, and an assistant running on
|
|
496
521
|
// Windows itself could not start a program at a Linux path anyway.
|
|
497
|
-
|
|
498
|
-
const
|
|
522
|
+
// AND A LAUNCHER WITH TWO SIDES BECOMES TWO ROWS, under the step that says this install is in WSL.
|
|
523
|
+
const rows = steps.flatMap(splitSides);
|
|
524
|
+
if (have.wsl) rows.unshift({ text: WSL_STEP });
|
|
525
|
+
// The Windows-side row leads, so `command` and `stdio` answer what they have always answered.
|
|
526
|
+
const first = rows.find((s) => s.copy)?.copy;
|
|
499
527
|
// `command` and `stdio` ride for the terminal, which prints a disk offer's copy by its shape.
|
|
500
|
-
return { client, served: true, route, steps, launch: client.launch ?? null, enables: null, ...evidence,
|
|
528
|
+
return { client, served: true, route, steps: rows, launch: client.launch ?? null, enables: null, ...evidence,
|
|
501
529
|
command: first?.text ?? null, stdio: first?.stdio ?? null, address: null, key: null,
|
|
502
530
|
note: "Nothing to sign up for and nothing to open up — this assistant runs the software itself, from the copy already on this machine." };
|
|
503
531
|
}
|
package/shared/env-aliases.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Copyright 2026 Cordillera Sàrl. Additional terms under section 7 of the AGPL-3.0 apply — see ADDITIONAL-TERMS.md
|
|
3
3
|
// env-aliases.mjs — ONE SPELLING, and the settings that no longer exist at all.
|
|
4
4
|
//
|
|
5
|
-
// A variable name must mean something to someone who has never read this code. `
|
|
5
|
+
// A variable name must mean something to someone who has never read this code. `CLEARANCE` names nothing
|
|
6
6
|
// to anyone — it is the internal codename of the first product this engine shipped — so the names an
|
|
7
7
|
// installer types are `CLEAROTRON_*`. Vendor keys are deliberately untouched: `SIGNA_API_KEY` already
|
|
8
8
|
// says who you bought it from.
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// private roster). This file owns only the matching — it names no identity of its own beyond the
|
|
18
18
|
// platform tokens ALLOWED_CONTEXT has to spell out, which is why it declares itself in
|
|
19
19
|
// DECLARATION_SOURCES.
|
|
20
|
+
import { createHash } from "node:crypto";
|
|
20
21
|
|
|
21
22
|
// ── the platform and public-register exemption ────────────────────────────────────────────────────
|
|
22
23
|
//
|
|
@@ -72,6 +73,26 @@ export const ALLOWED_TOKEN_CONTEXT =
|
|
|
72
73
|
|
|
73
74
|
export const ALLOWED_LINE_CONTEXT = /owner-bound|watchlist owner/i;
|
|
74
75
|
|
|
76
|
+
// ── THE CLOUD, AS A BILLING ROUTE ──────────────────────────────────────────────────────────────────
|
|
77
|
+
//
|
|
78
|
+
// Claude can be paid for through a cloud account, and one of the three clouds is Azure. The roster
|
|
79
|
+
// retires the same word as a client, so every sentence about paying through that cloud was refused as a
|
|
80
|
+
// retired identity: 67 of the 99 hits over the tree on 2026-09-18, and 11 lines of a release's squash
|
|
81
|
+
// message.
|
|
82
|
+
//
|
|
83
|
+
// EXACTLY AS WIDE AS THE EVIDENCE, AND NO WIDER. Only the names the product itself ships for that route
|
|
84
|
+
// are exempt: the cloud's name, its Foundry service, and the three clouds named as a list. Case-sensitive,
|
|
85
|
+
// because that is how the product spells them. The bare word is never exempt, in any casing, and a
|
|
86
|
+
// sentence that used it as shorthand for the cloud was reworded to say Azure instead.
|
|
87
|
+
//
|
|
88
|
+
// AND NOT IN CAPTURED DATA. A frozen demo run and a captured register fixture hold third parties' own
|
|
89
|
+
// text: a course title that happens to name the cloud is not a billing route, so there the rule does not
|
|
90
|
+
// apply and the hit is reported as it always was.
|
|
91
|
+
export const CLOUD_ROUTE_CONTEXT = /\bMicrosoft Azure\b|\bMicrosoft Foundry\b|\bGoogle(?: Cloud)?,\s+Microsoft(?: Azure)?\s+or\b/;
|
|
92
|
+
|
|
93
|
+
/** A frozen demo run, anywhere under `demo/<product>/run/`: what the engine read, captured whole. */
|
|
94
|
+
const FROZEN_RUN_RE = /(?:^|\/)demo\/[^/]+\/run\//;
|
|
95
|
+
|
|
75
96
|
/**
|
|
76
97
|
* Kept as the union so nothing that merely ASKS "is this line exempt at all" has to learn the split.
|
|
77
98
|
* Callers deciding whether to REPORT a hit must use the two above — this one cannot tell you which
|
|
@@ -155,20 +176,27 @@ export function matchSpans(name, line, suffixable) {
|
|
|
155
176
|
*
|
|
156
177
|
* Takes the RAW line and does its own unescaping, so no caller can forget that step.
|
|
157
178
|
*/
|
|
158
|
-
export function reportableOnLine(name, rawLine, suffixable) {
|
|
179
|
+
export function reportableOnLine(name, rawLine, suffixable, { cloudRoute = true } = {}) {
|
|
159
180
|
if (ALLOWED_LINE_CONTEXT.test(rawLine)) return false;
|
|
160
181
|
const text = unescapeBoundaries(rawLine);
|
|
161
|
-
return matchSpans(name, text, suffixable).some((sp) => !tokenExemptionCovers(text, sp));
|
|
182
|
+
return matchSpans(name, text, suffixable).some((sp) => !tokenExemptionCovers(text, sp, { cloudRoute }));
|
|
162
183
|
}
|
|
163
184
|
|
|
164
|
-
function tokenExemptionCovers(line, span) {
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
185
|
+
function tokenExemptionCovers(line, span, { cloudRoute = true } = {}) {
|
|
186
|
+
const contexts = [new RegExp(ALLOWED_TOKEN_CONTEXT.source, "gi")];
|
|
187
|
+
if (cloudRoute) contexts.push(new RegExp(CLOUD_ROUTE_CONTEXT.source, "g"));
|
|
188
|
+
for (const rx of contexts) {
|
|
189
|
+
for (const m of String(line).matchAll(rx)) {
|
|
190
|
+
const s = m.index, e = m.index + m[0].length;
|
|
191
|
+
if (span.start < e && s < span.end) return true; // overlap, in either direction
|
|
192
|
+
}
|
|
168
193
|
}
|
|
169
194
|
return false;
|
|
170
195
|
}
|
|
171
196
|
|
|
197
|
+
/** Whether the cloud-route exemption applies in this file: everywhere but captured third-party data. */
|
|
198
|
+
export const cloudRouteApplies = (file) => !isCapturedRegisterData(file) && !FROZEN_RUN_RE.test(String(file ?? ""));
|
|
199
|
+
|
|
172
200
|
// ── CAPTURED PUBLIC-REGISTER DATA ─────────────────────────────────────────────────────────
|
|
173
201
|
//
|
|
174
202
|
// `providers/*/test/fixtures/` holds vendor responses captured live from public trademark registers.
|
|
@@ -208,6 +236,32 @@ export const CAPTURED_DATA_GUIDANCE =
|
|
|
208
236
|
+ "`node scripts/check-capture.mjs <file>` before writing; or (b) a genuine identifier of ours — remove "
|
|
209
237
|
+ "it. Do NOT exempt the path: captured data is the one place a real identifier could hide unseen.";
|
|
210
238
|
|
|
239
|
+
// ── A PUBLISHED DICTIONARY, PINNED TO ITS BYTES ─────────────────────────────────────────────
|
|
240
|
+
//
|
|
241
|
+
// The roster retires ordinary English words, and a dictionary is every ordinary English word, so the
|
|
242
|
+
// two collide by construction and, like captured register data, the collision arrives on a tree
|
|
243
|
+
// nobody touched: the private roster gains an entry and the word list goes red. Unlike captured data,
|
|
244
|
+
// nothing of ours can be in it. It is a published third-party list (the file's header names it and its
|
|
245
|
+
// licence) that no client, matter or run ever wrote to.
|
|
246
|
+
//
|
|
247
|
+
// SO THE EXEMPTION IS FOR THESE BYTES, NOT THIS PATH. Each entry pins the file's SHA-256 as it was
|
|
248
|
+
// vetted. Change one line, a name slipped in among the words, and the hash no longer matches: the file
|
|
249
|
+
// is scanned in full again like any other, and the pin moves only in a commit that says it re-vetted.
|
|
250
|
+
// A name hidden in a dictionary is therefore still a finding, and a name anywhere else never was
|
|
251
|
+
// exempt.
|
|
252
|
+
export const PINNED_DICTIONARIES = Object.freeze({
|
|
253
|
+
"driver/wordlists/en.txt": Object.freeze({
|
|
254
|
+
sha256: "8021ad10f94da2a549f60db93fcf761156458fd682e1095e81f93db6843fb636",
|
|
255
|
+
what: "SCOWL size 50, American: the one-letter neighbourhood filter's ordinary-word list",
|
|
256
|
+
}),
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
/** Is this file a pinned dictionary whose bytes are exactly the ones that were vetted? */
|
|
260
|
+
export function isPinnedDictionary(file, text) {
|
|
261
|
+
const pin = PINNED_DICTIONARIES[String(file ?? "")];
|
|
262
|
+
return Boolean(pin) && createHash("sha256").update(String(text)).digest("hex") === pin.sha256;
|
|
263
|
+
}
|
|
264
|
+
|
|
211
265
|
/**
|
|
212
266
|
* Sweep a corpus for retired identities.
|
|
213
267
|
*
|
|
@@ -227,6 +281,7 @@ export function scanCorpus(files, readFn, { retired, suffixable, vetted = () =>
|
|
|
227
281
|
for (const f of files) {
|
|
228
282
|
const t = readFn(f);
|
|
229
283
|
if (!t) continue;
|
|
284
|
+
if (isPinnedDictionary(f, t)) continue;
|
|
230
285
|
// In captured register data the hit is reported by ENTRY NUMBER, never by value. The
|
|
231
286
|
// ordinary format prints the retired name and its twin, which is right where the fix is "delete
|
|
232
287
|
// this" — but a fixture collision is expected to fire on a tree nobody changed, and printing the
|
|
@@ -239,7 +294,7 @@ export function scanCorpus(files, readFn, { retired, suffixable, vetted = () =>
|
|
|
239
294
|
entry += 1;
|
|
240
295
|
// A LINE-scoped exemption skips the line; a TOKEN-scoped one must OVERLAP the match it excuses.
|
|
241
296
|
// Both live in reportableOnLine, so the declaration test cannot answer this differently.
|
|
242
|
-
if (!reportableOnLine(name, line, suffixable)) continue;
|
|
297
|
+
if (!reportableOnLine(name, line, suffixable, { cloudRoute: cloudRouteApplies(f) })) continue;
|
|
243
298
|
if (vetted(f, name)) continue;
|
|
244
299
|
hits.push(captured
|
|
245
300
|
? `${f}:${i + 1}: match table entry #${entry} of ${retired.length} (sentinels first, then the roster) `
|
|
@@ -302,8 +357,8 @@ const ADDRESS = /([A-Za-z0-9._%+-]+)@(?:cordillera\.(?:ch|test)|clearotron\.ai)\
|
|
|
302
357
|
const INTERNAL_MARKER = /\bINTERNAL[ -]ONLY\b/;
|
|
303
358
|
const INTERNAL_PHRASE = /\bfor internal use only\b/i;
|
|
304
359
|
|
|
305
|
-
// THE SECOND STAMPED FORM, and is why it exists. `driver/skills/
|
|
306
|
-
// opened with `INTERNAL — Gold-standard worked examples for
|
|
360
|
+
// THE SECOND STAMPED FORM, and is why it exists. `driver/skills/clearance-search/worked-examples.md`
|
|
361
|
+
// opened with `INTERNAL — Gold-standard worked examples for clearance-search SYNTHESIS.` and shipped: the
|
|
307
362
|
// marker above requires the word ONLY, and a document stamped `INTERNAL —` carries the identical
|
|
308
363
|
// classification without it.
|
|
309
364
|
//
|
|
@@ -361,6 +416,7 @@ export function scanOperatorIdentity(files, readFn, { withheld = () => false } =
|
|
|
361
416
|
for (const f of files) {
|
|
362
417
|
const t = readFn(f);
|
|
363
418
|
if (!t) continue;
|
|
419
|
+
if (isPinnedDictionary(f, t)) continue;
|
|
364
420
|
// A WITHHELD PATH DOES NOT SHIP, and pattern 4 is about what a shipped surface says to its reader.
|
|
365
421
|
// The withheld design tree holds twenty-one hits of exactly this shape and every one is correct in
|
|
366
422
|
// place: working notes, withheld wholesale by the list in shared/withheld-paths.mjs, and rewriting
|
|
@@ -32,3 +32,25 @@ export const SENTINELS = [
|
|
|
32
32
|
// and `Brindlow-Reach`. One row carries it so the option itself is exercised rather than merely
|
|
33
33
|
// declared — an unexercised option is a setting nobody has watched work.
|
|
34
34
|
export const SUFFIXABLE = new Set(["Brindlow"]);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* WHICH TABLE THE SWEEP WAS ARMED WITH, said out loud so a workflow can require it.
|
|
38
|
+
*
|
|
39
|
+
* The public tree can only ever run this sweep in sentinel mode — the real roster lives outside the
|
|
40
|
+
* repository by design, and the arm that sweeps here is wired to SENTINELS by construction. That is
|
|
41
|
+
* fine, and it is exactly why the mode has to be stated: a sweep with an empty table returns the same
|
|
42
|
+
* clean zero as a sweep that worked, so "it ran, and here is which table it ran against" is the only
|
|
43
|
+
* form of the claim a reader can act on.
|
|
44
|
+
*
|
|
45
|
+
* It is printed by the arm that actually sweeps the corpus, never at import. A marker that appeared
|
|
46
|
+
* merely because a module was loaded would survive the arm being deleted, which is the one thing it
|
|
47
|
+
* exists to catch — the guard stopping and every run staying green.
|
|
48
|
+
*/
|
|
49
|
+
export const SENTINEL_MODE_MARKER = "[repo-guard] blocklist mode=sentinel";
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The line the sweep prints when it read a REAL roster instead. That roster is private and never
|
|
53
|
+
* reaches this repository's runners by design, so on a public run the line does not mean a stronger
|
|
54
|
+
* check: it means the roster was loaded into a public log. The workflow refuses it.
|
|
55
|
+
*/
|
|
56
|
+
export const TABLE_MODE_MARKER = "[repo-guard] blocklist mode=table";
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// names-in-force.mjs — GENERATED by scripts/mint-names-in-force.mjs. Do not edit by hand.
|
|
4
4
|
//
|
|
5
5
|
// Every `CLEAROTRON_*` name this build reads. It exists so the retired-spelling check can name the
|
|
6
|
-
// replacement for an old `
|
|
6
|
+
// replacement for an old `CLEARANCE_*` line rather than only saying the old one is dead — see
|
|
7
7
|
// shared/env-aliases.mjs. Re-mint with:
|
|
8
8
|
//
|
|
9
9
|
// node scripts/mint-names-in-force.mjs
|
|
@@ -40,7 +40,9 @@ export const NAMES_IN_FORCE = Object.freeze([
|
|
|
40
40
|
"CLEAROTRON_COMMONLAW_SPLIT",
|
|
41
41
|
"CLEAROTRON_COUNT_FIRST",
|
|
42
42
|
"CLEAROTRON_CUSTOMERS_DIR",
|
|
43
|
+
"CLEAROTRON_CUT_PR",
|
|
43
44
|
"CLEAROTRON_CUT_REF",
|
|
45
|
+
"CLEAROTRON_CUT_REQUESTED",
|
|
44
46
|
"CLEAROTRON_DATABASE",
|
|
45
47
|
"CLEAROTRON_DEFAULT_AGENT",
|
|
46
48
|
"CLEAROTRON_DELIVERY",
|