@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.
- package/README.md +6 -6
- package/dist/cli.js +2194 -488
- 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 +40 -32
- package/src/engines2.ts +424 -29
- package/src/engines3.ts +115 -23
- package/src/explain.ts +3 -7
- package/src/help.ts +43 -0
- package/src/lifecycle.ts +95 -31
- 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';
|
|
2
|
-
import {
|
|
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 = (
|
|
740
|
+
const git = (...args: string[]) => gitArgs(root, args);
|
|
349
741
|
try {
|
|
350
|
-
const tip = git(
|
|
351
|
-
if (tip === git(
|
|
352
|
-
return git(
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
(
|
|
842
|
+
(excludePatterns.length ? '' : '. If one of these is versioned independently, set `release.version_exclude`.'),
|
|
448
843
|
});
|
|
449
844
|
}
|
|
450
845
|
}
|