@sun-asterisk/sungen 3.2.15 → 3.2.16-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.
Files changed (165) hide show
  1. package/dist/cli/commands/delivery.d.ts.map +1 -1
  2. package/dist/cli/commands/delivery.js +197 -13
  3. package/dist/cli/commands/delivery.js.map +1 -1
  4. package/dist/dashboard/templates/index.html +1 -1
  5. package/dist/exporters/feature-parser.d.ts +16 -1
  6. package/dist/exporters/feature-parser.d.ts.map +1 -1
  7. package/dist/exporters/feature-parser.js +21 -2
  8. package/dist/exporters/feature-parser.js.map +1 -1
  9. package/dist/exporters/matrix/build.d.ts +61 -0
  10. package/dist/exporters/matrix/build.d.ts.map +1 -0
  11. package/dist/exporters/matrix/build.js +497 -0
  12. package/dist/exporters/matrix/build.js.map +1 -0
  13. package/dist/exporters/matrix/export.d.ts +50 -0
  14. package/dist/exporters/matrix/export.d.ts.map +1 -0
  15. package/dist/exporters/matrix/export.js +152 -0
  16. package/dist/exporters/matrix/export.js.map +1 -0
  17. package/dist/exporters/matrix/fingerprint.d.ts +20 -0
  18. package/dist/exporters/matrix/fingerprint.d.ts.map +1 -0
  19. package/dist/exporters/matrix/fingerprint.js +83 -0
  20. package/dist/exporters/matrix/fingerprint.js.map +1 -0
  21. package/dist/exporters/matrix/gates.d.ts +21 -0
  22. package/dist/exporters/matrix/gates.d.ts.map +1 -0
  23. package/dist/exporters/matrix/gates.js +367 -0
  24. package/dist/exporters/matrix/gates.js.map +1 -0
  25. package/dist/exporters/matrix/map-loader.d.ts +22 -0
  26. package/dist/exporters/matrix/map-loader.d.ts.map +1 -0
  27. package/dist/exporters/matrix/map-loader.js +193 -0
  28. package/dist/exporters/matrix/map-loader.js.map +1 -0
  29. package/dist/exporters/matrix/render-csv.d.ts +9 -0
  30. package/dist/exporters/matrix/render-csv.d.ts.map +1 -0
  31. package/dist/exporters/matrix/render-csv.js +92 -0
  32. package/dist/exporters/matrix/render-csv.js.map +1 -0
  33. package/dist/exporters/matrix/render-xlsx.d.ts +41 -0
  34. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -0
  35. package/dist/exporters/matrix/render-xlsx.js +430 -0
  36. package/dist/exporters/matrix/render-xlsx.js.map +1 -0
  37. package/dist/exporters/matrix/types.d.ts +199 -0
  38. package/dist/exporters/matrix/types.d.ts.map +1 -0
  39. package/dist/exporters/matrix/types.js +15 -0
  40. package/dist/exporters/matrix/types.js.map +1 -0
  41. package/dist/exporters/matrix/wording.d.ts +61 -0
  42. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  43. package/dist/exporters/matrix/wording.js +221 -0
  44. package/dist/exporters/matrix/wording.js.map +1 -0
  45. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  46. package/dist/exporters/scenario-merger.js +2 -1
  47. package/dist/exporters/scenario-merger.js.map +1 -1
  48. package/dist/exporters/spec-parser.d.ts.map +1 -1
  49. package/dist/exporters/spec-parser.js +2 -1
  50. package/dist/exporters/spec-parser.js.map +1 -1
  51. package/dist/exporters/xlsx-report-builder.d.ts +1 -1
  52. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  53. package/dist/exporters/xlsx-report-builder.js +3 -3
  54. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  55. package/dist/harness/audit.d.ts.map +1 -1
  56. package/dist/harness/audit.js +16 -2
  57. package/dist/harness/audit.js.map +1 -1
  58. package/dist/harness/blindspot.d.ts.map +1 -1
  59. package/dist/harness/blindspot.js +2 -1
  60. package/dist/harness/blindspot.js.map +1 -1
  61. package/dist/harness/capability-plan.d.ts.map +1 -1
  62. package/dist/harness/capability-plan.js +3 -2
  63. package/dist/harness/capability-plan.js.map +1 -1
  64. package/dist/harness/capability.d.ts +25 -0
  65. package/dist/harness/capability.d.ts.map +1 -1
  66. package/dist/harness/capability.js +44 -2
  67. package/dist/harness/capability.js.map +1 -1
  68. package/dist/harness/feedback.d.ts.map +1 -1
  69. package/dist/harness/feedback.js +3 -2
  70. package/dist/harness/feedback.js.map +1 -1
  71. package/dist/harness/flow-check.d.ts.map +1 -1
  72. package/dist/harness/flow-check.js +2 -1
  73. package/dist/harness/flow-check.js.map +1 -1
  74. package/dist/harness/flow-plan.d.ts.map +1 -1
  75. package/dist/harness/flow-plan.js +3 -2
  76. package/dist/harness/flow-plan.js.map +1 -1
  77. package/dist/harness/intent.d.ts.map +1 -1
  78. package/dist/harness/intent.js +2 -1
  79. package/dist/harness/intent.js.map +1 -1
  80. package/dist/harness/journey.d.ts.map +1 -1
  81. package/dist/harness/journey.js +3 -2
  82. package/dist/harness/journey.js.map +1 -1
  83. package/dist/harness/ledger.d.ts.map +1 -1
  84. package/dist/harness/ledger.js +3 -2
  85. package/dist/harness/ledger.js.map +1 -1
  86. package/dist/harness/manifest.d.ts.map +1 -1
  87. package/dist/harness/manifest.js +4 -3
  88. package/dist/harness/manifest.js.map +1 -1
  89. package/dist/harness/parse.d.ts.map +1 -1
  90. package/dist/harness/parse.js +16 -3
  91. package/dist/harness/parse.js.map +1 -1
  92. package/dist/harness/quality-gates.d.ts.map +1 -1
  93. package/dist/harness/quality-gates.js +2 -1
  94. package/dist/harness/quality-gates.js.map +1 -1
  95. package/dist/harness/read-text.d.ts +22 -0
  96. package/dist/harness/read-text.d.ts.map +1 -0
  97. package/dist/harness/read-text.js +64 -0
  98. package/dist/harness/read-text.js.map +1 -0
  99. package/dist/harness/script-check.d.ts.map +1 -1
  100. package/dist/harness/script-check.js +3 -2
  101. package/dist/harness/script-check.js.map +1 -1
  102. package/dist/harness/sensors.d.ts.map +1 -1
  103. package/dist/harness/sensors.js +2 -10
  104. package/dist/harness/sensors.js.map +1 -1
  105. package/dist/harness/spec-coverage.d.ts +5 -0
  106. package/dist/harness/spec-coverage.d.ts.map +1 -1
  107. package/dist/harness/spec-coverage.js +17 -7
  108. package/dist/harness/spec-coverage.js.map +1 -1
  109. package/dist/harness/trace.d.ts.map +1 -1
  110. package/dist/harness/trace.js +4 -3
  111. package/dist/harness/trace.js.map +1 -1
  112. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  113. package/dist/harness/viewpoint-ledger.js +2 -1
  114. package/dist/harness/viewpoint-ledger.js.map +1 -1
  115. package/dist/orchestrator/templates/ai-src/commands/create-test.md +10 -1
  116. package/dist/orchestrator/templates/ai-src/commands/delivery.md +203 -120
  117. package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
  118. package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
  119. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +102 -6
  120. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  121. package/dist/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
  122. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +23 -0
  123. package/package.json +4 -4
  124. package/src/cli/commands/delivery.ts +227 -15
  125. package/src/dashboard/templates/index.html +1 -1
  126. package/src/exporters/feature-parser.ts +21 -2
  127. package/src/exporters/matrix/build.ts +532 -0
  128. package/src/exporters/matrix/export.ts +149 -0
  129. package/src/exporters/matrix/fingerprint.ts +49 -0
  130. package/src/exporters/matrix/gates.ts +400 -0
  131. package/src/exporters/matrix/map-loader.ts +163 -0
  132. package/src/exporters/matrix/render-csv.ts +94 -0
  133. package/src/exporters/matrix/render-xlsx.ts +420 -0
  134. package/src/exporters/matrix/types.ts +226 -0
  135. package/src/exporters/matrix/wording.ts +221 -0
  136. package/src/exporters/scenario-merger.ts +2 -1
  137. package/src/exporters/spec-parser.ts +2 -1
  138. package/src/exporters/xlsx-report-builder.ts +3 -2
  139. package/src/harness/audit.ts +17 -3
  140. package/src/harness/blindspot.ts +2 -1
  141. package/src/harness/capability-plan.ts +3 -2
  142. package/src/harness/capability.ts +53 -3
  143. package/src/harness/feedback.ts +3 -2
  144. package/src/harness/flow-check.ts +2 -1
  145. package/src/harness/flow-plan.ts +3 -2
  146. package/src/harness/intent.ts +2 -1
  147. package/src/harness/journey.ts +3 -2
  148. package/src/harness/ledger.ts +3 -2
  149. package/src/harness/manifest.ts +4 -3
  150. package/src/harness/parse.ts +17 -3
  151. package/src/harness/quality-gates.ts +2 -1
  152. package/src/harness/read-text.ts +28 -0
  153. package/src/harness/script-check.ts +3 -2
  154. package/src/harness/sensors.ts +2 -1
  155. package/src/harness/spec-coverage.ts +22 -7
  156. package/src/harness/trace.ts +4 -3
  157. package/src/harness/viewpoint-ledger.ts +2 -1
  158. package/src/orchestrator/templates/ai-src/commands/create-test.md +10 -1
  159. package/src/orchestrator/templates/ai-src/commands/delivery.md +203 -120
  160. package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
  161. package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
  162. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +102 -6
  163. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  164. package/src/orchestrator/templates/ai-src/skills/sungen-harness-audit/SKILL.md +1 -1
  165. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +23 -0
@@ -15,9 +15,55 @@ import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
15
15
  export interface CapabilityProfile {
16
16
  platform?: string; // web | mobile | … (the runtime adapter)
17
17
  enabled: string[]; // driver ids enabled for this project
18
+ /**
19
+ * Which VERIFICATION layers this project's test scope uses — the recorded "test type"
20
+ * (`ui` = E2E-only; add `api` / `db` when the project also verifies via API/DB). Explicit here wins;
21
+ * when absent it is DERIVED from `enabled` (ui always; api if the api driver is on; db if db is on).
22
+ * Generation and the audit use it so an E2E-only project does not get @api/@query verification it
23
+ * never asked for.
24
+ */
25
+ verification?: string[];
18
26
  source: 'capabilities.yaml' | 'absent';
19
27
  }
20
28
 
29
+ export type VerifyLayer = 'ui' | 'api' | 'db';
30
+
31
+ /**
32
+ * Effective verification scope = the recorded "test type". Explicit `verification` wins; otherwise
33
+ * derived from the enabled drivers. `ui` is always in scope (every project observes the UI/E2E result).
34
+ */
35
+ export function verificationScope(profile: CapabilityProfile): Set<VerifyLayer> {
36
+ if (profile.verification?.length) {
37
+ const s = new Set<VerifyLayer>(['ui']);
38
+ for (const v of profile.verification) if (v === 'api' || v === 'db' || v === 'ui') s.add(v);
39
+ return s;
40
+ }
41
+ const s = new Set<VerifyLayer>(['ui']);
42
+ if (profile.enabled.includes('api')) s.add('api');
43
+ if (profile.enabled.includes('db')) s.add('db');
44
+ return s;
45
+ }
46
+
47
+ export interface VerifyScopeFinding { layer: 'api' | 'db'; message: string; }
48
+
49
+ /**
50
+ * Flag API/DB verification that is OUT OF the project's recorded scope: a unit whose scenarios use
51
+ * `@api`/`@requires:api` (or `@query`/`@requires:db`) while the project is not API/DB-testing AND the
52
+ * viewpoint never asked for that layer. Deterministic; advisory only (a nudge, never a hard gate).
53
+ */
54
+ export function verificationScopeFindings(featureText: string, viewpointText: string, profile: CapabilityProfile): VerifyScopeFinding[] {
55
+ const scope = verificationScope(profile);
56
+ const vp = (viewpointText || '').toLowerCase();
57
+ const out: VerifyScopeFinding[] = [];
58
+ if (/@api\b|@requires:\s*api\b/.test(featureText) && !scope.has('api') && !/\bapi\b/.test(vp)) {
59
+ out.push({ layer: 'api', message: 'uses @api / @requires:api verification, but this project is not API-testing (capabilities `enabled` has no `api`) and the viewpoint does not request API verification. Keep the oracle UI-observable, OR record API in scope (capabilities `verification: [ui, api]` or `sungen capability add api`). Reserve API/DB verify for the ≤20% of effects with no UI observation.' });
60
+ }
61
+ if (/@query\b|@requires:\s*db\b/.test(featureText) && !scope.has('db') && !/\b(database|db)\b/.test(vp)) {
62
+ out.push({ layer: 'db', message: 'uses @query / @requires:db verification, but this project is not DB-testing (capabilities `enabled` has no `db`) and the viewpoint does not request DB verification. Keep the oracle UI-observable, OR record DB in scope (capabilities `verification: [ui, db]` or `sungen capability add db`). Reserve API/DB verify for the ≤20% of effects with no UI observation.' });
63
+ }
64
+ return out;
65
+ }
66
+
21
67
  export interface DriverMeta {
22
68
  id: string;
23
69
  kind: 'platform' | 'capability';
@@ -41,6 +87,7 @@ export function readCapabilities(cwd: string): CapabilityProfile {
41
87
  return {
42
88
  platform: typeof y.platform === 'string' ? y.platform : undefined,
43
89
  enabled: Array.isArray(y.enabled) ? y.enabled.map(String) : [],
90
+ verification: Array.isArray(y.verification) ? y.verification.map(String) : undefined,
44
91
  source: 'capabilities.yaml',
45
92
  };
46
93
  } catch {
@@ -48,14 +95,17 @@ export function readCapabilities(cwd: string): CapabilityProfile {
48
95
  }
49
96
  }
50
97
 
51
- export function writeCapabilities(cwd: string, profile: { platform?: string; enabled: string[] }): string {
98
+ export function writeCapabilities(cwd: string, profile: { platform?: string; enabled: string[]; verification?: string[] }): string {
52
99
  const p = capabilitiesPath(cwd);
53
100
  fs.mkdirSync(path.dirname(p), { recursive: true });
54
101
  const body =
55
102
  '# Sungen capability profile — which runtime/drivers this project uses.\n' +
56
103
  '# platform: the runtime adapter (web → Playwright, mobile → Appium). No default.\n' +
57
- '# enabled: drivers turned on (added via `sungen capability add <driver>`).\n\n' +
58
- stringifyYaml({ platform: profile.platform, enabled: profile.enabled });
104
+ '# enabled: drivers turned on (added via `sungen capability add <driver>`).\n' +
105
+ '# verification: the TEST TYPE — which layers this project verifies (`ui` = E2E only; add `api`/`db`\n' +
106
+ '# only if the project actually verifies via API/Database). Absent → derived from `enabled`.\n' +
107
+ '# This keeps an E2E-only project from getting @api/@query verification it never asked for.\n\n' +
108
+ stringifyYaml({ platform: profile.platform, enabled: profile.enabled, ...(profile.verification ? { verification: profile.verification } : {}) });
59
109
  fs.writeFileSync(p, body, 'utf-8');
60
110
  return p;
61
111
  }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import * as fs from 'fs';
14
14
  import * as path from 'path';
15
+ import { readTextFile } from './read-text';
15
16
 
16
17
  export type FeedbackType = 'test-design' | 'product' | 'other';
17
18
  export type FeedbackDecision = 'accept' | 'reject' | 'edit' | 'add' | 'none';
@@ -36,7 +37,7 @@ function currentAuditScore(screen?: string): number | undefined {
36
37
  if (!screen) return undefined;
37
38
  const p = path.join(process.cwd(), '.sungen', 'reports', `${screen}-audit.json`);
38
39
  if (!fs.existsSync(p)) return undefined;
39
- try { return JSON.parse(fs.readFileSync(p, 'utf-8'))?.score?.overall; } catch { return undefined; }
40
+ try { return JSON.parse(readTextFile(p))?.score?.overall; } catch { return undefined; }
40
41
  }
41
42
 
42
43
  export function recordFeedback(entry: Omit<FeedbackEntry, 'ts' | 'source' | 'auditScore'> & { ts?: string; source?: string }): string {
@@ -60,7 +61,7 @@ export function recordFeedback(entry: Omit<FeedbackEntry, 'ts' | 'source' | 'aud
60
61
  export function readFeedback(): FeedbackEntry[] {
61
62
  const p = feedbackPath();
62
63
  if (!fs.existsSync(p)) return [];
63
- return fs.readFileSync(p, 'utf-8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
64
+ return readTextFile(p).split('\n').filter(Boolean).map((l) => JSON.parse(l));
64
65
  }
65
66
 
66
67
  export interface FeedbackSummary {
@@ -13,6 +13,7 @@ import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import { loadScenarios } from './parse';
15
15
  import { parseScenarios } from './capability-plan';
16
+ import { readTextFile } from './read-text';
16
17
 
17
18
  const KNOWN_TARGETS = ['cart', 'category', 'brand', 'detail', 'checkout', 'wishlist', 'order', 'payment', 'login'];
18
19
 
@@ -33,7 +34,7 @@ function featurePath(base: string, kind: 'screens' | 'flows', name: string): str
33
34
  }
34
35
  function featureTags(fp: string): string[] {
35
36
  if (!fs.existsSync(fp)) return [];
36
- for (const line of fs.readFileSync(fp, 'utf-8').split('\n')) {
37
+ for (const line of readTextFile(fp).split('\n')) {
37
38
  const t = line.trim();
38
39
  if (t.startsWith('Feature:')) break;
39
40
  if (t.startsWith('@')) return t.split(/\s+/).filter((x) => x.startsWith('@'));
@@ -12,6 +12,7 @@ import * as path from 'path';
12
12
  import { parse as parseYaml } from 'yaml';
13
13
  import { MANUAL_REASONS, inferReasonCode } from './capability-plan';
14
14
  import { buildFlowCheck, Contract } from './flow-check';
15
+ import { readTextFile } from './read-text';
15
16
 
16
17
  const REF_RE = /\[([A-Za-z][\w ]*?):([\w &.-]+?)\]/g;
17
18
 
@@ -19,7 +20,7 @@ interface FlowScenario { name: string; manual: boolean; reason: string; tags: st
19
20
 
20
21
  function parseFlowScenarios(featurePath: string): FlowScenario[] {
21
22
  if (!fs.existsSync(featurePath)) return [];
22
- const lines = fs.readFileSync(featurePath, 'utf-8').split('\n');
23
+ const lines = readTextFile(featurePath).split('\n');
23
24
  const out: FlowScenario[] = [];
24
25
  const idx: number[] = [];
25
26
  lines.forEach((l, i) => { if (/^\s*Scenario:/.test(l)) idx.push(i); });
@@ -50,7 +51,7 @@ function parseFlowScenarios(featurePath: string): FlowScenario[] {
50
51
  function selectorKeys(selectorsPath: string): Set<string> {
51
52
  if (!fs.existsSync(selectorsPath)) return new Set();
52
53
  try {
53
- const y = parseYaml(fs.readFileSync(selectorsPath, 'utf-8')) || {};
54
+ const y = parseYaml(readTextFile(selectorsPath)) || {};
54
55
  return new Set(Object.keys(y).map((k) => k.toLowerCase()));
55
56
  } catch { return new Set(); }
56
57
  }
@@ -14,6 +14,7 @@
14
14
  */
15
15
  import * as fs from 'fs';
16
16
  import * as path from 'path';
17
+ import { readTextFile } from './read-text';
17
18
 
18
19
  export type IntentFocus = 'functional' | 'e-commerce' | 'security' | 'smoke';
19
20
 
@@ -50,7 +51,7 @@ export function readIntent(projectRoot: string): IntentProfile {
50
51
  const ctx = path.join(projectRoot, 'qa', 'context.md');
51
52
  if (!fs.existsSync(ctx)) return DEFAULT_INTENT;
52
53
  let text: string;
53
- try { text = fs.readFileSync(ctx, 'utf-8').toLowerCase(); } catch { return DEFAULT_INTENT; }
54
+ try { text = readTextFile(ctx).toLowerCase(); } catch { return DEFAULT_INTENT; }
54
55
 
55
56
  const grab = (key: string): string | undefined => {
56
57
  const m = text.match(new RegExp(`(?:^|\\n)\\s*${key}\\s*:\\s*([a-z0-9-]+)`));
@@ -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) {
@@ -22,7 +22,7 @@ If `spec_figma.md` exists OR the user provides a Figma URL for the PAT flow:
22
22
 
23
23
  ## Platform detection (do this FIRST)
24
24
 
25
- Read `qa/capabilities.yaml` and check the `platform` field.
25
+ Read `qa/capabilities.yaml` and check the `platform` field — **and the verification scope** (`verification:` if set, else derived from `enabled`: `ui` always; `api`/`db` only if that driver is on). This is the project's recorded **test type**: an **E2E/UI-only** project (no `api`/`db`) must NOT get `@api`/`@query`/`@requires:api|db` verification unless the test-viewpoint explicitly asks for it — keep oracles UI-observable, and cap any in-scope API/DB-in-E2E verification at the **≤20% band** (see `sungen-tc-generation` → "Respect the project's verification scope"). `sungen audit` flags `VERIFICATION-OUT-OF-SCOPE`.
26
26
 
27
27
  - If `platform: mobile` → the visual-capture step (Step 3, "Auto-detect visual source") uses the **`sungen-capture-mobile`** skill (Appium MCP live capture) **instead of** the web `sungen-capture` (figma/live). Scenarios use mobile gesture vocabulary from `sungen-mobile-gestures` (tap, swipe, long-press — not click/hover) and `accessibility-id`-first selectors; mobile-specific steps (permission dialogs, clipboard, geolocation) are valid Gherkin, not errors. The `Background:` is a **navigation recipe** (see `add-screen --reach`), not a URL path. Everything else — viewpoints, `sungen-tc-generation`, the harness/audit loop — is platform-agnostic and unchanged.
28
28
  - If `platform: web` or field absent → follow the **web path** (all steps below, unchanged) — Figma/live capture via `sungen-capture` as documented in Step 3.
@@ -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}}