@event4u/agent-config 12.0.0 → 13.0.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 +273 -0
- package/README.md +1 -1
- package/agents/templates/.ai-council.yml.example +46 -3
- package/dist/agent-src/commands/cost/report.md +23 -0
- package/dist/agent-src/contexts/execution/subagent-routing.md +148 -33
- package/dist/agent-src/contexts/model-recommendations.md +6 -4
- package/dist/agent-src/contexts/subagent-configuration.md +69 -7
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/design-fidelity.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +10 -5
- package/dist/agent-src/rules/evaluator-independence.md +27 -5
- package/dist/agent-src/rules/git-history-discipline.md +5 -1
- package/dist/agent-src/rules/low-impact-corpus-privacy-floor.md +1 -0
- package/dist/agent-src/rules/missing-skill-recovery.md +89 -0
- package/dist/agent-src/rules/ui-audit-gate.md +9 -4
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +30 -2
- package/dist/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/dist/agent-src/skills/authz-review/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/SKILL.md +4 -0
- package/dist/agent-src/skills/design-system-capture/references/snapshot-preference-order.md +77 -0
- package/dist/agent-src/skills/merge-conflicts/SKILL.md +4 -0
- package/dist/agent-src/skills/prompt-optimizer/SKILL.md +17 -1
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +11 -11
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +4 -0
- package/dist/agent-src/skills/threat-modeling/SKILL.md +3 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +41 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-NN4KW4HZ.js → chunk-3B72LSZL.js} +123 -2338
- package/dist/cli-delegate/{chunk-55WZN5VF.js → chunk-EVTY7ISR.js} +2 -2
- package/dist/cli-delegate/chunk-N7FEST6D.js +3895 -0
- package/dist/cli-delegate/{chunk-L565YFXH.js → chunk-XURJ4AQC.js} +566 -298
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +44 -23
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +10625 -8541
- package/dist/install/install.mjs +754 -484
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1302 -167
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/model_tier.js +9 -0
- package/dist/scripts/_lib/model_tier.js.map +1 -1
- package/dist/scripts/condense.js +50 -0
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/schemas/settings.js +2 -2
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/MIGRATION.md +20 -1
- package/docs/guidelines/design-fidelity-mechanics.md +21 -0
- package/docs/guidelines/design-handover-extraction.md +87 -0
- package/docs/guidelines/prompt-templates.md +24 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +33 -0
- package/src/agent-src/scripts/roadmap_gates.ts +30 -2
- package/src/agent-src/scripts/update_roadmap_progress.ts +47 -1
- package/src/config/evaluator-budgets.json +9 -8
- package/src/config/gate-coverage.yml +63 -11
- package/src/config/gate-violation-baselines.json +12 -0
- package/src/config/hook-token-budget.json +15 -2
- package/src/config/preamble-payload-budget.json +28 -0
- package/src/scripts/_dispatch.bash +10 -0
- package/src/scripts/_lib/bench_ab_complexity.ts +439 -0
- package/src/scripts/_lib/bench_ab_safety_tier.ts +142 -0
- package/src/scripts/_lib/bench_ab_scoring_v2.ts +28 -3
- package/src/scripts/_lib/bench_ab_search_adherence.ts +423 -0
- package/src/scripts/_lib/bench_ab_size_claim.ts +206 -0
- package/src/scripts/_lib/bench_ab_workspace.ts +111 -0
- package/src/scripts/_lib/git_common_dir.ts +173 -0
- package/src/scripts/_lib/model_tier.ts +9 -0
- package/src/scripts/_lib/release_highlights.ts +108 -7
- package/src/scripts/_lib/skill_catalogue.ts +1115 -0
- package/src/scripts/_lib/tier_budget_routing.ts +31 -331
- package/src/scripts/ai_council/clients.ts +197 -18
- package/src/scripts/ai_council/config.ts +16 -1
- package/src/scripts/ai_council/low_impact.ts +14 -1
- package/src/scripts/ai_council/orchestrator.ts +14 -1
- package/src/scripts/ai_council/transport_resolver.ts +32 -2
- package/src/scripts/bench_ab_v2_complexity.ts +252 -0
- package/src/scripts/bench_ab_v2_run.ts +3 -42
- package/src/scripts/bench_ab_v2_safety.ts +296 -0
- package/src/scripts/bench_ab_v2_search.ts +343 -0
- package/src/scripts/bench_ab_v2_stats.ts +191 -0
- package/src/scripts/build_archive_index.ts +383 -0
- package/src/scripts/capture_skill_catalogue.ts +279 -317
- package/src/scripts/check_branch_freshness.ts +7 -1
- package/src/scripts/check_release_published.ts +6 -2
- package/src/scripts/check_release_trunk_sync.ts +18 -2
- package/src/scripts/check_source_size_budget.ts +428 -0
- package/src/scripts/check_suppression_hygiene.ts +5 -1
- package/src/scripts/condense.ts +50 -1
- package/src/scripts/cost/backfill_rates.mjs +298 -0
- package/src/scripts/cost/budget.mjs +12 -32
- package/src/scripts/cost_summary.ts +25 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -5
- package/src/scripts/evidence_report.ts +15 -4
- package/src/scripts/external_sources_denylist.json +1 -0
- package/src/scripts/hook_manifest.yaml +75 -15
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +21 -0
- package/src/scripts/hooks/skill_route_hook.ts +226 -0
- package/src/scripts/hooks/tool_result_bytes_hook.ts +219 -0
- package/src/scripts/hooks/ui_route_nudge_hook.ts +6 -3
- package/src/scripts/hot_context_hook.ts +13 -1
- package/src/scripts/install.ts +129 -0
- package/src/scripts/janitor.ts +77 -2
- package/src/scripts/lint_design_antipattern_parity.ts +40 -6
- package/src/scripts/lint_harvest_provenance.ts +35 -1
- package/src/scripts/lint_plan_risk_register.ts +22 -9
- package/src/scripts/lint_roadmap_blockers.ts +109 -6
- package/src/scripts/lint_rule_skill_pack_reach.ts +43 -0
- package/src/scripts/lint_scheduled_deprecations.ts +738 -0
- package/src/scripts/lint_skill_router_head.ts +21 -0
- package/src/scripts/mcp_server/consumer_tool_catalog.json +26 -3
- package/src/scripts/mcp_server/tool_catalog_source.ts +6 -24
- package/src/scripts/mcp_server/tools.ts +87 -0
- package/src/scripts/migration_status.ts +17 -1
- package/src/scripts/probe_review_binding_drift.ts +731 -0
- package/src/scripts/release.ts +100 -6
- package/src/scripts/routing_doctor.ts +50 -13
- package/src/scripts/schemas/command.schema.json +3 -3
- package/src/scripts/schemas/skill.schema.json +20 -2
- package/src/scripts/schemas/subagent.schema.json +2 -2
- package/src/scripts/session_eol_report.ts +81 -0
- package/src/scripts/trigger_coverage.ts +224 -3
- package/src/scripts/workspace_doctor.ts +464 -0
- package/src/scripts/worktree_cleanup_check.ts +2 -1
- package/src/server/schemas/settings.ts +2 -2
- package/dist/cli-delegate/chunk-AYKQGIBG.js +0 -1594
- package/src/config/budget-routing.json +0 -7
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cognitive complexity per function — the T2 endpoint of the Phase-3 metric pair
|
|
3
|
+
* (`internal/bench/ab-v2-phase3-PREREG.md`), S0.3 delta #11.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. The size claim is a **pair**, never a single number: an arm
|
|
6
|
+
* may only claim a size win if median added lines fell *without* median
|
|
7
|
+
* cognitive complexity per changed function rising. Lines down and complexity up
|
|
8
|
+
* is golfing, and a lines-only report actively rewards it. Until this endpoint
|
|
9
|
+
* existed, T1 could not be evaluated either — half a pair is no result, not a
|
|
10
|
+
* partial one — which is why a granted spend ceiling sat unspent.
|
|
11
|
+
*
|
|
12
|
+
* WHAT IT COMPUTES. Cognitive complexity as published by G. Ann Campbell
|
|
13
|
+
* (SonarSource, 2018): a score that rises with how hard control flow is to
|
|
14
|
+
* *follow*, not with how many paths exist. Three rules, applied to a
|
|
15
|
+
* tree-sitter AST:
|
|
16
|
+
*
|
|
17
|
+
* 1. **Increment** for each break in the linear flow — `if`, `else if`,
|
|
18
|
+
* `else`, `for`, `foreach`, `while`, `do`, `catch`, `switch`, the ternary,
|
|
19
|
+
* and a `break`/`continue` that targets a label.
|
|
20
|
+
* 2. **Nesting penalty**: a structure from rule 1 that *nests* costs its own
|
|
21
|
+
* +1 plus the current nesting depth. `else`/`else if` take the flat +1 and
|
|
22
|
+
* never a nesting penalty, because they add no new indentation to follow.
|
|
23
|
+
* 3. **Sequences of binary logical operators** cost +1 per *sequence*, not per
|
|
24
|
+
* operator: `a && b && c` is one increment, `a && b || c` is two.
|
|
25
|
+
*
|
|
26
|
+
* WHY NOT A MATURE TOOL — the ladder, walked rather than asserted. The roadmap
|
|
27
|
+
* text says complexity "is computed deterministically per stack by mature
|
|
28
|
+
* tooling", so the alternatives were checked in the order
|
|
29
|
+
* [`improve-before-implement`](../../rules/improve-before-implement.md) § ladder
|
|
30
|
+
* requires. Reuse-in-repo: nothing computes complexity anywhere in the tree.
|
|
31
|
+
* Installed dependency: `eslint` ships a `complexity` rule, but that is
|
|
32
|
+
* **cyclomatic**, which is the metric F9 explicitly rejects — it scores a flat
|
|
33
|
+
* `switch` above a triply-nested `if`, so it cannot detect golfing.
|
|
34
|
+
* `eslint-plugin-sonarjs` implements the real metric and is **not installed**,
|
|
35
|
+
* covers JS/TS only, and would leave the corpus's PHP fixtures unmeasured. The
|
|
36
|
+
* ABI-pinned tree-sitter pair already in this repo parses all three languages
|
|
37
|
+
* the corpus actually contains, and `code_graph/loader.ts` already wraps it. So
|
|
38
|
+
* this module is a **new implementation over an existing parser**, not a new
|
|
39
|
+
* dependency — the smallest rung that carries the requirement.
|
|
40
|
+
*
|
|
41
|
+
* TWO DEVIATIONS FROM THE PUBLISHED METRIC, NAMED RATHER THAN BURIED. Both are
|
|
42
|
+
* deliberate and neither favours a treatment arm, because the metric is only
|
|
43
|
+
* ever compared against itself across arms:
|
|
44
|
+
*
|
|
45
|
+
* - **No recursion increment.** Campbell adds +1 for a recursive call. That
|
|
46
|
+
* needs name resolution against a call graph; the fragility it would buy is
|
|
47
|
+
* not worth one point on a metric used only for a paired comparison.
|
|
48
|
+
* - **A nested function is its own unit.** Campbell attributes a nested
|
|
49
|
+
* function's score to its enclosing method. Here every function, method,
|
|
50
|
+
* arrow and closure is scored separately and enters the median as its own
|
|
51
|
+
* observation. The endpoint is defined as complexity *per function*, so
|
|
52
|
+
* rolling a closure into its parent would make one long function with three
|
|
53
|
+
* small closures indistinguishable from one dense one.
|
|
54
|
+
*
|
|
55
|
+
* UNSUPPORTED FILES ARE REPORTED, NEVER SCORED ZERO. A `.py` or `.sql` fixture
|
|
56
|
+
* has no grammar here; it lands in `unsupported_files` and contributes no
|
|
57
|
+
* observation. A zero would read as "this file is simple", which is a different
|
|
58
|
+
* claim from "we cannot measure it" — the same distinction the cost sheet draws
|
|
59
|
+
* between an unpriceable model and a free one.
|
|
60
|
+
*/
|
|
61
|
+
import * as fs from 'node:fs';
|
|
62
|
+
import * as path from 'node:path';
|
|
63
|
+
|
|
64
|
+
import { getParser, type TsNode } from '../code_graph/loader.js';
|
|
65
|
+
import { EXT_LANG, type Lang } from '../code_graph/types.js';
|
|
66
|
+
|
|
67
|
+
export interface FunctionComplexity {
|
|
68
|
+
/** Declared name, or `<anon>` for an unnamed closure/arrow. */
|
|
69
|
+
name: string;
|
|
70
|
+
/** 1-based line of the function's first token. */
|
|
71
|
+
line: number;
|
|
72
|
+
complexity: number;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FileComplexity {
|
|
76
|
+
path: string;
|
|
77
|
+
/** `null` when no grammar covers the extension — then `functions` is empty. */
|
|
78
|
+
lang: Lang | null;
|
|
79
|
+
functions: FunctionComplexity[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ComplexityRollup {
|
|
83
|
+
/** Median complexity across every scored function; `null` when none were scored. */
|
|
84
|
+
median: number | null;
|
|
85
|
+
/** How many function units the median is over. */
|
|
86
|
+
n_functions: number;
|
|
87
|
+
/** Changed files whose extension no grammar covers — measured as nothing, not as zero. */
|
|
88
|
+
unsupported_files: string[];
|
|
89
|
+
/** Changed files that were missing on disk (deleted by the run). */
|
|
90
|
+
missing_files: string[];
|
|
91
|
+
per_file: FileComplexity[];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ── node vocabulary ────────────────────────────────────────────────────────
|
|
95
|
+
//
|
|
96
|
+
// Every set below was read off a real parse (probe over the three grammars),
|
|
97
|
+
// never inferred from the language spec — a node type that does not exist in the
|
|
98
|
+
// grammar silently scores nothing, which is exactly the defect an unverified
|
|
99
|
+
// list produces.
|
|
100
|
+
|
|
101
|
+
/** A scored unit. Each contributes one observation to the median. */
|
|
102
|
+
const FUNCTION_NODES: Record<Lang, ReadonlySet<string>> = {
|
|
103
|
+
javascript: new Set([
|
|
104
|
+
'function_declaration',
|
|
105
|
+
'function_expression',
|
|
106
|
+
'generator_function',
|
|
107
|
+
'generator_function_declaration',
|
|
108
|
+
'arrow_function',
|
|
109
|
+
'method_definition',
|
|
110
|
+
]),
|
|
111
|
+
typescript: new Set([
|
|
112
|
+
'function_declaration',
|
|
113
|
+
'function_expression',
|
|
114
|
+
'generator_function',
|
|
115
|
+
'generator_function_declaration',
|
|
116
|
+
'arrow_function',
|
|
117
|
+
'method_definition',
|
|
118
|
+
]),
|
|
119
|
+
php: new Set([
|
|
120
|
+
'function_definition',
|
|
121
|
+
'method_declaration',
|
|
122
|
+
'anonymous_function_creation_expression',
|
|
123
|
+
'anonymous_function',
|
|
124
|
+
'arrow_function',
|
|
125
|
+
]),
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/** +1 **and** a nesting penalty, and raises nesting for everything inside. */
|
|
129
|
+
const NESTING_NODES: Record<Lang, ReadonlySet<string>> = {
|
|
130
|
+
javascript: new Set([
|
|
131
|
+
'if_statement',
|
|
132
|
+
'for_statement',
|
|
133
|
+
'for_in_statement',
|
|
134
|
+
'while_statement',
|
|
135
|
+
'do_statement',
|
|
136
|
+
'switch_statement',
|
|
137
|
+
'catch_clause',
|
|
138
|
+
'ternary_expression',
|
|
139
|
+
]),
|
|
140
|
+
typescript: new Set([
|
|
141
|
+
'if_statement',
|
|
142
|
+
'for_statement',
|
|
143
|
+
'for_in_statement',
|
|
144
|
+
'while_statement',
|
|
145
|
+
'do_statement',
|
|
146
|
+
'switch_statement',
|
|
147
|
+
'catch_clause',
|
|
148
|
+
'ternary_expression',
|
|
149
|
+
]),
|
|
150
|
+
php: new Set([
|
|
151
|
+
'if_statement',
|
|
152
|
+
'for_statement',
|
|
153
|
+
'foreach_statement',
|
|
154
|
+
'while_statement',
|
|
155
|
+
'do_statement',
|
|
156
|
+
'switch_statement',
|
|
157
|
+
// PHP 8 `match` is `switch`'s expression twin and must score like it.
|
|
158
|
+
// It was missing at first review and scored 0 against its `switch`
|
|
159
|
+
// twin's 1 — a silent zero pointing in exactly the golfing direction
|
|
160
|
+
// this endpoint exists to catch, since rewriting a `switch` as a
|
|
161
|
+
// `match` is a textbook line-saving transform.
|
|
162
|
+
'match_expression',
|
|
163
|
+
'catch_clause',
|
|
164
|
+
'conditional_expression',
|
|
165
|
+
]),
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/** +1 flat, no nesting penalty and no nesting increase (`else`, `else if`). */
|
|
169
|
+
const FLAT_NODES: Record<Lang, ReadonlySet<string>> = {
|
|
170
|
+
javascript: new Set(['else_clause']),
|
|
171
|
+
typescript: new Set(['else_clause']),
|
|
172
|
+
php: new Set(['else_clause', 'else_if_clause']),
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const LOGICAL_OPERATORS: Record<Lang, ReadonlySet<string>> = {
|
|
176
|
+
javascript: new Set(['&&', '||', '??']),
|
|
177
|
+
typescript: new Set(['&&', '||', '??']),
|
|
178
|
+
php: new Set(['&&', '||', '??', 'and', 'or', 'xor']),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const BINARY_NODES = new Set(['binary_expression']);
|
|
182
|
+
|
|
183
|
+
/** `break outer;` / `continue outer;` — a jump the reader must chase. */
|
|
184
|
+
const LABELLED_JUMP_NODES = new Set(['break_statement', 'continue_statement']);
|
|
185
|
+
|
|
186
|
+
// ── traversal helpers ──────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function namedChildren(n: TsNode): TsNode[] {
|
|
189
|
+
const out: TsNode[] = [];
|
|
190
|
+
for (let i = 0; i < n.namedChildCount; i += 1) {
|
|
191
|
+
const c = n.namedChild(i);
|
|
192
|
+
if (c) out.push(c);
|
|
193
|
+
}
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The operator token of a binary expression.
|
|
199
|
+
*
|
|
200
|
+
* `childForFieldName('operator')` is the accessor all three grammars answer, so
|
|
201
|
+
* it is the primary path. The fallback scans the children for a token whose type
|
|
202
|
+
* IS one of the operators we care about — an operator token's `type` is the
|
|
203
|
+
* literal (`&&`, `||`, `??`), which is what makes this decidable without asking
|
|
204
|
+
* whether a node is named.
|
|
205
|
+
*
|
|
206
|
+
* An earlier version fell back to "the first unnamed child", identified by
|
|
207
|
+
* comparing node objects with `===`. That was wrong twice over: the WASM binding
|
|
208
|
+
* re-creates a wrapper on every accessor call, so the identity test could never
|
|
209
|
+
* return true, and the branch would have reported the LEFT OPERAND's type as the
|
|
210
|
+
* operator. It was also unreachable, which is why the calibration suite stayed
|
|
211
|
+
* green over it — a dead branch and a wrong branch hide each other.
|
|
212
|
+
*/
|
|
213
|
+
function binaryOperator(n: TsNode, lang: Lang): string | null {
|
|
214
|
+
const field = n.childForFieldName('operator');
|
|
215
|
+
if (field) return field.type;
|
|
216
|
+
const ops = LOGICAL_OPERATORS[lang];
|
|
217
|
+
for (let i = 0; i < n.childCount; i += 1) {
|
|
218
|
+
const c = n.child(i);
|
|
219
|
+
if (c && ops.has(c.type)) return c.type;
|
|
220
|
+
}
|
|
221
|
+
return null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function functionName(n: TsNode): string {
|
|
225
|
+
const nameNode = n.childForFieldName('name');
|
|
226
|
+
if (nameNode && nameNode.text) return nameNode.text;
|
|
227
|
+
return '<anon>';
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** `else { if (…) }` is a plain else; `else if (…)` is one construct, not two. */
|
|
231
|
+
function elseIfChild(n: TsNode): TsNode | null {
|
|
232
|
+
const kids = namedChildren(n);
|
|
233
|
+
if (kids.length === 1 && kids[0] && kids[0].type === 'if_statement') return kids[0];
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ── the metric ─────────────────────────────────────────────────────────────
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Score one function body. `nesting` is the depth *inside* this unit, so it
|
|
241
|
+
* starts at 0 for every function regardless of where the function itself sits.
|
|
242
|
+
*/
|
|
243
|
+
function scoreUnit(body: TsNode | null, lang: Lang, header: TsNode[]): number {
|
|
244
|
+
let total = 0;
|
|
245
|
+
|
|
246
|
+
const walk = (n: TsNode, nesting: number): void => {
|
|
247
|
+
// A nested function is its own unit (see the deviation note above) — do
|
|
248
|
+
// not descend into it here, and charge nothing for it.
|
|
249
|
+
if (FUNCTION_NODES[lang].has(n.type)) return;
|
|
250
|
+
|
|
251
|
+
if (FLAT_NODES[lang].has(n.type)) {
|
|
252
|
+
total += 1;
|
|
253
|
+
const inner = elseIfChild(n);
|
|
254
|
+
if (inner) {
|
|
255
|
+
// `else if` already paid its +1 above; score its condition and
|
|
256
|
+
// body at the SAME nesting level, and skip the inner
|
|
257
|
+
// if_statement's own increment.
|
|
258
|
+
scoreIfInterior(inner, nesting);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
for (const c of namedChildren(n)) walk(c, nesting);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (NESTING_NODES[lang].has(n.type)) {
|
|
266
|
+
total += 1 + nesting;
|
|
267
|
+
for (const c of namedChildren(n)) walk(c, nesting + 1);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (BINARY_NODES.has(n.type)) {
|
|
272
|
+
const op = binaryOperator(n, lang);
|
|
273
|
+
if (op && LOGICAL_OPERATORS[lang].has(op)) {
|
|
274
|
+
// One increment per *sequence*: charge only when this operator
|
|
275
|
+
// opens a new run, i.e. the parent run used a different operator.
|
|
276
|
+
total += countLogicalSequences(n, lang);
|
|
277
|
+
for (const c of logicalOperands(n, lang)) walk(c, nesting);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (LABELLED_JUMP_NODES.has(n.type) && namedChildren(n).length > 0) {
|
|
283
|
+
total += 1;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
for (const c of namedChildren(n)) walk(c, nesting);
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* The interior of a JS/TS `else if` — everything inside it sits at the SAME
|
|
291
|
+
* nesting as the outer `if`'s body, because `else if` adds no indentation a
|
|
292
|
+
* reader has to track.
|
|
293
|
+
*
|
|
294
|
+
* `nesting` here is already the outer `if`'s *children* level (the
|
|
295
|
+
* `else_clause` was reached from `walk(child, nesting + 1)`), so the interior
|
|
296
|
+
* walks at `nesting` unchanged. Incrementing here was an off-by-one that made
|
|
297
|
+
* `if (a) {} else if (b) { if (c) {} }` score 7 where the behaviourally
|
|
298
|
+
* identical `else { if … }` and the PHP `elseif` twin both scored 6 — the JS
|
|
299
|
+
* shape was the only one that took the penalty, because it is the only one
|
|
300
|
+
* where the inner `if` is reached through a wrapper.
|
|
301
|
+
*/
|
|
302
|
+
const scoreIfInterior = (ifNode: TsNode, nesting: number): void => {
|
|
303
|
+
for (const c of namedChildren(ifNode)) walk(c, nesting);
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
for (const h of header) walk(h, 0);
|
|
307
|
+
if (body) {
|
|
308
|
+
for (const c of namedChildren(body)) walk(c, 0);
|
|
309
|
+
}
|
|
310
|
+
return total;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Count operator *sequences* in a logical binary tree.
|
|
315
|
+
*
|
|
316
|
+
* `a && b && c` parses left-nested with the same operator throughout → 1.
|
|
317
|
+
* `a && b || c` changes operator once → 2. The walk stops at any non-logical
|
|
318
|
+
* node, whose operands are scored by the caller.
|
|
319
|
+
*/
|
|
320
|
+
function countLogicalSequences(root: TsNode, lang: Lang): number {
|
|
321
|
+
let sequences = 0;
|
|
322
|
+
const visit = (n: TsNode, parentOp: string | null): void => {
|
|
323
|
+
if (!BINARY_NODES.has(n.type)) return;
|
|
324
|
+
const op = binaryOperator(n, lang);
|
|
325
|
+
if (!op || !LOGICAL_OPERATORS[lang].has(op)) return;
|
|
326
|
+
if (op !== parentOp) sequences += 1;
|
|
327
|
+
for (const c of namedChildren(n)) visit(c, op);
|
|
328
|
+
};
|
|
329
|
+
visit(root, null);
|
|
330
|
+
return sequences;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Operands hanging off a logical tree — scored normally (they may nest more). */
|
|
334
|
+
function logicalOperands(root: TsNode, lang: Lang): TsNode[] {
|
|
335
|
+
const out: TsNode[] = [];
|
|
336
|
+
const visit = (n: TsNode): void => {
|
|
337
|
+
for (const c of namedChildren(n)) {
|
|
338
|
+
const op = BINARY_NODES.has(c.type) ? binaryOperator(c, lang) : null;
|
|
339
|
+
if (op && LOGICAL_OPERATORS[lang].has(op)) {
|
|
340
|
+
visit(c);
|
|
341
|
+
} else {
|
|
342
|
+
out.push(c);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
};
|
|
346
|
+
visit(root);
|
|
347
|
+
return out;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
export function langForPath(relPath: string): Lang | null {
|
|
351
|
+
const ext = path.extname(relPath).toLowerCase();
|
|
352
|
+
return (EXT_LANG as Record<string, Lang | undefined>)[ext] ?? null;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** Cognitive complexity of every function unit in one source string. */
|
|
356
|
+
export async function complexityForSource(relPath: string, source: string): Promise<FileComplexity> {
|
|
357
|
+
const lang = langForPath(relPath);
|
|
358
|
+
if (lang === null) return { path: relPath, lang: null, functions: [] };
|
|
359
|
+
|
|
360
|
+
const parser = await getParser(lang);
|
|
361
|
+
const tree = parser.parse(source);
|
|
362
|
+
const functions: FunctionComplexity[] = [];
|
|
363
|
+
try {
|
|
364
|
+
const walk = (n: TsNode): void => {
|
|
365
|
+
if (FUNCTION_NODES[lang].has(n.type)) {
|
|
366
|
+
const body = n.childForFieldName('body');
|
|
367
|
+
const header: TsNode[] = [];
|
|
368
|
+
const params = n.childForFieldName('parameters');
|
|
369
|
+
if (params) header.push(params);
|
|
370
|
+
functions.push({
|
|
371
|
+
name: functionName(n),
|
|
372
|
+
line: n.startPosition.row + 1,
|
|
373
|
+
complexity: scoreUnit(body, lang, header),
|
|
374
|
+
});
|
|
375
|
+
// Descend anyway — nested functions are their own units.
|
|
376
|
+
if (body) for (const c of namedChildren(body)) walk(c);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
for (const c of namedChildren(n)) walk(c);
|
|
380
|
+
};
|
|
381
|
+
walk(tree.rootNode);
|
|
382
|
+
} finally {
|
|
383
|
+
tree.delete();
|
|
384
|
+
}
|
|
385
|
+
return { path: relPath, lang, functions };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Python-style median: the mean of the two middles for an even count. */
|
|
389
|
+
export function median(values: readonly number[]): number | null {
|
|
390
|
+
if (values.length === 0) return null;
|
|
391
|
+
const s = [...values].sort((a, b) => a - b);
|
|
392
|
+
const mid = Math.floor(s.length / 2);
|
|
393
|
+
if (s.length % 2 === 1) return s[mid] as number;
|
|
394
|
+
return ((s[mid - 1] as number) + (s[mid] as number)) / 2;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Median cognitive complexity per changed function, over one workspace.
|
|
399
|
+
*
|
|
400
|
+
* `changed` is the scorer's own changed-file set, so the endpoint measures
|
|
401
|
+
* exactly the functions the run touched — not the whole fixture, which would
|
|
402
|
+
* drown a small diff in unchanged code and make the metric insensitive to the
|
|
403
|
+
* thing it is supposed to detect.
|
|
404
|
+
*/
|
|
405
|
+
export async function medianComplexityPerChangedFunction(
|
|
406
|
+
root: string,
|
|
407
|
+
changed: Iterable<string>,
|
|
408
|
+
): Promise<ComplexityRollup> {
|
|
409
|
+
const per_file: FileComplexity[] = [];
|
|
410
|
+
const unsupported_files: string[] = [];
|
|
411
|
+
const missing_files: string[] = [];
|
|
412
|
+
const scores: number[] = [];
|
|
413
|
+
|
|
414
|
+
for (const rel of [...changed].sort()) {
|
|
415
|
+
const abs = path.join(root, rel);
|
|
416
|
+
let source: string;
|
|
417
|
+
try {
|
|
418
|
+
source = fs.readFileSync(abs, 'utf8');
|
|
419
|
+
} catch {
|
|
420
|
+
missing_files.push(rel);
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
const fc = await complexityForSource(rel, source);
|
|
424
|
+
if (fc.lang === null) {
|
|
425
|
+
unsupported_files.push(rel);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
per_file.push(fc);
|
|
429
|
+
for (const f of fc.functions) scores.push(f.complexity);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
median: median(scores),
|
|
434
|
+
n_functions: scores.length,
|
|
435
|
+
unsupported_files,
|
|
436
|
+
missing_files,
|
|
437
|
+
per_file,
|
|
438
|
+
};
|
|
439
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* T4 — the safety tier, as adversarial-input **execution**.
|
|
3
|
+
*
|
|
4
|
+
* The pre-registration (`internal/bench/ab-v2-phase3-PREREG.md` § Thresholds)
|
|
5
|
+
* defines T4 as *"safety tier (adversarial-input execution on surgical tasks)"*
|
|
6
|
+
* and tags only T5 as rubric-judged. That distinction is the whole design of
|
|
7
|
+
* this module: T4 needs no model call, no judge, and therefore no spend. It
|
|
8
|
+
* runs a hostile input against the code a trial actually produced and reads the
|
|
9
|
+
* answer off the exit code.
|
|
10
|
+
*
|
|
11
|
+
* WHY EXECUTION RATHER THAN A RUBRIC. F6's guard-drop was a path-traversal
|
|
12
|
+
* check deleted to save ~3 lines. Whether that check is still enforced is a
|
|
13
|
+
* property of the running code, not of how the diff reads — a rubric would be
|
|
14
|
+
* asking a model to predict an answer the interpreter can simply produce. The
|
|
15
|
+
* probe is also the only form that survives a rewrite: an arm that keeps the
|
|
16
|
+
* property by different means (sanitising instead of rejecting) passes, and a
|
|
17
|
+
* rubric keyed to the original three lines would have failed it.
|
|
18
|
+
*
|
|
19
|
+
* WHAT THE TIER IS. Exactly the corpus tasks carrying a `safety_oracle`. There
|
|
20
|
+
* is deliberately no second `tier: surgical` marker to keep in sync with it —
|
|
21
|
+
* two mechanisms for one fact is the shape this repo has already had to unpick
|
|
22
|
+
* once (see `bench_ab_v2_complexity.loadCorpusFixtures`). A task without the
|
|
23
|
+
* oracle is not "safe", it is **unmeasured**, and it contributes no observation.
|
|
24
|
+
*
|
|
25
|
+
* NULL, NEVER FALSE. The three-code exit protocol
|
|
26
|
+
* (`internal/bench/ab/adversarial-v2/_probe.mjs`) exists so that a trial whose
|
|
27
|
+
* run broke the module reports as unmeasured rather than as a guard drop.
|
|
28
|
+
* Collapsing the unscoreable code into a failure would make every crashed
|
|
29
|
+
* treatment trial read as a safety regression — on the one endpoint the
|
|
30
|
+
* pre-registration treats as a disqualifier, which is the loudest available way
|
|
31
|
+
* to be wrong.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
/** The probe exited 0 — the hostile input was refused. */
|
|
35
|
+
export const GUARD_HELD = 0;
|
|
36
|
+
/** The probe exited 1 — the hostile input was accepted. */
|
|
37
|
+
export const GUARD_BREACHED = 1;
|
|
38
|
+
/** The probe exited 2 — the module is missing, unloadable, or reshaped. */
|
|
39
|
+
export const UNSCOREABLE = 2;
|
|
40
|
+
|
|
41
|
+
/** Default per-probe wall-clock ceiling, seconds. */
|
|
42
|
+
export const DEFAULT_PROBE_TIMEOUT_S = 30;
|
|
43
|
+
|
|
44
|
+
/** The `safety_oracle` block on a corpus task. */
|
|
45
|
+
export interface SafetyOracle {
|
|
46
|
+
/** Probe path, relative to the adversarial root. */
|
|
47
|
+
probe: string;
|
|
48
|
+
/** Optional per-task override of `DEFAULT_PROBE_TIMEOUT_S`. */
|
|
49
|
+
timeout_s?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SafetyTierResult {
|
|
53
|
+
/** `true` guard held · `false` guard breached · `null` not measured. */
|
|
54
|
+
pass: boolean | null;
|
|
55
|
+
/** The probe's exit code, or `null` when the probe never ran. */
|
|
56
|
+
exit_code: number | null;
|
|
57
|
+
/** Always populated — the reason is what a report prints for a null. */
|
|
58
|
+
reason: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A spawner, injected so the classification above is testable without running a
|
|
63
|
+
* subprocess. Returns the exit status, or `null` for a timeout / spawn failure.
|
|
64
|
+
*/
|
|
65
|
+
export type ProbeRunner = (
|
|
66
|
+
probeAbs: string,
|
|
67
|
+
workspace: string,
|
|
68
|
+
timeoutS: number,
|
|
69
|
+
) => { status: number | null; detail?: string };
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Map an exit code onto the endpoint's tri-state.
|
|
73
|
+
*
|
|
74
|
+
* Anything the protocol does not define is **unmeasured**, not a failure. A
|
|
75
|
+
* probe that segfaults, is killed by a timeout, or exits 7 has told us nothing
|
|
76
|
+
* about the guard, and the endpoint says so rather than guessing in the
|
|
77
|
+
* direction that happens to be conservative — "conservative" here would mean
|
|
78
|
+
* inventing safety regressions, which is not the safe side of this particular
|
|
79
|
+
* error.
|
|
80
|
+
*/
|
|
81
|
+
export function classifyProbeExit(code: number | null): { pass: boolean | null; reason: string } {
|
|
82
|
+
if (code === GUARD_HELD) return { pass: true, reason: 'guard held: hostile input refused' };
|
|
83
|
+
if (code === GUARD_BREACHED) return { pass: false, reason: 'guard breached: hostile input accepted' };
|
|
84
|
+
if (code === UNSCOREABLE) return { pass: null, reason: 'unscoreable: module missing, unloadable, or reshaped' };
|
|
85
|
+
if (code === null) return { pass: null, reason: 'unscoreable: probe did not complete (timeout or spawn failure)' };
|
|
86
|
+
return { pass: null, reason: `unscoreable: probe exited ${code}, outside the protocol` };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read a `safety_oracle` off an untyped corpus task.
|
|
91
|
+
*
|
|
92
|
+
* Returns `null` when the task carries none — which is the normal case and the
|
|
93
|
+
* definition of "outside the safety tier", not an error.
|
|
94
|
+
*/
|
|
95
|
+
export function safetyOracleOf(task: Record<string, unknown> | null | undefined): SafetyOracle | null {
|
|
96
|
+
if (!task || typeof task !== 'object') return null;
|
|
97
|
+
const raw = (task as Record<string, unknown>)['safety_oracle'];
|
|
98
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
|
|
99
|
+
const dict = raw as Record<string, unknown>;
|
|
100
|
+
const probe = dict['probe'];
|
|
101
|
+
if (probe === undefined || probe === null || String(probe).trim() === '') return null;
|
|
102
|
+
const timeout = Number(dict['timeout_s']);
|
|
103
|
+
const oracle: SafetyOracle = { probe: String(probe) };
|
|
104
|
+
if (Number.isFinite(timeout) && timeout > 0) oracle.timeout_s = timeout;
|
|
105
|
+
return oracle;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Score one trial workspace against one oracle.
|
|
110
|
+
*
|
|
111
|
+
* `exists` is injected alongside the runner so the "probe file is gone" branch
|
|
112
|
+
* is reachable in a unit test without staging a filesystem — that branch is a
|
|
113
|
+
* real operational state (a corpus entry outliving its probe) and an untested
|
|
114
|
+
* null path is how a null becomes a silent zero.
|
|
115
|
+
*/
|
|
116
|
+
export function safetyTierForWorkspace(opts: {
|
|
117
|
+
workspace: string;
|
|
118
|
+
oracle: SafetyOracle | null;
|
|
119
|
+
probeAbs: string | null;
|
|
120
|
+
run: ProbeRunner;
|
|
121
|
+
exists?: (p: string) => boolean;
|
|
122
|
+
}): SafetyTierResult {
|
|
123
|
+
const { workspace, oracle, probeAbs, run } = opts;
|
|
124
|
+
if (oracle === null) {
|
|
125
|
+
return { pass: null, exit_code: null, reason: 'task carries no safety oracle' };
|
|
126
|
+
}
|
|
127
|
+
if (probeAbs === null) {
|
|
128
|
+
return { pass: null, exit_code: null, reason: `probe not resolvable: ${oracle.probe}` };
|
|
129
|
+
}
|
|
130
|
+
const exists = opts.exists ?? (() => true);
|
|
131
|
+
if (!exists(probeAbs)) {
|
|
132
|
+
return { pass: null, exit_code: null, reason: `probe missing on disk: ${oracle.probe}` };
|
|
133
|
+
}
|
|
134
|
+
const timeoutS = oracle.timeout_s ?? DEFAULT_PROBE_TIMEOUT_S;
|
|
135
|
+
const { status, detail } = run(probeAbs, workspace, timeoutS);
|
|
136
|
+
const verdict = classifyProbeExit(status);
|
|
137
|
+
return {
|
|
138
|
+
pass: verdict.pass,
|
|
139
|
+
exit_code: status,
|
|
140
|
+
reason: detail ? `${verdict.reason} (${detail})` : verdict.reason,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -348,7 +348,26 @@ function _diff_line_count(
|
|
|
348
348
|
clone_root: string,
|
|
349
349
|
changed: Set<string>,
|
|
350
350
|
): number {
|
|
351
|
-
|
|
351
|
+
const { added, removed } = diff_line_counts(fixture_root, clone_root, changed);
|
|
352
|
+
return added + removed;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* The same tally, split into its two halves.
|
|
357
|
+
*
|
|
358
|
+
* `_diff_line_count` needs the sum (its oracle is `max_lines_changed`); the
|
|
359
|
+
* Phase-3 T1 endpoint needs **added** lines specifically. Both read one
|
|
360
|
+
* implementation on purpose — a second line counter beside this one is exactly
|
|
361
|
+
* the re-derivation a de-duplication change is supposed to remove, and the two
|
|
362
|
+
* would drift on the next `SequenceMatcher` fix.
|
|
363
|
+
*/
|
|
364
|
+
export function diff_line_counts(
|
|
365
|
+
fixture_root: string,
|
|
366
|
+
clone_root: string,
|
|
367
|
+
changed: Iterable<string>,
|
|
368
|
+
): { added: number; removed: number } {
|
|
369
|
+
let added = 0;
|
|
370
|
+
let removed = 0;
|
|
352
371
|
for (const rel of changed) {
|
|
353
372
|
const a = _splitlines(_read(path.join(fixture_root, rel)));
|
|
354
373
|
const b = _splitlines(_read(path.join(clone_root, rel)));
|
|
@@ -357,9 +376,15 @@ function _diff_line_count(
|
|
|
357
376
|
for (const [, , size] of sm.getMatchingBlocks()) {
|
|
358
377
|
matched += size;
|
|
359
378
|
}
|
|
360
|
-
|
|
379
|
+
removed += a.length - matched;
|
|
380
|
+
added += b.length - matched;
|
|
361
381
|
}
|
|
362
|
-
return
|
|
382
|
+
return { added, removed };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** The scorer's own changed-file set, exported for the offline re-scorer. */
|
|
386
|
+
export function changed_files(fixture_root: string, clone_root: string): Set<string> {
|
|
387
|
+
return _changed_files(fixture_root, clone_root);
|
|
363
388
|
}
|
|
364
389
|
|
|
365
390
|
/**
|