@sun-asterisk/sungen 3.2.24-beta.1 → 3.2.24-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/audit.js +22 -16
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/capability.d.ts.map +1 -1
- package/dist/cli/commands/capability.js +11 -12
- package/dist/cli/commands/capability.js.map +1 -1
- package/dist/cli/commands/challenge.d.ts.map +1 -1
- package/dist/cli/commands/challenge.js +2 -10
- package/dist/cli/commands/challenge.js.map +1 -1
- package/dist/cli/commands/depth-lint.d.ts.map +1 -1
- package/dist/cli/commands/depth-lint.js +2 -12
- package/dist/cli/commands/depth-lint.js.map +1 -1
- package/dist/cli/commands/gate.d.ts.map +1 -1
- package/dist/cli/commands/gate.js +2 -47
- package/dist/cli/commands/gate.js.map +1 -1
- package/dist/cli/commands/journey.d.ts.map +1 -1
- package/dist/cli/commands/journey.js +2 -12
- package/dist/cli/commands/journey.js.map +1 -1
- package/dist/cli/commands/manifest.d.ts.map +1 -1
- package/dist/cli/commands/manifest.js +2 -10
- package/dist/cli/commands/manifest.js.map +1 -1
- package/dist/cli/commands/next.d.ts +14 -0
- package/dist/cli/commands/next.d.ts.map +1 -0
- package/dist/cli/commands/next.js +130 -0
- package/dist/cli/commands/next.js.map +1 -0
- package/dist/cli/index.js +34 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/resolve-unit.d.ts +22 -0
- package/dist/cli/resolve-unit.d.ts.map +1 -0
- package/dist/cli/resolve-unit.js +101 -0
- package/dist/cli/resolve-unit.js.map +1 -0
- package/dist/exporters/feature-parser.d.ts +4 -3
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +10 -4
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/harness/audit.d.ts +9 -1
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +92 -6
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/capability-plan.d.ts +12 -1
- package/dist/harness/capability-plan.d.ts.map +1 -1
- package/dist/harness/capability-plan.js +16 -2
- package/dist/harness/capability-plan.js.map +1 -1
- package/dist/harness/capability.d.ts +12 -0
- package/dist/harness/capability.d.ts.map +1 -1
- package/dist/harness/capability.js +16 -0
- package/dist/harness/capability.js.map +1 -1
- package/dist/harness/catalog/drivers.yaml +5 -0
- package/dist/harness/flow-contract.d.ts +75 -0
- package/dist/harness/flow-contract.d.ts.map +1 -1
- package/dist/harness/flow-contract.js +122 -1
- package/dist/harness/flow-contract.js.map +1 -1
- package/dist/harness/next-step.d.ts +40 -0
- package/dist/harness/next-step.d.ts.map +1 -0
- package/dist/harness/next-step.js +242 -0
- package/dist/harness/next-step.js.map +1 -0
- package/dist/harness/spec-coverage.d.ts +20 -0
- package/dist/harness/spec-coverage.d.ts.map +1 -1
- package/dist/harness/spec-coverage.js +35 -0
- package/dist/harness/spec-coverage.js.map +1 -1
- package/dist/harness/viewpoint-ledger.d.ts +4 -0
- package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
- package/dist/harness/viewpoint-ledger.js +42 -0
- package/dist/harness/viewpoint-ledger.js.map +1 -1
- package/dist/orchestrator/templates/ai-src/commands/create-test.md +27 -8
- package/dist/orchestrator/templates/ai-src/commands/run-test.md +6 -0
- package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +36 -0
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +22 -14
- package/src/cli/commands/capability.ts +11 -10
- package/src/cli/commands/challenge.ts +2 -8
- package/src/cli/commands/depth-lint.ts +2 -10
- package/src/cli/commands/gate.ts +2 -10
- package/src/cli/commands/journey.ts +2 -10
- package/src/cli/commands/manifest.ts +2 -8
- package/src/cli/commands/next.ts +96 -0
- package/src/cli/index.ts +31 -0
- package/src/cli/resolve-unit.ts +72 -0
- package/src/exporters/feature-parser.ts +10 -4
- package/src/harness/audit.ts +95 -11
- package/src/harness/capability-plan.ts +23 -3
- package/src/harness/capability.ts +22 -0
- package/src/harness/catalog/drivers.yaml +5 -0
- package/src/harness/flow-contract.ts +158 -1
- package/src/harness/next-step.ts +237 -0
- package/src/harness/spec-coverage.ts +38 -0
- package/src/harness/viewpoint-ledger.ts +42 -0
- package/src/orchestrator/templates/ai-src/commands/create-test.md +27 -8
- package/src/orchestrator/templates/ai-src/commands/run-test.md +6 -0
- package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +36 -0
|
@@ -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
|
+
}
|
|
@@ -189,13 +189,19 @@ export function splitVpAndName(scenarioName: string): { vpId?: string; category1
|
|
|
189
189
|
*
|
|
190
190
|
* Flow ids carry the use-case decomposition (#592) and group by it, so the deliverable shows
|
|
191
191
|
* the reviewer WHICH flow each case belongs to instead of collapsing a whole journey into one
|
|
192
|
-
* "Function" block: `
|
|
193
|
-
*
|
|
194
|
-
*
|
|
192
|
+
* "Function" block: `BF` → Basic Flow, `AF0n` → Alternate Flow, `EF0n` → Exception Flow. The
|
|
193
|
+
* flat scheme keeps its old mapping (`HP`/`ER` functional, `EH` guards → Accessing).
|
|
194
|
+
* The phase may sit anywhere in the id: a project whose viewpoint declares themes writes a
|
|
195
|
+
* COMPOUND id (`VP-SEC-EF02-001`) carrying theme AND phase (#595).
|
|
195
196
|
*/
|
|
196
197
|
export function mapVpToCategory2(vpId: string | undefined, scenarioName?: string): string {
|
|
197
198
|
if (!vpId) return 'Function';
|
|
198
|
-
|
|
199
|
+
// The phase segment, wherever it sits in the id. A project whose viewpoint declares themes
|
|
200
|
+
// uses a COMPOUND id — `VP-SEC-EF02-001` carries both the theme (traceability) and the phase
|
|
201
|
+
// (flow) — and anchoring on `FL-` missed every one of them, so a use-case suite still grouped
|
|
202
|
+
// as Accessing/Function (#595). The phase wins over the theme here: a flow deliverable is read
|
|
203
|
+
// flow by flow.
|
|
204
|
+
const flow = vpId.match(/(?:^|-)(?:FL-)?(BF|AF|EF|HP|ER|EH|UI)\d*(?=-|$)/i);
|
|
199
205
|
if (flow) {
|
|
200
206
|
const phase = flow[1].toUpperCase();
|
|
201
207
|
if (phase === 'BF') return 'Basic Flow';
|
package/src/harness/audit.ts
CHANGED
|
@@ -10,7 +10,7 @@ import * as path from 'path';
|
|
|
10
10
|
import * as fs from 'fs';
|
|
11
11
|
import { loadUnitScenarios, readUnitFeatureText, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
|
|
12
12
|
import { checkViewpointBaseline, ViewpointBaseline } from './viewpoint-baseline';
|
|
13
|
-
import { flowQuality, statefulDepthFor, continuityGaps, FlowQualityResult } from './flow-contract';
|
|
13
|
+
import { flowQuality, statefulDepthFor, continuityGaps, flowInventory, misfiledPhases, FlowQualityResult, InventoryResult } from './flow-contract';
|
|
14
14
|
import { featureFilesFor } from './unit-paths';
|
|
15
15
|
import {
|
|
16
16
|
loadCatalog, viewpointGate, assertionDepth, dataThemesFor, depthThresholdFor, coverageBalance, duplicateClusters, traceability, claimProof, taxonomyLint,
|
|
@@ -22,10 +22,10 @@ import { manualReasonMismatches, MANUAL_REASONS, buildPlan } from './capability-
|
|
|
22
22
|
import { readCapabilities, verificationScopeFindings } from './capability';
|
|
23
23
|
import { readIntent, projectRootFromScreenDir, IntentProfile } from './intent';
|
|
24
24
|
import { getProvenance, Provenance } from './provenance';
|
|
25
|
-
import { specCoverage, SpecCoverageResult, parseSpecClauses } from './spec-coverage';
|
|
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, LedgerResult } from './viewpoint-ledger';
|
|
28
|
+
import { viewpointLedger, parseViewpointItems, browserGestureSubstitutions, 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';
|
|
@@ -50,6 +50,8 @@ export interface AuditReport {
|
|
|
50
50
|
ledger: LedgerResult; // atomic viewpoint-item coverage (per-bullet status)
|
|
51
51
|
viewpointBaseline: ViewpointBaseline; // is the yardstick still the accepted one? (#557)
|
|
52
52
|
flowQuality?: FlowQualityResult; // #569 — flow contract verification (flows only)
|
|
53
|
+
/** #595 — coverage per DECLARED FLOW; absent when the contract declares no `flows:`. */
|
|
54
|
+
flowInventory?: InventoryResult;
|
|
53
55
|
calibration: { // #8 — multi-axis score so a high overall can't hide a weak axis
|
|
54
56
|
axes: Record<string, number>;
|
|
55
57
|
weakest: { axis: string; value: number };
|
|
@@ -73,7 +75,7 @@ export interface AuditReport {
|
|
|
73
75
|
* `specFR` and `atomicLedger` entirely — so a flow's real coverage axis was never printed
|
|
74
76
|
* while `balance`, which carried no weight, was.
|
|
75
77
|
*/
|
|
76
|
-
axes: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean }>;
|
|
78
|
+
axes: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean; byDesign?: boolean }>;
|
|
77
79
|
formula: string;
|
|
78
80
|
};
|
|
79
81
|
gateStatus: 'PASS' | 'FAIL';
|
|
@@ -83,6 +85,23 @@ export interface AuditReport {
|
|
|
83
85
|
spec: SpecCoverageResult; // G2 — spec-clause coverage (FR + validation-trigger matrix)
|
|
84
86
|
}
|
|
85
87
|
|
|
88
|
+
/**
|
|
89
|
+
* Every unit name this project holds (screens + flows + api areas). Used to answer "does the
|
|
90
|
+
* project actually contain the spec this one says its requirements come from?".
|
|
91
|
+
*/
|
|
92
|
+
export function projectUnitDirs(projectRoot: string): string[] {
|
|
93
|
+
const out: string[] = [];
|
|
94
|
+
for (const kind of ['screens', 'flows', 'api']) {
|
|
95
|
+
const dir = path.join(projectRoot, 'qa', kind);
|
|
96
|
+
try {
|
|
97
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
98
|
+
if (e.isDirectory()) out.push(e.name);
|
|
99
|
+
}
|
|
100
|
+
} catch { /* the project may not use this unit kind */ }
|
|
101
|
+
}
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
/** The catalog-resolution id for a unit dir (relative to qa/): screen · flows/<flow> · api/<area> · api/flows/<flow>. */
|
|
87
106
|
export function catalogIdFromScreenDir(screenDir: string): string {
|
|
88
107
|
const parts = screenDir.split(path.sep);
|
|
@@ -223,6 +242,12 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
223
242
|
// businessDepth headroom: it can reach 1.0 only when all three are exercised, so a present-but-
|
|
224
243
|
// shallow flow can't claim a perfect score (floor 0.5 — assertion depth still dominates).
|
|
225
244
|
const isUiFlow = isUiFlowUnit;
|
|
245
|
+
// #595 — coverage per DECLARED FLOW, when the contract declares its inventory. Phase
|
|
246
|
+
// coverage answers "is there at least one exception scenario?"; a reviewer counting the use
|
|
247
|
+
// case's flows asks "are all eleven of them accounted for?". Those gave 100% and ~40% on the
|
|
248
|
+
// same suite, and the reviewer was measuring the right thing.
|
|
249
|
+
const inventory: InventoryResult | null = flowQ.contract ? flowInventory(flowQ.contract, scenarios) : null;
|
|
250
|
+
const misfiled = flowQ.contract ? misfiledPhases(flowQ.contract, scenarios, flowQ.contract.phases[0] ?? 'BF') : [];
|
|
226
251
|
let flowDepth = isUiFlow ? flowRegressionDepth(scenarios) : { stateful: false, countProof: false, teardown: false, multiSource: false, ratio: 1, missing: [] } as FlowDepthResult;
|
|
227
252
|
// Contract-declared statefulness generalizes the cart-hardcoded vocabulary: a flow that
|
|
228
253
|
// mutates ANY named collection (order, application, submission …) gets the same three
|
|
@@ -284,9 +309,13 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
284
309
|
// so a flow can finally EARN the coverage axis instead of always losing it to
|
|
285
310
|
// PAGE-TYPE-UNDETERMINED (or worse, being judged against a form checklist).
|
|
286
311
|
const flowScored = flowQ.hasContract;
|
|
287
|
-
|
|
312
|
+
// `byDesign` separates "this unit supplied no evidence" from "this axis does not apply to
|
|
313
|
+
// this KIND of unit". Only the first is a reason to cap the score: a contract flow can never
|
|
314
|
+
// supply balance evidence, so capping for it held every flow at 8.9 forever and printed
|
|
315
|
+
// "weakest: flowCoverage 100% — fix it to lift the number", which is not fixable advice (#595).
|
|
316
|
+
const axisDefs: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean; byDesign?: boolean }> = [
|
|
288
317
|
flowScored
|
|
289
|
-
? { key: 'flowCoverage', value: flowQ.phaseRatio, weight: 0.22, applicable: true, critical: true }
|
|
318
|
+
? { key: 'flowCoverage', value: inventory ? Math.min(inventory.ratio, flowQ.phaseRatio) : flowQ.phaseRatio, weight: 0.22, applicable: true, critical: true }
|
|
290
319
|
: { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
|
|
291
320
|
{ key: 'specFR', value: specRatio, weight: 0.15, applicable: spec.hasSpec && spec.frTotal > 0, critical: true },
|
|
292
321
|
{ key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0 && !viewpointMoved, critical: true },
|
|
@@ -297,7 +326,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
297
326
|
// exceptions) — that is the shape of a well-decomposed use case, not neglected business
|
|
298
327
|
// core. `flowCoverage` already measures whether the journey's phases are covered, so
|
|
299
328
|
// balance carries no independent evidence for a contract flow (#592).
|
|
300
|
-
{ key: 'balance', value: balanceScore, weight: 0.06, applicable: !flowScored, critical: false },
|
|
329
|
+
{ key: 'balance', value: balanceScore, weight: 0.06, applicable: !flowScored, critical: false, byDesign: flowScored },
|
|
301
330
|
];
|
|
302
331
|
const scored = axisDefs.filter((a) => a.applicable);
|
|
303
332
|
const weightSum = scored.reduce((t, a) => t + a.weight, 0) || 1;
|
|
@@ -316,7 +345,22 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
316
345
|
let cap = 10;
|
|
317
346
|
if (weakestCritical && weakestCritical.value < 0.5) cap = CAP_SEVERE;
|
|
318
347
|
else if (weakestCritical && weakestCritical.value < 0.7) cap = CAP_WEAK;
|
|
319
|
-
const missingEvidence = axisDefs.filter((a) => !a.applicable).map((a) => a.key);
|
|
348
|
+
const missingEvidence = axisDefs.filter((a) => !a.applicable && !a.byDesign).map((a) => a.key);
|
|
349
|
+
// Phase coverage is WEAK evidence of flow coverage: a phase counts as covered as soon as
|
|
350
|
+
// one scenario carries it, so `flowCoverage 100%` on a fifteen-flow use case meant only
|
|
351
|
+
// "each of BF/AF/EF appears at least once". Until the contract declares its inventory, the
|
|
352
|
+
// axis cannot be certified complete — so it is partial evidence and holds the top mark down,
|
|
353
|
+
// instead of printing 100% next to a finding that says we cannot actually tell (#595).
|
|
354
|
+
if (flowScored && !inventory) missingEvidence.push('flowCoverage (phase-only — no `flows:` inventory)');
|
|
355
|
+
// A requirement list hand-restated from spec documents the project does not hold makes
|
|
356
|
+
// `specFR 100%` a certificate over an unverifiable universe: the clause that was never
|
|
357
|
+
// copied across can never be reported missing. That is partial evidence, so it holds the
|
|
358
|
+
// top mark down rather than reading as complete FR coverage (#595).
|
|
359
|
+
const restated = restatedRequirementSources(readText(specPath) ?? '', projectUnitDirs(projectRootFromScreenDir(screenDir)));
|
|
360
|
+
if (restated.sources.length > 0 && restated.missing.length > 0) {
|
|
361
|
+
missingEvidence.push(`specFR (restated from absent specs: ${restated.missing.join(', ')})`);
|
|
362
|
+
}
|
|
363
|
+
const notApplicableByDesign = axisDefs.filter((a) => !a.applicable && a.byDesign).map((a) => a.key);
|
|
320
364
|
if (missingEvidence.length > 0) cap = Math.min(cap, CAP_PARTIAL_EVIDENCE);
|
|
321
365
|
const rawOverall = Math.min(weighted * 10, cap);
|
|
322
366
|
const capped = weakestCritical ? weighted * 10 > cap : false;
|
|
@@ -372,6 +416,37 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
372
416
|
if (!flowQ.contract!.minimalGuarantee) {
|
|
373
417
|
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).');
|
|
374
418
|
}
|
|
419
|
+
// #595 — a viewpoint item naming a browser-level gesture, answered with an in-app control.
|
|
420
|
+
for (const g of browserGestureSubstitutions(viewpointPath, scenarios).slice(0, 4)) {
|
|
421
|
+
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.`);
|
|
422
|
+
}
|
|
423
|
+
// #595 — a flow whose requirement list is a hand restatement of the screen specs it
|
|
424
|
+
// traverses. `specFR` read 100% over two restated FRs while the guard clause the flow most
|
|
425
|
+
// needed had never entered the system. Name the source documents so the completeness of the
|
|
426
|
+
// restatement becomes a question someone can answer.
|
|
427
|
+
if (restated.sources.length > 0 && restated.missing.length > 0) {
|
|
428
|
+
findings.push(`SPEC-RESTATED-UNVERIFIED: this flow's requirements are restated from ${restated.sources.length} source spec(s) the project does not hold [${restated.missing.join(', ')}]. specFR ${spec.frCovered}/${spec.frTotal} therefore certifies coverage of the RESTATEMENT, not of those documents — whatever was not copied across is invisible here, and a guard clause that never entered the flow spec can never be reported missing. Add the screen units (\`sungen add --screen …\`) so their FRs are checkable, or record in the flow spec which of their requirements are deliberately out of this flow's scope.`);
|
|
429
|
+
}
|
|
430
|
+
// #595 — the flow inventory. Without it, "the phases are covered" was allowed to stand in
|
|
431
|
+
// for "the use case's flows are covered", and a reviewer who counted flows read the same
|
|
432
|
+
// suite as roughly half done.
|
|
433
|
+
if (!inventory) {
|
|
434
|
+
findings.push('FLOW-INVENTORY-MISSING: the contract declares `phases:` but no `flows:` inventory, so coverage can only be measured per PHASE — and a phase counts as covered as soon as ONE scenario carries it. Declare each flow of the use case (`id`, `branchFrom`, `outcome`, `status`) so a flow nobody wrote is a named gap instead of a silent absence. Enumerate them with the step x risk matrix in the `sungen-tc-generation` skill.');
|
|
435
|
+
} else {
|
|
436
|
+
for (const f of inventory.uncovered) {
|
|
437
|
+
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
|
+
}
|
|
439
|
+
for (const u of inventory.undeclared.slice(0, 6)) {
|
|
440
|
+
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
|
+
}
|
|
442
|
+
for (const f of inventory.accounted.filter((x) => !x.reason)) {
|
|
443
|
+
findings.push(`FLOW-STATUS-UNREASONED: declared flow ${f.id} is \`${f.status}\` with no \`reason:\` — a deferral nobody can audit is the same as a missing flow. Say what blocks it (a capability, an open question for the BA, another suite that owns it).`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
// #595 — a phase id used for something that is not that kind of flow.
|
|
447
|
+
for (const m of misfiled.slice(0, 6)) {
|
|
448
|
+
findings.push(`FLOW-PHASE-MISFILED: "${m.scenario}" carries flow id ${m.id} but ${m.why}. Re-file it under the flow it belongs to; the phase count is not the flow count.`);
|
|
449
|
+
}
|
|
375
450
|
// #580 P9 — a release-critical flow must carry its Final-Inspection selection.
|
|
376
451
|
if (flowQ.contract!.golden && !/@golden\b/i.test(featureText)) {
|
|
377
452
|
findings.push(`GOLDEN-MISSING: the contract declares \`golden: true\` (release-critical) but no scenario is tagged @golden — Final Inspection (\`sungen inspect\`) will have nothing to run for this flow. Tag the happy-path scenario(s) that prove the outcome.`);
|
|
@@ -511,11 +586,19 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
511
586
|
// TQ-10 — surface the Capability Planner recommendation (recommend-only; never installs). Silenced
|
|
512
587
|
// by `capability_suggestions: off` in qa/context.md. Reuses the planner (trustworthy after TQ-9).
|
|
513
588
|
if (intent.capabilitySuggestions) {
|
|
514
|
-
|
|
589
|
+
// The catalog unit id (`flows/<f>`, `api/<a>`) carries the unit KIND, which gates which
|
|
590
|
+
// drivers can serve it at all.
|
|
591
|
+
const plan = buildPlan(screenDir, screenName, catalogScreenName);
|
|
515
592
|
if (plan.recommendations.length) {
|
|
516
593
|
const recs = plan.recommendations.map((r) => `\`sungen capability add ${r.driver}\` (automates ${r.count})`).join(' · ');
|
|
517
594
|
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.`);
|
|
518
595
|
}
|
|
596
|
+
// Named, not dropped: without this the scenarios read as "nothing could help", and the
|
|
597
|
+
// operator's only clue was an install that dead-ends (#597).
|
|
598
|
+
if (plan.unavailable.length) {
|
|
599
|
+
const un = plan.unavailable.map((r) => `${r.driver} (would automate ${r.count})`).join(' · ');
|
|
600
|
+
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.`);
|
|
601
|
+
}
|
|
519
602
|
}
|
|
520
603
|
// TQ-11b — automation-ready (pending capability): @requires:<cap> scenarios whose cap isn't enabled.
|
|
521
604
|
// They are NOT manual (real steps, compiled the moment the cap is added) — surface them distinctly.
|
|
@@ -610,7 +693,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
610
693
|
scenarioCount: scenarios.length,
|
|
611
694
|
gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
|
|
612
695
|
taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
|
|
613
|
-
...(isUiFlow ? { flowQuality: flowQ } : {}),
|
|
696
|
+
...(isUiFlow ? { flowQuality: flowQ, flowInventory: inventory ?? undefined } : {}),
|
|
614
697
|
score: {
|
|
615
698
|
overall: Math.round(overall * 10) / 10,
|
|
616
699
|
coverage: Math.round(coverage * 100) / 100,
|
|
@@ -622,7 +705,8 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
622
705
|
// flow) that carried no weight at all.
|
|
623
706
|
axes: axisDefs.map((a) => ({ ...a, weight: a.applicable ? a.weight / weightSum : 0 })),
|
|
624
707
|
formula: `overall = (${scored.map((a) => `${(a.weight / weightSum).toFixed(2)}*${a.key}`).join(' + ')}) * 10`
|
|
625
|
-
+ (missingEvidence.length ? ` — n/a (no evidence): ${missingEvidence.join(', ')}` : '')
|
|
708
|
+
+ (missingEvidence.length ? ` — n/a (no evidence): ${missingEvidence.join(', ')}` : '')
|
|
709
|
+
+ (notApplicableByDesign.length ? ` — n/a for this unit kind: ${notApplicableByDesign.join(', ')}` : ''),
|
|
626
710
|
},
|
|
627
711
|
gateStatus,
|
|
628
712
|
findings,
|
|
@@ -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
|
|
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"
|