@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.
- package/bin/panduck.mjs +11 -0
- package/locales/en-US/cli.json +46 -0
- package/locales/locales.json +7 -0
- package/locales/zh-CN/cli.json +46 -0
- package/package.json +52 -0
- package/readme.md +5 -0
- package/src/cli/cli.ts +28 -0
- package/src/cli/commands/check-cmd.ts +174 -0
- package/src/cli/commands/convert-cmd.ts +300 -0
- package/src/cli/commands/doctor-cmd.ts +84 -0
- package/src/cli/commands/formats-cmd.ts +50 -0
- package/src/cli/commands/inspect-cmd.ts +231 -0
- package/src/cli/commands/plan-cmd.ts +188 -0
- package/src/cli/console-backend.ts +16 -0
- package/src/cli/context.ts +98 -0
- package/src/cli/diagnostics.ts +24 -0
- package/src/cli/exit-codes.ts +14 -0
- package/src/cli/format-registry.ts +170 -0
- package/src/cli/index.ts +7 -0
- package/src/cli/main.ts +10 -0
- package/src/cli/options.ts +127 -0
- package/src/cli/paths.ts +56 -0
- package/src/cli/pipeline.ts +225 -0
- package/src/cli/report.ts +110 -0
- package/src/index.ts +8 -0
- package/src/node/index.ts +14 -0
- package/src/node/load.ts +120 -0
- package/src/types.ts +49 -0
- package/src/wasm/index.ts +2 -0
- package/src/wasm/load.ts +23 -0
- package/src/wasm/panduck_wasm.d.ts +6 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/** Stable Panduck CLI exit codes (see Living CLI contract). */
|
|
2
|
+
export const ExitCode = {
|
|
3
|
+
Success: 0,
|
|
4
|
+
LossPolicyViolation: 1,
|
|
5
|
+
InvalidArgs: 2,
|
|
6
|
+
InputUnsupported: 3,
|
|
7
|
+
ParseFailure: 4,
|
|
8
|
+
IrOrWriterFailure: 5,
|
|
9
|
+
OutputFailure: 6,
|
|
10
|
+
BudgetExceeded: 7,
|
|
11
|
+
BatchFailure: 8,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type ExitCodeValue = (typeof ExitCode)[keyof typeof ExitCode];
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import type { PanduckBindings } from "../types.js";
|
|
2
|
+
|
|
3
|
+
export type CapabilityState =
|
|
4
|
+
| "registered"
|
|
5
|
+
| "planned"
|
|
6
|
+
| "partial"
|
|
7
|
+
| "ready"
|
|
8
|
+
| "unavailable"
|
|
9
|
+
| "blocked";
|
|
10
|
+
|
|
11
|
+
export type OperationKind = "read" | "write";
|
|
12
|
+
|
|
13
|
+
export type OperationCapability = {
|
|
14
|
+
state: CapabilityState;
|
|
15
|
+
stages: string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type FormatRecord = {
|
|
19
|
+
format: string;
|
|
20
|
+
container: string[];
|
|
21
|
+
aliases?: string[];
|
|
22
|
+
reader: OperationCapability;
|
|
23
|
+
writer: OperationCapability;
|
|
24
|
+
embeddedText?: string[];
|
|
25
|
+
coverage: {
|
|
26
|
+
state: CapabilityState;
|
|
27
|
+
known: string[];
|
|
28
|
+
unknown: string[];
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const TEXT_READ_STAGES = ["probe", "decode", "validate"];
|
|
33
|
+
const TEXT_WRITE_STAGES = ["ir", "validate"];
|
|
34
|
+
const CONTAINER_READ_STAGES = ["probe", "index", "decode", "validate"];
|
|
35
|
+
const CONTAINER_WRITE_STAGES = ["ir", "package", "validate"];
|
|
36
|
+
|
|
37
|
+
function textFormat(
|
|
38
|
+
format: string,
|
|
39
|
+
reader: CapabilityState,
|
|
40
|
+
writer: CapabilityState,
|
|
41
|
+
known: string[],
|
|
42
|
+
unknown: string[],
|
|
43
|
+
aliases?: string[],
|
|
44
|
+
): FormatRecord {
|
|
45
|
+
return {
|
|
46
|
+
format,
|
|
47
|
+
container: ["text"],
|
|
48
|
+
aliases,
|
|
49
|
+
reader: { state: reader, stages: TEXT_READ_STAGES },
|
|
50
|
+
writer: { state: writer, stages: TEXT_WRITE_STAGES },
|
|
51
|
+
coverage: { state: reader === "ready" && writer === "ready" ? "ready" : "partial", known, unknown },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function containerFormat(
|
|
56
|
+
format: string,
|
|
57
|
+
container: string[],
|
|
58
|
+
reader: CapabilityState,
|
|
59
|
+
writer: CapabilityState,
|
|
60
|
+
embeddedText: string[],
|
|
61
|
+
known: string[],
|
|
62
|
+
unknown: string[],
|
|
63
|
+
): FormatRecord {
|
|
64
|
+
return {
|
|
65
|
+
format,
|
|
66
|
+
container,
|
|
67
|
+
reader: { state: reader, stages: CONTAINER_READ_STAGES },
|
|
68
|
+
writer: { state: writer, stages: CONTAINER_WRITE_STAGES },
|
|
69
|
+
embeddedText,
|
|
70
|
+
coverage: {
|
|
71
|
+
state:
|
|
72
|
+
reader === "ready" && writer === "ready"
|
|
73
|
+
? "ready"
|
|
74
|
+
: reader === "planned" && writer === "planned"
|
|
75
|
+
? "planned"
|
|
76
|
+
: "partial",
|
|
77
|
+
known,
|
|
78
|
+
unknown,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Static capability registry aligned with the Panduck Living CLI matrix. */
|
|
84
|
+
export function baseFormatRegistry(): FormatRecord[] {
|
|
85
|
+
return [
|
|
86
|
+
textFormat("markdown", "partial", "ready", ["paragraph", "heading", "code_block", "link"], ["table", "footnote"]),
|
|
87
|
+
textFormat("notedown", "partial", "planned", ["paragraph", "heading", "list", "link"], ["macro", "table"]),
|
|
88
|
+
textFormat("org", "partial", "partial", ["headline", "paragraph"], ["drawer", "babel"]),
|
|
89
|
+
textFormat("rst", "partial", "partial", ["paragraph", "directive"], ["table"]),
|
|
90
|
+
textFormat("tex", "partial", "partial", ["paragraph", "command"], ["environment"]),
|
|
91
|
+
textFormat("html", "planned", "planned", ["paragraph", "heading"], ["script", "style"]),
|
|
92
|
+
containerFormat(
|
|
93
|
+
"docx",
|
|
94
|
+
["zip", "opc"],
|
|
95
|
+
"partial",
|
|
96
|
+
"partial",
|
|
97
|
+
["oak-xml"],
|
|
98
|
+
["paragraph", "heading", "list", "table", "style", "hyperlink", "image", "bold", "italic", "footnote_reference", "footnote_body"],
|
|
99
|
+
["tracked_changes", "comment"],
|
|
100
|
+
),
|
|
101
|
+
containerFormat(
|
|
102
|
+
"epub",
|
|
103
|
+
["zip", "ocf"],
|
|
104
|
+
"planned",
|
|
105
|
+
"planned",
|
|
106
|
+
["oak-xml", "oak-xhtml"],
|
|
107
|
+
["spine", "nav", "image"],
|
|
108
|
+
["fixed_layout"],
|
|
109
|
+
),
|
|
110
|
+
containerFormat(
|
|
111
|
+
"pdf",
|
|
112
|
+
["pdf"],
|
|
113
|
+
"planned",
|
|
114
|
+
"planned",
|
|
115
|
+
[],
|
|
116
|
+
["page", "text_run", "image"],
|
|
117
|
+
["annotation", "form", "encryption"],
|
|
118
|
+
),
|
|
119
|
+
containerFormat(
|
|
120
|
+
"doc",
|
|
121
|
+
["ole"],
|
|
122
|
+
"planned",
|
|
123
|
+
"unavailable",
|
|
124
|
+
["oak-xml"],
|
|
125
|
+
["paragraph"],
|
|
126
|
+
["field", "revision"],
|
|
127
|
+
),
|
|
128
|
+
];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Merges N-API `supportedFormats()` names into the static registry as `registered` floors. */
|
|
132
|
+
export function getFormatRegistry(bindings?: PanduckBindings | null): FormatRecord[] {
|
|
133
|
+
const registered = new Set((bindings?.supportedFormats() ?? []).map((name) => name.toLowerCase()));
|
|
134
|
+
return baseFormatRegistry().map((record) => {
|
|
135
|
+
if (!registered.has(record.format)) {
|
|
136
|
+
return record;
|
|
137
|
+
}
|
|
138
|
+
const readerState = bumpRegistered(record.reader.state);
|
|
139
|
+
const writerState = bumpRegistered(record.writer.state);
|
|
140
|
+
return {
|
|
141
|
+
...record,
|
|
142
|
+
reader: { ...record.reader, state: readerState },
|
|
143
|
+
writer: { ...record.writer, state: writerState },
|
|
144
|
+
};
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function bumpRegistered(state: CapabilityState): CapabilityState {
|
|
149
|
+
if (state === "planned" || state === "registered") {
|
|
150
|
+
return "registered";
|
|
151
|
+
}
|
|
152
|
+
return state;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function findFormat(registry: FormatRecord[], id: string): FormatRecord | undefined {
|
|
156
|
+
const needle = id.toLowerCase();
|
|
157
|
+
return registry.find(
|
|
158
|
+
(record) =>
|
|
159
|
+
record.format === needle || record.aliases?.some((alias) => alias.toLowerCase() === needle),
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function isOperationReady(record: FormatRecord, operation: OperationKind): boolean {
|
|
164
|
+
const capability = operation === "read" ? record.reader : record.writer;
|
|
165
|
+
return capability.state === "ready";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function ebookAliasTarget(): string {
|
|
169
|
+
return "epub";
|
|
170
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { buildPanduckCli } from "./cli.js";
|
|
2
|
+
export { runCli } from "./main.js";
|
|
3
|
+
export { ExitCode } from "./exit-codes.js";
|
|
4
|
+
export { getFormatRegistry, baseFormatRegistry } from "./format-registry.js";
|
|
5
|
+
export type { FormatRecord, CapabilityState, OperationKind } from "./format-registry.js";
|
|
6
|
+
export { REPORT_SCHEMA_VERSION } from "./report.js";
|
|
7
|
+
export type { PanduckReport } from "./report.js";
|
package/src/cli/main.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { buildPanduckCli } from "./cli.js";
|
|
2
|
+
|
|
3
|
+
export async function runCli(argv: string[]): Promise<number> {
|
|
4
|
+
try {
|
|
5
|
+
return await buildPanduckCli().parse(argv);
|
|
6
|
+
} catch (error) {
|
|
7
|
+
console.error(`error: ${error instanceof Error ? error.message : String(error)}`);
|
|
8
|
+
return 2;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { ParsedOptions } from "@vmz/commander";
|
|
2
|
+
|
|
3
|
+
import { ExitCode } from "./exit-codes.js";
|
|
4
|
+
import type { LossPolicy } from "./report.js";
|
|
5
|
+
|
|
6
|
+
export type AssetPolicy = "preserve" | "copy" | "embed" | "drop";
|
|
7
|
+
export type LinkPolicy = "preserve" | "rewrite" | "drop";
|
|
8
|
+
export type DiagnosticsMode = "silent" | "human" | "json";
|
|
9
|
+
export type InspectStage = "probe" | "index" | "decode" | "validate";
|
|
10
|
+
|
|
11
|
+
export type SharedCliOptions = {
|
|
12
|
+
from?: string;
|
|
13
|
+
to?: string;
|
|
14
|
+
profile?: string;
|
|
15
|
+
config?: string;
|
|
16
|
+
json: boolean;
|
|
17
|
+
budgets: Record<string, number>;
|
|
18
|
+
loss: LossPolicy;
|
|
19
|
+
strict: boolean;
|
|
20
|
+
allowPartial: boolean;
|
|
21
|
+
diagnostics: DiagnosticsMode;
|
|
22
|
+
overwrite: boolean;
|
|
23
|
+
logFile?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const LOSS_VALUES = new Set<LossPolicy>(["allow", "warn", "deny"]);
|
|
27
|
+
const ASSET_VALUES = new Set<AssetPolicy>(["preserve", "copy", "embed", "drop"]);
|
|
28
|
+
const LINK_VALUES = new Set<LinkPolicy>(["preserve", "rewrite", "drop"]);
|
|
29
|
+
const DIAG_VALUES = new Set<DiagnosticsMode>(["silent", "human", "json"]);
|
|
30
|
+
const STAGE_VALUES = new Set<InspectStage>(["probe", "index", "decode", "validate"]);
|
|
31
|
+
|
|
32
|
+
export function readFlag(options: ParsedOptions, key: string): boolean {
|
|
33
|
+
return options[key] === true;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function readString(options: ParsedOptions, key: string): string | undefined {
|
|
37
|
+
const value = options[key];
|
|
38
|
+
if (typeof value === "string" && value.length > 0) {
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readSharedOptions(options: ParsedOptions): SharedCliOptions {
|
|
45
|
+
const loss = parseEnum(readString(options, "loss") ?? "warn", LOSS_VALUES, "loss");
|
|
46
|
+
const diagnostics = parseEnum(
|
|
47
|
+
readString(options, "diagnostics") ?? "human",
|
|
48
|
+
DIAG_VALUES,
|
|
49
|
+
"diagnostics",
|
|
50
|
+
);
|
|
51
|
+
return {
|
|
52
|
+
from: readString(options, "from"),
|
|
53
|
+
to: readString(options, "to"),
|
|
54
|
+
profile: readString(options, "profile"),
|
|
55
|
+
config: readString(options, "config"),
|
|
56
|
+
json: readFlag(options, "json"),
|
|
57
|
+
budgets: parseBudgets(options.budget),
|
|
58
|
+
loss,
|
|
59
|
+
strict: readFlag(options, "strict"),
|
|
60
|
+
allowPartial: readFlag(options, "allow-partial"),
|
|
61
|
+
diagnostics,
|
|
62
|
+
overwrite: readFlag(options, "overwrite"),
|
|
63
|
+
logFile: readString(options, "log-file"),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function parseAssetPolicy(value: string | undefined): AssetPolicy {
|
|
68
|
+
return parseEnum(value ?? "preserve", ASSET_VALUES, "asset-policy");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function parseLinkPolicy(value: string | undefined): LinkPolicy {
|
|
72
|
+
return parseEnum(value ?? "preserve", LINK_VALUES, "link-policy");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function parseInspectStage(value: string | undefined): InspectStage {
|
|
76
|
+
return parseEnum(value ?? "probe", STAGE_VALUES, "stage");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseEnum<T extends string>(value: string, allowed: Set<T>, name: string): T {
|
|
80
|
+
if (!allowed.has(value as T)) {
|
|
81
|
+
throw new CliOptionError(name, value);
|
|
82
|
+
}
|
|
83
|
+
return value as T;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function parseBudgets(raw: string | boolean | string[] | undefined): Record<string, number> {
|
|
87
|
+
const entries = normalizeRepeatable(raw);
|
|
88
|
+
const budgets: Record<string, number> = {};
|
|
89
|
+
for (const item of entries) {
|
|
90
|
+
const eq = item.indexOf("=");
|
|
91
|
+
if (eq <= 0) {
|
|
92
|
+
throw new CliOptionError("budget", item);
|
|
93
|
+
}
|
|
94
|
+
const key = item.slice(0, eq).trim();
|
|
95
|
+
const value = Number(item.slice(eq + 1).trim());
|
|
96
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
97
|
+
throw new CliOptionError("budget", item);
|
|
98
|
+
}
|
|
99
|
+
budgets[key] = value;
|
|
100
|
+
}
|
|
101
|
+
return budgets;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function normalizeRepeatable(raw: string | boolean | string[] | undefined): string[] {
|
|
105
|
+
if (raw === undefined || raw === false) {
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(raw)) {
|
|
109
|
+
return raw;
|
|
110
|
+
}
|
|
111
|
+
if (typeof raw === "string") {
|
|
112
|
+
return [raw];
|
|
113
|
+
}
|
|
114
|
+
return [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export class CliOptionError extends Error {
|
|
118
|
+
readonly field: string;
|
|
119
|
+
readonly value: string;
|
|
120
|
+
readonly exitCode = ExitCode.InvalidArgs;
|
|
121
|
+
|
|
122
|
+
constructor(field: string, value: string) {
|
|
123
|
+
super(`invalid ${field}: ${value}`);
|
|
124
|
+
this.field = field;
|
|
125
|
+
this.value = value;
|
|
126
|
+
}
|
|
127
|
+
}
|
package/src/cli/paths.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { extname, resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
const EXTENSION_HINTS: Record<string, string[]> = {
|
|
4
|
+
".md": ["markdown"],
|
|
5
|
+
".markdown": ["markdown"],
|
|
6
|
+
".nd": ["notedown"],
|
|
7
|
+
".org": ["org"],
|
|
8
|
+
".rst": ["rst"],
|
|
9
|
+
".tex": ["tex"],
|
|
10
|
+
".html": ["html"],
|
|
11
|
+
".htm": ["html"],
|
|
12
|
+
".docx": ["docx"],
|
|
13
|
+
".epub": ["epub"],
|
|
14
|
+
".pdf": ["pdf"],
|
|
15
|
+
".doc": ["doc"],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export function isStdinPath(path: string | undefined): boolean {
|
|
19
|
+
return path === "-";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function isStdoutPath(path: string | undefined): boolean {
|
|
23
|
+
return path === "-";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function normalizeFormatId(value: string): string {
|
|
27
|
+
const lowered = value.trim().toLowerCase();
|
|
28
|
+
if (lowered === "ebook") {
|
|
29
|
+
return "epub";
|
|
30
|
+
}
|
|
31
|
+
return lowered;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function extensionHints(path: string): string[] {
|
|
35
|
+
const ext = extname(path).toLowerCase();
|
|
36
|
+
return EXTENSION_HINTS[ext] ? [...EXTENSION_HINTS[ext]] : [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function inferTargetFromPath(path: string): string | undefined {
|
|
40
|
+
const hints = extensionHints(path);
|
|
41
|
+
return hints.length === 1 ? hints[0] : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function resolveFilesystemPath(path: string, cwd = process.cwd()): string {
|
|
45
|
+
if (isStdinPath(path) || isStdoutPath(path)) {
|
|
46
|
+
return path;
|
|
47
|
+
}
|
|
48
|
+
return resolve(cwd, path);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function pathsOverlap(inputPath: string, outputPath: string, cwd = process.cwd()): boolean {
|
|
52
|
+
if (isStdinPath(inputPath) || isStdoutPath(outputPath)) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
return resolveFilesystemPath(inputPath, cwd) === resolveFilesystemPath(outputPath, cwd);
|
|
56
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
|
|
6
|
+
import type { PanduckBindings } from "../types.js";
|
|
7
|
+
import { ExitCode } from "./exit-codes.js";
|
|
8
|
+
import type { SharedCliOptions } from "./options.js";
|
|
9
|
+
import { createReport, type PanduckReport } from "./report.js";
|
|
10
|
+
import { emitReport } from "./diagnostics.js";
|
|
11
|
+
import type { CliContext } from "./context.js";
|
|
12
|
+
import type { ResolvedFormats } from "./context.js";
|
|
13
|
+
import { isStdoutPath } from "./paths.js";
|
|
14
|
+
|
|
15
|
+
export type PipelineResult = {
|
|
16
|
+
exitCode: number;
|
|
17
|
+
report: PanduckReport;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function hasPipeline(
|
|
21
|
+
bindings: PanduckBindings | null,
|
|
22
|
+
from: string | undefined,
|
|
23
|
+
to: string,
|
|
24
|
+
): boolean {
|
|
25
|
+
if (!bindings?.supportsConversion || !from) {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
return bindings.supportsConversion(from, to);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function inferCheckTarget(bindings: PanduckBindings, from: string): string | undefined {
|
|
32
|
+
const routes = bindings.supportedConversions?.() ?? [];
|
|
33
|
+
const targets = routes
|
|
34
|
+
.filter((route) => route.startsWith(`${from}:`))
|
|
35
|
+
.map((route) => route.slice(from.length + 1));
|
|
36
|
+
if (targets.length === 1) {
|
|
37
|
+
return targets[0];
|
|
38
|
+
}
|
|
39
|
+
if (targets.includes("markdown")) {
|
|
40
|
+
return "markdown";
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export async function runCheckPipeline(input: {
|
|
46
|
+
ctx: CliContext;
|
|
47
|
+
bindings: PanduckBindings;
|
|
48
|
+
inputPath: string;
|
|
49
|
+
resolved: ResolvedFormats;
|
|
50
|
+
shared: SharedCliOptions;
|
|
51
|
+
}): Promise<PipelineResult> {
|
|
52
|
+
const { bindings, inputPath, resolved, shared, ctx } = input;
|
|
53
|
+
const from = resolved.from;
|
|
54
|
+
const to = resolved.to;
|
|
55
|
+
if (!from || !to) {
|
|
56
|
+
throw new Error("check requires resolved from/to formats");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const workdir = await mkdtemp(join(tmpdir(), "panduck-check-"));
|
|
60
|
+
const extension = to === "markdown" ? ".md" : `.${to}`;
|
|
61
|
+
const outputPath = join(workdir, `check${extension}`);
|
|
62
|
+
|
|
63
|
+
const result = await runConversionPipeline({
|
|
64
|
+
ctx,
|
|
65
|
+
bindings,
|
|
66
|
+
inputPath,
|
|
67
|
+
outputPath,
|
|
68
|
+
resolved: { ...resolved, to },
|
|
69
|
+
shared,
|
|
70
|
+
toolVersion: ctx.toolVersion,
|
|
71
|
+
operation: "check",
|
|
72
|
+
suppressStdout: true,
|
|
73
|
+
publishOutput: false,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
exitCode: result.exitCode,
|
|
78
|
+
report: {
|
|
79
|
+
...result.report,
|
|
80
|
+
operation: "check",
|
|
81
|
+
outputs: [{ path: outputPath, published: false }],
|
|
82
|
+
pipeline: {
|
|
83
|
+
...result.report.pipeline,
|
|
84
|
+
stages: ["read", "ir", "write", "validate"],
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function runConversionPipeline(input: {
|
|
91
|
+
ctx: CliContext;
|
|
92
|
+
bindings: PanduckBindings;
|
|
93
|
+
inputPath: string;
|
|
94
|
+
outputPath: string | undefined;
|
|
95
|
+
resolved: ResolvedFormats;
|
|
96
|
+
shared: SharedCliOptions;
|
|
97
|
+
toolVersion: string;
|
|
98
|
+
operation?: "convert" | "check";
|
|
99
|
+
suppressStdout?: boolean;
|
|
100
|
+
publishOutput?: boolean;
|
|
101
|
+
}): Promise<PipelineResult> {
|
|
102
|
+
const {
|
|
103
|
+
bindings,
|
|
104
|
+
inputPath,
|
|
105
|
+
outputPath,
|
|
106
|
+
resolved,
|
|
107
|
+
shared,
|
|
108
|
+
ctx,
|
|
109
|
+
operation = "convert",
|
|
110
|
+
suppressStdout = false,
|
|
111
|
+
publishOutput = true,
|
|
112
|
+
} = input;
|
|
113
|
+
const from = resolved.from;
|
|
114
|
+
const to = resolved.to;
|
|
115
|
+
if (!from || !to) {
|
|
116
|
+
throw new Error("conversion requires resolved from/to formats");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!bindings.convertDocument) {
|
|
120
|
+
return blocked(ctx, operation, inputPath, resolved, shared, "native convertDocument binding is missing");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const response = bindings.convertDocument(from, to, inputPath);
|
|
124
|
+
const markdown = response.markdown ?? "";
|
|
125
|
+
const binary = response.binary;
|
|
126
|
+
const parsedReport = safeParseReport(response.reportJson);
|
|
127
|
+
|
|
128
|
+
if (publishOutput && outputPath && !isStdoutPath(outputPath)) {
|
|
129
|
+
if (binary && binary.length > 0) {
|
|
130
|
+
await publishBinary(outputPath, binary);
|
|
131
|
+
} else {
|
|
132
|
+
await publishText(outputPath, markdown);
|
|
133
|
+
}
|
|
134
|
+
} else if (!suppressStdout && (isStdoutPath(outputPath) || !outputPath)) {
|
|
135
|
+
if (binary && binary.length > 0) {
|
|
136
|
+
process.stdout.write(binary);
|
|
137
|
+
} else {
|
|
138
|
+
process.stdout.write(markdown);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lossCount = Array.isArray(parsedReport?.losses) ? parsedReport.losses.length : 0;
|
|
143
|
+
const status = lossCount > 0 ? "success_with_loss" : "success";
|
|
144
|
+
let exitCode: number = ExitCode.Success;
|
|
145
|
+
if (shared.strict || shared.loss === "deny") {
|
|
146
|
+
if (lossCount > 0 || status === "success_with_loss") {
|
|
147
|
+
exitCode = ExitCode.LossPolicyViolation;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const report = createReport({
|
|
152
|
+
operation,
|
|
153
|
+
toolVersion: ctx.toolVersion,
|
|
154
|
+
status,
|
|
155
|
+
inputs: [{ path: inputPath, format: from }],
|
|
156
|
+
detection: { format: from, outer: "zip", inner: "opc", confidence: "verified", hints: resolved.hints },
|
|
157
|
+
pipeline: {
|
|
158
|
+
reader: from,
|
|
159
|
+
writer: to,
|
|
160
|
+
stages: operation === "check" ? ["read", "ir", "write", "validate"] : ["read", "ir", "write", "publish"],
|
|
161
|
+
},
|
|
162
|
+
losses: parsedReport?.losses as PanduckReport["losses"],
|
|
163
|
+
outputs: outputPath
|
|
164
|
+
? [{ path: outputPath, published: publishOutput && !isStdoutPath(outputPath) }]
|
|
165
|
+
: [{ path: "-", published: !suppressStdout }],
|
|
166
|
+
budgets: shared.budgets,
|
|
167
|
+
determinism: { profile: shared.profile ?? "default", config: shared.config ?? null },
|
|
168
|
+
statusPolicy: { loss: shared.loss, strict: shared.strict, exit_code: exitCode },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
return { exitCode, report };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function publishBinary(path: string, content: Uint8Array): Promise<void> {
|
|
175
|
+
const dir = dirname(path);
|
|
176
|
+
await mkdir(dir, { recursive: true });
|
|
177
|
+
const temp = join(dir, `.${basename(path)}.${randomBytes(4).toString("hex")}.tmp`);
|
|
178
|
+
await writeFile(temp, content);
|
|
179
|
+
await rename(temp, path);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function publishText(path: string, content: string): Promise<void> {
|
|
183
|
+
const dir = dirname(path);
|
|
184
|
+
await mkdir(dir, { recursive: true });
|
|
185
|
+
const temp = join(dir, `.${basename(path)}.${randomBytes(4).toString("hex")}.tmp`);
|
|
186
|
+
await writeFile(temp, content, "utf8");
|
|
187
|
+
await rename(temp, path);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function safeParseReport(raw: string): Record<string, unknown> | null {
|
|
191
|
+
try {
|
|
192
|
+
return JSON.parse(raw) as Record<string, unknown>;
|
|
193
|
+
} catch {
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function blocked(
|
|
199
|
+
ctx: CliContext,
|
|
200
|
+
operation: "convert" | "check",
|
|
201
|
+
inputPath: string,
|
|
202
|
+
resolved: ResolvedFormats,
|
|
203
|
+
shared: SharedCliOptions,
|
|
204
|
+
reason: string,
|
|
205
|
+
): PipelineResult {
|
|
206
|
+
const report = createReport({
|
|
207
|
+
operation,
|
|
208
|
+
toolVersion: ctx.toolVersion,
|
|
209
|
+
status: "blocked",
|
|
210
|
+
inputs: [{ path: inputPath, format: resolved.from }],
|
|
211
|
+
pipeline: { blocked_reason: reason },
|
|
212
|
+
diagnostics: [{ owner: "panduck", severity: "error", message: reason }],
|
|
213
|
+
determinism: { profile: shared.profile ?? "default", config: shared.config ?? null },
|
|
214
|
+
statusPolicy: { loss: shared.loss, strict: shared.strict, exit_code: ExitCode.IrOrWriterFailure },
|
|
215
|
+
});
|
|
216
|
+
return { exitCode: ExitCode.IrOrWriterFailure, report };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function emitPipelineResult(
|
|
220
|
+
result: PipelineResult,
|
|
221
|
+
shared: SharedCliOptions,
|
|
222
|
+
): Promise<number> {
|
|
223
|
+
emitReport(result.report, shared.diagnostics, shared.json);
|
|
224
|
+
return result.exitCode;
|
|
225
|
+
}
|