Skip to content

Commit 8034820

Browse files
committed
refactor(filesense): extract schema orchestration helpers
1 parent 3a24a50 commit 8034820

4 files changed

Lines changed: 370 additions & 139 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Filesense Schema Orchestration Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4+
5+
**Goal:** Extract mcp-filesense schema path/build/write helpers from `engine.ts` into a focused internal module without changing generated schema contents or Filesense indexing/query behavior.
6+
7+
**Architecture:** Keep `engine.ts` as the operation orchestrator and move schema path resolution, relative schema refs, schema builders, and idempotent schema file writes into `packages/mcp-filesense/src/engine-schema.ts`. Engine callers continue to ask for schema paths and refs through the helper module, preserving the existing public API and MCP tool contracts.
8+
9+
**Tech Stack:** TypeScript ESM, Node `fs`/`path`, Vitest.
10+
11+
---
12+
13+
## GitNexus Impact Summary
14+
15+
- `ensureSchemaFiles`: LOW risk, 3 direct callers (`init`, `syncIndexes`, `summarize`), 2 affected processes (`handleFilesenseTool`, `summarize`).
16+
- `buildIndexSchema`: LOW risk, direct caller `ensureSchemaFiles`, same affected processes.
17+
- `buildNotesSchema`: LOW risk, direct caller `ensureSchemaFiles`, same affected processes.
18+
- `schemaPathsForRoot`: HIGH risk, 4 direct callers (`ensureSchemaFiles`, `writeDirectoryIndex`, `summarize`, `check`), indirectly affects `init`, `syncIndexes`, `syncAndSummarize`, `handleFilesenseTool`, and `engine.test.ts`. User authorized continuing; tests must lock paths and generated contents.
19+
20+
## Files
21+
22+
- Create: `packages/mcp-filesense/src/engine-schema.ts`
23+
- Modify: `packages/mcp-filesense/src/engine.ts`
24+
- Modify: `packages/mcp-filesense/src/engine.test.ts`
25+
26+
### Task 1: Lock Generated Schema Behavior
27+
28+
- [ ] Add a focused Vitest case in `engine.test.ts` that initializes a custom `schemaDir`, then asserts:
29+
- `custom-schemas/FILES.schema.json` and `custom-schemas/FILES.notes.schema.json` exist.
30+
- Root `FILES.json.$schema` points to `custom-schemas/FILES.schema.json`.
31+
- Nested `FILES.json.$schema` points to `../custom-schemas/FILES.schema.json`.
32+
- Root `FILES.notes.json.$schema` points to `custom-schemas/FILES.notes.schema.json`.
33+
- Schema `$id`, `title`, required fields, and notes properties remain unchanged.
34+
- [ ] Run the focused test before implementation and confirm the new extraction-facing assertions fail or establish the current behavior baseline.
35+
36+
### Task 2: Extract Schema Helpers
37+
38+
- [ ] Move schema helper code from `engine.ts` to `engine-schema.ts`:
39+
- `schemaPathsForRoot`
40+
- `relativeSchemaRef`
41+
- `ensureSchemaFiles`
42+
- `buildIndexSchema`
43+
- `buildNotesSchema`
44+
- [ ] Pass small dependencies into `ensureSchemaFiles` (`exists`, `readJson`, `writeJson`, `stableStringify`) so file IO behavior remains owned by `engine.ts`.
45+
- [ ] Export only helpers needed by `engine.ts`.
46+
47+
### Task 3: Rewire Engine Orchestration
48+
49+
- [ ] Import `ensureSchemaFiles`, `relativeSchemaRef`, and `schemaPathsForRoot` from `engine-schema.ts`.
50+
- [ ] Replace local helper calls without changing `init`, `syncIndexes`, `summarize`, `check`, `query`, indexing, or MCP tool output behavior.
51+
- [ ] Remove the old schema helper definitions from `engine.ts`.
52+
53+
### Task 4: Verify
54+
55+
- [ ] Run focused tests: `pnpm --filter @frontagent/mcp-filesense test -- engine.test.ts`.
56+
- [ ] Run package typecheck: `pnpm --filter @frontagent/mcp-filesense typecheck`.
57+
- [ ] Run repository gate: `pnpm quality:precommit`.
58+
- [ ] Run GitNexus final diff analysis: `npx gitnexus detect-changes --scope all --repo <worktree-path>`.
59+
- [ ] Include `Closes #244` and the GitNexus impact summary in the PR body.
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import { promises as fs } from 'node:fs';
2+
import path from 'node:path';
3+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
4+
import {
5+
buildIndexSchema,
6+
buildNotesSchema,
7+
ensureSchemaFiles,
8+
relativeSchemaRef,
9+
schemaPathsForRoot,
10+
} from './engine-schema.js';
11+
import type { FilesenseConfig } from './types.js';
12+
13+
const TEST_DIR = path.join(import.meta.dirname, '..', '.test-schema-workspace');
14+
15+
const config: FilesenseConfig = {
16+
schemaVersion: '1.0',
17+
root: '.',
18+
recursive: true,
19+
indexFile: 'FILES.json',
20+
notesFile: 'FILES.notes.json',
21+
ignoreFile: '.filesignore',
22+
schemaDir: 'custom-schemas',
23+
exclude: ['.git', 'node_modules'],
24+
hashAlgorithm: 'sha1',
25+
};
26+
27+
async function ensureClean() {
28+
await fs.rm(TEST_DIR, { recursive: true, force: true });
29+
await fs.mkdir(TEST_DIR, { recursive: true });
30+
}
31+
32+
async function exists(targetPath: string): Promise<boolean> {
33+
try {
34+
await fs.access(targetPath);
35+
return true;
36+
} catch {
37+
return false;
38+
}
39+
}
40+
41+
async function readJson(targetPath: string): Promise<unknown> {
42+
return JSON.parse(await fs.readFile(targetPath, 'utf8')) as unknown;
43+
}
44+
45+
async function writeJson(targetPath: string, value: unknown): Promise<void> {
46+
await fs.mkdir(path.dirname(targetPath), { recursive: true });
47+
await fs.writeFile(targetPath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
48+
}
49+
50+
function stableStringify(value: unknown): string {
51+
return JSON.stringify(value);
52+
}
53+
54+
describe('schema orchestration helpers', () => {
55+
beforeEach(ensureClean);
56+
afterEach(async () => {
57+
await fs.rm(TEST_DIR, { recursive: true, force: true });
58+
});
59+
60+
it('resolves generated schema paths and relative refs from nested directories', () => {
61+
const paths = schemaPathsForRoot(TEST_DIR, config);
62+
expect(paths.indexSchemaPath).toBe(path.join(TEST_DIR, 'custom-schemas', 'FILES.schema.json'));
63+
expect(paths.notesSchemaPath).toBe(
64+
path.join(TEST_DIR, 'custom-schemas', 'FILES.notes.schema.json'),
65+
);
66+
67+
expect(relativeSchemaRef(TEST_DIR, paths.indexSchemaPath)).toBe(
68+
'custom-schemas/FILES.schema.json',
69+
);
70+
expect(relativeSchemaRef(path.join(TEST_DIR, 'src'), paths.indexSchemaPath)).toBe(
71+
'../custom-schemas/FILES.schema.json',
72+
);
73+
expect(relativeSchemaRef(TEST_DIR, paths.notesSchemaPath)).toBe(
74+
'custom-schemas/FILES.notes.schema.json',
75+
);
76+
});
77+
78+
it('builds the expected FILES schema contract', () => {
79+
const schema = buildIndexSchema(config);
80+
81+
expect(schema.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
82+
expect(schema.$id).toBe('https://filesense.dev/schema/1.0/FILES.schema.json');
83+
expect(schema.title).toBe('FILES.json');
84+
expect(schema.required).toEqual([
85+
'schema_version',
86+
'generated_at',
87+
'root_relative_path',
88+
'directory',
89+
'children',
90+
'sync',
91+
]);
92+
expect(schema.properties).toMatchObject({
93+
schema_version: { type: 'string' },
94+
children: {
95+
type: 'array',
96+
items: {
97+
required: [
98+
'name',
99+
'type',
100+
'path',
101+
'ext',
102+
'size',
103+
'mtimeMs',
104+
'hash',
105+
'summary',
106+
'importance',
107+
'status',
108+
],
109+
},
110+
},
111+
sync: {
112+
required: [
113+
'child_count',
114+
'file_count',
115+
'dir_count',
116+
'last_full_sync',
117+
'last_incremental_sync',
118+
],
119+
},
120+
});
121+
});
122+
123+
it('builds the expected FILES notes schema contract', () => {
124+
const schema = buildNotesSchema(config);
125+
126+
expect(schema.$schema).toBe('https://json-schema.org/draft/2020-12/schema');
127+
expect(schema.$id).toBe('https://filesense.dev/schema/1.0/FILES.notes.schema.json');
128+
expect(schema.title).toBe('FILES.notes.json');
129+
expect(schema.additionalProperties).toBe(false);
130+
expect(schema.properties).toMatchObject({
131+
$schema: { type: 'string' },
132+
directory_purpose: { type: 'string' },
133+
agent_hints: { type: 'array', items: { type: 'string' } },
134+
conventions: { type: 'array', items: { type: 'string' } },
135+
key_entrypoints: { type: 'array', items: { type: 'string' } },
136+
});
137+
});
138+
139+
it('writes generated schema files without rewriting unchanged contents', async () => {
140+
const paths = schemaPathsForRoot(TEST_DIR, config);
141+
const deps = { exists, readJson, writeJson, stableStringify };
142+
143+
await ensureSchemaFiles(TEST_DIR, config, deps);
144+
const firstIndexSchema = await fs.readFile(paths.indexSchemaPath, 'utf8');
145+
const firstNotesSchema = await fs.readFile(paths.notesSchemaPath, 'utf8');
146+
const firstIndexMtime = (await fs.stat(paths.indexSchemaPath)).mtimeMs;
147+
const firstNotesMtime = (await fs.stat(paths.notesSchemaPath)).mtimeMs;
148+
149+
await ensureSchemaFiles(TEST_DIR, config, deps);
150+
151+
expect(await fs.readFile(paths.indexSchemaPath, 'utf8')).toBe(firstIndexSchema);
152+
expect(await fs.readFile(paths.notesSchemaPath, 'utf8')).toBe(firstNotesSchema);
153+
expect((await fs.stat(paths.indexSchemaPath)).mtimeMs).toBe(firstIndexMtime);
154+
expect((await fs.stat(paths.notesSchemaPath)).mtimeMs).toBe(firstNotesMtime);
155+
});
156+
});
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
import path from 'node:path';
2+
import type { FilesenseConfig } from './types.js';
3+
4+
type SchemaFileDeps = {
5+
exists: (targetPath: string) => Promise<boolean>;
6+
readJson: (targetPath: string) => Promise<unknown>;
7+
stableStringify: (value: unknown) => string;
8+
writeJson: (targetPath: string, value: unknown) => Promise<void>;
9+
};
10+
11+
export function schemaPathsForRoot(root: string, config: FilesenseConfig) {
12+
return {
13+
indexSchemaPath: path.join(root, config.schemaDir, 'FILES.schema.json'),
14+
notesSchemaPath: path.join(root, config.schemaDir, 'FILES.notes.schema.json'),
15+
};
16+
}
17+
18+
export function relativeSchemaRef(dirPath: string, schemaPath: string): string {
19+
return path.relative(dirPath, schemaPath).replace(/\\/g, '/');
20+
}
21+
22+
export async function ensureSchemaFiles(
23+
root: string,
24+
config: FilesenseConfig,
25+
deps: SchemaFileDeps,
26+
): Promise<void> {
27+
const paths = schemaPathsForRoot(root, config);
28+
const indexSchema = buildIndexSchema(config);
29+
const notesSchema = buildNotesSchema(config);
30+
31+
const readSafe = async (p: string) => {
32+
try {
33+
return await deps.readJson(p);
34+
} catch {
35+
return null;
36+
}
37+
};
38+
39+
if (
40+
!(await deps.exists(paths.indexSchemaPath)) ||
41+
deps.stableStringify(await readSafe(paths.indexSchemaPath)) !==
42+
deps.stableStringify(indexSchema)
43+
) {
44+
await deps.writeJson(paths.indexSchemaPath, indexSchema);
45+
}
46+
if (
47+
!(await deps.exists(paths.notesSchemaPath)) ||
48+
deps.stableStringify(await readSafe(paths.notesSchemaPath)) !==
49+
deps.stableStringify(notesSchema)
50+
) {
51+
await deps.writeJson(paths.notesSchemaPath, notesSchema);
52+
}
53+
}
54+
55+
export function buildIndexSchema(config: FilesenseConfig): Record<string, unknown> {
56+
return {
57+
$schema: 'https://json-schema.org/draft/2020-12/schema',
58+
$id: `https://filesense.dev/schema/${config.schemaVersion}/FILES.schema.json`,
59+
title: 'FILES.json',
60+
type: 'object',
61+
required: [
62+
'schema_version',
63+
'generated_at',
64+
'root_relative_path',
65+
'directory',
66+
'children',
67+
'sync',
68+
],
69+
additionalProperties: false,
70+
properties: {
71+
$schema: { type: 'string' },
72+
schema_version: { type: 'string' },
73+
generated_at: { type: 'string' },
74+
root_relative_path: { type: 'string' },
75+
directory: {
76+
type: 'object',
77+
required: ['name', 'path'],
78+
additionalProperties: false,
79+
properties: { name: { type: 'string' }, path: { type: 'string' } },
80+
},
81+
children: {
82+
type: 'array',
83+
items: {
84+
type: 'object',
85+
required: [
86+
'name',
87+
'type',
88+
'path',
89+
'ext',
90+
'size',
91+
'mtimeMs',
92+
'hash',
93+
'summary',
94+
'importance',
95+
'status',
96+
],
97+
additionalProperties: false,
98+
properties: {
99+
name: { type: 'string' },
100+
type: { enum: ['file', 'dir'] },
101+
path: { type: 'string' },
102+
ext: { type: 'string' },
103+
size: { type: 'number' },
104+
mtimeMs: { type: 'number' },
105+
hash: { anyOf: [{ type: 'string' }, { type: 'null' }] },
106+
summary: { type: 'string' },
107+
importance: { enum: ['high', 'normal'] },
108+
status: { enum: ['active'] },
109+
},
110+
},
111+
},
112+
sync: {
113+
type: 'object',
114+
required: [
115+
'child_count',
116+
'file_count',
117+
'dir_count',
118+
'last_full_sync',
119+
'last_incremental_sync',
120+
],
121+
additionalProperties: false,
122+
properties: {
123+
child_count: { type: 'number' },
124+
file_count: { type: 'number' },
125+
dir_count: { type: 'number' },
126+
last_full_sync: { anyOf: [{ type: 'string' }, { type: 'null' }] },
127+
last_incremental_sync: { anyOf: [{ type: 'string' }, { type: 'null' }] },
128+
},
129+
},
130+
},
131+
};
132+
}
133+
134+
export function buildNotesSchema(config: FilesenseConfig): Record<string, unknown> {
135+
return {
136+
$schema: 'https://json-schema.org/draft/2020-12/schema',
137+
$id: `https://filesense.dev/schema/${config.schemaVersion}/FILES.notes.schema.json`,
138+
title: 'FILES.notes.json',
139+
type: 'object',
140+
additionalProperties: false,
141+
properties: {
142+
$schema: { type: 'string' },
143+
directory_purpose: { type: 'string' },
144+
agent_hints: { type: 'array', items: { type: 'string' } },
145+
conventions: { type: 'array', items: { type: 'string' } },
146+
key_entrypoints: { type: 'array', items: { type: 'string' } },
147+
},
148+
};
149+
}

0 commit comments

Comments
 (0)