@notedge/panduck 0.0.0

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.
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { createJiti } from "jiti";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const here = dirname(fileURLToPath(import.meta.url));
8
+ const jiti = createJiti(import.meta.url, { interopDefault: true });
9
+ const { runCli } = await jiti.import(join(here, "../src/cli/main.ts"));
10
+
11
+ process.exit(await runCli(process.argv.slice(2)));
@@ -0,0 +1,46 @@
1
+ {
2
+ "cli.intro": "panduck — document conversion orchestrator for notedown-ir::DocumentGraph",
3
+ "cli.cmd.convert": "Convert one input document to a target format",
4
+ "cli.cmd.plan": "Probe input and plan a conversion pipeline without writing output",
5
+ "cli.cmd.inspect": "Inspect input format and container structure",
6
+ "cli.cmd.check": "Validate source or generated output against Panduck contracts",
7
+ "cli.cmd.formats": "List format capabilities from the shared registry",
8
+ "cli.cmd.doctor": "Check CLI install, native bindings, and environment",
9
+ "cli.opt.to": "Target format ID (e.g. epub, docx, markdown, notedown)",
10
+ "cli.opt.from": "Source format ID when detection should be skipped",
11
+ "cli.opt.output": "Output file path, or - for stdout",
12
+ "cli.opt.profile": "Named conversion profile from config",
13
+ "cli.opt.config": "Path to Panduck config file",
14
+ "cli.opt.assets-dir": "Output assets root directory",
15
+ "cli.opt.asset-policy": "Asset handling: preserve, copy, embed, or drop",
16
+ "cli.opt.link-policy": "Link handling: preserve, rewrite, or drop",
17
+ "cli.opt.loss": "Loss policy: allow, warn, or deny",
18
+ "cli.opt.strict": "Reject unknown coverage in addition to loss",
19
+ "cli.opt.allow-partial": "Allow publishing partial source reads when the writer accepts them",
20
+ "cli.opt.diagnostics": "Diagnostics channel: silent, human, or json",
21
+ "cli.opt.batch": "Process multiple inputs independently",
22
+ "cli.opt.output-dir": "Batch output directory",
23
+ "cli.opt.report": "Write JSON report to a file, or - for stdout",
24
+ "cli.opt.overwrite": "Replace an existing output file",
25
+ "cli.opt.budget": "Parse budget limit (repeatable KEY=VALUE)",
26
+ "cli.opt.dry-run": "Alias for plan-style validation on convert",
27
+ "cli.opt.stage": "Inspect depth: probe, index, decode, or validate",
28
+ "cli.opt.path": "Layout path inside a container (inspect)",
29
+ "cli.opt.operation": "Filter formats by read or write operation",
30
+ "cli.opt.json": "Emit machine-readable JSON on stdout",
31
+ "cli.opt.log-file": "Append structured console events to a JSON lines log file",
32
+ "cli.err.same_path": "Input and output paths must not be the same: {path}",
33
+ "cli.err.missing_to_stdout": "Writing to stdout requires an explicit --to format",
34
+ "cli.err.missing_input": "Input path is required",
35
+ "cli.err.stdin_batch": "Standard input cannot be used with --batch",
36
+ "cli.err.ambiguous_format": "Ambiguous input format; specify --from explicitly",
37
+ "cli.err.unsupported_target": "Target format {format} is not ready for {operation}",
38
+ "cli.err.unsupported_source": "Source format {format} is not ready for read",
39
+ "cli.err.output_exists": "Output already exists; pass --overwrite to replace it",
40
+ "cli.err.not_implemented": "Pipeline step not implemented yet: {step}",
41
+ "cli.err.invalid_policy": "Invalid {name}: {value}",
42
+ "cli.msg.plan_ready": "Plan complete",
43
+ "cli.msg.convert_blocked": "Conversion blocked by capability registry",
44
+ "cli.msg.doctor_ok": "Panduck environment looks healthy",
45
+ "cli.msg.doctor_issues": "Panduck environment has issues"
46
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "defaultLocale": "en-US",
3
+ "locales": [
4
+ { "id": "en-US", "label": "English", "direction": "ltr" },
5
+ { "id": "zh-CN", "label": "简体中文", "direction": "ltr" }
6
+ ]
7
+ }
@@ -0,0 +1,46 @@
1
+ {
2
+ "cli.intro": "panduck — 面向 notedown-ir::DocumentGraph 的文档转换编排器",
3
+ "cli.cmd.convert": "将单个输入文档转换为目标格式",
4
+ "cli.cmd.plan": "探测输入并规划转换流水线,不写输出",
5
+ "cli.cmd.inspect": "查看输入格式与容器结构",
6
+ "cli.cmd.check": "按 Panduck 合同校验源文件或已生成输出",
7
+ "cli.cmd.formats": "列出共享注册表中的格式能力",
8
+ "cli.cmd.doctor": "检查 CLI 安装、原生绑定与环境",
9
+ "cli.opt.to": "目标格式 ID(如 epub、docx、markdown、notedown)",
10
+ "cli.opt.from": "在跳过自动探测时指定源格式 ID",
11
+ "cli.opt.output": "输出文件路径,或 - 表示标准输出",
12
+ "cli.opt.profile": "配置中的命名转换 profile",
13
+ "cli.opt.config": "Panduck 配置文件路径",
14
+ "cli.opt.assets-dir": "输出资产根目录",
15
+ "cli.opt.asset-policy": "资产策略:preserve、copy、embed 或 drop",
16
+ "cli.opt.link-policy": "链接策略:preserve、rewrite 或 drop",
17
+ "cli.opt.loss": "损失策略:allow、warn 或 deny",
18
+ "cli.opt.strict": "除损失外还拒绝 unknown coverage",
19
+ "cli.opt.allow-partial": "在 writer 接受时允许发布 partial 源读取",
20
+ "cli.opt.diagnostics": "诊断通道:silent、human 或 json",
21
+ "cli.opt.batch": "独立处理多个输入",
22
+ "cli.opt.output-dir": "批处理输出目录",
23
+ "cli.opt.report": "将 JSON 报告写入文件,或 - 表示标准输出",
24
+ "cli.opt.overwrite": "覆盖已存在的输出文件",
25
+ "cli.opt.budget": "解析预算上限(可重复 KEY=VALUE)",
26
+ "cli.opt.dry-run": "在 convert 上等价于 plan 式校验",
27
+ "cli.opt.stage": "inspect 深度:probe、index、decode 或 validate",
28
+ "cli.opt.path": "容器内布局路径(inspect)",
29
+ "cli.opt.operation": "按 read 或 write 过滤格式",
30
+ "cli.opt.json": "在标准输出打印机器可读 JSON",
31
+ "cli.opt.log-file": "将结构化 console 事件追加写入 JSON lines 日志文件",
32
+ "cli.err.same_path": "输入与输出路径不能相同:{path}",
33
+ "cli.err.missing_to_stdout": "写入标准输出时必须显式指定 --to 格式",
34
+ "cli.err.missing_input": "必须提供输入路径",
35
+ "cli.err.stdin_batch": "标准输入不能与 --batch 同时使用",
36
+ "cli.err.ambiguous_format": "输入格式存在歧义,请显式指定 --from",
37
+ "cli.err.unsupported_target": "目标格式 {format} 尚未就绪,无法 {operation}",
38
+ "cli.err.unsupported_source": "源格式 {format} 尚未就绪,无法读取",
39
+ "cli.err.output_exists": "输出已存在,请使用 --overwrite 覆盖",
40
+ "cli.err.not_implemented": "流水线步骤尚未实现:{step}",
41
+ "cli.err.invalid_policy": "无效的 {name}:{value}",
42
+ "cli.msg.plan_ready": "规划完成",
43
+ "cli.msg.convert_blocked": "能力注册表阻止了转换",
44
+ "cli.msg.doctor_ok": "Panduck 环境正常",
45
+ "cli.msg.doctor_issues": "Panduck 环境存在问题"
46
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "bin": {
3
+ "panduck": "./bin/panduck.mjs"
4
+ },
5
+ "dependencies": {
6
+ "@vmz/commander": "^0.2.0",
7
+ "jiti": "^2.4.2"
8
+ },
9
+ "description": "Panduck document conversion bindings (Node-API + TypeScript)",
10
+ "devDependencies": {
11
+ "@types/node": "^22.13.10",
12
+ "tsx": "^4.19.3",
13
+ "typescript": "^5.8.3"
14
+ },
15
+ "engines": {
16
+ "node": ">=20"
17
+ },
18
+ "exports": {
19
+ ".": "./src/index.ts",
20
+ "./cli": "./src/cli/index.ts",
21
+ "./node": "./src/node/index.ts",
22
+ "./wasm": "./src/wasm/index.ts"
23
+ },
24
+ "files": [
25
+ "bin",
26
+ "locales",
27
+ "src"
28
+ ],
29
+ "license": "MPL-2.0",
30
+ "name": "@notedge/panduck",
31
+ "optionalDependencies": {
32
+ "@notedge/panduck-darwin-arm64": "0.0.0",
33
+ "@notedge/panduck-darwin-x64": "0.0.0",
34
+ "@notedge/panduck-linux-arm64": "0.0.0",
35
+ "@notedge/panduck-linux-x64": "0.0.0",
36
+ "@notedge/panduck-unknown-wasm32": "0.0.0",
37
+ "@notedge/panduck-win32-x64": "0.0.0"
38
+ },
39
+ "publishConfig": {
40
+ "access": "public"
41
+ },
42
+ "repository": {
43
+ "type": "git",
44
+ "url": "git+https://github.com/oovm/npm-tools.git"
45
+ },
46
+ "scripts": {
47
+ "test:e2e": "tsx --test tests/**/*.test.ts",
48
+ "typecheck": "tsc --noEmit"
49
+ },
50
+ "type": "module",
51
+ "version": "0.0.0"
52
+ }
package/readme.md ADDED
@@ -0,0 +1,5 @@
1
+ # @notedge/panduck
2
+
3
+ TypeScript loader for Panduck Node-API bindings under the `@notedge` scope.
4
+
5
+ Build native artifacts first: `pnpm run build:napi` at the repo root.
package/src/cli/cli.ts ADDED
@@ -0,0 +1,28 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ import { createCli } from "@vmz/commander";
5
+
6
+ import { registerCheckCommand } from "./commands/check-cmd.js";
7
+ import { registerConvertCommand } from "./commands/convert-cmd.js";
8
+ import { registerDoctorCommand } from "./commands/doctor-cmd.js";
9
+ import { registerFormatsCommand } from "./commands/formats-cmd.js";
10
+ import { registerInspectCommand } from "./commands/inspect-cmd.js";
11
+ import { registerPlanCommand } from "./commands/plan-cmd.js";
12
+
13
+ export function buildPanduckCli() {
14
+ const localesRoot = join(dirname(fileURLToPath(import.meta.url)), "../../locales");
15
+ const cli = createCli("panduck")
16
+ .locales(localesRoot, { envKeys: ["PANDUCK_LOCALE", "LOCALE", "LANG", "LC_ALL"] })
17
+ .intro("cli.intro")
18
+ .option("--log-file <file>", "cli.opt.log-file");
19
+
20
+ registerConvertCommand(cli);
21
+ registerPlanCommand(cli);
22
+ registerInspectCommand(cli);
23
+ registerCheckCommand(cli);
24
+ registerFormatsCommand(cli);
25
+ registerDoctorCommand(cli);
26
+
27
+ return cli;
28
+ }
@@ -0,0 +1,174 @@
1
+ import type { Cli, ParsedOptions } from "@vmz/commander";
2
+
3
+ import {
4
+ assertReadableInput,
5
+ buildBlockedReport,
6
+ createContext,
7
+ resolveFormats,
8
+ } from "../context.js";
9
+ import { emitReport } from "../diagnostics.js";
10
+ import { ExitCode } from "../exit-codes.js";
11
+ import { findFormat } from "../format-registry.js";
12
+ import { CliOptionError, readSharedOptions, readString } from "../options.js";
13
+ import { createReport } from "../report.js";
14
+ import { configureConsoleBackend } from "../console-backend.js";
15
+ import {
16
+ emitPipelineResult,
17
+ hasPipeline,
18
+ inferCheckTarget,
19
+ runCheckPipeline,
20
+ } from "../pipeline.js";
21
+
22
+ export function registerCheckCommand(cli: Cli): void {
23
+ cli.command("check", "cli.cmd.check")
24
+ .option("--from <format>", "cli.opt.from")
25
+ .option("--format <format>", "cli.opt.to")
26
+ .option("--strict", "cli.opt.strict")
27
+ .option("--loss <policy>", "cli.opt.loss")
28
+ .option("--diagnostics <mode>", "cli.opt.diagnostics")
29
+ .option("--budget <limit>", "cli.opt.budget")
30
+ .option("--json", "cli.opt.json")
31
+ .action((options: ParsedOptions) => runCheck(options));
32
+ }
33
+
34
+ async function runCheck(options: ParsedOptions): Promise<number> {
35
+ try {
36
+ const shared = readSharedOptions(options);
37
+ const inputPath = options._[0] ?? readString(options, "input");
38
+ const outputFormat = readString(options, "format");
39
+ if (!inputPath) {
40
+ return ExitCode.InvalidArgs;
41
+ }
42
+
43
+ const ctx = createContext();
44
+ configureConsoleBackend(ctx.bindings, shared.logFile);
45
+ const resolved = resolveFormats(inputPath, undefined, {
46
+ ...shared,
47
+ to: outputFormat ?? shared.to,
48
+ });
49
+
50
+ if (resolved.ambiguous) {
51
+ const report = buildBlockedReport(
52
+ ctx,
53
+ "check",
54
+ inputPath,
55
+ resolved,
56
+ "ambiguous input format",
57
+ ExitCode.InputUnsupported,
58
+ shared,
59
+ );
60
+ emitReport(report, shared.diagnostics, shared.json);
61
+ return ExitCode.InputUnsupported;
62
+ }
63
+
64
+ try {
65
+ await assertReadableInput(inputPath);
66
+ } catch {
67
+ const report = buildBlockedReport(
68
+ ctx,
69
+ "check",
70
+ inputPath,
71
+ resolved,
72
+ "input is not readable",
73
+ ExitCode.InputUnsupported,
74
+ shared,
75
+ );
76
+ emitReport(report, shared.diagnostics, shared.json);
77
+ return ExitCode.InputUnsupported;
78
+ }
79
+
80
+ const source = resolved.from ? findFormat(ctx.registry, resolved.from) : undefined;
81
+ let targetFormat = resolved.to;
82
+ if (!targetFormat && resolved.from && ctx.bindings) {
83
+ targetFormat = inferCheckTarget(ctx.bindings, resolved.from);
84
+ }
85
+ const target = targetFormat ? findFormat(ctx.registry, targetFormat) : undefined;
86
+ const checkResolved = { ...resolved, to: targetFormat };
87
+
88
+ if (!targetFormat) {
89
+ const report = buildBlockedReport(
90
+ ctx,
91
+ "check",
92
+ inputPath,
93
+ checkResolved,
94
+ "target format is required for check",
95
+ ExitCode.InvalidArgs,
96
+ shared,
97
+ );
98
+ emitReport(report, shared.diagnostics, shared.json);
99
+ return ExitCode.InvalidArgs;
100
+ }
101
+
102
+ const routeReady = hasPipeline(ctx.bindings, resolved.from, targetFormat);
103
+
104
+ if (routeReady && ctx.bindings) {
105
+ try {
106
+ const result = await runCheckPipeline({
107
+ ctx,
108
+ bindings: ctx.bindings,
109
+ inputPath,
110
+ resolved: checkResolved,
111
+ shared,
112
+ });
113
+ return emitPipelineResult(result, shared);
114
+ } catch (error) {
115
+ const message = error instanceof Error ? error.message : String(error);
116
+ const report = buildBlockedReport(
117
+ ctx,
118
+ "check",
119
+ inputPath,
120
+ checkResolved,
121
+ message,
122
+ ExitCode.IrOrWriterFailure,
123
+ shared,
124
+ );
125
+ emitReport(report, shared.diagnostics, shared.json);
126
+ return ExitCode.IrOrWriterFailure;
127
+ }
128
+ }
129
+
130
+ const report = createReport({
131
+ operation: "check",
132
+ toolVersion: ctx.toolVersion,
133
+ status: "partial",
134
+ inputs: [{ path: inputPath, format: resolved.from }],
135
+ detection: { format: resolved.from, confidence: resolved.from ? "hint" : undefined, hints: resolved.hints },
136
+ pipeline: {
137
+ reader: source?.format,
138
+ writer: target?.format,
139
+ stages: ["validate"],
140
+ blocked_reason: routeReady
141
+ ? "native bindings are unavailable"
142
+ : "conversion route is not wired yet",
143
+ },
144
+ coverage: source?.coverage ?? target?.coverage,
145
+ diagnostics: [
146
+ {
147
+ owner: "panduck",
148
+ severity: "warning",
149
+ message: routeReady
150
+ ? "check requires native bindings for wired routes"
151
+ : "check currently validates readability and registry presence only",
152
+ code: "panduck.check.partial",
153
+ },
154
+ ],
155
+ budgets: shared.budgets,
156
+ determinism: { profile: shared.profile ?? "default", config: shared.config ?? null },
157
+ statusPolicy: {
158
+ loss: shared.loss,
159
+ strict: shared.strict,
160
+ exit_code: shared.strict ? ExitCode.LossPolicyViolation : ExitCode.Success,
161
+ },
162
+ });
163
+
164
+ emitReport(report, shared.diagnostics, shared.json);
165
+ return shared.strict ? ExitCode.LossPolicyViolation : ExitCode.Success;
166
+ } catch (error) {
167
+ if (error instanceof CliOptionError) {
168
+ console.error(error.message);
169
+ return error.exitCode;
170
+ }
171
+ console.error(error instanceof Error ? error.message : String(error));
172
+ return ExitCode.InvalidArgs;
173
+ }
174
+ }
@@ -0,0 +1,300 @@
1
+ import { access } from "node:fs/promises";
2
+ import { constants } from "node:fs";
3
+
4
+ import type { Cli, ParsedOptions } from "@vmz/commander";
5
+
6
+ import {
7
+ assertReadableInput,
8
+ buildBlockedReport,
9
+ createContext,
10
+ resolveFormats,
11
+ validateIoPaths,
12
+ } from "../context.js";
13
+ import { emitReport } from "../diagnostics.js";
14
+ import { ExitCode } from "../exit-codes.js";
15
+ import { findFormat, isOperationReady } from "../format-registry.js";
16
+ import {
17
+ CliOptionError,
18
+ parseAssetPolicy,
19
+ parseLinkPolicy,
20
+ readSharedOptions,
21
+ readString,
22
+ } from "../options.js";
23
+ import { createReport } from "../report.js";
24
+ import { isStdoutPath } from "../paths.js";
25
+ import { configureConsoleBackend } from "../console-backend.js";
26
+ import { emitPipelineResult, hasPipeline, runConversionPipeline } from "../pipeline.js";
27
+
28
+ export function registerConvertCommand(cli: Cli): void {
29
+ cli.command("convert", "cli.cmd.convert")
30
+ .option("--to <format>", "cli.opt.to")
31
+ .option("-o, --output <file>", "cli.opt.output")
32
+ .option("--from <format>", "cli.opt.from")
33
+ .option("--profile <name>", "cli.opt.profile")
34
+ .option("--config <file>", "cli.opt.config")
35
+ .option("--assets-dir <dir>", "cli.opt.assets-dir")
36
+ .option("--asset-policy <policy>", "cli.opt.asset-policy")
37
+ .option("--link-policy <policy>", "cli.opt.link-policy")
38
+ .option("--loss <policy>", "cli.opt.loss")
39
+ .option("--strict", "cli.opt.strict")
40
+ .option("--allow-partial", "cli.opt.allow-partial")
41
+ .option("--diagnostics <mode>", "cli.opt.diagnostics")
42
+ .option("--batch", "cli.opt.batch")
43
+ .option("--output-dir <dir>", "cli.opt.output-dir")
44
+ .option("--report <file>", "cli.opt.report")
45
+ .option("--overwrite", "cli.opt.overwrite")
46
+ .option("--budget <limit>", "cli.opt.budget")
47
+ .option("--dry-run", "cli.opt.dry-run")
48
+ .action((options: ParsedOptions) => runConvert(options));
49
+ }
50
+
51
+ async function runConvert(options: ParsedOptions): Promise<number> {
52
+ try {
53
+ const shared = readSharedOptions(options);
54
+ const inputPath = options._[0] ?? readString(options, "input");
55
+ const outputPath = readString(options, "output");
56
+ const reportPath = readString(options, "report");
57
+ const batch = options.batch === true;
58
+ const dryRun = options["dry-run"] === true;
59
+
60
+ if (!inputPath) {
61
+ return ExitCode.InvalidArgs;
62
+ }
63
+ if (batch && inputPath === "-") {
64
+ console.error("standard input cannot be used with --batch");
65
+ return ExitCode.InvalidArgs;
66
+ }
67
+ if (batch && !readString(options, "output-dir")) {
68
+ console.error("--batch requires --output-dir");
69
+ return ExitCode.InvalidArgs;
70
+ }
71
+
72
+ const overlapCode = validateIoPaths(inputPath, outputPath);
73
+ if (overlapCode !== null) {
74
+ console.error(`input and output paths must not be the same: ${inputPath}`);
75
+ return overlapCode;
76
+ }
77
+
78
+ if (isStdoutPath(outputPath) && !shared.to) {
79
+ console.error("writing to stdout requires an explicit --to format");
80
+ return ExitCode.InvalidArgs;
81
+ }
82
+
83
+ const ctx = createContext();
84
+ configureConsoleBackend(ctx.bindings, shared.logFile);
85
+ const resolved = resolveFormats(inputPath, outputPath, shared);
86
+
87
+ if (resolved.ambiguous) {
88
+ const report = buildBlockedReport(
89
+ ctx,
90
+ "convert",
91
+ inputPath,
92
+ resolved,
93
+ "ambiguous input format",
94
+ ExitCode.InputUnsupported,
95
+ shared,
96
+ );
97
+ emitReport(report, shared.diagnostics, shared.json);
98
+ return ExitCode.InputUnsupported;
99
+ }
100
+
101
+ if (!resolved.to) {
102
+ const report = buildBlockedReport(
103
+ ctx,
104
+ "convert",
105
+ inputPath,
106
+ resolved,
107
+ "target format is required",
108
+ ExitCode.InvalidArgs,
109
+ shared,
110
+ );
111
+ emitReport(report, shared.diagnostics, shared.json);
112
+ return ExitCode.InvalidArgs;
113
+ }
114
+
115
+ try {
116
+ await assertReadableInput(inputPath);
117
+ } catch {
118
+ const report = buildBlockedReport(
119
+ ctx,
120
+ "convert",
121
+ inputPath,
122
+ resolved,
123
+ "input is not readable",
124
+ ExitCode.InputUnsupported,
125
+ shared,
126
+ );
127
+ emitReport(report, shared.diagnostics, shared.json);
128
+ return ExitCode.InputUnsupported;
129
+ }
130
+
131
+ if (outputPath && outputPath !== "-" && !shared.overwrite) {
132
+ try {
133
+ await access(outputPath, constants.F_OK);
134
+ const report = buildBlockedReport(
135
+ ctx,
136
+ "convert",
137
+ inputPath,
138
+ resolved,
139
+ "output already exists",
140
+ ExitCode.OutputFailure,
141
+ shared,
142
+ );
143
+ emitReport(report, shared.diagnostics, shared.json);
144
+ return ExitCode.OutputFailure;
145
+ } catch {
146
+ // target does not exist yet
147
+ }
148
+ }
149
+
150
+ parseAssetPolicy(readString(options, "asset-policy"));
151
+ parseLinkPolicy(readString(options, "link-policy"));
152
+
153
+ const source = resolved.from ? findFormat(ctx.registry, resolved.from) : undefined;
154
+ const target = findFormat(ctx.registry, resolved.to);
155
+ if (!target) {
156
+ const report = buildBlockedReport(
157
+ ctx,
158
+ "convert",
159
+ inputPath,
160
+ resolved,
161
+ `unknown target format ${resolved.to}`,
162
+ ExitCode.InputUnsupported,
163
+ shared,
164
+ );
165
+ emitReport(report, shared.diagnostics, shared.json);
166
+ return ExitCode.InputUnsupported;
167
+ }
168
+
169
+ const routeReady = hasPipeline(ctx.bindings, resolved.from, resolved.to);
170
+
171
+ if (resolved.from === "doc") {
172
+ const report = buildBlockedReport(
173
+ ctx,
174
+ "convert",
175
+ inputPath,
176
+ resolved,
177
+ "legacy .doc import requires OLE reader support; convert to .docx first",
178
+ ExitCode.InputUnsupported,
179
+ shared,
180
+ );
181
+ emitReport(report, shared.diagnostics, shared.json);
182
+ return ExitCode.InputUnsupported;
183
+ }
184
+
185
+ if (!routeReady) {
186
+ if (source && !isOperationReady(source, "read")) {
187
+ const report = buildBlockedReport(
188
+ ctx,
189
+ "convert",
190
+ inputPath,
191
+ resolved,
192
+ `source format ${source.format} is not ready for read`,
193
+ ExitCode.InputUnsupported,
194
+ shared,
195
+ );
196
+ emitReport(report, shared.diagnostics, shared.json);
197
+ return ExitCode.InputUnsupported;
198
+ }
199
+
200
+ if (!isOperationReady(target, "write")) {
201
+ const report = buildBlockedReport(
202
+ ctx,
203
+ "convert",
204
+ inputPath,
205
+ resolved,
206
+ `target format ${target.format} is not ready for write`,
207
+ ExitCode.InputUnsupported,
208
+ shared,
209
+ );
210
+ emitReport(report, shared.diagnostics, shared.json);
211
+ return ExitCode.InputUnsupported;
212
+ }
213
+ }
214
+
215
+ if (dryRun) {
216
+ const report = createReport({
217
+ operation: "convert",
218
+ toolVersion: ctx.toolVersion,
219
+ status: "success",
220
+ inputs: [{ path: inputPath, format: resolved.from }],
221
+ detection: { format: resolved.from, confidence: "hint", hints: resolved.hints },
222
+ pipeline: {
223
+ reader: source?.format ?? resolved.from,
224
+ writer: target.format,
225
+ stages: ["detect", "read", "ir", "write", "publish"],
226
+ },
227
+ outputs: outputPath ? [{ path: outputPath, published: false }] : [],
228
+ budgets: shared.budgets,
229
+ determinism: { profile: shared.profile ?? "default", config: shared.config ?? null },
230
+ statusPolicy: { loss: shared.loss, strict: shared.strict, exit_code: ExitCode.Success },
231
+ });
232
+ emitReport(report, shared.diagnostics, shared.json);
233
+ return ExitCode.Success;
234
+ }
235
+
236
+ if (routeReady && ctx.bindings) {
237
+ try {
238
+ const result = await runConversionPipeline({
239
+ ctx,
240
+ bindings: ctx.bindings,
241
+ inputPath,
242
+ outputPath,
243
+ resolved,
244
+ shared,
245
+ toolVersion: ctx.toolVersion,
246
+ });
247
+ return emitPipelineResult(result, shared);
248
+ } catch (error) {
249
+ const message = error instanceof Error ? error.message : String(error);
250
+ const report = buildBlockedReport(
251
+ ctx,
252
+ "convert",
253
+ inputPath,
254
+ resolved,
255
+ message,
256
+ ExitCode.IrOrWriterFailure,
257
+ shared,
258
+ );
259
+ emitReport(report, shared.diagnostics, shared.json);
260
+ return ExitCode.IrOrWriterFailure;
261
+ }
262
+ }
263
+
264
+ const report = createReport({
265
+ operation: "convert",
266
+ toolVersion: ctx.toolVersion,
267
+ status: "blocked",
268
+ inputs: [{ path: inputPath, format: resolved.from }],
269
+ detection: { format: resolved.from, confidence: "hint", hints: resolved.hints },
270
+ pipeline: {
271
+ reader: source?.format ?? resolved.from,
272
+ writer: target.format,
273
+ stages: ["detect", "read", "ir", "write", "publish"],
274
+ blocked_reason: "conversion pipeline is not wired yet",
275
+ },
276
+ diagnostics: [
277
+ {
278
+ owner: "panduck",
279
+ severity: "error",
280
+ message: "conversion pipeline is not wired yet",
281
+ code: "panduck.convert.not_implemented",
282
+ },
283
+ ],
284
+ outputs: outputPath ? [{ path: outputPath, published: false }] : [],
285
+ budgets: shared.budgets,
286
+ determinism: { profile: shared.profile ?? "default", config: shared.config ?? null },
287
+ statusPolicy: { loss: shared.loss, strict: shared.strict, exit_code: ExitCode.IrOrWriterFailure },
288
+ });
289
+
290
+ emitReport(report, shared.diagnostics, shared.json);
291
+ return ExitCode.IrOrWriterFailure;
292
+ } catch (error) {
293
+ if (error instanceof CliOptionError) {
294
+ console.error(error.message);
295
+ return error.exitCode;
296
+ }
297
+ console.error(error instanceof Error ? error.message : String(error));
298
+ return ExitCode.InvalidArgs;
299
+ }
300
+ }