@imenam/mcp-documentor 1.0.57 → 1.0.58
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/dist/shared/schemas/doc.schema.d.ts +1 -0
- package/dist/shared/schemas/doc.schema.js +5 -5
- package/dist/src/core/code-ref-resolver.d.ts +2 -2
- package/dist/src/core/code-ref-resolver.js +14 -9
- package/dist/src/core/path-resolver.d.ts +20 -0
- package/dist/src/core/path-resolver.js +82 -0
- package/dist/src/index.js +11 -7
- package/dist/src/tools/check-freshness.d.ts +1 -1
- package/dist/src/tools/check-freshness.js +44 -12
- package/dist/src/tools/create-doc.js +2 -2
- package/dist/src/tools/get-doc-metadata.js +7 -3
- package/dist/src/tools/get-doc.js +6 -2
- package/dist/src/tools/git-diff-doc.js +11 -6
- package/dist/src/tools/list-docs.js +2 -2
- package/dist/src/tools/patch-doc.js +1 -1
- package/dist/src/tools/refresh-file-snapshot.js +10 -7
- package/dist/src/tools/update-doc.js +2 -2
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +3 -2
- package/scripts/migrate-paths-to-relative.js +192 -0
|
@@ -36,7 +36,7 @@ export const DocSchema = z.object({
|
|
|
36
36
|
updatedAt: z.string(),
|
|
37
37
|
});
|
|
38
38
|
export const FreshnessReportSchema = z.object({
|
|
39
|
-
status: z.enum(["up_to_date", "stale", "no_tracking"]),
|
|
39
|
+
status: z.enum(["up_to_date", "stale", "no_tracking", "unverifiable"]),
|
|
40
40
|
modified: z.array(z.string()),
|
|
41
41
|
missing: z.array(z.string()),
|
|
42
42
|
unchanged: z.array(z.string()),
|
|
@@ -47,7 +47,7 @@ export const CreateDocArgsSchema = z.object({
|
|
|
47
47
|
title: z.string().describe("Title of the documentation entry"),
|
|
48
48
|
content: z.string().optional().describe("Documentation content in markdown"),
|
|
49
49
|
content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
|
|
50
|
-
tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive."),
|
|
50
|
+
tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
|
|
51
51
|
tags: z.array(z.string()).optional().describe("Tags for categorization"),
|
|
52
52
|
folder: z.string().optional().describe("Folder path for organization, e.g. 'frontend/components'"),
|
|
53
53
|
related_docs: z.array(z.string()).optional().describe("IDs of related documentation entries"),
|
|
@@ -59,7 +59,7 @@ export const UpdateDocArgsSchema = z.object({
|
|
|
59
59
|
title: z.string().optional().describe("Title of the documentation entry"),
|
|
60
60
|
content: z.string().optional().describe("New content (replaces existing)"),
|
|
61
61
|
content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
|
|
62
|
-
tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive."),
|
|
62
|
+
tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
|
|
63
63
|
tags: z.array(z.string()).optional().describe("New tags (replaces existing)"),
|
|
64
64
|
related_docs: z.array(z.string()).optional().describe("Paths of related documentation entries (replaces existing)"),
|
|
65
65
|
});
|
|
@@ -80,7 +80,7 @@ export const SearchDocsArgsSchema = z.object({
|
|
|
80
80
|
});
|
|
81
81
|
export const RefreshFileSnapshotArgsSchema = z.object({
|
|
82
82
|
paths: z.array(z.string()).min(1).describe("Paths of documentation entries to refresh, e.g. ['backend/auth/authentication', 'setup']"),
|
|
83
|
-
file_paths: z.array(z.string()).optional().describe("Specific tracked
|
|
83
|
+
file_paths: z.array(z.string()).optional().describe("Specific tracked files to refresh, as absolute paths (they are matched against the stored paths, relative or absolute). Refreshes all tracked files if omitted."),
|
|
84
84
|
});
|
|
85
85
|
export const GetDocMetadataArgsSchema = z.object({
|
|
86
86
|
path: z.string().describe("Path of the documentation entry, e.g. 'backend/auth/authentication'"),
|
|
@@ -101,7 +101,7 @@ export const CheckLinksArgsSchema = z.object({
|
|
|
101
101
|
});
|
|
102
102
|
export const GitDiffDocArgsSchema = z.object({
|
|
103
103
|
paths: z.array(z.string()).min(1).describe("Paths of documentation entries to diff, e.g. ['backend/auth/authentication']"),
|
|
104
|
-
file_paths: z.array(z.string()).optional().describe("Specific tracked
|
|
104
|
+
file_paths: z.array(z.string()).optional().describe("Specific tracked files to diff, as absolute paths (matched against the stored paths, relative or absolute). All tracked files if omitted."),
|
|
105
105
|
});
|
|
106
106
|
export const SetProjectArgsSchema = z.object({
|
|
107
107
|
project: z.string().describe("Project name to switch to (created if it does not exist)"),
|
|
@@ -4,11 +4,11 @@ export interface CodeRefResult {
|
|
|
4
4
|
additionalTrackedFiles: TrackedFileInput[];
|
|
5
5
|
}
|
|
6
6
|
export declare function resolveCodeRefs(content: string): CodeRefResult;
|
|
7
|
-
export declare function snapshotTrackedFiles(inputs: TrackedFileInput[]): TrackedFile[];
|
|
7
|
+
export declare function snapshotTrackedFiles(inputs: TrackedFileInput[], project: string): TrackedFile[];
|
|
8
8
|
/**
|
|
9
9
|
* Merges snapshotted tracked files into an existing list.
|
|
10
10
|
* - If a file is not yet tracked: appended.
|
|
11
11
|
* - If a file is already tracked with sections and the new entry also has sections: sections are merged.
|
|
12
12
|
* - Otherwise (whole-file tracking involved): the existing entry is replaced.
|
|
13
13
|
*/
|
|
14
|
-
export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[]): TrackedFile[];
|
|
14
|
+
export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[], project: string): TrackedFile[];
|
|
@@ -2,6 +2,7 @@ import crypto from "crypto";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { snapshotSection } from "./section-tracker.js";
|
|
5
|
+
import { toStoredPath } from "./path-resolver.js";
|
|
5
6
|
const EXT_TO_LANG = {
|
|
6
7
|
ts: "typescript",
|
|
7
8
|
tsx: "tsx",
|
|
@@ -46,18 +47,21 @@ export function resolveCodeRefs(content) {
|
|
|
46
47
|
});
|
|
47
48
|
return { content: resolved, additionalTrackedFiles };
|
|
48
49
|
}
|
|
49
|
-
export function snapshotTrackedFiles(inputs) {
|
|
50
|
+
export function snapshotTrackedFiles(inputs, project) {
|
|
51
|
+
// Keyed on the *stored* (portable) path so two inputs pointing at the same
|
|
52
|
+
// file dedupe regardless of how the agent spelled the path.
|
|
50
53
|
const byPath = new Map();
|
|
51
54
|
for (const input of inputs) {
|
|
52
55
|
const fp = typeof input === "string" ? input : input.path;
|
|
53
56
|
const absolutePath = path.resolve(process.cwd(), fp);
|
|
57
|
+
const storedPath = toStoredPath(fp, project);
|
|
54
58
|
const mtime = fs.existsSync(absolutePath)
|
|
55
59
|
? fs.statSync(absolutePath).mtime.toISOString()
|
|
56
60
|
: new Date().toISOString();
|
|
57
61
|
const isWhole = typeof input === "string" || input.lines.length === 0;
|
|
58
|
-
const existing = byPath.get(
|
|
62
|
+
const existing = byPath.get(storedPath);
|
|
59
63
|
if (isWhole) {
|
|
60
|
-
byPath.set(
|
|
64
|
+
byPath.set(storedPath, { absolutePath, mtime, lineRanges: [], wholeFile: true });
|
|
61
65
|
}
|
|
62
66
|
else if (existing) {
|
|
63
67
|
if (!existing.wholeFile) {
|
|
@@ -65,7 +69,8 @@ export function snapshotTrackedFiles(inputs) {
|
|
|
65
69
|
}
|
|
66
70
|
}
|
|
67
71
|
else {
|
|
68
|
-
byPath.set(
|
|
72
|
+
byPath.set(storedPath, {
|
|
73
|
+
absolutePath,
|
|
69
74
|
mtime,
|
|
70
75
|
lineRanges: [...input.lines],
|
|
71
76
|
wholeFile: false,
|
|
@@ -73,16 +78,16 @@ export function snapshotTrackedFiles(inputs) {
|
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
80
|
const tracked = [];
|
|
76
|
-
for (const [
|
|
81
|
+
for (const [storedPath, { absolutePath, mtime, lineRanges, wholeFile }] of byPath) {
|
|
77
82
|
if (!wholeFile && lineRanges.length > 0 && fs.existsSync(absolutePath)) {
|
|
78
83
|
const sections = lineRanges
|
|
79
84
|
.filter(({ start, end }) => start <= end)
|
|
80
85
|
.map(({ start, end }) => snapshotSection(absolutePath, start, end));
|
|
81
|
-
tracked.push({ path:
|
|
86
|
+
tracked.push({ path: storedPath, lastModifiedAt: mtime, sections });
|
|
82
87
|
}
|
|
83
88
|
else {
|
|
84
89
|
const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
|
|
85
|
-
tracked.push({ path:
|
|
90
|
+
tracked.push({ path: storedPath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
return tracked;
|
|
@@ -93,10 +98,10 @@ export function snapshotTrackedFiles(inputs) {
|
|
|
93
98
|
* - If a file is already tracked with sections and the new entry also has sections: sections are merged.
|
|
94
99
|
* - Otherwise (whole-file tracking involved): the existing entry is replaced.
|
|
95
100
|
*/
|
|
96
|
-
export function mergeTrackedFiles(existing, additions) {
|
|
101
|
+
export function mergeTrackedFiles(existing, additions, project) {
|
|
97
102
|
if (additions.length === 0)
|
|
98
103
|
return existing;
|
|
99
|
-
const snapshotted = snapshotTrackedFiles(additions);
|
|
104
|
+
const snapshotted = snapshotTrackedFiles(additions, project);
|
|
100
105
|
const result = [...existing];
|
|
101
106
|
for (const newTf of snapshotted) {
|
|
102
107
|
const idx = result.findIndex(tf => tf.path === newTf.path);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** Test-only: clears the memoised env snapshot so a changed env is re-read. */
|
|
2
|
+
export declare function resetCodeRootsCache(): void;
|
|
3
|
+
/** The configured code root for a project, or undefined if none applies. */
|
|
4
|
+
export declare function getCodeRoot(project: string): string | undefined;
|
|
5
|
+
/**
|
|
6
|
+
* Converts an input path (typically the absolute path supplied by an agent)
|
|
7
|
+
* into the portable form persisted in a doc.
|
|
8
|
+
*
|
|
9
|
+
* Falls back to an absolute path — with a warning — when there is no code root,
|
|
10
|
+
* or when the file lives outside the root (including a different Windows drive).
|
|
11
|
+
*/
|
|
12
|
+
export declare function toStoredPath(inputPath: string, project: string): string;
|
|
13
|
+
/**
|
|
14
|
+
* Resolves a stored path to an absolute filesystem path on this machine.
|
|
15
|
+
*
|
|
16
|
+
* - absolute stored path (legacy / fallback) → returned as-is
|
|
17
|
+
* - relative stored path + configured root → joined with the root
|
|
18
|
+
* - relative stored path + no root configured → undefined (unverifiable here)
|
|
19
|
+
*/
|
|
20
|
+
export declare function toAbsolutePath(stored: string, project: string): string | undefined;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { createLogger } from "@imenam/mcp-gui-interface";
|
|
3
|
+
const logger = createLogger("PathResolver");
|
|
4
|
+
let cached;
|
|
5
|
+
function loadCodeRoots() {
|
|
6
|
+
if (cached)
|
|
7
|
+
return cached;
|
|
8
|
+
const defaultRoot = process.env.MCP_DOCUMENTOR_CODE_ROOT?.trim() || undefined;
|
|
9
|
+
let byProject = {};
|
|
10
|
+
const rawMap = process.env.MCP_DOCUMENTOR_CODE_ROOTS?.trim();
|
|
11
|
+
if (rawMap) {
|
|
12
|
+
try {
|
|
13
|
+
const parsed = JSON.parse(rawMap);
|
|
14
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
15
|
+
for (const [proj, root] of Object.entries(parsed)) {
|
|
16
|
+
if (typeof root === "string" && root.trim() !== "") {
|
|
17
|
+
byProject[proj] = root.trim();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
logger.warn("MCP_DOCUMENTOR_CODE_ROOTS is not a JSON object, ignoring it");
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
logger.warn("Failed to parse MCP_DOCUMENTOR_CODE_ROOTS as JSON, ignoring it");
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
cached = { default: defaultRoot, byProject };
|
|
30
|
+
return cached;
|
|
31
|
+
}
|
|
32
|
+
/** Test-only: clears the memoised env snapshot so a changed env is re-read. */
|
|
33
|
+
export function resetCodeRootsCache() {
|
|
34
|
+
cached = undefined;
|
|
35
|
+
}
|
|
36
|
+
/** The configured code root for a project, or undefined if none applies. */
|
|
37
|
+
export function getCodeRoot(project) {
|
|
38
|
+
const roots = loadCodeRoots();
|
|
39
|
+
return roots.byProject[project] ?? roots.default;
|
|
40
|
+
}
|
|
41
|
+
function toForwardSlashes(p) {
|
|
42
|
+
return p.split(path.sep).join("/");
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Converts an input path (typically the absolute path supplied by an agent)
|
|
46
|
+
* into the portable form persisted in a doc.
|
|
47
|
+
*
|
|
48
|
+
* Falls back to an absolute path — with a warning — when there is no code root,
|
|
49
|
+
* or when the file lives outside the root (including a different Windows drive).
|
|
50
|
+
*/
|
|
51
|
+
export function toStoredPath(inputPath, project) {
|
|
52
|
+
const abs = path.resolve(process.cwd(), inputPath);
|
|
53
|
+
const root = getCodeRoot(project);
|
|
54
|
+
if (!root) {
|
|
55
|
+
return toForwardSlashes(abs);
|
|
56
|
+
}
|
|
57
|
+
const rel = path.relative(path.resolve(root), abs);
|
|
58
|
+
// `rel` escaping the root ("../…") or being absolute (different drive on
|
|
59
|
+
// Windows) means the file is not under the root — keep it absolute.
|
|
60
|
+
if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
61
|
+
logger.warn(`tracked file "${abs}" is outside the code root for project "${project}" (${root}); storing as an absolute path`);
|
|
62
|
+
return toForwardSlashes(abs);
|
|
63
|
+
}
|
|
64
|
+
return toForwardSlashes(rel);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Resolves a stored path to an absolute filesystem path on this machine.
|
|
68
|
+
*
|
|
69
|
+
* - absolute stored path (legacy / fallback) → returned as-is
|
|
70
|
+
* - relative stored path + configured root → joined with the root
|
|
71
|
+
* - relative stored path + no root configured → undefined (unverifiable here)
|
|
72
|
+
*/
|
|
73
|
+
export function toAbsolutePath(stored, project) {
|
|
74
|
+
if (path.isAbsolute(stored)) {
|
|
75
|
+
return stored;
|
|
76
|
+
}
|
|
77
|
+
const root = getCodeRoot(project);
|
|
78
|
+
if (!root) {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
return path.resolve(root, stored);
|
|
82
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { updateDoc } from "./tools/update-doc.js";
|
|
|
18
18
|
import { searchDocs } from "./tools/search-docs.js";
|
|
19
19
|
import { checkFreshness, computeFreshness } from "./tools/check-freshness.js";
|
|
20
20
|
import { findAndHashSection } from "./core/section-tracker.js";
|
|
21
|
+
import { toAbsolutePath } from "./core/path-resolver.js";
|
|
21
22
|
import { setProject } from "./tools/set-project.js";
|
|
22
23
|
import { listProjects } from "./tools/list-projects.js";
|
|
23
24
|
import { refreshFileSnapshot } from "./tools/refresh-file-snapshot.js";
|
|
@@ -158,7 +159,7 @@ async function handleGetInitialState(msg) {
|
|
|
158
159
|
for (const doc of docs) {
|
|
159
160
|
const full = await storageEngine.getDoc(doc.id); // doc.id is now the full path
|
|
160
161
|
if (full && full.tracked_files.length > 0) {
|
|
161
|
-
const report = computeFreshness(full.tracked_files);
|
|
162
|
+
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
162
163
|
doc.isStale = report.status === "stale";
|
|
163
164
|
}
|
|
164
165
|
}
|
|
@@ -196,7 +197,7 @@ async function handleUpdateProject(msg) {
|
|
|
196
197
|
for (const doc of docs) {
|
|
197
198
|
const full = await storageEngine.getDoc(doc.id);
|
|
198
199
|
if (full && full.tracked_files.length > 0) {
|
|
199
|
-
const report = computeFreshness(full.tracked_files);
|
|
200
|
+
const report = computeFreshness(full.tracked_files, project);
|
|
200
201
|
doc.isStale = report.status === "stale";
|
|
201
202
|
}
|
|
202
203
|
}
|
|
@@ -232,11 +233,12 @@ async function handleListProjects(msg) {
|
|
|
232
233
|
}
|
|
233
234
|
async function handleListDocs(msg) {
|
|
234
235
|
try {
|
|
236
|
+
const currentProject = storageEngine.getConfig().currentProject;
|
|
235
237
|
const docs = await storageEngine.listDocs();
|
|
236
238
|
for (const doc of docs) {
|
|
237
239
|
const full = await storageEngine.getDoc(doc.id);
|
|
238
240
|
if (full && full.tracked_files.length > 0) {
|
|
239
|
-
const report = computeFreshness(full.tracked_files);
|
|
241
|
+
const report = computeFreshness(full.tracked_files, currentProject);
|
|
240
242
|
doc.isStale = report.status === "stale";
|
|
241
243
|
}
|
|
242
244
|
}
|
|
@@ -344,15 +346,15 @@ async function handleCreateDocIPC(msg) {
|
|
|
344
346
|
data: { success: true, id },
|
|
345
347
|
timestamp: new Date().toISOString(),
|
|
346
348
|
});
|
|
349
|
+
const config = storageEngine.getConfig();
|
|
347
350
|
const docs = await storageEngine.listDocs();
|
|
348
351
|
for (const doc of docs) {
|
|
349
352
|
const full = await storageEngine.getDoc(doc.id);
|
|
350
353
|
if (full && full.tracked_files.length > 0) {
|
|
351
|
-
const report = computeFreshness(full.tracked_files);
|
|
354
|
+
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
352
355
|
doc.isStale = report.status === "stale";
|
|
353
356
|
}
|
|
354
357
|
}
|
|
355
|
-
const config = storageEngine.getConfig();
|
|
356
358
|
broadcastToGUI({
|
|
357
359
|
type: "INITIAL_STATE",
|
|
358
360
|
data: { config, docs, projects: config.projects },
|
|
@@ -420,7 +422,9 @@ async function handleGetFileSection(msg) {
|
|
|
420
422
|
const trackedFile = resolved.doc.tracked_files.find((f) => f.path === filePath);
|
|
421
423
|
if (!trackedFile || !trackedFile.sections?.length)
|
|
422
424
|
throw new Error("No sections for this file");
|
|
423
|
-
const absolutePath =
|
|
425
|
+
const absolutePath = toAbsolutePath(trackedFile.path, resolved.project);
|
|
426
|
+
if (!absolutePath)
|
|
427
|
+
throw new Error("Cannot resolve file on this machine (no code root configured)");
|
|
424
428
|
const fileLines = fs.readFileSync(absolutePath, "utf-8").split("\n");
|
|
425
429
|
const sections = trackedFile.sections.map((section) => {
|
|
426
430
|
const result = findAndHashSection(absolutePath, section);
|
|
@@ -462,7 +466,7 @@ async function handleCheckFreshness(msg) {
|
|
|
462
466
|
});
|
|
463
467
|
return;
|
|
464
468
|
}
|
|
465
|
-
const report = computeFreshness(doc.tracked_files);
|
|
469
|
+
const report = computeFreshness(doc.tracked_files, resolved.project);
|
|
466
470
|
broadcastToGUI({
|
|
467
471
|
type: "CHECK_FRESHNESS_RESPONSE",
|
|
468
472
|
correlationId: msg.correlationId,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { TrackedFile, FreshnessReport } from "../../shared/schemas/doc.schema.js";
|
|
2
2
|
import type { StorageEngine } from "../core/storage-engine.js";
|
|
3
3
|
export declare function computeFileHash(absolutePath: string): string;
|
|
4
|
-
export declare function computeFreshness(trackedFiles: TrackedFile[]): FreshnessReport;
|
|
4
|
+
export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): FreshnessReport;
|
|
5
5
|
export declare function formatFreshnessReport(title: string, report: FreshnessReport): string;
|
|
6
6
|
export declare function checkFreshness(storageEngine: StorageEngine, args: unknown): Promise<{
|
|
7
7
|
isError: boolean;
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
2
|
import fs from "fs";
|
|
3
|
-
import path from "path";
|
|
4
3
|
import { CheckFreshnessArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
5
4
|
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
|
+
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
6
6
|
export function computeFileHash(absolutePath) {
|
|
7
7
|
return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
|
|
8
8
|
}
|
|
9
|
-
export function computeFreshness(trackedFiles) {
|
|
9
|
+
export function computeFreshness(trackedFiles, project) {
|
|
10
10
|
if (!trackedFiles || trackedFiles.length === 0) {
|
|
11
11
|
return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], total: 0 };
|
|
12
12
|
}
|
|
@@ -15,8 +15,11 @@ export function computeFreshness(trackedFiles) {
|
|
|
15
15
|
const unchanged = [];
|
|
16
16
|
const anchors_lost = [];
|
|
17
17
|
for (const tracked of trackedFiles) {
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
// A relative path with no code root configured on this machine is not
|
|
19
|
+
// resolvable here — count it as absent so we end up "unverifiable" rather
|
|
20
|
+
// than crying wolf with "stale".
|
|
21
|
+
const absolutePath = toAbsolutePath(tracked.path, project);
|
|
22
|
+
if (!absolutePath || !fs.existsSync(absolutePath)) {
|
|
20
23
|
missing.push(tracked.path);
|
|
21
24
|
continue;
|
|
22
25
|
}
|
|
@@ -68,6 +71,15 @@ export function computeFreshness(trackedFiles) {
|
|
|
68
71
|
}
|
|
69
72
|
}
|
|
70
73
|
}
|
|
74
|
+
// When every tracked file is absent, the source code simply isn't present on
|
|
75
|
+
// this machine (e.g. docs cloned without the codebase) — we can't verify
|
|
76
|
+
// freshness, so report "unverifiable" rather than crying wolf with "stale".
|
|
77
|
+
// A partial absence (some files missing, others present) is treated as a real
|
|
78
|
+
// change signal (likely deletions) and still counts as stale.
|
|
79
|
+
const allMissing = missing.length === trackedFiles.length;
|
|
80
|
+
if (allMissing) {
|
|
81
|
+
return { status: "unverifiable", modified, missing, unchanged, anchors_lost, total: trackedFiles.length };
|
|
82
|
+
}
|
|
71
83
|
const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
|
|
72
84
|
return {
|
|
73
85
|
status: isStale ? "stale" : "up_to_date",
|
|
@@ -82,6 +94,9 @@ export function formatFreshnessReport(title, report) {
|
|
|
82
94
|
if (report.status === "no_tracking") {
|
|
83
95
|
return `No tracked files for "${title}". Use tracked_files when creating or updating the doc to enable staleness detection.`;
|
|
84
96
|
}
|
|
97
|
+
if (report.status === "unverifiable") {
|
|
98
|
+
return `UNVERIFIABLE: "${title}" — none of the ${report.total} tracked file(s) are present on this machine, so freshness cannot be checked here. This is expected when the documentation is cloned without its source codebase.`;
|
|
99
|
+
}
|
|
85
100
|
let text = report.status === "stale"
|
|
86
101
|
? `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`
|
|
87
102
|
: `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
|
|
@@ -100,6 +115,15 @@ export function formatFreshnessReport(title, report) {
|
|
|
100
115
|
}
|
|
101
116
|
return text;
|
|
102
117
|
}
|
|
118
|
+
/** Cap on how many "not tracking any file" doc titles are listed before collapsing into a count. */
|
|
119
|
+
const UNTRACKED_LIST_LIMIT = 10;
|
|
120
|
+
/** Renders the untracked-docs list, truncated past UNTRACKED_LIST_LIMIT to avoid flooding the context. */
|
|
121
|
+
function renderUntrackedList(titles, heading) {
|
|
122
|
+
const shown = titles.slice(0, UNTRACKED_LIST_LIMIT).map((t) => ` - ${t}`);
|
|
123
|
+
const remaining = titles.length - UNTRACKED_LIST_LIMIT;
|
|
124
|
+
const suffix = remaining > 0 ? `\n … and ${remaining} more` : "";
|
|
125
|
+
return `\n\n${heading}\n${shown.join("\n")}${suffix}`;
|
|
126
|
+
}
|
|
103
127
|
export async function checkFreshness(storageEngine, args) {
|
|
104
128
|
try {
|
|
105
129
|
const validated = CheckFreshnessArgsSchema.parse(args);
|
|
@@ -108,8 +132,8 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
108
132
|
if (!resolved) {
|
|
109
133
|
return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
|
|
110
134
|
}
|
|
111
|
-
const { doc } = resolved;
|
|
112
|
-
const report = computeFreshness(doc.tracked_files);
|
|
135
|
+
const { doc, project } = resolved;
|
|
136
|
+
const report = computeFreshness(doc.tracked_files, project);
|
|
113
137
|
return { content: [{ type: "text", text: formatFreshnessReport(doc.title, report) }] };
|
|
114
138
|
}
|
|
115
139
|
if (validated.project) {
|
|
@@ -120,14 +144,18 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
120
144
|
const stale = [];
|
|
121
145
|
const noTracking = [];
|
|
122
146
|
let upToDate = 0;
|
|
147
|
+
let unverifiable = 0;
|
|
123
148
|
for (const summary of summaries) {
|
|
124
149
|
const doc = await storageEngine.getDoc(summary.id, validated.project);
|
|
125
150
|
if (!doc)
|
|
126
151
|
continue;
|
|
127
|
-
const report = computeFreshness(doc.tracked_files);
|
|
152
|
+
const report = computeFreshness(doc.tracked_files, validated.project);
|
|
128
153
|
if (report.status === "no_tracking") {
|
|
129
154
|
noTracking.push(doc.title);
|
|
130
155
|
}
|
|
156
|
+
else if (report.status === "unverifiable") {
|
|
157
|
+
unverifiable++;
|
|
158
|
+
}
|
|
131
159
|
else if (report.status === "stale") {
|
|
132
160
|
const details = [
|
|
133
161
|
...report.modified.map((f) => ` • modified: ${f}`),
|
|
@@ -141,12 +169,12 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
141
169
|
}
|
|
142
170
|
}
|
|
143
171
|
let text = `# Freshness report — project "${validated.project}"\n\n`;
|
|
144
|
-
text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length}
|
|
172
|
+
text += `${summaries.length} doc(s) scanned: **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.\n`;
|
|
145
173
|
if (stale.length > 0) {
|
|
146
174
|
text += `\n## Stale docs\n\n${stale.join("\n\n")}`;
|
|
147
175
|
}
|
|
148
176
|
if (noTracking.length > 0) {
|
|
149
|
-
text +=
|
|
177
|
+
text += renderUntrackedList(noTracking, "## Docs not tracking any file (no staleness detection)");
|
|
150
178
|
}
|
|
151
179
|
return { content: [{ type: "text", text }] };
|
|
152
180
|
}
|
|
@@ -161,14 +189,18 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
161
189
|
const stale = [];
|
|
162
190
|
const noTracking = [];
|
|
163
191
|
let upToDate = 0;
|
|
192
|
+
let unverifiable = 0;
|
|
164
193
|
for (const summary of summaries) {
|
|
165
194
|
const doc = await storageEngine.getDoc(summary.id, proj);
|
|
166
195
|
if (!doc)
|
|
167
196
|
continue;
|
|
168
|
-
const report = computeFreshness(doc.tracked_files);
|
|
197
|
+
const report = computeFreshness(doc.tracked_files, proj);
|
|
169
198
|
if (report.status === "no_tracking") {
|
|
170
199
|
noTracking.push(doc.title);
|
|
171
200
|
}
|
|
201
|
+
else if (report.status === "unverifiable") {
|
|
202
|
+
unverifiable++;
|
|
203
|
+
}
|
|
172
204
|
else if (report.status === "stale") {
|
|
173
205
|
const details = [
|
|
174
206
|
...report.modified.map((f) => ` • modified: ${f}`),
|
|
@@ -184,12 +216,12 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
184
216
|
totalDocs += summaries.length;
|
|
185
217
|
totalStale += stale.length;
|
|
186
218
|
let block = `## ${proj}\n\n`;
|
|
187
|
-
block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${noTracking.length}
|
|
219
|
+
block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.`;
|
|
188
220
|
if (stale.length > 0) {
|
|
189
221
|
block += `\n\n### Stale docs\n\n${stale.join("\n\n")}`;
|
|
190
222
|
}
|
|
191
223
|
if (noTracking.length > 0) {
|
|
192
|
-
block +=
|
|
224
|
+
block += renderUntrackedList(noTracking, "### Docs not tracking any file");
|
|
193
225
|
}
|
|
194
226
|
projectBlocks.push(block);
|
|
195
227
|
}
|
|
@@ -31,11 +31,12 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
|
|
|
31
31
|
if (project) {
|
|
32
32
|
storageEngine.ensureProject(project);
|
|
33
33
|
}
|
|
34
|
+
const resolvedProject = project ?? storageEngine.getConfig().currentProject;
|
|
34
35
|
const allTrackedInputs = [
|
|
35
36
|
...(validated.tracked_files ?? []),
|
|
36
37
|
...additionalTrackedFiles,
|
|
37
38
|
];
|
|
38
|
-
const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs) : [];
|
|
39
|
+
const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs, resolvedProject) : [];
|
|
39
40
|
const folder = validated.folder;
|
|
40
41
|
const doc = {
|
|
41
42
|
id,
|
|
@@ -48,7 +49,6 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
|
|
|
48
49
|
createdAt: now,
|
|
49
50
|
updatedAt: now,
|
|
50
51
|
};
|
|
51
|
-
const resolvedProject = project ?? storageEngine.getConfig().currentProject;
|
|
52
52
|
await storageEngine.saveDoc(doc, resolvedProject, "create");
|
|
53
53
|
if (embeddingEngine.isConfigured()) {
|
|
54
54
|
const embedding = await embeddingEngine.generateEmbedding(content);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import { GetDocMetadataArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
2
3
|
import { computeFreshness } from "./check-freshness.js";
|
|
3
4
|
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
|
+
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
4
6
|
export async function getDocMetadata(storageEngine, args) {
|
|
5
7
|
try {
|
|
6
8
|
const validated = GetDocMetadataArgsSchema.parse(args);
|
|
@@ -8,8 +10,8 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
8
10
|
if (!resolved) {
|
|
9
11
|
return { isError: true, content: [{ type: "text", text: `Documentation entry "${validated.path}" not found in any project.` }] };
|
|
10
12
|
}
|
|
11
|
-
const { doc } = resolved;
|
|
12
|
-
const report = computeFreshness(doc.tracked_files);
|
|
13
|
+
const { doc, project } = resolved;
|
|
14
|
+
const report = computeFreshness(doc.tracked_files, project);
|
|
13
15
|
const fileStatusIcon = (filePath) => {
|
|
14
16
|
if (report.modified.includes(filePath))
|
|
15
17
|
return "✗ modified";
|
|
@@ -28,8 +30,10 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
28
30
|
text += doc.tracked_files.map((f) => {
|
|
29
31
|
let entry = ` ${fileStatusIcon(f.path)} — ${f.path}\n File mtime: ${f.lastModifiedAt}`;
|
|
30
32
|
if (f.sections && f.sections.length > 0) {
|
|
33
|
+
const abs = toAbsolutePath(f.path, project);
|
|
34
|
+
const readable = abs && fs.existsSync(abs);
|
|
31
35
|
const sectionLines = f.sections.map((section, idx) => {
|
|
32
|
-
const result = findAndHashSection(
|
|
36
|
+
const result = readable ? findAndHashSection(abs, section) : { found: false };
|
|
33
37
|
const location = result.found
|
|
34
38
|
? `lines ${result.lines.start}–${result.lines.end}`
|
|
35
39
|
: "section introuvable (ancres modifiées)";
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import fs from "fs";
|
|
1
2
|
import { GetDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
2
3
|
import { computeFreshness } from "./check-freshness.js";
|
|
3
4
|
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
|
+
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
4
6
|
export async function getDoc(storageEngine, args) {
|
|
5
7
|
try {
|
|
6
8
|
const validated = GetDocArgsSchema.parse(args);
|
|
@@ -12,8 +14,10 @@ export async function getDoc(storageEngine, args) {
|
|
|
12
14
|
const trackedFilesText = doc.tracked_files.length > 0
|
|
13
15
|
? doc.tracked_files.map((f) => {
|
|
14
16
|
if (f.sections && f.sections.length > 0) {
|
|
17
|
+
const abs = toAbsolutePath(f.path, resolvedProject);
|
|
18
|
+
const readable = abs && fs.existsSync(abs);
|
|
15
19
|
const sectionLines = f.sections.map((section, idx) => {
|
|
16
|
-
const result = findAndHashSection(
|
|
20
|
+
const result = readable ? findAndHashSection(abs, section) : { found: false };
|
|
17
21
|
const location = result.found
|
|
18
22
|
? `lines ${result.lines.start}–${result.lines.end}`
|
|
19
23
|
: "section introuvable";
|
|
@@ -24,7 +28,7 @@ export async function getDoc(storageEngine, args) {
|
|
|
24
28
|
return ` - ${f.path} (snapshot: ${f.lastModifiedAt})`;
|
|
25
29
|
}).join("\n")
|
|
26
30
|
: " (none)";
|
|
27
|
-
const freshnessReport = computeFreshness(doc.tracked_files);
|
|
31
|
+
const freshnessReport = computeFreshness(doc.tracked_files, resolvedProject);
|
|
28
32
|
const stalenessBanner = freshnessReport.status === "stale"
|
|
29
33
|
? [
|
|
30
34
|
`> [!WARNING]`,
|
|
@@ -2,8 +2,12 @@ import { execSync } from "child_process";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { GitDiffDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
4
4
|
import { computeFreshness } from "./check-freshness.js";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
|
|
6
|
+
function getGitDiffForFile(tracked, project) {
|
|
7
|
+
const filePath = toAbsolutePath(tracked.path, project);
|
|
8
|
+
if (!filePath) {
|
|
9
|
+
return ` [INFO] Cannot resolve "${tracked.path}" on this machine (no code root configured) — source absent.`;
|
|
10
|
+
}
|
|
7
11
|
const fileDir = path.dirname(filePath);
|
|
8
12
|
let repoRoot;
|
|
9
13
|
try {
|
|
@@ -46,17 +50,18 @@ export async function gitDiffDoc(storageEngine, args) {
|
|
|
46
50
|
docSections.push(`## ${docPath}\n\n[ERROR] Documentation entry not found in any project.`);
|
|
47
51
|
continue;
|
|
48
52
|
}
|
|
49
|
-
const { doc } = resolved;
|
|
53
|
+
const { doc, project } = resolved;
|
|
50
54
|
if (!doc.tracked_files || doc.tracked_files.length === 0) {
|
|
51
55
|
docSections.push(`## ${doc.title}\n\n[INFO] No tracked files.`);
|
|
52
56
|
continue;
|
|
53
57
|
}
|
|
54
58
|
let filesToDiff;
|
|
55
59
|
if (validated.file_paths) {
|
|
56
|
-
|
|
60
|
+
const wanted = new Set(validated.file_paths.map((p) => toStoredPath(p, project)));
|
|
61
|
+
filesToDiff = doc.tracked_files.filter((tf) => wanted.has(tf.path));
|
|
57
62
|
}
|
|
58
63
|
else {
|
|
59
|
-
const freshness = computeFreshness(doc.tracked_files);
|
|
64
|
+
const freshness = computeFreshness(doc.tracked_files, project);
|
|
60
65
|
const changedPaths = new Set([...freshness.modified, ...freshness.missing]);
|
|
61
66
|
filesToDiff = doc.tracked_files.filter((tf) => changedPaths.has(tf.path));
|
|
62
67
|
}
|
|
@@ -75,7 +80,7 @@ export async function gitDiffDoc(storageEngine, args) {
|
|
|
75
80
|
return true;
|
|
76
81
|
})
|
|
77
82
|
.map((tf) => {
|
|
78
|
-
const diff = getGitDiffForFile(tf);
|
|
83
|
+
const diff = getGitDiffForFile(tf, project);
|
|
79
84
|
return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}\n\n${diff}`;
|
|
80
85
|
});
|
|
81
86
|
if (fileSections.length === 0) {
|
|
@@ -41,7 +41,7 @@ export async function listDocs(storageEngine, args) {
|
|
|
41
41
|
for (const summary of summaries) {
|
|
42
42
|
const doc = await storageEngine.getDoc(summary.id, proj);
|
|
43
43
|
if (doc && doc.tracked_files.length > 0) {
|
|
44
|
-
const report = computeFreshness(doc.tracked_files);
|
|
44
|
+
const report = computeFreshness(doc.tracked_files, proj);
|
|
45
45
|
summary.isStale = report.status === "stale";
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -59,7 +59,7 @@ export async function listDocs(storageEngine, args) {
|
|
|
59
59
|
for (const summary of summaries) {
|
|
60
60
|
const doc = await storageEngine.getDoc(summary.id, proj);
|
|
61
61
|
if (doc && doc.tracked_files.length > 0) {
|
|
62
|
-
const report = computeFreshness(doc.tracked_files);
|
|
62
|
+
const report = computeFreshness(doc.tracked_files, proj);
|
|
63
63
|
summary.isStale = report.status === "stale";
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -28,7 +28,7 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
|
|
|
28
28
|
};
|
|
29
29
|
}
|
|
30
30
|
doc.content = doc.content.replace(search, replacement);
|
|
31
|
-
doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
|
|
31
|
+
doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
|
|
32
32
|
doc.updatedAt = new Date().toISOString();
|
|
33
33
|
await storageEngine.saveDoc(doc, resolvedProject, "patch");
|
|
34
34
|
if (embeddingEngine.isConfigured()) {
|
|
@@ -2,6 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import { RefreshFileSnapshotArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
3
3
|
import { computeFileHash } from "./check-freshness.js";
|
|
4
4
|
import { relocateAndRefreshSection } from "../core/section-tracker.js";
|
|
5
|
+
import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
|
|
5
6
|
export async function refreshFileSnapshot(storageEngine, historyStore, args) {
|
|
6
7
|
try {
|
|
7
8
|
const validated = RefreshFileSnapshotArgsSchema.parse(args);
|
|
@@ -17,24 +18,26 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
|
|
|
17
18
|
const missing = [];
|
|
18
19
|
const skipped = [];
|
|
19
20
|
const anchorsLost = [];
|
|
20
|
-
// Normalize to forward slashes and lowercase for case-insensitive comparison
|
|
21
|
-
//
|
|
21
|
+
// Normalize to forward slashes and lowercase for case-insensitive comparison
|
|
22
|
+
// on Windows. Agent-supplied file_paths are absolute; map them to the stored
|
|
23
|
+
// (portable) form so they match tracked.path regardless of representation.
|
|
22
24
|
const norm = (p) => p.replace(/\\/g, "/").toLowerCase();
|
|
23
|
-
const resolvedFilePaths = validated.file_paths?.map(norm);
|
|
25
|
+
const resolvedFilePaths = validated.file_paths?.map((p) => norm(toStoredPath(p, resolvedProject)));
|
|
24
26
|
for (const tracked of doc.tracked_files) {
|
|
25
27
|
if (resolvedFilePaths && !resolvedFilePaths.includes(norm(tracked.path))) {
|
|
26
28
|
skipped.push(tracked.path);
|
|
27
29
|
continue;
|
|
28
30
|
}
|
|
29
|
-
|
|
31
|
+
const absolutePath = toAbsolutePath(tracked.path, resolvedProject);
|
|
32
|
+
if (!absolutePath || !fs.existsSync(absolutePath)) {
|
|
30
33
|
missing.push(tracked.path);
|
|
31
34
|
continue;
|
|
32
35
|
}
|
|
33
|
-
tracked.lastModifiedAt = fs.statSync(
|
|
36
|
+
tracked.lastModifiedAt = fs.statSync(absolutePath).mtime.toISOString();
|
|
34
37
|
if (tracked.sections && tracked.sections.length > 0) {
|
|
35
38
|
let hasLostAnchors = false;
|
|
36
39
|
const refreshedSections = tracked.sections.map((section) => {
|
|
37
|
-
const result = relocateAndRefreshSection(
|
|
40
|
+
const result = relocateAndRefreshSection(absolutePath, section);
|
|
38
41
|
if (!result.found)
|
|
39
42
|
hasLostAnchors = true;
|
|
40
43
|
return result.found ? result.refreshed : section;
|
|
@@ -48,7 +51,7 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
|
|
|
48
51
|
}
|
|
49
52
|
}
|
|
50
53
|
else {
|
|
51
|
-
tracked.contentHash = computeFileHash(
|
|
54
|
+
tracked.contentHash = computeFileHash(absolutePath);
|
|
52
55
|
refreshed.push(tracked.path);
|
|
53
56
|
}
|
|
54
57
|
}
|
|
@@ -28,10 +28,10 @@ export async function updateDoc(storageEngine, embeddingEngine, args) {
|
|
|
28
28
|
doc.tracked_files = snapshotTrackedFiles([
|
|
29
29
|
...validated.tracked_files,
|
|
30
30
|
...additionalTrackedFiles,
|
|
31
|
-
]);
|
|
31
|
+
], resolvedProject);
|
|
32
32
|
}
|
|
33
33
|
else {
|
|
34
|
-
doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles);
|
|
34
|
+
doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
|
|
35
35
|
}
|
|
36
36
|
if (validated.tags !== undefined) {
|
|
37
37
|
doc.tags = validated.tags;
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.58";
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated version file - do not edit manually
|
|
2
|
-
export const APP_VERSION = '1.0.
|
|
2
|
+
export const APP_VERSION = '1.0.58';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@imenam/mcp-documentor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.58",
|
|
4
4
|
"main": "dist/src/index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "echo \"Error: no test specified\" && exit 1",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
"type": "module",
|
|
42
42
|
"bin": {
|
|
43
43
|
"mcp-documentor": "dist/src/index.js",
|
|
44
|
-
"mcp-documentor-migrate": "scripts/migrate-links.js"
|
|
44
|
+
"mcp-documentor-migrate": "scripts/migrate-links.js",
|
|
45
|
+
"mcp-documentor-migrate-paths": "scripts/migrate-paths-to-relative.js"
|
|
45
46
|
},
|
|
46
47
|
"files": [
|
|
47
48
|
"dist",
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* migrate-paths-to-relative.js
|
|
4
|
+
*
|
|
5
|
+
* One-shot migration that rewrites the absolute paths stored in each doc's
|
|
6
|
+
* `tracked_files[].path` into the portable, code-root-relative form introduced
|
|
7
|
+
* alongside GitHub documentation sync. Portable paths let an agent running on a
|
|
8
|
+
* different machine (where the codebase lives at a different absolute location)
|
|
9
|
+
* resolve tracked files correctly.
|
|
10
|
+
*
|
|
11
|
+
* Resolution reuses the compiled path-resolver (dist/) so it behaves exactly
|
|
12
|
+
* like the running server — RUN `npm run build` FIRST.
|
|
13
|
+
*
|
|
14
|
+
* The conversion is driven by the same environment variables as the server:
|
|
15
|
+
* MCP_DOCUMENTOR_CODE_ROOT default code root
|
|
16
|
+
* MCP_DOCUMENTOR_CODE_ROOTS JSON map of per-project overrides
|
|
17
|
+
*
|
|
18
|
+
* A tracked file whose absolute path is NOT under the project's code root is
|
|
19
|
+
* left absolute and reported, so nothing is silently broken.
|
|
20
|
+
*
|
|
21
|
+
* The data dir is resolved like the server: --data-dir flag, else MCP_DATA_DIR,
|
|
22
|
+
* else <package>/data. The MCP_DATA_DIR fallback is what makes npx usable.
|
|
23
|
+
*
|
|
24
|
+
* Usage (from a clone):
|
|
25
|
+
* node scripts/migrate-paths-to-relative.js [--dry-run] [--data-dir <dir>]
|
|
26
|
+
*
|
|
27
|
+
* Usage (npx install — no clone):
|
|
28
|
+
* MCP_DOCUMENTOR_CODE_ROOT="<repos root>" MCP_DATA_DIR="<your data dir>" \
|
|
29
|
+
* npx -p @imenam/mcp-documentor mcp-documentor-migrate-paths --dry-run
|
|
30
|
+
*
|
|
31
|
+
* This migration is a ONE-TIME operation: run it once on the machine where the
|
|
32
|
+
* stored absolute paths are valid, publish the result (sync_docs / push_docs),
|
|
33
|
+
* and every other machine just pulls and sets its own MCP_DOCUMENTOR_CODE_ROOT.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import fs from "fs";
|
|
37
|
+
import path from "path";
|
|
38
|
+
import { fileURLToPath, pathToFileURL } from "url";
|
|
39
|
+
import matter from "gray-matter";
|
|
40
|
+
|
|
41
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
42
|
+
const rootDir = path.join(__dirname, "..");
|
|
43
|
+
|
|
44
|
+
const args = process.argv.slice(2);
|
|
45
|
+
const dryRun = args.includes("--dry-run");
|
|
46
|
+
|
|
47
|
+
// Data dir resolution mirrors the server (StorageEngine.getDataDir):
|
|
48
|
+
// --data-dir flag > MCP_DATA_DIR env > <package>/data fallback.
|
|
49
|
+
// The env fallback is what makes `npx` work: the data lives in the user's
|
|
50
|
+
// MCP_DATA_DIR, not inside the npx package cache.
|
|
51
|
+
const dataDirArgIdx = args.indexOf("--data-dir");
|
|
52
|
+
const dataDir =
|
|
53
|
+
dataDirArgIdx !== -1 && args[dataDirArgIdx + 1]
|
|
54
|
+
? args[dataDirArgIdx + 1]
|
|
55
|
+
: process.env.MCP_DATA_DIR && process.env.MCP_DATA_DIR.trim() !== ""
|
|
56
|
+
? process.env.MCP_DATA_DIR.trim()
|
|
57
|
+
: path.join(rootDir, "data");
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Reuse the compiled resolver so migration matches runtime behaviour exactly.
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
const resolverPath = path.join(rootDir, "dist", "src", "core", "path-resolver.js");
|
|
64
|
+
if (!fs.existsSync(resolverPath)) {
|
|
65
|
+
console.error(`[ERROR] Compiled resolver not found at: ${resolverPath}`);
|
|
66
|
+
console.error(" Run `npm run build` before this migration.");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
const { getCodeRoot, toStoredPath } = await import(pathToFileURL(resolverPath).href);
|
|
70
|
+
|
|
71
|
+
// ---------------------------------------------------------------------------
|
|
72
|
+
// Helpers
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
function walkMdFiles(dir) {
|
|
76
|
+
if (!fs.existsSync(dir)) return [];
|
|
77
|
+
const results = [];
|
|
78
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
79
|
+
const fullPath = path.join(dir, entry.name);
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
results.push(...walkMdFiles(fullPath));
|
|
82
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
83
|
+
results.push(fullPath);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return results;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function filePathToDocId(filePath, projectDir) {
|
|
90
|
+
return path.relative(projectDir, filePath).replace(/\.md$/, "").split(path.sep).join("/");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Load config
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const configPath = path.join(dataDir, "config.json");
|
|
98
|
+
if (!fs.existsSync(configPath)) {
|
|
99
|
+
console.error(`[ERROR] config.json not found at: ${configPath}`);
|
|
100
|
+
console.error(" Make sure --data-dir points to the right directory.");
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
|
105
|
+
const projects = config.projects ?? ["default"];
|
|
106
|
+
|
|
107
|
+
console.log(`${dryRun ? "[DRY RUN] " : ""}migrate-paths-to-relative`);
|
|
108
|
+
console.log(`Data dir : ${dataDir}`);
|
|
109
|
+
console.log(`Projects : ${projects.length}`);
|
|
110
|
+
console.log("");
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// Migrate
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
let totalDocsChanged = 0;
|
|
117
|
+
let totalConverted = 0;
|
|
118
|
+
let totalLeftAbsolute = 0;
|
|
119
|
+
const projectsWithoutRoot = [];
|
|
120
|
+
|
|
121
|
+
for (const project of projects) {
|
|
122
|
+
const root = getCodeRoot(project);
|
|
123
|
+
if (!root) {
|
|
124
|
+
projectsWithoutRoot.push(project);
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const projectDir = path.join(dataDir, "projects", project);
|
|
129
|
+
const files = walkMdFiles(projectDir);
|
|
130
|
+
if (files.length === 0) continue;
|
|
131
|
+
|
|
132
|
+
const docChanges = [];
|
|
133
|
+
|
|
134
|
+
for (const filePath of files) {
|
|
135
|
+
const fullId = filePathToDocId(filePath, projectDir);
|
|
136
|
+
const parsed = matter(fs.readFileSync(filePath, "utf-8"));
|
|
137
|
+
const tracked = parsed.data.tracked_files;
|
|
138
|
+
if (!Array.isArray(tracked) || tracked.length === 0) continue;
|
|
139
|
+
|
|
140
|
+
let docChanged = false;
|
|
141
|
+
for (const tf of tracked) {
|
|
142
|
+
if (!tf || typeof tf.path !== "string") continue;
|
|
143
|
+
if (!path.isAbsolute(tf.path)) continue; // already relative
|
|
144
|
+
|
|
145
|
+
const stored = toStoredPath(tf.path, project);
|
|
146
|
+
if (path.isAbsolute(stored)) {
|
|
147
|
+
// Outside the root (or different drive) — resolver kept it absolute.
|
|
148
|
+
totalLeftAbsolute++;
|
|
149
|
+
docChanges.push(` [${fullId}] KEPT ABSOLUTE (outside root): ${tf.path}`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
docChanges.push(` [${fullId}] "${tf.path}" → "${stored}"`);
|
|
154
|
+
tf.path = stored;
|
|
155
|
+
docChanged = true;
|
|
156
|
+
totalConverted++;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (docChanged) {
|
|
160
|
+
totalDocsChanged++;
|
|
161
|
+
if (!dryRun) {
|
|
162
|
+
fs.writeFileSync(filePath, matter.stringify(parsed.content, parsed.data), "utf-8");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (docChanges.length > 0) {
|
|
168
|
+
console.log(`### Project: ${project} (root: ${root})`);
|
|
169
|
+
docChanges.forEach((c) => console.log(c));
|
|
170
|
+
console.log("");
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ---------------------------------------------------------------------------
|
|
175
|
+
// Summary
|
|
176
|
+
// ---------------------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
console.log("─".repeat(60));
|
|
179
|
+
console.log(`Paths converted to relative : ${totalConverted}`);
|
|
180
|
+
console.log(`Paths kept absolute : ${totalLeftAbsolute} ← outside the code root`);
|
|
181
|
+
console.log(`Docs modified : ${totalDocsChanged}`);
|
|
182
|
+
if (projectsWithoutRoot.length > 0) {
|
|
183
|
+
console.log("");
|
|
184
|
+
console.log(`Projects skipped (no code root configured): ${projectsWithoutRoot.length}`);
|
|
185
|
+
console.log(` ${projectsWithoutRoot.join(", ")}`);
|
|
186
|
+
console.log(" → set MCP_DOCUMENTOR_CODE_ROOT or add them to MCP_DOCUMENTOR_CODE_ROOTS to migrate them.");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (dryRun) {
|
|
190
|
+
console.log("");
|
|
191
|
+
console.log("[DRY RUN] No files were modified. Run without --dry-run to apply.");
|
|
192
|
+
}
|