clearotron 0.3.2-beta.10 → 0.3.2-beta.11
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/CONTRIBUTING.md +1 -0
- package/INSTALL.md +49 -46
- package/README.md +8 -7
- package/bin/onboard.mjs +44 -13
- package/bin/start.mjs +31 -6
- package/build-info.json +2 -2
- package/docs/architecture/05-config-governance.md +1 -1
- package/driver/CHANGELOG.md +400 -388
- package/driver/engine/mcp/recording-server.mjs +20 -0
- package/driver/package.json +1 -1
- package/driver/phase0.mjs +16 -7
- package/driver/pipeline.mjs +48 -1
- package/driver/portal-local-auth.mjs +14 -4
- package/driver/portal-service.mjs +12 -3
- package/driver/publish/attr.mjs +36 -0
- package/driver/publish/index.mjs +10 -10
- package/driver/publish/parse.mjs +1 -1
- package/driver/publish/render.mjs +9 -9
- package/driver/publish/xlsx.mjs +7 -1
- package/driver/queue-watch-verdict.mjs +1 -1
- package/driver/suite-census.json +59 -23
- package/driver/unit-inventory.mjs +7 -7
- package/mcp-server/CHANGELOG.md +23 -19
- package/mcp-server/lib/audit-view.mjs +4 -4
- package/mcp-server/lib/trace.mjs +1 -1
- package/mcp-server/package.json +1 -1
- package/package.json +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/jx/README.md +2 -2
- package/providers/jx-subclass/README.md +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/scripts/README.md +5 -10
- package/scripts/citation-line-check.mjs +2 -2
- package/scripts/e2e.mjs +8 -8
- package/scripts/generated-files-are-current.mjs +35 -4
- package/scripts/live-surface-check.mjs +17 -17
- package/scripts/release-code-scanning-check.mjs +114 -0
- package/scripts/release-visible-check.mjs +117 -0
- package/shared/invocation.mjs +2 -2
- package/shared/register-selection.mjs +4 -1
- package/shared/root-doc-commands.mjs +10 -4
- package/shared/scope.mjs +2 -2
|
@@ -826,6 +826,26 @@ serve({
|
|
|
826
826
|
},
|
|
827
827
|
},
|
|
828
828
|
},
|
|
829
|
+
// OFFERED HERE, OR NEVER SENT. The acceptor took this field for a beta and the plan acted on it,
|
|
830
|
+
// and no frame ever proposed one, because the schema a model is given did not offer it. Worded as
|
|
831
|
+
// the owner approved it; it is model-facing prose, so its wording is his.
|
|
832
|
+
house_element_candidate: {
|
|
833
|
+
type: "object",
|
|
834
|
+
description:
|
|
835
|
+
"Only when an element of the mark is one the CLIENT already owns as a registered mark in the " +
|
|
836
|
+
"instructed classes (a house mark before a tagline, for instance): name that element, the remainder " +
|
|
837
|
+
"the analysis should be limited to, and why you read it as the client's own. Ownership is checked " +
|
|
838
|
+
"on the register, by owner, before anything is excluded; if it cannot be verified, nothing is. Omit " +
|
|
839
|
+
"the field when no element is the client's own.",
|
|
840
|
+
required: ["element", "remainder", "owner_basis"],
|
|
841
|
+
properties: {
|
|
842
|
+
element: { type: "string", description: "The client's own element, exactly as it appears in the mark." },
|
|
843
|
+
remainder: { type: "string",
|
|
844
|
+
description: "The rest of the mark: the part the analysis is limited to. Never empty, never the element itself." },
|
|
845
|
+
owner_basis: { type: "string",
|
|
846
|
+
description: "Why you read the element as the client's own — what in the matter says so." },
|
|
847
|
+
},
|
|
848
|
+
},
|
|
829
849
|
},
|
|
830
850
|
},
|
|
831
851
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
package/driver/package.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"name": "clearotron-driver",
|
|
3
3
|
"private": true,
|
|
4
4
|
"type": "module",
|
|
5
|
-
"version": "0.3.2-beta.
|
|
5
|
+
"version": "0.3.2-beta.11",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
7
7
|
"description": "Deterministic driver for the trademark clearance workflow: orchestration in code (fan-out, fan-in barrier, gating, retries); the model does judgment leaves only, through a reasoning CLI spawned per stage.",
|
|
8
8
|
"engines": {
|
package/driver/phase0.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import { existsSync, appendFileSync, readFileSync, mkdirSync } from "node:fs";
|
|
7
7
|
import { join, dirname } from "node:path";
|
|
8
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
8
|
+
import { createHash, randomUUID, randomInt } from "node:crypto";
|
|
9
9
|
import { ledgerPath } from "../providers/_shared/ledger-path.mjs";
|
|
10
10
|
import { config } from "./driver.config.mjs";
|
|
11
11
|
import { resolveProfile } from "./profiles.mjs";
|
|
@@ -26,7 +26,15 @@ const NOUN = [
|
|
|
26
26
|
"warren", "beacon", "quarry", "willow", "kestrel", "monolith", "estuary", "bramble", "compass", "drift",
|
|
27
27
|
];
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
// THE CODENAME IS DRAWN FROM THE OPERATING SYSTEM'S RANDOM SOURCE, not Math.random. It is a label, not a
|
|
30
|
+
// secret: it names a run's directory and appears in its reports, and the keys built from it name a run's
|
|
31
|
+
// attempts and ledgers — nothing is authorised by knowing one. But it is the first thing the run identifier
|
|
32
|
+
// is made of, and a label that is also unpredictable costs nothing here, while a predictable one invites
|
|
33
|
+
// every later reader to wonder what else leans on it. Same [0, 1) shape as Math.random, so the injected
|
|
34
|
+
// `rand` the tests pass for determinism works unchanged.
|
|
35
|
+
const cryptoRand = () => randomInt(0, 2 ** 32) / 2 ** 32;
|
|
36
|
+
|
|
37
|
+
export function genCodename(rand = cryptoRand) {
|
|
30
38
|
return `${ADJ[Math.floor(rand() * ADJ.length)]}-${NOUN[Math.floor(rand() * NOUN.length)]}`;
|
|
31
39
|
}
|
|
32
40
|
|
|
@@ -186,7 +194,7 @@ export function claimRunCodename({ slug, date, codename, registryPath = codename
|
|
|
186
194
|
// a pre-mint through raw genCodename would silently lose this collision protection.
|
|
187
195
|
// 20 straight collisions ⇒ the 400-name space is exhausted for this slug+date — suffix for freshness.
|
|
188
196
|
export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
|
|
189
|
-
rand =
|
|
197
|
+
rand = cryptoRand, claim = claimRunCodename }) {
|
|
190
198
|
for (let i = 0; i < 20; i++) {
|
|
191
199
|
const c = genCodename(rand);
|
|
192
200
|
if (!existsSync(runDirFor({ slug, date, codename: c, studioRoot }))
|
|
@@ -198,22 +206,23 @@ export function mintFreshCodename({ slug, date, studioRoot = config.studioRoot,
|
|
|
198
206
|
return `${genCodename(rand)}-${Date.now().toString(36)}`;
|
|
199
207
|
}
|
|
200
208
|
|
|
201
|
-
// Assemble the immutable run identity for a job. rand
|
|
209
|
+
// Assemble the immutable run identity for a job. rand and claim are injectable for tests — claim so a test's
|
|
210
|
+
// mints go to a registry of its own rather than the box-wide one every run on this account shares. studioRoot/archiveRoot are the
|
|
202
211
|
// FORWARDING agent's (derived from its queue dir by the runner); they default to clawdi's for back-compat.
|
|
203
212
|
// `codename`/`date` overrides exist for RESUME: re-driving a failed run must rebuild the SAME run identity
|
|
204
213
|
// (slug/date/codename → the same run-dir) so the idempotency skip reuses the prior stages instead of minting
|
|
205
214
|
// a fresh codename and re-spending everything (the "pearl-keystone" trap). A bare new run leaves both unset.
|
|
206
215
|
export function buildRunContext(
|
|
207
216
|
job,
|
|
208
|
-
{ rand =
|
|
209
|
-
codename: codenameOverride, date: dateOverride } = {},
|
|
217
|
+
{ rand = cryptoRand, now = new Date(), studioRoot = config.studioRoot, archiveRoot = config.archiveRoot,
|
|
218
|
+
codename: codenameOverride, date: dateOverride, claim = claimRunCodename } = {},
|
|
210
219
|
) {
|
|
211
220
|
const slug = deriveSlug(job);
|
|
212
221
|
const date = dateOverride ?? todayISO(now);
|
|
213
222
|
// Fresh mints go through mintFreshCodename (above) so they never land in a dir another run already owns.
|
|
214
223
|
// Overrides skip the check: RESUME (and the runner's dispatch pre-mint, which already minted freshly)
|
|
215
224
|
// wants the identity verbatim.
|
|
216
|
-
const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand });
|
|
225
|
+
const codename = codenameOverride ?? mintFreshCodename({ slug, date, studioRoot, archiveRoot, rand, claim });
|
|
217
226
|
return {
|
|
218
227
|
slug,
|
|
219
228
|
codename,
|
package/driver/pipeline.mjs
CHANGED
|
@@ -13518,7 +13518,7 @@ async function pipelineInner(job, opts = {}) {
|
|
|
13518
13518
|
// structured-only). Each stage is file-gated/resumable; per-card sessions feed the lint repair below.
|
|
13519
13519
|
// C2 — fold same-owner+same-mark duplicate filings into one finding BEFORE the overview + cards read
|
|
13520
13520
|
// findings.json, so the whole delivery phase (and the published copy) sees the single consolidated set.
|
|
13521
|
-
injectDeferralCoverage(P, run.runDir, note); // A3: unclosed reopen directives become reader-visible coverage rows first
|
|
13521
|
+
injectDeferralCoverage(P, run.runDir, note); injectMeaningGapCoverage(P, run.runDir, note); // A3: unclosed reopen directives, and meaning searches that did not complete, become reader-visible coverage rows first
|
|
13522
13522
|
// qw/cn-scope-honesty — the sibling injection: a CN-family-scope run whose zh lane did not run
|
|
13523
13523
|
// discloses what the native-language investigation would have searched, and where it is offered
|
|
13524
13524
|
// (coverage-limited: never clamps, never gates).
|
|
@@ -16831,3 +16831,50 @@ export function registerGapConditions(regGap) { // @internal
|
|
|
16831
16831
|
});
|
|
16832
16832
|
return out;
|
|
16833
16833
|
}
|
|
16834
|
+
|
|
16835
|
+
// ── A MEANING SEARCH THAT DID NOT COMPLETE REACHES THE CLIENT'S OWN PAGE, NOT ONLY THE AUDIT ────────────
|
|
16836
|
+
//
|
|
16837
|
+
// The connotation gate delivers a run whose dictated meaning searches did not all complete, and records
|
|
16838
|
+
// each unfinished one as a gap carrying its term — a gap the engine wrote for a search that never came
|
|
16839
|
+
// back, or the provider's own row for one it refused. The audit read those gaps; the report was never
|
|
16840
|
+
// handed the grid, so whether a client learned a meaning search was missing was up to the synthesis
|
|
16841
|
+
// model. This puts each on the coverage the report renders, the way an unclosed follow-up already is:
|
|
16842
|
+
// the same row, `deferralCoverageRow`, and an approved reader sentence that says it is left open — true
|
|
16843
|
+
// of a drop and of a refusal alike. Telling the two apart on the page
|
|
16844
|
+
// would need a sentence nobody has approved, and is not attempted here.
|
|
16845
|
+
//
|
|
16846
|
+
// The same guard as `injectDeferralCoverage`: a gap synthesis already weighed in is not added twice, and
|
|
16847
|
+
// a findings.json that fails its own schema after the push is left as it was.
|
|
16848
|
+
// THE WORDS ARE CHOSEN, NOT THE CAUSE: of the approved reasons, "nothing in the run's own record confirms it
|
|
16849
|
+
// was searched" is true of a search that never came back AND of one the provider refused, and does not
|
|
16850
|
+
// repeat the row's own "not completed this run" the way the generic fallback does. It borrows that arm's
|
|
16851
|
+
// sentence only; if the arm is ever reworded for its own case, re-read this row against it.
|
|
16852
|
+
const MEANING_SEARCH_UNFINISHED = "not-verified-closed";
|
|
16853
|
+
export function injectMeaningGapCoverage(P, runDir, note) { // @internal — exported so the refusal shape is testable without a run
|
|
16854
|
+
try {
|
|
16855
|
+
if (!existsSync(P.commonLawGrid) || !existsSync(P.findings)) return;
|
|
16856
|
+
const grid = JSON.parse(readFileSync(P.commonLawGrid, "utf8"));
|
|
16857
|
+
const terms = [...new Set((Array.isArray(grid?.gaps) ? grid.gaps : [])
|
|
16858
|
+
.filter((g) => String(g?.platform ?? "").toLowerCase() === "connotation")
|
|
16859
|
+
.map((g) => String(g?.term ?? "").trim()).filter(Boolean))];
|
|
16860
|
+
if (!terms.length) return;
|
|
16861
|
+
const doc = JSON.parse(readFileSync(P.findings, "utf8"));
|
|
16862
|
+
if (!Array.isArray(doc.coverage)) doc.coverage = [];
|
|
16863
|
+
const normTxt = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
|
|
16864
|
+
const covText = normTxt(doc.coverage.map((c) => `${c.area} ${c.note ?? ""}`).join(" "));
|
|
16865
|
+
let added = 0;
|
|
16866
|
+
for (const term of terms) {
|
|
16867
|
+
const slice = normTxt(term).split(" ").filter((w) => w.length >= 4).slice(0, 4).join(" ");
|
|
16868
|
+
if (slice && covText.includes(slice)) continue; // synthesis already weighed it in
|
|
16869
|
+
doc.coverage.push(deferralCoverageRow(term, MEANING_SEARCH_UNFINISHED));
|
|
16870
|
+
added++;
|
|
16871
|
+
}
|
|
16872
|
+
if (!added) return;
|
|
16873
|
+
parseFindingsJson(JSON.stringify(doc)); // re-validate the shape (throws → catch keeps original)
|
|
16874
|
+
atomicWrite(P.findings, `${JSON.stringify(doc, null, 2)}\n`);
|
|
16875
|
+
note(`[common-law] ${added} meaning search(es) that did not complete now reach the report's coverage`);
|
|
16876
|
+
runLog(runDir, { event: "meaning-gap-coverage", added });
|
|
16877
|
+
} catch (e) {
|
|
16878
|
+
note(`[common-law] meaning-gap coverage skipped: ${String(e?.message || e).replace(/\s+/g, " ").slice(0, 100)}`);
|
|
16879
|
+
}
|
|
16880
|
+
}
|
|
@@ -422,10 +422,20 @@ export function firstRunCredentialLines({ handoff, credentialPath, email, passph
|
|
|
422
422
|
return [head, ` PASSPHRASE: ${passphrase}`,
|
|
423
423
|
" Write it down now. It is not stored anywhere in a form that can be read back, and this line will not be printed again."];
|
|
424
424
|
}
|
|
425
|
-
return [head,
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
425
|
+
return [head, ...passphraseWithheldLines({ stream: "stderr", resetCommand }).map((line) => ` ${line}`)];
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* What stands where the passphrase would, on an output that is not a terminal. ONE composer for the two
|
|
430
|
+
* places a first run can hand the passphrase over \u2014 the portal's standard error and the launcher's closing
|
|
431
|
+
* box on standard output \u2014 so they give one reason and one way back in. It is never handed the
|
|
432
|
+
* passphrase, so no caller can wire the value through it.
|
|
433
|
+
*/
|
|
434
|
+
export function passphraseWithheldLines({ stream, resetCommand = "" }) {
|
|
435
|
+
return [
|
|
436
|
+
`The passphrase is NOT printed here: ${stream} is not a terminal, so this line would outlive the moment \u2014 a journal, a CI log, or a test's captured output.`,
|
|
437
|
+
"Nothing holds it now, this process included: what is on disk is a digest. To get one you can sign in with, run:",
|
|
438
|
+
` ${resetCommand}`];
|
|
429
439
|
}
|
|
430
440
|
|
|
431
441
|
export const SESSION_DOMAIN = "portal-session.v1|";
|
|
@@ -968,6 +968,15 @@ const DENIAL_REASON = Object.freeze({
|
|
|
968
968
|
429: "rate limited",
|
|
969
969
|
});
|
|
970
970
|
|
|
971
|
+
/**
|
|
972
|
+
* WHAT A REFUSED CLIENT IS TOLD — a closed set, keyed on the status, never the error's message. The message
|
|
973
|
+
* can come out of a third-party token check with a claim value, an address or a fragment of the rejected
|
|
974
|
+
* token in it, and some of our own refusals put the caller's address in theirs. A 401 keeps the words the
|
|
975
|
+
* browser contract already decodes ("not signed in"); every other status says the journal's code-owned
|
|
976
|
+
* reason. The message itself is still journalled nowhere a client reads. PURE.
|
|
977
|
+
*/
|
|
978
|
+
export const refusalWords = (status) => (status === 401 ? "not signed in" : DENIAL_REASON[status] ?? "refused at the door");
|
|
979
|
+
|
|
971
980
|
/**
|
|
972
981
|
* — THE ROW A REFUSAL FILES. One shape, one sink, whichever side of `route` decided the answer.
|
|
973
982
|
*
|
|
@@ -4399,7 +4408,7 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4399
4408
|
// row must not become a place a caller can write into by sending a body that fails to parse.
|
|
4400
4409
|
let body;
|
|
4401
4410
|
try { body = await readJsonBody(req); }
|
|
4402
|
-
catch
|
|
4411
|
+
catch { journal(400, "unreadable body", identity?.email); return send(res, 400, { error: "unreadable body" }); } // the parser's own message describes the bytes it was sent, and a response is not where a parse error is read back
|
|
4403
4412
|
const query = Object.fromEntries(url.searchParams.entries());
|
|
4404
4413
|
const r = await service.route(req.method, url.pathname, identity, body, query);
|
|
4405
4414
|
// A PLAIN DOCUMENT this server renders itself. Escaped into a `<pre>`, so nothing in the file can
|
|
@@ -4466,11 +4475,11 @@ export function makeHttpHandler({ verify, limiter, service, log = () => {}, devI
|
|
|
4466
4475
|
// person who typed the address gets a door.
|
|
4467
4476
|
const wantsHtml = String(req.headers.accept ?? "").includes("text/html");
|
|
4468
4477
|
if (wantsHtml) {
|
|
4469
|
-
const html = denialPage(e.status, e.
|
|
4478
|
+
const html = denialPage(e.status, refusalWords(e.status));
|
|
4470
4479
|
res.writeHead(e.status, { "content-type": "text/html; charset=utf-8", "content-length": Buffer.byteLength(html), "x-content-type-options": "nosniff" });
|
|
4471
4480
|
return res.end(html);
|
|
4472
4481
|
}
|
|
4473
|
-
return send(res, e.status, { error: e.
|
|
4482
|
+
return send(res, e.status, { error: refusalWords(e.status) });
|
|
4474
4483
|
}
|
|
4475
4484
|
log(`500 ${String(e?.message || e)}`);
|
|
4476
4485
|
if (!alreadyFiled(e)) journal(500, e?.message ?? String(e), identity?.email);
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
// attr.mjs — a value placed inside a quoted HTML attribute, and a URL placed inside an href.
|
|
4
|
+
//
|
|
5
|
+
// Every renderer here has its own `esc`, and each escapes `&`, `<` and `>` — which is right for text and
|
|
6
|
+
// wrong inside an attribute: a `"` in the value closes the attribute and whatever follows is markup. So a
|
|
7
|
+
// value that sits between quotes goes through `attrValue`, which also encodes both quote characters.
|
|
8
|
+
//
|
|
9
|
+
// An href needs one more guard, because a correctly escaped `javascript:` URL is still a link that runs
|
|
10
|
+
// code when clicked, and the same for `data:` and `vbscript:`. So an href is built by `hrefAttr` or not at
|
|
11
|
+
// all: http(s) only — or, when the caller says so, a reference with no scheme (the audit download is a file
|
|
12
|
+
// beside the report). A browser drops tabs, newlines and other control characters from a URL before it
|
|
13
|
+
// reads the scheme, so the scheme is read with those removed: `java` + newline + `script:` is `javascript:`.
|
|
14
|
+
|
|
15
|
+
// Every C0 control character, space and DEL — what a URL parser strips or ignores before the scheme.
|
|
16
|
+
const IGNORED_BEFORE_SCHEME = /[\x00-\x20\x7f]/g;
|
|
17
|
+
|
|
18
|
+
/** A value safe between the quotes of an HTML attribute: & < > " and ' all encoded. PURE. */
|
|
19
|
+
export const attrValue = (s) => String(s ?? "")
|
|
20
|
+
.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
21
|
+
.replace(/"/g, """).replace(/'/g, "'");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* An href value, attribute-safe, or null when it must not be a link — the caller then renders its text
|
|
25
|
+
* with no anchor. `relative: true` also admits a reference with no scheme, never a protocol-relative one
|
|
26
|
+
* (`//host/…` is an external address by another spelling). PURE.
|
|
27
|
+
*/
|
|
28
|
+
export function hrefAttr(u, { relative = false } = {}) {
|
|
29
|
+
const s = String(u ?? "").trim();
|
|
30
|
+
if (!s) return null;
|
|
31
|
+
const probe = s.replace(IGNORED_BEFORE_SCHEME, "");
|
|
32
|
+
const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(probe)?.[1];
|
|
33
|
+
if (scheme) { if (!/^https?$/i.test(scheme)) return null; }
|
|
34
|
+
else if (!relative || probe.startsWith("//") || probe.startsWith("\\\\")) return null;
|
|
35
|
+
return attrValue(s);
|
|
36
|
+
}
|
package/driver/publish/index.mjs
CHANGED
|
@@ -239,25 +239,25 @@ function indexRows(runs, { reportFile, linkPrefix = '', showAudit = true, client
|
|
|
239
239
|
// anchors are neutralised; the client column → aliased. Keyed by customerKey + runId so a demo run is
|
|
240
240
|
// exempt and shows real.
|
|
241
241
|
const ck = r.customerKey || '';
|
|
242
|
-
const runAttrs = `data-anon-href data-anon-key="${
|
|
242
|
+
const runAttrs = `data-anon-href data-anon-key="${attrValue(ck)}" data-anon-run="${attrValue(r.runId)}"`;
|
|
243
243
|
const mark = anonMark(r.matter, { key: ck, run: r.runId });
|
|
244
|
-
const matterCell = `<a ${runAttrs} href="${linkPrefix}${
|
|
244
|
+
const matterCell = `<a ${runAttrs} href="${linkPrefix}${attrValue(r.runId)}/${reportFile}">${mark}</a>`;
|
|
245
245
|
// Run cell: staff surfaces show date · HH:mm (Zurich, same-day issuedAt only) · codename; the
|
|
246
246
|
// customer-facing per-customer index shows the date ONLY — codenames and timestamps are internal.
|
|
247
247
|
const t = client ? '' : issuedTime(r);
|
|
248
248
|
const runCell = client ? esc(r.date) : `${esc(r.date)}${t ? ` · ${esc(t)}` : ''} · <code>${esc(r.codename || '')}</code>`;
|
|
249
|
-
return ` <tr data-client="${
|
|
249
|
+
return ` <tr data-client="${attrValue(ck)}">
|
|
250
250
|
<td>${matterCell}</td>
|
|
251
251
|
<td>${anonMark(r.title, { key: ck, run: r.runId })}</td>
|
|
252
252
|
<td>${anonClient(r.client, ck)}</td>
|
|
253
|
-
<td><span class="b b-${
|
|
253
|
+
<td><span class="b b-${attrValue(r.badge)}">${esc(r.overall)}</span>${qcFailed ? ' <span class="hold" title="machine QC checks failed — see the audit workbook">⚠ QC</span>' : ''}${!client && !qcFailed && r.clientGate?.inputsAbsent?.length ? ` <span class="disc" title="${escAttr(`published without ${r.clientGate.inputsAbsent.join(', ')} — each declared optional, so the release stands; the report was built without it`)}">◦ built without an input</span>` : ''}${
|
|
254
254
|
// spec 64 — the stance clause of THE one risk statement beside the (labelled) band pill, so the
|
|
255
255
|
// index can never show a bare severity word that reads as the whole answer. The tier word leads
|
|
256
256
|
// the statement; the pill already shows it, so the cell carries the clause after the first " — ".
|
|
257
257
|
// Legacy meta.json (no statement) renders this cell byte-identically.
|
|
258
258
|
r.statement ? `<span class="stmt" title="${escAttr(r.statement)}">${esc(String(r.statement).split(' — ').slice(1).join(' — ') || r.statement)}</span>` : ''}</td>
|
|
259
259
|
<td>${runCell}</td>${showAudit ? `
|
|
260
|
-
<td>${r.auditFile ? `<a ${runAttrs} href="${linkPrefix}${
|
|
260
|
+
<td>${r.auditFile ? `<a ${runAttrs} href="${linkPrefix}${attrValue(r.runId)}/${attrValue(r.auditFile)}">audit.xlsx</a>` : '—'}</td>` : ''}
|
|
261
261
|
</tr>`;
|
|
262
262
|
}).join('\n');
|
|
263
263
|
}
|
|
@@ -275,7 +275,7 @@ function clientFilterBar(clients) {
|
|
|
275
275
|
if (!clients || clients.length < 2) return '';
|
|
276
276
|
// data-anon="client" goes on the <option> itself (a span inside <option> is invalid) so the demo overlay
|
|
277
277
|
// aliases the visible label in privacy mode; the value stays the raw customerKey that data-client matches.
|
|
278
|
-
const opts = clients.map(c => `<option value="${
|
|
278
|
+
const opts = clients.map(c => `<option value="${attrValue(c.key)}" data-anon="client" data-anon-key="${attrValue(c.key)}">${esc(c.label)}</option>`).join('');
|
|
279
279
|
return `<div class="filterbar"><label class="flbl" for="clientFilter">Client</label>` +
|
|
280
280
|
`<select id="clientFilter"><option value="">All clients</option>${opts}</select></div>`;
|
|
281
281
|
}
|
|
@@ -377,7 +377,7 @@ ${rows}
|
|
|
377
377
|
// sidecar without importing this renderer. Imported as well as re-exported — `export ... from` re-exports
|
|
378
378
|
// without binding the name in this module's scope, and regenIndex below calls readArchivedSet directly.
|
|
379
379
|
export { ARCHIVE_TAGS_FILE, readArchivedSet, updateArchived } from './archive-tags.mjs';
|
|
380
|
-
import { readArchivedSet } from './archive-tags.mjs';
|
|
380
|
+
import { readArchivedSet } from './archive-tags.mjs'; import { attrValue, hrefAttr } from './attr.mjs'; // a quoted attribute, and an href
|
|
381
381
|
|
|
382
382
|
// Collapsible "Older / retired runs" fold for the STAFF index: a count in the summary (so viewers see how many
|
|
383
383
|
// runs we've done) with the names hidden until expanded. Named distinctly from the "Clearance reports" tab so
|
|
@@ -1665,9 +1665,9 @@ export function composeEmailHtml(reportMdPath, url, auditFile, names = [], deliv
|
|
|
1665
1665
|
// Heading-neutral match: accept the new "Reviewer's open questions" and the legacy "Open questions for the reviewer".
|
|
1666
1666
|
const oq = (secs['Summary'] || '').match(/\*\*(?:Reviewer's open questions|Open questions for the reviewer)[\s\S]*?(?=\n\n[^*\d])/i);
|
|
1667
1667
|
// The report link rides HIGH — right under the bottom line in the headline, not buried below the table.
|
|
1668
|
-
const reportLink = url
|
|
1669
|
-
? `<p style="margin:0 0 10px"><a href="${
|
|
1670
|
-
+ (auditUrl ? ` · <a href="${
|
|
1668
|
+
const reportLink = hrefAttr(url)
|
|
1669
|
+
? `<p style="margin:0 0 10px"><a href="${hrefAttr(url)}" style="color:#1a4fd6;font-weight:bold;font-size:12pt;text-decoration:none">▶ Open the full report</a>`
|
|
1670
|
+
+ (hrefAttr(auditUrl) ? ` · <a href="${hrefAttr(auditUrl)}" style="color:#3b4fd6;text-decoration:none">Download audit (Excel)</a>` : '')
|
|
1671
1671
|
+ `</p>`
|
|
1672
1672
|
: '';
|
|
1673
1673
|
// The search-scope signal leads the email. The 2026-06-12 "lint flags do not render on the email" rule
|
package/driver/publish/parse.mjs
CHANGED
|
@@ -99,7 +99,7 @@ export function stripInternal(s, { client = false } = {}) {
|
|
|
99
99
|
const out = [];
|
|
100
100
|
for (let line of lines) {
|
|
101
101
|
// normalize wrapped markers ("**::p:: x**", "*::p:: x*") so the marker is match-stable
|
|
102
|
-
line = line.replace(/(\*{1,2})\s*::p
|
|
102
|
+
line = line.replace(/(\*{1,2})\s*::p::([\s\S]*?)\1/g, (_, _star, x) => `::p:: ${x.replace(/^\s+/, '')}`); // no `\s*` before the capture: overlapping it with the lazy body backtracked quadratically on an unclosed marker
|
|
103
103
|
if (!line.includes('::p::')) { out.push(line); continue; }
|
|
104
104
|
if (client) {
|
|
105
105
|
const head = line.slice(0, line.indexOf('::p::')).replace(/[\s*_]+$/, '').trim();
|
|
@@ -25,7 +25,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
25
25
|
// its own split, which is how the two rules diverged. It calls stripTelemetry now, so the renderer holds
|
|
26
26
|
// no copy of the RULE either, only a call to it.
|
|
27
27
|
import { parseReport, stripInternal, stripTelemetry } from './parse.mjs';
|
|
28
|
-
import { clientConditions } from '../terminal-clamp.mjs';
|
|
28
|
+
import { clientConditions } from '../terminal-clamp.mjs'; import { hrefAttr } from './attr.mjs'; // an href is attribute-safe and http(s), or not a link
|
|
29
29
|
import { EXPORT_TOGGLE, exportPopover, EXPORT_MENU_JS } from './report-topbar.mjs'; // the export menu's shell and behaviour, shared with the knockout template // the reader's clause per condition, shared with the cover note
|
|
30
30
|
import { COMMON_LAW, normRegion, regionName, REGION_NAMES } from './regions.mjs';
|
|
31
31
|
import { parseFindingsJson, bindRecommendation, sentenceCaseLead, CLIENT_TIER_BY_COMPOSITE, bandOf, compareBlockingPower, inDispositionMode, reasonedNegativeGroups } from '../findings-model.mjs';
|
|
@@ -995,7 +995,7 @@ const NAMES_CAP = 8;
|
|
|
995
995
|
function clearedGroupsHtml(searchDepth, auditFile) {
|
|
996
996
|
const reg = (searchDepth && searchDepth.cleared && searchDepth.cleared.register) || [];
|
|
997
997
|
if (!reg.length) return '';
|
|
998
|
-
const link = (n) => auditFile ? `<div class="cmore"><a class="wb" href="${
|
|
998
|
+
const link = (n) => hrefAttr(auditFile, { relative: true }) ? `<div class="cmore"><a class="wb" href="${hrefAttr(auditFile, { relative: true })}">All ${n.toLocaleString('en-GB')} in the audit workbook</a></div>` : '';
|
|
999
999
|
return CLEARED_GROUP_ORDER.map((g) => {
|
|
1000
1000
|
const items = reg.filter((c) => c.group === g);
|
|
1001
1001
|
if (!items.length) return '';
|
|
@@ -1229,8 +1229,8 @@ function whatWasSearchedSection(opts, coverage = [], findings = [], recordsByUri
|
|
|
1229
1229
|
: '';
|
|
1230
1230
|
// THE BOARDS END THE SECTION WITH THE WORKBOOK: every search and its result are in the audit workbook,
|
|
1231
1231
|
// which is where a reader who wants the record goes.
|
|
1232
|
-
const more = opts?.auditFile
|
|
1233
|
-
? `<div class="cmore"><a class="wb" href="${
|
|
1232
|
+
const more = hrefAttr(opts?.auditFile, { relative: true })
|
|
1233
|
+
? `<div class="cmore"><a class="wb" href="${hrefAttr(opts.auditFile, { relative: true })}">Every search and result, in the audit workbook</a></div>` : '';
|
|
1234
1234
|
if (!rows.length && !openHtml && !prov) return '';
|
|
1235
1235
|
return `<div class="sec" id="searched"><span class="num"></span><h2>What was searched</h2></div>
|
|
1236
1236
|
<details class="searched"><summary><span class="gname">Counts for this search</span></summary><div class="gbody">${
|
|
@@ -1631,7 +1631,7 @@ function fullDetail(f, card, recordsByUri = new Map()) {
|
|
|
1631
1631
|
const regUri = (u, fb) => {
|
|
1632
1632
|
const h = regHref(u);
|
|
1633
1633
|
const label = esc(u || fb || 'registration');
|
|
1634
|
-
if (h) return `<a href="${
|
|
1634
|
+
if (hrefAttr(h)) return `<a href="${hrefAttr(h)}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
1635
1635
|
const note = RECORD_CITATION ? NO_LINK_NOTE[RECORD_CITATION] : null;
|
|
1636
1636
|
return note ? `${label}<span class="reg-nolink">${note}</span>` : label;
|
|
1637
1637
|
};
|
|
@@ -1744,7 +1744,7 @@ function fullDetail(f, card, recordsByUri = new Map()) {
|
|
|
1744
1744
|
const stat = status ? ` <i class="evstat">Evidence: ${esc(status)}</i>` : '';
|
|
1745
1745
|
const urls = (String(src).match(/https?:\/\/[^\s,|]+/g) || []).map(u => u.replace(/[).,;:]+$/, ''));
|
|
1746
1746
|
if (!urls.length) return `<li><b>${label}</b> ${esc(src)}${stat}</li>`;
|
|
1747
|
-
const links = urls.map(u => `<a href="${
|
|
1747
|
+
const links = urls.map(u => (hrefAttr(u) ? `<a href="${hrefAttr(u)}" target="_blank" rel="noopener noreferrer">${esc(u.replace(/^https?:\/\//, '').slice(0, 48))}</a>` : esc(u))).join(' · ');
|
|
1748
1748
|
const rest = String(src).replace(/https?:\/\/[^\s,|]+/g, ' ').replace(/[\s,|]+/g, ' ').trim().replace(/^[—–-]\s*/, '');
|
|
1749
1749
|
return `<li><b>${label}</b> ${links}${rest ? ` — ${esc(rest)}` : ''}${stat}</li>`;
|
|
1750
1750
|
};
|
|
@@ -1796,7 +1796,7 @@ function fullDetail(f, card, recordsByUri = new Map()) {
|
|
|
1796
1796
|
// such line. The SOURCE LINK stays: it is the only address a reader has for the record on this card
|
|
1797
1797
|
// until the workbook row lands beside it, and dropping both would take a fact away rather than a
|
|
1798
1798
|
// label. With no link there is nothing left to say, so the row does not render at all.
|
|
1799
|
-
const prov = link ? `<div class="prov"><a href="${
|
|
1799
|
+
const prov = hrefAttr(link) ? `<div class="prov"><a href="${hrefAttr(link)}" target="_blank" rel="noopener noreferrer">${esc(link.replace(/^https?:\/\//, '').slice(0, 48))}</a></div>` : '';
|
|
1800
1800
|
// WP-receipts W4 — the code-owned senior-right line (Owner decision 2026-07-05: VERY SIMPLE CLEAR ENGLISH,
|
|
1801
1801
|
// stated qualification, verdict untouched). Verified senior → nothing extra (the W2 receipt line on
|
|
1802
1802
|
// the fetched leg is the proof). Unverified → the open item, plainly, where the finding lives.
|
|
@@ -2551,7 +2551,7 @@ export function renderHtml(parsed, findings = [], coverage = [], opts = {}) {
|
|
|
2551
2551
|
const riskLabel = VERDICT_INFO ? (VERDICT_INFO.tier || TIER_STOP_PILL[i]) : RISK_STOPS[i].toUpperCase();
|
|
2552
2552
|
|
|
2553
2553
|
// Excel/audit download — lives inside the topbar Export popover (portal-report strips the link at serve time for non-staff).
|
|
2554
|
-
const excelBtn2 = !opts.auditFile ? '' : `<a class="util" href="${
|
|
2554
|
+
const excelBtn2 = !hrefAttr(opts.auditFile, { relative: true }) ? '' : `<a class="util" href="${hrefAttr(opts.auditFile, { relative: true })}" download>⬇ Download full audit (Excel)</a>`;
|
|
2555
2555
|
|
|
2556
2556
|
const cardFor = f => matchCard(f, cards);
|
|
2557
2557
|
const recordsByUri = opts.recordsByUri || new Map(); // Instance #6 — the run's _records/ set (publishReport loads it)
|
|
@@ -2854,7 +2854,7 @@ import { officeReasonSentences } from './office-record-links.mjs';
|
|
|
2854
2854
|
function officeRecordCell(uri, regUri, note) {
|
|
2855
2855
|
const l = RECORD_LINKS?.get(String(uri || '').toLowerCase());
|
|
2856
2856
|
if (!l?.label) return regUri(uri);
|
|
2857
|
-
if (l.href) return `<a href="${
|
|
2857
|
+
if (hrefAttr(l.href)) return `<a href="${hrefAttr(l.href)}" target="_blank" rel="noopener noreferrer">${esc(l.label)}</a>`;
|
|
2858
2858
|
return note ? `${esc(l.label)}<span class="reg-nolink">${note}</span>` : esc(l.label);
|
|
2859
2859
|
}
|
|
2860
2860
|
|
package/driver/publish/xlsx.mjs
CHANGED
|
@@ -706,7 +706,13 @@ export function validateAudit(wb, { findings = [], coverage = [], coverageJudgme
|
|
|
706
706
|
for (const name of EXPECT) if (!sheets.includes(name)) v.push(`missing tab "${name}"`);
|
|
707
707
|
|
|
708
708
|
// 3 — banned internal vocabulary anywhere a lawyer can see (tab name, header, or any cell).
|
|
709
|
-
|
|
709
|
+
// A LINK IS A CITATION, NOT VOCABULARY. The sources a finding cites are URLs a lawyer clicks, and `https`
|
|
710
|
+
// inside one matched the list, so every published example workbook printed an advisory about its own
|
|
711
|
+
// links. The address is taken out before the scan; "HTTP 404" written as prose is still found.
|
|
712
|
+
const scan = (txt, where) => {
|
|
713
|
+
const words = txt == null ? null : String(txt).replace(/\bhttps?:\/\/[^\s<>"')\]]+/gi, " ");
|
|
714
|
+
if (words != null && BANNED.test(words)) v.push(`banned vocab "${words.match(BANNED)[0]}" in ${where}`);
|
|
715
|
+
};
|
|
710
716
|
for (const name of sheets) scan(name, `tab name "${name}"`);
|
|
711
717
|
for (const ws of wb.worksheets) {
|
|
712
718
|
ws.eachRow((row, rn) => row.eachCell(cell => {
|
|
@@ -104,7 +104,7 @@ export function queueWatchVerdict({ queueDirs, watched, unitPath, unitError = nu
|
|
|
104
104
|
message: `this deployment resolves NO queue directory at all, so nothing enqueued to it can ever be drained (${unitPath} watches ${w.length})` };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
const norm = (p) => String(p ?? "").
|
|
107
|
+
const norm = (p) => { const s = String(p ?? ""); let e = s.length; while (e > 0 && s.charCodeAt(e - 1) === 47) e--; return s.slice(0, e); }; // trailing slashes, in one pass: `/\/+$/` retried every run of slashes that was not at the end
|
|
108
108
|
const watchedSet = new Set(w.map(norm));
|
|
109
109
|
const unwatched = q.map(norm).filter((d) => !watchedSet.has(d));
|
|
110
110
|
const where = `${q.length} queue dir(s) resolved; ${unitPath} watches ${w.length}`;
|