@gitdocket/core 0.0.0 → 0.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/package.json CHANGED
@@ -1,18 +1,39 @@
1
1
  {
2
2
  "name": "@gitdocket/core",
3
- "version": "0.0.0",
4
- "description": "Bootstrap reservation for GitDocket core; stable releases begin at 0.1.0.",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "The typed file-and-graph engine behind GitDocket.",
5
6
  "license": "Apache-2.0",
6
7
  "repository": {
7
8
  "type": "git",
8
9
  "url": "git+https://github.com/GitDocket/gitdocket.git",
9
10
  "directory": "packages/core"
10
11
  },
11
- "homepage": "https://github.com/GitDocket/gitdocket#readme",
12
- "files": ["README.md"],
12
+ "homepage": "https://gitdocket.com",
13
+ "engines": {
14
+ "bun": ">=1.3.14"
15
+ },
16
+ "exports": {
17
+ ".": "./src/index.ts",
18
+ "./cache": "./src/cache.ts",
19
+ "./orientation": "./src/orientation.ts",
20
+ "./overview": "./src/overview.ts"
21
+ },
22
+ "files": [
23
+ "src/**/*.ts",
24
+ "src/**/*.json",
25
+ "!src/**/*.test.ts"
26
+ ],
13
27
  "publishConfig": {
14
28
  "access": "public",
15
- "registry": "https://registry.npmjs.org/",
16
- "tag": "bootstrap"
29
+ "registry": "https://registry.npmjs.org/"
30
+ },
31
+ "dependencies": {
32
+ "remark-frontmatter": "^5.0.0",
33
+ "remark-parse": "^11.0.0",
34
+ "unified": "^11.0.5",
35
+ "unist-util-visit": "^5.1.0",
36
+ "yaml": "^2.9.0",
37
+ "zod": "^4.5.4"
17
38
  }
18
39
  }
package/src/bundle.ts ADDED
@@ -0,0 +1,127 @@
1
+ // Bundle loading: FileStore → parsed concept graph with ID resolution
2
+ // (aliases included), duplicate detection, and derived readiness.
3
+
4
+ import { readFile } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { CONFIG_FILENAME, type DocketConfig, parseConfig } from "./config";
7
+ import { type FileStore, LocalFileStore } from "./filestore";
8
+ import {
9
+ type Concept,
10
+ type Decision,
11
+ type Diagnostic,
12
+ parseConcept,
13
+ type WorkItem,
14
+ } from "./parse";
15
+ import { buildSchemas } from "./schema";
16
+ import { byManualOrder, isReady, isStatus, type Status } from "./states";
17
+
18
+ export interface Bundle {
19
+ config: DocketConfig;
20
+ concepts: Concept[];
21
+ workItems: WorkItem[];
22
+ decisions: Decision[];
23
+ diagnostics: Diagnostic[];
24
+ /** Resolve a work item or decision by id — aliases included. */
25
+ byId(id: string): WorkItem | Decision | undefined;
26
+ statusById: ReadonlyMap<string, Status>;
27
+ /** Tasks that are `todo` with every dependency `done`. Derived, never stored. */
28
+ readyIds(): string[];
29
+ }
30
+
31
+ /**
32
+ * The canonical ready queue used by every surface, including bare
33
+ * `docket task start`. Readiness comes from the bundle; manual rank and
34
+ * priority supply the user-controlled order, with task ID as the stable
35
+ * fallback.
36
+ */
37
+ export function readyWorkItems(bundle: Bundle): WorkItem[] {
38
+ return bundle
39
+ .readyIds()
40
+ .map((id) => bundle.byId(id))
41
+ .filter((item): item is WorkItem => item?.kind === "work")
42
+ .sort((a, z) => byManualOrder(a.fm, z.fm));
43
+ }
44
+
45
+ export async function loadBundle(
46
+ store: FileStore,
47
+ config: DocketConfig,
48
+ ): Promise<Bundle> {
49
+ const schemas = buildSchemas(config);
50
+ const concepts: Concept[] = [];
51
+ const diagnostics: Diagnostic[] = [];
52
+
53
+ for (const path of await store.list()) {
54
+ const parsed = parseConcept(path, await store.read(path), schemas);
55
+ diagnostics.push(...parsed.diagnostics);
56
+ if (parsed.concept) concepts.push(parsed.concept);
57
+ }
58
+
59
+ const workItems = concepts.filter((c): c is WorkItem => c.kind === "work");
60
+ const decisions = concepts.filter(
61
+ (c): c is Decision => c.kind === "decision",
62
+ );
63
+
64
+ const index = new Map<string, WorkItem | Decision>();
65
+ for (const item of [...workItems, ...decisions]) {
66
+ for (const id of [item.fm.id, ...item.fm.aliases]) {
67
+ const existing = index.get(id);
68
+ if (existing) {
69
+ diagnostics.push({
70
+ path: item.path,
71
+ message: `duplicate id ${id} (also in ${existing.path})`,
72
+ severity: "error",
73
+ });
74
+ } else {
75
+ index.set(id, item);
76
+ }
77
+ }
78
+ }
79
+
80
+ const statusById = new Map<string, Status>();
81
+ for (const item of workItems) {
82
+ if (isStatus(item.fm.status)) {
83
+ for (const id of [item.fm.id, ...item.fm.aliases])
84
+ statusById.set(id, item.fm.status);
85
+ }
86
+ }
87
+
88
+ return {
89
+ config,
90
+ concepts,
91
+ workItems,
92
+ decisions,
93
+ diagnostics,
94
+ byId: (id) => index.get(id),
95
+ statusById,
96
+ readyIds: () =>
97
+ workItems
98
+ .filter((w) => w.fm.type === "Task")
99
+ .filter((w) => isReady(w.fm.status, w.fm.depends_on, statusById))
100
+ .map((w) => w.fm.id),
101
+ };
102
+ }
103
+
104
+ /** Walk upward from `start` to the nearest directory containing docket.yaml. */
105
+ export async function findRepoRoot(start: string): Promise<string | undefined> {
106
+ let dir = start;
107
+ for (;;) {
108
+ const found = await readFile(join(dir, CONFIG_FILENAME), "utf8").then(
109
+ () => true,
110
+ () => false,
111
+ );
112
+ if (found) return dir;
113
+ const parent = join(dir, "..");
114
+ if (parent === dir) return undefined;
115
+ dir = parent;
116
+ }
117
+ }
118
+
119
+ /** Convenience: load the bundle of a repo checkout from its docket.yaml. */
120
+ export async function loadRepo(repoRoot: string): Promise<Bundle> {
121
+ const configSource = await readFile(
122
+ join(repoRoot, CONFIG_FILENAME),
123
+ "utf8",
124
+ ).catch(() => undefined);
125
+ const config = parseConfig(configSource);
126
+ return loadBundle(new LocalFileStore(join(repoRoot, config.bundle)), config);
127
+ }
package/src/cache.ts ADDED
@@ -0,0 +1,268 @@
1
+ // SQLite cache: disposable, derived, gitignored — files stay the
2
+ // source of truth. bun:sqlite makes this module Bun-only, so it ships as the
3
+ // `@gitdocket/core/cache` subpath and the main entry stays runtime-portable.
4
+ // Callers with a repo (the CLI) pass in git-derived activity rows.
5
+
6
+ import type { Database } from "bun:sqlite";
7
+ import { execFileSync } from "node:child_process";
8
+ import { readFile } from "node:fs/promises";
9
+ import { join } from "node:path";
10
+ import { Glob } from "bun";
11
+ import type { Bundle } from "./bundle";
12
+ import type { DocketConfig } from "./config";
13
+ import { resolveLink } from "./lint";
14
+ import {
15
+ resolveVerifyMarkers,
16
+ scanVerifyMarkers,
17
+ type VerifyMarker,
18
+ } from "./verify";
19
+
20
+ export interface ActivityRow {
21
+ taskId: string;
22
+ sha: string;
23
+ date: string;
24
+ subject: string;
25
+ }
26
+
27
+ export interface GitCheckpoint {
28
+ revision: string;
29
+ time: string;
30
+ }
31
+
32
+ /** Current Git revision and commit time, or undefined outside usable history. */
33
+ export function gitCheckpoint(cwd: string): GitCheckpoint | undefined {
34
+ try {
35
+ const [revision, time] = execFileSync(
36
+ "git",
37
+ ["show", "-s", "--format=%H%x1f%cI", "HEAD"],
38
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
39
+ )
40
+ .trim()
41
+ .split("\x1f");
42
+ return revision && time ? { revision, time } : undefined;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Scan configured repo files for verification markers. This lives beside the
50
+ * cache because both `docket index` and `docket serve` populate the same
51
+ * disposable verification rows from it. No config means a fully dormant
52
+ * feature, and nothing here invokes a test runner.
53
+ */
54
+ export async function scanRepoMarkers(
55
+ root: string,
56
+ config: DocketConfig,
57
+ bundle: Bundle,
58
+ ): Promise<VerifyMarker[]> {
59
+ if (!config.verify) return [];
60
+ const seen = new Set<string>();
61
+ const markers: VerifyMarker[] = [];
62
+ for (const pattern of config.verify.tests) {
63
+ for await (const path of new Glob(pattern).scan({ cwd: root })) {
64
+ const posix = path.replaceAll("\\", "/");
65
+ if (posix.includes("node_modules/") || seen.has(posix)) continue;
66
+ seen.add(posix);
67
+ const content = await readFile(join(root, path), "utf8").catch(() => "");
68
+ markers.push(...scanVerifyMarkers(posix, content));
69
+ }
70
+ }
71
+ markers.sort((a, z) => a.source.localeCompare(z.source) || a.line - z.line);
72
+ return resolveVerifyMarkers(
73
+ markers,
74
+ new Set(bundle.concepts.map((concept) => concept.path)),
75
+ );
76
+ }
77
+
78
+ /** Every commit carrying a Task trailer, one row per (task, commit). Empty if git can't answer. */
79
+ export function scanActivity(
80
+ cwd: string,
81
+ trailerKey: string,
82
+ byId: Bundle["byId"],
83
+ ): ActivityRow[] {
84
+ try {
85
+ const records = execFileSync(
86
+ "git",
87
+ [
88
+ "log",
89
+ `--format=%x1e%H%x1f%cI%x1f%s%x1f%(trailers:key=${trailerKey},valueonly)`,
90
+ ],
91
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
92
+ ).split("\x1e");
93
+ return records.slice(1).flatMap((record) => {
94
+ const [sha, date, subject, trailers] = record.split("\x1f");
95
+ if (!sha || !date) return [];
96
+ return (trailers ?? "")
97
+ .split("\n")
98
+ .map((t) => t.trim())
99
+ .filter(Boolean)
100
+ .map((id) => ({
101
+ taskId: byId(id)?.fm.id ?? id, // aliases resolve to the canonical id
102
+ sha,
103
+ date,
104
+ subject: (subject ?? "").trim(),
105
+ }));
106
+ });
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ /**
113
+ * Count distinct Task-trailered commits after a state-of-play watermark.
114
+ * Undefined means Git could not resolve the watermark/repository; callers
115
+ * render that uncertainty instead of pretending the note is current.
116
+ */
117
+ export function taskLinkedCommitsSince(
118
+ cwd: string,
119
+ trailerKey: string,
120
+ sha: string,
121
+ ): number | undefined {
122
+ if (!/^[0-9a-f]{7,40}$/i.test(sha)) return undefined;
123
+ try {
124
+ const records = execFileSync(
125
+ "git",
126
+ [
127
+ "log",
128
+ `${sha}..HEAD`,
129
+ `--format=%x1e%H%x1f%(trailers:key=${trailerKey},valueonly)`,
130
+ ],
131
+ { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] },
132
+ ).split("\x1e");
133
+ return records.slice(1).filter((record) => {
134
+ const [, trailers = ""] = record.split("\x1f");
135
+ return trailers.trim().length > 0;
136
+ }).length;
137
+ } catch {
138
+ return undefined;
139
+ }
140
+ }
141
+
142
+ const SCHEMA = `
143
+ DROP VIEW IF EXISTS backlinks;
144
+ DROP VIEW IF EXISTS board;
145
+ DROP VIEW IF EXISTS epic_rollup;
146
+ DROP TABLE IF EXISTS concepts;
147
+ DROP TABLE IF EXISTS links;
148
+ DROP TABLE IF EXISTS activity;
149
+ DROP TABLE IF EXISTS verifications;
150
+ DROP TABLE IF EXISTS verification_results;
151
+ CREATE TABLE concepts (
152
+ path TEXT PRIMARY KEY,
153
+ id TEXT,
154
+ type TEXT NOT NULL,
155
+ title TEXT,
156
+ status TEXT,
157
+ priority TEXT,
158
+ rank REAL,
159
+ epic TEXT,
160
+ timestamp TEXT
161
+ );
162
+ CREATE TABLE links (from_path TEXT NOT NULL, target TEXT NOT NULL, to_path TEXT);
163
+ CREATE TABLE activity (task_id TEXT NOT NULL, sha TEXT NOT NULL, date TEXT NOT NULL, subject TEXT NOT NULL);
164
+ -- Verification linkage: resolved docket:verifies markers.
165
+ -- kind is 'test' today; 'case' arrives with the eval profile (Phase B).
166
+ CREATE TABLE verifications (
167
+ concept_path TEXT NOT NULL,
168
+ kind TEXT NOT NULL,
169
+ source_path TEXT NOT NULL,
170
+ line INTEGER,
171
+ anchor TEXT
172
+ );
173
+ -- Populated by verify ingest; empty until then. Ephemeral by design:
174
+ -- a cache of CI's last word, dropped on rebuild like every other table.
175
+ CREATE TABLE verification_results (
176
+ source_path TEXT PRIMARY KEY,
177
+ status TEXT NOT NULL,
178
+ ran_at TEXT,
179
+ detail TEXT
180
+ );
181
+ CREATE VIEW backlinks AS
182
+ SELECT to_path AS path, from_path FROM links WHERE to_path IS NOT NULL;
183
+ -- Terminal history sorts by transition time (setStatus bumps timestamp),
184
+ -- newest first; active columns lead with manual rank — unranked
185
+ -- tasks trail in the default priority-then-id order.
186
+ CREATE VIEW board AS
187
+ SELECT status, priority, rank, id, title, path, timestamp FROM concepts
188
+ WHERE type = 'Task'
189
+ ORDER BY status,
190
+ CASE WHEN status IN ('done', 'closed') THEN timestamp END DESC,
191
+ rank IS NULL, rank,
192
+ priority, id;
193
+ -- last_activity: freshest timestamp across the epic and its tasks (setStatus
194
+ -- bumps timestamps, so this tracks the latest status transition anywhere in
195
+ -- the epic). Empty string when nothing is stamped, so DESC sorts it last.
196
+ CREATE VIEW epic_rollup AS
197
+ SELECT e.id AS epic_id, e.title AS epic_title,
198
+ COUNT(t.path) AS total,
199
+ COALESCE(SUM(t.status = 'done'), 0) AS done,
200
+ COALESCE(SUM(t.status = 'closed'), 0) AS closed,
201
+ max(COALESCE(MAX(t.timestamp), ''), COALESCE(e.timestamp, '')) AS last_activity
202
+ FROM concepts e
203
+ LEFT JOIN concepts t ON t.type = 'Task' AND t.epic LIKE '%/' || e.id || '-%'
204
+ WHERE e.type = 'Epic'
205
+ GROUP BY e.path;
206
+ `;
207
+
208
+ /** Rebuild the cache from scratch — it is derived and disposable, never migrated. */
209
+ export function buildCache(
210
+ db: Database,
211
+ bundle: Bundle,
212
+ activity: ActivityRow[] = [],
213
+ verifications: VerifyMarker[] = [],
214
+ ): void {
215
+ db.exec(SCHEMA);
216
+ const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
217
+ const paths = new Set(bundle.concepts.map((c) => c.path));
218
+
219
+ const insertConcept = db.prepare(
220
+ "INSERT INTO concepts (path, id, type, title, status, priority, rank, epic, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
221
+ );
222
+ const insertLink = db.prepare(
223
+ "INSERT INTO links (from_path, target, to_path) VALUES (?, ?, ?)",
224
+ );
225
+ const insertActivity = db.prepare(
226
+ "INSERT INTO activity (task_id, sha, date, subject) VALUES (?, ?, ?, ?)",
227
+ );
228
+ const insertVerification = db.prepare(
229
+ "INSERT INTO verifications (concept_path, kind, source_path, line, anchor) VALUES (?, ?, ?, ?, ?)",
230
+ );
231
+
232
+ db.transaction(() => {
233
+ for (const c of bundle.concepts) {
234
+ insertConcept.run(
235
+ c.path,
236
+ str(c.fm.id),
237
+ c.fm.type,
238
+ c.fm.title ?? null,
239
+ str(c.fm.status),
240
+ str(c.fm.priority),
241
+ typeof c.fm.rank === "number" ? c.fm.rank : null,
242
+ str(c.fm.epic),
243
+ str(c.fm.timestamp),
244
+ );
245
+ for (const l of c.links) {
246
+ if (!l.internal) continue;
247
+ const resolved = resolveLink(c.path, l.target);
248
+ insertLink.run(
249
+ c.path,
250
+ l.target,
251
+ resolved && paths.has(resolved) ? resolved : null,
252
+ );
253
+ }
254
+ }
255
+ for (const a of activity)
256
+ insertActivity.run(a.taskId, a.sha, a.date, a.subject);
257
+ for (const v of verifications) {
258
+ if (!v.spec) continue; // unresolved markers are lint's problem, not rows
259
+ insertVerification.run(
260
+ v.spec,
261
+ "test",
262
+ v.source,
263
+ v.line,
264
+ v.anchor ?? null,
265
+ );
266
+ }
267
+ })();
268
+ }
package/src/config.ts ADDED
@@ -0,0 +1,96 @@
1
+ // docket.yaml — parsed with explicit defaults rather than schema magic so a
2
+ // missing or partial config always yields a fully-populated DocketConfig.
3
+ // Unknown keys are preserved (OKF-style tolerance applies to config too).
4
+
5
+ import { parse as parseYaml } from "yaml";
6
+ import { STATES } from "./states";
7
+
8
+ export interface DocketConfig {
9
+ project: string;
10
+ bundle: string;
11
+ ids: { scheme: string; decision_prefix: string };
12
+ workflow: { states: readonly string[] };
13
+ git: { trailer: string; branch_prefix: string };
14
+ /** Verification linkage. null = key absent = feature fully dormant. */
15
+ verify: { tests: string[] } | null;
16
+ extra: Record<string, unknown>;
17
+ }
18
+
19
+ export const CONFIG_FILENAME = "docket.yaml";
20
+
21
+ /** Default bundle root — a Docket-signaling name that doesn't collide
22
+ * with a repo's existing docs/ folder. Repos with an explicit `bundle:` keep it. */
23
+ export const DEFAULT_BUNDLE = "docket/";
24
+
25
+ export function parseConfig(source?: string): DocketConfig {
26
+ const raw: Record<string, unknown> =
27
+ source &&
28
+ typeof parseYaml(source) === "object" &&
29
+ parseYaml(source) !== null
30
+ ? (parseYaml(source) as Record<string, unknown>)
31
+ : {};
32
+
33
+ const section = (key: string): Record<string, unknown> => {
34
+ const value = raw[key];
35
+ return typeof value === "object" && value !== null
36
+ ? (value as Record<string, unknown>)
37
+ : {};
38
+ };
39
+ const str = (value: unknown, fallback: string): string =>
40
+ typeof value === "string" && value.length > 0 ? value : fallback;
41
+
42
+ const ids = section("ids");
43
+ const workflow = section("workflow");
44
+ const git = section("git");
45
+ const configuredStates = Array.isArray(workflow.states)
46
+ ? workflow.states.filter((s): s is string => typeof s === "string")
47
+ : [...STATES];
48
+ // Canonical states are engine semantics, not optional feature flags. Append
49
+ // newly introduced states for older adopting configs so upgraded clients can
50
+ // expose them without rewriting the user's docket.yaml.
51
+ const states = [
52
+ ...configuredStates,
53
+ ...STATES.filter((state) => !configuredStates.includes(state)),
54
+ ];
55
+
56
+ // verify: the whole feature's on/off switch is this key's presence.
57
+ const verifySection = section("verify");
58
+ const verify =
59
+ "verify" in raw
60
+ ? {
61
+ tests: Array.isArray(verifySection.tests)
62
+ ? verifySection.tests.filter(
63
+ (g): g is string => typeof g === "string",
64
+ )
65
+ : [],
66
+ }
67
+ : null;
68
+
69
+ const known = new Set([
70
+ "project",
71
+ "bundle",
72
+ "ids",
73
+ "workflow",
74
+ "git",
75
+ "verify",
76
+ ]);
77
+ const extra = Object.fromEntries(
78
+ Object.entries(raw).filter(([k]) => !known.has(k)),
79
+ );
80
+
81
+ return {
82
+ project: str(raw.project, "DKT"),
83
+ bundle: str(raw.bundle, DEFAULT_BUNDLE),
84
+ ids: {
85
+ scheme: str(ids.scheme, "sequential"),
86
+ decision_prefix: str(ids.decision_prefix, "DEC"),
87
+ },
88
+ workflow: { states },
89
+ git: {
90
+ trailer: str(git.trailer, "Task"),
91
+ branch_prefix: str(git.branch_prefix, "task/"),
92
+ },
93
+ verify,
94
+ extra,
95
+ };
96
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Canonical agent-facing descriptions of engine-owned task semantics.
3
+ *
4
+ * Execution remains in states.ts, bundle.ts, and ops.ts. Shipped workflows
5
+ * and command adapters interpolate these claims instead of restating them,
6
+ * while workflow guard tests exercise the executable behavior behind them.
7
+ */
8
+
9
+ export const ENGINE_SEMANTICS = {
10
+ readiness:
11
+ "Ready is derived, never written: a task is ready only when its stored status is `todo` and every dependency resolves to `done`; an unknown dependency blocks it.",
12
+ readyOrdering:
13
+ "The ready queue puts ranked tasks first by ascending `rank`; rank ties and the unranked tail use priority (`p0` through `p3`), then ascending task ID as the stable fallback.",
14
+ transitions:
15
+ "Stored status changes go through the engine's canonical transition table; invalid transitions are rejected, `done` and `closed` are terminal, and moving to `closed` requires a disposition note. Only `done` satisfies dependencies or counts as completion.",
16
+ mutationOwnership: {
17
+ pickup:
18
+ "The engine owns task selection, the state-machine-checked status transition, active-task state, title derivation, and the context packet; the pickup workflow owns only their sequence, and a native adapter owns only its bounded rename binding.",
19
+ grooming:
20
+ "The engine owns ready/list derivation and task mutation mechanics; the groom workflow owns audit judgment, proposed changes, and the authorization boundary.",
21
+ close:
22
+ "The engine owns the state-machine-checked terminal move and dated Log mutation; the close workflow owns the choice between completion (`done`) and non-completion (`closed`), Outcome or Disposition judgment, documentation review, and derived index/log reconciliation.",
23
+ },
24
+ } as const;
25
+
26
+ export const READY_QUEUE_DESCRIPTION = `${ENGINE_SEMANTICS.readiness} ${ENGINE_SEMANTICS.readyOrdering}`;
@@ -0,0 +1,63 @@
1
+ // FileStore abstracts where a bundle's files live. Local filesystem now;
2
+ // a GitHub Git Data API implementation later lets the hosted App operate
3
+ // without ever cloning a repo.
4
+
5
+ import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+
8
+ export interface FileStore {
9
+ /** Relative paths (posix separators) of every .md file under the root, sorted. */
10
+ list(): Promise<string[]>;
11
+ read(path: string): Promise<string>;
12
+ write(path: string, content: string): Promise<void>;
13
+ }
14
+
15
+ export class LocalFileStore implements FileStore {
16
+ constructor(readonly root: string) {}
17
+
18
+ async list(): Promise<string[]> {
19
+ const out: string[] = [];
20
+ const walk = async (rel: string): Promise<void> => {
21
+ const entries = await readdir(join(this.root, rel), {
22
+ withFileTypes: true,
23
+ });
24
+ for (const entry of entries) {
25
+ if (entry.name.startsWith(".")) continue;
26
+ const relPath = rel === "" ? entry.name : `${rel}/${entry.name}`;
27
+ if (entry.isDirectory()) await walk(relPath);
28
+ else if (entry.name.endsWith(".md")) out.push(relPath);
29
+ }
30
+ };
31
+ await walk("");
32
+ return out.sort();
33
+ }
34
+
35
+ read(path: string): Promise<string> {
36
+ return readFile(join(this.root, path), "utf8");
37
+ }
38
+
39
+ async write(path: string, content: string): Promise<void> {
40
+ const abs = join(this.root, path);
41
+ await mkdir(dirname(abs), { recursive: true });
42
+ await writeFile(abs, content, "utf8");
43
+ }
44
+ }
45
+
46
+ /** Test double and future in-process cache seed. */
47
+ export class InMemoryFileStore implements FileStore {
48
+ constructor(readonly files = new Map<string, string>()) {}
49
+
50
+ async list(): Promise<string[]> {
51
+ return [...this.files.keys()].filter((p) => p.endsWith(".md")).sort();
52
+ }
53
+
54
+ async read(path: string): Promise<string> {
55
+ const content = this.files.get(path);
56
+ if (content === undefined) throw new Error(`not found: ${path}`);
57
+ return content;
58
+ }
59
+
60
+ async write(path: string, content: string): Promise<void> {
61
+ this.files.set(path, content);
62
+ }
63
+ }