@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.
@@ -0,0 +1,75 @@
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 { execSync } from 'node:child_process';
6
+ import { getHeadSha, changedFilesSince, parseDiffNameOnly } from './git.ts';
7
+
8
+ describe('parseDiffNameOnly', () => {
9
+ it('parses git diff --name-only output into string[]', () => {
10
+ const output = `src/a.ts\nsrc/b.ts\nREADME.md\n`;
11
+ const files = parseDiffNameOnly(output);
12
+ expect(files).toEqual(['src/a.ts', 'src/b.ts', 'README.md']);
13
+ });
14
+
15
+ it('returns empty array for empty output', () => {
16
+ expect(parseDiffNameOnly('')).toEqual([]);
17
+ });
18
+
19
+ it('trims whitespace from each line', () => {
20
+ const output = ` src/a.ts \n src/b.ts\n`;
21
+ const files = parseDiffNameOnly(output);
22
+ expect(files).toEqual(['src/a.ts', 'src/b.ts']);
23
+ });
24
+ });
25
+
26
+ describe('git operations in a real repo', () => {
27
+ let tmpDir: string;
28
+
29
+ beforeEach(() => {
30
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-git-'));
31
+ execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
32
+ execSync('git config user.email test@test.com', { cwd: tmpDir, stdio: 'ignore' });
33
+ execSync('git config user.name Test', { cwd: tmpDir, stdio: 'ignore' });
34
+ });
35
+
36
+ afterEach(() => {
37
+ fs.rmSync(tmpDir, { recursive: true, force: true });
38
+ });
39
+
40
+ it('getHeadSha returns the current HEAD commit hash', () => {
41
+ fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'content');
42
+ execSync('git add . && git commit -m "first"', { cwd: tmpDir, stdio: 'ignore' });
43
+ const sha = getHeadSha(tmpDir);
44
+ expect(sha).toBeTruthy();
45
+ expect(sha!.length).toBe(40);
46
+ });
47
+
48
+ it('getHeadSha returns null when there are no commits', () => {
49
+ const sha = getHeadSha(tmpDir);
50
+ expect(sha).toBeNull();
51
+ });
52
+
53
+ it('changedFilesSince returns [] when lastCommit === HEAD', () => {
54
+ fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'content');
55
+ execSync('git add . && git commit -m "first"', { cwd: tmpDir, stdio: 'ignore' });
56
+ const sha = getHeadSha(tmpDir)!;
57
+ const files = changedFilesSince(tmpDir, sha);
58
+ expect(files).toEqual([]);
59
+ });
60
+
61
+ it('changedFilesSince returns changed files since a previous commit', () => {
62
+ fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'v1');
63
+ execSync('git add . && git commit -m "first"', { cwd: tmpDir, stdio: 'ignore' });
64
+ const firstSha = getHeadSha(tmpDir)!;
65
+
66
+ // Second commit
67
+ fs.writeFileSync(path.join(tmpDir, 'a.ts'), 'v2');
68
+ fs.writeFileSync(path.join(tmpDir, 'b.ts'), 'new');
69
+ execSync('git add . && git commit -m "second"', { cwd: tmpDir, stdio: 'ignore' });
70
+
71
+ const files = changedFilesSince(tmpDir, firstSha);
72
+ expect(files).toContain('a.ts');
73
+ expect(files).toContain('b.ts');
74
+ });
75
+ });
package/src/git.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Git operations for codewalker's git-anchored incremental sync.
3
+ *
4
+ * Provides getHeadSha, changedFilesSince (git diff --name-only), and
5
+ * the pure parser parseDiffNameOnly.
6
+ */
7
+
8
+ import { execSync } from "node:child_process";
9
+
10
+ /**
11
+ * Get the current HEAD commit SHA for a repo.
12
+ * Returns null if there are no commits yet.
13
+ */
14
+ export function getHeadSha(repoDir: string): string | null {
15
+ try {
16
+ const sha = execSync("git rev-parse HEAD", {
17
+ cwd: repoDir,
18
+ encoding: "utf-8",
19
+ stdio: ["ignore", "pipe", "ignore"],
20
+ timeout: 5000,
21
+ }).trim();
22
+ return sha || null;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Get the list of files changed between two commits.
30
+ * Uses `git diff --name-only <from> <to>`.
31
+ * Returns an empty array if no files changed.
32
+ */
33
+ export function changedFilesSince(
34
+ repoDir: string,
35
+ sinceCommit: string,
36
+ ): string[] {
37
+ try {
38
+ const output = execSync(
39
+ `git diff --name-only "${sinceCommit}" HEAD`,
40
+ {
41
+ cwd: repoDir,
42
+ encoding: "utf-8",
43
+ stdio: ["ignore", "pipe", "ignore"],
44
+ timeout: 5000,
45
+ },
46
+ );
47
+ return parseDiffNameOnly(output);
48
+ } catch {
49
+ return [];
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Pure: parse the output of `git diff --name-only` into a string array.
55
+ * Each line is a file path; ignores empty lines.
56
+ */
57
+ export function parseDiffNameOnly(output: string): string[] {
58
+ return output
59
+ .split("\n")
60
+ .map((line) => line.trim())
61
+ .filter((line) => line.length > 0);
62
+ }
@@ -0,0 +1,100 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import * as fs from 'node:fs';
3
+ import * as path from 'node:path';
4
+ import * as os from 'node:os';
5
+
6
+ // We test the contract of the extension factory: it must register a tool and a command.
7
+ // We create a minimal pi API stub to verify the shape.
8
+
9
+ interface ToolRegistration {
10
+ name: string;
11
+ description: string;
12
+ parameters: unknown;
13
+ execute: Function;
14
+ }
15
+
16
+ interface CommandRegistration {
17
+ name: string;
18
+ description: string;
19
+ handler: Function;
20
+ }
21
+
22
+ function createPiStub(): {
23
+ tools: ToolRegistration[];
24
+ commands: CommandRegistration[];
25
+ api: Record<string, Function>;
26
+ } {
27
+ const tools: ToolRegistration[] = [];
28
+ const commands: CommandRegistration[] = [];
29
+ const api: Record<string, Function> = {};
30
+
31
+ return {
32
+ tools,
33
+ commands,
34
+ api: {
35
+ registerTool: (t: ToolRegistration) => { tools.push(t); },
36
+ registerCommand: (name: string, cmd: { description: string; handler: Function }) => {
37
+ commands.push({ name, description: cmd.description, handler: cmd.handler });
38
+ },
39
+ },
40
+ };
41
+ }
42
+
43
+ describe('index.ts contract', () => {
44
+ it('the default export is a factory function', async () => {
45
+ const mod = await import('./index.ts');
46
+ expect(typeof mod.default).toBe('function');
47
+ });
48
+
49
+ it('calling the factory registers a tool named codewalker_query and a command named codewalker', async () => {
50
+ const mod = await import('./index.ts');
51
+ const stub = createPiStub();
52
+
53
+ // Call the factory with the stub API
54
+ mod.default(stub.api as any);
55
+
56
+ // Check tool registered
57
+ const queryTool = stub.tools.find(t => t.name === 'codewalker_query');
58
+ expect(queryTool).toBeDefined();
59
+ expect(queryTool!.description).toContain('code index');
60
+
61
+ // Check command registered
62
+ const cmd = stub.commands.find(c => c.name === 'codewalker');
63
+ expect(cmd).toBeDefined();
64
+ expect(cmd!.description).toBeDefined();
65
+ });
66
+
67
+ it('tool.execute returns { content, details } with compact text content', async () => {
68
+ // Create a temporary project dir so the DB path exists
69
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-contract-'));
70
+ const piDir = path.join(tmpDir, '.pi');
71
+ fs.mkdirSync(piDir, { recursive: true });
72
+ const markerId = 'test-project-contract-' + Math.random().toString(36).slice(2, 8);
73
+ fs.writeFileSync(path.join(piDir, markerId + '.md'), `---\npi-project: true\nid: ${markerId}\n---\n`);
74
+
75
+ // Pre-create the codewalker global dir so the DB can be opened
76
+ const homePi = path.join(os.homedir(), '.pi', 'projects', markerId, 'codewalker');
77
+ fs.mkdirSync(homePi, { recursive: true });
78
+
79
+ const mod = await import('./index.ts');
80
+ const stub = createPiStub();
81
+
82
+ mod.default(stub.api as any);
83
+
84
+ const tool = stub.tools.find(t => t.name === 'codewalker_query')!;
85
+ const result = await tool.execute(
86
+ 'test-id',
87
+ { query: 'test' },
88
+ new AbortController().signal,
89
+ () => {},
90
+ { cwd: tmpDir },
91
+ );
92
+
93
+ expect(result).toHaveProperty('content');
94
+ expect(result).toHaveProperty('details');
95
+ expect(Array.isArray(result.content)).toBe(true);
96
+ expect(result.content[0]!.type).toBe('text');
97
+
98
+ fs.rmSync(tmpDir, { recursive: true, force: true });
99
+ });
100
+ });
package/src/index.ts ADDED
@@ -0,0 +1,124 @@
1
+ /**
2
+ * @aprimediet/codewalker
3
+ *
4
+ * Queryable, token-economical project & code index for the pi coding agent.
5
+ *
6
+ * Registers:
7
+ * - `codewalker_query` tool (agent-facing, compact results)
8
+ * - `/codewalker` command (human-facing) with subcommands scan, sync, query
9
+ */
10
+
11
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import { Type } from "typebox";
13
+ import { resolveProject, ensureProject } from "./project.ts";
14
+ import { runQuery } from "./query.ts";
15
+ import { scan, sync } from "./indexer.ts";
16
+ import { formatCompact, formatCardBody } from "./format.ts";
17
+
18
+ export default function codewalkerExtension(pi: ExtensionAPI): void {
19
+ // ----------------------------------------------------------------- tool
20
+ pi.registerTool({
21
+ name: "codewalker_query",
22
+ label: "Codewalker Query",
23
+ description:
24
+ "Search the project's code index for symbols (functions, consts, classes, types). " +
25
+ "Returns compact facts (name, kind, file:line, one-line summary) — use this BEFORE grepping/reading files.",
26
+ parameters: Type.Object({
27
+ query: Type.String({ description: "Search text — symbol name or concept keywords." }),
28
+ kind: Type.Optional(Type.String({ description: "Filter: function|const|class|type|method|enum" })),
29
+ limit: Type.Optional(Type.Number({ description: "Max hits (default 10)." })),
30
+ }),
31
+ async execute(_id, params, _signal, _onUpdate, ctx) {
32
+ const project = resolveProject(ctx.cwd);
33
+ const { rows, staleness } = runQuery(
34
+ project.dbPath,
35
+ {
36
+ query: (params as any).query as string,
37
+ kind: (params as any).kind as string | undefined,
38
+ limit: (params as any).limit as number | undefined,
39
+ },
40
+ project.root,
41
+ );
42
+
43
+ const text = formatCompact(rows, staleness);
44
+ return {
45
+ content: [{ type: "text" as const, text }],
46
+ details: { rows },
47
+ };
48
+ },
49
+ });
50
+
51
+ // ----------------------------------------------------------------- command
52
+ pi.registerCommand("codewalker", {
53
+ description:
54
+ "codewalker: scan | sync | query <text> | help\n" +
55
+ " scan Full (re)build of the code index\n" +
56
+ " sync Git-anchored incremental update\n" +
57
+ " query <text> Search the index for symbols\n" +
58
+ " help Show this help",
59
+ handler: async (args, ctx) => {
60
+ const tokens = (args ?? "").trim().split(/\s+/).filter(Boolean);
61
+ const sub = tokens[0] ?? "help";
62
+ const notify = (msg: string, level: "info" | "error" = "info") => {
63
+ if ((ctx as any).hasUI) (ctx as any).ui.notify(msg, level);
64
+ else console.log(msg);
65
+ };
66
+
67
+ try {
68
+ const project = await ensureProject(ctx.cwd);
69
+
70
+ switch (sub) {
71
+ case "scan": {
72
+ notify("Starting codewalker full scan…");
73
+ await scan({
74
+ projectRoot: project.root,
75
+ globalCodewalkerDir: project.codewalkerDir,
76
+ dbPath: project.dbPath,
77
+ entriesDir: project.entriesDir,
78
+ symbolsDir: project.symbolsDir,
79
+ });
80
+ notify("Codewalker scan complete.");
81
+ break;
82
+ }
83
+
84
+ case "sync": {
85
+ notify("Starting codewalker incremental sync…");
86
+ await sync({
87
+ projectRoot: project.root,
88
+ globalCodewalkerDir: project.codewalkerDir,
89
+ dbPath: project.dbPath,
90
+ entriesDir: project.entriesDir,
91
+ symbolsDir: project.symbolsDir,
92
+ });
93
+ notify("Codewalker sync complete.");
94
+ break;
95
+ }
96
+
97
+ case "query": {
98
+ const q = tokens.slice(1).join(" ");
99
+ if (!q) {
100
+ notify("Usage: /codewalker query <text>", "error");
101
+ return;
102
+ }
103
+ const { rows, staleness } = runQuery(project.dbPath, { query: q, limit: 10 }, project.root);
104
+ const text = formatCompact(rows, staleness);
105
+ notify(text);
106
+ break;
107
+ }
108
+
109
+ default: {
110
+ notify(
111
+ "codewalker: scan | sync | query <text> | help\n" +
112
+ " scan Full (re)build of the code index\n" +
113
+ " sync Git-anchored incremental update\n" +
114
+ " query <text> Search the index for symbols\n" +
115
+ " help Show this help",
116
+ );
117
+ }
118
+ }
119
+ } catch (e: any) {
120
+ notify(`codewalker error: ${e?.message ?? String(e)}`, "error");
121
+ }
122
+ },
123
+ });
124
+ }
@@ -0,0 +1,138 @@
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 { execSync } from 'node:child_process';
6
+ import { scan, rebuildDbFromCards } from './indexer.ts';
7
+ import { openDb, getMeta, searchSymbols } from './db.ts';
8
+
9
+ describe('indexer.ts', () => {
10
+ let tmpDir: string;
11
+ let globalDir: string;
12
+ let cardsDir: string;
13
+ let symbolsDir: string;
14
+ let dbPath: string;
15
+
16
+ beforeEach(() => {
17
+ tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-indexer-'));
18
+ globalDir = path.join(tmpDir, '.pi-global', 'projects', 'test-project', 'codewalker');
19
+ cardsDir = path.join(globalDir, 'entries');
20
+ symbolsDir = path.join(cardsDir, 'symbols');
21
+ dbPath = path.join(globalDir, 'index.db');
22
+ fs.mkdirSync(symbolsDir, { recursive: true });
23
+ });
24
+
25
+ afterEach(() => {
26
+ fs.rmSync(tmpDir, { recursive: true, force: true });
27
+ });
28
+
29
+ function writeFixture(name: string, content: string): string {
30
+ const p = path.join(tmpDir, name);
31
+ fs.mkdirSync(path.dirname(p), { recursive: true });
32
+ fs.writeFileSync(p, content, 'utf-8');
33
+ return p;
34
+ }
35
+
36
+ it('scan indexes TS files using regex fallback when ctags is unavailable', async () => {
37
+ // Write fixture files
38
+ writeFixture('src/hello.ts', 'export function hello(name: string): string { return "hi"; }');
39
+ writeFixture('src/math.ts', 'export const PI = 3.14;\nfunction internal() {}');
40
+
41
+ await scan({
42
+ projectRoot: tmpDir,
43
+ globalCodewalkerDir: globalDir,
44
+ dbPath,
45
+ entriesDir: cardsDir,
46
+ symbolsDir,
47
+ useCtags: false, // force regex fallback
48
+ });
49
+
50
+ // Check DB was populated
51
+ const db = openDb(dbPath);
52
+ expect(getMeta(db, 'last_full_scan')).toBeTruthy();
53
+
54
+ // Check symbols are findable
55
+ const hello = searchSymbols(db, 'hello', undefined, 10);
56
+ expect(hello.length).toBeGreaterThanOrEqual(1);
57
+ expect(hello.some(s => s.name === 'hello')).toBe(true);
58
+
59
+ // Check cards were written
60
+ const cardFiles = fs.readdirSync(symbolsDir, { recursive: true }).filter(f => String(f).endsWith('.md'));
61
+ expect(cardFiles.length).toBeGreaterThan(0);
62
+
63
+ db.close();
64
+ });
65
+
66
+ it('scan is idempotent — running twice produces no duplicates', async () => {
67
+ writeFixture('src/foo.ts', 'function foo() {}');
68
+
69
+ await scan({
70
+ projectRoot: tmpDir,
71
+ globalCodewalkerDir: globalDir,
72
+ dbPath,
73
+ entriesDir: cardsDir,
74
+ symbolsDir,
75
+ useCtags: false,
76
+ });
77
+
78
+ await scan({
79
+ projectRoot: tmpDir,
80
+ globalCodewalkerDir: globalDir,
81
+ dbPath,
82
+ entriesDir: cardsDir,
83
+ symbolsDir,
84
+ useCtags: false,
85
+ });
86
+
87
+ const db = openDb(dbPath);
88
+ const results = searchSymbols(db, '', undefined, 100);
89
+ const fooCount = results.filter(s => s.name === 'foo').length;
90
+ expect(fooCount).toBe(1);
91
+ db.close();
92
+ });
93
+
94
+ it('rebuildDbFromCards reproduces the DB from cards alone', async () => {
95
+ writeFixture('src/bar.ts', 'function bar() {}');
96
+
97
+ await scan({
98
+ projectRoot: tmpDir,
99
+ globalCodewalkerDir: globalDir,
100
+ dbPath,
101
+ entriesDir: cardsDir,
102
+ symbolsDir,
103
+ useCtags: false,
104
+ });
105
+
106
+ // Delete the DB
107
+ fs.rmSync(dbPath);
108
+ if (fs.existsSync(dbPath + '-wal')) fs.rmSync(dbPath + '-wal');
109
+
110
+ // Rebuild from cards
111
+ rebuildDbFromCards(dbPath, cardsDir);
112
+
113
+ // Check it works
114
+ const db = openDb(dbPath);
115
+ const bar = searchSymbols(db, 'bar', undefined, 10);
116
+ expect(bar.length).toBeGreaterThanOrEqual(1);
117
+ db.close();
118
+ });
119
+
120
+ it('scan with ctags absent still indexes TS/JS via regex', async () => {
121
+ writeFixture('src/test.ts', 'type Result = string;\ninterface Props { x: number; }');
122
+ writeFixture('src/util.js', 'function helper() { return 1; }');
123
+
124
+ await scan({
125
+ projectRoot: tmpDir,
126
+ globalCodewalkerDir: globalDir,
127
+ dbPath,
128
+ entriesDir: cardsDir,
129
+ symbolsDir,
130
+ useCtags: false,
131
+ });
132
+
133
+ const db = openDb(dbPath);
134
+ const all = searchSymbols(db, '', undefined, 100);
135
+ expect(all.length).toBeGreaterThanOrEqual(3);
136
+ db.close();
137
+ });
138
+ });