@sun-asterisk/sungen 3.0.0-beta.75 → 3.0.0-beta.78
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 +10 -0
- package/dist/cli/commands/audit.js.map +1 -1
- package/dist/cli/commands/delivery.d.ts.map +1 -1
- package/dist/cli/commands/delivery.js +30 -14
- package/dist/cli/commands/delivery.js.map +1 -1
- package/dist/cli/commands/ingest.d.ts +3 -0
- package/dist/cli/commands/ingest.d.ts.map +1 -0
- package/dist/cli/commands/ingest.js +179 -0
- package/dist/cli/commands/ingest.js.map +1 -0
- package/dist/cli/index.js +2 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/dashboard/templates/index.html +54 -54
- package/dist/harness/audit.d.ts +2 -0
- package/dist/harness/audit.d.ts.map +1 -1
- package/dist/harness/audit.js +15 -4
- package/dist/harness/audit.js.map +1 -1
- package/dist/harness/capability-plan.d.ts +6 -0
- package/dist/harness/capability-plan.d.ts.map +1 -1
- package/dist/harness/capability-plan.js +13 -0
- package/dist/harness/capability-plan.js.map +1 -1
- package/dist/harness/spec-coverage.d.ts +37 -0
- package/dist/harness/spec-coverage.d.ts.map +1 -0
- package/dist/harness/spec-coverage.js +159 -0
- package/dist/harness/spec-coverage.js.map +1 -0
- package/dist/ingest/baseline-audit.d.ts +38 -0
- package/dist/ingest/baseline-audit.d.ts.map +1 -0
- package/dist/ingest/baseline-audit.js +85 -0
- package/dist/ingest/baseline-audit.js.map +1 -0
- package/dist/ingest/gsheet-fetch.d.ts +9 -0
- package/dist/ingest/gsheet-fetch.d.ts.map +1 -0
- package/dist/ingest/gsheet-fetch.js +180 -0
- package/dist/ingest/gsheet-fetch.js.map +1 -0
- package/dist/ingest/index.d.ts +6 -0
- package/dist/ingest/index.d.ts.map +1 -0
- package/dist/ingest/index.js +22 -0
- package/dist/ingest/index.js.map +1 -0
- package/dist/ingest/legacy-parser.d.ts +39 -0
- package/dist/ingest/legacy-parser.d.ts.map +1 -0
- package/dist/ingest/legacy-parser.js +218 -0
- package/dist/ingest/legacy-parser.js.map +1 -0
- package/dist/ingest/reconcile.d.ts +30 -0
- package/dist/ingest/reconcile.d.ts.map +1 -0
- package/dist/ingest/reconcile.js +65 -0
- package/dist/ingest/reconcile.js.map +1 -0
- package/dist/ingest/to-gherkin.d.ts +33 -0
- package/dist/ingest/to-gherkin.d.ts.map +1 -0
- package/dist/ingest/to-gherkin.js +93 -0
- package/dist/ingest/to-gherkin.js.map +1 -0
- package/dist/orchestrator/ai-rules-updater.d.ts.map +1 -1
- package/dist/orchestrator/ai-rules-updater.js +2 -0
- package/dist/orchestrator/ai-rules-updater.js.map +1 -1
- package/dist/orchestrator/templates/ai-instructions/claude-skill-delivery.md +10 -0
- package/dist/orchestrator/templates/ai-instructions/claude-skill-ingest-legacy.md +79 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-delivery.md +10 -0
- package/dist/orchestrator/templates/ai-instructions/github-skill-sungen-ingest-legacy.md +79 -0
- package/package.json +3 -3
- package/src/cli/commands/audit.ts +7 -0
- package/src/cli/commands/delivery.ts +31 -15
- package/src/cli/commands/ingest.ts +141 -0
- package/src/cli/index.ts +2 -0
- package/src/dashboard/templates/index.html +54 -54
- package/src/harness/audit.ts +17 -4
- package/src/harness/capability-plan.ts +11 -0
- package/src/harness/spec-coverage.ts +139 -0
- package/src/ingest/baseline-audit.ts +100 -0
- package/src/ingest/gsheet-fetch.ts +152 -0
- package/src/ingest/index.ts +5 -0
- package/src/ingest/legacy-parser.ts +184 -0
- package/src/ingest/reconcile.ts +80 -0
- package/src/ingest/to-gherkin.ts +108 -0
- package/src/orchestrator/ai-rules-updater.ts +2 -0
- package/src/orchestrator/templates/ai-instructions/claude-skill-delivery.md +10 -0
- package/src/orchestrator/templates/ai-instructions/claude-skill-ingest-legacy.md +79 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-delivery.md +10 -0
- package/src/orchestrator/templates/ai-instructions/github-skill-sungen-ingest-legacy.md +79 -0
package/src/harness/audit.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* docs/orchestration-spec.md §5 and reports/sungen_home_gherkin_viewpoint_coverage_review.md.
|
|
8
8
|
*/
|
|
9
9
|
import * as path from 'path';
|
|
10
|
+
import * as fs from 'fs';
|
|
10
11
|
import { loadScenarios, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
|
|
11
12
|
import {
|
|
12
13
|
loadCatalog, viewpointGate, assertionDepth, dataThemesFor, coverageBalance, duplicateClusters, traceability, claimProof, taxonomyLint,
|
|
@@ -14,6 +15,7 @@ import {
|
|
|
14
15
|
} from './sensors';
|
|
15
16
|
import { readIntent, projectRootFromScreenDir, IntentProfile } from './intent';
|
|
16
17
|
import { getProvenance, Provenance } from './provenance';
|
|
18
|
+
import { specCoverage, SpecCoverageResult } from './spec-coverage';
|
|
17
19
|
|
|
18
20
|
export interface AuditReport {
|
|
19
21
|
screen: string;
|
|
@@ -37,15 +39,20 @@ export interface AuditReport {
|
|
|
37
39
|
findings: string[]; // human-actionable, what the Repair loop would target
|
|
38
40
|
intent: IntentProfile; // P3 — the intent profile that drove the thresholds
|
|
39
41
|
provenance: Provenance; // D1 — sungen version + catalog hash (diagnose cross-user score gaps)
|
|
42
|
+
spec: SpecCoverageResult; // G2 — spec-clause coverage (FR + validation-trigger matrix)
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
43
46
|
const featurePath = path.join(screenDir, 'features', `${screenName}.feature`);
|
|
44
47
|
const viewpointPath = path.join(screenDir, 'requirements', 'test-viewpoint.md');
|
|
45
48
|
|
|
49
|
+
const specPath = path.join(screenDir, 'requirements', 'spec.md');
|
|
50
|
+
const featureText = fs.existsSync(featurePath) ? fs.readFileSync(featurePath, 'utf-8') : '';
|
|
51
|
+
|
|
46
52
|
const scenarios: ScenarioInfo[] = loadScenarios(featurePath);
|
|
47
53
|
const viewpoints: ViewpointEntry[] = parseViewpointOverview(viewpointPath);
|
|
48
54
|
const catalog = loadCatalog();
|
|
55
|
+
const spec = specCoverage(specPath, scenarios, featureText);
|
|
49
56
|
|
|
50
57
|
const gate = viewpointGate(scenarios, viewpoints, catalog);
|
|
51
58
|
// P3 — intent profile from qa/context.md drives the depth threshold (focus).
|
|
@@ -100,16 +107,22 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
|
|
|
100
107
|
if (gate.universalGaps.length) {
|
|
101
108
|
findings.push(`UNIVERSAL: missing theme(s): ${gate.universalGaps.join(', ')} (low priority reminder).`);
|
|
102
109
|
}
|
|
110
|
+
for (const g of spec.triggerGaps) {
|
|
111
|
+
findings.push(`TRIGGER-UNCOVERED: spec validates "${g.constraint}"${g.code ? ` (${g.code})` : ''} on [${g.required.join(', ')}] but scenarios only exercise it on [${g.found.join(', ') || 'none'}] → add a ${g.missing.join(', ')}-trigger scenario for this constraint (don't collapse the trigger × input matrix).`);
|
|
112
|
+
}
|
|
113
|
+
for (const u of spec.uncoveredMust) {
|
|
114
|
+
findings.push(`SPEC-UNCOVERED: ${u.id} (MUST) has no covering scenario — "${u.text}" → add a scenario or tag one @spec:${u.id}.`);
|
|
115
|
+
}
|
|
103
116
|
|
|
104
|
-
// Gate
|
|
105
|
-
//
|
|
117
|
+
// Gate spans coverage (viewpoint themes), depth (data-correctness), claim-proof,
|
|
118
|
+
// AND spec-clause coverage (every MUST clause + every mandated validation trigger).
|
|
106
119
|
const gateStatus: 'PASS' | 'FAIL' =
|
|
107
|
-
gate.gaps.length === 0 && depth.verdict !== 'fail' && claim.verdict !== 'fail' ? 'PASS' : 'FAIL';
|
|
120
|
+
gate.gaps.length === 0 && depth.verdict !== 'fail' && claim.verdict !== 'fail' && spec.verdict !== 'fail' ? 'PASS' : 'FAIL';
|
|
108
121
|
|
|
109
122
|
return {
|
|
110
123
|
screen: screenName,
|
|
111
124
|
scenarioCount: scenarios.length,
|
|
112
|
-
gate, depth, claim, taxonomy, balance, duplicates, trace,
|
|
125
|
+
gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
|
|
113
126
|
score: {
|
|
114
127
|
overall: Math.round(overall * 10) / 10,
|
|
115
128
|
coverage: Math.round(coverage * 100) / 100,
|
|
@@ -43,6 +43,17 @@ const INFER: { code: string; re: RegExp }[] = [
|
|
|
43
43
|
{ code: 'M9', re: /\b(judgment|human|subjective|manual review)\b/i },
|
|
44
44
|
];
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* Classify free text (e.g. a legacy testcase's precondition+steps+expected) into a
|
|
48
|
+
* manual-reason code, or '' when nothing matches (→ UI-automatable). Reuses the same
|
|
49
|
+
* INFER patterns as the scenario planner so legacy-ingest and the Gherkin planner agree.
|
|
50
|
+
*/
|
|
51
|
+
export function classifyReason(text: string): string {
|
|
52
|
+
const t = (text || '').toLowerCase();
|
|
53
|
+
for (const { code, re } of INFER) if (re.test(t)) return code;
|
|
54
|
+
return '';
|
|
55
|
+
}
|
|
56
|
+
|
|
46
57
|
interface ParsedScenario { name: string; tags: string[]; manual: boolean; reason: string }
|
|
47
58
|
|
|
48
59
|
/** Parse scenarios with their tags + the reason comment line above (for @manual). */
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Spec-clause coverage (harness G2) — the spec-layer faithfulness check.
|
|
3
|
+
*
|
|
4
|
+
* Parses the structured parts of a screen `spec.md` (Functional Requirements + the
|
|
5
|
+
* Validation Rules table) and verifies (a) every MUST FR is covered by a scenario and
|
|
6
|
+
* (b) for EACH validation constraint, every trigger the spec mandates is actually
|
|
7
|
+
* exercised. The trigger check is PER-CONSTRAINT — so "validate format on blur AND on
|
|
8
|
+
* submit" tested only on blur is caught even when other constraints do use submit.
|
|
9
|
+
* Generic: no project data.
|
|
10
|
+
*/
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
import { ScenarioInfo } from './parse';
|
|
13
|
+
|
|
14
|
+
export type Modality = 'MUST' | 'SHOULD' | 'MAY';
|
|
15
|
+
|
|
16
|
+
export interface TriggerGap { constraint: string; code: string; required: string[]; found: string[]; missing: string[] }
|
|
17
|
+
|
|
18
|
+
export interface SpecCoverageResult {
|
|
19
|
+
hasSpec: boolean;
|
|
20
|
+
frTotal: number;
|
|
21
|
+
frCovered: number;
|
|
22
|
+
uncoveredMust: { id: string; text: string }[];
|
|
23
|
+
triggerGaps: TriggerGap[]; // per-constraint trigger matrix gaps
|
|
24
|
+
verdict: 'pass' | 'warn' | 'fail';
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface FrClause { id: string; text: string; modality: Modality }
|
|
28
|
+
interface ValRow { constraint: string; code: string; triggers: string[] }
|
|
29
|
+
|
|
30
|
+
// Parsing the spec's Trigger CELL: loose word match ("blur, submit").
|
|
31
|
+
const SPEC_TRIGGER: { trigger: string; re: RegExp }[] = [
|
|
32
|
+
{ trigger: 'blur', re: /\b(blur|on blur|focus[- ]?out)\b/i },
|
|
33
|
+
{ trigger: 'submit', re: /\b(submit|on submit)\b/i },
|
|
34
|
+
];
|
|
35
|
+
// Detecting the trigger ACTION in a scenario: must be an actual action, NOT the word
|
|
36
|
+
// "submit" in a "[Submit] button is disabled" assertion.
|
|
37
|
+
const ACTION_TRIGGER: { trigger: string; re: RegExp }[] = [
|
|
38
|
+
{ trigger: 'blur', re: /\b(press tab|blur|focus[- ]?out|loses? focus|tab away|tab out)\b/i },
|
|
39
|
+
{ trigger: 'submit', re: /(click \[submit\]|press enter|\benter key\b|送信する)/i },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
function modalityOf(text: string): Modality {
|
|
43
|
+
if (/\bMUST\b/.test(text)) return 'MUST';
|
|
44
|
+
if (/\bSHOULD\b/.test(text)) return 'SHOULD';
|
|
45
|
+
return 'MAY';
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function specTriggersIn(text: string): string[] {
|
|
49
|
+
return SPEC_TRIGGER.filter((t) => t.re.test(text)).map((t) => t.trigger);
|
|
50
|
+
}
|
|
51
|
+
function actionTriggersIn(text: string): string[] {
|
|
52
|
+
return ACTION_TRIGGER.filter((t) => t.re.test(text)).map((t) => t.trigger);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function parseSpecClauses(specPath: string): { frs: FrClause[]; valRows: ValRow[] } {
|
|
56
|
+
if (!fs.existsSync(specPath)) return { frs: [], valRows: [] };
|
|
57
|
+
const lines = fs.readFileSync(specPath, 'utf-8').split('\n');
|
|
58
|
+
|
|
59
|
+
const frs: FrClause[] = [];
|
|
60
|
+
for (const line of lines) {
|
|
61
|
+
const m = line.match(/\*\*FR-(\d+)\*\*\s*:\s*(.+)$/);
|
|
62
|
+
if (m) frs.push({ id: `FR-${m[1]}`, text: m[2].replace(/\*\*/g, '').trim(), modality: modalityOf(m[2]) });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Validation Rules table: a row carries a Constraint, a Trigger cell, and (often) a code.
|
|
66
|
+
const valRows: ValRow[] = [];
|
|
67
|
+
let cTrigger = -1, cConstraint = -1, cCode = -1, inTable = false;
|
|
68
|
+
for (const raw of lines) {
|
|
69
|
+
const line = raw.trim();
|
|
70
|
+
if (line.startsWith('|') && /\btrigger\b/i.test(line) && cTrigger < 0) {
|
|
71
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
72
|
+
cTrigger = cells.findIndex((c) => /^trigger$/i.test(c));
|
|
73
|
+
cConstraint = cells.findIndex((c) => /constraint/i.test(c));
|
|
74
|
+
cCode = cells.findIndex((c) => /code/i.test(c));
|
|
75
|
+
inTable = cTrigger >= 0;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (inTable) {
|
|
79
|
+
if (!line.startsWith('|')) { inTable = false; cTrigger = -1; continue; }
|
|
80
|
+
if (/^\|[\s|:-]+\|?$/.test(line)) continue;
|
|
81
|
+
const cells = line.split('|').map((c) => c.trim());
|
|
82
|
+
const triggers = [...new Set((cells[cTrigger] || '').split(/,|\band\b/i).flatMap((p) => specTriggersIn(p)))];
|
|
83
|
+
if (!triggers.length) continue; // server-only rows etc.
|
|
84
|
+
const constraint = (cConstraint >= 0 ? cells[cConstraint] : '').replace(/`/g, '') || 'validation';
|
|
85
|
+
const codeM = (cCode >= 0 ? cells[cCode] : line).match(/M\d+/);
|
|
86
|
+
valRows.push({ constraint, code: codeM ? codeM[0] : '', triggers });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { frs, valRows };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Split a feature file into per-scenario blocks (tags + comments + steps), keyed by blank lines. */
|
|
93
|
+
function scenarioBlocks(featureText: string): string[] {
|
|
94
|
+
return featureText.split(/\n\s*\n/).filter((b) => /\bScenario:/.test(b)).map((b) => b.toLowerCase());
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featureText: string): SpecCoverageResult {
|
|
98
|
+
const { frs, valRows } = parseSpecClauses(specPath);
|
|
99
|
+
if (!fs.existsSync(specPath) || (frs.length === 0 && valRows.length === 0)) {
|
|
100
|
+
return { hasSpec: fs.existsSync(specPath), frTotal: 0, frCovered: 0, uncoveredMust: [], triggerGaps: [], verdict: 'pass' };
|
|
101
|
+
}
|
|
102
|
+
const featLower = featureText.toLowerCase();
|
|
103
|
+
|
|
104
|
+
// FR coverage: explicit @spec:FR / literal FR-id citation, else keyword fallback.
|
|
105
|
+
const uncoveredMust: { id: string; text: string }[] = [];
|
|
106
|
+
let frCovered = 0;
|
|
107
|
+
for (const fr of frs) {
|
|
108
|
+
const idLower = fr.id.toLowerCase();
|
|
109
|
+
const cited = featLower.includes(idLower);
|
|
110
|
+
const words = [...new Set((fr.text.toLowerCase().match(/[a-z][a-z-]{4,}/g) || []))]
|
|
111
|
+
.filter((w) => !/must|should|system|screen|users?|value|input|field/.test(w));
|
|
112
|
+
const kwHit = words.length > 0 && scenarios.some((s) => words.filter((w) => s.haystack.includes(w)).length >= Math.min(2, words.length));
|
|
113
|
+
if (cited || kwHit) frCovered++;
|
|
114
|
+
else if (fr.modality === 'MUST') uncoveredMust.push({ id: fr.id, text: fr.text.slice(0, 90) });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Per-constraint trigger coverage — the matrix-collapse catch.
|
|
118
|
+
// generic words that don't identify a constraint (would over-match happy-path scenarios)
|
|
119
|
+
const GENERIC = new Set(['valid', 'local', 'part', 'domain', 'email', 'address', 'must', 'exist', 'store', 'field', 'input', 'value', 'formed', 'well', 'character', 'characters']);
|
|
120
|
+
const blocks = scenarioBlocks(featureText);
|
|
121
|
+
const triggerGaps: TriggerGap[] = [];
|
|
122
|
+
for (const row of valRows) {
|
|
123
|
+
const kw = (row.constraint.toLowerCase().match(/[a-z]{4,}/g) || []).filter((w) => !GENERIC.has(w));
|
|
124
|
+
// blocks that belong to this constraint: cite its msg code (primary), or mention a
|
|
125
|
+
// DISTINCTIVE constraint word (generic words filtered to avoid matching happy paths).
|
|
126
|
+
const own = blocks.filter((b) =>
|
|
127
|
+
(row.code && b.includes(row.code.toLowerCase())) || kw.some((w) => b.includes(w)),
|
|
128
|
+
);
|
|
129
|
+
if (!own.length) continue; // constraint has no scenarios at all — FR/viewpoint gate covers that
|
|
130
|
+
const found = [...new Set(own.flatMap((b) => actionTriggersIn(b)))];
|
|
131
|
+
const missing = row.triggers.filter((t) => !found.includes(t));
|
|
132
|
+
if (missing.length) triggerGaps.push({ constraint: row.constraint, code: row.code, required: row.triggers, found, missing });
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const verdict: SpecCoverageResult['verdict'] =
|
|
136
|
+
uncoveredMust.length > 0 || triggerGaps.length > 0 ? 'fail' : 'pass';
|
|
137
|
+
|
|
138
|
+
return { hasSpec: true, frTotal: frs.length, frCovered, uncoveredMust, triggerGaps, verdict };
|
|
139
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* QA baseline audit over a legacy testcase inventory (P-A). Reuses the harness
|
|
3
|
+
* duplicate sensor + the capability-plan reason classifier so the legacy view and
|
|
4
|
+
* the Gherkin view agree. Advisory — evaluates the *existing* manual suite and the
|
|
5
|
+
* automation/driver opportunity; it does not gate anything.
|
|
6
|
+
*/
|
|
7
|
+
import { LegacyInventory, LegacyTestcase } from './legacy-parser';
|
|
8
|
+
import { duplicateClusters } from '../harness/sensors';
|
|
9
|
+
import { ScenarioInfo } from '../harness/parse';
|
|
10
|
+
import { classifyReason, MANUAL_REASONS } from '../harness/capability-plan';
|
|
11
|
+
|
|
12
|
+
export interface BaselineReport {
|
|
13
|
+
total: number;
|
|
14
|
+
sheets: { name: string; type: string; rows: number }[];
|
|
15
|
+
byCategory: Record<string, number>;
|
|
16
|
+
byPriority: Record<string, number>;
|
|
17
|
+
byResult: Record<string, number>;
|
|
18
|
+
depthRatio: number; // % of TC whose Expected asserts a concrete value
|
|
19
|
+
deepCount: number;
|
|
20
|
+
duplicateClusters: number;
|
|
21
|
+
exactDuplicates: number;
|
|
22
|
+
reasons: { // capability-plan style distribution
|
|
23
|
+
ui: number; crossScreen: number; capabilityManual: number; keepManual: number;
|
|
24
|
+
byCode: Record<string, number>;
|
|
25
|
+
driverCandidates: { driver: string; count: number }[];
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** An Expected that asserts a concrete value (not just "displayed/visible"). */
|
|
30
|
+
function isDeep(tc: LegacyTestcase): boolean {
|
|
31
|
+
const e = (tc.expected || '').trim().toLowerCase();
|
|
32
|
+
if (!e) return false;
|
|
33
|
+
const shallowOnly = /^(.{0,40})(表示される|hiển thị|displayed|shown|is visible|表示)$/.test(e);
|
|
34
|
+
const hasValue = /(=|equals?|bằng|giá trị|\bvalue\b|"|「|\bmessage\b|エラー|error|\bcount\b|số lượng|\bformat\b|định dạng)/.test(e);
|
|
35
|
+
return hasValue || (!shallowOnly && e.length > 30);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Synthesize a ScenarioInfo-lite so we can reuse harness sensors on the inventory. */
|
|
39
|
+
export function legacyToScenarioInfo(tc: LegacyTestcase): ScenarioInfo {
|
|
40
|
+
const skeleton = (tc.steps || '')
|
|
41
|
+
.replace(/\{\{[^}]*\}\}/g, '{}').replace(/"[^"]*"/g, '""')
|
|
42
|
+
.replace(/\d+/g, 'N').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
43
|
+
const hay = `${tc.category} ${tc.steps} ${tc.expected}`.toLowerCase();
|
|
44
|
+
return {
|
|
45
|
+
name: `${tc.category}: ${tc.id}`,
|
|
46
|
+
category: (tc.category || '').toUpperCase().replace(/[^A-Z0-9]+/g, '-').slice(0, 16),
|
|
47
|
+
priority: tc.priority,
|
|
48
|
+
manual: true,
|
|
49
|
+
thenCount: tc.expected ? 1 : 0,
|
|
50
|
+
hasDataAssertion: isDeep(tc),
|
|
51
|
+
shallow: !isDeep(tc),
|
|
52
|
+
stepSkeleton: skeleton,
|
|
53
|
+
haystack: hay,
|
|
54
|
+
} as ScenarioInfo;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function baselineAudit(inv: LegacyInventory): BaselineReport {
|
|
58
|
+
const tcs = inv.testcases;
|
|
59
|
+
const inc = (m: Record<string, number>, k: string) => { m[k] = (m[k] || 0) + 1; };
|
|
60
|
+
|
|
61
|
+
const byCategory: Record<string, number> = {};
|
|
62
|
+
const byPriority: Record<string, number> = {};
|
|
63
|
+
const byResult: Record<string, number> = {};
|
|
64
|
+
let deepCount = 0;
|
|
65
|
+
const byCode: Record<string, number> = {};
|
|
66
|
+
let ui = 0, crossScreen = 0, capabilityManual = 0, keepManual = 0;
|
|
67
|
+
const driverHits: Record<string, number> = {};
|
|
68
|
+
|
|
69
|
+
for (const tc of tcs) {
|
|
70
|
+
inc(byCategory, tc.category || '(uncategorized)');
|
|
71
|
+
inc(byPriority, tc.priority);
|
|
72
|
+
inc(byResult, (tc.result || '(not-run)').trim() || '(not-run)');
|
|
73
|
+
if (isDeep(tc)) deepCount++;
|
|
74
|
+
|
|
75
|
+
const code = classifyReason(`${tc.precondition || ''} ${tc.steps} ${tc.expected} ${tc.testData || ''}`);
|
|
76
|
+
if (!code) { ui++; continue; }
|
|
77
|
+
inc(byCode, code);
|
|
78
|
+
if (code === 'XS') { crossScreen++; continue; }
|
|
79
|
+
const def = MANUAL_REASONS[code];
|
|
80
|
+
if (def?.cls === 'keep') keepManual++;
|
|
81
|
+
else { capabilityManual++; for (const d of def?.drivers || []) driverHits[d] = (driverHits[d] || 0) + 1; }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const dup = duplicateClusters(tcs.map(legacyToScenarioInfo));
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
total: tcs.length,
|
|
88
|
+
sheets: inv.sheets,
|
|
89
|
+
byCategory, byPriority, byResult,
|
|
90
|
+
depthRatio: tcs.length ? deepCount / tcs.length : 0,
|
|
91
|
+
deepCount,
|
|
92
|
+
duplicateClusters: dup.clusters.length,
|
|
93
|
+
exactDuplicates: dup.exactDuplicateCount,
|
|
94
|
+
reasons: {
|
|
95
|
+
ui, crossScreen, capabilityManual, keepManual, byCode,
|
|
96
|
+
driverCandidates: Object.entries(driverHits).map(([driver, count]) => ({ driver, count }))
|
|
97
|
+
.sort((a, b) => b.count - a.count),
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fetch a Google Sheet's tabs as a sheet-bundle — runs under the USER's own Google
|
|
3
|
+
* identity (Application Default Credentials), read-only. This is NOT an AI context, so
|
|
4
|
+
* it is not subject to the "ineligible for generative AI contexts" DLP that blocks AI
|
|
5
|
+
* connectors; and read access (Viewer/Commenter is enough) is all it needs.
|
|
6
|
+
*
|
|
7
|
+
* `googleapis` is an OPTIONAL dependency (lazy-required) — the core install stays lean;
|
|
8
|
+
* users who want the Google fetch run `npm i googleapis` + authenticate once with
|
|
9
|
+
* `gcloud auth application-default login` (or set GOOGLE_APPLICATION_CREDENTIALS).
|
|
10
|
+
*/
|
|
11
|
+
import * as path from 'path';
|
|
12
|
+
import * as readline from 'readline';
|
|
13
|
+
import { execSync } from 'child_process';
|
|
14
|
+
import { RawSheet } from './legacy-parser';
|
|
15
|
+
|
|
16
|
+
export interface SheetBundle { source: string; sheets: RawSheet[] }
|
|
17
|
+
|
|
18
|
+
const SCOPE = 'https://www.googleapis.com/auth/spreadsheets.readonly';
|
|
19
|
+
|
|
20
|
+
/** Accept a full Sheets URL or a bare spreadsheet ID. */
|
|
21
|
+
export function parseSpreadsheetId(idOrUrl: string): string {
|
|
22
|
+
const m = idOrUrl.match(/\/spreadsheets\/d\/([a-zA-Z0-9_-]+)/);
|
|
23
|
+
return m ? m[1] : idOrUrl.trim();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function ask(prompt: string): Promise<string> {
|
|
27
|
+
return new Promise((resolve) => {
|
|
28
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
rl.question(prompt, (a) => { rl.close(); resolve(a.trim()); });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Load `googleapis`; if missing, ask to install it (default Yes) and install into the
|
|
34
|
+
* sungen package dir, then load it. Throws with manual instructions if declined/failed. */
|
|
35
|
+
async function ensureGoogleapis(): Promise<any> {
|
|
36
|
+
try {
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
38
|
+
return require('googleapis').google;
|
|
39
|
+
} catch { /* not installed → offer to install */ }
|
|
40
|
+
|
|
41
|
+
const manual = '\n Install it manually: npm i googleapis\n' +
|
|
42
|
+
' Then authenticate: gcloud auth application-default login --scopes=' + SCOPE;
|
|
43
|
+
|
|
44
|
+
if (!process.stdin.isTTY) {
|
|
45
|
+
throw new Error('`--gsheet` needs the optional dependency `googleapis` (non-interactive shell — cannot prompt).' + manual);
|
|
46
|
+
}
|
|
47
|
+
const answer = (await ask(' `--gsheet` needs the `googleapis` package (read-only Google Sheets). Install it now? [Y/n] ')).toLowerCase();
|
|
48
|
+
if (answer && !/^y(es)?$/.test(answer)) {
|
|
49
|
+
throw new Error('Skipped install — `--gsheet` needs `googleapis`.' + manual);
|
|
50
|
+
}
|
|
51
|
+
const pkgDir = path.resolve(__dirname, '..', '..'); // dist/ingest|src/ingest → package root
|
|
52
|
+
console.log(' Installing googleapis (local to sungen, not saved to your project)…');
|
|
53
|
+
try {
|
|
54
|
+
// --no-save: keep sungen's core lean; this machine gets googleapis without declaring a dep.
|
|
55
|
+
execSync('npm install googleapis --no-save', { cwd: pkgDir, stdio: 'inherit' });
|
|
56
|
+
} catch {
|
|
57
|
+
throw new Error('npm install googleapis failed (permissions?).' + manual);
|
|
58
|
+
}
|
|
59
|
+
// Node caches the negative module lookup from process start, so requiring a just-installed
|
|
60
|
+
// package in THIS process is unreliable. Ask for a clean re-run (next process finds it).
|
|
61
|
+
try {
|
|
62
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
63
|
+
return require(require.resolve('googleapis', { paths: [pkgDir] })).google;
|
|
64
|
+
} catch {
|
|
65
|
+
console.log('\n ✓ googleapis installed. Please re-run the same `sungen ingest --gsheet …` command.\n');
|
|
66
|
+
process.exit(0);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Open the Google login in the browser (via gcloud ADC) so the user picks THEIR
|
|
71
|
+
* account, then store read-only credentials. Returns false if gcloud is unavailable. */
|
|
72
|
+
async function tryInteractiveLogin(): Promise<boolean> {
|
|
73
|
+
try {
|
|
74
|
+
execSync('gcloud --version', { stdio: 'ignore' });
|
|
75
|
+
} catch {
|
|
76
|
+
console.log('\n (gcloud not found — install the Google Cloud SDK, or export the sheet to .xlsx and use --legacy.)');
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
const ans = (await ask(' Open Google login in your browser now (pick the account that can see the sheet)? [Y/n] ')).toLowerCase();
|
|
80
|
+
if (ans && !/^y(es)?$/.test(ans)) return false;
|
|
81
|
+
try {
|
|
82
|
+
execSync(`gcloud auth application-default login --scopes=${SCOPE},https://www.googleapis.com/auth/cloud-platform`, { stdio: 'inherit' });
|
|
83
|
+
return true;
|
|
84
|
+
} catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function fetchGoogleSheet(idOrUrl: string): Promise<SheetBundle> {
|
|
90
|
+
const google = await ensureGoogleapis();
|
|
91
|
+
const spreadsheetId = parseSpreadsheetId(idOrUrl);
|
|
92
|
+
const doFetch = async () => {
|
|
93
|
+
// ADC: the user's own credentials (gcloud ADC). Fresh client each attempt so a
|
|
94
|
+
// just-completed login is picked up.
|
|
95
|
+
const auth = new google.auth.GoogleAuth({ scopes: [SCOPE] });
|
|
96
|
+
const sheets = google.sheets({ version: 'v4', auth });
|
|
97
|
+
const m = await sheets.spreadsheets.get({ spreadsheetId, fields: 'properties.title,sheets.properties.title' });
|
|
98
|
+
const tabs: string[] = (m.data?.sheets || []).map((s: any) => s.properties.title);
|
|
99
|
+
const r = await sheets.spreadsheets.values.batchGet({ spreadsheetId, ranges: tabs, majorDimension: 'ROWS' });
|
|
100
|
+
return { m, r };
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
let meta: any, resp: any;
|
|
104
|
+
try {
|
|
105
|
+
({ m: meta, r: resp } = await doFetch());
|
|
106
|
+
} catch (e: any) {
|
|
107
|
+
const msg = String(e?.message || e);
|
|
108
|
+
const noCreds = /default credentials|could not load|invalid_grant|reauth|unauthenticated/i.test(msg);
|
|
109
|
+
// Tool-driven browser login: on missing/expired creds, offer to open the Google
|
|
110
|
+
// login (gcloud ADC) so the user picks THEIR account, then retry once.
|
|
111
|
+
if (noCreds && process.stdin.isTTY && await tryInteractiveLogin()) {
|
|
112
|
+
({ m: meta, r: resp } = await doFetch());
|
|
113
|
+
} else if (noCreds) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
'Google login needed (no usable credentials for your account).\n' +
|
|
116
|
+
' Run: gcloud auth application-default login --scopes=' + SCOPE + '\n' +
|
|
117
|
+
' (opens a browser — pick the account that can see the sheet). Then re-run.\n' +
|
|
118
|
+
' No gcloud? Export the sheet to .xlsx and use --legacy instead.',
|
|
119
|
+
);
|
|
120
|
+
} else if (/access_denied|app is blocked|blocked this access|disallowed_useragent|admin|policy|org_internal|403/i.test(msg)) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
'Blocked by your organization\'s Google Workspace policy (admin App-access control):\n' +
|
|
123
|
+
' "' + msg.split('\n')[0] + '"\n' +
|
|
124
|
+
' Your org blocks OAuth apps from reading Sheets (a sensitive scope). This affects gcloud,\n' +
|
|
125
|
+
' a custom OAuth app, AND the AI connector equally — it is an admin decision, not a sungen bug.\n' +
|
|
126
|
+
' → Use the local path instead: have an owner export the sheet to .xlsx and run\n' +
|
|
127
|
+
' sungen ingest --legacy <file>.xlsx\n' +
|
|
128
|
+
' (no API, no admin needed). Or ask a Workspace admin to trust an app for the\n' +
|
|
129
|
+
' spreadsheets.readonly scope (Admin Console → Security → API controls).',
|
|
130
|
+
);
|
|
131
|
+
} else if (/no access|permission|not found|insufficient|API has not been used/i.test(msg)) {
|
|
132
|
+
throw new Error(
|
|
133
|
+
'Google access failed: ' + msg.split('\n')[0] + '\n' +
|
|
134
|
+
' Ensure the logged-in account has Viewer/Commenter on the sheet and the Sheets API is enabled.\n' +
|
|
135
|
+
' If your org blocks programmatic access to this file, ask an owner to export .xlsx → use --legacy.',
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
throw e;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const title: string = meta.data?.properties?.title || spreadsheetId;
|
|
143
|
+
const tabNames: string[] = (meta.data?.sheets || []).map((s: any) => s.properties.title);
|
|
144
|
+
const ranges: any[] = resp.data?.valueRanges || [];
|
|
145
|
+
|
|
146
|
+
const out: RawSheet[] = tabNames.map((name, i) => ({
|
|
147
|
+
name,
|
|
148
|
+
rows: (ranges[i]?.values || []).map((row: any[]) => row.map((c) => (c == null ? '' : String(c)))),
|
|
149
|
+
}));
|
|
150
|
+
|
|
151
|
+
return { source: title, sheets: out };
|
|
152
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy testcase ingest (P-A) — parse a manual testcase workbook (CSV or XLSX)
|
|
3
|
+
* into a normalized inventory. Deterministic: pure function of the input file
|
|
4
|
+
* (no network, no MCP). The AI skill handles the Google-Sheets/MCP fetch upstream.
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
|
|
9
|
+
export type SheetType = 'testcase' | 'viewpoint-matrix' | 'ui-checklist' | 'unknown';
|
|
10
|
+
export type Priority = 'high' | 'normal' | 'low' | 'unknown';
|
|
11
|
+
|
|
12
|
+
export interface LegacyTestcase {
|
|
13
|
+
id: string;
|
|
14
|
+
page: string;
|
|
15
|
+
category: string;
|
|
16
|
+
subCategory?: string;
|
|
17
|
+
precondition?: string;
|
|
18
|
+
testData?: string;
|
|
19
|
+
steps: string;
|
|
20
|
+
expected: string;
|
|
21
|
+
priority: Priority;
|
|
22
|
+
type?: string;
|
|
23
|
+
result?: string;
|
|
24
|
+
sheet: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SheetInfo { name: string; type: SheetType; rows: number }
|
|
28
|
+
|
|
29
|
+
export interface LegacyInventory {
|
|
30
|
+
source: { file: string };
|
|
31
|
+
sheets: SheetInfo[];
|
|
32
|
+
testcases: LegacyTestcase[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ---- quote-aware CSV parser (embedded commas + newlines) ----
|
|
36
|
+
export function parseCSV(text: string): string[][] {
|
|
37
|
+
const rows: string[][] = []; let row: string[] = [], field = '', q = false;
|
|
38
|
+
for (let i = 0; i < text.length; i++) {
|
|
39
|
+
const c = text[i];
|
|
40
|
+
if (q) {
|
|
41
|
+
if (c === '"') { if (text[i + 1] === '"') { field += '"'; i++; } else q = false; }
|
|
42
|
+
else field += c;
|
|
43
|
+
} else if (c === '"') q = true;
|
|
44
|
+
else if (c === ',') { row.push(field); field = ''; }
|
|
45
|
+
else if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; }
|
|
46
|
+
else if (c !== '\r') field += c;
|
|
47
|
+
}
|
|
48
|
+
if (field.length || row.length) { row.push(field); rows.push(row); }
|
|
49
|
+
return rows;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const norm = (s: string | undefined) => (s || '').replace(/\*/g, '').trim().toLowerCase();
|
|
53
|
+
|
|
54
|
+
function detectSheetType(rows: string[][]): { type: SheetType; headerRow: number } {
|
|
55
|
+
for (let i = 0; i < Math.min(rows.length, 40); i++) {
|
|
56
|
+
const cells = rows[i].map(norm);
|
|
57
|
+
if (cells.some((c) => /^tc\s*id$/.test(c)) && cells.some((c) => /steps?|expected/.test(c)))
|
|
58
|
+
return { type: 'testcase', headerRow: i };
|
|
59
|
+
if (cells.some((c) => /item type/.test(c)) || (cells[0] === 'id' && cells.some((c) => /^item/.test(c))))
|
|
60
|
+
return { type: 'ui-checklist', headerRow: i };
|
|
61
|
+
if (cells.some((c) => /^id\/function$/.test(c)) || cells.some((c) => /viewpoint/.test(c)))
|
|
62
|
+
return { type: 'viewpoint-matrix', headerRow: i };
|
|
63
|
+
}
|
|
64
|
+
return { type: 'unknown', headerRow: -1 };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function colMap(header: string[]): Record<string, number> {
|
|
68
|
+
const m: Record<string, number> = {};
|
|
69
|
+
header.forEach((h, i) => { const k = norm(h); if (k && !(k in m)) m[k] = i; });
|
|
70
|
+
return m;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function toPriority(raw: string): Priority {
|
|
74
|
+
const p = (raw || '').toLowerCase();
|
|
75
|
+
if (/high|cao/.test(p)) return 'high';
|
|
76
|
+
if (/low|thấp/.test(p)) return 'low';
|
|
77
|
+
if (/medium|normal|trung/.test(p)) return 'normal';
|
|
78
|
+
return 'unknown';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function parseTestcaseSheet(rows: string[][], headerRow: number, sheet: string): LegacyTestcase[] {
|
|
82
|
+
const cm = colMap(rows[headerRow]);
|
|
83
|
+
if (cm['tc id'] == null) return [];
|
|
84
|
+
const col = (r: string[], key: string) => (cm[key] != null ? (r[cm[key]] || '').trim() : '');
|
|
85
|
+
const out: LegacyTestcase[] = [];
|
|
86
|
+
// Fill-down ONLY the grouping columns (genuinely merged across rows). Precondition is
|
|
87
|
+
// kept PER-ROW: filling it down would bleed one row's "needs API/DB/login" into unrelated
|
|
88
|
+
// rows and falsely inflate the capability/driver signal.
|
|
89
|
+
const fd = { page: '', category: '', sub: '' };
|
|
90
|
+
for (let i = headerRow + 1; i < rows.length; i++) {
|
|
91
|
+
const r = rows[i];
|
|
92
|
+
const id = col(r, 'tc id');
|
|
93
|
+
if (!id || /^(total|module|test environment)/i.test(id)) continue;
|
|
94
|
+
fd.page = col(r, 'page name') || fd.page;
|
|
95
|
+
fd.category = col(r, 'category') || fd.category;
|
|
96
|
+
fd.sub = col(r, 'sub-category') || fd.sub;
|
|
97
|
+
const steps = col(r, 'steps');
|
|
98
|
+
const expected = col(r, 'expected results') || col(r, 'expected result');
|
|
99
|
+
if (!steps && !expected) continue; // skip noise / spacer rows
|
|
100
|
+
out.push({
|
|
101
|
+
id, sheet, page: fd.page, category: fd.category || '(uncategorized)',
|
|
102
|
+
subCategory: fd.sub || undefined, precondition: col(r, 'pre-condition') || undefined,
|
|
103
|
+
testData: col(r, 'test data') || undefined, steps, expected,
|
|
104
|
+
priority: toPriority(col(r, 'priority')),
|
|
105
|
+
type: col(r, 'testcase type') || undefined,
|
|
106
|
+
result: col(r, 'test result') || col(r, 'test result\nchrome (100%)') || undefined,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export interface RawSheet { name: string; rows: string[][] }
|
|
113
|
+
|
|
114
|
+
/** Load sheets from one input file: CSV (1 sheet), XLSX (all sheets), or a JSON
|
|
115
|
+
* sheet-bundle (the shape the Google-Sheets/MCP skill produces). */
|
|
116
|
+
async function loadSheets(filePath: string): Promise<RawSheet[]> {
|
|
117
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
118
|
+
if (ext === '.csv') {
|
|
119
|
+
return [{ name: path.basename(filePath, '.csv'), rows: parseCSV(fs.readFileSync(filePath, 'utf8')) }];
|
|
120
|
+
}
|
|
121
|
+
if (ext === '.json') {
|
|
122
|
+
// Bundle shapes: { sheets: [{name, rows}] } | [{name, rows}] | { "<tab>": [[..]] }
|
|
123
|
+
const data = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
124
|
+
const arr = Array.isArray(data) ? data : Array.isArray(data.sheets) ? data.sheets : null;
|
|
125
|
+
if (arr) return arr.map((s: any) => ({ name: String(s.name), rows: s.rows as string[][] }));
|
|
126
|
+
if (data && typeof data === 'object')
|
|
127
|
+
return Object.entries(data).map(([name, rows]) => ({ name, rows: rows as string[][] }));
|
|
128
|
+
throw new Error('Invalid JSON sheet-bundle: expected { sheets: [{name, rows}] }');
|
|
129
|
+
}
|
|
130
|
+
if (ext === '.xlsx') {
|
|
131
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
132
|
+
const ExcelJS = require('exceljs');
|
|
133
|
+
const wb = new ExcelJS.Workbook();
|
|
134
|
+
await wb.xlsx.readFile(filePath);
|
|
135
|
+
const out: RawSheet[] = [];
|
|
136
|
+
wb.eachSheet((ws: any) => {
|
|
137
|
+
const rows: string[][] = [];
|
|
138
|
+
ws.eachRow((row: any) => {
|
|
139
|
+
const cells: string[] = [];
|
|
140
|
+
row.eachCell({ includeEmpty: true }, (cell: any) => { cells.push(cell.text != null ? String(cell.text) : ''); });
|
|
141
|
+
rows.push(cells);
|
|
142
|
+
});
|
|
143
|
+
out.push({ name: ws.name, rows });
|
|
144
|
+
});
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
throw new Error(`Unsupported file type: ${ext} (use .csv, .xlsx, or a .json sheet-bundle)`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Classify sheets without parsing testcases — for `--list-sheets`. */
|
|
151
|
+
export async function listSheets(filePaths: string | string[]): Promise<SheetInfo[]> {
|
|
152
|
+
const files = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
153
|
+
const out: SheetInfo[] = [];
|
|
154
|
+
for (const f of files) {
|
|
155
|
+
for (const s of await loadSheets(f)) {
|
|
156
|
+
const { type } = detectSheetType(s.rows);
|
|
157
|
+
out.push({ name: s.name, type, rows: s.rows.length });
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Parse one or more inputs (CSV/XLSX/JSON-bundle) into an inventory. Optionally
|
|
164
|
+
* restrict to a set of tab names (`onlySheets`) — workbooks carry many tabs. */
|
|
165
|
+
export async function parseLegacyFile(filePaths: string | string[], onlySheets?: string[]): Promise<LegacyInventory> {
|
|
166
|
+
const files = Array.isArray(filePaths) ? filePaths : [filePaths];
|
|
167
|
+
const want = onlySheets && onlySheets.length ? new Set(onlySheets.map((s) => s.trim().toLowerCase())) : null;
|
|
168
|
+
|
|
169
|
+
const inv: LegacyInventory = {
|
|
170
|
+
source: { file: files.map((f) => path.basename(f)).join(', ') },
|
|
171
|
+
sheets: [], testcases: [],
|
|
172
|
+
};
|
|
173
|
+
for (const f of files) {
|
|
174
|
+
for (const s of await loadSheets(f)) {
|
|
175
|
+
if (want && !want.has(s.name.trim().toLowerCase())) continue;
|
|
176
|
+
const { type, headerRow } = detectSheetType(s.rows);
|
|
177
|
+
let tcs: LegacyTestcase[] = [];
|
|
178
|
+
if (type === 'testcase' && headerRow >= 0) tcs = parseTestcaseSheet(s.rows, headerRow, s.name);
|
|
179
|
+
inv.sheets.push({ name: s.name, type, rows: tcs.length || s.rows.length });
|
|
180
|
+
inv.testcases.push(...tcs);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return inv;
|
|
184
|
+
}
|