@imenam/mcp-documentor 1.0.76 → 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.
package/README.md CHANGED
@@ -9,6 +9,7 @@ Connect it to Cursor, Claude Desktop, or any MCP-compatible agent to let your AI
9
9
  - **Create & update docs** — write markdown documentation entries linked to source files; prefer partial edits via `patch_doc`
10
10
  - **Staleness tracking** — automatically detects when tracked files have changed since the last doc update
11
11
  - **Portable code references** — tracked-file paths are stored relative to a configurable code root (`MCP_DOCUMENTOR_CODE_ROOT`), so synced docs resolve their source files on any machine regardless of where the repos live
12
+ - **Track a branch, not a checkout** — point a project at a GitHub branch (`MCP_DOCUMENTOR_CODE_REPO`) and freshness is measured against *published* code: switching branches or editing without committing no longer makes docs look stale, and a server with no copy of the codebase (an MCP gateway) can still verify them
12
13
  - **Semantic search** — find relevant docs by meaning (requires an OpenAI API key)
13
14
  - **Multi-project support** — organize docs into separate projects; every MCP tool names the project it targets, and the GUI's project selector is a per-browser view preference
14
15
  - **GUI** — built-in web interface, either behind the proxy (`PROXY_URL`) or served directly on a local port (`APP_PORT`); with neither, the GUI worker is not started
@@ -94,6 +95,9 @@ Add to your MCP client config (e.g. Cursor `mcp.json`):
94
95
  | `MCP_LOG_DIR` | No | Overrides the log directory of both the server and the GUI worker (default: `<package>/.docs-data/logs`). Must be set in the MCP client config — the package `.env` is loaded after the logger starts |
95
96
  | `MCP_DOCUMENTOR_CODE_ROOT` | No | Base directory that tracked-file paths are stored relative to, keeping docs portable across machines (see [Portable tracked-file paths](#portable-tracked-file-paths)) |
96
97
  | `MCP_DOCUMENTOR_CODE_ROOTS` | No | JSON map of per-project code-root overrides, e.g. `{"my-proj":"/srv/my-proj"}` |
98
+ | `MCP_DOCUMENTOR_CODE_REPO` | No | Track a **GitHub branch** instead of the local working copy. Either one spec applied to every project — `owner/repo`, a GitHub URL, optional `#branch` suffix — or a JSON map of per-project specs, e.g. `{"web":"you/web#main","legacy":"local"}` (the value `local` opts a project back out to its working copy). Setting it is what enables the mode (see [Tracking a GitHub branch](#tracking-a-github-branch-instead-of-the-working-copy)) |
99
+ | `MCP_DOCUMENTOR_CODE_BRANCH` | No | Branch used for repositories carrying no `#branch` suffix (default: the repository's own default branch) |
100
+ | `MCP_DOCUMENTOR_CODE_TOKEN` | No | GitHub token used to read the code; falls back to `MCP_DOCUMENTOR_SYNC_TOKEN` |
97
101
  | `READ_ONLY` | No | When set to `1`/`true`/`yes`, disables all create/update/delete tools and GUI write actions (read-only mode) |
98
102
  | `MCP_DOCUMENTOR_SYNC_REMOTE` | No | HTTPS URL of the private GitHub repo to sync with; enables GitHub sync when set |
99
103
  | `MCP_DOCUMENTOR_SYNC_BRANCH` | No | Branch of the documentation sync repo to push/pull docs on (default: `main`) |
@@ -260,6 +264,60 @@ Review the report (`converted` / `kept absolute (outside root)` / `projects skip
260
264
 
261
265
  This is a **one-time** operation, and must run on the machine whose OS/filesystem **created** the stored absolute paths (path semantics differ between Windows and POSIX). Once migrated and synced, every other machine just `pull_docs` and sets its own `MCP_DOCUMENTOR_CODE_ROOT` — no migration needed.
262
266
 
267
+ ## Tracking a GitHub branch instead of the working copy
268
+
269
+ By default, freshness is measured against the **working copy on the machine running the server**. That has two consequences: switching git branches locally flips docs in and out of `stale` even though the deployed code never moved, and a server that has no copy of the codebase — typically an MCP gateway on another host — cannot verify anything at all.
270
+
271
+ Pointing a project at a repository makes the documentation track **one branch of published code** instead:
272
+
273
+ ```jsonc
274
+ {
275
+ "env": {
276
+ "MCP_DOCUMENTOR_CODE_REPO": "you/my-repo#main",
277
+ // optional: reuses MCP_DOCUMENTOR_SYNC_TOKEN when omitted
278
+ "MCP_DOCUMENTOR_CODE_TOKEN": "github_pat_..."
279
+ }
280
+ }
281
+ ```
282
+
283
+ A repository names exactly one codebase, so an instance serving **several projects** gives `MCP_DOCUMENTOR_CODE_REPO` a JSON map instead of a single spec — a leading `{` is what tells the two apart:
284
+
285
+ ```jsonc
286
+ "MCP_DOCUMENTOR_CODE_REPO": "{\"web\":\"you/web#main\",\"api\":\"you/api#main\",\"legacy\":\"local\"}"
287
+ ```
288
+
289
+ Projects absent from the map, and projects whose value is `local`, keep reading their local working copy. **With the variable unset, nothing changes** — the local behaviour is untouched.
290
+
291
+ The token needs **Contents: Read** on the code repository (a different repo from the docs sync one, so either widen the existing PAT's scope or issue a second one).
292
+
293
+ ### What changes once a project tracks a branch
294
+
295
+ | | Local working copy (default) | GitHub branch |
296
+ |---|---|---|
297
+ | Sees uncommitted edits | yes | no — only what is pushed |
298
+ | Affected by `git checkout` | yes | no |
299
+ | Needs the codebase on the server | yes | no |
300
+ | `git_diff_doc` baseline | last commit before the snapshot date | the exact commit the snapshot was taken at |
301
+
302
+ Every report names the source it used (`Code source: GitHub you/my-repo@main`), so a verdict is never ambiguous. If the source cannot be reached — network, auth, rate limit — the status is `unverifiable` with the reason attached, **never** `stale`.
303
+
304
+ ### Paths and migration
305
+
306
+ Stored tracked-file paths are already relative to the code root, and a code root is normally the repository root — so **existing docs keep working as-is**, with no migration and no mass re-snapshot. Two details make the switch seamless:
307
+
308
+ - Hashes are computed on line-ending-normalised content, so a snapshot taken from a CRLF checkout still matches the LF blob in the repository. Hashes stored by earlier versions are still recognised.
309
+ - Absolute paths sent by an agent are reduced by the code root when one is configured, and otherwise matched against the repository tree by their longest unambiguous suffix (a bare filename is only accepted when the repository holds exactly one file by that name).
310
+
311
+ Docs snapshotted before the switch carry no source commit: `check_freshness` works immediately, while `git_diff_doc` will ask for one `refresh_file_snapshot` to anchor them on the branch.
312
+
313
+ ### API usage
314
+
315
+ Freshness is **never computed in the background** — only when something asks for it: a `check_freshness` / `list_docs` / `get_doc` call, or the GUI loading a project's list. So the cost is bounded per request, not per unit of time.
316
+
317
+ Within one such request, the branch head is resolved **once** and every tracked file is compared against that same commit — no mixed verdict if someone pushes mid-scan. The next request resolves it again, so a push is visible immediately: there is no staleness window to configure.
318
+
319
+ Everything else is content-addressed and therefore cached for the life of the process: a commit's tree by its sha, a file's bytes by its blob sha. Each tracked file also records the blob sha it was snapshotted at, so an unchanged file is proven untouched without being downloaded at all. A full scan of an unchanged repository costs about two API calls — not one per file.
320
+
263
321
  ## Data Storage
264
322
 
265
323
  By default, data lives under **`.docs-data`** next to the resolved package root (the folder that contains `package.json` for the running install). Set **`MCP_DATA_DIR`** to use another directory. Contents include projects, SQLite/embeddings, and logs under `.docs-data/logs`. This tree is **not** part of the npm package; it is created at runtime.
@@ -20,6 +20,8 @@ export declare const TrackedFileSchema: z.ZodObject<{
20
20
  sectionHash: z.ZodString;
21
21
  lineCount: z.ZodOptional<z.ZodNumber>;
22
22
  }, z.core.$strip>>>;
23
+ sourceRevision: z.ZodOptional<z.ZodString>;
24
+ sourceBlobId: z.ZodOptional<z.ZodString>;
23
25
  }, z.core.$strip>;
24
26
  export type TrackedFile = z.infer<typeof TrackedFileSchema>;
25
27
  export declare const TrackedFileInputLineSchema: z.ZodObject<{
@@ -50,6 +52,8 @@ export declare const DocSchema: z.ZodObject<{
50
52
  sectionHash: z.ZodString;
51
53
  lineCount: z.ZodOptional<z.ZodNumber>;
52
54
  }, z.core.$strip>>>;
55
+ sourceRevision: z.ZodOptional<z.ZodString>;
56
+ sourceBlobId: z.ZodOptional<z.ZodString>;
53
57
  }, z.core.$strip>>>;
54
58
  tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
55
59
  folder: z.ZodOptional<z.ZodString>;
@@ -91,6 +95,8 @@ export declare const FreshnessReportSchema: z.ZodObject<{
91
95
  anchors_lost: z.ZodDefault<z.ZodArray<z.ZodString>>;
92
96
  broken: z.ZodDefault<z.ZodArray<z.ZodString>>;
93
97
  total: z.ZodNumber;
98
+ source: z.ZodOptional<z.ZodString>;
99
+ sourceError: z.ZodOptional<z.ZodString>;
94
100
  }, z.core.$strip>;
95
101
  export type FreshnessReport = z.infer<typeof FreshnessReportSchema>;
96
102
  export declare const CreateDocArgsSchema: z.ZodObject<{
@@ -12,6 +12,18 @@ export const TrackedFileSchema = z.object({
12
12
  lastModifiedAt: z.string(),
13
13
  contentHash: z.string().optional(),
14
14
  sections: z.array(TrackedSectionSchema).optional(),
15
+ /**
16
+ * Commit the snapshot was taken at, when the project reads its code from a repository.
17
+ * Absent for snapshots taken from a local working copy, which has no revision to record.
18
+ * `git_diff_doc` uses it as the diff base.
19
+ */
20
+ sourceRevision: z.string().optional(),
21
+ /**
22
+ * Content identity the source can hand over without transferring the file — the git blob sha
23
+ * on GitHub. When it still matches, the file is provably untouched and never gets downloaded,
24
+ * which is what keeps a full freshness scan down to a couple of API calls.
25
+ */
26
+ sourceBlobId: z.string().optional(),
15
27
  });
16
28
  export const TrackedFileInputLineSchema = z.object({
17
29
  start: z.number().int().positive(),
@@ -44,6 +56,10 @@ export const FreshnessReportSchema = z.object({
44
56
  /** Entries with neither sections nor contentHash (pre hash-based tracking) — cannot be checked until re-tracked. */
45
57
  broken: z.array(z.string()).default([]),
46
58
  total: z.number(),
59
+ /** Where the code was read from, e.g. "GitHub Imena/mcp-documentor@main" or "local working copy". */
60
+ source: z.string().optional(),
61
+ /** Set when the source could not be consulted at all (network, auth, rate limit) — the report is then "unverifiable", never "stale". */
62
+ sourceError: z.string().optional(),
47
63
  });
48
64
  /**
49
65
  * Every tool that targets a specific documentation entry takes this. It is mandatory
@@ -59,7 +75,7 @@ export const CreateDocArgsSchema = z.object({
59
75
  title: z.string().describe("Title of the documentation entry"),
60
76
  content: z.string().optional().describe("Documentation content in markdown"),
61
77
  content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
62
- tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths, or paths relative to the project's configured code root (MCP_DOCUMENTOR_CODE_ROOT); paths under the root are stored relative to it so the docs stay portable across machines. Tracked files must exist."),
78
+ tracked_files: z.array(TrackedFileInputSchema).optional().describe("Files to track for staleness detection. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths, or paths relative to the project's code root; they are stored relative to that root so the docs stay portable across machines. Tracked files must exist at the project's code source — which is the local working copy by default, or a branch of a GitHub repository when one is configured for the project (check_freshness reports which)."),
63
79
  tags: z.array(z.string()).optional().describe("Tags for categorization"),
64
80
  folder: z.string().optional().describe("Folder path for organization, e.g. 'frontend/components'"),
65
81
  related_docs: z.array(z.string()).optional().describe("IDs of related documentation entries"),
@@ -72,7 +88,7 @@ export const UpdateDocArgsSchema = z.object({
72
88
  title: z.string().optional().describe("Title of the documentation entry"),
73
89
  content: z.string().optional().describe("New content (replaces existing)"),
74
90
  content_file: z.string().optional().describe("Absolute path to a text file whose content will be used instead of the content parameter. Use this to reuse a previously persisted draft."),
75
- tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Files already tracked with the same scope keep their stored snapshot, so this never changes staleness — only refresh_file_snapshot acknowledges file changes. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths, or paths relative to the project's configured code root (MCP_DOCUMENTOR_CODE_ROOT)."),
91
+ tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). Files already tracked with the same scope keep their stored snapshot, so this never changes staleness — only refresh_file_snapshot acknowledges file changes. Each entry is either a plain path string (whole-file tracking) or an object { path, lines: [{ start, end }, ...] } for granular section tracking. Lines are 1-indexed and inclusive. Provide absolute paths, or paths relative to the project's code root."),
76
92
  tags: z.array(z.string()).optional().describe("New tags (replaces existing)"),
77
93
  related_docs: z.array(z.string()).optional().describe("Paths of related documentation entries (replaces existing)"),
78
94
  });
@@ -67,6 +67,9 @@ const GENERATED_VARS = [
67
67
  ];
68
68
  const OPTIONAL_VARS = [
69
69
  ["MCP_DOCUMENTOR_CODE_ROOTS", 'code roots par projet, ex: {"my-proj":"/srv/my-proj"}'],
70
+ ["MCP_DOCUMENTOR_CODE_REPO", 'repo GitHub suivi au lieu de la copie locale : "owner/repo#main", ou une map JSON par projet {"my-proj":"owner/repo#main","legacy":"local"}'],
71
+ ["MCP_DOCUMENTOR_CODE_BRANCH", "branche suivie quand le repo n'a pas de suffixe #branche"],
72
+ ["MCP_DOCUMENTOR_CODE_TOKEN", "PAT GitHub pour lire le code (défaut : MCP_DOCUMENTOR_SYNC_TOKEN)"],
70
73
  ["READ_ONLY", "1/true pour désactiver toutes les écritures"],
71
74
  ["SYNC_PULL_ON_START", "false pour désactiver le pull au démarrage"],
72
75
  ];
@@ -0,0 +1,10 @@
1
+ /** Runs `fn` as one operation. Anything it starts, however deeply nested, shares one scratch space. */
2
+ export declare function runInCodeOperation<T>(fn: () => T): T;
3
+ /**
4
+ * Computes `key` at most once per operation. Outside an operation there is nothing to share, so
5
+ * `compute` simply runs.
6
+ *
7
+ * The promise is what gets memoised, so concurrent callers await the same in-flight request — and
8
+ * a failure is remembered too, rather than retried once per tracked file.
9
+ */
10
+ export declare function oncePerOperation<T>(key: string, compute: () => Promise<T>): Promise<T>;
@@ -0,0 +1,42 @@
1
+ import { AsyncLocalStorage } from "async_hooks";
2
+ /**
3
+ * Scratch space shared by everything that runs while handling one request — one MCP tool call,
4
+ * one GUI message — and discarded when it ends.
5
+ *
6
+ * It exists for facts that must be *fresh at the moment the user asks* but *constant while the
7
+ * answer is being computed*. Resolving which commit a branch points at is the one such fact:
8
+ *
9
+ * - re-resolving it per tracked file would cost one API call per file and, worse, let a push
10
+ * landing mid-scan produce a verdict where some docs were judged against the old commit and
11
+ * others against the new one;
12
+ * - caching it for a fixed duration would answer a question asked *now* with a branch head read
13
+ * minutes ago, and nothing would ever recompute it in between — freshness is only ever
14
+ * computed on demand, there is no background scan to catch up.
15
+ *
16
+ * Scoping it to the operation gives both: always the current branch head, resolved exactly once.
17
+ * Content caches do not belong here — blobs and trees are addressed by their own sha, so they are
18
+ * immutable and stay cached for the life of the process.
19
+ */
20
+ const storage = new AsyncLocalStorage();
21
+ /** Runs `fn` as one operation. Anything it starts, however deeply nested, shares one scratch space. */
22
+ export function runInCodeOperation(fn) {
23
+ return storage.run(new Map(), fn);
24
+ }
25
+ /**
26
+ * Computes `key` at most once per operation. Outside an operation there is nothing to share, so
27
+ * `compute` simply runs.
28
+ *
29
+ * The promise is what gets memoised, so concurrent callers await the same in-flight request — and
30
+ * a failure is remembered too, rather than retried once per tracked file.
31
+ */
32
+ export function oncePerOperation(key, compute) {
33
+ const store = storage.getStore();
34
+ if (!store)
35
+ return compute();
36
+ const pending = store.get(key);
37
+ if (pending)
38
+ return pending;
39
+ const promise = compute();
40
+ store.set(key, promise);
41
+ return promise;
42
+ }
@@ -3,8 +3,15 @@ export interface CodeRefResult {
3
3
  content: string;
4
4
  additionalTrackedFiles: TrackedFileInput[];
5
5
  }
6
- export declare function resolveCodeRefs(content: string): CodeRefResult;
7
- export declare function snapshotTrackedFiles(inputs: TrackedFileInput[], project: string): TrackedFile[];
6
+ /**
7
+ * Expands every `[copy-from-codebase|path|start|end]` marker with the referenced lines, read
8
+ * from the project's code source, and reports each reference so the caller can track it.
9
+ *
10
+ * A reference that cannot be read is left in place untouched — the marker is more useful to the
11
+ * agent than a silently empty block.
12
+ */
13
+ export declare function resolveCodeRefs(content: string, project: string): Promise<CodeRefResult>;
14
+ export declare function snapshotTrackedFiles(inputs: TrackedFileInput[], project: string): Promise<TrackedFile[]>;
8
15
  export interface ResnapshotResult {
9
16
  tracked: TrackedFile[];
10
17
  /** Files already tracked whose stored snapshot was carried over untouched. */
@@ -23,7 +30,7 @@ export interface ResnapshotResult {
23
30
  * doc that was stale before this call is still stale after it. Files that were not tracked before
24
31
  * necessarily carry a snapshot of their current content: there is no earlier state to preserve.
25
32
  */
26
- export declare function resnapshotPreserving(existing: TrackedFile[], inputs: TrackedFileInput[], project: string): ResnapshotResult;
33
+ export declare function resnapshotPreserving(existing: TrackedFile[], inputs: TrackedFileInput[], project: string): Promise<ResnapshotResult>;
27
34
  /**
28
35
  * Merges snapshotted tracked files into an existing list, without ever refreshing what is
29
36
  * already tracked — acknowledging staleness is `refresh_file_snapshot`'s job alone.
@@ -32,4 +39,4 @@ export declare function resnapshotPreserving(existing: TrackedFile[], inputs: Tr
32
39
  * that region is appended; existing sections keep their stored hash.
33
40
  * - Otherwise the existing entry is left untouched.
34
41
  */
35
- export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[], project: string): TrackedFile[];
42
+ export declare function mergeTrackedFiles(existing: TrackedFile[], additions: TrackedFileInput[], project: string): Promise<TrackedFile[]>;
@@ -1,8 +1,8 @@
1
- import crypto from "crypto";
2
- import fs from "fs";
3
1
  import path from "path";
4
- import { snapshotSection } from "./section-tracker.js";
5
- import { toAbsolutePath, toStoredPath } from "./path-resolver.js";
2
+ import { createLogger } from "@imenam/mcp-gui-interface";
3
+ import { snapshotSection, hashFileContent } from "./section-tracker.js";
4
+ import { getCodeSource } from "./code-source.js";
5
+ const logger = createLogger("CodeRefResolver");
6
6
  const EXT_TO_LANG = {
7
7
  ts: "typescript",
8
8
  tsx: "tsx",
@@ -26,78 +26,103 @@ const EXT_TO_LANG = {
26
26
  // Syntax: [copy-from-codebase|/absolute/path/to/file.ts|10|50]
27
27
  // Using | as separator to avoid conflicts with Windows drive letters (C:\...)
28
28
  const CODE_REF_REGEX = /\[copy-from-codebase\|([^|]+)\|(\d+)\|(\d+)\]/g;
29
- function computeFileHash(absolutePath) {
30
- return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
31
- }
32
- export function resolveCodeRefs(content) {
29
+ /**
30
+ * Expands every `[copy-from-codebase|path|start|end]` marker with the referenced lines, read
31
+ * from the project's code source, and reports each reference so the caller can track it.
32
+ *
33
+ * A reference that cannot be read is left in place untouched — the marker is more useful to the
34
+ * agent than a silently empty block.
35
+ */
36
+ export async function resolveCodeRefs(content, project) {
37
+ const matches = [...content.matchAll(CODE_REF_REGEX)];
38
+ if (matches.length === 0)
39
+ return { content, additionalTrackedFiles: [] };
40
+ const source = getCodeSource(project);
33
41
  const additionalTrackedFiles = [];
34
- const resolved = content.replace(CODE_REF_REGEX, (match, filePath, startStr, endStr) => {
42
+ const replacements = new Map();
43
+ for (const match of matches) {
44
+ const [marker, filePath, startStr, endStr] = match;
45
+ if (replacements.has(marker))
46
+ continue;
35
47
  const start = parseInt(startStr, 10);
36
48
  const end = parseInt(endStr, 10);
37
- if (!fs.existsSync(filePath)) {
38
- console.warn(`[copy-from-codebase] File not found: ${filePath}`);
39
- return match;
49
+ let file;
50
+ try {
51
+ file = await source.read(await source.toStoredPath(filePath));
52
+ }
53
+ catch (err) {
54
+ const message = err instanceof Error ? err.message : String(err);
55
+ logger.warn(`[copy-from-codebase] Could not read ${filePath} from ${source.label}: ${message}`);
56
+ continue;
57
+ }
58
+ if (!file) {
59
+ logger.warn(`[copy-from-codebase] File not found in ${source.label}: ${filePath}`);
60
+ continue;
40
61
  }
41
- const lines = fs.readFileSync(filePath, "utf-8").split("\n");
42
- const extracted = lines.slice(start - 1, end).join("\n");
43
- const ext = path.extname(filePath).slice(1).toLowerCase();
44
- const lang = EXT_TO_LANG[ext] ?? "";
62
+ const extracted = file.content.lines.slice(start - 1, end).join("\n");
63
+ const lang = EXT_TO_LANG[path.extname(filePath).slice(1).toLowerCase()] ?? "";
64
+ replacements.set(marker, `\`\`\`${lang}\n${extracted}\n\`\`\``);
45
65
  additionalTrackedFiles.push({ path: filePath, lines: [{ start, end }] });
46
- return `\`\`\`${lang}\n${extracted}\n\`\`\``;
47
- });
66
+ }
67
+ const resolved = content.replace(CODE_REF_REGEX, (marker) => replacements.get(marker) ?? marker);
48
68
  return { content: resolved, additionalTrackedFiles };
49
69
  }
50
- export function snapshotTrackedFiles(inputs, project) {
70
+ export async function snapshotTrackedFiles(inputs, project) {
71
+ const source = getCodeSource(project);
51
72
  // Keyed on the *stored* (portable) path so two inputs pointing at the same
52
73
  // file dedupe regardless of how the agent spelled the path.
53
74
  const byPath = new Map();
54
75
  for (const input of inputs) {
55
76
  const fp = typeof input === "string" ? input : input.path;
56
- // A relative input path means "relative to the project's code root" — the same
57
- // convention used everywhere a stored path is read back. Never the server's cwd.
58
- const absolutePath = toAbsolutePath(fp, project);
59
- if (!absolutePath) {
60
- throw new Error(`Cannot track "${fp}": the path is relative but no code root is configured for project "${project}". Provide an absolute path or set MCP_DOCUMENTOR_CODE_ROOT.`);
61
- }
62
- if (!fs.existsSync(absolutePath)) {
63
- throw new Error(`Cannot track "${fp}": file not found at ${absolutePath}. Tracking snapshots the file content, so the file must exist.`);
64
- }
65
- const storedPath = toStoredPath(absolutePath, project);
66
- const mtime = fs.statSync(absolutePath).mtime.toISOString();
67
- const isWhole = typeof input === "string" || input.lines.length === 0;
77
+ const lines = typeof input === "string" ? [] : input.lines;
78
+ const storedPath = await toStoredPathOrThrow(source, fp, project);
68
79
  const existing = byPath.get(storedPath);
69
- if (isWhole) {
70
- byPath.set(storedPath, { absolutePath, mtime, lineRanges: [], wholeFile: true });
80
+ if (lines.length === 0) {
81
+ byPath.set(storedPath, { lineRanges: [], wholeFile: true });
71
82
  }
72
83
  else if (existing) {
73
- if (!existing.wholeFile) {
74
- existing.lineRanges.push(...input.lines);
75
- }
84
+ if (!existing.wholeFile)
85
+ existing.lineRanges.push(...lines);
76
86
  }
77
87
  else {
78
- byPath.set(storedPath, {
79
- absolutePath,
80
- mtime,
81
- lineRanges: [...input.lines],
82
- wholeFile: false,
83
- });
88
+ byPath.set(storedPath, { lineRanges: [...lines], wholeFile: false });
84
89
  }
85
90
  }
86
91
  const tracked = [];
87
- for (const [storedPath, { absolutePath, mtime, lineRanges, wholeFile }] of byPath) {
92
+ for (const [storedPath, { lineRanges, wholeFile }] of byPath) {
93
+ const file = await source.read(storedPath);
94
+ if (!file) {
95
+ throw new Error(`Cannot track "${storedPath}": not found in ${source.label}. Tracking snapshots the file content, so the file must exist there.`);
96
+ }
88
97
  const validRanges = lineRanges.filter(({ start, end }) => start <= end);
98
+ const base = {
99
+ path: storedPath,
100
+ lastModifiedAt: file.lastModifiedAt,
101
+ sourceRevision: file.revision,
102
+ sourceBlobId: file.blobId,
103
+ };
89
104
  if (!wholeFile && validRanges.length > 0) {
90
- const sections = dedupeSections(validRanges.map(({ start, end }) => snapshotSection(absolutePath, start, end)));
91
- tracked.push({ path: storedPath, lastModifiedAt: mtime, sections });
105
+ const sections = dedupeSections(validRanges.map(({ start, end }) => snapshotSection(file.content, start, end)));
106
+ tracked.push({ ...base, sections });
92
107
  }
93
108
  else {
94
109
  // Whole-file tracking — also the landing spot when every provided range was
95
110
  // invalid, so an entry never ends up with neither sections nor contentHash.
96
- tracked.push({ path: storedPath, lastModifiedAt: mtime, contentHash: computeFileHash(absolutePath) });
111
+ tracked.push({ ...base, contentHash: hashFileContent(file.content) });
97
112
  }
98
113
  }
99
114
  return tracked;
100
115
  }
116
+ /** Maps an agent-supplied path to its stored form, turning a source-specific failure into a clear error. */
117
+ async function toStoredPathOrThrow(source, inputPath, project) {
118
+ try {
119
+ return await source.toStoredPath(inputPath);
120
+ }
121
+ catch (err) {
122
+ const message = err instanceof Error ? err.message : String(err);
123
+ throw new Error(`Cannot track "${inputPath}" for project "${project}": ${message}`);
124
+ }
125
+ }
101
126
  const sameLines = (a, b) => a.length === b.length && a.every((line, i) => line === b[i]);
102
127
  /** Two sections describe the same region when they were anchored on the same top/bottom lines. */
103
128
  function sameRegion(a, b) {
@@ -137,8 +162,8 @@ function carryOverSnapshot(previous, fresh) {
137
162
  * doc that was stale before this call is still stale after it. Files that were not tracked before
138
163
  * necessarily carry a snapshot of their current content: there is no earlier state to preserve.
139
164
  */
140
- export function resnapshotPreserving(existing, inputs, project) {
141
- const fresh = snapshotTrackedFiles(inputs, project);
165
+ export async function resnapshotPreserving(existing, inputs, project) {
166
+ const fresh = await snapshotTrackedFiles(inputs, project);
142
167
  const byPath = new Map(existing.map((tf) => [tf.path, tf]));
143
168
  const preserved = [];
144
169
  const added = [];
@@ -167,13 +192,13 @@ export function resnapshotPreserving(existing, inputs, project) {
167
192
  * that region is appended; existing sections keep their stored hash.
168
193
  * - Otherwise the existing entry is left untouched.
169
194
  */
170
- export function mergeTrackedFiles(existing, additions, project) {
195
+ export async function mergeTrackedFiles(existing, additions, project) {
171
196
  if (additions.length === 0)
172
197
  return existing;
173
- const snapshotted = snapshotTrackedFiles(additions, project);
198
+ const snapshotted = await snapshotTrackedFiles(additions, project);
174
199
  const result = [...existing];
175
200
  for (const newTf of snapshotted) {
176
- const idx = result.findIndex(tf => tf.path === newTf.path);
201
+ const idx = result.findIndex((tf) => tf.path === newTf.path);
177
202
  if (idx < 0) {
178
203
  result.push(newTf);
179
204
  continue;
@@ -0,0 +1,79 @@
1
+ import type { TrackedSection } from "../../shared/schemas/doc.schema.js";
2
+ import { type FileContent } from "./section-tracker.js";
3
+ import { GitHubCodeApi } from "./github-code-api.js";
4
+ /**
5
+ * Where a project's source code is read from when checking or snapshotting documentation.
6
+ *
7
+ * Two backends:
8
+ *
9
+ * - **local** — the working copy on this machine, resolved through the code roots. This is the
10
+ * original behaviour and stays the default: it sees uncommitted work, and it sees whichever
11
+ * branch happens to be checked out.
12
+ * - **github** — one branch of one repository, read over the GitHub API. The documentation then
13
+ * tracks *published* code: switching branches locally, or editing without committing, no longer
14
+ * makes docs look stale, and a server that has no copy of the codebase at all (an MCP gateway)
15
+ * can still verify freshness.
16
+ *
17
+ * A project uses the GitHub backend only when a repository is configured for it. With no
18
+ * repository set, everything behaves exactly as before.
19
+ *
20
+ * MCP_DOCUMENTOR_CODE_REPO the repository to read code from, in either of two shapes:
21
+ * a single spec — "owner/repo", a GitHub URL, with an optional
22
+ * "#branch" suffix — applied to every project, or a JSON map of
23
+ * per-project specs, e.g.
24
+ * {"web":"Imena/web#main","legacy":"local"}
25
+ * — the value "local" opts a project back out to its working copy
26
+ * MCP_DOCUMENTOR_CODE_BRANCH branch used when a repository carries no "#branch" suffix
27
+ * (default: the repository's own default branch)
28
+ * MCP_DOCUMENTOR_CODE_TOKEN GitHub token; falls back to MCP_DOCUMENTOR_SYNC_TOKEN
29
+ */
30
+ /** One file as it exists at the source. */
31
+ export interface SourceFile {
32
+ content: FileContent;
33
+ /** Snapshot timestamp — the file's mtime locally, the commit date at the tracked ref on GitHub. */
34
+ lastModifiedAt: string;
35
+ /** The commit the content was read at; absent for the local filesystem, which has no revision. */
36
+ revision?: string;
37
+ /** Content identity at the source — the git blob sha on GitHub. See `CodeSource.contentId`. */
38
+ blobId?: string;
39
+ }
40
+ export interface CodeSource {
41
+ readonly kind: "local" | "github";
42
+ /** Short description of where this source reads from, for reports and error messages. */
43
+ readonly label: string;
44
+ /**
45
+ * Reads a stored tracked path. Returns undefined when the file does not exist at the source.
46
+ * Throws when the source itself could not be consulted (network, auth, rate limit) — callers
47
+ * must not read that as "the file changed".
48
+ */
49
+ read(storedPath: string): Promise<SourceFile | undefined>;
50
+ /**
51
+ * Identity of a file's content, obtained without transferring it — undefined when the source
52
+ * has no such shortcut, or when the file is absent. A stored identity that still matches proves
53
+ * the file is untouched, so freshness can be settled without reading anything.
54
+ */
55
+ contentId(storedPath: string): Promise<string | undefined>;
56
+ /** Converts a path supplied by an agent into the portable form persisted in a doc. */
57
+ toStoredPath(inputPath: string): Promise<string>;
58
+ }
59
+ /** Test-only / config-reload: drops the memoised environment snapshot and every cached source. */
60
+ export declare function resetCodeSourceCache(): void;
61
+ /** The code source a project reads from. Memoised — the configuration is environment-fixed. */
62
+ export declare function getCodeSource(project: string): CodeSource;
63
+ /**
64
+ * Where each tracked section of a file currently sits, as 1-indexed line ranges — `undefined`
65
+ * for a section whose anchors no longer match, and `undefined` for every section when the file
66
+ * itself cannot be read. Used to show positions without storing them.
67
+ */
68
+ export declare function locateTrackedSections(project: string, storedPath: string, sections: TrackedSection[]): Promise<Array<{
69
+ start: number;
70
+ end: number;
71
+ } | undefined>>;
72
+ /**
73
+ * Stored form of an agent-supplied path, for callers that only use it to match against paths a
74
+ * doc already tracks. A path the source cannot place is returned normalised rather than raising:
75
+ * it simply matches nothing, which is the right outcome for a filter.
76
+ */
77
+ export declare function toStoredPathForMatching(project: string, inputPath: string): Promise<string>;
78
+ /** The GitHub client behind a project's source, or undefined when it reads the local working copy. */
79
+ export declare function getGitHubApi(project: string): GitHubCodeApi | undefined;