@aprimediet/codewalker 1.0.0 → 1.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/README.md +44 -50
- package/index.ts +6 -42
- package/package.json +20 -39
- package/prompts/codewalker.md +7 -0
- package/skills/codewalker/SKILL.md +43 -0
- package/src/cards.test.ts +88 -0
- package/src/cards.ts +87 -0
- package/src/db.test.ts +197 -0
- package/src/db.ts +196 -0
- package/src/extract/ctags-parse.test.ts +108 -0
- package/src/extract/ctags-parse.ts +112 -0
- package/src/extract/ctags.ts +51 -0
- package/src/extract/docs.test.ts +81 -0
- package/src/extract/docs.ts +169 -0
- package/src/extract/regex.test.ts +202 -0
- package/src/extract/regex.ts +192 -0
- package/src/format.test.ts +71 -0
- package/src/format.ts +63 -0
- package/src/git.test.ts +75 -0
- package/src/git.ts +62 -0
- package/src/index.contract.test.ts +100 -0
- package/src/index.ts +124 -0
- package/src/indexer.test.ts +138 -0
- package/src/indexer.ts +352 -0
- package/src/project.test.ts +115 -0
- package/src/project.ts +204 -0
- package/src/query.test.ts +98 -0
- package/src/query.ts +73 -0
- package/src/sync.test.ts +116 -0
- package/src/types.ts +89 -0
- package/vitest.config.ts +28 -0
- package/LICENSE +0 -21
- package/agents.ts +0 -126
- package/compat.ts +0 -217
- package/detect.ts +0 -188
- package/docs/PRD.md +0 -78
- package/prd.ts +0 -106
- package/skills/learn-this/SKILL.md +0 -325
package/src/indexer.ts
ADDED
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codebase indexer: full scan and git-anchored incremental sync.
|
|
3
|
+
*
|
|
4
|
+
* - `scan()`: full build — walks the project tree, extracts symbols, writes cards, populates DB.
|
|
5
|
+
* - `sync()`: git-anchored incremental — reindexes only changed files.
|
|
6
|
+
* - `rebuildDbFromCards()`: rebuilds the DB from markdown cards alone (disposable-index property).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { openDb, upsertSymbol, deleteFileSymbols, deleteFile, setMeta, getMeta } from "./db.ts";
|
|
12
|
+
import { detectCtags, runCtags, runCtagsOnFile } from "./extract/ctags.ts";
|
|
13
|
+
import { parseCtagsOutput } from "./extract/ctags-parse.ts";
|
|
14
|
+
import { extractRegex } from "./extract/regex.ts";
|
|
15
|
+
import { extractDocComment } from "./extract/docs.ts";
|
|
16
|
+
import { renderCard, parseCard } from "./cards.ts";
|
|
17
|
+
import { getHeadSha, changedFilesSince } from "./git.ts";
|
|
18
|
+
import type { Symbol } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
export interface ScanOptions {
|
|
21
|
+
projectRoot: string;
|
|
22
|
+
globalCodewalkerDir: string;
|
|
23
|
+
dbPath: string;
|
|
24
|
+
entriesDir: string;
|
|
25
|
+
symbolsDir: string;
|
|
26
|
+
useCtags?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Supported file extensions for extraction
|
|
30
|
+
const SUPPORTED_EXTENSIONS = new Set([
|
|
31
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
|
|
32
|
+
".py", ".go",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Full scan: walk the project tree, extract symbols, write cards, populate DB.
|
|
37
|
+
* Idempotent: re-running rebuilds everything from scratch.
|
|
38
|
+
*/
|
|
39
|
+
export async function scan(options: ScanOptions): Promise<void> {
|
|
40
|
+
const { projectRoot, dbPath, entriesDir, symbolsDir, globalCodewalkerDir } = options;
|
|
41
|
+
const useCtags = options.useCtags ?? detectCtags();
|
|
42
|
+
|
|
43
|
+
// Ensure directories exist
|
|
44
|
+
fs.mkdirSync(symbolsDir, { recursive: true });
|
|
45
|
+
|
|
46
|
+
// Collect all source files
|
|
47
|
+
const files = collectSourceFiles(projectRoot);
|
|
48
|
+
|
|
49
|
+
// Extract symbols
|
|
50
|
+
const allSymbols: Symbol[] = [];
|
|
51
|
+
|
|
52
|
+
if (useCtags) {
|
|
53
|
+
const ctagsSymbols = runCtagsWrapper(projectRoot, files);
|
|
54
|
+
allSymbols.push(...ctagsSymbols);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Regex fallback for files ctags might have missed or when ctags is absent
|
|
58
|
+
const regexFiles = useCtags
|
|
59
|
+
? files.filter(f => !hasCtagsSupport(path.extname(f)))
|
|
60
|
+
: files;
|
|
61
|
+
|
|
62
|
+
for (const filePath of regexFiles) {
|
|
63
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
64
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
|
|
65
|
+
|
|
66
|
+
const source = fs.readFileSync(filePath, "utf-8");
|
|
67
|
+
const symbols = extractRegex(source, filePath);
|
|
68
|
+
allSymbols.push(...symbols);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// For all symbols, extract doc comments
|
|
72
|
+
for (const sym of allSymbols) {
|
|
73
|
+
try {
|
|
74
|
+
const source = fs.readFileSync(sym.file_path, "utf-8");
|
|
75
|
+
const doc = extractDocComment(source, sym.line_start);
|
|
76
|
+
sym.doc = doc;
|
|
77
|
+
} catch {
|
|
78
|
+
// File might have been deleted between scan and read
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Open DB
|
|
83
|
+
const db = openDb(dbPath);
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
// Start transaction
|
|
87
|
+
db.exec("BEGIN TRANSACTION");
|
|
88
|
+
|
|
89
|
+
// Clear existing data for this project's files
|
|
90
|
+
// We track which files we're about to index
|
|
91
|
+
const indexedPaths = new Set(allSymbols.map(s => s.file_path));
|
|
92
|
+
|
|
93
|
+
// Remove existing entries for files that no longer exist
|
|
94
|
+
const existingFiles = db.prepare("SELECT path FROM files").all() as { path: string }[];
|
|
95
|
+
for (const f of existingFiles) {
|
|
96
|
+
if (!indexedPaths.has(f.path)) {
|
|
97
|
+
deleteFileSymbols(db, f.path);
|
|
98
|
+
deleteFile(db, f.path);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// For existing files, also clean and re-insert
|
|
103
|
+
for (const sym of allSymbols) {
|
|
104
|
+
deleteFileSymbols(db, sym.file_path);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Write cards and insert symbols
|
|
108
|
+
for (const sym of allSymbols) {
|
|
109
|
+
// Generate card path
|
|
110
|
+
const fileSlug = slugFromPath(sym.file_path);
|
|
111
|
+
const cardFileName = `${sanitizeName(sym.name)}.md`;
|
|
112
|
+
const cardDir = path.join(symbolsDir, fileSlug);
|
|
113
|
+
fs.mkdirSync(cardDir, { recursive: true });
|
|
114
|
+
const cardPath = path.join(cardDir, cardFileName);
|
|
115
|
+
|
|
116
|
+
// Render and write card
|
|
117
|
+
const card = renderCard(sym);
|
|
118
|
+
const tmpPath = cardPath + ".tmp";
|
|
119
|
+
fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
|
|
120
|
+
fs.renameSync(tmpPath, cardPath);
|
|
121
|
+
|
|
122
|
+
// Update sym with card_path and insert into DB
|
|
123
|
+
sym.card_path = cardPath;
|
|
124
|
+
upsertSymbol(db, sym);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Update meta
|
|
128
|
+
const headSha = getHeadSha(projectRoot) ?? "";
|
|
129
|
+
setMeta(db, "last_indexed_commit", headSha);
|
|
130
|
+
setMeta(db, "last_full_scan", new Date().toISOString());
|
|
131
|
+
setMeta(db, "schema_version", "1");
|
|
132
|
+
|
|
133
|
+
db.exec("COMMIT");
|
|
134
|
+
} catch (e) {
|
|
135
|
+
db.exec("ROLLBACK");
|
|
136
|
+
throw e;
|
|
137
|
+
} finally {
|
|
138
|
+
db.close();
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Git-anchored incremental sync.
|
|
144
|
+
* Reindexes only changed files since last_indexed_commit.
|
|
145
|
+
*/
|
|
146
|
+
export async function sync(options: ScanOptions): Promise<void> {
|
|
147
|
+
const { projectRoot, dbPath, entriesDir, symbolsDir } = options;
|
|
148
|
+
const useCtags = options.useCtags ?? detectCtags();
|
|
149
|
+
|
|
150
|
+
const db = openDb(dbPath);
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const lastCommit = getMeta(db, "last_indexed_commit");
|
|
154
|
+
let changedFiles: string[] = [];
|
|
155
|
+
|
|
156
|
+
if (lastCommit) {
|
|
157
|
+
changedFiles = changedFilesSince(projectRoot, lastCommit);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Convert git's relative paths to absolute
|
|
161
|
+
const changedAbs = changedFiles.map((f) => path.resolve(projectRoot, f));
|
|
162
|
+
|
|
163
|
+
// Also scan for new files (git might not track untracked files)
|
|
164
|
+
const allFiles = collectSourceFiles(projectRoot);
|
|
165
|
+
const indexedFiles = new Set(
|
|
166
|
+
(db.prepare("SELECT path FROM files").all() as { path: string[] }).map(r => r.path as string),
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
// Find new files not yet indexed
|
|
170
|
+
const newFiles = allFiles.filter(f => !indexedFiles.has(f));
|
|
171
|
+
|
|
172
|
+
// Combine changed + new (all absolute paths)
|
|
173
|
+
const filesToProcess = new Set([...changedAbs, ...newFiles]);
|
|
174
|
+
|
|
175
|
+
if (filesToProcess.size === 0) {
|
|
176
|
+
// Nothing to do, but still update commit pointer
|
|
177
|
+
const headSha = getHeadSha(projectRoot) ?? "";
|
|
178
|
+
setMeta(db, "last_indexed_commit", headSha);
|
|
179
|
+
db.close();
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Process changed files
|
|
184
|
+
for (const fullPath of filesToProcess) {
|
|
185
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
186
|
+
|
|
187
|
+
if (!fs.existsSync(fullPath)) {
|
|
188
|
+
// File was deleted — use absolute path for DB operations
|
|
189
|
+
deleteFileSymbols(db, fullPath);
|
|
190
|
+
deleteFile(db, fullPath);
|
|
191
|
+
|
|
192
|
+
// Remove card directory
|
|
193
|
+
const fileSlug = slugFromPath(fullPath);
|
|
194
|
+
const cardDir = path.join(symbolsDir, fileSlug);
|
|
195
|
+
if (fs.existsSync(cardDir)) {
|
|
196
|
+
fs.rmSync(cardDir, { recursive: true, force: true });
|
|
197
|
+
}
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (!SUPPORTED_EXTENSIONS.has(ext)) continue;
|
|
202
|
+
|
|
203
|
+
// Re-extract
|
|
204
|
+
const source = fs.readFileSync(fullPath, "utf-8");
|
|
205
|
+
let symbols: Symbol[] = [];
|
|
206
|
+
|
|
207
|
+
if (useCtags && hasCtagsSupport(ext)) {
|
|
208
|
+
const ctagsOutput = runCtagsOnFile(fullPath, projectRoot);
|
|
209
|
+
symbols = parseCtagsOutput(ctagsOutput, projectRoot);
|
|
210
|
+
} else if (SUPPORTED_EXTENSIONS.has(ext)) {
|
|
211
|
+
symbols = extractRegex(source, fullPath);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Extract doc comments
|
|
215
|
+
for (const sym of symbols) {
|
|
216
|
+
sym.doc = extractDocComment(source, sym.line_start);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Remove old entries — use the full absolute path
|
|
220
|
+
deleteFileSymbols(db, fullPath);
|
|
221
|
+
|
|
222
|
+
// Write cards and insert
|
|
223
|
+
for (const sym of symbols) {
|
|
224
|
+
const fileSlug = slugFromPath(sym.file_path);
|
|
225
|
+
const cardFileName = `${sanitizeName(sym.name)}.md`;
|
|
226
|
+
const cardDir = path.join(symbolsDir, fileSlug);
|
|
227
|
+
fs.mkdirSync(cardDir, { recursive: true });
|
|
228
|
+
const cardPath = path.join(cardDir, cardFileName);
|
|
229
|
+
|
|
230
|
+
const card = renderCard(sym);
|
|
231
|
+
const tmpPath = cardPath + ".tmp";
|
|
232
|
+
fs.writeFileSync(tmpPath, card, { encoding: "utf-8", mode: 0o600 });
|
|
233
|
+
fs.renameSync(tmpPath, cardPath);
|
|
234
|
+
|
|
235
|
+
sym.card_path = cardPath;
|
|
236
|
+
upsertSymbol(db, sym);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Update commit pointer
|
|
241
|
+
const headSha = getHeadSha(projectRoot) ?? "";
|
|
242
|
+
setMeta(db, "last_indexed_commit", headSha);
|
|
243
|
+
|
|
244
|
+
db.close();
|
|
245
|
+
} catch (e) {
|
|
246
|
+
db.close();
|
|
247
|
+
throw e;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Rebuild the SQLite DB from markdown cards alone.
|
|
253
|
+
* This demonstrates the disposable-index property: cards are the source of truth.
|
|
254
|
+
*/
|
|
255
|
+
export function rebuildDbFromCards(
|
|
256
|
+
dbPath: string,
|
|
257
|
+
entriesDir: string,
|
|
258
|
+
): void {
|
|
259
|
+
const symbolsDir = path.join(entriesDir, "symbols");
|
|
260
|
+
if (!fs.existsSync(symbolsDir)) return;
|
|
261
|
+
|
|
262
|
+
const db = openDb(dbPath);
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
db.exec("BEGIN TRANSACTION");
|
|
266
|
+
|
|
267
|
+
// Walk card files
|
|
268
|
+
const walkDir = (dir: string): void => {
|
|
269
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
270
|
+
const fullPath = path.join(dir, entry.name);
|
|
271
|
+
if (entry.isDirectory()) {
|
|
272
|
+
walkDir(fullPath);
|
|
273
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
274
|
+
const card = fs.readFileSync(fullPath, "utf-8");
|
|
275
|
+
const parsed = parseCard(card);
|
|
276
|
+
if (!parsed) continue;
|
|
277
|
+
|
|
278
|
+
const { head } = parsed;
|
|
279
|
+
const locMatch = head.location.match(/^(.+):(\d+)-(\d+)$/);
|
|
280
|
+
if (!locMatch) continue;
|
|
281
|
+
|
|
282
|
+
upsertSymbol(db, {
|
|
283
|
+
name: head.name,
|
|
284
|
+
kind: head.kind,
|
|
285
|
+
file_path: locMatch[1] ?? head.location,
|
|
286
|
+
line_start: parseInt(locMatch[2] ?? "0", 10),
|
|
287
|
+
line_end: parseInt(locMatch[3] ?? "0", 10),
|
|
288
|
+
signature: head.signature,
|
|
289
|
+
doc: parsed.body,
|
|
290
|
+
summary: head.summary,
|
|
291
|
+
card_path: fullPath,
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
walkDir(symbolsDir);
|
|
298
|
+
db.exec("COMMIT");
|
|
299
|
+
} catch (e) {
|
|
300
|
+
db.exec("ROLLBACK");
|
|
301
|
+
throw e;
|
|
302
|
+
} finally {
|
|
303
|
+
db.close();
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// ---- Internal helpers ----
|
|
308
|
+
|
|
309
|
+
function collectSourceFiles(rootDir: string): string[] {
|
|
310
|
+
const files: string[] = [];
|
|
311
|
+
const walk = (dir: string) => {
|
|
312
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
313
|
+
const fullPath = path.join(dir, entry.name);
|
|
314
|
+
if (entry.isDirectory()) {
|
|
315
|
+
if (entry.name === "node_modules" || entry.name === ".git" || entry.name === ".pi" || entry.name.startsWith(".")) continue;
|
|
316
|
+
walk(fullPath);
|
|
317
|
+
} else if (entry.isFile()) {
|
|
318
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
319
|
+
if (SUPPORTED_EXTENSIONS.has(ext)) {
|
|
320
|
+
files.push(fullPath);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
walk(rootDir);
|
|
326
|
+
return files;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function hasCtagsSupport(ext: string): boolean {
|
|
330
|
+
return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".go"].includes(ext);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function runCtagsWrapper(projectRoot: string, files: string[]): Symbol[] {
|
|
334
|
+
try {
|
|
335
|
+
const output = runCtags(files, projectRoot);
|
|
336
|
+
return parseCtagsOutput(output, projectRoot);
|
|
337
|
+
} catch {
|
|
338
|
+
return [];
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function slugFromPath(filePath: string): string {
|
|
343
|
+
return filePath
|
|
344
|
+
.replace(/^\/+/, "")
|
|
345
|
+
.replace(/[^a-zA-Z0-9_\-/]/g, "_")
|
|
346
|
+
.replace(/\//g, "-")
|
|
347
|
+
.toLowerCase();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function sanitizeName(name: string): string {
|
|
351
|
+
return name.replace(/[^a-zA-Z0-9_$]/g, "_");
|
|
352
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
4
|
+
import * as os from 'node:os';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
// We'll test project.ts after writing the test
|
|
8
|
+
|
|
9
|
+
describe('project.ts', () => {
|
|
10
|
+
let tmpDir: string;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-project-'));
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
describe('slug + id algorithm', () => {
|
|
21
|
+
it('generates a deterministic id from project root: slug(basename)-sha1(absRoot)[:8]', async () => {
|
|
22
|
+
const mod = await import('./project.ts');
|
|
23
|
+
const p = mod.resolveProject(tmpDir);
|
|
24
|
+
// slug = basename lowercased, non-alphanumeric → '-'
|
|
25
|
+
const basename = path.basename(tmpDir).toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40) || 'project';
|
|
26
|
+
const hash = crypto.createHash('sha1').update(tmpDir).digest('hex').slice(0, 8);
|
|
27
|
+
const expectedId = `${basename}-${hash}`;
|
|
28
|
+
expect(p.id).toBe(expectedId);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('reuses an existing .pi/<id>.md marker id (marker-wins)', async () => {
|
|
32
|
+
const configDir = path.join(tmpDir, '.pi');
|
|
33
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
34
|
+
const markerId = 'my-project-a1b2c3d4';
|
|
35
|
+
const markerPath = path.join(configDir, `${markerId}.md`);
|
|
36
|
+
fs.writeFileSync(markerPath, `---\npi-project: true\nid: ${markerId}\n---\n# marker\n`, 'utf-8');
|
|
37
|
+
|
|
38
|
+
const mod = await import('./project.ts');
|
|
39
|
+
const p = mod.resolveProject(tmpDir);
|
|
40
|
+
expect(p.id).toBe(markerId);
|
|
41
|
+
expect(p.markerPath).toBe(markerPath);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe('findProjectRoot', () => {
|
|
46
|
+
it('walks up from cwd to find .git', async () => {
|
|
47
|
+
const gitDir = path.join(tmpDir, 'sub', 'dir');
|
|
48
|
+
fs.mkdirSync(gitDir, { recursive: true });
|
|
49
|
+
fs.writeFileSync(path.join(tmpDir, '.git'), '');
|
|
50
|
+
const mod = await import('./project.ts');
|
|
51
|
+
// We need to call resolveProject from within sub/dir
|
|
52
|
+
const p = mod.resolveProject(gitDir);
|
|
53
|
+
expect(p.root).toBe(tmpDir);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('walks up from cwd to find .pi config dir', async () => {
|
|
57
|
+
const piDir = path.join(tmpDir, 'deep', 'nested');
|
|
58
|
+
fs.mkdirSync(piDir, { recursive: true });
|
|
59
|
+
fs.mkdirSync(path.join(tmpDir, '.pi'), { recursive: true });
|
|
60
|
+
const mod = await import('./project.ts');
|
|
61
|
+
const p = mod.resolveProject(piDir);
|
|
62
|
+
expect(p.root).toBe(tmpDir);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns cwd when no marker or .git is found', async () => {
|
|
66
|
+
const isolated = path.join(tmpDir, 'isolated');
|
|
67
|
+
fs.mkdirSync(isolated, { recursive: true });
|
|
68
|
+
const mod = await import('./project.ts');
|
|
69
|
+
const p = mod.resolveProject(isolated);
|
|
70
|
+
expect(p.root).toBe(isolated);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('ProjectPaths', () => {
|
|
75
|
+
it('resolves codewalker paths under ~/.pi/projects/<id>/codewalker/', async () => {
|
|
76
|
+
const mod = await import('./project.ts');
|
|
77
|
+
const p = mod.resolveProject(tmpDir);
|
|
78
|
+
expect(p.codewalkerDir).toContain(path.join('projects', p.id, 'codewalker'));
|
|
79
|
+
expect(p.dbPath).toBe(path.join(p.codewalkerDir, 'index.db'));
|
|
80
|
+
expect(p.metaFile).toBe(path.join(p.codewalkerDir, 'meta.json'));
|
|
81
|
+
expect(p.entriesDir).toBe(path.join(p.codewalkerDir, 'entries'));
|
|
82
|
+
expect(p.symbolsDir).toBe(path.join(p.codewalkerDir, 'entries', 'symbols'));
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
describe('ensureProject', () => {
|
|
87
|
+
it('creates the codewalker directory structure and returns paths', async () => {
|
|
88
|
+
const mod = await import('./project.ts');
|
|
89
|
+
const p = await mod.ensureProject(tmpDir);
|
|
90
|
+
// marker file exists
|
|
91
|
+
expect(fs.existsSync(p.markerPath)).toBe(true);
|
|
92
|
+
// codewalker dir is created
|
|
93
|
+
expect(fs.existsSync(p.codewalkerDir)).toBe(true);
|
|
94
|
+
expect(fs.existsSync(p.entriesDir)).toBe(true);
|
|
95
|
+
expect(fs.existsSync(p.symbolsDir)).toBe(true);
|
|
96
|
+
// meta.json written
|
|
97
|
+
expect(fs.existsSync(p.metaFile)).toBe(true);
|
|
98
|
+
// meta.json has correct shape
|
|
99
|
+
const meta = JSON.parse(fs.readFileSync(p.metaFile, 'utf-8'));
|
|
100
|
+
expect(meta.id).toBe(p.id);
|
|
101
|
+
expect(meta.name).toBe(path.basename(tmpDir));
|
|
102
|
+
expect(Array.isArray(meta.paths)).toBe(true);
|
|
103
|
+
expect(meta.paths).toContain(tmpDir);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('is idempotent — calling ensureProject twice does not error', async () => {
|
|
107
|
+
const mod = await import('./project.ts');
|
|
108
|
+
await mod.ensureProject(tmpDir);
|
|
109
|
+
await mod.ensureProject(tmpDir);
|
|
110
|
+
// No error means idempotent
|
|
111
|
+
const p = mod.resolveProject(tmpDir);
|
|
112
|
+
expect(fs.existsSync(p.codewalkerDir)).toBe(true);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
package/src/project.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project identity + path layout for codewalker.
|
|
3
|
+
*
|
|
4
|
+
* The working tree stays clean: the only artifact written into <cwd>/.pi is a single identifier
|
|
5
|
+
* file `<project-id>.md`. Everything else (codewalker index, cards, meta) lives globally
|
|
6
|
+
* under ~/.pi/projects/<project-id>/codewalker/.
|
|
7
|
+
*
|
|
8
|
+
* The project id is deterministic from the project root path (`<slug>-<hash>`) so it is stable
|
|
9
|
+
* across runs; if the marker already records an id (e.g. the directory was moved), that id wins,
|
|
10
|
+
* so codewalker follows the project rather than the path.
|
|
11
|
+
*
|
|
12
|
+
* This module is adapted from @aprimediet/memory's project.ts — same id algorithm, same marker,
|
|
13
|
+
* different per-extension subdirectory.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import * as crypto from "node:crypto";
|
|
17
|
+
import * as fs from "node:fs";
|
|
18
|
+
import * as path from "node:path";
|
|
19
|
+
|
|
20
|
+
const CONFIG_DIR_NAME = ".pi";
|
|
21
|
+
|
|
22
|
+
export interface ProjectPaths {
|
|
23
|
+
id: string;
|
|
24
|
+
root: string;
|
|
25
|
+
configDir: string;
|
|
26
|
+
markerPath: string;
|
|
27
|
+
globalDir: string;
|
|
28
|
+
codewalkerDir: string;
|
|
29
|
+
dbPath: string;
|
|
30
|
+
metaFile: string;
|
|
31
|
+
entriesDir: string;
|
|
32
|
+
symbolsDir: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function piHome(): string {
|
|
36
|
+
// Try common pi home locations; fallback to ~/.pi
|
|
37
|
+
const homePi = path.join(osHomedir(), ".pi");
|
|
38
|
+
const projects = path.join(homePi, "projects");
|
|
39
|
+
// If ~/.pi exists and has a projects/ dir, use it
|
|
40
|
+
if (fs.existsSync(projects)) return homePi;
|
|
41
|
+
// Otherwise create it
|
|
42
|
+
fs.mkdirSync(projects, { recursive: true });
|
|
43
|
+
return homePi;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function osHomedir(): string {
|
|
47
|
+
return process.env.HOME || process.env.USERPROFILE || "/root";
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function projectsRoot(): string {
|
|
51
|
+
return path.join(piHome(), "projects");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function slug(name: string): string {
|
|
55
|
+
const s = name
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
58
|
+
.replace(/^-+|-+$/g, "")
|
|
59
|
+
.slice(0, 40);
|
|
60
|
+
return s || "project";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function pathHash(abs: string): string {
|
|
64
|
+
return crypto.createHash("sha1").update(abs).digest("hex").slice(0, 8);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function findProjectRoot(cwd: string): string {
|
|
68
|
+
let dir = path.resolve(cwd);
|
|
69
|
+
for (;;) {
|
|
70
|
+
if (fs.existsSync(path.join(dir, CONFIG_DIR_NAME)) || fs.existsSync(path.join(dir, ".git"))) return dir;
|
|
71
|
+
const parent = path.dirname(dir);
|
|
72
|
+
if (parent === dir) return cwd;
|
|
73
|
+
dir = parent;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Read an existing marker (a .pi/*.md file with `pi-project: true`); return its id + file. */
|
|
78
|
+
function readMarker(configDir: string): { id: string; file: string } | null {
|
|
79
|
+
if (!fs.existsSync(configDir)) return null;
|
|
80
|
+
let names: string[];
|
|
81
|
+
try {
|
|
82
|
+
names = fs.readdirSync(configDir).filter((n) => n.endsWith(".md"));
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
for (const name of names) {
|
|
87
|
+
const file = path.join(configDir, name);
|
|
88
|
+
try {
|
|
89
|
+
const content = fs.readFileSync(file, "utf-8");
|
|
90
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
91
|
+
if (!match) continue;
|
|
92
|
+
const fm = match[1] as string;
|
|
93
|
+
const lines = fm.split("\n");
|
|
94
|
+
const fmObj: Record<string, string> = {};
|
|
95
|
+
for (const line of lines) {
|
|
96
|
+
const sep = line.indexOf(":");
|
|
97
|
+
if (sep > 0) {
|
|
98
|
+
const key = line.slice(0, sep).trim();
|
|
99
|
+
const val = line.slice(sep + 1).trim();
|
|
100
|
+
fmObj[key] = val;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (fmObj["pi-project"] === "true" && fmObj["id"]) {
|
|
104
|
+
return { id: fmObj["id"], file };
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
/* not a marker */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function pathsForId(id: string, root: string, configDir: string, markerPath: string): ProjectPaths {
|
|
114
|
+
const globalDir = path.join(projectsRoot(), id);
|
|
115
|
+
return {
|
|
116
|
+
id,
|
|
117
|
+
root,
|
|
118
|
+
configDir,
|
|
119
|
+
markerPath,
|
|
120
|
+
globalDir,
|
|
121
|
+
codewalkerDir: path.join(globalDir, "codewalker"),
|
|
122
|
+
dbPath: path.join(globalDir, "codewalker", "index.db"),
|
|
123
|
+
metaFile: path.join(globalDir, "codewalker", "meta.json"),
|
|
124
|
+
entriesDir: path.join(globalDir, "codewalker", "entries"),
|
|
125
|
+
symbolsDir: path.join(globalDir, "codewalker", "entries", "symbols"),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Resolve project identity for a cwd (read-only — does not create anything). */
|
|
130
|
+
export function resolveProject(cwd: string): ProjectPaths {
|
|
131
|
+
const root = findProjectRoot(cwd);
|
|
132
|
+
const configDir = path.join(root, CONFIG_DIR_NAME);
|
|
133
|
+
const existing = readMarker(configDir);
|
|
134
|
+
const id = existing?.id ?? `${slug(path.basename(root))}-${pathHash(root)}`;
|
|
135
|
+
const markerPath = existing?.file ?? path.join(configDir, `${id}.md`);
|
|
136
|
+
return pathsForId(id, root, configDir, markerPath);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function markerBody(id: string, createdISO: string): string {
|
|
140
|
+
return [
|
|
141
|
+
"---",
|
|
142
|
+
"pi-project: true",
|
|
143
|
+
`id: ${id}`,
|
|
144
|
+
`created: ${createdISO}`,
|
|
145
|
+
"---",
|
|
146
|
+
"# pi codewalker project",
|
|
147
|
+
"",
|
|
148
|
+
"This file marks this directory as a pi codewalker project. To keep your working tree clean,",
|
|
149
|
+
"all codewalker artifacts are stored globally — NOT here — under:",
|
|
150
|
+
"",
|
|
151
|
+
` ~/.pi/projects/${id}/codewalker/`,
|
|
152
|
+
"",
|
|
153
|
+
"- `index.db` disposable SQLite+FTS5 index",
|
|
154
|
+
"- `meta.json` last-indexed commit and schema version",
|
|
155
|
+
"- `entries/` markdown cards (source of truth)",
|
|
156
|
+
"",
|
|
157
|
+
"Managed by @aprimediet/codewalker. Safe to commit (stable id) and safe to delete (recreated).",
|
|
158
|
+
"",
|
|
159
|
+
].join("\n");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Create the global directory structure + the cwd marker (idempotent). Returns the paths. */
|
|
163
|
+
export async function ensureProject(cwd: string): Promise<ProjectPaths> {
|
|
164
|
+
const p = resolveProject(cwd);
|
|
165
|
+
const nowISO = new Date().toISOString();
|
|
166
|
+
|
|
167
|
+
for (const dir of [p.codewalkerDir, p.entriesDir, p.symbolsDir]) {
|
|
168
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// marker in cwd (the only thing we write into the working tree)
|
|
172
|
+
if (!fs.existsSync(p.markerPath)) {
|
|
173
|
+
fs.mkdirSync(p.configDir, { recursive: true });
|
|
174
|
+
const tmp = `${p.markerPath}.tmp`;
|
|
175
|
+
fs.writeFileSync(tmp, markerBody(p.id, nowISO), { encoding: "utf-8", mode: 0o644 });
|
|
176
|
+
fs.renameSync(tmp, p.markerPath);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// meta.json — track every path this project has been seen at
|
|
180
|
+
interface ProjectMeta {
|
|
181
|
+
id: string;
|
|
182
|
+
name: string;
|
|
183
|
+
paths: string[];
|
|
184
|
+
created: string;
|
|
185
|
+
lastSeen: string;
|
|
186
|
+
}
|
|
187
|
+
let meta: ProjectMeta = { id: p.id, name: path.basename(p.root), paths: [], created: nowISO, lastSeen: nowISO };
|
|
188
|
+
try {
|
|
189
|
+
meta = { ...meta, ...(JSON.parse(fs.readFileSync(p.metaFile, "utf-8")) as ProjectMeta) };
|
|
190
|
+
} catch {
|
|
191
|
+
/* first run */
|
|
192
|
+
}
|
|
193
|
+
if (!meta.paths.includes(p.root)) meta.paths.push(p.root);
|
|
194
|
+
meta.lastSeen = nowISO;
|
|
195
|
+
try {
|
|
196
|
+
const tmp = `${p.metaFile}.tmp`;
|
|
197
|
+
fs.writeFileSync(tmp, JSON.stringify(meta, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
198
|
+
fs.renameSync(tmp, p.metaFile);
|
|
199
|
+
} catch {
|
|
200
|
+
/* non-fatal */
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
return p;
|
|
204
|
+
}
|