@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/LICENSE +661 -0
- package/LICENSES/MIT-universal-ontology.txt +9 -0
- package/README.md +45 -0
- package/THIRD-PARTY-NOTICES.md +34 -0
- package/assets/native-build.json +56 -0
- package/assets/native-rights.json +1832 -0
- package/assets/snapper.toml +4 -0
- package/assets/tool-manifest.json +1897 -0
- package/docs/ci-trust.md +40 -0
- package/docs/consumer-guide.md +87 -0
- package/docs/support-and-release.md +72 -0
- package/package.json +51 -0
- package/schemas/configuration.schema.json +48 -0
- package/schemas/result.schema.json +247 -0
- package/src/analysis.js +119 -0
- package/src/cli.js +85 -0
- package/src/configuration.js +123 -0
- package/src/contracts.js +55 -0
- package/src/document-analysis.js +79 -0
- package/src/document-worker.js +30 -0
- package/src/documents.js +136 -0
- package/src/formatting.js +44 -0
- package/src/native-tool.js +135 -0
- package/src/prose-diagnostics.js +58 -0
- package/src/quality.js +156 -0
- package/src/replacement.js +62 -0
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
|
+
}
|
package/src/contracts.js
ADDED
|
@@ -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
|
+
});
|
package/src/documents.js
ADDED
|
@@ -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
|
+
}
|