@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.
Files changed (233) hide show
  1. package/dist/capabilities/discover.d.ts +4 -0
  2. package/dist/capabilities/discover.d.ts.map +1 -1
  3. package/dist/capabilities/discover.js +16 -2
  4. package/dist/capabilities/discover.js.map +1 -1
  5. package/dist/cli/commands/audit.js +1 -1
  6. package/dist/cli/commands/audit.js.map +1 -1
  7. package/dist/cli/commands/contract-derive-command.d.ts +3 -2
  8. package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
  9. package/dist/cli/commands/contract-derive-command.js +3 -3
  10. package/dist/cli/commands/contract-derive-command.js.map +1 -1
  11. package/dist/cli/commands/contract.d.ts +3 -0
  12. package/dist/cli/commands/contract.d.ts.map +1 -1
  13. package/dist/cli/commands/contract.js +35 -5
  14. package/dist/cli/commands/contract.js.map +1 -1
  15. package/dist/cli/commands/delivery.d.ts.map +1 -1
  16. package/dist/cli/commands/delivery.js +9 -2
  17. package/dist/cli/commands/delivery.js.map +1 -1
  18. package/dist/cli/commands/journey.js +2 -2
  19. package/dist/cli/commands/journey.js.map +1 -1
  20. package/dist/cli/commands/next.d.ts.map +1 -1
  21. package/dist/cli/commands/next.js +8 -6
  22. package/dist/cli/commands/next.js.map +1 -1
  23. package/dist/cli/index.js +1 -1
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/project-sungen-invocation.d.ts +7 -0
  26. package/dist/cli/project-sungen-invocation.d.ts.map +1 -0
  27. package/dist/cli/project-sungen-invocation.js +76 -0
  28. package/dist/cli/project-sungen-invocation.js.map +1 -0
  29. package/dist/contract/accept-opaque-steps.d.ts +3 -0
  30. package/dist/contract/accept-opaque-steps.d.ts.map +1 -0
  31. package/dist/contract/accept-opaque-steps.js +114 -0
  32. package/dist/contract/accept-opaque-steps.js.map +1 -0
  33. package/dist/contract/approve-contract.d.ts +8 -1
  34. package/dist/contract/approve-contract.d.ts.map +1 -1
  35. package/dist/contract/approve-contract.js +6 -0
  36. package/dist/contract/approve-contract.js.map +1 -1
  37. package/dist/contract/build-contract-verdict.js +2 -2
  38. package/dist/contract/build-contract-verdict.js.map +1 -1
  39. package/dist/contract/build-trace-conformance.d.ts +5 -1
  40. package/dist/contract/build-trace-conformance.d.ts.map +1 -1
  41. package/dist/contract/build-trace-conformance.js +9 -4
  42. package/dist/contract/build-trace-conformance.js.map +1 -1
  43. package/dist/contract/classify-contract-change.d.ts +3 -2
  44. package/dist/contract/classify-contract-change.d.ts.map +1 -1
  45. package/dist/contract/classify-contract-change.js.map +1 -1
  46. package/dist/contract/classify-trace-conformance.d.ts +18 -3
  47. package/dist/contract/classify-trace-conformance.d.ts.map +1 -1
  48. package/dist/contract/classify-trace-conformance.js +64 -17
  49. package/dist/contract/classify-trace-conformance.js.map +1 -1
  50. package/dist/contract/contract-schema.d.ts +21 -0
  51. package/dist/contract/contract-schema.d.ts.map +1 -1
  52. package/dist/contract/contract-schema.js +25 -3
  53. package/dist/contract/contract-schema.js.map +1 -1
  54. package/dist/contract/contracts-lock-file.d.ts.map +1 -1
  55. package/dist/contract/contracts-lock-file.js +1 -0
  56. package/dist/contract/contracts-lock-file.js.map +1 -1
  57. package/dist/contract/derive-free-feature.d.ts +8 -0
  58. package/dist/contract/derive-free-feature.d.ts.map +1 -1
  59. package/dist/contract/derive-free-feature.js +10 -1
  60. package/dist/contract/derive-free-feature.js.map +1 -1
  61. package/dist/contract/project-contract-blockers.d.ts.map +1 -1
  62. package/dist/contract/project-contract-blockers.js +4 -2
  63. package/dist/contract/project-contract-blockers.js.map +1 -1
  64. package/dist/contract/record-contract-approval.d.ts +16 -1
  65. package/dist/contract/record-contract-approval.d.ts.map +1 -1
  66. package/dist/contract/record-contract-approval.js +29 -1
  67. package/dist/contract/record-contract-approval.js.map +1 -1
  68. package/dist/contract/render-free-gherkin.d.ts +2 -0
  69. package/dist/contract/render-free-gherkin.d.ts.map +1 -1
  70. package/dist/contract/render-free-gherkin.js +6 -0
  71. package/dist/contract/render-free-gherkin.js.map +1 -1
  72. package/dist/contract/trace/collect-trace-evidence.d.ts +8 -3
  73. package/dist/contract/trace/collect-trace-evidence.d.ts.map +1 -1
  74. package/dist/contract/trace/collect-trace-evidence.js +66 -35
  75. package/dist/contract/trace/collect-trace-evidence.js.map +1 -1
  76. package/dist/contract/trace/read-file-once.d.ts +20 -0
  77. package/dist/contract/trace/read-file-once.d.ts.map +1 -0
  78. package/dist/contract/trace/read-file-once.js +135 -0
  79. package/dist/contract/trace/read-file-once.js.map +1 -0
  80. package/dist/contract/trace/select-final-attempt.d.ts +2 -2
  81. package/dist/contract/trace/select-final-attempt.d.ts.map +1 -1
  82. package/dist/contract/trace/select-final-attempt.js +3 -3
  83. package/dist/contract/trace/select-final-attempt.js.map +1 -1
  84. package/dist/contract/trace/test-step-join.d.ts +3 -3
  85. package/dist/contract/trace/test-step-join.d.ts.map +1 -1
  86. package/dist/contract/trace/test-step-join.js +2 -2
  87. package/dist/contract/trace/test-step-join.js.map +1 -1
  88. package/dist/contract/trace/trace-file-reader.d.ts +14 -2
  89. package/dist/contract/trace/trace-file-reader.d.ts.map +1 -1
  90. package/dist/contract/trace/trace-file-reader.js +27 -45
  91. package/dist/contract/trace/trace-file-reader.js.map +1 -1
  92. package/dist/contract/trace/trace-run-record.d.ts +6 -3
  93. package/dist/contract/trace/trace-run-record.d.ts.map +1 -1
  94. package/dist/contract/trace/trace-run-record.js +23 -17
  95. package/dist/contract/trace/trace-run-record.js.map +1 -1
  96. package/dist/contract/trace/trace-steps.js +1 -1
  97. package/dist/contract/trace/trace-steps.js.map +1 -1
  98. package/dist/contract/trace/trace-zip-limits.d.ts +5 -1
  99. package/dist/contract/trace/trace-zip-limits.d.ts.map +1 -1
  100. package/dist/contract/trace/trace-zip-limits.js +6 -2
  101. package/dist/contract/trace/trace-zip-limits.js.map +1 -1
  102. package/dist/contract/verify-approval-decisions.d.ts.map +1 -1
  103. package/dist/contract/verify-approval-decisions.js +9 -4
  104. package/dist/contract/verify-approval-decisions.js.map +1 -1
  105. package/dist/decisions/confirmed-decision-commands.d.ts +11 -0
  106. package/dist/decisions/confirmed-decision-commands.d.ts.map +1 -1
  107. package/dist/decisions/confirmed-decision-commands.js +21 -3
  108. package/dist/decisions/confirmed-decision-commands.js.map +1 -1
  109. package/dist/decisions/human-confirmation.d.ts +2 -0
  110. package/dist/decisions/human-confirmation.d.ts.map +1 -1
  111. package/dist/decisions/human-confirmation.js +6 -0
  112. package/dist/decisions/human-confirmation.js.map +1 -1
  113. package/dist/exporters/matrix/xlsx-cells.js +1 -1
  114. package/dist/exporters/matrix/xlsx-cells.js.map +1 -1
  115. package/dist/graph/evaluate-graph.d.ts +3 -1
  116. package/dist/graph/evaluate-graph.d.ts.map +1 -1
  117. package/dist/graph/evaluate-graph.js +8 -5
  118. package/dist/graph/evaluate-graph.js.map +1 -1
  119. package/dist/graph/node-decl.d.ts +7 -0
  120. package/dist/graph/node-decl.d.ts.map +1 -1
  121. package/dist/graph/node-decl.js.map +1 -1
  122. package/dist/graph/nodes/test-case-contract-nodes.d.ts +3 -1
  123. package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
  124. package/dist/graph/nodes/test-case-contract-nodes.js +7 -4
  125. package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
  126. package/dist/graph/nodes/unit-contract-node.d.ts +2 -1
  127. package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
  128. package/dist/graph/nodes/unit-contract-node.js +8 -1
  129. package/dist/graph/nodes/unit-contract-node.js.map +1 -1
  130. package/dist/graph/nodes/unit-design-nodes.d.ts.map +1 -1
  131. package/dist/graph/nodes/unit-design-nodes.js +2 -1
  132. package/dist/graph/nodes/unit-design-nodes.js.map +1 -1
  133. package/dist/harness/catalog/gate-rules.yaml +2 -2
  134. package/dist/harness/gates/audit-report-verification.d.ts +15 -6
  135. package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
  136. package/dist/harness/gates/audit-report-verification.js +49 -20
  137. package/dist/harness/gates/audit-report-verification.js.map +1 -1
  138. package/dist/harness/gates/delivery-checklist.js +2 -2
  139. package/dist/harness/gates/delivery-checklist.js.map +1 -1
  140. package/dist/harness/gates/delivery-decision-gate.d.ts +4 -0
  141. package/dist/harness/gates/delivery-decision-gate.d.ts.map +1 -1
  142. package/dist/harness/gates/delivery-decision-gate.js +26 -1
  143. package/dist/harness/gates/delivery-decision-gate.js.map +1 -1
  144. package/dist/harness/gates/delivery-decision-step.d.ts +4 -1
  145. package/dist/harness/gates/delivery-decision-step.d.ts.map +1 -1
  146. package/dist/harness/gates/delivery-decision-step.js +17 -2
  147. package/dist/harness/gates/delivery-decision-step.js.map +1 -1
  148. package/dist/harness/journey.d.ts +19 -0
  149. package/dist/harness/journey.d.ts.map +1 -1
  150. package/dist/harness/journey.js +94 -11
  151. package/dist/harness/journey.js.map +1 -1
  152. package/dist/harness/provenance.d.ts +29 -0
  153. package/dist/harness/provenance.d.ts.map +1 -1
  154. package/dist/harness/provenance.js +109 -9
  155. package/dist/harness/provenance.js.map +1 -1
  156. package/dist/hooks/guard-protected-paths.d.ts.map +1 -1
  157. package/dist/hooks/guard-protected-paths.js +3 -2
  158. package/dist/hooks/guard-protected-paths.js.map +1 -1
  159. package/dist/hooks/guard-rules.d.ts +1 -1
  160. package/dist/hooks/guard-tty-spoof-rule.d.ts +1 -1
  161. package/dist/hooks/guard-tty-spoof-rule.js +2 -2
  162. package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
  163. package/dist/hooks/hand-back-core.d.ts +5 -3
  164. package/dist/hooks/hand-back-core.d.ts.map +1 -1
  165. package/dist/hooks/hand-back-core.js +17 -10
  166. package/dist/hooks/hand-back-core.js.map +1 -1
  167. package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
  168. package/dist/hooks/session-start-unit-state-hook.js +5 -3
  169. package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
  170. package/dist/orchestrator/assets-drift.d.ts +5 -0
  171. package/dist/orchestrator/assets-drift.d.ts.map +1 -1
  172. package/dist/orchestrator/assets-drift.js +12 -3
  173. package/dist/orchestrator/assets-drift.js.map +1 -1
  174. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  175. package/dist/orchestrator/project-initializer.js +1 -0
  176. package/dist/orchestrator/project-initializer.js.map +1 -1
  177. package/dist/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  178. package/dist/orchestrator/templates/ai-src/commands/run-test.md +1 -1
  179. package/package.json +3 -3
  180. package/src/capabilities/discover.ts +15 -2
  181. package/src/cli/commands/audit.ts +1 -1
  182. package/src/cli/commands/contract-derive-command.ts +7 -6
  183. package/src/cli/commands/contract.ts +36 -5
  184. package/src/cli/commands/delivery.ts +7 -2
  185. package/src/cli/commands/journey.ts +4 -4
  186. package/src/cli/commands/next.ts +8 -6
  187. package/src/cli/index.ts +1 -1
  188. package/src/cli/project-sungen-invocation.ts +39 -0
  189. package/src/contract/accept-opaque-steps.ts +114 -0
  190. package/src/contract/approve-contract.ts +10 -3
  191. package/src/contract/build-contract-verdict.ts +2 -2
  192. package/src/contract/build-trace-conformance.ts +8 -3
  193. package/src/contract/classify-contract-change.ts +3 -3
  194. package/src/contract/classify-trace-conformance.ts +76 -22
  195. package/src/contract/contract-schema.ts +31 -2
  196. package/src/contract/contracts-lock-file.ts +1 -0
  197. package/src/contract/derive-free-feature.ts +12 -2
  198. package/src/contract/project-contract-blockers.ts +4 -2
  199. package/src/contract/record-contract-approval.ts +41 -1
  200. package/src/contract/render-free-gherkin.ts +5 -0
  201. package/src/contract/trace/collect-trace-evidence.ts +67 -30
  202. package/src/contract/trace/read-file-once.ts +95 -0
  203. package/src/contract/trace/select-final-attempt.ts +3 -3
  204. package/src/contract/trace/test-step-join.ts +6 -5
  205. package/src/contract/trace/trace-file-reader.ts +34 -13
  206. package/src/contract/trace/trace-run-record.ts +17 -11
  207. package/src/contract/trace/trace-steps.ts +1 -1
  208. package/src/contract/trace/trace-zip-limits.ts +6 -2
  209. package/src/contract/verify-approval-decisions.ts +9 -5
  210. package/src/decisions/confirmed-decision-commands.ts +25 -4
  211. package/src/decisions/human-confirmation.ts +5 -0
  212. package/src/exporters/matrix/xlsx-cells.ts +1 -1
  213. package/src/graph/evaluate-graph.ts +9 -5
  214. package/src/graph/node-decl.ts +7 -0
  215. package/src/graph/nodes/test-case-contract-nodes.ts +9 -4
  216. package/src/graph/nodes/unit-contract-node.ts +8 -1
  217. package/src/graph/nodes/unit-design-nodes.ts +2 -1
  218. package/src/harness/catalog/gate-rules.yaml +2 -2
  219. package/src/harness/gates/audit-report-verification.ts +51 -23
  220. package/src/harness/gates/delivery-checklist.ts +2 -2
  221. package/src/harness/gates/delivery-decision-gate.ts +29 -1
  222. package/src/harness/gates/delivery-decision-step.ts +19 -3
  223. package/src/harness/journey.ts +95 -13
  224. package/src/harness/provenance.ts +99 -11
  225. package/src/hooks/guard-protected-paths.ts +3 -2
  226. package/src/hooks/guard-rules.ts +1 -1
  227. package/src/hooks/guard-tty-spoof-rule.ts +3 -3
  228. package/src/hooks/hand-back-core.ts +17 -11
  229. package/src/hooks/session-start-unit-state-hook.ts +5 -3
  230. package/src/orchestrator/assets-drift.ts +12 -5
  231. package/src/orchestrator/project-initializer.ts +1 -0
  232. package/src/orchestrator/templates/ai-src/commands/create-test.md +2 -2
  233. 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 the file is not a Playwright JSON report at all. */
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. A library call's `stepId` is the runner step's
4
- * `callId`; walking the runner's `parentId` chain says where the call ran:
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; callNumber: number; class: string; method: 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, callNumber: Number(callId.replace(/^call@/, '')) || 0, class: str(e.class), method: str(e.method),
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 number → source frames (`file:line` of an opaque step)
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 * as fs from 'fs';
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
- export interface LibraryPart { name: string; events: TraceEvent[]; network: TraceEvent[]; stacks: Map<number, StackFrame[]> }
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<number, StackFrame[]> {
60
- const out = new Map<number, StackFrame[]>();
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) || typeof row[0] !== 'number' || !Array.isArray(row[1])) continue;
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(row[0], frames);
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
- events: parseJsonl(zip, n),
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
- /** Read a trace from disk: a regular file, size-checked BEFORE it is read into memory. */
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
- const st = fs.statSync(file);
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` (one per run or shard).
8
- * Playwright wipes `test-results/` at the start of every run, so a record and its traces share a
9
- * lifetime.
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
- export const RUN_RECORD_DIR = 'test-results';
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
- const dir = path.join(projectRoot, RUN_RECORD_DIR);
38
- let names: string[];
39
- try { names = fs.readdirSync(dir); } catch { return []; }
40
- return names.filter((n) => /^sungen-run-record(?:\.[A-Za-z0-9._-]+)?\.json$/.test(n)).sort().map((n) => path.join(dir, n));
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.callNumber)),
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
- /** Trace format versions this reader was verified against: 8 = Playwright 1.57, 9 = 1.63. */
54
- export const SUPPORTED_TRACE_VERSIONS: ReadonlySet<number> = new Set([8, 9]);
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, and whose `valid_from` is the entry's `approvedAt`. A lock line copied
6
- * from elsewhere, re-pointed at another decision, or backdated fails here — `unverified`, reported
7
- * under contract.lock-uncommitted (hard).
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
- if (!listed.some((s) => s && s.id === scenario && s.hash === lockEntry.hash)) return `decision ${d.id} does not approve ${scenario} at hash ${lockEntry.hash}`;
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
- const valid = runJourney(i.projectRoot, i.unit).obligations.map((o) => o.id);
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 or proposed groups remain)';
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) break;
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
  }
@@ -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
- * Exempt cases (DOS-11.2) open nothing. A proposed case (never approved) asks nothing of its own: a
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: 'sungen update',
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 must be satisfied or waived with a reason."
539
- fix: "Close the open obligations and sign off, or waive with a reason."
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