clearotron 0.2.2 → 0.2.4
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 +64 -37
- package/.nvmrc +1 -1
- package/CONTRIBUTING.md +8 -4
- package/INSTALL.md +36 -14
- package/README.md +18 -22
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/clearotron.mjs +22 -0
- package/bin/example.mjs +10 -5
- package/bin/onboard.mjs +370 -28
- package/bin/start.mjs +220 -12
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/demo/full-country-search/run/_driver/framework.json +1 -1
- package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
- package/demo/knockout-search/run/_driver/framework.json +1 -1
- package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
- package/docs/E2E.md +5 -5
- package/docs/GLOSSARY.md +85 -0
- package/docs/PORTAL.md +2 -2
- package/docs/README.md +6 -4
- package/docs/SECURITY.md +17 -0
- package/docs/architecture/01-product-overview.md +21 -9
- package/docs/architecture/04-configuration-reference.md +2 -0
- package/docs/architecture/05-config-governance.md +19 -0
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/configuration.md +4 -3
- package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
- package/driver/CHANGELOG.md +90 -0
- package/driver/README.md +25 -6
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/config-staleness.mjs +1 -1
- package/driver/connotation-search.mjs +1 -1
- package/driver/contract-audit.mjs +5 -1
- package/driver/contract-e3-baseline.json +11 -11
- package/driver/dev-portal.mjs +2 -2
- package/driver/doubt-selection.mjs +1 -1
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +1 -1
- package/driver/effort-model.mjs +2 -2
- package/driver/engine/cli-version.mjs +103 -0
- package/driver/engine/probe.mjs +45 -5
- package/driver/enqueue-schema.mjs +8 -8
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +52 -2
- package/driver/flag-snapshot.mjs +20 -0
- package/driver/framework.mjs +1 -1
- package/driver/gateway.mjs +20 -3
- package/driver/outbox-backoff.mjs +1 -1
- package/driver/package.json +3 -3
- package/driver/party-facts.mjs +2 -2
- package/driver/phase0.mjs +1 -1
- package/driver/pipeline.mjs +211 -106
- package/driver/plain-register.mjs +134 -0
- package/driver/portal-access.mjs +1 -1
- package/driver/portal-config-view.mjs +70 -2
- package/driver/portal-families.mjs +1 -1
- package/driver/portal-local-auth.mjs +5 -1
- package/driver/portal-report.mjs +1 -1
- package/driver/portal-service.mjs +79 -9
- package/driver/portal-upstream.mjs +2 -2
- package/driver/predelivery-lint.mjs +151 -2
- package/driver/profile-page.html +10 -2
- package/driver/profile-service.mjs +4 -4
- package/driver/profiles/README.md +3 -3
- package/driver/profiles.mjs +144 -12
- package/driver/publish/index.mjs +2 -2
- package/driver/publish/knockout.mjs +7 -1
- package/driver/publish/render-knockout.mjs +453 -118
- package/driver/publish/render.mjs +111 -16
- package/driver/reference-strip-signatures.mjs +20 -0
- package/driver/search-policy.mjs +3 -3
- package/driver/skills/blind-frame/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +84 -0
- package/driver/skills/matter-frame/SKILL.md +6 -6
- package/driver/skills/matter-frame/watchlist-reference.md +2 -2
- package/driver/skills/prelim-common-law/SKILL.md +1 -1
- package/driver/skills/prelim-search/SKILL.md +1 -1
- package/driver/skills/prelim-search/report-prose.md +82 -0
- package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
- package/driver/skills/prelim-search/synthesis-rules.md +1 -1
- package/driver/skills/prelim-variants/SKILL.md +3 -3
- package/driver/stage-context.mjs +13 -0
- package/driver/stages.mjs +54 -7
- package/driver/suite-census.json +220 -58
- package/driver/systemd/clearotron-worker.service +3 -3
- package/driver/tokens.mjs +1 -1
- package/driver/unit-inventory.mjs +214 -27
- package/driver/verify.mjs +1 -1
- package/driver/whatif-memo-run.mjs +26 -4
- package/driver/whatif-memo.mjs +19 -1
- package/examples/README.md +2 -2
- package/examples/grants.example.json +6 -7
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/ops/EXAMPLES.md +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +31 -19
- package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
- package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/src/index-store.js +2 -2
- package/scripts/added-reference-check.mjs +45 -89
- package/scripts/citation-line-check.mjs +121 -2
- package/scripts/e2e.mjs +2 -2
- package/scripts/env-classify.mjs +67 -3
- package/scripts/generated-files-are-current.mjs +125 -0
- package/scripts/live-surface-check.mjs +38 -1
- package/scripts/mint-names-in-force.mjs +4 -2
- package/scripts/mint-public-residue.mjs +74 -0
- package/scripts/mint-reference-strip-backlog.mjs +12 -1
- package/scripts/mint-suite-census.mjs +5 -2
- package/scripts/nightly-notice.mjs +0 -2
- package/scripts/preinstall-node-check.mjs +24 -0
- package/scripts/render-check.mjs +13 -2
- package/scripts/report-frame-check.mjs +8 -1
- package/scripts/report-screenshot.mjs +6 -3
- package/scripts/revisit-render-check.mjs +6 -3
- package/scripts/score.mjs +1 -1
- package/scripts/strip-tracker-citations.mjs +38 -7
- package/scripts/test-full.mjs +226 -0
- package/scripts/test-run.mjs +16 -0
- package/shared/README.md +2 -1
- package/shared/anon-overlay.mjs +3 -3
- package/shared/browser-temp-root.mjs +142 -0
- package/shared/checkout-move.mjs +1 -1
- package/shared/client-door.mjs +4 -3
- package/shared/customer-store.mjs +1 -1
- package/shared/identifier-scan.mjs +1 -1
- package/shared/install-auth.mjs +35 -0
- package/shared/invocation.mjs +33 -1
- package/shared/listen.mjs +55 -3
- package/shared/names-in-force.mjs +6 -1
- package/shared/node-floor.mjs +97 -0
- package/shared/os-advice.mjs +91 -0
- package/shared/reap-on-exit.mjs +42 -0
- package/shared/reference-guard-classes.mjs +351 -0
- package/shared/staff-domain.mjs +154 -0
- package/shared/store-in-repo.mjs +23 -2
- package/shared/suite-census.mjs +31 -3
- package/shared/withheld-paths-access.mjs +36 -18
- package/skills/clearotron-ops/SKILL.md +1 -1
- package/driver/recipes/README.md +0 -49
- package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
- package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
- package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
- package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
- package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
- package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
- package/scripts/ai-page-render-check.mjs +0 -532
- package/scripts/clearances-render-check.mjs +0 -480
- package/scripts/composer-render-check.mjs +0 -1027
- package/scripts/home-render-check.mjs +0 -526
- package/scripts/portal-lifecycle-check.mjs +0 -639
|
@@ -0,0 +1,351 @@
|
|
|
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
|
+
// WHAT A PUBLIC TREE MUST NOT ACQUIRE, AS ONE TABLE.
|
|
5
|
+
//
|
|
6
|
+
// `scripts/added-reference-check.mjs` refuses these in a diff, so a pull request cannot add one. The
|
|
7
|
+
// floor beside `driver/test/fixtures/public-residue-backlog.json` counts what is already here, so the
|
|
8
|
+
// standing population can only fall. Two readers, one table: a class the diff guard refuses and the
|
|
9
|
+
// census does not count is a class whose number nobody can act on, and the reverse is a number nobody
|
|
10
|
+
// can hold.
|
|
11
|
+
//
|
|
12
|
+
// ── WHY THE TABLE CAN BE PUBLISHED AT ALL ────────────────────────────────────────────────────────
|
|
13
|
+
//
|
|
14
|
+
// A guard is a list of what it looks for, and this file ships in the tree it protects. So the test for
|
|
15
|
+
// every entry is whether SPELLING IT DISCLOSES ANYTHING. Generic account names, role words and the
|
|
16
|
+
// repository's own retired names disclose nobody. A person's name and the names given to working
|
|
17
|
+
// sessions do, and they are not here: those stay in the private table that
|
|
18
|
+
// `shared/identifier-sentinels.mjs` describes, reached through `CLEAROTRON_IDENTIFIER_BLOCKLIST`, and
|
|
19
|
+
// this file does not restate them under another heading.
|
|
20
|
+
//
|
|
21
|
+
// That split is a real limit and it is stated rather than papered over: public CI cannot refuse a name
|
|
22
|
+
// it is not allowed to know. What it can refuse is every class below, which is what let the last set
|
|
23
|
+
// through.
|
|
24
|
+
//
|
|
25
|
+
// QUOTING A BAD LINE IS STILL WRITING IT, and there is no exemption for backticks. Both comments in
|
|
26
|
+
// this file that once showed a banned token as an example were refused by the guard they describe, and
|
|
27
|
+
// the answer was to reword them rather than to excuse a span. An exemption for quoted text would be a
|
|
28
|
+
// hole in exactly the classes that need none: an account name inside backticks is as published as one
|
|
29
|
+
// outside them, and any author could quote their way past. Where a bad input genuinely has to be
|
|
30
|
+
// SHOWN, it goes in a test arm as a string literal — which this guard does not read, by the same rule
|
|
31
|
+
// that keeps it away from CSS and composite keys.
|
|
32
|
+
//
|
|
33
|
+
// ── WHAT IS DELIBERATELY NOT HERE ────────────────────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// PRODUCT VOCABULARY. `lane`, `round`, `box`, `ruling`, `prelim`, `knockout`, `seat` and `jx` are this
|
|
36
|
+
// product's own nouns. A guard refusing them fires thousands of times, and a guard that fires on
|
|
37
|
+
// correct prose is one whose next reader deletes from the workflow.
|
|
38
|
+
//
|
|
39
|
+
// `deploy` IS A SKILL NAME AND IS NOT BANNED, for the same reason. It is an ordinary English word and a
|
|
40
|
+
// paragraph about deploying would be refused line by line.
|
|
41
|
+
//
|
|
42
|
+
// `clawdi` IS NOT BANNED, and this one was measured rather than reasoned. It reads like an internal
|
|
43
|
+
// name and it is the product's own default agent id: `CLEAROTRON_DEFAULT_AGENT=clawdi` is in
|
|
44
|
+
// INSTALL.md, in the configuration reference and in the operations runbook, and it is a path segment in
|
|
45
|
+
// every run directory. 49 lines carry it in that sense and none carries it as a reference to the other
|
|
46
|
+
// product, which is the class that would have been worth refusing. Banning it would refuse the install
|
|
47
|
+
// instructions a stranger reads first.
|
|
48
|
+
|
|
49
|
+
import { execFileSync } from "node:child_process";
|
|
50
|
+
import { CUT_RECORD_PRESENT, isWithheld } from "./withheld-paths-access.mjs";
|
|
51
|
+
|
|
52
|
+
// ── A COLOUR IS NOT A CITATION, AND ONE PROPERTY LIST SETTLES BOTH READINGS ──────────────────────
|
|
53
|
+
//
|
|
54
|
+
// A three-digit hex colour and a three-digit reference are the same characters, and the digits cannot
|
|
55
|
+
// tell you which one you are looking at. The SITE settles it: a value whose property is a colour is a
|
|
56
|
+
// colour.
|
|
57
|
+
//
|
|
58
|
+
// TWO RULES FROM ONE LIST, and they are not interchangeable — this is the split
|
|
59
|
+
// `shared/identifier-scan.mjs` had to learn after a line-scoped exemption quietly cleared every other
|
|
60
|
+
// name on the line beside the one that earned it.
|
|
61
|
+
//
|
|
62
|
+
// TOKEN-SCOPED (`COLOUR_PROPERTY`) — anchored at the end of the text BEFORE a token, so it exempts
|
|
63
|
+
// that token and nothing else. This is what the diff guard uses: a stylesheet line may hold a colour
|
|
64
|
+
// and a citation, and only the colour is excused.
|
|
65
|
+
//
|
|
66
|
+
// LINE-SCOPED (`COLOUR_SITE`) — true anywhere on the line. Correct only where the whole line is a
|
|
67
|
+
// declaration and there is nothing else on it to miss, which is how
|
|
68
|
+
// `driver/test/prompt-payload-names-no-tracker-issue.test.mjs` reads a stylesheet.
|
|
69
|
+
//
|
|
70
|
+
// Lifted here from that test so the two stop being separate spellings of one rule.
|
|
71
|
+
const COLOUR_PROPERTIES = "color|background|background-color|border|border-color|fill|stroke|outline|box-shadow|text-shadow";
|
|
72
|
+
|
|
73
|
+
/** Anchored: true when the text immediately before a token is a colour property awaiting its value. */
|
|
74
|
+
export const COLOUR_PROPERTY = new RegExp(`(?:^|[;{\\s(,])(?:${COLOUR_PROPERTIES})\\s*:\\s*$`, "i");
|
|
75
|
+
|
|
76
|
+
/** Unanchored: true when the line is a colour declaration at all. */
|
|
77
|
+
export const COLOUR_SITE = new RegExp(`(?:^|[;{\\s])(?:${COLOUR_PROPERTIES})\\s*:`, "i");
|
|
78
|
+
|
|
79
|
+
const HEX_COLOUR = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b/g;
|
|
80
|
+
|
|
81
|
+
/** Strip hex colours, so what remains is only tokens that could be a reference. */
|
|
82
|
+
export const withoutColourValues = (line) => String(line).replace(HEX_COLOUR, (m, offset, whole) => {
|
|
83
|
+
if (/[a-fA-F]/.test(m.slice(1))) return ""; // letters ⇒ not a decimal number
|
|
84
|
+
return COLOUR_PROPERTY.test(whole.slice(0, offset)) ? "" : m; // all digits ⇒ the property decides
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
/** Strip the spans where a `#NNN` is an address rather than a reference. */
|
|
88
|
+
export const withoutLinkTargets = (line) => String(line)
|
|
89
|
+
.replace(/\]\([^)]*\)/g, "]()") // markdown link targets, anchors included
|
|
90
|
+
.replace(/https?:\/\/\S+/g, "") // bare URLs and their fragments
|
|
91
|
+
.replace(/<[^>]*>/g, ""); // angle-bracket autolinks
|
|
92
|
+
|
|
93
|
+
// A `#` COMMENT IS A COMMENT WHEREVER THE FILE FORMAT SAYS SO, not only in YAML. Extensionless is
|
|
94
|
+
// deliberate: a systemd unit or a dotfile often has no extension worth matching, so the KNOWN
|
|
95
|
+
// `#`-comment names are listed and everything else keeps the source rule.
|
|
96
|
+
const HASH_COMMENT = /(^|\/)(\.env[^/]*|[^/]*\.(ya?ml|sh|bash|service|timer|path|socket|conf|ini|toml|properties)|Dockerfile[^/]*|Makefile|\.gitignore|\.gitattributes)$/;
|
|
97
|
+
|
|
98
|
+
/** Is this line one the guard reads at all? Comments in source, everything in markdown. */
|
|
99
|
+
export const isProse = (path, line) => {
|
|
100
|
+
if (/\.mde?$/.test(path) || path.endsWith(".md")) return true;
|
|
101
|
+
const t = String(line).trim();
|
|
102
|
+
if (HASH_COMMENT.test(path)) return t.startsWith("#");
|
|
103
|
+
return t.startsWith("//") || t.startsWith("*") || t.startsWith("/*");
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// ── THE TWO TREES THIS GUARD DOES NOT READ ──────────────────────────────────────────────────────
|
|
107
|
+
//
|
|
108
|
+
// `demo/**` is recorded public-register data and the run artifacts replayed from it. The names in it
|
|
109
|
+
// are real trademark owners on a real register, which is what the demo is for; a guard reading it
|
|
110
|
+
// would refuse the product's own worked example.
|
|
111
|
+
//
|
|
112
|
+
// `driver/skills/**` is the engine's instruction text, and it has its own stricter guard next door in
|
|
113
|
+
// `driver/test/prompt-payload-names-no-tracker-issue.test.mjs` — which refuses a citation there in any
|
|
114
|
+
// form, prose line or not. Reading it here as well would put one tree under two rules that disagree
|
|
115
|
+
// about what a comment is.
|
|
116
|
+
const UNREAD_TREES = [/^demo\//, /^driver\/skills\//];
|
|
117
|
+
|
|
118
|
+
/** Does the guard read this path at all? */
|
|
119
|
+
export const isScannable = (path) => !UNREAD_TREES.some((re) => re.test(path));
|
|
120
|
+
|
|
121
|
+
// ── THE CLASSES ─────────────────────────────────────────────────────────────────────────────────
|
|
122
|
+
//
|
|
123
|
+
// Each is `{ id, pattern, why }`. The id is what the census counts under and what a floor entry names,
|
|
124
|
+
// so it is stable text: renaming one re-mints the fixture and the diff says so.
|
|
125
|
+
//
|
|
126
|
+
// `pattern` carries no `g` flag. A shared `g`-flagged regex carries `lastIndex` between `test` and
|
|
127
|
+
// `exec` calls, so the same instance answers differently on its second use — the kind of defect that
|
|
128
|
+
// shows up as one arm in ten failing. `EVERY_MATCH` below adds the flag for the one operation that
|
|
129
|
+
// needs it, and `String.prototype.matchAll` iterates a clone rather than the original (asserted in
|
|
130
|
+
// driver/test/the-public-residue-is-a-floor.test.mjs, because it is a property of the runtime and not
|
|
131
|
+
// of this file).
|
|
132
|
+
|
|
133
|
+
/** @type {{id: string, pattern: RegExp, why: string}[]} */
|
|
134
|
+
export const CLASSES = [
|
|
135
|
+
{
|
|
136
|
+
id: "bare-reference",
|
|
137
|
+
pattern: /#[0-9]{3,}/,
|
|
138
|
+
// THE REMEDY MOVED WITH THE CLASS BELOW. This used to read "write `tracker issue NNN`", which is
|
|
139
|
+
// now the next entry in this table — a guard telling authors to write the form it refuses two
|
|
140
|
+
// lines later. The remedy is not another spelling of the address; it is to drop the address.
|
|
141
|
+
why: "a bare #NNN reference — GitHub links it into whichever repository renders it, which is not "
|
|
142
|
+
+ "the one the number belongs to, and it lives on in public history. Say why the code is as it "
|
|
143
|
+
+ "is; the citation belongs in the commit message or the pull request body.",
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
// THE SPELLED FORM WAS THE REMEDY AND IS NOW THE CLASS. `tracker issue NNN` was what this guard
|
|
147
|
+
// told authors to write instead of `#NNN`, because it carries no `#` and GitHub cannot linkify it.
|
|
148
|
+
// That was right about the linkifying and wrong about the address: the tree carries the reason for
|
|
149
|
+
// a decision, never its address, and a reader outside this project cannot open the number either
|
|
150
|
+
// way. So the citation moves to the commit message and the pull request body, where it belongs,
|
|
151
|
+
// and the code says why rather than where.
|
|
152
|
+
id: "spelled-citation",
|
|
153
|
+
pattern: /\btracker\s+issues?\s+[0-9]+/i,
|
|
154
|
+
why: "a spelled tracker citation — the reason for a decision belongs in the tree, its address does "
|
|
155
|
+
+ "not. A reader outside this project cannot open the number. Say why the code is as it is; put "
|
|
156
|
+
+ "the citation in the commit message or the pull request body.",
|
|
157
|
+
},
|
|
158
|
+
{
|
|
159
|
+
id: "login",
|
|
160
|
+
pattern: /\b(?:testuser|azureuser|devuser)[0-9]*\b/i,
|
|
161
|
+
why: "an account name from the machines this is built on. Say what the account is for — 'the "
|
|
162
|
+
+ "account the timer runs as' — not what it is called.",
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
// THE PLACEHOLDER IS THE POINT OF THE NARROW LIST. `/home/you/trademark/pool` is what INSTALL.md
|
|
166
|
+
// and docs/E2E.md tell a reader to write, and it is the first thing a stranger copies. A
|
|
167
|
+
// `/home/<anything>/` rule would refuse the install instructions, so the accounts are named.
|
|
168
|
+
id: "home-path",
|
|
169
|
+
pattern: /\/home\/(?:testuser|azureuser|devuser|clearotron)[0-9]*\b/i,
|
|
170
|
+
why: "a home directory on one of the machines this is built on. Use the placeholder the install "
|
|
171
|
+
+ "documentation uses, or name the setting rather than the path.",
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
// ── DECLARED HERE, SPELLED ELSEWHERE, AND THE REASON IS THIS ENTRY'S OWN SENTENCE ──────────────
|
|
175
|
+
//
|
|
176
|
+
// This class had a pattern naming two private repositories as literals, and it shipped in the tree
|
|
177
|
+
// it protects — so the guard published exactly what it exists to refuse. Its own `why` says it: a
|
|
178
|
+
// public tree naming one publishes it, and the name outlives every link to it.
|
|
179
|
+
//
|
|
180
|
+
// THE SPLIT AT THE TOP OF THIS FILE ALREADY HAD THE ANSWER and this entry was on the wrong side of
|
|
181
|
+
// it. A generic account name discloses nobody, which is why the logins are spelled out. A
|
|
182
|
+
// repository name is not a generic word: it is a unique identifier of a private asset, and it
|
|
183
|
+
// belongs with the personal names in the private table rather than here.
|
|
184
|
+
//
|
|
185
|
+
// FOUND BY A REVIEWER, AND DEMONSTRATED RATHER THAN ARGUED. The private scan refused their review
|
|
186
|
+
// on its first pass, because reporting the defect meant quoting the pattern. A class whose literals
|
|
187
|
+
// cannot be discussed in a public review without tripping a guard does not belong in a public file
|
|
188
|
+
// — which is this file's own no-exemption-for-quoting rule, arriving from the other side.
|
|
189
|
+
//
|
|
190
|
+
// WHAT IS LOST, SAID PLAINLY RATHER THAN LEFT AS AN ABSENCE. Commit bodies are covered: the private
|
|
191
|
+
// merge scan refuses this class today. What this entry would have added is FILE coverage, and
|
|
192
|
+
// public CI cannot have it without the literals. So the class is declared with no pattern, skipped
|
|
193
|
+
// here, and populated only where the roster is. A guard that cannot spell what it looks for says so
|
|
194
|
+
// rather than looking clean.
|
|
195
|
+
id: "private-repo-name",
|
|
196
|
+
pattern: null,
|
|
197
|
+
why: "a private repository name. A public tree naming one publishes it, and the name outlives "
|
|
198
|
+
+ "every link to it. Refused in commit bodies by the private merge scan; not spellable here.",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
// THE FOUR ARE SPELLED OUT RATHER THAN `role-\w+`. `role-shaping` is a real phrase in
|
|
202
|
+
// driver/portal-service.mjs about what the report does with a party's role, and a prefix rule
|
|
203
|
+
// refuses it — the first false positive would land on product prose, which is how a guard loses
|
|
204
|
+
// its `&&`.
|
|
205
|
+
// AND ONE OF THEM IS A REGISTERED MARK, which matters in this product and nowhere else. The bare
|
|
206
|
+
// word in the pattern below — the one that is not prefixed `role-` — is banned because 20 files
|
|
207
|
+
// carry it as our own word for a role, and no current use of it as a mark is in this tree today. But the corpus this product searches is MARKS, and that is
|
|
208
|
+
// a well-known registered one — so the first false positive here will be a fixture, a worked example
|
|
209
|
+
// or a doctrine line that names it legitimately. That is the same defect this codebase has met three
|
|
210
|
+
// times in a different costume: a check that cannot tell a mark from its own vocabulary. When it
|
|
211
|
+
// arrives, the fix is a site rule that exempts the mark where a mark belongs — not a widened pattern,
|
|
212
|
+
// and not deleting the class, which would put 20 real leaks back.
|
|
213
|
+
id: "role-name",
|
|
214
|
+
pattern: /\brole-(?:dev|e2e|design|overwatch)\b|\boverwatch\b|\bclearance-runs\b/i,
|
|
215
|
+
why: "our own word for how this is built, not the reader's. Say what was done and how it was "
|
|
216
|
+
+ "verified; a stranger cannot use the organisation chart and should not have to meet it.",
|
|
217
|
+
},
|
|
218
|
+
{
|
|
219
|
+
id: "agent-trailer",
|
|
220
|
+
// The comment leader is matched so the trailer is recognised at the start of a comment, and
|
|
221
|
+
// CAPTURED OUT of the report: a reader told that the offending token includes the comment leader
|
|
222
|
+
// has been handed the syntax around the finding as though it were part of it.
|
|
223
|
+
pattern: /^[\s*/#]*(Agent:\s*role-\w+)/,
|
|
224
|
+
why: "the agent trailer, which belongs on private surfaces only. On a public surface the "
|
|
225
|
+
+ "attribution is the commit author and nothing else.",
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
// WORSE THAN THE CLASS ABOVE BECAUSE NOBODY READS IT AS PROSE. This one arrives in a template,
|
|
229
|
+
// survives review by looking like machinery, and a commit message cannot be amended once pushed.
|
|
230
|
+
// The same class is refused in commit bodies by the merge scan; nothing refused it inside a FILE
|
|
231
|
+
// until this, and a file is the surface that stays.
|
|
232
|
+
id: "machine-trailer",
|
|
233
|
+
pattern: /Co-Authored-By:\s*Claude|Claude-Session:|Generated with \[Claude|claude\.ai\/code\/session/,
|
|
234
|
+
why: "a machine-written attribution trailer or session link. On a public surface the attribution "
|
|
235
|
+
+ "is the commit author and nothing else.",
|
|
236
|
+
},
|
|
237
|
+
];
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Every class this line offends, as `{ id, token, why }`. Empty when the line is not read, is not
|
|
241
|
+
* prose, or is clean.
|
|
242
|
+
*
|
|
243
|
+
* Colour values and link targets are stripped once, before any class sees the line, so a class cannot
|
|
244
|
+
* be written in a way that reintroduces either exclusion by accident.
|
|
245
|
+
*/
|
|
246
|
+
// EVERY OCCURRENCE, NOT THE FIRST. This was one-per-class-per-line until the census arm planted two
|
|
247
|
+
// citations on one line and read back 1. For the diff guard the difference is cosmetic — the reader is
|
|
248
|
+
// shown the line either way — but the backlog is a COUNT, and a count that stops at the first hit lets
|
|
249
|
+
// a floored line quietly gain a second one. The permissive half of a gate is the dangerous half.
|
|
250
|
+
const EVERY_MATCH = new Map(CLASSES.filter((c) => c.pattern).map((c) => [c.id, new RegExp(c.pattern.source, c.pattern.flags + "g")]));
|
|
251
|
+
|
|
252
|
+
export function offendingClasses(path, line) {
|
|
253
|
+
if (!isScannable(path) || !isProse(path, line)) return [];
|
|
254
|
+
const text = withoutColourValues(withoutLinkTargets(line));
|
|
255
|
+
const out = [];
|
|
256
|
+
for (const c of CLASSES) {
|
|
257
|
+
// A CLASS WITH NO PATTERN IS ONE THIS TREE CANNOT SPELL, not one with nothing to find. It stays in
|
|
258
|
+
// the table so the census keeps its column and a reader meets the limit where the rule is, rather
|
|
259
|
+
// than inferring it from an absence.
|
|
260
|
+
const re = EVERY_MATCH.get(c.id);
|
|
261
|
+
if (!re) continue;
|
|
262
|
+
// A capture group, where a class has one, is the offending text with its surroundings dropped.
|
|
263
|
+
for (const m of text.matchAll(re)) out.push({ id: c.id, token: m[1] ?? m[0], why: c.why });
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* The standing population, per file, as `{ total, files: { path: number[] } }` — one count per class,
|
|
270
|
+
* in `CLASSES` order, for every file carrying at least one.
|
|
271
|
+
*
|
|
272
|
+
* A file with no hit is absent rather than zero-filled, so the fixture shrinks as the tree is repaired
|
|
273
|
+
* instead of recording a growing list of clean files.
|
|
274
|
+
*
|
|
275
|
+
* @param {string[]} files repo-relative paths
|
|
276
|
+
* @param {(path: string) => string} read
|
|
277
|
+
*/
|
|
278
|
+
const COLUMN = new Map(CLASSES.map((c, i) => [c.id, i]));
|
|
279
|
+
|
|
280
|
+
// ── THE CENSUS IS A STATEMENT ABOUT THE PUBLISHED TREE, AND IT CAN BE RUN OVER A BIGGER ONE ──────
|
|
281
|
+
//
|
|
282
|
+
// The withheld corpus is laid back over this tree at its pre-cut paths to run the suite with it. Under
|
|
283
|
+
// that overlay `git ls-files` returns 110 files that are not in the published repository, and measured
|
|
284
|
+
// before this existed they carry 360 hits — 299 of them the one class this floor holds at ZERO. So the
|
|
285
|
+
// arms would have gone red on files the committed fixture cannot record: writing them into it would
|
|
286
|
+
// publish the withheld path list, which is the thing the cut exists to prevent.
|
|
287
|
+
//
|
|
288
|
+
// `isWithheld` is the instrument for that question and this is its THIRD reader — corrected 2026-09-09
|
|
289
|
+
// from "sixth", which counted three readers that do not exist. Its own file states
|
|
290
|
+
// the condition a new caller must meet — the fallback has to make the caller STRICTER, never looser —
|
|
291
|
+
// and this one does: with no cut record it answers false for everything, nothing is skipped, and the
|
|
292
|
+
// census counts the whole tree.
|
|
293
|
+
//
|
|
294
|
+
// AND IT IS INERT TODAY, WHICH IS SAID HERE RATHER THAN LEFT TO BE DISCOVERED. Measured: neither the
|
|
295
|
+
// published repository nor the one the overlay lays from carries `shared/withheld-paths.mjs`, so
|
|
296
|
+
// `CUT_RECORD_PRESENT` is false in both and this skip has never yet excluded a path. It stays because
|
|
297
|
+
// it is the right question and costs nothing the day a record exists. What actually separates the two
|
|
298
|
+
// populations today is the caller's business, and the floor test does it by asking which files are in
|
|
299
|
+
// HEAD: the overlay stages what it lays and never commits it, so a laid path is in the index and not
|
|
300
|
+
// in the published tree. An armed-but-blind guard is worth having only while it says which it is.
|
|
301
|
+
//
|
|
302
|
+
// THE COUNT IS RETURNED, NOT SWALLOWED. A skip nobody can see is how an exclusion becomes an outage:
|
|
303
|
+
// a predicate that started answering true for everything would report a clean, empty census, and that
|
|
304
|
+
// reads exactly like a repaired tree. The arms assert on `skipped` in both directions.
|
|
305
|
+
export function censusOf(files, read) {
|
|
306
|
+
const out = { total: 0, files: {}, skipped: 0, cutRecord: CUT_RECORD_PRESENT };
|
|
307
|
+
for (const path of files) {
|
|
308
|
+
if (!isScannable(path)) continue;
|
|
309
|
+
if (isWithheld(path)) { out.skipped++; continue; }
|
|
310
|
+
let text;
|
|
311
|
+
try { text = read(path); } catch { continue; }
|
|
312
|
+
if (text.includes("\0")) continue; // a binary blob is not prose
|
|
313
|
+
const counts = CLASSES.map(() => 0);
|
|
314
|
+
for (const line of text.split("\n")) {
|
|
315
|
+
for (const { id } of offendingClasses(path, line)) counts[COLUMN.get(id)]++;
|
|
316
|
+
}
|
|
317
|
+
const sum = counts.reduce((a, b) => a + b, 0);
|
|
318
|
+
if (sum) { out.files[path] = counts; out.total += sum; }
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The files this checkout PUBLISHES, out of the ones it tracks.
|
|
325
|
+
*
|
|
326
|
+
* The suite is also run over a bigger tree: the withheld corpus is laid back over a clone at its
|
|
327
|
+
* pre-cut paths so our own arms can run against it. That overlay stages what it lays and never commits
|
|
328
|
+
* it, so a laid path is in the index and not in HEAD — which is the whole discriminator, and it is
|
|
329
|
+
* exact rather than a heuristic about paths.
|
|
330
|
+
*
|
|
331
|
+
* The backlog is a statement about the published repository, so both the floor and the mint read this
|
|
332
|
+
* and not the raw tracked list. Two spellings of "the population" is one population and one guess.
|
|
333
|
+
*
|
|
334
|
+
* @param {string[]} trackedList what `trackedFiles` returned — enumerated there so the loud
|
|
335
|
+
* no-checkout skip and the corpus marker stay in one place
|
|
336
|
+
* @param {string} root
|
|
337
|
+
* @returns {{files: string[], laid: number} | {error: string}}
|
|
338
|
+
*/
|
|
339
|
+
export function publishedOf(trackedList, root) {
|
|
340
|
+
let head;
|
|
341
|
+
try {
|
|
342
|
+
head = new Set(execFileSync("git", ["-C", root, "ls-tree", "-r", "HEAD", "--name-only"],
|
|
343
|
+
{ encoding: "utf8", maxBuffer: 1 << 28 }).split("\n").filter(Boolean));
|
|
344
|
+
} catch (e) {
|
|
345
|
+
// A tree with no HEAD cannot say what it published, and that is a could-not-look. It must not
|
|
346
|
+
// become "nothing is laid here", which is the permissive reading and the one that passes.
|
|
347
|
+
return { error: `could not read HEAD in ${root}: ${String(e.message).split("\n")[0]}` };
|
|
348
|
+
}
|
|
349
|
+
const files = trackedList.filter((f) => head.has(f));
|
|
350
|
+
return { files, laid: trackedList.length - files.length };
|
|
351
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
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
|
+
// staff-domain.mjs — what a staff-domain rule ADMITS, decided before one is written.
|
|
4
|
+
//
|
|
5
|
+
// `PORTAL_STAFF_DOMAINS` is a list of email domains, and every address at a listed domain that gets
|
|
6
|
+
// past this instance's sign-in door is staff: it sees every brand owner on the instance
|
|
7
|
+
// (driver/portal-access.mjs). That is a rule about a set of people, not about one person.
|
|
8
|
+
//
|
|
9
|
+
// The install used to build that rule by taking everything after the last `@` of the one address it
|
|
10
|
+
// was given. On a laptop that address is `<account>@localhost` and the rule admits the one identity
|
|
11
|
+
// that can sign in, which is why it was safe and why nobody looked at it again. Given a real address
|
|
12
|
+
// the same line turns a person into their whole employer, or into every customer of a webmail
|
|
13
|
+
// provider, with nothing said to the operator and nothing to undo. A stranger's install granted a
|
|
14
|
+
// documentation domain that way, read its own settings page back, and reported it as a back door.
|
|
15
|
+
//
|
|
16
|
+
// So the derivation is classified before it is used, and there are three answers:
|
|
17
|
+
//
|
|
18
|
+
// "narrow" the domain cannot name a second person on this machine — `localhost`, or any bare
|
|
19
|
+
// hostname with no dot. Deriving a rule from it is what it always was: safe, silent.
|
|
20
|
+
// "public" a webmail or shared provider. Never a staff rule: it would admit strangers, and no
|
|
21
|
+
// deployment can want it. Refused outright, and the refusal names the domain.
|
|
22
|
+
// "reserved" a domain reserved for documentation and testing (RFC 2606). Nobody's real mail lives
|
|
23
|
+
// there, so a rule built from one is always an address somebody typed as a placeholder.
|
|
24
|
+
// Refused for the same reason, with a different sentence, because the remedy differs.
|
|
25
|
+
// "wide" an ordinary routable domain. It may well be the right rule — it is how a firm admits
|
|
26
|
+
// its own lawyers — but it admits people the operator has not met, so it is stated in
|
|
27
|
+
// the words the settings page will use and confirmed before it is written. It is never
|
|
28
|
+
// derived silently from one address.
|
|
29
|
+
//
|
|
30
|
+
// FAIL-CLOSED, in the direction of a smaller grant. A domain wrongly classified `public` refuses, and
|
|
31
|
+
// the operator sets `PORTAL_STAFF_DOMAINS` themselves in one line — an explicit decision, recorded
|
|
32
|
+
// where the settings page can name it. A domain wrongly classified `wide` asks a question. Neither
|
|
33
|
+
// outcome grants anything, which is why the lists below are allowed to be short and stay short: the
|
|
34
|
+
// `wide` branch is what actually protects an operator, and the lists only decide whether the product
|
|
35
|
+
// asks a question or refuses to ask one.
|
|
36
|
+
|
|
37
|
+
/** Last-@ semantics, matching driver/portal-access.mjs and shared/scope.mjs `isFirmDomain`. */
|
|
38
|
+
export function domainOfEmail(email) {
|
|
39
|
+
const e = String(email ?? "").trim().toLowerCase();
|
|
40
|
+
const at = e.lastIndexOf("@");
|
|
41
|
+
return at > 0 ? e.slice(at + 1) : "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Webmail and shared mailbox providers, exactly.
|
|
46
|
+
*
|
|
47
|
+
* Not a census of the internet, and deliberately not growing into one: an unlisted provider still
|
|
48
|
+
* lands in `wide`, where it is stated and confirmed rather than written silently. What this list buys
|
|
49
|
+
* is that the commonest addresses a person types — their own personal mail — are refused with a
|
|
50
|
+
* sentence about why, instead of being offered as a rule somebody might say yes to.
|
|
51
|
+
*/
|
|
52
|
+
export const PUBLIC_EMAIL_DOMAINS = Object.freeze(new Set([
|
|
53
|
+
"gmail.com", "googlemail.com", "icloud.com", "me.com", "mac.com",
|
|
54
|
+
"aol.com", "mail.com", "gmx.com", "gmx.de", "gmx.net", "web.de", "t-online.de",
|
|
55
|
+
"proton.me", "protonmail.com", "protonmail.ch", "pm.me", "tuta.io", "tutanota.com",
|
|
56
|
+
"fastmail.com", "fastmail.fm", "hey.com", "zoho.com", "hushmail.com",
|
|
57
|
+
"mail.ru", "inbox.ru", "list.ru", "bk.ru", "rambler.ru",
|
|
58
|
+
"qq.com", "163.com", "126.com", "sina.com", "naver.com", "daum.net",
|
|
59
|
+
"seznam.cz", "wp.pl", "o2.pl", "interia.pl", "libero.it", "virgilio.it", "tiscali.it",
|
|
60
|
+
"free.fr", "orange.fr", "wanadoo.fr", "laposte.net", "sfr.fr", "bbox.fr",
|
|
61
|
+
"comcast.net", "verizon.net", "att.net", "sbcglobal.net", "bellsouth.net", "cox.net",
|
|
62
|
+
"btinternet.com", "sky.com", "virginmedia.com", "ntlworld.com", "talktalk.net",
|
|
63
|
+
"bigpond.com", "optusnet.com.au", "shaw.ca", "sympatico.ca", "rogers.com",
|
|
64
|
+
"xs4all.nl", "ziggo.nl", "telenet.be", "uol.com.br", "bol.com.br", "terra.com.br",
|
|
65
|
+
"rediffmail.com", "yandex.com", "yandex.ru", "ya.ru",
|
|
66
|
+
]));
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Providers that sell the same mailbox under a per-country domain.
|
|
70
|
+
*
|
|
71
|
+
* Matched on the FIRST label, so `yahoo.co.uk` and `yahoo.fr` are covered without listing every
|
|
72
|
+
* country. Kept to names that are a provider and nothing else — a first label like `free` or `orange`
|
|
73
|
+
* is a real company's name somewhere, so those stay in the exact list above.
|
|
74
|
+
*/
|
|
75
|
+
export const PUBLIC_EMAIL_FAMILIES = Object.freeze(new Set([
|
|
76
|
+
"yahoo", "ymail", "rocketmail", "hotmail", "outlook", "live", "msn", "gmx", "yandex", "googlemail",
|
|
77
|
+
]));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Reserved for documentation and testing — RFC 2606 §2 and §3.
|
|
81
|
+
*
|
|
82
|
+
* `example.com`, `example.net`, `example.org` and everything under them, plus the whole of the
|
|
83
|
+
* `.example`, `.invalid`, `.test` and `.localhost` top-level names. Real mail is never delivered to
|
|
84
|
+
* any of them, so an address at one is a placeholder somebody typed — which is exactly what happened.
|
|
85
|
+
*
|
|
86
|
+
* `localhost` ITSELF IS NOT HERE. A bare `localhost` is the local-account default and is classified
|
|
87
|
+
* `narrow` below; only `something.localhost` reaches this test.
|
|
88
|
+
*/
|
|
89
|
+
function isReservedDomain(domain) {
|
|
90
|
+
if (/^example\.(com|net|org)$/.test(domain)) return true;
|
|
91
|
+
if (/\.example\.(com|net|org)$/.test(domain)) return true;
|
|
92
|
+
const tld = domain.slice(domain.lastIndexOf(".") + 1);
|
|
93
|
+
return domain.includes(".") && ["example", "invalid", "test", "localhost"].includes(tld);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** A domain that cannot name a second person on this machine: `localhost`, or any dotless hostname. */
|
|
97
|
+
function isNarrowDomain(domain) {
|
|
98
|
+
return domain === "localhost" || domain === "localhost.localdomain" || !domain.includes(".");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What a staff rule built from this domain would admit.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} domain a bare domain, already lowercased — `domainOfEmail` produces one.
|
|
105
|
+
* @returns {"narrow"|"public"|"reserved"|"wide"|""} "" only when there is no domain to classify.
|
|
106
|
+
*/
|
|
107
|
+
export function classifyStaffDomain(domain) {
|
|
108
|
+
const d = String(domain ?? "").trim().toLowerCase();
|
|
109
|
+
if (!d) return "";
|
|
110
|
+
if (isNarrowDomain(d)) return "narrow";
|
|
111
|
+
if (isReservedDomain(d)) return "reserved";
|
|
112
|
+
if (PUBLIC_EMAIL_DOMAINS.has(d)) return "public";
|
|
113
|
+
if (PUBLIC_EMAIL_FAMILIES.has(d.slice(0, d.indexOf(".")))) return "public";
|
|
114
|
+
return "wide";
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The rule in the words the settings page uses, so the operator recognises what they agreed to.
|
|
119
|
+
*
|
|
120
|
+
* ONE COMPOSER FOR BOTH SURFACES. The People & access screen renders "Anyone at <domain>" under a
|
|
121
|
+
* heading that calls it a config rule rather than a person, and describes staff as capable of seeing
|
|
122
|
+
* every brand owner. A consent prompt phrased any other way asks about one thing and shows another.
|
|
123
|
+
*/
|
|
124
|
+
export function staffGrantSentence(domain, { staffLabel = "Staff" } = {}) {
|
|
125
|
+
return `Anyone at ${domain} — a rule, not a person. ${staffLabel}: capable to see every brand owner `
|
|
126
|
+
+ "on this instance.";
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Why this domain cannot become a staff rule, and what to do instead.
|
|
131
|
+
*
|
|
132
|
+
* Returns null for a domain that CAN — `narrow` and `wide` are not refusals, and `wide` is answered by
|
|
133
|
+
* the confirmation the caller runs, not by this function.
|
|
134
|
+
*/
|
|
135
|
+
export function staffDomainRefusal(domain, { variable = "PORTAL_STAFF_DOMAINS" } = {}) {
|
|
136
|
+
const d = String(domain ?? "").trim().toLowerCase();
|
|
137
|
+
const verdict = classifyStaffDomain(d);
|
|
138
|
+
if (verdict === "public") {
|
|
139
|
+
return `${d} is a public email provider, so a staff rule built from it would admit anyone with an `
|
|
140
|
+
+ `address there — not your colleagues. Refusing to write one.\n`
|
|
141
|
+
+ ` Use an address at a domain your organisation controls, or set ${variable} yourself to the `
|
|
142
|
+
+ "domain you mean.\n"
|
|
143
|
+
+ " If this machine is only yours, the local-account form takes no rule at all: leave the "
|
|
144
|
+
+ "address as <account>@localhost.";
|
|
145
|
+
}
|
|
146
|
+
if (verdict === "reserved") {
|
|
147
|
+
return `${d} is reserved for documentation and receives no real mail (RFC 2606), so it is an `
|
|
148
|
+
+ "address somebody typed as a placeholder rather than one that signs in. Refusing to build a "
|
|
149
|
+
+ "staff rule from it.\n"
|
|
150
|
+
+ " Use the address you actually sign in with, or the local-account form <account>@localhost "
|
|
151
|
+
+ "if this machine is only yours.";
|
|
152
|
+
}
|
|
153
|
+
return null;
|
|
154
|
+
}
|
package/shared/store-in-repo.mjs
CHANGED
|
@@ -38,13 +38,34 @@
|
|
|
38
38
|
// that does not exist yet is still misconfigured if it names another tree — the service would create it
|
|
39
39
|
// and then fail every commit — so the lexical judgment stands where the filesystem cannot overturn it.
|
|
40
40
|
|
|
41
|
-
import { resolve, dirname } from "node:path";
|
|
41
|
+
import { resolve, dirname, relative, isAbsolute } from "node:path";
|
|
42
42
|
import { realpathSync } from "node:fs";
|
|
43
43
|
|
|
44
44
|
/** `resolve`, then the symlink-resolved form when the path exists — null when it cannot be read. */
|
|
45
45
|
const real = (p) => { try { return realpathSync(resolve(p)); } catch { return null; } };
|
|
46
46
|
|
|
47
|
-
|
|
47
|
+
// CONTAINMENT IS ASKED OF THE PATH LIBRARY, NOT OF THE STRING.
|
|
48
|
+
//
|
|
49
|
+
// This was `dir.startsWith(root + "/")`, with the separator written in. On Windows `resolve()` returns
|
|
50
|
+
// backslashes, so `C:\…\config\recipes` does not start with `C:\…\config/` and a folder plainly inside
|
|
51
|
+
// its parent read as OUTSIDE — an operator on a correct install was told their recipe store was outside
|
|
52
|
+
// the repository root, and the saved-search door refused. Measured on his paths.
|
|
53
|
+
//
|
|
54
|
+
// The posix control passed throughout, which is why it survived: the string test is right on the platform
|
|
55
|
+
// it was written on and wrong on the one nobody here runs.
|
|
56
|
+
//
|
|
57
|
+
// `relative` also settles two cases a prefix test gets wrong on Windows even with the separator fixed: a
|
|
58
|
+
// different drive letter yields an absolute path rather than a `..` walk, and path comparison there is
|
|
59
|
+
// case-insensitive in a way `startsWith` is not. A sibling directory whose name merely begins with the
|
|
60
|
+
// root's — `config` and `configXX` — is still outside, which the old `+ "/"` also got right and any
|
|
61
|
+
// replacement had to keep.
|
|
62
|
+
// `impl` is the path module to ask, injected ONLY so a check can drive the Windows behaviour from a
|
|
63
|
+
// Linux runner. That matters here more than usual: this defect was invisible on the platform every one
|
|
64
|
+
// of us runs, and a guard that can only be exercised on Windows is a guard nobody will ever see fail.
|
|
65
|
+
export const within = (dir, root, impl = { relative, isAbsolute }) => {
|
|
66
|
+
const rel = impl.relative(root, dir);
|
|
67
|
+
return rel === "" || (!rel.startsWith("..") && !impl.isAbsolute(rel));
|
|
68
|
+
};
|
|
48
69
|
|
|
49
70
|
/**
|
|
50
71
|
* Is `storeDir` somewhere `git -C repoRoot add` could stage it?
|
package/shared/suite-census.mjs
CHANGED
|
@@ -38,7 +38,7 @@ export const CENSUS_WORKSPACES = Object.freeze([
|
|
|
38
38
|
|
|
39
39
|
// ── — THE FOURTH POPULATION, AND WHY IT IS NOT A FOURTH WORKSPACE ROW ───────────────────────
|
|
40
40
|
//
|
|
41
|
-
// MEASURED (
|
|
41
|
+
// MEASURED (in testing, 2026-08-24): `npm run test:providers` on the control tree ran 59 files / 624
|
|
42
42
|
// tests; on the cut tree, 47 files / 426 tests. **198 tests disappeared and the command exited 0.** No
|
|
43
43
|
// failure, no warning, no line saying a file was not picked up.
|
|
44
44
|
//
|
|
@@ -62,6 +62,13 @@ export const CENSUS_ROOT_SCRIPTS = Object.freeze([
|
|
|
62
62
|
Object.freeze({
|
|
63
63
|
script: "test:providers",
|
|
64
64
|
globs: Object.freeze(["providers/_shared/test/*.test.mjs", "providers/*/test/*.test.mjs"]),
|
|
65
|
+
// THE COMMAND NO LONGER SPELLS THOSE GLOBS; it reaches them through this file, which builds the
|
|
66
|
+
// list in code so the corpus has one definition and two entry points. The literals above stay
|
|
67
|
+
// exactly as they were — they are this module's EXPECTATION, and deleting them in favour of the
|
|
68
|
+
// resolved list would leave two derived sides with nothing to disagree about, which is the defect
|
|
69
|
+
// the block above this table exists to record. What changes is only how the measurement is taken:
|
|
70
|
+
// the resolved list is asked whether it still reaches each glob.
|
|
71
|
+
resolvedBy: "scripts/test-full.mjs",
|
|
65
72
|
}),
|
|
66
73
|
]);
|
|
67
74
|
|
|
@@ -74,7 +81,7 @@ export const CENSUS_ROOT_SCRIPTS = Object.freeze([
|
|
|
74
81
|
*
|
|
75
82
|
* PURE: `readManifest` is injected, so the canary drives every branch over a planted manifest.
|
|
76
83
|
*/
|
|
77
|
-
export function rootScriptDisagreements(readManifest, declared = CENSUS_ROOT_SCRIPTS) {
|
|
84
|
+
export function rootScriptDisagreements(readManifest, declared = CENSUS_ROOT_SCRIPTS, resolveFiles = null) {
|
|
78
85
|
const root = readManifest("package.json");
|
|
79
86
|
if (!root) return ["the root package.json could not be read, so what the root scripts collect is unknown"];
|
|
80
87
|
const scripts = root.scripts ?? {};
|
|
@@ -86,8 +93,23 @@ export function rootScriptDisagreements(readManifest, declared = CENSUS_ROOT_SCR
|
|
|
86
93
|
continue;
|
|
87
94
|
}
|
|
88
95
|
for (const g of globs) {
|
|
89
|
-
if (
|
|
96
|
+
if (line.includes(g)) continue;
|
|
97
|
+
// THE COMMAND DOES NOT SPELL THIS GLOB. That is a disagreement unless the script says it
|
|
98
|
+
// resolves the corpus elsewhere AND the resolved list is available to be checked. A declared
|
|
99
|
+
// `resolvedBy` with no resolver in hand is a could-not-look, never a pass: it would let an
|
|
100
|
+
// entry opt out of the whole check by naming a file.
|
|
101
|
+
const via = declared.find((d) => d.script === script)?.resolvedBy;
|
|
102
|
+
if (!via) {
|
|
90
103
|
out.push(`\`${script}\` is censused as collecting ${g} and its command is: ${line.trim()}`);
|
|
104
|
+
} else if (!line.includes(via)) {
|
|
105
|
+
out.push(`\`${script}\` is censused as resolving its corpus through ${via}, and its command `
|
|
106
|
+
+ `does not name it: ${line.trim()}`);
|
|
107
|
+
} else if (typeof resolveFiles !== "function") {
|
|
108
|
+
out.push(`\`${script}\` resolves its corpus through ${via} and no resolver was supplied, so `
|
|
109
|
+
+ `what it collects could not be read — which is not the same as it being right`);
|
|
110
|
+
} else if (!resolveFiles(script).some((f) => globMatches(g, f))) {
|
|
111
|
+
out.push(`\`${script}\` is censused as collecting ${g}; ${via} resolves a corpus that `
|
|
112
|
+
+ `reaches no such file`);
|
|
91
113
|
}
|
|
92
114
|
}
|
|
93
115
|
}
|
|
@@ -117,6 +139,12 @@ export function rootScriptDisagreements(readManifest, declared = CENSUS_ROOT_SCR
|
|
|
117
139
|
// PURE AND INJECTABLE. `readManifest` is passed in, so the canary drives every branch over planted
|
|
118
140
|
// manifests without touching the tree — the same rule the rest of this module follows.
|
|
119
141
|
|
|
142
|
+
/** Does one `*`-style glob match this path? `*` stops at a separator, as a shell's does. */
|
|
143
|
+
export function globMatches(glob, path) {
|
|
144
|
+
const re = new RegExp("^" + glob.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*") + "$");
|
|
145
|
+
return re.test(path);
|
|
146
|
+
}
|
|
147
|
+
|
|
120
148
|
/** `npm run <name>` inside a script, so an indirection resolves within its own manifest. */
|
|
121
149
|
const NPM_RUN = /^\s*npm\s+run\s+([A-Za-z0-9:_-]+)\s*$/;
|
|
122
150
|
|