@imenam/mcp-documentor 1.0.61 → 1.0.62
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.js +2 -2
- package/dist/src/core/code-ref-resolver.d.ts +25 -4
- package/dist/src/core/code-ref-resolver.js +92 -25
- package/dist/src/index.js +34 -10
- package/dist/src/tools/check-freshness.js +69 -86
- package/dist/src/tools/update-doc.js +17 -4
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -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. Provide absolute paths
|
|
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, or paths relative to the project's configured code root (MCP_DOCUMENTOR_CODE_ROOT); paths under the root are stored relative to it so the docs stay portable across machines. Tracked files must exist."),
|
|
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. Provide absolute paths
|
|
62
|
+
tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Files already tracked with the same scope keep their stored snapshot, so this never changes staleness — only refresh_file_snapshot acknowledges file changes. 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, or paths relative to the project's configured code root (MCP_DOCUMENTOR_CODE_ROOT)."),
|
|
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
|
});
|
|
@@ -5,10 +5,31 @@ export interface CodeRefResult {
|
|
|
5
5
|
}
|
|
6
6
|
export declare function resolveCodeRefs(content: string): CodeRefResult;
|
|
7
7
|
export declare function snapshotTrackedFiles(inputs: TrackedFileInput[], project: string): TrackedFile[];
|
|
8
|
+
export interface ResnapshotResult {
|
|
9
|
+
tracked: TrackedFile[];
|
|
10
|
+
/** Files already tracked whose stored snapshot was carried over untouched. */
|
|
11
|
+
preserved: string[];
|
|
12
|
+
/** Files that were not tracked before; their snapshot is an initialisation, not a refresh. */
|
|
13
|
+
added: string[];
|
|
14
|
+
/** Files whose tracking scope changed, leaving no prior snapshot to carry over. */
|
|
15
|
+
reset: string[];
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Rebuilds a tracked-file list from `inputs`, keeping the stored snapshot of every file that
|
|
19
|
+
* was already tracked with the same scope.
|
|
20
|
+
*
|
|
21
|
+
* Re-declaring `tracked_files` states which files a doc depends on — it says nothing about whether
|
|
22
|
+
* the doc still describes them correctly. Only `refresh_file_snapshot` may acknowledge that, so a
|
|
23
|
+
* doc that was stale before this call is still stale after it. Files that were not tracked before
|
|
24
|
+
* necessarily carry a snapshot of their current content: there is no earlier state to preserve.
|
|
25
|
+
*/
|
|
26
|
+
export declare function resnapshotPreserving(existing: TrackedFile[], inputs: TrackedFileInput[], project: string): ResnapshotResult;
|
|
8
27
|
/**
|
|
9
|
-
* Merges snapshotted tracked files into an existing list
|
|
10
|
-
*
|
|
11
|
-
* - If a file is
|
|
12
|
-
* -
|
|
28
|
+
* Merges snapshotted tracked files into an existing list, without ever refreshing what is
|
|
29
|
+
* already tracked — acknowledging staleness is `refresh_file_snapshot`'s job alone.
|
|
30
|
+
* - If a file is not yet tracked: appended with a snapshot of its current content.
|
|
31
|
+
* - If a file is already tracked by section and the addition covers a region not yet tracked:
|
|
32
|
+
* that region is appended; existing sections keep their stored hash.
|
|
33
|
+
* - Otherwise the existing entry is left untouched.
|
|
13
34
|
*/
|
|
14
35
|
export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[], project: string): TrackedFile[];
|
|
@@ -2,7 +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
|
+
import { toAbsolutePath, toStoredPath } from "./path-resolver.js";
|
|
6
6
|
const EXT_TO_LANG = {
|
|
7
7
|
ts: "typescript",
|
|
8
8
|
tsx: "tsx",
|
|
@@ -53,11 +53,17 @@ export function snapshotTrackedFiles(inputs, project) {
|
|
|
53
53
|
const byPath = new Map();
|
|
54
54
|
for (const input of inputs) {
|
|
55
55
|
const fp = typeof input === "string" ? input : input.path;
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
// A relative input path means "relative to the project's code root" — the same
|
|
57
|
+
// convention used everywhere a stored path is read back. Never the server's cwd.
|
|
58
|
+
const absolutePath = toAbsolutePath(fp, project);
|
|
59
|
+
if (!absolutePath) {
|
|
60
|
+
throw new Error(`Cannot track "${fp}": the path is relative but no code root is configured for project "${project}". Provide an absolute path or set MCP_DOCUMENTOR_CODE_ROOT.`);
|
|
61
|
+
}
|
|
62
|
+
if (!fs.existsSync(absolutePath)) {
|
|
63
|
+
throw new Error(`Cannot track "${fp}": file not found at ${absolutePath}. Tracking snapshots the file content, so the file must exist.`);
|
|
64
|
+
}
|
|
65
|
+
const storedPath = toStoredPath(absolutePath, project);
|
|
66
|
+
const mtime = fs.statSync(absolutePath).mtime.toISOString();
|
|
61
67
|
const isWhole = typeof input === "string" || input.lines.length === 0;
|
|
62
68
|
const existing = byPath.get(storedPath);
|
|
63
69
|
if (isWhole) {
|
|
@@ -79,24 +85,87 @@ export function snapshotTrackedFiles(inputs, project) {
|
|
|
79
85
|
}
|
|
80
86
|
const tracked = [];
|
|
81
87
|
for (const [storedPath, { absolutePath, mtime, lineRanges, wholeFile }] of byPath) {
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
.map(({ start, end }) => snapshotSection(absolutePath, start, end));
|
|
88
|
+
const validRanges = lineRanges.filter(({ start, end }) => start <= end);
|
|
89
|
+
if (!wholeFile && validRanges.length > 0) {
|
|
90
|
+
const sections = dedupeSections(validRanges.map(({ start, end }) => snapshotSection(absolutePath, start, end)));
|
|
86
91
|
tracked.push({ path: storedPath, lastModifiedAt: mtime, sections });
|
|
87
92
|
}
|
|
88
93
|
else {
|
|
89
|
-
|
|
90
|
-
|
|
94
|
+
// Whole-file tracking — also the landing spot when every provided range was
|
|
95
|
+
// invalid, so an entry never ends up with neither sections nor contentHash.
|
|
96
|
+
tracked.push({ path: storedPath, lastModifiedAt: mtime, contentHash: computeFileHash(absolutePath) });
|
|
91
97
|
}
|
|
92
98
|
}
|
|
93
99
|
return tracked;
|
|
94
100
|
}
|
|
101
|
+
const sameLines = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
|
|
102
|
+
/** Two sections describe the same region when they were anchored on the same top/bottom lines. */
|
|
103
|
+
function sameRegion(a, b) {
|
|
104
|
+
return sameLines(a.anchors.top, b.anchors.top) && sameLines(a.anchors.bottom, b.anchors.bottom);
|
|
105
|
+
}
|
|
106
|
+
function dedupeSections(sections) {
|
|
107
|
+
const unique = [];
|
|
108
|
+
for (const section of sections) {
|
|
109
|
+
if (!unique.some((kept) => sameRegion(kept, section)))
|
|
110
|
+
unique.push(section);
|
|
111
|
+
}
|
|
112
|
+
return unique;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Carries a previously stored snapshot over to a freshly snapshotted entry for the same file.
|
|
116
|
+
*
|
|
117
|
+
* Returns `undefined` when nothing can be carried over — the file was tracked whole and is now
|
|
118
|
+
* tracked by section (or the reverse), so no prior hash describes the newly tracked region.
|
|
119
|
+
*/
|
|
120
|
+
function carryOverSnapshot(previous, fresh) {
|
|
121
|
+
if (!fresh.sections && !previous.sections)
|
|
122
|
+
return previous;
|
|
123
|
+
if (!fresh.sections || !previous.sections)
|
|
124
|
+
return undefined;
|
|
125
|
+
const sections = fresh.sections.map((section) => previous.sections.find((prev) => sameRegion(prev, section)) ?? section);
|
|
126
|
+
const carriedAny = sections.some((section, i) => section !== fresh.sections[i]);
|
|
127
|
+
if (!carriedAny)
|
|
128
|
+
return undefined;
|
|
129
|
+
return { ...previous, sections, lastModifiedAt: previous.lastModifiedAt };
|
|
130
|
+
}
|
|
95
131
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
* -
|
|
132
|
+
* Rebuilds a tracked-file list from `inputs`, keeping the stored snapshot of every file that
|
|
133
|
+
* was already tracked with the same scope.
|
|
134
|
+
*
|
|
135
|
+
* Re-declaring `tracked_files` states which files a doc depends on — it says nothing about whether
|
|
136
|
+
* the doc still describes them correctly. Only `refresh_file_snapshot` may acknowledge that, so a
|
|
137
|
+
* doc that was stale before this call is still stale after it. Files that were not tracked before
|
|
138
|
+
* necessarily carry a snapshot of their current content: there is no earlier state to preserve.
|
|
139
|
+
*/
|
|
140
|
+
export function resnapshotPreserving(existing, inputs, project) {
|
|
141
|
+
const fresh = snapshotTrackedFiles(inputs, project);
|
|
142
|
+
const byPath = new Map(existing.map((tf) => [tf.path, tf]));
|
|
143
|
+
const preserved = [];
|
|
144
|
+
const added = [];
|
|
145
|
+
const reset = [];
|
|
146
|
+
const tracked = fresh.map((entry) => {
|
|
147
|
+
const previous = byPath.get(entry.path);
|
|
148
|
+
if (!previous) {
|
|
149
|
+
added.push(entry.path);
|
|
150
|
+
return entry;
|
|
151
|
+
}
|
|
152
|
+
const carried = carryOverSnapshot(previous, entry);
|
|
153
|
+
if (!carried) {
|
|
154
|
+
reset.push(entry.path);
|
|
155
|
+
return entry;
|
|
156
|
+
}
|
|
157
|
+
preserved.push(entry.path);
|
|
158
|
+
return carried;
|
|
159
|
+
});
|
|
160
|
+
return { tracked, preserved, added, reset };
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Merges snapshotted tracked files into an existing list, without ever refreshing what is
|
|
164
|
+
* already tracked — acknowledging staleness is `refresh_file_snapshot`'s job alone.
|
|
165
|
+
* - If a file is not yet tracked: appended with a snapshot of its current content.
|
|
166
|
+
* - If a file is already tracked by section and the addition covers a region not yet tracked:
|
|
167
|
+
* that region is appended; existing sections keep their stored hash.
|
|
168
|
+
* - Otherwise the existing entry is left untouched.
|
|
100
169
|
*/
|
|
101
170
|
export function mergeTrackedFiles(existing, additions, project) {
|
|
102
171
|
if (additions.length === 0)
|
|
@@ -107,16 +176,14 @@ export function mergeTrackedFiles(existing, additions, project) {
|
|
|
107
176
|
const idx = result.findIndex(tf => tf.path === newTf.path);
|
|
108
177
|
if (idx < 0) {
|
|
109
178
|
result.push(newTf);
|
|
179
|
+
continue;
|
|
110
180
|
}
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
};
|
|
117
|
-
}
|
|
118
|
-
else {
|
|
119
|
-
result[idx] = newTf;
|
|
181
|
+
const current = result[idx];
|
|
182
|
+
if (!newTf.sections || !current.sections)
|
|
183
|
+
continue;
|
|
184
|
+
const novel = newTf.sections.filter((section) => !current.sections.some((kept) => sameRegion(kept, section)));
|
|
185
|
+
if (novel.length > 0) {
|
|
186
|
+
result[idx] = { ...current, sections: [...current.sections, ...novel] };
|
|
120
187
|
}
|
|
121
188
|
}
|
|
122
189
|
return result;
|
package/dist/src/index.js
CHANGED
|
@@ -159,8 +159,15 @@ async function handleGetInitialState(msg) {
|
|
|
159
159
|
for (const doc of docs) {
|
|
160
160
|
const full = await storageEngine.getDoc(doc.id); // doc.id is now the full path
|
|
161
161
|
if (full && full.tracked_files.length > 0) {
|
|
162
|
-
|
|
163
|
-
|
|
162
|
+
// A doc whose freshness cannot be computed (legacy hash-less entries)
|
|
163
|
+
// needs attention: surface it as stale rather than crashing the view.
|
|
164
|
+
try {
|
|
165
|
+
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
166
|
+
doc.isStale = report.status === "stale";
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
doc.isStale = true;
|
|
170
|
+
}
|
|
164
171
|
}
|
|
165
172
|
}
|
|
166
173
|
const bundles = storageEngine.getBundles();
|
|
@@ -197,8 +204,13 @@ async function handleUpdateProject(msg) {
|
|
|
197
204
|
for (const doc of docs) {
|
|
198
205
|
const full = await storageEngine.getDoc(doc.id);
|
|
199
206
|
if (full && full.tracked_files.length > 0) {
|
|
200
|
-
|
|
201
|
-
|
|
207
|
+
try {
|
|
208
|
+
const report = computeFreshness(full.tracked_files, project);
|
|
209
|
+
doc.isStale = report.status === "stale";
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
doc.isStale = true;
|
|
213
|
+
}
|
|
202
214
|
}
|
|
203
215
|
}
|
|
204
216
|
broadcastToGUI({
|
|
@@ -238,8 +250,13 @@ async function handleListDocs(msg) {
|
|
|
238
250
|
for (const doc of docs) {
|
|
239
251
|
const full = await storageEngine.getDoc(doc.id);
|
|
240
252
|
if (full && full.tracked_files.length > 0) {
|
|
241
|
-
|
|
242
|
-
|
|
253
|
+
try {
|
|
254
|
+
const report = computeFreshness(full.tracked_files, currentProject);
|
|
255
|
+
doc.isStale = report.status === "stale";
|
|
256
|
+
}
|
|
257
|
+
catch {
|
|
258
|
+
doc.isStale = true;
|
|
259
|
+
}
|
|
243
260
|
}
|
|
244
261
|
}
|
|
245
262
|
broadcastToGUI({
|
|
@@ -351,8 +368,15 @@ async function handleCreateDocIPC(msg) {
|
|
|
351
368
|
for (const doc of docs) {
|
|
352
369
|
const full = await storageEngine.getDoc(doc.id);
|
|
353
370
|
if (full && full.tracked_files.length > 0) {
|
|
354
|
-
|
|
355
|
-
|
|
371
|
+
// A doc whose freshness cannot be computed (legacy hash-less entries)
|
|
372
|
+
// needs attention: surface it as stale rather than crashing the view.
|
|
373
|
+
try {
|
|
374
|
+
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
375
|
+
doc.isStale = report.status === "stale";
|
|
376
|
+
}
|
|
377
|
+
catch {
|
|
378
|
+
doc.isStale = true;
|
|
379
|
+
}
|
|
356
380
|
}
|
|
357
381
|
}
|
|
358
382
|
broadcastToGUI({
|
|
@@ -684,7 +708,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
684
708
|
},
|
|
685
709
|
],
|
|
686
710
|
},
|
|
687
|
-
description: "Files to track for staleness detection. Use a plain path string for whole-file tracking, or { path, lines: [{ start, end }] } to track specific sections only (reduces false positives when unrelated parts of the file change).",
|
|
711
|
+
description: "Files to track for staleness detection. Use a plain path string for whole-file tracking, or { path, lines: [{ start, end }] } to track specific sections only (reduces false positives when unrelated parts of the file change). Paths are absolute, or relative to the project's code root. Tracked files must exist.",
|
|
688
712
|
},
|
|
689
713
|
tags: {
|
|
690
714
|
type: "array",
|
|
@@ -764,7 +788,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
764
788
|
},
|
|
765
789
|
],
|
|
766
790
|
},
|
|
767
|
-
description: "New list of tracked files (replaces existing). Use a plain path string for whole-file tracking, or { path, lines: [{ start, end }] } to track specific sections only (reduces false positives when unrelated parts of the file change).",
|
|
791
|
+
description: "New list of tracked files (replaces existing). Files already tracked with the same scope keep their stored snapshot, so this never changes staleness — only refresh_file_snapshot acknowledges file changes. Use a plain path string for whole-file tracking, or { path, lines: [{ start, end }] } to track specific sections only (reduces false positives when unrelated parts of the file change). Paths are absolute, or relative to the project's code root.",
|
|
768
792
|
},
|
|
769
793
|
tags: {
|
|
770
794
|
type: "array",
|
|
@@ -60,15 +60,9 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
else {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (currentMtime > storedMtime + STALENESS_TOLERANCE_MS) {
|
|
67
|
-
modified.push(tracked.path);
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
unchanged.push(tracked.path);
|
|
71
|
-
}
|
|
63
|
+
// Hash comparison is the only freshness system. An entry with neither
|
|
64
|
+
// sections nor contentHash predates it and cannot be checked.
|
|
65
|
+
throw new Error(`Tracked file "${tracked.path}" has no contentHash and no sections — it predates hash-based tracking. Call refresh_file_snapshot on this doc to snapshot it, or re-declare it via update_doc.`);
|
|
72
66
|
}
|
|
73
67
|
}
|
|
74
68
|
// When every tracked file is absent, the source code simply isn't present on
|
|
@@ -124,6 +118,62 @@ function renderUntrackedList(titles, heading) {
|
|
|
124
118
|
const suffix = remaining > 0 ? `\n … and ${remaining} more` : "";
|
|
125
119
|
return `\n\n${heading}\n${shown.join("\n")}${suffix}`;
|
|
126
120
|
}
|
|
121
|
+
async function scanProjectFreshness(storageEngine, project) {
|
|
122
|
+
const summaries = await storageEngine.listDocs(project);
|
|
123
|
+
const scan = { docCount: summaries.length, stale: [], noTracking: [], broken: [], upToDate: 0, unverifiable: 0 };
|
|
124
|
+
for (const summary of summaries) {
|
|
125
|
+
const doc = await storageEngine.getDoc(summary.id, project);
|
|
126
|
+
if (!doc)
|
|
127
|
+
continue;
|
|
128
|
+
let report;
|
|
129
|
+
try {
|
|
130
|
+
report = computeFreshness(doc.tracked_files, project);
|
|
131
|
+
}
|
|
132
|
+
catch (err) {
|
|
133
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
134
|
+
scan.broken.push(` - **${doc.title}** [${doc.id}]: ${message}`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (report.status === "no_tracking") {
|
|
138
|
+
scan.noTracking.push(doc.title);
|
|
139
|
+
}
|
|
140
|
+
else if (report.status === "unverifiable") {
|
|
141
|
+
scan.unverifiable++;
|
|
142
|
+
}
|
|
143
|
+
else if (report.status === "stale") {
|
|
144
|
+
const details = [
|
|
145
|
+
...report.modified.map((f) => ` • modified: ${f}`),
|
|
146
|
+
...report.missing.map((f) => ` • missing: ${f}`),
|
|
147
|
+
...(report.anchors_lost ?? []).map((f) => ` • ⚠ anchors lost (needs update_doc): ${f}`),
|
|
148
|
+
].join("\n");
|
|
149
|
+
scan.stale.push(` - **${doc.title}** [${doc.id}]\n${details}`);
|
|
150
|
+
}
|
|
151
|
+
else {
|
|
152
|
+
scan.upToDate++;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return scan;
|
|
156
|
+
}
|
|
157
|
+
function renderScanSummary(scan) {
|
|
158
|
+
let text = `${scan.docCount} doc(s) scanned: **${scan.stale.length} stale**, ${scan.upToDate} up to date, ${scan.unverifiable} unverifiable (source absent), ${scan.noTracking.length} not tracking any file`;
|
|
159
|
+
if (scan.broken.length > 0)
|
|
160
|
+
text += `, **${scan.broken.length} with broken tracking**`;
|
|
161
|
+
return text + ".";
|
|
162
|
+
}
|
|
163
|
+
function renderScanSections(scan, headingLevel) {
|
|
164
|
+
let text = "";
|
|
165
|
+
if (scan.broken.length > 0) {
|
|
166
|
+
text += `\n\n${headingLevel} Docs with broken tracking — fix required\n\n${scan.broken.join("\n")}`;
|
|
167
|
+
text += `\n → These docs could not be checked. Re-read the source files and re-declare tracked_files via update_doc, then call refresh_file_snapshot if the doc content is still accurate.`;
|
|
168
|
+
}
|
|
169
|
+
if (scan.stale.length > 0) {
|
|
170
|
+
text += `\n\n${headingLevel} Stale docs\n\n${scan.stale.join("\n\n")}`;
|
|
171
|
+
}
|
|
172
|
+
if (scan.noTracking.length > 0) {
|
|
173
|
+
text += renderUntrackedList(scan.noTracking, `${headingLevel} Docs not tracking any file (no staleness detection)`);
|
|
174
|
+
}
|
|
175
|
+
return text;
|
|
176
|
+
}
|
|
127
177
|
export async function checkFreshness(storageEngine, args) {
|
|
128
178
|
try {
|
|
129
179
|
const validated = CheckFreshnessArgsSchema.parse(args);
|
|
@@ -137,45 +187,11 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
137
187
|
return { content: [{ type: "text", text: formatFreshnessReport(doc.title, report) }] };
|
|
138
188
|
}
|
|
139
189
|
if (validated.project) {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
190
|
+
const scan = await scanProjectFreshness(storageEngine, validated.project);
|
|
191
|
+
if (scan.docCount === 0) {
|
|
142
192
|
return { content: [{ type: "text", text: `No documentation entries found in project "${validated.project}".` }] };
|
|
143
193
|
}
|
|
144
|
-
const
|
|
145
|
-
const noTracking = [];
|
|
146
|
-
let upToDate = 0;
|
|
147
|
-
let unverifiable = 0;
|
|
148
|
-
for (const summary of summaries) {
|
|
149
|
-
const doc = await storageEngine.getDoc(summary.id, validated.project);
|
|
150
|
-
if (!doc)
|
|
151
|
-
continue;
|
|
152
|
-
const report = computeFreshness(doc.tracked_files, validated.project);
|
|
153
|
-
if (report.status === "no_tracking") {
|
|
154
|
-
noTracking.push(doc.title);
|
|
155
|
-
}
|
|
156
|
-
else if (report.status === "unverifiable") {
|
|
157
|
-
unverifiable++;
|
|
158
|
-
}
|
|
159
|
-
else if (report.status === "stale") {
|
|
160
|
-
const details = [
|
|
161
|
-
...report.modified.map((f) => ` • modified: ${f}`),
|
|
162
|
-
...report.missing.map((f) => ` • missing: ${f}`),
|
|
163
|
-
...(report.anchors_lost ?? []).map((f) => ` • ⚠ anchors lost (needs update_doc): ${f}`),
|
|
164
|
-
].join("\n");
|
|
165
|
-
stale.push(` - **${doc.title}** [${doc.id}]\n${details}`);
|
|
166
|
-
}
|
|
167
|
-
else {
|
|
168
|
-
upToDate++;
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
let text = `# Freshness report — project "${validated.project}"\n\n`;
|
|
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`;
|
|
173
|
-
if (stale.length > 0) {
|
|
174
|
-
text += `\n## Stale docs\n\n${stale.join("\n\n")}`;
|
|
175
|
-
}
|
|
176
|
-
if (noTracking.length > 0) {
|
|
177
|
-
text += renderUntrackedList(noTracking, "## Docs not tracking any file (no staleness detection)");
|
|
178
|
-
}
|
|
194
|
+
const text = `# Freshness report — project "${validated.project}"\n\n${renderScanSummary(scan)}${renderScanSections(scan, "##")}`;
|
|
179
195
|
return { content: [{ type: "text", text }] };
|
|
180
196
|
}
|
|
181
197
|
// No id, no project → scan all projects
|
|
@@ -184,49 +200,16 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
184
200
|
const projectBlocks = [];
|
|
185
201
|
let totalDocs = 0;
|
|
186
202
|
let totalStale = 0;
|
|
203
|
+
let totalBroken = 0;
|
|
187
204
|
for (const proj of allProjects) {
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
for (const summary of summaries) {
|
|
194
|
-
const doc = await storageEngine.getDoc(summary.id, proj);
|
|
195
|
-
if (!doc)
|
|
196
|
-
continue;
|
|
197
|
-
const report = computeFreshness(doc.tracked_files, proj);
|
|
198
|
-
if (report.status === "no_tracking") {
|
|
199
|
-
noTracking.push(doc.title);
|
|
200
|
-
}
|
|
201
|
-
else if (report.status === "unverifiable") {
|
|
202
|
-
unverifiable++;
|
|
203
|
-
}
|
|
204
|
-
else if (report.status === "stale") {
|
|
205
|
-
const details = [
|
|
206
|
-
...report.modified.map((f) => ` • modified: ${f}`),
|
|
207
|
-
...report.missing.map((f) => ` • missing: ${f}`),
|
|
208
|
-
...(report.anchors_lost ?? []).map((f) => ` • ⚠ anchors lost (needs update_doc): ${f}`),
|
|
209
|
-
].join("\n");
|
|
210
|
-
stale.push(` - **${doc.title}** [${doc.id}]\n${details}`);
|
|
211
|
-
}
|
|
212
|
-
else {
|
|
213
|
-
upToDate++;
|
|
214
|
-
}
|
|
215
|
-
}
|
|
216
|
-
totalDocs += summaries.length;
|
|
217
|
-
totalStale += stale.length;
|
|
218
|
-
let block = `## ${proj}\n\n`;
|
|
219
|
-
block += `${summaries.length} doc(s): **${stale.length} stale**, ${upToDate} up to date, ${unverifiable} unverifiable (source absent), ${noTracking.length} not tracking any file.`;
|
|
220
|
-
if (stale.length > 0) {
|
|
221
|
-
block += `\n\n### Stale docs\n\n${stale.join("\n\n")}`;
|
|
222
|
-
}
|
|
223
|
-
if (noTracking.length > 0) {
|
|
224
|
-
block += renderUntrackedList(noTracking, "### Docs not tracking any file");
|
|
225
|
-
}
|
|
226
|
-
projectBlocks.push(block);
|
|
205
|
+
const scan = await scanProjectFreshness(storageEngine, proj);
|
|
206
|
+
totalDocs += scan.docCount;
|
|
207
|
+
totalStale += scan.stale.length;
|
|
208
|
+
totalBroken += scan.broken.length;
|
|
209
|
+
projectBlocks.push(`## ${proj}\n\n${renderScanSummary(scan)}${renderScanSections(scan, "###")}`);
|
|
227
210
|
}
|
|
228
211
|
let text = `# Freshness report — all projects (${allProjects.length})\n\n`;
|
|
229
|
-
text += `Total: ${totalDocs} doc(s) across all projects, **${totalStale} stale
|
|
212
|
+
text += `Total: ${totalDocs} doc(s) across all projects, **${totalStale} stale**${totalBroken > 0 ? `, **${totalBroken} with broken tracking**` : ""}.\n\n`;
|
|
230
213
|
text += projectBlocks.join("\n\n---\n\n");
|
|
231
214
|
return { content: [{ type: "text", text }] };
|
|
232
215
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import { UpdateDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
3
3
|
import { persistDraft } from "../core/draft-store.js";
|
|
4
|
-
import { resolveCodeRefs,
|
|
4
|
+
import { resolveCodeRefs, resnapshotPreserving, mergeTrackedFiles } from "../core/code-ref-resolver.js";
|
|
5
5
|
export async function updateDoc(storageEngine, embeddingEngine, args) {
|
|
6
6
|
try {
|
|
7
7
|
const validated = UpdateDocArgsSchema.parse(args);
|
|
@@ -24,11 +24,16 @@ export async function updateDoc(storageEngine, embeddingEngine, args) {
|
|
|
24
24
|
doc.content = content;
|
|
25
25
|
additionalTrackedFiles = refs;
|
|
26
26
|
}
|
|
27
|
+
// Re-declaring tracked_files must never acknowledge staleness — that is
|
|
28
|
+
// refresh_file_snapshot's job alone. Snapshots of files already tracked with
|
|
29
|
+
// the same scope are carried over as-is; only genuinely new entries get one.
|
|
30
|
+
let trackedResult;
|
|
27
31
|
if (validated.tracked_files !== undefined) {
|
|
28
|
-
doc.tracked_files
|
|
32
|
+
trackedResult = resnapshotPreserving(doc.tracked_files, [
|
|
29
33
|
...validated.tracked_files,
|
|
30
34
|
...additionalTrackedFiles,
|
|
31
35
|
], resolvedProject);
|
|
36
|
+
doc.tracked_files = trackedResult.tracked;
|
|
32
37
|
}
|
|
33
38
|
else {
|
|
34
39
|
doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
|
|
@@ -57,8 +62,16 @@ export async function updateDoc(storageEngine, embeddingEngine, args) {
|
|
|
57
62
|
changes.push("content");
|
|
58
63
|
if (validated.content_file !== undefined)
|
|
59
64
|
changes.push("content (from file)");
|
|
60
|
-
if (
|
|
61
|
-
|
|
65
|
+
if (trackedResult !== undefined) {
|
|
66
|
+
const parts = [`${doc.tracked_files.length} files`];
|
|
67
|
+
if (trackedResult.preserved.length > 0)
|
|
68
|
+
parts.push(`${trackedResult.preserved.length} kept their existing snapshot`);
|
|
69
|
+
if (trackedResult.added.length > 0)
|
|
70
|
+
parts.push(`${trackedResult.added.length} newly tracked`);
|
|
71
|
+
if (trackedResult.reset.length > 0)
|
|
72
|
+
parts.push(`${trackedResult.reset.length} re-snapshotted (tracking scope changed): ${trackedResult.reset.join(", ")}`);
|
|
73
|
+
changes.push(`tracked_files (${parts.join("; ")}). Staleness is unaffected — use refresh_file_snapshot to acknowledge file changes.`);
|
|
74
|
+
}
|
|
62
75
|
if (validated.tags !== undefined)
|
|
63
76
|
changes.push(`tags (${doc.tags.join(", ") || "none"})`);
|
|
64
77
|
if (validated.related_docs !== undefined)
|
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.62";
|
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.62';
|