@sun-asterisk/sungen 3.2.17 → 3.2.18

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 (187) hide show
  1. package/dist/cli/commands/audit.d.ts.map +1 -1
  2. package/dist/cli/commands/audit.js +23 -0
  3. package/dist/cli/commands/audit.js.map +1 -1
  4. package/dist/cli/commands/delivery.d.ts.map +1 -1
  5. package/dist/cli/commands/delivery.js +209 -16
  6. package/dist/cli/commands/delivery.js.map +1 -1
  7. package/dist/cli/index.js +5 -0
  8. package/dist/cli/index.js.map +1 -1
  9. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  10. package/dist/dashboard/snapshot-builder.js +8 -1
  11. package/dist/dashboard/snapshot-builder.js.map +1 -1
  12. package/dist/dashboard/templates/index.html +1 -1
  13. package/dist/exporters/api-environment.d.ts +11 -0
  14. package/dist/exporters/api-environment.d.ts.map +1 -0
  15. package/dist/exporters/api-environment.js +129 -0
  16. package/dist/exporters/api-environment.js.map +1 -0
  17. package/dist/exporters/feature-parser.d.ts +16 -1
  18. package/dist/exporters/feature-parser.d.ts.map +1 -1
  19. package/dist/exporters/feature-parser.js +21 -2
  20. package/dist/exporters/feature-parser.js.map +1 -1
  21. package/dist/exporters/matrix/build.d.ts +70 -0
  22. package/dist/exporters/matrix/build.d.ts.map +1 -0
  23. package/dist/exporters/matrix/build.js +516 -0
  24. package/dist/exporters/matrix/build.js.map +1 -0
  25. package/dist/exporters/matrix/export.d.ts +50 -0
  26. package/dist/exporters/matrix/export.d.ts.map +1 -0
  27. package/dist/exporters/matrix/export.js +152 -0
  28. package/dist/exporters/matrix/export.js.map +1 -0
  29. package/dist/exporters/matrix/fingerprint.d.ts +20 -0
  30. package/dist/exporters/matrix/fingerprint.d.ts.map +1 -0
  31. package/dist/exporters/matrix/fingerprint.js +83 -0
  32. package/dist/exporters/matrix/fingerprint.js.map +1 -0
  33. package/dist/exporters/matrix/gates.d.ts +21 -0
  34. package/dist/exporters/matrix/gates.d.ts.map +1 -0
  35. package/dist/exporters/matrix/gates.js +367 -0
  36. package/dist/exporters/matrix/gates.js.map +1 -0
  37. package/dist/exporters/matrix/map-loader.d.ts +22 -0
  38. package/dist/exporters/matrix/map-loader.d.ts.map +1 -0
  39. package/dist/exporters/matrix/map-loader.js +193 -0
  40. package/dist/exporters/matrix/map-loader.js.map +1 -0
  41. package/dist/exporters/matrix/render-csv.d.ts +9 -0
  42. package/dist/exporters/matrix/render-csv.d.ts.map +1 -0
  43. package/dist/exporters/matrix/render-csv.js +92 -0
  44. package/dist/exporters/matrix/render-csv.js.map +1 -0
  45. package/dist/exporters/matrix/render-xlsx.d.ts +41 -0
  46. package/dist/exporters/matrix/render-xlsx.d.ts.map +1 -0
  47. package/dist/exporters/matrix/render-xlsx.js +430 -0
  48. package/dist/exporters/matrix/render-xlsx.js.map +1 -0
  49. package/dist/exporters/matrix/types.d.ts +199 -0
  50. package/dist/exporters/matrix/types.d.ts.map +1 -0
  51. package/dist/exporters/matrix/types.js +15 -0
  52. package/dist/exporters/matrix/types.js.map +1 -0
  53. package/dist/exporters/matrix/wording.d.ts +61 -0
  54. package/dist/exporters/matrix/wording.d.ts.map +1 -0
  55. package/dist/exporters/matrix/wording.js +221 -0
  56. package/dist/exporters/matrix/wording.js.map +1 -0
  57. package/dist/exporters/scenario-merger.d.ts.map +1 -1
  58. package/dist/exporters/scenario-merger.js +2 -1
  59. package/dist/exporters/scenario-merger.js.map +1 -1
  60. package/dist/exporters/spec-parser.d.ts.map +1 -1
  61. package/dist/exporters/spec-parser.js +2 -1
  62. package/dist/exporters/spec-parser.js.map +1 -1
  63. package/dist/exporters/xlsx-report-builder.d.ts +1 -1
  64. package/dist/exporters/xlsx-report-builder.d.ts.map +1 -1
  65. package/dist/exporters/xlsx-report-builder.js +3 -3
  66. package/dist/exporters/xlsx-report-builder.js.map +1 -1
  67. package/dist/harness/audit.d.ts +10 -0
  68. package/dist/harness/audit.d.ts.map +1 -1
  69. package/dist/harness/audit.js +98 -16
  70. package/dist/harness/audit.js.map +1 -1
  71. package/dist/harness/blindspot.d.ts.map +1 -1
  72. package/dist/harness/blindspot.js +2 -1
  73. package/dist/harness/blindspot.js.map +1 -1
  74. package/dist/harness/capability-plan.d.ts +2 -0
  75. package/dist/harness/capability-plan.d.ts.map +1 -1
  76. package/dist/harness/capability-plan.js +17 -4
  77. package/dist/harness/capability-plan.js.map +1 -1
  78. package/dist/harness/feedback.d.ts.map +1 -1
  79. package/dist/harness/feedback.js +3 -2
  80. package/dist/harness/feedback.js.map +1 -1
  81. package/dist/harness/flow-check.d.ts.map +1 -1
  82. package/dist/harness/flow-check.js +2 -1
  83. package/dist/harness/flow-check.js.map +1 -1
  84. package/dist/harness/flow-plan.d.ts.map +1 -1
  85. package/dist/harness/flow-plan.js +3 -2
  86. package/dist/harness/flow-plan.js.map +1 -1
  87. package/dist/harness/intent.d.ts.map +1 -1
  88. package/dist/harness/intent.js +2 -1
  89. package/dist/harness/intent.js.map +1 -1
  90. package/dist/harness/journey.d.ts.map +1 -1
  91. package/dist/harness/journey.js +3 -2
  92. package/dist/harness/journey.js.map +1 -1
  93. package/dist/harness/ledger.d.ts.map +1 -1
  94. package/dist/harness/ledger.js +3 -2
  95. package/dist/harness/ledger.js.map +1 -1
  96. package/dist/harness/manifest.d.ts.map +1 -1
  97. package/dist/harness/manifest.js +4 -3
  98. package/dist/harness/manifest.js.map +1 -1
  99. package/dist/harness/parse.d.ts.map +1 -1
  100. package/dist/harness/parse.js +16 -3
  101. package/dist/harness/parse.js.map +1 -1
  102. package/dist/harness/quality-gates.d.ts.map +1 -1
  103. package/dist/harness/quality-gates.js +2 -1
  104. package/dist/harness/quality-gates.js.map +1 -1
  105. package/dist/harness/read-text.d.ts +22 -0
  106. package/dist/harness/read-text.d.ts.map +1 -0
  107. package/dist/harness/read-text.js +64 -0
  108. package/dist/harness/read-text.js.map +1 -0
  109. package/dist/harness/script-check.d.ts.map +1 -1
  110. package/dist/harness/script-check.js +3 -2
  111. package/dist/harness/script-check.js.map +1 -1
  112. package/dist/harness/sensors.d.ts +13 -1
  113. package/dist/harness/sensors.d.ts.map +1 -1
  114. package/dist/harness/sensors.js +63 -20
  115. package/dist/harness/sensors.js.map +1 -1
  116. package/dist/harness/spec-coverage.d.ts +5 -0
  117. package/dist/harness/spec-coverage.d.ts.map +1 -1
  118. package/dist/harness/spec-coverage.js +17 -7
  119. package/dist/harness/spec-coverage.js.map +1 -1
  120. package/dist/harness/trace.d.ts.map +1 -1
  121. package/dist/harness/trace.js +4 -3
  122. package/dist/harness/trace.js.map +1 -1
  123. package/dist/harness/viewpoint-baseline.d.ts +49 -0
  124. package/dist/harness/viewpoint-baseline.d.ts.map +1 -0
  125. package/dist/harness/viewpoint-baseline.js +141 -0
  126. package/dist/harness/viewpoint-baseline.js.map +1 -0
  127. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  128. package/dist/harness/viewpoint-ledger.js +2 -1
  129. package/dist/harness/viewpoint-ledger.js.map +1 -1
  130. package/dist/orchestrator/assets-drift.d.ts +24 -0
  131. package/dist/orchestrator/assets-drift.d.ts.map +1 -0
  132. package/dist/orchestrator/assets-drift.js +80 -0
  133. package/dist/orchestrator/assets-drift.js.map +1 -0
  134. package/dist/orchestrator/templates/ai-src/commands/create-test.md +24 -0
  135. package/dist/orchestrator/templates/ai-src/commands/delivery.md +203 -120
  136. package/dist/orchestrator/templates/ai-src/config/claude.md +1 -1
  137. package/dist/orchestrator/templates/ai-src/config/copilot.md +1 -1
  138. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +102 -6
  139. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  140. package/dist/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +23 -0
  141. package/package.json +3 -3
  142. package/src/cli/commands/audit.ts +23 -1
  143. package/src/cli/commands/delivery.ts +240 -18
  144. package/src/cli/index.ts +6 -0
  145. package/src/dashboard/snapshot-builder.ts +8 -1
  146. package/src/dashboard/templates/index.html +1 -1
  147. package/src/exporters/api-environment.ts +97 -0
  148. package/src/exporters/feature-parser.ts +21 -2
  149. package/src/exporters/matrix/build.ts +551 -0
  150. package/src/exporters/matrix/export.ts +149 -0
  151. package/src/exporters/matrix/fingerprint.ts +49 -0
  152. package/src/exporters/matrix/gates.ts +400 -0
  153. package/src/exporters/matrix/map-loader.ts +163 -0
  154. package/src/exporters/matrix/render-csv.ts +94 -0
  155. package/src/exporters/matrix/render-xlsx.ts +420 -0
  156. package/src/exporters/matrix/types.ts +226 -0
  157. package/src/exporters/matrix/wording.ts +221 -0
  158. package/src/exporters/scenario-merger.ts +2 -1
  159. package/src/exporters/spec-parser.ts +2 -1
  160. package/src/exporters/xlsx-report-builder.ts +3 -2
  161. package/src/harness/audit.ts +104 -18
  162. package/src/harness/blindspot.ts +2 -1
  163. package/src/harness/capability-plan.ts +17 -4
  164. package/src/harness/feedback.ts +3 -2
  165. package/src/harness/flow-check.ts +2 -1
  166. package/src/harness/flow-plan.ts +3 -2
  167. package/src/harness/intent.ts +2 -1
  168. package/src/harness/journey.ts +3 -2
  169. package/src/harness/ledger.ts +3 -2
  170. package/src/harness/manifest.ts +4 -3
  171. package/src/harness/parse.ts +17 -3
  172. package/src/harness/quality-gates.ts +2 -1
  173. package/src/harness/read-text.ts +28 -0
  174. package/src/harness/script-check.ts +3 -2
  175. package/src/harness/sensors.ts +55 -8
  176. package/src/harness/spec-coverage.ts +22 -7
  177. package/src/harness/trace.ts +4 -3
  178. package/src/harness/viewpoint-baseline.ts +128 -0
  179. package/src/harness/viewpoint-ledger.ts +2 -1
  180. package/src/orchestrator/assets-drift.ts +64 -0
  181. package/src/orchestrator/templates/ai-src/commands/create-test.md +24 -0
  182. package/src/orchestrator/templates/ai-src/commands/delivery.md +203 -120
  183. package/src/orchestrator/templates/ai-src/config/claude.md +1 -1
  184. package/src/orchestrator/templates/ai-src/config/copilot.md +1 -1
  185. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +102 -6
  186. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +1 -0
  187. package/src/orchestrator/templates/ai-src/skills/sungen-tc-generation/SKILL.md +23 -0
@@ -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
@@ -76,6 +77,10 @@ const has = (haystacks: string[], kw: string) => {
76
77
 
77
78
  export interface GateResult {
78
79
  pageType: string | null;
80
+ /** How the page type was decided — a declaration is authoritative, a guess is not. */
81
+ pageTypeSource?: 'declared' | 'detected' | 'undetermined';
82
+ /** Keyword evidence behind a detected type (best hits vs the runner-up). */
83
+ pageTypeEvidence?: { hits: number; runnerUp: number };
79
84
  themesTotal: number;
80
85
  themesCovered: number; // deeply covered (has a data assertion)
81
86
  coverageRatio: number;
@@ -83,7 +88,18 @@ export interface GateResult {
83
88
  universalGaps: string[];
84
89
  }
85
90
 
86
- export function viewpointGate(scenarios: ScenarioInfo[], viewpoints: ViewpointEntry[], catalog: Catalog, isMobile = false): GateResult {
91
+ /**
92
+ * An explicit `page-type: <id>` line in test-viewpoint.md — the project stating what
93
+ * kind of screen this is. A declaration always beats keyword detection.
94
+ */
95
+ export function declaredPageType(viewpointText: string, catalog: Catalog): string | null {
96
+ const m = viewpointText.match(/^\s*(?:[-*]\s*)?\**page[- ]?type\**\s*[:=]\s*`?([a-z0-9-]+)`?/im);
97
+ if (!m) return null;
98
+ const id = m[1].toLowerCase();
99
+ return Object.keys(catalog.page_types).includes(id) ? id : null;
100
+ }
101
+
102
+ export function viewpointGate(scenarios: ScenarioInfo[], viewpoints: ViewpointEntry[], catalog: Catalog, isMobile = false, viewpointText = ''): GateResult {
87
103
  const haystacks = [
88
104
  ...scenarios.map((s) => s.haystack),
89
105
  ...viewpoints.map((v) => `${v.id} ${v.reason}`.toLowerCase()),
@@ -94,14 +110,35 @@ export function viewpointGate(scenarios: ScenarioInfo[], viewpoints: ViewpointEn
94
110
  // too, so a mobile commerce/form screen is selected as ecommerce-list/form by keyword fit —
95
111
  // `mobile-home` does not crowd out a better-fitting type. With no type matching → pageType stays
96
112
  // null → no themes required → the gate passes leniently rather than false-FAILing.
113
+ // Detection needs EVIDENCE, not a single coincidental word. A knowledge-base search
114
+ // screen was classified `ecommerce-list` on ONE keyword hit and then judged against
115
+ // cart / add-to-cart / brand-filter themes it can never have — 40% of the quality
116
+ // score decided by one word, in both directions (an earlier misread scored it `auth`
117
+ // and handed it 3/3). Below the threshold the page type is UNDETERMINED: no themes
118
+ // are demanded (the audit says so instead of inventing a gap), and the project can
119
+ // settle it for good by declaring `page-type:` in test-viewpoint.md.
120
+ const MIN_HITS = 2; // at least two distinct keywords
121
+ const MIN_MARGIN = 1; // and a clear lead over the runner-up
97
122
  let pageType: string | null = null;
123
+ let source: GateResult['pageTypeSource'] = 'undetermined';
98
124
  let best = 0;
99
- for (const [pt, def] of Object.entries(catalog.page_types)) {
100
- if (pt.startsWith('mobile-') && !isMobile) continue;
101
- const hits = def.detect_keywords.filter((k) => has(haystacks, k)).length;
102
- if (hits > best) { best = hits; pageType = pt; }
125
+ let runnerUp = 0;
126
+ const declared = declaredPageType(viewpointText, catalog);
127
+ if (declared) {
128
+ pageType = declared;
129
+ source = 'declared';
130
+ } else {
131
+ for (const [pt, def] of Object.entries(catalog.page_types)) {
132
+ if (pt.startsWith('mobile-') && !isMobile) continue;
133
+ const hits = def.detect_keywords.filter((k) => has(haystacks, k)).length;
134
+ if (hits > best) { runnerUp = best; best = hits; pageType = pt; }
135
+ else if (hits > runnerUp) { runnerUp = hits; }
136
+ }
137
+ if (pageType && best >= MIN_HITS && best - runnerUp >= MIN_MARGIN) source = 'detected';
138
+ else { pageType = null; source = 'undetermined'; }
103
139
  }
104
140
 
141
+ const evidence = { hits: best, runnerUp };
105
142
  const gaps: GateResult['gaps'] = [];
106
143
  let total = 0, covered = 0;
107
144
  if (pageType) {
@@ -127,8 +164,13 @@ export function viewpointGate(scenarios: ScenarioInfo[], viewpoints: ViewpointEn
127
164
 
128
165
  return {
129
166
  pageType,
167
+ pageTypeSource: source,
168
+ pageTypeEvidence: evidence,
130
169
  themesTotal: total,
131
170
  themesCovered: covered,
171
+ // No page type → no themes → the ratio is NOT a free 1.0: the score treats this
172
+ // axis as not-applicable and renormalises, so breadth can neither be faked nor
173
+ // punished by a guess.
132
174
  coverageRatio: total ? covered / total : 1,
133
175
  gaps,
134
176
  universalGaps,
@@ -376,8 +418,13 @@ export function coverageBalance(scenarios: ScenarioInfo[]): BalanceResult {
376
418
  byBucket[bucketForCategory(s.category)]++;
377
419
  }
378
420
 
379
- const core = byBucket['business-core'];
380
- const secondary = byBucket['presentation'] + byBucket['validation-security'];
421
+ // `behavior` (LOGIC / TRANSITION / WORKFLOW) IS business behaviour — it is the
422
+ // main category of sungen's own prescribed taxonomy. Counting only the commerce/API
423
+ // vocabulary (CART, PRODUCT, CHECKOUT, ENDPOINT…) as core meant a standard suite of
424
+ // 37 VP-LOGIC scenarios scored core=0 → a balance axis of 0% on a perfectly
425
+ // well-balanced suite. Navigation joins the secondary side, where it belongs.
426
+ const core = byBucket['business-core'] + byBucket['behavior'];
427
+ const secondary = byBucket['presentation'] + byBucket['validation-security'] + byBucket['navigation'];
381
428
  const imbalanced = secondary > core * 1.5 && core > 0;
382
429
  const unclassifiedRatio = scenarios.length ? byBucket['other'] / scenarios.length : 0;
383
430
  // A high `other` share means the VP taxonomy drifted from the catalog — the balance axis is then
@@ -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*(.+)$/);
@@ -0,0 +1,128 @@
1
+ import * as crypto from 'crypto';
2
+ import * as fs from 'fs';
3
+ import * as path from 'path';
4
+ import { parseViewpointOverview } from './parse';
5
+
6
+ /**
7
+ * Keep the yardstick from moving with the thing it measures.
8
+ *
9
+ * `test-viewpoint.md` is the project's declaration of WHAT must be tested, and two
10
+ * scored axes measure the suite against it: `traceability` (do scenarios use the
11
+ * declared viewpoint ids?) and `atomicLedger` (is every declared item covered?).
12
+ * Both are only evidence while the declaration is INDEPENDENT of the suite.
13
+ *
14
+ * A `/sungen:create-test` run rewrote a filled test-viewpoint.md, replacing the
15
+ * declared categories with exactly the ones it had just generated. Both axes read
16
+ * 100% by construction — and the performance viewpoint it dropped on the way
17
+ * (4 perf scenarios → 2) went unreported, because VP-PERF was no longer declared
18
+ * for anything to be missing from.
19
+ *
20
+ * So: remember the declaration, and when it changes, say so and stop counting the
21
+ * two axes that depend on it until a human accepts the new baseline. Same rule the
22
+ * score model already applies to an undetermined page type — absent independent
23
+ * evidence scores nothing, in either direction.
24
+ */
25
+
26
+ /** Axes whose evidence is `test-viewpoint.md` — excluded while the baseline is unconfirmed. */
27
+ export const VIEWPOINT_DEPENDENT_AXES = ['atomicLedger', 'traceability'] as const;
28
+
29
+ export interface ViewpointBaseline {
30
+ status: 'absent' | 'new' | 'unchanged' | 'changed';
31
+ /** sha1 of the normalized file — '' when the unit has no test-viewpoint.md. */
32
+ hash: string;
33
+ ids: string[];
34
+ /** Only on `changed`: how the declaration moved. */
35
+ added?: string[];
36
+ removed?: string[];
37
+ recordedAt?: string;
38
+ }
39
+
40
+ interface BaselineFile {
41
+ version: 1;
42
+ units: Record<string, { hash: string; ids: string[]; recordedAt: string }>;
43
+ }
44
+
45
+ function baselinePath(projectRoot: string): string {
46
+ return path.join(projectRoot, '.sungen', 'viewpoint-baseline.json');
47
+ }
48
+
49
+ function readBaselineFile(projectRoot: string): BaselineFile {
50
+ const p = baselinePath(projectRoot);
51
+ if (!fs.existsSync(p)) return { version: 1, units: {} };
52
+ try {
53
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8')) as BaselineFile;
54
+ return parsed && typeof parsed === 'object' && parsed.units ? parsed : { version: 1, units: {} };
55
+ } catch {
56
+ return { version: 1, units: {} }; // unreadable → treat as unrecorded, never throw
57
+ }
58
+ }
59
+
60
+ function writeBaselineFile(projectRoot: string, data: BaselineFile): void {
61
+ const p = baselinePath(projectRoot);
62
+ fs.mkdirSync(path.dirname(p), { recursive: true });
63
+ fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
64
+ }
65
+
66
+ /**
67
+ * Hash the DECLARATION, not the prose: an author reflowing a sentence must not read
68
+ * as the taxonomy moving. Only the declared viewpoint ids are fingerprinted.
69
+ */
70
+ function fingerprint(ids: string[]): string {
71
+ return crypto.createHash('sha1').update(ids.join('\n')).digest('hex').slice(0, 12);
72
+ }
73
+
74
+ /**
75
+ * Compare the unit's current test-viewpoint declaration to the recorded baseline.
76
+ * Read-only — recording is the caller's decision, so an audit stays a measurement
77
+ * and `--accept-viewpoint` stays the explicit human act. A first sighting reports
78
+ * `new` (nothing to compare against yet).
79
+ */
80
+ export function checkViewpointBaseline(
81
+ projectRoot: string,
82
+ unitId: string,
83
+ viewpointPath: string,
84
+ ): ViewpointBaseline {
85
+ if (!fs.existsSync(viewpointPath)) return { status: 'absent', hash: '', ids: [] };
86
+ const ids = parseViewpointOverview(viewpointPath).map((v) => v.id).sort();
87
+ const hash = fingerprint(ids);
88
+ if (ids.length === 0) return { status: 'absent', hash: '', ids: [] };
89
+
90
+ const file = readBaselineFile(projectRoot);
91
+ const recorded = file.units[unitId];
92
+ if (!recorded) return { status: 'new', hash, ids };
93
+ if (recorded.hash === hash) return { status: 'unchanged', hash, ids, recordedAt: recorded.recordedAt };
94
+
95
+ const before = new Set(recorded.ids ?? []);
96
+ const now = new Set(ids);
97
+ return {
98
+ status: 'changed',
99
+ hash,
100
+ ids,
101
+ added: ids.filter((i) => !before.has(i)),
102
+ removed: (recorded.ids ?? []).filter((i) => !now.has(i)),
103
+ recordedAt: recorded.recordedAt,
104
+ };
105
+ }
106
+
107
+ /** Record the current declaration as the accepted baseline (`sungen audit --accept-viewpoint`). */
108
+ export function acceptViewpointBaseline(
109
+ projectRoot: string,
110
+ unitId: string,
111
+ current: { hash: string; ids: string[] },
112
+ ): void {
113
+ const file = readBaselineFile(projectRoot);
114
+ file.units[unitId] = {
115
+ hash: current.hash,
116
+ ids: current.ids,
117
+ recordedAt: new Date().toISOString(),
118
+ };
119
+ writeBaselineFile(projectRoot, file);
120
+ }
121
+
122
+ /** Read the declaration without recording anything (used by `--accept-viewpoint`). */
123
+ export function readViewpointDeclaration(viewpointPath: string): { hash: string; ids: string[] } | null {
124
+ if (!fs.existsSync(viewpointPath)) return null;
125
+ const ids = parseViewpointOverview(viewpointPath).map((v) => v.id).sort();
126
+ if (ids.length === 0) return null;
127
+ return { hash: fingerprint(ids), ids };
128
+ }
@@ -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) {
@@ -0,0 +1,64 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { getPackageVersion } from '../exporters/package-info';
4
+
5
+ /**
6
+ * Are this project's AI assets (commands + skills under `.claude/` etc.) the ones
7
+ * the running sungen would write?
8
+ *
9
+ * Upgrading the npm package does NOT refresh them — `sungen update` does. Nothing
10
+ * said so, so a project kept running prompts from an older build indefinitely: a
11
+ * QA upgraded, re-ran `/sungen:create-test`, and got the OLD command (141 lines vs
12
+ * the shipped 267) with none of the newer guidance. The failure is invisible
13
+ * because everything still "works", just to an older specification.
14
+ *
15
+ * The manifest already records the version + a hash per managed file; this only
16
+ * reads it and reports.
17
+ */
18
+ export interface AssetsDrift {
19
+ recordedVersion: string;
20
+ runningVersion: string;
21
+ /** Managed files whose content no longer matches the manifest's hash. */
22
+ changed: number;
23
+ total: number;
24
+ }
25
+
26
+ export function checkAssetsDrift(cwd: string): AssetsDrift | null {
27
+ const manifestPath = path.join(cwd, '.sungen', 'manifest.json');
28
+ if (!fs.existsSync(manifestPath)) return null; // not a sungen project (yet)
29
+
30
+ let manifest: { version?: string; managed?: Record<string, string> };
31
+ try {
32
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf-8'));
33
+ } catch {
34
+ return null; // unreadable → say nothing
35
+ }
36
+ const recordedVersion = manifest.version ?? '';
37
+ const runningVersion = getPackageVersion();
38
+ const managed = manifest.managed ?? {};
39
+ const total = Object.keys(managed).length;
40
+ if (!recordedVersion || total === 0) return null;
41
+
42
+ // A version match is the common case — trust it and skip hashing entirely.
43
+ if (recordedVersion === runningVersion) return null;
44
+
45
+ let changed = 0;
46
+ for (const rel of Object.keys(managed)) {
47
+ if (!fs.existsSync(path.join(cwd, rel))) changed++;
48
+ }
49
+ return { recordedVersion, runningVersion, changed, total };
50
+ }
51
+
52
+ /** One line, on stderr so `--json` output stays machine-readable. */
53
+ export function warnAssetsDrift(cwd: string): void {
54
+ const drift = checkAssetsDrift(cwd);
55
+ if (!drift) return;
56
+ const gray = '\x1b[90m';
57
+ const cyan = '\x1b[36m';
58
+ const reset = '\x1b[0m';
59
+ process.stderr.write(
60
+ `${gray}note: this project's AI commands/skills were written by sungen ${drift.recordedVersion}, ` +
61
+ `you are running ${drift.runningVersion} — run ${cyan}sungen update${reset}${gray} to refresh them ` +
62
+ `(upgrading the package alone does not).${reset}\n`,
63
+ );
64
+ }
@@ -115,12 +115,16 @@ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the d
115
115
  - **Fill test-viewpoint.md first** — I'll help you identify edge cases, known issues, and design decisions for this screen before generating tests
116
116
  - **Continue without it** — generate tests from spec and other sources only
117
117
 
118
+ **A filled `test-viewpoint.md` is an INPUT — never an output.** Do NOT rewrite, re-order, or replace its viewpoint declarations as part of generating tests. It is the yardstick two scored axes measure the suite against (`traceability`, `atomicLedger`): rewrite it to match what you just generated and both read 100% by construction, while any viewpoint you quietly dropped stops being missing from anything. If the declared taxonomy genuinely does not fit the screen, **report the mismatch and ask** — propose the diff (ids added / removed and why) and let the QA decide. `sungen audit` reports `VIEWPOINT-BASELINE-CHANGED` and stops scoring both axes when this file moves, so an unannounced rewrite lowers the score rather than raising it.
119
+
118
120
  **Context discovery (prefer an isolated agent).** Reading all sources here can flood this context. **Claude Code:** spawn the **`sungen-discovery`** sub-agent (Task tool, `subagent_type: sungen-discovery`) to read spec/figma/ui/live in isolation and return a **compact discovery report** (sources, completeness, conflicts, recommended route, key facts); use that report instead of pasting raw sources. **Copilot / no sub-agents:** do the reading inline as below.
119
121
  {{/cap}}
120
122
  {{^cap parallel-subagents}}
121
123
  - If `test-viewpoint.md` exists → read it. If it only contains HTML comments (scaffold template), ask:
122
124
  - **1) Fill test-viewpoint.md first** — identify edge cases, known issues, and design decisions before generating tests
123
125
  - **2) Continue without it** — generate tests from spec and other sources only
126
+
127
+ **A filled `test-viewpoint.md` is an INPUT — never an output.** Do NOT rewrite, re-order, or replace its viewpoint declarations while generating tests. It is the yardstick two scored axes measure the suite against (`traceability`, `atomicLedger`): rewrite it to match what you just generated and both read 100% by construction, while any viewpoint you quietly dropped stops being missing from anything. If the declared taxonomy genuinely does not fit the screen, **report the mismatch and ask** — propose the diff (ids added / removed and why) and let the QA decide. `sungen audit` reports `VIEWPOINT-BASELINE-CHANGED` and stops scoring both axes when this file moves.
124
128
  {{/cap}}
125
129
 
126
130
  **Auto-detect visual source** — do NOT ask the user to pick a source. Instead, check what already exists and use it:
@@ -256,3 +260,23 @@ If the unit is **api-first** (`qa/api/<name>/` or `qa/api/flows/<name>/`), the d
256
260
  {{^cap parallel-subagents}}
257
261
  **No selectors.yaml** — selectors are generated during `/sungen-run-test`.
258
262
  {{/cap}}
263
+
264
+ ## Finish — always hand the next step back
265
+
266
+ Do not stop after writing the files. Close every run with, in this order:
267
+
268
+ 1. **The traceability check below** — an untagged requirement becomes an uncovered gap at delivery.
269
+ 2. **The harness result in one line** — score, gate verdict, and the axis the `SCORE-CAPPED` finding
270
+ names (that finding is the single answer to "how do I raise the score").
271
+ 3. **`AskUserQuestion` with the next actions** — never end with prose alone. Offer
272
+ `/sungen:run-test <name>` (recommended once the tiers are written), re-running `create-test` to
273
+ extend, and "Done for now". A run that ends without this leaves the operator guessing.
274
+
275
+ ### Requirement traceability
276
+
277
+ Cross-check `requirements/spec.md` against the scenarios you wrote: every `FR-`/`TR-`/`NFR-` id
278
+ must either carry a `@spec:<id>` tag on the scenario that proves it, or be a conscious
279
+ out-of-scope decision you state in the summary. `sungen audit` reports `SPEC-TRACE-IMPLICIT` for
280
+ requirements it could only match by keyword — treat that list as a to-do: add the tag to the
281
+ proving scenario, do not leave the link to inference. Delivery's requirement table follows the
282
+ tag only, so an untagged requirement is later reported as an uncovered gap.