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,236 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { readFileSync } from "node:fs"
3
+ import path from "node:path"
4
+ import { fileURLToPath } from "node:url"
5
+
6
+ import { DEFECT_CODES, LAYOUT_CODES } from "./contracts.mjs"
7
+ import { checkLayout } from "./gates-layout.mjs"
8
+ import { ProbeError } from "./layout-probe.mjs"
9
+
10
+ const FIXTURE_PATH = path.join(path.dirname(fileURLToPath(import.meta.url)), "tests", "fixtures", "boxes.json")
11
+
12
+ const PARENT = "body > main > section"
13
+ const PARENT_RECT = { x: 0, y: 0, w: 600, h: 400 }
14
+
15
+ type Size = { w: number; h: number }
16
+ type Box = ReturnType<typeof box>
17
+
18
+ function box(overrides: Record<string, unknown> = {}) {
19
+ return {
20
+ selector: `${PARENT} > p`,
21
+ tag: "p",
22
+ rect: { x: 10, y: 10, w: 100, h: 20 },
23
+ parentSelector: PARENT,
24
+ parentRect: PARENT_RECT,
25
+ scroll: { sw: 100, cw: 100, sh: 20, ch: 20 },
26
+ overflow: { x: "visible", y: "visible" },
27
+ position: "static",
28
+ textLen: 10,
29
+ ...overrides,
30
+ }
31
+ }
32
+
33
+ function img(rect: Size, natural: Size, overrides: Record<string, unknown> = {}) {
34
+ return box({ selector: `${PARENT} > figure > img`, tag: "img", rect: { x: 0, y: 0, ...rect }, textLen: 0, objectFit: "fill", natural, ...overrides })
35
+ }
36
+
37
+ function probe(boxes: Box[], overrides: Record<string, unknown> = {}) {
38
+ return { probe_version: 1, viewport: { w: 1280, h: 900 }, document: { w: 1280, h: 2000 }, count: boxes.length, truncated: false, boxes, ...overrides }
39
+ }
40
+
41
+ function codes(result: ReturnType<typeof checkLayout>) {
42
+ return result.defects.map((defect) => defect.code)
43
+ }
44
+
45
+ describe("checkLayout L1 overflow", () => {
46
+ test("#given a child spilling 3px past the right edge of its parent #when checked #then layout_overflow is raised with the contract severity", () => {
47
+ // given
48
+ const child = box({ rect: { x: 500, y: 10, w: 103, h: 20 } })
49
+ // when
50
+ const result = checkLayout(probe([child]))
51
+ // then
52
+ expect(codes(result)).toEqual(["layout_overflow"])
53
+ expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_overflow.severity)
54
+ expect(result.defects[0].selector).toBe(child.selector)
55
+ expect(result.defects[0].hint).toBe(DEFECT_CODES.layout_overflow.hint)
56
+ expect(result.defects[0].message).toContain("3px")
57
+ })
58
+
59
+ test("#given a child spilling exactly 2px #when checked #then no defect is raised", () => {
60
+ expect(checkLayout(probe([box({ rect: { x: 500, y: 10, w: 102, h: 20 } })])).defects).toEqual([])
61
+ })
62
+
63
+ test("#given a 2px spill that float arithmetic on 0.1px probe values turns into 2.000000000000001 #when checked #then no defect is raised", () => {
64
+ // given
65
+ const child = box({ rect: { x: 0.1, y: 0, w: 9.8, h: 5 }, parentRect: { x: 0.1, y: 0, w: 7.8, h: 20 } })
66
+ expect(child.rect.x + child.rect.w - (child.parentRect.x + child.parentRect.w)).toBeGreaterThan(2)
67
+ // when / then
68
+ expect(checkLayout(probe([child])).defects).toEqual([])
69
+ })
70
+
71
+ test("#given a child spilling 3px past the top edge #when checked #then layout_overflow is raised", () => {
72
+ expect(codes(checkLayout(probe([box({ rect: { x: 10, y: -3, w: 100, h: 20 } })])))).toEqual(["layout_overflow"])
73
+ })
74
+
75
+ test("#given absolute and fixed children spilling 50px #when checked #then they are exempt", () => {
76
+ const boxes = ["absolute", "fixed"].map((position, i) => box({ selector: `${PARENT} > div:nth-of-type(${i + 1}) > p`, parentSelector: `${PARENT} > div:nth-of-type(${i + 1})`, rect: { x: 580, y: 10, w: 70, h: 20 }, position }))
77
+ expect(checkLayout(probe(boxes)).defects).toEqual([])
78
+ })
79
+
80
+ test("#given a box with no parent rect #when checked #then L1 is skipped without throwing", () => {
81
+ expect(checkLayout(probe([box({ parentSelector: null, parentRect: null })])).defects).toEqual([])
82
+ })
83
+ })
84
+
85
+ describe("checkLayout L2 and L3 scroll inequality", () => {
86
+ test("#given text wider than its box by 3px under overflow hidden #when checked #then layout_text_clipped is raised", () => {
87
+ const clipped = box({ scroll: { sw: 103, cw: 100, sh: 20, ch: 20 }, overflow: { x: "hidden", y: "visible" } })
88
+ const result = checkLayout(probe([clipped]))
89
+ expect(codes(result)).toEqual(["layout_text_clipped"])
90
+ expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_text_clipped.severity)
91
+ })
92
+
93
+ test("#given text taller than its box by 3px under overflow clip #when checked #then layout_text_clipped is raised", () => {
94
+ const clipped = box({ scroll: { sw: 100, cw: 100, sh: 23, ch: 20 }, overflow: { x: "visible", y: "clip" } })
95
+ expect(codes(checkLayout(probe([clipped])))).toEqual(["layout_text_clipped"])
96
+ })
97
+
98
+ test("#given a 2px excess under overflow hidden #when checked #then no defect is raised", () => {
99
+ const edge = box({ scroll: { sw: 102, cw: 100, sh: 22, ch: 20 }, overflow: { x: "hidden", y: "hidden" } })
100
+ expect(checkLayout(probe([edge])).defects).toEqual([])
101
+ })
102
+
103
+ test("#given a pre that scrolls horizontally under overflow auto #when checked #then minor layout_scroll_container is raised", () => {
104
+ const scroller = box({ selector: `${PARENT} > pre`, tag: "pre", scroll: { sw: 812, cw: 550, sh: 156, ch: 156 }, overflow: { x: "auto", y: "auto" } })
105
+ const result = checkLayout(probe([scroller]))
106
+ expect(codes(result)).toEqual(["layout_scroll_container"])
107
+ expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_scroll_container.severity)
108
+ })
109
+
110
+ test("#given content overflowing a visible box #when checked #then neither L2 nor L3 fires", () => {
111
+ const visible = box({ scroll: { sw: 300, cw: 100, sh: 90, ch: 20 } })
112
+ expect(checkLayout(probe([visible])).defects).toEqual([])
113
+ })
114
+ })
115
+
116
+ describe("checkLayout L4 image distortion", () => {
117
+ test("#given an img rendered 300x150 with natural 300x160 #when checked #then layout_image_distorted names 6.7%", () => {
118
+ const result = checkLayout(probe([img({ w: 300, h: 150 }, { w: 300, h: 160 })]))
119
+ expect(codes(result)).toEqual(["layout_image_distorted"])
120
+ expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_image_distorted.severity)
121
+ expect(result.defects[0].message).toContain("6.7%")
122
+ })
123
+
124
+ test("#given an img rendered 300x150 with natural 300x154 (under 3% drift) #when checked #then no defect is raised", () => {
125
+ expect(checkLayout(probe([img({ w: 300, h: 150 }, { w: 300, h: 154 })])).defects).toEqual([])
126
+ })
127
+
128
+ test("#given a distorted img under object-fit cover, contain, or scale-down #when checked #then it is exempt", () => {
129
+ const boxes = ["cover", "contain", "scale-down"].map((objectFit, i) => img({ w: 300, h: 150 }, { w: 300, h: 300 }, { objectFit, selector: `${PARENT} > figure:nth-of-type(${i + 1}) > img`, parentSelector: `${PARENT} > figure:nth-of-type(${i + 1})` }))
130
+ expect(checkLayout(probe(boxes)).defects).toEqual([])
131
+ })
132
+
133
+ test("#given an img whose natural size is 0x0 (not loaded) #when checked #then it is skipped without throwing", () => {
134
+ expect(() => checkLayout(probe([img({ w: 300, h: 150 }, { w: 0, h: 0 })]))).not.toThrow()
135
+ expect(checkLayout(probe([img({ w: 300, h: 150 }, { w: 0, h: 0 })])).defects).toEqual([])
136
+ })
137
+
138
+ test("#given an img with a zero rendered height #when checked #then it is skipped", () => {
139
+ expect(checkLayout(probe([img({ w: 300, h: 0 }, { w: 300, h: 160 })])).defects).toEqual([])
140
+ })
141
+ })
142
+
143
+ describe("checkLayout L5 sibling overlap", () => {
144
+ const first = box({ selector: `${PARENT} > p:nth-of-type(1)`, rect: { x: 0, y: 0, w: 100, h: 100 } })
145
+
146
+ test("#given two static siblings intersecting 13x13 #when checked #then layout_sibling_overlap names both selectors", () => {
147
+ const second = box({ selector: `${PARENT} > p:nth-of-type(2)`, rect: { x: 87, y: 87, w: 100, h: 100 }, position: "relative" })
148
+ const result = checkLayout(probe([first, second]))
149
+ expect(codes(result)).toEqual(["layout_sibling_overlap"])
150
+ expect(result.defects[0].severity).toBe(DEFECT_CODES.layout_sibling_overlap.severity)
151
+ expect(result.defects[0].message).toContain(first.selector)
152
+ expect(result.defects[0].message).toContain(second.selector)
153
+ })
154
+
155
+ test("#given two siblings intersecting 12x40 #when checked #then no defect is raised", () => {
156
+ const second = box({ selector: `${PARENT} > p:nth-of-type(2)`, rect: { x: 88, y: 60, w: 100, h: 100 } })
157
+ expect(checkLayout(probe([first, second])).defects).toEqual([])
158
+ })
159
+
160
+ test("#given a 13x13 intersection where one box is absolute #when checked #then it is exempt", () => {
161
+ const badge = box({ selector: `${PARENT} > p:nth-of-type(2)`, rect: { x: 87, y: 87, w: 100, h: 100 }, position: "absolute" })
162
+ expect(checkLayout(probe([first, badge])).defects).toEqual([])
163
+ })
164
+
165
+ test("#given a 13x13 intersection between boxes of different parents #when checked #then it is not a sibling overlap", () => {
166
+ const cousin = box({ selector: "body > main > div > p", parentSelector: "body > main > div", rect: { x: 87, y: 87, w: 100, h: 100 }, parentRect: { x: 0, y: 0, w: 600, h: 400 } })
167
+ expect(checkLayout(probe([first, cousin])).defects).toEqual([])
168
+ })
169
+ })
170
+
171
+ describe("checkLayout probe validation and summary", () => {
172
+ test("#given probe_version 2 #when checked #then a typed ProbeError is thrown", () => {
173
+ expect(() => checkLayout(probe([], { probe_version: 2 }))).toThrow(ProbeError)
174
+ try {
175
+ checkLayout(probe([], { probe_version: 2 }))
176
+ } catch (error) {
177
+ expect((error as ProbeError).code).toBe("unsupported_probe_version")
178
+ }
179
+ })
180
+
181
+ test("#given a probe without a boxes array #when checked #then a typed ProbeError is thrown", () => {
182
+ const malformed = JSON.parse('{"probe_version": 1}')
183
+ expect(() => checkLayout(malformed)).toThrow(ProbeError)
184
+ })
185
+
186
+ test("#given a truncated probe #when checked #then the summary ends with the truncation note", () => {
187
+ const result = checkLayout(probe([box()], { count: 500, truncated: true }))
188
+ expect(result.summary.endsWith("layout sample truncated")).toBe(true)
189
+ })
190
+
191
+ test("#given a complete probe #when checked #then the summary carries no truncation note", () => {
192
+ expect(checkLayout(probe([box()])).summary).not.toContain("truncated")
193
+ })
194
+
195
+ test("#given every defect raised across the rules #when inspected #then each code is a LAYOUT_CODES entry", () => {
196
+ const boxes = [
197
+ box({ rect: { x: 590, y: 10, w: 20, h: 20 } }),
198
+ box({ selector: `${PARENT} > h2`, scroll: { sw: 130, cw: 100, sh: 20, ch: 20 }, overflow: { x: "hidden", y: "hidden" } }),
199
+ img({ w: 300, h: 150 }, { w: 300, h: 300 }),
200
+ ]
201
+ const raised = codes(checkLayout(probe(boxes)))
202
+ expect(raised.length).toBeGreaterThan(0)
203
+ expect(raised.every((code: string) => LAYOUT_CODES.includes(code))).toBe(true)
204
+ })
205
+ })
206
+
207
+ describe("checkLayout on the fixture sample", () => {
208
+ test("#given the boxes.json sample with the two measured real-sample images #when checked #then only the pre scroll container is reported", () => {
209
+ // given
210
+ const sample = JSON.parse(readFileSync(FIXTURE_PATH, "utf8"))
211
+ // when
212
+ const result = checkLayout(sample)
213
+ // then
214
+ expect(result.defects.map((defect) => [defect.code, defect.selector])).toEqual([["layout_scroll_container", "#method > pre"]])
215
+ expect(result.summary).not.toContain("truncated")
216
+ })
217
+ })
218
+
219
+ describe("checkLayout L1 inside a scrolling or clipping parent", () => {
220
+ const wideTable = (parentOverflow?: { x: string; y: string }) =>
221
+ box({ selector: `${PARENT} > div > table`, tag: "table", rect: { x: 0, y: 10, w: 816, h: 200 }, parentSelector: `${PARENT} > div`, ...(parentOverflow ? { parentOverflow } : {}) })
222
+
223
+ test("#given a table spilling 216px inside a parent with overflow-x auto #when checked #then it is the advisory scroll container, not an overflow", () => {
224
+ const result = checkLayout(probe([wideTable({ x: "auto", y: "visible" })]))
225
+ expect(codes(result)).toEqual(["layout_scroll_container"])
226
+ expect(result.defects[0]?.severity).toBe(DEFECT_CODES.layout_scroll_container.severity)
227
+ })
228
+
229
+ test("#given the same spill inside a parent with overflow-x hidden #when checked #then the content is reported clipped", () => {
230
+ expect(codes(checkLayout(probe([wideTable({ x: "hidden", y: "visible" })])))).toEqual(["layout_text_clipped"])
231
+ })
232
+
233
+ test("#given the same spill with no parentOverflow recorded #when checked #then it stays a layout_overflow", () => {
234
+ expect(codes(checkLayout(probe([wideTable()])))).toEqual(["layout_overflow"])
235
+ })
236
+ })
@@ -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
+ }