pi-weave 0.1.7 → 0.1.8
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/README.md +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
package/src/core/frontmatter.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { NoteMeta, NoteSource } from "./types";
|
|
1
|
+
import type { NoteFrontMatter, NoteMeta, NoteSource } from "./types";
|
|
2
2
|
import { NOTE_SOURCES } from "./types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
@@ -7,21 +7,94 @@ import { NOTE_SOURCES } from "./types";
|
|
|
7
7
|
* Deliberately a *subset*: notes are meant to be human-editable plain text,
|
|
8
8
|
* so we write only `key: value` scalars and `[a, b]` inline arrays, and we
|
|
9
9
|
* parse exactly that. Anything richer belongs in the Markdown body.
|
|
10
|
+
*
|
|
11
|
+
* ## Parsing a subset without writing a subset
|
|
12
|
+
*
|
|
13
|
+
* "We parse a subset" used to also mean "we write a subset", and those are
|
|
14
|
+
* not the same promise. `parseNoteFile` read five keys and `serializeNote`
|
|
15
|
+
* wrote five keys, so every write through core silently deleted whatever
|
|
16
|
+
* else the file carried — `aliases`, `cssclass`, `publish`, any property an
|
|
17
|
+
* Obsidian user or another tool had added. Reading a note and appending one
|
|
18
|
+
* line destroyed the rest of its metadata (weave-workspace §11 P5).
|
|
19
|
+
*
|
|
20
|
+
* The fix is *not* a fuller YAML parser. A vault is plain text a human edits,
|
|
21
|
+
* and a parser that understands more is a parser that **rewrites** more —
|
|
22
|
+
* every construct it learns to read is a construct it will re-emit in its own
|
|
23
|
+
* preferred spelling. The fix is to stop conflating "parsed" with
|
|
24
|
+
* "preserved":
|
|
25
|
+
*
|
|
26
|
+
* - The five keys in {@link MANAGED_FRONT_MATTER_KEYS} are **owned**: parsed
|
|
27
|
+
* into {@link NoteMeta}, and re-rendered from those values on write.
|
|
28
|
+
* - Every other line is **carried**: emitted as the exact bytes it was read
|
|
29
|
+
* as, in its original position. Unknown keys, blank lines, junk without a
|
|
30
|
+
* colon, and syntax this subset cannot represent at all — block lists,
|
|
31
|
+
* folded scalars, nested maps — all survive, because none of them is ever
|
|
32
|
+
* interpreted.
|
|
33
|
+
*
|
|
34
|
+
* The round-trip property is therefore: **owned fields round-trip by value,
|
|
35
|
+
* every other byte round-trips by byte, and key order is preserved.**
|
|
36
|
+
*
|
|
37
|
+
* ## Two rules that keep writes from inventing content
|
|
38
|
+
*
|
|
39
|
+
* **An owned key is never added to a file that did not have it, unless it
|
|
40
|
+
* carries a value the parser would not have defaulted to.** Real vaults are
|
|
41
|
+
* full of hand-written notes with no `created`/`updated`; `parseNoteFile`
|
|
42
|
+
* defaults those to `""`, and writing `created: ` back would be the engine
|
|
43
|
+
* fabricating metadata during an unrelated edit. Since no mutation path ever
|
|
44
|
+
* *sets* `created`, it stays `""` and stays absent; `updated` is appended
|
|
45
|
+
* only because a caller explicitly asked for a bump, which is a requested
|
|
46
|
+
* change rather than a silent one. See {@link isDefaulted}.
|
|
47
|
+
*
|
|
48
|
+
* **An owned key whose on-disk syntax this subset cannot represent is
|
|
49
|
+
* frozen**: carried verbatim, and not re-rendered or duplicated. Obsidian's
|
|
50
|
+
* property editor writes tags as a YAML block list —
|
|
51
|
+
*
|
|
52
|
+
* ```yaml
|
|
53
|
+
* tags:
|
|
54
|
+
* - reading
|
|
55
|
+
* ```
|
|
56
|
+
*
|
|
57
|
+
* — which is exactly the shape a line-oriented serializer would destroy, by
|
|
58
|
+
* rewriting the parent as `tags: []` and orphaning the two children under it.
|
|
59
|
+
* So such a construct is left alone entirely and the append pass skips the
|
|
60
|
+
* key, because emitting a second `tags:` further down would leave the user
|
|
61
|
+
* with a duplicated property. The cost is that the engine neither reads nor
|
|
62
|
+
* updates those tags — the same blindness it has today, minus the data loss.
|
|
63
|
+
* It is observable rather than silent: every mutation API returns the note as
|
|
64
|
+
* re-parsed from what was actually written, so a caller that sets tags on
|
|
65
|
+
* such a note gets back a note whose tags did not change.
|
|
10
66
|
*/
|
|
11
67
|
|
|
12
68
|
export interface ParsedNoteFile {
|
|
13
69
|
meta: NoteMeta;
|
|
14
70
|
body: string;
|
|
71
|
+
/** The front-matter block verbatim, for lossless re-serialization. */
|
|
72
|
+
frontMatter: NoteFrontMatter;
|
|
15
73
|
}
|
|
16
74
|
|
|
17
75
|
/** Generic, tolerant front-matter parse (any fields) — for non-note OKF files. */
|
|
18
76
|
export interface ParsedFrontMatter {
|
|
19
77
|
fields: Map<string, string>;
|
|
20
78
|
body: string;
|
|
79
|
+
/** The raw block lines in file order, `---` fences excluded. */
|
|
80
|
+
lines: readonly string[];
|
|
21
81
|
}
|
|
22
82
|
|
|
23
83
|
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n?/;
|
|
24
84
|
|
|
85
|
+
/**
|
|
86
|
+
* The front-matter keys this module owns — parsed into {@link NoteMeta} and
|
|
87
|
+
* re-rendered on write. Everything else is carried verbatim.
|
|
88
|
+
*
|
|
89
|
+
* The array order is also the order in which a missing owned key is appended,
|
|
90
|
+
* so a note that gains `updated` gains it in a predictable place.
|
|
91
|
+
*/
|
|
92
|
+
export const MANAGED_FRONT_MATTER_KEYS = ["title", "created", "updated", "tags", "source"] as const;
|
|
93
|
+
|
|
94
|
+
export type ManagedFrontMatterKey = (typeof MANAGED_FRONT_MATTER_KEYS)[number];
|
|
95
|
+
|
|
96
|
+
const MANAGED: ReadonlySet<string> = new Set<string>(MANAGED_FRONT_MATTER_KEYS);
|
|
97
|
+
|
|
25
98
|
export function quoteField(value: string): string {
|
|
26
99
|
// Only quote when the value could confuse our subset parser.
|
|
27
100
|
if (/[:#[\]]|^\s|\s$|^$/.test(value)) {
|
|
@@ -38,6 +111,48 @@ export function unquoteField(value: string): string {
|
|
|
38
111
|
return trimmed;
|
|
39
112
|
}
|
|
40
113
|
|
|
114
|
+
/** One classified line of a front-matter block. */
|
|
115
|
+
export interface FrontMatterLine {
|
|
116
|
+
/** The line exactly as it appears in the file. */
|
|
117
|
+
text: string;
|
|
118
|
+
/** The top-level key it declares, or null for blank/junk/continuation lines. */
|
|
119
|
+
key: string | null;
|
|
120
|
+
/**
|
|
121
|
+
* True when the value is a scalar or inline array this subset can both read
|
|
122
|
+
* and write. False for a key introducing a block construct — its value
|
|
123
|
+
* lives in the indented lines below it, which no line-oriented rewrite can
|
|
124
|
+
* safely touch.
|
|
125
|
+
*/
|
|
126
|
+
scalar: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Classify a front-matter block line by line.
|
|
131
|
+
*
|
|
132
|
+
* The **single** rule for "which line is which key", shared by the parser and
|
|
133
|
+
* the serializer. A second copy of this rule is a second way for a write to
|
|
134
|
+
* disagree with a read about whether a line is owned — and disagreeing in
|
|
135
|
+
* that direction means either dropping a property or duplicating it.
|
|
136
|
+
*
|
|
137
|
+
* Block detection needs one line of lookahead, which is why this classifies
|
|
138
|
+
* the whole block rather than exposing a per-line predicate: `tags:` is a
|
|
139
|
+
* scalar with an empty value on its own, and the head of a list when the next
|
|
140
|
+
* line is indented.
|
|
141
|
+
*/
|
|
142
|
+
export function scanFrontMatter(lines: readonly string[]): FrontMatterLine[] {
|
|
143
|
+
return lines.map((text, i): FrontMatterLine => {
|
|
144
|
+
const idx = text.indexOf(":");
|
|
145
|
+
// Indented lines belong to whatever is above them: treating one as a
|
|
146
|
+
// top-level key would let the serializer hoist it out of its parent.
|
|
147
|
+
if (idx <= 0 || /^\s/.test(text)) return { text, key: null, scalar: false };
|
|
148
|
+
const key = text.slice(0, idx).trim();
|
|
149
|
+
if (key.length === 0) return { text, key: null, scalar: false };
|
|
150
|
+
const value = text.slice(idx + 1).trim();
|
|
151
|
+
const blockHead = value.length === 0 && /^\s+\S/.test(lines[i + 1] ?? "");
|
|
152
|
+
return { text, key, scalar: !blockHead };
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
41
156
|
function parseTags(value: string): string[] {
|
|
42
157
|
const trimmed = value.trim();
|
|
43
158
|
if (!trimmed.startsWith("[") || !trimmed.endsWith("]")) {
|
|
@@ -58,21 +173,98 @@ function parseSource(value: string): NoteSource {
|
|
|
58
173
|
: "human";
|
|
59
174
|
}
|
|
60
175
|
|
|
61
|
-
/**
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
"
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
176
|
+
/** Render one owned key from its parsed value. */
|
|
177
|
+
function renderManaged(key: ManagedFrontMatterKey, meta: NoteMeta): string {
|
|
178
|
+
switch (key) {
|
|
179
|
+
case "title":
|
|
180
|
+
return `title: ${quoteField(meta.title)}`;
|
|
181
|
+
case "created":
|
|
182
|
+
return `created: ${meta.created}`;
|
|
183
|
+
case "updated":
|
|
184
|
+
return `updated: ${meta.updated}`;
|
|
185
|
+
case "tags":
|
|
186
|
+
return `tags: [${meta.tags.map(quoteField).join(", ")}]`;
|
|
187
|
+
default:
|
|
188
|
+
return `source: ${meta.source}`;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* True when omitting `key` would parse back to the same value — the value is
|
|
194
|
+
* indistinguishable from the parser's default for an absent key.
|
|
195
|
+
*
|
|
196
|
+
* Gates **appending** an owned key that the file did not already declare, so
|
|
197
|
+
* an edit to a note's body cannot grow its front matter with fields the user
|
|
198
|
+
* never wrote. A key the file *does* declare is always re-rendered, never
|
|
199
|
+
* dropped: `tags: []` and `source: human` written by hand are the user's
|
|
200
|
+
* lines, and deleting them would be the mirror-image mutation.
|
|
201
|
+
*
|
|
202
|
+
* `title` is never defaulted: it is the one required field, and a note
|
|
203
|
+
* without it is malformed rather than empty.
|
|
204
|
+
*/
|
|
205
|
+
function isDefaulted(key: ManagedFrontMatterKey, meta: NoteMeta): boolean {
|
|
206
|
+
switch (key) {
|
|
207
|
+
case "title":
|
|
208
|
+
return false;
|
|
209
|
+
case "created":
|
|
210
|
+
return meta.created.length === 0;
|
|
211
|
+
case "updated":
|
|
212
|
+
return meta.updated.length === 0;
|
|
213
|
+
case "tags":
|
|
214
|
+
return meta.tags.length === 0;
|
|
215
|
+
default:
|
|
216
|
+
return meta.source === "human";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Serialize note metadata + body to the on-disk Markdown form.
|
|
222
|
+
*
|
|
223
|
+
* With `frontMatter` supplied — as `parseNoteFile` returns and every vault
|
|
224
|
+
* write path threads through — the original block is replayed line for line:
|
|
225
|
+
* owned scalar keys are re-rendered in place from `meta`, everything else is
|
|
226
|
+
* emitted as the exact bytes it was read as. Owned keys the original did not
|
|
227
|
+
* declare are appended at the end of the block, and only when they carry a
|
|
228
|
+
* non-default value.
|
|
229
|
+
*
|
|
230
|
+
* Without it, the canonical five-line block is written — the shape `addNote`
|
|
231
|
+
* creates for a brand-new note, where there is no prior layout to respect.
|
|
232
|
+
*/
|
|
233
|
+
export function serializeNote(meta: NoteMeta, body: string, frontMatter?: NoteFrontMatter): string {
|
|
234
|
+
const block = frontMatter === undefined ? canonicalBlock(meta) : replayBlock(meta, frontMatter);
|
|
235
|
+
return ["---", ...block, "---", "", body.replace(/\s+$/, ""), ""].join("\n");
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function canonicalBlock(meta: NoteMeta): string[] {
|
|
239
|
+
return MANAGED_FRONT_MATTER_KEYS.map((key) => renderManaged(key, meta));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function replayBlock(meta: NoteMeta, frontMatter: NoteFrontMatter): string[] {
|
|
243
|
+
/** Top-level keys the file declares, in any syntax — including frozen ones. */
|
|
244
|
+
const declared = new Set<string>();
|
|
245
|
+
/** Owned keys already re-rendered, so a duplicate key collapses to one line. */
|
|
246
|
+
const rendered = new Set<string>();
|
|
247
|
+
const out: string[] = [];
|
|
248
|
+
|
|
249
|
+
for (const line of scanFrontMatter(frontMatter)) {
|
|
250
|
+
if (line.key !== null) declared.add(line.key);
|
|
251
|
+
if (line.key === null || !line.scalar || !MANAGED.has(line.key)) {
|
|
252
|
+
out.push(line.text); // carried verbatim — never interpreted, never reformatted
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// A repeated owned key is a typo, and `parseFrontMatter` keeps the last
|
|
256
|
+
// occurrence. Re-rendering the parsed value at the *first* position and
|
|
257
|
+
// dropping the rest preserves that value while collapsing the duplicate.
|
|
258
|
+
if (rendered.has(line.key)) continue;
|
|
259
|
+
rendered.add(line.key);
|
|
260
|
+
out.push(renderManaged(line.key as ManagedFrontMatterKey, meta));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const key of MANAGED_FRONT_MATTER_KEYS) {
|
|
264
|
+
if (declared.has(key) || isDefaulted(key, meta)) continue;
|
|
265
|
+
out.push(renderManaged(key, meta));
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
76
268
|
}
|
|
77
269
|
|
|
78
270
|
/**
|
|
@@ -85,14 +277,16 @@ export function parseFrontMatter(text: string): ParsedFrontMatter | null {
|
|
|
85
277
|
const match = FRONTMATTER_RE.exec(text);
|
|
86
278
|
if (!match || match[1] === undefined) return null;
|
|
87
279
|
const body = text.slice(match[0].length).replace(/^\n/, "").replace(/\s+$/, "");
|
|
280
|
+
const lines = match[1].split("\n");
|
|
88
281
|
const fields = new Map<string, string>();
|
|
89
|
-
for (const line of
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
282
|
+
for (const line of scanFrontMatter(lines)) {
|
|
283
|
+
// Blank/junk lines and block constructs are skipped: front matter is
|
|
284
|
+
// best-effort, and a value this subset cannot read is one it must not
|
|
285
|
+
// pretend to have read as `""`.
|
|
286
|
+
if (line.key === null || !line.scalar) continue;
|
|
287
|
+
fields.set(line.key, line.text.slice(line.text.indexOf(":") + 1).trim());
|
|
94
288
|
}
|
|
95
|
-
return { fields, body };
|
|
289
|
+
return { fields, body, lines };
|
|
96
290
|
}
|
|
97
291
|
|
|
98
292
|
/**
|
|
@@ -115,5 +309,5 @@ export function parseNoteFile(text: string): ParsedNoteFile {
|
|
|
115
309
|
tags: parseTags(parsed.fields.get("tags") ?? "[]"),
|
|
116
310
|
source: parseSource(parsed.fields.get("source") ?? "human"),
|
|
117
311
|
};
|
|
118
|
-
return { meta, body: parsed.body };
|
|
312
|
+
return { meta, body: parsed.body, frontMatter: parsed.lines };
|
|
119
313
|
}
|
package/src/core/git.ts
CHANGED
|
@@ -18,7 +18,26 @@ export interface GitExecOptions {
|
|
|
18
18
|
|
|
19
19
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
20
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Process-wide count of `git` subprocesses spawned by this module.
|
|
23
|
+
*
|
|
24
|
+
* Every git call in core funnels through the `git()` helper below, so this
|
|
25
|
+
* is an exact spawn count rather than a model of one. `src/core/cache/
|
|
26
|
+
* workspace` samples it around its own work to report `CacheStats.gitCalls`,
|
|
27
|
+
* which is what makes "a no-change rebuild spawns zero git processes"
|
|
28
|
+
* (weave-workspace §4.1) an assertion about observed behaviour.
|
|
29
|
+
*
|
|
30
|
+
* Monotonic and never reset: callers take deltas.
|
|
31
|
+
*/
|
|
32
|
+
let spawnCount = 0;
|
|
33
|
+
|
|
34
|
+
/** Number of git subprocesses spawned so far in this process. Monotonic. */
|
|
35
|
+
export function gitSpawnCount(): number {
|
|
36
|
+
return spawnCount;
|
|
37
|
+
}
|
|
38
|
+
|
|
21
39
|
async function git(args: string[], cwd: string, timeoutMs: number): Promise<string | null> {
|
|
40
|
+
spawnCount += 1;
|
|
22
41
|
return new Promise((resolve) => {
|
|
23
42
|
execFile("git", args, { cwd, timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 }, (err, stdout) => {
|
|
24
43
|
if (err) resolve(null);
|
package/src/core/graph/build.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import type { Note, RepoIndex, StalenessReport, VaultStatus } from "../types";
|
|
12
12
|
import type { SummaryRecord } from "../summaries";
|
|
13
13
|
import type { EdgeKind, GraphEdge, GraphModel, GraphNode } from "./model";
|
|
14
|
+
import { buildPathIndex, resolveMentions, type PathIndex } from "./mentions";
|
|
14
15
|
import { extractWikilinks } from "./wikilinks";
|
|
15
16
|
|
|
16
17
|
/** Hard cap on note nodes (docs/weave-view.md M3 guard). */
|
|
@@ -85,7 +86,14 @@ function parseRemote(raw: string): { label: string; url: string } {
|
|
|
85
86
|
return { label: tail.replace(/\.git$/, ""), url: s };
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
function buildVaultSide(
|
|
89
|
+
function buildVaultSide(
|
|
90
|
+
input: BuildGraphInput,
|
|
91
|
+
maxNotes: number,
|
|
92
|
+
nodes: GraphNode[],
|
|
93
|
+
edges: GraphEdge[],
|
|
94
|
+
danglingLinks: Record<string, string[]>,
|
|
95
|
+
paths: PathIndex,
|
|
96
|
+
): string[] {
|
|
89
97
|
const truncated = input.notes.length > maxNotes;
|
|
90
98
|
const kept = input.notes.slice(0, maxNotes);
|
|
91
99
|
|
|
@@ -108,8 +116,14 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
|
|
|
108
116
|
updated: note.updated,
|
|
109
117
|
};
|
|
110
118
|
if (note.tags.length > 0) detail.tags = note.tags.join(", ");
|
|
111
|
-
|
|
112
|
-
|
|
119
|
+
// The names, not just the count (§4.2). `detail` keeps carrying the count
|
|
120
|
+
// because it is what the TUI's side panel prints; the structured targets
|
|
121
|
+
// go on the model, where a UI can turn them into ghost nodes.
|
|
122
|
+
const dangling = links.filter((slug) => !keptSlugs.has(slug));
|
|
123
|
+
if (dangling.length > 0) {
|
|
124
|
+
detail["dangling links"] = String(dangling.length);
|
|
125
|
+
danglingLinks[note.slug] = dangling;
|
|
126
|
+
}
|
|
113
127
|
detail.preview = preview(note.body);
|
|
114
128
|
|
|
115
129
|
nodes.push({ id: `note:${note.slug}`, kind: "note", label: note.title, provenance: note.source, detail });
|
|
@@ -117,6 +131,14 @@ function buildVaultSide(input: BuildGraphInput, maxNotes: number, nodes: GraphNo
|
|
|
117
131
|
for (const target of resolved) {
|
|
118
132
|
edges.push({ source: `note:${note.slug}`, target: `note:${target}`, kind: "links-to" });
|
|
119
133
|
}
|
|
134
|
+
// A note body naming a repo path → `mentions` (§4.4). Emitted after the
|
|
135
|
+
// wiki-links so a note's edges read vault-ward first, then code-ward, and
|
|
136
|
+
// only for paths that are already nodes — `paths` is built from the repo
|
|
137
|
+
// index, so a mention of an unindexed file resolves to its enclosing
|
|
138
|
+
// module or to nothing at all. Never to a phantom node.
|
|
139
|
+
for (const target of resolveMentions(note.body, paths)) {
|
|
140
|
+
edges.push({ source: `note:${note.slug}`, target, kind: "mentions" });
|
|
141
|
+
}
|
|
120
142
|
}
|
|
121
143
|
return [...keptSlugs];
|
|
122
144
|
}
|
|
@@ -243,7 +265,16 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
|
|
|
243
265
|
const maxNotes = options.maxNotes ?? DEFAULT_MAX_NOTES;
|
|
244
266
|
const nodes: GraphNode[] = [];
|
|
245
267
|
const edges: GraphEdge[] = [];
|
|
246
|
-
|
|
268
|
+
const danglingLinks: Record<string, string[]> = {};
|
|
269
|
+
// Built before the vault side, because that is where `mentions` edges are
|
|
270
|
+
// emitted and they need to know which repo paths are real nodes. Derived
|
|
271
|
+
// from the same `structure` arrays `buildRepositorySide` walks, so the two
|
|
272
|
+
// cannot disagree about which ids exist. Empty when there is no repository:
|
|
273
|
+
// a vault-only graph has nothing to mention.
|
|
274
|
+
const paths = input.repository === null
|
|
275
|
+
? (new Map<string, string>() as PathIndex)
|
|
276
|
+
: buildPathIndex(input.repository.index.structure);
|
|
277
|
+
buildVaultSide(input, maxNotes, nodes, edges, danglingLinks, paths);
|
|
247
278
|
if (input.repository !== null) {
|
|
248
279
|
buildRepositorySide(input.repository, input.summaries, nodes, edges);
|
|
249
280
|
}
|
|
@@ -252,7 +283,9 @@ export function buildGraph(input: BuildGraphInput, options: { maxNotes?: number
|
|
|
252
283
|
staleness: input.repository?.staleness ?? null,
|
|
253
284
|
nodes,
|
|
254
285
|
edges,
|
|
286
|
+
danglingLinks,
|
|
255
287
|
};
|
|
256
288
|
}
|
|
257
289
|
|
|
258
290
|
export type { EdgeKind, GraphEdge, GraphModel, GraphNode, NodeKind } from "./model";
|
|
291
|
+
export { buildPathIndex, extractPathMentions, resolveMentions, type PathIndex } from "./mentions";
|
|
@@ -11,22 +11,17 @@
|
|
|
11
11
|
|
|
12
12
|
import { readFile } from "node:fs/promises";
|
|
13
13
|
import { join, resolve, sep } from "node:path";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
resolveVaultRoot,
|
|
26
|
-
type BuildGraphInput,
|
|
27
|
-
type GraphModel,
|
|
28
|
-
type Note,
|
|
29
|
-
} from "../index";
|
|
14
|
+
// Imported from the individual modules rather than the `../index` barrel:
|
|
15
|
+
// `src/core/cache/workspace` imports this file, and the barrel re-exports
|
|
16
|
+
// that cache, so going through the barrel would close an import cycle.
|
|
17
|
+
import { findGitRoot } from "../git";
|
|
18
|
+
import { resolveVaultRoot } from "../paths";
|
|
19
|
+
import { assessStaleness, readRepoIndex } from "../repoIndex";
|
|
20
|
+
import { readSummaryMap } from "../summaries";
|
|
21
|
+
import type { Note } from "../types";
|
|
22
|
+
import { getNote, readVault, resolveNotePath } from "../vault";
|
|
23
|
+
import { buildGraph, DEFAULT_MAX_NOTES, type BuildGraphInput } from "./build";
|
|
24
|
+
import type { GraphModel } from "./model";
|
|
30
25
|
|
|
31
26
|
/** A note read for the viewers (read-only; never cached). Mirrors the vault `Note` shape. */
|
|
32
27
|
export interface ViewNote {
|
|
@@ -76,31 +71,49 @@ export async function readOkfFileForView(cwd: string, rel: string): Promise<{ pa
|
|
|
76
71
|
}
|
|
77
72
|
}
|
|
78
73
|
|
|
74
|
+
/**
|
|
75
|
+
* Assemble the repository half of the graph input for `cwd`, or null when
|
|
76
|
+
* cwd is not inside a git repository with a readable `.okf` index. Shared
|
|
77
|
+
* with `src/core/cache/workspace` so the cached and uncached paths cannot
|
|
78
|
+
* drift in what they read.
|
|
79
|
+
*/
|
|
80
|
+
export async function readRepositorySide(
|
|
81
|
+
cwd: string,
|
|
82
|
+
): Promise<Pick<BuildGraphInput, "repository" | "summaries"> | null> {
|
|
83
|
+
const repoRoot = await findGitRoot(cwd);
|
|
84
|
+
if (repoRoot === null) return null;
|
|
85
|
+
const index = await readRepoIndex(repoRoot);
|
|
86
|
+
if (index === null) return null;
|
|
87
|
+
return {
|
|
88
|
+
repository: { index, staleness: await assessStaleness(repoRoot) },
|
|
89
|
+
summaries: await readSummaryMap(repoRoot), // deep-scan sidecars, read live
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
79
93
|
/**
|
|
80
94
|
* Assemble the fresh graph from disk. Called on every viewer fetch
|
|
81
95
|
* (no caching — docs/weave-view.md §2). Reads the vault (capped at
|
|
82
96
|
* DEFAULT_MAX_NOTES) and, when cwd is an indexed git repository, the repo
|
|
83
97
|
* index + deep-scan summary sidecars. Degrades to a vault-only graph when
|
|
84
98
|
* the repo has no index or the index is corrupt.
|
|
99
|
+
*
|
|
100
|
+
* One read per note: `readVault` returns bodies *and* the file count, so the
|
|
101
|
+
* old `listNotes` → `getNote`-per-slug → `noteCount` sequence (2N reads plus
|
|
102
|
+
* a third readdir) is now N reads and one readdir (weave-workspace §4.1).
|
|
85
103
|
*/
|
|
86
104
|
export async function buildCurrentGraph(cwd: string, vaultRoot: string = resolveVaultRoot()): Promise<GraphModel> {
|
|
87
|
-
const
|
|
88
|
-
const loaded = await Promise.all(noteSummaries.map((s) => getNote(vaultRoot, s.slug)));
|
|
89
|
-
const notes = loaded.filter((n): n is Note => n !== null);
|
|
105
|
+
const { notes, fileCount } = await readVault(vaultRoot);
|
|
90
106
|
|
|
91
107
|
const input: BuildGraphInput = {
|
|
92
|
-
vault: { root: vaultRoot, exists: true, noteCount:
|
|
93
|
-
notes,
|
|
108
|
+
vault: { root: vaultRoot, exists: true, noteCount: fileCount },
|
|
109
|
+
notes: notes.slice(0, DEFAULT_MAX_NOTES),
|
|
94
110
|
repository: null,
|
|
95
111
|
};
|
|
96
112
|
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
input.repository = { index, staleness: await assessStaleness(repoRoot) };
|
|
102
|
-
input.summaries = await readSummaryMap(repoRoot); // deep-scan sidecars, read live
|
|
103
|
-
}
|
|
113
|
+
const repo = await readRepositorySide(cwd);
|
|
114
|
+
if (repo !== null) {
|
|
115
|
+
input.repository = repo.repository;
|
|
116
|
+
if (repo.summaries !== undefined) input.summaries = repo.summaries;
|
|
104
117
|
}
|
|
105
118
|
return buildGraph(input);
|
|
106
119
|
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `mentions` edges: a note body that names a repository path → note → node
|
|
3
|
+
* (weave-workspace §4.4, §15.5).
|
|
4
|
+
*
|
|
5
|
+
* `EdgeKind` has declared `"mentions"` since the graph model was written and
|
|
6
|
+
* `buildGraph` had never emitted one, so every consumer carried a branch that
|
|
7
|
+
* could not be taken and the legend advertised a relationship the product did
|
|
8
|
+
* not have. This module is the implementation side of that choice.
|
|
9
|
+
*
|
|
10
|
+
* Pure and I/O-free, like its sibling `wikilinks.ts`: a regex over a string
|
|
11
|
+
* and a lookup in a map built from the repo index. No parsing, no LLM, no
|
|
12
|
+
* new dependency (§4.4 is explicit about all three).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { RepoStructure } from "../types";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A repo-relative path candidate: two or more `/`-joined segments of word
|
|
19
|
+
* characters, dots and dashes.
|
|
20
|
+
*
|
|
21
|
+
* The lookbehind is the part doing real work. `(?<![\w/.@:-])` refuses to
|
|
22
|
+
* start a match immediately after a character that would make this the *tail*
|
|
23
|
+
* of something longer, which is what keeps `https://github.com/org/repo/src`
|
|
24
|
+
* and `user@host.com/path` from being read as repo paths. Without it every
|
|
25
|
+
* URL in every note becomes a candidate, and since candidates are only kept
|
|
26
|
+
* when they hit a real node the damage would be invisible until the day
|
|
27
|
+
* someone's repo happened to contain `org/repo`.
|
|
28
|
+
*
|
|
29
|
+
* At least one slash is required, so prose words are not candidates. Trailing
|
|
30
|
+
* sentence punctuation is stripped by {@link extractPathMentions} rather than
|
|
31
|
+
* excluded here, because `src/core.` at the end of a sentence should match
|
|
32
|
+
* `src/core` and a regex that refuses the dot cannot also accept
|
|
33
|
+
* `src/core/vault.ts`.
|
|
34
|
+
*/
|
|
35
|
+
const PATH_RE = /(?<![\w/.@:-])([\w.-]+(?:\/[\w.-]+)+)/g;
|
|
36
|
+
|
|
37
|
+
/** Punctuation that ends a sentence rather than a path. */
|
|
38
|
+
const TRAILING_PUNCT_RE = /[.,;:!?)\]}'"]+$/;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Repo path → graph node id, for every path-addressable node the builder
|
|
42
|
+
* emits.
|
|
43
|
+
*
|
|
44
|
+
* Deliberately built from {@link RepoStructure} rather than by scanning the
|
|
45
|
+
* finished node list: both are derived from the same arrays, so mirroring the
|
|
46
|
+
* id construction here keeps the invariant that *every* value in this map is
|
|
47
|
+
* a node that exists, while reading it back off `GraphNode.detail.path` would
|
|
48
|
+
* mean re-parsing a display-only field (§4.2) to recover something structured
|
|
49
|
+
* we already had.
|
|
50
|
+
*/
|
|
51
|
+
export type PathIndex = ReadonlyMap<string, string>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Index the path-bearing nodes of a repository, exactly mirroring the ids
|
|
55
|
+
* `buildRepositorySide` emits.
|
|
56
|
+
*
|
|
57
|
+
* Insertion order is modules, then entry points, then packages, then `.okf`
|
|
58
|
+
* files. Later kinds do **not** overwrite earlier ones: a path that is both a
|
|
59
|
+
* module and an entry point resolves to the module, which is the coarser and
|
|
60
|
+
* more navigable of the two. The choice only has to be *stable*, and pinning
|
|
61
|
+
* it here is what makes it so.
|
|
62
|
+
*
|
|
63
|
+
* `(root)` is skipped. It is a display label for "files directly in the repo
|
|
64
|
+
* root", not a path, and no note body can spell it in a way that reaches
|
|
65
|
+
* here anyway.
|
|
66
|
+
*/
|
|
67
|
+
export function buildPathIndex(structure: RepoStructure): PathIndex {
|
|
68
|
+
const index = new Map<string, string>();
|
|
69
|
+
const put = (path: string, id: string): void => {
|
|
70
|
+
if (path.length > 0 && !index.has(path)) index.set(path, id);
|
|
71
|
+
};
|
|
72
|
+
for (const mod of structure.modules) {
|
|
73
|
+
if (mod.path === "(root)") continue;
|
|
74
|
+
put(mod.path, `module:${mod.path}`);
|
|
75
|
+
}
|
|
76
|
+
for (const entry of structure.entryPoints) put(entry, `entryPoint:${entry}`);
|
|
77
|
+
for (const pkg of structure.packages) put(pkg.manifestPath, `package:${pkg.manifestPath}`);
|
|
78
|
+
// `.okf` files are addressed by their repo-relative path (".okf/…"), while
|
|
79
|
+
// their node ids drop the prefix — the same rename `buildRepositorySide`
|
|
80
|
+
// performs.
|
|
81
|
+
for (const file of structure.okFiles ?? []) {
|
|
82
|
+
put(file, `okf:${file.replace(/^\.okf\//, "")}`);
|
|
83
|
+
}
|
|
84
|
+
return index;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Every distinct path-like token in a note body, in order of first
|
|
89
|
+
* appearance, with trailing sentence punctuation and trailing slashes
|
|
90
|
+
* removed.
|
|
91
|
+
*
|
|
92
|
+
* Exported for its own tests: the regex is the part of this feature most
|
|
93
|
+
* likely to be wrong, and testing it through `buildGraph` alone would mean
|
|
94
|
+
* every false-positive case needed a whole repo fixture.
|
|
95
|
+
*/
|
|
96
|
+
export function extractPathMentions(body: string): string[] {
|
|
97
|
+
const out: string[] = [];
|
|
98
|
+
const seen = new Set<string>();
|
|
99
|
+
for (const match of body.matchAll(PATH_RE)) {
|
|
100
|
+
// `?? ""` rather than an `undefined` guard: the group is mandatory, so it
|
|
101
|
+
// always participates and a guard would be a branch no input can take.
|
|
102
|
+
// An empty string falls out at the `includes("/")` check just below.
|
|
103
|
+
const path = (match[1] ?? "").replace(TRAILING_PUNCT_RE, "").replace(/\/+$/, "");
|
|
104
|
+
if (!path.includes("/") || seen.has(path)) continue;
|
|
105
|
+
seen.add(path);
|
|
106
|
+
out.push(path);
|
|
107
|
+
}
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Resolve the repo nodes a note body mentions, as node ids in first-appearance
|
|
113
|
+
* order, deduplicated.
|
|
114
|
+
*
|
|
115
|
+
* ## Granularity: exact match, else the longest **enclosing** module
|
|
116
|
+
*
|
|
117
|
+
* This is the decision that keeps the edge count sane, and it runs in exactly
|
|
118
|
+
* one direction.
|
|
119
|
+
*
|
|
120
|
+
* *Never downward.* A note saying "see `src/core`" produces **one** edge, to
|
|
121
|
+
* `module:src/core`. It does not fan out to the files beneath it. Expanding a
|
|
122
|
+
* prefix downward is how a three-word sentence turns into forty edges and the
|
|
123
|
+
* graph column becomes unreadable — the explicit failure mode §4.4 warns
|
|
124
|
+
* about.
|
|
125
|
+
*
|
|
126
|
+
* *Upward, at most one step.* A note saying `src/core/vault.ts` is making a
|
|
127
|
+
* genuine reference, but most repository files are not graph nodes (only
|
|
128
|
+
* modules, entry points, packages and `.okf` files are), so an exact-match-only
|
|
129
|
+
* rule would silently drop the majority of real mentions and leave the feature
|
|
130
|
+
* looking broken. The fallback walks the path's ancestors longest-first and
|
|
131
|
+
* takes the first that is a **module**, so the mention lands on the nearest
|
|
132
|
+
* indexed container: `src/core/graph/build.ts` → `module:src/core`.
|
|
133
|
+
*
|
|
134
|
+
* The asymmetry is what makes this safe. Each distinct mentioned path resolves
|
|
135
|
+
* to **at most one** node, so the edges a note can contribute are bounded by
|
|
136
|
+
* the number of distinct paths it names, and the collapse only ever reduces
|
|
137
|
+
* that further — two files in the same module fold into a single edge because
|
|
138
|
+
* the result is deduplicated by target.
|
|
139
|
+
*
|
|
140
|
+
* The ancestor walk is restricted to modules on purpose: an entry point or a
|
|
141
|
+
* manifest is a *file*, and "this note mentions a path inside `package.json`"
|
|
142
|
+
* is not a relationship that exists.
|
|
143
|
+
*/
|
|
144
|
+
export function resolveMentions(body: string, paths: PathIndex): string[] {
|
|
145
|
+
const out: string[] = [];
|
|
146
|
+
const seen = new Set<string>();
|
|
147
|
+
for (const path of extractPathMentions(body)) {
|
|
148
|
+
const id = paths.get(path) ?? enclosingModule(path, paths);
|
|
149
|
+
if (id === null || id === undefined || seen.has(id)) continue;
|
|
150
|
+
seen.add(id);
|
|
151
|
+
out.push(id);
|
|
152
|
+
}
|
|
153
|
+
return out;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The nearest ancestor directory of `path` that is an indexed module, or
|
|
158
|
+
* `null`. Longest first, so `src/core/graph/x.ts` prefers `src/core/graph`
|
|
159
|
+
* over `src/core` when both are modules.
|
|
160
|
+
*/
|
|
161
|
+
function enclosingModule(path: string, paths: PathIndex): string | null {
|
|
162
|
+
const segments = path.split("/");
|
|
163
|
+
for (let cut = segments.length - 1; cut > 0; cut--) {
|
|
164
|
+
const ancestor = segments.slice(0, cut).join("/");
|
|
165
|
+
const id = paths.get(ancestor);
|
|
166
|
+
// Only a module may absorb a mention of something inside it.
|
|
167
|
+
if (id !== undefined && id.startsWith("module:")) return id;
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|