@sun-asterisk/sungen 3.3.0-alpha.2 → 3.3.0-alpha.4
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/dist/cli/commands/contract.d.ts +3 -0
- package/dist/cli/commands/contract.d.ts.map +1 -1
- package/dist/cli/commands/contract.js +34 -4
- package/dist/cli/commands/contract.js.map +1 -1
- package/dist/contract/accept-opaque-steps.d.ts +3 -0
- package/dist/contract/accept-opaque-steps.d.ts.map +1 -0
- package/dist/contract/accept-opaque-steps.js +114 -0
- package/dist/contract/accept-opaque-steps.js.map +1 -0
- package/dist/contract/approve-contract.d.ts +8 -1
- package/dist/contract/approve-contract.d.ts.map +1 -1
- package/dist/contract/approve-contract.js +6 -0
- package/dist/contract/approve-contract.js.map +1 -1
- package/dist/contract/build-contract-verdict.js +2 -2
- package/dist/contract/build-contract-verdict.js.map +1 -1
- package/dist/contract/build-trace-conformance.d.ts +5 -1
- package/dist/contract/build-trace-conformance.d.ts.map +1 -1
- package/dist/contract/build-trace-conformance.js +9 -4
- package/dist/contract/build-trace-conformance.js.map +1 -1
- package/dist/contract/classify-contract-change.d.ts +3 -2
- package/dist/contract/classify-contract-change.d.ts.map +1 -1
- package/dist/contract/classify-contract-change.js.map +1 -1
- package/dist/contract/classify-trace-conformance.d.ts +18 -3
- package/dist/contract/classify-trace-conformance.d.ts.map +1 -1
- package/dist/contract/classify-trace-conformance.js +64 -17
- package/dist/contract/classify-trace-conformance.js.map +1 -1
- package/dist/contract/contract-schema.d.ts +21 -0
- package/dist/contract/contract-schema.d.ts.map +1 -1
- package/dist/contract/contract-schema.js +25 -3
- package/dist/contract/contract-schema.js.map +1 -1
- package/dist/contract/contracts-lock-file.d.ts.map +1 -1
- package/dist/contract/contracts-lock-file.js +1 -0
- package/dist/contract/contracts-lock-file.js.map +1 -1
- package/dist/contract/project-contract-blockers.d.ts.map +1 -1
- package/dist/contract/project-contract-blockers.js +4 -2
- package/dist/contract/project-contract-blockers.js.map +1 -1
- package/dist/contract/record-contract-approval.d.ts +16 -1
- package/dist/contract/record-contract-approval.d.ts.map +1 -1
- package/dist/contract/record-contract-approval.js +29 -1
- package/dist/contract/record-contract-approval.js.map +1 -1
- package/dist/contract/render-free-gherkin.d.ts +2 -0
- package/dist/contract/render-free-gherkin.d.ts.map +1 -1
- package/dist/contract/render-free-gherkin.js +6 -0
- package/dist/contract/render-free-gherkin.js.map +1 -1
- package/dist/contract/trace/collect-trace-evidence.d.ts +8 -3
- package/dist/contract/trace/collect-trace-evidence.d.ts.map +1 -1
- package/dist/contract/trace/collect-trace-evidence.js +65 -34
- package/dist/contract/trace/collect-trace-evidence.js.map +1 -1
- package/dist/contract/trace/read-file-once.d.ts +20 -0
- package/dist/contract/trace/read-file-once.d.ts.map +1 -0
- package/dist/contract/trace/read-file-once.js +135 -0
- package/dist/contract/trace/read-file-once.js.map +1 -0
- package/dist/contract/trace/select-final-attempt.d.ts +2 -2
- package/dist/contract/trace/select-final-attempt.d.ts.map +1 -1
- package/dist/contract/trace/select-final-attempt.js +3 -3
- package/dist/contract/trace/select-final-attempt.js.map +1 -1
- package/dist/contract/trace/trace-file-reader.d.ts +7 -1
- package/dist/contract/trace/trace-file-reader.d.ts.map +1 -1
- package/dist/contract/trace/trace-file-reader.js +17 -41
- package/dist/contract/trace/trace-file-reader.js.map +1 -1
- package/dist/contract/trace/trace-run-record.d.ts +2 -2
- package/dist/contract/trace/trace-run-record.d.ts.map +1 -1
- package/dist/contract/trace/trace-run-record.js +3 -3
- package/dist/contract/trace/trace-run-record.js.map +1 -1
- package/dist/contract/verify-approval-decisions.d.ts.map +1 -1
- package/dist/contract/verify-approval-decisions.js +9 -4
- package/dist/contract/verify-approval-decisions.js.map +1 -1
- package/dist/decisions/git-head-trust.d.ts +12 -0
- package/dist/decisions/git-head-trust.d.ts.map +1 -1
- package/dist/decisions/git-head-trust.js +52 -0
- package/dist/decisions/git-head-trust.js.map +1 -1
- package/dist/decisions/legacy-waiver-migration.d.ts +6 -1
- package/dist/decisions/legacy-waiver-migration.d.ts.map +1 -1
- package/dist/decisions/legacy-waiver-migration.js +8 -2
- package/dist/decisions/legacy-waiver-migration.js.map +1 -1
- package/dist/harness/gates/in-run-decision-check.d.ts.map +1 -1
- package/dist/harness/gates/in-run-decision-check.js +19 -11
- package/dist/harness/gates/in-run-decision-check.js.map +1 -1
- package/dist/harness/gates/migrated-waiver-import-bounds.d.ts +13 -0
- package/dist/harness/gates/migrated-waiver-import-bounds.d.ts.map +1 -0
- package/dist/harness/gates/migrated-waiver-import-bounds.js +141 -0
- package/dist/harness/gates/migrated-waiver-import-bounds.js.map +1 -0
- package/dist/harness/gates/migrated-waiver-review.d.ts +14 -4
- package/dist/harness/gates/migrated-waiver-review.d.ts.map +1 -1
- package/dist/harness/gates/migrated-waiver-review.js +78 -80
- package/dist/harness/gates/migrated-waiver-review.js.map +1 -1
- package/package.json +3 -3
- package/src/cli/commands/contract.ts +35 -4
- package/src/contract/accept-opaque-steps.ts +114 -0
- package/src/contract/approve-contract.ts +10 -3
- package/src/contract/build-contract-verdict.ts +2 -2
- package/src/contract/build-trace-conformance.ts +8 -3
- package/src/contract/classify-contract-change.ts +3 -3
- package/src/contract/classify-trace-conformance.ts +76 -22
- package/src/contract/contract-schema.ts +31 -2
- package/src/contract/contracts-lock-file.ts +1 -0
- package/src/contract/project-contract-blockers.ts +4 -2
- package/src/contract/record-contract-approval.ts +41 -1
- package/src/contract/render-free-gherkin.ts +5 -0
- package/src/contract/trace/collect-trace-evidence.ts +66 -29
- package/src/contract/trace/read-file-once.ts +95 -0
- package/src/contract/trace/select-final-attempt.ts +3 -3
- package/src/contract/trace/trace-file-reader.ts +17 -6
- package/src/contract/trace/trace-run-record.ts +3 -3
- package/src/contract/verify-approval-decisions.ts +9 -5
- package/src/decisions/git-head-trust.ts +44 -0
- package/src/decisions/legacy-waiver-migration.ts +9 -2
- package/src/harness/gates/in-run-decision-check.ts +16 -12
- package/src/harness/gates/migrated-waiver-import-bounds.ts +103 -0
- package/src/harness/gates/migrated-waiver-review.ts +90 -37
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read trace evidence (a run record, a JSON report, a trace.zip) by opening it ONCE and checking what
|
|
3
|
+
* was opened (#926) — never "check the path, then read the path", which leaves a window in which the
|
|
4
|
+
* file can be swapped for a link to anything outside the project.
|
|
5
|
+
*
|
|
6
|
+
* readFileOnce(file) a regular file, read from its descriptor; O_NONBLOCK so a
|
|
7
|
+
* FIFO swapped in cannot hang the guard; size checked on the
|
|
8
|
+
* descriptor before a byte is read
|
|
9
|
+
* readInsideProject(root, file) the same for a path from untrusted input:
|
|
10
|
+
* 1. its real path must sit inside the project's real root
|
|
11
|
+
* 2. that real path is opened with O_NOFOLLOW (a link put
|
|
12
|
+
* there in the meantime fails the open)
|
|
13
|
+
* 3. AFTER the open the path is resolved again: still inside,
|
|
14
|
+
* and the same file (device + inode) as the descriptor —
|
|
15
|
+
* a swap made before the open and undone after it is caught
|
|
16
|
+
*
|
|
17
|
+
* A file truncated or grown while it is read gives the bytes the descriptor had; the caller hashes
|
|
18
|
+
* those bytes, so nothing unread is ever vouched for.
|
|
19
|
+
*
|
|
20
|
+
* Windows has no O_NOFOLLOW (the constant is 0 there): the open follows a link swapped in, and the
|
|
21
|
+
* device + inode re-check after the open is what refuses it.
|
|
22
|
+
*/
|
|
23
|
+
import * as fs from 'fs';
|
|
24
|
+
import * as path from 'path';
|
|
25
|
+
|
|
26
|
+
export type FileRead =
|
|
27
|
+
| { status: 'read'; real: string; bytes: Buffer }
|
|
28
|
+
| { status: 'missing' }
|
|
29
|
+
| { status: 'outside'; why: string }
|
|
30
|
+
| { status: 'not-a-file' }
|
|
31
|
+
| { status: 'too-large'; size: number };
|
|
32
|
+
|
|
33
|
+
const { O_RDONLY } = fs.constants;
|
|
34
|
+
const O_NOFOLLOW = fs.constants.O_NOFOLLOW ?? 0;
|
|
35
|
+
const O_NONBLOCK = fs.constants.O_NONBLOCK ?? 0;
|
|
36
|
+
|
|
37
|
+
function readFd(fd: number, size: number): Buffer {
|
|
38
|
+
const buf = Buffer.alloc(size);
|
|
39
|
+
let off = 0;
|
|
40
|
+
while (off < size) {
|
|
41
|
+
const n = fs.readSync(fd, buf, off, size - off, off);
|
|
42
|
+
if (n === 0) break;
|
|
43
|
+
off += n;
|
|
44
|
+
}
|
|
45
|
+
return off === size ? buf : buf.subarray(0, off);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function openAndRead(file: string, flags: number, maxBytes: number, recheck?: (opened: fs.Stats) => string | null): FileRead {
|
|
49
|
+
let fd: number;
|
|
50
|
+
try { fd = fs.openSync(file, flags); } catch (e) {
|
|
51
|
+
const code = (e as NodeJS.ErrnoException).code;
|
|
52
|
+
if (code === 'ENOENT') return { status: 'missing' };
|
|
53
|
+
// O_NOFOLLOW on a link: ELOOP (Linux, macOS) or EMLINK (FreeBSD).
|
|
54
|
+
if (code === 'ELOOP' || code === 'EMLINK') return { status: 'outside', why: 'it became a link while it was opened' };
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const st = fs.fstatSync(fd);
|
|
59
|
+
if (!st.isFile()) return { status: 'not-a-file' };
|
|
60
|
+
if (st.size > maxBytes) return { status: 'too-large', size: st.size };
|
|
61
|
+
const moved = recheck?.(st);
|
|
62
|
+
if (moved) return { status: 'outside', why: moved };
|
|
63
|
+
return { status: 'read', real: file, bytes: readFd(fd, st.size) };
|
|
64
|
+
} finally {
|
|
65
|
+
fs.closeSync(fd);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A regular file, opened once and read from that descriptor. */
|
|
70
|
+
export const readFileOnce = (file: string, maxBytes = Number.POSITIVE_INFINITY): FileRead =>
|
|
71
|
+
openAndRead(file, O_RDONLY | O_NONBLOCK, maxBytes);
|
|
72
|
+
|
|
73
|
+
/** A regular file INSIDE the project (symlinks resolved), opened once; see the header for the checks. */
|
|
74
|
+
export function readInsideProject(projectRoot: string, file: string, maxBytes = Number.POSITIVE_INFINITY): FileRead {
|
|
75
|
+
let root: string;
|
|
76
|
+
let real: string;
|
|
77
|
+
try {
|
|
78
|
+
root = fs.realpathSync(projectRoot);
|
|
79
|
+
real = fs.realpathSync(file);
|
|
80
|
+
} catch (e) {
|
|
81
|
+
return (e as NodeJS.ErrnoException).code === 'ENOENT' ? { status: 'missing' } : { status: 'outside', why: 'its real path cannot be resolved' };
|
|
82
|
+
}
|
|
83
|
+
const within = (p: string): boolean => p.startsWith(root + path.sep);
|
|
84
|
+
if (!within(real)) return { status: 'outside', why: 'its real path is outside the project' };
|
|
85
|
+
return openAndRead(real, O_RDONLY | O_NOFOLLOW | O_NONBLOCK, maxBytes, (opened) => {
|
|
86
|
+
try {
|
|
87
|
+
const again = fs.realpathSync(file);
|
|
88
|
+
if (!within(again)) return 'it was swapped for a link outside the project while it was opened';
|
|
89
|
+
const now = fs.statSync(again);
|
|
90
|
+
return now.dev === opened.dev && now.ino === opened.ino ? null : 'it was swapped for another file while it was opened';
|
|
91
|
+
} catch {
|
|
92
|
+
return 'it moved while it was opened';
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
}
|
|
@@ -32,10 +32,10 @@ function resultOf(r: unknown): ReportResult | null {
|
|
|
32
32
|
return { retry: r.retry as number, status: r.status, startTime: String(r.startTime ?? ''), duration: Number(r.duration) || 0, tracePath: trace?.path ?? null };
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/** Every test of a report; throws when
|
|
36
|
-
export function readPlaywrightReport(file: string): ReportTest[] {
|
|
35
|
+
/** Every test of a report (`text`: its bytes, already read once by the caller); throws when it is not a Playwright JSON report at all. */
|
|
36
|
+
export function readPlaywrightReport(file: string, text?: string): ReportTest[] {
|
|
37
37
|
let doc: unknown;
|
|
38
|
-
try { doc = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`the JSON report ${file} is not readable (${(e as Error).message.split('\n')[0]})`); }
|
|
38
|
+
try { doc = JSON.parse(text ?? fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`the JSON report ${file} is not readable (${(e as Error).message.split('\n')[0]})`); }
|
|
39
39
|
if (!isObj(doc) || !Array.isArray(doc.suites)) throw new Error(`${file} is not a Playwright JSON report (no suites)`);
|
|
40
40
|
const out: ReportTest[] = [];
|
|
41
41
|
for (const top of doc.suites) {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* a part without one is refused. Every JSONL line must parse — a line that does not is never skipped.
|
|
13
13
|
*/
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
|
-
import
|
|
15
|
+
import { FileRead, readFileOnce } from './read-file-once';
|
|
16
16
|
import { openZip, ZipArchive } from './trace-zip-reader';
|
|
17
17
|
import { SUPPORTED_TRACE_VERSIONS, traceZipLimits, TraceRejected, TraceZipLimits } from './trace-zip-limits';
|
|
18
18
|
|
|
@@ -124,10 +124,21 @@ export function readTraceBuffer(buf: Buffer, label: string, limits: TraceZipLimi
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
/**
|
|
127
|
+
/**
|
|
128
|
+
* A trace opened once (read-file-once.ts) → parsed: the size was checked on the descriptor BEFORE it
|
|
129
|
+
* was read into memory. `missing` / `outside` are the caller's to report; here they throw.
|
|
130
|
+
*/
|
|
131
|
+
export function traceFromRead(read: FileRead, label: string, limits: TraceZipLimits = traceZipLimits()): ParsedTrace {
|
|
132
|
+
switch (read.status) {
|
|
133
|
+
case 'read': return readTraceBuffer(read.bytes, read.real, limits);
|
|
134
|
+
case 'too-large': throw new TraceRejected('zip.too-large', `${read.size} B > ${limits.maxZipBytes}`);
|
|
135
|
+
case 'not-a-file': throw new TraceRejected('zip.malformed', `${label} is not a regular file`);
|
|
136
|
+
case 'missing': throw new TraceRejected('zip.malformed', `${label} does not exist`);
|
|
137
|
+
default: throw new TraceRejected('zip.malformed', `${label} was not read: ${read.why}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** Read a trace from a trusted path (a fixture, a test): a regular file, opened once. */
|
|
128
142
|
export function readTraceFile(file: string, limits: TraceZipLimits = traceZipLimits()): ParsedTrace {
|
|
129
|
-
|
|
130
|
-
if (!st.isFile()) throw new TraceRejected('zip.malformed', `${file} is not a regular file`);
|
|
131
|
-
if (st.size > limits.maxZipBytes) throw new TraceRejected('zip.too-large', `${st.size} B > ${limits.maxZipBytes}`);
|
|
132
|
-
return readTraceBuffer(fs.readFileSync(file), file, limits);
|
|
143
|
+
return traceFromRead(readFileOnce(file, limits.maxZipBytes), file, limits);
|
|
133
144
|
}
|
|
@@ -60,10 +60,10 @@ function attemptOf(raw: unknown, i: number): RunRecordAttempt {
|
|
|
60
60
|
return { testId, file, title, retry: retry as number, status, startTime, duration, trace: trace as RunRecordAttempt['trace'] };
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/** Read and check one record; throws an Error naming what is wrong. */
|
|
64
|
-
export function readRunRecord(file: string): RunRecord {
|
|
63
|
+
/** Read and check one record (`text`: its bytes, already read once by the caller); throws an Error naming what is wrong. */
|
|
64
|
+
export function readRunRecord(file: string, text?: string): RunRecord {
|
|
65
65
|
let raw: unknown;
|
|
66
|
-
try { raw = JSON.parse(fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`not readable JSON (${(e as Error).message.split('\n')[0]})`); }
|
|
66
|
+
try { raw = JSON.parse(text ?? fs.readFileSync(file, 'utf8')); } catch (e) { throw new Error(`not readable JSON (${(e as Error).message.split('\n')[0]})`); }
|
|
67
67
|
if (!isObj(raw)) throw new Error('not an object');
|
|
68
68
|
const extra = Object.keys(raw).find((k) => !TOP.has(k));
|
|
69
69
|
if (extra) throw new Error(`unknown key "${extra}"`);
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
* Every approved lock entry must be vouched for by its decision (DOS-13.2, DOS-14.4, review M1):
|
|
3
3
|
* `qa/<kind>/<unit>/decisions/<decisionId>.yaml`, COMMITTED in git HEAD (outside git: on disk),
|
|
4
4
|
* intact (schema, id = content, entry_hash = entry), of kind `contract-approval`, whose payload lists
|
|
5
|
-
* this scenario at this hash
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* this scenario at this hash — with exactly the opaque steps the entry says QA accepted (#923) — and
|
|
6
|
+
* whose `valid_from` is the entry's `approvedAt`. A lock line copied from elsewhere, re-pointed at
|
|
7
|
+
* another decision, backdated, or given an acceptance nobody recorded fails here — `unverified`,
|
|
8
|
+
* reported under contract.lock-uncommitted (hard).
|
|
8
9
|
*
|
|
9
10
|
* Only entries that are otherwise identical to HEAD are checked: an entry already added, changed or
|
|
10
11
|
* tampered is a hard finding on its own.
|
|
@@ -14,6 +15,7 @@ import { computeDecisionId, computeEntryHash } from '../decisions/decision-ident
|
|
|
14
15
|
import { DecisionEntry, storedEntryErrors } from '../decisions/decision-schema';
|
|
15
16
|
import { safeYamlParse } from '../utils/safe-yaml-parse';
|
|
16
17
|
import { HeadSnapshot, headFileText, posixRel, readTextOrNull } from './contract-git';
|
|
18
|
+
import { sameJson } from './contract-hash';
|
|
17
19
|
import { ContractLock, isHumanApproved, LockEntry } from './contract-schema';
|
|
18
20
|
import type { UncommittedEntry } from './lock-vs-git-head';
|
|
19
21
|
|
|
@@ -38,8 +40,10 @@ function loadDecision(projectRoot: string, unitDir: string, id: string, snap: He
|
|
|
38
40
|
|
|
39
41
|
function mismatch(scenario: string, lockEntry: LockEntry, d: DecisionEntry): string | null {
|
|
40
42
|
if (d.kind !== 'contract-approval') return `decision ${d.id} is a ${d.kind}, not a contract-approval`;
|
|
41
|
-
const listed = Array.isArray(d.payload?.scenarios) ? d.payload!.scenarios as Array<{ id?: unknown; hash?: unknown }> : [];
|
|
42
|
-
|
|
43
|
+
const listed = Array.isArray(d.payload?.scenarios) ? d.payload!.scenarios as Array<{ id?: unknown; hash?: unknown; acceptedOpaque?: unknown }> : [];
|
|
44
|
+
const item = listed.find((s) => s && s.id === scenario && s.hash === lockEntry.hash);
|
|
45
|
+
if (!item) return `decision ${d.id} does not approve ${scenario} at hash ${lockEntry.hash}`;
|
|
46
|
+
if (!sameJson(item.acceptedOpaque ?? [], lockEntry.acceptedOpaque ?? [])) return `decision ${d.id} does not accept the opaque step(s) the lock entry of ${scenario} lists (acceptedOpaque)`;
|
|
43
47
|
if (Date.parse(lockEntry.approvedAt) !== Date.parse(d.valid_from)) return `approvedAt ${lockEntry.approvedAt} is not decision ${d.id}'s valid_from ${d.valid_from}`;
|
|
44
48
|
return null;
|
|
45
49
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* did not. Outside git, or for a file git does not track, nothing is trusted. Never throws.
|
|
7
7
|
*/
|
|
8
8
|
import { execFileSync } from 'child_process';
|
|
9
|
+
import * as fs from 'fs';
|
|
9
10
|
import * as path from 'path';
|
|
10
11
|
|
|
11
12
|
/** stdout of a git command run in `cwd`, or null when git fails (no repo, no HEAD, untracked path…). */
|
|
@@ -17,6 +18,49 @@ export function gitOutput(cwd: string, args: string[]): string | null {
|
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
/**
|
|
22
|
+
* The text of each `<rev>:<path>` spec, read by ONE `git cat-file --batch` in `cwd` (`./path` is relative
|
|
23
|
+
* to it): null where git holds no such file; null for the whole call when git fails.
|
|
24
|
+
*/
|
|
25
|
+
export function gitFileTexts(cwd: string, specs: string[]): Array<string | null> | null {
|
|
26
|
+
if (specs.length === 0) return [];
|
|
27
|
+
if (specs.some((s) => /[\r\n]/.test(s))) return null;
|
|
28
|
+
let out: Buffer;
|
|
29
|
+
try {
|
|
30
|
+
out = execFileSync('git', ['cat-file', '--batch'], { cwd, input: `${specs.join('\n')}\n`, stdio: ['pipe', 'pipe', 'ignore'], maxBuffer: 256 * 1024 * 1024, timeout: 60000 });
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
// Each answer: `<oid> <type> <size>\n<content>\n`, or `<spec> missing\n` (also `ambiguous`).
|
|
35
|
+
const texts: Array<string | null> = [];
|
|
36
|
+
let at = 0;
|
|
37
|
+
for (let i = 0; i < specs.length; i++) {
|
|
38
|
+
const eol = out.indexOf(0x0a, at);
|
|
39
|
+
if (eol < 0) return null;
|
|
40
|
+
const header = out.subarray(at, eol).toString('utf8').split(' ');
|
|
41
|
+
at = eol + 1;
|
|
42
|
+
if (header.length !== 3 || !/^\d+$/.test(header[2])) { texts.push(null); continue; }
|
|
43
|
+
const size = Number(header[2]);
|
|
44
|
+
texts.push(header[1] === 'blob' ? out.subarray(at, at + size).toString('utf8') : null);
|
|
45
|
+
at += size + 1;
|
|
46
|
+
}
|
|
47
|
+
return texts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Whether `cwd` is in a shallow clone. git < 2.15 does not know `--is-shallow-repository` and echoes it
|
|
52
|
+
* back, so only an exact `true` / `false` is an answer; otherwise the `shallow` file in the git directory
|
|
53
|
+
* (common dir, or the git dir) decides, and when not even that can be found the clone is taken as shallow
|
|
54
|
+
* — its history is then unknown, which fails closed for the callers that ask.
|
|
55
|
+
*/
|
|
56
|
+
export function isShallowRepository(cwd: string): boolean {
|
|
57
|
+
const answer = gitOutput(cwd, ['rev-parse', '--is-shallow-repository'])?.trim();
|
|
58
|
+
if (answer === 'true' || answer === 'false') return answer === 'true';
|
|
59
|
+
const dirs = ['--git-common-dir', '--git-dir'].map((flag) => gitOutput(cwd, ['rev-parse', flag])?.trim())
|
|
60
|
+
.filter((d): d is string => !!d && !d.startsWith('-'));
|
|
61
|
+
return dirs.length === 0 || dirs.some((d) => fs.existsSync(path.join(path.resolve(cwd, d), 'shallow')));
|
|
62
|
+
}
|
|
63
|
+
|
|
20
64
|
/** The committed text of `abs` (a file inside `projectRoot`), or null when git HEAD has no such file. */
|
|
21
65
|
export function committedText(projectRoot: string, abs: string): string | null {
|
|
22
66
|
const rel = path.relative(projectRoot, abs).split(path.sep).join('/');
|
|
@@ -125,11 +125,18 @@ export function commitWaiverSync(projectRoot: string, unit: string, slug: string
|
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
-
/**
|
|
128
|
+
/**
|
|
129
|
+
* Record a waiver given now on the CLI; supersedes the waiver in force for that obligation, if any.
|
|
130
|
+
* The same waiver again records nothing — unless the one in force was imported (`where: migrated`):
|
|
131
|
+
* re-confirming it on a terminal is how a human vouches for a migrated waiver its legacy record cannot
|
|
132
|
+
* (decision.in-run), so it always writes the `cli` decision that supersedes it (#925).
|
|
133
|
+
*/
|
|
129
134
|
export function recordWaiver(projectRoot: string, unit: string, obligation: string, w: LegacyWaiver): AppendResult {
|
|
130
135
|
const scope = unitDecisionScope(projectRoot, unit);
|
|
131
136
|
if (!scope) throw new Error(`Cannot record a waiver: "${unit}" does not name exactly one unit under qa/screens, qa/flows or qa/api.`);
|
|
132
137
|
const current = effectiveWaiverEntries(readDecisions(projectRoot, scope, { kind: 'waiver' }).entries).get(obligation);
|
|
133
|
-
if (current && sameWaiver(asLegacyWaiver(current), w))
|
|
138
|
+
if (current && current.source?.where !== 'migrated' && sameWaiver(asLegacyWaiver(current), w)) {
|
|
139
|
+
return { status: 'already-recorded', entry: current, path: current.path };
|
|
140
|
+
}
|
|
134
141
|
return appendDecision(projectRoot, scope, waiverDraft(obligation, w, { by: gitUserName(projectRoot), where: 'cli' }, current?.id));
|
|
135
142
|
}
|
|
@@ -11,10 +11,13 @@
|
|
|
11
11
|
*
|
|
12
12
|
* A MIGRATED waiver (DOS-13.5) was reviewed when its legacy record was committed — the only record the
|
|
13
13
|
* import trusts — so it is judged by THAT commit (migrated-waiver-review.ts), never by its own decision
|
|
14
|
-
* file: in-run when the record was committed after the run started, or
|
|
15
|
-
*
|
|
16
|
-
* project's
|
|
17
|
-
*
|
|
14
|
+
* file: in-run when the record was committed after the run started, or — run start or not (#925) —
|
|
15
|
+
* when it could not have been the import's source: not strictly before the decision file, the unit's
|
|
16
|
+
* import marker or the project's adoption of 3.3 appeared. Holding it to its decision file's commit would make
|
|
17
|
+
* every legacy project's waivers in-run the day they are imported (phase 09). An entry HEAD's legacy
|
|
18
|
+
* file does not vouch for — absent, uncommitted, different, introduced beyond the commits the walk
|
|
19
|
+
* reads (#924) or at a shallow clone's boundary — is judged as any other; in the last two cases an
|
|
20
|
+
* in-run finding says why.
|
|
18
21
|
*/
|
|
19
22
|
import * as fs from 'fs';
|
|
20
23
|
import * as path from 'path';
|
|
@@ -22,7 +25,7 @@ import { DECISION_FILE_PATH } from '../../decisions/decision-file';
|
|
|
22
25
|
import { DecisionVisibility } from '../../decisions/decision-schema';
|
|
23
26
|
import { gitOutput } from '../../decisions/git-head-trust';
|
|
24
27
|
import { readDecisions } from '../../decisions/read-decisions';
|
|
25
|
-
import {
|
|
28
|
+
import { migratedWaiverReview } from './migrated-waiver-review';
|
|
26
29
|
|
|
27
30
|
export const IN_RUN_KINDS = ['gate-override', 'waiver', 'contract-approval'] as const;
|
|
28
31
|
export const IN_RUN_RULE = 'decision.in-run';
|
|
@@ -60,18 +63,18 @@ const isInRunKind = (kind: string): boolean => (IN_RUN_KINDS as readonly string[
|
|
|
60
63
|
export function inRunDecisions(projectRoot: string, scopes: string[], runStart: string | null, committed: Set<string> | null): InRunDecision[] {
|
|
61
64
|
const out: InRunDecision[] = [];
|
|
62
65
|
const after = (t: string): boolean => !!runStart && Date.parse(t) > Date.parse(runStart);
|
|
66
|
+
const migratedReview = committed ? migratedWaiverReview(projectRoot) : null;
|
|
63
67
|
for (const scope of scopes) {
|
|
64
68
|
for (const e of readDecisions(projectRoot, scope).entries) {
|
|
65
69
|
if (!isInRunKind(e.kind)) continue;
|
|
70
|
+
// A migrated waiver re-confirmed on a terminal is replaced: the new decision is judged on its own.
|
|
71
|
+
if (e.superseded_by && e.source?.where === 'migrated') continue;
|
|
66
72
|
const rel = path.relative(projectRoot, e.path).split(path.sep).join('/');
|
|
67
73
|
const why: string[] = [];
|
|
68
|
-
const
|
|
69
|
-
if (
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
? `migrated: its legacy record was committed at ${legacyAt}, after the run started ${runStart}`
|
|
73
|
-
: `migrated: its legacy record was committed at ${legacyAt}, after this decision first appeared`);
|
|
74
|
-
}
|
|
74
|
+
const migrated = migratedReview?.reviewOf(scope, e) ?? null;
|
|
75
|
+
if (migrated?.at) {
|
|
76
|
+
if (after(migrated.at)) why.push(`migrated: its legacy record was committed at ${migrated.at}, after the run started ${runStart}`);
|
|
77
|
+
if (migrated.notImported) why.push(migrated.notImported);
|
|
75
78
|
} else if (committed && !committed.has(rel)) {
|
|
76
79
|
why.push('not in git HEAD (never committed through review)');
|
|
77
80
|
} else if (committed && runStart) {
|
|
@@ -82,6 +85,7 @@ export function inRunDecisions(projectRoot: string, scopes: string[], runStart:
|
|
|
82
85
|
} else if (!committed && after(e.valid_from)) {
|
|
83
86
|
why.push(`recorded ${e.valid_from}, after the run started ${runStart}`);
|
|
84
87
|
}
|
|
88
|
+
if (why.length && migrated?.unvouched) why.push(migrated.unvouched);
|
|
85
89
|
if (why.length) out.push({ id: e.id, kind: e.kind, path: rel, visibility: e.visibility, why });
|
|
86
90
|
}
|
|
87
91
|
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Could a migrated waiver's legacy record have been the source of the import that wrote it? (#925)
|
|
3
|
+
*
|
|
4
|
+
* The one-time import (legacy-waiver-migration.ts) reads a record git HEAD ALREADY holds, and only
|
|
5
|
+
* before 3.3 takes over. So a genuine record was committed strictly before each of:
|
|
6
|
+
* 1. the decision file's first appearance — its first commit, else its creation on disk;
|
|
7
|
+
* 2. the unit's import marker `.sungen/migrations/<slug>.waivers-imported.json` — the commit that
|
|
8
|
+
* added the one HEAD holds (an agent can delete or recreate the file, not that commit), else its
|
|
9
|
+
* creation on disk; once it exists the import never reads that unit's legacy file again;
|
|
10
|
+
* 3. the project's adoption of 3.3 — the first commit that added any 3.3-only file: an import marker
|
|
11
|
+
* or other `.sungen/migrations/` file, a decision file (`qa/**\/decisions/*.yaml`) or a contract lock
|
|
12
|
+
* (`qa/**\/contracts.lock.yaml`). Only the 3.3 CLI writes them, and only committed ones count: a
|
|
13
|
+
* legacy waiver is one written before the project's first reviewed 3.3 change. Mixed versions pay
|
|
14
|
+
* for it: a waiver a pre-3.3 CLI writes after that commit must be re-confirmed on a terminal.
|
|
15
|
+
* A record failing any bound claims a migration that never happened: the decision is in-run.
|
|
16
|
+
*
|
|
17
|
+
* What stays open (the limit of any time-based check, as for ordinary decisions): a record forged and
|
|
18
|
+
* committed before the project's first 3.3 commit — indistinguishable from a real legacy waiver, as on
|
|
19
|
+
* a project adopting 3.3 for the very first time — and commit times forged with GIT_COMMITTER_DATE.
|
|
20
|
+
*/
|
|
21
|
+
import * as fs from 'fs';
|
|
22
|
+
import * as path from 'path';
|
|
23
|
+
import { committedText, gitOutput } from '../../decisions/git-head-trust';
|
|
24
|
+
import { formatCommand } from '../../decisions/human-confirmation';
|
|
25
|
+
import { importMarkerPath } from '../../decisions/legacy-waiver-migration';
|
|
26
|
+
import type { ReadDecision } from '../../decisions/read-decisions';
|
|
27
|
+
|
|
28
|
+
/** Files only the 3.3 CLI writes, as pathspecs relative to the project. */
|
|
29
|
+
const ADOPTION_PATHSPECS = ['.sungen/migrations', ':(glob)qa/**/decisions/*.yaml', ':(glob)qa/**/contracts.lock.yaml'];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What a human does about a migrated waiver its legacy record cannot vouch for: record it anew on a
|
|
33
|
+
* terminal (a `cli` decision that supersedes it — the superseded one is no longer judged), or accept it
|
|
34
|
+
* for one delivery.
|
|
35
|
+
*/
|
|
36
|
+
export const migratedWaiverRemedy = (scope: string, ob: string): string =>
|
|
37
|
+
`re-confirm it on a terminal: ${formatCommand(['sungen', 'journey', '--screen', scope.split('/').pop() ?? scope, '--waive', ob, '--reason', '…'])}, `
|
|
38
|
+
+ 'then commit the new decision; or accept it once with `sungen delivery --accept-in-run`';
|
|
39
|
+
|
|
40
|
+
const validMs = (iso: string | undefined): number | null => (iso && !Number.isNaN(Date.parse(iso)) ? Date.parse(iso) : null);
|
|
41
|
+
const createdMs = (abs: string): number | null => {
|
|
42
|
+
try { const s = fs.statSync(abs); return s.birthtimeMs || s.mtimeMs; } catch { return null; }
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** One run's bounds: each decision's first appearance, each unit's marker and the project's adoption, read once. */
|
|
46
|
+
export function migratedWaiverImportBounds(projectRoot: string) {
|
|
47
|
+
const relOf = (abs: string): string => path.relative(projectRoot, abs).split(path.sep).join('/');
|
|
48
|
+
const firstSeen = new Map<string, number>();
|
|
49
|
+
const markers = new Map<string, number | null>();
|
|
50
|
+
let adoption: number | null | undefined;
|
|
51
|
+
|
|
52
|
+
/** When a decision file first appeared: its first commit, else its creation (or last change) on disk. */
|
|
53
|
+
const decisionMs = (e: ReadDecision): number => {
|
|
54
|
+
if (!firstSeen.has(e.path)) {
|
|
55
|
+
const added = gitOutput(projectRoot, ['log', '--diff-filter=A', '--format=%cI', '--', relOf(e.path)])?.trim().split('\n').filter(Boolean).pop();
|
|
56
|
+
firstSeen.set(e.path, validMs(added) ?? createdMs(e.path) ?? Number.NEGATIVE_INFINITY);
|
|
57
|
+
}
|
|
58
|
+
return firstSeen.get(e.path)!;
|
|
59
|
+
};
|
|
60
|
+
const markerMs = (slug: string): number | null => {
|
|
61
|
+
if (!markers.has(slug)) {
|
|
62
|
+
const abs = importMarkerPath(projectRoot, slug);
|
|
63
|
+
const added = committedText(projectRoot, abs) !== null
|
|
64
|
+
? validMs(gitOutput(projectRoot, ['log', '-1', '--diff-filter=A', '--format=%cI', '--', relOf(abs)])?.trim()) : null;
|
|
65
|
+
markers.set(slug, added ?? createdMs(abs));
|
|
66
|
+
}
|
|
67
|
+
return markers.get(slug)!;
|
|
68
|
+
};
|
|
69
|
+
const adoptionMs = (): number | null => {
|
|
70
|
+
if (adoption === undefined) {
|
|
71
|
+
const added = (gitOutput(projectRoot, ['log', '--diff-filter=A', '--format=%cI', '--', ...ADOPTION_PATHSPECS]) ?? '')
|
|
72
|
+
.split('\n').map((l) => validMs(l.trim())).filter((t): t is number => t !== null);
|
|
73
|
+
adoption = added.length ? Math.min(...added) : null;
|
|
74
|
+
}
|
|
75
|
+
return adoption;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** The first bound a record committed at `legacy` (ms) crosses, or undefined. */
|
|
79
|
+
const boundCrossed = (slug: string, e: ReadDecision, legacy: number): string | undefined => {
|
|
80
|
+
const decision = decisionMs(e);
|
|
81
|
+
if (legacy > decision) return 'after this decision first appeared';
|
|
82
|
+
if (legacy === decision) return 'when this decision first appeared, not before it — an import only reads a record already committed';
|
|
83
|
+
const marker = markerMs(slug);
|
|
84
|
+
if (marker !== null && legacy >= marker) {
|
|
85
|
+
return `after this unit's waivers were imported (${relOf(importMarkerPath(projectRoot, slug))}, ${new Date(marker).toISOString()}) `
|
|
86
|
+
+ '— from then on the import never reads the legacy file';
|
|
87
|
+
}
|
|
88
|
+
const adopted = adoptionMs();
|
|
89
|
+
if (adopted !== null && legacy >= adopted) {
|
|
90
|
+
return `after this project adopted sungen 3.3 (${new Date(adopted).toISOString()}: the first commit adding a .sungen/migrations/ file, `
|
|
91
|
+
+ 'a decision or a contract lock) — a legacy waiver is one written before that';
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
/** Why a record committed at `at` (state file of `slug`) cannot have been the source of `e`'s import; undefined when it can. */
|
|
98
|
+
notImported(slug: string, e: ReadDecision, at: string): string | undefined {
|
|
99
|
+
const why = boundCrossed(slug, e, Date.parse(at));
|
|
100
|
+
return why && `migrated: its legacy record was committed at ${at}, ${why}; ${migratedWaiverRemedy(e.scope, String(e.payload?.obligation))}`;
|
|
101
|
+
},
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -7,62 +7,115 @@
|
|
|
7
7
|
*
|
|
8
8
|
* vouched HEAD's state file holds the record (same reason, `at`, auditHashAtWaive) — reviewedAt is
|
|
9
9
|
* the commit time of the oldest commit in the unbroken run, back from HEAD, that holds it
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* imported the record could have been the import's source (#925) — always checked, run start or not:
|
|
11
|
+
* committed strictly before the decision file, the unit's import marker and the project's
|
|
12
|
+
* adoption of 3.3 first appeared (migrated-waiver-import-bounds.ts). Failing one, the decision
|
|
13
|
+
* claims a migration that never happened → in-run, even at `gate --phase deliver`, which has
|
|
14
|
+
* no run start.
|
|
15
|
+
* with a run start also in-run when reviewedAt is after it (the same test as any committed decision)
|
|
13
16
|
* Anything else — no git, no such record in HEAD, a record that differs — is not vouched for: the
|
|
14
|
-
* decision is judged as any other (`not in git HEAD`, or its own commit time).
|
|
17
|
+
* decision is judged as any other (`not in git HEAD`, or its own commit time). Only a rewritten history
|
|
18
|
+
* (a squash or rebase that folds the record's commit into the import's) can fail a genuine import; the
|
|
19
|
+
* finding says which bound it crossed.
|
|
20
|
+
*
|
|
21
|
+
* Nor is a record whose introducing commit is out of reach — judged as any other, with `unvouched`
|
|
22
|
+
* saying why, never vouched for by a later commit taken for its introduction: the walk reads at most
|
|
23
|
+
* MAX_COMMITS commits of a state file and the record is held by the commit before them too (#924), or
|
|
24
|
+
* the record reaches a shallow clone's boundary commit. One review reads each state file's history
|
|
25
|
+
* once (one `git log`, one `git cat-file --batch`), however many waivers share it.
|
|
15
26
|
*/
|
|
16
|
-
import
|
|
17
|
-
import * as path from 'path';
|
|
18
|
-
import { committedText, gitOutput } from '../../decisions/git-head-trust';
|
|
27
|
+
import { gitFileTexts, gitOutput, isShallowRepository } from '../../decisions/git-head-trust';
|
|
19
28
|
import type { ReadDecision } from '../../decisions/read-decisions';
|
|
20
29
|
import { asLegacyWaiver, isLegacyWaiver, sameWaiver } from '../../decisions/waiver-decisions';
|
|
21
30
|
import { reportSlug } from '../unit-paths';
|
|
31
|
+
import { migratedWaiverImportBounds, migratedWaiverRemedy } from './migrated-waiver-import-bounds';
|
|
22
32
|
|
|
23
|
-
/** Commits of one state file the walk reads at most
|
|
33
|
+
/** Commits of one state file the walk reads at most. */
|
|
24
34
|
const MAX_COMMITS = 200;
|
|
25
35
|
|
|
26
|
-
|
|
36
|
+
type State = { waivers?: Record<string, unknown> } | null;
|
|
37
|
+
/** A state file in git: HEAD's version and its newest MAX_COMMITS + 1 commits (newest first; `root`: no parent shown). */
|
|
38
|
+
interface StateHistory { rel: string; head: State; commits: Array<{ at: string; state: State; root: boolean }> }
|
|
39
|
+
|
|
40
|
+
/** A migrated waiver HEAD's legacy file holds: when it was reviewed, or why that cannot be said. */
|
|
41
|
+
export interface MigratedReview {
|
|
42
|
+
/** When its legacy record was committed; null when that commit is out of reach (see `unvouched`). */
|
|
43
|
+
at: string | null;
|
|
44
|
+
/** Set with `at`: why the record cannot have been the import's source — the decision is in-run. */
|
|
45
|
+
notImported?: string;
|
|
46
|
+
/** Set when `at` is null: why the record vouches for nothing — the decision is judged as any other. */
|
|
47
|
+
unvouched?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const parse = (text: string | null): State => {
|
|
27
51
|
if (text === null) return null;
|
|
28
52
|
try { return JSON.parse(text.replace(/^/, '')); } catch { return null; }
|
|
29
53
|
};
|
|
30
54
|
|
|
31
|
-
function holds(state:
|
|
55
|
+
function holds(state: State, e: ReadDecision, ob: string): boolean {
|
|
32
56
|
const w = state?.waivers?.[ob];
|
|
33
57
|
return isLegacyWaiver(w) && w.at === e.valid_from && sameWaiver(w, asLegacyWaiver(e));
|
|
34
58
|
}
|
|
35
59
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
return oldest && !Number.isNaN(Date.parse(oldest)) ? oldest : null;
|
|
60
|
+
function readHistory(projectRoot: string, rel: string): StateHistory {
|
|
61
|
+
const log = (gitOutput(projectRoot, ['log', `--max-count=${MAX_COMMITS + 1}`, '--format=%H %cI %P', '--', rel]) ?? '')
|
|
62
|
+
.split('\n').filter(Boolean).map((line) => line.trim().split(' '));
|
|
63
|
+
const texts = gitFileTexts(projectRoot, [`HEAD:./${rel}`, ...log.map(([sha]) => `${sha}:./${rel}`)]);
|
|
64
|
+
return {
|
|
65
|
+
rel,
|
|
66
|
+
head: parse(texts?.[0] ?? null),
|
|
67
|
+
commits: log.map(([, at, ...parents], i) => ({ at, state: parse(texts?.[i + 1] ?? null), root: parents.length === 0 })),
|
|
68
|
+
};
|
|
46
69
|
}
|
|
47
70
|
|
|
48
|
-
/**
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
71
|
+
/**
|
|
72
|
+
* The commit time of the oldest commit, in the unbroken run back from HEAD, whose state file holds the
|
|
73
|
+
* record. The walk reads MAX_COMMITS commits; the next one only says whether the last of them introduced it.
|
|
74
|
+
* In a shallow clone a commit with no parent shown is the boundary: every file seems to appear there, so a
|
|
75
|
+
* record reaching it has no known commit time.
|
|
76
|
+
*/
|
|
77
|
+
function introducedAt(h: StateHistory, e: ReadDecision, ob: string, scope: string, shallow: () => boolean): MigratedReview | null {
|
|
78
|
+
let run = 0;
|
|
79
|
+
while (run < h.commits.length && holds(h.commits[run].state, e, ob)) run++;
|
|
80
|
+
const unvouched = (why: string): MigratedReview => ({ at: null, unvouched: `migrated, but its legacy record cannot vouch for it: ${why}` });
|
|
81
|
+
if (run > MAX_COMMITS) {
|
|
82
|
+
return unvouched(`${h.rel} changed in more than ${MAX_COMMITS} commits, and the commit that introduced the record is older than all of them`);
|
|
59
83
|
}
|
|
60
|
-
|
|
84
|
+
const oldest = run ? h.commits[run - 1] : null;
|
|
85
|
+
if (oldest?.root && shallow()) {
|
|
86
|
+
return unvouched(`the git history is shallow, so the record's real commit time is unknown — fetch the full history `
|
|
87
|
+
+ `(\`git fetch --unshallow\`; \`fetch-depth: 0\` in CI), or ${migratedWaiverRemedy(scope, ob)}`);
|
|
88
|
+
}
|
|
89
|
+
return oldest && !Number.isNaN(Date.parse(oldest.at)) ? { at: oldest.at } : null;
|
|
61
90
|
}
|
|
62
91
|
|
|
63
|
-
/**
|
|
64
|
-
export function
|
|
65
|
-
const
|
|
66
|
-
|
|
67
|
-
|
|
92
|
+
/** One run's reads for migrated waivers: each state file's history, decision's first commit and import marker, read once. */
|
|
93
|
+
export function migratedWaiverReview(projectRoot: string) {
|
|
94
|
+
const histories = new Map<string, StateHistory>();
|
|
95
|
+
const bounds = migratedWaiverImportBounds(projectRoot);
|
|
96
|
+
let isShallow: boolean | undefined;
|
|
97
|
+
const shallow = (): boolean => (isShallow ??= isShallowRepository(projectRoot));
|
|
98
|
+
const historyOf = (rel: string): StateHistory => {
|
|
99
|
+
if (!histories.has(rel)) histories.set(rel, readHistory(projectRoot, rel));
|
|
100
|
+
return histories.get(rel)!;
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
/** How a migrated waiver was reviewed; null when it is not one HEAD's legacy file holds (judged as any other). */
|
|
105
|
+
reviewOf(scope: string, e: ReadDecision): MigratedReview | null {
|
|
106
|
+
const ob = e.payload?.obligation;
|
|
107
|
+
if (e.kind !== 'waiver' || e.source?.where !== 'migrated' || typeof ob !== 'string') return null;
|
|
108
|
+
// The journey keys its state by the unit name it was given: `<name>`, or the catalog id for api units.
|
|
109
|
+
const slugs = new Set([scope.split('/').pop() ?? '', reportSlug(scope.replace(/^(screens|flows)\//, ''))]);
|
|
110
|
+
let unvouched: MigratedReview | null = null;
|
|
111
|
+
for (const slug of slugs) {
|
|
112
|
+
const h = historyOf(`.sungen/journey/${slug}.state.json`);
|
|
113
|
+
if (!holds(h.head, e, ob)) continue;
|
|
114
|
+
const review = introducedAt(h, e, ob, scope, shallow);
|
|
115
|
+
if (review?.at) return { at: review.at, notImported: bounds.notImported(slug, e, review.at) };
|
|
116
|
+
unvouched = unvouched ?? review;
|
|
117
|
+
}
|
|
118
|
+
return unvouched;
|
|
119
|
+
},
|
|
120
|
+
};
|
|
68
121
|
}
|