@rungs/cli 0.1.3 → 0.3.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 +61 -11
- package/dist/cli.js +1250 -129
- 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/files/{{workflow_path}} +9 -1
- package/modules/ci/module.toml +2 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +11 -5
- package/modules/concurrency/module.toml +3 -1
- 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 +72 -4
- 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 +197 -0
- package/src/check.ts +56 -6
- package/src/cli.ts +406 -27
- package/src/concurrency.ts +412 -0
- package/src/engines.ts +261 -13
- package/src/engines2.ts +89 -4
- 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 +237 -0
- package/src/types.ts +34 -0
package/dist/cli.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
5
|
-
import { dirname as
|
|
5
|
+
import { dirname as dirname9, join as join14, resolve as resolve5 } from "node:path";
|
|
6
6
|
|
|
7
7
|
// src/manifest.ts
|
|
8
8
|
import { readdirSync as readdirSync2, readFileSync, statSync as statSync2 } from "node:fs";
|
|
@@ -106,6 +106,7 @@ function loadManifest(dir) {
|
|
|
106
106
|
params: raw.params ?? {},
|
|
107
107
|
gates: raw.gates ?? [],
|
|
108
108
|
detect: raw.detect ?? {},
|
|
109
|
+
skills: raw.skills ?? {},
|
|
109
110
|
provenance: raw.provenance,
|
|
110
111
|
threshold: raw.threshold,
|
|
111
112
|
dir
|
|
@@ -161,6 +162,13 @@ function auditModules(mods) {
|
|
|
161
162
|
if (!g.why?.trim()) {
|
|
162
163
|
issues.push({ module: mod.name, kind: "gate-no-why", detail: `gate '${g.id}' has no 'why'` });
|
|
163
164
|
}
|
|
165
|
+
if (g.kind === "declared" && !g.applicability) {
|
|
166
|
+
issues.push({
|
|
167
|
+
module: mod.name,
|
|
168
|
+
kind: "gate-no-applicability",
|
|
169
|
+
detail: `gate '${g.id}' does not declare applicability (repo-content | our-artifacts | our-schema)`
|
|
170
|
+
});
|
|
171
|
+
}
|
|
164
172
|
}
|
|
165
173
|
}
|
|
166
174
|
return issues;
|
|
@@ -209,9 +217,9 @@ function resolveParams(mods, overrides = {}, repoRoot) {
|
|
|
209
217
|
}
|
|
210
218
|
return out;
|
|
211
219
|
}
|
|
212
|
-
function markers(
|
|
220
|
+
function markers(targetPath2, module, version) {
|
|
213
221
|
const hash = /\.(toml|ya?ml|gitignore|gitattributes|sh|ps1|conf|properties)$|(^|\/)\.(gitignore|gitattributes)$/.test(
|
|
214
|
-
|
|
222
|
+
targetPath2
|
|
215
223
|
);
|
|
216
224
|
return hash ? { begin: `# rungs:begin ${module}@${version}`, end: `# rungs:end ${module}` } : { begin: `<!-- rungs:begin ${module}@${version} -->`, end: `<!-- rungs:end ${module} -->` };
|
|
217
225
|
}
|
|
@@ -225,8 +233,8 @@ function mergeBlock(existing, fragment, module) {
|
|
|
225
233
|
const after = existing.slice(e.index + e[0].length);
|
|
226
234
|
return `${before}${fragment.trim()}${after}`;
|
|
227
235
|
}
|
|
228
|
-
const
|
|
229
|
-
return `${existing}${
|
|
236
|
+
const sep3 = existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
|
|
237
|
+
return `${existing}${sep3}${fragment.trim()}
|
|
230
238
|
`;
|
|
231
239
|
}
|
|
232
240
|
|
|
@@ -267,7 +275,7 @@ function addModule(mod, repoRoot, params, opts = {}) {
|
|
|
267
275
|
const base = join3(mod.dir, "skills");
|
|
268
276
|
const dir = opts.skillsDir ?? ".claude/skills";
|
|
269
277
|
for (const rel of walk(base)) {
|
|
270
|
-
write(`${dir}/${rel}`, sub(readFileSync2(join3(base, rel), "utf8")), "skill");
|
|
278
|
+
write(`${dir}/${rel}`, withOptedInExtensions(mod, rel, sub(readFileSync2(join3(base, rel), "utf8"))), "skill");
|
|
271
279
|
}
|
|
272
280
|
}
|
|
273
281
|
if (has("fragments")) {
|
|
@@ -346,6 +354,18 @@ var gateEntry = (mod) => (g) => {
|
|
|
346
354
|
if (g.why) lines.push(`why = """${g.why.trim()}"""`);
|
|
347
355
|
return lines.join("\n");
|
|
348
356
|
};
|
|
357
|
+
function blockedByParadigm(order, paradigms) {
|
|
358
|
+
const blocked = /* @__PURE__ */ new Map();
|
|
359
|
+
for (const mod of order) {
|
|
360
|
+
if (paradigms.has(mod.name)) {
|
|
361
|
+
blocked.set(mod.name, mod.name);
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
const dep = mod.requires.find((d) => blocked.has(d));
|
|
365
|
+
if (dep) blocked.set(mod.name, blocked.get(dep));
|
|
366
|
+
}
|
|
367
|
+
return blocked;
|
|
368
|
+
}
|
|
349
369
|
function resolveInstallOrder(requested, all) {
|
|
350
370
|
const byName = new Map(all.map((m) => [m.name, m]));
|
|
351
371
|
const order = [];
|
|
@@ -379,6 +399,19 @@ function resolveInstallOrder(requested, all) {
|
|
|
379
399
|
}
|
|
380
400
|
var contentHash = (s) => createHash("sha256").update(s.replace(/\r\n/g, "\n")).digest("hex").slice(0, 12);
|
|
381
401
|
var SHARED = /* @__PURE__ */ new Set(["AGENTS.md", "CLAUDE.md", ".gitignore", ".gitattributes", ".ai/gates.toml"]);
|
|
402
|
+
function withOptedInExtensions(mod, rel, content) {
|
|
403
|
+
const name = rel.split(/[\\/]/)[0];
|
|
404
|
+
const extensions = mod.skills?.[name]?.extensions;
|
|
405
|
+
if (!extensions || !Object.keys(extensions).length) return content;
|
|
406
|
+
const m = content.match(/^---\n([\s\S]*?)\n---/);
|
|
407
|
+
if (!m) return content;
|
|
408
|
+
const added = Object.entries(extensions).filter(([k]) => !new RegExp(`^${k}:`, "m").test(m[1])).map(([k, v]) => `${k}: ${v}`);
|
|
409
|
+
if (!added.length) return content;
|
|
410
|
+
return content.replace(/^---\n[\s\S]*?\n---/, `---
|
|
411
|
+
${m[1]}
|
|
412
|
+
${added.join("\n")}
|
|
413
|
+
---`);
|
|
414
|
+
}
|
|
382
415
|
function emittedFiles(mod, params, skillsDir = ".claude/skills") {
|
|
383
416
|
const out = /* @__PURE__ */ new Map();
|
|
384
417
|
const sub = (t) => substitute(t, mod.name, params);
|
|
@@ -392,7 +425,9 @@ function emittedFiles(mod, params, skillsDir = ".claude/skills") {
|
|
|
392
425
|
for (const rel of walk(base)) {
|
|
393
426
|
const target = sub(prefix + rel).split("\\").join("/");
|
|
394
427
|
if (SHARED.has(target)) continue;
|
|
395
|
-
|
|
428
|
+
let content = sub(readFileSync2(join3(base, rel), "utf8"));
|
|
429
|
+
if (dir === "skills") content = withOptedInExtensions(mod, rel, content);
|
|
430
|
+
out.set(target, content);
|
|
396
431
|
}
|
|
397
432
|
}
|
|
398
433
|
return out;
|
|
@@ -816,21 +851,146 @@ function writeReport(repoRoot, entries, harnesses, stamp) {
|
|
|
816
851
|
// src/check.ts
|
|
817
852
|
import { appendFileSync, existsSync as existsSync7, readFileSync as readFileSync8 } from "node:fs";
|
|
818
853
|
import { execSync as execSync3 } from "node:child_process";
|
|
819
|
-
import { dirname as
|
|
854
|
+
import { dirname as dirname5, join as join10 } from "node:path";
|
|
820
855
|
import { fileURLToPath } from "node:url";
|
|
821
856
|
import { parse as parse2 } from "smol-toml";
|
|
822
857
|
|
|
823
858
|
// src/engines.ts
|
|
824
859
|
import { existsSync as existsSync6, readFileSync as readFileSync7 } from "node:fs";
|
|
825
|
-
import { join as
|
|
860
|
+
import { join as join9, dirname as dirname4, resolve as resolve2 } from "node:path";
|
|
861
|
+
import { parse as parseToml } from "smol-toml";
|
|
862
|
+
|
|
863
|
+
// src/selftest.ts
|
|
864
|
+
import { mkdirSync as mkdirSync3, mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
865
|
+
import { tmpdir } from "node:os";
|
|
866
|
+
import { dirname as dirname3, join as join6 } from "node:path";
|
|
867
|
+
function targetPath(table) {
|
|
868
|
+
const t = Array.isArray(table) ? table[0] ?? {} : table ?? {};
|
|
869
|
+
const pattern = t.file ?? [t.scan ?? []].flat()[0] ?? "**/*.md";
|
|
870
|
+
if (!pattern.includes("*")) return pattern;
|
|
871
|
+
return pattern.replace(/\*\*\//g, "probe/").replace(/\/\*\*/g, "/probe").replace(/\*/g, "probe").replace(/probe\.probe$/, "probe.md");
|
|
872
|
+
}
|
|
873
|
+
function build(root, table, fx, input) {
|
|
874
|
+
const write = (rel, body) => {
|
|
875
|
+
const full = join6(root, rel);
|
|
876
|
+
mkdirSync3(dirname3(full), { recursive: true });
|
|
877
|
+
writeFileSync3(full, body);
|
|
878
|
+
return rel;
|
|
879
|
+
};
|
|
880
|
+
if (typeof input === "string") return [write(targetPath(table), `${input}
|
|
881
|
+
`)];
|
|
882
|
+
if (!fx || typeof fx !== "object") return null;
|
|
883
|
+
if (fx.packages && typeof fx.packages === "object") {
|
|
884
|
+
return Object.entries(fx.packages).map(
|
|
885
|
+
([rel, version]) => write(rel, JSON.stringify({ name: rel.replace(/\W/g, "-"), version }))
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
if (Array.isArray(fx.fragments) && typeof fx.version === "string") {
|
|
889
|
+
const dir = fx.dir ?? "changelog.d";
|
|
890
|
+
const written = fx.fragments.map((n) => write(`${dir}/${n}`, `# ${n}
|
|
891
|
+
`));
|
|
892
|
+
written.push(write("package.json", JSON.stringify({ version: fx.version })));
|
|
893
|
+
return written;
|
|
894
|
+
}
|
|
895
|
+
if (typeof fx.matching_files === "number") {
|
|
896
|
+
const base = fx.location ?? dirname3(targetPath(table));
|
|
897
|
+
const marker = fx.exempt ? `<!-- ${fx.exempt} -->
|
|
898
|
+
` : "";
|
|
899
|
+
return Array.from(
|
|
900
|
+
{ length: fx.matching_files },
|
|
901
|
+
(_, i) => write(join6(base === "." ? "" : base, `probe-${i}.md`), `${marker}# probe ${i}
|
|
902
|
+
`)
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
const contentKeys = ["frontmatter", "sections", "opening", "body", "row", "table"];
|
|
906
|
+
if (contentKeys.some((k) => k in fx)) {
|
|
907
|
+
const parts = [];
|
|
908
|
+
if (fx.table) parts.push(`## ${fx.table}`, "");
|
|
909
|
+
if (fx.frontmatter && typeof fx.frontmatter === "object") {
|
|
910
|
+
parts.push("---");
|
|
911
|
+
for (const [k, v] of Object.entries(fx.frontmatter)) parts.push(`${k}: ${v}`);
|
|
912
|
+
parts.push("---", "");
|
|
913
|
+
}
|
|
914
|
+
if (fx.opening) parts.push(String(fx.opening), "");
|
|
915
|
+
for (const s of fx.sections ?? []) parts.push(`## ${s}`, "", "text", "");
|
|
916
|
+
if (fx.row && typeof fx.row === "object") {
|
|
917
|
+
const cols = Object.keys(fx.row);
|
|
918
|
+
parts.push(
|
|
919
|
+
`| ${cols.join(" | ")} |`,
|
|
920
|
+
`| ${cols.map(() => "---").join(" | ")} |`,
|
|
921
|
+
`| ${cols.map((c2) => fx.row[c2]).join(" | ")} |`,
|
|
922
|
+
""
|
|
923
|
+
);
|
|
924
|
+
}
|
|
925
|
+
if (fx.body) parts.push(String(fx.body), "");
|
|
926
|
+
return [write(fx.file ?? targetPath(table), `${parts.join("\n")}
|
|
927
|
+
`)];
|
|
928
|
+
}
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
var CONTEXT_FREE = /* @__PURE__ */ new Set([
|
|
932
|
+
"frontmatter-schema",
|
|
933
|
+
"sections",
|
|
934
|
+
"file-budget",
|
|
935
|
+
"register-schema",
|
|
936
|
+
"file-population",
|
|
937
|
+
"changelog-freshness",
|
|
938
|
+
"computed-claim"
|
|
939
|
+
]);
|
|
940
|
+
function deparam(spec, dir) {
|
|
941
|
+
const walk3 = (v) => typeof v === "string" ? v.replace(/\{\{[^}]+\}\}/g, dir) : Array.isArray(v) ? v.map(walk3) : v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk3(x)])) : v;
|
|
942
|
+
return walk3(spec);
|
|
943
|
+
}
|
|
944
|
+
function runSelfTests(gateId, engine, table, blocks) {
|
|
945
|
+
const out = [];
|
|
946
|
+
for (const b of blocks) {
|
|
947
|
+
const expect = b.expect === "fail" ? "fail" : "pass";
|
|
948
|
+
if (!CONTEXT_FREE.has(engine)) {
|
|
949
|
+
out.push({ gate: gateId, expect, outcome: "unrun", detail: `${engine} fixtures need context the fixture does not carry` });
|
|
950
|
+
continue;
|
|
951
|
+
}
|
|
952
|
+
if (!(engine in ENGINES)) {
|
|
953
|
+
out.push({ gate: gateId, expect, outcome: "unrun", detail: `engine '${engine}' not implemented` });
|
|
954
|
+
continue;
|
|
955
|
+
}
|
|
956
|
+
const root = mkdtempSync(join6(tmpdir(), "rungs-selftest-"));
|
|
957
|
+
try {
|
|
958
|
+
const files = build(root, table, b.fixture, b.input);
|
|
959
|
+
let spec = b.fixture?.opted_in ? Array.isArray(table) ? table.map((s) => ({ ...s, extensions_opted_in: b.fixture.opted_in })) : { ...table, extensions_opted_in: b.fixture.opted_in } : table;
|
|
960
|
+
if (Array.isArray(b.fixture?.fragments)) spec = deparam(spec, b.fixture.dir ?? "changelog.d");
|
|
961
|
+
if (Array.isArray(b.fixture?.exclude)) {
|
|
962
|
+
const ex = b.fixture.exclude;
|
|
963
|
+
spec = Array.isArray(spec) ? spec.map((s) => ({ ...s, exclude: ex })) : { ...spec, exclude: ex };
|
|
964
|
+
}
|
|
965
|
+
if (!files) {
|
|
966
|
+
out.push({ gate: gateId, expect, outcome: "unrun", detail: `no builder for fixture ${JSON.stringify(b.fixture).slice(0, 60)}` });
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
let findings;
|
|
970
|
+
try {
|
|
971
|
+
findings = ENGINES[engine](spec, root, files).findings;
|
|
972
|
+
} catch (e) {
|
|
973
|
+
out.push({ gate: gateId, expect, outcome: "unrun", detail: `engine threw: ${e.message}`.slice(0, 90) });
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const fired = findings.length > 0;
|
|
977
|
+
out.push(
|
|
978
|
+
fired === (expect === "fail") ? { gate: gateId, expect, outcome: "ok" } : { gate: gateId, expect, outcome: "mismatch", detail: `expected ${expect}, ${fired ? `fired: ${findings[0].message}`.slice(0, 80) : "did not fire"}` }
|
|
979
|
+
);
|
|
980
|
+
} finally {
|
|
981
|
+
rmSync(root, { recursive: true, force: true });
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
return out;
|
|
985
|
+
}
|
|
826
986
|
|
|
827
987
|
// src/engines2.ts
|
|
828
988
|
import { readFileSync as readFileSync5 } from "node:fs";
|
|
829
989
|
import { execSync } from "node:child_process";
|
|
830
|
-
import { join as
|
|
990
|
+
import { join as join7 } from "node:path";
|
|
831
991
|
var read = (root, rel) => {
|
|
832
992
|
try {
|
|
833
|
-
return readFileSync5(
|
|
993
|
+
return readFileSync5(join7(root, rel), "utf8");
|
|
834
994
|
} catch {
|
|
835
995
|
return "";
|
|
836
996
|
}
|
|
@@ -924,40 +1084,44 @@ var renderFreshness = (t, root, files) => {
|
|
|
924
1084
|
var registerSchema = (t, root, files) => {
|
|
925
1085
|
const findings = [];
|
|
926
1086
|
let examined = 0;
|
|
927
|
-
const
|
|
928
|
-
for (const
|
|
929
|
-
const
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
if (
|
|
933
|
-
const
|
|
934
|
-
|
|
935
|
-
(
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
examined++;
|
|
944
|
-
for (const [key, values] of Object.entries(t.enum ?? {})) {
|
|
945
|
-
const v = strip(row[key]);
|
|
946
|
-
if (v && !values.map(String).includes(v)) {
|
|
947
|
-
findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
|
|
948
|
-
}
|
|
949
|
-
}
|
|
950
|
-
for (const c2 of t.non_empty ?? []) {
|
|
951
|
-
if (!strip(row[c2])) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is empty` });
|
|
1087
|
+
const specs = [t, ...Object.values(t).filter((v) => v && typeof v === "object" && !Array.isArray(v) && v.table)];
|
|
1088
|
+
for (const t2 of specs) {
|
|
1089
|
+
const targets = t2.file ?? specs[0].file ? [t2.file ?? specs[0].file] : expand(files, t2.scan);
|
|
1090
|
+
for (const rel of targets) {
|
|
1091
|
+
const text = read(root, rel);
|
|
1092
|
+
if (!text) continue;
|
|
1093
|
+
for (const table of parseTables(text)) {
|
|
1094
|
+
const heading = sectionOf(text, table.headerLine).replace(/^#+\s*/, "").trim().toLowerCase();
|
|
1095
|
+
if (t2.table && !heading.startsWith(String(t2.table).toLowerCase())) continue;
|
|
1096
|
+
const cols = t2.required_cols ?? t2.table_columns ?? [];
|
|
1097
|
+
const present = cols.filter(
|
|
1098
|
+
(c2) => table.headers.some((h) => h.toLowerCase() === String(c2).toLowerCase())
|
|
1099
|
+
);
|
|
1100
|
+
if (cols.length && present.length < Math.max(2, Math.ceil(cols.length / 2))) continue;
|
|
1101
|
+
for (const c2 of cols) {
|
|
1102
|
+
if (!present.includes(c2)) findings.push({ file: rel, message: `register table missing column '${c2}'` });
|
|
952
1103
|
}
|
|
953
|
-
for (const
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
for (const
|
|
957
|
-
const v = strip(row[
|
|
958
|
-
if (
|
|
959
|
-
|
|
960
|
-
|
|
1104
|
+
for (const row of table.rows) {
|
|
1105
|
+
if (Object.values(row).every((v) => !v || v === "\u2014")) continue;
|
|
1106
|
+
examined++;
|
|
1107
|
+
for (const [key, values] of Object.entries(t2.enum ?? {})) {
|
|
1108
|
+
const v = strip(row[key]);
|
|
1109
|
+
if (v && !values.map(String).includes(v)) {
|
|
1110
|
+
findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
for (const c2 of t2.non_empty ?? []) {
|
|
1114
|
+
if (!strip(row[c2])) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is empty` });
|
|
1115
|
+
}
|
|
1116
|
+
for (const cond of t2.conditional ?? []) {
|
|
1117
|
+
const matches = Object.entries(cond.when ?? {}).every(([k, v]) => strip(row[k]) === String(v));
|
|
1118
|
+
if (!matches) continue;
|
|
1119
|
+
for (const c2 of cond.non_empty ?? []) {
|
|
1120
|
+
const v = strip(row[c2]);
|
|
1121
|
+
if (!v) findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' required when ${JSON.stringify(cond.when)}` });
|
|
1122
|
+
else if (cond.min_words?.[c2] && v.split(/\s+/).length < cond.min_words[c2]) {
|
|
1123
|
+
findings.push({ file: rel, message: `row ${firstCell(row)}: '${c2}' is too thin to be a reason` });
|
|
1124
|
+
}
|
|
961
1125
|
}
|
|
962
1126
|
}
|
|
963
1127
|
}
|
|
@@ -1073,6 +1237,16 @@ var crossReference = (t, root, files) => {
|
|
|
1073
1237
|
}
|
|
1074
1238
|
return { findings, examined: skills.length };
|
|
1075
1239
|
};
|
|
1240
|
+
function landedWork(root, branch, base) {
|
|
1241
|
+
const git2 = (cmd2) => execSync(`git ${cmd2}`, { cwd: root, stdio: "pipe" }).toString().trim();
|
|
1242
|
+
try {
|
|
1243
|
+
const tip = git2(`rev-parse ${branch}`);
|
|
1244
|
+
if (tip === git2(`rev-parse ${base}`)) return false;
|
|
1245
|
+
return git2(`log ${base} --merges --format=%P`).split("\n").some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
|
|
1246
|
+
} catch {
|
|
1247
|
+
return true;
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1076
1250
|
var gitStatusReconcile = (t, root, files) => {
|
|
1077
1251
|
const findings = [];
|
|
1078
1252
|
let merged;
|
|
@@ -1094,7 +1268,7 @@ var gitStatusReconcile = (t, root, files) => {
|
|
|
1094
1268
|
const status = text.match(new RegExp(`^${t.status_field ?? "status"}:\\s*(\\S+)`, "m"))?.[1];
|
|
1095
1269
|
if (!branch || !status) continue;
|
|
1096
1270
|
examined++;
|
|
1097
|
-
if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status)) {
|
|
1271
|
+
if (merged.has(branch) && (t.pre_review_statuses ?? []).includes(status) && landedWork(root, branch, t.integration_branch ?? "main")) {
|
|
1098
1272
|
findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
|
|
1099
1273
|
}
|
|
1100
1274
|
}
|
|
@@ -1106,8 +1280,10 @@ var computedClaim = (t, root, files) => {
|
|
|
1106
1280
|
let examined = 0;
|
|
1107
1281
|
for (const spec of specs) {
|
|
1108
1282
|
const values = /* @__PURE__ */ new Map();
|
|
1283
|
+
const excluded = (rel) => (spec.exclude ?? []).some((p) => matchAny([rel], p).length > 0);
|
|
1109
1284
|
for (const src of spec.sources ?? []) {
|
|
1110
1285
|
for (const rel of matchAny(files, src.file)) {
|
|
1286
|
+
if (excluded(rel)) continue;
|
|
1111
1287
|
const text = read(root, rel);
|
|
1112
1288
|
let v;
|
|
1113
1289
|
if (src.path && rel.endsWith(".json")) {
|
|
@@ -1126,8 +1302,9 @@ var computedClaim = (t, root, files) => {
|
|
|
1126
1302
|
}
|
|
1127
1303
|
const distinct = new Set(values.values());
|
|
1128
1304
|
if (spec.rule === "all-agree" && distinct.size > 1) {
|
|
1305
|
+
const where = [...values.entries()].map(([rel, v]) => `${rel}=${v}`).join(", ");
|
|
1129
1306
|
findings.push({
|
|
1130
|
-
message: `${spec.id} disagrees across ${values.size} locations: ${
|
|
1307
|
+
message: `${spec.id} disagrees across ${values.size} locations: ${where}` + (spec.autofix ? ` \u2014 run \`${spec.autofix}\`` : "") + (spec.exclude?.length ? "" : ". If one of these is versioned independently, list it in `exclude`.")
|
|
1131
1308
|
});
|
|
1132
1309
|
}
|
|
1133
1310
|
}
|
|
@@ -1137,16 +1314,54 @@ var computedClaim = (t, root, files) => {
|
|
|
1137
1314
|
// src/engines3.ts
|
|
1138
1315
|
import { readFileSync as readFileSync6 } from "node:fs";
|
|
1139
1316
|
import { execSync as execSync2 } from "node:child_process";
|
|
1140
|
-
import { join as
|
|
1317
|
+
import { join as join8 } from "node:path";
|
|
1141
1318
|
var read2 = (root, rel) => {
|
|
1142
1319
|
try {
|
|
1143
|
-
return readFileSync6(
|
|
1320
|
+
return readFileSync6(join8(root, rel), "utf8");
|
|
1144
1321
|
} catch {
|
|
1145
1322
|
return "";
|
|
1146
1323
|
}
|
|
1147
1324
|
};
|
|
1148
1325
|
var expand2 = (files, p, f = []) => [...new Set((p ?? f).flatMap((x) => matchAny(files, x)))];
|
|
1149
1326
|
var escapeRe2 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1327
|
+
function versionParts(s) {
|
|
1328
|
+
const m = /^(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
|
|
1329
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
1330
|
+
}
|
|
1331
|
+
function versionCmp(a, b) {
|
|
1332
|
+
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
|
|
1333
|
+
}
|
|
1334
|
+
var changelogFreshness = (t, root, files) => {
|
|
1335
|
+
const specs = Array.isArray(t) ? t : [t];
|
|
1336
|
+
const findings = [];
|
|
1337
|
+
let examined = 0;
|
|
1338
|
+
for (const spec of specs) {
|
|
1339
|
+
const src = spec.version ?? {};
|
|
1340
|
+
let current = null;
|
|
1341
|
+
for (const rel of matchAny(files, src.file ?? "package.json")) {
|
|
1342
|
+
try {
|
|
1343
|
+
const raw = (src.path ?? "version").split(".").reduce((o, k) => o?.[k], JSON.parse(read2(root, rel)));
|
|
1344
|
+
current = versionParts(String(raw ?? ""));
|
|
1345
|
+
} catch {
|
|
1346
|
+
}
|
|
1347
|
+
if (current) break;
|
|
1348
|
+
}
|
|
1349
|
+
if (!current) continue;
|
|
1350
|
+
for (const rel of expand2(files, spec.fragments, [])) {
|
|
1351
|
+
const name = rel.split("/").pop().replace(/\.md$/, "");
|
|
1352
|
+
const v = versionParts(name);
|
|
1353
|
+
if (!v) continue;
|
|
1354
|
+
examined++;
|
|
1355
|
+
if (versionCmp(v, current) < 0) {
|
|
1356
|
+
findings.push({
|
|
1357
|
+
file: rel,
|
|
1358
|
+
message: spec.message?.trim() || `fragment names ${name}, below the ${current.join(".")} being prepared \u2014 it was consumed by an earlier release and should have been deleted`
|
|
1359
|
+
});
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
return { findings, examined };
|
|
1364
|
+
};
|
|
1150
1365
|
var exempted2 = (text, marker) => !!marker && new RegExp(`${escapeRe2(marker)}\\s*\\S`).test(text);
|
|
1151
1366
|
function tableRows(text, near) {
|
|
1152
1367
|
const lines = text.split("\n");
|
|
@@ -1266,11 +1481,52 @@ var mergeDriverCheck = (t, root) => {
|
|
|
1266
1481
|
}
|
|
1267
1482
|
return { findings, examined: declared.length };
|
|
1268
1483
|
};
|
|
1484
|
+
var boardReconcile = (t, root, _files) => {
|
|
1485
|
+
const rel = t.file;
|
|
1486
|
+
const text = read2(root, rel);
|
|
1487
|
+
if (!text) return { findings: [{ message: `board not found at ${rel}` }], examined: 0 };
|
|
1488
|
+
if (exempted2(text, t.exempt_marker)) return { findings: [], examined: 0 };
|
|
1489
|
+
const groups = t.groups ?? {};
|
|
1490
|
+
const dir = rel.split("/").slice(0, -1).join("/");
|
|
1491
|
+
const findings = [];
|
|
1492
|
+
let heading = "";
|
|
1493
|
+
let examined = 0;
|
|
1494
|
+
for (const line of text.split("\n")) {
|
|
1495
|
+
const h = /^##\s+(.+?)\s*$/.exec(line);
|
|
1496
|
+
if (h) {
|
|
1497
|
+
heading = h[1];
|
|
1498
|
+
continue;
|
|
1499
|
+
}
|
|
1500
|
+
if (!line.startsWith("|")) continue;
|
|
1501
|
+
const link = /^\|\s*\[[^\]]+\]\(([^)]+)\)/.exec(line);
|
|
1502
|
+
if (!link) continue;
|
|
1503
|
+
if (!Object.hasOwn(groups, heading)) continue;
|
|
1504
|
+
examined++;
|
|
1505
|
+
const target = `${dir}/${link[1]}`.replace(/[^/]+\/\.\.\//g, "");
|
|
1506
|
+
const item = read2(root, target);
|
|
1507
|
+
if (!item) {
|
|
1508
|
+
findings.push({ file: rel, message: `row under '${heading}' links to a missing file: ${link[1]}` });
|
|
1509
|
+
continue;
|
|
1510
|
+
}
|
|
1511
|
+
const status = /^status:\s*(\S+)/m.exec(item)?.[1] ?? "";
|
|
1512
|
+
if (!groups[heading].includes(status)) {
|
|
1513
|
+
findings.push({
|
|
1514
|
+
file: rel,
|
|
1515
|
+
message: `${link[1]} is under '${heading}' but its status is '${status}' (expected ${groups[heading].join(" | ")})`
|
|
1516
|
+
});
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
const seen = new Set([...text.matchAll(/^##\s+(.+?)\s*$/gm)].map((m) => m[1]));
|
|
1520
|
+
for (const g of Object.keys(groups)) {
|
|
1521
|
+
if (!seen.has(g)) findings.push({ file: rel, message: `declared group '${g}' has no heading in the board` });
|
|
1522
|
+
}
|
|
1523
|
+
return { findings, examined };
|
|
1524
|
+
};
|
|
1269
1525
|
|
|
1270
1526
|
// src/engines.ts
|
|
1271
1527
|
var read3 = (root, rel) => {
|
|
1272
1528
|
try {
|
|
1273
|
-
return readFileSync7(
|
|
1529
|
+
return readFileSync7(join9(root, rel), "utf8");
|
|
1274
1530
|
} catch {
|
|
1275
1531
|
return "";
|
|
1276
1532
|
}
|
|
@@ -1289,10 +1545,12 @@ var fileBudget = (t, root, files) => {
|
|
|
1289
1545
|
const findings = [];
|
|
1290
1546
|
let examined = 0;
|
|
1291
1547
|
for (const rel of targets) {
|
|
1292
|
-
if (excluded.has(rel) || !existsSync6(
|
|
1548
|
+
if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
|
|
1293
1549
|
examined++;
|
|
1294
1550
|
const n = loadedLines(read3(root, rel));
|
|
1295
|
-
if (n > t.max_lines)
|
|
1551
|
+
if (n > t.max_lines) {
|
|
1552
|
+
findings.push({ file: rel, message: `${n} loaded lines (blank lines and comments excluded), budget ${t.max_lines}` });
|
|
1553
|
+
}
|
|
1296
1554
|
}
|
|
1297
1555
|
return { findings, examined };
|
|
1298
1556
|
};
|
|
@@ -1304,10 +1562,11 @@ var sections = (t, root, files) => {
|
|
|
1304
1562
|
const targets = dropGenerated(root, spec.file ? [spec.file] : expand3(files, spec.scan));
|
|
1305
1563
|
const excluded = new Set(expand3(files, spec.exclude, []));
|
|
1306
1564
|
for (const rel of targets) {
|
|
1307
|
-
if (excluded.has(rel) || !existsSync6(
|
|
1565
|
+
if (excluded.has(rel) || !existsSync6(join9(root, rel))) continue;
|
|
1308
1566
|
examined++;
|
|
1309
1567
|
const text = read3(root, rel);
|
|
1310
|
-
const
|
|
1568
|
+
const matches = [...text.matchAll(/^(#{1,6})\s+(.+?)\s*$/gm)];
|
|
1569
|
+
const heads = matches.map((m) => m[2]);
|
|
1311
1570
|
for (const want of spec.required ?? []) {
|
|
1312
1571
|
const idx = heads.findIndex((h) => h.toLowerCase().startsWith(String(want).toLowerCase()));
|
|
1313
1572
|
if (idx === -1) {
|
|
@@ -1315,8 +1574,9 @@ var sections = (t, root, files) => {
|
|
|
1315
1574
|
continue;
|
|
1316
1575
|
}
|
|
1317
1576
|
if (spec.non_empty) {
|
|
1318
|
-
const
|
|
1319
|
-
const
|
|
1577
|
+
const level = matches[idx][1].length;
|
|
1578
|
+
const after = text.slice(matches[idx].index + matches[idx][0].length);
|
|
1579
|
+
const body = after.split(new RegExp(`^#{1,${level}}\\s+`, "m"))[0].replace(/<!--[\s\S]*?-->/g, "").trim();
|
|
1320
1580
|
if (!body) findings.push({ file: rel, message: `section '${want}' is empty` });
|
|
1321
1581
|
}
|
|
1322
1582
|
}
|
|
@@ -1346,21 +1606,70 @@ var frontmatterSchema = (t, root, files) => {
|
|
|
1346
1606
|
if (!keys.includes(req)) findings.push({ file: rel, message: `missing '${req}'` });
|
|
1347
1607
|
}
|
|
1348
1608
|
if (spec.allowed) {
|
|
1609
|
+
const optedIn = spec.extensions_allowed_from ? optedInExtensions(rel, spec) : /* @__PURE__ */ new Set();
|
|
1349
1610
|
for (const k of keys) {
|
|
1350
|
-
if (
|
|
1611
|
+
if (spec.allowed.includes(k) || optedIn.has(k)) continue;
|
|
1612
|
+
findings.push({ file: rel, message: `non-spec key '${k}'` });
|
|
1351
1613
|
}
|
|
1352
1614
|
}
|
|
1615
|
+
const field2 = (k) => m[1].match(new RegExp(`^${k}:\\s*(.+)$`, "m"))?.[1].trim().replace(/^["']|["']$/g, "");
|
|
1353
1616
|
for (const [key, values] of Object.entries(spec.enum ?? {})) {
|
|
1354
|
-
const v =
|
|
1617
|
+
const v = field2(key);
|
|
1355
1618
|
if (v && !values.map(String).includes(v)) {
|
|
1356
1619
|
findings.push({ file: rel, message: `${key}='${v}' not one of ${values.join(", ")}` });
|
|
1357
1620
|
}
|
|
1358
1621
|
}
|
|
1622
|
+
for (const pair of spec.reciprocal?.pairs ?? []) {
|
|
1623
|
+
const from = field2(pair.from);
|
|
1624
|
+
if (!from) continue;
|
|
1625
|
+
const target = expand3(files, spec.scan).find((r) => r.includes(from.replace(/\.md$/, "")));
|
|
1626
|
+
if (!target) {
|
|
1627
|
+
findings.push({ file: rel, message: `${pair.from} names '${from}', which is not a record here` });
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
const back = read3(root, target).match(/^---\n([\s\S]*?)\n---/)?.[1] ?? "";
|
|
1631
|
+
const id = field2("id") ?? "";
|
|
1632
|
+
if (!new RegExp(`^${pair.to}:\\s*.*${escapeRe3(id)}`, "m").test(back)) {
|
|
1633
|
+
findings.push({ file: rel, message: `${pair.from} \u2192 ${from}, but it does not name this record back in '${pair.to}'` });
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
for (const [status, requires] of Object.entries(spec.reciprocal?.required_when ?? {})) {
|
|
1637
|
+
if (field2("status") === status && !field2(String(requires))) {
|
|
1638
|
+
findings.push({ file: rel, message: `status is '${status}' but '${requires}' is absent` });
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1359
1641
|
}
|
|
1360
1642
|
}
|
|
1361
1643
|
return { findings, examined };
|
|
1362
1644
|
};
|
|
1645
|
+
function resolvesHere(root, rel, href) {
|
|
1646
|
+
const from = dirname4(rel);
|
|
1647
|
+
const decoded = decodeURIComponent(href);
|
|
1648
|
+
if (existsSync6(resolve2(root, from, decoded))) return true;
|
|
1649
|
+
const stripped = decoded.replace(/:\d+(?::\d+)?$/, "");
|
|
1650
|
+
return stripped !== decoded && existsSync6(resolve2(root, from, stripped));
|
|
1651
|
+
}
|
|
1652
|
+
function backtickedPaths(rel, text, root, hints) {
|
|
1653
|
+
const out = [];
|
|
1654
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1655
|
+
for (const m of text.matchAll(/`([^`\n]+)`/g)) {
|
|
1656
|
+
const raw = m[1].trim();
|
|
1657
|
+
if (seen.has(raw) || !hints.some((h) => raw.includes(h))) continue;
|
|
1658
|
+
seen.add(raw);
|
|
1659
|
+
if (raw.startsWith("/")) continue;
|
|
1660
|
+
if (/[*?{}#\s]|^\w+:/.test(raw)) continue;
|
|
1661
|
+
if (!raw.includes("/")) continue;
|
|
1662
|
+
if (!/\.[a-z0-9]{1,5}$/i.test(raw)) continue;
|
|
1663
|
+
const bare = raw.replace(/^\.\//, "");
|
|
1664
|
+
if (!existsSync6(join9(root, bare)) && !existsSync6(resolve2(root, dirname4(rel), bare))) {
|
|
1665
|
+
out.push({ message: `stale path in a code span \u2192 ${raw}` });
|
|
1666
|
+
}
|
|
1667
|
+
}
|
|
1668
|
+
return out;
|
|
1669
|
+
}
|
|
1363
1670
|
var linkIntegrity = (t, root, files) => {
|
|
1671
|
+
if (Array.isArray(t)) t = t[0] ?? {};
|
|
1672
|
+
const checks = t.check ?? ["relative_markdown_links"];
|
|
1364
1673
|
const scan = expand3(files, t.scan, ["**/*.md"]);
|
|
1365
1674
|
const excluded = new Set(expand3(files, t.exclude, []));
|
|
1366
1675
|
const findings = [];
|
|
@@ -1370,11 +1679,14 @@ var linkIntegrity = (t, root, files) => {
|
|
|
1370
1679
|
const text = read3(root, rel);
|
|
1371
1680
|
examined++;
|
|
1372
1681
|
if (/path-ok:\s*\S/.test(text)) continue;
|
|
1682
|
+
if (checks.includes("backticked_paths")) {
|
|
1683
|
+
findings.push(...backtickedPaths(rel, text, root, t.path_hint ?? ["/"]).map((f) => ({ ...f, file: rel })));
|
|
1684
|
+
}
|
|
1685
|
+
if (!checks.includes("relative_markdown_links")) continue;
|
|
1373
1686
|
const scannable = text.replace(/`+[^`\n]*`+/g, (s) => " ".repeat(s.length));
|
|
1374
1687
|
for (const m of scannable.matchAll(/\]\((?!https?:|#|mailto:)([^)\s#]+)/g)) {
|
|
1375
1688
|
if (/\{\{[a-z_.]+\}\}/.test(m[1])) continue;
|
|
1376
|
-
|
|
1377
|
-
if (!existsSync6(target)) findings.push({ file: rel, message: `broken link \u2192 ${m[1]}` });
|
|
1689
|
+
if (!resolvesHere(root, rel, m[1])) findings.push({ file: rel, message: `broken link \u2192 ${m[1]}` });
|
|
1378
1690
|
}
|
|
1379
1691
|
}
|
|
1380
1692
|
return { findings, examined };
|
|
@@ -1392,13 +1704,17 @@ var filePopulation = (t, root, files) => {
|
|
|
1392
1704
|
const findings = [];
|
|
1393
1705
|
const failAt = t.fail_at ?? Infinity;
|
|
1394
1706
|
if (hits.length >= failAt) {
|
|
1395
|
-
|
|
1707
|
+
const scanned = [t.scan ?? []].flat();
|
|
1708
|
+
findings.push({
|
|
1709
|
+
message: `${hits.length} matching file(s), threshold ${failAt} (e.g. ${hits[0]})` + (scanned.length ? ` \u2014 matched against ${scanned.join(", ")}` : "")
|
|
1710
|
+
});
|
|
1396
1711
|
}
|
|
1397
1712
|
return { findings, examined: hits.length };
|
|
1398
1713
|
};
|
|
1399
1714
|
var gateMeta = (_t, root) => {
|
|
1400
1715
|
const findings = [];
|
|
1401
|
-
|
|
1716
|
+
let unrun = 0;
|
|
1717
|
+
const registry = join9(root, ".ai", "gates.toml");
|
|
1402
1718
|
if (!existsSync6(registry)) return { findings, examined: 0 };
|
|
1403
1719
|
const text = readFileSync7(registry, "utf8");
|
|
1404
1720
|
const entries = [...text.matchAll(/\[\[gates\]\][\s\S]*?(?=\n\[\[gates\]\]|\n# rungs:end|$)/g)].map((m) => m[0]);
|
|
@@ -1409,7 +1725,7 @@ var gateMeta = (_t, root) => {
|
|
|
1409
1725
|
const table = entry.match(/^table\s*=\s*"(.+)"/m)?.[1];
|
|
1410
1726
|
if (!id || kind !== "declared" || !table) continue;
|
|
1411
1727
|
examined++;
|
|
1412
|
-
const tablePath =
|
|
1728
|
+
const tablePath = join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules", dirname4(table), "gates", table.split("/").pop());
|
|
1413
1729
|
const src = existsSync6(tablePath) ? readFileSync7(tablePath, "utf8") : "";
|
|
1414
1730
|
const forGate = [...src.matchAll(/\[\[self_test\]\][\s\S]*?(?=\n\[\[|\n\[|$)/g)].map((m) => m[0]).filter((b) => b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`) || b.includes(`gate = "${id}"`));
|
|
1415
1731
|
for (const direction of ["pass", "fail"]) {
|
|
@@ -1417,10 +1733,61 @@ var gateMeta = (_t, root) => {
|
|
|
1417
1733
|
findings.push({ message: `gate '${id}' has no self-test expecting '${direction}'` });
|
|
1418
1734
|
}
|
|
1419
1735
|
}
|
|
1736
|
+
const engine = entry.match(/^engine\s*=\s*"(.+)"/m)?.[1];
|
|
1737
|
+
const parsed = parseTable(tablePath, table.split("/")[0]);
|
|
1738
|
+
if (engine && parsed) {
|
|
1739
|
+
const blocks = (Array.isArray(parsed.self_test) ? parsed.self_test : []).filter((b) => b?.gate === id).map((b) => ({ expect: String(b.expect), input: b.input, fixture: b.fixture }));
|
|
1740
|
+
for (const r of runSelfTests(id, engine, parsed[tableKeyFor(engine)] ?? parsed, blocks)) {
|
|
1741
|
+
if (r.outcome === "mismatch") findings.push({ message: `self-test for '${id}' ${r.detail}` });
|
|
1742
|
+
else if (r.outcome === "unrun") unrun++;
|
|
1743
|
+
}
|
|
1744
|
+
}
|
|
1420
1745
|
}
|
|
1746
|
+
if (unrun) console.error(` ${unrun} self-test fixture(s) have no builder and did not run \u2014 not passes (F-018)`);
|
|
1421
1747
|
return { findings, examined };
|
|
1422
1748
|
};
|
|
1749
|
+
function optedInExtensions(rel, spec) {
|
|
1750
|
+
if (Array.isArray(spec.extensions_opted_in)) return new Set(spec.extensions_opted_in.map(String));
|
|
1751
|
+
const name = rel.split("/").slice(-2)[0];
|
|
1752
|
+
if (!name) return /* @__PURE__ */ new Set();
|
|
1753
|
+
try {
|
|
1754
|
+
const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
|
|
1755
|
+
const owner = mods.find((m) => m.skills?.[name]?.extensions);
|
|
1756
|
+
return new Set(Object.keys(owner?.skills?.[name]?.extensions ?? {}));
|
|
1757
|
+
} catch {
|
|
1758
|
+
return /* @__PURE__ */ new Set();
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1423
1761
|
var escapeRe3 = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1762
|
+
function parseTable(path, module) {
|
|
1763
|
+
if (!existsSync6(path)) return null;
|
|
1764
|
+
try {
|
|
1765
|
+
const mods = loadAllModules(join9(dirname4(new URL(import.meta.url).pathname.slice(1)), "..", "modules"));
|
|
1766
|
+
const params = resolveParams(mods, {}, ".");
|
|
1767
|
+
return parseToml(substitute(readFileSync7(path, "utf8"), module, params));
|
|
1768
|
+
} catch {
|
|
1769
|
+
return null;
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
var tableKeyFor = (engine) => ({
|
|
1773
|
+
"file-budget": "file_budget",
|
|
1774
|
+
"frontmatter-schema": "frontmatter_schema",
|
|
1775
|
+
"link-integrity": "link_integrity",
|
|
1776
|
+
"file-population": "file_population",
|
|
1777
|
+
"render-freshness": "render_freshness",
|
|
1778
|
+
"register-schema": "register_schema",
|
|
1779
|
+
"self-declared-closure": "self_declared_closure",
|
|
1780
|
+
"filename-schema": "filename_schema",
|
|
1781
|
+
"cross-reference": "cross_reference",
|
|
1782
|
+
"git-status-reconcile": "merged_status",
|
|
1783
|
+
"computed-claim": "computed_claim",
|
|
1784
|
+
"term-ownership": "term_ownership",
|
|
1785
|
+
"rule-propagation": "rule_propagation",
|
|
1786
|
+
"git-state": "git_state",
|
|
1787
|
+
"merge-driver-check": "merge_driver_check",
|
|
1788
|
+
"board-reconcile": "board_reconcile",
|
|
1789
|
+
"changelog-freshness": "changelog_freshness"
|
|
1790
|
+
})[engine] ?? engine;
|
|
1424
1791
|
var ENGINES = {
|
|
1425
1792
|
"file-budget": fileBudget,
|
|
1426
1793
|
sections,
|
|
@@ -1439,27 +1806,50 @@ var ENGINES = {
|
|
|
1439
1806
|
"term-ownership": termOwnership,
|
|
1440
1807
|
"rule-propagation": rulePropagation,
|
|
1441
1808
|
"git-state": gitState,
|
|
1442
|
-
"merge-driver-check": mergeDriverCheck
|
|
1809
|
+
"merge-driver-check": mergeDriverCheck,
|
|
1810
|
+
"board-reconcile": boardReconcile,
|
|
1811
|
+
"changelog-freshness": changelogFreshness
|
|
1443
1812
|
};
|
|
1444
1813
|
function isImplemented(engine) {
|
|
1445
1814
|
return engine in ENGINES;
|
|
1446
1815
|
}
|
|
1447
1816
|
|
|
1448
1817
|
// src/check.ts
|
|
1449
|
-
var MODULES =
|
|
1818
|
+
var MODULES = join10(dirname5(fileURLToPath(import.meta.url)), "..", "modules");
|
|
1450
1819
|
function loadRegistry(repoRoot) {
|
|
1451
|
-
const path =
|
|
1820
|
+
const path = join10(repoRoot, ".ai", "gates.toml");
|
|
1452
1821
|
if (!existsSync7(path)) return { runner: {}, gates: [] };
|
|
1453
1822
|
const raw = parse2(readFileSync8(path, "utf8"));
|
|
1454
1823
|
return { runner: raw.runner ?? {}, gates: raw.gates ?? [] };
|
|
1455
1824
|
}
|
|
1456
|
-
function
|
|
1457
|
-
|
|
1825
|
+
function tierSelects(runnerTiers, requested, gateTier) {
|
|
1826
|
+
if (!gateTier) return true;
|
|
1827
|
+
const at = runnerTiers.indexOf(requested);
|
|
1828
|
+
const of = runnerTiers.indexOf(gateTier);
|
|
1829
|
+
if (at < 0 || of < 0) return gateTier === requested;
|
|
1830
|
+
return of <= at;
|
|
1831
|
+
}
|
|
1832
|
+
var UnknownTierError = class extends Error {
|
|
1833
|
+
requested;
|
|
1834
|
+
declared;
|
|
1835
|
+
constructor(requested, declared) {
|
|
1836
|
+
super(`unknown tier "${requested}"`);
|
|
1837
|
+
this.requested = requested;
|
|
1838
|
+
this.declared = declared;
|
|
1839
|
+
}
|
|
1840
|
+
};
|
|
1841
|
+
function runGates(repoRoot, tier, now = () => Date.now(), only) {
|
|
1842
|
+
const { runner, gates } = loadRegistry(repoRoot);
|
|
1843
|
+
const runnerTiers = Array.isArray(runner?.tiers) ? runner.tiers : [];
|
|
1844
|
+
if (tier && runnerTiers.length && !runnerTiers.includes(tier)) {
|
|
1845
|
+
throw new UnknownTierError(tier, runnerTiers);
|
|
1846
|
+
}
|
|
1458
1847
|
const files = walk(repoRoot);
|
|
1459
1848
|
const runs = [];
|
|
1460
1849
|
for (const g of gates) {
|
|
1461
1850
|
if (g.trigger) continue;
|
|
1462
|
-
if (
|
|
1851
|
+
if (only && !only.has(g.id)) continue;
|
|
1852
|
+
if (tier && !tierSelects(runnerTiers, tier, g.tier)) continue;
|
|
1463
1853
|
const started = now();
|
|
1464
1854
|
let status = "pass";
|
|
1465
1855
|
let findings = [];
|
|
@@ -1515,7 +1905,7 @@ function runGates(repoRoot, tier, now = () => Date.now()) {
|
|
|
1515
1905
|
function loadTable(ref, repoRoot) {
|
|
1516
1906
|
if (!ref) return null;
|
|
1517
1907
|
const [mod, file] = ref.split("/");
|
|
1518
|
-
const path =
|
|
1908
|
+
const path = join10(MODULES, mod, "gates", file);
|
|
1519
1909
|
if (!existsSync7(path)) return null;
|
|
1520
1910
|
try {
|
|
1521
1911
|
return parse2(substitute(readFileSync8(path, "utf8"), mod, installedParams(repoRoot)));
|
|
@@ -1527,7 +1917,7 @@ var paramCache = null;
|
|
|
1527
1917
|
function installedParams(repoRoot) {
|
|
1528
1918
|
if (paramCache?.root === repoRoot) return paramCache.params;
|
|
1529
1919
|
const defaults = resolveParams(loadAllModules(MODULES), {}, repoRoot);
|
|
1530
|
-
const recordPath =
|
|
1920
|
+
const recordPath = join10(repoRoot, ".ai", "rungs.toml");
|
|
1531
1921
|
if (existsSync7(recordPath)) {
|
|
1532
1922
|
try {
|
|
1533
1923
|
const rec = parse2(readFileSync8(recordPath, "utf8"));
|
|
@@ -1558,17 +1948,18 @@ var tableKey = (engine) => ({
|
|
|
1558
1948
|
"term-ownership": "term_ownership",
|
|
1559
1949
|
"rule-propagation": "rule_propagation",
|
|
1560
1950
|
"git-state": "git_state",
|
|
1561
|
-
"merge-driver-check": "merge_driver_check"
|
|
1951
|
+
"merge-driver-check": "merge_driver_check",
|
|
1952
|
+
"board-reconcile": "board_reconcile"
|
|
1562
1953
|
})[engine] ?? engine;
|
|
1563
1954
|
function appendLedger(repoRoot, runs, stamp) {
|
|
1564
1955
|
const { runner } = loadRegistry(repoRoot);
|
|
1565
1956
|
if (runner.ledger === false) return;
|
|
1566
|
-
const path =
|
|
1957
|
+
const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
|
|
1567
1958
|
const lines = runs.map((r) => JSON.stringify({ at: stamp, id: r.id, status: r.status, ms: r.ms, examined: r.examined })).join("\n");
|
|
1568
1959
|
appendFileSync(path, lines + "\n");
|
|
1569
1960
|
}
|
|
1570
1961
|
function ledgerQuestions(repoRoot, gates) {
|
|
1571
|
-
const path =
|
|
1962
|
+
const path = join10(repoRoot, ".ai", ".gate-ledger.jsonl");
|
|
1572
1963
|
if (!existsSync7(path)) return { neverFired: [], alwaysFires: [], runs: 0 };
|
|
1573
1964
|
const rows = readFileSync8(path, "utf8").split("\n").filter(Boolean).map((l) => JSON.parse(l));
|
|
1574
1965
|
const by = /* @__PURE__ */ new Map();
|
|
@@ -1585,12 +1976,12 @@ function ledgerQuestions(repoRoot, gates) {
|
|
|
1585
1976
|
}
|
|
1586
1977
|
|
|
1587
1978
|
// src/lifecycle.ts
|
|
1588
|
-
import { copyFileSync, existsSync as existsSync8, mkdirSync as
|
|
1589
|
-
import { dirname as
|
|
1979
|
+
import { copyFileSync, existsSync as existsSync8, mkdirSync as mkdirSync4, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1980
|
+
import { dirname as dirname6, join as join11 } from "node:path";
|
|
1590
1981
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1591
1982
|
import { execSync as execSync4 } from "node:child_process";
|
|
1592
1983
|
import { parse as parse3 } from "smol-toml";
|
|
1593
|
-
var SRC =
|
|
1984
|
+
var SRC = dirname6(fileURLToPath2(import.meta.url));
|
|
1594
1985
|
var PROFILES = {
|
|
1595
1986
|
minimal: ["instructions"],
|
|
1596
1987
|
tracked: ["instructions", "gates", "backlog", "findings", "adr", "session"],
|
|
@@ -1599,7 +1990,7 @@ var PROFILES = {
|
|
|
1599
1990
|
fleet: ["instructions", "gates", "backlog", "findings", "adr", "session", "ci", "specs", "workflows", "skills", "audit", "release", "doc-authority", "concurrency", "design-sync"]
|
|
1600
1991
|
};
|
|
1601
1992
|
function readRecord(repoRoot) {
|
|
1602
|
-
const p =
|
|
1993
|
+
const p = join11(repoRoot, ".ai", "rungs.toml");
|
|
1603
1994
|
if (!existsSync8(p)) return null;
|
|
1604
1995
|
try {
|
|
1605
1996
|
const raw = parse3(readFileSync9(p, "utf8"));
|
|
@@ -1620,7 +2011,7 @@ function planUpgrade(repoRoot, mods, record) {
|
|
|
1620
2011
|
const kept = new Set(installed.kept?.files ?? []);
|
|
1621
2012
|
for (const [rel, wouldEmit] of emitted) {
|
|
1622
2013
|
if (kept.has(rel)) continue;
|
|
1623
|
-
const full =
|
|
2014
|
+
const full = join11(repoRoot, rel);
|
|
1624
2015
|
if (!existsSync8(full)) {
|
|
1625
2016
|
files.push({ rel, state: "missing" });
|
|
1626
2017
|
continue;
|
|
@@ -1639,18 +2030,63 @@ function applyUpgrade(repoRoot, mods, record, plan) {
|
|
|
1639
2030
|
const params = resolveParams(mods, paramsFrom(record), repoRoot);
|
|
1640
2031
|
const skillsDir = record.harnesses.includes("claude") ? ".claude/skills" : ".agents/skills";
|
|
1641
2032
|
let written = 0;
|
|
2033
|
+
const rewritten = /* @__PURE__ */ new Map();
|
|
1642
2034
|
for (const item of plan) {
|
|
1643
2035
|
const mod = mods.find((m) => m.name === item.module);
|
|
1644
2036
|
const emitted = emittedFiles(mod, params, skillsDir);
|
|
1645
2037
|
for (const f of item.files) {
|
|
1646
2038
|
if (f.state !== "stale" && f.state !== "missing") continue;
|
|
1647
|
-
const full =
|
|
1648
|
-
|
|
1649
|
-
|
|
2039
|
+
const full = join11(repoRoot, f.rel);
|
|
2040
|
+
const content = emitted.get(f.rel);
|
|
2041
|
+
mkdirSync4(dirname6(full), { recursive: true });
|
|
2042
|
+
writeFileSync4(full, content);
|
|
2043
|
+
if (!rewritten.has(mod.name)) rewritten.set(mod.name, /* @__PURE__ */ new Map());
|
|
2044
|
+
rewritten.get(mod.name).set(f.rel, contentHash(content));
|
|
1650
2045
|
written++;
|
|
1651
2046
|
}
|
|
1652
2047
|
}
|
|
1653
|
-
|
|
2048
|
+
const upgraded = plan.map((p) => mods.find((m) => m.name === p.module)).filter(Boolean);
|
|
2049
|
+
const gateActions = upgraded.length ? registerGates(upgraded, repoRoot, false) : [];
|
|
2050
|
+
const recorded = updateRecordAfterUpgrade(
|
|
2051
|
+
repoRoot,
|
|
2052
|
+
upgraded.map((m) => ({ module: m.name, version: m.version, hashes: rewritten.get(m.name) ?? /* @__PURE__ */ new Map() }))
|
|
2053
|
+
);
|
|
2054
|
+
return { written, gates: gateActions.length, recorded };
|
|
2055
|
+
}
|
|
2056
|
+
function updateRecordAfterUpgrade(repoRoot, updates) {
|
|
2057
|
+
const path = join11(repoRoot, ".ai", "rungs.toml");
|
|
2058
|
+
if (!existsSync8(path) || !updates.length) return 0;
|
|
2059
|
+
const lines = readFileSync9(path, "utf8").split("\n");
|
|
2060
|
+
const byModule = new Map(updates.map((u) => [u.module, u]));
|
|
2061
|
+
let changed = 0;
|
|
2062
|
+
let current = null;
|
|
2063
|
+
const out = [];
|
|
2064
|
+
for (const line of lines) {
|
|
2065
|
+
const header = /^\[modules\.([^\].]+)(\.[^\]]+)?\]/.exec(line);
|
|
2066
|
+
if (header) {
|
|
2067
|
+
current = byModule.has(header[1]) ? { module: header[1], hashes: header[2] === ".hashes" } : null;
|
|
2068
|
+
out.push(line);
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
if (current && !current.hashes && /^version\s*=/.test(line)) {
|
|
2072
|
+
const next = `version = "${byModule.get(current.module).version}"`;
|
|
2073
|
+
if (next !== line) changed++;
|
|
2074
|
+
out.push(next);
|
|
2075
|
+
continue;
|
|
2076
|
+
}
|
|
2077
|
+
if (current?.hashes) {
|
|
2078
|
+
const entry = /^"([^"]+)"\s*=/.exec(line);
|
|
2079
|
+
const replacement = entry && byModule.get(current.module).hashes.get(entry[1]);
|
|
2080
|
+
if (replacement) {
|
|
2081
|
+
out.push(`"${entry[1]}" = "${replacement}"`);
|
|
2082
|
+
changed++;
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
out.push(line);
|
|
2087
|
+
}
|
|
2088
|
+
writeFileSync4(path, out.join("\n"));
|
|
2089
|
+
return changed;
|
|
1654
2090
|
}
|
|
1655
2091
|
function paramsFrom(record) {
|
|
1656
2092
|
const out = {};
|
|
@@ -1660,7 +2096,7 @@ function paramsFrom(record) {
|
|
|
1660
2096
|
return out;
|
|
1661
2097
|
}
|
|
1662
2098
|
function eject(repoRoot, mods, dryRun = false) {
|
|
1663
|
-
const dest =
|
|
2099
|
+
const dest = join11(repoRoot, ".rungs");
|
|
1664
2100
|
const engines = ["glob.ts", "engines.ts", "engines2.ts"];
|
|
1665
2101
|
const { gates } = loadRegistry(repoRoot);
|
|
1666
2102
|
const declared = gates.filter((g) => g.kind === "declared" && g.table);
|
|
@@ -1670,23 +2106,23 @@ function eject(repoRoot, mods, dryRun = false) {
|
|
|
1670
2106
|
for (const t of tables) actions.push(`.rungs/tables/${t.replace("/", "-").replace(/.toml$/, ".json")}`);
|
|
1671
2107
|
actions.push(".rungs/run-gate.mjs", ".ai/gates.toml (rewritten to command gates)");
|
|
1672
2108
|
if (dryRun) return { actions, gates: declared.length };
|
|
1673
|
-
|
|
1674
|
-
for (const f of engines) copyFileSync(
|
|
2109
|
+
mkdirSync4(join11(dest, "tables"), { recursive: true });
|
|
2110
|
+
for (const f of engines) copyFileSync(join11(SRC, f), join11(dest, f));
|
|
1675
2111
|
const record = readRecord(repoRoot);
|
|
1676
2112
|
const params = resolveParams(mods, record ? paramsFrom(record) : {}, repoRoot);
|
|
1677
2113
|
for (const t of tables) {
|
|
1678
2114
|
const [mod, file] = t.split("/");
|
|
1679
|
-
const src =
|
|
2115
|
+
const src = join11(SRC, "..", "modules", mod, "gates", file);
|
|
1680
2116
|
if (!existsSync8(src)) continue;
|
|
1681
2117
|
try {
|
|
1682
2118
|
const parsed = parse3(substitute(readFileSync9(src, "utf8"), mod, params));
|
|
1683
|
-
|
|
2119
|
+
writeFileSync4(join11(dest, "tables", `${mod}-${file.replace(/\.toml$/, ".json")}`), JSON.stringify(parsed, null, 2));
|
|
1684
2120
|
} catch {
|
|
1685
2121
|
}
|
|
1686
2122
|
}
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
const registry =
|
|
2123
|
+
writeFileSync4(join11(dest, "run-gate.mjs"), RUNNER);
|
|
2124
|
+
writeFileSync4(join11(dest, "README.md"), EJECT_README);
|
|
2125
|
+
const registry = join11(repoRoot, ".ai", "gates.toml");
|
|
1690
2126
|
let text = readFileSync9(registry, "utf8");
|
|
1691
2127
|
for (const g of declared) {
|
|
1692
2128
|
text = text.replace(
|
|
@@ -1695,7 +2131,7 @@ function eject(repoRoot, mods, dryRun = false) {
|
|
|
1695
2131
|
command = "node .rungs/run-gate.mjs ${g.id}"`
|
|
1696
2132
|
);
|
|
1697
2133
|
}
|
|
1698
|
-
|
|
2134
|
+
writeFileSync4(registry, `${text}
|
|
1699
2135
|
# Ejected: gates above run from .rungs/ and no longer need rungs installed.
|
|
1700
2136
|
`);
|
|
1701
2137
|
return { actions, gates: declared.length };
|
|
@@ -1749,7 +2185,7 @@ itself to whoever finds it.
|
|
|
1749
2185
|
To go back, delete this directory and re-run \`rungs add\`.
|
|
1750
2186
|
`;
|
|
1751
2187
|
function setupGit(repoRoot, dryRun = false) {
|
|
1752
|
-
const attrs =
|
|
2188
|
+
const attrs = join11(repoRoot, ".gitattributes");
|
|
1753
2189
|
if (!existsSync8(attrs)) return { drivers: [], rerere: false };
|
|
1754
2190
|
const drivers = [...new Set([...readFileSync9(attrs, "utf8").matchAll(/merge=(rungs-[\w-]+)/g)].map((m) => m[1]))];
|
|
1755
2191
|
const done = [];
|
|
@@ -1777,9 +2213,433 @@ function setupGit(repoRoot, dryRun = false) {
|
|
|
1777
2213
|
return { drivers: done, rerere };
|
|
1778
2214
|
}
|
|
1779
2215
|
|
|
2216
|
+
// src/explain.ts
|
|
2217
|
+
var isRunnable = (g) => g.kind !== "command" && !g.trigger && !!g.engine;
|
|
2218
|
+
var IN_SCOPE = /* @__PURE__ */ new Set(["theirs", "ours-current", "ours-diverged"]);
|
|
2219
|
+
var FOREIGN_SAFE = /* @__PURE__ */ new Set(["repo-content"]);
|
|
2220
|
+
function explain(mods, results, repoRoot, files) {
|
|
2221
|
+
return explainWith(ENGINES, mods, results, repoRoot, files);
|
|
2222
|
+
}
|
|
2223
|
+
function explainWith(engines, mods, results, repoRoot, files) {
|
|
2224
|
+
const inScope = results.filter((r) => IN_SCOPE.has(r.state));
|
|
2225
|
+
const scope = inScope.map((r) => r.module);
|
|
2226
|
+
const stateOf = new Map(inScope.map((r) => [r.module, r.state]));
|
|
2227
|
+
const reported = [];
|
|
2228
|
+
const skipped = { command: 0, unimplemented: [], undeclared: [], errored: [] };
|
|
2229
|
+
for (const name of scope) {
|
|
2230
|
+
const mod = mods.find((m) => m.name === name);
|
|
2231
|
+
if (!mod) continue;
|
|
2232
|
+
const isOurs = stateOf.get(name) !== "theirs";
|
|
2233
|
+
for (const g of mod.gates) {
|
|
2234
|
+
if (!isRunnable(g)) {
|
|
2235
|
+
if (g.kind === "command") skipped.command++;
|
|
2236
|
+
continue;
|
|
2237
|
+
}
|
|
2238
|
+
if (!isOurs) {
|
|
2239
|
+
if (!g.applicability) {
|
|
2240
|
+
skipped.undeclared.push(g.id);
|
|
2241
|
+
continue;
|
|
2242
|
+
}
|
|
2243
|
+
if (!FOREIGN_SAFE.has(g.applicability)) continue;
|
|
2244
|
+
}
|
|
2245
|
+
if (!(g.engine in engines)) {
|
|
2246
|
+
skipped.unimplemented.push(g.id);
|
|
2247
|
+
continue;
|
|
2248
|
+
}
|
|
2249
|
+
const table = loadTable(g.table ? `${mod.name}/${g.table.replace(/^gates\//, "")}` : void 0, repoRoot);
|
|
2250
|
+
if (!table) {
|
|
2251
|
+
skipped.errored.push({ gate: g.id, message: `table '${g.table ?? "(none)"}' not found` });
|
|
2252
|
+
continue;
|
|
2253
|
+
}
|
|
2254
|
+
try {
|
|
2255
|
+
const key = tableKey(g.engine);
|
|
2256
|
+
let section = table[key] ?? table;
|
|
2257
|
+
if (Array.isArray(section) && section.some((s) => s?.id)) {
|
|
2258
|
+
const mine = section.filter((s) => !s.id || g.id.includes(s.id));
|
|
2259
|
+
if (mine.length) section = mine;
|
|
2260
|
+
}
|
|
2261
|
+
const r = engines[g.engine](section, repoRoot, files);
|
|
2262
|
+
if (r.findings.length) {
|
|
2263
|
+
reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });
|
|
2264
|
+
}
|
|
2265
|
+
} catch (e) {
|
|
2266
|
+
skipped.errored.push({ gate: g.id, message: e.message });
|
|
2267
|
+
}
|
|
2268
|
+
}
|
|
2269
|
+
}
|
|
2270
|
+
return { reported: collapseDuplicates(reported), skipped, scope };
|
|
2271
|
+
}
|
|
2272
|
+
function collapseDuplicates(reported) {
|
|
2273
|
+
const out = [];
|
|
2274
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2275
|
+
for (const r of reported) {
|
|
2276
|
+
const key = `${r.module} ${r.findings.map((f) => `${f.file ?? ""}|${f.message}`).join("")}`;
|
|
2277
|
+
const prior = seen.get(key);
|
|
2278
|
+
if (prior) {
|
|
2279
|
+
prior.gate = `${prior.gate} + ${r.gate}`;
|
|
2280
|
+
continue;
|
|
2281
|
+
}
|
|
2282
|
+
seen.set(key, r);
|
|
2283
|
+
out.push(r);
|
|
2284
|
+
}
|
|
2285
|
+
return out;
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
// src/backlog.ts
|
|
2289
|
+
import { existsSync as existsSync9, mkdirSync as mkdirSync5, readFileSync as readFileSync10, renameSync, writeFileSync as writeFileSync5 } from "node:fs";
|
|
2290
|
+
import { dirname as dirname7, join as join12, relative as relative2, resolve as resolve3, sep as sep2 } from "node:path";
|
|
2291
|
+
var FINISHED = /* @__PURE__ */ new Set(["done", "rejected"]);
|
|
2292
|
+
var field = (text, name) => text.match(new RegExp(`^${name}:\\s*(\\S+)`, "m"))?.[1] ?? "";
|
|
2293
|
+
var posix = (p) => p.split(sep2).join("/");
|
|
2294
|
+
var LINK = /\]\((?!https?:|#|mailto:)([^)\s#]+)((?:#[^)\s]*)?)\)/g;
|
|
2295
|
+
function planArchive(repoRoot, backlogRoot = "docs/backlog") {
|
|
2296
|
+
const itemsDir = join12(repoRoot, ...backlogRoot.split("/"), "items");
|
|
2297
|
+
const archiveDir = join12(repoRoot, ...backlogRoot.split("/"), "archive");
|
|
2298
|
+
const moves = [];
|
|
2299
|
+
const held = [];
|
|
2300
|
+
const files = walk(repoRoot);
|
|
2301
|
+
const items = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, itemsDir)) + "/") && f.endsWith(".md"));
|
|
2302
|
+
for (const rel of items) {
|
|
2303
|
+
const base = posix(rel).split("/").pop();
|
|
2304
|
+
if (/^(README|TEMPLATE)\.md$/i.test(base)) continue;
|
|
2305
|
+
const text = readFileSync10(join12(repoRoot, rel), "utf8");
|
|
2306
|
+
const status = field(text, "status");
|
|
2307
|
+
const id = field(text, "id");
|
|
2308
|
+
if (!FINISHED.has(status)) continue;
|
|
2309
|
+
if (field(text, "type") === "epic") {
|
|
2310
|
+
const children = (text.match(/^children:\s*\[(.*)\]/m)?.[1] ?? "").split(",").map((s) => s.trim()).filter(Boolean);
|
|
2311
|
+
const archived = files.filter((f) => posix(f).startsWith(posix(relative2(repoRoot, archiveDir)) + "/") && f.endsWith(".md"));
|
|
2312
|
+
const unfinished = children.filter((c2) => {
|
|
2313
|
+
const f = items.find((i) => i.includes(`${c2}-`)) ?? archived.find((i) => i.includes(`${c2}-`));
|
|
2314
|
+
return !f || !FINISHED.has(field(readFileSync10(join12(repoRoot, f), "utf8"), "status"));
|
|
2315
|
+
});
|
|
2316
|
+
if (unfinished.length) {
|
|
2317
|
+
held.push({ file: rel, reason: `epic with unfinished children: ${unfinished.join(", ")}` });
|
|
2318
|
+
continue;
|
|
2319
|
+
}
|
|
2320
|
+
}
|
|
2321
|
+
moves.push({
|
|
2322
|
+
id,
|
|
2323
|
+
status,
|
|
2324
|
+
from: rel,
|
|
2325
|
+
to: posix(join12(relative2(repoRoot, archiveDir), posix(rel).split("/").pop()))
|
|
2326
|
+
});
|
|
2327
|
+
}
|
|
2328
|
+
const moved = new Map(moves.map((m) => [resolve3(repoRoot, m.from), m.to]));
|
|
2329
|
+
const rewrites = [];
|
|
2330
|
+
for (const rel of files) {
|
|
2331
|
+
if (!isRewritable(rel)) continue;
|
|
2332
|
+
const links = retargets(repoRoot, rel, moved).length;
|
|
2333
|
+
if (links || moved.has(resolve3(repoRoot, rel))) rewrites.push({ file: rel, links });
|
|
2334
|
+
}
|
|
2335
|
+
return { root: backlogRoot, moves, rewrites, held };
|
|
2336
|
+
}
|
|
2337
|
+
function isRewritable(rel) {
|
|
2338
|
+
const p = posix(rel);
|
|
2339
|
+
if (!p.endsWith(".md")) return false;
|
|
2340
|
+
return !/^modules\/[^/]+\/(files|fragments)\//.test(p) && !p.startsWith("node_modules/");
|
|
2341
|
+
}
|
|
2342
|
+
function retargets(repoRoot, rel, moved) {
|
|
2343
|
+
const oldDir = dirname7(resolve3(repoRoot, rel));
|
|
2344
|
+
const selfMoved = moved.get(resolve3(repoRoot, rel));
|
|
2345
|
+
const newDir = dirname7(resolve3(repoRoot, selfMoved ?? rel));
|
|
2346
|
+
const out = [];
|
|
2347
|
+
for (const m of readFileSync10(join12(repoRoot, rel), "utf8").matchAll(LINK)) {
|
|
2348
|
+
const href = m[1];
|
|
2349
|
+
if (href.includes("{{")) continue;
|
|
2350
|
+
const target = resolve3(oldDir, decodeURIComponent(href));
|
|
2351
|
+
const targetMoved = moved.get(target);
|
|
2352
|
+
if (!targetMoved && !selfMoved) continue;
|
|
2353
|
+
if (!targetMoved && !existsSync9(target)) continue;
|
|
2354
|
+
const targetNew = targetMoved ? resolve3(repoRoot, targetMoved) : target;
|
|
2355
|
+
const to = posix(relative2(newDir, targetNew));
|
|
2356
|
+
if (to !== posix(href)) out.push({ href, to });
|
|
2357
|
+
}
|
|
2358
|
+
return out;
|
|
2359
|
+
}
|
|
2360
|
+
function applyArchive(repoRoot, plan) {
|
|
2361
|
+
const moved = new Map(plan.moves.map((m) => [resolve3(repoRoot, m.from), m.to]));
|
|
2362
|
+
for (const rel of walk(repoRoot)) {
|
|
2363
|
+
if (!isRewritable(rel)) continue;
|
|
2364
|
+
const edits = retargets(repoRoot, rel, moved);
|
|
2365
|
+
if (!edits.length) continue;
|
|
2366
|
+
const path = join12(repoRoot, rel);
|
|
2367
|
+
let text = readFileSync10(path, "utf8");
|
|
2368
|
+
text = text.replace(LINK, (whole, href, anchor) => {
|
|
2369
|
+
const edit = edits.find((e) => e.href === href);
|
|
2370
|
+
return edit ? `](${edit.to}${anchor})` : whole;
|
|
2371
|
+
});
|
|
2372
|
+
writeFileSync5(path, text);
|
|
2373
|
+
}
|
|
2374
|
+
for (const m of plan.moves) {
|
|
2375
|
+
const to = join12(repoRoot, ...m.to.split("/"));
|
|
2376
|
+
mkdirSync5(dirname7(to), { recursive: true });
|
|
2377
|
+
renameSync(join12(repoRoot, m.from), to);
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
|
|
2381
|
+
// src/concurrency.ts
|
|
2382
|
+
import { existsSync as existsSync10, mkdtempSync as mkdtempSync2, readFileSync as readFileSync11, rmSync as rmSync2, writeFileSync as writeFileSync6, unlinkSync } from "node:fs";
|
|
2383
|
+
import { execFileSync } from "node:child_process";
|
|
2384
|
+
import { hostname } from "node:os";
|
|
2385
|
+
import { join as join13, resolve as resolve4, dirname as dirname8, basename as basename2 } from "node:path";
|
|
2386
|
+
import { tmpdir as tmpdir2 } from "node:os";
|
|
2387
|
+
function loopParams(root) {
|
|
2388
|
+
const p = installedParams(root).concurrency ?? {};
|
|
2389
|
+
const integration = String(p.integration_branch ?? "main");
|
|
2390
|
+
const greenPrefix = String(p.green_prefix ?? "green/");
|
|
2391
|
+
return {
|
|
2392
|
+
integration,
|
|
2393
|
+
// The green ref marks the last *verified* merge of the integration branch,
|
|
2394
|
+
// so it is prefix + that branch — not prefix + whatever you are cutting.
|
|
2395
|
+
greenRef: `${greenPrefix}${integration}`,
|
|
2396
|
+
integPrefix: String(p.integ_prefix ?? "integ/")
|
|
2397
|
+
};
|
|
2398
|
+
}
|
|
2399
|
+
function git(root, args2) {
|
|
2400
|
+
return execFileSync("git", args2, { cwd: root, stdio: "pipe", encoding: "utf8" }).trim();
|
|
2401
|
+
}
|
|
2402
|
+
function gitOk(root, args2) {
|
|
2403
|
+
try {
|
|
2404
|
+
git(root, args2);
|
|
2405
|
+
return true;
|
|
2406
|
+
} catch {
|
|
2407
|
+
return false;
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
function revParse(root, ref) {
|
|
2411
|
+
try {
|
|
2412
|
+
return git(root, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`]);
|
|
2413
|
+
} catch {
|
|
2414
|
+
return null;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
function sessionStart(root, branch, at, dryRun = false) {
|
|
2418
|
+
const { integration, greenRef } = loopParams(root);
|
|
2419
|
+
const lines = [];
|
|
2420
|
+
if (!branch) return { ok: false, lines: ["a branch name is required: `rungs session start <branch> [path]`"] };
|
|
2421
|
+
if (revParse(root, `refs/heads/${branch}`)) {
|
|
2422
|
+
return { ok: false, lines: [`branch '${branch}' already exists \u2014 pick another name, or check out the worktree that holds it`] };
|
|
2423
|
+
}
|
|
2424
|
+
const green = revParse(root, `refs/heads/${greenRef}`);
|
|
2425
|
+
const base = green ? greenRef : integration;
|
|
2426
|
+
const baseSha = green ?? revParse(root, integration);
|
|
2427
|
+
if (!baseSha) return { ok: false, lines: [`neither '${greenRef}' nor '${integration}' resolves \u2014 is this the right repo?`] };
|
|
2428
|
+
if (green) {
|
|
2429
|
+
lines.push(`base ${greenRef} (${baseSha.slice(0, 8)}) \u2014 the last verified merge`);
|
|
2430
|
+
} else {
|
|
2431
|
+
lines.push(`no ${greenRef} ref yet \u2014 cutting from the tip of ${integration} (${baseSha.slice(0, 8)}) instead.`);
|
|
2432
|
+
lines.push(`That tip has not been verified by a land. The first successful \`rungs land\` creates ${greenRef}.`);
|
|
2433
|
+
}
|
|
2434
|
+
const path = resolve4(at ?? join13(dirname8(root), `${basename2(root)}-${branch.replace(/[^\w.-]+/g, "-")}`));
|
|
2435
|
+
if (existsSync10(path)) return { ok: false, lines: [`${path} already exists \u2014 rungs never writes over a directory it did not create`] };
|
|
2436
|
+
lines.push(`worktree ${path}`);
|
|
2437
|
+
lines.push(`branch ${branch}`);
|
|
2438
|
+
if (dryRun) return { ok: true, lines };
|
|
2439
|
+
try {
|
|
2440
|
+
git(root, ["worktree", "add", "-b", branch, path, baseSha]);
|
|
2441
|
+
} catch (e) {
|
|
2442
|
+
return { ok: false, lines: [...lines, `git refused: ${String(e.stderr ?? e.message).trim().split("\n").slice(-2).join(" ")}`] };
|
|
2443
|
+
}
|
|
2444
|
+
return { ok: true, lines };
|
|
2445
|
+
}
|
|
2446
|
+
function preflight(root) {
|
|
2447
|
+
const { integration } = loopParams(root);
|
|
2448
|
+
if (!revParse(root, integration)) return { ok: false, lines: [`'${integration}' does not resolve \u2014 is this the right repo?`] };
|
|
2449
|
+
let base;
|
|
2450
|
+
try {
|
|
2451
|
+
base = git(root, ["merge-base", "HEAD", integration]);
|
|
2452
|
+
} catch {
|
|
2453
|
+
return { ok: false, lines: [`no merge base between HEAD and ${integration}; nothing to compare`] };
|
|
2454
|
+
}
|
|
2455
|
+
const names = (args2) => new Set(git(root, args2).split("\n").map((s) => s.trim()).filter(Boolean));
|
|
2456
|
+
const theirs = names(["diff", "--name-only", base, integration]);
|
|
2457
|
+
const mine = /* @__PURE__ */ new Set([
|
|
2458
|
+
...names(["diff", "--name-only", base, "HEAD"]),
|
|
2459
|
+
...names(["diff", "--name-only", "HEAD"]),
|
|
2460
|
+
...names(["diff", "--name-only", "--cached"])
|
|
2461
|
+
]);
|
|
2462
|
+
const ahead = Number(git(root, ["rev-list", "--count", `${base}..${integration}`]));
|
|
2463
|
+
const overlap = [...mine].filter((f) => theirs.has(f)).sort();
|
|
2464
|
+
const lines = [
|
|
2465
|
+
`${integration} is ${ahead} commit(s) ahead of your base, touching ${theirs.size} file(s).`,
|
|
2466
|
+
`You have touched ${mine.size} file(s).`
|
|
2467
|
+
];
|
|
2468
|
+
if (!overlap.length) {
|
|
2469
|
+
lines.push("No overlap. The commit count is not the signal \u2014 these two sets not intersecting is.");
|
|
2470
|
+
return { ok: true, lines };
|
|
2471
|
+
}
|
|
2472
|
+
lines.push(`${overlap.length} file(s) changed on both sides:`);
|
|
2473
|
+
for (const f of overlap.slice(0, 20)) lines.push(` ${f}`);
|
|
2474
|
+
if (overlap.length > 20) lines.push(` \u2026and ${overlap.length - 20} more`);
|
|
2475
|
+
lines.push("Merge sooner rather than later. Shared code is a scheduling problem, not a tooling one.");
|
|
2476
|
+
return { ok: true, lines };
|
|
2477
|
+
}
|
|
2478
|
+
function lockPath(root) {
|
|
2479
|
+
return join13(git(root, ["rev-parse", "--git-common-dir"]).replace(/^\.git$/, join13(root, ".git")), "rungs-land.lock");
|
|
2480
|
+
}
|
|
2481
|
+
function alive(pid) {
|
|
2482
|
+
try {
|
|
2483
|
+
process.kill(pid, 0);
|
|
2484
|
+
return true;
|
|
2485
|
+
} catch (e) {
|
|
2486
|
+
return e?.code === "EPERM";
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
function land(root, branch, runner, dryRun = false) {
|
|
2490
|
+
const { integration, greenRef, integPrefix } = loopParams(root);
|
|
2491
|
+
const lines = [];
|
|
2492
|
+
if (!branch) return { ok: false, lines: ["a branch name is required: `rungs land <branch>`"] };
|
|
2493
|
+
const head = revParse(root, `refs/heads/${branch}`);
|
|
2494
|
+
if (!head) return { ok: false, lines: [`branch '${branch}' does not exist`] };
|
|
2495
|
+
const before = revParse(root, `refs/heads/${integration}`);
|
|
2496
|
+
if (!before) return { ok: false, lines: [`'${integration}' does not resolve`] };
|
|
2497
|
+
const lp = lockPath(root);
|
|
2498
|
+
if (existsSync10(lp)) {
|
|
2499
|
+
try {
|
|
2500
|
+
const held = JSON.parse(readFileSync11(lp, "utf8"));
|
|
2501
|
+
if (held.host === hostname() && alive(held.pid)) {
|
|
2502
|
+
return {
|
|
2503
|
+
ok: false,
|
|
2504
|
+
lines: [
|
|
2505
|
+
`another land is in progress: pid ${held.pid} on ${held.host}, landing '${held.branch}' since ${held.started}.`,
|
|
2506
|
+
"Concurrent landing is refused, not silently merged."
|
|
2507
|
+
]
|
|
2508
|
+
};
|
|
2509
|
+
}
|
|
2510
|
+
lines.push(`taking over a stale lock from pid ${held.pid} (${held.started}) \u2014 that process is gone.`);
|
|
2511
|
+
} catch {
|
|
2512
|
+
lines.push("an unreadable lock file was replaced.");
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
if (dryRun) {
|
|
2516
|
+
lines.push(`would merge ${branch} (${head.slice(0, 8)}) onto ${integration} (${before.slice(0, 8)}) via ${integPrefix}${branch}, verify, then advance.`);
|
|
2517
|
+
return { ok: true, lines };
|
|
2518
|
+
}
|
|
2519
|
+
const lock = { pid: process.pid, host: hostname(), started: (/* @__PURE__ */ new Date()).toISOString(), branch };
|
|
2520
|
+
writeFileSync6(lp, JSON.stringify(lock));
|
|
2521
|
+
const scratch = mkdtempSync2(join13(tmpdir2(), "rungs-land-"));
|
|
2522
|
+
const parked = `${integPrefix}${branch}`;
|
|
2523
|
+
try {
|
|
2524
|
+
git(root, ["worktree", "add", "--detach", scratch, before]);
|
|
2525
|
+
try {
|
|
2526
|
+
git(scratch, ["-c", "user.email=rungs@localhost", "-c", "user.name=rungs", "merge", "--no-ff", "-m", `land ${branch}`, head]);
|
|
2527
|
+
} catch (e) {
|
|
2528
|
+
const conflicts = (() => {
|
|
2529
|
+
try {
|
|
2530
|
+
return git(scratch, ["diff", "--name-only", "--diff-filter=U"]).split("\n").filter(Boolean);
|
|
2531
|
+
} catch {
|
|
2532
|
+
return [];
|
|
2533
|
+
}
|
|
2534
|
+
})();
|
|
2535
|
+
lines.push(`merge conflict \u2014 ${integration} is unchanged.`);
|
|
2536
|
+
for (const f of conflicts.slice(0, 15)) lines.push(` ${f}`);
|
|
2537
|
+
lines.push("Reconcile generated artifacts by regenerating, never by merging text.");
|
|
2538
|
+
return { ok: false, lines };
|
|
2539
|
+
}
|
|
2540
|
+
const merged = git(scratch, ["rev-parse", "HEAD"]);
|
|
2541
|
+
const res = runner(scratch);
|
|
2542
|
+
lines.push(`merged tree ${merged.slice(0, 8)} \u2014 ${res.pass} pass \xB7 ${res.failing.length} fail`);
|
|
2543
|
+
if (res.failing.length) {
|
|
2544
|
+
const ids = new Set(res.failing.map((f) => f.id));
|
|
2545
|
+
let base = null;
|
|
2546
|
+
try {
|
|
2547
|
+
git(scratch, ["reset", "--hard", before]);
|
|
2548
|
+
base = runner(scratch, ids);
|
|
2549
|
+
} catch {
|
|
2550
|
+
base = null;
|
|
2551
|
+
}
|
|
2552
|
+
const attributable = base !== null && base.failing.length + base.pass >= ids.size;
|
|
2553
|
+
const baseFindings = new Map((base?.failing ?? []).map((f) => [f.id, new Set(f.findings)]));
|
|
2554
|
+
const introduced = [];
|
|
2555
|
+
const inherited = [];
|
|
2556
|
+
for (const f of res.failing) {
|
|
2557
|
+
const seen = attributable ? baseFindings.get(f.id) ?? /* @__PURE__ */ new Set() : null;
|
|
2558
|
+
const fresh = seen ? f.findings.filter((x) => !seen.has(x)) : f.findings;
|
|
2559
|
+
if (fresh.length) introduced.push({ id: f.id, findings: fresh });
|
|
2560
|
+
else inherited.push(f);
|
|
2561
|
+
}
|
|
2562
|
+
for (const f of inherited) {
|
|
2563
|
+
lines.push(` inherited ${f.id}${f.findings[0] ? ` \u2014 ${f.findings[0]}` : ""}`);
|
|
2564
|
+
}
|
|
2565
|
+
for (const f of introduced) {
|
|
2566
|
+
lines.push(` INTRODUCED ${f.id}${f.findings[0] ? ` \u2014 ${f.findings[0]}` : ""}`);
|
|
2567
|
+
for (const extra of f.findings.slice(1, 4)) lines.push(` ${extra}`);
|
|
2568
|
+
}
|
|
2569
|
+
if (base === null) {
|
|
2570
|
+
lines.push(" The merge base could not be gated, so nothing here is attributable and all of it blocks.");
|
|
2571
|
+
} else if (!attributable) {
|
|
2572
|
+
lines.push(" Some gates could not be attributed against the merge base, so they block. We do not land on an unknown.");
|
|
2573
|
+
}
|
|
2574
|
+
if (introduced.length) {
|
|
2575
|
+
git(root, ["update-ref", `refs/heads/${parked}`, merged]);
|
|
2576
|
+
lines.push(
|
|
2577
|
+
`${introduced.length} introduced by this branch. ${integration} is unchanged, and the merged tree is parked on '${parked}' \u2014 fix it there and land again.`
|
|
2578
|
+
);
|
|
2579
|
+
return { ok: false, lines };
|
|
2580
|
+
}
|
|
2581
|
+
lines.push(
|
|
2582
|
+
`${inherited.length} failure(s), all already red on ${integration} before this branch. Landing anyway \u2014 they are not this branch's to fix, and blocking on them is how a gate gets bypassed.`
|
|
2583
|
+
);
|
|
2584
|
+
git(scratch, ["reset", "--hard", merged]);
|
|
2585
|
+
}
|
|
2586
|
+
try {
|
|
2587
|
+
git(root, ["update-ref", `refs/heads/${integration}`, merged, before]);
|
|
2588
|
+
} catch {
|
|
2589
|
+
git(root, ["update-ref", `refs/heads/${parked}`, merged]);
|
|
2590
|
+
return {
|
|
2591
|
+
ok: false,
|
|
2592
|
+
lines: [
|
|
2593
|
+
...lines,
|
|
2594
|
+
`${integration} moved while this land was verifying, so the advance was refused rather than overwriting it.`,
|
|
2595
|
+
`Your verified merge is parked on '${parked}'. Re-run \`rungs land ${branch}\` to rebuild it on the new tip.`
|
|
2596
|
+
]
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
git(root, ["update-ref", `refs/heads/${greenRef}`, merged]);
|
|
2600
|
+
lines.push(`${integration} \u2192 ${merged.slice(0, 8)}, and ${greenRef} now marks it verified.`);
|
|
2601
|
+
if (revParse(root, `refs/heads/${parked}`)) git(root, ["update-ref", "-d", `refs/heads/${parked}`]);
|
|
2602
|
+
return { ok: true, lines };
|
|
2603
|
+
} finally {
|
|
2604
|
+
try {
|
|
2605
|
+
git(root, ["worktree", "remove", "--force", scratch]);
|
|
2606
|
+
} catch {
|
|
2607
|
+
rmSync2(scratch, { recursive: true, force: true });
|
|
2608
|
+
try {
|
|
2609
|
+
git(root, ["worktree", "prune"]);
|
|
2610
|
+
} catch {
|
|
2611
|
+
}
|
|
2612
|
+
}
|
|
2613
|
+
try {
|
|
2614
|
+
unlinkSync(lp);
|
|
2615
|
+
} catch {
|
|
2616
|
+
}
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
function worktrees(root) {
|
|
2620
|
+
const { integration } = loopParams(root);
|
|
2621
|
+
const out = git(root, ["worktree", "list", "--porcelain"]);
|
|
2622
|
+
const rows = [];
|
|
2623
|
+
for (const block of out.split("\n\n").filter((b) => b.trim())) {
|
|
2624
|
+
const path = block.match(/^worktree (.+)$/m)?.[1];
|
|
2625
|
+
const branch = block.match(/^branch refs\/heads\/(.+)$/m)?.[1];
|
|
2626
|
+
if (!path || !branch || branch === integration) continue;
|
|
2627
|
+
const merged = gitOk(root, ["merge-base", "--is-ancestor", branch, integration]);
|
|
2628
|
+
let dirty = false;
|
|
2629
|
+
try {
|
|
2630
|
+
dirty = git(path, ["status", "--porcelain"]).length > 0;
|
|
2631
|
+
} catch {
|
|
2632
|
+
dirty = false;
|
|
2633
|
+
}
|
|
2634
|
+
rows.push({ path, branch, merged, dirty });
|
|
2635
|
+
}
|
|
2636
|
+
return { rows, integration };
|
|
2637
|
+
}
|
|
2638
|
+
|
|
1780
2639
|
// src/cli.ts
|
|
1781
|
-
|
|
1782
|
-
var
|
|
2640
|
+
import { existsSync as existsSync11 } from "node:fs";
|
|
2641
|
+
var HERE = dirname9(fileURLToPath3(import.meta.url));
|
|
2642
|
+
var MODULES2 = join14(HERE, "..", "modules");
|
|
1783
2643
|
var c = {
|
|
1784
2644
|
dim: (s) => `\x1B[2m${s}\x1B[0m`,
|
|
1785
2645
|
bold: (s) => `\x1B[1m${s}\x1B[0m`,
|
|
@@ -1828,7 +2688,7 @@ ${mods.length} modules
|
|
|
1828
2688
|
const issues = auditModules(mods);
|
|
1829
2689
|
console.log();
|
|
1830
2690
|
if (issues.length === 0) {
|
|
1831
|
-
console.log(c.green(" audit clean") + c.dim(" \u2014 every parameter accounted for
|
|
2691
|
+
console.log(c.green(" audit clean") + c.dim(" \u2014 every parameter accounted for; every gate has a table, a why, and a declared applicability"));
|
|
1832
2692
|
} else {
|
|
1833
2693
|
console.log(c.red(` ${issues.length} issue(s):`));
|
|
1834
2694
|
for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} \u2014 ${i.detail}`);
|
|
@@ -1836,8 +2696,8 @@ ${mods.length} modules
|
|
|
1836
2696
|
console.log();
|
|
1837
2697
|
return issues.length === 0 ? 0 : 1;
|
|
1838
2698
|
}
|
|
1839
|
-
function cmdDoctor(target) {
|
|
1840
|
-
const root =
|
|
2699
|
+
function cmdDoctor(target, doExplain = false) {
|
|
2700
|
+
const root = resolve5(target);
|
|
1841
2701
|
const mods = loadAllModules(MODULES2);
|
|
1842
2702
|
console.log(c.bold(`
|
|
1843
2703
|
rungs doctor \u2014 ${root}
|
|
@@ -1906,6 +2766,9 @@ rungs doctor \u2014 ${root}
|
|
|
1906
2766
|
console.log(c.dim(" This reports presence, never quality. It cannot tell whether an adopted"));
|
|
1907
2767
|
console.log(c.dim(" system is good, complete, or working \u2014 only that files are where a"));
|
|
1908
2768
|
console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
|
|
2769
|
+
reportLedger(root);
|
|
2770
|
+
if (doExplain) reportExplain(mods, results, root, files);
|
|
2771
|
+
else advertiseAnalysis(results);
|
|
1909
2772
|
const theirs = byState("theirs");
|
|
1910
2773
|
console.log(c.bold(" Next\n"));
|
|
1911
2774
|
if (ours) {
|
|
@@ -1931,6 +2794,58 @@ rungs doctor \u2014 ${root}
|
|
|
1931
2794
|
function firstSentence(s) {
|
|
1932
2795
|
return s.trim().replace(/\s+/g, " ").split(/(?<=\.)\s/)[0];
|
|
1933
2796
|
}
|
|
2797
|
+
function advertiseAnalysis(results) {
|
|
2798
|
+
const inScope = results.filter((r) => IN_SCOPE.has(r.state)).length;
|
|
2799
|
+
if (!inScope) return;
|
|
2800
|
+
console.log(c.bold(" Analysis\n"));
|
|
2801
|
+
console.log(` ${inScope} of these are things this repo already has, and can be checked against it.`);
|
|
2802
|
+
console.log(` ${c.cyan("rungs doctor --explain")} ${c.dim("\u2014 evidenced findings, and the incident behind each check")}
|
|
2803
|
+
`);
|
|
2804
|
+
}
|
|
2805
|
+
function reportExplain(mods, results, root, files) {
|
|
2806
|
+
const { reported, skipped, scope } = explain(mods, results, root, files);
|
|
2807
|
+
console.log(c.bold(" What it also checked\n"));
|
|
2808
|
+
if (!scope.length) {
|
|
2809
|
+
console.log(c.dim(" Nothing \u2014 detectors run only over what this repo already has, and"));
|
|
2810
|
+
console.log(c.dim(" detection found no equivalent of any module. There is nothing here to"));
|
|
2811
|
+
console.log(c.dim(" check that would not be checking our conventions against your repo.\n"));
|
|
2812
|
+
return;
|
|
2813
|
+
}
|
|
2814
|
+
const total = reported.reduce((n, r) => n + r.findings.length, 0);
|
|
2815
|
+
console.log(
|
|
2816
|
+
c.dim(` ran the detectors for ${scope.length} module(s) this repo already has: `) + c.dim(scope.join(" ")) + "\n"
|
|
2817
|
+
);
|
|
2818
|
+
for (const r of reported) {
|
|
2819
|
+
const n = r.findings.length;
|
|
2820
|
+
console.log(` ${c.yellow(r.gate.padEnd(34))} ${c.bold(String(n))} ${n === 1 ? "finding" : "findings"}`);
|
|
2821
|
+
for (const f of r.findings.slice(0, 4)) {
|
|
2822
|
+
console.log(c.dim(` ${f.file ? `${f.file}: ` : ""}${f.message}`));
|
|
2823
|
+
}
|
|
2824
|
+
if (n > 4) console.log(c.dim(` \u2026and ${n - 4} more`));
|
|
2825
|
+
if (r.why) console.log(c.dim(` why: ${firstSentence(r.why)}`));
|
|
2826
|
+
console.log();
|
|
2827
|
+
}
|
|
2828
|
+
if (!total) {
|
|
2829
|
+
console.log(c.dim(" No detector fired. That is not a clean bill of health \u2014 see below.\n"));
|
|
2830
|
+
}
|
|
2831
|
+
console.log(c.dim(" This is not an audit, and it is deliberately incomplete:"));
|
|
2832
|
+
console.log(c.dim(" \xB7 Detectors ran only for modules this repo already has an equivalent of."));
|
|
2833
|
+
console.log(c.dim(" \xB7 They read rungs-shaped inputs. A finding may be true and framed against"));
|
|
2834
|
+
console.log(c.dim(" a convention you never adopted \u2014 that is our defect, not yours."));
|
|
2835
|
+
if (skipped.command) {
|
|
2836
|
+
console.log(c.dim(` \xB7 ${skipped.command} command gate(s) not run. rungs does not execute commands in a repo it is only reading.`));
|
|
2837
|
+
}
|
|
2838
|
+
if (skipped.undeclared.length) {
|
|
2839
|
+
console.log(c.dim(` \xB7 ${skipped.undeclared.length} gate(s) never said whether they can read a repo like yours, so they did not: ${skipped.undeclared.join(" ")}`));
|
|
2840
|
+
}
|
|
2841
|
+
if (skipped.unimplemented.length) {
|
|
2842
|
+
console.log(c.dim(` \xB7 ${skipped.unimplemented.length} declared gate(s) have no engine and were skipped, never passed: ${skipped.unimplemented.join(" ")}`));
|
|
2843
|
+
}
|
|
2844
|
+
for (const e of skipped.errored) {
|
|
2845
|
+
console.log(c.dim(` \xB7 ${e.gate} could not run here (${e.message}) \u2014 a fact about this pass, not about your repo.`));
|
|
2846
|
+
}
|
|
2847
|
+
console.log();
|
|
2848
|
+
}
|
|
1934
2849
|
function cmdAdd(names, root, dryRun, harnesses, stamp) {
|
|
1935
2850
|
const mods = loadAllModules(MODULES2);
|
|
1936
2851
|
const { order, missing } = resolveInstallOrder(names, mods);
|
|
@@ -1953,6 +2868,29 @@ function cmdAdd(names, root, dryRun, harnesses, stamp) {
|
|
|
1953
2868
|
}
|
|
1954
2869
|
(overrides[modName] ??= {})[param] = rhs.join("=");
|
|
1955
2870
|
}
|
|
2871
|
+
for (const [modName, vals] of Object.entries(overrides)) {
|
|
2872
|
+
const mod = mods.find((m) => m.name === modName);
|
|
2873
|
+
if (!mod) {
|
|
2874
|
+
console.log(
|
|
2875
|
+
c.red(`
|
|
2876
|
+
--set names a module that does not exist: ${modName}`) + c.dim(`
|
|
2877
|
+
Known: ${mods.map((m) => m.name).join(", ")}
|
|
2878
|
+
`)
|
|
2879
|
+
);
|
|
2880
|
+
return 1;
|
|
2881
|
+
}
|
|
2882
|
+
for (const k of Object.keys(vals)) {
|
|
2883
|
+
if (!(k in mod.params)) {
|
|
2884
|
+
const known = Object.keys(mod.params);
|
|
2885
|
+
console.log(
|
|
2886
|
+
c.red(`
|
|
2887
|
+
--set names a parameter ${modName} does not have: ${k}`) + c.dim(`
|
|
2888
|
+
${known.length ? `${modName} takes: ${known.join(", ")}` : `${modName} takes no parameters`}`) + c.dim("\n `rungs modules --params` lists every parameter and its default.\n")
|
|
2889
|
+
);
|
|
2890
|
+
return 1;
|
|
2891
|
+
}
|
|
2892
|
+
}
|
|
2893
|
+
}
|
|
1956
2894
|
const params = resolveParams(mods, overrides, root);
|
|
1957
2895
|
for (const [m, vals] of Object.entries(overrides)) {
|
|
1958
2896
|
for (const [k, v] of Object.entries(vals)) console.log(c.dim(` set ${m}.${k} = ${v}`));
|
|
@@ -1963,9 +2901,50 @@ rungs add ${names.join(" ")} \u2192 ${root}${dryRun ? c.yellow(" (dry run)") :
|
|
|
1963
2901
|
`));
|
|
1964
2902
|
if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(", ")}
|
|
1965
2903
|
`));
|
|
2904
|
+
const scanned = scanRepo(root);
|
|
2905
|
+
const paradigms = new Set(
|
|
2906
|
+
order.map((m) => detect(m, root, scanned)).filter((r) => r.state === "paradigm").map((r) => r.module)
|
|
2907
|
+
);
|
|
2908
|
+
const overridden = flags.has("--confirm-paradigm");
|
|
2909
|
+
const blocked = overridden ? /* @__PURE__ */ new Map() : blockedByParadigm(order, paradigms);
|
|
2910
|
+
if (overridden && paradigms.size) {
|
|
2911
|
+
for (const name of paradigms) {
|
|
2912
|
+
const p = detect(order.find((m) => m.name === name), root, scanned).paradigm;
|
|
2913
|
+
console.log(
|
|
2914
|
+
c.yellow(` ${name}: installing over an existing ${p.id}`) + c.dim(` (${p.matched[0]}) \u2014 --confirm-paradigm`)
|
|
2915
|
+
);
|
|
2916
|
+
}
|
|
2917
|
+
console.log(c.dim(" You will have two systems for one job. That is a choice, not a merge.\n"));
|
|
2918
|
+
}
|
|
2919
|
+
let toInstall = order;
|
|
2920
|
+
if (blocked.size) {
|
|
2921
|
+
for (const mod of order) {
|
|
2922
|
+
const cause = blocked.get(mod.name);
|
|
2923
|
+
if (!cause) continue;
|
|
2924
|
+
if (cause === mod.name) {
|
|
2925
|
+
const p = detect(mod, root, scanned).paradigm;
|
|
2926
|
+
console.log(c.yellow(` ${mod.name}: this repo already does this another way \u2014 ${p.id}`));
|
|
2927
|
+
console.log(c.dim(` matched ${p.matched[0]}`));
|
|
2928
|
+
for (const line of (p.note ?? "").trim().split("\n")) console.log(c.dim(` ${line || ""}`));
|
|
2929
|
+
if (p.compare) console.log(c.dim(` compare: ${p.compare}`));
|
|
2930
|
+
} else {
|
|
2931
|
+
console.log(c.yellow(` ${mod.name}: not installed \u2014 it requires ${cause}.`));
|
|
2932
|
+
}
|
|
2933
|
+
}
|
|
2934
|
+
toInstall = resolveInstallOrder(names.filter((n) => !blocked.has(n)), mods).order;
|
|
2935
|
+
const dropped = order.filter((m) => !toInstall.includes(m) && !blocked.has(m.name));
|
|
2936
|
+
if (dropped.length) {
|
|
2937
|
+
console.log(c.dim(` ${dropped.map((m) => m.name).join(", ")} not written \u2014 pulled in only for the above`));
|
|
2938
|
+
}
|
|
2939
|
+
console.log(
|
|
2940
|
+
c.dim(`
|
|
2941
|
+
Pass --confirm-paradigm to install anyway.`) + (toInstall.length ? c.dim(" Continuing with the rest.\n") : c.dim(" Nothing was written.\n"))
|
|
2942
|
+
);
|
|
2943
|
+
if (!toInstall.length) return 1;
|
|
2944
|
+
}
|
|
1966
2945
|
const installed = [];
|
|
1967
2946
|
const wrote = /* @__PURE__ */ new Map();
|
|
1968
|
-
for (const mod of
|
|
2947
|
+
for (const mod of toInstall) {
|
|
1969
2948
|
if (mod.threshold?.confirm && !dryRun && !flags.has("--confirm-threshold")) {
|
|
1970
2949
|
console.log(
|
|
1971
2950
|
c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) + c.dim(" Skipped \u2014 pass --confirm-threshold to install it.\n")
|
|
@@ -2030,10 +3009,70 @@ rungs render \u2014 ${root}
|
|
|
2030
3009
|
}
|
|
2031
3010
|
return 0;
|
|
2032
3011
|
}
|
|
3012
|
+
function report(r) {
|
|
3013
|
+
console.log();
|
|
3014
|
+
for (const l of r.lines) console.log(` ${r.ok ? l : c.yellow(l)}`);
|
|
3015
|
+
console.log();
|
|
3016
|
+
return r.ok ? 0 : 1;
|
|
3017
|
+
}
|
|
3018
|
+
function landRunner(dir, only) {
|
|
3019
|
+
const runs = runGates(dir, void 0, void 0, only);
|
|
3020
|
+
const failing = runs.filter((r) => r.status === "fail" || r.status === "error");
|
|
3021
|
+
return {
|
|
3022
|
+
pass: runs.filter((r) => r.status === "pass").length,
|
|
3023
|
+
// `file: message`, so the same broken link in the same file is the same
|
|
3024
|
+
// finding across two runs, and a *new* one is visibly not.
|
|
3025
|
+
failing: failing.map((r) => ({
|
|
3026
|
+
id: r.id,
|
|
3027
|
+
findings: r.findings.map((f) => `${f.file ? `${f.file}: ` : ""}${f.message}`)
|
|
3028
|
+
}))
|
|
3029
|
+
};
|
|
3030
|
+
}
|
|
3031
|
+
function cmdWorktrees(root) {
|
|
3032
|
+
const { rows, integration } = worktrees(root);
|
|
3033
|
+
console.log(c.bold(`
|
|
3034
|
+
rungs worktrees \u2014 merged into ${integration}?
|
|
3035
|
+
`));
|
|
3036
|
+
if (!rows.length) {
|
|
3037
|
+
console.log(c.dim(" no linked worktrees. `rungs session start <branch>` creates one.\n"));
|
|
3038
|
+
return 0;
|
|
3039
|
+
}
|
|
3040
|
+
for (const w of rows) {
|
|
3041
|
+
const state = w.merged && w.dirty ? c.red("merged \xB7 DIRTY") : w.merged ? c.green("merged \xB7 prunable") : c.dim("in flight");
|
|
3042
|
+
console.log(` ${state.padEnd(28)} ${w.branch.padEnd(30)} ${c.dim(w.path)}`);
|
|
3043
|
+
}
|
|
3044
|
+
const risky = rows.filter((w) => w.merged && w.dirty);
|
|
3045
|
+
const prunable = rows.filter((w) => w.merged && !w.dirty);
|
|
3046
|
+
console.log();
|
|
3047
|
+
if (risky.length) {
|
|
3048
|
+
console.log(c.red(` ${risky.length} worktree(s) hold uncommitted work on a branch that already landed.`));
|
|
3049
|
+
console.log(c.dim(" That is where work actually gets lost. Commit it somewhere or decide to drop it."));
|
|
3050
|
+
}
|
|
3051
|
+
if (prunable.length) console.log(c.dim(` ${prunable.length} prunable. Removing a worktree is your call, not this command's.`));
|
|
3052
|
+
console.log();
|
|
3053
|
+
return 0;
|
|
3054
|
+
}
|
|
2033
3055
|
function cmdCheck(root, tier, stamp) {
|
|
2034
|
-
|
|
3056
|
+
let runs;
|
|
3057
|
+
try {
|
|
3058
|
+
runs = runGates(root, tier);
|
|
3059
|
+
} catch (e) {
|
|
3060
|
+
if (!(e instanceof UnknownTierError)) throw e;
|
|
3061
|
+
console.log(c.yellow(`
|
|
3062
|
+
unknown tier "${e.requested}"`) + c.dim(` \u2014 this repo declares ${e.declared.join(", ")}.`));
|
|
3063
|
+
console.log(c.dim(" Nothing ran. Use `rungs check` to run every registered gate.\n"));
|
|
3064
|
+
return 1;
|
|
3065
|
+
}
|
|
2035
3066
|
if (!runs.length) {
|
|
2036
|
-
|
|
3067
|
+
const runnable = loadRegistry(root).gates.filter((g) => !g.trigger);
|
|
3068
|
+
if (runnable.length && tier) {
|
|
3069
|
+
const tiers = [...new Set(runnable.map((g) => g.tier).filter(Boolean))];
|
|
3070
|
+
console.log(c.yellow(`
|
|
3071
|
+
no gates in the ${tier} tier \u2014 ${runnable.length} are registered`) + c.dim(` (${tiers.length ? tiers.join(", ") : "none tiered"}).`));
|
|
3072
|
+
console.log(c.dim(" Nothing ran. Use `rungs check` to run every registered gate.\n"));
|
|
3073
|
+
} else {
|
|
3074
|
+
console.log(c.yellow("\n no gates registered \u2014 is this a rungs repo?\n"));
|
|
3075
|
+
}
|
|
2037
3076
|
return 1;
|
|
2038
3077
|
}
|
|
2039
3078
|
appendLedger(root, runs, stamp);
|
|
@@ -2060,26 +3099,67 @@ rungs check \u2014 ${root}${tier ? ` (${tier} tier)` : ""}
|
|
|
2060
3099
|
c.yellow("\n Unimplemented gates are not passes.") + c.dim(" A registry reporting green because most of its\n gates do nothing is the worst failure this tool could have, so they block.")
|
|
2061
3100
|
);
|
|
2062
3101
|
}
|
|
3102
|
+
console.log();
|
|
3103
|
+
return n("fail") + n("unimplemented") + n("error") > 0 ? 1 : 0;
|
|
3104
|
+
}
|
|
3105
|
+
function reportLedger(root) {
|
|
2063
3106
|
const { gates } = loadRegistry(root);
|
|
2064
3107
|
const q = ledgerQuestions(root, gates);
|
|
2065
|
-
if (q.neverFired.length
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
console.log(c.dim(" Is that still a risk here, or is the gate scoped too narrowly?"));
|
|
2071
|
-
}
|
|
2072
|
-
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
2073
|
-
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim("Red by default is a gate people learn to bypass.")}`);
|
|
2074
|
-
}
|
|
2075
|
-
console.log(
|
|
2076
|
-
c.dim("\n These are questions, not verdicts. The ledger records whether a gate ran")
|
|
2077
|
-
);
|
|
2078
|
-
console.log(c.dim(" and whether it fired \u2014 never whether it is valuable. Gates invoked"));
|
|
2079
|
-
console.log(c.dim(" directly, and CI runs, are not counted."));
|
|
3108
|
+
if (!q.neverFired.length && !q.alwaysFires.length) return;
|
|
3109
|
+
console.log(c.bold(` Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
|
|
3110
|
+
for (const g of q.neverFired.slice(0, 3)) {
|
|
3111
|
+
console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ""))}`);
|
|
3112
|
+
console.log(c.dim(" Is that still a risk here, or is the gate scoped too narrowly?"));
|
|
2080
3113
|
}
|
|
2081
|
-
|
|
2082
|
-
|
|
3114
|
+
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
3115
|
+
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim("Red by default is a gate people learn to bypass.")}`);
|
|
3116
|
+
}
|
|
3117
|
+
console.log(c.dim("\n These are questions, not verdicts. The ledger records whether a gate ran"));
|
|
3118
|
+
console.log(c.dim(" and whether it fired \u2014 never whether it is valuable. Gates invoked"));
|
|
3119
|
+
console.log(c.dim(" directly, and CI runs, are not counted.\n"));
|
|
3120
|
+
}
|
|
3121
|
+
function cmdBacklogArchive(root, dryRun) {
|
|
3122
|
+
const record = readRecord(root);
|
|
3123
|
+
const configured = record?.modules["backlog"]?.params?.root;
|
|
3124
|
+
const backlogRoot = `docs/${configured ?? "backlog"}`;
|
|
3125
|
+
if (!existsSync11(join14(root, ...backlogRoot.split("/"), "items"))) {
|
|
3126
|
+
console.log(c.red(`
|
|
3127
|
+
no backlog at ${backlogRoot}/items
|
|
3128
|
+
`));
|
|
3129
|
+
return 1;
|
|
3130
|
+
}
|
|
3131
|
+
const plan = planArchive(root, backlogRoot);
|
|
3132
|
+
console.log(c.bold(`
|
|
3133
|
+
rungs backlog archive \u2192 ${root}${dryRun ? c.yellow(" (dry run)") : ""}
|
|
3134
|
+
`));
|
|
3135
|
+
for (const h of plan.held) console.log(c.yellow(` held ${h.file}`) + c.dim(` \u2014 ${h.reason}`));
|
|
3136
|
+
if (plan.held.length) console.log();
|
|
3137
|
+
if (!plan.moves.length) {
|
|
3138
|
+
console.log(c.dim(" nothing to archive \u2014 no item is done or rejected.\n"));
|
|
3139
|
+
return 0;
|
|
3140
|
+
}
|
|
3141
|
+
const byStatus = /* @__PURE__ */ new Map();
|
|
3142
|
+
for (const m of plan.moves) byStatus.set(m.status, (byStatus.get(m.status) ?? 0) + 1);
|
|
3143
|
+
console.log(
|
|
3144
|
+
` ${c.bold(String(plan.moves.length))} item(s) \u2014 ${[...byStatus].map(([s, n]) => `${n} ${s}`).join(" \xB7 ")}`
|
|
3145
|
+
);
|
|
3146
|
+
for (const m of plan.moves.slice(0, 5)) console.log(c.dim(` ${m.from} \u2192 ${m.to}`));
|
|
3147
|
+
if (plan.moves.length > 5) console.log(c.dim(` \u2026and ${plan.moves.length - 5} more`));
|
|
3148
|
+
const touched = plan.rewrites.filter((r) => r.links);
|
|
3149
|
+
const links = touched.reduce((n, r) => n + r.links, 0);
|
|
3150
|
+
console.log(`
|
|
3151
|
+
${c.bold(String(links))} link(s) repointed across ${touched.length} file(s)`);
|
|
3152
|
+
for (const r of touched.slice(0, 5)) console.log(c.dim(` ${r.file} (${r.links})`));
|
|
3153
|
+
if (touched.length > 5) console.log(c.dim(` \u2026and ${touched.length - 5} more`));
|
|
3154
|
+
if (dryRun) {
|
|
3155
|
+
console.log(c.dim("\n Nothing written. Drop --dry-run to apply.\n"));
|
|
3156
|
+
return 0;
|
|
3157
|
+
}
|
|
3158
|
+
applyArchive(root, plan);
|
|
3159
|
+
console.log(c.green(`
|
|
3160
|
+
archived ${plan.moves.length} item(s)`) + c.dim(" \u2014 ids stay spent and every citation still resolves."));
|
|
3161
|
+
console.log(c.dim(" Run `rungs check` to confirm.\n"));
|
|
3162
|
+
return 0;
|
|
2083
3163
|
}
|
|
2084
3164
|
function cmdInit(root, profile, dryRun, harnesses, stamp) {
|
|
2085
3165
|
if (readRecord(root)) {
|
|
@@ -2122,10 +3202,15 @@ rungs upgrade \u2014 ${root}${apply ? "" : c.yellow(" (preview)")}
|
|
|
2122
3202
|
console.log(` ${c.yellow("diverged")} ${f.rel} ${c.dim("\u2014 yours, left alone")}`);
|
|
2123
3203
|
}
|
|
2124
3204
|
}
|
|
2125
|
-
if (apply
|
|
2126
|
-
const written = applyUpgrade(root, mods, record, plan);
|
|
3205
|
+
if (apply) {
|
|
3206
|
+
const { written, gates, recorded } = applyUpgrade(root, mods, record, plan);
|
|
3207
|
+
const parts = [
|
|
3208
|
+
written ? `${written} file(s)` : "",
|
|
3209
|
+
gates ? `${gates} gate registration(s)` : "",
|
|
3210
|
+
recorded ? `${recorded} record line(s)` : ""
|
|
3211
|
+
].filter(Boolean);
|
|
2127
3212
|
console.log(c.green(`
|
|
2128
|
-
updated ${
|
|
3213
|
+
updated ${parts.length ? parts.join(" \xB7 ") : "nothing"}`));
|
|
2129
3214
|
}
|
|
2130
3215
|
console.log(
|
|
2131
3216
|
`
|
|
@@ -2161,10 +3246,17 @@ var COMMANDS = [
|
|
|
2161
3246
|
["upgrade [path]", "move to newer module versions, never touching what you edited"],
|
|
2162
3247
|
["eject [path]", "materialise the engines; stop depending on rungs"],
|
|
2163
3248
|
["setup git [path]", "install the merge drivers .gitattributes names"],
|
|
2164
|
-
["modules", "list the module set and audit the manifests"]
|
|
3249
|
+
["modules", "list the module set and audit the manifests"],
|
|
3250
|
+
["backlog archive [path]", "move finished items to archive/, repointing every link"],
|
|
3251
|
+
["session start <branch>", "cut a branch and worktree from the last verified merge"],
|
|
3252
|
+
["preflight [path]", "did the integration branch change files you changed?"],
|
|
3253
|
+
["land <branch>", "merge \u2192 verify the merged tree \u2192 advance, or refuse and park it"],
|
|
3254
|
+
["worktrees [path]", "which worktrees are merged, prunable, or merged and still dirty"]
|
|
2165
3255
|
];
|
|
2166
3256
|
var FLAGS = [
|
|
2167
3257
|
["--dry-run", "report what would happen, write nothing"],
|
|
3258
|
+
["--explain", "doctor: also run the detectors over what this repo already has"],
|
|
3259
|
+
["--confirm-paradigm", "add: install a module this repo already solves another way"],
|
|
2168
3260
|
["--into <path>", "add: install into this repo instead of the working directory"],
|
|
2169
3261
|
["--set m.param=value", "add/init: override a module parameter. Repeatable"],
|
|
2170
3262
|
["--confirm-threshold", "add: install a module whose rung is above this repo"],
|
|
@@ -2231,21 +3323,36 @@ switch (cmd) {
|
|
|
2231
3323
|
case "modules":
|
|
2232
3324
|
process.exit(cmdModules(flags.has("--params")));
|
|
2233
3325
|
case "doctor":
|
|
2234
|
-
process.exit(cmdDoctor(args[0] ?? process.cwd()));
|
|
3326
|
+
process.exit(cmdDoctor(args[0] ?? process.cwd(), flags.has("--explain")));
|
|
3327
|
+
case "backlog": {
|
|
3328
|
+
if (args[0] !== "archive") {
|
|
3329
|
+
console.log(c.red(`
|
|
3330
|
+
unknown: rungs backlog ${args[0] ?? ""}`) + c.dim("\n The only subcommand is `archive`.\n"));
|
|
3331
|
+
process.exit(1);
|
|
3332
|
+
}
|
|
3333
|
+
process.exit(cmdBacklogArchive(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run")));
|
|
3334
|
+
}
|
|
2235
3335
|
case "check": {
|
|
2236
3336
|
const tier = args[1] ?? (flags.has("--full") ? "full" : flags.has("--fast") ? "fast" : void 0);
|
|
2237
|
-
process.exit(cmdCheck(
|
|
3337
|
+
process.exit(cmdCheck(resolve5(args[0] ?? process.cwd()), tier, STAMP));
|
|
2238
3338
|
}
|
|
2239
3339
|
case "init": {
|
|
2240
3340
|
const profile = args[1] ?? "tracked";
|
|
2241
|
-
process.exit(cmdInit(
|
|
3341
|
+
process.exit(cmdInit(resolve5(args[0] ?? process.cwd()), profile, flags.has("--dry-run"), HARNESSES, STAMP));
|
|
2242
3342
|
}
|
|
2243
3343
|
case "upgrade":
|
|
2244
|
-
process.exit(cmdUpgrade(
|
|
3344
|
+
process.exit(cmdUpgrade(resolve5(args[0] ?? process.cwd()), flags.has("--apply")));
|
|
2245
3345
|
case "eject":
|
|
2246
|
-
process.exit(cmdEject(
|
|
3346
|
+
process.exit(cmdEject(resolve5(args[0] ?? process.cwd()), flags.has("--dry-run")));
|
|
2247
3347
|
case "setup": {
|
|
2248
|
-
|
|
3348
|
+
if (args[0] !== "git") {
|
|
3349
|
+
console.log(
|
|
3350
|
+
c.red(`
|
|
3351
|
+
unknown: rungs setup ${args[0] ?? ""}`.trimEnd()) + c.dim("\n The only subcommand is `git`, and the path comes after it: `rungs setup git [path]`.\n")
|
|
3352
|
+
);
|
|
3353
|
+
process.exit(1);
|
|
3354
|
+
}
|
|
3355
|
+
const r = setupGit(resolve5(args[1] ?? process.cwd()), flags.has("--dry-run"));
|
|
2249
3356
|
console.log(
|
|
2250
3357
|
r.drivers.length ? `
|
|
2251
3358
|
installed ${r.drivers.length} merge driver(s): ${r.drivers.join(", ")}` + (r.rerere ? c.dim(" \xB7 rerere on") : "") + c.dim("\n Declared drivers were inert until now \u2014 a fresh clone needs this once.\n") : c.dim("\n no rungs merge drivers declared in .gitattributes\n")
|
|
@@ -2253,11 +3360,25 @@ switch (cmd) {
|
|
|
2253
3360
|
process.exit(0);
|
|
2254
3361
|
}
|
|
2255
3362
|
case "render":
|
|
2256
|
-
process.exit(cmdRender(
|
|
3363
|
+
process.exit(cmdRender(resolve5(args[0] ?? process.cwd()), HARNESSES, STAMP));
|
|
3364
|
+
case "session": {
|
|
3365
|
+
if (args[0] !== "start") {
|
|
3366
|
+
console.log(c.red(`
|
|
3367
|
+
unknown: rungs session ${args[0] ?? ""}`.trimEnd()) + c.dim("\n The only subcommand is `start`: `rungs session start <branch> [path]`.\n"));
|
|
3368
|
+
process.exit(1);
|
|
3369
|
+
}
|
|
3370
|
+
process.exit(report(sessionStart(process.cwd(), args[1], args[2], flags.has("--dry-run"))));
|
|
3371
|
+
}
|
|
3372
|
+
case "preflight":
|
|
3373
|
+
process.exit(report(preflight(resolve5(args[0] ?? process.cwd()))));
|
|
3374
|
+
case "land":
|
|
3375
|
+
process.exit(report(land(process.cwd(), args[0], landRunner, flags.has("--dry-run"))));
|
|
3376
|
+
case "worktrees":
|
|
3377
|
+
process.exit(cmdWorktrees(resolve5(args[0] ?? process.cwd())));
|
|
2257
3378
|
case "add": {
|
|
2258
3379
|
const target = flags.has("--into") ? args[args.length - 1] : process.cwd();
|
|
2259
3380
|
const names = flags.has("--into") ? args.slice(0, -1) : args;
|
|
2260
|
-
process.exit(cmdAdd(names,
|
|
3381
|
+
process.exit(cmdAdd(names, resolve5(target), flags.has("--dry-run"), HARNESSES, STAMP));
|
|
2261
3382
|
}
|
|
2262
3383
|
default: {
|
|
2263
3384
|
const wantedHelp = cmd === void 0 || cmd === "help" || cmd === "--help" || cmd === "-h";
|