clearotron 0.2.4 → 0.3.0-beta.1
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 +13 -2
- package/CONTRIBUTING.md +1 -1
- package/INSTALL.md +62 -38
- package/bin/brandowner.mjs +18 -169
- package/bin/clearotron.mjs +3 -1
- package/bin/connect.mjs +28 -19
- package/bin/disconnect.mjs +3 -3
- package/bin/example.mjs +7 -7
- package/bin/framework-preflight.mjs +49 -0
- package/bin/grant.mjs +151 -93
- package/bin/onboard.mjs +220 -138
- package/bin/start.mjs +146 -137
- package/bin/stop.mjs +2 -2
- package/bin/update.mjs +1 -1
- package/build-info.json +2 -2
- package/docs/CLIENT-MCP.md +2 -2
- package/docs/E2E.md +12 -2
- package/docs/ONBOARDING.md +1 -1
- package/docs/PORTAL.md +14 -13
- package/docs/SECURITY.md +23 -24
- package/docs/architecture/04-configuration-reference.md +12 -5
- package/docs/architecture/05-config-governance.md +7 -7
- package/docs/architecture/07-quality-and-audit.md +1 -1
- package/docs/architecture/08-development-guide.md +5 -0
- package/docs/configuration.md +118 -0
- package/docs/decisions/0004-documentation-structure.md +2 -2
- package/docs/decisions/0006-what-the-public-repository-carries.md +2 -2
- package/driver/CHANGELOG.md +70 -0
- package/driver/ask-ledger.mjs +2 -2
- package/driver/cancel.mjs +27 -0
- package/driver/case-law-sources.mjs +3 -3
- package/driver/company-bundle.mjs +261 -0
- package/driver/compare.mjs +1 -1
- package/driver/compose-read.mjs +2 -2
- package/driver/config-inventory.mjs +2 -2
- package/driver/contract-audit.mjs +1 -1
- package/driver/contract-e3-backlog.mjs +1 -1
- package/driver/contract-vocabulary.mjs +1 -1
- package/driver/declination-call.mjs +1 -1
- package/driver/deliver-trigger.sh +3 -3
- package/driver/dev-portal.mjs +3 -1
- package/driver/digest-queue.mjs +1 -1
- package/driver/disposition-tool.mjs +1 -1
- package/driver/doc-constants.mjs +1 -1
- package/driver/drain-posture.mjs +2 -2
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +44 -9
- package/driver/effective-scope.mjs +30 -1
- package/driver/effort-model.mjs +6 -6
- package/driver/engine/CONTRACT.md +2 -2
- package/driver/engine/anthropic-agent.mjs +11 -11
- package/driver/engine/jx-turn.mjs +1 -1
- package/driver/engine/mcp/gather-config.mjs +29 -4
- package/driver/engine/mcp/recording-server.mjs +73 -1
- package/driver/engine/openai-agent.mjs +1 -1
- package/driver/engine/probe.mjs +28 -4
- package/driver/enqueue-schema.mjs +23 -3
- package/driver/findings-model.mjs +2 -2
- package/driver/flag-snapshot.mjs +2 -2
- package/driver/floor-duty.mjs +2 -2
- package/driver/frame-diff-model.mjs +1 -1
- package/driver/framework-preflight.mjs +143 -0
- package/driver/gateway.mjs +9 -1
- package/driver/hit-list.mjs +1 -1
- package/driver/jx-lanes.mjs +1 -1
- package/driver/jx.mjs +1 -1
- package/driver/knockout-assess-record.mjs +1 -1
- package/driver/knockout-review-record.mjs +435 -0
- package/driver/order-probe.mjs +1 -1
- package/driver/outbox-backoff.mjs +2 -2
- package/driver/owner-use-check.mjs +2 -2
- package/driver/package.json +1 -1
- package/driver/pipeline-knockout.mjs +105 -8
- package/driver/pipeline.mjs +81 -30
- package/driver/plain-register.mjs +77 -3
- package/driver/portal-access.mjs +141 -74
- package/driver/portal-config-view.mjs +59 -70
- package/driver/portal-report.mjs +4 -4
- package/driver/portal-service.mjs +348 -141
- package/driver/portal-upstream.mjs +105 -17
- package/driver/predelivery-lint.mjs +43 -18
- package/driver/product-rows.mjs +1 -1
- package/driver/products.mjs +1 -1
- package/driver/profile-page.html +30 -5
- package/driver/profile-service.mjs +197 -26
- package/driver/profiles.mjs +48 -1
- package/driver/publish/index.mjs +31 -13
- package/driver/publish/knockout.mjs +9 -5
- package/driver/publish/office-record-links.mjs +189 -0
- package/driver/publish/parse.mjs +3 -3
- package/driver/publish/publish-inputs.mjs +26 -0
- package/driver/publish/render-knockout.mjs +42 -42
- package/driver/publish/render.mjs +29 -4
- package/driver/publish/report-data.mjs +2 -2
- package/driver/publish/seed-pool.mjs +1 -1
- package/driver/publish/templates/report.css +8 -8
- package/driver/publish/xlsx.mjs +49 -7
- package/driver/queue-watch-verdict.mjs +2 -2
- package/driver/recipe-service.mjs +1 -1
- package/driver/record-carry.mjs +1 -1
- package/driver/reference-score.mjs +1 -1
- package/driver/reference-strip-signatures.mjs +1 -1
- package/driver/register-availability.mjs +4 -3
- package/driver/register-count.mjs +3 -3
- package/driver/register-records.mjs +1 -1
- package/driver/repair-composers.mjs +1 -1
- package/driver/repairs.mjs +3 -3
- package/driver/replay-archive.mjs +1 -1
- package/driver/report-card-record.mjs +1 -1
- package/driver/result-noun-fields.mjs +5 -0
- package/driver/roster-verdict.mjs +48 -5
- package/driver/run-activity.mjs +1 -1
- package/driver/run-requirements.mjs +18 -5
- package/driver/runner.mjs +24 -15
- package/driver/search-policy.mjs +8 -8
- package/driver/senior-rights.mjs +1 -1
- package/driver/skills/prelim-search/delivery-contract.md +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.md +10 -7
- package/driver/stages-knockout.mjs +72 -6
- package/driver/stages.mjs +10 -10
- package/driver/suite-census.json +238 -70
- package/driver/synthesis-record.mjs +2 -2
- package/driver/systemd/clearotron-client-mcp.service +3 -3
- package/driver/systemd/clearotron-deploy.service +2 -2
- package/driver/systemd/clearotron-mcp-face.service +1 -1
- package/driver/systemd/clearotron-portal.service +3 -3
- package/driver/systemd/clearotron-worker.service +5 -5
- package/driver/systemd/install-census.mjs +1 -1
- package/driver/systemd/render-units.mjs +9 -9
- package/driver/terminal-clamp.mjs +1 -1
- package/driver/trigger-cap.mjs +18 -2
- package/driver/unit-inventory.mjs +8 -8
- package/driver/usage-ledger.mjs +5 -3
- package/driver/verify-knockout.mjs +7 -7
- package/driver/verify.mjs +5 -5
- package/driver/whatif-memo-run.mjs +1 -1
- package/driver/whatif-queue.mjs +3 -3
- package/driver/whatif-worker.mjs +2 -2
- package/examples/README.md +1 -1
- package/examples/grants.example.json +25 -24
- package/mcp-server/CHANGELOG.md +10 -0
- package/mcp-server/http-server.mjs +3 -3
- package/mcp-server/key-socket.mjs +1 -1
- package/mcp-server/lib/audit-view.mjs +3 -3
- package/mcp-server/lib/brief.mjs +3 -3
- package/mcp-server/lib/driver.mjs +1 -1
- package/mcp-server/lib/events.mjs +1 -1
- package/mcp-server/lib/http-handler.mjs +2 -2
- package/mcp-server/lib/instructions.mjs +2 -2
- package/mcp-server/lib/knockout.mjs +1 -1
- package/mcp-server/lib/ops.mjs +6 -3
- package/mcp-server/lib/options.mjs +15 -4
- package/mcp-server/lib/plan.mjs +7 -6
- package/mcp-server/lib/runs.mjs +10 -0
- package/mcp-server/lib/whatif.mjs +5 -5
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/README.md +1 -1
- package/mcp-server/remote/client-mcp-apikey.service +1 -1
- package/mcp-server/remote/client-mcp.service +2 -2
- package/mcp-server/remote/trademark-artifacts-http.service +1 -1
- package/mcp-server/server.mjs +38 -24
- package/package.json +2 -2
- package/portal-ui/dist/assets/{index-KFAHMgdT.js → index-CWTHP0sH.js} +3471 -1901
- package/portal-ui/dist/assets/{index-1ziUJX1E.css → index-KpytsmNH.css} +79 -26
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/lane-probe.mjs +9 -3
- package/providers/jx-subclass/lookup.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/oauth-mcp-bridge/systemd/courtlistener-mcp.service +1 -1
- package/scripts/citation-drift-report.mjs +1 -1
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/drive-env-check.mjs +1 -1
- package/scripts/e2e.mjs +5 -5
- package/scripts/env-audit.mjs +13 -1
- package/scripts/headless-page.mjs +5 -5
- package/scripts/live-surface-check.mjs +26 -2
- package/scripts/mint-names-in-force.mjs +19 -5
- package/scripts/mint-reference-strip-backlog.mjs +1 -1
- package/scripts/mint-suite-census.mjs +37 -10
- package/scripts/pack-publishable.mjs +1 -1
- package/scripts/preinstall-node-check.mjs +1 -1
- package/scripts/release-await-cut.mjs +3 -3
- package/scripts/release-cut-decision.mjs +1 -1
- package/scripts/release-dist-tag.mjs +1 -1
- package/scripts/release-install-check.mjs +1 -1
- package/scripts/release-notes-lint.mjs +1 -1
- package/scripts/release-publish-guard.mjs +1 -1
- package/scripts/release-version-pr-checks.mjs +2 -2
- package/scripts/release-version.mjs +61 -5
- package/scripts/render-brand-banner.mjs +1 -1
- package/scripts/render-check.mjs +2 -2
- package/scripts/repo-writes.mjs +1 -1
- package/scripts/report-frame-check.mjs +1 -1
- package/scripts/report-screenshot.mjs +2 -2
- package/scripts/retire-bare-refs.mjs +1 -1
- package/scripts/revisit-render-check.mjs +1 -1
- package/scripts/score.mjs +1 -1
- package/scripts/strip-titles-and-attributions.mjs +389 -0
- package/scripts/strip-tracker-citations.mjs +122 -5
- package/scripts/test-run.mjs +4 -4
- package/scripts/third-party-notices.mjs +1 -1
- package/scripts/verify-publishable.mjs +1 -1
- package/shared/access-audience.mjs +2 -2
- package/shared/anon-overlay.mjs +1 -1
- package/shared/brand.mjs +15 -1
- package/shared/bundle-freshness.mjs +1 -1
- package/shared/bundle-rebuild.mjs +1 -1
- package/shared/checkout-move.mjs +2 -2
- package/shared/client-door.mjs +8 -8
- package/shared/connect-clients.mjs +7 -7
- package/shared/connector-signin-probe.mjs +1 -1
- package/shared/env-aliases.mjs +1 -1
- package/shared/env-local.mjs +5 -5
- package/shared/grants-edit.mjs +76 -0
- package/shared/install-auth.mjs +1 -1
- package/shared/listen.mjs +3 -3
- package/shared/mcp-challenge.mjs +1 -1
- package/shared/names-in-force.mjs +2 -1
- package/shared/onboarding-store.mjs +19 -2
- package/shared/reference-guard-classes.mjs +44 -2
- package/shared/register-selection.mjs +1 -1
- package/shared/scope.mjs +223 -56
- package/shared/secret-file.mjs +1 -1
- package/shared/server-units.mjs +1 -1
- package/shared/staff-domain.mjs +45 -78
- package/shared/summary-blocks.mjs +2 -2
- package/shared/systemd-failure.mjs +3 -3
- package/shared/tracked-files.mjs +1 -1
- package/shared/trigger-lane.mjs +1 -1
- package/shared/tty-style.mjs +1 -1
- package/shared/usage-block.mjs +1 -1
- package/shared/vacuous-pass.mjs +1 -1
- package/shared/verb-shim.mjs +1 -1
|
@@ -0,0 +1,389 @@
|
|
|
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
|
+
// Removes two more classes of internal attribution, over the population the citation-opener sweep beside
|
|
5
|
+
// it (strip-tracker-citations.mjs) scans, with the same exclusions:
|
|
6
|
+
//
|
|
7
|
+
// TITLES — a test title that opens with an issue number:
|
|
8
|
+
// test("1223 with no research credential …") → test("with no research credential …")
|
|
9
|
+
// test("2087 arm 1 — the sources are …") → test("arm 1 — the sources are …")
|
|
10
|
+
// ATTRIBUTIONS — a comment that names who a ruling came from:
|
|
11
|
+
// // Owner ruling, 2026-08-29: … → // Ruling, 2026-08-29: …
|
|
12
|
+
// The ruling and its date stay; the person goes, as public attribution is by role and date.
|
|
13
|
+
//
|
|
14
|
+
// node scripts/strip-titles-and-attributions.mjs what would change, per class
|
|
15
|
+
// node scripts/strip-titles-and-attributions.mjs --apply change it
|
|
16
|
+
//
|
|
17
|
+
// WHY A SIBLING AND NOT MORE RULES IN THE OPENER SWEEP. That sweep is about one construction and its arms
|
|
18
|
+
// pin it; neither class here is a tracker citation, and folding them in would make its counts mean two
|
|
19
|
+
// things. What it has that is worth sharing — which files are in scope, which are excluded by name, and
|
|
20
|
+
// which tree is the published one — is IMPORTED, so there is still one population definition.
|
|
21
|
+
//
|
|
22
|
+
// THE POPULATION DIFFERS FROM THAT SWEEP'S IN TWO NAMED PLACES. `.env.example` is added: it carries two
|
|
23
|
+
// members of the attribution class and the opener sweep has never scanned it. And this file and its arms
|
|
24
|
+
// are excluded by name, because they quote both classes as specimens.
|
|
25
|
+
//
|
|
26
|
+
// WHAT EACH TRANSFORM WILL NOT DO, and each is an arm in the-title-and-attribution-sweep-takes-the-label.test.mjs:
|
|
27
|
+
// · A title's label is taken WHOLE: a workstream letter (1957F), an item suffix (2191-F54, 1135-12,
|
|
28
|
+
// 600.3) or a second number (526/1067) is part of it, with or without its hash. Exactly one separator
|
|
29
|
+
// after it goes with it: a space, `: `, or ` — `.
|
|
30
|
+
// · A bare number the same file uses as a value in its code is what the title is ABOUT — `409 splits: …`
|
|
31
|
+
// in a file that calls `withFetch(409, …)` — and a title that opens with a date is dated, not
|
|
32
|
+
// labelled. Both are handed off.
|
|
33
|
+
// · A title whose remainder would open with punctuation or with ANOTHER reference, would be empty, or
|
|
34
|
+
// would read the same as another title in its file is HANDED OFF, never rewritten: a reader decides.
|
|
35
|
+
// Another reference after the label is the title's subject ("2018 1010 still fires …"), and a sweep
|
|
36
|
+
// that took the label would take the subject the next time it ran. A remainder opening with
|
|
37
|
+
// a CLI flag (`--dry-run`) or a code span is ordinary prose and is rewritten.
|
|
38
|
+
// · Only the FIRST argument of test(), it() or describe() is a title. A numbered string anywhere else is
|
|
39
|
+
// somebody's data.
|
|
40
|
+
// · A title another line QUOTES BY NAME moves with it: a table that anchors on a test's full name, with
|
|
41
|
+
// its separator written as the character or as its escape, is rewritten in the same pass. A quote the
|
|
42
|
+
// sweep may not touch — in a file excluded by name, or outside the population — keeps the title as it
|
|
43
|
+
// is, and the title goes to a reader.
|
|
44
|
+
// · An attribution is rewritten only on a COMMENT line, a JSX `{/* … */}` block included, or in markdown
|
|
45
|
+
// PROSE outside a code fence. Inside a string the words are text a user reads or a value a run writes,
|
|
46
|
+
// and changing either is not a comment edit, so those are handed off. So is a line where the word after
|
|
47
|
+
// "owner" is a verb ("the owner ordered"): dropping the person would leave "the ordered".
|
|
48
|
+
// · A phrase wrapped across two lines is taken as one — "(owner⏎// ruling, 2026-09-08)" becomes
|
|
49
|
+
// "(ruling,⏎// 2026-09-08)" — and no line is added or removed, so every line citation into a swept
|
|
50
|
+
// file still lands where it did.
|
|
51
|
+
// · The article follows the word it now stands before: "an owner ruling" becomes "a ruling", and "an
|
|
52
|
+
// owner order" stays "an order", on one line or across two.
|
|
53
|
+
// · A line that carries a bare line citation (a line number cited with no symbol beside it) is handed off. The
|
|
54
|
+
// citation ratchet judges a changed line as an added one, so any rewrite of that line would read as
|
|
55
|
+
// adding the citation. The ratchet's own matcher decides, never a copy of its pattern.
|
|
56
|
+
// · THE FLOOR. After the sweep, every line the class's own instrument still counts must be on a hand-off
|
|
57
|
+
// list. A line neither rewritten nor handed off is a rule that cannot see it — it is reported, and
|
|
58
|
+
// --apply refuses to write anything while one exists.
|
|
59
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
60
|
+
import { execFileSync } from "node:child_process";
|
|
61
|
+
import { join, dirname } from "node:path";
|
|
62
|
+
import { fileURLToPath } from "node:url";
|
|
63
|
+
import { isScannable, EXCLUDED } from "./strip-tracker-citations.mjs";
|
|
64
|
+
import { publishedOf } from "../shared/reference-guard-classes.mjs";
|
|
65
|
+
import { newBareCitations } from "./citation-line-check.mjs";
|
|
66
|
+
|
|
67
|
+
const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..");
|
|
68
|
+
const APPLY = process.argv.includes("--apply");
|
|
69
|
+
|
|
70
|
+
// ── THE POPULATION ───────────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
export const OWN_SPECIMENS = [
|
|
72
|
+
"scripts/strip-titles-and-attributions.mjs",
|
|
73
|
+
"driver/test/the-title-and-attribution-sweep-takes-the-label.test.mjs",
|
|
74
|
+
];
|
|
75
|
+
const ALSO_SCANNED = /(?:^|\/)\.env\.example$/;
|
|
76
|
+
export const inPopulation = (f) =>
|
|
77
|
+
!OWN_SPECIMENS.includes(f) && !EXCLUDED.includes(f) && (isScannable(f) || ALSO_SCANNED.test(f));
|
|
78
|
+
|
|
79
|
+
// ── TITLES ───────────────────────────────────────────────────────────────────────────────────────────
|
|
80
|
+
export const TITLE_CALL = /^(\s*(?:test|it|describe)(?:\.(?:skip|only|todo))?\(\s*)(["'`])/;
|
|
81
|
+
const PART = String.raw`\d+(?:[A-Za-z]\d*)?(?:-[A-Za-z]?\d+[A-Za-z]?)*`;
|
|
82
|
+
/** The label, whole — digits, a workstream letter, item suffixes, a second number after / or . — and ONE separator. */
|
|
83
|
+
export const TITLE_NUMBER = new RegExp(String.raw`^#?\d{3,}(?:[A-Za-z]\d*)?(?:-[A-Za-z]?\d+[A-Za-z]?)*(?:[/.]#?${PART})*(?:\s*[—–:]\s+|\s+-\s+|\s+)`);
|
|
84
|
+
const OPENS_WITH_NUMBER = /^#?\d{3,}/;
|
|
85
|
+
const DATE_LED = /^#?\d{4}-\d{2}-\d{2}(?!\d)/;
|
|
86
|
+
const PUNCTUATION_LED = /^[—–:,.;!?)\]-]/;
|
|
87
|
+
const ORDINARY_LEAD = /^(?:--[a-z]|`)/;
|
|
88
|
+
|
|
89
|
+
/** The title literal's text, up to its own unescaped closing quote; null when it does not close on this line. */
|
|
90
|
+
function titleText(rest, q) {
|
|
91
|
+
for (let i = 0; i < rest.length; i++) {
|
|
92
|
+
if (rest[i] === "\\") { i++; continue; }
|
|
93
|
+
if (rest[i] === q) return rest.slice(0, i);
|
|
94
|
+
}
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A code line with its strings and trailing comment removed — what is left is what the program computes with. */
|
|
99
|
+
const codeOnly = (l) => l.replace(/\\./g, "").replace(/"[^"]*"|'[^']*'|`[^`]*`/g, '""').replace(/\/\/.*$/, "");
|
|
100
|
+
|
|
101
|
+
/** Every number of three or more digits a file uses as a VALUE: in code, outside strings, comments and titles. PURE. */
|
|
102
|
+
export function valuesInCode(lines, kinds) {
|
|
103
|
+
const out = new Set();
|
|
104
|
+
lines.forEach((l, i) => {
|
|
105
|
+
if (kinds[i] !== "code" || TITLE_CALL.test(l) || /^\s*[+`'"]/.test(l)) return;
|
|
106
|
+
for (const m of codeOnly(l).matchAll(/(?<![\w#.$-])(\d{3,})(?![\w.])/g)) out.add(m[1]);
|
|
107
|
+
});
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One line's title, stripped. PURE. `valuesInCode` is the file's own numbers-as-values, from valuesInCode().
|
|
113
|
+
* @returns {{line: string, changed: boolean, handoff?: string, title?: string}}
|
|
114
|
+
*/
|
|
115
|
+
export function stripTitle(line, { valuesInCode = new Set() } = {}) {
|
|
116
|
+
const m = TITLE_CALL.exec(line);
|
|
117
|
+
if (!m) return { line, changed: false };
|
|
118
|
+
const rest = line.slice(m[0].length);
|
|
119
|
+
if (!OPENS_WITH_NUMBER.test(rest)) return { line, changed: false };
|
|
120
|
+
if (DATE_LED.test(rest)) return { line, changed: false, handoff: "a date opens this title — it is dated, not labelled" };
|
|
121
|
+
const bare = /^(\d{3,})/.exec(rest);
|
|
122
|
+
if (bare && valuesInCode.has(bare[1]))
|
|
123
|
+
return { line, changed: false, handoff: "this file uses the number as a value in its code — it is what the title is about" };
|
|
124
|
+
const t = TITLE_NUMBER.exec(rest);
|
|
125
|
+
if (!t) return { line, changed: false, handoff: "a number with no separator after it — part of the title, or a label with none?" };
|
|
126
|
+
const remainder = rest.slice(t[0].length);
|
|
127
|
+
if (OPENS_WITH_NUMBER.test(remainder))
|
|
128
|
+
return { line, changed: false, handoff: "what is left opens with another reference — the title's subject, which a second run would take" };
|
|
129
|
+
const text = titleText(remainder, m[2]);
|
|
130
|
+
if (text === null || text.trim() === "") return { line, changed: false, handoff: "the title would be empty, or does not close on this line" };
|
|
131
|
+
if (PUNCTUATION_LED.test(remainder) && !ORDINARY_LEAD.test(remainder))
|
|
132
|
+
return { line, changed: false, handoff: "what is left would open with punctuation" };
|
|
133
|
+
return { line: m[0] + remainder, changed: true, title: text };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── ATTRIBUTIONS ─────────────────────────────────────────────────────────────────────────────────────
|
|
137
|
+
/** Where "owner" can start: a word boundary, or just after an escape such as the \n that opens a message. */
|
|
138
|
+
const START = String.raw`(?:(?<![A-Za-z0-9_])|(?<=\\[ntr]))`;
|
|
139
|
+
export const ATTRIBUTION = new RegExp(String.raw`${START}(an?\s+)?(owner)(\s+)(rulings?|steer|order)\b`, "gi");
|
|
140
|
+
/** "owner" before another form of the word — a verb, mostly: "the owner ordered". */
|
|
141
|
+
const OTHER_FORM = new RegExp(String.raw`${START}owner\s+(?:ruling(?!s?\b)|steer(?!\b)|order(?!\b))`, "i");
|
|
142
|
+
const COMMENT_BY_EXT = [
|
|
143
|
+
[/\.(?:mjs|js|jsx|ts|tsx|css)$/, /^\s*(?:\/\/|\*|\/\*)/],
|
|
144
|
+
[/\.(?:sh|yml|yaml|toml|service|timer)$|(?:^|\/)\.git(?:attributes|ignore)$|(?:^|\/)\.env\.example$/, /^\s*#/],
|
|
145
|
+
];
|
|
146
|
+
const BLOCK_OPENS = /^\s*\{?\/\*/;
|
|
147
|
+
const caseLike = (model, word) =>
|
|
148
|
+
model === model.toUpperCase() ? word.toUpperCase()
|
|
149
|
+
: model[0] === model[0].toUpperCase() ? word[0].toUpperCase() + word.slice(1) : word;
|
|
150
|
+
const articleFor = (word) => (/^[aeiou]/i.test(word) ? "an" : "a");
|
|
151
|
+
|
|
152
|
+
/** "owner ruling" → "ruling", keeping the case the phrase was written in and fixing the article. PURE. */
|
|
153
|
+
export function dropOwner(text) {
|
|
154
|
+
return text.replace(ATTRIBUTION, (_, article, owner, _sp, word) => {
|
|
155
|
+
const kept = caseLike(owner, word.toLowerCase());
|
|
156
|
+
return article ? `${caseLike(article.trim(), articleFor(word))} ${kept}` : kept;
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* One line's attribution, dropped — where the line is somewhere a person reads rather than a value.
|
|
162
|
+
* `kind` is "comment", "prose" or "code". PURE.
|
|
163
|
+
*/
|
|
164
|
+
export function stripAttribution(line, kind) {
|
|
165
|
+
ATTRIBUTION.lastIndex = 0;
|
|
166
|
+
const hit = ATTRIBUTION.test(line);
|
|
167
|
+
ATTRIBUTION.lastIndex = 0;
|
|
168
|
+
const other = OTHER_FORM.test(line);
|
|
169
|
+
if (!hit && !other) return { line, changed: false };
|
|
170
|
+
if (other) return { line, changed: false, handoff: 'the word after "owner" is a verb or another form here — dropping the person would leave "the ordered"' };
|
|
171
|
+
if (kind === "code") return { line, changed: false, handoff: "inside a string or code — text a user reads or a value a run writes, not a comment" };
|
|
172
|
+
const out = dropOwner(line);
|
|
173
|
+
return { line: out, changed: out !== line };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Which kind of line this is. A markdown code fence is code; a JS block comment, JSX's included, is comment throughout. */
|
|
177
|
+
export function lineKinds(file, lines) {
|
|
178
|
+
if (/\.md$/i.test(file)) {
|
|
179
|
+
let fenced = false;
|
|
180
|
+
return lines.map((l) => { if (/^\s*```/.test(l)) { fenced = !fenced; return "code"; } return fenced ? "code" : "prose"; });
|
|
181
|
+
}
|
|
182
|
+
const rule = COMMENT_BY_EXT.find(([ext]) => ext.test(file));
|
|
183
|
+
if (!rule) return lines.map(() => "code");
|
|
184
|
+
const blocks = rule === COMMENT_BY_EXT[0];
|
|
185
|
+
let inBlock = false;
|
|
186
|
+
return lines.map((l) => {
|
|
187
|
+
if (inBlock) { if (l.includes("*/")) inBlock = false; return "comment"; }
|
|
188
|
+
if (blocks && BLOCK_OPENS.test(l)) { inBlock = !l.includes("*/"); return "comment"; }
|
|
189
|
+
return rule[1].test(l) ? "comment" : "code";
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const RATCHET_WHY = "the line carries a bare line citation, and the citation ratchet reads any rewrite of it as adding one";
|
|
194
|
+
/** Whether the citation ratchet would read this line, as rewritten, as ADDING a bare citation — its own matcher, called. */
|
|
195
|
+
const ratchetRefuses = (file, line, text) => newBareCitations([{ file, line, text }]).length > 0;
|
|
196
|
+
|
|
197
|
+
const LEADER = String.raw`\s*(?:(?:\/\/|#|\*|\{?\/\*)\s*)?`;
|
|
198
|
+
const WRAP_TAIL = /\b(?:an?\s+)?owner\s*$/i;
|
|
199
|
+
const WRAP_HEAD = new RegExp(String.raw`^(${LEADER})(rulings?|steer|order)\b([,:]?) ?`, "i");
|
|
200
|
+
const ARTICLE_TAIL = /\b(an?)(\s*)$/i;
|
|
201
|
+
const OWNER_HEAD = new RegExp(String.raw`^${LEADER}owner\s+(rulings?|steer|order)\b`, "i");
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The attribution wrapped across a line break, taken as one phrase: the word moves up to where "owner" stood,
|
|
205
|
+
* and an article left at the end of a line agrees with the word that now follows it. Two lines in, two out. PURE.
|
|
206
|
+
*/
|
|
207
|
+
export function unwrapPairs(lines, kinds, refuses = () => false) {
|
|
208
|
+
const out = lines.slice(), rewritten = [], handoff = [];
|
|
209
|
+
for (let i = 0; i + 1 < out.length; i++) {
|
|
210
|
+
const a = out[i], b = out[i + 1];
|
|
211
|
+
const tail = WRAP_TAIL.exec(a), head = WRAP_HEAD.exec(b);
|
|
212
|
+
const readable = kinds[i] !== "code" && kinds[i + 1] !== "code";
|
|
213
|
+
if (tail && head) {
|
|
214
|
+
if (!readable) { handoff.push({ line: i + 1, why: "a wrapped attribution inside a string or code" }); continue; }
|
|
215
|
+
const na = a.slice(0, tail.index) + dropOwner(`${tail[0].trimEnd()} ${head[2]}`) + head[3];
|
|
216
|
+
const nb = head[1] + b.slice(head[0].length);
|
|
217
|
+
if (refuses(i, na) || refuses(i + 1, nb)) { handoff.push({ line: i + 1, why: RATCHET_WHY }, { line: i + 2, why: RATCHET_WHY }); continue; }
|
|
218
|
+
out[i] = na; out[i + 1] = nb;
|
|
219
|
+
rewritten.push(i + 1);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const art = ARTICLE_TAIL.exec(a), oh = OWNER_HEAD.exec(b);
|
|
223
|
+
if (art && oh && readable) {
|
|
224
|
+
const na = a.slice(0, art.index) + caseLike(art[1], articleFor(oh[1])) + art[2];
|
|
225
|
+
if (na !== a && (refuses(i, na) || refuses(i + 1, dropOwner(b)))) { handoff.push({ line: i + 1, why: RATCHET_WHY }, { line: i + 2, why: RATCHET_WHY }); continue; }
|
|
226
|
+
out[i] = na;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return { lines: out, rewritten, handoff };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ── THE FLOOR ───────────────────────────────────────────────────────────────────────────────────────
|
|
233
|
+
/** The two instruments the ruling counted its classes with, line-based and verbatim in what they match. */
|
|
234
|
+
export const INSTRUMENT = {
|
|
235
|
+
title: /^\s*(?:test|it|describe)(?:\.\w+)?\(\s*["'`]#?\d{3,}/,
|
|
236
|
+
attribution: /owner (?:ruling|steer|order)/i,
|
|
237
|
+
};
|
|
238
|
+
/**
|
|
239
|
+
* Every line of a swept file that an instrument still counts and no hand-off list names, and every quote that
|
|
240
|
+
* still names a renamed title as it was — a rule that cannot see the line, reported rather than left in silence. PURE.
|
|
241
|
+
*/
|
|
242
|
+
export function accountFor(file, lines, titleHandoff, attributionHandoff, rename = () => undefined) {
|
|
243
|
+
const listed = (h) => new Set(h.filter((x) => x.file === file).map((x) => x.line));
|
|
244
|
+
const t = listed(titleHandoff), a = listed(attributionHandoff);
|
|
245
|
+
const out = [];
|
|
246
|
+
lines.forEach((l, i) => {
|
|
247
|
+
if (/\.test\.(?:mjs|js|ts)$/.test(file) && INSTRUMENT.title.test(l) && !t.has(i + 1)) out.push({ file, line: i + 1, class: "title", text: l.trim().slice(0, 110) });
|
|
248
|
+
if (INSTRUMENT.attribution.test(l) && !a.has(i + 1)) out.push({ file, line: i + 1, class: "attribution", text: l.trim().slice(0, 110) });
|
|
249
|
+
if (!TITLE_CALL.test(l) && renameQuoted(l, rename).hits) out.push({ file, line: i + 1, class: "reference", text: l.trim().slice(0, 110) });
|
|
250
|
+
});
|
|
251
|
+
return out;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ── THE SURVEY ───────────────────────────────────────────────────────────────────────────────────────
|
|
255
|
+
/** A quoted string that could name a test — and the separators a test's full name is joined with. */
|
|
256
|
+
const QUOTED = /(["'`])([^"'`\n]*\d{3,}[^"'`\n]*)\1/g;
|
|
257
|
+
const NAME_SEPARATOR = /(\s*(?:›|\\u203a| > )\s*)/;
|
|
258
|
+
|
|
259
|
+
/** Every quoted part of `line` that `rename` maps, rewritten; the separators stay exactly as written. PURE. */
|
|
260
|
+
function renameQuoted(line, rename) {
|
|
261
|
+
let hits = 0;
|
|
262
|
+
const out = line.replace(QUOTED, (_, q, seg) => {
|
|
263
|
+
const parts = seg.split(NAME_SEPARATOR);
|
|
264
|
+
for (let k = 0; k < parts.length; k += 2) {
|
|
265
|
+
const to = rename(parts[k]);
|
|
266
|
+
if (to !== undefined) { parts[k] = to; hits++; }
|
|
267
|
+
}
|
|
268
|
+
return q + parts.join("") + q;
|
|
269
|
+
});
|
|
270
|
+
return { line: out, hits };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Per-file classification over the population. PURE; `read` is injected so an arm drives a planted tree. */
|
|
274
|
+
export function surveyOf(files, read) {
|
|
275
|
+
const titles = { rewritten: {}, handoff: [] }, attributions = { rewritten: {}, handoff: [] }, references = { rewritten: {} };
|
|
276
|
+
const unreadable = [], writes = {}, unaccounted = [];
|
|
277
|
+
const bump = (o, f, n = 1) => { o[f] = (o[f] ?? 0) + n; };
|
|
278
|
+
const state = new Map(); // file → { lines, out }
|
|
279
|
+
const renamed = new Map(); // a title as it was → { to, file, i }
|
|
280
|
+
// 1. TITLES — only in test files, and a file's rewrites are held back together if any two would collide.
|
|
281
|
+
for (const f of files.filter(inPopulation)) {
|
|
282
|
+
let text;
|
|
283
|
+
try { text = read(f); } catch (e) { unreadable.push({ file: f, why: String(e?.message ?? e).slice(0, 120) }); continue; }
|
|
284
|
+
const lines = text.split("\n"), out = lines.slice();
|
|
285
|
+
state.set(f, { lines, out });
|
|
286
|
+
if (!/\.test\.(?:mjs|js|ts)$/.test(f)) continue;
|
|
287
|
+
const values = valuesInCode(lines, lineKinds(f, lines));
|
|
288
|
+
const done = [];
|
|
289
|
+
lines.forEach((l, i) => {
|
|
290
|
+
const r = stripTitle(l, { valuesInCode: values });
|
|
291
|
+
if (r.handoff) titles.handoff.push({ file: f, line: i + 1, why: r.handoff, text: l.trim().slice(0, 110) });
|
|
292
|
+
else if (r.changed && ratchetRefuses(f, i + 1, r.line)) titles.handoff.push({ file: f, line: i + 1, why: RATCHET_WHY, text: l.trim().slice(0, 110) });
|
|
293
|
+
else if (r.changed) done.push({ i, line: r.line, title: r.title, was: l });
|
|
294
|
+
});
|
|
295
|
+
const others = new Set(lines.map((l) => { const m = TITLE_CALL.exec(l); return m ? titleText(l.slice(m[0].length), m[2]) : null; })
|
|
296
|
+
.filter((t, i) => t !== null && !done.some((d) => d.i === i)));
|
|
297
|
+
const counts = {};
|
|
298
|
+
for (const d of done) counts[d.title] = (counts[d.title] ?? 0) + 1;
|
|
299
|
+
for (const d of done) {
|
|
300
|
+
if (counts[d.title] > 1 || others.has(d.title)) {
|
|
301
|
+
titles.handoff.push({ file: f, line: d.i + 1, why: "would read the same as another title in this file", text: d.was.trim().slice(0, 110) });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
out[d.i] = d.line;
|
|
305
|
+
const m = TITLE_CALL.exec(d.was);
|
|
306
|
+
renamed.set(titleText(d.was.slice(m[0].length), m[2]), { to: d.title, file: f, i: d.i });
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// 2. A TITLE ANOTHER LINE QUOTES BY NAME moves with it, in the same pass. A quote this sweep may not touch —
|
|
310
|
+
// in a file excluded by name, or outside the population — keeps the title as it is, for a reader.
|
|
311
|
+
for (const g of files.filter((g) => !inPopulation(g) && !OWN_SPECIMENS.includes(g))) {
|
|
312
|
+
let text;
|
|
313
|
+
try { text = read(g); } catch { continue; }
|
|
314
|
+
if (text.includes("\0")) continue;
|
|
315
|
+
text.split("\n").forEach((l, j) => {
|
|
316
|
+
if (TITLE_CALL.test(l)) return;
|
|
317
|
+
renameQuoted(l, (part) => {
|
|
318
|
+
const r = renamed.get(part);
|
|
319
|
+
if (!r) return undefined;
|
|
320
|
+
const st = state.get(r.file);
|
|
321
|
+
st.out[r.i] = st.lines[r.i];
|
|
322
|
+
renamed.delete(part);
|
|
323
|
+
titles.handoff.push({ file: r.file, line: r.i + 1, why: `quoted by name at ${g}:${j + 1}, which this sweep may not touch`, text: st.lines[r.i].trim().slice(0, 110) });
|
|
324
|
+
return undefined;
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
for (const r of renamed.values()) bump(titles.rewritten, r.file);
|
|
329
|
+
const rename = (part) => renamed.get(part)?.to;
|
|
330
|
+
for (const [f, st] of state) st.out.forEach((l, j) => {
|
|
331
|
+
if (TITLE_CALL.test(l)) return;
|
|
332
|
+
const r = renameQuoted(l, rename);
|
|
333
|
+
// A quote whose line the ratchet would refuse is left, and the floor reports it: --apply then refuses
|
|
334
|
+
// outright rather than land a title its quote no longer names.
|
|
335
|
+
if (r.hits && !ratchetRefuses(f, j + 1, r.line)) { st.out[j] = r.line; bump(references.rewritten, f, r.hits); }
|
|
336
|
+
});
|
|
337
|
+
// 3. ATTRIBUTIONS — every file in the population, on the line kinds a person reads.
|
|
338
|
+
for (const [f, st] of state) {
|
|
339
|
+
const kinds = lineKinds(f, st.out);
|
|
340
|
+
const wrapped = unwrapPairs(st.out, kinds, (i, text) => ratchetRefuses(f, i + 1, text));
|
|
341
|
+
const out = wrapped.lines;
|
|
342
|
+
for (const _ of wrapped.rewritten) bump(attributions.rewritten, f);
|
|
343
|
+
const held = new Set(wrapped.handoff.map((h) => h.line - 1));
|
|
344
|
+
for (const h of wrapped.handoff) attributions.handoff.push({ file: f, line: h.line, why: h.why, text: st.lines[h.line - 1].trim().slice(0, 110) });
|
|
345
|
+
out.forEach((l, i) => {
|
|
346
|
+
if (held.has(i)) return;
|
|
347
|
+
const r = stripAttribution(l, kinds[i]);
|
|
348
|
+
if (r.handoff) attributions.handoff.push({ file: f, line: i + 1, why: r.handoff, text: l.trim().slice(0, 110) });
|
|
349
|
+
else if (r.changed && ratchetRefuses(f, i + 1, r.line)) attributions.handoff.push({ file: f, line: i + 1, why: RATCHET_WHY, text: l.trim().slice(0, 110) });
|
|
350
|
+
else if (r.changed) { out[i] = r.line; bump(attributions.rewritten, f); }
|
|
351
|
+
});
|
|
352
|
+
if (out.some((l, i) => l !== st.lines[i])) writes[f] = out.join("\n");
|
|
353
|
+
unaccounted.push(...accountFor(f, out, titles.handoff, attributions.handoff, rename));
|
|
354
|
+
}
|
|
355
|
+
return { titles, attributions, references, unreadable, writes, unaccounted };
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const total = (o) => Object.values(o).reduce((a, n) => a + n, 0);
|
|
359
|
+
|
|
360
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
361
|
+
// THE PUBLISHED POPULATION, NOT THE INDEX — the same helper, for the same reason, as the opener sweep.
|
|
362
|
+
const all = execFileSync("git", ["-C", ROOT, "ls-files"], { encoding: "utf8", maxBuffer: 1 << 28 }).split("\n").filter(Boolean);
|
|
363
|
+
const pub = publishedOf(all, ROOT);
|
|
364
|
+
if (pub.error) { console.error(`strip-titles-and-attributions: ${pub.error}`); process.exit(2); }
|
|
365
|
+
const s = surveyOf(pub.files, (f) => readFileSync(join(ROOT, f), "utf8"));
|
|
366
|
+
if (s.unreadable.length) {
|
|
367
|
+
console.log(`${s.unreadable.length} file(s) COULD NOT BE READ — every count below excludes them:`);
|
|
368
|
+
for (const u of s.unreadable) console.log(` ${u.file} ${u.why}`);
|
|
369
|
+
console.log("");
|
|
370
|
+
}
|
|
371
|
+
if (s.unaccounted.length) {
|
|
372
|
+
console.log(`${s.unaccounted.length} line(s) an instrument still counts that the sweep neither rewrote nor handed off — UNACCOUNTED:`);
|
|
373
|
+
for (const u of s.unaccounted) console.log(` ${u.file}:${u.line} [${u.class}] ${u.text}`);
|
|
374
|
+
if (APPLY) { console.error("strip-titles-and-attributions: refusing to --apply while a line is unaccounted for"); process.exit(2); }
|
|
375
|
+
console.log("");
|
|
376
|
+
}
|
|
377
|
+
const verb = APPLY ? "rewrote" : "would rewrite";
|
|
378
|
+
console.log(`titles: ${verb} ${total(s.titles.rewritten)} across ${Object.keys(s.titles.rewritten).length} file(s); ${s.titles.handoff.length} handed to a reader`);
|
|
379
|
+
console.log(`attributions: ${verb} ${total(s.attributions.rewritten)} across ${Object.keys(s.attributions.rewritten).length} file(s); ${s.attributions.handoff.length} handed to a reader`);
|
|
380
|
+
console.log(`references: ${verb} ${total(s.references.rewritten)} quoted test name(s) across ${Object.keys(s.references.rewritten).length} file(s)`);
|
|
381
|
+
for (const [label, h] of [["titles", s.titles.handoff], ["attributions", s.attributions.handoff]]) {
|
|
382
|
+
if (!h.length) continue;
|
|
383
|
+
console.log(`\n${label} — the hand-off list:`);
|
|
384
|
+
for (const x of h) console.log(` ${x.file}:${x.line} [${x.why}] ${x.text}`);
|
|
385
|
+
}
|
|
386
|
+
if (APPLY) for (const [f, text] of Object.entries(s.writes)) writeFileSync(join(ROOT, f), text);
|
|
387
|
+
console.log(`\nexcluded by name, shared with the opener sweep: ${EXCLUDED.join(", ")}`);
|
|
388
|
+
console.log(`excluded by name, this sweep's own specimens: ${OWN_SPECIMENS.join(", ")}`);
|
|
389
|
+
}
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
// wants a guard against reintroduction rather than a backlog. Different residue, different repair.
|
|
59
59
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
60
60
|
import { execFileSync } from "node:child_process";
|
|
61
|
-
import { publishedOf } from "../shared/reference-guard-classes.mjs";
|
|
61
|
+
import { publishedOf, wrapsInto } from "../shared/reference-guard-classes.mjs";
|
|
62
62
|
import { join, dirname } from "node:path";
|
|
63
63
|
import { fileURLToPath } from "node:url";
|
|
64
64
|
|
|
@@ -69,10 +69,82 @@ const APPLY = process.argv.includes("--apply");
|
|
|
69
69
|
// survives the replacement.
|
|
70
70
|
export const OPENER = /((?:^|["'`(\[]|\/\/|\/\*|#|\*|·|──)\s*)(?:refs\s+)?tracker issues?\s+\d+\s*[—:–-]\s+/i;
|
|
71
71
|
|
|
72
|
+
// ── THE SECOND MECHANICAL CASE: A CITATION STANDING BESIDE A SENTENCE, IN PARENTHESES ──────────────
|
|
73
|
+
//
|
|
74
|
+
// Measured before it was written, because "the sweep is nearly done" was false: two openers were left
|
|
75
|
+
// and seven hundred and fifty citations were not openers at all. Of those, three hundred and twenty-one
|
|
76
|
+
// sit in parentheses that hold the citation AND NOTHING ELSE, which makes them a label beside a
|
|
77
|
+
// sentence rather than part of it — the same shape as an opener, wearing brackets.
|
|
78
|
+
//
|
|
79
|
+
// ONLY WHEN THE BRACKETS HOLD NOTHING ELSE, and that is the whole of the narrowing. Sixty-eight of them
|
|
80
|
+
// read "(tracker issue NNN, owner's ruling 2026-09-07)" or "(tracker issue NNN / tracker issue NNN)",
|
|
81
|
+
// where the brackets carry a ruling, a date or a second citation. Removing those brackets deletes
|
|
82
|
+
// content, so they are not mechanical and they go to the reader with the rest. A pattern wide enough to
|
|
83
|
+
// take them would be a pattern that deletes rulings.
|
|
84
|
+
//
|
|
85
|
+
// AND THE DRAWN RULE IS KEPT THE LENGTH IT WAS. A hundred and six of them sit in a box heading —
|
|
86
|
+
// `// ── WHAT THIS DOES (tracker issue NNN) ─────────` — where removing twenty characters leaves the
|
|
87
|
+
// rule twenty short and the heading ragged against every other heading in the file. Every one measured
|
|
88
|
+
// uses U+2500 and sits in a .mjs or .yml comment, so there is no markdown `---` to mistake for a rule.
|
|
89
|
+
// The replacement re-pads by exactly what it removed, which keeps the line the length it was and makes
|
|
90
|
+
// the diff say "the citation left" rather than "this heading was reflowed".
|
|
91
|
+
export const PARENTHETICAL = /[ \t]*\(\s*(?:refs\s+)?tracker issues?\s+\d+\s*\)/i;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Remove a bracketed citation, keeping a trailing drawn rule as long as it was. PURE.
|
|
95
|
+
*
|
|
96
|
+
* Returns the line unchanged when the brackets hold anything besides the citation, which is the case
|
|
97
|
+
* the pattern above already declines to match.
|
|
98
|
+
*/
|
|
99
|
+
export function stripParenthetical(line) {
|
|
100
|
+
if (!PARENTHETICAL.test(line)) return line;
|
|
101
|
+
const was = line.length;
|
|
102
|
+
const out = line.replace(PARENTHETICAL, "");
|
|
103
|
+
const rule = /─+$/.exec(out);
|
|
104
|
+
if (!rule) return out;
|
|
105
|
+
return out.slice(0, rule.index) + "─".repeat(rule[0].length + (was - out.length));
|
|
106
|
+
}
|
|
107
|
+
|
|
72
108
|
// Any remaining citation, mechanical or not. The difference between this count and the opener count is
|
|
73
109
|
// the hand-off: a citation inside a sentence cannot be removed without rewriting the sentence around it.
|
|
74
110
|
export const ANY_CITATION = /\btracker issues?\s+\d+/i;
|
|
75
111
|
|
|
112
|
+
// ── A LINE CARRYING ANOTHER CITATION IS NOT REWRITTEN, IT IS HANDED OFF ─────────────────────────────
|
|
113
|
+
//
|
|
114
|
+
// REWRITING A LINE RE-AGES EVERY CITATION ON IT. A diff has no notion of a partial edit: touching one
|
|
115
|
+
// character puts the whole line on the added side, so a by-line citation that has sat there for months
|
|
116
|
+
// becomes newly-added and fails the check that refuses new ones. Two lines in this tree are in that
|
|
117
|
+
// state — the citation guard's own exemption table, which quotes synthetic fixture citations as DATA —
|
|
118
|
+
// and rewriting them turned a strip into three new bare citations.
|
|
119
|
+
//
|
|
120
|
+
// MEASURED ON THE WRONG POPULATION FIRST, which is why this is written down rather than just fixed. I
|
|
121
|
+
// checked the lines the sweep LEAVES and found none carrying a by-line citation, and reported the
|
|
122
|
+
// hazard as absent. The hazard is on the lines the sweep REWRITES, and I had not looked at those at
|
|
123
|
+
// all. An answer about the wrong set is not a smaller answer, it is a different question.
|
|
124
|
+
// A DOT-EXTENSION, NOT A LIST OF THEM. Naming six extensions made this blind to the rest: the tree
|
|
125
|
+
// carries three by-line citations outside that list — one into a stylesheet and two into a run log —
|
|
126
|
+
// and any of them sitting on a sweepable line would have been rewritten and re-aged silently. It is the
|
|
127
|
+
// `:<line>` that makes a citation, not which language the target happens to be written in. Widening it
|
|
128
|
+
// changes nothing on today's tree, which is the point: the same answer from a rule that cannot go blind
|
|
129
|
+
// the day somebody cites a seventh kind of file.
|
|
130
|
+
// ── A CITATION THAT WRAPPED IS IN NEITHER PILE ─────────────────────────────────────────────────────
|
|
131
|
+
//
|
|
132
|
+
// Every rule here reads one line, so a citation broken across a line break is invisible to all of them:
|
|
133
|
+
// `tracker issue` at the end of one line and its number at the start of the next matches no pattern,
|
|
134
|
+
// counts in no total, and appears on no hand-off list. Thirty-six of them are in the tree. That is the
|
|
135
|
+
// same shape as a line stripped before it is classified — not a smaller number, an absent one.
|
|
136
|
+
//
|
|
137
|
+
// FOUND AND HANDED OFF, NOT JOINED. Joining rewrites BOTH lines, which re-ages every citation on either
|
|
138
|
+
// and re-flows the prose around them; and the removal itself is a sentence repair across a break, which
|
|
139
|
+
// is precisely the work the hand-off exists for. So this makes them visible and stops there.
|
|
140
|
+
// IT LIVES IN shared/ NOW, because the census needs the same answer. The residue census walked one line
|
|
141
|
+
// at a time and was blind to exactly this class while this file could see it — two instruments over one
|
|
142
|
+
// tree, disagreeing about what a citation is. Re-exported here rather than moved out of sight: this file
|
|
143
|
+
// is where the rule is explained, and its arms import it from here.
|
|
144
|
+
export { WRAPPED_HEAD, WRAPPED_TAIL, wrapsInto } from "../shared/reference-guard-classes.mjs";
|
|
145
|
+
|
|
146
|
+
export const CARRIES_ANOTHER_CITATION = /[A-Za-z0-9_.\-/]+\.[A-Za-z0-9]+:\d+/;
|
|
147
|
+
|
|
76
148
|
export const EXCLUDED = [
|
|
77
149
|
// Citations used as literal test DATA — the corpus the citation guard is checked against.
|
|
78
150
|
"driver/test/a-bare-reference-added-in-a-diff-is-refused.test.mjs",
|
|
@@ -87,12 +159,41 @@ export const EXCLUDED = [
|
|
|
87
159
|
// only reason it surfaced: before that, these lines were counted and never listed.
|
|
88
160
|
"scripts/strip-tracker-citations.mjs",
|
|
89
161
|
"driver/test/the-citation-strip-removes-openers-and-nothing-else.test.mjs",
|
|
162
|
+
// The RELEASE-NOTE linter's own corpus. Its arm asserts that "Fixed: The demo works again — tracker
|
|
163
|
+
// issue 97." is refused, and the citation IS the specimen: sweep it and the arm asserts that a clean
|
|
164
|
+
// sentence is clean, which passes over a linter that has stopped looking. Same argument as the two
|
|
165
|
+
// files above, found by the sweep listing a line it should never take.
|
|
166
|
+
"driver/test/a-release-note-is-written-for-its-reader.test.mjs",
|
|
90
167
|
// Pinned at a content hash; a prose repair is not worth spending a freeze on.
|
|
91
168
|
"driver/publish/render.mjs",
|
|
169
|
+
// The residue FLOOR's own specimens. Seven citations here, every one a synthetic number (1234, 1235)
|
|
170
|
+
// inside a string this file writes to a temp tree or hands to the counter, so the guard can be checked
|
|
171
|
+
// against a known population. Sweeping them edits the corpus the floor is measured on, and the floor
|
|
172
|
+
// would then hold against something it had stopped counting — the same trap the two files above name,
|
|
173
|
+
// in the module that counts rather than the one that strips.
|
|
174
|
+
"driver/test/the-public-residue-is-a-floor.test.mjs",
|
|
92
175
|
];
|
|
93
176
|
|
|
177
|
+
/**
|
|
178
|
+
* WHICH FILES THE SWEEP CAN SEE — and it could not see enough.
|
|
179
|
+
*
|
|
180
|
+
* This named six extensions and reported ZERO openers left, over a population that omitted every
|
|
181
|
+
* stylesheet, every unit file, the shell script, the HTML template, the React components and the
|
|
182
|
+
* dotfiles. Sixty-four citations sat in those, most of them the plainest mechanical case there is — a
|
|
183
|
+
* `/* tracker issue NNN — ` opening a comment — and the sweep's own report said the mechanical pass was
|
|
184
|
+
* finished. A filter that names file types cannot see the ones it omits, and the number it prints is
|
|
185
|
+
* true about its population and silent about the gap.
|
|
186
|
+
*
|
|
187
|
+
* The population is now the residue floor's: every tracked text file the public tree publishes. The two
|
|
188
|
+
* instruments disagreed about which files exist, which is a worse disagreement than the one their
|
|
189
|
+
* headers already explain.
|
|
190
|
+
*/
|
|
191
|
+
const SCANNABLE_EXT = /\.(mjs|md|yml|yaml|ts|tsx|js|jsx|json|css|html|sh|service|timer|toml)$/;
|
|
192
|
+
const SCANNABLE_NAME = /^(?:.*\/)?\.(gitattributes|gitignore)$/;
|
|
193
|
+
|
|
94
194
|
export const isScannable = (f) =>
|
|
95
|
-
|
|
195
|
+
(SCANNABLE_EXT.test(f) || SCANNABLE_NAME.test(f))
|
|
196
|
+
&& !f.startsWith("portal-ui/dist/") && !EXCLUDED.includes(f);
|
|
96
197
|
|
|
97
198
|
/** Per-file classification. PURE, and `read` is injected so an arm can drive it over a synthetic tree. */
|
|
98
199
|
export function surveyOf(files, read) {
|
|
@@ -110,13 +211,29 @@ export function surveyOf(files, read) {
|
|
|
110
211
|
// opener AND a second citation further along was stripped once, counted as done, and never reached
|
|
111
212
|
// the hand-off list a person is told to read — its survivor was invisible in the one place it should
|
|
112
213
|
// have been named. Test the RESULT: a line can be both stripped and still owed to a reader.
|
|
113
|
-
const
|
|
114
|
-
|
|
214
|
+
const lines = text.split("\n");
|
|
215
|
+
// The wrapped form, before the per-line rules: it belongs to a PAIR, so no single-line pass can see
|
|
216
|
+
// it. Reported at the line the citation starts on, which is the one a reader has to open.
|
|
217
|
+
lines.forEach((line, i) => {
|
|
218
|
+
if (wrapsInto(line, lines[i + 1])) handoff.push({ file: f, line: i + 1, text: `${line.trim()} ⏎ ${String(lines[i + 1]).trim()}`.slice(0, 200) });
|
|
219
|
+
});
|
|
220
|
+
const out = lines.map((line, i) => {
|
|
221
|
+
// Refused before either rule runs, so a line is never half-swept: it goes to the reader whole.
|
|
222
|
+
const holdBack = CARRIES_ANOTHER_CITATION.test(line);
|
|
223
|
+
const opened = !holdBack && OPENER.test(line) ? (n++, line.replace(OPENER, "$1")) : line;
|
|
224
|
+
const after = !holdBack && PARENTHETICAL.test(opened) ? (n++, stripParenthetical(opened)) : opened;
|
|
115
225
|
if (ANY_CITATION.test(after)) handoff.push({ file: f, line: i + 1, text: after.trim() });
|
|
116
226
|
return after;
|
|
117
227
|
});
|
|
118
228
|
if (n) { stripped[f] = n; strippedTotal += n; }
|
|
119
|
-
|
|
229
|
+
// COUNTED THE WAY THE HAND-OFF LIST IS BUILT, and the two used to disagree by exactly the wrapped
|
|
230
|
+
// ones. This was `out.filter(ANY_CITATION)` — one line at a time — and a wrapped citation matches on
|
|
231
|
+
// NEITHER of its lines alone, which is the whole reason it needed a pair-aware detector three lines
|
|
232
|
+
// up. So the list said 305 and this said 285, and the closing line is the number a reader quotes.
|
|
233
|
+
// A total that cannot see a class the same function already detects is worse than no total: it is
|
|
234
|
+
// low by a fixed amount, in the direction that flatters, and nothing about it looks wrong.
|
|
235
|
+
remainingTotal += out.filter((l) => ANY_CITATION.test(l)).length
|
|
236
|
+
+ out.filter((l, i) => !ANY_CITATION.test(l) && wrapsInto(l, out[i + 1])).length;
|
|
120
237
|
if (APPLY && n) writeFileSync(join(ROOT, f), out.join("\n"));
|
|
121
238
|
}
|
|
122
239
|
return { strippedTotal, remainingTotal, stripped, handoff, unreadable };
|
package/scripts/test-run.mjs
CHANGED
|
@@ -50,7 +50,7 @@ import { mkdtempSync, mkdirSync, rmSync, readdirSync, statSync, existsSync, read
|
|
|
50
50
|
import { delimiter, dirname, join, parse as parsePath, resolve, sep } from "node:path";
|
|
51
51
|
import { fileURLToPath } from "node:url";
|
|
52
52
|
import { tmpdir } from "node:os";
|
|
53
|
-
import { snapshotRepo, repoWrites, explainRepoWrites } from "./repo-writes.mjs";
|
|
53
|
+
import { snapshotRepo, repoWrites, explainRepoWrites } from "./repo-writes.mjs";
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
// ── TAIL — THIS WRAPPER READS BOTH SPELLINGS; IT DOES NOT TRANSLATE THE ENVIRONMENT ───────────
|
|
@@ -223,10 +223,10 @@ const providerChosen = ["CLEAROTRON_DATABASE"]
|
|
|
223
223
|
.some((n) => String(process.env[n] ?? "").trim() !== "");
|
|
224
224
|
if (!providerChosen) process.env.CLEAROTRON_DATABASE = "corsearch";
|
|
225
225
|
|
|
226
|
-
// ── THE SUITE'S OWN
|
|
226
|
+
// ── THE SUITE'S OWN COMPANY FIXTURES, ASKED FOR IN ONE PLACE ──────────────────────────────────
|
|
227
227
|
//
|
|
228
228
|
// Three profiles in `driver/profiles/` are the suite's, and a resolved roster no longer offers them:
|
|
229
|
-
// an outside user's
|
|
229
|
+
// an outside user's company picker listed them on a `git clone` install, where the packaging
|
|
230
230
|
// exclusion that protects the tarball does not apply. They are marked `testFixture` and the loader
|
|
231
231
|
// refuses them on every route.
|
|
232
232
|
//
|
|
@@ -658,7 +658,7 @@ mkdirSync(process.env.CLEAROTRON_SUITE_TELEMETRY_DIR, { recursive: true });
|
|
|
658
658
|
|
|
659
659
|
// ── NO TEST MAY WRITE INSIDE THIS CHECKOUT ──────────────────────────────────────────────────────────
|
|
660
660
|
//
|
|
661
|
-
//
|
|
661
|
+
// `scripts/repo-writes.mjs` carries the whole reason. In one line: `node --test`
|
|
662
662
|
// runs test FILES concurrently against ONE shared working tree, so a test that writes into the checkout
|
|
663
663
|
// is read by every other file in the run, and the red that causes surfaces somewhere else entirely —
|
|
664
664
|
// another arm, another branch, another session, in a diff that never touched the file.
|
|
@@ -268,7 +268,7 @@ export function collect(root = ROOT, tree = npmTree(root), { linkTarget = defaul
|
|
|
268
268
|
// the occurrences are NOT equivalent — the deduped ones are stubs with no `dependencies` at all,
|
|
269
269
|
// and which one you meet first is an ordering accident.
|
|
270
270
|
//
|
|
271
|
-
// Measured on this tree
|
|
271
|
+
// Measured on this tree: `ajv@8.20.0` appears twice under
|
|
272
272
|
// `@modelcontextprotocol/sdk` — first as a stub with 0 children, then with 4. The stub was met
|
|
273
273
|
// first, so `ajv` got its row and its ENTIRE SUBTREE was never walked. `fast-uri` is one of those
|
|
274
274
|
// four children, which is how a production dependency came to ship with no licence recorded.
|
|
@@ -46,7 +46,7 @@ export function buffersDirs(root, out = []) {
|
|
|
46
46
|
|
|
47
47
|
const keep = process.argv.includes("--keep");
|
|
48
48
|
const staging = mkdtempSync(join(tmpdir(), "clearotron-verify-"));
|
|
49
|
-
// ── CAN THIS RUN ON THE TREE IT IS POINTED AT
|
|
49
|
+
// ── CAN THIS RUN ON THE TREE IT IS POINTED AT ───────────────────────────────────────────────────────
|
|
50
50
|
//
|
|
51
51
|
// THIS SCRIPT WAS INVOKED BY NOTHING for as long as it has existed on the public tree: no workflow, no
|
|
52
52
|
// npm script, every other mention of its name a comment. That is the shape — an
|
|
@@ -72,7 +72,7 @@ export function audienceLabel(aud) {
|
|
|
72
72
|
return list.map((a) => `${String(a).slice(0, 8)}…`).join(",");
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// ── THE AUDIENCE THE EDGE ISSUES, AS OPPOSED TO THE ONE THIS INSTALL EXPECTS
|
|
75
|
+
// ── THE AUDIENCE THE EDGE ISSUES, AS OPPOSED TO THE ONE THIS INSTALL EXPECTS ───────────────────────
|
|
76
76
|
//
|
|
77
77
|
// Everything above reads LOCAL configuration. What follows reads the EDGE, so `doctor` can compare the
|
|
78
78
|
// two — the half of the recreation trap with no symptom of its own. Deleting and recreating a
|
|
@@ -133,7 +133,7 @@ const ACCESS_RESOURCE_RE = /cloudflare-access-protected-resource/i;
|
|
|
133
133
|
|
|
134
134
|
export function readAudience({ location = "", status = null, error = null, wwwAuthenticate = "", viaEdge = false } = {}) {
|
|
135
135
|
if (error) return { kind: "unreachable", why: String(error?.message ?? error).slice(0, 200) };
|
|
136
|
-
// ── THREE DOORS, NOT ONE
|
|
136
|
+
// ── THREE DOORS, NOT ONE ────────────────────────────────────────────────────────────────────────
|
|
137
137
|
//
|
|
138
138
|
// This returned `not-fronted` for every response with no redirect, and measured against production's
|
|
139
139
|
// four configured hostnames that one label covered three materially different states:
|
package/shared/anon-overlay.mjs
CHANGED
|
@@ -83,7 +83,7 @@ const OVERLAY_BODY = `(function(){
|
|
|
83
83
|
// Case-insensitive client lookup, and the reason it is not redundant: surfaces tag rows by profile
|
|
84
84
|
// KEY ("coastline") and by display NAME ("Coastline"), so lowercasing both makes a single-token name
|
|
85
85
|
// resolve to its key's alias. The two surfaces that tagged by NAME were Quality and Feedback, both
|
|
86
|
-
// retired
|
|
86
|
+
// retired — the fold stays because it is what makes the lookup total over either spelling,
|
|
87
87
|
// not because those pages are coming back.
|
|
88
88
|
var CL = {}; for(var ck in (C.clients||{})){ if(Object.prototype.hasOwnProperty.call(C.clients,ck)) CL[String(ck).toLowerCase()] = C.clients[ck]; }
|
|
89
89
|
var KEYS = (DA.clientKeys||[]).map(function(x){return String(x).toLowerCase();});
|