@rungs/cli 0.3.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2184 -478
  3. package/dist/cli.js.map +4 -4
  4. package/modules/README.md +25 -3
  5. package/modules/adr/files/{{path}}/README.md +1 -1
  6. package/modules/adr/gates/adr.toml +1 -1
  7. package/modules/adr/module.toml +1 -1
  8. package/modules/audit/fragments/AGENTS.md +2 -2
  9. package/modules/audit/module.toml +1 -1
  10. package/modules/audit/skills/assess/SKILL.md +1 -1
  11. package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
  12. package/modules/backlog/files/docs/{{root}}/README.md +2 -2
  13. package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
  14. package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
  15. package/modules/backlog/fragments/AGENTS.md +2 -2
  16. package/modules/backlog/module.toml +1 -1
  17. package/modules/backlog/skills/work-item/SKILL.md +1 -1
  18. package/modules/ci/files/{{workflow_path}} +3 -3
  19. package/modules/ci/module.toml +1 -1
  20. package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
  21. package/modules/concurrency/fragments/AGENTS.md +5 -4
  22. package/modules/concurrency/fragments/gitattributes +2 -2
  23. package/modules/concurrency/gates/concurrency.toml +3 -3
  24. package/modules/concurrency/module.toml +1 -1
  25. package/modules/doc-authority/files/{{registry_path}} +1 -1
  26. package/modules/doc-authority/module.toml +1 -1
  27. package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
  28. package/modules/findings/gates/findings.toml +5 -0
  29. package/modules/findings/module.toml +1 -1
  30. package/modules/findings/skills/record-finding/SKILL.md +1 -1
  31. package/modules/gates/files/.ai/gates.toml +1 -1
  32. package/modules/gates/fragments/AGENTS.md +6 -5
  33. package/modules/gates/module.toml +1 -1
  34. package/modules/instructions/files/.ai/rules/README.md +2 -2
  35. package/modules/instructions/files/.ai/rungs.mjs +52 -0
  36. package/modules/instructions/files/AGENTS.md +4 -2
  37. package/modules/instructions/files/CLAUDE.md +1 -1
  38. package/modules/instructions/fragments/AGENTS.md +2 -2
  39. package/modules/instructions/gates/core.toml +2 -2
  40. package/modules/instructions/module.toml +1 -1
  41. package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
  42. package/modules/release/gates/release.toml +169 -17
  43. package/modules/release/module.toml +9 -5
  44. package/modules/release/skills/cut-release/SKILL.md +43 -15
  45. package/modules/session/files/{{archive}}/README.md +1 -1
  46. package/modules/session/files/{{path}} +2 -2
  47. package/modules/session/module.toml +1 -1
  48. package/modules/specs/files/{{path}}/README.md +2 -2
  49. package/modules/specs/module.toml +1 -1
  50. package/modules/workflows/module.toml +1 -1
  51. package/modules/workflows/rules/planning-tiers.md +1 -1
  52. package/package.json +3 -2
  53. package/src/add.ts +204 -48
  54. package/src/backlog.ts +354 -48
  55. package/src/check.ts +54 -33
  56. package/src/cli.ts +196 -69
  57. package/src/concurrency.ts +628 -42
  58. package/src/detect.ts +11 -3
  59. package/src/emitted-path.ts +274 -0
  60. package/src/engine-table.ts +66 -0
  61. package/src/engines.ts +18 -29
  62. package/src/engines2.ts +403 -20
  63. package/src/engines3.ts +111 -20
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +86 -27
  67. package/src/manifest.ts +41 -5
  68. package/src/render.ts +106 -21
  69. package/src/selftest.ts +87 -10
  70. package/src/storage-key.ts +20 -0
  71. package/src/substitute.ts +47 -5
  72. package/src/text.ts +11 -0
  73. package/src/types.ts +16 -3
  74. package/src/version-source.ts +144 -0
package/src/engines2.ts CHANGED
@@ -1,12 +1,16 @@
1
- import { existsSync, readFileSync } from 'node:fs';
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
+ import { TextDecoder } from 'node:util';
5
+ import { resolveEmittedPath } from './emitted-path.ts';
4
6
  import { matchAny } from './glob.ts';
7
+ import { readVersionSource } from './version-source.ts';
5
8
  import type { Engine, Finding } from './engines.ts';
9
+ import { semanticText } from './text.ts';
6
10
 
7
11
  const read = (root: string, rel: string) => {
8
12
  try {
9
- return readFileSync(join(root, rel), 'utf8');
13
+ return semanticText(readFileSync(join(root, rel), 'utf8'));
10
14
  } catch {
11
15
  return '';
12
16
  }
@@ -159,6 +163,12 @@ export const registerSchema: Engine = (t, root, files) => {
159
163
  }
160
164
  for (const row of table.rows) {
161
165
  if (Object.values(row).every((v) => !v || v === '—')) continue;
166
+ // An em dash in the identity column is an explicit no-record row. The
167
+ // generated findings register uses it to keep an empty table legible:
168
+ // `| — | | | *nothing open* | ... |`. Looking only for an entirely
169
+ // blank row made that label turn the placeholder into a malformed real
170
+ // finding on the first `rungs check` in a fresh tracked consumer.
171
+ if (strip(Object.values(row)[0]) === '—') continue;
162
172
  examined++;
163
173
  for (const [key, values] of Object.entries<any>(t.enum ?? {})) {
164
174
  const v = strip(row[key]);
@@ -360,6 +370,372 @@ export const crossReference: Engine = (t, root, files) => {
360
370
  const gitArgs = (root: string, args: string[]) =>
361
371
  execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString().trim();
362
372
 
373
+ export const parseGitPathList = (output: string): string[] =>
374
+ output.split('\0').filter(Boolean);
375
+
376
+ const gitPaths = (root: string, args: string[]) =>
377
+ parseGitPathList(execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString());
378
+
379
+ const gitRefExists = (root: string, ref: string): boolean => {
380
+ try {
381
+ gitArgs(root, ['show-ref', '--verify', '--quiet', ref]);
382
+ return true;
383
+ } catch {
384
+ return false;
385
+ }
386
+ };
387
+
388
+ export type IntegrationRefResolution =
389
+ | { ref: string; finding?: never }
390
+ | { ref?: never; finding: string };
391
+
392
+ /**
393
+ * Resolve a configured branch name without Git's short-name DWIM rules.
394
+ *
395
+ * CI checkouts commonly have `origin/main` but no local `main`. Conversely, a
396
+ * developer checkout can carry both, temporarily at different commits. The
397
+ * precedence here is deliberate and stable as more remotes appear: local,
398
+ * exact `origin`, then a sole matching remote. More than one non-origin match
399
+ * is unknown, not permission to choose whichever ref Git happens to prefer.
400
+ */
401
+ export function resolveIntegrationRef(root: string, branch: string): IntegrationRefResolution {
402
+ const local = `refs/heads/${branch}`;
403
+ if (gitRefExists(root, local)) return { ref: local };
404
+
405
+ const origin = `refs/remotes/origin/${branch}`;
406
+ if (gitRefExists(root, origin)) return { ref: origin };
407
+
408
+ const remotes = gitArgs(root, ['remote'])
409
+ .split('\n')
410
+ .map((remote) => remote.trim())
411
+ .filter((remote) => remote && remote !== 'origin');
412
+ const matches = remotes
413
+ .map((remote) => `refs/remotes/${remote}/${branch}`)
414
+ .filter((ref) => gitRefExists(root, ref))
415
+ .sort();
416
+
417
+ if (matches.length === 1) return { ref: matches[0] };
418
+ if (matches.length > 1) {
419
+ return {
420
+ finding: `integration branch '${branch}' is ambiguous across ${matches.join(', ')}`,
421
+ };
422
+ }
423
+ return {
424
+ finding: `integration branch '${branch}' has no local or remote-tracking ref`,
425
+ };
426
+ }
427
+
428
+ const matchesAny = (rel: string, patterns: string[] | undefined) =>
429
+ (patterns ?? []).some((pattern) => matchAny([rel], pattern).length > 0);
430
+
431
+ const patternList = (value: unknown, allowEmpty = false): value is string[] =>
432
+ Array.isArray(value) && (allowEmpty || value.length > 0) &&
433
+ value.every((pattern) => typeof pattern === 'string' && pattern.trim().length > 0);
434
+
435
+ type ExemptionWrapper = { kind: 'plain' | 'line' | 'block' | 'html' | 'quote'; close?: string };
436
+
437
+ function wrapperCloseAt(reason: string, wrapper: ExemptionWrapper): number {
438
+ if (!wrapper.close) return -1;
439
+ if (wrapper.kind !== 'quote') return reason.indexOf(wrapper.close);
440
+ for (let index = 0; index < reason.length; index++) {
441
+ if (reason[index] === '\\') {
442
+ index++;
443
+ } else if (reason[index] === wrapper.close) {
444
+ return index;
445
+ }
446
+ }
447
+ return -1;
448
+ }
449
+
450
+ /**
451
+ * Extract substantive same-line reasons while retaining wrapper state across
452
+ * the whole document. A marker on the second line of a block, HTML comment or
453
+ * quoted string must still stop at that wrapper's close token; otherwise an
454
+ * adjacent code-only edit would be mistaken for a new reason.
455
+ */
456
+ function exemptionEvidence(text: string, marker: string): string[] {
457
+ const evidence: string[] = [];
458
+ let wrapper: ExemptionWrapper = { kind: 'plain' };
459
+
460
+ for (let index = 0; index < text.length; index++) {
461
+ if (text.startsWith(marker, index)) {
462
+ const reasonAt = index + marker.length;
463
+ const cr = text.indexOf('\r', reasonAt);
464
+ const lf = text.indexOf('\n', reasonAt);
465
+ const lineEnd = cr < 0 ? (lf < 0 ? text.length : lf) :
466
+ (lf < 0 ? cr : Math.min(cr, lf));
467
+ const rawTail = text.slice(reasonAt, lineEnd);
468
+ const leading = rawTail.match(/^[ \t]*/)?.[0].length ?? 0;
469
+ if (/[\p{L}\p{N}]/u.test(rawTail[leading] ?? '')) {
470
+ let reason = rawTail.slice(leading);
471
+ const closeAt = wrapperCloseAt(reason, wrapper);
472
+ if (closeAt >= 0) reason = reason.slice(0, closeAt);
473
+ reason = reason.trimEnd();
474
+ if (reason) evidence.push(reason);
475
+ }
476
+ }
477
+
478
+ if (wrapper.kind === 'line') {
479
+ if (text[index] === '\r' || text[index] === '\n') wrapper = { kind: 'plain' };
480
+ continue;
481
+ }
482
+ if (wrapper.kind === 'block') {
483
+ if (text.startsWith('*/', index)) {
484
+ wrapper = { kind: 'plain' };
485
+ index++;
486
+ }
487
+ continue;
488
+ }
489
+ if (wrapper.kind === 'html') {
490
+ if (text.startsWith('-->', index)) {
491
+ wrapper = { kind: 'plain' };
492
+ index += 2;
493
+ }
494
+ continue;
495
+ }
496
+ if (wrapper.kind === 'quote') {
497
+ if (text[index] === '\\') {
498
+ index++;
499
+ } else if (text[index] === wrapper.close) {
500
+ wrapper = { kind: 'plain' };
501
+ }
502
+ continue;
503
+ }
504
+
505
+ if (text.startsWith('//', index)) {
506
+ wrapper = { kind: 'line' };
507
+ index++;
508
+ } else if (text.startsWith('/*', index)) {
509
+ wrapper = { kind: 'block', close: '*/' };
510
+ index++;
511
+ } else if (text.startsWith('<!--', index)) {
512
+ wrapper = { kind: 'html', close: '-->' };
513
+ index += 3;
514
+ } else if (text[index] === '"' || text[index] === "'" || text[index] === '`') {
515
+ wrapper = { kind: 'quote', close: text[index] };
516
+ }
517
+ }
518
+ return evidence;
519
+ }
520
+
521
+ const utf8 = new TextDecoder('utf-8', { fatal: true });
522
+
523
+ type GitTreeEntry = { mode: string; type: string; oid: string };
524
+
525
+ function gitTreeEntry(root: string, treeish: string, rel: string): GitTreeEntry | undefined {
526
+ const output = execFileSync(
527
+ 'git',
528
+ ['--literal-pathspecs', 'ls-tree', '-z', treeish, '--', rel],
529
+ { cwd: root, stdio: 'pipe' },
530
+ );
531
+ if (!output.length) return undefined;
532
+ const records = output.toString('utf8').split('\0').filter(Boolean);
533
+ if (records.length !== 1) throw new Error(`unexpected tree entry count for ${rel}`);
534
+ const match = records[0].match(/^([0-7]{6}) ([a-z]+) ([0-9a-f]+)\t/);
535
+ if (!match) throw new Error(`cannot parse tree entry for ${rel}`);
536
+ return { mode: match[1], type: match[2], oid: match[3] };
537
+ }
538
+
539
+ function candidateGitModesAreRegular(root: string, rel: string): boolean {
540
+ const output = execFileSync(
541
+ 'git',
542
+ ['--literal-pathspecs', 'ls-files', '--stage', '-z', '--', rel],
543
+ { cwd: root, stdio: 'pipe' },
544
+ );
545
+ const entries = output.toString('utf8').split('\0').filter(Boolean).map((record) => {
546
+ const match = record.match(/^([0-7]{6}) [0-9a-f]+ ([0-3])\t/);
547
+ if (!match) throw new Error(`cannot parse index entry for ${rel}`);
548
+ return { mode: match[1], stage: match[2] };
549
+ });
550
+
551
+ // The index is the current proposed Git object when it has an entry. An
552
+ // absent entry is an untracked path whose canonical filesystem leaf was
553
+ // already proven regular above. HEAD is deliberately irrelevant here: its
554
+ // historical mode must not veto a staged conversion to an ordinary blob.
555
+ return entries.length === 0 ||
556
+ (entries.length === 1 && entries[0].stage === '0' &&
557
+ ['100644', '100755'].includes(entries[0].mode));
558
+ }
559
+
560
+ function candidateExemptionText(root: string, rel: string): string | undefined {
561
+ let resolved;
562
+ try {
563
+ resolved = resolveEmittedPath(root, 'release exemption evidence', rel);
564
+ if (resolved.leafAlias || !lstatSync(resolved.absolute).isFile()) return undefined;
565
+ } catch {
566
+ return undefined;
567
+ }
568
+ if (!candidateGitModesAreRegular(root, rel)) return undefined;
569
+
570
+ const attributes = execFileSync(
571
+ 'git',
572
+ ['check-attr', '-z', 'diff', 'text', 'binary', '--', rel],
573
+ { cwd: root, stdio: 'pipe' },
574
+ ).toString().split('\0');
575
+ for (let index = 0; index + 2 < attributes.length; index += 3) {
576
+ const attribute = attributes[index + 1];
577
+ const value = attributes[index + 2];
578
+ if ((attribute === 'diff' && value === 'unset') || (attribute === 'binary' && value === 'set')) {
579
+ return undefined;
580
+ }
581
+ }
582
+
583
+ try {
584
+ const bytes = readFileSync(resolved.absolute);
585
+ if (bytes.includes(0)) return undefined;
586
+ return utf8.decode(bytes);
587
+ } catch {
588
+ return undefined;
589
+ }
590
+ }
591
+
592
+ function inheritedExemptionEvidence(root: string, mergeBase: string, marker: string): Set<string> {
593
+ let paths = Buffer.alloc(0);
594
+ try {
595
+ paths = execFileSync(
596
+ 'git',
597
+ ['grep', '-I', '-l', '-z', '-F', '-e', marker, mergeBase, '--'],
598
+ { cwd: root, stdio: 'pipe' },
599
+ );
600
+ } catch (error: any) {
601
+ if (error?.status !== 1) throw error;
602
+ paths = Buffer.isBuffer(error?.stdout) ? error.stdout : Buffer.from(error?.stdout ?? '');
603
+ }
604
+
605
+ const prefix = `${mergeBase}:`;
606
+ const inherited = new Set<string>();
607
+ for (const named of utf8.decode(paths).split('\0').filter(Boolean)) {
608
+ if (!named.startsWith(prefix)) throw new Error('cannot parse historical exemption path');
609
+ const rel = named.slice(prefix.length);
610
+ const entry = gitTreeEntry(root, mergeBase, rel);
611
+ if (!entry || entry.type !== 'blob') throw new Error(`cannot resolve historical exemption blob for ${rel}`);
612
+ const bytes = execFileSync('git', ['cat-file', 'blob', entry.oid], { cwd: root, stdio: 'pipe' });
613
+ if (bytes.includes(0)) throw new Error(`historical exemption blob is not text: ${rel}`);
614
+ for (const reason of exemptionEvidence(utf8.decode(bytes), marker)) inherited.add(reason);
615
+ }
616
+ return inherited;
617
+ }
618
+
619
+ /**
620
+ * Return only exemption evidence introduced by the complete current delta.
621
+ *
622
+ * Current files are read only through a canonical, regular-file, UTF-8 text
623
+ * boundary. The caller's complete changed-path set already spans committed,
624
+ * staged, unstaged and non-ignored untracked work, so staging does not select a
625
+ * different evidence path.
626
+ *
627
+ * A reason must be novel relative to every marker/reason line in the merge-base
628
+ * tree. That conservative rule makes the otherwise unknowable copy-vs-coincidence
629
+ * case deterministic in every Git state: reuse or relocation requires rewording
630
+ * the reason to explain why this branch is safe.
631
+ */
632
+ function hasBranchLocalExemption(
633
+ root: string,
634
+ mergeBase: string,
635
+ changed: string[],
636
+ marker: string,
637
+ ): boolean {
638
+ const inherited = inheritedExemptionEvidence(root, mergeBase, marker);
639
+
640
+ return changed.some((rel) => {
641
+ const text = candidateExemptionText(root, rel);
642
+ return text !== undefined && exemptionEvidence(text, marker)
643
+ .some((reason) => !inherited.has(reason));
644
+ });
645
+ }
646
+
647
+ /**
648
+ * Require a changed companion file when a branch changes a configured path.
649
+ *
650
+ * A fragment that merely exists is not evidence for this branch: inherited and
651
+ * deleted fragments must not discharge its release-note obligation. Git state
652
+ * is read as four explicit sets so the verdict is the same before and after a
653
+ * developer stages or commits the work.
654
+ */
655
+ export const changeRequiresFile: Engine = (t, root) => {
656
+ if (!patternList(t.require_when_changed) || !patternList(t.requires_one_of)) {
657
+ return {
658
+ findings: [{
659
+ message: "change-requires-file requires non-empty 'require_when_changed' and 'requires_one_of' pattern arrays",
660
+ }],
661
+ examined: 0,
662
+ };
663
+ }
664
+ if (t.ignore_when_only !== undefined && !patternList(t.ignore_when_only, true)) {
665
+ return {
666
+ findings: [{ message: "change-requires-file 'ignore_when_only' must be an array of non-empty patterns" }],
667
+ examined: 0,
668
+ };
669
+ }
670
+ if (t.exempt_marker !== undefined &&
671
+ (typeof t.exempt_marker !== 'string' || !t.exempt_marker.trim())) {
672
+ return {
673
+ findings: [{ message: "change-requires-file 'exempt_marker' must be a non-empty string when configured" }],
674
+ examined: 0,
675
+ };
676
+ }
677
+
678
+ const baseName = String(t.base_branch ?? 'main');
679
+ let changed: string[];
680
+ let mergeBase: string;
681
+ let untracked: string[];
682
+ try {
683
+ const resolved = resolveIntegrationRef(root, baseName);
684
+ if (!resolved.ref) {
685
+ return {
686
+ findings: [{ message: `${resolved.finding}; required companion file not evaluated` }],
687
+ examined: 0,
688
+ };
689
+ }
690
+ mergeBase = gitArgs(root, ['merge-base', 'HEAD', resolved.ref]);
691
+ untracked = gitPaths(root, ['ls-files', '--others', '--exclude-standard', '-z']);
692
+ changed = [...new Set([
693
+ ...gitPaths(root, ['diff', '--name-only', '--no-renames', '-z', mergeBase, 'HEAD']),
694
+ ...gitPaths(root, ['diff', '--cached', '--name-only', '--no-renames', '-z']),
695
+ ...gitPaths(root, ['diff', '--name-only', '--no-renames', '-z']),
696
+ ...untracked,
697
+ ])].sort();
698
+ } catch {
699
+ return {
700
+ findings: [{ message: `cannot read git changes against '${baseName}'; required companion file not evaluated` }],
701
+ examined: 0,
702
+ };
703
+ }
704
+
705
+ const examined = changed.length;
706
+ const ignore = t.ignore_when_only as string[] | undefined;
707
+ if (ignore?.length && changed.length && changed.every((rel) => matchesAny(rel, ignore))) {
708
+ return { findings: [], examined };
709
+ }
710
+
711
+ if (!changed.some((rel) => matchesAny(rel, t.require_when_changed))) {
712
+ return { findings: [], examined };
713
+ }
714
+
715
+ const companion = changed.find(
716
+ (rel) => matchesAny(rel, t.requires_one_of) && existsSync(join(root, rel)),
717
+ );
718
+ if (companion) return { findings: [], examined };
719
+
720
+ if (t.exempt_marker) {
721
+ try {
722
+ if (hasBranchLocalExemption(root, mergeBase, changed, t.exempt_marker)) {
723
+ return { findings: [], examined };
724
+ }
725
+ } catch {
726
+ return {
727
+ findings: [{ message: `cannot read git exemption provenance against '${baseName}'; required companion file not evaluated` }],
728
+ examined: 0,
729
+ };
730
+ }
731
+ }
732
+
733
+ return {
734
+ findings: [{ message: String(t.message ?? 'changed shipping code requires a companion file').trim() }],
735
+ examined,
736
+ };
737
+ };
738
+
363
739
  function landedWork(root: string, branch: string, base: string): boolean {
364
740
  const git = (...args: string[]) => gitArgs(root, args);
365
741
  try {
@@ -378,9 +754,14 @@ function landedWork(root: string, branch: string, base: string): boolean {
378
754
  export const gitStatusReconcile: Engine = (t, root, files) => {
379
755
  const findings: Finding[] = [];
380
756
  let merged: Set<string>;
757
+ let integrationRef: string;
381
758
  try {
759
+ const integration = String(t.integration_branch ?? 'main');
760
+ const resolved = resolveIntegrationRef(root, integration);
761
+ if (!resolved.ref) return { findings: [{ message: `${resolved.finding}; status not reconciled` }], examined: 0 };
762
+ integrationRef = resolved.ref;
382
763
  merged = new Set(
383
- gitArgs(root, ['branch', '--merged', t.integration_branch ?? 'main', '--format=%(refname:short)'])
764
+ gitArgs(root, ['branch', '--merged', integrationRef, '--format=%(refname:short)'])
384
765
  .split('\n')
385
766
  .map((s) => s.trim())
386
767
  .filter(Boolean),
@@ -400,7 +781,7 @@ export const gitStatusReconcile: Engine = (t, root, files) => {
400
781
  if (
401
782
  merged.has(branch) &&
402
783
  (t.pre_review_statuses ?? []).includes(status) &&
403
- landedWork(root, branch, t.integration_branch ?? 'main')
784
+ landedWork(root, branch, integrationRef)
404
785
  ) {
405
786
  findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
406
787
  }
@@ -415,6 +796,7 @@ export const computedClaim: Engine = (t, root, files) => {
415
796
  let examined = 0;
416
797
  for (const spec of specs) {
417
798
  const values = new Map<string, string>();
799
+ let specExamined = 0;
418
800
  // Which files share a version is the repo's judgement, not something to infer
419
801
  // (F-023). The default sources glob `*/package.json`, which is right for a
420
802
  // monorepo released in lockstep and wrong for a sibling that is deliberately
@@ -422,27 +804,28 @@ export const computedClaim: Engine = (t, root, files) => {
422
804
  // package, correctly, and installing the gate would have failed a healthy
423
805
  // layout. So a repo states the exceptions rather than the engine guessing
424
806
  // them, and `all-agree` keeps needing no opinion about which file is right.
425
- const excluded = (rel: string) => (spec.exclude ?? []).some((p: string) => matchAny([rel], p).length > 0);
807
+ const excludePatterns = (spec.exclude ?? []).filter((pattern: unknown): pattern is string =>
808
+ typeof pattern === 'string' && pattern.trim().length > 0,
809
+ );
810
+ const excluded = (rel: string) => excludePatterns.some((pattern: string) => matchAny([rel], pattern).length > 0);
426
811
  for (const src of spec.sources ?? []) {
427
812
  for (const rel of matchAny(files, src.file)) {
428
813
  if (excluded(rel)) continue;
429
- const text = read(root, rel);
430
- let v: string | undefined;
431
- if (src.path && rel.endsWith('.json')) {
432
- try {
433
- v = src.path.split('.').reduce((o: any, k: string) => o?.[k], JSON.parse(text));
434
- } catch {
435
- /* unparseable is not a disagreement */
436
- }
437
- } else if (src.xpath) {
438
- v = text.match(new RegExp(`<${src.xpath.split('//')[1]}>(.*?)<`))?.[1];
439
- }
440
- if (v) {
441
- examined++;
442
- values.set(rel, String(v));
814
+ examined++;
815
+ specExamined++;
816
+ const result = readVersionSource(root, rel, src);
817
+ if (!result.ok) {
818
+ findings.push({ file: rel, message: `${spec.id} version source ${result.reason}` });
819
+ continue;
443
820
  }
821
+ values.set(rel, result.value);
444
822
  }
445
823
  }
824
+ if (specExamined === 0) {
825
+ findings.push({
826
+ message: `${spec.id} found no configured version sources; check the source globs and exclusions`,
827
+ });
828
+ }
446
829
  const distinct = new Set(values.values());
447
830
  if (spec.rule === 'all-agree' && distinct.size > 1) {
448
831
  // Name the file beside its value. The message used to list the distinct
@@ -456,7 +839,7 @@ export const computedClaim: Engine = (t, root, files) => {
456
839
  message:
457
840
  `${spec.id} disagrees across ${values.size} locations: ${where}` +
458
841
  (spec.autofix ? ` — run \`${spec.autofix}\`` : '') +
459
- (spec.exclude?.length ? '' : '. If one of these is versioned independently, list it in `exclude`.'),
842
+ (excludePatterns.length ? '' : '. If one of these is versioned independently, set `release.version_exclude`.'),
460
843
  });
461
844
  }
462
845
  }
package/src/engines3.ts CHANGED
@@ -3,10 +3,12 @@ import { execFileSync } from 'node:child_process';
3
3
  import { join } from 'node:path';
4
4
  import { matchAny } from './glob.ts';
5
5
  import type { Engine, Finding } from './engines.ts';
6
+ import { readVersionSource } from './version-source.ts';
7
+ import { semanticText } from './text.ts';
6
8
 
7
9
  const read = (root: string, rel: string) => {
8
10
  try {
9
- return readFileSync(join(root, rel), 'utf8');
11
+ return semanticText(readFileSync(join(root, rel), 'utf8'));
10
12
  } catch {
11
13
  return '';
12
14
  }
@@ -34,10 +36,15 @@ export function versionCmp(a: number[], b: number[]): number {
34
36
  * `changelog.d/0.1.1.md` survived two releases and was still there at 0.2.0
35
37
  * preparation (F-022). So the rule becomes mechanical.
36
38
  *
37
- * A fragment is stale when its filename names a version **below** the version
38
- * being prepared. Files whose names are not versions are ignored rather than
39
- * reported: the module's own fixtures use `42.feature.md`, and a gate that
40
- * refuses a naming convention it was not asked about is a gate people disable.
39
+ * The repository also states the last version whose fragments were consumed.
40
+ * That boundary must equal the package version in a steady tree, and a fragment
41
+ * at or below it is stale. Equality is what closes F-025: after a release, the
42
+ * forgotten fragment and package version are equal, so comparing only those two
43
+ * values cannot tell preparation from already-consumed work.
44
+ *
45
+ * Files whose names are not versions are ignored rather than reported: the
46
+ * module's own fixtures use `42.feature.md`, and a gate that refuses a naming
47
+ * convention it was not asked about is a gate people disable.
41
48
  */
42
49
  export const changelogFreshness: Engine = (t, root, files) => {
43
50
  const specs = Array.isArray(t) ? t : [t];
@@ -45,34 +52,119 @@ export const changelogFreshness: Engine = (t, root, files) => {
45
52
  let examined = 0;
46
53
 
47
54
  for (const spec of specs) {
48
- const src = spec.version ?? {};
55
+ const sources = Array.isArray(spec.versions)
56
+ ? spec.versions
57
+ : [spec.version ?? { file: 'package.json', path: 'version' }];
49
58
  let current: number[] | null = null;
50
- for (const rel of matchAny(files, src.file ?? 'package.json')) {
51
- try {
52
- const raw = (src.path ?? 'version')
53
- .split('.')
54
- .reduce((o: any, k: string) => o?.[k], JSON.parse(read(root, rel)));
55
- current = versionParts(String(raw ?? ''));
56
- } catch {
57
- /* unparseable is not a stale fragment */
59
+ let currentSource = '';
60
+ const versionProblems: Finding[] = [];
61
+ for (const source of sources) {
62
+ for (const rel of matchAny(files, source.file ?? 'package.json')) {
63
+ const result = readVersionSource(root, rel, source);
64
+ if (!result.ok) {
65
+ versionProblems.push({ file: rel, message: `release version source ${result.reason}` });
66
+ continue;
67
+ }
68
+ const parsed = versionParts(result.value);
69
+ if (!parsed) {
70
+ versionProblems.push({
71
+ file: rel,
72
+ message: `release version source must contain a three-part numeric version; found ${JSON.stringify(result.value)}`,
73
+ });
74
+ continue;
75
+ }
76
+ current = parsed;
77
+ if (current) {
78
+ currentSource = rel;
79
+ break;
80
+ }
58
81
  }
59
82
  if (current) break;
60
83
  }
61
- // Without a version to compare against there is no claim to make. Saying
62
- // nothing is right; passing loudly would not be.
63
- if (!current) continue;
84
+ const marker = spec.consumed_through ?? 'changelog.d/CONSUMED_THROUGH';
85
+ const markerExists = existsSync(join(root, marker));
86
+ if (!markerExists) {
87
+ findings.push({
88
+ file: marker,
89
+ message:
90
+ `release consumption marker '${marker}' is missing — create it with 'none' if no release has consumed fragments, or the exact last consumed version`,
91
+ });
92
+ continue;
93
+ }
94
+
95
+ examined++;
96
+ const markerRaw = read(root, marker);
97
+ const markerValue = markerRaw.endsWith('\r\n')
98
+ ? markerRaw.slice(0, -2)
99
+ : markerRaw.endsWith('\n')
100
+ ? markerRaw.slice(0, -1)
101
+ : markerRaw;
102
+
103
+ if (markerValue === 'UNINITIALIZED') {
104
+ findings.push({
105
+ file: marker,
106
+ message:
107
+ `release consumption marker '${marker}' is UNINITIALIZED — replace it with 'none' if no release has consumed fragments, or the exact last consumed version`,
108
+ });
109
+ continue;
110
+ }
111
+
112
+ const firstRelease = markerValue === 'none';
113
+ // `versionParts` intentionally trims package metadata. The marker is a
114
+ // one-token schema, so validate its complete content before reusing that
115
+ // parser; whitespace, a BOM or a second newline must not become valid.
116
+ const consumed = firstRelease || !/^\d+\.\d+\.\d+$/.test(markerValue)
117
+ ? null
118
+ : versionParts(markerValue);
119
+ if (!firstRelease && !consumed) {
120
+ findings.push({
121
+ file: marker,
122
+ message:
123
+ `release consumption marker '${marker}' must contain exactly 'none' or a three-part numeric version; found ${JSON.stringify(markerValue)}`,
124
+ });
125
+ continue;
126
+ }
127
+
128
+ findings.push(...versionProblems);
129
+
130
+ // Without a package version there used to be no claim to make. A concrete
131
+ // consumption boundary changes that: its steady-state equality cannot be
132
+ // checked, so reporting green would make the new state assertion vacuous.
133
+ if (!current) {
134
+ if (consumed) {
135
+ findings.push({
136
+ file: sources[0]?.file ?? 'package.json',
137
+ message:
138
+ `cannot reconcile consumed-through ${markerValue} because none of the declared version sources contains a three-part numeric version`,
139
+ });
140
+ }
141
+ continue;
142
+ }
143
+
144
+ if (consumed && versionCmp(consumed, current) !== 0) {
145
+ const relation = versionCmp(consumed, current) < 0 ? 'below' : 'above';
146
+ findings.push({
147
+ file: marker,
148
+ message:
149
+ `release consumption marker names ${markerValue}, ${relation} version ${current.join('.')} in ${currentSource} — they must match in a steady tree; advance both during reversible release preparation`,
150
+ });
151
+ }
64
152
 
65
153
  for (const rel of expand(files, spec.fragments, [])) {
66
154
  const name = rel.split('/').pop()!.replace(/\.md$/, '');
67
155
  const v = versionParts(name);
68
156
  if (!v) continue;
69
157
  examined++;
70
- if (versionCmp(v, current) < 0) {
158
+ const belowPackage = versionCmp(v, current) < 0;
159
+ const alreadyConsumed = consumed ? versionCmp(v, consumed) <= 0 : false;
160
+ if (belowPackage || alreadyConsumed) {
71
161
  findings.push({
72
162
  file: rel,
73
163
  message:
74
164
  spec.message?.trim() ||
75
- `fragment names ${name}, below the ${current.join('.')} being prepared — it was consumed by an earlier release and should have been deleted`,
165
+ (alreadyConsumed
166
+ ? `fragment names ${name}, at or below consumed-through ${markerValue} — it was already assembled and should have been deleted`
167
+ : `fragment names ${name}, below package version ${current.join('.')} — it belongs to an earlier release and should have been deleted`),
76
168
  });
77
169
  }
78
170
  }
@@ -339,4 +431,3 @@ export const boardReconcile: Engine = (t, root, _files) => {
339
431
 
340
432
  return { findings, examined };
341
433
  };
342
-
package/src/explain.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { ENGINES, type Finding } from './engines.ts';
2
- import { loadTable, tableKey } from './check.ts';
2
+ import { loadTable } from './check.ts';
3
3
  import type { DetectResult, Manifest } from './types.ts';
4
+ import { selectEngineTable } from './engine-table.ts';
4
5
 
5
6
  /**
6
7
  * `doctor` answers a *presence* question — which of our modules does this repo
@@ -138,12 +139,7 @@ export function explainWith(
138
139
  }
139
140
 
140
141
  try {
141
- const key = tableKey(g.engine!);
142
- let section = table[key] ?? table;
143
- if (Array.isArray(section) && section.some((s: any) => s?.id)) {
144
- const mine = section.filter((s: any) => !s.id || g.id.includes(s.id));
145
- if (mine.length) section = mine;
146
- }
142
+ const section = selectEngineTable(table, g.engine!, g.id);
147
143
  const r = engines[g.engine!](section, repoRoot, files);
148
144
  if (r.findings.length) {
149
145
  reported.push({ module: mod.name, gate: g.id, why: g.why, findings: r.findings, examined: r.examined });