@imenam/mcp-documentor 1.0.61 → 1.0.63
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 +4 -0
- package/dist/shared/schemas/doc.schema.js +5 -3
- 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 +10 -6
- package/dist/src/tools/check-freshness.js +90 -93
- package/dist/src/tools/get-doc-metadata.js +2 -0
- package/dist/src/tools/get-doc.js +16 -4
- package/dist/src/tools/git-diff-doc.js +3 -1
- package/dist/src/tools/list-docs.js +3 -1
- 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
|
@@ -65,6 +65,8 @@ export interface DocSummary {
|
|
|
65
65
|
updatedAt: string;
|
|
66
66
|
tags: string[];
|
|
67
67
|
isStale: boolean;
|
|
68
|
+
/** True when tracked entries have no snapshot and freshness cannot be computed. */
|
|
69
|
+
isBroken?: boolean;
|
|
68
70
|
folder?: string;
|
|
69
71
|
}
|
|
70
72
|
export interface DocMatch {
|
|
@@ -81,11 +83,13 @@ export declare const FreshnessReportSchema: z.ZodObject<{
|
|
|
81
83
|
stale: "stale";
|
|
82
84
|
no_tracking: "no_tracking";
|
|
83
85
|
unverifiable: "unverifiable";
|
|
86
|
+
broken: "broken";
|
|
84
87
|
}>;
|
|
85
88
|
modified: z.ZodArray<z.ZodString>;
|
|
86
89
|
missing: z.ZodArray<z.ZodString>;
|
|
87
90
|
unchanged: z.ZodArray<z.ZodString>;
|
|
88
91
|
anchors_lost: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
92
|
+
broken: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
89
93
|
total: z.ZodNumber;
|
|
90
94
|
}, z.core.$strip>;
|
|
91
95
|
export type FreshnessReport = z.infer<typeof FreshnessReportSchema>;
|
|
@@ -36,18 +36,20 @@ 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", "unverifiable"]),
|
|
39
|
+
status: z.enum(["up_to_date", "stale", "no_tracking", "unverifiable", "broken"]),
|
|
40
40
|
modified: z.array(z.string()),
|
|
41
41
|
missing: z.array(z.string()),
|
|
42
42
|
unchanged: z.array(z.string()),
|
|
43
43
|
anchors_lost: z.array(z.string()).default([]),
|
|
44
|
+
/** Entries with neither sections nor contentHash (pre hash-based tracking) — cannot be checked until re-tracked. */
|
|
45
|
+
broken: z.array(z.string()).default([]),
|
|
44
46
|
total: z.number(),
|
|
45
47
|
});
|
|
46
48
|
export const CreateDocArgsSchema = z.object({
|
|
47
49
|
title: z.string().describe("Title of the documentation entry"),
|
|
48
50
|
content: z.string().optional().describe("Documentation content in markdown"),
|
|
49
51
|
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
|
|
52
|
+
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
53
|
tags: z.array(z.string()).optional().describe("Tags for categorization"),
|
|
52
54
|
folder: z.string().optional().describe("Folder path for organization, e.g. 'frontend/components'"),
|
|
53
55
|
related_docs: z.array(z.string()).optional().describe("IDs of related documentation entries"),
|
|
@@ -59,7 +61,7 @@ export const UpdateDocArgsSchema = z.object({
|
|
|
59
61
|
title: z.string().optional().describe("Title of the documentation entry"),
|
|
60
62
|
content: z.string().optional().describe("New content (replaces existing)"),
|
|
61
63
|
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
|
|
64
|
+
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
65
|
tags: z.array(z.string()).optional().describe("New tags (replaces existing)"),
|
|
64
66
|
related_docs: z.array(z.string()).optional().describe("Paths of related documentation entries (replaces existing)"),
|
|
65
67
|
});
|
|
@@ -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
|
@@ -160,7 +160,8 @@ async function handleGetInitialState(msg) {
|
|
|
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
162
|
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
163
|
-
doc.isStale = report.status === "stale";
|
|
163
|
+
doc.isStale = report.status === "stale" || report.status === "broken";
|
|
164
|
+
doc.isBroken = report.status === "broken";
|
|
164
165
|
}
|
|
165
166
|
}
|
|
166
167
|
const bundles = storageEngine.getBundles();
|
|
@@ -198,7 +199,8 @@ async function handleUpdateProject(msg) {
|
|
|
198
199
|
const full = await storageEngine.getDoc(doc.id);
|
|
199
200
|
if (full && full.tracked_files.length > 0) {
|
|
200
201
|
const report = computeFreshness(full.tracked_files, project);
|
|
201
|
-
doc.isStale = report.status === "stale";
|
|
202
|
+
doc.isStale = report.status === "stale" || report.status === "broken";
|
|
203
|
+
doc.isBroken = report.status === "broken";
|
|
202
204
|
}
|
|
203
205
|
}
|
|
204
206
|
broadcastToGUI({
|
|
@@ -239,7 +241,8 @@ async function handleListDocs(msg) {
|
|
|
239
241
|
const full = await storageEngine.getDoc(doc.id);
|
|
240
242
|
if (full && full.tracked_files.length > 0) {
|
|
241
243
|
const report = computeFreshness(full.tracked_files, currentProject);
|
|
242
|
-
doc.isStale = report.status === "stale";
|
|
244
|
+
doc.isStale = report.status === "stale" || report.status === "broken";
|
|
245
|
+
doc.isBroken = report.status === "broken";
|
|
243
246
|
}
|
|
244
247
|
}
|
|
245
248
|
broadcastToGUI({
|
|
@@ -352,7 +355,8 @@ async function handleCreateDocIPC(msg) {
|
|
|
352
355
|
const full = await storageEngine.getDoc(doc.id);
|
|
353
356
|
if (full && full.tracked_files.length > 0) {
|
|
354
357
|
const report = computeFreshness(full.tracked_files, config.currentProject);
|
|
355
|
-
doc.isStale = report.status === "stale";
|
|
358
|
+
doc.isStale = report.status === "stale" || report.status === "broken";
|
|
359
|
+
doc.isBroken = report.status === "broken";
|
|
356
360
|
}
|
|
357
361
|
}
|
|
358
362
|
broadcastToGUI({
|
|
@@ -684,7 +688,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
684
688
|
},
|
|
685
689
|
],
|
|
686
690
|
},
|
|
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).",
|
|
691
|
+
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
692
|
},
|
|
689
693
|
tags: {
|
|
690
694
|
type: "array",
|
|
@@ -764,7 +768,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
764
768
|
},
|
|
765
769
|
],
|
|
766
770
|
},
|
|
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).",
|
|
771
|
+
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
772
|
},
|
|
769
773
|
tags: {
|
|
770
774
|
type: "array",
|
|
@@ -8,12 +8,13 @@ export function computeFileHash(absolutePath) {
|
|
|
8
8
|
}
|
|
9
9
|
export function computeFreshness(trackedFiles, project) {
|
|
10
10
|
if (!trackedFiles || trackedFiles.length === 0) {
|
|
11
|
-
return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], total: 0 };
|
|
11
|
+
return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], broken: [], total: 0 };
|
|
12
12
|
}
|
|
13
13
|
const modified = [];
|
|
14
14
|
const missing = [];
|
|
15
15
|
const unchanged = [];
|
|
16
16
|
const anchors_lost = [];
|
|
17
|
+
const broken = [];
|
|
17
18
|
for (const tracked of trackedFiles) {
|
|
18
19
|
// A relative path with no code root configured on this machine is not
|
|
19
20
|
// resolvable here — count it as absent so we end up "unverifiable" rather
|
|
@@ -60,15 +61,11 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
60
61
|
}
|
|
61
62
|
}
|
|
62
63
|
else {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
else {
|
|
70
|
-
unchanged.push(tracked.path);
|
|
71
|
-
}
|
|
64
|
+
// Hash comparison is the only freshness system. An entry with neither
|
|
65
|
+
// sections nor contentHash predates it and cannot be checked — it is
|
|
66
|
+
// reported as broken (never checked by any other means) so readers can
|
|
67
|
+
// still expose the doc and its tracked list to the agent for re-tracking.
|
|
68
|
+
broken.push(tracked.path);
|
|
72
69
|
}
|
|
73
70
|
}
|
|
74
71
|
// When every tracked file is absent, the source code simply isn't present on
|
|
@@ -78,15 +75,18 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
78
75
|
// change signal (likely deletions) and still counts as stale.
|
|
79
76
|
const allMissing = missing.length === trackedFiles.length;
|
|
80
77
|
if (allMissing) {
|
|
81
|
-
return { status: "unverifiable", modified, missing, unchanged, anchors_lost, total: trackedFiles.length };
|
|
78
|
+
return { status: "unverifiable", modified, missing, unchanged, anchors_lost, broken, total: trackedFiles.length };
|
|
82
79
|
}
|
|
83
80
|
const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
|
|
81
|
+
// Broken tracking outranks everything: no freshness verdict is trustworthy
|
|
82
|
+
// until the broken entries are re-tracked.
|
|
84
83
|
return {
|
|
85
|
-
status: isStale ? "stale" : "up_to_date",
|
|
84
|
+
status: broken.length > 0 ? "broken" : isStale ? "stale" : "up_to_date",
|
|
86
85
|
modified,
|
|
87
86
|
missing,
|
|
88
87
|
unchanged,
|
|
89
88
|
anchors_lost,
|
|
89
|
+
broken,
|
|
90
90
|
total: trackedFiles.length,
|
|
91
91
|
};
|
|
92
92
|
}
|
|
@@ -97,11 +97,23 @@ export function formatFreshnessReport(title, report) {
|
|
|
97
97
|
if (report.status === "unverifiable") {
|
|
98
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
99
|
}
|
|
100
|
-
let text
|
|
101
|
-
|
|
102
|
-
|
|
100
|
+
let text;
|
|
101
|
+
if (report.status === "broken") {
|
|
102
|
+
text = `BROKEN TRACKING: "${title}" — some tracked entries have no content snapshot and cannot be checked.\n`;
|
|
103
|
+
}
|
|
104
|
+
else if (report.status === "stale") {
|
|
105
|
+
text = `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`;
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
text = `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
|
|
109
|
+
}
|
|
103
110
|
const anchorLostCount = report.anchors_lost?.length ?? 0;
|
|
104
|
-
|
|
111
|
+
const brokenCount = report.broken?.length ?? 0;
|
|
112
|
+
text += `\nSummary: ${report.modified.length} modified, ${report.missing.length} missing, ${anchorLostCount} with lost anchors, ${brokenCount} with broken tracking, ${report.unchanged.length} unchanged out of ${report.total} tracked file(s).`;
|
|
113
|
+
if (brokenCount > 0) {
|
|
114
|
+
text += `\n\n⚠ Files with broken tracking — no snapshot to compare against:\n${report.broken.map((f) => ` - ${f}`).join("\n")}`;
|
|
115
|
+
text += `\n → These entries predate hash-based tracking. Re-read the source files and re-declare them in tracked_files via update_doc, or call refresh_file_snapshot if the doc content is still accurate.`;
|
|
116
|
+
}
|
|
105
117
|
if (report.modified.length > 0) {
|
|
106
118
|
text += `\n\nModified files (call refresh_file_snapshot to acknowledge):\n${report.modified.map((f) => ` - ${f}`).join("\n")}`;
|
|
107
119
|
}
|
|
@@ -124,6 +136,58 @@ function renderUntrackedList(titles, heading) {
|
|
|
124
136
|
const suffix = remaining > 0 ? `\n … and ${remaining} more` : "";
|
|
125
137
|
return `\n\n${heading}\n${shown.join("\n")}${suffix}`;
|
|
126
138
|
}
|
|
139
|
+
async function scanProjectFreshness(storageEngine, project) {
|
|
140
|
+
const summaries = await storageEngine.listDocs(project);
|
|
141
|
+
const scan = { docCount: summaries.length, stale: [], noTracking: [], broken: [], upToDate: 0, unverifiable: 0 };
|
|
142
|
+
for (const summary of summaries) {
|
|
143
|
+
const doc = await storageEngine.getDoc(summary.id, project);
|
|
144
|
+
if (!doc)
|
|
145
|
+
continue;
|
|
146
|
+
const report = computeFreshness(doc.tracked_files, project);
|
|
147
|
+
if (report.status === "broken") {
|
|
148
|
+
const files = report.broken.map((f) => ` • no snapshot: ${f}`).join("\n");
|
|
149
|
+
scan.broken.push(` - **${doc.title}** [${doc.id}]\n${files}`);
|
|
150
|
+
}
|
|
151
|
+
else if (report.status === "no_tracking") {
|
|
152
|
+
scan.noTracking.push(doc.title);
|
|
153
|
+
}
|
|
154
|
+
else if (report.status === "unverifiable") {
|
|
155
|
+
scan.unverifiable++;
|
|
156
|
+
}
|
|
157
|
+
else if (report.status === "stale") {
|
|
158
|
+
const details = [
|
|
159
|
+
...report.modified.map((f) => ` • modified: ${f}`),
|
|
160
|
+
...report.missing.map((f) => ` • missing: ${f}`),
|
|
161
|
+
...(report.anchors_lost ?? []).map((f) => ` • ⚠ anchors lost (needs update_doc): ${f}`),
|
|
162
|
+
].join("\n");
|
|
163
|
+
scan.stale.push(` - **${doc.title}** [${doc.id}]\n${details}`);
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
scan.upToDate++;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return scan;
|
|
170
|
+
}
|
|
171
|
+
function renderScanSummary(scan) {
|
|
172
|
+
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`;
|
|
173
|
+
if (scan.broken.length > 0)
|
|
174
|
+
text += `, **${scan.broken.length} with broken tracking**`;
|
|
175
|
+
return text + ".";
|
|
176
|
+
}
|
|
177
|
+
function renderScanSections(scan, headingLevel) {
|
|
178
|
+
let text = "";
|
|
179
|
+
if (scan.broken.length > 0) {
|
|
180
|
+
text += `\n\n${headingLevel} Docs with broken tracking — fix required\n\n${scan.broken.join("\n")}`;
|
|
181
|
+
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.`;
|
|
182
|
+
}
|
|
183
|
+
if (scan.stale.length > 0) {
|
|
184
|
+
text += `\n\n${headingLevel} Stale docs\n\n${scan.stale.join("\n\n")}`;
|
|
185
|
+
}
|
|
186
|
+
if (scan.noTracking.length > 0) {
|
|
187
|
+
text += renderUntrackedList(scan.noTracking, `${headingLevel} Docs not tracking any file (no staleness detection)`);
|
|
188
|
+
}
|
|
189
|
+
return text;
|
|
190
|
+
}
|
|
127
191
|
export async function checkFreshness(storageEngine, args) {
|
|
128
192
|
try {
|
|
129
193
|
const validated = CheckFreshnessArgsSchema.parse(args);
|
|
@@ -137,45 +201,11 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
137
201
|
return { content: [{ type: "text", text: formatFreshnessReport(doc.title, report) }] };
|
|
138
202
|
}
|
|
139
203
|
if (validated.project) {
|
|
140
|
-
const
|
|
141
|
-
if (
|
|
204
|
+
const scan = await scanProjectFreshness(storageEngine, validated.project);
|
|
205
|
+
if (scan.docCount === 0) {
|
|
142
206
|
return { content: [{ type: "text", text: `No documentation entries found in project "${validated.project}".` }] };
|
|
143
207
|
}
|
|
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
|
-
}
|
|
208
|
+
const text = `# Freshness report — project "${validated.project}"\n\n${renderScanSummary(scan)}${renderScanSections(scan, "##")}`;
|
|
179
209
|
return { content: [{ type: "text", text }] };
|
|
180
210
|
}
|
|
181
211
|
// No id, no project → scan all projects
|
|
@@ -184,49 +214,16 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
184
214
|
const projectBlocks = [];
|
|
185
215
|
let totalDocs = 0;
|
|
186
216
|
let totalStale = 0;
|
|
217
|
+
let totalBroken = 0;
|
|
187
218
|
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);
|
|
219
|
+
const scan = await scanProjectFreshness(storageEngine, proj);
|
|
220
|
+
totalDocs += scan.docCount;
|
|
221
|
+
totalStale += scan.stale.length;
|
|
222
|
+
totalBroken += scan.broken.length;
|
|
223
|
+
projectBlocks.push(`## ${proj}\n\n${renderScanSummary(scan)}${renderScanSections(scan, "###")}`);
|
|
227
224
|
}
|
|
228
225
|
let text = `# Freshness report — all projects (${allProjects.length})\n\n`;
|
|
229
|
-
text += `Total: ${totalDocs} doc(s) across all projects, **${totalStale} stale
|
|
226
|
+
text += `Total: ${totalDocs} doc(s) across all projects, **${totalStale} stale**${totalBroken > 0 ? `, **${totalBroken} with broken tracking**` : ""}.\n\n`;
|
|
230
227
|
text += projectBlocks.join("\n\n---\n\n");
|
|
231
228
|
return { content: [{ type: "text", text }] };
|
|
232
229
|
}
|
|
@@ -13,6 +13,8 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
13
13
|
const { doc, project } = resolved;
|
|
14
14
|
const report = computeFreshness(doc.tracked_files, project);
|
|
15
15
|
const fileStatusIcon = (filePath) => {
|
|
16
|
+
if (report.broken.includes(filePath))
|
|
17
|
+
return "⚠ broken tracking (no snapshot — re-declare via update_doc or run refresh_file_snapshot)";
|
|
16
18
|
if (report.modified.includes(filePath))
|
|
17
19
|
return "✗ modified";
|
|
18
20
|
if (report.missing.includes(filePath))
|
|
@@ -29,8 +29,20 @@ export async function getDoc(storageEngine, args) {
|
|
|
29
29
|
}).join("\n")
|
|
30
30
|
: " (none)";
|
|
31
31
|
const freshnessReport = computeFreshness(doc.tracked_files, resolvedProject);
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
let stalenessBanner = "";
|
|
33
|
+
if (freshnessReport.status === "broken") {
|
|
34
|
+
stalenessBanner = [
|
|
35
|
+
`> [!WARNING]`,
|
|
36
|
+
`> **Le suivi de fichiers de cette documentation est cassé.**`,
|
|
37
|
+
`> Les entrées suivantes n'ont aucun snapshot de contenu et ne peuvent pas être vérifiées :`,
|
|
38
|
+
...freshnessReport.broken.map((f) => `> - \`${f}\` *(aucun snapshot)*`),
|
|
39
|
+
`>`,
|
|
40
|
+
`> La fraîcheur de ce document est invérifiable tant que ces entrées ne sont pas re-suivies. Relisez les fichiers sources et re-déclarez-les via \`update_doc\` (tracked_files), ou appelez \`refresh_file_snapshot\` si le contenu ci-dessous est encore exact.`,
|
|
41
|
+
``,
|
|
42
|
+
].join("\n");
|
|
43
|
+
}
|
|
44
|
+
else if (freshnessReport.status === "stale") {
|
|
45
|
+
stalenessBanner = [
|
|
34
46
|
`> [!WARNING]`,
|
|
35
47
|
`> **Cette documentation est potentiellement obsolète.**`,
|
|
36
48
|
`> Les fichiers suivants ont été modifiés depuis la dernière mise à jour de ce document :`,
|
|
@@ -41,8 +53,8 @@ export async function getDoc(storageEngine, args) {
|
|
|
41
53
|
`>`,
|
|
42
54
|
`> Vérifiez si le contenu ci-dessous est toujours valide. Si nécessaire, utilisez \`git_diff_doc\` pour voir les changements, puis mettez à jour la documentation avec \`patch_doc\` ou \`update_doc\`.`,
|
|
43
55
|
``,
|
|
44
|
-
].join("\n")
|
|
45
|
-
|
|
56
|
+
].join("\n");
|
|
57
|
+
}
|
|
46
58
|
const text = [
|
|
47
59
|
stalenessBanner,
|
|
48
60
|
`# ${doc.title}`,
|
|
@@ -62,7 +62,9 @@ export async function gitDiffDoc(storageEngine, args) {
|
|
|
62
62
|
}
|
|
63
63
|
else {
|
|
64
64
|
const freshness = computeFreshness(doc.tracked_files, project);
|
|
65
|
-
|
|
65
|
+
// Broken entries (no snapshot) can't be hash-compared, but the git diff
|
|
66
|
+
// is still the best signal the agent has to re-assess them — include them.
|
|
67
|
+
const changedPaths = new Set([...freshness.modified, ...freshness.missing, ...freshness.broken]);
|
|
66
68
|
filesToDiff = doc.tracked_files.filter((tf) => changedPaths.has(tf.path));
|
|
67
69
|
}
|
|
68
70
|
if (filesToDiff.length === 0) {
|
|
@@ -21,7 +21,7 @@ function renderProjectDocs(summaries, totalLabel) {
|
|
|
21
21
|
if (key)
|
|
22
22
|
lines.push(`\n 📁 ${key}`);
|
|
23
23
|
for (const s of groups.get(key)) {
|
|
24
|
-
const staleTag = s.isStale ? " [STALE]" : "";
|
|
24
|
+
const staleTag = s.isBroken ? " [BROKEN TRACKING]" : s.isStale ? " [STALE]" : "";
|
|
25
25
|
const tags = s.tags.length > 0 ? ` (${s.tags.join(", ")})` : "";
|
|
26
26
|
const indent = key ? " " : " ";
|
|
27
27
|
lines.push(`${indent}- ${s.title} [${s.id}]${tags}${staleTag} — updated ${s.updatedAt}`);
|
|
@@ -43,6 +43,7 @@ export async function listDocs(storageEngine, args) {
|
|
|
43
43
|
if (doc && doc.tracked_files.length > 0) {
|
|
44
44
|
const report = computeFreshness(doc.tracked_files, proj);
|
|
45
45
|
summary.isStale = report.status === "stale";
|
|
46
|
+
summary.isBroken = report.status === "broken";
|
|
46
47
|
}
|
|
47
48
|
}
|
|
48
49
|
if (summaries.length === 0) {
|
|
@@ -61,6 +62,7 @@ export async function listDocs(storageEngine, args) {
|
|
|
61
62
|
if (doc && doc.tracked_files.length > 0) {
|
|
62
63
|
const report = computeFreshness(doc.tracked_files, proj);
|
|
63
64
|
summary.isStale = report.status === "stale";
|
|
65
|
+
summary.isBroken = report.status === "broken";
|
|
64
66
|
}
|
|
65
67
|
}
|
|
66
68
|
grandTotal += summaries.length;
|
|
@@ -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.63";
|
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.63';
|