@event4u/agent-config 9.22.0 → 9.23.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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +69 -256
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +13 -0
- package/dist/agent-src/contexts/execution/cheap-question-mechanics.md +12 -2
- package/dist/agent-src/contexts/execution/mandated-lines.md +180 -0
- package/dist/agent-src/rules/context-hygiene.md +17 -0
- package/dist/agent-src/rules/downstream-changes.md +26 -0
- package/dist/agent-src/rules/think-before-action.md +25 -0
- package/dist/agent-src/rules/token-budget-discipline.md +46 -3
- package/dist/agent-src/rules/token-efficiency.md +13 -0
- package/dist/agent-src/rules/ui-audit-gate.md +33 -2
- package/dist/agent-src/skills/adversarial-review/SKILL.md +18 -4
- package/dist/agent-src/skills/decision-review/SKILL.md +49 -0
- package/dist/agent-src/skills/judge-artifact-completeness/SKILL.md +5 -1
- package/dist/agent-src/skills/judge-bug-hunter/SKILL.md +16 -0
- package/dist/agent-src/skills/judge-synthesis/SKILL.md +14 -1
- package/dist/agent-src/skills/rule-writing/SKILL.md +33 -0
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +31 -0
- package/dist/agent-src/skills/skill-reviewer/SKILL.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-DVRS2KVR.js +4602 -0
- package/dist/cli-delegate/{chunk-ELMFOQPW.js → chunk-SJQMJI7M.js} +8 -4597
- package/dist/cli-delegate/cmd_export.js +2 -1
- package/dist/cli-delegate/cmd_prune.js +2 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -1
- package/dist/cli-delegate/cmd_settings_set.js +3370 -0
- package/dist/cli-delegate/cmd_sync.js +2 -1
- package/dist/cli-delegate/cmd_uninstall.js +2 -1
- package/dist/cli-delegate/cmd_validate.js +2 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.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 +79 -1
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/scripts/condense.js +47 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/settings.js +82 -0
- package/dist/server/routes/settings.js.map +1 -1
- package/dist/shared/settingsClasses.js +191 -0
- package/dist/shared/settingsClasses.js.map +1 -0
- package/docs/contracts/settings-classes.md +342 -0
- package/docs/guidelines/agent-infra/artifact-drafting-protocol-mechanics.md +23 -2
- package/docs/guidelines/agent-infra/false-green.md +187 -0
- package/docs/guidelines/agent-infra/gate-authoring.md +177 -0
- package/package.json +3 -1
- package/src/config/ci-local-parity.yml +12 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +54 -12
- package/src/config/gate-violation-baselines.json +6 -0
- package/src/config/pack-size-budget.json +4 -3
- package/src/scripts/_cli/cmd_settings_set.ts +509 -0
- package/src/scripts/_dispatch.bash +16 -0
- package/src/scripts/_lib/gate_ledger.ts +295 -0
- package/src/scripts/_lib/gate_result.ts +101 -0
- package/src/scripts/_lib/gate_self_test.ts +129 -0
- package/src/scripts/_lib/measured_render.ts +97 -0
- package/src/scripts/_lib/ratchet_base_ref.ts +373 -0
- package/src/scripts/check_gate_completeness.ts +244 -0
- package/src/scripts/check_gate_coverage.ts +35 -6
- package/src/scripts/check_references.ts +125 -15
- package/src/scripts/check_review_dispositions.ts +14 -1
- package/src/scripts/check_suppression_hygiene.ts +503 -0
- package/src/scripts/condense.ts +47 -2
- package/src/scripts/hook_manifest.yaml +23 -0
- package/src/scripts/hooks/dispatch_hook.ts +65 -1
- package/src/scripts/hooks/host_semantics.ts +140 -0
- package/src/scripts/lint_framework_leakage.ts +147 -15
- package/src/scripts/lint_mandated_lines.ts +339 -0
- package/src/scripts/lint_roadmap_complexity.ts +102 -11
- package/src/scripts/lint_roadmap_family_cap.ts +191 -0
- package/src/scripts/lint_settings_classes.ts +397 -0
- package/src/scripts/lint_token_budget_discipline.ts +109 -1
- package/src/scripts/report_imperative_density.ts +229 -0
- package/src/scripts/verify_physical_move.ts +10 -1
- package/src/server/io/yamlIO.ts +173 -0
- package/src/shared/settingsClasses.ts +235 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Completeness accounting — "planned work reaches exactly one terminal outcome".
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS. `_lib/scan_scope.ts` closed one half of the false-green
|
|
5
|
+
* class: a gate whose *root* is dead now fails instead of printing a green
|
|
6
|
+
* checkmark over zero files. It cannot close the other half. A gate with a live
|
|
7
|
+
* root that enumerates 300 targets, `continue`s past 280 of them on assorted
|
|
8
|
+
* unwritten conditions, and checks 20, still exits 0 — and its output is
|
|
9
|
+
* indistinguishable from a gate that checked all 300. Absence of findings and
|
|
10
|
+
* absence of scanning look alike, which is the exact failure this repository
|
|
11
|
+
* has recorded four separate times from its own history.
|
|
12
|
+
*
|
|
13
|
+
* The ledger makes the per-target accounting explicit and mechanical:
|
|
14
|
+
*
|
|
15
|
+
* 1. The gate **plans** every target it intends to consider.
|
|
16
|
+
* 2. Every planned target must reach exactly ONE terminal outcome —
|
|
17
|
+
* {@link GateLedger.complete}, {@link GateLedger.fail},
|
|
18
|
+
* {@link GateLedger.skip}, or {@link GateLedger.outOfScope}.
|
|
19
|
+
* 3. {@link GateLedger.finalize} throws when any planned target reached none.
|
|
20
|
+
*
|
|
21
|
+
* A planned target with no terminal outcome is a silent `continue` — the defect
|
|
22
|
+
* this module exists to catch. Naming it costs one call at the top of the loop
|
|
23
|
+
* body and one at each exit.
|
|
24
|
+
*
|
|
25
|
+
* **Why skip reasons are a closed union rather than a free string.** Every gate
|
|
26
|
+
* in this tree already skips targets, and every one of them explains itself in
|
|
27
|
+
* prose that no reader can aggregate: "excluded dir", "too big", "binary?",
|
|
28
|
+
* "(no creds)". A closed vocabulary makes the skips countable and comparable
|
|
29
|
+
* across the estate, and — the load-bearing part — makes an unlisted reason a
|
|
30
|
+
* *typecheck* failure rather than a code review someone has to notice. Adding a
|
|
31
|
+
* genuinely new reason is a visible one-line diff to {@link SKIP_REASON_MESSAGE},
|
|
32
|
+
* which is the review surface a free string never gets.
|
|
33
|
+
*
|
|
34
|
+
* Scope note: this is deliberately not a scan-root guard. Use
|
|
35
|
+
* {@link import('./scan_scope.js').assertScanned} for the root, and this for
|
|
36
|
+
* the items under it. The two are complementary and a gate wants both.
|
|
37
|
+
*/
|
|
38
|
+
import process from 'node:process';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The closed vocabulary of reasons a planned target may go unchecked.
|
|
42
|
+
*
|
|
43
|
+
* Seeded from the reasons this repository's own gates already emit in prose, so
|
|
44
|
+
* the first adopters translate rather than invent. Every code carries a
|
|
45
|
+
* one-sentence message in {@link SKIP_REASON_MESSAGE}; a code without one does
|
|
46
|
+
* not typecheck, because the record type is keyed on this union.
|
|
47
|
+
*/
|
|
48
|
+
export type GateSkipReason =
|
|
49
|
+
| 'excluded_directory'
|
|
50
|
+
| 'size_limit'
|
|
51
|
+
| 'binary_content'
|
|
52
|
+
| 'missing_credentials'
|
|
53
|
+
| 'rules_unavailable'
|
|
54
|
+
| 'manifest_absent'
|
|
55
|
+
| 'no_applicable_files'
|
|
56
|
+
| 'disabled_by_configuration'
|
|
57
|
+
| 'generated_artifact'
|
|
58
|
+
| 'dead_scan_root'
|
|
59
|
+
| 'declared_exemption'
|
|
60
|
+
| 'not_applicable_kind';
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* One sentence per skip code, printed verbatim in the ledger's own output.
|
|
64
|
+
*
|
|
65
|
+
* The sentence is the audit surface: a reader who sees `size_limit ×41` in a
|
|
66
|
+
* gate's report should be able to decide, without opening the gate, whether 41
|
|
67
|
+
* unchecked targets is expected.
|
|
68
|
+
*/
|
|
69
|
+
export const SKIP_REASON_MESSAGE: Record<GateSkipReason, string> = {
|
|
70
|
+
excluded_directory: 'the target sits under a directory this gate deliberately does not walk',
|
|
71
|
+
size_limit: 'the target exceeds the size ceiling this gate reads within',
|
|
72
|
+
binary_content: 'the target is not decodable text, so the check does not apply to it',
|
|
73
|
+
missing_credentials: 'the check needs a credential that is not present in this environment',
|
|
74
|
+
rules_unavailable: 'the rule or policy corpus this check compares against could not be loaded',
|
|
75
|
+
manifest_absent: 'the manifest that declares what to check for this target does not exist',
|
|
76
|
+
no_applicable_files: 'the target resolved to zero files the check applies to',
|
|
77
|
+
disabled_by_configuration: 'a setting in this repository turns this check off for the target',
|
|
78
|
+
generated_artifact: 'the target is generated output whose source is checked instead',
|
|
79
|
+
dead_scan_root: 'the root this target was enumerated from no longer exists',
|
|
80
|
+
declared_exemption: 'the target carries an explicit, reviewable exemption from this check',
|
|
81
|
+
not_applicable_kind: 'the target is a kind of artifact this check does not apply to',
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/** Terminal outcomes a planned target can reach. Exactly one, exactly once. */
|
|
85
|
+
export type GateOutcomeKind = 'completed' | 'failed' | 'skipped' | 'out_of_scope';
|
|
86
|
+
|
|
87
|
+
/** What {@link GateLedger.finalize} returns once every planned target is accounted for. */
|
|
88
|
+
export interface GateLedgerTally {
|
|
89
|
+
planned: number;
|
|
90
|
+
completed: number;
|
|
91
|
+
skipped: number;
|
|
92
|
+
failed: number;
|
|
93
|
+
out_of_scope: number;
|
|
94
|
+
/** Planned targets that reached no terminal outcome. Non-zero means `finalize` threw. */
|
|
95
|
+
unaccounted: number;
|
|
96
|
+
/** Skip counts per code, for the report line and for downstream auditing. */
|
|
97
|
+
skips_by_reason: Partial<Record<GateSkipReason, number>>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Raised when planned work did not reach a terminal outcome. */
|
|
101
|
+
export class UnaccountedTargetsError extends Error {
|
|
102
|
+
readonly gate: string;
|
|
103
|
+
readonly targets: readonly string[];
|
|
104
|
+
|
|
105
|
+
constructor(gate: string, targets: readonly string[]) {
|
|
106
|
+
const shown = targets.slice(0, 20);
|
|
107
|
+
const more = targets.length - shown.length;
|
|
108
|
+
super(
|
|
109
|
+
`${gate}: ${String(targets.length)} planned target(s) reached no terminal outcome — ` +
|
|
110
|
+
'the gate enumerated them and then neither checked, failed, skipped, nor ' +
|
|
111
|
+
'excluded them, so its green result covers less than it appears to. ' +
|
|
112
|
+
`Unaccounted: ${shown.join(', ')}${more > 0 ? ` … and ${String(more)} more` : ''}`,
|
|
113
|
+
);
|
|
114
|
+
this.name = 'UnaccountedTargetsError';
|
|
115
|
+
this.gate = gate;
|
|
116
|
+
this.targets = targets;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Raised when the ledger is used incorrectly.
|
|
122
|
+
*
|
|
123
|
+
* A double terminal outcome, or an outcome for a target that was never planned,
|
|
124
|
+
* means the accounting itself is wrong — and an accounting bug that passes
|
|
125
|
+
* quietly would restore exactly the blind spot this module removes. So these
|
|
126
|
+
* throw rather than warn.
|
|
127
|
+
*/
|
|
128
|
+
export class LedgerUsageError extends Error {
|
|
129
|
+
constructor(message: string) {
|
|
130
|
+
super(message);
|
|
131
|
+
this.name = 'LedgerUsageError';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
interface Resolution {
|
|
136
|
+
kind: GateOutcomeKind;
|
|
137
|
+
reason: string;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Per-target completeness ledger for one gate run.
|
|
142
|
+
*
|
|
143
|
+
* ```ts
|
|
144
|
+
* const ledger = new GateLedger('lint_example');
|
|
145
|
+
* ledger.plan(files);
|
|
146
|
+
* for (const file of files) {
|
|
147
|
+
* if (isGenerated(file)) { ledger.outOfScope(file, 'generated_artifact'); continue; }
|
|
148
|
+
* if (tooBig(file)) { ledger.skip(file, 'size_limit'); continue; }
|
|
149
|
+
* const finding = check(file);
|
|
150
|
+
* if (finding) ledger.fail(file, finding); else ledger.complete(file);
|
|
151
|
+
* }
|
|
152
|
+
* ledger.report(); // throws if anything was left unaccounted
|
|
153
|
+
* ```
|
|
154
|
+
*/
|
|
155
|
+
export class GateLedger {
|
|
156
|
+
readonly gate: string;
|
|
157
|
+
/** Insertion-ordered so the unaccounted list reads in enumeration order. */
|
|
158
|
+
private readonly planned = new Map<string, Resolution | undefined>();
|
|
159
|
+
|
|
160
|
+
constructor(gate: string) {
|
|
161
|
+
this.gate = gate;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Declare one or more targets this run intends to consider. */
|
|
165
|
+
plan(target: string | readonly string[]): void {
|
|
166
|
+
const targets = typeof target === 'string' ? [target] : target;
|
|
167
|
+
for (const t of targets) {
|
|
168
|
+
if (this.planned.has(t)) {
|
|
169
|
+
throw new LedgerUsageError(
|
|
170
|
+
`${this.gate}: target planned twice: ${t}. A duplicate plan makes the ` +
|
|
171
|
+
'denominator wrong in the direction that hides work.',
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
this.planned.set(t, undefined);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The target was checked and satisfied the gate. */
|
|
179
|
+
complete(target: string): void {
|
|
180
|
+
this.resolve(target, { kind: 'completed', reason: '' });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The target was checked and violated the gate. `reason` is the finding text. */
|
|
184
|
+
fail(target: string, reason: string): void {
|
|
185
|
+
this.resolve(target, { kind: 'failed', reason });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The target was NOT checked; `reason` names why, from the closed vocabulary. */
|
|
189
|
+
skip(target: string, reason: GateSkipReason): void {
|
|
190
|
+
this.resolve(target, { kind: 'skipped', reason });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** The target does not belong to this gate's corpus; `reason` names why. */
|
|
194
|
+
outOfScope(target: string, reason: GateSkipReason): void {
|
|
195
|
+
this.resolve(target, { kind: 'out_of_scope', reason });
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private resolve(target: string, resolution: Resolution): void {
|
|
199
|
+
if (!this.planned.has(target)) {
|
|
200
|
+
throw new LedgerUsageError(
|
|
201
|
+
`${this.gate}: ${resolution.kind} recorded for an unplanned target: ${target}. ` +
|
|
202
|
+
'Every target reaching an outcome must first be planned, or the ' +
|
|
203
|
+
'denominator understates the work.',
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
const existing = this.planned.get(target);
|
|
207
|
+
if (existing !== undefined) {
|
|
208
|
+
throw new LedgerUsageError(
|
|
209
|
+
`${this.gate}: target resolved twice (${existing.kind} then ${resolution.kind}): ${target}.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
this.planned.set(target, resolution);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Planned targets that have not yet reached a terminal outcome, in enumeration order. */
|
|
216
|
+
unaccountedTargets(): string[] {
|
|
217
|
+
const out: string[] = [];
|
|
218
|
+
for (const [target, resolution] of this.planned) {
|
|
219
|
+
if (resolution === undefined) {
|
|
220
|
+
out.push(target);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Close the ledger and return the tally.
|
|
228
|
+
*
|
|
229
|
+
* @throws {UnaccountedTargetsError} when any planned target reached no outcome.
|
|
230
|
+
*/
|
|
231
|
+
finalize(): GateLedgerTally {
|
|
232
|
+
const unaccounted = this.unaccountedTargets();
|
|
233
|
+
if (unaccounted.length > 0) {
|
|
234
|
+
throw new UnaccountedTargetsError(this.gate, unaccounted);
|
|
235
|
+
}
|
|
236
|
+
const tally: GateLedgerTally = {
|
|
237
|
+
planned: this.planned.size,
|
|
238
|
+
completed: 0,
|
|
239
|
+
skipped: 0,
|
|
240
|
+
failed: 0,
|
|
241
|
+
out_of_scope: 0,
|
|
242
|
+
unaccounted: 0,
|
|
243
|
+
skips_by_reason: {},
|
|
244
|
+
};
|
|
245
|
+
for (const resolution of this.planned.values()) {
|
|
246
|
+
if (resolution === undefined) {
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
if (resolution.kind === 'completed') {
|
|
250
|
+
tally.completed += 1;
|
|
251
|
+
} else if (resolution.kind === 'failed') {
|
|
252
|
+
tally.failed += 1;
|
|
253
|
+
} else {
|
|
254
|
+
if (resolution.kind === 'skipped') {
|
|
255
|
+
tally.skipped += 1;
|
|
256
|
+
} else {
|
|
257
|
+
tally.out_of_scope += 1;
|
|
258
|
+
}
|
|
259
|
+
const code = resolution.reason as GateSkipReason;
|
|
260
|
+
tally.skips_by_reason[code] = (tally.skips_by_reason[code] ?? 0) + 1;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return tally;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Finalize and print the denominator on the success path.
|
|
268
|
+
*
|
|
269
|
+
* The line goes to stdout unconditionally, exactly as
|
|
270
|
+
* {@link import('./scan_scope.js').reportScanned} does and for the same
|
|
271
|
+
* recorded reason: CI passes `--quiet`, and a count only visible without it
|
|
272
|
+
* is not a count. It is deliberately NOT the `scanned: <N>` line
|
|
273
|
+
* `check_gate_coverage` parses — that stays the single machine-read
|
|
274
|
+
* coverage signal, and this is the human-auditable completeness breakdown
|
|
275
|
+
* beside it.
|
|
276
|
+
*
|
|
277
|
+
* @throws {UnaccountedTargetsError} via {@link finalize}.
|
|
278
|
+
*/
|
|
279
|
+
report(write: (chunk: string) => unknown = process.stdout.write.bind(process.stdout)): GateLedgerTally {
|
|
280
|
+
const tally = this.finalize();
|
|
281
|
+
const checked = tally.completed + tally.failed;
|
|
282
|
+
const parts = [
|
|
283
|
+
`scanned=${String(checked)}`,
|
|
284
|
+
`planned=${String(tally.planned)}`,
|
|
285
|
+
`skipped=${String(tally.skipped + tally.out_of_scope)}`,
|
|
286
|
+
];
|
|
287
|
+
write(`${this.gate} ledger: ${parts.join(' ')}\n`);
|
|
288
|
+
const codes = Object.keys(tally.skips_by_reason).sort() as GateSkipReason[];
|
|
289
|
+
for (const code of codes) {
|
|
290
|
+
const n = tally.skips_by_reason[code] ?? 0;
|
|
291
|
+
write(` ${code} ×${String(n)} — ${SKIP_REASON_MESSAGE[code]}\n`);
|
|
292
|
+
}
|
|
293
|
+
return tally;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Estate-level result handling — telling "found violations" from "broke".
|
|
3
|
+
*
|
|
4
|
+
* A gate exits non-zero for two structurally different reasons, and the exit
|
|
5
|
+
* code alone cannot separate them:
|
|
6
|
+
*
|
|
7
|
+
* - **verdict** — it ran, inspected its corpus, and found violations. The
|
|
8
|
+
* estate is being measured correctly; something in the tree is wrong.
|
|
9
|
+
* - **estate-invalidating** — it could not measure at all: the scan root is
|
|
10
|
+
* dead, planned targets went unaccounted, the ratchet's base ref is
|
|
11
|
+
* unreachable, the ledger was mis-used. Nothing was certified, and a report
|
|
12
|
+
* built from this run is not a report.
|
|
13
|
+
*
|
|
14
|
+
* Conflating them costs in both directions. Treated as a verdict, an
|
|
15
|
+
* invalidated run sends a contributor hunting for a violation that does not
|
|
16
|
+
* exist. Treated as a crash, a real violation gets triaged as flakiness. This
|
|
17
|
+
* repository has recorded three traps of the second kind — estate invalidation
|
|
18
|
+
* misreported as a per-gate red.
|
|
19
|
+
*
|
|
20
|
+
* The classification reads the failure's own error name, which the structural
|
|
21
|
+
* errors already carry, rather than pattern-matching prose. A gate that throws
|
|
22
|
+
* something else is `crashed`: unknown, non-green, and reported as itself.
|
|
23
|
+
*
|
|
24
|
+
* **Gaming risk.** Nothing here can be gamed by a passing gate — the module
|
|
25
|
+
* only ever downgrades confidence. The real risk is the opposite: a future
|
|
26
|
+
* error class that IS estate-invalidating but is not listed below reads as a
|
|
27
|
+
* plain crash. Mitigated by keying on the shared `_lib` error names and by the
|
|
28
|
+
* fixture that pins each one; the residual is that a new structural error must
|
|
29
|
+
* be added here deliberately.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Error names that mean the measurement is void, not that the tree is dirty.
|
|
34
|
+
*
|
|
35
|
+
* Sourced from the shared gate primitives: `scan_scope`, `gate_ledger`, and
|
|
36
|
+
* `ratchet_base_ref`.
|
|
37
|
+
*/
|
|
38
|
+
export const ESTATE_INVALIDATING_ERRORS: readonly string[] = [
|
|
39
|
+
'DeadScopeError',
|
|
40
|
+
'UnaccountedTargetsError',
|
|
41
|
+
'LedgerUsageError',
|
|
42
|
+
'BaseRefUnavailableError',
|
|
43
|
+
'ProbeOverflowError',
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
export type GateOutcome = 'clean' | 'violations' | 'crashed' | 'estate_invalid';
|
|
47
|
+
|
|
48
|
+
export interface GateRunSignal {
|
|
49
|
+
/** Process exit code, or `null` when the gate never produced one. */
|
|
50
|
+
exitCode: number | null;
|
|
51
|
+
/** Combined stdout + stderr, used only to spot a named structural error. */
|
|
52
|
+
output: string;
|
|
53
|
+
/** True when the runner itself could not execute the gate. */
|
|
54
|
+
spawnFailed?: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** True when `output` names one of the structural error classes. */
|
|
58
|
+
export function namesEstateInvalidatingError(output: string): boolean {
|
|
59
|
+
return ESTATE_INVALIDATING_ERRORS.some((name) => output.includes(name));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify one gate run.
|
|
64
|
+
*
|
|
65
|
+
* Fail-closed on a null exit code: a gate that produced no verdict was not run,
|
|
66
|
+
* and a gate that was not run is not a passing gate.
|
|
67
|
+
*/
|
|
68
|
+
export function classifyGateRun(signal: GateRunSignal): GateOutcome {
|
|
69
|
+
if (signal.spawnFailed === true || signal.exitCode === null) {
|
|
70
|
+
return 'crashed';
|
|
71
|
+
}
|
|
72
|
+
if (namesEstateInvalidatingError(signal.output)) {
|
|
73
|
+
return 'estate_invalid';
|
|
74
|
+
}
|
|
75
|
+
return signal.exitCode === 0 ? 'clean' : 'violations';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** True when this outcome must never contribute to a green aggregate. */
|
|
79
|
+
export function blocksGreenAggregate(outcome: GateOutcome): boolean {
|
|
80
|
+
return outcome !== 'clean';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* One-line explanation, so the estate report says WHICH kind of failure it is.
|
|
85
|
+
*/
|
|
86
|
+
export function describeOutcome(id: string, outcome: GateOutcome): string {
|
|
87
|
+
switch (outcome) {
|
|
88
|
+
case 'clean':
|
|
89
|
+
return `${id}: clean`;
|
|
90
|
+
case 'violations':
|
|
91
|
+
return `${id}: found violations — the measurement is sound, the tree is not`;
|
|
92
|
+
case 'estate_invalid':
|
|
93
|
+
return (
|
|
94
|
+
`${id}: ESTATE INVALID — it could not measure (dead scope, unaccounted targets, ` +
|
|
95
|
+
'unreachable base ref, or ledger misuse). Nothing was certified by this run, so ' +
|
|
96
|
+
'no report built on it is a report'
|
|
97
|
+
);
|
|
98
|
+
case 'crashed':
|
|
99
|
+
return `${id}: crashed with no verdict — not run, therefore not passing`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Self-test harness — a gate proving, on demand, that its rejections fire.
|
|
3
|
+
*
|
|
4
|
+
* WHY. Every gate in this tree has fixtures in `tests/`, and those fixtures run
|
|
5
|
+
* in CI on the test job. That leaves one gap: nothing proves the gate binary a
|
|
6
|
+
* contributor actually invokes still rejects anything. A gate whose detection
|
|
7
|
+
* silently stopped matching — a regex that no longer fires, a scan root that
|
|
8
|
+
* moved, a suppression that widened — passes its unit tests against imported
|
|
9
|
+
* functions while the CLI it ships reports clean forever.
|
|
10
|
+
*
|
|
11
|
+
* `--self-test` closes that: the gate builds known-bad fixtures in a temporary
|
|
12
|
+
* directory and invokes ITSELF against them, asserting each rejection fires.
|
|
13
|
+
*
|
|
14
|
+
* **The floor is the load-bearing part.** A self-test is itself a checker, so a
|
|
15
|
+
* truncated case list must fail rather than print success — otherwise deleting
|
|
16
|
+
* cases is the cheapest way to a green self-test. {@link runSelfTest} refuses
|
|
17
|
+
* to pass below its declared minimum, and the minimum is a constant in the
|
|
18
|
+
* gate's own source where a reviewer sees it change.
|
|
19
|
+
*
|
|
20
|
+
* **Gaming risk.** The obvious degenerate pass is a case whose `expect` is
|
|
21
|
+
* `accept` and whose fixture is empty — it asserts nothing and still counts
|
|
22
|
+
* toward the floor. Mitigated by requiring at least one `reject` case and by
|
|
23
|
+
* counting reject cases separately in the floor check; the residual is that no
|
|
24
|
+
* harness can judge whether a fixture is *representative*, which stays a human
|
|
25
|
+
* read at review time.
|
|
26
|
+
*/
|
|
27
|
+
import { spawnSync } from 'node:child_process';
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import process from 'node:process';
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Invoke a gate's real CLI against a fixture.
|
|
33
|
+
*
|
|
34
|
+
* Deliberately shells out rather than calling `main()` in-process: the thing
|
|
35
|
+
* under test is the binary a contributor runs, including its argv parsing and
|
|
36
|
+
* its entry guard. An in-process call would skip exactly the layers that have
|
|
37
|
+
* silently no-opped in this repository before.
|
|
38
|
+
*/
|
|
39
|
+
export function runGateCli(
|
|
40
|
+
repoRoot: string,
|
|
41
|
+
scriptRelPath: string,
|
|
42
|
+
args: readonly string[],
|
|
43
|
+
cwd: string,
|
|
44
|
+
): number {
|
|
45
|
+
const tsx = path.join(
|
|
46
|
+
repoRoot,
|
|
47
|
+
'node_modules',
|
|
48
|
+
'.bin',
|
|
49
|
+
process.platform === 'win32' ? 'tsx.cmd' : 'tsx',
|
|
50
|
+
);
|
|
51
|
+
const res = spawnSync(tsx, [path.join(repoRoot, scriptRelPath), ...args], {
|
|
52
|
+
cwd,
|
|
53
|
+
encoding: 'utf-8',
|
|
54
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
55
|
+
// The child must not recurse into its own self-test.
|
|
56
|
+
env: { ...process.env, GATE_SELF_TEST_CHILD: '1' },
|
|
57
|
+
});
|
|
58
|
+
return res.status ?? 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SelfTestCase {
|
|
62
|
+
/** Short description, printed per case. */
|
|
63
|
+
name: string;
|
|
64
|
+
/** `reject` — the gate must fail on this fixture. `accept` — it must pass. */
|
|
65
|
+
expect: 'reject' | 'accept';
|
|
66
|
+
/** Runs the fixture and returns the gate's exit code. */
|
|
67
|
+
run: () => number;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface SelfTestOptions {
|
|
71
|
+
gate: string;
|
|
72
|
+
cases: readonly SelfTestCase[];
|
|
73
|
+
/** Minimum total cases. Below this, the run FAILS regardless of results. */
|
|
74
|
+
minCases: number;
|
|
75
|
+
/** Minimum `reject` cases. A suite that only proves passes proves nothing. */
|
|
76
|
+
minRejectCases?: number;
|
|
77
|
+
write?: (chunk: string) => unknown;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Run a gate's self-test suite.
|
|
82
|
+
*
|
|
83
|
+
* @returns process exit code — 0 when every case behaved and the floor held.
|
|
84
|
+
*/
|
|
85
|
+
export function runSelfTest(opts: SelfTestOptions): number {
|
|
86
|
+
const write = opts.write ?? process.stdout.write.bind(process.stdout);
|
|
87
|
+
const minReject = opts.minRejectCases ?? 1;
|
|
88
|
+
const rejectCases = opts.cases.filter((c) => c.expect === 'reject').length;
|
|
89
|
+
|
|
90
|
+
if (opts.cases.length < opts.minCases || rejectCases < minReject) {
|
|
91
|
+
write(
|
|
92
|
+
`❌ ${opts.gate} --self-test: ${String(opts.cases.length)} case(s) ` +
|
|
93
|
+
`(${String(rejectCases)} rejecting) is below the declared floor of ` +
|
|
94
|
+
`${String(opts.minCases)} (${String(minReject)} rejecting). A truncated self-test ` +
|
|
95
|
+
'must fail rather than print success — deleting cases is otherwise the cheapest ' +
|
|
96
|
+
'route to a green one.\n',
|
|
97
|
+
);
|
|
98
|
+
return 1;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let failed = 0;
|
|
102
|
+
for (const testCase of opts.cases) {
|
|
103
|
+
let exit: number;
|
|
104
|
+
try {
|
|
105
|
+
exit = testCase.run();
|
|
106
|
+
} catch (e) {
|
|
107
|
+
write(`❌ ${opts.gate} --self-test: ${testCase.name} — threw: ${String(e)}\n`);
|
|
108
|
+
failed += 1;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const behaved = testCase.expect === 'reject' ? exit !== 0 : exit === 0;
|
|
112
|
+
if (behaved) {
|
|
113
|
+
write(`✅ ${testCase.name} (expected ${testCase.expect}, exit ${String(exit)})\n`);
|
|
114
|
+
} else {
|
|
115
|
+
write(
|
|
116
|
+
`❌ ${testCase.name}: expected the gate to ${testCase.expect} this fixture, ` +
|
|
117
|
+
`got exit ${String(exit)}. The gate's detection has stopped firing on a case ` +
|
|
118
|
+
'it is supposed to catch.\n',
|
|
119
|
+
);
|
|
120
|
+
failed += 1;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
write(
|
|
125
|
+
`\n${opts.gate} --self-test: ${String(opts.cases.length - failed)}/${String(opts.cases.length)} ` +
|
|
126
|
+
`case(s) behaved (${String(rejectCases)} rejecting, floor ${String(opts.minCases)})\n`,
|
|
127
|
+
);
|
|
128
|
+
return failed > 0 ? 1 : 0;
|
|
129
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render-only-measured — a category with no measurement is never a number.
|
|
3
|
+
*
|
|
4
|
+
* WHY. The recorded failure: the always-budget report printed a share for a
|
|
5
|
+
* dimension it counted **zero** artefacts in. `0 / 0` became `0.0%`, and `0.0%`
|
|
6
|
+
* reads as a measurement that came back low — the most confident possible way
|
|
7
|
+
* to say nothing. Nobody looking at the page could tell the difference between
|
|
8
|
+
* "we measured this and it is at zero" and "we never measured this at all".
|
|
9
|
+
*
|
|
10
|
+
* The invariant is one line: **an unmeasured category renders as absent or
|
|
11
|
+
* explicitly "not measured", never as a zero and never as a computed share.**
|
|
12
|
+
* A zero is a claim; absence of measurement is not.
|
|
13
|
+
*
|
|
14
|
+
* The second half is the gap list. A coverage number published alone invites
|
|
15
|
+
* the reader to assume the remainder is small and boring. Naming the
|
|
16
|
+
* un-measured artefacts beside the covered count costs one line and removes the
|
|
17
|
+
* assumption — see {@link renderCoverage}.
|
|
18
|
+
*
|
|
19
|
+
* **Gaming risk.** A generator can still bypass this module and interpolate its
|
|
20
|
+
* own `${(n/d*100).toFixed(1)}%`. Nothing here can stop that; the mitigation is
|
|
21
|
+
* the paired test assertion (a generator handed an unmeasured dimension must
|
|
22
|
+
* emit no percentage) plus review. The residual is a generator that neither
|
|
23
|
+
* imports this nor carries that test, which is why the invariant is written
|
|
24
|
+
* into the authoring guideline rather than only into this file.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** How an unmeasured category is spelled. Exported so tests can pin it. */
|
|
28
|
+
export const NOT_MEASURED = 'not measured';
|
|
29
|
+
|
|
30
|
+
export interface ShareOptions {
|
|
31
|
+
/** Decimal places for a real share. Default 1. */
|
|
32
|
+
precision?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Set false when the dimension was not measured at all, even though a
|
|
35
|
+
* denominator happens to be available. A denominator alone does not prove
|
|
36
|
+
* the measurement ran.
|
|
37
|
+
*/
|
|
38
|
+
measured?: boolean;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A percentage, or {@link NOT_MEASURED} when there is nothing to divide.
|
|
43
|
+
*
|
|
44
|
+
* Never returns `0.0%` for an empty denominator — that is the whole point.
|
|
45
|
+
*/
|
|
46
|
+
export function renderShare(numerator: number, denominator: number, opts: ShareOptions = {}): string {
|
|
47
|
+
if (opts.measured === false || denominator <= 0 || !Number.isFinite(denominator)) {
|
|
48
|
+
return NOT_MEASURED;
|
|
49
|
+
}
|
|
50
|
+
if (!Number.isFinite(numerator)) {
|
|
51
|
+
return NOT_MEASURED;
|
|
52
|
+
}
|
|
53
|
+
return `${((numerator / denominator) * 100).toFixed(opts.precision ?? 1)}%`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** A count, or {@link NOT_MEASURED} when the category was never measured. */
|
|
57
|
+
export function renderCount(value: number | null | undefined, measured = true): string {
|
|
58
|
+
if (!measured || value === null || value === undefined || !Number.isFinite(value)) {
|
|
59
|
+
return NOT_MEASURED;
|
|
60
|
+
}
|
|
61
|
+
return String(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface CoverageLine {
|
|
65
|
+
label: string;
|
|
66
|
+
covered: number;
|
|
67
|
+
total: number;
|
|
68
|
+
/** The un-covered artefacts, named. Empty array means genuinely none. */
|
|
69
|
+
gaps: readonly string[];
|
|
70
|
+
/** Cap on how many gap names are printed inline. Default 8. */
|
|
71
|
+
maxGaps?: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A coverage number WITH its gap list.
|
|
76
|
+
*
|
|
77
|
+
* The gap list is not optional formatting. A coverage number published alone
|
|
78
|
+
* lets the reader assume the remainder is small and boring; naming it removes
|
|
79
|
+
* the assumption at the cost of one line. When the count is capped for length,
|
|
80
|
+
* the true remainder is still stated — a silently truncated gap list reads as
|
|
81
|
+
* "covered everything" exactly like the number it was meant to qualify.
|
|
82
|
+
*/
|
|
83
|
+
export function renderCoverage(line: CoverageLine): string {
|
|
84
|
+
const share = renderShare(line.covered, line.total);
|
|
85
|
+
const head = `${line.label}: ${String(line.covered)}/${String(line.total)} (${share})`;
|
|
86
|
+
if (line.total <= 0) {
|
|
87
|
+
return `${line.label}: ${NOT_MEASURED} — the population is empty, so there is no coverage to report`;
|
|
88
|
+
}
|
|
89
|
+
if (line.gaps.length === 0) {
|
|
90
|
+
return `${head} — no gaps`;
|
|
91
|
+
}
|
|
92
|
+
const cap = line.maxGaps ?? 8;
|
|
93
|
+
const shown = line.gaps.slice(0, cap);
|
|
94
|
+
const more = line.gaps.length - shown.length;
|
|
95
|
+
const tail = more > 0 ? `, … and ${String(more)} more` : '';
|
|
96
|
+
return `${head} — not covered: ${shown.join(', ')}${tail}`;
|
|
97
|
+
}
|