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,173 @@
|
|
|
1
|
+
// gates-figures.mjs - visual gates: G2 palette, G8 chart without figure, G9 chart svg text,
|
|
2
|
+
// G10 Korean serif/brush fonts.
|
|
3
|
+
//
|
|
4
|
+
// Each gate is { codes, skip?(ctx), run(ctx) -> Defect[] } over the context built by
|
|
5
|
+
// gates-static.mjs. Plain Node ESM, sibling imports only.
|
|
6
|
+
|
|
7
|
+
import { fontFamilies, hexColors, normalizeHex, resolveVar } from "./css-lite.mjs"
|
|
8
|
+
import { decodeEntities, proseText } from "./html-lite.mjs"
|
|
9
|
+
|
|
10
|
+
const ALWAYS_ALLOWED = new Set(["#ffffff", "#000000"])
|
|
11
|
+
const MAX_PALETTE = 20
|
|
12
|
+
const SVG_PAINT = /(?:fill|stroke|stop-color|flood-color|lighting-color)\s*(?:=\s*["']?|:\s*)(#[0-9a-f]{3,8})(?![\w-])/gi
|
|
13
|
+
const SVG_FONT = /font-family\s*(?:=\s*("[^"]*"|'[^']*')|:\s*([^;"'>]+))/gi
|
|
14
|
+
const ICON_MAX = 32
|
|
15
|
+
const CHART_MIN = 200
|
|
16
|
+
const ICON_NAME = /icon|logo/i
|
|
17
|
+
const ICON_HOSTS = new Set(["a", "button", "nav", "header", "footer", "h1", "h2", "h3", "h4", "h5", "h6"])
|
|
18
|
+
const CAPTION_CLASS = /^(?:caption|fig-?cap(?:tion)?)$/
|
|
19
|
+
const DEFAULT_FONT_SIZE = 16
|
|
20
|
+
const MIN_TITLE_CHARS = 4
|
|
21
|
+
const MIN_TICKS = 3
|
|
22
|
+
const NUMERIC = /^[-+\u2212]?[$\u20AC\u00A3\u00A5\u20A9]?\d[\d,.:]*\s?(?:%|[a-z\uAC00-\uD7A3]{0,3})$/i
|
|
23
|
+
const SERIF_FAMILY = /nanummyeongjo|applemyungjo|gungsuh|batang|nanumbrush|nanumpen|\uAD81\uC11C|\uBC14\uD0D5|\uBA85\uC870/
|
|
24
|
+
|
|
25
|
+
function pixels(value) {
|
|
26
|
+
const match = /^\s*(\d+(?:\.\d+)?)\s*(?:px)?\s*$/i.exec(value ?? "")
|
|
27
|
+
return match ? Number(match[1]) : null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Largest declared dimension: width/height attributes or style, else the viewBox. */
|
|
31
|
+
function declaredSize(node) {
|
|
32
|
+
const style = node.attrs.style ?? ""
|
|
33
|
+
const fromStyle = (prop) => pixels(new RegExp(`(?:^|;)\\s*${prop}\\s*:\\s*([^;]+)`, "i").exec(style)?.[1])
|
|
34
|
+
const sizes = [pixels(node.attrs.width), pixels(node.attrs.height), fromStyle("width"), fromStyle("height")].filter((n) => n !== null)
|
|
35
|
+
if (sizes.length) return Math.max(...sizes)
|
|
36
|
+
const box = String(node.attrs.viewbox ?? "").trim().split(/[\s,]+/).map(Number)
|
|
37
|
+
return box.length === 4 && box.every(Number.isFinite) ? Math.max(box[2], box[3]) : null
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isIcon(ctx, node) {
|
|
41
|
+
const size = declaredSize(node)
|
|
42
|
+
if (size !== null && size <= ICON_MAX) return true
|
|
43
|
+
if (ICON_NAME.test(`${node.attrs.class ?? ""} ${node.attrs.id ?? ""}`)) return true
|
|
44
|
+
return Boolean(ctx.util.closest(node, (cur) => ICON_HOSTS.has(cur.tag)))
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const isFigure = (ctx) => (cur) => cur.tag === "figure" || ctx.util.classTokens(cur).includes("fig")
|
|
48
|
+
|
|
49
|
+
function hasCaption(ctx, figure) {
|
|
50
|
+
return ctx.util.descendants(figure).some((node) => (node.tag === "figcaption" || ctx.util.classTokens(node).some((token) => CAPTION_CLASS.test(token))) && proseText(node).length > 0)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function visuals(ctx) {
|
|
54
|
+
return ctx.elements.map(({ node }) => node).filter((node) => (node.tag === "img" || node.tag === "svg") && !isIcon(ctx, node))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function label(node) {
|
|
58
|
+
if (node.tag === "img") {
|
|
59
|
+
const src = node.attrs.src ?? ""
|
|
60
|
+
return `<img src="${src.length > 48 ? `${src.slice(0, 48)}...` : src}">`
|
|
61
|
+
}
|
|
62
|
+
return `<svg${node.attrs.width ? ` width="${node.attrs.width}"` : ""}${node.attrs.height ? ` height="${node.attrs.height}"` : ""}>`
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function svgTexts(raw) {
|
|
66
|
+
return [...raw.matchAll(/<text\b([^>]*)>([\s\S]*?)<\/text\s*>/gi)]
|
|
67
|
+
.map((match) => ({
|
|
68
|
+
size: Number(/font-size\s*(?:=\s*["']?|:\s*)(\d+(?:\.\d+)?)/i.exec(match[1])?.[1] ?? DEFAULT_FONT_SIZE),
|
|
69
|
+
value: decodeEntities(match[2].replace(/<[^>]*>/g, "")).replace(/\s+/g, " ").trim(),
|
|
70
|
+
}))
|
|
71
|
+
.filter((text) => text.value)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function missingChartLabels(raw) {
|
|
75
|
+
const texts = svgTexts(raw)
|
|
76
|
+
const title = decodeEntities(/<title\b[^>]*>([\s\S]*?)<\/title\s*>/i.exec(raw)?.[1] ?? "").trim()
|
|
77
|
+
if (!texts.length && !title) return null
|
|
78
|
+
const maxSize = Math.max(0, ...texts.map((text) => text.size))
|
|
79
|
+
const missing = []
|
|
80
|
+
if (!title && !texts.some((text) => text.size === maxSize && [...text.value].length >= MIN_TITLE_CHARS)) missing.push("title")
|
|
81
|
+
const ticks = texts.filter((text) => NUMERIC.test(text.value)).length
|
|
82
|
+
if (ticks < MIN_TICKS) missing.push(`numeric ticks (${ticks} < ${MIN_TICKS})`)
|
|
83
|
+
if (!texts.some((text) => /\p{L}/u.test(text.value) && !NUMERIC.test(text.value))) missing.push("text label")
|
|
84
|
+
return missing
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function colorUses(ctx) {
|
|
88
|
+
const uses = new Map()
|
|
89
|
+
const add = (hex, node) => {
|
|
90
|
+
const color = normalizeHex(hex)
|
|
91
|
+
if (!color) return
|
|
92
|
+
const entry = uses.get(color) ?? { node, count: 0 }
|
|
93
|
+
entry.count++
|
|
94
|
+
uses.set(color, entry)
|
|
95
|
+
}
|
|
96
|
+
for (const { node, css } of [...ctx.styleBlocks, ...ctx.styleAttrs]) for (const hex of hexColors(css)) add(hex, node)
|
|
97
|
+
for (const node of ctx.all) {
|
|
98
|
+
if (node.tag !== "svg") continue
|
|
99
|
+
for (const key of ["fill", "stroke", "stop-color"]) if (node.attrs[key]) add(node.attrs[key], node)
|
|
100
|
+
for (const match of (node.raw ?? "").matchAll(SVG_PAINT)) add(match[1], node)
|
|
101
|
+
}
|
|
102
|
+
return uses
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function fontDeclarations(ctx) {
|
|
106
|
+
const found = []
|
|
107
|
+
for (const { node, css } of ctx.styleBlocks) for (const { families } of fontFamilies(css)) found.push({ node, families })
|
|
108
|
+
for (const { node, css } of ctx.styleAttrs) for (const { families } of fontFamilies(`x{${resolveVar(css, ctx.tokens)}}`)) found.push({ node, families })
|
|
109
|
+
for (const node of ctx.all) {
|
|
110
|
+
if (node.tag !== "svg") continue
|
|
111
|
+
const values = [node.attrs["font-family"], ...[...(node.raw ?? "").matchAll(SVG_FONT)].map((m) => m[1] ?? m[2])].filter(Boolean)
|
|
112
|
+
for (const value of values) for (const { families } of fontFamilies(`x{font-family:${value}}`)) found.push({ node, families })
|
|
113
|
+
}
|
|
114
|
+
return found
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const figureGates = [
|
|
118
|
+
{
|
|
119
|
+
codes: ["palette_off_token"],
|
|
120
|
+
skip: (ctx) => ctx.palette.size === 0,
|
|
121
|
+
run(ctx) {
|
|
122
|
+
const palette = new Set([...ctx.palette].map(normalizeHex))
|
|
123
|
+
const defects = []
|
|
124
|
+
for (const [color, { node, count }] of colorUses(ctx)) {
|
|
125
|
+
if (defects.length >= MAX_PALETTE) break
|
|
126
|
+
if (palette.has(color) || ALWAYS_ALLOWED.has(color)) continue
|
|
127
|
+
defects.push(ctx.defect("palette_off_token", node, `color ${color} is not in the design-spec palette (${count} use${count === 1 ? "" : "s"})`))
|
|
128
|
+
}
|
|
129
|
+
return defects
|
|
130
|
+
},
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
codes: ["chart_without_figure"],
|
|
134
|
+
run(ctx) {
|
|
135
|
+
return visuals(ctx)
|
|
136
|
+
.filter((node) => {
|
|
137
|
+
const figure = ctx.util.closest(node, isFigure(ctx))
|
|
138
|
+
return !figure || !hasCaption(ctx, figure)
|
|
139
|
+
})
|
|
140
|
+
.map((node) => ctx.defect("chart_without_figure", node, `${label(node)} has no figure container with a non-empty caption`))
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
codes: ["chart_svg_no_text", "chart_svg_missing_labels"],
|
|
145
|
+
run(ctx) {
|
|
146
|
+
const defects = []
|
|
147
|
+
for (const node of visuals(ctx)) {
|
|
148
|
+
if (node.tag !== "svg") continue
|
|
149
|
+
if (!((declaredSize(node) ?? 0) >= CHART_MIN || ctx.util.closest(node, isFigure(ctx)))) continue
|
|
150
|
+
const missing = missingChartLabels(node.raw ?? "")
|
|
151
|
+
if (missing === null) defects.push(ctx.defect("chart_svg_no_text", node, `${label(node)} chart has no <text> and no <title>`))
|
|
152
|
+
else if (missing.length) defects.push(ctx.defect("chart_svg_missing_labels", node, `${label(node)} chart is missing: ${missing.join(", ")}`))
|
|
153
|
+
}
|
|
154
|
+
return defects
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
codes: ["korean_serif_font"],
|
|
159
|
+
run(ctx) {
|
|
160
|
+
const defects = []
|
|
161
|
+
const seen = new Set()
|
|
162
|
+
for (const { node, families } of fontDeclarations(ctx)) {
|
|
163
|
+
for (const family of families) {
|
|
164
|
+
const key = family.toLowerCase().replace(/[\s"'_-]/g, "")
|
|
165
|
+
if (!SERIF_FAMILY.test(key) || seen.has(key)) continue
|
|
166
|
+
seen.add(key)
|
|
167
|
+
defects.push(ctx.defect("korean_serif_font", node, `serif or brush Korean font family "${family}"`))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return defects
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
]
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
// gates-layout.mjs - L1-L5 geometric gates over the layout probe output (boxes.json).
|
|
2
|
+
//
|
|
3
|
+
// Pure functions over the object layout-probe.mjs returns: no browser, no DOM, and selectors are
|
|
4
|
+
// taken verbatim from the probe (the probe already built them with the html-lite rule). Severities
|
|
5
|
+
// and hints are read from contracts.DEFECT_CODES, never set here. Measured pixel amounts are
|
|
6
|
+
// rounded to 0.1px (the probe's own precision) before a threshold compare, so float noise such as
|
|
7
|
+
// 2.000000000000001 never crosses a 2px limit.
|
|
8
|
+
|
|
9
|
+
import { DEFECT_CODES } from "./contracts.mjs"
|
|
10
|
+
import { PROBE_VERSION, ProbeError } from "./layout-probe.mjs"
|
|
11
|
+
|
|
12
|
+
/** Thresholds (draft D10): rounding tolerances, not taste. */
|
|
13
|
+
export const OVERFLOW_PX = 2
|
|
14
|
+
export const SCROLL_SLACK_PX = 2
|
|
15
|
+
export const ASPECT_DRIFT = 0.03
|
|
16
|
+
export const OVERLAP_PX = 12
|
|
17
|
+
|
|
18
|
+
const OUT_OF_FLOW = new Set(["absolute", "fixed"])
|
|
19
|
+
const IN_FLOW = new Set(["static", "relative"])
|
|
20
|
+
const CLIPPING = new Set(["hidden", "clip"])
|
|
21
|
+
const SCROLLING = new Set(["auto", "scroll"])
|
|
22
|
+
const ASPECT_SAFE_FITS = new Set(["cover", "contain", "scale-down"])
|
|
23
|
+
|
|
24
|
+
const r1 = (n) => Math.round(n * 10) / 10
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {{ code: string, severity: string, selector: string, line: null, message: string, hint: string }} Defect
|
|
28
|
+
*/
|
|
29
|
+
function defect(code, selector, message) {
|
|
30
|
+
const { severity, hint } = DEFECT_CODES[code]
|
|
31
|
+
return { code, severity, selector, line: null, message, hint }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// L1: the largest spill of the child rect past any side of its parent rect. A parent that scrolls
|
|
35
|
+
// on the spill axis makes it intentional scrolling (the advisory scroll container); a parent that
|
|
36
|
+
// clips on that axis hides the spilled content (text clipped).
|
|
37
|
+
function checkOverflow(box) {
|
|
38
|
+
if (!box.parentRect || OUT_OF_FLOW.has(box.position)) return null
|
|
39
|
+
const c = box.rect
|
|
40
|
+
const p = box.parentRect
|
|
41
|
+
const sides = [
|
|
42
|
+
["left", p.x - c.x],
|
|
43
|
+
["top", p.y - c.y],
|
|
44
|
+
["right", c.x + c.w - (p.x + p.w)],
|
|
45
|
+
["bottom", c.y + c.h - (p.y + p.h)],
|
|
46
|
+
].map(([side, px]) => [side, r1(px)])
|
|
47
|
+
const [side, px] = sides.reduce((worst, entry) => (entry[1] > worst[1] ? entry : worst))
|
|
48
|
+
if (px <= OVERFLOW_PX) return null
|
|
49
|
+
const parentMode = box.parentOverflow?.[side === "left" || side === "right" ? "x" : "y"]
|
|
50
|
+
if (SCROLLING.has(parentMode)) {
|
|
51
|
+
return defect("layout_scroll_container", box.selector, `${box.tag} is ${px}px wider than ${box.parentSelector}, which scrolls (overflow: ${parentMode})`)
|
|
52
|
+
}
|
|
53
|
+
if (CLIPPING.has(parentMode)) {
|
|
54
|
+
return defect("layout_text_clipped", box.selector, `${box.tag} spills ${px}px past the ${side} edge of ${box.parentSelector}, which clips it (overflow: ${parentMode})`)
|
|
55
|
+
}
|
|
56
|
+
return defect("layout_overflow", box.selector, `${box.tag} spills ${px}px past the ${side} edge of ${box.parentSelector}`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// L2/L3: content larger than the box on an axis whose computed overflow clips or scrolls.
|
|
60
|
+
function checkScroll(box) {
|
|
61
|
+
const { sw, cw, sh, ch } = box.scroll
|
|
62
|
+
const axes = [
|
|
63
|
+
["x", sw - cw, box.overflow.x],
|
|
64
|
+
["y", sh - ch, box.overflow.y],
|
|
65
|
+
].filter(([, excess]) => r1(excess) > SCROLL_SLACK_PX)
|
|
66
|
+
const clipped = axes.find(([, , mode]) => CLIPPING.has(mode))
|
|
67
|
+
if (clipped) {
|
|
68
|
+
const [axis, excess, mode] = clipped
|
|
69
|
+
return defect("layout_text_clipped", box.selector, `${box.tag} content exceeds its box by ${r1(excess)}px on ${axis} under overflow: ${mode}`)
|
|
70
|
+
}
|
|
71
|
+
const scrolled = axes.find(([, , mode]) => SCROLLING.has(mode))
|
|
72
|
+
if (scrolled) {
|
|
73
|
+
const [axis, excess, mode] = scrolled
|
|
74
|
+
return defect("layout_scroll_container", box.selector, `${box.tag} scrolls ${r1(excess)}px on ${axis} under overflow: ${mode}`)
|
|
75
|
+
}
|
|
76
|
+
return null
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// L4: rendered aspect ratio drifting from the natural one without an aspect-preserving fit.
|
|
80
|
+
function checkAspect(box) {
|
|
81
|
+
if (box.tag !== "img" || !box.natural) return null
|
|
82
|
+
const { w, h } = box.rect
|
|
83
|
+
const { w: nw, h: nh } = box.natural
|
|
84
|
+
if (!(w > 0 && h > 0 && nw > 0 && nh > 0)) return null
|
|
85
|
+
const fit = box.objectFit ?? "fill"
|
|
86
|
+
if (ASPECT_SAFE_FITS.has(fit)) return null
|
|
87
|
+
const drift = Math.abs(w / h / (nw / nh) - 1)
|
|
88
|
+
if (drift <= ASPECT_DRIFT) return null
|
|
89
|
+
const percent = (drift * 100).toFixed(1)
|
|
90
|
+
return defect("layout_image_distorted", box.selector, `img rendered ${w}x${h} from natural ${nw}x${nh} distorts the aspect ratio by ${percent}% (object-fit: ${fit})`)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function intersection(a, b) {
|
|
94
|
+
const ix = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
|
|
95
|
+
const iy = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
|
|
96
|
+
return { ix: r1(ix), iy: r1(iy) }
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// L5: in-flow siblings (same parentSelector) whose rects intersect by more than 12px on both axes.
|
|
100
|
+
function checkSiblings(boxes) {
|
|
101
|
+
const groups = new Map()
|
|
102
|
+
for (const box of boxes) {
|
|
103
|
+
if (box.parentSelector === null || box.parentSelector === undefined || !IN_FLOW.has(box.position)) continue
|
|
104
|
+
const group = groups.get(box.parentSelector) ?? []
|
|
105
|
+
group.push(box)
|
|
106
|
+
groups.set(box.parentSelector, group)
|
|
107
|
+
}
|
|
108
|
+
const defects = []
|
|
109
|
+
for (const group of groups.values()) {
|
|
110
|
+
for (let i = 0; i < group.length; i += 1) {
|
|
111
|
+
for (let j = i + 1; j < group.length; j += 1) {
|
|
112
|
+
const { ix, iy } = intersection(group[i].rect, group[j].rect)
|
|
113
|
+
if (Math.min(ix, iy) <= OVERLAP_PX) continue
|
|
114
|
+
defects.push(defect("layout_sibling_overlap", group[i].selector, `${group[i].selector} and ${group[j].selector} overlap by ${ix}x${iy}px`))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return defects
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function validateProbe(probe) {
|
|
122
|
+
if (probe === null || typeof probe !== "object") throw new ProbeError("invalid_probe", "layout probe must be an object")
|
|
123
|
+
if (probe.probe_version !== PROBE_VERSION) {
|
|
124
|
+
throw new ProbeError("unsupported_probe_version", `layout probe_version must be ${PROBE_VERSION}, got ${probe.probe_version}`)
|
|
125
|
+
}
|
|
126
|
+
if (!Array.isArray(probe.boxes)) throw new ProbeError("invalid_probe", "layout probe boxes must be an array")
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* @param {{ probe_version: number, count?: number, truncated?: boolean, boxes: object[] }} probe
|
|
131
|
+
* @returns {{ defects: Defect[], summary: string }}
|
|
132
|
+
*/
|
|
133
|
+
export function checkLayout(probe) {
|
|
134
|
+
validateProbe(probe)
|
|
135
|
+
const defects = []
|
|
136
|
+
for (const box of probe.boxes) {
|
|
137
|
+
for (const rule of [checkOverflow, checkScroll, checkAspect]) {
|
|
138
|
+
const found = rule(box)
|
|
139
|
+
if (found) defects.push(found)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
defects.push(...checkSiblings(probe.boxes))
|
|
143
|
+
let summary = `layout: ${probe.boxes.length} boxes checked, ${defects.length} defects`
|
|
144
|
+
if (probe.truncated) summary += `; layout sample truncated`
|
|
145
|
+
return { defects, summary }
|
|
146
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// gates-static.mjs - deterministic static gates (G1-G15) over a parsed report.
|
|
2
|
+
//
|
|
3
|
+
// Builds one document context (a document-order walk with h2 sections, citation helpers and a
|
|
4
|
+
// defect factory) and hands it to the text, figure and structure gate modules. Every severity
|
|
5
|
+
// comes from contracts.DEFECT_CODES; the single override is unsourced_number under
|
|
6
|
+
// `Lineage: section`, derived from the spec mode passed in, never from a caller-supplied
|
|
7
|
+
// severity. Plain Node ESM, node: builtins and siblings only.
|
|
8
|
+
|
|
9
|
+
import * as nodeFs from "node:fs"
|
|
10
|
+
|
|
11
|
+
import { DEFECT_CODES, LINEAGE_MODES } from "./contracts.mjs"
|
|
12
|
+
import { customProperties } from "./css-lite.mjs"
|
|
13
|
+
import { figureGates } from "./gates-figures.mjs"
|
|
14
|
+
import { structureGates } from "./gates-structure.mjs"
|
|
15
|
+
import { textGates } from "./gates-text.mjs"
|
|
16
|
+
import { PROSE_TAGS, proseText, selectorPath } from "./html-lite.mjs"
|
|
17
|
+
|
|
18
|
+
const CITATION_MARKER = /\[S\d+\]|\[Source\s*\d+\]|\[\uCD9C\uCC98[^\]]*\]|\[\^\d+\]/i
|
|
19
|
+
const ANCHOR_HREF = /^#(?:s|src|source|ref|fn)/i
|
|
20
|
+
const ANCHOR_TEXT = /^\[(?:\uCD9C\uCC98|Source\s*\d+|S\d+)\]$/i
|
|
21
|
+
/** Closing ("how this report was made") heading; Korean alternates cover the same register. */
|
|
22
|
+
export const CLOSING_HEADING = /\b(?:method(?:ology)?|how)\b|\uB4B7\uBA74|\uBC29\uBC95|\uC5B4\uB5BB\uAC8C|\uB9CC\uB4E4\uC5B4/i
|
|
23
|
+
export const SOURCES_HEADING = /references|sources|bibliography|\uCD9C\uCC98|\uCC38\uACE0\s*(?:\uBB38\uD5CC|\uC790\uB8CC)/i
|
|
24
|
+
const OTHER_KINDS = [
|
|
25
|
+
["toc", /\b(?:contents|toc)\b|\uBAA9\uCC28/i],
|
|
26
|
+
["appendix", /appendix|\uBD80\uB85D/i],
|
|
27
|
+
["glossary", /glossary|\uC6A9\uC5B4/i],
|
|
28
|
+
]
|
|
29
|
+
const TEXT_SKIP = new Set(["code", "pre", "kbd", "samp", "script", "style", "svg", "template", "noscript"])
|
|
30
|
+
const HEADING_TAG = /^h[1-6]$/
|
|
31
|
+
/** The one sanctioned severity override: section-level lineage demotes unsourced_number. */
|
|
32
|
+
const SECTION_LINEAGE_SEVERITY = "minor"
|
|
33
|
+
|
|
34
|
+
const GATES = [...textGates, ...figureGates, ...structureGates]
|
|
35
|
+
|
|
36
|
+
function classTokens(node) {
|
|
37
|
+
return String(node.attrs.class ?? "").toLowerCase().split(/\s+/).filter(Boolean)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function closest(node, predicate) {
|
|
41
|
+
for (let cur = node.parent; cur; cur = cur.parent) if (predicate(cur)) return cur
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function descendants(node, out = []) {
|
|
46
|
+
for (const child of node.children) {
|
|
47
|
+
if (child.tag === "#text") continue
|
|
48
|
+
out.push(child)
|
|
49
|
+
descendants(child, out)
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isCitationAnchor(node) {
|
|
55
|
+
return node.tag === "a" && (ANCHOR_HREF.test(node.attrs.href ?? "") || ANCHOR_TEXT.test(proseText(node)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A citation marker in the element's prose or a citation anchor below it. */
|
|
59
|
+
function hasCitation(node) {
|
|
60
|
+
return CITATION_MARKER.test(proseText(node)) || descendants(node).some(isCitationAnchor)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toRegExp(pattern) {
|
|
64
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(String(pattern), "i")
|
|
65
|
+
return new RegExp(re.source, re.flags.replace(/[gy]/g, ""))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findBody(doc) {
|
|
69
|
+
const stack = [doc]
|
|
70
|
+
while (stack.length) {
|
|
71
|
+
const node = stack.pop()
|
|
72
|
+
if (node.tag === "body") return node
|
|
73
|
+
for (const child of node.children) if (child.tag !== "#text") stack.push(child)
|
|
74
|
+
}
|
|
75
|
+
return doc
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sectionKind(text, closingTest) {
|
|
79
|
+
if (SOURCES_HEADING.test(text)) return "sources"
|
|
80
|
+
if (closingTest(text)) return "closing"
|
|
81
|
+
return OTHER_KINDS.find(([, re]) => re.test(text))?.[0] ?? null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Document-order walk of body: element scopes, prose text entries with owners, h2 sections. */
|
|
85
|
+
function walkBody(ctx, node, state) {
|
|
86
|
+
for (const child of node.children) {
|
|
87
|
+
if (child.tag === "#text") {
|
|
88
|
+
if (state.skip) continue
|
|
89
|
+
ctx.texts.push({ node: child, owner: state.owner ?? node, cell: state.cell, scope: ctx.scope })
|
|
90
|
+
if (ctx.scope.section && !state.heading) ctx.scope.section.prose.push(child.text)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (child.tag === "h2") {
|
|
94
|
+
const section = { heading: child, kind: sectionKind(proseText(child), ctx.isClosing), prose: [], anchors: 0 }
|
|
95
|
+
ctx.sections.push(section)
|
|
96
|
+
ctx.scope = { section, closingH3: false }
|
|
97
|
+
} else if (child.tag === "h3") {
|
|
98
|
+
ctx.scope = { section: ctx.scope.section, closingH3: ctx.isClosing(proseText(child)) }
|
|
99
|
+
}
|
|
100
|
+
ctx.elements.push({ node: child, scope: ctx.scope })
|
|
101
|
+
if (ctx.scope.section && isCitationAnchor(child)) ctx.scope.section.anchors++
|
|
102
|
+
walkBody(ctx, child, {
|
|
103
|
+
skip: state.skip || TEXT_SKIP.has(child.tag),
|
|
104
|
+
owner: PROSE_TAGS.has(child.tag) ? child : state.owner,
|
|
105
|
+
cell: state.cell || child.tag === "td" || child.tag === "th",
|
|
106
|
+
heading: state.heading || HEADING_TAG.test(child.tag),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildContext(doc, options) {
|
|
112
|
+
const lineageMode = options.lineageMode ?? "inline"
|
|
113
|
+
if (!LINEAGE_MODES.includes(lineageMode)) throw new TypeError(`lineageMode must be one of ${LINEAGE_MODES.join(", ")}`)
|
|
114
|
+
const closingPattern = options.closingPattern ? toRegExp(options.closingPattern) : null
|
|
115
|
+
const all = descendants(doc)
|
|
116
|
+
const styleBlocks = all.filter((node) => node.tag === "style").map((node) => ({ node, css: node.raw ?? "" }))
|
|
117
|
+
const styleAttrs = all.filter((node) => node.attrs.style).map((node) => ({ node, css: node.attrs.style }))
|
|
118
|
+
const ctx = {
|
|
119
|
+
doc,
|
|
120
|
+
body: findBody(doc),
|
|
121
|
+
all,
|
|
122
|
+
elements: [],
|
|
123
|
+
texts: [],
|
|
124
|
+
sections: [],
|
|
125
|
+
scope: { section: null, closingH3: false },
|
|
126
|
+
styleBlocks,
|
|
127
|
+
styleAttrs,
|
|
128
|
+
tokens: customProperties(styleBlocks.map((entry) => entry.css).join("\n")).light,
|
|
129
|
+
palette: new Set(options.palette ?? []),
|
|
130
|
+
requireSections: (options.requireSections ?? []).map(toRegExp),
|
|
131
|
+
baseDir: options.baseDir ?? null,
|
|
132
|
+
fs: options.fs ?? nodeFs,
|
|
133
|
+
isClosing: (text) => CLOSING_HEADING.test(text) || Boolean(closingPattern?.test(text)),
|
|
134
|
+
util: { classTokens, closest, descendants, hasCitation, citationMarker: CITATION_MARKER },
|
|
135
|
+
kindOf: (scope) => (scope.closingH3 ? "closing" : (scope.section?.kind ?? null)),
|
|
136
|
+
defect(code, node, message) {
|
|
137
|
+
const entry = DEFECT_CODES[code]
|
|
138
|
+
const severity = code === "unsourced_number" && lineageMode === "section" ? SECTION_LINEAGE_SEVERITY : entry.severity
|
|
139
|
+
return { code, severity, selector: selectorPath(node) || node.tag, line: node.line, message, hint: entry.hint }
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
walkBody(ctx, ctx.body, { skip: false, owner: null, cell: false, heading: false })
|
|
143
|
+
return ctx
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run G1-G15 over a parsed report (html-lite root).
|
|
148
|
+
* @param {object} doc parseHtml(...) result
|
|
149
|
+
* @param {{ palette?: Set<string>, lineageMode?: "inline"|"section", requireSections?: (RegExp|string)[],
|
|
150
|
+
* baseDir?: string, fs?: { statSync(path: string): { size: number } }, closingPattern?: RegExp|string }} options
|
|
151
|
+
* @returns {{ defects: object[], gatesRun: string[], gatesSkipped: string[] }}
|
|
152
|
+
*/
|
|
153
|
+
export function checkStatic(doc, options = {}) {
|
|
154
|
+
const ctx = buildContext(doc, options)
|
|
155
|
+
const defects = []
|
|
156
|
+
const gatesRun = []
|
|
157
|
+
const gatesSkipped = []
|
|
158
|
+
for (const gate of GATES) {
|
|
159
|
+
if (gate.skip?.(ctx)) {
|
|
160
|
+
gatesSkipped.push(...gate.codes)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
gatesRun.push(...gate.codes)
|
|
164
|
+
defects.push(...gate.run(ctx))
|
|
165
|
+
}
|
|
166
|
+
return { defects, gatesRun, gatesSkipped }
|
|
167
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// gates-structure.mjs - structure gates: G11 section without citation, G12 closing section,
|
|
2
|
+
// G13 citation section, G14 broken asset reference, G15 required sections.
|
|
3
|
+
//
|
|
4
|
+
// Each gate is { codes, skip?(ctx), run(ctx) -> Defect[] } over the context built by
|
|
5
|
+
// gates-static.mjs. Plain Node ESM, node: builtins and siblings only.
|
|
6
|
+
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
|
|
9
|
+
import { proseText } from "./html-lite.mjs"
|
|
10
|
+
|
|
11
|
+
const MIN_SECTION_PROSE = 120
|
|
12
|
+
const UNCHECKED_KINDS = new Set(["closing", "sources", "toc", "appendix", "glossary"])
|
|
13
|
+
const DEFINITION_TAGS = new Set(["p", "li", "dd", "dt", "td"])
|
|
14
|
+
const DEFINITION_LINE = /^\[(S|Source\s*)(\d+)\]/i
|
|
15
|
+
const MIN_DEFINITIONS = 3
|
|
16
|
+
const NOT_RELATIVE = /^(?:[a-z][a-z\d+.-]*:|\/\/|\/|#)/i
|
|
17
|
+
|
|
18
|
+
function headingNodes(ctx, tags) {
|
|
19
|
+
return ctx.elements.map(({ node }) => node).filter((node) => tags.includes(node.tag))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Local references an asset gate checks: img src, source srcset, stylesheet href, script src. */
|
|
23
|
+
function assetReferences(ctx) {
|
|
24
|
+
const refs = []
|
|
25
|
+
for (const node of ctx.all) {
|
|
26
|
+
const { attrs } = node
|
|
27
|
+
if (node.tag === "img" && attrs.src) refs.push({ node, url: attrs.src })
|
|
28
|
+
else if (node.tag === "script" && attrs.src) refs.push({ node, url: attrs.src })
|
|
29
|
+
else if (node.tag === "link" && attrs.href && /(?:^|\s)stylesheet(?:\s|$)/i.test(attrs.rel ?? "")) refs.push({ node, url: attrs.href })
|
|
30
|
+
else if (node.tag === "source" && attrs.srcset) {
|
|
31
|
+
for (const candidate of attrs.srcset.split(",")) {
|
|
32
|
+
const url = candidate.trim().split(/\s+/)[0]
|
|
33
|
+
if (url) refs.push({ node, url })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return refs.filter(({ url }) => url.trim() && !NOT_RELATIVE.test(url.trim()))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function localPath(baseDir, url) {
|
|
41
|
+
const bare = url.trim().replace(/[?#].*$/, "")
|
|
42
|
+
let decoded = bare
|
|
43
|
+
try {
|
|
44
|
+
decoded = decodeURIComponent(bare)
|
|
45
|
+
} catch {
|
|
46
|
+
// a malformed %-escape is kept verbatim; the stat below reports it as missing
|
|
47
|
+
}
|
|
48
|
+
return join(baseDir, ...decoded.split("/"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assetProblem(fs, file) {
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.statSync(file)
|
|
54
|
+
return stat.size === 0 ? "is 0 bytes" : null
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return `is missing (${error?.code ?? "stat failed"})`
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const structureGates = [
|
|
61
|
+
{
|
|
62
|
+
codes: ["section_without_citation"],
|
|
63
|
+
run(ctx) {
|
|
64
|
+
const defects = []
|
|
65
|
+
for (const section of ctx.sections) {
|
|
66
|
+
if (UNCHECKED_KINDS.has(section.kind)) continue
|
|
67
|
+
const prose = section.prose.join(" ").replace(/\s+/g, " ").trim()
|
|
68
|
+
if (prose.length < MIN_SECTION_PROSE || section.anchors > 0 || ctx.util.citationMarker.test(prose)) continue
|
|
69
|
+
const title = proseText(section.heading)
|
|
70
|
+
defects.push(ctx.defect("section_without_citation", section.heading, `section "${title}" has ${prose.length} prose characters and no citation marker or anchor`))
|
|
71
|
+
}
|
|
72
|
+
return defects
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
codes: ["missing_closing_section"],
|
|
77
|
+
run(ctx) {
|
|
78
|
+
if (headingNodes(ctx, ["h2", "h3"]).some((node) => ctx.isClosing(proseText(node)))) return []
|
|
79
|
+
return [ctx.defect("missing_closing_section", ctx.body, "no h2/h3 heading names how the report was made (method / how)")]
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
codes: ["missing_citation_section"],
|
|
84
|
+
run(ctx) {
|
|
85
|
+
if (ctx.sections.some((section) => section.kind === "sources")) return []
|
|
86
|
+
const ids = new Set()
|
|
87
|
+
for (const { node } of ctx.elements) {
|
|
88
|
+
if (!DEFINITION_TAGS.has(node.tag)) continue
|
|
89
|
+
const match = DEFINITION_LINE.exec(proseText(node))
|
|
90
|
+
if (match) ids.add(`${match[1].trim().toLowerCase()}${match[2]}`)
|
|
91
|
+
}
|
|
92
|
+
if (ids.size >= MIN_DEFINITIONS) return []
|
|
93
|
+
return [ctx.defect("missing_citation_section", ctx.body, `no references/sources h2 and ${ids.size} [S<n>] definition line(s) (< ${MIN_DEFINITIONS})`)]
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
codes: ["broken_asset_reference"],
|
|
98
|
+
skip: (ctx) => !ctx.baseDir,
|
|
99
|
+
run(ctx) {
|
|
100
|
+
const defects = []
|
|
101
|
+
for (const { node, url } of assetReferences(ctx)) {
|
|
102
|
+
const problem = assetProblem(ctx.fs, localPath(ctx.baseDir, url))
|
|
103
|
+
if (problem) defects.push(ctx.defect("broken_asset_reference", node, `<${node.tag}> asset "${url}" ${problem}`))
|
|
104
|
+
}
|
|
105
|
+
return defects
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
codes: ["missing_required_section"],
|
|
110
|
+
skip: (ctx) => ctx.requireSections.length === 0,
|
|
111
|
+
run(ctx) {
|
|
112
|
+
const titles = headingNodes(ctx, ["h1", "h2", "h3", "h4", "h5", "h6"]).map((node) => proseText(node))
|
|
113
|
+
return ctx.requireSections
|
|
114
|
+
.filter((re) => !titles.some((title) => re.test(title)))
|
|
115
|
+
.map((re) => ctx.defect("missing_required_section", ctx.body, `no heading matches the required section ${re}`))
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
]
|