clearotron 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +64 -37
- package/.nvmrc +1 -1
- package/CONTRIBUTING.md +8 -4
- package/INSTALL.md +36 -14
- package/README.md +18 -22
- package/THIRD-PARTY-NOTICES.md +1 -1
- package/bin/clearotron.mjs +22 -0
- package/bin/example.mjs +10 -5
- package/bin/onboard.mjs +370 -28
- package/bin/start.mjs +220 -12
- package/build-info.json +2 -2
- package/demo/README.md +1 -1
- package/demo/full-country-search/run/_driver/framework.json +1 -1
- package/demo/global-preliminary-search/run/_driver/framework.json +1 -1
- package/demo/knockout-search/run/_driver/framework.json +1 -1
- package/demo/multi-country-focus-search/run/_driver/framework.json +1 -1
- package/docs/E2E.md +5 -5
- package/docs/GLOSSARY.md +85 -0
- package/docs/PORTAL.md +2 -2
- package/docs/README.md +6 -4
- package/docs/SECURITY.md +17 -0
- package/docs/architecture/01-product-overview.md +21 -9
- package/docs/architecture/04-configuration-reference.md +2 -0
- package/docs/architecture/05-config-governance.md +19 -0
- package/docs/architecture/05-customer-profiles.md +2 -2
- package/docs/configuration.md +4 -3
- package/docs/decisions/0006-what-the-public-repository-carries.md +30 -5
- package/driver/CHANGELOG.md +90 -0
- package/driver/README.md +25 -6
- package/driver/common-law-receipts.mjs +1 -1
- package/driver/config-staleness.mjs +1 -1
- package/driver/connotation-search.mjs +1 -1
- package/driver/contract-audit.mjs +5 -1
- package/driver/contract-e3-baseline.json +11 -11
- package/driver/dev-portal.mjs +2 -2
- package/driver/doubt-selection.mjs +1 -1
- package/driver/drainer-identity.mjs +1 -1
- package/driver/driver.config.mjs +1 -1
- package/driver/effort-model.mjs +2 -2
- package/driver/engine/cli-version.mjs +103 -0
- package/driver/engine/probe.mjs +45 -5
- package/driver/enqueue-schema.mjs +8 -8
- package/driver/feedback-store.mjs +1 -1
- package/driver/findings-model.mjs +52 -2
- package/driver/flag-snapshot.mjs +20 -0
- package/driver/framework.mjs +1 -1
- package/driver/gateway.mjs +20 -3
- package/driver/outbox-backoff.mjs +1 -1
- package/driver/package.json +3 -3
- package/driver/party-facts.mjs +2 -2
- package/driver/phase0.mjs +1 -1
- package/driver/pipeline.mjs +211 -106
- package/driver/plain-register.mjs +134 -0
- package/driver/portal-access.mjs +1 -1
- package/driver/portal-config-view.mjs +70 -2
- package/driver/portal-families.mjs +1 -1
- package/driver/portal-local-auth.mjs +5 -1
- package/driver/portal-report.mjs +1 -1
- package/driver/portal-service.mjs +79 -9
- package/driver/portal-upstream.mjs +2 -2
- package/driver/predelivery-lint.mjs +151 -2
- package/driver/profile-page.html +10 -2
- package/driver/profile-service.mjs +4 -4
- package/driver/profiles/README.md +3 -3
- package/driver/profiles.mjs +144 -12
- package/driver/publish/index.mjs +2 -2
- package/driver/publish/knockout.mjs +7 -1
- package/driver/publish/render-knockout.mjs +453 -118
- package/driver/publish/render.mjs +111 -16
- package/driver/reference-strip-signatures.mjs +20 -0
- package/driver/search-policy.mjs +3 -3
- package/driver/skills/blind-frame/SKILL.md +1 -1
- package/driver/skills/knockout-assess/SKILL.md +84 -0
- package/driver/skills/matter-frame/SKILL.md +6 -6
- package/driver/skills/matter-frame/watchlist-reference.md +2 -2
- package/driver/skills/prelim-common-law/SKILL.md +1 -1
- package/driver/skills/prelim-search/SKILL.md +1 -1
- package/driver/skills/prelim-search/report-prose.md +82 -0
- package/driver/skills/prelim-search/risk-framework-demo.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework-triage.manifest.json +1 -1
- package/driver/skills/prelim-search/risk-framework.manifest.json +1 -1
- package/driver/skills/prelim-search/synthesis-rules.md +1 -1
- package/driver/skills/prelim-variants/SKILL.md +3 -3
- package/driver/stage-context.mjs +13 -0
- package/driver/stages.mjs +54 -7
- package/driver/suite-census.json +220 -58
- package/driver/systemd/clearotron-worker.service +3 -3
- package/driver/tokens.mjs +1 -1
- package/driver/unit-inventory.mjs +214 -27
- package/driver/verify.mjs +1 -1
- package/driver/whatif-memo-run.mjs +26 -4
- package/driver/whatif-memo.mjs +19 -1
- package/examples/README.md +2 -2
- package/examples/grants.example.json +6 -7
- package/mcp-server/CHANGELOG.md +4 -0
- package/mcp-server/lib/scrub.mjs +1 -1
- package/mcp-server/lib/whatif.mjs +2 -2
- package/mcp-server/package.json +1 -1
- package/mcp-server/packs/ops/EXAMPLES.md +2 -2
- package/mcp-server/server.mjs +1 -1
- package/package.json +31 -19
- package/portal-ui/dist/assets/{index-Ct3iQtZ2.css → index-1ziUJX1E.css} +43 -2
- package/portal-ui/dist/assets/{index-BUGgiYtF.js → index-KFAHMgdT.js} +289 -73
- package/portal-ui/dist/index.html +2 -2
- package/portal-ui/package.json +1 -1
- package/providers/_shared/term-shape.mjs +1 -1
- package/providers/oauth-mcp-bridge/CHANGELOG.md +4 -0
- package/providers/oauth-mcp-bridge/package.json +1 -1
- package/providers/uspto-local/src/index-store.js +2 -2
- package/scripts/added-reference-check.mjs +45 -89
- package/scripts/citation-line-check.mjs +121 -2
- package/scripts/e2e.mjs +2 -2
- package/scripts/env-classify.mjs +67 -3
- package/scripts/generated-files-are-current.mjs +125 -0
- package/scripts/live-surface-check.mjs +38 -1
- package/scripts/mint-names-in-force.mjs +4 -2
- package/scripts/mint-public-residue.mjs +74 -0
- package/scripts/mint-reference-strip-backlog.mjs +12 -1
- package/scripts/mint-suite-census.mjs +5 -2
- package/scripts/nightly-notice.mjs +0 -2
- package/scripts/preinstall-node-check.mjs +24 -0
- package/scripts/render-check.mjs +13 -2
- package/scripts/report-frame-check.mjs +8 -1
- package/scripts/report-screenshot.mjs +6 -3
- package/scripts/revisit-render-check.mjs +6 -3
- package/scripts/score.mjs +1 -1
- package/scripts/strip-tracker-citations.mjs +38 -7
- package/scripts/test-full.mjs +226 -0
- package/scripts/test-run.mjs +16 -0
- package/shared/README.md +2 -1
- package/shared/anon-overlay.mjs +3 -3
- package/shared/browser-temp-root.mjs +142 -0
- package/shared/checkout-move.mjs +1 -1
- package/shared/client-door.mjs +4 -3
- package/shared/customer-store.mjs +1 -1
- package/shared/identifier-scan.mjs +1 -1
- package/shared/install-auth.mjs +35 -0
- package/shared/invocation.mjs +33 -1
- package/shared/listen.mjs +55 -3
- package/shared/names-in-force.mjs +6 -1
- package/shared/node-floor.mjs +97 -0
- package/shared/os-advice.mjs +91 -0
- package/shared/reap-on-exit.mjs +42 -0
- package/shared/reference-guard-classes.mjs +351 -0
- package/shared/staff-domain.mjs +154 -0
- package/shared/store-in-repo.mjs +23 -2
- package/shared/suite-census.mjs +31 -3
- package/shared/withheld-paths-access.mjs +36 -18
- package/skills/clearotron-ops/SKILL.md +1 -1
- package/driver/recipes/README.md +0 -49
- package/driver/skills/prelim-search/risk-framework-aurora.manifest.json +0 -19
- package/driver/skills/prelim-search/risk-framework-aurora.md +0 -77
- package/driver/skills/prelim-search/risk-framework-zephyr.manifest.json +0 -14
- package/driver/skills/prelim-search/risk-framework-zephyr.md +0 -33
- package/driver/skills/prelim-search/worked-examples-aurora.md +0 -10
- package/driver/skills/prelim-search/worked-examples-zephyr.md +0 -10
- package/scripts/ai-page-render-check.mjs +0 -532
- package/scripts/clearances-render-check.mjs +0 -480
- package/scripts/composer-render-check.mjs +0 -1027
- package/scripts/home-render-check.mjs +0 -526
- package/scripts/portal-lifecycle-check.mjs +0 -639
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
// report-derived cover text alone, because a gate that simply stops running is not a gate that passes.
|
|
24
24
|
|
|
25
25
|
import { REGION_NAMES } from "./publish/regions.mjs";
|
|
26
|
+
import { PLAIN_FORMS, SENTENCE_WORD_LIMIT, termMatcher } from "./plain-register.mjs"; // the pinned rule, not a fourth copy
|
|
26
27
|
import { canonicalJurisdictionCode } from "./jurisdiction-codes.mjs"; // one spelling of a territory code
|
|
27
28
|
import { searchedCovers } from "./frame-diff-model.mjs"; // one copy of the EU-reach rule
|
|
28
29
|
import { partyFactSources, partyFactViolations, partyFactMessage, canJudgePartyFacts } from "./party-facts.mjs"; //
|
|
@@ -31,6 +32,7 @@ import { writeUpViolations, writeUpMessage } from "./narrative-write-ups.mjs";
|
|
|
31
32
|
import { findRegistryArithmeticIssues, findRegistryViolations, splitBlocks } from "./registry-fidelity.mjs";
|
|
32
33
|
import { CLIENT_TIER_BY_COMPOSITE, joinFindingToBlock, parseBlockOrd, worstLiveBand, NO_RATED_CONFLICTS, deriveActionConditions, isUnconditionalProceed, verdictStance, joinAskToAnswer, projectAssessmentField, POSITION_REQUIRED_DISPOSITIONS, OFF_FIELD_GROUNDS, FINDINGS_SCHEMA_VERSION, netChainMarkers, STATEMENT_CLAUSE_MAX } from "./findings-model.mjs";
|
|
33
34
|
import { normalizeBand } from "./framework.mjs";
|
|
35
|
+
import { knockoutNoteView, REQUEST_NOTE_WORDS, REQUEST_SUBJECT_WORDS } from "./findings-model.mjs"; // one reader for where a note prints
|
|
34
36
|
|
|
35
37
|
// V4-3: diacritics FOLD (NFD strip) instead of being deleted — "Televisión" must normalize to
|
|
36
38
|
// "television" (deletion made it "televisin", so a diacritic mention never matched its introduction).
|
|
@@ -60,7 +62,7 @@ const REF_ALLOWLIST = new Set([
|
|
|
60
62
|
// marketplace-platform vocabulary (the common-law sweep's standing surfaces, not entities to introduce).
|
|
61
63
|
// LOAD-BEARING — do NOT delete to "de-gaming" the lint: the per-run profile-platform union (runLint
|
|
62
64
|
// `extraVocab`) is DOMAIN-derived, so a single-label domain yields the bare brand ("walmart.com" →
|
|
63
|
-
// "walmart", so
|
|
65
|
+
// "walmart", so a drinks account's Walmart/Target/GNC are already covered with no static) but a MULTI-part domain
|
|
64
66
|
// does NOT ("store.steampowered.com" → "store steampowered", never "steam"), and "xbox"/"playstation"/
|
|
65
67
|
// "nintendo" appear in NO platform domain at all. Removing these would fire false orphan flags on every
|
|
66
68
|
// gaming report. The structural de-gaming fix is per-customer marketplace ALIASES in the bundle data
|
|
@@ -2352,6 +2354,145 @@ const knockoutSurfaces = (findings) => {
|
|
|
2352
2354
|
return { report: report.filter(Boolean).join("\n\n"), working: working.filter(Boolean).join("\n\n") };
|
|
2353
2355
|
};
|
|
2354
2356
|
|
|
2357
|
+
// ── PLAIN LANGUAGE ON WHAT A READER SEES FIRST (tracker issue 333) ─────────────────────────────────
|
|
2358
|
+
//
|
|
2359
|
+
// The report goes to a lawyer who layers advice on top, and that lawyer's client reads the same page.
|
|
2360
|
+
// The band, the summary, the basis line and the one-liners are the whole product for the second reader,
|
|
2361
|
+
// and they were the hardest lines on it: "a prior owner is likely to prevail on the marks-and-goods
|
|
2362
|
+
// comparison", "subsisting European rights", "the confusion comparison meets on every limb".
|
|
2363
|
+
//
|
|
2364
|
+
// THE PARTITION IS DEFAULT-VISIBLE vs FOLDED, WHICH IS NOT THE report/working SPLIT ABOVE. Since the
|
|
2365
|
+
// page folds each card's argument (tracker issue 331 A.3), a finding's `basis` is now behind a click,
|
|
2366
|
+
// and 333 rule 2 allows the lawyer's vocabulary there where a plain word would lose precision. The same
|
|
2367
|
+
// is true of the long `assessment`. So this check reads exactly the fields the renderer DRAWS without
|
|
2368
|
+
// a click, and reads nothing else — a check whose population is "the report" would flag the very
|
|
2369
|
+
// paragraphs the design deliberately moved out of the reader's way.
|
|
2370
|
+
//
|
|
2371
|
+
// THE WORD LIST IS A TRIPWIRE, NOT THE RULE. 333 rejects a banned-word list as the mechanism, and it is
|
|
2372
|
+
// right to: the rule is "does the reader already own this word?", which no list bounds. The list is what
|
|
2373
|
+
// a machine can check, seeded from the terms actually found on the two delivered pages. The teaching is
|
|
2374
|
+
// in the skill, where a writer reads it.
|
|
2375
|
+
//
|
|
2376
|
+
// FLAG-ONLY, AND NEVER PROJECTED. 333: "A hit is a rewrite of that line, never a disclosure and never a
|
|
2377
|
+
// run failure." These carry surface "findings", which runKnockoutLint's caller does not project onto
|
|
2378
|
+
// the cover note or the workbook — so a flag reaches whoever is fixing the run and nobody else.
|
|
2379
|
+
// ── THE RULE IS NOT WRITTEN HERE ANY MORE ───────────────────────────────────────────────────────────
|
|
2380
|
+
//
|
|
2381
|
+
// This file carried its own vocabulary list and its own sentence limit, imported nothing, and was the
|
|
2382
|
+
// copy that actually ran on every knockout delivery — a fourth copy of a rule the other three are pinned
|
|
2383
|
+
// to each other by a test. It had already drifted: it flagged `senior right`, `limb` and `lane`, which
|
|
2384
|
+
// appear in no pinned copy, so a seat was corrected against a rule it was never taught; and it did not
|
|
2385
|
+
// flag `instructed`, which the doctrine does teach. It also carried no plain form at all, naming the
|
|
2386
|
+
// term and deferring the replacement to a skill — and the replacement is the half the doctrine calls
|
|
2387
|
+
// load-bearing.
|
|
2388
|
+
//
|
|
2389
|
+
// It reads the pinned source now. The three untaught terms go with it: teaching a term is a doctrine
|
|
2390
|
+
// change, and `PLAIN_FORMS` is pinned to both documents by
|
|
2391
|
+
// `the-two-register-rule-says-the-same-thing-to-both-products.test.mjs`, so a term added here without
|
|
2392
|
+
// its worked swap in both would red that test rather than silently widening what a seat is corrected on.
|
|
2393
|
+
//
|
|
2394
|
+
// WHAT WAS NOT LOST IN THE CONSOLIDATION. The patterns here handled inflections and the pinned source
|
|
2395
|
+
// did not — `proprietors`, `prevailing`. Reading terms from the weaker matcher would have narrowed the
|
|
2396
|
+
// live check while looking like a tidy-up, so the inflections moved INTO the pinned source as
|
|
2397
|
+
// `termMatcher`, and both sides use it.
|
|
2398
|
+
|
|
2399
|
+
/**
|
|
2400
|
+
* The fields a reader of the knockout meets before opening anything, named one by one rather than
|
|
2401
|
+
* derived, so adding a field to the page is a deliberate addition here too.
|
|
2402
|
+
*/
|
|
2403
|
+
function knockoutVisibleProse(findings) {
|
|
2404
|
+
const out = [];
|
|
2405
|
+
const add = (where, v) => { const t = String(v ?? "").trim(); if (t) out.push({ where, text: t }); };
|
|
2406
|
+
add("the batch summary", findings?.batch?.executiveSummary);
|
|
2407
|
+
for (const c of findings?.batch?.standardCaveats ?? []) add("a standing caveat", c);
|
|
2408
|
+
for (const m of findings?.marks ?? []) {
|
|
2409
|
+
const n = String(m?.name ?? "a mark");
|
|
2410
|
+
add(`${n}'s basis line`, m?.basis);
|
|
2411
|
+
for (const f of m?.factors ?? []) add(`${n}'s "why this band" list`, f);
|
|
2412
|
+
for (const f of m?.counterFactors ?? []) add(`${n}'s "why not the next band" list`, f);
|
|
2413
|
+
add(`${n}'s mitigation line`, m?.mitigation);
|
|
2414
|
+
for (const p of m?.purpleNotes ?? []) add(`${n}'s note to the reviewing lawyer`, p?.text ?? p);
|
|
2415
|
+
for (const r of m?.registerReads ?? []) add(`${n}'s read of a filing`, r?.read);
|
|
2416
|
+
// The finding's ONE sentence. Its `basis` is folded and is deliberately not read here.
|
|
2417
|
+
for (const f of m?.findings ?? []) add(`${n} conflict ${f?.ordinal ?? ""}`.trim(), f?.net);
|
|
2418
|
+
}
|
|
2419
|
+
return out;
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/**
|
|
2423
|
+
* plainLanguageChecks — two flags over the default-visible prose: the lawyer's vocabulary, and a
|
|
2424
|
+
* sentence carrying more than one idea. Each names the field, so a hit is a line somebody can rewrite
|
|
2425
|
+
* rather than a score.
|
|
2426
|
+
*/
|
|
2427
|
+
/**
|
|
2428
|
+
* The checks that are internal BY DESIGN, named once so the rule lives beside the thing it governs.
|
|
2429
|
+
*
|
|
2430
|
+
* A hit on any of these is a line for whoever is fixing the run to rewrite. It is never something a
|
|
2431
|
+
* client is shown and it never fails a run — see the header above and the doctrine these implement.
|
|
2432
|
+
* Every other check on this lane is relabelled `report` as it is pushed, one line at a time, and these
|
|
2433
|
+
* three sit in the middle of that list: making them projectable is a one-line edit that looks exactly
|
|
2434
|
+
* like its neighbours and that no test would have caught, because the arms enumerating failure ids
|
|
2435
|
+
* filter these three out before asserting — correctly, for what those arms check.
|
|
2436
|
+
*/
|
|
2437
|
+
export const NEVER_PROJECTED = Object.freeze(new Set([
|
|
2438
|
+
"reviewer-note-subject", "plain-language-vocabulary", "plain-language-sentence-length",
|
|
2439
|
+
]));
|
|
2440
|
+
|
|
2441
|
+
/** The surface a check that must never reach a reader carries. Not `report`, and not a caller's choice. */
|
|
2442
|
+
export const INTERNAL_SURFACE = "findings";
|
|
2443
|
+
|
|
2444
|
+
/**
|
|
2445
|
+
* THE SURFACE IS NOT THE CALLER'S TO CHOOSE. It used to be a parameter with a default, so the property
|
|
2446
|
+
* held because every caller happened to pass nothing — a run that satisfies a rule rather than a rule.
|
|
2447
|
+
* A future caller passing `report` would have put a plain-language hit on the page a client reads, with
|
|
2448
|
+
* nothing going red.
|
|
2449
|
+
*/
|
|
2450
|
+
export function plainLanguageChecks({ findings } = {}) {
|
|
2451
|
+
const surface = INTERNAL_SURFACE;
|
|
2452
|
+
const fields = knockoutVisibleProse(findings);
|
|
2453
|
+
const vocab = [];
|
|
2454
|
+
const longSentences = [];
|
|
2455
|
+
for (const { where, text } of fields) {
|
|
2456
|
+
for (const [term] of PLAIN_FORMS) if (termMatcher(term).test(text)) vocab.push(`${where}: "${term}"`);
|
|
2457
|
+
for (const sentence of text.split(/(?<=[.!?])\s+|\n+/)) {
|
|
2458
|
+
const n = sentence.trim().split(/\s+/).filter(Boolean).length;
|
|
2459
|
+
if (n > SENTENCE_WORD_LIMIT) longSentences.push(`${where}: ${n} words`);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
// ── A NOTE THAT WILL PRINT IN THE PLACE ITS WRITER DID NOT MEAN ────────────────────────────────────
|
|
2463
|
+
//
|
|
2464
|
+
// The page files a reviewer's note by what it TALKS ABOUT: name the request and it prints at the top,
|
|
2465
|
+
// above the conflicts; otherwise it prints under that name's cards. That is what lets an archived run
|
|
2466
|
+
// put its mis-scoping flag where the owner asked for it without a new field.
|
|
2467
|
+
//
|
|
2468
|
+
// THE COST OF THAT CHOICE IS EXACTLY THIS FLAG. A note plainly about the asking — the client's stated
|
|
2469
|
+
// industry, its own prior use, whether the goods are the right ones — that never says "the request"
|
|
2470
|
+
// sorts as a note about the NAME and prints at the bottom, which is the defect the move exists to fix.
|
|
2471
|
+
// Nothing about the page would look wrong; the line would simply be in the last place read.
|
|
2472
|
+
//
|
|
2473
|
+
// So the reviewer says so, to the one person who can fix it, in the one place a fix belongs: the note.
|
|
2474
|
+
// The page never guesses. Adding a clause naming the request is the whole remedy.
|
|
2475
|
+
const misfiled = [];
|
|
2476
|
+
for (const m of findings?.marks ?? []) {
|
|
2477
|
+
for (const raw of m?.purpleNotes ?? []) {
|
|
2478
|
+
const v = knockoutNoteView(raw);
|
|
2479
|
+
if (!v.text || v.about) continue; // the rater said which; nothing is inferred
|
|
2480
|
+
if (REQUEST_NOTE_WORDS.test(v.text)) continue; // it names the request and will print at the top
|
|
2481
|
+
if (!REQUEST_SUBJECT_WORDS.test(v.text)) continue;
|
|
2482
|
+
misfiled.push(`${m?.name ?? "a mark"}: "${v.text.trim().replace(/\s+/g, " ").slice(0, 70)}…"`);
|
|
2483
|
+
}
|
|
2484
|
+
}
|
|
2485
|
+
const say = (hits) => `${hits.slice(0, 5).join("; ")}${hits.length > 5 ? `; +${hits.length - 5} more` : ""}`;
|
|
2486
|
+
return [
|
|
2487
|
+
check("reviewer-note-subject", "voice", surface, misfiled.length === 0,
|
|
2488
|
+
misfiled.length ? `a note about what was asked that never names the request — it will print under this name's conflicts rather than at the top of the page, where a question about the request belongs. Name the request in the note: ${say(misfiled)}` : ""),
|
|
2489
|
+
check("plain-language-vocabulary", "voice", surface, vocab.length === 0,
|
|
2490
|
+
vocab.length ? `the lawyer's vocabulary on lines a reader meets before opening anything — rewrite the line in the words the reader already owns (the skill carries the swaps): ${say(vocab)}` : ""),
|
|
2491
|
+
check("plain-language-sentence-length", "voice", surface, longSentences.length === 0,
|
|
2492
|
+
longSentences.length ? `a default-visible sentence carrying more than one idea (over ${SENTENCE_WORD_LIMIT} words) — split it, conclusion first: ${say(longSentences)}` : ""),
|
|
2493
|
+
];
|
|
2494
|
+
}
|
|
2495
|
+
|
|
2355
2496
|
/**
|
|
2356
2497
|
* KNOCKOUT_ABSENT_BY_DESIGN — the clearance checks this lane does NOT run, each with the reason its
|
|
2357
2498
|
* input does not exist here. This is the third state (/'s rule, in the lint's own vocabulary):
|
|
@@ -2412,7 +2553,12 @@ export function runKnockoutLint({ findings }) {
|
|
|
2412
2553
|
// "all", because on the clearance lane it asserts agreement BETWEEN surfaces. This lane has one
|
|
2413
2554
|
// surface, so "report" is also the truer label here. The filter is deliberately not `!== "findings"`:
|
|
2414
2555
|
// that would default a check added next year to PROJECTING onto a surface a client principal reads.
|
|
2415
|
-
|
|
2556
|
+
// AND IT REFUSES TO PROMOTE THE THREE THAT ARE INTERNAL BY DESIGN. Pinning the surface inside
|
|
2557
|
+
// `plainLanguageChecks` stops a caller ASKING for a projectable one; it does not stop this line
|
|
2558
|
+
// relabelling the answer afterwards, which is the same edit one step later and looks like every other
|
|
2559
|
+
// line around it. So the rule is enforced where the surface is actually chosen. Anything added to
|
|
2560
|
+
// NEVER_PROJECTED is covered here by construction rather than by whoever adds it remembering.
|
|
2561
|
+
const onReport = (list) => list.map((c) => (NEVER_PROJECTED.has(c.id) ? c : { ...c, surface: "report" }));
|
|
2416
2562
|
checks.push(...permissionProseChecks({ text: report, surface: "report", idSuffix: ":knockout", structural: true, cards: false }));
|
|
2417
2563
|
checks.push(...onReport(scopeNumberProseChecks({ reportMd: report })));
|
|
2418
2564
|
checks.push(...onReport(countingChecks({ report })));
|
|
@@ -2423,6 +2569,9 @@ export function runKnockoutLint({ findings }) {
|
|
|
2423
2569
|
// (publish/report-registry.mjs re-renders archived findings without re-running the merged validator)
|
|
2424
2570
|
// this scan is the only permission-prose coverage there is.
|
|
2425
2571
|
if (working.trim()) checks.push(...permissionProseChecks({ text: working, surface: "findings", idSuffix: ":knockout-working", structural: true, cards: false }));
|
|
2572
|
+
// tracker issue 333 — the plain-language reviewer, over the fields a reader meets before opening a
|
|
2573
|
+
// fold. Internal by surface, so a hit reaches whoever is fixing the run and never a delivery surface.
|
|
2574
|
+
checks.push(...plainLanguageChecks({ findings }));
|
|
2426
2575
|
const failures = checks.filter((c) => !c.pass);
|
|
2427
2576
|
return { checks, failures, notApplicable: KNOCKOUT_ABSENT_BY_DESIGN };
|
|
2428
2577
|
}
|
package/driver/profile-page.html
CHANGED
|
@@ -35,7 +35,7 @@ ul.roster{list-style:none;margin:0;padding:0}
|
|
|
35
35
|
ul.roster li{padding:8px 10px;border-radius:8px;cursor:pointer;font-size:14px;border:1px solid transparent}
|
|
36
36
|
ul.roster li:hover{background:var(--bg)}
|
|
37
37
|
ul.roster li.sel{background:var(--bg);border-color:var(--line);font-weight:700}
|
|
38
|
-
/* the sub-line truncates (2-line clamp) — a long industry string (
|
|
38
|
+
/* the sub-line truncates (2-line clamp) — a long industry string (one profile's is ~110 chars) must never
|
|
39
39
|
stretch the 240px roster column into an overflowing wall of text */
|
|
40
40
|
ul.roster li .ind{display:-webkit-box;-webkit-line-clamp:2;line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;font-size:11.5px;color:var(--muted);font-weight:400}
|
|
41
41
|
ul.roster li .ind code.slug{font-size:10.5px;letter-spacing:.02em}
|
|
@@ -136,7 +136,15 @@ const csv = (s) => String(s||"").split(",").map(x=>x.trim()).filter(Boolean);
|
|
|
136
136
|
const fwName = (path) => { const m = String(path||"").match(/risk-framework-([a-z0-9-]+)\.md/i); return m ? (m[1].charAt(0).toUpperCase() + m[1].slice(1) + " framework") : "Generic default framework"; };
|
|
137
137
|
// doc 50 — the band ladder preview, from the manifest the service serves (tones map to the page palette).
|
|
138
138
|
const TONE_BG = {severe:"#b3261e", high:"#c75b13", medium:"#9a8400", low:"#4e7a31", minimal:"#2f55a4"};
|
|
139
|
-
|
|
139
|
+
// LABELLED, for the same reason as the portal's copy of this row. Four coloured pills with nothing
|
|
140
|
+
// saying what they are, sitting under a heading that reads "Risk framework in force", were read by an
|
|
141
|
+
// outside user as four frameworks: "how do I have FOUR risk frameworks live at the same time?" He had
|
|
142
|
+
// one. The label names the framework's ownership of the scale — "its ratings" — because the sentence
|
|
143
|
+
// immediately above names the framework and the only ambiguity is whether these belong to it.
|
|
144
|
+
//
|
|
145
|
+
// The two editors say the same words on purpose. This one is the staff page and the portal has its own;
|
|
146
|
+
// a reader who sees both should not have to work out whether they are describing the same thing.
|
|
147
|
+
const bandLadder = (m) => m && m.bands ? `<div style="font-size:12px;color:var(--slate);margin:10px 0 0">Its ratings, strongest concern first:</div><div style="margin:6px 0 2px">${m.bands.map(b=>`<span style="display:inline-block;margin:0 6px 4px 0;padding:2px 9px;border-radius:999px;background:${TONE_BG[b.tone]||"#666"};color:#fff;font-size:11px;font-weight:700">${esc(b.label)}</span>`).join("")}</div>` : "";
|
|
140
148
|
// doc 50 — "What the bands mean": deck-native prose the service lifts from the framework .md at view time
|
|
141
149
|
// (display-only; omitted whenever extraction missed). The decks are Privileged & Confidential, so the prose
|
|
142
150
|
// is wrapped in data-anon="mark" — the demo privacy blur must cover it.
|
|
@@ -75,7 +75,7 @@ function manifestFor(fwPath) {
|
|
|
75
75
|
// shipped decks, so a deck edit that breaks extraction fails CI instead of silently blanking the box.
|
|
76
76
|
const stripMd = (s) => String(s ?? "").replace(/\*+/g, "").trim();
|
|
77
77
|
|
|
78
|
-
// matrix-shaped decks
|
|
78
|
+
// matrix-shaped decks: the deck's "Band meanings" table is the only table whose FIRST cell is
|
|
79
79
|
// exactly the band label (the matrix table suffixes its labels with the deck's internal indices, e.g.
|
|
80
80
|
// "**Very High** *(5)*") — take that row's cells as { band, meaning, response }.
|
|
81
81
|
function matrixBandMeanings(deck, manifest) {
|
|
@@ -97,7 +97,7 @@ function matrixBandMeanings(deck, manifest) {
|
|
|
97
97
|
return out;
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
// bands-shaped decks (house
|
|
100
|
+
// bands-shaped decks (the house default among them): each band lives under its own heading ("## VERY HIGH RISK") — find
|
|
101
101
|
// the section whose heading STARTS WITH the band label ("High" must never steal "VERY HIGH RISK") and lift
|
|
102
102
|
// EVERY rung the band states, in the deck's own order.
|
|
103
103
|
//
|
|
@@ -172,8 +172,8 @@ function bandMeaningsFor(fwPath, manifest) {
|
|
|
172
172
|
|
|
173
173
|
// CODE-OWNED fields — the framework SELECTION. The editor page has no form fields for these, so a save
|
|
174
174
|
// assembled from the form omits them, and defaultWriteProfile rewrites the whole file: the 2026-07-04/05 UI
|
|
175
|
-
// saves (587324ab, cea0ca2f, c59030a1) silently stripped frameworkPath/workedExamplesPath from
|
|
176
|
-
//
|
|
175
|
+
// saves (587324ab, cea0ca2f, c59030a1) silently stripped frameworkPath/workedExamplesPath from two
|
|
176
|
+
// profiles, flipping both customers to the house-default framework. The on-disk value ALWAYS wins here — a
|
|
177
177
|
// client body can neither drop, change, nor introduce a framework selection (git + review gated, per the
|
|
178
178
|
// header rules). Applied to validate AND save so the dry run judges exactly what a save would write.
|
|
179
179
|
// Search-depth spine: allowedRecipes/jxPolicy/runCaps have NO form fields on the editor page — without
|
|
@@ -21,8 +21,8 @@ With no key, `resolveProfile(job)` matches `job.forwarderDomain` against each
|
|
|
21
21
|
profile's `matchDomains[]` (exact host or dot-suffix — the original `selectCustomer` semantics),
|
|
22
22
|
else `generic.json`. The applicant named in the request (`job.customer`) NEVER selects a profile —
|
|
23
23
|
it only activates `selfExclusionOwners[]` when it matches the profile's `name`
|
|
24
|
-
(`applicantMatchesProfile`: word-boundary containment, so
|
|
25
|
-
|
|
24
|
+
(`applicantMatchesProfile`: word-boundary containment, so a search forwarded from a customer's own
|
|
25
|
+
domain but asking about a third-party applicant never buries that customer's own conflicts as
|
|
26
26
|
"own rights").
|
|
27
27
|
|
|
28
28
|
Resolution happens ONCE at run start and is frozen into the run's sidecar
|
|
@@ -52,7 +52,7 @@ consumes it; a unit test greps each symbol, so a field with no live consumer fai
|
|
|
52
52
|
| `selfExclusionOwners[]` | structural | the customer's own/affiliate names — own-rights classification seed, applicant-gated as above |
|
|
53
53
|
| `delivery` | delivery | `{ email: "summary", privileged: bool }` — the "Privileged & Confidential" header. Two further optional sub-keys are accepted: `style` (a prose string, guarded by the same anti-rule check as a context pack, dictated into the `report-overview` and `report-card` stages as presentation tone and never to `synthesis`, the rating stage) and `template` (a report-template name; `"standard"` is the only one that exists, and it is the default). **Absent ⇒ neutral default** (`{email:"summary"}` — deliberately SILENT on `privileged`, which is three-state on every surface that reads it (`confPosture`): `true` extends the marking to "Attorney Work Product", `false` is a deliberate OFF, and absent is no opinion, which gets the plain "Privileged & Confidential" every legal deliverable carries. Saying `false` in the neutral overlay read as an instruction to strip the marking, and a Generic-default clearance shipped with no line at all). `email` no longer selects anything: every run's mail is a COVER NOTE pointing at the one report. The old `"table"` value — a full review table inlined into the mail body — is **retired**; it is still accepted at load so stored profiles keep validating, and folded to `"summary"` by `normalizeDelivery`. A customer who wants their own house format gets it drafted by the assistant from the run's `report-data.json`, where a person reads it before it goes |
|
|
54
54
|
| `riskAppetite` | context | a PROSE-POSTURE string (optional) that flavours **emphasis + recommended follow-up** in delivery curation — the two stages that are dictated it, `report-overview` and `report-card` — **never the Level/Composite**. A load-time anti-threshold guard rejects numeric/threshold phrasing (`>50%`, `Level C or above`, `threshold`); the "never decides" invariance itself is gated by review |
|
|
55
|
-
| `marketplaceDensity` | structural | `"sparse"` (default) \| `"dense"` — selects the per-profile grid cell budget so a byte-heavy marketplace's verbatim stdout fits the worker output channel (sparse ⇒ 98-cell budget; dense ⇒ 16). Dense fits long retail listings (
|
|
55
|
+
| `marketplaceDensity` | structural | `"sparse"` (default) \| `"dense"` — selects the per-profile grid cell budget so a byte-heavy marketplace's verbatim stdout fits the worker output channel (sparse ⇒ 98-cell budget; dense ⇒ 16). Dense fits long retail listings (beverages and supplements); gaming stores stay sparse |
|
|
56
56
|
| `frameworkPath` | rating authority | optional path to the customer's OWN risk framework (`skills/prelim-search/<file>.md`, path-escape-blocked). **The framework in force RATES the matter** — the customer's own if on file, else the Generic default `risk-framework.md`; nothing in between. Each framework is a prose deck (the client's own rubric, reasoned WITH) plus a `.manifest.json` sidecar carrying its band vocabulary (`test/framework-lint.test.mjs` guards the pair). Git + legal-team gated; the config UI shows which is in force, read-only |
|
|
57
57
|
| `workedExamplesPath` | context | optional path to a per-customer worked-examples set (`skills/prelim-search/<file>.md`). The analysis DEPTH TARGET in `synthesis`, calibrated under that customer's framework; **absent ⇒ the Generic default `worked-examples.md`** |
|
|
58
58
|
| `defaultProduct` | entitlement | which of the four searches runs when a request names none (`search-policy.mjs`). May be left unset, and that is not a gap — a clearance that names no product is then named by its own resolved territories |
|
package/driver/profiles.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// One git-owned JSON file per customer under profiles/ — hand-authored, PR-reviewed, onboarded one
|
|
6
6
|
// customer at a time. generic.json is the universal fallback and MUST exist;
|
|
7
|
-
//
|
|
7
|
+
// one test-suite profile reproduces today's behavior exactly (the regression anchor). Every field shipped
|
|
8
8
|
// here has working machinery behind it — parked knobs live in profiles/README.md, not in the files.
|
|
9
9
|
//
|
|
10
10
|
// Resolution is FORWARDER-DOMAIN ONLY: the profile describes WHO ASKS US (the
|
|
@@ -143,10 +143,10 @@ export const SAFE_GRID_CELLS = 98;
|
|
|
143
143
|
// A DENSE marketplace profile (long retail URLs + many listings per cell — e.g. beverages/supplements on
|
|
144
144
|
// Amazon/GNC/iHerb) makes each grid cell ~5-10x heavier in OUTPUT BYTES than a sparse gaming-store cell, so
|
|
145
145
|
// the cell-count budget above (calibrated on sparse stores) overflows the worker's output channel and the
|
|
146
|
-
// verbatim stdout transcription truncates mid-JSON (
|
|
146
|
+
// verbatim stdout transcription truncates mid-JSON (measured on a dense beverages profile, 2026-06-14: ~21 dense cells ≈ 20KB cut
|
|
147
147
|
// the ledger unparseable). A dense profile gets a much smaller cell budget so each grid call's stdout stays
|
|
148
148
|
// well under that ceiling (a 7-platform dense profile ⇒ floor 8 ⇒ batchSize 2 ⇒ ≤16 cells/call). The
|
|
149
|
-
// gaming
|
|
149
|
+
// gaming budget is unchanged (default density = sparse).
|
|
150
150
|
export const DENSE_GRID_CELLS = 16;
|
|
151
151
|
|
|
152
152
|
// The two values a profile may hold, as a LIST rather than as a phrase repeated at each site.
|
|
@@ -238,6 +238,9 @@ export const KNOWN_PROFILE_KEYS = [
|
|
|
238
238
|
// queue was a cost bomb the run-slot cap doesn't stop. Visible, git-tracked profile config (never a
|
|
239
239
|
// hidden env var); enforced at the runner's admission chokepoint for BOTH doors (email + portal).
|
|
240
240
|
"runCaps",
|
|
241
|
+
// AN ACCOUNT THE SUITE USES AND NO INSTALL OFFERS. `testFixture: true` keeps a profile in the tree,
|
|
242
|
+
// under its own name, and out of every roster this module resolves — see loadProfiles.
|
|
243
|
+
"testFixture",
|
|
241
244
|
// THIS RECORD IS FICTION. `demoData: true` marks a profile as demo data, and a
|
|
242
245
|
// real clearance refuses to start under it — at the runner's admission wall, so no door can miss it.
|
|
243
246
|
//
|
|
@@ -276,6 +279,12 @@ export const CUSTOMER_ONLY_KEYS = [
|
|
|
276
279
|
// may trigger (allowedRecipes) nor re-declare its jx deepening policy — both are the same
|
|
277
280
|
// rating-authority-adjacent discipline as frameworkPath, one notch out.
|
|
278
281
|
"allowedRecipes", "jxPolicy",
|
|
282
|
+
// WHETHER AN ACCOUNT IS OFFERED AT ALL is the account's own fact, and a project overlay must not touch
|
|
283
|
+
// it in either direction. Un-marking would put a test account into a customer's picker through an
|
|
284
|
+
// overlay nobody reviewed as identity; marking would hide a real account from its own owner. Same
|
|
285
|
+
// reasoning as `demoData` one notch out — that one decides whether fiction may spend money, this one
|
|
286
|
+
// decides whether it is visible, and neither is a project's to decide.
|
|
287
|
+
"testFixture",
|
|
279
288
|
// Admission caps bind the ACCOUNT: a project widening its own caps would hollow the customer's.
|
|
280
289
|
"runCaps",
|
|
281
290
|
// Provenance binds the ACCOUNT and cannot be overlaid in either direction: a project marking a real
|
|
@@ -305,6 +314,7 @@ export const FIELD_CONSUMERS = {
|
|
|
305
314
|
allowedRecipes: { file: "search-policy.mjs", symbol: "allowedRecipes" }, // entitlement gate on the resolved search selection
|
|
306
315
|
jxPolicy: { file: "pipeline.mjs", symbol: "jxPolicy" }, // frozen into the run sidecar for the Stage-1.5 lanes (resume-safe)
|
|
307
316
|
demoData: { file: "runner.mjs", symbol: "demoData" }, // the admission wall refuses a real clearance on demo data
|
|
317
|
+
testFixture: { file: "profiles.mjs", symbol: "loadProfiles" }, // never offered by a resolved roster; the suite asks for it by name
|
|
308
318
|
runCaps: { file: "runner.mjs", symbol: "runCaps" }, // admission caps at claimAndPrep (queued + monthly, both doors)
|
|
309
319
|
};
|
|
310
320
|
|
|
@@ -644,7 +654,7 @@ function readProfilesLayer(dir) {
|
|
|
644
654
|
*
|
|
645
655
|
* `dir` names the OVERLAY, not the whole store: passing it keeps the bundled set underneath, which is
|
|
646
656
|
* what makes an empty store a working install. Pass `dir: null` for the bundled set alone. */
|
|
647
|
-
export function loadProfiles({ dir, force = false } = {}) {
|
|
657
|
+
export function loadProfiles({ dir, force = false, includeTestFixtures, includeDemo } = {}) {
|
|
648
658
|
// — LAYERING APPLIES ONLY TO THE ENV-RESOLVED STORE, and that boundary is deliberate.
|
|
649
659
|
//
|
|
650
660
|
// `dir` OMITTED => resolve the deployment's store, overlay over base. `dir` PASSED => that directory
|
|
@@ -659,7 +669,10 @@ export function loadProfiles({ dir, force = false } = {}) {
|
|
|
659
669
|
const explicit = dir !== undefined;
|
|
660
670
|
const overlay = explicit ? null : PROFILES_OVERLAY_DIR;
|
|
661
671
|
const baseDir = explicit ? (dir || PROFILES_BASE_DIR) : PROFILES_BASE_DIR;
|
|
662
|
-
|
|
672
|
+
// EVERY DIMENSION THAT CHANGES THE ANSWER IS IN THE KEY. A cache keyed on the directory alone
|
|
673
|
+
// serves the demo's roster to the next caller that did not ask for it, which is the defect this
|
|
674
|
+
// gate exists to prevent, arriving from inside.
|
|
675
|
+
const cacheKey = `${overlay ?? ""} :: ${baseDir} :: ${includeTestFixtures ?? "env"} :: ${process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ?? ""} :: ${includeDemo ?? "env"} :: ${process.env.CLEAROTRON_DEMO_PROFILES ?? ""}`;
|
|
663
676
|
if (cache && !force && cache.key === cacheKey) return cache.profiles;
|
|
664
677
|
|
|
665
678
|
// FAIL LOUD ON AN UNREADABLE OVERLAY, the same ruling as the doctrine tree's. existsSync() answers
|
|
@@ -675,7 +688,7 @@ export function loadProfiles({ dir, force = false } = {}) {
|
|
|
675
688
|
// chosen. driver/test/pool-admin-reassign.test.mjs asserts "with CLEAROTRON_CUSTOMERS_DIR unset it REFUSES
|
|
676
689
|
// rather than validating against the demo roster", and mcp-server's roster boot check counts the
|
|
677
690
|
// configured roster exactly. Layering the whole bundled set underneath a configured store would put
|
|
678
|
-
//
|
|
691
|
+
// the three test accounts into every deployment's roster: a typo'd customer key would be checked
|
|
679
692
|
// against demo fixtures, and a boot check that says "N customers" would count ours among theirs.
|
|
680
693
|
//
|
|
681
694
|
// `generic` is different in kind from the rest of that directory. It is not a demo customer — it is
|
|
@@ -683,6 +696,84 @@ export function loadProfiles({ dir, force = false } = {}) {
|
|
|
683
696
|
// is the one file whose absence makes an empty store a refusal, so that is the only one that falls
|
|
684
697
|
// through. Everything else in a deployment's roster is the deployment's own.
|
|
685
698
|
const profiles = overlay ? readProfilesLayer(overlay) : readProfilesLayer(baseDir);
|
|
699
|
+
|
|
700
|
+
// ── A TEST FIXTURE IS PRESENT AND NEVER OFFERED ─────────────────────────────────────────────────
|
|
701
|
+
//
|
|
702
|
+
// An outside user installed this product and their brand-owner picker offered three of our test
|
|
703
|
+
// accounts. The picker was not wrong: it listed the roster, and the roster was the bundled directory.
|
|
704
|
+
//
|
|
705
|
+
// `package.json` already excludes those files, and that exclusion works — the published tarball
|
|
706
|
+
// carries two profiles. It protects ONE route. `INSTALL.md` documents `git clone && npm install` as a
|
|
707
|
+
// first-class way in, and on that route nothing is excluded, the checkout IS the bundled directory,
|
|
708
|
+
// and every fixture loads. A packaging rule cannot answer a question the loader is asked.
|
|
709
|
+
//
|
|
710
|
+
// So the loader answers it. A profile marked `testFixture` is refused from the roster this returns, on
|
|
711
|
+
// every route, whether it was excluded from a tarball or not — and the suite asks for it by name.
|
|
712
|
+
//
|
|
713
|
+
// TWO FLAGS, TWO QUESTIONS, AND THE SECOND ONE'S ANSWER CHANGED. `testFixture` marks visibility:
|
|
714
|
+
// a suite fixture is never offered. `demoData` marks provenance: the account is fiction and the
|
|
715
|
+
// admission wall refuses a real clearance under it. This file argued that `demoData` must leave its
|
|
716
|
+
// account LISTABLE, because the demo needed to show it — true until the owner ruled otherwise on
|
|
717
|
+
// 2026-09-08. A fresh install now resolves `generic` alone, and the demo asks for its own account
|
|
718
|
+
// when it runs, so the demo account is refused here too. The flags stay separate because the
|
|
719
|
+
// questions are: a fixture is never shown to anybody, a demo account is shown to the demo.
|
|
720
|
+
//
|
|
721
|
+
// ONE ROSTER, NOT TWO. The tested set and the resolvable set are now the same files with a flag,
|
|
722
|
+
// rather than five profiles the suite exercises against two a customer receives. That disjoint pair is
|
|
723
|
+
// the defect this module's own header narrates from 2026-07-19, and a packaging-only fix recreates it.
|
|
724
|
+
//
|
|
725
|
+
// THE SUITE ASKS ONCE, BY ENVIRONMENT, RATHER THAN AT NINETY-FOUR CALL SITES. Most of the checks that
|
|
726
|
+
// need a fixture do not call this function — they exercise code that calls it, so there is no argument
|
|
727
|
+
// to pass. `scripts/test-run.mjs` sets the variable for every child, which keeps the ask visible in
|
|
728
|
+
// one place and OFF everywhere else, including any process that merely imports this module.
|
|
729
|
+
//
|
|
730
|
+
// That leaves the tested roster larger than the resolved one, which is the disjoint-roster shape this
|
|
731
|
+
// module's header narrates and the reason a packaging-only fix was refused. What makes it one roster
|
|
732
|
+
// rather than two is `a-clean-install-offers-generic-alone`: it names both sides — every file
|
|
733
|
+
// in the directory, and exactly what a resolved roster returns — so the difference between them is
|
|
734
|
+
// asserted rather than assumed, and adding a fixture without marking it reds that check.
|
|
735
|
+
//
|
|
736
|
+
// AN EXPLICIT ARGUMENT BEATS THE ENVIRONMENT, and the distinction is `undefined` rather than falsiness.
|
|
737
|
+
// The suite sets the variable for every child, so a check asking for the RESOLVED roster — the one a
|
|
738
|
+
// customer gets — would otherwise be handed the suite's own. Two of this change's own arms failed that
|
|
739
|
+
// way before this line: they asked for what an install offers and were told what the suite runs with.
|
|
740
|
+
const asked = includeTestFixtures !== undefined
|
|
741
|
+
? includeTestFixtures
|
|
742
|
+
: String(process.env.CLEAROTRON_TEST_FIXTURE_PROFILES ?? "").trim() === "1";
|
|
743
|
+
if (!asked) for (const [k, p] of [...profiles]) if (p?.testFixture === true) profiles.delete(k);
|
|
744
|
+
|
|
745
|
+
// THE DEMO ACCOUNT IS NOT PART OF A FRESH INSTALL EITHER — owner ruling, 2026-09-08: a clean install
|
|
746
|
+
// resolves `generic` and nothing else, and the demo brings its own account when somebody runs it.
|
|
747
|
+
// Nobody should have to clean demo material out of an environment they just created.
|
|
748
|
+
//
|
|
749
|
+
// Same shape as the gate above and for the same reason: the packaging exclusion cannot answer this,
|
|
750
|
+
// because `git clone && npm install` is a documented route on which the checkout IS the bundled
|
|
751
|
+
// directory. The loader answers it on every route.
|
|
752
|
+
const askedDemo = includeDemo !== undefined
|
|
753
|
+
? includeDemo
|
|
754
|
+
: String(process.env.CLEAROTRON_DEMO_PROFILES ?? "").trim() === "1";
|
|
755
|
+
|
|
756
|
+
// — AND IT GATES THE LAYER, NOT THE FLAG. The ruling is about the demo account this PRODUCT bundles.
|
|
757
|
+
// A deployment that put a `demoData` account in its OWN configured store chose to have it: that is an
|
|
758
|
+
// operator's decision about their own roster, and the admission wall still refuses a real clearance
|
|
759
|
+
// under it, so nothing is spent. Gating on the flag alone would have reached into a configured store
|
|
760
|
+
// and deleted an account nobody asked us to hide — the test deployment has one. When an overlay is
|
|
761
|
+
// configured this loader reads that store and not the bundled directory, so the question is which
|
|
762
|
+
// directory was actually read — NOT whether an overlay was set. An explicit `dir` pointing at a
|
|
763
|
+
// configured store leaves `overlay` null while reading somebody's own roster, and `!overlay` would
|
|
764
|
+
// have called that bundled and gated it.
|
|
765
|
+
const bundled = (overlay ?? baseDir) === PROFILES_BASE_DIR;
|
|
766
|
+
//
|
|
767
|
+
// THE MORE SPECIFIC FLAG DECIDES. Every shipped profile but `generic` carries `demoData`, the three
|
|
768
|
+
// suite fixtures included, so a demo gate applied to all of them would swallow a roster somebody had
|
|
769
|
+
// explicitly asked for: `includeTestFixtures: true` would return `generic` alone, silently, and a
|
|
770
|
+
// check that asks for its own fixtures would pass having exercised nothing. That is the same vacuous
|
|
771
|
+
// green two of this loader's own checks produced before the argument beat the environment. A fixture's
|
|
772
|
+
// visibility is answered by its own gate above; this one answers only for accounts that are demo data
|
|
773
|
+
// and nothing more specific.
|
|
774
|
+
if (!askedDemo && bundled) {
|
|
775
|
+
for (const [k, p] of [...profiles]) if (p?.demoData === true && p?.testFixture !== true) profiles.delete(k);
|
|
776
|
+
}
|
|
686
777
|
if (overlay && !profiles.has("generic")) {
|
|
687
778
|
const base = readProfilesLayer(baseDir);
|
|
688
779
|
if (base.has("generic")) profiles.set("generic", base.get("generic"));
|
|
@@ -742,6 +833,32 @@ export function resolveProfile(job, { profiles = loadProfiles() } = {}) {
|
|
|
742
833
|
const PROJECTS_SUBDIR = "projects";
|
|
743
834
|
let projectCache = null;
|
|
744
835
|
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Is `key` a profile that exists in one of these roots and is marked as a test fixture?
|
|
839
|
+
*
|
|
840
|
+
* Read from the FILE rather than from the roster, deliberately: the roster handed to the projects walk
|
|
841
|
+
* is the resolved one, which has already dropped every fixture — so asking it cannot tell "a fixture
|
|
842
|
+
* whose projects are beside it" from "a project directory whose customer is gone". Those want different
|
|
843
|
+
* answers and the file is the only place that still holds the difference.
|
|
844
|
+
*/
|
|
845
|
+
function readProfileFlag(roots, key, flag) {
|
|
846
|
+
for (const root of roots) {
|
|
847
|
+
const f = join(root, `${key}.json`);
|
|
848
|
+
if (!existsSync(f)) continue;
|
|
849
|
+
try { return JSON.parse(readFileSync(f, "utf8"))?.[flag] === true; } catch { return false; }
|
|
850
|
+
}
|
|
851
|
+
return false;
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const isTestFixtureKey = (roots, key) => readProfileFlag(roots, key, "testFixture");
|
|
855
|
+
|
|
856
|
+
/** Is this project directory's customer an account the DEMO brings, rather than one this install has?
|
|
857
|
+
* Read from the FILE for the same reason as the fixture check beside it: the roster handed to this walk
|
|
858
|
+
* has already dropped the account, so asking the roster cannot tell "the demo's, and hidden" from "the
|
|
859
|
+
* customer is gone", and the second is an authoring error that must still hard-fail. */
|
|
860
|
+
const isDemoKey = (roots, key) => readProfileFlag(roots, key, "demoData");
|
|
861
|
+
|
|
745
862
|
/** Load every profiles/projects/<customer>/<slug>.json → Map("<customer>/<slug>" → overlay). Each overlay is
|
|
746
863
|
* validated in SPARSE mode (PROJECT_KEYS optional, customer-only keys rejected) and carries its lifted-out
|
|
747
864
|
* `projectName` (default = slug) + optional sibling `<slug>.context.md`. A project directory under an unknown
|
|
@@ -778,8 +895,22 @@ export function loadProjects({ dir, profiles, force = false } = {}) {
|
|
|
778
895
|
for (const ent of readdirSync(projDir, { withFileTypes: true })) {
|
|
779
896
|
if (!ent.isDirectory()) continue; // only <customer>/ subdirs participate; a stray file is ignored
|
|
780
897
|
const ck = ent.name;
|
|
781
|
-
|
|
898
|
+
// A PROJECT UNDER A TEST FIXTURE IS SKIPPED, NOT A FAULT. The refusal below is right and stays: a
|
|
899
|
+
// project directory with no customer is a typo or a half-deleted account, and resolving it silently
|
|
900
|
+
// would attach an overlay to nothing. But a fixture's own projects are in the same checkout as the
|
|
901
|
+
// fixture, and the roster no longer offers the fixture — so on a clone install this refusal would
|
|
902
|
+
// fire on the product's own files and stop the process at load. Skipped by reading the fixture
|
|
903
|
+
// marker from the file, because the roster this walk was handed is the one that already dropped it.
|
|
904
|
+
if (!roster.has(ck)) {
|
|
905
|
+
if (isTestFixtureKey(roots, ck)) continue;
|
|
906
|
+
// AND THE DEMO'S OWN PROJECTS, for the identical reason. `profiles/projects/demo-brand-owner/`
|
|
907
|
+
// ships, and since a fresh install stopped resolving that account this walk met a project whose
|
|
908
|
+
// customer it could not see — a HARD startup failure, from the product's own files, on every
|
|
909
|
+
// clean install. Caught by the arm below, not by review; it is the third time this shape has been
|
|
910
|
+
// found in this loader.
|
|
911
|
+
if (isDemoKey(roots, ck)) continue;
|
|
782
912
|
throw new Error(`profiles/projects/${ck}/: no customer profile "${ck}" — a project must live under a known customer (add profiles/${ck}.json first)`);
|
|
913
|
+
}
|
|
783
914
|
const cdir = join(projDir, ck);
|
|
784
915
|
for (const f of readdirSync(cdir).filter((n) => n.endsWith(".json")).sort()) {
|
|
785
916
|
const slug = f.replace(/\.json$/, "");
|
|
@@ -845,7 +976,7 @@ export function resolveEffectiveProfile(job, { profiles = loadProfiles(), projec
|
|
|
845
976
|
//
|
|
846
977
|
// The customer's platforms are CLIENT-MANDATED — the account asked for those marketplaces to be
|
|
847
978
|
// searched, and a project may add to that instruction but never revoke it. Replace semantics meant a
|
|
848
|
-
// project that stated its own marketplaces silently DELETED the customer's:
|
|
979
|
+
// project that stated its own marketplaces silently DELETED the customer's: one test account
|
|
849
980
|
// names 7 games storefronts, its console-ecosystem project names 9 mostly-retail sites, and every run
|
|
850
981
|
// of that project searched the 9 — dropping store.epicgames.com, itch.io, apps.microsoft.com and
|
|
851
982
|
// mobygames.com. The report still read as clean coverage, because the sweep faithfully covered the
|
|
@@ -888,10 +1019,11 @@ export function resolveEffectiveProfile(job, { profiles = loadProfiles(), projec
|
|
|
888
1019
|
}
|
|
889
1020
|
|
|
890
1021
|
/** The self-exclusion gate: a profile's selfExclusionOwners[]
|
|
891
|
-
* may only inject when the job's APPLICANT is the profile's customer —
|
|
892
|
-
*
|
|
893
|
-
* (that would delete true conflicts from a delivered
|
|
894
|
-
* profile name in the applicant string
|
|
1022
|
+
* may only inject when the job's APPLICANT is the profile's customer — a search forwarded from a
|
|
1023
|
+
* customer's own domain, but asking about a THIRD-PARTY applicant, must not classify that
|
|
1024
|
+
* customer's conflicts as own rights (that would delete true conflicts from a delivered
|
|
1025
|
+
* clearance). Word-boundary containment of the profile name in the applicant string
|
|
1026
|
+
* ("Foxglade Interactive" ⊂ "Foxglade Interactive Ltd"), never fuzzy. */
|
|
895
1027
|
export function applicantMatchesProfile(profile, customer) {
|
|
896
1028
|
const n = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9 ]+/g, " ").replace(/\s+/g, " ").trim();
|
|
897
1029
|
const c = n(customer);
|
package/driver/publish/index.mjs
CHANGED
|
@@ -463,7 +463,7 @@ export function regenIndex(poolDir) {
|
|
|
463
463
|
// Do NOT chmod the customer dirs: they inherit the set-gid pool group (an explicit dir chmod here was the
|
|
464
464
|
// 2026-06-02→-06-08 Caddy-403 bug — see the publishReport comment). Only the index FILES get 0o640.
|
|
465
465
|
// The page heading speaks the customer's DISPLAY name (profile .name), never the internal key slug —
|
|
466
|
-
// "Trademark clearance —
|
|
466
|
+
// "Trademark clearance — foxglade" read as an internal id on a client-facing page. Best-effort: profiles
|
|
467
467
|
// unreadable ⇒ fall back to the filter label (the runs' client string) ⇒ the key (displayOf, built above).
|
|
468
468
|
for (const [key, crs] of byCustomer) {
|
|
469
469
|
// LEAK-#9 fail-closed: NEVER emit a client-facing customer/generic/index.html. 'generic' is the shared
|
|
@@ -1426,7 +1426,7 @@ export const RISK_TIERS = [
|
|
|
1426
1426
|
[/LOW/, { bg: '#4472C4', fg: '#fff', label: 'LOW', txt: '#2f55a4' }],
|
|
1427
1427
|
];
|
|
1428
1428
|
// doc 50 — the run's band LADDER (from opts.bands / meta.framework.bands): per-run vocabulary with the
|
|
1429
|
-
// same template palette keyed by TONE, so "Moderate" (house) and "Medium" (
|
|
1429
|
+
// same template palette keyed by TONE, so "Moderate" (house) and "Medium" (a customer ladder) both colour correctly
|
|
1430
1430
|
// and a 5-band matrix framework (incl. "Low") lands on the same ramp. Set per compose; null = legacy.
|
|
1431
1431
|
let EMAIL_BANDS = null;
|
|
1432
1432
|
export const TONE_TIER = {
|
|
@@ -370,6 +370,12 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
370
370
|
let ownerChecks = [];
|
|
371
371
|
try { ownerChecks = JSON.parse(readFileSync(driverDir(runDir, 'owner-checks.json'), 'utf8')).checks ?? []; }
|
|
372
372
|
catch { ownerChecks = []; }
|
|
373
|
+
// The request the run was given, read the same tolerant way as the sidecars above and for the same
|
|
374
|
+
// reason (tracker issue 331 A.1). It is what "About this request" states; a run archived before the
|
|
375
|
+
// sidecar existed has none, and its page renders exactly as it was delivered.
|
|
376
|
+
let instructedScope = null;
|
|
377
|
+
try { instructedScope = JSON.parse(readFileSync(driverDir(runDir, 'instructed-scope.json'), 'utf8')); }
|
|
378
|
+
catch { instructedScope = null; }
|
|
373
379
|
|
|
374
380
|
// ── — THIS LANE HAD NO RECORD-ORIGIN LOGIC AT ALL ─────────────────────────────────────────────
|
|
375
381
|
//
|
|
@@ -534,7 +540,7 @@ export async function publishKnockout({ runId, codename, runDir, findings, plan,
|
|
|
534
540
|
};
|
|
535
541
|
const markBand = single ? overall : worstBand(framework, [m]);
|
|
536
542
|
writeRO(file, renderKnockoutHtml(one, framework, {
|
|
537
|
-
runId, overall: markBand, issued, auditFile, probeRan, registerCounts, registerRecords, ownerChecks, identity, matter: runId,
|
|
543
|
+
runId, overall: markBand, issued, auditFile, probeRan, registerCounts, registerRecords, ownerChecks, instructedScope, identity, matter: runId,
|
|
538
544
|
// — an invented mark says so on its own report. Resolved ONCE above the loop:
|
|
539
545
|
// the answer is a property of the run, and asking per mark would let a multi-mark demo mark some
|
|
540
546
|
// documents and not others if the roster moved mid-publish.
|