@rungs/cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +6 -6
  2. package/dist/cli.js +2194 -488
  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 +40 -32
  62. package/src/engines2.ts +424 -29
  63. package/src/engines3.ts +115 -23
  64. package/src/explain.ts +3 -7
  65. package/src/help.ts +43 -0
  66. package/src/lifecycle.ts +95 -31
  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';
2
- import { execSync } from 'node:child_process';
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
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]);
@@ -344,12 +354,394 @@ export const crossReference: Engine = (t, root, files) => {
344
354
  * this repo does not use — it deletes branches on merge — and it is the
345
355
  * direction to be wrong in, because the alternative is the daily false positive.
346
356
  */
357
+ /**
358
+ * `git` as an argv array, never a shell string.
359
+ *
360
+ * `--format=%(refname:short)` is a **bash syntax error** — unquoted parentheses —
361
+ * so `backlog-merged-status` threw on every Linux and macOS repo, hit its catch,
362
+ * and reported "cannot read git branches; status not reconciled" as a finding.
363
+ * The gate ships in four of five profiles and had never once worked off Windows,
364
+ * where `execSync` goes through cmd.exe and parentheses are ordinary characters.
365
+ * Found by the CI matrix on its first run (F-033).
366
+ *
367
+ * Branch names come out of work-item frontmatter, so this is also the difference
368
+ * between reading a field and passing it to a shell.
369
+ */
370
+ const gitArgs = (root: string, args: string[]) =>
371
+ execFileSync('git', args, { cwd: root, stdio: 'pipe' }).toString().trim();
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
+
347
739
  function landedWork(root: string, branch: string, base: string): boolean {
348
- const git = (cmd: string) => execSync(`git ${cmd}`, { cwd: root, stdio: 'pipe' }).toString().trim();
740
+ const git = (...args: string[]) => gitArgs(root, args);
349
741
  try {
350
- const tip = git(`rev-parse ${branch}`);
351
- if (tip === git(`rev-parse ${base}`)) return false;
352
- return git(`log ${base} --merges --format=%P`)
742
+ const tip = git('rev-parse', branch);
743
+ if (tip === git('rev-parse', base)) return false;
744
+ return git('log', base, '--merges', '--format=%P')
353
745
  .split('\n')
354
746
  .some((line) => line.trim().split(/\s+/).slice(1).includes(tip));
355
747
  } catch {
@@ -362,13 +754,14 @@ function landedWork(root: string, branch: string, base: string): boolean {
362
754
  export const gitStatusReconcile: Engine = (t, root, files) => {
363
755
  const findings: Finding[] = [];
364
756
  let merged: Set<string>;
757
+ let integrationRef: string;
365
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;
366
763
  merged = new Set(
367
- execSync(`git branch --merged ${t.integration_branch ?? 'main'} --format=%(refname:short)`, {
368
- cwd: root,
369
- stdio: 'pipe',
370
- })
371
- .toString()
764
+ gitArgs(root, ['branch', '--merged', integrationRef, '--format=%(refname:short)'])
372
765
  .split('\n')
373
766
  .map((s) => s.trim())
374
767
  .filter(Boolean),
@@ -388,7 +781,7 @@ export const gitStatusReconcile: Engine = (t, root, files) => {
388
781
  if (
389
782
  merged.has(branch) &&
390
783
  (t.pre_review_statuses ?? []).includes(status) &&
391
- landedWork(root, branch, t.integration_branch ?? 'main')
784
+ landedWork(root, branch, integrationRef)
392
785
  ) {
393
786
  findings.push({ file: rel, message: `branch ${branch} is merged but status is '${status}'` });
394
787
  }
@@ -403,6 +796,7 @@ export const computedClaim: Engine = (t, root, files) => {
403
796
  let examined = 0;
404
797
  for (const spec of specs) {
405
798
  const values = new Map<string, string>();
799
+ let specExamined = 0;
406
800
  // Which files share a version is the repo's judgement, not something to infer
407
801
  // (F-023). The default sources glob `*/package.json`, which is right for a
408
802
  // monorepo released in lockstep and wrong for a sibling that is deliberately
@@ -410,27 +804,28 @@ export const computedClaim: Engine = (t, root, files) => {
410
804
  // package, correctly, and installing the gate would have failed a healthy
411
805
  // layout. So a repo states the exceptions rather than the engine guessing
412
806
  // them, and `all-agree` keeps needing no opinion about which file is right.
413
- 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);
414
811
  for (const src of spec.sources ?? []) {
415
812
  for (const rel of matchAny(files, src.file)) {
416
813
  if (excluded(rel)) continue;
417
- const text = read(root, rel);
418
- let v: string | undefined;
419
- if (src.path && rel.endsWith('.json')) {
420
- try {
421
- v = src.path.split('.').reduce((o: any, k: string) => o?.[k], JSON.parse(text));
422
- } catch {
423
- /* unparseable is not a disagreement */
424
- }
425
- } else if (src.xpath) {
426
- v = text.match(new RegExp(`<${src.xpath.split('//')[1]}>(.*?)<`))?.[1];
427
- }
428
- if (v) {
429
- examined++;
430
- 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;
431
820
  }
821
+ values.set(rel, result.value);
432
822
  }
433
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
+ }
434
829
  const distinct = new Set(values.values());
435
830
  if (spec.rule === 'all-agree' && distinct.size > 1) {
436
831
  // Name the file beside its value. The message used to list the distinct
@@ -444,7 +839,7 @@ export const computedClaim: Engine = (t, root, files) => {
444
839
  message:
445
840
  `${spec.id} disagrees across ${values.size} locations: ${where}` +
446
841
  (spec.autofix ? ` — run \`${spec.autofix}\`` : '') +
447
- (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`.'),
448
843
  });
449
844
  }
450
845
  }