@sun-asterisk/sungen 3.2.16-beta.12 → 3.2.16-beta.13

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.
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Keep the yardstick from moving with the thing it measures.
3
+ *
4
+ * `test-viewpoint.md` is the project's declaration of WHAT must be tested, and two
5
+ * scored axes measure the suite against it: `traceability` (do scenarios use the
6
+ * declared viewpoint ids?) and `atomicLedger` (is every declared item covered?).
7
+ * Both are only evidence while the declaration is INDEPENDENT of the suite.
8
+ *
9
+ * A `/sungen:create-test` run rewrote a filled test-viewpoint.md, replacing the
10
+ * declared categories with exactly the ones it had just generated. Both axes read
11
+ * 100% by construction — and the performance viewpoint it dropped on the way
12
+ * (4 perf scenarios → 2) went unreported, because VP-PERF was no longer declared
13
+ * for anything to be missing from.
14
+ *
15
+ * So: remember the declaration, and when it changes, say so and stop counting the
16
+ * two axes that depend on it until a human accepts the new baseline. Same rule the
17
+ * score model already applies to an undetermined page type — absent independent
18
+ * evidence scores nothing, in either direction.
19
+ */
20
+ /** Axes whose evidence is `test-viewpoint.md` — excluded while the baseline is unconfirmed. */
21
+ export declare const VIEWPOINT_DEPENDENT_AXES: readonly ["atomicLedger", "traceability"];
22
+ export interface ViewpointBaseline {
23
+ status: 'absent' | 'new' | 'unchanged' | 'changed';
24
+ /** sha1 of the normalized file — '' when the unit has no test-viewpoint.md. */
25
+ hash: string;
26
+ ids: string[];
27
+ /** Only on `changed`: how the declaration moved. */
28
+ added?: string[];
29
+ removed?: string[];
30
+ recordedAt?: string;
31
+ }
32
+ /**
33
+ * Compare the unit's current test-viewpoint declaration to the recorded baseline.
34
+ * Read-only — recording is the caller's decision, so an audit stays a measurement
35
+ * and `--accept-viewpoint` stays the explicit human act. A first sighting reports
36
+ * `new` (nothing to compare against yet).
37
+ */
38
+ export declare function checkViewpointBaseline(projectRoot: string, unitId: string, viewpointPath: string): ViewpointBaseline;
39
+ /** Record the current declaration as the accepted baseline (`sungen audit --accept-viewpoint`). */
40
+ export declare function acceptViewpointBaseline(projectRoot: string, unitId: string, current: {
41
+ hash: string;
42
+ ids: string[];
43
+ }): void;
44
+ /** Read the declaration without recording anything (used by `--accept-viewpoint`). */
45
+ export declare function readViewpointDeclaration(viewpointPath: string): {
46
+ hash: string;
47
+ ids: string[];
48
+ } | null;
49
+ //# sourceMappingURL=viewpoint-baseline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewpoint-baseline.d.ts","sourceRoot":"","sources":["../../src/harness/viewpoint-baseline.ts"],"names":[],"mappings":"AAKA;;;;;;;;;;;;;;;;;;GAkBG;AAEH,+FAA+F;AAC/F,eAAO,MAAM,wBAAwB,2CAA4C,CAAC;AAElF,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,QAAQ,GAAG,KAAK,GAAG,WAAW,GAAG,SAAS,CAAC;IACnD,+EAA+E;IAC/E,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,EAAE,CAAC;IACd,oDAAoD;IACpD,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,EAAE,CAAC;IACnB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAoCD;;;;;GAKG;AACH,wBAAgB,sBAAsB,CACpC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,aAAa,EAAE,MAAM,GACpB,iBAAiB,CAqBnB;AAED,mGAAmG;AACnG,wBAAgB,uBAAuB,CACrC,WAAW,EAAE,MAAM,EACnB,MAAM,EAAE,MAAM,EACd,OAAO,EAAE;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GACvC,IAAI,CAQN;AAED,sFAAsF;AACtF,wBAAgB,wBAAwB,CAAC,aAAa,EAAE,MAAM,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAAE,GAAG,IAAI,CAKtG"}
@@ -0,0 +1,141 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.VIEWPOINT_DEPENDENT_AXES = void 0;
37
+ exports.checkViewpointBaseline = checkViewpointBaseline;
38
+ exports.acceptViewpointBaseline = acceptViewpointBaseline;
39
+ exports.readViewpointDeclaration = readViewpointDeclaration;
40
+ const crypto = __importStar(require("crypto"));
41
+ const fs = __importStar(require("fs"));
42
+ const path = __importStar(require("path"));
43
+ const parse_1 = require("./parse");
44
+ /**
45
+ * Keep the yardstick from moving with the thing it measures.
46
+ *
47
+ * `test-viewpoint.md` is the project's declaration of WHAT must be tested, and two
48
+ * scored axes measure the suite against it: `traceability` (do scenarios use the
49
+ * declared viewpoint ids?) and `atomicLedger` (is every declared item covered?).
50
+ * Both are only evidence while the declaration is INDEPENDENT of the suite.
51
+ *
52
+ * A `/sungen:create-test` run rewrote a filled test-viewpoint.md, replacing the
53
+ * declared categories with exactly the ones it had just generated. Both axes read
54
+ * 100% by construction — and the performance viewpoint it dropped on the way
55
+ * (4 perf scenarios → 2) went unreported, because VP-PERF was no longer declared
56
+ * for anything to be missing from.
57
+ *
58
+ * So: remember the declaration, and when it changes, say so and stop counting the
59
+ * two axes that depend on it until a human accepts the new baseline. Same rule the
60
+ * score model already applies to an undetermined page type — absent independent
61
+ * evidence scores nothing, in either direction.
62
+ */
63
+ /** Axes whose evidence is `test-viewpoint.md` — excluded while the baseline is unconfirmed. */
64
+ exports.VIEWPOINT_DEPENDENT_AXES = ['atomicLedger', 'traceability'];
65
+ function baselinePath(projectRoot) {
66
+ return path.join(projectRoot, '.sungen', 'viewpoint-baseline.json');
67
+ }
68
+ function readBaselineFile(projectRoot) {
69
+ const p = baselinePath(projectRoot);
70
+ if (!fs.existsSync(p))
71
+ return { version: 1, units: {} };
72
+ try {
73
+ const parsed = JSON.parse(fs.readFileSync(p, 'utf-8'));
74
+ return parsed && typeof parsed === 'object' && parsed.units ? parsed : { version: 1, units: {} };
75
+ }
76
+ catch {
77
+ return { version: 1, units: {} }; // unreadable → treat as unrecorded, never throw
78
+ }
79
+ }
80
+ function writeBaselineFile(projectRoot, data) {
81
+ const p = baselinePath(projectRoot);
82
+ fs.mkdirSync(path.dirname(p), { recursive: true });
83
+ fs.writeFileSync(p, `${JSON.stringify(data, null, 2)}\n`, 'utf-8');
84
+ }
85
+ /**
86
+ * Hash the DECLARATION, not the prose: an author reflowing a sentence must not read
87
+ * as the taxonomy moving. Only the declared viewpoint ids are fingerprinted.
88
+ */
89
+ function fingerprint(ids) {
90
+ return crypto.createHash('sha1').update(ids.join('\n')).digest('hex').slice(0, 12);
91
+ }
92
+ /**
93
+ * Compare the unit's current test-viewpoint declaration to the recorded baseline.
94
+ * Read-only — recording is the caller's decision, so an audit stays a measurement
95
+ * and `--accept-viewpoint` stays the explicit human act. A first sighting reports
96
+ * `new` (nothing to compare against yet).
97
+ */
98
+ function checkViewpointBaseline(projectRoot, unitId, viewpointPath) {
99
+ if (!fs.existsSync(viewpointPath))
100
+ return { status: 'absent', hash: '', ids: [] };
101
+ const ids = (0, parse_1.parseViewpointOverview)(viewpointPath).map((v) => v.id).sort();
102
+ const hash = fingerprint(ids);
103
+ if (ids.length === 0)
104
+ return { status: 'absent', hash: '', ids: [] };
105
+ const file = readBaselineFile(projectRoot);
106
+ const recorded = file.units[unitId];
107
+ if (!recorded)
108
+ return { status: 'new', hash, ids };
109
+ if (recorded.hash === hash)
110
+ return { status: 'unchanged', hash, ids, recordedAt: recorded.recordedAt };
111
+ const before = new Set(recorded.ids ?? []);
112
+ const now = new Set(ids);
113
+ return {
114
+ status: 'changed',
115
+ hash,
116
+ ids,
117
+ added: ids.filter((i) => !before.has(i)),
118
+ removed: (recorded.ids ?? []).filter((i) => !now.has(i)),
119
+ recordedAt: recorded.recordedAt,
120
+ };
121
+ }
122
+ /** Record the current declaration as the accepted baseline (`sungen audit --accept-viewpoint`). */
123
+ function acceptViewpointBaseline(projectRoot, unitId, current) {
124
+ const file = readBaselineFile(projectRoot);
125
+ file.units[unitId] = {
126
+ hash: current.hash,
127
+ ids: current.ids,
128
+ recordedAt: new Date().toISOString(),
129
+ };
130
+ writeBaselineFile(projectRoot, file);
131
+ }
132
+ /** Read the declaration without recording anything (used by `--accept-viewpoint`). */
133
+ function readViewpointDeclaration(viewpointPath) {
134
+ if (!fs.existsSync(viewpointPath))
135
+ return null;
136
+ const ids = (0, parse_1.parseViewpointOverview)(viewpointPath).map((v) => v.id).sort();
137
+ if (ids.length === 0)
138
+ return null;
139
+ return { hash: fingerprint(ids), ids };
140
+ }
141
+ //# sourceMappingURL=viewpoint-baseline.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"viewpoint-baseline.js","sourceRoot":"","sources":["../../src/harness/viewpoint-baseline.ts"],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+EA,wDAyBC;AAGD,0DAYC;AAGD,4DAKC;AA/HD,+CAAiC;AACjC,uCAAyB;AACzB,2CAA6B;AAC7B,mCAAiD;AAEjD;;;;;;;;;;;;;;;;;;GAkBG;AAEH,+FAA+F;AAClF,QAAA,wBAAwB,GAAG,CAAC,cAAc,EAAE,cAAc,CAAU,CAAC;AAkBlF,SAAS,YAAY,CAAC,WAAmB;IACvC,OAAO,IAAI,CAAC,IAAI,CAAC,WAAW,EAAE,SAAS,EAAE,yBAAyB,CAAC,CAAC;AACtE,CAAC;AAED,SAAS,gBAAgB,CAAC,WAAmB;IAC3C,MAAM,CAAC,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;IACpC,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,CAAC,CAAC;QAAE,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IACxD,IAAI,CAAC;QACH,MAAM,MAAM,GAAG,IAAI,CAAC,KAAK,CAAC,EAAE,CAAC,YAAY,CAAC,CAAC,EAAE,OAAO,CAAC,CAAiB,CAAC;QACvE,OAAO,MAAM,IAAI,OAAO,MAAM,KAAK,QAAQ,IAAI,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC;IACnG,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,EAAE,OAAO,EAAE,CAAC,EAAE,KAAK,EAAE,EAAE,EAAE,CAAC,CAAG,gDAAgD;IACtF,CAAC;AACH,CAAC;AAED,SAAS,iBAAiB,CAAC,WAAmB,EAAE,IAAkB;IAChE,MAAM,CAAC,GAAG,YAAY,CAAC,WAAW,CAAC,CAAC;IACpC,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACnD,EAAE,CAAC,aAAa,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC;AACrE,CAAC;AAED;;;GAGG;AACH,SAAS,WAAW,CAAC,GAAa;IAChC,OAAO,MAAM,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC;AACrF,CAAC;AAED;;;;;GAKG;AACH,SAAgB,sBAAsB,CACpC,WAAmB,EACnB,MAAc,EACd,aAAqB;IAErB,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC;IAClF,MAAM,GAAG,GAAG,IAAA,8BAAsB,EAAC,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC1E,MAAM,IAAI,GAAG,WAAW,CAAC,GAAG,CAAC,CAAC;IAC9B,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,EAAE,MAAM,EAAE,QAAQ,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,EAAE,EAAE,CAAC;IAErE,MAAM,IAAI,GAAG,gBAAgB,CAAC,WAAW,CAAC,CAAC;IAC3C,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;IACpC,IAAI,CAAC,QAAQ;QAAE,OAAO,EAAE,MAAM,EAAE,KAAK,EAAE,IAAI,EAAE,GAAG,EAAE,CAAC;IACnD,IAAI,QAAQ,CAAC,IAAI,KAAK,IAAI;QAAE,OAAO,EAAE,MAAM,EAAE,WAAW,EAAE,IAAI,EAAE,GAAG,EAAE,UAAU,EAAE,QAAQ,CAAC,UAAU,EAAE,CAAC;IAEvG,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,QAAQ,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC;IAC3C,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,GAAG,CAAC,CAAC;IACzB,OAAO;QACL,MAAM,EAAE,SAAS;QACjB,IAAI;QACJ,GAAG;QACH,KAAK,EAAE,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACxC,OAAO,EAAE,CAAC,QAAQ,CAAC,GAAG,IAAI,EAAE,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC;QACxD,UAAU,EAAE,QAAQ,CAAC,UAAU;KAChC,CAAC;AACJ,CAAC;AAED,mGAAmG;AACnG,SAAgB,uBAAuB,CACrC,WAAmB,EACnB,MAAc,EACd,OAAwC;IAExC,MAAM,IAAI,GAAG,gBAAgB,CAAC,WAAW,CAAC,CAAC;IAC3C,IAAI,CAAC,KAAK,CAAC,MAAM,CAAC,GAAG;QACnB,IAAI,EAAE,OAAO,CAAC,IAAI;QAClB,GAAG,EAAE,OAAO,CAAC,GAAG;QAChB,UAAU,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;KACrC,CAAC;IACF,iBAAiB,CAAC,WAAW,EAAE,IAAI,CAAC,CAAC;AACvC,CAAC;AAED,sFAAsF;AACtF,SAAgB,wBAAwB,CAAC,aAAqB;IAC5D,IAAI,CAAC,EAAE,CAAC,UAAU,CAAC,aAAa,CAAC;QAAE,OAAO,IAAI,CAAC;IAC/C,MAAM,GAAG,GAAG,IAAA,8BAAsB,EAAC,aAAa,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAC1E,IAAI,GAAG,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAClC,OAAO,EAAE,IAAI,EAAE,WAAW,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC;AACzC,CAAC"}
@@ -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:
@@ -340,6 +340,7 @@ Security: [S1 – admin only]
340
340
  `sungen audit` enforces these. Generate compliant output up front:
341
341
 
342
342
  1. **Taxonomy-match** (`VP-TAXONOMY-MISMATCH`, gate-FAIL) — when `test-viewpoint.md` declares its own viewpoint IDs (e.g. `VP0`, `VP1`, … `VP12`, `MS-HP-001`, `MS-EH-001`), **reuse those IDs verbatim as the scenario codes**. Do NOT invent a generic `VP-UI / VP-LOGIC / VP-VAL` scheme — that breaks the coverage matrix. Only fall back to `VP-<CATEGORY>-<NNN>` when the viewpoint file declares no IDs.
343
+ - **Match the scenarios to the file — never the file to the scenarios.** A filled `test-viewpoint.md` is an input; do not rewrite its declarations to fit what you generated. That is not compliance, it is moving the yardstick: `traceability` + `atomicLedger` then read 100% by construction and a dropped viewpoint stops being missing from anything. Disagree with the taxonomy → propose the diff and ask. `sungen audit` reports `VIEWPOINT-BASELINE-CHANGED` and excludes both axes until a human accepts the change (`sungen audit --screen <name> --accept-viewpoint`).
343
344
  2. **Spec-coverage triggers** (`TRIGGER-UNCOVERED`, gate-FAIL) — the Validation-Rules table lists a **trigger** per constraint (e.g. `blur, submit`). Generate one scenario **per (constraint × trigger)** — a `format` rule validating *on blur AND on submit* needs BOTH a blur scenario (`press Tab`) and a submit scenario (`click [Submit]` / `press Enter`). Never collapse the trigger × input matrix to one representative case.
344
345
  3. **Claim-Proof** (`CLAIM-UNPROVEN`) — a title claiming `all`/`only`/`every`/`single`/`correct`/`same`/`changes`/`hidden`/`cleared`/`restored`/`independent`/`sanitized`/`announces` MUST have the matching assertion (`see all …`, count, `remember`+compare, `is hidden`, return-and-assert-empty, etc.). If the title promises it, the steps must prove it.
345
346
  - **Negative / absence claims** (`does not` / `no` / `never` / `prevents` / `không` / `chưa` — any language; `no-side-effect/no-duplicate`, `negative-claim/absence`): the `Then` must **differ** between the claim holding and not holding. A terminal `see [X] page` that looks identical whether or not the bad thing happened proves nothing. For a side-effect that should NOT repeat (re-submit on back, re-charge, duplicate order, resend OTP), assert the **count is unchanged** (`User see [Records] table with {{one}}` / `row with {{count}}`); if it's not UI-observable, mark `@manual` with a request-count oracle (shape below). This is general — it covers any side-effect, not a fixed verb list.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sun-asterisk/sungen",
3
- "version": "3.2.16-beta.12",
3
+ "version": "3.2.16-beta.13",
4
4
  "description": "Deterministic E2E Test Compiler - Gherkin + Selectors → Playwright tests",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -12,7 +12,7 @@
12
12
  "copy-templates": "mkdir -p dist/generators/test-generator/adapters/playwright/templates/steps && mkdir -p dist/generators/test-generator/templates && mkdir -p dist/orchestrator/templates && mkdir -p dist/dashboard/templates && cp -r src/generators/test-generator/adapters/playwright/templates/*.hbs dist/generators/test-generator/adapters/playwright/templates/ 2>/dev/null || true && cp -r src/generators/test-generator/adapters/playwright/templates/steps dist/generators/test-generator/adapters/playwright/templates/ && mkdir -p dist/generators/test-generator/adapters/appium/templates/steps && cp -r src/generators/test-generator/adapters/appium/templates/*.hbs dist/generators/test-generator/adapters/appium/templates/ 2>/dev/null || true && cp -r src/generators/test-generator/adapters/appium/templates/steps dist/generators/test-generator/adapters/appium/templates/ && cp src/generators/test-generator/templates/*.hbs dist/generators/test-generator/templates/ 2>/dev/null || true && cp -r src/orchestrator/templates/* dist/orchestrator/templates/ && cp src/dashboard/templates/index.html dist/dashboard/templates/index.html && mkdir -p dist/harness/catalog && cp src/harness/catalog/*.yaml dist/harness/catalog/",
13
13
  "build:dashboard": "cd ../../dashboard && npm install --silent && npm run build && cd - && cp ../../dashboard/dist/index.html src/dashboard/templates/index.html",
14
14
  "dev": "tsx src/cli/index.ts",
15
- "test": "tsx tests/golden/run.ts && tsx tests/audit/run.ts && tsx tests/ingest/run.ts && tsx tests/eval/run.ts && tsx tests/exporter/run.ts && tsx tests/exporter/delivery-matrix.run.ts && tsx tests/exporter/delivery-scope-strictness.run.ts && tsx tests/exporter/feature-parser-category.run.ts && tsx tests/exporter/api-detail-sheet.run.ts && tsx tests/exporter/overview-sheet.run.ts && tsx tests/exporter/delivery-divider-tcid.run.ts && tsx tests/exporter/note-expected-actual.run.ts && tsx tests/exporter/test-data-vars.run.ts && tsx tests/exporter/mobile-app-id.run.ts && tsx tests/exporter/api-testcase-cells.run.ts && tsx tests/exporter/delivery-cases-result.run.ts && tsx tests/exporter/precondition-auth-role.run.ts && tsx tests/exporter/report-builder-parity.run.ts && tsx tests/exporter/delivery-preflight-testdata.run.ts && tsx tests/exporter/delivery-preflight-selectors.run.ts && tsx tests/exporter/delivery-prerun-overview.run.ts && tsx tests/exporter/delivery-locale-env-discovery.run.ts && tsx tests/exporter/manual-testcase-testdata.run.ts && tsx tests/dashboard/api-flows-discovery.run.ts && tsx tests/dashboard/status-results-fallback.run.ts && tsx tests/codegen/verb-ref-masking.run.ts && tsx tests/codegen/table-aria-hidden-locators.run.ts && tsx tests/codegen/steps-base-not-compiled.run.ts && tsx tests/codegen/manual-oracle-gate.run.ts && tsx tests/codegen/locator-value-qualified-assertions.run.ts && tsx tests/codegen/table-match-data-column-mapping.run.ts && tsx tests/codegen/param-binding-native-type.run.ts && tsx tests/codegen/table-row-scope-redeclaration.run.ts && tsx tests/codegen/table-row-label-resolution.run.ts && tsx tests/api-runtime/base-path-url-join.run.ts && tsx tests/api-runtime/file-upload-multipart.run.ts && tsx tests/api-runtime/schema-assertion.run.ts && tsx tests/runtime/test-data-cross-ref.run.ts && tsx tests/runtime/test-data-array-projection.run.ts && tsx tests/runtime/env-secret-interpolation.run.ts && tsx tests/capabilities/run.ts && tsx tests/openapi/run.ts && tsx tests/api-field-coverage/run.ts && tsx tests/packaging/run.ts && tsx tests/generate-hint/run.ts && tsx tests/template-assertion/run.ts && tsx tests/journey/run.ts && tsx tests/harness/serial-cascade.run.ts && tsx tests/harness/score-model.run.ts && tsx tests/harness/spec-trace-crlf.run.ts && tsx tests/ai-skills/cross-assistant-orphan.run.ts && tsx tests/init/codex-mcp-config.run.ts && tsx src/orchestrator/ai-skills/golden-skills.test.ts && tsx tests/db-runtime/sql-placeholder-rewrite.run.ts && tsx tests/db-runtime/fallback-flow-codified.run.ts && tsx tests/db-runtime/cosmos-engine.run.ts && tsx tests/db-runtime/mongodb-engine.run.ts && tsx tests/db-runtime/dynamodb-engine.run.ts && tsx tests/db-runtime/mysql-integration.opt-in.run.ts && tsx tests/db-runtime/mongodb-integration.opt-in.run.ts && tsx tests/db-runtime/dynamodb-integration.opt-in.run.ts",
15
+ "test": "tsx tests/golden/run.ts && tsx tests/audit/run.ts && tsx tests/ingest/run.ts && tsx tests/eval/run.ts && tsx tests/exporter/run.ts && tsx tests/exporter/delivery-matrix.run.ts && tsx tests/exporter/delivery-scope-strictness.run.ts && tsx tests/exporter/feature-parser-category.run.ts && tsx tests/exporter/api-detail-sheet.run.ts && tsx tests/exporter/overview-sheet.run.ts && tsx tests/exporter/delivery-divider-tcid.run.ts && tsx tests/exporter/note-expected-actual.run.ts && tsx tests/exporter/test-data-vars.run.ts && tsx tests/exporter/mobile-app-id.run.ts && tsx tests/exporter/api-testcase-cells.run.ts && tsx tests/exporter/delivery-cases-result.run.ts && tsx tests/exporter/precondition-auth-role.run.ts && tsx tests/exporter/report-builder-parity.run.ts && tsx tests/exporter/delivery-preflight-testdata.run.ts && tsx tests/exporter/delivery-preflight-selectors.run.ts && tsx tests/exporter/delivery-prerun-overview.run.ts && tsx tests/exporter/delivery-locale-env-discovery.run.ts && tsx tests/exporter/manual-testcase-testdata.run.ts && tsx tests/dashboard/api-flows-discovery.run.ts && tsx tests/dashboard/status-results-fallback.run.ts && tsx tests/codegen/verb-ref-masking.run.ts && tsx tests/codegen/table-aria-hidden-locators.run.ts && tsx tests/codegen/steps-base-not-compiled.run.ts && tsx tests/codegen/manual-oracle-gate.run.ts && tsx tests/codegen/locator-value-qualified-assertions.run.ts && tsx tests/codegen/table-match-data-column-mapping.run.ts && tsx tests/codegen/param-binding-native-type.run.ts && tsx tests/codegen/table-row-scope-redeclaration.run.ts && tsx tests/codegen/table-row-label-resolution.run.ts && tsx tests/api-runtime/base-path-url-join.run.ts && tsx tests/api-runtime/file-upload-multipart.run.ts && tsx tests/api-runtime/schema-assertion.run.ts && tsx tests/runtime/test-data-cross-ref.run.ts && tsx tests/runtime/test-data-array-projection.run.ts && tsx tests/runtime/env-secret-interpolation.run.ts && tsx tests/capabilities/run.ts && tsx tests/openapi/run.ts && tsx tests/api-field-coverage/run.ts && tsx tests/packaging/run.ts && tsx tests/generate-hint/run.ts && tsx tests/template-assertion/run.ts && tsx tests/journey/run.ts && tsx tests/harness/serial-cascade.run.ts && tsx tests/harness/score-model.run.ts && tsx tests/harness/spec-trace-crlf.run.ts && tsx tests/harness/viewpoint-baseline.run.ts && tsx tests/ai-skills/cross-assistant-orphan.run.ts && tsx tests/init/codex-mcp-config.run.ts && tsx src/orchestrator/ai-skills/golden-skills.test.ts && tsx tests/db-runtime/sql-placeholder-rewrite.run.ts && tsx tests/db-runtime/fallback-flow-codified.run.ts && tsx tests/db-runtime/cosmos-engine.run.ts && tsx tests/db-runtime/mongodb-engine.run.ts && tsx tests/db-runtime/dynamodb-engine.run.ts && tsx tests/db-runtime/mysql-integration.opt-in.run.ts && tsx tests/db-runtime/mongodb-integration.opt-in.run.ts && tsx tests/db-runtime/dynamodb-integration.opt-in.run.ts",
16
16
  "test:update": "tsx tests/golden/run.ts --update && tsx tests/audit/run.ts --update && tsx tests/ingest/run.ts --update",
17
17
  "prepublishOnly": "npm run build:dashboard && npm run build"
18
18
  },
@@ -39,8 +39,8 @@
39
39
  "@babel/types": "^7.28.5",
40
40
  "@cucumber/gherkin": "^37.0.0",
41
41
  "@cucumber/messages": "^31.0.0",
42
- "@sungen/driver-data-factory": "3.2.16-beta.12",
43
- "@sungen/driver-ui": "3.2.16-beta.12",
42
+ "@sungen/driver-data-factory": "3.2.16-beta.13",
43
+ "@sungen/driver-ui": "3.2.16-beta.13",
44
44
  "chalk": "^5.6.2",
45
45
  "commander": "^14.0.2",
46
46
  "dotenv": "^17.2.3",
@@ -1,8 +1,9 @@
1
1
  import { Command } from 'commander';
2
2
  import * as path from 'path';
3
3
  import * as fs from 'fs';
4
- import { runAudit, AuditReport } from '../../harness/audit';
4
+ import { runAudit, AuditReport, catalogIdFromScreenDir } from '../../harness/audit';
5
5
  import { reportSlug } from '../../harness/unit-paths';
6
+ import { acceptViewpointBaseline } from '../../harness/viewpoint-baseline';
6
7
 
7
8
  function findScreenDir(name: string): string | null {
8
9
  // `name` may be a bare screen/flow/area, or an api unit id (`api/<area>`, `api/flows/<flow>`).
@@ -100,6 +101,7 @@ export function registerAuditCommand(program: Command): void {
100
101
  .option('--api <name>', 'API-first area or api flow to audit (e.g. orders, flows/signup)')
101
102
  .option('--area <name>', 'Alias of --api — an API-first area (qa/api/<name>)')
102
103
  .option('--json', 'Output the raw JSON report')
104
+ .option('--accept-viewpoint', 'Accept the current test-viewpoint.md as the baseline the suite is measured against')
103
105
  .action((options) => {
104
106
  try {
105
107
  const name = options.screen || options.api || options.area;
@@ -109,6 +111,21 @@ export function registerAuditCommand(program: Command): void {
109
111
 
110
112
  const report = runAudit(dir, name);
111
113
 
114
+ // The baseline is only ever written HERE, never by the measurement itself:
115
+ // a first sighting has nothing to compare against, and any later change is
116
+ // a human decision (`--accept-viewpoint`) — see harness/viewpoint-baseline.ts.
117
+ const unitId = catalogIdFromScreenDir(dir);
118
+ const vb = report.viewpointBaseline;
119
+ if (vb.status === 'new' || (options.acceptViewpoint && vb.status === 'changed')) {
120
+ acceptViewpointBaseline(process.cwd(), unitId, { hash: vb.hash, ids: vb.ids });
121
+ if (options.acceptViewpoint && !options.json) {
122
+ console.log(`\n ✓ test-viewpoint.md accepted as the baseline for ${name} (${vb.ids.length} viewpoint id(s)).`);
123
+ console.log(' Re-run the audit to score atomicLedger + traceability against it.\n');
124
+ }
125
+ } else if (options.acceptViewpoint && !options.json) {
126
+ console.log(`\n ○ nothing to accept for ${name} — the viewpoint baseline is ${vb.status}.\n`);
127
+ }
128
+
112
129
  // Persist report under .sungen/reports/ (flat slug: api flow `flows/x` → `flows-x-audit.json`).
113
130
  const outDir = path.join(process.cwd(), '.sungen', 'reports');
114
131
  fs.mkdirSync(outDir, { recursive: true });
@@ -407,6 +407,22 @@ function commonPrefix(lists: string[][]): number {
407
407
  return n;
408
408
  }
409
409
 
410
+ /**
411
+ * What the parent row's Action says when the variants share no opening step.
412
+ *
413
+ * The parent is the row a reviewer (and the customer) reads AS the test case, and
414
+ * with nothing to hoist its Action cell came out empty — 12 of 38 items in a real
415
+ * delivery. The steps are not missing, they are per-variant; say that, and name the
416
+ * axis they vary along so the cell carries meaning on its own.
417
+ */
418
+ export function varyingTriggerNote(dimensions: string | undefined, count: number): string {
419
+ // `dimensions` reads "matching rule — title only · content only · …"; the part
420
+ // before the dash is the axis name.
421
+ const axis = dimensions?.split('—')[0].trim();
422
+ const where = axis && axis.length > 0 && axis.length <= 60 ? ` by ${axis}` : '';
423
+ return `The action differs${where} — run each of the ${count} variants below with its own steps.`;
424
+ }
425
+
410
426
  /**
411
427
  * Expand one map variant ref to concrete variant refs:
412
428
  * a bare VP-id whose scenario has a dataset means ALL its rows.
@@ -471,7 +487,10 @@ export function buildMatrix(inputs: BuildInputs): MatrixModel {
471
487
  [v.ref, v.precondition.filter((line) => !commonPre.includes(line))])),
472
488
  triggerDeltas: Object.fromEntries(groupVariants.map((v) =>
473
489
  [v.ref, v.trigger.slice(prefixLen)])),
474
- trigger: (first?.trigger ?? []).slice(0, prefixLen),
490
+ // Nothing shared to hoist → say what varies rather than leaving the cell blank.
491
+ trigger: prefixLen > 0 || groupVariants.length === 0
492
+ ? (first?.trigger ?? []).slice(0, prefixLen)
493
+ : [varyingTriggerNote(g.dimensions, groupVariants.length)],
475
494
  variants: groupVariants,
476
495
  result,
477
496
  resultCounts: counts,
@@ -9,6 +9,7 @@
9
9
  import * as path from 'path';
10
10
  import * as fs from 'fs';
11
11
  import { loadScenarios, parseViewpointOverview, ScenarioInfo, ViewpointEntry } from './parse';
12
+ import { checkViewpointBaseline, ViewpointBaseline } from './viewpoint-baseline';
12
13
  import { featureBasename } from './unit-paths';
13
14
  import {
14
15
  loadCatalog, viewpointGate, assertionDepth, dataThemesFor, depthThresholdFor, coverageBalance, duplicateClusters, traceability, claimProof, taxonomyLint,
@@ -46,6 +47,7 @@ export interface AuditReport {
46
47
  flowDepth: FlowDepthResult; // H3 — stateful-flow regression depth (count / teardown / multi-source)
47
48
  oracle: OracleStrengthResult; // H4 — facet-oracle strength (weak name-substring vs title/detail/API/DB)
48
49
  ledger: LedgerResult; // atomic viewpoint-item coverage (per-bullet status)
50
+ viewpointBaseline: ViewpointBaseline; // is the yardstick still the accepted one? (#557)
49
51
  calibration: { // #8 — multi-axis score so a high overall can't hide a weak axis
50
52
  axes: Record<string, number>;
51
53
  weakest: { axis: string; value: number };
@@ -73,7 +75,7 @@ export interface AuditReport {
73
75
  }
74
76
 
75
77
  /** The catalog-resolution id for a unit dir (relative to qa/): screen · flows/<flow> · api/<area> · api/flows/<flow>. */
76
- function catalogIdFromScreenDir(screenDir: string): string {
78
+ export function catalogIdFromScreenDir(screenDir: string): string {
77
79
  const parts = screenDir.split(path.sep);
78
80
  const qa = parts.lastIndexOf('qa');
79
81
  if (qa >= 0) {
@@ -154,6 +156,9 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
154
156
  const capGate = scoringCap?.gateProvider as
155
157
  ((i: { scenarios: ScenarioInfo[]; viewpoints: ViewpointEntry[]; catalog: Catalog; focus: typeof intent.focus; cwd: string; screenName: string; threshold: number; businessCriticalMethods?: string[] }) => { gate: GateResult; depth: DepthResult }) | undefined;
156
158
  const provided = capGate?.({ scenarios, viewpoints, catalog, focus: intent.focus, cwd: projectRootFromScreenDir(screenDir), screenName: catalogScreenName, threshold: depthThresholdFor(intent.focus), businessCriticalMethods: intent.businessCriticalMethods });
159
+ // Is the declaration this suite is measured against still the one a human accepted?
160
+ const viewpointBaseline = checkViewpointBaseline(
161
+ projectRootFromScreenDir(screenDir), catalogScreenName, viewpointPath);
157
162
  const viewpointText = fs.existsSync(viewpointPath) ? readTextFile(viewpointPath) : '';
158
163
  const gate = provided?.gate ?? viewpointGate(scenarios, viewpoints, catalog, platform === 'mobile', viewpointText);
159
164
  const depth = provided?.depth ?? assertionDepth(scenarios, dataThemesFor(catalog, gate.pageType), intent.focus);
@@ -234,13 +239,19 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
234
239
  // claimProof, specFR, atomicLedger — now carry weight. A suite where two
235
240
  // thirds of the scenarios do not prove their own title used to score 7.9.
236
241
  const specRatio = spec.frTotal ? spec.frCovered / spec.frTotal : 1;
242
+ // 3. Evidence has to be INDEPENDENT of what it judges. `atomicLedger` and
243
+ // `traceability` both measure the suite against test-viewpoint.md, so a run
244
+ // that rewrites that file scores them 100% by construction — which is exactly
245
+ // what a create-test run did, silently dropping the performance viewpoint on
246
+ // the way. While the declaration is unconfirmed, neither axis is evidence.
247
+ const viewpointMoved = viewpointBaseline.status === 'changed';
237
248
  const axisDefs: Array<{ key: string; value: number; weight: number; applicable: boolean; critical: boolean }> = [
238
249
  { key: 'coverage', value: coverage, weight: 0.22, applicable: !!gate.pageType && gate.themesTotal > 0, critical: true },
239
250
  { key: 'specFR', value: specRatio, weight: 0.15, applicable: spec.hasSpec && spec.frTotal > 0, critical: true },
240
- { key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0, critical: true },
251
+ { key: 'atomicLedger', value: ledger.ratio, weight: 0.13, applicable: ledger.hasViewpoint && ledger.total > 0 && !viewpointMoved, critical: true },
241
252
  { key: 'businessDepth', value: businessDepth, weight: 0.20, applicable: true, critical: true },
242
253
  { key: 'claimProof', value: claim.ratio, weight: 0.15, applicable: claim.withClaims > 0, critical: true },
243
- { key: 'traceability', value: traceScore, weight: 0.09, applicable: viewpoints.length > 0, critical: false },
254
+ { key: 'traceability', value: traceScore, weight: 0.09, applicable: viewpoints.length > 0 && !viewpointMoved, critical: false },
244
255
  { key: 'balance', value: balanceScore, weight: 0.06, applicable: true, critical: false },
245
256
  ];
246
257
  const scored = axisDefs.filter((a) => a.applicable);
@@ -278,6 +289,13 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
278
289
  : `this unit supplies no evidence for [${missingEvidence.join(', ')}], and a top mark has to rest on complete evidence`;
279
290
  findings.push(`SCORE-CAPPED: overall held at ${cap.toFixed(1)} because ${why}.`);
280
291
  }
292
+ if (viewpointBaseline.status === 'changed') {
293
+ const moved = [
294
+ viewpointBaseline.removed?.length ? `removed [${viewpointBaseline.removed.join(', ')}]` : '',
295
+ viewpointBaseline.added?.length ? `added [${viewpointBaseline.added.join(', ')}]` : '',
296
+ ].filter(Boolean).join(', ');
297
+ findings.push(`VIEWPOINT-BASELINE-CHANGED: test-viewpoint.md no longer declares what it did when this unit was last accepted — ${moved || 'the declared ids were reordered or replaced'}. atomicLedger + traceability measure the suite AGAINST this file, so they are excluded from the score until the change is confirmed: a generator that rewrites the declaration scores both 100% by construction, and a viewpoint dropped from the file stops being missing from anything. Review the diff (a removed id means that coverage is now unclaimed), then run \`sungen audit --screen ${screenName} --accept-viewpoint\`.`);
298
+ }
281
299
  if (gate.pageTypeSource === 'undetermined') {
282
300
  findings.push(`PAGE-TYPE-UNDETERMINED: no page type fits this screen with enough confidence (best ${gate.pageTypeEvidence?.hits ?? 0} keyword hit(s)), so NO critical themes were demanded and the coverage axis is excluded from the score — declare it in test-viewpoint.md (\`page-type: <id>\`) to have the theme checklist applied.`);
283
301
  }
@@ -465,7 +483,7 @@ export function runAudit(screenDir: string, screenName: string): AuditReport {
465
483
  screen: screenName,
466
484
  scenarioCount: scenarios.length,
467
485
  gate, depth, claim, taxonomy, balance, duplicates, trace, spec,
468
- taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, calibration,
486
+ taxonomyMismatch, downstream, manualOracle: manualOracleResult, automatableManual: autoManual, flowDepth, oracle, ledger, viewpointBaseline, calibration,
469
487
  score: {
470
488
  overall: Math.round(overall * 10) / 10,
471
489
  coverage: Math.round(coverage * 100) / 100,
@@ -114,6 +114,11 @@ export function inferFromText(reason: string): string | undefined {
114
114
  return undefined;
115
115
  }
116
116
 
117
+ /** Every code the text supports, not just the first rule in declaration order. */
118
+ export function inferAllFromText(reason: string): string[] {
119
+ return INFER.filter((r) => r.re.test(reason)).map((r) => r.code);
120
+ }
121
+
117
122
  export interface ReasonMismatch { scenario: string; explicit: string; inferred: string }
118
123
 
119
124
  /**
@@ -150,8 +155,15 @@ export function manualReasonMismatches(featurePath: string): ReasonMismatch[] {
150
155
  else if (l === '') continue;
151
156
  else break;
152
157
  }
153
- const inferred = inferFromText(parts.join(' '));
154
- if (inferred && inferred !== explicit) out.push({ scenario: m[1].trim(), explicit, inferred });
158
+ // The block scanned here includes the tester PROCEDURE, so several codes can
159
+ // match at once "not worth automating" (M8, the author's actual reason) and
160
+ // "the service stays responsive" (M6, a word about latency, not layout). The
161
+ // first rule in declaration order used to win and the correct tag was reported
162
+ // as a mismatch. A tag is only wrong when the text supports some OTHER code and
163
+ // NOT the declared one.
164
+ const codes = inferAllFromText(parts.join(' '));
165
+ const inferred = codes.find((c) => c !== explicit);
166
+ if (inferred && !codes.includes(explicit)) out.push({ scenario: m[1].trim(), explicit, inferred });
155
167
  }
156
168
  return out;
157
169
  }
@@ -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
+ }
@@ -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:
@@ -340,6 +340,7 @@ Security: [S1 – admin only]
340
340
  `sungen audit` enforces these. Generate compliant output up front:
341
341
 
342
342
  1. **Taxonomy-match** (`VP-TAXONOMY-MISMATCH`, gate-FAIL) — when `test-viewpoint.md` declares its own viewpoint IDs (e.g. `VP0`, `VP1`, … `VP12`, `MS-HP-001`, `MS-EH-001`), **reuse those IDs verbatim as the scenario codes**. Do NOT invent a generic `VP-UI / VP-LOGIC / VP-VAL` scheme — that breaks the coverage matrix. Only fall back to `VP-<CATEGORY>-<NNN>` when the viewpoint file declares no IDs.
343
+ - **Match the scenarios to the file — never the file to the scenarios.** A filled `test-viewpoint.md` is an input; do not rewrite its declarations to fit what you generated. That is not compliance, it is moving the yardstick: `traceability` + `atomicLedger` then read 100% by construction and a dropped viewpoint stops being missing from anything. Disagree with the taxonomy → propose the diff and ask. `sungen audit` reports `VIEWPOINT-BASELINE-CHANGED` and excludes both axes until a human accepts the change (`sungen audit --screen <name> --accept-viewpoint`).
343
344
  2. **Spec-coverage triggers** (`TRIGGER-UNCOVERED`, gate-FAIL) — the Validation-Rules table lists a **trigger** per constraint (e.g. `blur, submit`). Generate one scenario **per (constraint × trigger)** — a `format` rule validating *on blur AND on submit* needs BOTH a blur scenario (`press Tab`) and a submit scenario (`click [Submit]` / `press Enter`). Never collapse the trigger × input matrix to one representative case.
344
345
  3. **Claim-Proof** (`CLAIM-UNPROVEN`) — a title claiming `all`/`only`/`every`/`single`/`correct`/`same`/`changes`/`hidden`/`cleared`/`restored`/`independent`/`sanitized`/`announces` MUST have the matching assertion (`see all …`, count, `remember`+compare, `is hidden`, return-and-assert-empty, etc.). If the title promises it, the steps must prove it.
345
346
  - **Negative / absence claims** (`does not` / `no` / `never` / `prevents` / `không` / `chưa` — any language; `no-side-effect/no-duplicate`, `negative-claim/absence`): the `Then` must **differ** between the claim holding and not holding. A terminal `see [X] page` that looks identical whether or not the bad thing happened proves nothing. For a side-effect that should NOT repeat (re-submit on back, re-charge, duplicate order, resend OTP), assert the **count is unchanged** (`User see [Records] table with {{one}}` / `row with {{count}}`); if it's not UI-observable, mark `@manual` with a request-count oracle (shape below). This is general — it covers any side-effect, not a fixed verb list.