@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.
@@ -2,9 +2,11 @@ import { execSync } from "child_process";
2
2
  import path from "path";
3
3
  import { GitDiffDocArgsSchema } from "../../shared/schemas/doc.schema.js";
4
4
  import { computeFreshness } from "./check-freshness.js";
5
- import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
5
+ import { toAbsolutePath } from "../core/path-resolver.js";
6
+ import { getCodeSource, getGitHubApi, toStoredPathForMatching } from "../core/code-source.js";
6
7
  import { assertProject, scopeErrorResult } from "../core/project-scope.js";
7
- function getGitDiffForFile(tracked, project) {
8
+ /** Diff of one tracked file against the local working copy, via the git CLI. */
9
+ function getLocalGitDiff(tracked, project) {
8
10
  const filePath = toAbsolutePath(tracked.path, project);
9
11
  if (!filePath) {
10
12
  return ` [INFO] Cannot resolve "${tracked.path}" on this machine (no code root configured) — source absent.`;
@@ -40,6 +42,55 @@ function getGitDiffForFile(tracked, project) {
40
42
  }
41
43
  return diff;
42
44
  }
45
+ /**
46
+ * Diffs tracked files against the branch they are tracked on, using the commit each snapshot
47
+ * was taken at as the base.
48
+ *
49
+ * Files are grouped by that base commit so one comparison covers every file snapshotted at the
50
+ * same revision — which, for a doc refreshed in one go, means a single API call.
51
+ */
52
+ async function getGitHubDiffs(api, trackedFiles) {
53
+ const diffs = new Map();
54
+ const byBase = new Map();
55
+ for (const tracked of trackedFiles) {
56
+ if (!tracked.sourceRevision) {
57
+ diffs.set(tracked.path, ` [INFO] No source commit recorded for "${tracked.path}" — this snapshot predates branch tracking, or was taken from a local working copy.\n` +
58
+ ` → Call refresh_file_snapshot to re-anchor it on ${api.label}, after which diffs become available.`);
59
+ continue;
60
+ }
61
+ const group = byBase.get(tracked.sourceRevision);
62
+ if (group)
63
+ group.push(tracked);
64
+ else
65
+ byBase.set(tracked.sourceRevision, [tracked]);
66
+ }
67
+ if (byBase.size === 0)
68
+ return diffs;
69
+ const { commitSha: head } = await api.resolveRef();
70
+ for (const [base, files] of byBase) {
71
+ if (base === head) {
72
+ for (const file of files) {
73
+ diffs.set(file.path, ` [INFO] ${api.label} is still at the snapshot commit ${base.slice(0, 7)} — no changes.`);
74
+ }
75
+ continue;
76
+ }
77
+ let patches;
78
+ try {
79
+ patches = await api.comparePatches(base, head);
80
+ }
81
+ catch (err) {
82
+ const message = err instanceof Error ? err.message : String(err);
83
+ for (const file of files)
84
+ diffs.set(file.path, ` [ERROR] Could not compare ${base.slice(0, 7)}...${head.slice(0, 7)}: ${message}`);
85
+ continue;
86
+ }
87
+ for (const file of files) {
88
+ const patch = patches.get(file.path);
89
+ diffs.set(file.path, patch ?? ` [INFO] No changes to this file between ${base.slice(0, 7)} and ${head.slice(0, 7)}.`);
90
+ }
91
+ }
92
+ return diffs;
93
+ }
43
94
  export async function gitDiffDoc(storageEngine, args) {
44
95
  try {
45
96
  const validated = GitDiffDocArgsSchema.parse(args);
@@ -47,6 +98,11 @@ export async function gitDiffDoc(storageEngine, args) {
47
98
  const projectError = assertProject(storageEngine, project);
48
99
  if (projectError)
49
100
  return scopeErrorResult(projectError);
101
+ const source = getCodeSource(project);
102
+ const githubApi = getGitHubApi(project);
103
+ const wanted = validated.file_paths
104
+ ? new Set(await Promise.all(validated.file_paths.map((p) => toStoredPathForMatching(project, p))))
105
+ : undefined;
50
106
  const docSections = [];
51
107
  const seenFilePaths = new Set();
52
108
  for (const docPath of validated.paths) {
@@ -60,12 +116,11 @@ export async function gitDiffDoc(storageEngine, args) {
60
116
  continue;
61
117
  }
62
118
  let filesToDiff;
63
- if (validated.file_paths) {
64
- const wanted = new Set(validated.file_paths.map((p) => toStoredPath(p, project)));
119
+ if (wanted) {
65
120
  filesToDiff = doc.tracked_files.filter((tf) => wanted.has(tf.path));
66
121
  }
67
122
  else {
68
- const freshness = computeFreshness(doc.tracked_files, project);
123
+ const freshness = await computeFreshness(doc.tracked_files, project);
69
124
  // Broken entries (no snapshot) can't be hash-compared, but the git diff
70
125
  // is still the best signal the agent has to re-assess them — include them.
71
126
  const changedPaths = new Set([...freshness.modified, ...freshness.missing, ...freshness.broken]);
@@ -78,22 +133,23 @@ export async function gitDiffDoc(storageEngine, args) {
78
133
  docSections.push(`## ${doc.title}\n\n${msg}`);
79
134
  continue;
80
135
  }
81
- const fileSections = filesToDiff
82
- .filter((tf) => {
136
+ const fresh = filesToDiff.filter((tf) => {
83
137
  if (seenFilePaths.has(tf.path))
84
138
  return false;
85
139
  seenFilePaths.add(tf.path);
86
140
  return true;
87
- })
88
- .map((tf) => {
89
- const diff = getGitDiffForFile(tf, project);
90
- return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}\n\n${diff}`;
91
141
  });
92
- if (fileSections.length === 0) {
142
+ if (fresh.length === 0) {
93
143
  docSections.push(`## ${doc.title} [${docPath}]\n\n[INFO] All tracked files already shown in a previous documentation section.`);
94
144
  continue;
95
145
  }
96
- docSections.push(`## ${doc.title} [${docPath}]\n\n${fileSections.join("\n\n---\n\n")}`);
146
+ const diffs = githubApi ? await getGitHubDiffs(githubApi, fresh) : undefined;
147
+ const fileSections = fresh.map((tf) => {
148
+ const diff = diffs ? diffs.get(tf.path) : getLocalGitDiff(tf, project);
149
+ const base = tf.sourceRevision ? `\nSnapshot commit: ${tf.sourceRevision.slice(0, 7)}` : "";
150
+ return `### ${tf.path}\nSnapshot date: ${tf.lastModifiedAt}${base}\n\n${diff}`;
151
+ });
152
+ docSections.push(`## ${doc.title} [${docPath}]\n\nCode source: ${source.label}\n\n${fileSections.join("\n\n---\n\n")}`);
97
153
  }
98
154
  return { content: [{ type: "text", text: docSections.join("\n\n═══════════════════════════════════\n\n") }] };
99
155
  }
@@ -41,7 +41,7 @@ export async function listDocs(storageEngine, args) {
41
41
  for (const summary of summaries) {
42
42
  const doc = await storageEngine.getDoc(summary.id, proj);
43
43
  if (doc && doc.tracked_files.length > 0) {
44
- const report = computeFreshness(doc.tracked_files, proj);
44
+ const report = await computeFreshness(doc.tracked_files, proj);
45
45
  summary.isStale = report.status === "stale";
46
46
  summary.isBroken = report.status === "broken";
47
47
  }
@@ -60,7 +60,7 @@ export async function listDocs(storageEngine, args) {
60
60
  for (const summary of summaries) {
61
61
  const doc = await storageEngine.getDoc(summary.id, proj);
62
62
  if (doc && doc.tracked_files.length > 0) {
63
- const report = computeFreshness(doc.tracked_files, proj);
63
+ const report = await computeFreshness(doc.tracked_files, proj);
64
64
  summary.isStale = report.status === "stale";
65
65
  summary.isBroken = report.status === "broken";
66
66
  }
@@ -17,7 +17,7 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
17
17
  const rawReplacement = validated.replacement_file
18
18
  ? fs.readFileSync(validated.replacement_file, "utf-8")
19
19
  : validated.replacement;
20
- const { content: replacement, additionalTrackedFiles } = resolveCodeRefs(rawReplacement);
20
+ const { content: replacement, additionalTrackedFiles } = await resolveCodeRefs(rawReplacement, resolvedProject);
21
21
  if (!doc.content.includes(search)) {
22
22
  const preview = doc.content.slice(0, 300).replace(/\n/g, "↵");
23
23
  return {
@@ -29,7 +29,7 @@ export async function patchDoc(storageEngine, embeddingEngine, args) {
29
29
  };
30
30
  }
31
31
  doc.content = doc.content.replace(search, replacement);
32
- doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
32
+ doc.tracked_files = await mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
33
33
  doc.updatedAt = new Date().toISOString();
34
34
  await storageEngine.saveDoc(doc, resolvedProject, "patch");
35
35
  if (embeddingEngine.isConfigured()) {
@@ -1,8 +1,6 @@
1
- import fs from "fs";
2
1
  import { RefreshFileSnapshotArgsSchema } from "../../shared/schemas/doc.schema.js";
3
- import { computeFileHash } from "./check-freshness.js";
4
- import { relocateAndRefreshSection } from "../core/section-tracker.js";
5
- import { toAbsolutePath, toStoredPath } from "../core/path-resolver.js";
2
+ import { relocateAndRefreshSection, hashFileContent } from "../core/section-tracker.js";
3
+ import { getCodeSource, toStoredPathForMatching } from "../core/code-source.js";
6
4
  import { assertProject, scopeErrorResult } from "../core/project-scope.js";
7
5
  export async function refreshFileSnapshot(storageEngine, historyStore, args) {
8
6
  try {
@@ -11,7 +9,15 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
11
9
  const projectError = assertProject(storageEngine, resolvedProject);
12
10
  if (projectError)
13
11
  return scopeErrorResult(projectError);
12
+ const source = getCodeSource(resolvedProject);
14
13
  const docSections = [];
14
+ // Normalize to forward slashes and lowercase for case-insensitive comparison
15
+ // on Windows. Agent-supplied file_paths are absolute; map them to the stored
16
+ // (portable) form so they match tracked.path regardless of representation.
17
+ const norm = (p) => p.replace(/\\/g, "/").toLowerCase();
18
+ const resolvedFilePaths = validated.file_paths
19
+ ? await Promise.all(validated.file_paths.map(async (p) => norm(await toStoredPathForMatching(resolvedProject, p))))
20
+ : undefined;
15
21
  for (const docPath of validated.paths) {
16
22
  const doc = await storageEngine.getDoc(docPath, resolvedProject);
17
23
  if (!doc) {
@@ -22,31 +28,27 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
22
28
  const missing = [];
23
29
  const skipped = [];
24
30
  const anchorsLost = [];
25
- // Normalize to forward slashes and lowercase for case-insensitive comparison
26
- // on Windows. Agent-supplied file_paths are absolute; map them to the stored
27
- // (portable) form so they match tracked.path regardless of representation.
28
- const norm = (p) => p.replace(/\\/g, "/").toLowerCase();
29
- const resolvedFilePaths = validated.file_paths?.map((p) => norm(toStoredPath(p, resolvedProject)));
30
31
  for (const tracked of doc.tracked_files) {
31
32
  if (resolvedFilePaths && !resolvedFilePaths.includes(norm(tracked.path))) {
32
33
  skipped.push(tracked.path);
33
34
  continue;
34
35
  }
35
- const absolutePath = toAbsolutePath(tracked.path, resolvedProject);
36
- if (!absolutePath || !fs.existsSync(absolutePath)) {
36
+ const file = await source.read(tracked.path);
37
+ if (!file) {
37
38
  missing.push(tracked.path);
38
39
  continue;
39
40
  }
40
- tracked.lastModifiedAt = fs.statSync(absolutePath).mtime.toISOString();
41
+ tracked.lastModifiedAt = file.lastModifiedAt;
42
+ tracked.sourceRevision = file.revision;
43
+ tracked.sourceBlobId = file.blobId;
41
44
  if (tracked.sections && tracked.sections.length > 0) {
42
45
  let hasLostAnchors = false;
43
- const refreshedSections = tracked.sections.map((section) => {
44
- const result = relocateAndRefreshSection(absolutePath, section);
46
+ tracked.sections = tracked.sections.map((section) => {
47
+ const result = relocateAndRefreshSection(file.content, section);
45
48
  if (!result.found)
46
49
  hasLostAnchors = true;
47
50
  return result.found ? result.refreshed : section;
48
51
  });
49
- tracked.sections = refreshedSections;
50
52
  if (hasLostAnchors) {
51
53
  anchorsLost.push(tracked.path);
52
54
  }
@@ -55,13 +57,13 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
55
57
  }
56
58
  }
57
59
  else {
58
- tracked.contentHash = computeFileHash(absolutePath);
60
+ tracked.contentHash = hashFileContent(file.content);
59
61
  refreshed.push(tracked.path);
60
62
  }
61
63
  }
62
64
  await storageEngine.saveDoc(doc, resolvedProject);
63
65
  historyStore.record(resolvedProject, doc, "snapshot");
64
- let section = `**${doc.title}** [${docPath}]: refreshed ${refreshed.length}, anchors lost ${anchorsLost.length}, missing ${missing.length}, skipped ${skipped.length}`;
66
+ let section = `**${doc.title}** [${docPath}]: refreshed ${refreshed.length}, anchors lost ${anchorsLost.length}, missing ${missing.length}, skipped ${skipped.length} (source: ${source.label})`;
65
67
  if (refreshed.length > 0)
66
68
  section += `\n${refreshed.map((f) => ` ✓ ${f}`).join("\n")}`;
67
69
  if (missing.length > 0)
@@ -16,12 +16,12 @@ export async function updateDoc(storageEngine, embeddingEngine, args) {
16
16
  }
17
17
  let additionalTrackedFiles = [];
18
18
  if (validated.content_file !== undefined) {
19
- const { content, additionalTrackedFiles: refs } = resolveCodeRefs(fs.readFileSync(validated.content_file, "utf-8"));
19
+ const { content, additionalTrackedFiles: refs } = await resolveCodeRefs(fs.readFileSync(validated.content_file, "utf-8"), resolvedProject);
20
20
  doc.content = content;
21
21
  additionalTrackedFiles = refs;
22
22
  }
23
23
  else if (validated.content !== undefined) {
24
- const { content, additionalTrackedFiles: refs } = resolveCodeRefs(validated.content);
24
+ const { content, additionalTrackedFiles: refs } = await resolveCodeRefs(validated.content, resolvedProject);
25
25
  doc.content = content;
26
26
  additionalTrackedFiles = refs;
27
27
  }
@@ -30,14 +30,14 @@ export async function updateDoc(storageEngine, embeddingEngine, args) {
30
30
  // the same scope are carried over as-is; only genuinely new entries get one.
31
31
  let trackedResult;
32
32
  if (validated.tracked_files !== undefined) {
33
- trackedResult = resnapshotPreserving(doc.tracked_files, [
33
+ trackedResult = await resnapshotPreserving(doc.tracked_files, [
34
34
  ...validated.tracked_files,
35
35
  ...additionalTrackedFiles,
36
36
  ], resolvedProject);
37
37
  doc.tracked_files = trackedResult.tracked;
38
38
  }
39
39
  else {
40
- doc.tracked_files = mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
40
+ doc.tracked_files = await mergeTrackedFiles(doc.tracked_files, additionalTrackedFiles, resolvedProject);
41
41
  }
42
42
  if (validated.tags !== undefined) {
43
43
  doc.tags = validated.tags;
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.77";
1
+ export declare const APP_VERSION = "1.0.78";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.77';
2
+ export const APP_VERSION = '1.0.78';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-documentor",
3
- "version": "1.0.77",
3
+ "version": "1.0.78",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",