@imenam/mcp-documentor 1.0.34 → 1.0.36
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/dist/shared/schemas/doc.schema.d.ts +49 -2
- package/dist/shared/schemas/doc.schema.js +23 -3
- package/dist/src/core/section-tracker.d.ts +40 -0
- package/dist/src/core/section-tracker.js +99 -0
- package/dist/src/index.js +54 -4
- package/dist/src/tools/check-freshness.js +43 -5
- package/dist/src/tools/create-doc.js +16 -8
- package/dist/src/tools/get-doc-metadata.js +15 -3
- package/dist/src/tools/get-doc.js +14 -1
- package/dist/src/tools/refresh-file-snapshot.js +29 -4
- package/dist/src/tools/update-doc.js +16 -8
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +1 -1
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
export declare const TrackedSectionSchema: z.ZodObject<{
|
|
3
|
+
anchors: z.ZodObject<{
|
|
4
|
+
top: z.ZodArray<z.ZodString>;
|
|
5
|
+
bottom: z.ZodArray<z.ZodString>;
|
|
6
|
+
}, z.core.$strip>;
|
|
7
|
+
sectionHash: z.ZodString;
|
|
8
|
+
}, z.core.$strip>;
|
|
9
|
+
export type TrackedSection = z.infer<typeof TrackedSectionSchema>;
|
|
2
10
|
export declare const TrackedFileSchema: z.ZodObject<{
|
|
3
11
|
path: z.ZodString;
|
|
4
12
|
lastModifiedAt: z.ZodString;
|
|
5
13
|
contentHash: z.ZodOptional<z.ZodString>;
|
|
14
|
+
sections: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
15
|
+
anchors: z.ZodObject<{
|
|
16
|
+
top: z.ZodArray<z.ZodString>;
|
|
17
|
+
bottom: z.ZodArray<z.ZodString>;
|
|
18
|
+
}, z.core.$strip>;
|
|
19
|
+
sectionHash: z.ZodString;
|
|
20
|
+
}, z.core.$strip>>>;
|
|
6
21
|
}, z.core.$strip>;
|
|
7
22
|
export type TrackedFile = z.infer<typeof TrackedFileSchema>;
|
|
23
|
+
export declare const TrackedFileInputLineSchema: z.ZodObject<{
|
|
24
|
+
start: z.ZodNumber;
|
|
25
|
+
end: z.ZodNumber;
|
|
26
|
+
}, z.core.$strip>;
|
|
27
|
+
export declare const TrackedFileInputSchema: z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
28
|
+
path: z.ZodString;
|
|
29
|
+
lines: z.ZodArray<z.ZodObject<{
|
|
30
|
+
start: z.ZodNumber;
|
|
31
|
+
end: z.ZodNumber;
|
|
32
|
+
}, z.core.$strip>>;
|
|
33
|
+
}, z.core.$strip>]>;
|
|
34
|
+
export type TrackedFileInput = z.infer<typeof TrackedFileInputSchema>;
|
|
8
35
|
export declare const DocSchema: z.ZodObject<{
|
|
9
36
|
id: z.ZodString;
|
|
10
37
|
title: z.ZodString;
|
|
@@ -13,6 +40,13 @@ export declare const DocSchema: z.ZodObject<{
|
|
|
13
40
|
path: z.ZodString;
|
|
14
41
|
lastModifiedAt: z.ZodString;
|
|
15
42
|
contentHash: z.ZodOptional<z.ZodString>;
|
|
43
|
+
sections: z.ZodOptional<z.ZodArray<z.ZodObject<{
|
|
44
|
+
anchors: z.ZodObject<{
|
|
45
|
+
top: z.ZodArray<z.ZodString>;
|
|
46
|
+
bottom: z.ZodArray<z.ZodString>;
|
|
47
|
+
}, z.core.$strip>;
|
|
48
|
+
sectionHash: z.ZodString;
|
|
49
|
+
}, z.core.$strip>>>;
|
|
16
50
|
}, z.core.$strip>>>;
|
|
17
51
|
tags: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
18
52
|
folder: z.ZodOptional<z.ZodString>;
|
|
@@ -47,13 +81,20 @@ export declare const FreshnessReportSchema: z.ZodObject<{
|
|
|
47
81
|
modified: z.ZodArray<z.ZodString>;
|
|
48
82
|
missing: z.ZodArray<z.ZodString>;
|
|
49
83
|
unchanged: z.ZodArray<z.ZodString>;
|
|
84
|
+
anchors_lost: z.ZodDefault<z.ZodArray<z.ZodString>>;
|
|
50
85
|
total: z.ZodNumber;
|
|
51
86
|
}, z.core.$strip>;
|
|
52
87
|
export type FreshnessReport = z.infer<typeof FreshnessReportSchema>;
|
|
53
88
|
export declare const CreateDocArgsSchema: z.ZodObject<{
|
|
54
89
|
title: z.ZodString;
|
|
55
90
|
content: z.ZodString;
|
|
56
|
-
tracked_files: z.ZodOptional<z.ZodArray<z.ZodString
|
|
91
|
+
tracked_files: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
92
|
+
path: z.ZodString;
|
|
93
|
+
lines: z.ZodArray<z.ZodObject<{
|
|
94
|
+
start: z.ZodNumber;
|
|
95
|
+
end: z.ZodNumber;
|
|
96
|
+
}, z.core.$strip>>;
|
|
97
|
+
}, z.core.$strip>]>>>;
|
|
57
98
|
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
58
99
|
folder: z.ZodOptional<z.ZodString>;
|
|
59
100
|
related_docs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
@@ -62,7 +103,13 @@ export declare const UpdateDocArgsSchema: z.ZodObject<{
|
|
|
62
103
|
path: z.ZodString;
|
|
63
104
|
title: z.ZodOptional<z.ZodString>;
|
|
64
105
|
content: z.ZodOptional<z.ZodString>;
|
|
65
|
-
tracked_files: z.ZodOptional<z.ZodArray<z.ZodString
|
|
106
|
+
tracked_files: z.ZodOptional<z.ZodArray<z.ZodUnion<readonly [z.ZodString, z.ZodObject<{
|
|
107
|
+
path: z.ZodString;
|
|
108
|
+
lines: z.ZodArray<z.ZodObject<{
|
|
109
|
+
start: z.ZodNumber;
|
|
110
|
+
end: z.ZodNumber;
|
|
111
|
+
}, z.core.$strip>>;
|
|
112
|
+
}, z.core.$strip>]>>>;
|
|
66
113
|
tags: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
67
114
|
related_docs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
68
115
|
}, z.core.$strip>;
|
|
@@ -1,9 +1,28 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
export const TrackedSectionSchema = z.object({
|
|
3
|
+
anchors: z.object({
|
|
4
|
+
top: z.array(z.string()),
|
|
5
|
+
bottom: z.array(z.string()),
|
|
6
|
+
}),
|
|
7
|
+
sectionHash: z.string(),
|
|
8
|
+
});
|
|
2
9
|
export const TrackedFileSchema = z.object({
|
|
3
10
|
path: z.string(),
|
|
4
11
|
lastModifiedAt: z.string(),
|
|
5
12
|
contentHash: z.string().optional(),
|
|
6
|
-
|
|
13
|
+
sections: z.array(TrackedSectionSchema).optional(),
|
|
14
|
+
});
|
|
15
|
+
export const TrackedFileInputLineSchema = z.object({
|
|
16
|
+
start: z.number().int().positive(),
|
|
17
|
+
end: z.number().int().positive(),
|
|
18
|
+
});
|
|
19
|
+
export const TrackedFileInputSchema = z.union([
|
|
20
|
+
z.string(),
|
|
21
|
+
z.object({
|
|
22
|
+
path: z.string(),
|
|
23
|
+
lines: z.array(TrackedFileInputLineSchema).min(1),
|
|
24
|
+
}),
|
|
25
|
+
]);
|
|
7
26
|
export const DocSchema = z.object({
|
|
8
27
|
id: z.string(),
|
|
9
28
|
title: z.string(),
|
|
@@ -20,12 +39,13 @@ export const FreshnessReportSchema = z.object({
|
|
|
20
39
|
modified: z.array(z.string()),
|
|
21
40
|
missing: z.array(z.string()),
|
|
22
41
|
unchanged: z.array(z.string()),
|
|
42
|
+
anchors_lost: z.array(z.string()).default([]),
|
|
23
43
|
total: z.number(),
|
|
24
44
|
});
|
|
25
45
|
export const CreateDocArgsSchema = z.object({
|
|
26
46
|
title: z.string().describe("Title of the documentation entry"),
|
|
27
47
|
content: z.string().describe("Documentation content in markdown"),
|
|
28
|
-
tracked_files: z.array(
|
|
48
|
+
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."),
|
|
29
49
|
tags: z.array(z.string()).optional().describe("Tags for categorization"),
|
|
30
50
|
folder: z.string().optional().describe("Folder path for organization, e.g. 'frontend/components'"),
|
|
31
51
|
related_docs: z.array(z.string()).optional().describe("IDs of related documentation entries"),
|
|
@@ -34,7 +54,7 @@ export const UpdateDocArgsSchema = z.object({
|
|
|
34
54
|
path: z.string().describe("Path of the documentation entry to update, e.g. 'backend/auth/authentication' or 'authentication' for root docs"),
|
|
35
55
|
title: z.string().optional().describe("Title of the documentation entry"),
|
|
36
56
|
content: z.string().optional().describe("New content (replaces existing)"),
|
|
37
|
-
tracked_files: z.array(
|
|
57
|
+
tracked_files: z.array(TrackedFileInputSchema).optional().describe("New list of tracked files (replaces existing). 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."),
|
|
38
58
|
tags: z.array(z.string()).optional().describe("New tags (replaces existing)"),
|
|
39
59
|
related_docs: z.array(z.string()).optional().describe("Paths of related documentation entries (replaces existing)"),
|
|
40
60
|
});
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { TrackedSection } from "../../shared/schemas/doc.schema.js";
|
|
2
|
+
/**
|
|
3
|
+
* Reads a file and snapshots a section defined by 1-indexed [start, end] line numbers.
|
|
4
|
+
* Extracts top/bottom anchor lines and computes a hash of the section content.
|
|
5
|
+
* The `lines` input is intentionally not persisted — only anchors and sectionHash are stored.
|
|
6
|
+
*/
|
|
7
|
+
export declare function snapshotSection(absolutePath: string, start: number, end: number): TrackedSection;
|
|
8
|
+
type FindResult = {
|
|
9
|
+
found: false;
|
|
10
|
+
} | {
|
|
11
|
+
found: true;
|
|
12
|
+
currentHash: string;
|
|
13
|
+
lines: {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Locates a tracked section in the current file content using its stored anchors,
|
|
20
|
+
* then hashes the found content for comparison with the stored sectionHash.
|
|
21
|
+
* Also returns the current 1-indexed line range where the section was found,
|
|
22
|
+
* so callers can display it without storing it.
|
|
23
|
+
*
|
|
24
|
+
* Returns { found: false } if the anchors cannot be located — which is treated as stale,
|
|
25
|
+
* since the surrounding context has changed enough to invalidate the documentation.
|
|
26
|
+
*/
|
|
27
|
+
export declare function findAndHashSection(absolutePath: string, section: TrackedSection): FindResult;
|
|
28
|
+
type RelocateResult = {
|
|
29
|
+
found: false;
|
|
30
|
+
} | {
|
|
31
|
+
found: true;
|
|
32
|
+
refreshed: TrackedSection;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Locates a section by its current anchors, then refreshes its sectionHash to match
|
|
36
|
+
* the current file content. Used by refresh_file_snapshot to acknowledge changes.
|
|
37
|
+
* Anchors are kept as-is (they were found successfully); only sectionHash is updated.
|
|
38
|
+
*/
|
|
39
|
+
export declare function relocateAndRefreshSection(absolutePath: string, section: TrackedSection): RelocateResult;
|
|
40
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
const ANCHOR_SIZE = 3;
|
|
4
|
+
/**
|
|
5
|
+
* Reads a file and snapshots a section defined by 1-indexed [start, end] line numbers.
|
|
6
|
+
* Extracts top/bottom anchor lines and computes a hash of the section content.
|
|
7
|
+
* The `lines` input is intentionally not persisted — only anchors and sectionHash are stored.
|
|
8
|
+
*/
|
|
9
|
+
export function snapshotSection(absolutePath, start, end) {
|
|
10
|
+
const fileContent = fs.readFileSync(absolutePath, "utf-8");
|
|
11
|
+
const allLines = fileContent.split("\n");
|
|
12
|
+
const startIdx = Math.max(0, start - 1);
|
|
13
|
+
const endIdx = Math.min(allLines.length - 1, end - 1);
|
|
14
|
+
const sectionLines = allLines.slice(startIdx, endIdx + 1);
|
|
15
|
+
let top;
|
|
16
|
+
let bottom;
|
|
17
|
+
if (sectionLines.length < ANCHOR_SIZE * 2) {
|
|
18
|
+
// Short section: all lines become the top anchor; bottom is empty (section = top anchor)
|
|
19
|
+
top = [...sectionLines];
|
|
20
|
+
bottom = [];
|
|
21
|
+
}
|
|
22
|
+
else {
|
|
23
|
+
top = sectionLines.slice(0, ANCHOR_SIZE);
|
|
24
|
+
bottom = sectionLines.slice(sectionLines.length - ANCHOR_SIZE);
|
|
25
|
+
}
|
|
26
|
+
const sectionHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
|
|
27
|
+
return { anchors: { top, bottom }, sectionHash };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Locates a tracked section in the current file content using its stored anchors,
|
|
31
|
+
* then hashes the found content for comparison with the stored sectionHash.
|
|
32
|
+
* Also returns the current 1-indexed line range where the section was found,
|
|
33
|
+
* so callers can display it without storing it.
|
|
34
|
+
*
|
|
35
|
+
* Returns { found: false } if the anchors cannot be located — which is treated as stale,
|
|
36
|
+
* since the surrounding context has changed enough to invalidate the documentation.
|
|
37
|
+
*/
|
|
38
|
+
export function findAndHashSection(absolutePath, section) {
|
|
39
|
+
const fileContent = fs.readFileSync(absolutePath, "utf-8");
|
|
40
|
+
const fileLines = fileContent.split("\n");
|
|
41
|
+
const { top, bottom } = section.anchors;
|
|
42
|
+
if (bottom.length === 0) {
|
|
43
|
+
// Short-section path: the top anchor IS the full section content
|
|
44
|
+
for (let i = 0; i <= fileLines.length - top.length; i++) {
|
|
45
|
+
if (top.every((line, j) => fileLines[i + j] === line)) {
|
|
46
|
+
const sectionLines = fileLines.slice(i, i + top.length);
|
|
47
|
+
const currentHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
|
|
48
|
+
return { found: true, currentHash, lines: { start: i + 1, end: i + top.length } };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { found: false };
|
|
52
|
+
}
|
|
53
|
+
// Normal path: locate top anchor, then search for bottom anchor below it
|
|
54
|
+
for (let i = 0; i <= fileLines.length - top.length; i++) {
|
|
55
|
+
if (!top.every((line, j) => fileLines[i + j] === line))
|
|
56
|
+
continue;
|
|
57
|
+
const searchStart = i + top.length;
|
|
58
|
+
for (let k = searchStart; k <= fileLines.length - bottom.length; k++) {
|
|
59
|
+
if (!bottom.every((line, j) => fileLines[k + j] === line))
|
|
60
|
+
continue;
|
|
61
|
+
const sectionLines = fileLines.slice(i, k + bottom.length);
|
|
62
|
+
const currentHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
|
|
63
|
+
return { found: true, currentHash, lines: { start: i + 1, end: k + bottom.length } };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return { found: false };
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Locates a section by its current anchors, then refreshes its sectionHash to match
|
|
70
|
+
* the current file content. Used by refresh_file_snapshot to acknowledge changes.
|
|
71
|
+
* Anchors are kept as-is (they were found successfully); only sectionHash is updated.
|
|
72
|
+
*/
|
|
73
|
+
export function relocateAndRefreshSection(absolutePath, section) {
|
|
74
|
+
const fileContent = fs.readFileSync(absolutePath, "utf-8");
|
|
75
|
+
const fileLines = fileContent.split("\n");
|
|
76
|
+
const { top, bottom } = section.anchors;
|
|
77
|
+
if (bottom.length === 0) {
|
|
78
|
+
for (let i = 0; i <= fileLines.length - top.length; i++) {
|
|
79
|
+
if (top.every((line, j) => fileLines[i + j] === line)) {
|
|
80
|
+
const sectionHash = crypto.createHash("sha256").update(fileLines.slice(i, i + top.length).join("\n")).digest("hex");
|
|
81
|
+
return { found: true, refreshed: { anchors: section.anchors, sectionHash } };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { found: false };
|
|
85
|
+
}
|
|
86
|
+
for (let i = 0; i <= fileLines.length - top.length; i++) {
|
|
87
|
+
if (!top.every((line, j) => fileLines[i + j] === line))
|
|
88
|
+
continue;
|
|
89
|
+
const searchStart = i + top.length;
|
|
90
|
+
for (let k = searchStart; k <= fileLines.length - bottom.length; k++) {
|
|
91
|
+
if (!bottom.every((line, j) => fileLines[k + j] === line))
|
|
92
|
+
continue;
|
|
93
|
+
const sectionLines = fileLines.slice(i, k + bottom.length);
|
|
94
|
+
const sectionHash = crypto.createHash("sha256").update(sectionLines.join("\n")).digest("hex");
|
|
95
|
+
return { found: true, refreshed: { anchors: section.anchors, sectionHash } };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { found: false };
|
|
99
|
+
}
|
package/dist/src/index.js
CHANGED
|
@@ -450,8 +450,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
450
450
|
content: { type: "string", description: "Documentation content in markdown. To link to another document inline, use the syntax [text](doc://target-doc-id) — the viewer will navigate to that document on click. To link to a heading within the same document, use [text](#heading-slug). Use check_links to validate both types. For code blocks, always specify a language identifier for syntax highlighting, e.g. ```typescript, ```python, ```bash, ```json, ```yaml, ```sql." },
|
|
451
451
|
tracked_files: {
|
|
452
452
|
type: "array",
|
|
453
|
-
items: {
|
|
454
|
-
|
|
453
|
+
items: {
|
|
454
|
+
oneOf: [
|
|
455
|
+
{ type: "string", description: "Plain file path for whole-file tracking" },
|
|
456
|
+
{
|
|
457
|
+
type: "object",
|
|
458
|
+
properties: {
|
|
459
|
+
path: { type: "string", description: "File path to track" },
|
|
460
|
+
lines: {
|
|
461
|
+
type: "array",
|
|
462
|
+
items: {
|
|
463
|
+
type: "object",
|
|
464
|
+
properties: {
|
|
465
|
+
start: { type: "integer", minimum: 1, description: "First line of the section (1-indexed, inclusive)" },
|
|
466
|
+
end: { type: "integer", minimum: 1, description: "Last line of the section (1-indexed, inclusive)" },
|
|
467
|
+
},
|
|
468
|
+
required: ["start", "end"],
|
|
469
|
+
},
|
|
470
|
+
minItems: 1,
|
|
471
|
+
description: "One or more line ranges to track within the file",
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
required: ["path", "lines"],
|
|
475
|
+
description: "Granular section tracking: only the specified line ranges are monitored",
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
},
|
|
479
|
+
description: "Files to track for staleness detection. Use a plain path string for whole-file tracking, or { path, lines: [{ start, end }] } to track specific sections only (reduces false positives when unrelated parts of the file change).",
|
|
455
480
|
},
|
|
456
481
|
tags: {
|
|
457
482
|
type: "array",
|
|
@@ -504,8 +529,33 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
|
504
529
|
content: { type: "string", description: "New content (replaces existing). Supports [text](doc://target-doc-path) to link inline to other documents, and [text](#heading-slug) to link to headings within this document. For code blocks, always specify a language identifier for syntax highlighting, e.g. ```typescript, ```python, ```bash, ```json, ```yaml, ```sql." },
|
|
505
530
|
tracked_files: {
|
|
506
531
|
type: "array",
|
|
507
|
-
items: {
|
|
508
|
-
|
|
532
|
+
items: {
|
|
533
|
+
oneOf: [
|
|
534
|
+
{ type: "string", description: "Plain file path for whole-file tracking" },
|
|
535
|
+
{
|
|
536
|
+
type: "object",
|
|
537
|
+
properties: {
|
|
538
|
+
path: { type: "string", description: "File path to track" },
|
|
539
|
+
lines: {
|
|
540
|
+
type: "array",
|
|
541
|
+
items: {
|
|
542
|
+
type: "object",
|
|
543
|
+
properties: {
|
|
544
|
+
start: { type: "integer", minimum: 1, description: "First line of the section (1-indexed, inclusive)" },
|
|
545
|
+
end: { type: "integer", minimum: 1, description: "Last line of the section (1-indexed, inclusive)" },
|
|
546
|
+
},
|
|
547
|
+
required: ["start", "end"],
|
|
548
|
+
},
|
|
549
|
+
minItems: 1,
|
|
550
|
+
description: "One or more line ranges to track within the file",
|
|
551
|
+
},
|
|
552
|
+
},
|
|
553
|
+
required: ["path", "lines"],
|
|
554
|
+
description: "Granular section tracking: only the specified line ranges are monitored",
|
|
555
|
+
},
|
|
556
|
+
],
|
|
557
|
+
},
|
|
558
|
+
description: "New list of tracked files (replaces existing). Use a plain path string for whole-file tracking, or { path, lines: [{ start, end }] } to track specific sections only (reduces false positives when unrelated parts of the file change).",
|
|
509
559
|
},
|
|
510
560
|
tags: {
|
|
511
561
|
type: "array",
|
|
@@ -2,23 +2,52 @@ import crypto from "crypto";
|
|
|
2
2
|
import fs from "fs";
|
|
3
3
|
import path from "path";
|
|
4
4
|
import { CheckFreshnessArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
5
|
+
import { findAndHashSection } from "../core/section-tracker.js";
|
|
5
6
|
export function computeFileHash(absolutePath) {
|
|
6
7
|
return crypto.createHash("sha256").update(fs.readFileSync(absolutePath)).digest("hex");
|
|
7
8
|
}
|
|
8
9
|
export function computeFreshness(trackedFiles) {
|
|
9
10
|
if (!trackedFiles || trackedFiles.length === 0) {
|
|
10
|
-
return { status: "no_tracking", modified: [], missing: [], unchanged: [], total: 0 };
|
|
11
|
+
return { status: "no_tracking", modified: [], missing: [], unchanged: [], anchors_lost: [], total: 0 };
|
|
11
12
|
}
|
|
12
13
|
const modified = [];
|
|
13
14
|
const missing = [];
|
|
14
15
|
const unchanged = [];
|
|
16
|
+
const anchors_lost = [];
|
|
15
17
|
for (const tracked of trackedFiles) {
|
|
16
18
|
const absolutePath = path.resolve(process.cwd(), tracked.path);
|
|
17
19
|
if (!fs.existsSync(absolutePath)) {
|
|
18
20
|
missing.push(tracked.path);
|
|
19
21
|
continue;
|
|
20
22
|
}
|
|
21
|
-
if (tracked.
|
|
23
|
+
if (tracked.sections && tracked.sections.length > 0) {
|
|
24
|
+
let sectionStale = false;
|
|
25
|
+
let anchorLost = false;
|
|
26
|
+
for (const section of tracked.sections) {
|
|
27
|
+
const result = findAndHashSection(absolutePath, section);
|
|
28
|
+
if (!result.found) {
|
|
29
|
+
sectionStale = true;
|
|
30
|
+
anchorLost = true;
|
|
31
|
+
break;
|
|
32
|
+
}
|
|
33
|
+
else if (result.currentHash !== section.sectionHash) {
|
|
34
|
+
sectionStale = true;
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
if (sectionStale) {
|
|
39
|
+
if (anchorLost) {
|
|
40
|
+
anchors_lost.push(tracked.path);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
modified.push(tracked.path);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
unchanged.push(tracked.path);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (tracked.contentHash) {
|
|
22
51
|
const currentHash = computeFileHash(absolutePath);
|
|
23
52
|
if (currentHash !== tracked.contentHash) {
|
|
24
53
|
modified.push(tracked.path);
|
|
@@ -39,12 +68,13 @@ export function computeFreshness(trackedFiles) {
|
|
|
39
68
|
}
|
|
40
69
|
}
|
|
41
70
|
}
|
|
42
|
-
const isStale = modified.length > 0 || missing.length > 0;
|
|
71
|
+
const isStale = modified.length > 0 || missing.length > 0 || anchors_lost.length > 0;
|
|
43
72
|
return {
|
|
44
73
|
status: isStale ? "stale" : "up_to_date",
|
|
45
74
|
modified,
|
|
46
75
|
missing,
|
|
47
76
|
unchanged,
|
|
77
|
+
anchors_lost,
|
|
48
78
|
total: trackedFiles.length,
|
|
49
79
|
};
|
|
50
80
|
}
|
|
@@ -55,13 +85,19 @@ export function formatFreshnessReport(title, report) {
|
|
|
55
85
|
let text = report.status === "stale"
|
|
56
86
|
? `STALE: "${title}" — tracked files have changed since the documentation was last updated.\n`
|
|
57
87
|
: `UP TO DATE: "${title}" — no tracked files have been modified.\n`;
|
|
58
|
-
|
|
88
|
+
const anchorLostCount = report.anchors_lost?.length ?? 0;
|
|
89
|
+
text += `\nSummary: ${report.modified.length} modified, ${report.missing.length} missing, ${anchorLostCount} with lost anchors, ${report.unchanged.length} unchanged out of ${report.total} tracked file(s).`;
|
|
59
90
|
if (report.modified.length > 0) {
|
|
60
|
-
text += `\n\nModified files:\n${report.modified.map((f) => ` - ${f}`).join("\n")}`;
|
|
91
|
+
text += `\n\nModified files (call refresh_file_snapshot to acknowledge):\n${report.modified.map((f) => ` - ${f}`).join("\n")}`;
|
|
61
92
|
}
|
|
62
93
|
if (report.missing.length > 0) {
|
|
63
94
|
text += `\n\nMissing files:\n${report.missing.map((f) => ` - ${f}`).join("\n")}`;
|
|
64
95
|
}
|
|
96
|
+
if (anchorLostCount > 0) {
|
|
97
|
+
text += `\n\n⚠ Files with lost anchors — refresh_file_snapshot cannot fix this:\n${report.anchors_lost.map((f) => ` - ${f}`).join("\n")}`;
|
|
98
|
+
text += `\n → The tracked section(s) could not be located in the current file content (anchors were likely modified by a recent commit).`;
|
|
99
|
+
text += `\n → Required action: call update_doc with the new line numbers to re-establish section tracking.`;
|
|
100
|
+
}
|
|
65
101
|
return text;
|
|
66
102
|
}
|
|
67
103
|
export async function checkFreshness(storageEngine, args) {
|
|
@@ -96,6 +132,7 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
96
132
|
const details = [
|
|
97
133
|
...report.modified.map((f) => ` • modified: ${f}`),
|
|
98
134
|
...report.missing.map((f) => ` • missing: ${f}`),
|
|
135
|
+
...(report.anchors_lost ?? []).map((f) => ` • ⚠ anchors lost (needs update_doc): ${f}`),
|
|
99
136
|
].join("\n");
|
|
100
137
|
stale.push(` - **${doc.title}** [${doc.id}]\n${details}`);
|
|
101
138
|
}
|
|
@@ -136,6 +173,7 @@ export async function checkFreshness(storageEngine, args) {
|
|
|
136
173
|
const details = [
|
|
137
174
|
...report.modified.map((f) => ` • modified: ${f}`),
|
|
138
175
|
...report.missing.map((f) => ` • missing: ${f}`),
|
|
176
|
+
...(report.anchors_lost ?? []).map((f) => ` • ⚠ anchors lost (needs update_doc): ${f}`),
|
|
139
177
|
].join("\n");
|
|
140
178
|
stale.push(` - **${doc.title}** [${doc.id}]\n${details}`);
|
|
141
179
|
}
|
|
@@ -2,6 +2,7 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { CreateDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
4
4
|
import { computeFileHash } from "./check-freshness.js";
|
|
5
|
+
import { snapshotSection } from "../core/section-tracker.js";
|
|
5
6
|
function slugify(title) {
|
|
6
7
|
return title
|
|
7
8
|
.normalize("NFD")
|
|
@@ -10,17 +11,24 @@ function slugify(title) {
|
|
|
10
11
|
.replace(/[^a-z0-9]+/g, "-")
|
|
11
12
|
.replace(/^-|-$/g, "");
|
|
12
13
|
}
|
|
13
|
-
function snapshotTrackedFiles(
|
|
14
|
+
function snapshotTrackedFiles(inputs) {
|
|
14
15
|
const tracked = [];
|
|
15
|
-
for (const
|
|
16
|
+
for (const input of inputs) {
|
|
17
|
+
const fp = typeof input === "string" ? input : input.path;
|
|
16
18
|
const absolutePath = path.resolve(process.cwd(), fp);
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
const mtime = fs.existsSync(absolutePath)
|
|
20
|
+
? fs.statSync(absolutePath).mtime.toISOString()
|
|
21
|
+
: new Date().toISOString();
|
|
22
|
+
if (typeof input !== "string" && input.lines.length > 0 && fs.existsSync(absolutePath)) {
|
|
23
|
+
const sections = input.lines
|
|
24
|
+
.filter(({ start, end }) => start <= end)
|
|
25
|
+
.map(({ start, end }) => snapshotSection(absolutePath, start, end));
|
|
26
|
+
tracked.push({ path: absolutePath, lastModifiedAt: mtime, sections });
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
|
|
30
|
+
tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
|
|
22
31
|
}
|
|
23
|
-
tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
|
|
24
32
|
}
|
|
25
33
|
return tracked;
|
|
26
34
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GetDocMetadataArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
2
2
|
import { computeFreshness } from "./check-freshness.js";
|
|
3
|
+
import { findAndHashSection } from "../core/section-tracker.js";
|
|
3
4
|
export async function getDocMetadata(storageEngine, args) {
|
|
4
5
|
try {
|
|
5
6
|
const validated = GetDocMetadataArgsSchema.parse(args);
|
|
@@ -24,9 +25,20 @@ export async function getDocMetadata(storageEngine, args) {
|
|
|
24
25
|
text += `**Freshness:** ${report.status}\n`;
|
|
25
26
|
if (doc.tracked_files.length > 0) {
|
|
26
27
|
text += `\nTracked files (${doc.tracked_files.length} total):\n`;
|
|
27
|
-
text += doc.tracked_files
|
|
28
|
-
|
|
29
|
-
.
|
|
28
|
+
text += doc.tracked_files.map((f) => {
|
|
29
|
+
let entry = ` ${fileStatusIcon(f.path)} — ${f.path}\n Snapshot: ${f.lastModifiedAt}`;
|
|
30
|
+
if (f.sections && f.sections.length > 0) {
|
|
31
|
+
const sectionLines = f.sections.map((section, idx) => {
|
|
32
|
+
const result = findAndHashSection(f.path, section);
|
|
33
|
+
const location = result.found
|
|
34
|
+
? `lines ${result.lines.start}–${result.lines.end}`
|
|
35
|
+
: "section introuvable (ancres modifiées)";
|
|
36
|
+
return ` - Section ${idx + 1}: ${location}`;
|
|
37
|
+
});
|
|
38
|
+
entry += `\n Sections (position actuelle):\n${sectionLines.join("\n")}`;
|
|
39
|
+
}
|
|
40
|
+
return entry;
|
|
41
|
+
}).join("\n");
|
|
30
42
|
}
|
|
31
43
|
else {
|
|
32
44
|
text += "\nNo tracked files.";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { GetDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
2
2
|
import { computeFreshness } from "./check-freshness.js";
|
|
3
|
+
import { findAndHashSection } from "../core/section-tracker.js";
|
|
3
4
|
export async function getDoc(storageEngine, args) {
|
|
4
5
|
try {
|
|
5
6
|
const validated = GetDocArgsSchema.parse(args);
|
|
@@ -9,7 +10,19 @@ export async function getDoc(storageEngine, args) {
|
|
|
9
10
|
}
|
|
10
11
|
const { doc, project: resolvedProject } = resolved;
|
|
11
12
|
const trackedFilesText = doc.tracked_files.length > 0
|
|
12
|
-
? doc.tracked_files.map((f) =>
|
|
13
|
+
? doc.tracked_files.map((f) => {
|
|
14
|
+
if (f.sections && f.sections.length > 0) {
|
|
15
|
+
const sectionLines = f.sections.map((section, idx) => {
|
|
16
|
+
const result = findAndHashSection(f.path, section);
|
|
17
|
+
const location = result.found
|
|
18
|
+
? `lines ${result.lines.start}–${result.lines.end}`
|
|
19
|
+
: "section introuvable";
|
|
20
|
+
return ` - Section ${idx + 1}: ${location}`;
|
|
21
|
+
});
|
|
22
|
+
return ` - ${f.path} (snapshot: ${f.lastModifiedAt})\n${sectionLines.join("\n")}`;
|
|
23
|
+
}
|
|
24
|
+
return ` - ${f.path} (snapshot: ${f.lastModifiedAt})`;
|
|
25
|
+
}).join("\n")
|
|
13
26
|
: " (none)";
|
|
14
27
|
const freshnessReport = computeFreshness(doc.tracked_files);
|
|
15
28
|
const stalenessBanner = freshnessReport.status === "stale"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from "fs";
|
|
2
2
|
import { RefreshFileSnapshotArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
3
3
|
import { computeFileHash } from "./check-freshness.js";
|
|
4
|
+
import { relocateAndRefreshSection } from "../core/section-tracker.js";
|
|
4
5
|
export async function refreshFileSnapshot(storageEngine, historyStore, args) {
|
|
5
6
|
try {
|
|
6
7
|
const validated = RefreshFileSnapshotArgsSchema.parse(args);
|
|
@@ -15,6 +16,7 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
|
|
|
15
16
|
const refreshed = [];
|
|
16
17
|
const missing = [];
|
|
17
18
|
const skipped = [];
|
|
19
|
+
const anchorsLost = [];
|
|
18
20
|
for (const tracked of doc.tracked_files) {
|
|
19
21
|
if (validated.file_paths && !validated.file_paths.includes(tracked.path)) {
|
|
20
22
|
skipped.push(tracked.path);
|
|
@@ -25,16 +27,39 @@ export async function refreshFileSnapshot(storageEngine, historyStore, args) {
|
|
|
25
27
|
continue;
|
|
26
28
|
}
|
|
27
29
|
tracked.lastModifiedAt = fs.statSync(tracked.path).mtime.toISOString();
|
|
28
|
-
tracked.
|
|
29
|
-
|
|
30
|
+
if (tracked.sections && tracked.sections.length > 0) {
|
|
31
|
+
let hasLostAnchors = false;
|
|
32
|
+
const refreshedSections = tracked.sections.map((section) => {
|
|
33
|
+
const result = relocateAndRefreshSection(tracked.path, section);
|
|
34
|
+
if (!result.found)
|
|
35
|
+
hasLostAnchors = true;
|
|
36
|
+
return result.found ? result.refreshed : section;
|
|
37
|
+
});
|
|
38
|
+
tracked.sections = refreshedSections;
|
|
39
|
+
if (hasLostAnchors) {
|
|
40
|
+
anchorsLost.push(tracked.path);
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
refreshed.push(tracked.path);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
tracked.contentHash = computeFileHash(tracked.path);
|
|
48
|
+
refreshed.push(tracked.path);
|
|
49
|
+
}
|
|
30
50
|
}
|
|
31
51
|
await storageEngine.saveDoc(doc, resolvedProject);
|
|
32
52
|
historyStore.record(resolvedProject, doc, "snapshot");
|
|
33
|
-
let section = `**${doc.title}** [${docPath}]: refreshed ${refreshed.length}, missing ${missing.length}, skipped ${skipped.length}`;
|
|
53
|
+
let section = `**${doc.title}** [${docPath}]: refreshed ${refreshed.length}, anchors lost ${anchorsLost.length}, missing ${missing.length}, skipped ${skipped.length}`;
|
|
34
54
|
if (refreshed.length > 0)
|
|
35
55
|
section += `\n${refreshed.map((f) => ` ✓ ${f}`).join("\n")}`;
|
|
36
56
|
if (missing.length > 0)
|
|
37
|
-
section += `\n${missing.map((f) => ` ✗ ${f}`).join("\n")}`;
|
|
57
|
+
section += `\n${missing.map((f) => ` ✗ missing: ${f}`).join("\n")}`;
|
|
58
|
+
if (anchorsLost.length > 0) {
|
|
59
|
+
section += `\n${anchorsLost.map((f) => ` ⚠ anchors lost: ${f}`).join("\n")}`;
|
|
60
|
+
section += `\n → refresh_file_snapshot cannot fix this. The tracked section(s) could not be located in the current file content.`;
|
|
61
|
+
section += `\n → Required action: call update_doc with the new line numbers to re-establish section tracking.`;
|
|
62
|
+
}
|
|
38
63
|
docSections.push(section);
|
|
39
64
|
}
|
|
40
65
|
return { content: [{ type: "text", text: docSections.join("\n\n") }] };
|
|
@@ -2,17 +2,25 @@ import fs from "fs";
|
|
|
2
2
|
import path from "path";
|
|
3
3
|
import { UpdateDocArgsSchema } from "../../shared/schemas/doc.schema.js";
|
|
4
4
|
import { computeFileHash } from "./check-freshness.js";
|
|
5
|
-
|
|
5
|
+
import { snapshotSection } from "../core/section-tracker.js";
|
|
6
|
+
function snapshotTrackedFiles(inputs) {
|
|
6
7
|
const tracked = [];
|
|
7
|
-
for (const
|
|
8
|
+
for (const input of inputs) {
|
|
9
|
+
const fp = typeof input === "string" ? input : input.path;
|
|
8
10
|
const absolutePath = path.resolve(process.cwd(), fp);
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
const mtime = fs.existsSync(absolutePath)
|
|
12
|
+
? fs.statSync(absolutePath).mtime.toISOString()
|
|
13
|
+
: new Date().toISOString();
|
|
14
|
+
if (typeof input !== "string" && input.lines.length > 0 && fs.existsSync(absolutePath)) {
|
|
15
|
+
const sections = input.lines
|
|
16
|
+
.filter(({ start, end }) => start <= end)
|
|
17
|
+
.map(({ start, end }) => snapshotSection(absolutePath, start, end));
|
|
18
|
+
tracked.push({ path: absolutePath, lastModifiedAt: mtime, sections });
|
|
19
|
+
}
|
|
20
|
+
else {
|
|
21
|
+
const contentHash = fs.existsSync(absolutePath) ? computeFileHash(absolutePath) : undefined;
|
|
22
|
+
tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
|
|
14
23
|
}
|
|
15
|
-
tracked.push({ path: absolutePath, lastModifiedAt: mtime, ...(contentHash !== undefined && { contentHash }) });
|
|
16
24
|
}
|
|
17
25
|
return tracked;
|
|
18
26
|
}
|
package/dist/src/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const APP_VERSION = "1.0.
|
|
1
|
+
export declare const APP_VERSION = "1.0.36";
|
package/dist/src/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Auto-generated version file - do not edit manually
|
|
2
|
-
export const APP_VERSION = '1.0.
|
|
2
|
+
export const APP_VERSION = '1.0.36';
|