@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.
- package/README.md +58 -0
- package/dist/shared/schemas/doc.schema.d.ts +6 -0
- package/dist/shared/schemas/doc.schema.js +18 -2
- package/dist/src/cli/setup-mcp.js +3 -0
- package/dist/src/core/code-operation.d.ts +10 -0
- package/dist/src/core/code-operation.js +42 -0
- package/dist/src/core/code-ref-resolver.d.ts +11 -4
- package/dist/src/core/code-ref-resolver.js +76 -51
- package/dist/src/core/code-source.d.ts +79 -0
- package/dist/src/core/code-source.js +248 -0
- package/dist/src/core/github-code-api.d.ts +72 -0
- package/dist/src/core/github-code-api.js +182 -0
- package/dist/src/core/section-tracker.d.ts +38 -13
- package/dist/src/core/section-tracker.js +75 -72
- package/dist/src/index.js +19 -11
- package/dist/src/tools/check-freshness.d.ts +1 -2
- package/dist/src/tools/check-freshness.js +82 -34
- package/dist/src/tools/create-doc.js +2 -2
- package/dist/src/tools/get-doc-metadata.js +14 -13
- package/dist/src/tools/get-doc.js +23 -20
- package/dist/src/tools/git-diff-doc.js +69 -13
- package/dist/src/tools/list-docs.js +2 -2
- package/dist/src/tools/patch-doc.js +2 -2
- package/dist/src/tools/refresh-file-snapshot.js +19 -17
- package/dist/src/tools/update-doc.js +4 -4
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
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
|
|
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
|
|
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
|
-
|
|
7
|
-
|
|
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 {
|
|
5
|
-
import {
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
42
|
-
const
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
|
|
57
|
-
|
|
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 (
|
|
70
|
-
byPath.set(storedPath, {
|
|
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(...
|
|
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, {
|
|
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(
|
|
91
|
-
tracked.push({
|
|
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({
|
|
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;
|