@danielblomma/cortex-mcp 0.4.2 → 0.6.4
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 +64 -16
- package/bin/cortex.mjs +32 -60
- package/package.json +17 -3
- package/scaffold/.context/ontology.cypher +47 -0
- package/scaffold/.githooks/post-commit +14 -0
- package/scaffold/.githooks/post-rewrite +23 -0
- package/scaffold/mcp/package-lock.json +19 -23
- package/scaffold/mcp/package.json +3 -1
- package/scaffold/mcp/src/contextEntities.ts +311 -0
- package/scaffold/mcp/src/defaults.ts +6 -0
- package/scaffold/mcp/src/embed.ts +163 -37
- package/scaffold/mcp/src/frontmatter.ts +39 -0
- package/scaffold/mcp/src/graph.ts +330 -109
- package/scaffold/mcp/src/graphMetrics.ts +12 -0
- package/scaffold/mcp/src/impactPresentation.ts +202 -0
- package/scaffold/mcp/src/impactRanking.ts +237 -0
- package/scaffold/mcp/src/impactResponse.ts +47 -0
- package/scaffold/mcp/src/impactResults.ts +173 -0
- package/scaffold/mcp/src/impactSeed.ts +33 -0
- package/scaffold/mcp/src/impactTraversal.ts +83 -0
- package/scaffold/mcp/src/jsonl.ts +34 -0
- package/scaffold/mcp/src/loadGraph.ts +345 -86
- package/scaffold/mcp/src/paths.ts +24 -2
- package/scaffold/mcp/src/presets.ts +137 -0
- package/scaffold/mcp/src/relatedResponse.ts +30 -0
- package/scaffold/mcp/src/relatedTraversal.ts +101 -0
- package/scaffold/mcp/src/rules.ts +27 -0
- package/scaffold/mcp/src/search.ts +191 -355
- package/scaffold/mcp/src/searchCore.ts +274 -0
- package/scaffold/mcp/src/searchResults.ts +133 -0
- package/scaffold/mcp/src/server.ts +95 -3
- package/scaffold/mcp/src/types.ts +99 -3
- package/scaffold/scripts/context.sh +12 -46
- package/scaffold/scripts/dashboard.mjs +797 -0
- package/scaffold/scripts/dashboard.sh +13 -0
- package/scaffold/scripts/ingest.mjs +2219 -59
- package/scaffold/scripts/install-git-hooks.sh +3 -1
- package/scaffold/scripts/memory-compile.mjs +232 -0
- package/scaffold/scripts/memory-compile.sh +20 -0
- package/scaffold/scripts/memory-lint.mjs +375 -0
- package/scaffold/scripts/memory-lint.sh +20 -0
- package/scaffold/scripts/parsers/config.mjs +178 -0
- package/scaffold/scripts/parsers/cpp.mjs +316 -0
- package/scaffold/scripts/parsers/dotnet/VbNetParser/Program.cs +374 -0
- package/scaffold/scripts/parsers/dotnet/VbNetParser/VbNetParser.csproj +13 -0
- package/scaffold/scripts/parsers/javascript/ast.mjs +61 -0
- package/scaffold/scripts/parsers/javascript/calls.mjs +53 -0
- package/scaffold/scripts/parsers/javascript/chunks.mjs +388 -0
- package/scaffold/scripts/parsers/javascript/imports.mjs +162 -0
- package/scaffold/scripts/parsers/javascript/patterns.mjs +82 -0
- package/scaffold/scripts/parsers/javascript/scope-analysis.mjs +3 -0
- package/scaffold/scripts/parsers/javascript/scope-builder.mjs +305 -0
- package/scaffold/scripts/parsers/javascript/scope-resolver.mjs +82 -0
- package/scaffold/scripts/parsers/javascript.mjs +27 -350
- package/scaffold/scripts/parsers/resources.mjs +166 -0
- package/scaffold/scripts/parsers/sql.mjs +137 -0
- package/scaffold/scripts/parsers/vbnet.mjs +143 -0
- package/scaffold/scripts/status.sh +15 -8
- package/scaffold/scripts/capture-note.sh +0 -55
- package/scaffold/scripts/plan-state-engine.cjs +0 -310
- package/scaffold/scripts/plan-state.sh +0 -71
|
@@ -10,12 +10,14 @@ if [[ ! -d "$HOOKS_DIR" ]]; then
|
|
|
10
10
|
fi
|
|
11
11
|
|
|
12
12
|
chmod +x \
|
|
13
|
+
"$HOOKS_DIR/post-commit" \
|
|
13
14
|
"$HOOKS_DIR/post-merge" \
|
|
14
15
|
"$HOOKS_DIR/post-checkout" \
|
|
16
|
+
"$HOOKS_DIR/post-rewrite" \
|
|
15
17
|
"$HOOKS_DIR/_cortex-update-runner.sh"
|
|
16
18
|
|
|
17
19
|
git -C "$REPO_ROOT" config core.hooksPath .githooks
|
|
18
20
|
|
|
19
21
|
echo "[hooks] installed core.hooksPath=.githooks"
|
|
20
|
-
echo "[hooks] post-merge + post-
|
|
22
|
+
echo "[hooks] post-checkout + post-merge + post-commit + post-rewrite now trigger background cortex update"
|
|
21
23
|
echo "[hooks] logs: .context/hooks/update.log"
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
|
|
10
|
+
? path.resolve(process.env.CORTEX_PROJECT_ROOT)
|
|
11
|
+
: path.resolve(__dirname, "..");
|
|
12
|
+
const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
|
|
13
|
+
const MEMORY_DIR = path.join(CONTEXT_DIR, "memory");
|
|
14
|
+
const RAW_DIR = path.join(MEMORY_DIR, "raw");
|
|
15
|
+
const COMPILED_DIR = path.join(MEMORY_DIR, "compiled");
|
|
16
|
+
|
|
17
|
+
const ALLOWED_TYPES = new Set([
|
|
18
|
+
"decision",
|
|
19
|
+
"gotcha",
|
|
20
|
+
"fix",
|
|
21
|
+
"benchmark",
|
|
22
|
+
"migration-note",
|
|
23
|
+
"note"
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const REQUIRED_FIELDS = ["title", "type", "summary"];
|
|
27
|
+
|
|
28
|
+
// ── ID generation ──────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
function normalizeId(filename) {
|
|
31
|
+
const base = path.basename(filename, path.extname(filename)).toLowerCase();
|
|
32
|
+
return `memory:${base.replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")}`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ── Validation ─────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
function validate(rawPath, fields, body) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
|
|
40
|
+
for (const field of REQUIRED_FIELDS) {
|
|
41
|
+
if (!fields.get(field)) {
|
|
42
|
+
errors.push(`missing required field: ${field}`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const memoryType = fields.get("type") || "";
|
|
47
|
+
if (memoryType && !ALLOWED_TYPES.has(memoryType)) {
|
|
48
|
+
errors.push(`unknown type "${memoryType}" (allowed: ${[...ALLOWED_TYPES].join(", ")})`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!body) {
|
|
52
|
+
errors.push("empty body — compiled articles need explanatory text");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return errors;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Supersession detection ─────────────────────────────────
|
|
59
|
+
|
|
60
|
+
function detectSuperseded(compiledArticles) {
|
|
61
|
+
const byTarget = new Map();
|
|
62
|
+
for (const article of compiledArticles) {
|
|
63
|
+
for (const target of article.appliesTo) {
|
|
64
|
+
const list = byTarget.get(target) ?? [];
|
|
65
|
+
list.push(article);
|
|
66
|
+
byTarget.set(target, list);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const warnings = [];
|
|
71
|
+
for (const [target, articles] of byTarget) {
|
|
72
|
+
if (articles.length > 1) {
|
|
73
|
+
const ids = articles.map((a) => a.id).join(", ");
|
|
74
|
+
warnings.push(`multiple memories for ${target}: ${ids} — consider adding supersedes field`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return warnings;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ── Compiled article serialization ─────────────────────────
|
|
81
|
+
|
|
82
|
+
function serializeCompiled(article) {
|
|
83
|
+
const lines = ["---"];
|
|
84
|
+
lines.push(`id: ${article.id}`);
|
|
85
|
+
lines.push(`title: ${article.title}`);
|
|
86
|
+
lines.push(`type: ${article.type}`);
|
|
87
|
+
lines.push(`summary: ${article.summary}`);
|
|
88
|
+
|
|
89
|
+
if (article.evidence) lines.push(`evidence: ${article.evidence}`);
|
|
90
|
+
if (article.appliesTo.length > 0) lines.push(`applies_to: ${article.appliesTo.join(", ")}`);
|
|
91
|
+
if (article.decisionOrGotcha) lines.push(`decision_or_gotcha: ${article.decisionOrGotcha}`);
|
|
92
|
+
if (article.sources.length > 0) lines.push(`sources: ${article.sources.join(", ")}`);
|
|
93
|
+
if (article.supersedes) lines.push(`supersedes: ${article.supersedes}`);
|
|
94
|
+
lines.push(`freshness: ${article.freshness}`);
|
|
95
|
+
lines.push(`updated_at: ${article.updatedAt}`);
|
|
96
|
+
lines.push(`status: ${article.status}`);
|
|
97
|
+
lines.push(`trust_level: ${article.trustLevel}`);
|
|
98
|
+
if (article.sourceOfTruth) lines.push(`source_of_truth: true`);
|
|
99
|
+
|
|
100
|
+
lines.push("---");
|
|
101
|
+
lines.push("");
|
|
102
|
+
lines.push(article.body);
|
|
103
|
+
lines.push("");
|
|
104
|
+
|
|
105
|
+
return lines.join("\n");
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Main compile pipeline ──────────────────────────────────
|
|
109
|
+
|
|
110
|
+
function compileRawNote(rawPath) {
|
|
111
|
+
const markdown = fs.readFileSync(rawPath, "utf8");
|
|
112
|
+
const stats = fs.statSync(rawPath);
|
|
113
|
+
const { fields, body } = parseFrontmatter(markdown);
|
|
114
|
+
const filename = path.basename(rawPath);
|
|
115
|
+
|
|
116
|
+
const errors = validate(rawPath, fields, body);
|
|
117
|
+
if (errors.length > 0) {
|
|
118
|
+
return { ok: false, rawPath, errors };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const article = {
|
|
122
|
+
id: fields.get("id") || normalizeId(filename),
|
|
123
|
+
title: fields.get("title"),
|
|
124
|
+
type: fields.get("type"),
|
|
125
|
+
summary: fields.get("summary"),
|
|
126
|
+
evidence: fields.get("evidence") || "",
|
|
127
|
+
appliesTo: parseStringList(fields.get("applies_to")),
|
|
128
|
+
decisionOrGotcha: fields.get("decision_or_gotcha") || fields.get("decision") || "",
|
|
129
|
+
sources: parseStringList(fields.get("sources")),
|
|
130
|
+
supersedes: fields.get("supersedes") || "",
|
|
131
|
+
freshness: fields.get("freshness") || "current",
|
|
132
|
+
updatedAt: fields.get("updated_at") || stats.mtime.toISOString(),
|
|
133
|
+
status: fields.get("status") || "active",
|
|
134
|
+
trustLevel: Number(fields.get("trust_level")) || 70,
|
|
135
|
+
sourceOfTruth: fields.get("source_of_truth")?.toLowerCase() === "true",
|
|
136
|
+
body
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
return { ok: true, rawPath, article };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function run() {
|
|
143
|
+
const verbose = process.argv.includes("--verbose");
|
|
144
|
+
const dryRun = process.argv.includes("--dry-run");
|
|
145
|
+
|
|
146
|
+
if (!fs.existsSync(RAW_DIR)) {
|
|
147
|
+
console.log(`[memory-compile] no raw directory at ${RAW_DIR} — nothing to compile`);
|
|
148
|
+
console.log(`[memory-compile] create ${RAW_DIR} and add .md notes to get started`);
|
|
149
|
+
process.exit(0);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const rawFiles = fs.readdirSync(RAW_DIR, { withFileTypes: true })
|
|
153
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
|
|
154
|
+
.map((entry) => entry.name)
|
|
155
|
+
.sort();
|
|
156
|
+
|
|
157
|
+
if (rawFiles.length === 0) {
|
|
158
|
+
console.log("[memory-compile] no raw .md files found — nothing to compile");
|
|
159
|
+
process.exit(0);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log(`[memory-compile] found ${rawFiles.length} raw note(s)`);
|
|
163
|
+
|
|
164
|
+
fs.mkdirSync(COMPILED_DIR, { recursive: true });
|
|
165
|
+
|
|
166
|
+
const results = rawFiles.map((filename) =>
|
|
167
|
+
compileRawNote(path.join(RAW_DIR, filename))
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
const successes = results.filter((r) => r.ok);
|
|
171
|
+
const failures = results.filter((r) => !r.ok);
|
|
172
|
+
|
|
173
|
+
// Report validation failures
|
|
174
|
+
for (const failure of failures) {
|
|
175
|
+
console.log(`[memory-compile] SKIP ${path.basename(failure.rawPath)}:`);
|
|
176
|
+
for (const error of failure.errors) {
|
|
177
|
+
console.log(` - ${error}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Detect supersession warnings
|
|
182
|
+
const articles = successes.map((r) => r.article);
|
|
183
|
+
const supersessionWarnings = detectSuperseded(articles);
|
|
184
|
+
for (const warning of supersessionWarnings) {
|
|
185
|
+
console.log(`[memory-compile] WARN ${warning}`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Write compiled articles
|
|
189
|
+
let written = 0;
|
|
190
|
+
let skipped = 0;
|
|
191
|
+
|
|
192
|
+
for (const { article, rawPath } of successes) {
|
|
193
|
+
const outputFilename = path.basename(rawPath);
|
|
194
|
+
const outputPath = path.join(COMPILED_DIR, outputFilename);
|
|
195
|
+
const compiled = serializeCompiled(article);
|
|
196
|
+
|
|
197
|
+
// Skip if compiled output is identical to existing
|
|
198
|
+
if (fs.existsSync(outputPath)) {
|
|
199
|
+
const existing = fs.readFileSync(outputPath, "utf8");
|
|
200
|
+
if (existing === compiled) {
|
|
201
|
+
if (verbose) console.log(`[memory-compile] unchanged: ${outputFilename}`);
|
|
202
|
+
skipped++;
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (dryRun) {
|
|
208
|
+
console.log(`[memory-compile] would write: ${outputFilename} (${article.id})`);
|
|
209
|
+
if (verbose) console.log(compiled);
|
|
210
|
+
} else {
|
|
211
|
+
fs.writeFileSync(outputPath, compiled, "utf8");
|
|
212
|
+
console.log(`[memory-compile] compiled: ${outputFilename} → ${article.id}`);
|
|
213
|
+
}
|
|
214
|
+
written++;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
console.log("");
|
|
218
|
+
console.log(`[memory-compile] ${dryRun ? "dry-run " : ""}summary:`);
|
|
219
|
+
console.log(` compiled: ${written}`);
|
|
220
|
+
console.log(` skipped (unchanged): ${skipped}`);
|
|
221
|
+
console.log(` failed: ${failures.length}`);
|
|
222
|
+
if (supersessionWarnings.length > 0) {
|
|
223
|
+
console.log(` supersession warnings: ${supersessionWarnings.length}`);
|
|
224
|
+
}
|
|
225
|
+
console.log(` total raw: ${rawFiles.length}`);
|
|
226
|
+
|
|
227
|
+
if (failures.length > 0) {
|
|
228
|
+
process.exit(1);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
run();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Keep in sync with scripts/memory-compile.sh
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
CONTEXT_DIR="$REPO_ROOT/.context"
|
|
7
|
+
|
|
8
|
+
printf "[memory-compile] repo: %s\n" "$REPO_ROOT"
|
|
9
|
+
|
|
10
|
+
if [[ ! -d "$CONTEXT_DIR" ]]; then
|
|
11
|
+
echo "[memory-compile] missing .context/ directory — run cortex init first"
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
16
|
+
echo "[memory-compile] Node.js is required but not found on PATH"
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
node "$REPO_ROOT/scripts/memory-compile.mjs" "$@"
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { parseFrontmatter, parseStringList } from "../mcp/dist/frontmatter.js";
|
|
6
|
+
|
|
7
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
8
|
+
const __dirname = path.dirname(__filename);
|
|
9
|
+
const REPO_ROOT = process.env.CORTEX_PROJECT_ROOT
|
|
10
|
+
? path.resolve(process.env.CORTEX_PROJECT_ROOT)
|
|
11
|
+
: path.resolve(__dirname, "..");
|
|
12
|
+
const CONTEXT_DIR = path.join(REPO_ROOT, ".context");
|
|
13
|
+
const MEMORY_DIR = path.join(CONTEXT_DIR, "memory");
|
|
14
|
+
const COMPILED_DIR = path.join(MEMORY_DIR, "compiled");
|
|
15
|
+
const CACHE_DIR = path.join(CONTEXT_DIR, "cache");
|
|
16
|
+
|
|
17
|
+
const ALLOWED_TYPES = new Set([
|
|
18
|
+
"decision",
|
|
19
|
+
"gotcha",
|
|
20
|
+
"fix",
|
|
21
|
+
"benchmark",
|
|
22
|
+
"migration-note",
|
|
23
|
+
"note"
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
const REQUIRED_FIELDS = ["title", "type", "summary"];
|
|
27
|
+
|
|
28
|
+
const STALE_DAYS = 90;
|
|
29
|
+
|
|
30
|
+
// ── Index loading ─────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
function readJsonl(filePath) {
|
|
33
|
+
if (!fs.existsSync(filePath)) return [];
|
|
34
|
+
return fs
|
|
35
|
+
.readFileSync(filePath, "utf8")
|
|
36
|
+
.split(/\r?\n/)
|
|
37
|
+
.map((line) => line.trim())
|
|
38
|
+
.filter(Boolean)
|
|
39
|
+
.map((line) => {
|
|
40
|
+
try { return JSON.parse(line); }
|
|
41
|
+
catch { return null; }
|
|
42
|
+
})
|
|
43
|
+
.filter(Boolean);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function loadKnownEntityIds() {
|
|
47
|
+
const ids = new Set();
|
|
48
|
+
|
|
49
|
+
for (const row of readJsonl(path.join(CACHE_DIR, "documents.jsonl"))) {
|
|
50
|
+
if (row.id) ids.add(row.id);
|
|
51
|
+
}
|
|
52
|
+
for (const row of readJsonl(path.join(CACHE_DIR, "entities.adr.jsonl"))) {
|
|
53
|
+
if (row.id) ids.add(row.id);
|
|
54
|
+
}
|
|
55
|
+
for (const row of readJsonl(path.join(CACHE_DIR, "entities.rule.jsonl"))) {
|
|
56
|
+
if (row.id) ids.add(row.id);
|
|
57
|
+
}
|
|
58
|
+
for (const row of readJsonl(path.join(CACHE_DIR, "entities.chunk.jsonl"))) {
|
|
59
|
+
if (row.id) ids.add(row.id);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return ids;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function loadKnownFilePaths() {
|
|
66
|
+
const paths = new Set();
|
|
67
|
+
for (const row of readJsonl(path.join(CACHE_DIR, "documents.jsonl"))) {
|
|
68
|
+
if (row.path) paths.add(row.path);
|
|
69
|
+
}
|
|
70
|
+
return paths;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Compiled article loading ──────────────────────────────
|
|
74
|
+
|
|
75
|
+
function loadCompiledArticles() {
|
|
76
|
+
if (!fs.existsSync(COMPILED_DIR)) return [];
|
|
77
|
+
|
|
78
|
+
return fs
|
|
79
|
+
.readdirSync(COMPILED_DIR, { withFileTypes: true })
|
|
80
|
+
.filter((entry) => entry.isFile() && entry.name.toLowerCase().endsWith(".md"))
|
|
81
|
+
.map((entry) => entry.name)
|
|
82
|
+
.sort()
|
|
83
|
+
.map((filename) => {
|
|
84
|
+
const absolutePath = path.join(COMPILED_DIR, filename);
|
|
85
|
+
const markdown = fs.readFileSync(absolutePath, "utf8");
|
|
86
|
+
const stats = fs.statSync(absolutePath);
|
|
87
|
+
const { fields, body } = parseFrontmatter(markdown);
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
filename,
|
|
91
|
+
id: fields.get("id") || "",
|
|
92
|
+
title: fields.get("title") || "",
|
|
93
|
+
type: fields.get("type") || "",
|
|
94
|
+
summary: fields.get("summary") || "",
|
|
95
|
+
evidence: fields.get("evidence") || "",
|
|
96
|
+
appliesTo: parseStringList(fields.get("applies_to")),
|
|
97
|
+
decisionOrGotcha: fields.get("decision_or_gotcha") || fields.get("decision") || "",
|
|
98
|
+
sources: parseStringList(fields.get("sources")),
|
|
99
|
+
supersedes: fields.get("supersedes") || "",
|
|
100
|
+
freshness: fields.get("freshness") || "",
|
|
101
|
+
updatedAt: fields.get("updated_at") || stats.mtime.toISOString(),
|
|
102
|
+
status: fields.get("status") || "active",
|
|
103
|
+
trustLevel: Number(fields.get("trust_level")) || 70,
|
|
104
|
+
sourceOfTruth: fields.get("source_of_truth")?.toLowerCase() === "true",
|
|
105
|
+
body
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Lint checks ───────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function checkMissingProvenance(article) {
|
|
113
|
+
const issues = [];
|
|
114
|
+
|
|
115
|
+
for (const field of REQUIRED_FIELDS) {
|
|
116
|
+
if (!article[field]) {
|
|
117
|
+
issues.push({ severity: "error", file: article.filename, message: `missing required field: ${field}` });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!article.id) {
|
|
122
|
+
issues.push({ severity: "error", file: article.filename, message: "missing id field" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const memoryType = article.type;
|
|
126
|
+
if (memoryType && !ALLOWED_TYPES.has(memoryType)) {
|
|
127
|
+
issues.push({
|
|
128
|
+
severity: "error",
|
|
129
|
+
file: article.filename,
|
|
130
|
+
message: `unknown type "${memoryType}" (allowed: ${[...ALLOWED_TYPES].join(", ")})`
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (!article.body) {
|
|
135
|
+
issues.push({ severity: "error", file: article.filename, message: "empty body" });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (article.appliesTo.length === 0 && article.sources.length === 0) {
|
|
139
|
+
issues.push({
|
|
140
|
+
severity: "warn",
|
|
141
|
+
file: article.filename,
|
|
142
|
+
message: "no applies_to or sources — memory has no link to codebase"
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return issues;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function checkOrphaned(article, knownEntityIds, knownFilePaths) {
|
|
150
|
+
const issues = [];
|
|
151
|
+
|
|
152
|
+
for (const target of article.appliesTo) {
|
|
153
|
+
if (!knownEntityIds.has(target)) {
|
|
154
|
+
issues.push({
|
|
155
|
+
severity: "warn",
|
|
156
|
+
file: article.filename,
|
|
157
|
+
message: `applies_to target not found in index: ${target}`
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
for (const source of article.sources) {
|
|
163
|
+
const asFileId = `file:${source}`;
|
|
164
|
+
if (!knownEntityIds.has(asFileId) && !knownFilePaths.has(source)) {
|
|
165
|
+
issues.push({
|
|
166
|
+
severity: "warn",
|
|
167
|
+
file: article.filename,
|
|
168
|
+
message: `source file not found in index: ${source}`
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return issues;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function checkStale(article) {
|
|
177
|
+
const issues = [];
|
|
178
|
+
|
|
179
|
+
if (article.freshness.toLowerCase() === "stale") {
|
|
180
|
+
issues.push({
|
|
181
|
+
severity: "warn",
|
|
182
|
+
file: article.filename,
|
|
183
|
+
message: "freshness is marked stale"
|
|
184
|
+
});
|
|
185
|
+
return issues;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (!article.updatedAt) return issues;
|
|
189
|
+
|
|
190
|
+
const updatedDate = new Date(article.updatedAt);
|
|
191
|
+
if (isNaN(updatedDate.getTime())) return issues;
|
|
192
|
+
|
|
193
|
+
const ageMs = Date.now() - updatedDate.getTime();
|
|
194
|
+
const ageDays = ageMs / (1000 * 60 * 60 * 24);
|
|
195
|
+
|
|
196
|
+
if (ageDays > STALE_DAYS) {
|
|
197
|
+
issues.push({
|
|
198
|
+
severity: "warn",
|
|
199
|
+
file: article.filename,
|
|
200
|
+
message: `updated_at is ${Math.floor(ageDays)} days old (threshold: ${STALE_DAYS} days)`
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function checkDuplicates(articles) {
|
|
208
|
+
const issues = [];
|
|
209
|
+
const byId = new Map();
|
|
210
|
+
|
|
211
|
+
for (const article of articles) {
|
|
212
|
+
if (!article.id) continue;
|
|
213
|
+
const list = byId.get(article.id) ?? [];
|
|
214
|
+
list.push(article);
|
|
215
|
+
byId.set(article.id, list);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const [id, group] of byId) {
|
|
219
|
+
if (group.length > 1) {
|
|
220
|
+
const files = group.map((a) => a.filename).join(", ");
|
|
221
|
+
issues.push({
|
|
222
|
+
severity: "error",
|
|
223
|
+
file: group[0].filename,
|
|
224
|
+
message: `duplicate memory id "${id}" in files: ${files}`
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return issues;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function checkContradictions(articles) {
|
|
233
|
+
const issues = [];
|
|
234
|
+
const activeArticles = articles.filter((a) => a.status === "active");
|
|
235
|
+
|
|
236
|
+
// Group active articles by applies_to target
|
|
237
|
+
const byTarget = new Map();
|
|
238
|
+
for (const article of activeArticles) {
|
|
239
|
+
for (const target of article.appliesTo) {
|
|
240
|
+
const list = byTarget.get(target) ?? [];
|
|
241
|
+
list.push(article);
|
|
242
|
+
byTarget.set(target, list);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Conflicting types on the same target signal potential contradiction
|
|
247
|
+
const conflictingPairs = new Set(["decision|decision", "gotcha|fix", "fix|fix", "decision|gotcha"]);
|
|
248
|
+
|
|
249
|
+
for (const [target, group] of byTarget) {
|
|
250
|
+
if (group.length < 2) continue;
|
|
251
|
+
|
|
252
|
+
for (let i = 0; i < group.length; i++) {
|
|
253
|
+
for (let j = i + 1; j < group.length; j++) {
|
|
254
|
+
const a = group[i];
|
|
255
|
+
const b = group[j];
|
|
256
|
+
|
|
257
|
+
// Skip if one supersedes the other
|
|
258
|
+
if (a.supersedes === b.id || b.supersedes === a.id) continue;
|
|
259
|
+
|
|
260
|
+
const pair = [a.type, b.type].sort().join("|");
|
|
261
|
+
if (conflictingPairs.has(pair)) {
|
|
262
|
+
issues.push({
|
|
263
|
+
severity: "warn",
|
|
264
|
+
file: a.filename,
|
|
265
|
+
message: `potential contradiction on ${target}: "${a.id}" (${a.type}) vs "${b.id}" (${b.type})`
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Check for broken supersedes references
|
|
273
|
+
const allIds = new Set(articles.map((a) => a.id));
|
|
274
|
+
for (const article of articles) {
|
|
275
|
+
if (!article.supersedes) continue;
|
|
276
|
+
if (!allIds.has(article.supersedes)) {
|
|
277
|
+
issues.push({
|
|
278
|
+
severity: "warn",
|
|
279
|
+
file: article.filename,
|
|
280
|
+
message: `supersedes target "${article.supersedes}" not found among compiled articles`
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return issues;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ── Main ──────────────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
function run() {
|
|
291
|
+
const verbose = process.argv.includes("--verbose");
|
|
292
|
+
const json = process.argv.includes("--json");
|
|
293
|
+
|
|
294
|
+
if (!fs.existsSync(COMPILED_DIR)) {
|
|
295
|
+
if (json) {
|
|
296
|
+
console.log(JSON.stringify({ issues: [], summary: { errors: 0, warnings: 0, articles: 0 } }));
|
|
297
|
+
} else {
|
|
298
|
+
console.log("[memory-lint] no compiled directory — nothing to lint");
|
|
299
|
+
}
|
|
300
|
+
process.exit(0);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const articles = loadCompiledArticles();
|
|
304
|
+
|
|
305
|
+
if (articles.length === 0) {
|
|
306
|
+
if (json) {
|
|
307
|
+
console.log(JSON.stringify({ issues: [], summary: { errors: 0, warnings: 0, articles: 0 } }));
|
|
308
|
+
} else {
|
|
309
|
+
console.log("[memory-lint] no compiled articles found");
|
|
310
|
+
}
|
|
311
|
+
process.exit(0);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const knownEntityIds = loadKnownEntityIds();
|
|
315
|
+
const knownFilePaths = loadKnownFilePaths();
|
|
316
|
+
|
|
317
|
+
const allIssues = [];
|
|
318
|
+
|
|
319
|
+
// Per-article checks
|
|
320
|
+
for (const article of articles) {
|
|
321
|
+
allIssues.push(...checkMissingProvenance(article));
|
|
322
|
+
allIssues.push(...checkOrphaned(article, knownEntityIds, knownFilePaths));
|
|
323
|
+
allIssues.push(...checkStale(article));
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Cross-article checks
|
|
327
|
+
allIssues.push(...checkDuplicates(articles));
|
|
328
|
+
allIssues.push(...checkContradictions(articles));
|
|
329
|
+
|
|
330
|
+
const errors = allIssues.filter((i) => i.severity === "error");
|
|
331
|
+
const warnings = allIssues.filter((i) => i.severity === "warn");
|
|
332
|
+
|
|
333
|
+
if (json) {
|
|
334
|
+
console.log(JSON.stringify({
|
|
335
|
+
issues: allIssues,
|
|
336
|
+
summary: { errors: errors.length, warnings: warnings.length, articles: articles.length }
|
|
337
|
+
}, null, 2));
|
|
338
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
console.log(`[memory-lint] linting ${articles.length} compiled article(s)`);
|
|
342
|
+
console.log("");
|
|
343
|
+
|
|
344
|
+
if (allIssues.length === 0) {
|
|
345
|
+
console.log("[memory-lint] no issues found");
|
|
346
|
+
process.exit(0);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// Group issues by file for readable output
|
|
350
|
+
const byFile = new Map();
|
|
351
|
+
for (const issue of allIssues) {
|
|
352
|
+
const list = byFile.get(issue.file) ?? [];
|
|
353
|
+
list.push(issue);
|
|
354
|
+
byFile.set(issue.file, list);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const [file, issues] of byFile) {
|
|
358
|
+
console.log(` ${file}:`);
|
|
359
|
+
for (const issue of issues) {
|
|
360
|
+
const prefix = issue.severity === "error" ? "ERROR" : "WARN";
|
|
361
|
+
console.log(` ${prefix}: ${issue.message}`);
|
|
362
|
+
}
|
|
363
|
+
if (verbose) console.log("");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
console.log("");
|
|
367
|
+
console.log(`[memory-lint] summary:`);
|
|
368
|
+
console.log(` articles: ${articles.length}`);
|
|
369
|
+
console.log(` errors: ${errors.length}`);
|
|
370
|
+
console.log(` warnings: ${warnings.length}`);
|
|
371
|
+
|
|
372
|
+
process.exit(errors.length > 0 ? 1 : 0);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
run();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Keep in sync with scripts/memory-lint.sh
|
|
3
|
+
set -euo pipefail
|
|
4
|
+
|
|
5
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
6
|
+
CONTEXT_DIR="$REPO_ROOT/.context"
|
|
7
|
+
|
|
8
|
+
printf "[memory-lint] repo: %s\n" "$REPO_ROOT"
|
|
9
|
+
|
|
10
|
+
if [[ ! -d "$CONTEXT_DIR" ]]; then
|
|
11
|
+
echo "[memory-lint] missing .context/ directory — run cortex init first"
|
|
12
|
+
exit 1
|
|
13
|
+
fi
|
|
14
|
+
|
|
15
|
+
if ! command -v node >/dev/null 2>&1; then
|
|
16
|
+
echo "[memory-lint] Node.js is required but not found on PATH"
|
|
17
|
+
exit 1
|
|
18
|
+
fi
|
|
19
|
+
|
|
20
|
+
node "$REPO_ROOT/scripts/memory-lint.mjs" "$@"
|