@rdjperron/lint 0.6.0 → 0.8.0
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 +283 -122
- package/dist/{config-CD2iCM6L.mjs → config-BCudqv3_.mjs} +58 -2
- package/dist/config-BCudqv3_.mjs.map +1 -0
- package/dist/config.d.mts +38 -1
- package/dist/config.mjs +2 -2
- package/dist/index.mjs +691 -7
- package/dist/index.mjs.map +1 -1
- package/dist/repo.d.mts +16 -27
- package/dist/repo.mjs +28 -164
- package/dist/repo.mjs.map +1 -1
- package/dist/verify.mjs +122 -50
- package/dist/verify.mjs.map +1 -1
- package/fixtures/README.md +5 -0
- package/fixtures/convex/explicit-table-ids.fixture.ts +4 -0
- package/fixtures/convex-checked-constructors.fixture.ts +4 -0
- package/fixtures/convex-require-validators.fixture.ts +4 -0
- package/package.json +8 -3
- package/dist/config-CD2iCM6L.mjs.map +0 -1
package/dist/repo.d.mts
CHANGED
|
@@ -3,46 +3,40 @@
|
|
|
3
3
|
* The promises a repository's prose can only state, held as faults instead.
|
|
4
4
|
*
|
|
5
5
|
* - a relative Markdown link points at something that is there;
|
|
6
|
-
* - the decision register is one well-formed line per row, each number given once;
|
|
7
|
-
* - a decision number cited anywhere is a live row in the register, never a retired one;
|
|
8
6
|
* - a research entry is named by its date, and a dated folder opens with a README;
|
|
9
|
-
* - every path the config names is there
|
|
7
|
+
* - every path the config names is there.
|
|
10
8
|
*
|
|
11
9
|
* Every path in a fault is relative to the root, so it reads as the path you would type to open
|
|
12
10
|
* the offender. Nothing here imports a test runner: the consumer's test asserts on what
|
|
13
11
|
* `checkRepo` returns. It never throws on a tree or a config it cannot read: that is a fault too.
|
|
14
12
|
*/
|
|
15
13
|
export interface RepoConfig {
|
|
16
|
-
/** The repository root, absolute. */
|
|
17
|
-
root: string;
|
|
18
14
|
/**
|
|
19
|
-
* The
|
|
20
|
-
*
|
|
15
|
+
* The repository root, absolute. Every other path in this config is relative to it, and
|
|
16
|
+
* `docs`, `./docs` and `docs/` name the same one.
|
|
21
17
|
*/
|
|
22
|
-
|
|
18
|
+
root: string;
|
|
23
19
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
* Directory and file names the walk of `docs` does not enter, wherever they sit. Added to
|
|
21
|
+
* `DEFAULT_SKIP`.
|
|
26
22
|
*/
|
|
27
|
-
extensions?: readonly string[];
|
|
28
|
-
/** Directory and file names no walk enters, wherever they sit. Added to `DEFAULT_SKIP`. */
|
|
29
23
|
skip?: readonly string[];
|
|
30
|
-
/**
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
/** The living documents at the root. Replaces `DEFAULT_DOCUMENTS`. */
|
|
24
|
+
/**
|
|
25
|
+
* The living documents at the root. Replaces `DEFAULT_DOCUMENTS`, any of which a repo may
|
|
26
|
+
* lack.
|
|
27
|
+
*/
|
|
35
28
|
documents?: readonly string[];
|
|
36
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* The directory whose every Markdown file is a living document. `docs` by default, and a repo
|
|
31
|
+
* that names none may have none.
|
|
32
|
+
*/
|
|
37
33
|
docs?: string;
|
|
38
|
-
/** More documents held as the living ones are
|
|
34
|
+
/** More documents whose links are held as the living ones' are. */
|
|
39
35
|
linked?: readonly string[];
|
|
40
|
-
/** The decision register. `decisions.md` in the `docs` directory by default. */
|
|
41
|
-
register?: string;
|
|
42
36
|
/** The research directory. `_research` by default, and a repo may have none yet. */
|
|
43
37
|
research?: string;
|
|
44
38
|
}
|
|
45
|
-
export declare const CHECKS: readonly ["scans", "links", "
|
|
39
|
+
export declare const CHECKS: readonly ["scans", "links", "research"];
|
|
46
40
|
export type Check = (typeof CHECKS)[number];
|
|
47
41
|
/** The faults of each check, empty where the check holds. */
|
|
48
42
|
export type RepoFaults = { readonly [check in Check]: readonly string[]; };
|
|
@@ -50,20 +44,15 @@ export type RepoFaults = { readonly [check in Check]: readonly string[]; };
|
|
|
50
44
|
export declare const TITLES: {
|
|
51
45
|
scans: string;
|
|
52
46
|
links: string;
|
|
53
|
-
register: string;
|
|
54
|
-
citations: string;
|
|
55
47
|
research: string;
|
|
56
48
|
};
|
|
57
49
|
/** What to do about a failing check, for the message of the consumer's assertion. */
|
|
58
50
|
export declare const HINTS: {
|
|
59
51
|
scans: string;
|
|
60
52
|
links: string;
|
|
61
|
-
register: string;
|
|
62
|
-
citations: string;
|
|
63
53
|
research: string;
|
|
64
54
|
};
|
|
65
55
|
export declare const DEFAULT_DOCUMENTS: readonly string[];
|
|
66
|
-
export declare const DEFAULT_EXTENSIONS: readonly string[];
|
|
67
56
|
/** Vendored, generated and built trees. */
|
|
68
57
|
export declare const DEFAULT_SKIP: readonly string[];
|
|
69
58
|
export declare function checkRepo(config: RepoConfig): RepoFaults;
|
package/dist/repo.mjs
CHANGED
|
@@ -1,14 +1,12 @@
|
|
|
1
1
|
import { lstatSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
2
|
-
import { dirname, join, posix, relative, resolve } from "node:path";
|
|
2
|
+
import { dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
3
3
|
//#region src/repo.ts
|
|
4
4
|
/**
|
|
5
5
|
* The promises a repository's prose can only state, held as faults instead.
|
|
6
6
|
*
|
|
7
7
|
* - a relative Markdown link points at something that is there;
|
|
8
|
-
* - the decision register is one well-formed line per row, each number given once;
|
|
9
|
-
* - a decision number cited anywhere is a live row in the register, never a retired one;
|
|
10
8
|
* - a research entry is named by its date, and a dated folder opens with a README;
|
|
11
|
-
* - every path the config names is there
|
|
9
|
+
* - every path the config names is there.
|
|
12
10
|
*
|
|
13
11
|
* Every path in a fault is relative to the root, so it reads as the path you would type to open
|
|
14
12
|
* the offender. Nothing here imports a test runner: the consumer's test asserts on what
|
|
@@ -17,24 +15,18 @@ import { dirname, join, posix, relative, resolve } from "node:path";
|
|
|
17
15
|
const CHECKS = [
|
|
18
16
|
"scans",
|
|
19
17
|
"links",
|
|
20
|
-
"register",
|
|
21
|
-
"citations",
|
|
22
18
|
"research"
|
|
23
19
|
];
|
|
24
20
|
/** What each check promises, for the title of the consumer's test. */
|
|
25
21
|
const TITLES = {
|
|
26
|
-
scans: "every path the config names is there and
|
|
22
|
+
scans: "every path the config names is there, and every symbolic link leads somewhere",
|
|
27
23
|
links: "every relative Markdown link resolves",
|
|
28
|
-
register: "the register is one well-formed line per row, each number given once",
|
|
29
|
-
citations: "every decision number cited anywhere is a live row in the register",
|
|
30
24
|
research: "every research entry is named by its date, and every dated folder has a README"
|
|
31
25
|
};
|
|
32
26
|
/** What to do about a failing check, for the message of the consumer's assertion. */
|
|
33
27
|
const HINTS = {
|
|
34
28
|
scans: "A check that reads nothing passes on nothing. Correct the path in the config, or remove it with the thing it named.",
|
|
35
29
|
links: "Fix the path, or delete the link with the thing it pointed at.",
|
|
36
|
-
register: "A row is one line, its number given once and never after it was retired, and a number that leaves the rows joins the retired list. Rewrite a rule in place under its own number.",
|
|
37
|
-
citations: "A citation of a retired number, or of one never given, sends the reader nowhere. Cite the row that carries the rule now. `git log -S \"D<n>\"` on the register shows what a retired row said.",
|
|
38
30
|
research: "The research sorts by name, so the name carries the date. Date an entry by the day it was opened, and give a folder a README that says what is in it."
|
|
39
31
|
};
|
|
40
32
|
const DEFAULT_DOCUMENTS = [
|
|
@@ -42,22 +34,6 @@ const DEFAULT_DOCUMENTS = [
|
|
|
42
34
|
"AGENTS.md",
|
|
43
35
|
"BACKLOG.md"
|
|
44
36
|
];
|
|
45
|
-
const DEFAULT_EXTENSIONS = [
|
|
46
|
-
"ts",
|
|
47
|
-
"tsx",
|
|
48
|
-
"mts",
|
|
49
|
-
"cts",
|
|
50
|
-
"js",
|
|
51
|
-
"jsx",
|
|
52
|
-
"mjs",
|
|
53
|
-
"cjs",
|
|
54
|
-
"md",
|
|
55
|
-
"json",
|
|
56
|
-
"jsonc",
|
|
57
|
-
"yml",
|
|
58
|
-
"yaml",
|
|
59
|
-
"html"
|
|
60
|
-
];
|
|
61
37
|
/** Vendored, generated and built trees. */
|
|
62
38
|
const DEFAULT_SKIP = [
|
|
63
39
|
"node_modules",
|
|
@@ -78,9 +54,6 @@ function isSymbolicLink(tree, path) {
|
|
|
78
54
|
return false;
|
|
79
55
|
}
|
|
80
56
|
}
|
|
81
|
-
function read(tree, path) {
|
|
82
|
-
return readFileSync(join(tree.root, path), "utf8").replaceAll("\r\n", "\n");
|
|
83
|
-
}
|
|
84
57
|
function names(tree, path) {
|
|
85
58
|
try {
|
|
86
59
|
return readdirSync(join(tree.root, path)).toSorted();
|
|
@@ -94,21 +67,18 @@ function child(base, name) {
|
|
|
94
67
|
function walk(tree, base) {
|
|
95
68
|
return names(tree, base).flatMap((name) => {
|
|
96
69
|
const path = child(base, name);
|
|
97
|
-
if (tree.skip.has(name)
|
|
70
|
+
if (tree.skip.has(name)) return [];
|
|
98
71
|
const kind = kindOf(tree, path);
|
|
99
72
|
if (kind === "file") return [path];
|
|
100
73
|
if (kind === "absent") tree.broken.add(path);
|
|
101
74
|
return kind === "directory" && !isSymbolicLink(tree, path) ? walk(tree, path) : [];
|
|
102
75
|
});
|
|
103
76
|
}
|
|
104
|
-
/** `
|
|
77
|
+
/** `docs`, `./docs` and `docs/` are one path, and the root itself is the empty one. */
|
|
105
78
|
function normal(path) {
|
|
106
79
|
const clean = posix.normalize(path).replace(/\/+$/, "");
|
|
107
80
|
return clean === "." ? "" : clean;
|
|
108
81
|
}
|
|
109
|
-
function hasExtension(name, extensions) {
|
|
110
|
-
return extensions.some((extension) => name.endsWith(`.${extension}`));
|
|
111
|
-
}
|
|
112
82
|
function lineOf(source, index) {
|
|
113
83
|
return source.slice(0, index).split("\n").length;
|
|
114
84
|
}
|
|
@@ -134,12 +104,12 @@ function decoded(target) {
|
|
|
134
104
|
}
|
|
135
105
|
}
|
|
136
106
|
function linksIn(tree, file) {
|
|
137
|
-
const source =
|
|
107
|
+
const source = readFileSync(join(tree.root, file), "utf8");
|
|
138
108
|
return [...source.matchAll(LINK)].flatMap((match) => {
|
|
139
109
|
const target = match[1] ?? "";
|
|
140
110
|
const path = decoded(target);
|
|
141
|
-
if (OUTSIDE.test(target)
|
|
142
|
-
const points = path === void 0 ? void 0 : relative(tree.root, resolve(tree.root, dirname(file), path));
|
|
111
|
+
if (OUTSIDE.test(target)) return [];
|
|
112
|
+
const points = path === void 0 ? void 0 : relative(tree.root, resolve(tree.root, dirname(file), path)).replaceAll(sep, "/");
|
|
143
113
|
return [{
|
|
144
114
|
from: file,
|
|
145
115
|
target,
|
|
@@ -155,94 +125,9 @@ function linkFaults(tree, links) {
|
|
|
155
125
|
return kindOf(tree, link.points) === "absent" ? [`${at}, which is not there`] : [];
|
|
156
126
|
});
|
|
157
127
|
}
|
|
158
|
-
/** A line that opens as a row does: a list item whose first word is a bold decision number. */
|
|
159
|
-
const ROW_START = /^- \*\*D(\d+)\*\*/gm;
|
|
160
|
-
/** A whole row: the number, one sentence or more, then the date and an optional source. */
|
|
161
|
-
const ROW = /^- \*\*D\d+\*\* \S.*[.?!] \(\d{4}-\d{2}-\d{2}(?:, \S.*)?\)$/;
|
|
162
|
-
/** A line that names a number in bold as a row does, whatever it opens with. */
|
|
163
|
-
const ROW_LIKE = /\*\*D\d+\*\*/;
|
|
164
|
-
const RETIRED_SECTION = /^## Retired[ \t]*$([\s\S]*?)(?=^## |(?![\s\S]))/m;
|
|
165
|
-
/** A mistyped number in the retired list would otherwise print a gap per number below it. */
|
|
166
|
-
const GAPS_SHOWN = 20;
|
|
167
|
-
const CITATION = /\bD(\d+)\b/g;
|
|
168
|
-
function numbersIn(text) {
|
|
169
|
-
return [...text.matchAll(CITATION)].map((match) => Number(match[1]));
|
|
170
|
-
}
|
|
171
|
-
function readRegister(tree, path) {
|
|
172
|
-
const text = kindOf(tree, path) === "file" ? read(tree, path) : "";
|
|
173
|
-
const rows = [...text.matchAll(ROW_START)].map((match) => ({
|
|
174
|
-
number: Number(match[1]),
|
|
175
|
-
line: lineOf(text, match.index),
|
|
176
|
-
text: text.slice(match.index).split("\n", 1)[0] ?? ""
|
|
177
|
-
}));
|
|
178
|
-
const section = RETIRED_SECTION.exec(text);
|
|
179
|
-
return {
|
|
180
|
-
path,
|
|
181
|
-
text,
|
|
182
|
-
rows,
|
|
183
|
-
live: new Set(rows.map((row) => row.number)),
|
|
184
|
-
retired: new Set(numbersIn(section?.[1] ?? "")),
|
|
185
|
-
hasRetiredSection: section !== null
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
function rowFaults(register) {
|
|
189
|
-
const seen = /* @__PURE__ */ new Set();
|
|
190
|
-
return register.rows.flatMap((row) => {
|
|
191
|
-
const at = `${register.path}:${row.line} D${row.number}`;
|
|
192
|
-
const found = [
|
|
193
|
-
...ROW.test(row.text) ? [] : [`${at} is not "- **Dn** Sentence. (date, source)"`],
|
|
194
|
-
...seen.has(row.number) ? [`${at} is a second row`] : [],
|
|
195
|
-
...register.retired.has(row.number) ? [`${at} is retired`] : []
|
|
196
|
-
];
|
|
197
|
-
seen.add(row.number);
|
|
198
|
-
return found;
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
function gapFaults(register) {
|
|
202
|
-
const highest = Math.max(0, ...register.live, ...register.retired);
|
|
203
|
-
const gaps = [];
|
|
204
|
-
let more = 0;
|
|
205
|
-
for (let number = 1; number <= highest; number++) {
|
|
206
|
-
if (register.live.has(number) || register.retired.has(number)) continue;
|
|
207
|
-
if (gaps.length < GAPS_SHOWN) gaps.push(number);
|
|
208
|
-
else more++;
|
|
209
|
-
}
|
|
210
|
-
return [...gaps.map((number) => `${register.path} D${number} is neither a row nor retired`), ...more === 0 ? [] : [`${register.path} ${more} more numbers up to D${highest} are neither a row nor retired`]];
|
|
211
|
-
}
|
|
212
|
-
function registerFaults(register) {
|
|
213
|
-
const rowLike = register.text.split("\n").flatMap((text, index) => ROW_LIKE.test(text) && !text.startsWith("- **D") ? [`${register.path}:${index + 1} names a row but is not one: "${text.slice(0, 40)}"`] : []);
|
|
214
|
-
return [
|
|
215
|
-
...rowFaults(register),
|
|
216
|
-
...rowLike,
|
|
217
|
-
...register.hasRetiredSection ? [] : [`${register.path} has no "## Retired" section`],
|
|
218
|
-
...gapFaults(register)
|
|
219
|
-
];
|
|
220
|
-
}
|
|
221
|
-
function citationsIn(tree, file) {
|
|
222
|
-
const source = read(tree, file);
|
|
223
|
-
return [...source.matchAll(CITATION)].map((match) => ({
|
|
224
|
-
where: `${file}:${lineOf(source, match.index)}`,
|
|
225
|
-
number: Number(match[1])
|
|
226
|
-
}));
|
|
227
|
-
}
|
|
228
|
-
/** A row may cite another row. The retired list and the prose around the rows are not held. */
|
|
229
|
-
function citationsInRows(register) {
|
|
230
|
-
return register.rows.flatMap((row) => numbersIn(row.text.slice(row.text.indexOf("**", 4) + 2)).map((number) => ({
|
|
231
|
-
where: `${register.path}:${row.line}`,
|
|
232
|
-
number
|
|
233
|
-
})));
|
|
234
|
-
}
|
|
235
|
-
function citationFaults(register, citations) {
|
|
236
|
-
return citations.flatMap(({ where, number }) => {
|
|
237
|
-
if (register.live.has(number)) return [];
|
|
238
|
-
return register.retired.has(number) ? [`${where} cites D${number}, which is retired`] : [`${where} cites D${number}, which the register has no row for`];
|
|
239
|
-
});
|
|
240
|
-
}
|
|
241
128
|
function isUnder(path, base) {
|
|
242
129
|
return base === "" || path === base || path.startsWith(`${base}/`);
|
|
243
130
|
}
|
|
244
|
-
/** `package-lock.json`, `pnpm-lock.yaml`: generated, and full of hashes that read like numbers. */
|
|
245
|
-
const LOCKFILE = /[-_.]lock\.(?:json|jsonc|ya?ml)$/;
|
|
246
131
|
function allNamed(by, paths, want) {
|
|
247
132
|
return paths.map((path) => ({
|
|
248
133
|
path: normal(path),
|
|
@@ -272,17 +157,12 @@ function usable(tree, named) {
|
|
|
272
157
|
const NO_FAULTS = {
|
|
273
158
|
scans: [],
|
|
274
159
|
links: [],
|
|
275
|
-
register: [],
|
|
276
|
-
citations: [],
|
|
277
160
|
research: []
|
|
278
161
|
};
|
|
279
162
|
function checkRepo(config) {
|
|
280
|
-
const leftOut = Object.keys(config.leftOut ?? {}).map(normal);
|
|
281
163
|
const tree = {
|
|
282
164
|
root: resolve(config.root),
|
|
283
165
|
skip: /* @__PURE__ */ new Set([...DEFAULT_SKIP, ...config.skip ?? []]),
|
|
284
|
-
leftOut: new Set(leftOut),
|
|
285
|
-
extensions: config.extensions ?? DEFAULT_EXTENSIONS,
|
|
286
166
|
broken: /* @__PURE__ */ new Set()
|
|
287
167
|
};
|
|
288
168
|
if (kindOf(tree, "") !== "directory") return {
|
|
@@ -290,13 +170,10 @@ function checkRepo(config) {
|
|
|
290
170
|
scans: [`${tree.root}, named by \`root\`, is not a directory`]
|
|
291
171
|
};
|
|
292
172
|
const docs = namedOr("docs", config.docs, "docs", "directory");
|
|
293
|
-
const registerAt = namedOr("register", config.register, child(docs.path, "decisions.md"), "file");
|
|
294
173
|
const research = namedOr("research", config.research, "_research", "directory");
|
|
295
|
-
const codeRoots = allNamed("named by `codeRoots`", config.codeRoots, "directory");
|
|
296
174
|
const documents = config.documents === void 0 ? allNamed("a default of `documents`", DEFAULT_DOCUMENTS, "file") : allNamed("named by `documents`", config.documents, "file");
|
|
297
175
|
const linked = allNamed("named by `linked`", config.linked ?? [], "file");
|
|
298
|
-
const
|
|
299
|
-
const entries = (researchKind === "directory" ? names(tree, research.path) : []).flatMap((name) => {
|
|
176
|
+
const entries = names(tree, research.path).flatMap((name) => {
|
|
300
177
|
const path = child(research.path, name);
|
|
301
178
|
const kind = kindOf(tree, path);
|
|
302
179
|
if (kind === "absent") tree.broken.add(path);
|
|
@@ -307,6 +184,7 @@ function checkRepo(config) {
|
|
|
307
184
|
}];
|
|
308
185
|
});
|
|
309
186
|
const frozen = (file) => isUnder(file, research.path);
|
|
187
|
+
const held = (link) => !frozen(link.from) || link.points === void 0 || frozen(link.points);
|
|
310
188
|
const living = [
|
|
311
189
|
...usable(tree, documents),
|
|
312
190
|
...walk(tree, docs.path).filter((file) => file.endsWith(".md") && !frozen(file)),
|
|
@@ -316,42 +194,28 @@ function checkRepo(config) {
|
|
|
316
194
|
const readme = `${entry.path}/README.md`;
|
|
317
195
|
return entry.folder && kindOf(tree, readme) === "file" ? [readme] : [];
|
|
318
196
|
})]);
|
|
319
|
-
const
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
const citing = [.../* @__PURE__ */ new Set([
|
|
325
|
-
...living,
|
|
326
|
-
...scanned.flatMap((scan) => scan.files),
|
|
327
|
-
...atRoot
|
|
328
|
-
])].filter((file) => file !== registerAt.path && !frozen(file) && !LOCKFILE.test(file));
|
|
329
|
-
const held = kindOf(tree, registerAt.path) === "file";
|
|
330
|
-
const register = readRegister(tree, registerAt.path);
|
|
331
|
-
const wrongKind = researchKind === "file" ? namedFault(tree, research) : [];
|
|
332
|
-
const leftOutFaults = leftOut.flatMap((path) => kindOf(tree, path) === "absent" ? [`${path}, named by \`leftOut\`, is not there`] : []);
|
|
197
|
+
const mayBeAbsent = /* @__PURE__ */ new Set([
|
|
198
|
+
...config.documents === void 0 ? documents : [],
|
|
199
|
+
...config.docs === void 0 ? [docs] : [],
|
|
200
|
+
research
|
|
201
|
+
]);
|
|
333
202
|
return {
|
|
334
|
-
scans: [
|
|
335
|
-
...
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
...[...tree.broken].toSorted().map((path) => `${path} is a symbolic link that leads nowhere`)
|
|
347
|
-
],
|
|
348
|
-
links: linkFaults(tree, [...linking].flatMap((file) => linksIn(tree, file))),
|
|
349
|
-
register: held ? registerFaults(register) : [],
|
|
350
|
-
citations: held ? citationFaults(register, [...citing.flatMap((file) => citationsIn(tree, file)), ...citationsInRows(register)]) : [],
|
|
203
|
+
scans: [...[
|
|
204
|
+
...documents,
|
|
205
|
+
docs,
|
|
206
|
+
...linked,
|
|
207
|
+
research
|
|
208
|
+
].flatMap((one) => {
|
|
209
|
+
if (kindOf(tree, one.path) !== "absent") return [one];
|
|
210
|
+
if (!isSymbolicLink(tree, one.path)) return mayBeAbsent.has(one) ? [] : [one];
|
|
211
|
+
tree.broken.add(one.path);
|
|
212
|
+
return [];
|
|
213
|
+
}).flatMap((one) => namedFault(tree, one)), ...[...tree.broken].toSorted().map((path) => `${path} is a symbolic link that leads nowhere`)],
|
|
214
|
+
links: linkFaults(tree, [...linking].flatMap((file) => linksIn(tree, file)).filter(held)),
|
|
351
215
|
research: researchFaults(tree, entries)
|
|
352
216
|
};
|
|
353
217
|
}
|
|
354
218
|
//#endregion
|
|
355
|
-
export { CHECKS, DEFAULT_DOCUMENTS,
|
|
219
|
+
export { CHECKS, DEFAULT_DOCUMENTS, DEFAULT_SKIP, HINTS, TITLES, checkRepo };
|
|
356
220
|
|
|
357
221
|
//# sourceMappingURL=repo.mjs.map
|
package/dist/repo.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"repo.mjs","names":[],"sources":["../src/repo.ts"],"sourcesContent":["/**\n * The promises a repository's prose can only state, held as faults instead.\n *\n * - a relative Markdown link points at something that is there;\n * - the decision register is one well-formed line per row, each number given once;\n * - a decision number cited anywhere is a live row in the register, never a retired one;\n * - a research entry is named by its date, and a dated folder opens with a README;\n * - every path the config names is there and holds something to check.\n *\n * Every path in a fault is relative to the root, so it reads as the path you would type to open\n * the offender. Nothing here imports a test runner: the consumer's test asserts on what\n * `checkRepo` returns. It never throws on a tree or a config it cannot read: that is a fault too.\n */\n\nimport { lstatSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { dirname, join, posix, relative, resolve } from \"node:path\";\n\nexport interface RepoConfig {\n /** The repository root, absolute. */\n root: string;\n /**\n * The directories scanned for decision numbers. Every path in this config is relative to the\n * root, and `lib`, `./lib` and `lib/` name the same one.\n */\n codeRoots: readonly string[];\n /**\n * The extensions scanned for decision numbers, in the code roots and at the root itself.\n * Replaces `DEFAULT_EXTENSIONS`.\n */\n extensions?: readonly string[];\n /** Directory and file names no walk enters, wherever they sit. Added to `DEFAULT_SKIP`. */\n skip?: readonly string[];\n /** Paths no walk enters, each with the reason it is left out. */\n leftOut?: { readonly [path: string]: string };\n /** The living documents at the root. Replaces `DEFAULT_DOCUMENTS`. */\n documents?: readonly string[];\n /** The directory whose every Markdown file is a living document. `docs` by default. */\n docs?: string;\n /** More documents held as the living ones are, for their links and their citations. */\n linked?: readonly string[];\n /** The decision register. `decisions.md` in the `docs` directory by default. */\n register?: string;\n /** The research directory. `_research` by default, and a repo may have none yet. */\n research?: string;\n}\n\nexport const CHECKS = [\"scans\", \"links\", \"register\", \"citations\", \"research\"] as const;\n\nexport type Check = (typeof CHECKS)[number];\n\n/** The faults of each check, empty where the check holds. */\nexport type RepoFaults = { readonly [check in Check]: readonly string[] };\n\n/** What each check promises, for the title of the consumer's test. */\nexport const TITLES = {\n scans: \"every path the config names is there and holds something to check\",\n links: \"every relative Markdown link resolves\",\n register: \"the register is one well-formed line per row, each number given once\",\n citations: \"every decision number cited anywhere is a live row in the register\",\n research: \"every research entry is named by its date, and every dated folder has a README\",\n} satisfies { readonly [check in Check]: string };\n\n/** What to do about a failing check, for the message of the consumer's assertion. */\nexport const HINTS = {\n scans:\n \"A check that reads nothing passes on nothing. Correct the path in the config, or remove it \" +\n \"with the thing it named.\",\n links: \"Fix the path, or delete the link with the thing it pointed at.\",\n register:\n \"A row is one line, its number given once and never after it was retired, and a number that \" +\n \"leaves the rows joins the retired list. Rewrite a rule in place under its own number.\",\n citations:\n \"A citation of a retired number, or of one never given, sends the reader nowhere. Cite the \" +\n 'row that carries the rule now. `git log -S \"D<n>\"` on the register shows what a retired ' +\n \"row said.\",\n research:\n \"The research sorts by name, so the name carries the date. Date an entry by the day it was \" +\n \"opened, and give a folder a README that says what is in it.\",\n} satisfies { readonly [check in Check]: string };\n\nexport const DEFAULT_DOCUMENTS: readonly string[] = [\"README.md\", \"AGENTS.md\", \"BACKLOG.md\"];\n\nexport const DEFAULT_EXTENSIONS: readonly string[] = [\n \"ts\",\n \"tsx\",\n \"mts\",\n \"cts\",\n \"js\",\n \"jsx\",\n \"mjs\",\n \"cjs\",\n \"md\",\n \"json\",\n \"jsonc\",\n \"yml\",\n \"yaml\",\n \"html\",\n];\n\n/** Vendored, generated and built trees. */\nexport const DEFAULT_SKIP: readonly string[] = [\"node_modules\", \"_generated\", \"dist\"];\n\ninterface Tree {\n root: string;\n skip: ReadonlySet<string>;\n leftOut: ReadonlySet<string>;\n extensions: readonly string[];\n /** Symbolic links a walk met that lead nowhere. */\n broken: Set<string>;\n}\n\ntype Kind = \"file\" | \"directory\" | \"absent\";\n\nfunction kindOf(tree: Tree, path: string): Kind {\n try {\n return statSync(join(tree.root, path)).isDirectory() ? \"directory\" : \"file\";\n } catch {\n return \"absent\";\n }\n}\n\nfunction isSymbolicLink(tree: Tree, path: string): boolean {\n try {\n return lstatSync(join(tree.root, path)).isSymbolicLink();\n } catch {\n return false;\n }\n}\n\n// A Windows checkout may hand back CRLF, and every pattern here ends its line on `\\n`.\nfunction read(tree: Tree, path: string): string {\n return readFileSync(join(tree.root, path), \"utf8\").replaceAll(\"\\r\\n\", \"\\n\");\n}\n\nfunction names(tree: Tree, path: string): string[] {\n try {\n return readdirSync(join(tree.root, path)).toSorted();\n } catch {\n return [];\n }\n}\n\nfunction child(base: string, name: string): string {\n return base === \"\" ? name : `${base}/${name}`;\n}\n\n// A linked directory is not entered: it may lead back to one of its own parents.\nfunction walk(tree: Tree, base: string): string[] {\n return names(tree, base).flatMap((name) => {\n const path = child(base, name);\n if (tree.skip.has(name) || tree.leftOut.has(path)) return [];\n const kind = kindOf(tree, path);\n if (kind === \"file\") return [path];\n if (kind === \"absent\") tree.broken.add(path);\n return kind === \"directory\" && !isSymbolicLink(tree, path) ? walk(tree, path) : [];\n });\n}\n\n/** `lib`, `./lib` and `lib/` are one path, and the root itself is the empty one. */\nfunction normal(path: string): string {\n const clean = posix.normalize(path).replace(/\\/+$/, \"\");\n return clean === \".\" ? \"\" : clean;\n}\n\nfunction hasExtension(name: string, extensions: readonly string[]): boolean {\n return extensions.some((extension) => name.endsWith(`.${extension}`));\n}\n\nfunction lineOf(source: string, index: number): number {\n return source.slice(0, index).split(\"\\n\").length;\n}\n\n/** A single note, `YYYYMMDD.HHMM.topic.md`. */\nconst DATED_NOTE = /^\\d{8}\\.\\d{4}\\.[a-z0-9-]+\\.md$/;\n\n/** Research of several files, `YYYYMMDD.topic/`. */\nconst DATED_FOLDER = /^\\d{8}\\.[a-z0-9-]+$/;\n\ninterface Entry {\n path: string;\n name: string;\n folder: boolean;\n}\n\nfunction researchFaults(tree: Tree, entries: readonly Entry[]): string[] {\n return entries.flatMap((entry) => {\n if (!(entry.folder ? DATED_FOLDER : DATED_NOTE).test(entry.name)) {\n return [`${entry.path} is not named YYYYMMDD.HHMM.topic.md or YYYYMMDD.topic/`];\n }\n return entry.folder && kindOf(tree, `${entry.path}/README.md`) !== \"file\"\n ? [`${entry.path}/ has no README.md`]\n : [];\n });\n}\n\n/** `](target)`, with an optional title. Bare `path.md` inside backticks is not a link. */\nconst LINK = /\\]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)/g;\n\n/** A scheme, a protocol-relative host or a bare anchor: the target is outside the tree. */\nconst OUTSIDE = /^(?:[a-z][a-z0-9+.-]*:|\\/\\/|#)/i;\n\ninterface Link {\n from: string;\n target: string;\n line: number;\n /** Where the target leads, relative to the root. Absent when its escapes do not decode. */\n points: string | undefined;\n}\n\nfunction decoded(target: string): string | undefined {\n try {\n return decodeURI(target.split(\"#\")[0] ?? \"\");\n } catch {\n return undefined;\n }\n}\n\nfunction linksIn(tree: Tree, file: string): Link[] {\n const source = read(tree, file);\n return [...source.matchAll(LINK)].flatMap((match) => {\n const target = match[1] ?? \"\";\n const path = decoded(target);\n if (OUTSIDE.test(target) || path === \"\") return [];\n const points =\n path === undefined ? undefined : relative(tree.root, resolve(tree.root, dirname(file), path));\n return [{ from: file, target, line: lineOf(source, match.index), points }];\n });\n}\n\nfunction linkFaults(tree: Tree, links: readonly Link[]): string[] {\n return links.flatMap((link) => {\n const at = `${link.from}:${link.line} points at ${link.target}`;\n if (link.points === undefined) return [`${at}, whose percent-escapes do not decode`];\n return kindOf(tree, link.points) === \"absent\" ? [`${at}, which is not there`] : [];\n });\n}\n\n/** A line that opens as a row does: a list item whose first word is a bold decision number. */\nconst ROW_START = /^- \\*\\*D(\\d+)\\*\\*/gm;\n\n/** A whole row: the number, one sentence or more, then the date and an optional source. */\nconst ROW = /^- \\*\\*D\\d+\\*\\* \\S.*[.?!] \\(\\d{4}-\\d{2}-\\d{2}(?:, \\S.*)?\\)$/;\n\n/** A line that names a number in bold as a row does, whatever it opens with. */\nconst ROW_LIKE = /\\*\\*D\\d+\\*\\*/;\n\nconst RETIRED_SECTION = /^## Retired[ \\t]*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))/m;\n\n/** A mistyped number in the retired list would otherwise print a gap per number below it. */\nconst GAPS_SHOWN = 20;\n\nconst CITATION = /\\bD(\\d+)\\b/g;\n\ninterface Row {\n number: number;\n line: number;\n text: string;\n}\n\ninterface Register {\n path: string;\n text: string;\n rows: readonly Row[];\n live: ReadonlySet<number>;\n /** Every number the `## Retired` section names, however its lines wrap. */\n retired: ReadonlySet<number>;\n hasRetiredSection: boolean;\n}\n\nfunction numbersIn(text: string): number[] {\n return [...text.matchAll(CITATION)].map((match) => Number(match[1]));\n}\n\nfunction readRegister(tree: Tree, path: string): Register {\n const text = kindOf(tree, path) === \"file\" ? read(tree, path) : \"\";\n const rows = [...text.matchAll(ROW_START)].map((match) => ({\n number: Number(match[1]),\n line: lineOf(text, match.index),\n text: text.slice(match.index).split(\"\\n\", 1)[0] ?? \"\",\n }));\n const section = RETIRED_SECTION.exec(text);\n return {\n path,\n text,\n rows,\n live: new Set(rows.map((row) => row.number)),\n retired: new Set(numbersIn(section?.[1] ?? \"\")),\n hasRetiredSection: section !== null,\n };\n}\n\nfunction rowFaults(register: Register): string[] {\n const seen = new Set<number>();\n return register.rows.flatMap((row) => {\n const at = `${register.path}:${row.line} D${row.number}`;\n const found = [\n ...(ROW.test(row.text) ? [] : [`${at} is not \"- **Dn** Sentence. (date, source)\"`]),\n ...(seen.has(row.number) ? [`${at} is a second row`] : []),\n ...(register.retired.has(row.number) ? [`${at} is retired`] : []),\n ];\n seen.add(row.number);\n return found;\n });\n}\n\nfunction gapFaults(register: Register): string[] {\n const highest = Math.max(0, ...register.live, ...register.retired);\n const gaps: number[] = [];\n let more = 0;\n for (let number = 1; number <= highest; number++) {\n if (register.live.has(number) || register.retired.has(number)) continue;\n if (gaps.length < GAPS_SHOWN) gaps.push(number);\n else more++;\n }\n return [\n ...gaps.map((number) => `${register.path} D${number} is neither a row nor retired`),\n ...(more === 0\n ? []\n : [`${register.path} ${more} more numbers up to D${highest} are neither a row nor retired`]),\n ];\n}\n\nfunction registerFaults(register: Register): string[] {\n const rowLike = register.text\n .split(\"\\n\")\n .flatMap((text, index) =>\n ROW_LIKE.test(text) && !text.startsWith(\"- **D\")\n ? [`${register.path}:${index + 1} names a row but is not one: \"${text.slice(0, 40)}\"`]\n : [],\n );\n return [\n ...rowFaults(register),\n ...rowLike,\n ...(register.hasRetiredSection ? [] : [`${register.path} has no \"## Retired\" section`]),\n ...gapFaults(register),\n ];\n}\n\ninterface Citation {\n where: string;\n number: number;\n}\n\nfunction citationsIn(tree: Tree, file: string): Citation[] {\n const source = read(tree, file);\n return [...source.matchAll(CITATION)].map((match) => ({\n where: `${file}:${lineOf(source, match.index)}`,\n number: Number(match[1]),\n }));\n}\n\n/** A row may cite another row. The retired list and the prose around the rows are not held. */\nfunction citationsInRows(register: Register): Citation[] {\n return register.rows.flatMap((row) =>\n numbersIn(row.text.slice(row.text.indexOf(\"**\", 4) + 2)).map((number) => ({\n where: `${register.path}:${row.line}`,\n number,\n })),\n );\n}\n\nfunction citationFaults(register: Register, citations: readonly Citation[]): string[] {\n return citations.flatMap(({ where, number }) => {\n if (register.live.has(number)) return [];\n return register.retired.has(number)\n ? [`${where} cites D${number}, which is retired`]\n : [`${where} cites D${number}, which the register has no row for`];\n });\n}\n\nfunction isUnder(path: string, base: string): boolean {\n return base === \"\" || path === base || path.startsWith(`${base}/`);\n}\n\n/** `package-lock.json`, `pnpm-lock.yaml`: generated, and full of hashes that read like numbers. */\nconst LOCKFILE = /[-_.]lock\\.(?:json|jsonc|ya?ml)$/;\n\ninterface Named {\n path: string;\n /** How the path came to be named, for the fault. */\n by: string;\n want: Kind;\n}\n\nfunction allNamed(by: string, paths: readonly string[], want: Kind): Named[] {\n return paths.map((path) => ({ path: normal(path), by, want }));\n}\n\nfunction namedOr(option: string, path: string | undefined, fallback: string, want: Kind): Named {\n return path === undefined\n ? { path: fallback, by: `the default of \\`${option}\\``, want }\n : { path: normal(path), by: `named by \\`${option}\\``, want };\n}\n\nfunction namedFault(tree: Tree, named: Named): string[] {\n const kind = kindOf(tree, named.path);\n if (kind === named.want) return [];\n const shown = named.path === \"\" ? \".\" : named.path;\n return [`${shown}, ${named.by}, is ${kind === \"absent\" ? \"not there\" : `not a ${named.want}`}`];\n}\n\nfunction usable(tree: Tree, named: readonly Named[]): string[] {\n return named.flatMap((one) => (kindOf(tree, one.path) === one.want ? [one.path] : []));\n}\n\nconst NO_FAULTS: RepoFaults = { scans: [], links: [], register: [], citations: [], research: [] };\n\nexport function checkRepo(config: RepoConfig): RepoFaults {\n const leftOut = Object.keys(config.leftOut ?? {}).map(normal);\n const tree: Tree = {\n root: resolve(config.root),\n skip: new Set([...DEFAULT_SKIP, ...(config.skip ?? [])]),\n leftOut: new Set(leftOut),\n extensions: config.extensions ?? DEFAULT_EXTENSIONS,\n broken: new Set(),\n };\n if (kindOf(tree, \"\") !== \"directory\") {\n return { ...NO_FAULTS, scans: [`${tree.root}, named by \\`root\\`, is not a directory`] };\n }\n\n const docs = namedOr(\"docs\", config.docs, \"docs\", \"directory\");\n const registerAt = namedOr(\"register\", config.register, child(docs.path, \"decisions.md\"), \"file\");\n const research = namedOr(\"research\", config.research, \"_research\", \"directory\");\n const codeRoots = allNamed(\"named by `codeRoots`\", config.codeRoots, \"directory\");\n const documents =\n config.documents === undefined\n ? allNamed(\"a default of `documents`\", DEFAULT_DOCUMENTS, \"file\")\n : allNamed(\"named by `documents`\", config.documents, \"file\");\n const linked = allNamed(\"named by `linked`\", config.linked ?? [], \"file\");\n\n // A repo has no research until its first, so an absent directory is not a fault.\n const researchKind = kindOf(tree, research.path);\n const entries = (researchKind === \"directory\" ? names(tree, research.path) : []).flatMap(\n (name) => {\n const path = child(research.path, name);\n const kind = kindOf(tree, path);\n if (kind === \"absent\") tree.broken.add(path);\n return kind === \"absent\" ? [] : [{ path, name, folder: kind === \"directory\" }];\n },\n );\n\n // Inside the research only a folder's README is held: an entry is frozen once it is on the\n // main branch, so its body names paths and numbers as they stood, and some have moved since.\n const frozen = (file: string): boolean => isUnder(file, research.path);\n\n const living = [\n ...usable(tree, documents),\n ...walk(tree, docs.path).filter((file) => file.endsWith(\".md\") && !frozen(file)),\n ...usable(tree, linked),\n ];\n const linking = new Set([\n ...living,\n ...entries.flatMap((entry) => {\n const readme = `${entry.path}/README.md`;\n return entry.folder && kindOf(tree, readme) === \"file\" ? [readme] : [];\n }),\n ]);\n\n const scanned = usable(tree, codeRoots).map((base) => ({\n base,\n files: walk(tree, base).filter((file) => hasExtension(file, tree.extensions)),\n }));\n const atRoot = names(tree, \"\").filter(\n (name) =>\n hasExtension(name, tree.extensions) &&\n !tree.skip.has(name) &&\n !tree.leftOut.has(name) &&\n kindOf(tree, name) === \"file\",\n );\n const citing = [\n ...new Set([...living, ...scanned.flatMap((scan) => scan.files), ...atRoot]),\n ].filter((file) => file !== registerAt.path && !frozen(file) && !LOCKFILE.test(file));\n\n // With no register to read, every citation would fail: `scans` says so once instead.\n const held = kindOf(tree, registerAt.path) === \"file\";\n const register = readRegister(tree, registerAt.path);\n const wrongKind = researchKind === \"file\" ? namedFault(tree, research) : [];\n const leftOutFaults = leftOut.flatMap((path) =>\n kindOf(tree, path) === \"absent\" ? [`${path}, named by \\`leftOut\\`, is not there`] : [],\n );\n\n return {\n scans: [\n ...[...codeRoots, ...documents, docs, registerAt, ...linked].flatMap((one) =>\n namedFault(tree, one),\n ),\n ...wrongKind,\n ...leftOutFaults,\n ...scanned.flatMap((scan) =>\n scan.files.length === 0\n ? [`${scan.base === \"\" ? \".\" : scan.base} holds no file to scan for decision numbers`]\n : [],\n ),\n ...(held && register.rows.length === 0 ? [`${registerAt.path} holds no row`] : []),\n ...[...tree.broken].toSorted().map((path) => `${path} is a symbolic link that leads nowhere`),\n ],\n links: linkFaults(\n tree,\n [...linking].flatMap((file) => linksIn(tree, file)),\n ),\n register: held ? registerFaults(register) : [],\n citations: held\n ? citationFaults(register, [\n ...citing.flatMap((file) => citationsIn(tree, file)),\n ...citationsInRows(register),\n ])\n : [],\n research: researchFaults(tree, entries),\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"repo.mjs","names":[],"sources":["../src/repo.ts"],"sourcesContent":["/**\n * The promises a repository's prose can only state, held as faults instead.\n *\n * - a relative Markdown link points at something that is there;\n * - a research entry is named by its date, and a dated folder opens with a README;\n * - every path the config names is there.\n *\n * Every path in a fault is relative to the root, so it reads as the path you would type to open\n * the offender. Nothing here imports a test runner: the consumer's test asserts on what\n * `checkRepo` returns. It never throws on a tree or a config it cannot read: that is a fault too.\n */\n\nimport { lstatSync, readdirSync, readFileSync, statSync } from \"node:fs\";\nimport { dirname, join, posix, relative, resolve, sep } from \"node:path\";\n\nexport interface RepoConfig {\n /**\n * The repository root, absolute. Every other path in this config is relative to it, and\n * `docs`, `./docs` and `docs/` name the same one.\n */\n root: string;\n /**\n * Directory and file names the walk of `docs` does not enter, wherever they sit. Added to\n * `DEFAULT_SKIP`.\n */\n skip?: readonly string[];\n /**\n * The living documents at the root. Replaces `DEFAULT_DOCUMENTS`, any of which a repo may\n * lack.\n */\n documents?: readonly string[];\n /**\n * The directory whose every Markdown file is a living document. `docs` by default, and a repo\n * that names none may have none.\n */\n docs?: string;\n /** More documents whose links are held as the living ones' are. */\n linked?: readonly string[];\n /** The research directory. `_research` by default, and a repo may have none yet. */\n research?: string;\n}\n\nexport const CHECKS = [\"scans\", \"links\", \"research\"] as const;\n\nexport type Check = (typeof CHECKS)[number];\n\n/** The faults of each check, empty where the check holds. */\nexport type RepoFaults = { readonly [check in Check]: readonly string[] };\n\n/** What each check promises, for the title of the consumer's test. */\nexport const TITLES = {\n scans: \"every path the config names is there, and every symbolic link leads somewhere\",\n links: \"every relative Markdown link resolves\",\n research: \"every research entry is named by its date, and every dated folder has a README\",\n} satisfies { readonly [check in Check]: string };\n\n/** What to do about a failing check, for the message of the consumer's assertion. */\nexport const HINTS = {\n scans:\n \"A check that reads nothing passes on nothing. Correct the path in the config, or remove it \" +\n \"with the thing it named.\",\n links: \"Fix the path, or delete the link with the thing it pointed at.\",\n research:\n \"The research sorts by name, so the name carries the date. Date an entry by the day it was \" +\n \"opened, and give a folder a README that says what is in it.\",\n} satisfies { readonly [check in Check]: string };\n\nexport const DEFAULT_DOCUMENTS: readonly string[] = [\"README.md\", \"AGENTS.md\", \"BACKLOG.md\"];\n\n/** Vendored, generated and built trees. */\nexport const DEFAULT_SKIP: readonly string[] = [\"node_modules\", \"_generated\", \"dist\"];\n\ninterface Tree {\n root: string;\n skip: ReadonlySet<string>;\n /** Symbolic links a walk met that lead nowhere. */\n broken: Set<string>;\n}\n\ntype Kind = \"file\" | \"directory\" | \"absent\";\n\nfunction kindOf(tree: Tree, path: string): Kind {\n try {\n return statSync(join(tree.root, path)).isDirectory() ? \"directory\" : \"file\";\n } catch {\n return \"absent\";\n }\n}\n\nfunction isSymbolicLink(tree: Tree, path: string): boolean {\n try {\n return lstatSync(join(tree.root, path)).isSymbolicLink();\n } catch {\n return false;\n }\n}\n\nfunction names(tree: Tree, path: string): string[] {\n try {\n return readdirSync(join(tree.root, path)).toSorted();\n } catch {\n return [];\n }\n}\n\nfunction child(base: string, name: string): string {\n return base === \"\" ? name : `${base}/${name}`;\n}\n\n// A linked directory is not entered: it may lead back to one of its own parents.\nfunction walk(tree: Tree, base: string): string[] {\n return names(tree, base).flatMap((name) => {\n const path = child(base, name);\n if (tree.skip.has(name)) return [];\n const kind = kindOf(tree, path);\n if (kind === \"file\") return [path];\n if (kind === \"absent\") tree.broken.add(path);\n return kind === \"directory\" && !isSymbolicLink(tree, path) ? walk(tree, path) : [];\n });\n}\n\n/** `docs`, `./docs` and `docs/` are one path, and the root itself is the empty one. */\nfunction normal(path: string): string {\n const clean = posix.normalize(path).replace(/\\/+$/, \"\");\n return clean === \".\" ? \"\" : clean;\n}\n\nfunction lineOf(source: string, index: number): number {\n return source.slice(0, index).split(\"\\n\").length;\n}\n\n/** A single note, `YYYYMMDD.HHMM.topic.md`. */\nconst DATED_NOTE = /^\\d{8}\\.\\d{4}\\.[a-z0-9-]+\\.md$/;\n\n/** Research of several files, `YYYYMMDD.topic/`. */\nconst DATED_FOLDER = /^\\d{8}\\.[a-z0-9-]+$/;\n\ninterface Entry {\n path: string;\n name: string;\n folder: boolean;\n}\n\nfunction researchFaults(tree: Tree, entries: readonly Entry[]): string[] {\n return entries.flatMap((entry) => {\n if (!(entry.folder ? DATED_FOLDER : DATED_NOTE).test(entry.name)) {\n return [`${entry.path} is not named YYYYMMDD.HHMM.topic.md or YYYYMMDD.topic/`];\n }\n return entry.folder && kindOf(tree, `${entry.path}/README.md`) !== \"file\"\n ? [`${entry.path}/ has no README.md`]\n : [];\n });\n}\n\n/** `](target)`, with an optional title. Bare `path.md` inside backticks is not a link. */\nconst LINK = /\\]\\(([^)\\s]+)(?:\\s+\"[^\"]*\")?\\)/g;\n\n/** A scheme, a protocol-relative host or a bare anchor: the target is outside the tree. */\nconst OUTSIDE = /^(?:[a-z][a-z0-9+.-]*:|\\/\\/|#)/i;\n\ninterface Link {\n from: string;\n target: string;\n line: number;\n /** Where the target leads, relative to the root. Absent when its escapes do not decode. */\n points: string | undefined;\n}\n\nfunction decoded(target: string): string | undefined {\n try {\n return decodeURI(target.split(\"#\")[0] ?? \"\");\n } catch {\n return undefined;\n }\n}\n\nfunction linksIn(tree: Tree, file: string): Link[] {\n const source = readFileSync(join(tree.root, file), \"utf8\");\n return [...source.matchAll(LINK)].flatMap((match) => {\n const target = match[1] ?? \"\";\n const path = decoded(target);\n if (OUTSIDE.test(target)) return [];\n // `relative` answers with `\\` on Windows, and every path here has `/` between its names.\n const points =\n path === undefined\n ? undefined\n : relative(tree.root, resolve(tree.root, dirname(file), path)).replaceAll(sep, \"/\");\n return [{ from: file, target, line: lineOf(source, match.index), points }];\n });\n}\n\nfunction linkFaults(tree: Tree, links: readonly Link[]): string[] {\n return links.flatMap((link) => {\n const at = `${link.from}:${link.line} points at ${link.target}`;\n if (link.points === undefined) return [`${at}, whose percent-escapes do not decode`];\n return kindOf(tree, link.points) === \"absent\" ? [`${at}, which is not there`] : [];\n });\n}\n\nfunction isUnder(path: string, base: string): boolean {\n return base === \"\" || path === base || path.startsWith(`${base}/`);\n}\n\ninterface Named {\n path: string;\n /** How the path came to be named, for the fault. */\n by: string;\n want: Kind;\n}\n\nfunction allNamed(by: string, paths: readonly string[], want: Kind): Named[] {\n return paths.map((path) => ({ path: normal(path), by, want }));\n}\n\nfunction namedOr(option: string, path: string | undefined, fallback: string, want: Kind): Named {\n return path === undefined\n ? { path: fallback, by: `the default of \\`${option}\\``, want }\n : { path: normal(path), by: `named by \\`${option}\\``, want };\n}\n\nfunction namedFault(tree: Tree, named: Named): string[] {\n const kind = kindOf(tree, named.path);\n if (kind === named.want) return [];\n const shown = named.path === \"\" ? \".\" : named.path;\n return [`${shown}, ${named.by}, is ${kind === \"absent\" ? \"not there\" : `not a ${named.want}`}`];\n}\n\nfunction usable(tree: Tree, named: readonly Named[]): string[] {\n return named.flatMap((one) => (kindOf(tree, one.path) === one.want ? [one.path] : []));\n}\n\nconst NO_FAULTS: RepoFaults = { scans: [], links: [], research: [] };\n\nexport function checkRepo(config: RepoConfig): RepoFaults {\n const tree: Tree = {\n root: resolve(config.root),\n skip: new Set([...DEFAULT_SKIP, ...(config.skip ?? [])]),\n broken: new Set(),\n };\n if (kindOf(tree, \"\") !== \"directory\") {\n return { ...NO_FAULTS, scans: [`${tree.root}, named by \\`root\\`, is not a directory`] };\n }\n\n const docs = namedOr(\"docs\", config.docs, \"docs\", \"directory\");\n const research = namedOr(\"research\", config.research, \"_research\", \"directory\");\n const documents =\n config.documents === undefined\n ? allNamed(\"a default of `documents`\", DEFAULT_DOCUMENTS, \"file\")\n : allNamed(\"named by `documents`\", config.documents, \"file\");\n const linked = allNamed(\"named by `linked`\", config.linked ?? [], \"file\");\n\n const entries = names(tree, research.path).flatMap((name) => {\n const path = child(research.path, name);\n const kind = kindOf(tree, path);\n if (kind === \"absent\") tree.broken.add(path);\n return kind === \"absent\" ? [] : [{ path, name, folder: kind === \"directory\" }];\n });\n\n // Inside the research only a folder's README is held, and only for the links that stay inside\n // it: an entry is frozen once it is on the main branch, and the living tree moves on.\n const frozen = (file: string): boolean => isUnder(file, research.path);\n const held = (link: Link): boolean =>\n !frozen(link.from) || link.points === undefined || frozen(link.points);\n\n const living = [\n ...usable(tree, documents),\n ...walk(tree, docs.path).filter((file) => file.endsWith(\".md\") && !frozen(file)),\n ...usable(tree, linked),\n ];\n const linking = new Set([\n ...living,\n ...entries.flatMap((entry) => {\n const readme = `${entry.path}/README.md`;\n return entry.folder && kindOf(tree, readme) === \"file\" ? [readme] : [];\n }),\n ]);\n\n // A default names a place a repo may not have, and the research one it may not have yet. A\n // path the config names has to be there.\n const mayBeAbsent = new Set([\n ...(config.documents === undefined ? documents : []),\n ...(config.docs === undefined ? [docs] : []),\n research,\n ]);\n // Absent means nothing is at the path, and a link that leads nowhere is something.\n const expected = [...documents, docs, ...linked, research].flatMap((one) => {\n if (kindOf(tree, one.path) !== \"absent\") return [one];\n if (!isSymbolicLink(tree, one.path)) return mayBeAbsent.has(one) ? [] : [one];\n tree.broken.add(one.path);\n return [];\n });\n\n return {\n scans: [\n ...expected.flatMap((one) => namedFault(tree, one)),\n ...[...tree.broken].toSorted().map((path) => `${path} is a symbolic link that leads nowhere`),\n ],\n links: linkFaults(tree, [...linking].flatMap((file) => linksIn(tree, file)).filter(held)),\n research: researchFaults(tree, entries),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;AA0CA,MAAa,SAAS;CAAC;CAAS;CAAS;AAAU;;AAQnD,MAAa,SAAS;CACpB,OAAO;CACP,OAAO;CACP,UAAU;AACZ;;AAGA,MAAa,QAAQ;CACnB,OACE;CAEF,OAAO;CACP,UACE;AAEJ;AAEA,MAAa,oBAAuC;CAAC;CAAa;CAAa;AAAY;;AAG3F,MAAa,eAAkC;CAAC;CAAgB;CAAc;AAAM;AAWpF,SAAS,OAAO,MAAY,MAAoB;CAC9C,IAAI;EACF,OAAO,SAAS,KAAK,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,YAAY,IAAI,cAAc;CACvE,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,eAAe,MAAY,MAAuB;CACzD,IAAI;EACF,OAAO,UAAU,KAAK,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,eAAe;CACzD,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,MAAM,MAAY,MAAwB;CACjD,IAAI;EACF,OAAO,YAAY,KAAK,KAAK,MAAM,IAAI,CAAC,CAAC,CAAC,SAAS;CACrD,QAAQ;EACN,OAAO,CAAC;CACV;AACF;AAEA,SAAS,MAAM,MAAc,MAAsB;CACjD,OAAO,SAAS,KAAK,OAAO,GAAG,KAAK,GAAG;AACzC;AAGA,SAAS,KAAK,MAAY,MAAwB;CAChD,OAAO,MAAM,MAAM,IAAI,CAAC,CAAC,SAAS,SAAS;EACzC,MAAM,OAAO,MAAM,MAAM,IAAI;EAC7B,IAAI,KAAK,KAAK,IAAI,IAAI,GAAG,OAAO,CAAC;EACjC,MAAM,OAAO,OAAO,MAAM,IAAI;EAC9B,IAAI,SAAS,QAAQ,OAAO,CAAC,IAAI;EACjC,IAAI,SAAS,UAAU,KAAK,OAAO,IAAI,IAAI;EAC3C,OAAO,SAAS,eAAe,CAAC,eAAe,MAAM,IAAI,IAAI,KAAK,MAAM,IAAI,IAAI,CAAC;CACnF,CAAC;AACH;;AAGA,SAAS,OAAO,MAAsB;CACpC,MAAM,QAAQ,MAAM,UAAU,IAAI,CAAC,CAAC,QAAQ,QAAQ,EAAE;CACtD,OAAO,UAAU,MAAM,KAAK;AAC9B;AAEA,SAAS,OAAO,QAAgB,OAAuB;CACrD,OAAO,OAAO,MAAM,GAAG,KAAK,CAAC,CAAC,MAAM,IAAI,CAAC,CAAC;AAC5C;;AAGA,MAAM,aAAa;;AAGnB,MAAM,eAAe;AAQrB,SAAS,eAAe,MAAY,SAAqC;CACvE,OAAO,QAAQ,SAAS,UAAU;EAChC,IAAI,EAAE,MAAM,SAAS,eAAe,WAAA,CAAY,KAAK,MAAM,IAAI,GAC7D,OAAO,CAAC,GAAG,MAAM,KAAK,wDAAwD;EAEhF,OAAO,MAAM,UAAU,OAAO,MAAM,GAAG,MAAM,KAAK,WAAW,MAAM,SAC/D,CAAC,GAAG,MAAM,KAAK,mBAAmB,IAClC,CAAC;CACP,CAAC;AACH;;AAGA,MAAM,OAAO;;AAGb,MAAM,UAAU;AAUhB,SAAS,QAAQ,QAAoC;CACnD,IAAI;EACF,OAAO,UAAU,OAAO,MAAM,GAAG,CAAC,CAAC,MAAM,EAAE;CAC7C,QAAQ;EACN;CACF;AACF;AAEA,SAAS,QAAQ,MAAY,MAAsB;CACjD,MAAM,SAAS,aAAa,KAAK,KAAK,MAAM,IAAI,GAAG,MAAM;CACzD,OAAO,CAAC,GAAG,OAAO,SAAS,IAAI,CAAC,CAAC,CAAC,SAAS,UAAU;EACnD,MAAM,SAAS,MAAM,MAAM;EAC3B,MAAM,OAAO,QAAQ,MAAM;EAC3B,IAAI,QAAQ,KAAK,MAAM,GAAG,OAAO,CAAC;EAElC,MAAM,SACJ,SAAS,KAAA,IACL,KAAA,IACA,SAAS,KAAK,MAAM,QAAQ,KAAK,MAAM,QAAQ,IAAI,GAAG,IAAI,CAAC,CAAC,CAAC,WAAW,KAAK,GAAG;EACtF,OAAO,CAAC;GAAE,MAAM;GAAM;GAAQ,MAAM,OAAO,QAAQ,MAAM,KAAK;GAAG;EAAO,CAAC;CAC3E,CAAC;AACH;AAEA,SAAS,WAAW,MAAY,OAAkC;CAChE,OAAO,MAAM,SAAS,SAAS;EAC7B,MAAM,KAAK,GAAG,KAAK,KAAK,GAAG,KAAK,KAAK,aAAa,KAAK;EACvD,IAAI,KAAK,WAAW,KAAA,GAAW,OAAO,CAAC,GAAG,GAAG,sCAAsC;EACnF,OAAO,OAAO,MAAM,KAAK,MAAM,MAAM,WAAW,CAAC,GAAG,GAAG,qBAAqB,IAAI,CAAC;CACnF,CAAC;AACH;AAEA,SAAS,QAAQ,MAAc,MAAuB;CACpD,OAAO,SAAS,MAAM,SAAS,QAAQ,KAAK,WAAW,GAAG,KAAK,EAAE;AACnE;AASA,SAAS,SAAS,IAAY,OAA0B,MAAqB;CAC3E,OAAO,MAAM,KAAK,UAAU;EAAE,MAAM,OAAO,IAAI;EAAG;EAAI;CAAK,EAAE;AAC/D;AAEA,SAAS,QAAQ,QAAgB,MAA0B,UAAkB,MAAmB;CAC9F,OAAO,SAAS,KAAA,IACZ;EAAE,MAAM;EAAU,IAAI,oBAAoB,OAAO;EAAK;CAAK,IAC3D;EAAE,MAAM,OAAO,IAAI;EAAG,IAAI,cAAc,OAAO;EAAK;CAAK;AAC/D;AAEA,SAAS,WAAW,MAAY,OAAwB;CACtD,MAAM,OAAO,OAAO,MAAM,MAAM,IAAI;CACpC,IAAI,SAAS,MAAM,MAAM,OAAO,CAAC;CAEjC,OAAO,CAAC,GADM,MAAM,SAAS,KAAK,MAAM,MAAM,KAC7B,IAAI,MAAM,GAAG,OAAO,SAAS,WAAW,cAAc,SAAS,MAAM,QAAQ;AAChG;AAEA,SAAS,OAAO,MAAY,OAAmC;CAC7D,OAAO,MAAM,SAAS,QAAS,OAAO,MAAM,IAAI,IAAI,MAAM,IAAI,OAAO,CAAC,IAAI,IAAI,IAAI,CAAC,CAAE;AACvF;AAEA,MAAM,YAAwB;CAAE,OAAO,CAAC;CAAG,OAAO,CAAC;CAAG,UAAU,CAAC;AAAE;AAEnE,SAAgB,UAAU,QAAgC;CACxD,MAAM,OAAa;EACjB,MAAM,QAAQ,OAAO,IAAI;EACzB,sBAAM,IAAI,IAAI,CAAC,GAAG,cAAc,GAAI,OAAO,QAAQ,CAAC,CAAE,CAAC;EACvD,wBAAQ,IAAI,IAAI;CAClB;CACA,IAAI,OAAO,MAAM,EAAE,MAAM,aACvB,OAAO;EAAE,GAAG;EAAW,OAAO,CAAC,GAAG,KAAK,KAAK,wCAAwC;CAAE;CAGxF,MAAM,OAAO,QAAQ,QAAQ,OAAO,MAAM,QAAQ,WAAW;CAC7D,MAAM,WAAW,QAAQ,YAAY,OAAO,UAAU,aAAa,WAAW;CAC9E,MAAM,YACJ,OAAO,cAAc,KAAA,IACjB,SAAS,4BAA4B,mBAAmB,MAAM,IAC9D,SAAS,wBAAwB,OAAO,WAAW,MAAM;CAC/D,MAAM,SAAS,SAAS,qBAAqB,OAAO,UAAU,CAAC,GAAG,MAAM;CAExE,MAAM,UAAU,MAAM,MAAM,SAAS,IAAI,CAAC,CAAC,SAAS,SAAS;EAC3D,MAAM,OAAO,MAAM,SAAS,MAAM,IAAI;EACtC,MAAM,OAAO,OAAO,MAAM,IAAI;EAC9B,IAAI,SAAS,UAAU,KAAK,OAAO,IAAI,IAAI;EAC3C,OAAO,SAAS,WAAW,CAAC,IAAI,CAAC;GAAE;GAAM;GAAM,QAAQ,SAAS;EAAY,CAAC;CAC/E,CAAC;CAID,MAAM,UAAU,SAA0B,QAAQ,MAAM,SAAS,IAAI;CACrE,MAAM,QAAQ,SACZ,CAAC,OAAO,KAAK,IAAI,KAAK,KAAK,WAAW,KAAA,KAAa,OAAO,KAAK,MAAM;CAEvE,MAAM,SAAS;EACb,GAAG,OAAO,MAAM,SAAS;EACzB,GAAG,KAAK,MAAM,KAAK,IAAI,CAAC,CAAC,QAAQ,SAAS,KAAK,SAAS,KAAK,KAAK,CAAC,OAAO,IAAI,CAAC;EAC/E,GAAG,OAAO,MAAM,MAAM;CACxB;CACA,MAAM,0BAAU,IAAI,IAAI,CACtB,GAAG,QACH,GAAG,QAAQ,SAAS,UAAU;EAC5B,MAAM,SAAS,GAAG,MAAM,KAAK;EAC7B,OAAO,MAAM,UAAU,OAAO,MAAM,MAAM,MAAM,SAAS,CAAC,MAAM,IAAI,CAAC;CACvE,CAAC,CACH,CAAC;CAID,MAAM,8BAAc,IAAI,IAAI;EAC1B,GAAI,OAAO,cAAc,KAAA,IAAY,YAAY,CAAC;EAClD,GAAI,OAAO,SAAS,KAAA,IAAY,CAAC,IAAI,IAAI,CAAC;EAC1C;CACF,CAAC;CASD,OAAO;EACL,OAAO,CACL,GATa;GAAC,GAAG;GAAW;GAAM,GAAG;GAAQ;EAAQ,CAAC,CAAC,SAAS,QAAQ;GAC1E,IAAI,OAAO,MAAM,IAAI,IAAI,MAAM,UAAU,OAAO,CAAC,GAAG;GACpD,IAAI,CAAC,eAAe,MAAM,IAAI,IAAI,GAAG,OAAO,YAAY,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC,GAAG;GAC5E,KAAK,OAAO,IAAI,IAAI,IAAI;GACxB,OAAO,CAAC;EACV,CAIc,CAAC,CAAC,SAAS,QAAQ,WAAW,MAAM,GAAG,CAAC,GAClD,GAAG,CAAC,GAAG,KAAK,MAAM,CAAC,CAAC,SAAS,CAAC,CAAC,KAAK,SAAS,GAAG,KAAK,uCAAuC,CAC9F;EACA,OAAO,WAAW,MAAM,CAAC,GAAG,OAAO,CAAC,CAAC,SAAS,SAAS,QAAQ,MAAM,IAAI,CAAC,CAAC,CAAC,OAAO,IAAI,CAAC;EACxF,UAAU,eAAe,MAAM,OAAO;CACxC;AACF"}
|