@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
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
// List-boundary recheck adapted from OwlAPI at 1cdc5a33b9538cce8ced88f21af18138da7a8923,
|
|
3
|
+
// scripts/documentation-quality.mjs, originally from universal-ontology at
|
|
4
|
+
// 58a306013d3701f59dfe34341e96fac5a011e3ed. Copyright 2026 Hadden Industries Ltd.
|
|
5
|
+
// Original MIT terms retained in LICENSES/MIT-universal-ontology.txt.
|
|
6
|
+
import { runNative } from "./native-tool.js";
|
|
7
|
+
// Requalified against 0.11.9. Owner approval: this chat, 2026-10-05.
|
|
8
|
+
// Mirrors OwlAPI's narrow container boundary and continuation recheck approach.
|
|
9
|
+
function listItem(line = "") {
|
|
10
|
+
let offset = 0,
|
|
11
|
+
quotes = 0;
|
|
12
|
+
while (true) {
|
|
13
|
+
const match = /^[ ]{0,3}> ?/u.exec(line.slice(offset));
|
|
14
|
+
if (!match) break;
|
|
15
|
+
offset += match[0].length;
|
|
16
|
+
quotes++;
|
|
17
|
+
}
|
|
18
|
+
const match = /^( {0,3})(\d{1,9}[.)]|[-+*]) +(\S.*)$/u.exec(
|
|
19
|
+
line.slice(offset),
|
|
20
|
+
);
|
|
21
|
+
if (!match) return undefined;
|
|
22
|
+
return {
|
|
23
|
+
prefix: line.slice(0, offset),
|
|
24
|
+
quotes,
|
|
25
|
+
indent: match[1].length,
|
|
26
|
+
ordered: /^\d/u.test(match[2]),
|
|
27
|
+
contentOffset: line.length - match[3].length,
|
|
28
|
+
prose: match[3],
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export function checkProse(tool, text) {
|
|
32
|
+
const lines = text.split(/\r?\n/u);
|
|
33
|
+
return runNative(tool, text, true).filter((d) => {
|
|
34
|
+
const index = d.line - 1,
|
|
35
|
+
item = listItem(lines[index]);
|
|
36
|
+
if (!item) return true;
|
|
37
|
+
const previous = listItem(lines[index - 1]);
|
|
38
|
+
const quotedNumber = d.rule === "fused" && item.quotes > 0 && item.ordered;
|
|
39
|
+
const adjacent =
|
|
40
|
+
d.rule === "wrap" &&
|
|
41
|
+
previous &&
|
|
42
|
+
previous.prefix === item.prefix &&
|
|
43
|
+
previous.indent === item.indent &&
|
|
44
|
+
previous.ordered === item.ordered;
|
|
45
|
+
if (!quotedNumber && !adjacent) return true;
|
|
46
|
+
const prose = [item.prose];
|
|
47
|
+
for (let next = index + 1; next < lines.length; next++) {
|
|
48
|
+
const line = lines[next];
|
|
49
|
+
if (!line.startsWith(item.prefix) || listItem(line)) break;
|
|
50
|
+
const content = line.slice(item.prefix.length),
|
|
51
|
+
indent = item.contentOffset - item.prefix.length;
|
|
52
|
+
if (!content.startsWith(" ".repeat(indent))) break;
|
|
53
|
+
prose.push(content.slice(indent));
|
|
54
|
+
}
|
|
55
|
+
// Malformed output, unknown findings, crashes, and timeouts still fail closed.
|
|
56
|
+
return runNative(tool, prose.join("\n") + "\n", true).length !== 0;
|
|
57
|
+
});
|
|
58
|
+
}
|
package/src/quality.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
import { loadConfiguration } from "./configuration.js";
|
|
3
|
+
import { selectDocuments, readDocument } from "./documents.js";
|
|
4
|
+
import { resolveTool, manifest } from "./native-tool.js";
|
|
5
|
+
import { createDocumentAnalyzer } from "./document-analysis.js";
|
|
6
|
+
import { replaceDocument } from "./replacement.js";
|
|
7
|
+
import {
|
|
8
|
+
decode,
|
|
9
|
+
fail,
|
|
10
|
+
limits,
|
|
11
|
+
metadata,
|
|
12
|
+
OperationError,
|
|
13
|
+
diagnosticBudget,
|
|
14
|
+
} from "./contracts.js";
|
|
15
|
+
export function createResult(mode = "check", explicit = false) {
|
|
16
|
+
return {
|
|
17
|
+
schemaVersion: 1,
|
|
18
|
+
package: { name: metadata.name, version: metadata.version },
|
|
19
|
+
preset: "authored-gfm@1",
|
|
20
|
+
tools: {
|
|
21
|
+
prettier: "3.9.9",
|
|
22
|
+
eslint: "10.12.0",
|
|
23
|
+
markdown: "8.0.3",
|
|
24
|
+
snapper: manifest.snapperVersion,
|
|
25
|
+
},
|
|
26
|
+
operation: ["check", "format", "inspect"].includes(mode) ? mode : "unknown",
|
|
27
|
+
configDigest: null,
|
|
28
|
+
configuration: null,
|
|
29
|
+
selection: {
|
|
30
|
+
mode: explicit ? "explicit" : "full",
|
|
31
|
+
files: [],
|
|
32
|
+
exclusions: [],
|
|
33
|
+
},
|
|
34
|
+
diagnostics: [],
|
|
35
|
+
outcome: "error",
|
|
36
|
+
exitCode: 2,
|
|
37
|
+
written: [],
|
|
38
|
+
unchanged: [],
|
|
39
|
+
unprocessed: [],
|
|
40
|
+
errors: [],
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
/** Run full or explicit read-only checking, inspection, or guarded formatting. */
|
|
44
|
+
export async function runQuality(options = {}) {
|
|
45
|
+
const mode = options.mode ?? "check";
|
|
46
|
+
const result = createResult(mode, options.files !== undefined);
|
|
47
|
+
let analyzer;
|
|
48
|
+
try {
|
|
49
|
+
if (!["check", "format", "inspect"].includes(mode))
|
|
50
|
+
fail("INVALID_OPERATION", "Unknown operation.");
|
|
51
|
+
const context = loadConfiguration(options);
|
|
52
|
+
result.configDigest = context.configDigest;
|
|
53
|
+
result.configuration = context.config;
|
|
54
|
+
result.selection = await selectDocuments(context, options.files);
|
|
55
|
+
result.unprocessed = [...result.selection.files];
|
|
56
|
+
// No native process or tool resolution is necessary for a zero selection.
|
|
57
|
+
if (result.selection.files.length === 0) {
|
|
58
|
+
result.outcome = "clean";
|
|
59
|
+
result.exitCode = 0;
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
const tool = resolveTool();
|
|
63
|
+
if (mode === "inspect") {
|
|
64
|
+
result.unprocessed = [];
|
|
65
|
+
result.outcome = "clean";
|
|
66
|
+
result.exitCode = 0;
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
analyzer = createDocumentAnalyzer(context, tool);
|
|
70
|
+
const candidates = [];
|
|
71
|
+
let total = 0,
|
|
72
|
+
outputTotal = 0,
|
|
73
|
+
diagnosticBytes = 0;
|
|
74
|
+
for (const file of result.selection.files) {
|
|
75
|
+
const item = readDocument(context.root, file);
|
|
76
|
+
if (mode === "format" && item.stat.nlink !== 1n)
|
|
77
|
+
fail("HARD_LINK", "Hard-linked documents cannot be formatted.");
|
|
78
|
+
total += item.bytes.length;
|
|
79
|
+
if (total > limits.totalBytes)
|
|
80
|
+
fail("BATCH_LIMIT", "Total document bytes exceed the limit.");
|
|
81
|
+
const original = decode(item.bytes);
|
|
82
|
+
const formatted = await analyzer.analyze({ text: original, file, mode });
|
|
83
|
+
const outputBytes = Buffer.byteLength(formatted.output);
|
|
84
|
+
if (outputBytes > limits.fileBytes)
|
|
85
|
+
fail("DOCUMENT_LIMIT", "Formatted document exceeds the size limit.");
|
|
86
|
+
outputTotal += outputBytes;
|
|
87
|
+
if (outputTotal > limits.totalBytes)
|
|
88
|
+
fail("BATCH_LIMIT", "Total formatted document bytes exceed the limit.");
|
|
89
|
+
const diagnostics = formatted.diagnostics;
|
|
90
|
+
if (mode === "check" && !item.bytes.equals(Buffer.from(formatted.output)))
|
|
91
|
+
diagnostics.push({
|
|
92
|
+
source: "formatter",
|
|
93
|
+
rule: "layout",
|
|
94
|
+
line: 1,
|
|
95
|
+
column: 1,
|
|
96
|
+
severity: "error",
|
|
97
|
+
message: "Document requires formatting.",
|
|
98
|
+
});
|
|
99
|
+
diagnosticBudget(diagnostics);
|
|
100
|
+
for (const d of diagnostics) {
|
|
101
|
+
const diagnostic = { path: file, ...d };
|
|
102
|
+
diagnosticBytes += Buffer.byteLength(JSON.stringify(diagnostic)) + 1;
|
|
103
|
+
if (
|
|
104
|
+
result.diagnostics.length >= limits.diagnostics ||
|
|
105
|
+
diagnosticBytes > limits.diagnosticBytes
|
|
106
|
+
)
|
|
107
|
+
fail(
|
|
108
|
+
"DIAGNOSTIC_LIMIT",
|
|
109
|
+
"Diagnostic count or output bytes exceed the limit.",
|
|
110
|
+
);
|
|
111
|
+
result.diagnostics.push(diagnostic);
|
|
112
|
+
}
|
|
113
|
+
if (mode === "format")
|
|
114
|
+
candidates.push({ file, item, output: formatted.output });
|
|
115
|
+
}
|
|
116
|
+
result.diagnostics.sort((a, b) =>
|
|
117
|
+
a.path < b.path
|
|
118
|
+
? -1
|
|
119
|
+
: a.path > b.path
|
|
120
|
+
? 1
|
|
121
|
+
: a.line - b.line ||
|
|
122
|
+
a.column - b.column ||
|
|
123
|
+
a.rule.localeCompare(b.rule, "en"),
|
|
124
|
+
);
|
|
125
|
+
if (result.diagnostics.length) {
|
|
126
|
+
result.outcome = "findings";
|
|
127
|
+
result.exitCode = 1;
|
|
128
|
+
return result;
|
|
129
|
+
}
|
|
130
|
+
if (mode === "format")
|
|
131
|
+
for (const candidate of candidates) {
|
|
132
|
+
if (candidate.item.bytes.equals(Buffer.from(candidate.output)))
|
|
133
|
+
result.unchanged.push(candidate.file);
|
|
134
|
+
else {
|
|
135
|
+
replaceDocument(context.root, candidate.item, candidate.output);
|
|
136
|
+
result.written.push(candidate.file);
|
|
137
|
+
}
|
|
138
|
+
result.unprocessed.shift();
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
result.unchanged = [...result.selection.files];
|
|
142
|
+
result.unprocessed = [];
|
|
143
|
+
}
|
|
144
|
+
result.outcome = "clean";
|
|
145
|
+
result.exitCode = 0;
|
|
146
|
+
} catch (error) {
|
|
147
|
+
result.errors.push({
|
|
148
|
+
code: error instanceof OperationError ? error.code : "OPERATION_FAILED",
|
|
149
|
+
message:
|
|
150
|
+
error instanceof OperationError ? error.message : "Operation failed.",
|
|
151
|
+
});
|
|
152
|
+
} finally {
|
|
153
|
+
if (analyzer) await analyzer.close();
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
import {
|
|
3
|
+
openSync,
|
|
4
|
+
closeSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
fsyncSync,
|
|
7
|
+
chmodSync,
|
|
8
|
+
renameSync,
|
|
9
|
+
unlinkSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import { safePath } from "./configuration.js";
|
|
14
|
+
import { fail } from "./contracts.js";
|
|
15
|
+
import { readDocument } from "./documents.js";
|
|
16
|
+
function unchanged(root, item) {
|
|
17
|
+
safePath(root, item.path, { file: true });
|
|
18
|
+
const { stat, bytes } = readDocument(root, item.path);
|
|
19
|
+
if (
|
|
20
|
+
stat.dev !== item.stat.dev ||
|
|
21
|
+
stat.ino !== item.stat.ino ||
|
|
22
|
+
stat.nlink !== 1n ||
|
|
23
|
+
stat.mode !== item.stat.mode ||
|
|
24
|
+
!bytes.equals(item.bytes)
|
|
25
|
+
)
|
|
26
|
+
fail(
|
|
27
|
+
"PREIMAGE_CHANGED",
|
|
28
|
+
"File identity, permissions, or bytes changed before replacement.",
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
export function replaceDocument(root, item, output) {
|
|
32
|
+
unchanged(root, item);
|
|
33
|
+
if (
|
|
34
|
+
typeof process.getuid === "function" &&
|
|
35
|
+
item.stat.uid !== BigInt(process.getuid())
|
|
36
|
+
)
|
|
37
|
+
fail(
|
|
38
|
+
"FILE_OWNERSHIP",
|
|
39
|
+
"Formatting a file owned by another user is unsupported.",
|
|
40
|
+
);
|
|
41
|
+
const temp = join(
|
|
42
|
+
dirname(item.path),
|
|
43
|
+
`.markdown-quality-${randomUUID()}.tmp`,
|
|
44
|
+
);
|
|
45
|
+
let fd,
|
|
46
|
+
created = false;
|
|
47
|
+
try {
|
|
48
|
+
fd = openSync(temp, "wx", Number(item.stat.mode & 0o777n));
|
|
49
|
+
created = true;
|
|
50
|
+
writeFileSync(fd, output, "utf8");
|
|
51
|
+
chmodSync(temp, Number(item.stat.mode & 0o777n));
|
|
52
|
+
fsyncSync(fd);
|
|
53
|
+
closeSync(fd);
|
|
54
|
+
fd = undefined;
|
|
55
|
+
unchanged(root, item);
|
|
56
|
+
renameSync(temp, item.path);
|
|
57
|
+
created = false;
|
|
58
|
+
} finally {
|
|
59
|
+
if (fd !== undefined) closeSync(fd);
|
|
60
|
+
if (created) unlinkSync(temp);
|
|
61
|
+
}
|
|
62
|
+
}
|