@sun-asterisk/sungen 3.2.1-beta.1 → 3.2.2-beta.10
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/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +31 -0
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/depth-lint.d.ts +3 -0
- package/dist/cli/commands/depth-lint.d.ts.map +1 -0
- package/dist/cli/commands/depth-lint.js +88 -0
- package/dist/cli/commands/depth-lint.js.map +1 -0
- package/dist/cli/commands/gate.d.ts +3 -0
- package/dist/cli/commands/gate.d.ts.map +1 -0
- package/dist/cli/commands/gate.js +83 -0
- package/dist/cli/commands/gate.js.map +1 -0
- package/dist/cli/commands/journey.d.ts +3 -0
- package/dist/cli/commands/journey.d.ts.map +1 -0
- package/dist/cli/commands/journey.js +96 -0
- package/dist/cli/commands/journey.js.map +1 -0
- package/dist/cli/index.js +6 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/exporters/feature-parser.d.ts +25 -0
- package/dist/exporters/feature-parser.d.ts.map +1 -1
- package/dist/exporters/feature-parser.js +59 -0
- package/dist/exporters/feature-parser.js.map +1 -1
- package/dist/exporters/types.d.ts +38 -0
- package/dist/exporters/types.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.d.ts +31 -2
- package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
- package/dist/exporters/xlsx-exporter.js +144 -1
- package/dist/exporters/xlsx-exporter.js.map +1 -1
- package/dist/harness/depth-lint.d.ts +25 -0
- package/dist/harness/depth-lint.d.ts.map +1 -0
- package/dist/harness/depth-lint.js +118 -0
- package/dist/harness/depth-lint.js.map +1 -0
- package/dist/harness/journey.d.ts +68 -0
- package/dist/harness/journey.d.ts.map +1 -0
- package/dist/harness/journey.js +328 -0
- package/dist/harness/journey.js.map +1 -0
- package/dist/harness/parse.d.ts.map +1 -1
- package/dist/harness/parse.js +4 -1
- package/dist/harness/parse.js.map +1 -1
- package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
- package/dist/orchestrator/ai-rules-updater.js +1 -0
- package/dist/orchestrator/ai-rules-updater.js.map +1 -1
- package/dist/orchestrator/templates/ai-instructions/claude-agent-generator.md +44 -0
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +24 -1
- package/dist/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +3 -0
- package/dist/orchestrator/templates/ai-instructions/claude-skill-delivery.md +27 -0
- package/dist/orchestrator/templates/ai-instructions/claude-skill-error-mapping.md +22 -3
- package/dist/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +2 -0
- package/dist/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +3 -0
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +4 -1
- package/dist/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +3 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-delivery.md +27 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-error-mapping.md +22 -3
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +2 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +3 -0
- package/dist/orchestrator/templates/specs-api.d.ts +7 -0
- package/dist/orchestrator/templates/specs-api.d.ts.map +1 -1
- package/dist/orchestrator/templates/specs-api.js +13 -2
- package/dist/orchestrator/templates/specs-api.js.map +1 -1
- package/dist/orchestrator/templates/specs-api.ts +13 -2
- package/package.json +3 -3
- package/src/cli/commands/delivery.ts +32 -2
- package/src/cli/commands/depth-lint.ts +51 -0
- package/src/cli/commands/gate.ts +44 -0
- package/src/cli/commands/journey.ts +59 -0
- package/src/cli/index.ts +6 -0
- package/src/exporters/feature-parser.ts +57 -0
- package/src/exporters/types.ts +38 -0
- package/src/exporters/xlsx-exporter.ts +176 -2
- package/src/harness/depth-lint.ts +122 -0
- package/src/harness/journey.ts +333 -0
- package/src/harness/parse.ts +4 -1
- package/src/orchestrator/ai-rules-updater.ts +1 -0
- package/src/orchestrator/templates/ai-instructions/claude-agent-generator.md +44 -0
- package/src/orchestrator/templates/ai-instructions/claude-cmd-create-test.md +24 -1
- package/src/orchestrator/templates/ai-instructions/claude-cmd-run-test.md +3 -0
- package/src/orchestrator/templates/ai-instructions/claude-skill-delivery.md +27 -0
- package/src/orchestrator/templates/ai-instructions/claude-skill-error-mapping.md +22 -3
- package/src/orchestrator/templates/ai-instructions/claude-skill-gherkin-syntax.md +2 -0
- package/src/orchestrator/templates/ai-instructions/claude-skill-tc-generation.md +3 -0
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-create-test.md +4 -1
- package/src/orchestrator/templates/ai-instructions/copilot-cmd-run-test.md +3 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-delivery.md +27 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-error-mapping.md +22 -3
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-gherkin-syntax.md +2 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-tc-generation.md +3 -0
- package/src/orchestrator/templates/specs-api.ts +13 -2
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { runDepthLint, renderDepthLint } from '../../harness/depth-lint';
|
|
5
|
+
import { reportSlug } from '../../harness/unit-paths';
|
|
6
|
+
|
|
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
|
+
|
|
17
|
+
export function registerDepthLintCommand(program: Command): void {
|
|
18
|
+
program
|
|
19
|
+
.command('depth-lint')
|
|
20
|
+
.description('Harness: classify shallow business-critical scenarios → deepen-in-place (with the data-assertion template) vs cross-screen (route to a flow). Generation-time depth self-check (#384).')
|
|
21
|
+
.option('-s, --screen <name>', 'Screen or flow name to lint')
|
|
22
|
+
.option('--json', 'Output the raw JSON report')
|
|
23
|
+
.action((options) => {
|
|
24
|
+
try {
|
|
25
|
+
const name = options.screen;
|
|
26
|
+
if (!name) throw new Error('Provide --screen <name>');
|
|
27
|
+
const dir = findScreenDir(name);
|
|
28
|
+
if (!dir) throw new Error(`Not found: qa/screens/${name} or qa/flows/${name}`);
|
|
29
|
+
|
|
30
|
+
const report = runDepthLint(dir, name);
|
|
31
|
+
|
|
32
|
+
const outDir = path.join(process.cwd(), '.sungen', 'reports');
|
|
33
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
34
|
+
const outPath = path.join(outDir, `${reportSlug(name)}-depth-lint.json`);
|
|
35
|
+
fs.writeFileSync(outPath, JSON.stringify(report, null, 2), 'utf-8');
|
|
36
|
+
|
|
37
|
+
if (options.json) {
|
|
38
|
+
console.log(JSON.stringify(report, null, 2));
|
|
39
|
+
} else {
|
|
40
|
+
renderDepthLint(report);
|
|
41
|
+
console.log(` Report: ${path.relative(process.cwd(), outPath)}`);
|
|
42
|
+
console.log('');
|
|
43
|
+
}
|
|
44
|
+
// Non-zero when there are deepen-in-place candidates the generator should fix before audit.
|
|
45
|
+
process.exit(report.deepen.length > 0 ? 2 : 0);
|
|
46
|
+
} catch (error) {
|
|
47
|
+
console.error('Error:', error instanceof Error ? error.message : error);
|
|
48
|
+
process.exit(1);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { runGate, renderGate, GatePhase } from '../../harness/journey';
|
|
5
|
+
|
|
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
|
+
|
|
16
|
+
const PHASES: GatePhase[] = ['create', 'run', 'deliver'];
|
|
17
|
+
|
|
18
|
+
export function registerGateCommand(program: Command): void {
|
|
19
|
+
program
|
|
20
|
+
.command('gate')
|
|
21
|
+
.description('Inter-phase HALT gate (#381): a phase boundary passes only when its required obligations are satisfied or explicitly waived. Exit 2 = HALT (no silent bad output crosses the boundary).')
|
|
22
|
+
.option('-s, --screen <name>', 'Screen / flow / api unit name')
|
|
23
|
+
.option('-p, --phase <phase>', `Phase boundary: ${PHASES.join(' | ')}`)
|
|
24
|
+
.option('--json', 'Output the raw verdict')
|
|
25
|
+
.action((options) => {
|
|
26
|
+
try {
|
|
27
|
+
const name = options.screen;
|
|
28
|
+
if (!name) throw new Error('Provide --screen <name>');
|
|
29
|
+
const phase = options.phase as GatePhase;
|
|
30
|
+
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}`);
|
|
32
|
+
|
|
33
|
+
const verdict = runGate(process.cwd(), name, phase);
|
|
34
|
+
if (options.json) console.log(JSON.stringify(verdict, null, 2));
|
|
35
|
+
else console.log(renderGate(verdict));
|
|
36
|
+
|
|
37
|
+
// Exit 2 on HALT — usable in CI / the orchestration loop to block the next phase.
|
|
38
|
+
process.exit(verdict.status === 'halt' ? 2 : 0);
|
|
39
|
+
} catch (error) {
|
|
40
|
+
console.error('Error:', error instanceof Error ? error.message : error);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { Command } from 'commander';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import * as fs from 'fs';
|
|
4
|
+
import { runJourney, waive, signoff, renderJourneyBoard } from '../../harness/journey';
|
|
5
|
+
import { reportSlug } from '../../harness/unit-paths';
|
|
6
|
+
|
|
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
|
+
|
|
17
|
+
export function registerJourneyCommand(program: Command): void {
|
|
18
|
+
program
|
|
19
|
+
.command('journey')
|
|
20
|
+
.description('Durable "you are here" board (#381): obligations + what-to-review + next, synthesised read-only from the audit report + ledger already on disk.')
|
|
21
|
+
.option('-s, --screen <name>', 'Screen / flow / api unit name')
|
|
22
|
+
.option('--waive <obligation>', 'Waive an obligation (e.g. OB-coverage) — requires --reason')
|
|
23
|
+
.option('--reason <text>', 'The reason a waived obligation is acceptable (mandatory with --waive)')
|
|
24
|
+
.option('--signoff', 'Sign off the review queue — the single human gate (allowed only when every other obligation is satisfied/waived)')
|
|
25
|
+
.option('--by <name>', 'Who is signing off (recorded with --signoff)')
|
|
26
|
+
.option('--json', 'Output the raw JSON report')
|
|
27
|
+
.action((options) => {
|
|
28
|
+
try {
|
|
29
|
+
const name = options.screen;
|
|
30
|
+
if (!name) throw new Error('Provide --screen <name>');
|
|
31
|
+
if (!findScreenDir(name)) throw new Error(`Not found: qa/screens/${name}, qa/flows/${name}, or qa/api/${name}`);
|
|
32
|
+
|
|
33
|
+
const report = options.waive
|
|
34
|
+
? waive(process.cwd(), name, options.waive, options.reason || '')
|
|
35
|
+
: options.signoff
|
|
36
|
+
? signoff(process.cwd(), name, options.by)
|
|
37
|
+
: runJourney(process.cwd(), name);
|
|
38
|
+
|
|
39
|
+
const outDir = path.join(process.cwd(), '.sungen', 'journey');
|
|
40
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
41
|
+
const slug = reportSlug(name);
|
|
42
|
+
fs.writeFileSync(path.join(outDir, `${slug}.json`), JSON.stringify(report, null, 2), 'utf-8');
|
|
43
|
+
const board = renderJourneyBoard(report);
|
|
44
|
+
fs.writeFileSync(path.join(outDir, `${slug}.board.md`), board, 'utf-8');
|
|
45
|
+
|
|
46
|
+
if (options.json) {
|
|
47
|
+
console.log(JSON.stringify(report, null, 2));
|
|
48
|
+
} else {
|
|
49
|
+
console.log('');
|
|
50
|
+
console.log(board);
|
|
51
|
+
console.log(` Board: ${path.relative(process.cwd(), path.join(outDir, `${slug}.board.md`))}`);
|
|
52
|
+
console.log('');
|
|
53
|
+
}
|
|
54
|
+
} catch (error) {
|
|
55
|
+
console.error('Error:', error instanceof Error ? error.message : error);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
}
|
package/src/cli/index.ts
CHANGED
|
@@ -15,6 +15,9 @@ import { registerFigmaCommand } from './commands/figma';
|
|
|
15
15
|
import { registerAddFlowCommand } from './commands/add-flow';
|
|
16
16
|
import { registerDashboardCommand } from './commands/dashboard';
|
|
17
17
|
import { registerAuditCommand } from './commands/audit';
|
|
18
|
+
import { registerDepthLintCommand } from './commands/depth-lint';
|
|
19
|
+
import { registerJourneyCommand } from './commands/journey';
|
|
20
|
+
import { registerGateCommand } from './commands/gate';
|
|
18
21
|
import { registerIngestCommand } from './commands/ingest';
|
|
19
22
|
import { registerEvalCommand } from './commands/eval';
|
|
20
23
|
import { registerManifestCommand } from './commands/manifest';
|
|
@@ -57,6 +60,9 @@ async function main() {
|
|
|
57
60
|
registerAddFlowCommand(program);
|
|
58
61
|
registerDashboardCommand(program);
|
|
59
62
|
registerAuditCommand(program);
|
|
63
|
+
registerDepthLintCommand(program);
|
|
64
|
+
registerJourneyCommand(program);
|
|
65
|
+
registerGateCommand(program);
|
|
60
66
|
registerManifestCommand(program);
|
|
61
67
|
registerLedgerCommand(program);
|
|
62
68
|
registerFeedbackCommand(program);
|
|
@@ -164,6 +164,9 @@ export function splitVpAndName(scenarioName: string): { vpId?: string; category1
|
|
|
164
164
|
* Map VP prefix to Category 2.
|
|
165
165
|
* XSS/Injection security tests are input-validation tests → Function.
|
|
166
166
|
* All other VP-SEC tests (auth, RBAC, access control) → Accessing.
|
|
167
|
+
* API auth viewpoints (missing/invalid/insufficient credentials) are access-control
|
|
168
|
+
* tests → Accessing; every other API viewpoint (contract, error, idempotency, flow,
|
|
169
|
+
* async, and the numbered baseline ids) is functional → Function.
|
|
167
170
|
*/
|
|
168
171
|
export function mapVpToCategory2(vpId: string | undefined, scenarioName?: string): string {
|
|
169
172
|
if (!vpId) return 'Function';
|
|
@@ -171,12 +174,66 @@ export function mapVpToCategory2(vpId: string | undefined, scenarioName?: string
|
|
|
171
174
|
if (scenarioName && /xss|injection/i.test(scenarioName)) return 'Function';
|
|
172
175
|
return 'Accessing';
|
|
173
176
|
}
|
|
177
|
+
if (vpId.startsWith('VP-API-')) {
|
|
178
|
+
if (vpId.startsWith('VP-API-AUTH')) return 'Accessing';
|
|
179
|
+
return 'Function';
|
|
180
|
+
}
|
|
174
181
|
if (vpId.startsWith('VP-UI-')) return 'GUI';
|
|
175
182
|
if (vpId.startsWith('VP-VAL-')) return 'Function';
|
|
176
183
|
if (vpId.startsWith('VP-LOGIC-')) return 'Function';
|
|
177
184
|
return 'Function';
|
|
178
185
|
}
|
|
179
186
|
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// API annotation helpers
|
|
189
|
+
// Used by the XLSX API-detail sheet only — no effect on CSV/BM-2-901-13 path.
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Extract ordered @api:<name> call sequence from feature-level or scenario tags.
|
|
194
|
+
* Tags may contain call arguments: @api:register(name={{x}},email={{y}}) — we strip
|
|
195
|
+
* the argument parens and keep only the endpoint name.
|
|
196
|
+
*
|
|
197
|
+
* Example: ["@api:register(name={{n}})", "@api:count_users(email={{e}})"]
|
|
198
|
+
* → ["register", "count_users"]
|
|
199
|
+
*/
|
|
200
|
+
export function extractApiCallOrder(tags: string[]): string[] {
|
|
201
|
+
return tags
|
|
202
|
+
.filter((t) => t.startsWith('@api:'))
|
|
203
|
+
.map((t) => {
|
|
204
|
+
const body = t.slice('@api:'.length);
|
|
205
|
+
const parenIdx = body.indexOf('(');
|
|
206
|
+
return parenIdx >= 0 ? body.slice(0, parenIdx) : body;
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Extract the @cases:<dataset> name from tags (returns the first one found, or null).
|
|
212
|
+
* Used to label the "cases" annotation so the detail sheet can note which scenarios
|
|
213
|
+
* exercise a given endpoint with a matrix of input/status pairs.
|
|
214
|
+
*/
|
|
215
|
+
export function extractCasesDataset(tags: string[]): string | null {
|
|
216
|
+
const tag = tags.find((t) => t.startsWith('@cases:'));
|
|
217
|
+
return tag ? tag.slice('@cases:'.length) : null;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Extract the concurrency invariant text from @concurrent:<N> and @query:<oracle> tags.
|
|
222
|
+
* N is the number of parallel fires; the invariant the band proves is exactly-one success
|
|
223
|
+
* (regardless of N), cross-checked by the @query DB oracle. Returns e.g.
|
|
224
|
+
* "2× parallel → exactly-one; @query user_count", or '' when absent.
|
|
225
|
+
*/
|
|
226
|
+
export function extractConcurrencyInvariant(tags: string[]): string {
|
|
227
|
+
const concurrentTag = tags.find((t) => t.startsWith('@concurrent:'));
|
|
228
|
+
if (!concurrentTag) return '';
|
|
229
|
+
const n = concurrentTag.slice('@concurrent:'.length);
|
|
230
|
+
const queryTag = tags.find((t) => t.startsWith('@query:'));
|
|
231
|
+
const oracle = queryTag ? queryTag.slice('@query:'.length).split('(')[0] : '';
|
|
232
|
+
const parts = [`${n}× parallel → exactly-one`];
|
|
233
|
+
if (oracle) parts.push(`@query ${oracle}`);
|
|
234
|
+
return parts.join('; ');
|
|
235
|
+
}
|
|
236
|
+
|
|
180
237
|
/**
|
|
181
238
|
* Generate TC ID, namespaced by screen/flow so it is globally unique across the
|
|
182
239
|
* whole project. This matters because the dashboard tracks each test case by its
|
package/src/exporters/types.ts
CHANGED
|
@@ -128,3 +128,41 @@ export interface EnvironmentInfo {
|
|
|
128
128
|
projectName: string;
|
|
129
129
|
executor: string;
|
|
130
130
|
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* One row in the "API detail" worksheet — one row per catalog endpoint.
|
|
134
|
+
* Populated from the apis.yaml catalog + scenario annotations (@cases, @api, @concurrent).
|
|
135
|
+
* Only emitted for api-kind units; never touches the BM-2-901-13 Testcases sheet.
|
|
136
|
+
*/
|
|
137
|
+
export interface ApiDetailRow {
|
|
138
|
+
/** Endpoint path from catalog (e.g. /register, /users/count?email=:email) */
|
|
139
|
+
endpoint: string;
|
|
140
|
+
/** HTTP method (GET, POST, …) */
|
|
141
|
+
method: string;
|
|
142
|
+
/** Auth / datasource string composed from catalog datasource + any @auth tag */
|
|
143
|
+
authDatasource: string;
|
|
144
|
+
/** Request shape: body fields / params / encoding from the catalog entry */
|
|
145
|
+
requestShape: string;
|
|
146
|
+
/** Expected-status matrix: the catalog expect.status plus a pointer to any @cases dataset
|
|
147
|
+
* that drives this endpoint, e.g. "201; @cases:register_cases". */
|
|
148
|
+
expectedStatusMatrix: string;
|
|
149
|
+
/** Ordered @api:<name> call sequence for flow scenarios referencing this endpoint */
|
|
150
|
+
flowSteps: string;
|
|
151
|
+
/** Concurrency invariant for @concurrent scenarios, e.g. "2× parallel → exactly-one; @query <oracle>" */
|
|
152
|
+
concurrencyInvariant: string;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Catalog entry as parsed from apis.yaml.
|
|
157
|
+
* All fields are typed loosely (unknown) because the yaml structure may vary — callers
|
|
158
|
+
* must guard before use.
|
|
159
|
+
*/
|
|
160
|
+
export interface ApiCatalogEntry {
|
|
161
|
+
method?: string;
|
|
162
|
+
path?: string;
|
|
163
|
+
datasource?: string;
|
|
164
|
+
description?: string;
|
|
165
|
+
body?: unknown;
|
|
166
|
+
params?: unknown;
|
|
167
|
+
expect?: { status?: number | string };
|
|
168
|
+
}
|
|
@@ -13,10 +13,15 @@ import * as fs from 'fs';
|
|
|
13
13
|
import * as path from 'path';
|
|
14
14
|
import ExcelJS from 'exceljs';
|
|
15
15
|
import JSZip from 'jszip';
|
|
16
|
-
import { ScreenSummary, TestCaseRow } from './types';
|
|
16
|
+
import { ApiCatalogEntry, ApiDetailRow, ScreenSummary, TestCaseRow } from './types';
|
|
17
17
|
import { getPackageVersion } from './package-info';
|
|
18
18
|
import { SUN_LOGO_PNG_BASE64 } from './sun-logo';
|
|
19
19
|
import { deliverableBasename } from './csv-exporter';
|
|
20
|
+
import {
|
|
21
|
+
extractApiCallOrder,
|
|
22
|
+
extractCasesDataset,
|
|
23
|
+
extractConcurrencyInvariant,
|
|
24
|
+
} from './feature-parser';
|
|
20
25
|
|
|
21
26
|
const COL_COUNT = 16;
|
|
22
27
|
const HEADER_FILL = 'FFD9D2E9'; // lavender — matches the summary-header band on row 6
|
|
@@ -37,15 +42,31 @@ function applyBorder(cell: AnyCell): void {
|
|
|
37
42
|
};
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
/**
|
|
46
|
+
* Optional context for the supplementary "API detail" worksheet.
|
|
47
|
+
* Passed only when the unit is kind:api. When omitted, only the standard
|
|
48
|
+
* Testcases sheet is emitted (non-api delivery stays byte-identical).
|
|
49
|
+
*/
|
|
50
|
+
export interface ApiDetailContext {
|
|
51
|
+
/** Parsed apis.yaml catalog keyed by endpoint name */
|
|
52
|
+
catalog: Record<string, ApiCatalogEntry>;
|
|
53
|
+
/** Pre-built detail rows (one per catalog endpoint) */
|
|
54
|
+
rows: ApiDetailRow[];
|
|
55
|
+
}
|
|
56
|
+
|
|
40
57
|
export function renderXlsx(
|
|
41
58
|
summary: ScreenSummary,
|
|
42
59
|
rows: TestCaseRow[],
|
|
43
|
-
specLink: string
|
|
60
|
+
specLink: string,
|
|
61
|
+
apiDetail?: ApiDetailContext,
|
|
44
62
|
): ExcelJS.Workbook {
|
|
45
63
|
const wb = new ExcelJS.Workbook();
|
|
46
64
|
wb.creator = 'sungen delivery';
|
|
47
65
|
wb.created = new Date();
|
|
48
66
|
addTestcaseSheet(wb, 'Testcases', summary, rows, specLink);
|
|
67
|
+
if (apiDetail) {
|
|
68
|
+
addApiDetailSheet(wb, apiDetail.rows);
|
|
69
|
+
}
|
|
49
70
|
return wb;
|
|
50
71
|
}
|
|
51
72
|
|
|
@@ -410,6 +431,159 @@ function addTestcaseSheet(
|
|
|
410
431
|
};
|
|
411
432
|
}
|
|
412
433
|
|
|
434
|
+
// ---------------------------------------------------------------------------
|
|
435
|
+
// API detail sheet (api-kind units only)
|
|
436
|
+
// Second worksheet appended after Testcases — never alters the Testcases sheet.
|
|
437
|
+
// ---------------------------------------------------------------------------
|
|
438
|
+
|
|
439
|
+
const API_DETAIL_HEADER_FILL = 'FF2E5984'; // dark blue header for differentiation
|
|
440
|
+
const API_DETAIL_HEADER_FONT = 'FFFFFFFF'; // white text on dark header
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* Build ApiDetailRow[] from the apis.yaml catalog + feature-level annotations.
|
|
444
|
+
* Called once per feature file for api-kind units in the delivery pipeline.
|
|
445
|
+
*
|
|
446
|
+
* @param catalog Parsed apis.yaml keyed by endpoint name
|
|
447
|
+
* @param scenarios Scenario-level tag arrays from parseFeatureMetadata().scenarios
|
|
448
|
+
*/
|
|
449
|
+
export function buildApiDetailRows(
|
|
450
|
+
catalog: Record<string, ApiCatalogEntry>,
|
|
451
|
+
scenarios: Array<{ tags: string[] }>,
|
|
452
|
+
): ApiDetailRow[] {
|
|
453
|
+
const rows: ApiDetailRow[] = [];
|
|
454
|
+
|
|
455
|
+
for (const [endpointName, entry] of Object.entries(catalog)) {
|
|
456
|
+
const method = (entry.method ?? '').toUpperCase();
|
|
457
|
+
const endpoint = entry.path ?? endpointName;
|
|
458
|
+
const datasource = entry.datasource ?? '';
|
|
459
|
+
|
|
460
|
+
// Auth: look for @auth: tag in any scenario that calls this endpoint.
|
|
461
|
+
const authTags = scenarios.flatMap((s) => {
|
|
462
|
+
const calls = extractApiCallOrder(s.tags);
|
|
463
|
+
if (!calls.includes(endpointName)) return [];
|
|
464
|
+
return s.tags.filter((t) => t.startsWith('@auth:'));
|
|
465
|
+
});
|
|
466
|
+
const uniqueAuth = [...new Set(authTags.map((t) => t.slice('@auth:'.length)))];
|
|
467
|
+
const authDatasource = [datasource, ...uniqueAuth].filter(Boolean).join('; ');
|
|
468
|
+
|
|
469
|
+
// Request shape: compose from body + params + encoding.
|
|
470
|
+
const bodyStr = entry.body
|
|
471
|
+
? `body: ${typeof entry.body === 'string' ? entry.body : JSON.stringify(entry.body)}`
|
|
472
|
+
: '';
|
|
473
|
+
const paramsArr = Array.isArray(entry.params) ? entry.params as string[] : [];
|
|
474
|
+
const paramsStr = paramsArr.length > 0 ? `params: [${paramsArr.join(', ')}]` : '';
|
|
475
|
+
const requestShape = [bodyStr, paramsStr].filter(Boolean).join('; ') || '—';
|
|
476
|
+
|
|
477
|
+
// Expected-status matrix: aggregate @cases dataset labels + expected status
|
|
478
|
+
// from scenarios that call this endpoint. Fall back to catalog expect.status.
|
|
479
|
+
const statusEntries: string[] = [];
|
|
480
|
+
for (const sc of scenarios) {
|
|
481
|
+
const calls = extractApiCallOrder(sc.tags);
|
|
482
|
+
if (!calls.includes(endpointName)) continue;
|
|
483
|
+
const dataset = extractCasesDataset(sc.tags);
|
|
484
|
+
if (dataset) {
|
|
485
|
+
// @cases dataset name as label — actual per-row statuses live in test-data.yaml
|
|
486
|
+
statusEntries.push(`@cases:${dataset}`);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
// Show the catalog baseline status plus a pointer to any @cases matrix dataset (the per-row
|
|
490
|
+
// statuses live in test-data; resolving them into this cell is a later enrichment).
|
|
491
|
+
const catalogStatus = entry.expect?.status != null ? String(entry.expect.status) : '';
|
|
492
|
+
const expectedStatusMatrix =
|
|
493
|
+
[catalogStatus, ...new Set(statusEntries)].filter(Boolean).join('; ') || '—';
|
|
494
|
+
|
|
495
|
+
// Flow steps: ordered @api names from flow-tagged scenarios referencing this endpoint.
|
|
496
|
+
const flowStepsSet = new Set<string>();
|
|
497
|
+
for (const sc of scenarios) {
|
|
498
|
+
const calls = extractApiCallOrder(sc.tags);
|
|
499
|
+
if (!calls.includes(endpointName)) continue;
|
|
500
|
+
// All scenarios show their call order; flow scenarios show multi-step chains.
|
|
501
|
+
if (calls.length > 1) {
|
|
502
|
+
flowStepsSet.add(calls.join(' → '));
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const flowSteps = [...flowStepsSet].join('; ') || '—';
|
|
506
|
+
|
|
507
|
+
// Concurrency invariant: from @concurrent scenarios calling this endpoint.
|
|
508
|
+
const concurrencyParts: string[] = [];
|
|
509
|
+
for (const sc of scenarios) {
|
|
510
|
+
const calls = extractApiCallOrder(sc.tags);
|
|
511
|
+
if (!calls.includes(endpointName)) continue;
|
|
512
|
+
const inv = extractConcurrencyInvariant(sc.tags);
|
|
513
|
+
if (inv) concurrencyParts.push(inv);
|
|
514
|
+
}
|
|
515
|
+
const concurrencyInvariant = concurrencyParts.join('; ') || '—';
|
|
516
|
+
|
|
517
|
+
rows.push({
|
|
518
|
+
endpoint,
|
|
519
|
+
method,
|
|
520
|
+
authDatasource,
|
|
521
|
+
requestShape,
|
|
522
|
+
expectedStatusMatrix,
|
|
523
|
+
flowSteps,
|
|
524
|
+
concurrencyInvariant,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return rows;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Append a second "API detail" worksheet to the workbook.
|
|
533
|
+
* Called only for api-kind units; no effect on the Testcases sheet or other sheets.
|
|
534
|
+
*
|
|
535
|
+
* Columns: Endpoint · Method · Auth/Datasource · Request shape ·
|
|
536
|
+
* Expected-status matrix · Flow steps · Concurrency invariant
|
|
537
|
+
*/
|
|
538
|
+
export function addApiDetailSheet(wb: ExcelJS.Workbook, detailRows: ApiDetailRow[]): void {
|
|
539
|
+
const ws = wb.addWorksheet('API detail');
|
|
540
|
+
const ARIAL_FONT = 'Arial';
|
|
541
|
+
|
|
542
|
+
ws.columns = [
|
|
543
|
+
{ header: 'Endpoint', width: 35 },
|
|
544
|
+
{ header: 'Method', width: 10 },
|
|
545
|
+
{ header: 'Auth / Datasource', width: 22 },
|
|
546
|
+
{ header: 'Request shape', width: 40 },
|
|
547
|
+
{ header: 'Expected-status matrix', width: 30 },
|
|
548
|
+
{ header: 'Flow steps', width: 40 },
|
|
549
|
+
{ header: 'Concurrency invariant', width: 35 },
|
|
550
|
+
];
|
|
551
|
+
|
|
552
|
+
// Style the auto-generated header row (row 1).
|
|
553
|
+
const headerRow = ws.getRow(1);
|
|
554
|
+
headerRow.height = 30;
|
|
555
|
+
headerRow.eachCell((cell) => {
|
|
556
|
+
cell.font = { bold: true, color: { argb: API_DETAIL_HEADER_FONT }, name: ARIAL_FONT };
|
|
557
|
+
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: API_DETAIL_HEADER_FILL } };
|
|
558
|
+
cell.alignment = { horizontal: 'center', vertical: 'middle', wrapText: true };
|
|
559
|
+
applyBorder(cell);
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
ws.views = [{ state: 'frozen', ySplit: 1 }];
|
|
563
|
+
|
|
564
|
+
for (const r of detailRows) {
|
|
565
|
+
const row = ws.addRow([
|
|
566
|
+
r.endpoint,
|
|
567
|
+
r.method,
|
|
568
|
+
r.authDatasource,
|
|
569
|
+
r.requestShape,
|
|
570
|
+
r.expectedStatusMatrix,
|
|
571
|
+
r.flowSteps,
|
|
572
|
+
r.concurrencyInvariant,
|
|
573
|
+
]);
|
|
574
|
+
row.alignment = { vertical: 'top', wrapText: true };
|
|
575
|
+
row.eachCell({ includeEmpty: true }, (cell) => {
|
|
576
|
+
applyBorder(cell);
|
|
577
|
+
cell.font = { name: ARIAL_FONT };
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
ws.autoFilter = {
|
|
582
|
+
from: { row: 1, column: 1 },
|
|
583
|
+
to: { row: ws.rowCount, column: 7 },
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
413
587
|
/**
|
|
414
588
|
* Write the workbook to `qa/deliverables/<screen>-testcases[.env].xlsx`.
|
|
415
589
|
* When `SUNGEN_ENV` is set, the env name is appended so locale exports don't
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Depth lint (issue #384) — a deterministic, generation-time depth self-check.
|
|
3
|
+
*
|
|
4
|
+
* The audit's `assertionDepth` sensor decides WHICH business-critical scenarios are shallow
|
|
5
|
+
* (the authoritative set). This lint adds the missing half: for each shallow business-critical
|
|
6
|
+
* scenario it classifies the *fix* using the catalog's per-theme `depth` metadata —
|
|
7
|
+
* • cross_screen:false → DEEPEN in place (emit the theme's `depth.template` value assertion)
|
|
8
|
+
* • cross_screen:true → DEFER (flow-own, or @manual:Mx with a reason) — leaves the depth denominator
|
|
9
|
+
* so a generator (or the create-test repair step) can act mechanically BEFORE the first audit,
|
|
10
|
+
* instead of churning the 3-round repair budget on scenarios that can't be deepened on-screen.
|
|
11
|
+
*
|
|
12
|
+
* Reuses the audit plumbing verbatim (parse + catalog + assertionDepth) → same verdict as `sungen audit`.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'fs';
|
|
15
|
+
import * as path from 'path';
|
|
16
|
+
import { loadScenarios, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
|
|
17
|
+
import { loadCatalog, viewpointGate, assertionDepth, dataThemesFor, CatalogTheme } from './sensors';
|
|
18
|
+
|
|
19
|
+
export type DepthAction = 'deepen' | 'defer';
|
|
20
|
+
|
|
21
|
+
export interface DepthLintItem {
|
|
22
|
+
scenario: string;
|
|
23
|
+
theme: string | null;
|
|
24
|
+
action: DepthAction;
|
|
25
|
+
/** the exact deep step to emit (deepen) or the routing hint (defer) */
|
|
26
|
+
fix: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface DepthLintReport {
|
|
30
|
+
screen: string;
|
|
31
|
+
pageType: string | null;
|
|
32
|
+
focus: string;
|
|
33
|
+
threshold: number;
|
|
34
|
+
bcDepthRatio: number;
|
|
35
|
+
verdict: 'pass' | 'warn' | 'fail';
|
|
36
|
+
businessCriticalTotal: number;
|
|
37
|
+
shallowTotal: number;
|
|
38
|
+
/** shallow business-critical scenarios that CAN be deepened on-screen (actionable now) */
|
|
39
|
+
deepen: DepthLintItem[];
|
|
40
|
+
/** shallow business-critical scenarios that are cross-screen → route to a flow / @manual */
|
|
41
|
+
defer: DepthLintItem[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Find the data-theme a scenario belongs to (precise depth.keywords, fallback theme.keywords). */
|
|
45
|
+
function matchTheme(s: ScenarioInfo, dataThemes: CatalogTheme[]): CatalogTheme | undefined {
|
|
46
|
+
return dataThemes.find((t) => {
|
|
47
|
+
const kws = t.depth?.keywords?.length ? t.depth.keywords : t.keywords;
|
|
48
|
+
return kws.some((k) => s.haystack.includes(k.toLowerCase()));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function runDepthLint(screenDir: string, screenName: string, focus = 'functional'): DepthLintReport {
|
|
53
|
+
const last = screenName.split('/').pop() || screenName;
|
|
54
|
+
const featurePath = path.join(screenDir, 'features', `${last}.feature`);
|
|
55
|
+
const viewpointPath = path.join(screenDir, 'requirements', 'test-viewpoint.md');
|
|
56
|
+
|
|
57
|
+
const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
|
|
58
|
+
const viewpoints: ViewpointEntry[] = parseViewpointOverview(viewpointPath);
|
|
59
|
+
const catalog = loadCatalog();
|
|
60
|
+
const gate = viewpointGate(scenarios, viewpoints, catalog);
|
|
61
|
+
const dataThemes = dataThemesFor(catalog, gate.pageType);
|
|
62
|
+
const depth = assertionDepth(scenarios, dataThemes, focus);
|
|
63
|
+
|
|
64
|
+
const byName = new Map(scenarios.map((s) => [s.name, s]));
|
|
65
|
+
const deepen: DepthLintItem[] = [];
|
|
66
|
+
const defer: DepthLintItem[] = [];
|
|
67
|
+
|
|
68
|
+
for (const sb of depth.shallowBusinessCritical) {
|
|
69
|
+
const s = byName.get(sb.name);
|
|
70
|
+
const theme = s ? matchTheme(s, dataThemes) : undefined;
|
|
71
|
+
const crossScreen = theme?.depth?.cross_screen ?? false;
|
|
72
|
+
if (crossScreen) {
|
|
73
|
+
defer.push({
|
|
74
|
+
scenario: sb.name,
|
|
75
|
+
theme: theme?.theme ?? null,
|
|
76
|
+
action: 'defer',
|
|
77
|
+
fix: `cross-screen — own it in a flow (sungen add-flow) or tag @manual:Mx with a reason; do not fake an on-screen data assertion`,
|
|
78
|
+
});
|
|
79
|
+
} else {
|
|
80
|
+
deepen.push({
|
|
81
|
+
scenario: sb.name,
|
|
82
|
+
theme: theme?.theme ?? null,
|
|
83
|
+
action: 'deepen',
|
|
84
|
+
fix: theme?.depth?.template ?? `add a data assertion (\`... with {{value}}\` or \`see all ... contain {{v}}\`)`,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
screen: screenName,
|
|
91
|
+
pageType: gate.pageType,
|
|
92
|
+
focus,
|
|
93
|
+
threshold: depth.threshold,
|
|
94
|
+
bcDepthRatio: depth.bcDepthRatio,
|
|
95
|
+
verdict: depth.verdict,
|
|
96
|
+
businessCriticalTotal: depth.businessCriticalTotal,
|
|
97
|
+
shallowTotal: depth.businessCriticalShallow,
|
|
98
|
+
deepen,
|
|
99
|
+
defer,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function renderDepthLint(r: DepthLintReport): void {
|
|
104
|
+
const pct = (n: number) => `${Math.round(n * 100)}%`;
|
|
105
|
+
console.log('');
|
|
106
|
+
console.log(`━━━ Depth lint: ${r.screen} (page-type ${r.pageType ?? 'unknown'}) ━━━`);
|
|
107
|
+
console.log('');
|
|
108
|
+
console.log(` businessDepth ${pct(r.bcDepthRatio)} (threshold ${pct(r.threshold)} · focus ${r.focus}) → ${r.verdict.toUpperCase()}`);
|
|
109
|
+
console.log(` ${r.businessCriticalTotal} business-critical · ${r.shallowTotal} shallow → ${r.deepen.length} deepen-in-place · ${r.defer.length} cross-screen`);
|
|
110
|
+
if (r.deepen.length) {
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(' ── DEEPEN IN PLACE (fix before audit) ──');
|
|
113
|
+
for (const d of r.deepen) console.log(` • ${d.scenario}\n [${d.theme}] → ${d.fix}`);
|
|
114
|
+
}
|
|
115
|
+
if (r.defer.length) {
|
|
116
|
+
console.log('');
|
|
117
|
+
console.log(' ── CROSS-SCREEN (route to flow / @manual:Mx) ──');
|
|
118
|
+
for (const d of r.defer) console.log(` • ${d.scenario} [${d.theme}]`);
|
|
119
|
+
}
|
|
120
|
+
if (!r.deepen.length && !r.defer.length) console.log(' ✓ no shallow business-critical scenarios');
|
|
121
|
+
console.log('');
|
|
122
|
+
}
|