Skip to content

Commit cea0c12

Browse files
author
cortex-agent
committed
fix(memory): 加固记忆完整性校验
修复组合 fix plan 的行号漂移,补齐 schema 值校验、绝对定位与 project 参数,并让 doctor 和 knowledge-lint 共用公开校验入口。 Refs #2
1 parent 48066fb commit cea0c12

10 files changed

Lines changed: 471 additions & 63 deletions

File tree

lib/commands/doctor.js

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -304,8 +304,10 @@ async function doctor(ctx) {
304304
} else {
305305
for (const issue of result.issues) {
306306
const locator = issue.line
307-
? `${issue.path || `.agent/memory/MEMORY.md`}:${issue.line}`
308-
: issue.path || `.agent/memory/${issue.type}`;
307+
? `${result.parsed.indexPath}:${issue.line}`
308+
: issue.path
309+
? path.join(result.memoryRoot, issue.path)
310+
: path.join(result.memoryRoot, issue.type);
309311
console.log(` - ${issue.kind}: ${locator} ${issue.detail}`);
310312
}
311313
const s = result.summary;
@@ -321,10 +323,21 @@ async function doctor(ctx) {
321323
console.log(
322324
` - ${isZh ? "修复" : "remedy"}: ${
323325
isZh
324-
? "运行 `cortex-agent memory-validate --fix --yes` 自动修复 drift/orphan/duplicate"
325-
: "run `cortex-agent memory-validate --fix --yes` to auto-fix drift/orphan/duplicate"
326+
? "运行 `cortex-agent memory validate --fix --yes` 自动修复 drift/orphan/duplicate"
327+
: "run `cortex-agent memory validate --fix --yes` to auto-fix drift/orphan/duplicate"
326328
}`
327329
);
330+
if (options.fix) {
331+
const plan = memoryValidate.buildFixPlan(result.parsed, result.issues, result.memoryRoot);
332+
const applied = memoryValidate.applyFixPlan(result.parsed, plan, { confirm: true });
333+
console.log(` - ${isZh ? "已修复" : "fixed"}: ${applied.applied} ${isZh ? "项安全修改" : "safe edit(s)"}`);
334+
const after = memoryValidate.validateMemory({ projectRoot: cwd });
335+
if (after.issues.length === 0) {
336+
console.log(` - ${isZh ? "修复后状态" : "status after fix"}: ${isZh ? "正常" : "ok"}`);
337+
} else {
338+
console.log(` - ${isZh ? "修复后剩余" : "remaining after fix"}: ${after.issues.length}`);
339+
}
340+
}
328341
}
329342
} catch (err) {
330343
console.log(`\n[${isZh ? "memory-integrity" : "memory-integrity"}]`);

lib/memory-validate.js

Lines changed: 118 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,28 @@ const REQUIRED_FRONTMATTER_KEYS = Object.freeze([
4646
"created",
4747
"tags",
4848
]);
49+
const ALLOWED_FRONTMATTER_KEYS = new Set([
50+
...REQUIRED_FRONTMATTER_KEYS,
51+
"expires",
52+
"metadata",
53+
"source",
54+
"related",
55+
]);
56+
const SLUG_PATTERN = /^[a-z0-9_][a-z0-9_-]*$/;
4957

5058
// ─── Default path resolution ──────────────────────────────────────────────────
5159

5260
function defaultMemoryRoot(projectRoot) {
5361
return path.join(projectRoot, ".agent", "memory");
5462
}
5563

64+
function resolveMemoryPath(memoryRoot, relativePath) {
65+
const root = path.resolve(memoryRoot);
66+
const resolved = path.resolve(root, relativePath);
67+
if (resolved !== root && !resolved.startsWith(root + path.sep)) return null;
68+
return resolved;
69+
}
70+
5671
// ─── MEMORY.md parser (V-1 / V-2 / V-5 sources) ──────────────────────────────
5772

5873
function parseMemoryIndex(memoryRoot) {
@@ -114,11 +129,19 @@ function parseFrontmatter(text) {
114129
if (!m) return null;
115130
const result = {};
116131
const lines = m[1].split(/\r?\n/);
132+
let currentKey = null;
117133
for (const line of lines) {
118134
if (!line.trim() || line.trim().startsWith("#")) continue;
135+
const listItem = line.match(/^\s+-\s+(.+)$/);
136+
if (listItem && currentKey) {
137+
if (!Array.isArray(result[currentKey])) result[currentKey] = [];
138+
result[currentKey].push(listItem[1].replace(/^["']|["']$/g, "").trim());
139+
continue;
140+
}
119141
const kv = line.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
120142
if (!kv) continue;
121143
const key = kv[1];
144+
currentKey = key;
122145
let raw = kv[2];
123146
// Strip trailing inline comment after a quoted value.
124147
if (
@@ -128,14 +151,66 @@ function parseFrontmatter(text) {
128151
raw = raw.slice(1, -1);
129152
} else if (raw.startsWith("[") && raw.endsWith("]")) {
130153
raw = raw.slice(1, -1).split(",").map((s) => s.trim()).filter(Boolean);
131-
} else if (raw === "true" || raw === "false" || raw === "null" || raw === "") {
132-
// Keep as-is.
154+
} else if (raw === "") {
155+
raw = key === "metadata" ? {} : [];
156+
} else if (raw === "true" || raw === "false" || raw === "null") {
157+
// Keep simple literals as strings; the schema validator rejects wrong types.
133158
}
134159
result[key] = raw;
135160
}
136161
return result;
137162
}
138163

164+
function isValidDate(value) {
165+
if (typeof value !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
166+
const [year, month, day] = value.split("-").map(Number);
167+
const date = new Date(Date.UTC(year, month - 1, day));
168+
return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day;
169+
}
170+
171+
function validateFrontmatterValues(frontmatter, expectedType) {
172+
const errors = [];
173+
for (const key of Object.keys(frontmatter)) {
174+
if (!ALLOWED_FRONTMATTER_KEYS.has(key)) errors.push(`unknown frontmatter key: ${key}`);
175+
}
176+
if (typeof frontmatter.name !== "string" || !SLUG_PATTERN.test(frontmatter.name)) {
177+
errors.push("name must match ^[a-z0-9_][a-z0-9_-]*$");
178+
} else if (frontmatter.name.length > 64) {
179+
errors.push("name must be at most 64 characters");
180+
}
181+
if (typeof frontmatter.description !== "string") {
182+
errors.push("description must be a string");
183+
} else if (frontmatter.description.length > 200) {
184+
errors.push("description must be at most 200 characters");
185+
}
186+
if (!ALL_TYPES.includes(frontmatter.type)) {
187+
errors.push(`type must be one of: ${ALL_TYPES.join(", ")}`);
188+
} else if (frontmatter.type !== expectedType) {
189+
errors.push(`type must match directory "${expectedType}"`);
190+
}
191+
if (!isValidDate(frontmatter.created)) errors.push("created must be a valid YYYY-MM-DD date");
192+
if (frontmatter.expires !== undefined && frontmatter.expires !== "null" && !isValidDate(frontmatter.expires)) {
193+
errors.push("expires must be null or a valid YYYY-MM-DD date");
194+
}
195+
if (!Array.isArray(frontmatter.tags) || frontmatter.tags.length < 1 || frontmatter.tags.length > 10) {
196+
errors.push("tags must contain 1 to 10 items");
197+
} else {
198+
frontmatter.tags.forEach((tag, index) => {
199+
if (typeof tag !== "string" || !SLUG_PATTERN.test(tag)) {
200+
errors.push(`tags[${index}] must match ^[a-z0-9_][a-z0-9_-]*$`);
201+
}
202+
});
203+
}
204+
if (frontmatter.metadata !== undefined && (typeof frontmatter.metadata !== "object" || Array.isArray(frontmatter.metadata))) {
205+
errors.push("metadata must be an object");
206+
}
207+
if (frontmatter.source !== undefined && typeof frontmatter.source !== "string") errors.push("source must be a string");
208+
if (frontmatter.related !== undefined && (!Array.isArray(frontmatter.related) || frontmatter.related.some((item) => typeof item !== "string"))) {
209+
errors.push("related must be an array of strings");
210+
}
211+
return errors;
212+
}
213+
139214
// ─── Validators ─────────────────────────────────────────────────────────────────
140215

141216
function validateDrift(parsed) {
@@ -161,7 +236,17 @@ function validateMissing(parsed, memoryRoot) {
161236
const section = parsed.sections[type];
162237
if (!section) continue;
163238
for (const item of section.items) {
164-
const fullPath = path.join(memoryRoot, item.path);
239+
const fullPath = resolveMemoryPath(memoryRoot, item.path);
240+
if (!fullPath) {
241+
issues.push({
242+
kind: "missing",
243+
type,
244+
line: item.line,
245+
path: item.path,
246+
detail: `MEMORY.md path ${item.path} escapes the memory root`,
247+
});
248+
continue;
249+
}
165250
let stat = null;
166251
try { stat = fs.statSync(fullPath); } catch (_) {}
167252
const placeholder = path.basename(item.path) === ".gitkeep";
@@ -205,12 +290,15 @@ function validateSchema(memoryRoot) {
205290
continue;
206291
}
207292
const missingKeys = REQUIRED_FRONTMATTER_KEYS.filter((k) => !(k in fm));
208-
if (missingKeys.length > 0) {
293+
const valueErrors = missingKeys.length === 0 ? validateFrontmatterValues(fm, type) : [];
294+
if (missingKeys.length > 0 || valueErrors.length > 0) {
209295
issues.push({
210296
kind: "schema",
211297
type,
212298
path: path.join(type, file),
213-
detail: `topic file is missing frontmatter keys: ${missingKeys.join(", ")}`,
299+
detail: missingKeys.length > 0
300+
? `topic file is missing frontmatter keys: ${missingKeys.join(", ")}`
301+
: `topic file frontmatter is invalid: ${valueErrors.join("; ")}`,
214302
});
215303
}
216304
}
@@ -354,16 +442,22 @@ function buildFixPlan(parsed, issues, memoryRoot) {
354442
// computation sees the *post-fix* item count (otherwise appending N
355443
// orphans to a section with declaredCount=K would still leave drift).
356444
const orphanAppendCount = Object.create(null);
445+
const duplicateRemovalCount = Object.create(null);
357446
for (const issue of issues) {
358-
if (issue.kind !== "orphan") continue;
359-
orphanAppendCount[issue.type] = (orphanAppendCount[issue.type] || 0) + 1;
447+
if (issue.kind === "orphan") {
448+
orphanAppendCount[issue.type] = (orphanAppendCount[issue.type] || 0) + 1;
449+
} else if (issue.kind === "duplicate") {
450+
duplicateRemovalCount[issue.type] = (duplicateRemovalCount[issue.type] || 0) + 1;
451+
}
360452
}
361453

362454
// drift: rewrite section headers
363455
for (const type of ALL_TYPES) {
364456
const section = parsed.sections[type];
365457
if (!section) continue;
366-
const projectedItems = section.items.length + (orphanAppendCount[type] || 0);
458+
const projectedItems = section.items.length
459+
- (duplicateRemovalCount[type] || 0)
460+
+ (orphanAppendCount[type] || 0);
367461
if (section.declaredCount !== projectedItems) {
368462
const newHeader = `## ${type} (${projectedItems}/${section.declaredCap})`;
369463
edits.push({
@@ -415,15 +509,13 @@ function buildFixPlan(parsed, issues, memoryRoot) {
415509
}
416510

417511
// duplicate: drop the duplicate line (keep first occurrence)
418-
const duplicateLines = new Set();
419512
const seen = new Set();
420513
for (const type of ALL_TYPES) {
421514
const section = parsed.sections[type];
422515
if (!section) continue;
423516
seen.clear();
424517
for (const item of section.items) {
425518
if (seen.has(item.path)) {
426-
duplicateLines.add(item.line);
427519
edits.push({
428520
kind: "duplicate",
429521
type,
@@ -461,37 +553,29 @@ function applyFixPlan(parsed, plan, { confirm = false } = {}) {
461553
}
462554
if (!plan || !plan.ok) return { ok: false, applied: 0 };
463555
const lines = parsed.lines.slice();
464-
// Apply drift (replace header)
465-
for (const edit of plan.edits) {
466-
if (edit.kind === "drift") {
467-
lines[edit.line - 1] = edit.after;
468-
}
556+
// Apply every original-line edit in descending order before any insertion,
557+
// so no operation can invalidate another edit's line number.
558+
const lineEdits = plan.edits
559+
.filter((edit) => edit.kind === "drift" || edit.kind === "duplicate")
560+
.sort((a, b) => b.line - a.line);
561+
for (const edit of lineEdits) {
562+
if (edit.kind === "duplicate") lines.splice(edit.line - 1, 1);
563+
else lines[edit.line - 1] = edit.after;
469564
}
470-
// Apply orphan (insert after the section header). Find the next "## "
471-
// boundary for each edit before inserting.
565+
// Insert orphans after line-based edits. Locate the section by its stable
566+
// heading instead of stale source line numbers.
472567
const orphanInsertions = plan.edits
473568
.filter((e) => e.kind === "orphan")
474-
// Process in reverse line order so earlier insertions do not shift
475-
// later line numbers.
476-
.sort((a, b) => b.line - a.line);
569+
.sort((a, b) => ALL_TYPES.indexOf(b.type) - ALL_TYPES.indexOf(a.type));
477570
for (const edit of orphanInsertions) {
478-
const section = parsed.sections[edit.type];
479-
let insertAt = edit.line; // after header line
571+
const headerIndex = lines.findIndex((line) => new RegExp(`^##\\s+${edit.type}\\s*\\(`).test(line));
572+
if (headerIndex === -1) continue;
573+
let insertAt = headerIndex + 1;
480574
while (insertAt < lines.length && !lines[insertAt].match(/^##\s/)) {
481575
insertAt += 1;
482576
}
483577
lines.splice(insertAt, 0, edit.after);
484578
}
485-
// Apply duplicate (remove line). Process in reverse order so deletions
486-
// do not shift earlier line numbers.
487-
const duplicateDeletions = plan.edits
488-
.filter((e) => e.kind === "duplicate")
489-
.sort((a, b) => b.line - a.line);
490-
for (const edit of duplicateDeletions) {
491-
if (edit.line >= 1 && edit.line <= lines.length) {
492-
lines.splice(edit.line - 1, 1);
493-
}
494-
}
495579
const newText = lines.join("\n") + (parsed.text.endsWith("\n") ? "" : "\n");
496580
fs.writeFileSync(parsed.indexPath, newText);
497581
return { ok: true, applied: plan.edits.length, newText };
@@ -504,8 +588,10 @@ module.exports = {
504588
ALL_TYPES,
505589
REQUIRED_FRONTMATTER_KEYS,
506590
defaultMemoryRoot,
591+
resolveMemoryPath,
507592
parseMemoryIndex,
508593
parseFrontmatter,
594+
validateFrontmatterValues,
509595
validateMemory,
510596
buildFixPlan,
511597
applyFixPlan,

0 commit comments

Comments
 (0)