@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.
@@ -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
@@ -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 { toAbsolutePath } from "./core/path-resolver.js";
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 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");
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(absolutePath, section);
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: fileLines.slice(start - 1, end).join("\n"),
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
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
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 computeFileHash(absolutePath: string): string;
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 { toAbsolutePath } from "../core/path-resolver.js";
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 computeFileHash(absolutePath) {
8
- return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
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", modified: [], missing: [], unchanged: [], anchors_lost: [], broken: [], total: 0 };
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
- // 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)) {
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(absolutePath, section);
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.currentHash !== section.sectionHash) {
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
- const currentHash = computeFileHash(absolutePath);
57
- if (currentHash !== tracked.contentHash) {
58
- modified.push(tracked.path);
77
+ if (fileContentMatches(file.content, tracked.contentHash)) {
78
+ unchanged.push(tracked.path);
59
79
  }
60
80
  else {
61
- unchanged.push(tracked.path);
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 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.
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 { status: "unverifiable", modified, missing, unchanged, anchors_lost, broken, total: trackedFiles.length };
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
- 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.`;
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.\n`;
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.\n`;
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.\n`;
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 = { docCount: summaries.length, stale: [], noTracking: [], broken: [], upToDate: 0, unverifiable: 0 };
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 = `${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`;
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 { 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.";
@@ -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 { 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 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 trackedFilesText = doc.tracked_files.length > 0
16
- ? doc.tracked_files.map((f) => {
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
- }).join("\n")
31
- : " (none)";
32
- const freshnessReport = computeFreshness(doc.tracked_files, resolvedProject);
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}`,