@rungs/cli 0.1.3 → 0.2.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 +45 -8
- package/dist/cli.js +883 -127
- package/dist/cli.js.map +4 -4
- package/modules/README.md +13 -0
- package/modules/adr/gates/adr.toml +14 -2
- package/modules/adr/module.toml +19 -1
- package/modules/audit/module.toml +1 -0
- package/modules/backlog/gates/ids.toml +33 -0
- package/modules/backlog/module.toml +61 -1
- package/modules/ci/module.toml +1 -0
- package/modules/concurrency/module.toml +2 -0
- package/modules/design-sync/module.toml +2 -0
- package/modules/doc-authority/module.toml +4 -0
- package/modules/findings/module.toml +3 -0
- package/modules/gates/gates/structural.toml +61 -17
- package/modules/gates/module.toml +5 -0
- package/modules/instructions/module.toml +4 -0
- package/modules/release/gates/release.toml +43 -0
- package/modules/release/module.toml +16 -1
- package/modules/release/skills/cut-release/SKILL.md +8 -1
- package/modules/session/module.toml +4 -2
- package/modules/skills/module.toml +3 -0
- package/modules/specs/module.toml +4 -0
- package/modules/workflows/module.toml +2 -0
- package/package.json +1 -1
- package/src/add.ts +64 -2
- package/src/backlog.ts +182 -0
- package/src/check.ts +47 -4
- package/src/cli.ts +297 -27
- package/src/engines.ts +261 -13
- package/src/engines2.ts +69 -3
- package/src/engines3.ts +147 -0
- package/src/explain.ts +189 -0
- package/src/lifecycle.ts +90 -3
- package/src/manifest.ts +13 -1
- package/src/selftest.ts +221 -0
- package/src/types.ts +34 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[module]
|
|
2
2
|
name = "session"
|
|
3
|
-
version = "1.
|
|
3
|
+
version = "1.2.0"
|
|
4
4
|
rung = 1
|
|
5
5
|
summary = "A handoff document carrying state between sessions: resume point, active constraints, and a dated archive."
|
|
6
6
|
|
|
@@ -27,7 +27,8 @@ default = ".ai/archive"
|
|
|
27
27
|
[[gates]]
|
|
28
28
|
id = "session-sections-present"
|
|
29
29
|
kind = "declared"
|
|
30
|
-
engine = "
|
|
30
|
+
engine = "sections"
|
|
31
|
+
applicability = "our-schema"
|
|
31
32
|
table = "gates/session.toml"
|
|
32
33
|
tier = "fast"
|
|
33
34
|
why = """
|
|
@@ -39,6 +40,7 @@ reopens a settled question, which is the specific failure this document exists t
|
|
|
39
40
|
id = "session-archive-named"
|
|
40
41
|
kind = "declared"
|
|
41
42
|
engine = "filename-schema"
|
|
43
|
+
applicability = "our-schema"
|
|
42
44
|
table = "gates/session.toml"
|
|
43
45
|
tier = "fast"
|
|
44
46
|
why = "An archive whose filenames do not say what closed and what came next is a folder nobody opens."
|
|
@@ -30,6 +30,7 @@ consumed_by = "render"
|
|
|
30
30
|
id = "skills-spec-pure"
|
|
31
31
|
kind = "declared"
|
|
32
32
|
engine = "frontmatter-schema"
|
|
33
|
+
applicability = "our-schema"
|
|
33
34
|
table = "gates/skills.toml"
|
|
34
35
|
tier = "fast"
|
|
35
36
|
why = """
|
|
@@ -43,6 +44,7 @@ is stated at the point of the choice.
|
|
|
43
44
|
id = "skills-description-routes"
|
|
44
45
|
kind = "declared"
|
|
45
46
|
engine = "frontmatter-schema"
|
|
47
|
+
applicability = "our-schema"
|
|
46
48
|
table = "gates/skills.toml"
|
|
47
49
|
tier = "fast"
|
|
48
50
|
why = """
|
|
@@ -55,6 +57,7 @@ what the skill does and the phrases people actually say.
|
|
|
55
57
|
id = "skills-names-neighbours"
|
|
56
58
|
kind = "declared"
|
|
57
59
|
engine = "cross-reference"
|
|
60
|
+
applicability = "our-schema"
|
|
58
61
|
table = "gates/skills.toml"
|
|
59
62
|
tier = "fast"
|
|
60
63
|
why = """
|
|
@@ -37,6 +37,7 @@ default = 600
|
|
|
37
37
|
id = "specs-status-evidence"
|
|
38
38
|
kind = "declared"
|
|
39
39
|
engine = "register-schema"
|
|
40
|
+
applicability = "our-schema"
|
|
40
41
|
table = "gates/specs.toml"
|
|
41
42
|
tier = "fast"
|
|
42
43
|
why = """
|
|
@@ -49,6 +50,7 @@ integration is worse than no spec, because it is read as verified.
|
|
|
49
50
|
id = "specs-scope-section"
|
|
50
51
|
kind = "declared"
|
|
51
52
|
engine = "sections"
|
|
53
|
+
applicability = "our-schema"
|
|
52
54
|
table = "gates/specs.toml"
|
|
53
55
|
tier = "fast"
|
|
54
56
|
why = "Out-of-scope is what stops an agent silently expanding a surface while implementing an unrelated story."
|
|
@@ -57,6 +59,7 @@ why = "Out-of-scope is what stops an agent silently expanding a surface while
|
|
|
57
59
|
id = "specs-ids-unique"
|
|
58
60
|
kind = "declared"
|
|
59
61
|
engine = "id-integrity"
|
|
62
|
+
applicability = "our-schema"
|
|
60
63
|
table = "gates/specs.toml"
|
|
61
64
|
tier = "fast"
|
|
62
65
|
why = "Story ids are cited from commits, plans and work items; two stories sharing one make every citation ambiguous."
|
|
@@ -65,6 +68,7 @@ why = "Story ids are cited from commits, plans and work items; two stories sh
|
|
|
65
68
|
id = "specs-split-threshold"
|
|
66
69
|
kind = "declared"
|
|
67
70
|
engine = "file-budget"
|
|
71
|
+
applicability = "repo-content"
|
|
68
72
|
table = "gates/specs.toml"
|
|
69
73
|
tier = "fast"
|
|
70
74
|
why = "A spec past the threshold is one nobody reads to the end, and its later sections are where the unverified claims collect."
|
|
@@ -29,6 +29,7 @@ default = "docs/plans"
|
|
|
29
29
|
id = "workflows-plan-tier-declared"
|
|
30
30
|
kind = "declared"
|
|
31
31
|
engine = "frontmatter-schema"
|
|
32
|
+
applicability = "our-schema"
|
|
32
33
|
table = "gates/workflows.toml"
|
|
33
34
|
tier = "fast"
|
|
34
35
|
why = """
|
|
@@ -41,6 +42,7 @@ call the table exists to replace.
|
|
|
41
42
|
id = "workflows-plan-sections"
|
|
42
43
|
kind = "declared"
|
|
43
44
|
engine = "sections"
|
|
45
|
+
applicability = "our-schema"
|
|
44
46
|
table = "gates/workflows.toml"
|
|
45
47
|
tier = "fast"
|
|
46
48
|
why = "A tier-2 proposal missing its proof-of-reuse gate is a tier-1 plan wearing a heavier label."
|
package/package.json
CHANGED
package/src/add.ts
CHANGED
|
@@ -66,7 +66,11 @@ export function addModule(
|
|
|
66
66
|
const base = join(mod.dir, 'skills');
|
|
67
67
|
const dir = opts.skillsDir ?? '.claude/skills';
|
|
68
68
|
for (const rel of walk(base)) {
|
|
69
|
-
|
|
69
|
+
// Through the same helper `emittedFiles` uses. These two paths both emit
|
|
70
|
+
// skills and are easy to change apart — patching only `emittedFiles` for
|
|
71
|
+
// F-019 left `add` still writing the un-extended file, so an install and
|
|
72
|
+
// an upgrade would have produced different content for the same skill.
|
|
73
|
+
write(`${dir}/${rel}`, withOptedInExtensions(mod, rel, sub(readFileSync(join(base, rel), 'utf8'))), 'skill');
|
|
70
74
|
}
|
|
71
75
|
}
|
|
72
76
|
|
|
@@ -165,6 +169,31 @@ const gateEntry = (mod: Manifest) => (g: Manifest['gates'][number]) => {
|
|
|
165
169
|
};
|
|
166
170
|
|
|
167
171
|
/** Dependency order, refusing anything unmet — naming the incident (ADR-0003). */
|
|
172
|
+
/**
|
|
173
|
+
* Every module in `order` that cannot be installed because one of the modules
|
|
174
|
+
* it needs — or itself — is a different paradigm.
|
|
175
|
+
*
|
|
176
|
+
* A refusal has to travel *up* the dependency edges, not just stop at the
|
|
177
|
+
* module that matched. `add audit` pulls `findings` which pulls `backlog`; if
|
|
178
|
+
* the repo's work lives in an issue tracker, refusing `backlog` and installing
|
|
179
|
+
* `audit` anyway would ship an audit procedure whose findings have nowhere to
|
|
180
|
+
* close — which is the exact incident (268 audit documents, no register) that
|
|
181
|
+
* made `audit → findings → backlog` a declared dependency in the first place.
|
|
182
|
+
*/
|
|
183
|
+
export function blockedByParadigm(order: Manifest[], paradigms: ReadonlySet<string>): Map<string, string> {
|
|
184
|
+
const blocked = new Map<string, string>();
|
|
185
|
+
// `order` is already dependency-first, so one forward pass settles it.
|
|
186
|
+
for (const mod of order) {
|
|
187
|
+
if (paradigms.has(mod.name)) {
|
|
188
|
+
blocked.set(mod.name, mod.name);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const dep = mod.requires.find((d) => blocked.has(d));
|
|
192
|
+
if (dep) blocked.set(mod.name, blocked.get(dep)!);
|
|
193
|
+
}
|
|
194
|
+
return blocked;
|
|
195
|
+
}
|
|
196
|
+
|
|
168
197
|
export function resolveInstallOrder(requested: string[], all: Manifest[]): { order: Manifest[]; missing: string[] } {
|
|
169
198
|
const byName = new Map(all.map((m) => [m.name, m]));
|
|
170
199
|
const order: Manifest[] = [];
|
|
@@ -232,6 +261,37 @@ export const contentHash = (s: string) => createHash('sha256').update(s.replace(
|
|
|
232
261
|
*/
|
|
233
262
|
const SHARED = new Set(['AGENTS.md', 'CLAUDE.md', '.gitignore', '.gitattributes', '.ai/gates.toml']);
|
|
234
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Add the harness extensions a module opted this skill into.
|
|
266
|
+
*
|
|
267
|
+
* F-019. `[skills.work-item] extensions = { disable-model-invocation = true }`
|
|
268
|
+
* was declared in the `backlog` manifest, documented in `modules/README.md`, and
|
|
269
|
+
* **implemented at no layer**: `grep -n extensions src/*.ts` returned nothing, so
|
|
270
|
+
* the key never reached the emitted `SKILL.md`, and the gate that is supposed to
|
|
271
|
+
* police it could not see the opt-in either. `work-item` creates branches and
|
|
272
|
+
* merges, and the manifest's stated reason for opting it out of model invocation
|
|
273
|
+
* had been inert since it was written.
|
|
274
|
+
*
|
|
275
|
+
* Injected here rather than written into the source skill because that is the
|
|
276
|
+
* point of the opt-in: the file stays spec-pure and portable
|
|
277
|
+
* ([ADR-0001](../docs/decisions/ADR-0001-multi-harness-rendering.md)), and the
|
|
278
|
+
* extension — with its portability cost — stays attached to the module's
|
|
279
|
+
* decision to take it.
|
|
280
|
+
*/
|
|
281
|
+
function withOptedInExtensions(mod: Manifest, rel: string, content: string): string {
|
|
282
|
+
const name = rel.split(/[\\/]/)[0];
|
|
283
|
+
const extensions = mod.skills?.[name]?.extensions;
|
|
284
|
+
if (!extensions || !Object.keys(extensions).length) return content;
|
|
285
|
+
|
|
286
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
287
|
+
if (!m) return content; // no frontmatter to extend; `skills-spec-pure` reports it
|
|
288
|
+
const added = Object.entries(extensions)
|
|
289
|
+
.filter(([k]) => !new RegExp(`^${k}:`, 'm').test(m[1]))
|
|
290
|
+
.map(([k, v]) => `${k}: ${v}`);
|
|
291
|
+
if (!added.length) return content;
|
|
292
|
+
return content.replace(/^---\n[\s\S]*?\n---/, `---\n${m[1]}\n${added.join('\n')}\n---`);
|
|
293
|
+
}
|
|
294
|
+
|
|
235
295
|
export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude/skills'): Map<string, string> {
|
|
236
296
|
const out = new Map<string, string>();
|
|
237
297
|
const sub = (t: string) => substitute(t, mod.name, params);
|
|
@@ -245,7 +305,9 @@ export function emittedFiles(mod: Manifest, params: Params, skillsDir = '.claude
|
|
|
245
305
|
for (const rel of walk(base)) {
|
|
246
306
|
const target = sub(prefix + rel).split('\\').join('/');
|
|
247
307
|
if (SHARED.has(target)) continue;
|
|
248
|
-
|
|
308
|
+
let content = sub(readFileSync(join(base, rel), 'utf8'));
|
|
309
|
+
if (dir === 'skills') content = withOptedInExtensions(mod, rel, content);
|
|
310
|
+
out.set(target, content);
|
|
249
311
|
}
|
|
250
312
|
}
|
|
251
313
|
return out;
|
package/src/backlog.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { walk } from './glob.ts';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* `rungs backlog archive` — move finished items out of `items/` and repoint
|
|
7
|
+
* every link in the repo at their new home.
|
|
8
|
+
*
|
|
9
|
+
* F-015. Three files shipped into **every** consumer repo named this command,
|
|
10
|
+
* two of them saying "never by hand", and it did not exist: `rungs backlog`
|
|
11
|
+
* answered *"unknown command"*. So the instruction was unfollowable everywhere
|
|
12
|
+
* rungs had ever been installed, and the reason it says *never by hand* is
|
|
13
|
+
* exactly why it could not be worked around — moving 39 files and rewriting
|
|
14
|
+
* every citation of them is the kind of repo-wide edit that fails silently.
|
|
15
|
+
*
|
|
16
|
+
* The link rewrite is the whole substance of the command. It resolves each
|
|
17
|
+
* link from the citing file's **own** directory rather than pattern-matching
|
|
18
|
+
* text, because the same target is written `items/WI-001-x.md`,
|
|
19
|
+
* `../items/WI-001-x.md` and `WI-001-x.md` depending on who is citing it, and a
|
|
20
|
+
* regex over any one of those spellings silently misses the others.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export interface ArchiveMove {
|
|
24
|
+
id: string;
|
|
25
|
+
status: string;
|
|
26
|
+
from: string;
|
|
27
|
+
to: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ArchivePlan {
|
|
31
|
+
root: string;
|
|
32
|
+
moves: ArchiveMove[];
|
|
33
|
+
/** Files whose links change, with how many links move in each. */
|
|
34
|
+
rewrites: { file: string; links: number }[];
|
|
35
|
+
/** Items that look finished but are not eligible, with the reason. */
|
|
36
|
+
held: { file: string; reason: string }[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Statuses whose work can no longer change. Mirrors backlog README §8. */
|
|
40
|
+
const FINISHED = new Set(['done', 'rejected']);
|
|
41
|
+
|
|
42
|
+
const field = (text: string, name: string) => text.match(new RegExp(`^${name}:\\s*(\\S+)`, 'm'))?.[1] ?? '';
|
|
43
|
+
|
|
44
|
+
const posix = (p: string) => p.split(sep).join('/');
|
|
45
|
+
|
|
46
|
+
/** A relative markdown link that could point at a repo file. */
|
|
47
|
+
const LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
|
|
48
|
+
|
|
49
|
+
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');
|
|
52
|
+
const moves: ArchiveMove[] = [];
|
|
53
|
+
const held: ArchivePlan['held'] = [];
|
|
54
|
+
|
|
55
|
+
const files = walk(repoRoot);
|
|
56
|
+
const items = files.filter((f) => posix(f).startsWith(posix(relative(repoRoot, itemsDir)) + '/') && f.endsWith('.md'));
|
|
57
|
+
|
|
58
|
+
for (const rel of items) {
|
|
59
|
+
if (/README\.md$/i.test(rel) || /TEMPLATE\.md$/i.test(rel)) continue;
|
|
60
|
+
const text = readFileSync(join(repoRoot, rel), 'utf8');
|
|
61
|
+
const status = field(text, 'status');
|
|
62
|
+
const id = field(text, 'id');
|
|
63
|
+
if (!FINISHED.has(status)) continue;
|
|
64
|
+
|
|
65
|
+
// An epic whose children are not all finished is still live bookkeeping: it
|
|
66
|
+
// is the thing that says what remains. Moving it would file the index of
|
|
67
|
+
// open work under "cannot change any more".
|
|
68
|
+
if (field(text, 'type') === 'epic') {
|
|
69
|
+
const children = (text.match(/^children:\s*\[(.*)\]/m)?.[1] ?? '')
|
|
70
|
+
.split(',')
|
|
71
|
+
.map((s) => s.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
const unfinished = children.filter((c) => {
|
|
74
|
+
const f = items.find((i) => i.includes(`${c}-`));
|
|
75
|
+
return !f || !FINISHED.has(field(readFileSync(join(repoRoot, f), 'utf8'), 'status'));
|
|
76
|
+
});
|
|
77
|
+
if (unfinished.length) {
|
|
78
|
+
held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(', ')}` });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `posix(rel)` first: `walk` yields `/`-separated paths, so splitting on the
|
|
84
|
+
// platform `sep` on Windows never splits and the basename came back as the
|
|
85
|
+
// whole path — producing `archive/docs/backlog/items/WI-001-….md`.
|
|
86
|
+
moves.push({
|
|
87
|
+
id,
|
|
88
|
+
status,
|
|
89
|
+
from: rel,
|
|
90
|
+
to: posix(join(relative(repoRoot, archiveDir), posix(rel).split('/').pop()!)),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Where each moved file ends up, keyed by its absolute old path, so a link can
|
|
95
|
+
// be looked up by what it resolves to rather than by how it was spelled.
|
|
96
|
+
const moved = new Map(moves.map((m) => [resolve(repoRoot, m.from), m.to]));
|
|
97
|
+
const rewrites: ArchivePlan['rewrites'] = [];
|
|
98
|
+
|
|
99
|
+
for (const rel of files) {
|
|
100
|
+
if (!isRewritable(rel)) continue;
|
|
101
|
+
const links = retargets(repoRoot, rel, moved).length;
|
|
102
|
+
if (links || moved.has(resolve(repoRoot, rel))) rewrites.push({ file: rel, links });
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return { root: backlogRoot, moves, rewrites, held };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* A module's `files/` and `fragments/` are **templates**, not repo content.
|
|
110
|
+
* Their links are relative to wherever the fragment merges into, they carry
|
|
111
|
+
* `{{param}}` tokens, and resolving them here reports every one as broken —
|
|
112
|
+
* which is why `link_integrity.exclude` already skips them. Rewriting them
|
|
113
|
+
* would be worse than reporting them: it would bake this repo's paths into what
|
|
114
|
+
* every consumer repo gets installed.
|
|
115
|
+
*/
|
|
116
|
+
function isRewritable(rel: string): boolean {
|
|
117
|
+
const p = posix(rel);
|
|
118
|
+
if (!p.endsWith('.md')) return false;
|
|
119
|
+
return !/^modules\/[^/]+\/(files|fragments)\//.test(p) && !p.startsWith('node_modules/');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The links in one file that this archive run has to change, and what to.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately **only** links whose target moved, plus — when the citing file is
|
|
126
|
+
* itself moving — links that would otherwise break from the new location. The
|
|
127
|
+
* first version compared every link's written form against a freshly computed
|
|
128
|
+
* relative path and counted a difference as a change, which claimed 334 links
|
|
129
|
+
* across 58 files including `AGENTS.md`, `README.md` and module templates. Most
|
|
130
|
+
* of those were equivalent spellings of an unmoved target. Rewriting them would
|
|
131
|
+
* have been a repo-wide reflow disguised as an archive.
|
|
132
|
+
*/
|
|
133
|
+
function retargets(repoRoot: string, rel: string, moved: Map<string, string>): { href: string; to: string }[] {
|
|
134
|
+
const oldDir = dirname(resolve(repoRoot, rel));
|
|
135
|
+
const selfMoved = moved.get(resolve(repoRoot, rel));
|
|
136
|
+
const newDir = dirname(resolve(repoRoot, selfMoved ?? rel));
|
|
137
|
+
const out: { href: string; to: string }[] = [];
|
|
138
|
+
|
|
139
|
+
for (const m of readFileSync(join(repoRoot, rel), 'utf8').matchAll(LINK)) {
|
|
140
|
+
const href = m[1];
|
|
141
|
+
if (href.includes('{{')) continue; // a template link, resolved at install
|
|
142
|
+
const target = resolve(oldDir, decodeURIComponent(href));
|
|
143
|
+
const targetMoved = moved.get(target);
|
|
144
|
+
if (!targetMoved && !selfMoved) continue;
|
|
145
|
+
if (!targetMoved && !existsSync(target)) continue; // already broken; not this command's to fix
|
|
146
|
+
const targetNew = targetMoved ? resolve(repoRoot, targetMoved) : target;
|
|
147
|
+
// No `./` prefix. It is never required for a relative markdown link, and
|
|
148
|
+
// adding it rewrites the spelling of paths whose *target* is what changed —
|
|
149
|
+
// turning a one-word diff into a whole-line one across 37 files.
|
|
150
|
+
const to = posix(relative(newDir, targetNew));
|
|
151
|
+
if (to !== posix(href)) out.push({ href, to });
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function applyArchive(repoRoot: string, plan: ArchivePlan): void {
|
|
157
|
+
const moved = new Map(plan.moves.map((m) => [resolve(repoRoot, m.from), m.to]));
|
|
158
|
+
|
|
159
|
+
// Rewrite before moving. Every path is computed from the plan rather than from
|
|
160
|
+
// the filesystem, so the order is a choice — and this order means a crash
|
|
161
|
+
// halfway leaves the files still where the links say they are.
|
|
162
|
+
for (const rel of walk(repoRoot)) {
|
|
163
|
+
if (!isRewritable(rel)) continue;
|
|
164
|
+
const edits = retargets(repoRoot, rel, moved);
|
|
165
|
+
if (!edits.length) continue;
|
|
166
|
+
const path = join(repoRoot, rel);
|
|
167
|
+
let text = readFileSync(path, 'utf8');
|
|
168
|
+
// Replace through the same matcher that found them, so a href appearing in
|
|
169
|
+
// prose as well as in a link cannot be hit by a bare string replace.
|
|
170
|
+
text = text.replace(LINK, (whole, href: string, anchor: string) => {
|
|
171
|
+
const edit = edits.find((e) => e.href === href);
|
|
172
|
+
return edit ? `](${edit.to}${anchor})` : whole;
|
|
173
|
+
});
|
|
174
|
+
writeFileSync(path, text);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
for (const m of plan.moves) {
|
|
178
|
+
const to = join(repoRoot, ...m.to.split('/'));
|
|
179
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
180
|
+
renameSync(join(repoRoot, m.from), to);
|
|
181
|
+
}
|
|
182
|
+
}
|
package/src/check.ts
CHANGED
|
@@ -44,8 +44,50 @@ export function loadRegistry(repoRoot: string): { runner: any; gates: RegistryGa
|
|
|
44
44
|
return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* ADR-0008: a tier is an ordered **level**, not a tag. `[runner] tiers` declares
|
|
49
|
+
* the order, and asking for one runs every gate at that level or below it.
|
|
50
|
+
*
|
|
51
|
+
* This was string equality, so `full` selected only gates labelled `full` — zero
|
|
52
|
+
* of them on a registry where everything is `fast`, which is this repo. The run
|
|
53
|
+
* then reported no gates and exited as though the release had been gated, and
|
|
54
|
+
* `cut-release` told every consumer to gate on exactly that command (F-020).
|
|
55
|
+
*/
|
|
56
|
+
export function tierSelects(runnerTiers: string[], requested: string, gateTier?: string): boolean {
|
|
57
|
+
if (!gateTier) return true; // untiered gates run in every tier
|
|
58
|
+
const at = runnerTiers.indexOf(requested);
|
|
59
|
+
const of = runnerTiers.indexOf(gateTier);
|
|
60
|
+
// An undeclared tier on either side cannot be ordered. Fall back to equality
|
|
61
|
+
// rather than guessing a position — silently including it would be worse.
|
|
62
|
+
if (at < 0 || of < 0) return gateTier === requested;
|
|
63
|
+
return of <= at;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* No parameter properties: Node's strip-only TypeScript mode rejects them, and
|
|
68
|
+
* `dist/` is built from these sources for a runtime that has no compiler. The
|
|
69
|
+
* same constraint is what v0.1.1 shipped broken (ERR_UNSUPPORTED_NODE_MODULES_
|
|
70
|
+
* TYPE_STRIPPING), so it is worth the four extra lines.
|
|
71
|
+
*/
|
|
72
|
+
export class UnknownTierError extends Error {
|
|
73
|
+
requested: string;
|
|
74
|
+
declared: string[];
|
|
75
|
+
constructor(requested: string, declared: string[]) {
|
|
76
|
+
super(`unknown tier "${requested}"`);
|
|
77
|
+
this.requested = requested;
|
|
78
|
+
this.declared = declared;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
47
82
|
export function runGates(repoRoot: string, tier?: string, now = () => Date.now()): GateRun[] {
|
|
48
|
-
const { gates } = loadRegistry(repoRoot);
|
|
83
|
+
const { runner, gates } = loadRegistry(repoRoot);
|
|
84
|
+
const runnerTiers: string[] = Array.isArray(runner?.tiers) ? runner.tiers : [];
|
|
85
|
+
// A tier nobody declared selects nothing, and "selected nothing" is
|
|
86
|
+
// indistinguishable from "everything passed" at the exit code. Refuse it here
|
|
87
|
+
// rather than let a typo read as a green release gate.
|
|
88
|
+
if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
|
|
89
|
+
throw new UnknownTierError(tier, runnerTiers);
|
|
90
|
+
}
|
|
49
91
|
const files = walk(repoRoot);
|
|
50
92
|
const runs: GateRun[] = [];
|
|
51
93
|
|
|
@@ -53,7 +95,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
|
|
|
53
95
|
// A hook fires on a tool call, not in the runner. Skipping it here is
|
|
54
96
|
// correct; counting it as a pass would not be.
|
|
55
97
|
if (g.trigger) continue;
|
|
56
|
-
if (tier &&
|
|
98
|
+
if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
|
|
57
99
|
|
|
58
100
|
const started = now();
|
|
59
101
|
let status: Status = 'pass';
|
|
@@ -124,7 +166,7 @@ export function runGates(repoRoot: string, tier?: string, now = () => Date.now()
|
|
|
124
166
|
* parameters before parsing — which is also what makes a gate honour the
|
|
125
167
|
* prefix, root and budget that repo actually chose.
|
|
126
168
|
*/
|
|
127
|
-
function loadTable(ref: string | undefined, repoRoot: string): any | null {
|
|
169
|
+
export function loadTable(ref: string | undefined, repoRoot: string): any | null {
|
|
128
170
|
if (!ref) return null;
|
|
129
171
|
const [mod, file] = ref.split('/');
|
|
130
172
|
const path = join(MODULES, mod, 'gates', file);
|
|
@@ -157,7 +199,7 @@ function installedParams(repoRoot: string): Params {
|
|
|
157
199
|
return defaults;
|
|
158
200
|
}
|
|
159
201
|
|
|
160
|
-
const tableKey = (engine: string) =>
|
|
202
|
+
export const tableKey = (engine: string) =>
|
|
161
203
|
({
|
|
162
204
|
'file-budget': 'file_budget',
|
|
163
205
|
sections: 'sections',
|
|
@@ -177,6 +219,7 @@ const tableKey = (engine: string) =>
|
|
|
177
219
|
'rule-propagation': 'rule_propagation',
|
|
178
220
|
'git-state': 'git_state',
|
|
179
221
|
'merge-driver-check': 'merge_driver_check',
|
|
222
|
+
'board-reconcile': 'board_reconcile',
|
|
180
223
|
})[engine] ?? engine;
|
|
181
224
|
|
|
182
225
|
/**
|