create-evda 0.1.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/lib/doctor.mjs ADDED
@@ -0,0 +1,419 @@
1
+ // evda doctor: validate a directory against Spec 04 and 05 and say what was examined.
2
+ //
3
+ // Every section prints exactly one of:
4
+ // CHECKED <n> <noun> it looked at n things, and n is never 0 (0 is a broken discovery step)
5
+ // SKIPPED <noun>: why it had nothing to look at, and says so; a skip is never a tick
6
+ // A run that exits 0 without a CHECKED line of n > 0 is itself a failure. The guard is at the end.
7
+ //
8
+ // FALSIFIED 2026-09-15, three ways, on a fresh scaffold, each watched:
9
+ // 1. Appended H-0001 (refined) and H-0002 with `**Supersedes:** H-0001` and no `Superseded by`
10
+ // on H-0001 -> "FAIL ledger/hypotheses.md:80 H-0002 supersedes H-0001 but H-0001 does not
11
+ // say Superseded by: H-0002. The chain reads differently from each end." and "FAILED 1
12
+ // failure(s), 1 warning(s), 8 check(s) with something in them", exit 1.
13
+ // 2. Removed ledger/ with the role still mapped -> "FAIL role ledger is mapped to ledger but
14
+ // that directory does not exist. An empty directory and a missing one are different facts."
15
+ // and "SKIPPED hypotheses: ledger file is missing (already failed above)", exit 1.
16
+ // 3. Emptied the mapping and layers -> "CHECKED 0 roles 0 mapped, 6 absent" followed by
17
+ // "FAIL roles CHECKED 0 is not a pass. The discovery step found nothing to examine, which
18
+ // is a broken step, not a clean record." and "FAILED 3 failure(s)", exit 1.
19
+ // FALSIFIED 2026-09-16, sections 12 and 13, on a fresh scaffold whose record/people/sam.md carried
20
+ // two references, one with `password: hunter2` and one through a connector the folder did not
21
+ // declare -> "CHECKED 3 folder manifests 0 with connectors", then
22
+ // "FAIL record/people/sam.md REFUSED: record/people/sam.md: references[0] carries a field
23
+ // named "password". A reference holds a pointer, a label and a date; ..."
24
+ // "FAIL record/people/sam.md references contacts:2F1A-77 through contacts, which
25
+ // record/people/folder.evda.json does not declare (it declares: none)."
26
+ // "CHECKED 2 references across 1 file(s), 1 refused" and "FAILED 2 failure(s)", exit 1.
27
+ // The value hunter2 appeared nowhere in the output. The same run, the first time, was made against
28
+ // the wrong record: `evda doctor scratch` with a bare directory name fell through the locator to
29
+ // ~/.evda/current, which is why bin/evda.mjs pickDir now treats a directory that exists as the
30
+ // argument.
31
+ // FALSIFIED 2026-09-16, section 11b, on a scaffold with one private circle (workshop), an agent
32
+ // granted viewer on it through `evda grant`, and a hand-written grants/peers/bot.atlas--core.eco
33
+ // file giving an agent editor on a circle the record does not have ->
34
+ // "FAIL grant grants/peers/bot.atlas--core.eco.yaml: grants editor to an agent. An agent holds
35
+ // viewer and nothing else (Spec 00 #65)."
36
+ // "FAIL grant grants/peers/bot.atlas--core.eco.yaml grants circle "core", and this record has
37
+ // no such circle."
38
+ // "CHECKED 2 grants across 2 circle(s), 2 to agents", "FAILED 2 failure(s)", exit 1.
39
+ // `evda access workshop --as iris.atlas` on the same record: ALLOWED, exit 0, naming the grant
40
+ // file; `evda access core --as iris.atlas`: REFUSED, exit 2, "no circle named "core"".
41
+
42
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
43
+ import { join, relative, resolve, sep } from "node:path";
44
+ import { readManifest, checkVersion, unknownKeys, unknownLayers, holderOf, holderProblems, KNOWN_LAYERS, SCHEMA_VERSION } from "./manifest.mjs";
45
+ import { readGrants } from "./grants.mjs";
46
+ import { resolveAll, resolveRole, rolesForLayer, ROLES } from "./roles.mjs";
47
+ import { parseLedger, countHeads } from "./ledger.mjs";
48
+ import { validateEntries } from "./validate.mjs";
49
+ import { deriveDue, currentIndex, currentDigest } from "./index.mjs";
50
+ import { mdFiles, fileDate } from "./record.mjs";
51
+ import { splitFence, digestFrom, digestBytes, DIGEST_BUDGET } from "./agents.mjs";
52
+ import { readCircles, coverage, LEGACY_MANIFEST } from "./circles.mjs";
53
+ import { folderManifests, folderFor, viewNamedIn, FOLDER_MANIFEST } from "./folders.mjs";
54
+ import { referencesIn } from "./references.mjs";
55
+ import { EvdaError } from "./errors.mjs";
56
+
57
+ // Same rule as record.mjs fileDate: a daily file is dated by a leading YYYY-MM-DD in its name.
58
+ // A suffix after the date is allowed (`2026-01-19 - The Scale Question.md`); no date is not.
59
+ const DAILY_NAME = /^\d{4}-\d{2}-\d{2}(?:[ .-]|$)/;
60
+
61
+ export function doctor(dir, { out = console.log, via = "argument" } = {}) {
62
+ const failures = [];
63
+ const warnings = [];
64
+ const checked = [];
65
+ const fail = (where, msg) => { failures.push(`${where} ${msg}`); out(` FAIL ${where} ${msg}`); };
66
+ const warn = (where, msg) => { warnings.push(`${where} ${msg}`); out(` WARN ${where} ${msg}`); };
67
+ const ok = (n, noun, detail) => {
68
+ checked.push({ n, noun });
69
+ out(` CHECKED ${n} ${noun}${detail ? ` ${detail}` : ""}`);
70
+ if (n === 0) fail(noun, `CHECKED 0 is not a pass. The discovery step found nothing to examine, which is a broken step, not a clean record.`);
71
+ };
72
+ const skip = (noun, why) => out(` SKIPPED ${noun}: ${why}`);
73
+
74
+ out(`evda doctor ${dir} (located via ${via})`);
75
+
76
+ // 1. manifest
77
+ let manifest;
78
+ try {
79
+ manifest = readManifest(dir);
80
+ } catch (e) {
81
+ out(` ${e.message}`);
82
+ return finish(e.exit ?? 2);
83
+ }
84
+ const unknown = unknownKeys(manifest);
85
+ const holder = holderOf(manifest);
86
+ for (const p of holderProblems(manifest)) fail("evda.json", p);
87
+ if (!manifest.id) warn("evda.json", 'has no "id". Another record names this one by its id in a grant or a reference; without one it cannot be granted anything.');
88
+ ok(1, "manifest", `evda.json: schema ${manifest.evda}, held by a ${holder.kind}${holder.id ? ` (${holder.id})` : ""}, layers [${manifest.layers.join(", ")}]${unknown.length ? `, ${unknown.length} unknown key(s) preserved: ${unknown.join(", ")}` : ""}`);
89
+
90
+ // 2. version
91
+ let version;
92
+ try {
93
+ version = checkVersion(dir, manifest);
94
+ } catch (e) {
95
+ out(` ${e.message}`);
96
+ fail(".evda/version", e.code);
97
+ return finish(e.exit ?? 2);
98
+ }
99
+ ok(1, "version file", `.evda/version = ${version.onDisk}, matches evda.json${version.newerThanTool ? ` (WARN: newer than this tool's ${SCHEMA_VERSION}; operating known layers only, everything else preserved)` : ""}`);
100
+ if (version.newerThanTool) warn("evda.json", `schema ${manifest.evda} is newer than this tool (${SCHEMA_VERSION}). Nothing unknown will be touched.`);
101
+
102
+ // 3. layers against mapping. Two records of one fact, so they must agree.
103
+ const evda = { dir, manifest, version };
104
+ let roles;
105
+ try {
106
+ roles = resolveAll(evda);
107
+ } catch (e) {
108
+ out(` ${e.message}`);
109
+ fail("evda.json mapping", e.code);
110
+ return finish(2);
111
+ }
112
+ for (const layer of manifest.layers) {
113
+ if (!KNOWN_LAYERS.includes(layer)) { warn("evda.json layers", `"${layer}" is not a layer this tool knows. Preserved, not validated.`); continue; }
114
+ const mapped = rolesForLayer(layer).filter((r) => manifest.mapping[r] !== undefined);
115
+ if (mapped.length === 0) fail("evda.json layers", `layer "${layer}" is declared but none of its roles (${rolesForLayer(layer).join(", ")}) is in the mapping.`);
116
+ }
117
+ for (const r of roles) {
118
+ if (!r.mapped) continue;
119
+ if (!r.known) { warn("evda.json mapping", `role "${r.role}" is not one this tool knows. Preserved, not validated.`); continue; }
120
+ if (r.layer && !manifest.layers.includes(r.layer)) fail("evda.json mapping", `role "${r.role}" is mapped but its layer "${r.layer}" is not declared in layers.`);
121
+ }
122
+ if (manifest.layers.length) ok(manifest.layers.length, "layers", manifest.layers.join(", "));
123
+ else fail("evda.json layers", "no layers declared. An EVDA with no layers is a directory with a manifest in it.");
124
+
125
+ // 4. roles. A mapped path that does not exist is loud. An absent role is stated.
126
+ const mappedKnown = roles.filter((r) => r.mapped && r.known);
127
+ for (const r of roles) {
128
+ if (!r.known) continue;
129
+ if (!r.mapped) { out(` ${r.role.padEnd(16)} absent (not in mapping)`); continue; }
130
+ if (r.kind === "file") {
131
+ if (!r.exists) { fail(`role ${r.role}`, `is mapped to ${r.rel} but that file does not exist.`); continue; }
132
+ if (r.isDir) { fail(`role ${r.role}`, `is mapped to ${r.rel} which is a directory, not a file.`); continue; }
133
+ out(` ${r.role.padEnd(16)} -> ${r.rel} present`);
134
+ continue;
135
+ }
136
+ if (!r.exists) { fail(`role ${r.role}`, `is mapped to ${r.rel} but that directory does not exist. An empty directory and a missing one are different facts.`); continue; }
137
+ if (!r.isDir) { fail(`role ${r.role}`, `is mapped to ${r.rel} which is a file, not a directory.`); continue; }
138
+ let detail = "";
139
+ if (r.file) detail = r.fileExists ? `${r.fileRel} present` : `${r.fileRel} MISSING`;
140
+ else detail = `${countMd(r.abs)} md file(s)`;
141
+ out(` ${r.role.padEnd(16)} -> ${r.rel} ${detail}`);
142
+ if (r.file && !r.fileExists) fail(`role ${r.role}`, `${r.fileRel} does not exist. The ledger role is mapped, so its file must be there.`);
143
+ }
144
+ ok(mappedKnown.length, "roles", `${mappedKnown.length} mapped, ${roles.filter((r) => r.known && !r.mapped).length} absent`);
145
+
146
+ // 5. operating layer, a mapped role like the others. Only a record with none is a failure.
147
+ const agentsRole = resolveRole(evda, "agents");
148
+ let fence = null;
149
+ if (!agentsRole.mapped) {
150
+ fail("evda.json mapping", 'has no "agents" role. A record without its operating layer is not installable; add "agents": "<the file agents read>" (AGENTS.md, CLAUDE.md, or wherever it lives).');
151
+ } else if (!agentsRole.exists || agentsRole.isDir) {
152
+ skip("operating layer", "agents role did not resolve to a file (already failed above)");
153
+ } else {
154
+ const rel = agentsRole.rel;
155
+ const agents = readFileSync(agentsRole.abs, "utf8");
156
+ if (agents.trim() === "") fail(rel, "is empty.");
157
+ else {
158
+ if (!/ledger/i.test(agents)) warn(rel, "never mentions the ledger. An operating layer that does not name the loop cannot operate it.");
159
+ if (!/hypothes/i.test(agents)) warn(rel, "never mentions hypotheses.");
160
+ try {
161
+ fence = splitFence(agents);
162
+ } catch (e) {
163
+ fail(rel, e.message);
164
+ }
165
+ if (fence === null) warn(rel, "has no evda:generated fence. The owner has taken the whole file; `evda upgrade` has nothing it owns to replace.");
166
+ if (!/## What I have been taught/.test(agents)) warn(rel, 'has no "## What I have been taught" section, so a correction has nowhere to land.');
167
+ }
168
+ // Claude Code reads CLAUDE.md and nothing else. If the operating layer is elsewhere, CLAUDE.md
169
+ // has to import it or a Claude Code session here starts with no instructions.
170
+ if (rel !== "CLAUDE.md") {
171
+ const claudePath = join(dir, "CLAUDE.md");
172
+ const imports = existsSync(claudePath) && new RegExp(`^@${rel.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*$`, "m").test(readFileSync(claudePath, "utf8"));
173
+ if (!imports) warn("CLAUDE.md", `does not import ${rel}. Claude Code loads CLAUDE.md and not ${rel}, so a Claude Code session here would start with no operating layer.`);
174
+ }
175
+ ok(1, "operating layer", rel);
176
+ }
177
+
178
+ // 6. record files
179
+ const recordRoles = mappedKnown.filter((r) => r.layer === "record" && r.exists && r.isDir);
180
+ if (recordRoles.length === 0) {
181
+ skip("record files", manifest.layers.includes("record") ? "no record role resolved to a directory" : "no record layer declared");
182
+ } else {
183
+ let total = 0;
184
+ let undated = 0;
185
+ for (const r of recordRoles) {
186
+ const files = mdFiles(r.abs);
187
+ total += files.length;
188
+ if (r.role === "record/daily") {
189
+ const bad = readdirSync(r.abs).filter((n) => n.endsWith(".md") && !DAILY_NAME.test(n));
190
+ if (bad.length) warn(r.rel, `${bad.length} file(s) not named YYYY-MM-DD.md, invisible to the occurrence index: ${bad.slice(0, 5).join(", ")}${bad.length > 5 ? ", ..." : ""}`);
191
+ }
192
+ for (const p of files) {
193
+ if (!fileDate(p, readFileSync(p, "utf8"))) undated += 1;
194
+ }
195
+ }
196
+ if (total === 0) skip("record files", `every mapped record directory is empty (${recordRoles.map((r) => r.rel).join(", ")})`);
197
+ else ok(total, "record files", `across ${recordRoles.length} role(s)${undated ? `, ${undated} undated (no date in filename or frontmatter)` : ""}`);
198
+ }
199
+
200
+ // 7. ledger
201
+ let parsed = null;
202
+ let ledgerRel = null;
203
+ const ledgerRole = resolveRole(evda, "ledger");
204
+ if (!ledgerRole.mapped) {
205
+ skip("hypotheses", "no ledger role in the mapping");
206
+ } else if (!ledgerRole.exists || !ledgerRole.fileExists) {
207
+ skip("hypotheses", "ledger file is missing (already failed above)");
208
+ } else {
209
+ ledgerRel = ledgerRole.fileRel;
210
+ const text = readFileSync(ledgerRole.file, "utf8");
211
+ const heads = countHeads(text);
212
+ parsed = parseLedger(text);
213
+ if (!/^\s{4}### H-\d{4}:/m.test(text)) warn(ledgerRel, "has no entry template at its top. A stranger opening this file has no shape to write to.");
214
+ if (heads === 0) {
215
+ skip("hypotheses", `${ledgerRel} has no entries yet`);
216
+ } else if (parsed.entries.length === 0) {
217
+ fail(ledgerRel, `has ${heads} entry heading(s) but the parser found 0 entries. The parser is broken, not the ledger.`);
218
+ } else {
219
+ for (const p of validateEntries(parsed, { root: dir })) {
220
+ (p.level === "fail" ? fail : warn)(`${ledgerRel}:${p.line}`, p.message);
221
+ }
222
+ ok(parsed.entries.length, "hypotheses", `${ledgerRel}: ${summarizeStatus(parsed)}`);
223
+ }
224
+ }
225
+
226
+ // 8. temporal index
227
+ if (!ledgerRole.mapped || !parsed) {
228
+ skip("temporal index", "no ledger to derive it from");
229
+ } else {
230
+ const expected = deriveDue(parsed, ledgerRel);
231
+ const actual = currentIndex(evda);
232
+ const rows = expected ? expected.trimEnd().split("\n").length : 0;
233
+ if (actual === null) warn(".evda/due.jsonl", `missing. Derived, so nothing is lost; run \`evda reindex\` (${rows} row(s) would be written).`);
234
+ else if (actual !== expected) warn(".evda/due.jsonl", `is stale against the ledger. Run \`evda reindex\`. Nothing is lost; it is derived.`);
235
+ ok(1, "temporal index", actual === null ? "absent" : actual === expected ? `.evda/due.jsonl matches the ledger (${rows} due)` : "stale");
236
+ }
237
+
238
+ // 9. digest, derived from the fence. Budgeted.
239
+ if (!fence) {
240
+ skip("digest", "the operating layer has no generated fence to derive it from");
241
+ } else {
242
+ const expected = digestFrom(fence.generated);
243
+ const bytes = digestBytes(expected);
244
+ if (bytes > DIGEST_BUDGET) fail(".evda/digest.md", `would be ${bytes} bytes, over the ${DIGEST_BUDGET} byte budget.`);
245
+ const actual = currentDigest(evda);
246
+ if (actual === null) warn(".evda/digest.md", "missing. Derived; run `evda reindex`.");
247
+ else if (actual !== expected) warn(".evda/digest.md", `is stale against ${agentsRole.rel}. Run \`evda reindex\`.`);
248
+ ok(1, "digest", `${bytes}/${DIGEST_BUDGET} bytes${actual === expected ? `, matches ${agentsRole.rel}` : ""}`);
249
+ }
250
+
251
+ // 10. circles, each an eco declared by its own eco.yaml (HQ-1162, HQ-1165). A directory under
252
+ // the role with no manifest is a failure; a public circle is readable, a private one is declared
253
+ // only; a circle still on circle.json is read and told to move.
254
+ const circlesRole = resolveRole(evda, "circles");
255
+ if (!circlesRole.mapped) {
256
+ skip("circles", "no circles role in the mapping");
257
+ } else if (!circlesRole.exists || !circlesRole.isDir) {
258
+ skip("circles", "circles role did not resolve to a directory (already failed above)");
259
+ } else {
260
+ let circles;
261
+ try {
262
+ circles = readCircles(evda);
263
+ } catch (e) {
264
+ out(` ${e.message}`);
265
+ fail(circlesRole.rel, e.code);
266
+ return finish(e.exit ?? 1);
267
+ }
268
+ for (const c of circles) {
269
+ for (const p of c.problems) (p.level === "fail" ? fail : warn)(`circle ${c.name}`, p.message);
270
+ const members = Object.entries(c.members).map(([n, m]) => `${n} -> ${m.kind === "path" ? m.rel : `table ${m.table}`}`).join(", ");
271
+ const policy = c.policy ? `, policy: ${c.policy.rules.length} rule(s)` : "";
272
+ out(` ${c.name.padEnd(16)} ${c.manifest?.public === true ? "public" : "private"} ${c.readable ? "readable" : "not readable"}${c.source === LEGACY_MANIFEST ? " (circle.json)" : ""}${members ? ` [${members}]` : ""}${policy}`);
273
+ }
274
+ if (circles.length === 0) skip("circles", `${circlesRole.rel} holds no circle directories`);
275
+ else ok(circles.length, "circles", `${circles.filter((c) => c.readable).length} public and readable, ${circles.filter((c) => c.manifest?.public === false).length} private, ${circles.filter((c) => c.policy).length} with a policy${circles.some((c) => c.source === LEGACY_MANIFEST) ? `, ${circles.filter((c) => c.source === LEGACY_MANIFEST).length} still on circle.json` : ""}`);
276
+
277
+ // 11. every entry resolves to exactly one circle (Spec 07, section 3). Zero is an entry nobody
278
+ // can say who may read; two is an entry with two access answers, which an encryption boundary
279
+ // cannot have.
280
+ if (circles.length > 0) {
281
+ const entries = [];
282
+ for (const r of mappedKnown.filter((x) => x.layer === "record" && x.exists && x.isDir)) entries.push(...mdFiles(r.abs));
283
+ if (ledgerRole.mapped && ledgerRole.fileExists) entries.push(ledgerRole.file);
284
+ if (entries.length === 0) {
285
+ skip("entries across circles", "the record has no entries to cover");
286
+ } else {
287
+ const cov = coverage(evda, circles, entries);
288
+ const none = cov.filter((c) => c.circles.length === 0);
289
+ const many = cov.filter((c) => c.circles.length > 1);
290
+ const show = (list) => list.slice(0, 5).map((c) => relative(dir, c.abs)).join(", ") + (list.length > 5 ? ", ..." : "");
291
+ if (none.length) fail("circles", `${none.length} entr${none.length === 1 ? "y" : "ies"} no circle covers: ${show(none)}. Every entry resolves to exactly one circle; a private circle whose members cover the rest of the record is the usual answer.`);
292
+ if (many.length) fail("circles", `${many.length} entr${many.length === 1 ? "y" : "ies"} covered by more than one circle: ${many.slice(0, 5).map((c) => `${relative(dir, c.abs)} (${c.circles.join(", ")})`).join(", ")}${many.length > 5 ? ", ..." : ""}. One access answer per entry.`);
293
+ ok(entries.length, "entries", `across ${circles.length} circles, ${none.length} uncovered, ${many.length} covered twice`);
294
+ }
295
+ }
296
+ }
297
+
298
+ // 11b. grants (Spec 00 #65): who may read each circle, one substrate member tuple per file under
299
+ // the grants role. A grant on a circle that does not exist, or one that gives an agent more than
300
+ // viewer, fails; a grant on a public circle is a warning, since it changes nothing.
301
+ const grantsRole = resolveRole(evda, "grants");
302
+ if (!grantsRole.mapped) {
303
+ skip("grants", "no grants role in the mapping");
304
+ } else if (!grantsRole.exists || !grantsRole.isDir) {
305
+ skip("grants", "grants role did not resolve to a directory (already failed above)");
306
+ } else {
307
+ const grants = readGrants(evda);
308
+ const known = circlesRole.mapped && circlesRole.exists && circlesRole.isDir ? readCircles(evda) ?? [] : [];
309
+ const circleNames = new Set(known.map((c) => c.name));
310
+ const publicNames = new Set(known.filter((c) => c.manifest?.public === true).map((c) => c.name));
311
+ for (const g of grants) {
312
+ for (const p of g.problems) (p.level === "fail" ? fail : warn)("grant", p.message);
313
+ if (g.circle && !circleNames.has(g.circle)) fail("grant", `${g.rel} grants circle "${g.circle}", and this record has no such circle.`);
314
+ else if (g.circle && publicNames.has(g.circle)) warn("grant", `${g.rel} grants circle "${g.circle}", which is public; the grant changes nothing.`);
315
+ if (g.principalId) out(` ${g.principalId.padEnd(24)} ${g.role ?? "?"} on ${g.circle ?? "?"} (${g.holder ? g.holder.kind + (g.holder.name ? ` ${g.holder.name}` : "") : "holder unknown"})`);
316
+ }
317
+ if (grants.length === 0) skip("grants", `${grantsRole.rel} holds no grant files`);
318
+ else ok(grants.length, "grants", `across ${new Set(grants.map((g) => g.circle).filter(Boolean)).size} circle(s), ${grants.filter((g) => g.holder?.kind === "agent").length} to agents`);
319
+ }
320
+
321
+ // 12. folder manifests (Spec 07, section 2): each record folder says what its entries are and
322
+ // which connectors feed its references. A manifest that is not JSON refuses the run.
323
+ let folders = [];
324
+ if (recordRoles.length === 0) {
325
+ skip("folder manifests", "no record role resolved to a directory");
326
+ } else {
327
+ try {
328
+ folders = folderManifests(evda);
329
+ } catch (e) {
330
+ out(` ${e.message}`);
331
+ fail(FOLDER_MANIFEST, e.code);
332
+ return finish(e.exit ?? 2);
333
+ }
334
+ for (const f of folders) {
335
+ for (const p of f.problems) (p.level === "fail" ? fail : warn)(`folder ${relative(dir, f.dir) || "."}`, p.message);
336
+ out(` ${(relative(dir, f.dir) || ".").padEnd(24)} type ${f.type ?? "?"} connectors [${f.connectors.join(", ")}] views [${f.views.map((v) => v.view).join(", ")}]`);
337
+ }
338
+ if (folders.length === 0) skip("folder manifests", `no record folder carries ${FOLDER_MANIFEST}`);
339
+ else ok(folders.length, "folder manifests", `${folders.filter((f) => f.connectors.length).length} with connectors`);
340
+
341
+ // 12b. views (Spec 07, section 5): a manifest's `views` declare the markdown files the renderer
342
+ // (@evda/views, not this package) writes over the folder's entries. Doctor checks what it can
343
+ // without the renderer: the path stays inside the record, the file is there, and its header
344
+ // names the same view. What the header says beyond the name is the renderer's to refuse.
345
+ let views = 0;
346
+ for (const f of folders) {
347
+ const where = `folder ${relative(dir, f.dir) || "."}`;
348
+ for (const v of f.views) {
349
+ views += 1;
350
+ const abs = resolve(dir, v.path);
351
+ if (abs !== dir && !abs.startsWith(dir + sep)) { fail(where, `view "${v.view}" names ${v.path}, which is outside the record. A view's path is relative to the record root.`); continue; }
352
+ if (!existsSync(abs) || !statSync(abs).isFile()) { fail(where, `view "${v.view}" names ${v.path}, and no file is there. The renderer writes it; until then the declaration promises a file the record does not hold.`); continue; }
353
+ const named = viewNamedIn(readFileSync(abs, "utf8"));
354
+ if (named === null) fail(where, `${v.path} is declared as view "${v.view}" but does not open with a header naming its query (---, view: <name>, ---).`);
355
+ else if (named !== v.view) fail(where, `${v.path} is declared as view "${v.view}" but its header says view: ${named}. The header and the declaration must agree.`);
356
+ }
357
+ }
358
+ if (views === 0) skip("views", "no folder manifest declares a view");
359
+ else ok(views, "views", `across ${folders.filter((f) => f.views.length).length} folder(s)`);
360
+ }
361
+
362
+ // 13. references (Spec 07, section 4): a pointer, a label and a date, never the payload, never
363
+ // a secret, and only through a connector the folder declares.
364
+ if (recordRoles.length === 0) {
365
+ skip("references", "no record role resolved to a directory");
366
+ } else {
367
+ let refs = 0;
368
+ let refused = 0;
369
+ const files = new Set();
370
+ for (const r of recordRoles) {
371
+ for (const p of mdFiles(r.abs)) {
372
+ const rel = relative(dir, p);
373
+ const found = referencesIn(readFileSync(p, "utf8"), rel);
374
+ for (const pr of found.problems) { refused += 1; fail(rel, pr.message); }
375
+ if (found.references.length === 0 && found.problems.length === 0) continue;
376
+ files.add(rel);
377
+ refs += found.references.length + found.problems.length;
378
+ const folder = folderFor(p, folders);
379
+ for (const ref of found.references) {
380
+ if (!folder) fail(rel, `references ${ref.ref} but no folder above it carries ${FOLDER_MANIFEST}. A reference resolves through a connector the folder declares; say what the folder holds and which connectors feed it.`);
381
+ else if (!folder.connectors.includes(ref.scheme)) fail(rel, `references ${ref.ref} through ${ref.scheme}, which ${folder.rel} does not declare (it declares: ${folder.connectors.join(", ") || "none"}).`);
382
+ }
383
+ }
384
+ }
385
+ if (refs === 0) skip("references", "no record file carries a references list");
386
+ else ok(refs, "references", `across ${files.size} file(s)${refused ? `, ${refused} refused` : ""}`);
387
+ }
388
+
389
+ return finish(0);
390
+
391
+ function finish(refusedExit) {
392
+ if (refusedExit === 2) {
393
+ out(`REFUSED nothing was written`);
394
+ return { failures, warnings, checked, exit: 2 };
395
+ }
396
+ const real = checked.filter((c) => c.n > 0);
397
+ if (real.length === 0) {
398
+ fail("doctor", "printed no CHECKED line with n > 0. A checker that examined nothing is not a checker.");
399
+ }
400
+ if (failures.length) {
401
+ out(`FAILED ${failures.length} failure(s), ${warnings.length} warning(s), ${real.length} check(s) with something in them`);
402
+ return { failures, warnings, checked, exit: 1 };
403
+ }
404
+ out(`OK ${real.length} check(s), ${warnings.length} warning(s)`);
405
+ return { failures, warnings, checked, exit: 0 };
406
+ }
407
+ }
408
+
409
+ function countMd(dir) {
410
+ try { return mdFiles(dir).length; } catch { return 0; }
411
+ }
412
+
413
+ function summarizeStatus(parsed) {
414
+ const counts = {};
415
+ for (const e of parsed.entries) counts[e.statusWord || "?"] = (counts[e.statusWord || "?"] ?? 0) + 1;
416
+ return Object.entries(counts).map(([k, v]) => `${v} ${k}`).join(", ");
417
+ }
418
+
419
+ export { EvdaError };
package/lib/errors.mjs ADDED
@@ -0,0 +1,26 @@
1
+ // One error type for the whole data layer. `exit` is the process exit code a CLI should use:
2
+ // 1 something is wrong with the record and the tool said what
3
+ // 2 the tool REFUSED to operate (version mismatch, no manifest, escaped path). Nothing was written.
4
+ export class EvdaError extends Error {
5
+ constructor(message, { code = "EVDA", exit = 1 } = {}) {
6
+ super(message);
7
+ this.name = "EvdaError";
8
+ this.code = code;
9
+ this.exit = exit;
10
+ }
11
+ }
12
+
13
+ // Is `argv1` this module's file, the way node was actually invoked? Through realpath on BOTH
14
+ // sides, because an installed bin is a symlink (node_modules/.bin/evda -> ../create-evda/bin/
15
+ // evda.mjs) and path.resolve() does not follow symlinks. Comparing resolve(argv[1]) to the
16
+ // module URL is how the first clean-machine `npx create-evda` exited 0 having written nothing.
17
+ // `moduleUrl` is the caller's import.meta.url.
18
+ import { realpathSync } from "node:fs";
19
+ import { fileURLToPath } from "node:url";
20
+ export function isEntry(argv1, moduleUrl) {
21
+ try {
22
+ return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
@@ -0,0 +1,160 @@
1
+ // folder.evda.json: what a folder's entries are (Spec 07, section 2).
2
+ //
3
+ // {
4
+ // "type": "person",
5
+ // "connectors": ["contacts"],
6
+ // "views": [{ "view": "everyone", "of": "person", "in": "people.eco", "path": "record/people/Everyone.md" }]
7
+ // }
8
+ //
9
+ // `type` is what the entries are, one of the Spec 07 types. `connectors` are the reference schemes
10
+ // the folder's entries may point through; the resolver registry reads this list and skips a scheme
11
+ // the folder does not name. `views` are the folder's view declarations (Spec 07, section 5), one
12
+ // object each: `view` (its name), `of` (the entity kind it renders), `in` (the eco the entries live
13
+ // in), `path` (the markdown file it renders to, relative to the record root), and optionally
14
+ // `status` (keep only these) and `group`. The renderer is `@evda/views`, not this package, so this
15
+ // package checks the shape and leaves what the values mean to the renderer. The manifest never
16
+ // names a circle: a circle declares its own members by path, and a second copy would drift.
17
+ // Nothing moves: the manifest sits in the folder it describes, and a folder without one is a
18
+ // folder that has not said what it holds.
19
+ //
20
+ // A file inherits the nearest manifest above it, within the record. An entry that carries a
21
+ // reference must sit under a manifest, and that manifest must declare the reference's scheme.
22
+
23
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
24
+ import { join, relative, resolve, sep } from "node:path";
25
+ import { resolveAll } from "./roles.mjs";
26
+ import { SCHEMES } from "./references.mjs";
27
+ import { EvdaError } from "./errors.mjs";
28
+
29
+ export const FOLDER_MANIFEST = "folder.evda.json";
30
+ export const FOLDER_TYPES = ["record", "person", "project", "course", "source", "concept", "event", "log", "ticket", "capture", "piece", "blob", "rule", "hypothesis"];
31
+ export const FOLDER_KEYS = ["type", "connectors", "views"];
32
+ export const VIEW_KEYS_REQUIRED = ["view", "of", "in", "path"];
33
+ export const VIEW_KEYS = [...VIEW_KEYS_REQUIRED, "status", "group"];
34
+
35
+ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
36
+ const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
37
+
38
+ // The view a markdown file's header names, or null when the file does not open with a header
39
+ // (`---`, `view: <name>` among the lines, `---`). This is the one thing doctor reads off a view
40
+ // file; the rest of the header is the renderer's.
41
+ export function viewNamedIn(text) {
42
+ const lines = text.split("\n");
43
+ if (lines[0] !== "---") return null;
44
+ let name = null;
45
+ for (let i = 1; i < lines.length; i++) {
46
+ if (lines[i] === "---") return name;
47
+ const m = /^view:\s*(.+?)\s*$/.exec(lines[i]);
48
+ if (m) name = m[1];
49
+ }
50
+ return null;
51
+ }
52
+
53
+ // Read one folder's manifest. null when the folder has none. Throws when the file is present and
54
+ // not JSON, because a manifest that cannot be read is worse than none.
55
+ export function readFolderManifest(dirAbs) {
56
+ const p = join(dirAbs, FOLDER_MANIFEST);
57
+ if (!existsSync(p)) return null;
58
+ try {
59
+ return JSON.parse(readFileSync(p, "utf8"));
60
+ } catch (e) {
61
+ throw new EvdaError(`REFUSED: ${p} is not valid JSON (${e.message}).`, { code: "BAD_FOLDER", exit: 2 });
62
+ }
63
+ }
64
+
65
+ // Validate a manifest. Returns { manifest, type, connectors, views, problems }; never throws for a
66
+ // problem in the content, so doctor can list them all.
67
+ export function validateFolderManifest(m, rel) {
68
+ const problems = [];
69
+ const fail = (msg) => problems.push({ level: "fail", message: `${rel}: ${msg}` });
70
+ const warn = (msg) => problems.push({ level: "warn", message: `${rel}: ${msg}` });
71
+ const out = { manifest: m, type: null, connectors: [], views: [], problems };
72
+ if (!isObject(m)) { fail("must hold a JSON object."); return out; }
73
+ if (!nonEmpty(m.type)) fail(`has no "type". Say what the entries are: ${FOLDER_TYPES.join(", ")}.`);
74
+ else {
75
+ out.type = m.type.trim();
76
+ if (!FOLDER_TYPES.includes(out.type)) warn(`type "${out.type}" is not one this tool knows (${FOLDER_TYPES.join(", ")}). Preserved; nothing renders it.`);
77
+ }
78
+ if (m.connectors !== undefined) {
79
+ if (!Array.isArray(m.connectors) || !m.connectors.every(nonEmpty)) fail(`"connectors" must be a list of scheme names.`);
80
+ else {
81
+ for (const c of m.connectors) {
82
+ if (!SCHEMES.includes(c)) fail(`connector "${c}" is not a reference scheme this tool can resolve (${SCHEMES.join(", ")}). A connector nothing resolves is a promise nothing keeps.`);
83
+ }
84
+ out.connectors = [...new Set(m.connectors)];
85
+ if (out.connectors.length !== m.connectors.length) warn(`"connectors" lists a scheme twice.`);
86
+ }
87
+ }
88
+ if (m.views !== undefined) {
89
+ if (!Array.isArray(m.views)) fail(`"views" must be a list of view declarations.`);
90
+ else {
91
+ const names = new Set();
92
+ m.views.forEach((v, i) => {
93
+ const at = `views[${i}]`;
94
+ if (!isObject(v)) { fail(`${at} must be a view declaration: { view, of, in, path, status?, group? }, not ${JSON.stringify(v)}.`); return; }
95
+ const missing = VIEW_KEYS_REQUIRED.filter((k) => !nonEmpty(v[k]));
96
+ if (missing.length) fail(`${at} has no "${missing.join('", "')}". A view declares its name (view), what it renders (of), which eco (in), and the file it renders to (path).`);
97
+ if (v.status !== undefined && (!Array.isArray(v.status) || !v.status.every(nonEmpty))) fail(`${at} "status" must be a list of status words.`);
98
+ if (v.group !== undefined && !nonEmpty(v.group)) fail(`${at} "group" must be a word.`);
99
+ const unknown = Object.keys(v).filter((k) => !VIEW_KEYS.includes(k));
100
+ if (unknown.length) warn(`${at} carries ${unknown.length} key(s) this tool does not know, preserved: ${unknown.join(", ")}.`);
101
+ if (nonEmpty(v.view)) {
102
+ if (names.has(v.view)) fail(`declares view "${v.view}" twice.`);
103
+ names.add(v.view);
104
+ }
105
+ if (!missing.length) out.views.push(v);
106
+ });
107
+ }
108
+ }
109
+ if (m.circle !== undefined || m.circles !== undefined) fail(`names a circle. A circle declares its own members by path (eco.yaml); a second copy here would drift.`);
110
+ const unknown = Object.keys(m).filter((k) => !FOLDER_KEYS.includes(k));
111
+ if (unknown.length) warn(`${unknown.length} unknown key(s) preserved, not validated: ${unknown.join(", ")}.`);
112
+ return out;
113
+ }
114
+
115
+ // Every folder manifest under the record's record roles, deepest last. Each item is
116
+ // { rel, abs, dir, ...validateFolderManifest }. Throws only on a manifest that is not JSON.
117
+ export function folderManifests(evda) {
118
+ const out = [];
119
+ const roots = resolveAll(evda).filter((r) => r.known && r.mapped && r.layer === "record" && r.exists && r.isDir);
120
+ const walk = (dir) => {
121
+ const m = readFolderManifest(dir);
122
+ if (m !== null) {
123
+ const abs = join(dir, FOLDER_MANIFEST);
124
+ out.push({ rel: relative(evda.dir, abs), abs, dir, ...validateFolderManifest(m, relative(evda.dir, abs)) });
125
+ }
126
+ for (const name of readdirSync(dir).sort()) {
127
+ if (name.startsWith(".")) continue;
128
+ const p = join(dir, name);
129
+ if (statSync(p).isDirectory()) walk(p);
130
+ }
131
+ };
132
+ for (const r of roots) walk(r.abs);
133
+ return out;
134
+ }
135
+
136
+ // The manifest that governs a file: the nearest one in an ancestor directory, or null.
137
+ export function folderFor(fileAbs, manifests) {
138
+ let best = null;
139
+ for (const m of manifests) {
140
+ if (fileAbs === m.dir || !fileAbs.startsWith(m.dir + sep)) continue;
141
+ if (!best || m.dir.length > best.dir.length) best = m;
142
+ }
143
+ return best;
144
+ }
145
+
146
+ // Write a manifest into a folder inside the record. Refuses a folder outside the record, an
147
+ // unknown connector, and an existing manifest unless told to overwrite.
148
+ export function writeFolderManifest(evda, dir, { type, connectors = [], views = [] }, { overwrite = false } = {}) {
149
+ const abs = resolve(evda.dir, dir);
150
+ if (abs !== evda.dir && !abs.startsWith(evda.dir + sep)) throw new EvdaError(`REFUSED: ${dir} is outside the record.`, { code: "BAD_FOLDER", exit: 2 });
151
+ if (!existsSync(abs) || !statSync(abs).isDirectory()) throw new EvdaError(`${dir} is not a directory in the record.`, { code: "BAD_FOLDER", exit: 2 });
152
+ const m = { type, connectors, views };
153
+ const v = validateFolderManifest(m, relative(evda.dir, join(abs, FOLDER_MANIFEST)) || FOLDER_MANIFEST);
154
+ const first = v.problems.find((p) => p.level === "fail");
155
+ if (first) throw new EvdaError(`REFUSED: ${first.message}`, { code: "BAD_FOLDER", exit: 2 });
156
+ const p = join(abs, FOLDER_MANIFEST);
157
+ if (existsSync(p) && !overwrite) throw new EvdaError(`${relative(evda.dir, p)} exists; pass overwrite to replace it.`, { exit: 2 });
158
+ writeFileSync(p, JSON.stringify(m, null, 2) + "\n");
159
+ return p;
160
+ }