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.
Files changed (161) hide show
  1. package/.env.example +64 -37
  2. package/.nvmrc +1 -1
  3. package/CONTRIBUTING.md +8 -4
  4. package/INSTALL.md +36 -14
  5. package/README.md +18 -22
  6. package/THIRD-PARTY-NOTICES.md +1 -1
  7. package/bin/clearotron.mjs +22 -0
  8. package/bin/example.mjs +10 -5
  9. package/bin/onboard.mjs +370 -28
  10. package/bin/start.mjs +220 -12
  11. package/build-info.json +2 -2
  12. package/demo/README.md +1 -1
  13. package/demo/full-country-search/run/_driver/framework.json +1 -1
  14. package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
  15. package/demo/knockout-search/run/_driver/framework.json +1 -1
  16. package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
  17. package/docs/E2E.md +5 -5
  18. package/docs/GLOSSARY.md +85 -0
  19. package/docs/PORTAL.md +2 -2
  20. package/docs/README.md +6 -4
  21. package/docs/SECURITY.md +17 -0
  22. package/docs/architecture/01-product-overview.md +21 -9
  23. package/docs/architecture/04-configuration-reference.md +2 -0
  24. package/docs/architecture/05-config-governance.md +19 -0
  25. package/docs/architecture/05-customer-profiles.md +2 -2
  26. package/docs/configuration.md +4 -3
  27. package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
  28. package/driver/CHANGELOG.md +90 -0
  29. package/driver/README.md +25 -6
  30. package/driver/common-law-receipts.mjs +1 -1
  31. package/driver/config-staleness.mjs +1 -1
  32. package/driver/connotation-search.mjs +1 -1
  33. package/driver/contract-audit.mjs +5 -1
  34. package/driver/contract-e3-baseline.json +11 -11
  35. package/driver/dev-portal.mjs +2 -2
  36. package/driver/doubt-selection.mjs +1 -1
  37. package/driver/drainer-identity.mjs +1 -1
  38. package/driver/driver.config.mjs +1 -1
  39. package/driver/effort-model.mjs +2 -2
  40. package/driver/engine/cli-version.mjs +103 -0
  41. package/driver/engine/probe.mjs +45 -5
  42. package/driver/enqueue-schema.mjs +8 -8
  43. package/driver/feedback-store.mjs +1 -1
  44. package/driver/findings-model.mjs +52 -2
  45. package/driver/flag-snapshot.mjs +20 -0
  46. package/driver/framework.mjs +1 -1
  47. package/driver/gateway.mjs +20 -3
  48. package/driver/outbox-backoff.mjs +1 -1
  49. package/driver/package.json +3 -3
  50. package/driver/party-facts.mjs +2 -2
  51. package/driver/phase0.mjs +1 -1
  52. package/driver/pipeline.mjs +211 -106
  53. package/driver/plain-register.mjs +134 -0
  54. package/driver/portal-access.mjs +1 -1
  55. package/driver/portal-config-view.mjs +70 -2
  56. package/driver/portal-families.mjs +1 -1
  57. package/driver/portal-local-auth.mjs +5 -1
  58. package/driver/portal-report.mjs +1 -1
  59. package/driver/portal-service.mjs +79 -9
  60. package/driver/portal-upstream.mjs +2 -2
  61. package/driver/predelivery-lint.mjs +151 -2
  62. package/driver/profile-page.html +10 -2
  63. package/driver/profile-service.mjs +4 -4
  64. package/driver/profiles/README.md +3 -3
  65. package/driver/profiles.mjs +144 -12
  66. package/driver/publish/index.mjs +2 -2
  67. package/driver/publish/knockout.mjs +7 -1
  68. package/driver/publish/render-knockout.mjs +453 -118
  69. package/driver/publish/render.mjs +111 -16
  70. package/driver/reference-strip-signatures.mjs +20 -0
  71. package/driver/search-policy.mjs +3 -3
  72. package/driver/skills/blind-frame/SKILL.md +1 -1
  73. package/driver/skills/knockout-assess/SKILL.md +84 -0
  74. package/driver/skills/matter-frame/SKILL.md +6 -6
  75. package/driver/skills/matter-frame/watchlist-reference.md +2 -2
  76. package/driver/skills/prelim-common-law/SKILL.md +1 -1
  77. package/driver/skills/prelim-search/SKILL.md +1 -1
  78. package/driver/skills/prelim-search/report-prose.md +82 -0
  79. package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
  80. package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
  81. package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
  82. package/driver/skills/prelim-search/synthesis-rules.md +1 -1
  83. package/driver/skills/prelim-variants/SKILL.md +3 -3
  84. package/driver/stage-context.mjs +13 -0
  85. package/driver/stages.mjs +54 -7
  86. package/driver/suite-census.json +220 -58
  87. package/driver/systemd/clearotron-worker.service +3 -3
  88. package/driver/tokens.mjs +1 -1
  89. package/driver/unit-inventory.mjs +214 -27
  90. package/driver/verify.mjs +1 -1
  91. package/driver/whatif-memo-run.mjs +26 -4
  92. package/driver/whatif-memo.mjs +19 -1
  93. package/examples/README.md +2 -2
  94. package/examples/grants.example.json +6 -7
  95. package/mcp-server/CHANGELOG.md +4 -0
  96. package/mcp-server/lib/scrub.mjs +1 -1
  97. package/mcp-server/lib/whatif.mjs +2 -2
  98. package/mcp-server/package.json +1 -1
  99. package/mcp-server/packs/ops/EXAMPLES.md +2 -2
  100. package/mcp-server/server.mjs +1 -1
  101. package/package.json +31 -19
  102. package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
  103. package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
  104. package/portal-ui/dist/index.html +2 -2
  105. package/portal-ui/package.json +1 -1
  106. package/providers/_shared/term-shape.mjs +1 -1
  107. package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
  108. package/providers/oauth-mcp-bridge/package.json +1 -1
  109. package/providers/uspto-local/src/index-store.js +2 -2
  110. package/scripts/added-reference-check.mjs +45 -89
  111. package/scripts/citation-line-check.mjs +121 -2
  112. package/scripts/e2e.mjs +2 -2
  113. package/scripts/env-classify.mjs +67 -3
  114. package/scripts/generated-files-are-current.mjs +125 -0
  115. package/scripts/live-surface-check.mjs +38 -1
  116. package/scripts/mint-names-in-force.mjs +4 -2
  117. package/scripts/mint-public-residue.mjs +74 -0
  118. package/scripts/mint-reference-strip-backlog.mjs +12 -1
  119. package/scripts/mint-suite-census.mjs +5 -2
  120. package/scripts/nightly-notice.mjs +0 -2
  121. package/scripts/preinstall-node-check.mjs +24 -0
  122. package/scripts/render-check.mjs +13 -2
  123. package/scripts/report-frame-check.mjs +8 -1
  124. package/scripts/report-screenshot.mjs +6 -3
  125. package/scripts/revisit-render-check.mjs +6 -3
  126. package/scripts/score.mjs +1 -1
  127. package/scripts/strip-tracker-citations.mjs +38 -7
  128. package/scripts/test-full.mjs +226 -0
  129. package/scripts/test-run.mjs +16 -0
  130. package/shared/README.md +2 -1
  131. package/shared/anon-overlay.mjs +3 -3
  132. package/shared/browser-temp-root.mjs +142 -0
  133. package/shared/checkout-move.mjs +1 -1
  134. package/shared/client-door.mjs +4 -3
  135. package/shared/customer-store.mjs +1 -1
  136. package/shared/identifier-scan.mjs +1 -1
  137. package/shared/install-auth.mjs +35 -0
  138. package/shared/invocation.mjs +33 -1
  139. package/shared/listen.mjs +55 -3
  140. package/shared/names-in-force.mjs +6 -1
  141. package/shared/node-floor.mjs +97 -0
  142. package/shared/os-advice.mjs +91 -0
  143. package/shared/reap-on-exit.mjs +42 -0
  144. package/shared/reference-guard-classes.mjs +351 -0
  145. package/shared/staff-domain.mjs +154 -0
  146. package/shared/store-in-repo.mjs +23 -2
  147. package/shared/suite-census.mjs +31 -3
  148. package/shared/withheld-paths-access.mjs +36 -18
  149. package/skills/clearotron-ops/SKILL.md +1 -1
  150. package/driver/recipes/README.md +0 -49
  151. package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
  152. package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
  153. package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
  154. package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
  155. package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
  156. package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
  157. package/scripts/ai-page-render-check.mjs +0 -532
  158. package/scripts/clearances-render-check.mjs +0 -480
  159. package/scripts/composer-render-check.mjs +0 -1027
  160. package/scripts/home-render-check.mjs +0 -526
  161. 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
+ }
@@ -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
- const within = (dir, root) => dir === root || dir.startsWith(root.endsWith("/") ? root : root + "/");
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?
@@ -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 (eggie/Jerry, 2026-08-24): `npm run test:providers` on the control tree ran 59 files / 624
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 (!line.includes(g)) {
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