@spexcode/spec-core 0.6.2 → 0.6.3
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/dist/anchors.d.ts +94 -0
- package/dist/anchors.js +730 -0
- package/dist/git.d.ts +166 -0
- package/dist/git.js +2736 -0
- package/dist/graph.d.ts +34 -0
- package/dist/graph.js +237 -0
- package/dist/graphDelta.d.ts +45 -0
- package/dist/graphDelta.js +84 -0
- package/dist/harness-identity.d.ts +31 -0
- package/dist/harness-identity.js +20 -0
- package/dist/identity-presets.d.ts +152 -0
- package/dist/identity-presets.js +132 -0
- package/dist/index.d.ts +45 -0
- package/dist/index.js +19 -0
- package/dist/layout.d.ts +183 -0
- package/dist/layout.js +548 -0
- package/dist/process-identity.d.ts +37 -0
- package/dist/process-identity.js +214 -0
- package/dist/project-identity.d.ts +12 -0
- package/dist/project-identity.js +71 -0
- package/dist/project-store.d.ts +3 -0
- package/dist/project-store.js +14 -0
- package/dist/resilience.d.ts +2 -0
- package/dist/resilience.js +40 -0
- package/dist/review/index.d.ts +3 -0
- package/{src → dist}/review/index.js +3 -3
- package/dist/review/reviewFilters.d.ts +77 -0
- package/dist/review/reviewFilters.js +308 -0
- package/dist/review/reviewQuery.d.ts +66 -0
- package/dist/review/reviewQuery.js +180 -0
- package/dist/review/session.d.ts +4 -0
- package/dist/review/session.js +8 -0
- package/dist/reviewSnapshot.d.ts +15 -0
- package/dist/reviewSnapshot.js +12 -0
- package/dist/root-lru.d.ts +4 -0
- package/{src/root-lru.ts → dist/root-lru.js} +26 -30
- package/dist/specs.d.ts +117 -0
- package/dist/specs.js +489 -0
- package/package.json +19 -8
- package/src/anchors.ts +0 -728
- package/src/git.ts +0 -2556
- package/src/graph.ts +0 -251
- package/src/harness-identity.ts +0 -26
- package/src/identity-presets.d.ts +0 -13
- package/src/identity-presets.js +0 -138
- package/src/index.ts +0 -20
- package/src/layout.ts +0 -637
- package/src/process-identity.ts +0 -207
- package/src/project-identity.ts +0 -73
- package/src/project-store.ts +0 -17
- package/src/resilience.ts +0 -41
- package/src/review/reviewFilters.js +0 -324
- package/src/review/reviewQuery.js +0 -174
- package/src/review/session.js +0 -13
- package/src/reviewSnapshot.ts +0 -28
- package/src/specs.ts +0 -498
package/dist/specs.js
ADDED
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { join, relative, basename } from 'node:path';
|
|
4
|
+
import { repoRoot, historyIndex, rowsFor, historyStats, pathsStats, driftIndex, driftFor, fileDiffAt, sourceIndexes, treeTextFiles, primeAncestorClosures, ancestorsOf, inAncestors } from './git.js';
|
|
5
|
+
import { parseCodeEntry, parseRelation, relationClaimsPath } from './anchors.js';
|
|
6
|
+
// a node is any directory under .spec holding a spec.md; its parent is the nearest ancestor that also holds one.
|
|
7
|
+
const ROOT = repoRoot();
|
|
8
|
+
const SPEC_DIR = join(ROOT, '.spec');
|
|
9
|
+
// line-based frontmatter: scalars are `key: value`; an empty key followed by `- item` lines is a list (e.g. `code:`).
|
|
10
|
+
export function parseFrontmatter(src) {
|
|
11
|
+
const fm = {};
|
|
12
|
+
let body = src;
|
|
13
|
+
const m = src.match(/^---\n([\s\S]*?)\n---\n?([\s\S]*)$/);
|
|
14
|
+
if (m) {
|
|
15
|
+
let key = null;
|
|
16
|
+
for (const line of m[1].split('\n')) {
|
|
17
|
+
const item = line.match(/^\s*-\s+(.*)$/);
|
|
18
|
+
if (item && key) {
|
|
19
|
+
if (!Array.isArray(fm[key]))
|
|
20
|
+
fm[key] = fm[key] ? [fm[key]] : [];
|
|
21
|
+
fm[key].push(item[1].trim());
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
const i = line.indexOf(':');
|
|
25
|
+
if (i > 0) {
|
|
26
|
+
key = line.slice(0, i).trim();
|
|
27
|
+
fm[key] = line.slice(i + 1).trim();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
body = m[2];
|
|
31
|
+
}
|
|
32
|
+
return { fm, body };
|
|
33
|
+
}
|
|
34
|
+
const str = (v, d = '') => (Array.isArray(v) ? v.join(', ') : v ?? d);
|
|
35
|
+
const list = (v) => (Array.isArray(v) ? v : v ? [v] : []);
|
|
36
|
+
const PART_ALIASES = {
|
|
37
|
+
'raw source': 'rawSource',
|
|
38
|
+
'expanded spec': 'expandedSpec',
|
|
39
|
+
};
|
|
40
|
+
function parseParts(body) {
|
|
41
|
+
const acc = { rawSource: [], expandedSpec: [] };
|
|
42
|
+
let cur = null;
|
|
43
|
+
let inFence = false;
|
|
44
|
+
let any = false;
|
|
45
|
+
for (const line of body.split('\n')) {
|
|
46
|
+
const fence = /^\s*```/.test(line);
|
|
47
|
+
if (!inFence && !fence) {
|
|
48
|
+
const h2 = line.match(/^##\s+(.+?)\s*$/); // exactly two hashes — `###` won't match
|
|
49
|
+
if (h2) {
|
|
50
|
+
const key = PART_ALIASES[h2[1].trim().toLowerCase()];
|
|
51
|
+
if (key) {
|
|
52
|
+
cur = key;
|
|
53
|
+
any = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
// an unrecognized `## …` heading is just content of the current part — fall through.
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (fence)
|
|
60
|
+
inFence = !inFence;
|
|
61
|
+
if (cur === 'rawSource')
|
|
62
|
+
acc.rawSource.push(line);
|
|
63
|
+
else if (cur === 'expandedSpec')
|
|
64
|
+
acc.expandedSpec.push(line);
|
|
65
|
+
}
|
|
66
|
+
if (!any)
|
|
67
|
+
return null;
|
|
68
|
+
const t = (a) => a.join('\n').trim();
|
|
69
|
+
return { rawSource: t(acc.rawSource), expandedSpec: t(acc.expandedSpec) };
|
|
70
|
+
}
|
|
71
|
+
export function deriveStatus(d) {
|
|
72
|
+
if (d.fmStatus === 'pending' && !d.hasCode && d.drift === 0)
|
|
73
|
+
return 'pending';
|
|
74
|
+
if (d.hasOverlay)
|
|
75
|
+
return 'active';
|
|
76
|
+
if (d.drift > 0)
|
|
77
|
+
return 'drift';
|
|
78
|
+
if (d.version > 0)
|
|
79
|
+
return 'merged';
|
|
80
|
+
const fb = d.fmStatus;
|
|
81
|
+
if (fb === 'active' || fb === 'merged' || fb === 'drift')
|
|
82
|
+
return fb;
|
|
83
|
+
return 'pending';
|
|
84
|
+
}
|
|
85
|
+
function walk(dir, parent, acc) {
|
|
86
|
+
let myId = parent;
|
|
87
|
+
if (existsSync(join(dir, 'spec.md'))) {
|
|
88
|
+
myId = basename(dir);
|
|
89
|
+
const relPath = relative(ROOT, join(dir, 'spec.md'));
|
|
90
|
+
const { fm, body } = parseFrontmatter(readFileSync(join(dir, 'spec.md'), 'utf8'));
|
|
91
|
+
acc.push({ id: myId, parent, relPath, fm, body });
|
|
92
|
+
}
|
|
93
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
94
|
+
if (e.isDirectory())
|
|
95
|
+
walk(join(dir, e.name), myId, acc);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// the id MINT ([[id-url-safe]]): key each node — given its path segments under .spec — to its leaf dir
|
|
99
|
+
// name, or on a leaf collision the shortest globally-unique trailing path-suffix. A node id is a URL-safe
|
|
100
|
+
// single token — never a '/'-joined path, which would break every `:id` route and fetch that treats an id
|
|
101
|
+
// as one path segment. So the disambiguation separator is '_': like '/' it never occurs inside a dir
|
|
102
|
+
// basename (so the join stays unambiguous), but unlike '/' it is a URL/wikilink/DOM-safe unreserved char,
|
|
103
|
+
// so a collision-qualified id (e.g. `.plugins_<id>`) stays a single token everywhere it is resolved.
|
|
104
|
+
// Exported as the ONE mint every id producer shares: spec-eval mints its node ids through this same
|
|
105
|
+
// function over this same universe (every spec node), so a colliding leaf carries one canonical id
|
|
106
|
+
// system-wide instead of a second, diverging bare-leaf scheme.
|
|
107
|
+
export function mintIds(segs) {
|
|
108
|
+
// NFC pins one canonical byte form for a non-ASCII dir name (macOS hands out NFD basenames), so a typed
|
|
109
|
+
// `[[中文节点]]` (NFC, what an IME emits) string-matches the minted id on every platform.
|
|
110
|
+
const suffix = (s, k) => s.slice(s.length - k).join('_').normalize('NFC');
|
|
111
|
+
return segs.map((s, i) => {
|
|
112
|
+
let k = 1;
|
|
113
|
+
while (k < s.length && segs.some((o, j) => j !== i && o.length >= k && suffix(o, k) === suffix(s, k)))
|
|
114
|
+
k++;
|
|
115
|
+
return suffix(s, k);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
// re-key each node via the mint (overrides walk's placeholder basename id/parent); the second loop
|
|
119
|
+
// recomputes parent by path-ancestry.
|
|
120
|
+
function reId(acc) {
|
|
121
|
+
const segs = acc.map((r) => r.relPath.split(/[/\\]/).slice(1, -1)); // path under .spec, minus 'spec.md'
|
|
122
|
+
const ids = mintIds(segs);
|
|
123
|
+
for (let i = 0; i < acc.length; i++)
|
|
124
|
+
acc[i].id = ids[i];
|
|
125
|
+
for (let i = 0; i < acc.length; i++) {
|
|
126
|
+
let best = -1;
|
|
127
|
+
for (let j = 0; j < acc.length; j++) {
|
|
128
|
+
const o = segs[j], s = segs[i];
|
|
129
|
+
if (j !== i && o.length < s.length && o.every((seg, x) => seg === s[x]) && (best < 0 || o.length > segs[best].length))
|
|
130
|
+
best = j;
|
|
131
|
+
}
|
|
132
|
+
acc[i].parent = best >= 0 ? acc[best].id : null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
function raws() {
|
|
136
|
+
const acc = [];
|
|
137
|
+
if (existsSync(SPEC_DIR))
|
|
138
|
+
walk(SPEC_DIR, null, acc);
|
|
139
|
+
reId(acc);
|
|
140
|
+
return acc;
|
|
141
|
+
}
|
|
142
|
+
// async twin of walk/raws for the HOT board build ([[graph-cache]]): reading each spec.md through
|
|
143
|
+
// fs/promises YIELDS the event loop between files, so a build never stalls a `/health` liveness probe the
|
|
144
|
+
// way the sync walk (one ~450ms uninterrupted stretch) did. Same output as raws() — identical push order
|
|
145
|
+
// (pre-order DFS, dir before children) and the same reId — so every caller reads the same nodes; only
|
|
146
|
+
// loadSpecs (already async, on the hot path) uses it, the light one-shot callers keep the sync raws().
|
|
147
|
+
async function walkAsync(dir, parent, acc, root) {
|
|
148
|
+
let myId = parent;
|
|
149
|
+
if (existsSync(join(dir, 'spec.md'))) {
|
|
150
|
+
myId = basename(dir);
|
|
151
|
+
const relPath = relative(root, join(dir, 'spec.md'));
|
|
152
|
+
const { fm, body } = parseFrontmatter(await readFile(join(dir, 'spec.md'), 'utf8'));
|
|
153
|
+
acc.push({ id: myId, parent, relPath, fm, body });
|
|
154
|
+
}
|
|
155
|
+
for (const e of await readdir(dir, { withFileTypes: true })) {
|
|
156
|
+
if (e.isDirectory())
|
|
157
|
+
await walkAsync(join(dir, e.name), myId, acc, root);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
async function rawsAsync(root, tip = 'HEAD', snapshot) {
|
|
161
|
+
if (snapshot || tip !== 'HEAD') {
|
|
162
|
+
const acc = [];
|
|
163
|
+
const files = snapshot?.files ?? treeTextFiles(root, tip, '.spec');
|
|
164
|
+
for (const [relPath, source] of [...files].sort(([a], [b]) => a.localeCompare(b))) {
|
|
165
|
+
if (!relPath.endsWith('/spec.md'))
|
|
166
|
+
continue;
|
|
167
|
+
const segs = relPath.split('/');
|
|
168
|
+
const { fm, body } = parseFrontmatter(source);
|
|
169
|
+
acc.push({ id: segs[segs.length - 2], parent: null, relPath, fm, body });
|
|
170
|
+
}
|
|
171
|
+
reId(acc);
|
|
172
|
+
return acc;
|
|
173
|
+
}
|
|
174
|
+
const acc = [];
|
|
175
|
+
const specDir = join(root, '.spec');
|
|
176
|
+
if (existsSync(specDir))
|
|
177
|
+
await walkAsync(specDir, null, acc, root);
|
|
178
|
+
reId(acc);
|
|
179
|
+
return acc;
|
|
180
|
+
}
|
|
181
|
+
// the claim rule shared by both relations (exact path, dir-prefix, or *-glob). See [[governed-related]].
|
|
182
|
+
function claimMatcher(file) {
|
|
183
|
+
const rel = file.startsWith('/') ? relative(ROOT, file) : file;
|
|
184
|
+
return (claim) => relationClaimsPath(claim, rel);
|
|
185
|
+
}
|
|
186
|
+
// spec node(s) that GOVERN a file (frontmatter `code:` — source of truth, drives drift + eval freshness); reads only
|
|
187
|
+
// frontmatter (cheap, no git) so a per-edit hook can call it. `scoped` = every claiming entry carries a
|
|
188
|
+
// `#selector` — such a governor still displays, but does not count toward the owners bound ([[code-anchor]]).
|
|
189
|
+
export function specOwners(file) {
|
|
190
|
+
const claims = claimMatcher(file);
|
|
191
|
+
return raws().flatMap((r) => {
|
|
192
|
+
const entries = list(r.fm.code).map(parseCodeEntry).filter((e) => claims(e.path));
|
|
193
|
+
return entries.length ? [{ id: r.id, desc: str(r.fm.desc), scoped: entries.every((e) => e.anchor !== null) }] : [];
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
// spec node(s) that REFERENCE a file (frontmatter `related:` — carries coverage, never drift, never eval freshness):
|
|
197
|
+
// [[governed-related]]'s other half, same claim rule, same cheap frontmatter-only read.
|
|
198
|
+
export function specRelated(file) {
|
|
199
|
+
const claims = claimMatcher(file);
|
|
200
|
+
return raws().filter((r) => list(r.fm.related).some((e) => claims(parseCodeEntry(e).path))).map((r) => ({ id: r.id, desc: str(r.fm.desc) }));
|
|
201
|
+
}
|
|
202
|
+
// memo fileDiffAt by (version sha + spec.md path) — a commit's patch is immutable. Keyed by path too: one
|
|
203
|
+
// commit can patch several nodes' spec.md. `{hash:'',patch:''}` for an unversioned node (no git call).
|
|
204
|
+
const diffCache = new Map();
|
|
205
|
+
async function latestDiff(relPath, hash) {
|
|
206
|
+
if (!hash)
|
|
207
|
+
return { hash: '', patch: '' };
|
|
208
|
+
const key = `${hash}\0${relPath}`;
|
|
209
|
+
const hit = diffCache.get(key);
|
|
210
|
+
if (hit)
|
|
211
|
+
return hit;
|
|
212
|
+
const val = { hash, patch: await fileDiffAt(ROOT, relPath, hash) };
|
|
213
|
+
diffCache.set(key, val);
|
|
214
|
+
return val;
|
|
215
|
+
}
|
|
216
|
+
export function loadSpecsLite() {
|
|
217
|
+
return raws().map((r) => ({
|
|
218
|
+
id: r.id,
|
|
219
|
+
title: str(r.fm.title, r.id),
|
|
220
|
+
path: r.relPath,
|
|
221
|
+
desc: str(r.fm.desc),
|
|
222
|
+
body: r.body.trim(),
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
// one node's body + parsed parts, filesystem-only (no git). The board omits both to stay lean
|
|
226
|
+
// ([[graph-lean]]); the detail view fetches them here when a node opens. null when the id isn't a node.
|
|
227
|
+
export function specContent(id) {
|
|
228
|
+
const r = raws().find((x) => x.id === id);
|
|
229
|
+
return r ? { body: r.body.trim(), parts: parseParts(r.body) } : null;
|
|
230
|
+
}
|
|
231
|
+
export async function loadSpecs(root = ROOT, options = {}) {
|
|
232
|
+
// The default pair shares one immutable-event snapshot; explicit sides let callers skip or supply either
|
|
233
|
+
// projection. Every node below is then a pure in-memory lookup.
|
|
234
|
+
const tip = options.tip ?? 'HEAD';
|
|
235
|
+
if (options.snapshot && options.snapshot.tip !== tip) {
|
|
236
|
+
throw new Error(`loadSpecs snapshot tip '${options.snapshot.tip}' does not match requested tip '${tip}'`);
|
|
237
|
+
}
|
|
238
|
+
const indexes = options.history === undefined && options.drift === undefined
|
|
239
|
+
? sourceIndexes(root, tip)
|
|
240
|
+
: Promise.all([
|
|
241
|
+
options.history === null ? Promise.resolve(null) : options.history ?? historyIndex(root, tip),
|
|
242
|
+
options.drift === null ? Promise.resolve(null) : options.drift ?? driftIndex(root, tip),
|
|
243
|
+
]);
|
|
244
|
+
const [[idx, didx], allRaws] = await Promise.all([indexes, rawsAsync(root, tip, options.snapshot)]);
|
|
245
|
+
const prepared = allRaws.map((r) => ({
|
|
246
|
+
r,
|
|
247
|
+
h: idx ? rowsFor(idx, r.relPath) : [],
|
|
248
|
+
codeRel: parseRelation(list(r.fm.code), 'code'),
|
|
249
|
+
relatedRel: parseRelation(list(r.fm.related), 'related'),
|
|
250
|
+
}));
|
|
251
|
+
if (didx) {
|
|
252
|
+
const queries = [];
|
|
253
|
+
for (const { r, h, codeRel, relatedRel } of prepared) {
|
|
254
|
+
if (!h[0]?.hash || (!codeRel.entries.length && !relatedRel.entries.some((entry) => !entry.selectors.length)))
|
|
255
|
+
continue;
|
|
256
|
+
queries.push({ hash: h[0].hash, node: r.id });
|
|
257
|
+
}
|
|
258
|
+
primeAncestorClosures(didx, queries.map(({ hash }) => hash));
|
|
259
|
+
// Only an ack named for this node and outside its version's ancestry becomes a cover. Discover that
|
|
260
|
+
// exact roster from the now-primed bases instead of retaining closures for older, non-covering acks.
|
|
261
|
+
const covers = [];
|
|
262
|
+
for (const [hash, nodes] of didx.acks)
|
|
263
|
+
if (queries.some(({ hash: baseHash, node }) => {
|
|
264
|
+
const base = ancestorsOf(didx, baseHash);
|
|
265
|
+
return !!base && nodes.has(node) && !inAncestors(didx, base, hash);
|
|
266
|
+
}))
|
|
267
|
+
covers.push(hash);
|
|
268
|
+
primeAncestorClosures(didx, covers);
|
|
269
|
+
}
|
|
270
|
+
const loaded = [];
|
|
271
|
+
for (const { r, h, codeRel, relatedRel } of prepared) {
|
|
272
|
+
// session = the Session: trailer of the node's latest version; frontmatter `session:` is the fallback.
|
|
273
|
+
const fmSession = str(r.fm.session);
|
|
274
|
+
const session = h[0]?.session || (fmSession && fmSession !== 'null' ? fmSession : null);
|
|
275
|
+
// a code:/related: row may pin symbols (`path#fn` — [[code-anchor]]): parseRelation groups each
|
|
276
|
+
// relation per BASE path, so `code`/`related` carry the distinct PATHS (what every path consumer —
|
|
277
|
+
// drift, claims, eval attribution — expects, file-level as before), the scoped entries (path +
|
|
278
|
+
// selectors) ride separately for lint's anchor engine, and structural problems (duplicates,
|
|
279
|
+
// bare/scoped mixing, glob selectors, the code cap) surface as lint integrity errors.
|
|
280
|
+
const codeEntries = codeRel.entries;
|
|
281
|
+
const code = codeEntries.map((e) => e.path);
|
|
282
|
+
const codeScoped = codeEntries.filter((e) => e.selectors.length > 0);
|
|
283
|
+
const relatedEntries = relatedRel.entries;
|
|
284
|
+
const related = relatedEntries.map((e) => e.path);
|
|
285
|
+
const relatedScoped = relatedEntries.filter((e) => e.selectors.length > 0);
|
|
286
|
+
const relationProblems = [...codeRel.problems, ...relatedRel.problems];
|
|
287
|
+
const S = h[0]?.hash || '';
|
|
288
|
+
const driftFiles = [];
|
|
289
|
+
for (const f of code) {
|
|
290
|
+
const d = didx ? { file: f, behind: driftFor(didx, S, f, r.id) } : { file: f, behind: 0 };
|
|
291
|
+
if (d.behind > 0)
|
|
292
|
+
driftFiles.push(d);
|
|
293
|
+
}
|
|
294
|
+
const drift = driftFiles.reduce((a, d) => a + d.behind, 0);
|
|
295
|
+
// related drift is the SOFT tier ([[governed-related]]): same ancestry basis, but it stays OUT of
|
|
296
|
+
// `drift` — it never feeds status, the commit gate, or eval freshness. It surfaces only as a lint warn nudge.
|
|
297
|
+
// A SCOPED related entry is excluded here: its file-level movement is silent by design — only a
|
|
298
|
+
// selector HIT warns, and that verdict needs the anchor engine, so lint derives it, not the loader.
|
|
299
|
+
const relatedDriftFiles = [];
|
|
300
|
+
for (const e of relatedEntries) {
|
|
301
|
+
if (e.selectors.length)
|
|
302
|
+
continue;
|
|
303
|
+
const d = didx ? { file: e.path, behind: driftFor(didx, S, e.path, r.id) } : { file: e.path, behind: 0 };
|
|
304
|
+
if (d.behind > 0)
|
|
305
|
+
relatedDriftFiles.push(d);
|
|
306
|
+
}
|
|
307
|
+
const fmStatus = str(r.fm.status, '') || null;
|
|
308
|
+
loaded.push({
|
|
309
|
+
id: r.id,
|
|
310
|
+
parent: r.parent,
|
|
311
|
+
path: r.relPath,
|
|
312
|
+
title: str(r.fm.title, r.id),
|
|
313
|
+
status: deriveStatus({ version: h.length, drift, hasCode: code.length > 0, fmStatus: fmStatus ?? undefined }),
|
|
314
|
+
fmStatus,
|
|
315
|
+
session,
|
|
316
|
+
hue: Number(str(r.fm.hue, '210')),
|
|
317
|
+
desc: str(r.fm.desc),
|
|
318
|
+
code,
|
|
319
|
+
codeEntries,
|
|
320
|
+
codeScoped,
|
|
321
|
+
related,
|
|
322
|
+
relatedEntries,
|
|
323
|
+
relatedScoped,
|
|
324
|
+
relationProblems,
|
|
325
|
+
version: h.length,
|
|
326
|
+
reason: h[0]?.reason || '',
|
|
327
|
+
// ISO date of the node's latest version commit (h is newest-first), or null if unversioned.
|
|
328
|
+
lastEdited: h[0]?.date || null,
|
|
329
|
+
drift,
|
|
330
|
+
driftFiles,
|
|
331
|
+
relatedDriftFiles,
|
|
332
|
+
// the latest version's spec.md patch is NOT precomputed here (it cost 2 git show forks per node on
|
|
333
|
+
// cold load); the history tab fetches it lazily via specDiffAt. See [[work-pane]].
|
|
334
|
+
body: r.body.trim(),
|
|
335
|
+
parts: parseParts(r.body),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
return loaded;
|
|
339
|
+
}
|
|
340
|
+
// per-node version timeline; each row sums the node's spec.md stat (rename-followed, read on demand) and its
|
|
341
|
+
// governed-code stat (pathsStats) — separate because spec.md needs rename-following a plain `git log -- path` can't do.
|
|
342
|
+
export async function specHistory(id) {
|
|
343
|
+
const node = raws().find((r) => r.id === id);
|
|
344
|
+
if (!node)
|
|
345
|
+
return [];
|
|
346
|
+
const codePaths = [...new Set(list(node.fm.code).map((e) => parseCodeEntry(e).path))];
|
|
347
|
+
// index (cached) and the code-path walk are independent — run them in parallel, both async git.
|
|
348
|
+
const [idx, cStats] = await Promise.all([historyIndex(ROOT), pathsStats(ROOT, codePaths)]);
|
|
349
|
+
const sStats = await historyStats(ROOT, idx, node.relPath);
|
|
350
|
+
return rowsFor(idx, node.relPath).map((v) => {
|
|
351
|
+
const s = sStats.get(v.hash) ?? { additions: 0, deletions: 0, files: 0 };
|
|
352
|
+
const c = cStats.get(v.hash) ?? { additions: 0, deletions: 0, files: 0 };
|
|
353
|
+
return { ...v, additions: s.additions + c.additions, deletions: s.deletions + c.deletions, files: s.files + c.files };
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
// the line-diff a specific version introduced to a node's spec.md, by hash; fetched lazily when a history
|
|
357
|
+
// item expands. fileDiffAt resolves the spec.md path AT that commit (reparents). `{hash:'',patch:''}` for
|
|
358
|
+
// an empty hash, null for an unknown id.
|
|
359
|
+
export async function specDiffAt(id, hash) {
|
|
360
|
+
const node = raws().find((r) => r.id === id);
|
|
361
|
+
if (!node)
|
|
362
|
+
return null;
|
|
363
|
+
if (!hash)
|
|
364
|
+
return { hash: '', patch: '' };
|
|
365
|
+
return latestDiff(node.relPath, hash);
|
|
366
|
+
}
|
|
367
|
+
// @@@ public instance root - cross-repository consumers import this identity instead of matching a folder
|
|
368
|
+
// literal. `plugin-system` remains the separate root for this project's system spec.
|
|
369
|
+
export const PLUGIN_INSTANCE_ROOT = '.plugins';
|
|
370
|
+
// field-driven surface - a plugin is a spec node at ANY depth under a plugin root that carries a
|
|
371
|
+
// `surface: system|command|hook|skill|agent|review` frontmatter field naming where it plugs in. There are no
|
|
372
|
+
// `command/`/`system/`/`hook/`/`skill/`/`agent/` bucket dirs (those were graph-invisible grouping dirs with no spec.md, so
|
|
373
|
+
// the spec graph skipped them — path != graph); the surface is a FIELD on the node, so the plugin is a real
|
|
374
|
+
// graph child (a grouping parent like `.plugins/prompts` is itself a spec node, never a bare dir). BOTH plugin roots participate: `.plugins` (the instance — DIY dev-flow plugins) and
|
|
375
|
+
// `plugin-system` (the project system spec). loadConfig gathers the `command` surface, loadSystemConfig the `system`
|
|
376
|
+
// surface, loadHookConfig the `hook` surface, loadSkillConfig the `skill` surface, loadAgentConfig the `agent`
|
|
377
|
+
// surface (sub-agent definitions); each scans the children under every root and filters by the field. The plugins also show on the board as ordinary spec nodes (via loadSpecs).
|
|
378
|
+
// root node - the spec tree's single top-level node: the one directory directly under .spec/ that
|
|
379
|
+
// holds a spec.md. The dogfood repo names it 'spexcode'; a repo scaffolded by `spex init` names it
|
|
380
|
+
// 'project' (or whatever the adopter renames it to). Detected DYNAMICALLY so the config loaders resolve
|
|
381
|
+
// the ACTUAL root's config dirs — never a hardcoded 'spexcode', which silently returned [] in an adopter
|
|
382
|
+
// repo, so their .plugins/core contract never loaded and their launched agents got no system prompt.
|
|
383
|
+
// Returns null when .spec holds no such directory. (resolveLayout's `main` is a checkout PATH, not the
|
|
384
|
+
// root node NAME, so it can't serve this — a tiny filesystem probe is the right seam.)
|
|
385
|
+
function rootNode() {
|
|
386
|
+
if (!existsSync(SPEC_DIR))
|
|
387
|
+
return null;
|
|
388
|
+
for (const e of readdirSync(SPEC_DIR, { withFileTypes: true })) {
|
|
389
|
+
if (e.isDirectory() && existsSync(join(SPEC_DIR, e.name, 'spec.md')))
|
|
390
|
+
return e.name;
|
|
391
|
+
}
|
|
392
|
+
return null;
|
|
393
|
+
}
|
|
394
|
+
// resolved at call time (not module-eval) so it tracks the live tree.
|
|
395
|
+
// @@@ legacy-tree refusal - v0.3.0 renamed the plugin instance root `.config` → `.plugins`. A pre-0.3.0
|
|
396
|
+
// tree would otherwise load an EMPTY plugin surface — no contract block, no hooks, no commands — and the
|
|
397
|
+
// launched agents would silently run ungoverned. So refuse loudly: existence-only probe (never a dual
|
|
398
|
+
// read of legacy content), pointing at the one-shot migrator. Delete this check in 0.4.0.
|
|
399
|
+
function configRoots() {
|
|
400
|
+
const root = rootNode();
|
|
401
|
+
if (!root)
|
|
402
|
+
return [];
|
|
403
|
+
if (existsSync(join(SPEC_DIR, root, '.config')) && !existsSync(join(SPEC_DIR, root, PLUGIN_INSTANCE_ROOT))) {
|
|
404
|
+
throw new Error(`.spec/${root}/.config exists but .spec/${root}/${PLUGIN_INSTANCE_ROOT} does not — this spec tree predates the v0.3.0 ` +
|
|
405
|
+
`plugin rename (.config → ${PLUGIN_INSTANCE_ROOT}). Refusing to load an empty plugin surface (agents would launch ` +
|
|
406
|
+
`ungoverned). Run \`spex doctor --migrate\` to migrate the tree.`);
|
|
407
|
+
}
|
|
408
|
+
return [PLUGIN_INSTANCE_ROOT, 'plugin-system'].map((r) => join(SPEC_DIR, root, r));
|
|
409
|
+
}
|
|
410
|
+
// co-located bundle files = everything under the node folder except its spec.md, repo-relative, recursive.
|
|
411
|
+
function bundleFiles(dir) {
|
|
412
|
+
const out = [];
|
|
413
|
+
const walk = (d) => {
|
|
414
|
+
for (const e of readdirSync(d, { withFileTypes: true })) {
|
|
415
|
+
const p = join(d, e.name);
|
|
416
|
+
if (e.isDirectory())
|
|
417
|
+
walk(p);
|
|
418
|
+
else if (e.name !== 'spec.md')
|
|
419
|
+
out.push(relative(ROOT, p));
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
walk(dir);
|
|
423
|
+
return out.sort();
|
|
424
|
+
}
|
|
425
|
+
// gather the preset nodes under a plugin root that declare `surface: <surface>`. The scan is RECURSIVE —
|
|
426
|
+
// `surface` is a FIELD, not a path (the design's core tenet), so a plugin may live at ANY depth: under a
|
|
427
|
+
// surface-less grouping shelf (the auxiliary `surface: system` contracts live under `.plugins/prompts/`),
|
|
428
|
+
// or under a plugin that is itself a grouping parent (`.plugins/core` is a `surface: system` contract whose
|
|
429
|
+
// CHILDREN are `surface: hook` nodes). The field filter keeps it safe: a node only gathers if it declares THIS
|
|
430
|
+
// surface, so descending past a matched node never double-counts (children carry a different surface),
|
|
431
|
+
// and the gather set is path-independent — regrouping a plugin never changes what materializes.
|
|
432
|
+
function loadSurface(surface) {
|
|
433
|
+
const out = [];
|
|
434
|
+
const visit = (nodeDir, name) => {
|
|
435
|
+
if (existsSync(join(nodeDir, 'spec.md'))) {
|
|
436
|
+
const { fm, body } = parseFrontmatter(readFileSync(join(nodeDir, 'spec.md'), 'utf8'));
|
|
437
|
+
// @@@ skip pending - a `status: pending` plugin is DECLARED INTENT, not yet active. It renders on the
|
|
438
|
+
// board (via loadSpecs) but must NOT gather: neither a command preset, nor folded into a system prompt,
|
|
439
|
+
// nor a live hook. Only built/active plugins surface here, so pending stubs stay inert.
|
|
440
|
+
// the surface field may name SEVERAL surfaces (comma-separated or a YAML list) — the node plugs
|
|
441
|
+
// into every one it lists, so the match is membership, not equality.
|
|
442
|
+
const surfaces = list(fm.surface).flatMap((v) => String(v).split(',')).map((v) => v.trim()).filter(Boolean);
|
|
443
|
+
if (surfaces.includes(surface) && str(fm.status) !== 'pending') {
|
|
444
|
+
out.push({
|
|
445
|
+
name,
|
|
446
|
+
title: str(fm.title, name),
|
|
447
|
+
desc: str(fm.desc),
|
|
448
|
+
kind: str(fm.kind, 'mutating'),
|
|
449
|
+
dir: relative(ROOT, nodeDir),
|
|
450
|
+
files: bundleFiles(nodeDir),
|
|
451
|
+
body: body.trim(),
|
|
452
|
+
events: list(fm.events),
|
|
453
|
+
order: Number(str(fm.order, '0')) || 0,
|
|
454
|
+
block: str(fm.block) === 'true',
|
|
455
|
+
tools: list(fm.tools),
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
for (const e of readdirSync(nodeDir, { withFileTypes: true })) {
|
|
460
|
+
if (e.isDirectory())
|
|
461
|
+
visit(join(nodeDir, e.name), e.name);
|
|
462
|
+
}
|
|
463
|
+
};
|
|
464
|
+
for (const root of configRoots()) {
|
|
465
|
+
if (!existsSync(root))
|
|
466
|
+
continue;
|
|
467
|
+
for (const e of readdirSync(root, { withFileTypes: true })) {
|
|
468
|
+
if (e.isDirectory())
|
|
469
|
+
visit(join(root, e.name), e.name);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
return out.sort((a, b) => a.name.localeCompare(b.name));
|
|
473
|
+
}
|
|
474
|
+
export function loadConfig() { return loadSurface('command'); }
|
|
475
|
+
export function loadSystemConfig() { return loadSurface('system'); }
|
|
476
|
+
// the hook handlers (compiled into the per-session hook manifest the dispatcher reads). Each carries its
|
|
477
|
+
// `events`/`order`/`block` binding + co-located script `files`.
|
|
478
|
+
export function loadHookConfig() { return loadSurface('hook'); }
|
|
479
|
+
// the skill bundles (materialized into each harness's auto-discovered SKILL.md dir). Each node's `desc` is the
|
|
480
|
+
// load-trigger and its `body` is the on-demand instructions; loadSurface passes the folder basename as `name`.
|
|
481
|
+
export function loadSkillConfig() { return loadSurface('skill'); }
|
|
482
|
+
// the sub-agent definitions (materialized into each harness's auto-discovered agent dir, e.g. claude's
|
|
483
|
+
// .claude/agents/<name>.md). Like a skill, the node's `desc` is the on-demand load-trigger and its `body` is the
|
|
484
|
+
// agent's system prompt; additionally its `tools` field is the harness tool allowlist for the spawned agent.
|
|
485
|
+
export function loadAgentConfig() { return loadSurface('agent'); }
|
|
486
|
+
// the review-track prose presets ([[review-commands]]): offered in the eval detail's remark-composer `/`
|
|
487
|
+
// dropdown; picking one PREFILLS the composer with the node's `body` ({node}/{scenario}/{expected}
|
|
488
|
+
// placeholders filled at insert time). Display+prefill only — the send stays the ordinary remark write.
|
|
489
|
+
export function loadReviewConfig() { return loadSurface('review'); }
|
package/package.json
CHANGED
|
@@ -1,18 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@spexcode/spec-core",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.3",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "SpexCode's dependency-minimal spec graph core.",
|
|
6
|
+
"engines": {
|
|
7
|
+
"node": ">=22"
|
|
8
|
+
},
|
|
9
|
+
"dependencies": {},
|
|
6
10
|
"files": [
|
|
7
|
-
"
|
|
11
|
+
"dist",
|
|
8
12
|
"templates"
|
|
9
13
|
],
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
10
15
|
"exports": {
|
|
11
|
-
".":
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
".": {
|
|
17
|
+
"types": "./dist/index.d.ts",
|
|
18
|
+
"default": "./dist/index.js"
|
|
19
|
+
},
|
|
20
|
+
"./review": {
|
|
21
|
+
"types": "./dist/review/index.d.ts",
|
|
22
|
+
"default": "./dist/review/index.js"
|
|
23
|
+
},
|
|
24
|
+
"./identity": {
|
|
25
|
+
"types": "./dist/identity-presets.d.ts",
|
|
26
|
+
"default": "./dist/identity-presets.js"
|
|
27
|
+
}
|
|
17
28
|
}
|
|
18
29
|
}
|