@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/backlog.ts CHANGED
@@ -1,5 +1,20 @@
1
- import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
2
- import { dirname, join, relative, resolve, sep } from 'node:path';
1
+ import {
2
+ existsSync,
3
+ lstatSync,
4
+ mkdirSync,
5
+ readFileSync,
6
+ realpathSync,
7
+ renameSync,
8
+ statSync,
9
+ writeFileSync,
10
+ } from 'node:fs';
11
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
12
+ import {
13
+ preflightEmittedPaths,
14
+ resolveEmittedPath,
15
+ UnsafeEmittedPathError,
16
+ type ResolvedEmittedPath,
17
+ } from './emitted-path.ts';
3
18
  import { walk } from './glob.ts';
4
19
 
5
20
  /**
@@ -36,6 +51,74 @@ export interface ArchivePlan {
36
51
  held: { file: string; reason: string }[];
37
52
  }
38
53
 
54
+ export interface ResolvedArchiveTree {
55
+ /** Normalized portable path recorded in the archive plan. */
56
+ root: string;
57
+ items: ResolvedEmittedPath;
58
+ archive: ResolvedEmittedPath;
59
+ itemsExists: boolean;
60
+ archiveExists: boolean;
61
+ }
62
+
63
+ interface PreparedArchiveMove {
64
+ move: ArchiveMove;
65
+ from: ResolvedEmittedPath;
66
+ to: ResolvedEmittedPath;
67
+ }
68
+
69
+ interface PreparedArchiveRewrite {
70
+ file: string;
71
+ path: ResolvedEmittedPath;
72
+ original: string;
73
+ updated: string;
74
+ links: number;
75
+ }
76
+
77
+ interface PreparedArchive {
78
+ moves: PreparedArchiveMove[];
79
+ rewrites: PreparedArchiveRewrite[];
80
+ }
81
+
82
+ const ARCHIVE_OPERATION = 'backlog archive';
83
+
84
+ const missingEntry = (error: unknown) =>
85
+ error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR');
86
+
87
+ function existingDirectory(path: ResolvedEmittedPath): boolean {
88
+ try {
89
+ if (!statSync(path.absolute).isDirectory()) {
90
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, 'the existing archive tree entry is not a directory');
91
+ }
92
+ return true;
93
+ } catch (error) {
94
+ if (error instanceof UnsafeEmittedPathError) throw error;
95
+ if (missingEntry(error)) return false;
96
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, 'the archive tree entry cannot be inspected');
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Resolve the two archive-tree directories before callers inspect either one.
102
+ * Appending the fixed names also validates the configured root as portable
103
+ * repository-relative data without treating the root and its children as
104
+ * conflicting file emissions.
105
+ */
106
+ export function resolveArchiveTree(repoRoot: string, backlogRoot = 'docs/backlog'): ResolvedArchiveTree {
107
+ const [items, archive] = preflightEmittedPaths(repoRoot, [
108
+ { moduleName: ARCHIVE_OPERATION, target: `${backlogRoot}/items` },
109
+ { moduleName: ARCHIVE_OPERATION, target: `${backlogRoot}/archive` },
110
+ ]);
111
+ const suffix = '/items';
112
+ const root = items.target.slice(0, -suffix.length);
113
+ return {
114
+ root,
115
+ items,
116
+ archive,
117
+ itemsExists: existingDirectory(items),
118
+ archiveExists: existingDirectory(archive),
119
+ };
120
+ }
121
+
39
122
  /** Statuses whose work can no longer change. Mirrors backlog README §8. */
40
123
  const FINISHED = new Set(['done', 'rejected']);
41
124
 
@@ -47,13 +130,22 @@ const posix = (p: string) => p.split(sep).join('/');
47
130
  const LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
48
131
 
49
132
  export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): ArchivePlan {
50
- const itemsDir = join(repoRoot, ...backlogRoot.split('/'), 'items');
51
- const archiveDir = join(repoRoot, ...backlogRoot.split('/'), 'archive');
133
+ const tree = resolveArchiveTree(repoRoot, backlogRoot);
52
134
  const moves: ArchiveMove[] = [];
53
135
  const held: ArchivePlan['held'] = [];
54
136
 
55
- const files = walk(repoRoot);
56
- const items = files.filter((f) => posix(f).startsWith(posix(relative(repoRoot, itemsDir)) + '/') && f.endsWith('.md'));
137
+ if (!tree.itemsExists) return { root: tree.root, moves, rewrites: [], held };
138
+
139
+ // Walk the canonical contained directories directly. The repository-wide
140
+ // walker intentionally does not follow directory aliases; an inward alias
141
+ // is nevertheless a valid archive tree and must retain normal behavior.
142
+ const beneath = (directory: ResolvedEmittedPath) =>
143
+ walk(directory.absolute)
144
+ .filter((file) => file.endsWith('.md'))
145
+ .map((file) => `${directory.target}/${posix(file)}`)
146
+ .sort();
147
+ const items = beneath(tree.items);
148
+ const archived = tree.archiveExists ? beneath(tree.archive) : [];
57
149
 
58
150
  for (const rel of items) {
59
151
  // The **basename**, exactly — not a suffix of the path. `/TEMPLATE\.md$/i`
@@ -64,7 +156,8 @@ export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): Arc
64
156
  // to the wrong end reads as careful and is not.
65
157
  const base = posix(rel).split('/').pop()!;
66
158
  if (/^(README|TEMPLATE)\.md$/i.test(base)) continue;
67
- const text = readFileSync(join(repoRoot, rel), 'utf8');
159
+ const source = resolveEmittedPath(repoRoot, ARCHIVE_OPERATION, rel);
160
+ const text = readFileSync(source.absolute, 'utf8');
68
161
  const status = field(text, 'status');
69
162
  const id = field(text, 'id');
70
163
  if (!FINISHED.has(status)) continue;
@@ -82,12 +175,14 @@ export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): Arc
82
175
  // as unfinished, so an epic whose children had all landed could never be
83
176
  // archived and the hold message named five done items as outstanding. The
84
177
  // more finished an epic got, the more stuck it became.
85
- const archived = files.filter((f) => posix(f).startsWith(posix(relative(repoRoot, archiveDir)) + '/') && f.endsWith('.md'));
86
178
  const unfinished = children.filter((c) => {
87
179
  const f = items.find((i) => i.includes(`${c}-`)) ?? archived.find((i) => i.includes(`${c}-`));
88
180
  // Still `!f` → genuinely unknown, and an unknown holds. A child nobody
89
181
  // can find is not evidence that it finished.
90
- return !f || !FINISHED.has(field(readFileSync(join(repoRoot, f), 'utf8'), 'status'));
182
+ return (
183
+ !f ||
184
+ !FINISHED.has(field(readFileSync(resolveEmittedPath(repoRoot, ARCHIVE_OPERATION, f).absolute, 'utf8'), 'status'))
185
+ );
91
186
  });
92
187
  if (unfinished.length) {
93
188
  held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(', ')}` });
@@ -102,22 +197,16 @@ export function planArchive(repoRoot: string, backlogRoot = 'docs/backlog'): Arc
102
197
  id,
103
198
  status,
104
199
  from: rel,
105
- to: posix(join(relative(repoRoot, archiveDir), posix(rel).split('/').pop()!)),
200
+ to: `${tree.archive.target}/${posix(rel).split('/').pop()!}`,
106
201
  });
107
202
  }
108
203
 
109
- // Where each moved file ends up, keyed by its absolute old path, so a link can
110
- // be looked up by what it resolves to rather than by how it was spelled.
111
- const moved = new Map(moves.map((m) => [resolve(repoRoot, m.from), m.to]));
112
- const rewrites: ArchivePlan['rewrites'] = [];
113
-
114
- for (const rel of files) {
115
- if (!isRewritable(rel)) continue;
116
- const links = retargets(repoRoot, rel, moved).length;
117
- if (links || moved.has(resolve(repoRoot, rel))) rewrites.push({ file: rel, links });
118
- }
119
-
120
- return { root: backlogRoot, moves, rewrites, held };
204
+ const provisional = { root: tree.root, moves, rewrites: [], held };
205
+ const prepared = prepareArchive(repoRoot, provisional);
206
+ return {
207
+ ...provisional,
208
+ rewrites: prepared.rewrites.map(({ file, links }) => ({ file, links })),
209
+ };
121
210
  }
122
211
 
123
212
  /**
@@ -145,17 +234,31 @@ function isRewritable(rel: string): boolean {
145
234
  * of those were equivalent spellings of an unmoved target. Rewriting them would
146
235
  * have been a repo-wide reflow disguised as an archive.
147
236
  */
148
- function retargets(repoRoot: string, rel: string, moved: Map<string, string>): { href: string; to: string }[] {
237
+ function retargets(
238
+ repoRoot: string,
239
+ rel: string,
240
+ moved: Map<string, string>,
241
+ source = readFileSync(join(repoRoot, ...rel.split('/')), 'utf8'),
242
+ ): { href: string; to: string }[] {
149
243
  const oldDir = dirname(resolve(repoRoot, rel));
150
- const selfMoved = moved.get(resolve(repoRoot, rel));
244
+ const movedDestination = (path: string) => {
245
+ const lexical = moved.get(path);
246
+ if (lexical) return lexical;
247
+ try {
248
+ return moved.get(realpathSync.native(path));
249
+ } catch {
250
+ return undefined;
251
+ }
252
+ };
253
+ const selfMoved = movedDestination(resolve(repoRoot, rel));
151
254
  const newDir = dirname(resolve(repoRoot, selfMoved ?? rel));
152
255
  const out: { href: string; to: string }[] = [];
153
256
 
154
- for (const m of readFileSync(join(repoRoot, rel), 'utf8').matchAll(LINK)) {
257
+ for (const m of source.matchAll(LINK)) {
155
258
  const href = m[1];
156
259
  if (href.includes('{{')) continue; // a template link, resolved at install
157
260
  const target = resolve(oldDir, decodeURIComponent(href));
158
- const targetMoved = moved.get(target);
261
+ const targetMoved = movedDestination(target);
159
262
  if (!targetMoved && !selfMoved) continue;
160
263
  if (!targetMoved && !existsSync(target)) continue; // already broken; not this command's to fix
161
264
  const targetNew = targetMoved ? resolve(repoRoot, targetMoved) : target;
@@ -168,30 +271,233 @@ function retargets(repoRoot: string, rel: string, moved: Map<string, string>): {
168
271
  return out;
169
272
  }
170
273
 
171
- export function applyArchive(repoRoot: string, plan: ArchivePlan): void {
172
- const moved = new Map(plan.moves.map((m) => [resolve(repoRoot, m.from), m.to]));
173
-
174
- // Rewrite before moving. Every path is computed from the plan rather than from
175
- // the filesystem, so the order is a choice — and this order means a crash
176
- // halfway leaves the files still where the links say they are.
177
- for (const rel of walk(repoRoot)) {
178
- if (!isRewritable(rel)) continue;
179
- const edits = retargets(repoRoot, rel, moved);
180
- if (!edits.length) continue;
181
- const path = join(repoRoot, rel);
182
- let text = readFileSync(path, 'utf8');
183
- // Replace through the same matcher that found them, so a href appearing in
184
- // prose as well as in a link cannot be hit by a bare string replace.
185
- text = text.replace(LINK, (whole, href: string, anchor: string) => {
186
- const edit = edits.find((e) => e.href === href);
187
- return edit ? `](${edit.to}${anchor})` : whole;
274
+ function requireRegularFile(path: ResolvedEmittedPath, purpose: string): void {
275
+ if (path.leafAlias) {
276
+ throw new UnsafeEmittedPathError(
277
+ ARCHIVE_OPERATION,
278
+ path.target,
279
+ `the ${purpose} is a symlink or junction leaf`,
280
+ );
281
+ }
282
+ try {
283
+ if (!lstatSync(path.absolute).isFile()) {
284
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, `the ${purpose} is not a regular file`);
285
+ }
286
+ } catch (error) {
287
+ if (error instanceof UnsafeEmittedPathError) throw error;
288
+ if (missingEntry(error)) {
289
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, `the ${purpose} no longer exists`);
290
+ }
291
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, `the ${purpose} cannot be inspected`);
292
+ }
293
+ }
294
+
295
+ function requireMissingDestination(path: ResolvedEmittedPath): void {
296
+ if (path.leafAlias) {
297
+ throw new UnsafeEmittedPathError(
298
+ ARCHIVE_OPERATION,
299
+ path.target,
300
+ 'the archive destination is an existing symlink or junction leaf',
301
+ );
302
+ }
303
+ try {
304
+ lstatSync(path.absolute);
305
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, 'the archive destination already exists');
306
+ } catch (error) {
307
+ if (error instanceof UnsafeEmittedPathError) throw error;
308
+ if (!missingEntry(error)) {
309
+ throw new UnsafeEmittedPathError(ARCHIVE_OPERATION, path.target, 'the archive destination cannot be inspected');
310
+ }
311
+ }
312
+ }
313
+
314
+ function requireCanonicalDescendant(
315
+ directory: ResolvedEmittedPath,
316
+ path: ResolvedEmittedPath,
317
+ purpose: string,
318
+ ): void {
319
+ const fromDirectory = relative(directory.absolute, path.absolute);
320
+ if (
321
+ !fromDirectory ||
322
+ fromDirectory === '..' ||
323
+ fromDirectory.startsWith(`..${sep}`) ||
324
+ isAbsolute(fromDirectory)
325
+ ) {
326
+ throw new UnsafeEmittedPathError(
327
+ ARCHIVE_OPERATION,
328
+ path.target,
329
+ `the ${purpose} resolves outside the canonical '${directory.target}' tree`,
330
+ );
331
+ }
332
+ }
333
+
334
+ function prepareMoves(repoRoot: string, plan: ArchivePlan, tree: ResolvedArchiveTree): PreparedArchiveMove[] {
335
+ if (plan.root !== tree.root) {
336
+ throw new UnsafeEmittedPathError(
337
+ ARCHIVE_OPERATION,
338
+ plan.root,
339
+ `the plan root does not match its normalized archive root '${tree.root}'`,
340
+ );
341
+ }
342
+
343
+ const itemsPrefix = `${tree.items.target}/`;
344
+ const archivePrefix = `${tree.archive.target}/`;
345
+ for (const move of plan.moves) {
346
+ if (!move.from.startsWith(itemsPrefix)) {
347
+ throw new UnsafeEmittedPathError(
348
+ ARCHIVE_OPERATION,
349
+ move.from,
350
+ `an archive source must be below '${tree.items.target}'`,
351
+ );
352
+ }
353
+ const expected = `${archivePrefix}${move.from.split('/').pop()!}`;
354
+ if (move.to !== expected) {
355
+ throw new UnsafeEmittedPathError(
356
+ ARCHIVE_OPERATION,
357
+ move.to,
358
+ `the archive destination for '${move.from}' must be '${expected}'`,
359
+ );
360
+ }
361
+ }
362
+
363
+ const resolved = preflightEmittedPaths(
364
+ repoRoot,
365
+ plan.moves.flatMap((move) => [
366
+ { moduleName: ARCHIVE_OPERATION, target: move.from },
367
+ { moduleName: ARCHIVE_OPERATION, target: move.to },
368
+ ]),
369
+ );
370
+
371
+ return plan.moves.map((move, index) => {
372
+ const from = resolved[index * 2];
373
+ const to = resolved[index * 2 + 1];
374
+ requireCanonicalDescendant(tree.items, from, 'archive source');
375
+ requireCanonicalDescendant(tree.archive, to, 'archive destination');
376
+ requireRegularFile(from, 'archive source');
377
+ requireMissingDestination(to);
378
+
379
+ const source = readFileSync(from.absolute, 'utf8');
380
+ if (field(source, 'id') !== move.id || field(source, 'status') !== move.status || !FINISHED.has(move.status)) {
381
+ throw new UnsafeEmittedPathError(
382
+ ARCHIVE_OPERATION,
383
+ move.from,
384
+ 'the archive plan is stale or does not match the source item frontmatter',
385
+ );
386
+ }
387
+ return { move, from, to };
388
+ });
389
+ }
390
+
391
+ function preparedText(source: string, edits: { href: string; to: string }[]): string {
392
+ // Replace through the same matcher that found the links, so an href appearing
393
+ // in prose as well as in a link cannot be hit by a bare string replace.
394
+ return source.replace(LINK, (whole, href: string, anchor: string) => {
395
+ const edit = edits.find((candidate) => candidate.href === href);
396
+ return edit ? `](${edit.to}${anchor})` : whole;
397
+ });
398
+ }
399
+
400
+ function rewriteSummary(rewrites: PreparedArchiveRewrite[]): ArchivePlan['rewrites'] {
401
+ return rewrites
402
+ .map(({ file, links }) => ({ file, links }))
403
+ .sort((left, right) => left.file.localeCompare(right.file));
404
+ }
405
+
406
+ function prepareArchive(repoRoot: string, plan: ArchivePlan, verifyRecordedRewrites = false): PreparedArchive {
407
+ const tree = resolveArchiveTree(repoRoot, plan.root);
408
+ const moves = prepareMoves(repoRoot, plan, tree);
409
+
410
+ if (verifyRecordedRewrites) {
411
+ const recorded = preflightEmittedPaths(
412
+ repoRoot,
413
+ plan.rewrites.map((rewrite) => ({
414
+ moduleName: ARCHIVE_OPERATION,
415
+ target: rewrite.file,
416
+ writeExisting: true,
417
+ })),
418
+ );
419
+ recorded.forEach((path) => requireRegularFile(path, 'recorded rewrite target'));
420
+ }
421
+
422
+ // Where each moved file ends up, keyed by its lexical old path, so a link is
423
+ // selected by what its written spelling resolves to. Actual I/O below uses
424
+ // the canonical paths that were validated for this operation.
425
+ const moved = new Map<string, string>();
426
+ for (const { move, from } of moves) {
427
+ moved.set(resolve(repoRoot, ...move.from.split('/')), move.to);
428
+ moved.set(from.absolute, move.to);
429
+ }
430
+
431
+ // Prefer the archive plan's lexical spelling for a moved source, then the
432
+ // previously recorded rewrite spelling. The general walker can also see the
433
+ // canonical side of an inward directory alias; deduplicating by canonical
434
+ // identity prevents one physical file from being prepared twice.
435
+ const candidatePaths: { file: string; path: ResolvedEmittedPath }[] = [];
436
+ const seenCanonical = new Set<string>();
437
+ const addCandidate = (file: string) => {
438
+ if (!isRewritable(file)) return;
439
+ const path = resolveEmittedPath(repoRoot, ARCHIVE_OPERATION, file);
440
+ if (seenCanonical.has(path.absolute)) return;
441
+ seenCanonical.add(path.absolute);
442
+ candidatePaths.push({ file, path });
443
+ };
444
+ moves.forEach(({ move }) => addCandidate(move.from));
445
+ if (verifyRecordedRewrites) plan.rewrites.forEach(({ file }) => addCandidate(file));
446
+ walk(repoRoot).sort().forEach(addCandidate);
447
+
448
+ const drafts = candidatePaths
449
+ .flatMap(({ file, path }) => {
450
+ const original = readFileSync(path.absolute, 'utf8');
451
+ const edits = retargets(repoRoot, file, moved, original);
452
+ if (!edits.length) return [];
453
+ return [{ file, original, updated: preparedText(original, edits), links: edits.length }];
188
454
  });
189
- writeFileSync(path, text);
455
+
456
+ const paths = preflightEmittedPaths(
457
+ repoRoot,
458
+ drafts.map((rewrite) => ({
459
+ moduleName: ARCHIVE_OPERATION,
460
+ target: rewrite.file,
461
+ writeExisting: true,
462
+ })),
463
+ );
464
+ const rewrites = drafts.map((rewrite, index) => ({ ...rewrite, path: paths[index] }));
465
+ rewrites.forEach(({ path }) => requireRegularFile(path, 'rewrite target'));
466
+
467
+ if (verifyRecordedRewrites) {
468
+ const expected = [...plan.rewrites].sort((left, right) => left.file.localeCompare(right.file));
469
+ const actual = rewriteSummary(rewrites);
470
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) {
471
+ throw new UnsafeEmittedPathError(
472
+ ARCHIVE_OPERATION,
473
+ plan.root,
474
+ 'the archive plan is stale: its complete rewrite set no longer matches the repository',
475
+ );
476
+ }
190
477
  }
191
478
 
192
- for (const m of plan.moves) {
193
- const to = join(repoRoot, ...m.to.split('/'));
194
- mkdirSync(dirname(to), { recursive: true });
195
- renameSync(join(repoRoot, m.from), to);
479
+ return { moves, rewrites };
480
+ }
481
+
482
+ export function applyArchive(repoRoot: string, plan: ArchivePlan): void {
483
+ const prepared = prepareArchive(repoRoot, plan, true);
484
+
485
+ // Check every captured source again before the first mutation. This catches
486
+ // ordinary stale-plan edits without allowing an earlier rewrite to land.
487
+ for (const rewrite of prepared.rewrites) {
488
+ if (readFileSync(rewrite.path.absolute, 'utf8') !== rewrite.original) {
489
+ throw new UnsafeEmittedPathError(
490
+ ARCHIVE_OPERATION,
491
+ rewrite.file,
492
+ 'the rewrite target changed after archive preflight',
493
+ );
494
+ }
196
495
  }
496
+
497
+ // Rewrite before moving. Every mutation uses the canonical paths carried by
498
+ // the validated operation, so an inward alias works and an outward alias can
499
+ // never be followed during application.
500
+ for (const move of prepared.moves) mkdirSync(dirname(move.to.absolute), { recursive: true });
501
+ for (const rewrite of prepared.rewrites) writeFileSync(rewrite.path.absolute, rewrite.updated);
502
+ for (const move of prepared.moves) renameSync(move.from.absolute, move.to.absolute);
197
503
  }
package/src/check.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { appendFileSync, existsSync, readFileSync } from 'node:fs';
1
+ import { appendFileSync, existsSync, readFileSync, realpathSync } from 'node:fs';
2
2
  import { execSync } from 'node:child_process';
3
- import { dirname, join } from 'node:path';
3
+ import { dirname, join, resolve } from 'node:path';
4
4
  import { fileURLToPath } from 'node:url';
5
5
  import { parse } from 'smol-toml';
6
6
  import { ENGINES, isImplemented, type Finding } from './engines.ts';
7
7
  import { walk } from './glob.ts';
8
8
  import { resolveParams, substitute, type Params } from './substitute.ts';
9
9
  import { loadAllModules } from './manifest.ts';
10
+ import { selectEngineTable } from './engine-table.ts';
10
11
 
11
12
  const MODULES = join(dirname(fileURLToPath(import.meta.url)), '..', 'modules');
12
13
 
@@ -79,6 +80,55 @@ export class UnknownTierError extends Error {
79
80
  }
80
81
  }
81
82
 
83
+ function commandText(value: unknown): string {
84
+ if (value === undefined || value === null) return '';
85
+ return Buffer.isBuffer(value) ? value.toString('utf8') : String(value);
86
+ }
87
+
88
+ function normalizeCommandText(value: unknown, repoRoot: string): string {
89
+ let text = commandText(value).replace(/\r\n?/g, '\n').trim();
90
+ const absolute = resolve(repoRoot);
91
+ const roots = [absolute];
92
+ try {
93
+ roots.push(realpathSync.native(absolute));
94
+ } catch {
95
+ // A command failure still needs a diagnostic if the checkout disappears or
96
+ // cannot be canonicalized while its error is being reported.
97
+ }
98
+ const variants = [...new Set(roots.flatMap((root) => [
99
+ root,
100
+ root.replaceAll('\\', '/'),
101
+ root.replaceAll('/', '\\'),
102
+ ]))].sort((left, right) => right.length - left.length);
103
+ for (const variant of variants) {
104
+ const escaped = variant.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
105
+ text = text.replace(new RegExp(escaped, process.platform === 'win32' ? 'gi' : 'g'), '<repo>');
106
+ }
107
+ return text;
108
+ }
109
+
110
+ /** Preserve actionable command output while keeping land attribution independent of root spelling and EOL. */
111
+ function commandFailure(error: any, repoRoot: string): Finding {
112
+ const stderr = normalizeCommandText(error?.stderr, repoRoot);
113
+ const stdout = normalizeCommandText(error?.stdout, repoRoot);
114
+ const fallback = normalizeCommandText(error?.message, repoRoot);
115
+ const status = typeof error?.status === 'number'
116
+ ? String(error.status)
117
+ : error?.signal
118
+ ? `signal ${error.signal}`
119
+ : 'unknown';
120
+ const streams = [
121
+ ...(stderr ? [`stderr:\n${stderr}`] : []),
122
+ ...(stdout ? [`stdout:\n${stdout}`] : []),
123
+ ];
124
+ const detail = streams.length ? streams.join('\n') : fallback;
125
+ const diagnostic = `command exited with status ${status}${detail ? `\n${detail}` : ''}`;
126
+ return {
127
+ message: diagnostic,
128
+ identity: `command:${status}${detail ? `\n${detail}` : ''}`,
129
+ };
130
+ }
131
+
82
132
  /**
83
133
  * `only` narrows the run to named gate ids. Attribution needs it: after a merged
84
134
  * tree goes red, `land` re-runs **just the failing gates** against the merge base
@@ -114,7 +164,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
114
164
  execSync(g.command, { cwd: repoRoot, stdio: 'pipe' });
115
165
  } catch (e: any) {
116
166
  status = 'fail';
117
- findings = [{ message: String(e.stderr ?? e.stdout ?? e.message).trim().split('\n').slice(-3).join(' ') }];
167
+ findings = [commandFailure(e, repoRoot)];
118
168
  }
119
169
  } else if (!g.engine || !isImplemented(g.engine)) {
120
170
  // Never green. An engine named in a table and missing from the CLI is an
@@ -129,13 +179,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
129
179
  findings = [{ message: `table '${g.table}' not found` }];
130
180
  } else {
131
181
  try {
132
- const key = tableKey(g.engine);
133
- let section = table[key] ?? table;
134
- // An array table holds one entry per gate; select by trailing id.
135
- if (Array.isArray(section) && section.some((s: any) => s?.id)) {
136
- const mine = section.filter((s: any) => !s.id || g.id.includes(s.id));
137
- if (mine.length) section = mine;
138
- }
182
+ const section = selectEngineTable(table, g.engine, g.id);
139
183
  const r = ENGINES[g.engine](section, repoRoot, files);
140
184
  findings = r.findings;
141
185
  examined = r.examined;
@@ -206,29 +250,6 @@ export function installedParams(repoRoot: string): Params {
206
250
  return defaults;
207
251
  }
208
252
 
209
- export const tableKey = (engine: string) =>
210
- ({
211
- 'file-budget': 'file_budget',
212
- sections: 'sections',
213
- 'frontmatter-schema': 'frontmatter_schema',
214
- 'link-integrity': 'link_integrity',
215
- 'file-population': 'file_population',
216
- 'gate-meta': 'gate_meta',
217
- 'id-integrity': '__whole__',
218
- 'render-freshness': 'render_freshness',
219
- 'register-schema': 'register_schema',
220
- 'self-declared-closure': 'self_declared_closure',
221
- 'filename-schema': 'filename_schema',
222
- 'cross-reference': 'cross_reference',
223
- 'git-status-reconcile': 'merged_status',
224
- 'computed-claim': 'computed_claim',
225
- 'term-ownership': 'term_ownership',
226
- 'rule-propagation': 'rule_propagation',
227
- 'git-state': 'git_state',
228
- 'merge-driver-check': 'merge_driver_check',
229
- 'board-reconcile': 'board_reconcile',
230
- })[engine] ?? engine;
231
-
232
253
  /**
233
254
  * ADR-0005 tier A. One line per gate per run: what the runner directly observes
234
255
  * and nothing that needs interpretation. Local, gitignored, never transmitted.