@rungs/cli 0.1.2 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -8
- package/dist/cli.js +883 -127
- package/dist/cli.js.map +4 -4
- package/modules/README.md +13 -0
- package/modules/adr/gates/adr.toml +14 -2
- package/modules/adr/module.toml +19 -1
- package/modules/audit/module.toml +1 -0
- package/modules/backlog/gates/ids.toml +33 -0
- package/modules/backlog/module.toml +61 -1
- package/modules/ci/module.toml +1 -0
- package/modules/concurrency/module.toml +2 -0
- package/modules/design-sync/module.toml +2 -0
- package/modules/doc-authority/module.toml +4 -0
- package/modules/findings/module.toml +3 -0
- package/modules/gates/gates/structural.toml +61 -17
- package/modules/gates/module.toml +5 -0
- package/modules/instructions/module.toml +4 -0
- package/modules/release/gates/release.toml +43 -0
- package/modules/release/module.toml +16 -1
- package/modules/release/skills/cut-release/SKILL.md +8 -1
- package/modules/session/module.toml +4 -2
- package/modules/skills/module.toml +3 -0
- package/modules/specs/module.toml +4 -0
- package/modules/workflows/module.toml +2 -0
- package/package.json +1 -1
- package/src/add.ts +64 -2
- package/src/backlog.ts +182 -0
- package/src/check.ts +47 -4
- package/src/cli.ts +297 -27
- package/src/engines.ts +261 -13
- package/src/engines2.ts +69 -3
- package/src/engines3.ts +147 -0
- package/src/explain.ts +189 -0
- package/src/lifecycle.ts +90 -3
- package/src/manifest.ts +13 -1
- package/src/selftest.ts +221 -0
- package/src/types.ts +34 -0
package/src/cli.ts
CHANGED
|
@@ -3,11 +3,14 @@ import { fileURLToPath } from 'node:url';
|
|
|
3
3
|
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { auditModules, loadAllModules } from './manifest.ts';
|
|
5
5
|
import { detect, scanRepo } from './detect.ts';
|
|
6
|
-
import { addModule, adoptableGates, registerGates, resolveInstallOrder, writeInstallRecord } from './add.ts';
|
|
6
|
+
import { addModule, adoptableGates, blockedByParadigm, registerGates, resolveInstallOrder, writeInstallRecord } from './add.ts';
|
|
7
7
|
import { render, writeReport, type Harness } from './render.ts';
|
|
8
8
|
import { resolveParams } from './substitute.ts';
|
|
9
|
-
import { appendLedger, ledgerQuestions, loadRegistry, runGates } from './check.ts';
|
|
9
|
+
import { appendLedger, type GateRun, ledgerQuestions, loadRegistry, runGates, UnknownTierError } from './check.ts';
|
|
10
10
|
import { applyUpgrade, eject, planUpgrade, PROFILES, readRecord, setupGit } from './lifecycle.ts';
|
|
11
|
+
import { explain, IN_SCOPE as EXPLAINABLE } from './explain.ts';
|
|
12
|
+
import { applyArchive, planArchive } from './backlog.ts';
|
|
13
|
+
import { existsSync } from 'node:fs';
|
|
11
14
|
import type { DetectResult, Manifest } from './types.ts';
|
|
12
15
|
|
|
13
16
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
@@ -65,7 +68,7 @@ function cmdModules(showParams = false) {
|
|
|
65
68
|
const issues = auditModules(mods);
|
|
66
69
|
console.log();
|
|
67
70
|
if (issues.length === 0) {
|
|
68
|
-
console.log(c.green(' audit clean') + c.dim(' — every parameter accounted for
|
|
71
|
+
console.log(c.green(' audit clean') + c.dim(' — every parameter accounted for; every gate has a table, a why, and a declared applicability'));
|
|
69
72
|
} else {
|
|
70
73
|
console.log(c.red(` ${issues.length} issue(s):`));
|
|
71
74
|
for (const i of issues) console.log(` ${c.yellow(i.module)} ${c.dim(i.kind)} — ${i.detail}`);
|
|
@@ -74,7 +77,7 @@ function cmdModules(showParams = false) {
|
|
|
74
77
|
return issues.length === 0 ? 0 : 1;
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
function cmdDoctor(target: string) {
|
|
80
|
+
function cmdDoctor(target: string, doExplain = false) {
|
|
78
81
|
const root = resolve(target);
|
|
79
82
|
const mods = loadAllModules(MODULES);
|
|
80
83
|
console.log(c.bold(`\nrungs doctor — ${root}\n`));
|
|
@@ -153,6 +156,11 @@ function cmdDoctor(target: string) {
|
|
|
153
156
|
console.log(c.dim(' system is good, complete, or working — only that files are where a'));
|
|
154
157
|
console.log(c.dim(" module's files would be. Signatures under-detect on purpose.\n"));
|
|
155
158
|
|
|
159
|
+
reportLedger(root);
|
|
160
|
+
|
|
161
|
+
if (doExplain) reportExplain(mods, results, root, files);
|
|
162
|
+
else advertiseAnalysis(results);
|
|
163
|
+
|
|
156
164
|
// `doctor` is the command the README makes the entry point, and it used to stop on the sentence
|
|
157
165
|
// above — fifteen `absent` lines and nothing to do next. The recommendation is deliberately a
|
|
158
166
|
// **single** command, and never the maximal one: the brief names selling rung 5 to a rung-1 repo
|
|
@@ -186,6 +194,101 @@ function firstSentence(s: string): string {
|
|
|
186
194
|
return s.trim().replace(/\s+/g, ' ').split(/(?<=\.)\s/)[0];
|
|
187
195
|
}
|
|
188
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Say that the analysis exists, and how much of it there is. Never what it
|
|
199
|
+
* found (WI-049).
|
|
200
|
+
*
|
|
201
|
+
* `--explain` is the capability both external reviews called the strongest
|
|
202
|
+
* thing here, and plain `doctor` printed no occurrence of the string `explain`
|
|
203
|
+
* — it was reachable only from `--help`. WI-038 put the *findings* behind a flag
|
|
204
|
+
* for a measured reason: 114 on `hexguard` would bury the `Next` line that
|
|
205
|
+
* WI-005 exists to protect. The flag was never the problem; the silence was.
|
|
206
|
+
*
|
|
207
|
+
* **It reports scope, not findings, and it runs no engine.** The first version
|
|
208
|
+
* printed a finding count, which meant running the detectors on the plain path.
|
|
209
|
+
* Measured on `rift-forge` 2026-08-16: plain `doctor` went from **1.6s to
|
|
210
|
+
* 16.8s** warm — a 10× tax on the entry point to advertise a flag. WI-049's
|
|
211
|
+
* plan named this outcome in advance and named this fallback.
|
|
212
|
+
*
|
|
213
|
+
* So the number is the one detection already computed. It claims what it can
|
|
214
|
+
* prove: these are things the repo has, and our checks can read them. It does
|
|
215
|
+
* not claim anything was found, because finding out costs the 15 seconds.
|
|
216
|
+
*/
|
|
217
|
+
function advertiseAnalysis(results: DetectResult[]) {
|
|
218
|
+
const inScope = results.filter((r) => EXPLAINABLE.has(r.state)).length;
|
|
219
|
+
if (!inScope) return;
|
|
220
|
+
|
|
221
|
+
console.log(c.bold(' Analysis\n'));
|
|
222
|
+
console.log(` ${inScope} of these are things this repo already has, and can be checked against it.`);
|
|
223
|
+
console.log(` ${c.cyan('rungs doctor --explain')} ${c.dim('— evidenced findings, and the incident behind each check')}\n`);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The defect half of `doctor` (WI-038). Every line carries a path and a count
|
|
228
|
+
* or a quote; there is no score, grade, bar, or maturity label anywhere, and
|
|
229
|
+
* there is not going to be — ADR-0005 tier C refuses composites permanently,
|
|
230
|
+
* and a single word over incommensurable signals is the purest form of the
|
|
231
|
+
* probe-encoding-a-guess the corpus warns about.
|
|
232
|
+
*
|
|
233
|
+
* The incident is attached to each detector rather than to each finding: it is
|
|
234
|
+
* why the check exists, not what was found, and repeating it per row would bury
|
|
235
|
+
* the evidence under the provenance.
|
|
236
|
+
*/
|
|
237
|
+
function reportExplain(mods: Manifest[], results: DetectResult[], root: string, files: string[]) {
|
|
238
|
+
const { reported, skipped, scope } = explain(mods, results, root, files);
|
|
239
|
+
|
|
240
|
+
console.log(c.bold(' What it also checked\n'));
|
|
241
|
+
|
|
242
|
+
if (!scope.length) {
|
|
243
|
+
console.log(c.dim(' Nothing — detectors run only over what this repo already has, and'));
|
|
244
|
+
console.log(c.dim(' detection found no equivalent of any module. There is nothing here to'));
|
|
245
|
+
console.log(c.dim(' check that would not be checking our conventions against your repo.\n'));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const total = reported.reduce((n, r) => n + r.findings.length, 0);
|
|
250
|
+
console.log(
|
|
251
|
+
c.dim(` ran the detectors for ${scope.length} module(s) this repo already has: `) + c.dim(scope.join(' ')) + '\n',
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
for (const r of reported) {
|
|
255
|
+
const n = r.findings.length;
|
|
256
|
+
console.log(` ${c.yellow(r.gate.padEnd(34))} ${c.bold(String(n))} ${n === 1 ? 'finding' : 'findings'}`);
|
|
257
|
+
for (const f of r.findings.slice(0, 4)) {
|
|
258
|
+
console.log(c.dim(` ${f.file ? `${f.file}: ` : ''}${f.message}`));
|
|
259
|
+
}
|
|
260
|
+
if (n > 4) console.log(c.dim(` …and ${n - 4} more`));
|
|
261
|
+
if (r.why) console.log(c.dim(` why: ${firstSentence(r.why)}`));
|
|
262
|
+
console.log();
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!total) {
|
|
266
|
+
console.log(c.dim(' No detector fired. That is not a clean bill of health — see below.\n'));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Pins. ADR-0005's rule that green must never read as verified applies with
|
|
270
|
+
// more force here than in the ledger: this pass runs our checks over content
|
|
271
|
+
// written to somebody else's conventions, and the honest failure mode is a
|
|
272
|
+
// sound finding in a frame the repo never adopted.
|
|
273
|
+
console.log(c.dim(' This is not an audit, and it is deliberately incomplete:'));
|
|
274
|
+
console.log(c.dim(' · Detectors ran only for modules this repo already has an equivalent of.'));
|
|
275
|
+
console.log(c.dim(" · They read rungs-shaped inputs. A finding may be true and framed against"));
|
|
276
|
+
console.log(c.dim(' a convention you never adopted — that is our defect, not yours.'));
|
|
277
|
+
if (skipped.command) {
|
|
278
|
+
console.log(c.dim(` · ${skipped.command} command gate(s) not run. rungs does not execute commands in a repo it is only reading.`));
|
|
279
|
+
}
|
|
280
|
+
if (skipped.undeclared.length) {
|
|
281
|
+
console.log(c.dim(` · ${skipped.undeclared.length} gate(s) never said whether they can read a repo like yours, so they did not: ${skipped.undeclared.join(' ')}`));
|
|
282
|
+
}
|
|
283
|
+
if (skipped.unimplemented.length) {
|
|
284
|
+
console.log(c.dim(` · ${skipped.unimplemented.length} declared gate(s) have no engine and were skipped, never passed: ${skipped.unimplemented.join(' ')}`));
|
|
285
|
+
}
|
|
286
|
+
for (const e of skipped.errored) {
|
|
287
|
+
console.log(c.dim(` · ${e.gate} could not run here (${e.message}) — a fact about this pass, not about your repo.`));
|
|
288
|
+
}
|
|
289
|
+
console.log();
|
|
290
|
+
}
|
|
291
|
+
|
|
189
292
|
function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
|
|
190
293
|
const mods = loadAllModules(MODULES);
|
|
191
294
|
const { order, missing } = resolveInstallOrder(names, mods);
|
|
@@ -222,9 +325,74 @@ function cmdAdd(names: string[], root: string, dryRun: boolean, harnesses: Harne
|
|
|
222
325
|
console.log(c.bold(`\nrungs add ${names.join(' ')} → ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
|
|
223
326
|
if (pulled.length) console.log(c.dim(` pulled in by dependency: ${pulled.map((m) => m.name).join(', ')}\n`));
|
|
224
327
|
|
|
328
|
+
// ADR-0004 state 5: a repo that solves this module's problem a different way
|
|
329
|
+
// gets the comparison and a stop, not an install beside what it already runs.
|
|
330
|
+
//
|
|
331
|
+
// The state existed in the ADR and in `doctor` and nowhere else, so `add`
|
|
332
|
+
// wrote straight over it — for every paradigm, since the CLI shipped
|
|
333
|
+
// (WI-043, from F-014). Measured 2026-08-16: a repo with `.github/ISSUE_TEMPLATE/`
|
|
334
|
+
// reported `backlog paradigm · external-tracker`, and `add backlog` then wrote
|
|
335
|
+
// `docs/`, `AGENTS.md`, `.ai/` and 12 gates without mentioning it once.
|
|
336
|
+
//
|
|
337
|
+
// Unlike `--confirm-threshold` above, this refusal **also applies under
|
|
338
|
+
// `--dry-run`**. A preview that installs what the real run refuses is a
|
|
339
|
+
// preview of a different command.
|
|
340
|
+
const scanned = scanRepo(root);
|
|
341
|
+
const paradigms = new Set(
|
|
342
|
+
order.map((m) => detect(m, root, scanned)).filter((r) => r.state === 'paradigm').map((r) => r.module),
|
|
343
|
+
);
|
|
344
|
+
const overridden = flags.has('--confirm-paradigm');
|
|
345
|
+
const blocked = overridden ? new Map<string, string>() : blockedByParadigm(order, paradigms);
|
|
346
|
+
|
|
347
|
+
// An override that prints nothing is indistinguishable from a detection that
|
|
348
|
+
// found nothing, and the two want opposite follow-ups.
|
|
349
|
+
if (overridden && paradigms.size) {
|
|
350
|
+
for (const name of paradigms) {
|
|
351
|
+
const p = detect(order.find((m) => m.name === name)!, root, scanned).paradigm!;
|
|
352
|
+
console.log(
|
|
353
|
+
c.yellow(` ${name}: installing over an existing ${p.id}`) +
|
|
354
|
+
c.dim(` (${p.matched[0]}) — --confirm-paradigm`),
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
console.log(c.dim(' You will have two systems for one job. That is a choice, not a merge.\n'));
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Re-resolve from what survives rather than filtering `order` in place. A
|
|
361
|
+
// dependency is only ever pulled in *for* something; `add backlog` on an
|
|
362
|
+
// issue-tracker repo was still writing `instructions` and `gates`, which
|
|
363
|
+
// nobody asked for and which were pulled in solely for the module being
|
|
364
|
+
// refused. Recomputing the closure drops them, and keeps anything a *surviving*
|
|
365
|
+
// request still needs.
|
|
366
|
+
let toInstall = order;
|
|
367
|
+
if (blocked.size) {
|
|
368
|
+
for (const mod of order) {
|
|
369
|
+
const cause = blocked.get(mod.name);
|
|
370
|
+
if (!cause) continue;
|
|
371
|
+
if (cause === mod.name) {
|
|
372
|
+
const p = detect(mod, root, scanned).paradigm!;
|
|
373
|
+
console.log(c.yellow(` ${mod.name}: this repo already does this another way — ${p.id}`));
|
|
374
|
+
console.log(c.dim(` matched ${p.matched[0]}`));
|
|
375
|
+
for (const line of (p.note ?? '').trim().split('\n')) console.log(c.dim(` ${line || ''}`));
|
|
376
|
+
if (p.compare) console.log(c.dim(` compare: ${p.compare}`));
|
|
377
|
+
} else {
|
|
378
|
+
console.log(c.yellow(` ${mod.name}: not installed — it requires ${cause}.`));
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
toInstall = resolveInstallOrder(names.filter((n) => !blocked.has(n)), mods).order;
|
|
382
|
+
const dropped = order.filter((m) => !toInstall.includes(m) && !blocked.has(m.name));
|
|
383
|
+
if (dropped.length) {
|
|
384
|
+
console.log(c.dim(` ${dropped.map((m) => m.name).join(', ')} not written — pulled in only for the above`));
|
|
385
|
+
}
|
|
386
|
+
console.log(
|
|
387
|
+
c.dim(`\n Pass --confirm-paradigm to install anyway.`) +
|
|
388
|
+
(toInstall.length ? c.dim(' Continuing with the rest.\n') : c.dim(' Nothing was written.\n')),
|
|
389
|
+
);
|
|
390
|
+
if (!toInstall.length) return 1;
|
|
391
|
+
}
|
|
392
|
+
|
|
225
393
|
const installed: Manifest[] = [];
|
|
226
394
|
const wrote = new Map<string, Set<string>>();
|
|
227
|
-
for (const mod of
|
|
395
|
+
for (const mod of toInstall) {
|
|
228
396
|
if (mod.threshold?.confirm && !dryRun && !flags.has('--confirm-threshold')) {
|
|
229
397
|
console.log(
|
|
230
398
|
c.yellow(` ${mod.name}: requires ${mod.threshold.minimum}+ ${mod.threshold.metric}.`) +
|
|
@@ -300,9 +468,35 @@ function cmdRender(root: string, harnesses: Harness[], stamp: string) {
|
|
|
300
468
|
}
|
|
301
469
|
|
|
302
470
|
function cmdCheck(root: string, tier: string | undefined, stamp: string) {
|
|
303
|
-
|
|
471
|
+
let runs: GateRun[];
|
|
472
|
+
try {
|
|
473
|
+
runs = runGates(root, tier);
|
|
474
|
+
} catch (e) {
|
|
475
|
+
// ADR-0008. A tier nobody declared used to select nothing and exit as though
|
|
476
|
+
// the gates had passed — the one failure mode a release step cannot have.
|
|
477
|
+
if (!(e instanceof UnknownTierError)) throw e;
|
|
478
|
+
console.log(c.yellow(`\n unknown tier "${e.requested}"`) + c.dim(` — this repo declares ${e.declared.join(', ')}.`));
|
|
479
|
+
console.log(c.dim(' Nothing ran. Use `rungs check` to run every registered gate.\n'));
|
|
480
|
+
return 1;
|
|
481
|
+
}
|
|
304
482
|
if (!runs.length) {
|
|
305
|
-
|
|
483
|
+
// Two situations printed the same sentence, and it was the wrong one for the case that
|
|
484
|
+
// actually happens: a registry full of `fast` gates filtered by `--full` asked "is this a
|
|
485
|
+
// rungs repo?" about a repo holding 25 of them, and `cut-release` told every consumer to
|
|
486
|
+
// gate a release on exactly that command (F-020). Blame the filter when there is one.
|
|
487
|
+
//
|
|
488
|
+
// Hooks are excluded because a hook fires on a tool call rather than in the runner: it is
|
|
489
|
+
// registered, and no tier value could ever have selected it. Counting it here would offer
|
|
490
|
+
// the reader a gate that changing the tier cannot reach.
|
|
491
|
+
const runnable = loadRegistry(root).gates.filter((g) => !g.trigger);
|
|
492
|
+
if (runnable.length && tier) {
|
|
493
|
+
const tiers = [...new Set(runnable.map((g) => g.tier).filter(Boolean))];
|
|
494
|
+
console.log(c.yellow(`\n no gates in the ${tier} tier — ${runnable.length} are registered`) +
|
|
495
|
+
c.dim(` (${tiers.length ? tiers.join(', ') : 'none tiered'}).`));
|
|
496
|
+
console.log(c.dim(' Nothing ran. Use `rungs check` to run every registered gate.\n'));
|
|
497
|
+
} else {
|
|
498
|
+
console.log(c.yellow('\n no gates registered — is this a rungs repo?\n'));
|
|
499
|
+
}
|
|
306
500
|
return 1;
|
|
307
501
|
}
|
|
308
502
|
appendLedger(root, runs, stamp);
|
|
@@ -334,25 +528,82 @@ function cmdCheck(root: string, tier: string | undefined, stamp: string) {
|
|
|
334
528
|
);
|
|
335
529
|
}
|
|
336
530
|
|
|
531
|
+
console.log();
|
|
532
|
+
return n('fail') + n('unimplemented') + n('error') > 0 ? 1 : 0;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* ADR-0005 tier B: the two questions the ledger can ask without judgement.
|
|
537
|
+
*
|
|
538
|
+
* This printed from `check` and belonged in `doctor`, which is what both the
|
|
539
|
+
* ADR and the README say (F-012). The ADR does not merely name the command, it
|
|
540
|
+
* gives the reason: *"They must be pull (`doctor`), never push; no output
|
|
541
|
+
* during normal runs."* `check` is the normal run — it is what CI and every
|
|
542
|
+
* pre-merge habit invoke — so printing there was the push the tier was written
|
|
543
|
+
* to forbid, arriving inside the feature that forbade it.
|
|
544
|
+
*/
|
|
545
|
+
function reportLedger(root: string) {
|
|
337
546
|
const { gates } = loadRegistry(root);
|
|
338
547
|
const q = ledgerQuestions(root, gates);
|
|
339
|
-
if (q.neverFired.length
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
346
|
-
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim('Red by default is a gate people learn to bypass.')}`);
|
|
347
|
-
}
|
|
348
|
-
console.log(
|
|
349
|
-
c.dim('\n These are questions, not verdicts. The ledger records whether a gate ran'),
|
|
350
|
-
);
|
|
351
|
-
console.log(c.dim(' and whether it fired — never whether it is valuable. Gates invoked'));
|
|
352
|
-
console.log(c.dim(' directly, and CI runs, are not counted.'));
|
|
548
|
+
if (!q.neverFired.length && !q.alwaysFires.length) return;
|
|
549
|
+
|
|
550
|
+
console.log(c.bold(` Ledger questions ${c.dim(`(${q.runs} recorded runs)`)}`));
|
|
551
|
+
for (const g of q.neverFired.slice(0, 3)) {
|
|
552
|
+
console.log(` ${c.cyan(g.id)} has never fired. ${c.dim(firstSentence(g.why ?? ''))}`);
|
|
553
|
+
console.log(c.dim(' Is that still a risk here, or is the gate scoped too narrowly?'));
|
|
353
554
|
}
|
|
354
|
-
|
|
355
|
-
|
|
555
|
+
for (const g of q.alwaysFires.slice(0, 3)) {
|
|
556
|
+
console.log(` ${c.cyan(g.id)} fails ${g.rate}. ${c.dim('Red by default is a gate people learn to bypass.')}`);
|
|
557
|
+
}
|
|
558
|
+
console.log(c.dim('\n These are questions, not verdicts. The ledger records whether a gate ran'));
|
|
559
|
+
console.log(c.dim(' and whether it fired — never whether it is valuable. Gates invoked'));
|
|
560
|
+
console.log(c.dim(' directly, and CI runs, are not counted.\n'));
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function cmdBacklogArchive(root: string, dryRun: boolean) {
|
|
564
|
+
const record = readRecord(root);
|
|
565
|
+
const configured = record?.modules['backlog']?.params?.root;
|
|
566
|
+
const backlogRoot = `docs/${configured ?? 'backlog'}`;
|
|
567
|
+
|
|
568
|
+
if (!existsSync(join(root, ...backlogRoot.split('/'), 'items'))) {
|
|
569
|
+
console.log(c.red(`\n no backlog at ${backlogRoot}/items\n`));
|
|
570
|
+
return 1;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const plan = planArchive(root, backlogRoot);
|
|
574
|
+
console.log(c.bold(`\nrungs backlog archive → ${root}${dryRun ? c.yellow(' (dry run)') : ''}\n`));
|
|
575
|
+
|
|
576
|
+
for (const h of plan.held) console.log(c.yellow(` held ${h.file}`) + c.dim(` — ${h.reason}`));
|
|
577
|
+
if (plan.held.length) console.log();
|
|
578
|
+
|
|
579
|
+
if (!plan.moves.length) {
|
|
580
|
+
console.log(c.dim(' nothing to archive — no item is done or rejected.\n'));
|
|
581
|
+
return 0;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const byStatus = new Map<string, number>();
|
|
585
|
+
for (const m of plan.moves) byStatus.set(m.status, (byStatus.get(m.status) ?? 0) + 1);
|
|
586
|
+
console.log(
|
|
587
|
+
` ${c.bold(String(plan.moves.length))} item(s) — ${[...byStatus].map(([s, n]) => `${n} ${s}`).join(' · ')}`,
|
|
588
|
+
);
|
|
589
|
+
for (const m of plan.moves.slice(0, 5)) console.log(c.dim(` ${m.from} → ${m.to}`));
|
|
590
|
+
if (plan.moves.length > 5) console.log(c.dim(` …and ${plan.moves.length - 5} more`));
|
|
591
|
+
|
|
592
|
+
const touched = plan.rewrites.filter((r) => r.links);
|
|
593
|
+
const links = touched.reduce((n, r) => n + r.links, 0);
|
|
594
|
+
console.log(`\n ${c.bold(String(links))} link(s) repointed across ${touched.length} file(s)`);
|
|
595
|
+
for (const r of touched.slice(0, 5)) console.log(c.dim(` ${r.file} (${r.links})`));
|
|
596
|
+
if (touched.length > 5) console.log(c.dim(` …and ${touched.length - 5} more`));
|
|
597
|
+
|
|
598
|
+
if (dryRun) {
|
|
599
|
+
console.log(c.dim('\n Nothing written. Drop --dry-run to apply.\n'));
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
applyArchive(root, plan);
|
|
604
|
+
console.log(c.green(`\n archived ${plan.moves.length} item(s)`) + c.dim(' — ids stay spent and every citation still resolves.'));
|
|
605
|
+
console.log(c.dim(' Run `rungs check` to confirm.\n'));
|
|
606
|
+
return 0;
|
|
356
607
|
}
|
|
357
608
|
|
|
358
609
|
function cmdInit(root: string, profile: string, dryRun: boolean, harnesses: Harness[], stamp: string) {
|
|
@@ -395,9 +646,18 @@ function cmdUpgrade(root: string, apply: boolean) {
|
|
|
395
646
|
}
|
|
396
647
|
}
|
|
397
648
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
649
|
+
// Not `apply && stale`. A module version that only adds a gate has no stale
|
|
650
|
+
// file, so the whole apply step was skipped and the registry silently kept the
|
|
651
|
+
// old block — F-016, measured on a scratch consumer where `session` 1.1.0 →
|
|
652
|
+
// 1.2.0 added a gate and `rungs check` went on running the previous twenty.
|
|
653
|
+
if (apply) {
|
|
654
|
+
const { written, gates, recorded } = applyUpgrade(root, mods, record, plan);
|
|
655
|
+
const parts = [
|
|
656
|
+
written ? `${written} file(s)` : '',
|
|
657
|
+
gates ? `${gates} gate registration(s)` : '',
|
|
658
|
+
recorded ? `${recorded} record line(s)` : '',
|
|
659
|
+
].filter(Boolean);
|
|
660
|
+
console.log(c.green(`\n updated ${parts.length ? parts.join(' · ') : 'nothing'}`));
|
|
401
661
|
}
|
|
402
662
|
console.log(
|
|
403
663
|
`\n ${stale} to update · ${diverged} diverged\n` +
|
|
@@ -457,11 +717,14 @@ const COMMANDS: [usage: string, blurb: string][] = [
|
|
|
457
717
|
['eject [path]', 'materialise the engines; stop depending on rungs'],
|
|
458
718
|
['setup git [path]', 'install the merge drivers .gitattributes names'],
|
|
459
719
|
['modules', 'list the module set and audit the manifests'],
|
|
720
|
+
['backlog archive [path]', 'move finished items to archive/, repointing every link'],
|
|
460
721
|
];
|
|
461
722
|
|
|
462
723
|
/** Every flag the parser honours. A flag absent here is a flag nobody can find. */
|
|
463
724
|
const FLAGS: [flag: string, blurb: string][] = [
|
|
464
725
|
['--dry-run', 'report what would happen, write nothing'],
|
|
726
|
+
['--explain', "doctor: also run the detectors over what this repo already has"],
|
|
727
|
+
['--confirm-paradigm', 'add: install a module this repo already solves another way'],
|
|
465
728
|
['--into <path>', 'add: install into this repo instead of the working directory'],
|
|
466
729
|
['--set m.param=value', 'add/init: override a module parameter. Repeatable'],
|
|
467
730
|
['--confirm-threshold', 'add: install a module whose rung is above this repo'],
|
|
@@ -547,7 +810,14 @@ switch (cmd) {
|
|
|
547
810
|
case 'modules':
|
|
548
811
|
process.exit(cmdModules(flags.has('--params')));
|
|
549
812
|
case 'doctor':
|
|
550
|
-
process.exit(cmdDoctor(args[0] ?? process.cwd()));
|
|
813
|
+
process.exit(cmdDoctor(args[0] ?? process.cwd(), flags.has('--explain')));
|
|
814
|
+
case 'backlog': {
|
|
815
|
+
if (args[0] !== 'archive') {
|
|
816
|
+
console.log(c.red(`\n unknown: rungs backlog ${args[0] ?? ''}`) + c.dim('\n The only subcommand is `archive`.\n'));
|
|
817
|
+
process.exit(1);
|
|
818
|
+
}
|
|
819
|
+
process.exit(cmdBacklogArchive(resolve(args[1] ?? process.cwd()), flags.has('--dry-run')));
|
|
820
|
+
}
|
|
551
821
|
case 'check': {
|
|
552
822
|
const tier = args[1] ?? (flags.has('--full') ? 'full' : flags.has('--fast') ? 'fast' : undefined);
|
|
553
823
|
process.exit(cmdCheck(resolve(args[0] ?? process.cwd()), tier, STAMP));
|