@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.
@@ -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; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
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; when MCP_DOCUMENTOR_CODE_ROOT is configured they are stored relative to it so the docs stay portable across machines."),
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
- * - If a file is not yet tracked: appended.
11
- * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
12
- * - Otherwise (whole-file tracking involved): the existing entry is replaced.
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
- const absolutePath = path.resolve(process.cwd(), fp);
57
- const storedPath = toStoredPath(fp, project);
58
- const mtime = fs.existsSync(absolutePath)
59
- ? fs.statSync(absolutePath).mtime.toISOString()
60
- : new Date().toISOString();
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
- if (!wholeFile && lineRanges.length > 0 && fs.existsSync(absolutePath)) {
83
- const sections = lineRanges
84
- .filter(({ start, end }) => start <= end)
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
- const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
90
- tracked.push({ path: storedPath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
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
- * Merges snapshotted tracked files into an existing list.
97
- * - If a file is not yet tracked: appended.
98
- * - If a file is already tracked with sections and the new entry also has sections: sections are merged.
99
- * - Otherwise (whole-file tracking involved): the existing entry is replaced.
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
- else if (newTf.sections && result[idx].sections) {
112
- result[idx] = {
113
- ...result[idx],
114
- sections: [...result[idx].sections, ...newTf.sections],
115
- lastModifiedAt: newTf.lastModifiedAt,
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
- const report = computeFreshness(full.tracked_files, config.currentProject);
163
- doc.isStale = report.status === "stale";
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
- const report = computeFreshness(full.tracked_files, project);
201
- doc.isStale = report.status === "stale";
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
- const report = computeFreshness(full.tracked_files, currentProject);
242
- doc.isStale = report.status === "stale";
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
- const report = computeFreshness(full.tracked_files, config.currentProject);
355
- doc.isStale = report.status === "stale";
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
- const currentMtime = fs.statSync(absolutePath).mtime.getTime();
64
- const storedMtime = new Date(tracked.lastModifiedAt).getTime();
65
- const STALENESS_TOLERANCE_MS = 2000;
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 summaries = await storageEngine.listDocs(validated.project);
141
- if (summaries.length === 0) {
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 stale = [];
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 summaries = await storageEngine.listDocs(proj);
189
- const stale = [];
190
- const noTracking = [];
191
- let upToDate = 0;
192
- let unverifiable = 0;
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**.\n\n`;
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, snapshotTrackedFiles, mergeTrackedFiles } from "../core/code-ref-resolver.js";
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 = snapshotTrackedFiles([
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 (validated.tracked_files !== undefined)
61
- changes.push(`tracked_files (${doc.tracked_files.length} files)`);
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)
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.61";
1
+ export declare const APP_VERSION = "1.0.62";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.61';
2
+ export const APP_VERSION = '1.0.62';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-documentor",
3
- "version": "1.0.61",
3
+ "version": "1.0.62",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",