lazycodex-ai 5.0.0-beta.89 → 5.0.0-beta.90

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 (159) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.zh-cn.md +1 -1
  6. package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
  7. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
  8. package/dist/cli/index.js +1661 -859
  9. package/dist/cli/install-native/index.d.ts +9 -1
  10. package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
  11. package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
  12. package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
  13. package/dist/cli/install-native/plan.d.ts +8 -0
  14. package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
  15. package/dist/cli/install-native/run-native-install.d.ts +11 -1
  16. package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
  17. package/dist/cli/native-edition-hint.d.ts +2 -2
  18. package/dist/cli-node/index.js +1661 -859
  19. package/package.json +1 -1
  20. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  21. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  22. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  23. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  24. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  25. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  26. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  27. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  28. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  29. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  30. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  31. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
  33. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
  34. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  35. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  36. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  37. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  38. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  39. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  40. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
  41. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  44. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  45. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  46. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  47. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
  48. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  49. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  68. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  69. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  70. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  71. package/packages/omo-codex/plugin/package-lock.json +12 -12
  72. package/packages/omo-codex/plugin/package.json +1 -1
  73. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
  74. package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
  75. package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
  76. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
  77. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  78. package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
  79. package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
  80. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
  81. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
  82. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
  83. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
  84. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
  85. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
  86. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
  87. package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
  88. package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
  89. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
  90. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  91. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  92. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
  93. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  94. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  95. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
  96. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  97. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
  98. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
  99. package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  100. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
  101. package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  102. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  103. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
  104. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
  105. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
  106. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
  107. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
  108. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  109. package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
  110. package/packages/shared-skills/skills/browser/SKILL.md +8 -1
  111. package/packages/shared-skills/skills/browser/references/install.md +34 -5
  112. package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
  113. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
  114. package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
  115. package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
  116. package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
  117. package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
  118. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
  119. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
  120. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
  121. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  122. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
  123. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
  124. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
  125. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  126. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
  127. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  128. package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
  129. package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  130. package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  131. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
  132. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  133. package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  134. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  135. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  136. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
  137. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  138. package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  139. package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
  140. package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  141. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
  142. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  143. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  144. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  145. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
  146. package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
  147. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  148. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  149. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  150. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
  151. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  152. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  153. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  154. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  155. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  156. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  157. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  158. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  159. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
@@ -0,0 +1,265 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { readFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ import { DEFECT_CODES, STATIC_CODES } from "./contracts.mjs"
6
+ import { parseLineageMode, parsePalette } from "./design-spec.mjs"
7
+ import { checkStatic } from "./gates-static.mjs"
8
+ import { parseHtml } from "./html-lite.mjs"
9
+
10
+ type Defect = { code: string; severity: string; selector: string; line: number; message: string; hint: string }
11
+ type Result = { defects: Defect[]; gatesRun: string[]; gatesSkipped: string[] }
12
+ type FakeStat = { size: number }
13
+
14
+ const FIXTURES = join(import.meta.dir, "tests", "fixtures")
15
+ const GOOD = readFileSync(join(FIXTURES, "good-report.html"), "utf8")
16
+ const BAD = readFileSync(join(FIXTURES, "bad-report.html"), "utf8")
17
+ const SPEC = readFileSync(join(FIXTURES, "design-spec.md"), "utf8")
18
+ const PALETTE = parsePalette(SPEC)
19
+
20
+ function run(html: string, options: Record<string, unknown> = {}): Result {
21
+ return checkStatic(parseHtml(html), {
22
+ palette: PALETTE,
23
+ lineageMode: parseLineageMode(SPEC),
24
+ requireSections: [],
25
+ baseDir: FIXTURES,
26
+ ...options,
27
+ }) as Result
28
+ }
29
+
30
+ const codes = (result: Result) => result.defects.map((defect) => defect.code)
31
+ const has = (html: string, code: string, options: Record<string, unknown> = {}) => codes(run(html, options)).includes(code)
32
+
33
+ describe("checkStatic fixtures", () => {
34
+ test("#given the passing fixture report #when every gate runs #then no defect is raised", () => {
35
+ // given
36
+ const options = { requireSections: [/sources/i, /review/i] }
37
+ // when
38
+ const result = run(GOOD, options)
39
+ // then
40
+ expect(codes(result)).toEqual([])
41
+ expect(result.gatesSkipped).toEqual([])
42
+ expect([...result.gatesRun].sort()).toEqual([...STATIC_CODES].sort())
43
+ })
44
+
45
+ test("#given the failing fixture report #when every gate runs #then each static code is raised exactly once", () => {
46
+ // given
47
+ const options = { requireSections: [/risks/i] }
48
+ // when
49
+ const result = run(BAD, options)
50
+ // then
51
+ expect([...codes(result)].sort()).toEqual([...STATIC_CODES].sort())
52
+ expect(STATIC_CODES).toHaveLength(17)
53
+ expect(codes(result)).not.toContain("missing_promised_deliverable")
54
+ })
55
+
56
+ test("#given the failing fixture report #when defects are read #then each carries contract severity, selector, line, token message and hint", () => {
57
+ // given
58
+ const result = run(BAD, { requireSections: [/risks/i] })
59
+ // when
60
+ const byCode = new Map(result.defects.map((defect) => [defect.code, defect]))
61
+ // then
62
+ for (const defect of result.defects) {
63
+ const contract = DEFECT_CODES[defect.code as keyof typeof DEFECT_CODES]
64
+ expect(defect.severity).toBe(contract.severity)
65
+ expect(defect.hint).toBe(contract.hint)
66
+ expect(typeof defect.selector).toBe("string")
67
+ expect(defect.selector.length).toBeGreaterThan(0)
68
+ expect(Number.isInteger(defect.line)).toBe(true)
69
+ }
70
+ expect(byCode.get("palette_off_token")?.message).toContain("#123456")
71
+ expect(byCode.get("unsourced_number")?.message).toContain("12.5%")
72
+ expect(byCode.get("broken_asset_reference")?.message).toContain("missing-figure.png")
73
+ expect(byCode.get("korean_serif_font")?.message).toContain("Nanum Myeongjo")
74
+ expect(byCode.get("emoji_in_prose")?.message).toContain("\u{1F680}")
75
+ })
76
+ })
77
+
78
+ describe("mutation proofs on the passing fixture", () => {
79
+ test("#given keep-all removed #when checked #then only korean_no_keep_all is raised", () => {
80
+ // given
81
+ const mutated = GOOD.replace("word-break: keep-all;", "")
82
+ // when
83
+ const result = run(mutated, { requireSections: [/sources/i] })
84
+ // then
85
+ expect(codes(result)).toEqual(["korean_no_keep_all"])
86
+ })
87
+
88
+ test("#given the closing heading renamed #when checked #then the missing_closing_section blocker is raised", () => {
89
+ // given
90
+ const mutated = GOOD.replace("<h2>How this report was made</h2>", "<h2>Notes on the data</h2>")
91
+ // when
92
+ const result = run(mutated)
93
+ // then
94
+ const defect = result.defects.find((entry) => entry.code === "missing_closing_section")
95
+ expect(defect?.severity).toBe("blocker")
96
+ })
97
+
98
+ test("#given the sources heading renamed #when checked #then the missing_citation_section blocker is raised", () => {
99
+ // given
100
+ const mutated = GOOD.replace("<h2>Sources</h2>", "<h2>Further reading</h2>")
101
+ // when
102
+ const result = run(mutated)
103
+ // then
104
+ const defect = result.defects.find((entry) => entry.code === "missing_citation_section")
105
+ expect(defect?.severity).toBe("blocker")
106
+ })
107
+
108
+ test("#given no sources heading but three [S<n>] definition lines #when checked #then no citation-section blocker", () => {
109
+ // given
110
+ const html = "<h2>Notes</h2><p>[S1] First source title</p><p>[S2] Second source title</p><p>[Source 3] Third source title</p>"
111
+ // when
112
+ const found = has(html, "missing_citation_section")
113
+ // then
114
+ expect(found).toBe(false)
115
+ })
116
+ })
117
+
118
+ describe("unsourced_number", () => {
119
+ const claim = "\uB9E4\uCD9C\uC774 12.5% \uC99D\uAC00\uD588\uB2E4"
120
+
121
+ test("#given a bare revenue claim #when checked #then unsourced_number is raised", () => {
122
+ // given
123
+ const html = `<p>${claim}</p>`
124
+ // when
125
+ const found = has(html, "unsourced_number")
126
+ // then
127
+ expect(found).toBe(true)
128
+ })
129
+
130
+ test.each([
131
+ ["a [S3] marker", `<p>${claim} [S3]</p>`],
132
+ ["a MEASURED token", `<p>${claim} MEASURED</p>`],
133
+ ["a data-lineage attribute", `<p data-lineage="DERIVED">${claim}</p>`],
134
+ ["a #s3 anchor", `<p>${claim} <a href="#s3">3</a></p>`],
135
+ ])("#given the claim with %s #when checked #then it is not flagged", (_label, html) => {
136
+ // given / when
137
+ const found = has(html, "unsourced_number")
138
+ // then
139
+ expect(found).toBe(false)
140
+ })
141
+
142
+ test("#given dates, versions, figure refs and bare years only #when checked #then no claim is found", () => {
143
+ // given
144
+ const html = "<p>Released 2024-03-01 as v2.3.1 in 2019, see Figure 3 and Table 2.</p>"
145
+ // when
146
+ const found = has(html, "unsourced_number")
147
+ // then
148
+ expect(found).toBe(false)
149
+ })
150
+
151
+ test("#given inline and section lineage modes #when the same claim is checked #then severity is major then minor", () => {
152
+ // given
153
+ const html = `<p>${claim}</p>`
154
+ // when
155
+ const inline = run(html, { lineageMode: "inline" }).defects.find((d) => d.code === "unsourced_number")
156
+ const section = run(html, { lineageMode: "section" }).defects.find((d) => d.code === "unsourced_number")
157
+ // then
158
+ expect(inline?.severity).toBe(DEFECT_CODES.unsourced_number.severity)
159
+ expect(section?.severity).toBe("minor")
160
+ })
161
+
162
+ test("#given a claim inside a figure, a table and the closing section #when checked #then none is flagged", () => {
163
+ // given
164
+ const html = `<figure><p>${claim}</p><figcaption>x</figcaption></figure><table><tr><td><p>${claim}</p></td></tr></table><h2>How it was made</h2><p>${claim}</p>`
165
+ // when
166
+ const found = has(html, "unsourced_number")
167
+ // then
168
+ expect(found).toBe(false)
169
+ })
170
+ })
171
+
172
+ describe("dashes and emoji", () => {
173
+ test("#given an en dash between years #when checked #then it is allowed", () => {
174
+ // given / when
175
+ const found = has("<p>2019\u20132024</p>", "en_dash_in_prose")
176
+ // then
177
+ expect(found).toBe(false)
178
+ })
179
+
180
+ test("#given an en dash between letters #when checked #then en_dash_in_prose is raised", () => {
181
+ // given / when
182
+ const found = has("<p>a\u2013b</p>", "en_dash_in_prose")
183
+ // then
184
+ expect(found).toBe(true)
185
+ })
186
+
187
+ test("#given em dashes in a td and in the sources section #when checked #then they are out of prose scope", () => {
188
+ // given
189
+ const html = "<table><tr><td>\u2014</td></tr></table><h2>Sources</h2><ol><li>Title \u2014 subtitle</li></ol>"
190
+ // when
191
+ const found = has(html, "em_dash_in_prose")
192
+ // then
193
+ expect(found).toBe(false)
194
+ })
195
+
196
+ test("#given a check-mark emoji in prose #when checked #then emoji_in_prose is raised", () => {
197
+ // given / when
198
+ const found = has("<p>\u2705 done</p>", "emoji_in_prose")
199
+ // then
200
+ expect(found).toBe(true)
201
+ })
202
+
203
+ test("#given a plain check mark in a td #when checked #then no emoji is raised", () => {
204
+ // given / when
205
+ const found = has("<table><tr><td>\u2713</td></tr></table>", "emoji_in_prose")
206
+ // then
207
+ expect(found).toBe(false)
208
+ })
209
+ })
210
+
211
+ describe("figures and charts", () => {
212
+ test("#given a 16px svg inside an h2 #when checked #then it is not treated as a chart", () => {
213
+ // given
214
+ const html = '<h2>Title <svg width="16" height="16" viewBox="0 0 16 16"><path d="M0 0h16"/></svg></h2>'
215
+ // when
216
+ const found = codes(run(html)).filter((code) => code.startsWith("chart_"))
217
+ // then
218
+ expect(found).toEqual([])
219
+ })
220
+
221
+ test("#given a figure svg with title, three ticks and a label #when checked #then no chart defect", () => {
222
+ // given
223
+ const svg = '<svg width="400" height="200"><title>Deploys</title><text>0</text><text>5</text><text>10</text><text>Weeks</text></svg>'
224
+ // when
225
+ const found = codes(run(`<figure>${svg}<figcaption>Deploys per week</figcaption></figure>`)).filter((code) => code.startsWith("chart_"))
226
+ // then
227
+ expect(found).toEqual([])
228
+ })
229
+ })
230
+
231
+ describe("gate bookkeeping", () => {
232
+ test("#given a spec with an empty palette #when checked #then palette_off_token is skipped, not run", () => {
233
+ // given
234
+ const palette = parsePalette("# Design spec\n\n## Citations\n- Lineage: inline\n")
235
+ // when
236
+ const result = run('<p style="color: #123456">x</p>', { palette })
237
+ // then
238
+ expect(palette.size).toBe(0)
239
+ expect(result.gatesSkipped).toContain("palette_off_token")
240
+ expect(result.gatesRun).not.toContain("palette_off_token")
241
+ expect(codes(result)).not.toContain("palette_off_token")
242
+ })
243
+
244
+ test("#given an injected fs with a zero-byte and a missing asset #when checked #then both are broken and remote ones are ignored", () => {
245
+ // given
246
+ const sizes = new Map<string, number>([[join("base", "empty.png"), 0], [join("base", "ok.css"), 10]])
247
+ const fs = {
248
+ statSync(file: string): FakeStat {
249
+ const size = sizes.get(file)
250
+ if (size === undefined) throw Object.assign(new Error("ENOENT"), { code: "ENOENT" })
251
+ return { size }
252
+ },
253
+ }
254
+ const html = '<link rel="stylesheet" href="ok.css"><img src="empty.png"><script src="gone.js"></script><img src="https://example.org/a.png">'
255
+ // when
256
+ const broken = run(html, { baseDir: "base", fs }).defects.filter((d) => d.code === "broken_asset_reference")
257
+ // then
258
+ expect(broken.map((d) => d.message.includes("empty.png") || d.message.includes("gone.js"))).toEqual([true, true])
259
+ })
260
+
261
+ test("#given an unknown lineage mode #when checked #then it throws instead of guessing a severity", () => {
262
+ // given / when / then
263
+ expect(() => run("<p>x</p>", { lineageMode: "loose" })).toThrow(TypeError)
264
+ })
265
+ })
@@ -0,0 +1,118 @@
1
+ // gates-structure.mjs - structure gates: G11 section without citation, G12 closing section,
2
+ // G13 citation section, G14 broken asset reference, G15 required sections.
3
+ //
4
+ // Each gate is { codes, skip?(ctx), run(ctx) -> Defect[] } over the context built by
5
+ // gates-static.mjs. Plain Node ESM, node: builtins and siblings only.
6
+
7
+ import { join } from "node:path"
8
+
9
+ import { proseText } from "./html-lite.mjs"
10
+
11
+ const MIN_SECTION_PROSE = 120
12
+ const UNCHECKED_KINDS = new Set(["closing", "sources", "toc", "appendix", "glossary"])
13
+ const DEFINITION_TAGS = new Set(["p", "li", "dd", "dt", "td"])
14
+ const DEFINITION_LINE = /^\[(S|Source\s*)(\d+)\]/i
15
+ const MIN_DEFINITIONS = 3
16
+ const NOT_RELATIVE = /^(?:[a-z][a-z\d+.-]*:|\/\/|\/|#)/i
17
+
18
+ function headingNodes(ctx, tags) {
19
+ return ctx.elements.map(({ node }) => node).filter((node) => tags.includes(node.tag))
20
+ }
21
+
22
+ /** Local references an asset gate checks: img src, source srcset, stylesheet href, script src. */
23
+ function assetReferences(ctx) {
24
+ const refs = []
25
+ for (const node of ctx.all) {
26
+ const { attrs } = node
27
+ if (node.tag === "img" && attrs.src) refs.push({ node, url: attrs.src })
28
+ else if (node.tag === "script" && attrs.src) refs.push({ node, url: attrs.src })
29
+ else if (node.tag === "link" && attrs.href && /(?:^|\s)stylesheet(?:\s|$)/i.test(attrs.rel ?? "")) refs.push({ node, url: attrs.href })
30
+ else if (node.tag === "source" && attrs.srcset) {
31
+ for (const candidate of attrs.srcset.split(",")) {
32
+ const url = candidate.trim().split(/\s+/)[0]
33
+ if (url) refs.push({ node, url })
34
+ }
35
+ }
36
+ }
37
+ return refs.filter(({ url }) => url.trim() && !NOT_RELATIVE.test(url.trim()))
38
+ }
39
+
40
+ function localPath(baseDir, url) {
41
+ const bare = url.trim().replace(/[?#].*$/, "")
42
+ let decoded = bare
43
+ try {
44
+ decoded = decodeURIComponent(bare)
45
+ } catch {
46
+ // a malformed %-escape is kept verbatim; the stat below reports it as missing
47
+ }
48
+ return join(baseDir, ...decoded.split("/"))
49
+ }
50
+
51
+ function assetProblem(fs, file) {
52
+ try {
53
+ const stat = fs.statSync(file)
54
+ return stat.size === 0 ? "is 0 bytes" : null
55
+ } catch (error) {
56
+ return `is missing (${error?.code ?? "stat failed"})`
57
+ }
58
+ }
59
+
60
+ export const structureGates = [
61
+ {
62
+ codes: ["section_without_citation"],
63
+ run(ctx) {
64
+ const defects = []
65
+ for (const section of ctx.sections) {
66
+ if (UNCHECKED_KINDS.has(section.kind)) continue
67
+ const prose = section.prose.join(" ").replace(/\s+/g, " ").trim()
68
+ if (prose.length < MIN_SECTION_PROSE || section.anchors > 0 || ctx.util.citationMarker.test(prose)) continue
69
+ const title = proseText(section.heading)
70
+ defects.push(ctx.defect("section_without_citation", section.heading, `section "${title}" has ${prose.length} prose characters and no citation marker or anchor`))
71
+ }
72
+ return defects
73
+ },
74
+ },
75
+ {
76
+ codes: ["missing_closing_section"],
77
+ run(ctx) {
78
+ if (headingNodes(ctx, ["h2", "h3"]).some((node) => ctx.isClosing(proseText(node)))) return []
79
+ return [ctx.defect("missing_closing_section", ctx.body, "no h2/h3 heading names how the report was made (method / how)")]
80
+ },
81
+ },
82
+ {
83
+ codes: ["missing_citation_section"],
84
+ run(ctx) {
85
+ if (ctx.sections.some((section) => section.kind === "sources")) return []
86
+ const ids = new Set()
87
+ for (const { node } of ctx.elements) {
88
+ if (!DEFINITION_TAGS.has(node.tag)) continue
89
+ const match = DEFINITION_LINE.exec(proseText(node))
90
+ if (match) ids.add(`${match[1].trim().toLowerCase()}${match[2]}`)
91
+ }
92
+ if (ids.size >= MIN_DEFINITIONS) return []
93
+ return [ctx.defect("missing_citation_section", ctx.body, `no references/sources h2 and ${ids.size} [S<n>] definition line(s) (< ${MIN_DEFINITIONS})`)]
94
+ },
95
+ },
96
+ {
97
+ codes: ["broken_asset_reference"],
98
+ skip: (ctx) => !ctx.baseDir,
99
+ run(ctx) {
100
+ const defects = []
101
+ for (const { node, url } of assetReferences(ctx)) {
102
+ const problem = assetProblem(ctx.fs, localPath(ctx.baseDir, url))
103
+ if (problem) defects.push(ctx.defect("broken_asset_reference", node, `<${node.tag}> asset "${url}" ${problem}`))
104
+ }
105
+ return defects
106
+ },
107
+ },
108
+ {
109
+ codes: ["missing_required_section"],
110
+ skip: (ctx) => ctx.requireSections.length === 0,
111
+ run(ctx) {
112
+ const titles = headingNodes(ctx, ["h1", "h2", "h3", "h4", "h5", "h6"]).map((node) => proseText(node))
113
+ return ctx.requireSections
114
+ .filter((re) => !titles.some((title) => re.test(title)))
115
+ .map((re) => ctx.defect("missing_required_section", ctx.body, `no heading matches the required section ${re}`))
116
+ },
117
+ },
118
+ ]
@@ -0,0 +1,190 @@
1
+ // gates-text.mjs - prose gates: G1 keep-all, G3 emoji, G4 dashes, G5/G6 headings, G7 unsourced numbers.
2
+ //
3
+ // Each gate is { codes, run(ctx) -> Defect[] } over the context built by gates-static.mjs.
4
+ // Plain Node ESM, sibling imports only.
5
+
6
+ import { proseText } from "./html-lite.mjs"
7
+
8
+ const HANGUL = /[\uAC00-\uD7A3]/g
9
+ const KEEP_ALL = /word-break\s*:\s*keep-all/i
10
+ const MIN_HANGUL = 20
11
+ /** Dingbats (2700-27BF) that render as emoji without FE0F (Emoji_Presentation=Yes). */
12
+ const DINGBAT_EMOJI = new Set([0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2753, 0x2754, 0x2755, 0x2757, 0x2795, 0x2796, 0x2797, 0x27b0, 0x27bf])
13
+ const EM_DASH = "\u2014"
14
+ const EN_DASH = "\u2013"
15
+ const HEADING_NUMBER = /^\s*(?:\d+(?:[.-]\d+)*[.):]?|[IVXLC]+[.)]|[A-Z][.)])\s+/
16
+ const GENERIC = new Set(["overview", "introduction", "background", "summary", "conclusion", "conclusions", "results", "discussion", "methodology"])
17
+ const MAX_HEADING_HANGUL = 26
18
+ const MAX_HEADING_WORDS = 12
19
+ const LINEAGE_TOKEN = /\b(?:MEASURED|ASSUMED|DERIVED)\b/
20
+ const NUMBER_CONTAINERS = new Set(["figure", "table", "nav", "header", "footer"])
21
+ const MAX_UNSOURCED = 50
22
+ const MONTH = "(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\\.?"
23
+ /** Stripped before claim detection: dates, versions, figure refs, times, bare years. */
24
+ const NOT_CLAIMS = [
25
+ /\b\d{4}[-./]\d{1,2}(?:[-./]\d{1,2})?\b/g,
26
+ /\b\d{2}\.\d{2}\.\d{2}\b/g,
27
+ /\d{2,4}\s*\uB144\s*\d{1,2}\s*\uC6D4(?:\s*\d{1,2}\s*\uC77C)?/g,
28
+ /\d{1,2}\s*\uC6D4(?:\s*\d{1,2}\s*\uC77C)?/g,
29
+ /(?:19|20)\d{2}\s*\uB144/g,
30
+ new RegExp(`\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?(?:,?\\s+\\d{4})?`, "gi"),
31
+ new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}(?:\\s+\\d{4})?`, "gi"),
32
+ /\bv\d+(?:\.\d+)*\b|\b\d+(?:\.\d+){2,}\b/gi,
33
+ /\b(?:version|ver\.?|release)\s*\d+(?:\.\d+)*/gi,
34
+ /\b(?:fig(?:ure)?\.?|table|chart|section|step|phase|wave|part)\s*\d+(?:\.\d+)*/gi,
35
+ /(?:\uADF8\uB9BC|\uB3C4\uD45C|\uD45C|\uBC84\uC804)\s*\d+(?:\.\d+)*/g,
36
+ /\b\d{1,2}:\d{2}\b/g,
37
+ /\b(?:19|20)\d{2}s?\b/g,
38
+ ]
39
+ const NUM = "(?<![\\p{L}\\d.,])\\d+(?:[.,]\\d+)*"
40
+ const UNIT =
41
+ "(?:%|\\u2030|\\u00D7|\\uD37C\\uC13C\\uD2B8|\\uB2EC\\uB7EC|\\uC720\\uB85C|\\uC5D4|\\uC6D0|\\uB9CC|\\uC5B5|\\uC870|\\uCC9C|\\uCD08|\\uBD84|\\uC2DC\\uAC04|\\uC77C|\\uC8FC|\\uAC1C\\uC6D4|\\uB144|\\uBA85|\\uAC74|\\uAC1C|\\uD68C|\\uBC88|\\uBC30" +
42
+ "|(?:percent|pp|bps?|x|k|m|b|bn|million|billion|thousand|trillion|usd|krw|eur|ms|s|secs?|seconds?|mins?|minutes?|h|hrs?|hours?|days?|weeks?|months?|years?|kb|mb|gb|tb|users?|people|employees|engineers|tokens?|requests?|stars?|downloads?|customers?)(?![\\p{L}\\d]))"
43
+ const CLAIMS = [
44
+ new RegExp(`[$\\u20AC\\u00A3\\u00A5\\u20A9]\\s?${NUM}`, "u"),
45
+ new RegExp(`${NUM}\\s?${UNIT}`, "iu"),
46
+ /(?<![\d.,])\d{1,3}(?:,\d{3})+(?![\d,])/,
47
+ /(?<![\d.])\d+\.\d+(?![\d.])/,
48
+ ]
49
+
50
+ function snippet(text, index, span = 24) {
51
+ return text.slice(Math.max(0, index - span), index + span).trim()
52
+ }
53
+
54
+ /** Concatenated prose per owning element, in document order; `keep` filters text entries. */
55
+ function ownerTexts(ctx, keep) {
56
+ const byOwner = new Map()
57
+ for (const entry of ctx.texts) {
58
+ if (!keep(entry)) continue
59
+ byOwner.set(entry.owner, (byOwner.get(entry.owner) ?? "") + entry.node.text)
60
+ }
61
+ return [...byOwner].map(([owner, text]) => ({ owner, text: text.replace(/\s+/g, " ") }))
62
+ }
63
+
64
+ function firstEmoji(text) {
65
+ const chars = [...text]
66
+ for (let i = 0; i < chars.length; i++) {
67
+ const cp = chars[i].codePointAt(0)
68
+ if (chars[i + 1] === "\uFE0F" && cp !== 0xfe0f) return chars[i] + chars[i + 1]
69
+ if ((cp >= 0x1f000 && cp <= 0x1faff) || (cp >= 0x2600 && cp <= 0x26ff) || DINGBAT_EMOJI.has(cp)) return chars[i]
70
+ }
71
+ return null
72
+ }
73
+
74
+ const isDigit = (ch) => ch >= "0" && ch <= "9"
75
+
76
+ /** En dash allowed only when both neighbors (one optional space) are digits. */
77
+ function badEnDash(text) {
78
+ for (let at = text.indexOf(EN_DASH); at !== -1; at = text.indexOf(EN_DASH, at + 1)) {
79
+ const before = text[at - 1] === " " ? text[at - 2] : text[at - 1]
80
+ const after = text[at + 1] === " " ? text[at + 2] : text[at + 1]
81
+ if (!isDigit(before ?? "") || !isDigit(after ?? "")) return at
82
+ }
83
+ return -1
84
+ }
85
+
86
+ const stripNumbering = (text) => text.replace(HEADING_NUMBER, "").trim()
87
+
88
+ function headings(ctx, max) {
89
+ return ctx.elements.filter(({ node }) => /^h[1-6]$/.test(node.tag) && Number(node.tag[1]) <= max).map(({ node }) => node)
90
+ }
91
+
92
+ /** Citation markers need no stripping: an element carrying one is skipped before this runs. */
93
+ function claimIn(text) {
94
+ let rest = text
95
+ for (const re of NOT_CLAIMS) rest = rest.replace(re, " ")
96
+ for (const re of CLAIMS) {
97
+ const match = re.exec(rest)
98
+ if (match) return match[0].trim()
99
+ }
100
+ return null
101
+ }
102
+
103
+ function numberCandidates(ctx) {
104
+ const { closest, classTokens, descendants } = ctx.util
105
+ return ctx.elements.filter(({ node, scope }) => {
106
+ if (node.tag !== "p" && node.tag !== "li") return false
107
+ if (node.tag === "li" && descendants(node).some((child) => child.tag === "p" || child.tag === "li")) return false
108
+ const kind = ctx.kindOf(scope)
109
+ if (kind === "closing" || kind === "sources") return false
110
+ return !closest(node, (cur) => NUMBER_CONTAINERS.has(cur.tag) || classTokens(cur).includes("fig"))
111
+ })
112
+ }
113
+
114
+ export const textGates = [
115
+ {
116
+ codes: ["korean_no_keep_all"],
117
+ run(ctx) {
118
+ const syllables = ctx.texts.reduce((sum, entry) => sum + (entry.node.text.match(HANGUL)?.length ?? 0), 0)
119
+ if (syllables < MIN_HANGUL) return []
120
+ if ([...ctx.styleBlocks, ...ctx.styleAttrs].some((entry) => KEEP_ALL.test(entry.css))) return []
121
+ return [ctx.defect("korean_no_keep_all", ctx.body, `${syllables} Hangul syllables in prose and no "word-break: keep-all" in any style block or style attribute`)]
122
+ },
123
+ },
124
+ {
125
+ codes: ["emoji_in_prose"],
126
+ run(ctx) {
127
+ const defects = []
128
+ for (const { owner, text } of ownerTexts(ctx, () => true)) {
129
+ const emoji = firstEmoji(text)
130
+ if (emoji) defects.push(ctx.defect("emoji_in_prose", owner, `emoji "${emoji}" in <${owner.tag}>: "${snippet(text, text.indexOf(emoji))}"`))
131
+ }
132
+ return defects
133
+ },
134
+ },
135
+ {
136
+ codes: ["em_dash_in_prose", "en_dash_in_prose"],
137
+ run(ctx) {
138
+ const defects = []
139
+ const inScope = (entry) => !entry.cell && ctx.kindOf(entry.scope) !== "sources"
140
+ for (const { owner, text } of ownerTexts(ctx, inScope)) {
141
+ const em = text.indexOf(EM_DASH)
142
+ if (em !== -1) defects.push(ctx.defect("em_dash_in_prose", owner, `em dash "${EM_DASH}" in <${owner.tag}>: "${snippet(text, em)}"`))
143
+ const en = badEnDash(text)
144
+ if (en !== -1) defects.push(ctx.defect("en_dash_in_prose", owner, `en dash "${EN_DASH}" outside a numeric range in <${owner.tag}>: "${snippet(text, en)}"`))
145
+ }
146
+ return defects
147
+ },
148
+ },
149
+ {
150
+ codes: ["heading_too_long"],
151
+ run(ctx) {
152
+ const defects = []
153
+ for (const node of headings(ctx, 4)) {
154
+ const text = stripNumbering(proseText(node))
155
+ const hangul = /[\uAC00-\uD7A3]/.test(text)
156
+ const size = hangul ? [...text].length : text.split(/\s+/).filter(Boolean).length
157
+ if (size > (hangul ? MAX_HEADING_HANGUL : MAX_HEADING_WORDS)) {
158
+ defects.push(ctx.defect("heading_too_long", node, `<${node.tag}> "${text}" is ${size} ${hangul ? "characters (> 26)" : "words (> 12)"}`))
159
+ }
160
+ }
161
+ return defects
162
+ },
163
+ },
164
+ {
165
+ codes: ["heading_generic"],
166
+ run(ctx) {
167
+ const defects = []
168
+ for (const node of headings(ctx, 3)) {
169
+ const text = stripNumbering(proseText(node))
170
+ if (GENERIC.has(text.toLowerCase().replace(/[.:]+$/, ""))) defects.push(ctx.defect("heading_generic", node, `generic <${node.tag}> "${text}"`))
171
+ }
172
+ return defects
173
+ },
174
+ },
175
+ {
176
+ codes: ["unsourced_number"],
177
+ run(ctx) {
178
+ const defects = []
179
+ for (const { node } of numberCandidates(ctx)) {
180
+ if (defects.length >= MAX_UNSOURCED) break
181
+ const text = proseText(node)
182
+ if (LINEAGE_TOKEN.test(text) || ctx.util.hasCitation(node)) continue
183
+ if (node.attrs["data-lineage"] !== undefined || ctx.util.descendants(node).some((child) => child.attrs["data-lineage"] !== undefined)) continue
184
+ const claim = claimIn(text)
185
+ if (claim) defects.push(ctx.defect("unsourced_number", node, `number "${claim}" without a lineage tag or citation: "${snippet(text, text.indexOf(claim))}"`))
186
+ }
187
+ return defects
188
+ },
189
+ },
190
+ ]
@@ -0,0 +1,43 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { readFileSync } from "node:fs"
3
+ import { join } from "node:path"
4
+
5
+ import { DEFECT_CODES, GATE_STATUSES, LINEAGE_MODES } from "./contracts.mjs"
6
+
7
+ describe("report-gates glossary vocabulary parity", () => {
8
+ test("#given the defect codes #when the glossary is loaded #then every exported code appears as a backticked table cell", () => {
9
+ const glossaryPath = join(import.meta.dir, "../references/report-gates.md")
10
+ const glossary = readFileSync(glossaryPath, "utf8")
11
+
12
+ const missing: string[] = []
13
+ for (const code of Object.keys(DEFECT_CODES)) {
14
+ if (!glossary.includes(`\`${code}\``)) {
15
+ missing.push(code)
16
+ }
17
+ }
18
+
19
+ expect(missing, `Missing codes: ${missing.join(", ")}`).toEqual([])
20
+ })
21
+
22
+ test("#given the glossary #when scanned for backticked tokens #then no extra defect code appears that is not exported", () => {
23
+ const glossaryPath = join(import.meta.dir, "../references/report-gates.md")
24
+ const glossary = readFileSync(glossaryPath, "utf8")
25
+
26
+ const defectCodeSet = new Set(Object.keys(DEFECT_CODES))
27
+ const validTokens = new Set([...defectCodeSet, ...GATE_STATUSES, ...LINEAGE_MODES])
28
+ const backtickRegex = /`([a-z_][a-z0-9_]*)`/g
29
+ const extras: string[] = []
30
+
31
+ let match
32
+ while ((match = backtickRegex.exec(glossary)) !== null) {
33
+ const token = match[1]
34
+ // Only flag tokens that look like defect codes (underscore-separated)
35
+ // and aren't already exported from contracts
36
+ if (!validTokens.has(token) && token.includes("_") && token.length > 3) {
37
+ if (!extras.includes(token)) extras.push(token)
38
+ }
39
+ }
40
+
41
+ expect(extras, `Extra codes not in contracts: ${extras.join(", ")}`).toEqual([])
42
+ })
43
+ })