@aprimediet/codewalker 1.1.0 → 1.3.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 +28 -3
- package/package.json +1 -1
- package/prompts/codewalker.md +3 -1
- package/skills/codewalker/SKILL.md +118 -28
- package/src/cards.test.ts +123 -1
- package/src/cards.ts +53 -0
- package/src/db.test.ts +405 -3
- package/src/db.ts +402 -29
- package/src/enrich.test.ts +102 -0
- package/src/enrich.ts +107 -0
- package/src/format.test.ts +103 -0
- package/src/format.ts +11 -0
- package/src/index.contract.test.ts +77 -1
- package/src/index.ts +273 -19
- package/src/indexer.heal.test.ts +90 -0
- package/src/indexer.ts +9 -1
- package/src/libs/cards.test.ts +86 -0
- package/src/libs/cards.ts +53 -0
- package/src/libs/dts.test.ts +269 -0
- package/src/libs/dts.ts +213 -0
- package/src/libs/indexer.test.ts +236 -0
- package/src/libs/indexer.ts +291 -0
- package/src/libs/resolve.test.ts +218 -0
- package/src/libs/resolve.ts +120 -0
- package/src/notes-cards.test.ts +99 -0
- package/src/notes-cards.ts +92 -0
- package/src/notes.test.ts +172 -0
- package/src/notes.ts +145 -0
- package/src/project.test.ts +12 -1
- package/src/project.ts +7 -1
- package/src/query.test.ts +148 -1
- package/src/query.ts +28 -8
- package/src/types.ts +44 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for libs/resolve.ts — library dependency discovery.
|
|
3
|
+
*
|
|
4
|
+
* Covers:
|
|
5
|
+
* - PURE parseDependencies (deps / deps+devDeps)
|
|
6
|
+
* - PURE resolveTypesEntry (types → typings → index.d.ts → main)
|
|
7
|
+
* - Integration locateLibrary over a fixture node_modules
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
11
|
+
import * as fs from "node:fs";
|
|
12
|
+
import * as path from "node:path";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import { parseDependencies, resolveTypesEntry, locateLibrary } from "./resolve.ts";
|
|
15
|
+
|
|
16
|
+
// ── PURE: parseDependencies ────────────────────────────────────
|
|
17
|
+
describe("parseDependencies", () => {
|
|
18
|
+
it("returns names from `dependencies`", () => {
|
|
19
|
+
const pkg = { dependencies: { express: "^4.0.0", lodash: "^4.17.0" } };
|
|
20
|
+
const result = parseDependencies(pkg);
|
|
21
|
+
expect(result).toEqual(["express", "lodash"]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("returns empty array when no dependencies", () => {
|
|
25
|
+
expect(parseDependencies({})).toEqual([]);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("ignores devDependencies by default", () => {
|
|
29
|
+
const pkg = {
|
|
30
|
+
dependencies: { express: "^4.0.0" },
|
|
31
|
+
devDependencies: { vitest: "^1.0.0" },
|
|
32
|
+
};
|
|
33
|
+
expect(parseDependencies(pkg)).toEqual(["express"]);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("includes devDependencies when includeDev=true", () => {
|
|
37
|
+
const pkg = {
|
|
38
|
+
dependencies: { express: "^4.0.0" },
|
|
39
|
+
devDependencies: { vitest: "^1.0.0", typescript: "^5.0.0" },
|
|
40
|
+
};
|
|
41
|
+
const result = parseDependencies(pkg, true);
|
|
42
|
+
expect(result).toContain("express");
|
|
43
|
+
expect(result).toContain("vitest");
|
|
44
|
+
expect(result).toContain("typescript");
|
|
45
|
+
expect(result).toHaveLength(3);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("ignores peerDependencies and optionalDependencies", () => {
|
|
49
|
+
const pkg = {
|
|
50
|
+
dependencies: { express: "^4.0.0" },
|
|
51
|
+
peerDependencies: { react: "^18.0.0" },
|
|
52
|
+
optionalDependencies: { fsevents: "^2.0.0" },
|
|
53
|
+
};
|
|
54
|
+
expect(parseDependencies(pkg)).toEqual(["express"]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns empty array for null/undefined input", () => {
|
|
58
|
+
expect(parseDependencies(null as any)).toEqual([]);
|
|
59
|
+
expect(parseDependencies(undefined as any)).toEqual([]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── PURE: resolveTypesEntry ────────────────────────────────────
|
|
64
|
+
describe("resolveTypesEntry", () => {
|
|
65
|
+
it("prefers `types` field", () => {
|
|
66
|
+
const pkg = { types: "dist/index.d.ts", typings: "lib/index.d.ts" };
|
|
67
|
+
expect(resolveTypesEntry(pkg)).toBe("dist/index.d.ts");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("falls back to `typings` field", () => {
|
|
71
|
+
const pkg = { typings: "lib/index.d.ts" };
|
|
72
|
+
expect(resolveTypesEntry(pkg)).toBe("lib/index.d.ts");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("falls back to `index.d.ts`", () => {
|
|
76
|
+
const pkg = {};
|
|
77
|
+
expect(resolveTypesEntry(pkg)).toBe("index.d.ts");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("derives from `main` by swapping .js for .d.ts", () => {
|
|
81
|
+
const pkg = { main: "dist/main.js" };
|
|
82
|
+
expect(resolveTypesEntry(pkg)).toBe("dist/main.d.ts");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("handles main with no extension by appending .d.ts", () => {
|
|
86
|
+
const pkg = { main: "dist/main" };
|
|
87
|
+
expect(resolveTypesEntry(pkg)).toBe("dist/main.d.ts");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("returns index.d.ts when main is not present", () => {
|
|
91
|
+
const pkg = {};
|
|
92
|
+
expect(resolveTypesEntry(pkg)).toBe("index.d.ts");
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// ── Integration: locateLibrary ─────────────────────────────────
|
|
97
|
+
describe("locateLibrary", () => {
|
|
98
|
+
let tmpDir: string;
|
|
99
|
+
let projectRoot: string;
|
|
100
|
+
let nodeModulesDir: string;
|
|
101
|
+
|
|
102
|
+
beforeEach(() => {
|
|
103
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-resolve-"));
|
|
104
|
+
projectRoot = path.join(tmpDir, "my-project");
|
|
105
|
+
nodeModulesDir = path.join(projectRoot, "node_modules");
|
|
106
|
+
fs.mkdirSync(nodeModulesDir, { recursive: true });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
afterEach(() => {
|
|
110
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns version, dtsPath, and readmePath for a typed package", () => {
|
|
114
|
+
// Create a typed package
|
|
115
|
+
const pkgDir = path.join(nodeModulesDir, "typed-pkg");
|
|
116
|
+
fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
|
|
117
|
+
|
|
118
|
+
fs.writeFileSync(
|
|
119
|
+
path.join(pkgDir, "package.json"),
|
|
120
|
+
JSON.stringify({
|
|
121
|
+
name: "typed-pkg",
|
|
122
|
+
version: "2.1.0",
|
|
123
|
+
types: "dist/index.d.ts",
|
|
124
|
+
main: "dist/index.js",
|
|
125
|
+
}),
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
fs.writeFileSync(
|
|
129
|
+
path.join(pkgDir, "dist", "index.d.ts"),
|
|
130
|
+
"export declare function hello(): void;\n",
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
fs.writeFileSync(
|
|
134
|
+
path.join(pkgDir, "README.md"),
|
|
135
|
+
"# typed-pkg\nA typed package.\n",
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
const result = locateLibrary(projectRoot, "typed-pkg");
|
|
139
|
+
expect(result).not.toBeNull();
|
|
140
|
+
expect(result!.version).toBe("2.1.0");
|
|
141
|
+
expect(result!.dtsPath).toBe(path.join(pkgDir, "dist", "index.d.ts"));
|
|
142
|
+
expect(result!.readmePath).toBe(path.join(pkgDir, "README.md"));
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("returns null for a non-existent package", () => {
|
|
146
|
+
const result = locateLibrary(projectRoot, "non-existent-pkg");
|
|
147
|
+
expect(result).toBeNull();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("returns null when node_modules does not exist", () => {
|
|
151
|
+
const noNodeModules = path.join(tmpDir, "empty-project");
|
|
152
|
+
fs.mkdirSync(noNodeModules);
|
|
153
|
+
const result = locateLibrary(noNodeModules, "anything");
|
|
154
|
+
expect(result).toBeNull();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("returns dtsPath=null for a package with no .d.ts file", () => {
|
|
158
|
+
// JS-only package, no types
|
|
159
|
+
const pkgDir = path.join(nodeModulesDir, "js-only");
|
|
160
|
+
fs.mkdirSync(pkgDir);
|
|
161
|
+
|
|
162
|
+
fs.writeFileSync(
|
|
163
|
+
path.join(pkgDir, "package.json"),
|
|
164
|
+
JSON.stringify({
|
|
165
|
+
name: "js-only",
|
|
166
|
+
version: "0.5.0",
|
|
167
|
+
main: "index.js",
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
// Create the JS file but no .d.ts
|
|
172
|
+
fs.writeFileSync(path.join(pkgDir, "index.js"), "module.exports = {};\n");
|
|
173
|
+
|
|
174
|
+
// Also no README
|
|
175
|
+
const result = locateLibrary(projectRoot, "js-only");
|
|
176
|
+
expect(result).not.toBeNull();
|
|
177
|
+
expect(result!.version).toBe("0.5.0");
|
|
178
|
+
expect(result!.dtsPath).toBeNull();
|
|
179
|
+
expect(result!.readmePath).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("finds README.md case-insensitively (README.md or readme.md)", () => {
|
|
183
|
+
const pkgDir = path.join(nodeModulesDir, "readme-case");
|
|
184
|
+
fs.mkdirSync(path.join(pkgDir, "dist"), { recursive: true });
|
|
185
|
+
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
path.join(pkgDir, "package.json"),
|
|
188
|
+
JSON.stringify({ name: "readme-case", version: "1.0.0" }),
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Only readme.md (lowercase)
|
|
192
|
+
fs.writeFileSync(path.join(pkgDir, "readme.md"), "# Lowercase readme\n");
|
|
193
|
+
|
|
194
|
+
// Need index.d.ts so it doesn't return null
|
|
195
|
+
fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export const foo: number;\n");
|
|
196
|
+
|
|
197
|
+
const result = locateLibrary(projectRoot, "readme-case");
|
|
198
|
+
expect(result).not.toBeNull();
|
|
199
|
+
expect(result!.readmePath).toBe(path.join(pkgDir, "readme.md"));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("resolves package.json even without types field (uses index.d.ts fallback)", () => {
|
|
203
|
+
const pkgDir = path.join(nodeModulesDir, "no-types-field");
|
|
204
|
+
fs.mkdirSync(pkgDir);
|
|
205
|
+
|
|
206
|
+
fs.writeFileSync(
|
|
207
|
+
path.join(pkgDir, "package.json"),
|
|
208
|
+
JSON.stringify({ name: "no-types-field", version: "3.0.0" }),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
fs.writeFileSync(path.join(pkgDir, "index.d.ts"), "export const bar: boolean;\n");
|
|
212
|
+
|
|
213
|
+
const result = locateLibrary(projectRoot, "no-types-field");
|
|
214
|
+
expect(result).not.toBeNull();
|
|
215
|
+
expect(result!.version).toBe("3.0.0");
|
|
216
|
+
expect(result!.dtsPath).toBe(path.join(pkgDir, "index.d.ts"));
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library dependency discovery for codewalker v1.2.
|
|
3
|
+
*
|
|
4
|
+
* - `parseDependencies(pkgJson, includeDev?)`: PURE — extract dep names from package.json.
|
|
5
|
+
* - `resolveTypesEntry(pkgJson)`: PURE — find the .d.ts entry point.
|
|
6
|
+
* - `locateLibrary(projectRoot, name)`: integration — read the installed package info.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface LocateResult {
|
|
13
|
+
version: string;
|
|
14
|
+
dtsPath: string | null;
|
|
15
|
+
readmePath: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Extract dependency names from a package.json object.
|
|
20
|
+
* By default returns only `dependencies`; set `includeDev=true` to add `devDependencies`.
|
|
21
|
+
* Ignores peerDependencies and optionalDependencies.
|
|
22
|
+
* PURE — no I/O.
|
|
23
|
+
*/
|
|
24
|
+
export function parseDependencies(
|
|
25
|
+
pkgJson: Record<string, any> | null | undefined,
|
|
26
|
+
includeDev = false,
|
|
27
|
+
): string[] {
|
|
28
|
+
if (!pkgJson) return [];
|
|
29
|
+
|
|
30
|
+
const deps: string[] = [];
|
|
31
|
+
|
|
32
|
+
if (pkgJson.dependencies) {
|
|
33
|
+
deps.push(...Object.keys(pkgJson.dependencies));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (includeDev && pkgJson.devDependencies) {
|
|
37
|
+
deps.push(...Object.keys(pkgJson.devDependencies));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return deps;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the `.d.ts` entry point for a package.
|
|
45
|
+
* Priority: `types` → `typings` → `index.d.ts` → derive from `main` (swap .js for .d.ts).
|
|
46
|
+
* Returns a relative path string.
|
|
47
|
+
* PURE — no I/O.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveTypesEntry(
|
|
50
|
+
pkgJson: Record<string, any> | null | undefined,
|
|
51
|
+
): string {
|
|
52
|
+
if (!pkgJson) return "index.d.ts";
|
|
53
|
+
|
|
54
|
+
if (pkgJson.types) return pkgJson.types;
|
|
55
|
+
if (pkgJson.typings) return pkgJson.typings;
|
|
56
|
+
|
|
57
|
+
// Derive from `main` if present
|
|
58
|
+
if (pkgJson.main) {
|
|
59
|
+
const main = pkgJson.main as string;
|
|
60
|
+
// Swap .js|.mjs|.cjs endings for .d.ts; otherwise append .d.ts
|
|
61
|
+
if (/\.(js|mjs|cjs)$/.test(main)) {
|
|
62
|
+
return main.replace(/\.(js|mjs|cjs)$/, ".d.ts");
|
|
63
|
+
}
|
|
64
|
+
return main + ".d.ts";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return "index.d.ts";
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Locate an installed library in `node_modules/<name>`.
|
|
72
|
+
* Returns null if the package or its directory does not exist.
|
|
73
|
+
* Integration — reads the filesystem.
|
|
74
|
+
*/
|
|
75
|
+
export function locateLibrary(
|
|
76
|
+
projectRoot: string,
|
|
77
|
+
name: string,
|
|
78
|
+
): LocateResult | null {
|
|
79
|
+
const nmDir = path.join(projectRoot, "node_modules");
|
|
80
|
+
if (!fs.existsSync(nmDir)) return null;
|
|
81
|
+
|
|
82
|
+
const pkgDir = path.join(nmDir, name);
|
|
83
|
+
const pkgJsonPath = path.join(pkgDir, "package.json");
|
|
84
|
+
|
|
85
|
+
if (!fs.existsSync(pkgJsonPath)) return null;
|
|
86
|
+
|
|
87
|
+
let pkgJson: Record<string, any>;
|
|
88
|
+
try {
|
|
89
|
+
pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const version = pkgJson.version ?? "unknown";
|
|
95
|
+
|
|
96
|
+
// Resolve .d.ts path
|
|
97
|
+
const typesRel = resolveTypesEntry(pkgJson);
|
|
98
|
+
let dtsPath: string | null = path.join(pkgDir, typesRel);
|
|
99
|
+
if (!fs.existsSync(dtsPath)) {
|
|
100
|
+
// Try common alternative locations
|
|
101
|
+
const altDts = path.join(pkgDir, "index.d.ts");
|
|
102
|
+
if (fs.existsSync(altDts)) {
|
|
103
|
+
dtsPath = altDts;
|
|
104
|
+
} else {
|
|
105
|
+
dtsPath = null;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Find README (case-insensitive)
|
|
110
|
+
let readmePath: string | null = null;
|
|
111
|
+
for (const name of ["README.md", "readme.md", "Readme.md"]) {
|
|
112
|
+
const candidate = path.join(pkgDir, name);
|
|
113
|
+
if (fs.existsSync(candidate)) {
|
|
114
|
+
readmePath = candidate;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { version, dtsPath, readmePath };
|
|
120
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { renderNoteCard, parseNoteCard } from './notes-cards.ts';
|
|
3
|
+
import type { Note } from './types.ts';
|
|
4
|
+
|
|
5
|
+
function makeNote(overrides: Partial<Note> = {}): Note {
|
|
6
|
+
return {
|
|
7
|
+
note_kind: 'glossary',
|
|
8
|
+
title: 'Idempotency Key',
|
|
9
|
+
body: 'A client-supplied key that makes a retried POST safe to replay.',
|
|
10
|
+
tags: 'api, payments',
|
|
11
|
+
related: 'createCharge, charge.ts:88-140',
|
|
12
|
+
card_path: '',
|
|
13
|
+
...overrides,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('renderNoteCard', () => {
|
|
18
|
+
it('renders a glossary note with frontmatter head', () => {
|
|
19
|
+
const note = makeNote();
|
|
20
|
+
const md = renderNoteCard(note);
|
|
21
|
+
|
|
22
|
+
expect(md).toContain('---');
|
|
23
|
+
expect(md).toContain('note_kind: glossary');
|
|
24
|
+
expect(md).toContain('title: Idempotency Key');
|
|
25
|
+
expect(md).toContain('tags: api, payments');
|
|
26
|
+
expect(md).toContain('related: createCharge, charge.ts:88-140');
|
|
27
|
+
expect(md).toContain('summary: A client-supplied key that makes a retried POST safe to replay.');
|
|
28
|
+
|
|
29
|
+
// Body
|
|
30
|
+
expect(md).toContain('# Idempotency Key');
|
|
31
|
+
expect(md).toContain(note.body);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('renders a decision note', () => {
|
|
35
|
+
const note = makeNote({
|
|
36
|
+
note_kind: 'decision',
|
|
37
|
+
title: 'Use SQLite over ChromaDB',
|
|
38
|
+
body: 'We chose SQLite+FTS5 because the agent can expand queries itself.',
|
|
39
|
+
tags: 'tech-decision, database',
|
|
40
|
+
related: 'docs/tech-decision.md',
|
|
41
|
+
});
|
|
42
|
+
const md = renderNoteCard(note);
|
|
43
|
+
expect(md).toContain('note_kind: decision');
|
|
44
|
+
expect(md).toContain('title: Use SQLite over ChromaDB');
|
|
45
|
+
expect(md).toContain('tags: tech-decision, database');
|
|
46
|
+
expect(md).toContain('related: docs/tech-decision.md');
|
|
47
|
+
expect(md).toContain('# Use SQLite over ChromaDB');
|
|
48
|
+
expect(md).toContain(note.body);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('sanitizes newlines in head fields', () => {
|
|
52
|
+
const note = makeNote({
|
|
53
|
+
title: 'Multi\nline',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const md = renderNoteCard(note);
|
|
57
|
+
// title should not have raw newlines in frontmatter
|
|
58
|
+
const fmMatch = md.match(/^title: (.+)$/m);
|
|
59
|
+
expect(fmMatch).not.toBeNull();
|
|
60
|
+
expect(fmMatch![1]!).not.toContain('\n');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('uses body first line as summary', () => {
|
|
64
|
+
const note = makeNote();
|
|
65
|
+
const md = renderNoteCard(note);
|
|
66
|
+
// The first line of body should appear as summary
|
|
67
|
+
expect(md).toContain('summary: A client-supplied key that makes a retried POST safe to replay.');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
describe('parseNoteCard', () => {
|
|
72
|
+
it('round-trips renderNoteCard -> parseNoteCard preserving head fields', () => {
|
|
73
|
+
const note = makeNote();
|
|
74
|
+
const md = renderNoteCard(note);
|
|
75
|
+
const parsed = parseNoteCard(md);
|
|
76
|
+
expect(parsed).not.toBeNull();
|
|
77
|
+
expect(parsed!.note_kind).toBe('glossary');
|
|
78
|
+
expect(parsed!.title).toBe('Idempotency Key');
|
|
79
|
+
expect(parsed!.tags).toBe('api, payments');
|
|
80
|
+
expect(parsed!.related).toBe('createCharge, charge.ts:88-140');
|
|
81
|
+
// summary from body first line
|
|
82
|
+
expect(parsed!.summary).toBeTruthy();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('returns null for invalid markdown', () => {
|
|
86
|
+
expect(parseNoteCard('no frontmatter')).toBeNull();
|
|
87
|
+
expect(parseNoteCard('')).toBeNull();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('returns null for card without note_kind in frontmatter', () => {
|
|
91
|
+
const md = `---
|
|
92
|
+
name: foo
|
|
93
|
+
kind: function
|
|
94
|
+
---
|
|
95
|
+
# foo
|
|
96
|
+
`;
|
|
97
|
+
expect(parseNoteCard(md)).toBeNull();
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Note (glossary/decision) card rendering and parsing for codewalker v1.3.
|
|
3
|
+
*
|
|
4
|
+
* PURE module — no I/O. Renders a Note into a markdown card
|
|
5
|
+
* with frontmatter head (note_kind, title, tags, related, summary) and body.
|
|
6
|
+
*
|
|
7
|
+
* Cards live at entries/glossary/<slug>.md and entries/decisions/<slug>.md.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { Note, NoteKind } from "./types.ts";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Render a Note (glossary term or decision) into a markdown card string.
|
|
14
|
+
*
|
|
15
|
+
* Frontmatter head includes note_kind, title, tags, related, summary.
|
|
16
|
+
* Body starts with `# <title>` and contains the full body text.
|
|
17
|
+
* Summary is the first line of the body.
|
|
18
|
+
*/
|
|
19
|
+
export function renderNoteCard(note: Note): string {
|
|
20
|
+
const lines: string[] = ["---"];
|
|
21
|
+
|
|
22
|
+
addNoteHeadField(lines, "note_kind", note.note_kind);
|
|
23
|
+
addNoteHeadField(lines, "title", note.title);
|
|
24
|
+
if (note.tags) addNoteHeadField(lines, "tags", note.tags);
|
|
25
|
+
if (note.related) addNoteHeadField(lines, "related", note.related);
|
|
26
|
+
|
|
27
|
+
// Summary = first line of body
|
|
28
|
+
const summary = note.body.split("\n")[0]?.trim() || note.title;
|
|
29
|
+
addNoteHeadField(lines, "summary", summary);
|
|
30
|
+
|
|
31
|
+
lines.push("---");
|
|
32
|
+
lines.push("");
|
|
33
|
+
|
|
34
|
+
// Body
|
|
35
|
+
lines.push(`# ${note.title}`);
|
|
36
|
+
lines.push("");
|
|
37
|
+
|
|
38
|
+
if (note.body) {
|
|
39
|
+
lines.push(note.body);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return lines.join("\n") + "\n";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse a note card from a markdown string.
|
|
47
|
+
* Returns null if the card is invalid or not a note card.
|
|
48
|
+
*/
|
|
49
|
+
export function parseNoteCard(text: string): {
|
|
50
|
+
note_kind: NoteKind;
|
|
51
|
+
title: string;
|
|
52
|
+
tags: string;
|
|
53
|
+
related: string;
|
|
54
|
+
summary: string;
|
|
55
|
+
} | null {
|
|
56
|
+
const trimmed = text.trim();
|
|
57
|
+
if (!trimmed.startsWith("---")) return null;
|
|
58
|
+
|
|
59
|
+
const endOfFm = trimmed.indexOf("\n---", 3);
|
|
60
|
+
if (endOfFm === -1) return null;
|
|
61
|
+
|
|
62
|
+
const fmRaw = trimmed.slice(3, endOfFm).trim();
|
|
63
|
+
|
|
64
|
+
// Parse frontmatter lines into a record
|
|
65
|
+
const fm: Record<string, string> = {};
|
|
66
|
+
for (const line of fmRaw.split("\n")) {
|
|
67
|
+
const sep = line.indexOf(":");
|
|
68
|
+
if (sep > 0) {
|
|
69
|
+
const key = line.slice(0, sep).trim();
|
|
70
|
+
const value = line.slice(sep + 1).trim();
|
|
71
|
+
fm[key] = value;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const noteKind = fm["note_kind"];
|
|
76
|
+
if (noteKind !== "glossary" && noteKind !== "decision") return null;
|
|
77
|
+
if (!fm["title"]) return null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
note_kind: noteKind as NoteKind,
|
|
81
|
+
title: fm["title"] ?? "",
|
|
82
|
+
tags: fm["tags"] ?? "",
|
|
83
|
+
related: fm["related"] ?? "",
|
|
84
|
+
summary: fm["summary"] ?? "",
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Add a key:value line to the frontmatter, sanitizing newlines. */
|
|
89
|
+
function addNoteHeadField(lines: string[], key: string, value: string): void {
|
|
90
|
+
const safe = value.replace(/\n/g, " ").trim();
|
|
91
|
+
lines.push(`${key}: ${safe}`);
|
|
92
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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 { openDb, searchNotes } from './db.ts';
|
|
6
|
+
import { addNote, rebuildNotesDbFromCards } from './notes.ts';
|
|
7
|
+
|
|
8
|
+
describe('notes.ts', () => {
|
|
9
|
+
let tmpDir: string;
|
|
10
|
+
let glossaryDir: string;
|
|
11
|
+
let decisionsDir: string;
|
|
12
|
+
let dbPath: string;
|
|
13
|
+
|
|
14
|
+
beforeEach(() => {
|
|
15
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'cw-notes-'));
|
|
16
|
+
glossaryDir = path.join(tmpDir, 'glossary');
|
|
17
|
+
decisionsDir = path.join(tmpDir, 'decisions');
|
|
18
|
+
dbPath = path.join(tmpDir, 'test.db');
|
|
19
|
+
fs.mkdirSync(glossaryDir, { recursive: true });
|
|
20
|
+
fs.mkdirSync(decisionsDir, { recursive: true });
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('addNote', () => {
|
|
28
|
+
it('writes a glossary card and inserts a DB row', () => {
|
|
29
|
+
addNote(dbPath, {
|
|
30
|
+
note_kind: 'glossary',
|
|
31
|
+
title: 'Idempotency Key',
|
|
32
|
+
body: 'A client-supplied key that makes retries safe.',
|
|
33
|
+
tags: 'api,payments',
|
|
34
|
+
related: 'createCharge',
|
|
35
|
+
card_path: '',
|
|
36
|
+
}, glossaryDir);
|
|
37
|
+
|
|
38
|
+
// Card file exists under glossary dir
|
|
39
|
+
const files = fs.readdirSync(glossaryDir);
|
|
40
|
+
expect(files.length).toBeGreaterThan(0);
|
|
41
|
+
const cardFile = files.find((f) => f.endsWith('.md'));
|
|
42
|
+
expect(cardFile).toBeDefined();
|
|
43
|
+
|
|
44
|
+
// Card content has frontmatter
|
|
45
|
+
const cardContent = fs.readFileSync(path.join(glossaryDir, cardFile!), 'utf-8');
|
|
46
|
+
expect(cardContent).toContain('note_kind: glossary');
|
|
47
|
+
expect(cardContent).toContain('title: Idempotency Key');
|
|
48
|
+
|
|
49
|
+
// DB row exists
|
|
50
|
+
const db = openDb(dbPath);
|
|
51
|
+
const results = searchNotes(db, 'Idempotency');
|
|
52
|
+
expect(results).toHaveLength(1);
|
|
53
|
+
expect(results[0]!.name).toBe('Idempotency Key');
|
|
54
|
+
expect(results[0]!.note_kind).toBe('glossary');
|
|
55
|
+
db.close();
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('writes a decision card under decisions dir', () => {
|
|
59
|
+
addNote(dbPath, {
|
|
60
|
+
note_kind: 'decision',
|
|
61
|
+
title: 'Use SQLite over ChromaDB',
|
|
62
|
+
body: 'Chosen for zero-infra approach.',
|
|
63
|
+
tags: 'tech-decision',
|
|
64
|
+
related: '',
|
|
65
|
+
card_path: '',
|
|
66
|
+
}, decisionsDir);
|
|
67
|
+
|
|
68
|
+
const files = fs.readdirSync(decisionsDir);
|
|
69
|
+
const cardFile = files.find((f) => f.endsWith('.md'));
|
|
70
|
+
expect(cardFile).toBeDefined();
|
|
71
|
+
|
|
72
|
+
const cardContent = fs.readFileSync(path.join(decisionsDir, cardFile!), 'utf-8');
|
|
73
|
+
expect(cardContent).toContain('note_kind: decision');
|
|
74
|
+
expect(cardContent).toContain('title: Use SQLite over ChromaDB');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('is idempotent — re-adding same note updates in place', () => {
|
|
78
|
+
addNote(dbPath, {
|
|
79
|
+
note_kind: 'glossary', title: 'Term',
|
|
80
|
+
body: 'Version 1', tags: '', related: '',
|
|
81
|
+
card_path: '',
|
|
82
|
+
}, glossaryDir);
|
|
83
|
+
|
|
84
|
+
addNote(dbPath, {
|
|
85
|
+
note_kind: 'glossary', title: 'Term',
|
|
86
|
+
body: 'Version 2 updated', tags: 'v2', related: '',
|
|
87
|
+
card_path: '',
|
|
88
|
+
}, glossaryDir);
|
|
89
|
+
|
|
90
|
+
// Single card file
|
|
91
|
+
const files = fs.readdirSync(glossaryDir).filter((f) => f.endsWith('.md'));
|
|
92
|
+
expect(files).toHaveLength(1);
|
|
93
|
+
|
|
94
|
+
// DB has single row with updated body
|
|
95
|
+
const db = openDb(dbPath);
|
|
96
|
+
const results = searchNotes(db, '');
|
|
97
|
+
expect(results).toHaveLength(1);
|
|
98
|
+
expect(results[0]!.summary).toContain('updated');
|
|
99
|
+
db.close();
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('rebuildNotesDbFromCards', () => {
|
|
104
|
+
it('reconstructs DB rows from glossary + decisions cards alone', () => {
|
|
105
|
+
// Add two notes
|
|
106
|
+
addNote(dbPath, {
|
|
107
|
+
note_kind: 'glossary', title: 'Cache Stampede',
|
|
108
|
+
body: 'When many requests miss cache simultaneously.', tags: 'cache',
|
|
109
|
+
related: 'getOrCreate',
|
|
110
|
+
card_path: '',
|
|
111
|
+
}, glossaryDir);
|
|
112
|
+
addNote(dbPath, {
|
|
113
|
+
note_kind: 'decision', title: 'Use triggers for FTS',
|
|
114
|
+
body: 'FTS triggers prevent index corruption.', tags: 'sqlite',
|
|
115
|
+
related: 'db.ts',
|
|
116
|
+
card_path: '',
|
|
117
|
+
}, decisionsDir);
|
|
118
|
+
|
|
119
|
+
// Delete the DB to simulate disposable-index property
|
|
120
|
+
fs.rmSync(dbPath, { force: true });
|
|
121
|
+
|
|
122
|
+
// Rebuild from cards
|
|
123
|
+
rebuildNotesDbFromCards(dbPath, glossaryDir, decisionsDir);
|
|
124
|
+
|
|
125
|
+
// Query should find both
|
|
126
|
+
const db = openDb(dbPath);
|
|
127
|
+
const results = searchNotes(db, '');
|
|
128
|
+
expect(results).toHaveLength(2);
|
|
129
|
+
|
|
130
|
+
const glossaryHits = results.filter((r) => r.note_kind === 'glossary');
|
|
131
|
+
const decisionHits = results.filter((r) => r.note_kind === 'decision');
|
|
132
|
+
expect(glossaryHits).toHaveLength(1);
|
|
133
|
+
expect(decisionHits).toHaveLength(1);
|
|
134
|
+
expect(glossaryHits[0]!.name).toBe('Cache Stampede');
|
|
135
|
+
expect(decisionHits[0]!.name).toBe('Use triggers for FTS');
|
|
136
|
+
|
|
137
|
+
db.close();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('handles empty directories gracefully', () => {
|
|
141
|
+
// No cards added — should not throw
|
|
142
|
+
fs.rmSync(dbPath, { force: true });
|
|
143
|
+
rebuildNotesDbFromCards(dbPath, glossaryDir, decisionsDir);
|
|
144
|
+
const db = openDb(dbPath);
|
|
145
|
+
const results = searchNotes(db, '');
|
|
146
|
+
expect(results).toHaveLength(0);
|
|
147
|
+
db.close();
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('preserves card_path in rebuilt rows', () => {
|
|
151
|
+
addNote(dbPath, {
|
|
152
|
+
note_kind: 'glossary', title: 'Idempotency Key',
|
|
153
|
+
body: 'Retry-safe POST key.', tags: '', related: '',
|
|
154
|
+
card_path: '',
|
|
155
|
+
}, glossaryDir);
|
|
156
|
+
|
|
157
|
+
// Get the card path
|
|
158
|
+
const files = fs.readdirSync(glossaryDir).filter((f) => f.endsWith('.md'));
|
|
159
|
+
const cardPath = path.join(glossaryDir, files[0]!);
|
|
160
|
+
|
|
161
|
+
fs.rmSync(dbPath, { force: true });
|
|
162
|
+
rebuildNotesDbFromCards(dbPath, glossaryDir, decisionsDir);
|
|
163
|
+
|
|
164
|
+
const db = openDb(dbPath);
|
|
165
|
+
const row = db.prepare("SELECT card_path FROM notes WHERE title = ?").get('Idempotency Key') as any;
|
|
166
|
+
expect(row).not.toBeUndefined();
|
|
167
|
+
// card_path might be absolute or relative depending on how we stored it
|
|
168
|
+
expect(row.card_path).toBeTruthy();
|
|
169
|
+
db.close();
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
});
|