@imenam/mcp-documentor 1.0.78 → 1.0.80

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.
Files changed (63) hide show
  1. package/dist/src/core/concurrency.d.ts +12 -0
  2. package/dist/src/core/concurrency.js +27 -0
  3. package/dist/src/core/github-code-api.d.ts +6 -1
  4. package/dist/src/core/github-code-api.js +40 -27
  5. package/dist/src/core/storage-engine.d.ts +15 -1
  6. package/dist/src/core/storage-engine.js +24 -10
  7. package/dist/src/index.js +11 -11
  8. package/dist/src/tools/check-freshness.d.ts +6 -0
  9. package/dist/src/tools/check-freshness.js +63 -83
  10. package/dist/src/tools/list-docs.js +18 -19
  11. package/dist/src/version.d.ts +1 -1
  12. package/dist/src/version.js +1 -1
  13. package/gui/dist/assets/{arc-CsM5OJTM.js → arc-CdXRzMBT.js} +1 -1
  14. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-BwIu1llT.js → architectureDiagram-3BPJPVTR-CGUnwe_I.js} +1 -1
  15. package/gui/dist/assets/{blockDiagram-GPEHLZMM-BVijVnPI.js → blockDiagram-GPEHLZMM-BCI5_vQE.js} +1 -1
  16. package/gui/dist/assets/{c4Diagram-AAUBKEIU-D-rcZUZu.js → c4Diagram-AAUBKEIU-Bm9a6HGg.js} +1 -1
  17. package/gui/dist/assets/channel-f0BgDIcS.js +1 -0
  18. package/gui/dist/assets/{chunk-2J33WTMH-ByfTGREe.js → chunk-2J33WTMH-c7xNwExL.js} +1 -1
  19. package/gui/dist/assets/{chunk-4BX2VUAB-B_5oCXyz.js → chunk-4BX2VUAB-CmcU4Q0o.js} +1 -1
  20. package/gui/dist/assets/{chunk-55IACEB6-Bc2Kq9HD.js → chunk-55IACEB6-K8rRjNKN.js} +1 -1
  21. package/gui/dist/assets/{chunk-727SXJPM-CVwmKEMQ.js → chunk-727SXJPM-DMPnL1bv.js} +1 -1
  22. package/gui/dist/assets/{chunk-AQP2D5EJ-BkMuEFxD.js → chunk-AQP2D5EJ-BeRIIft8.js} +1 -1
  23. package/gui/dist/assets/{chunk-FMBD7UC4-CU7EDl9I.js → chunk-FMBD7UC4-CN4It-fp.js} +1 -1
  24. package/gui/dist/assets/{chunk-ND2GUHAM-1b34Dtkn.js → chunk-ND2GUHAM-Dy-ZqPua.js} +1 -1
  25. package/gui/dist/assets/{chunk-QZHKN3VN-DnUfMecI.js → chunk-QZHKN3VN-CaV4seJw.js} +1 -1
  26. package/gui/dist/assets/classDiagram-4FO5ZUOK-tGvt4n1B.js +1 -0
  27. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-tGvt4n1B.js +1 -0
  28. package/gui/dist/assets/{cose-bilkent-S5V4N54A-D9OBrxKr.js → cose-bilkent-S5V4N54A-B1qt_j1Z.js} +1 -1
  29. package/gui/dist/assets/{dagre-BM42HDAG-zDqwL8xs.js → dagre-BM42HDAG-4x5JEBQG.js} +1 -1
  30. package/gui/dist/assets/{diagram-2AECGRRQ-ejbP8WIj.js → diagram-2AECGRRQ-Q7TvjRjv.js} +1 -1
  31. package/gui/dist/assets/{diagram-5GNKFQAL-Cv9WmtFO.js → diagram-5GNKFQAL-Dguj26bf.js} +1 -1
  32. package/gui/dist/assets/{diagram-KO2AKTUF-BI0ybJiN.js → diagram-KO2AKTUF-DBv6MoZF.js} +1 -1
  33. package/gui/dist/assets/{diagram-LMA3HP47-DlVGttLq.js → diagram-LMA3HP47-mfVXwL5q.js} +1 -1
  34. package/gui/dist/assets/{diagram-OG6HWLK6-DjoAH-Fq.js → diagram-OG6HWLK6-Bn3_yQWG.js} +1 -1
  35. package/gui/dist/assets/{erDiagram-TEJ5UH35-BVQHVGK4.js → erDiagram-TEJ5UH35-Bp4wnBw7.js} +1 -1
  36. package/gui/dist/assets/{flowDiagram-I6XJVG4X-oZP2Z-6T.js → flowDiagram-I6XJVG4X-CGk72E1Q.js} +1 -1
  37. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-dy4QyEOP.js → ganttDiagram-6RSMTGT7-C1WQIgpu.js} +1 -1
  38. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-BxgLy7Ln.js → gitGraphDiagram-PVQCEYII-DSIs1ZqI.js} +1 -1
  39. package/gui/dist/assets/{index-Dtv0slFQ.js → index-T7ZLHNgs.js} +4 -4
  40. package/gui/dist/assets/{infoDiagram-5YYISTIA-DNOdinpR.js → infoDiagram-5YYISTIA-DzHdfk5Y.js} +1 -1
  41. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-C4pQ434G.js → ishikawaDiagram-YF4QCWOH-DLxqTmDU.js} +1 -1
  42. package/gui/dist/assets/{journeyDiagram-JHISSGLW-CBrVYW94.js → journeyDiagram-JHISSGLW-DYUPmZnf.js} +1 -1
  43. package/gui/dist/assets/{kanban-definition-UN3LZRKU-Df-rgiY8.js → kanban-definition-UN3LZRKU-f6km2MDT.js} +1 -1
  44. package/gui/dist/assets/{linear-B_6fRXVt.js → linear-DHhAaAXH.js} +1 -1
  45. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-CRAd_VDM.js → mindmap-definition-RKZ34NQL-nergVxV3.js} +1 -1
  46. package/gui/dist/assets/{pieDiagram-4H26LBE5-CJkmLrDI.js → pieDiagram-4H26LBE5-D4IJAdGz.js} +1 -1
  47. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-D-rDEcnF.js → quadrantDiagram-W4KKPZXB-B5yYdOsV.js} +1 -1
  48. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-DE8MhSMe.js → requirementDiagram-4Y6WPE33-BrhJp07G.js} +1 -1
  49. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-BwlxtrUs.js → sankeyDiagram-5OEKKPKP-CUBA4e7F.js} +1 -1
  50. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-B4LjBzWg.js → sequenceDiagram-3UESZ5HK-DP0-rUie.js} +1 -1
  51. package/gui/dist/assets/{stateDiagram-AJRCARHV-Bv72afPK.js → stateDiagram-AJRCARHV-_BnVcpam.js} +1 -1
  52. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-B5Vwq56z.js +1 -0
  53. package/gui/dist/assets/{timeline-definition-PNZ67QCA-CBxgf8iW.js → timeline-definition-PNZ67QCA-crbYjsrq.js} +1 -1
  54. package/gui/dist/assets/{vennDiagram-CIIHVFJN-DPSdK06q.js → vennDiagram-CIIHVFJN-EOqENtE1.js} +1 -1
  55. package/gui/dist/assets/{wardley-L42UT6IY-JIAzuoPm.js → wardley-L42UT6IY-B1FVq-_Y.js} +1 -1
  56. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-YDfaF1wT.js → wardleyDiagram-YWT4CUSO-D1DZwIH_.js} +1 -1
  57. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-DHlPryZX.js → xychartDiagram-2RQKCTM6-B6Wr8_OJ.js} +1 -1
  58. package/gui/dist/index.html +1 -1
  59. package/package.json +1 -1
  60. package/gui/dist/assets/channel-DKPwwuQf.js +0 -1
  61. package/gui/dist/assets/classDiagram-4FO5ZUOK-CnCvVGW9.js +0 -1
  62. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-CnCvVGW9.js +0 -1
  63. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-BKJDOkkR.js +0 -1
@@ -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.
@@ -1,9 +1,14 @@
1
- import type { DocEntry, DocSummary, ExportBundle } from "../../shared/schemas/doc.schema.js";
1
+ import type { DocEntry, DocSummary, TrackedFile, ExportBundle } from "../../shared/schemas/doc.schema.js";
2
2
  import type { HistoryStore } from "./history-store.js";
3
3
  interface GlobalConfig {
4
4
  projects: string[];
5
5
  foldersMigrated?: boolean;
6
6
  }
7
+ /** One doc as a project listing yields it: the GUI-facing summary, plus what it tracks. */
8
+ export interface DocListing {
9
+ summary: DocSummary;
10
+ tracked_files: TrackedFile[];
11
+ }
7
12
  /**
8
13
  * GitHub sync settings. Sourced exclusively from environment variables — the
9
14
  * repository, branch and token are administration concerns and must never be
@@ -43,6 +48,15 @@ export declare class StorageEngine {
43
48
  saveDoc(doc: DocEntry, project: string, changeType?: "create" | "update" | "patch"): Promise<void>;
44
49
  getDoc(id: string, project: string): Promise<DocEntry | null>;
45
50
  listDocs(project: string): Promise<DocSummary[]>;
51
+ /**
52
+ * Same listing, plus the tracked files parsed from the very same read.
53
+ *
54
+ * Freshness has to know what each doc tracks, and fetching that with `getDoc` per summary read
55
+ * and re-parsed every file of the project a second time. Callers that scan a whole project use
56
+ * this instead; `tracked_files` stays out of `DocSummary` so it is never shipped to the GUI,
57
+ * which only needs the stale/broken flags.
58
+ */
59
+ listDocsDetailed(project: string): Promise<DocListing[]>;
46
60
  deleteDoc(id: string, project: string): Promise<boolean>;
47
61
  moveDoc(oldId: string, newId: string, project: string): Promise<boolean>;
48
62
  getBundles(): ExportBundle[];
@@ -151,11 +151,22 @@ export class StorageEngine {
151
151
  };
152
152
  }
153
153
  async listDocs(project) {
154
+ return (await this.listDocsDetailed(project)).map((listing) => listing.summary);
155
+ }
156
+ /**
157
+ * Same listing, plus the tracked files parsed from the very same read.
158
+ *
159
+ * Freshness has to know what each doc tracks, and fetching that with `getDoc` per summary read
160
+ * and re-parsed every file of the project a second time. Callers that scan a whole project use
161
+ * this instead; `tracked_files` stays out of `DocSummary` so it is never shipped to the GUI,
162
+ * which only needs the stale/broken flags.
163
+ */
164
+ async listDocsDetailed(project) {
154
165
  const projectDir = this.getProjectDir(project);
155
166
  if (!fs.existsSync(projectDir))
156
167
  return [];
157
168
  const files = this.walkMdFiles(projectDir);
158
- const summaries = [];
169
+ const listings = [];
159
170
  for (const filePath of files) {
160
171
  try {
161
172
  const raw = fs.readFileSync(filePath, "utf-8");
@@ -164,21 +175,24 @@ export class StorageEngine {
164
175
  const relPath = path.relative(projectDir, filePath);
165
176
  const id = relPath.replace(/\.md$/, "").split(path.sep).join("/");
166
177
  const folder = id.includes("/") ? id.split("/").slice(0, -1).join("/") : undefined;
167
- summaries.push({
168
- id,
169
- title: data.title ?? "",
170
- createdAt: stat.birthtime.toISOString(),
171
- updatedAt: data.updatedAt ?? "",
172
- tags: (data.tags ?? []),
173
- folder,
174
- isStale: false,
178
+ listings.push({
179
+ summary: {
180
+ id,
181
+ title: data.title ?? "",
182
+ createdAt: stat.birthtime.toISOString(),
183
+ updatedAt: data.updatedAt ?? "",
184
+ tags: (data.tags ?? []),
185
+ folder,
186
+ isStale: false,
187
+ },
188
+ tracked_files: (data.tracked_files ?? []),
175
189
  });
176
190
  }
177
191
  catch (err) {
178
192
  logger.warn(`Failed to read doc ${filePath}: ${err}`);
179
193
  }
180
194
  }
181
- return summaries;
195
+ return listings;
182
196
  }
183
197
  // resolveDoc() (first project whose tree contained the path) was removed on purpose:
184
198
  // it silently sent reads and writes to the wrong project when the same doc 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";
@@ -203,16 +204,15 @@ function requireProjectFromIPC(msg) {
203
204
  }
204
205
  /** Loads one project's summaries and flags each entry as stale/broken. */
205
206
  async function listDocsWithFreshness(project) {
206
- const docs = await storageEngine.listDocs(project);
207
- for (const doc of docs) {
208
- 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
- }
215
- return docs;
207
+ const listings = await storageEngine.listDocsDetailed(project);
208
+ await mapWithConcurrency(listings, DOC_CONCURRENCY, async ({ summary, tracked_files }) => {
209
+ if (tracked_files.length === 0)
210
+ return;
211
+ const report = await computeFreshness(tracked_files, project);
212
+ summary.isStale = report.status === "stale" || report.status === "broken";
213
+ summary.isBroken = report.status === "broken";
214
+ });
215
+ return listings.map((listing) => listing.summary);
216
216
  }
217
217
  /**
218
218
  * Tells every connected GUI that one project's docs changed. Clients showing
@@ -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".
@@ -173,9 +153,9 @@ function renderUntrackedList(titles, heading) {
173
153
  return `\n\n${heading}\n${shown.join("\n")}${suffix}`;
174
154
  }
175
155
  async function scanProjectFreshness(storageEngine, project) {
176
- const summaries = await storageEngine.listDocs(project);
156
+ const listings = await storageEngine.listDocsDetailed(project);
177
157
  const scan = {
178
- docCount: summaries.length,
158
+ docCount: listings.length,
179
159
  stale: [],
180
160
  noTracking: [],
181
161
  broken: [],
@@ -183,11 +163,11 @@ async function scanProjectFreshness(storageEngine, project) {
183
163
  unverifiable: 0,
184
164
  source: getCodeSource(project).label,
185
165
  };
186
- for (const summary of summaries) {
187
- const doc = await storageEngine.getDoc(summary.id, project);
188
- if (!doc)
189
- continue;
190
- const report = await computeFreshness(doc.tracked_files, project);
166
+ const checked = await mapWithConcurrency(listings, DOC_CONCURRENCY, async ({ summary, tracked_files }) => ({
167
+ doc: summary,
168
+ report: await computeFreshness(tracked_files, project),
169
+ }));
170
+ for (const { doc, report } of checked) {
191
171
  if (report.sourceError)
192
172
  scan.sourceError = report.sourceError;
193
173
  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(listings, project) {
8
+ await mapWithConcurrency(listings, DOC_CONCURRENCY, async ({ summary, tracked_files }) => {
9
+ if (tracked_files.length === 0)
10
+ return;
11
+ const report = await computeFreshness(tracked_files, project);
12
+ summary.isStale = report.status === "stale";
13
+ summary.isBroken = report.status === "broken";
14
+ });
15
+ return listings.map((listing) => listing.summary);
16
+ }
2
17
  function renderProjectDocs(summaries, totalLabel) {
3
18
  if (summaries.length === 0)
4
19
  return " (no entries)";
@@ -37,15 +52,7 @@ export async function listDocs(storageEngine, args) {
37
52
  const isMultiProject = projectsToList.length > 1;
38
53
  if (!isMultiProject) {
39
54
  const proj = projectsToList[0];
40
- 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
- }
55
+ const summaries = await flagStaleness(await storageEngine.listDocsDetailed(proj), proj);
49
56
  if (summaries.length === 0) {
50
57
  return { content: [{ type: "text", text: `No documentation entries found in project "${proj}".` }] };
51
58
  }
@@ -56,15 +63,7 @@ export async function listDocs(storageEngine, args) {
56
63
  const lines = [];
57
64
  let grandTotal = 0;
58
65
  for (const proj of projectsToList) {
59
- 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
- }
66
+ const summaries = await flagStaleness(await storageEngine.listDocsDetailed(proj), proj);
68
67
  grandTotal += summaries.length;
69
68
  lines.push(`\n🗂 ${proj} (${summaries.length})`);
70
69
  lines.push(renderProjectDocs(summaries, ""));
@@ -1 +1 @@
1
- export declare const APP_VERSION = "1.0.78";
1
+ export declare const APP_VERSION = "1.0.80";
@@ -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.80';
@@ -1 +1 @@
1
- import{a0 as ln,a1 as an,a2 as y,a3 as tn,a4 as H,a5 as q,a6 as _,a7 as un,a8 as rn,a9 as L,aa as o,ab as B,ac as sn,ad as on,ae as fn}from"./index-Dtv0slFQ.js";function cn(l){return l.innerRadius}function yn(l){return l.outerRadius}function gn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,I,D,v,A,C,a){var O=I-l,i=D-h,n=C-v,d=a-A,u=d*O-n*i;if(!(u*u<y))return u=(n*(h-A)-d*(l-v))/u,[l+u*O,h+u*i]}function W(l,h,I,D,v,A,C){var a=l-I,O=h-D,i=(C?A:-A)/L(a*a+O*O),n=i*O,d=-i*a,u=l+n,s=h+d,f=I+n,c=D+d,F=(u+f)/2,t=(s+c)/2,m=f-u,g=c-s,R=m*m+g*g,T=v-A,P=u*c-f*s,S=(g<0?-1:1)*L(fn(0,T*T*R-P*P)),j=(P*g-m*S)/R,z=(-P*m-g*S)/R,w=(P*g+m*S)/R,p=(-P*m+g*S)/R,x=j-F,e=z-t,r=w-F,G=p-t;return x*x+e*e>r*r+G*G&&(j=w,z=p),{cx:j,cy:z,x01:-n,y01:-d,x11:j*(v/T-1),y11:z*(v/T-1)}}function hn(){var l=cn,h=yn,I=B(0),D=null,v=gn,A=dn,C=mn,a=null,O=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-an,c=A.apply(this,arguments)-an,F=un(c-f),t=c>f;if(a||(a=n=O()),s<u&&(d=s,s=u,u=d),!(s>y))a.moveTo(0,0);else if(F>tn-y)a.moveTo(s*H(f),s*q(f)),a.arc(0,0,s,f,c,!t),u>y&&(a.moveTo(u*H(c),u*q(c)),a.arc(0,0,u,c,f,t));else{var m=f,g=c,R=f,T=c,P=F,S=F,j=C.apply(this,arguments)/2,z=j>y&&(D?+D.apply(this,arguments):L(u*u+s*s)),w=_(un(s-u)/2,+I.apply(this,arguments)),p=w,x=w,e,r;if(z>y){var G=sn(z/u*q(j)),M=sn(z/s*q(j));(P-=G*2)>y?(G*=t?1:-1,R+=G,T-=G):(P=0,R=T=(f+c)/2),(S-=M*2)>y?(M*=t?1:-1,m+=M,g-=M):(S=0,m=g=(f+c)/2)}var J=s*H(m),K=s*q(m),N=u*H(T),Q=u*q(T);if(w>y){var U=s*H(g),V=s*q(g),X=u*H(R),Y=u*q(R),E;if(F<rn)if(E=pn(J,K,X,Y,U,V,N,Q)){var Z=J-E[0],$=K-E[1],b=U-E[0],k=V-E[1],nn=1/q(on((Z*b+$*k)/(L(Z*Z+$*$)*L(b*b+k*k)))/2),en=L(E[0]*E[0]+E[1]*E[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}S>y?x>y?(e=W(X,Y,J,K,s,x,t),r=W(U,V,N,Q,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(J,K),a.arc(0,0,s,m,g,!t)):a.moveTo(J,K),!(u>y)||!(P>y)?a.lineTo(N,Q):p>y?(e=W(N,Q,U,V,u,-p,t),r=W(J,K,X,Y,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-rn/2;return[H(d)*n,q(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:B(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:B(+n),i):h},i.cornerRadius=function(n){return arguments.length?(I=typeof n=="function"?n:B(+n),i):I},i.padRadius=function(n){return arguments.length?(D=n==null?null:typeof n=="function"?n:B(+n),i):D},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:B(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:B(+n),i):A},i.padAngle=function(n){return arguments.length?(C=typeof n=="function"?n:B(+n),i):C},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d};
1
+ import{a0 as ln,a1 as an,a2 as y,a3 as tn,a4 as H,a5 as q,a6 as _,a7 as un,a8 as rn,a9 as L,aa as o,ab as B,ac as sn,ad as on,ae as fn}from"./index-T7ZLHNgs.js";function cn(l){return l.innerRadius}function yn(l){return l.outerRadius}function gn(l){return l.startAngle}function dn(l){return l.endAngle}function mn(l){return l&&l.padAngle}function pn(l,h,I,D,v,A,C,a){var O=I-l,i=D-h,n=C-v,d=a-A,u=d*O-n*i;if(!(u*u<y))return u=(n*(h-A)-d*(l-v))/u,[l+u*O,h+u*i]}function W(l,h,I,D,v,A,C){var a=l-I,O=h-D,i=(C?A:-A)/L(a*a+O*O),n=i*O,d=-i*a,u=l+n,s=h+d,f=I+n,c=D+d,F=(u+f)/2,t=(s+c)/2,m=f-u,g=c-s,R=m*m+g*g,T=v-A,P=u*c-f*s,S=(g<0?-1:1)*L(fn(0,T*T*R-P*P)),j=(P*g-m*S)/R,z=(-P*m-g*S)/R,w=(P*g+m*S)/R,p=(-P*m+g*S)/R,x=j-F,e=z-t,r=w-F,G=p-t;return x*x+e*e>r*r+G*G&&(j=w,z=p),{cx:j,cy:z,x01:-n,y01:-d,x11:j*(v/T-1),y11:z*(v/T-1)}}function hn(){var l=cn,h=yn,I=B(0),D=null,v=gn,A=dn,C=mn,a=null,O=ln(i);function i(){var n,d,u=+l.apply(this,arguments),s=+h.apply(this,arguments),f=v.apply(this,arguments)-an,c=A.apply(this,arguments)-an,F=un(c-f),t=c>f;if(a||(a=n=O()),s<u&&(d=s,s=u,u=d),!(s>y))a.moveTo(0,0);else if(F>tn-y)a.moveTo(s*H(f),s*q(f)),a.arc(0,0,s,f,c,!t),u>y&&(a.moveTo(u*H(c),u*q(c)),a.arc(0,0,u,c,f,t));else{var m=f,g=c,R=f,T=c,P=F,S=F,j=C.apply(this,arguments)/2,z=j>y&&(D?+D.apply(this,arguments):L(u*u+s*s)),w=_(un(s-u)/2,+I.apply(this,arguments)),p=w,x=w,e,r;if(z>y){var G=sn(z/u*q(j)),M=sn(z/s*q(j));(P-=G*2)>y?(G*=t?1:-1,R+=G,T-=G):(P=0,R=T=(f+c)/2),(S-=M*2)>y?(M*=t?1:-1,m+=M,g-=M):(S=0,m=g=(f+c)/2)}var J=s*H(m),K=s*q(m),N=u*H(T),Q=u*q(T);if(w>y){var U=s*H(g),V=s*q(g),X=u*H(R),Y=u*q(R),E;if(F<rn)if(E=pn(J,K,X,Y,U,V,N,Q)){var Z=J-E[0],$=K-E[1],b=U-E[0],k=V-E[1],nn=1/q(on((Z*b+$*k)/(L(Z*Z+$*$)*L(b*b+k*k)))/2),en=L(E[0]*E[0]+E[1]*E[1]);p=_(w,(u-en)/(nn-1)),x=_(w,(s-en)/(nn+1))}else p=x=0}S>y?x>y?(e=W(X,Y,J,K,s,x,t),r=W(U,V,N,Q,s,x,t),a.moveTo(e.cx+e.x01,e.cy+e.y01),x<w?a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,x,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,s,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),!t),a.arc(r.cx,r.cy,x,o(r.y11,r.x11),o(r.y01,r.x01),!t))):(a.moveTo(J,K),a.arc(0,0,s,m,g,!t)):a.moveTo(J,K),!(u>y)||!(P>y)?a.lineTo(N,Q):p>y?(e=W(N,Q,U,V,u,-p,t),r=W(J,K,X,Y,u,-p,t),a.lineTo(e.cx+e.x01,e.cy+e.y01),p<w?a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(r.y01,r.x01),!t):(a.arc(e.cx,e.cy,p,o(e.y01,e.x01),o(e.y11,e.x11),!t),a.arc(0,0,u,o(e.cy+e.y11,e.cx+e.x11),o(r.cy+r.y11,r.cx+r.x11),t),a.arc(r.cx,r.cy,p,o(r.y11,r.x11),o(r.y01,r.x01),!t))):a.arc(0,0,u,T,R,t)}if(a.closePath(),n)return a=null,n+""||null}return i.centroid=function(){var n=(+l.apply(this,arguments)+ +h.apply(this,arguments))/2,d=(+v.apply(this,arguments)+ +A.apply(this,arguments))/2-rn/2;return[H(d)*n,q(d)*n]},i.innerRadius=function(n){return arguments.length?(l=typeof n=="function"?n:B(+n),i):l},i.outerRadius=function(n){return arguments.length?(h=typeof n=="function"?n:B(+n),i):h},i.cornerRadius=function(n){return arguments.length?(I=typeof n=="function"?n:B(+n),i):I},i.padRadius=function(n){return arguments.length?(D=n==null?null:typeof n=="function"?n:B(+n),i):D},i.startAngle=function(n){return arguments.length?(v=typeof n=="function"?n:B(+n),i):v},i.endAngle=function(n){return arguments.length?(A=typeof n=="function"?n:B(+n),i):A},i.padAngle=function(n){return arguments.length?(C=typeof n=="function"?n:B(+n),i):C},i.context=function(n){return arguments.length?(a=n??null,i):a},i}export{hn as d};