@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.
- package/dist/cli/case-triage-hint.d.ts +14 -0
- package/dist/cli/case-triage-hint.d.ts.map +1 -0
- package/dist/cli/case-triage-hint.js +23 -0
- package/dist/cli/case-triage-hint.js.map +1 -0
- package/dist/cli/commands/contract-derive-command.d.ts.map +1 -1
- package/dist/cli/commands/contract-derive-command.js +4 -1
- package/dist/cli/commands/contract-derive-command.js.map +1 -1
- package/dist/cli/commands/contract.d.ts.map +1 -1
- package/dist/cli/commands/contract.js +5 -2
- package/dist/cli/commands/contract.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +50 -16
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/gate.d.ts.map +1 -1
- package/dist/cli/commands/gate.js +2 -1
- package/dist/cli/commands/gate.js.map +1 -1
- package/dist/cli/commands/generate.d.ts.map +1 -1
- package/dist/cli/commands/generate.js +14 -2
- package/dist/cli/commands/generate.js.map +1 -1
- package/dist/cli/commands/journey.d.ts.map +1 -1
- package/dist/cli/commands/journey.js +4 -2
- package/dist/cli/commands/journey.js.map +1 -1
- package/dist/cli/commands/next.d.ts +7 -3
- package/dist/cli/commands/next.d.ts.map +1 -1
- package/dist/cli/commands/next.js +28 -5
- package/dist/cli/commands/next.js.map +1 -1
- package/dist/cli/commands/triage.d.ts.map +1 -1
- package/dist/cli/commands/triage.js +5 -3
- package/dist/cli/commands/triage.js.map +1 -1
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/update.js +6 -2
- package/dist/cli/commands/update.js.map +1 -1
- package/dist/cli/index.js +4 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/json-error-on-exit.d.ts +34 -0
- package/dist/cli/json-error-on-exit.d.ts.map +1 -0
- package/dist/cli/json-error-on-exit.js +63 -0
- package/dist/cli/json-error-on-exit.js.map +1 -0
- package/dist/cli/project-sungen-invocation.d.ts +6 -0
- package/dist/cli/project-sungen-invocation.d.ts.map +1 -1
- package/dist/cli/project-sungen-invocation.js +19 -0
- package/dist/cli/project-sungen-invocation.js.map +1 -1
- package/dist/cli/run-command-hints.d.ts +9 -0
- package/dist/cli/run-command-hints.d.ts.map +1 -0
- package/dist/cli/run-command-hints.js +84 -0
- package/dist/cli/run-command-hints.js.map +1 -0
- package/dist/cli/waiting-for-you-block.d.ts +18 -0
- package/dist/cli/waiting-for-you-block.d.ts.map +1 -0
- package/dist/cli/waiting-for-you-block.js +22 -0
- package/dist/cli/waiting-for-you-block.js.map +1 -0
- package/dist/contract/derive-free-feature.d.ts +2 -0
- package/dist/contract/derive-free-feature.d.ts.map +1 -1
- package/dist/contract/derive-free-feature.js +22 -0
- package/dist/contract/derive-free-feature.js.map +1 -1
- package/dist/decisions/legacy-waiver-migration.d.ts +2 -0
- package/dist/decisions/legacy-waiver-migration.d.ts.map +1 -1
- package/dist/decisions/legacy-waiver-migration.js +5 -2
- package/dist/decisions/legacy-waiver-migration.js.map +1 -1
- package/dist/exporters/csv-exporter.d.ts +11 -2
- package/dist/exporters/csv-exporter.d.ts.map +1 -1
- package/dist/exporters/csv-exporter.js +8 -4
- package/dist/exporters/csv-exporter.js.map +1 -1
- package/dist/exporters/matrix/export.d.ts +6 -1
- package/dist/exporters/matrix/export.d.ts.map +1 -1
- package/dist/exporters/matrix/export.js +11 -4
- package/dist/exporters/matrix/export.js.map +1 -1
- package/dist/exporters/xlsx-exporter.d.ts +3 -2
- package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.js +4 -4
- package/dist/exporters/xlsx-exporter.js.map +1 -1
- package/dist/graph/board-human-actions.d.ts +5 -0
- package/dist/graph/board-human-actions.d.ts.map +1 -0
- package/dist/graph/board-human-actions.js +83 -0
- package/dist/graph/board-human-actions.js.map +1 -0
- package/dist/graph/evaluate-graph.d.ts +18 -0
- package/dist/graph/evaluate-graph.d.ts.map +1 -1
- package/dist/graph/evaluate-graph.js +31 -6
- package/dist/graph/evaluate-graph.js.map +1 -1
- package/dist/graph/human-action.d.ts +21 -0
- package/dist/graph/human-action.d.ts.map +1 -0
- package/dist/graph/human-action.js +3 -0
- package/dist/graph/human-action.js.map +1 -0
- package/dist/graph/node-decl.d.ts +9 -0
- package/dist/graph/node-decl.d.ts.map +1 -1
- package/dist/graph/node-decl.js +4 -1
- package/dist/graph/node-decl.js.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/test-case-contract-nodes.js +16 -3
- package/dist/graph/nodes/test-case-contract-nodes.js.map +1 -1
- package/dist/graph/nodes/test-case-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/test-case-nodes.js +16 -4
- package/dist/graph/nodes/test-case-nodes.js.map +1 -1
- package/dist/graph/nodes/unit-audit-nodes.d.ts.map +1 -1
- package/dist/graph/nodes/unit-audit-nodes.js +1 -0
- package/dist/graph/nodes/unit-audit-nodes.js.map +1 -1
- package/dist/graph/nodes/unit-contract-node.d.ts.map +1 -1
- package/dist/graph/nodes/unit-contract-node.js +8 -0
- package/dist/graph/nodes/unit-contract-node.js.map +1 -1
- package/dist/graph/unit-artifact-probes.d.ts.map +1 -1
- package/dist/graph/unit-artifact-probes.js +3 -1
- package/dist/graph/unit-artifact-probes.js.map +1 -1
- package/dist/harness/catalog/gate-rules.yaml +1 -1
- package/dist/harness/gates/audit-report-verification.d.ts +2 -0
- package/dist/harness/gates/audit-report-verification.d.ts.map +1 -1
- package/dist/harness/gates/audit-report-verification.js +8 -3
- package/dist/harness/gates/audit-report-verification.js.map +1 -1
- package/dist/harness/gates/delivery-checklist.d.ts.map +1 -1
- package/dist/harness/gates/delivery-checklist.js +9 -1
- package/dist/harness/gates/delivery-checklist.js.map +1 -1
- package/dist/harness/gates/in-run-decision-check.d.ts.map +1 -1
- package/dist/harness/gates/in-run-decision-check.js +10 -3
- package/dist/harness/gates/in-run-decision-check.js.map +1 -1
- package/dist/harness/journey.d.ts +21 -0
- package/dist/harness/journey.d.ts.map +1 -1
- package/dist/harness/journey.js +61 -11
- package/dist/harness/journey.js.map +1 -1
- package/dist/harness/next-step.d.ts +16 -0
- package/dist/harness/next-step.d.ts.map +1 -1
- package/dist/harness/next-step.js +14 -0
- package/dist/harness/next-step.js.map +1 -1
- package/dist/hooks/guard-tty-spoof-rule.d.ts +3 -2
- package/dist/hooks/guard-tty-spoof-rule.d.ts.map +1 -1
- package/dist/hooks/guard-tty-spoof-rule.js +2 -2
- package/dist/hooks/guard-tty-spoof-rule.js.map +1 -1
- package/dist/hooks/hand-back-core.d.ts +5 -2
- package/dist/hooks/hand-back-core.d.ts.map +1 -1
- package/dist/hooks/hand-back-core.js +10 -4
- package/dist/hooks/hand-back-core.js.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.d.ts +2 -1
- package/dist/hooks/session-start-unit-state-hook.d.ts.map +1 -1
- package/dist/hooks/session-start-unit-state-hook.js +4 -1
- package/dist/hooks/session-start-unit-state-hook.js.map +1 -1
- package/dist/hooks/stop-next-step-hook.d.ts.map +1 -1
- package/dist/hooks/stop-next-step-hook.js +1 -0
- package/dist/hooks/stop-next-step-hook.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/delivery.md +2 -0
- package/dist/orchestrator/templates/ai-src/commands/review.md +2 -0
- package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
- package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
- package/dist/orchestrator/templates/ai-src/partials/waiting-for-you.copilot.md +11 -0
- package/package.json +3 -3
- package/src/cli/case-triage-hint.ts +31 -0
- package/src/cli/commands/contract-derive-command.ts +3 -1
- package/src/cli/commands/contract.ts +5 -2
- package/src/cli/commands/delivery.ts +49 -16
- package/src/cli/commands/gate.ts +2 -1
- package/src/cli/commands/generate.ts +13 -2
- package/src/cli/commands/journey.ts +4 -2
- package/src/cli/commands/next.ts +30 -9
- package/src/cli/commands/triage.ts +5 -3
- package/src/cli/commands/update.ts +6 -2
- package/src/cli/index.ts +4 -0
- package/src/cli/json-error-on-exit.ts +72 -0
- package/src/cli/project-sungen-invocation.ts +20 -0
- package/src/cli/run-command-hints.ts +47 -0
- package/src/cli/waiting-for-you-block.ts +27 -0
- package/src/contract/derive-free-feature.ts +21 -0
- package/src/decisions/legacy-waiver-migration.ts +5 -1
- package/src/exporters/csv-exporter.ts +12 -4
- package/src/exporters/matrix/export.ts +11 -4
- package/src/exporters/xlsx-exporter.ts +5 -4
- package/src/graph/board-human-actions.ts +80 -0
- package/src/graph/evaluate-graph.ts +39 -8
- package/src/graph/human-action.ts +33 -0
- package/src/graph/node-decl.ts +11 -0
- package/src/graph/nodes/test-case-contract-nodes.ts +20 -4
- package/src/graph/nodes/test-case-nodes.ts +19 -5
- package/src/graph/nodes/unit-audit-nodes.ts +2 -1
- package/src/graph/nodes/unit-contract-node.ts +12 -1
- package/src/graph/unit-artifact-probes.ts +3 -1
- package/src/harness/catalog/gate-rules.yaml +1 -1
- package/src/harness/gates/audit-report-verification.ts +7 -2
- package/src/harness/gates/delivery-checklist.ts +11 -1
- package/src/harness/gates/in-run-decision-check.ts +11 -4
- package/src/harness/journey.ts +79 -8
- package/src/harness/next-step.ts +27 -1
- package/src/hooks/guard-tty-spoof-rule.ts +5 -4
- package/src/hooks/hand-back-core.ts +11 -5
- package/src/hooks/session-start-unit-state-hook.ts +4 -1
- package/src/hooks/stop-next-step-hook.ts +1 -0
- package/src/orchestrator/templates/ai-src/commands/delivery.md +2 -0
- package/src/orchestrator/templates/ai-src/commands/review.md +2 -0
- package/src/orchestrator/templates/ai-src/partials/waiting-for-you.claude.md +11 -0
- package/src/orchestrator/templates/ai-src/partials/waiting-for-you.codex.md +11 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
105
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) })),
|
package/src/cli/commands/gate.ts
CHANGED
|
@@ -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
|
-
|
|
259
|
-
|
|
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
|
-
|
|
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
|
});
|
package/src/cli/commands/next.ts
CHANGED
|
@@ -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)
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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} ${
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
+
}
|