@event4u/agent-config 9.10.0 → 9.12.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 +111 -0
- package/README.md +20 -16
- package/agents/templates/.ai-council.yml.example +481 -0
- package/agents/templates/.ai-video.xml.example +247 -0
- package/dist/agent-src/commands/fix/ci.md +29 -0
- package/dist/agent-src/commands/pr/create.md +60 -0
- package/dist/agent-src/contexts/authority/kernel-rule-edits.md +18 -2
- package/dist/agent-src/rules/design-fidelity.md +2 -2
- package/dist/agent-src/skills/ai-code-blindspots/SKILL.md +1 -0
- package/dist/agent-src/skills/design-intelligence/SKILL.md +27 -1
- package/dist/agent-src/skills/design-intelligence/data/font-pairings-reference.csv +74 -74
- package/dist/agent-src/skills/design-intelligence/data/manifest.json +2 -0
- package/dist/agent-src/skills/design-tokens/scripts/tokens.ts +0 -7
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +15 -2
- package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
- package/dist/agent-src/skills/typography-system/SKILL.md +55 -11
- package/dist/agent-src/skills/ui-apply-generic/SKILL.md +180 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
- package/dist/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
- package/dist/agent-src/templates/scripts/work_engine/state.ts +21 -4
- package/dist/cli/initRouting.js +74 -11
- package/dist/cli/initRouting.js.map +1 -1
- package/dist/cli/main.js +14 -3
- package/dist/cli/main.js.map +1 -1
- package/dist/cli-delegate/chunk-523HCFYO.js +90 -0
- package/dist/cli-delegate/chunk-736JCWLM.js +1104 -0
- package/dist/cli-delegate/chunk-7HQ52DHU.js +74 -0
- package/dist/cli-delegate/chunk-A3HJ6NTI.js +1084 -0
- package/dist/cli-delegate/chunk-EJYIGZ3T.js +125 -0
- package/dist/cli-delegate/chunk-FTAISGDQ.js +117 -0
- package/dist/cli-delegate/chunk-GME3Q44L.js +82 -0
- package/dist/cli-delegate/chunk-GRRPEPRN.js +336 -0
- package/dist/cli-delegate/chunk-HJ6DAJHR.js +502 -0
- package/dist/cli-delegate/chunk-HYWSRNUX.js +179 -0
- package/dist/cli-delegate/chunk-J55WI3RR.js +47 -0
- package/dist/cli-delegate/chunk-MCJYCQPF.js +35 -0
- package/dist/cli-delegate/chunk-NDJLNC6Q.js +169 -0
- package/dist/cli-delegate/chunk-OMIVVCG2.js +26 -0
- package/dist/cli-delegate/chunk-OY53J6II.js +7336 -0
- package/dist/cli-delegate/chunk-QFKQLQ7X.js +10835 -0
- package/dist/cli-delegate/chunk-TEGJU2JX.js +6908 -0
- package/dist/cli-delegate/chunk-TJIAM5FW.js +133 -0
- package/dist/cli-delegate/chunk-XX6TOPD7.js +715 -0
- package/dist/cli-delegate/cmd_analyze_session.js +294 -0
- package/dist/cli-delegate/cmd_conformance.js +643 -0
- package/dist/cli-delegate/cmd_converge.js +326 -0
- package/dist/cli-delegate/cmd_doctor.js +142 -0
- package/dist/cli-delegate/cmd_explain.js +2576 -0
- package/dist/cli-delegate/cmd_export.js +298 -0
- package/dist/cli-delegate/cmd_fleet.js +333 -0
- package/dist/cli-delegate/cmd_memory_get.js +3878 -0
- package/dist/cli-delegate/cmd_migrate.js +11 -0
- package/dist/cli-delegate/cmd_preflight.js +153 -0
- package/dist/cli-delegate/cmd_prune.js +446 -0
- package/dist/cli-delegate/cmd_refresh.js +28 -0
- package/dist/cli-delegate/cmd_settings_check.js +574 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +225 -0
- package/dist/cli-delegate/cmd_sync.js +266 -0
- package/dist/cli-delegate/cmd_uninstall.js +657 -0
- package/dist/cli-delegate/cmd_update.js +425 -0
- package/dist/cli-delegate/cmd_upgrade.js +444 -0
- package/dist/cli-delegate/cmd_validate.js +307 -0
- package/dist/cli-delegate/cmd_versions.js +370 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +45 -16
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +13 -7
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +6 -4
- package/dist/install/emit_host_rules_cli.js +2 -1
- package/dist/install/emit_host_rules_cli.js.map +1 -1
- package/dist/install/install.mjs +634 -409
- package/dist/install/ruleInScope.js +123 -0
- package/dist/install/ruleInScope.js.map +1 -0
- package/dist/install/rule_scope.js +7 -3
- package/dist/install/rule_scope.js.map +1 -1
- package/dist/install/toolDetection.js +40 -12
- package/dist/install/toolDetection.js.map +1 -1
- package/dist/install/wizard-plan.js +12 -3
- package/dist/install/wizard-plan.js.map +1 -1
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1553 -2728
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +6 -22
- package/dist/scripts/condense.js.map +1 -1
- package/docs/guidelines/design-fidelity-mechanics.md +14 -0
- package/package.json +4 -2
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +34 -30
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +88 -33
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +23 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +24 -1
- package/src/agent-src/templates/scripts/work_engine/directives/ui/stack_bundles.ts +332 -0
- package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +449 -11
- package/src/agent-src/templates/scripts/work_engine/state.ts +21 -4
- package/src/config/discovery/packs.yml +7 -0
- package/src/config/evaluator-budgets.json +1 -1
- package/src/config/pack-size-budget.json +27 -0
- package/src/install/emit_host_rules_cli.ts +2 -1
- package/src/install/ruleInScope.ts +133 -0
- package/src/install/rule_scope.ts +7 -3
- package/src/install/toolDetection.ts +39 -8
- package/src/install/wizard-plan.ts +13 -3
- package/src/scripts/_cli/cmd_conformance.ts +2 -1
- package/src/scripts/_cli/cmd_converge.ts +2 -1
- package/src/scripts/_cli/cmd_doctor.ts +214 -13
- package/src/scripts/_cli/cmd_export.ts +2 -1
- package/src/scripts/_cli/cmd_fleet.ts +2 -1
- package/src/scripts/_cli/cmd_migrate.ts +2 -1
- package/src/scripts/_cli/cmd_preflight.ts +2 -1
- package/src/scripts/_cli/cmd_refresh.ts +2 -1
- package/src/scripts/_cli/cmd_update.ts +2 -1
- package/src/scripts/_cli/cmd_upgrade.ts +2 -1
- package/src/scripts/_cli/cmd_versions.ts +2 -1
- package/src/scripts/_cli/detection_report.ts +480 -0
- package/src/scripts/_dispatch.bash +52 -14
- package/src/scripts/_lib/changelog_eras.ts +103 -9
- package/src/scripts/_lib/environment_detector.ts +441 -0
- package/src/scripts/_lib/global_deploy_inventory.ts +38 -2
- package/src/scripts/_lib/package_root.ts +72 -0
- package/src/scripts/ai_council/config.ts +15 -7
- package/src/scripts/ai_council/modes.ts +68 -2
- package/src/scripts/ai_council/transport_resolver.ts +331 -0
- package/src/scripts/check_bundle_path_leakage.ts +10 -4
- package/src/scripts/check_dependency_floors.ts +153 -0
- package/src/scripts/check_installer_import_purity.ts +295 -0
- package/src/scripts/check_pack_size.ts +188 -0
- package/src/scripts/condense.ts +7 -28
- package/src/scripts/council_cli.ts +6 -2
- package/src/scripts/evaluator_umbrella.sh +34 -1
- package/src/scripts/install-hooks.sh +36 -4
- package/src/scripts/install.ts +210 -5
- package/src/scripts/lint_eval_fixture_citations.ts +113 -0
- package/src/scripts/lint_ui_stack_bundles.ts +201 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* check_dependency_floors — keep every runtime dependency floor at a settled
|
|
4
|
+
* minor, so consumers on `prefer-offline=true` or a lagging registry mirror
|
|
5
|
+
* cannot hit `ETARGET — No matching version found`
|
|
6
|
+
* (road-to-zero-ceremony-install § Phase 2).
|
|
7
|
+
*
|
|
8
|
+
* Why a gate and not a retry: `dependencies` are resolved on the CONSUMER's
|
|
9
|
+
* machine by npm, BEFORE our `bin` is executed. When resolution fails, npx
|
|
10
|
+
* aborts and no code of ours ever runs — so this failure mode cannot be
|
|
11
|
+
* caught, retried, or explained from inside the CLI. It can only be made
|
|
12
|
+
* unreachable at publish time, which is what this gate does. CONTRIBUTING
|
|
13
|
+
* § "Runtime dependency floors" states the rule in prose; this is its teeth.
|
|
14
|
+
*
|
|
15
|
+
* The rule: a caret floor pins the minimum compatible version, so its patch
|
|
16
|
+
* component is `.0` (`^9.5.0`, never `^9.6.1`). A floor pinned to a
|
|
17
|
+
* just-published patch fails against cached registry metadata that predates
|
|
18
|
+
* it, even though the version exists on public npm.
|
|
19
|
+
*
|
|
20
|
+
* Exact pins (no range operator) are a different, deliberate mechanism — an
|
|
21
|
+
* ABI lock — and are allowed only when listed in EXACT_PIN_EXCEPTIONS with a
|
|
22
|
+
* reason, so a drive-by pin cannot hide among them.
|
|
23
|
+
*
|
|
24
|
+
* SECURITY FLOORS OUTRANK THIS RULE. A floor whose patch component is non-zero
|
|
25
|
+
* *because everything below it carries an advisory* must never be lowered to
|
|
26
|
+
* satisfy resolvability — that trades a rare install failure for a live CVE.
|
|
27
|
+
* Those live in SECURITY_FLOOR_EXCEPTIONS with the advisory and the vulnerable
|
|
28
|
+
* range, and the gate then enforces the floor rather than fighting it.
|
|
29
|
+
*
|
|
30
|
+
* Usage:
|
|
31
|
+
* ./scripts-run src/scripts/check_dependency_floors [--json]
|
|
32
|
+
*
|
|
33
|
+
* Exit codes: 0 green · 1 a floor violates the rule · 2 misuse / unreadable manifest.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import * as fs from 'node:fs';
|
|
37
|
+
import * as path from 'node:path';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Dependencies deliberately pinned to an exact version, with the reason. An
|
|
44
|
+
* exact pin cannot suffer the freshest-patch failure (the version is either
|
|
45
|
+
* cached or it is not), but it also forfeits patch fixes — so each one is
|
|
46
|
+
* named here rather than silently tolerated.
|
|
47
|
+
*/
|
|
48
|
+
export const EXACT_PIN_EXCEPTIONS: Readonly<Record<string, string>> = {
|
|
49
|
+
'web-tree-sitter': 'ABI-locked against tree-sitter-wasms; see code-graph engine pin',
|
|
50
|
+
'tree-sitter-wasms': 'ABI-locked against web-tree-sitter; see code-graph engine pin',
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Floors that are non-settled ON PURPOSE, because every version below them is
|
|
55
|
+
* vulnerable. The required range is pinned here so the gate ENFORCES the floor
|
|
56
|
+
* instead of asking for it to be lowered — lowering one trades a rare install
|
|
57
|
+
* failure for a live advisory, which is never the right trade.
|
|
58
|
+
*/
|
|
59
|
+
export const SECURITY_FLOOR_EXCEPTIONS: Readonly<Record<string, { required: string; reason: string }>> = {
|
|
60
|
+
'@fastify/static': {
|
|
61
|
+
required: '^10.1.2',
|
|
62
|
+
reason:
|
|
63
|
+
'high-severity route-guard bypass via path traversal (CVSS 7.5) affects <=10.1.1; ' +
|
|
64
|
+
'10.1.2 is the fix. Verified with npm audit on a clean 10.1.0 install, 2026-07-31.',
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** A settled caret floor: `^X.Y.0`. */
|
|
69
|
+
const SETTLED_CARET = /^\^\d+\.\d+\.0$/;
|
|
70
|
+
|
|
71
|
+
/** An exact version with no range operator: `1.2.3`. */
|
|
72
|
+
const EXACT_VERSION = /^\d+\.\d+\.\d+$/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Every violation in `dependencies`, as human-readable lines. Empty array
|
|
76
|
+
* means the manifest cannot produce a freshest-patch `ETARGET` for a consumer.
|
|
77
|
+
*/
|
|
78
|
+
export function evaluate(dependencies: Readonly<Record<string, string>>): string[] {
|
|
79
|
+
const errors: string[] = [];
|
|
80
|
+
for (const [name, range] of Object.entries(dependencies)) {
|
|
81
|
+
const security = SECURITY_FLOOR_EXCEPTIONS[name];
|
|
82
|
+
if (security !== undefined) {
|
|
83
|
+
// The floor is the control. Enforce it; never ask for it to settle.
|
|
84
|
+
if (range !== security.required) {
|
|
85
|
+
errors.push(
|
|
86
|
+
`${name}@${range}: security floor must stay at ${security.required} — ${security.reason}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (EXACT_VERSION.test(range)) {
|
|
92
|
+
if (!(name in EXACT_PIN_EXCEPTIONS)) {
|
|
93
|
+
errors.push(
|
|
94
|
+
`${name}@${range}: exact pin without an entry in EXACT_PIN_EXCEPTIONS — ` +
|
|
95
|
+
'add the reason there, or use a settled caret floor (^X.Y.0)',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (SETTLED_CARET.test(range)) continue;
|
|
101
|
+
if (range.startsWith('^')) {
|
|
102
|
+
errors.push(
|
|
103
|
+
`${name}@${range}: caret floor is not a settled minor — lower the patch to ` +
|
|
104
|
+
`.0 (e.g. ${range.replace(/\.\d+$/, '.0')}) so a lagging registry mirror ` +
|
|
105
|
+
'can still resolve it',
|
|
106
|
+
);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
errors.push(
|
|
110
|
+
`${name}@${range}: unsupported range shape — use a settled caret floor (^X.Y.0), ` +
|
|
111
|
+
'or an exact pin listed in EXACT_PIN_EXCEPTIONS',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return errors;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
118
|
+
const asJson = argv.includes('--json');
|
|
119
|
+
const manifestPath = path.join(REPO_ROOT, 'package.json');
|
|
120
|
+
let dependencies: Record<string, string>;
|
|
121
|
+
try {
|
|
122
|
+
const raw = JSON.parse(fs.readFileSync(manifestPath, 'utf-8')) as {
|
|
123
|
+
dependencies?: Record<string, string>;
|
|
124
|
+
};
|
|
125
|
+
dependencies = raw.dependencies ?? {};
|
|
126
|
+
} catch (err) {
|
|
127
|
+
process.stderr.write(`❌ dependency floors: cannot read ${manifestPath}: ${String(err)}\n`);
|
|
128
|
+
return 2;
|
|
129
|
+
}
|
|
130
|
+
if (Object.keys(dependencies).length === 0) {
|
|
131
|
+
process.stderr.write('❌ dependency floors: no runtime dependencies found — gate would pass vacuously\n');
|
|
132
|
+
return 2;
|
|
133
|
+
}
|
|
134
|
+
const errors = evaluate(dependencies);
|
|
135
|
+
if (asJson) {
|
|
136
|
+
process.stdout.write(`${JSON.stringify({ checked: Object.keys(dependencies).length, errors }, null, 2)}\n`);
|
|
137
|
+
return errors.length > 0 ? 1 : 0;
|
|
138
|
+
}
|
|
139
|
+
if (errors.length > 0) {
|
|
140
|
+
for (const e of errors) process.stderr.write(`❌ dependency floor: ${e}\n`);
|
|
141
|
+
return 1;
|
|
142
|
+
}
|
|
143
|
+
process.stdout.write(
|
|
144
|
+
`✅ dependency floors settled (${Object.keys(dependencies).length} runtime deps) — ` +
|
|
145
|
+
'no freshest-patch ETARGET reachable for a consumer\n',
|
|
146
|
+
);
|
|
147
|
+
return 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const _selfPath = fileURLToPath(import.meta.url);
|
|
151
|
+
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(_selfPath)) {
|
|
152
|
+
process.exit(main());
|
|
153
|
+
}
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Guard against a maintainer-only CLI being bundled into the consumer installer.
|
|
4
|
+
*
|
|
5
|
+
* ## The incident this exists to prevent (road-to-consistent-rule-scoping, 2026-07-31)
|
|
6
|
+
*
|
|
7
|
+
* `src/install/rule_scope.ts` imported the rule-scoping predicate from
|
|
8
|
+
* `src/scripts/condense.ts` — deliberately, so install semantics could not drift
|
|
9
|
+
* from projection semantics. Correct intent. But `condense.ts` is maintainer CLI
|
|
10
|
+
* tooling that ends in a module-level self-invoke:
|
|
11
|
+
*
|
|
12
|
+
* const isMain = _isCliEntry();
|
|
13
|
+
* if (isMain) { process.exit(main()); }
|
|
14
|
+
*
|
|
15
|
+
* `_isCliEntry()` compares `import.meta.url` against `process.argv[1]`. That is
|
|
16
|
+
* right for a file run directly — and wrong once esbuild BUNDLES the module,
|
|
17
|
+
* because `import.meta.url` then resolves to the BUNDLE's own URL, which *is*
|
|
18
|
+
* `argv[1]` when a consumer runs `node dist/install/install.mjs`. The guard
|
|
19
|
+
* fired inside the installer, `condense.main()` ran, found no maintainer source
|
|
20
|
+
* tree in a consumer checkout, printed "No source directory found" and called
|
|
21
|
+
* `process.exit(1)` — killing the install AFTER the payload had already synced.
|
|
22
|
+
*
|
|
23
|
+
* All four `smoke-public-install` legs went red. Nothing caught it earlier:
|
|
24
|
+
* `prepack-check` verifies that imports resolve to SHIPPED paths (they did),
|
|
25
|
+
* the bundle-freshness gate only byte-diffs the rebuilt output, and the
|
|
26
|
+
* behavioural smoke test only runs post-push in its own workflow.
|
|
27
|
+
*
|
|
28
|
+
* ## What this checks
|
|
29
|
+
*
|
|
30
|
+
* The transitive relative-import closure of the installer bundle entry must
|
|
31
|
+
* contain NO module-level `process.exit(` outside the entry itself.
|
|
32
|
+
*
|
|
33
|
+
* "Module-level" is decided structurally, not textually: the AST walk descends
|
|
34
|
+
* through top-level statements and nested blocks/ifs/try but STOPS at any
|
|
35
|
+
* function, method, class, arrow or accessor body. So
|
|
36
|
+
* `function main() { process.exit(1) }` is fine — it only runs when called —
|
|
37
|
+
* while `if (isMain) { process.exit(main()) }` is not, because importing the
|
|
38
|
+
* module runs it.
|
|
39
|
+
*
|
|
40
|
+
* The entry module is exempt: it IS the CLI, and its own exit is the point.
|
|
41
|
+
*
|
|
42
|
+
* ## Why this shape and not the alternatives
|
|
43
|
+
*
|
|
44
|
+
* - **Not a string scan of the built bundle.** That reports a mystery literal
|
|
45
|
+
* with no cause. This reports the import CHAIN
|
|
46
|
+
* (`install.ts → rule_scope.ts → condense.ts`), which is the actionable fact.
|
|
47
|
+
* - **Not "forbid importing condense.ts".** A denylist of one file rots the
|
|
48
|
+
* moment a second CLI module grows an entry guard. The hazard is the
|
|
49
|
+
* module-level exit, so that is what is detected.
|
|
50
|
+
* - **Runs on SOURCE, so it needs no build.** It fails in `task ci` before a
|
|
51
|
+
* push instead of on four CI legs after one.
|
|
52
|
+
*
|
|
53
|
+
* Exit 0 clean, 1 on any finding. No network, no build, no spend.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import * as fs from 'node:fs';
|
|
57
|
+
import * as path from 'node:path';
|
|
58
|
+
import { fileURLToPath } from 'node:url';
|
|
59
|
+
|
|
60
|
+
import ts from 'typescript';
|
|
61
|
+
|
|
62
|
+
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
63
|
+
const REPO_ROOT = path.resolve(_HERE, '..', '..');
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Bundle entry points that ship to consumers as a single file.
|
|
67
|
+
*
|
|
68
|
+
* Keep in sync with the `build:*bundle` scripts in package.json. Each entry is
|
|
69
|
+
* exempt from the check for its own module-level exit — it is the CLI.
|
|
70
|
+
*/
|
|
71
|
+
const BUNDLE_ENTRIES: readonly string[] = [
|
|
72
|
+
'src/scripts/install.ts', // → dist/install/install.mjs (build:install-bundle)
|
|
73
|
+
];
|
|
74
|
+
|
|
75
|
+
interface Finding {
|
|
76
|
+
/** The offending module, repo-relative POSIX. */
|
|
77
|
+
readonly module: string;
|
|
78
|
+
/** 1-indexed line of the offending call. */
|
|
79
|
+
readonly line: number;
|
|
80
|
+
/** How the entry reaches it, entry first. */
|
|
81
|
+
readonly chain: readonly string[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function toPosix(p: string): string {
|
|
85
|
+
return p.split(path.sep).join('/');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function relToRepo(abs: string, root: string = REPO_ROOT): string {
|
|
89
|
+
return toPosix(path.relative(root, abs));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve a relative import specifier to a source file on disk.
|
|
94
|
+
*
|
|
95
|
+
* The codebase imports with `.js` extensions (NodeNext) while the sources are
|
|
96
|
+
* `.ts`, so try the TypeScript twin first, then the literal path, then an
|
|
97
|
+
* `index` file. Returns `null` for anything unresolvable — a bare package
|
|
98
|
+
* specifier, a type-only path that no longer exists, or a `.json` asset.
|
|
99
|
+
*/
|
|
100
|
+
function resolveImport(fromFile: string, specifier: string): string | null {
|
|
101
|
+
if (!specifier.startsWith('.')) return null;
|
|
102
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
103
|
+
const candidates = [
|
|
104
|
+
base.replace(/\.js$/, '.ts'),
|
|
105
|
+
base.replace(/\.js$/, '.tsx'),
|
|
106
|
+
base,
|
|
107
|
+
`${base}.ts`,
|
|
108
|
+
path.join(base, 'index.ts'),
|
|
109
|
+
];
|
|
110
|
+
for (const c of candidates) {
|
|
111
|
+
try {
|
|
112
|
+
if (fs.statSync(c).isFile()) return c;
|
|
113
|
+
} catch {
|
|
114
|
+
// next candidate
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Relative import specifiers declared by `source`, in source order. */
|
|
121
|
+
function importSpecifiers(source: ts.SourceFile): string[] {
|
|
122
|
+
const out: string[] = [];
|
|
123
|
+
const visit = (node: ts.Node): void => {
|
|
124
|
+
if (
|
|
125
|
+
(ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) &&
|
|
126
|
+
node.moduleSpecifier !== undefined &&
|
|
127
|
+
ts.isStringLiteral(node.moduleSpecifier)
|
|
128
|
+
) {
|
|
129
|
+
out.push(node.moduleSpecifier.text);
|
|
130
|
+
} else if (
|
|
131
|
+
ts.isCallExpression(node) &&
|
|
132
|
+
node.expression.kind === ts.SyntaxKind.ImportKeyword &&
|
|
133
|
+
node.arguments.length > 0 &&
|
|
134
|
+
ts.isStringLiteral(node.arguments[0] as ts.Node)
|
|
135
|
+
) {
|
|
136
|
+
// A dynamic import() is still bundled by esbuild unless code-split,
|
|
137
|
+
// so it carries the same hazard as a static one.
|
|
138
|
+
out.push((node.arguments[0] as ts.StringLiteral).text);
|
|
139
|
+
}
|
|
140
|
+
ts.forEachChild(node, visit);
|
|
141
|
+
};
|
|
142
|
+
ts.forEachChild(source, visit);
|
|
143
|
+
return out;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** True for nodes whose body only runs when called — the walk stops here. */
|
|
147
|
+
function isDeferredScope(node: ts.Node): boolean {
|
|
148
|
+
return (
|
|
149
|
+
ts.isFunctionDeclaration(node) ||
|
|
150
|
+
ts.isFunctionExpression(node) ||
|
|
151
|
+
ts.isArrowFunction(node) ||
|
|
152
|
+
ts.isMethodDeclaration(node) ||
|
|
153
|
+
ts.isConstructorDeclaration(node) ||
|
|
154
|
+
ts.isGetAccessorDeclaration(node) ||
|
|
155
|
+
ts.isSetAccessorDeclaration(node) ||
|
|
156
|
+
ts.isClassDeclaration(node) ||
|
|
157
|
+
ts.isClassExpression(node)
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Is `node` a `process.exit(...)` call? */
|
|
162
|
+
function isProcessExitCall(node: ts.Node): boolean {
|
|
163
|
+
if (!ts.isCallExpression(node)) return false;
|
|
164
|
+
const callee = node.expression;
|
|
165
|
+
if (!ts.isPropertyAccessExpression(callee)) return false;
|
|
166
|
+
return (
|
|
167
|
+
ts.isIdentifier(callee.expression) &&
|
|
168
|
+
callee.expression.text === 'process' &&
|
|
169
|
+
callee.name.text === 'exit'
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* 1-indexed lines of every `process.exit(` that executes at MODULE LOAD.
|
|
175
|
+
*
|
|
176
|
+
* Descends through statement-level structure (blocks, if/else, try/catch/finally,
|
|
177
|
+
* loops, labels) and stops at any deferred scope, so a call inside a function is
|
|
178
|
+
* not reported while one inside a top-level `if` is.
|
|
179
|
+
*/
|
|
180
|
+
function moduleLevelExitLines(source: ts.SourceFile): number[] {
|
|
181
|
+
const lines: number[] = [];
|
|
182
|
+
const visit = (node: ts.Node): void => {
|
|
183
|
+
if (isDeferredScope(node)) return;
|
|
184
|
+
if (isProcessExitCall(node)) {
|
|
185
|
+
lines.push(source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1);
|
|
186
|
+
}
|
|
187
|
+
ts.forEachChild(node, visit);
|
|
188
|
+
};
|
|
189
|
+
ts.forEachChild(source, visit);
|
|
190
|
+
return lines;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function parse(file: string): ts.SourceFile | null {
|
|
194
|
+
let text: string;
|
|
195
|
+
try {
|
|
196
|
+
text = fs.readFileSync(file, 'utf-8');
|
|
197
|
+
} catch {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
return ts.createSourceFile(file, text, ts.ScriptTarget.ESNext, true);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Walk one entry's closure; return every module-level exit found below it.
|
|
205
|
+
*
|
|
206
|
+
* `root` defaults to the repo; tests pass a fixture tree so the chain-reporting
|
|
207
|
+
* itself is covered, not only the per-file detector.
|
|
208
|
+
*/
|
|
209
|
+
function auditEntry(entryRel: string, root: string = REPO_ROOT): Finding[] {
|
|
210
|
+
const entryAbs = path.join(root, entryRel);
|
|
211
|
+
if (!fs.existsSync(entryAbs)) {
|
|
212
|
+
process.stderr.write(
|
|
213
|
+
`check-installer-import-purity: entry ${entryRel} does not exist — ` +
|
|
214
|
+
'update BUNDLE_ENTRIES\n',
|
|
215
|
+
);
|
|
216
|
+
process.exitCode = 1;
|
|
217
|
+
return [];
|
|
218
|
+
}
|
|
219
|
+
const findings: Finding[] = [];
|
|
220
|
+
const seen = new Set<string>([entryAbs]);
|
|
221
|
+
// BFS so the reported chain is a shortest path — the least confusing one.
|
|
222
|
+
const queue: Array<{ file: string; chain: string[] }> = [
|
|
223
|
+
{ file: entryAbs, chain: [entryRel] },
|
|
224
|
+
];
|
|
225
|
+
while (queue.length > 0) {
|
|
226
|
+
const { file, chain } = queue.shift() as { file: string; chain: string[] };
|
|
227
|
+
const source = parse(file);
|
|
228
|
+
if (source === null) continue;
|
|
229
|
+
|
|
230
|
+
// The entry is the CLI; its own module-level exit is intended.
|
|
231
|
+
if (file !== entryAbs) {
|
|
232
|
+
for (const line of moduleLevelExitLines(source)) {
|
|
233
|
+
findings.push({ module: relToRepo(file, root), line, chain });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
for (const spec of importSpecifiers(source)) {
|
|
238
|
+
const target = resolveImport(file, spec);
|
|
239
|
+
if (target === null || seen.has(target)) continue;
|
|
240
|
+
seen.add(target);
|
|
241
|
+
queue.push({ file: target, chain: [...chain, relToRepo(target, root)] });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
return findings;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function main(): number {
|
|
248
|
+
const all: Finding[] = [];
|
|
249
|
+
for (const entry of BUNDLE_ENTRIES) {
|
|
250
|
+
all.push(...auditEntry(entry));
|
|
251
|
+
}
|
|
252
|
+
if (all.length === 0) {
|
|
253
|
+
process.stdout.write(
|
|
254
|
+
`✅ installer import purity: ${BUNDLE_ENTRIES.length} bundle entr` +
|
|
255
|
+
`${BUNDLE_ENTRIES.length === 1 ? 'y' : 'ies'} reach no module-level ` +
|
|
256
|
+
'process.exit()\n',
|
|
257
|
+
);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
process.stdout.write('❌ installer import purity — module-level process.exit() reachable:\n');
|
|
261
|
+
for (const f of all) {
|
|
262
|
+
process.stdout.write(`\n ${f.module}:${f.line}\n`);
|
|
263
|
+
process.stdout.write(` reached via: ${f.chain.join(' → ')}\n`);
|
|
264
|
+
}
|
|
265
|
+
process.stdout.write(
|
|
266
|
+
'\n Why this fails the build: esbuild bundles the whole closure into\n' +
|
|
267
|
+
' dist/install/install.mjs. A module-level process.exit() then runs when a\n' +
|
|
268
|
+
' CONSUMER loads the installer — and a CLI entry guard comparing\n' +
|
|
269
|
+
' import.meta.url to process.argv[1] evaluates TRUE inside a bundle, because\n' +
|
|
270
|
+
' the bundle IS argv[1].\n\n' +
|
|
271
|
+
' Fix: move the code the installer actually needs into a module with no CLI\n' +
|
|
272
|
+
' entry and no top-level exit, and let the CLI re-export it. Do NOT relax\n' +
|
|
273
|
+
' this gate — the failure it prevents kills a consumer install after the\n' +
|
|
274
|
+
' payload has already been written.\n',
|
|
275
|
+
);
|
|
276
|
+
return 1;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const _isCliEntry = (): boolean => {
|
|
280
|
+
if (process.argv[1] === undefined) return false;
|
|
281
|
+
try {
|
|
282
|
+
return (
|
|
283
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
284
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
285
|
+
);
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
|
|
291
|
+
if (_isCliEntry()) {
|
|
292
|
+
process.exit(main());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export { auditEntry, moduleLevelExitLines, BUNDLE_ENTRIES };
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* check_pack_size — enforce the published-tarball payload budget
|
|
4
|
+
* (road-to-zero-ceremony-install Phase 4).
|
|
5
|
+
*
|
|
6
|
+
* Two metrics, both ungated before this script existed:
|
|
7
|
+
* 1. COMPRESSED tarball size — absolute max, plus the >regression_pct creep
|
|
8
|
+
* rule (fails even under the absolute budget, so slow rot cannot hide
|
|
9
|
+
* under a generous cap).
|
|
10
|
+
* 2. PER-SKILL share of the skills subtree — no single skill may silently
|
|
11
|
+
* reclaim space freed elsewhere. Named exceptions carry a reason.
|
|
12
|
+
*
|
|
13
|
+
* The UNPACKED size is deliberately NOT re-gated here: it is already owned by
|
|
14
|
+
* `evaluator-budgets.unpacked_size_mb`, measured at release time on the BUILT
|
|
15
|
+
* artifact. Two gates for one lever is how conflicting numbers start.
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* ./scripts-run src/scripts/check_pack_size [--json]
|
|
19
|
+
* ./scripts-run src/scripts/check_pack_size --pack-json <file> # test/offline
|
|
20
|
+
*
|
|
21
|
+
* Exit codes: 0 green · 1 over budget · 2 misuse / unreadable input.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { execFileSync } from 'node:child_process';
|
|
25
|
+
import * as fs from 'node:fs';
|
|
26
|
+
import * as path from 'node:path';
|
|
27
|
+
import { fileURLToPath } from 'node:url';
|
|
28
|
+
|
|
29
|
+
const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), '..', '..', '..');
|
|
30
|
+
const BUDGET_PATH = path.join(REPO_ROOT, 'src', 'config', 'pack-size-budget.json');
|
|
31
|
+
const SKILL_PREFIX = 'dist/agent-src/skills/';
|
|
32
|
+
|
|
33
|
+
export interface PackFile {
|
|
34
|
+
path: string;
|
|
35
|
+
size: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface PackResult {
|
|
39
|
+
size: number;
|
|
40
|
+
unpackedSize: number;
|
|
41
|
+
files: PackFile[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface PackSizeBudget {
|
|
45
|
+
regression_pct: number;
|
|
46
|
+
budgets: Record<string, { max: number; last_measured: number; method: string }>;
|
|
47
|
+
per_skill_share: {
|
|
48
|
+
max_pct: number;
|
|
49
|
+
basis: string;
|
|
50
|
+
rationale: string;
|
|
51
|
+
exceptions: Record<string, { max_pct: number; measured_pct: number; reason: string }>;
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse `npm pack --json` stdout. npm runs the `prepare` lifecycle script even
|
|
57
|
+
* under `--ignore-scripts` on some versions, and this repo's `prepare` prints a
|
|
58
|
+
* banner — straight into the stream we parse. Slice from the first `[` so a
|
|
59
|
+
* lifecycle banner is tolerated instead of becoming a SyntaxError.
|
|
60
|
+
*/
|
|
61
|
+
export function parsePackJson(stdout: string): PackResult {
|
|
62
|
+
const start = stdout.indexOf('[');
|
|
63
|
+
const parsed = JSON.parse(start >= 0 ? stdout.slice(start) : stdout) as PackResult[];
|
|
64
|
+
const first = parsed[0];
|
|
65
|
+
if (first === undefined) throw new Error('npm pack --json returned an empty array');
|
|
66
|
+
return first;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Bytes per skill under `dist/agent-src/skills/`, plus the subtree total. */
|
|
70
|
+
export function skillBytes(files: readonly PackFile[]): { perSkill: Record<string, number>; total: number } {
|
|
71
|
+
const perSkill: Record<string, number> = {};
|
|
72
|
+
let total = 0;
|
|
73
|
+
for (const file of files) {
|
|
74
|
+
if (!file.path.startsWith(SKILL_PREFIX)) continue;
|
|
75
|
+
const name = file.path.slice(SKILL_PREFIX.length).split('/')[0];
|
|
76
|
+
if (name === undefined || name === '') continue;
|
|
77
|
+
perSkill[name] = (perSkill[name] ?? 0) + file.size;
|
|
78
|
+
total += file.size;
|
|
79
|
+
}
|
|
80
|
+
return { perSkill, total };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Every violation, as human-readable lines. Empty array means green. */
|
|
84
|
+
export function evaluate(budget: PackSizeBudget, pack: PackResult): string[] {
|
|
85
|
+
const errors: string[] = [];
|
|
86
|
+
|
|
87
|
+
const packedMb = pack.size / 1e6;
|
|
88
|
+
const entry = budget.budgets['packed_size_mb'];
|
|
89
|
+
if (entry === undefined) {
|
|
90
|
+
errors.push('packed_size_mb: missing from the budget file — gate would pass vacuously');
|
|
91
|
+
} else if (packedMb > entry.max) {
|
|
92
|
+
errors.push(`packed_size_mb: measured ${packedMb.toFixed(3)} exceeds budget ${entry.max}`);
|
|
93
|
+
} else {
|
|
94
|
+
const pct = budget.regression_pct;
|
|
95
|
+
const ceiling = entry.last_measured * (1 + pct / 100);
|
|
96
|
+
if (entry.last_measured > 0 && packedMb > ceiling) {
|
|
97
|
+
errors.push(
|
|
98
|
+
`packed_size_mb: measured ${packedMb.toFixed(3)} regressed >${pct}% vs ` +
|
|
99
|
+
`last_measured ${entry.last_measured} (ceiling ${ceiling.toFixed(3)}) — ` +
|
|
100
|
+
'fails even under the absolute budget',
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const { perSkill, total } = skillBytes(pack.files);
|
|
106
|
+
if (total === 0) {
|
|
107
|
+
errors.push(`per_skill_share: no files under ${SKILL_PREFIX} — gate would pass vacuously`);
|
|
108
|
+
return errors;
|
|
109
|
+
}
|
|
110
|
+
const { max_pct: defaultMax, exceptions } = budget.per_skill_share;
|
|
111
|
+
for (const [name, bytes] of Object.entries(perSkill)) {
|
|
112
|
+
const share = (bytes / total) * 100;
|
|
113
|
+
const exception = exceptions[name];
|
|
114
|
+
const cap = exception?.max_pct ?? defaultMax;
|
|
115
|
+
if (share > cap) {
|
|
116
|
+
errors.push(
|
|
117
|
+
exception === undefined
|
|
118
|
+
? `per_skill_share: ${name} is ${share.toFixed(2)}% of the skills payload, over the ${cap}% cap — ` +
|
|
119
|
+
'shrink it, or add a named exception with a reason'
|
|
120
|
+
: `per_skill_share: ${name} is ${share.toFixed(2)}%, over its own exception cap of ${cap}% — ` +
|
|
121
|
+
'the exception is not a blank cheque; shrink it or raise the cap with a reason',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
for (const name of Object.keys(exceptions)) {
|
|
126
|
+
if (!(name in perSkill)) {
|
|
127
|
+
errors.push(`per_skill_share: exception for '${name}' is stale — no such skill ships`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return errors;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function readPack(argv: readonly string[]): PackResult {
|
|
134
|
+
const idx = argv.indexOf('--pack-json');
|
|
135
|
+
if (idx >= 0) {
|
|
136
|
+
const file = argv[idx + 1];
|
|
137
|
+
if (file === undefined) throw new Error('--pack-json needs a path');
|
|
138
|
+
return parsePackJson(fs.readFileSync(file, 'utf-8'));
|
|
139
|
+
}
|
|
140
|
+
const stdout = execFileSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts', '--silent'], {
|
|
141
|
+
cwd: REPO_ROOT,
|
|
142
|
+
encoding: 'utf-8',
|
|
143
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
144
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
145
|
+
});
|
|
146
|
+
return parsePackJson(stdout);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
150
|
+
let budget: PackSizeBudget;
|
|
151
|
+
let pack: PackResult;
|
|
152
|
+
try {
|
|
153
|
+
budget = JSON.parse(fs.readFileSync(BUDGET_PATH, 'utf-8')) as PackSizeBudget;
|
|
154
|
+
} catch (err) {
|
|
155
|
+
process.stderr.write(`❌ pack size: cannot read ${BUDGET_PATH}: ${String(err)}\n`);
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
try {
|
|
159
|
+
pack = readPack(argv);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
process.stderr.write(`❌ pack size: npm pack failed: ${String(err)}\n`);
|
|
162
|
+
return 2;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const errors = evaluate(budget, pack);
|
|
166
|
+
const { perSkill, total } = skillBytes(pack.files);
|
|
167
|
+
if (argv.includes('--json')) {
|
|
168
|
+
process.stdout.write(
|
|
169
|
+
`${JSON.stringify({ packed_mb: pack.size / 1e6, skills_total_bytes: total, skills: Object.keys(perSkill).length, errors }, null, 2)}\n`,
|
|
170
|
+
);
|
|
171
|
+
return errors.length > 0 ? 1 : 0;
|
|
172
|
+
}
|
|
173
|
+
if (errors.length > 0) {
|
|
174
|
+
for (const e of errors) process.stderr.write(`❌ pack size: ${e}\n`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
177
|
+
process.stdout.write(
|
|
178
|
+
`✅ pack size within budget — ${(pack.size / 1e6).toFixed(3)} MB packed, ` +
|
|
179
|
+
`${Object.keys(perSkill).length} skills, largest share ` +
|
|
180
|
+
`${Math.max(...Object.values(perSkill).map((b) => (b / total) * 100)).toFixed(2)}%\n`,
|
|
181
|
+
);
|
|
182
|
+
return 0;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const _selfPath = fileURLToPath(import.meta.url);
|
|
186
|
+
if (process.argv[1] !== undefined && path.resolve(process.argv[1]) === path.resolve(_selfPath)) {
|
|
187
|
+
process.exit(main());
|
|
188
|
+
}
|
package/src/scripts/condense.ts
CHANGED
|
@@ -238,6 +238,13 @@ function _stripChars(s: string, chars: string): string {
|
|
|
238
238
|
// the repo root.
|
|
239
239
|
// =============================================================================
|
|
240
240
|
|
|
241
|
+
// road-to-consistent-rule-scoping: the predicate moved to a side-effect-free
|
|
242
|
+
// module so the consumer installer can import it WITHOUT pulling this file's
|
|
243
|
+
// module-level CLI self-invoke into its bundle. Re-exported here because four
|
|
244
|
+
// in-repo consumers import it from `condense.js`.
|
|
245
|
+
export { rule_in_scope } from '../install/ruleInScope.js';
|
|
246
|
+
import { rule_in_scope } from '../install/ruleInScope.js';
|
|
247
|
+
|
|
241
248
|
const _HERE = path.dirname(fileURLToPath(import.meta.url)); // <repo>/src/scripts
|
|
242
249
|
const _DEFAULT_PROJECT_ROOT = path.resolve(_HERE, '..', '..');
|
|
243
250
|
|
|
@@ -1014,34 +1021,6 @@ function _read_rule_packs(): string[] | null {
|
|
|
1014
1021
|
* `null` (no role filtering), so every existing 2- and 3-arg call site
|
|
1015
1022
|
* compiles and behaves unchanged.
|
|
1016
1023
|
*/
|
|
1017
|
-
export function rule_in_scope(
|
|
1018
|
-
source_path: string,
|
|
1019
|
-
scope: readonly string[] | null,
|
|
1020
|
-
pack_scope: readonly string[] | null = null,
|
|
1021
|
-
role_scope: readonly string[] | null = null,
|
|
1022
|
-
): boolean {
|
|
1023
|
-
if (scope === null && pack_scope === null && role_scope === null) {
|
|
1024
|
-
return true;
|
|
1025
|
-
}
|
|
1026
|
-
const [meta] = _parse_frontmatter(_readText(source_path));
|
|
1027
|
-
if (meta['type'] === 'always' || meta['alwaysApply'] === true) {
|
|
1028
|
-
return true; // kernel always projects
|
|
1029
|
-
}
|
|
1030
|
-
const axis = (key: string, configured: readonly string[] | null): boolean => {
|
|
1031
|
-
if (configured === null) {
|
|
1032
|
-
return true;
|
|
1033
|
-
}
|
|
1034
|
-
const values = Array.isArray(meta[key])
|
|
1035
|
-
? (meta[key] as unknown[]).map((w) => String(w))
|
|
1036
|
-
: [];
|
|
1037
|
-
if (values.length === 0) {
|
|
1038
|
-
return true; // untagged → fail safe: ship it
|
|
1039
|
-
}
|
|
1040
|
-
return values.some((v) => configured.includes(v));
|
|
1041
|
-
};
|
|
1042
|
-
return axis('workspaces', scope) && axis('packs', pack_scope) && axis('roles', role_scope);
|
|
1043
|
-
}
|
|
1044
|
-
|
|
1045
1024
|
/** List rule basenames under RULES_SOURCE, workspace/pack scope applied. */
|
|
1046
1025
|
function _scoped_rule_basenames(): string[] {
|
|
1047
1026
|
const scope = _read_rule_workspaces();
|