@sun-asterisk/sungen 3.3.0-alpha.6 → 3.3.0-alpha.8

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 (185) hide show
  1. package/dist/cli/case-triage-hint.d.ts +14 -0
  2. package/dist/cli/case-triage-hint.d.ts.map +1 -0
  3. package/dist/cli/case-triage-hint.js +23 -0
  4. package/dist/cli/case-triage-hint.js.map +1 -0
  5. package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
  6. package/dist/cli/commands/contract-derive-command.js +4 -1
  7. package/dist/cli/commands/contract-derive-command.js.map +1 -1
  8. package/dist/cli/commands/contract.d.ts.map +1 -1
  9. package/dist/cli/commands/contract.js +5 -2
  10. package/dist/cli/commands/contract.js.map +1 -1
  11. package/dist/cli/commands/delivery.d.ts.map +1 -1
  12. package/dist/cli/commands/delivery.js +50 -16
  13. package/dist/cli/commands/delivery.js.map +1 -1
  14. package/dist/cli/commands/gate.d.ts.map +1 -1
  15. package/dist/cli/commands/gate.js +2 -1
  16. package/dist/cli/commands/gate.js.map +1 -1
  17. package/dist/cli/commands/generate.d.ts.map +1 -1
  18. package/dist/cli/commands/generate.js +14 -2
  19. package/dist/cli/commands/generate.js.map +1 -1
  20. package/dist/cli/commands/journey.d.ts.map +1 -1
  21. package/dist/cli/commands/journey.js +4 -2
  22. package/dist/cli/commands/journey.js.map +1 -1
  23. package/dist/cli/commands/next.d.ts +7 -3
  24. package/dist/cli/commands/next.d.ts.map +1 -1
  25. package/dist/cli/commands/next.js +28 -5
  26. package/dist/cli/commands/next.js.map +1 -1
  27. package/dist/cli/commands/triage.d.ts.map +1 -1
  28. package/dist/cli/commands/triage.js +5 -3
  29. package/dist/cli/commands/triage.js.map +1 -1
  30. package/dist/cli/commands/update.d.ts.map +1 -1
  31. package/dist/cli/commands/update.js +6 -2
  32. package/dist/cli/commands/update.js.map +1 -1
  33. package/dist/cli/index.js +4 -0
  34. package/dist/cli/index.js.map +1 -1
  35. package/dist/cli/json-error-on-exit.d.ts +34 -0
  36. package/dist/cli/json-error-on-exit.d.ts.map +1 -0
  37. package/dist/cli/json-error-on-exit.js +63 -0
  38. package/dist/cli/json-error-on-exit.js.map +1 -0
  39. package/dist/cli/project-sungen-invocation.d.ts +6 -0
  40. package/dist/cli/project-sungen-invocation.d.ts.map +1 -1
  41. package/dist/cli/project-sungen-invocation.js +19 -0
  42. package/dist/cli/project-sungen-invocation.js.map +1 -1
  43. package/dist/cli/run-command-hints.d.ts +9 -0
  44. package/dist/cli/run-command-hints.d.ts.map +1 -0
  45. package/dist/cli/run-command-hints.js +84 -0
  46. package/dist/cli/run-command-hints.js.map +1 -0
  47. package/dist/cli/waiting-for-you-block.d.ts +18 -0
  48. package/dist/cli/waiting-for-you-block.d.ts.map +1 -0
  49. package/dist/cli/waiting-for-you-block.js +22 -0
  50. package/dist/cli/waiting-for-you-block.js.map +1 -0
  51. package/dist/contract/derive-free-feature.d.ts +2 -0
  52. package/dist/contract/derive-free-feature.d.ts.map +1 -1
  53. package/dist/contract/derive-free-feature.js +22 -0
  54. package/dist/contract/derive-free-feature.js.map +1 -1
  55. package/dist/decisions/legacy-waiver-migration.d.ts +2 -0
  56. package/dist/decisions/legacy-waiver-migration.d.ts.map +1 -1
  57. package/dist/decisions/legacy-waiver-migration.js +5 -2
  58. package/dist/decisions/legacy-waiver-migration.js.map +1 -1
  59. package/dist/exporters/csv-exporter.d.ts +11 -2
  60. package/dist/exporters/csv-exporter.d.ts.map +1 -1
  61. package/dist/exporters/csv-exporter.js +8 -4
  62. package/dist/exporters/csv-exporter.js.map +1 -1
  63. package/dist/exporters/matrix/export.d.ts +6 -1
  64. package/dist/exporters/matrix/export.d.ts.map +1 -1
  65. package/dist/exporters/matrix/export.js +11 -4
  66. package/dist/exporters/matrix/export.js.map +1 -1
  67. package/dist/exporters/xlsx-exporter.d.ts +3 -2
  68. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  69. package/dist/exporters/xlsx-exporter.js +4 -4
  70. package/dist/exporters/xlsx-exporter.js.map +1 -1
  71. package/dist/graph/board-human-actions.d.ts +5 -0
  72. package/dist/graph/board-human-actions.d.ts.map +1 -0
  73. package/dist/graph/board-human-actions.js +83 -0
  74. package/dist/graph/board-human-actions.js.map +1 -0
  75. package/dist/graph/evaluate-graph.d.ts +18 -0
  76. package/dist/graph/evaluate-graph.d.ts.map +1 -1
  77. package/dist/graph/evaluate-graph.js +31 -6
  78. package/dist/graph/evaluate-graph.js.map +1 -1
  79. package/dist/graph/human-action.d.ts +21 -0
  80. package/dist/graph/human-action.d.ts.map +1 -0
  81. package/dist/graph/human-action.js +3 -0
  82. package/dist/graph/human-action.js.map +1 -0
  83. package/dist/graph/node-decl.d.ts +9 -0
  84. package/dist/graph/node-decl.d.ts.map +1 -1
  85. package/dist/graph/node-decl.js +4 -1
  86. package/dist/graph/node-decl.js.map +1 -1
  87. package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
  88. package/dist/graph/nodes/test-case-contract-nodes.js +16 -3
  89. package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
  90. package/dist/graph/nodes/test-case-nodes.d.ts.map +1 -1
  91. package/dist/graph/nodes/test-case-nodes.js +16 -4
  92. package/dist/graph/nodes/test-case-nodes.js.map +1 -1
  93. package/dist/graph/nodes/unit-audit-nodes.d.ts.map +1 -1
  94. package/dist/graph/nodes/unit-audit-nodes.js +1 -0
  95. package/dist/graph/nodes/unit-audit-nodes.js.map +1 -1
  96. package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
  97. package/dist/graph/nodes/unit-contract-node.js +8 -0
  98. package/dist/graph/nodes/unit-contract-node.js.map +1 -1
  99. package/dist/graph/unit-artifact-probes.d.ts.map +1 -1
  100. package/dist/graph/unit-artifact-probes.js +3 -1
  101. package/dist/graph/unit-artifact-probes.js.map +1 -1
  102. package/dist/harness/catalog/gate-rules.yaml +1 -1
  103. package/dist/harness/gates/audit-report-verification.d.ts +2 -0
  104. package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
  105. package/dist/harness/gates/audit-report-verification.js +8 -3
  106. package/dist/harness/gates/audit-report-verification.js.map +1 -1
  107. package/dist/harness/gates/delivery-checklist.d.ts.map +1 -1
  108. package/dist/harness/gates/delivery-checklist.js +9 -1
  109. package/dist/harness/gates/delivery-checklist.js.map +1 -1
  110. package/dist/harness/gates/in-run-decision-check.d.ts.map +1 -1
  111. package/dist/harness/gates/in-run-decision-check.js +10 -3
  112. package/dist/harness/gates/in-run-decision-check.js.map +1 -1
  113. package/dist/harness/journey.d.ts +21 -0
  114. package/dist/harness/journey.d.ts.map +1 -1
  115. package/dist/harness/journey.js +61 -11
  116. package/dist/harness/journey.js.map +1 -1
  117. package/dist/harness/next-step.d.ts +16 -0
  118. package/dist/harness/next-step.d.ts.map +1 -1
  119. package/dist/harness/next-step.js +14 -0
  120. package/dist/harness/next-step.js.map +1 -1
  121. package/dist/hooks/guard-tty-spoof-rule.d.ts +3 -2
  122. package/dist/hooks/guard-tty-spoof-rule.d.ts.map +1 -1
  123. package/dist/hooks/guard-tty-spoof-rule.js +2 -2
  124. package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
  125. package/dist/hooks/hand-back-core.d.ts +5 -2
  126. package/dist/hooks/hand-back-core.d.ts.map +1 -1
  127. package/dist/hooks/hand-back-core.js +10 -4
  128. package/dist/hooks/hand-back-core.js.map +1 -1
  129. package/dist/hooks/session-start-unit-state-hook.d.ts +2 -1
  130. package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
  131. package/dist/hooks/session-start-unit-state-hook.js +4 -1
  132. package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
  133. package/dist/hooks/stop-next-step-hook.d.ts.map +1 -1
  134. package/dist/hooks/stop-next-step-hook.js +1 -0
  135. package/dist/hooks/stop-next-step-hook.js.map +1 -1
  136. package/dist/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  137. package/dist/orchestrator/templates/ai-src/commands/review.md +2 -0
  138. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  139. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  140. package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
  141. package/package.json +3 -3
  142. package/src/cli/case-triage-hint.ts +31 -0
  143. package/src/cli/commands/contract-derive-command.ts +3 -1
  144. package/src/cli/commands/contract.ts +5 -2
  145. package/src/cli/commands/delivery.ts +49 -16
  146. package/src/cli/commands/gate.ts +2 -1
  147. package/src/cli/commands/generate.ts +13 -2
  148. package/src/cli/commands/journey.ts +4 -2
  149. package/src/cli/commands/next.ts +30 -9
  150. package/src/cli/commands/triage.ts +5 -3
  151. package/src/cli/commands/update.ts +6 -2
  152. package/src/cli/index.ts +4 -0
  153. package/src/cli/json-error-on-exit.ts +72 -0
  154. package/src/cli/project-sungen-invocation.ts +20 -0
  155. package/src/cli/run-command-hints.ts +47 -0
  156. package/src/cli/waiting-for-you-block.ts +27 -0
  157. package/src/contract/derive-free-feature.ts +21 -0
  158. package/src/decisions/legacy-waiver-migration.ts +5 -1
  159. package/src/exporters/csv-exporter.ts +12 -4
  160. package/src/exporters/matrix/export.ts +11 -4
  161. package/src/exporters/xlsx-exporter.ts +5 -4
  162. package/src/graph/board-human-actions.ts +80 -0
  163. package/src/graph/evaluate-graph.ts +39 -8
  164. package/src/graph/human-action.ts +33 -0
  165. package/src/graph/node-decl.ts +11 -0
  166. package/src/graph/nodes/test-case-contract-nodes.ts +20 -4
  167. package/src/graph/nodes/test-case-nodes.ts +19 -5
  168. package/src/graph/nodes/unit-audit-nodes.ts +2 -1
  169. package/src/graph/nodes/unit-contract-node.ts +12 -1
  170. package/src/graph/unit-artifact-probes.ts +3 -1
  171. package/src/harness/catalog/gate-rules.yaml +1 -1
  172. package/src/harness/gates/audit-report-verification.ts +7 -2
  173. package/src/harness/gates/delivery-checklist.ts +11 -1
  174. package/src/harness/gates/in-run-decision-check.ts +11 -4
  175. package/src/harness/journey.ts +79 -8
  176. package/src/harness/next-step.ts +27 -1
  177. package/src/hooks/guard-tty-spoof-rule.ts +5 -4
  178. package/src/hooks/hand-back-core.ts +11 -5
  179. package/src/hooks/session-start-unit-state-hook.ts +4 -1
  180. package/src/hooks/stop-next-step-hook.ts +1 -0
  181. package/src/orchestrator/templates/ai-src/commands/delivery.md +2 -0
  182. package/src/orchestrator/templates/ai-src/commands/review.md +2 -0
  183. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
  184. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
  185. package/src/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
@@ -11,6 +11,7 @@ import * as path from 'path';
11
11
  import { deriveFreeFeature, DeriveResult, nothingDerived } from '../../contract/derive-free-feature';
12
12
  import { registeredTraceResolver } from '../../contract/resolve-unit-trace-identities';
13
13
  import { resolveContractUnit } from '../../contract/contract-units';
14
+ import { withProjectInvocation } from '../project-sungen-invocation';
14
15
 
15
16
  function print(r: DeriveResult, unit: string): void {
16
17
  const L = console.log;
@@ -19,6 +20,7 @@ function print(r: DeriveResult, unit: string): void {
19
20
  L(`━━━ Contract derive: ${unit} — free tests' traces → ${rel} ━━━`);
20
21
  L('');
21
22
  if (r.note) L(` ⚠ ${r.note}`);
23
+ for (const w of r.warnings ?? []) L(` ⚠ ${w}`);
22
24
  for (const s of r.scenarios) L(` + ${s.title} (${s.steps} steps${s.low ? `, ${s.low} low identity` : ''}${s.opaque ? `, ${s.opaque} opaque` : ''})`);
23
25
  for (const s of r.skipped) L(` - ${s.id} skipped: ${s.reason}`);
24
26
  if (r.refused?.length) {
@@ -27,7 +29,7 @@ function print(r: DeriveResult, unit: string): void {
27
29
  L(' Move the value into test-data (or an env overlay) and use it from there, then derive again.');
28
30
  } else if (r.written) {
29
31
  L(` ✓ Wrote ${rel}. Review the steps (they ARE the contract), then QA approves on a terminal:`);
30
- L(` sungen contract approve --screen ${unit}`);
32
+ L(` ${withProjectInvocation(`sungen contract approve --screen ${unit}`, process.cwd())}`);
31
33
  L(' After approval, re-run the free tests (trace: "on") — a trace older than the contract proves nothing.');
32
34
  } else if (r.scenarios.length) {
33
35
  L(` = ${rel} is already up to date.`);
@@ -28,6 +28,7 @@ import { resolveContractUnit } from '../../contract/contract-units';
28
28
  import { secretScanHits } from '../../contract/field-semantic-redaction';
29
29
  import { resolveUnitTraceIdentities } from '../../contract/resolve-unit-trace-identities';
30
30
  import { registerDeriveCommand } from './contract-derive-command';
31
+ import { withProjectInvocationInText } from '../project-sungen-invocation';
31
32
 
32
33
  const MARK: Record<string, string> = {
33
34
  match: '✓', strengthened: '↑', proposed: '+', 'hash-changed': '⚖', 'oracle-weakened': '✗', 'target-changed': '✗', 'assertion-lost': '✗',
@@ -44,7 +45,8 @@ function generateCommand(unit: { name: string; dir: string }): string {
44
45
  }
45
46
 
46
47
  function printVerdict(v: ContractVerdict, reportFile: string, generate: string): void {
47
- const L = console.log;
48
+ // The approve / --accept-opaque / generate lines as this project runs sungen (#963).
49
+ const L = (line: string): void => console.log(withProjectInvocationInText(line, process.cwd()));
48
50
  const head = v.verdict === 'pass' ? '✓ PASS' : v.verdict === 'review'
49
51
  ? '⚖ REVIEW — changed, not weaker. Exit 0 here, but review blocks `sungen delivery` until QA re-approves' : '✗ FAIL';
50
52
  L('');
@@ -124,7 +126,8 @@ export function registerContractCommand(program: Command): void {
124
126
  console.log(`\n✓ Approved ${r.approved.length} scenario(s)${r.removed.length ? `, removed ${r.removed.length}` : ''} → ${path.relative(process.cwd(), r.lockPath)} (decision ${r.decisionId})`);
125
127
  console.log(' Commit the lock and the decision through review: until they are in git HEAD, `sungen contract check` reports contract.lock-uncommitted.\n');
126
128
  } catch (error) {
127
- console.error('Error:', error instanceof Error ? error.message : error);
129
+ // A refusal names the command a human runs instead: as this project runs sungen (#963).
130
+ console.error('Error:', error instanceof Error ? withProjectInvocationInText(error.message, process.cwd()) : error);
128
131
  process.exit(1);
129
132
  }
130
133
  });
@@ -17,6 +17,7 @@ import { mergeFeatureAndSpec } from '../../exporters/scenario-merger';
17
17
  import {
18
18
  buildTestCaseRows,
19
19
  buildSummary,
20
+ deliverableBasename,
20
21
  renderCsv,
21
22
  writeCsv,
22
23
  } from '../../exporters/csv-exporter';
@@ -41,11 +42,16 @@ import { MatrixFinding } from '../../exporters/matrix/types';
41
42
  import { applyManualCarry, ManualCarryReport, PriorManualResults, readPriorManualResults } from '../../exporters/matrix/carry-manual-results';
42
43
  import { formatTestOutcome, resultTotals } from '../../exporters/matrix/result-totals';
43
44
  import { readCapabilities } from '../../harness/capability';
45
+ import { splitFeatureBlocks } from '../../contract/free-feature-blocks';
44
46
  import { processIO } from '../../decisions/human-confirmation';
45
47
  import { DeliveryDecisionGate } from '../../harness/gates/delivery-decision-gate';
46
48
  import { runDeliveryDecisionStep } from '../../harness/gates/delivery-decision-step';
47
49
  import { deliverableGateOverrides, InRunAcceptance } from '../../harness/gates/gate-override-report';
48
50
  import { ChecklistUnit, renderChecklist, unitChecklist } from '../../harness/gates/delivery-checklist';
51
+ import { withProjectInvocationInText } from '../project-sungen-invocation';
52
+ import { freeRunCommand, freeRunHasResults, generatedSubdir, plainRunCommand } from '../run-command-hints';
53
+ import { isHandWritten } from '../../harness/parse';
54
+ import { FREE_RUN_DIR } from '../../contract/trace/trace-run-record';
49
55
 
50
56
  const COLOR = {
51
57
  reset: '\x1b[0m',
@@ -101,14 +107,30 @@ function makeTarget(screen: string, featureBaseName: string, kind: UnitKind): De
101
107
  }
102
108
 
103
109
  /**
104
- * List all `.feature` files inside a screen/flow/api unit as separate targets.
105
- * Returns empty array when the directory has no features yet.
110
+ * A derived `*.free.feature` none of whose scenarios QA has approved yet (every one still `@proposed`):
111
+ * a proposal, not a delivery unit — counted as one, it ended in "Not exported" (#939). Approving a
112
+ * scenario drops its tag (`contract approve`), and from then on the file is a unit like any other.
113
+ */
114
+ function proposalOnly(featureFile: string): boolean {
115
+ if (!featureFile.endsWith('.free.feature')) return false;
116
+ try {
117
+ // No scenario block at all (empty, or not Gherkin) is not a proposal: it stays listed and the preflight names it.
118
+ const blocks = splitFeatureBlocks(fs.readFileSync(featureFile, 'utf8'));
119
+ return blocks.length > 0 && blocks.every((b) => b.proposed);
120
+ } catch {
121
+ return false; // unreadable: listed, so the preflight names the problem
122
+ }
123
+ }
124
+
125
+ /**
126
+ * List all `.feature` files inside a screen/flow/api unit as separate targets — a proposal-only
127
+ * free feature excepted. Returns empty array when the directory has no features yet.
106
128
  */
107
129
  function listFeatureTargets(cwd: string, screen: string, kind: UnitKind): DeliveryTarget[] {
108
130
  const featuresDir = path.join(cwd, 'qa', qaParent(kind), screen, 'features');
109
131
  if (!fs.existsSync(featuresDir)) return [];
110
132
  return fs.readdirSync(featuresDir)
111
- .filter((f) => f.endsWith('.feature'))
133
+ .filter((f) => f.endsWith('.feature') && !proposalOnly(path.join(featuresDir, f)))
112
134
  .map((f) => makeTarget(screen, f.slice(0, -'.feature'.length), kind))
113
135
  .sort((a, b) => a.featureBaseName.localeCompare(b.featureBaseName));
114
136
  }
@@ -169,8 +191,7 @@ function qaDir(cwd: string, target: DeliveryTarget): string {
169
191
  // dashboard snapshot builder loads the identical contract (shared + unit merge).
170
192
 
171
193
  function generatedDir(cwd: string, target: DeliveryTarget): string {
172
- const sub = target.kind === 'flow' ? path.join('flows', target.screen) : target.kind === 'api' ? path.join('api', target.screen) : target.screen;
173
- return path.join(cwd, 'specs', 'generated', sub);
194
+ return path.join(cwd, generatedSubdir(target.kind, target.screen));
174
195
  }
175
196
 
176
197
  // ----------------------------------------------------------------------------
@@ -233,6 +254,9 @@ export function runPreflight(cwd: string, target: DeliveryTarget): PreflightChec
233
254
  const selectorsFile = path.join(base, 'selectors', `${target.featureBaseName}.yaml`);
234
255
  const specFile = path.join(genBase, `${target.featureBaseName}.spec.ts`);
235
256
  const resultsFile = resolveResultsPath(cwd, target);
257
+ // A derived `*.free.feature` is implemented by hand-written tests (DOS-14.8): no selectors, never compiled,
258
+ // run with the project's free-test config into its own outputDir — so its hints are that run's (#964).
259
+ const free = isHandWritten([], featureFile);
236
260
 
237
261
  const featureOk = checkFeatureReal(featureFile);
238
262
  // test-data is only REQUIRED when the feature actually references a
@@ -243,9 +267,9 @@ export function runPreflight(cwd: string, target: DeliveryTarget): PreflightChec
243
267
  ? checkTestDataHasVars(testDataFile)
244
268
  : true;
245
269
  // API units have no DOM → no selectors; the catalog (apis.yaml) is the contract. N/A, not missing.
246
- const selectorsOk = target.kind === 'api' ? true : checkSelectorsHasEntries(selectorsFile, target.featureBaseName);
247
- const specOk = fs.existsSync(specFile);
248
- const resultsOk = resultsFile !== null;
270
+ const selectorsOk = target.kind === 'api' || free ? true : checkSelectorsHasEntries(selectorsFile, target.featureBaseName);
271
+ const specOk = free || fs.existsSync(specFile);
272
+ const resultsOk = free ? freeRunHasResults(cwd) : resultsFile !== null;
249
273
 
250
274
  const label = target.isFlow ? `flow/${target.featureBaseName}` : target.featureBaseName;
251
275
  const missing: string[] = [];
@@ -267,13 +291,15 @@ export function runPreflight(cwd: string, target: DeliveryTarget): PreflightChec
267
291
  missing.push(`compiled .spec.ts missing (optional — needed to run): ${path.relative(cwd, specFile)}`);
268
292
  suggestions.push(`sungen generate --${target.kind === 'flow' ? 'flow' : target.kind === 'api' ? 'api' : 'screen'} ${target.screen}`);
269
293
  }
270
- if (!resultsOk) {
294
+ if (!resultsOk && free) {
295
+ missing.push(`free-test results missing (optional): ${FREE_RUN_DIR}/`);
296
+ suggestions.push(freeRunCommand(cwd));
297
+ } else if (!resultsOk) {
271
298
  const env = process.env.SUNGEN_ENV;
272
299
  const envSuffix = env ? `.${env}` : '';
273
300
  missing.push(`test-result${envSuffix}.json missing (optional)`);
274
301
  const genRel = path.relative(cwd, genBase);
275
- const envPrefix = env ? `SUNGEN_ENV=${env} ` : '';
276
- suggestions.push(`${envPrefix}PLAYWRIGHT_JSON_OUTPUT_NAME=${genRel}/${target.featureBaseName}-test-result.json npx playwright test ${genRel}/${target.featureBaseName}.spec.ts`);
302
+ suggestions.push(plainRunCommand(genRel, target.featureBaseName, env));
277
303
  }
278
304
 
279
305
  return {
@@ -731,11 +757,13 @@ async function exportMatrixTarget(
731
757
  // counted, so this line, the workbook's totals and the summary table all agree (#891). A file
732
758
  // that cannot be read is reported where it matters — when it would be overwritten.
733
759
  const format = opts.format ?? 'xlsx';
760
+ // A draft (DRAFT banner) is written to its own `…-testcases.DRAFT.<ext>`, never onto the official file (#965).
761
+ const draft = model.manifest.approvalState === 'draft';
734
762
  let prior: PriorManualResults | null = null;
735
763
  let priorError: Error | null = null;
736
764
  try { prior = await readPriorManualResults(deliverableXlsxPath(cwd, paths.unit)); }
737
- // A CSV-only export overwrites no workbook, so an unreadable one only means "nothing to carry".
738
- catch (e) { if (format !== 'csv') priorError = e as Error; }
765
+ // A CSV-only export or a draft overwrites no official workbook, so an unreadable one only means "nothing to carry".
766
+ catch (e) { if (format !== 'csv' && !draft) priorError = e as Error; }
739
767
  applyManualCarry(model, prior);
740
768
  model.gateOverrides = deliverableGateOverrides(decisions?.overridesFor(qaDir(cwd, target)) ?? [], hasReview(model.findings), decisions?.acceptedInRun);
741
769
 
@@ -785,6 +813,9 @@ async function exportMatrixTarget(
785
813
  const failed = model.items.reduce((a, i) => a + i.resultCounts.failed, 0);
786
814
  const written = [xlsxPath, csvPath].filter((p): p is string => !!p).map((p) => path.relative(cwd, p));
787
815
  for (const rel of written) log(` ${COLOR.green}→ ${rel}${COLOR.reset}`);
816
+ if (draft) {
817
+ log(` ${COLOR.yellow}draft only — not a deliverable:${COLOR.reset} it carries the DRAFT banner, and the official ${deliverableBasename(paths.unit)} file was neither written nor changed.`);
818
+ }
788
819
  if (manualCarry) printManualCarry(manualCarry);
789
820
  return {
790
821
  label: paths.label,
@@ -912,7 +943,8 @@ async function printDeliveryChecklist(cwd: string, targets: DeliveryTarget[], ac
912
943
  const rows: Array<{ label: string; boxes: Awaited<ReturnType<typeof unitChecklist>> }> = [];
913
944
  for (const u of units.values()) rows.push({ label: u.label, boxes: await unitChecklist(cwd, u, accepted) });
914
945
  log('');
915
- for (const line of renderChecklist(rows)) log(line);
946
+ // Each box's fix command as this project runs sungen (#963).
947
+ for (const line of renderChecklist(rows)) log(withProjectInvocationInText(line, cwd));
916
948
  }
917
949
 
918
950
  // ----------------------------------------------------------------------------
@@ -931,7 +963,7 @@ export function registerDeliveryCommand(program: Command): void {
931
963
  .option('--full', 'Alias of --legacy')
932
964
  .option('--check', 'Matrix gates only — validate the delivery map, write nothing')
933
965
  .option('--approve [ids]', 'Approve proposed groups (all, or a comma-separated id list) + stamp fingerprints')
934
- .option('--preview', 'Render the matrix despite review-required findings (DRAFT watermark)')
966
+ .option('--preview', 'Render the matrix despite review-required findings — a DRAFT (watermarked) written to <unit>-testcases.DRAFT.*, never onto the official file')
935
967
  .option('--format <format>', 'Matrix output format: xlsx (default) | csv | both', 'xlsx')
936
968
  .option('--accept-in-run', 'Deliver despite gate-overrides/waivers/approvals recorded during the run (decision.in-run) — a human confirms on a terminal')
937
969
  .action(async (names: string[], options: {
@@ -1025,7 +1057,8 @@ export function registerDeliveryCommand(program: Command): void {
1025
1057
  }
1026
1058
  if (options.approve === undefined) {
1027
1059
  const gate = await runDeliveryDecisionStep({
1028
- projectRoot: cwd, acceptInRun: !!options.acceptInRun, io: processIO(), log,
1060
+ // The accept-in-run command and its refusal as this project runs sungen (#963).
1061
+ projectRoot: cwd, acceptInRun: !!options.acceptInRun, io: processIO(), log: (line) => log(withProjectInvocationInText(line, cwd)),
1029
1062
  // --preview / --check soften only the matrix export; --legacy has no draft mode, so it always needs one.
1030
1063
  requireTrustedAudit: !!(options.legacy || options.full) || (!options.preview && !options.check),
1031
1064
  refs: toExport.map((t) => ({ label: t.isFlow ? `flow/${t.featureBaseName}` : t.featureBaseName, unitName: t.screen, unitDir: qaDir(cwd, t), resultsPath: resolveResultsPath(cwd, t) })),
@@ -3,6 +3,7 @@ import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { runGate, renderGate, GatePhase } from '../../harness/journey';
5
5
  import { findUnitDir } from '../resolve-unit';
6
+ import { withProjectInvocationInText } from '../project-sungen-invocation';
6
7
 
7
8
 
8
9
  const PHASES: GatePhase[] = ['create', 'run', 'deliver'];
@@ -24,7 +25,7 @@ export function registerGateCommand(program: Command): void {
24
25
 
25
26
  const verdict = runGate(process.cwd(), name, phase);
26
27
  if (options.json) console.log(JSON.stringify(verdict, null, 2));
27
- else console.log(renderGate(verdict));
28
+ else console.log(withProjectInvocationInText(renderGate(verdict), process.cwd())); // waive / sign-off hints as the project runs sungen (#963)
28
29
 
29
30
  // Exit 2 on HALT — usable in CI / the orchestration loop to block the next phase.
30
31
  process.exit(verdict.status === 'halt' ? 2 : 0);
@@ -8,6 +8,7 @@ import { capabilityRegistry } from '../../capabilities/registry';
8
8
  import { discoverAndRegisterCapabilities } from '../../capabilities/discover';
9
9
  import { readCapabilities, writeCapabilities, driverMeta, loadDriverCatalog } from '../../harness/capability';
10
10
  import { isHandWritten } from '../../harness/parse';
11
+ import { generatedSubdir, plainRunCommand } from '../run-command-hints';
11
12
 
12
13
  /**
13
14
  * Find .feature files recursively in a directory
@@ -255,8 +256,18 @@ export function registerGenerateCommand(program: Command): void {
255
256
  if (shown.length < ddWarnings.length) console.log(` … and ${ddWarnings.length - shown.length} more (run with -v to list all)`);
256
257
  }
257
258
 
258
- const nextStep = platform === 'mobile' ? 'npm run test:mobile' : 'npx playwright test --ui';
259
- console.log(`Next step: ${nextStep}\n`);
259
+ // Never the interactive `--ui` (#939): an agent cannot drive it, and run-test is what records the results.
260
+ const unit = screenName || flowName || apiName;
261
+ const nextStep = platform === 'mobile' ? 'npm run test:mobile' : unit ? `/sungen:run-test ${unit}` : 'npx playwright test';
262
+ console.log(`Next step: ${nextStep}`);
263
+ // The plain run writes each feature's results where `sungen next` and `sungen delivery` read them (#964) —
264
+ // a bare `npx playwright test` wrote them to the config's default file, which neither reads.
265
+ if (platform !== 'mobile' && unit) {
266
+ const genRel = generatedSubdir(apiName ? 'api' : flowName ? 'flow' : 'screen', unit);
267
+ console.log(' or the plain run — it writes the results file `sungen next` and `sungen delivery` read:');
268
+ for (const f of featureFiles) console.log(` ${plainRunCommand(genRel, path.basename(f, '.feature'))}`);
269
+ }
270
+ console.log('');
260
271
  } catch (error) {
261
272
  console.error('Error:', error instanceof Error ? error.message : error);
262
273
  process.exit(1);
@@ -5,6 +5,7 @@ import { reportSlug } from '../../harness/unit-paths';
5
5
  import { findUnitDir } from '../resolve-unit';
6
6
  import { answerPendingMigration, decideWithConfirmation, DECIDE_KINDS, signoffWithConfirmation, waiveWithConfirmation } from '../../decisions/confirmed-decision-commands';
7
7
  import { processIO } from '../../decisions/human-confirmation';
8
+ import { withProjectInvocationInText } from '../project-sungen-invocation';
8
9
 
9
10
  const collect = (value: string, previous: string[]): string[] => [...previous, value];
10
11
 
@@ -77,12 +78,13 @@ export function registerJourneyCommand(program: Command): void {
77
78
  console.log(JSON.stringify(report, null, 2));
78
79
  } else {
79
80
  console.log('');
80
- console.log(board);
81
+ console.log(withProjectInvocationInText(board, process.cwd()));
81
82
  console.log(` Board: ${written ? path.relative(process.cwd(), written) : '(not written — read-only checkout)'}`);
82
83
  console.log('');
83
84
  }
84
85
  } catch (error) {
85
- console.error('Error:', error instanceof Error ? error.message : error);
86
+ // A refusal names the command a human runs instead: as this project runs sungen (#963).
87
+ console.error('Error:', error instanceof Error ? withProjectInvocationInText(error.message, process.cwd()) : error);
86
88
  process.exit(1);
87
89
  }
88
90
  });
@@ -9,9 +9,13 @@
9
9
  * sungen next <unit> --case <id> one test case: its state, and its steps after the blocking
10
10
  * unit steps before them (OES-10.3)
11
11
  * --json the same, machine-readable; steps add nodeId, granularity,
12
- * testCase?, gates?; the unit adds `results` (#942); every older
13
- * field is kept. stdout is the payload alone (#660, #836)
14
- * The text names the run the results come from and warns when they are older than the suite (#942).
12
+ * testCase?, gates?; the unit adds `results` (#942) and
13
+ * `humanActions: [{command, reason, kind, rendered}]` (#954) —
14
+ * `command` canonical, `rendered` as this project runs it (#963),
15
+ * and `humanActionsHeldBy[Command]` when a blocking step holds them (#966);
16
+ * every older field is kept. stdout is the payload alone (#660, #836)
17
+ * The text names the run the results come from and warns when they are older than the suite (#942),
18
+ * and ends with "Waiting for you": every pending human-only command, with why (#954).
15
19
  */
16
20
  import { Command } from 'commander';
17
21
  import * as fs from 'fs';
@@ -22,7 +26,10 @@ import { testCaseStates } from '../../graph/test-case-states';
22
26
  import { describeResultsRun } from '../../graph/unit-results-freshness';
23
27
  import { deriveSteps, NextStep, readUnitState, UnitState } from '../../harness/next-step';
24
28
  import { resolveUnit, resolveUnitCandidates, AmbiguousUnitError } from '../resolve-unit';
25
- import { withProjectInvocation } from '../project-sungen-invocation';
29
+ import { withProjectInvocation, withProjectInvocationInText } from '../project-sungen-invocation';
30
+ import { waitingForYouLines } from '../waiting-for-you-block';
31
+ import { caseTriageLines } from '../case-triage-hint';
32
+ import { testCaseNeed } from '../../graph/test-case-satisfied';
26
33
 
27
34
  /** Every unit the project holds, so `sungen next` with no argument still answers. */
28
35
  function allUnits(cwd: string): Array<{ name: string; kind: UnitState['kind']; dir: string }> {
@@ -38,15 +45,24 @@ function allUnits(cwd: string): Array<{ name: string; kind: UnitState['kind']; d
38
45
  return out;
39
46
  }
40
47
 
41
- /** Steps as text, each `sungen …` command as this project runs it (#939); `--json` keeps them canonical. */
48
+ /** Steps as text, each `sungen …` command — the reason's too (#963) — as this project runs it (#939); `--json` keeps them canonical. */
42
49
  function renderSteps(steps: NextStep[], cwd: string): void {
43
50
  for (const st of steps) {
44
51
  const mark = st.kind === 'blocked' ? '⛔' : st.kind === 'optional' ? '○' : st.kind === 'decide' ? '⚖' : '→';
45
- console.log(` ${mark} ${withProjectInvocation(st.command, cwd)}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}`);
46
- console.log(` ${st.because}`);
52
+ console.log(` ${mark} ${withProjectInvocationInText(st.command, cwd)}${st.kind === 'decide' ? ' (a QA decision — ask, do not run)' : ''}`);
53
+ console.log(` ${withProjectInvocationInText(st.because, cwd)}`);
47
54
  }
48
55
  }
49
56
 
57
+ /**
58
+ * `--json`: each human action keeps its canonical `command` and adds `rendered` — the line a human runs in
59
+ * this project (`npx sungen …` with a local install; an instruction unchanged), as the text shows it (#963).
60
+ */
61
+ function withRenderedHumanActions(s: UnitState, cwd: string): UnitState {
62
+ if (!s.humanActions) return s;
63
+ return { ...s, humanActions: s.humanActions.map((a) => ({ ...a, rendered: a.instruction ? a.command : withProjectInvocation(a.command, cwd) })) };
64
+ }
65
+
50
66
  function render(s: UnitState, cwd: string): void {
51
67
  const L = console.log;
52
68
  L('');
@@ -61,7 +77,7 @@ function render(s: UnitState, cwd: string): void {
61
77
  ].filter(Boolean);
62
78
  L(` state: ${held.length ? held.join(' · ') : '(nothing yet)'}`);
63
79
  if (s.audit) L(` audit: ${s.audit.overall}/10 [${s.audit.gateStatus}] · ${s.audit.findings.length} finding(s)`);
64
- else if (s.auditNotCounted) L(` audit: ${s.auditNotCounted.state === 'stale' ? 'stale' : 'not trusted'} — ${s.auditNotCounted.reason}`);
80
+ else if (s.auditNotCounted) L(` audit: ${s.auditNotCounted.state === 'stale' ? 'stale' : 'not trusted'} — ${withProjectInvocationInText(s.auditNotCounted.reason, cwd)}`);
65
81
  if (s.results) {
66
82
  L(` results: ${s.results.files.length} file${s.results.files.length === 1 ? '' : 's'}, ${describeResultsRun(s.results)}`);
67
83
  if (s.results.warning) L(` ⚠ ${s.results.warning}`);
@@ -69,6 +85,8 @@ function render(s: UnitState, cwd: string): void {
69
85
  L('');
70
86
  renderSteps(s.steps, cwd);
71
87
  L('');
88
+ const waiting = waitingForYouLines(s.humanActions, cwd, s.humanActionsHeldBy, s.humanActionsHeldByCommand);
89
+ if (waiting.length) { for (const line of waiting) L(line); L(''); }
72
90
  }
73
91
 
74
92
  /** `--case <id>`: the case's state and its frontier. Exits 1 when the case cannot be found. */
@@ -99,6 +117,8 @@ function caseView(s: UnitState, cwd: string, unitDir: string, id: string, json:
99
117
  L('');
100
118
  if (steps.length) renderSteps(steps, cwd);
101
119
  else L(` ✓ nothing to do for this test case — ${testCaseStatusLabel(tc)}`);
120
+ // "needs triage" names how to classify the red, even while a unit step holds its own step back (#966).
121
+ if (testCaseNeed(tc) === 'triage') for (const line of caseTriageLines(s, tc, steps, cwd)) L(line);
102
122
  L('');
103
123
  }
104
124
 
@@ -136,7 +156,8 @@ export function registerNextCommand(program: Command): void {
136
156
  });
137
157
  }
138
158
  if (o.json) {
139
- console.log(JSON.stringify(states.length === 1 ? states[0] : states, null, 2));
159
+ const out = states.map((s) => withRenderedHumanActions(s, cwd));
160
+ console.log(JSON.stringify(out.length === 1 ? out[0] : out, null, 2));
140
161
  return;
141
162
  }
142
163
  for (const s of states) render(s, cwd);
@@ -1,6 +1,7 @@
1
1
  import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import { resolveUnit } from '../resolve-unit';
4
+ import { withProjectInvocation, withProjectInvocationInText } from '../project-sungen-invocation';
4
5
  import { processIO } from '../../decisions/human-confirmation';
5
6
  import { manualClassifications } from '../../graph/manual-triage-decisions';
6
7
  import { testCaseStates } from '../../graph/test-case-states';
@@ -47,7 +48,7 @@ export function registerTriageCommand(program: Command): void {
47
48
  .option('--api <name>', 'API-first area or api flow (qa/api/<name>)')
48
49
  .option('--area <name>', 'Alias of --api')
49
50
  .option('--case <id>', 'Classify one red test case by hand (its VP-id) — with --as and --reason')
50
- .option('--as <class>', 'product (anyone) | test-defect | environment (a human types a confirmation on a TTY)')
51
+ .option('--as <class>', 'product (anyone may record it) | test-defect | environment (test-defect and environment: a human types a confirmation on a TTY)')
51
52
  .option('--reason <text>', 'Why the red is that class (mandatory with --as)')
52
53
  .option('--json', 'Output the raw JSON report')
53
54
  .action(async (o: TriageOptions) => {
@@ -82,11 +83,12 @@ export function registerTriageCommand(program: Command): void {
82
83
  }
83
84
  printManual(cwd, unit.dir, name);
84
85
  if (report.rows.some((r) => r.class === 'unclassified')) {
85
- L(`\n Unclassified: read the evidence (trace, error), then classify by hand:\n sungen triage --${unit.kind === 'api' ? 'api' : 'screen'} ${name} --case <id> --as product|test-defect|environment --reason "…"`);
86
+ L(`\n Unclassified: read the evidence (trace, error), then classify by hand:\n ${withProjectInvocation(`sungen triage --${unit.kind === 'api' ? 'api' : 'screen'} ${name} --case <id> --as product|test-defect|environment --reason "…"`, cwd)}`);
86
87
  }
87
88
  L(`\n Report: ${path.relative(cwd, saved)}\n`);
88
89
  } catch (error) {
89
- console.error('Error:', error instanceof Error ? error.message : error);
90
+ // A refusal names the command a human runs instead: as this project runs sungen (#963).
91
+ console.error('Error:', error instanceof Error ? withProjectInvocationInText(error.message, process.cwd()) : error);
90
92
  process.exitCode = 1;
91
93
  }
92
94
  });
@@ -26,7 +26,9 @@ import { syncProjectSungenDeps } from '../../orchestrator/project-deps-sync';
26
26
  *
27
27
  * The public `--skip-npm-install` flag is still available for users who
28
28
  * already installed the binary by other means (CI pipelines, corporate
29
- * mirror, manual download) and only want to refresh the project AI assets.
29
+ * mirror, manual download). It skips only step 1: the project sync still
30
+ * re-pins package.json (security overrides included) and runs `npm install`
31
+ * there — `--skip-project` skips that (#939).
30
32
  */
31
33
 
32
34
  const SKIP_NPM_ENV = 'SUNGEN_UPDATE_SKIP_NPM';
@@ -70,9 +72,11 @@ export function registerUpdateCommand(program: Command): void {
70
72
  '--tag <dist-tag>',
71
73
  'Install a specific npm dist-tag (e.g. latest, beta, alpha, next). Overrides --beta / --alpha.',
72
74
  )
75
+ // #939: it skips only the GLOBAL install — the project is still synced, so the help says so.
73
76
  .option(
74
77
  '--skip-npm-install',
75
- 'Skip the global npm install step (refresh project AI assets only)',
78
+ 'Skip the global npm install step. The project is still synced: its sungen packages are re-pinned in package.json '
79
+ + '(security `overrides` included) and `npm install` runs in the project — add --skip-project to skip that too',
76
80
  false,
77
81
  )
78
82
  .option(
package/src/cli/index.ts CHANGED
@@ -45,6 +45,7 @@ import { capabilityRegistry } from '../capabilities/registry';
45
45
  import { discoverAndRegisterCapabilities } from '../capabilities/discover';
46
46
  import { warnAssetsDrift } from '../orchestrator/assets-drift';
47
47
  import { attachCliHandBack } from '../hooks/hand-back-core';
48
+ import { attachJsonErrorOnExit, commandSupportsJson } from './json-error-on-exit';
48
49
  import { registerHookCommand } from '../hooks/hook-cli-command';
49
50
 
50
51
  // Read version from package.json so `--version` never drifts from the released version.
@@ -60,6 +61,8 @@ async function main() {
60
61
  // those rules live in the hook core (src/hooks/hand-back-core.ts) — this file only wires it.
61
62
  const handBack = attachCliHandBack(process.argv, process.cwd());
62
63
  const program = new Command();
64
+ // #939: a failed `--json` command prints {"error":{code,message}} on stdout (json-error-on-exit.ts).
65
+ const jsonErrors = attachJsonErrorOnExit(process.argv, (args) => commandSupportsJson(program, args));
63
66
 
64
67
  program
65
68
  .name('sungen')
@@ -130,6 +133,7 @@ registerSetupMobileEnvCommand(program);
130
133
  // The action knows its unit and output mode; the hand-back default above is refined here.
131
134
  program.hook('preAction', (_thisCommand, actionCommand) => {
132
135
  handBack.onAction(actionCommand.name(), actionCommand.opts() as Record<string, unknown>, actionCommand.args);
136
+ jsonErrors.onAction();
133
137
  });
134
138
 
135
139
  await program.parseAsync(process.argv);
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `--json` failures speak JSON too (#939 item 7). A command that supports `--json` and fails used to
3
+ * print its error on stderr and NOTHING on stdout, so a caller parsing stdout got an empty string. Now,
4
+ * when such a command exits non-zero without having written stdout, the exit prints one object:
5
+ *
6
+ * {"error":{"code":"command-failed" | "usage-error","message":"<the error it printed>"}}
7
+ *
8
+ * command-failed the action ran and failed (`next nope --json`)
9
+ * usage-error commander refused the arguments before any action ran (`audit --json --bogus`)
10
+ * The human error stays on stderr, unchanged. A command that printed its own JSON keeps it, whatever
11
+ * its exit code (`contract check --json` exits 2 with the verdict); a command without `--json` is never
12
+ * touched — commander's own "unknown option '--json'" is its usage error, not a JSON caller's.
13
+ * Like the hand-back, it hangs off process exit: commands exit from inside their actions.
14
+ */
15
+ import type { Command } from 'commander';
16
+
17
+ export type JsonErrorCode = 'command-failed' | 'usage-error';
18
+ export interface JsonError { error: { code: JsonErrorCode; message: string } }
19
+
20
+ const ANSI = /\u001b\[[0-9;]*m/g;
21
+ const ERROR_LINE = /^(?:Error|error|Fatal error|❌[^:]*):\s*/;
22
+ const MAX_CAPTURE = 64 * 1024;
23
+
24
+ /** The error object for what the command printed on stderr: from its first error line on, the hand-back dropped. */
25
+ export function jsonErrorPayload(stderr: string, actionRan: boolean, exitCode: number): JsonError {
26
+ const lines = stderr.replace(ANSI, '').split('\n').map((l) => l.trimEnd()).filter((l) => l && !/^Next: /.test(l));
27
+ const at = lines.findIndex((l) => ERROR_LINE.test(l));
28
+ const picked = at >= 0 ? lines.slice(at) : lines.slice(-1);
29
+ const message = picked.join('\n').replace(ERROR_LINE, '').trim() || `exited with code ${exitCode}`;
30
+ return { error: { code: actionRan ? 'command-failed' : 'usage-error', message } };
31
+ }
32
+
33
+ /** The (sub)command `args` name, walked down from `program`, defines `--json`. */
34
+ export function commandSupportsJson(program: Command, args: string[]): boolean {
35
+ let cmd: Command = program;
36
+ for (const a of args) {
37
+ if (a.startsWith('-')) continue;
38
+ const sub = cmd.commands.find((c) => c.name() === a || c.aliases().includes(a));
39
+ if (!sub) break;
40
+ cmd = sub;
41
+ }
42
+ return cmd !== program && cmd.options.some((o) => o.long === '--json');
43
+ }
44
+
45
+ /**
46
+ * Wire it to process exit for `argv` (a no-op without `--json`). `supportsJson` is asked at exit, once
47
+ * every command is registered; `onAction` marks that an action ran (a failure then is not a usage error).
48
+ */
49
+ export function attachJsonErrorOnExit(argv: string[], supportsJson: (args: string[]) => boolean): { onAction: () => void } {
50
+ const args = argv.slice(2);
51
+ if (!args.includes('--json')) return { onAction: () => undefined };
52
+ let wroteStdout = false;
53
+ let stderr = '';
54
+ let actionRan = false;
55
+ const writeOut = process.stdout.write.bind(process.stdout);
56
+ const writeErr = process.stderr.write.bind(process.stderr);
57
+ process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => {
58
+ if (String(chunk).length) wroteStdout = true;
59
+ return (writeOut as (...a: unknown[]) => boolean)(chunk, ...rest);
60
+ }) as typeof process.stdout.write;
61
+ process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => {
62
+ if (stderr.length < MAX_CAPTURE) stderr += String(chunk);
63
+ return (writeErr as (...a: unknown[]) => boolean)(chunk, ...rest);
64
+ }) as typeof process.stderr.write;
65
+ process.on('exit', (code) => {
66
+ try {
67
+ if (code === 0 || wroteStdout || !supportsJson(args)) return;
68
+ writeOut(`${JSON.stringify(jsonErrorPayload(stderr, actionRan, code))}\n`);
69
+ } catch { /* the exit code is the contract; this object is a courtesy */ }
70
+ });
71
+ return { onAction: () => { actionRan = true; } };
72
+ }
@@ -8,6 +8,9 @@
8
8
  *
9
9
  * sungenInvocation(root) 'npx sungen' when <root>/node_modules/@sun-asterisk/sungen exists, else 'sungen'
10
10
  * withProjectInvocation(cmd, root) a canonical `sungen …` command as this project runs it
11
+ * withProjectInvocationInText(text, root)
12
+ * every `sungen …` command inside printed text (a refusal, a hint, a
13
+ * step's reason) as this project runs it (#963); prose mentions stay
11
14
  * updateCommand(root, version?) `<invocation> update` plus `--alpha` / `--beta` (`--tag <id>` for another prerelease)
12
15
  *
13
16
  * Only the project root itself is looked at — where `npm i` puts a QA project's install — so a
@@ -27,6 +30,23 @@ export function withProjectInvocation(command: string, projectRoot: string): str
27
30
  return inv !== 'sungen' && /^sungen\b/.test(command) ? `${inv}${command.slice('sungen'.length)}` : command;
28
31
  }
29
32
 
33
+ /**
34
+ * A `sungen` that starts a command inside text: at the start of a line (after indentation), after a
35
+ * backtick or `(`, after ": ", "run " or "then " — and followed by a subcommand or an option. A prose
36
+ * mention ("written by sungen 3.3.0", "an older sungen", "sungen: …") is none of these.
37
+ */
38
+ const COMMAND_IN_TEXT = /(^[ \t]*|[`(]|:[ \t]+|\b(?:[Rr]un|then)[ \t]+)sungen(?= [a-z-])/gm;
39
+
40
+ /**
41
+ * Printed text (a refusal, a hint, a step's reason) with every `sungen …` command in it as this project
42
+ * runs sungen (#963): the human-only commands a QA copies from a refusal must run the project's own
43
+ * install, not an older global one. Text for machines (`--json`) stays canonical; this is for the screen.
44
+ */
45
+ export function withProjectInvocationInText(text: string, projectRoot: string): string {
46
+ const inv = sungenInvocation(projectRoot);
47
+ return inv === 'sungen' ? text : text.replace(COMMAND_IN_TEXT, (_m, before: string) => `${before}${inv}`);
48
+ }
49
+
30
50
  /** The `sungen update` flag that keeps the channel of `version` (its prerelease id); '' for stable. */
31
51
  export function updateChannelFlag(version: string = getPackageVersion()): string {
32
52
  const id = version.match(/^\d+\.\d+\.\d+-([0-9A-Za-z-]+)/)?.[1];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The run commands sungen suggests, each writing its results where the reader that asks for them
3
+ * looks (#964). A hint whose results nobody reads leaves a passing run invisible: the plain
4
+ * `npx playwright test` wrote `test-result.json` at the project root, which `sungen next` never reads.
5
+ *
6
+ * generatedSubdir(kind, unit) `specs/generated/<unit>` · `…/flows/<flow>` · `…/api/<area>` — where
7
+ * `generate` writes a unit's specs and `next` / `delivery` read its results
8
+ * plainRunCommand(genRel, feature) one compiled feature's spec with the JSON reporter pointed at
9
+ * `<genRel>/<feature>-test-result.json` (graph/unit-artifact-probes RESULT_FILE)
10
+ * freeRunCommand(cwd) hand-written @free / impl:code tests: the project's free-test config, which
11
+ * writes into its own outputDir `test-results-free/` (#941)
12
+ * freeRunHasResults(cwd) that run left its results (or a sungen run record) there
13
+ */
14
+ import * as fs from 'fs';
15
+ import * as path from 'path';
16
+ import { FREE_RUN_DIR } from '../contract/trace/trace-run-record';
17
+
18
+ export type RunUnitKind = 'screen' | 'flow' | 'api';
19
+
20
+ export function generatedSubdir(kind: RunUnitKind, unit: string): string {
21
+ return path.posix.join('specs', 'generated', ...(kind === 'flow' ? ['flows'] : kind === 'api' ? ['api'] : []), unit);
22
+ }
23
+
24
+ /** `[SUNGEN_ENV=<env> ]PLAYWRIGHT_JSON_OUTPUT_NAME=<genRel>/<feature>-test-result.json npx playwright test <genRel>/<feature>.spec.ts` */
25
+ export function plainRunCommand(genRel: string, featureBaseName: string, env?: string): string {
26
+ return `${env ? `SUNGEN_ENV=${env} ` : ''}PLAYWRIGHT_JSON_OUTPUT_NAME=${genRel}/${featureBaseName}-test-result.json npx playwright test ${genRel}/${featureBaseName}.spec.ts`;
27
+ }
28
+
29
+ const FREE_CONFIG = /^playwright\.free\.config\.(?:ts|mts|cts|js|mjs|cjs)$/;
30
+
31
+ /** The free run: `npx playwright test -c <playwright.free.config.*>` when the project has one, else what that config holds. */
32
+ export function freeRunCommand(cwd: string): string {
33
+ let config: string | undefined;
34
+ try { config = fs.readdirSync(cwd).find((f) => FREE_CONFIG.test(f)); } catch { /* no project dir: the generic form */ }
35
+ return config
36
+ ? `npx playwright test -c ${config}`
37
+ : `run the hand-written tests with the project's free-test config (outputDir '${FREE_RUN_DIR}', trace: 'on', the sungen run-record reporter — "Free tests" in the sungen docs)`;
38
+ }
39
+
40
+ /** The free run left its JSON results or a sungen run record in `test-results-free/`. */
41
+ export function freeRunHasResults(cwd: string): boolean {
42
+ try {
43
+ return fs.readdirSync(path.join(cwd, FREE_RUN_DIR)).some((f) => f === 'results.json' || /^sungen-run-record.*\.json$/.test(f));
44
+ } catch {
45
+ return false; // never run
46
+ }
47
+ }