create-evda 0.1.4 → 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 +93 -8
- package/bin/evda.mjs +437 -5
- package/create-evda.mjs +68 -12
- 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 +351 -19
- package/lib/filechanges.mjs +622 -0
- package/lib/folders.mjs +22 -8
- 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 +467 -35
- package/lib/migrate-people.mjs +231 -0
- package/lib/organize.mjs +52 -12
- package/lib/parity.mjs +111 -0
- package/lib/presets.mjs +379 -0
- 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 +146 -2
- package/lib/skills.mjs +266 -0
- package/lib/subrecord.mjs +121 -0
- package/lib/sync-rules.mjs +87 -0
- package/lib/tools.mjs +120 -29
- package/lib/update-check.mjs +146 -0
- package/lib/work.mjs +180 -0
- package/lib/yaml.mjs +117 -42
- package/package.json +25 -15
- package/presets/journal/preset.json +19 -0
- package/presets/projects-and-areas/preset.json +34 -0
- package/skills/evda/SKILL.md +76 -0
- package/templates/AGENTS.md +129 -15
- package/templates/README.md +15 -1
- package/templates/circles-README.md +3 -3
- package/templates/evda-digest.md +32 -5
- package/LICENSE +0 -21
package/lib/circles.mjs
CHANGED
|
@@ -9,26 +9,69 @@
|
|
|
9
9
|
//
|
|
10
10
|
// members where the circle's bytes live, relative to the record root. The mapping principle
|
|
11
11
|
// one level down: a record adopts a circle by declaring where its members already are,
|
|
12
|
-
// and nothing moves. A member
|
|
12
|
+
// and nothing moves. A path member covers the file it names or everything under the
|
|
13
|
+
// directory it names, and a member at the record root ("" or ".") covers everything.
|
|
14
|
+
// PRECEDENCE (HQ-1364): a file's home is the circle whose covering member path is
|
|
15
|
+
// LONGEST, so a circle declared at the root is the private default and every other
|
|
16
|
+
// circle carves its subtree out of it; `Health & Energy/Therapy` is therapy, not health,
|
|
17
|
+
// not core, whatever order the manifests are read in. Two circles whose members cover
|
|
18
|
+
// one file at the same length is a file with two access answers, and doctor fails it.
|
|
19
|
+
// `coverage()` here, `circleForPath` in filechanges.mjs and the scan's `scanPrefixes`
|
|
20
|
+
// all resolve this one way. A member is { path } (bytes in this record), { table, where }
|
|
13
21
|
// (rows on a host, selected by equality; allowed only in a public circle, because a
|
|
14
|
-
// private circle's host may not read it)
|
|
22
|
+
// private circle's host may not read it), or { where } alone (files in this record
|
|
23
|
+
// whose frontmatter matches, wherever they live: a standing release rule, on any
|
|
24
|
+
// circle). The mark an owner writes on one entry is `share: <circle>` or
|
|
25
|
+
// `share: [<circle>, ...]` in its frontmatter, and the circle's rule for it is
|
|
26
|
+
// `where: { share: <its name> }`; a where value matches a scalar or a list holding it.
|
|
27
|
+
// The fourth member is { render: { view, path, shape? } }, on a public circle only
|
|
28
|
+
// (HQ-1351, Spec 07 section 5): a file that does not exist in the record and is
|
|
29
|
+
// RENDERED at publish from every released entry whose frontmatter says `view: <view>`,
|
|
30
|
+
// sorted by `order`, written into the export at `path`. `shape` is "list" (the default,
|
|
31
|
+
// one JSON list of the entries' items) or "object" (exactly one entry, its item alone).
|
|
32
|
+
// The rendering is lib/render.mjs. A rendered path is never also a path member, and a
|
|
33
|
+
// released entry that names a view no render member declares refuses the publish.
|
|
34
|
+
// mount on a circle held FROM ANOTHER HOLDER (HQ-835): `{ holder: <their record id>, path:
|
|
35
|
+
// <where their files land here> }`. The wire carries each file at its address in the
|
|
36
|
+
// home holder's tree; on this device it lands under `path` (Spec 07 section 3, "at its
|
|
37
|
+
// address under a mount, never at a path in their own tree"). `path` must be a path
|
|
38
|
+
// member of the circle, so the scan and the fold agree on where the mount is.
|
|
39
|
+
// frozen on a circle held from another holder whose grant was revoked (HQ-835): `{ since:
|
|
40
|
+
// <date>, by: revocation }`. The copy stays readable and stops updating: it is neither
|
|
41
|
+
// scanned for edits nor minted into, and nothing under its mount is deleted (Spec 00
|
|
42
|
+
// #37). Written by `lib/declarations.mjs` when the owner's notice arrives; lifted by a
|
|
43
|
+
// re-declaration.
|
|
44
|
+
// release on the circle a file lives in: "never", or a list of circle names it may be released
|
|
45
|
+
// into. Default never, so a new circle cannot leak by a stray mark. Spec 07 section 3,
|
|
46
|
+
// ruled 2026-09-22 (HQ-1351): every entry has exactly one HOME circle by path; it may be
|
|
47
|
+
// released into any number of other circles by mark or by rule, and each release is
|
|
48
|
+
// the same file sealed once more at sync, derived from the one copy. Public is the
|
|
49
|
+
// release with no key. Routing by home circle is unchanged; released circles are added.
|
|
15
50
|
// policy a list of rules (Spec 07, section 6), each one a test: what the rule is in the owner's
|
|
16
51
|
// words, the correction that made it (verbatim), an example that passes, one that fails,
|
|
17
52
|
// a check written so it could come back false, and a status. A rule is never edited in
|
|
18
53
|
// place; a revision is a new rule with `revisedBy` written on the old one. The plain
|
|
19
54
|
// sentence saying who may read the circle and why goes in the substrate's `description`.
|
|
20
55
|
//
|
|
56
|
+
// A circle's id is opaque and random (Spec 08, ruled 2026-09-17): `id: ecos/<opaque>` and
|
|
57
|
+
// `namespace: <opaque>.eco`, minted once when the circle is created and never derived from its
|
|
58
|
+
// name. The name is the directory under the circles role and stays on the owner's devices; the id
|
|
59
|
+
// is what travels (a Change's `eco`, an export's manifest), so a name there would be the
|
|
60
|
+
// disclosure. An id still derived from the name is read, with a warning to re-mint it.
|
|
61
|
+
//
|
|
21
62
|
// `public: true` is the whole of what makes a circle public: the reader takes the same path it
|
|
22
|
-
// would for any circle and needs no key.
|
|
23
|
-
//
|
|
63
|
+
// would for any circle and needs no key. This module declares and validates circles; it reads no
|
|
64
|
+
// private circle's bytes. Sealing a private circle for sync is `lib/filechanges.mjs`.
|
|
24
65
|
//
|
|
25
66
|
// `circle.json` is the manifest this package wrote before circles converged on ecos (2026-09-16).
|
|
26
67
|
// It is still read, with a warning, so a record written against it keeps working until it is
|
|
27
68
|
// rewritten as `eco.yaml`. Nothing writes it any more.
|
|
28
69
|
|
|
29
70
|
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, mkdirSync } from "node:fs";
|
|
30
|
-
import {
|
|
71
|
+
import { randomBytes } from "node:crypto";
|
|
72
|
+
import { join, resolve, relative, sep, isAbsolute } from "node:path";
|
|
31
73
|
import { resolveRole } from "./roles.mjs";
|
|
74
|
+
import { boundaries, insideAny } from "./records.mjs";
|
|
32
75
|
import { parseYaml, toYaml } from "./yaml.mjs";
|
|
33
76
|
import { EvdaError } from "./errors.mjs";
|
|
34
77
|
|
|
@@ -38,14 +81,59 @@ export const ECO_MANIFEST = "eco.yaml";
|
|
|
38
81
|
export const LEGACY_MANIFEST = "circle.json";
|
|
39
82
|
export const KNOWN_ECO_KEYS = [
|
|
40
83
|
"id", "kind", "schemaVersion", "sourceLayer", "namespace", "title", "type", "description",
|
|
41
|
-
"ecoVersion", "layer", "public", "maintainers", "metadata", "members", "policy",
|
|
84
|
+
"ecoVersion", "layer", "public", "maintainers", "metadata", "members", "policy", "release", "mount",
|
|
85
|
+
"frozen", "read_by",
|
|
42
86
|
];
|
|
87
|
+
// The frontmatter key an owner writes to release one entry: a circle name, or a list of them.
|
|
88
|
+
export const MARK = "share";
|
|
89
|
+
// The frontmatter flag on an entry that is just noted (HQ-1374): kept on the owner's devices,
|
|
90
|
+
// searchable by them, read by no AI until they release it. Not a circle and not a mark, and a mark
|
|
91
|
+
// never releases a noted entry anywhere. lib/readers.mjs holds the rest of what it means.
|
|
92
|
+
export const NOTED = "noted";
|
|
93
|
+
// The frontmatter flag on an entry that is sealed (HQ-1220): nothing but the owner's own devices
|
|
94
|
+
// opens it, ever. Stronger than noted: no AI reads it whoever it says it is, no tool releases it,
|
|
95
|
+
// no mark and no public path publishes it. A section is sealed by a fenced block whose info string
|
|
96
|
+
// is `sealed` (withoutSealed below). lib/readers.mjs holds what each reader does with it.
|
|
97
|
+
export const SEALED = "sealed";
|
|
98
|
+
// Carrying the two flags (HQ-1414). Every command that copies or moves record content somewhere
|
|
99
|
+
// else carries `noted` and `sealed` with it, so a copy is never more readable than what it copied.
|
|
100
|
+
// The paths are COPY_PATHS; test/carry.test.mjs holds one canary test per path and fails when a new
|
|
101
|
+
// file-writing module appears that it has not classified. EVDA_CARRY_FALSIFY=<path>:<flag>,... makes
|
|
102
|
+
// that one path drop that one flag and nothing else, so each test shows the path is what holds.
|
|
103
|
+
export const COPY_PATHS = ["organize", "gather", "gather-apply", "migrate-people", "publish", "sync"];
|
|
104
|
+
export const carries = (path, flag) => !String(process.env.EVDA_CARRY_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(`${path}:${flag}`);
|
|
105
|
+
// Which flag, if any, holds an entry back when `path` copies it: "sealed" first (the stronger),
|
|
106
|
+
// then "noted", else null.
|
|
107
|
+
export function heldBy(front, path) {
|
|
108
|
+
if (carries(path, SEALED) && sealedFront(front)) return SEALED;
|
|
109
|
+
if (carries(path, NOTED) && front?.[NOTED] === true) return NOTED;
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
// The key on a circle (or on evda.json, for the whole record) that says which AI reads it. Validated
|
|
113
|
+
// and printed by lib/readers.mjs; here it is only a key the manifest may carry.
|
|
114
|
+
export const READ_BY = "read_by";
|
|
115
|
+
// The shapes a render member may take: a list of every entry's item, or one entry's item alone.
|
|
116
|
+
export const RENDER_SHAPES = ["list", "object"];
|
|
117
|
+
// A circle's release: "never", or the names of the circles its files may be released into.
|
|
118
|
+
export const isRelease = (v) => v === "never" || (Array.isArray(v) && v.length > 0 && v.every((n) => typeof n === "string" && n.trim() !== ""));
|
|
43
119
|
|
|
44
120
|
export const circlesDir = (evda) => resolveRole(evda, "circles");
|
|
45
121
|
export const ecoManifestPath = (dir) => join(dir, ECO_MANIFEST);
|
|
46
122
|
export const ecoId = (name) => `ecos/${name}`;
|
|
47
123
|
export const ecoNamespace = (name) => `${name}.eco`;
|
|
48
124
|
|
|
125
|
+
// A fresh opaque circle id: `c` and 32 hex characters (128 random bits). Never a function of the
|
|
126
|
+
// name, so two circles called "therapy" in two records share nothing a reader of the id could use.
|
|
127
|
+
export const OPAQUE_ID = /^c[0-9a-f]{32}$/;
|
|
128
|
+
export const mintCircleId = () => `c${randomBytes(16).toString("hex")}`;
|
|
129
|
+
|
|
130
|
+
// The opaque part of an eco manifest's id, when its id and namespace agree on one. null otherwise.
|
|
131
|
+
export function opaqueIdOf(manifest) {
|
|
132
|
+
const id = typeof manifest?.id === "string" && manifest.id.startsWith("ecos/") ? manifest.id.slice("ecos/".length) : null;
|
|
133
|
+
if (id === null || id === "" || id.includes("/")) return null;
|
|
134
|
+
return manifest.namespace === ecoNamespace(id) ? id : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
49
137
|
// Every subdirectory of the circles role, whether or not it carries a manifest. Dotfiles are not
|
|
50
138
|
// circles. A file at the top level of the role is ignored (the scaffold's README lives there).
|
|
51
139
|
export function circleDirs(evda) {
|
|
@@ -151,7 +239,7 @@ export function validateCircle(evda, dir) {
|
|
|
151
239
|
const problems = [];
|
|
152
240
|
const fail = (m) => problems.push({ level: "fail", message: m });
|
|
153
241
|
const warn = (m) => problems.push({ level: "warn", message: m });
|
|
154
|
-
const out = { name: dir.name, rel: dir.rel, abs: dir.abs, manifest: null, source: null, problems, readable: false, members: {}, policy: null };
|
|
242
|
+
const out = { name: dir.name, rel: dir.rel, abs: dir.abs, id: null, manifest: null, source: null, problems, readable: false, members: {}, policy: null, release: "never", mount: null, frozen: null };
|
|
155
243
|
|
|
156
244
|
const { manifest: m, source } = readCircleManifest(dir);
|
|
157
245
|
out.source = source;
|
|
@@ -169,12 +257,23 @@ export function validateCircle(evda, dir) {
|
|
|
169
257
|
|
|
170
258
|
// The substrate's required fields, so the substrate's own reader can open this manifest.
|
|
171
259
|
if (m.kind !== "eco") fail(`${rel}: "kind" must be "eco", got ${JSON.stringify(m.kind)}. A circle is an eco.`);
|
|
172
|
-
|
|
173
|
-
if (m.
|
|
260
|
+
const opaque = opaqueIdOf(m);
|
|
261
|
+
if (typeof m.id !== "string" || !m.id.startsWith("ecos/")) fail(`${rel}: "id" must be "ecos/<id>", got ${JSON.stringify(m.id)}.`);
|
|
262
|
+
else if (opaque === null) fail(`${rel}: "id" and "namespace" must name the same eco ("${m.id}" wants namespace "${ecoNamespace(m.id.slice("ecos/".length))}"), got ${JSON.stringify(m.namespace)}.`);
|
|
263
|
+
else if (opaque === dir.name) warn(`${rel}: "id" is derived from the circle's name, and the id is what leaves the device. Re-mint it as a random id (Spec 08, ruled 2026-09-17).`);
|
|
264
|
+
else if (!OPAQUE_ID.test(opaque)) warn(`${rel}: "id" ${JSON.stringify(m.id)} is not a minted opaque id (c and 32 hex). If it says anything about the circle, it says it wherever the id travels.`);
|
|
265
|
+
out.id = opaque;
|
|
174
266
|
if (!nonEmpty(m.schemaVersion) && typeof m.schemaVersion !== "number") fail(`${rel}: "schemaVersion" is missing.`);
|
|
175
267
|
if (!nonEmpty(m.title)) fail(`${rel}: "title" is missing.`);
|
|
176
268
|
|
|
177
269
|
if (typeof m.public !== "boolean") fail(`${rel}: "public" must be true or false. A circle that does not say who may read it cannot be read.`);
|
|
270
|
+
// release: whether a file here may be released into the public circle by the mark. Read only on
|
|
271
|
+
// a keyed circle; the public circle releases nothing, it gathers.
|
|
272
|
+
if (m.release !== undefined) {
|
|
273
|
+
if (!isRelease(m.release)) fail(`${rel}: "release" must be "never" or a non-empty list of circle names, got ${JSON.stringify(m.release)}. It says where a file living here may be released to.`);
|
|
274
|
+
else if (m.public === true) warn(`${rel}: "release" is set on a public circle. Everyone already reads it, so there is nowhere further to release to. Ignored.`);
|
|
275
|
+
else out.release = m.release;
|
|
276
|
+
}
|
|
178
277
|
if (!Array.isArray(m.maintainers) || m.maintainers.length === 0) {
|
|
179
278
|
fail(`${rel}: "maintainers" must be a non-empty list. A circle nobody holds has no one who may grant it.`);
|
|
180
279
|
} else {
|
|
@@ -192,13 +291,64 @@ export function validateCircle(evda, dir) {
|
|
|
192
291
|
if (!isObject(member)) { fail(`${rel}: member "${name}" is not a mapping.`); continue; }
|
|
193
292
|
const hasPath = typeof member.path === "string";
|
|
194
293
|
const hasTable = typeof member.table === "string";
|
|
195
|
-
|
|
294
|
+
const hasWhere = member.where !== undefined;
|
|
295
|
+
const hasRender = member.render !== undefined;
|
|
296
|
+
if (hasPath && hasTable) { fail(`${rel}: member "${name}" must have exactly one of "path" or "table".`); continue; }
|
|
297
|
+
if (hasRender) {
|
|
298
|
+
// A render member: a file the export carries that the record does not hold, rendered at
|
|
299
|
+
// publish from the released entries that name its view (HQ-1351). Public only, because a
|
|
300
|
+
// view is rendered on the way out and only the public circle is published.
|
|
301
|
+
if (hasPath || hasTable || hasWhere) { fail(`${rel}: member "${name}" has "render" and also path, table or where. A render member stands alone.`); continue; }
|
|
302
|
+
if (m.public !== true) { fail(`${rel}: member "${name}" is a render, and this circle is not public. A view is rendered at publish, and only the public circle is published.`); continue; }
|
|
303
|
+
const r = member.render;
|
|
304
|
+
if (!isObject(r) || !nonEmpty(r.view) || !nonEmpty(r.path)) { fail(`${rel}: member "${name}" render must be { view: <name>, path: <record path>, shape?: list | object }, got ${JSON.stringify(r)}.`); continue; }
|
|
305
|
+
const shape = r.shape ?? "list";
|
|
306
|
+
if (!RENDER_SHAPES.includes(shape)) { fail(`${rel}: member "${name}" render shape must be one of ${RENDER_SHAPES.join(" | ")}, got ${JSON.stringify(r.shape)}.`); continue; }
|
|
307
|
+
if (isAbsolute(r.path)) { fail(`${rel}: member "${name}" render path must be relative to the record root, got ${JSON.stringify(r.path)}.`); continue; }
|
|
308
|
+
const path = r.path.replace(/^\.?\/+/, "").replace(/\/+$/, "");
|
|
309
|
+
const abs = resolve(evda.dir, path);
|
|
310
|
+
if (path === "" || abs === evda.dir || !abs.startsWith(evda.dir + sep)) { fail(`${rel}: member "${name}" render path (${r.path}) is the record root or resolves outside the record.`); continue; }
|
|
311
|
+
out.members[name] = { kind: "render", view: r.view.trim(), rel: path, abs, shape };
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (!hasPath && !hasTable && !hasWhere) { fail(`${rel}: member "${name}" must have "path", "table", "where" (files selected by their frontmatter), or "render" (a view rendered at publish).`); continue; }
|
|
315
|
+
if (!hasPath && !hasTable) {
|
|
316
|
+
// A rule member: files in this record whose frontmatter matches, wherever they live. On any
|
|
317
|
+
// circle. `where: { share: <this circle> }` is the owner's per-entry mark; anything else is
|
|
318
|
+
// a standing rule by property, agreed once.
|
|
319
|
+
if (!isObject(member.where) || Object.keys(member.where).length === 0) { fail(`${rel}: member "${name}" where must be a non-empty mapping of frontmatter key -> scalar.`); continue; }
|
|
320
|
+
const bad = Object.entries(member.where).filter(([, v]) => !isScalar(v));
|
|
321
|
+
if (bad.length) { fail(`${rel}: member "${name}" where has non-scalar value(s): ${bad.map(([k]) => k).join(", ")}.`); continue; }
|
|
322
|
+
if (member.where[MARK] !== undefined && member.where[MARK] !== dir.name) warn(`${rel}: member "${name}" selects ${MARK}: ${JSON.stringify(member.where[MARK])}, and this circle is "${dir.name}". A mark names the circle it releases into; this rule would gather files marked for another one.`);
|
|
323
|
+
out.members[name] = { kind: "rule", where: member.where };
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
196
326
|
if (hasPath) {
|
|
197
|
-
if (
|
|
198
|
-
|
|
327
|
+
if (isAbsolute(member.path)) { fail(`${rel}: member "${name}" path must be relative to the record root, got ${JSON.stringify(member.path)}.`); continue; }
|
|
328
|
+
// "" or "." is the record root itself: the member claims everything a longer member of any
|
|
329
|
+
// circle does not (HQ-1364). It is kept as "" so every reader compares one spelling.
|
|
330
|
+
// Every member path is kept in one spelling, the resolved path relative to the root: the
|
|
331
|
+
// home of a file is picked by comparing member path lengths, so `./x` or `x/` read one
|
|
332
|
+
// level deeper than `x` and could take a file from the circle that really homes it, and
|
|
333
|
+
// `x/..` was the root under another name (HQ-1406).
|
|
334
|
+
const resolved = resolve(evda.dir, member.path);
|
|
335
|
+
const path = resolved === evda.dir ? "" : relative(evda.dir, resolved).split(sep).join("/");
|
|
336
|
+
if (path === "") {
|
|
337
|
+
if (m.public === true) { fail(`${rel}: member "${name}" is the record root, and this circle is public. The root is the private default; a public circle names what it publishes.`); continue; }
|
|
338
|
+
out.members[name] = { kind: "path", rel: "", abs: evda.dir, isDir: true };
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
const abs = resolve(evda.dir, path);
|
|
199
342
|
if (abs !== evda.dir && !abs.startsWith(evda.dir + sep)) { fail(`${rel}: member "${name}" path (${member.path}) resolves outside the record.`); continue; }
|
|
200
|
-
if (!existsSync(abs)) {
|
|
201
|
-
|
|
343
|
+
if (!existsSync(abs)) {
|
|
344
|
+
// Coded, so the sync layer can tell "this device was never sent the circle" from a broken
|
|
345
|
+
// declaration: a device without a grant on a circle has none of its members, by design.
|
|
346
|
+
fail(`${rel}: member "${name}" is declared at ${member.path} but nothing is there. A missing member and an empty one are different facts.`);
|
|
347
|
+
problems.at(-1).code = "MISSING_MEMBER";
|
|
348
|
+
problems.at(-1).member = { name, rel: path };
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
out.members[name] = { kind: "path", rel: path, abs, isDir: statSync(abs).isDirectory() };
|
|
202
352
|
} else {
|
|
203
353
|
if (member.table.trim() === "") { fail(`${rel}: member "${name}" has an empty table name.`); continue; }
|
|
204
354
|
if (m.public !== true) fail(`${rel}: member "${name}" is a table, and this circle is not public. A table is readable by its host; a private circle's host may not read it (the person's threat model).`);
|
|
@@ -209,6 +359,43 @@ export function validateCircle(evda, dir) {
|
|
|
209
359
|
out.members[name] = { kind: "table", table: member.table, where };
|
|
210
360
|
}
|
|
211
361
|
}
|
|
362
|
+
// A rendered file is written into the export at its path; two members rendering to one path,
|
|
363
|
+
// or two rendering one view, is one file with two answers. And a rendered path is never also
|
|
364
|
+
// a path member: the export cannot carry both the record's bytes and a rendering there.
|
|
365
|
+
const renders = Object.entries(out.members).filter(([, mm]) => mm.kind === "render");
|
|
366
|
+
const paths = Object.values(out.members).filter((mm) => mm.kind === "path");
|
|
367
|
+
const seenPath = new Map();
|
|
368
|
+
const seenView = new Map();
|
|
369
|
+
for (const [name, mm] of renders) {
|
|
370
|
+
if (seenPath.has(mm.rel)) fail(`${rel}: members "${seenPath.get(mm.rel)}" and "${name}" both render to ${mm.rel}. One file per path.`);
|
|
371
|
+
seenPath.set(mm.rel, name);
|
|
372
|
+
if (seenView.has(mm.view)) fail(`${rel}: members "${seenView.get(mm.view)}" and "${name}" both render view "${mm.view}". One member per view.`);
|
|
373
|
+
seenView.set(mm.view, name);
|
|
374
|
+
const under = paths.find((p) => p.rel === "" || (p.isDir ? mm.rel === p.rel || mm.rel.startsWith(`${p.rel}/`) : mm.rel === p.rel));
|
|
375
|
+
if (under) fail(`${rel}: member "${name}" renders to ${mm.rel}, which is also under a path member of this circle (${under.rel || "the record root"}). A rendered path is never also a path member.`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// A circle mounted from another holder: its files land under `mount.path`, which must be one of
|
|
380
|
+
// this circle's path members, so what the scan walks and what the fold writes are one place.
|
|
381
|
+
if (m.mount !== undefined) {
|
|
382
|
+
if (!isObject(m.mount) || !nonEmpty(m.mount.holder) || !nonEmpty(m.mount.path)) fail(`${rel}: "mount" must be { holder: <record id>, path: <relative path> }, got ${JSON.stringify(m.mount)}.`);
|
|
383
|
+
else if (m.public === true) fail(`${rel}: "mount" is set on a public circle. A mount is another holder's private circle held here; a public circle is read from its export.`);
|
|
384
|
+
else {
|
|
385
|
+
const at = m.mount.path.replace(/\/+$/, "");
|
|
386
|
+
const member = Object.values(out.members).find((mm) => mm.kind === "path" && mm.rel.replace(/\/+$/, "") === at);
|
|
387
|
+
const declared = isObject(m.members) && Object.values(m.members).some((mm) => isObject(mm) && typeof mm.path === "string" && mm.path.replace(/\/+$/, "") === at);
|
|
388
|
+
if (!declared) fail(`${rel}: "mount.path" (${m.mount.path}) is not a path member of this circle. Declare it as one, so the scan and the fold agree on where the mount is.`);
|
|
389
|
+
else if (member && !member.isDir) fail(`${rel}: "mount.path" (${m.mount.path}) is a file. A mount is a directory the other holder's files land under.`);
|
|
390
|
+
else out.mount = { holder: m.mount.holder.trim(), rel: at };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Frozen: a held copy whose grant was revoked. Says since when and by what; nothing else changes.
|
|
395
|
+
if (m.frozen !== undefined) {
|
|
396
|
+
if (!isObject(m.frozen) || !/^\d{4}-\d{2}-\d{2}$/.test(String(m.frozen.since ?? "")) || !nonEmpty(m.frozen.by)) fail(`${rel}: "frozen" must be { since: YYYY-MM-DD, by: <what froze it> }, got ${JSON.stringify(m.frozen)}.`);
|
|
397
|
+
else if (m.public === true) fail(`${rel}: "frozen" is set on a public circle. A public circle has no grant to revoke.`);
|
|
398
|
+
else out.frozen = { since: String(m.frozen.since), by: m.frozen.by.trim() };
|
|
212
399
|
}
|
|
213
400
|
|
|
214
401
|
if (!nonEmpty(m.description)) warn(`${rel}: has no "description". Say in plain language who may read this circle and why.`);
|
|
@@ -230,6 +417,54 @@ export function readCircles(evda) {
|
|
|
230
417
|
return dirs.map((d) => validateCircle(evda, d));
|
|
231
418
|
}
|
|
232
419
|
|
|
420
|
+
// The mounts a record holds, checked against its `mounts` role (HQ-835). A circle held from another
|
|
421
|
+
// holder lands at `<mounts>/<owner record id>/<one subfolder>`; without the role, anywhere its
|
|
422
|
+
// manifest says, one per owner. Returns { root, held, owners, problems }, where each problem is a
|
|
423
|
+
// sentence for doctor. Never throws for a problem; a mount root at the record root is one.
|
|
424
|
+
//
|
|
425
|
+
// - a circle mounted from this record's own id (a holder does not mount its own circles);
|
|
426
|
+
// - under a mapped root, a mount that is not exactly one subfolder under its owner's directory;
|
|
427
|
+
// - two mounts where one is the other or lies inside it (one subfolder per circle);
|
|
428
|
+
// - a home circle with a path member under the mount root (what lands there is another holder's).
|
|
429
|
+
export function mountProblems(evda, circles) {
|
|
430
|
+
const problems = [];
|
|
431
|
+
const r = resolveRole(evda, "mounts");
|
|
432
|
+
let root = null;
|
|
433
|
+
if (r.mapped) {
|
|
434
|
+
root = String(r.rel).replace(/\/+$/, "").replace(/^\.\/+/, "");
|
|
435
|
+
if (root === "" || root === ".") {
|
|
436
|
+
problems.push('the "mounts" role is the record root; another holder\'s files would sit beside this record\'s own. Map it to a directory such as mounts.');
|
|
437
|
+
root = null;
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
const me = evda.manifest?.id ?? null;
|
|
441
|
+
const held = circles.filter((c) => c.mount);
|
|
442
|
+
for (const c of held) {
|
|
443
|
+
if (me && c.mount.holder === me) problems.push(`circle "${c.name}" is mounted from ${me}, which is this record. A holder does not mount its own circles.`);
|
|
444
|
+
if (root !== null) {
|
|
445
|
+
const owner = `${root}/${c.mount.holder}/`;
|
|
446
|
+
const rest = c.mount.rel.startsWith(owner) ? c.mount.rel.slice(owner.length) : null;
|
|
447
|
+
if (rest === null || rest === "" || rest.includes("/")) problems.push(`circle "${c.name}" is mounted at ${c.mount.rel}; under the mounts role a circle from ${c.mount.holder} is held at ${owner}<one subfolder>.`);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
for (let i = 0; i < held.length; i++) {
|
|
451
|
+
for (let j = i + 1; j < held.length; j++) {
|
|
452
|
+
const [a, b] = [held[i].mount.rel, held[j].mount.rel];
|
|
453
|
+
if (a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)) problems.push(`circles "${held[i].name}" (${a}) and "${held[j].name}" (${b}) are mounted one inside the other. One subfolder per circle, never nested.`);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
if (root !== null) {
|
|
457
|
+
for (const c of circles.filter((x) => !x.mount)) {
|
|
458
|
+
for (const [name, m] of Object.entries(c.members)) {
|
|
459
|
+
if (m.kind !== "path" || m.rel === "") continue;
|
|
460
|
+
const rel = m.rel.replace(/\/+$/, "");
|
|
461
|
+
if (rel === root || rel.startsWith(`${root}/`)) problems.push(`circle "${c.name}" member "${name}" (${rel}) is under the mount root ${root}; what lands there is another holder's, held by the circle mounted at it.`);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return { root, held: held.length, owners: new Set(held.map((c) => c.mount.holder)).size, problems };
|
|
466
|
+
}
|
|
467
|
+
|
|
233
468
|
// The circles this tool may read: public, and with no failing problem. Throws when the record
|
|
234
469
|
// has a circles role and a circle in it is malformed, because serving a record with a broken
|
|
235
470
|
// boundary declaration is worse than serving nothing.
|
|
@@ -264,30 +499,246 @@ export function holders(circle) {
|
|
|
264
499
|
return Array.isArray(circle.manifest?.maintainers) ? circle.manifest.maintainers : [];
|
|
265
500
|
}
|
|
266
501
|
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
|
|
270
|
-
|
|
502
|
+
// A path member covers the file it names, or every file under the directory it names; the record
|
|
503
|
+
// root ("") covers every file; a table, rule or render member covers no file.
|
|
504
|
+
const underPath = (m, abs, root) => m.kind === "path" && (m.rel === "" ? abs.startsWith(root + sep) : m.isDir ? abs.startsWith(m.abs + sep) : abs === m.abs);
|
|
505
|
+
|
|
506
|
+
// The circles that HOME an entry (Spec 07 section 3, HQ-1364): of every path member covering it,
|
|
507
|
+
// across every circle, the ones whose member path is longest. One name is the answer; two names is
|
|
508
|
+
// two circles claiming the same file at the same depth, which doctor fails; none is uncovered.
|
|
509
|
+
export function homesOf(evda, circles, abs) {
|
|
510
|
+
// A file inside a sub-record of a bounded record is the sub-record's, homed in none of these.
|
|
511
|
+
const stops = boundaries(evda);
|
|
512
|
+
if (stops.length && insideAny(stops, abs.slice(evda.dir.length + 1).split(sep).join("/"))) return [];
|
|
513
|
+
let best = -1;
|
|
514
|
+
let names = [];
|
|
515
|
+
for (const c of circles) {
|
|
516
|
+
for (const m of Object.values(c.members)) {
|
|
517
|
+
if (!underPath(m, abs, evda.dir)) continue;
|
|
518
|
+
if (m.rel.length > best) { best = m.rel.length; names = [c.name]; }
|
|
519
|
+
else if (m.rel.length === best && !names.includes(c.name)) names.push(c.name);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
return names;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Every entry resolves to exactly one circle (Spec 07, section 3). Returns each entry with the
|
|
526
|
+
// circles that home it, so doctor can fail on zero and on two.
|
|
271
527
|
export function coverage(evda, circles, entries) {
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
528
|
+
return entries.map((abs) => ({ abs, circles: homesOf(evda, circles, abs) }));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// The frontmatter of a markdown file as an object, or null when it has none or it does not parse.
|
|
532
|
+
// A file with no frontmatter is not marked; a file whose frontmatter is broken is not marked either,
|
|
533
|
+
// and doctor's typed-entry pass names it if it claims a kind.
|
|
534
|
+
export function frontmatterOf(text) {
|
|
535
|
+
const lines = text.split("\n");
|
|
536
|
+
if (lines[0] !== "---") return null;
|
|
537
|
+
const end = lines.indexOf("---", 1);
|
|
538
|
+
if (end === -1) return null;
|
|
539
|
+
try {
|
|
540
|
+
const front = parseYaml(lines.slice(1, end).join("\n"));
|
|
541
|
+
return isObject(front) ? front : null;
|
|
542
|
+
} catch {
|
|
543
|
+
return null;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// Every markdown file under the record root, skipping dot-directories and node_modules. The mark
|
|
548
|
+
// walk reads the whole record, not only the record roles: a released fact may live anywhere.
|
|
549
|
+
export function markdownFiles(root) {
|
|
550
|
+
const out = [];
|
|
551
|
+
const walk = (abs) => {
|
|
552
|
+
for (const name of readdirSync(abs).sort()) {
|
|
553
|
+
if (name.startsWith(".") || name === "node_modules") continue;
|
|
554
|
+
const p = join(abs, name);
|
|
555
|
+
const st = statSync(p);
|
|
556
|
+
if (st.isDirectory()) walk(p);
|
|
557
|
+
else if (st.isFile() && /\.md$/i.test(name)) out.push(p);
|
|
558
|
+
}
|
|
559
|
+
};
|
|
560
|
+
walk(root);
|
|
561
|
+
return out;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
// A where value matches the frontmatter value, or one item of it when the frontmatter holds a
|
|
565
|
+
// list: `share: [doctor, public]` matches `where: { share: doctor }`.
|
|
566
|
+
// Whether a file is just noted: markdown whose frontmatter says `noted: true`. Anything unreadable
|
|
567
|
+
// or without frontmatter is not.
|
|
568
|
+
export function isNoted(abs) {
|
|
569
|
+
if (!/\.md$/i.test(abs)) return false;
|
|
570
|
+
let text;
|
|
571
|
+
try { text = readFileSync(abs, "utf8"); } catch { return false; }
|
|
572
|
+
return frontmatterOf(text)?.[NOTED] === true;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// A markdown file's parsed frontmatter, or null when it is unreadable or has none.
|
|
576
|
+
function frontOfFile(abs) {
|
|
577
|
+
if (!/\.md$/i.test(abs)) return null;
|
|
578
|
+
try { return frontmatterOf(readFileSync(abs, "utf8")); } catch { return null; }
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// Sealed (HQ-1220). EVDA_SEALED_FALSIFY=file ignores the file flag and =sections ignores sealed
|
|
582
|
+
// blocks, everywhere at once, so a test can show each reader is held by this filter and no other.
|
|
583
|
+
const sealedFalsified = (mode) => String(process.env.EVDA_SEALED_FALSIFY ?? "").split(",").map((m) => m.trim()).includes(mode);
|
|
584
|
+
|
|
585
|
+
// Whether parsed frontmatter seals its whole entry: `sealed: true`, nothing else.
|
|
586
|
+
export const sealedFront = (front) => !sealedFalsified("file") && front?.[SEALED] === true;
|
|
587
|
+
|
|
588
|
+
// Whether a file is sealed: markdown whose frontmatter says `sealed: true`. A symlink is read through,
|
|
589
|
+
// so a link to a sealed file is sealed.
|
|
590
|
+
export function isSealed(abs) {
|
|
591
|
+
if (!/\.md$/i.test(abs)) return false;
|
|
592
|
+
let text;
|
|
593
|
+
try { text = readFileSync(abs, "utf8"); } catch { return false; }
|
|
594
|
+
return sealedFront(frontmatterOf(text));
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// The sealed sections of a text: a fence of three or more backticks or tildes whose info string
|
|
598
|
+
// starts with the word `sealed`, to the next fence of the same character at least as long. Fenced
|
|
599
|
+
// code around it does not unseal it; an unclosed one seals to the end of the text. Returns
|
|
600
|
+
// [{ start, end, closed }] as 0-based line indexes, the fences included.
|
|
601
|
+
const SEAL_OPEN = /^ {0,3}(`{3,}|~{3,})[ \t]*sealed(?:[ \t].*)?$/i;
|
|
602
|
+
export function sealedSections(text) {
|
|
603
|
+
if (sealedFalsified("sections")) return [];
|
|
604
|
+
const lines = String(text).split("\n");
|
|
605
|
+
const out = [];
|
|
606
|
+
for (let i = 0; i < lines.length; i++) {
|
|
607
|
+
const open = SEAL_OPEN.exec(lines[i].replace(/\r$/, ""));
|
|
608
|
+
if (!open) continue;
|
|
609
|
+
const fence = open[1];
|
|
610
|
+
const close = new RegExp(`^ {0,3}${fence[0] === "`" ? "`" : "~"}{${fence.length},}[ \\t]*$`);
|
|
611
|
+
let j = i + 1;
|
|
612
|
+
while (j < lines.length && !close.test(lines[j].replace(/\r$/, ""))) j++;
|
|
613
|
+
const closed = j < lines.length;
|
|
614
|
+
out.push({ start: i, end: closed ? j : lines.length - 1, closed });
|
|
615
|
+
i = j;
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The text with every sealed section taken out. `placeholder` null removes the lines (an export);
|
|
621
|
+
// a string replaces each section with that one line (an AI's view, which says something is there).
|
|
622
|
+
export function withoutSealed(text, { placeholder = null } = {}) {
|
|
623
|
+
const sections = sealedSections(text);
|
|
624
|
+
if (!sections.length) return { text, sections: 0, unclosed: 0 };
|
|
625
|
+
const lines = String(text).split("\n");
|
|
626
|
+
const kept = [];
|
|
627
|
+
let at = 0;
|
|
628
|
+
for (const s of sections) {
|
|
629
|
+
kept.push(...lines.slice(at, s.start));
|
|
630
|
+
if (placeholder !== null) kept.push(placeholder);
|
|
631
|
+
at = s.end + 1;
|
|
632
|
+
}
|
|
633
|
+
kept.push(...lines.slice(at));
|
|
634
|
+
let out = kept.join("\n");
|
|
635
|
+
if (at >= lines.length && placeholder === null && !out.endsWith("\n") && out !== "") out += "\n";
|
|
636
|
+
return { text: out, sections: sections.length, unclosed: sections.filter((s) => !s.closed).length };
|
|
277
637
|
}
|
|
278
638
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
639
|
+
export const matchesWhere =(front, where) => Object.entries(where).every(([k, v]) => front[k] === v || (Array.isArray(front[k]) && front[k].includes(v)));
|
|
640
|
+
const filesUnderPath = (abs) => {
|
|
641
|
+
const st = statSync(abs);
|
|
642
|
+
if (st.isFile()) return [abs];
|
|
643
|
+
const out = [];
|
|
644
|
+
for (const name of readdirSync(abs).sort()) {
|
|
645
|
+
if (name.startsWith(".")) continue;
|
|
646
|
+
out.push(...filesUnderPath(join(abs, name)));
|
|
647
|
+
}
|
|
648
|
+
return out;
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
// What each circle gathers beyond its own paths (Spec 07 section 3, HQ-1351): for every circle that
|
|
652
|
+
// has a rule member, each file whose frontmatter matches, released out of its one home circle when
|
|
653
|
+
// that home's `release` names this circle; plus every match it refused and why. For a public circle
|
|
654
|
+
// the files under its path members and its table members are listed too, so doctor can print the
|
|
655
|
+
// whole public set every run and it cannot grow quietly. `files` is the markdown to scan; the
|
|
656
|
+
// default is the whole record. Only circles with a rule member or public: true are returned.
|
|
657
|
+
export function released(evda, circles, { files = null } = {}) {
|
|
658
|
+
const rel = (abs) => abs.slice(evda.dir.length + 1).split(sep).join("/");
|
|
659
|
+
const stops = boundaries(evda);
|
|
660
|
+
const scan = (files ?? markdownFiles(evda.dir)).filter((abs) => !stops.length || !insideAny(stops, rel(abs)));
|
|
661
|
+
// The home circle(s) of a file, by longest member path: the same answer coverage() gives doctor.
|
|
662
|
+
const homeNames = (abs) => homesOf(evda, circles, abs);
|
|
663
|
+
const out = [];
|
|
664
|
+
for (const c of circles) {
|
|
665
|
+
const isPublic = c.manifest?.public === true;
|
|
666
|
+
const rules = Object.entries(c.members).filter(([, m]) => m.kind === "rule");
|
|
667
|
+
if (!isPublic && rules.length === 0) continue;
|
|
668
|
+
const byPath = [];
|
|
669
|
+
const byRule = [];
|
|
670
|
+
const refused = [];
|
|
671
|
+
const tables = [];
|
|
672
|
+
const renders = [];
|
|
673
|
+
if (isPublic) {
|
|
674
|
+
for (const [name, m] of Object.entries(c.members)) {
|
|
675
|
+
// Only the files this member HOMES: a file under it that a longer member of another circle
|
|
676
|
+
// claims is that circle's, not public.
|
|
677
|
+
if (m.kind === "path") for (const abs of filesUnderPath(m.abs)) {
|
|
678
|
+
if (homeNames(abs).join() !== c.name) continue;
|
|
679
|
+
const held = heldBy(frontOfFile(abs), "publish");
|
|
680
|
+
if (held === SEALED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is sealed: only its owner opens it, on their own devices, and a sealed entry is never published, even from a public folder" }); continue; }
|
|
681
|
+
if (held === NOTED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is just noted, and a noted entry is never published, even from a public folder" }); continue; }
|
|
682
|
+
byPath.push({ path: rel(abs), abs, member: name });
|
|
683
|
+
}
|
|
684
|
+
if (m.kind === "table") tables.push({ member: name, table: m.table, where: m.where });
|
|
685
|
+
// A render member is listed so the whole public set is on the screen; what it draws from
|
|
686
|
+
// is lib/render.mjs's answer, computed from byPath and byRule.
|
|
687
|
+
if (m.kind === "render") renders.push({ member: name, view: m.view, path: m.rel, shape: m.shape });
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
for (const [name, m] of rules) {
|
|
691
|
+
for (const abs of scan) {
|
|
692
|
+
let text;
|
|
693
|
+
try { text = readFileSync(abs, "utf8"); } catch { continue; }
|
|
694
|
+
const front = frontmatterOf(text);
|
|
695
|
+
if (!front || !matchesWhere(front, m.where)) continue;
|
|
696
|
+
// Sealed: no rule and no mark releases it, ever (HQ-1220). Checked before noted, so an entry
|
|
697
|
+
// that is both is refused as the stronger of the two.
|
|
698
|
+
const held = heldBy(front, "publish");
|
|
699
|
+
if (held === SEALED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is sealed: only its owner opens it, on their own devices, and no mark releases a sealed entry" }); continue; }
|
|
700
|
+
// Just noted: no rule and no mark releases it until the owner says it can be read.
|
|
701
|
+
if (held === NOTED) { refused.push({ path: rel(abs), abs, member: name, reason: "it is just noted, and a noted entry goes nowhere until its owner releases it" }); continue; }
|
|
702
|
+
const homes = circles.filter((k) => homeNames(abs).includes(k.name));
|
|
703
|
+
if (homes.length === 1 && homes[0] === c) continue; // already here by path
|
|
704
|
+
if (homes.length === 0) { refused.push({ path: rel(abs), abs, member: name, reason: "no circle covers it by path, so nothing says it may be released" }); continue; }
|
|
705
|
+
if (homes.length > 1) { refused.push({ path: rel(abs), abs, member: name, reason: `covered by ${homes.length} circles (${homes.map((o) => o.name).join(", ")}); one home per entry` }); continue; }
|
|
706
|
+
const [home] = homes;
|
|
707
|
+
if (home.manifest?.public === true) { refused.push({ path: rel(abs), abs, member: name, circle: home.name, reason: `already public (home "${home.name}"), so there is nowhere further to release to` }); continue; }
|
|
708
|
+
if (home.release === "never") { refused.push({ path: rel(abs), abs, member: name, circle: home.name, reason: `its home circle "${home.name}" releases nothing (release: never)` }); continue; }
|
|
709
|
+
if (!home.release.includes(c.name)) { refused.push({ path: rel(abs), abs, member: name, circle: home.name, reason: `its home circle "${home.name}" may release to ${home.release.map((n) => `"${n}"`).join(", ")}, not to "${c.name}"` }); continue; }
|
|
710
|
+
byRule.push({ path: rel(abs), abs, member: name, circle: home.name });
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
out.push({ circle: c.name, public: isPublic, byPath, byRule, tables, renders, refused });
|
|
714
|
+
}
|
|
715
|
+
return out;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Build an eco manifest for a circle. `name` is the directory name and never reaches the id: the
|
|
719
|
+
// id is minted at random here, once, and the namespace follows from it, so the two cannot disagree.
|
|
720
|
+
// Pass `id` only to rewrite a circle that already has one.
|
|
721
|
+
export function circleManifest({ name, id = mintCircleId(), title = name, isPublic = false, holders: hs = [], members = {}, policy = null, description = null, release = null, mount = null }) {
|
|
282
722
|
const m = {
|
|
283
|
-
id: ecoId(
|
|
723
|
+
id: ecoId(id),
|
|
284
724
|
kind: "eco",
|
|
285
725
|
schemaVersion: ECO_SCHEMA_VERSION,
|
|
286
|
-
namespace: ecoNamespace(
|
|
726
|
+
namespace: ecoNamespace(id),
|
|
287
727
|
title,
|
|
288
728
|
};
|
|
289
729
|
if (description) m.description = description;
|
|
290
730
|
m.public = isPublic;
|
|
731
|
+
// A circle says where a file living in it may be released to. Written explicitly, so the default
|
|
732
|
+
// is visible in the file rather than remembered.
|
|
733
|
+
if (!isPublic) {
|
|
734
|
+
if (release !== null && !isRelease(release)) throw new EvdaError(`"release" must be "never" or a non-empty list of circle names.`, { exit: 2 });
|
|
735
|
+
m.release = release ?? "never";
|
|
736
|
+
} else if (release !== null) throw new EvdaError(`a public circle has no "release"; everyone already reads it.`, { exit: 2 });
|
|
737
|
+
if (mount !== null) {
|
|
738
|
+
if (isPublic) throw new EvdaError(`a public circle has no "mount"; it is read from its export.`, { exit: 2 });
|
|
739
|
+
if (!isObject(mount) || !nonEmpty(mount.holder) || !nonEmpty(mount.path)) throw new EvdaError(`"mount" must be { holder: <record id>, path: <relative path> }.`, { exit: 2 });
|
|
740
|
+
m.mount = { holder: mount.holder, path: mount.path };
|
|
741
|
+
}
|
|
291
742
|
m.maintainers = hs.map((h) => (typeof h === "string" ? { name: h, role: "holder" } : { name: h.name, role: h.role ?? "holder" }));
|
|
292
743
|
m.members = members;
|
|
293
744
|
if (Array.isArray(policy)) m.policy = policy;
|