@rungs/cli 0.3.0 → 0.4.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.
Files changed (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2194 -488
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +25 -3
  5. package/modules/adr/files/{{path}}/README.md +1 -1
  6. package/modules/adr/gates/adr.toml +1 -1
  7. package/modules/adr/module.toml +1 -1
  8. package/modules/audit/fragments/AGENTS.md +2 -2
  9. package/modules/audit/module.toml +1 -1
  10. package/modules/audit/skills/assess/SKILL.md +1 -1
  11. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
  12. package/modules/backlog/files/docs/{{root}}/README.md +2 -2
  13. package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
  14. package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
  15. package/modules/backlog/fragments/AGENTS.md +2 -2
  16. package/modules/backlog/module.toml +1 -1
  17. package/modules/backlog/skills/work-item/SKILL.md +1 -1
  18. package/modules/ci/files/{{workflow_path}} +3 -3
  19. package/modules/ci/module.toml +1 -1
  20. package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
  21. package/modules/concurrency/fragments/AGENTS.md +5 -4
  22. package/modules/concurrency/fragments/gitattributes +2 -2
  23. package/modules/concurrency/gates/concurrency.toml +3 -3
  24. package/modules/concurrency/module.toml +1 -1
  25. package/modules/doc-authority/files/{{registry_path}} +1 -1
  26. package/modules/doc-authority/module.toml +1 -1
  27. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
  28. package/modules/findings/gates/findings.toml +5 -0
  29. package/modules/findings/module.toml +1 -1
  30. package/modules/findings/skills/record-finding/SKILL.md +1 -1
  31. package/modules/gates/files/.ai/gates.toml +1 -1
  32. package/modules/gates/fragments/AGENTS.md +6 -5
  33. package/modules/gates/module.toml +1 -1
  34. package/modules/instructions/files/.ai/rules/README.md +2 -2
  35. package/modules/instructions/files/.ai/rungs.mjs +52 -0
  36. package/modules/instructions/files/AGENTS.md +4 -2
  37. package/modules/instructions/files/CLAUDE.md +1 -1
  38. package/modules/instructions/fragments/AGENTS.md +2 -2
  39. package/modules/instructions/gates/core.toml +2 -2
  40. package/modules/instructions/module.toml +1 -1
  41. package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
  42. package/modules/release/gates/release.toml +169 -17
  43. package/modules/release/module.toml +9 -5
  44. package/modules/release/skills/cut-release/SKILL.md +43 -15
  45. package/modules/session/files/{{archive}}/README.md +1 -1
  46. package/modules/session/files/{{path}} +2 -2
  47. package/modules/session/module.toml +1 -1
  48. package/modules/specs/files/{{path}}/README.md +2 -2
  49. package/modules/specs/module.toml +1 -1
  50. package/modules/workflows/module.toml +1 -1
  51. package/modules/workflows/rules/planning-tiers.md +1 -1
  52. package/package.json +3 -2
  53. package/src/add.ts +204 -48
  54. package/src/backlog.ts +354 -48
  55. package/src/check.ts +54 -33
  56. package/src/cli.ts +196 -69
  57. package/src/concurrency.ts +628 -42
  58. package/src/detect.ts +11 -3
  59. package/src/emitted-path.ts +274 -0
  60. package/src/engine-table.ts +66 -0
  61. package/src/engines.ts +40 -32
  62. package/src/engines2.ts +424 -29
  63. package/src/engines3.ts +115 -23
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +95 -31
  67. package/src/manifest.ts +41 -5
  68. package/src/render.ts +106 -21
  69. package/src/selftest.ts +87 -10
  70. package/src/storage-key.ts +20 -0
  71. package/src/substitute.ts +47 -5
  72. package/src/text.ts +11 -0
  73. package/src/types.ts +16 -3
  74. package/src/version-source.ts +144 -0
package/src/detect.ts CHANGED
@@ -2,7 +2,8 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import type { DetectResult, Manifest } from './types.ts';
4
4
  import { matchAny, walk } from './glob.ts';
5
- import { contentHash, emittedFiles } from './add.ts';
5
+ import { contentHash, emittedFiles, preflightModuleEmissions } from './add.ts';
6
+ import { resolveEmittedPath } from './emitted-path.ts';
6
7
  import type { Params } from './substitute.ts';
7
8
 
8
9
  const SAMPLE = 3;
@@ -215,7 +216,9 @@ export interface InstalledModule {
215
216
  */
216
217
  export function ownedState(mod: Manifest, repoRoot: string, installed: InstalledModule) {
217
218
  const params = installed.params_all ?? {};
218
- const emitted = emittedFiles(mod, params, installed.skillsDir ?? '.claude/skills');
219
+ const skillsDir = installed.skillsDir ?? '.claude/skills';
220
+ preflightModuleEmissions([mod], repoRoot, params, skillsDir);
221
+ const emitted = emittedFiles(mod, params, skillsDir);
219
222
  const kept = new Set(installed.kept?.files ?? []);
220
223
  const out = {
221
224
  version: installed.version,
@@ -232,7 +235,12 @@ export function ownedState(mod: Manifest, repoRoot: string, installed: Installed
232
235
  out.kept.push(rel);
233
236
  continue;
234
237
  }
235
- const full = join(repoRoot, rel);
238
+ const resolved = resolveEmittedPath(repoRoot, mod.name, rel);
239
+ const full = resolved.absolute;
240
+ if (resolved.leafAlias) {
241
+ out.diverged.push(rel);
242
+ continue;
243
+ }
236
244
  if (!existsSync(full)) {
237
245
  out.missing.push(rel);
238
246
  continue;
@@ -0,0 +1,274 @@
1
+ import { lstatSync, realpathSync, statSync } from 'node:fs';
2
+ import { basename, dirname, isAbsolute, relative, resolve, sep, win32 } from 'node:path';
3
+ import { canonicalCaselessSegmentEqual } from './storage-key.ts';
4
+
5
+ /**
6
+ * A module path is repository-relative data, not a host-native path. Treating
7
+ * both separators as structural is what makes the same parameter safe when a
8
+ * record written on Windows is later upgraded on POSIX (or the reverse).
9
+ */
10
+ export class UnsafeEmittedPathError extends Error {
11
+ readonly moduleName: string;
12
+ readonly target: string;
13
+ readonly reason: string;
14
+
15
+ constructor(
16
+ moduleName: string,
17
+ target: string,
18
+ reason: string,
19
+ ) {
20
+ super(`module '${moduleName}' emitted unsafe target ${JSON.stringify(target)}: ${reason}`);
21
+ this.name = 'UnsafeEmittedPathError';
22
+ this.moduleName = moduleName;
23
+ this.target = target;
24
+ this.reason = reason;
25
+ }
26
+ }
27
+
28
+ export interface ResolvedEmittedPath {
29
+ /** Portable, slash-separated form used in actions and install records. */
30
+ target: string;
31
+ /** Canonical absolute destination proven to be below the canonical repo root. */
32
+ absolute: string;
33
+ /** The final path entry itself is an alias; writes must not follow it. */
34
+ leafAlias: boolean;
35
+ }
36
+
37
+ export interface EmittedPathCandidate {
38
+ moduleName: string;
39
+ target: string;
40
+ /** Managed block/shared-registry destinations may intentionally coincide. */
41
+ shared?: boolean;
42
+ /** This phase replaces or merges an existing file rather than keeping it. */
43
+ writeExisting?: boolean;
44
+ }
45
+
46
+ const missingEntry = (error: unknown) =>
47
+ error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
48
+
49
+ function hasUnpairedUtf16Surrogate(value: string): boolean {
50
+ for (let i = 0; i < value.length; i++) {
51
+ const unit = value.charCodeAt(i);
52
+ if (unit >= 0xd800 && unit <= 0xdbff) {
53
+ const next = value.charCodeAt(i + 1);
54
+ if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
55
+ i++;
56
+ continue;
57
+ }
58
+ if (unit >= 0xdc00 && unit <= 0xdfff) return true;
59
+ }
60
+ return false;
61
+ }
62
+
63
+ function canonicalCaselessAncestor(ancestor: string, descendant: string): boolean {
64
+ const ancestorSegments = ancestor.split(sep);
65
+ const descendantSegments = descendant.split(sep);
66
+ return (
67
+ ancestorSegments.length < descendantSegments.length &&
68
+ ancestorSegments.every((segment, index) => canonicalCaselessSegmentEqual(segment, descendantSegments[index]))
69
+ );
70
+ }
71
+
72
+ function canonicalCaselessPathEqual(left: string, right: string): boolean {
73
+ const leftSegments = left.split(sep);
74
+ const rightSegments = right.split(sep);
75
+ return (
76
+ leftSegments.length === rightSegments.length &&
77
+ leftSegments.every((segment, index) => canonicalCaselessSegmentEqual(segment, rightSegments[index]))
78
+ );
79
+ }
80
+
81
+ /**
82
+ * Realpath the deepest existing ancestor, then append the still-missing suffix.
83
+ * `resolve()` alone cannot see an in-repository symlink or junction that points
84
+ * out of the repository. A dangling alias fails closed because realpath cannot
85
+ * establish where a subsequent write would land.
86
+ */
87
+ function canonicalWithMissing(path: string, moduleName: string, target: string): string {
88
+ let cursor = resolve(path);
89
+ const suffix: string[] = [];
90
+
91
+ for (;;) {
92
+ try {
93
+ lstatSync(cursor);
94
+ } catch (error) {
95
+ if (!missingEntry(error)) {
96
+ throw new UnsafeEmittedPathError(moduleName, target, 'its existing ancestor cannot be inspected');
97
+ }
98
+ const parent = dirname(cursor);
99
+ if (parent === cursor) {
100
+ throw new UnsafeEmittedPathError(moduleName, target, 'no canonical existing ancestor can be established');
101
+ }
102
+ suffix.unshift(basename(cursor));
103
+ cursor = parent;
104
+ continue;
105
+ }
106
+
107
+ let canonical: string;
108
+ try {
109
+ canonical = realpathSync.native(cursor);
110
+ } catch {
111
+ throw new UnsafeEmittedPathError(moduleName, target, 'its existing ancestor cannot be resolved canonically');
112
+ }
113
+ if (suffix.length) {
114
+ try {
115
+ if (!statSync(canonical).isDirectory()) {
116
+ throw new UnsafeEmittedPathError(moduleName, target, 'its deepest existing ancestor is not a directory');
117
+ }
118
+ } catch (error) {
119
+ if (error instanceof UnsafeEmittedPathError) throw error;
120
+ throw new UnsafeEmittedPathError(moduleName, target, 'its existing ancestor cannot be inspected');
121
+ }
122
+ }
123
+ return resolve(canonical, ...suffix);
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Resolve one module-emitted destination and prove it remains in `repoRoot`.
129
+ * Unsafe syntax is refused before host path resolution so `C:relative`, `\\rooted`
130
+ * and mixed-separator traversal cannot change meaning between operating systems.
131
+ */
132
+ export function resolveEmittedPath(repoRoot: string, moduleName: string, target: string): ResolvedEmittedPath {
133
+ if (!target || target.includes('\0')) {
134
+ throw new UnsafeEmittedPathError(moduleName, target, 'a non-empty portable relative file path is required');
135
+ }
136
+
137
+ const portable = target.replace(/\\/g, '/');
138
+ if (portable.startsWith('/') || win32.isAbsolute(target) || /^[A-Za-z]:/.test(portable)) {
139
+ throw new UnsafeEmittedPathError(moduleName, target, 'absolute, rooted, and drive-relative paths are not allowed');
140
+ }
141
+
142
+ const segments = portable.split('/');
143
+ if (segments.includes('..')) {
144
+ throw new UnsafeEmittedPathError(moduleName, target, "parent traversal ('..') is not allowed");
145
+ }
146
+ if (segments.some((segment) => segment === '' || segment === '.')) {
147
+ throw new UnsafeEmittedPathError(moduleName, target, "empty and current-directory ('.') path segments are not allowed");
148
+ }
149
+ if (segments.some(hasUnpairedUtf16Surrogate)) {
150
+ throw new UnsafeEmittedPathError(
151
+ moduleName,
152
+ target,
153
+ 'unpaired UTF-16 surrogate code units are not allowed in path segments',
154
+ );
155
+ }
156
+ if (segments.some((segment) => /[\u0000-\u001f<>:"|?*]/.test(segment) || /[ .]$/.test(segment))) {
157
+ throw new UnsafeEmittedPathError(
158
+ moduleName,
159
+ target,
160
+ 'it contains a character or trailing suffix that is not a portable filename',
161
+ );
162
+ }
163
+ const windowsDevice = /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i;
164
+ if (segments.some((segment) => windowsDevice.test(segment))) {
165
+ throw new UnsafeEmittedPathError(moduleName, target, 'Windows device-name path segments are not allowed');
166
+ }
167
+
168
+ const canonicalRoot = canonicalWithMissing(repoRoot, moduleName, target);
169
+ const lexicalDestination = resolve(repoRoot, ...segments);
170
+ const canonicalDestination = canonicalWithMissing(lexicalDestination, moduleName, target);
171
+ const fromRoot = relative(canonicalRoot, canonicalDestination);
172
+
173
+ if (!fromRoot || fromRoot === '..' || fromRoot.startsWith(`..${sep}`) || isAbsolute(fromRoot)) {
174
+ throw new UnsafeEmittedPathError(moduleName, target, 'it resolves outside the canonical consumer repository');
175
+ }
176
+
177
+ let leafAlias = false;
178
+ try {
179
+ leafAlias = lstatSync(lexicalDestination).isSymbolicLink();
180
+ } catch (error) {
181
+ if (!missingEntry(error)) {
182
+ throw new UnsafeEmittedPathError(moduleName, target, 'its destination cannot be inspected');
183
+ }
184
+ }
185
+
186
+ return { target: portable, absolute: canonicalDestination, leafAlias };
187
+ }
188
+
189
+ /** Resolve a complete operation and reject two exclusive names for one destination. */
190
+ export function preflightEmittedPaths(
191
+ repoRoot: string,
192
+ candidates: EmittedPathCandidate[],
193
+ ): ResolvedEmittedPath[] {
194
+ const resolved = candidates.map((candidate) => {
195
+ const destination = resolveEmittedPath(repoRoot, candidate.moduleName, candidate.target);
196
+ if (candidate.writeExisting) {
197
+ if (destination.leafAlias) {
198
+ throw new UnsafeEmittedPathError(
199
+ candidate.moduleName,
200
+ candidate.target,
201
+ 'the destination is a symlink or junction leaf and this operation will not write through it',
202
+ );
203
+ }
204
+
205
+ // Every overwrite/merge sink ultimately uses writeFileSync. Prove an
206
+ // existing leaf is a regular file now, during the complete-operation
207
+ // preflight, so a later directory/FIFO/socket cannot fail after an
208
+ // earlier candidate has already been written. A hard-linked file is also
209
+ // refused: replacing it would mutate every other name for the same inode,
210
+ // including a name outside the consumer repository.
211
+ try {
212
+ const leaf = lstatSync(destination.absolute);
213
+ if (!leaf.isFile()) {
214
+ throw new UnsafeEmittedPathError(
215
+ candidate.moduleName,
216
+ candidate.target,
217
+ 'the existing destination is not a regular file',
218
+ );
219
+ }
220
+ if (leaf.nlink > 1) {
221
+ throw new UnsafeEmittedPathError(
222
+ candidate.moduleName,
223
+ candidate.target,
224
+ 'the existing destination has multiple hard links and this operation will not overwrite it',
225
+ );
226
+ }
227
+ } catch (error) {
228
+ if (error instanceof UnsafeEmittedPathError) throw error;
229
+ if (!missingEntry(error)) {
230
+ throw new UnsafeEmittedPathError(
231
+ candidate.moduleName,
232
+ candidate.target,
233
+ 'the destination cannot be inspected before writing',
234
+ );
235
+ }
236
+ }
237
+ }
238
+ return destination;
239
+ });
240
+ const seen: { candidate: EmittedPathCandidate; resolved: ResolvedEmittedPath }[] = [];
241
+
242
+ for (let i = 0; i < candidates.length; i++) {
243
+ const candidate = candidates[i];
244
+ const destination = resolved[i];
245
+ // A module plan is portable: names that coexist only on a case-sensitive
246
+ // checkout are still one destination when that record reaches Windows or a
247
+ // default case-insensitive macOS volume. Use the same conservative storage
248
+ // relation as managed refs, including compatibility and full case forms.
249
+ const prior = seen.find((entry) => canonicalCaselessPathEqual(entry.resolved.absolute, destination.absolute));
250
+ if (!prior) {
251
+ const structural = seen.find((entry) =>
252
+ canonicalCaselessAncestor(entry.resolved.absolute, destination.absolute) ||
253
+ canonicalCaselessAncestor(destination.absolute, entry.resolved.absolute),
254
+ );
255
+ if (!structural) {
256
+ seen.push({ candidate, resolved: destination });
257
+ continue;
258
+ }
259
+ throw new UnsafeEmittedPathError(
260
+ candidate.moduleName,
261
+ candidate.target,
262
+ `it has a file/descendant collision with module '${structural.candidate.moduleName}' target '${structural.candidate.target}' after canonical resolution`,
263
+ );
264
+ }
265
+ if (candidate.shared && prior.candidate.shared && destination.target === prior.resolved.target) continue;
266
+ throw new UnsafeEmittedPathError(
267
+ candidate.moduleName,
268
+ candidate.target,
269
+ `it collides with module '${prior.candidate.moduleName}' target '${prior.candidate.target}' after canonical resolution`,
270
+ );
271
+ }
272
+
273
+ return resolved;
274
+ }
@@ -0,0 +1,66 @@
1
+ /**
2
+ * One table-section authority for every declared engine dispatch path.
3
+ *
4
+ * F-041 existed because the production runner, module self-test runner and
5
+ * ejected runner each kept a different map and then fell back to the whole
6
+ * table. A missing entry therefore looked exactly like a green gate that had
7
+ * nothing to examine. Keep this file dependency-free so eject can copy the
8
+ * selector without pulling the CLI into the consumer.
9
+ */
10
+
11
+ export const WHOLE_TABLE = '__whole__';
12
+
13
+ export const ENGINE_TABLE_KEYS: Readonly<Record<string, string>> = Object.freeze({
14
+ 'file-budget': 'file_budget',
15
+ sections: 'sections',
16
+ 'frontmatter-schema': 'frontmatter_schema',
17
+ 'link-integrity': 'link_integrity',
18
+ 'file-population': 'file_population',
19
+ 'gate-meta': 'gate_meta',
20
+ 'id-integrity': WHOLE_TABLE,
21
+ 'render-freshness': 'render_freshness',
22
+ 'register-schema': 'register_schema',
23
+ 'self-declared-closure': 'self_declared_closure',
24
+ 'filename-schema': 'filename_schema',
25
+ 'cross-reference': 'cross_reference',
26
+ 'git-status-reconcile': 'merged_status',
27
+ 'computed-claim': 'computed_claim',
28
+ 'term-ownership': 'term_ownership',
29
+ 'rule-propagation': 'rule_propagation',
30
+ 'git-state': 'git_state',
31
+ 'merge-driver-check': 'merge_driver_check',
32
+ 'board-reconcile': 'board_reconcile',
33
+ 'changelog-freshness': 'changelog_freshness',
34
+ 'change-requires-file': 'change_requires_file',
35
+ 'shell-safety': 'shell_safety',
36
+ });
37
+
38
+ const entryMatches = (entry: any, gateId: string) =>
39
+ !!entry?.id && gateId.includes(String(entry.id));
40
+
41
+ /** Select exactly the section an engine declared, or refuse an unknown shape. */
42
+ export function selectEngineTable(raw: any, engine: string, gateId: string): any {
43
+ if (!Object.prototype.hasOwnProperty.call(ENGINE_TABLE_KEYS, engine)) {
44
+ throw new Error(`engine '${engine}' has no table-section mapping`);
45
+ }
46
+
47
+ const key = ENGINE_TABLE_KEYS[engine];
48
+ if (key === WHOLE_TABLE) return raw;
49
+ if (!raw || typeof raw !== 'object' || !(key in raw)) {
50
+ throw new Error(`gate '${gateId}' requires table section '${key}' for engine '${engine}'`);
51
+ }
52
+
53
+ const section = raw[key];
54
+ if (!Array.isArray(section)) return section;
55
+
56
+ const identified = section.filter((entry: any) => entry?.id);
57
+ if (!identified.length) return section;
58
+ const matched = identified.filter((entry: any) => entryMatches(entry, gateId));
59
+ // Some sections intentionally share one subject-named spec across sibling
60
+ // gates (`id = "rules"`). A matching id narrows an array; no match keeps the
61
+ // already-selected section. The dangerous fallback was from a missing
62
+ // *section* to the whole document, and that remains forbidden above.
63
+ return matched.length
64
+ ? section.filter((entry: any) => !entry?.id || entryMatches(entry, gateId))
65
+ : section;
66
+ }
package/src/engines.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { existsSync, readFileSync, statSync } from 'node:fs';
2
2
  import { join, dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { matchAny, walk } from './glob.ts';
4
5
  import { parse as parseToml } from 'smol-toml';
5
6
  import { runSelfTests } from './selftest.ts';
6
7
  import { loadAllModules } from './manifest.ts';
8
+
7
9
  import { resolveParams, substitute } from './substitute.ts';
8
10
  import {
9
11
  computedClaim,
10
12
  crossReference,
13
+ changeRequiresFile,
11
14
  filenameSchema,
12
15
  gitStatusReconcile,
13
16
  idIntegrity,
@@ -16,10 +19,31 @@ import {
16
19
  selfDeclaredClosure,
17
20
  } from './engines2.ts';
18
21
  import { boardReconcile, changelogFreshness, gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
22
+ import { selectEngineTable } from './engine-table.ts';
23
+ import { semanticText } from './text.ts';
24
+
25
+ /**
26
+ * Where the CLI's own `modules/` lives.
27
+ *
28
+ * This was `new URL(import.meta.url).pathname.slice(1)` in three places. The
29
+ * `.slice(1)` strips a leading `/`, which is right on Windows — `/C:/…` becomes
30
+ * `C:/…` — and **wrong everywhere else**, where `/home/runner/…` becomes the
31
+ * relative `home/runner/…`. On Linux and macOS the directory did not resolve,
32
+ * `loadAllModules` found nothing, and three gates silently lost the data they
33
+ * read from the module set: `skills-spec-pure` and `skills-description-routes`
34
+ * reported every opted-in extension as a non-spec key, and
35
+ * `gates-self-tests-both-directions` reported gates that have fixtures as
36
+ * having none. All three passed here and failed on the first Linux run (F-036).
37
+ *
38
+ * `fileURLToPath` is what the rest of the codebase already used.
39
+ */
40
+ const CLI_MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'modules');
19
41
 
20
42
  export interface Finding {
21
43
  file?: string;
22
44
  message: string;
45
+ /** Stable comparison form when the displayed diagnostic carries environment-specific detail. */
46
+ identity?: string;
23
47
  }
24
48
  export interface EngineResult {
25
49
  findings: Finding[];
@@ -30,7 +54,7 @@ export type Engine = (table: any, repoRoot: string, files: string[]) => EngineRe
30
54
 
31
55
  const read = (root: string, rel: string) => {
32
56
  try {
33
- return readFileSync(join(root, rel), 'utf8');
57
+ return semanticText(readFileSync(join(root, rel), 'utf8'));
34
58
  } catch {
35
59
  return '';
36
60
  }
@@ -352,7 +376,7 @@ export const gateMeta: Engine = (_t, root) => {
352
376
 
353
377
  const registry = join(root, '.ai', 'gates.toml');
354
378
  if (!existsSync(registry)) return { findings, examined: 0 };
355
- const text = readFileSync(registry, 'utf8');
379
+ const text = semanticText(readFileSync(registry, 'utf8'));
356
380
  const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
357
381
  let examined = 0;
358
382
  for (const entry of entries) {
@@ -362,8 +386,8 @@ export const gateMeta: Engine = (_t, root) => {
362
386
  if (!id || kind !== 'declared' || !table) continue;
363
387
  examined++;
364
388
  // Tables live in the CLI, not the repo, so read them from the module set.
365
- const tablePath = join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules', dirname(table), 'gates', table.split('/').pop()!);
366
- const src = existsSync(tablePath) ? readFileSync(tablePath, 'utf8') : '';
389
+ const tablePath = join(CLI_MODULES, dirname(table), 'gates', table.split('/').pop()!);
390
+ const src = existsSync(tablePath) ? semanticText(readFileSync(tablePath, 'utf8')) : '';
367
391
  const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)]
368
392
  .map((m) => m[0])
369
393
  .filter((b) => b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`));
@@ -393,9 +417,14 @@ export const gateMeta: Engine = (_t, root) => {
393
417
  const blocks = (Array.isArray(parsed.self_test) ? parsed.self_test : [])
394
418
  .filter((b: any) => b?.gate === id)
395
419
  .map((b: any) => ({ expect: String(b.expect), input: b.input, fixture: b.fixture }));
396
- for (const r of runSelfTests(id, engine, parsed[tableKeyFor(engine)] ?? parsed, blocks)) {
397
- if (r.outcome === 'mismatch') findings.push({ message: `self-test for '${id}' ${r.detail}` });
398
- else if (r.outcome === 'unrun') unrun++;
420
+ try {
421
+ const section = selectEngineTable(parsed, engine, id);
422
+ for (const r of runSelfTests(id, engine, section, blocks)) {
423
+ if (r.outcome === 'mismatch') findings.push({ message: `self-test for '${id}' ${r.detail}` });
424
+ else if (r.outcome === 'unrun') unrun++;
425
+ }
426
+ } catch (e: any) {
427
+ findings.push({ message: `gate '${id}' table dispatch failed: ${e.message}` });
399
428
  }
400
429
  }
401
430
  }
@@ -426,7 +455,7 @@ function optedInExtensions(rel: string, spec: any): Set<string> {
426
455
  const name = rel.split('/').slice(-2)[0];
427
456
  if (!name) return new Set();
428
457
  try {
429
- const mods = loadAllModules(join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules'));
458
+ const mods = loadAllModules(CLI_MODULES);
430
459
  const owner = mods.find((m) => m.skills?.[name]?.extensions);
431
460
  return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
432
461
  } catch {
@@ -450,36 +479,14 @@ function parseTable(path: string, module: string): any | null {
450
479
  // and the runner reported the gate broken — a mismatch entirely of the
451
480
  // harness's making. A fixture and the table it tests must resolve against
452
481
  // the same parameters or neither means anything.
453
- const mods = loadAllModules(join(dirname(new URL(import.meta.url).pathname.slice(1)), '..', 'modules'));
482
+ const mods = loadAllModules(CLI_MODULES);
454
483
  const params = resolveParams(mods, {}, '.');
455
- return parseToml(substitute(readFileSync(path, 'utf8'), module, params));
484
+ return parseToml(substitute(semanticText(readFileSync(path, 'utf8')), module, params));
456
485
  } catch {
457
486
  return null;
458
487
  }
459
488
  }
460
489
 
461
- /** Duplicated from `check.ts` rather than imported, to keep engines dependency-free of the runner. */
462
- const tableKeyFor = (engine: string) =>
463
- ({
464
- 'file-budget': 'file_budget',
465
- 'frontmatter-schema': 'frontmatter_schema',
466
- 'link-integrity': 'link_integrity',
467
- 'file-population': 'file_population',
468
- 'render-freshness': 'render_freshness',
469
- 'register-schema': 'register_schema',
470
- 'self-declared-closure': 'self_declared_closure',
471
- 'filename-schema': 'filename_schema',
472
- 'cross-reference': 'cross_reference',
473
- 'git-status-reconcile': 'merged_status',
474
- 'computed-claim': 'computed_claim',
475
- 'term-ownership': 'term_ownership',
476
- 'rule-propagation': 'rule_propagation',
477
- 'git-state': 'git_state',
478
- 'merge-driver-check': 'merge_driver_check',
479
- 'board-reconcile': 'board_reconcile',
480
- 'changelog-freshness': 'changelog_freshness',
481
- })[engine] ?? engine;
482
-
483
490
  export const ENGINES: Record<string, Engine> = {
484
491
  'file-budget': fileBudget,
485
492
  sections,
@@ -501,6 +508,7 @@ export const ENGINES: Record<string, Engine> = {
501
508
  'merge-driver-check': mergeDriverCheck,
502
509
  'board-reconcile': boardReconcile,
503
510
  'changelog-freshness': changelogFreshness,
511
+ 'change-requires-file': changeRequiresFile,
504
512
  };
505
513
 
506
514
  /**