@rungs/cli 0.3.1 → 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 +2184 -478
  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 +18 -29
  62. package/src/engines2.ts +403 -20
  63. package/src/engines3.ts +111 -20
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +86 -27
  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
@@ -10,6 +10,7 @@ import { resolveParams, substitute } from './substitute.ts';
10
10
  import {
11
11
  computedClaim,
12
12
  crossReference,
13
+ changeRequiresFile,
13
14
  filenameSchema,
14
15
  gitStatusReconcile,
15
16
  idIntegrity,
@@ -18,6 +19,8 @@ import {
18
19
  selfDeclaredClosure,
19
20
  } from './engines2.ts';
20
21
  import { boardReconcile, changelogFreshness, gitState, mergeDriverCheck, rulePropagation, termOwnership } from './engines3.ts';
22
+ import { selectEngineTable } from './engine-table.ts';
23
+ import { semanticText } from './text.ts';
21
24
 
22
25
  /**
23
26
  * Where the CLI's own `modules/` lives.
@@ -39,6 +42,8 @@ const CLI_MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'modules
39
42
  export interface Finding {
40
43
  file?: string;
41
44
  message: string;
45
+ /** Stable comparison form when the displayed diagnostic carries environment-specific detail. */
46
+ identity?: string;
42
47
  }
43
48
  export interface EngineResult {
44
49
  findings: Finding[];
@@ -49,7 +54,7 @@ export type Engine = (table: any, repoRoot: string, files: string[]) => EngineRe
49
54
 
50
55
  const read = (root: string, rel: string) => {
51
56
  try {
52
- return readFileSync(join(root, rel), 'utf8');
57
+ return semanticText(readFileSync(join(root, rel), 'utf8'));
53
58
  } catch {
54
59
  return '';
55
60
  }
@@ -371,7 +376,7 @@ export const gateMeta: Engine = (_t, root) => {
371
376
 
372
377
  const registry = join(root, '.ai', 'gates.toml');
373
378
  if (!existsSync(registry)) return { findings, examined: 0 };
374
- const text = readFileSync(registry, 'utf8');
379
+ const text = semanticText(readFileSync(registry, 'utf8'));
375
380
  const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
376
381
  let examined = 0;
377
382
  for (const entry of entries) {
@@ -382,7 +387,7 @@ export const gateMeta: Engine = (_t, root) => {
382
387
  examined++;
383
388
  // Tables live in the CLI, not the repo, so read them from the module set.
384
389
  const tablePath = join(CLI_MODULES, dirname(table), 'gates', table.split('/').pop()!);
385
- const src = existsSync(tablePath) ? readFileSync(tablePath, 'utf8') : '';
390
+ const src = existsSync(tablePath) ? semanticText(readFileSync(tablePath, 'utf8')) : '';
386
391
  const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)]
387
392
  .map((m) => m[0])
388
393
  .filter((b) => b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`));
@@ -412,9 +417,14 @@ export const gateMeta: Engine = (_t, root) => {
412
417
  const blocks = (Array.isArray(parsed.self_test) ? parsed.self_test : [])
413
418
  .filter((b: any) => b?.gate === id)
414
419
  .map((b: any) => ({ expect: String(b.expect), input: b.input, fixture: b.fixture }));
415
- for (const r of runSelfTests(id, engine, parsed[tableKeyFor(engine)] ?? parsed, blocks)) {
416
- if (r.outcome === 'mismatch') findings.push({ message: `self-test for '${id}' ${r.detail}` });
417
- 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}` });
418
428
  }
419
429
  }
420
430
  }
@@ -471,34 +481,12 @@ function parseTable(path: string, module: string): any | null {
471
481
  // the same parameters or neither means anything.
472
482
  const mods = loadAllModules(CLI_MODULES);
473
483
  const params = resolveParams(mods, {}, '.');
474
- return parseToml(substitute(readFileSync(path, 'utf8'), module, params));
484
+ return parseToml(substitute(semanticText(readFileSync(path, 'utf8')), module, params));
475
485
  } catch {
476
486
  return null;
477
487
  }
478
488
  }
479
489
 
480
- /** Duplicated from `check.ts` rather than imported, to keep engines dependency-free of the runner. */
481
- const tableKeyFor = (engine: string) =>
482
- ({
483
- 'file-budget': 'file_budget',
484
- 'frontmatter-schema': 'frontmatter_schema',
485
- 'link-integrity': 'link_integrity',
486
- 'file-population': 'file_population',
487
- 'render-freshness': 'render_freshness',
488
- 'register-schema': 'register_schema',
489
- 'self-declared-closure': 'self_declared_closure',
490
- 'filename-schema': 'filename_schema',
491
- 'cross-reference': 'cross_reference',
492
- 'git-status-reconcile': 'merged_status',
493
- 'computed-claim': 'computed_claim',
494
- 'term-ownership': 'term_ownership',
495
- 'rule-propagation': 'rule_propagation',
496
- 'git-state': 'git_state',
497
- 'merge-driver-check': 'merge_driver_check',
498
- 'board-reconcile': 'board_reconcile',
499
- 'changelog-freshness': 'changelog_freshness',
500
- })[engine] ?? engine;
501
-
502
490
  export const ENGINES: Record<string, Engine> = {
503
491
  'file-budget': fileBudget,
504
492
  sections,
@@ -520,6 +508,7 @@ export const ENGINES: Record<string, Engine> = {
520
508
  'merge-driver-check': mergeDriverCheck,
521
509
  'board-reconcile': boardReconcile,
522
510
  'changelog-freshness': changelogFreshness,
511
+ 'change-requires-file': changeRequiresFile,
523
512
  };
524
513
 
525
514
  /**