pi-weave 0.1.1

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.
@@ -0,0 +1,404 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { basename, join, posix } from "node:path";
3
+ import {
4
+ currentBranch,
5
+ changedFiles,
6
+ defaultBranch,
7
+ excludeOkfLocally,
8
+ findGitRoot,
9
+ hashWorktreeFiles,
10
+ headSha,
11
+ listFiles,
12
+ remotes,
13
+ snapshotGitState,
14
+ } from "./git";
15
+ import { languageForExtension } from "./languages";
16
+ import { OKF_DIR, OKF_MANIFEST, REPOSITORY_DIR } from "./paths";
17
+ import { NOTE_SOURCES } from "./types";
18
+ import type {
19
+ GitState,
20
+ NoteSource,
21
+ RepoIdentity,
22
+ RepoIndex,
23
+ RepoModule,
24
+ RepoPackage,
25
+ RepoStructure,
26
+ StalenessReport,
27
+ } from "./types";
28
+
29
+ /**
30
+ * Repository knowledge: the derived, disposable index under <repo>/.okf/.
31
+ *
32
+ * Scope of this module: Level 0 (structure) + light Level 1 (modules,
33
+ * packages, entry points). Source code is the source of truth; this index is
34
+ * a compiler artifact (design §4) — it can always be regenerated.
35
+ */
36
+
37
+ const GENERATOR = "pi-weave";
38
+
39
+ export interface ScanOptions {
40
+ /** Cap files considered (safety valve for huge repos, design §9). */
41
+ maxFiles?: number;
42
+ now?: Date;
43
+ }
44
+
45
+ const DEFAULT_MAX_FILES = 100_000;
46
+
47
+ const MANIFEST_KINDS: Record<string, RepoPackage["kind"]> = {
48
+ "package.json": "npm",
49
+ "pyproject.toml": "python",
50
+ "Cargo.toml": "rust",
51
+ "go.mod": "go",
52
+ "Gemfile": "ruby",
53
+ };
54
+
55
+ const ENTRY_POINT_CANDIDATES: RegExp[] = [
56
+ /^src\/(index|main|mod|lib|cli)\.[jt]sx?$/,
57
+ /^(index|main)\.[jt]sx?$/,
58
+ /^cmd\/[^/]+\/main\.go$/,
59
+ /^(main|__main__|app)\.py$/,
60
+ /^src\/main\.(py|go|rs|java|kt)$/,
61
+ /^src\/lib\.rs$/,
62
+ /^Sources\/.*\/main\.swift$/,
63
+ ];
64
+
65
+ function extensionOf(path: string): string {
66
+ const name = posix.basename(path).toLowerCase();
67
+ if (name === "dockerfile" || name.startsWith("dockerfile.")) return ".dockerfile";
68
+ const idx = name.lastIndexOf(".");
69
+ return idx > 0 ? name.slice(idx) : "";
70
+ }
71
+
72
+ function detectLanguages(files: string[]): Record<string, number> {
73
+ const counts = new Map<string, number>();
74
+ for (const file of files) {
75
+ const lang = languageForExtension(extensionOf(file));
76
+ if (!lang) continue;
77
+ counts.set(lang, (counts.get(lang) ?? 0) + 1);
78
+ }
79
+ return Object.fromEntries([...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])));
80
+ }
81
+
82
+ function detectPackages(files: string[]): RepoPackage[] {
83
+ const packages: RepoPackage[] = [];
84
+ for (const file of files) {
85
+ const kind = MANIFEST_KINDS[posix.basename(file)];
86
+ if (!kind) continue;
87
+ const dir = posix.dirname(file);
88
+ packages.push({ manifestPath: file, kind, name: dir === "." ? "(root)" : posix.basename(dir) });
89
+ }
90
+ return packages.sort((a, b) => a.manifestPath.localeCompare(b.manifestPath));
91
+ }
92
+
93
+ /**
94
+ * Best-effort package names from npm/pyproject/rust manifests. Read failures
95
+ * are fine: the directory-name fallback from detectPackages remains.
96
+ */
97
+ async function enrichPackageNames(root: string, packages: RepoPackage[]): Promise<void> {
98
+ for (const pkg of packages) {
99
+ try {
100
+ const text = await fs.readFile(join(root, pkg.manifestPath), "utf8");
101
+ const name = readNameField(text, pkg.kind);
102
+ if (name) pkg.name = name;
103
+ } catch {
104
+ // manifest unreadable — keep the directory-name fallback
105
+ }
106
+ }
107
+ }
108
+
109
+ function readNameField(text: string, kind: RepoPackage["kind"]): string | null {
110
+ if (kind === "npm") {
111
+ try {
112
+ const parsed: unknown = JSON.parse(text);
113
+ if (parsed && typeof parsed === "object") {
114
+ const name = (parsed as Record<string, unknown>).name;
115
+ if (typeof name === "string" && name.length > 0) return name;
116
+ }
117
+ } catch {
118
+ return null;
119
+ }
120
+ return null;
121
+ }
122
+ // TOML-ish manifests: first `name = "..."` line wins.
123
+ const match = /^name\s*=\s*"([^"]+)"/m.exec(text);
124
+ return match?.[1] ?? null;
125
+ }
126
+
127
+ function detectEntryPoints(files: string[]): string[] {
128
+ return files.filter((f) => ENTRY_POINT_CANDIDATES.some((re) => re.test(f))).sort();
129
+ }
130
+
131
+ /** Module grouping: the first 1-2 meaningful path segments, file-counted. */
132
+ function detectModules(files: string[]): RepoModule[] {
133
+ const SKIP_TOP = new Set(["node_modules", ".git"]);
134
+ const counts = new Map<string, number>();
135
+ for (const file of files) {
136
+ const segments = file.split("/");
137
+ const top = segments[0];
138
+ if (!top || SKIP_TOP.has(top)) continue;
139
+ let key: string;
140
+ if (segments.length > 2 && (top === "src" || top === "packages" || top === "apps" || top === "lib")) {
141
+ key = `${top}/${segments[1]}`;
142
+ } else if (segments.length > 1) {
143
+ key = top;
144
+ } else {
145
+ key = "(root)";
146
+ }
147
+ counts.set(key, (counts.get(key) ?? 0) + 1);
148
+ }
149
+ return [...counts.entries()]
150
+ .map(([path, fileCount]) => ({ path, fileCount }))
151
+ .sort((a, b) => b.fileCount - a.fileCount || a.path.localeCompare(b.path));
152
+ }
153
+
154
+ function detectTopLevel(files: string[]): { name: string; fileCount: number }[] {
155
+ const counts = new Map<string, number>();
156
+ for (const file of files) {
157
+ const idx = file.indexOf("/");
158
+ const name = idx === -1 ? "(root files)" : file.slice(0, idx);
159
+ counts.set(name, (counts.get(name) ?? 0) + 1);
160
+ }
161
+ return [...counts.entries()]
162
+ .map(([name, fileCount]) => ({ name, fileCount }))
163
+ .sort((a, b) => a.name.localeCompare(b.name));
164
+ }
165
+
166
+ /**
167
+ * Recursively count files under <root>/.okf (the derived index). The index is
168
+ * gitignored/excluded locally (design §15), so `listFiles` never sees it; we
169
+ * walk it directly so the viewer can surface `.okf` as a distinct folder.
170
+ * Returns repo-relative paths (e.g. "repository/git.json") or [] when absent.
171
+ */
172
+ async function listOkfFiles(root: string): Promise<string[]> {
173
+ const dir = join(root, OKF_DIR);
174
+ const out: string[] = [];
175
+ async function walk(p: string): Promise<void> {
176
+ let entries;
177
+ try {
178
+ entries = await fs.readdir(p, { withFileTypes: true });
179
+ } catch {
180
+ return;
181
+ }
182
+ for (const e of entries) {
183
+ const full = join(p, e.name);
184
+ if (e.isDirectory()) await walk(full);
185
+ else out.push(posix.relative(root, full));
186
+ }
187
+ }
188
+ await walk(dir);
189
+ return out.sort();
190
+ }
191
+
192
+ export function buildStructure(files: string[], now: Date = new Date()): RepoStructure {
193
+ return {
194
+ capturedAt: now.toISOString(),
195
+ fileCount: files.length,
196
+ languages: detectLanguages(files),
197
+ packages: detectPackages(files),
198
+ modules: detectModules(files),
199
+ entryPoints: detectEntryPoints(files),
200
+ topLevel: detectTopLevel(files),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Build a repository index from git (file list, identity, staleness anchor).
206
+ * Returns null when `root` is not a git repository or has no commits.
207
+ */
208
+ export async function buildRepoIndex(root: string, options: ScanOptions = {}): Promise<RepoIndex | null> {
209
+ const allFiles = await listFiles(root);
210
+ if (allFiles === null) return null;
211
+
212
+ const gitState: GitState | null = await snapshotGitState(root);
213
+ if (!gitState) return null; // unborn HEAD / no commits yet — nothing to anchor to
214
+
215
+ const capped = allFiles.slice(0, options.maxFiles ?? DEFAULT_MAX_FILES);
216
+ const now = options.now ?? new Date();
217
+ const structure = buildStructure(capped, now);
218
+ await enrichPackageNames(root, structure.packages);
219
+
220
+ // Surface the derived .okf index as an expandable folder in the repo tree
221
+ // so it is visible in the viewer. It is excluded from the git source list,
222
+ // so we capture it explicitly (without polluting the source stats like
223
+ // languages/fileCount).
224
+ const okFiles = await listOkfFiles(root);
225
+ if (okFiles.length > 0) {
226
+ structure.okFiles = okFiles;
227
+ structure.modules = [
228
+ ...structure.modules.filter((m) => m.path !== ".okf"),
229
+ { path: ".okf", fileCount: okFiles.length },
230
+ ];
231
+ }
232
+
233
+ const identity: RepoIdentity = {
234
+ name: basename(root),
235
+ root,
236
+ remotes: await remotes(root),
237
+ defaultBranch: await defaultBranch(root),
238
+ };
239
+
240
+ const timestamp = now.toISOString();
241
+ return {
242
+ okfVersion: 1,
243
+ scope: "repository",
244
+ generator: GENERATOR,
245
+ // Machine-derived knowledge carries its provenance (AGENTS.md rule 4).
246
+ source: "generated",
247
+ created: timestamp,
248
+ updated: timestamp,
249
+ identity,
250
+ git: gitState,
251
+ structure,
252
+ };
253
+ }
254
+
255
+ /**
256
+ * Write an index to <root>/.okf/ (creating the directory). Also ensures the
257
+ * index is excluded from git locally (Model A default, design §15) so the
258
+ * derived knowledge never makes the worktree — or the staleness anchor —
259
+ * dirty by itself.
260
+ */
261
+ export async function writeRepoIndex(root: string, index: RepoIndex): Promise<string> {
262
+ const dir = join(root, OKF_DIR);
263
+ const repoDir = join(dir, REPOSITORY_DIR);
264
+ await fs.mkdir(repoDir, { recursive: true });
265
+
266
+ const manifest = {
267
+ okfVersion: index.okfVersion,
268
+ scope: index.scope,
269
+ generator: index.generator,
270
+ source: index.source,
271
+ created: index.created,
272
+ updated: index.updated,
273
+ };
274
+
275
+ await Promise.all([
276
+ fs.writeFile(join(dir, OKF_MANIFEST), JSON.stringify(manifest, null, 2) + "\n", "utf8"),
277
+ fs.writeFile(join(repoDir, "identity.json"), JSON.stringify(index.identity, null, 2) + "\n", "utf8"),
278
+ fs.writeFile(join(repoDir, "git.json"), JSON.stringify(index.git, null, 2) + "\n", "utf8"),
279
+ fs.writeFile(join(repoDir, "structure.json"), JSON.stringify(index.structure, null, 2) + "\n", "utf8"),
280
+ ]);
281
+ await excludeOkfLocally(root);
282
+ return dir;
283
+ }
284
+
285
+ /** Read an existing index, or null when absent/malformed. */
286
+ export async function readRepoIndex(root: string): Promise<RepoIndex | null> {
287
+ const dir = join(root, OKF_DIR);
288
+ const repoDir = join(dir, REPOSITORY_DIR);
289
+ try {
290
+ const [manifest, identity, git, structure] = await Promise.all([
291
+ fs.readFile(join(dir, OKF_MANIFEST), "utf8"),
292
+ fs.readFile(join(repoDir, "identity.json"), "utf8"),
293
+ fs.readFile(join(repoDir, "git.json"), "utf8"),
294
+ fs.readFile(join(repoDir, "structure.json"), "utf8"),
295
+ ]);
296
+ const manifestJson = JSON.parse(manifest) as Record<string, unknown>;
297
+ return {
298
+ okfVersion: 1,
299
+ scope: "repository",
300
+ generator: typeof manifestJson.generator === "string" ? manifestJson.generator : GENERATOR,
301
+ // Pre-provenance indexes were still machine-written: default generated.
302
+ source: readProvenance(manifestJson.source),
303
+ created: typeof manifestJson.created === "string" ? manifestJson.created : "",
304
+ updated: typeof manifestJson.updated === "string" ? manifestJson.updated : "",
305
+ identity: JSON.parse(identity) as RepoIdentity,
306
+ git: JSON.parse(git) as GitState,
307
+ structure: JSON.parse(structure) as RepoStructure,
308
+ };
309
+ } catch {
310
+ return null;
311
+ }
312
+ }
313
+
314
+ /**
315
+ * Compare the stored index against live git state.
316
+ * - missing: no index on disk
317
+ * - fresh: HEAD and worktree unchanged since capture
318
+ * - stale: HEAD moved or worktree diverged
319
+ */
320
+ export async function assessStaleness(repoRoot: string): Promise<StalenessReport> {
321
+ const index = await readRepoIndex(repoRoot);
322
+ if (!index) return { state: "missing", reasons: ["no .okf index found"] };
323
+
324
+ const gitRoot = await findGitRoot(repoRoot);
325
+ if (!gitRoot) return { state: "stale", reasons: ["directory is no longer inside a git repository"] };
326
+
327
+ const reasons: string[] = [];
328
+ const sha = await headSha(gitRoot);
329
+ if (!sha) {
330
+ reasons.push("repository has no commits (unborn HEAD)");
331
+ } else if (sha !== index.git.headSha) {
332
+ reasons.push(`HEAD moved: ${index.git.headSha.slice(0, 7)} -> ${sha.slice(0, 7)}`);
333
+ }
334
+
335
+ const branch = await currentBranch(gitRoot);
336
+ if (branch && branch !== index.git.branch) {
337
+ reasons.push(`branch changed: ${index.git.branch} -> ${branch}`);
338
+ }
339
+
340
+ const changed = await changedFiles(gitRoot);
341
+ const previous = new Set(index.git.changedFiles);
342
+ const current = new Set(changed);
343
+ const newlyChanged = changed.filter((f) => !previous.has(f));
344
+ const resolved = [...previous].filter((f) => !current.has(f));
345
+ if (newlyChanged.length > 0) {
346
+ reasons.push(`${newlyChanged.length} new uncommitted change(s): ${newlyChanged.slice(0, 5).join(", ")}${newlyChanged.length > 5 ? ", …" : ""}`);
347
+ }
348
+ if (resolved.length > 0 && sha === index.git.headSha) {
349
+ reasons.push(`${resolved.length} previously-changed file(s) resolved`);
350
+ }
351
+
352
+ // Path membership alone misses re-edits of files that were already dirty
353
+ // at capture time — compare worktree content hashes for paths dirty in
354
+ // both snapshots. (Indexes captured before content anchoring carry no
355
+ // hashes → degrade to path-only comparison.)
356
+ const previousHashes: Record<string, string | null> = index.git.changedHashes ?? {};
357
+ const currentHashes = await hashWorktreeFiles(gitRoot, changed);
358
+ const contentChanged = changed.filter((f) => {
359
+ const before = previousHashes[f];
360
+ return before !== undefined && before !== currentHashes[f];
361
+ });
362
+ if (contentChanged.length > 0) {
363
+ reasons.push(
364
+ `${contentChanged.length} uncommitted file(s) edited since capture: ${contentChanged.slice(0, 5).join(", ")}${contentChanged.length > 5 ? ", …" : ""}`,
365
+ );
366
+ }
367
+
368
+ return { state: reasons.length === 0 ? "fresh" : "stale", reasons };
369
+ }
370
+
371
+ /**
372
+ * Read a manifest provenance field, defaulting unknown/missing values to
373
+ * "generated": every repository index written before the field existed was
374
+ * still machine-written.
375
+ */
376
+ function readProvenance(value: unknown): NoteSource {
377
+ return (NOTE_SOURCES as readonly string[]).includes(value as string)
378
+ ? (value as NoteSource)
379
+ : "generated";
380
+ }
381
+
382
+ /** Compact human/agent-readable summary of an index. */
383
+ export function summarizeIndex(index: RepoIndex): string[] {
384
+ const { structure, identity, git } = index;
385
+ const lines: string[] = [];
386
+ lines.push(`Repository: ${identity.name}`);
387
+ lines.push(`Git: ${git.branch} @ ${git.headSha.slice(0, 7)}${git.changedFiles.length > 0 ? ` (${git.changedFiles.length} uncommitted)` : ""}`);
388
+ lines.push(`Files: ${structure.fileCount}`);
389
+ const langs = Object.entries(structure.languages).slice(0, 6);
390
+ if (langs.length > 0) {
391
+ lines.push(`Languages: ${langs.map(([name, count]) => `${name} (${count})`).join(", ")}`);
392
+ }
393
+ if (structure.packages.length > 0) {
394
+ lines.push(`Packages: ${structure.packages.map((p) => p.name).join(", ")}`);
395
+ }
396
+ const modules = structure.modules.slice(0, 12);
397
+ if (modules.length > 0) {
398
+ lines.push(`Modules: ${modules.map((m) => `${m.path} (${m.fileCount})`).join(", ")}`);
399
+ }
400
+ if (structure.entryPoints.length > 0) {
401
+ lines.push(`Entry points: ${structure.entryPoints.join(", ")}`);
402
+ }
403
+ return lines;
404
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Turn a human title into a stable, filesystem-safe slug.
3
+ *
4
+ * Slugs are the identity of vault notes, so this function is deliberately
5
+ * simple and deterministic: lowercase, ASCII-fold, dashes.
6
+ */
7
+ export function slugify(title: string): string {
8
+ const slug = title
9
+ .normalize("NFKD")
10
+ .replace(/[̀-ͯ]/g, "") // strip combining marks
11
+ .toLowerCase()
12
+ .replace(/[^a-z0-9]+/g, "-")
13
+ .replace(/^-+|-+$/g, "")
14
+ .replace(/-{2,}/g, "-");
15
+ return slug.length > 0 ? slug : "note";
16
+ }
17
+
18
+ /**
19
+ * Find a free slug in the vault given a desired base, appending -2, -3, ...
20
+ * `exists` is injected so this stays pure and trivially testable.
21
+ */
22
+ export function uniqueSlug(base: string, exists: (slug: string) => boolean): string {
23
+ if (!exists(base)) return base;
24
+ for (let n = 2; ; n++) {
25
+ const candidate = `${base}-${n}`;
26
+ if (!exists(candidate)) return candidate;
27
+ }
28
+ }