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.
- package/dist/cli/index.js +15 -15
- package/dist/cli-node/index.js +15 -15
- package/dist/index.js +14 -14
- package/dist/skills/browser/runtime/omowright/manifest.json +1 -1
- package/dist/skills/ulw-research/SKILL.md +58 -1
- package/dist/skills/ulw-research/references/deliverable-phase.md +51 -9
- package/dist/skills/ulw-research/references/report-gates.md +2 -1
- package/dist/skills/ulw-research/scripts/contracts.mjs +3 -11
- package/dist/skills/ulw-research/scripts/contracts.test.ts +1 -0
- package/dist/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
- package/dist/skills/ulw-research/scripts/copy-returns-polish.test.ts +6 -6
- package/dist/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
- package/dist/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
- package/dist/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
- package/dist/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
- package/dist/skills/ulw-research/scripts/fact-commands.mjs +6 -47
- package/dist/skills/ulw-research/scripts/fact-fill.mjs +3 -2
- package/dist/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
- package/dist/skills/ulw-research/scripts/report-tools.test.ts +245 -4
- package/dist/skills/ulw-research/scripts/source-commands.mjs +8 -3
- package/dist/tui.js +14 -14
- package/package.json +14 -14
- package/packages/omo-codex/plugin/.codex-plugin/plugin.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/bootstrap/package.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/comment-checker/package.json +1 -1
- package/packages/omo-codex/plugin/components/git-bash/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/git-bash/package.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/lazycodex-executor-verify/package.json +1 -1
- package/packages/omo-codex/plugin/components/lsp/dist/.omo-runtime-manifest.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/hooks/hooks.json +2 -2
- package/packages/omo-codex/plugin/components/lsp/package.json +1 -1
- package/packages/omo-codex/plugin/components/rules/hooks/hooks.json +4 -4
- package/packages/omo-codex/plugin/components/rules/package.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/teammode/package.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/telemetry/package.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ultrawork/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/hooks/hooks.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-execute-continuation/package.json +1 -1
- package/packages/omo-codex/plugin/components/ulw-loop/hooks/hooks.json +5 -5
- package/packages/omo-codex/plugin/components/ulw-loop/package.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-git-bash-mcp-reminder.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-lsp-diagnostics-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-compact-resetting-project-rule-cache.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-comments.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-lsp-diagnostics.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-checking-thread-title-hygiene.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-matching-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/post-tool-use-recording-spawn-admission.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-enforcing-unlimited-goal-budget.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-guarding-ulw-loop-spawns.json +1 -1
- package/packages/omo-codex/plugin/hooks/pre-tool-use-recommending-git-bash-mcp.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-auto-update.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-checking-bootstrap-provisioning.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/hooks/session-start-recording-session-telemetry.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-execute-continuation.json +1 -1
- package/packages/omo-codex/plugin/hooks/stop-checking-ulw-loop-resume.json +1 -1
- package/packages/omo-codex/plugin/hooks/subagent-stop-verifying-lazycodex-executor-evidence.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ultrawork-trigger.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-checking-ulw-loop-steering.json +1 -1
- package/packages/omo-codex/plugin/hooks/user-prompt-submit-loading-project-rules.json +1 -1
- package/packages/omo-codex/plugin/package-lock.json +12 -12
- package/packages/omo-codex/plugin/package.json +1 -1
- package/packages/omo-codex/plugin/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/omo-codex/plugin/skills/ulw-research/SKILL.md +58 -1
- package/packages/omo-codex/plugin/skills/ulw-research/references/deliverable-phase.md +51 -9
- package/packages/omo-codex/plugin/skills/ulw-research/references/report-gates.md +2 -1
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/contracts.mjs +3 -11
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-commands.mjs +6 -47
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fact-fill.mjs +3 -2
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
- package/packages/omo-codex/plugin/skills/ulw-research/scripts/source-commands.mjs +8 -3
- package/packages/omo-codex/scripts/install-dist/install-local.mjs +2 -2
- package/packages/shared-skills/skills/browser/runtime/omowright/manifest.json +1 -1
- package/packages/shared-skills/skills/ulw-research/SKILL.md +58 -1
- package/packages/shared-skills/skills/ulw-research/references/deliverable-phase.md +51 -9
- package/packages/shared-skills/skills/ulw-research/references/report-gates.md +2 -1
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.mjs +3 -11
- package/packages/shared-skills/skills/ulw-research/scripts/contracts.test.ts +1 -0
- package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-fidelity-policy.test.ts +156 -0
- package/packages/shared-skills/skills/ulw-research/scripts/copy-returns-polish.test.ts +6 -6
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-fill.mjs +86 -0
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-meaning.mjs +14 -22
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-polish.mjs +7 -2
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands-verify.mjs +18 -4
- package/packages/shared-skills/skills/ulw-research/scripts/fact-commands.mjs +6 -47
- package/packages/shared-skills/skills/ulw-research/scripts/fact-fill.mjs +3 -2
- package/packages/shared-skills/skills/ulw-research/scripts/fidelity-policy.mjs +183 -0
- package/packages/shared-skills/skills/ulw-research/scripts/report-tools.test.ts +245 -4
- package/packages/shared-skills/skills/ulw-research/scripts/source-commands.mjs +8 -3
|
@@ -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.
|
|
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
|
|
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
|
|
53
|
-
if (
|
|
54
|
-
|
|
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,
|
|
129
|
-
|
|
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
|
|
144
|
-
if (
|
|
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
|
|
178
|
-
const
|
|
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
|
|
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,
|
|
2
|
-
//
|
|
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 {
|
|
10
|
+
import { join, resolve } from "node:path"
|
|
11
11
|
|
|
12
|
-
import { CliError, parseArgs, sha256Of
|
|
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
|
|
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
|
-
|
|
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 (!
|
|
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 = []
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// fidelity-policy.mjs - what the locked `## Fidelity` block of brief.md means to the CLI stages.
|
|
2
|
+
//
|
|
3
|
+
// lockedBrief is the gate the six reader-facing stages (fact fill, verify, pairs, meaning-check,
|
|
4
|
+
// polish-apply, polish-diff) run before reading or writing anything: an unlocked brief returns
|
|
5
|
+
// one fidelity_interview_incomplete defect (exit 1), a malformed block is a usage error (exit 2).
|
|
6
|
+
// fillPolicy reads the fields fact fill consumes (a value off its table is a usage error, exit 2),
|
|
7
|
+
// and policyErrors refuses a placeholder whose row the brief forbids: derived_not_allowed
|
|
8
|
+
// (`derived numbers: none` against a derived, converted, or assumed row) and source_outside_floor
|
|
9
|
+
// (the source floor is evaluated live against each source's meta.json at every fill and verify;
|
|
10
|
+
// under any floor but `standard` a row resting on no source is refused too; a row's outside_floor
|
|
11
|
+
// is only the cached result). briefHashOf binds the locked block into the fill map, and
|
|
12
|
+
// briefBindingErrors refuses an edition whose map was filled under another brief (brief_changed).
|
|
13
|
+
// Collection stages never call this module. node: builtins and sibling imports only.
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync } from "node:fs"
|
|
16
|
+
import { join, resolve } from "node:path"
|
|
17
|
+
|
|
18
|
+
import { CliError, sha256Of } from "./cli-support.mjs"
|
|
19
|
+
import { DEFECT_CODES } from "./contracts.mjs"
|
|
20
|
+
import { KIND_RULES } from "./fact-ledger-kinds.mjs"
|
|
21
|
+
import { FIDELITY_FIELDS, meaningCheckEnabled, requireLockedBrief } from "./fidelity-brief.mjs"
|
|
22
|
+
|
|
23
|
+
export { meaningCheckEnabled }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Ledger kinds a brief with `derived numbers: none` refuses. A policy list, not the ledger's
|
|
27
|
+
* derivation set: it also names assumed. Every name must be a KIND_RULES kind (checked at load).
|
|
28
|
+
*/
|
|
29
|
+
const DERIVED_KINDS = Object.freeze(["derived", "converted", "assumed"])
|
|
30
|
+
for (const kind of DERIVED_KINDS) {
|
|
31
|
+
if (!Object.hasOwn(KIND_RULES, kind)) throw new Error(`fidelity-policy: ${kind} is not a KIND_RULES kind`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Fields whose only legal values are a closed table: value (any case) -> its reading. */
|
|
35
|
+
const VALUE_TABLES = Object.freeze({
|
|
36
|
+
"derived numbers": Object.freeze({ allowed: false, none: true }),
|
|
37
|
+
fallback: Object.freeze({ "block-quote": "quote", omit: "omit" }),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
/** The reading of a table value; any other text is a usage error naming the field and the value. */
|
|
41
|
+
function tableValue(field, value) {
|
|
42
|
+
const table = VALUE_TABLES[field]
|
|
43
|
+
const key = value.trim().toLowerCase()
|
|
44
|
+
if (Object.hasOwn(table, key)) return table[key]
|
|
45
|
+
throw new CliError(`${field} "${value}" is not ${Object.keys(table).join(", ")}`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How each field value the fill stages consume is read; the single owner of those readings. */
|
|
49
|
+
export const FIDELITY_VALUE_RULES = Object.freeze({
|
|
50
|
+
tables: VALUE_TABLES,
|
|
51
|
+
derivedKinds: DERIVED_KINDS,
|
|
52
|
+
derivedRefused: (kind) => DERIVED_KINDS.includes(kind),
|
|
53
|
+
derivedNone: (value) => tableValue("derived numbers", value),
|
|
54
|
+
fallback: (value) => tableValue("fallback", value),
|
|
55
|
+
translations: (value) => /with translation beside/iu.test(value),
|
|
56
|
+
meaningCheck: (value) => meaningCheckEnabled({ fields: { "meaning check": value } }),
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
/** The `source floor` forms: `pattern` reads one, `listed` forms carry a comma list. */
|
|
60
|
+
export const SOURCE_FLOOR_RULES = Object.freeze([
|
|
61
|
+
Object.freeze({ kind: "standard", form: "standard", pattern: /^standard$/iu, listed: false }),
|
|
62
|
+
Object.freeze({ kind: "primary", form: "primary only", pattern: /^primary only$/iu, listed: false }),
|
|
63
|
+
Object.freeze({ kind: "pointed", form: "pointed only: <list>", pattern: /^pointed only:\s*(.+)$/iu, listed: true }),
|
|
64
|
+
Object.freeze({ kind: "classes", form: "classes: <list>", pattern: /^classes:\s*(.+)$/iu, listed: true }),
|
|
65
|
+
])
|
|
66
|
+
const lineOf = (text, at) => text.slice(0, at).split("\n").length
|
|
67
|
+
const listOf = (text) => text.split(",").map((entry) => entry.trim()).filter(Boolean)
|
|
68
|
+
|
|
69
|
+
/** brief.md from --brief, else the session's; a usage error when neither is known. */
|
|
70
|
+
export function briefPathOf(flags) {
|
|
71
|
+
if (flags.brief !== undefined && flags.brief !== true) return resolve(flags.brief)
|
|
72
|
+
const dir = flags["session-dir"] ?? process.env.SESSION_DIR
|
|
73
|
+
if (!dir || dir === true) throw new CliError("--brief is required without --session-dir (or SESSION_DIR)")
|
|
74
|
+
return join(resolve(dir), "brief.md")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @returns {{ parsed: object, refusal: null } | { parsed: null, refusal: { json: object, summary: string, exitCode: 1 } }}
|
|
79
|
+
*/
|
|
80
|
+
export function lockedBrief(command, flags) {
|
|
81
|
+
const path = briefPathOf(flags)
|
|
82
|
+
const lock = requireLockedBrief(path)
|
|
83
|
+
if (lock.exitCode === 2) throw new CliError(`${path}: ${lock.errors.join("; ")}`)
|
|
84
|
+
if (lock.locked) return { parsed: lock.parsed, refusal: null }
|
|
85
|
+
const json = { command, status: "fail", exitCode: 1, defects: lock.defects }
|
|
86
|
+
return { parsed: null, refusal: { json, summary: `${command}: ${lock.defects[0].message}`, exitCode: 1 } }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The source floor as a rule, or null for `standard`; a usage error for any other text.
|
|
91
|
+
* @returns {null | { kind: "primary" } | { kind: "pointed" | "classes", list: string[] }}
|
|
92
|
+
*/
|
|
93
|
+
export function parseSourceFloor(value) {
|
|
94
|
+
const text = value.trim()
|
|
95
|
+
for (const rule of SOURCE_FLOOR_RULES) {
|
|
96
|
+
const match = rule.pattern.exec(text)
|
|
97
|
+
if (!match) continue
|
|
98
|
+
if (rule.kind === "standard") return null
|
|
99
|
+
return rule.listed ? { kind: rule.kind, list: listOf(match[1]) } : { kind: rule.kind }
|
|
100
|
+
}
|
|
101
|
+
throw new CliError(`source floor "${value}" is not ${SOURCE_FLOOR_RULES.map((rule) => rule.form).join(", ")}`)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hostOf(url) {
|
|
105
|
+
return typeof url === "string" && URL.canParse(url) ? new URL(url).hostname.toLowerCase() : null
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Whether one source (id and meta.json) satisfies a parsed floor rule. */
|
|
109
|
+
export function sourceInsideFloor(rule, sourceId, meta) {
|
|
110
|
+
if (rule.kind === "primary") return meta.provenance === "primary"
|
|
111
|
+
if (rule.kind === "pointed") return rule.list.includes(sourceId) || rule.list.includes(meta.url)
|
|
112
|
+
const host = hostOf(meta.url)
|
|
113
|
+
return host !== null && rule.list.some((entry) => host === entry.toLowerCase() || host.endsWith(`.${entry.toLowerCase()}`))
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The fields fact fill consumes: the fallback form and whether a translation binds to its quote. */
|
|
117
|
+
export function fillPolicy(parsed) {
|
|
118
|
+
const field = (name) => parsed.fields[name]
|
|
119
|
+
return {
|
|
120
|
+
fallback: FIDELITY_VALUE_RULES.fallback(field("fallback")),
|
|
121
|
+
translations: FIDELITY_VALUE_RULES.translations(field("languages")),
|
|
122
|
+
derivedNone: FIDELITY_VALUE_RULES.derivedNone(field("derived numbers")),
|
|
123
|
+
floor: parseSourceFloor(field("source floor")),
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Source ids behind a row: its own, or for a derived row its operands' sources; null marks a leaf with no source. */
|
|
128
|
+
function sourcesOf(row, rows, seen = new Set()) {
|
|
129
|
+
if (seen.has(row.fact_id)) return []
|
|
130
|
+
seen.add(row.fact_id)
|
|
131
|
+
if (row.source_id) return [row.source_id]
|
|
132
|
+
const inputs = [...(row.operands ?? []), ...(row.derived_from ?? [])]
|
|
133
|
+
if (inputs.length === 0) return [null]
|
|
134
|
+
return inputs.flatMap((id) => (rows.has(id) ? sourcesOf(rows.get(id), rows, seen) : [null]))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** sha256 of the locked `## Fidelity` block normalized: the seven fields in FIDELITY_FIELDS order, `field: value` per line. */
|
|
138
|
+
export const briefHashOf = (parsed) => sha256Of(FIDELITY_FIELDS.map((field) => `${field}: ${parsed.fields[field]}`).join("\n"))
|
|
139
|
+
|
|
140
|
+
/** brief_changed when a fill map was not written under the current brief (or predates the binding). */
|
|
141
|
+
export function briefBindingErrors(map, mapPath, parsed) {
|
|
142
|
+
if (map.brief_sha256 === briefHashOf(parsed)) return []
|
|
143
|
+
const why = typeof map.brief_sha256 === "string" ? "was filled under a different ## Fidelity block" : "records no brief_sha256"
|
|
144
|
+
return [{ code: "brief_changed", token: null, line: null, message: `${mapPath} ${why}; re-run fact fill under the current brief`, severity: DEFECT_CODES.brief_changed.severity, integrity: DEFECT_CODES.brief_changed.integrity, hint: DEFECT_CODES.brief_changed.hint }]
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function metaOf(sessionDir, sourceId, cache) {
|
|
148
|
+
if (!cache.has(sourceId)) {
|
|
149
|
+
const file = join(sessionDir, "sources", sourceId, "meta.json")
|
|
150
|
+
cache.set(sourceId, existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : { url: null, provenance: "unknown" })
|
|
151
|
+
}
|
|
152
|
+
return cache.get(sourceId)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const defect = (code, pointer, text, message) => ({ code, token: pointer.token, line: lineOf(text, pointer.start), message, severity: DEFECT_CODES[code].severity, hint: DEFECT_CODES[code].hint })
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Refusals for the placeholders of one fill source (or the data-fact elements of one edition at
|
|
159
|
+
* verify). Sets each referenced row's outside_floor to this evaluation and reports whether any
|
|
160
|
+
* cached value changed.
|
|
161
|
+
* @param {{ token: string, start: number, parsed: { id?: string } }[]} found findPlaceholders output
|
|
162
|
+
* @returns {{ errors: object[], cacheChanged: boolean }}
|
|
163
|
+
*/
|
|
164
|
+
export function policyErrors(text, found, ledger, sessionDir, policy) {
|
|
165
|
+
const rows = new Map(ledger.rows.map((row) => [row.fact_id, row]))
|
|
166
|
+
const metas = new Map()
|
|
167
|
+
const errors = []
|
|
168
|
+
let cacheChanged = false
|
|
169
|
+
for (const pointer of found) {
|
|
170
|
+
const row = rows.get(pointer.parsed?.id)
|
|
171
|
+
if (row === undefined || row.status !== "active") continue
|
|
172
|
+
if (policy.derivedNone && FIDELITY_VALUE_RULES.derivedRefused(row.kind)) {
|
|
173
|
+
errors.push(defect("derived_not_allowed", pointer, text, `${row.fact_id} is ${row.kind}; the brief records derived numbers: none`))
|
|
174
|
+
}
|
|
175
|
+
const sources = sourcesOf(row, rows)
|
|
176
|
+
const outside = policy.floor === null ? [] : [...new Set(sources.filter((id) => id === null || !sourceInsideFloor(policy.floor, id, metaOf(sessionDir, id, metas))))]
|
|
177
|
+
if (row.outside_floor !== outside.length > 0) cacheChanged = true
|
|
178
|
+
row.outside_floor = outside.length > 0
|
|
179
|
+
const named = outside.map((id) => id ?? "no source").join(", ")
|
|
180
|
+
if (outside.length > 0) errors.push(defect("source_outside_floor", pointer, text, `${row.fact_id} rests on ${named}, outside the source floor`))
|
|
181
|
+
}
|
|
182
|
+
return { errors, cacheChanged }
|
|
183
|
+
}
|