@event4u/agent-config 14.15.0 → 14.17.0
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +84 -153
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/inbox.md +172 -6
- package/dist/agent-src/commands/fix/commit-messages.md +336 -0
- package/dist/agent-src/commands/fix.md +5 -3
- package/dist/agent-src/rules/commit-conventions.md +1 -1
- package/dist/agent-src/rules/security-sensitive-stop.md +7 -5
- package/dist/agent-src/skills/accessibility-auditor/SKILL.md +20 -2
- package/dist/agent-src/skills/aws-infrastructure/SKILL.md +41 -0
- package/dist/agent-src/skills/conventional-commits-writing/SKILL.md +234 -4
- package/dist/agent-src/skills/design-intelligence/data/ux-guidelines.csv +99 -99
- package/dist/agent-src/skills/fe-design/SKILL.md +6 -3
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +28 -0
- package/dist/agent-src/skills/iconography/SKILL.md +37 -6
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/terraform/SKILL.md +29 -0
- package/dist/agent-src/skills/threat-modeling/data/reasoning.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/surfaces.csv +1 -0
- package/dist/agent-src/skills/threat-modeling/data/threats.csv +5 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +158 -33
- package/dist/cli-delegate/cmd_explain.js +24 -112
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -21
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +169 -41
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +160 -35
- package/dist/router.json +1 -1
- package/docs/guidelines/design-antipatterns.md +10 -2
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +2 -2
- package/src/config/web-launch-readiness.json +1 -1
- package/src/scripts/_cli/explain_last/index.ts +1 -1
- package/src/scripts/_cli/explain_last/memory.ts +8 -104
- package/src/scripts/check_artefact_count_messaging.ts +9 -0
- package/src/scripts/check_release_highlights.ts +77 -4
- package/src/scripts/check_web_launch_readiness.ts +295 -15
- package/src/scripts/design_slop_rules.ts +19 -9
- package/src/scripts/git_authorization_hook.ts +257 -35
- package/src/scripts/hooks/block_unauthorized_git.ts +16 -4
- package/src/scripts/hooks/merge_impact.ts +68 -0
- package/src/scripts/measure_release_mix.ts +384 -0
- package/src/scripts/release.ts +4 -17
- package/src/scripts/release_drill.ts +154 -1
- package/src/scripts/release_mix_taxonomy.json +172 -0
- package/src/scripts/release_publication.ts +91 -0
- package/src/scripts/attest_artifact.ts +0 -193
|
@@ -2,12 +2,12 @@
|
|
|
2
2
|
* Highlight plausibility gate (release-truth Phase 2).
|
|
3
3
|
*
|
|
4
4
|
* The recorded failure: the curated release head said `_none_` twice while
|
|
5
|
-
* false — 9.13.0 shipped
|
|
5
|
+
* false — 9.13.0 shipped behavior changes, a removed public trigger type, a
|
|
6
6
|
* security fix, and honest nulls; the head claimed none of it. Same at
|
|
7
7
|
* 9.14.0.
|
|
8
8
|
*
|
|
9
9
|
* This gate derives GENERATED evidence categories from the release span
|
|
10
|
-
* (security-tagged commits,
|
|
10
|
+
* (security-tagged commits, behavior/default changes from conventional
|
|
11
11
|
* commit types + rule/schema diffs, honest-null markers, removed public
|
|
12
12
|
* surface) and FAILS when a populated generated category meets a `_none_`
|
|
13
13
|
* curated field. It blocks the contradiction only — a human still writes the
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
* ## REVERSED 2026-09-01 — an unrewritten derived line now BLOCKS
|
|
24
24
|
*
|
|
25
25
|
* This header used to end the paragraph above with *"An unrewritten derived
|
|
26
|
-
* line warns; it never blocks."* That is no longer the
|
|
26
|
+
* line warns; it never blocks."* That is no longer the behavior, and the
|
|
27
27
|
* sentence is replaced rather than deleted so the reversal is legible.
|
|
28
28
|
*
|
|
29
29
|
* **The premise was never wrong; the conclusion drawn from it was.** A derived
|
|
@@ -74,6 +74,7 @@ import {
|
|
|
74
74
|
previous_release_tag,
|
|
75
75
|
stale_draft_labels,
|
|
76
76
|
} from './_lib/release_highlights.js';
|
|
77
|
+
import { loadTaxonomy, measureRange } from './measure_release_mix.js';
|
|
77
78
|
import {
|
|
78
79
|
CURATED_HEAD_INSTRUCTION,
|
|
79
80
|
extract_changelog_section,
|
|
@@ -149,7 +150,7 @@ export function highlight_contradictions(
|
|
|
149
150
|
* Exported so the advisory-vs-blocking decision is a fixture, not a claim.
|
|
150
151
|
*
|
|
151
152
|
* CORRECTED 2026-09-01 with the reversal. This comment used to say that
|
|
152
|
-
* `stale_draft_labels` firing while the exit code stays 0 is the
|
|
153
|
+
* `stale_draft_labels` firing while the exit code stays 0 is the behavior the
|
|
153
154
|
* cadence blocker adjudicates. That is no longer what happens — the branch at
|
|
154
155
|
* the call site returns 1 — and the sentence is replaced rather than deleted
|
|
155
156
|
* because it was the load-bearing claim on the other side.
|
|
@@ -313,6 +314,10 @@ export function main(argv: readonly string[]): number {
|
|
|
313
314
|
}
|
|
314
315
|
const contradictions = highlight_contradictions(curated, derived);
|
|
315
316
|
if (contradictions.length === 0) {
|
|
317
|
+
const mix = check_governance_mix_response(section.body, from, to, version);
|
|
318
|
+
if (mix !== 0) {
|
|
319
|
+
return mix;
|
|
320
|
+
}
|
|
316
321
|
process.stdout.write(`✅ curated head plausible for ${version} (span ${from}..${to})\n`);
|
|
317
322
|
return 0;
|
|
318
323
|
}
|
|
@@ -332,6 +337,74 @@ export function main(argv: readonly string[]): number {
|
|
|
332
337
|
return 1;
|
|
333
338
|
}
|
|
334
339
|
|
|
340
|
+
/**
|
|
341
|
+
* The governance-versus-product response, checked for PRESENCE, never for the
|
|
342
|
+
* ratio (ADR-253).
|
|
343
|
+
*
|
|
344
|
+
* `measure_release_mix` publishes a level. This is the one place that level has
|
|
345
|
+
* a consequence, and the consequence is deliberately about completeness of the
|
|
346
|
+
* written answer: when governance-only commits strictly outnumber consumer-only
|
|
347
|
+
* commits over the release span, the section under release must carry a written
|
|
348
|
+
* response naming either the next cycle's consumer work or a maintainer
|
|
349
|
+
* justification. No number is enforced, and no number can be — both council
|
|
350
|
+
* seats refused a threshold on fewer than two readings, and a gate with no
|
|
351
|
+
* threshold that blocks on the ratio is a smuggled threshold.
|
|
352
|
+
*
|
|
353
|
+
* The response lives OUTSIDE the curated head, as its own `> **Governance
|
|
354
|
+
* mix:**` line, so it does not consume the head's ten-line cap and does not
|
|
355
|
+
* become a sixth label every historical section would suddenly lack.
|
|
356
|
+
*
|
|
357
|
+
* Scope matches every other check here: only the section under release is read.
|
|
358
|
+
*
|
|
359
|
+
* A measurement failure DEGRADES to a printed warning rather than to a refusal.
|
|
360
|
+
* This is a governance signal, not a correctness or security control, and a
|
|
361
|
+
* shallow clone or a missing tag on the release path would otherwise turn an
|
|
362
|
+
* unrelated environment fact into a blocked release.
|
|
363
|
+
*/
|
|
364
|
+
export const MIX_RESPONSE_MARKER = '**Governance mix:**';
|
|
365
|
+
const MIX_RESPONSE_MIN_CHARS = 40;
|
|
366
|
+
|
|
367
|
+
export function check_governance_mix_response(
|
|
368
|
+
body: string,
|
|
369
|
+
from: string,
|
|
370
|
+
to: string,
|
|
371
|
+
version: string,
|
|
372
|
+
): number {
|
|
373
|
+
let reading;
|
|
374
|
+
try {
|
|
375
|
+
reading = measureRange(from, to, loadTaxonomy(), version, REPO_ROOT);
|
|
376
|
+
} catch (err) {
|
|
377
|
+
process.stdout.write(
|
|
378
|
+
`⚠️ governance mix not measured for ${version}: ${(err as Error).message}\n`,
|
|
379
|
+
);
|
|
380
|
+
return 0;
|
|
381
|
+
}
|
|
382
|
+
const o = reading.response_obligation;
|
|
383
|
+
const level = `governance-only ${o.governance_only} vs consumer-only ${o.consumer_only} (taxonomy ${reading.taxonomy_version})`;
|
|
384
|
+
if (!o.triggered) {
|
|
385
|
+
process.stdout.write(`✅ governance mix for ${version}: ${level} — no response owed\n`);
|
|
386
|
+
return 0;
|
|
387
|
+
}
|
|
388
|
+
const idx = body.indexOf(MIX_RESPONSE_MARKER);
|
|
389
|
+
const written = idx === -1 ? '' : body.slice(idx + MIX_RESPONSE_MARKER.length).split('\n')[0]!.trim();
|
|
390
|
+
if (written.length >= MIX_RESPONSE_MIN_CHARS) {
|
|
391
|
+
process.stdout.write(`✅ governance mix for ${version}: ${level} — response present\n`);
|
|
392
|
+
return 0;
|
|
393
|
+
}
|
|
394
|
+
process.stderr.write(
|
|
395
|
+
`❌ the ${version} section owes a governance-versus-product response: ${level}.\n` +
|
|
396
|
+
' Governance-only commits outnumber consumer-only commits over the release span, so\n' +
|
|
397
|
+
' the section carries a written response naming either the next cycle\'s consumer work\n' +
|
|
398
|
+
' or a maintainer justification (docs/contracts/CHANGELOG-conventions.md § Governance-\n' +
|
|
399
|
+
' versus-product response; the decline this replaces is ADR-253).\n' +
|
|
400
|
+
` Add one line under the ${version} curated head, for example:\n` +
|
|
401
|
+
` > ${MIX_RESPONSE_MARKER} ${level}. Next cycle ships <the consumer work>.\n` +
|
|
402
|
+
' Only that section is read; historical sections are out of scope. No ratio is\n' +
|
|
403
|
+
' enforced — this blocks a MISSING answer, never a particular number.\n',
|
|
404
|
+
);
|
|
405
|
+
return 1;
|
|
406
|
+
}
|
|
407
|
+
|
|
335
408
|
const _isMain = (() => {
|
|
336
409
|
const entry = process.argv[1];
|
|
337
410
|
if (!entry) return false;
|
|
@@ -100,8 +100,23 @@ export interface Undecided {
|
|
|
100
100
|
/** `file:line` the instrument stopped at, so the reader can look at it. */
|
|
101
101
|
location: string;
|
|
102
102
|
reason: string;
|
|
103
|
+
/** WHY it could not decide — see `UndecidedReason`. Never a fifth state. */
|
|
104
|
+
reason_class: UndecidedReason;
|
|
103
105
|
}
|
|
104
106
|
|
|
107
|
+
/**
|
|
108
|
+
* The two ways a check can fail to decide. A REASON CLASS on the existing
|
|
109
|
+
* `Undecided` state, deliberately not a new state.
|
|
110
|
+
*
|
|
111
|
+
* `instrument-limit` the input carries no evidence this reader can consume.
|
|
112
|
+
* A static reader looking for `<img>` in a tree with no
|
|
113
|
+
* HTML page is not finding zero violations — it is
|
|
114
|
+
* reading zero bytes, and those are different answers.
|
|
115
|
+
* `declaration-missing` the evidence is readable, but the CALLER declared
|
|
116
|
+
* nothing that would settle which rule applies to it.
|
|
117
|
+
*/
|
|
118
|
+
export type UndecidedReason = 'instrument-limit' | 'declaration-missing';
|
|
119
|
+
|
|
105
120
|
/** A tier the region axis moved, and why. Reported so the axis is visible. */
|
|
106
121
|
export interface Escalated {
|
|
107
122
|
check: string;
|
|
@@ -337,14 +352,35 @@ export function tierFor(
|
|
|
337
352
|
return e === undefined ? { tier: c.tier, escalatedBy: null } : { tier: e.to, escalatedBy: e.why };
|
|
338
353
|
}
|
|
339
354
|
|
|
340
|
-
/**
|
|
341
|
-
|
|
355
|
+
/**
|
|
356
|
+
* The `web_launch_readiness:` block of `.agent-settings.yml`, as read.
|
|
357
|
+
*
|
|
358
|
+
* `site_type` and `region` join `enabled` here because they were CLI-ONLY, and
|
|
359
|
+
* that is D12: a consumer could set `enabled: true`, run the gate, and get
|
|
360
|
+
* `required-legal-pages` at SITUATIONAL — non-blocking, exit 0 — while the same
|
|
361
|
+
* finding is CRITICAL and blocking for a DE-targeted site. The escalation was
|
|
362
|
+
* not overridden; it was never reachable, because `--region` defaults to
|
|
363
|
+
* `unspecified` and no settings key could say otherwise.
|
|
364
|
+
*
|
|
365
|
+
* `undefined` means the key is ABSENT, which is a different fact from
|
|
366
|
+
* `region: 'unspecified'` — the first is a caller who said nothing, the second
|
|
367
|
+
* a caller who said "no jurisdiction". `render()` distinguishes them.
|
|
368
|
+
*/
|
|
369
|
+
export interface WebLaunchSettings {
|
|
370
|
+
enabled: boolean;
|
|
371
|
+
site_type?: SiteType;
|
|
372
|
+
region?: Region;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Two-space keys under `web_launch_readiness:` in `.agent-settings.yml`. */
|
|
376
|
+
export function readSettings(root: string): WebLaunchSettings {
|
|
342
377
|
let raw: string;
|
|
343
378
|
try {
|
|
344
379
|
raw = fs.readFileSync(path.join(root, '.agent-settings.yml'), 'utf-8');
|
|
345
380
|
} catch {
|
|
346
|
-
return false;
|
|
381
|
+
return { enabled: false };
|
|
347
382
|
}
|
|
383
|
+
const out: WebLaunchSettings = { enabled: false };
|
|
348
384
|
let inSection = false;
|
|
349
385
|
for (const line of raw.split('\n')) {
|
|
350
386
|
if (/^web_launch_readiness:\s*$/.test(line)) {
|
|
@@ -355,12 +391,22 @@ export function enabled(root: string): boolean {
|
|
|
355
391
|
inSection = false;
|
|
356
392
|
continue;
|
|
357
393
|
}
|
|
358
|
-
if (inSection
|
|
394
|
+
if (!inSection) continue;
|
|
395
|
+
if (/^\s{2}enabled:\s*true\s*$/.test(line)) out.enabled = true;
|
|
396
|
+
const st = /^\s{2}site_type:\s*["']?([a-z-]+)["']?\s*$/.exec(line);
|
|
397
|
+
if (st !== null) out.site_type = st[1] as SiteType;
|
|
398
|
+
const rg = /^\s{2}region:\s*["']?([a-z]+)["']?\s*$/.exec(line);
|
|
399
|
+
if (rg !== null) out.region = rg[1] as Region;
|
|
359
400
|
}
|
|
360
|
-
return
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** `web_launch_readiness.enabled: true` in `.agent-settings.yml` — default OFF. */
|
|
405
|
+
export function enabled(root: string): boolean {
|
|
406
|
+
return readSettings(root).enabled;
|
|
361
407
|
}
|
|
362
408
|
|
|
363
|
-
/*
|
|
409
|
+
/* -- staging indexability: the check that predates the IMPLS table --------- */
|
|
364
410
|
|
|
365
411
|
const NOINDEX_RE = /<meta[^>]+name\s*=\s*["']robots["'][^>]*content\s*=\s*["'][^"']*noindex/i;
|
|
366
412
|
const XROBOTS_RE = /x-robots-tag[^\n]*noindex/i;
|
|
@@ -778,11 +824,153 @@ const analyticsAndConsent: Impl = (files) => analyseConsentOrder(files).hits;
|
|
|
778
824
|
* EMPTY `Located[]` means "applied and found nothing" is what keeps the
|
|
779
825
|
* silent-green defect out of `IMPLS`, and folding a third state into that return
|
|
780
826
|
* type would put the invariant back in play for all eight implementations.
|
|
827
|
+
*
|
|
828
|
+
* EVERY check has an entry, and the entries are NOT interchangeable. Measured
|
|
829
|
+
* on this tree before the table was filled: a build holding only `index.js` and
|
|
830
|
+
* `src/App.vue` — one `<img>` with no alt, no `<title>`, no `lang`, no charset,
|
|
831
|
+
* no viewport, no canonical — produced
|
|
832
|
+
*
|
|
833
|
+
* PASSED: staging-noindex-leftover, https-enforcement, per-route-metadata,
|
|
834
|
+
* image-alternative-text, document-head-basics,
|
|
835
|
+
* canonical-and-sitemap-coherence, analytics-and-consent-wiring
|
|
836
|
+
* exit 0
|
|
837
|
+
*
|
|
838
|
+
* Five of those iterated ZERO files, because `isPage()` filtered every file out.
|
|
839
|
+
* The `Impl` contract above states the rule they broke in its own words, and
|
|
840
|
+
* `audit()` turned each empty array into a pass.
|
|
841
|
+
*
|
|
842
|
+
* The probes are keyed to EVIDENCE SURFACE, not to one shared blindness
|
|
843
|
+
* predicate. An AI council (2026-09-04, 2/2, anthropic + openai) rejected the
|
|
844
|
+
* single predicate: "no HTML page" is honest for a check that reads rendered
|
|
845
|
+
* markup and semantically wrong for one that matches route paths, where the
|
|
846
|
+
* absence of HTML does not establish the absence of a route surface. Four
|
|
847
|
+
* surfaces, nine checks.
|
|
848
|
+
*/
|
|
849
|
+
interface UndecideHit extends Located {
|
|
850
|
+
reason_class: UndecidedReason;
|
|
851
|
+
/**
|
|
852
|
+
* The instrument could not read this check's evidence surface AT ALL, so
|
|
853
|
+
* whatever the `Impl` returned came from the same zero evidence and must
|
|
854
|
+
* not be reported.
|
|
855
|
+
*
|
|
856
|
+
* Without this, `custom-error-route` on a component-rendered build reports
|
|
857
|
+
* "no 404.html in the build output" as a HIGH finding AND, two lines lower,
|
|
858
|
+
* that the absence of the file establishes nothing. Both sentences are
|
|
859
|
+
* printed from the same read, and one of them is wrong. A PER-LOCATION
|
|
860
|
+
* undecided (the consent-order pass) is the opposite case: other locations
|
|
861
|
+
* were decided, so its findings stand and the cover legitimately overlaps.
|
|
862
|
+
*/
|
|
863
|
+
surface_blind?: true;
|
|
864
|
+
}
|
|
865
|
+
export interface UndecideCtx {
|
|
866
|
+
region: Region;
|
|
867
|
+
siteType: SiteType;
|
|
868
|
+
escalations: readonly Escalation[];
|
|
869
|
+
}
|
|
870
|
+
export type Undecide = (files: readonly SourceFile[], ctx: UndecideCtx) => UndecideHit[];
|
|
871
|
+
|
|
872
|
+
/** Surface 1 — anything at all was read. */
|
|
873
|
+
const readAnything = (files: readonly SourceFile[]): boolean => files.length > 0;
|
|
874
|
+
|
|
875
|
+
/** Surface 2 — a rendered HTML page exists, so markup is inspectable. */
|
|
876
|
+
const hasPage = (files: readonly SourceFile[]): boolean => files.some(isPage);
|
|
877
|
+
|
|
878
|
+
/** Surface 3 — code the consent pass can read at all (`.md` is prose, not code). */
|
|
879
|
+
const hasCode = (files: readonly SourceFile[]): boolean => files.some((f) => !/\.md$/i.test(f.base));
|
|
880
|
+
|
|
881
|
+
const ROUTE_DIR_RE = /(^|\/)(routes?|pages)(\/|$)/i;
|
|
882
|
+
const ROUTER_FILE_RE = /^(router|routes)\.(js|jsx|ts|tsx|mjs|cjs)$/i;
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* Surface 4 — a statically inspectable ROUTE surface.
|
|
886
|
+
*
|
|
887
|
+
* Enumerated rather than described, because a predicate is only as good as the
|
|
888
|
+
* artefacts it names: an HTML page, a `sitemap.xml`, a `routes/` or `pages/`
|
|
889
|
+
* tree, or a router module. None of the four present means a 404 route or a
|
|
890
|
+
* legal page can exist entirely inside a client bundle, and its absence AS A
|
|
891
|
+
* FILE establishes nothing. One of the four present and the absence IS the
|
|
892
|
+
* finding — which is why this is not the markup predicate wearing another name.
|
|
781
893
|
*/
|
|
782
|
-
|
|
894
|
+
const hasRouteSurface = (files: readonly SourceFile[]): boolean =>
|
|
895
|
+
files.some(
|
|
896
|
+
(f) =>
|
|
897
|
+
isPage(f) ||
|
|
898
|
+
f.base === 'sitemap.xml' ||
|
|
899
|
+
ROUTE_DIR_RE.test(f.rel.replace(/\\/g, '/')) ||
|
|
900
|
+
ROUTER_FILE_RE.test(f.base),
|
|
901
|
+
);
|
|
902
|
+
|
|
903
|
+
/** A whole-tree undecided: an absence has no line, and inventing one would lie. */
|
|
904
|
+
const blind = (text: string, reason_class: UndecidedReason = 'instrument-limit'): UndecideHit[] => [
|
|
905
|
+
{ file: '.', line: 0, text, reason_class, surface_blind: true },
|
|
906
|
+
];
|
|
907
|
+
|
|
908
|
+
/** `p(files)` true → decidable, no entry. False → one whole-tree undecided. */
|
|
909
|
+
const unless = (p: (f: readonly SourceFile[]) => boolean, text: string): Undecide =>
|
|
910
|
+
(files) => (p(files) ? [] : blind(text));
|
|
911
|
+
|
|
912
|
+
const NO_MARKUP =
|
|
913
|
+
'no HTML page in this build tree, so this check read zero bytes rather than finding zero ' +
|
|
914
|
+
'violations. Markup is produced by components or a template engine that a static reader ' +
|
|
915
|
+
'does not evaluate';
|
|
783
916
|
|
|
784
917
|
export const UNDECIDABLE: Readonly<Record<string, Undecide>> = {
|
|
785
|
-
'
|
|
918
|
+
'staging-noindex-leftover': unless(
|
|
919
|
+
readAnything,
|
|
920
|
+
'the build tree holds no scannable file at all — "no indexability leftover" would be a ' +
|
|
921
|
+
'verdict on zero bytes. This check reads .js/.ts/.vue too, so a page-only predicate ' +
|
|
922
|
+
'would understate what it can normally see.',
|
|
923
|
+
),
|
|
924
|
+
'https-enforcement': unless(hasPage, `${NO_MARKUP}, so an insecure asset URL is invisible here rather than absent.`),
|
|
925
|
+
'per-route-metadata': unless(hasPage, `${NO_MARKUP}, and a head manager sets <title> and description at runtime.`),
|
|
926
|
+
'image-alternative-text': unless(hasPage, `${NO_MARKUP}, so an <img> with no alt cannot be reached.`),
|
|
927
|
+
'document-head-basics': unless(hasPage, `${NO_MARKUP}, and lang/charset/viewport come from a shell this reader does not resolve.`),
|
|
928
|
+
'canonical-and-sitemap-coherence': unless(hasPage, `${NO_MARKUP}, and rel="canonical" is commonly injected at runtime.`),
|
|
929
|
+
'custom-error-route': unless(
|
|
930
|
+
hasRouteSurface,
|
|
931
|
+
'no statically inspectable route surface — no HTML page, no sitemap.xml, no routes/ or ' +
|
|
932
|
+
'pages/ tree, no router module. A 404 route may exist only inside a client bundle, so ' +
|
|
933
|
+
'its absence as a FILE establishes nothing.',
|
|
934
|
+
),
|
|
935
|
+
'required-legal-pages': (files, ctx) => {
|
|
936
|
+
if (!hasRouteSurface(files)) {
|
|
937
|
+
return blind(
|
|
938
|
+
'no statically inspectable route surface — no HTML page, no sitemap.xml, no ' +
|
|
939
|
+
'routes/ or pages/ tree, no router module. A legal page matched on its PATH ' +
|
|
940
|
+
'cannot be found in a tree that publishes no paths.',
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
// The declaration arm, and it is D12's silent loss stated precisely.
|
|
944
|
+
// The matcher accepts EITHER the DE pair (Impressum + Datenschutz) or
|
|
945
|
+
// the generic pair (privacy + terms/imprint/legal). A tree carrying only
|
|
946
|
+
// the generic pair passes at every region — including `de`, where an
|
|
947
|
+
// Impressum is owed by name. With no region declared, whether the
|
|
948
|
+
// generic pair discharges the obligation is a question about the
|
|
949
|
+
// CALLER, not about the tree, and the instrument must not answer it.
|
|
950
|
+
const paths = files.map((f) => f.rel.replace(/\\/g, '/'));
|
|
951
|
+
const all = (res: RegExp[]): boolean => res.every((re) => paths.some((p) => re.test(p)));
|
|
952
|
+
const registered = ctx.escalations.some((e) => e.check === 'required-legal-pages');
|
|
953
|
+
if (ctx.region === 'unspecified' && registered && all(LEGAL_GENERIC) && !all(LEGAL_DE)) {
|
|
954
|
+
return blind(
|
|
955
|
+
'this build carries the generic legal pair (privacy + terms/imprint/legal) and ' +
|
|
956
|
+
'NOT the DE pair (Impressum + Datenschutz), and no region was declared. The ' +
|
|
957
|
+
'config registers a region escalation for this check, so the answer depends on ' +
|
|
958
|
+
'a declaration the caller did not make: pass `--region` or set ' +
|
|
959
|
+
'`web_launch_readiness.region` in .agent-settings.yml.',
|
|
960
|
+
'declaration-missing',
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
return [];
|
|
964
|
+
},
|
|
965
|
+
'analytics-and-consent-wiring': (files) => {
|
|
966
|
+
if (!hasCode(files)) {
|
|
967
|
+
return blind(
|
|
968
|
+
'the build tree holds no code file at all (prose is excluded from this pass by ' +
|
|
969
|
+
'construction), so neither a measurement tag nor a consent gate could be read.',
|
|
970
|
+
);
|
|
971
|
+
}
|
|
972
|
+
return analyseConsentOrder(files).undecided.map((u) => ({ ...u, reason_class: 'instrument-limit' as const }));
|
|
973
|
+
},
|
|
786
974
|
};
|
|
787
975
|
|
|
788
976
|
/** First line containing `needle`, 1-indexed; 1 when absent (an absence has no line). */
|
|
@@ -813,6 +1001,18 @@ export function audit(
|
|
|
813
1001
|
siteType: SiteType,
|
|
814
1002
|
root = REPO_ROOT,
|
|
815
1003
|
region: Region = 'unspecified',
|
|
1004
|
+
/**
|
|
1005
|
+
* The blind-spot probe table, overridable so a test can DELETE one entry
|
|
1006
|
+
* and observe that check fall back to `passed`.
|
|
1007
|
+
*
|
|
1008
|
+
* That reversal is the whole proof the predicates are narrow: an entry that
|
|
1009
|
+
* cannot be removed without the fixture staying undecided was never doing
|
|
1010
|
+
* the work, and a probe that answers `undecided` on every input is as
|
|
1011
|
+
* useless as one that answers `passed` on every input — only harder to
|
|
1012
|
+
* notice. Same shape as `root` and `region`: defaulted, so no caller
|
|
1013
|
+
* changes.
|
|
1014
|
+
*/
|
|
1015
|
+
undecidable: Readonly<Record<string, Undecide>> = UNDECIDABLE,
|
|
816
1016
|
): Report {
|
|
817
1017
|
const { checks, siteTypes, regions, escalations } = loadConfig(root);
|
|
818
1018
|
if (!siteTypes.includes(siteType)) {
|
|
@@ -858,14 +1058,24 @@ export function audit(
|
|
|
858
1058
|
impl === undefined
|
|
859
1059
|
? scanIndexability(buildDir).hits
|
|
860
1060
|
: impl(files);
|
|
861
|
-
const undecided =
|
|
1061
|
+
const undecided = undecidable[c.id]?.(files, { region, siteType, escalations }) ?? [];
|
|
862
1062
|
for (const u of undecided) {
|
|
863
|
-
unknown.push({
|
|
1063
|
+
unknown.push({
|
|
1064
|
+
check: c.id,
|
|
1065
|
+
tier,
|
|
1066
|
+
location: `${u.file}:${String(u.line)}`,
|
|
1067
|
+
reason: u.text,
|
|
1068
|
+
reason_class: u.reason_class,
|
|
1069
|
+
});
|
|
864
1070
|
}
|
|
865
1071
|
if (hits.length === 0 && undecided.length === 0) {
|
|
866
1072
|
passed.push(c.id);
|
|
867
1073
|
continue;
|
|
868
1074
|
}
|
|
1075
|
+
// A surface-blind probe read nothing, so the Impl's hits came from the
|
|
1076
|
+
// same nothing. Reporting them alongside "this establishes nothing"
|
|
1077
|
+
// would print two contradictory sentences from one read.
|
|
1078
|
+
if (undecided.some((u) => u.surface_blind === true)) continue;
|
|
869
1079
|
for (const h of hits) {
|
|
870
1080
|
findings.push({
|
|
871
1081
|
check: c.id,
|
|
@@ -877,7 +1087,7 @@ export function audit(
|
|
|
877
1087
|
});
|
|
878
1088
|
}
|
|
879
1089
|
}
|
|
880
|
-
|
|
1090
|
+
const report: Report = {
|
|
881
1091
|
site_type: siteType,
|
|
882
1092
|
region,
|
|
883
1093
|
enabled: enabled(root),
|
|
@@ -889,6 +1099,58 @@ export function audit(
|
|
|
889
1099
|
escalated,
|
|
890
1100
|
scanned_files: files.length,
|
|
891
1101
|
};
|
|
1102
|
+
assertEveryCheckAccounted(report, checks);
|
|
1103
|
+
return report;
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
/** The five report buckets, as a set of check ids each. */
|
|
1107
|
+
export function bucketsOf(r: Report): Readonly<Record<string, ReadonlySet<string>>> {
|
|
1108
|
+
return {
|
|
1109
|
+
findings: new Set(r.findings.map((f) => f.check)),
|
|
1110
|
+
passed: new Set(r.passed),
|
|
1111
|
+
unimplemented: new Set(r.unimplemented),
|
|
1112
|
+
unknown: new Set(r.unknown.map((u) => u.check)),
|
|
1113
|
+
skipped: new Set(r.skipped.map((s) => s.check)),
|
|
1114
|
+
};
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/**
|
|
1118
|
+
* Every configured check lands in at least one bucket — enforced HERE, not only
|
|
1119
|
+
* in a test, because a test is not present in a consumer's run.
|
|
1120
|
+
*
|
|
1121
|
+
* A COVER, not a partition. A check can be in `findings` AND `unknown` at once:
|
|
1122
|
+
* `audit()` pushes both and only short-circuits to `passed` when both are empty.
|
|
1123
|
+
* Asserting disjointness would therefore red a correct report.
|
|
1124
|
+
*
|
|
1125
|
+
* Compared by check ID rather than by cardinality, because duplicate entries can
|
|
1126
|
+
* make two counts agree while a check has silently vanished — the exact failure
|
|
1127
|
+
* the report's own `passed`/`unimplemented` split exists to prevent. An AI
|
|
1128
|
+
* council (2026-09-04, 2/2) chose the throw over a report field: exposing the
|
|
1129
|
+
* gap as data makes the consumer responsible for noticing what it cannot
|
|
1130
|
+
* reliably detect, and a check that lands nowhere reads as green.
|
|
1131
|
+
*
|
|
1132
|
+
* The error carries the missing ids AND the buckets built so far, so a
|
|
1133
|
+
* diagnosis does not lose the findings that were already computed.
|
|
1134
|
+
*/
|
|
1135
|
+
export function assertEveryCheckAccounted(r: Report, checks: readonly CheckDef[]): void {
|
|
1136
|
+
const buckets = bucketsOf(r);
|
|
1137
|
+
const seen = new Set<string>();
|
|
1138
|
+
for (const ids of Object.values(buckets)) for (const id of ids) seen.add(id);
|
|
1139
|
+
const configured = new Set(checks.map((c) => c.id));
|
|
1140
|
+
const missing = [...configured].filter((id) => !seen.has(id)).sort();
|
|
1141
|
+
const stray = [...seen].filter((id) => !configured.has(id)).sort();
|
|
1142
|
+
if (missing.length === 0 && stray.length === 0) return;
|
|
1143
|
+
const where = Object.entries(buckets)
|
|
1144
|
+
.map(([k, v]) => `${k}=[${[...v].sort().join(', ')}]`)
|
|
1145
|
+
.join(' · ');
|
|
1146
|
+
throw new DeadScopeError(
|
|
1147
|
+
'check_web_launch_readiness',
|
|
1148
|
+
'the report does not account for every configured check, so some result would have ' +
|
|
1149
|
+
'been read as clean by omission.' +
|
|
1150
|
+
(missing.length > 0 ? `\n in no bucket: ${missing.join(', ')}` : '') +
|
|
1151
|
+
(stray.length > 0 ? `\n in a bucket but not configured: ${stray.join(', ')}` : '') +
|
|
1152
|
+
`\n buckets built so far: ${where}`,
|
|
1153
|
+
);
|
|
892
1154
|
}
|
|
893
1155
|
|
|
894
1156
|
const TIER_ORDER: readonly Tier[] = ['critical', 'high', 'medium', 'situational'];
|
|
@@ -897,6 +1159,17 @@ const TIER_ORDER: readonly Tier[] = ['critical', 'high', 'medium', 'situational'
|
|
|
897
1159
|
export function render(r: Report): string {
|
|
898
1160
|
const out: string[] = [];
|
|
899
1161
|
out.push(`site type: ${r.site_type} · region: ${r.region}`);
|
|
1162
|
+
if (r.region === 'unspecified') {
|
|
1163
|
+
// 2.2. The axis that decides SEVERITY is invisible when it is absent,
|
|
1164
|
+
// and an invisible axis reads as "no escalation applies" rather than as
|
|
1165
|
+
// "nobody said". `escalated` is empty on `unspecified` BY CONSTRUCTION,
|
|
1166
|
+
// so silence here is exactly the shape of the lost escalation.
|
|
1167
|
+
out.push(
|
|
1168
|
+
' region unspecified → DE escalation not active. Every tier below is the base tier; ' +
|
|
1169
|
+
'declare `--region de` or `web_launch_readiness.region` to raise the ones the ' +
|
|
1170
|
+
'config registers.',
|
|
1171
|
+
);
|
|
1172
|
+
}
|
|
900
1173
|
for (const e of r.escalated) {
|
|
901
1174
|
// Printed BEFORE the findings, because a reader who sees a check at
|
|
902
1175
|
// CRITICAL on one run and SITUATIONAL on another needs the axis that
|
|
@@ -940,16 +1213,23 @@ export function main(argv: string[] = process.argv.slice(2), root = REPO_ROOT):
|
|
|
940
1213
|
return i >= 0 ? argv[i + 1] : undefined;
|
|
941
1214
|
};
|
|
942
1215
|
const build = arg('--build');
|
|
943
|
-
|
|
944
|
-
|
|
1216
|
+
// CLI first, settings second, default last. A flag is an explicit statement
|
|
1217
|
+
// about THIS run; a settings key is a standing declaration about the
|
|
1218
|
+
// project; the default is nobody saying anything, and only the last of the
|
|
1219
|
+
// three is silent about a jurisdiction.
|
|
1220
|
+
const settings = readSettings(root);
|
|
1221
|
+
const siteType = (arg('--site-type') ?? settings.site_type) as SiteType | undefined;
|
|
1222
|
+
const region = (arg('--region') ?? settings.region ?? 'unspecified') as Region;
|
|
945
1223
|
if (build === undefined || siteType === undefined) {
|
|
946
1224
|
process.stderr.write(
|
|
947
1225
|
'usage: check_web_launch_readiness --build <dir> --site-type <type> ' +
|
|
948
|
-
'[--region <de|eu|us|unspecified>] [--json]\n'
|
|
1226
|
+
'[--region <de|eu|us|unspecified>] [--json]\n' +
|
|
1227
|
+
' --site-type and --region also resolve from `web_launch_readiness:` in ' +
|
|
1228
|
+
'.agent-settings.yml; a flag overrides the file.\n',
|
|
949
1229
|
);
|
|
950
1230
|
return 2;
|
|
951
1231
|
}
|
|
952
|
-
if (!enabled
|
|
1232
|
+
if (!settings.enabled && !argv.includes('--force')) {
|
|
953
1233
|
process.stdout.write(
|
|
954
1234
|
'web-launch-readiness is DEFAULT-OFF until the pre-registered benchmark returns\n' +
|
|
955
1235
|
'positive (claim:web-launch-readiness-finds-more, status unbacked). Enable with\n' +
|
|
@@ -214,20 +214,30 @@ export const SLOP_RULES: SlopRule[] = [
|
|
|
214
214
|
catalogId: "V1",
|
|
215
215
|
severity: "P1",
|
|
216
216
|
engines: ["css"],
|
|
217
|
-
description:
|
|
217
|
+
description:
|
|
218
|
+
"Colored accent border > 1px — a side stripe, a four-sided colored border, or a gradient border-image (V1)",
|
|
218
219
|
message:
|
|
219
|
-
"
|
|
220
|
-
gated: (ctx) =>
|
|
220
|
+
"Colored accent border reads as scaffold-default. Remove it, or declare an intentional accent border in DESIGN.md (V1).",
|
|
221
|
+
gated: (ctx) =>
|
|
222
|
+
ctx.has("side stripe", "side-stripe", "stripe accent", "accent border", "card border"),
|
|
221
223
|
detect: ({ content }) =>
|
|
222
224
|
cssBlocks(content)
|
|
223
|
-
// a side-stripe is a left/right border with a width > 1px and a color;
|
|
224
225
|
// exclude semantic blockquote/quote selectors (legitimate left rule)
|
|
225
226
|
.filter((b) => !/blockquote|\bquote\b|<q>/i.test(b.selector))
|
|
226
|
-
.filter((b) =>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
227
|
+
.filter((b) => {
|
|
228
|
+
// A colored value, but never a placeholder one: `transparent` and
|
|
229
|
+
// `currentColor` at > 1px are the layout-reservation and
|
|
230
|
+
// inherit-the-text-color idioms, not an accent decision.
|
|
231
|
+
const COLOR = "(?!transparent|currentcolor|inherit|initial|unset)(?:#|rgb|hsl|oklch|var\\(|[a-z])";
|
|
232
|
+
const WIDTH = "(?:[2-9]|\\d{2,})px";
|
|
233
|
+
// (a) the original side stripe: border-left / border-right
|
|
234
|
+
const stripe = new RegExp(`border-(?:left|right)\\s*:\\s*${WIDTH}\\s+\\w+\\s+${COLOR}`, "i");
|
|
235
|
+
// (b) the current form: a fully colored border on all four sides
|
|
236
|
+
const foursided = new RegExp(`border\\s*:\\s*${WIDTH}\\s+\\w+\\s+${COLOR}`, "i");
|
|
237
|
+
// (c) a gradient border, which carries no width token at all
|
|
238
|
+
const gradient = /border-image\s*:[^;]*(?:linear|conic|radial)-gradient/i;
|
|
239
|
+
return stripe.test(b.body) || foursided.test(b.body) || gradient.test(b.body);
|
|
240
|
+
})
|
|
231
241
|
.map((b) => ({ line: b.line, snippet: b.selector.slice(0, 80) })),
|
|
232
242
|
},
|
|
233
243
|
{
|