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.
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.ru.md +1 -1
- package/README.zh-cn.md +1 -1
- package/dist/agents/sisyphus/grok-4.d.ts +3 -2
- package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
- package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
- package/dist/cli/index.js +1661 -859
- package/dist/cli/install-native/index.d.ts +9 -1
- package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
- package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
- package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
- package/dist/cli/install-native/plan.d.ts +8 -0
- package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
- package/dist/cli/install-native/run-native-install.d.ts +11 -1
- package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
- package/dist/cli/native-edition-hint.d.ts +2 -2
- package/dist/cli-node/index.js +1661 -859
- package/dist/hooks/auto-update-checker/checker/sandbox-refresh.d.ts +51 -0
- package/dist/hooks/auto-update-checker/checker/semver-compare.d.ts +17 -0
- package/dist/hooks/auto-update-checker/checker.d.ts +3 -0
- package/dist/hooks/auto-update-checker/hook/background-update-check.d.ts +8 -0
- package/dist/hooks/auto-update-checker/hook.d.ts +2 -0
- package/dist/hooks/native-edition-nudge/hook.d.ts +1 -1
- package/dist/index.js +1433 -1055
- package/dist/shared/index.d.ts +1 -0
- package/dist/shared/opencode-plugin-sandbox.d.ts +54 -0
- package/dist/skills/browser/SKILL.md +8 -1
- package/dist/skills/browser/references/install.md +34 -5
- package/dist/skills/browser/runtime/omowright/index.js +2 -2
- package/dist/skills/browser/runtime/omowright/manifest.json +3 -3
- package/dist/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/dist/skills/browser/scripts/browser-install.mjs +13 -1
- package/dist/skills/ulw-research/AGENTS.md +63 -0
- package/dist/skills/ulw-research/SKILL.md +38 -26
- package/dist/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/dist/skills/ulw-research/references/report-gates.md +66 -0
- package/dist/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/dist/skills/ulw-research/scripts/cli-support.test.ts +88 -0
- package/dist/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/dist/skills/ulw-research/scripts/contracts.test.ts +177 -0
- package/dist/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/dist/skills/ulw-research/scripts/css-lite.test.ts +159 -0
- package/dist/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/dist/skills/ulw-research/scripts/design-spec.test.ts +162 -0
- package/dist/skills/ulw-research/scripts/entities.mjs +39 -0
- package/dist/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/dist/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/dist/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/dist/skills/ulw-research/scripts/format-extract.test.ts +243 -0
- package/dist/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/dist/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/dist/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
- package/dist/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/dist/skills/ulw-research/scripts/gates-static.test.ts +265 -0
- package/dist/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/dist/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/dist/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
- package/dist/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/dist/skills/ulw-research/scripts/html-lite.test.ts +191 -0
- package/dist/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/dist/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
- package/dist/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/dist/skills/ulw-research/scripts/outcome.test.ts +281 -0
- package/dist/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/dist/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
- package/dist/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/dist/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/dist/skills/ulw-research/scripts/report-tools.test.ts +213 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
- package/dist/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
- package/dist/skills/visual-qa/references/browser-setup.md +3 -1
- package/dist/tui.js +534 -66
- package/package.json +15 -15
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
- package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
- package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
- package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
- package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
- package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
- package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
- package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
- package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
- package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
- package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
- package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
- package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
- package/packages/shared-skills/skills/browser/SKILL.md +8 -1
- package/packages/shared-skills/skills/browser/references/install.md +34 -5
- package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
- package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
- package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
- package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
- package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
- package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
- package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
- package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
- package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
- package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
- package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
- package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
- package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
- package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
- package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
- package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
- package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
- package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
- package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
- package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
- package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
- package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
- package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
- package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
- package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
- package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
- package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
- 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
|
+
}
|