@dogsbay/commands 0.2.0-beta.101
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 +137 -0
- package/dist/commands/content.d.ts +78 -0
- package/dist/commands/content.d.ts.map +1 -0
- package/dist/commands/content.js +634 -0
- package/dist/commands/content.js.map +1 -0
- package/dist/commands/examples.d.ts +49 -0
- package/dist/commands/examples.d.ts.map +1 -0
- package/dist/commands/examples.js +173 -0
- package/dist/commands/examples.js.map +1 -0
- package/dist/commands/keys.d.ts +14 -0
- package/dist/commands/keys.d.ts.map +1 -0
- package/dist/commands/keys.js +87 -0
- package/dist/commands/keys.js.map +1 -0
- package/dist/commands/link-retarget.d.ts +18 -0
- package/dist/commands/link-retarget.d.ts.map +1 -0
- package/dist/commands/link-retarget.js +104 -0
- package/dist/commands/link-retarget.js.map +1 -0
- package/dist/commands/refactor-files.d.ts +27 -0
- package/dist/commands/refactor-files.d.ts.map +1 -0
- package/dist/commands/refactor-files.js +264 -0
- package/dist/commands/refactor-files.js.map +1 -0
- package/dist/commands/refactor-keys.d.ts +21 -0
- package/dist/commands/refactor-keys.d.ts.map +1 -0
- package/dist/commands/refactor-keys.js +224 -0
- package/dist/commands/refactor-keys.js.map +1 -0
- package/dist/commands/refactor-structure.d.ts +19 -0
- package/dist/commands/refactor-structure.d.ts.map +1 -0
- package/dist/commands/refactor-structure.js +314 -0
- package/dist/commands/refactor-structure.js.map +1 -0
- package/dist/commands/reindex.d.ts +10 -0
- package/dist/commands/reindex.d.ts.map +1 -0
- package/dist/commands/reindex.js +23 -0
- package/dist/commands/reindex.js.map +1 -0
- package/dist/commands/topicmap-edit.d.ts +65 -0
- package/dist/commands/topicmap-edit.d.ts.map +1 -0
- package/dist/commands/topicmap-edit.js +194 -0
- package/dist/commands/topicmap-edit.js.map +1 -0
- package/dist/commands/topicmap.d.ts +13 -0
- package/dist/commands/topicmap.d.ts.map +1 -0
- package/dist/commands/topicmap.js +69 -0
- package/dist/commands/topicmap.js.map +1 -0
- package/dist/commands/validate.d.ts +13 -0
- package/dist/commands/validate.d.ts.map +1 -0
- package/dist/commands/validate.js +753 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/commands/where-used.d.ts +16 -0
- package/dist/commands/where-used.d.ts.map +1 -0
- package/dist/commands/where-used.js +83 -0
- package/dist/commands/where-used.js.map +1 -0
- package/dist/edits.d.ts +54 -0
- package/dist/edits.d.ts.map +1 -0
- package/dist/edits.js +158 -0
- package/dist/edits.js.map +1 -0
- package/dist/engine.d.ts +103 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +115 -0
- package/dist/engine.js.map +1 -0
- package/dist/flags.d.ts +31 -0
- package/dist/flags.d.ts.map +1 -0
- package/dist/flags.js +73 -0
- package/dist/flags.js.map +1 -0
- package/dist/frontmatter-schema.d.ts +97 -0
- package/dist/frontmatter-schema.d.ts.map +1 -0
- package/dist/frontmatter-schema.js +240 -0
- package/dist/frontmatter-schema.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +70 -0
- package/dist/index.js.map +1 -0
- package/dist/indexes.d.ts +175 -0
- package/dist/indexes.d.ts.map +1 -0
- package/dist/indexes.js +536 -0
- package/dist/indexes.js.map +1 -0
- package/dist/json-schema.d.ts +10 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +50 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/storage.d.ts +68 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +193 -0
- package/dist/storage.js.map +1 -0
- package/dist/workspace.d.ts +30 -0
- package/dist/workspace.d.ts.map +1 -0
- package/dist/workspace.js +63 -0
- package/dist/workspace.js.map +1 -0
- package/package.json +47 -0
|
@@ -0,0 +1,753 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `validate` — the workspace health gate.
|
|
3
|
+
*
|
|
4
|
+
* Every check is an index lookup: broken relative links, missing link
|
|
5
|
+
* anchors, missing include targets, undefined keys, conflicting key
|
|
6
|
+
* definitions, malformed keys files, topic-map parse errors, and
|
|
7
|
+
* topic-map entries pointing at missing files. Docs that fail to parse
|
|
8
|
+
* are surfaced too — a doc the indexer can't read can hide any of the
|
|
9
|
+
* above.
|
|
10
|
+
*/
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
import { dirname as dirnameOf, joinPath } from "@dogsbay/format-topicmap";
|
|
13
|
+
import { defineCommand } from "../engine.js";
|
|
14
|
+
import { anchorAliases, anchorAliasTargets } from "../indexes.js";
|
|
15
|
+
import { WELL_KNOWN_FIELDS, matchesType, isSafeAutoFix, nearMisses, contentRelativePath, requiredFieldsFor, typeOf, AGGREGATOR_OWNED, } from "../frontmatter-schema.js";
|
|
16
|
+
import { dirname as dirOf, relative as relPath } from "node:path/posix";
|
|
17
|
+
import { load as loadYaml } from "js-yaml";
|
|
18
|
+
const input = z.object({}).strict();
|
|
19
|
+
async function siteLayout(storage) {
|
|
20
|
+
const out = { roots: [""], prefixes: [], checkable: true };
|
|
21
|
+
for (const name of ["dogsbay.config.yml", "dogsbay.config.yaml", "dogsbay.config.json"]) {
|
|
22
|
+
if (!(await storage.exists(name)))
|
|
23
|
+
continue;
|
|
24
|
+
try {
|
|
25
|
+
const text = await storage.read(name);
|
|
26
|
+
const cfg = (name.endsWith(".json") ? JSON.parse(text) : loadYaml(text));
|
|
27
|
+
if (!cfg || typeof cfg !== "object")
|
|
28
|
+
return out;
|
|
29
|
+
const site = (cfg.site ?? {});
|
|
30
|
+
if (typeof site.basePath === "string" && site.basePath.replace(/\//g, ""))
|
|
31
|
+
out.prefixes.push(site.basePath.replace(/^\/+|\/+$/g, ""));
|
|
32
|
+
if (typeof site.url === "string") {
|
|
33
|
+
try {
|
|
34
|
+
const u = new URL(site.url);
|
|
35
|
+
const pth = u.pathname.replace(/^\/+|\/+$/g, "");
|
|
36
|
+
if (pth)
|
|
37
|
+
out.prefixes.push(pth);
|
|
38
|
+
}
|
|
39
|
+
catch { /* not a URL */ }
|
|
40
|
+
}
|
|
41
|
+
const content = (cfg.content ?? {});
|
|
42
|
+
const sources = Array.isArray(content.sources) ? content.sources : [];
|
|
43
|
+
const roots = [];
|
|
44
|
+
for (const src of sources) {
|
|
45
|
+
if (!src || typeof src !== "object")
|
|
46
|
+
continue;
|
|
47
|
+
const rec = src;
|
|
48
|
+
if (typeof rec.git === "string" || typeof rec.repo === "string") {
|
|
49
|
+
out.checkable = false;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (typeof rec.namespace === "string" || typeof rec.version === "string" || typeof rec.locale === "string")
|
|
53
|
+
out.checkable = false;
|
|
54
|
+
if (typeof rec.path === "string") {
|
|
55
|
+
const root = rec.path.replace(/^\.\/+/, "").replace(/^\/+|\/+$/g, "");
|
|
56
|
+
if (root.startsWith("..")) {
|
|
57
|
+
out.checkable = false;
|
|
58
|
+
continue;
|
|
59
|
+
} // above the workspace: unknowable
|
|
60
|
+
roots.push(root);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (typeof content.path === "string")
|
|
64
|
+
roots.push(content.path.replace(/^\.\/+/, "").replace(/^\/+|\/+$/g, ""));
|
|
65
|
+
if (roots.length)
|
|
66
|
+
out.roots = [...new Set(roots)];
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
/* unreadable config: resolve against the workspace root */
|
|
70
|
+
}
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** js-yaml's own complaint about the frontmatter block, with the line it points at. */
|
|
76
|
+
function yamlErrorOf(source) {
|
|
77
|
+
const lines = source.split("\n");
|
|
78
|
+
if (lines[0]?.trim() !== "---")
|
|
79
|
+
return undefined;
|
|
80
|
+
let end = -1;
|
|
81
|
+
for (let i = 1; i < lines.length; i++)
|
|
82
|
+
if (/^(---|\.\.\.)[ \t]*\r?$/.test(lines[i])) {
|
|
83
|
+
end = i;
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
if (end === -1)
|
|
87
|
+
return undefined;
|
|
88
|
+
try {
|
|
89
|
+
loadYaml(lines.slice(1, end).join("\n"));
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
const e = err;
|
|
94
|
+
const message = String(e.message ?? err).split("\n")[0];
|
|
95
|
+
return { message, line: (e.mark?.line ?? 0) + 2 }; // +1 for the opener, +1 for 1-based
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The frontmatter policy, read from the same config file as the site layout:
|
|
100
|
+
* declared `taxonomies:` (their `values`/`hierarchical` ARE the check) plus
|
|
101
|
+
* the opt-in `content.frontmatter` strictness. Absent config → the
|
|
102
|
+
* well-known-field type checks only.
|
|
103
|
+
*/
|
|
104
|
+
async function frontmatterPolicy(storage, layout) {
|
|
105
|
+
const out = { taxonomies: {}, propSchema: {}, required: [], unknown: "ignore", roots: layout.roots };
|
|
106
|
+
for (const name of ["dogsbay.config.yml", "dogsbay.config.yaml", "dogsbay.config.json"]) {
|
|
107
|
+
if (!(await storage.exists(name)))
|
|
108
|
+
continue;
|
|
109
|
+
try {
|
|
110
|
+
const text = await storage.read(name);
|
|
111
|
+
const cfg = (name.endsWith(".json") ? JSON.parse(text) : loadYaml(text));
|
|
112
|
+
if (!cfg || typeof cfg !== "object")
|
|
113
|
+
return out;
|
|
114
|
+
const taxonomies = (cfg.taxonomies ?? {});
|
|
115
|
+
for (const [field, raw] of Object.entries(taxonomies)) {
|
|
116
|
+
if (!raw || typeof raw !== "object")
|
|
117
|
+
continue;
|
|
118
|
+
const rec = raw;
|
|
119
|
+
out.taxonomies[field] = {
|
|
120
|
+
values: Array.isArray(rec.values) ? rec.values.filter((v) => typeof v === "string") : undefined,
|
|
121
|
+
hierarchical: rec.hierarchical === true,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
// `propSchema` shorthand is a bare type name; the long form adds an enum
|
|
125
|
+
const propSchema = (cfg.propSchema ?? {});
|
|
126
|
+
for (const [key, raw] of Object.entries(propSchema)) {
|
|
127
|
+
if (typeof raw === "string" && ["string", "number", "boolean"].includes(raw)) {
|
|
128
|
+
out.propSchema[key] = { type: raw };
|
|
129
|
+
}
|
|
130
|
+
else if (raw && typeof raw === "object") {
|
|
131
|
+
const rec = raw;
|
|
132
|
+
if (typeof rec.type === "string" && ["string", "number", "boolean"].includes(rec.type)) {
|
|
133
|
+
out.propSchema[key] = {
|
|
134
|
+
type: rec.type,
|
|
135
|
+
enum: Array.isArray(rec.enum) ? rec.enum.map((v) => String(v)) : undefined,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const content = (cfg.content ?? {});
|
|
141
|
+
const fm = (content.frontmatter ?? {});
|
|
142
|
+
if (fm.unknown === "warn" || fm.unknown === "error")
|
|
143
|
+
out.unknown = fm.unknown;
|
|
144
|
+
else if (fm.unknown !== undefined && fm.unknown !== "ignore") {
|
|
145
|
+
// the CLI throws on this; the IDE reads the config directly, so say so rather than
|
|
146
|
+
// silently applying no policy at all
|
|
147
|
+
out.invalid = `content.frontmatter.unknown must be "ignore" | "warn" | "error"; got ${JSON.stringify(fm.unknown)}`;
|
|
148
|
+
}
|
|
149
|
+
const required = fm.required;
|
|
150
|
+
if (required && typeof required === "object") {
|
|
151
|
+
for (const [glob, fields] of Object.entries(required)) {
|
|
152
|
+
const list = Array.isArray(fields) ? fields.filter((f) => typeof f === "string") : typeof fields === "string" ? [fields] : [];
|
|
153
|
+
if (list.length)
|
|
154
|
+
out.required.push({ glob, fields: list });
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* unreadable config: no policy rather than a guessed one */
|
|
160
|
+
}
|
|
161
|
+
break;
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Frontmatter findings for one document. Pure: the caller supplies the raw
|
|
167
|
+
* source (for key lines) and the policy.
|
|
168
|
+
*/
|
|
169
|
+
function frontmatterIssues(doc, keyLines, policy) {
|
|
170
|
+
const out = [];
|
|
171
|
+
const fm = doc.frontmatter ?? {};
|
|
172
|
+
const at = (key) => (keyLines.has(key) ? { line: keyLines.get(key) } : {});
|
|
173
|
+
// the fix is the EXISTING props-set verb (span-aware, re-parses what it wrote, expect-gated) —
|
|
174
|
+
// `expect` is the hash of the bytes this finding was computed from
|
|
175
|
+
const setFix = (key, value, label) => ({
|
|
176
|
+
fix: { verb: "props-set", args: { file: doc.path, set: { [key]: value }, expect: doc.hash }, label },
|
|
177
|
+
});
|
|
178
|
+
for (const [key, value] of Object.entries(fm)) {
|
|
179
|
+
const taxonomy = policy.taxonomies[key];
|
|
180
|
+
const spec = WELL_KNOWN_FIELDS[key];
|
|
181
|
+
if (AGGREGATOR_OWNED.includes(key))
|
|
182
|
+
continue; // reported below, once, whatever its type
|
|
183
|
+
if (!spec && !taxonomy) {
|
|
184
|
+
// an unknown-key policy applies to CONTENT only: a README or a plan in the same
|
|
185
|
+
// workspace is not a page, and reporting its keys is noise
|
|
186
|
+
if (policy.unknown !== "ignore" && contentRelativePath(policy, doc.path) !== null) {
|
|
187
|
+
out.push({
|
|
188
|
+
severity: policy.unknown === "error" ? "error" : "warning",
|
|
189
|
+
code: "frontmatter-unknown",
|
|
190
|
+
message: `unknown frontmatter key "${key}" (not a well-known field or a declared taxonomy)`,
|
|
191
|
+
file: doc.path,
|
|
192
|
+
...at(key),
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
if (value === null || value === undefined)
|
|
198
|
+
continue; // an empty key is a stub, not a wrong type
|
|
199
|
+
// A DECLARED taxonomy wins over the built-in field spec: taxonomy terms are strings (the
|
|
200
|
+
// collector drops anything else), so `product: 3` must be reported even though the chunker's
|
|
201
|
+
// own `product` facet would have taken a number.
|
|
202
|
+
const effective = taxonomy ? { type: "string|string[]" } : spec;
|
|
203
|
+
if (effective && !matchesType(value, effective.type)) {
|
|
204
|
+
out.push({
|
|
205
|
+
severity: "error",
|
|
206
|
+
code: "frontmatter-type",
|
|
207
|
+
message: `"${key}" should be ${effective.type}, got ${typeOf(value)}`,
|
|
208
|
+
file: doc.path,
|
|
209
|
+
...at(key),
|
|
210
|
+
});
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (spec && !matchesType(value, spec.type)) {
|
|
214
|
+
out.push({
|
|
215
|
+
severity: "error",
|
|
216
|
+
code: "frontmatter-type",
|
|
217
|
+
message: `"${key}" should be ${spec.type}, got ${typeOf(value)}`,
|
|
218
|
+
file: doc.path,
|
|
219
|
+
...at(key),
|
|
220
|
+
});
|
|
221
|
+
continue; // an enum check on the wrong type says nothing useful
|
|
222
|
+
}
|
|
223
|
+
// `tags: api, guides` is ONE tag called "api, guides" — the coercion wraps the scalar, it
|
|
224
|
+
// does not split it, so the writer silently gets a tag nobody will ever match
|
|
225
|
+
// ONLY where the coercion wraps rather than splits: the taxonomy/meta fields. The chunker's
|
|
226
|
+
// facet axes split a string themselves, so a comma there is fine.
|
|
227
|
+
const wrapsScalar = taxonomy !== undefined || key === "tags" || key === "audience" || key === "category";
|
|
228
|
+
if (typeof value === "string" && /,\s*\S/.test(value) && wrapsScalar) {
|
|
229
|
+
out.push({
|
|
230
|
+
severity: "warning",
|
|
231
|
+
code: "frontmatter-comma-scalar",
|
|
232
|
+
message: `"${key}: ${value}" is ONE value containing a comma, not a list — write it as a YAML list (\`[a, b]\`) if you meant several`,
|
|
233
|
+
file: doc.path,
|
|
234
|
+
...at(key),
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
// a value the pipeline discards: an empty string or an empty list is not "no value", it is a
|
|
238
|
+
// value that silently disappears (parseMeta drops both)
|
|
239
|
+
if ((typeof value === "string" && value.trim() === "") || (Array.isArray(value) && value.length === 0)) {
|
|
240
|
+
out.push({
|
|
241
|
+
severity: "warning",
|
|
242
|
+
code: "frontmatter-empty",
|
|
243
|
+
message: `"${key}" is empty, so it is dropped — remove the key or give it a value`,
|
|
244
|
+
file: doc.path,
|
|
245
|
+
...at(key),
|
|
246
|
+
});
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
// a closed set: the well-known `status`, or a taxonomy that declared `values`
|
|
250
|
+
const allowed = spec?.values ?? taxonomy?.values;
|
|
251
|
+
const terms = typeof value === "string" ? [value] : Array.isArray(value) ? value.filter((v) => typeof v === "string") : [];
|
|
252
|
+
if (allowed) {
|
|
253
|
+
for (const term of terms) {
|
|
254
|
+
const head = taxonomy?.hierarchical ? term.split("/")[0] : term;
|
|
255
|
+
if (allowed.includes(term) || (taxonomy?.hierarchical && allowed.includes(head)))
|
|
256
|
+
continue;
|
|
257
|
+
// a hierarchical taxonomy declares its HEAD terms, so `apu/rest` is a near miss of `api`
|
|
258
|
+
const probe = taxonomy?.hierarchical ? head : term;
|
|
259
|
+
const near = nearMisses(probe, allowed);
|
|
260
|
+
// a hierarchical fix keeps the sub-term: `apu/rest` → `api/rest`, never `api`
|
|
261
|
+
const replacement = near.length === 1
|
|
262
|
+
? (taxonomy?.hierarchical && term !== head ? `${near[0]}${term.slice(head.length)}` : near[0])
|
|
263
|
+
: undefined;
|
|
264
|
+
out.push({
|
|
265
|
+
severity: "error",
|
|
266
|
+
code: "frontmatter-enum",
|
|
267
|
+
message: taxonomy?.hierarchical && term !== head
|
|
268
|
+
? `"${key}: ${term}" starts with "${head}", which is not one of ${allowed.join(", ")}`
|
|
269
|
+
: `"${key}: ${term}" is not one of ${allowed.join(", ")}`,
|
|
270
|
+
file: doc.path,
|
|
271
|
+
...at(key),
|
|
272
|
+
...(near.length ? { candidates: near } : {}),
|
|
273
|
+
// ONLY a case slip is applied with one click: at edit distance 2 the single
|
|
274
|
+
// candidate for `unstable` is `stable`, and inverting a writer's meaning on a
|
|
275
|
+
// click is worse than making them choose
|
|
276
|
+
...(replacement && typeof value === "string" && isSafeAutoFix(probe, near[0])
|
|
277
|
+
? setFix(key, replacement, `Use "${replacement}"`)
|
|
278
|
+
: {}),
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (taxonomy && !taxonomy.hierarchical) {
|
|
283
|
+
for (const term of terms) {
|
|
284
|
+
if (!term.includes("/"))
|
|
285
|
+
continue;
|
|
286
|
+
out.push({
|
|
287
|
+
severity: "warning",
|
|
288
|
+
code: "frontmatter-nesting",
|
|
289
|
+
message: `"${key}: ${term}" uses slash nesting, but the ${key} taxonomy is not hierarchical — it will be one flat term`,
|
|
290
|
+
file: doc.path,
|
|
291
|
+
...at(key),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
// the free-form `props:` bag, against the config's `propSchema`
|
|
297
|
+
const props = fm.props;
|
|
298
|
+
if (props && typeof props === "object" && !Array.isArray(props)) {
|
|
299
|
+
for (const [key, value] of Object.entries(props)) {
|
|
300
|
+
const spec = policy.propSchema[key];
|
|
301
|
+
if (!spec || value === null || value === undefined)
|
|
302
|
+
continue;
|
|
303
|
+
if (typeof value !== spec.type) {
|
|
304
|
+
out.push({
|
|
305
|
+
severity: "error",
|
|
306
|
+
code: "frontmatter-prop-type",
|
|
307
|
+
message: `"props.${key}" should be ${spec.type}, got ${typeOf(value)}`,
|
|
308
|
+
file: doc.path,
|
|
309
|
+
...at("props"),
|
|
310
|
+
});
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
if (spec.enum && !spec.enum.includes(String(value))) {
|
|
314
|
+
const near = nearMisses(String(value), spec.enum);
|
|
315
|
+
out.push({
|
|
316
|
+
severity: "error",
|
|
317
|
+
code: "frontmatter-prop-enum",
|
|
318
|
+
message: `"props.${key}: ${String(value)}" is not one of ${spec.enum.join(", ")}`,
|
|
319
|
+
file: doc.path,
|
|
320
|
+
...at("props"),
|
|
321
|
+
...(near.length ? { candidates: near } : {}),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
// keys the multi-source build writes: a writer setting one by hand routes the page wrong
|
|
327
|
+
for (const key of AGGREGATOR_OWNED) {
|
|
328
|
+
if (fm[key] === undefined)
|
|
329
|
+
continue;
|
|
330
|
+
out.push({
|
|
331
|
+
severity: "warning",
|
|
332
|
+
code: "frontmatter-aggregator-owned",
|
|
333
|
+
message: `"${key}" is written by the multi-source build, not by hand — the build overwrites it, so setting it here has no effect`,
|
|
334
|
+
file: doc.path,
|
|
335
|
+
...at(key),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
for (const field of requiredFieldsFor(policy, doc.path)) {
|
|
339
|
+
const value = fm[field];
|
|
340
|
+
if (value === undefined || value === null || value === "") {
|
|
341
|
+
out.push({
|
|
342
|
+
severity: "error",
|
|
343
|
+
code: "frontmatter-required",
|
|
344
|
+
message: `"${field}" is required here but is ${value === undefined ? "missing" : "empty"}`,
|
|
345
|
+
file: doc.path,
|
|
346
|
+
...at(field),
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return out;
|
|
351
|
+
}
|
|
352
|
+
/** A site path is a page when a content root has it as `x.md`, `x/index.md`, `x.mdx` — or an existing FILE (an asset); `_`-prefixed segments are never routed (except `_assets`). */
|
|
353
|
+
function resolveSitePath(index, layout, sitePath) {
|
|
354
|
+
const candidates = [];
|
|
355
|
+
let p = sitePath;
|
|
356
|
+
for (const pre of layout.prefixes)
|
|
357
|
+
if (p === pre || p.startsWith(pre + "/")) {
|
|
358
|
+
p = p.slice(pre.length).replace(/^\/+/, "");
|
|
359
|
+
break;
|
|
360
|
+
}
|
|
361
|
+
// `_`-prefixed segments are never routed by the build (fragments, includes) — except under `_assets/`, which is served
|
|
362
|
+
const hidden = (path) => {
|
|
363
|
+
if (path.startsWith("_assets/") || path.includes("/_assets/"))
|
|
364
|
+
return false;
|
|
365
|
+
return path.split("/").some((seg) => seg.startsWith("_"));
|
|
366
|
+
};
|
|
367
|
+
for (const root of layout.roots) {
|
|
368
|
+
const base = root ? `${root}/${p}` : p;
|
|
369
|
+
for (const c of [`${base}.md`, `${base}/index.md`, `${base}.mdx`]) {
|
|
370
|
+
candidates.push(c);
|
|
371
|
+
if (!hidden(c) && (index.docs.has(c) || index.maps.has(c)))
|
|
372
|
+
return { doc: index.docs.has(c) ? c : undefined, file: c, candidates };
|
|
373
|
+
}
|
|
374
|
+
candidates.push(base);
|
|
375
|
+
if (!hidden(base) && index.files.has(base))
|
|
376
|
+
return { file: base, candidates };
|
|
377
|
+
}
|
|
378
|
+
return { candidates };
|
|
379
|
+
}
|
|
380
|
+
export const validateCommand = defineCommand({
|
|
381
|
+
name: "validate",
|
|
382
|
+
writes: "none",
|
|
383
|
+
description: "Validate the workspace: links, anchors, includes, keys, and topic maps",
|
|
384
|
+
input,
|
|
385
|
+
async run(ctx) {
|
|
386
|
+
const index = await ctx.workspace.index();
|
|
387
|
+
const issues = [];
|
|
388
|
+
const layout = await siteLayout(ctx.workspace.storage);
|
|
389
|
+
const policy = await frontmatterPolicy(ctx.workspace.storage, layout);
|
|
390
|
+
// basename → workspace files with that name (a moved target is usually the same file elsewhere)
|
|
391
|
+
const byBase = new Map();
|
|
392
|
+
for (const f of index.files) {
|
|
393
|
+
const b = f.slice(f.lastIndexOf("/") + 1);
|
|
394
|
+
const list = byBase.get(b) ?? [];
|
|
395
|
+
list.push(f);
|
|
396
|
+
byBase.set(b, list);
|
|
397
|
+
}
|
|
398
|
+
// Basenames that say nothing about WHICH file was meant: a dead `old/index.md` must not be
|
|
399
|
+
// auto-pointed at whatever single other index.md exists. Listed as candidates, never fixed.
|
|
400
|
+
const GENERIC = new Set(["index.md", "_index.md", "readme.md", "overview.md", "introduction.md", "intro.md", "getting-started.md", "changelog.md"]);
|
|
401
|
+
const candidatesFor = (target, fromDoc) => (byBase.get(target.slice(target.lastIndexOf("/") + 1)) ?? []).filter((f) => f !== target && f !== fromDoc);
|
|
402
|
+
const autoFixable = (target, cands) => cands.length === 1 && !GENERIC.has(target.slice(target.lastIndexOf("/") + 1).toLowerCase());
|
|
403
|
+
/** A fix that rewrites one destination in `docPath`, when exactly one candidate exists. */
|
|
404
|
+
// `expect` = the hash of the bytes the problem was computed from: the verb refuses (stale-expect)
|
|
405
|
+
// if the file changed since, instead of retargeting whatever is there now
|
|
406
|
+
const retargetFix = (doc, href, line, newDest, label) => ({
|
|
407
|
+
fix: { verb: "link-retarget", args: { file: doc.path, from: href, to: newDest, ...(line !== undefined ? { line } : {}), expect: doc.hash }, label },
|
|
408
|
+
});
|
|
409
|
+
// The candidate is a REAL file name (decoded); a bare `](…)` destination cannot hold whitespace,
|
|
410
|
+
// parens or angle brackets, so the rewritten destination is percent-encoded the way the source
|
|
411
|
+
// href was (or, if the file name needs it, regardless) — a raw space would un-link the reference.
|
|
412
|
+
const destEncode = (dest, likeHref) => {
|
|
413
|
+
const needs = /[\s()<>]/.test(dest);
|
|
414
|
+
if (!needs && !/%[0-9A-Fa-f]{2}/.test(likeHref))
|
|
415
|
+
return dest;
|
|
416
|
+
return dest.split("/").map((seg) => encodeURIComponent(seg).replace(/%2F/g, "/")).join("/");
|
|
417
|
+
};
|
|
418
|
+
const relDest = (fromDoc, toFile, likeHref = "") => destEncode(relPath(dirOf(fromDoc) || ".", toFile), likeHref);
|
|
419
|
+
if (policy.invalid) {
|
|
420
|
+
issues.push({ severity: "error", code: "frontmatter-policy-invalid", message: policy.invalid, file: "dogsbay.config.yml" });
|
|
421
|
+
}
|
|
422
|
+
for (const doc of index.docs.values()) {
|
|
423
|
+
if (doc.parseError) {
|
|
424
|
+
issues.push({
|
|
425
|
+
severity: "error",
|
|
426
|
+
code: "doc-parse-failed",
|
|
427
|
+
message: `cannot parse: ${doc.parseError}`,
|
|
428
|
+
file: doc.path,
|
|
429
|
+
});
|
|
430
|
+
continue;
|
|
431
|
+
}
|
|
432
|
+
{
|
|
433
|
+
const fm = doc.frontmatter;
|
|
434
|
+
const isMapping = !!fm && typeof fm === "object" && !Array.isArray(fm);
|
|
435
|
+
const hasKeys = isMapping && Object.keys(fm).length > 0;
|
|
436
|
+
// no second read: the index carries the block's shape and key lines from its own pass
|
|
437
|
+
// A block that is not a MAPPING — a list, a scalar, tab-indented YAML — yields an array or
|
|
438
|
+
// `{}`, and EVERY field on the page is then ignored with no other symptom.
|
|
439
|
+
if (doc.frontmatterBlock === "unterminated") {
|
|
440
|
+
// the parser treats the whole file as an unterminated block: no tree, no headings,
|
|
441
|
+
// no links — the page renders EMPTY, and nothing else would report it
|
|
442
|
+
issues.push({
|
|
443
|
+
severity: "error",
|
|
444
|
+
code: "frontmatter-unterminated",
|
|
445
|
+
message: "the frontmatter block is never closed (`---` on its own line), so the entire page is read as frontmatter and renders empty",
|
|
446
|
+
file: doc.path,
|
|
447
|
+
line: 1,
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
else if (!isMapping || (!hasKeys && !doc.frontmatterEmpty)) {
|
|
451
|
+
// a YAML SYNTAX error (duplicate key, bad quoting) also lands here with `{}` —
|
|
452
|
+
// the parser's own message and line say far more than "it is not a mapping"
|
|
453
|
+
const yamlError = yamlErrorOf(await ctx.workspace.storage.read(doc.path).catch(() => ""));
|
|
454
|
+
if (yamlError) {
|
|
455
|
+
issues.push({
|
|
456
|
+
severity: "error",
|
|
457
|
+
code: "frontmatter-yaml-error",
|
|
458
|
+
message: `the frontmatter is not valid YAML: ${yamlError.message}`,
|
|
459
|
+
file: doc.path,
|
|
460
|
+
line: yamlError.line,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
else
|
|
464
|
+
issues.push({
|
|
465
|
+
severity: "error",
|
|
466
|
+
code: "frontmatter-not-a-mapping",
|
|
467
|
+
message: `the frontmatter block is ${Array.isArray(fm) ? "a list" : "not a YAML mapping"} (it must be \`key: value\` pairs), so every field on this page is ignored`,
|
|
468
|
+
file: doc.path,
|
|
469
|
+
line: 1,
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
else if (hasKeys || requiredFieldsFor(policy, doc.path).length) {
|
|
473
|
+
issues.push(...frontmatterIssues(doc, doc.frontmatterKeyLines, policy));
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
for (const link of doc.links) {
|
|
477
|
+
const at = link.line !== undefined ? { line: link.line } : {};
|
|
478
|
+
if (link.siteAbsolute) {
|
|
479
|
+
// `/guides/x` is resolved the way the SITE BUILD routes it: under the content roots
|
|
480
|
+
// (dogsbay.config `content.sources[].path`, else the workspace root), basePath stripped,
|
|
481
|
+
// as `x.md` / `x/index.md` / `x.mdx` or an existing asset file — the other half of link
|
|
482
|
+
// checking (validate used to skip these; native corpora write every link this way)
|
|
483
|
+
if (!layout.checkable)
|
|
484
|
+
continue;
|
|
485
|
+
const sitePath = link.sitePath ?? "";
|
|
486
|
+
if (!sitePath)
|
|
487
|
+
continue; // "/" — the home page
|
|
488
|
+
const hit = resolveSitePath(index, layout, sitePath);
|
|
489
|
+
if (!hit.file) {
|
|
490
|
+
const cands = candidatesFor(`${sitePath}.md`);
|
|
491
|
+
issues.push({
|
|
492
|
+
severity: "warning",
|
|
493
|
+
code: "link-site-absolute-unresolved",
|
|
494
|
+
message: `${link.kind} "${link.href}" — no page or file at ${hit.candidates.slice(0, 3).join(", ")}${layout.roots[0] ? "" : " (workspace root taken as the content root)"}${cands.length ? ` (same name at: ${cands.slice(0, 3).join(", ")})` : ""}`,
|
|
495
|
+
file: doc.path,
|
|
496
|
+
...at,
|
|
497
|
+
...(cands.length ? { candidates: cands } : {}),
|
|
498
|
+
});
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const foundDoc = hit.doc ? index.docs.get(hit.doc) : undefined;
|
|
502
|
+
if (link.fragment && foundDoc && !foundDoc.anchors.includes(link.fragment)) {
|
|
503
|
+
const mismatch = anchorAliases(foundDoc.headings).has(link.fragment);
|
|
504
|
+
const want = mismatch ? anchorAliasTargets(foundDoc.headings, foundDoc.anchors).get(link.fragment) : undefined;
|
|
505
|
+
const pathPart = link.href.split("#")[0] ?? "";
|
|
506
|
+
issues.push({
|
|
507
|
+
severity: "warning",
|
|
508
|
+
code: mismatch ? "link-anchor-rule-mismatch" : "link-anchor-missing",
|
|
509
|
+
message: mismatch
|
|
510
|
+
? `link "${link.href}" — #${link.fragment} exists under another tool's heading-slug rule, but Dogsbay renders ${want ? `#${want}` : "a different anchor"} in ${hit.doc}`
|
|
511
|
+
: `link "${link.href}" — no anchor #${link.fragment} in ${hit.doc}`,
|
|
512
|
+
file: doc.path,
|
|
513
|
+
...at,
|
|
514
|
+
...(want ? retargetFix(doc, link.href, link.line, `${pathPart}#${want}`, `Use #${want}`) : {}),
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
continue;
|
|
518
|
+
}
|
|
519
|
+
if (!link.target)
|
|
520
|
+
continue; // external: not ours to check
|
|
521
|
+
if (link.escapes) {
|
|
522
|
+
// it may well exist one level up — that is a different fact from "missing",
|
|
523
|
+
// and the right fix (open the parent as the project, or move the file) differs
|
|
524
|
+
issues.push({
|
|
525
|
+
severity: "warning",
|
|
526
|
+
code: "link-escapes-workspace",
|
|
527
|
+
message: `${link.kind} "${link.href}" resolves outside the workspace root (${link.target}) — not checked`,
|
|
528
|
+
file: doc.path,
|
|
529
|
+
...at,
|
|
530
|
+
});
|
|
531
|
+
continue;
|
|
532
|
+
}
|
|
533
|
+
const targetDoc = index.docs.get(link.target);
|
|
534
|
+
const targetExists = targetDoc !== undefined ||
|
|
535
|
+
index.maps.has(link.target) ||
|
|
536
|
+
(await ctx.workspace.storage.exists(link.target));
|
|
537
|
+
if (!targetExists) {
|
|
538
|
+
const cands = candidatesFor(link.target, doc.path);
|
|
539
|
+
const fragment = link.fragment ? `#${link.fragment}` : "";
|
|
540
|
+
issues.push({
|
|
541
|
+
severity: "error",
|
|
542
|
+
code: link.kind === "image" ? "image-target-missing" : "link-target-missing",
|
|
543
|
+
message: `${link.kind} "${link.href}" → ${link.target} does not exist${cands.length ? ` (same name at: ${cands.slice(0, 3).join(", ")})` : ""}`,
|
|
544
|
+
file: doc.path,
|
|
545
|
+
...at,
|
|
546
|
+
...(cands.length ? { candidates: cands } : {}),
|
|
547
|
+
...(autoFixable(link.target, cands) ? retargetFix(doc, link.href, link.line, relDest(doc.path, cands[0], link.href) + fragment, `Point at ${cands[0]}`) : {}),
|
|
548
|
+
});
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (link.fragment && targetDoc && !targetDoc.anchors.includes(link.fragment)) {
|
|
552
|
+
// the anchor may exist under ANOTHER renderer's slug rule (GitHub, mkdocs): a
|
|
553
|
+
// migration-era mismatch the author's old toolchain accepted — say so, rather
|
|
554
|
+
// than reporting a dead link that is not dead in their tool
|
|
555
|
+
const aliases = anchorAliases(targetDoc.headings);
|
|
556
|
+
if (aliases.has(link.fragment)) {
|
|
557
|
+
const want = anchorAliasTargets(targetDoc.headings, targetDoc.anchors).get(link.fragment);
|
|
558
|
+
const pathPart = link.href.split("#")[0] ?? "";
|
|
559
|
+
issues.push({
|
|
560
|
+
severity: "warning",
|
|
561
|
+
code: "link-anchor-rule-mismatch",
|
|
562
|
+
message: `link "${link.href}" — #${link.fragment} exists under another tool's heading-slug rule, but Dogsbay renders ${want ? `#${want}` : "a different anchor"} in ${link.target}`,
|
|
563
|
+
file: doc.path,
|
|
564
|
+
...at,
|
|
565
|
+
...(want ? retargetFix(doc, link.href, link.line, `${pathPart}#${want}`, `Use #${want}`) : {}),
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
issues.push({
|
|
570
|
+
severity: "warning",
|
|
571
|
+
code: "link-anchor-missing",
|
|
572
|
+
message: `link "${link.href}" — no anchor #${link.fragment} in ${link.target}`,
|
|
573
|
+
file: doc.path,
|
|
574
|
+
...at,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
for (const include of doc.includes) {
|
|
580
|
+
// Out-of-root include (#027): the build's Minja pass REFUSES these
|
|
581
|
+
// (path containment), so flag them here where the failure is cheap
|
|
582
|
+
// to see — before a build dies on it. `..`-escapes keep their
|
|
583
|
+
// leading segments through normalizePath; absolute raws never
|
|
584
|
+
// resolve workspace-relative at all. Deliberately CONSERVATIVE:
|
|
585
|
+
// every absolute raw is flagged, including one whose filesystem
|
|
586
|
+
// path happens to sit inside the build root (which the build
|
|
587
|
+
// would allow) — validate has no filesystem-root context, and an
|
|
588
|
+
// absolute include is non-portable anyway. `..\`-style raws are
|
|
589
|
+
// caught for Windows-authored corpora (normalizePath splits on
|
|
590
|
+
// `/` only, so they never surface in `target`).
|
|
591
|
+
if (include.target.startsWith("../") || include.raw.startsWith("/") ||
|
|
592
|
+
/^[A-Za-z]:[\\/]/.test(include.raw) || include.raw.startsWith("..\\")) {
|
|
593
|
+
issues.push({
|
|
594
|
+
severity: "error",
|
|
595
|
+
code: "include-escapes-workspace",
|
|
596
|
+
message: `{% include "${include.raw}" %} resolves outside the workspace — the build refuses out-of-root includes`,
|
|
597
|
+
file: doc.path,
|
|
598
|
+
...(include.line !== undefined ? { line: include.line } : {}),
|
|
599
|
+
});
|
|
600
|
+
continue;
|
|
601
|
+
}
|
|
602
|
+
if (!(await ctx.workspace.storage.exists(include.target))) {
|
|
603
|
+
const cands = candidatesFor(include.target, doc.path);
|
|
604
|
+
issues.push({
|
|
605
|
+
severity: "error",
|
|
606
|
+
code: "include-target-missing",
|
|
607
|
+
message: `{% include "${include.raw}" %} → ${include.target} does not exist${cands.length ? ` (same name at: ${cands.slice(0, 3).join(", ")})` : ""}`,
|
|
608
|
+
file: doc.path,
|
|
609
|
+
...(include.line !== undefined ? { line: include.line } : {}),
|
|
610
|
+
...(cands.length ? { candidates: cands } : {}),
|
|
611
|
+
...(autoFixable(include.target, cands) ? retargetFix(doc, include.raw, include.line, relDest(doc.path, cands[0], include.raw), `Point at ${cands[0]}`) : {}),
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
for (const name of doc.keyUses) {
|
|
616
|
+
if (!index.keys.has(name)) {
|
|
617
|
+
issues.push({
|
|
618
|
+
severity: "error",
|
|
619
|
+
code: "key-undefined",
|
|
620
|
+
message: `key "${name}" is referenced but defined in no keys file`,
|
|
621
|
+
file: doc.path,
|
|
622
|
+
});
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
for (const [name, defs] of index.keys) {
|
|
627
|
+
if (defs.length > 1) {
|
|
628
|
+
issues.push({
|
|
629
|
+
severity: "warning",
|
|
630
|
+
code: "key-conflict",
|
|
631
|
+
message: `key "${name}" defined ${defs.length} times (${defs.map((d) => d.file).join(", ")}) — first wins`,
|
|
632
|
+
file: defs[0].file,
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
for (const err of index.keyFileErrors) {
|
|
637
|
+
issues.push({
|
|
638
|
+
severity: "error",
|
|
639
|
+
code: "keys-file-invalid",
|
|
640
|
+
message: err.message,
|
|
641
|
+
file: err.file,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
for (const map of index.maps.values()) {
|
|
645
|
+
for (const err of map.errors) {
|
|
646
|
+
issues.push({
|
|
647
|
+
severity: "error",
|
|
648
|
+
code: `topicmap-${err.code}`,
|
|
649
|
+
message: err.message,
|
|
650
|
+
file: err.mapPath || map.path,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
// Root-aware walk over the map include graph. "/"-rooted hrefs
|
|
655
|
+
// resolve against the ROOT map's directory (the topicmap loader
|
|
656
|
+
// convention), so target-existence and cycle checks must carry the
|
|
657
|
+
// root dir through the graph — a per-map loop can't see them.
|
|
658
|
+
// Maps unreachable from any root still get their RELATIVE refs
|
|
659
|
+
// checked; their "/"-rooted refs are skipped (no root, no meaning).
|
|
660
|
+
const includedByRelative = new Set();
|
|
661
|
+
for (const map of index.maps.values()) {
|
|
662
|
+
for (const ref of map.refs) {
|
|
663
|
+
if (ref.isMap && ref.target)
|
|
664
|
+
includedByRelative.add(ref.target);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const roots = [...index.maps.keys()].filter((path) => !includedByRelative.has(path));
|
|
668
|
+
const visited = new Set();
|
|
669
|
+
const reportedCycles = new Set();
|
|
670
|
+
const reportedMissing = new Set();
|
|
671
|
+
const walkMaps = async (path, rootDir, trail, resolveRootRelative) => {
|
|
672
|
+
if (trail.includes(path)) {
|
|
673
|
+
const cycle = [...trail.slice(trail.indexOf(path)), path];
|
|
674
|
+
const key = [...cycle].sort().join("→");
|
|
675
|
+
if (!reportedCycles.has(key)) {
|
|
676
|
+
reportedCycles.add(key);
|
|
677
|
+
issues.push({
|
|
678
|
+
severity: "error",
|
|
679
|
+
code: "topicmap-cycle",
|
|
680
|
+
message: `circular topic map include: ${cycle.join(" → ")}`,
|
|
681
|
+
file: path,
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
const wasVisited = visited.has(path);
|
|
687
|
+
visited.add(path);
|
|
688
|
+
const entry = index.maps.get(path);
|
|
689
|
+
for (const ref of entry?.refs ?? []) {
|
|
690
|
+
let target = ref.target;
|
|
691
|
+
if (!target && ref.rootRelative && resolveRootRelative) {
|
|
692
|
+
target = joinPath(rootDir, ref.href.replace(/^\/+/, ""));
|
|
693
|
+
}
|
|
694
|
+
if (!target)
|
|
695
|
+
continue; // external, or root-relative with no known root
|
|
696
|
+
if (!wasVisited) {
|
|
697
|
+
const missingKey = `${path}|${ref.href}`;
|
|
698
|
+
if (!reportedMissing.has(missingKey) &&
|
|
699
|
+
!(await ctx.workspace.storage.exists(target))) {
|
|
700
|
+
reportedMissing.add(missingKey);
|
|
701
|
+
issues.push({
|
|
702
|
+
severity: "error",
|
|
703
|
+
code: "topicmap-target-missing",
|
|
704
|
+
message: `entry "${ref.href}" → ${target} does not exist`,
|
|
705
|
+
file: path,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
// Recurse into unvisited maps; ALSO re-enter a map already on
|
|
710
|
+
// the current trail so the cycle gets reported (visited alone
|
|
711
|
+
// would skip it — that's only correct for diamonds)
|
|
712
|
+
if (ref.isMap &&
|
|
713
|
+
index.maps.has(target) &&
|
|
714
|
+
(!visited.has(target) || trail.includes(target))) {
|
|
715
|
+
await walkMaps(target, rootDir, [...trail, path], resolveRootRelative);
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
for (const root of roots) {
|
|
720
|
+
await walkMaps(root, dirnameOf(root), [], true);
|
|
721
|
+
}
|
|
722
|
+
for (const path of index.maps.keys()) {
|
|
723
|
+
if (!visited.has(path))
|
|
724
|
+
await walkMaps(path, dirnameOf(path), [], false);
|
|
725
|
+
}
|
|
726
|
+
// the same href twice in one file is one finding (70% of rows on a real corpus were duplicates)
|
|
727
|
+
const seen = new Set();
|
|
728
|
+
const unique = issues.filter((i) => {
|
|
729
|
+
const k = `${i.severity}|${i.code}|${i.file ?? ""}|${i.line ?? ""}|${i.message}`;
|
|
730
|
+
if (seen.has(k))
|
|
731
|
+
return false;
|
|
732
|
+
seen.add(k);
|
|
733
|
+
return true;
|
|
734
|
+
});
|
|
735
|
+
issues.length = 0;
|
|
736
|
+
issues.push(...unique);
|
|
737
|
+
const errors = issues.filter((issue) => issue.severity === "error").length;
|
|
738
|
+
const warnings = issues.length - errors;
|
|
739
|
+
return {
|
|
740
|
+
data: {
|
|
741
|
+
checked: index.stats,
|
|
742
|
+
errors,
|
|
743
|
+
warnings,
|
|
744
|
+
issues,
|
|
745
|
+
},
|
|
746
|
+
issues,
|
|
747
|
+
summary: issues.length === 0
|
|
748
|
+
? `workspace clean — ${index.stats.docs} doc(s), ${index.stats.maps} map(s), ${index.stats.keys} key(s)`
|
|
749
|
+
: `${errors} error(s), ${warnings} warning(s) across ${index.stats.docs} doc(s)`,
|
|
750
|
+
};
|
|
751
|
+
},
|
|
752
|
+
});
|
|
753
|
+
//# sourceMappingURL=validate.js.map
|