@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.
- package/README.md +6 -6
- package/dist/cli.js +2184 -478
- package/dist/cli.js.map +4 -4
- package/modules/README.md +25 -3
- package/modules/adr/files/{{path}}/README.md +1 -1
- package/modules/adr/gates/adr.toml +1 -1
- package/modules/adr/module.toml +1 -1
- package/modules/audit/fragments/AGENTS.md +2 -2
- package/modules/audit/module.toml +1 -1
- package/modules/audit/skills/assess/SKILL.md +1 -1
- package/modules/backlog/files/docs/{{root}}/BACKLOG.md +1 -1
- package/modules/backlog/files/docs/{{root}}/README.md +2 -2
- package/modules/backlog/files/docs/{{root}}/archive/README.md +1 -1
- package/modules/backlog/files/docs/{{root}}/items/README.md +1 -1
- package/modules/backlog/fragments/AGENTS.md +2 -2
- package/modules/backlog/module.toml +1 -1
- package/modules/backlog/skills/work-item/SKILL.md +1 -1
- package/modules/ci/files/{{workflow_path}} +3 -3
- package/modules/ci/module.toml +1 -1
- package/modules/concurrency/files/docs/concurrent-sessions.md +66 -18
- package/modules/concurrency/fragments/AGENTS.md +5 -4
- package/modules/concurrency/fragments/gitattributes +2 -2
- package/modules/concurrency/gates/concurrency.toml +3 -3
- package/modules/concurrency/module.toml +1 -1
- package/modules/doc-authority/files/{{registry_path}} +1 -1
- package/modules/doc-authority/module.toml +1 -1
- package/modules/findings/files/docs/{{backlog.root}}/FINDINGS.md +1 -1
- package/modules/findings/gates/findings.toml +5 -0
- package/modules/findings/module.toml +1 -1
- package/modules/findings/skills/record-finding/SKILL.md +1 -1
- package/modules/gates/files/.ai/gates.toml +1 -1
- package/modules/gates/fragments/AGENTS.md +6 -5
- package/modules/gates/module.toml +1 -1
- package/modules/instructions/files/.ai/rules/README.md +2 -2
- package/modules/instructions/files/.ai/rungs.mjs +52 -0
- package/modules/instructions/files/AGENTS.md +4 -2
- package/modules/instructions/files/CLAUDE.md +1 -1
- package/modules/instructions/fragments/AGENTS.md +2 -2
- package/modules/instructions/gates/core.toml +2 -2
- package/modules/instructions/module.toml +1 -1
- package/modules/release/files/{{changelog_dir}}/CONSUMED_THROUGH +1 -0
- package/modules/release/gates/release.toml +169 -17
- package/modules/release/module.toml +9 -5
- package/modules/release/skills/cut-release/SKILL.md +43 -15
- package/modules/session/files/{{archive}}/README.md +1 -1
- package/modules/session/files/{{path}} +2 -2
- package/modules/session/module.toml +1 -1
- package/modules/specs/files/{{path}}/README.md +2 -2
- package/modules/specs/module.toml +1 -1
- package/modules/workflows/module.toml +1 -1
- package/modules/workflows/rules/planning-tiers.md +1 -1
- package/package.json +3 -2
- package/src/add.ts +204 -48
- package/src/backlog.ts +354 -48
- package/src/check.ts +54 -33
- package/src/cli.ts +196 -69
- package/src/concurrency.ts +628 -42
- package/src/detect.ts +11 -3
- package/src/emitted-path.ts +274 -0
- package/src/engine-table.ts +66 -0
- package/src/engines.ts +18 -29
- package/src/engines2.ts +403 -20
- package/src/engines3.ts +111 -20
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +86 -27
- package/src/manifest.ts +41 -5
- package/src/render.ts +106 -21
- package/src/selftest.ts +87 -10
- package/src/storage-key.ts +20 -0
- package/src/substitute.ts +47 -5
- package/src/text.ts +11 -0
- package/src/types.ts +16 -3
- 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',
|
|
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,
|
|
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
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
(
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
if (!
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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 });
|