claude-memory-lint 0.1.0

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.
@@ -0,0 +1,205 @@
1
+ 'use strict';
2
+ // Shared collector for the memory archive: a directory of Markdown files plus
3
+ // one index file (default name `MEMORY.md`) that links to them.
4
+ //
5
+ // Every detector in this package needs the SAME input — per file: name,
6
+ // frontmatter, body, `[[wikilink]]` references, and the matching index line —
7
+ // so this module reads it once and hands back plain data. It never judges
8
+ // anything (no pass/fail here); detectors consume this structure and decide.
9
+ //
10
+ // Disk access is confined to `listFiles` and `readCollection`. Everything
11
+ // else is a pure function: text/structure in, structure out.
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+
15
+ const INDEX_NAME = 'MEMORY.md';
16
+
17
+ // The index file documents the folder, not a memory itself: it never carries
18
+ // frontmatter and is never a linkable entry, so it stays out of file counts.
19
+ const OUT_OF_SCOPE = new Set([INDEX_NAME, 'README.md']);
20
+
21
+ // CRLF and a leading BOM both break naive line-counting. Normalizing on the
22
+ // way in is the only defense that doesn't depend on remembering it in every
23
+ // regex downstream.
24
+ function normalize(raw) {
25
+ let t = String(raw).split('\r\n').join('\n').split('\r').join('\n');
26
+ if (t.charCodeAt(0) === 0xfeff) t = t.slice(1);
27
+ return t;
28
+ }
29
+
30
+ // Minimal YAML, on purpose: a `key: value` line at column 0; an indented line
31
+ // is a continuation of the previous key's value and is kept as raw text
32
+ // inside it. The collector never judges deep structure, only whether the
33
+ // block is made of recognizable pairs. A block with no pair at all returns
34
+ // null and becomes 'malformed' upstream.
35
+ function parseSimpleYaml(block) {
36
+ const lines = block.split('\n');
37
+ const data = {};
38
+ let currentKey = null;
39
+ let hadAnyPair = false;
40
+ for (const line of lines) {
41
+ if (line.trim() === '') continue;
42
+ if (/^\s/.test(line)) {
43
+ if (currentKey === null) return null; // indentation with no prior key = malformed
44
+ data[currentKey] += '\n' + line;
45
+ continue;
46
+ }
47
+ const m = line.match(/^([A-Za-z0-9_.-]+):\s?(.*)$/);
48
+ if (!m) return null;
49
+ currentKey = m[1];
50
+ data[currentKey] = m[2];
51
+ hadAnyPair = true;
52
+ }
53
+ return hadAnyPair ? data : null;
54
+ }
55
+
56
+ // YAML frontmatter between `---`/`---` at the START of the (already
57
+ // normalized) text. Three states, never blended:
58
+ // 'valid' : opens and closes `---`, and the block parses into pairs
59
+ // 'malformed' : opens `---` but never closes, OR closes but the block
60
+ // doesn't parse into any key: value pair
61
+ // 'absent' : the text doesn't start with a `---` line
62
+ function parseFrontmatter(txt) {
63
+ const lines = txt.split('\n');
64
+ if (lines[0] !== '---') {
65
+ return { state: 'absent', data: null, raw: null, reason: null, body: txt };
66
+ }
67
+ let end = -1;
68
+ for (let i = 1; i < lines.length; i++) {
69
+ if (lines[i] === '---') { end = i; break; }
70
+ }
71
+ if (end === -1) {
72
+ return {
73
+ state: 'malformed',
74
+ data: null,
75
+ raw: lines.slice(1).join('\n'),
76
+ reason: 'opens `---` but never closes',
77
+ body: '',
78
+ };
79
+ }
80
+ const yamlBlock = lines.slice(1, end).join('\n');
81
+ const body = lines.slice(end + 1).join('\n').replace(/^\n+/, '');
82
+ const data = parseSimpleYaml(yamlBlock);
83
+ if (data === null) {
84
+ return {
85
+ state: 'malformed',
86
+ data: null,
87
+ raw: yamlBlock,
88
+ reason: 'block between the `---` markers has no key: value pair',
89
+ body,
90
+ };
91
+ }
92
+ return { state: 'valid', data, raw: yamlBlock, reason: null, body };
93
+ }
94
+
95
+ // `[[...]]` links from the body, in order of appearance, de-duplicated.
96
+ function extractLinks(body) {
97
+ const seen = new Set();
98
+ const links = [];
99
+ const re = /\[\[([^\]]+)\]\]/g;
100
+ let m;
101
+ while ((m = re.exec(String(body)))) {
102
+ const name = m[1].trim();
103
+ if (!seen.has(name)) { seen.add(name); links.push(name); }
104
+ }
105
+ return links;
106
+ }
107
+
108
+ // One index line is `- [Title](file.md)` with an optional suffix afterwards
109
+ // (` — note` or ` - note`; both forms show up in real indexes).
110
+ const RE_INDEX_LINE = /^-\s*\[([^\]]*)\]\(([^)]+)\)(.*)$/;
111
+
112
+ function parseIndex(txt) {
113
+ const lines = String(txt).split('\n');
114
+ const entries = [];
115
+ for (let i = 0; i < lines.length; i++) {
116
+ const m = lines[i].match(RE_INDEX_LINE);
117
+ if (!m) continue;
118
+ entries.push({
119
+ line: i + 1,
120
+ title: m[1].trim(),
121
+ file: m[2].trim(),
122
+ suffix: m[3].trim(),
123
+ raw: lines[i],
124
+ });
125
+ }
126
+ return entries;
127
+ }
128
+
129
+ // Cross-reference file <-> index in BOTH directions, matched by FILE NAME
130
+ // (the link target), never by title — title is free prose and can repeat or
131
+ // drift from the file name.
132
+ // - filesWithoutLine : file in the archive with no index entry
133
+ // - orphanLines : index entry whose file doesn't exist in the archive
134
+ function correlate(fileNames, entries) {
135
+ const byFile = new Map();
136
+ for (const e of entries) {
137
+ if (!byFile.has(e.file)) byFile.set(e.file, []);
138
+ byFile.get(e.file).push(e);
139
+ }
140
+ const fileSet = new Set(fileNames);
141
+
142
+ const lineByFile = new Map();
143
+ const filesWithoutLine = [];
144
+ for (const name of fileNames) {
145
+ const es = byFile.get(name);
146
+ if (es && es.length) lineByFile.set(name, es[0]);
147
+ else filesWithoutLine.push(name);
148
+ }
149
+
150
+ const orphanLines = entries.filter((e) => !fileSet.has(e.file));
151
+
152
+ return { lineByFile, filesWithoutLine, orphanLines };
153
+ }
154
+
155
+ // Builds the record for ONE file from its raw content — pure, no disk access.
156
+ function buildFile(name, raw) {
157
+ const txt = normalize(raw);
158
+ const frontmatter = parseFrontmatter(txt);
159
+ const links = extractLinks(frontmatter.body);
160
+ return { name, frontmatter, body: frontmatter.body, links };
161
+ }
162
+
163
+ // --- I/O: the only place this module touches fs. ---------------------------
164
+
165
+ function listFiles(dir) {
166
+ if (!fs.existsSync(dir)) return [];
167
+ return fs.readdirSync(dir).filter((f) => f.endsWith('.md') && !OUT_OF_SCOPE.has(f)).sort();
168
+ }
169
+
170
+ // Reads the whole archive: every `.md` file (except the index) plus the
171
+ // index itself, builds each file's record and cross-references both ways.
172
+ function readCollection(dir) {
173
+ const names = listFiles(dir);
174
+ const files = names.map((name) =>
175
+ buildFile(name, fs.readFileSync(path.join(dir, name), 'utf8'))
176
+ );
177
+
178
+ const indexPath = path.join(dir, INDEX_NAME);
179
+ const indexRead = fs.existsSync(indexPath);
180
+ const entries = indexRead ? parseIndex(normalize(fs.readFileSync(indexPath, 'utf8'))) : [];
181
+
182
+ const { lineByFile, filesWithoutLine, orphanLines } = correlate(names, entries);
183
+ const withLine = files.map((f) => ({ ...f, indexLine: lineByFile.get(f.name) || null }));
184
+
185
+ // `entries` is exposed as `indexEntries` so a detector can judge the index
186
+ // file's own text (title + suffix note on each line), not just the notes
187
+ // it points to. The index is the single file every boot reads in full —
188
+ // treating it as out-of-scope for content detectors (it is legitimately
189
+ // out of scope for file COUNTS, see `OUT_OF_SCOPE` above) would leave the
190
+ // most-read file in the archive unjudged.
191
+ return { dir, indexRead, indexPath, indexEntries: entries, files: withLine, filesWithoutLine, orphanLines };
192
+ }
193
+
194
+ module.exports = {
195
+ INDEX_NAME,
196
+ normalize,
197
+ parseSimpleYaml,
198
+ parseFrontmatter,
199
+ extractLinks,
200
+ parseIndex,
201
+ correlate,
202
+ buildFile,
203
+ listFiles,
204
+ readCollection,
205
+ };
package/lib/config.js ADDED
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+ // Loads `memory-lint.config.json` from the current working directory (or a
3
+ // path given by `MEMORY_LINT_CONFIG`), merged over the built-in defaults
4
+ // below. There is no hardcoded ceiling copied from any real archive: the
5
+ // defaults are round placeholder numbers, and the README asks the user to
6
+ // measure their own current file and tighten them — that's the whole point
7
+ // of a closed budget (see detectors/budget.js).
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+
11
+ const DEFAULTS = {
12
+ budget: {
13
+ // Ceiling for MEMORY.md itself. Lines/bytes are a PROXY for the token
14
+ // cost of loading the file at boot, never a conversion — the bytes-per-
15
+ // token ratio depends on language and drifts as the file changes.
16
+ // These starting numbers are placeholders. Run the CLI once to see your
17
+ // actual size, then set these to that size (closed budget: new content
18
+ // must merge into or replace existing content, not just add to it).
19
+ lines: 200,
20
+ bytes: 20000,
21
+ // Claude Code (observed, as of this writing) loads at most this many
22
+ // lines of MEMORY.md at boot and warns about the rest. Configurable
23
+ // because that ceiling is the host application's behavior, not this
24
+ // tool's, and may change.
25
+ readerLineLimit: 200,
26
+ // Below this many lines, warn that the reader limit is close, before it
27
+ // silently cuts content off.
28
+ proximityWarning: 190,
29
+ },
30
+ provenance: {
31
+ // Where the line-baseline (see detectors/provenance.js) is stored,
32
+ // relative to the config file's directory (or CWD if there is none).
33
+ baselineFile: 'memory-lint.baseline.json',
34
+ },
35
+ perishable: {
36
+ // Only files touched on/after this date are judged (ISO `YYYY-MM-DD`).
37
+ // `null` means "judge everything" — set this once, to the date you adopt
38
+ // the convention, so pre-existing files aren't retroactively flagged.
39
+ cutoffDate: null,
40
+ },
41
+ pii: {
42
+ // `null` means "use the category list this package ships"
43
+ // (lib/pii-categories.json). Point this at your own file to add, drop,
44
+ // or re-tune a category — the detector never merges the two lists, so a
45
+ // custom file replaces the shipped one entirely.
46
+ categoriesFile: null,
47
+ },
48
+ };
49
+
50
+ function deepMerge(base, extra) {
51
+ if (!extra || typeof extra !== 'object') return base;
52
+ const out = { ...base };
53
+ for (const [k, v] of Object.entries(extra)) {
54
+ out[k] = v && typeof v === 'object' && !Array.isArray(v) && base[k]
55
+ ? deepMerge(base[k], v)
56
+ : v;
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function loadConfig(explicitPath) {
62
+ const file = explicitPath || process.env.MEMORY_LINT_CONFIG ||
63
+ path.join(process.cwd(), 'memory-lint.config.json');
64
+ if (!fs.existsSync(file)) return { config: DEFAULTS, file: null };
65
+ try {
66
+ const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
67
+ return { config: deepMerge(DEFAULTS, raw), file };
68
+ } catch (e) {
69
+ return { config: DEFAULTS, file, error: e.message };
70
+ }
71
+ }
72
+
73
+ module.exports = { DEFAULTS, deepMerge, loadConfig };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ // CLOSED BUDGET for the memory index file (`MEMORY.md`).
3
+ //
4
+ // What it measures (effect, not intent): the SIZE of the file that loads at
5
+ // every session boot, against a ceiling from config. Closed budget means the
6
+ // ceiling is frozen at "today's size": new content has to merge into or
7
+ // replace existing content, never just add to it. Without this trip-wire the
8
+ // file only grows, one line per closed session, and every session boot pays
9
+ // for it.
10
+ //
11
+ // Unit: LINES and BYTES, both. They're a PROXY for token cost, never a
12
+ // conversion — a bytes-per-token ratio measured in English underestimates
13
+ // non-English text by a wide margin. Anyone who wants the real token number
14
+ // measures it by ablation (diff two boots with/without the file), not by
15
+ // dividing bytes.
16
+ //
17
+ // Exit code: 0 within budget | 1 over budget | 2 archive not found (unchecked)
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ // Reader line limit is checked SEPARATELY from budget growth: budget bounds
22
+ // growth, this bounds visibility. A ceiling configured above the reader's
23
+ // own cutoff can stay "green" while the newest lines silently never reach
24
+ // context (they land at the end of the index, so they're the first thing to
25
+ // vanish).
26
+ function measureText(buf) {
27
+ const bytes = Buffer.isBuffer(buf) ? buf.length : Buffer.byteLength(String(buf), 'utf8');
28
+ const raw = Buffer.isBuffer(buf) ? buf.toString('utf8') : String(buf);
29
+ const txt = raw.split('\r\n').join('\n').split('\r').join('\n').replace(/^/, '');
30
+ const withoutTrailingBreak = txt.replace(/\n$/, '');
31
+ return { bytes, lines: withoutTrailingBreak === '' ? 0 : withoutTrailingBreak.split('\n').length };
32
+ }
33
+
34
+ function measureArchive(dir, indexName = 'MEMORY.md') {
35
+ const file = path.join(dir, indexName);
36
+ if (!fs.existsSync(file)) return null;
37
+ return { file, ...measureText(fs.readFileSync(file)) };
38
+ }
39
+
40
+ function verdict(measured, budget) {
41
+ const exceeded = [];
42
+ if (measured.lines > budget.lines) exceeded.push('lines');
43
+ if (measured.bytes > budget.bytes) exceeded.push('bytes');
44
+ return {
45
+ withinBudget: exceeded.length === 0,
46
+ exceeded,
47
+ headroom: { lines: budget.lines - measured.lines, bytes: budget.bytes - measured.bytes },
48
+ };
49
+ }
50
+
51
+ // Verdict on the READER LIMIT, separate from the growth verdict: the two
52
+ // call for different fixes —
53
+ // (a) file over the limit -> prune the archive (the tail lines are
54
+ // invisible TODAY, in every session);
55
+ // (b) configured budget over the limit -> lower the budget (raising it
56
+ // back up would make the gate green over a file the reader still cuts).
57
+ function readerVerdict(measured, budget, limit = budget.readerLineLimit, warnAt = budget.proximityWarning) {
58
+ const reasons = [];
59
+ if (measured.lines > limit) {
60
+ reasons.push(
61
+ `file has ${measured.lines} lines, above the reader limit (${limit}): ` +
62
+ `${measured.lines - limit} line(s) at the END of the index never reach context`
63
+ );
64
+ }
65
+ if (budget.lines > limit) {
66
+ reasons.push(
67
+ `configured budget (${budget.lines} lines) is above the reader limit (${limit}): ` +
68
+ `the budget currently allows growing past what the reader loads`
69
+ );
70
+ }
71
+ const cutOff = Math.max(0, measured.lines - limit);
72
+ return {
73
+ withinLimit: reasons.length === 0,
74
+ reasons,
75
+ cutOff,
76
+ limit,
77
+ warnAt,
78
+ // Proximity is only worth reporting while nothing has failed yet.
79
+ close: reasons.length === 0 && measured.lines >= warnAt,
80
+ };
81
+ }
82
+
83
+ module.exports = { measureText, measureArchive, verdict, readerVerdict };
84
+
85
+ // --- CLI ---------------------------------------------------------------
86
+ // Usage: node lib/detectors/budget.js [--json]
87
+ // The archive directory comes from `locateArchive()` — the same resolution
88
+ // order (MEMORY_LINT_DIR -> autoMemoryDirectory -> derived default) the other
89
+ // five detectors use. A caller-supplied positional directory used to bypass
90
+ // that order silently; unifying on `locateArchive()` means `--archive <dir>`
91
+ // at the `memory-lint` CLI level (which sets MEMORY_LINT_DIR) now reaches
92
+ // this detector the same way it reaches every other one.
93
+ if (require.main === module) {
94
+ const { loadConfig } = require('../config');
95
+ const { locateArchive } = require('../locate-archive');
96
+ const json = process.argv.includes('--json');
97
+ const { dir, source } = locateArchive();
98
+ const { config } = loadConfig();
99
+ const budget = config.budget;
100
+ const measured = measureArchive(dir);
101
+
102
+ if (!measured) {
103
+ const msg = `BUDGET: ARCHIVE NOT FOUND - ${path.join(dir, 'MEMORY.md')} does not exist.`;
104
+ if (json) console.log(JSON.stringify({ dir, source, state: 'absent', measured: null }, null, 2));
105
+ else console.log(msg);
106
+ console.error(msg);
107
+ process.exit(2);
108
+ }
109
+
110
+ const v = verdict(measured, budget);
111
+ const r = readerVerdict(measured, budget);
112
+ const out = { dir, source, budget, measured, ...v, reader: r };
113
+
114
+ if (json) {
115
+ console.log(JSON.stringify(out, null, 2));
116
+ } else {
117
+ console.log(`archive: ${measured.file}`);
118
+ console.log(`lines: ${measured.lines} / budget ${budget.lines} bytes: ${measured.bytes} / budget ${budget.bytes}`);
119
+ console.log(
120
+ `\nBUDGET: ${v.withinBudget ? 'within budget' : 'OVER BUDGET'}` +
121
+ (v.withinBudget ? '' : ` (${v.exceeded.join(' and ')})`)
122
+ );
123
+ if (!r.withinLimit) console.log(`READER LIMIT: exceeded - ${r.reasons.join(' | ')}`);
124
+ else if (r.close) console.log(`WARNING: ${measured.lines} lines, ${r.limit - measured.lines} away from the reader limit.`);
125
+ }
126
+ process.exit(v.withinBudget && r.withinLimit ? 0 : 1);
127
+ }
@@ -0,0 +1,96 @@
1
+ 'use strict';
2
+ // REQUIRED FRONTMATTER detector.
3
+ //
4
+ // What it judges (not collects — see lib/collector.js): every file in the
5
+ // archive has VALID frontmatter, and inside it a non-empty `description`.
6
+ // Without that, a file is invisible to whatever decides which memory to open
7
+ // — the frontmatter state ('valid' | 'malformed' | 'absent') already comes
8
+ // from `parseFrontmatter`/`readCollection`; this module only holds the
9
+ // VERDICT over that fact, the same collector/judge split used by budget.js.
10
+ //
11
+ // Declared scope: this detector fails on MISSING frontmatter/description. It
12
+ // does not judge whether the description is *well written* — that's a
13
+ // content-quality call, not something detectable by shape.
14
+ const fs = require('fs');
15
+ const { readCollection } = require('../collector');
16
+ const { locateArchive } = require('../locate-archive');
17
+
18
+ // Judges ONE already-collected file. Pure: struct in, verdict out — no I/O,
19
+ // which is what lets the two-sided test attack this function directly.
20
+ function judgeFile(file) {
21
+ const fm = file.frontmatter;
22
+ if (fm.state === 'absent') {
23
+ return { name: file.name, state: 'no_frontmatter', reason: 'file does not start with `---`' };
24
+ }
25
+ if (fm.state === 'malformed') {
26
+ return { name: file.name, state: 'malformed', reason: fm.reason };
27
+ }
28
+ const desc = fm.data && typeof fm.data.description === 'string' ? fm.data.description.trim() : '';
29
+ if (!desc) {
30
+ return { name: file.name, state: 'no_description', reason: 'valid frontmatter but `description` missing (or empty)' };
31
+ }
32
+ return { name: file.name, state: 'ok', reason: null };
33
+ }
34
+
35
+ function auditFrontmatter(files) {
36
+ return files.map(judgeFile);
37
+ }
38
+
39
+ // Summary built from the SAME list the gate reads, never a second parallel
40
+ // count.
41
+ function summarizeFrontmatter(lines) {
42
+ const noFrontmatter = lines.filter((l) => l.state === 'no_frontmatter');
43
+ const malformed = lines.filter((l) => l.state === 'malformed');
44
+ const noDescription = lines.filter((l) => l.state === 'no_description');
45
+ const ok = lines.filter((l) => l.state === 'ok');
46
+ return {
47
+ total: lines.length,
48
+ ok: ok.length,
49
+ noFrontmatter: noFrontmatter.length,
50
+ malformed: malformed.length,
51
+ noDescription: noDescription.length,
52
+ failed: noFrontmatter.length + malformed.length + noDescription.length,
53
+ namesNoFrontmatter: noFrontmatter.map((l) => l.name),
54
+ namesMalformed: malformed.map((l) => l.name),
55
+ namesNoDescription: noDescription.map((l) => l.name),
56
+ };
57
+ }
58
+
59
+ module.exports = { judgeFile, auditFrontmatter, summarizeFrontmatter };
60
+
61
+ // --- CLI ---------------------------------------------------------------
62
+ // Usage: node lib/detectors/frontmatter.js [--json]
63
+ // Exit: 0 nothing failed | 1 something failed | 2 archive not found
64
+ if (require.main === module) {
65
+ const json = process.argv.includes('--json');
66
+ const { dir, source } = locateArchive();
67
+
68
+ if (!fs.existsSync(dir)) {
69
+ const msg = `FRONTMATTER: ARCHIVE NOT FOUND - ${dir} (path from ${source}).`;
70
+ if (json) console.log(JSON.stringify({ dir, source, state: 'absent', lines: null }, null, 2));
71
+ else console.log(msg);
72
+ console.error(msg);
73
+ process.exit(2);
74
+ }
75
+
76
+ const r = readCollection(dir);
77
+ const lines = auditFrontmatter(r.files);
78
+ const summary = summarizeFrontmatter(lines);
79
+ const state = summary.failed > 0 ? 'failed' : 'ok';
80
+
81
+ if (json) {
82
+ console.log(JSON.stringify({ dir, source, state, summary, lines }, null, 2));
83
+ } else {
84
+ console.log(`archive: ${dir} (${source})`);
85
+ console.log(`total: ${summary.total} ok: ${summary.ok} no frontmatter: ${summary.noFrontmatter} malformed: ${summary.malformed} no description: ${summary.noDescription}`);
86
+ if (summary.failed) {
87
+ console.log(`\nFRONTMATTER: ${summary.failed} failed.`);
88
+ if (summary.namesNoFrontmatter.length) console.log(` no frontmatter: ${summary.namesNoFrontmatter.join(', ')}`);
89
+ if (summary.namesMalformed.length) console.log(` malformed: ${summary.namesMalformed.join(', ')}`);
90
+ if (summary.namesNoDescription.length) console.log(` no description: ${summary.namesNoDescription.join(', ')}`);
91
+ } else {
92
+ console.log('\nFRONTMATTER: nothing failed.');
93
+ }
94
+ }
95
+ process.exit(summary.failed > 0 ? 1 : 0);
96
+ }