@event4u/agent-config 14.14.0 → 14.15.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 +63 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +513 -17
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +1043 -390
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +703 -115
- package/package.json +1 -1
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +62 -2
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +4 -3
- package/src/config/rule-enforcement-baseline.json +0 -1
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/release_highlights.ts +75 -10
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_release_highlights.ts +17 -1
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/evolution_lab.ts +53 -1
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/git_authorization_hook.ts +476 -6
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hooks/block_unauthorized_git.ts +238 -9
- package/src/scripts/hooks/merge_impact.ts +207 -0
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/lint_code_comments.ts +106 -1
- package/src/scripts/release.ts +5 -17
- package/src/scripts/release_drill.ts +76 -1
- package/src/scripts/release_env.ts +31 -0
- package/src/scripts/release_publication.ts +125 -1
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- package/src/scripts/sync_pr_branch.ts +521 -42
|
@@ -21,14 +21,16 @@
|
|
|
21
21
|
* (`claim:web-launch-readiness-finds-more`, status `unbacked`). Absent the
|
|
22
22
|
* opt-in, this exits 0 and says why rather than auditing.
|
|
23
23
|
*
|
|
24
|
-
* Exit codes: 0 = no critical/high finding
|
|
25
|
-
*
|
|
24
|
+
* Exit codes: 0 = no critical/high finding and nothing left undecided (or not
|
|
25
|
+
* enabled) · 1 = a blocking finding, OR an applicable check the instrument could
|
|
26
|
+
* not decide · 2 = usage or a dead scan scope.
|
|
26
27
|
*/
|
|
27
28
|
import * as fs from 'node:fs';
|
|
28
29
|
import * as path from 'node:path';
|
|
29
30
|
import process from 'node:process';
|
|
30
31
|
import { fileURLToPath } from 'node:url';
|
|
31
32
|
|
|
33
|
+
import { asOf } from './_lib/as_of.js';
|
|
32
34
|
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
33
35
|
|
|
34
36
|
const HERE = fileURLToPath(import.meta.url);
|
|
@@ -42,7 +44,12 @@ export type Tier = 'critical' | 'high' | 'medium' | 'situational';
|
|
|
42
44
|
*
|
|
43
45
|
* `applies_to` decides WHETHER a check applies; a region decides HOW SEVERELY.
|
|
44
46
|
* A legal page is not more or less *applicable* in Germany — it is more or less
|
|
45
|
-
* *optional*, because
|
|
47
|
+
* *optional*, because German and EU law make it owed rather than advisable. The
|
|
48
|
+
* two citations behind that sentence are NOT restated here: they live in the
|
|
49
|
+
* escalation row's `authority` field in `web-launch-readiness.json`, where a
|
|
50
|
+
* `review_by` date can lapse them. That is the whole point of the
|
|
51
|
+
* `ddg-citation-authority` decision — a statute in prose cited law already dead
|
|
52
|
+
* for twenty-seven months and nothing could see it.
|
|
46
53
|
* Modelling it as a tier escalation keeps one check with one implementation and
|
|
47
54
|
* puts the jurisdiction where it belongs: on the consequence.
|
|
48
55
|
*/
|
|
@@ -56,6 +63,10 @@ export interface CheckDef {
|
|
|
56
63
|
why: string;
|
|
57
64
|
remediation: string;
|
|
58
65
|
verification: string;
|
|
66
|
+
/** Statute + where it can be read. Required once the row asserts a legal basis. */
|
|
67
|
+
authority?: string;
|
|
68
|
+
/** ISO date the citation must be re-read by. A lapsed date FAILS the loader. */
|
|
69
|
+
review_by?: string;
|
|
59
70
|
}
|
|
60
71
|
|
|
61
72
|
export interface Finding {
|
|
@@ -75,6 +86,22 @@ export interface Skipped {
|
|
|
75
86
|
reason: string;
|
|
76
87
|
}
|
|
77
88
|
|
|
89
|
+
/**
|
|
90
|
+
* An applicable check the instrument could not decide.
|
|
91
|
+
*
|
|
92
|
+
* NOT a finding (nothing was established) and NOT a pass (nothing was cleared).
|
|
93
|
+
* The third state exists because a detector that guesses on an undecidable input
|
|
94
|
+
* is worse than one that abstains — and because a pass this checker did not earn
|
|
95
|
+
* is exactly the silent-green defect its own header names.
|
|
96
|
+
*/
|
|
97
|
+
export interface Undecided {
|
|
98
|
+
check: string;
|
|
99
|
+
tier: Tier;
|
|
100
|
+
/** `file:line` the instrument stopped at, so the reader can look at it. */
|
|
101
|
+
location: string;
|
|
102
|
+
reason: string;
|
|
103
|
+
}
|
|
104
|
+
|
|
78
105
|
/** A tier the region axis moved, and why. Reported so the axis is visible. */
|
|
79
106
|
export interface Escalated {
|
|
80
107
|
check: string;
|
|
@@ -93,6 +120,8 @@ export interface Report {
|
|
|
93
120
|
passed: string[];
|
|
94
121
|
/** Applicable checks with no implementation yet — never counted as passed. */
|
|
95
122
|
unimplemented: string[];
|
|
123
|
+
/** Applicable, implemented, and undecidable on THIS input. Never a pass. */
|
|
124
|
+
unknown: Undecided[];
|
|
96
125
|
/** Tiers the region axis raised. Empty on `unspecified`, by construction. */
|
|
97
126
|
escalated: Escalated[];
|
|
98
127
|
scanned_files: number;
|
|
@@ -103,6 +132,141 @@ export interface Escalation {
|
|
|
103
132
|
region: Region;
|
|
104
133
|
to: Tier;
|
|
105
134
|
why: string;
|
|
135
|
+
/** Same contract as `CheckDef` — an escalation row is a legal claim too. */
|
|
136
|
+
authority?: string;
|
|
137
|
+
review_by?: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* -- 2.1/2.2: a legal claim carries its own authority and expiry ------------ */
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A statute reference: a named instrument, or a numbered article/section.
|
|
144
|
+
*
|
|
145
|
+
* The list is deliberately short and abbreviation-shaped. A broader "any legal
|
|
146
|
+
* word" match would fire on `regulatory exposure` and turn every jurisdictional
|
|
147
|
+
* sentence into a citation the gate then demands a source for — which is how a
|
|
148
|
+
* scoped gate becomes a blanket one nobody can satisfy. The numbered-provision
|
|
149
|
+
* arm (`Art. 13`, `§ 5`) catches an instrument the list does not name yet.
|
|
150
|
+
*
|
|
151
|
+
* `TMG` is retained as a token on purpose, and this is a HISTORICAL NOTE rather
|
|
152
|
+
* than a citation: the statute was superseded by the DDG in May 2024, so a row
|
|
153
|
+
* that names it again is exactly what this detector must still catch.
|
|
154
|
+
*/
|
|
155
|
+
export const STATUTE_RE =
|
|
156
|
+
/\b(TMG|DDG|TTDSG|TDDDG|DSGVO|GDPR|BDSG|RDG|UWG|BGB|HGB|StGB|CCPA|CPRA|HIPAA|ePrivacy|DSA|DMA|BFSG|EAA)\b|(?:\bArt\.|\bArticle\b|§)\s*\d/;
|
|
157
|
+
|
|
158
|
+
/** A config row that may assert a legal basis: a check, or a region escalation. */
|
|
159
|
+
export interface LegalRow {
|
|
160
|
+
kind: 'check' | 'escalation';
|
|
161
|
+
id: string;
|
|
162
|
+
why: string;
|
|
163
|
+
authority?: string;
|
|
164
|
+
review_by?: string;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Today, as an ISO date, through the repository's one sanctioned clock seam.
|
|
171
|
+
*
|
|
172
|
+
* NOT `new Date()`: a lapsed-date verdict read off the wall clock is a verdict
|
|
173
|
+
* that greens on Monday and reds on Friday from the same tree, which is the
|
|
174
|
+
* defect `lint_deterministic_time` exists to keep out of a gate. `asOf()`
|
|
175
|
+
* resolves `--as-of`, `AC_AS_OF`, the commit date under CI, and only then the
|
|
176
|
+
* wall clock — with its own non-reproducibility warning attached.
|
|
177
|
+
*/
|
|
178
|
+
export function asOfToday(): string {
|
|
179
|
+
return asOf().toISOString().slice(0, 10);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Every row of the config that could carry a legal claim, in report order. */
|
|
183
|
+
export function legalRowsOf(doc: {
|
|
184
|
+
checks?: CheckDef[];
|
|
185
|
+
regions?: { escalations?: Escalation[] };
|
|
186
|
+
}): LegalRow[] {
|
|
187
|
+
const rows: LegalRow[] = [];
|
|
188
|
+
for (const c of doc.checks ?? []) {
|
|
189
|
+
rows.push({
|
|
190
|
+
kind: 'check',
|
|
191
|
+
id: c.id,
|
|
192
|
+
why: c.why ?? '',
|
|
193
|
+
...(c.authority === undefined ? {} : { authority: c.authority }),
|
|
194
|
+
...(c.review_by === undefined ? {} : { review_by: c.review_by }),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
for (const e of doc.regions?.escalations ?? []) {
|
|
198
|
+
rows.push({
|
|
199
|
+
kind: 'escalation',
|
|
200
|
+
id: `${e.check}@${e.region}`,
|
|
201
|
+
why: e.why ?? '',
|
|
202
|
+
...(e.authority === undefined ? {} : { authority: e.authority }),
|
|
203
|
+
...(e.review_by === undefined ? {} : { review_by: e.review_by }),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
return rows;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The legal-row contract, as a list of violations (empty = clean).
|
|
211
|
+
*
|
|
212
|
+
* Three obligations, and the third is the one that makes the other two more
|
|
213
|
+
* than decoration:
|
|
214
|
+
*
|
|
215
|
+
* 1. a row whose `why` NAMES a statute must carry `authority` and `review_by`;
|
|
216
|
+
* 2. `authority` and `review_by` travel together — a citation with no expiry
|
|
217
|
+
* is the comment this contract exists to replace, and an expiry with no
|
|
218
|
+
* citation dates nothing. This half is what keeps the contract intact when
|
|
219
|
+
* the statute moves OUT of the prose and INTO `authority`;
|
|
220
|
+
* 3. a `review_by` on or after `asOf` is live; anything earlier is LAPSED and
|
|
221
|
+
* is a violation rather than a warning. A date nothing reads is a comment,
|
|
222
|
+
* and this repository already documents a warning ignored eighteen times.
|
|
223
|
+
*
|
|
224
|
+
* A row that asserts no legal basis is not policed. The gate is scoped, not
|
|
225
|
+
* blanket: demanding a citation from `A framework default error page loses the
|
|
226
|
+
* visitor` would make the contract unsatisfiable and therefore ignorable.
|
|
227
|
+
*/
|
|
228
|
+
export function legalRowViolations(rows: readonly LegalRow[], asOf: string): string[] {
|
|
229
|
+
const out: string[] = [];
|
|
230
|
+
for (const r of rows) {
|
|
231
|
+
const namesStatute = STATUTE_RE.test(r.why);
|
|
232
|
+
const hasAuthority = (r.authority ?? '').trim().length > 0;
|
|
233
|
+
const hasDate = (r.review_by ?? '').trim().length > 0;
|
|
234
|
+
const where = `${r.kind} "${r.id}"`;
|
|
235
|
+
// One violation per MISSING FIELD, never one per rule that noticed it.
|
|
236
|
+
// Dropping `authority` from a statute row trips both obligation 1 and
|
|
237
|
+
// obligation 2, and reporting it twice would make the count a property
|
|
238
|
+
// of the rule set rather than of the config.
|
|
239
|
+
const isLegal = namesStatute || hasAuthority || hasDate;
|
|
240
|
+
if (isLegal && !hasAuthority) {
|
|
241
|
+
out.push(
|
|
242
|
+
`${where}: carries no \`authority\` — ` +
|
|
243
|
+
(namesStatute
|
|
244
|
+
? 'its `why` names a statute, and a citation with no source is a comment rather than a claim anyone can re-check.'
|
|
245
|
+
: 'a `review_by` with no citation dates nothing.'),
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (isLegal && !hasDate) {
|
|
249
|
+
out.push(
|
|
250
|
+
`${where}: carries no \`review_by\` — ` +
|
|
251
|
+
(namesStatute
|
|
252
|
+
? 'its `why` names a statute, and the citation has no date by which it must be re-read.'
|
|
253
|
+
: 'an undated citation cannot go stale visibly.'),
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
if (!hasDate) continue;
|
|
257
|
+
const date = (r.review_by ?? '').trim();
|
|
258
|
+
if (!ISO_DATE_RE.test(date) || Number.isNaN(Date.parse(date))) {
|
|
259
|
+
out.push(`${where}: \`review_by\` is "${date}", which is not an ISO YYYY-MM-DD date.`);
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
if (date < asOf) {
|
|
263
|
+
out.push(
|
|
264
|
+
`${where}: \`review_by\` lapsed on ${date} (as of ${asOf}) — re-read the citation, ` +
|
|
265
|
+
'then move the date. Overdue is a finding, not a silent pass.',
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return out;
|
|
106
270
|
}
|
|
107
271
|
|
|
108
272
|
export function loadConfig(root = REPO_ROOT): {
|
|
@@ -144,6 +308,15 @@ export function loadConfig(root = REPO_ROOT): {
|
|
|
144
308
|
'situational tier for a legally owed page.',
|
|
145
309
|
);
|
|
146
310
|
}
|
|
311
|
+
const legal = legalRowViolations(legalRowsOf(doc), asOfToday());
|
|
312
|
+
if (legal.length > 0) {
|
|
313
|
+
// A gate that audited a site while shipping an unbacked legal claim of
|
|
314
|
+
// its own would be auditing from a position it has not earned.
|
|
315
|
+
throw new DeadScopeError(
|
|
316
|
+
'check_web_launch_readiness',
|
|
317
|
+
`${CONFIG_REL} ships a legal claim the row cannot back:\n - ${legal.join('\n - ')}`,
|
|
318
|
+
);
|
|
319
|
+
}
|
|
147
320
|
return { checks, siteTypes, regions, escalations };
|
|
148
321
|
}
|
|
149
322
|
|
|
@@ -479,17 +652,44 @@ const requiredLegalPages: Impl = (files) => {
|
|
|
479
652
|
};
|
|
480
653
|
|
|
481
654
|
/**
|
|
482
|
-
* Analytics loads only behind consent.
|
|
655
|
+
* Analytics loads only behind consent — INCLUDING the load order (3.1).
|
|
656
|
+
*
|
|
657
|
+
* The row's own `verification` field asks for the runtime assertion: *"Load the
|
|
658
|
+
* page with consent declined and assert no request to the analytics origin."*
|
|
659
|
+
* A static reader cannot load a page. The strongest assertion it CAN make is
|
|
660
|
+
* source order within one document, so that is what it makes — and where even
|
|
661
|
+
* that is undecidable it returns `unknown` (3.2) rather than a pass.
|
|
483
662
|
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
663
|
+
* What the ordering pass covers, stated so the message can say it: the position
|
|
664
|
+
* of the measurement tag relative to the gate script INSIDE one file. What it
|
|
665
|
+
* does not cover: runtime order, deferred or dynamic imports, order established
|
|
666
|
+
* by a bundler across files, and anything a minifier put on one line — each of
|
|
667
|
+
* which produces an `unknown`, never a silent pass.
|
|
488
668
|
*/
|
|
489
669
|
const ANALYTICS_RE = /(googletagmanager\.com|google-analytics\.com|gtag\(|plausible\.io|matomo|segment\.com|hotjar)/i;
|
|
490
670
|
const CONSENT_RE = /(consent|cookiebanner|cookie-banner|cookieconsent|klaro|usercentrics|borlabs|osano)/i;
|
|
491
|
-
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* A line that can actually LOAD something, as opposed to prose about loading.
|
|
674
|
+
*
|
|
675
|
+
* `consent-order-bad` says the word *consent* in its `<meta description>` and
|
|
676
|
+
* still fires: a sentence describing a gate is not a gate, and a positional
|
|
677
|
+
* detector that counted it would report a correctly ordered page while the tag
|
|
678
|
+
* fires first. Applied to markup only — every line of a `.js` file is code.
|
|
679
|
+
*/
|
|
680
|
+
const EXECUTABLE_LINE_RE = /<script\b|\bsrc\s*=|\bimport\b|\brequire\s*\(|\.js\b/i;
|
|
681
|
+
|
|
682
|
+
/** Past this, a line is a bundler artefact and its internal order is not source order. */
|
|
683
|
+
const MINIFIED_LINE_CHARS = 400;
|
|
684
|
+
|
|
685
|
+
interface ConsentOrder {
|
|
686
|
+
hits: Located[];
|
|
687
|
+
undecided: Located[];
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
function analyseConsentOrder(files: readonly SourceFile[]): ConsentOrder {
|
|
492
691
|
const hits: Located[] = [];
|
|
692
|
+
const undecided: Located[] = [];
|
|
493
693
|
// Prose is excluded from BOTH sides, and the fixture is why: a `.md` file
|
|
494
694
|
// in the build tree describing the consent banner matched CONSENT_RE, so
|
|
495
695
|
// the check reported a pass on a fixture seeded to fire it. Documentation
|
|
@@ -497,21 +697,92 @@ const analyticsAndConsent: Impl = (files) => {
|
|
|
497
697
|
// runs the other way, so an analytics name mentioned in a README must not
|
|
498
698
|
// count as analytics either.
|
|
499
699
|
const code = files.filter((f) => !/\.md$/i.test(f.base));
|
|
500
|
-
const
|
|
501
|
-
|
|
700
|
+
const loadable = (f: SourceFile, line: string): boolean => !isPage(f) || EXECUTABLE_LINE_RE.test(line);
|
|
701
|
+
const firstLoad = (f: SourceFile, re: RegExp): number =>
|
|
702
|
+
f.lines.findIndex((l) => re.test(l) && loadable(f, l));
|
|
703
|
+
const anyConsent = code.some((f) => firstLoad(f, CONSENT_RE) >= 0);
|
|
704
|
+
|
|
502
705
|
for (const f of code) {
|
|
503
|
-
f
|
|
504
|
-
|
|
706
|
+
const a = firstLoad(f, ANALYTICS_RE);
|
|
707
|
+
if (a < 0) continue;
|
|
708
|
+
if (!anyConsent) {
|
|
709
|
+
// Unchanged from the pre-3.1 behaviour, and still the sharpest
|
|
710
|
+
// finding available: nothing in the build can gate the tag.
|
|
711
|
+
f.lines.forEach((line, i) => {
|
|
712
|
+
if (!ANALYTICS_RE.test(line) || !loadable(f, line)) return;
|
|
713
|
+
hits.push({
|
|
714
|
+
file: f.rel,
|
|
715
|
+
line: i + 1,
|
|
716
|
+
text:
|
|
717
|
+
`${line.trim().slice(0, 120)} — analytics is loaded here and no consent mechanism ` +
|
|
718
|
+
'was found anywhere in the build, so nothing can gate it. Source order was not the ' +
|
|
719
|
+
'question: there is no gate to be ordered against.',
|
|
720
|
+
});
|
|
721
|
+
});
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const c = firstLoad(f, CONSENT_RE);
|
|
725
|
+
const aLine = f.lines[a] ?? '';
|
|
726
|
+
if (c < 0) {
|
|
727
|
+
undecided.push({
|
|
728
|
+
file: f.rel,
|
|
729
|
+
line: a + 1,
|
|
730
|
+
text:
|
|
731
|
+
'analytics is loaded here and the consent mechanism lives in another file. Which of ' +
|
|
732
|
+
'the two the browser runs first is decided by the bundler and the page, not by source ' +
|
|
733
|
+
'order across files, and a static read cannot establish it.',
|
|
734
|
+
});
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (c === a) {
|
|
738
|
+
undecided.push({
|
|
739
|
+
file: f.rel,
|
|
740
|
+
line: a + 1,
|
|
741
|
+
text:
|
|
742
|
+
'the analytics tag and the consent gate are on the SAME line, so line order cannot ' +
|
|
743
|
+
'separate them. Ordering is undecidable on this input.',
|
|
744
|
+
});
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (aLine.length > MINIFIED_LINE_CHARS) {
|
|
748
|
+
undecided.push({
|
|
749
|
+
file: f.rel,
|
|
750
|
+
line: a + 1,
|
|
751
|
+
text:
|
|
752
|
+
`this line is ${String(aLine.length)} characters, i.e. minified — the order inside a ` +
|
|
753
|
+
'bundler artefact is not the source order this pass reads.',
|
|
754
|
+
});
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
if (a < c) {
|
|
505
758
|
hits.push({
|
|
506
759
|
file: f.rel,
|
|
507
|
-
line:
|
|
760
|
+
line: a + 1,
|
|
508
761
|
text:
|
|
509
|
-
`${
|
|
510
|
-
|
|
762
|
+
`${aLine.trim().slice(0, 120)} — the analytics tag is at line ${String(a + 1)}, ` +
|
|
763
|
+
`ABOVE the consent gate at line ${String(c + 1)} of the same file, so it is reached ` +
|
|
764
|
+
'before consent can be recorded. Source order inside this file is what was checked; ' +
|
|
765
|
+
'runtime order, dynamic imports and cross-file bundler order were not.',
|
|
511
766
|
});
|
|
512
|
-
}
|
|
767
|
+
}
|
|
513
768
|
}
|
|
514
|
-
return hits;
|
|
769
|
+
return { hits, undecided };
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const analyticsAndConsent: Impl = (files) => analyseConsentOrder(files).hits;
|
|
773
|
+
|
|
774
|
+
/**
|
|
775
|
+
* Checks that can report `unknown`, and the probe that decides when they do.
|
|
776
|
+
*
|
|
777
|
+
* A separate table rather than a widened `Impl` signature: the invariant that an
|
|
778
|
+
* EMPTY `Located[]` means "applied and found nothing" is what keeps the
|
|
779
|
+
* silent-green defect out of `IMPLS`, and folding a third state into that return
|
|
780
|
+
* type would put the invariant back in play for all eight implementations.
|
|
781
|
+
*/
|
|
782
|
+
type Undecide = (files: readonly SourceFile[]) => Located[];
|
|
783
|
+
|
|
784
|
+
export const UNDECIDABLE: Readonly<Record<string, Undecide>> = {
|
|
785
|
+
'analytics-and-consent-wiring': (files) => analyseConsentOrder(files).undecided,
|
|
515
786
|
};
|
|
516
787
|
|
|
517
788
|
/** First line containing `needle`, 1-indexed; 1 when absent (an absence has no line). */
|
|
@@ -560,6 +831,7 @@ export function audit(
|
|
|
560
831
|
const skipped: Skipped[] = [];
|
|
561
832
|
const passed: string[] = [];
|
|
562
833
|
const unimplemented: string[] = [];
|
|
834
|
+
const unknown: Undecided[] = [];
|
|
563
835
|
const escalated: Escalated[] = [];
|
|
564
836
|
// ONE walk for every check. Eight walkers over the same bytes is eight
|
|
565
837
|
// times the IO and eight chances to disagree about what counts as a file.
|
|
@@ -586,7 +858,11 @@ export function audit(
|
|
|
586
858
|
impl === undefined
|
|
587
859
|
? scanIndexability(buildDir).hits
|
|
588
860
|
: impl(files);
|
|
589
|
-
|
|
861
|
+
const undecided = UNDECIDABLE[c.id]?.(files) ?? [];
|
|
862
|
+
for (const u of undecided) {
|
|
863
|
+
unknown.push({ check: c.id, tier, location: `${u.file}:${String(u.line)}`, reason: u.text });
|
|
864
|
+
}
|
|
865
|
+
if (hits.length === 0 && undecided.length === 0) {
|
|
590
866
|
passed.push(c.id);
|
|
591
867
|
continue;
|
|
592
868
|
}
|
|
@@ -609,6 +885,7 @@ export function audit(
|
|
|
609
885
|
skipped,
|
|
610
886
|
passed,
|
|
611
887
|
unimplemented,
|
|
888
|
+
unknown,
|
|
612
889
|
escalated,
|
|
613
890
|
scanned_files: files.length,
|
|
614
891
|
};
|
|
@@ -637,6 +914,15 @@ export function render(r: Report): string {
|
|
|
637
914
|
out.push(` verification: ${x.verification}`);
|
|
638
915
|
}
|
|
639
916
|
}
|
|
917
|
+
if (r.unknown.length > 0) {
|
|
918
|
+
// Printed with the findings rather than with the passes, because an
|
|
919
|
+
// undecided check is an open question about the site — not a clearance.
|
|
920
|
+
out.push('', `UNDECIDED — applicable, audited, and not decidable from this build (${String(r.unknown.length)})`);
|
|
921
|
+
for (const u of r.unknown) {
|
|
922
|
+
out.push(` ${u.location} ${u.check}`);
|
|
923
|
+
out.push(` unknown: ${u.reason}`);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
640
926
|
if (r.passed.length > 0) out.push('', `PASSED: ${r.passed.join(', ')}`);
|
|
641
927
|
if (r.unimplemented.length > 0) {
|
|
642
928
|
out.push('', `NOT YET IMPLEMENTED (applicable, not audited): ${r.unimplemented.join(', ')}`);
|
|
@@ -697,7 +983,11 @@ export function main(argv: string[] = process.argv.slice(2), root = REPO_ROOT):
|
|
|
697
983
|
process.stdout.write(`${render(r)}\n`);
|
|
698
984
|
}
|
|
699
985
|
const blocking = r.findings.filter((f) => f.tier === 'critical' || f.tier === 'high');
|
|
700
|
-
|
|
986
|
+
// An UNDECIDED applicable check exits non-zero whatever its tier. Exiting 0
|
|
987
|
+
// would report the run as clean on a question the instrument never answered,
|
|
988
|
+
// which is the same silent-green failure as counting an unimplemented check
|
|
989
|
+
// as passed — the tier axis grades findings, and this is not one.
|
|
990
|
+
return blocking.length > 0 || r.unknown.length > 0 ? 1 : 0;
|
|
701
991
|
}
|
|
702
992
|
|
|
703
993
|
if (path.resolve(process.argv[1] ?? '') === path.resolve(HERE)) {
|
|
@@ -89,6 +89,8 @@ import {
|
|
|
89
89
|
} from './_lib/promotion_evidence.js';
|
|
90
90
|
import { MERGE_AUTHORITY_ROADMAP, readMergeAuthorityStatus } from './_lib/promotion_capability.js';
|
|
91
91
|
import { assertNotSemanticNoOp, isSemanticNoOp, SemanticNoOpError } from './_lib/semantic_noop.js';
|
|
92
|
+
import { buildRunSpec } from './_lib/experiment_binding.js';
|
|
93
|
+
import { ExperimentDriftError, assertUnchanged, freeze } from './_lib/experiment_freeze.js';
|
|
92
94
|
import {
|
|
93
95
|
RECIPES,
|
|
94
96
|
byteCompare,
|
|
@@ -635,6 +637,15 @@ function guardAbort(e: unknown): number | undefined {
|
|
|
635
637
|
);
|
|
636
638
|
return EXIT_GUARD_ABORT;
|
|
637
639
|
}
|
|
640
|
+
if (e instanceof ExperimentDriftError) {
|
|
641
|
+
process.stderr.write(
|
|
642
|
+
`evolution_lab: ABORTED on experiment drift (moved: ${e.changed.join(', ')})\n` +
|
|
643
|
+
` ${e.message}\n` +
|
|
644
|
+
' The run cloned one record set and would have scored another. Nothing is\n' +
|
|
645
|
+
' reported: an ROI figure over a set that moved mid-run is not a comparison.\n',
|
|
646
|
+
);
|
|
647
|
+
return EXIT_GUARD_ABORT;
|
|
648
|
+
}
|
|
638
649
|
if (e instanceof BudgetConfigError) {
|
|
639
650
|
process.stderr.write(`evolution_lab: ABORTED — ${(e as Error).message}\n`);
|
|
640
651
|
return EXIT_GUARD_ABORT;
|
|
@@ -767,10 +778,38 @@ function verbRun(argv: readonly string[]): number {
|
|
|
767
778
|
return fail(`vector ${vf} rejected — ${(e as Error).message}`);
|
|
768
779
|
}
|
|
769
780
|
}
|
|
781
|
+
// The experiment binding. Frozen HERE, before the first clone, and asserted
|
|
782
|
+
// again after the loop. The window it closes is real and specific: the loop
|
|
783
|
+
// below clones from `record`, then RE-READS the same file from disk to
|
|
784
|
+
// evaluate it, so a record rewritten in between produces a run that cloned
|
|
785
|
+
// one thing and scored another. Every guard already in this verb fires
|
|
786
|
+
// before the run starts and never looks again.
|
|
787
|
+
const ordered = [...files].sort(byteCompare);
|
|
788
|
+
const readRecord = (f: string): string => {
|
|
789
|
+
try {
|
|
790
|
+
return fs.readFileSync(f, 'utf-8');
|
|
791
|
+
} catch {
|
|
792
|
+
// Unreadable is a STATE of the record set here, not a reason to
|
|
793
|
+
// fail: the loop below already reports the specific file with its
|
|
794
|
+
// own message, and a freeze that pre-empted it would replace a
|
|
795
|
+
// precise diagnostic with a vaguer one. The marker still moves the
|
|
796
|
+
// digest, so a record that appears or vanishes mid-run is drift.
|
|
797
|
+
return '\u0000<unreadable>';
|
|
798
|
+
}
|
|
799
|
+
};
|
|
800
|
+
let frozenSpec;
|
|
801
|
+
let frozenDigest: string;
|
|
802
|
+
try {
|
|
803
|
+
frozenSpec = buildRunSpec(ordered, readRecord);
|
|
804
|
+
frozenDigest = freeze(frozenSpec);
|
|
805
|
+
} catch (e) {
|
|
806
|
+
return fail(`experiment spec could not be built — ${(e as Error).message}`);
|
|
807
|
+
}
|
|
808
|
+
|
|
770
809
|
const seen = new Set<string>();
|
|
771
810
|
const ids: string[] = [];
|
|
772
811
|
const results: CascadeResult[] = [];
|
|
773
|
-
for (const f of
|
|
812
|
+
for (const f of ordered) {
|
|
774
813
|
let record: CandidateRecord;
|
|
775
814
|
try {
|
|
776
815
|
record = loadRecord(f);
|
|
@@ -821,6 +860,19 @@ function verbRun(argv: readonly string[]): number {
|
|
|
821
860
|
results.push(result);
|
|
822
861
|
}
|
|
823
862
|
|
|
863
|
+
// Assert BEFORE the verdicts are rendered, and before the cascade-abort
|
|
864
|
+
// return below: a drifted run has no verdict worth printing, whichever way
|
|
865
|
+
// the cascade came out.
|
|
866
|
+
try {
|
|
867
|
+
assertUnchanged(frozenDigest, frozenSpec, buildRunSpec(ordered, readRecord));
|
|
868
|
+
} catch (e) {
|
|
869
|
+
const aborted = guardAbort(e);
|
|
870
|
+
if (aborted !== undefined) {
|
|
871
|
+
return aborted;
|
|
872
|
+
}
|
|
873
|
+
throw e;
|
|
874
|
+
}
|
|
875
|
+
|
|
824
876
|
for (const r of results) {
|
|
825
877
|
if (r.outcome === 'abort') {
|
|
826
878
|
process.stdout.write(
|
|
@@ -72,7 +72,7 @@ interface FnSpec {
|
|
|
72
72
|
cells: Record<string, string>;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
// Each condense.
|
|
75
|
+
// Each condense.ts dispatcher generator → what it emits, for which host(s),
|
|
76
76
|
// via which mechanism. Universal generators (symlink fan-out to every host
|
|
77
77
|
// that consumes the artifact) list all consuming hosts explicitly. This is the
|
|
78
78
|
// one place the host×artifact mechanism is asserted; the coverage guard below
|
|
@@ -222,10 +222,17 @@ export function render_md(matrix: Record<string, Record<string, string>>): strin
|
|
|
222
222
|
const lines: string[] = [
|
|
223
223
|
'# Capability matrix — what works on which host',
|
|
224
224
|
'',
|
|
225
|
-
'> **Generated
|
|
226
|
-
'>
|
|
227
|
-
'>
|
|
228
|
-
'>
|
|
225
|
+
'> **Generated — still generated, not hand-maintained prose wearing a',
|
|
226
|
+
'> generated header.** Emitted by `src/scripts/generate_capability_matrix.ts`;',
|
|
227
|
+
'> do NOT hand-edit. Derived from the `generate_tools()` projection logic in',
|
|
228
|
+
'> `src/scripts/condense.ts` (each cell traces to a `generate_*` dispatcher',
|
|
229
|
+
'> call). Drift-checked in CI (`--check`).',
|
|
230
|
+
'>',
|
|
231
|
+
'> Until 2026-09-03 this header named two Python-era generator paths that',
|
|
232
|
+
'> have not existed since ADR-200 ended that era, so a reader checking the',
|
|
233
|
+
'> provenance of this table found dead paths and no way to tell whether',
|
|
234
|
+
'> anything still produced it. They are described rather than reproduced',
|
|
235
|
+
'> here, so every path this header names resolves.',
|
|
229
236
|
'',
|
|
230
237
|
'Cells: **✅ native** (host consumes the artifact directly — symlink /',
|
|
231
238
|
'native dir) · **🔁 adapter** (projected through a host-specific',
|
|
@@ -337,8 +344,8 @@ interface ParsedArgs {
|
|
|
337
344
|
class _ArgExit extends Error {}
|
|
338
345
|
|
|
339
346
|
function _argError(msg: string): never {
|
|
340
|
-
process.stderr.write('usage: generate_capability_matrix
|
|
341
|
-
process.stderr.write(`generate_capability_matrix
|
|
347
|
+
process.stderr.write('usage: generate_capability_matrix [-h] [--check] [--quiet]\n');
|
|
348
|
+
process.stderr.write(`generate_capability_matrix: error: ${msg}\n`);
|
|
342
349
|
process.exitCode = 2;
|
|
343
350
|
throw new _ArgExit();
|
|
344
351
|
}
|
|
@@ -347,7 +354,7 @@ function parse_args(argv: string[]): ParsedArgs {
|
|
|
347
354
|
const out: ParsedArgs = { check: false, quiet: false };
|
|
348
355
|
for (const a of argv) {
|
|
349
356
|
if (a === '-h' || a === '--help') {
|
|
350
|
-
process.stdout.write('usage: generate_capability_matrix
|
|
357
|
+
process.stdout.write('usage: generate_capability_matrix [-h] [--check] [--quiet]\n');
|
|
351
358
|
process.exitCode = 0;
|
|
352
359
|
throw new _ArgExit();
|
|
353
360
|
} else if (a === '--check') {
|
|
@@ -383,7 +390,7 @@ export function main(argv: string[] | null = null): number {
|
|
|
383
390
|
const missing = coverage_guard();
|
|
384
391
|
if (missing.length > 0) {
|
|
385
392
|
process.stderr.write(
|
|
386
|
-
'❌ generate_capability_matrix: condense.
|
|
393
|
+
'❌ generate_capability_matrix: condense.ts dispatcher has ' +
|
|
387
394
|
`generator(s) not mapped in _FN_SPEC: ${_pyListRepr(missing)}. Add an _FN_SPEC ` +
|
|
388
395
|
'entry so the matrix stays derived (never silently drift).\n',
|
|
389
396
|
);
|