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/LICENSE +21 -0
- package/README.md +207 -0
- package/bin/evda.mjs +382 -0
- package/create-evda.mjs +184 -0
- package/lib/agents.mjs +90 -0
- package/lib/circles.mjs +317 -0
- package/lib/doctor.mjs +419 -0
- package/lib/errors.mjs +26 -0
- package/lib/folders.mjs +160 -0
- package/lib/grants.mjs +188 -0
- package/lib/index.mjs +67 -0
- package/lib/ledger.mjs +168 -0
- package/lib/locate.mjs +76 -0
- package/lib/manifest.mjs +181 -0
- package/lib/migrate.mjs +66 -0
- package/lib/record.mjs +80 -0
- package/lib/references.mjs +283 -0
- package/lib/resolvers.mjs +123 -0
- package/lib/roles.mjs +89 -0
- package/lib/validate.mjs +84 -0
- package/lib/vectorcli.mjs +49 -0
- package/lib/vectors.mjs +53 -0
- package/lib/yaml.mjs +352 -0
- package/migrations/README.md +20 -0
- package/package.json +31 -0
- package/templates/AGENTS.md +170 -0
- package/templates/CLAUDE.md +3 -0
- package/templates/circles-README.md +27 -0
- package/templates/evda-digest.md +35 -0
- package/templates/hypotheses.md +70 -0
package/lib/manifest.mjs
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// evda.json and .evda/version.
|
|
2
|
+
//
|
|
3
|
+
// Two copies of the schema version exist on purpose. `evda.json` declares what the record claims
|
|
4
|
+
// to be; `.evda/version` records what a tool last left on disk. They are written by different
|
|
5
|
+
// acts (a hand edit or a newer tool can change the manifest; only a migration changes the version
|
|
6
|
+
// file), so a disagreement between them is information, and the tool refuses rather than guesses.
|
|
7
|
+
//
|
|
8
|
+
// Unknown keys in the manifest are preserved on every write. A v1 tool reading a v2 manifest
|
|
9
|
+
// mutates only the keys it knows and writes the object back whole, in the order it was read.
|
|
10
|
+
|
|
11
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
|
|
12
|
+
import { join, dirname, resolve } from "node:path";
|
|
13
|
+
import { EvdaError } from "./errors.mjs";
|
|
14
|
+
|
|
15
|
+
export const SCHEMA_VERSION = 1;
|
|
16
|
+
export const KNOWN_LAYERS = ["record", "ledger", "circles"];
|
|
17
|
+
export const KNOWN_KEYS = ["evda", "id", "name", "owner", "created", "layers", "mapping"];
|
|
18
|
+
|
|
19
|
+
// Who holds the record (Spec 07, section 1): a person, an agent, or a company. `owner.kind` on the
|
|
20
|
+
// manifest; absent means person. `id` is the record's atlas id, the name another record uses in a
|
|
21
|
+
// grant (`@<id>/...` in a reference, `principalId` in a member tuple).
|
|
22
|
+
export const HOLDER_KINDS = ["person", "agent", "company"];
|
|
23
|
+
|
|
24
|
+
export function holderOf(evda) {
|
|
25
|
+
const m = evda.manifest ?? evda;
|
|
26
|
+
const owner = m.owner && typeof m.owner === "object" ? m.owner : {};
|
|
27
|
+
return { id: typeof m.id === "string" && m.id.trim() !== "" ? m.id : null, kind: owner.kind ?? "person", name: typeof owner.name === "string" ? owner.name : null };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Problems with the holder fields, for doctor. [] when they are fine or absent.
|
|
31
|
+
export function holderProblems(manifest) {
|
|
32
|
+
const out = [];
|
|
33
|
+
if (manifest.owner !== undefined && (manifest.owner === null || typeof manifest.owner !== "object" || Array.isArray(manifest.owner))) out.push(`"owner" must be a mapping.`);
|
|
34
|
+
else if (manifest.owner?.kind !== undefined && !HOLDER_KINDS.includes(manifest.owner.kind)) out.push(`"owner.kind" must be one of ${HOLDER_KINDS.join(", ")}, got ${JSON.stringify(manifest.owner.kind)}.`);
|
|
35
|
+
if (manifest.id !== undefined) {
|
|
36
|
+
if (typeof manifest.id !== "string" || manifest.id.trim() === "") out.push(`"id" must be the record's atlas id, a non-empty string.`);
|
|
37
|
+
else if (/\s/.test(manifest.id) || manifest.id.includes("/") || manifest.id.includes("--")) out.push(`"id" ${JSON.stringify(manifest.id)} is not a record id: no whitespace, no /, no -- (it names this record in another record's grant).`);
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const manifestPath = (dir) => join(dir, "evda.json");
|
|
43
|
+
export const versionPath = (dir) => join(dir, ".evda", "version");
|
|
44
|
+
|
|
45
|
+
// Walk up from `start` until a directory holding evda.json is found. Null if none.
|
|
46
|
+
export function findRoot(start = process.cwd()) {
|
|
47
|
+
let dir = resolve(start);
|
|
48
|
+
for (;;) {
|
|
49
|
+
if (existsSync(manifestPath(dir))) return dir;
|
|
50
|
+
const parent = dirname(dir);
|
|
51
|
+
if (parent === dir) return null;
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function readManifest(dir) {
|
|
57
|
+
const p = manifestPath(dir);
|
|
58
|
+
if (!existsSync(p)) {
|
|
59
|
+
throw new EvdaError(`REFUSED: no evda.json in ${dir}. This directory is not an EVDA.`, {
|
|
60
|
+
code: "NO_MANIFEST",
|
|
61
|
+
exit: 2,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
let m;
|
|
65
|
+
try {
|
|
66
|
+
m = JSON.parse(readFileSync(p, "utf8"));
|
|
67
|
+
} catch (e) {
|
|
68
|
+
throw new EvdaError(`REFUSED: evda.json is not valid JSON (${e.message}). Nothing was written.`, {
|
|
69
|
+
code: "BAD_MANIFEST",
|
|
70
|
+
exit: 2,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
if (m === null || typeof m !== "object" || Array.isArray(m)) {
|
|
74
|
+
throw new EvdaError("REFUSED: evda.json must hold a JSON object.", { code: "BAD_MANIFEST", exit: 2 });
|
|
75
|
+
}
|
|
76
|
+
if (!Number.isInteger(m.evda) || m.evda < 1) {
|
|
77
|
+
throw new EvdaError('REFUSED: evda.json has no integer "evda" schema version.', {
|
|
78
|
+
code: "BAD_MANIFEST",
|
|
79
|
+
exit: 2,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
if (!Array.isArray(m.layers) || !m.layers.every((l) => typeof l === "string")) {
|
|
83
|
+
throw new EvdaError('REFUSED: evda.json "layers" must be a list of layer names.', {
|
|
84
|
+
code: "BAD_MANIFEST",
|
|
85
|
+
exit: 2,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
if (m.mapping === undefined) {
|
|
89
|
+
throw new EvdaError(
|
|
90
|
+
'REFUSED: evda.json has no "mapping". Every tool reads roles through the mapping, so without one there is nothing to read.',
|
|
91
|
+
{ code: "BAD_MANIFEST", exit: 2 }
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (m.mapping === null || typeof m.mapping !== "object" || Array.isArray(m.mapping)) {
|
|
95
|
+
throw new EvdaError('REFUSED: evda.json "mapping" must be an object of role -> relative path.', {
|
|
96
|
+
code: "BAD_MANIFEST",
|
|
97
|
+
exit: 2,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
for (const [role, rel] of Object.entries(m.mapping)) {
|
|
101
|
+
if (typeof rel !== "string" || rel === "") {
|
|
102
|
+
throw new EvdaError(`REFUSED: mapping for role "${role}" is not a path.`, { code: "BAD_MANIFEST", exit: 2 });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return m;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function unknownKeys(manifest) {
|
|
109
|
+
return Object.keys(manifest).filter((k) => !KNOWN_KEYS.includes(k));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function unknownLayers(manifest) {
|
|
113
|
+
return manifest.layers.filter((l) => !KNOWN_LAYERS.includes(l));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Writes the object back whole. Key order is insertion order, which JSON.parse preserves, so a
|
|
117
|
+
// manifest read and written unchanged is byte-identical apart from formatting.
|
|
118
|
+
export function writeManifest(dir, manifest) {
|
|
119
|
+
writeFileSync(manifestPath(dir), JSON.stringify(manifest, null, 2) + "\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// null when the file is absent. Throws when it is present and not an integer.
|
|
123
|
+
export function readVersion(dir) {
|
|
124
|
+
const p = versionPath(dir);
|
|
125
|
+
if (!existsSync(p)) return null;
|
|
126
|
+
const raw = readFileSync(p, "utf8").trim();
|
|
127
|
+
if (!/^\d+$/.test(raw)) {
|
|
128
|
+
throw new EvdaError(`REFUSED: .evda/version holds "${raw}", which is not a schema version.`, {
|
|
129
|
+
code: "BAD_VERSION",
|
|
130
|
+
exit: 2,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return Number(raw);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function writeVersion(dir, v) {
|
|
137
|
+
mkdirSync(join(dir, ".evda"), { recursive: true });
|
|
138
|
+
writeFileSync(versionPath(dir), `${v}\n`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The refusal. Returns what it compared so callers can print it.
|
|
142
|
+
export function checkVersion(dir, manifest) {
|
|
143
|
+
const onDisk = readVersion(dir);
|
|
144
|
+
if (onDisk === null) {
|
|
145
|
+
throw new EvdaError(
|
|
146
|
+
[
|
|
147
|
+
`REFUSED: evda.json declares schema ${manifest.evda} but there is no .evda/version file.`,
|
|
148
|
+
" The manifest says what the record claims to be; .evda/version says what is actually on disk.",
|
|
149
|
+
" Without the second there is nothing to check the first against, and guessing is how a record",
|
|
150
|
+
" starts disagreeing with itself.",
|
|
151
|
+
" If .evda/ was deleted on purpose: run `evda reindex --adopt`, which validates the layout",
|
|
152
|
+
` against schema ${manifest.evda} and writes the version file only if it passes.`,
|
|
153
|
+
].join("\n"),
|
|
154
|
+
{ code: "NO_VERSION", exit: 2 }
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
if (onDisk !== manifest.evda) {
|
|
158
|
+
throw new EvdaError(
|
|
159
|
+
[
|
|
160
|
+
`REFUSED: evda.json declares schema ${manifest.evda} but .evda/version says ${onDisk}.`,
|
|
161
|
+
" These are two records of one fact and they disagree. Nothing was read or written.",
|
|
162
|
+
` If a migration was interrupted: run \`evda migrate\`, which moves the files on disk to ${manifest.evda}.`,
|
|
163
|
+
` If evda.json was edited by hand: put "evda" back to ${onDisk}.`,
|
|
164
|
+
].join("\n"),
|
|
165
|
+
{ code: "VERSION_MISMATCH", exit: 2 }
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
declared: manifest.evda,
|
|
170
|
+
onDisk,
|
|
171
|
+
newerThanTool: manifest.evda > SCHEMA_VERSION,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Open a record for operating. Every command goes through here except `migrate` and
|
|
176
|
+
// `reindex --adopt`, which exist to repair the two states this refuses.
|
|
177
|
+
export function openEvda(dir) {
|
|
178
|
+
const manifest = readManifest(dir);
|
|
179
|
+
const version = checkVersion(dir, manifest);
|
|
180
|
+
return { dir: resolve(dir), manifest, version };
|
|
181
|
+
}
|
package/lib/migrate.mjs
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Migrations are files, forward-only, and each one says what it cannot reverse (Spec 05).
|
|
2
|
+
//
|
|
3
|
+
// A migration is `migrations/NNNN-<name>.mjs` with a default export:
|
|
4
|
+
// { from: 1, to: 2, cannotReverse: "one sentence", up(dir) }
|
|
5
|
+
// `cannotReverse` is required and non-empty. A migration that does not say what it destroys does
|
|
6
|
+
// not run. There are no down migrations; a record is never moved backwards, it is restored from
|
|
7
|
+
// a copy.
|
|
8
|
+
//
|
|
9
|
+
// This runner is the only thing that writes .evda/version after `create-evda`.
|
|
10
|
+
|
|
11
|
+
import { readdirSync, existsSync } from "node:fs";
|
|
12
|
+
import { join, dirname, resolve } from "node:path";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
|
+
import { EvdaError } from "./errors.mjs";
|
|
15
|
+
import { readManifest, readVersion, writeVersion, writeManifest, SCHEMA_VERSION } from "./manifest.mjs";
|
|
16
|
+
|
|
17
|
+
export const DEFAULT_MIGRATIONS_DIR = resolve(dirname(fileURLToPath(import.meta.url)), "..", "migrations");
|
|
18
|
+
|
|
19
|
+
export async function loadMigrations(dir = DEFAULT_MIGRATIONS_DIR) {
|
|
20
|
+
if (!existsSync(dir)) return [];
|
|
21
|
+
const files = readdirSync(dir).filter((f) => /^\d{4}-.*\.mjs$/.test(f)).sort();
|
|
22
|
+
const out = [];
|
|
23
|
+
for (const f of files) {
|
|
24
|
+
const mod = await import(pathToFileURL(join(dir, f)).href);
|
|
25
|
+
const m = mod.default;
|
|
26
|
+
if (!m || !Number.isInteger(m.from) || !Number.isInteger(m.to) || typeof m.up !== "function") {
|
|
27
|
+
throw new EvdaError(`migration ${f} does not export { from, to, up }.`, { exit: 2 });
|
|
28
|
+
}
|
|
29
|
+
if (m.to !== m.from + 1) throw new EvdaError(`migration ${f} must move exactly one version (${m.from} -> ${m.to}).`, { exit: 2 });
|
|
30
|
+
if (typeof m.cannotReverse !== "string" || m.cannotReverse.trim() === "") {
|
|
31
|
+
throw new EvdaError(`REFUSED: migration ${f} does not say what it cannot reverse. It does not run until it does.`, { exit: 2 });
|
|
32
|
+
}
|
|
33
|
+
out.push({ file: f, ...m });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function migrate(dir, { migrationsDir = DEFAULT_MIGRATIONS_DIR, target = SCHEMA_VERSION, out = () => {} } = {}) {
|
|
39
|
+
const manifest = readManifest(dir);
|
|
40
|
+
const onDisk = readVersion(dir);
|
|
41
|
+
if (onDisk === null) {
|
|
42
|
+
throw new EvdaError("REFUSED: no .evda/version, so there is no starting point to migrate from. Run `evda reindex --adopt` first.", { exit: 2 });
|
|
43
|
+
}
|
|
44
|
+
const goal = Math.max(target, manifest.evda);
|
|
45
|
+
if (onDisk > goal) {
|
|
46
|
+
throw new EvdaError(`REFUSED: .evda/version is ${onDisk}, newer than anything this tool knows (${goal}). Migrations are forward-only.`, { exit: 2 });
|
|
47
|
+
}
|
|
48
|
+
const all = await loadMigrations(migrationsDir);
|
|
49
|
+
const ran = [];
|
|
50
|
+
let v = onDisk;
|
|
51
|
+
while (v < goal) {
|
|
52
|
+
const m = all.find((x) => x.from === v);
|
|
53
|
+
if (!m) throw new EvdaError(`no migration from ${v} to ${v + 1}. On disk: ${onDisk}, wanted: ${goal}. Ran: ${ran.join(", ") || "none"}.`, { exit: 2 });
|
|
54
|
+
out(`migrate ${m.from} -> ${m.to} ${m.file}`);
|
|
55
|
+
out(` cannot reverse: ${m.cannotReverse}`);
|
|
56
|
+
await m.up(dir);
|
|
57
|
+
v = m.to;
|
|
58
|
+
writeVersion(dir, v);
|
|
59
|
+
ran.push(m.file);
|
|
60
|
+
}
|
|
61
|
+
if (manifest.evda !== v) {
|
|
62
|
+
manifest.evda = v;
|
|
63
|
+
writeManifest(dir, manifest);
|
|
64
|
+
}
|
|
65
|
+
return { from: onDisk, to: v, ran };
|
|
66
|
+
}
|
package/lib/record.mjs
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// The corpus, read through roles. `moment` is the occurrence primitive from Spec 05: given a
|
|
2
|
+
// window, return what was written. The interface is lifted from Iridesce's moment(); the music and
|
|
3
|
+
// instrument halves are Iridesce's own sources and did not transfer.
|
|
4
|
+
//
|
|
5
|
+
// Same honesty rule as the original: a role the record does not have is reported as absent, never
|
|
6
|
+
// as an empty list, because an empty list is a claim that nothing was written.
|
|
7
|
+
|
|
8
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
9
|
+
import { join, relative } from "node:path";
|
|
10
|
+
import { resolveRole, rolesForLayer } from "./roles.mjs";
|
|
11
|
+
import { EvdaError } from "./errors.mjs";
|
|
12
|
+
|
|
13
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
14
|
+
const LEADING_DATE = /^(\d{4}-\d{2}-\d{2})/;
|
|
15
|
+
|
|
16
|
+
export function mdFiles(dir, { recursive = true } = {}) {
|
|
17
|
+
const out = [];
|
|
18
|
+
const walk = (d) => {
|
|
19
|
+
for (const name of readdirSync(d).sort()) {
|
|
20
|
+
if (name.startsWith(".")) continue;
|
|
21
|
+
const p = join(d, name);
|
|
22
|
+
const st = statSync(p);
|
|
23
|
+
if (st.isDirectory()) { if (recursive) walk(p); continue; }
|
|
24
|
+
if (name.endsWith(".md")) out.push(p);
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
walk(dir);
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The date of a record file: leading YYYY-MM-DD in the filename, else a `date:` or `created:`
|
|
32
|
+
// frontmatter key. null when neither exists.
|
|
33
|
+
export function fileDate(path, text) {
|
|
34
|
+
const base = path.split("/").pop();
|
|
35
|
+
const fromName = LEADING_DATE.exec(base)?.[1];
|
|
36
|
+
if (fromName) return fromName;
|
|
37
|
+
if (text.startsWith("---")) {
|
|
38
|
+
const end = text.indexOf("\n---", 3);
|
|
39
|
+
const fm = end === -1 ? "" : text.slice(3, end);
|
|
40
|
+
const m = /^(?:date|created):\s*["']?(\d{4}-\d{2}-\d{2})/m.exec(fm);
|
|
41
|
+
if (m) return m[1];
|
|
42
|
+
}
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function headings(text) {
|
|
47
|
+
return text.split("\n").filter((l) => /^#{1,6} /.test(l)).map((l) => l.replace(/^#+ /, "").trim());
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function moment(evda, { from, to = from } = {}) {
|
|
51
|
+
if (!DATE.test(from ?? "") || !DATE.test(to ?? "")) {
|
|
52
|
+
throw new EvdaError(`moment needs YYYY-MM-DD dates, got ${from}..${to}`);
|
|
53
|
+
}
|
|
54
|
+
const out = { window: { from, to }, roles: {}, entries: [] };
|
|
55
|
+
for (const role of rolesForLayer("record")) {
|
|
56
|
+
const r = resolveRole(evda, role);
|
|
57
|
+
if (!r.mapped) { out.roles[role] = "absent"; continue; }
|
|
58
|
+
if (!r.exists) {
|
|
59
|
+
throw new EvdaError(`role ${role} is mapped to "${r.rel}" but that directory does not exist.`, { code: "MISSING_ROLE" });
|
|
60
|
+
}
|
|
61
|
+
let undated = 0;
|
|
62
|
+
const files = mdFiles(r.abs);
|
|
63
|
+
for (const p of files) {
|
|
64
|
+
const text = readFileSync(p, "utf8");
|
|
65
|
+
const date = fileDate(p, text);
|
|
66
|
+
if (!date) { undated += 1; continue; }
|
|
67
|
+
if (date < from || date > to) continue;
|
|
68
|
+
out.entries.push({
|
|
69
|
+
date,
|
|
70
|
+
role,
|
|
71
|
+
path: relative(evda.dir, p),
|
|
72
|
+
words: text.split(/\s+/).filter(Boolean).length,
|
|
73
|
+
headings: headings(text),
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
out.roles[role] = { path: r.rel, files: files.length, undated };
|
|
77
|
+
}
|
|
78
|
+
out.entries.sort((a, b) => (a.date === b.date ? a.path.localeCompare(b.path) : a.date.localeCompare(b.date)));
|
|
79
|
+
return out;
|
|
80
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// References instead of copies (Spec 07, section 4; Spec 00 #62).
|
|
2
|
+
//
|
|
3
|
+
// A reference is a stable pointer to something the record does not hold. It is written once, in
|
|
4
|
+
// the entry, and resolved when read. What is stored is the pointer, a label to render when the
|
|
5
|
+
// source is unreachable, and the date it last resolved. Never the payload, and never a secret.
|
|
6
|
+
//
|
|
7
|
+
// Four schemes, each a pointer at something outside this record:
|
|
8
|
+
//
|
|
9
|
+
// contacts:<identifier> an entry in the device's Contacts
|
|
10
|
+
// 1password:op://<vault>/<item> a password manager item. The item, never a field: the
|
|
11
|
+
// agent asks the manager for the field at use and never
|
|
12
|
+
// writes what comes back
|
|
13
|
+
// url:<https://...> a page
|
|
14
|
+
// @<atlasId>/<eco>/<kind>/<id> an entry in another EVDA, the substrate's federated
|
|
15
|
+
// EntityRef (common/src/entity-ref.ts). The eco segment is
|
|
16
|
+
// optional, as it is there; `test/entity-ref-parity.test.mjs`
|
|
17
|
+
// checks this parser against the substrate's
|
|
18
|
+
//
|
|
19
|
+
// A wikilink is a pointer inside this record and is Spec 03's business, not a reference.
|
|
20
|
+
//
|
|
21
|
+
// In an entry, references sit in the frontmatter:
|
|
22
|
+
//
|
|
23
|
+
// ---
|
|
24
|
+
// references:
|
|
25
|
+
// - ref: contacts:2F1A-77
|
|
26
|
+
// label: Sam Rivera
|
|
27
|
+
// resolved: 2026-09-10
|
|
28
|
+
// ---
|
|
29
|
+
//
|
|
30
|
+
// `ref` and `label` are required; `resolved` is a date or absent. Any other key is a copy of
|
|
31
|
+
// something the source holds and is refused. A value shaped like a secret, or a key named like
|
|
32
|
+
// one, is refused with nothing written: Spec 00 #39 puts credentials on the host, substituted at
|
|
33
|
+
// egress, and ekati's own convention is a handle in the record and a value only at the boundary.
|
|
34
|
+
|
|
35
|
+
import { EvdaError } from "./errors.mjs";
|
|
36
|
+
import { parseYaml } from "./yaml.mjs";
|
|
37
|
+
|
|
38
|
+
export const SCHEMES = ["contacts", "1password", "url", "evda"];
|
|
39
|
+
export const REFERENCE_KEYS = ["ref", "label", "resolved"];
|
|
40
|
+
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
41
|
+
|
|
42
|
+
// The substrate's namespace rule (common/src/workflow-ref.ts hasNamespaceSuffix): a DNS-label-safe
|
|
43
|
+
// stem ending in .eco or .kb. Ported, because this package imports nothing from the workspace.
|
|
44
|
+
const NAMESPACE_STEM = /^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/;
|
|
45
|
+
export function isVaultNamespace(value) {
|
|
46
|
+
for (const tld of [".eco", ".kb"]) {
|
|
47
|
+
if (value.endsWith(tld)) {
|
|
48
|
+
const stem = value.slice(0, -tld.length);
|
|
49
|
+
return stem.length > 0 && NAMESPACE_STEM.test(stem);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// The federated EntityRef, parsed as common/src/entity-ref.ts parses it. Returns
|
|
56
|
+
// { atlasId, eco?, kind, id } or null. Only the federated form is a reference here: a pointer with
|
|
57
|
+
// no atlas names an entry in this record, and that is a wikilink.
|
|
58
|
+
export function parseEvdaAddress(input) {
|
|
59
|
+
if (typeof input !== "string" || !input.startsWith("@")) return null;
|
|
60
|
+
const slash = input.indexOf("/");
|
|
61
|
+
if (slash <= 1) return null;
|
|
62
|
+
const atlasId = input.slice(1, slash);
|
|
63
|
+
const rest = input.slice(slash + 1);
|
|
64
|
+
const s = rest.indexOf("/");
|
|
65
|
+
if (s <= 0 || s === rest.length - 1) return null;
|
|
66
|
+
const first = rest.slice(0, s);
|
|
67
|
+
let eco;
|
|
68
|
+
let kind;
|
|
69
|
+
let id;
|
|
70
|
+
if (isVaultNamespace(first)) {
|
|
71
|
+
eco = first;
|
|
72
|
+
const tail = rest.slice(s + 1);
|
|
73
|
+
const t = tail.indexOf("/");
|
|
74
|
+
if (t <= 0 || t === tail.length - 1) return null;
|
|
75
|
+
kind = tail.slice(0, t);
|
|
76
|
+
id = tail.slice(t + 1);
|
|
77
|
+
} else {
|
|
78
|
+
kind = first;
|
|
79
|
+
id = rest.slice(s + 1);
|
|
80
|
+
}
|
|
81
|
+
if (!kind || !id) return null;
|
|
82
|
+
const out = { atlasId, kind, id };
|
|
83
|
+
if (eco !== undefined) out.eco = eco;
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// One pointer string to { scheme, pointer, ...parts }. Throws an EvdaError naming what is wrong,
|
|
88
|
+
// so a caller that wants a yes or no wraps it; doctor wants the sentence.
|
|
89
|
+
export function parseReference(input) {
|
|
90
|
+
if (typeof input !== "string" || input.trim() === "") throw bad("reference is empty.");
|
|
91
|
+
const s = input.trim();
|
|
92
|
+
if (s !== input) throw bad(`reference has leading or trailing whitespace: ${JSON.stringify(input)}.`);
|
|
93
|
+
if (s.startsWith("@")) {
|
|
94
|
+
const a = parseEvdaAddress(s);
|
|
95
|
+
if (!a) throw bad(`reference ${s} is not an EVDA address. The form is @<atlasId>/<eco>/<kind>/<id>, eco optional.`);
|
|
96
|
+
return { scheme: "evda", ref: s, pointer: s, ...a };
|
|
97
|
+
}
|
|
98
|
+
const colon = s.indexOf(":");
|
|
99
|
+
if (colon <= 0) throw bad(`reference ${s} has no scheme. Known: ${SCHEMES.join(", ")}, or an @<atlasId>/... address.`);
|
|
100
|
+
const scheme = s.slice(0, colon);
|
|
101
|
+
const pointer = s.slice(colon + 1);
|
|
102
|
+
if (!SCHEMES.includes(scheme)) throw bad(`reference scheme "${scheme}" is not one this tool knows (${SCHEMES.join(", ")}).`);
|
|
103
|
+
if (pointer === "") throw bad(`reference ${s} has nothing after the scheme.`);
|
|
104
|
+
switch (scheme) {
|
|
105
|
+
case "contacts":
|
|
106
|
+
if (/\s/.test(pointer)) throw bad(`reference contacts identifier ${JSON.stringify(pointer)} contains whitespace.`);
|
|
107
|
+
return { scheme, ref: s, pointer };
|
|
108
|
+
case "1password": {
|
|
109
|
+
const m = /^op:\/\/([^/]+)\/([^/]+)(\/.*)?$/.exec(pointer);
|
|
110
|
+
if (!m) throw bad(`reference ${s} is not an item address. The form is 1password:op://<vault>/<item>.`);
|
|
111
|
+
if (m[3]) throw bad(`reference ${s} names a field. A reference is the item, never a field; the agent asks the manager for the field at use and never writes what comes back.`);
|
|
112
|
+
return { scheme, ref: s, pointer, vault: m[1], item: m[2] };
|
|
113
|
+
}
|
|
114
|
+
case "url": {
|
|
115
|
+
let u;
|
|
116
|
+
try {
|
|
117
|
+
u = new URL(pointer);
|
|
118
|
+
} catch {
|
|
119
|
+
throw bad(`reference ${s} is not a URL.`);
|
|
120
|
+
}
|
|
121
|
+
if (u.protocol !== "http:" && u.protocol !== "https:") throw bad(`reference ${s} is not a page; only http and https are pages.`);
|
|
122
|
+
return { scheme, ref: s, pointer, url: u };
|
|
123
|
+
}
|
|
124
|
+
default:
|
|
125
|
+
throw bad(`reference ${s} has an unhandled scheme.`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function bad(msg) {
|
|
130
|
+
return new EvdaError(msg, { code: "BAD_REFERENCE", exit: 1 });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Secret detection. A key named like a secret, or a value shaped like one, refuses the write.
|
|
134
|
+
// The shapes are the obvious ones: a private key block, a JWT, the prefixes the common services
|
|
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.
|
|
137
|
+
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
|
+
|
|
186
|
+
// Why a key name looks like it holds a secret, or null.
|
|
187
|
+
export function secretKey(name) {
|
|
188
|
+
return SECRET_KEY.test(String(name)) ? `a field named "${name}"` : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Validate one stored reference { ref, label, resolved }. Returns the parsed reference with the
|
|
192
|
+
// scheme and parts, or throws:
|
|
193
|
+
// SECRET_IN_REFERENCE exit 2, refused, nothing written
|
|
194
|
+
// BAD_REFERENCE exit 1, malformed
|
|
195
|
+
export function validateReference(r, where = "reference") {
|
|
196
|
+
if (r === null || typeof r !== "object" || Array.isArray(r)) throw bad(`${where} is not a mapping of ref, label, resolved.`);
|
|
197
|
+
for (const [k, v] of Object.entries(r)) {
|
|
198
|
+
const byName = secretKey(k);
|
|
199
|
+
if (byName) throw secret(`${where} carries ${byName}. A reference holds a pointer, a label and a date; a secret is asked of its keeper at use and never written.`);
|
|
200
|
+
const byValue = secretShape(typeof v === "string" ? v : JSON.stringify(v));
|
|
201
|
+
if (byValue) throw secret(`${where}.${k} holds ${byValue}. A reference holds a pointer, a label and a date; a secret is asked of its keeper at use and never written.`);
|
|
202
|
+
}
|
|
203
|
+
const unknown = Object.keys(r).filter((k) => !REFERENCE_KEYS.includes(k));
|
|
204
|
+
if (unknown.length) throw bad(`${where} carries ${unknown.map((k) => JSON.stringify(k)).join(", ")}. A reference stores the pointer, a label and the date it last resolved; anything else is a copy of what the source holds.`);
|
|
205
|
+
if (typeof r.ref !== "string") throw bad(`${where} has no "ref".`);
|
|
206
|
+
const parsed = parseReference(r.ref);
|
|
207
|
+
if (typeof r.label !== "string" || r.label.trim() === "") throw bad(`${where} (${r.ref}) has no "label". The label is what renders when the source is unreachable; without one the reference renders as nothing.`);
|
|
208
|
+
if (r.resolved !== undefined && r.resolved !== null && !DATE.test(String(r.resolved))) throw bad(`${where} (${r.ref}) "resolved" must be a YYYY-MM-DD date, got ${JSON.stringify(r.resolved)}.`);
|
|
209
|
+
return { ...parsed, label: r.label.trim(), resolved: r.resolved ? String(r.resolved) : null };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function secret(msg) {
|
|
213
|
+
return new EvdaError(`REFUSED: ${msg}`, { code: "SECRET_IN_REFERENCE", exit: 2 });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Build a stored reference. Same refusals as validateReference, so nothing that would fail
|
|
217
|
+
// doctor is ever written.
|
|
218
|
+
export function reference({ ref, label, resolved = null }) {
|
|
219
|
+
const out = { ref, label };
|
|
220
|
+
if (resolved) out.resolved = resolved;
|
|
221
|
+
validateReference(out);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// The frontmatter block of an entry, parsed, or null when the entry has none.
|
|
226
|
+
export function frontmatter(text) {
|
|
227
|
+
if (!text.startsWith("---\n") && !text.startsWith("---\r\n")) return null;
|
|
228
|
+
const end = text.indexOf("\n---", 3);
|
|
229
|
+
if (end === -1) return null;
|
|
230
|
+
const block = text.slice(text.indexOf("\n") + 1, end);
|
|
231
|
+
const fm = parseYaml(block);
|
|
232
|
+
return fm !== null && typeof fm === "object" && !Array.isArray(fm) ? fm : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Does the frontmatter block name a `references` key at all? Asked before parsing, so a file whose
|
|
236
|
+
// frontmatter this YAML subset cannot read is left alone unless it is actually declaring references.
|
|
237
|
+
function declaresReferences(text) {
|
|
238
|
+
if (!text.startsWith("---")) return false;
|
|
239
|
+
const end = text.indexOf("\n---", 3);
|
|
240
|
+
const block = end === -1 ? text : text.slice(0, end);
|
|
241
|
+
return /^references\s*:/m.test(block);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// The references an entry declares. Returns { references: [...], problems: [...] }, one problem
|
|
245
|
+
// per reference that fails, so doctor names every one. `references` holds only the ones that pass.
|
|
246
|
+
// A file with no `references:` in its frontmatter has nothing here, whatever else its frontmatter
|
|
247
|
+
// holds; a record's other keys are not this tool's to validate.
|
|
248
|
+
export function referencesIn(text, where = "entry") {
|
|
249
|
+
const out = { references: [], problems: [] };
|
|
250
|
+
if (!declaresReferences(text)) return out;
|
|
251
|
+
let fm;
|
|
252
|
+
try {
|
|
253
|
+
fm = frontmatter(text);
|
|
254
|
+
} catch (e) {
|
|
255
|
+
out.problems.push({ level: "fail", code: "BAD_YAML", message: `${where}: declares references but its frontmatter is not readable YAML (${e.message}).` });
|
|
256
|
+
return out;
|
|
257
|
+
}
|
|
258
|
+
if (!fm || fm.references === undefined) return out;
|
|
259
|
+
if (!Array.isArray(fm.references)) {
|
|
260
|
+
out.problems.push({ level: "fail", code: "BAD_REFERENCE", message: `${where}: "references" must be a list.` });
|
|
261
|
+
return out;
|
|
262
|
+
}
|
|
263
|
+
fm.references.forEach((r, i) => {
|
|
264
|
+
try {
|
|
265
|
+
out.references.push({ ...validateReference(r, `${where}: references[${i}]`), raw: r });
|
|
266
|
+
} catch (e) {
|
|
267
|
+
if (!(e instanceof EvdaError)) throw e;
|
|
268
|
+
out.problems.push({ level: "fail", code: e.code, message: e.message });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// What a reference renders as, given the result of resolving it. Never an empty string: the label
|
|
275
|
+
// is always there, and an unresolved reference says so and since when.
|
|
276
|
+
export function renderReference(ref, result = null) {
|
|
277
|
+
const label = ref.label;
|
|
278
|
+
if (result && result.status === "resolved") return result.label && result.label !== label ? `${label} (${result.label})` : label;
|
|
279
|
+
const since = ref.resolved ? `since ${ref.resolved}` : "never resolved";
|
|
280
|
+
if (result && result.status === "skipped") return `${label} (not resolved: ${result.why}; ${since})`;
|
|
281
|
+
if (result && result.status === "unreachable") return `${label} (unreachable ${since}: ${result.why})`;
|
|
282
|
+
return `${label} (unresolved, ${since})`;
|
|
283
|
+
}
|