@sun-asterisk/sungen 3.2.24-beta.2 → 3.2.24-beta.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +5 -14
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/capability.d.ts.map +1 -1
  5. package/dist/cli/commands/capability.js +11 -12
  6. package/dist/cli/commands/capability.js.map +1 -1
  7. package/dist/cli/commands/challenge.d.ts.map +1 -1
  8. package/dist/cli/commands/challenge.js +2 -10
  9. package/dist/cli/commands/challenge.js.map +1 -1
  10. package/dist/cli/commands/depth-lint.d.ts.map +1 -1
  11. package/dist/cli/commands/depth-lint.js +2 -12
  12. package/dist/cli/commands/depth-lint.js.map +1 -1
  13. package/dist/cli/commands/gate.d.ts.map +1 -1
  14. package/dist/cli/commands/gate.js +2 -47
  15. package/dist/cli/commands/gate.js.map +1 -1
  16. package/dist/cli/commands/journey.d.ts.map +1 -1
  17. package/dist/cli/commands/journey.js +2 -12
  18. package/dist/cli/commands/journey.js.map +1 -1
  19. package/dist/cli/commands/manifest.d.ts.map +1 -1
  20. package/dist/cli/commands/manifest.js +2 -10
  21. package/dist/cli/commands/manifest.js.map +1 -1
  22. package/dist/cli/commands/next.d.ts +14 -0
  23. package/dist/cli/commands/next.d.ts.map +1 -0
  24. package/dist/cli/commands/next.js +130 -0
  25. package/dist/cli/commands/next.js.map +1 -0
  26. package/dist/cli/index.js +34 -0
  27. package/dist/cli/index.js.map +1 -1
  28. package/dist/cli/resolve-unit.d.ts +22 -0
  29. package/dist/cli/resolve-unit.d.ts.map +1 -0
  30. package/dist/cli/resolve-unit.js +101 -0
  31. package/dist/cli/resolve-unit.js.map +1 -0
  32. package/dist/harness/audit.d.ts.map +1 -1
  33. package/dist/harness/audit.js +39 -2
  34. package/dist/harness/audit.js.map +1 -1
  35. package/dist/harness/capability-plan.d.ts +12 -1
  36. package/dist/harness/capability-plan.d.ts.map +1 -1
  37. package/dist/harness/capability-plan.js +16 -2
  38. package/dist/harness/capability-plan.js.map +1 -1
  39. package/dist/harness/capability.d.ts +12 -0
  40. package/dist/harness/capability.d.ts.map +1 -1
  41. package/dist/harness/capability.js +16 -0
  42. package/dist/harness/capability.js.map +1 -1
  43. package/dist/harness/catalog/drivers.yaml +5 -0
  44. package/dist/harness/flow-contract.d.ts +16 -1
  45. package/dist/harness/flow-contract.d.ts.map +1 -1
  46. package/dist/harness/flow-contract.js +8 -1
  47. package/dist/harness/flow-contract.js.map +1 -1
  48. package/dist/harness/next-step.d.ts +40 -0
  49. package/dist/harness/next-step.d.ts.map +1 -0
  50. package/dist/harness/next-step.js +242 -0
  51. package/dist/harness/next-step.js.map +1 -0
  52. package/dist/harness/sensors.d.ts.map +1 -1
  53. package/dist/harness/sensors.js +13 -0
  54. package/dist/harness/sensors.js.map +1 -1
  55. package/dist/harness/viewpoint-ledger.d.ts +35 -0
  56. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  57. package/dist/harness/viewpoint-ledger.js +40 -0
  58. package/dist/harness/viewpoint-ledger.js.map +1 -1
  59. package/dist/orchestrator/templates/ai-src/commands/create-test.md +14 -6
  60. package/dist/orchestrator/templates/ai-src/commands/run-test.md +6 -0
  61. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +26 -0
  62. package/package.json +3 -3
  63. package/src/cli/commands/audit.ts +5 -12
  64. package/src/cli/commands/capability.ts +11 -10
  65. package/src/cli/commands/challenge.ts +2 -8
  66. package/src/cli/commands/depth-lint.ts +2 -10
  67. package/src/cli/commands/gate.ts +2 -10
  68. package/src/cli/commands/journey.ts +2 -10
  69. package/src/cli/commands/manifest.ts +2 -8
  70. package/src/cli/commands/next.ts +96 -0
  71. package/src/cli/index.ts +31 -0
  72. package/src/cli/resolve-unit.ts +72 -0
  73. package/src/harness/audit.ts +39 -3
  74. package/src/harness/capability-plan.ts +23 -3
  75. package/src/harness/capability.ts +22 -0
  76. package/src/harness/catalog/drivers.yaml +5 -0
  77. package/src/harness/flow-contract.ts +24 -3
  78. package/src/harness/next-step.ts +237 -0
  79. package/src/harness/sensors.ts +13 -0
  80. package/src/harness/viewpoint-ledger.ts +65 -0
  81. package/src/orchestrator/templates/ai-src/commands/create-test.md +14 -6
  82. package/src/orchestrator/templates/ai-src/commands/run-test.md +6 -0
  83. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +26 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.24-beta.2",
3
+ "version": "3.2.24-beta.4",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -39,8 +39,8 @@
39
39
  "@babel/types": "^7.28.5",
40
40
  "@cucumber/gherkin": "^37.0.0",
41
41
  "@cucumber/messages": "^31.0.0",
42
- "@sungen/driver-data-factory": "3.2.24-beta.2",
43
- "@sungen/driver-ui": "3.2.24-beta.2",
42
+ "@sungen/driver-data-factory": "3.2.24-beta.4",
43
+ "@sungen/driver-ui": "3.2.24-beta.4",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.2",
46
46
  "dotenv": "^17.2.3",
@@ -4,17 +4,8 @@ import * as fs from 'fs';
4
4
  import { runAudit, AuditReport, catalogIdFromScreenDir } from '../../harness/audit';
5
5
  import { reportSlug } from '../../harness/unit-paths';
6
6
  import { acceptViewpointBaseline } from '../../harness/viewpoint-baseline';
7
+ import { findUnitDir } from '../resolve-unit';
7
8
 
8
- function findScreenDir(name: string): string | null {
9
- // `name` may be a bare screen/flow/area, or an api unit id (`api/<area>`, `api/flows/<flow>`).
10
- const candidates = [
11
- path.join(process.cwd(), 'qa', 'screens', name),
12
- path.join(process.cwd(), 'qa', 'flows', name),
13
- path.join(process.cwd(), 'qa', 'api', name), // qa/api/<area> or qa/api/flows/<flow>
14
- ];
15
- for (const c of candidates) if (fs.existsSync(c)) return c;
16
- return null;
17
- }
18
9
 
19
10
  function bar(ratio: number, width = 20): string {
20
11
  const n = Math.round(ratio * width);
@@ -60,7 +51,9 @@ function render(r: AuditReport): void {
60
51
  // axis read as a contradiction.
61
52
  const inv = r.flowInventory;
62
53
  if (inv) {
63
- L(` flows: ${inv.covered.length}/${inv.covered.length + inv.uncovered.length} declared covered → flowCoverage ${(inv.ratio * 100).toFixed(0)}%`
54
+ const auto = inv.covered.filter((c) => c.automated).length;
55
+ L(` flows: ${inv.covered.length}/${inv.covered.length + inv.uncovered.length} declared covered, ${auto} with automated evidence`
56
+ + (inv.manualOnly.length ? ` (manual-only: ${inv.manualOnly.map((f) => f.id).join(', ')})` : '')
64
57
  + (inv.accounted.length ? ` (${inv.accounted.map((f) => `${f.id}=${f.status}`).join(', ')})` : ''));
65
58
  }
66
59
  L(` handoffs asserted: ${fq.handoffs.asserted}/${fq.handoffs.total}${fq.offGoal.length ? ` ⚠ off-goal scenarios: ${fq.offGoal.length} (${fq.offGoalCategories.join(', ')})` : ''}`);
@@ -143,7 +136,7 @@ export function registerAuditCommand(program: Command): void {
143
136
  try {
144
137
  const name = options.screen || options.api || options.area;
145
138
  if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
146
- const dir = findScreenDir(name);
139
+ const dir = findUnitDir(name);
147
140
  if (!dir) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
148
141
 
149
142
  const report = runAudit(dir, name);
@@ -9,6 +9,7 @@ import {
9
9
  import { buildPlan, MANUAL_REASONS } from '../../harness/capability-plan';
10
10
  import { adapterRegistry } from '../../generators/test-generator/adapters';
11
11
  import { emitMobileRuntime, installMobileDriver } from '../../orchestrator/mobile-runtime-scaffolder';
12
+ import { findUnitDir, resolveUnit } from '../resolve-unit';
12
13
 
13
14
  /** The running CLI's (core) version — opt-in drivers install in lockstep with it (not `@latest`). */
14
15
  function coreVersion(): string {
@@ -97,13 +98,6 @@ function verifyCapabilityDriver(pkg: string, cwd: string): void {
97
98
  }
98
99
  }
99
100
 
100
- function findScreenDir(name: string): string | null {
101
- const s = path.join(process.cwd(), 'qa', 'screens', name);
102
- if (fs.existsSync(s)) return s;
103
- const f = path.join(process.cwd(), 'qa', 'flows', name);
104
- if (fs.existsSync(f)) return f;
105
- return null;
106
- }
107
101
 
108
102
  export function registerCapabilityCommand(program: Command): void {
109
103
  const cap = program
@@ -143,9 +137,10 @@ export function registerCapabilityCommand(program: Command): void {
143
137
  .option('--json', 'Output the raw plan JSON')
144
138
  .action((o: { screen: string; json?: boolean }) => {
145
139
  try {
146
- const dir = findScreenDir(o.screen);
147
- if (!dir) throw new Error(`Screen/flow not found: qa/screens/${o.screen} or qa/flows/${o.screen}`);
148
- const plan = buildPlan(dir, o.screen);
140
+ const resolved = resolveUnit(o.screen);
141
+ if (!resolved) throw new Error(`Screen/flow not found: qa/screens/${o.screen} or qa/flows/${o.screen}`);
142
+ // The catalog unit id carries the unit KIND, which gates which drivers can serve it.
143
+ const plan = buildPlan(resolved.dir, o.screen, resolved.unitId);
149
144
 
150
145
  const outDir = path.join(process.cwd(), '.sungen', 'reports');
151
146
  fs.mkdirSync(outDir, { recursive: true });
@@ -170,6 +165,12 @@ export function registerCapabilityCommand(program: Command): void {
170
165
  L(` • ${r.count} @manual:${r.reason} → enable \`${r.driver}\` (${r.pkg}) → would unblock ${r.count}`);
171
166
  }
172
167
  }
168
+ if (plan.unavailable.length) {
169
+ L(' Cannot help here (driver does not support this unit kind — do NOT install):');
170
+ for (const r of plan.unavailable) {
171
+ L(` • ${r.count} @manual:${r.reason} → \`${r.driver}\` has no ${r.unitKind} support; automate the behaviour on the owning SCREEN unit instead`);
172
+ }
173
+ }
173
174
  if (plan.keep.length) {
174
175
  L(` Keep manual (judgment floor — do NOT automate): ${plan.keep.map((k) => `${k.code}×${k.count} (${MANUAL_REASONS[k.code].label})`).join(', ')}`);
175
176
  }
@@ -2,14 +2,8 @@ import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { buildChallenge, renderChallengeMarkdown } from '../../harness/challenge';
5
+ import { findUnitDir } from '../resolve-unit';
5
6
 
6
- function findScreenDir(name: string): string | null {
7
- const screen = path.join(process.cwd(), 'qa', 'screens', name);
8
- if (fs.existsSync(screen)) return screen;
9
- const flow = path.join(process.cwd(), 'qa', 'flows', name);
10
- if (fs.existsSync(flow)) return flow;
11
- return null;
12
- }
13
7
 
14
8
  export function registerChallengeCommand(program: Command): void {
15
9
  program
@@ -19,7 +13,7 @@ export function registerChallengeCommand(program: Command): void {
19
13
  .option('--json', 'Output the raw JSON report')
20
14
  .action((o) => {
21
15
  try {
22
- const dir = findScreenDir(o.screen);
16
+ const dir = findUnitDir(o.screen);
23
17
  if (!dir) throw new Error(`Screen/flow not found: qa/screens/${o.screen} or qa/flows/${o.screen}`);
24
18
  const report = buildChallenge(dir, o.screen);
25
19
 
@@ -3,16 +3,8 @@ import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { runDepthLint, renderDepthLint } from '../../harness/depth-lint';
5
5
  import { reportSlug } from '../../harness/unit-paths';
6
+ import { findUnitDir } from '../resolve-unit';
6
7
 
7
- function findScreenDir(name: string): string | null {
8
- const candidates = [
9
- path.join(process.cwd(), 'qa', 'screens', name),
10
- path.join(process.cwd(), 'qa', 'flows', name),
11
- path.join(process.cwd(), 'qa', 'api', name),
12
- ];
13
- for (const c of candidates) if (fs.existsSync(c)) return c;
14
- return null;
15
- }
16
8
 
17
9
  export function registerDepthLintCommand(program: Command): void {
18
10
  program
@@ -24,7 +16,7 @@ export function registerDepthLintCommand(program: Command): void {
24
16
  try {
25
17
  const name = options.screen;
26
18
  if (!name) throw new Error('Provide --screen <name>');
27
- const dir = findScreenDir(name);
19
+ const dir = findUnitDir(name);
28
20
  if (!dir) throw new Error(`Not found: qa/screens/${name} or qa/flows/${name}`);
29
21
 
30
22
  const report = runDepthLint(dir, name);
@@ -2,16 +2,8 @@ import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { runGate, renderGate, GatePhase } from '../../harness/journey';
5
+ import { findUnitDir } from '../resolve-unit';
5
6
 
6
- function findScreenDir(name: string): string | null {
7
- const candidates = [
8
- path.join(process.cwd(), 'qa', 'screens', name),
9
- path.join(process.cwd(), 'qa', 'flows', name),
10
- path.join(process.cwd(), 'qa', 'api', name),
11
- ];
12
- for (const c of candidates) if (fs.existsSync(c)) return c;
13
- return null;
14
- }
15
7
 
16
8
  const PHASES: GatePhase[] = ['create', 'run', 'deliver'];
17
9
 
@@ -28,7 +20,7 @@ export function registerGateCommand(program: Command): void {
28
20
  if (!name) throw new Error('Provide --screen <name>');
29
21
  const phase = options.phase as GatePhase;
30
22
  if (!PHASES.includes(phase)) throw new Error(`Provide --phase <${PHASES.join('|')}>`);
31
- if (!findScreenDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
23
+ if (!findUnitDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
32
24
 
33
25
  const verdict = runGate(process.cwd(), name, phase);
34
26
  if (options.json) console.log(JSON.stringify(verdict, null, 2));
@@ -3,16 +3,8 @@ import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { runJourney, waive, signoff, renderJourneyBoard, rollupJourney, renderRollupBoard } from '../../harness/journey';
5
5
  import { reportSlug } from '../../harness/unit-paths';
6
+ import { findUnitDir } from '../resolve-unit';
6
7
 
7
- function findScreenDir(name: string): string | null {
8
- const candidates = [
9
- path.join(process.cwd(), 'qa', 'screens', name),
10
- path.join(process.cwd(), 'qa', 'flows', name),
11
- path.join(process.cwd(), 'qa', 'api', name),
12
- ];
13
- for (const c of candidates) if (fs.existsSync(c)) return c;
14
- return null;
15
- }
16
8
 
17
9
  export function registerJourneyCommand(program: Command): void {
18
10
  program
@@ -41,7 +33,7 @@ export function registerJourneyCommand(program: Command): void {
41
33
 
42
34
  const name = options.screen;
43
35
  if (!name) throw new Error('Provide --screen <name> (or --all for the roll-up)');
44
- if (!findScreenDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
36
+ if (!findUnitDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
45
37
 
46
38
  const report = options.waive
47
39
  ? waive(process.cwd(), name, options.waive, options.reason || '')
@@ -2,14 +2,8 @@ import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
4
  import { buildManifest, diffManifest, loadManifest, saveManifest } from '../../harness/manifest';
5
+ import { findUnitDir } from '../resolve-unit';
5
6
 
6
- function findScreenDir(name: string): string | null {
7
- for (const p of ['screens', 'flows', 'api']) {
8
- const d = path.join(process.cwd(), 'qa', p, name);
9
- if (fs.existsSync(d)) return d;
10
- }
11
- return null;
12
- }
13
7
 
14
8
  export function registerManifestCommand(program: Command): void {
15
9
  program
@@ -24,7 +18,7 @@ export function registerManifestCommand(program: Command): void {
24
18
  try {
25
19
  const name = options.screen || options.api || options.area;
26
20
  if (!name) throw new Error('Provide --screen <name> (or --api <area>)');
27
- const dir = findScreenDir(name);
21
+ const dir = findUnitDir(name);
28
22
  if (!dir) throw new Error(`Not found: qa/screens|flows|api/${name}`);
29
23
 
30
24
  if (options.diff) {
@@ -0,0 +1,96 @@
1
+ /**
2
+ * `sungen next [unit]` — the hand-back every command ends with.
3
+ *
4
+ * The next step is derived from what the project HOLDS, not from which command just ran, so a
5
+ * session cannot lose the thread by finishing somewhere that has no footer of its own (#597).
6
+ */
7
+ import { Command } from 'commander';
8
+ import * as fs from 'fs';
9
+ import * as path from 'path';
10
+ import { readUnitState, UnitState } from '../../harness/next-step';
11
+ import { resolveUnit, resolveUnitCandidates, AmbiguousUnitError } from '../resolve-unit';
12
+
13
+ /** Every unit the project holds, so `sungen next` with no argument still answers. */
14
+ function allUnits(cwd: string): Array<{ name: string; kind: UnitState['kind']; dir: string }> {
15
+ const out: Array<{ name: string; kind: UnitState['kind']; dir: string }> = [];
16
+ for (const [dir, kind] of [['screens', 'screen'], ['flows', 'flow'], ['api', 'api']] as const) {
17
+ const base = path.join(cwd, 'qa', dir);
18
+ try {
19
+ for (const e of fs.readdirSync(base, { withFileTypes: true })) {
20
+ if (e.isDirectory()) out.push({ name: e.name, kind, dir: path.join(base, e.name) });
21
+ }
22
+ } catch { /* the project may not use this unit kind */ }
23
+ }
24
+ return out;
25
+ }
26
+
27
+ function render(s: UnitState): void {
28
+ const L = console.log;
29
+ L('');
30
+ L(`━━━ Next steps: ${s.unit} (${s.kind}) ━━━`);
31
+ L('');
32
+ const held = [
33
+ s.hasSpec && 'spec', s.hasViewpoint && 'viewpoint',
34
+ s.kind === 'flow' && s.hasContract && (s.hasFlowInventory ? 'contract+inventory' : 'contract (phases only)'),
35
+ s.scenarioCount > 0 && `${s.scenarioCount} scenarios`,
36
+ s.hasSelectors && (s.selectorsArePlaceholder ? 'selectors (placeholder)' : 'selectors'),
37
+ s.compiled && 'compiled', s.hasResults && 'results', s.hasDeliverable && 'deliverable',
38
+ ].filter(Boolean);
39
+ L(` state: ${held.length ? held.join(' · ') : '(nothing yet)'}`);
40
+ if (s.audit) L(` audit: ${s.audit.overall}/10 [${s.audit.gateStatus}] · ${s.audit.findings.length} finding(s)`);
41
+ L('');
42
+ for (const st of s.steps) {
43
+ const mark = st.kind === 'blocked' ? '⛔' : st.kind === 'optional' ? '○' : '→';
44
+ L(` ${mark} ${st.command}`);
45
+ L(` ${st.because}`);
46
+ }
47
+ L('');
48
+ }
49
+
50
+ export function registerNextCommand(program: Command): void {
51
+ program
52
+ .command('next [unit]')
53
+ .description('What to do next for a unit (or every unit), derived from the project state')
54
+ .option('--json', 'Machine-readable output')
55
+ .action((unit: string | undefined, o: { json?: boolean }) => {
56
+ try {
57
+ const cwd = process.cwd();
58
+ let states: UnitState[];
59
+ if (unit) {
60
+ const resolved = resolveUnit(unit, cwd);
61
+ if (!resolved) {
62
+ console.error(`Error: no unit named "${unit}" under qa/screens, qa/flows or qa/api.`);
63
+ process.exit(1);
64
+ }
65
+ states = [readUnitState(cwd, resolved.dir, unit, resolved.kind)];
66
+ } else {
67
+ const units = allUnits(cwd);
68
+ if (units.length === 0) {
69
+ console.error('Error: this project holds no units yet — run `sungen add --screen <name> --path <url>` or `sungen add-flow --flow <name> --path <url>`.');
70
+ process.exit(1);
71
+ }
72
+ // A name in two places is ambiguous; report it rather than answering for one of them.
73
+ states = units.map((u) => {
74
+ if (resolveUnitCandidates(u.name, cwd).length > 1) throw new AmbiguousUnitError(u.name, resolveUnitCandidates(u.name, cwd));
75
+ return readUnitState(cwd, u.dir, u.name, u.kind);
76
+ });
77
+ }
78
+ if (o.json) {
79
+ console.log(JSON.stringify(states.length === 1 ? states[0] : states, null, 2));
80
+ return;
81
+ }
82
+ for (const s of states) render(s);
83
+ } catch (e) {
84
+ console.error(`Error: ${e instanceof Error ? e.message : e}`);
85
+ process.exit(1);
86
+ }
87
+ });
88
+ }
89
+
90
+ /**
91
+ * The one-line hand-back every command prints last. Kept deliberately small: it points at the
92
+ * resolver instead of restating a next step, so no command can drift from the real state.
93
+ */
94
+ export function printHandBack(unit?: string): void {
95
+ console.log(`Next: sungen next${unit ? ` ${unit}` : ''}`);
96
+ }
package/src/cli/index.ts CHANGED
@@ -27,6 +27,7 @@ import { registerFeedbackCommand } from './commands/feedback';
27
27
  import { registerQaFeedbackCommand } from './commands/qa-feedback';
28
28
  import { registerScriptCheckCommand } from './commands/script-check';
29
29
  import { registerTraceCommand } from './commands/trace';
30
+ import { registerNextCommand } from './commands/next';
30
31
  import { registerChallengeCommand } from './commands/challenge';
31
32
  import { registerBlindspotCommand } from './commands/blindspot';
32
33
  import { registerCapabilityCommand } from './commands/capability';
@@ -74,6 +75,7 @@ async function main() {
74
75
  registerQaFeedbackCommand(program);
75
76
  registerScriptCheckCommand(program);
76
77
  registerTraceCommand(program);
78
+ registerNextCommand(program);
77
79
  registerChallengeCommand(program);
78
80
  registerBlindspotCommand(program);
79
81
  registerCapabilityCommand(program);
@@ -96,6 +98,35 @@ async function main() {
96
98
  // say so (stderr, so `--json` output stays parseable).
97
99
  program.hook('preAction', () => { warnAssetsDrift(process.cwd()); });
98
100
 
101
+ // #597 — the hand-back, once, for EVERY command. It used to be hardcoded per command and only
102
+ // two of twenty-nine printed one, so a run that finished anywhere else finished in silence:
103
+ // `capability add` ended with a blank line and the session stopped mid-workflow. Adding a
104
+ // footer to the other twenty-seven would repeat the mistake in bulk.
105
+ //
106
+ // It hangs off `process.on('exit')` rather than a postAction hook because several commands
107
+ // exit with a MEANINGFUL code (`depth-lint` returns 2 when there are deepen candidates) and
108
+ // `process.exit` skips commander's hooks entirely — a postAction version printed for `audit`
109
+ // and silently not for `depth-lint`, which is the same class of gap all over again.
110
+ //
111
+ // It points at `sungen next` instead of restating a step, so it can never drift from the real
112
+ // project state. Skipped on a hard error (code 1): there the error message is the actionable
113
+ // thing, and burying it under a footer helps nobody. `--json` keeps stdout parseable.
114
+ let handBack: { unit?: string; json: boolean } | null = null;
115
+ program.hook('preAction', (_thisCommand, actionCommand) => {
116
+ if (actionCommand.name() === 'next') return; // `next` IS the answer
117
+ const opts = actionCommand.opts() as Record<string, unknown>;
118
+ const unit = [opts.screen, opts.flow, opts.area].find((v) => typeof v === 'string' && v)
119
+ ?? (typeof actionCommand.args?.[0] === 'string' ? actionCommand.args[0] : undefined);
120
+ handBack = { unit: unit as string | undefined, json: opts.json === true };
121
+ });
122
+ process.on('exit', (code) => {
123
+ if (!handBack || code === 1) return;
124
+ const line = `\nNext: sungen next${handBack.unit ? ` ${handBack.unit}` : ''}`;
125
+ // stderr under --json so the hand-back never lands in the parsed payload.
126
+ if (handBack.json) process.stderr.write(`${line}\n`);
127
+ else process.stdout.write(`${line}\n`);
128
+ });
129
+
99
130
  await program.parseAsync(process.argv);
100
131
  }
101
132
 
@@ -0,0 +1,72 @@
1
+ /**
2
+ * One resolver for "which unit does this name mean?", shared by every command.
3
+ *
4
+ * Seven commands each had their own copy, and all seven searched `qa/screens/` FIRST. So a
5
+ * directory at `qa/screens/<name>/` SHADOWED a real flow of the same name — and the failure was
6
+ * silent, not loud: `sungen audit` resolved to the phantom screen, found no feature file, and
7
+ * reported 0 scenarios with no contract and no scored axis. A run that dropped the entire quality
8
+ * gate looked like a suite with nothing in it.
9
+ *
10
+ * That shape is not hypothetical: the mock driver resolves its catalog from
11
+ * `qa/screens/<unit>/mock/mocks.yaml` only, so making a mock work for a flow means creating
12
+ * exactly that phantom directory (#597).
13
+ *
14
+ * A name resolving to two units is ambiguous, and ambiguity is reported rather than resolved by
15
+ * search order.
16
+ */
17
+ import * as fs from 'fs';
18
+ import * as path from 'path';
19
+
20
+ export type UnitKind = 'screen' | 'flow' | 'api';
21
+
22
+ const KINDS: Array<{ kind: UnitKind; dir: string }> = [
23
+ { kind: 'screen', dir: 'screens' },
24
+ { kind: 'flow', dir: 'flows' },
25
+ { kind: 'api', dir: 'api' }, // qa/api/<area> or qa/api/flows/<flow>
26
+ ];
27
+
28
+ export interface ResolvedUnit {
29
+ dir: string;
30
+ kind: UnitKind;
31
+ /** Catalog unit id relative to qa/: `<screen>` · `flows/<f>` · `api/<a>` · `api/flows/<f>`. */
32
+ unitId: string;
33
+ }
34
+
35
+ /** Every unit directory this name matches, in `screens, flows, api` order. */
36
+ export function resolveUnitCandidates(name: string, cwd = process.cwd()): ResolvedUnit[] {
37
+ const out: ResolvedUnit[] = [];
38
+ for (const { kind, dir } of KINDS) {
39
+ const p = path.join(cwd, 'qa', dir, name);
40
+ if (!fs.existsSync(p)) continue;
41
+ out.push({ dir: p, kind, unitId: kind === 'screen' ? name : `${dir}/${name}` });
42
+ }
43
+ return out;
44
+ }
45
+
46
+ export class AmbiguousUnitError extends Error {
47
+ constructor(public readonly name: string, public readonly candidates: ResolvedUnit[]) {
48
+ super(
49
+ `"${name}" names ${candidates.length} units: ${candidates.map((c) => `qa/${c.kind === 'screen' ? 'screens/' : ''}${c.unitId}`).join(' and ')}.\n`
50
+ + ' Resolving this by search order silently picks one and drops the other unit\'s feature,\n'
51
+ + ' contract and scored axes — a report with 0 scenarios rather than an error. Rename one,\n'
52
+ + ' or remove the directory that should not exist (a stray `qa/screens/<flow-name>/`\n'
53
+ + ' created to satisfy a screen-only driver is the usual cause).',
54
+ );
55
+ this.name = 'AmbiguousUnitError';
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The single unit this name means. Throws `AmbiguousUnitError` when several match; returns null
61
+ * when none do (callers report "unit not found" with their own guidance).
62
+ */
63
+ export function resolveUnit(name: string, cwd = process.cwd()): ResolvedUnit | null {
64
+ const found = resolveUnitCandidates(name, cwd);
65
+ if (found.length > 1) throw new AmbiguousUnitError(name, found);
66
+ return found[0] ?? null;
67
+ }
68
+
69
+ /** Back-compat shape for the callers that only want the directory. */
70
+ export function findUnitDir(name: string, cwd = process.cwd()): string | null {
71
+ return resolveUnit(name, cwd)?.dir ?? null;
72
+ }
@@ -25,7 +25,7 @@ import { getProvenance, Provenance } from './provenance';
25
25
  import { specCoverage, SpecCoverageResult, parseSpecClauses, restatedRequirementSources } from './spec-coverage';
26
26
  import { downstreamScope, manualOracle, readText, DownstreamResult, ManualOracleResult,
27
27
  negativeSideEffect, sourceBacked, crossArtifactOwnership, isolationRisk, serialCascadeRisk } from './quality-gates';
28
- import { viewpointLedger, parseViewpointItems, browserGestureSubstitutions, LedgerResult } from './viewpoint-ledger';
28
+ import { viewpointLedger, parseViewpointItems, browserGestureSubstitutions, viewpointIntegrity, LedgerResult } from './viewpoint-ledger';
29
29
  import { capabilityRegistry } from '../capabilities/registry';
30
30
  import { discoverAndRegisterCapabilities } from '../capabilities/discover';
31
31
  import { contextRouter } from '../capabilities/context-router';
@@ -315,7 +315,11 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
315
315
  // "weakest: flowCoverage 100% — fix it to lift the number", which is not fixable advice (#595).
316
316
  const axisDefs: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean; byDesign?: boolean }> = [
317
317
  flowScored
318
- ? { key: 'flowCoverage', value: inventory ? Math.min(inventory.ratio, flowQ.phaseRatio) : flowQ.phaseRatio, weight: 0.22, applicable: true, critical: true }
318
+ // A declared flow claimed only by @manual scenarios is ACCOUNTED FOR but nothing runs, so
319
+ // it cannot count the same as an automated one — `covered` was collapsing three very
320
+ // different states into one number (#599). Half credit: the design work is real, the
321
+ // regression evidence is not.
322
+ ? { key: 'flowCoverage', value: inventory ? Math.min((inventory.ratio + inventory.automatedRatio) / 2, flowQ.phaseRatio) : flowQ.phaseRatio, weight: 0.22, applicable: true, critical: true }
319
323
  : { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
320
324
  { key: 'specFR', value: specRatio, weight: 0.15, applicable: spec.hasSpec && spec.frTotal > 0, critical: true },
321
325
  { key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0 && !viewpointMoved, critical: true },
@@ -416,6 +420,27 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
416
420
  if (!flowQ.contract!.minimalGuarantee) {
417
421
  findings.push('FLOW-GUARANTEE-MISSING: the contract declares no `minimalGuarantee:` — the postcondition that must hold in EVERY flow, including failure ("no second account for the same email", "no verification mail on a rejected submit"). Exception/guard scenarios have nothing to assert against without it; add it (and `successGuarantee:` for the goal-reached side).');
418
422
  }
423
+ // #599 — integrity of the DECLARATION itself: three ways it can be internally wrong while
424
+ // every coverage number still reads well.
425
+ {
426
+ const testDataDir = path.join(screenDir, 'test-data');
427
+ const extra: Array<{ label: string; text: string }> = [];
428
+ try {
429
+ for (const f of fs.readdirSync(testDataDir).filter((x) => x.endsWith('.yaml'))) {
430
+ extra.push({ label: `test-data/${f}`, text: readTextFile(path.join(testDataDir, f)) ?? '' });
431
+ }
432
+ } catch { /* api units and fresh scaffolds carry none */ }
433
+ const vi = viewpointIntegrity(viewpointPath, scenarios.map((sc) => sc.vpId ?? '').filter(Boolean), extra);
434
+ for (const d of vi.duplicateIds.slice(0, 4)) {
435
+ findings.push(`VIEWPOINT-ID-REUSED: "${d.id}" labels ${d.count} different claims (${d.texts.map((t) => `"${t.slice(0, 44)}…"`).join(' · ')}). The ledger then counts ${d.count} items while traceability sees ONE id, so both the coverage arithmetic and "which test proves this?" go wrong — and neither number looks suspicious. Give each atomic claim its own id; several ids may share a flow.`);
436
+ }
437
+ for (const d of vi.danglingRefs.slice(0, 5)) {
438
+ findings.push(`TRACE-REF-DANGLING: ${d.where} cites "${d.ref}", which no scenario carries. Nothing breaks at runtime, so a wrong pointer survives every refactor and quietly misleads the next maintainer. Fix the reference, or the scenario id it was meant to name.`);
439
+ }
440
+ if (vi.uniformPriority) {
441
+ findings.push(`VIEWPOINT-PRIORITY-UNIFORM: all ${vi.uniformPriority.rows} rows of the Priority Viewpoints table are "${vi.uniformPriority.levels[0]}". That is a formality, not a priority: nothing can be selected for a smoke run and the release conversation has no input. Rank by business impact and risk — the basic flow and the data-integrity guards are not the same bet as an exit path a screen suite already covers.`);
442
+ }
443
+ }
419
444
  // #595 — a viewpoint item naming a browser-level gesture, answered with an in-app control.
420
445
  for (const g of browserGestureSubstitutions(viewpointPath, scenarios).slice(0, 4)) {
421
446
  findings.push(`VIEWPOINT-GESTURE-SUBSTITUTED: the viewpoint asks for "${g.gesture}" ("${g.item.slice(0, 80)}…") but the suite exercises an in-app control instead. They are different mechanisms — an app button runs the screen's own re-hydrate logic, browser history does not, and a defect that only shows on history navigation lives in exactly that gap. The step vocabulary has no reload/back step (#593), so defer it with \`@manual\` naming the gesture rather than substituting silently.`);
@@ -436,6 +461,9 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
436
461
  for (const f of inventory.uncovered) {
437
462
  findings.push(`FLOW-UNCOVERED: declared flow ${f.id}${f.branchFrom ? ` (branches from ${f.branchFrom})` : ''} has status \`covered\` but no scenario carries its id — write it, or change its status to \`deferred\` / \`pending-clarification\` / \`out-of-scope\` WITH a reason. Silence is the one option the inventory removes.`);
438
463
  }
464
+ if (inventory.manualOnly.length > 0) {
465
+ findings.push(`FLOW-MANUAL-ONLY: ${inventory.manualOnly.length} declared flow(s) are claimed only by @manual scenarios — ${inventory.manualOnly.map((f) => f.id).join(', ')}. They are accounted for, but NOTHING RUNS for them: a regression run would not notice if any of these broke. \`status: covered\` says the flow was thought about; it does not say the suite can catch a regression in it, so flowCoverage credits them at half. Either automate them (check the capability plan for a driver that would) or say plainly in the delivery that this flow's evidence is a manual procedure.`);
466
+ }
439
467
  for (const u of inventory.undeclared.slice(0, 6)) {
440
468
  findings.push(`FLOW-UNDECLARED: "${u.scenario}" claims flow id ${u.id}, which the contract's inventory does not declare — a phase id invented for one scenario inflates the flow count without adding branch coverage. Either declare ${u.id} as a real flow (branch point + own outcome), or fold the scenario into the flow it actually belongs to.`);
441
469
  }
@@ -586,11 +614,19 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
586
614
  // TQ-10 — surface the Capability Planner recommendation (recommend-only; never installs). Silenced
587
615
  // by `capability_suggestions: off` in qa/context.md. Reuses the planner (trustworthy after TQ-9).
588
616
  if (intent.capabilitySuggestions) {
589
- const plan = buildPlan(screenDir, screenName);
617
+ // The catalog unit id (`flows/<f>`, `api/<a>`) carries the unit KIND, which gates which
618
+ // drivers can serve it at all.
619
+ const plan = buildPlan(screenDir, screenName, catalogScreenName);
590
620
  if (plan.recommendations.length) {
591
621
  const recs = plan.recommendations.map((r) => `\`sungen capability add ${r.driver}\` (automates ${r.count})`).join(' · ');
592
622
  findings.push(`CAPABILITY-SUGGESTION: ${plan.capabilityManual} @manual scenario(s) are capability-manual (a driver could automate them) — ${recs}. Recommend-only: nothing is installed automatically; the ${plan.judgmentManual} judgment-manual (M6/M8/M9) correctly stay manual.`);
593
623
  }
624
+ // Named, not dropped: without this the scenarios read as "nothing could help", and the
625
+ // operator's only clue was an install that dead-ends (#597).
626
+ if (plan.unavailable.length) {
627
+ const un = plan.unavailable.map((r) => `${r.driver} (would automate ${r.count})`).join(' · ');
628
+ findings.push(`CAPABILITY-UNAVAILABLE: ${un} — ${plan.unavailable.length === 1 ? 'that driver does' : 'those drivers do'} not support a ${plan.unavailable[0].unitKind} unit, so ${plan.unavailable.length === 1 ? 'it' : 'they'} cannot automate these scenarios here. Do NOT install: the scenarios stay @manual until the driver gains ${plan.unavailable[0].unitKind} support. Automating the same behaviour on the owning SCREEN unit is the available route today.`);
629
+ }
594
630
  }
595
631
  // TQ-11b — automation-ready (pending capability): @requires:<cap> scenarios whose cap isn't enabled.
596
632
  // They are NOT manual (real steps, compiled the moment the cap is added) — surface them distinctly.
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import * as fs from 'fs';
10
10
  import * as path from 'path';
11
- import { loadDriverCatalog } from './capability';
11
+ import { loadDriverCatalog, unitKindOf, driverServesUnit } from './capability';
12
12
  import { readTextFile } from './read-text';
13
13
  import { featureFilesFor } from './unit-paths';
14
14
 
@@ -190,12 +190,21 @@ export interface CapabilityPlan {
190
190
  crossScreen: number; // automatable via a flow — not a single-screen driver gap
191
191
  capabilityManualPct: number;
192
192
  recommendations: { driver: string; pkg: string; reason: string; count: number; scenarios: string[] }[];
193
+ /**
194
+ * Drivers that WOULD have been recommended but cannot serve this unit kind. Surfaced, never
195
+ * dropped: the scenarios stay manual for a reason the operator needs to know, and a silent
196
+ * omission would read as "nothing could help here" (#597).
197
+ */
198
+ unavailable: { driver: string; reason: string; count: number; unitKind: string }[];
193
199
  keep: { code: string; count: number }[];
194
200
  }
195
201
 
196
- export function buildPlan(screenDir: string, screenName: string): CapabilityPlan {
202
+ export function buildPlan(screenDir: string, screenName: string, unitId = screenName): CapabilityPlan {
197
203
  const scenarios = featureFilesFor(screenDir, screenName).flatMap(parseScenarios);
198
204
  const catalog = loadDriverCatalog();
205
+ // Recommending a driver that cannot serve this unit KIND costs an install, a dead end and a
206
+ // manual rollback — which is exactly what the mock driver (screen-only) did on a flow (#597).
207
+ const unitKind = unitKindOf(unitId);
199
208
 
200
209
  const modes: Record<string, number> = {};
201
210
  const byReason: Record<string, number> = {};
@@ -230,7 +239,17 @@ export function buildPlan(screenDir: string, screenName: string): CapabilityPlan
230
239
  }
231
240
  }
232
241
 
233
- const recommendations = [...recByDriver.entries()]
242
+ const servable = [...recByDriver.entries()].filter(([d]) => driverServesUnit(d, unitKind));
243
+ const unavailable = [...recByDriver.entries()]
244
+ .filter(([d]) => !driverServesUnit(d, unitKind))
245
+ .map(([driver, v]) => ({
246
+ driver,
247
+ reason: v.reason,
248
+ count: v.scenarios.length,
249
+ unitKind,
250
+ }))
251
+ .sort((a, b) => b.count - a.count);
252
+ const recommendations = servable
234
253
  .map(([driver, v]) => ({
235
254
  driver,
236
255
  pkg: catalog[driver]?.package || `@sungen/driver-${driver}`,
@@ -252,6 +271,7 @@ export function buildPlan(screenDir: string, screenName: string): CapabilityPlan
252
271
  crossScreen,
253
272
  capabilityManualPct: manualTotal ? Math.round((capabilityManual / manualTotal) * 100) : 0,
254
273
  recommendations,
274
+ unavailable,
255
275
  keep: Object.entries(keepCount).map(([code, count]) => ({ code, count })).sort((a, b) => b.count - a.count),
256
276
  };
257
277
  }
@@ -73,6 +73,28 @@ export interface DriverMeta {
73
73
  bundled?: boolean; // shipped as a core dependency — present without `capability add`
74
74
  capabilities: string[];
75
75
  unblocks?: string[];
76
+ /**
77
+ * Unit kinds this driver can actually serve. Absent = all kinds.
78
+ *
79
+ * A driver that only resolves its config from one unit shape must say so, or the capability
80
+ * planner recommends it for a unit it can never serve — which is what happened with the mock
81
+ * driver on a flow: install, dead end, manual rollback (#597).
82
+ */
83
+ units?: Array<'screen' | 'flow' | 'api'>;
84
+ }
85
+
86
+ /** The unit kind of a catalog unit id: `flows/<f>` → flow, `api/<a>` → api, else screen. */
87
+ export function unitKindOf(catalogUnitId: string): 'screen' | 'flow' | 'api' {
88
+ const seg = catalogUnitId.split('/')[0];
89
+ if (seg === 'api') return 'api';
90
+ if (seg === 'flows') return 'flow';
91
+ return 'screen';
92
+ }
93
+
94
+ /** Can this driver serve that unit kind? Unknown driver → assume yes (nothing to gate on). */
95
+ export function driverServesUnit(driverId: string, kind: 'screen' | 'flow' | 'api'): boolean {
96
+ const units = driverMeta(driverId)?.units;
97
+ return !units || units.includes(kind);
76
98
  }
77
99
 
78
100
  export function capabilitiesPath(cwd: string): string {
@@ -61,6 +61,11 @@ drivers:
61
61
  status: shipped
62
62
  capabilities: ["@mock", "@network"]
63
63
  unblocks: [M3]
64
+ # `units:` is the unit KINDS this driver can actually serve. Absent → all kinds.
65
+ # The mock driver resolves its catalog from qa/screens/<unit>/mock/mocks.yaml only
66
+ # (mockCatalogPath is hardcoded to that shape), so it cannot serve a flow. Recommending
67
+ # it for one sent an operator through an install, a dead end and a manual rollback (#597).
68
+ units: [screen]
64
69
  mail-file:
65
70
  kind: capability
66
71
  package: "@sungen/driver-mail-file"