oh-my-opencode 5.1.47 → 5.1.48

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 (100) hide show
  1. package/dist/cli/index.js +15 -15
  2. package/dist/cli-node/index.js +15 -15
  3. package/dist/index.js +14 -14
  4. package/dist/skills/browser/runtime/omowright/manifest.json +1 -1
  5. package/dist/skills/ulw-research/SKILL.md +58 -1
  6. package/dist/skills/ulw-research/references/deliverable-phase.md +51 -9
  7. package/dist/skills/ulw-research/references/report-gates.md +2 -1
  8. package/dist/skills/ulw-research/scripts/contracts.mjs +3 -11
  9. package/dist/skills/ulw-research/scripts/contracts.test.ts +1 -0
  10. package/dist/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
  11. package/dist/skills/ulw-research/scripts/copy-returns-polish.test.ts +6 -6
  12. package/dist/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  13. package/dist/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
  14. package/dist/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
  15. package/dist/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
  16. package/dist/skills/ulw-research/scripts/fact-commands.mjs +6 -47
  17. package/dist/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  18. package/dist/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  19. package/dist/skills/ulw-research/scripts/report-tools.test.ts +245 -4
  20. package/dist/skills/ulw-research/scripts/source-commands.mjs +8 -3
  21. package/dist/tui.js +14 -14
  22. package/package.json +14 -14
  23. package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
  24. package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
  25. package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
  26. package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
  27. package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
  28. package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
  29. package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
  30. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
  31. package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
  32. package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
  33. package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
  34. package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
  35. package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
  36. package/packages/omo-codex/plugin/components/rules/package.json +1 -1
  37. package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
  38. package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
  39. package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
  40. package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
  41. package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
  42. package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
  43. package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
  44. package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
  45. package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
  46. package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
  47. package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
  48. package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
  49. package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
  50. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
  51. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
  52. package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
  53. package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
  54. package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
  55. package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
  56. package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
  57. package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
  58. package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
  59. package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
  60. package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
  61. package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
  62. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
  63. package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
  64. package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
  65. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
  66. package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
  67. package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
  68. package/packages/omo-codex/plugin/package-lock.json +12 -12
  69. package/packages/omo-codex/plugin/package.json +1 -1
  70. package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
  71. package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +58 -1
  72. package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +51 -9
  73. package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +2 -1
  74. package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +3 -11
  75. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  76. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
  77. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
  78. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
  79. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +6 -47
  80. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  81. package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  82. package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +8 -3
  83. package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
  84. package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
  85. package/packages/shared-skills/skills/ulw-research/SKILL.md +58 -1
  86. package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +51 -9
  87. package/packages/shared-skills/skills/ulw-research/references/report-gates.md +2 -1
  88. package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +3 -11
  89. package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +1 -0
  90. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
  91. package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-polish.test.ts +6 -6
  92. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
  93. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
  94. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
  95. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
  96. package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +6 -47
  97. package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.mjs +3 -2
  98. package/packages/shared-skills/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
  99. package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +245 -4
  100. package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +8 -3
@@ -0,0 +1,156 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+
6
+ import { FILL_INPUT_FORMAT_RULES, FILL_INPUT_FORMATS } from "./fact-commands-fill.mjs"
7
+ import { KIND_RULES } from "./fact-ledger-kinds.mjs"
8
+ import { FILL_FORMATS } from "./fact-fill.mjs"
9
+ import { meaningCheckEnabled as briefMeaningCheckEnabled } from "./fidelity-brief.mjs"
10
+ import { FIDELITY_VALUE_RULES, meaningCheckEnabled, parseSourceFloor, policyErrors, SOURCE_FLOOR_RULES, sourceInsideFloor } from "./fidelity-policy.mjs"
11
+
12
+ type PolicyRow = { fact_id: string; kind: string; status: string; source_id: string | null; operands: string[]; derived_from: string[] }
13
+ const policyRow = (fact_id: string, kind: string, source_id: string | null, operands: string[] = []): PolicyRow => ({ fact_id, kind, status: "active", source_id, operands, derived_from: [] })
14
+
15
+ /** policyErrors over one placeholder per row id under a parsed floor, as code and message pairs. */
16
+ function floorRefusals(rows: PolicyRow[], ids: string[], floor: ReturnType<typeof parseSourceFloor>) {
17
+ const found = ids.map((id, index) => ({ token: `{{${id}}}`, start: index, parsed: { id } }))
18
+ const refused = policyErrors(ids.map((id) => `{{${id}}}`).join(""), found, { rows }, tmpdir(), { derivedNone: false, floor })
19
+ return refused.errors.map((error: { code: string; message: string }) => [error.code, error.message])
20
+ }
21
+
22
+ const referenceValues = async (field: string) => {
23
+ const row = (await Bun.file(REFERENCE).text()).split("\n").find((line) => line.startsWith(`| \`${field}\` |`)) ?? ""
24
+ return [...(row.split("|")[2] ?? "").matchAll(/`([^`]+)`/gu)].map((match) => match[1])
25
+ }
26
+ import { pointedFlag } from "./source-commands.mjs"
27
+
28
+ const REFERENCE = join(import.meta.dir, "..", "references", "deliverable-phase.md")
29
+
30
+ function briefWithFloor(floor: string) {
31
+ const dir = mkdtempSync(join(tmpdir(), "policy-registry-"))
32
+ const lines = ["as of: latest at run start, 2026-10-23", "meaning check: on", `source floor: ${floor}`, "derived numbers: allowed", "languages: report en; sources en; quotes original", "identifier classes: none", "fallback: block-quote"]
33
+ writeFileSync(join(dir, "brief.md"), `## Fidelity\n${lines.map((line) => `${line} (answered_by: default)`).join("\n")}\n`)
34
+ return dir
35
+ }
36
+
37
+ const floorOf = (text: string) => {
38
+ const parsed = parseSourceFloor(text)
39
+ if (parsed === null) throw new Error(`parseSourceFloor(${text}) read no floor`)
40
+ return parsed
41
+ }
42
+
43
+ const derivedRefusal = (kind: string) => {
44
+ const row = { fact_id: "F1", kind, status: "active", source_id: null, operands: [], derived_from: [] }
45
+ const found = [{ token: "{{F1}}", start: 0, parsed: { id: "F1" } }]
46
+ const refused = policyErrors("{{F1}}", found, { rows: [row] }, tmpdir(), { derivedNone: true, floor: null })
47
+ return refused.errors.some((error: { code: string }) => error.code === "derived_not_allowed")
48
+ }
49
+
50
+ describe("fidelity-policy registry: copies must not return", () => {
51
+ test("#given every ledger kind #when derived numbers none refuses #then policyErrors agrees with FIDELITY_VALUE_RULES and assumed is refused", () => {
52
+ const kinds = Object.keys(KIND_RULES)
53
+ for (const kind of kinds) expect({ kind, refused: derivedRefusal(kind) }).toEqual({ kind, refused: FIDELITY_VALUE_RULES.derivedRefused(kind) })
54
+ for (const kind of FIDELITY_VALUE_RULES.derivedKinds) expect(kinds).toContain(kind)
55
+ expect(FIDELITY_VALUE_RULES.derivedRefused("assumed")).toBe(true)
56
+ })
57
+
58
+ test("#given each source floor form #when parsed #then the rule matches the registry and an unknown form is a usage error", () => {
59
+ expect(parseSourceFloor("standard")).toBeNull()
60
+ expect(parseSourceFloor("Primary Only")).toEqual({ kind: "primary" })
61
+ expect(parseSourceFloor("pointed only: S4, https://a.example/x")).toEqual({ kind: "pointed", list: ["S4", "https://a.example/x"] })
62
+ expect(parseSourceFloor("classes: example.com, gov.uk")).toEqual({ kind: "classes", list: ["example.com", "gov.uk"] })
63
+ for (const rule of SOURCE_FLOOR_RULES) expect(Object.isFrozen(rule)).toBe(true)
64
+ expect(() => parseSourceFloor("only nice sources")).toThrow(/source floor/)
65
+ })
66
+
67
+ test("#given pointed and classes floors #when evaluated #then ids, urls, hosts, and suffixes match and notexample.com does not", () => {
68
+ const pointed = floorOf("pointed only: S4, https://a.example/x")
69
+ expect(sourceInsideFloor(pointed, "S4", { url: null })).toBe(true)
70
+ expect(sourceInsideFloor(pointed, "S9", { url: "https://a.example/x" })).toBe(true)
71
+ expect(sourceInsideFloor(pointed, "S9", { url: "https://a.example/y" })).toBe(false)
72
+ const classes = floorOf("classes: example.com")
73
+ for (const [url, inside] of [["https://example.com/a", true], ["https://docs.example.com/a", true], ["https://notexample.com/a", false], [null, false]] as const) {
74
+ expect({ url, inside: sourceInsideFloor(classes, "S1", { url }) }).toEqual({ url, inside })
75
+ }
76
+ expect(sourceInsideFloor({ kind: "primary" }, "S1", { provenance: "primary" })).toBe(true)
77
+ expect(sourceInsideFloor({ kind: "primary" }, "S1", { provenance: "secondary" })).toBe(false)
78
+ })
79
+
80
+ test("#given a brief floor #when pointedFlag reads it #then it agrees with parseSourceFloor for every form", () => {
81
+ const url = "https://a.example/x"
82
+ for (const floor of ["pointed only: S4, https://a.example/x", "pointed only: S4", "standard", "primary only", "classes: a.example"]) {
83
+ const dir = briefWithFloor(floor)
84
+ try {
85
+ const rule = (() => {
86
+ try {
87
+ return parseSourceFloor(floor)
88
+ } catch {
89
+ return null
90
+ }
91
+ })()
92
+ expect({ floor, flag: pointedFlag(dir, url) }).toEqual({ floor, flag: rule?.kind === "pointed" && rule.list.includes(url) })
93
+ } finally {
94
+ rmSync(dir, { recursive: true, force: true })
95
+ }
96
+ }
97
+ })
98
+
99
+ test("#given a malformed or pending floor #when pointedFlag reads it #then malformed is a usage error and pending is not pointed", () => {
100
+ const malformed = briefWithFloor("not a floor")
101
+ const pending = mkdtempSync(join(tmpdir(), "policy-registry-"))
102
+ writeFileSync(join(pending, "brief.md"), "## Fidelity\nsource floor: pending (answered_by: pending)\n")
103
+ try {
104
+ expect(() => pointedFlag(malformed, "https://a.example/x")).toThrow(/source floor "not a floor"/)
105
+ expect(pointedFlag(pending, "https://a.example/x")).toBe(false)
106
+ } finally {
107
+ for (const dir of [malformed, pending]) rmSync(dir, { recursive: true, force: true })
108
+ }
109
+ })
110
+
111
+ test("#given derived numbers and fallback values #when read #then only their table values pass, any case, and anything else names field and value", () => {
112
+ expect(["none", "None", " none "].map((value) => FIDELITY_VALUE_RULES.derivedNone(value))).toEqual([true, true, true])
113
+ expect(["allowed", "Allowed"].map((value) => FIDELITY_VALUE_RULES.derivedNone(value))).toEqual([false, false])
114
+ for (const value of ["nope", "no", "n/a", "none.", "measured only"]) expect(() => FIDELITY_VALUE_RULES.derivedNone(value)).toThrow(`derived numbers "${value}" is not allowed, none`)
115
+ expect(["block-quote", "Omit"].map((value) => FIDELITY_VALUE_RULES.fallback(value))).toEqual(["quote", "omit"])
116
+ for (const value of ["drop", "omit sentence", "quote"]) expect(() => FIDELITY_VALUE_RULES.fallback(value)).toThrow(`fallback "${value}" is not block-quote, omit`)
117
+ })
118
+
119
+ test("#given the deliverable reference #when its derived numbers and fallback rows are read #then they name exactly the registry tables", async () => {
120
+ for (const [field, table] of Object.entries(FIDELITY_VALUE_RULES.tables)) expect({ field, values: await referenceValues(field) }).toEqual({ field, values: Object.keys(table) })
121
+ expect(Object.keys(FIDELITY_VALUE_RULES.tables)).toEqual(["derived numbers", "fallback"])
122
+ })
123
+
124
+ test("#given sourceless rows #when judged under a strict floor and under standard #then strict refuses each, a derived row over one included, and standard refuses none", () => {
125
+ const rows = [policyRow("F1", "measured", "S1"), policyRow("A1", "assumed", null), policyRow("D1", "derived", null, ["F1", "A1"]), policyRow("D2", "derived", null, ["F9"])]
126
+ const ids = ["F1", "A1", "D1", "D2"]
127
+ expect(floorRefusals(rows, ids, floorOf("pointed only: S1"))).toEqual([
128
+ ["source_outside_floor", "A1 rests on no source, outside the source floor"],
129
+ ["source_outside_floor", "D1 rests on no source, outside the source floor"],
130
+ ["source_outside_floor", "D2 rests on no source, outside the source floor"],
131
+ ])
132
+ expect(floorRefusals(rows, ids, parseSourceFloor("standard"))).toEqual([])
133
+ })
134
+
135
+ test("#given the meaning check value rule #when read through the policy or the brief #then both are the same function", () => {
136
+ expect(meaningCheckEnabled).toBe(briefMeaningCheckEnabled)
137
+ for (const value of ["on", "off", "Off", "on; force strong: money", "offline"]) {
138
+ expect({ value, on: FIDELITY_VALUE_RULES.meaningCheck(value) }).toEqual({ value, on: meaningCheckEnabled({ fields: { "meaning check": value } }) })
139
+ }
140
+ })
141
+
142
+ test("#given the fill input vocabulary #when derived #then every input maps to a FILL_FORMATS member and every output format is reachable", () => {
143
+ const target = (format: string) => (FILL_INPUT_FORMAT_RULES.aliases as Record<string, string>)[format] ?? format
144
+ for (const format of FILL_INPUT_FORMATS) expect(FILL_FORMATS as readonly string[]).toContain(target(format))
145
+ for (const format of FILL_FORMATS) expect(FILL_INPUT_FORMATS as readonly string[]).toContain(format)
146
+ for (const format of Object.values(FILL_INPUT_FORMAT_RULES.extensions)) expect(FILL_INPUT_FORMATS as readonly string[]).toContain(format)
147
+ expect(FILL_INPUT_FORMAT_RULES.aliases).toEqual({ tex: "md" })
148
+ })
149
+
150
+ test("#given the deliverable reference #when its source floor row is read #then it names exactly the SOURCE_FLOOR_RULES forms", async () => {
151
+ const row = (await Bun.file(REFERENCE).text()).split("\n").find((line) => line.startsWith("| `source floor` |")) ?? ""
152
+ const values = row.split("|")[2] ?? ""
153
+ const forms = [...values.matchAll(/`([^`]+)`/gu)].map((match) => match[1])
154
+ expect(forms).toEqual(SOURCE_FLOOR_RULES.map((rule) => rule.form))
155
+ })
156
+ })
@@ -78,7 +78,7 @@ describe("the polish edit contract is polish-apply's", () => {
78
78
  return "refused"
79
79
  }
80
80
  })()
81
- const cli = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "out.html")]))
81
+ const cli = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "out.html")]))
82
82
  expect(cli === "ok" ? "ok" : "refused").toBe(direct)
83
83
  if (cli !== "ok") expect(cli).toContain(editsPath)
84
84
  })
@@ -87,7 +87,7 @@ describe("the polish edit contract is polish-apply's", () => {
87
87
 
88
88
  describe("the ledger loader is one shared helper", () => {
89
89
  const pairs = (extra: string[]) => () => runFactMeaning("pairs", [reportPath, "--brief", BRIEF, "--out", join(dir, "pairs.json"), ...extra])
90
- const diff = (extra: string[]) => () => runFactPolish("polish-diff", ["--before", reportPath, "--after", reportPath, ...extra])
90
+ const diff = (extra: string[]) => () => runFactPolish("polish-diff", ["--before", reportPath, "--after", reportPath, "--brief", BRIEF, ...extra])
91
91
 
92
92
  test("#given the command modules #when read #then neither defines its own ledger loader or session resolver", () => {
93
93
  expect(polishCommand.ledgerFrom).toBe(ledgerFrom)
@@ -122,7 +122,7 @@ describe("the edition format comes from verify's grammar", () => {
122
122
  expect(source("fact-commands-polish.mjs")).not.toMatch(/md\|markdown/u)
123
123
  for (const name of ["r.md", "R.MD", "r.markdown", "R.Markdown", "r.html", "r.HTML"]) {
124
124
  const path = file(name, readFileSync(reportPath, "utf8"))
125
- const result = outcome(() => runFactPolish("polish-apply", [path, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "out.html")]))
125
+ const result = outcome(() => runFactPolish("polish-apply", [path, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "out.html")]))
126
126
  expect(result.includes("takes the filled HTML edition"), name).toBe(editionFormatOf(path, undefined) === "md")
127
127
  }
128
128
  })
@@ -130,7 +130,7 @@ describe("the edition format comes from verify's grammar", () => {
130
130
 
131
131
  describe("polish-diff counts with contracts and exits on blockers only", () => {
132
132
  type DiffRun = { exitCode: number; json: { counts: Record<string, number>; defects: { severity: string }[] } }
133
- const run = (before: string, after: string) => runFactPolish("polish-diff", ["--before", file("b.html", doc(before)), "--after", file("a.html", doc(after)), "--ledger", ledgerPath]) as unknown as DiffRun
133
+ const run = (before: string, after: string) => runFactPolish("polish-diff", ["--before", file("b.html", doc(before)), "--after", file("a.html", doc(after)), "--ledger", ledgerPath, "--brief", BRIEF]) as unknown as DiffRun
134
134
 
135
135
  test("#given every registry code #when polishDiffFails classifies it #then only registry blockers fail", () => {
136
136
  for (const [code, entry] of Object.entries(DEFECT_CODES) as [string, { severity: string }][]) expect(polishDiffFails([{ code, severity: "minor" }]), code).toBe(entry.severity === "blocker")
@@ -161,7 +161,7 @@ describe("fact polish-apply prints every rejection reason verbatim", () => {
161
161
  test("#given an edit holding & #when fact polish-apply runs #then the printed line carries polish-apply's own markup reason", () => {
162
162
  const before = "Revenue & profit rose across the year"
163
163
  const editsPath = file("edits-markup-ampersand.json", JSON.stringify([{ before, after: "Revenue and profit rose across the year", reason: "wording" }]))
164
- const run = runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "ampersand.html")]) as unknown as ApplyRun
164
+ const run = runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "ampersand.html")]) as unknown as ApplyRun
165
165
 
166
166
  expect(run.exitCode).toBe(0)
167
167
  expect(run.json.data.rejected).toBe(1)
@@ -203,7 +203,7 @@ describe("polish-apply writes atomically and honours --format", () => {
203
203
 
204
204
  test("#given --format md on an .html report #when polish-apply runs #then it refuses the Markdown edition", () => {
205
205
  const editsPath = file("edits-format.json", "[]")
206
- const result = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--out", join(dir, "fmt.html"), "--format", "md"]))
206
+ const result = outcome(() => runFactPolish("polish-apply", [reportPath, "--edits", editsPath, "--ledger", ledgerPath, "--brief", BRIEF, "--out", join(dir, "fmt.html"), "--format", "md"]))
207
207
  expect(result).toContain("takes the filled HTML edition")
208
208
  })
209
209
  })
@@ -0,0 +1,86 @@
1
+ // fact-commands-fill.mjs - `fact fill`, the reader-facing fill of a placeholder source.
2
+ //
3
+ // It refuses an unlocked brief (fidelity_interview_incomplete) and applies the locked `## Fidelity`
4
+ // fields: `derived numbers: none` and the source floor refuse rows (derived_not_allowed,
5
+ // source_outside_floor, evaluated at every fill), `languages` decides whether a translation after a
6
+ // quote binds to it, and `fallback` picks the block quote or omission for --replace-failed. Any
7
+ // error writes no edition, fill map, or state; the one write that survives a refusal is the
8
+ // ledger's cached outside_floor per row (skipped under --view). The fill map records brief_sha256,
9
+ // the locked block fact verify checks it against. node: builtins and sibling imports only.
10
+
11
+ import { readFileSync, writeFileSync } from "node:fs"
12
+ import { dirname, extname, resolve } from "node:path"
13
+
14
+ import { CliError, sha256Of, writeJsonAtomic } from "./cli-support.mjs"
15
+ import { DEFECT_CODES } from "./contracts.mjs"
16
+ import { FILL_FORMATS, fill } from "./fact-fill.mjs"
17
+ import { readLedger, writeLedger } from "./fact-ledger.mjs"
18
+ import { briefHashOf, fillPolicy, lockedBrief, policyErrors } from "./fidelity-policy.mjs"
19
+ import { readState, writeState } from "./fidelity-state.mjs"
20
+ import { findPlaceholders } from "./placeholder.mjs"
21
+ import { sessionDirOf } from "./source-commands.mjs"
22
+
23
+ /** The CLI's input vocabulary: fill's output formats plus aliases (tex is filled as md), and the extension defaults. */
24
+ export const FILL_INPUT_FORMAT_RULES = Object.freeze({
25
+ aliases: Object.freeze({ tex: "md" }),
26
+ extensions: Object.freeze({ ".md": "md", ".tex": "tex" }),
27
+ })
28
+ export const FILL_INPUT_FORMATS = Object.freeze([...FILL_FORMATS, ...Object.keys(FILL_INPUT_FORMAT_RULES.aliases)])
29
+ const fillFormatOf = (format) => FILL_INPUT_FORMAT_RULES.aliases[format] ?? format
30
+
31
+ function readText(path) {
32
+ try {
33
+ return readFileSync(path, "utf8")
34
+ } catch (error) {
35
+ throw new CliError(`cannot read ${path}: ${error.code ?? error.message}`)
36
+ }
37
+ }
38
+
39
+ /** The inline edition: local stylesheets and scripts inlined, relative image paths made absolute. */
40
+ function inlineAssets(html, baseDir) {
41
+ const local = (ref) => !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(ref)
42
+ return html
43
+ .replace(/<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi, (tag) => {
44
+ const href = /\bhref=["']([^"']+)["']/i.exec(tag)?.[1]
45
+ return href && local(href) ? `<style>${readText(resolve(baseDir, href))}</style>` : tag
46
+ })
47
+ .replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)><\/script>/gi, (tag, pre, src, post) => (local(src) ? `<script${pre}${post}>${readText(resolve(baseDir, src))}</script>` : tag))
48
+ .replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (tag, pre, src, post) => (local(src) && !src.startsWith("/") ? `${pre}${resolve(baseDir, src)}${post}` : tag))
49
+ }
50
+
51
+ /** @param {{ positionals: string[], flags: Record<string, any> }} args parsed by runFact */
52
+ export function runFactFill(args) {
53
+ const [source] = args.positionals
54
+ const { flags } = args
55
+ if (!source || (flags.out === undefined && !flags.view)) throw new CliError("usage: fact fill <src> (--out <path> | --view) [--format html|md|tex] [--inline] [--replace-failed] [--brief brief.md]")
56
+ const format = flags.format ?? FILL_INPUT_FORMAT_RULES.extensions[extname(source).toLowerCase()] ?? "html"
57
+ if (!FILL_INPUT_FORMATS.includes(format)) throw new CliError(`--format must be ${FILL_INPUT_FORMATS.slice(0, -1).join(", ")}, or ${FILL_INPUT_FORMATS.at(-1)}`)
58
+ if (flags.inline && format !== "html") throw new CliError("--inline applies to the html edition only")
59
+ const sessionDir = sessionDirOf(flags)
60
+ const { parsed, refusal } = lockedBrief("fact fill", flags)
61
+ if (refusal) return refusal
62
+ const policy = fillPolicy(parsed)
63
+ const text = readText(source)
64
+ const ledger = readLedger(sessionDir)
65
+ const refused = policyErrors(text, findPlaceholders(text), ledger, sessionDir, policy)
66
+ if (refused.cacheChanged && !flags.view) writeLedger(sessionDir, ledger)
67
+ const state = flags["replace-failed"] ? readState(sessionDir) : null
68
+ const result = fill(text, ledger, { format: fillFormatOf(format), view: flags.view === true, fallback: policy.fallback, ...(state ? { replaceFailed: state } : {}) })
69
+ const warnings = result.warnings
70
+ if (result.out === null || refused.errors.length > 0) {
71
+ const filled = result.errors.map((error) => ({ ...error, severity: DEFECT_CODES[error.code]?.severity ?? "blocker", hint: DEFECT_CODES[error.code]?.hint ?? null }))
72
+ const defects = [...refused.errors, ...filled]
73
+ return { json: { command: "fact fill", status: "fail", exitCode: 1, defects, warnings }, summary: `fact fill: FAIL - ${defects.length} error(s), nothing written`, exitCode: 1 }
74
+ }
75
+ const entries = policy.translations ? result.fillMap : result.fillMap.filter((entry) => entry.translates === undefined)
76
+ const out = flags.inline ? inlineAssets(result.out, dirname(resolve(source))) : result.out
77
+ if (flags.view) return { text: out, summary: `fact fill --view: ${entries.length} pointer(s), ${warnings.length} warning(s)`, exitCode: 0 }
78
+ writeFileSync(flags.out, out)
79
+ if (state) writeState(sessionDir, state)
80
+ const artifact = sha256Of(out)
81
+ const map = `${resolve(flags.out)}.fill-map.json`
82
+ const brief = briefHashOf(parsed)
83
+ writeJsonAtomic(map, { schemaVersion: 1, format, artifact: resolve(flags.out), artifact_sha256: artifact, brief_sha256: brief, entries })
84
+ const data = { out: resolve(flags.out), map, artifact_sha256: artifact, brief_sha256: brief, occurrences: entries.length, warnings }
85
+ return { json: { command: "fact fill", status: "pass", exitCode: 0, defects: [], data }, summary: `fact fill: wrote ${flags.out} (${entries.length} pointer(s))`, exitCode: 0 }
86
+ }
@@ -8,7 +8,8 @@
8
8
  // fidelity-calibration.jsonl: a header with the promotion criterion on first write, then per run
9
9
  // one run line plus one line per flagged pair. The run id hashes the inputs only, so a rerun or a
10
10
  // --dispute rerun of the same inputs replaces that run's lines in place (disputes carry forward)
11
- // instead of counting a second run. Neither command calls a model or the network.
11
+ // instead of counting a second run. Both refuse an unlocked brief (fidelity_interview_incomplete) before
12
+ // reading anything else. Neither command calls a model or the network.
12
13
 
13
14
  import { existsSync, readFileSync } from "node:fs"
14
15
  import { dirname, join, resolve } from "node:path"
@@ -18,7 +19,8 @@ import { countDefects } from "./contracts.mjs"
18
19
  import { ledgerFrom, markdownAsHtml, readEdition, sessionOf } from "./fact-commands-verify.mjs"
19
20
  import { pairsChangedSince } from "./fact-commands-polish.mjs"
20
21
  import { extractPairs, PAIR_KINDS } from "./fact-pairs.mjs"
21
- import { FIDELITY_INCOMPLETE, meaningCheckEnabled, requireLockedBrief } from "./fidelity-brief.mjs"
22
+ import { FIDELITY_INCOMPLETE } from "./fidelity-brief.mjs"
23
+ import { briefPathOf, lockedBrief, meaningCheckEnabled } from "./fidelity-policy.mjs"
22
24
  import { MEANING_RECORD, writeFidelityRecord } from "./fidelity-records.mjs"
23
25
  import { blocksDelivery } from "./gates-fidelity.mjs"
24
26
  import { ingestVerdicts, unverifiedDefects, VERDICT_SCHEMA } from "./meaning-check.mjs"
@@ -36,25 +38,15 @@ const inSession = (session, name, flag) => {
36
38
  return join(session, name)
37
39
  }
38
40
 
39
- function briefState(flags, session) {
40
- const path = flags.brief ?? inSession(session, "brief.md", "--brief")
41
- const lock = requireLockedBrief(path)
42
- if (lock.exitCode === 2) throw new CliError(`${path}: malformed ## Fidelity block: ${lock.errors.join("; ")}`)
43
- return { path, locked: lock.locked, off: !meaningCheckEnabled(lock.parsed) }
44
- }
45
-
46
41
  function runPairs(args) {
47
42
  const [report] = args.positionals
48
43
  const { flags } = args
49
44
  if (!report) throw new CliError("usage: fact pairs <report> [--format html|md] [--ledger F] [--claims claims.json] [--changed-since <fill-map.json>] [--brief brief.md] [--out pairs.json]")
50
45
  const session = sessionOf(flags)
51
46
  const out = resolve(flags.out ?? inSession(session, PAIRS_FILE, "--out"))
52
- const brief = briefState(flags, session)
53
- if (!brief.locked) {
54
- const json = { command: "fact pairs", status: "fail", exitCode: 1, error: "fidelity_interview_incomplete", message: `${brief.path} has no locked ## Fidelity block` }
55
- return { json, summary: `fact pairs: ${json.message}`, exitCode: 1 }
56
- }
57
- if (brief.off) {
47
+ const { parsed, refusal } = lockedBrief("fact pairs", flags)
48
+ if (refusal) return refusal
49
+ if (!meaningCheckEnabled(parsed)) {
58
50
  const record = { schemaVersion: 1, skipped: true, reason: "meaning check: off", pairs: [] }
59
51
  writeJsonAtomic(out, record)
60
52
  return { json: { command: "fact pairs", status: "skipped", exitCode: 0, data: { out, ...record } }, summary: "fact pairs: skipped (meaning check: off)", exitCode: 0 }
@@ -125,23 +117,23 @@ function recordCalibration(path, lines, runId, pairsSha) {
125
117
  }
126
118
 
127
119
  /** A `{ skipped: true }` pairs file is accepted only when the locked brief turns the meaning check off. */
128
- function skippedRefusal(flags, session) {
129
- const brief = briefState(flags, session)
130
- if (!brief.locked) return { error: FIDELITY_INCOMPLETE, message: `${brief.path} has no locked ## Fidelity block, so a skipped meaning check cannot be accepted` }
131
- if (!brief.off) return { error: "meaning_check_required", message: `${flags.pairs} says skipped but ${brief.path} has meaning check on; rerun fact pairs` }
120
+ function skippedRefusal(flags, parsed) {
121
+ if (meaningCheckEnabled(parsed)) return { error: "meaning_check_required", message: `${flags.pairs} says skipped but ${briefPathOf(flags)} has meaning check on; rerun fact pairs` }
132
122
  return null
133
123
  }
134
124
 
135
125
  function runMeaningCheck(args) {
136
126
  const { flags } = args
137
- const usage = "usage: fact meaning-check --pairs pairs.json --verdicts cheap.json [--verdicts strong.json] [--dispute P<n> --reason TEXT] [--run-id ID] [--calibration F] [--out F]"
127
+ const usage = "usage: fact meaning-check --pairs pairs.json --verdicts cheap.json [--verdicts strong.json] [--dispute P<n> --reason TEXT] [--run-id ID] [--calibration F] [--brief brief.md] [--out F]"
138
128
  if (flags.pairs === undefined || !Array.isArray(flags.verdicts)) throw new CliError(usage)
139
129
  const session = sessionOf(flags)
130
+ const { parsed, refusal } = lockedBrief("fact meaning-check", flags)
131
+ if (refusal) return { ...refusal, json: { ...refusal.json, error: FIDELITY_INCOMPLETE } }
140
132
  const pairsText = readFileSync(flags.pairs, "utf8")
141
133
  const pairsDoc = readJsonFile(flags.pairs)
142
134
  if (pairsDoc?.skipped === true) {
143
- const refusal = skippedRefusal(flags, session)
144
- if (refusal !== null) return { json: { command: "fact meaning-check", status: "fail", exitCode: 1, ...refusal }, summary: `fact meaning-check: ${refusal.message}`, exitCode: 1 }
135
+ const skipped = skippedRefusal(flags, parsed)
136
+ if (skipped !== null) return { json: { command: "fact meaning-check", status: "fail", exitCode: 1, ...skipped }, summary: `fact meaning-check: ${skipped.message}`, exitCode: 1 }
145
137
  return { json: { command: "fact meaning-check", status: "skipped", exitCode: 0, defects: [] }, summary: JSON.stringify({ command: "fact meaning-check", status: "skipped" }), exitCode: 0 }
146
138
  }
147
139
  if (typeof pairsDoc?.artifact_sha256 !== "string" || typeof pairsDoc.report !== "string") throw new CliError(`${flags.pairs} names no artifact_sha256 and report, so the meaning record cannot be bound to an edition; rerun fact pairs`)
@@ -19,6 +19,7 @@ import { CliError, parseArgs, readJsonFile, sha256Of, writeJsonAtomic, writeText
19
19
  import { countDefects, DEFECT_CODES } from "./contracts.mjs"
20
20
  import { editionFormatOf, ledgerFrom, markdownAsHtml, sessionOf } from "./fact-commands-verify.mjs"
21
21
  import { extractPairs } from "./fact-pairs.mjs"
22
+ import { lockedBrief } from "./fidelity-policy.mjs"
22
23
  import { applyEdits, POLISH_EDIT_FIELDS, validatePolishEdits } from "./polish-apply.mjs"
23
24
  import { diffPolish } from "./polish-diff.mjs"
24
25
  import { normalizeText } from "./text-normalize.mjs"
@@ -57,8 +58,10 @@ const rejectionLine = (entry, edit) => ` rejected edit ${entry.index} ("${befor
57
58
  function runPolishApply(args) {
58
59
  const [report] = args.positionals
59
60
  const { flags } = args
60
- if (!report || flags.edits === undefined || flags.out === undefined) throw new CliError("usage: fact polish-apply <report.html> --edits polish-edits.json --out <report.polished.html> [--ledger F] [--session-dir D]")
61
+ if (!report || flags.edits === undefined || flags.out === undefined) throw new CliError("usage: fact polish-apply <report.html> --edits polish-edits.json --out <report.polished.html> [--ledger F] [--brief brief.md] [--session-dir D]")
61
62
  if (editionFormatOf(report, flags.format) === "md") throw new CliError("fact polish-apply takes the filled HTML edition; polish a Markdown edition through its HTML source")
63
+ const { refusal } = lockedBrief("fact polish-apply", flags)
64
+ if (refusal) return refusal
62
65
  const html = readText(report)
63
66
  const editsText = readText(flags.edits)
64
67
  const edits = readJsonFile(flags.edits)
@@ -79,7 +82,9 @@ function runPolishApply(args) {
79
82
 
80
83
  function runPolishDiff(args) {
81
84
  const { flags } = args
82
- if (flags.before === undefined || flags.after === undefined) throw new CliError("usage: fact polish-diff --before <pre-polish.html> --after <polished.html> [--ledger F] [--out polish-defects.json] [--session-dir D]")
85
+ if (flags.before === undefined || flags.after === undefined) throw new CliError("usage: fact polish-diff --before <pre-polish.html> --after <polished.html> [--ledger F] [--out polish-defects.json] [--brief brief.md] [--session-dir D]")
86
+ const { refusal } = lockedBrief("fact polish-diff", flags)
87
+ if (refusal) return refusal
83
88
  const after = readText(flags.after)
84
89
  const defects = diffPolish(readText(flags.before), after, ledgerFrom(flags)).map((defect) => ({ ...defect, hint: DEFECT_CODES[defect.code]?.hint ?? null }))
85
90
  const counts = countDefects(defects)
@@ -8,6 +8,11 @@
8
8
  // escaped HTML projection (same line numbers) and checked with its fill map, which is the only
9
9
  // record of where its values sit; LaTeX is verified through its Markdown source.
10
10
  //
11
+ // The locked brief is re-judged here, not only at fill: the edition's fill map (--fill-map, else
12
+ // `<report>.fill-map.json` when present) must carry the current brief's brief_sha256
13
+ // (brief_changed), and policyErrors re-runs over every data-fact the edition carries, so an
14
+ // edition that violates the current derived-numbers or source-floor rule fails here too.
15
+ //
11
16
  // The delivery barrier that reads these records (and every other fidelity record) is
12
17
  // fidelity-records.mjs.
13
18
 
@@ -19,6 +24,7 @@ import { countDefects } from "./contracts.mjs"
19
24
  import { recompute } from "./fact-derive.mjs"
20
25
  import { readLedger } from "./fact-ledger.mjs"
21
26
  import { validateLedger } from "./fact-ledger-schema.mjs"
27
+ import { briefBindingErrors, fillPolicy, lockedBrief, policyErrors } from "./fidelity-policy.mjs"
22
28
  import { FIDELITY_RECORD, writeFidelityRecord } from "./fidelity-records.mjs"
23
29
  import { blocksDelivery, checkFidelity } from "./gates-fidelity.mjs"
24
30
  import { parseMarkdown } from "./md-lite.mjs"
@@ -159,8 +165,11 @@ export function runFactVerify(args) {
159
165
  const [report] = args.positionals
160
166
  const { flags } = args
161
167
  if (!report) throw new CliError("usage: fact verify <report> [--format html|md] [--sources D] [--claims claims.json] [--fill-map F] [--brief brief.md] [--out fidelity-defects.json]")
162
- const edition = readEdition(report, flags)
163
168
  const sessionDir = sessionDirOf(flags)
169
+ const { parsed, refusal } = lockedBrief("fact verify", flags)
170
+ if (refusal) return refusal
171
+ const policy = fillPolicy(parsed)
172
+ const edition = readEdition(report, flags)
164
173
  const claimsPath = flags.claims ?? join(sessionDir, "claims.json")
165
174
  const briefPath = flags.brief ?? join(sessionDir, "brief.md")
166
175
  const ledger = readLedger(sessionDir)
@@ -174,10 +183,15 @@ export function runFactVerify(args) {
174
183
  baseDir: dirname(resolve(report)),
175
184
  recompute,
176
185
  })
177
- const counts = countDefects(result.defects)
178
- const status = result.defects.some(blocksDelivery) ? "fail" : "pass"
186
+ const checked = projected?.html ?? edition.text
187
+ const pointers = [...checked.matchAll(/\bdata-fact="([^"]+)"/gu)].map((match) => ({ token: match[0], start: match.index, parsed: { id: match[1] } }))
188
+ const mapPath = flags["fill-map"] ?? `${report}.fill-map.json`
189
+ const bound = existsSync(mapPath) ? briefBindingErrors(readJsonFile(mapPath), mapPath, parsed) : []
190
+ const defects = [...bound, ...policyErrors(checked, pointers, ledger, sessionDir, policy).errors, ...result.defects]
191
+ const counts = countDefects(defects)
192
+ const status = defects.some(blocksDelivery) ? "fail" : "pass"
179
193
  const exitCode = status === "fail" ? 1 : 0
180
194
  const out = resolve(flags.out ?? join(sessionDir, FIDELITY_RECORD))
181
- const record = writeFidelityRecord(out, "fidelity", { command: "fact verify", status, exitCode, format: edition.format, report: resolve(report), artifact_sha256: sha256Of(edition.text), defects: result.defects, counts, gatesRun: result.gatesRun })
195
+ const record = writeFidelityRecord(out, "fidelity", { command: "fact verify", status, exitCode, format: edition.format, report: resolve(report), artifact_sha256: sha256Of(edition.text), defects, counts, gatesRun: result.gatesRun })
182
196
  return { json: { ...record, out }, summary: `fact verify: ${status.toUpperCase()} - ${counts.blocker} blocker, ${counts.major} major, ${counts.minor} minor`, exitCode }
183
197
  }
@@ -1,22 +1,19 @@
1
- // fact-commands.mjs - the `fact` subcommands of report-tools: add, quote, retract, export, fill,
2
- // derive, verify (fact-commands-verify.mjs), pairs and meaning-check (fact-commands-meaning.mjs), polish-apply and
3
- // polish-diff (fact-commands-polish.mjs). Every value reaches the ledger from a source
1
+ // fact-commands.mjs - the `fact` subcommands of report-tools: add, quote, retract, export, derive,
2
+ // fill (fact-commands-fill.mjs), verify (fact-commands-verify.mjs), pairs and meaning-check
3
+ // (fact-commands-meaning.mjs), polish-apply and polish-diff (fact-commands-polish.mjs). Every value reaches the ledger from a source
4
4
  // snapshot through a deterministic locator or from a declared derivation; nothing here accepts a
5
5
  // typed value. add and quote print the row with 60 chars of source context on each side (JSON
6
6
  // with --json). Exit 0 ok, 1 semantic failure (fact_not_located, fill errors, a refused
7
7
  // derivation), 2 usage. node: builtins and sibling imports only.
8
8
 
9
9
  import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
10
- import { dirname, extname, join, resolve } from "node:path"
10
+ import { join, resolve } from "node:path"
11
11
 
12
- import { CliError, parseArgs, sha256Of, writeJsonAtomic } from "./cli-support.mjs"
13
- import { DEFECT_CODES } from "./contracts.mjs"
12
+ import { CliError, parseArgs, sha256Of } from "./cli-support.mjs"
14
13
  import { derive } from "./fact-derive.mjs"
15
- import { fill } from "./fact-fill.mjs"
16
14
  import { addRow, readLedger, retractRow, supersedeRow, writeLedger } from "./fact-ledger.mjs"
17
15
  import { locateFind, locateMatch, locatePageLine, locateTable } from "./fact-locate.mjs"
18
16
  import { QUALIFIER_FORMS } from "./fidelity-lexicon.mjs"
19
- import { readState, writeState } from "./fidelity-state.mjs"
20
17
  import { exportRows } from "./fidelity-values.mjs"
21
18
  import { pointedFlag, sessionDirOf, snapshotOf } from "./source-commands.mjs"
22
19
 
@@ -157,44 +154,6 @@ function runExport(args) {
157
154
  return { json: { command: "fact export", status: "pass", exitCode: 0, data: { out: resolve(args.flags.out), format: args.flags.csv ? "csv" : "json", sha256: sha256Of(text), coverage } }, summary: coverage, exitCode: 0 }
158
155
  }
159
156
 
160
- /** The inline edition: local stylesheets and scripts inlined, relative image paths made absolute. */
161
- function inlineAssets(html, baseDir) {
162
- const local = (ref) => !/^[a-z][a-z0-9+.-]*:|^\/\//i.test(ref)
163
- return html
164
- .replace(/<link\b[^>]*\brel=["']stylesheet["'][^>]*>/gi, (tag) => {
165
- const href = /\bhref=["']([^"']+)["']/i.exec(tag)?.[1]
166
- return href && local(href) ? `<style>${readText(resolve(baseDir, href))}</style>` : tag
167
- })
168
- .replace(/<script\b([^>]*)\bsrc=["']([^"']+)["']([^>]*)><\/script>/gi, (tag, pre, src, post) => (local(src) ? `<script${pre}${post}>${readText(resolve(baseDir, src))}</script>` : tag))
169
- .replace(/(<img\b[^>]*\bsrc=["'])([^"']+)(["'])/gi, (tag, pre, src, post) => (local(src) && !src.startsWith("/") ? `${pre}${resolve(baseDir, src)}${post}` : tag))
170
- }
171
-
172
- function runFill(args) {
173
- const [source] = args.positionals
174
- const { flags } = args
175
- if (!source || (flags.out === undefined && !flags.view)) throw new CliError("usage: fact fill <src> (--out <path> | --view) [--format html|md|tex] [--inline] [--replace-failed]")
176
- const format = flags.format ?? ({ ".md": "md", ".tex": "tex" }[extname(source).toLowerCase()] ?? "html")
177
- if (!["html", "md", "tex"].includes(format)) throw new CliError("--format must be html, md, or tex")
178
- if (flags.inline && format !== "html") throw new CliError("--inline applies to the html edition only")
179
- const sessionDir = sessionDirOf(flags)
180
- const state = flags["replace-failed"] ? readState(sessionDir) : null
181
- const result = fill(readText(source), readLedger(sessionDir), { format: format === "tex" ? "md" : format, view: flags.view === true, ...(state ? { replaceFailed: state } : {}) })
182
- const warnings = result.warnings
183
- if (result.out === null) {
184
- const defects = result.errors.map((error) => ({ ...error, severity: DEFECT_CODES[error.code]?.severity ?? "blocker", hint: DEFECT_CODES[error.code]?.hint ?? null }))
185
- return { json: { command: "fact fill", status: "fail", exitCode: 1, defects, warnings }, summary: `fact fill: FAIL - ${defects.length} error(s), nothing written`, exitCode: 1 }
186
- }
187
- const out = flags.inline ? inlineAssets(result.out, dirname(resolve(source))) : result.out
188
- if (flags.view) return { text: out, summary: `fact fill --view: ${result.fillMap.length} pointer(s), ${warnings.length} warning(s)`, exitCode: 0 }
189
- writeFileSync(flags.out, out)
190
- if (state) writeState(sessionDir, state)
191
- const artifact = sha256Of(out)
192
- const map = `${resolve(flags.out)}.fill-map.json`
193
- writeJsonAtomic(map, { schemaVersion: 1, format, artifact: resolve(flags.out), artifact_sha256: artifact, entries: result.fillMap })
194
- const data = { out: resolve(flags.out), map, artifact_sha256: artifact, occurrences: result.fillMap.length, warnings }
195
- return { json: { command: "fact fill", status: "pass", exitCode: 0, defects: [], data }, summary: `fact fill: wrote ${flags.out} (${result.fillMap.length} pointer(s))`, exitCode: 0 }
196
- }
197
-
198
157
  function runDerive(args) {
199
158
  const { flags } = args
200
159
  const op = args.required("op")
@@ -230,7 +189,7 @@ export async function runFact(argv) {
230
189
  case "export":
231
190
  return runExport(args)
232
191
  case "fill":
233
- return runFill(args)
192
+ return (await import("./fact-commands-fill.mjs")).runFactFill(args)
234
193
  case "derive":
235
194
  return runDerive(args)
236
195
  case "verify":
@@ -10,7 +10,8 @@ import { failureCount, markReplaced, sentenceKey } from "./fidelity-state.mjs"
10
10
  import { findPlaceholders, lead, qualifiedText, renderBlockquote, renderQuote, renderValueHtml, subSpan, valueParts } from "./placeholder.mjs"
11
11
  import { splitSentences } from "./sentence-split.mjs"
12
12
 
13
- const FORMATS = Object.freeze(["html", "md"])
13
+ /** The output formats fill renders. */
14
+ export const FILL_FORMATS = Object.freeze(["html", "md"])
14
15
  const TRANSLATION = Object.freeze({
15
16
  html: /^\s*<span data-translates="(Q\d+)">[\s\S]*?<\/span>/u,
16
17
  md: /^\s*<!-- translates: (Q\d+) -->[^\n]*/u,
@@ -166,7 +167,7 @@ function assemble(text, parts, context) {
166
167
  */
167
168
  export function fill(sourceText, ledger, options = {}) {
168
169
  const format = options.format ?? "html"
169
- if (!FORMATS.includes(format)) throw new TypeError(`format must be one of ${FORMATS.join(", ")}`)
170
+ if (!FILL_FORMATS.includes(format)) throw new TypeError(`format must be one of ${FILL_FORMATS.join(", ")}`)
170
171
  const rows = new Map(ledger.rows.map((row) => [row.fact_id, row]))
171
172
  const found = findPlaceholders(sourceText)
172
173
  const errors = []