@event4u/agent-config 9.32.0 → 9.33.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 +96 -1
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +6 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/dist/cli/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-PMQ6Z3T3.js} +1651 -144
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +256 -226
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +19 -26
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +13 -3
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- 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 +18 -0
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3637 -1158
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/src/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +19 -3
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/orchestration_record.ts +45 -0
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +128 -6
- package/src/scripts/ai_council/orchestrator.ts +12 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/quorum.ts +20 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_skill_frontmatter_safety.ts +55 -1
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +10 -0
- package/src/scripts/report_conformance_funnel.ts +40 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
package/src/scripts/install.ts
CHANGED
|
@@ -975,13 +975,17 @@ function ensure_agent_settings(
|
|
|
975
975
|
success(`${SETTINGS_FILE} created (rule_loading_tier=${profile}${suffix})`);
|
|
976
976
|
}
|
|
977
977
|
|
|
978
|
-
function ensure_vscode_bridge(
|
|
978
|
+
function ensure_vscode_bridge(
|
|
979
|
+
project_root: string,
|
|
980
|
+
package_type: string,
|
|
981
|
+
force: boolean,
|
|
982
|
+
): Record<string, unknown>[] {
|
|
979
983
|
const plugin_paths: Record<string, string> = {
|
|
980
984
|
npm: './node_modules/@event4u/agent-config/plugin/agent-config',
|
|
981
985
|
};
|
|
982
986
|
const plugin_path = plugin_paths[package_type] ?? './plugin/agent-config';
|
|
983
987
|
const bridge = { 'chat.pluginLocations': { [plugin_path]: true } };
|
|
984
|
-
merge_json_file(
|
|
988
|
+
return merge_json_file(
|
|
985
989
|
path.join(project_root, '.vscode', 'settings.json'),
|
|
986
990
|
bridge,
|
|
987
991
|
force,
|
|
@@ -1043,8 +1047,11 @@ function _remove_legacy_augment_trampolines(): void {
|
|
|
1043
1047
|
fs.unlinkSync(legacy);
|
|
1044
1048
|
skip(`removed legacy ~/.augment/hooks/${name}`);
|
|
1045
1049
|
}
|
|
1046
|
-
} catch {
|
|
1047
|
-
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
// A swallowed failure leaves a stale trampoline that no manifest entry
|
|
1052
|
+
// covers, so uninstall cannot reach it. Non-fatal and semantics-neutral:
|
|
1053
|
+
// the removal is still best-effort, it is simply no longer silent.
|
|
1054
|
+
warn(`could not remove legacy ${legacy}: ${err instanceof Error ? err.message : String(err)}`);
|
|
1048
1055
|
}
|
|
1049
1056
|
}
|
|
1050
1057
|
}
|
|
@@ -5075,8 +5082,15 @@ function _main_project_install(
|
|
|
5075
5082
|
const merged_keys_by_tool: Record<string, Record<string, unknown>[]> = {};
|
|
5076
5083
|
|
|
5077
5084
|
if (!opts.skip_bridges) {
|
|
5078
|
-
|
|
5079
|
-
|
|
5085
|
+
// `.vscode/settings.json` and `.augment/settings.json` are the two bridges
|
|
5086
|
+
// written unconditionally, so neither belongs to a selected tool. The
|
|
5087
|
+
// lockfile keys `merged_keys` by tool name (`_VALID_TOOLS`), so both are
|
|
5088
|
+
// recorded under the substrate entry `augment` — see
|
|
5089
|
+
// `docs/contracts/install-layout.md` § Untracked surfaces for the residual.
|
|
5090
|
+
merged_keys_by_tool['augment'] = [
|
|
5091
|
+
...ensure_vscode_bridge(project_root, package_type, opts.force),
|
|
5092
|
+
...ensure_augment_bridge(project_root, opts.force),
|
|
5093
|
+
];
|
|
5080
5094
|
if (_is_tool_enabled(tools, 'claude-code')) {
|
|
5081
5095
|
merged_keys_by_tool['claude-code'] = ensure_claude_bridge(project_root, opts.force);
|
|
5082
5096
|
}
|
|
@@ -15,6 +15,16 @@
|
|
|
15
15
|
* are allowed but not required — the top-level owner/review_by covers the
|
|
16
16
|
* file.
|
|
17
17
|
*
|
|
18
|
+
* ROW-LEVEL schema, opt-in (road-to-cost-parity-0-program § 1.2b/1.3): a
|
|
19
|
+
* budget file that declares a `row_schema` block additionally has every row
|
|
20
|
+
* of every top-level array checked for the fields it names — in practice
|
|
21
|
+
* `source` (so a baseline figure without a traceable origin cannot enter the
|
|
22
|
+
* file) plus the three honest-null fields. The honest-null contract is the
|
|
23
|
+
* part that binds: a row that records a `revised_from` without a
|
|
24
|
+
* `revision_evidence` pointer fails the file, because a target that moved
|
|
25
|
+
* without published evidence is precisely the drift the clause exists to
|
|
26
|
+
* refuse. Files that declare no `row_schema` are untouched by this half.
|
|
27
|
+
*
|
|
18
28
|
* Exit codes: 0 clean · 1 findings · 2 internal error.
|
|
19
29
|
*/
|
|
20
30
|
import * as fs from 'node:fs';
|
|
@@ -41,6 +51,76 @@ export function checkBudgetDoc(relPath: string, doc: Record<string, unknown>): s
|
|
|
41
51
|
return errors;
|
|
42
52
|
}
|
|
43
53
|
|
|
54
|
+
const DEFAULT_ROW_FIELDS = [
|
|
55
|
+
'id',
|
|
56
|
+
'unit',
|
|
57
|
+
'baseline',
|
|
58
|
+
'source',
|
|
59
|
+
'revisable',
|
|
60
|
+
'revision_evidence',
|
|
61
|
+
'revised_from',
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
function isRow(value: unknown): value is Record<string, unknown> {
|
|
65
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Row-level checks for budget files that opt in via a `row_schema` block.
|
|
70
|
+
* Returns [] for every file that does not declare one.
|
|
71
|
+
*/
|
|
72
|
+
export function checkBudgetRows(relPath: string, doc: Record<string, unknown>): string[] {
|
|
73
|
+
const schema = doc['row_schema'];
|
|
74
|
+
if (!isRow(schema)) return [];
|
|
75
|
+
|
|
76
|
+
const declared = schema['required_row_fields'];
|
|
77
|
+
const required =
|
|
78
|
+
Array.isArray(declared) && declared.every((f) => typeof f === 'string')
|
|
79
|
+
? (declared as string[])
|
|
80
|
+
: DEFAULT_ROW_FIELDS;
|
|
81
|
+
|
|
82
|
+
const errors: string[] = [];
|
|
83
|
+
if (typeof doc['schema_version'] !== 'number') {
|
|
84
|
+
errors.push(`${relPath}: \`row_schema\` declared but no numeric top-level \`schema_version\``);
|
|
85
|
+
}
|
|
86
|
+
if (typeof doc['registered_at'] !== 'string' || !DATE_RE.test(doc['registered_at'])) {
|
|
87
|
+
errors.push(`${relPath}: \`row_schema\` declared but no valid top-level \`registered_at\` (YYYY-MM-DD)`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
for (const [key, value] of Object.entries(doc)) {
|
|
91
|
+
if (!Array.isArray(value)) continue;
|
|
92
|
+
value.forEach((row, index) => {
|
|
93
|
+
if (!isRow(row)) return;
|
|
94
|
+
const id = typeof row['id'] === 'string' ? row['id'] : `#${index}`;
|
|
95
|
+
const where = `${relPath}: ${key}[${index}] (${id})`;
|
|
96
|
+
|
|
97
|
+
for (const field of required) {
|
|
98
|
+
if (!(field in row)) errors.push(`${where}: missing \`${field}\``);
|
|
99
|
+
}
|
|
100
|
+
if ('source' in row) {
|
|
101
|
+
const source = row['source'];
|
|
102
|
+
if (typeof source !== 'string' || source.trim() === '') {
|
|
103
|
+
errors.push(`${where}: empty \`source\` — a baseline figure needs a traceable origin`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if ('revisable' in row && row['revisable'] !== true) {
|
|
107
|
+
errors.push(`${where}: \`revisable\` must be true — an unrevisable target is not honest-null`);
|
|
108
|
+
}
|
|
109
|
+
// The honest-null contract: a moved target must publish what moved it.
|
|
110
|
+
const revisedFrom = row['revised_from'];
|
|
111
|
+
if (revisedFrom !== null && revisedFrom !== undefined) {
|
|
112
|
+
const evidence = row['revision_evidence'];
|
|
113
|
+
if (typeof evidence !== 'string' || evidence.trim() === '') {
|
|
114
|
+
errors.push(
|
|
115
|
+
`${where}: sets \`revised_from\` without a \`revision_evidence\` pointer — a revision without published evidence`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
123
|
+
|
|
44
124
|
export function budgetFiles(dir: string = CONFIG_DIR): string[] {
|
|
45
125
|
try {
|
|
46
126
|
return fs
|
|
@@ -85,6 +165,7 @@ export function main(): number {
|
|
|
85
165
|
continue;
|
|
86
166
|
}
|
|
87
167
|
errors.push(...checkBudgetDoc(rel, doc));
|
|
168
|
+
errors.push(...checkBudgetRows(rel, doc));
|
|
88
169
|
const review = doc['review_by'];
|
|
89
170
|
if (typeof review === 'string' && DATE_RE.test(review) && review < today) {
|
|
90
171
|
process.stderr.write(
|
|
@@ -47,6 +47,19 @@ const _RE_PERMISSION_MODE = /^\s*permissionMode:\s*['"]?bypassPermissions/;
|
|
|
47
47
|
// Python: re.match(r"\s*allowed-tools:\s*(.+)$", text)
|
|
48
48
|
const _RE_ALLOWED_TOOLS = /^\s*allowed-tools:\s*(.+)$/;
|
|
49
49
|
|
|
50
|
+
// Subagent definitions grant tools under a TOP-LEVEL `tools:` key (subagent-v1,
|
|
51
|
+
// `src/scripts/schemas/subagent.schema.json`), not under `execution.allowed_tools`
|
|
52
|
+
// and not as `allowed-tools`. Both other spellings were already read here; this
|
|
53
|
+
// one was not, and `src/subagents` was not a scan root either — so the two
|
|
54
|
+
// over-broad-grant checks below had never inspected a subagent definition.
|
|
55
|
+
// Top-level anchored (no leading whitespace) so a nested `tools:` inside some
|
|
56
|
+
// other block cannot be mistaken for a grant; the key is unused by skills,
|
|
57
|
+
// commands and domain artefacts, so the anchor costs no coverage.
|
|
58
|
+
const _RE_TOOLS_KEY = /^tools:\s*(.*)$/;
|
|
59
|
+
// A YAML block-sequence item under it (` - Bash`). Inline flow (`tools: [Bash]`)
|
|
60
|
+
// is captured by the key regex's own trailing group.
|
|
61
|
+
const _RE_SEQ_ITEM = /^\s+-\s*(.+)$/;
|
|
62
|
+
|
|
50
63
|
/** Mirror Python `str.strip("'\"")` — strip any leading/trailing `'` or `"`. */
|
|
51
64
|
function _stripQuotes(s: string): string {
|
|
52
65
|
let lo = 0;
|
|
@@ -151,6 +164,44 @@ export function _scan(sf: sl.ScannedFile): sl.Finding[] {
|
|
|
151
164
|
}
|
|
152
165
|
}
|
|
153
166
|
|
|
167
|
+
// Top-level `tools:` — the subagent-v1 grant key. Collect the inline value
|
|
168
|
+
// plus any block-sequence items that follow, and run the same two checks the
|
|
169
|
+
// other two spellings get. A subagent is never an execution skill (no
|
|
170
|
+
// `execution:` block), so the bare-Bash arm applies by construction.
|
|
171
|
+
for (let i = 0; i < body.length; i += 1) {
|
|
172
|
+
const [lineno, text] = body[i] as [number, string];
|
|
173
|
+
const key = _RE_TOOLS_KEY.exec(text);
|
|
174
|
+
if (!key) {
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
const grants: string[] = [];
|
|
178
|
+
const inline = (key[1] ?? '').trim();
|
|
179
|
+
if (inline !== '') {
|
|
180
|
+
grants.push(inline);
|
|
181
|
+
}
|
|
182
|
+
for (let j = i + 1; j < body.length; j += 1) {
|
|
183
|
+
const [, next] = body[j] as [number, string];
|
|
184
|
+
const item = _RE_SEQ_ITEM.exec(next);
|
|
185
|
+
if (!item) {
|
|
186
|
+
break;
|
|
187
|
+
}
|
|
188
|
+
grants.push(_stripQuotes((item[1] as string).trim()));
|
|
189
|
+
}
|
|
190
|
+
const joined = grants.join(', ');
|
|
191
|
+
if (joined !== '' && (_WILDCARD_TOOL.test(joined) || _BARE_BASH.test(joined))) {
|
|
192
|
+
out.push(
|
|
193
|
+
new sl.Finding(
|
|
194
|
+
sf.rel,
|
|
195
|
+
lineno,
|
|
196
|
+
CHECK,
|
|
197
|
+
'HIGH',
|
|
198
|
+
'wildcard / bare-Bash tool grant (over-broad)',
|
|
199
|
+
sf.weight,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
154
205
|
if (Object.keys(ex).length === 0) {
|
|
155
206
|
return out;
|
|
156
207
|
}
|
|
@@ -258,7 +309,10 @@ export function main(argv: readonly string[] | null = null): number {
|
|
|
258
309
|
const args = parse_args(argv ?? process.argv.slice(2));
|
|
259
310
|
|
|
260
311
|
const findings: sl.Finding[] = [];
|
|
261
|
-
|
|
312
|
+
// `src/subagents` is the fourth root, added because its omission is what made
|
|
313
|
+
// the over-broad-grant checks blind: the only artefact in the tree granting a
|
|
314
|
+
// bare shell lives there, and the gate had never read it.
|
|
315
|
+
const roots = ['src/skills', 'src/agent-src', 'src/domains', 'src/subagents'];
|
|
262
316
|
let scanned = 0;
|
|
263
317
|
for (const sf of sl.iter_corpus(roots, ['.md'])) {
|
|
264
318
|
scanned += 1;
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* lint_workflow_paths — a workflow `paths:` filter that matches nothing runs
|
|
4
|
+
* nothing, silently, forever.
|
|
5
|
+
*
|
|
6
|
+
* ## The defect class
|
|
7
|
+
*
|
|
8
|
+
* A GitHub `on.<event>.paths` filter is a promise: "run this job when these
|
|
9
|
+
* files change". When the named tree is renamed, retired, or gitignored, the
|
|
10
|
+
* promise stops being kept and nothing says so — the workflow does not error,
|
|
11
|
+
* it simply never triggers. The job still appears in the Actions list, still
|
|
12
|
+
* shows green from its last real run, and a reader auditing the pipeline reads
|
|
13
|
+
* an intent the pipeline no longer has.
|
|
14
|
+
*
|
|
15
|
+
* That is strictly worse than a missing filter. A missing filter over-triggers
|
|
16
|
+
* and wastes minutes; a dead one under-triggers and hides regressions, and it
|
|
17
|
+
* is invisible to every other gate in this suite: `actionlint` validates the
|
|
18
|
+
* schema (a string is a valid string), and no reference checker resolves a
|
|
19
|
+
* glob against the tree.
|
|
20
|
+
*
|
|
21
|
+
* ## Measured, not assumed
|
|
22
|
+
*
|
|
23
|
+
* The census that motivated this gate found **20** dead entries across 6
|
|
24
|
+
* workflow files in four classes: a source tree retired by ADR-051, a
|
|
25
|
+
* **gitignored** projection tree (matched by no diff, ever), two paths that
|
|
26
|
+
* moved during the Python-to-TS migration, and two bare repo-root paths whose
|
|
27
|
+
* real files live under `src/`. All 20 were classified before this gate was
|
|
28
|
+
* written, and all 20 were repaired in the same change — so the gate ships
|
|
29
|
+
* **strict** over a corpus verified empty, rather than advisory over a corpus
|
|
30
|
+
* nobody looked at. A gate that reports zero findings on a corpus it has never
|
|
31
|
+
* been proven able to fail on is blind, not clean; `--self-test` is what
|
|
32
|
+
* separates those two.
|
|
33
|
+
*
|
|
34
|
+
* ## What counts as a match
|
|
35
|
+
*
|
|
36
|
+
* A glob matches when at least one **tracked** file matches it. Tracked, not
|
|
37
|
+
* on-disk, is the correct universe on purpose: a `paths:` filter is evaluated
|
|
38
|
+
* against a diff, and only tracked files appear in diffs. This is exactly why
|
|
39
|
+
* `.augment/**` is dead here despite the directory existing locally — it is
|
|
40
|
+
* gitignored, so no change to it can ever reach a pull request.
|
|
41
|
+
*
|
|
42
|
+
* Directory-shaped entries (`taskfiles/**`, or a bare `src/scripts`) match by
|
|
43
|
+
* prefix, mirroring GitHub's own semantics.
|
|
44
|
+
*
|
|
45
|
+
* ## The escape, and why it is inline
|
|
46
|
+
*
|
|
47
|
+
* A filter naming a path that does not exist *yet* — a tree an in-flight PR
|
|
48
|
+
* introduces — declares itself on the entry:
|
|
49
|
+
*
|
|
50
|
+
* - "future/tree/ **" # workflow-path-allow: lands in PR #1234, filter pre-staged
|
|
51
|
+
*
|
|
52
|
+
* The reason is required and must be more than one word. There is deliberately
|
|
53
|
+
* no allowlist JSON: a side-channel file is the shape that grows past twenty
|
|
54
|
+
* entries and quietly becomes the budget bypass `autonomous-execution` names.
|
|
55
|
+
*/
|
|
56
|
+
import * as fs from 'node:fs';
|
|
57
|
+
import { execFileSync } from 'node:child_process';
|
|
58
|
+
import * as os from 'node:os';
|
|
59
|
+
import * as path from 'node:path';
|
|
60
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
|
+
|
|
62
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
63
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
64
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
65
|
+
|
|
66
|
+
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
67
|
+
const REAL_REPO_ROOT = path.resolve(SCRIPTS_DIR, '..', '..');
|
|
68
|
+
|
|
69
|
+
const ALLOW_RE = /#\s*workflow-path-allow:\s*(.+?)\s*$/;
|
|
70
|
+
/** A reason must carry real content — two words minimum, eight characters. */
|
|
71
|
+
const MIN_REASON_WORDS = 2;
|
|
72
|
+
const MIN_REASON_CHARS = 8;
|
|
73
|
+
|
|
74
|
+
export interface PathFinding {
|
|
75
|
+
readonly file: string;
|
|
76
|
+
readonly line: number;
|
|
77
|
+
readonly glob: string;
|
|
78
|
+
readonly why: 'no-match' | 'thin-reason';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Translate the subset of glob syntax GitHub path filters use into a RegExp.
|
|
83
|
+
*
|
|
84
|
+
* `**` crosses directory separators, `*` and `?` do not. Everything else is a
|
|
85
|
+
* literal — no brace expansion, no character classes, because GitHub does not
|
|
86
|
+
* document them for this field and guessing at unsupported syntax would make
|
|
87
|
+
* the gate disagree with the thing it is auditing.
|
|
88
|
+
*/
|
|
89
|
+
export function globToRegExp(glob: string): RegExp {
|
|
90
|
+
let out = '';
|
|
91
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
92
|
+
const c = glob[i] as string;
|
|
93
|
+
if (c === '*') {
|
|
94
|
+
if (glob[i + 1] === '*') {
|
|
95
|
+
out += '.*';
|
|
96
|
+
i += 1;
|
|
97
|
+
if (glob[i + 1] === '/') {
|
|
98
|
+
i += 1;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
out += '[^/]*';
|
|
102
|
+
}
|
|
103
|
+
} else if (c === '?') {
|
|
104
|
+
out += '[^/]';
|
|
105
|
+
} else {
|
|
106
|
+
out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new RegExp(`^${out}$`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Does `glob` match at least one path in `tracked`? */
|
|
113
|
+
export function globMatchesAny(glob: string, tracked: readonly string[]): boolean {
|
|
114
|
+
const re = globToRegExp(glob);
|
|
115
|
+
const prefix = `${glob.replace(/\/?\*\*$/, '').replace(/\/$/, '')}/`;
|
|
116
|
+
return tracked.some((t) => re.test(t) || t === glob || t.startsWith(prefix));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract every `paths:` / `paths-ignore:` list entry from one workflow body.
|
|
121
|
+
*
|
|
122
|
+
* A hand-rolled scan rather than a YAML parse, deliberately: the gate must
|
|
123
|
+
* report the LINE an entry sits on so a maintainer can jump to it, and a parsed
|
|
124
|
+
* document has thrown that away. The block ends at the first non-comment,
|
|
125
|
+
* non-list line — which is how YAML block sequences end anyway.
|
|
126
|
+
*/
|
|
127
|
+
export function extractPathEntries(
|
|
128
|
+
body: string,
|
|
129
|
+
): { line: number; glob: string; allow: string | null }[] {
|
|
130
|
+
const out: { line: number; glob: string; allow: string | null }[] = [];
|
|
131
|
+
let inBlock = false;
|
|
132
|
+
const lines = body.split('\n');
|
|
133
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
134
|
+
const line = lines[i] as string;
|
|
135
|
+
if (/^\s*paths(-ignore)?:\s*$/.test(line)) {
|
|
136
|
+
inBlock = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!inBlock) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (line.trim() === '' || line.trim().startsWith('#')) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const m = line.match(/^\s*-\s*(?:'([^']*)'|"([^"]*)"|([^#\s]+))\s*(#.*)?$/);
|
|
146
|
+
if (m === null) {
|
|
147
|
+
inBlock = false;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const glob = (m[1] ?? m[2] ?? m[3] ?? '').trim();
|
|
151
|
+
if (glob === '') {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const allowMatch = (m[4] ?? '').match(ALLOW_RE);
|
|
155
|
+
out.push({ line: i + 1, glob, allow: allowMatch ? (allowMatch[1] as string) : null });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _trackedFiles(repoRoot: string): string[] {
|
|
161
|
+
try {
|
|
162
|
+
return execFileSync('git', ['ls-files'], {
|
|
163
|
+
cwd: repoRoot,
|
|
164
|
+
encoding: 'utf-8',
|
|
165
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
166
|
+
})
|
|
167
|
+
.split('\n')
|
|
168
|
+
.filter((l) => l !== '');
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _reasonIsThin(reason: string): boolean {
|
|
175
|
+
return (
|
|
176
|
+
reason.trim().length < MIN_REASON_CHARS ||
|
|
177
|
+
reason.trim().split(/\s+/).filter(Boolean).length < MIN_REASON_WORDS
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ScanOptions {
|
|
182
|
+
readonly repoRoot: string;
|
|
183
|
+
readonly workflowDir: string;
|
|
184
|
+
readonly tracked: readonly string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function scanWorkflowDir(
|
|
188
|
+
opts: ScanOptions,
|
|
189
|
+
ledger: GateLedger,
|
|
190
|
+
): PathFinding[] {
|
|
191
|
+
const findings: PathFinding[] = [];
|
|
192
|
+
let files: string[] = [];
|
|
193
|
+
try {
|
|
194
|
+
files = fs
|
|
195
|
+
.readdirSync(opts.workflowDir)
|
|
196
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
197
|
+
.sort();
|
|
198
|
+
} catch {
|
|
199
|
+
files = [];
|
|
200
|
+
}
|
|
201
|
+
ledger.plan(files);
|
|
202
|
+
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
let body: string;
|
|
205
|
+
try {
|
|
206
|
+
body = fs.readFileSync(path.join(opts.workflowDir, f), 'utf-8');
|
|
207
|
+
} catch {
|
|
208
|
+
ledger.fail(f, 'unreadable');
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const before = findings.length;
|
|
212
|
+
for (const entry of extractPathEntries(body)) {
|
|
213
|
+
if (entry.allow !== null) {
|
|
214
|
+
if (_reasonIsThin(entry.allow)) {
|
|
215
|
+
findings.push({ file: f, line: entry.line, glob: entry.glob, why: 'thin-reason' });
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!globMatchesAny(entry.glob, opts.tracked)) {
|
|
220
|
+
findings.push({ file: f, line: entry.line, glob: entry.glob, why: 'no-match' });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (findings.length > before) {
|
|
224
|
+
ledger.fail(f, `${String(findings.length - before)} dead path filter(s)`);
|
|
225
|
+
} else {
|
|
226
|
+
ledger.complete(f);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return findings;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface Args {
|
|
233
|
+
quiet: boolean;
|
|
234
|
+
selfTest: boolean;
|
|
235
|
+
root: string | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _parseArgs(argv: readonly string[]): Args {
|
|
239
|
+
const args: Args = { quiet: false, selfTest: false, root: null };
|
|
240
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
241
|
+
const a = argv[i];
|
|
242
|
+
if (a === '--quiet') {
|
|
243
|
+
args.quiet = true;
|
|
244
|
+
} else if (a === '--self-test') {
|
|
245
|
+
args.selfTest = true;
|
|
246
|
+
} else if (a === '--root') {
|
|
247
|
+
args.root = argv[i + 1] ?? null;
|
|
248
|
+
i += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return args;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _selfTest(): number {
|
|
255
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lwp-selftest-'));
|
|
256
|
+
const mkRepo = (name: string, workflow: string): string => {
|
|
257
|
+
const repo = path.join(tmp, name);
|
|
258
|
+
fs.mkdirSync(path.join(repo, '.github', 'workflows'), { recursive: true });
|
|
259
|
+
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
|
260
|
+
fs.writeFileSync(path.join(repo, 'src', 'real.ts'), '// real\n');
|
|
261
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
262
|
+
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
263
|
+
fs.writeFileSync(path.join(repo, '.github', 'workflows', 'w.yml'), workflow, 'utf-8');
|
|
264
|
+
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
265
|
+
return repo;
|
|
266
|
+
};
|
|
267
|
+
const run = (repo: string): number =>
|
|
268
|
+
runGateCli(
|
|
269
|
+
REAL_REPO_ROOT,
|
|
270
|
+
'src/scripts/lint_workflow_paths.ts',
|
|
271
|
+
['--root', repo, '--quiet'],
|
|
272
|
+
REAL_REPO_ROOT,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const HEAD = 'on:\n pull_request:\n paths:\n';
|
|
276
|
+
try {
|
|
277
|
+
const dead = mkRepo('dead', `${HEAD} - "retired/tree/**"\n`);
|
|
278
|
+
const live = mkRepo('live', `${HEAD} - "src/**"\n`);
|
|
279
|
+
const allowed = mkRepo(
|
|
280
|
+
'allowed',
|
|
281
|
+
`${HEAD} - "future/tree/**" # workflow-path-allow: lands in the follow-up PR\n`,
|
|
282
|
+
);
|
|
283
|
+
const thin = mkRepo('thin', `${HEAD} - "future/tree/**" # workflow-path-allow: soon\n`);
|
|
284
|
+
const bareDir = mkRepo('baredir', `${HEAD} - "src"\n`);
|
|
285
|
+
return runSelfTest({
|
|
286
|
+
gate: 'lint_workflow_paths',
|
|
287
|
+
minCases: 4,
|
|
288
|
+
minRejectCases: 2,
|
|
289
|
+
cases: [
|
|
290
|
+
{
|
|
291
|
+
name: 'a filter naming a retired tree is rejected',
|
|
292
|
+
expect: 'reject',
|
|
293
|
+
run: () => run(dead),
|
|
294
|
+
},
|
|
295
|
+
{ name: 'a filter that matches tracked files passes', expect: 'accept', run: () => run(live) },
|
|
296
|
+
{
|
|
297
|
+
name: 'a bare directory entry matches by prefix, as GitHub does',
|
|
298
|
+
expect: 'accept',
|
|
299
|
+
run: () => run(bareDir),
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'a declared pre-staged filter with a real reason passes',
|
|
303
|
+
expect: 'accept',
|
|
304
|
+
run: () => run(allowed),
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'a one-word reason is rejected — an unclassifiable exception is boilerplate',
|
|
308
|
+
expect: 'reject',
|
|
309
|
+
run: () => run(thin),
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
} finally {
|
|
314
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
319
|
+
const args = _parseArgs(argv);
|
|
320
|
+
if (args.selfTest) {
|
|
321
|
+
return _selfTest();
|
|
322
|
+
}
|
|
323
|
+
const repoRoot = args.root !== null ? path.resolve(args.root) : REAL_REPO_ROOT;
|
|
324
|
+
const workflowDir = path.join(repoRoot, '.github', 'workflows');
|
|
325
|
+
const tracked = _trackedFiles(repoRoot);
|
|
326
|
+
|
|
327
|
+
const ledger = new GateLedger('lint_workflow_paths');
|
|
328
|
+
const findings = scanWorkflowDir({ repoRoot, workflowDir, tracked }, ledger);
|
|
329
|
+
const tally = ledger.finalize();
|
|
330
|
+
|
|
331
|
+
if (!args.quiet && findings.length > 0) {
|
|
332
|
+
process.stdout.write('\nlint_workflow_paths — path filters that can never fire:\n\n');
|
|
333
|
+
for (const f of findings) {
|
|
334
|
+
const why =
|
|
335
|
+
f.why === 'no-match'
|
|
336
|
+
? 'matches no tracked file'
|
|
337
|
+
: 'workflow-path-allow reason is too thin to classify';
|
|
338
|
+
process.stdout.write(
|
|
339
|
+
` ${f.file}:${String(f.line)} ${f.glob}\n ${why}\n`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
process.stdout.write(
|
|
343
|
+
'\n Fix: delete the entry if the tree is retired, or repoint it if the path moved.\n' +
|
|
344
|
+
' A filter that matches nothing does not over-trigger — it never triggers.\n',
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (!args.quiet) {
|
|
348
|
+
process.stdout.write(
|
|
349
|
+
`\nlint_workflow_paths: ${String(findings.length)} dead filter(s) across ` +
|
|
350
|
+
`${String(new Set(findings.map((f) => f.file)).size)} workflow file(s)\n`,
|
|
351
|
+
);
|
|
352
|
+
ledger.report();
|
|
353
|
+
}
|
|
354
|
+
reportScanned({
|
|
355
|
+
gate: 'lint_workflow_paths',
|
|
356
|
+
scanned: tally.completed + tally.failed,
|
|
357
|
+
units: 'workflow file(s)',
|
|
358
|
+
roots: ['.github/workflows'],
|
|
359
|
+
});
|
|
360
|
+
return findings.length > 0 ? 1 : 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
364
|
+
function _isCliEntry(): boolean {
|
|
365
|
+
if (process.argv[1] === undefined) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
369
|
+
if (import.meta.url === argvUrl) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
return (
|
|
374
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
375
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
376
|
+
);
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
383
|
+
process.exit(main());
|
|
384
|
+
}
|
|
@@ -557,7 +557,15 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
557
557
|
return 0;
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
-
|
|
560
|
+
// `__AGENT_CONFIG_BUNDLE__` is defined true by every esbuild target. Inside a
|
|
561
|
+
// bundle `import.meta.url` is the OUTPUT file's URL for every bundled module,
|
|
562
|
+
// so the guard below would match whenever the bundle is invoked directly — and
|
|
563
|
+
// importing this module from a bundled CLI entry (`cmd_doctor` bundles under
|
|
564
|
+
// `build:cli-delegate`) would run the census and exit before that entry's own
|
|
565
|
+
// main. Same shape as the guard in `dispatch_economy_report.ts`.
|
|
566
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
567
|
+
const _isBundled = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
|
|
568
|
+
if (!_isBundled && process.argv[1] !== undefined) {
|
|
561
569
|
const invokedUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
562
570
|
if (import.meta.url === invokedUrl) process.exit(main());
|
|
563
571
|
}
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
* ignored, which is the failure `check-rule-invariants` already paid for once.
|
|
36
36
|
* The point is that the condition becomes VISIBLE, not that it becomes illegal.
|
|
37
37
|
*
|
|
38
|
+
* BEFORE QUOTING A COUNT FROM THIS REPORT
|
|
39
|
+
* ---------------------------------------
|
|
40
|
+
* Read `agents/settings/contexts/carrier-divergence-109-vs-24.md` first. The
|
|
41
|
+
* figure "109 divergent pairs, binding undefined" reached five independent
|
|
42
|
+
* release reviews as the tree's largest technical debt; all 109 carry
|
|
43
|
+
* byte-identical prose, and the real actionable set is the 24-pair `paths:`
|
|
44
|
+
* subset below, in the over-delivery direction. That correction had no stable
|
|
45
|
+
* surface, which is why every fresh reader re-derived the wrong number from
|
|
46
|
+
* this report's own output.
|
|
47
|
+
*
|
|
38
48
|
* THE FOUR CLASSES, AND WHICH ONE A READER MUST ACT ON
|
|
39
49
|
* ---------------------------------------------------
|
|
40
50
|
* body-diff the two carriers deliver different PROSE. This is the class
|