create-evda 0.1.5 → 0.1.6
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/CHANGELOG.md +100 -0
- package/README.md +74 -7
- package/bin/evda.mjs +391 -6
- package/create-evda.mjs +54 -10
- package/docs/local-model.md +34 -0
- package/hooks/evda-read-guard.mjs +7 -0
- package/lib/chat-export.mjs +198 -0
- package/lib/circle-plan.mjs +277 -0
- package/lib/circles.mjs +479 -28
- package/lib/connect.mjs +46 -0
- package/lib/day-file.mjs +93 -0
- package/lib/declarations.mjs +235 -0
- package/lib/devices.mjs +202 -0
- package/lib/doctor.mjs +346 -13
- package/lib/filechanges.mjs +622 -0
- package/lib/gather-apply.mjs +635 -0
- package/lib/gather.mjs +808 -0
- package/lib/grants.mjs +7 -1
- package/lib/guard.mjs +132 -0
- package/lib/hooks.mjs +124 -0
- package/lib/keep-hook.mjs +185 -0
- package/lib/kinds.mjs +23 -6
- package/lib/kinds.schema.json +2 -1
- package/lib/manifest.mjs +14 -1
- package/lib/mcp-auth.mjs +370 -0
- package/lib/mcp-http.mjs +336 -0
- package/lib/mcp.mjs +425 -36
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +58 -7
- package/lib/prompt-hook.mjs +88 -0
- package/lib/publish.mjs +264 -0
- package/lib/readers.mjs +319 -0
- package/lib/records.mjs +52 -0
- package/lib/references.mjs +5 -51
- package/lib/render.mjs +158 -0
- package/lib/roles.mjs +102 -1
- package/lib/secret-shape.mjs +58 -0
- package/lib/setup.mjs +118 -0
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +118 -27
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +24 -15
- package/presets/journal/preset.json +9 -1
- package/presets/projects-and-areas/preset.json +16 -9
- package/skills/evda/SKILL.md +58 -0
- package/templates/AGENTS.md +118 -14
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +30 -6
- package/LICENSE +0 -21
- package/presets/student/preset.json +0 -13
package/lib/readers.mjs
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// Who may read each part of a record, and the entries no AI reads (HQ-1374).
|
|
2
|
+
//
|
|
3
|
+
// Two facts, both written in the record where the owner can read them:
|
|
4
|
+
//
|
|
5
|
+
// read_by on a circle's eco.yaml, or on evda.json for the whole record: which AI reads that part.
|
|
6
|
+
// The default for a new record is the AI the owner chose at setup (create-evda writes it
|
|
7
|
+
// into evda.json), because most people have no local model and the barrier to using this
|
|
8
|
+
// has to be as low as it can be. Restricting it is opt-in. A circle without its own
|
|
9
|
+
// read_by takes evda.json's; neither saying anything means the record states no reader,
|
|
10
|
+
// and whatever AI opens it reads what it opens.
|
|
11
|
+
//
|
|
12
|
+
// read_by: claude one AI, by its setup key
|
|
13
|
+
// read_by: local only a model on the owner's own devices
|
|
14
|
+
// read_by: none no AI; the owner reads it on their devices
|
|
15
|
+
// read_by: a list, each a key or one of these mappings:
|
|
16
|
+
// - ai: claude an AI by its setup key (lib/setup.mjs PLATFORMS)
|
|
17
|
+
// model: <name> optional, the model when it matters
|
|
18
|
+
// terms: consumer | api | business optional, which of the company's terms apply
|
|
19
|
+
// training: off | on | unknown what the owner confirmed
|
|
20
|
+
// confirmed: YYYY-MM-DD when they confirmed it
|
|
21
|
+
// - local: <model name, or any> a model running on the owner's own hardware
|
|
22
|
+
// on: <which devices> optional, in the owner's words
|
|
23
|
+
//
|
|
24
|
+
// Printed the way Spec 06 "The call on phrasing" rules: a statement of who reads it,
|
|
25
|
+
// never a promise that nobody can. "Read by: Anthropic through Claude, training off".
|
|
26
|
+
//
|
|
27
|
+
// noted `noted: true` in one entry's frontmatter: the owner said "just note this". It stays on
|
|
28
|
+
// their devices and in their own search, and no AI reads it until they say it can, which
|
|
29
|
+
// clears the flag (releaseNote below). It is not a circle and not a share mark, and a mark
|
|
30
|
+
// never releases it (lib/circles.mjs released()).
|
|
31
|
+
//
|
|
32
|
+
// sealed `sealed: true` in one entry's frontmatter, or a fenced ```sealed block inside one
|
|
33
|
+
// (HQ-1220): nothing but the owner's own devices opens it, ever. Stronger than noted: it
|
|
34
|
+
// is refused to every caller, a local model through the MCP included, no tool releases
|
|
35
|
+
// it, and an AI is never told where it is or whether a search touched it. A sealed
|
|
36
|
+
// section is taken out of the file the AI is shown and replaced by one line saying
|
|
37
|
+
// something sealed is there. The helpers live in lib/circles.mjs beside NOTED.
|
|
38
|
+
//
|
|
39
|
+
// Nothing here is a lock. The words are plain files on the owner's disk, and an agent that ignores
|
|
40
|
+
// its instructions can open them. What this module gives is the answer every well-behaved reader
|
|
41
|
+
// asks before it reads: the MCP server refuses in code, the Claude Code hook (hooks/) refuses the
|
|
42
|
+
// ordinary file tools, and AGENTS.md tells any other agent to check and decline out loud.
|
|
43
|
+
|
|
44
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
45
|
+
import { join, relative, resolve, sep } from "node:path";
|
|
46
|
+
import { readCircles, homesOf, frontmatterOf, NOTED, READ_BY, SEALED, sealedFront, sealedSections, withoutSealed } from "./circles.mjs";
|
|
47
|
+
import { resolveRole } from "./roles.mjs";
|
|
48
|
+
import { mdFiles } from "./record.mjs";
|
|
49
|
+
import { parseYaml } from "./yaml.mjs";
|
|
50
|
+
import { EvdaError } from "./errors.mjs";
|
|
51
|
+
import { PLATFORMS, PLATFORM_KEYS } from "./setup.mjs";
|
|
52
|
+
|
|
53
|
+
export { NOTED, READ_BY, SEALED, withoutSealed };
|
|
54
|
+
|
|
55
|
+
// What an AI is shown where a sealed section was, and what it is told about sealed entries it was
|
|
56
|
+
// not shown. Neither names a path, a count of matches, or anything that depends on the words.
|
|
57
|
+
export const SEALED_PLACEHOLDER = "[sealed: only the owner opens this part, on their own devices]";
|
|
58
|
+
export const SEALED_WHY = "It is sealed: only its owner opens it, on their own devices. No AI reads it, and no tool releases it.";
|
|
59
|
+
export const SEALED_HELD = "Some of this record is sealed: only its owner opens it, on their own devices. No AI is shown it or told where it is; the owner's own search on their device (evda search) covers it.";
|
|
60
|
+
|
|
61
|
+
export const TERMS = ["consumer", "api", "business"];
|
|
62
|
+
export const TRAINING = ["off", "on", "unknown"];
|
|
63
|
+
const COMPANY = { claude: "Anthropic", chatgpt: "OpenAI", gemini: "Google", cursor: "Cursor", other: null };
|
|
64
|
+
const APP = { claude: "Claude", chatgpt: "ChatGPT or Codex", gemini: "Gemini", cursor: "Cursor", other: null };
|
|
65
|
+
const TERMS_WORDS = { consumer: "personal-plan terms", api: "API terms", business: "business terms" };
|
|
66
|
+
const READER_KEYS = { ai: ["ai", "name", "model", "terms", "training", "confirmed"], local: ["local", "on"] };
|
|
67
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
68
|
+
const nonEmpty = (v) => typeof v === "string" && v.trim() !== "";
|
|
69
|
+
|
|
70
|
+
// The test hooks. Each one breaks exactly one refusal so the tests can be watched failing.
|
|
71
|
+
// EVDA_READERS_FALSIFY=noted-readable a noted entry is treated as readable
|
|
72
|
+
// EVDA_READERS_FALSIFY=ignore-reader a circle's read_by is ignored
|
|
73
|
+
// EVDA_READERS_FALSIFY=accept-any a read_by that does not parse is reported as fine
|
|
74
|
+
const FALSIFY = () => process.env.EVDA_READERS_FALSIFY ?? "";
|
|
75
|
+
|
|
76
|
+
// Parse one read_by value. Returns { readers, problems }: readers is null when the value is absent,
|
|
77
|
+
// otherwise a list of { kind: "ai" | "local" | "none", ... }. Never throws; every problem is a
|
|
78
|
+
// sentence for doctor.
|
|
79
|
+
export function parseReaders(value, where = "read_by") {
|
|
80
|
+
const problems = [];
|
|
81
|
+
if (value === undefined || value === null) return { readers: null, problems };
|
|
82
|
+
const items = Array.isArray(value) ? value : [value];
|
|
83
|
+
if (items.length === 0) { problems.push(`${where} is an empty list. Say who reads it: an AI (claude, chatgpt, gemini, cursor, other), local, or none.`); return { readers: null, problems }; }
|
|
84
|
+
const readers = [];
|
|
85
|
+
items.forEach((item, i) => {
|
|
86
|
+
const at = Array.isArray(value) ? `${where}[${i}]` : where;
|
|
87
|
+
if (typeof item === "string") {
|
|
88
|
+
const s = item.trim().toLowerCase();
|
|
89
|
+
if (s === "none") readers.push({ kind: "none" });
|
|
90
|
+
else if (s === "local") readers.push({ kind: "local", model: null, on: null });
|
|
91
|
+
else if (PLATFORM_KEYS.includes(s)) readers.push({ kind: "ai", ai: s });
|
|
92
|
+
else problems.push(`${at} is ${JSON.stringify(item)}. A reader is one of ${[...PLATFORM_KEYS, "local", "none"].join(", ")}, or a mapping with "ai" or "local".`);
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
if (!isObject(item)) { problems.push(`${at} must be a word or a mapping, got ${JSON.stringify(item)}.`); return; }
|
|
96
|
+
if (item.ai !== undefined && item.local !== undefined) { problems.push(`${at} has both "ai" and "local". One reader per item.`); return; }
|
|
97
|
+
if (item.ai !== undefined) {
|
|
98
|
+
const ai = String(item.ai).trim().toLowerCase();
|
|
99
|
+
if (!PLATFORM_KEYS.includes(ai)) { problems.push(`${at} ai is ${JSON.stringify(item.ai)}; it is one of ${PLATFORM_KEYS.join(", ")}.`); return; }
|
|
100
|
+
if (item.terms !== undefined && !TERMS.includes(item.terms)) problems.push(`${at} terms is ${JSON.stringify(item.terms)}; it is one of ${TERMS.join(", ")}.`);
|
|
101
|
+
if (item.training !== undefined && !TRAINING.includes(item.training)) problems.push(`${at} training is ${JSON.stringify(item.training)}; it is one of ${TRAINING.join(", ")}.`);
|
|
102
|
+
if (item.confirmed !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(item.confirmed))) problems.push(`${at} confirmed is ${JSON.stringify(item.confirmed)}; it is a date, YYYY-MM-DD.`);
|
|
103
|
+
const extra = Object.keys(item).filter((k) => !READER_KEYS.ai.includes(k));
|
|
104
|
+
if (extra.length) problems.push(`${at} has key(s) a reader does not take: ${extra.join(", ")}.`);
|
|
105
|
+
readers.push({ kind: "ai", ai, name: nonEmpty(item.name) ? item.name.trim() : null, model: nonEmpty(item.model) ? item.model.trim() : null, terms: TERMS.includes(item.terms) ? item.terms : null, training: TRAINING.includes(item.training) ? item.training : null, confirmed: item.confirmed ? String(item.confirmed) : null });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (item.local !== undefined) {
|
|
109
|
+
if (!nonEmpty(String(item.local ?? ""))) { problems.push(`${at} local is empty; name the model, or write any.`); return; }
|
|
110
|
+
const extra = Object.keys(item).filter((k) => !READER_KEYS.local.includes(k));
|
|
111
|
+
if (extra.length) problems.push(`${at} has key(s) a local reader does not take: ${extra.join(", ")}.`);
|
|
112
|
+
const model = String(item.local).trim();
|
|
113
|
+
readers.push({ kind: "local", model: model.toLowerCase() === "any" ? null : model, on: nonEmpty(item.on) ? item.on.trim() : null });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
problems.push(`${at} names no reader. Write ai: <${PLATFORM_KEYS.join(" | ")}> or local: <model or any>.`);
|
|
117
|
+
});
|
|
118
|
+
if (readers.some((r) => r.kind === "none") && readers.length > 1) problems.push(`${where} says none and also names a reader. None means no AI reads it; it stands alone.`);
|
|
119
|
+
if (FALSIFY() === "accept-any") return { readers, problems: [] };
|
|
120
|
+
return { readers, problems };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// One reader as the sentence the owner reads. States the fact, never a promise.
|
|
124
|
+
export function describeReader(r) {
|
|
125
|
+
if (r.kind === "none") return "no AI; only you, on your own devices";
|
|
126
|
+
if (r.kind === "local") return `a model on your own devices${r.model ? ` (${r.model})` : ""}${r.on ? `: ${r.on}` : ""}`;
|
|
127
|
+
const company = COMPANY[r.ai];
|
|
128
|
+
const app = r.ai === "other" ? r.name ?? "an AI you named at setup" : APP[r.ai];
|
|
129
|
+
const who = company ? `${company} through ${app}${r.model ? ` (${r.model})` : ""}` : `${app}${r.model ? ` (${r.model})` : ""}`;
|
|
130
|
+
const parts = [who];
|
|
131
|
+
if (r.terms) parts.push(TERMS_WORDS[r.terms]);
|
|
132
|
+
if (r.training === "off") parts.push(`training off${r.confirmed ? `, confirmed ${r.confirmed}` : ""}`);
|
|
133
|
+
else if (r.training === "on") parts.push("training on");
|
|
134
|
+
else parts.push("training not confirmed off");
|
|
135
|
+
return parts.join(", ");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function describeReaders(readers, { isPublic = false } = {}) {
|
|
139
|
+
if (isPublic) return "anyone; it is public";
|
|
140
|
+
if (readers === null) return "not stated; whatever AI opens this record reads what it opens";
|
|
141
|
+
return readers.map(describeReader).join("; ");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Which AI is calling. An MCP client names itself on initialize (clientInfo.name); $EVDA_READER
|
|
145
|
+
// overrides it, and is how a local model's client says it is local. Unknown is { ai: null }.
|
|
146
|
+
export function callerFrom({ clientName = null, env = process.env } = {}) {
|
|
147
|
+
const stated = String(env.EVDA_READER ?? "").trim().toLowerCase();
|
|
148
|
+
if (stated === "local") return { ai: null, local: true, label: "a model on this device" };
|
|
149
|
+
if (stated && PLATFORM_KEYS.includes(stated)) return { ai: stated, local: false, label: APP[stated] ?? stated };
|
|
150
|
+
const n = String(clientName ?? "").toLowerCase();
|
|
151
|
+
const ai = /claude|anthropic/.test(n) ? "claude" : /codex|openai|chatgpt/.test(n) ? "chatgpt" : /gemini|google/.test(n) ? "gemini" : /cursor/.test(n) ? "cursor" : null;
|
|
152
|
+
return { ai, local: false, label: ai ? APP[ai] : clientName ? `a client that calls itself ${clientName}` : "an AI that did not say which it is" };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Everything a run of access checks needs, read once: the circles and evda.json's reader.
|
|
156
|
+
export function readContext(evda) {
|
|
157
|
+
let circles = [];
|
|
158
|
+
try { circles = readCircles(evda) ?? []; } catch { circles = []; }
|
|
159
|
+
const record = parseReaders(evda.manifest?.[READ_BY], "evda.json read_by");
|
|
160
|
+
return { evda, circles, record };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// The reader answer for one file: whose read_by applies, and what it says.
|
|
164
|
+
export function readersFor(ctx, abs) {
|
|
165
|
+
const homes = homesOf(ctx.evda, ctx.circles, abs);
|
|
166
|
+
const home = homes.length === 1 ? ctx.circles.find((c) => c.name === homes[0]) : null;
|
|
167
|
+
if (home?.manifest?.public === true) return { readers: null, source: `circle ${home.name}`, circle: home.name, isPublic: true };
|
|
168
|
+
if (home && home.manifest?.[READ_BY] !== undefined) {
|
|
169
|
+
const own = parseReaders(home.manifest[READ_BY], `circle ${home.name} read_by`);
|
|
170
|
+
return { readers: own.problems.length ? [{ kind: "none" }] : own.readers, source: `circle ${home.name}`, circle: home.name, isPublic: false, broken: own.problems.length > 0 };
|
|
171
|
+
}
|
|
172
|
+
if (ctx.record.readers !== null) return { readers: ctx.record.problems.length ? [{ kind: "none" }] : ctx.record.readers, source: "evda.json", circle: home?.name ?? null, isPublic: false, broken: ctx.record.problems.length > 0 };
|
|
173
|
+
return { readers: null, source: null, circle: home?.name ?? null, isPublic: false };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function frontOf(abs) {
|
|
177
|
+
if (!/\.md$/i.test(abs)) return null;
|
|
178
|
+
try { return frontmatterOf(readFileSync(abs, "utf8")); } catch { return null; }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export const isSealedFile = (abs) => sealedFront(frontOf(abs));
|
|
182
|
+
|
|
183
|
+
// The text an AI may be shown of a file it may read: every sealed section replaced by one line.
|
|
184
|
+
export function aiView(text) {
|
|
185
|
+
return withoutSealed(text, { placeholder: SEALED_PLACEHOLDER });
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const isNotedFile = (abs) => FALSIFY() !== "noted-readable" && frontOf(abs)?.[NOTED] === true;
|
|
189
|
+
|
|
190
|
+
// May this caller read this file? { ok, why, kind } where kind is "noted" or "reader" on a refusal.
|
|
191
|
+
// A reader that is broken (a read_by doctor fails) refuses, because a boundary nobody can read is
|
|
192
|
+
// not one to guess at.
|
|
193
|
+
export function mayRead(ctx, abs, caller) {
|
|
194
|
+
const rel = relative(ctx.evda.dir, abs).split(sep).join("/");
|
|
195
|
+
// Sealed before noted: an entry that is both is refused as the stronger, and no caller is exempt.
|
|
196
|
+
if (isSealedFile(abs)) return { ok: false, kind: "sealed", path: rel, why: `${rel} is sealed: only its owner opens it, on their own devices. No AI reads it, and no tool releases it.` };
|
|
197
|
+
if (isNotedFile(abs)) return { ok: false, kind: "noted", path: rel, why: `${rel} is just noted. No AI reads it until its owner says it can; they can open it themselves, or say you can read it now.` };
|
|
198
|
+
if (FALSIFY() === "ignore-reader") return { ok: true };
|
|
199
|
+
const r = readersFor(ctx, abs);
|
|
200
|
+
if (r.isPublic || r.readers === null) return { ok: true };
|
|
201
|
+
const where = r.source === "evda.json" ? "this record" : `the ${r.circle} circle`;
|
|
202
|
+
const said = describeReaders(r.readers);
|
|
203
|
+
if (r.broken) return { ok: false, kind: "reader", path: rel, why: `${rel} is in ${where}, whose read_by does not parse, so no AI reads it until doctor passes on it.` };
|
|
204
|
+
const allowed = r.readers.some((x) => (x.kind === "local" && caller.local) || (x.kind === "ai" && (x.ai === caller.ai || (x.ai === "other" && caller.ai === null && !caller.local))));
|
|
205
|
+
if (allowed) return { ok: true };
|
|
206
|
+
return { ok: false, kind: "reader", path: rel, why: `${rel} is in ${where}, which its owner set to be read by ${said}. You are ${caller.label}, so it is theirs to open, or to change who reads it.` };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// A plain sentence for everything that was held back from one tool answer.
|
|
210
|
+
// A sealed refusal is never named; `sealed` true (or any sealed refusal) adds SEALED_HELD, which is the
|
|
211
|
+
// same sentence whatever was asked.
|
|
212
|
+
export function heldBack(refusals, { sealed = false } = {}) {
|
|
213
|
+
const anySealed = sealed || refusals.some((r) => r.kind === "sealed");
|
|
214
|
+
refusals = refusals.filter((r) => r.kind !== "sealed");
|
|
215
|
+
if (!refusals.length) return anySealed ? SEALED_HELD : "";
|
|
216
|
+
const noted = refusals.filter((r) => r.kind === "noted");
|
|
217
|
+
const reader = refusals.filter((r) => r.kind === "reader");
|
|
218
|
+
const show = (xs) => xs.slice(0, 10).map((r) => r.path).join(", ") + (xs.length > 10 ? `, and ${xs.length - 10} more` : "");
|
|
219
|
+
const lines = [];
|
|
220
|
+
if (noted.length) lines.push(`Not shown: ${noted.length} just-noted entr${noted.length === 1 ? "y" : "ies"} (${show(noted)}). No AI reads these until the owner says it can; they can open them, or tell you that you can read one now.`);
|
|
221
|
+
if (reader.length) lines.push(`Not shown: ${reader.length} file(s) in parts of the record set to be read by a different AI (${show(reader)}). ${reader[0].why}`);
|
|
222
|
+
if (anySealed) lines.push(SEALED_HELD);
|
|
223
|
+
return lines.join("\n");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// The captures folder, where a noted entry lands.
|
|
227
|
+
function capturesDir(evda) {
|
|
228
|
+
const r = resolveRole(evda, "record/captures");
|
|
229
|
+
if (r.mapped) return r;
|
|
230
|
+
const d = resolveRole(evda, "record/daily");
|
|
231
|
+
if (!d.mapped) throw new EvdaError('REFUSED: this record maps neither "record/captures" nor "record/daily", so there is nowhere to note it. Nothing was written.', { code: "NO_ROLE", exit: 2 });
|
|
232
|
+
return d;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const pad = (n) => String(n).padStart(2, "0");
|
|
236
|
+
const localDate = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
237
|
+
const localTime = (d) => `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
238
|
+
|
|
239
|
+
// Write the owner's words as a just-noted capture. Its filename carries a time and nothing of the
|
|
240
|
+
// words, so a listing discloses nothing. Returns { path } relative to the record.
|
|
241
|
+
export function note(evda, words, { now = new Date() } = {}) {
|
|
242
|
+
const dir = capturesDir(evda);
|
|
243
|
+
mkdirSync(dir.abs, { recursive: true });
|
|
244
|
+
const stamp = `${localDate(now)}-${pad(now.getHours())}${pad(now.getMinutes())}`;
|
|
245
|
+
let abs = join(dir.abs, `${stamp}-noted.md`);
|
|
246
|
+
for (let n = 2; existsSync(abs); n++) abs = join(dir.abs, `${stamp}-noted-${n}.md`);
|
|
247
|
+
writeFileSync(abs, ["---", `${NOTED}: true`, `date: ${localDate(now)}`, `time: "${localTime(now)}"`, "---", "", words.replace(/\n+$/, ""), ""].join("\n"));
|
|
248
|
+
return { path: relative(evda.dir, abs).split(sep).join("/") };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Clear the flag on one noted entry, on the owner's yes. Changes one frontmatter line and adds the
|
|
252
|
+
// date it was released; the words are never touched.
|
|
253
|
+
export function releaseNote(evda, path, { now = new Date() } = {}) {
|
|
254
|
+
if (typeof path !== "string" || path.trim() === "") throw new EvdaError('REFUSED: name the noted entry to release, as a path in the record. Nothing was changed.', { code: "BAD_ARGUMENT", exit: 2 });
|
|
255
|
+
const outside = () => new EvdaError(`REFUSED: ${path} is outside the record. Nothing was changed.`, { code: "OUTSIDE", exit: 2 });
|
|
256
|
+
const named = resolve(evda.dir, path.trim());
|
|
257
|
+
if (!existsSync(named)) {
|
|
258
|
+
if (!named.startsWith(evda.dir + sep)) throw outside();
|
|
259
|
+
throw new EvdaError(`REFUSED: nothing is at ${path}. Nothing was changed.`, { code: "MISSING", exit: 2 });
|
|
260
|
+
}
|
|
261
|
+
// Inside is decided on real paths: a record reached through a symlink (the owner's shell path to
|
|
262
|
+
// it) is the same record, and a symlink inside the record that leads out of it is outside
|
|
263
|
+
// (HQ-1406: an absolute path through a link to the record was refused as outside).
|
|
264
|
+
const root = realpathSync(evda.dir);
|
|
265
|
+
const abs = realpathSync(named);
|
|
266
|
+
if (!abs.startsWith(root + sep)) throw outside();
|
|
267
|
+
if (!statSync(abs).isFile()) throw new EvdaError(`REFUSED: nothing is at ${path}. Nothing was changed.`, { code: "MISSING", exit: 2 });
|
|
268
|
+
const text = readFileSync(abs, "utf8");
|
|
269
|
+
const lines = text.split("\n");
|
|
270
|
+
const end = lines[0] === "---" ? lines.indexOf("---", 1) : -1;
|
|
271
|
+
const at = end === -1 ? -1 : lines.slice(1, end).findIndex(notedLine) + 1;
|
|
272
|
+
if (at <= 0) throw new EvdaError(`REFUSED: ${path} is not just noted (its frontmatter has no "noted: true"). Nothing was changed.`, { code: "NOT_NOTED", exit: 2 });
|
|
273
|
+
lines.splice(at, 1, `released: ${localDate(now)}`);
|
|
274
|
+
writeFileSync(abs, lines.join("\n"));
|
|
275
|
+
return { path: relative(root, abs).split(sep).join("/") };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// The top-level frontmatter line that makes an entry noted, spelled any way the frontmatter reader
|
|
279
|
+
// reads as `noted: true` (True, a trailing comment, a quoted key), so release clears every entry
|
|
280
|
+
// the rest of the package holds back (HQ-1406: `noted: True` was held back and could not be released).
|
|
281
|
+
function notedLine(l) {
|
|
282
|
+
if (/^\s/.test(l)) return false;
|
|
283
|
+
try {
|
|
284
|
+
const one = parseYaml(l);
|
|
285
|
+
return one !== null && typeof one === "object" && Object.keys(one).length === 1 && one[NOTED] === true;
|
|
286
|
+
} catch {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Every noted entry in the record's markdown, for doctor and for the owner's own search.
|
|
292
|
+
export function notedFiles(evda, files) {
|
|
293
|
+
return files.filter((abs) => frontOf(abs)?.[NOTED] === true);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// The owner's own search, on this device, with no model: every markdown file under the record,
|
|
297
|
+
// noted and sealed entries included and marked (a line in a sealed section is marked sealed too).
|
|
298
|
+
// The MCP's evda_search is the AI's and leaves them out.
|
|
299
|
+
export function ownerSearch(evda, query, { files }) {
|
|
300
|
+
const needle = query.toLowerCase();
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const abs of files) {
|
|
303
|
+
let text;
|
|
304
|
+
try { text = readFileSync(abs, "utf8"); } catch { continue; }
|
|
305
|
+
const front = frontmatterOf(text);
|
|
306
|
+
const noted = front?.[NOTED] === true;
|
|
307
|
+
const sealedFile = sealedFront(front);
|
|
308
|
+
const sections = sealedSections(text);
|
|
309
|
+
text.split("\n").forEach((line, i) => {
|
|
310
|
+
if (!line.toLowerCase().includes(needle)) return;
|
|
311
|
+
const hit = { path: relative(evda.dir, abs).split(sep).join("/"), line: i + 1, text: line, noted };
|
|
312
|
+
if (sealedFile || sections.some((s) => i >= s.start && i <= s.end)) hit.sealed = true;
|
|
313
|
+
out.push(hit);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
return out;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export { mdFiles, PLATFORMS };
|
package/lib/records.mjs
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Records inside records (Spec 07 section 2, "A record inside a record", HQ-1371).
|
|
2
|
+
//
|
|
3
|
+
// An evda.json anywhere below a record's root starts a sub-record: its own four roles resolved from
|
|
4
|
+
// its own root, its own circles, its own holders, with no depth limit. This module finds them. Only
|
|
5
|
+
// the OUTERMOST sub-records of a record are its own; a sub-record's sub-records are found when the
|
|
6
|
+
// sub-record itself is read, which is how doctor walks every level.
|
|
7
|
+
//
|
|
8
|
+
// The boundary is opt-in for circles (lib/manifest.mjs `isBounded`): a record that declares
|
|
9
|
+
// "subrecords": "bounded" stops its circles, its coverage, its scan prefixes and its released set
|
|
10
|
+
// at every sub-record, and holds a sub-record's files only through a circle it mounts. A record that
|
|
11
|
+
// does not declare it keeps covering them as its own, which is what it synced before HQ-1371.
|
|
12
|
+
//
|
|
13
|
+
// The walk is the sync walk (lib/sync-rules.mjs), so a sub-record under an excluded path is not a
|
|
14
|
+
// sub-record of this record: nothing of it syncs from here either way.
|
|
15
|
+
|
|
16
|
+
import { dirname } from "node:path";
|
|
17
|
+
import { excluder, walk } from "./sync-rules.mjs";
|
|
18
|
+
import { isBounded } from "./manifest.mjs";
|
|
19
|
+
|
|
20
|
+
const MANIFEST = "evda.json";
|
|
21
|
+
|
|
22
|
+
// EVDA_SUBRECORDS_FALSIFY=<mode,...>, for the tests: no-boundary makes a bounded record's circles
|
|
23
|
+
// cover its sub-records anyway; blind finds no sub-record at all, so doctor walks no level below.
|
|
24
|
+
export const FALSIFY_MODES = ["no-boundary", "blind"];
|
|
25
|
+
const falsified = (mode) => (process.env.EVDA_SUBRECORDS_FALSIFY ?? "").split(",").map((s) => s.trim()).includes(mode);
|
|
26
|
+
|
|
27
|
+
// The outermost sub-record roots under `dir`, relative, sorted. `exclude` is the plan's excluder.
|
|
28
|
+
export function subRecords(dir, { exclude = excluder() } = {}) {
|
|
29
|
+
const found = [];
|
|
30
|
+
if (falsified("blind")) return found;
|
|
31
|
+
for (const e of walk(dir, { exclude })) {
|
|
32
|
+
if (e.kind !== "file") continue;
|
|
33
|
+
if (!(e.rel === MANIFEST || e.rel.endsWith(`/${MANIFEST}`))) continue;
|
|
34
|
+
if (e.rel === MANIFEST) continue;
|
|
35
|
+
found.push(dirname(e.rel));
|
|
36
|
+
}
|
|
37
|
+
found.sort();
|
|
38
|
+
return found.filter((r) => !found.some((o) => o !== r && r.startsWith(`${o}/`)));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const cache = new WeakMap();
|
|
42
|
+
|
|
43
|
+
// The sub-record roots the record's circles stop at: every outermost sub-record when the record is
|
|
44
|
+
// bounded, none when it is not. Cached per open record, since coverage asks once per file.
|
|
45
|
+
export function boundaries(evda) {
|
|
46
|
+
if (!isBounded(evda.manifest) || falsified("no-boundary")) return [];
|
|
47
|
+
if (!cache.has(evda)) cache.set(evda, subRecords(evda.dir));
|
|
48
|
+
return cache.get(evda);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Whether a relative path is inside one of `roots`.
|
|
52
|
+
export const insideAny = (roots, rel) => roots.some((r) => rel === r || rel.startsWith(`${r}/`));
|
package/lib/references.mjs
CHANGED
|
@@ -34,6 +34,9 @@
|
|
|
34
34
|
|
|
35
35
|
import { EvdaError } from "./errors.mjs";
|
|
36
36
|
import { parseYaml } from "./yaml.mjs";
|
|
37
|
+
import { secretShape } from "./secret-shape.mjs";
|
|
38
|
+
|
|
39
|
+
export { secretShape };
|
|
37
40
|
|
|
38
41
|
export const SCHEMES = ["contacts", "1password", "url", "evda"];
|
|
39
42
|
export const REFERENCE_KEYS = ["ref", "label", "resolved"];
|
|
@@ -130,58 +133,9 @@ function bad(msg) {
|
|
|
130
133
|
return new EvdaError(msg, { code: "BAD_REFERENCE", exit: 1 });
|
|
131
134
|
}
|
|
132
135
|
|
|
133
|
-
// Secret detection
|
|
134
|
-
//
|
|
135
|
-
// stamp on their tokens, a bearer header, a URL carrying a password or a credential in its query,
|
|
136
|
-
// and as a last net a long mixed-case high-entropy token that is not a UUID.
|
|
136
|
+
// Secret detection is lib/secret-shape.mjs, shared with the prompt hook; a key named like a secret
|
|
137
|
+
// is refused here too.
|
|
137
138
|
const SECRET_KEY = /(pass(word|wd|phrase)?|secret|token|api[-_ ]?key|access[-_ ]?key|private[-_ ]?key|credential|bearer|cookie|session[-_ ]?id|otp|pin[-_ ]?code|client[-_ ]?secret)/i;
|
|
138
|
-
const SECRET_PARAM = /^(key|token|access_token|api_key|apikey|auth|password|secret|sig|signature|session|x-amz-signature|x-amz-credential)$/i;
|
|
139
|
-
const SECRET_VALUE = [
|
|
140
|
-
[/-----BEGIN [A-Z ]*PRIVATE KEY-----/, "a private key block"],
|
|
141
|
-
[/\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/, "a JWT"],
|
|
142
|
-
[/\b(sk|pk|rk)-[A-Za-z0-9_-]{16,}\b/, "an API key (sk-...)"],
|
|
143
|
-
[/\b(sk|pk|rk)_(live|test)_[A-Za-z0-9]{8,}\b/, "an API key (sk_live_...)"],
|
|
144
|
-
[/\bgh[pousr]_[A-Za-z0-9]{20,}\b/, "a GitHub token"],
|
|
145
|
-
[/\bglpat-[A-Za-z0-9_-]{20,}\b/, "a GitLab token"],
|
|
146
|
-
[/\bxox[abprs]-[A-Za-z0-9-]{10,}\b/, "a Slack token"],
|
|
147
|
-
[/\bAKIA[0-9A-Z]{16}\b/, "an AWS access key id"],
|
|
148
|
-
[/\bAIza[0-9A-Za-z_-]{35}\b/, "a Google API key"],
|
|
149
|
-
[/\bBearer\s+[A-Za-z0-9._~+/-]{16,}=*/i, "a bearer token"],
|
|
150
|
-
];
|
|
151
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
152
|
-
|
|
153
|
-
function entropy(s) {
|
|
154
|
-
const counts = new Map();
|
|
155
|
-
for (const c of s) counts.set(c, (counts.get(c) ?? 0) + 1);
|
|
156
|
-
let h = 0;
|
|
157
|
-
for (const n of counts.values()) {
|
|
158
|
-
const p = n / s.length;
|
|
159
|
-
h -= p * Math.log2(p);
|
|
160
|
-
}
|
|
161
|
-
return h;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Why a string looks like a secret, or null.
|
|
165
|
-
export function secretShape(value) {
|
|
166
|
-
if (typeof value !== "string") return null;
|
|
167
|
-
for (const [re, what] of SECRET_VALUE) if (re.test(value)) return what;
|
|
168
|
-
const m = /^(?:url:)?([a-z][a-z0-9+.-]*:\/\/[^\s]+)$/i.exec(value);
|
|
169
|
-
if (m) {
|
|
170
|
-
let u = null;
|
|
171
|
-
try { u = new URL(m[1]); } catch { u = null; }
|
|
172
|
-
if (u) {
|
|
173
|
-
if (u.password !== "") return "a password in the URL";
|
|
174
|
-
for (const k of u.searchParams.keys()) if (SECRET_PARAM.test(k)) return `a credential in the URL query (${k})`;
|
|
175
|
-
}
|
|
176
|
-
}
|
|
177
|
-
for (const tok of value.split(/[\s"'<>]+/)) {
|
|
178
|
-
if (tok.length < 40 || UUID.test(tok)) continue;
|
|
179
|
-
if (!/[A-Z]/.test(tok) || !/[a-z]/.test(tok) || !/\d/.test(tok)) continue;
|
|
180
|
-
if (!/^[A-Za-z0-9+/=_-]+$/.test(tok)) continue;
|
|
181
|
-
if (entropy(tok) >= 4.5) return "a long high-entropy token";
|
|
182
|
-
}
|
|
183
|
-
return null;
|
|
184
|
-
}
|
|
185
139
|
|
|
186
140
|
// Why a key name looks like it holds a secret, or null.
|
|
187
141
|
export function secretKey(name) {
|
package/lib/render.mjs
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// Views rendered at publish (Spec 07 section 5, HQ-1351).
|
|
2
|
+
//
|
|
3
|
+
// One fact, stored once, as a typed entry placed by its subject. The public circle carries a
|
|
4
|
+
// `render` member for each file the site reads, and the file is rendered on the way out from every
|
|
5
|
+
// released entry whose frontmatter names the view:
|
|
6
|
+
//
|
|
7
|
+
// ---
|
|
8
|
+
// kind: piece
|
|
9
|
+
// share: public the mark, so the public circle's rule gathers it
|
|
10
|
+
// view: projects which render member it feeds
|
|
11
|
+
// order: 3 its place in the rendered list; unique within the view
|
|
12
|
+
// id: proj_iris the item's own id, repeated so a reader of the folder sees it
|
|
13
|
+
// title: Iris
|
|
14
|
+
// ---
|
|
15
|
+
// ```json
|
|
16
|
+
// { "id": "proj_iris", ... } the item, exactly as the rendered file carries it
|
|
17
|
+
// ```
|
|
18
|
+
//
|
|
19
|
+
// A list view is the entries' items as one JSON list, sorted by `order`. An object view holds
|
|
20
|
+
// exactly one entry and renders its item alone. The rendered bytes are deterministic (two-space
|
|
21
|
+
// JSON, one trailing newline), so the same entries render to the same file on any device.
|
|
22
|
+
//
|
|
23
|
+
// Provenance (HQ-1367). An entry re-derived from its subject's own record says where from and when:
|
|
24
|
+
//
|
|
25
|
+
// derived_from: the record paths (a folder, a README) and shipped tickets (HQ-<n>) it was
|
|
26
|
+
// - "01_Projects/Evda/Iris - Universal Thinking Partner/README.md"
|
|
27
|
+
// - HQ-1351 derived from; a path must exist in the record
|
|
28
|
+
// derived_at: "2026-09-25" when; an ISO date or date-time, never in the future
|
|
29
|
+
//
|
|
30
|
+
// Both or neither: an entry with only one is a problem, since a stranger's answer must trace to what
|
|
31
|
+
// the record held. The fields live in the entry's frontmatter and never reach the rendered file.
|
|
32
|
+
// `provenance` on each rendered entry reports them, with `changed`: the source files modified after
|
|
33
|
+
// derived_at, which is a fact about files the publish pass reads to find what to re-derive.
|
|
34
|
+
//
|
|
35
|
+
// Everything that can go wrong is a problem the caller fails on, never a dropped entry: a view no
|
|
36
|
+
// render member declares, a missing or repeated `order`, a body with no JSON block or one that does
|
|
37
|
+
// not parse, an `id` in the frontmatter that is not the item's, an object view with two entries, a
|
|
38
|
+
// render member that draws nothing. A publish with any problem refuses; doctor names each one.
|
|
39
|
+
|
|
40
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
41
|
+
import { isAbsolute, resolve, sep } from "node:path";
|
|
42
|
+
import { createHash } from "node:crypto";
|
|
43
|
+
import { frontmatterOf, withoutSealed } from "./circles.mjs";
|
|
44
|
+
|
|
45
|
+
// The frontmatter keys an entry uses to reach a view.
|
|
46
|
+
export const VIEW = "view";
|
|
47
|
+
export const ORDER = "order";
|
|
48
|
+
export const DERIVED_FROM = "derived_from";
|
|
49
|
+
export const DERIVED_AT = "derived_at";
|
|
50
|
+
|
|
51
|
+
const TICKET = /^HQ-[1-9][0-9]*$/;
|
|
52
|
+
const ISO = /^\d{4}-\d{2}-\d{2}(T\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:\d{2})?)?$/;
|
|
53
|
+
|
|
54
|
+
// The provenance an entry carries: { provenance } (null when it carries none) or { problem }.
|
|
55
|
+
// `recordDir` resolves paths; `now` is injectable so a test can pin "the future".
|
|
56
|
+
export function provenanceOf(front, recordDir, { now = new Date() } = {}) {
|
|
57
|
+
const from = front?.[DERIVED_FROM];
|
|
58
|
+
const at = front?.[DERIVED_AT];
|
|
59
|
+
if (from === undefined && at === undefined) return { provenance: null };
|
|
60
|
+
if (from === undefined) return { problem: `has ${DERIVED_AT} and no ${DERIVED_FROM}. A derived entry names what it was derived from.` };
|
|
61
|
+
if (at === undefined) return { problem: `has ${DERIVED_FROM} and no ${DERIVED_AT}. A derived entry says when it was derived.` };
|
|
62
|
+
if (!Array.isArray(from) || from.length === 0 || from.some((s) => typeof s !== "string" || s.trim() === "")) return { problem: `has ${DERIVED_FROM}: ${JSON.stringify(from)}; it is a non-empty list of record paths and HQ-<n> tickets.` };
|
|
63
|
+
const when = String(at);
|
|
64
|
+
if (!ISO.test(when) || Number.isNaN(Date.parse(when))) return { problem: `has ${DERIVED_AT}: ${JSON.stringify(at)}, which is not an ISO date.` };
|
|
65
|
+
const t = Date.parse(when);
|
|
66
|
+
if (t > now.getTime() + 24 * 3600 * 1000) return { problem: `has ${DERIVED_AT}: ${when}, which is in the future.` };
|
|
67
|
+
const root = resolve(recordDir);
|
|
68
|
+
const tickets = [];
|
|
69
|
+
const files = [];
|
|
70
|
+
for (const s of from) {
|
|
71
|
+
if (TICKET.test(s)) { tickets.push(s); continue; }
|
|
72
|
+
if (isAbsolute(s)) return { problem: `derives from ${JSON.stringify(s)}, an absolute path; a source is relative to the record root.` };
|
|
73
|
+
const abs = resolve(root, s);
|
|
74
|
+
if (abs !== root && !abs.startsWith(root + sep)) return { problem: `derives from ${JSON.stringify(s)}, which is outside the record.` };
|
|
75
|
+
if (!existsSync(abs)) return { problem: `derives from ${JSON.stringify(s)}, and nothing is there. A source the record does not hold cannot be traced to.` };
|
|
76
|
+
files.push({ path: s, abs });
|
|
77
|
+
}
|
|
78
|
+
// A date with no time is the end of that day: a source saved that afternoon was read for it.
|
|
79
|
+
const cutoff = /T/.test(when) ? t : t + 24 * 3600 * 1000;
|
|
80
|
+
const changed = files.filter((f) => { try { return statSync(f.abs).mtimeMs > cutoff; } catch { return false; } }).map((f) => f.path);
|
|
81
|
+
return { provenance: { from: [...from], at: when, tickets, files: files.map((f) => f.path), changed } };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const sha256Hex = (bytes) => createHash("sha256").update(bytes).digest("hex");
|
|
85
|
+
|
|
86
|
+
// The item an entry holds: the first ```json fence in the body. { value } or { problem }.
|
|
87
|
+
export function entryJson(text) {
|
|
88
|
+
const m = /^```json[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*$/m.exec(text);
|
|
89
|
+
if (!m) return { problem: "has no ```json block. A view entry holds its item in one fenced JSON block." };
|
|
90
|
+
try {
|
|
91
|
+
return { value: JSON.parse(m[1]) };
|
|
92
|
+
} catch (e) {
|
|
93
|
+
return { problem: `its \`\`\`json block does not parse (${e.message}).` };
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Render the bytes of one view. Pure: the same entries render to the same bytes in any order.
|
|
98
|
+
export function renderBytes(shape, items) {
|
|
99
|
+
const value = shape === "object" ? items[0] : items;
|
|
100
|
+
return Buffer.from(JSON.stringify(value, null, 2) + "\n", "utf8");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Group the files released into a public circle (`gathered` is `released()`'s row for it, or any
|
|
104
|
+
// object with byPath and byRule lists of { path, abs, member, circle? }) by the view each names,
|
|
105
|
+
// and render every render member of the circle.
|
|
106
|
+
//
|
|
107
|
+
// Returns { views, loose, problems }:
|
|
108
|
+
// views one per render member: { member, view, path, shape, entries: [{ path, abs, sha256,
|
|
109
|
+
// order, id, home }], bytes } with entries sorted by order; bytes is null when the
|
|
110
|
+
// view has a problem
|
|
111
|
+
// loose the released files that name no view, unchanged, exported as themselves
|
|
112
|
+
// problems [{ path, message }], each one a refusal
|
|
113
|
+
export function renderViews(evda, circle, gathered) {
|
|
114
|
+
const renders = Object.entries(circle.members).filter(([, m]) => m.kind === "render").map(([member, m]) => ({ member, view: m.view, path: m.rel, shape: m.shape, entries: [], bytes: null }));
|
|
115
|
+
const byView = new Map(renders.map((r) => [r.view, r]));
|
|
116
|
+
const loose = [];
|
|
117
|
+
const problems = [];
|
|
118
|
+
const problem = (path, message) => problems.push({ path, message });
|
|
119
|
+
|
|
120
|
+
for (const f of [...gathered.byPath, ...gathered.byRule]) {
|
|
121
|
+
let text;
|
|
122
|
+
try { text = readFileSync(f.abs, "utf8"); } catch (e) { problem(f.path, `could not be read (${e.message}).`); continue; }
|
|
123
|
+
// A sealed section (HQ-1220) is never rendered, whatever fence it holds.
|
|
124
|
+
text = withoutSealed(text).text;
|
|
125
|
+
const front = frontmatterOf(text);
|
|
126
|
+
const view = front?.[VIEW];
|
|
127
|
+
if (view === undefined || view === null || view === "") { loose.push(f); continue; }
|
|
128
|
+
if (typeof view !== "string") { problem(f.path, `has ${VIEW}: ${JSON.stringify(view)}, which is not a view name.`); continue; }
|
|
129
|
+
const target = byView.get(view);
|
|
130
|
+
if (!target) { problem(f.path, `names view "${view}", and no render member of circle "${circle.name}" declares it (declared: ${renders.map((r) => r.view).join(", ") || "none"}). A released entry that reaches nowhere is a silent drop.`); continue; }
|
|
131
|
+
const order = front[ORDER];
|
|
132
|
+
if (!Number.isInteger(order)) { problem(f.path, `has ${ORDER}: ${JSON.stringify(order)}; a view entry says where it goes with an integer.`); continue; }
|
|
133
|
+
const json = entryJson(text);
|
|
134
|
+
if (json.problem) { problem(f.path, json.problem); continue; }
|
|
135
|
+
const item = json.value;
|
|
136
|
+
const itemId = item && typeof item === "object" && !Array.isArray(item) ? item.id : undefined;
|
|
137
|
+
if (front.id !== undefined && itemId !== undefined && String(front.id) !== String(itemId)) { problem(f.path, `says id: ${JSON.stringify(front.id)} in its frontmatter and its item says "id": ${JSON.stringify(itemId)}. One id per fact.`); continue; }
|
|
138
|
+
const prov = provenanceOf(front, evda.dir);
|
|
139
|
+
if (prov.problem) { problem(f.path, prov.problem); continue; }
|
|
140
|
+
target.entries.push({ path: f.path, abs: f.abs, sha256: sha256Hex(Buffer.from(text, "utf8")), order, id: front.id ?? itemId ?? null, home: f.circle ?? circle.name, provenance: prov.provenance, item });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
for (const r of renders) {
|
|
144
|
+
r.entries.sort((a, b) => a.order - b.order || a.path.localeCompare(b.path));
|
|
145
|
+
const seen = new Map();
|
|
146
|
+
for (const e of r.entries) {
|
|
147
|
+
if (seen.has(e.order)) problem(e.path, `has ${ORDER}: ${e.order} in view "${r.view}", and so does ${seen.get(e.order)}. One place per entry.`);
|
|
148
|
+
seen.set(e.order, e.path);
|
|
149
|
+
}
|
|
150
|
+
if (r.entries.length === 0) problem(r.path, `is rendered by member "${r.member}" from view "${r.view}", and no released entry names that view. A view that draws nothing is not published.`);
|
|
151
|
+
if (r.shape === "object" && r.entries.length > 1) problem(r.path, `is an object view ("${r.view}") and ${r.entries.length} entries name it: ${r.entries.map((e) => e.path).join(", ")}. An object view holds exactly one.`);
|
|
152
|
+
const mine = problems.some((p) => p.path === r.path || r.entries.some((e) => e.path === p.path));
|
|
153
|
+
if (!mine) r.bytes = renderBytes(r.shape, r.entries.map((e) => e.item));
|
|
154
|
+
for (const e of r.entries) delete e.item;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return { views: renders, loose, problems };
|
|
158
|
+
}
|