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,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
+ ]
@@ -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
+ ]