@imenam/mcp-documentor 1.0.77 → 1.0.79
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/concurrency.d.ts +12 -0
- package/dist/src/core/concurrency.js +27 -0
- package/dist/src/core/github-code-api.d.ts +77 -0
- package/dist/src/core/github-code-api.js +195 -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 +27 -18
- package/dist/src/tools/check-freshness.d.ts +7 -2
- package/dist/src/tools/check-freshness.js +108 -77
- 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 +18 -17
- 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
|
@@ -17,9 +17,11 @@ import { getDoc } from "./tools/get-doc.js";
|
|
|
17
17
|
import { listDocs } from "./tools/list-docs.js";
|
|
18
18
|
import { updateDoc } from "./tools/update-doc.js";
|
|
19
19
|
import { searchDocs } from "./tools/search-docs.js";
|
|
20
|
-
import { checkFreshness, computeFreshness } from "./tools/check-freshness.js";
|
|
20
|
+
import { checkFreshness, computeFreshness, DOC_CONCURRENCY } from "./tools/check-freshness.js";
|
|
21
|
+
import { mapWithConcurrency } from "./core/concurrency.js";
|
|
21
22
|
import { findAndHashSection } from "./core/section-tracker.js";
|
|
22
|
-
import {
|
|
23
|
+
import { getCodeSource } from "./core/code-source.js";
|
|
24
|
+
import { runInCodeOperation } from "./core/code-operation.js";
|
|
23
25
|
import { listProjects } from "./tools/list-projects.js";
|
|
24
26
|
import { refreshFileSnapshot } from "./tools/refresh-file-snapshot.js";
|
|
25
27
|
import { getDocMetadata } from "./tools/get-doc-metadata.js";
|
|
@@ -112,7 +114,11 @@ function broadcastToGUI(message) {
|
|
|
112
114
|
}
|
|
113
115
|
}
|
|
114
116
|
}
|
|
117
|
+
/** Each GUI message is one operation: the branch head it reads is resolved once, and freshly. */
|
|
115
118
|
function handleIPCMessage(msg) {
|
|
119
|
+
return runInCodeOperation(() => dispatchIPCMessage(msg));
|
|
120
|
+
}
|
|
121
|
+
function dispatchIPCMessage(msg) {
|
|
116
122
|
if (msg.type === "ALREADY_RUNNING") {
|
|
117
123
|
guiStatus = "already_running";
|
|
118
124
|
guiUrl = msg.data?.url ?? guiUrl;
|
|
@@ -199,14 +205,14 @@ function requireProjectFromIPC(msg) {
|
|
|
199
205
|
/** Loads one project's summaries and flags each entry as stale/broken. */
|
|
200
206
|
async function listDocsWithFreshness(project) {
|
|
201
207
|
const docs = await storageEngine.listDocs(project);
|
|
202
|
-
|
|
208
|
+
await mapWithConcurrency(docs, DOC_CONCURRENCY, async (doc) => {
|
|
203
209
|
const full = await storageEngine.getDoc(doc.id, project);
|
|
204
|
-
if (full
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
+
if (!full || full.tracked_files.length === 0)
|
|
211
|
+
return;
|
|
212
|
+
const report = await computeFreshness(full.tracked_files, project);
|
|
213
|
+
doc.isStale = report.status === "stale" || report.status === "broken";
|
|
214
|
+
doc.isBroken = report.status === "broken";
|
|
215
|
+
});
|
|
210
216
|
return docs;
|
|
211
217
|
}
|
|
212
218
|
/**
|
|
@@ -512,19 +518,19 @@ async function handleGetFileSection(msg) {
|
|
|
512
518
|
const trackedFile = doc.tracked_files.find((f) => f.path === filePath);
|
|
513
519
|
if (!trackedFile || !trackedFile.sections?.length)
|
|
514
520
|
throw new Error("No sections for this file");
|
|
515
|
-
const
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
521
|
+
const source = getCodeSource(project);
|
|
522
|
+
const file = await source.read(trackedFile.path);
|
|
523
|
+
if (!file)
|
|
524
|
+
throw new Error(`Cannot read "${trackedFile.path}" from ${source.label}`);
|
|
519
525
|
const sections = trackedFile.sections.map((section) => {
|
|
520
|
-
const result = findAndHashSection(
|
|
526
|
+
const result = findAndHashSection(file.content, section);
|
|
521
527
|
if (!result.found)
|
|
522
528
|
return null;
|
|
523
529
|
const { start, end } = result.lines;
|
|
524
530
|
return {
|
|
525
531
|
start,
|
|
526
532
|
end,
|
|
527
|
-
code:
|
|
533
|
+
code: file.content.lines.slice(start - 1, end).join("\n"),
|
|
528
534
|
};
|
|
529
535
|
}).filter(Boolean);
|
|
530
536
|
broadcastToGUI({
|
|
@@ -556,7 +562,7 @@ async function handleCheckFreshness(msg) {
|
|
|
556
562
|
});
|
|
557
563
|
return;
|
|
558
564
|
}
|
|
559
|
-
const report = computeFreshness(doc.tracked_files, project);
|
|
565
|
+
const report = await computeFreshness(doc.tracked_files, project);
|
|
560
566
|
broadcastToGUI({
|
|
561
567
|
type: "CHECK_FRESHNESS_RESPONSE",
|
|
562
568
|
correlationId: msg.correlationId,
|
|
@@ -1368,7 +1374,10 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
1368
1374
|
tools: isReadOnly() ? tools.filter((t) => !WRITE_TOOLS.has(t.name)) : tools,
|
|
1369
1375
|
};
|
|
1370
1376
|
});
|
|
1371
|
-
|
|
1377
|
+
// Each tool call is one operation: the branch head every tracked file is compared against is
|
|
1378
|
+
// resolved once for the whole call, and never carried over to the next one.
|
|
1379
|
+
server.setRequestHandler(CallToolRequestSchema, (request) => runInCodeOperation(() => handleToolCall(request)));
|
|
1380
|
+
async function handleToolCall(request) {
|
|
1372
1381
|
const { name, arguments: args } = request.params;
|
|
1373
1382
|
if (isReadOnly() && WRITE_TOOLS.has(name)) {
|
|
1374
1383
|
return {
|
|
@@ -1484,7 +1493,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
1484
1493
|
isError: true,
|
|
1485
1494
|
};
|
|
1486
1495
|
}
|
|
1487
|
-
}
|
|
1496
|
+
}
|
|
1488
1497
|
async function syncPullOnStartup() {
|
|
1489
1498
|
if (!syncEngine.isConfigured())
|
|
1490
1499
|
return;
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { TrackedFile, FreshnessReport } from "../../shared/schemas/doc.schema.js";
|
|
2
2
|
import type { StorageEngine } from "../core/storage-engine.js";
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Docs checked at once during a whole-project scan. With FILE_CONCURRENCY this caps in-flight
|
|
5
|
+
* source reads at 32 — enough to hide network latency on a 160-doc project, far below anything
|
|
6
|
+
* GitHub objects to.
|
|
7
|
+
*/
|
|
8
|
+
export declare const DOC_CONCURRENCY = 8;
|
|
9
|
+
export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): Promise<FreshnessReport>;
|
|
5
10
|
export declare function formatFreshnessReport(title: string, report: FreshnessReport): string;
|
|
6
11
|
export declare function checkFreshness(storageEngine: StorageEngine, args: unknown): Promise<{
|
|
7
12
|
content: {
|
|
@@ -1,82 +1,91 @@
|
|
|
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";
|
|
4
|
+
import { mapWithConcurrency } from "../core/concurrency.js";
|
|
6
5
|
import { resolveDocInProject, isScopeError, scopeErrorResult } from "../core/project-scope.js";
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
/** Files of one doc checked at once. Kept modest: docs are also scanned several at a time. */
|
|
7
|
+
const FILE_CONCURRENCY = 4;
|
|
8
|
+
/**
|
|
9
|
+
* Docs checked at once during a whole-project scan. With FILE_CONCURRENCY this caps in-flight
|
|
10
|
+
* source reads at 32 — enough to hide network latency on a 160-doc project, far below anything
|
|
11
|
+
* GitHub objects to.
|
|
12
|
+
*/
|
|
13
|
+
export const DOC_CONCURRENCY = 8;
|
|
14
|
+
/** Compares one tracked file with the source, never throwing — the failure is part of the verdict. */
|
|
15
|
+
async function checkTrackedFile(tracked, source) {
|
|
16
|
+
try {
|
|
17
|
+
// When the source can identify the content without transferring it and that identity is
|
|
18
|
+
// unchanged, the file is provably untouched — sections included — so nothing is read.
|
|
19
|
+
if (tracked.sourceBlobId && (await source.contentId(tracked.path)) === tracked.sourceBlobId) {
|
|
20
|
+
return { kind: "unchanged" };
|
|
21
|
+
}
|
|
22
|
+
const file = await source.read(tracked.path);
|
|
23
|
+
// A path that does not resolve at the source counts as absent, so a doc whose files are all
|
|
24
|
+
// gone ends up "unverifiable" rather than crying wolf with "stale".
|
|
25
|
+
if (!file)
|
|
26
|
+
return { kind: "missing" };
|
|
27
|
+
if (tracked.sections && tracked.sections.length > 0) {
|
|
28
|
+
for (const section of tracked.sections) {
|
|
29
|
+
const result = findAndHashSection(file.content, section);
|
|
30
|
+
if (!result.found)
|
|
31
|
+
return { kind: "anchors_lost" };
|
|
32
|
+
if (!result.matchesStored)
|
|
33
|
+
return { kind: "modified" };
|
|
34
|
+
}
|
|
35
|
+
return { kind: "unchanged" };
|
|
36
|
+
}
|
|
37
|
+
if (tracked.contentHash) {
|
|
38
|
+
return fileContentMatches(file.content, tracked.contentHash) ? { kind: "unchanged" } : { kind: "modified" };
|
|
39
|
+
}
|
|
40
|
+
// Hash comparison is the only freshness system. An entry with neither sections nor
|
|
41
|
+
// contentHash predates it and cannot be checked — it is reported as broken (never checked by
|
|
42
|
+
// any other means) so readers can still expose the doc and its tracked list for re-tracking.
|
|
43
|
+
return { kind: "broken" };
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
// The source itself is unreachable (network, auth, rate limit). Nothing can be compared, and
|
|
47
|
+
// reporting what we did not manage to read as "modified" would be crying wolf.
|
|
48
|
+
return { kind: "source_error", message: err instanceof Error ? err.message : String(err) };
|
|
49
|
+
}
|
|
9
50
|
}
|
|
10
|
-
export function computeFreshness(trackedFiles, project) {
|
|
51
|
+
export async function computeFreshness(trackedFiles, project) {
|
|
52
|
+
const source = getCodeSource(project);
|
|
53
|
+
const empty = { modified: [], missing: [], unchanged: [], anchors_lost: [], broken: [], source: source.label };
|
|
11
54
|
if (!trackedFiles || trackedFiles.length === 0) {
|
|
12
|
-
return { status: "no_tracking",
|
|
55
|
+
return { status: "no_tracking", ...empty, total: 0 };
|
|
56
|
+
}
|
|
57
|
+
const verdicts = await mapWithConcurrency(trackedFiles, FILE_CONCURRENCY, (tracked) => checkTrackedFile(tracked, source));
|
|
58
|
+
const failed = verdicts.find((verdict) => verdict.kind === "source_error");
|
|
59
|
+
if (failed) {
|
|
60
|
+
return { status: "unverifiable", ...empty, total: trackedFiles.length, sourceError: failed.message };
|
|
13
61
|
}
|
|
14
62
|
const modified = [];
|
|
15
63
|
const missing = [];
|
|
16
64
|
const unchanged = [];
|
|
17
65
|
const anchors_lost = [];
|
|
18
66
|
const broken = [];
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
let sectionStale = false;
|
|
30
|
-
let anchorLost = false;
|
|
31
|
-
for (const section of tracked.sections) {
|
|
32
|
-
const result = findAndHashSection(absolutePath, section);
|
|
33
|
-
if (!result.found) {
|
|
34
|
-
sectionStale = true;
|
|
35
|
-
anchorLost = true;
|
|
36
|
-
break;
|
|
37
|
-
}
|
|
38
|
-
else if (result.currentHash !== section.sectionHash) {
|
|
39
|
-
sectionStale = true;
|
|
40
|
-
break;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
if (sectionStale) {
|
|
44
|
-
if (anchorLost) {
|
|
45
|
-
anchors_lost.push(tracked.path);
|
|
46
|
-
}
|
|
47
|
-
else {
|
|
48
|
-
modified.push(tracked.path);
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
else {
|
|
52
|
-
unchanged.push(tracked.path);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
else if (tracked.contentHash) {
|
|
56
|
-
const currentHash = computeFileHash(absolutePath);
|
|
57
|
-
if (currentHash !== tracked.contentHash) {
|
|
58
|
-
modified.push(tracked.path);
|
|
59
|
-
}
|
|
60
|
-
else {
|
|
61
|
-
unchanged.push(tracked.path);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
else {
|
|
65
|
-
// Hash comparison is the only freshness system. An entry with neither
|
|
66
|
-
// sections nor contentHash predates it and cannot be checked — it is
|
|
67
|
-
// reported as broken (never checked by any other means) so readers can
|
|
68
|
-
// still expose the doc and its tracked list to the agent for re-tracking.
|
|
69
|
-
broken.push(tracked.path);
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
// When every tracked file is absent, the source code simply isn't present on
|
|
73
|
-
// this machine (e.g. docs cloned without the codebase) — we can't verify
|
|
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
|
-
// change signal (likely deletions) and still counts as stale.
|
|
67
|
+
const buckets = { modified, missing, unchanged, anchors_lost, broken };
|
|
68
|
+
verdicts.forEach((verdict, i) => {
|
|
69
|
+
if (verdict.kind !== "source_error")
|
|
70
|
+
buckets[verdict.kind].push(trackedFiles[i].path);
|
|
71
|
+
});
|
|
72
|
+
// When every tracked file is absent, the source code simply isn't present at the source
|
|
73
|
+
// (e.g. docs cloned without the codebase, or a branch where those files don't exist) — we
|
|
74
|
+
// can't verify freshness, so report "unverifiable" rather than crying wolf with "stale".
|
|
75
|
+
// A partial absence (some files missing, others present) is treated as a real change signal
|
|
76
|
+
// (likely deletions) and still counts as stale.
|
|
77
77
|
const allMissing = missing.length === trackedFiles.length;
|
|
78
78
|
if (allMissing) {
|
|
79
|
-
return {
|
|
79
|
+
return {
|
|
80
|
+
status: "unverifiable",
|
|
81
|
+
modified,
|
|
82
|
+
missing,
|
|
83
|
+
unchanged,
|
|
84
|
+
anchors_lost,
|
|
85
|
+
broken,
|
|
86
|
+
total: trackedFiles.length,
|
|
87
|
+
source: source.label,
|
|
88
|
+
};
|
|
80
89
|
}
|
|
81
90
|
const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
|
|
82
91
|
// Broken tracking outranks everything: no freshness verdict is trustworthy
|
|
@@ -89,24 +98,30 @@ export function computeFreshness(trackedFiles, project) {
|
|
|
89
98
|
anchors_lost,
|
|
90
99
|
broken,
|
|
91
100
|
total: trackedFiles.length,
|
|
101
|
+
source: source.label,
|
|
92
102
|
};
|
|
93
103
|
}
|
|
94
104
|
export function formatFreshnessReport(title, report) {
|
|
105
|
+
const sourceLine = report.source ? `\nCode source: ${report.source}.` : "";
|
|
95
106
|
if (report.status === "no_tracking") {
|
|
96
107
|
return `No tracked files for "${title}". Use tracked_files when creating or updating the doc to enable staleness detection.`;
|
|
97
108
|
}
|
|
98
109
|
if (report.status === "unverifiable") {
|
|
99
|
-
|
|
110
|
+
const reason = report.sourceError
|
|
111
|
+
? `the code source could not be consulted — ${report.sourceError}`
|
|
112
|
+
: `none of the ${report.total} tracked file(s) exist there`;
|
|
113
|
+
return (`UNVERIFIABLE: "${title}" — ${reason}, so freshness cannot be checked.${sourceLine}\n` +
|
|
114
|
+
`This is expected when the documentation is read without access to its source codebase.`);
|
|
100
115
|
}
|
|
101
116
|
let text;
|
|
102
117
|
if (report.status === "broken") {
|
|
103
|
-
text = `BROKEN TRACKING: "${title}" — some tracked entries have no content snapshot and cannot be checked
|
|
118
|
+
text = `BROKEN TRACKING: "${title}" — some tracked entries have no content snapshot and cannot be checked.${sourceLine}\n`;
|
|
104
119
|
}
|
|
105
120
|
else if (report.status === "stale") {
|
|
106
|
-
text = `STALE: "${title}" — tracked files have changed since the documentation was last updated
|
|
121
|
+
text = `STALE: "${title}" — tracked files have changed since the documentation was last updated.${sourceLine}\n`;
|
|
107
122
|
}
|
|
108
123
|
else {
|
|
109
|
-
text = `UP TO DATE: "${title}" — no tracked files have been modified
|
|
124
|
+
text = `UP TO DATE: "${title}" — no tracked files have been modified.${sourceLine}\n`;
|
|
110
125
|
}
|
|
111
126
|
const anchorLostCount = report.anchors_lost?.length ?? 0;
|
|
112
127
|
const brokenCount = report.broken?.length ?? 0;
|
|
@@ -139,12 +154,25 @@ function renderUntrackedList(titles, heading) {
|
|
|
139
154
|
}
|
|
140
155
|
async function scanProjectFreshness(storageEngine, project) {
|
|
141
156
|
const summaries = await storageEngine.listDocs(project);
|
|
142
|
-
const scan = {
|
|
143
|
-
|
|
157
|
+
const scan = {
|
|
158
|
+
docCount: summaries.length,
|
|
159
|
+
stale: [],
|
|
160
|
+
noTracking: [],
|
|
161
|
+
broken: [],
|
|
162
|
+
upToDate: 0,
|
|
163
|
+
unverifiable: 0,
|
|
164
|
+
source: getCodeSource(project).label,
|
|
165
|
+
};
|
|
166
|
+
const checked = await mapWithConcurrency(summaries, DOC_CONCURRENCY, async (summary) => {
|
|
144
167
|
const doc = await storageEngine.getDoc(summary.id, project);
|
|
145
|
-
|
|
168
|
+
return doc ? { doc, report: await computeFreshness(doc.tracked_files, project) } : undefined;
|
|
169
|
+
});
|
|
170
|
+
for (const entry of checked) {
|
|
171
|
+
if (!entry)
|
|
146
172
|
continue;
|
|
147
|
-
const report =
|
|
173
|
+
const { doc, report } = entry;
|
|
174
|
+
if (report.sourceError)
|
|
175
|
+
scan.sourceError = report.sourceError;
|
|
148
176
|
if (report.status === "broken") {
|
|
149
177
|
const files = report.broken.map((f) => ` • no snapshot: ${f}`).join("\n");
|
|
150
178
|
scan.broken.push(` - **${doc.title}** [${doc.id}]\n${files}`);
|
|
@@ -170,7 +198,10 @@ async function scanProjectFreshness(storageEngine, project) {
|
|
|
170
198
|
return scan;
|
|
171
199
|
}
|
|
172
200
|
function renderScanSummary(scan) {
|
|
173
|
-
let text =
|
|
201
|
+
let text = `Code source: ${scan.source}.\n\n`;
|
|
202
|
+
if (scan.sourceError)
|
|
203
|
+
text += `⚠ The code source could not be consulted: ${scan.sourceError}\n\n`;
|
|
204
|
+
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
205
|
if (scan.broken.length > 0)
|
|
175
206
|
text += `, **${scan.broken.length} with broken tracking**`;
|
|
176
207
|
return text + ".";
|
|
@@ -198,7 +229,7 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
198
229
|
const resolved = await resolveDocInProject(storageEngine, validated.path, project, "freshness check");
|
|
199
230
|
if (isScopeError(resolved))
|
|
200
231
|
return scopeErrorResult(resolved);
|
|
201
|
-
const report = computeFreshness(resolved.doc.tracked_files, project);
|
|
232
|
+
const report = await computeFreshness(resolved.doc.tracked_files, project);
|
|
202
233
|
return { content: [{ type: "text", text: formatFreshnessReport(resolved.doc.title, report) }] };
|
|
203
234
|
}
|
|
204
235
|
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.";
|