@event4u/agent-config 9.28.0 → 9.30.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/AGENTS.md +6 -6
- package/CHANGELOG.md +172 -5
- package/README.md +2 -1
- package/agents/templates/.ai-council.yml.example +55 -24
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/cost/report.md +2 -2
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/adversarial.md +8 -7
- package/dist/agent-src/commands/team/delegate.md +14 -12
- package/dist/agent-src/commands/team/review.md +9 -8
- package/dist/agent-src/commands/team/status.md +9 -8
- package/dist/agent-src/commands/team.md +29 -19
- package/dist/agent-src/contexts/execution/auto-dispatch-classification.md +88 -2
- package/dist/agent-src/contexts/execution/auto-orchestration-activation.md +29 -21
- package/dist/agent-src/contexts/execution/host-capability-manifest.md +26 -15
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +22 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +65 -1
- package/dist/agent-src/contexts/execution/subagent-routing.md +13 -11
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +25 -0
- package/dist/agent-src/contexts/execution/subagent-steering.md +6 -3
- package/dist/agent-src/rules/delegation-policy.md +11 -7
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/rules/settings-ask-protocol.md +8 -7
- package/dist/agent-src/rules/token-budget-discipline.md +1 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/forensics-report/SKILL.md +114 -0
- package/dist/agent-src/skills/forensics-report/evals/triggers.json +17 -0
- package/dist/agent-src/skills/premortem/SKILL.md +30 -1
- package/dist/agent-src/skills/reasoning-orchestrator/SKILL.md +2 -1
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +17 -5
- package/dist/agent-src/skills/subagent-orchestration/schemas/subagent-status.json +61 -3
- package/dist/agent-src/skills/test-case-discovery/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +4 -4
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +23 -0
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-22GCIJ2O.js → chunk-66OVWGB5.js} +1 -1
- package/dist/cli-delegate/{chunk-7OMQCH4V.js → chunk-BTJNRUX3.js} +22 -35
- package/dist/cli-delegate/{chunk-DVRS2KVR.js → chunk-DDUNNFYX.js} +24 -17
- package/dist/cli-delegate/{chunk-PQSAQKHW.js → chunk-EQY4FLJB.js} +3 -3
- package/dist/cli-delegate/{chunk-2BE5I2MN.js → chunk-IYTGIG2Y.js} +155 -91
- package/dist/cli-delegate/{chunk-GB2FSUEE.js → chunk-LCFJIQ4J.js} +32 -0
- package/dist/cli-delegate/{chunk-VSYT7OHO.js → chunk-LVRYBKTI.js} +31 -2
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/{chunk-JXARCGVN.js → chunk-MP3JT3IU.js} +1 -1
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +2803 -137
- package/dist/cli-delegate/cmd_doctor.js +10 -5
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +88 -8
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +3 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +2 -8
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +72 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -2
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +39 -2
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +12 -4
- package/dist/hooks/dispatch.js +2278 -611
- package/dist/install/install.mjs +56 -17
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +4122 -699
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +54 -0
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +147 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +12 -7
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/shared/settingsCarveOut.js +0 -9
- package/dist/shared/settingsCarveOut.js.map +1 -1
- package/dist/shared/settingsClasses.js +11 -9
- package/dist/shared/settingsClasses.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +58 -12
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/agent-src/templates/AGENTS.md +4 -4
- package/src/config/agent-settings.template.yml +88 -66
- package/src/config/discovery/packs.yml +11 -0
- package/src/config/discovery/workspaces.yml +1 -1
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +31 -44
- package/src/scripts/_cli/cmd_doctor.ts +65 -48
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/agent_settings.ts +58 -0
- package/src/scripts/_lib/auto_dispatch.corpus.test.ts +1 -1
- package/src/scripts/_lib/auto_dispatch.ts +23 -18
- package/src/scripts/_lib/capsule_trigger.ts +124 -0
- package/src/scripts/_lib/host_capability.ts +63 -6
- package/src/scripts/_lib/judgment_ladder.ts +634 -0
- package/src/scripts/_lib/orchestration_gate.ts +8 -0
- package/src/scripts/_lib/orchestration_record.ts +78 -10
- package/src/scripts/_lib/orchestration_savings.ts +1 -1
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +354 -13
- package/src/scripts/_lib/self_repair_store.ts +28 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +341 -0
- package/src/scripts/_lib/subagent_response.ts +94 -0
- package/src/scripts/_lib/subagent_spawn.ts +13 -0
- package/src/scripts/_lib/subagent_steering.ts +9 -4
- package/src/scripts/_lib/tier_budget_routing.ts +6 -1
- package/src/scripts/_lib/worker_budget.ts +37 -2
- package/src/scripts/ai_council/clients.ts +126 -55
- package/src/scripts/ai_council/config.ts +94 -6
- package/src/scripts/ai_council/handoff.ts +113 -0
- package/src/scripts/ai_council/modes.ts +19 -4
- package/src/scripts/ai_council/orchestrator.ts +105 -0
- package/src/scripts/ai_council/quorum.ts +84 -0
- package/src/scripts/ai_council/session.ts +44 -0
- package/src/scripts/ai_council/transport_resolver.ts +127 -1
- package/src/scripts/ai_team/availability.ts +110 -0
- package/src/scripts/ai_team/config.ts +34 -14
- package/src/scripts/ai_team/team_dispatch.ts +71 -29
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_critic_protocol.ts +396 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +219 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +24 -1
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/condense.ts +162 -2
- package/src/scripts/council_cli.ts +401 -25
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/forensics_report.ts +333 -0
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +106 -35
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_discovery_vocabulary.ts +2 -0
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_never_silent.ts +276 -0
- package/src/scripts/lint_no_activation_gates.ts +271 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +12 -1
- package/src/scripts/rebuild_hook_bundle.ts +178 -0
- package/src/scripts/release.ts +74 -2
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +118 -36
- package/src/scripts/score_skill_selection.ts +19 -5
- package/src/scripts/self_repair_cli.ts +337 -59
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/scripts/team_events_spike.ts +271 -0
- package/src/scripts/team_review_gate_hook.ts +31 -9
- package/src/server/schemas/settings.ts +24 -17
- package/src/shared/settingsCarveOut.ts +0 -10
- package/src/shared/settingsClasses.ts +11 -9
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-DmR0mbk4.js +0 -26
- package/dist/ui/assets/index-DmR0mbk4.js.map +0 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Hook-injection byte bench + CI gate (road-to-token-economy-cache Phase 3.2)
|
|
4
|
+
* — the latency harness's twin for the INJECTION side.
|
|
5
|
+
*
|
|
6
|
+
* Runs every concern bound on the `claude` platform against the committed
|
|
7
|
+
* fixture envelope of each slot it is bound to (tests/fixtures/hooks/
|
|
8
|
+
* <event>.json), in-process via CONCERN_REGISTRY with the hook-stdin
|
|
9
|
+
* override, under AGENT_CONFIG_REPLAY=1 (state writes skipped). Measures the
|
|
10
|
+
* bytes of the stdout payload fields a concern injects — `reason` +
|
|
11
|
+
* `additional_context` + `context` — and gates them against
|
|
12
|
+
* `src/config/hook-token-budget.json`:
|
|
13
|
+
*
|
|
14
|
+
* - a concern whose emission exceeds its row (or the default cap) → RED
|
|
15
|
+
* - an EMITTING concern with no explicit row above the default is fine;
|
|
16
|
+
* the default cap IS its row — but a breach names the missing row so
|
|
17
|
+
* the fix is a registered decision, not a silent default bump
|
|
18
|
+
* - per-slot SUM over all bound concerns > the slot cap → RED
|
|
19
|
+
*
|
|
20
|
+
* HONEST SCOPE, stated up front: concerns are conditional-silence by design —
|
|
21
|
+
* a generic fixture triggers few of them, so most measure 0 bytes here. The
|
|
22
|
+
* gate therefore enforces the cap on whatever DOES fire under the committed
|
|
23
|
+
* fixtures (deterministic, CI-reproducible), while the census mode
|
|
24
|
+
* (`--record`) measures REAL sessions: it appends counts-only lines
|
|
25
|
+
* ({ts, slot, concern, bytes}) to agents/runtime/state/injection-census.jsonl
|
|
26
|
+
* — the repeat-injection-census blocker's instrument. Claiming the fixture
|
|
27
|
+
* numbers are the live distribution would be the coverage inflation this
|
|
28
|
+
* repo's gate discipline refuses; the two modes exist because neither
|
|
29
|
+
* substitutes for the other.
|
|
30
|
+
*
|
|
31
|
+
* Class A: in-process, no daemon, no network. Exit 0 green · 1 breach ·
|
|
32
|
+
* 2 unusable invocation. Dead-scope guarded: zero concerns run = RED.
|
|
33
|
+
*
|
|
34
|
+
* Usage:
|
|
35
|
+
* ./scripts-run src/scripts/bench_hook_injection [--format text|json]
|
|
36
|
+
* [--record] [--platform claude]
|
|
37
|
+
*/
|
|
38
|
+
import * as fs from 'node:fs';
|
|
39
|
+
import * as path from 'node:path';
|
|
40
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
41
|
+
|
|
42
|
+
import { CONCERN_REGISTRY } from './hooks/concern_registry.js';
|
|
43
|
+
import { setHookStdinOverride, clearHookStdinOverride } from './hooks/hook_stdin.js';
|
|
44
|
+
import { _load_yaml, _resolve_concerns, EVENT_VOCABULARY, type JsonObject } from './hooks/dispatch_hook.js';
|
|
45
|
+
|
|
46
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
47
|
+
const _IN_BUNDLE = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
|
|
48
|
+
const REPO_ROOT = path.resolve(
|
|
49
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
50
|
+
...(_IN_BUNDLE ? ['..'] : ['..', '..']),
|
|
51
|
+
);
|
|
52
|
+
const MANIFEST = path.join(REPO_ROOT, 'src', 'scripts', 'hook_manifest.yaml');
|
|
53
|
+
const BUDGET = path.join(REPO_ROOT, 'src', 'config', 'hook-token-budget.json');
|
|
54
|
+
const FIXTURES = path.join(REPO_ROOT, 'tests', 'fixtures', 'hooks');
|
|
55
|
+
const CENSUS_FILE = path.join('agents', 'runtime', 'state', 'injection-census.jsonl');
|
|
56
|
+
|
|
57
|
+
export interface Measurement {
|
|
58
|
+
slot: string;
|
|
59
|
+
concern: string;
|
|
60
|
+
bytes: number;
|
|
61
|
+
cap: number;
|
|
62
|
+
breach: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface Budget {
|
|
66
|
+
default_cap_bytes: number;
|
|
67
|
+
per_concern_caps_bytes: Record<string, number | string>;
|
|
68
|
+
per_slot_sum_caps_bytes: Record<string, number | string>;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function capFor(budget: Budget, concern: string): number {
|
|
72
|
+
const v = budget.per_concern_caps_bytes[concern];
|
|
73
|
+
return typeof v === 'number' ? v : budget.default_cap_bytes;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Run one registered concern in-process with the fixture envelope; return payload bytes. */
|
|
77
|
+
export function runConcernOnce(
|
|
78
|
+
script: string,
|
|
79
|
+
args: readonly string[],
|
|
80
|
+
envelope: JsonObject,
|
|
81
|
+
): number {
|
|
82
|
+
const main = CONCERN_REGISTRY[script];
|
|
83
|
+
if (main === undefined) return -1; // not in registry — parity tests own that failure
|
|
84
|
+
let out = '';
|
|
85
|
+
const prevWrite = process.stdout.write;
|
|
86
|
+
const prevErr = process.stderr.write;
|
|
87
|
+
const prevArgv = process.argv;
|
|
88
|
+
const prevReplay = process.env['AGENT_CONFIG_REPLAY'];
|
|
89
|
+
setHookStdinOverride(JSON.stringify(envelope));
|
|
90
|
+
process.env['AGENT_CONFIG_REPLAY'] = '1';
|
|
91
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
92
|
+
process.stdout.write = ((chunk: any, enc?: any, cb?: any): boolean => {
|
|
93
|
+
out += typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8');
|
|
94
|
+
const callback = typeof enc === 'function' ? enc : cb;
|
|
95
|
+
if (typeof callback === 'function') callback();
|
|
96
|
+
return true;
|
|
97
|
+
}) as typeof process.stdout.write;
|
|
98
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
99
|
+
process.stderr.write = ((chunk: any, enc?: any, cb?: any): boolean => {
|
|
100
|
+
const callback = typeof enc === 'function' ? enc : cb;
|
|
101
|
+
if (typeof callback === 'function') callback();
|
|
102
|
+
return true;
|
|
103
|
+
}) as typeof process.stderr.write;
|
|
104
|
+
try {
|
|
105
|
+
process.argv = [prevArgv[0] as string, script, ...args, '--platform', 'claude'];
|
|
106
|
+
main([...args, '--platform', 'claude']);
|
|
107
|
+
} catch {
|
|
108
|
+
/* a crashing concern injects nothing — latency/parity suites own crashes */
|
|
109
|
+
} finally {
|
|
110
|
+
process.stdout.write = prevWrite;
|
|
111
|
+
process.stderr.write = prevErr;
|
|
112
|
+
process.argv = prevArgv;
|
|
113
|
+
if (prevReplay === undefined) delete process.env['AGENT_CONFIG_REPLAY'];
|
|
114
|
+
else process.env['AGENT_CONFIG_REPLAY'] = prevReplay;
|
|
115
|
+
clearHookStdinOverride();
|
|
116
|
+
}
|
|
117
|
+
const text = out.trim();
|
|
118
|
+
if (!text) return 0;
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(text) as Record<string, unknown>;
|
|
121
|
+
let bytes = 0;
|
|
122
|
+
for (const key of ['reason', 'additional_context', 'context']) {
|
|
123
|
+
const v = parsed[key];
|
|
124
|
+
if (typeof v === 'string') bytes += Buffer.byteLength(v, 'utf-8');
|
|
125
|
+
}
|
|
126
|
+
return bytes;
|
|
127
|
+
} catch {
|
|
128
|
+
return Buffer.byteLength(text, 'utf-8'); // non-JSON stdout counts whole
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function bench(opts: { platform?: string; budgetPath?: string } = {}): { measurements: Measurement[]; slotSums: Record<string, { bytes: number; cap: number; breach: boolean }>; errors: string[] } {
|
|
133
|
+
const platform = opts.platform ?? 'claude';
|
|
134
|
+
const manifest = _load_yaml(MANIFEST);
|
|
135
|
+
const budget = JSON.parse(fs.readFileSync(opts.budgetPath ?? BUDGET, 'utf8')) as Budget;
|
|
136
|
+
const measurements: Measurement[] = [];
|
|
137
|
+
const slotSums: Record<string, { bytes: number; cap: number; breach: boolean }> = {};
|
|
138
|
+
const errors: string[] = [];
|
|
139
|
+
|
|
140
|
+
for (const event of EVENT_VOCABULARY) {
|
|
141
|
+
const fixtureFile = path.join(FIXTURES, `${event}.json`);
|
|
142
|
+
if (!fs.existsSync(fixtureFile)) continue;
|
|
143
|
+
const envelope = JSON.parse(fs.readFileSync(fixtureFile, 'utf8')) as JsonObject;
|
|
144
|
+
const concerns = _resolve_concerns(manifest, platform, event);
|
|
145
|
+
let sum = 0;
|
|
146
|
+
for (const concern of concerns) {
|
|
147
|
+
const bytes = runConcernOnce(String(concern['script']), Array.isArray(concern['args']) ? concern['args'].map(String) : [], envelope);
|
|
148
|
+
if (bytes < 0) {
|
|
149
|
+
errors.push(`${event}/${String(concern['name'])}: not in CONCERN_REGISTRY`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const cap = capFor(budget, String(concern['name']));
|
|
153
|
+
const breach = bytes > cap;
|
|
154
|
+
measurements.push({ slot: event, concern: String(concern['name']), bytes, cap, breach });
|
|
155
|
+
sum += bytes;
|
|
156
|
+
}
|
|
157
|
+
const slotCapRaw = budget.per_slot_sum_caps_bytes[event];
|
|
158
|
+
const slotCap = typeof slotCapRaw === 'number' ? slotCapRaw : Number.MAX_SAFE_INTEGER;
|
|
159
|
+
slotSums[event] = { bytes: sum, cap: slotCap, breach: sum > slotCap };
|
|
160
|
+
}
|
|
161
|
+
return { measurements, slotSums, errors };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function main(argv: readonly string[]): number {
|
|
165
|
+
let format: 'text' | 'json' = 'text';
|
|
166
|
+
let record = false;
|
|
167
|
+
let platform = 'claude';
|
|
168
|
+
for (let i = 0; i < argv.length; i++) {
|
|
169
|
+
const a = argv[i];
|
|
170
|
+
if (a === '--format') format = argv[++i] === 'json' ? 'json' : 'text';
|
|
171
|
+
else if (a === '--record') record = true;
|
|
172
|
+
else if (a === '--platform') platform = argv[++i] ?? platform;
|
|
173
|
+
}
|
|
174
|
+
let result: ReturnType<typeof bench>;
|
|
175
|
+
try {
|
|
176
|
+
result = bench({ platform });
|
|
177
|
+
} catch (err) {
|
|
178
|
+
process.stderr.write(`bench_hook_injection: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
179
|
+
return 2;
|
|
180
|
+
}
|
|
181
|
+
// Dead-scope guard: a bench that ran zero concerns proves nothing (a
|
|
182
|
+
// gate that scans nothing exits green is the recorded failure class).
|
|
183
|
+
if (result.measurements.length === 0) {
|
|
184
|
+
process.stderr.write('bench_hook_injection: DEAD SCOPE — zero concerns ran; fixtures or manifest unreadable\n');
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
if (record) {
|
|
188
|
+
try {
|
|
189
|
+
const file = path.join(process.cwd(), CENSUS_FILE);
|
|
190
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
191
|
+
const ts = new Date().toISOString();
|
|
192
|
+
for (const m of result.measurements) {
|
|
193
|
+
if (m.bytes > 0) {
|
|
194
|
+
fs.appendFileSync(file, JSON.stringify({ ts, slot: m.slot, concern: m.concern, bytes: m.bytes }) + '\n');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
} catch {
|
|
198
|
+
/* census is observability — never fails the bench */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const breaches = result.measurements.filter((m) => m.breach);
|
|
202
|
+
const slotBreaches = Object.entries(result.slotSums).filter(([, s]) => s.breach);
|
|
203
|
+
if (format === 'json') {
|
|
204
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
205
|
+
} else {
|
|
206
|
+
const emitting = result.measurements.filter((m) => m.bytes > 0);
|
|
207
|
+
process.stdout.write(`bench_hook_injection: ${result.measurements.length} concern-slot pairs · ${emitting.length} emitted under the committed fixtures\n`);
|
|
208
|
+
for (const m of emitting) {
|
|
209
|
+
process.stdout.write(` ${m.slot.padEnd(20)} ${m.concern.padEnd(24)} ${String(m.bytes).padStart(6)} B (cap ${m.cap})${m.breach ? ' ❌ BREACH' : ''}\n`);
|
|
210
|
+
}
|
|
211
|
+
for (const [slot, s] of Object.entries(result.slotSums)) {
|
|
212
|
+
if (s.bytes > 0) process.stdout.write(` slot-sum ${slot.padEnd(20)} ${String(s.bytes).padStart(6)} B (cap ${s.cap === Number.MAX_SAFE_INTEGER ? '—' : s.cap})${s.breach ? ' ❌ BREACH' : ''}\n`);
|
|
213
|
+
}
|
|
214
|
+
for (const e of result.errors) process.stdout.write(` warn: ${e}\n`);
|
|
215
|
+
}
|
|
216
|
+
if (breaches.length > 0 || slotBreaches.length > 0) {
|
|
217
|
+
process.stderr.write(`bench_hook_injection: ${breaches.length} concern breach(es), ${slotBreaches.length} slot-sum breach(es) — the budget row is the decision surface (src/config/hook-token-budget.json)\n`);
|
|
218
|
+
return 1;
|
|
219
|
+
}
|
|
220
|
+
return 0;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function _isCliEntry(): boolean {
|
|
224
|
+
if (_IN_BUNDLE) return false;
|
|
225
|
+
if (process.argv[1] === undefined) return false;
|
|
226
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
227
|
+
if (import.meta.url === argvUrl) return true;
|
|
228
|
+
try {
|
|
229
|
+
return fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
230
|
+
} catch {
|
|
231
|
+
return false;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (_isCliEntry()) process.exit(main(process.argv.slice(2)));
|
|
@@ -35,6 +35,7 @@ import * as fs from 'node:fs';
|
|
|
35
35
|
import * as path from 'node:path';
|
|
36
36
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
37
37
|
|
|
38
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
38
39
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
39
40
|
|
|
40
41
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -101,48 +102,81 @@ export function trigger_is_meaningful(value: string): boolean {
|
|
|
101
102
|
return true;
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
|
|
105
|
+
/** Every frontmatter finding for one ADR file. Pure — no I/O, no enumeration. */
|
|
106
|
+
export function check_one(rel: string, text: string): AdrFinding[] {
|
|
107
|
+
const findings: AdrFinding[] = [];
|
|
108
|
+
const fm = parse_frontmatter(text);
|
|
109
|
+
|
|
110
|
+
if (fm === null) {
|
|
111
|
+
findings.push({ file: rel, level: 'error', message: 'no YAML frontmatter block' });
|
|
112
|
+
return findings;
|
|
113
|
+
}
|
|
114
|
+
for (const req of REQUIRED) {
|
|
115
|
+
if (!fm[req]) findings.push({ file: rel, level: 'error', message: `missing \`${req}\`` });
|
|
116
|
+
}
|
|
117
|
+
if (fm['status'] && !ALLOWED_STATUS.has(fm['status'])) {
|
|
118
|
+
findings.push({
|
|
119
|
+
file: rel,
|
|
120
|
+
level: 'error',
|
|
121
|
+
message: `status \`${fm['status']}\` is not one of: ${[...ALLOWED_STATUS].sort().join(', ')}`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const date = fm['date'] ?? '';
|
|
126
|
+
const grandfathered = date !== '' && date < REVIEW_TRIGGER_SINCE;
|
|
127
|
+
const trigger = fm['review_trigger'];
|
|
128
|
+
|
|
129
|
+
if (!trigger) {
|
|
130
|
+
findings.push({
|
|
131
|
+
file: rel,
|
|
132
|
+
level: grandfathered ? 'warn' : 'error',
|
|
133
|
+
message: grandfathered
|
|
134
|
+
? `no \`review_trigger\` (grandfathered — dated ${date}, before ${REVIEW_TRIGGER_SINCE})`
|
|
135
|
+
: `missing \`review_trigger\` — name the CONDITION that would reopen this decision`,
|
|
136
|
+
});
|
|
137
|
+
} else if (!trigger_is_meaningful(trigger)) {
|
|
138
|
+
findings.push({
|
|
139
|
+
file: rel,
|
|
140
|
+
level: grandfathered ? 'warn' : 'error',
|
|
141
|
+
message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
return findings;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Check every ADR in `dir`, optionally recording per-file completeness.
|
|
149
|
+
*
|
|
150
|
+
* The candidate set is every `*.md` in the directory, not the `ADR-*.md` subset:
|
|
151
|
+
* the name filter used to be a bare `continue`, so a decision record saved as
|
|
152
|
+
* `adr-131-foo.md` (lower case) or `131-foo.md` left the corpus without a trace
|
|
153
|
+
* and the gate still printed a green line over the files it did read. It is now
|
|
154
|
+
* an out-of-scope outcome with a reason, and it prints.
|
|
155
|
+
*
|
|
156
|
+
* A `warn`-level finding resolves as `complete`: warnings are allowed by this
|
|
157
|
+
* gate's exit-code contract, so `fail` is reserved for what actually reds it.
|
|
158
|
+
*/
|
|
159
|
+
export function check(dir: string = ADR_DIR, ledger?: GateLedger): AdrFinding[] {
|
|
105
160
|
const findings: AdrFinding[] = [];
|
|
106
161
|
if (!fs.existsSync(dir)) return findings;
|
|
107
162
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const rel = path.relative(REPO_ROOT, path.join(dir, name));
|
|
111
|
-
const fm = parse_frontmatter(fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
163
|
+
const candidates = fs.readdirSync(dir).filter((n) => n.endsWith('.md')).sort();
|
|
164
|
+
ledger?.plan(candidates);
|
|
112
165
|
|
|
113
|
-
|
|
114
|
-
|
|
166
|
+
for (const name of candidates) {
|
|
167
|
+
if (!/^ADR-.*\.md$/.test(name)) {
|
|
168
|
+
ledger?.outOfScope(name, 'not_applicable_kind');
|
|
115
169
|
continue;
|
|
116
170
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
if (fm['status'] && !ALLOWED_STATUS.has(fm['status'])) {
|
|
121
|
-
findings.push({
|
|
122
|
-
file: rel,
|
|
123
|
-
level: 'error',
|
|
124
|
-
message: `status \`${fm['status']}\` is not one of: ${[...ALLOWED_STATUS].sort().join(', ')}`,
|
|
125
|
-
});
|
|
126
|
-
}
|
|
171
|
+
const rel = path.relative(REPO_ROOT, path.join(dir, name));
|
|
172
|
+
const own = check_one(rel, fs.readFileSync(path.join(dir, name), 'utf-8'));
|
|
173
|
+
findings.push(...own);
|
|
127
174
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
findings.push({
|
|
134
|
-
file: rel,
|
|
135
|
-
level: grandfathered ? 'warn' : 'error',
|
|
136
|
-
message: grandfathered
|
|
137
|
-
? `no \`review_trigger\` (grandfathered — dated ${date}, before ${REVIEW_TRIGGER_SINCE})`
|
|
138
|
-
: `missing \`review_trigger\` — name the CONDITION that would reopen this decision`,
|
|
139
|
-
});
|
|
140
|
-
} else if (!trigger_is_meaningful(trigger)) {
|
|
141
|
-
findings.push({
|
|
142
|
-
file: rel,
|
|
143
|
-
level: grandfathered ? 'warn' : 'error',
|
|
144
|
-
message: `\`review_trigger\` is a cadence, not a condition: "${trigger}". A calendar review is ignored; an event fires.`,
|
|
145
|
-
});
|
|
175
|
+
const errors = own.filter((f) => f.level === 'error').length;
|
|
176
|
+
if (errors > 0) {
|
|
177
|
+
ledger?.fail(name, `${String(errors)} frontmatter error(s)`);
|
|
178
|
+
} else {
|
|
179
|
+
ledger?.complete(name);
|
|
146
180
|
}
|
|
147
181
|
}
|
|
148
182
|
return findings;
|
|
@@ -150,10 +184,17 @@ export function check(dir: string = ADR_DIR): AdrFinding[] {
|
|
|
150
184
|
|
|
151
185
|
function main(argv: string[]): number {
|
|
152
186
|
const as_json = argv.includes('--json');
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
187
|
+
const ledger = new GateLedger('check_adr_frontmatter');
|
|
188
|
+
const findings = check(ADR_DIR, ledger);
|
|
189
|
+
// Finalized here, printed later by `report()` (which re-finalizes — the call
|
|
190
|
+
// is pure): the denominator is needed for the scope assertion below, which
|
|
191
|
+
// runs before any output. It is the ledger's own per-file accounting — the
|
|
192
|
+
// ADRs that actually reached a checked outcome — where it used to be a SECOND
|
|
193
|
+
// `readdirSync` of the same directory, independent of the loop that did the
|
|
194
|
+
// work, so the printed count could not disagree with the scan even when the
|
|
195
|
+
// scan had skipped files.
|
|
196
|
+
const tally = ledger.finalize();
|
|
197
|
+
const total = tally.completed + tally.failed;
|
|
157
198
|
|
|
158
199
|
// `check()` returns findings, never a count, so a moved `docs/decisions/`
|
|
159
200
|
// yields an empty list — reported as "0 ADR(s) · 0 error(s)" and green.
|
|
@@ -174,7 +215,9 @@ function main(argv: string[]): number {
|
|
|
174
215
|
}
|
|
175
216
|
|
|
176
217
|
if (as_json) {
|
|
177
|
-
|
|
218
|
+
// The tally rides in the payload rather than on stdout: `ledger.report()`
|
|
219
|
+
// would print beside the JSON and break every parser of this mode.
|
|
220
|
+
process.stdout.write(JSON.stringify({ findings, ledger: tally }, null, 2) + '\n');
|
|
178
221
|
return 0;
|
|
179
222
|
}
|
|
180
223
|
|
|
@@ -185,6 +228,7 @@ function main(argv: string[]): number {
|
|
|
185
228
|
`ADR frontmatter: ${total} ADR(s) · ${errors.length} error(s) · ` +
|
|
186
229
|
`${warns.length} grandfathered without a revisit condition\n`,
|
|
187
230
|
);
|
|
231
|
+
ledger.report();
|
|
188
232
|
for (const e of errors) process.stderr.write(` ❌ ${e.file}: ${e.message}\n`);
|
|
189
233
|
|
|
190
234
|
if (errors.length > 0) {
|
|
@@ -41,6 +41,7 @@ import { fileURLToPath } from "node:url";
|
|
|
41
41
|
import { runGateCli, runSelfTest, type SelfTestCase } from "./_lib/gate_self_test.js";
|
|
42
42
|
import { reportScanned } from "./_lib/scan_scope.js";
|
|
43
43
|
|
|
44
|
+
// ledger-exempt: single remote-ref probe — the entire scope is ONE ls-remote answer (0 or 1 refs) resolved to one aggregate ancestor verdict, and every empty path already publishes its reason via reportScanned allowEmpty; there is no per-target collection to account.
|
|
44
45
|
const DEFAULT_BASE = "main";
|
|
45
46
|
const SELF_TEST_MIN_CASES = 3;
|
|
46
47
|
const SELF_TEST_MIN_REJECT = 1;
|
|
@@ -49,6 +49,7 @@ import * as os from 'node:os';
|
|
|
49
49
|
import * as path from 'node:path';
|
|
50
50
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
51
51
|
|
|
52
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
52
53
|
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
53
54
|
import { assertScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
54
55
|
|
|
@@ -423,20 +424,32 @@ export interface RunResult {
|
|
|
423
424
|
readonly corpora: number;
|
|
424
425
|
/** CSVs actually opened, reported so "checked nothing" cannot read as clean. */
|
|
425
426
|
readonly csvs: number;
|
|
427
|
+
/** Per-corpus completeness accounting — every discovered corpus reaches one outcome. */
|
|
428
|
+
readonly ledger: GateLedger;
|
|
426
429
|
}
|
|
427
430
|
|
|
428
431
|
export function run_checks(todayStamp: number, skillsDir: string = SKILLS_DIR): RunResult {
|
|
429
432
|
const corpora = discover_corpora(skillsDir);
|
|
433
|
+
const ledger = new GateLedger('check_corpus_staleness');
|
|
434
|
+
ledger.plan(corpora.map((c) => c.slug));
|
|
430
435
|
const findings: CorpusFinding[] = [];
|
|
431
436
|
let csvs = 0;
|
|
432
437
|
for (const corpus of corpora) {
|
|
433
|
-
|
|
434
|
-
|
|
438
|
+
const found: CorpusFinding[] = [
|
|
439
|
+
...check_staleness(corpus, todayStamp),
|
|
440
|
+
...check_attribution_pin(corpus),
|
|
441
|
+
];
|
|
435
442
|
const csv = check_csv_integrity(corpus);
|
|
436
|
-
|
|
443
|
+
found.push(...csv.findings);
|
|
437
444
|
csvs += csv.opened;
|
|
445
|
+
findings.push(...found);
|
|
446
|
+
if (found.length > 0) {
|
|
447
|
+
ledger.fail(corpus.slug, `${String(found.length)} corpus finding(s)`);
|
|
448
|
+
} else {
|
|
449
|
+
ledger.complete(corpus.slug);
|
|
450
|
+
}
|
|
438
451
|
}
|
|
439
|
-
return { findings, corpora: corpora.length, csvs };
|
|
452
|
+
return { findings, corpora: corpora.length, csvs, ledger };
|
|
440
453
|
}
|
|
441
454
|
|
|
442
455
|
const HELP = `check_corpus_staleness — offline staleness + CSV-integrity gate for the grounding corpora
|
|
@@ -651,6 +664,7 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
651
664
|
|
|
652
665
|
// gate-coverage contract (src/config/gate-coverage.yml): corpora inspected.
|
|
653
666
|
process.stdout.write(`scanned: ${String(result.corpora)}\n`);
|
|
667
|
+
result.ledger.report();
|
|
654
668
|
|
|
655
669
|
if (result.findings.length > 0) {
|
|
656
670
|
process.stdout.write(`❌ ${String(result.findings.length)} corpus finding(s):\n`);
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Detector corpus gate (P4.3 of road-to-rule-delivery-integrity).
|
|
4
|
+
*
|
|
5
|
+
* Every self-repair detector must carry all THREE fixture classes, and every
|
|
6
|
+
* fixture must behave as its class says:
|
|
7
|
+
*
|
|
8
|
+
* fire a recorded real failure the detector must catch
|
|
9
|
+
* near-miss-fire a real failure phrased unlike the canonical one, which must
|
|
10
|
+
* still be caught — recall at the boundary
|
|
11
|
+
* must-not-fire text that superficially matches and must stay silent
|
|
12
|
+
*
|
|
13
|
+
* The three-class rule is the point of the gate rather than a stylistic one.
|
|
14
|
+
* Precision and recall fail in opposite directions and a single corpus hides
|
|
15
|
+
* that: a detector with `fire` cases only looks perfect while opening records on
|
|
16
|
+
* praise, which is exactly what `detectUserReport` did until 2026-08-09 (it
|
|
17
|
+
* fired on "das ist fine, du hast nichts falsch gemacht"). A missing class is
|
|
18
|
+
* therefore a gate failure, not a warning — the absent half is where the defects
|
|
19
|
+
* live.
|
|
20
|
+
*
|
|
21
|
+
* Registration is explicit: a detector added to `self_repair.ts` without a
|
|
22
|
+
* matching corpus entry fails here, which is the coupling that keeps the corpus
|
|
23
|
+
* from silently lagging the code.
|
|
24
|
+
*/
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
28
|
+
|
|
29
|
+
import * as YAML from 'yaml';
|
|
30
|
+
|
|
31
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
32
|
+
import {
|
|
33
|
+
type DefectFinding,
|
|
34
|
+
type TurnSnapshot,
|
|
35
|
+
detectCouncilClaim,
|
|
36
|
+
detectLanguageMirror,
|
|
37
|
+
detectUserReport,
|
|
38
|
+
} from './_lib/self_repair.js';
|
|
39
|
+
import { assertScanned } from './_lib/scan_scope.js';
|
|
40
|
+
|
|
41
|
+
export const CORPUS_REL = path.join('tests', 'fixtures', 'self-repair-detector-corpus.yml');
|
|
42
|
+
|
|
43
|
+
const GATE = 'check_detector_corpus';
|
|
44
|
+
const CLASSES = ['fire', 'near-miss-fire', 'must-not-fire'] as const;
|
|
45
|
+
type FixtureClass = (typeof CLASSES)[number];
|
|
46
|
+
|
|
47
|
+
/** One corpus entry, in the union of shapes the three detectors need. */
|
|
48
|
+
export interface Fixture {
|
|
49
|
+
text?: string;
|
|
50
|
+
reply?: string;
|
|
51
|
+
pinned_language?: 'de' | 'en';
|
|
52
|
+
tool_commands?: string[];
|
|
53
|
+
note?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type Corpus = Record<string, Partial<Record<FixtureClass, Fixture[]>>>;
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Every detector that must appear in the corpus, with how to run it.
|
|
60
|
+
*
|
|
61
|
+
* Keyed by the `defect_class` the detector produces, so the corpus reads as a
|
|
62
|
+
* list of defect classes rather than of function names.
|
|
63
|
+
*/
|
|
64
|
+
export const DETECTORS: Record<string, (f: Fixture) => DefectFinding | null> = {
|
|
65
|
+
'user-reported': (f) => detectUserReport(f.text ?? f.reply ?? ''),
|
|
66
|
+
'council-availability-claim': (f) => detectCouncilClaim(asTurn(f)),
|
|
67
|
+
'language-mirror': (f) => detectLanguageMirror(asTurn(f)),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
function asTurn(f: Fixture): TurnSnapshot {
|
|
71
|
+
return {
|
|
72
|
+
prompt: f.text ?? '',
|
|
73
|
+
reply: f.reply ?? '',
|
|
74
|
+
toolCommands: f.tool_commands ?? [],
|
|
75
|
+
pinnedLanguage: f.pinned_language ?? null,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export interface Finding {
|
|
80
|
+
detector: string;
|
|
81
|
+
kind: 'missing-class' | 'empty-class' | 'wrong-outcome' | 'unregistered';
|
|
82
|
+
message: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Pure: audit a parsed corpus against the registered detectors. */
|
|
86
|
+
export function auditCorpus(corpus: Corpus): { findings: Finding[]; fixtures: number } {
|
|
87
|
+
const findings: Finding[] = [];
|
|
88
|
+
let fixtures = 0;
|
|
89
|
+
|
|
90
|
+
for (const name of Object.keys(corpus)) {
|
|
91
|
+
if (!(name in DETECTORS)) {
|
|
92
|
+
findings.push({
|
|
93
|
+
detector: name,
|
|
94
|
+
kind: 'unregistered',
|
|
95
|
+
message: `corpus names "${name}", which is not a registered detector`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
for (const [name, run] of Object.entries(DETECTORS)) {
|
|
101
|
+
const entry = corpus[name];
|
|
102
|
+
if (entry === undefined) {
|
|
103
|
+
findings.push({
|
|
104
|
+
detector: name,
|
|
105
|
+
kind: 'missing-class',
|
|
106
|
+
message: `no corpus entry at all — needs all of: ${CLASSES.join(', ')}`,
|
|
107
|
+
});
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
for (const cls of CLASSES) {
|
|
111
|
+
const cases = entry[cls];
|
|
112
|
+
if (cases === undefined) {
|
|
113
|
+
findings.push({
|
|
114
|
+
detector: name,
|
|
115
|
+
kind: 'missing-class',
|
|
116
|
+
message: `missing the \`${cls}\` class`,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (cases.length === 0) {
|
|
121
|
+
// An empty class is the same hole as a missing one wearing a key.
|
|
122
|
+
findings.push({
|
|
123
|
+
detector: name,
|
|
124
|
+
kind: 'empty-class',
|
|
125
|
+
message: `\`${cls}\` is present but empty`,
|
|
126
|
+
});
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const f of cases) {
|
|
130
|
+
fixtures += 1;
|
|
131
|
+
const fired = run(f) !== null;
|
|
132
|
+
const shouldFire = cls !== 'must-not-fire';
|
|
133
|
+
if (fired !== shouldFire) {
|
|
134
|
+
const what = f.text ?? f.reply ?? '(empty)';
|
|
135
|
+
findings.push({
|
|
136
|
+
detector: name,
|
|
137
|
+
kind: 'wrong-outcome',
|
|
138
|
+
message:
|
|
139
|
+
`\`${cls}\` fixture ${shouldFire ? 'did NOT fire' : 'FIRED'}: ` +
|
|
140
|
+
JSON.stringify(what.slice(0, 90)),
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { findings, fixtures };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function loadCorpus(repoRoot: string): Corpus {
|
|
150
|
+
const p = path.join(repoRoot, CORPUS_REL);
|
|
151
|
+
const parsed = YAML.parse(fs.readFileSync(p, 'utf-8')) as { detectors?: Corpus };
|
|
152
|
+
return parsed.detectors ?? {};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function main(repoRoot: string = process.cwd()): number {
|
|
156
|
+
const corpus = loadCorpus(repoRoot);
|
|
157
|
+
const { findings, fixtures } = auditCorpus(corpus);
|
|
158
|
+
|
|
159
|
+
// A corpus that parsed to nothing is the dead-scope case, not a pass.
|
|
160
|
+
assertScanned({
|
|
161
|
+
gate: GATE,
|
|
162
|
+
scanned: fixtures,
|
|
163
|
+
units: 'detector fixtures',
|
|
164
|
+
roots: [CORPUS_REL],
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// Per-detector completeness: every registered detector AND every corpus
|
|
168
|
+
// entry reaches exactly one outcome, so a detector silently absent from the
|
|
169
|
+
// loop above could not read as a pass.
|
|
170
|
+
const ledger = new GateLedger(GATE);
|
|
171
|
+
const names = [...new Set([...Object.keys(DETECTORS), ...Object.keys(corpus)])].sort();
|
|
172
|
+
ledger.plan(names);
|
|
173
|
+
const failing = new Set(findings.map((f) => f.detector));
|
|
174
|
+
for (const name of names) {
|
|
175
|
+
if (failing.has(name)) {
|
|
176
|
+
ledger.fail(name, 'corpus finding(s) against this detector');
|
|
177
|
+
} else {
|
|
178
|
+
ledger.complete(name);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
ledger.report();
|
|
182
|
+
|
|
183
|
+
if (findings.length > 0) {
|
|
184
|
+
process.stderr.write(`${GATE}: ${String(findings.length)} finding(s)\n\n`);
|
|
185
|
+
for (const f of findings) {
|
|
186
|
+
process.stderr.write(` ${f.detector}: ${f.message}\n`);
|
|
187
|
+
}
|
|
188
|
+
process.stderr.write(
|
|
189
|
+
`\nEvery detector needs all three classes (${CLASSES.join(', ')}) and every\n` +
|
|
190
|
+
'fixture must behave as its class says. Precision and recall fail in\n' +
|
|
191
|
+
'opposite directions; a corpus missing one half hides the other.\n',
|
|
192
|
+
);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
`✅ ${GATE}: ${String(Object.keys(DETECTORS).length)} detector(s) × ` +
|
|
198
|
+
`${String(CLASSES.length)} classes, ${String(fixtures)} fixtures, all behaving\n`,
|
|
199
|
+
);
|
|
200
|
+
return 0;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function isCliEntry(): boolean {
|
|
204
|
+
const argv1 = process.argv[1];
|
|
205
|
+
if (argv1 === undefined) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
try {
|
|
209
|
+
return (
|
|
210
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) === fs.realpathSync(path.resolve(argv1))
|
|
211
|
+
);
|
|
212
|
+
} catch {
|
|
213
|
+
return pathToFileURL(argv1).href === import.meta.url;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (isCliEntry()) {
|
|
218
|
+
process.exit(main());
|
|
219
|
+
}
|