Skip to content

Commit 0186174

Browse files
mcp-tool-shopclaude
andcommitted
feat(memories): diagnostics channel + index health summary + dedupe (FT-MR3/4/10)
Structured diagnostics[] ({severity,code,message,refPath?,line?,hint?}) on the analysis result, populated where missing/orphan/unresolved are recorded; flat missingFiles/orphanFiles kept as derived views. cmdIndex now prints a 'N missing / orphan / dup / id-too-long' health summary from validateMemory. Hoisted the 3 copy-pasted resolveRefPath into src/paths.ts (behavior-preserving). All exported from the barrel for uniform rendering under the unified CLI. Tests: memories 54->65. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 33bab27 commit 0186174

9 files changed

Lines changed: 378 additions & 41 deletions

File tree

packages/memories/src/analyze.ts

Lines changed: 41 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,19 +13,8 @@ import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
1313
import { join, dirname, relative, extname, basename, resolve } from "node:path";
1414
import { estimateTokens } from "@mcptoolshop/ai-loadout";
1515
import { parseMemoryMd } from "./parser.js";
16-
import type { MemoryAnalysis, MemoryRef } from "./types.js";
17-
18-
/**
19-
* Try to resolve a reference path against multiple base directories.
20-
* Returns the first path that exists, or null.
21-
*/
22-
function resolveRefPath(refPath: string, ...baseDirs: string[]): string | null {
23-
for (const base of baseDirs) {
24-
const full = join(base, refPath);
25-
if (existsSync(full)) return full;
26-
}
27-
return null;
28-
}
16+
import { resolveRefPath } from "./paths.js";
17+
import type { MemoryAnalysis, MemoryRef, Diagnostic } from "./types.js";
2918

3019
/**
3120
* Analyze a MEMORY.md file and its referenced topic files.
@@ -47,6 +36,12 @@ export function analyzeMemoryMd(filePath: string): MemoryAnalysis {
4736

4837
// Check which referenced files exist
4938
// Try: relative to MEMORY.md, then relative to parent dir
39+
//
40+
// FT-MR3: every missing/orphan signal is recorded as a structured Diagnostic
41+
// here, at the point of detection — the flat string[] arrays below are derived
42+
// views kept for back-compat. Library consumers read `diagnostics`; the CLI
43+
// renders it uniformly. Nothing goes to stderr.
44+
const diagnostics: Diagnostic[] = [];
5045
const missingFiles: string[] = [];
5146
let topicTokens = 0;
5247

@@ -58,9 +53,25 @@ export function analyzeMemoryMd(filePath: string): MemoryAnalysis {
5853
topicTokens += estimateTokens(topicContent);
5954
} catch {
6055
missingFiles.push(ref.path);
56+
diagnostics.push({
57+
severity: "error",
58+
code: "MISSING_TOPIC_FILE",
59+
message: `Referenced topic file not found: ${ref.path}`,
60+
refPath: ref.path,
61+
line: ref.line,
62+
hint: "Create the file or remove the reference from MEMORY.md",
63+
});
6164
}
6265
} else {
6366
missingFiles.push(ref.path);
67+
diagnostics.push({
68+
severity: "error",
69+
code: "MISSING_TOPIC_FILE",
70+
message: `Referenced topic file not found: ${ref.path}`,
71+
refPath: ref.path,
72+
line: ref.line,
73+
hint: "Create the file or remove the reference from MEMORY.md",
74+
});
6475
}
6576
}
6677

@@ -85,11 +96,12 @@ export function analyzeMemoryMd(filePath: string): MemoryAnalysis {
8596
if (stat.isFile() && extname(entry) === ".md" && entry !== "MEMORY.md") {
8697
if (!referencedBasenames.has(entry)) {
8798
orphanFiles.push(entry);
99+
diagnostics.push(orphanDiagnostic(entry));
88100
}
89101
}
90102
// Also scan subdirectories
91103
if (stat.isDirectory()) {
92-
scanDir(fullPath, fileDir, referencedBasenames, orphanFiles);
104+
scanDir(fullPath, fileDir, referencedBasenames, orphanFiles, diagnostics);
93105
}
94106
} catch {
95107
// Skip entries we can't stat (permission denied, broken symlinks)
@@ -106,12 +118,24 @@ export function analyzeMemoryMd(filePath: string): MemoryAnalysis {
106118
refs,
107119
orphanFiles,
108120
missingFiles,
121+
diagnostics,
109122
totalTokens: inlineTokens + topicTokens,
110123
inlineTokens,
111124
topicTokens,
112125
};
113126
}
114127

128+
/** FT-MR3: build the structured Diagnostic for an orphan topic file. */
129+
function orphanDiagnostic(path: string): Diagnostic {
130+
return {
131+
severity: "warning",
132+
code: "ORPHAN_TOPIC_FILE",
133+
message: `Topic file not referenced in MEMORY.md: ${path}`,
134+
refPath: path,
135+
hint: "Add a reference in MEMORY.md or delete the file",
136+
};
137+
}
138+
115139
/**
116140
* Recursively scan a directory for .md files not in the referenced set.
117141
*/
@@ -120,6 +144,7 @@ function scanDir(
120144
baseDir: string,
121145
referenced: Set<string>,
122146
orphans: string[],
147+
diagnostics: Diagnostic[],
123148
): void {
124149
let entries: string[];
125150
try {
@@ -134,11 +159,12 @@ function scanDir(
134159
const stat = statSync(fullPath);
135160

136161
if (stat.isDirectory()) {
137-
scanDir(fullPath, baseDir, referenced, orphans);
162+
scanDir(fullPath, baseDir, referenced, orphans, diagnostics);
138163
} else if (extname(entry) === ".md") {
139164
const relPath = relative(baseDir, fullPath).replace(/\\/g, "/");
140165
if (!referenced.has(relPath) && !referenced.has(basename(relPath))) {
141166
orphans.push(relPath);
167+
diagnostics.push(orphanDiagnostic(relPath));
142168
}
143169
}
144170
} catch {

packages/memories/src/cli.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,27 @@ import { analyzeMemoryMd } from "./analyze.js";
1717
import { generateIndex } from "./index-gen.js";
1818
import { validateMemory, validateMemoryIndex } from "./validate.js";
1919
import { generateStats, formatStats } from "./stats.js";
20+
import type { ValidationIssue } from "./types.js";
21+
22+
/**
23+
* FT-MR4: roll up the validateMemory issue list into the four headline counts
24+
* the `index` health summary and `validate` both surface. Centralizing this
25+
* keeps the `index` summary byte-identical to what `validate` reports in
26+
* detail, so the numbers can never drift between the two commands.
27+
*/
28+
function healthCounts(issues: ValidationIssue[]): {
29+
missing: number;
30+
orphan: number;
31+
duplicate: number;
32+
idTooLong: number;
33+
} {
34+
return {
35+
missing: issues.filter((i) => i.code === "MISSING_TOPIC_FILE").length,
36+
orphan: issues.filter((i) => i.code === "ORPHAN_TOPIC_FILE").length,
37+
duplicate: issues.filter((i) => i.code === "DUPLICATE_REF").length,
38+
idTooLong: issues.filter((i) => i.code === "ID_TOO_LONG").length,
39+
};
40+
}
2041

2142
// ── Colors ────────────────────────────────────────────────────
2243
const BOLD = "\x1b[1m";
@@ -223,6 +244,18 @@ async function cmdIndex(args: string[]) {
223244
warn(`${n} unresolved ref${n === 1 ? "" : "s"} skipped — run \`validate\` for detail`);
224245
}
225246

247+
// FT-MR4: run the richer validateMemory(analysis) rollup and print a one-line
248+
// health summary so `index` surfaces the same counts `validate` computes.
249+
// Previously `index` only printed validateMemoryIndex issues if non-empty and
250+
// never showed the missing/orphan/duplicate/id-too-long picture — pairing it
251+
// with the diagnostics channel here means a single `index` run tells the user
252+
// whether the memory store is healthy, pointing them at `validate` for detail.
253+
const memoryIssues = validateMemory(analysis);
254+
const c = healthCounts(memoryIssues);
255+
info(
256+
`${c.missing} missing · ${c.orphan} orphan · ${c.duplicate} duplicate · ${c.idTooLong} id-too-long — run \`validate\` for detail`,
257+
);
258+
226259
// Validate
227260
const issues = validateMemoryIndex(index);
228261
if (issues.length > 0) {

packages/memories/src/index-gen.ts

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,14 @@
55
* from the parsed memory references and their topic files.
66
*/
77

8-
import { readFileSync, existsSync } from "node:fs";
9-
import { join, dirname, resolve } from "node:path";
8+
import { readFileSync } from "node:fs";
9+
import { dirname, resolve } from "node:path";
1010
import { estimateTokens, parseFrontmatter } from "@mcptoolshop/ai-loadout";
1111
import type { LoadoutEntry, Budget, Frontmatter } from "@mcptoolshop/ai-loadout";
1212
import { DEFAULT_TRIGGERS } from "@mcptoolshop/ai-loadout";
1313
import type { MemoryAnalysis, MemoryIndex, MemoryRef } from "./types.js";
1414
import { extractKeywords } from "./analyze.js";
15-
16-
function resolveRefPath(refPath: string, ...baseDirs: string[]): string | null {
17-
for (const base of baseDirs) {
18-
const full = join(base, refPath);
19-
if (existsSync(full)) return full;
20-
}
21-
return null;
22-
}
15+
import { resolveRefPath } from "./paths.js";
2316

2417
/**
2518
* Generate a memory dispatch index from analysis results.
@@ -51,9 +44,25 @@ export function generateIndex(
5144
// unresolved path already lands in analysis.missingFiles; that is the
5245
// observability channel. The CLI (cmdIndex) reads missingFiles and prints
5346
// a one-line summary; the library stays quiet.
47+
//
48+
// FT-MR3: also record the structured diagnostic so SDK callers get
49+
// severity + code + line + hint, not just a bare string. missingFiles
50+
// stays a derived view kept in lockstep for back-compat.
5451
if (!analysis.missingFiles.includes(ref.path)) {
5552
analysis.missingFiles.push(ref.path);
5653
}
54+
if (!analysis.diagnostics.some(
55+
(d) => d.code === "UNRESOLVED_REF" && d.refPath === ref.path,
56+
)) {
57+
analysis.diagnostics.push({
58+
severity: "error",
59+
code: "UNRESOLVED_REF",
60+
message: `Reference could not be resolved to a file on disk: ${ref.path}`,
61+
refPath: ref.path,
62+
line: ref.line,
63+
hint: "Create the file, fix the path, or remove the reference from MEMORY.md",
64+
});
65+
}
5766
continue;
5867
}
5968

packages/memories/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type {
44
MemoryRef,
55
MemoryAnalysis,
66
MemoryIndex,
7+
Diagnostic,
78
} from "./types.js";
89

910
// Re-export kernel types
@@ -34,6 +35,12 @@ export { generateIndex } from "./index-gen.js";
3435
// ── Validator ─────────────────────────────────────────────────
3536
export { validateMemory, validateMemoryIndex } from "./validate.js";
3637

38+
// ── Path resolution ───────────────────────────────────────────
39+
// FT-MR10: the single shared ref-path resolver, so the unified loadout-os CLI
40+
// (and any SDK consumer) resolves topic refs identically to analyze/index-gen/
41+
// validate.
42+
export { resolveRefPath } from "./paths.js";
43+
3744
// ── Stats ─────────────────────────────────────────────────────
3845
export { generateStats, formatStats } from "./stats.js";
3946
export type { StatsReport } from "./stats.js";

packages/memories/src/paths.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Shared path-resolution helper for claude-memories.
3+
*
4+
* FT-MR10: the same `resolveRefPath` logic was copy-pasted in analyze.ts,
5+
* index-gen.ts, and (a 2-arg variant) validate.ts. A single implementation
6+
* here guarantees analyze / index-gen / validate — and the unified loadout-os
7+
* CLI — all resolve a referenced topic path identically.
8+
*
9+
* Strategy (unchanged, behavior-preserving): try each base directory in order,
10+
* joining the ref path onto it, and return the first that exists on disk;
11+
* otherwise null. Callers pass MEMORY.md's own directory first, then its
12+
* parent (for refs written project-root-relative like "memory/foo.md" when
13+
* MEMORY.md itself lives inside memory/).
14+
*/
15+
16+
import { existsSync } from "node:fs";
17+
import { join } from "node:path";
18+
19+
/**
20+
* Resolve a reference path against one or more base directories.
21+
* Returns the first `join(base, refPath)` that exists, or null.
22+
*/
23+
export function resolveRefPath(refPath: string, ...baseDirs: string[]): string | null {
24+
for (const base of baseDirs) {
25+
const full = join(base, refPath);
26+
if (existsSync(full)) return full;
27+
}
28+
return null;
29+
}

packages/memories/src/tests/cli.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,3 +145,62 @@ describe("unresolved-ref summary (MEM-B03)", () => {
145145
}
146146
});
147147
});
148+
149+
// ── FT-MR4: `index` surfaces the health-summary counts ────────────────
150+
describe("index health summary (FT-MR4)", () => {
151+
it("prints the missing · orphan · duplicate · id-too-long one-liner", () => {
152+
const dir = mkdtempSync(join(tmpdir(), "mem-health-"));
153+
try {
154+
const out = join(dir, "index.json");
155+
// The fixture has missing refs (nullout, xrpl-lab) and an over-long id.
156+
const res = runCli(["index", join(FIXTURES, "MEMORY.md"), "--out", out]);
157+
assert.equal(res.status, 0);
158+
assert.match(res.stdout, /\d+ missing · \d+ orphan · \d+ duplicate · \d+ id-too-long/);
159+
assert.match(res.stdout, /run `validate` for detail/);
160+
} finally {
161+
rmSync(dir, { recursive: true, force: true });
162+
}
163+
});
164+
165+
it("the index summary counts match what validate reports", () => {
166+
const dir = mkdtempSync(join(tmpdir(), "mem-health-match-"));
167+
try {
168+
const out = join(dir, "index.json");
169+
const idx = runCli(["index", join(FIXTURES, "MEMORY.md"), "--out", out]);
170+
const val = runCli(["validate", join(FIXTURES, "MEMORY.md")]);
171+
172+
// Pull the counts the index summary printed.
173+
const m = idx.stdout.match(
174+
/(\d+) missing · (\d+) orphan · (\d+) duplicate · (\d+) id-too-long/,
175+
);
176+
assert.ok(m, "index must print the four headline counts");
177+
const [, missing, orphan, duplicate, idTooLong] = m.map(Number);
178+
179+
// validate prints one line per issue; count occurrences of each code.
180+
const count = (code: string) =>
181+
(val.stdout.match(new RegExp(`\\[${code}\\]`, "g")) ?? []).length;
182+
assert.equal(missing, count("MISSING_TOPIC_FILE"), "missing count agrees with validate");
183+
assert.equal(orphan, count("ORPHAN_TOPIC_FILE"), "orphan count agrees with validate");
184+
assert.equal(duplicate, count("DUPLICATE_REF"), "duplicate count agrees with validate");
185+
assert.equal(idTooLong, count("ID_TOO_LONG"), "id-too-long count agrees with validate");
186+
} finally {
187+
rmSync(dir, { recursive: true, force: true });
188+
}
189+
});
190+
191+
it("a clean MEMORY.md reports all-zero counts", () => {
192+
const dir = mkdtempSync(join(tmpdir(), "mem-health-clean-"));
193+
try {
194+
writeFileSync(join(dir, "topic.md"), "# Topic\nbody\n");
195+
writeFileSync(
196+
join(dir, "MEMORY.md"),
197+
"# Mem\n\n## Active\n\nTopic — a real topic → `topic.md`\n",
198+
);
199+
const res = runCli(["index", join(dir, "MEMORY.md"), "--out", join(dir, "out.json")]);
200+
assert.equal(res.status, 0);
201+
assert.match(res.stdout, /0 missing · 0 orphan · 0 duplicate · 0 id-too-long/);
202+
} finally {
203+
rmSync(dir, { recursive: true, force: true });
204+
}
205+
});
206+
});

0 commit comments

Comments
 (0)