@imenam/mcp-documentor 1.0.78 → 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.
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Maps `items` with at most `limit` calls in flight, preserving input order in the result.
3
+ *
4
+ * Freshness work is almost entirely waiting on the network: one round trip per file that has to
5
+ * be read. Running those sequentially makes a whole-project scan take latency × file count, which
6
+ * is what pushes a large project past the MCP client's timeout. Order is preserved because the
7
+ * reports list files in the order the doc declares them.
8
+ *
9
+ * A rejection propagates once every worker has settled; callers that need per-item failures
10
+ * (freshness does) catch inside `fn` and return a value instead.
11
+ */
12
+ export declare function mapWithConcurrency<T, R>(items: readonly T[], limit: number, fn: (item: T, index: number) => Promise<R>): Promise<R[]>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Maps `items` with at most `limit` calls in flight, preserving input order in the result.
3
+ *
4
+ * Freshness work is almost entirely waiting on the network: one round trip per file that has to
5
+ * be read. Running those sequentially makes a whole-project scan take latency × file count, which
6
+ * is what pushes a large project past the MCP client's timeout. Order is preserved because the
7
+ * reports list files in the order the doc declares them.
8
+ *
9
+ * A rejection propagates once every worker has settled; callers that need per-item failures
10
+ * (freshness does) catch inside `fn` and return a value instead.
11
+ */
12
+ export async function mapWithConcurrency(items, limit, fn) {
13
+ if (items.length === 0)
14
+ return [];
15
+ const results = new Array(items.length);
16
+ let next = 0;
17
+ const worker = async () => {
18
+ while (true) {
19
+ const index = next++;
20
+ if (index >= items.length)
21
+ return;
22
+ results[index] = await fn(items[index], index);
23
+ }
24
+ };
25
+ await Promise.all(Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker));
26
+ return results;
27
+ }
@@ -52,8 +52,13 @@ export declare class GitHubCodeApi {
52
52
  * once within it, however many files that operation ends up reading.
53
53
  */
54
54
  resolveRef(): Promise<ResolvedRef>;
55
- /** path → blob sha for every file in the commit's tree. */
55
+ /** path → blob sha for every file in the commit's tree. A commit's tree never changes. */
56
56
  getTree(commitSha: string): Promise<Map<string, string>>;
57
+ /**
58
+ * Memoises `compute` under `key`, sharing the in-flight promise with concurrent callers.
59
+ * A failure is evicted, so a transient network error does not poison the cache for good.
60
+ */
61
+ private cached;
57
62
  /** Whether the tree we hold for this commit is known to be incomplete. */
58
63
  isTreeTruncated(commitSha: string): boolean;
59
64
  /** Blob sha for one path at one commit, or undefined if the file does not exist there. */
@@ -54,6 +54,9 @@ export class GitHubApiError extends Error {
54
54
  export class GitHubCodeApi {
55
55
  spec;
56
56
  token;
57
+ // In-flight requests are cached, not just their results: a whole-project scan checks many docs
58
+ // at once, and they all need the same tree — and often the same blobs. Caching the promise makes
59
+ // concurrent callers await one request instead of each firing its own.
57
60
  treeByCommit = new Map();
58
61
  truncatedCommits = new Set();
59
62
  blobCache = new Map();
@@ -113,25 +116,38 @@ export class GitHubCodeApi {
113
116
  };
114
117
  });
115
118
  }
116
- /** path → blob sha for every file in the commit's tree. */
117
- async getTree(commitSha) {
118
- const cached = this.treeByCommit.get(commitSha);
119
- if (cached)
120
- return cached;
121
- const tree = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/trees/${commitSha}?recursive=1`);
122
- const map = new Map();
123
- for (const entry of tree.tree ?? []) {
124
- if (entry.type === "blob")
125
- map.set(entry.path, entry.sha);
126
- }
127
- if (tree.truncated) {
128
- // Past ~100k entries GitHub stops listing. Existence checks would then be wrong, so we
129
- // remember it and fall back to a per-path lookup for anything absent from the partial tree.
130
- this.truncatedCommits.add(commitSha);
131
- logger.warn(`${this.label}: tree listing truncated at ${map.size} entries; falling back to per-path lookups`);
132
- }
133
- this.treeByCommit.set(commitSha, map);
134
- return map;
119
+ /** path → blob sha for every file in the commit's tree. A commit's tree never changes. */
120
+ getTree(commitSha) {
121
+ return this.cached(this.treeByCommit, commitSha, async () => {
122
+ const tree = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/trees/${commitSha}?recursive=1`);
123
+ const map = new Map();
124
+ for (const entry of tree.tree ?? []) {
125
+ if (entry.type === "blob")
126
+ map.set(entry.path, entry.sha);
127
+ }
128
+ if (tree.truncated) {
129
+ // Past ~100k entries GitHub stops listing. Existence checks would then be wrong, so we
130
+ // remember it and fall back to a per-path lookup for anything absent from the partial tree.
131
+ this.truncatedCommits.add(commitSha);
132
+ logger.warn(`${this.label}: tree listing truncated at ${map.size} entries; falling back to per-path lookups`);
133
+ }
134
+ return map;
135
+ });
136
+ }
137
+ /**
138
+ * Memoises `compute` under `key`, sharing the in-flight promise with concurrent callers.
139
+ * A failure is evicted, so a transient network error does not poison the cache for good.
140
+ */
141
+ cached(store, key, compute) {
142
+ const pending = store.get(key);
143
+ if (pending)
144
+ return pending;
145
+ const promise = compute().catch((err) => {
146
+ store.delete(key);
147
+ throw err;
148
+ });
149
+ store.set(key, promise);
150
+ return promise;
135
151
  }
136
152
  /** Whether the tree we hold for this commit is known to be incomplete. */
137
153
  isTreeTruncated(commitSha) {
@@ -154,14 +170,11 @@ export class GitHubCodeApi {
154
170
  }
155
171
  }
156
172
  /** A blob's bytes. Blobs are immutable, so this is cached for the life of the process. */
157
- async getBlob(blobSha) {
158
- const cached = this.blobCache.get(blobSha);
159
- if (cached)
160
- return cached;
161
- const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`);
162
- const bytes = Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
163
- this.blobCache.set(blobSha, bytes);
164
- return bytes;
173
+ getBlob(blobSha) {
174
+ return this.cached(this.blobCache, blobSha, async () => {
175
+ const blob = await this.request(`/repos/${this.spec.owner}/${this.spec.repo}/git/blobs/${blobSha}`);
176
+ return Buffer.from(blob.content ?? "", blob.encoding === "base64" ? "base64" : "utf-8");
177
+ });
165
178
  }
166
179
  /**
167
180
  * Unified diff of every file that changed between two commits, keyed by path.
package/dist/src/index.js CHANGED
@@ -17,7 +17,8 @@ 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
23
  import { getCodeSource } from "./core/code-source.js";
23
24
  import { runInCodeOperation } from "./core/code-operation.js";
@@ -204,14 +205,14 @@ function requireProjectFromIPC(msg) {
204
205
  /** Loads one project's summaries and flags each entry as stale/broken. */
205
206
  async function listDocsWithFreshness(project) {
206
207
  const docs = await storageEngine.listDocs(project);
207
- for (const doc of docs) {
208
+ await mapWithConcurrency(docs, DOC_CONCURRENCY, async (doc) => {
208
209
  const full = await storageEngine.getDoc(doc.id, project);
209
- if (full && full.tracked_files.length > 0) {
210
- const report = await computeFreshness(full.tracked_files, project);
211
- doc.isStale = report.status === "stale" || report.status === "broken";
212
- doc.isBroken = report.status === "broken";
213
- }
214
- }
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
+ });
215
216
  return docs;
216
217
  }
217
218
  /**
@@ -1,5 +1,11 @@
1
1
  import type { TrackedFile, FreshnessReport } from "../../shared/schemas/doc.schema.js";
2
2
  import type { StorageEngine } from "../core/storage-engine.js";
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;
3
9
  export declare function computeFreshness(trackedFiles: TrackedFile[], project: string): Promise<FreshnessReport>;
4
10
  export declare function formatFreshnessReport(title: string, report: FreshnessReport): string;
5
11
  export declare function checkFreshness(storageEngine: StorageEngine, args: unknown): Promise<{
@@ -1,94 +1,74 @@
1
1
  import { CheckFreshnessArgsSchema } from "../../shared/schemas/doc.schema.js";
2
2
  import { findAndHashSection, fileContentMatches } from "../core/section-tracker.js";
3
3
  import { getCodeSource } from "../core/code-source.js";
4
+ import { mapWithConcurrency } from "../core/concurrency.js";
4
5
  import { resolveDocInProject, isScopeError, scopeErrorResult } from "../core/project-scope.js";
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
+ }
50
+ }
5
51
  export async function computeFreshness(trackedFiles, project) {
6
52
  const source = getCodeSource(project);
7
53
  const empty = { modified: [], missing: [], unchanged: [], anchors_lost: [], broken: [], source: source.label };
8
54
  if (!trackedFiles || trackedFiles.length === 0) {
9
55
  return { status: "no_tracking", ...empty, total: 0 };
10
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 };
61
+ }
11
62
  const modified = [];
12
63
  const missing = [];
13
64
  const unchanged = [];
14
65
  const anchors_lost = [];
15
66
  const broken = [];
16
- for (const tracked of trackedFiles) {
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) {
46
- missing.push(tracked.path);
47
- continue;
48
- }
49
- if (tracked.sections && tracked.sections.length > 0) {
50
- let sectionStale = false;
51
- let anchorLost = false;
52
- for (const section of tracked.sections) {
53
- const result = findAndHashSection(file.content, section);
54
- if (!result.found) {
55
- sectionStale = true;
56
- anchorLost = true;
57
- break;
58
- }
59
- else if (!result.matchesStored) {
60
- sectionStale = true;
61
- break;
62
- }
63
- }
64
- if (sectionStale) {
65
- if (anchorLost) {
66
- anchors_lost.push(tracked.path);
67
- }
68
- else {
69
- modified.push(tracked.path);
70
- }
71
- }
72
- else {
73
- unchanged.push(tracked.path);
74
- }
75
- }
76
- else if (tracked.contentHash) {
77
- if (fileContentMatches(file.content, tracked.contentHash)) {
78
- unchanged.push(tracked.path);
79
- }
80
- else {
81
- modified.push(tracked.path);
82
- }
83
- }
84
- else {
85
- // Hash comparison is the only freshness system. An entry with neither
86
- // sections nor contentHash predates it and cannot be checked — it is
87
- // reported as broken (never checked by any other means) so readers can
88
- // still expose the doc and its tracked list to the agent for re-tracking.
89
- broken.push(tracked.path);
90
- }
91
- }
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
+ });
92
72
  // When every tracked file is absent, the source code simply isn't present at the source
93
73
  // (e.g. docs cloned without the codebase, or a branch where those files don't exist) — we
94
74
  // can't verify freshness, so report "unverifiable" rather than crying wolf with "stale".
@@ -183,11 +163,14 @@ async function scanProjectFreshness(storageEngine, project) {
183
163
  unverifiable: 0,
184
164
  source: getCodeSource(project).label,
185
165
  };
186
- for (const summary of summaries) {
166
+ const checked = await mapWithConcurrency(summaries, DOC_CONCURRENCY, async (summary) => {
187
167
  const doc = await storageEngine.getDoc(summary.id, project);
188
- if (!doc)
168
+ return doc ? { doc, report: await computeFreshness(doc.tracked_files, project) } : undefined;
169
+ });
170
+ for (const entry of checked) {
171
+ if (!entry)
189
172
  continue;
190
- const report = await computeFreshness(doc.tracked_files, project);
173
+ const { doc, report } = entry;
191
174
  if (report.sourceError)
192
175
  scan.sourceError = report.sourceError;
193
176
  if (report.status === "broken") {
@@ -1,4 +1,19 @@
1
- import { computeFreshness } from "./check-freshness.js";
1
+ import { computeFreshness, DOC_CONCURRENCY } from "./check-freshness.js";
2
+ import { mapWithConcurrency } from "../core/concurrency.js";
3
+ /**
4
+ * Fills in each summary's staleness flags, checking several docs at once — a listing is a
5
+ * whole-project scan, and doing it one doc at a time is what made large projects time out.
6
+ */
7
+ async function flagStaleness(storageEngine, summaries, project) {
8
+ await mapWithConcurrency(summaries, DOC_CONCURRENCY, async (summary) => {
9
+ const doc = await storageEngine.getDoc(summary.id, project);
10
+ if (!doc || doc.tracked_files.length === 0)
11
+ return;
12
+ const report = await computeFreshness(doc.tracked_files, project);
13
+ summary.isStale = report.status === "stale";
14
+ summary.isBroken = report.status === "broken";
15
+ });
16
+ }
2
17
  function renderProjectDocs(summaries, totalLabel) {
3
18
  if (summaries.length === 0)
4
19
  return " (no entries)";
@@ -38,14 +53,7 @@ export async function listDocs(storageEngine, args) {
38
53
  if (!isMultiProject) {
39
54
  const proj = projectsToList[0];
40
55
  const summaries = await storageEngine.listDocs(proj);
41
- for (const summary of summaries) {
42
- const doc = await storageEngine.getDoc(summary.id, proj);
43
- if (doc && doc.tracked_files.length > 0) {
44
- const report = await computeFreshness(doc.tracked_files, proj);
45
- summary.isStale = report.status === "stale";
46
- summary.isBroken = report.status === "broken";
47
- }
48
- }
56
+ await flagStaleness(storageEngine, summaries, proj);
49
57
  if (summaries.length === 0) {
50
58
  return { content: [{ type: "text", text: `No documentation entries found in project "${proj}".` }] };
51
59
  }
@@ -57,14 +65,7 @@ export async function listDocs(storageEngine, args) {
57
65
  let grandTotal = 0;
58
66
  for (const proj of projectsToList) {
59
67
  const summaries = await storageEngine.listDocs(proj);
60
- for (const summary of summaries) {
61
- const doc = await storageEngine.getDoc(summary.id, proj);
62
- if (doc && doc.tracked_files.length > 0) {
63
- const report = await computeFreshness(doc.tracked_files, proj);
64
- summary.isStale = report.status === "stale";
65
- summary.isBroken = report.status === "broken";
66
- }
67
- }
68
+ await flagStaleness(storageEngine, summaries, proj);
68
69
  grandTotal += summaries.length;
69
70
  lines.push(`\n🗂 ${proj} (${summaries.length})`);
70
71
  lines.push(renderProjectDocs(summaries, ""));
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.78";
1
+ export declare const APP_VERSION = "1.0.79";
@@ -1,2 +1,2 @@
1
1
  // Auto-generated version file - do not edit manually
2
- export const APP_VERSION = '1.0.78';
2
+ export const APP_VERSION = '1.0.79';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@imenam/mcp-documentor",
3
- "version": "1.0.78",
3
+ "version": "1.0.79",
4
4
  "main": "dist/src/index.js",
5
5
  "scripts": {
6
6
  "test": "echo \"Error: no test specified\" && exit 1",