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.
Files changed (220) hide show
  1. package/README.ja.md +1 -1
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/README.ru.md +1 -1
  5. package/README.zh-cn.md +1 -1
  6. package/dist/agents/sisyphus/grok-4.d.ts +3 -2
  7. package/dist/cli/config-manager/refresh-opencode-plugin-sandbox.d.ts +31 -0
  8. package/dist/cli/doctor/checks/tui-plugin-config.d.ts +11 -0
  9. package/dist/cli/index.js +1661 -859
  10. package/dist/cli/install-native/index.d.ts +9 -1
  11. package/dist/cli/install-native/legacy-omo-bin.d.ts +27 -0
  12. package/dist/cli/install-native/offer-native-setup.d.ts +31 -0
  13. package/dist/cli/install-native/omo-bin-test-fixtures.d.ts +35 -0
  14. package/dist/cli/install-native/plan.d.ts +8 -0
  15. package/dist/cli/install-native/repair-legacy-omo-bin.d.ts +26 -0
  16. package/dist/cli/install-native/run-native-install.d.ts +11 -1
  17. package/dist/cli/install-native/verify-omo-command.d.ts +22 -0
  18. package/dist/cli/native-edition-hint.d.ts +2 -2
  19. package/dist/cli-node/index.js +1661 -859
  20. package/dist/hooks/auto-update-checker/checker/sandbox-refresh.d.ts +51 -0
  21. package/dist/hooks/auto-update-checker/checker/semver-compare.d.ts +17 -0
  22. package/dist/hooks/auto-update-checker/checker.d.ts +3 -0
  23. package/dist/hooks/auto-update-checker/hook/background-update-check.d.ts +8 -0
  24. package/dist/hooks/auto-update-checker/hook.d.ts +2 -0
  25. package/dist/hooks/native-edition-nudge/hook.d.ts +1 -1
  26. package/dist/index.js +1433 -1055
  27. package/dist/shared/index.d.ts +1 -0
  28. package/dist/shared/opencode-plugin-sandbox.d.ts +54 -0
  29. package/dist/skills/browser/SKILL.md +8 -1
  30. package/dist/skills/browser/references/install.md +34 -5
  31. package/dist/skills/browser/runtime/omowright/index.js +2 -2
  32. package/dist/skills/browser/runtime/omowright/manifest.json +3 -3
  33. package/dist/skills/browser/scripts/browser-doctor.mjs +14 -3
  34. package/dist/skills/browser/scripts/browser-install.mjs +13 -1
  35. package/dist/skills/ulw-research/AGENTS.md +63 -0
  36. package/dist/skills/ulw-research/SKILL.md +38 -26
  37. package/dist/skills/ulw-research/references/deliverable-phase.md +187 -0
  38. package/dist/skills/ulw-research/references/report-gates.md +66 -0
  39. package/dist/skills/ulw-research/scripts/cli-support.mjs +102 -0
  40. package/dist/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  41. package/dist/skills/ulw-research/scripts/contracts.mjs +162 -0
  42. package/dist/skills/ulw-research/scripts/contracts.test.ts +177 -0
  43. package/dist/skills/ulw-research/scripts/css-lite.mjs +204 -0
  44. package/dist/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  45. package/dist/skills/ulw-research/scripts/design-spec.mjs +161 -0
  46. package/dist/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  47. package/dist/skills/ulw-research/scripts/entities.mjs +39 -0
  48. package/dist/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  49. package/dist/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  50. package/dist/skills/ulw-research/scripts/format-extract.mjs +229 -0
  51. package/dist/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  52. package/dist/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  53. package/dist/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  54. package/dist/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  55. package/dist/skills/ulw-research/scripts/gates-static.mjs +167 -0
  56. package/dist/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  57. package/dist/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  58. package/dist/skills/ulw-research/scripts/gates-text.mjs +190 -0
  59. package/dist/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  60. package/dist/skills/ulw-research/scripts/html-lite.mjs +239 -0
  61. package/dist/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  62. package/dist/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  63. package/dist/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  64. package/dist/skills/ulw-research/scripts/outcome.mjs +250 -0
  65. package/dist/skills/ulw-research/scripts/outcome.test.ts +281 -0
  66. package/dist/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  67. package/dist/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  68. package/dist/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  69. package/dist/skills/ulw-research/scripts/report-tools.mjs +61 -0
  70. package/dist/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  71. package/dist/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  72. package/dist/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  73. package/dist/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  74. package/dist/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  75. package/dist/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  76. package/dist/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  77. package/dist/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  78. package/dist/skills/visual-qa/references/browser-setup.md +3 -1
  79. package/dist/tui.js +534 -66
  80. package/package.json +15 -15
  81. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  82. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  83. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  84. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  85. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  86. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  87. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  88. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  89. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  90. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  91. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  92. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  93. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-5.6.md +5 -1
  94. package/packages/omo-codex/plugin/components/rules/bundled-rules/hephaestus/gpt-6.md +5 -1
  95. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  96. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  97. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  98. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  99. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  100. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  101. package/packages/omo-codex/plugin/components/ultrawork/dist/cli.js +7 -3
  102. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  103. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  104. package/packages/omo-codex/plugin/components/ultrawork/src/directive-content.ts +1 -1
  105. package/packages/omo-codex/plugin/components/ulw-execute-continuation/directive.md +1 -1
  106. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  107. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  108. package/packages/omo-codex/plugin/components/ulw-loop/directive.md +7 -3
  109. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  110. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  111. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  112. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  113. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  114. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  115. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  116. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  117. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  118. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  119. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  120. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  121. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  122. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  123. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  124. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  125. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  126. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  127. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  128. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  129. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  130. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  131. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  132. package/packages/omo-codex/plugin/package-lock.json +12 -12
  133. package/packages/omo-codex/plugin/package.json +1 -1
  134. package/packages/omo-codex/plugin/scripts/sync-skills.mjs +2 -2
  135. package/packages/omo-codex/plugin/skills/browser/SKILL.md +8 -1
  136. package/packages/omo-codex/plugin/skills/browser/references/install.md +34 -5
  137. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/index.js +2 -2
  138. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +3 -3
  139. package/packages/omo-codex/plugin/skills/browser/scripts/browser-doctor.mjs +14 -3
  140. package/packages/omo-codex/plugin/skills/browser/scripts/browser-install.mjs +13 -1
  141. package/packages/omo-codex/plugin/skills/ultrawork/SKILL.md +7 -3
  142. package/packages/omo-codex/plugin/skills/ulw-research/AGENTS.md +63 -0
  143. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +37 -25
  144. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +187 -0
  145. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +66 -0
  146. package/packages/omo-codex/plugin/skills/ulw-research/scripts/cli-support.mjs +102 -0
  147. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +162 -0
  148. package/packages/omo-codex/plugin/skills/ulw-research/scripts/css-lite.mjs +204 -0
  149. package/packages/omo-codex/plugin/skills/ulw-research/scripts/design-spec.mjs +161 -0
  150. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entities.mjs +39 -0
  151. package/packages/omo-codex/plugin/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  152. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  153. package/packages/omo-codex/plugin/skills/ulw-research/scripts/format-extract.mjs +229 -0
  154. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  155. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  156. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-static.mjs +167 -0
  157. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  158. package/packages/omo-codex/plugin/skills/ulw-research/scripts/gates-text.mjs +190 -0
  159. package/packages/omo-codex/plugin/skills/ulw-research/scripts/html-lite.mjs +239 -0
  160. package/packages/omo-codex/plugin/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  161. package/packages/omo-codex/plugin/skills/ulw-research/scripts/outcome.mjs +250 -0
  162. package/packages/omo-codex/plugin/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  163. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  164. package/packages/omo-codex/plugin/skills/ulw-research/scripts/report-tools.mjs +61 -0
  165. package/packages/omo-codex/plugin/skills/visual-qa/references/browser-setup.md +3 -1
  166. package/packages/omo-codex/plugin/test/sync-skills-codex-compatibility.test.mjs +11 -0
  167. package/packages/omo-codex/plugin/test/sync-skills-test-support.mjs +2 -2
  168. package/packages/omo-codex/plugin/test/sync-skills.test.mjs +20 -0
  169. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  170. package/packages/prompts-core/prompts/ultrawork/codex.md +7 -3
  171. package/packages/shared-skills/skills/browser/SKILL.md +8 -1
  172. package/packages/shared-skills/skills/browser/references/install.md +34 -5
  173. package/packages/shared-skills/skills/browser/runtime/omowright/index.js +2 -2
  174. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +3 -3
  175. package/packages/shared-skills/skills/browser/scripts/browser-doctor.mjs +14 -3
  176. package/packages/shared-skills/skills/browser/scripts/browser-install.mjs +13 -1
  177. package/packages/shared-skills/skills/ulw-research/AGENTS.md +63 -0
  178. package/packages/shared-skills/skills/ulw-research/SKILL.md +38 -26
  179. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +187 -0
  180. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +66 -0
  181. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.mjs +102 -0
  182. package/packages/shared-skills/skills/ulw-research/scripts/cli-support.test.ts +88 -0
  183. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +162 -0
  184. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +177 -0
  185. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.mjs +204 -0
  186. package/packages/shared-skills/skills/ulw-research/scripts/css-lite.test.ts +159 -0
  187. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.mjs +161 -0
  188. package/packages/shared-skills/skills/ulw-research/scripts/design-spec.test.ts +162 -0
  189. package/packages/shared-skills/skills/ulw-research/scripts/entities.mjs +39 -0
  190. package/packages/shared-skills/skills/ulw-research/scripts/entry-guard.mjs +21 -0
  191. package/packages/shared-skills/skills/ulw-research/scripts/format-extract-css.mjs +122 -0
  192. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.mjs +229 -0
  193. package/packages/shared-skills/skills/ulw-research/scripts/format-extract.test.ts +243 -0
  194. package/packages/shared-skills/skills/ulw-research/scripts/gates-figures.mjs +173 -0
  195. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.mjs +146 -0
  196. package/packages/shared-skills/skills/ulw-research/scripts/gates-layout.test.ts +236 -0
  197. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.mjs +167 -0
  198. package/packages/shared-skills/skills/ulw-research/scripts/gates-static.test.ts +265 -0
  199. package/packages/shared-skills/skills/ulw-research/scripts/gates-structure.mjs +118 -0
  200. package/packages/shared-skills/skills/ulw-research/scripts/gates-text.mjs +190 -0
  201. package/packages/shared-skills/skills/ulw-research/scripts/glossary-vocabulary.test.ts +43 -0
  202. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.mjs +239 -0
  203. package/packages/shared-skills/skills/ulw-research/scripts/html-lite.test.ts +191 -0
  204. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.mjs +83 -0
  205. package/packages/shared-skills/skills/ulw-research/scripts/layout-probe.test.ts +89 -0
  206. package/packages/shared-skills/skills/ulw-research/scripts/outcome.mjs +250 -0
  207. package/packages/shared-skills/skills/ulw-research/scripts/outcome.test.ts +281 -0
  208. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.mjs +171 -0
  209. package/packages/shared-skills/skills/ulw-research/scripts/repair-tracker.test.ts +246 -0
  210. package/packages/shared-skills/skills/ulw-research/scripts/report-tools-commands.mjs +214 -0
  211. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.mjs +61 -0
  212. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +213 -0
  213. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/assets/figure-1.svg +1 -0
  214. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/bad-report.html +44 -0
  215. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/boxes.json +90 -0
  216. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/design-spec.md +29 -0
  217. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/good-report.html +57 -0
  218. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference-report.html +86 -0
  219. package/packages/shared-skills/skills/ulw-research/scripts/tests/fixtures/reference.md +23 -0
  220. package/packages/shared-skills/skills/visual-qa/references/browser-setup.md +3 -1
@@ -0,0 +1,88 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { pathToFileURL } from "node:url"
6
+
7
+ import { CliError, exitCodeFor, parseArgs, readJsonFile, writeJsonAtomic } from "./cli-support.mjs"
8
+ import { isCliEntry } from "./entry-guard.mjs"
9
+
10
+ function tempDir() {
11
+ return mkdtempSync(join(tmpdir(), "report-tools-"))
12
+ }
13
+
14
+ describe("parseArgs", () => {
15
+ test("#given spaced, equals, repeated, and boolean flags #when parsed #then positionals and flag values are separated", () => {
16
+ // given
17
+ const argv = ["a.html", "--design-spec", "spec.md", "--max-defects=5", "--require-section", "^Methods", "--require-section", "^Sources", "--json", "b"]
18
+ // when
19
+ const parsed = parseArgs(argv, { repeatable: ["require-section"], booleans: ["json"] })
20
+ // then
21
+ expect(parsed.positionals).toEqual(["a.html", "b"])
22
+ expect(parsed.flags["design-spec"]).toBe("spec.md")
23
+ expect(parsed.flags["max-defects"]).toBe("5")
24
+ expect(parsed.flags["require-section"]).toEqual(["^Methods", "^Sources"])
25
+ expect(parsed.flags.json).toBe(true)
26
+ })
27
+
28
+ test("#given a value flag with no value #when parsed #then a CliError names the flag", () => {
29
+ expect(() => parseArgs(["--design-spec"], {})).toThrow(CliError)
30
+ expect(() => parseArgs(["--design-spec"], {})).toThrow("--design-spec")
31
+ })
32
+
33
+ test("#given an integer flag helper #when the value is not a positive integer #then a CliError is thrown", () => {
34
+ const parsed = parseArgs(["--cap", "0"], {})
35
+ expect(() => parsed.int("cap")).toThrow(CliError)
36
+ expect(parseArgs(["--cap", "7"], {}).int("cap")).toBe(7)
37
+ expect(parseArgs([], {}).int("cap", 400)).toBe(400)
38
+ })
39
+ })
40
+
41
+ describe("exitCodeFor", () => {
42
+ test("#given results and errors #when mapped #then pass is 0, semantic failure is 1, usage and IO errors are 2", () => {
43
+ expect(exitCodeFor({ exitCode: 0 })).toBe(0)
44
+ expect(exitCodeFor({ exitCode: 1 })).toBe(1)
45
+ expect(exitCodeFor(new CliError("usage"))).toBe(2)
46
+ const io = Object.assign(new Error("ENOENT"), { code: "ENOENT" })
47
+ expect(exitCodeFor(io)).toBe(2)
48
+ })
49
+ })
50
+
51
+ describe("JSON files", () => {
52
+ test("#given an existing file #when written atomically #then the new content replaces it and no temp file is left", () => {
53
+ // given
54
+ const dir = tempDir()
55
+ const file = join(dir, "state.json")
56
+ writeFileSync(file, '{"old":true}')
57
+ // when
58
+ writeJsonAtomic(file, { fresh: 1 })
59
+ // then
60
+ expect(JSON.parse(readFileSync(file, "utf8"))).toEqual({ fresh: 1 })
61
+ expect(readJsonFile(file)).toEqual({ fresh: 1 })
62
+ const leftovers = readdirSync(dir).filter((name) => name !== "state.json")
63
+ expect(leftovers).toEqual([])
64
+ })
65
+
66
+ test("#given a missing or malformed file #when read #then a CliError names the path", () => {
67
+ const dir = tempDir()
68
+ expect(() => readJsonFile(join(dir, "missing.json"))).toThrow(CliError)
69
+ const bad = join(dir, "bad.json")
70
+ writeFileSync(bad, "{nope")
71
+ expect(() => readJsonFile(bad)).toThrow("bad.json")
72
+ })
73
+ })
74
+
75
+ describe("isCliEntry", () => {
76
+ test("#given argv[1] pointing at this module #when checked #then it is an entry, and another path is not", () => {
77
+ const original = process.argv[1]
78
+ try {
79
+ const self = join(import.meta.dir, "entry-guard.mjs")
80
+ process.argv[1] = self
81
+ expect(isCliEntry(pathToFileURL(self).href)).toBe(true)
82
+ process.argv[1] = join(import.meta.dir, "contracts.mjs")
83
+ expect(isCliEntry(pathToFileURL(self).href)).toBe(false)
84
+ } finally {
85
+ process.argv[1] = original
86
+ }
87
+ })
88
+ })
@@ -0,0 +1,162 @@
1
+ // contracts.mjs - the fixed vocabulary every report-tools module shares.
2
+ //
3
+ // Severities live here and nowhere else: no module exports a way to change them, so a
4
+ // content-integrity defect can never be demoted by a caller. Plain Node ESM, node: builtins
5
+ // only, runnable by `node` and `bun` alike.
6
+
7
+ export const SCHEMA_VERSION = 1
8
+
9
+ function deepFreeze(value) {
10
+ if (value === null || typeof value !== "object") return value
11
+ for (const key of Object.keys(value)) deepFreeze(value[key])
12
+ return Object.freeze(value)
13
+ }
14
+
15
+ /** code -> { severity, integrity, hint }. integrity = blocks delivery even on repair exhaustion. */
16
+ export const DEFECT_CODES = deepFreeze({
17
+ korean_no_keep_all: { severity: "major", integrity: false, hint: 'Add "word-break: keep-all; overflow-wrap: anywhere" to the body or the slide text container.' },
18
+ palette_off_token: { severity: "minor", integrity: false, hint: "Replace the literal color with a design-spec token (var(--...)) or add the color to the spec palette." },
19
+ emoji_in_prose: { severity: "major", integrity: false, hint: "Remove the emoji; use an inline SVG icon or plain text." },
20
+ em_dash_in_prose: { severity: "major", integrity: false, hint: "Rewrite the sentence with a comma, colon, or period instead of an em dash." },
21
+ en_dash_in_prose: { severity: "minor", integrity: false, hint: "Use a hyphen for compounds; keep the en dash only between numbers." },
22
+ heading_too_long: { severity: "minor", integrity: false, hint: "Shorten the heading to one line (26 Korean characters or 12 English words)." },
23
+ heading_generic: { severity: "minor", integrity: false, hint: "Make the heading carry the section's claim instead of a generic label." },
24
+ unsourced_number: { severity: "major", integrity: false, hint: "Tag the number MEASURED / ASSUMED / DERIVED or cite its source in the same paragraph." },
25
+ chart_without_figure: { severity: "major", integrity: false, hint: "Wrap the image or chart in a figure container with a caption." },
26
+ chart_svg_no_text: { severity: "major", integrity: false, hint: "Give the chart a title, axis labels, units, and value labels as SVG text." },
27
+ chart_svg_missing_labels: { severity: "minor", integrity: false, hint: "Add the missing title, axis ticks, or unit label to the chart." },
28
+ korean_serif_font: { severity: "major", integrity: false, hint: "Use a gothic (sans) Korean family; serif and brush families are banned by the design spec." },
29
+ section_without_citation: { severity: "major", integrity: false, hint: "Cite at least one source inside this section or merge it into a cited one." },
30
+ missing_closing_section: { severity: "blocker", integrity: true, hint: "Add the closing section that explains how the report was made (method, sources, gates)." },
31
+ missing_citation_section: { severity: "blocker", integrity: true, hint: "Add a sources or references section, or [S<n>] definition lines." },
32
+ broken_asset_reference: { severity: "blocker", integrity: true, hint: "Re-render or restore the missing asset file before delivery." },
33
+ missing_required_section: { severity: "blocker", integrity: true, hint: "Add the section the brief requires (matched by --require-section)." },
34
+ missing_promised_deliverable: { severity: "blocker", integrity: true, hint: "Produce the promised deliverable or record it as blocked_capability or skipped with a reason." },
35
+ layout_overflow: { severity: "major", integrity: false, hint: "Constrain the element to its container (max-width: 100%, overflow-wrap: anywhere, or a narrower measure)." },
36
+ layout_text_clipped: { severity: "major", integrity: false, hint: "Remove the overflow: hidden clip or give the text room to wrap." },
37
+ layout_scroll_container: { severity: "minor", integrity: false, hint: "Scrolling content prints clipped; make the content fit or wrap the table in a scroll wrapper only for HTML deliverables." },
38
+ layout_image_distorted: { severity: "major", integrity: false, hint: "Preserve the image aspect ratio (object-fit: contain inside a fixed container)." },
39
+ layout_sibling_overlap: { severity: "major", integrity: false, hint: "Separate the overlapping elements (margins, grid, or a slide split)." },
40
+ })
41
+
42
+ const LAYOUT_PREFIX = "layout_"
43
+ /** Codes checkStatic can raise (G1-G15). */
44
+ export const STATIC_CODES = Object.freeze(
45
+ Object.keys(DEFECT_CODES).filter((code) => !code.startsWith(LAYOUT_PREFIX) && code !== "missing_promised_deliverable"),
46
+ )
47
+ /** Codes checkLayout can raise (L1-L5). */
48
+ export const LAYOUT_CODES = Object.freeze(Object.keys(DEFECT_CODES).filter((code) => code.startsWith(LAYOUT_PREFIX)))
49
+ /** Codes only the outcome manifest verifier raises. */
50
+ export const MANIFEST_CODES = Object.freeze(["missing_promised_deliverable"])
51
+
52
+ export const INTEGRITY_CODES = Object.freeze(
53
+ new Set(Object.entries(DEFECT_CODES).filter(([, entry]) => entry.integrity).map(([code]) => code)),
54
+ )
55
+
56
+ export const LANES = Object.freeze(["template-strict", "template-vibe", "no-format", "edit-existing"])
57
+ export const STATES = Object.freeze(["none", "partial", "complete"])
58
+ export const DELIVERABLE_FORMATS = Object.freeze(["pdf", "docx", "html", "md", "latex", "slides", "post"])
59
+ export const DELIVERABLE_STATUSES = Object.freeze(["pending", "delivered", "blocked_capability", "skipped", "failed"])
60
+ export const GATE_NAMES = Object.freeze(["static", "layout", "visual", "proofread"])
61
+ export const GATE_STATUSES = Object.freeze(["pass", "fail", "not_run"])
62
+ export const LINEAGE_MODES = Object.freeze(["inline", "section"])
63
+ export const ANSWERED_BY = Object.freeze(["user", "default", "request"])
64
+ export const REPAIR_DEFAULTS = Object.freeze({ maxAttempts: 3, plateauLimit: 2, wallClockMs: 15 * 60 * 1000 })
65
+ export const DESIGN_SPEC_SECTIONS = Object.freeze([
66
+ "Extracted from",
67
+ "Tokens",
68
+ "Typography",
69
+ "Layout",
70
+ "Structure",
71
+ "Figures",
72
+ "Citations",
73
+ "Open questions",
74
+ ])
75
+
76
+ const ISO_DATE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/
77
+
78
+ function isObject(value) {
79
+ return value !== null && typeof value === "object" && !Array.isArray(value)
80
+ }
81
+
82
+ function check(errors, condition, message) {
83
+ if (!condition) errors.push(message)
84
+ }
85
+
86
+ function validateDeliverable(row, index, errors) {
87
+ const at = `deliverables[${index}]`
88
+ if (!isObject(row)) return errors.push(`${at} must be an object`)
89
+ check(errors, DELIVERABLE_FORMATS.includes(row.format), `${at}.format must be one of ${DELIVERABLE_FORMATS.join(", ")}`)
90
+ check(errors, DELIVERABLE_STATUSES.includes(row.status), `${at}.status must be one of ${DELIVERABLE_STATUSES.join(", ")}`)
91
+ check(errors, typeof row.promised === "boolean", `${at}.promised must be a boolean`)
92
+ check(errors, typeof row.updatedAt === "string" && ISO_DATE.test(row.updatedAt), `${at}.updatedAt must be an ISO timestamp`)
93
+ for (const key of ["path", "reason"]) {
94
+ if (row[key] !== undefined) check(errors, typeof row[key] === "string", `${at}.${key} must be a string`)
95
+ }
96
+ for (const key of ["bytes", "pages"]) {
97
+ if (row[key] !== undefined) check(errors, Number.isInteger(row[key]) && row[key] >= 0, `${at}.${key} must be a non-negative integer`)
98
+ }
99
+ }
100
+
101
+ function validateDefect(defect, at, errors) {
102
+ if (!isObject(defect)) return errors.push(`${at} must be an object`)
103
+ check(errors, Object.hasOwn(DEFECT_CODES, defect.code), `${at}.code is not a known defect code`)
104
+ check(errors, typeof defect.message === "string", `${at}.message must be a string`)
105
+ }
106
+
107
+ /** @returns {{ ok: boolean, errors: string[] }} */
108
+ export function validateOutcomeManifest(manifest) {
109
+ const errors = []
110
+ if (!isObject(manifest)) return { ok: false, errors: ["manifest must be an object (schemaVersion missing)"] }
111
+ check(errors, manifest.schemaVersion === SCHEMA_VERSION, `schemaVersion must be ${SCHEMA_VERSION}`)
112
+ check(errors, typeof manifest.sessionDir === "string" && manifest.sessionDir.length > 0, "sessionDir must be a non-empty string")
113
+ check(errors, typeof manifest.startedAt === "string" && ISO_DATE.test(manifest.startedAt), "startedAt must be an ISO timestamp")
114
+ check(errors, manifest.finishedAt === null || (typeof manifest.finishedAt === "string" && ISO_DATE.test(manifest.finishedAt)), "finishedAt must be null or an ISO timestamp")
115
+ check(errors, manifest.elapsedMinutes === null || (Number.isFinite(manifest.elapsedMinutes) && manifest.elapsedMinutes >= 0), "elapsedMinutes must be null or a non-negative number")
116
+ check(errors, manifest.lane === null || LANES.includes(manifest.lane), `lane must be null or one of ${LANES.join(", ")}`)
117
+ check(errors, manifest.interview === null || isObject(manifest.interview), "interview must be null or an object")
118
+ if (Array.isArray(manifest.deliverables)) manifest.deliverables.forEach((row, index) => validateDeliverable(row, index, errors))
119
+ else errors.push("deliverables must be an array")
120
+ if (Array.isArray(manifest.renders)) {
121
+ manifest.renders.forEach((render, index) => {
122
+ check(errors, isObject(render) && typeof render.path === "string" && Number.isInteger(render.page) && render.page >= 1, `renders[${index}] must be { path: string, page: integer >= 1 }`)
123
+ })
124
+ } else errors.push("renders must be an array")
125
+ if (isObject(manifest.gates)) {
126
+ for (const gate of GATE_NAMES) check(errors, GATE_STATUSES.includes(manifest.gates[gate]), `gates.${gate} must be one of ${GATE_STATUSES.join(", ")}`)
127
+ } else errors.push("gates must be an object")
128
+ if (Array.isArray(manifest.residualDefects)) manifest.residualDefects.forEach((defect, index) => validateDefect(defect, `residualDefects[${index}]`, errors))
129
+ else errors.push("residualDefects must be an array")
130
+ check(errors, manifest.repair === null || isObject(manifest.repair), "repair must be null or an object")
131
+ check(errors, manifest.sources === null || isObject(manifest.sources), "sources must be null or an object")
132
+ return { ok: errors.length === 0, errors }
133
+ }
134
+
135
+ function isMeasure(value) {
136
+ return Array.isArray(value) && value.length === 3 && value.every((n) => Number.isInteger(n) && n >= 0)
137
+ }
138
+
139
+ /** @returns {{ ok: boolean, errors: string[] }} */
140
+ export function validateRepairState(state) {
141
+ const errors = []
142
+ if (!isObject(state)) return { ok: false, errors: ["repair state must be an object (schemaVersion missing)"] }
143
+ check(errors, state.schemaVersion === SCHEMA_VERSION, `schemaVersion must be ${SCHEMA_VERSION}`)
144
+ check(errors, typeof state.startedAt === "string" && ISO_DATE.test(state.startedAt), "startedAt must be an ISO timestamp")
145
+ if (isObject(state.budget)) {
146
+ for (const key of ["maxAttempts", "plateauLimit", "wallClockMs"]) {
147
+ check(errors, Number.isInteger(state.budget[key]) && state.budget[key] > 0, `budget.${key} must be a positive integer`)
148
+ }
149
+ } else errors.push("budget must be an object")
150
+ if (Array.isArray(state.attempts)) {
151
+ state.attempts.forEach((attempt, index) => {
152
+ const at = `attempts[${index}]`
153
+ if (!isObject(attempt)) return errors.push(`${at} must be an object`)
154
+ check(errors, typeof attempt.at === "string" && ISO_DATE.test(attempt.at), `${at}.at must be an ISO timestamp`)
155
+ check(errors, isMeasure(attempt.measure), `${at}.measure must be [blocker, major, minor] counts`)
156
+ check(errors, typeof attempt.signature === "string", `${at}.signature must be a string`)
157
+ })
158
+ } else errors.push("attempts must be an array")
159
+ check(errors, Number.isInteger(state.consecutiveNonProgress) && state.consecutiveNonProgress >= 0, "consecutiveNonProgress must be a non-negative integer")
160
+ check(errors, Array.isArray(state.seenSignatures) && state.seenSignatures.every((s) => typeof s === "string"), "seenSignatures must be an array of strings")
161
+ return { ok: errors.length === 0, errors }
162
+ }
@@ -0,0 +1,177 @@
1
+ import { describe, expect, test } from "bun:test"
2
+
3
+ import {
4
+ DEFECT_CODES,
5
+ DELIVERABLE_FORMATS,
6
+ DELIVERABLE_STATUSES,
7
+ DESIGN_SPEC_SECTIONS,
8
+ GATE_NAMES,
9
+ GATE_STATUSES,
10
+ INTEGRITY_CODES,
11
+ LANES,
12
+ LAYOUT_CODES,
13
+ LINEAGE_MODES,
14
+ MANIFEST_CODES,
15
+ REPAIR_DEFAULTS,
16
+ SCHEMA_VERSION,
17
+ STATES,
18
+ STATIC_CODES,
19
+ validateOutcomeManifest,
20
+ validateRepairState,
21
+ } from "./contracts.mjs"
22
+
23
+ const SEVERITIES = new Set(["blocker", "major", "minor"])
24
+ const STATIC_CODES_EXPECTED = [
25
+ "korean_no_keep_all",
26
+ "palette_off_token",
27
+ "emoji_in_prose",
28
+ "em_dash_in_prose",
29
+ "en_dash_in_prose",
30
+ "heading_too_long",
31
+ "heading_generic",
32
+ "unsourced_number",
33
+ "chart_without_figure",
34
+ "chart_svg_no_text",
35
+ "chart_svg_missing_labels",
36
+ "korean_serif_font",
37
+ "section_without_citation",
38
+ "missing_closing_section",
39
+ "missing_citation_section",
40
+ "broken_asset_reference",
41
+ "missing_required_section",
42
+ ]
43
+ const LAYOUT_CODES_EXPECTED = [
44
+ "layout_overflow",
45
+ "layout_text_clipped",
46
+ "layout_scroll_container",
47
+ "layout_image_distorted",
48
+ "layout_sibling_overlap",
49
+ ]
50
+
51
+ function sampleManifest() {
52
+ return {
53
+ schemaVersion: SCHEMA_VERSION,
54
+ sessionDir: "/tmp/session/20260101-120000",
55
+ startedAt: "2026-01-01T12:00:00.000Z",
56
+ finishedAt: null,
57
+ elapsedMinutes: null,
58
+ lane: "no-format",
59
+ interview: null,
60
+ deliverables: [
61
+ { format: "pdf", status: "pending", promised: true, updatedAt: "2026-01-01T12:00:00.000Z" },
62
+ ],
63
+ renders: [],
64
+ gates: { static: "not_run", layout: "not_run", visual: "not_run", proofread: "not_run" },
65
+ residualDefects: [],
66
+ repair: null,
67
+ sources: null,
68
+ }
69
+ }
70
+
71
+ describe("DEFECT_CODES", () => {
72
+ test("#given the code table #when every code is inspected #then it has a severity, a hint, and an integrity flag", () => {
73
+ expect(Object.keys(DEFECT_CODES).sort()).toEqual([...STATIC_CODES_EXPECTED, "missing_promised_deliverable", ...LAYOUT_CODES_EXPECTED].sort())
74
+ for (const [code, entry] of Object.entries(DEFECT_CODES)) {
75
+ expect(SEVERITIES.has(entry.severity), `${code} severity`).toBe(true)
76
+ expect(typeof entry.hint).toBe("string")
77
+ expect(entry.hint.length).toBeGreaterThan(10)
78
+ expect(typeof entry.integrity).toBe("boolean")
79
+ }
80
+ })
81
+
82
+ test("#given the code table #when integrity codes are derived #then they are exactly the five blocker codes", () => {
83
+ expect([...INTEGRITY_CODES].sort()).toEqual([
84
+ "broken_asset_reference",
85
+ "missing_citation_section",
86
+ "missing_closing_section",
87
+ "missing_promised_deliverable",
88
+ "missing_required_section",
89
+ ])
90
+ for (const code of INTEGRITY_CODES) expect(DEFECT_CODES[code].severity).toBe("blocker")
91
+ for (const [code, entry] of Object.entries(DEFECT_CODES)) {
92
+ if (entry.severity === "blocker") expect(INTEGRITY_CODES.has(code), `${code} must be integrity`).toBe(true)
93
+ }
94
+ })
95
+
96
+ test("#given the frozen table #when a severity write is attempted #then it throws and the value is unchanged", () => {
97
+ expect(Object.isFrozen(DEFECT_CODES)).toBe(true)
98
+ expect(Object.isFrozen(DEFECT_CODES.korean_no_keep_all)).toBe(true)
99
+ expect(() => {
100
+ "use strict"
101
+ DEFECT_CODES.korean_no_keep_all.severity = "minor"
102
+ }).toThrow()
103
+ expect(DEFECT_CODES.korean_no_keep_all.severity).toBe("major")
104
+ })
105
+ })
106
+
107
+ describe("code groups", () => {
108
+ test("#given the code groups #when unioned #then they partition the code table exactly", () => {
109
+ expect([...STATIC_CODES].sort()).toEqual(STATIC_CODES_EXPECTED.slice().sort())
110
+ expect([...LAYOUT_CODES].sort()).toEqual(LAYOUT_CODES_EXPECTED.slice().sort())
111
+ expect([...MANIFEST_CODES]).toEqual(["missing_promised_deliverable"])
112
+ const union = [...STATIC_CODES, ...LAYOUT_CODES, ...MANIFEST_CODES].sort()
113
+ expect(union).toEqual(Object.keys(DEFECT_CODES).sort())
114
+ expect(new Set(union).size).toBe(union.length)
115
+ expect(Object.isFrozen(STATIC_CODES)).toBe(true)
116
+ })
117
+ })
118
+
119
+ describe("enums", () => {
120
+ test("#given the vocabulary #when read #then lanes, states, formats, statuses, gates, lineage modes are fixed lists", () => {
121
+ expect(LANES).toEqual(["template-strict", "template-vibe", "no-format", "edit-existing"])
122
+ expect(STATES).toEqual(["none", "partial", "complete"])
123
+ expect(DELIVERABLE_FORMATS).toEqual(["pdf", "docx", "html", "md", "latex", "slides", "post"])
124
+ expect(DELIVERABLE_STATUSES).toEqual(["pending", "delivered", "blocked_capability", "skipped", "failed"])
125
+ expect(GATE_NAMES).toEqual(["static", "layout", "visual", "proofread"])
126
+ expect(GATE_STATUSES).toEqual(["pass", "fail", "not_run"])
127
+ expect(LINEAGE_MODES).toEqual(["inline", "section"])
128
+ expect(DESIGN_SPEC_SECTIONS).toEqual([
129
+ "Extracted from", "Tokens", "Typography", "Layout", "Structure", "Figures", "Citations", "Open questions",
130
+ ])
131
+ expect(REPAIR_DEFAULTS).toEqual({ maxAttempts: 3, plateauLimit: 2, wallClockMs: 900_000 })
132
+ expect(Object.isFrozen(LANES)).toBe(true)
133
+ expect(Object.isFrozen(REPAIR_DEFAULTS)).toBe(true)
134
+ })
135
+ })
136
+
137
+ describe("validateOutcomeManifest", () => {
138
+ test("#given an empty object #when validated #then it fails naming schemaVersion", () => {
139
+ const result = validateOutcomeManifest({})
140
+ expect(result.ok).toBe(false)
141
+ expect(result.errors.some((e) => e.includes("schemaVersion"))).toBe(true)
142
+ })
143
+
144
+ test("#given a well-formed manifest #when validated #then it passes", () => {
145
+ expect(validateOutcomeManifest(sampleManifest())).toEqual({ ok: true, errors: [] })
146
+ })
147
+
148
+ test("#given a deliverable with an unknown status #when validated #then the error names the row", () => {
149
+ const m = sampleManifest()
150
+ m.deliverables[0].status = "done"
151
+ const result = validateOutcomeManifest(m)
152
+ expect(result.ok).toBe(false)
153
+ expect(result.errors.join("\n")).toMatch(/deliverables\[0\]\.status/)
154
+ })
155
+
156
+ test("#given a gate with an unknown value #when validated #then it fails", () => {
157
+ const m = sampleManifest()
158
+ m.gates.static = "ok"
159
+ expect(validateOutcomeManifest(m).ok).toBe(false)
160
+ })
161
+ })
162
+
163
+ describe("validateRepairState", () => {
164
+ test("#given a well-formed state #when validated #then it passes, and a bad measure fails", () => {
165
+ const good = {
166
+ schemaVersion: SCHEMA_VERSION,
167
+ startedAt: "2026-01-01T12:00:00.000Z",
168
+ budget: { maxAttempts: 3, plateauLimit: 2, wallClockMs: 900_000 },
169
+ attempts: [{ at: "2026-01-01T12:01:00.000Z", measure: [1, 2, 0], signature: "em_dash_in_prose|unsourced_number" }],
170
+ consecutiveNonProgress: 0,
171
+ seenSignatures: ["em_dash_in_prose|unsourced_number"],
172
+ }
173
+ expect(validateRepairState(good)).toEqual({ ok: true, errors: [] })
174
+ const bad = { ...good, attempts: [{ at: "x", measure: [1, 2], signature: "" }] }
175
+ expect(validateRepairState(bad).ok).toBe(false)
176
+ })
177
+ })
@@ -0,0 +1,204 @@
1
+ // css-lite.mjs - a tolerant, dependency-free CSS scanner for report gates and spec extraction.
2
+ //
3
+ // Never throws on malformed input: an unbalanced stylesheet yields the rules closed so far.
4
+ // Plain Node ESM, no imports.
5
+
6
+ const GROUP_AT = /^@(?:-[a-z]+-)?(?:media|supports|container|layer|document|scope|starting-style|keyframes)\b/i
7
+ const HEX = /(?<![\w&])#([0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{4}|[0-9a-f]{3})(?![\w-])/gi
8
+ const DARK_MEDIA = /prefers-color-scheme\s*:\s*dark/i
9
+ const DARK_THEME = /\[data-theme\s*=\s*["']?dark/i
10
+ const TOKEN_SCOPE = /(?:^|[\s,>])(?::root|html)\b|^\[data-theme/i
11
+
12
+ function stripComments(css) {
13
+ return css.replace(/\/\*[\s\S]*?(?:\*\/|$)/g, "")
14
+ }
15
+
16
+ /** Split on `separator` outside quotes and parentheses. */
17
+ function splitTopLevel(text, separator) {
18
+ const parts = []
19
+ let depth = 0
20
+ let quote = null
21
+ let current = ""
22
+ for (let i = 0; i < text.length; i++) {
23
+ const ch = text[i]
24
+ if (quote) {
25
+ if (ch === "\\") current += ch + (text[++i] ?? "")
26
+ else {
27
+ if (ch === quote) quote = null
28
+ current += ch
29
+ }
30
+ continue
31
+ }
32
+ if (ch === '"' || ch === "'") quote = ch
33
+ else if (ch === "(") depth++
34
+ else if (ch === ")") depth = Math.max(0, depth - 1)
35
+ else if (ch === separator && depth === 0) {
36
+ parts.push(current)
37
+ current = ""
38
+ continue
39
+ }
40
+ current += ch
41
+ }
42
+ parts.push(current)
43
+ return parts
44
+ }
45
+
46
+ function parseDeclarations(text) {
47
+ const declarations = []
48
+ for (const piece of splitTopLevel(text, ";")) {
49
+ const colon = piece.indexOf(":")
50
+ if (colon <= 0) continue
51
+ const rawProp = piece.slice(0, colon).trim()
52
+ const value = piece.slice(colon + 1).trim()
53
+ if (!rawProp || /[\s{}]/.test(rawProp)) continue
54
+ declarations.push({ prop: rawProp.startsWith("--") ? rawProp : rawProp.toLowerCase(), value })
55
+ }
56
+ return declarations
57
+ }
58
+
59
+ /**
60
+ * @returns {{ selector: string, declarations: { prop: string, value: string }[], atRule?: string }[]}
61
+ * atRule is the chain of enclosing grouping at-rule preludes joined by a space.
62
+ */
63
+ export function scanRules(css) {
64
+ if (typeof css !== "string") return []
65
+ const src = stripComments(css)
66
+ const slots = []
67
+ const stack = []
68
+ let buffer = ""
69
+ for (let i = 0; i < src.length; i++) {
70
+ const ch = src[i]
71
+ if (ch === '"' || ch === "'") {
72
+ let end = i + 1
73
+ while (end < src.length && src[end] !== ch) end += src[end] === "\\" ? 2 : 1
74
+ buffer += src.slice(i, end + 1)
75
+ i = end
76
+ } else if (ch === "{") {
77
+ const pieces = splitTopLevel(buffer, ";")
78
+ const prelude = pieces.pop().trim()
79
+ const frame = stack.at(-1)
80
+ if (frame && pieces.length) frame.body += `${pieces.join(";")};`
81
+ const group = GROUP_AT.test(prelude)
82
+ const slot = group ? -1 : slots.push(null) - 1
83
+ stack.push({ prelude, group, slot, body: "" })
84
+ buffer = ""
85
+ } else if (ch === "}") {
86
+ const frame = stack.pop()
87
+ if (frame && !frame.group) {
88
+ const atChain = stack.filter((entry) => entry.group).map((entry) => entry.prelude)
89
+ const rule = { selector: frame.prelude, declarations: parseDeclarations(frame.body + buffer) }
90
+ if (atChain.length) rule.atRule = atChain.join(" ")
91
+ slots[frame.slot] = rule
92
+ }
93
+ buffer = ""
94
+ } else if (ch === ";" && stack.length === 0) {
95
+ buffer = ""
96
+ } else {
97
+ buffer += ch
98
+ }
99
+ }
100
+ return slots.filter(Boolean)
101
+ }
102
+
103
+ function isDarkRule(rule) {
104
+ if (rule.atRule && DARK_MEDIA.test(rule.atRule)) return true
105
+ return DARK_THEME.test(rule.selector.replace(/:not\([^)]*\)/gi, ""))
106
+ }
107
+
108
+ /**
109
+ * Global design tokens from `:root`, `html`, and `[data-theme]` rules. Dark = inside
110
+ * `@media (prefers-color-scheme: dark)` or on a `[data-theme="dark"]` selector. Last value wins.
111
+ * @returns {{ light: Map<string, string>, dark: Map<string, string> }}
112
+ */
113
+ export function customProperties(css) {
114
+ const light = new Map()
115
+ const dark = new Map()
116
+ for (const rule of scanRules(css)) {
117
+ if (!rule.selector.split(",").some((part) => TOKEN_SCOPE.test(part.trim()))) continue
118
+ const target = isDarkRule(rule) ? dark : light
119
+ for (const { prop, value } of rule.declarations) if (prop.startsWith("--")) target.set(prop, value)
120
+ }
121
+ return { light, dark }
122
+ }
123
+
124
+ function splitFamilies(value) {
125
+ return splitTopLevel(value, ",")
126
+ .map((family) => family.trim().replace(/^(["'])(.*)\1$/, "$2").trim())
127
+ .filter(Boolean)
128
+ }
129
+
130
+ /** Font stack after the size token of a `font` shorthand, or null when the shorthand has none. */
131
+ function shorthandFamilies(value) {
132
+ const match = /(?:^|\s)[\d.]+(?:px|em|rem|pt|%|vw|vh|ch|ex)(?:\s*\/\s*\S+)?\s+(.+)$/i.exec(value)
133
+ return match ? match[1] : null
134
+ }
135
+
136
+ /** @returns {{ selector: string, families: string[] }[]} var() references resolve against light tokens. */
137
+ export function fontFamilies(css) {
138
+ const props = customProperties(css).light
139
+ const result = []
140
+ for (const rule of scanRules(css)) {
141
+ for (const { prop, value } of rule.declarations) {
142
+ const resolved = resolveVar(value, props)
143
+ const stack = prop === "font-family" ? resolved : prop === "font" ? shorthandFamilies(resolved) : null
144
+ if (stack === null) continue
145
+ const families = splitFamilies(stack)
146
+ if (families.length) result.push({ selector: rule.selector, families })
147
+ }
148
+ }
149
+ return result
150
+ }
151
+
152
+ /** #abc / #abcd / #aabbcc / #aabbccdd -> #aabbcc (lowercase, alpha dropped); anything else -> null. */
153
+ export function normalizeHex(hex) {
154
+ const match = /^#([0-9a-f]{3,8})$/i.exec(String(hex).trim())
155
+ if (!match || ![3, 4, 6, 8].includes(match[1].length)) return null
156
+ const digits = match[1].toLowerCase()
157
+ const six = digits.length <= 4 ? [...digits.slice(0, 3)].map((d) => d + d).join("") : digits.slice(0, 6)
158
+ return `#${six}`
159
+ }
160
+
161
+ /** Every hex color in `text`, normalized, in order of appearance, repeats kept. */
162
+ export function hexColors(text) {
163
+ if (typeof text !== "string") return []
164
+ return [...text.matchAll(HEX)].map((match) => normalizeHex(match[0]))
165
+ }
166
+
167
+ /** Unique px widths named in @media preludes (min-/max-width and range syntax), ascending. */
168
+ export function mediaBreakpoints(css) {
169
+ if (typeof css !== "string") return []
170
+ const widths = new Set()
171
+ for (const media of stripComments(css).matchAll(/@media\b([^{;]*)/gi)) {
172
+ for (const match of media[1].matchAll(/\b(?:min-|max-)?width\s*(?::|[<>]=?|=)\s*(\d+(?:\.\d+)?)px/gi)) {
173
+ widths.add(Number(match[1]))
174
+ }
175
+ }
176
+ return [...widths].sort((a, b) => a - b)
177
+ }
178
+
179
+ function lookup(props, name) {
180
+ if (props instanceof Map) return props.get(name)
181
+ return props && Object.hasOwn(props, name) ? props[name] : undefined
182
+ }
183
+
184
+ /** Substitute var(--name[, fallback]) from `props` (Map or object); unresolvable references keep their text. */
185
+ export function resolveVar(value, props, depth = 0) {
186
+ if (typeof value !== "string" || depth > 10) return value
187
+ let out = ""
188
+ let cursor = 0
189
+ for (;;) {
190
+ const start = value.indexOf("var(", cursor)
191
+ if (start === -1) return out + value.slice(cursor)
192
+ let end = start + 4
193
+ for (let open = 1; end < value.length && open > 0; end++) {
194
+ if (value[end] === "(") open++
195
+ else if (value[end] === ")") open--
196
+ }
197
+ const [name, ...rest] = splitTopLevel(value.slice(start + 4, end - 1), ",")
198
+ const fallback = rest.length ? rest.join(",").trim() : undefined
199
+ const replacement = lookup(props, name.trim()) ?? fallback
200
+ const original = value.slice(start, end)
201
+ out += value.slice(cursor, start) + (replacement === undefined ? original : resolveVar(replacement, props, depth + 1))
202
+ cursor = end
203
+ }
204
+ }