@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
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The "Waiting for you" block (#954) — every pending human-only action of a unit, as ready-to-copy
|
|
3
|
+
* commands with the reason under each. One renderer for the three places that show it: `sungen next`
|
|
4
|
+
* (text) and the Stop / SessionStart hook context. Commands — and those inside a reason (#963) — as the
|
|
5
|
+
* project runs sungen (`npx sungen` with a local install, #939); `--json` keeps `command` canonical in
|
|
6
|
+
* `humanActions` and adds `rendered`. An instruction (a commit through review, a `git checkout --`) is
|
|
7
|
+
* marked `✎ … (by hand, not a command)`, never shown as one to copy.
|
|
8
|
+
*/
|
|
9
|
+
import type { HumanAction } from '../graph/human-action';
|
|
10
|
+
import { withProjectInvocation, withProjectInvocationInText } from './project-sungen-invocation';
|
|
11
|
+
|
|
12
|
+
export const WAITING_FOR_YOU_HEAD = '⏳ Waiting for you — a human runs these in their own terminal (in Claude Code: ! <command>); an agent never runs them:';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The block's lines (indented two spaces), or none when nothing waits for a human and nothing holds the
|
|
16
|
+
* rest. `heldBy`: the title of the blocking step that cut the frontier — the block is not exhaustive then,
|
|
17
|
+
* and says what holds the rest, with that step's command (`heldByCommand`) when given (#966). A held
|
|
18
|
+
* block with no item of its own still says so, rather than vanishing (a QA read "nothing is waiting").
|
|
19
|
+
*/
|
|
20
|
+
export function waitingForYouLines(actions: readonly HumanAction[] | undefined, projectRoot: string, heldBy?: string, heldByCommand?: string): string[] {
|
|
21
|
+
const holds = heldBy ? `"${heldBy}" clears${heldByCommand ? ` (the step above: ${withProjectInvocationInText(heldByCommand, projectRoot)})` : ''}` : '';
|
|
22
|
+
if (!actions?.length) return heldBy ? [` ⏳ Waiting for you: nothing yet — more appear once ${holds}.`] : [];
|
|
23
|
+
return [` ${WAITING_FOR_YOU_HEAD}`, ...actions.flatMap((a) => [
|
|
24
|
+
a.instruction ? ` ✎ ${a.command} (by hand, not a command)` : ` ${withProjectInvocation(a.command, projectRoot)}`,
|
|
25
|
+
` ${withProjectInvocationInText(a.reason, projectRoot)}`,
|
|
26
|
+
]), ...(heldBy ? [` More appear once ${holds}.`] : [])];
|
|
27
|
+
}
|
|
@@ -22,6 +22,7 @@ import { scrubKnownSecrets, scrubMachinePaths, secretScanHits, SecretHit } from
|
|
|
22
22
|
import { idOfTitle, splitFeatureBlocks } from './free-feature-blocks';
|
|
23
23
|
import { sensitiveLiteralHits } from './lock-secret-guard';
|
|
24
24
|
import { scenariosOfFeatureFile } from './normalize-feature-steps';
|
|
25
|
+
import { featureFilesFor } from '../harness/unit-paths';
|
|
25
26
|
import { CONTROL_CHARS, freeFeatureText, renderTraceSteps, scenarioBlockText } from './render-free-gherkin';
|
|
26
27
|
import { collectRuns, TraceRun } from './trace/collect-trace-evidence';
|
|
27
28
|
import { readIdentityCache } from './trace/identity-cache';
|
|
@@ -39,6 +40,8 @@ export interface DeriveResult {
|
|
|
39
40
|
skipped: Array<{ id: string; reason: string; approved?: true }>;
|
|
40
41
|
refused?: SecretHit[];
|
|
41
42
|
note?: string;
|
|
43
|
+
/** A derived id another `.feature` of the unit already uses (#939) — `contract check` and approve would trip on it later. */
|
|
44
|
+
warnings?: string[];
|
|
42
45
|
}
|
|
43
46
|
|
|
44
47
|
const MACHINE_PATH = /(?:\/Users|\/home)\/[^/\s"'`]+\/|[A-Za-z]:\\Users\\/;
|
|
@@ -59,6 +62,22 @@ function semanticHits(text: string, label: string): SecretHit[] {
|
|
|
59
62
|
} finally { fs.rmSync(dir, { recursive: true, force: true }); }
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/**
|
|
66
|
+
* The derived ids another `.feature` of the unit already carries (#939): `contract check` reports an id
|
|
67
|
+
* used twice and approve refuses it, so say it now — when it is still one rename away — not at check.
|
|
68
|
+
*/
|
|
69
|
+
function reusedIdWarnings(unit: { name: string; dir: string }, freeFile: string, ids: string[]): string[] {
|
|
70
|
+
const owners = new Map<string, string>();
|
|
71
|
+
for (const f of featureFilesFor(unit.dir, unit.name)) {
|
|
72
|
+
if (path.resolve(f) === path.resolve(freeFile) || !fs.existsSync(f)) continue;
|
|
73
|
+
try {
|
|
74
|
+
for (const sc of scenariosOfFeatureFile(f)) if (!owners.has(sc.id)) owners.set(sc.id, path.basename(f));
|
|
75
|
+
} catch { /* an unparseable sibling is `contract check`'s to report */ }
|
|
76
|
+
}
|
|
77
|
+
return ids.filter((id) => owners.has(id)).map((id) =>
|
|
78
|
+
`${id} is already the ID of a scenario in ${owners.get(id)} — the derived scenario reuses it; give the free test (its title's leading ID) or that scenario its own ID, or \`sungen contract check\` reports it twice and approval refuses it`);
|
|
79
|
+
}
|
|
80
|
+
|
|
62
81
|
/**
|
|
63
82
|
* Nothing was derived and nothing explains it away: no test of the unit left an admissible trace
|
|
64
83
|
* (#932). Not when the scenarios are approved (derive leaves those alone by design), up to date,
|
|
@@ -108,6 +127,8 @@ export async function deriveFreeFeature(i: DeriveInput): Promise<DeriveResult> {
|
|
|
108
127
|
result.scenarios.push({ id, title, steps: rendered[0].length, low: rendered[0].filter((s) => s.low).length, opaque: rendered[0].filter((s) => s.opaque).length });
|
|
109
128
|
}
|
|
110
129
|
if (!result.scenarios.length) return result;
|
|
130
|
+
const reused = reusedIdWarnings(i.unit, file, result.scenarios.map((x) => x.id));
|
|
131
|
+
if (reused.length) result.warnings = reused;
|
|
111
132
|
|
|
112
133
|
const text = freeFeatureText(unitBase, [...blocks.values()]);
|
|
113
134
|
const label = path.basename(file);
|
|
@@ -80,9 +80,13 @@ export function planWaiverSync(projectRoot: string, unit: string, slug: string,
|
|
|
80
80
|
return { scope, waivers, untrusted, stateWaivers, imports, markDone: !!scope && untrusted.length === 0 };
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/** The command a human types to give (or confirm) a waiver — the board's line and `sungen next`'s "Waiting for you" (#954). */
|
|
84
|
+
export const waiveCommand = (unit: string, obligation: string, reason: string): string =>
|
|
85
|
+
formatCommand(['sungen', 'journey', '--screen', unit, '--waive', obligation, '--reason', reason]);
|
|
86
|
+
|
|
83
87
|
/** The needs-you line for a waiver that is not in git HEAD: not counted, and how a human confirms it. */
|
|
84
88
|
export function untrustedWaiverText(unit: string, slug: string, title: string, u: UntrustedWaiver): string {
|
|
85
|
-
const confirm =
|
|
89
|
+
const confirm = waiveCommand(unit, u.obligation, u.waiver.reason);
|
|
86
90
|
return `⚠️ Waiver on "${title}" is UNTRUSTED — not counted (decision.in-run: it is in .sungen/journey/${slug}.state.json `
|
|
87
91
|
+ `but not in git HEAD). If it is yours, confirm it on a terminal: ${confirm}`;
|
|
88
92
|
}
|
|
@@ -439,12 +439,12 @@ export function renderCsv(summary: ScreenSummary, rows: TestCaseRow[], specLink:
|
|
|
439
439
|
* multi-locale delivery aggregator. Pass an empty string to force the
|
|
440
440
|
* base (no suffix) variant.
|
|
441
441
|
*/
|
|
442
|
-
export function writeCsv(cwd: string, screen: string, csvContent: string, locale?: string | null): string {
|
|
442
|
+
export function writeCsv(cwd: string, screen: string, csvContent: string, locale?: string | null, opts: DeliverableFileOptions = {}): string {
|
|
443
443
|
const outDir = path.join(cwd, 'qa', 'deliverables');
|
|
444
444
|
if (!fs.existsSync(outDir)) {
|
|
445
445
|
fs.mkdirSync(outDir, { recursive: true });
|
|
446
446
|
}
|
|
447
|
-
const outPath = path.join(outDir, `${deliverableBasename(screen, locale)}.csv`);
|
|
447
|
+
const outPath = path.join(outDir, `${deliverableBasename(screen, locale, opts)}.csv`);
|
|
448
448
|
fs.writeFileSync(outPath, csvContent, 'utf-8');
|
|
449
449
|
return outPath;
|
|
450
450
|
}
|
|
@@ -455,8 +455,16 @@ export function writeCsv(cwd: string, screen: string, csvContent: string, locale
|
|
|
455
455
|
* locale is given (or `SUNGEN_ENV` is set and no explicit override). Pass
|
|
456
456
|
* `null` / empty string to force the base variant.
|
|
457
457
|
*/
|
|
458
|
-
export function deliverableBasename(screen: string, locale?: string | null): string {
|
|
458
|
+
export function deliverableBasename(screen: string, locale?: string | null, opts: DeliverableFileOptions = {}): string {
|
|
459
459
|
const effective = locale !== undefined ? locale : process.env.SUNGEN_ENV;
|
|
460
|
-
return effective ? `${screen}-testcases.${effective}` : `${screen}-testcases`;
|
|
460
|
+
return `${effective ? `${screen}-testcases.${effective}` : `${screen}-testcases`}${opts.draft ? DRAFT_MARK : ''}`;
|
|
461
461
|
}
|
|
462
462
|
|
|
463
|
+
/**
|
|
464
|
+
* A DRAFT render (a watermarked `--preview`) is written beside the official deliverable, never onto it
|
|
465
|
+
* (#965): `<screen>-testcases[.env].DRAFT.<ext>`. Readers of `qa/deliverables/` skip it (isDraftDeliverable).
|
|
466
|
+
*/
|
|
467
|
+
export interface DeliverableFileOptions { draft?: boolean }
|
|
468
|
+
export const DRAFT_MARK = '.DRAFT';
|
|
469
|
+
export const isDraftDeliverable = (fileName: string): boolean => /\.DRAFT\.[^.]+$/.test(fileName);
|
|
470
|
+
|
|
@@ -169,7 +169,12 @@ export function approveMatrix(paths: MatrixTargetPaths, groupIds?: string[]): {
|
|
|
169
169
|
|
|
170
170
|
export type MatrixFormat = 'xlsx' | 'csv' | 'both';
|
|
171
171
|
|
|
172
|
-
/**
|
|
172
|
+
/**
|
|
173
|
+
* Render + write the requested format(s). Caller has already enforced the gate policy. A draft (the
|
|
174
|
+
* matrix is not approved for official delivery: it carries the DRAFT banner) goes to its own
|
|
175
|
+
* `…-testcases.DRAFT.<ext>` and never overwrites the official file (#965); the tester's Manual results
|
|
176
|
+
* are still carried from the official workbook when it can be read.
|
|
177
|
+
*/
|
|
173
178
|
export async function writeMatrixDeliverables(
|
|
174
179
|
paths: MatrixTargetPaths,
|
|
175
180
|
model: MatrixModel,
|
|
@@ -179,23 +184,25 @@ export async function writeMatrixDeliverables(
|
|
|
179
184
|
): Promise<{ csvPath?: string; xlsxPath?: string; manualCarry?: ManualCarryReport }> {
|
|
180
185
|
const out: { csvPath?: string; xlsxPath?: string; manualCarry?: ManualCarryReport } = {};
|
|
181
186
|
const xlsx = format === 'xlsx' || format === 'both';
|
|
187
|
+
const draft = model.manifest.approvalState === 'draft';
|
|
182
188
|
// Read the workbook about to be overwritten BEFORE writing anything, so the tester's Manual
|
|
183
189
|
// results survive (#756) and a refusal leaves no half-written target behind.
|
|
184
190
|
// A CSV-only export overwrites no workbook, so an unreadable one is no reason to refuse it — but a
|
|
185
191
|
// readable one still holds the Manual results the CSV should show.
|
|
192
|
+
// A draft overwrites nothing official either, so an unreadable official workbook is no reason to refuse it.
|
|
186
193
|
const prior = priorRead !== undefined ? priorRead
|
|
187
|
-
: xlsx ? await readPriorManualResults(deliverableXlsxPath(paths.cwd, paths.unit))
|
|
194
|
+
: xlsx && !draft ? await readPriorManualResults(deliverableXlsxPath(paths.cwd, paths.unit))
|
|
188
195
|
: await readPriorManualResults(deliverableXlsxPath(paths.cwd, paths.unit)).catch(() => null);
|
|
189
196
|
// Into the model first: the CSV, every cached total and roll-up, and the caller's summary
|
|
190
197
|
// all read it (#891).
|
|
191
198
|
applyManualCarry(model, prior);
|
|
192
199
|
if (format === 'csv' || format === 'both') {
|
|
193
|
-
out.csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model));
|
|
200
|
+
out.csvPath = writeCsv(paths.cwd, paths.unit, renderMatrixCsv(model), undefined, { draft });
|
|
194
201
|
}
|
|
195
202
|
if (xlsx) {
|
|
196
203
|
const wb = renderMatrixXlsx(model, getPackageVersion());
|
|
197
204
|
out.manualCarry = carryManualResults(wb, model, prior);
|
|
198
|
-
out.xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb);
|
|
205
|
+
out.xlsxPath = await writeXlsx(paths.cwd, paths.unit, wb, undefined, { draft });
|
|
199
206
|
}
|
|
200
207
|
return out;
|
|
201
208
|
}
|
|
@@ -18,7 +18,7 @@ import ExcelJS from 'exceljs';
|
|
|
18
18
|
import JSZip from 'jszip';
|
|
19
19
|
import { ApiCatalogEntry, ApiDetailRow, ScreenSummary, TestCaseRow } from './types';
|
|
20
20
|
import { getPackageVersion } from './package-info';
|
|
21
|
-
import { deliverableBasename } from './csv-exporter';
|
|
21
|
+
import { deliverableBasename, DeliverableFileOptions } from './csv-exporter';
|
|
22
22
|
import {
|
|
23
23
|
extractApiCallOrder,
|
|
24
24
|
extractCasesDataset,
|
|
@@ -114,8 +114,8 @@ export function buildApiDetailRows(
|
|
|
114
114
|
* to force the base (no suffix) variant.
|
|
115
115
|
*/
|
|
116
116
|
/** Where `writeXlsx` puts a unit's workbook — shared so a reader targets the identical file. */
|
|
117
|
-
export function deliverableXlsxPath(cwd: string, screen: string, locale?: string | null): string {
|
|
118
|
-
return path.join(cwd, 'qa', 'deliverables', `${deliverableBasename(screen, locale)}.xlsx`);
|
|
117
|
+
export function deliverableXlsxPath(cwd: string, screen: string, locale?: string | null, opts: DeliverableFileOptions = {}): string {
|
|
118
|
+
return path.join(cwd, 'qa', 'deliverables', `${deliverableBasename(screen, locale, opts)}.xlsx`);
|
|
119
119
|
}
|
|
120
120
|
|
|
121
121
|
export async function writeXlsx(
|
|
@@ -123,8 +123,9 @@ export async function writeXlsx(
|
|
|
123
123
|
screen: string,
|
|
124
124
|
wb: ExcelJS.Workbook,
|
|
125
125
|
locale?: string | null,
|
|
126
|
+
opts: DeliverableFileOptions = {},
|
|
126
127
|
): Promise<string> {
|
|
127
|
-
const outPath = deliverableXlsxPath(cwd, screen, locale);
|
|
128
|
+
const outPath = deliverableXlsxPath(cwd, screen, locale, opts);
|
|
128
129
|
const outDir = path.dirname(outPath);
|
|
129
130
|
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
|
130
131
|
const buffer = await wb.xlsx.writeBuffer();
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The human-only actions the graph does not hold ("Waiting for you", #954) — asked by the journey
|
|
3
|
+
* board and the delivery's decision check, read through THEIR functions (never restated here):
|
|
4
|
+
*
|
|
5
|
+
* waive a waiver the board re-surfaces: stale (the audit changed since) or untrusted (not
|
|
6
|
+
* in git HEAD) — readJourneyHumanAsks, the reconcile `runJourney` commits
|
|
7
|
+
* decide a gate-override that relaxes nothing because no human confirmed it (ineffective) —
|
|
8
|
+
* re-recorded with `journey --decide`, superseding it (loadUnitGateOverrides)
|
|
9
|
+
* signoff every other obligation holds — assertCanSignOff, as `journey --signoff` checks it
|
|
10
|
+
* accept-in-run a decision recorded during the run — evaluateDeliveryDecisions, as `delivery` runs it
|
|
11
|
+
* The sign-off and accept-in-run only once the frontier was not cut (the unit has run and nothing
|
|
12
|
+
* blocks before it). Read-only and advisory: whatever cannot be read adds nothing.
|
|
13
|
+
*/
|
|
14
|
+
import { formatCommand } from '../decisions/human-confirmation';
|
|
15
|
+
import { waiveCommand } from '../decisions/legacy-waiver-migration';
|
|
16
|
+
import { evaluateDeliveryDecisions } from '../harness/gates/delivery-decision-gate';
|
|
17
|
+
import { IN_RUN_RULE } from '../harness/gates/in-run-decision-check';
|
|
18
|
+
import { loadUnitGateOverrides, unitScopeOfDir } from '../harness/gates/unit-gate-overrides';
|
|
19
|
+
import { readJourneyHumanAsks, signoffCommand } from '../harness/journey';
|
|
20
|
+
import type { UnitState } from '../harness/next-step';
|
|
21
|
+
import type { HumanAction } from './human-action';
|
|
22
|
+
import { unitResultFiles } from './unit-artifact-probes';
|
|
23
|
+
|
|
24
|
+
function journeyActions(projectRoot: string, s: UnitState, cut: boolean): HumanAction[] {
|
|
25
|
+
const asks = readJourneyHumanAsks(projectRoot, s.unit);
|
|
26
|
+
const out: HumanAction[] = asks.redecide.map((w) => ({
|
|
27
|
+
kind: 'waive',
|
|
28
|
+
command: waiveCommand(s.unit, w.obligation, w.reason),
|
|
29
|
+
reason: w.why === 'stale'
|
|
30
|
+
? `the waiver on "${w.title}" is stale — the evidence changed since it was given, so it no longer counts; if it still holds, a human gives it again on a terminal (or repair the obligation)`
|
|
31
|
+
: `the waiver on "${w.title}" is not in git HEAD (decision.in-run), so it is not counted — if it is yours, confirm it on a terminal`,
|
|
32
|
+
}));
|
|
33
|
+
if (!cut && asks.canSignOff && asks.signoff.status !== 'given') {
|
|
34
|
+
out.push({
|
|
35
|
+
kind: 'signoff',
|
|
36
|
+
command: signoffCommand(s.unit),
|
|
37
|
+
reason: asks.signoff.status === 'stale'
|
|
38
|
+
? `the sign-off of ${asks.signoff.at} is stale — the suite changed since; review the queue (\`sungen journey --screen ${s.unit}\`) and sign off again`
|
|
39
|
+
: `every other obligation is satisfied or waived — the human sign-off is the one gate \`sungen gate --phase deliver\` still waits on; review the queue (\`sungen journey --screen ${s.unit}\`, ${asks.report.needsYou.length} item(s)), then sign`,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return out;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function unconfirmedOverrides(projectRoot: string, unitDir: string, s: UnitState): HumanAction[] {
|
|
46
|
+
return loadUnitGateOverrides(projectRoot, unitScopeOfDir(projectRoot, unitDir)).ineffective.map((o) => {
|
|
47
|
+
const { humanAccepted: _ignored, ...payload } = o.entry.payload ?? {};
|
|
48
|
+
return {
|
|
49
|
+
kind: 'decide',
|
|
50
|
+
command: formatCommand(['sungen', 'journey', '--screen', s.unit, '--decide', 'gate-override', '--text', o.entry.text, '--reason', o.entry.reason,
|
|
51
|
+
'--payload', JSON.stringify(payload), '--supersedes', o.decisionId]),
|
|
52
|
+
reason: `the gate-override ${o.decisionId}${o.rule ? ` (${o.rule})` : ''} relaxes nothing: ${o.reason} — if it is meant, a human records it again on a terminal, superseding it`,
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function inRunActions(projectRoot: string, unitDir: string, s: UnitState): HumanAction[] {
|
|
58
|
+
const files = unitResultFiles(projectRoot, s.unit);
|
|
59
|
+
const refs = (files.length ? files : [null]).map((resultsPath) => ({ label: s.unit, unitName: s.unit, unitDir, resultsPath }));
|
|
60
|
+
const inRun = evaluateDeliveryDecisions(projectRoot, refs).inRun;
|
|
61
|
+
if (!inRun.length) return [];
|
|
62
|
+
const which = inRun.slice(0, 3).map((d) => `${d.kind} ${d.id}`).join(', ');
|
|
63
|
+
return [{
|
|
64
|
+
kind: 'accept-in-run',
|
|
65
|
+
command: formatCommand(['sungen', 'delivery', s.unit, '--accept-in-run']),
|
|
66
|
+
reason: `${inRun.length} decision(s) recorded during the run (${IN_RUN_RULE}): ${which}${inRun.length > 3 ? ', …' : ''} — \`sungen delivery\` refuses them until they are committed through review and the tests re-run, or a human accepts them on a terminal`,
|
|
67
|
+
}];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The board's and the delivery's asks of a human for this unit. `cut`: the frontier stopped at a blocking node. */
|
|
71
|
+
export function boardHumanActions(projectRoot: string, unitDir: string, s: UnitState, cut: boolean): HumanAction[] {
|
|
72
|
+
const out: HumanAction[] = [];
|
|
73
|
+
const add = (read: () => HumanAction[]): void => {
|
|
74
|
+
try { out.push(...read()); } catch { /* advisory: an unreadable board or decision adds nothing */ }
|
|
75
|
+
};
|
|
76
|
+
add(() => journeyActions(projectRoot, s, cut));
|
|
77
|
+
add(() => unconfirmedOverrides(projectRoot, unitDir, s));
|
|
78
|
+
if (!cut) add(() => inRunActions(projectRoot, unitDir, s));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -11,8 +11,12 @@
|
|
|
11
11
|
* `--case <id>` (`testCase` option): the frontier is that case's test-case nodes plus the blocking
|
|
12
12
|
* unit nodes before them; non-blocking unit advice is left to the unit view. A `caseOnly` test-case
|
|
13
13
|
* node speaks only there (its unit-level decision is in the unit view already).
|
|
14
|
+
*
|
|
15
|
+
* evaluateHumanActions reads the SAME walk for "Waiting for you" (#954): the human-only actions behind
|
|
16
|
+
* the offered steps, and whether the walk was cut.
|
|
14
17
|
*/
|
|
15
18
|
import type { NextStep, UnitState } from '../harness/next-step';
|
|
19
|
+
import type { HumanAction } from './human-action';
|
|
16
20
|
import type { NodeDecl, StepCore } from './node-decl';
|
|
17
21
|
import type { TestCaseState } from './test-case-state-shape';
|
|
18
22
|
|
|
@@ -37,30 +41,57 @@ function openCases(node: NodeDecl, s: UnitState, tcStates: TestCaseState[] | und
|
|
|
37
41
|
return tcStates.filter((tc) => (!only || tc.id === only) && node.active(s, tc) && !node.satisfied(s, tc));
|
|
38
42
|
}
|
|
39
43
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
44
|
+
/** One offered step and what produced it: the node, and the cases open at it (test-case nodes). */
|
|
45
|
+
interface Offered { node: NodeDecl; step: NextStep; cases: TestCaseState[] }
|
|
46
|
+
|
|
47
|
+
/** The walk both answers share: the offered steps in rank order, and whether an open blocking node cut it. */
|
|
48
|
+
function walkFrontier(nodes: readonly NodeDecl[], s: UnitState, tcStates: TestCaseState[] | undefined, opts: EvaluateOptions): { offered: Offered[]; cut: boolean; cutBy?: NodeDecl } {
|
|
43
49
|
const ordered = [...nodes].sort((a, b) => a.rank - b.rank);
|
|
44
50
|
const open = new Set<string>();
|
|
45
|
-
const
|
|
51
|
+
const offered: Offered[] = [];
|
|
46
52
|
let cut = false;
|
|
53
|
+
let cutBy: NodeDecl | undefined;
|
|
47
54
|
for (const node of ordered) {
|
|
48
55
|
if (!node.appliesTo.includes(s.kind) || (node.granularity === 'test-case' && node.caseOnly && !opts.testCase)) continue;
|
|
49
56
|
if (cut && !node.surfacesPastCut) continue;
|
|
50
57
|
let step: NextStep | null = null;
|
|
58
|
+
let cases: TestCaseState[] = [];
|
|
51
59
|
if (node.granularity === 'unit') {
|
|
52
60
|
if (!node.active(s) || node.satisfied(s) || (cut && !node.surfacesPastCut!(s))) continue;
|
|
53
61
|
if (!opts.testCase || node.blocking) step = withMeta(node, node.step(s));
|
|
54
62
|
} else {
|
|
55
|
-
|
|
63
|
+
cases = openCases(node, s, tcStates, opts.testCase).filter((tc) => !cut || node.surfacesPastCut!(s, tc));
|
|
56
64
|
if (!cases.length) continue;
|
|
57
65
|
const single = Boolean(opts.testCase);
|
|
58
66
|
step = withMeta(node, node.step(s, cases, single), single ? cases[0].id : undefined);
|
|
59
67
|
}
|
|
60
68
|
open.add(node.id);
|
|
61
69
|
const ready = node.requires.every((r) => !open.has(r));
|
|
62
|
-
if (step && (cut || ready || !node.blocking))
|
|
63
|
-
if (node.blocking && !cut) cut = true;
|
|
70
|
+
if (step && (cut || ready || !node.blocking)) offered.push({ node, step, cases });
|
|
71
|
+
if (node.blocking && !cut) { cut = true; cutBy = node; }
|
|
64
72
|
}
|
|
65
|
-
return
|
|
73
|
+
return { offered, cut, ...(cutBy ? { cutBy } : {}) };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function evaluateGraph(
|
|
77
|
+
nodes: readonly NodeDecl[], s: UnitState, tcStates?: TestCaseState[], opts: EvaluateOptions = {},
|
|
78
|
+
): NextStep[] {
|
|
79
|
+
return walkFrontier(nodes, s, tcStates, opts).offered.map((o) => o.step);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The HUMAN-ONLY actions of the same frontier ("Waiting for you", #954) — never a second walk with
|
|
84
|
+
* rules of its own: each offered step's node says what a person runs (its `human` hook), and a decide
|
|
85
|
+
* step without one is its own command. `cut`: an open blocking node ended the walk, so only what does
|
|
86
|
+
* not depend on it is offered (a hard verdict that surfaces past the cut), and the board's asks that
|
|
87
|
+
* need the unit past it (sign-off, accept-in-run) wait; `heldBy` is that node's title and `heldByCommand`
|
|
88
|
+
* its offered step's command (#966: "More appear once …" names what to run).
|
|
89
|
+
*/
|
|
90
|
+
export function evaluateHumanActions(nodes: readonly NodeDecl[], s: UnitState, tcStates?: TestCaseState[]): { actions: HumanAction[]; cut: boolean; heldBy?: string; heldByCommand?: string } {
|
|
91
|
+
const { offered, cut, cutBy } = walkFrontier(nodes, s, tcStates, {});
|
|
92
|
+
const actions = offered.flatMap(({ node, step, cases }) => (node.human
|
|
93
|
+
? node.human(s, step, cases)
|
|
94
|
+
: step.kind === 'decide' ? [{ command: step.command, reason: step.because, kind: 'decide' as const }] : []));
|
|
95
|
+
const heldByCommand = cutBy ? offered.find((o) => o.node === cutBy)?.step.command : undefined;
|
|
96
|
+
return { actions, cut, ...(cutBy ? { heldBy: cutBy.title } : {}), ...(heldByCommand ? { heldByCommand } : {}) };
|
|
66
97
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A HUMAN-ONLY action of a unit — what `sungen next` lists under "Waiting for you" (#954) and `--json`
|
|
3
|
+
* carries as `humanActions`. An agent presents these and never runs them: they approve, sign, waive,
|
|
4
|
+
* classify or accept something on a person's behalf, and most refuse without a typed confirmation.
|
|
5
|
+
*
|
|
6
|
+
* `command` is canonical (`sungen …`, or a slash command); the text renders it as the project runs
|
|
7
|
+
* sungen (`npx sungen` with a local install). Placeholders a human fills are quoted (`"<name>"`, `"…"`)
|
|
8
|
+
* so a copied command never turns into a shell redirection.
|
|
9
|
+
*/
|
|
10
|
+
export type HumanActionKind =
|
|
11
|
+
| 'contract-approve' // approve (or re-approve) the Test Contract — `sungen contract approve`
|
|
12
|
+
| 'accept-opaque' // accept a reviewed opaque trace step — `contract approve --scenario <id> --accept-opaque`
|
|
13
|
+
| 'contract-commit' // commit, or restore, a lock / approval decision through review
|
|
14
|
+
| 'contract-review' // read the contract verdict on a terminal (it could not be built)
|
|
15
|
+
| 'accept-viewpoint' // accept the changed viewpoint baseline — `sungen audit --accept-viewpoint`
|
|
16
|
+
| 'defect-draft' // decide whether a product-defect candidate is a defect — `sungen defect draft`
|
|
17
|
+
| 'rerun-environment' // re-run the reds the environment caused, once a human confirms it is up
|
|
18
|
+
| 'triage-classify' // classify a red by hand as test-defect / environment — `sungen triage --case --as`
|
|
19
|
+
| 'signoff' // the single human gate — `sungen journey --signoff`
|
|
20
|
+
| 'waive' // give a waiver again — `sungen journey --waive`
|
|
21
|
+
| 'decide' // record a decision — `sungen journey --decide`
|
|
22
|
+
| 'accept-in-run'; // deliver despite decisions recorded during the run — `sungen delivery --accept-in-run`
|
|
23
|
+
|
|
24
|
+
export interface HumanAction {
|
|
25
|
+
command: string;
|
|
26
|
+
reason: string;
|
|
27
|
+
kind: HumanActionKind;
|
|
28
|
+
/**
|
|
29
|
+
* `command` is an instruction a person carries out by hand (commit through review, restore from git),
|
|
30
|
+
* not a command line to copy — the block marks it so, and never renders it as one.
|
|
31
|
+
*/
|
|
32
|
+
instruction?: true;
|
|
33
|
+
}
|
package/src/graph/node-decl.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* `sungen next <unit> --case <id>`).
|
|
12
12
|
*/
|
|
13
13
|
import type { NextStep, UnitState } from '../harness/next-step';
|
|
14
|
+
import type { HumanAction, HumanActionKind } from './human-action';
|
|
14
15
|
import type { TestCaseState } from './test-case-state-shape';
|
|
15
16
|
|
|
16
17
|
export type NodeActor = 'ai' | 'human' | 'deterministic';
|
|
@@ -35,8 +36,18 @@ interface NodeDeclBase {
|
|
|
35
36
|
gates: readonly string[];
|
|
36
37
|
/** Artifacts the step produces, for a reader of the graph. */
|
|
37
38
|
produces: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* The HUMAN-ONLY actions behind an offered step ("Waiting for you", #954): what a person runs, with
|
|
41
|
+
* why. Without it a `decide` step is one action of kind `decide` and any other step is none. `cases`
|
|
42
|
+
* are the cases open at a test-case node (none for a unit node).
|
|
43
|
+
*/
|
|
44
|
+
human?(s: UnitState, step: StepCore, cases: TestCaseState[]): HumanAction[];
|
|
38
45
|
}
|
|
39
46
|
|
|
47
|
+
/** The `human` hook of a node whose decide step IS the human action: its command and reason, under `kind`. */
|
|
48
|
+
export const humanStep = (kind: HumanActionKind) => (_s: UnitState, step: StepCore): HumanAction[] =>
|
|
49
|
+
[{ command: step.command, reason: step.because, kind }];
|
|
50
|
+
|
|
40
51
|
export interface UnitNodeDecl extends NodeDeclBase {
|
|
41
52
|
granularity: 'unit';
|
|
42
53
|
active(s: UnitState): boolean;
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* unit.gate-fail), #940; tc.contract-approve (changed, not weaker) is review and waits behind it. Exempt cases (DOS-11.2) open nothing. A proposed case (never approved) asks nothing of its own: a
|
|
17
17
|
* compiled test without a lock is never blocked, and the unit view offers the approval.
|
|
18
18
|
*/
|
|
19
|
-
import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
19
|
+
import { ALL_KINDS, humanStep, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
20
20
|
import type { UnitState } from '../../harness/next-step';
|
|
21
|
+
import type { HumanAction } from '../human-action';
|
|
21
22
|
import type { TestCaseState } from '../test-case-state-shape';
|
|
22
23
|
import { exemptionOf } from '../test-case-satisfied';
|
|
23
24
|
import { idsArg, listed } from './test-case-nodes';
|
|
@@ -31,7 +32,7 @@ const withDetail = (cases: TestCaseState[]): string =>
|
|
|
31
32
|
const check = (s: UnitState, trace = false): string => `sungen contract check --screen ${s.unit}${trace ? ' --trace' : ''}`;
|
|
32
33
|
|
|
33
34
|
function contractNode(id: string, rank: number, title: string, actor: TestCaseNodeDecl['actor'], gates: string[], caseOnly: boolean,
|
|
34
|
-
active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean): TestCaseNodeDecl {
|
|
35
|
+
active: (tc: TestCaseState) => boolean, step: Step, hard?: (tc: TestCaseState) => boolean, human?: TestCaseNodeDecl['human']): TestCaseNodeDecl {
|
|
35
36
|
return {
|
|
36
37
|
id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, blocking: false, rank, gates, step,
|
|
37
38
|
requires: caseOnly ? ['unit.gate-fail'] : ['unit.run'], produces: ['contracts.lock.yaml', 'decisions/<id>.yaml'],
|
|
@@ -39,9 +40,23 @@ function contractNode(id: string, rank: number, title: string, actor: TestCaseNo
|
|
|
39
40
|
active: (_s, tc) => active(tc),
|
|
40
41
|
satisfied: (_s, tc) => exemptionOf(tc) !== null,
|
|
41
42
|
...(hard ? { surfacesPastCut: (_s: UnitState, tc: TestCaseState) => hard(tc) } : {}),
|
|
43
|
+
...(human ? { human } : {}),
|
|
42
44
|
};
|
|
43
45
|
}
|
|
44
46
|
|
|
47
|
+
/**
|
|
48
|
+
* An opaque step is cleared by a QA accepting it as it reads now, never by re-approving (#923): one
|
|
49
|
+
* `--accept-opaque` per case whose only hold is the opaque step. A case held by a `low` identity is
|
|
50
|
+
* not acceptable yet (`contract check --trace` resolves it first), so it is not offered (#954).
|
|
51
|
+
*/
|
|
52
|
+
function acceptOpaque(s: UnitState, _step: StepCore, cases: TestCaseState[]): HumanAction[] {
|
|
53
|
+
return cases.filter((c) => classOf(c) === 'opaque' && !c.contract?.lowIdentity).map((c) => ({
|
|
54
|
+
kind: 'accept-opaque',
|
|
55
|
+
command: `sungen contract approve --screen ${s.unit} --scenario ${c.id} --accept-opaque`,
|
|
56
|
+
reason: `${c.id} carries a step sungen cannot compare${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA reviews it verbatim with its file:line in \`${check(s)}\`, then accepts it as it reads now; until then the contract is in review and \`sungen delivery\` waits`,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
45
60
|
const conformanceStep: Step = (s, cases) => {
|
|
46
61
|
const low = cases.filter((c) => c.contract?.lowIdentity);
|
|
47
62
|
if (low.length) {
|
|
@@ -57,7 +72,8 @@ const conformanceStep: Step = (s, cases) => {
|
|
|
57
72
|
export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
|
|
58
73
|
contractNode('tc.contract-approve', 106, 'Re-approve this changed case', 'human', ['contract.hash-changed'], true,
|
|
59
74
|
(tc) => classOf(tc) === 'hash-changed' && !tc.contract?.uncommitted,
|
|
60
|
-
(s, [c]) => ({ kind: 'decide', command: `sungen contract approve --screen ${s.unit} --scenario ${c.id}`, because: `this case changed since QA approved it, not weaker${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA re-approves this scenario on a terminal; until then review blocks \`sungen delivery\`. An agent presents it and never approves its own edit` })
|
|
75
|
+
(s, [c]) => ({ kind: 'decide', command: `sungen contract approve --screen ${s.unit} --scenario ${c.id}`, because: `this case changed since QA approved it, not weaker${c.contract?.detail ? ` (${c.contract.detail})` : ''} — a QA re-approves this scenario on a terminal; until then review blocks \`sungen delivery\`. An agent presents it and never approves its own edit` }),
|
|
76
|
+
undefined, humanStep('contract-approve')),
|
|
61
77
|
contractNode('tc.contract-check', 107, 'Restore the approved check of this case', 'human',
|
|
62
78
|
['contract.oracle-weakened', 'contract.target-changed', 'contract.assertion-lost', 'contract.lock-uncommitted'], true,
|
|
63
79
|
(tc) => Boolean(tc.contract?.uncommitted) || HARD_LADDER.has(classOf(tc) ?? ''),
|
|
@@ -70,5 +86,5 @@ export const TEST_CASE_CONTRACT_NODES: readonly TestCaseNodeDecl[] = [
|
|
|
70
86
|
(s, cases) => ({ kind: 'do', command: `/sungen:run-test ${s.unit}${idsArg(cases)}`, because: `${cases.length} approved free / impl:code test case(s) have no trace that proves them (${withDetail(cases)}) — run them again with trace: 'on' and the sungen run-record reporter (run-test Phase 4 does), then \`${check(s, true)}\`. Until then \`sungen contract check\` fails (contract.unproven) and \`sungen delivery\` is blocked; re-approving or editing the .feature cannot prove them` }),
|
|
71
87
|
(tc) => classOf(tc) === 'unproven'),
|
|
72
88
|
contractNode('tc.conformance', 138, 'Settle the traces in review', 'ai', ['contract.opaque-step', 'contract.inconclusive'], false,
|
|
73
|
-
(tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep),
|
|
89
|
+
(tc) => classOf(tc) === 'opaque' || classOf(tc) === 'inconclusive', conformanceStep, undefined, acceptOpaque),
|
|
74
90
|
];
|
|
@@ -15,8 +15,9 @@
|
|
|
15
15
|
* the reds the cases account for; unit.triage keeps the rest (unattributedReds), so a red no case
|
|
16
16
|
* explains — an @cases row, a renamed scenario, a nested describe title — still gets a triage step.
|
|
17
17
|
*/
|
|
18
|
-
import { ALL_KINDS, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
18
|
+
import { ALL_KINDS, humanStep, StepCore, TestCaseNodeDecl } from '../node-decl';
|
|
19
19
|
import type { UnitState } from '../../harness/next-step';
|
|
20
|
+
import type { HumanAction } from '../human-action';
|
|
20
21
|
import type { TestCaseState } from '../test-case-state-shape';
|
|
21
22
|
import { exemptionOf, TestCaseNeed, triggerOf } from '../test-case-satisfied';
|
|
22
23
|
|
|
@@ -31,15 +32,28 @@ const withRule = (cases: TestCaseState[]): string =>
|
|
|
31
32
|
`${cases.slice(0, 3).map((c) => `${c.id}: ${c.triage?.ruleId}`).join('; ')}${cases.length > 3 ? '; …' : ''}`;
|
|
32
33
|
|
|
33
34
|
function caseNode(id: string, need: TestCaseNeed, rank: number, title: string, actor: TestCaseNodeDecl['actor'],
|
|
34
|
-
step: (s: UnitState, cases: TestCaseState[], single: boolean) => StepCore, produces: string[]): TestCaseNodeDecl {
|
|
35
|
+
step: (s: UnitState, cases: TestCaseState[], single: boolean) => StepCore, produces: string[], human?: TestCaseNodeDecl['human']): TestCaseNodeDecl {
|
|
35
36
|
return {
|
|
36
37
|
id, title, actor, granularity: 'test-case', appliesTo: ALL_KINDS, requires: ['unit.run'], blocking: false, rank,
|
|
37
38
|
gates: [], produces, step,
|
|
38
39
|
active: (_s, tc) => triggerOf(tc) === need,
|
|
39
40
|
satisfied: (_s, tc) => exemptionOf(tc) !== null,
|
|
41
|
+
...(human ? { human } : {}),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
42
44
|
|
|
45
|
+
/**
|
|
46
|
+
* A red triage left unclassified is a human's to classify (#928): test-defect and environment take it
|
|
47
|
+
* off the product-defect path, so they need a person on a terminal. One action per such case (#954).
|
|
48
|
+
*/
|
|
49
|
+
function classifyByHand(s: UnitState, _step: StepCore, cases: TestCaseState[]): HumanAction[] {
|
|
50
|
+
return cases.filter((c) => c.triage?.class === 'unclassified').map((c) => ({
|
|
51
|
+
kind: 'triage-classify',
|
|
52
|
+
command: `sungen triage ${flag(s)} ${s.unit} --case ${c.id} --as "<test-defect|environment>" --reason "…"`,
|
|
53
|
+
reason: `${c.id} failed and \`sungen triage\` left it unclassified — read its evidence (trace, error), then classify it: test-defect or environment take it off the product-defect path, so a human types them on a terminal (\`--as product\` anyone may record)`,
|
|
54
|
+
}));
|
|
55
|
+
}
|
|
56
|
+
|
|
43
57
|
export const TEST_CASE_NODES: readonly TestCaseNodeDecl[] = [
|
|
44
58
|
caseNode('tc.run', 'run', 131, 'Run the cases that never ran', 'ai', (s, cases) => ({
|
|
45
59
|
kind: 'do',
|
|
@@ -58,19 +72,19 @@ export const TEST_CASE_NODES: readonly TestCaseNodeDecl[] = [
|
|
|
58
72
|
command: `sungen triage ${flag(s)} ${s.unit}`,
|
|
59
73
|
because: `${cases.length} test case(s) failed in the latest run and are not classified yet${left ? ` (${left} left unclassified by the last triage — read its evidence and classify by hand: \`sungen triage ${flag(s)} ${s.unit} --case <id> --as product|test-defect|environment --reason "…"\`; test-defect and environment are a human's call, on a terminal)` : ''} — classify each (known defect, test defect, environment, product candidate) before exporting or extending: an unexplained red in the deliverable is a finding nobody has judged`,
|
|
60
74
|
};
|
|
61
|
-
}, ['.sungen/triage/<unit>.json']),
|
|
75
|
+
}, ['.sungen/triage/<unit>.json'], classifyByHand),
|
|
62
76
|
caseNode('tc.product-candidate', 'product-candidate', 134, 'Decide on the product-defect candidates', 'human', (s, cases, single) => ({
|
|
63
77
|
kind: 'decide',
|
|
64
78
|
command: `sungen defect draft ${flag(s)} ${s.unit}${single ? ` --scenario ${cases[0].id}` : ''}`,
|
|
65
79
|
because: `${cases.length} red test case(s) look like a product defect (${listed(cases)}) — the app answered and the answer differs from the oracle. A QA decides whether it is a defect to record; an agent presents it and does not draft it on its own`,
|
|
66
|
-
}), ['qa/defects/DEF-<n>-<slug>.md']),
|
|
80
|
+
}), ['qa/defects/DEF-<n>-<slug>.md'], humanStep('defect-draft')),
|
|
67
81
|
// decide, not do: an agent re-running into an environment that is still down gets the same red and
|
|
68
82
|
// the same step again — a loop with no stop. A human confirms the environment first (#928 review W3).
|
|
69
83
|
caseNode('tc.rerun-environment', 'rerun-environment', 139, 'Re-run the cases the environment failed, once it is up', 'human', (s, cases) => ({
|
|
70
84
|
kind: 'decide',
|
|
71
85
|
command: `/sungen:run-test ${s.unit}${idsArg(cases)}`,
|
|
72
86
|
because: `${cases.length} red test case(s) failed on the environment, not the app (${withRule(cases)}) — the run never reached a usable app, so their results say nothing about it yet. A human confirms the app, base URL, network, auth state or device is up, then they are re-run; an agent presents this and does not re-run on its own — into an environment that is still down, the same red comes back`,
|
|
73
|
-
}), ['specs/generated/<unit>/<unit>-test-result.json']),
|
|
87
|
+
}), ['specs/generated/<unit>/<unit>-test-result.json'], humanStep('rerun-environment')),
|
|
74
88
|
caseNode('tc.fix-test', 'fix-test', 139.5, 'Fix the tests triaged as test defects', 'ai', (s, cases) => ({
|
|
75
89
|
kind: 'do',
|
|
76
90
|
command: `/sungen:run-test ${s.unit}${idsArg(cases)}`,
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* 9 unit.repair-findings findings with a known repair → repair + re-audit, names ≤ 4 codes
|
|
9
9
|
* 10 unit.gate-fail gateStatus FAIL → blocked, cuts
|
|
10
10
|
*/
|
|
11
|
-
import { ALL_KINDS, UnitNodeDecl } from '../node-decl';
|
|
11
|
+
import { ALL_KINDS, humanStep, UnitNodeDecl } from '../node-decl';
|
|
12
12
|
import type { UnitState } from '../../harness/next-step';
|
|
13
13
|
|
|
14
14
|
/** The finding codes worth naming in a next step — each maps to a specific repair. */
|
|
@@ -66,6 +66,7 @@ export const UNIT_AUDIT_NODES: readonly UnitNodeDecl[] = [
|
|
|
66
66
|
command: `sungen audit --screen ${s.unit} --accept-viewpoint`,
|
|
67
67
|
because: 'test-viewpoint.md changed since the accepted baseline, so the ledger and traceability axes are not evidence until a QA confirms the new declaration. A decision, not a step: an agent presents it and does not run it — accepting the yardstick you just wrote makes both axes read 100% by construction',
|
|
68
68
|
}),
|
|
69
|
+
human: humanStep('accept-viewpoint'),
|
|
69
70
|
},
|
|
70
71
|
{
|
|
71
72
|
id: 'unit.repair-findings', title: 'Repair the named findings', actor: 'ai', granularity: 'unit',
|
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* A free / impl:code scenario only its TRACE cannot prove — unproven, inconclusive, opaque — is not a
|
|
17
17
|
* unit decision: the case nodes say what proves it (test-case-contract-nodes.ts, phase 09).
|
|
18
18
|
*/
|
|
19
|
-
import { ALL_KINDS, StepCore, UnitNodeDecl, unitKindDir } from '../node-decl';
|
|
19
|
+
import { ALL_KINDS, humanStep, StepCore, UnitNodeDecl, unitKindDir } from '../node-decl';
|
|
20
20
|
import type { UnitState } from '../../harness/next-step';
|
|
21
|
+
import type { HumanActionKind } from '../human-action';
|
|
21
22
|
|
|
22
23
|
const HUMAN_ONLY = 'A QA decision on a terminal: an agent presents it and never approves its own edit';
|
|
23
24
|
const approveCmd = (s: UnitState): string => `sungen contract approve --screen ${s.unit}`;
|
|
@@ -50,6 +51,10 @@ function contractStep(s: UnitState): StepCore {
|
|
|
50
51
|
return { kind: 'decide', command: approveCmd(s), because: `${c.review} approved scenario(s) changed since QA approved them (changed, not weaker) — review blocks \`sungen delivery\` until QA re-approves. ${HUMAN_ONLY}` };
|
|
51
52
|
}
|
|
52
53
|
|
|
54
|
+
/** What the human does for the step contractStep offered: approve, commit / restore through review, or read the verdict. */
|
|
55
|
+
const contractActionKind = (command: string): HumanActionKind =>
|
|
56
|
+
command.startsWith('sungen contract approve') ? 'contract-approve' : command.startsWith('sungen contract check') ? 'contract-review' : 'contract-commit';
|
|
57
|
+
|
|
53
58
|
const CONTRACT_RULES = ['contract.hash-changed', 'contract.oracle-weakened', 'contract.target-changed', 'contract.assertion-lost', 'contract.lock-uncommitted'];
|
|
54
59
|
/** The verdict is a pass and it was built: nothing blocks, nothing to repair. */
|
|
55
60
|
const verdictClean = (s: UnitState): boolean => !s.contract!.error && s.contract!.verdict === 'pass';
|
|
@@ -67,6 +72,11 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
|
|
|
67
72
|
active: (s) => Boolean(s.contract),
|
|
68
73
|
satisfied: nothingToDecide,
|
|
69
74
|
step: contractStep,
|
|
75
|
+
human: (_s, step) => {
|
|
76
|
+
const kind = contractActionKind(step.command);
|
|
77
|
+
// A lock repair is a commit (or `git checkout --`) after review — prose, never a sungen command to copy.
|
|
78
|
+
return [{ command: step.command, reason: step.because, kind, ...(kind === 'contract-commit' ? { instruction: true as const } : {}) }];
|
|
79
|
+
},
|
|
70
80
|
// A hard verdict must not wait behind a re-audit (#940): the audit's PASS says nothing about it —
|
|
71
81
|
// a broken approved check, or a lock that is not what QA approved and committed (contract.lock-
|
|
72
82
|
// uncommitted, hard in `contract check`; the step names that repair first). What stays behind the
|
|
@@ -85,5 +95,6 @@ export const UNIT_CONTRACT_NODES: readonly UnitNodeDecl[] = [
|
|
|
85
95
|
command: approveCmd(s),
|
|
86
96
|
because: `${s.contract!.proposed} scenario(s) have no approved contract yet — approving locks the checks QA reviewed, so a later edit that weakens one (a healer's included) fails \`sungen contract check\`. ${HUMAN_ONLY}; proposed scenarios do not block`,
|
|
87
97
|
}),
|
|
98
|
+
human: humanStep('contract-approve'),
|
|
88
99
|
},
|
|
89
100
|
];
|
|
@@ -9,6 +9,7 @@ import { parse as parseYaml } from 'yaml';
|
|
|
9
9
|
import { featureFilesFor } from '../harness/unit-paths';
|
|
10
10
|
import { readTextFile } from '../harness/read-text';
|
|
11
11
|
import { loadPlaywrightReport } from '../exporters/playwright-report-parser';
|
|
12
|
+
import { isDraftDeliverable } from '../exporters/csv-exporter';
|
|
12
13
|
|
|
13
14
|
/** `<f>-test-result[.<os>][.<env>].json` — a mobile run writes the OS into the name, an env run the env (#868). */
|
|
14
15
|
export const RESULT_FILE = /-test-result(?:\.[^.]+)*\.json$/;
|
|
@@ -72,6 +73,7 @@ export function countFailedResults(resultFiles: string[]): number {
|
|
|
72
73
|
|
|
73
74
|
export function hasDeliverableFor(projectRoot: string, slug: string): boolean {
|
|
74
75
|
try {
|
|
75
|
-
|
|
76
|
+
// A `--preview` DRAFT is not a deliverable (#965).
|
|
77
|
+
return fs.readdirSync(path.join(projectRoot, 'qa', 'deliverables')).some((f) => f.startsWith(`${slug}-`) && !isDraftDeliverable(f));
|
|
76
78
|
} catch { return false; } // nothing exported yet
|
|
77
79
|
}
|