@latysh-development/monolith 0.1.0-alpha.0 → 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/README.md +63 -16
- package/dist/brief/collect.d.ts.map +1 -1
- package/dist/brief/collect.js +41 -1
- package/dist/brief/constants.d.ts +2 -0
- package/dist/brief/constants.d.ts.map +1 -1
- package/dist/brief/constants.js +2 -0
- package/dist/brief/render.d.ts +7 -0
- package/dist/brief/render.d.ts.map +1 -1
- package/dist/brief/render.js +52 -1
- package/dist/brief/types.d.ts +17 -1
- package/dist/brief/types.d.ts.map +1 -1
- package/dist/cli/index.js +45 -0
- package/dist/discovery/compose.d.ts +28 -0
- package/dist/discovery/compose.d.ts.map +1 -0
- package/dist/discovery/compose.js +159 -0
- package/dist/discovery/contracts.d.ts +211 -0
- package/dist/discovery/contracts.d.ts.map +1 -0
- package/dist/discovery/contracts.js +555 -0
- package/dist/discovery/docs.d.ts +278 -0
- package/dist/discovery/docs.d.ts.map +1 -0
- package/dist/discovery/docs.js +1182 -0
- package/dist/discovery/facts.d.ts +49 -0
- package/dist/discovery/facts.d.ts.map +1 -0
- package/dist/discovery/facts.js +541 -0
- package/dist/discovery/index.d.ts +8 -0
- package/dist/discovery/index.d.ts.map +1 -0
- package/dist/discovery/index.js +9 -0
- package/dist/discovery/orchestrate.d.ts +35 -0
- package/dist/discovery/orchestrate.d.ts.map +1 -0
- package/dist/discovery/orchestrate.js +32 -0
- package/dist/discovery/render.d.ts +36 -0
- package/dist/discovery/render.d.ts.map +1 -0
- package/dist/discovery/render.js +278 -0
- package/dist/discovery/traverse.d.ts +197 -0
- package/dist/discovery/traverse.d.ts.map +1 -0
- package/dist/discovery/traverse.js +510 -0
- package/dist/durable/errors.d.ts +6 -2
- package/dist/durable/errors.d.ts.map +1 -1
- package/dist/durable/errors.js +9 -3
- package/dist/durable/fs-store.d.ts +21 -0
- package/dist/durable/fs-store.d.ts.map +1 -1
- package/dist/durable/fs-store.js +103 -3
- package/dist/durable/prune-check.d.ts +17 -0
- package/dist/durable/prune-check.d.ts.map +1 -1
- package/dist/durable/prune-check.js +27 -0
- package/dist/durable/runtime.d.ts +5 -3
- package/dist/durable/runtime.d.ts.map +1 -1
- package/dist/durable/runtime.js +23 -6
- package/dist/durable/seams.d.ts +4 -0
- package/dist/durable/seams.d.ts.map +1 -1
- package/dist/durable/testing.js +2 -2
- package/dist/finalize/cli.d.ts +10 -0
- package/dist/finalize/cli.d.ts.map +1 -0
- package/dist/finalize/cli.js +134 -0
- package/dist/finalize/core.d.ts +108 -0
- package/dist/finalize/core.d.ts.map +1 -0
- package/dist/finalize/core.js +767 -0
- package/dist/finalize/index.d.ts +5 -0
- package/dist/finalize/index.d.ts.map +1 -0
- package/dist/finalize/index.js +2 -0
- package/dist/finalize/proposal.d.ts +97 -0
- package/dist/finalize/proposal.d.ts.map +1 -0
- package/dist/finalize/proposal.js +213 -0
- package/dist/ideas/cli.d.ts +33 -0
- package/dist/ideas/cli.d.ts.map +1 -0
- package/dist/ideas/cli.js +158 -0
- package/dist/ideas/format.d.ts +38 -0
- package/dist/ideas/format.d.ts.map +1 -0
- package/dist/ideas/format.js +194 -0
- package/dist/ideas/index.d.ts +6 -0
- package/dist/ideas/index.d.ts.map +1 -0
- package/dist/ideas/index.js +3 -0
- package/dist/ideas/store.d.ts +55 -0
- package/dist/ideas/store.d.ts.map +1 -0
- package/dist/ideas/store.js +262 -0
- package/dist/ideas/types.d.ts +54 -0
- package/dist/ideas/types.d.ts.map +1 -0
- package/dist/ideas/types.js +26 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/init/scaffold.d.ts +4 -0
- package/dist/init/scaffold.d.ts.map +1 -1
- package/dist/init/scaffold.js +6 -2
- package/dist/managed/index.d.ts +73 -0
- package/dist/managed/index.d.ts.map +1 -0
- package/dist/managed/index.js +222 -0
- package/dist/root/index.d.ts +7 -0
- package/dist/root/index.d.ts.map +1 -1
- package/dist/root/index.js +13 -1
- package/dist/update/cli.d.ts +8 -0
- package/dist/update/cli.d.ts.map +1 -0
- package/dist/update/cli.js +115 -0
- package/dist/update/index.d.ts +78 -0
- package/dist/update/index.d.ts.map +1 -0
- package/dist/update/index.js +285 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/templates/default-instance/instruction/.cursorrules +6 -0
- package/templates/default-instance/instruction/.windsurfrules +6 -0
- package/templates/default-instance/instruction/CLAUDE.md +4 -0
- package/templates/default-instance/managed/agent.md +20 -0
- package/templates/default-instance/managed/bootstrap.md +7 -0
- package/templates/default-instance/managed/index-idea.md +5 -0
- package/templates/default-instance/managed/routing-idea.md +14 -0
- package/templates/default-instance/monolith/AGENT.md +1 -0
- package/templates/default-instance/monolith/context/maps/routing-map.md +4 -0
- package/templates/default-instance/monolith/context/maps/semantic-map.md +2 -0
- package/templates/default-instance/monolith/memory/working/active.md +11 -0
- package/templates/default-instance/monolith/skills/INDEX.md +4 -0
- package/templates/default-instance/monolith/skills/finalize-story/SKILL.md +51 -1
- package/templates/default-instance/monolith/skills/idea-capture/SKILL.md +157 -0
- package/templates/default-instance/projectroot/AGENTS.md +4 -0
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deterministic Markdown documentation analyzer (DISCOVERY-001B-2D, ADR-021). Internal.
|
|
3
|
+
*
|
|
4
|
+
* Principle: a document is a source of CLAIMS, never proof that the implementation matches them.
|
|
5
|
+
* This module records what a document's text STRUCTURALLY contains (it exists, it has an ATX
|
|
6
|
+
* heading of level N with this raw text on line L, it has an inline link with this raw target) and
|
|
7
|
+
* nothing about what the project is. It never extracts a technology, a component, a dependency, a
|
|
8
|
+
* decision or a runtime statement from prose, and it never checks anything against the code.
|
|
9
|
+
* Every result carries `assertion: 'DOCUMENT_CONTENT_NOT_VERIFIED'`; observations of the kinds
|
|
10
|
+
* HEADING, LINK and SECTION have `evidence: 'DOCUMENT_TEXT'` (what the author wrote), only
|
|
11
|
+
* DOCUMENT_PRESENT has `evidence: 'OBSERVED_FILE'` (the traversal saw the file).
|
|
12
|
+
*
|
|
13
|
+
* Contract: `DiscoveryResult` (schema 1) is closed and has no place for line positions, so it is NOT
|
|
14
|
+
* changed. This module returns its own `DocumentationResult` and reuses the diagnostic shape, the
|
|
15
|
+
* fact-id function, the path and name predicates, the UTF-16 order and the traversal result.
|
|
16
|
+
*
|
|
17
|
+
* Scope: `README.md` at the project root and `*.md` below `docs/`, and only those that the given
|
|
18
|
+
* `TraversalResult` lists as regular files. Nothing is searched for: a document the traversal did
|
|
19
|
+
* not list does not exist for this module.
|
|
20
|
+
*
|
|
21
|
+
* Reading: each document is read through `readProjectFile` (ADR-011/014: every component below the
|
|
22
|
+
* root is `lstat`-checked and none may be a symlink; `O_NOFOLLOW|O_NONBLOCK`; regular single-link
|
|
23
|
+
* file; bounded read), with a per-document and a total byte cap. That the traversal listed a path
|
|
24
|
+
* proves nothing about the next read, so every read is made on its own terms. NOT defended
|
|
25
|
+
* (ADR-013, SEC-001B.3 open; TM-C unsupported): a process that swaps a directory or file between the
|
|
26
|
+
* check and the open. Do not run it on a tree an untrusted process is modifying.
|
|
27
|
+
* The content is only ever split into lines and matched; it is never evaluated, run, fetched, or
|
|
28
|
+
* resolved: no subprocess, no network, no file access through a link target, no model.
|
|
29
|
+
*
|
|
30
|
+
* Supported Markdown subset (everything else is skipped, never guessed):
|
|
31
|
+
* - ATX headings (`#`..`######`, up to 3 spaces of indentation, optional closing `#` sequence);
|
|
32
|
+
* - inline links `[text](target)` and `[text](target "title")` on ONE line, text without nested
|
|
33
|
+
* brackets, target without whitespace (or `<...>`);
|
|
34
|
+
* - fenced code blocks (``` and ~~~) are skipped whole; so are YAML front matter, HTML comment
|
|
35
|
+
* lines, indented (4+) lines and over-long lines; inline code spans hide what they contain.
|
|
36
|
+
* NOT supported (no observation is made for them): setext headings, reference-style links,
|
|
37
|
+
* autolinks and bare URLs, images, raw HTML, headings/links split over lines, headings inside
|
|
38
|
+
* block quotes or list items, entity and escape decoding. Heading and link text is the RAW source
|
|
39
|
+
* text (inline markup is not rendered); a link target is never resolved, normalised or fetched.
|
|
40
|
+
*
|
|
41
|
+
* Invalid UTF-8: the reader decodes leniently (U+FFFD for invalid bytes); the two cannot be told
|
|
42
|
+
* apart, so a document containing U+FFFD or NUL is not analysed at all.
|
|
43
|
+
*/
|
|
44
|
+
import { isAbsolute } from 'path';
|
|
45
|
+
import { readProjectFile } from '../root/index.js';
|
|
46
|
+
import { DiscoveryError, compareUtf16, computeFactId, isKeepableName, isSafeRelativePath, } from './contracts.js';
|
|
47
|
+
export const DOCUMENTATION_SCHEMA_VERSION = 1;
|
|
48
|
+
/** How an observation was obtained; a function of the kind, never chosen per observation. */
|
|
49
|
+
export const DOCUMENTATION_EVIDENCE_BY_KIND = {
|
|
50
|
+
/** The traversal listed a regular file with a supported name. Says nothing about its content being right. */
|
|
51
|
+
DOCUMENT_PRESENT: 'OBSERVED_FILE',
|
|
52
|
+
/** The author wrote an ATX heading here. Says nothing about the project. */
|
|
53
|
+
HEADING: 'DOCUMENT_TEXT',
|
|
54
|
+
/** Lines between a heading and the next heading of the same or a higher level. A text range, not a component. */
|
|
55
|
+
SECTION: 'DOCUMENT_TEXT',
|
|
56
|
+
/** The author wrote an inline link here. Never resolved, never followed, never checked. */
|
|
57
|
+
LINK: 'DOCUMENT_TEXT',
|
|
58
|
+
};
|
|
59
|
+
const SOURCE_BY_KIND = {
|
|
60
|
+
DOCUMENT_PRESENT: 'markdown-file',
|
|
61
|
+
HEADING: 'markdown-atx-heading',
|
|
62
|
+
SECTION: 'markdown-section',
|
|
63
|
+
LINK: 'markdown-inline-link',
|
|
64
|
+
};
|
|
65
|
+
export const DEFAULT_DOCUMENTATION_LIMITS = Object.freeze({
|
|
66
|
+
maxDocuments: 50,
|
|
67
|
+
maxDocumentBytes: 256 * 1024,
|
|
68
|
+
maxTotalBytes: 2 * 1024 * 1024,
|
|
69
|
+
maxHeadingsPerDocument: 500,
|
|
70
|
+
maxLinksPerDocument: 1000,
|
|
71
|
+
maxTextLength: 200,
|
|
72
|
+
maxTargetLength: 2048,
|
|
73
|
+
maxLineLength: 8192,
|
|
74
|
+
maxObservations: 5000,
|
|
75
|
+
maxDiagnostics: 200,
|
|
76
|
+
});
|
|
77
|
+
const LIMIT_KEYS = Object.keys(DEFAULT_DOCUMENTATION_LIMITS);
|
|
78
|
+
/** Closed list. `true`: something inside the declared scope was not extracted, so the result is `PARTIAL`. */
|
|
79
|
+
export const DOCUMENTATION_DIAGNOSTIC_AFFECTS_COMPLETENESS = {
|
|
80
|
+
DOC_INPUT_PATH_REJECTED: true,
|
|
81
|
+
DOC_LIMIT_DOCUMENTS: true,
|
|
82
|
+
DOC_LIMIT_TOTAL_BYTES: true,
|
|
83
|
+
DOC_LIMIT_HEADINGS: true,
|
|
84
|
+
DOC_LIMIT_LINKS: true,
|
|
85
|
+
DOC_LIMIT_OBSERVATIONS: true,
|
|
86
|
+
DOC_LIMIT_DIAGNOSTICS: true,
|
|
87
|
+
DOC_READ_FAILED: true,
|
|
88
|
+
DOC_TOO_LARGE: true,
|
|
89
|
+
DOC_INVALID_ENCODING: true,
|
|
90
|
+
DOC_LINE_TOO_LONG: true,
|
|
91
|
+
DOC_TEXT_ADJUSTED: true,
|
|
92
|
+
DOC_LINK_REJECTED: true,
|
|
93
|
+
DOC_EMPTY: false,
|
|
94
|
+
DOC_NOT_REGULAR_FILE: false,
|
|
95
|
+
DOC_FRONT_MATTER_SKIPPED: false,
|
|
96
|
+
DOC_UNCLOSED_FENCE: true,
|
|
97
|
+
DOC_SETEXT_HEADING_UNSUPPORTED: false,
|
|
98
|
+
DOC_SECTIONS_NOT_RECORDED: false,
|
|
99
|
+
DOC_HTML_COMMENT_SKIPPED: false,
|
|
100
|
+
DOC_INDENTED_LINES_SKIPPED: false,
|
|
101
|
+
};
|
|
102
|
+
/** Whether a second run on the unchanged tree must return the same diagnostic. */
|
|
103
|
+
const DIAGNOSTIC_IS_STABLE = {
|
|
104
|
+
DOC_INPUT_PATH_REJECTED: true,
|
|
105
|
+
DOC_LIMIT_DOCUMENTS: true,
|
|
106
|
+
DOC_LIMIT_TOTAL_BYTES: true,
|
|
107
|
+
DOC_LIMIT_HEADINGS: true,
|
|
108
|
+
DOC_LIMIT_LINKS: true,
|
|
109
|
+
DOC_LIMIT_OBSERVATIONS: true,
|
|
110
|
+
DOC_LIMIT_DIAGNOSTICS: true,
|
|
111
|
+
// A read can fail because the file changed or the disk misbehaved: not a function of the tree alone.
|
|
112
|
+
DOC_READ_FAILED: false,
|
|
113
|
+
DOC_TOO_LARGE: true,
|
|
114
|
+
DOC_INVALID_ENCODING: true,
|
|
115
|
+
DOC_LINE_TOO_LONG: true,
|
|
116
|
+
DOC_TEXT_ADJUSTED: true,
|
|
117
|
+
DOC_LINK_REJECTED: true,
|
|
118
|
+
DOC_EMPTY: true,
|
|
119
|
+
DOC_NOT_REGULAR_FILE: true,
|
|
120
|
+
DOC_FRONT_MATTER_SKIPPED: true,
|
|
121
|
+
DOC_UNCLOSED_FENCE: true,
|
|
122
|
+
DOC_SETEXT_HEADING_UNSUPPORTED: true,
|
|
123
|
+
DOC_SECTIONS_NOT_RECORDED: true,
|
|
124
|
+
DOC_HTML_COMMENT_SKIPPED: true,
|
|
125
|
+
DOC_INDENTED_LINES_SKIPPED: true,
|
|
126
|
+
};
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// Link and text helpers
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
export function classifyLinkTarget(target) {
|
|
131
|
+
if (target.startsWith('#'))
|
|
132
|
+
return 'anchor';
|
|
133
|
+
if (/^https?:\/\//i.test(target) || target.startsWith('//'))
|
|
134
|
+
return 'external';
|
|
135
|
+
if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(target) || target.startsWith('/'))
|
|
136
|
+
return 'other';
|
|
137
|
+
return 'relative';
|
|
138
|
+
}
|
|
139
|
+
const MAX_SAFE = Number.MAX_SAFE_INTEGER;
|
|
140
|
+
/** Text as kept in an observation: tabs as spaces, trimmed, bounded; unsafe characters withhold it. */
|
|
141
|
+
function fitText(raw, max) {
|
|
142
|
+
const text = raw.replace(/\t/g, ' ').trim();
|
|
143
|
+
if (text.length === 0)
|
|
144
|
+
return { text: '', textStatus: 'FULL' };
|
|
145
|
+
if (!isKeepableName(text, MAX_SAFE))
|
|
146
|
+
return { text: '', textStatus: 'WITHHELD' };
|
|
147
|
+
if (text.length <= max)
|
|
148
|
+
return { text, textStatus: 'FULL' };
|
|
149
|
+
let cut = max;
|
|
150
|
+
const last = text.charCodeAt(cut - 1);
|
|
151
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
152
|
+
cut--; // do not split a surrogate pair
|
|
153
|
+
return { text: text.slice(0, cut), textStatus: 'TRUNCATED' };
|
|
154
|
+
}
|
|
155
|
+
const isAsciiPunct = (c) => (c >= 33 && c <= 47) || (c >= 58 && c <= 64) || (c >= 91 && c <= 96) || (c >= 123 && c <= 126);
|
|
156
|
+
function runLength(s, i, code) {
|
|
157
|
+
let j = i;
|
|
158
|
+
while (j < s.length && s.charCodeAt(j) === code)
|
|
159
|
+
j++;
|
|
160
|
+
return j - i;
|
|
161
|
+
}
|
|
162
|
+
/** Index just after the next run of EXACTLY `len` backticks at or after `from`, or -1. */
|
|
163
|
+
function findBacktickRun(s, from, len) {
|
|
164
|
+
let i = from;
|
|
165
|
+
while (i < s.length) {
|
|
166
|
+
const j = s.indexOf('`', i);
|
|
167
|
+
if (j < 0)
|
|
168
|
+
return -1;
|
|
169
|
+
const n = runLength(s, j, 96);
|
|
170
|
+
if (n === len)
|
|
171
|
+
return j + n;
|
|
172
|
+
i = j + n;
|
|
173
|
+
}
|
|
174
|
+
return -1;
|
|
175
|
+
}
|
|
176
|
+
const TITLE_SCAN_MAX = 512;
|
|
177
|
+
/** `[text](target "title")` starting at the `[` at `i`, on one line, or null. Linear in the construct scanned. */
|
|
178
|
+
function parseInlineLink(line, i, maxTarget) {
|
|
179
|
+
const n = line.length;
|
|
180
|
+
let j = i + 1;
|
|
181
|
+
for (; j < n;) {
|
|
182
|
+
const c = line.charCodeAt(j);
|
|
183
|
+
if (c === 92)
|
|
184
|
+
j += j + 1 < n && isAsciiPunct(line.charCodeAt(j + 1)) ? 2 : 1;
|
|
185
|
+
else if (c === 96) {
|
|
186
|
+
const len = runLength(line, j, 96);
|
|
187
|
+
const end = findBacktickRun(line, j + len, len);
|
|
188
|
+
j = end < 0 ? j + len : end;
|
|
189
|
+
}
|
|
190
|
+
else if (c === 91)
|
|
191
|
+
return null; // nested bracket: not in the supported subset
|
|
192
|
+
else if (c === 93)
|
|
193
|
+
break;
|
|
194
|
+
else
|
|
195
|
+
j++;
|
|
196
|
+
}
|
|
197
|
+
if (j >= n || line.charCodeAt(j + 1) !== 40)
|
|
198
|
+
return null;
|
|
199
|
+
const text = line.slice(i + 1, j);
|
|
200
|
+
let k = j + 2;
|
|
201
|
+
const skipWs = () => {
|
|
202
|
+
while (k < n && (line.charCodeAt(k) === 32 || line.charCodeAt(k) === 9))
|
|
203
|
+
k++;
|
|
204
|
+
};
|
|
205
|
+
skipWs();
|
|
206
|
+
let target;
|
|
207
|
+
if (line.charCodeAt(k) === 60) {
|
|
208
|
+
const start = k + 1;
|
|
209
|
+
let e = start;
|
|
210
|
+
while (e < n && e - start <= maxTarget) {
|
|
211
|
+
const c = line.charCodeAt(e);
|
|
212
|
+
if (c === 62)
|
|
213
|
+
break;
|
|
214
|
+
if (c === 60 || c < 32)
|
|
215
|
+
return null;
|
|
216
|
+
e += c === 92 && e + 1 < n && isAsciiPunct(line.charCodeAt(e + 1)) ? 2 : 1;
|
|
217
|
+
}
|
|
218
|
+
if (e - start > maxTarget)
|
|
219
|
+
return { start: i, end: e, text: '', target: '', overflow: true };
|
|
220
|
+
if (e >= n || line.charCodeAt(e) !== 62)
|
|
221
|
+
return null;
|
|
222
|
+
target = line.slice(start, e);
|
|
223
|
+
k = e + 1;
|
|
224
|
+
}
|
|
225
|
+
else {
|
|
226
|
+
const start = k;
|
|
227
|
+
let depth = 0;
|
|
228
|
+
while (k < n && k - start <= maxTarget) {
|
|
229
|
+
const c = line.charCodeAt(k);
|
|
230
|
+
if (c === 32 || c === 9)
|
|
231
|
+
break;
|
|
232
|
+
if (c < 32)
|
|
233
|
+
return null;
|
|
234
|
+
if (c === 92 && k + 1 < n && isAsciiPunct(line.charCodeAt(k + 1))) {
|
|
235
|
+
k += 2;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (c === 40)
|
|
239
|
+
depth++;
|
|
240
|
+
else if (c === 41) {
|
|
241
|
+
if (depth === 0)
|
|
242
|
+
break;
|
|
243
|
+
depth--;
|
|
244
|
+
}
|
|
245
|
+
k++;
|
|
246
|
+
}
|
|
247
|
+
if (k - start > maxTarget)
|
|
248
|
+
return { start: i, end: k, text: '', target: '', overflow: true };
|
|
249
|
+
if (depth !== 0 || k >= n)
|
|
250
|
+
return null;
|
|
251
|
+
target = line.slice(start, k);
|
|
252
|
+
}
|
|
253
|
+
if (target.length === 0)
|
|
254
|
+
return null;
|
|
255
|
+
skipWs();
|
|
256
|
+
const q = line.charCodeAt(k);
|
|
257
|
+
if (q === 34 || q === 39 || q === 40) {
|
|
258
|
+
const close = q === 40 ? 41 : q;
|
|
259
|
+
let e = k + 1;
|
|
260
|
+
while (e < n && e - k <= TITLE_SCAN_MAX) {
|
|
261
|
+
const c = line.charCodeAt(e);
|
|
262
|
+
if (c === 92 && e + 1 < n)
|
|
263
|
+
e += 2;
|
|
264
|
+
else if (c === close)
|
|
265
|
+
break;
|
|
266
|
+
else
|
|
267
|
+
e++;
|
|
268
|
+
}
|
|
269
|
+
if (e >= n || line.charCodeAt(e) !== close)
|
|
270
|
+
return null;
|
|
271
|
+
k = e + 1;
|
|
272
|
+
skipWs();
|
|
273
|
+
}
|
|
274
|
+
if (line.charCodeAt(k) !== 41)
|
|
275
|
+
return null;
|
|
276
|
+
return { start: i, end: k + 1, text, target };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Inline scan of one line: finds supported links, skipping escapes, code spans, images and HTML tags.
|
|
280
|
+
* `pending` is the length of a backtick run opened earlier in the same paragraph that has not closed
|
|
281
|
+
* yet; what lies between is not scanned (an omission, never a false link). Returns the new `pending`.
|
|
282
|
+
*/
|
|
283
|
+
function scanInline(line, pending, maxTarget, emit) {
|
|
284
|
+
const n = line.length;
|
|
285
|
+
let i = 0;
|
|
286
|
+
if (pending !== null) {
|
|
287
|
+
const end = findBacktickRun(line, 0, pending);
|
|
288
|
+
if (end < 0)
|
|
289
|
+
return pending;
|
|
290
|
+
i = end;
|
|
291
|
+
}
|
|
292
|
+
let bangAt = -2;
|
|
293
|
+
let noGt = false;
|
|
294
|
+
while (i < n) {
|
|
295
|
+
const c = line.charCodeAt(i);
|
|
296
|
+
if (c === 92) {
|
|
297
|
+
i += i + 1 < n && isAsciiPunct(line.charCodeAt(i + 1)) ? 2 : 1;
|
|
298
|
+
}
|
|
299
|
+
else if (c === 96) {
|
|
300
|
+
const len = runLength(line, i, 96);
|
|
301
|
+
const end = findBacktickRun(line, i + len, len);
|
|
302
|
+
if (end < 0)
|
|
303
|
+
return len;
|
|
304
|
+
i = end;
|
|
305
|
+
}
|
|
306
|
+
else if (c === 33) {
|
|
307
|
+
bangAt = i++;
|
|
308
|
+
}
|
|
309
|
+
else if (c === 60 && !noGt) {
|
|
310
|
+
const next = line.charCodeAt(i + 1);
|
|
311
|
+
if ((next >= 65 && next <= 90) || (next >= 97 && next <= 122) || next === 47) {
|
|
312
|
+
const gt = line.indexOf('>', i + 1);
|
|
313
|
+
if (gt < 0)
|
|
314
|
+
noGt = true;
|
|
315
|
+
else {
|
|
316
|
+
i = gt + 1;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
i++;
|
|
321
|
+
}
|
|
322
|
+
else if (c === 91) {
|
|
323
|
+
const link = parseInlineLink(line, i, maxTarget);
|
|
324
|
+
if (link) {
|
|
325
|
+
if (bangAt !== i - 1)
|
|
326
|
+
emit(link);
|
|
327
|
+
i = link.end;
|
|
328
|
+
}
|
|
329
|
+
else
|
|
330
|
+
i++;
|
|
331
|
+
}
|
|
332
|
+
else
|
|
333
|
+
i++;
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
const ATX = /^ {0,3}(#{1,6})(?:[ \t]+(.*))?$/;
|
|
338
|
+
const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})(.*)$/;
|
|
339
|
+
const FENCE_CLOSE = /^ {0,3}(`+|~+)[ \t]*$/;
|
|
340
|
+
const SETEXT_UNDERLINE = /^ {0,3}(?:=+|-+)[ \t]*$/;
|
|
341
|
+
const BLOCK_MARKER_START = /^[ \t]*(?:[-*+>|]|\d+[.)])/;
|
|
342
|
+
function indentWidth(line) {
|
|
343
|
+
let w = 0;
|
|
344
|
+
for (let i = 0; i < line.length; i++) {
|
|
345
|
+
const c = line.charCodeAt(i);
|
|
346
|
+
if (c === 32)
|
|
347
|
+
w++;
|
|
348
|
+
else if (c === 9)
|
|
349
|
+
w += 4 - (w % 4);
|
|
350
|
+
else
|
|
351
|
+
break;
|
|
352
|
+
}
|
|
353
|
+
return w;
|
|
354
|
+
}
|
|
355
|
+
function splitLines(content) {
|
|
356
|
+
const text = content.charCodeAt(0) === 0xfeff ? content.slice(1) : content;
|
|
357
|
+
const lines = text.split(/\r\n|\r|\n/);
|
|
358
|
+
if (lines.length > 0 && lines[lines.length - 1] === '')
|
|
359
|
+
lines.pop();
|
|
360
|
+
return lines;
|
|
361
|
+
}
|
|
362
|
+
/** Closing sequence stripped per CommonMark: `## Title ##` -> `Title`; `# ###` -> ``. */
|
|
363
|
+
function headingContent(body) {
|
|
364
|
+
let text = body.trim();
|
|
365
|
+
let e = text.length;
|
|
366
|
+
while (e > 0 && text.charCodeAt(e - 1) === 35)
|
|
367
|
+
e--;
|
|
368
|
+
if (e === 0)
|
|
369
|
+
return '';
|
|
370
|
+
const before = text.charCodeAt(e - 1);
|
|
371
|
+
if (e < text.length && (before === 32 || before === 9))
|
|
372
|
+
text = text.slice(0, e).trimEnd();
|
|
373
|
+
return text;
|
|
374
|
+
}
|
|
375
|
+
/** Pure: no I/O. Total over any string. */
|
|
376
|
+
export function parseMarkdownStructure(content, limits) {
|
|
377
|
+
const lines = splitLines(content);
|
|
378
|
+
const notes = {
|
|
379
|
+
frontMatterLines: 0,
|
|
380
|
+
htmlCommentLines: 0,
|
|
381
|
+
indentedLines: 0,
|
|
382
|
+
longLines: 0,
|
|
383
|
+
setextLines: 0,
|
|
384
|
+
textAdjusted: 0,
|
|
385
|
+
linksRejected: 0,
|
|
386
|
+
headingsDropped: 0,
|
|
387
|
+
linksDropped: 0,
|
|
388
|
+
};
|
|
389
|
+
const allHeadings = [];
|
|
390
|
+
const headings = [];
|
|
391
|
+
const links = [];
|
|
392
|
+
const groupsByHeading = [];
|
|
393
|
+
let first = 0;
|
|
394
|
+
if (lines.length > 0 && /^---[ \t]*$/.test(lines[0])) {
|
|
395
|
+
for (let k = 1; k < lines.length; k++) {
|
|
396
|
+
const l = lines[k];
|
|
397
|
+
if (/^(?:---|\.\.\.)[ \t]*$/.test(l)) {
|
|
398
|
+
first = k + 1;
|
|
399
|
+
notes.frontMatterLines = first;
|
|
400
|
+
break;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
let fence = null;
|
|
405
|
+
let inComment = false;
|
|
406
|
+
let inGroup = false;
|
|
407
|
+
let afterParagraph = false;
|
|
408
|
+
let pending = null;
|
|
409
|
+
let linkOrdinal = 0;
|
|
410
|
+
for (let idx = first; idx < lines.length; idx++) {
|
|
411
|
+
const raw = lines[idx];
|
|
412
|
+
const ln = idx + 1;
|
|
413
|
+
if (fence) {
|
|
414
|
+
const m = raw.length <= limits.maxLineLength ? FENCE_CLOSE.exec(raw) : null;
|
|
415
|
+
if (m && m[1][0] === fence.ch && m[1].length >= fence.len)
|
|
416
|
+
fence = null;
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
if (inComment) {
|
|
420
|
+
notes.htmlCommentLines++;
|
|
421
|
+
if (raw.includes('-->'))
|
|
422
|
+
inComment = false;
|
|
423
|
+
inGroup = false;
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
if (/^[ \t]*$/.test(raw)) {
|
|
427
|
+
inGroup = false;
|
|
428
|
+
afterParagraph = false;
|
|
429
|
+
pending = null;
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
if (raw.length > limits.maxLineLength) {
|
|
433
|
+
notes.longLines++;
|
|
434
|
+
if (!inGroup && groupsByHeading.length > 0)
|
|
435
|
+
groupsByHeading[groupsByHeading.length - 1]++;
|
|
436
|
+
inGroup = true;
|
|
437
|
+
afterParagraph = false;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
const open = raw.indexOf('<!--');
|
|
441
|
+
if (open >= 0) {
|
|
442
|
+
notes.htmlCommentLines++;
|
|
443
|
+
if (indentWidth(raw) < 4 && /^[ \t]*<!--/.test(raw) && !raw.includes('-->', open + 4))
|
|
444
|
+
inComment = true;
|
|
445
|
+
inGroup = false;
|
|
446
|
+
afterParagraph = false;
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
const fo = FENCE_OPEN.exec(raw);
|
|
450
|
+
if (fo && !(fo[1][0] === '`' && fo[2].includes('`'))) {
|
|
451
|
+
fence = { ch: fo[1][0], len: fo[1].length, line: ln };
|
|
452
|
+
inGroup = false;
|
|
453
|
+
afterParagraph = false;
|
|
454
|
+
pending = null;
|
|
455
|
+
continue;
|
|
456
|
+
}
|
|
457
|
+
if (indentWidth(raw) >= 4) {
|
|
458
|
+
notes.indentedLines++;
|
|
459
|
+
if (!inGroup && groupsByHeading.length > 0)
|
|
460
|
+
groupsByHeading[groupsByHeading.length - 1]++;
|
|
461
|
+
inGroup = true;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
const am = ATX.exec(raw);
|
|
465
|
+
if (am) {
|
|
466
|
+
const level = am[1].length;
|
|
467
|
+
allHeadings.push({ level, line: ln });
|
|
468
|
+
groupsByHeading.push(0);
|
|
469
|
+
const ordinal = allHeadings.length;
|
|
470
|
+
if (ordinal <= limits.maxHeadings) {
|
|
471
|
+
const t = fitText(headingContent(am[2] ?? ''), limits.maxTextLength);
|
|
472
|
+
if (t.textStatus !== 'FULL')
|
|
473
|
+
notes.textAdjusted++;
|
|
474
|
+
headings.push({ ordinal, level, line: ln, text: t.text, textStatus: t.textStatus });
|
|
475
|
+
}
|
|
476
|
+
else
|
|
477
|
+
notes.headingsDropped++;
|
|
478
|
+
inGroup = false;
|
|
479
|
+
afterParagraph = false;
|
|
480
|
+
pending = null;
|
|
481
|
+
}
|
|
482
|
+
else {
|
|
483
|
+
if (afterParagraph && SETEXT_UNDERLINE.test(raw)) {
|
|
484
|
+
notes.setextLines++;
|
|
485
|
+
afterParagraph = false;
|
|
486
|
+
continue;
|
|
487
|
+
}
|
|
488
|
+
if (!inGroup && groupsByHeading.length > 0)
|
|
489
|
+
groupsByHeading[groupsByHeading.length - 1]++;
|
|
490
|
+
inGroup = true;
|
|
491
|
+
afterParagraph = !BLOCK_MARKER_START.test(raw);
|
|
492
|
+
}
|
|
493
|
+
pending = scanInline(raw, pending, limits.maxTargetLength, (l) => {
|
|
494
|
+
if (l.overflow || !isKeepableName(l.target, limits.maxTargetLength)) {
|
|
495
|
+
notes.linksRejected++;
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (links.length >= limits.maxLinks) {
|
|
499
|
+
notes.linksDropped++;
|
|
500
|
+
return;
|
|
501
|
+
}
|
|
502
|
+
const t = fitText(l.text, limits.maxTextLength);
|
|
503
|
+
if (t.textStatus !== 'FULL')
|
|
504
|
+
notes.textAdjusted++;
|
|
505
|
+
links.push({ ordinal: ++linkOrdinal, line: ln, text: t.text, textStatus: t.textStatus, target: l.target, linkType: classifyLinkTarget(l.target) });
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
if (fence)
|
|
509
|
+
notes.unclosedFenceLine = fence.line;
|
|
510
|
+
const sections = [];
|
|
511
|
+
if (notes.setextLines === 0) {
|
|
512
|
+
const stack = []; // indexes into allHeadings
|
|
513
|
+
const ends = new Array(allHeadings.length).fill(lines.length);
|
|
514
|
+
const parents = new Array(allHeadings.length).fill(0);
|
|
515
|
+
allHeadings.forEach((h, k) => {
|
|
516
|
+
while (stack.length > 0 && allHeadings[stack[stack.length - 1]].level >= h.level) {
|
|
517
|
+
ends[stack.pop()] = h.line - 1;
|
|
518
|
+
}
|
|
519
|
+
parents[k] = stack.length > 0 ? stack[stack.length - 1] + 1 : 0;
|
|
520
|
+
stack.push(k);
|
|
521
|
+
});
|
|
522
|
+
for (let k = 0; k < Math.min(allHeadings.length, limits.maxHeadings); k++) {
|
|
523
|
+
const s = {
|
|
524
|
+
headingOrdinal: k + 1,
|
|
525
|
+
startLine: allHeadings[k].line,
|
|
526
|
+
endLine: ends[k],
|
|
527
|
+
ownTextLineGroups: groupsByHeading[k],
|
|
528
|
+
};
|
|
529
|
+
if (parents[k] > 0)
|
|
530
|
+
s.parentHeadingOrdinal = parents[k];
|
|
531
|
+
sections.push(s);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
return { lines: lines.length, headings, links, sections, notes };
|
|
535
|
+
}
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
// Observations: ids, order
|
|
538
|
+
// ---------------------------------------------------------------------------
|
|
539
|
+
function contentKey(o) {
|
|
540
|
+
switch (o.kind) {
|
|
541
|
+
case 'DOCUMENT_PRESENT':
|
|
542
|
+
return `${o.analysis}\u0000${o.bytes ?? ''}\u0000${o.lines ?? ''}`;
|
|
543
|
+
case 'HEADING':
|
|
544
|
+
return `${o.level}\u0000${o.textStatus}\u0000${o.text}`;
|
|
545
|
+
case 'SECTION':
|
|
546
|
+
return `${o.source.endLine}\u0000${o.parentHeadingOrdinal ?? ''}\u0000${o.ownTextLineGroups}`;
|
|
547
|
+
case 'LINK':
|
|
548
|
+
return `${o.linkType}\u0000${o.textStatus}\u0000${o.text}\u0000${o.target}`;
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
export function observationId(o) {
|
|
552
|
+
const full = o;
|
|
553
|
+
return computeFactId(`DOC_${full.kind}`, full.source.path, `${full.source.startLine ?? 0}:${full.ordinal}`, contentKey(full));
|
|
554
|
+
}
|
|
555
|
+
const KIND_RANK = { DOCUMENT_PRESENT: 0, HEADING: 1, SECTION: 2, LINK: 3 };
|
|
556
|
+
/** (path, first line, kind rank, ordinal) in UTF-16 / numeric order. */
|
|
557
|
+
export function compareObservations(a, b) {
|
|
558
|
+
return (compareUtf16(a.source.path, b.source.path) ||
|
|
559
|
+
(a.source.startLine ?? 0) - (b.source.startLine ?? 0) ||
|
|
560
|
+
KIND_RANK[a.kind] - KIND_RANK[b.kind] ||
|
|
561
|
+
a.ordinal - b.ordinal);
|
|
562
|
+
}
|
|
563
|
+
/** Completeness-affecting codes (0), traversal codes (1), the others (2); then code, path, message, count. */
|
|
564
|
+
export function compareDocDiagnostics(a, b) {
|
|
565
|
+
const rank = (d) => d.code.startsWith('TRAVERSAL_') ? 1 : DOCUMENTATION_DIAGNOSTIC_AFFECTS_COMPLETENESS[d.code] ? 0 : 2;
|
|
566
|
+
return rank(a) - rank(b) || compareUtf16(a.code, b.code) || compareUtf16(a.path ?? '', b.path ?? '') || compareUtf16(a.message, b.message) || (a.count ?? 0) - (b.count ?? 0);
|
|
567
|
+
}
|
|
568
|
+
// ---------------------------------------------------------------------------
|
|
569
|
+
// Analysis
|
|
570
|
+
// ---------------------------------------------------------------------------
|
|
571
|
+
function resolveLimits(partial) {
|
|
572
|
+
const limits = { ...DEFAULT_DOCUMENTATION_LIMITS };
|
|
573
|
+
for (const [key, value] of Object.entries(partial ?? {})) {
|
|
574
|
+
if (!LIMIT_KEYS.includes(key))
|
|
575
|
+
throw new DiscoveryError('INVALID_LIMITS', `unknown limit "${key}".`);
|
|
576
|
+
if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 1) {
|
|
577
|
+
throw new DiscoveryError('INVALID_LIMITS', `limit "${key}" must be a positive integer.`);
|
|
578
|
+
}
|
|
579
|
+
limits[key] = value;
|
|
580
|
+
}
|
|
581
|
+
return limits;
|
|
582
|
+
}
|
|
583
|
+
const READ_ERROR_CODES = new Set(Object.keys({
|
|
584
|
+
FILE_MISSING: 0,
|
|
585
|
+
FILE_OUTSIDE_PROJECT: 0,
|
|
586
|
+
FILE_UNRESOLVABLE: 0,
|
|
587
|
+
FILE_NOT_REGULAR: 0,
|
|
588
|
+
FILE_EMPTY: 0,
|
|
589
|
+
FILE_TOO_LARGE: 0,
|
|
590
|
+
FILE_CHANGED: 0,
|
|
591
|
+
FILE_UNREADABLE: 0,
|
|
592
|
+
FILE_SYMLINK: 0,
|
|
593
|
+
FILE_HARDLINKED: 0,
|
|
594
|
+
READ_BOUNDARY_INVALID: 0,
|
|
595
|
+
READ_UNSUPPORTED_PLATFORM: 0,
|
|
596
|
+
}));
|
|
597
|
+
const ENTRY_KINDS = new Set(['file', 'directory', 'symlink', 'other']);
|
|
598
|
+
const depthOf = (p) => p.split('/').length;
|
|
599
|
+
const isDocumentName = (p) => p === 'README.md' || (p.startsWith('docs/') && /\.md$/i.test(p));
|
|
600
|
+
export function analyzeDocumentation(projectRoot, traversal, options = {}) {
|
|
601
|
+
if (typeof projectRoot !== 'string' || !isAbsolute(projectRoot)) {
|
|
602
|
+
throw new DiscoveryError('INVALID_INPUT', 'the project root must be an explicit absolute path.');
|
|
603
|
+
}
|
|
604
|
+
if (!traversal ||
|
|
605
|
+
!Array.isArray(traversal.entries) ||
|
|
606
|
+
!Array.isArray(traversal.diagnostics) ||
|
|
607
|
+
(traversal.completeness !== 'COMPLETE' && traversal.completeness !== 'PARTIAL') ||
|
|
608
|
+
typeof traversal.reproducible !== 'boolean') {
|
|
609
|
+
throw new DiscoveryError('INVALID_INPUT', 'the traversal result is malformed.');
|
|
610
|
+
}
|
|
611
|
+
const limits = resolveLimits(options.limits);
|
|
612
|
+
const read = options.readDocument ?? ((root, rel, o) => readProjectFile(root, rel, o));
|
|
613
|
+
const diags = [];
|
|
614
|
+
const diag = (code, message, path, count) => {
|
|
615
|
+
const d = { code, message };
|
|
616
|
+
if (path !== undefined)
|
|
617
|
+
d.path = path;
|
|
618
|
+
if (count !== undefined)
|
|
619
|
+
d.count = count;
|
|
620
|
+
diags.push(d);
|
|
621
|
+
};
|
|
622
|
+
// 1. Input hygiene, as in fact extraction.
|
|
623
|
+
let rejected = 0;
|
|
624
|
+
const kindByPath = new Map();
|
|
625
|
+
for (const e of traversal.entries) {
|
|
626
|
+
if (!e || !ENTRY_KINDS.has(e.kind) || !isSafeRelativePath(e.path)) {
|
|
627
|
+
rejected++;
|
|
628
|
+
continue;
|
|
629
|
+
}
|
|
630
|
+
const prior = kindByPath.get(e.path);
|
|
631
|
+
if (prior === undefined || compareUtf16(e.kind, prior) < 0)
|
|
632
|
+
kindByPath.set(e.path, e.kind);
|
|
633
|
+
}
|
|
634
|
+
if (rejected > 0)
|
|
635
|
+
diag('DOC_INPUT_PATH_REJECTED', 'traversal entries with an unsafe path or an unknown kind were ignored.', undefined, rejected);
|
|
636
|
+
// 2. Candidates: only what the traversal listed. A link or a special file is never opened.
|
|
637
|
+
const found = [];
|
|
638
|
+
for (const [path, kind] of [...kindByPath].sort((a, b) => compareUtf16(a[0], b[0]))) {
|
|
639
|
+
if (!isDocumentName(path))
|
|
640
|
+
continue;
|
|
641
|
+
if (kind === 'file')
|
|
642
|
+
found.push(path);
|
|
643
|
+
else if (kind === 'symlink' || kind === 'other')
|
|
644
|
+
diag('DOC_NOT_REGULAR_FILE', `an entry with a documentation name is not a regular file (${kind}); it was not read.`, path);
|
|
645
|
+
}
|
|
646
|
+
found.sort((a, b) => (a === 'README.md' ? -1 : b === 'README.md' ? 1 : depthOf(a) - depthOf(b) || compareUtf16(a, b)));
|
|
647
|
+
const candidates = found.slice(0, limits.maxDocuments);
|
|
648
|
+
if (found.length > candidates.length)
|
|
649
|
+
diag('DOC_LIMIT_DOCUMENTS', `documents beyond ${limits.maxDocuments} (deepest last) were not analysed.`, undefined, found.length - candidates.length);
|
|
650
|
+
// 3. Read and parse, document by document, inside the byte budgets.
|
|
651
|
+
const documents = [];
|
|
652
|
+
const headings = [];
|
|
653
|
+
const sections = [];
|
|
654
|
+
const links = [];
|
|
655
|
+
let remaining = limits.maxTotalBytes;
|
|
656
|
+
let bytesRead = 0;
|
|
657
|
+
let totalLimited = 0;
|
|
658
|
+
const parseLimits = {
|
|
659
|
+
maxHeadings: limits.maxHeadingsPerDocument,
|
|
660
|
+
maxLinks: limits.maxLinksPerDocument,
|
|
661
|
+
maxTextLength: limits.maxTextLength,
|
|
662
|
+
maxTargetLength: limits.maxTargetLength,
|
|
663
|
+
maxLineLength: limits.maxLineLength,
|
|
664
|
+
};
|
|
665
|
+
const present = (path, analysis, bytes, lines) => {
|
|
666
|
+
const o = { kind: 'DOCUMENT_PRESENT', evidence: 'OBSERVED_FILE', source: { path, observation: 'markdown-file' }, ordinal: 0, format: 'markdown', analysis };
|
|
667
|
+
if (bytes !== undefined)
|
|
668
|
+
o.bytes = bytes;
|
|
669
|
+
if (lines !== undefined)
|
|
670
|
+
o.lines = lines;
|
|
671
|
+
documents.push({ ...o, id: observationId(o) });
|
|
672
|
+
};
|
|
673
|
+
for (const path of candidates) {
|
|
674
|
+
if (remaining <= 0) {
|
|
675
|
+
totalLimited++;
|
|
676
|
+
present(path, 'NOT_ANALYZED');
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
const cap = Math.min(limits.maxDocumentBytes, remaining);
|
|
680
|
+
let content;
|
|
681
|
+
let size = 0;
|
|
682
|
+
try {
|
|
683
|
+
// The reader is a seam: whatever it returns or throws is untrusted.
|
|
684
|
+
const r = read(projectRoot, path, { maxBytes: cap, allowEmpty: true });
|
|
685
|
+
if (r.ok) {
|
|
686
|
+
if (typeof r.content !== 'string')
|
|
687
|
+
throw new TypeError('content');
|
|
688
|
+
size = Buffer.byteLength(r.content, 'utf8');
|
|
689
|
+
if (size > cap)
|
|
690
|
+
throw new RangeError('size');
|
|
691
|
+
content = r.content;
|
|
692
|
+
}
|
|
693
|
+
else {
|
|
694
|
+
const code = READ_ERROR_CODES.has(r.code) ? r.code : 'UNKNOWN';
|
|
695
|
+
if (code === 'FILE_TOO_LARGE' && cap < limits.maxDocumentBytes)
|
|
696
|
+
totalLimited++;
|
|
697
|
+
else if (code === 'FILE_TOO_LARGE')
|
|
698
|
+
diag('DOC_TOO_LARGE', `the document exceeds ${limits.maxDocumentBytes} bytes and was not read.`, path);
|
|
699
|
+
else
|
|
700
|
+
diag('DOC_READ_FAILED', `the document was refused or could not be read (${code}).`, path);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
catch {
|
|
704
|
+
diag('DOC_READ_FAILED', 'the document could not be read (unexpected failure).', path);
|
|
705
|
+
}
|
|
706
|
+
if (content === undefined) {
|
|
707
|
+
present(path, 'NOT_ANALYZED');
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
bytesRead += size;
|
|
711
|
+
remaining -= size;
|
|
712
|
+
if (content.includes('�') || content.includes('\u0000')) {
|
|
713
|
+
diag('DOC_INVALID_ENCODING', 'the document contains invalid UTF-8 (U+FFFD) or NUL; it was not analysed.', path);
|
|
714
|
+
present(path, 'NOT_ANALYZED', size);
|
|
715
|
+
continue;
|
|
716
|
+
}
|
|
717
|
+
const parsed = parseMarkdownStructure(content, parseLimits);
|
|
718
|
+
const n = parsed.notes;
|
|
719
|
+
if (content.length === 0)
|
|
720
|
+
diag('DOC_EMPTY', 'the document is empty; nothing was extracted.', path);
|
|
721
|
+
if (n.frontMatterLines > 0)
|
|
722
|
+
diag('DOC_FRONT_MATTER_SKIPPED', 'leading YAML front matter was skipped; its content is not analysed.', path, n.frontMatterLines);
|
|
723
|
+
if (n.htmlCommentLines > 0)
|
|
724
|
+
diag('DOC_HTML_COMMENT_SKIPPED', 'lines with or inside HTML comments were not analysed.', path, n.htmlCommentLines);
|
|
725
|
+
if (n.indentedLines > 0)
|
|
726
|
+
diag('DOC_INDENTED_LINES_SKIPPED', 'lines indented by 4 or more columns were not analysed (indented code or nested content).', path, n.indentedLines);
|
|
727
|
+
if (n.longLines > 0)
|
|
728
|
+
diag('DOC_LINE_TOO_LONG', `lines longer than ${limits.maxLineLength} characters were not analysed.`, path, n.longLines);
|
|
729
|
+
if (n.setextLines > 0) {
|
|
730
|
+
diag('DOC_SETEXT_HEADING_UNSUPPORTED', 'setext headings (underlined with = or -) are not recognised.', path, n.setextLines);
|
|
731
|
+
diag('DOC_SECTIONS_NOT_RECORDED', 'sections are not recorded for this document: an unrecognised heading would make their boundaries wrong.', path);
|
|
732
|
+
}
|
|
733
|
+
if (n.unclosedFenceLine !== undefined)
|
|
734
|
+
diag('DOC_UNCLOSED_FENCE', `a fenced code block opened on line ${n.unclosedFenceLine} never closes; the rest of the document is code and was not analysed.`, path);
|
|
735
|
+
if (n.textAdjusted > 0)
|
|
736
|
+
diag('DOC_TEXT_ADJUSTED', 'heading or link text that was too long or contained unsafe characters was truncated or withheld.', path, n.textAdjusted);
|
|
737
|
+
if (n.linksRejected > 0)
|
|
738
|
+
diag('DOC_LINK_REJECTED', 'links whose target is too long or contains unsafe characters were not recorded (a construct that merely looks like a link may be counted).', path, n.linksRejected);
|
|
739
|
+
if (n.headingsDropped > 0)
|
|
740
|
+
diag('DOC_LIMIT_HEADINGS', `headings beyond ${limits.maxHeadingsPerDocument} were not recorded.`, path, n.headingsDropped);
|
|
741
|
+
if (n.linksDropped > 0)
|
|
742
|
+
diag('DOC_LIMIT_LINKS', `links beyond ${limits.maxLinksPerDocument} were not recorded.`, path, n.linksDropped);
|
|
743
|
+
present(path, 'ANALYZED', size, parsed.lines);
|
|
744
|
+
const at = (observation, startLine, endLine) => ({ path, observation, startLine, endLine });
|
|
745
|
+
for (const h of parsed.headings) {
|
|
746
|
+
const o = { kind: 'HEADING', evidence: 'DOCUMENT_TEXT', source: at('markdown-atx-heading', h.line, h.line), ordinal: h.ordinal, level: h.level, text: h.text, textStatus: h.textStatus };
|
|
747
|
+
headings.push({ ...o, id: observationId(o) });
|
|
748
|
+
}
|
|
749
|
+
for (const s of parsed.sections) {
|
|
750
|
+
const o = { kind: 'SECTION', evidence: 'DOCUMENT_TEXT', source: at('markdown-section', s.startLine, s.endLine), ordinal: s.headingOrdinal, headingOrdinal: s.headingOrdinal, ownTextLineGroups: s.ownTextLineGroups };
|
|
751
|
+
if (s.parentHeadingOrdinal !== undefined)
|
|
752
|
+
o.parentHeadingOrdinal = s.parentHeadingOrdinal;
|
|
753
|
+
sections.push({ ...o, id: observationId(o) });
|
|
754
|
+
}
|
|
755
|
+
for (const l of parsed.links) {
|
|
756
|
+
const o = { kind: 'LINK', evidence: 'DOCUMENT_TEXT', source: at('markdown-inline-link', l.line, l.line), ordinal: l.ordinal, text: l.text, textStatus: l.textStatus, target: l.target, linkType: l.linkType };
|
|
757
|
+
links.push({ ...o, id: observationId(o) });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
if (totalLimited > 0)
|
|
761
|
+
diag('DOC_LIMIT_TOTAL_BYTES', `documents that did not fit in the total of ${limits.maxTotalBytes} bytes were not analysed.`, undefined, totalLimited);
|
|
762
|
+
// 4. Global budget, cutting the bulkiest kind first: links, then sections, then headings.
|
|
763
|
+
// A section or a link of a dropped heading is dropped with it.
|
|
764
|
+
let budget = limits.maxObservations;
|
|
765
|
+
const take = (list, keep = () => true) => {
|
|
766
|
+
const out = [];
|
|
767
|
+
for (const x of list) {
|
|
768
|
+
if (!keep(x))
|
|
769
|
+
continue;
|
|
770
|
+
if (budget <= 0)
|
|
771
|
+
break;
|
|
772
|
+
budget--;
|
|
773
|
+
out.push(x);
|
|
774
|
+
}
|
|
775
|
+
return out;
|
|
776
|
+
};
|
|
777
|
+
const keptDocs = take(documents);
|
|
778
|
+
const keptHeadings = take(headings);
|
|
779
|
+
const headingKeys = new Set(keptHeadings.map((h) => `${h.source.path}\u0000${h.ordinal}`));
|
|
780
|
+
const keptSections = take(sections, (s) => headingKeys.has(`${s.source.path}\u0000${s.ordinal}`));
|
|
781
|
+
const keptLinks = take(links);
|
|
782
|
+
const dropped = documents.length + headings.length + sections.length + links.length - (keptDocs.length + keptHeadings.length + keptSections.length + keptLinks.length);
|
|
783
|
+
if (dropped > 0)
|
|
784
|
+
diag('DOC_LIMIT_OBSERVATIONS', `observations beyond ${limits.maxObservations} were not recorded (links are cut first).`, undefined, dropped);
|
|
785
|
+
const observations = [...keptDocs, ...keptHeadings, ...keptSections, ...keptLinks].sort(compareObservations);
|
|
786
|
+
// 5. Diagnostics, completeness, reproducibility: separate properties.
|
|
787
|
+
const fromTraversal = traversal.diagnostics.map((d) => {
|
|
788
|
+
const out = { code: `TRAVERSAL_${String(d.code)}`, message: String(d.message) };
|
|
789
|
+
if (d.path !== undefined && isSafeRelativePath(d.path))
|
|
790
|
+
out.path = d.path;
|
|
791
|
+
if (d.count !== undefined)
|
|
792
|
+
out.count = d.count;
|
|
793
|
+
return out;
|
|
794
|
+
});
|
|
795
|
+
let diagnostics = [...diags, ...fromTraversal].sort(compareDocDiagnostics);
|
|
796
|
+
let diagnosticsDropped = 0;
|
|
797
|
+
if (diagnostics.length > limits.maxDiagnostics) {
|
|
798
|
+
diagnosticsDropped = diagnostics.length - (limits.maxDiagnostics - 1);
|
|
799
|
+
diagnostics = diagnostics.slice(0, limits.maxDiagnostics - 1);
|
|
800
|
+
diagnostics.unshift({ code: 'DOC_LIMIT_DIAGNOSTICS', count: diagnosticsDropped, message: `${diagnosticsDropped} diagnostics beyond the limit of ${limits.maxDiagnostics} were not kept.` });
|
|
801
|
+
}
|
|
802
|
+
const codes = diags.map((d) => d.code);
|
|
803
|
+
if (diagnosticsDropped > 0)
|
|
804
|
+
codes.push('DOC_LIMIT_DIAGNOSTICS');
|
|
805
|
+
const partial = traversal.completeness === 'PARTIAL' || codes.some((c) => DOCUMENTATION_DIAGNOSTIC_AFFECTS_COMPLETENESS[c]);
|
|
806
|
+
const reproducible = traversal.reproducible && codes.every((c) => DIAGNOSTIC_IS_STABLE[c]);
|
|
807
|
+
const result = {
|
|
808
|
+
schemaVersion: DOCUMENTATION_SCHEMA_VERSION,
|
|
809
|
+
assertion: 'DOCUMENT_CONTENT_NOT_VERIFIED',
|
|
810
|
+
limits,
|
|
811
|
+
completeness: partial ? 'PARTIAL' : 'COMPLETE',
|
|
812
|
+
reproducible,
|
|
813
|
+
traversal: { completeness: traversal.completeness, reproducible: traversal.reproducible },
|
|
814
|
+
stats: {
|
|
815
|
+
inputEntries: traversal.entries.length,
|
|
816
|
+
candidateDocuments: found.length,
|
|
817
|
+
documentsAnalyzed: keptDocs.filter((d) => d.analysis === 'ANALYZED').length,
|
|
818
|
+
documentsNotAnalyzed: keptDocs.filter((d) => d.analysis === 'NOT_ANALYZED').length,
|
|
819
|
+
bytesRead,
|
|
820
|
+
observations: observations.length,
|
|
821
|
+
},
|
|
822
|
+
observations,
|
|
823
|
+
diagnostics,
|
|
824
|
+
};
|
|
825
|
+
const violations = validateDocumentationResult(result);
|
|
826
|
+
if (violations.length > 0)
|
|
827
|
+
throw new DiscoveryError('RESULT_INVALID', `the documentation result failed validation: ${violations.slice(0, 3).join('; ')}`);
|
|
828
|
+
return result;
|
|
829
|
+
}
|
|
830
|
+
// ---------------------------------------------------------------------------
|
|
831
|
+
// Validator
|
|
832
|
+
// ---------------------------------------------------------------------------
|
|
833
|
+
const MAX_VIOLATIONS = 100;
|
|
834
|
+
const RESULT_KEYS = ['schemaVersion', 'assertion', 'limits', 'completeness', 'reproducible', 'traversal', 'stats', 'observations', 'diagnostics'];
|
|
835
|
+
const STAT_NAMES = ['inputEntries', 'candidateDocuments', 'documentsAnalyzed', 'documentsNotAnalyzed', 'bytesRead', 'observations'];
|
|
836
|
+
const OBSERVATION_KEYS = {
|
|
837
|
+
DOCUMENT_PRESENT: ['id', 'kind', 'evidence', 'source', 'ordinal', 'format', 'analysis', 'bytes', 'lines'],
|
|
838
|
+
HEADING: ['id', 'kind', 'evidence', 'source', 'ordinal', 'level', 'text', 'textStatus'],
|
|
839
|
+
SECTION: ['id', 'kind', 'evidence', 'source', 'ordinal', 'headingOrdinal', 'parentHeadingOrdinal', 'ownTextLineGroups'],
|
|
840
|
+
LINK: ['id', 'kind', 'evidence', 'source', 'ordinal', 'text', 'textStatus', 'target', 'linkType'],
|
|
841
|
+
};
|
|
842
|
+
const HEX16 = /^[0-9a-f]{16}$/;
|
|
843
|
+
const TRAVERSAL_CODE = /^TRAVERSAL_[A-Z][A-Z0-9_]{0,63}$/;
|
|
844
|
+
// eslint-disable-next-line no-control-regex
|
|
845
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f]/;
|
|
846
|
+
const ABSOLUTE_PATH_IN_TEXT = /(^|[\s"'`(=:])\/[^\s/]|[A-Za-z]:[\\/]|\\\\[^\s\\]/;
|
|
847
|
+
const isObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
848
|
+
const isCount = (v) => typeof v === 'number' && Number.isSafeInteger(v) && v >= 0;
|
|
849
|
+
const own = (o, k) => (Object.prototype.hasOwnProperty.call(o, k) ? o[k] : undefined);
|
|
850
|
+
/**
|
|
851
|
+
* Check of a `DocumentationResult` against the schema-1 contract. Returns the violations (empty = valid).
|
|
852
|
+
* Input is `unknown`: a malformed value yields violations, never a `TypeError`. A violation names a
|
|
853
|
+
* location and a rule and never repeats a value from the input. At most 100 are returned. Plain
|
|
854
|
+
* JSON-like data only (no accessors that throw, no `Proxy`).
|
|
855
|
+
*/
|
|
856
|
+
export function validateDocumentationResult(result) {
|
|
857
|
+
const bad = [];
|
|
858
|
+
const fail = (m) => {
|
|
859
|
+
if (bad.length < MAX_VIOLATIONS)
|
|
860
|
+
bad.push(m);
|
|
861
|
+
};
|
|
862
|
+
if (!isObject(result))
|
|
863
|
+
return ['result: not an object'];
|
|
864
|
+
const keysOk = (o, allowed, at) => {
|
|
865
|
+
for (const k of Object.keys(o))
|
|
866
|
+
if (!allowed.includes(k))
|
|
867
|
+
fail(`${at}: unknown property`);
|
|
868
|
+
};
|
|
869
|
+
keysOk(result, RESULT_KEYS, 'result');
|
|
870
|
+
if (result.schemaVersion !== DOCUMENTATION_SCHEMA_VERSION)
|
|
871
|
+
fail('schemaVersion');
|
|
872
|
+
if (result.assertion !== 'DOCUMENT_CONTENT_NOT_VERIFIED')
|
|
873
|
+
fail('assertion');
|
|
874
|
+
const limits = {};
|
|
875
|
+
let limitsOk = false;
|
|
876
|
+
const rawLimits = own(result, 'limits');
|
|
877
|
+
if (!isObject(rawLimits))
|
|
878
|
+
fail('limits: not an object');
|
|
879
|
+
else {
|
|
880
|
+
limitsOk = true;
|
|
881
|
+
keysOk(rawLimits, LIMIT_KEYS, 'limits');
|
|
882
|
+
for (const k of LIMIT_KEYS) {
|
|
883
|
+
const v = own(rawLimits, k);
|
|
884
|
+
if (typeof v !== 'number' || !Number.isSafeInteger(v) || v < 1) {
|
|
885
|
+
fail(`limits.${k}: not a positive integer`);
|
|
886
|
+
limitsOk = false;
|
|
887
|
+
}
|
|
888
|
+
else
|
|
889
|
+
limits[k] = v;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
const verdict = (v) => v === 'COMPLETE' || v === 'PARTIAL';
|
|
893
|
+
const completeness = own(result, 'completeness');
|
|
894
|
+
const reproducible = own(result, 'reproducible');
|
|
895
|
+
if (!verdict(completeness))
|
|
896
|
+
fail('completeness: not COMPLETE or PARTIAL');
|
|
897
|
+
if (typeof reproducible !== 'boolean')
|
|
898
|
+
fail('reproducible: not a boolean');
|
|
899
|
+
const traversal = own(result, 'traversal');
|
|
900
|
+
let traversalPartial = false;
|
|
901
|
+
let traversalUnstable = false;
|
|
902
|
+
if (!isObject(traversal))
|
|
903
|
+
fail('traversal: not an object');
|
|
904
|
+
else {
|
|
905
|
+
keysOk(traversal, ['completeness', 'reproducible'], 'traversal');
|
|
906
|
+
if (!verdict(own(traversal, 'completeness')))
|
|
907
|
+
fail('traversal.completeness: not COMPLETE or PARTIAL');
|
|
908
|
+
if (typeof own(traversal, 'reproducible') !== 'boolean')
|
|
909
|
+
fail('traversal.reproducible: not a boolean');
|
|
910
|
+
traversalPartial = own(traversal, 'completeness') === 'PARTIAL';
|
|
911
|
+
traversalUnstable = own(traversal, 'reproducible') === false;
|
|
912
|
+
}
|
|
913
|
+
const stats = own(result, 'stats');
|
|
914
|
+
let statsOk = false;
|
|
915
|
+
if (!isObject(stats))
|
|
916
|
+
fail('stats: not an object');
|
|
917
|
+
else {
|
|
918
|
+
statsOk = true;
|
|
919
|
+
keysOk(stats, STAT_NAMES, 'stats');
|
|
920
|
+
for (const k of STAT_NAMES) {
|
|
921
|
+
if (!isCount(own(stats, k))) {
|
|
922
|
+
fail(`stats.${k}: not a non-negative integer`);
|
|
923
|
+
statsOk = false;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
}
|
|
927
|
+
// observations: size first, so an oversized array is refused without walking it
|
|
928
|
+
const observations = own(result, 'observations');
|
|
929
|
+
if (!Array.isArray(observations))
|
|
930
|
+
fail('observations: not an array');
|
|
931
|
+
else if (limitsOk && observations.length > limits.maxObservations)
|
|
932
|
+
fail('observations: more than limits.maxObservations');
|
|
933
|
+
else {
|
|
934
|
+
const docs = new Map();
|
|
935
|
+
const seenIds = new Set();
|
|
936
|
+
const perDoc = new Map();
|
|
937
|
+
const valid = [];
|
|
938
|
+
let analyzedBytes = 0;
|
|
939
|
+
const textOk = (o, at) => {
|
|
940
|
+
const { text, textStatus } = o;
|
|
941
|
+
if (textStatus !== 'FULL' && textStatus !== 'TRUNCATED' && textStatus !== 'WITHHELD')
|
|
942
|
+
return fail(`${at}: textStatus`);
|
|
943
|
+
if (typeof text !== 'string' || CONTROL.test(text))
|
|
944
|
+
return fail(`${at}: text is not plain text`);
|
|
945
|
+
if (limitsOk && text.length > limits.maxTextLength)
|
|
946
|
+
fail(`${at}: text longer than limits.maxTextLength`);
|
|
947
|
+
if (textStatus === 'WITHHELD' && text !== '')
|
|
948
|
+
fail(`${at}: withheld text must be empty`);
|
|
949
|
+
if (textStatus === 'TRUNCATED' && limitsOk && text.length < limits.maxTextLength - 1)
|
|
950
|
+
fail(`${at}: truncated although shorter than the limit`);
|
|
951
|
+
};
|
|
952
|
+
// First pass: shape, ids, documents.
|
|
953
|
+
observations.forEach((raw, i) => {
|
|
954
|
+
const at = `observations[${i}]`;
|
|
955
|
+
if (!isObject(raw))
|
|
956
|
+
return fail(`${at}: not an object`);
|
|
957
|
+
const kind = raw.kind;
|
|
958
|
+
if (typeof kind !== 'string' || !Object.prototype.hasOwnProperty.call(DOCUMENTATION_EVIDENCE_BY_KIND, kind))
|
|
959
|
+
return fail(`${at}: kind`);
|
|
960
|
+
const k = kind;
|
|
961
|
+
keysOk(raw, OBSERVATION_KEYS[k], at);
|
|
962
|
+
if (typeof raw.id !== 'string' || !HEX16.test(raw.id))
|
|
963
|
+
return fail(`${at}: id`);
|
|
964
|
+
if (raw.evidence !== DOCUMENTATION_EVIDENCE_BY_KIND[k])
|
|
965
|
+
fail(`${at}: evidence does not match the kind`);
|
|
966
|
+
const src = raw.source;
|
|
967
|
+
if (!isObject(src))
|
|
968
|
+
return fail(`${at}: source: not an object`);
|
|
969
|
+
keysOk(src, ['path', 'observation', 'startLine', 'endLine'], `${at}.source`);
|
|
970
|
+
if (!isSafeRelativePath(src.path))
|
|
971
|
+
return fail(`${at}.source: unsafe path`);
|
|
972
|
+
if (src.observation !== SOURCE_BY_KIND[k])
|
|
973
|
+
fail(`${at}.source: observation does not match the kind`);
|
|
974
|
+
const whole = k === 'DOCUMENT_PRESENT';
|
|
975
|
+
if (whole) {
|
|
976
|
+
if (src.startLine !== undefined || src.endLine !== undefined)
|
|
977
|
+
fail(`${at}.source: a document has no line range`);
|
|
978
|
+
}
|
|
979
|
+
else if (!isCount(src.startLine) || !isCount(src.endLine) || src.startLine < 1 || src.endLine < src.startLine) {
|
|
980
|
+
return fail(`${at}.source: invalid line range`);
|
|
981
|
+
}
|
|
982
|
+
else if ((k === 'HEADING' || k === 'LINK') && src.startLine !== src.endLine)
|
|
983
|
+
fail(`${at}.source: ${k} is a single line`);
|
|
984
|
+
if (!isCount(raw.ordinal) || (whole ? raw.ordinal !== 0 : raw.ordinal < 1))
|
|
985
|
+
return fail(`${at}: ordinal`);
|
|
986
|
+
const o = raw;
|
|
987
|
+
if (o.id !== observationId(o))
|
|
988
|
+
fail(`${at}: id does not match its content`);
|
|
989
|
+
if (seenIds.has(o.id))
|
|
990
|
+
fail(`${at}: duplicate id`);
|
|
991
|
+
seenIds.add(o.id);
|
|
992
|
+
switch (o.kind) {
|
|
993
|
+
case 'DOCUMENT_PRESENT': {
|
|
994
|
+
if (o.format !== 'markdown')
|
|
995
|
+
fail(`${at}: format`);
|
|
996
|
+
if (o.analysis !== 'ANALYZED' && o.analysis !== 'NOT_ANALYZED')
|
|
997
|
+
fail(`${at}: analysis`);
|
|
998
|
+
if (o.analysis === 'ANALYZED') {
|
|
999
|
+
if (!isCount(o.bytes) || !isCount(o.lines))
|
|
1000
|
+
fail(`${at}: an analysed document needs bytes and lines`);
|
|
1001
|
+
else {
|
|
1002
|
+
analyzedBytes += o.bytes;
|
|
1003
|
+
if (limitsOk && o.bytes > limits.maxDocumentBytes)
|
|
1004
|
+
fail(`${at}: more bytes than limits.maxDocumentBytes`);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
else if (o.lines !== undefined)
|
|
1008
|
+
fail(`${at}: lines on a document that was not analysed`);
|
|
1009
|
+
else if (o.bytes !== undefined && !isCount(o.bytes))
|
|
1010
|
+
fail(`${at}: bytes`);
|
|
1011
|
+
if (docs.has(o.source.path))
|
|
1012
|
+
fail(`${at}: duplicate document`);
|
|
1013
|
+
docs.set(o.source.path, o);
|
|
1014
|
+
break;
|
|
1015
|
+
}
|
|
1016
|
+
case 'HEADING':
|
|
1017
|
+
if (![1, 2, 3, 4, 5, 6].includes(o.level))
|
|
1018
|
+
fail(`${at}: level`);
|
|
1019
|
+
textOk(o, at);
|
|
1020
|
+
break;
|
|
1021
|
+
case 'SECTION':
|
|
1022
|
+
if (o.headingOrdinal !== o.ordinal)
|
|
1023
|
+
fail(`${at}: headingOrdinal differs from ordinal`);
|
|
1024
|
+
if (o.parentHeadingOrdinal !== undefined && !(isCount(o.parentHeadingOrdinal) && o.parentHeadingOrdinal >= 1 && o.parentHeadingOrdinal < o.ordinal))
|
|
1025
|
+
fail(`${at}: parentHeadingOrdinal`);
|
|
1026
|
+
if (!isCount(o.ownTextLineGroups))
|
|
1027
|
+
fail(`${at}: ownTextLineGroups`);
|
|
1028
|
+
break;
|
|
1029
|
+
case 'LINK':
|
|
1030
|
+
textOk(o, at);
|
|
1031
|
+
if (typeof o.target !== 'string' || (limitsOk && !isKeepableName(o.target, limits.maxTargetLength)))
|
|
1032
|
+
fail(`${at}: target is not a keepable string`);
|
|
1033
|
+
else if (o.linkType !== classifyLinkTarget(o.target))
|
|
1034
|
+
fail(`${at}: linkType does not match the target`);
|
|
1035
|
+
break;
|
|
1036
|
+
}
|
|
1037
|
+
valid.push(o);
|
|
1038
|
+
});
|
|
1039
|
+
// Second pass: relations between observations.
|
|
1040
|
+
valid.forEach((o, i) => {
|
|
1041
|
+
const at = `observations[${i}]`;
|
|
1042
|
+
if (o.kind === 'DOCUMENT_PRESENT')
|
|
1043
|
+
return;
|
|
1044
|
+
const doc = docs.get(o.source.path);
|
|
1045
|
+
if (!doc)
|
|
1046
|
+
return fail(`${at}: no DOCUMENT_PRESENT for its path`);
|
|
1047
|
+
if (doc.analysis !== 'ANALYZED')
|
|
1048
|
+
return fail(`${at}: observation of a document that was not analysed`);
|
|
1049
|
+
if (o.source.endLine > doc.lines)
|
|
1050
|
+
fail(`${at}: line range beyond the document`);
|
|
1051
|
+
const d = perDoc.get(o.source.path) ?? { headings: new Map(), links: 0, lastLink: 0 };
|
|
1052
|
+
perDoc.set(o.source.path, d);
|
|
1053
|
+
if (o.kind === 'HEADING') {
|
|
1054
|
+
if (d.headings.has(o.ordinal))
|
|
1055
|
+
fail(`${at}: duplicate heading ordinal`);
|
|
1056
|
+
d.headings.set(o.ordinal, o);
|
|
1057
|
+
}
|
|
1058
|
+
else if (o.kind === 'LINK') {
|
|
1059
|
+
d.links++;
|
|
1060
|
+
if (o.ordinal <= d.lastLink)
|
|
1061
|
+
fail(`${at}: link ordinal order`);
|
|
1062
|
+
d.lastLink = o.ordinal;
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
valid.forEach((o, i) => {
|
|
1066
|
+
if (o.kind !== 'SECTION')
|
|
1067
|
+
return;
|
|
1068
|
+
const at = `observations[${i}]`;
|
|
1069
|
+
const h = perDoc.get(o.source.path)?.headings;
|
|
1070
|
+
const head = h?.get(o.headingOrdinal);
|
|
1071
|
+
if (!head)
|
|
1072
|
+
return fail(`${at}: no heading for this section`);
|
|
1073
|
+
if (head.source.startLine !== o.source.startLine)
|
|
1074
|
+
fail(`${at}: section does not start at its heading`);
|
|
1075
|
+
if (o.parentHeadingOrdinal !== undefined) {
|
|
1076
|
+
const parent = h?.get(o.parentHeadingOrdinal);
|
|
1077
|
+
if (!parent || parent.level >= head.level)
|
|
1078
|
+
fail(`${at}: parent is not a heading of a lower level`);
|
|
1079
|
+
}
|
|
1080
|
+
});
|
|
1081
|
+
if (limitsOk) {
|
|
1082
|
+
if (docs.size > limits.maxDocuments)
|
|
1083
|
+
fail('observations: more documents than limits.maxDocuments');
|
|
1084
|
+
if (analyzedBytes > limits.maxTotalBytes)
|
|
1085
|
+
fail('observations: more bytes than limits.maxTotalBytes');
|
|
1086
|
+
for (const d of perDoc.values()) {
|
|
1087
|
+
if (d.headings.size > limits.maxHeadingsPerDocument)
|
|
1088
|
+
fail('observations: more headings in one document than the limit');
|
|
1089
|
+
if (d.links > limits.maxLinksPerDocument)
|
|
1090
|
+
fail('observations: more links in one document than the limit');
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
for (let i = 1; i < valid.length; i++) {
|
|
1094
|
+
if (compareObservations(valid[i - 1], valid[i]) > 0)
|
|
1095
|
+
fail(`observations[${i}]: order`);
|
|
1096
|
+
}
|
|
1097
|
+
if (statsOk) {
|
|
1098
|
+
const st = stats;
|
|
1099
|
+
const analyzed = [...docs.values()].filter((d) => d.analysis === 'ANALYZED').length;
|
|
1100
|
+
if (st.observations !== observations.length)
|
|
1101
|
+
fail('stats.observations: differs from the number of observations');
|
|
1102
|
+
if (st.documentsAnalyzed !== analyzed)
|
|
1103
|
+
fail('stats.documentsAnalyzed: differs from the analysed documents');
|
|
1104
|
+
if (st.documentsNotAnalyzed !== docs.size - analyzed)
|
|
1105
|
+
fail('stats.documentsNotAnalyzed: differs from the documents not analysed');
|
|
1106
|
+
if (st.candidateDocuments < docs.size)
|
|
1107
|
+
fail('stats.candidateDocuments: fewer than the documents');
|
|
1108
|
+
if (st.bytesRead < analyzedBytes)
|
|
1109
|
+
fail('stats.bytesRead: fewer than the analysed bytes');
|
|
1110
|
+
if (limitsOk && st.bytesRead > limits.maxTotalBytes)
|
|
1111
|
+
fail('stats.bytesRead: more than limits.maxTotalBytes');
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
const diagnostics = own(result, 'diagnostics');
|
|
1115
|
+
let affecting = false;
|
|
1116
|
+
let unstable = false;
|
|
1117
|
+
if (!Array.isArray(diagnostics))
|
|
1118
|
+
fail('diagnostics: not an array');
|
|
1119
|
+
else if (limitsOk && diagnostics.length > limits.maxDiagnostics)
|
|
1120
|
+
fail('diagnostics: more than limits.maxDiagnostics');
|
|
1121
|
+
else {
|
|
1122
|
+
const parsed = [];
|
|
1123
|
+
let allOk = true;
|
|
1124
|
+
diagnostics.forEach((raw, i) => {
|
|
1125
|
+
const at = `diagnostics[${i}]`;
|
|
1126
|
+
if (!isObject(raw)) {
|
|
1127
|
+
allOk = false;
|
|
1128
|
+
return fail(`${at}: not an object`);
|
|
1129
|
+
}
|
|
1130
|
+
keysOk(raw, ['code', 'path', 'count', 'message'], at);
|
|
1131
|
+
const { code, path, count, message } = raw;
|
|
1132
|
+
const known = typeof code === 'string' && Object.prototype.hasOwnProperty.call(DOCUMENTATION_DIAGNOSTIC_AFFECTS_COMPLETENESS, code);
|
|
1133
|
+
let ok = true;
|
|
1134
|
+
if (!known && !(typeof code === 'string' && TRAVERSAL_CODE.test(code))) {
|
|
1135
|
+
fail(`${at}: unknown code`);
|
|
1136
|
+
ok = false;
|
|
1137
|
+
}
|
|
1138
|
+
if (path !== undefined && !isSafeRelativePath(path)) {
|
|
1139
|
+
fail(`${at}: unsafe path`);
|
|
1140
|
+
ok = false;
|
|
1141
|
+
}
|
|
1142
|
+
if (count !== undefined && !(isCount(count) && count >= 1)) {
|
|
1143
|
+
fail(`${at}: count is not a positive integer`);
|
|
1144
|
+
ok = false;
|
|
1145
|
+
}
|
|
1146
|
+
if (typeof message !== 'string' || message.length === 0 || CONTROL.test(message)) {
|
|
1147
|
+
fail(`${at}: message is not plain text`);
|
|
1148
|
+
ok = false;
|
|
1149
|
+
}
|
|
1150
|
+
else if (ABSOLUTE_PATH_IN_TEXT.test(message)) {
|
|
1151
|
+
fail(`${at}: message contains an absolute path`);
|
|
1152
|
+
ok = false;
|
|
1153
|
+
}
|
|
1154
|
+
if (!ok) {
|
|
1155
|
+
allOk = false;
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
if (known) {
|
|
1159
|
+
if (DOCUMENTATION_DIAGNOSTIC_AFFECTS_COMPLETENESS[code])
|
|
1160
|
+
affecting = true;
|
|
1161
|
+
if (!DIAGNOSTIC_IS_STABLE[code])
|
|
1162
|
+
unstable = true;
|
|
1163
|
+
}
|
|
1164
|
+
parsed.push(raw);
|
|
1165
|
+
});
|
|
1166
|
+
if (allOk) {
|
|
1167
|
+
parsed.forEach((d, i) => {
|
|
1168
|
+
if (d.code === 'DOC_LIMIT_DIAGNOSTICS' && i !== 0)
|
|
1169
|
+
fail(`diagnostics[${i}]: the DOC_LIMIT_DIAGNOSTICS summary must come first`);
|
|
1170
|
+
if (i === 0 || (i === 1 && parsed[0].code === 'DOC_LIMIT_DIAGNOSTICS'))
|
|
1171
|
+
return;
|
|
1172
|
+
if (compareDocDiagnostics(parsed[i - 1], d) > 0)
|
|
1173
|
+
fail(`diagnostics[${i}]: order`);
|
|
1174
|
+
});
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
if (completeness === 'COMPLETE' && (traversalPartial || affecting))
|
|
1178
|
+
fail('completeness: COMPLETE although the traversal or a diagnostic says PARTIAL');
|
|
1179
|
+
if (reproducible === true && (traversalUnstable || unstable))
|
|
1180
|
+
fail('reproducible: true although the traversal or a diagnostic says it is not');
|
|
1181
|
+
return bad;
|
|
1182
|
+
}
|