@imenam/mcp-documentor 1.0.77 → 1.0.78
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/README.md +58 -0
- package/dist/shared/schemas/doc.schema.d.ts +6 -0
- package/dist/shared/schemas/doc.schema.js +18 -2
- package/dist/src/cli/setup-mcp.js +3 -0
- package/dist/src/core/code-operation.d.ts +10 -0
- package/dist/src/core/code-operation.js +42 -0
- package/dist/src/core/code-ref-resolver.d.ts +11 -4
- package/dist/src/core/code-ref-resolver.js +76 -51
- package/dist/src/core/code-source.d.ts +79 -0
- package/dist/src/core/code-source.js +248 -0
- package/dist/src/core/github-code-api.d.ts +72 -0
- package/dist/src/core/github-code-api.js +182 -0
- package/dist/src/core/section-tracker.d.ts +38 -13
- package/dist/src/core/section-tracker.js +75 -72
- package/dist/src/index.js +19 -11
- package/dist/src/tools/check-freshness.d.ts +1 -2
- package/dist/src/tools/check-freshness.js +82 -34
- package/dist/src/tools/create-doc.js +2 -2
- package/dist/src/tools/get-doc-metadata.js +14 -13
- package/dist/src/tools/get-doc.js +23 -20
- package/dist/src/tools/git-diff-doc.js +69 -13
- package/dist/src/tools/list-docs.js +2 -2
- package/dist/src/tools/patch-doc.js +2 -2
- package/dist/src/tools/refresh-file-snapshot.js +19 -17
- package/dist/src/tools/update-doc.js +4 -4
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,21 +1,38 @@
|
|
|
1
1
|
import crypto from "crypto";
|
|
2
|
-
import fs from "fs";
|
|
3
2
|
const ANCHOR_SIZE = 3;
|
|
4
3
|
// Tolerance (in lines) around the expected bottom anchor position when lineCount is known.
|
|
5
4
|
const BOTTOM_ANCHOR_TOLERANCE = 10;
|
|
5
|
+
const sha256 = (text) => crypto.createHash("sha256").update(text).digest("hex");
|
|
6
|
+
const stripCR = (line) => (line.endsWith("\r") ? line.slice(0, -1) : line);
|
|
7
|
+
export function toFileContent(text) {
|
|
8
|
+
const raw = text.split("\n");
|
|
9
|
+
return { raw, lines: raw.map(stripCR) };
|
|
10
|
+
}
|
|
11
|
+
export function bytesToFileContent(bytes) {
|
|
12
|
+
return toFileContent(bytes.toString("utf-8"));
|
|
13
|
+
}
|
|
14
|
+
/** The whole-file hash written into a snapshot: taken over the normalised content. */
|
|
15
|
+
export function hashFileContent(fc) {
|
|
16
|
+
return sha256(fc.lines.join("\n"));
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Whether a stored whole-file hash still describes this content — true for the current
|
|
20
|
+
* (normalised) hash and for a legacy hash taken over the raw bytes.
|
|
21
|
+
*/
|
|
22
|
+
export function fileContentMatches(fc, storedHash) {
|
|
23
|
+
return hashFileContent(fc) === storedHash || sha256(fc.raw.join("\n")) === storedHash;
|
|
24
|
+
}
|
|
6
25
|
/**
|
|
7
|
-
* Reads a
|
|
26
|
+
* Reads a section defined by 1-indexed [start, end] line numbers and snapshots it.
|
|
8
27
|
* Extracts top/bottom anchor lines and computes a hash of the section content.
|
|
9
|
-
* The
|
|
10
|
-
* `lineCount` is used by
|
|
28
|
+
* The section text is intentionally not persisted — only anchors, sectionHash, and lineCount.
|
|
29
|
+
* `lineCount` is used by locateSection to constrain the bottom anchor search and prevent
|
|
11
30
|
* false matches when the file contains repeated lines.
|
|
12
31
|
*/
|
|
13
|
-
export function snapshotSection(
|
|
14
|
-
const fileContent = fs.readFileSync(absolutePath, "utf-8");
|
|
15
|
-
const allLines = fileContent.split("\n");
|
|
32
|
+
export function snapshotSection(fc, start, end) {
|
|
16
33
|
const startIdx = Math.max(0, start - 1);
|
|
17
|
-
const endIdx = Math.min(
|
|
18
|
-
const sectionLines =
|
|
34
|
+
const endIdx = Math.min(fc.lines.length - 1, end - 1);
|
|
35
|
+
const sectionLines = fc.lines.slice(startIdx, endIdx + 1);
|
|
19
36
|
let top;
|
|
20
37
|
let bottom;
|
|
21
38
|
if (sectionLines.length < ANCHOR_SIZE * 2) {
|
|
@@ -27,32 +44,26 @@ export function snapshotSection(absolutePath, start, end) {
|
|
|
27
44
|
top = sectionLines.slice(0, ANCHOR_SIZE);
|
|
28
45
|
bottom = sectionLines.slice(sectionLines.length - ANCHOR_SIZE);
|
|
29
46
|
}
|
|
30
|
-
|
|
31
|
-
return { anchors: { top, bottom }, sectionHash, lineCount: sectionLines.length };
|
|
47
|
+
return { anchors: { top, bottom }, sectionHash: sha256(sectionLines.join("\n")), lineCount: sectionLines.length };
|
|
32
48
|
}
|
|
33
49
|
/**
|
|
34
|
-
*
|
|
35
|
-
* then hashes the found content for comparison with the stored sectionHash.
|
|
36
|
-
* Also returns the current 1-indexed line range where the section was found,
|
|
37
|
-
* so callers can display it without storing it.
|
|
50
|
+
* Finds a tracked section in the current content using its stored anchors.
|
|
38
51
|
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
52
|
+
* Anchors stored before line-ending normalisation may still carry a trailing CR, so they are
|
|
53
|
+
* stripped here too — the comparison is always normalised on both sides.
|
|
41
54
|
*/
|
|
42
|
-
|
|
43
|
-
const
|
|
44
|
-
const
|
|
45
|
-
const
|
|
55
|
+
function locateSection(fc, section) {
|
|
56
|
+
const top = section.anchors.top.map(stripCR);
|
|
57
|
+
const bottom = section.anchors.bottom.map(stripCR);
|
|
58
|
+
const fileLines = fc.lines;
|
|
46
59
|
if (bottom.length === 0) {
|
|
47
60
|
// Short-section path: the top anchor IS the full section content
|
|
48
61
|
for (let i = 0; i <= fileLines.length - top.length; i++) {
|
|
49
62
|
if (top.every((line, j) => fileLines[i + j] === line)) {
|
|
50
|
-
|
|
51
|
-
const currentHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
|
|
52
|
-
return { found: true, currentHash, lines: { start: i + 1, end: i + top.length } };
|
|
63
|
+
return { start: i, endExclusive: i + top.length };
|
|
53
64
|
}
|
|
54
65
|
}
|
|
55
|
-
return
|
|
66
|
+
return undefined;
|
|
56
67
|
}
|
|
57
68
|
// Normal path: locate top anchor, then search for bottom anchor below it.
|
|
58
69
|
// If lineCount is stored, constrain the search window to prevent false matches on repeated lines.
|
|
@@ -60,64 +71,56 @@ export function findAndHashSection(absolutePath, section) {
|
|
|
60
71
|
if (!top.every((line, j) => fileLines[i + j] === line))
|
|
61
72
|
continue;
|
|
62
73
|
const searchStart = i + top.length;
|
|
63
|
-
|
|
64
|
-
const
|
|
65
|
-
? i + section.lineCount - bottom.length
|
|
66
|
-
: undefined;
|
|
67
|
-
const kMin = expectedBottomStart !== undefined
|
|
68
|
-
? Math.max(searchStart, expectedBottomStart - BOTTOM_ANCHOR_TOLERANCE)
|
|
69
|
-
: searchStart;
|
|
74
|
+
const expectedBottomStart = section.lineCount !== undefined ? i + section.lineCount - bottom.length : undefined;
|
|
75
|
+
const kMin = expectedBottomStart !== undefined ? Math.max(searchStart, expectedBottomStart - BOTTOM_ANCHOR_TOLERANCE) : searchStart;
|
|
70
76
|
const kMax = expectedBottomStart !== undefined
|
|
71
77
|
? Math.min(fileLines.length - bottom.length, expectedBottomStart + BOTTOM_ANCHOR_TOLERANCE)
|
|
72
78
|
: fileLines.length - bottom.length;
|
|
73
79
|
for (let k = kMin; k <= kMax; k++) {
|
|
74
80
|
if (!bottom.every((line, j) => fileLines[k + j] === line))
|
|
75
81
|
continue;
|
|
76
|
-
|
|
77
|
-
const currentHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
|
|
78
|
-
return { found: true, currentHash, lines: { start: i + 1, end: k + bottom.length } };
|
|
82
|
+
return { start: i, endExclusive: k + bottom.length };
|
|
79
83
|
}
|
|
80
84
|
}
|
|
81
|
-
return
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Locates a tracked section in the current content and compares it with its stored hash.
|
|
89
|
+
*
|
|
90
|
+
* Returns { found: false } if the anchors cannot be located — treated as stale by callers,
|
|
91
|
+
* since the surrounding context has changed enough to invalidate the documentation.
|
|
92
|
+
*/
|
|
93
|
+
export function findAndHashSection(fc, section) {
|
|
94
|
+
const located = locateSection(fc, section);
|
|
95
|
+
if (!located)
|
|
96
|
+
return { found: false };
|
|
97
|
+
const { start, endExclusive } = located;
|
|
98
|
+
const currentHash = sha256(fc.lines.slice(start, endExclusive).join("\n"));
|
|
99
|
+
const legacyHash = sha256(fc.raw.slice(start, endExclusive).join("\n"));
|
|
100
|
+
return {
|
|
101
|
+
found: true,
|
|
102
|
+
currentHash,
|
|
103
|
+
matchesStored: currentHash === section.sectionHash || legacyHash === section.sectionHash,
|
|
104
|
+
lines: { start: start + 1, end: endExclusive },
|
|
105
|
+
};
|
|
82
106
|
}
|
|
83
107
|
/**
|
|
84
108
|
* Locates a section by its current anchors, then refreshes its sectionHash to match
|
|
85
|
-
* the current
|
|
86
|
-
*
|
|
109
|
+
* the current content. Used by refresh_file_snapshot to acknowledge changes.
|
|
110
|
+
* The anchors are re-stored normalised — they were found, so only their encoding changes.
|
|
87
111
|
*/
|
|
88
|
-
export function relocateAndRefreshSection(
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
const { top, bottom } = section.anchors;
|
|
92
|
-
if (bottom.length === 0) {
|
|
93
|
-
for (let i = 0; i <= fileLines.length - top.length; i++) {
|
|
94
|
-
if (top.every((line, j) => fileLines[i + j] === line)) {
|
|
95
|
-
const sectionHash = crypto.createHash("sha256").update(fileLines.slice(i, i + top.length).join("\n")).digest("hex");
|
|
96
|
-
return { found: true, refreshed: { anchors: section.anchors, sectionHash } };
|
|
97
|
-
}
|
|
98
|
-
}
|
|
112
|
+
export function relocateAndRefreshSection(fc, section) {
|
|
113
|
+
const located = locateSection(fc, section);
|
|
114
|
+
if (!located)
|
|
99
115
|
return { found: false };
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
: searchStart;
|
|
111
|
-
const kMax = expectedBottomStart !== undefined
|
|
112
|
-
? Math.min(fileLines.length - bottom.length, expectedBottomStart + BOTTOM_ANCHOR_TOLERANCE)
|
|
113
|
-
: fileLines.length - bottom.length;
|
|
114
|
-
for (let k = kMin; k <= kMax; k++) {
|
|
115
|
-
if (!bottom.every((line, j) => fileLines[k + j] === line))
|
|
116
|
-
continue;
|
|
117
|
-
const sectionLines = fileLines.slice(i, k + bottom.length);
|
|
118
|
-
const sectionHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
|
|
119
|
-
return { found: true, refreshed: { anchors: section.anchors, sectionHash, lineCount: sectionLines.length } };
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return { found: false };
|
|
116
|
+
const { start, endExclusive } = located;
|
|
117
|
+
const sectionLines = fc.lines.slice(start, endExclusive);
|
|
118
|
+
return {
|
|
119
|
+
found: true,
|
|
120
|
+
refreshed: {
|
|
121
|
+
anchors: { top: section.anchors.top.map(stripCR), bottom: section.anchors.bottom.map(stripCR) },
|
|
122
|
+
sectionHash: sha256(sectionLines.join("\n")),
|
|
123
|
+
lineCount: sectionLines.length,
|
|
124
|
+
},
|
|
125
|
+
};
|
|
123
126
|
}
|
package/dist/src/index.js
CHANGED
|
@@ -19,7 +19,8 @@ import { updateDoc } from "./tools/update-doc.js";
|
|
|
19
19
|
import { searchDocs } from "./tools/search-docs.js";
|
|
20
20
|
import { checkFreshness, computeFreshness } from "./tools/check-freshness.js";
|
|
21
21
|
import { findAndHashSection } from "./core/section-tracker.js";
|
|
22
|
-
import {
|
|
22
|
+
import { getCodeSource } from "./core/code-source.js";
|
|
23
|
+
import { runInCodeOperation } from "./core/code-operation.js";
|
|
23
24
|
import { listProjects } from "./tools/list-projects.js";
|
|
24
25
|
import { refreshFileSnapshot } from "./tools/refresh-file-snapshot.js";
|
|
25
26
|
import { getDocMetadata } from "./tools/get-doc-metadata.js";
|
|
@@ -112,7 +113,11 @@ function broadcastToGUI(message) {
|
|
|
112
113
|
}
|
|
113
114
|
}
|
|
114
115
|
}
|
|
116
|
+
/** Each GUI message is one operation: the branch head it reads is resolved once, and freshly. */
|
|
115
117
|
function handleIPCMessage(msg) {
|
|
118
|
+
return runInCodeOperation(() => dispatchIPCMessage(msg));
|
|
119
|
+
}
|
|
120
|
+
function dispatchIPCMessage(msg) {
|
|
116
121
|
if (msg.type === "ALREADY_RUNNING") {
|
|
117
122
|
guiStatus = "already_running";
|
|
118
123
|
guiUrl = msg.data?.url ?? guiUrl;
|
|
@@ -202,7 +207,7 @@ async function listDocsWithFreshness(project) {
|
|
|
202
207
|
for (const doc of docs) {
|
|
203
208
|
const full = await storageEngine.getDoc(doc.id, project);
|
|
204
209
|
if (full && full.tracked_files.length > 0) {
|
|
205
|
-
const report = computeFreshness(full.tracked_files, project);
|
|
210
|
+
const report = await computeFreshness(full.tracked_files, project);
|
|
206
211
|
doc.isStale = report.status === "stale" || report.status === "broken";
|
|
207
212
|
doc.isBroken = report.status === "broken";
|
|
208
213
|
}
|
|
@@ -512,19 +517,19 @@ async function handleGetFileSection(msg) {
|
|
|
512
517
|
const trackedFile = doc.tracked_files.find((f) => f.path === filePath);
|
|
513
518
|
if (!trackedFile || !trackedFile.sections?.length)
|
|
514
519
|
throw new Error("No sections for this file");
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
520
|
+
const source = getCodeSource(project);
|
|
521
|
+
const file = await source.read(trackedFile.path);
|
|
522
|
+
if (!file)
|
|
523
|
+
throw new Error(`Cannot read "${trackedFile.path}" from ${source.label}`);
|
|
519
524
|
const sections = trackedFile.sections.map((section) => {
|
|
520
|
-
const result = findAndHashSection(
|
|
525
|
+
const result = findAndHashSection(file.content, section);
|
|
521
526
|
if (!result.found)
|
|
522
527
|
return null;
|
|
523
528
|
const { start, end } = result.lines;
|
|
524
529
|
return {
|
|
525
530
|
start,
|
|
526
531
|
end,
|
|
527
|
-
code:
|
|
532
|
+
code: file.content.lines.slice(start - 1, end).join("\n"),
|
|
528
533
|
};
|
|
529
534
|
}).filter(Boolean);
|
|
530
535
|
broadcastToGUI({
|
|
@@ -556,7 +561,7 @@ async function handleCheckFreshness(msg) {
|
|
|
556
561
|
});
|
|
557
562
|
return;
|
|
558
563
|
}
|
|
559
|
-
const report = computeFreshness(doc.tracked_files, project);
|
|
564
|
+
const report = await computeFreshness(doc.tracked_files, project);
|
|
560
565
|
broadcastToGUI({
|
|
561
566
|
type: "CHECK_FRESHNESS_RESPONSE",
|
|
562
567
|
correlationId: msg.correlationId,
|
|
@@ -1368,7 +1373,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1368
1373
|
tools: isReadOnly() ? tools.filter((t) => !WRITE_TOOLS.has(t.name)) : tools,
|
|
1369
1374
|
};
|
|
1370
1375
|
});
|
|
1371
|
-
|
|
1376
|
+
// Each tool call is one operation: the branch head every tracked file is compared against is
|
|
1377
|
+
// resolved once for the whole call, and never carried over to the next one.
|
|
1378
|
+
server.setRequestHandler(CallToolRequestSchema, (request) => runInCodeOperation(() => handleToolCall(request)));
|
|
1379
|
+
async function handleToolCall(request) {
|
|
1372
1380
|
const { name, arguments: args } = request.params;
|
|
1373
1381
|
if (isReadOnly() && WRITE_TOOLS.has(name)) {
|
|
1374
1382
|
return {
|
|
@@ -1484,7 +1492,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1484
1492
|
isError: true,
|
|
1485
1493
|
};
|
|
1486
1494
|
}
|
|
1487
|
-
}
|
|
1495
|
+
}
|
|
1488
1496
|
async function syncPullOnStartup() {
|
|
1489
1497
|
if (!syncEngine.isConfigured())
|
|
1490
1498
|
return;
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import type { TrackedFile, FreshnessReport } from "../../shared/schemas/doc.schema.js";
|
|
2
2
|
import type { StorageEngine } from "../core/storage-engine.js";
|
|
3
|
-
export declare function
|
|
4
|
-
export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): FreshnessReport;
|
|
3
|
+
export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): Promise<FreshnessReport>;
|
|
5
4
|
export declare function formatFreshnessReport(title: string, report: FreshnessReport): string;
|
|
6
5
|
export declare function checkFreshness(storageEngine: StorageEngine, args: unknown): Promise<{
|
|
7
6
|
content: {
|
|
@@ -1,15 +1,12 @@
|
|
|
1
|
-
import crypto from "crypto";
|
|
2
|
-
import fs from "fs";
|
|
3
1
|
import { CheckFreshnessArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
4
|
-
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
|
-
import {
|
|
2
|
+
import { findAndHashSection, fileContentMatches } from "../core/section-tracker.js";
|
|
3
|
+
import { getCodeSource } from "../core/code-source.js";
|
|
6
4
|
import { resolveDocInProject, isScopeError, scopeErrorResult } from "../core/project-scope.js";
|
|
7
|
-
export function
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
export function computeFreshness(trackedFiles, project) {
|
|
5
|
+
export async function computeFreshness(trackedFiles, project) {
|
|
6
|
+
const source = getCodeSource(project);
|
|
7
|
+
const empty = { modified: [], missing: [], unchanged: [], anchors_lost: [], broken: [], source: source.label };
|
|
11
8
|
if (!trackedFiles || trackedFiles.length === 0) {
|
|
12
|
-
return { status: "no_tracking",
|
|
9
|
+
return { status: "no_tracking", ...empty, total: 0 };
|
|
13
10
|
}
|
|
14
11
|
const modified = [];
|
|
15
12
|
const missing = [];
|
|
@@ -17,11 +14,35 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
17
14
|
const anchors_lost = [];
|
|
18
15
|
const broken = [];
|
|
19
16
|
for (const tracked of trackedFiles) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
17
|
+
let file;
|
|
18
|
+
try {
|
|
19
|
+
// When the source can identify the content without transferring it and that identity is
|
|
20
|
+
// unchanged, the file is provably untouched — sections included — so nothing is read.
|
|
21
|
+
if (tracked.sourceBlobId && (await source.contentId(tracked.path)) === tracked.sourceBlobId) {
|
|
22
|
+
unchanged.push(tracked.path);
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
file = await source.read(tracked.path);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
// The source itself is unreachable (network, auth, rate limit). Nothing can be compared, and
|
|
29
|
+
// reporting what we did not manage to read as "modified" would be crying wolf.
|
|
30
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
31
|
+
return {
|
|
32
|
+
status: "unverifiable",
|
|
33
|
+
modified: [],
|
|
34
|
+
missing: [],
|
|
35
|
+
unchanged: [],
|
|
36
|
+
anchors_lost: [],
|
|
37
|
+
broken: [],
|
|
38
|
+
total: trackedFiles.length,
|
|
39
|
+
source: source.label,
|
|
40
|
+
sourceError: message,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
// A path that does not resolve at the source counts as absent, so we end up "unverifiable"
|
|
44
|
+
// rather than crying wolf with "stale".
|
|
45
|
+
if (!file) {
|
|
25
46
|
missing.push(tracked.path);
|
|
26
47
|
continue;
|
|
27
48
|
}
|
|
@@ -29,13 +50,13 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
29
50
|
let sectionStale = false;
|
|
30
51
|
let anchorLost = false;
|
|
31
52
|
for (const section of tracked.sections) {
|
|
32
|
-
const result = findAndHashSection(
|
|
53
|
+
const result = findAndHashSection(file.content, section);
|
|
33
54
|
if (!result.found) {
|
|
34
55
|
sectionStale = true;
|
|
35
56
|
anchorLost = true;
|
|
36
57
|
break;
|
|
37
58
|
}
|
|
38
|
-
else if (result.
|
|
59
|
+
else if (!result.matchesStored) {
|
|
39
60
|
sectionStale = true;
|
|
40
61
|
break;
|
|
41
62
|
}
|
|
@@ -53,12 +74,11 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
53
74
|
}
|
|
54
75
|
}
|
|
55
76
|
else if (tracked.contentHash) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
modified.push(tracked.path);
|
|
77
|
+
if (fileContentMatches(file.content, tracked.contentHash)) {
|
|
78
|
+
unchanged.push(tracked.path);
|
|
59
79
|
}
|
|
60
80
|
else {
|
|
61
|
-
|
|
81
|
+
modified.push(tracked.path);
|
|
62
82
|
}
|
|
63
83
|
}
|
|
64
84
|
else {
|
|
@@ -69,14 +89,23 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
69
89
|
broken.push(tracked.path);
|
|
70
90
|
}
|
|
71
91
|
}
|
|
72
|
-
// When every tracked file is absent, the source code simply isn't present
|
|
73
|
-
//
|
|
74
|
-
// freshness, so report "unverifiable" rather than crying wolf with "stale".
|
|
75
|
-
// A partial absence (some files missing, others present) is treated as a real
|
|
76
|
-
//
|
|
92
|
+
// When every tracked file is absent, the source code simply isn't present at the source
|
|
93
|
+
// (e.g. docs cloned without the codebase, or a branch where those files don't exist) — we
|
|
94
|
+
// can't verify freshness, so report "unverifiable" rather than crying wolf with "stale".
|
|
95
|
+
// A partial absence (some files missing, others present) is treated as a real change signal
|
|
96
|
+
// (likely deletions) and still counts as stale.
|
|
77
97
|
const allMissing = missing.length === trackedFiles.length;
|
|
78
98
|
if (allMissing) {
|
|
79
|
-
return {
|
|
99
|
+
return {
|
|
100
|
+
status: "unverifiable",
|
|
101
|
+
modified,
|
|
102
|
+
missing,
|
|
103
|
+
unchanged,
|
|
104
|
+
anchors_lost,
|
|
105
|
+
broken,
|
|
106
|
+
total: trackedFiles.length,
|
|
107
|
+
source: source.label,
|
|
108
|
+
};
|
|
80
109
|
}
|
|
81
110
|
const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
|
|
82
111
|
// Broken tracking outranks everything: no freshness verdict is trustworthy
|
|
@@ -89,24 +118,30 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
89
118
|
anchors_lost,
|
|
90
119
|
broken,
|
|
91
120
|
total: trackedFiles.length,
|
|
121
|
+
source: source.label,
|
|
92
122
|
};
|
|
93
123
|
}
|
|
94
124
|
export function formatFreshnessReport(title, report) {
|
|
125
|
+
const sourceLine = report.source ? `\nCode source: ${report.source}.` : "";
|
|
95
126
|
if (report.status === "no_tracking") {
|
|
96
127
|
return `No tracked files for "${title}". Use tracked_files when creating or updating the doc to enable staleness detection.`;
|
|
97
128
|
}
|
|
98
129
|
if (report.status === "unverifiable") {
|
|
99
|
-
|
|
130
|
+
const reason = report.sourceError
|
|
131
|
+
? `the code source could not be consulted — ${report.sourceError}`
|
|
132
|
+
: `none of the ${report.total} tracked file(s) exist there`;
|
|
133
|
+
return (`UNVERIFIABLE: "${title}" — ${reason}, so freshness cannot be checked.${sourceLine}\n` +
|
|
134
|
+
`This is expected when the documentation is read without access to its source codebase.`);
|
|
100
135
|
}
|
|
101
136
|
let text;
|
|
102
137
|
if (report.status === "broken") {
|
|
103
|
-
text = `BROKEN TRACKING: "${title}" — some tracked entries have no content snapshot and cannot be checked
|
|
138
|
+
text = `BROKEN TRACKING: "${title}" — some tracked entries have no content snapshot and cannot be checked.${sourceLine}\n`;
|
|
104
139
|
}
|
|
105
140
|
else if (report.status === "stale") {
|
|
106
|
-
text = `STALE: "${title}" — tracked files have changed since the documentation was last updated
|
|
141
|
+
text = `STALE: "${title}" — tracked files have changed since the documentation was last updated.${sourceLine}\n`;
|
|
107
142
|
}
|
|
108
143
|
else {
|
|
109
|
-
text = `UP TO DATE: "${title}" — no tracked files have been modified
|
|
144
|
+
text = `UP TO DATE: "${title}" — no tracked files have been modified.${sourceLine}\n`;
|
|
110
145
|
}
|
|
111
146
|
const anchorLostCount = report.anchors_lost?.length ?? 0;
|
|
112
147
|
const brokenCount = report.broken?.length ?? 0;
|
|
@@ -139,12 +174,22 @@ function renderUntrackedList(titles, heading) {
|
|
|
139
174
|
}
|
|
140
175
|
async function scanProjectFreshness(storageEngine, project) {
|
|
141
176
|
const summaries = await storageEngine.listDocs(project);
|
|
142
|
-
const scan = {
|
|
177
|
+
const scan = {
|
|
178
|
+
docCount: summaries.length,
|
|
179
|
+
stale: [],
|
|
180
|
+
noTracking: [],
|
|
181
|
+
broken: [],
|
|
182
|
+
upToDate: 0,
|
|
183
|
+
unverifiable: 0,
|
|
184
|
+
source: getCodeSource(project).label,
|
|
185
|
+
};
|
|
143
186
|
for (const summary of summaries) {
|
|
144
187
|
const doc = await storageEngine.getDoc(summary.id, project);
|
|
145
188
|
if (!doc)
|
|
146
189
|
continue;
|
|
147
|
-
const report = computeFreshness(doc.tracked_files, project);
|
|
190
|
+
const report = await computeFreshness(doc.tracked_files, project);
|
|
191
|
+
if (report.sourceError)
|
|
192
|
+
scan.sourceError = report.sourceError;
|
|
148
193
|
if (report.status === "broken") {
|
|
149
194
|
const files = report.broken.map((f) => ` • no snapshot: ${f}`).join("\n");
|
|
150
195
|
scan.broken.push(` - **${doc.title}** [${doc.id}]\n${files}`);
|
|
@@ -170,7 +215,10 @@ async function scanProjectFreshness(storageEngine, project) {
|
|
|
170
215
|
return scan;
|
|
171
216
|
}
|
|
172
217
|
function renderScanSummary(scan) {
|
|
173
|
-
let text =
|
|
218
|
+
let text = `Code source: ${scan.source}.\n\n`;
|
|
219
|
+
if (scan.sourceError)
|
|
220
|
+
text += `⚠ The code source could not be consulted: ${scan.sourceError}\n\n`;
|
|
221
|
+
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`;
|
|
174
222
|
if (scan.broken.length > 0)
|
|
175
223
|
text += `, **${scan.broken.length} with broken tracking**`;
|
|
176
224
|
return text + ".";
|
|
@@ -198,7 +246,7 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
198
246
|
const resolved = await resolveDocInProject(storageEngine, validated.path, project, "freshness check");
|
|
199
247
|
if (isScopeError(resolved))
|
|
200
248
|
return scopeErrorResult(resolved);
|
|
201
|
-
const report = computeFreshness(resolved.doc.tracked_files, project);
|
|
249
|
+
const report = await computeFreshness(resolved.doc.tracked_files, project);
|
|
202
250
|
return { content: [{ type: "text", text: formatFreshnessReport(resolved.doc.title, report) }] };
|
|
203
251
|
}
|
|
204
252
|
if (validated.project) {
|
|
@@ -17,7 +17,7 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
|
|
|
17
17
|
const rawContent = validated.content_file
|
|
18
18
|
? fs.readFileSync(validated.content_file, "utf-8")
|
|
19
19
|
: validated.content;
|
|
20
|
-
const { content, additionalTrackedFiles } = resolveCodeRefs(rawContent);
|
|
20
|
+
const { content, additionalTrackedFiles } = await resolveCodeRefs(rawContent, project);
|
|
21
21
|
const slug = slugify(validated.title);
|
|
22
22
|
const id = validated.folder ? `${validated.folder}/${slug}` : slug;
|
|
23
23
|
const now = new Date().toISOString();
|
|
@@ -34,7 +34,7 @@ export async function createDoc(storageEngine, embeddingEngine, args) {
|
|
|
34
34
|
...(validated.tracked_files ?? []),
|
|
35
35
|
...additionalTrackedFiles,
|
|
36
36
|
];
|
|
37
|
-
const trackedFiles = allTrackedInputs.length > 0 ? snapshotTrackedFiles(allTrackedInputs, resolvedProject) : [];
|
|
37
|
+
const trackedFiles = allTrackedInputs.length > 0 ? await snapshotTrackedFiles(allTrackedInputs, resolvedProject) : [];
|
|
38
38
|
const folder = validated.folder;
|
|
39
39
|
const doc = {
|
|
40
40
|
id,
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
1
|
import { GetDocMetadataArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
3
2
|
import { computeFreshness } from "./check-freshness.js";
|
|
4
|
-
import {
|
|
5
|
-
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
3
|
+
import { locateTrackedSections } from "../core/code-source.js";
|
|
6
4
|
import { resolveDocInProject, isScopeError, scopeErrorResult } from "../core/project-scope.js";
|
|
7
5
|
export async function getDocMetadata(storageEngine, args) {
|
|
8
6
|
try {
|
|
@@ -12,7 +10,7 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
12
10
|
return scopeErrorResult(resolved);
|
|
13
11
|
const { doc } = resolved;
|
|
14
12
|
const project = validated.project;
|
|
15
|
-
const report = computeFreshness(doc.tracked_files, project);
|
|
13
|
+
const report = await computeFreshness(doc.tracked_files, project);
|
|
16
14
|
const fileStatusIcon = (filePath) => {
|
|
17
15
|
if (report.broken.includes(filePath))
|
|
18
16
|
return "⚠ broken tracking (no snapshot — re-declare via update_doc or run refresh_file_snapshot)";
|
|
@@ -28,24 +26,27 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
28
26
|
text += `**Created:** ${doc.createdAt}\n`;
|
|
29
27
|
text += `**Updated:** ${doc.updatedAt}\n`;
|
|
30
28
|
text += `**Freshness:** ${report.status}\n`;
|
|
29
|
+
text += `**Code source:** ${report.source ?? "unknown"}\n`;
|
|
30
|
+
if (report.sourceError)
|
|
31
|
+
text += `**Source error:** ${report.sourceError}\n`;
|
|
31
32
|
if (doc.tracked_files.length > 0) {
|
|
32
33
|
text += `\nTracked files (${doc.tracked_files.length} total):\n`;
|
|
33
|
-
|
|
34
|
-
|
|
34
|
+
const entries = await Promise.all(doc.tracked_files.map(async (f) => {
|
|
35
|
+
const revision = f.sourceRevision ? `\n Snapshot commit: ${f.sourceRevision.slice(0, 7)}` : "";
|
|
36
|
+
let entry = ` ${fileStatusIcon(f.path)} — ${f.path}\n File mtime: ${f.lastModifiedAt}${revision}`;
|
|
35
37
|
if (f.sections && f.sections.length > 0) {
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const location = result.found
|
|
41
|
-
? `lines ${result.lines.start}–${result.lines.end}`
|
|
38
|
+
const positions = await locateTrackedSections(project, f.path, f.sections);
|
|
39
|
+
const sectionLines = positions.map((position, idx) => {
|
|
40
|
+
const location = position
|
|
41
|
+
? `lines ${position.start}–${position.end}`
|
|
42
42
|
: "section introuvable (ancres modifiées)";
|
|
43
43
|
return ` - Section ${idx + 1}: ${location}`;
|
|
44
44
|
});
|
|
45
45
|
entry += `\n Sections (position actuelle):\n${sectionLines.join("\n")}`;
|
|
46
46
|
}
|
|
47
47
|
return entry;
|
|
48
|
-
})
|
|
48
|
+
}));
|
|
49
|
+
text += entries.join("\n");
|
|
49
50
|
}
|
|
50
51
|
else {
|
|
51
52
|
text += "\nNo tracked files.";
|
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import fs from "fs";
|
|
2
1
|
import { GetDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
3
2
|
import { computeFreshness } from "./check-freshness.js";
|
|
4
|
-
import {
|
|
5
|
-
import { toAbsolutePath } from "../core/path-resolver.js";
|
|
3
|
+
import { locateTrackedSections } from "../core/code-source.js";
|
|
6
4
|
import { resolveDocInProject, isScopeError, scopeErrorResult } from "../core/project-scope.js";
|
|
7
5
|
export async function getDoc(storageEngine, args) {
|
|
8
6
|
try {
|
|
@@ -12,24 +10,18 @@ export async function getDoc(storageEngine, args) {
|
|
|
12
10
|
return scopeErrorResult(resolved);
|
|
13
11
|
const { doc } = resolved;
|
|
14
12
|
const resolvedProject = validated.project;
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
if (f.sections && f.sections.length > 0) {
|
|
18
|
-
const abs = toAbsolutePath(f.path, resolvedProject);
|
|
19
|
-
const readable = abs && fs.existsSync(abs);
|
|
20
|
-
const sectionLines = f.sections.map((section, idx) => {
|
|
21
|
-
const result = readable ? findAndHashSection(abs, section) : { found: false };
|
|
22
|
-
const location = result.found
|
|
23
|
-
? `lines ${result.lines.start}–${result.lines.end}`
|
|
24
|
-
: "section introuvable";
|
|
25
|
-
return ` - Section ${idx + 1}: ${location}`;
|
|
26
|
-
});
|
|
27
|
-
return ` - ${f.path} (snapshot: ${f.lastModifiedAt})\n${sectionLines.join("\n")}`;
|
|
28
|
-
}
|
|
13
|
+
const trackedFileEntries = await Promise.all(doc.tracked_files.map(async (f) => {
|
|
14
|
+
if (!f.sections || f.sections.length === 0)
|
|
29
15
|
return ` - ${f.path} (snapshot: ${f.lastModifiedAt})`;
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
16
|
+
const positions = await locateTrackedSections(resolvedProject, f.path, f.sections);
|
|
17
|
+
const sectionLines = positions.map((position, idx) => {
|
|
18
|
+
const location = position ? `lines ${position.start}–${position.end}` : "section introuvable";
|
|
19
|
+
return ` - Section ${idx + 1}: ${location}`;
|
|
20
|
+
});
|
|
21
|
+
return ` - ${f.path} (snapshot: ${f.lastModifiedAt})\n${sectionLines.join("\n")}`;
|
|
22
|
+
}));
|
|
23
|
+
const trackedFilesText = trackedFileEntries.length > 0 ? trackedFileEntries.join("\n") : " (none)";
|
|
24
|
+
const freshnessReport = await computeFreshness(doc.tracked_files, resolvedProject);
|
|
33
25
|
let stalenessBanner = "";
|
|
34
26
|
if (freshnessReport.status === "broken") {
|
|
35
27
|
stalenessBanner = [
|
|
@@ -52,10 +44,21 @@ export async function getDoc(storageEngine, args) {
|
|
|
52
44
|
...freshnessReport.missing.map((f) => `> - \`${f}\` *(introuvable)*`),
|
|
53
45
|
],
|
|
54
46
|
`>`,
|
|
47
|
+
`> Source du code : ${freshnessReport.source ?? "inconnue"}.`,
|
|
55
48
|
`> 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\`.`,
|
|
56
49
|
``,
|
|
57
50
|
].join("\n");
|
|
58
51
|
}
|
|
52
|
+
else if (freshnessReport.sourceError) {
|
|
53
|
+
stalenessBanner = [
|
|
54
|
+
`> [!NOTE]`,
|
|
55
|
+
`> **La fraîcheur de cette documentation n'a pas pu être vérifiée.**`,
|
|
56
|
+
`> Source du code : ${freshnessReport.source ?? "inconnue"} — ${freshnessReport.sourceError}`,
|
|
57
|
+
`>`,
|
|
58
|
+
`> Le contenu ci-dessous est affiché tel quel : il peut être à jour comme obsolète.`,
|
|
59
|
+
``,
|
|
60
|
+
].join("\n");
|
|
61
|
+
}
|
|
59
62
|
const text = [
|
|
60
63
|
stalenessBanner,
|
|
61
64
|
`# ${doc.title}`,
|