@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.
- package/README.md +6 -6
- package/dist/cli.js +2194 -488
- package/dist/cli.js.map +4 -4
- package/modules/README.md +25 -3
- package/modules/adr/files/{{path}}/README.md +1 -1
- package/modules/adr/gates/adr.toml +1 -1
- package/modules/adr/module.toml +1 -1
- package/modules/audit/fragments/AGENTS.md +2 -2
- package/modules/audit/module.toml +1 -1
- package/modules/audit/skills/assess/SKILL.md +1 -1
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
- package/modules/backlog/files/docs/{{root}}/README.md +2 -2
- package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
- package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
- package/modules/backlog/fragments/AGENTS.md +2 -2
- package/modules/backlog/module.toml +1 -1
- package/modules/backlog/skills/work-item/SKILL.md +1 -1
- package/modules/ci/files/{{workflow_path}} +3 -3
- package/modules/ci/module.toml +1 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
- package/modules/concurrency/fragments/AGENTS.md +5 -4
- package/modules/concurrency/fragments/gitattributes +2 -2
- package/modules/concurrency/gates/concurrency.toml +3 -3
- package/modules/concurrency/module.toml +1 -1
- package/modules/doc-authority/files/{{registry_path}} +1 -1
- package/modules/doc-authority/module.toml +1 -1
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
- package/modules/findings/gates/findings.toml +5 -0
- package/modules/findings/module.toml +1 -1
- package/modules/findings/skills/record-finding/SKILL.md +1 -1
- package/modules/gates/files/.ai/gates.toml +1 -1
- package/modules/gates/fragments/AGENTS.md +6 -5
- package/modules/gates/module.toml +1 -1
- package/modules/instructions/files/.ai/rules/README.md +2 -2
- package/modules/instructions/files/.ai/rungs.mjs +52 -0
- package/modules/instructions/files/AGENTS.md +4 -2
- package/modules/instructions/files/CLAUDE.md +1 -1
- package/modules/instructions/fragments/AGENTS.md +2 -2
- package/modules/instructions/gates/core.toml +2 -2
- package/modules/instructions/module.toml +1 -1
- package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
- package/modules/release/gates/release.toml +169 -17
- package/modules/release/module.toml +9 -5
- package/modules/release/skills/cut-release/SKILL.md +43 -15
- package/modules/session/files/{{archive}}/README.md +1 -1
- package/modules/session/files/{{path}} +2 -2
- package/modules/session/module.toml +1 -1
- package/modules/specs/files/{{path}}/README.md +2 -2
- package/modules/specs/module.toml +1 -1
- package/modules/workflows/module.toml +1 -1
- package/modules/workflows/rules/planning-tiers.md +1 -1
- package/package.json +3 -2
- package/src/add.ts +204 -48
- package/src/backlog.ts +354 -48
- package/src/check.ts +54 -33
- package/src/cli.ts +196 -69
- package/src/concurrency.ts +628 -42
- package/src/detect.ts +11 -3
- package/src/emitted-path.ts +274 -0
- package/src/engine-table.ts +66 -0
- package/src/engines.ts +40 -32
- package/src/engines2.ts +424 -29
- package/src/engines3.ts +115 -23
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +95 -31
- package/src/manifest.ts +41 -5
- package/src/render.ts +106 -21
- package/src/selftest.ts +87 -10
- package/src/storage-key.ts +20 -0
- package/src/substitute.ts +47 -5
- package/src/text.ts +11 -0
- package/src/types.ts +16 -3
- package/src/version-source.ts +144 -0
package/src/backlog.ts
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
|
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
|
-
|
|
56
|
-
|
|
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
|
|
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
|
|
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(
|
|
200
|
+
to: `${tree.archive.target}/${posix(rel).split('/').pop()!}`,
|
|
106
201
|
});
|
|
107
202
|
}
|
|
108
203
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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(
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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 = [
|
|
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
|
|
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.
|