@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.
@@ -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 file and snapshots a section defined by 1-indexed [start, end] line numbers.
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 `lines` input is intentionally not persisted — only anchors, sectionHash, and lineCount are stored.
10
- * `lineCount` is used by findAndHashSection to constrain the bottom anchor search and prevent
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(absolutePath, start, end) {
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(allLines.length - 1, end - 1);
18
- const sectionLines = allLines.slice(startIdx, endIdx + 1);
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
- const sectionHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
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
- * Locates a tracked section in the current file content using its stored anchors,
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
- * Returns { found: false } if the anchors cannot be located — which is treated as stale,
40
- * since the surrounding context has changed enough to invalidate the documentation.
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
- export function findAndHashSection(absolutePath, section) {
43
- const fileContent = fs.readFileSync(absolutePath, "utf-8");
44
- const fileLines = fileContent.split("\n");
45
- const { top, bottom } = section.anchors;
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
- const sectionLines = fileLines.slice(i, i + top.length);
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 { found: false };
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
- // Constrain bottom anchor search when lineCount is known
64
- const expectedBottomStart = section.lineCount !== undefined
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
- const sectionLines = fileLines.slice(i, k + bottom.length);
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 { found: false };
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 file content. Used by refresh_file_snapshot to acknowledge changes.
86
- * Anchors are kept as-is (they were found successfully); only sectionHash is updated.
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(absolutePath, section) {
89
- const fileContent = fs.readFileSync(absolutePath, "utf-8");
90
- const fileLines = fileContent.split("\n");
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
- for (let i = 0; i <= fileLines.length - top.length; i++) {
102
- if (!top.every((line, j) => fileLines[i + j] === line))
103
- continue;
104
- const searchStart = i + top.length;
105
- const expectedBottomStart = section.lineCount !== undefined
106
- ? i + section.lineCount - bottom.length
107
- : undefined;
108
- const kMin = expectedBottomStart !== undefined
109
- ? Math.max(searchStart, expectedBottomStart - BOTTOM_ANCHOR_TOLERANCE)
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 { toAbsolutePath } from "./core/path-resolver.js";
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
- for (const doc of docs) {
208
+ await mapWithConcurrency(docs, DOC_CONCURRENCY, async (doc) => {
203
209
  const full = await storageEngine.getDoc(doc.id, project);
204
- if (full && full.tracked_files.length > 0) {
205
- const report = computeFreshness(full.tracked_files, project);
206
- doc.isStale = report.status === "stale" || report.status === "broken";
207
- doc.isBroken = report.status === "broken";
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 absolutePath = toAbsolutePath(trackedFile.path, project);
516
- if (!absolutePath)
517
- throw new Error("Cannot resolve file on this machine (no code root configured)");
518
- const fileLines = fs.readFileSync(absolutePath, "utf-8").split("\n");
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(absolutePath, section);
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: fileLines.slice(start - 1, end).join("\n"),
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
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
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
- export declare function computeFileHash(absolutePath: string): string;
4
- export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): FreshnessReport;
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 { toAbsolutePath } from "../core/path-resolver.js";
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
- export function computeFileHash(absolutePath) {
8
- return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
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", modified: [], missing: [], unchanged: [], anchors_lost: [], broken: [], total: 0 };
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
- for (const tracked of trackedFiles) {
20
- // A relative path with no code root configured on this machine is not
21
- // resolvable here — count it as absent so we end up "unverifiable" rather
22
- // than crying wolf with "stale".
23
- const absolutePath = toAbsolutePath(tracked.path, project);
24
- if (!absolutePath || !fs.existsSync(absolutePath)) {
25
- missing.push(tracked.path);
26
- continue;
27
- }
28
- if (tracked.sections && tracked.sections.length > 0) {
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 { status: "unverifiable", modified, missing, unchanged, anchors_lost, broken, total: trackedFiles.length };
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
- 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.`;
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.\n`;
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.\n`;
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.\n`;
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 = { docCount: summaries.length, stale: [], noTracking: [], broken: [], upToDate: 0, unverifiable: 0 };
143
- for (const summary of summaries) {
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
- if (!doc)
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 = computeFreshness(doc.tracked_files, project);
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 = `${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`;
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 { findAndHashSection } from "../core/section-tracker.js";
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
- text += doc.tracked_files.map((f) => {
34
- let entry = ` ${fileStatusIcon(f.path)} — ${f.path}\n File mtime: ${f.lastModifiedAt}`;
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 abs = toAbsolutePath(f.path, project);
37
- const readable = abs && fs.existsSync(abs);
38
- const sectionLines = f.sections.map((section, idx) => {
39
- const result = readable ? findAndHashSection(abs, section) : { found: false };
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
- }).join("\n");
48
+ }));
49
+ text += entries.join("\n");
49
50
  }
50
51
  else {
51
52
  text += "\nNo tracked files.";