@hadden-industries/markdown-quality 0.1.0-alpha.2

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/src/cli.js ADDED
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+ import { parseArgs } from "node:util";
4
+ import { runQuality, createResult } from "./quality.js";
5
+ import { terminalText } from "./contracts.js";
6
+ const args = process.argv.slice(2);
7
+ if (args.length === 0 || ["--help", "-h"].includes(args[0])) {
8
+ process.stdout.write(
9
+ "Usage: markdown-quality <check|format|inspect> [--root PATH] [--config PATH] [--json] [--files-json JSON] [-- literal.md ...]\nFull selection is the default. Comparison options are deferred. Exit: 0 clean, 1 findings, 2 operation failure.\n",
10
+ );
11
+ } else {
12
+ const terminator = args.indexOf("--");
13
+ let json = args
14
+ .slice(0, terminator < 0 ? args.length : terminator)
15
+ .includes("--json");
16
+ try {
17
+ const { values, positionals } = parseArgs({
18
+ args: args.slice(1),
19
+ allowPositionals: true,
20
+ strict: true,
21
+ options: {
22
+ root: { type: "string" },
23
+ config: { type: "string" },
24
+ json: { type: "boolean" },
25
+ "files-json": { type: "string" },
26
+ },
27
+ });
28
+ const hasTerminator = args.includes("--");
29
+ if (
30
+ positionals.length !== (hasTerminator ? args.length - terminator - 1 : 0)
31
+ )
32
+ throw new Error("Literal filenames must follow --.");
33
+ json = values.json ?? false;
34
+ if (values["files-json"] !== undefined && hasTerminator)
35
+ throw new Error("Use only one explicit selection mode.");
36
+ const files =
37
+ values["files-json"] !== undefined
38
+ ? JSON.parse(values["files-json"])
39
+ : hasTerminator
40
+ ? positionals
41
+ : undefined;
42
+ const result = await runQuality({
43
+ mode: args[0],
44
+ root: values.root,
45
+ config: values.config,
46
+ files,
47
+ });
48
+ if (json) process.stdout.write(JSON.stringify(result) + "\n");
49
+ else {
50
+ for (const d of result.diagnostics)
51
+ process.stdout.write(
52
+ `${terminalText(d.path)}:${d.line}:${d.column}: ${d.source}/${d.rule}: ${terminalText(d.message)}\n`,
53
+ );
54
+ for (const e of result.errors)
55
+ process.stdout.write(`${e.code}: ${terminalText(e.message)}\n`);
56
+ process.stdout.write(
57
+ `${result.outcome}; ${result.selection.mode} selection; ${result.selection.files.length} documents; ${result.written.length} written.\n`,
58
+ );
59
+ if (args[0] === "inspect")
60
+ process.stdout.write(
61
+ JSON.stringify({
62
+ package: result.package,
63
+ preset: result.preset,
64
+ tools: result.tools,
65
+ configDigest: result.configDigest,
66
+ configuration: result.configuration,
67
+ selection: result.selection,
68
+ }) + "\n",
69
+ );
70
+ }
71
+ process.exitCode = result.exitCode;
72
+ } catch (error) {
73
+ const message = error.message.startsWith("Unexpected token")
74
+ ? "Invalid files JSON."
75
+ : error.message;
76
+ const result = createResult(args[0]);
77
+ result.errors = [{ code: "CLI_INPUT", message }];
78
+ process.stdout.write(
79
+ json
80
+ ? JSON.stringify(result) + "\n"
81
+ : `CLI_INPUT: ${terminalText(message)}\n`,
82
+ );
83
+ process.exitCode = 2;
84
+ }
85
+ }
@@ -0,0 +1,123 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
3
+ import { resolve, dirname, join, relative, isAbsolute, sep } from "node:path";
4
+ import Ajv from "ajv";
5
+ import markdown from "@eslint/markdown";
6
+ import { decode, digest, fail, limits } from "./contracts.js";
7
+ const schema = JSON.parse(
8
+ readFileSync(
9
+ new URL("../schemas/configuration.schema.json", import.meta.url),
10
+ "utf8",
11
+ ),
12
+ );
13
+ const validate = new Ajv({ allErrors: true, strict: true }).compile(schema);
14
+ export function contained(root, path) {
15
+ const rel = relative(root, path);
16
+ return (
17
+ rel === "" ||
18
+ (!isAbsolute(rel) && rel !== ".." && !rel.startsWith(".." + sep))
19
+ );
20
+ }
21
+ export function safePath(root, input, { missing = false, file = false } = {}) {
22
+ if (
23
+ typeof input !== "string" ||
24
+ input.includes("\0") ||
25
+ input.replace(/^[A-Za-z]:[\\/]/u, "").includes(":")
26
+ )
27
+ fail("UNSAFE_PATH", "Invalid path.");
28
+ const path = resolve(root, input);
29
+ if (!contained(root, path))
30
+ fail("UNSAFE_PATH", "Path escapes the consumer root.");
31
+ const parts = relative(root, path).split(sep).filter(Boolean);
32
+ if (parts.length === 0 && file && !lstatSync(root).isFile())
33
+ fail("INVALID_FILE", "Expected a regular file.");
34
+ let current = root;
35
+ for (let i = 0; i < parts.length; i++) {
36
+ current = join(current, parts[i]);
37
+ let stat;
38
+ try {
39
+ stat = lstatSync(current);
40
+ } catch (error) {
41
+ if (missing && error.code === "ENOENT") return path;
42
+ throw error;
43
+ }
44
+ if (stat.isSymbolicLink())
45
+ fail("UNSAFE_PATH", "Symbolic links and junctions are not supported.");
46
+ if (!contained(root, realpathSync(current)))
47
+ fail("UNSAFE_PATH", "Resolved path escapes the consumer root.");
48
+ if (i < parts.length - 1 && !stat.isDirectory())
49
+ fail("UNSAFE_PATH", "A path parent is not a directory.");
50
+ if (i === parts.length - 1 && file && !stat.isFile())
51
+ fail("INVALID_FILE", "Expected a regular file.");
52
+ }
53
+ return path;
54
+ }
55
+ export function loadConfiguration({ root, config } = {}) {
56
+ if (!root) {
57
+ root = resolve(process.cwd());
58
+ while (!existsSync(join(root, ".markdown-quality.json"))) {
59
+ const parent = dirname(root);
60
+ if (parent === root)
61
+ fail("MISSING_CONFIG", "No .markdown-quality.json found.");
62
+ root = parent;
63
+ }
64
+ }
65
+ root = resolve(root);
66
+ const rootStat = lstatSync(root);
67
+ if (
68
+ !rootStat.isDirectory() ||
69
+ rootStat.isSymbolicLink() ||
70
+ realpathSync(root) !== root
71
+ )
72
+ fail(
73
+ "UNSAFE_ROOT",
74
+ "Consumer root must be a real directory without linked parents.",
75
+ );
76
+ const path = safePath(root, config ?? ".markdown-quality.json", {
77
+ file: true,
78
+ });
79
+ if (lstatSync(path).size > limits.configBytes)
80
+ fail("CONFIG_LIMIT", "Configuration exceeds the size limit.");
81
+ const bytes = readFileSync(path);
82
+ let value;
83
+ try {
84
+ value = JSON.parse(decode(bytes));
85
+ } catch (error) {
86
+ if (error.code) throw error;
87
+ fail("INVALID_CONFIG", "Configuration must be JSON.");
88
+ }
89
+ if (!validate(value))
90
+ fail(
91
+ "INVALID_CONFIG",
92
+ "Configuration does not match schema version 1: " +
93
+ JSON.stringify(validate.errors),
94
+ );
95
+ for (const rule of Object.keys(value.lint ?? {})) {
96
+ if (
97
+ !rule.startsWith("markdown/") ||
98
+ !Object.hasOwn(markdown.rules, rule.slice(9))
99
+ )
100
+ fail("INVALID_RULE", "Unknown Markdown rule: " + rule);
101
+ }
102
+ for (const pattern of [...value.include, ...(value.exclude ?? [])]) {
103
+ if (
104
+ pattern.startsWith("/") ||
105
+ pattern.includes("\\") ||
106
+ pattern.split("/").includes("..") ||
107
+ pattern.includes("\0")
108
+ )
109
+ fail(
110
+ "INVALID_PATTERN",
111
+ "Patterns must be relative slash-separated globs.",
112
+ );
113
+ }
114
+ const effective = {
115
+ ...value,
116
+ exclude: value.exclude ?? [],
117
+ ignoreFiles: value.ignoreFiles ?? [".gitignore", ".prettierignore"],
118
+ lint: value.lint ?? {},
119
+ links: { localFiles: true, rootRelative: "reject", ...value.links },
120
+ layout: { endOfLine: "preserve", tabWidth: 2, ...value.layout },
121
+ };
122
+ return { root, config: effective, configDigest: digest(bytes) };
123
+ }
@@ -0,0 +1,55 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { createHash } from "node:crypto";
3
+ import { readFileSync } from "node:fs";
4
+ export const packageRoot = new URL("../", import.meta.url);
5
+ export const metadata = JSON.parse(
6
+ readFileSync(new URL("../package.json", import.meta.url), "utf8"),
7
+ );
8
+ export const limits = Object.freeze({
9
+ fileBytes: 2_097_152,
10
+ totalBytes: 33_554_432,
11
+ files: 10_000,
12
+ entries: 100_000,
13
+ configBytes: 262_144,
14
+ nativeMs: 15_000,
15
+ nativeOutputBytes: 8_388_608,
16
+ diagnostics: 10_000,
17
+ documentDiagnostics: 1_000,
18
+ diagnosticBytes: 4_194_304,
19
+ selectionBytes: 4_194_304,
20
+ });
21
+ export const digest = (bytes) =>
22
+ createHash("sha256").update(bytes).digest("hex");
23
+ export class OperationError extends Error {
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.code = code;
27
+ }
28
+ }
29
+ export function fail(code, message) {
30
+ throw new OperationError(code, message);
31
+ }
32
+ export function diagnosticBudget(diagnostics) {
33
+ if (
34
+ diagnostics.length > limits.documentDiagnostics ||
35
+ Buffer.byteLength(JSON.stringify(diagnostics)) > limits.diagnosticBytes
36
+ )
37
+ fail(
38
+ "DIAGNOSTIC_LIMIT",
39
+ "Diagnostic count or output bytes exceed the limit.",
40
+ );
41
+ }
42
+ export function terminalText(value) {
43
+ return String(value).replace(
44
+ /[\u0000-\u001f\u007f-\u009f\u2028-\u202e\u2066-\u2069]/gu,
45
+ (character) =>
46
+ "\\u" + character.codePointAt(0).toString(16).padStart(4, "0"),
47
+ );
48
+ }
49
+ export function decode(bytes) {
50
+ try {
51
+ return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
52
+ } catch {
53
+ fail("INVALID_UTF8", "Input is not valid UTF-8.");
54
+ }
55
+ }
@@ -0,0 +1,79 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { Worker } from "node:worker_threads";
3
+ import { OperationError } from "./contracts.js";
4
+ export function createDocumentAnalyzer(context, tool) {
5
+ const worker = new Worker(new URL("./document-worker.js", import.meta.url), {
6
+ workerData: { context, tool },
7
+ resourceLimits: { maxOldGenerationSizeMb: 128, stackSizeMb: 4 },
8
+ env: {},
9
+ });
10
+ let pending,
11
+ closed = false,
12
+ termination;
13
+ function settle(error, value) {
14
+ if (!pending) return;
15
+ const request = pending;
16
+ pending = undefined;
17
+ clearTimeout(request.timer);
18
+ if (error) request.reject(error);
19
+ else request.resolve(value);
20
+ }
21
+ function close() {
22
+ closed = true;
23
+ settle(
24
+ new OperationError("ANALYSIS_FAILURE", "Document analysis stopped."),
25
+ );
26
+ return (termination ??= worker.terminate());
27
+ }
28
+ worker.on("message", (value) =>
29
+ value.error
30
+ ? settle(new OperationError(value.error.code, value.error.message))
31
+ : settle(null, value),
32
+ );
33
+ worker.on("error", () => {
34
+ closed = true;
35
+ settle(
36
+ new OperationError(
37
+ "ANALYSIS_FAILURE",
38
+ "Document analysis failed or exceeded its memory limit.",
39
+ ),
40
+ );
41
+ });
42
+ worker.on("exit", () => {
43
+ closed = true;
44
+ settle(
45
+ new OperationError(
46
+ "ANALYSIS_FAILURE",
47
+ "Document worker exited without a result.",
48
+ ),
49
+ );
50
+ });
51
+ return {
52
+ analyze(data) {
53
+ if (closed || pending)
54
+ return Promise.reject(
55
+ new OperationError(
56
+ "ANALYSIS_FAILURE",
57
+ "Document analysis is unavailable.",
58
+ ),
59
+ );
60
+ return new Promise((resolve, reject) => {
61
+ pending = {
62
+ resolve,
63
+ reject,
64
+ timer: setTimeout(() => {
65
+ settle(
66
+ new OperationError(
67
+ "ANALYSIS_TIMEOUT",
68
+ "Document analysis exceeded 30 seconds.",
69
+ ),
70
+ );
71
+ void close();
72
+ }, 30_000),
73
+ };
74
+ worker.postMessage(data);
75
+ });
76
+ },
77
+ close,
78
+ };
79
+ }
@@ -0,0 +1,30 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { formatDocument } from "./formatting.js";
4
+ import { createLinter, lintDocument, checkLinks } from "./analysis.js";
5
+ import { diagnosticBudget, OperationError } from "./contracts.js";
6
+ const { context, tool } = workerData;
7
+ const linter = createLinter(context);
8
+ parentPort.on("message", async ({ text, file, mode }) => {
9
+ try {
10
+ const formatted = await formatDocument(text, context, tool);
11
+ const proposed = mode === "format" ? formatted.output : text;
12
+ const diagnostics = [
13
+ ...formatted.diagnostics,
14
+ ...(await lintDocument(linter, proposed, file)),
15
+ ...checkLinks(context, proposed, file),
16
+ ];
17
+ diagnosticBudget(diagnostics);
18
+ parentPort.postMessage({ output: formatted.output, diagnostics });
19
+ } catch (error) {
20
+ parentPort.postMessage({
21
+ error: {
22
+ code: error instanceof OperationError ? error.code : "ANALYSIS_FAILURE",
23
+ message:
24
+ error instanceof OperationError
25
+ ? error.message
26
+ : "Document analysis failed.",
27
+ },
28
+ });
29
+ }
30
+ });
@@ -0,0 +1,136 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import {
3
+ lstatSync,
4
+ openSync,
5
+ closeSync,
6
+ fstatSync,
7
+ readSync,
8
+ opendirSync,
9
+ } from "node:fs";
10
+ import { join, relative, sep } from "node:path";
11
+ import picomatch from "picomatch";
12
+ import * as prettier from "prettier";
13
+ import { safePath } from "./configuration.js";
14
+ import { fail, limits } from "./contracts.js";
15
+ const blocked = new Set([
16
+ ".git",
17
+ "node_modules",
18
+ ".venv",
19
+ "venv",
20
+ ".hi",
21
+ ".sdlc",
22
+ ]);
23
+ export async function selectDocuments(context, files) {
24
+ const { root, config } = context;
25
+ if (
26
+ files !== undefined &&
27
+ (!Array.isArray(files) ||
28
+ files.some((p) => typeof p !== "string") ||
29
+ files.length > limits.files)
30
+ )
31
+ fail(
32
+ "INVALID_SELECTION",
33
+ "Explicit files must be a bounded JSON string array.",
34
+ );
35
+ const included = picomatch(config.include, { dot: true });
36
+ const excluded = picomatch(config.exclude, { dot: true });
37
+ const ignores = config.ignoreFiles.map((p) => {
38
+ const path = safePath(root, p, { missing: true, file: true });
39
+ try {
40
+ if (lstatSync(path).size > limits.configBytes)
41
+ fail("IGNORE_LIMIT", "Ignore file exceeds the size limit.");
42
+ } catch (error) {
43
+ if (error.code !== "ENOENT") throw error;
44
+ }
45
+ return path;
46
+ });
47
+ let visited = 0;
48
+ const candidates = [];
49
+ let candidateBytes = 0;
50
+ function addCandidate(path) {
51
+ candidateBytes += Buffer.byteLength(path);
52
+ if (candidateBytes > limits.selectionBytes)
53
+ fail("SELECTION_LIMIT", "Discovered path bytes exceed the limit.");
54
+ candidates.push(path);
55
+ }
56
+ function walk(dir) {
57
+ const directory = opendirSync(dir);
58
+ try {
59
+ for (let entry; (entry = directory.readSync()) !== null;) {
60
+ if (++visited > limits.entries)
61
+ fail("SELECTION_LIMIT", "Directory entry limit exceeded.");
62
+ if (blocked.has(entry.name)) continue;
63
+ const path = join(dir, entry.name);
64
+ if (entry.isSymbolicLink()) continue;
65
+ if (entry.isDirectory()) {
66
+ if (!excluded(relative(root, path).split(sep).join("/") + "/"))
67
+ walk(path);
68
+ } else if (entry.isFile() && entry.name.endsWith(".md"))
69
+ addCandidate(path);
70
+ }
71
+ } finally {
72
+ directory.closeSync();
73
+ }
74
+ }
75
+ if (files === undefined) walk(root);
76
+ else
77
+ for (const file of files)
78
+ addCandidate(safePath(root, file, { file: true }));
79
+ const selected = [],
80
+ exclusions = [];
81
+ for (const path of [...new Set(candidates)].sort()) {
82
+ const rel = relative(root, path).split(sep).join("/");
83
+ let reason;
84
+ if (rel.split("/").some((part) => blocked.has(part))) reason = "mandatory";
85
+ else if (!rel.endsWith(".md") || !included(rel)) reason = "not-included";
86
+ else if (excluded(rel)) reason = "excluded";
87
+ else if (
88
+ (
89
+ await prettier.getFileInfo(path, {
90
+ ignorePath: ignores,
91
+ resolveConfig: false,
92
+ withNodeModules: false,
93
+ })
94
+ ).ignored
95
+ )
96
+ reason = "ignored";
97
+ if (reason) exclusions.push({ path: rel, reason });
98
+ else {
99
+ safePath(root, path, { file: true });
100
+ selected.push(rel);
101
+ }
102
+ }
103
+ if (selected.length > limits.files)
104
+ fail("SELECTION_LIMIT", "Document count limit exceeded.");
105
+ return {
106
+ files: selected,
107
+ exclusions,
108
+ mode: files === undefined ? "full" : "explicit",
109
+ };
110
+ }
111
+ export function readDocument(root, file) {
112
+ const path = safePath(root, file, { file: true });
113
+ const fd = openSync(path, "r");
114
+ try {
115
+ const stat = fstatSync(fd, { bigint: true });
116
+ if (!stat.isFile()) fail("INVALID_FILE", "Expected a regular file.");
117
+ if (stat.size > BigInt(limits.fileBytes))
118
+ fail("DOCUMENT_LIMIT", "Document exceeds the size limit.");
119
+ const buffer = Buffer.alloc(limits.fileBytes + 1);
120
+ let size = 0,
121
+ count;
122
+ while (
123
+ size < buffer.length &&
124
+ (count = readSync(fd, buffer, size, buffer.length - size, null)) !== 0
125
+ )
126
+ size += count;
127
+ if (size > limits.fileBytes)
128
+ fail("DOCUMENT_LIMIT", "Document exceeds the size limit.");
129
+ // Own only the observed bytes, rather than retaining the bounded scratch buffer.
130
+ const bytes = Buffer.alloc(size);
131
+ buffer.copy(bytes, 0, 0, size);
132
+ return { path, bytes, stat };
133
+ } finally {
134
+ closeSync(fd);
135
+ }
136
+ }
@@ -0,0 +1,44 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import * as prettier from "prettier";
3
+ import { parse } from "./analysis.js";
4
+ import { runNative } from "./native-tool.js";
5
+ import { checkProse } from "./prose-diagnostics.js";
6
+ import { fail } from "./contracts.js";
7
+ function semantic(node) {
8
+ const value = {};
9
+ for (const [key, item] of Object.entries(node)) {
10
+ if (key === "position") continue;
11
+ if (key === "children") value.children = item.map(semantic);
12
+ else if (key === "value" && node.type === "text")
13
+ value.value = item.replace(/\s+/gu, " ");
14
+ else value[key] = item;
15
+ }
16
+ return value;
17
+ }
18
+ export async function formatDocument(text, context, tool) {
19
+ const endOfLine =
20
+ context.config.layout.endOfLine === "preserve"
21
+ ? text.includes("\r\n")
22
+ ? "crlf"
23
+ : "lf"
24
+ : context.config.layout.endOfLine;
25
+ const options = {
26
+ parser: "markdown",
27
+ proseWrap: "preserve",
28
+ embeddedLanguageFormatting: "off",
29
+ endOfLine,
30
+ tabWidth: context.config.layout.tabWidth,
31
+ plugins: [],
32
+ };
33
+ const layout = await prettier.format(text, options);
34
+ const result = await prettier.format(runNative(tool, layout), options);
35
+ if (
36
+ JSON.stringify(semantic(parse(text))) !==
37
+ JSON.stringify(semantic(parse(result)))
38
+ )
39
+ fail("PRESERVATION", "Formatting changed parsed meaning or a literal.");
40
+ const second = await prettier.format(runNative(tool, result), options);
41
+ if (result !== second)
42
+ fail("CONVERGENCE", "Formatter pipeline did not converge.");
43
+ return { output: result, diagnostics: checkProse(tool, result) };
44
+ }
@@ -0,0 +1,135 @@
1
+ // SPDX-License-Identifier: AGPL-3.0-only
2
+ import { createRequire } from "node:module";
3
+ import { readFileSync, lstatSync, realpathSync } from "node:fs";
4
+ import { join, dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawnSync } from "node:child_process";
7
+ import { decode, digest, fail, limits, metadata } from "./contracts.js";
8
+ const require = createRequire(import.meta.url);
9
+ export const manifest = JSON.parse(
10
+ readFileSync(
11
+ new URL("../assets/tool-manifest.json", import.meta.url),
12
+ "utf8",
13
+ ),
14
+ );
15
+ export function resolveTool() {
16
+ const key = `${process.platform}-${process.arch}`;
17
+ const spec = manifest.platforms[key];
18
+ if (
19
+ !spec ||
20
+ (process.platform === "linux" &&
21
+ !process.report.getReport().header.glibcVersionRuntime)
22
+ )
23
+ fail(
24
+ "UNSUPPORTED_PLATFORM",
25
+ "Only Windows x64 and glibc Linux x64 are supported.",
26
+ );
27
+ let pkg;
28
+ try {
29
+ pkg = require.resolve(spec.package + "/package.json");
30
+ } catch {
31
+ fail(
32
+ "MISSING_TOOL",
33
+ "Install the matching optional platform package with lifecycle scripts disabled.",
34
+ );
35
+ }
36
+ if (JSON.parse(readFileSync(pkg, "utf8")).version !== metadata.version)
37
+ fail("TOOL_VERSION", "Core and native package versions differ.");
38
+ const executable = join(dirname(pkg), spec.executable);
39
+ if (!lstatSync(executable).isFile() || lstatSync(executable).isSymbolicLink())
40
+ fail("TOOL_IDENTITY", "Native executable must be a regular file.");
41
+ if (digest(readFileSync(executable)) !== spec.sha256)
42
+ fail("TOOL_IDENTITY", "Native executable digest mismatch.");
43
+ return {
44
+ executable: realpathSync(executable),
45
+ key,
46
+ version: manifest.snapperVersion,
47
+ sha256: spec.sha256,
48
+ };
49
+ }
50
+ export function validateDiagnostics(result, text) {
51
+ if (result.error || result.signal || ![0, 1].includes(result.status))
52
+ fail("NATIVE_FAILURE", "Native check failed or exceeded resource limits.");
53
+ let reports;
54
+ try {
55
+ reports = JSON.parse(decode(result.stdout));
56
+ } catch {
57
+ fail("NATIVE_REPORT", "Malformed native JSON.");
58
+ }
59
+ if (Array.isArray(reports) && reports.length === 0 && result.status === 0)
60
+ return [];
61
+ if (
62
+ !Array.isArray(reports) ||
63
+ reports.length !== 1 ||
64
+ typeof reports[0]?.would_reformat !== "boolean" ||
65
+ !Array.isArray(reports[0].diagnostics)
66
+ )
67
+ fail("NATIVE_REPORT", "Invalid single-document native report.");
68
+ const lines = text.split("\n").length;
69
+ for (const d of reports[0].diagnostics)
70
+ if (
71
+ !d ||
72
+ !["fused", "wrap", "long"].includes(d.kind) ||
73
+ !Number.isSafeInteger(d.line) ||
74
+ d.line < 1 ||
75
+ d.line > lines ||
76
+ typeof d.excerpt !== "string"
77
+ )
78
+ fail("NATIVE_REPORT", "Invalid or unknown native diagnostic.");
79
+ if (
80
+ result.status === 1 &&
81
+ reports[0].diagnostics.length === 0 &&
82
+ !reports[0].would_reformat
83
+ )
84
+ fail("NATIVE_REPORT", "Inconsistent native failure report.");
85
+ // Snapper's documented advisory length threshold is independent of wrapping.
86
+ // This preset has no sentence-length policy; validate but omit that advisory.
87
+ return reports[0].diagnostics
88
+ .filter((d) => d.kind !== "long")
89
+ .map((d) => ({
90
+ source: "snapper",
91
+ rule: d.kind,
92
+ line: d.line,
93
+ column: 1,
94
+ message: `Sentence layout: ${d.kind}.`,
95
+ severity: "error",
96
+ }));
97
+ }
98
+ export function runNative(tool, text, check = false) {
99
+ const args = [
100
+ "--native",
101
+ "--config",
102
+ fileURLToPath(new URL("../assets/snapper.toml", import.meta.url)),
103
+ "--format",
104
+ "markdown",
105
+ "--stdin-filepath",
106
+ "document.md",
107
+ "--max-width",
108
+ "0",
109
+ "--color",
110
+ "never",
111
+ ];
112
+ if (check) args.push("--check", "--output-format", "json");
113
+ // A fixed working directory and virtual filename keep consumer configuration out.
114
+ const env = Object.fromEntries(
115
+ ["SystemRoot", "WINDIR", "TEMP", "TMP", "LANG", "LC_ALL"]
116
+ .filter((k) => process.env[k])
117
+ .map((k) => [k, process.env[k]]),
118
+ );
119
+ const result = spawnSync(tool.executable, args, {
120
+ input: Buffer.from(text),
121
+ cwd: fileURLToPath(new URL("../assets/", import.meta.url)),
122
+ env,
123
+ shell: false,
124
+ timeout: limits.nativeMs,
125
+ maxBuffer: limits.nativeOutputBytes,
126
+ windowsHide: true,
127
+ });
128
+ if (check) return validateDiagnostics(result, text);
129
+ if (result.error || result.signal || result.status !== 0)
130
+ fail(
131
+ "NATIVE_FAILURE",
132
+ "Native formatter failed or exceeded resource limits.",
133
+ );
134
+ return decode(result.stdout);
135
+ }