lazycodex-ai 5.0.0-beta.89 → 5.0.0-beta.90
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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/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/package.json +1 -1
- 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,167 @@
|
|
|
1
|
+
// gates-static.mjs - deterministic static gates (G1-G15) over a parsed report.
|
|
2
|
+
//
|
|
3
|
+
// Builds one document context (a document-order walk with h2 sections, citation helpers and a
|
|
4
|
+
// defect factory) and hands it to the text, figure and structure gate modules. Every severity
|
|
5
|
+
// comes from contracts.DEFECT_CODES; the single override is unsourced_number under
|
|
6
|
+
// `Lineage: section`, derived from the spec mode passed in, never from a caller-supplied
|
|
7
|
+
// severity. Plain Node ESM, node: builtins and siblings only.
|
|
8
|
+
|
|
9
|
+
import * as nodeFs from "node:fs"
|
|
10
|
+
|
|
11
|
+
import { DEFECT_CODES, LINEAGE_MODES } from "./contracts.mjs"
|
|
12
|
+
import { customProperties } from "./css-lite.mjs"
|
|
13
|
+
import { figureGates } from "./gates-figures.mjs"
|
|
14
|
+
import { structureGates } from "./gates-structure.mjs"
|
|
15
|
+
import { textGates } from "./gates-text.mjs"
|
|
16
|
+
import { PROSE_TAGS, proseText, selectorPath } from "./html-lite.mjs"
|
|
17
|
+
|
|
18
|
+
const CITATION_MARKER = /\[S\d+\]|\[Source\s*\d+\]|\[\uCD9C\uCC98[^\]]*\]|\[\^\d+\]/i
|
|
19
|
+
const ANCHOR_HREF = /^#(?:s|src|source|ref|fn)/i
|
|
20
|
+
const ANCHOR_TEXT = /^\[(?:\uCD9C\uCC98|Source\s*\d+|S\d+)\]$/i
|
|
21
|
+
/** Closing ("how this report was made") heading; Korean alternates cover the same register. */
|
|
22
|
+
export const CLOSING_HEADING = /\b(?:method(?:ology)?|how)\b|\uB4B7\uBA74|\uBC29\uBC95|\uC5B4\uB5BB\uAC8C|\uB9CC\uB4E4\uC5B4/i
|
|
23
|
+
export const SOURCES_HEADING = /references|sources|bibliography|\uCD9C\uCC98|\uCC38\uACE0\s*(?:\uBB38\uD5CC|\uC790\uB8CC)/i
|
|
24
|
+
const OTHER_KINDS = [
|
|
25
|
+
["toc", /\b(?:contents|toc)\b|\uBAA9\uCC28/i],
|
|
26
|
+
["appendix", /appendix|\uBD80\uB85D/i],
|
|
27
|
+
["glossary", /glossary|\uC6A9\uC5B4/i],
|
|
28
|
+
]
|
|
29
|
+
const TEXT_SKIP = new Set(["code", "pre", "kbd", "samp", "script", "style", "svg", "template", "noscript"])
|
|
30
|
+
const HEADING_TAG = /^h[1-6]$/
|
|
31
|
+
/** The one sanctioned severity override: section-level lineage demotes unsourced_number. */
|
|
32
|
+
const SECTION_LINEAGE_SEVERITY = "minor"
|
|
33
|
+
|
|
34
|
+
const GATES = [...textGates, ...figureGates, ...structureGates]
|
|
35
|
+
|
|
36
|
+
function classTokens(node) {
|
|
37
|
+
return String(node.attrs.class ?? "").toLowerCase().split(/\s+/).filter(Boolean)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function closest(node, predicate) {
|
|
41
|
+
for (let cur = node.parent; cur; cur = cur.parent) if (predicate(cur)) return cur
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function descendants(node, out = []) {
|
|
46
|
+
for (const child of node.children) {
|
|
47
|
+
if (child.tag === "#text") continue
|
|
48
|
+
out.push(child)
|
|
49
|
+
descendants(child, out)
|
|
50
|
+
}
|
|
51
|
+
return out
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isCitationAnchor(node) {
|
|
55
|
+
return node.tag === "a" && (ANCHOR_HREF.test(node.attrs.href ?? "") || ANCHOR_TEXT.test(proseText(node)))
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A citation marker in the element's prose or a citation anchor below it. */
|
|
59
|
+
function hasCitation(node) {
|
|
60
|
+
return CITATION_MARKER.test(proseText(node)) || descendants(node).some(isCitationAnchor)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toRegExp(pattern) {
|
|
64
|
+
const re = pattern instanceof RegExp ? pattern : new RegExp(String(pattern), "i")
|
|
65
|
+
return new RegExp(re.source, re.flags.replace(/[gy]/g, ""))
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function findBody(doc) {
|
|
69
|
+
const stack = [doc]
|
|
70
|
+
while (stack.length) {
|
|
71
|
+
const node = stack.pop()
|
|
72
|
+
if (node.tag === "body") return node
|
|
73
|
+
for (const child of node.children) if (child.tag !== "#text") stack.push(child)
|
|
74
|
+
}
|
|
75
|
+
return doc
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sectionKind(text, closingTest) {
|
|
79
|
+
if (SOURCES_HEADING.test(text)) return "sources"
|
|
80
|
+
if (closingTest(text)) return "closing"
|
|
81
|
+
return OTHER_KINDS.find(([, re]) => re.test(text))?.[0] ?? null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Document-order walk of body: element scopes, prose text entries with owners, h2 sections. */
|
|
85
|
+
function walkBody(ctx, node, state) {
|
|
86
|
+
for (const child of node.children) {
|
|
87
|
+
if (child.tag === "#text") {
|
|
88
|
+
if (state.skip) continue
|
|
89
|
+
ctx.texts.push({ node: child, owner: state.owner ?? node, cell: state.cell, scope: ctx.scope })
|
|
90
|
+
if (ctx.scope.section && !state.heading) ctx.scope.section.prose.push(child.text)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
if (child.tag === "h2") {
|
|
94
|
+
const section = { heading: child, kind: sectionKind(proseText(child), ctx.isClosing), prose: [], anchors: 0 }
|
|
95
|
+
ctx.sections.push(section)
|
|
96
|
+
ctx.scope = { section, closingH3: false }
|
|
97
|
+
} else if (child.tag === "h3") {
|
|
98
|
+
ctx.scope = { section: ctx.scope.section, closingH3: ctx.isClosing(proseText(child)) }
|
|
99
|
+
}
|
|
100
|
+
ctx.elements.push({ node: child, scope: ctx.scope })
|
|
101
|
+
if (ctx.scope.section && isCitationAnchor(child)) ctx.scope.section.anchors++
|
|
102
|
+
walkBody(ctx, child, {
|
|
103
|
+
skip: state.skip || TEXT_SKIP.has(child.tag),
|
|
104
|
+
owner: PROSE_TAGS.has(child.tag) ? child : state.owner,
|
|
105
|
+
cell: state.cell || child.tag === "td" || child.tag === "th",
|
|
106
|
+
heading: state.heading || HEADING_TAG.test(child.tag),
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function buildContext(doc, options) {
|
|
112
|
+
const lineageMode = options.lineageMode ?? "inline"
|
|
113
|
+
if (!LINEAGE_MODES.includes(lineageMode)) throw new TypeError(`lineageMode must be one of ${LINEAGE_MODES.join(", ")}`)
|
|
114
|
+
const closingPattern = options.closingPattern ? toRegExp(options.closingPattern) : null
|
|
115
|
+
const all = descendants(doc)
|
|
116
|
+
const styleBlocks = all.filter((node) => node.tag === "style").map((node) => ({ node, css: node.raw ?? "" }))
|
|
117
|
+
const styleAttrs = all.filter((node) => node.attrs.style).map((node) => ({ node, css: node.attrs.style }))
|
|
118
|
+
const ctx = {
|
|
119
|
+
doc,
|
|
120
|
+
body: findBody(doc),
|
|
121
|
+
all,
|
|
122
|
+
elements: [],
|
|
123
|
+
texts: [],
|
|
124
|
+
sections: [],
|
|
125
|
+
scope: { section: null, closingH3: false },
|
|
126
|
+
styleBlocks,
|
|
127
|
+
styleAttrs,
|
|
128
|
+
tokens: customProperties(styleBlocks.map((entry) => entry.css).join("\n")).light,
|
|
129
|
+
palette: new Set(options.palette ?? []),
|
|
130
|
+
requireSections: (options.requireSections ?? []).map(toRegExp),
|
|
131
|
+
baseDir: options.baseDir ?? null,
|
|
132
|
+
fs: options.fs ?? nodeFs,
|
|
133
|
+
isClosing: (text) => CLOSING_HEADING.test(text) || Boolean(closingPattern?.test(text)),
|
|
134
|
+
util: { classTokens, closest, descendants, hasCitation, citationMarker: CITATION_MARKER },
|
|
135
|
+
kindOf: (scope) => (scope.closingH3 ? "closing" : (scope.section?.kind ?? null)),
|
|
136
|
+
defect(code, node, message) {
|
|
137
|
+
const entry = DEFECT_CODES[code]
|
|
138
|
+
const severity = code === "unsourced_number" && lineageMode === "section" ? SECTION_LINEAGE_SEVERITY : entry.severity
|
|
139
|
+
return { code, severity, selector: selectorPath(node) || node.tag, line: node.line, message, hint: entry.hint }
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
walkBody(ctx, ctx.body, { skip: false, owner: null, cell: false, heading: false })
|
|
143
|
+
return ctx
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run G1-G15 over a parsed report (html-lite root).
|
|
148
|
+
* @param {object} doc parseHtml(...) result
|
|
149
|
+
* @param {{ palette?: Set<string>, lineageMode?: "inline"|"section", requireSections?: (RegExp|string)[],
|
|
150
|
+
* baseDir?: string, fs?: { statSync(path: string): { size: number } }, closingPattern?: RegExp|string }} options
|
|
151
|
+
* @returns {{ defects: object[], gatesRun: string[], gatesSkipped: string[] }}
|
|
152
|
+
*/
|
|
153
|
+
export function checkStatic(doc, options = {}) {
|
|
154
|
+
const ctx = buildContext(doc, options)
|
|
155
|
+
const defects = []
|
|
156
|
+
const gatesRun = []
|
|
157
|
+
const gatesSkipped = []
|
|
158
|
+
for (const gate of GATES) {
|
|
159
|
+
if (gate.skip?.(ctx)) {
|
|
160
|
+
gatesSkipped.push(...gate.codes)
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
gatesRun.push(...gate.codes)
|
|
164
|
+
defects.push(...gate.run(ctx))
|
|
165
|
+
}
|
|
166
|
+
return { defects, gatesRun, gatesSkipped }
|
|
167
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// gates-structure.mjs - structure gates: G11 section without citation, G12 closing section,
|
|
2
|
+
// G13 citation section, G14 broken asset reference, G15 required sections.
|
|
3
|
+
//
|
|
4
|
+
// Each gate is { codes, skip?(ctx), run(ctx) -> Defect[] } over the context built by
|
|
5
|
+
// gates-static.mjs. Plain Node ESM, node: builtins and siblings only.
|
|
6
|
+
|
|
7
|
+
import { join } from "node:path"
|
|
8
|
+
|
|
9
|
+
import { proseText } from "./html-lite.mjs"
|
|
10
|
+
|
|
11
|
+
const MIN_SECTION_PROSE = 120
|
|
12
|
+
const UNCHECKED_KINDS = new Set(["closing", "sources", "toc", "appendix", "glossary"])
|
|
13
|
+
const DEFINITION_TAGS = new Set(["p", "li", "dd", "dt", "td"])
|
|
14
|
+
const DEFINITION_LINE = /^\[(S|Source\s*)(\d+)\]/i
|
|
15
|
+
const MIN_DEFINITIONS = 3
|
|
16
|
+
const NOT_RELATIVE = /^(?:[a-z][a-z\d+.-]*:|\/\/|\/|#)/i
|
|
17
|
+
|
|
18
|
+
function headingNodes(ctx, tags) {
|
|
19
|
+
return ctx.elements.map(({ node }) => node).filter((node) => tags.includes(node.tag))
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Local references an asset gate checks: img src, source srcset, stylesheet href, script src. */
|
|
23
|
+
function assetReferences(ctx) {
|
|
24
|
+
const refs = []
|
|
25
|
+
for (const node of ctx.all) {
|
|
26
|
+
const { attrs } = node
|
|
27
|
+
if (node.tag === "img" && attrs.src) refs.push({ node, url: attrs.src })
|
|
28
|
+
else if (node.tag === "script" && attrs.src) refs.push({ node, url: attrs.src })
|
|
29
|
+
else if (node.tag === "link" && attrs.href && /(?:^|\s)stylesheet(?:\s|$)/i.test(attrs.rel ?? "")) refs.push({ node, url: attrs.href })
|
|
30
|
+
else if (node.tag === "source" && attrs.srcset) {
|
|
31
|
+
for (const candidate of attrs.srcset.split(",")) {
|
|
32
|
+
const url = candidate.trim().split(/\s+/)[0]
|
|
33
|
+
if (url) refs.push({ node, url })
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return refs.filter(({ url }) => url.trim() && !NOT_RELATIVE.test(url.trim()))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function localPath(baseDir, url) {
|
|
41
|
+
const bare = url.trim().replace(/[?#].*$/, "")
|
|
42
|
+
let decoded = bare
|
|
43
|
+
try {
|
|
44
|
+
decoded = decodeURIComponent(bare)
|
|
45
|
+
} catch {
|
|
46
|
+
// a malformed %-escape is kept verbatim; the stat below reports it as missing
|
|
47
|
+
}
|
|
48
|
+
return join(baseDir, ...decoded.split("/"))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assetProblem(fs, file) {
|
|
52
|
+
try {
|
|
53
|
+
const stat = fs.statSync(file)
|
|
54
|
+
return stat.size === 0 ? "is 0 bytes" : null
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return `is missing (${error?.code ?? "stat failed"})`
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export const structureGates = [
|
|
61
|
+
{
|
|
62
|
+
codes: ["section_without_citation"],
|
|
63
|
+
run(ctx) {
|
|
64
|
+
const defects = []
|
|
65
|
+
for (const section of ctx.sections) {
|
|
66
|
+
if (UNCHECKED_KINDS.has(section.kind)) continue
|
|
67
|
+
const prose = section.prose.join(" ").replace(/\s+/g, " ").trim()
|
|
68
|
+
if (prose.length < MIN_SECTION_PROSE || section.anchors > 0 || ctx.util.citationMarker.test(prose)) continue
|
|
69
|
+
const title = proseText(section.heading)
|
|
70
|
+
defects.push(ctx.defect("section_without_citation", section.heading, `section "${title}" has ${prose.length} prose characters and no citation marker or anchor`))
|
|
71
|
+
}
|
|
72
|
+
return defects
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
codes: ["missing_closing_section"],
|
|
77
|
+
run(ctx) {
|
|
78
|
+
if (headingNodes(ctx, ["h2", "h3"]).some((node) => ctx.isClosing(proseText(node)))) return []
|
|
79
|
+
return [ctx.defect("missing_closing_section", ctx.body, "no h2/h3 heading names how the report was made (method / how)")]
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
codes: ["missing_citation_section"],
|
|
84
|
+
run(ctx) {
|
|
85
|
+
if (ctx.sections.some((section) => section.kind === "sources")) return []
|
|
86
|
+
const ids = new Set()
|
|
87
|
+
for (const { node } of ctx.elements) {
|
|
88
|
+
if (!DEFINITION_TAGS.has(node.tag)) continue
|
|
89
|
+
const match = DEFINITION_LINE.exec(proseText(node))
|
|
90
|
+
if (match) ids.add(`${match[1].trim().toLowerCase()}${match[2]}`)
|
|
91
|
+
}
|
|
92
|
+
if (ids.size >= MIN_DEFINITIONS) return []
|
|
93
|
+
return [ctx.defect("missing_citation_section", ctx.body, `no references/sources h2 and ${ids.size} [S<n>] definition line(s) (< ${MIN_DEFINITIONS})`)]
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
codes: ["broken_asset_reference"],
|
|
98
|
+
skip: (ctx) => !ctx.baseDir,
|
|
99
|
+
run(ctx) {
|
|
100
|
+
const defects = []
|
|
101
|
+
for (const { node, url } of assetReferences(ctx)) {
|
|
102
|
+
const problem = assetProblem(ctx.fs, localPath(ctx.baseDir, url))
|
|
103
|
+
if (problem) defects.push(ctx.defect("broken_asset_reference", node, `<${node.tag}> asset "${url}" ${problem}`))
|
|
104
|
+
}
|
|
105
|
+
return defects
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
codes: ["missing_required_section"],
|
|
110
|
+
skip: (ctx) => ctx.requireSections.length === 0,
|
|
111
|
+
run(ctx) {
|
|
112
|
+
const titles = headingNodes(ctx, ["h1", "h2", "h3", "h4", "h5", "h6"]).map((node) => proseText(node))
|
|
113
|
+
return ctx.requireSections
|
|
114
|
+
.filter((re) => !titles.some((title) => re.test(title)))
|
|
115
|
+
.map((re) => ctx.defect("missing_required_section", ctx.body, `no heading matches the required section ${re}`))
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// gates-text.mjs - prose gates: G1 keep-all, G3 emoji, G4 dashes, G5/G6 headings, G7 unsourced numbers.
|
|
2
|
+
//
|
|
3
|
+
// Each gate is { codes, run(ctx) -> Defect[] } over the context built by gates-static.mjs.
|
|
4
|
+
// Plain Node ESM, sibling imports only.
|
|
5
|
+
|
|
6
|
+
import { proseText } from "./html-lite.mjs"
|
|
7
|
+
|
|
8
|
+
const HANGUL = /[\uAC00-\uD7A3]/g
|
|
9
|
+
const KEEP_ALL = /word-break\s*:\s*keep-all/i
|
|
10
|
+
const MIN_HANGUL = 20
|
|
11
|
+
/** Dingbats (2700-27BF) that render as emoji without FE0F (Emoji_Presentation=Yes). */
|
|
12
|
+
const DINGBAT_EMOJI = new Set([0x2705, 0x270a, 0x270b, 0x2728, 0x274c, 0x274e, 0x2753, 0x2754, 0x2755, 0x2757, 0x2795, 0x2796, 0x2797, 0x27b0, 0x27bf])
|
|
13
|
+
const EM_DASH = "\u2014"
|
|
14
|
+
const EN_DASH = "\u2013"
|
|
15
|
+
const HEADING_NUMBER = /^\s*(?:\d+(?:[.-]\d+)*[.):]?|[IVXLC]+[.)]|[A-Z][.)])\s+/
|
|
16
|
+
const GENERIC = new Set(["overview", "introduction", "background", "summary", "conclusion", "conclusions", "results", "discussion", "methodology"])
|
|
17
|
+
const MAX_HEADING_HANGUL = 26
|
|
18
|
+
const MAX_HEADING_WORDS = 12
|
|
19
|
+
const LINEAGE_TOKEN = /\b(?:MEASURED|ASSUMED|DERIVED)\b/
|
|
20
|
+
const NUMBER_CONTAINERS = new Set(["figure", "table", "nav", "header", "footer"])
|
|
21
|
+
const MAX_UNSOURCED = 50
|
|
22
|
+
const MONTH = "(?:jan|feb|mar|apr|may|jun|jul|aug|sep|sept|oct|nov|dec)[a-z]*\\.?"
|
|
23
|
+
/** Stripped before claim detection: dates, versions, figure refs, times, bare years. */
|
|
24
|
+
const NOT_CLAIMS = [
|
|
25
|
+
/\b\d{4}[-./]\d{1,2}(?:[-./]\d{1,2})?\b/g,
|
|
26
|
+
/\b\d{2}\.\d{2}\.\d{2}\b/g,
|
|
27
|
+
/\d{2,4}\s*\uB144\s*\d{1,2}\s*\uC6D4(?:\s*\d{1,2}\s*\uC77C)?/g,
|
|
28
|
+
/\d{1,2}\s*\uC6D4(?:\s*\d{1,2}\s*\uC77C)?/g,
|
|
29
|
+
/(?:19|20)\d{2}\s*\uB144/g,
|
|
30
|
+
new RegExp(`\\b${MONTH}\\s+\\d{1,2}(?:st|nd|rd|th)?(?:,?\\s+\\d{4})?`, "gi"),
|
|
31
|
+
new RegExp(`\\b\\d{1,2}(?:st|nd|rd|th)?\\s+${MONTH}(?:\\s+\\d{4})?`, "gi"),
|
|
32
|
+
/\bv\d+(?:\.\d+)*\b|\b\d+(?:\.\d+){2,}\b/gi,
|
|
33
|
+
/\b(?:version|ver\.?|release)\s*\d+(?:\.\d+)*/gi,
|
|
34
|
+
/\b(?:fig(?:ure)?\.?|table|chart|section|step|phase|wave|part)\s*\d+(?:\.\d+)*/gi,
|
|
35
|
+
/(?:\uADF8\uB9BC|\uB3C4\uD45C|\uD45C|\uBC84\uC804)\s*\d+(?:\.\d+)*/g,
|
|
36
|
+
/\b\d{1,2}:\d{2}\b/g,
|
|
37
|
+
/\b(?:19|20)\d{2}s?\b/g,
|
|
38
|
+
]
|
|
39
|
+
const NUM = "(?<![\\p{L}\\d.,])\\d+(?:[.,]\\d+)*"
|
|
40
|
+
const UNIT =
|
|
41
|
+
"(?:%|\\u2030|\\u00D7|\\uD37C\\uC13C\\uD2B8|\\uB2EC\\uB7EC|\\uC720\\uB85C|\\uC5D4|\\uC6D0|\\uB9CC|\\uC5B5|\\uC870|\\uCC9C|\\uCD08|\\uBD84|\\uC2DC\\uAC04|\\uC77C|\\uC8FC|\\uAC1C\\uC6D4|\\uB144|\\uBA85|\\uAC74|\\uAC1C|\\uD68C|\\uBC88|\\uBC30" +
|
|
42
|
+
"|(?:percent|pp|bps?|x|k|m|b|bn|million|billion|thousand|trillion|usd|krw|eur|ms|s|secs?|seconds?|mins?|minutes?|h|hrs?|hours?|days?|weeks?|months?|years?|kb|mb|gb|tb|users?|people|employees|engineers|tokens?|requests?|stars?|downloads?|customers?)(?![\\p{L}\\d]))"
|
|
43
|
+
const CLAIMS = [
|
|
44
|
+
new RegExp(`[$\\u20AC\\u00A3\\u00A5\\u20A9]\\s?${NUM}`, "u"),
|
|
45
|
+
new RegExp(`${NUM}\\s?${UNIT}`, "iu"),
|
|
46
|
+
/(?<![\d.,])\d{1,3}(?:,\d{3})+(?![\d,])/,
|
|
47
|
+
/(?<![\d.])\d+\.\d+(?![\d.])/,
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
function snippet(text, index, span = 24) {
|
|
51
|
+
return text.slice(Math.max(0, index - span), index + span).trim()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Concatenated prose per owning element, in document order; `keep` filters text entries. */
|
|
55
|
+
function ownerTexts(ctx, keep) {
|
|
56
|
+
const byOwner = new Map()
|
|
57
|
+
for (const entry of ctx.texts) {
|
|
58
|
+
if (!keep(entry)) continue
|
|
59
|
+
byOwner.set(entry.owner, (byOwner.get(entry.owner) ?? "") + entry.node.text)
|
|
60
|
+
}
|
|
61
|
+
return [...byOwner].map(([owner, text]) => ({ owner, text: text.replace(/\s+/g, " ") }))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function firstEmoji(text) {
|
|
65
|
+
const chars = [...text]
|
|
66
|
+
for (let i = 0; i < chars.length; i++) {
|
|
67
|
+
const cp = chars[i].codePointAt(0)
|
|
68
|
+
if (chars[i + 1] === "\uFE0F" && cp !== 0xfe0f) return chars[i] + chars[i + 1]
|
|
69
|
+
if ((cp >= 0x1f000 && cp <= 0x1faff) || (cp >= 0x2600 && cp <= 0x26ff) || DINGBAT_EMOJI.has(cp)) return chars[i]
|
|
70
|
+
}
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const isDigit = (ch) => ch >= "0" && ch <= "9"
|
|
75
|
+
|
|
76
|
+
/** En dash allowed only when both neighbors (one optional space) are digits. */
|
|
77
|
+
function badEnDash(text) {
|
|
78
|
+
for (let at = text.indexOf(EN_DASH); at !== -1; at = text.indexOf(EN_DASH, at + 1)) {
|
|
79
|
+
const before = text[at - 1] === " " ? text[at - 2] : text[at - 1]
|
|
80
|
+
const after = text[at + 1] === " " ? text[at + 2] : text[at + 1]
|
|
81
|
+
if (!isDigit(before ?? "") || !isDigit(after ?? "")) return at
|
|
82
|
+
}
|
|
83
|
+
return -1
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const stripNumbering = (text) => text.replace(HEADING_NUMBER, "").trim()
|
|
87
|
+
|
|
88
|
+
function headings(ctx, max) {
|
|
89
|
+
return ctx.elements.filter(({ node }) => /^h[1-6]$/.test(node.tag) && Number(node.tag[1]) <= max).map(({ node }) => node)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Citation markers need no stripping: an element carrying one is skipped before this runs. */
|
|
93
|
+
function claimIn(text) {
|
|
94
|
+
let rest = text
|
|
95
|
+
for (const re of NOT_CLAIMS) rest = rest.replace(re, " ")
|
|
96
|
+
for (const re of CLAIMS) {
|
|
97
|
+
const match = re.exec(rest)
|
|
98
|
+
if (match) return match[0].trim()
|
|
99
|
+
}
|
|
100
|
+
return null
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function numberCandidates(ctx) {
|
|
104
|
+
const { closest, classTokens, descendants } = ctx.util
|
|
105
|
+
return ctx.elements.filter(({ node, scope }) => {
|
|
106
|
+
if (node.tag !== "p" && node.tag !== "li") return false
|
|
107
|
+
if (node.tag === "li" && descendants(node).some((child) => child.tag === "p" || child.tag === "li")) return false
|
|
108
|
+
const kind = ctx.kindOf(scope)
|
|
109
|
+
if (kind === "closing" || kind === "sources") return false
|
|
110
|
+
return !closest(node, (cur) => NUMBER_CONTAINERS.has(cur.tag) || classTokens(cur).includes("fig"))
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const textGates = [
|
|
115
|
+
{
|
|
116
|
+
codes: ["korean_no_keep_all"],
|
|
117
|
+
run(ctx) {
|
|
118
|
+
const syllables = ctx.texts.reduce((sum, entry) => sum + (entry.node.text.match(HANGUL)?.length ?? 0), 0)
|
|
119
|
+
if (syllables < MIN_HANGUL) return []
|
|
120
|
+
if ([...ctx.styleBlocks, ...ctx.styleAttrs].some((entry) => KEEP_ALL.test(entry.css))) return []
|
|
121
|
+
return [ctx.defect("korean_no_keep_all", ctx.body, `${syllables} Hangul syllables in prose and no "word-break: keep-all" in any style block or style attribute`)]
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
codes: ["emoji_in_prose"],
|
|
126
|
+
run(ctx) {
|
|
127
|
+
const defects = []
|
|
128
|
+
for (const { owner, text } of ownerTexts(ctx, () => true)) {
|
|
129
|
+
const emoji = firstEmoji(text)
|
|
130
|
+
if (emoji) defects.push(ctx.defect("emoji_in_prose", owner, `emoji "${emoji}" in <${owner.tag}>: "${snippet(text, text.indexOf(emoji))}"`))
|
|
131
|
+
}
|
|
132
|
+
return defects
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
{
|
|
136
|
+
codes: ["em_dash_in_prose", "en_dash_in_prose"],
|
|
137
|
+
run(ctx) {
|
|
138
|
+
const defects = []
|
|
139
|
+
const inScope = (entry) => !entry.cell && ctx.kindOf(entry.scope) !== "sources"
|
|
140
|
+
for (const { owner, text } of ownerTexts(ctx, inScope)) {
|
|
141
|
+
const em = text.indexOf(EM_DASH)
|
|
142
|
+
if (em !== -1) defects.push(ctx.defect("em_dash_in_prose", owner, `em dash "${EM_DASH}" in <${owner.tag}>: "${snippet(text, em)}"`))
|
|
143
|
+
const en = badEnDash(text)
|
|
144
|
+
if (en !== -1) defects.push(ctx.defect("en_dash_in_prose", owner, `en dash "${EN_DASH}" outside a numeric range in <${owner.tag}>: "${snippet(text, en)}"`))
|
|
145
|
+
}
|
|
146
|
+
return defects
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
{
|
|
150
|
+
codes: ["heading_too_long"],
|
|
151
|
+
run(ctx) {
|
|
152
|
+
const defects = []
|
|
153
|
+
for (const node of headings(ctx, 4)) {
|
|
154
|
+
const text = stripNumbering(proseText(node))
|
|
155
|
+
const hangul = /[\uAC00-\uD7A3]/.test(text)
|
|
156
|
+
const size = hangul ? [...text].length : text.split(/\s+/).filter(Boolean).length
|
|
157
|
+
if (size > (hangul ? MAX_HEADING_HANGUL : MAX_HEADING_WORDS)) {
|
|
158
|
+
defects.push(ctx.defect("heading_too_long", node, `<${node.tag}> "${text}" is ${size} ${hangul ? "characters (> 26)" : "words (> 12)"}`))
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return defects
|
|
162
|
+
},
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
codes: ["heading_generic"],
|
|
166
|
+
run(ctx) {
|
|
167
|
+
const defects = []
|
|
168
|
+
for (const node of headings(ctx, 3)) {
|
|
169
|
+
const text = stripNumbering(proseText(node))
|
|
170
|
+
if (GENERIC.has(text.toLowerCase().replace(/[.:]+$/, ""))) defects.push(ctx.defect("heading_generic", node, `generic <${node.tag}> "${text}"`))
|
|
171
|
+
}
|
|
172
|
+
return defects
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
{
|
|
176
|
+
codes: ["unsourced_number"],
|
|
177
|
+
run(ctx) {
|
|
178
|
+
const defects = []
|
|
179
|
+
for (const { node } of numberCandidates(ctx)) {
|
|
180
|
+
if (defects.length >= MAX_UNSOURCED) break
|
|
181
|
+
const text = proseText(node)
|
|
182
|
+
if (LINEAGE_TOKEN.test(text) || ctx.util.hasCitation(node)) continue
|
|
183
|
+
if (node.attrs["data-lineage"] !== undefined || ctx.util.descendants(node).some((child) => child.attrs["data-lineage"] !== undefined)) continue
|
|
184
|
+
const claim = claimIn(text)
|
|
185
|
+
if (claim) defects.push(ctx.defect("unsourced_number", node, `number "${claim}" without a lineage tag or citation: "${snippet(text, text.indexOf(claim))}"`))
|
|
186
|
+
}
|
|
187
|
+
return defects
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
]
|