@hadden-industries/markdown-quality 1.0.2 → 1.0.3
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/assets/.editorconfig +4 -0
- package/docs/consumer-guide.md +24 -4
- package/docs/support-and-release.md +16 -12
- package/package.json +3 -3
- package/src/analysis.js +2 -2
- package/src/document-analysis.js +61 -23
- package/src/document-memo.js +97 -0
- package/src/document-worker.js +151 -24
- package/src/formatting.js +50 -9
- package/src/literal-layout.js +45 -11
- package/src/native-checks.js +247 -0
- package/src/native-staging.js +218 -0
- package/src/prose-diagnostics.js +119 -0
- package/src/quality.js +173 -39
- package/src/whitespace.js +57 -0
package/docs/consumer-guide.md
CHANGED
|
@@ -4,14 +4,15 @@ Install an exact coordinated public release after registry availability is verif
|
|
|
4
4
|
Acquisition needs no npm account or token; disable lifecycle scripts.
|
|
5
5
|
|
|
6
6
|
```sh
|
|
7
|
-
npm install --save-dev --save-exact --ignore-scripts @hadden-industries/markdown-quality@1.0.
|
|
7
|
+
npm install --save-dev --save-exact --ignore-scripts @hadden-industries/markdown-quality@1.0.3
|
|
8
8
|
npm ci --ignore-scripts
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The source candidate is `1.0.
|
|
12
|
-
|
|
11
|
+
The source candidate is `1.0.3`; confirm its registry availability and exact tuple qualification before using the command above.
|
|
12
|
+
General pilot availability and stable `latest` designation are separate decisions.
|
|
13
|
+
The owner explicitly authorized 1.0.3 to be published as `latest`, subject to its complete archive, registry/provenance and immutable-release verification.
|
|
13
14
|
The previously qualified alpha.4 tuple remains immutable historical evidence.
|
|
14
|
-
|
|
15
|
+
That release decision does not accept or change either consumer's separately paused migration; both still require their exact scope and trusted-run acceptance before cutover.
|
|
15
16
|
Keep the consumer lockfile.
|
|
16
17
|
An isolated `tooling/markdown` npm project supports consumers that do not use Node for their application.
|
|
17
18
|
Its commands must pass the actual repository root.
|
|
@@ -77,11 +78,30 @@ Lint messages describe the violated rule without quoting document content.
|
|
|
77
78
|
These are conservative operational limits; benchmark and platform acceptance remains part of candidate qualification.
|
|
78
79
|
|
|
79
80
|
Formatting compares maintained parsed structure and literals before replacement and verifies convergence.
|
|
81
|
+
The owner-approved stricter whitespace policy updates `authored-gfm@1` directly; no second preset is provided.
|
|
82
|
+
This change is currently unreleased; existing published npm archives retain their original behavior.
|
|
83
|
+
Trailing spaces and tabs are errors everywhere except inside fenced and indented code blocks.
|
|
84
|
+
This includes blank lines, headings, lists, tables, and opening or closing fence lines.
|
|
85
|
+
Space-based hard breaks are forbidden; formatting replaces actual parsed hard breaks with an explicit backslash followed by a newline, preserving the break.
|
|
86
|
+
The whitespace policy cannot be disabled through lint overrides or editor settings.
|
|
80
87
|
Fenced and indented code retain literal whitespace, including whitespace-only lines and trailing spaces; embedded code formatting remains disabled.
|
|
81
88
|
Inline code compares [CommonMark code-span semantics](https://spec.commonmark.org/0.31.2/#code-spans): each line ending means one space, while interior spaces, tabs and other literal characters remain significant.
|
|
82
89
|
Delimiter padding is already handled by the maintained parser; the comparison does not collapse or trim additional whitespace.
|
|
83
90
|
The parsed comparison is an additional backstop, not a universal rendered-equivalence guarantee.
|
|
84
91
|
It conservatively refuses unsupported semantic or literal changes.
|
|
92
|
+
Inline code and raw HTML have no blanket trailing-whitespace exemption.
|
|
93
|
+
When trimming would change their interpreted content, or otherwise change parsed meaning, the operation reports the whitespace findings for manual correction and leaves the whole batch untouched.
|
|
94
|
+
Both checking and formatting validate the selected documents; checking never changes checkout files, and formatting writes only after every candidate passes validation.
|
|
95
|
+
Native checks may group exact document snapshots and complete list-item continuations in a private temporary directory outside the checkout.
|
|
96
|
+
Groups have at most 32 independent files and 4 MiB of input, with the existing native deadline and output cap.
|
|
97
|
+
The package supplies its own immutable native configuration and EditorConfig boundary; consumer settings do not control these checks.
|
|
98
|
+
If private staging cannot be established, checking uses the original standard-input path.
|
|
99
|
+
Payloads are removed after each native invocation, and the owned directory is removed after the analysis worker stops, before any formatting replacement.
|
|
100
|
+
Changed staging identities or incomplete cleanup cause an operational failure and block formatting writes.
|
|
101
|
+
Abrupt process or host termination can leave private temporary files for operating-system or owner cleanup; deletion does not promise secure erasure.
|
|
102
|
+
No persistent syntax, formatting or filesystem-validity cache is created.
|
|
103
|
+
Use explicit selection exclusions for signed, byte-sensitive, or verbatim upstream documents that must remain unchanged.
|
|
104
|
+
Git whitespace checks can supplement this policy but do not replace full-document checking.
|
|
85
105
|
Ordinary file permission bits are preserved; ownership changes, alternate streams, ACL inheritance, arbitrary metadata, and adversarial filesystem races are not promised as preserved.
|
|
86
106
|
Use a clean baseline or retained preimages when original uncommitted bytes must be recoverable.
|
|
87
107
|
|
|
@@ -20,13 +20,13 @@ Only a processing 404 is retried; mismatched integrity and other failures stop d
|
|
|
20
20
|
Publication is never repeated by this availability check.
|
|
21
21
|
The three bounded waits fit within the publisher's thirty-minute job limit.
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
Version 1.0.2 is published under the pilot tag and passed complete Windows/Linux registry qualification in run `37417063198`; stable promotion remains gated.
|
|
24
|
+
General stable promotion requires the same qualified tuple on both platforms, public registry readback and unauthenticated installation, both pilot acceptances, and a timed restoration exercise; the explicit 1.0.3 exception below preserves qualification while separating the paused consumer migrations.
|
|
25
25
|
The 1.0.1 corrective candidate restores checking and formatting of literal code lines containing trailing or whitespace-only spaces.
|
|
26
26
|
The original 1.0.1 tuple is published under `pilot` and passed both-platform complete registry qualification in recovery run `37413673170`, without republishing any package.
|
|
27
27
|
The 1.0.2 correction compares inline-code line endings as spaces under CommonMark, retaining meaningful interior whitespace and the strict fenced/indented-code guard.
|
|
28
28
|
This repair changes shipped JavaScript and therefore requires a new coordinated patch version; the unchanged 1.0.0 tuple completed registry qualification separately in recovery run `37409484508`.
|
|
29
|
-
|
|
29
|
+
The earlier literal-preservation releases retained their exact platform, registry and pilot gates; their qualification does not establish delivery or consumer adoption of a later version.
|
|
30
30
|
Changed/referrer mode and later fleet adoptions remain deferred.
|
|
31
31
|
|
|
32
32
|
Prepare assets explicitly from their frozen manifest.
|
|
@@ -49,10 +49,10 @@ An upstream Cargo lock is a superset of features and development inputs and does
|
|
|
49
49
|
The original upstream license and per-asset repack identity are retained.
|
|
50
50
|
|
|
51
51
|
The owner selected public distribution after npm rejected the private publication with E402.
|
|
52
|
-
All three packages declare public access and share the source candidate version 1.0.
|
|
53
|
-
The tuple retains alpha.4's qualified native executable/source/notice bytes and
|
|
52
|
+
All three packages declare public access and share the source candidate version 1.0.3.
|
|
53
|
+
The tuple retains alpha.4's qualified native executable/source/notice bytes and adds bounded request-local reuse, native checking batches and exact-input check-first formatting while retaining independent prose checks and preservation/convergence checks.
|
|
54
54
|
The first alpha.3 hosted OWLAPI shadow checks passed correctness but exceeded the accepted 30-second budget on both platforms.
|
|
55
|
-
Alpha.3 and alpha.4 remain immutable historical evidence; every new tuple requires fresh transported-archive
|
|
55
|
+
Alpha.3 and alpha.4 remain immutable historical evidence; every new tuple requires fresh transported-archive and registry qualification, with exact-version pilot qualification governed by the applicable owner release decision.
|
|
56
56
|
The public alpha.2 archives remain immutable historical delivery evidence and are unqualified for Linux execution.
|
|
57
57
|
The packer normalizes the Linux npm member's executable mode with Python tarfile, verifies all member bytes are unchanged, and recomputes the final archive integrity.
|
|
58
58
|
Qualify the exact transported release archives on both platforms, rather than each platform's independently generated package.
|
|
@@ -65,24 +65,28 @@ Recovery preserves the original candidate's source and archives separately from
|
|
|
65
65
|
An admission job without OIDC binds a complete three-package origin map before effects; both platform jobs qualify previously attempted packages before the publisher may run.
|
|
66
66
|
Only a prior planned effect with an explicitly skipped publication step is eligible for first publication; an attempted or unknown outcome must be verified in the registry and cannot become eligible through a 404.
|
|
67
67
|
Each downstream job rehashes its downloaded archives against the admitted manifest.
|
|
68
|
-
The publisher rechecks current main, all three local archive hashes and version absence immediately before each single eligible publication
|
|
68
|
+
The publisher rechecks current main, all three local archive hashes and version absence immediately before each single eligible publication and verifies both native archives before core.
|
|
69
|
+
Its explicit `distribution-tag` dispatch choice accepts only `pilot` or `latest` and defaults to `pilot`; select `latest` only for an owner-authorized stable release.
|
|
70
|
+
Publication step names retain their historical "under pilot" wording because their exact identifiers are required to reconcile earlier interrupted runs; the selected input controls the actual npm label.
|
|
69
71
|
Only the publication job has OIDC issuance permission; no long-lived npm token is supplied.
|
|
70
72
|
Both registry jobs use native npm signature/attestation verification and bind each installed package to its admitted source, workflow, exact publication run and attempt.
|
|
71
73
|
Their explicit non-cancellation and successful-publication condition prevents a skipped recovery ancestor from suppressing final qualification after ordinary publication.
|
|
72
74
|
Successful publication alone does not establish complete registry qualification; retain both actual terminal platform reports.
|
|
73
75
|
Each platform installs and cryptographically verifies its supported native package and core; opposite-platform native archives receive anonymous raw-byte verification, with the combined platform records covering the complete tuple.
|
|
74
76
|
Structural provenance fixtures do not establish cryptographic acceptance.
|
|
75
|
-
The workflow does not
|
|
77
|
+
The workflow does not move labels on existing packages or grant npm tag-management permission.
|
|
78
|
+
The owner's explicit 1.0.3 stable/latest decision in [plan revision 8](https://github.com/Hadden-Industries/markdown-quality/blob/v1.0.3/docs/plans/implementation-plan.md#1-status-authority-and-purpose) supersedes the pilot-promotion hold for that release only; consumer adoption remains separate.
|
|
76
79
|
GitHub artifacts expire after 30 days, so export the complete frozen source, manifest, archives and verification bundles to the retained evidence store before expiry, for the security policy's maintained-lifetime-plus-three-years floor.
|
|
77
80
|
Public installation needs no registry token, subscription, or OIDC authority.
|
|
78
81
|
Public npm provenance is eligible only through a qualified supported publisher; do not claim it for the local bootstrap.
|
|
79
82
|
Retain attributable source/build/repack evidence and qualify attestations separately.
|
|
80
83
|
|
|
81
|
-
Publish native packages first under
|
|
84
|
+
Publish native packages first under the explicitly accepted availability label and publish the core only when the tuple is coherent.
|
|
82
85
|
Read back hashes, public visibility, and fresh unauthenticated archive acquisition for every package.
|
|
83
86
|
Qualify root and isolated consumers on both supported platforms from the registry, without credentials.
|
|
84
87
|
Do not perform registry publication while rights, publisher authentication, independent assurance, or platform evidence remains missing.
|
|
85
|
-
A partial publication
|
|
88
|
+
A partial publication is not a completed coordinated release, even when individual package labels are visible; preserve its exact outcomes and use the unchanged-version recovery procedure below.
|
|
89
|
+
Changed bytes require a new corrective version.
|
|
86
90
|
|
|
87
91
|
For unchanged-version recovery, manually dispatch `publish.yml` with the original `candidate-run` and `manifest-sha256`, plus the terminal prior `recovery-run` and exact `recovery-attempt`.
|
|
88
92
|
The original legacy publication can omit `recovery-origins-sha256` only when its source is the original artifact source and its retained manifest matches exactly.
|
|
@@ -99,7 +103,7 @@ For recovery, retain the exact consumer base revision, old manifest and lock, wo
|
|
|
99
103
|
Restore only migration-owned changes.
|
|
100
104
|
Verify byte identities and required checks, then record elapsed restoration time.
|
|
101
105
|
Downgrading a package cannot restore formatted document bytes.
|
|
102
|
-
|
|
106
|
+
The general pilot-promotion path requires one pilot rehearsal; the owner's exact 1.0.3 release decision leaves those consumer restoration exercises with their separately gated migrations.
|
|
103
107
|
|
|
104
108
|
Security reports use GitHub's private vulnerability reporting route when enabled; otherwise contact the repository owner through the project's existing private channel.
|
|
105
109
|
No unattended observation service is assumed.
|
|
@@ -120,7 +124,7 @@ These budgets do not impose OS runtime limits or raise existing package limits.
|
|
|
120
124
|
Require zero unexpected errors or adjudicated false positives in the window and accepted fixture/probe corpus, retaining expected negative probes separately.
|
|
121
125
|
Rehearse one task-owned pilot restoration within 60 minutes through byte/sentinel readback and incumbent local checks; hosted required-status recovery remains separate acceptance evidence.
|
|
122
126
|
The alpha.4 pilot cutovers passed both hosted platform windows and scoped restoration; those records do not establish acceptance of a later exact tuple.
|
|
123
|
-
Version 1.0.
|
|
127
|
+
Version 1.0.2 requires fresh per-consumer scope, hosted platform windows, restoration and exact trusted-run acceptance before promotion.
|
|
124
128
|
|
|
125
129
|
Provide AGPL corresponding source and build materials alongside object-code delivery to actual recipients.
|
|
126
130
|
Preserve third-party license texts and attribution.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hadden-industries/markdown-quality",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Shared, bounded Markdown formatting, GFM linting, and local-link checking",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "AGPL-3.0-only",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"prettier": "3.9.9"
|
|
47
47
|
},
|
|
48
48
|
"optionalDependencies": {
|
|
49
|
-
"@hadden-industries/markdown-quality-win32-x64": "1.0.
|
|
50
|
-
"@hadden-industries/markdown-quality-linux-x64": "1.0.
|
|
49
|
+
"@hadden-industries/markdown-quality-win32-x64": "1.0.3",
|
|
50
|
+
"@hadden-industries/markdown-quality-linux-x64": "1.0.3"
|
|
51
51
|
}
|
|
52
52
|
}
|
package/src/analysis.js
CHANGED
|
@@ -61,7 +61,7 @@ export async function lintDocument(linter, text, file) {
|
|
|
61
61
|
severity: d.severity === 2 ? "error" : "warning",
|
|
62
62
|
}));
|
|
63
63
|
}
|
|
64
|
-
export function checkLinks(context, text, file) {
|
|
64
|
+
export function checkLinks(context, text, file, memo) {
|
|
65
65
|
if (!context.config.links.localFiles) return [];
|
|
66
66
|
const diagnostics = [];
|
|
67
67
|
function walk(node) {
|
|
@@ -114,6 +114,6 @@ export function checkLinks(context, text, file) {
|
|
|
114
114
|
}
|
|
115
115
|
for (const child of node.children ?? []) walk(child);
|
|
116
116
|
}
|
|
117
|
-
walk(parse(text));
|
|
117
|
+
walk(memo ? memo.parse(text) : parse(text));
|
|
118
118
|
return diagnostics;
|
|
119
119
|
}
|
package/src/document-analysis.js
CHANGED
|
@@ -25,11 +25,37 @@ export function createDocumentAnalyzer(context, tool) {
|
|
|
25
25
|
);
|
|
26
26
|
return (termination ??= worker.terminate());
|
|
27
27
|
}
|
|
28
|
-
|
|
28
|
+
function deadline(milliseconds) {
|
|
29
|
+
clearTimeout(pending.timer);
|
|
30
|
+
pending.timer = setTimeout(() => {
|
|
31
|
+
settle(
|
|
32
|
+
new OperationError(
|
|
33
|
+
"ANALYSIS_TIMEOUT",
|
|
34
|
+
"Document analysis exceeded 30 seconds.",
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
void close();
|
|
38
|
+
}, milliseconds);
|
|
39
|
+
}
|
|
40
|
+
worker.on("message", (value) => {
|
|
41
|
+
if (value.progress) {
|
|
42
|
+
if (!pending) return;
|
|
43
|
+
if (
|
|
44
|
+
!Number.isFinite(value.remainingMs) ||
|
|
45
|
+
value.remainingMs <= 0 ||
|
|
46
|
+
value.remainingMs > 30_000
|
|
47
|
+
) {
|
|
48
|
+
settle(
|
|
49
|
+
new OperationError("ANALYSIS_FAILURE", "Invalid analysis deadline."),
|
|
50
|
+
);
|
|
51
|
+
void close();
|
|
52
|
+
} else deadline(value.remainingMs);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
29
55
|
value.error
|
|
30
56
|
? settle(new OperationError(value.error.code, value.error.message))
|
|
31
|
-
: settle(null, value)
|
|
32
|
-
);
|
|
57
|
+
: settle(null, value);
|
|
58
|
+
});
|
|
33
59
|
worker.on("error", () => {
|
|
34
60
|
closed = true;
|
|
35
61
|
settle(
|
|
@@ -48,31 +74,43 @@ export function createDocumentAnalyzer(context, tool) {
|
|
|
48
74
|
),
|
|
49
75
|
);
|
|
50
76
|
});
|
|
77
|
+
function request(data, milliseconds = 30_000) {
|
|
78
|
+
if (closed || pending)
|
|
79
|
+
return Promise.reject(
|
|
80
|
+
new OperationError(
|
|
81
|
+
"ANALYSIS_FAILURE",
|
|
82
|
+
"Document analysis is unavailable.",
|
|
83
|
+
),
|
|
84
|
+
);
|
|
85
|
+
return new Promise((resolve, reject) => {
|
|
86
|
+
pending = {
|
|
87
|
+
resolve,
|
|
88
|
+
reject,
|
|
89
|
+
};
|
|
90
|
+
deadline(milliseconds);
|
|
91
|
+
worker.postMessage(data);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
51
94
|
return {
|
|
52
|
-
|
|
53
|
-
if (
|
|
95
|
+
prepare(data, milliseconds = 30_000) {
|
|
96
|
+
if (
|
|
97
|
+
!Number.isFinite(milliseconds) ||
|
|
98
|
+
milliseconds <= 0 ||
|
|
99
|
+
milliseconds > 30_000
|
|
100
|
+
)
|
|
54
101
|
return Promise.reject(
|
|
55
102
|
new OperationError(
|
|
56
|
-
"
|
|
57
|
-
"Document analysis
|
|
103
|
+
"ANALYSIS_TIMEOUT",
|
|
104
|
+
"Document analysis exceeded 30 seconds.",
|
|
58
105
|
),
|
|
59
106
|
);
|
|
60
|
-
return
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"ANALYSIS_TIMEOUT",
|
|
68
|
-
"Document analysis exceeded 30 seconds.",
|
|
69
|
-
),
|
|
70
|
-
);
|
|
71
|
-
void close();
|
|
72
|
-
}, 30_000),
|
|
73
|
-
};
|
|
74
|
-
worker.postMessage(data);
|
|
75
|
-
});
|
|
107
|
+
return request({ ...data, action: "prepare" }, milliseconds);
|
|
108
|
+
},
|
|
109
|
+
precheck(documents, staging) {
|
|
110
|
+
return request({ documents, staging, action: "precheck" });
|
|
111
|
+
},
|
|
112
|
+
verify(documents, staging) {
|
|
113
|
+
return request({ documents, staging, action: "verify" });
|
|
76
114
|
},
|
|
77
115
|
close,
|
|
78
116
|
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
|
+
import { parse } from "./analysis.js";
|
|
3
|
+
import { fail, limits } from "./contracts.js";
|
|
4
|
+
|
|
5
|
+
/** Reuse pure owned results for one document; never retain filesystem validity. */
|
|
6
|
+
export function createDocumentMemo() {
|
|
7
|
+
const trees = new Map();
|
|
8
|
+
const layouts = new Map();
|
|
9
|
+
let retainedBytes = 0,
|
|
10
|
+
disposed = false,
|
|
11
|
+
layoutEntries = 0;
|
|
12
|
+
const counts = { parses: 0, parseHits: 0, layouts: 0, layoutHits: 0 };
|
|
13
|
+
function active() {
|
|
14
|
+
if (disposed) fail("ANALYSIS_FAILURE", "Document analysis is unavailable.");
|
|
15
|
+
}
|
|
16
|
+
function reserve(bytes, entries) {
|
|
17
|
+
if (entries >= 4 || retainedBytes + bytes > limits.fileBytes) return false;
|
|
18
|
+
retainedBytes += bytes;
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
return {
|
|
22
|
+
parse(text) {
|
|
23
|
+
active();
|
|
24
|
+
if (trees.has(text)) {
|
|
25
|
+
counts.parseHits++;
|
|
26
|
+
return trees.get(text);
|
|
27
|
+
}
|
|
28
|
+
counts.parses++;
|
|
29
|
+
const tree = parse(text);
|
|
30
|
+
if (reserve(Buffer.byteLength(text), trees.size)) trees.set(text, tree);
|
|
31
|
+
return tree;
|
|
32
|
+
},
|
|
33
|
+
async layout(text, options, compute) {
|
|
34
|
+
active();
|
|
35
|
+
const identity = layoutIdentity(options);
|
|
36
|
+
const variants = layouts.get(text);
|
|
37
|
+
if (identity !== null && variants?.has(identity)) {
|
|
38
|
+
counts.layoutHits++;
|
|
39
|
+
return variants.get(identity);
|
|
40
|
+
}
|
|
41
|
+
counts.layouts++;
|
|
42
|
+
const result = await compute();
|
|
43
|
+
// Retain only completed values: failed or pending work is never admission.
|
|
44
|
+
if (
|
|
45
|
+
identity !== null &&
|
|
46
|
+
reserve(
|
|
47
|
+
Buffer.byteLength(text) + Buffer.byteLength(result),
|
|
48
|
+
layoutEntries,
|
|
49
|
+
)
|
|
50
|
+
) {
|
|
51
|
+
const entries = variants ?? new Map();
|
|
52
|
+
entries.set(identity, result);
|
|
53
|
+
layouts.set(text, entries);
|
|
54
|
+
layoutEntries++;
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
},
|
|
58
|
+
get statistics() {
|
|
59
|
+
return { ...counts, retainedBytes, disposed };
|
|
60
|
+
},
|
|
61
|
+
dispose() {
|
|
62
|
+
trees.clear();
|
|
63
|
+
layouts.clear();
|
|
64
|
+
retainedBytes = 0;
|
|
65
|
+
layoutEntries = 0;
|
|
66
|
+
disposed = true;
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Capture every own option, including explicit undefined/null and plugin order.
|
|
72
|
+
// Opaque or executable plugin/options bypass reuse rather than assert purity.
|
|
73
|
+
function layoutIdentity(options) {
|
|
74
|
+
if (
|
|
75
|
+
options === null ||
|
|
76
|
+
typeof options !== "object" ||
|
|
77
|
+
![Object.prototype, null].includes(Object.getPrototypeOf(options))
|
|
78
|
+
)
|
|
79
|
+
return null;
|
|
80
|
+
const keys = Reflect.ownKeys(options);
|
|
81
|
+
if (keys.some((key) => typeof key !== "string")) return null;
|
|
82
|
+
const identity = [];
|
|
83
|
+
for (const key of keys.sort()) {
|
|
84
|
+
const descriptor = Object.getOwnPropertyDescriptor(options, key);
|
|
85
|
+
if (!Object.hasOwn(descriptor, "value")) return null;
|
|
86
|
+
const value = descriptor.value;
|
|
87
|
+
if (key === "plugins" && Array.isArray(value) && value.length === 0)
|
|
88
|
+
identity.push([key, "empty-plugins"]);
|
|
89
|
+
else if (value === null) identity.push([key, "null"]);
|
|
90
|
+
else if (["string", "boolean", "undefined"].includes(typeof value))
|
|
91
|
+
identity.push([key, typeof value, value]);
|
|
92
|
+
else if (typeof value === "number" && Number.isFinite(value))
|
|
93
|
+
identity.push([key, "number", Object.is(value, -0) ? "-0" : value]);
|
|
94
|
+
else return null;
|
|
95
|
+
}
|
|
96
|
+
return JSON.stringify(identity);
|
|
97
|
+
}
|
package/src/document-worker.js
CHANGED
|
@@ -1,30 +1,157 @@
|
|
|
1
1
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
2
2
|
import { parentPort, workerData } from "node:worker_threads";
|
|
3
|
-
import {
|
|
3
|
+
import { prepareFormattedDocument } from "./formatting.js";
|
|
4
4
|
import { createLinter, lintDocument, checkLinks } from "./analysis.js";
|
|
5
5
|
import { diagnosticBudget, OperationError } from "./contracts.js";
|
|
6
|
+
import { checkTrailingWhitespace } from "./whitespace.js";
|
|
7
|
+
import { createDocumentMemo } from "./document-memo.js";
|
|
8
|
+
import { checkProseGroup } from "./prose-diagnostics.js";
|
|
9
|
+
import {
|
|
10
|
+
runNativeChecks,
|
|
11
|
+
bindNativeCheck,
|
|
12
|
+
matchesNativeCheck,
|
|
13
|
+
nativeFileCheckEligible,
|
|
14
|
+
} from "./native-checks.js";
|
|
6
15
|
const { context, tool } = workerData;
|
|
7
16
|
const linter = createLinter(context);
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
17
|
+
function reported(error) {
|
|
18
|
+
return {
|
|
19
|
+
code: error instanceof OperationError ? error.code : "ANALYSIS_FAILURE",
|
|
20
|
+
message:
|
|
21
|
+
error instanceof OperationError
|
|
22
|
+
? error.message
|
|
23
|
+
: "Document analysis failed.",
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function budgetObserver(elapsed) {
|
|
27
|
+
return {
|
|
28
|
+
start(indices) {
|
|
29
|
+
const remainingMs = Math.min(
|
|
30
|
+
...indices.map((index) => 30_000 - elapsed[index]),
|
|
31
|
+
);
|
|
32
|
+
if (remainingMs <= 0)
|
|
33
|
+
throw new OperationError(
|
|
34
|
+
"ANALYSIS_TIMEOUT",
|
|
35
|
+
"Document analysis exceeded 30 seconds.",
|
|
36
|
+
);
|
|
37
|
+
parentPort.postMessage({ progress: true, remainingMs });
|
|
38
|
+
},
|
|
39
|
+
finish(indices, milliseconds) {
|
|
40
|
+
for (const index of indices) elapsed[index] += milliseconds;
|
|
41
|
+
this.start(indices);
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
parentPort.on(
|
|
46
|
+
"message",
|
|
47
|
+
async ({ text, file, mode, action, documents, staging, precheck }) => {
|
|
48
|
+
let memo;
|
|
49
|
+
try {
|
|
50
|
+
if (action === "precheck") {
|
|
51
|
+
const elapsedMs = documents.map(() => 0),
|
|
52
|
+
observer = budgetObserver(elapsedMs);
|
|
53
|
+
const started = performance.now();
|
|
54
|
+
let nativeMs = 0;
|
|
55
|
+
const eligible = documents
|
|
56
|
+
.map((text, index) => ({ text, index }))
|
|
57
|
+
.filter(({ text }) => nativeFileCheckEligible(text, staging));
|
|
58
|
+
const reports = runNativeChecks(
|
|
59
|
+
tool,
|
|
60
|
+
eligible.map(({ text }) => text),
|
|
61
|
+
staging,
|
|
62
|
+
{
|
|
63
|
+
start: (indices) =>
|
|
64
|
+
observer.start(indices.map((index) => eligible[index].index)),
|
|
65
|
+
finish: (indices, milliseconds) => {
|
|
66
|
+
nativeMs += milliseconds;
|
|
67
|
+
observer.finish(
|
|
68
|
+
indices.map((index) => eligible[index].index),
|
|
69
|
+
milliseconds,
|
|
70
|
+
);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
const workerMs = performance.now() - started;
|
|
75
|
+
observer.finish(
|
|
76
|
+
documents.map((_, index) => index),
|
|
77
|
+
Math.max(0, workerMs - nativeMs),
|
|
78
|
+
);
|
|
79
|
+
const checks = new Array(documents.length);
|
|
80
|
+
for (const [index, report] of reports.entries())
|
|
81
|
+
checks[eligible[index].index] = bindNativeCheck(
|
|
82
|
+
tool,
|
|
83
|
+
eligible[index].text,
|
|
84
|
+
report,
|
|
85
|
+
);
|
|
86
|
+
parentPort.postMessage({
|
|
87
|
+
checks,
|
|
88
|
+
elapsedMs,
|
|
89
|
+
workerMs,
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (action === "verify") {
|
|
94
|
+
const elapsed = documents.map((document) => document.elapsedMs);
|
|
95
|
+
if (
|
|
96
|
+
elapsed.some(
|
|
97
|
+
(milliseconds) =>
|
|
98
|
+
!Number.isFinite(milliseconds) || milliseconds < 0,
|
|
99
|
+
)
|
|
100
|
+
)
|
|
101
|
+
throw new OperationError(
|
|
102
|
+
"ANALYSIS_FAILURE",
|
|
103
|
+
"Invalid analysis deadline.",
|
|
104
|
+
);
|
|
105
|
+
const observer = budgetObserver(elapsed);
|
|
106
|
+
const diagnostics = checkProseGroup(
|
|
107
|
+
tool,
|
|
108
|
+
documents.map((document) => document.output),
|
|
109
|
+
staging,
|
|
110
|
+
observer,
|
|
111
|
+
documents.map((document) =>
|
|
112
|
+
matchesNativeCheck(tool, document.output, document.precheck)
|
|
113
|
+
? document.precheck.report
|
|
114
|
+
: undefined,
|
|
115
|
+
),
|
|
116
|
+
);
|
|
117
|
+
parentPort.postMessage({ diagnostics });
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
memo = createDocumentMemo();
|
|
121
|
+
const formatted = await prepareFormattedDocument(
|
|
122
|
+
text,
|
|
123
|
+
context,
|
|
124
|
+
tool,
|
|
125
|
+
memo,
|
|
126
|
+
precheck,
|
|
127
|
+
);
|
|
128
|
+
const proposed = mode === "format" ? formatted.output : text;
|
|
129
|
+
try {
|
|
130
|
+
const whitespace = checkTrailingWhitespace(proposed, memo);
|
|
131
|
+
const diagnostics = [
|
|
132
|
+
...whitespace,
|
|
133
|
+
...(await lintDocument(linter, proposed, file)),
|
|
134
|
+
...checkLinks(context, proposed, file, memo),
|
|
135
|
+
];
|
|
136
|
+
diagnosticBudget(diagnostics);
|
|
137
|
+
parentPort.postMessage({
|
|
138
|
+
output: formatted.output,
|
|
139
|
+
diagnostics,
|
|
140
|
+
whitespaceCount: whitespace.length,
|
|
141
|
+
prechecked: formatted.prechecked,
|
|
142
|
+
});
|
|
143
|
+
} catch (error) {
|
|
144
|
+
// Preserve prose-before-lint failure order; no provisional result is admitted.
|
|
145
|
+
parentPort.postMessage({
|
|
146
|
+
output: formatted.output,
|
|
147
|
+
deferredError: reported(error),
|
|
148
|
+
prechecked: formatted.prechecked,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
} catch (error) {
|
|
152
|
+
parentPort.postMessage({ error: reported(error) });
|
|
153
|
+
} finally {
|
|
154
|
+
memo?.dispose();
|
|
155
|
+
}
|
|
156
|
+
},
|
|
157
|
+
);
|
package/src/formatting.js
CHANGED
|
@@ -3,7 +3,9 @@ import { formatLayout } from "./literal-layout.js";
|
|
|
3
3
|
import { parse } from "./analysis.js";
|
|
4
4
|
import { runNative } from "./native-tool.js";
|
|
5
5
|
import { checkProse } from "./prose-diagnostics.js";
|
|
6
|
+
import { normalizeTrailingWhitespace } from "./whitespace.js";
|
|
6
7
|
import { fail } from "./contracts.js";
|
|
8
|
+
import { matchesNativeCheck } from "./native-checks.js";
|
|
7
9
|
function semantic(node) {
|
|
8
10
|
const value = {};
|
|
9
11
|
for (const [key, item] of Object.entries(node)) {
|
|
@@ -19,7 +21,25 @@ function semantic(node) {
|
|
|
19
21
|
}
|
|
20
22
|
return value;
|
|
21
23
|
}
|
|
22
|
-
|
|
24
|
+
/** Prepare guarded candidate bytes; callers must independently verify prose before admission. */
|
|
25
|
+
export async function prepareFormattedDocument(
|
|
26
|
+
text,
|
|
27
|
+
context,
|
|
28
|
+
tool,
|
|
29
|
+
memo,
|
|
30
|
+
precheck,
|
|
31
|
+
) {
|
|
32
|
+
const tree = (source) => (memo ? memo.parse(source) : parse(source));
|
|
33
|
+
const normalized = normalizeTrailingWhitespace(text, memo);
|
|
34
|
+
// A lint violation is not authority to erase literal content (inline code or
|
|
35
|
+
// raw HTML) or accidentally create a hard break by trimming beside a slash.
|
|
36
|
+
// Return the original for reporting; batch validation will prevent all writes.
|
|
37
|
+
if (
|
|
38
|
+
normalized !== text &&
|
|
39
|
+
JSON.stringify(semantic(tree(text))) !==
|
|
40
|
+
JSON.stringify(semantic(tree(normalized)))
|
|
41
|
+
)
|
|
42
|
+
return { output: text };
|
|
23
43
|
const endOfLine =
|
|
24
44
|
context.config.layout.endOfLine === "preserve"
|
|
25
45
|
? text.includes("\r\n")
|
|
@@ -34,19 +54,40 @@ export async function formatDocument(text, context, tool) {
|
|
|
34
54
|
tabWidth: context.config.layout.tabWidth,
|
|
35
55
|
plugins: [],
|
|
36
56
|
};
|
|
37
|
-
const layout = await formatLayout(
|
|
38
|
-
|
|
57
|
+
const layout = await formatLayout(normalized, options, memo);
|
|
58
|
+
if (
|
|
59
|
+
layout === text &&
|
|
60
|
+
matchesNativeCheck(tool, text, precheck) &&
|
|
61
|
+
precheck.report.wouldReformat === false
|
|
62
|
+
)
|
|
63
|
+
// Layout byte identity and the admitted native identity prove a composed
|
|
64
|
+
// fixed point. The exact check still supplies prose findings and rechecks.
|
|
65
|
+
return { output: text, prechecked: true };
|
|
66
|
+
const result = await formatLayout(
|
|
67
|
+
normalizeTrailingWhitespace(runNative(tool, layout), memo),
|
|
68
|
+
options,
|
|
69
|
+
memo,
|
|
70
|
+
);
|
|
39
71
|
// Byte identity proves preservation and an observed fixed point of the entire
|
|
40
72
|
// deterministic formatter pipeline. The independent prose check still runs.
|
|
41
|
-
if (result === text)
|
|
42
|
-
return { output: result, diagnostics: checkProse(tool, result) };
|
|
73
|
+
if (result === text) return { output: result };
|
|
43
74
|
if (
|
|
44
|
-
JSON.stringify(semantic(
|
|
45
|
-
JSON.stringify(semantic(
|
|
75
|
+
JSON.stringify(semantic(tree(text))) !==
|
|
76
|
+
JSON.stringify(semantic(tree(result)))
|
|
46
77
|
)
|
|
47
78
|
fail("PRESERVATION", "Formatting changed parsed meaning or a literal.");
|
|
48
|
-
const second = await formatLayout(
|
|
79
|
+
const second = await formatLayout(
|
|
80
|
+
normalizeTrailingWhitespace(runNative(tool, result), memo),
|
|
81
|
+
options,
|
|
82
|
+
memo,
|
|
83
|
+
);
|
|
49
84
|
if (result !== second)
|
|
50
85
|
fail("CONVERGENCE", "Formatter pipeline did not converge.");
|
|
51
|
-
return { output: result
|
|
86
|
+
return { output: result };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Format one document with its independent prose check, for ungrouped callers. */
|
|
90
|
+
export async function formatDocument(text, context, tool, memo) {
|
|
91
|
+
const formatted = await prepareFormattedDocument(text, context, tool, memo);
|
|
92
|
+
return { ...formatted, diagnostics: checkProse(tool, formatted.output) };
|
|
52
93
|
}
|