@sun-asterisk/sungen 3.2.32 → 3.2.33

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 (243) hide show
  1. package/dist/cli/commands/audit.d.ts +1 -0
  2. package/dist/cli/commands/audit.d.ts.map +1 -1
  3. package/dist/cli/commands/audit.js +10 -1
  4. package/dist/cli/commands/audit.js.map +1 -1
  5. package/dist/cli/commands/capability.d.ts.map +1 -1
  6. package/dist/cli/commands/capability.js +35 -17
  7. package/dist/cli/commands/capability.js.map +1 -1
  8. package/dist/cli/commands/delivery.d.ts.map +1 -1
  9. package/dist/cli/commands/delivery.js +39 -18
  10. package/dist/cli/commands/delivery.js.map +1 -1
  11. package/dist/cli/commands/generate.d.ts.map +1 -1
  12. package/dist/cli/commands/generate.js +14 -7
  13. package/dist/cli/commands/generate.js.map +1 -1
  14. package/dist/cli/commands/results-merge.d.ts.map +1 -1
  15. package/dist/cli/commands/results-merge.js +2 -12
  16. package/dist/cli/commands/results-merge.js.map +1 -1
  17. package/dist/cli/commands/results-sanitize.d.ts.map +1 -1
  18. package/dist/cli/commands/results-sanitize.js +2 -13
  19. package/dist/cli/commands/results-sanitize.js.map +1 -1
  20. package/dist/dashboard/snapshot-builder.d.ts.map +1 -1
  21. package/dist/dashboard/snapshot-builder.js +6 -10
  22. package/dist/dashboard/snapshot-builder.js.map +1 -1
  23. package/dist/exporters/feature-parser.d.ts +21 -0
  24. package/dist/exporters/feature-parser.d.ts.map +1 -1
  25. package/dist/exporters/feature-parser.js +32 -1
  26. package/dist/exporters/feature-parser.js.map +1 -1
  27. package/dist/exporters/locale-variants.d.ts +7 -1
  28. package/dist/exporters/locale-variants.d.ts.map +1 -1
  29. package/dist/exporters/locale-variants.js +23 -16
  30. package/dist/exporters/locale-variants.js.map +1 -1
  31. package/dist/exporters/matrix/build.d.ts.map +1 -1
  32. package/dist/exporters/matrix/build.js +13 -7
  33. package/dist/exporters/matrix/build.js.map +1 -1
  34. package/dist/exporters/matrix/carry-manual-results.d.ts +70 -0
  35. package/dist/exporters/matrix/carry-manual-results.d.ts.map +1 -0
  36. package/dist/exporters/matrix/carry-manual-results.js +232 -0
  37. package/dist/exporters/matrix/carry-manual-results.js.map +1 -0
  38. package/dist/exporters/matrix/export.d.ts +2 -0
  39. package/dist/exporters/matrix/export.d.ts.map +1 -1
  40. package/dist/exporters/matrix/export.js +7 -1
  41. package/dist/exporters/matrix/export.js.map +1 -1
  42. package/dist/exporters/matrix/wording.d.ts +7 -0
  43. package/dist/exporters/matrix/wording.d.ts.map +1 -1
  44. package/dist/exporters/matrix/wording.js +27 -1
  45. package/dist/exporters/matrix/wording.js.map +1 -1
  46. package/dist/exporters/xlsx-exporter.d.ts +2 -0
  47. package/dist/exporters/xlsx-exporter.d.ts.map +1 -1
  48. package/dist/exporters/xlsx-exporter.js +7 -2
  49. package/dist/exporters/xlsx-exporter.js.map +1 -1
  50. package/dist/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +2 -2
  51. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +8 -2
  52. package/dist/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +7 -3
  53. package/dist/generators/test-generator/adapters/appium/templates/test-file.hbs +68 -4
  54. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +7 -1
  55. package/dist/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +8 -4
  56. package/dist/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +5 -1
  57. package/dist/generators/test-generator/code-generator.d.ts +2 -1
  58. package/dist/generators/test-generator/code-generator.d.ts.map +1 -1
  59. package/dist/generators/test-generator/code-generator.js +67 -3
  60. package/dist/generators/test-generator/code-generator.js.map +1 -1
  61. package/dist/generators/test-generator/diagnostics.d.ts +33 -4
  62. package/dist/generators/test-generator/diagnostics.d.ts.map +1 -1
  63. package/dist/generators/test-generator/diagnostics.js +32 -3
  64. package/dist/generators/test-generator/diagnostics.js.map +1 -1
  65. package/dist/generators/test-generator/patterns/index.d.ts +12 -2
  66. package/dist/generators/test-generator/patterns/index.d.ts.map +1 -1
  67. package/dist/generators/test-generator/patterns/index.js +15 -9
  68. package/dist/generators/test-generator/patterns/index.js.map +1 -1
  69. package/dist/generators/test-generator/patterns/types.d.ts +15 -0
  70. package/dist/generators/test-generator/patterns/types.d.ts.map +1 -1
  71. package/dist/generators/test-generator/patterns/with-parent-scope.d.ts +5 -0
  72. package/dist/generators/test-generator/patterns/with-parent-scope.d.ts.map +1 -1
  73. package/dist/generators/test-generator/patterns/with-parent-scope.js +6 -0
  74. package/dist/generators/test-generator/patterns/with-parent-scope.js.map +1 -1
  75. package/dist/generators/test-generator/step-mapper.d.ts +46 -0
  76. package/dist/generators/test-generator/step-mapper.d.ts.map +1 -1
  77. package/dist/generators/test-generator/step-mapper.js +84 -9
  78. package/dist/generators/test-generator/step-mapper.js.map +1 -1
  79. package/dist/generators/test-generator/utils/data-resolver.d.ts +25 -0
  80. package/dist/generators/test-generator/utils/data-resolver.d.ts.map +1 -1
  81. package/dist/generators/test-generator/utils/data-resolver.js +39 -8
  82. package/dist/generators/test-generator/utils/data-resolver.js.map +1 -1
  83. package/dist/generators/test-generator/utils/selector-entry-fields.d.ts.map +1 -1
  84. package/dist/generators/test-generator/utils/selector-entry-fields.js +1 -0
  85. package/dist/generators/test-generator/utils/selector-entry-fields.js.map +1 -1
  86. package/dist/generators/test-generator/utils/selector-resolver.d.ts +37 -4
  87. package/dist/generators/test-generator/utils/selector-resolver.d.ts.map +1 -1
  88. package/dist/generators/test-generator/utils/selector-resolver.js +212 -79
  89. package/dist/generators/test-generator/utils/selector-resolver.js.map +1 -1
  90. package/dist/harness/audit.d.ts.map +1 -1
  91. package/dist/harness/audit.js +12 -4
  92. package/dist/harness/audit.js.map +1 -1
  93. package/dist/harness/capability.d.ts +7 -1
  94. package/dist/harness/capability.d.ts.map +1 -1
  95. package/dist/harness/capability.js +15 -12
  96. package/dist/harness/capability.js.map +1 -1
  97. package/dist/harness/catalog/universal-viewpoints.yaml +5 -5
  98. package/dist/harness/config-file.d.ts +17 -0
  99. package/dist/harness/config-file.d.ts.map +1 -0
  100. package/dist/harness/config-file.js +100 -0
  101. package/dist/harness/config-file.js.map +1 -0
  102. package/dist/harness/depth-lint.d.ts.map +1 -1
  103. package/dist/harness/depth-lint.js +2 -1
  104. package/dist/harness/depth-lint.js.map +1 -1
  105. package/dist/harness/flow-check.d.ts.map +1 -1
  106. package/dist/harness/flow-check.js +6 -2
  107. package/dist/harness/flow-check.js.map +1 -1
  108. package/dist/harness/keyword-match.d.ts +59 -0
  109. package/dist/harness/keyword-match.d.ts.map +1 -0
  110. package/dist/harness/keyword-match.js +86 -0
  111. package/dist/harness/keyword-match.js.map +1 -0
  112. package/dist/harness/perf.d.ts +7 -1
  113. package/dist/harness/perf.d.ts.map +1 -1
  114. package/dist/harness/perf.js +13 -10
  115. package/dist/harness/perf.js.map +1 -1
  116. package/dist/harness/quality-gates.d.ts +9 -2
  117. package/dist/harness/quality-gates.d.ts.map +1 -1
  118. package/dist/harness/quality-gates.js +16 -4
  119. package/dist/harness/quality-gates.js.map +1 -1
  120. package/dist/harness/query-catalog.js +0 -0
  121. package/dist/harness/query-catalog.js.map +1 -1
  122. package/dist/harness/read-text.d.ts +6 -0
  123. package/dist/harness/read-text.d.ts.map +1 -1
  124. package/dist/harness/read-text.js +7 -1
  125. package/dist/harness/read-text.js.map +1 -1
  126. package/dist/harness/secret-scan.d.ts +27 -1
  127. package/dist/harness/secret-scan.d.ts.map +1 -1
  128. package/dist/harness/secret-scan.js +57 -4
  129. package/dist/harness/secret-scan.js.map +1 -1
  130. package/dist/harness/sensors.d.ts +12 -0
  131. package/dist/harness/sensors.d.ts.map +1 -1
  132. package/dist/harness/sensors.js +41 -18
  133. package/dist/harness/sensors.js.map +1 -1
  134. package/dist/harness/spec-branches.d.ts +0 -18
  135. package/dist/harness/spec-branches.d.ts.map +1 -1
  136. package/dist/harness/spec-branches.js +23 -2
  137. package/dist/harness/spec-branches.js.map +1 -1
  138. package/dist/harness/spec-coverage.d.ts.map +1 -1
  139. package/dist/harness/spec-coverage.js +14 -4
  140. package/dist/harness/spec-coverage.js.map +1 -1
  141. package/dist/harness/viewpoint-ledger.d.ts.map +1 -1
  142. package/dist/harness/viewpoint-ledger.js +2 -1
  143. package/dist/harness/viewpoint-ledger.js.map +1 -1
  144. package/dist/harness/write-atomic.d.ts +2 -0
  145. package/dist/harness/write-atomic.d.ts.map +1 -0
  146. package/dist/harness/write-atomic.js +62 -0
  147. package/dist/harness/write-atomic.js.map +1 -0
  148. package/dist/orchestrator/ai-skills/writer.d.ts.map +1 -1
  149. package/dist/orchestrator/ai-skills/writer.js +15 -7
  150. package/dist/orchestrator/ai-skills/writer.js.map +1 -1
  151. package/dist/orchestrator/assets-drift.d.ts.map +1 -1
  152. package/dist/orchestrator/assets-drift.js +11 -6
  153. package/dist/orchestrator/assets-drift.js.map +1 -1
  154. package/dist/orchestrator/project-deps-sync.d.ts.map +1 -1
  155. package/dist/orchestrator/project-deps-sync.js +22 -1
  156. package/dist/orchestrator/project-deps-sync.js.map +1 -1
  157. package/dist/orchestrator/project-initializer.d.ts +8 -0
  158. package/dist/orchestrator/project-initializer.d.ts.map +1 -1
  159. package/dist/orchestrator/project-initializer.js +49 -7
  160. package/dist/orchestrator/project-initializer.js.map +1 -1
  161. package/dist/orchestrator/security-overrides-catalog.d.ts +39 -0
  162. package/dist/orchestrator/security-overrides-catalog.d.ts.map +1 -0
  163. package/dist/orchestrator/security-overrides-catalog.js +63 -0
  164. package/dist/orchestrator/security-overrides-catalog.js.map +1 -0
  165. package/dist/orchestrator/security-overrides.d.ts +51 -0
  166. package/dist/orchestrator/security-overrides.d.ts.map +1 -0
  167. package/dist/orchestrator/security-overrides.js +165 -0
  168. package/dist/orchestrator/security-overrides.js.map +1 -0
  169. package/dist/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -0
  170. package/dist/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +3 -1
  171. package/dist/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +9 -0
  172. package/dist/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +13 -1
  173. package/dist/orchestrator/templates/specs-locale-fixture.ts +21 -9
  174. package/dist/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  175. package/dist/orchestrator/templates/specs-url-assert.d.ts +17 -1
  176. package/dist/orchestrator/templates/specs-url-assert.d.ts.map +1 -1
  177. package/dist/orchestrator/templates/specs-url-assert.js +14 -2
  178. package/dist/orchestrator/templates/specs-url-assert.js.map +1 -1
  179. package/dist/orchestrator/templates/specs-url-assert.ts +24 -2
  180. package/dist/orchestrator/templates/wdio.conf.ts +24 -1
  181. package/package.json +3 -3
  182. package/src/cli/commands/audit.ts +10 -2
  183. package/src/cli/commands/capability.ts +51 -18
  184. package/src/cli/commands/delivery.ts +38 -16
  185. package/src/cli/commands/generate.ts +15 -7
  186. package/src/cli/commands/results-merge.ts +2 -8
  187. package/src/cli/commands/results-sanitize.ts +2 -9
  188. package/src/dashboard/snapshot-builder.ts +6 -8
  189. package/src/exporters/feature-parser.ts +32 -2
  190. package/src/exporters/locale-variants.ts +23 -17
  191. package/src/exporters/matrix/build.ts +14 -8
  192. package/src/exporters/matrix/carry-manual-results.ts +229 -0
  193. package/src/exporters/matrix/export.ts +10 -4
  194. package/src/exporters/matrix/wording.ts +26 -1
  195. package/src/exporters/xlsx-exporter.ts +7 -2
  196. package/src/generators/test-generator/adapters/adapter-interface.ts +2 -2
  197. package/src/generators/test-generator/adapters/appium/templates/steps/actions/reinstall-app-action.hbs +2 -2
  198. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/label-value-assertion.hbs +8 -2
  199. package/src/generators/test-generator/adapters/appium/templates/steps/assertions/visible-with-role-variable-assertion.hbs +7 -3
  200. package/src/generators/test-generator/adapters/appium/templates/test-file.hbs +68 -4
  201. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/label-value-assertion.hbs +7 -1
  202. package/src/generators/test-generator/adapters/playwright/templates/steps/assertions/visible-with-role-variable-assertion.hbs +8 -4
  203. package/src/generators/test-generator/adapters/playwright/templates/steps/partials/url-assertion.hbs +5 -1
  204. package/src/generators/test-generator/code-generator.ts +54 -3
  205. package/src/generators/test-generator/diagnostics.ts +33 -4
  206. package/src/generators/test-generator/patterns/index.ts +15 -8
  207. package/src/generators/test-generator/patterns/types.ts +15 -0
  208. package/src/generators/test-generator/patterns/with-parent-scope.ts +6 -0
  209. package/src/generators/test-generator/step-mapper.ts +90 -8
  210. package/src/generators/test-generator/utils/data-resolver.ts +41 -12
  211. package/src/generators/test-generator/utils/selector-entry-fields.ts +1 -0
  212. package/src/generators/test-generator/utils/selector-resolver.ts +238 -82
  213. package/src/harness/audit.ts +17 -7
  214. package/src/harness/capability.ts +23 -12
  215. package/src/harness/catalog/universal-viewpoints.yaml +5 -5
  216. package/src/harness/config-file.ts +66 -0
  217. package/src/harness/depth-lint.ts +2 -1
  218. package/src/harness/flow-check.ts +6 -2
  219. package/src/harness/keyword-match.ts +85 -0
  220. package/src/harness/perf.ts +15 -7
  221. package/src/harness/quality-gates.ts +16 -4
  222. package/src/harness/query-catalog.ts +0 -0
  223. package/src/harness/read-text.ts +7 -1
  224. package/src/harness/secret-scan.ts +60 -4
  225. package/src/harness/sensors.ts +50 -19
  226. package/src/harness/spec-branches.ts +5 -2
  227. package/src/harness/spec-coverage.ts +14 -4
  228. package/src/harness/viewpoint-ledger.ts +2 -1
  229. package/src/harness/write-atomic.ts +22 -0
  230. package/src/orchestrator/ai-skills/writer.ts +18 -3
  231. package/src/orchestrator/assets-drift.ts +10 -5
  232. package/src/orchestrator/project-deps-sync.ts +23 -1
  233. package/src/orchestrator/project-initializer.ts +54 -6
  234. package/src/orchestrator/security-overrides-catalog.ts +69 -0
  235. package/src/orchestrator/security-overrides.ts +149 -0
  236. package/src/orchestrator/templates/ai-src/skills/sungen-delivery/SKILL.md +14 -0
  237. package/src/orchestrator/templates/ai-src/skills/sungen-error-mapping/SKILL.md +3 -1
  238. package/src/orchestrator/templates/ai-src/skills/sungen-gherkin-syntax/SKILL.md +9 -0
  239. package/src/orchestrator/templates/ai-src/skills/sungen-selector-keys/SKILL.md +13 -1
  240. package/src/orchestrator/templates/specs-locale-fixture.ts +21 -9
  241. package/src/orchestrator/templates/specs-pw-shape-reporter.ts +0 -0
  242. package/src/orchestrator/templates/specs-url-assert.ts +24 -2
  243. package/src/orchestrator/templates/wdio.conf.ts +24 -1
@@ -12,6 +12,7 @@
12
12
  import * as fs from 'fs';
13
13
  import * as path from 'path';
14
14
  import { featureFilesFor } from './unit-paths';
15
+ import { keywordHit } from './keyword-match';
15
16
  import { loadScenarios } from './parse';
16
17
  import { parseScenarios } from './capability-plan';
17
18
  import { readTextFile } from './read-text';
@@ -45,7 +46,10 @@ function featureTags(fp: string): string[] {
45
46
  }
46
47
  function targetsFromHint(hint: string): string[] {
47
48
  const h = hint.toLowerCase();
48
- const hits = KNOWN_TARGETS.filter((k) => h.includes(k));
49
+ // Word-aware (#779): every KNOWN_TARGET is an ordinary noun, so a substring test read
50
+ // `re·order` / `dis·order` as the target `order` and then matched the two to each other —
51
+ // a deferral about reorder history reported COVERED by a flow scenario about a disorder.
52
+ const hits = KNOWN_TARGETS.filter((k) => keywordHit(h, k));
49
53
  if (hits.length) return hits;
50
54
  // fallback: significant words after "->"
51
55
  const after = h.split('->').pop() || h;
@@ -87,7 +91,7 @@ export function buildFlowCheck(cwd: string, onlyFlow?: string): FlowCheckReport
87
91
  // `@owned-by:<flow>` names the owner explicitly → only that flow's scenarios can cover it
88
92
  // (a false @owned-by is then surfaced as missing). Else any flow may cover it (legacy).
89
93
  const pool = s.ownedBy ? flowScenarios.filter((fs2) => fs2.flow === s.ownedBy) : flowScenarios;
90
- const matches = pool.filter((fs2) => targets.some((t) => fs2.haystack.includes(t)));
94
+ const matches = pool.filter((fs2) => targets.some((t) => keywordHit(fs2.haystack, t)));
91
95
  let verdict: Deferral['verdict'] = 'missing';
92
96
  let via: string | undefined;
93
97
  if (matches.some((m) => m.deep)) { verdict = 'covered'; via = matches.find((m) => m.deep)!.flow; }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Keyword matching for the audit's coverage sensors — the single rule every "does a scenario
3
+ * mention this?" question goes through.
4
+ *
5
+ * WHY THIS EXISTS (#776). The sensors used to ask `haystack.includes(keyword)`. A substring match
6
+ * is satisfied by a keyword buried inside an unrelated word, and it fails in the direction that
7
+ * HIDES problems: a theme nobody tested reads as covered. Measured case — the scenario title
8
+ * "…five steps from email through to information entry" made `format-boundary` covered on a screen
9
+ * with no text input at all, because in·format·ion contains `format`. That one word moved the
10
+ * screen from 7.9 FAIL to 8.9 PASS.
11
+ *
12
+ * THE RULE: anchor a word boundary at the START of the keyword, leave the END free.
13
+ *
14
+ * Not `\b…\b` at both ends, which is the obvious fix and is wrong. Every collision this defect
15
+ * produces is a PREFIX collision — the innocent word carries extra letters BEFORE the keyword:
16
+ *
17
+ * format ← in·format·ion, ·format·ting range ← ar·range·ment, o·range
18
+ * saved ← un·saved created ← re·created
19
+ * success ← un·success·ful length ← wave·length
20
+ *
21
+ * The `unsaved` / `unsuccessful` pair is the sharp end: the prefix INVERTS the meaning, so a
22
+ * scenario asserting a submit FAILED is what marks the submit-SUCCESS theme covered.
23
+ *
24
+ * Meanwhile English inflection lives entirely in the SUFFIX, and a trailing `\b` destroys it:
25
+ * `submit` stops matching "submitted", `success` stops matching "successfully", `valid` stops
26
+ * matching "validation", `create` stops matching "created". Those are true positives, and a
27
+ * both-ends boundary turns 4 of 5 of them into false gaps — trading a silent failure for a noisy
28
+ * one. A leading boundary alone kills all six collisions above and keeps all of them.
29
+ *
30
+ * WHAT THIS RULE DOES NOT FIX, stated rather than implied. A free suffix still admits a collision
31
+ * when the keyword is a true PREFIX of a common unrelated word:
32
+ *
33
+ * feed → "feedback" list → "listener" length → "lengthy"
34
+ *
35
+ * No boundary rule can separate `feed` from `feedback`, so the cure is a less ambiguous KEYWORD,
36
+ * which is a catalog decision rather than a matcher one. #778 took it: the shipped catalog no
37
+ * longer carries any of the three bare forms — `"product list"`, `"feed item"` and
38
+ * `"max length"` / `"min length"` replaced them, and `cart-correctness` lost bare `price` and
39
+ * `quantity` (ordinary listing vocabulary that credited a cart theme on a screen with no cart).
40
+ *
41
+ * That closes today's instances, not the class. `tests/harness/keyword-word-boundary.run.ts`
42
+ * section (e) sweeps every single-word ASCII keyword in the catalog — theme `keywords`,
43
+ * `detect_keywords` and `depth.keywords` alike — so a NEW loose keyword fails the suite instead of
44
+ * entering silently. Adding one is possible, but only as a conscious act via that file's
45
+ * `KNOWN_LOOSE` map, which #778 left empty.
46
+ *
47
+ * NON-ASCII KEYWORDS fall back to substring, on purpose. JavaScript's `\b` is defined against
48
+ * `[A-Za-z0-9_]`, so `\bログイン` and `\bđăng nhập` never match anything at all — the anchor would
49
+ * silently disable the keyword rather than tighten it. Japanese and Chinese have no word
50
+ * boundaries to anchor to, which is precisely where substring matching is the CORRECT behaviour
51
+ * and the false-positive risk is low. Today every keyword reaching here is ASCII by construction
52
+ * (the catalog is English; the ledger and spec sensors extract words with `[a-z]` character
53
+ * classes), so this branch is a guard against a future non-ASCII catalog entry failing closed.
54
+ */
55
+
56
+ /** True when `kw`'s first character can carry a `\b` anchor — i.e. it is an ASCII word character. */
57
+ const anchorable = (kw: string): boolean => /^[a-z0-9_]/i.test(kw);
58
+
59
+ const cache = new Map<string, RegExp | null>();
60
+
61
+ /**
62
+ * Compile `kw` to its matcher, or `null` when the keyword must be compared as a plain substring.
63
+ * Cached: the sensors re-ask the same handful of catalog keywords once per scenario.
64
+ */
65
+ function matcherFor(kw: string): RegExp | null {
66
+ const key = kw.toLowerCase();
67
+ if (cache.has(key)) return cache.get(key)!;
68
+ const re = anchorable(key)
69
+ ? new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}`)
70
+ : null;
71
+ cache.set(key, re);
72
+ return re;
73
+ }
74
+
75
+ /** Does `haystack` mention `kw` as a word (not as the tail of a longer one)? */
76
+ export function keywordHit(haystack: string, kw: string): boolean {
77
+ const h = haystack.toLowerCase();
78
+ const re = matcherFor(kw);
79
+ return re ? re.test(h) : h.includes(kw.toLowerCase());
80
+ }
81
+
82
+ /** Does ANY of `haystacks` mention `kw`? */
83
+ export function hasKeyword(haystacks: string[], kw: string): boolean {
84
+ return haystacks.some((h) => keywordHit(h, kw));
85
+ }
@@ -23,10 +23,8 @@
23
23
  * units:
24
24
  * place-order: { scenario_ms: 20000 }
25
25
  */
26
- import * as fs from 'fs';
27
26
  import * as path from 'path';
28
- import { parse as parseYaml } from 'yaml';
29
- import { readTextFile } from './read-text';
27
+ import { readYamlConfig, warnUnreadableConfig } from './config-file';
30
28
 
31
29
  export interface PerfConfig {
32
30
  /** 0..100 — e.g. 75 for p75. */
@@ -51,12 +49,22 @@ export function perfConfigPath(projectRoot: string): string {
51
49
  return path.join(projectRoot, 'qa', 'perf.yaml');
52
50
  }
53
51
 
54
- /** Absent file → null (perf reporting is opt-in; nothing changes until configured). */
52
+ /**
53
+ * Absent file → null (perf reporting is opt-in; nothing changes until configured).
54
+ *
55
+ * An UNREADABLE file is not that. Returning null for it drops the perf section out of the delivered
56
+ * matrix (`exporters/matrix/export.ts`) exactly as if no budgets had ever been declared — so a typo
57
+ * in qa/perf.yaml reads, in the deliverable, as "this project has no performance budgets". Say so.
58
+ */
55
59
  export function loadPerfConfig(projectRoot: string): PerfConfig | null {
56
60
  const p = perfConfigPath(projectRoot);
57
- if (!fs.existsSync(p)) return null;
58
- let raw: Record<string, unknown>;
59
- try { raw = parseYaml(readTextFile(p)) as Record<string, unknown>; } catch { return null; }
61
+ const read = readYamlConfig<Record<string, unknown> | null>(p);
62
+ if (read.state === 'absent') return null;
63
+ if (read.state === 'unreadable') {
64
+ warnUnreadableConfig(p, read.error, 'perf budgets are not applied and the perf section is omitted from the delivered matrix');
65
+ return null;
66
+ }
67
+ const raw = read.value as Record<string, unknown>;
60
68
  if (!raw || typeof raw !== 'object') return null;
61
69
  const pctRaw = String(raw.percentile ?? 'p75').toLowerCase().replace(/^p/, '');
62
70
  const percentile = Math.min(100, Math.max(1, Number(pctRaw) || 75));
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import * as fs from 'fs';
7
7
  import * as path from 'path';
8
+ import { keywordHit } from './keyword-match';
8
9
  import { ScenarioInfo, loadScenarios, idPrefix } from './parse';
9
10
  import { parseManualComments } from '../exporters/scenario-merger';
10
11
  import { readTextFile } from './read-text';
@@ -57,12 +58,19 @@ function downstreamRoutes(specText: string): string[] {
57
58
  }
58
59
 
59
60
  /**
60
- * H5 — a @parallel stateful (cart-mutating) flow with NO per-scenario isolation is flaky: scenarios
61
- * share state, so cart count/quantity asserts race. Mitigations: @cleanup:cart, @isolate, a fresh
61
+ * H5 — a stateful (cart-mutating) flow with NO per-scenario isolation is flaky: scenarios share
62
+ * state, so cart count/quantity asserts race. Mitigations: @cleanup:cart, @isolate, a fresh
62
63
  * browser context, or a "Given … empty cart" background. Returns true when the risk is unmitigated.
64
+ *
65
+ * #759 — this used to require a literal `@parallel`, which made the gate miss the common case.
66
+ * Parallel is the DEFAULT (code-generator.ts: `isParallelFeature = !isSerialFeature`); `@parallel`
67
+ * only NAMES that default and changes no codegen, so an untagged stateful flow is isolated exactly
68
+ * like a tagged one and carries exactly the same risk. The tag that actually changes the emitted
69
+ * suite is `@serial` — one shared ordered page, where state carry-over is the point — so that is
70
+ * what exempts a flow now. Same reading as serialCascadeRisk() below, which already said so.
63
71
  */
64
72
  export function isolationRisk(featureText: string, stateful: boolean, collection = 'cart'): boolean {
65
- if (!stateful || !/@parallel\b/i.test(featureText)) return false;
73
+ if (!stateful || /@serial\b/i.test(featureText)) return false;
66
74
  // What discharges the risk is isolation of THE MUTATED COLLECTION — so the cleanup tag has
67
75
  // to name it (`@cleanup:cart` for a cart; `@cleanup:registration` for a registration flow).
68
76
  // A cleanup of something else (`@cleanup:overlay`) leaves the shared state shared.
@@ -149,8 +157,12 @@ export function downstreamScope(
149
157
  // names the same screen under its human label (register ↔ [Registration]).
150
158
  const labelsOf = (s: ScenarioInfo): string[] =>
151
159
  [...s.haystack.matchAll(/\[([^\]]+)\]/g)].map((m) => m[1].split(':')[0]);
160
+ // The slug is the route's last segment — in practice an ordinary noun (`order`, `detail`,
161
+ // `login`), so it is matched as a WORD (#779). `dis·order` is not a reference to `/account/order`,
162
+ // and crediting it downgraded a route nothing mentions from `absent` to merely `underCovered`.
163
+ // The full route stays a literal: a path is an identifier, not prose.
152
164
  const refs = scenarios.filter((s) =>
153
- s.haystack.includes(slug) || s.haystack.includes(route.toLowerCase()) ||
165
+ keywordHit(s.haystack, slug) || s.haystack.includes(route.toLowerCase()) ||
154
166
  labelsOf(s).some((l) => sameScreenName(slug, l)));
155
167
  if (!refs.length) {
156
168
  // A scenario's haystack is its name + steps, so a route documented in a @manual scenario's
Binary file
@@ -17,9 +17,15 @@ import * as fs from 'fs';
17
17
  *
18
18
  * A Windows-authored spec is completely normal, so every harness parser that
19
19
  * reads a user file must go through here.
20
+ *
21
+ * A leading UTF-8 BOM is stripped for the same reason. It is what PowerShell's `Out-File` and
22
+ * several Windows editors write by default, and it is invisible in every editor — but it breaks a
23
+ * line-anchored `/^\s*Scenario:/` on line 1, and `JSON.parse` rejects it outright while npm and
24
+ * most other tools quietly ignore it. A file the rest of the ecosystem reads fine must not read as
25
+ * corrupt here.
20
26
  */
21
27
  export function readTextFile(filePath: string): string {
22
- return fs.readFileSync(filePath, 'utf-8').replace(/\r\n?/g, '\n');
28
+ return fs.readFileSync(filePath, 'utf-8').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
23
29
  }
24
30
 
25
31
  /** `readTextFile` split into lines — the common case for line-anchored parsers. */
@@ -11,6 +11,19 @@ import * as path from 'path';
11
11
 
12
12
  export interface SecretHit { file: string; line: number; reason: string }
13
13
 
14
+ /** A yaml sink the scanner could not read. Not a hit — the absence of one it cannot vouch for. */
15
+ export interface UnscannedFile { file: string; reason: string }
16
+
17
+ /**
18
+ * The scan result, deliberately two lists rather than one.
19
+ *
20
+ * This is a security control, and its whole value is that "no hits" means "I looked". A file the
21
+ * scanner could not read used to be dropped by a bare `catch`, so an unread yaml and a yaml with no
22
+ * secrets produced the identical answer: clean. Returning `unscanned` alongside makes the caller
23
+ * hold both facts — what was found, and what the finding does not cover.
24
+ */
25
+ export interface SecretScanResult { hits: SecretHit[]; unscanned: UnscannedFile[] }
26
+
14
27
  // High-confidence vendor token prefixes (very low false-positive rate).
15
28
  const VENDOR_PREFIXES = /\b(figd_[A-Za-z0-9_-]{12,}|ghp_[A-Za-z0-9]{20,}|gho_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|sk-[A-Za-z0-9]{16,}|AKIA[0-9A-Z]{12,}|xox[baprs]-[A-Za-z0-9-]{10,}|AIza[0-9A-Za-z_-]{30,})/;
16
29
  // A secret-named key assigned a long, non-placeholder value.
@@ -51,16 +64,59 @@ function scanText(text: string, file: string): SecretHit[] {
51
64
  /** Scan a unit dir's committed yaml sinks for likely real secrets: test-data/*.yaml,
52
65
  * mock/*.yaml (mock response bodies are a credential magnet — pasted DevTools
53
66
  * responses carry real tokens), and api/*.yaml (endpoint catalogs carry headers). */
54
- export function scanTestDataSecrets(baseDir: string): SecretHit[] {
67
+ export function scanTestDataSecrets(baseDir: string): SecretScanResult {
55
68
  const hits: SecretHit[] = [];
69
+ const unscanned: UnscannedFile[] = [];
70
+ const why = (e: unknown): string => (e instanceof Error ? e.message.split('\n')[0] : String(e));
71
+
56
72
  for (const sub of ['test-data', 'mock', 'api']) {
57
73
  const dir = path.join(baseDir, sub);
58
74
  if (!fs.existsSync(dir)) continue;
59
- for (const f of fs.readdirSync(dir)) {
75
+
76
+ // A directory that cannot be LISTED is a directory that cannot be scanned. This used to throw
77
+ // out of the scanner and abort the whole `generate`/`delivery` run; report it in the same place
78
+ // a hit would appear instead, so the operator learns what the clean verdict does not cover
79
+ // rather than losing the deliverable to an unrelated permission fault.
80
+ let entries: string[];
81
+ try { entries = fs.readdirSync(dir); } catch (e) { unscanned.push({ file: sub, reason: why(e) }); continue; }
82
+
83
+ for (const f of entries) {
60
84
  if (!/\.ya?ml$/i.test(f)) continue;
61
85
  const p = path.join(dir, f);
62
- try { hits.push(...scanText(fs.readFileSync(p, 'utf-8'), path.join(sub, f))); } catch { /* ignore */ }
86
+ const rel = path.join(sub, f);
87
+ let text: string;
88
+ // `readdirSync` listed this file, so it exists as a name. A read that fails here means a
89
+ // restricted mount, a dangling symlink, or a file removed between the two calls — never
90
+ // "there was nothing to read". Silently skipping it let a committed credential ship under an
91
+ // explicit "clean" verdict.
92
+ try { text = fs.readFileSync(p, 'utf-8'); } catch (e) { unscanned.push({ file: rel, reason: why(e) }); continue; }
93
+ hits.push(...scanText(text, rel));
63
94
  }
64
95
  }
65
- return hits;
96
+ return { hits, unscanned };
97
+ }
98
+
99
+ /**
100
+ * The operator-facing lines for a scan result — one renderer, because `generate` and `delivery`
101
+ * had grown two copies of it and only one of them would have learned about `unscanned`.
102
+ *
103
+ * Empty array when there is nothing to say. The unscanned block is NOT folded into the hit block:
104
+ * "possible secret" and "not examined" are different claims, and printing the second under the
105
+ * first's heading invites it to be dismissed as another false positive.
106
+ */
107
+ export function formatSecretScan(result: SecretScanResult, limit = 10): string[] {
108
+ const out: string[] = [];
109
+ if (result.hits.length) {
110
+ out.push('', '⚠️ Possible secret(s) in committed test-data (review — do NOT commit real credentials):');
111
+ for (const h of result.hits.slice(0, limit)) out.push(` ${h.file}:${h.line} — ${h.reason}`);
112
+ if (result.hits.length > limit) out.push(` … and ${result.hits.length - limit} more`);
113
+ out.push(' Move real secrets to an env overlay / CI secret; keep test-data placeholders only.');
114
+ }
115
+ if (result.unscanned.length) {
116
+ out.push('', `⚠️ Secret scan could NOT read ${result.unscanned.length} committed yaml file(s) — they are NOT covered by the result above:`);
117
+ for (const u of result.unscanned.slice(0, limit)) out.push(` ${u.file} — ${u.reason}`);
118
+ if (result.unscanned.length > limit) out.push(` … and ${result.unscanned.length - limit} more`);
119
+ out.push(' Make them readable and re-run before treating this unit as scanned.');
120
+ }
121
+ return out;
66
122
  }
@@ -11,6 +11,7 @@ import * as path from 'path';
11
11
  import { parse as parseYaml } from 'yaml';
12
12
  import { ScenarioInfo, ViewpointEntry, idPrefix } from './parse';
13
13
  import { readTextFile } from './read-text';
14
+ import { keywordHit, hasKeyword } from './keyword-match';
14
15
 
15
16
  // Business-critical category keywords (matched by CONTAINMENT against the VP category, so a
16
17
  // compound category like LIST-DISPLAY / ADD-TO-CART / PRODUCT-DISCOVERY classifies correctly).
@@ -86,18 +87,11 @@ export function loadCatalog(): Catalog {
86
87
  return parseYaml(readTextFile(p)) as Catalog;
87
88
  }
88
89
 
89
- // Word-aware keyword match: `\b<kw>(s|es|ed|ing)?\b`, so a keyword matches whole words (plus
90
- // simple plural/verb suffixes) but NOT as a substring of a larger word — e.g. `form` must not
91
- // match inside `Format` or `@platform`, while `product` still matches `products`. Multi-word
92
- // keywords ("add to cart", "is hidden") match the phrase with a boundary at each end.
93
- const wordRe = (kw: string) => {
94
- const esc = kw.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
95
- return new RegExp(`\\b${esc}(?:s|es|ed|ing)?\\b`);
96
- };
97
- const has = (haystacks: string[], kw: string) => {
98
- const re = wordRe(kw);
99
- return haystacks.some((h) => re.test(h));
100
- };
90
+ // Word-aware keyword match — one rule for every sensor, in ./keyword-match (#776). A keyword
91
+ // matches at a word START but may carry any suffix, so `form` does not match inside `Format` or
92
+ // `@platform` while `submit` still matches `submitted`. The old local copy anchored BOTH ends,
93
+ // which silently dropped every inflected true positive; see that module for the measurements.
94
+ const has = hasKeyword;
101
95
 
102
96
  // ---------- Sensor 1: Viewpoint Gate (catalog-driven) ----------
103
97
 
@@ -112,6 +106,13 @@ export interface GateResult {
112
106
  coverageRatio: number;
113
107
  gaps: { theme: string; keywords: string[]; status: 'missing' | 'shallow' }[];
114
108
  universalGaps: string[];
109
+ /**
110
+ * WHICH scenario covered each satisfied theme, and on which keyword — the audit's second line of
111
+ * defence against a bogus coverage claim (#776). A bare count ("themes 3/3") gives a reviewer
112
+ * nothing to check, so a nonsense match survives; naming the evidence makes it obvious on sight.
113
+ * Mirrors how the Claim-Proof section already names the scenario behind each claim.
114
+ */
115
+ coveredBy: { theme: string; by: string; keyword: string }[];
115
116
  }
116
117
 
117
118
  /**
@@ -166,20 +167,36 @@ export function viewpointGate(scenarios: ScenarioInfo[], viewpoints: ViewpointEn
166
167
 
167
168
  const evidence = { hits: best, runnerUp };
168
169
  const gaps: GateResult['gaps'] = [];
170
+ const coveredBy: GateResult['coveredBy'] = [];
169
171
  let total = 0, covered = 0;
170
172
  if (pageType) {
171
173
  for (const t of catalog.page_types[pageType].must_cover) {
172
174
  total++;
173
175
  // Scenarios that cover this theme by keyword (include @manual — a manual
174
176
  // scenario with a real assertion still covers the viewpoint for design).
175
- const coverers = scenarios.filter((s) => t.keywords.some((k) => s.haystack.includes(k.toLowerCase())));
177
+ // Word-aware since #776: a substring match let `information` satisfy `format-boundary`.
178
+ const coverers = scenarios.filter((s) => t.keywords.some((k) => keywordHit(s.haystack, k)));
176
179
  if (coverers.length === 0) { gaps.push({ theme: t.theme, keywords: t.keywords, status: 'missing' }); continue; }
177
180
  // A theme WITHOUT `depth:` is visibility/navigation — landing/seeing/leaving IS the
178
181
  // assertion, so mere presence covers it. Only DATA-correctness themes (depth.requires:
179
182
  // data-assertion) need a data assertion to count as deeply covered.
180
183
  const needsDepth = t.depth?.requires === 'data-assertion';
181
- if (!needsDepth) { covered++; continue; }
182
- if (coverers.some((s) => s.hasDataAssertion)) covered++;
184
+ // Name the evidence for whichever scenario actually carries the theme — the one with the
185
+ // data assertion when depth is required, since that is the scenario the credit rests on.
186
+ const proof = (needsDepth ? coverers.find((s) => s.hasDataAssertion) : undefined) ?? coverers[0];
187
+ const credit = (): void => {
188
+ covered++;
189
+ coveredBy.push({
190
+ theme: t.theme,
191
+ by: proof.haystack,
192
+ // `proof` came out of `coverers`, which was filtered on this same predicate, so the find
193
+ // always lands — the `!` states that rather than inventing a fallback keyword that would
194
+ // misattribute the credit if the invariant ever broke.
195
+ keyword: t.keywords.find((k) => keywordHit(proof.haystack, k))!,
196
+ });
197
+ };
198
+ if (!needsDepth) { credit(); continue; }
199
+ if (coverers.some((s) => s.hasDataAssertion)) credit();
183
200
  else gaps.push({ theme: t.theme, keywords: t.keywords, status: 'shallow' });
184
201
  }
185
202
  }
@@ -203,6 +220,7 @@ export function viewpointGate(scenarios: ScenarioInfo[], viewpoints: ViewpointEn
203
220
  coverageRatio: total ? covered / total : 1,
204
221
  gaps,
205
222
  universalGaps,
223
+ coveredBy,
206
224
  };
207
225
  }
208
226
 
@@ -269,7 +287,7 @@ export function assertionDepth(
269
287
  const isDismissBehavior = (s: ScenarioInfo) =>
270
288
  /\bis hidden\b|\bare hidden\b|\bdismiss|\bdisappear|\bno longer (visible|shown|present|displayed)\b|\bcloses? the (modal|dialog|popup|overlay|panel|menu)\b/.test(s.haystack);
271
289
  const isDepthRequired = (s: ScenarioInfo) =>
272
- s.category !== 'NAV' && !isDismissBehavior(s) && depthKeywords.some((k) => s.haystack.includes(k.toLowerCase()));
290
+ s.category !== 'NAV' && !isDismissBehavior(s) && depthKeywords.some((k) => keywordHit(s.haystack, k));
273
291
 
274
292
  const required = nonManual.filter(isDepthRequired);
275
293
  const reqShallow = required.filter((s) => s.shallow);
@@ -347,7 +365,7 @@ export function flowCoveredThemes(
347
365
  ): { theme: string; flow: string }[] {
348
366
  const out: { theme: string; flow: string }[] = [];
349
367
  for (const g of gaps) {
350
- const hit = flowScenarios.find((s) => s.deep && g.keywords.some((k) => s.haystack.includes(k.toLowerCase())));
368
+ const hit = flowScenarios.find((s) => s.deep && g.keywords.some((k) => keywordHit(s.haystack, k)));
351
369
  if (hit) out.push({ theme: g.theme, flow: hit.flow });
352
370
  }
353
371
  return out;
@@ -394,7 +412,8 @@ export function flowRegressionDepth(scenarios: ScenarioInfo[]): FlowDepthResult
394
412
  export interface OracleStrengthResult {
395
413
  weak: { name: string; hint: string; code?: string }[]; // facet-by-name-substring, or URL-only (code ORACLE-URL-ONLY)
396
414
  facetClaims: number; // scenarios that touch a category/brand facet (denominator)
397
- ratio: number; // 1 - weak/facetClaims (1 when none) — caps businessDepth
415
+ weakFacet: number; // the facet-class subset of `weak` — the only numerator `facetClaims` denominates
416
+ ratio: number; // 1 - weakFacet/facetClaims, clamped 0..1 (1 when no facet claim) — caps businessDepth
398
417
  }
399
418
 
400
419
  // "see all [<item name/title>] ... contain(s) {{<facet>.term}}" — asserting every item's NAME carries
@@ -429,9 +448,11 @@ export function oracleStrength(scenarios: ScenarioInfo[]): OracleStrengthResult
429
448
  hint: `every \`Then\` is a URL assertion${guardLike ? ' on a permission / guard scenario' : ''} — it proves the router pushed a path, not that the page rendered for this user${guardLike ? ', and this is exactly the scenario a late 403 redirect turns falsely green' : ''}. Give the page selector an \`anchor:\` (a heading/landmark only the rendered page has) — one line per screen, and every \`see [X] page\` on it becomes a content oracle — or add a content assertion after it.`,
430
449
  });
431
450
  }
451
+ let weakFacet = 0;
432
452
  for (const s of scenarios) {
433
453
  if (s.manual) continue; // a @manual facet check is a deliberate deferral, not a weak automated oracle
434
454
  if (WEAK_FACET_ORACLE.test(s.stepsText)) {
455
+ weakFacet++;
435
456
  weak.push({
436
457
  name: s.name.slice(0, 80),
437
458
  hint: 'asserting every item NAME contains a category/brand term does not prove facet membership — assert the results-page TITLE/header carries the facet, a detail-page facet field, or an API/DB oracle; or defer the exhaustive check to @manual:M2.',
@@ -439,7 +460,17 @@ export function oracleStrength(scenarios: ScenarioInfo[]): OracleStrengthResult
439
460
  }
440
461
  }
441
462
  const facetClaims = scenarios.filter((s) => FACET_REF.test(s.stepsText) || FACET_REF.test(s.name)).length;
442
- return { weak, facetClaims, ratio: facetClaims ? 1 - weak.length / Math.max(1, facetClaims) : 1 };
463
+ // Only the FACET class is denominated by `facetClaims`. `weak` also carries ORACLE-URL-ONLY
464
+ // findings (#662), which make no category/brand claim at all — averaging them into this axis
465
+ // drove `ratio` negative (5 URL-only over 2 facet claims → 1 - 5/2 = -1.5), which walked through
466
+ // the businessDepth floor and took the CLI's bar renderer down with it (#762). A URL-only oracle
467
+ // is NAMED as a finding and holds the score out of the senior band (`seniorBandedOverall`);
468
+ // it is not averaged into the facet axis — the invariant `url-only-oracle.run.ts` asserts.
469
+ // The clamp is not belt-and-braces: a weak-facet scenario need not itself satisfy FACET_REF
470
+ // (`see all [name] contains {{price.term}}` is weak but claims no facet), so `weakFacet` can
471
+ // still outrun `facetClaims`. This axis is declared 0..1; it leaves here inside 0..1.
472
+ const ratio = facetClaims ? Math.max(0, Math.min(1, 1 - weakFacet / facetClaims)) : 1;
473
+ return { weak, facetClaims, weakFacet, ratio };
443
474
  }
444
475
 
445
476
  /** Collect data-correctness themes (depth.requires) for a page-type + universal. */
@@ -16,6 +16,7 @@
16
16
  * explicit — a named heading, a table that declares conditions and outcomes, or an if/else
17
17
  * cascade — because a false "you missed a branch" costs more trust than it buys coverage.
18
18
  */
19
+ import { keywordHit } from './keyword-match';
19
20
  import { ScenarioInfo } from './parse';
20
21
 
21
22
  export interface SpecBranch {
@@ -172,7 +173,9 @@ export function unenumeratedBranches(branches: SpecBranch[], featureText: string
172
173
  const words = [...new Set((b.label.toLowerCase().match(/[a-z][a-z-]{3,}/g) ?? []))]
173
174
  .filter((w) => !GENERIC_WORD.has(w));
174
175
  if (words.length === 0) return !hay.includes(b.label.toLowerCase());
175
- const hits = words.filter((w) => hay.includes(w)).length;
176
+ // Word-aware (#779): `in·active` and `check·list` are not mentions of `active` and `list`,
177
+ // and two of them credited a branch no scenario cites.
178
+ const hits = words.filter((w) => keywordHit(hay, w)).length;
176
179
  return hits < Math.min(2, words.length);
177
180
  });
178
181
  }
@@ -289,7 +292,7 @@ export function visibilityPairGaps(scenarios: ScenarioInfo[]): VisibilityPairGap
289
292
  const subject = distinctive(s);
290
293
  // The counterpart has to be about the SAME thing — two shared distinctive words, the same
291
294
  // bar the ledger's prose matching uses.
292
- const paired = allows.some((a) => subject.filter((w) => a.haystack.includes(w)).length >= Math.min(2, subject.length));
295
+ const paired = allows.some((a) => subject.filter((w) => keywordHit(a.haystack, w)).length >= Math.min(2, subject.length));
293
296
  if (!paired) out.push({ scenario: s.name, subject: subject.slice(0, 4), kind: 'permission' });
294
297
  }
295
298
  return out;
@@ -9,6 +9,7 @@
9
9
  * Generic: no project data.
10
10
  */
11
11
  import * as fs from 'fs';
12
+ import { keywordHit } from './keyword-match';
12
13
  import { ScenarioInfo } from './parse';
13
14
  import { readTextFile } from './read-text';
14
15
 
@@ -139,7 +140,11 @@ function directionCovered(block: string, dir: FrDirection, subject: string[]): b
139
140
  const pool = lines.length ? lines : [block];
140
141
  // Narrow to the lines about the clause's element when the suite names it the same way the spec
141
142
  // does; a spec in one language and selector keys in another must not read as "no evidence".
142
- const about = subject.length ? pool.filter((l) => subject.some((x) => l.includes(x))) : [];
143
+ // Word-aware (#779), or the narrowing lets in a line about something else and then trusts it:
144
+ // `under·score` is not a mention of `Score`, but it selected the only absence assertion in the
145
+ // block and so proved a MUST NOT clause about the Score. (`keywordHit` keeps substring semantics
146
+ // for a non-ASCII element name, which is what the cross-language case above depends on.)
147
+ const about = subject.length ? pool.filter((l) => subject.some((x) => keywordHit(l, x))) : [];
143
148
  const scan = about.length ? about : pool;
144
149
  return scan.some((l) => NEGATIVE_ASSERTION.test(l) === (dir.polarity === 'negate'));
145
150
  }
@@ -269,7 +274,12 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
269
274
  const cited = featLower.includes(idLower) || cites.has(fr.id.toUpperCase());
270
275
  const words = [...new Set((fr.text.toLowerCase().match(/[a-z][a-z-]{4,}/g) || []))]
271
276
  .filter((w) => !/must|should|system|screen|users?|value|input|field/.test(w));
272
- const kwHit = words.length > 0 && scenarios.some((s) => words.filter((w) => s.haystack.includes(w)).length >= Math.min(2, words.length));
277
+ // Word-aware, not substring (#779). `haystack.includes('format')` is satisfied by
278
+ // in·format·ion and `includes('length')` by wave·length, and two such collisions clear the
279
+ // `>= 2 words` bar as easily as one — so a MUST clause nobody tested read as covered. The
280
+ // threshold is a separate judgement (is one word enough evidence?) and is unchanged; see
281
+ // keyword-match.ts for why the boundary is leading-only.
282
+ const kwHit = words.length > 0 && scenarios.some((s) => words.filter((w) => keywordHit(s.haystack, w)).length >= Math.min(2, words.length));
273
283
  if (!cited && !kwHit) {
274
284
  if (fr.modality === 'MUST') uncoveredMust.push({ id: fr.id, text: fr.text.slice(0, 90) });
275
285
  continue;
@@ -279,7 +289,7 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
279
289
  // Which scenarios speak for this requirement — by its id (tags and comments sit in the block)
280
290
  // or by the same keyword rule used above.
281
291
  const owning = blocks.filter((b) => b.includes(idLower)
282
- || (words.length > 0 && words.filter((w) => b.includes(w)).length >= Math.min(2, words.length)));
292
+ || (words.length > 0 && words.filter((w) => keywordHit(b, w)).length >= Math.min(2, words.length)));
283
293
  // Cited from outside any scenario (a header comment): nothing to judge a direction against, so
284
294
  // report nothing rather than invent a gap.
285
295
  if (!owning.length) { frCovered += units; continue; }
@@ -299,7 +309,7 @@ export function specCoverage(specPath: string, scenarios: ScenarioInfo[], featur
299
309
  // blocks that belong to this constraint: cite its msg code (primary), or mention a
300
310
  // DISTINCTIVE constraint word (generic words filtered to avoid matching happy paths).
301
311
  const own = blocks.filter((b) =>
302
- (row.code && b.includes(row.code.toLowerCase())) || kw.some((w) => b.includes(w)),
312
+ (row.code && b.includes(row.code.toLowerCase())) || kw.some((w) => keywordHit(b, w)),
303
313
  );
304
314
  if (!own.length) continue; // constraint has no scenarios at all — FR/viewpoint gate covers that
305
315
  const found = [...new Set(own.flatMap((b) => actionTriggersIn(b)))];
@@ -11,6 +11,7 @@
11
11
  import * as fs from 'fs';
12
12
  import { ScenarioInfo } from './parse';
13
13
  import { readTextFile } from './read-text';
14
+ import { keywordHit } from './keyword-match';
14
15
 
15
16
  export interface LedgerItem { id?: string; text: string; covered: boolean }
16
17
 
@@ -198,7 +199,7 @@ export function viewpointLedger(viewpointPath: string, scenarios: ScenarioInfo[]
198
199
  } else {
199
200
  const words = [...new Set((item.text.toLowerCase().match(/[a-z][a-z-]{3,}/g) || []).filter((w) => !GENERIC.has(w)))];
200
201
  const need = Math.min(2, words.length);
201
- isCovered = words.length > 0 && scenarios.some((s) => words.filter((w) => s.haystack.includes(w)).length >= need);
202
+ isCovered = words.length > 0 && scenarios.some((s) => words.filter((w) => keywordHit(s.haystack, w)).length >= need);
202
203
  }
203
204
  if (isCovered) covered++;
204
205
  else missing.push({ id: item.id, text: item.text });
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Write a file so that a crash mid-write can never leave a truncated one behind: write a sibling
3
+ * tmp, then rename. Same directory = same device, so the rename is atomic; a concurrent writer is
4
+ * last-rename-wins. On failure the tmp is removed and the error rethrown — the caller still learns.
5
+ *
6
+ * `results merge` and `results sanitize` each grew this block inline, for the same reason and with
7
+ * the same comment. The third caller (`ai-skills/writer.ts`) did not, and its truncated manifest is
8
+ * precisely the corrupt input that #782's readers then absorbed in silence — so the two halves of
9
+ * that defect are one file apart.
10
+ */
11
+ import * as fs from 'fs';
12
+
13
+ export function writeFileAtomic(file: string, data: string): void {
14
+ const tmp = `${file}.tmp-${process.pid}`;
15
+ try {
16
+ fs.writeFileSync(tmp, data);
17
+ fs.renameSync(tmp, file);
18
+ } catch (err) {
19
+ try { fs.rmSync(tmp, { force: true }); } catch { /* best-effort cleanup — the real error is below */ }
20
+ throw err;
21
+ }
22
+ }
@@ -16,6 +16,8 @@ import * as path from 'path';
16
16
  import * as crypto from 'crypto';
17
17
  import { BuiltFile } from './build';
18
18
  import { AdapterKey } from './adapters';
19
+ import { readJsonConfig, warnUnreadableConfig } from '../../harness/config-file';
20
+ import { writeFileAtomic } from '../../harness/write-atomic';
19
21
 
20
22
  export interface ApplyOptions { force?: boolean; dryRun?: boolean; version: string }
21
23
  export interface ApplyReport {
@@ -42,10 +44,21 @@ function adapterOf(relPath: string): AdapterKey | null {
42
44
  return null;
43
45
  }
44
46
 
47
+ /**
48
+ * The manifest is what makes orphan cleanup possible: it is the only record of which files a
49
+ * PREVIOUS version managed. An unreadable one used to read as an empty one — so cleanup found
50
+ * nothing to remove, and the stale files it failed to remove are absent from the NEW manifest too.
51
+ * No later run can ever find them again. Say it, then carry on with what can still be done.
52
+ */
45
53
  function readManifest(cwd: string): Manifest {
46
54
  const p = path.join(cwd, MANIFEST_REL);
47
- if (!fs.existsSync(p)) return { version: '', managed: {} };
48
- try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return { version: '', managed: {} }; }
55
+ const read = readJsonConfig<Manifest>(p);
56
+ if (read.state === 'unreadable') {
57
+ warnUnreadableConfig(p, read.error,
58
+ 'orphan cleanup is skipped this run, so files managed by an older version stay in place');
59
+ return { version: '', managed: {} };
60
+ }
61
+ return read.state === 'ok' ? read.value : { version: '', managed: {} };
49
62
  }
50
63
 
51
64
  export function applyOutputs(cwd: string, outputs: BuiltFile[], opts: ApplyOptions): ApplyReport {
@@ -121,7 +134,9 @@ export function applyOutputs(cwd: string, outputs: BuiltFile[], opts: ApplyOptio
121
134
  Object.assign(mergedManaged, newManaged);
122
135
  const mPath = path.join(cwd, MANIFEST_REL);
123
136
  fs.mkdirSync(path.dirname(mPath), { recursive: true });
124
- fs.writeFileSync(mPath, JSON.stringify({ version: opts.version, managed: mergedManaged }, null, 2));
137
+ // Atomic: an interrupted `sungen init`/`update` must not leave a half-written manifest, because
138
+ // the reader above can only report that damage — it cannot undo it.
139
+ writeFileAtomic(mPath, JSON.stringify({ version: opts.version, managed: mergedManaged }, null, 2));
125
140
  }
126
141
  return r;
127
142
  }