@sun-asterisk/sungen 3.2.16-beta.7 → 3.2.16-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/exporters/matrix/build.d.ts.map +1 -1
  2. package/dist/exporters/matrix/build.js +17 -3
  3. package/dist/exporters/matrix/build.js.map +1 -1
  4. package/dist/exporters/matrix/gates.js +6 -1
  5. package/dist/exporters/matrix/gates.js.map +1 -1
  6. package/dist/exporters/matrix/types.d.ts +3 -0
  7. package/dist/exporters/matrix/types.d.ts.map +1 -1
  8. package/dist/exporters/matrix/types.js.map +1 -1
  9. package/dist/exporters/matrix/wording.d.ts +10 -0
  10. package/dist/exporters/matrix/wording.d.ts.map +1 -1
  11. package/dist/exporters/matrix/wording.js +52 -0
  12. package/dist/exporters/matrix/wording.js.map +1 -1
  13. package/dist/harness/audit.d.ts.map +1 -1
  14. package/dist/harness/audit.js +11 -2
  15. package/dist/harness/audit.js.map +1 -1
  16. package/dist/harness/blindspot.d.ts.map +1 -1
  17. package/dist/harness/blindspot.js +2 -1
  18. package/dist/harness/blindspot.js.map +1 -1
  19. package/dist/harness/capability-plan.d.ts.map +1 -1
  20. package/dist/harness/capability-plan.js +3 -2
  21. package/dist/harness/capability-plan.js.map +1 -1
  22. package/dist/harness/feedback.d.ts.map +1 -1
  23. package/dist/harness/feedback.js +3 -2
  24. package/dist/harness/feedback.js.map +1 -1
  25. package/dist/harness/flow-check.d.ts.map +1 -1
  26. package/dist/harness/flow-check.js +2 -1
  27. package/dist/harness/flow-check.js.map +1 -1
  28. package/dist/harness/flow-plan.d.ts.map +1 -1
  29. package/dist/harness/flow-plan.js +3 -2
  30. package/dist/harness/flow-plan.js.map +1 -1
  31. package/dist/harness/intent.d.ts.map +1 -1
  32. package/dist/harness/intent.js +2 -1
  33. package/dist/harness/intent.js.map +1 -1
  34. package/dist/harness/journey.d.ts.map +1 -1
  35. package/dist/harness/journey.js +3 -2
  36. package/dist/harness/journey.js.map +1 -1
  37. package/dist/harness/ledger.d.ts.map +1 -1
  38. package/dist/harness/ledger.js +3 -2
  39. package/dist/harness/ledger.js.map +1 -1
  40. package/dist/harness/manifest.d.ts.map +1 -1
  41. package/dist/harness/manifest.js +4 -3
  42. package/dist/harness/manifest.js.map +1 -1
  43. package/dist/harness/parse.d.ts.map +1 -1
  44. package/dist/harness/parse.js +16 -3
  45. package/dist/harness/parse.js.map +1 -1
  46. package/dist/harness/quality-gates.d.ts.map +1 -1
  47. package/dist/harness/quality-gates.js +2 -1
  48. package/dist/harness/quality-gates.js.map +1 -1
  49. package/dist/harness/read-text.d.ts +22 -0
  50. package/dist/harness/read-text.d.ts.map +1 -0
  51. package/dist/harness/read-text.js +64 -0
  52. package/dist/harness/read-text.js.map +1 -0
  53. package/dist/harness/script-check.d.ts.map +1 -1
  54. package/dist/harness/script-check.js +3 -2
  55. package/dist/harness/script-check.js.map +1 -1
  56. package/dist/harness/sensors.d.ts.map +1 -1
  57. package/dist/harness/sensors.js +2 -10
  58. package/dist/harness/sensors.js.map +1 -1
  59. package/dist/harness/spec-coverage.d.ts +5 -0
  60. package/dist/harness/spec-coverage.d.ts.map +1 -1
  61. package/dist/harness/spec-coverage.js +17 -7
  62. package/dist/harness/spec-coverage.js.map +1 -1
  63. package/dist/harness/trace.d.ts.map +1 -1
  64. package/dist/harness/trace.js +4 -3
  65. package/dist/harness/trace.js.map +1 -1
  66. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  67. package/dist/harness/viewpoint-ledger.js +2 -1
  68. package/dist/harness/viewpoint-ledger.js.map +1 -1
  69. package/dist/orchestrator/templates/ai-src/commands/create-test.md +9 -0
  70. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -0
  71. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  72. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
  73. package/package.json +4 -4
  74. package/src/exporters/matrix/build.ts +24 -4
  75. package/src/exporters/matrix/gates.ts +6 -1
  76. package/src/exporters/matrix/types.ts +3 -0
  77. package/src/exporters/matrix/wording.ts +47 -0
  78. package/src/harness/audit.ts +11 -2
  79. package/src/harness/blindspot.ts +2 -1
  80. package/src/harness/capability-plan.ts +3 -2
  81. package/src/harness/feedback.ts +3 -2
  82. package/src/harness/flow-check.ts +2 -1
  83. package/src/harness/flow-plan.ts +3 -2
  84. package/src/harness/intent.ts +2 -1
  85. package/src/harness/journey.ts +3 -2
  86. package/src/harness/ledger.ts +3 -2
  87. package/src/harness/manifest.ts +4 -3
  88. package/src/harness/parse.ts +17 -3
  89. package/src/harness/quality-gates.ts +2 -1
  90. package/src/harness/read-text.ts +28 -0
  91. package/src/harness/script-check.ts +3 -2
  92. package/src/harness/sensors.ts +2 -1
  93. package/src/harness/spec-coverage.ts +22 -7
  94. package/src/harness/trace.ts +4 -3
  95. package/src/harness/viewpoint-ledger.ts +2 -1
  96. package/src/orchestrator/templates/ai-src/commands/create-test.md +9 -0
  97. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +5 -0
  98. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  99. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +22 -0
@@ -16,6 +16,7 @@ import * as fs from 'fs';
16
16
  import * as path from 'path';
17
17
  import * as crypto from 'crypto';
18
18
  import { reportSlug } from './unit-paths';
19
+ import { readTextFile } from './read-text';
19
20
 
20
21
  export type ObStatus = 'satisfied' | 'needs-work' | 'pending' | 'waived';
21
22
 
@@ -44,13 +45,13 @@ export interface JourneyReport {
44
45
  }
45
46
 
46
47
  function readJSON(p: string): any {
47
- try { return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null; } catch { return null; }
48
+ try { return fs.existsSync(p) ? JSON.parse(readTextFile(p)) : null; } catch { return null; }
48
49
  }
49
50
 
50
51
  function readLedgerPhases(p: string): string[] {
51
52
  if (!fs.existsSync(p)) return [];
52
53
  const out: string[] = [];
53
- for (const line of fs.readFileSync(p, 'utf-8').split('\n')) {
54
+ for (const line of readTextFile(p).split('\n')) {
54
55
  if (!line.trim()) continue;
55
56
  try { const d = JSON.parse(line); if (d.step) out.push(String(d.step)); } catch { /* skip */ }
56
57
  }
@@ -14,6 +14,7 @@
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import { reportSlug } from './unit-paths';
17
+ import { readTextFile } from './read-text';
17
18
 
18
19
  export interface LedgerEvent {
19
20
  ts: string;
@@ -75,7 +76,7 @@ export function recordEvent(screen: string, ev: Omit<LedgerEvent, 'ts'> & { ts?:
75
76
  export function readEvents(screen: string): LedgerEvent[] {
76
77
  const p = ledgerPath(screen);
77
78
  if (!fs.existsSync(p)) return [];
78
- return fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
79
+ return readTextFile(p).split('\n').filter(Boolean).map((l) => JSON.parse(l));
79
80
  }
80
81
 
81
82
  export interface LedgerReport {
@@ -121,7 +122,7 @@ export function buildReport(screen: string, opts: { allRuns?: boolean } = {}): L
121
122
  const auditPath = path.join(process.cwd(), '.sungen', 'reports', `${reportSlug(screen)}-audit.json`);
122
123
  if (fs.existsSync(auditPath)) {
123
124
  try {
124
- const a = JSON.parse(fs.readFileSync(auditPath, 'utf-8'));
125
+ const a = JSON.parse(readTextFile(auditPath));
125
126
  coveredCritical = a?.gate?.themesCovered ?? null;
126
127
  scenarioCount = a?.scenarioCount ?? null;
127
128
  } catch { /* ignore */ }
@@ -12,6 +12,7 @@ import * as fs from 'fs';
12
12
  import * as path from 'path';
13
13
  import { createHash } from 'crypto';
14
14
  import { featureBasename, reportSlug } from './unit-paths';
15
+ import { readTextFile } from './read-text';
15
16
 
16
17
  export interface SpecSection { name: string; hash: string }
17
18
  export interface ManifestEntry { scenario: string; vpCode?: string; section: string; specHash: string }
@@ -59,7 +60,7 @@ function matchSection(featureSection: string, specNames: string[]): string | nul
59
60
  /** Parse spec.md into hashable sections (## and ### headings; "Section:" prefix stripped). */
60
61
  export function parseSpecSections(specPath: string): SpecSection[] {
61
62
  if (!fs.existsSync(specPath)) return [];
62
- const lines = fs.readFileSync(specPath, 'utf-8').split('\n');
63
+ const lines = readTextFile(specPath).split('\n');
63
64
  const sections: { name: string; body: string[] }[] = [];
64
65
  let cur: { name: string; body: string[] } | null = null;
65
66
  for (const line of lines) {
@@ -79,7 +80,7 @@ export function parseSpecSections(specPath: string): SpecSection[] {
79
80
  /** Parse feature into scenario → section mapping using `# --- Section: X ---` comments. */
80
81
  function parseFeatureSections(featurePath: string): { scenario: string; vpCode?: string; section: string }[] {
81
82
  if (!fs.existsSync(featurePath)) return [];
82
- const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
83
+ const lines = readTextFile(featurePath).split('\n');
83
84
  const out: { scenario: string; vpCode?: string; section: string }[] = [];
84
85
  let section = '(unsectioned)';
85
86
  for (const raw of lines) {
@@ -164,7 +165,7 @@ export function manifestPath(screenName: string): string {
164
165
  }
165
166
  export function loadManifest(screenName: string): Manifest | null {
166
167
  const p = manifestPath(screenName);
167
- return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null;
168
+ return fs.existsSync(p) ? JSON.parse(readTextFile(p)) : null;
168
169
  }
169
170
  export function saveManifest(m: Manifest): string {
170
171
  const p = manifestPath(m.screen);
@@ -7,6 +7,7 @@
7
7
  */
8
8
  import * as fs from 'fs';
9
9
  import { GherkinParser, ParsedScenario, ParsedStep } from '../generators/gherkin-parser';
10
+ import { readTextFile } from './read-text';
10
11
 
11
12
  export type Priority = 'high' | 'normal' | 'low' | 'unknown';
12
13
 
@@ -51,9 +52,22 @@ export function idPrefix(id: string): string {
51
52
 
52
53
  // ---------- test-viewpoint.md ----------
53
54
 
55
+ /**
56
+ * A viewpoint OVERVIEW id may be a bare category (`VP-LOGIC`, `VP-SEC`) — that is
57
+ * the scheme sungen itself prescribes, with the sequence number living on the
58
+ * scenario (`VP-LOGIC-013`). `isIdLike` demands a digit to avoid picking prose out
59
+ * of tables, which silently rejected every category id: a project declaring
60
+ * VP-LOGIC/VP-VAL/VP-SEC/VP-UI/VP-NAV/VP-PERF/VP-I18N parsed as ONE viewpoint
61
+ * (only VP-I18N, for the "18"), traceability collapsed to ~0%, and the audit told
62
+ * QA to re-tag scenarios that were already correct.
63
+ */
64
+ function isViewpointId(s: string): boolean {
65
+ return isIdLike(s) || /^VP-[A-Z][A-Z0-9]*$/i.test(s);
66
+ }
67
+
54
68
  export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
55
69
  if (!fs.existsSync(filePath)) return [];
56
- const text = fs.readFileSync(filePath, 'utf-8');
70
+ const text = readTextFile(filePath);
57
71
  const lines = text.split('\n');
58
72
 
59
73
  const entries = new Map<string, ViewpointEntry>();
@@ -68,7 +82,7 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
68
82
  const cells = line.split('|').map((c) => c.trim()).filter((_, i, a) => i > 0 && i < a.length - 1);
69
83
  if (cells.length >= 3) {
70
84
  const id = cells[0];
71
- if (isIdLike(id) && !/^-+$/.test(cells[1])) {
85
+ if (isViewpointId(id) && !/^-+$/.test(cells[1])) {
72
86
  const pr = /high/i.test(cells[1]) ? 'High' : /medium/i.test(cells[1]) ? 'Medium' : /low/i.test(cells[1]) ? 'Low' : 'Unknown';
73
87
  entries.set(id.toUpperCase(), { id: id.toUpperCase(), priority: pr as any, reason: cells[2] });
74
88
  }
@@ -85,7 +99,7 @@ export function parseViewpointOverview(filePath: string): ViewpointEntry[] {
85
99
  if (/^##\s/.test(line)) { group = undefined; }
86
100
  if (group) {
87
101
  const m = line.match(/^[-*+]\s+([A-Za-z][A-Za-z0-9.-]*)/);
88
- if (m && isIdLike(m[1])) {
102
+ if (m && isViewpointId(m[1])) {
89
103
  const id = m[1].toUpperCase();
90
104
  const existing = entries.get(id);
91
105
  if (existing) existing.group = group;
@@ -7,6 +7,7 @@ import * as fs from 'fs';
7
7
  import * as path from 'path';
8
8
  import { ScenarioInfo, loadScenarios, idPrefix } from './parse';
9
9
  import { parseManualComments } from '../exporters/scenario-merger';
10
+ import { readTextFile } from './read-text';
10
11
 
11
12
  // ---------- #2 Downstream-scope ----------
12
13
 
@@ -202,5 +203,5 @@ export function crossArtifactOwnership(screenDir: string, scenarios: ScenarioInf
202
203
 
203
204
  // convenience reader
204
205
  export function readText(p: string): string {
205
- return fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : '';
206
+ return fs.existsSync(p) ? readTextFile(p) : '';
206
207
  }
@@ -0,0 +1,28 @@
1
+ import * as fs from 'fs';
2
+
3
+ /**
4
+ * Read a user-authored text file with line endings NORMALISED to `\n`.
5
+ *
6
+ * Why this exists: the harness parses spec.md / test-viewpoint.md / .feature with
7
+ * line-anchored regexes such as `/^\s*Scenario:\s*(.+)$/`. In JavaScript `.` does
8
+ * NOT match `\r`, so on a CRLF file `(.+)$` can never reach the end of the line
9
+ * and the match silently fails. The parser then reports "nothing found" rather
10
+ * than an error, which is far worse than crashing:
11
+ *
12
+ * - `spec-coverage` found 0 requirements → the FR sensor scored a vacuous
13
+ * 100% and never emitted SPEC-UNCOVERED / SPEC-TRACE-IMPLICIT.
14
+ * - `parseViewpointOverview` found 1 of N viewpoints → traceability collapsed
15
+ * to ~0% and the audit told QA to "re-tag" scenarios that were already
16
+ * correct.
17
+ *
18
+ * A Windows-authored spec is completely normal, so every harness parser that
19
+ * reads a user file must go through here.
20
+ */
21
+ export function readTextFile(filePath: string): string {
22
+ return fs.readFileSync(filePath, 'utf-8').replace(/\r\n?/g, '\n');
23
+ }
24
+
25
+ /** `readTextFile` split into lines — the common case for line-anchored parsers. */
26
+ export function readTextLines(filePath: string): string[] {
27
+ return readTextFile(filePath).split('\n');
28
+ }
@@ -17,6 +17,7 @@ import * as path from 'path';
17
17
  import * as os from 'os';
18
18
  import { loadScenarios, ScenarioInfo } from './parse';
19
19
  import { featureBasename } from './unit-paths';
20
+ import { readTextFile } from './read-text';
20
21
 
21
22
  export interface ScriptCheckResult {
22
23
  screen: string;
@@ -153,7 +154,7 @@ export async function runScriptCheck(screenDir: string, screenName: string, kind
153
154
  let specTitles: string[] = [];
154
155
  let specSrc = '';
155
156
  if (committedSpec) {
156
- specSrc = fs.readFileSync(committedSpec, 'utf-8');
157
+ specSrc = readTextFile(committedSpec);
157
158
  specTitles = extractTestTitles(specSrc);
158
159
  } else {
159
160
  findings.push('No generated spec found under specs/generated/ — run `sungen generate` / `/sungen:run-test` first.');
@@ -195,7 +196,7 @@ export async function runScriptCheck(screenDir: string, screenName: string, kind
195
196
  const fresh = findSpec(tmp, screenName, kind);
196
197
  if (fresh) {
197
198
  const a = normalize(specSrc);
198
- const b = normalize(fs.readFileSync(fresh, 'utf-8'));
199
+ const b = normalize(readTextFile(fresh));
199
200
  if (a !== b) {
200
201
  drift = 'drift';
201
202
  // collect a few differing lines
@@ -10,6 +10,7 @@ import * as fs from 'fs';
10
10
  import * as path from 'path';
11
11
  import { parse as parseYaml } from 'yaml';
12
12
  import { ScenarioInfo, ViewpointEntry, idPrefix } from './parse';
13
+ import { readTextFile } from './read-text';
13
14
 
14
15
  // Business-critical category keywords (matched by CONTAINMENT against the VP category, so a
15
16
  // compound category like LIST-DISPLAY / ADD-TO-CART / PRODUCT-DISCOVERY classifies correctly).
@@ -56,7 +57,7 @@ export interface Catalog {
56
57
 
57
58
  export function loadCatalog(): Catalog {
58
59
  const p = path.join(__dirname, 'catalog', 'universal-viewpoints.yaml');
59
- return parseYaml(fs.readFileSync(p, 'utf-8')) as Catalog;
60
+ return parseYaml(readTextFile(p)) as Catalog;
60
61
  }
61
62
 
62
63
  // Word-aware keyword match: `\b<kw>(s|es|ed|ing)?\b`, so a keyword matches whole words (plus
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import { ScenarioInfo } from './parse';
13
+ import { readTextFile } from './read-text';
13
14
 
14
15
  export type Modality = 'MUST' | 'SHOULD' | 'MAY';
15
16
 
@@ -20,6 +21,11 @@ export interface SpecCoverageResult {
20
21
  frTotal: number;
21
22
  frCovered: number;
22
23
  uncoveredMust: { id: string; text: string }[];
24
+ /** Requirements counted as covered ONLY by keyword inference — no scenario
25
+ * cites the id. The audit accepts that (it asks "did you think about this
26
+ * requirement?"), but the link is not machine-checkable: delivery's
27
+ * requirement table and any later refactor cannot follow it. */
28
+ inferredOnly: string[];
23
29
  triggerGaps: TriggerGap[]; // per-constraint trigger matrix gaps
24
30
  verdict: 'pass' | 'warn' | 'fail';
25
31
  }
@@ -40,8 +46,12 @@ const ACTION_TRIGGER: { trigger: string; re: RegExp }[] = [
40
46
  ];
41
47
 
42
48
  function modalityOf(text: string): Modality {
43
- if (/\bMUST\b/.test(text)) return 'MUST';
44
- if (/\bSHOULD\b/.test(text)) return 'SHOULD';
49
+ // Specs are written in the project's language — a Vietnamese spec states
50
+ // obligation with PHẢI / KHÔNG ĐƯỢC and recommendation with NÊN. Reading only
51
+ // English keywords silently demoted every requirement to MAY, so the
52
+ // "uncovered MUST" finding could never fire on those projects.
53
+ if (/\bMUST\b/i.test(text) || /\bPHẢI\b/i.test(text) || /KHÔNG ĐƯỢC/i.test(text)) return 'MUST';
54
+ if (/\bSHOULD\b/i.test(text) || /\bNÊN\b/i.test(text)) return 'SHOULD';
45
55
  return 'MAY';
46
56
  }
47
57
 
@@ -54,7 +64,7 @@ function actionTriggersIn(text: string): string[] {
54
64
 
55
65
  export function parseSpecClauses(specPath: string): { frs: FrClause[]; valRows: ValRow[] } {
56
66
  if (!fs.existsSync(specPath)) return { frs: [], valRows: [] };
57
- const lines = fs.readFileSync(specPath, 'utf-8').split('\n');
67
+ const lines = readTextFile(specPath).split('\n');
58
68
 
59
69
  const frs: FrClause[] = [];
60
70
  for (const line of lines) {
@@ -97,12 +107,13 @@ function scenarioBlocks(featureText: string): string[] {
97
107
  export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featureText: string): SpecCoverageResult {
98
108
  const { frs, valRows } = parseSpecClauses(specPath);
99
109
  if (!fs.existsSync(specPath) || (frs.length === 0 && valRows.length === 0)) {
100
- return { hasSpec: fs.existsSync(specPath), frTotal: 0, frCovered: 0, uncoveredMust: [], triggerGaps: [], verdict: 'pass' };
110
+ return { hasSpec: fs.existsSync(specPath), frTotal: 0, frCovered: 0, uncoveredMust: [], inferredOnly: [], triggerGaps: [], verdict: 'pass' };
101
111
  }
102
112
  const featLower = featureText.toLowerCase();
103
113
 
104
114
  // FR coverage: explicit @spec:FR / literal FR-id citation, else keyword fallback.
105
115
  const uncoveredMust: { id: string; text: string }[] = [];
116
+ const inferredOnly: string[] = [];
106
117
  let frCovered = 0;
107
118
  for (const fr of frs) {
108
119
  const idLower = fr.id.toLowerCase();
@@ -110,8 +121,12 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
110
121
  const words = [...new Set((fr.text.toLowerCase().match(/[a-z][a-z-]{4,}/g) || []))]
111
122
  .filter((w) => !/must|should|system|screen|users?|value|input|field/.test(w));
112
123
  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) });
124
+ if (cited || kwHit) {
125
+ frCovered++;
126
+ if (!cited) inferredOnly.push(fr.id); // covered, but nothing links it explicitly
127
+ } else if (fr.modality === 'MUST') {
128
+ uncoveredMust.push({ id: fr.id, text: fr.text.slice(0, 90) });
129
+ }
115
130
  }
116
131
 
117
132
  // Per-constraint trigger coverage — the matrix-collapse catch.
@@ -135,5 +150,5 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
135
150
  const verdict: SpecCoverageResult['verdict'] =
136
151
  uncoveredMust.length > 0 || triggerGaps.length > 0 ? 'fail' : 'pass';
137
152
 
138
- return { hasSpec: true, frTotal: frs.length, frCovered, uncoveredMust, triggerGaps, verdict };
153
+ return { hasSpec: true, frTotal: frs.length, frCovered, uncoveredMust, inferredOnly, triggerGaps, verdict };
139
154
  }
@@ -15,23 +15,24 @@ import * as fs from 'fs';
15
15
  import * as path from 'path';
16
16
  import { reportSlug } from './unit-paths';
17
17
  import { segmentRuns, latestRunEvents, LedgerEvent } from './ledger';
18
+ import { readTextFile } from './read-text';
18
19
 
19
20
  interface ManualItem { scenario: string; reason: string }
20
21
 
21
22
  function readJson(p: string): any | null {
22
- try { return fs.existsSync(p) ? JSON.parse(fs.readFileSync(p, 'utf-8')) : null; } catch { return null; }
23
+ try { return fs.existsSync(p) ? JSON.parse(readTextFile(p)) : null; } catch { return null; }
23
24
  }
24
25
 
25
26
  function readLedger(screen: string): any[] {
26
27
  const p = path.join(process.cwd(), '.sungen', 'ledger', `${reportSlug(screen)}.jsonl`);
27
28
  if (!fs.existsSync(p)) return [];
28
- return fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
29
+ return readTextFile(p).split('\n').filter(Boolean).map((l) => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
29
30
  }
30
31
 
31
32
  /** Parse @manual scenarios + the explanatory comment line above each. */
32
33
  function parseManual(featurePath: string): ManualItem[] {
33
34
  if (!fs.existsSync(featurePath)) return [];
34
- const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
35
+ const lines = readTextFile(featurePath).split('\n');
35
36
  const out: ManualItem[] = [];
36
37
  for (let i = 0; i < lines.length; i++) {
37
38
  const m = lines[i].match(/^\s*Scenario:\s*(.+)$/);
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import { ScenarioInfo } from './parse';
13
+ import { readTextFile } from './read-text';
13
14
 
14
15
  export interface LedgerItem { id?: string; text: string; covered: boolean }
15
16
 
@@ -27,7 +28,7 @@ const GENERIC = new Set(['display', 'shown', 'value', 'field', 'input', 'page',
27
28
  /** Extract atomic checklist items from a viewpoint file (format-tolerant). */
28
29
  export function parseViewpointItems(viewpointPath: string): { id?: string; text: string }[] {
29
30
  if (!fs.existsSync(viewpointPath)) return [];
30
- const lines = fs.readFileSync(viewpointPath, 'utf-8').split('\n');
31
+ const lines = readTextFile(viewpointPath).split('\n');
31
32
  const items: { id?: string; text: string }[] = [];
32
33
  let inFence = false;
33
34
  for (const raw of lines) {
@@ -68,6 +68,15 @@ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the d
68
68
  If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the design loop differs — **no visual capture, no selectors**; the contract is the named-endpoint catalog. **Follow the `sungen-api-design` + `sungen-api-coverage-model` skills end-to-end** instead of the screen/flow steps: `sungen context --area <name>` (discover endpoints + `fields:`) → **enumerate the Tier-1 case list per endpoint from the coverage model** (contract + not-found/required-matrix + auth + idempotency, expanded mechanically from `fields:`) → generate `@api`/`@cases`/flow/`@concurrent`/`@query` scenarios with **strict assertions** (prove the effect, never status-only) → **`sungen audit --area <name>` gate + reviewer + repair loop to businessDepth ≥ 0.7** → record + trace. Then recommend `/sungen-run-test <name>`. The capture / viewpoint-group / selector steps do **not** apply.
69
69
  {{/cap}}
70
70
 
71
+ ## Requirement traceability (before you finish)
72
+
73
+ Cross-check `requirements/spec.md` against the scenarios you wrote: every `FR-`/`TR-`/`NFR-` id
74
+ must either carry a `@spec:<id>` tag on the scenario that proves it, or be a conscious
75
+ out-of-scope decision you state in the summary. `sungen audit` reports `SPEC-TRACE-IMPLICIT` for
76
+ requirements it could only match by keyword — treat that list as a to-do: add the tag to the
77
+ proving scenario, do not leave the link to inference. Delivery's requirement table follows the
78
+ tag only, so an untagged requirement is later reported as an uncovered gap.
79
+
71
80
  ## Steps
72
81
 
73
82
  {{#cap parallel-subagents}}
@@ -89,6 +89,11 @@ target × category grid with explicit `—` gaps, dispositions, manifest. CSV mi
89
89
  flat with a `Level` column (`item`/`variant`) + a requirement-coverage appendix.
90
90
  `delivery_item_count` ≠ progress — variants are the execution metric.
91
91
 
92
+ **Runtime-captured values**: a scenario may produce its own values — `User remember [X] text as
93
+ {{v}}` (compared later) or a capability binding (`@query:`/`@api:`/`@dataFactory:`). These never
94
+ exist in test-data, so Gate D does not ask for them: the capture step renders as an instruction
95
+ (`Note the X text for later (as "v")`) and later references read `the captured v`.
96
+
92
97
  **Data fidelity**: invisible test data is made visible, never normalized — `''` → `(empty)`,
93
98
  whitespace-only → `(5 spaces)`, padded → `" value "` quoted verbatim. A trim/collapse here would
94
99
  silently break the whitespace tests it describes.
@@ -288,6 +288,7 @@ Options: `nth` `exact` `scope` `match` `variant` `frame` `contenteditable` `colu
288
288
 
289
289
  | Tag | Effect |
290
290
  |---|---|
291
+ | `@spec:<id>` | **Traceability**: this scenario proves requirement `<id>` from `requirements/spec.md` (`@spec:FR-003`). Repeatable — a scenario may prove several. Read by `sungen audit` (requirement coverage) and by delivery (the Coverage sheet's requirement table). Without it the link is only inferred from wording and cannot survive an edit. |
291
292
  | `@manual` | Skip in generation |
292
293
  | `@auth:role` | Use auth storage state for role |
293
294
  | `@no-auth` | Disable inherited auth |
@@ -4,6 +4,28 @@ description: 'Use when create-test needs to translate spec.md/Figma/UI into .fea
4
4
  user-invocable: false
5
5
  ---
6
6
 
7
+
8
+ ## Traceability — tag the requirement each scenario proves
9
+
10
+ Every requirement id in `requirements/spec.md` (`FR-`/`TR-`/`NFR-`) must be reachable from a
11
+ scenario, and the link must be **written down, not inferred**:
12
+
13
+ ```gherkin
14
+ @high @spec:FR-003
15
+ Scenario: VP-VAL-002 An email in an invalid format shows the format error
16
+ ```
17
+
18
+ - Put `@spec:<id>` on the scenario that actually proves the requirement; repeat the tag when one
19
+ scenario proves several (`@spec:FR-003 @spec:FR-004`).
20
+ - One requirement may be proved by several scenarios — tag each of them.
21
+ - A requirement you deliberately do not cover here (performance budget, i18n suite, server-side
22
+ concern) needs no tag; record it later in the delivery map's `requirements:` section with a
23
+ status and reason.
24
+ - Why it matters: `sungen audit` also counts a requirement as covered when its keywords merely
25
+ resemble a scenario title, and reports `SPEC-TRACE-IMPLICIT` for those. Delivery's requirement
26
+ table follows **only** the explicit tag — an untagged requirement reads as an uncovered gap in
27
+ the customer-facing coverage sheet even when a scenario does prove it.
28
+
7
29
  ## ⚠️ Gotchas — read before generating
8
30
 
9
31
  - **Field-level test-data follows the Data Factory standard — don't hand-invent values.** When the unit has input fields, the field values (valid / boundary / invalid + error codes) come from the **`sungen-data-factory`** catalog via a field-map + `sungen data gen` (create-test step 5.3), not free-form guessing. Weave the standardized boundary/invalid sets into `@cases` with `CHK-*` trace. This skill still owns scenario structure, viewpoints, and non-field oracles.