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.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +340 -0
- package/bin/memory-lint.js +136 -0
- package/lib/collector.js +205 -0
- package/lib/config.js +73 -0
- package/lib/detectors/budget.js +127 -0
- package/lib/detectors/frontmatter.js +96 -0
- package/lib/detectors/honesty.js +351 -0
- package/lib/detectors/perishable.js +356 -0
- package/lib/detectors/pii.js +511 -0
- package/lib/detectors/provenance.js +668 -0
- package/lib/index.js +13 -0
- package/lib/locate-archive.js +44 -0
- package/lib/mask-code.js +87 -0
- package/lib/pii-categories.json +403 -0
- package/memory-lint.config.json +19 -0
- package/package.json +31 -0
package/lib/collector.js
ADDED
|
@@ -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
|
+
}
|