@sun-asterisk/sungen 3.3.0-alpha.1 → 3.3.0-alpha.3
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/capabilities/discover.d.ts +4 -0
- package/dist/capabilities/discover.d.ts.map +1 -1
- package/dist/capabilities/discover.js +16 -2
- package/dist/capabilities/discover.js.map +1 -1
- package/dist/cli/commands/audit.js +1 -1
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/contract-derive-command.d.ts +3 -2
- package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
- package/dist/cli/commands/contract-derive-command.js +3 -3
- package/dist/cli/commands/contract-derive-command.js.map +1 -1
- 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 +35 -5
- package/dist/cli/commands/contract.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +9 -2
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/journey.js +2 -2
- package/dist/cli/commands/journey.js.map +1 -1
- package/dist/cli/commands/next.d.ts.map +1 -1
- package/dist/cli/commands/next.js +8 -6
- package/dist/cli/commands/next.js.map +1 -1
- package/dist/cli/index.js +1 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/project-sungen-invocation.d.ts +7 -0
- package/dist/cli/project-sungen-invocation.d.ts.map +1 -0
- package/dist/cli/project-sungen-invocation.js +76 -0
- package/dist/cli/project-sungen-invocation.js.map +1 -0
- 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/derive-free-feature.d.ts +8 -0
- package/dist/contract/derive-free-feature.d.ts.map +1 -1
- package/dist/contract/derive-free-feature.js +10 -1
- package/dist/contract/derive-free-feature.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 +66 -35
- 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/test-step-join.d.ts +3 -3
- package/dist/contract/trace/test-step-join.d.ts.map +1 -1
- package/dist/contract/trace/test-step-join.js +2 -2
- package/dist/contract/trace/test-step-join.js.map +1 -1
- package/dist/contract/trace/trace-file-reader.d.ts +14 -2
- package/dist/contract/trace/trace-file-reader.d.ts.map +1 -1
- package/dist/contract/trace/trace-file-reader.js +27 -45
- package/dist/contract/trace/trace-file-reader.js.map +1 -1
- package/dist/contract/trace/trace-run-record.d.ts +6 -3
- package/dist/contract/trace/trace-run-record.d.ts.map +1 -1
- package/dist/contract/trace/trace-run-record.js +23 -17
- package/dist/contract/trace/trace-run-record.js.map +1 -1
- package/dist/contract/trace/trace-steps.js +1 -1
- package/dist/contract/trace/trace-steps.js.map +1 -1
- package/dist/contract/trace/trace-zip-limits.d.ts +5 -1
- package/dist/contract/trace/trace-zip-limits.d.ts.map +1 -1
- package/dist/contract/trace/trace-zip-limits.js +6 -2
- package/dist/contract/trace/trace-zip-limits.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/confirmed-decision-commands.d.ts +11 -0
- package/dist/decisions/confirmed-decision-commands.d.ts.map +1 -1
- package/dist/decisions/confirmed-decision-commands.js +21 -3
- package/dist/decisions/confirmed-decision-commands.js.map +1 -1
- package/dist/decisions/human-confirmation.d.ts +2 -0
- package/dist/decisions/human-confirmation.d.ts.map +1 -1
- package/dist/decisions/human-confirmation.js +6 -0
- package/dist/decisions/human-confirmation.js.map +1 -1
- package/dist/exporters/matrix/xlsx-cells.js +1 -1
- package/dist/exporters/matrix/xlsx-cells.js.map +1 -1
- package/dist/graph/evaluate-graph.d.ts +3 -1
- package/dist/graph/evaluate-graph.d.ts.map +1 -1
- package/dist/graph/evaluate-graph.js +8 -5
- package/dist/graph/evaluate-graph.js.map +1 -1
- package/dist/graph/node-decl.d.ts +7 -0
- package/dist/graph/node-decl.d.ts.map +1 -1
- package/dist/graph/node-decl.js.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.d.ts +3 -1
- package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.js +7 -4
- package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
- package/dist/graph/nodes/unit-contract-node.d.ts +2 -1
- package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
- package/dist/graph/nodes/unit-contract-node.js +8 -1
- package/dist/graph/nodes/unit-contract-node.js.map +1 -1
- package/dist/graph/nodes/unit-design-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/unit-design-nodes.js +2 -1
- package/dist/graph/nodes/unit-design-nodes.js.map +1 -1
- package/dist/harness/catalog/gate-rules.yaml +2 -2
- package/dist/harness/gates/audit-report-verification.d.ts +15 -6
- package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
- package/dist/harness/gates/audit-report-verification.js +49 -20
- package/dist/harness/gates/audit-report-verification.js.map +1 -1
- package/dist/harness/gates/delivery-checklist.js +2 -2
- package/dist/harness/gates/delivery-checklist.js.map +1 -1
- package/dist/harness/gates/delivery-decision-gate.d.ts +4 -0
- package/dist/harness/gates/delivery-decision-gate.d.ts.map +1 -1
- package/dist/harness/gates/delivery-decision-gate.js +26 -1
- package/dist/harness/gates/delivery-decision-gate.js.map +1 -1
- package/dist/harness/gates/delivery-decision-step.d.ts +4 -1
- package/dist/harness/gates/delivery-decision-step.d.ts.map +1 -1
- package/dist/harness/gates/delivery-decision-step.js +17 -2
- package/dist/harness/gates/delivery-decision-step.js.map +1 -1
- package/dist/harness/journey.d.ts +19 -0
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +94 -11
- package/dist/harness/journey.js.map +1 -1
- package/dist/harness/provenance.d.ts +29 -0
- package/dist/harness/provenance.d.ts.map +1 -1
- package/dist/harness/provenance.js +109 -9
- package/dist/harness/provenance.js.map +1 -1
- package/dist/hooks/guard-protected-paths.d.ts.map +1 -1
- package/dist/hooks/guard-protected-paths.js +3 -2
- package/dist/hooks/guard-protected-paths.js.map +1 -1
- package/dist/hooks/guard-rules.d.ts +1 -1
- package/dist/hooks/guard-tty-spoof-rule.d.ts +1 -1
- package/dist/hooks/guard-tty-spoof-rule.js +2 -2
- package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
- package/dist/hooks/hand-back-core.d.ts +5 -3
- package/dist/hooks/hand-back-core.d.ts.map +1 -1
- package/dist/hooks/hand-back-core.js +17 -10
- package/dist/hooks/hand-back-core.js.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.js +5 -3
- package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
- package/dist/orchestrator/assets-drift.d.ts +5 -0
- package/dist/orchestrator/assets-drift.d.ts.map +1 -1
- package/dist/orchestrator/assets-drift.js +12 -3
- package/dist/orchestrator/assets-drift.js.map +1 -1
- package/dist/orchestrator/project-initializer.d.ts.map +1 -1
- package/dist/orchestrator/project-initializer.js +1 -0
- package/dist/orchestrator/project-initializer.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
- package/package.json +3 -3
- package/src/capabilities/discover.ts +15 -2
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/contract-derive-command.ts +7 -6
- package/src/cli/commands/contract.ts +36 -5
- package/src/cli/commands/delivery.ts +7 -2
- package/src/cli/commands/journey.ts +4 -4
- package/src/cli/commands/next.ts +8 -6
- package/src/cli/index.ts +1 -1
- package/src/cli/project-sungen-invocation.ts +39 -0
- 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/derive-free-feature.ts +12 -2
- 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 +67 -30
- package/src/contract/trace/read-file-once.ts +95 -0
- package/src/contract/trace/select-final-attempt.ts +3 -3
- package/src/contract/trace/test-step-join.ts +6 -5
- package/src/contract/trace/trace-file-reader.ts +34 -13
- package/src/contract/trace/trace-run-record.ts +17 -11
- package/src/contract/trace/trace-steps.ts +1 -1
- package/src/contract/trace/trace-zip-limits.ts +6 -2
- package/src/contract/verify-approval-decisions.ts +9 -5
- package/src/decisions/confirmed-decision-commands.ts +25 -4
- package/src/decisions/human-confirmation.ts +5 -0
- package/src/exporters/matrix/xlsx-cells.ts +1 -1
- package/src/graph/evaluate-graph.ts +9 -5
- package/src/graph/node-decl.ts +7 -0
- package/src/graph/nodes/test-case-contract-nodes.ts +9 -4
- package/src/graph/nodes/unit-contract-node.ts +8 -1
- package/src/graph/nodes/unit-design-nodes.ts +2 -1
- package/src/harness/catalog/gate-rules.yaml +2 -2
- package/src/harness/gates/audit-report-verification.ts +51 -23
- package/src/harness/gates/delivery-checklist.ts +2 -2
- package/src/harness/gates/delivery-decision-gate.ts +29 -1
- package/src/harness/gates/delivery-decision-step.ts +19 -3
- package/src/harness/journey.ts +95 -13
- package/src/harness/provenance.ts +99 -11
- package/src/hooks/guard-protected-paths.ts +3 -2
- package/src/hooks/guard-rules.ts +1 -1
- package/src/hooks/guard-tty-spoof-rule.ts +3 -3
- package/src/hooks/hand-back-core.ts +17 -11
- package/src/hooks/session-start-unit-state-hook.ts +5 -3
- package/src/orchestrator/assets-drift.ts +12 -5
- package/src/orchestrator/project-initializer.ts +1 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
- package/src/orchestrator/templates/ai-src/commands/run-test.md +1 -1
|
@@ -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) {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Join the test runner's steps (test.trace) with the browser calls (<N>-trace.trace) — phase 07
|
|
3
|
-
* criterion 2, proven 114/114 on a real app.
|
|
4
|
-
* `callId`;
|
|
3
|
+
* criterion 2, proven 114/114 on a real app. Up to format 9 a library call's `stepId` is the runner
|
|
4
|
+
* step's `callId`; from format 10 (Playwright 1.64) the two share ONE id and `stepId` is gone, so the
|
|
5
|
+
* call's own `callId` is the step. Walking the runner's `parentId` chain says where the call ran:
|
|
5
6
|
*
|
|
6
7
|
* body the test function itself, with the path of enclosing `test.step` titles
|
|
7
8
|
* hook a user `beforeEach` / `afterEach` (stage setup | teardown)
|
|
@@ -26,7 +27,7 @@ export type CallPhase =
|
|
|
26
27
|
| { phase: 'unjoined' };
|
|
27
28
|
|
|
28
29
|
export interface JoinedCall {
|
|
29
|
-
part: string; callId: string;
|
|
30
|
+
part: string; callId: string; class: string; method: string;
|
|
30
31
|
params: Record<string, unknown>; stepId?: string; where: CallPhase; failed: boolean; startTime: number;
|
|
31
32
|
}
|
|
32
33
|
|
|
@@ -90,9 +91,9 @@ export function joinCalls(trace: ParsedTrace): { steps: Map<string, RunnerStep>;
|
|
|
90
91
|
for (const e of part.events) {
|
|
91
92
|
if (e.type !== 'before') continue;
|
|
92
93
|
const callId = str(e.callId);
|
|
93
|
-
const stepId = str(e.stepId) || undefined;
|
|
94
|
+
const stepId = str(e.stepId) || (part.version >= 10 ? callId : '') || undefined;
|
|
94
95
|
calls.push({
|
|
95
|
-
part: part.name, callId,
|
|
96
|
+
part: part.name, callId, class: str(e.class), method: str(e.method),
|
|
96
97
|
params: (e.params && typeof e.params === 'object' ? e.params : {}) as Record<string, unknown>,
|
|
97
98
|
stepId, where: stepId ? classifyStep(steps, stepId) : { phase: 'unjoined' }, failed: failed.has(callId), startTime: Number(e.startTime) || 0,
|
|
98
99
|
});
|
|
@@ -5,20 +5,25 @@
|
|
|
5
5
|
* test.trace the test runner's steps (test.step, hooks, fixtures, expect, pw:api)
|
|
6
6
|
* <N>-trace.trace a library part: the browser calls, frame snapshots, logs
|
|
7
7
|
* <N>-trace.network resources (stylesheets inlined into a replayed snapshot come from here)
|
|
8
|
-
* <N>-trace.stacks call
|
|
8
|
+
* <N>-trace.stacks call id → source frames (`file:line` of an opaque step)
|
|
9
9
|
* resources/<sha1> resource bodies
|
|
10
10
|
*
|
|
11
11
|
* Every part that has a header (test.trace and each library part) must declare a supported version;
|
|
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
|
|
|
19
19
|
export type TraceEvent = { type: string } & Record<string, unknown>;
|
|
20
20
|
export interface StackFrame { file: string; line: number; column: number }
|
|
21
|
-
|
|
21
|
+
/**
|
|
22
|
+
* One library part. `version` is its declared format (already accepted by the gate); `stacks` is
|
|
23
|
+
* keyed by call id — v8/v9 wrote a call NUMBER (`11` → `call@11`, Playwright's legacy id), v10 the
|
|
24
|
+
* call id itself, which it shares with the runner step (`ulgt@50`).
|
|
25
|
+
*/
|
|
26
|
+
export interface LibraryPart { name: string; version: number; events: TraceEvent[]; network: TraceEvent[]; stacks: Map<string, StackFrame[]> }
|
|
22
27
|
|
|
23
28
|
export interface ParsedTrace {
|
|
24
29
|
label: string;
|
|
@@ -56,19 +61,21 @@ function parseJsonl(zip: ZipArchive, name: string): TraceEvent[] {
|
|
|
56
61
|
return out;
|
|
57
62
|
}
|
|
58
63
|
|
|
59
|
-
function parseStacks(zip: ZipArchive, name: string): Map<
|
|
60
|
-
const out = new Map<
|
|
64
|
+
function parseStacks(zip: ZipArchive, name: string): Map<string, StackFrame[]> {
|
|
65
|
+
const out = new Map<string, StackFrame[]>();
|
|
61
66
|
if (!zip.has(name)) return out;
|
|
62
67
|
let doc: { files?: unknown; stacks?: unknown };
|
|
63
68
|
try { doc = JSON.parse(zip.readText(name)); } catch { throw new TraceRejected('trace.malformed', `${name} is not JSON`); }
|
|
64
69
|
const files = Array.isArray(doc.files) ? doc.files.map(String) : [];
|
|
65
70
|
for (const row of Array.isArray(doc.stacks) ? doc.stacks : []) {
|
|
66
|
-
if (!Array.isArray(row) ||
|
|
71
|
+
if (!Array.isArray(row) || !Array.isArray(row[1])) continue;
|
|
72
|
+
const id = typeof row[0] === 'number' ? `call@${row[0]}` : row[0];
|
|
73
|
+
if (typeof id !== 'string' || !CALL_ID.test(id)) continue;
|
|
67
74
|
const frames = (row[1] as unknown[]).filter(Array.isArray).map((f) => {
|
|
68
75
|
const [fileIdx, line, column] = f as number[];
|
|
69
76
|
return { file: files[fileIdx] ?? '', line: Number(line) || 0, column: Number(column) || 0 };
|
|
70
77
|
});
|
|
71
|
-
out.set(
|
|
78
|
+
out.set(id, frames);
|
|
72
79
|
}
|
|
73
80
|
return out;
|
|
74
81
|
}
|
|
@@ -81,9 +88,12 @@ export function readTraceBuffer(buf: Buffer, label: string, limits: TraceZipLimi
|
|
|
81
88
|
const runner = parseJsonl(zip, 'test.trace');
|
|
82
89
|
const library: LibraryPart[] = zip.names().filter((n) => /^\d+-trace\.trace$/.test(n)).sort().map((n) => {
|
|
83
90
|
const base = n.replace(/\.trace$/, '');
|
|
91
|
+
const events = parseJsonl(zip, n);
|
|
92
|
+
const version = headerOf(events)?.version;
|
|
84
93
|
return {
|
|
85
94
|
name: n,
|
|
86
|
-
|
|
95
|
+
version: typeof version === 'number' ? version : NaN, // a non-number is refused just below
|
|
96
|
+
events,
|
|
87
97
|
network: zip.has(`${base}.network`) ? parseJsonl(zip, `${base}.network`) : [],
|
|
88
98
|
stacks: parseStacks(zip, `${base}.stacks`),
|
|
89
99
|
};
|
|
@@ -114,10 +124,21 @@ export function readTraceBuffer(buf: Buffer, label: string, limits: TraceZipLimi
|
|
|
114
124
|
};
|
|
115
125
|
}
|
|
116
126
|
|
|
117
|
-
/**
|
|
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. */
|
|
118
142
|
export function readTraceFile(file: string, limits: TraceZipLimits = traceZipLimits()): ParsedTrace {
|
|
119
|
-
|
|
120
|
-
if (!st.isFile()) throw new TraceRejected('zip.malformed', `${file} is not a regular file`);
|
|
121
|
-
if (st.size > limits.maxZipBytes) throw new TraceRejected('zip.too-large', `${st.size} B > ${limits.maxZipBytes}`);
|
|
122
|
-
return readTraceBuffer(fs.readFileSync(file), file, limits);
|
|
143
|
+
return traceFromRead(readFileOnce(file, limits.maxZipBytes), file, limits);
|
|
123
144
|
}
|
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
* the code that produced it live, so an unknown key, a wrong type or a malformed hash means the
|
|
5
5
|
* record is not trusted at all (every trace it would vouch for is unbound).
|
|
6
6
|
*
|
|
7
|
-
* Found by convention: `<project>/test-results/sungen-run-record*.json`
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* Found by convention: `<project>/test-results-free/sungen-run-record*.json` — the free-test config's
|
|
8
|
+
* own outputDir (#941) — or `<project>/test-results/sungen-run-record*.json` (one per run or shard).
|
|
9
|
+
* Playwright wipes its outputDir at the start of every run, so a record and its traces share a
|
|
10
|
+
* lifetime; the free run has its own so the compiled suite's next run cannot delete its traces.
|
|
10
11
|
*/
|
|
11
12
|
import * as fs from 'fs';
|
|
12
13
|
import * as path from 'path';
|
|
@@ -31,13 +32,18 @@ const ATTEMPT = new Set(['testId', 'file', 'title', 'retry', 'status', 'startTim
|
|
|
31
32
|
const SHA = /^[0-9a-f]{64}$/;
|
|
32
33
|
const isObj = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
33
34
|
|
|
34
|
-
|
|
35
|
+
/** The free / impl:code run's own outputDir (the documented free-test config); compiled runs clear `test-results/`. */
|
|
36
|
+
export const FREE_RUN_DIR = 'test-results-free';
|
|
37
|
+
/** Where run records are read, in this order: the free run's dir, then Playwright's default (older configs). */
|
|
38
|
+
export const RUN_RECORD_DIRS: readonly string[] = [FREE_RUN_DIR, 'test-results'];
|
|
35
39
|
|
|
36
40
|
export function findRunRecords(projectRoot: string): string[] {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
+
return RUN_RECORD_DIRS.flatMap((rel) => {
|
|
42
|
+
const dir = path.join(projectRoot, rel);
|
|
43
|
+
let names: string[];
|
|
44
|
+
try { names = fs.readdirSync(dir); } catch { return []; }
|
|
45
|
+
return names.filter((n) => /^sungen-run-record(?:\.[A-Za-z0-9._-]+)?\.json$/.test(n)).sort().map((n) => path.join(dir, n));
|
|
46
|
+
});
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
function attemptOf(raw: unknown, i: number): RunRecordAttempt {
|
|
@@ -54,10 +60,10 @@ function attemptOf(raw: unknown, i: number): RunRecordAttempt {
|
|
|
54
60
|
return { testId, file, title, retry: retry as number, status, startTime, duration, trace: trace as RunRecordAttempt['trace'] };
|
|
55
61
|
}
|
|
56
62
|
|
|
57
|
-
/** Read and check one record; throws an Error naming what is wrong. */
|
|
58
|
-
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 {
|
|
59
65
|
let raw: unknown;
|
|
60
|
-
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]})`); }
|
|
61
67
|
if (!isObj(raw)) throw new Error('not an object');
|
|
62
68
|
const extra = Object.keys(raw).find((k) => !TOP.has(k));
|
|
63
69
|
if (extra) throw new Error(`unknown key "${extra}"`);
|
|
@@ -70,7 +70,7 @@ export function traceIntentSteps(trace: ParsedTrace): IntentStep[] {
|
|
|
70
70
|
const runner = c.stepId ? steps.get(c.stepId) : undefined;
|
|
71
71
|
out.push({
|
|
72
72
|
kind, method: c.method, callId: c.callId, params: c.params, group: groupOf(steps, c.stepId),
|
|
73
|
-
location: firstFrame(runner?.stack) ?? firstFrame(stacks.get(c.part)?.get(c.
|
|
73
|
+
location: firstFrame(runner?.stack) ?? firstFrame(stacks.get(c.part)?.get(c.callId)),
|
|
74
74
|
...(verbatim ? { verbatim } : {}), startTime: c.startTime,
|
|
75
75
|
});
|
|
76
76
|
}
|
|
@@ -50,8 +50,12 @@ export function traceZipLimits(env: Record<string, string | undefined> = process
|
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
-
/**
|
|
54
|
-
|
|
53
|
+
/**
|
|
54
|
+
* Trace format versions this reader was verified against: 8 = Playwright 1.57, 9 = 1.63, 10 = 1.64
|
|
55
|
+
* (one shared call id for a library call and its runner step; stacks keyed by that id). Anything
|
|
56
|
+
* else — a newer format included — is refused until it has been checked the same way.
|
|
57
|
+
*/
|
|
58
|
+
export const SUPPORTED_TRACE_VERSIONS: ReadonlySet<number> = new Set([8, 9, 10]);
|
|
55
59
|
|
|
56
60
|
export type TraceRejectCode =
|
|
57
61
|
| 'zip.too-large' | 'zip.too-many-entries' | 'zip.entry-too-large' | 'zip.total-too-large' | 'zip.ratio'
|
|
@@ -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
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* CLI runs on process.stdin/stderr is the code a test drives with a simulated TTY.
|
|
4
4
|
*
|
|
5
5
|
* waive `sungen journey --screen <u> --waive <OB> --reason …` phrase: the obligation id
|
|
6
|
+
* signoff `sungen journey --screen <u> --signoff [--by <name>]` phrase: SIGN-OFF (review W3)
|
|
6
7
|
* decide `sungen journey [--screen <u>] --decide <kind> --text … --reason … [--anchor …]` phrase: the kind
|
|
7
8
|
* migration a leftover write-ahead sentinel → REVERT or KEEP (takumi T6)
|
|
8
9
|
*
|
|
@@ -16,13 +17,13 @@
|
|
|
16
17
|
import * as path from 'path';
|
|
17
18
|
import { assertNoPendingMigration, pendingMigrationState, PendingMigration, readPendingMigration, resolvePendingMigration, sentinelPath } from '../basis/migration-sentinel';
|
|
18
19
|
import { parseAnchorId } from '../basis/anchor-grammar';
|
|
19
|
-
import { JourneyReport, runJourney, waive } from '../harness/journey';
|
|
20
|
+
import { assertCanSignOff, assertWaivable, JourneyReport, runJourney, signoff, signoffSigner, waive } from '../harness/journey';
|
|
20
21
|
import { reportSlug } from '../harness/unit-paths';
|
|
21
22
|
import { appendDecision, AppendResult } from './append-decision';
|
|
22
23
|
import { PROJECT_SCOPE, unitDecisionScope } from './decision-file';
|
|
23
24
|
import { DecisionDraft, DecisionKind, draftErrors, JsonValue } from './decision-schema';
|
|
24
25
|
import { gitUserName } from './decision-source';
|
|
25
|
-
import { askMigrationChoice, ConfirmationIO, formatCommand, requireTypedConfirmation } from './human-confirmation';
|
|
26
|
+
import { askMigrationChoice, ConfirmationIO, formatCommand, requireInteractive, requireTypedConfirmation } from './human-confirmation';
|
|
26
27
|
import { readDecisions } from './read-decisions';
|
|
27
28
|
import { overridePayloadErrors } from '../harness/gates/gate-override-validation';
|
|
28
29
|
|
|
@@ -30,8 +31,7 @@ export interface WaiveInput { projectRoot: string; unit: string; obligation: str
|
|
|
30
31
|
|
|
31
32
|
export async function waiveWithConfirmation(i: WaiveInput): Promise<JourneyReport> {
|
|
32
33
|
if (!i.reason || !i.reason.trim()) throw new Error('A reason is required to waive (anti-amnesia: a waiver must record WHY). Use --reason "...".');
|
|
33
|
-
|
|
34
|
-
if (!valid.includes(i.obligation)) throw new Error(`Unknown obligation "${i.obligation}". Valid: ${valid.join(', ')}`);
|
|
34
|
+
assertWaivable(runJourney(i.projectRoot, i.unit), i.unit, i.obligation);
|
|
35
35
|
await requireTypedConfirmation({
|
|
36
36
|
what: `waive ${i.obligation} for ${i.unit}`,
|
|
37
37
|
phrase: i.obligation,
|
|
@@ -42,6 +42,27 @@ export async function waiveWithConfirmation(i: WaiveInput): Promise<JourneyRepor
|
|
|
42
42
|
return waive(i.projectRoot, i.unit, i.obligation, i.reason);
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
export interface SignoffInput { projectRoot: string; unit: string; by?: string; io: ConfirmationIO }
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The single human gate (DOS §3.4, S5), typed by a person on a terminal: the gate suggests it, so an
|
|
49
|
+
* agent must not be able to give it. Refused before the prompt when the board cannot be signed off.
|
|
50
|
+
*/
|
|
51
|
+
export async function signoffWithConfirmation(i: SignoffInput): Promise<JourneyReport> {
|
|
52
|
+
const what = `sign off ${i.unit} (the single human gate before delivery)`;
|
|
53
|
+
const command = formatCommand(['sungen', 'journey', '--screen', i.unit, '--signoff', ...(i.by ? ['--by', i.by] : [])]);
|
|
54
|
+
requireInteractive(i.io, what, command); // before the board is read: a refused sign-off writes nothing
|
|
55
|
+
const { signer, gitUser } = signoffSigner(i.projectRoot, i.unit, i.by); // nobody to name → refused, no prompt
|
|
56
|
+
const before = runJourney(i.projectRoot, i.unit);
|
|
57
|
+
assertCanSignOff(before);
|
|
58
|
+
await requireTypedConfirmation({
|
|
59
|
+
what, phrase: 'SIGN-OFF', command,
|
|
60
|
+
summary: [`by: ${signer}${signer !== gitUser ? ` (recorded by git user ${gitUser})` : ''}`, `${before.needsYou.length} item(s) in the review queue — you have reviewed them`, 'it goes stale as soon as the audit changes (sign off again then)'],
|
|
61
|
+
io: i.io,
|
|
62
|
+
});
|
|
63
|
+
return signoff(i.projectRoot, i.unit, i.by);
|
|
64
|
+
}
|
|
65
|
+
|
|
45
66
|
/** Kinds `--decide` records. The others have a command of their own that computes their payload. */
|
|
46
67
|
export const DECIDE_KINDS: readonly DecisionKind[] = ['gate-override', 'clarification'];
|
|
47
68
|
const OWN_COMMAND: Partial<Record<DecisionKind, string>> = {
|
|
@@ -79,6 +79,11 @@ function refuseWithoutTty(what: string, command: string): HumanConfirmationRefus
|
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/** Refuse up front, before any state is read or written, when no human is at a terminal. */
|
|
83
|
+
export function requireInteractive(io: ConfirmationIO, what: string, command: string): void {
|
|
84
|
+
if (!isInteractive(io)) throw refuseWithoutTty(what, command);
|
|
85
|
+
}
|
|
86
|
+
|
|
82
87
|
/** A fresh, hard-to-guess suffix for one prompt. */
|
|
83
88
|
export const confirmationNonce = (): string => randomBytes(3).toString('hex').toUpperCase();
|
|
84
89
|
|
|
@@ -49,7 +49,7 @@ export function draftBanner(ws: ExcelJS.Worksheet, model: MatrixModel): void {
|
|
|
49
49
|
if (model.manifest.approvalState !== 'draft') return;
|
|
50
50
|
ws.mergeCells('A5:H5');
|
|
51
51
|
const c = ws.getCell('A5');
|
|
52
|
-
c.value = 'DRAFT — NOT APPROVED FOR OFFICIAL DELIVERY (review-required findings
|
|
52
|
+
c.value = 'DRAFT — NOT APPROVED FOR OFFICIAL DELIVERY (review-required findings, proposed groups, or an audit that does not pass)';
|
|
53
53
|
c.font = { bold: true, size: 11, name: ARIAL_FONT, color: { argb: 'FFFFFFFF' } };
|
|
54
54
|
c.fill = { type: 'pattern', pattern: 'solid', fgColor: DRAFT_RED };
|
|
55
55
|
c.alignment = { horizontal: 'center', vertical: 'middle' };
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
*
|
|
5
5
|
* open active ∧ ¬satisfied (a test-case node is open when at least one case is)
|
|
6
6
|
* frontier open ∧ (every `requires` satisfied ∨ ¬blocking)
|
|
7
|
-
* cut an open blocking node ends the walk — nothing ranked after it is offered
|
|
7
|
+
* cut an open blocking node ends the walk — nothing ranked after it is offered, except a
|
|
8
|
+
* node's instances that `surfacesPastCut` (a hard contract verdict, #940), which follow
|
|
9
|
+
* the cutting step and never cut themselves
|
|
8
10
|
*
|
|
9
11
|
* `--case <id>` (`testCase` option): the frontier is that case's test-case nodes plus the blocking
|
|
10
12
|
* unit nodes before them; non-blocking unit advice is left to the unit view. A `caseOnly` test-case
|
|
@@ -41,22 +43,24 @@ export function evaluateGraph(
|
|
|
41
43
|
const ordered = [...nodes].sort((a, b) => a.rank - b.rank);
|
|
42
44
|
const open = new Set<string>();
|
|
43
45
|
const out: NextStep[] = [];
|
|
46
|
+
let cut = false;
|
|
44
47
|
for (const node of ordered) {
|
|
45
48
|
if (!node.appliesTo.includes(s.kind) || (node.granularity === 'test-case' && node.caseOnly && !opts.testCase)) continue;
|
|
49
|
+
if (cut && !node.surfacesPastCut) continue;
|
|
46
50
|
let step: NextStep | null = null;
|
|
47
51
|
if (node.granularity === 'unit') {
|
|
48
|
-
if (!node.active(s) || node.satisfied(s)) continue;
|
|
52
|
+
if (!node.active(s) || node.satisfied(s) || (cut && !node.surfacesPastCut!(s))) continue;
|
|
49
53
|
if (!opts.testCase || node.blocking) step = withMeta(node, node.step(s));
|
|
50
54
|
} else {
|
|
51
|
-
const cases = openCases(node, s, tcStates, opts.testCase);
|
|
55
|
+
const cases = openCases(node, s, tcStates, opts.testCase).filter((tc) => !cut || node.surfacesPastCut!(s, tc));
|
|
52
56
|
if (!cases.length) continue;
|
|
53
57
|
const single = Boolean(opts.testCase);
|
|
54
58
|
step = withMeta(node, node.step(s, cases, single), single ? cases[0].id : undefined);
|
|
55
59
|
}
|
|
56
60
|
open.add(node.id);
|
|
57
61
|
const ready = node.requires.every((r) => !open.has(r));
|
|
58
|
-
if (step && (ready || !node.blocking)) out.push(step);
|
|
59
|
-
if (node.blocking)
|
|
62
|
+
if (step && (cut || ready || !node.blocking)) out.push(step);
|
|
63
|
+
if (node.blocking && !cut) cut = true;
|
|
60
64
|
}
|
|
61
65
|
return out;
|
|
62
66
|
}
|
package/src/graph/node-decl.ts
CHANGED
|
@@ -42,6 +42,11 @@ export interface UnitNodeDecl extends NodeDeclBase {
|
|
|
42
42
|
active(s: UnitState): boolean;
|
|
43
43
|
satisfied(s: UnitState): boolean;
|
|
44
44
|
step(s: UnitState): StepCore;
|
|
45
|
+
/**
|
|
46
|
+
* Offered even when a blocking node ranked before it cut the walk — only while this holds (#940: a
|
|
47
|
+
* hard contract verdict must not hide behind a stale or failing audit). It never cuts by itself.
|
|
48
|
+
*/
|
|
49
|
+
surfacesPastCut?(s: UnitState): boolean;
|
|
45
50
|
}
|
|
46
51
|
|
|
47
52
|
export interface TestCaseNodeDecl extends NodeDeclBase {
|
|
@@ -55,6 +60,8 @@ export interface TestCaseNodeDecl extends NodeDeclBase {
|
|
|
55
60
|
satisfied(s: UnitState, tc: TestCaseState): boolean;
|
|
56
61
|
/** One step for `cases` (all open at this node): a unit-level fold, or exactly one case when `single`. */
|
|
57
62
|
step(s: UnitState, cases: TestCaseState[], single: boolean): StepCore;
|
|
63
|
+
/** The cases offered even past a cut (see UnitNodeDecl.surfacesPastCut). */
|
|
64
|
+
surfacesPastCut?(s: UnitState, tc: TestCaseState): boolean;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
export type NodeDecl = UnitNodeDecl | TestCaseNodeDecl;
|
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
* tc.conformance its trace is in review: a `low` target identity do contract check --trace
|
|
12
12
|
* · its final attempt did not pass (inconclusive) do run-test --ids
|
|
13
13
|
* · a step no code can compare (opaque) decide contract check
|
|
14
|
-
*
|
|
14
|
+
* tc.contract-check (a weaker / retargeted / lost check, or an uncommitted lock entry) and tc.unproven
|
|
15
|
+
* are hard verdicts: they are offered past a stale or failing audit (the cut at unit.audit-stale /
|
|
16
|
+
* unit.gate-fail), #940; tc.contract-approve (changed, not weaker) is review and waits behind it. Exempt cases (DOS-11.2) open nothing. A proposed case (never approved) asks nothing of its own: a
|
|
15
17
|
* compiled test without a lock is never blocked, and the unit view offers the approval.
|
|
16
18
|
*/
|
|
17
19
|
import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
@@ -29,13 +31,14 @@ const withDetail = (cases: TestCaseState[]): string =>
|
|
|
29
31
|
const check = (s: UnitState, trace = false): string => `sungen contract check --screen ${s.unit}${trace ? ' --trace' : ''}`;
|
|
30
32
|
|
|
31
33
|
function contractNode(id: string, rank: number, title: string, actor: TestCaseNodeDecl['actor'], gates: string[], caseOnly: boolean,
|
|
32
|
-
active: (tc: TestCaseState) => boolean, step: Step): TestCaseNodeDecl {
|
|
34
|
+
active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean): TestCaseNodeDecl {
|
|
33
35
|
return {
|
|
34
36
|
id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, blocking: false, rank, gates, step,
|
|
35
37
|
requires: caseOnly ? ['unit.gate-fail'] : ['unit.run'], produces: ['contracts.lock.yaml', 'decisions/<id>.yaml'],
|
|
36
38
|
...(caseOnly ? { caseOnly: true as const } : {}),
|
|
37
39
|
active: (_s, tc) => active(tc),
|
|
38
40
|
satisfied: (_s, tc) => exemptionOf(tc) !== null,
|
|
41
|
+
...(hard ? { surfacesPastCut: (_s: UnitState, tc: TestCaseState) => hard(tc) } : {}),
|
|
39
42
|
};
|
|
40
43
|
}
|
|
41
44
|
|
|
@@ -60,10 +63,12 @@ export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
|
|
|
60
63
|
(tc) => Boolean(tc.contract?.uncommitted) || HARD_LADDER.has(classOf(tc) ?? ''),
|
|
61
64
|
(s, [c]) => ({ kind: 'blocked', command: check(s), because: c.contract?.uncommitted
|
|
62
65
|
? `its lock entry is not what QA approved and committed (contract.lock-uncommitted) — \`sungen next ${s.unit}\` names the repair; \`sungen delivery\` is blocked until then`
|
|
63
|
-
: `this case breaks its approved contract (${c.contract?.class}${c.contract?.detail ? `: ${c.contract.detail}` : ''}) — restore the approved check (in the .feature, or in the hand-written test of a free / impl:code case), or a QA approves the change on a terminal (\`sungen contract approve --screen ${s.unit} --scenario ${c.id}\`). \`sungen delivery\` is blocked until then; never weaken an expected value to go green` })
|
|
66
|
+
: `this case breaks its approved contract (${c.contract?.class}${c.contract?.detail ? `: ${c.contract.detail}` : ''}) — restore the approved check (in the .feature, or in the hand-written test of a free / impl:code case), or a QA approves the change on a terminal (\`sungen contract approve --screen ${s.unit} --scenario ${c.id}\`). \`sungen delivery\` is blocked until then; never weaken an expected value to go green` }),
|
|
67
|
+
(tc) => Boolean(tc.contract?.uncommitted) || HARD_LADDER.has(classOf(tc) ?? '')),
|
|
64
68
|
contractNode('tc.unproven', 136, 'Prove the approved free cases with a trace', 'ai', ['contract.unproven'], false,
|
|
65
69
|
(tc) => classOf(tc) === 'unproven',
|
|
66
|
-
(s, cases) => ({ kind: 'do', command: `/sungen:run-test ${s.unit}${idsArg(cases)}`, because: `${cases.length} approved free / impl:code test case(s) have no trace that proves them (${withDetail(cases)}) — run them again with trace: 'on' and the sungen run-record reporter (run-test Phase 4 does), then \`${check(s, true)}\`. Until then \`sungen contract check\` fails (contract.unproven) and \`sungen delivery\` is blocked; re-approving or editing the .feature cannot prove them` })
|
|
70
|
+
(s, cases) => ({ kind: 'do', command: `/sungen:run-test ${s.unit}${idsArg(cases)}`, because: `${cases.length} approved free / impl:code test case(s) have no trace that proves them (${withDetail(cases)}) — run them again with trace: 'on' and the sungen run-record reporter (run-test Phase 4 does), then \`${check(s, true)}\`. Until then \`sungen contract check\` fails (contract.unproven) and \`sungen delivery\` is blocked; re-approving or editing the .feature cannot prove them` }),
|
|
71
|
+
(tc) => classOf(tc) === 'unproven'),
|
|
67
72
|
contractNode('tc.conformance', 138, 'Settle the traces in review', 'ai', ['contract.opaque-step', 'contract.inconclusive'], false,
|
|
68
73
|
(tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep),
|
|
69
74
|
];
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
* verdict `sungen contract check` and the delivery guard rebuild (buildContractVerdict), so the three
|
|
4
4
|
* never disagree. Always `decide`: an agent presents it and never approves its own edit.
|
|
5
5
|
*
|
|
6
|
-
* unit.contract-approve (rank 105, after the audit gate, before the first run
|
|
6
|
+
* unit.contract-approve (rank 105, after the audit gate, before the first run; a hard verdict —
|
|
7
|
+
* hard or uncommitted — is offered past a stale or failing audit too, #940)
|
|
7
8
|
* error the verdict could not be built → check it on a terminal
|
|
8
9
|
* uncommitted by kind: tampered → re-approve or restore · untracked → commit after review ·
|
|
9
10
|
* edited → review and commit, or `git checkout --` the lock · unverified → commit the
|
|
@@ -66,6 +67,12 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
|
|
|
66
67
|
active: (s) => Boolean(s.contract),
|
|
67
68
|
satisfied: nothingToDecide,
|
|
68
69
|
step: contractStep,
|
|
70
|
+
// A hard verdict must not wait behind a re-audit (#940): the audit's PASS says nothing about it —
|
|
71
|
+
// a broken approved check, or a lock that is not what QA approved and committed (contract.lock-
|
|
72
|
+
// uncommitted, hard in `contract check`; the step names that repair first). What stays behind the
|
|
73
|
+
// cut: a changed-not-weaker contract (review — QA re-approves after the design is fixed) and a
|
|
74
|
+
// verdict that could not be built (not a verdict; `contract check` names the error).
|
|
75
|
+
surfacesPastCut: (s) => !s.contract!.error && (s.contract!.uncommitted > 0 || s.contract!.hard > 0),
|
|
69
76
|
},
|
|
70
77
|
{
|
|
71
78
|
id: 'unit.contract-proposed', title: 'Approve the Test Contract', actor: 'human', granularity: 'unit',
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* 6 unit.flow-inventory a flow contract without `flows:` → add it, re-audit
|
|
11
11
|
*/
|
|
12
12
|
import { ALL_KINDS, UnitNodeDecl, unitKindDir } from '../node-decl';
|
|
13
|
+
import { updateChannelFlag } from '../../cli/project-sungen-invocation';
|
|
13
14
|
|
|
14
15
|
const design = (unit: string): string => `/sungen:create-test ${unit}`;
|
|
15
16
|
|
|
@@ -34,7 +35,7 @@ export const UNIT_DESIGN_NODES: readonly UnitNodeDecl[] = [
|
|
|
34
35
|
satisfied: (s) => !s.assetsDrift,
|
|
35
36
|
step: (s) => ({
|
|
36
37
|
kind: 'do',
|
|
37
|
-
command:
|
|
38
|
+
command: `sungen update${updateChannelFlag(s.assetsDrift!.runningVersion)}`, // keep the channel (#939)
|
|
38
39
|
because: `this project's AI commands/skills were written by sungen ${s.assetsDrift!.recordedVersion} and you are running ${s.assetsDrift!.runningVersion} — upgrading the package does NOT refresh them, so any rule added since ${s.assetsDrift!.recordedVersion} is not in effect for the next run`,
|
|
39
40
|
}),
|
|
40
41
|
},
|
|
@@ -535,8 +535,8 @@ rules:
|
|
|
535
535
|
stage: deliver
|
|
536
536
|
severity: error
|
|
537
537
|
kind: soft
|
|
538
|
-
rationale: "Pre-delivery halt: everything automated plus the single human sign-off
|
|
539
|
-
fix: "Close the open obligations
|
|
538
|
+
rationale: "Pre-delivery halt: everything automated must be satisfied or waived with a reason, plus the single human sign-off, which is given (sungen journey --signoff), never waived."
|
|
539
|
+
fix: "Close or waive the open obligations, then a human signs off: sungen journey --screen <unit> --signoff --by <name>."
|
|
540
540
|
since: "<=3.2.34"
|
|
541
541
|
offAllowed: false
|
|
542
542
|
|