oh-my-opencode 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 (220) 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/agents/sisyphus/grok-4.d.ts +3 -2
  7. package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
  8. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
  9. package/dist/cli/index.js +1661 -859
  10. package/dist/cli/install-native/index.d.ts +9 -1
  11. package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
  12. package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
  13. package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
  14. package/dist/cli/install-native/plan.d.ts +8 -0
  15. package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
  16. package/dist/cli/install-native/run-native-install.d.ts +11 -1
  17. package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
  18. package/dist/cli/native-edition-hint.d.ts +2 -2
  19. package/dist/cli-node/index.js +1661 -859
  20. package/dist/hooks/auto-update-checker/checker/sandbox-refresh.d.ts +51 -0
  21. package/dist/hooks/auto-update-checker/checker/semver-compare.d.ts +17 -0
  22. package/dist/hooks/auto-update-checker/checker.d.ts +3 -0
  23. package/dist/hooks/auto-update-checker/hook/background-update-check.d.ts +8 -0
  24. package/dist/hooks/auto-update-checker/hook.d.ts +2 -0
  25. package/dist/hooks/native-edition-nudge/hook.d.ts +1 -1
  26. package/dist/index.js +1433 -1055
  27. package/dist/shared/index.d.ts +1 -0
  28. package/dist/shared/opencode-plugin-sandbox.d.ts +54 -0
  29. package/dist/skills/browser/SKILL.md +8 -1
  30. package/dist/skills/browser/references/install.md +34 -5
  31. package/dist/skills/browser/runtime/omowright/index.js +2 -2
  32. package/dist/skills/browser/runtime/omowright/manifest.json +3 -3
  33. package/dist/skills/browser/scripts/browser-doctor.mjs +14 -3
  34. package/dist/skills/browser/scripts/browser-install.mjs +13 -1
  35. package/dist/skills/ulw-research/AGENTS.md +63 -0
  36. package/dist/skills/ulw-research/SKILL.md +38 -26
  37. package/dist/skills/ulw-research/references/deliverable-phase.md +187 -0
  38. package/dist/skills/ulw-research/references/report-gates.md +66 -0
  39. package/dist/skills/ulw-research/scripts/cli-support.mjs +102 -0
  40. package/dist/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  41. package/dist/skills/ulw-research/scripts/contracts.mjs +162 -0
  42. package/dist/skills/ulw-research/scripts/contracts.test.ts +177 -0
  43. package/dist/skills/ulw-research/scripts/css-lite.mjs +204 -0
  44. package/dist/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  45. package/dist/skills/ulw-research/scripts/design-spec.mjs +161 -0
  46. package/dist/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  47. package/dist/skills/ulw-research/scripts/entities.mjs +39 -0
  48. package/dist/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  49. package/dist/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  50. package/dist/skills/ulw-research/scripts/format-extract.mjs +229 -0
  51. package/dist/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  52. package/dist/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  53. package/dist/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  54. package/dist/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  55. package/dist/skills/ulw-research/scripts/gates-static.mjs +167 -0
  56. package/dist/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  57. package/dist/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  58. package/dist/skills/ulw-research/scripts/gates-text.mjs +190 -0
  59. package/dist/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  60. package/dist/skills/ulw-research/scripts/html-lite.mjs +239 -0
  61. package/dist/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  62. package/dist/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  63. package/dist/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  64. package/dist/skills/ulw-research/scripts/outcome.mjs +250 -0
  65. package/dist/skills/ulw-research/scripts/outcome.test.ts +281 -0
  66. package/dist/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  67. package/dist/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  68. package/dist/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  69. package/dist/skills/ulw-research/scripts/report-tools.mjs +61 -0
  70. package/dist/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  71. package/dist/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  72. package/dist/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  73. package/dist/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  74. package/dist/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  75. package/dist/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  76. package/dist/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  77. package/dist/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  78. package/dist/skills/visual-qa/references/browser-setup.md +3 -1
  79. package/dist/tui.js +534 -66
  80. package/package.json +15 -15
  81. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  82. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  83. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  84. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  85. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  86. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  87. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  88. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  89. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  90. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  91. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  92. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  93. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
  94. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
  95. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  96. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  97. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  98. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  99. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  100. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  101. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
  102. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  103. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  104. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  105. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  106. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  107. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  108. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
  109. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  110. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  118. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  119. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  120. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  121. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  122. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  123. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  124. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  125. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  126. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  127. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  128. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  129. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  130. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  131. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  132. package/packages/omo-codex/plugin/package-lock.json +12 -12
  133. package/packages/omo-codex/plugin/package.json +1 -1
  134. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
  135. package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
  136. package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
  137. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
  138. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  139. package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
  140. package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
  141. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
  142. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
  143. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
  144. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
  145. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
  146. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
  147. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
  148. package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
  149. package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
  150. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
  151. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  152. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  153. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
  154. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  155. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  156. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
  157. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  158. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
  159. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
  160. package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  161. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
  162. package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  163. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  164. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
  165. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
  166. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
  167. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
  168. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
  169. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  170. package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
  171. package/packages/shared-skills/skills/browser/SKILL.md +8 -1
  172. package/packages/shared-skills/skills/browser/references/install.md +34 -5
  173. package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
  174. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
  175. package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
  176. package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
  177. package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
  178. package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
  179. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
  180. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
  181. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
  182. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  183. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
  184. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
  185. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
  186. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  187. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
  188. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  189. package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
  190. package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  191. package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  192. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
  193. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  194. package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  195. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  196. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  197. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
  198. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  199. package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  200. package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
  201. package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  202. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
  203. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  204. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  205. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  206. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
  207. package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
  208. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  209. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  210. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  211. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
  212. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  213. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  214. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  215. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  216. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  217. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  218. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  219. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  220. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
@@ -0,0 +1,173 @@
1
+ // gates-figures.mjs - visual gates: G2 palette, G8 chart without figure, G9 chart svg text,
2
+ // G10 Korean serif/brush fonts.
3
+ //
4
+ // Each gate is { codes, skip?(ctx), run(ctx) -> Defect[] } over the context built by
5
+ // gates-static.mjs. Plain Node ESM, sibling imports only.
6
+
7
+ import { fontFamilies, hexColors, normalizeHex, resolveVar } from "./css-lite.mjs"
8
+ import { decodeEntities, proseText } from "./html-lite.mjs"
9
+
10
+ const ALWAYS_ALLOWED = new Set(["#ffffff", "#000000"])
11
+ const MAX_PALETTE = 20
12
+ const SVG_PAINT = /(?:fill|stroke|stop-color|flood-color|lighting-color)\s*(?:=\s*["']?|:\s*)(#[0-9a-f]{3,8})(?![\w-])/gi
13
+ const SVG_FONT = /font-family\s*(?:=\s*("[^"]*"|'[^']*')|:\s*([^;"'>]+))/gi
14
+ const ICON_MAX = 32
15
+ const CHART_MIN = 200
16
+ const ICON_NAME = /icon|logo/i
17
+ const ICON_HOSTS = new Set(["a", "button", "nav", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6"])
18
+ const CAPTION_CLASS = /^(?:caption|fig-?cap(?:tion)?)$/
19
+ const DEFAULT_FONT_SIZE = 16
20
+ const MIN_TITLE_CHARS = 4
21
+ const MIN_TICKS = 3
22
+ const NUMERIC = /^[-+\u2212]?[$\u20AC\u00A3\u00A5\u20A9]?\d[\d,.:]*\s?(?:%|[a-z\uAC00-\uD7A3]{0,3})$/i
23
+ const SERIF_FAMILY = /nanummyeongjo|applemyungjo|gungsuh|batang|nanumbrush|nanumpen|\uAD81\uC11C|\uBC14\uD0D5|\uBA85\uC870/
24
+
25
+ function pixels(value) {
26
+ const match = /^\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*$/i.exec(value ?? "")
27
+ return match ? Number(match[1]) : null
28
+ }
29
+
30
+ /** Largest declared dimension: width/height attributes or style, else the viewBox. */
31
+ function declaredSize(node) {
32
+ const style = node.attrs.style ?? ""
33
+ const fromStyle = (prop) => pixels(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1])
34
+ const sizes = [pixels(node.attrs.width), pixels(node.attrs.height), fromStyle("width"), fromStyle("height")].filter((n) => n !== null)
35
+ if (sizes.length) return Math.max(...sizes)
36
+ const box = String(node.attrs.viewbox ?? "").trim().split(/[\s,]+/).map(Number)
37
+ return box.length === 4 && box.every(Number.isFinite) ? Math.max(box[2], box[3]) : null
38
+ }
39
+
40
+ function isIcon(ctx, node) {
41
+ const size = declaredSize(node)
42
+ if (size !== null && size <= ICON_MAX) return true
43
+ if (ICON_NAME.test(`${node.attrs.class ?? ""} ${node.attrs.id ?? ""}`)) return true
44
+ return Boolean(ctx.util.closest(node, (cur) => ICON_HOSTS.has(cur.tag)))
45
+ }
46
+
47
+ const isFigure = (ctx) => (cur) => cur.tag === "figure" || ctx.util.classTokens(cur).includes("fig")
48
+
49
+ function hasCaption(ctx, figure) {
50
+ return ctx.util.descendants(figure).some((node) => (node.tag === "figcaption" || ctx.util.classTokens(node).some((token) => CAPTION_CLASS.test(token))) && proseText(node).length > 0)
51
+ }
52
+
53
+ function visuals(ctx) {
54
+ return ctx.elements.map(({ node }) => node).filter((node) => (node.tag === "img" || node.tag === "svg") && !isIcon(ctx, node))
55
+ }
56
+
57
+ function label(node) {
58
+ if (node.tag === "img") {
59
+ const src = node.attrs.src ?? ""
60
+ return `<img src="${src.length > 48 ? `${src.slice(0, 48)}...` : src}">`
61
+ }
62
+ return `<svg${node.attrs.width ? ` width="${node.attrs.width}"` : ""}${node.attrs.height ? ` height="${node.attrs.height}"` : ""}>`
63
+ }
64
+
65
+ function svgTexts(raw) {
66
+ return [...raw.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text\s*>/gi)]
67
+ .map((match) => ({
68
+ size: Number(/font-size\s*(?:=\s*["']?|:\s*)(\d+(?:\.\d+)?)/i.exec(match[1])?.[1] ?? DEFAULT_FONT_SIZE),
69
+ value: decodeEntities(match[2].replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim(),
70
+ }))
71
+ .filter((text) => text.value)
72
+ }
73
+
74
+ function missingChartLabels(raw) {
75
+ const texts = svgTexts(raw)
76
+ const title = decodeEntities(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(raw)?.[1] ?? "").trim()
77
+ if (!texts.length && !title) return null
78
+ const maxSize = Math.max(0, ...texts.map((text) => text.size))
79
+ const missing = []
80
+ if (!title && !texts.some((text) => text.size === maxSize && [...text.value].length >= MIN_TITLE_CHARS)) missing.push("title")
81
+ const ticks = texts.filter((text) => NUMERIC.test(text.value)).length
82
+ if (ticks < MIN_TICKS) missing.push(`numeric ticks (${ticks} < ${MIN_TICKS})`)
83
+ if (!texts.some((text) => /\p{L}/u.test(text.value) && !NUMERIC.test(text.value))) missing.push("text label")
84
+ return missing
85
+ }
86
+
87
+ function colorUses(ctx) {
88
+ const uses = new Map()
89
+ const add = (hex, node) => {
90
+ const color = normalizeHex(hex)
91
+ if (!color) return
92
+ const entry = uses.get(color) ?? { node, count: 0 }
93
+ entry.count++
94
+ uses.set(color, entry)
95
+ }
96
+ for (const { node, css } of [...ctx.styleBlocks, ...ctx.styleAttrs]) for (const hex of hexColors(css)) add(hex, node)
97
+ for (const node of ctx.all) {
98
+ if (node.tag !== "svg") continue
99
+ for (const key of ["fill", "stroke", "stop-color"]) if (node.attrs[key]) add(node.attrs[key], node)
100
+ for (const match of (node.raw ?? "").matchAll(SVG_PAINT)) add(match[1], node)
101
+ }
102
+ return uses
103
+ }
104
+
105
+ function fontDeclarations(ctx) {
106
+ const found = []
107
+ for (const { node, css } of ctx.styleBlocks) for (const { families } of fontFamilies(css)) found.push({ node, families })
108
+ for (const { node, css } of ctx.styleAttrs) for (const { families } of fontFamilies(`x{${resolveVar(css, ctx.tokens)}}`)) found.push({ node, families })
109
+ for (const node of ctx.all) {
110
+ if (node.tag !== "svg") continue
111
+ const values = [node.attrs["font-family"], ...[...(node.raw ?? "").matchAll(SVG_FONT)].map((m) => m[1] ?? m[2])].filter(Boolean)
112
+ for (const value of values) for (const { families } of fontFamilies(`x{font-family:${value}}`)) found.push({ node, families })
113
+ }
114
+ return found
115
+ }
116
+
117
+ export const figureGates = [
118
+ {
119
+ codes: ["palette_off_token"],
120
+ skip: (ctx) => ctx.palette.size === 0,
121
+ run(ctx) {
122
+ const palette = new Set([...ctx.palette].map(normalizeHex))
123
+ const defects = []
124
+ for (const [color, { node, count }] of colorUses(ctx)) {
125
+ if (defects.length >= MAX_PALETTE) break
126
+ if (palette.has(color) || ALWAYS_ALLOWED.has(color)) continue
127
+ defects.push(ctx.defect("palette_off_token", node, `color ${color} is not in the design-spec palette (${count} use${count === 1 ? "" : "s"})`))
128
+ }
129
+ return defects
130
+ },
131
+ },
132
+ {
133
+ codes: ["chart_without_figure"],
134
+ run(ctx) {
135
+ return visuals(ctx)
136
+ .filter((node) => {
137
+ const figure = ctx.util.closest(node, isFigure(ctx))
138
+ return !figure || !hasCaption(ctx, figure)
139
+ })
140
+ .map((node) => ctx.defect("chart_without_figure", node, `${label(node)} has no figure container with a non-empty caption`))
141
+ },
142
+ },
143
+ {
144
+ codes: ["chart_svg_no_text", "chart_svg_missing_labels"],
145
+ run(ctx) {
146
+ const defects = []
147
+ for (const node of visuals(ctx)) {
148
+ if (node.tag !== "svg") continue
149
+ if (!((declaredSize(node) ?? 0) >= CHART_MIN || ctx.util.closest(node, isFigure(ctx)))) continue
150
+ const missing = missingChartLabels(node.raw ?? "")
151
+ if (missing === null) defects.push(ctx.defect("chart_svg_no_text", node, `${label(node)} chart has no <text> and no <title>`))
152
+ else if (missing.length) defects.push(ctx.defect("chart_svg_missing_labels", node, `${label(node)} chart is missing: ${missing.join(", ")}`))
153
+ }
154
+ return defects
155
+ },
156
+ },
157
+ {
158
+ codes: ["korean_serif_font"],
159
+ run(ctx) {
160
+ const defects = []
161
+ const seen = new Set()
162
+ for (const { node, families } of fontDeclarations(ctx)) {
163
+ for (const family of families) {
164
+ const key = family.toLowerCase().replace(/[\s"'_-]/g, "")
165
+ if (!SERIF_FAMILY.test(key) || seen.has(key)) continue
166
+ seen.add(key)
167
+ defects.push(ctx.defect("korean_serif_font", node, `serif or brush Korean font family "${family}"`))
168
+ }
169
+ }
170
+ return defects
171
+ },
172
+ },
173
+ ]
@@ -0,0 +1,146 @@
1
+ // gates-layout.mjs - L1-L5 geometric gates over the layout probe output (boxes.json).
2
+ //
3
+ // Pure functions over the object layout-probe.mjs returns: no browser, no DOM, and selectors are
4
+ // taken verbatim from the probe (the probe already built them with the html-lite rule). Severities
5
+ // and hints are read from contracts.DEFECT_CODES, never set here. Measured pixel amounts are
6
+ // rounded to 0.1px (the probe's own precision) before a threshold compare, so float noise such as
7
+ // 2.000000000000001 never crosses a 2px limit.
8
+
9
+ import { DEFECT_CODES } from "./contracts.mjs"
10
+ import { PROBE_VERSION, ProbeError } from "./layout-probe.mjs"
11
+
12
+ /** Thresholds (draft D10): rounding tolerances, not taste. */
13
+ export const OVERFLOW_PX = 2
14
+ export const SCROLL_SLACK_PX = 2
15
+ export const ASPECT_DRIFT = 0.03
16
+ export const OVERLAP_PX = 12
17
+
18
+ const OUT_OF_FLOW = new Set(["absolute", "fixed"])
19
+ const IN_FLOW = new Set(["static", "relative"])
20
+ const CLIPPING = new Set(["hidden", "clip"])
21
+ const SCROLLING = new Set(["auto", "scroll"])
22
+ const ASPECT_SAFE_FITS = new Set(["cover", "contain", "scale-down"])
23
+
24
+ const r1 = (n) => Math.round(n * 10) / 10
25
+
26
+ /**
27
+ * @typedef {{ code: string, severity: string, selector: string, line: null, message: string, hint: string }} Defect
28
+ */
29
+ function defect(code, selector, message) {
30
+ const { severity, hint } = DEFECT_CODES[code]
31
+ return { code, severity, selector, line: null, message, hint }
32
+ }
33
+
34
+ // L1: the largest spill of the child rect past any side of its parent rect. A parent that scrolls
35
+ // on the spill axis makes it intentional scrolling (the advisory scroll container); a parent that
36
+ // clips on that axis hides the spilled content (text clipped).
37
+ function checkOverflow(box) {
38
+ if (!box.parentRect || OUT_OF_FLOW.has(box.position)) return null
39
+ const c = box.rect
40
+ const p = box.parentRect
41
+ const sides = [
42
+ ["left", p.x - c.x],
43
+ ["top", p.y - c.y],
44
+ ["right", c.x + c.w - (p.x + p.w)],
45
+ ["bottom", c.y + c.h - (p.y + p.h)],
46
+ ].map(([side, px]) => [side, r1(px)])
47
+ const [side, px] = sides.reduce((worst, entry) => (entry[1] > worst[1] ? entry : worst))
48
+ if (px <= OVERFLOW_PX) return null
49
+ const parentMode = box.parentOverflow?.[side === "left" || side === "right" ? "x" : "y"]
50
+ if (SCROLLING.has(parentMode)) {
51
+ return defect("layout_scroll_container", box.selector, `${box.tag} is ${px}px wider than ${box.parentSelector}, which scrolls (overflow: ${parentMode})`)
52
+ }
53
+ if (CLIPPING.has(parentMode)) {
54
+ return defect("layout_text_clipped", box.selector, `${box.tag} spills ${px}px past the ${side} edge of ${box.parentSelector}, which clips it (overflow: ${parentMode})`)
55
+ }
56
+ return defect("layout_overflow", box.selector, `${box.tag} spills ${px}px past the ${side} edge of ${box.parentSelector}`)
57
+ }
58
+
59
+ // L2/L3: content larger than the box on an axis whose computed overflow clips or scrolls.
60
+ function checkScroll(box) {
61
+ const { sw, cw, sh, ch } = box.scroll
62
+ const axes = [
63
+ ["x", sw - cw, box.overflow.x],
64
+ ["y", sh - ch, box.overflow.y],
65
+ ].filter(([, excess]) => r1(excess) > SCROLL_SLACK_PX)
66
+ const clipped = axes.find(([, , mode]) => CLIPPING.has(mode))
67
+ if (clipped) {
68
+ const [axis, excess, mode] = clipped
69
+ return defect("layout_text_clipped", box.selector, `${box.tag} content exceeds its box by ${r1(excess)}px on ${axis} under overflow: ${mode}`)
70
+ }
71
+ const scrolled = axes.find(([, , mode]) => SCROLLING.has(mode))
72
+ if (scrolled) {
73
+ const [axis, excess, mode] = scrolled
74
+ return defect("layout_scroll_container", box.selector, `${box.tag} scrolls ${r1(excess)}px on ${axis} under overflow: ${mode}`)
75
+ }
76
+ return null
77
+ }
78
+
79
+ // L4: rendered aspect ratio drifting from the natural one without an aspect-preserving fit.
80
+ function checkAspect(box) {
81
+ if (box.tag !== "img" || !box.natural) return null
82
+ const { w, h } = box.rect
83
+ const { w: nw, h: nh } = box.natural
84
+ if (!(w > 0 && h > 0 && nw > 0 && nh > 0)) return null
85
+ const fit = box.objectFit ?? "fill"
86
+ if (ASPECT_SAFE_FITS.has(fit)) return null
87
+ const drift = Math.abs(w / h / (nw / nh) - 1)
88
+ if (drift <= ASPECT_DRIFT) return null
89
+ const percent = (drift * 100).toFixed(1)
90
+ return defect("layout_image_distorted", box.selector, `img rendered ${w}x${h} from natural ${nw}x${nh} distorts the aspect ratio by ${percent}% (object-fit: ${fit})`)
91
+ }
92
+
93
+ function intersection(a, b) {
94
+ const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
95
+ const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
96
+ return { ix: r1(ix), iy: r1(iy) }
97
+ }
98
+
99
+ // L5: in-flow siblings (same parentSelector) whose rects intersect by more than 12px on both axes.
100
+ function checkSiblings(boxes) {
101
+ const groups = new Map()
102
+ for (const box of boxes) {
103
+ if (box.parentSelector === null || box.parentSelector === undefined || !IN_FLOW.has(box.position)) continue
104
+ const group = groups.get(box.parentSelector) ?? []
105
+ group.push(box)
106
+ groups.set(box.parentSelector, group)
107
+ }
108
+ const defects = []
109
+ for (const group of groups.values()) {
110
+ for (let i = 0; i < group.length; i += 1) {
111
+ for (let j = i + 1; j < group.length; j += 1) {
112
+ const { ix, iy } = intersection(group[i].rect, group[j].rect)
113
+ if (Math.min(ix, iy) <= OVERLAP_PX) continue
114
+ defects.push(defect("layout_sibling_overlap", group[i].selector, `${group[i].selector} and ${group[j].selector} overlap by ${ix}x${iy}px`))
115
+ }
116
+ }
117
+ }
118
+ return defects
119
+ }
120
+
121
+ function validateProbe(probe) {
122
+ if (probe === null || typeof probe !== "object") throw new ProbeError("invalid_probe", "layout probe must be an object")
123
+ if (probe.probe_version !== PROBE_VERSION) {
124
+ throw new ProbeError("unsupported_probe_version", `layout probe_version must be ${PROBE_VERSION}, got ${probe.probe_version}`)
125
+ }
126
+ if (!Array.isArray(probe.boxes)) throw new ProbeError("invalid_probe", "layout probe boxes must be an array")
127
+ }
128
+
129
+ /**
130
+ * @param {{ probe_version: number, count?: number, truncated?: boolean, boxes: object[] }} probe
131
+ * @returns {{ defects: Defect[], summary: string }}
132
+ */
133
+ export function checkLayout(probe) {
134
+ validateProbe(probe)
135
+ const defects = []
136
+ for (const box of probe.boxes) {
137
+ for (const rule of [checkOverflow, checkScroll, checkAspect]) {
138
+ const found = rule(box)
139
+ if (found) defects.push(found)
140
+ }
141
+ }
142
+ defects.push(...checkSiblings(probe.boxes))
143
+ let summary = `layout: ${probe.boxes.length} boxes checked, ${defects.length} defects`
144
+ if (probe.truncated) summary += `; layout sample truncated`
145
+ return { defects, summary }
146
+ }
@@ -0,0 +1,167 @@
1
+ // gates-static.mjs - deterministic static gates (G1-G15) over a parsed report.
2
+ //
3
+ // Builds one document context (a document-order walk with h2 sections, citation helpers and a
4
+ // defect factory) and hands it to the text, figure and structure gate modules. Every severity
5
+ // comes from contracts.DEFECT_CODES; the single override is unsourced_number under
6
+ // `Lineage: section`, derived from the spec mode passed in, never from a caller-supplied
7
+ // severity. Plain Node ESM, node: builtins and siblings only.
8
+
9
+ import * as nodeFs from "node:fs"
10
+
11
+ import { DEFECT_CODES, LINEAGE_MODES } from "./contracts.mjs"
12
+ import { customProperties } from "./css-lite.mjs"
13
+ import { figureGates } from "./gates-figures.mjs"
14
+ import { structureGates } from "./gates-structure.mjs"
15
+ import { textGates } from "./gates-text.mjs"
16
+ import { PROSE_TAGS, proseText, selectorPath } from "./html-lite.mjs"
17
+
18
+ const CITATION_MARKER = /\[S\d+\]|\[Source\s*\d+\]|\[\uCD9C\uCC98[^\]]*\]|\[\^\d+\]/i
19
+ const ANCHOR_HREF = /^#(?:s|src|source|ref|fn)/i
20
+ const ANCHOR_TEXT = /^\[(?:\uCD9C\uCC98|Source\s*\d+|S\d+)\]$/i
21
+ /** Closing ("how this report was made") heading; Korean alternates cover the same register. */
22
+ export const CLOSING_HEADING = /\b(?:method(?:ology)?|how)\b|\uB4B7\uBA74|\uBC29\uBC95|\uC5B4\uB5BB\uAC8C|\uB9CC\uB4E4\uC5B4/i
23
+ export const SOURCES_HEADING = /references|sources|bibliography|\uCD9C\uCC98|\uCC38\uACE0\s*(?:\uBB38\uD5CC|\uC790\uB8CC)/i
24
+ const OTHER_KINDS = [
25
+ ["toc", /\b(?:contents|toc)\b|\uBAA9\uCC28/i],
26
+ ["appendix", /appendix|\uBD80\uB85D/i],
27
+ ["glossary", /glossary|\uC6A9\uC5B4/i],
28
+ ]
29
+ const TEXT_SKIP = new Set(["code", "pre", "kbd", "samp", "script", "style", "svg", "template", "noscript"])
30
+ const HEADING_TAG = /^h[1-6]$/
31
+ /** The one sanctioned severity override: section-level lineage demotes unsourced_number. */
32
+ const SECTION_LINEAGE_SEVERITY = "minor"
33
+
34
+ const GATES = [...textGates, ...figureGates, ...structureGates]
35
+
36
+ function classTokens(node) {
37
+ return String(node.attrs.class ?? "").toLowerCase().split(/\s+/).filter(Boolean)
38
+ }
39
+
40
+ function closest(node, predicate) {
41
+ for (let cur = node.parent; cur; cur = cur.parent) if (predicate(cur)) return cur
42
+ return null
43
+ }
44
+
45
+ function descendants(node, out = []) {
46
+ for (const child of node.children) {
47
+ if (child.tag === "#text") continue
48
+ out.push(child)
49
+ descendants(child, out)
50
+ }
51
+ return out
52
+ }
53
+
54
+ function isCitationAnchor(node) {
55
+ return node.tag === "a" && (ANCHOR_HREF.test(node.attrs.href ?? "") || ANCHOR_TEXT.test(proseText(node)))
56
+ }
57
+
58
+ /** A citation marker in the element's prose or a citation anchor below it. */
59
+ function hasCitation(node) {
60
+ return CITATION_MARKER.test(proseText(node)) || descendants(node).some(isCitationAnchor)
61
+ }
62
+
63
+ function toRegExp(pattern) {
64
+ const re = pattern instanceof RegExp ? pattern : new RegExp(String(pattern), "i")
65
+ return new RegExp(re.source, re.flags.replace(/[gy]/g, ""))
66
+ }
67
+
68
+ function findBody(doc) {
69
+ const stack = [doc]
70
+ while (stack.length) {
71
+ const node = stack.pop()
72
+ if (node.tag === "body") return node
73
+ for (const child of node.children) if (child.tag !== "#text") stack.push(child)
74
+ }
75
+ return doc
76
+ }
77
+
78
+ function sectionKind(text, closingTest) {
79
+ if (SOURCES_HEADING.test(text)) return "sources"
80
+ if (closingTest(text)) return "closing"
81
+ return OTHER_KINDS.find(([, re]) => re.test(text))?.[0] ?? null
82
+ }
83
+
84
+ /** Document-order walk of body: element scopes, prose text entries with owners, h2 sections. */
85
+ function walkBody(ctx, node, state) {
86
+ for (const child of node.children) {
87
+ if (child.tag === "#text") {
88
+ if (state.skip) continue
89
+ ctx.texts.push({ node: child, owner: state.owner ?? node, cell: state.cell, scope: ctx.scope })
90
+ if (ctx.scope.section && !state.heading) ctx.scope.section.prose.push(child.text)
91
+ continue
92
+ }
93
+ if (child.tag === "h2") {
94
+ const section = { heading: child, kind: sectionKind(proseText(child), ctx.isClosing), prose: [], anchors: 0 }
95
+ ctx.sections.push(section)
96
+ ctx.scope = { section, closingH3: false }
97
+ } else if (child.tag === "h3") {
98
+ ctx.scope = { section: ctx.scope.section, closingH3: ctx.isClosing(proseText(child)) }
99
+ }
100
+ ctx.elements.push({ node: child, scope: ctx.scope })
101
+ if (ctx.scope.section && isCitationAnchor(child)) ctx.scope.section.anchors++
102
+ walkBody(ctx, child, {
103
+ skip: state.skip || TEXT_SKIP.has(child.tag),
104
+ owner: PROSE_TAGS.has(child.tag) ? child : state.owner,
105
+ cell: state.cell || child.tag === "td" || child.tag === "th",
106
+ heading: state.heading || HEADING_TAG.test(child.tag),
107
+ })
108
+ }
109
+ }
110
+
111
+ function buildContext(doc, options) {
112
+ const lineageMode = options.lineageMode ?? "inline"
113
+ if (!LINEAGE_MODES.includes(lineageMode)) throw new TypeError(`lineageMode must be one of ${LINEAGE_MODES.join(", ")}`)
114
+ const closingPattern = options.closingPattern ? toRegExp(options.closingPattern) : null
115
+ const all = descendants(doc)
116
+ const styleBlocks = all.filter((node) => node.tag === "style").map((node) => ({ node, css: node.raw ?? "" }))
117
+ const styleAttrs = all.filter((node) => node.attrs.style).map((node) => ({ node, css: node.attrs.style }))
118
+ const ctx = {
119
+ doc,
120
+ body: findBody(doc),
121
+ all,
122
+ elements: [],
123
+ texts: [],
124
+ sections: [],
125
+ scope: { section: null, closingH3: false },
126
+ styleBlocks,
127
+ styleAttrs,
128
+ tokens: customProperties(styleBlocks.map((entry) => entry.css).join("\n")).light,
129
+ palette: new Set(options.palette ?? []),
130
+ requireSections: (options.requireSections ?? []).map(toRegExp),
131
+ baseDir: options.baseDir ?? null,
132
+ fs: options.fs ?? nodeFs,
133
+ isClosing: (text) => CLOSING_HEADING.test(text) || Boolean(closingPattern?.test(text)),
134
+ util: { classTokens, closest, descendants, hasCitation, citationMarker: CITATION_MARKER },
135
+ kindOf: (scope) => (scope.closingH3 ? "closing" : (scope.section?.kind ?? null)),
136
+ defect(code, node, message) {
137
+ const entry = DEFECT_CODES[code]
138
+ const severity = code === "unsourced_number" && lineageMode === "section" ? SECTION_LINEAGE_SEVERITY : entry.severity
139
+ return { code, severity, selector: selectorPath(node) || node.tag, line: node.line, message, hint: entry.hint }
140
+ },
141
+ }
142
+ walkBody(ctx, ctx.body, { skip: false, owner: null, cell: false, heading: false })
143
+ return ctx
144
+ }
145
+
146
+ /**
147
+ * Run G1-G15 over a parsed report (html-lite root).
148
+ * @param {object} doc parseHtml(...) result
149
+ * @param {{ palette?: Set<string>, lineageMode?: "inline"|"section", requireSections?: (RegExp|string)[],
150
+ * baseDir?: string, fs?: { statSync(path: string): { size: number } }, closingPattern?: RegExp|string }} options
151
+ * @returns {{ defects: object[], gatesRun: string[], gatesSkipped: string[] }}
152
+ */
153
+ export function checkStatic(doc, options = {}) {
154
+ const ctx = buildContext(doc, options)
155
+ const defects = []
156
+ const gatesRun = []
157
+ const gatesSkipped = []
158
+ for (const gate of GATES) {
159
+ if (gate.skip?.(ctx)) {
160
+ gatesSkipped.push(...gate.codes)
161
+ continue
162
+ }
163
+ gatesRun.push(...gate.codes)
164
+ defects.push(...gate.run(ctx))
165
+ }
166
+ return { defects, gatesRun, gatesSkipped }
167
+ }
@@ -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
+ ]