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,356 @@
1
+ 'use strict';
2
+ // STABLE RESIDUE vs. PERISHABLE STATE in the memory archive.
3
+ //
4
+ // The failure mode this exists to catch: a status note records that some
5
+ // external system was broken/blocked on a given day. The note was correct
6
+ // that day. Days later the same system works fine, but nothing in the
7
+ // archive says so, and by then someone has built a costly workaround around
8
+ // a restriction that no longer exists. The sibling failure: a cited fact
9
+ // that was true when written and has quietly gone stale since.
10
+ //
11
+ // The split to make:
12
+ //
13
+ // STABLE RESIDUE -- the area exists, the decision was made, the rule
14
+ // holds. Write it down. It ages well.
15
+ // PERISHABLE STATE -- "paused at step 5 of 9", "the block is active",
16
+ // "the spec is in section 2". This is a measurement
17
+ // of a moment. Let it expire, or if it must stay on
18
+ // record, carry a MEASUREMENT DATE and a RECHECK
19
+ // ROUTE — perishable by construction.
20
+ //
21
+ // -- THE BOUNDARY (which file is a "state card") LIVES IN ONE PLACE ---------
22
+ //
23
+ // `isStateCard` is that place. The signal comes from a command, not from
24
+ // reading prose: `metadata.type: project` in frontmatter, or an explicit
25
+ // `measuredOn:`/`recheck:` field. Judging the BODY by keyword ("paused",
26
+ // "live", "active") was considered and rejected for the same reason: it
27
+ // can't tell an actual state claim from prose describing someone else's
28
+ // state, and produces false positives in both directions.
29
+ //
30
+ // -- MENTION IS NOT USE -------------------------------------------------------
31
+ //
32
+ // Backticks and fenced blocks are quotation. A measurement date written
33
+ // INSIDE backticks (an example of the convention in a doc note) doesn't
34
+ // count as a real measurement. Masking is shared, not reimplemented.
35
+ //
36
+ // -- CUT: ONLY NEW CARDS ------------------------------------------------------
37
+ //
38
+ // Same decision as the provenance detector, for the same reason: a detector
39
+ // that debuts by flagging every pre-existing file leaves the gate red
40
+ // forever, and a gate that's permanently red stops being a signal. A card
41
+ // enters judgment if it declares `measuredOn:`/`recheck:` in frontmatter, OR
42
+ // its `metadata.modified` is on/after the configured cutoff date. The
43
+ // backlog doesn't disappear: it's counted separately and surfaced as a
44
+ // warning.
45
+ //
46
+ // -- KNOWN LIMITS, DECLARED RATHER THAN HIDDEN --------------------------------
47
+ //
48
+ // (a) A file with no `metadata` block at all escapes the signal entirely.
49
+ // Closing this would need a second trigger on prose, which is the exact
50
+ // failure mode this design avoids. The gap is pinned by a test and the
51
+ // declarative door (`measuredOn:`/`recheck:`) is the way in for anyone
52
+ // who writes a state card without a `type`.
53
+ // (b) The cut is BY FILE, not by line: `metadata.modified` belongs to the
54
+ // file, so appending one new note to an old state card drags the WHOLE
55
+ // file into the cut. Same class of defect the line-level cut in
56
+ // provenance.js fixes; not fixed here on purpose because the fix
57
+ // changes the unit of judgment, which is a design decision, not a
58
+ // detail.
59
+ // (c) The measurement date is validated against the calendar and rejected if
60
+ // it's in the future: an accepted-but-invalid marker would leave the
61
+ // card LOOKING measured, which is worse than a red gate.
62
+ //
63
+ // Exit: 0 ok | 1 failed | 2 archive not found (not checked)
64
+
65
+ const fs = require('fs');
66
+ const { readCollection } = require('../collector');
67
+ const { locateArchive } = require('../locate-archive');
68
+ const { maskCode } = require('../mask-code');
69
+
70
+ const STATE_TYPE = 'project';
71
+
72
+ // --- reading frontmatter (fact, not judgment) ------------------------------
73
+
74
+ // `metadata` arrives from the collector as a raw YAML block: the level-0 key
75
+ // with its indented lines glued underneath. The `(?:^|\n)\s*` anchor is
76
+ // required — without it, a key like `node_type: memory` could match before
77
+ // `type: project` and misclassify the whole file.
78
+ const RE_TYPE = /(?:^|\n)\s*type:\s*(\S+)/;
79
+ const RE_MODIFIED = /(?:^|\n)\s*modified:\s*(\S+)/;
80
+
81
+ function frontmatterField(file, key) {
82
+ const data = (file && file.frontmatter && file.frontmatter.data) || {};
83
+ const v = data[key];
84
+ return typeof v === 'string' && v.trim() ? v.trim() : null;
85
+ }
86
+
87
+ function metadataBlock(file) {
88
+ const data = (file && file.frontmatter && file.frontmatter.data) || {};
89
+ return typeof data.metadata === 'string' ? data.metadata : '';
90
+ }
91
+
92
+ function declaredType(file) {
93
+ const m = RE_TYPE.exec(metadataBlock(file));
94
+ if (m) return m[1];
95
+ return frontmatterField(file, 'type');
96
+ }
97
+
98
+ function modifiedDate(file) {
99
+ const m = RE_MODIFIED.exec(metadataBlock(file));
100
+ return m ? m[1] : null;
101
+ }
102
+
103
+ // --- the boundary: which file is a state card -------------------------------
104
+
105
+ // Returns `{ isCard, via }`. `via` names the signal that decided, so both the
106
+ // gate's message and the test assertion say which rule fired.
107
+ function isStateCard(file) {
108
+ if (frontmatterField(file, 'measuredOn') || frontmatterField(file, 'recheck')) {
109
+ return { isCard: true, via: 'declared field (measuredOn/recheck)' };
110
+ }
111
+ const type = declaredType(file);
112
+ if (type === STATE_TYPE) return { isCard: true, via: `metadata.type: ${STATE_TYPE}` };
113
+ return { isCard: false, via: type ? `metadata.type: ${type}` : 'no type declared' };
114
+ }
115
+
116
+ // --- the two markers a state card carries ----------------------------------
117
+
118
+ const RE_DATE = /\b(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4})\b/;
119
+ // `measured on <date>` in the body. Accepts a couple of verb forms so a
120
+ // reasonable phrasing isn't rejected on a technicality.
121
+ const RE_MEASURED_ON = /\bmeasured\s+on\s*:?\s*(\d{4}-\d{2}-\d{2}|\d{1,2}\/\d{1,2}\/\d{2,4})\b/i;
122
+ // `recheck: <how to reconfirm>` — needs an actual route, not just the word.
123
+ const RE_RECHECK = /\brecheck\s*:\s*(\S.{2,})/i;
124
+
125
+ // -- THE DATE HAS TO BE AN ACTUAL DATE, AND IN THE PAST ----------------------
126
+ //
127
+ // `\d{4}-\d{2}-\d{2}` matches `2026-13-45`, and the slash form matches
128
+ // `32/01/2026`: without calendar validation the gate goes GREEN on a wrong
129
+ // marker, which is worse than red — the card ends up LOOKING measured. A
130
+ // future date is the same defect through a different door: a measurement is
131
+ // of the past by construction, and a future date is either a typo or a
132
+ // marker planted just to quiet the gate. The rejection names the reason
133
+ // instead of returning a bare null.
134
+ function todayLocal(d) {
135
+ const t = d || new Date();
136
+ const p = (n) => String(n).padStart(2, '0');
137
+ return `${t.getFullYear()}-${p(t.getMonth() + 1)}-${p(t.getDate())}`;
138
+ }
139
+
140
+ // Returns the date in ISO form, or null if the calendar doesn't allow it.
141
+ function normalizeDate(txt) {
142
+ let y;
143
+ let mo;
144
+ let d;
145
+ const iso = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(txt));
146
+ if (iso) {
147
+ [, y, mo, d] = iso;
148
+ } else {
149
+ const br = /^(\d{1,2})\/(\d{1,2})\/(\d{2,4})$/.exec(String(txt));
150
+ if (!br) return null;
151
+ d = br[1];
152
+ mo = br[2];
153
+ y = br[3].length === 2 ? `20${br[3]}` : br[3];
154
+ }
155
+ const Y = Number(y);
156
+ const M = Number(mo);
157
+ const D = Number(d);
158
+ const dt = new Date(Date.UTC(Y, M - 1, D));
159
+ if (dt.getUTCFullYear() !== Y || dt.getUTCMonth() !== M - 1 || dt.getUTCDate() !== D) return null;
160
+ const p = (n) => String(n).padStart(2, '0');
161
+ return `${String(Y).padStart(4, '0')}-${p(M)}-${p(D)}`;
162
+ }
163
+
164
+ function validateMeasurementDate(txt, today) {
165
+ const iso = normalizeDate(txt);
166
+ if (!iso) return { ok: false, reason: 'impossible date' };
167
+ // Inclusive: measuring TODAY is the normal case.
168
+ if (iso > String(today)) return { ok: false, reason: 'date in the future' };
169
+ return { ok: true, reason: null };
170
+ }
171
+
172
+ // Each candidate (declared field, then body) goes through calendar
173
+ // validation; the first VALID one wins, and if none wins the reported reason
174
+ // is the first rejection's — the gate says what was actually written, not
175
+ // just that something's missing.
176
+ function findMeasurementDate(file, opts) {
177
+ const today = (opts && opts.today) || todayLocal();
178
+ const candidates = [];
179
+ const field = frontmatterField(file, 'measuredOn');
180
+ if (field) {
181
+ const mc = RE_DATE.exec(field);
182
+ if (mc) candidates.push({ txt: mc[1], via: 'frontmatter measuredOn' });
183
+ }
184
+ const body = maskCode(String((file && file.body) || ''));
185
+ const m = RE_MEASURED_ON.exec(body);
186
+ if (m) candidates.push({ txt: m[1], via: 'body "measured on <date>"' });
187
+
188
+ let reason = null;
189
+ for (const c of candidates) {
190
+ const v = validateMeasurementDate(c.txt, today);
191
+ if (v.ok) return { date: c.txt, via: c.via, reason: null };
192
+ if (!reason) reason = v.reason;
193
+ }
194
+ if (!reason && field) reason = 'measuredOn field has no readable date';
195
+ return { date: null, via: null, reason };
196
+ }
197
+
198
+ // Route to recheck: the command, call or step that reconfirms the state.
199
+ function findRecheckRoute(file) {
200
+ const field = frontmatterField(file, 'recheck');
201
+ if (field && field.length >= 3) return { route: field, via: 'frontmatter recheck' };
202
+ const body = maskCode(String((file && file.body) || ''));
203
+ const m = RE_RECHECK.exec(body);
204
+ if (m) return { route: m[1].trim(), via: 'body "recheck: …"' };
205
+ return { route: null, via: null };
206
+ }
207
+
208
+ // --- cut: who enters judgment -----------------------------------------------
209
+
210
+ function isInCutoff(file, cutoff) {
211
+ if (!cutoff) return { inCutoff: true, via: 'no cutoff configured' };
212
+ if (frontmatterField(file, 'measuredOn') || frontmatterField(file, 'recheck')) {
213
+ return { inCutoff: true, via: 'declared field (measuredOn/recheck)' };
214
+ }
215
+ const mod = modifiedDate(file);
216
+ if (mod && String(mod) >= String(cutoff)) return { inCutoff: true, via: `modified ${mod}` };
217
+ return { inCutoff: false, via: mod ? `modified ${mod}` : 'no date' };
218
+ }
219
+
220
+ // --- judgment ----------------------------------------------------------------
221
+
222
+ // Judges ONE already-collected file. Pure: struct in, verdict out.
223
+ //
224
+ // Three end states, never blended:
225
+ // 'not_a_card' : stable residue, out of this gate's scope
226
+ // 'out_of_cutoff': state card predating the convention (declared backlog)
227
+ // 'ok'/'unmarked': new state card, judged
228
+ function judgeFile(file, opts) {
229
+ const o = opts || {};
230
+ const cutoff = o.cutoff || null;
231
+ const scope = isStateCard(file);
232
+ if (!scope.isCard) {
233
+ return { name: file.name, state: 'not_a_card', via: scope.via, missing: [] };
234
+ }
235
+
236
+ const window = isInCutoff(file, cutoff);
237
+ const measurement = findMeasurementDate(file, { today: o.today || todayLocal() });
238
+ const route = findRecheckRoute(file);
239
+ const missing = [];
240
+ if (!measurement.date) missing.push('measurement_date');
241
+ if (!route.route) missing.push('recheck_route');
242
+
243
+ const base = {
244
+ name: file.name,
245
+ signal: scope.via,
246
+ via: window.via,
247
+ measurementDate: measurement.date,
248
+ measurementVia: measurement.via,
249
+ measurementReason: measurement.reason || null,
250
+ route: route.route,
251
+ routeVia: route.via,
252
+ missing,
253
+ };
254
+
255
+ if (!window.inCutoff) return { ...base, state: 'out_of_cutoff' };
256
+ if (missing.length) return { ...base, state: 'unmarked' };
257
+ return { ...base, state: 'ok' };
258
+ }
259
+
260
+ function auditPerishable(files, opts) {
261
+ return files.map((f) => judgeFile(f, opts));
262
+ }
263
+
264
+ // Summary from the SAME list the gate reads, never a second parallel count.
265
+ function summarizePerishable(lines) {
266
+ const cards = lines.filter((l) => l.state !== 'not_a_card');
267
+ const outside = cards.filter((l) => l.state === 'out_of_cutoff');
268
+ const inside = cards.filter((l) => l.state !== 'out_of_cutoff');
269
+ const unmarked = inside.filter((l) => l.state === 'unmarked');
270
+ const outsideNoDate = outside.filter((l) => l.missing.includes('measurement_date'));
271
+ const outsideNoRoute = outside.filter((l) => l.missing.includes('recheck_route'));
272
+ return {
273
+ total: lines.length,
274
+ cards: cards.length,
275
+ notCards: lines.length - cards.length,
276
+ outOfCutoff: outside.length,
277
+ outOfCutoffNoDate: outsideNoDate.length,
278
+ outOfCutoffNoRoute: outsideNoRoute.length,
279
+ inCutoff: inside.length,
280
+ ok: inside.length - unmarked.length,
281
+ unmarked: unmarked.length,
282
+ failed: unmarked.length,
283
+ namesUnmarked: unmarked.map((l) => l.name),
284
+ missingInCutoff: unmarked.reduce((n, l) => n + l.missing.length, 0),
285
+ };
286
+ }
287
+
288
+ module.exports = {
289
+ STATE_TYPE,
290
+ frontmatterField,
291
+ declaredType,
292
+ modifiedDate,
293
+ isStateCard,
294
+ todayLocal,
295
+ normalizeDate,
296
+ validateMeasurementDate,
297
+ findMeasurementDate,
298
+ findRecheckRoute,
299
+ isInCutoff,
300
+ judgeFile,
301
+ auditPerishable,
302
+ summarizePerishable,
303
+ };
304
+
305
+ // --- CLI ---------------------------------------------------------------
306
+ // Usage: node lib/detectors/perishable.js [--json]
307
+ // Exit: 0 nothing failed | 1 new state card missing a measurement date or a
308
+ // recheck route | 2 archive not found (not checked).
309
+ if (require.main === module) {
310
+ const { loadConfig } = require('../config');
311
+ const json = process.argv.includes('--json');
312
+ const { config } = loadConfig();
313
+ const cutoff = process.env.MEMORY_LINT_PERISHABLE_CUTOFF || config.perishable.cutoffDate;
314
+ const { dir, source } = locateArchive();
315
+
316
+ if (!fs.existsSync(dir)) {
317
+ const msg = `PERISHABLE: ARCHIVE NOT CHECKED - ${dir} not found (path from ${source}).`;
318
+ if (json) console.log(JSON.stringify({ dir, source, cutoff, state: 'absent', summary: null, lines: null }, null, 2));
319
+ else console.log(msg);
320
+ console.error(msg);
321
+ process.exit(2);
322
+ }
323
+
324
+ const r = readCollection(dir);
325
+ const lines = auditPerishable(r.files, { cutoff });
326
+ const summary = summarizePerishable(lines);
327
+ const state = summary.failed > 0 ? 'failed' : 'ok';
328
+
329
+ if (json) {
330
+ console.log(JSON.stringify({ dir, source, cutoff, state, summary, lines: lines.filter((l) => l.state === 'unmarked') }, null, 2));
331
+ } else {
332
+ console.log(`archive: ${dir} (${source})`);
333
+ console.log(`cutoff: ${cutoff || '(none, everything judged)'}`);
334
+ console.log(`signal for a state card: metadata.type: ${STATE_TYPE}, or a declared field`);
335
+ console.log(`total: ${summary.total} state cards: ${summary.cards} stable residue (out of scope): ${summary.notCards}`);
336
+ console.log(`cards in cutoff: ${summary.inCutoff} out of cutoff: ${summary.outOfCutoff}`);
337
+ console.log(`in cutoff: ok ${summary.ok} unmarked ${summary.unmarked} (missing: ${summary.missingInCutoff})`);
338
+ if (summary.failed) {
339
+ console.log(`\nPERISHABLE: ${summary.failed} failed.`);
340
+ for (const l of lines.filter((x) => x.state === 'unmarked')) {
341
+ const reason = l.measurementReason ? ` date rejected: ${l.measurementReason}` : '';
342
+ console.log(` ${l.name} [${l.missing.join('+')}] (${l.signal})${reason}`);
343
+ }
344
+ console.log('\nA recorded perishable state carries both markers: `measured on <date>` and `recheck: <how to reconfirm>`.');
345
+ } else {
346
+ console.log('\nPERISHABLE: nothing failed.');
347
+ }
348
+ if (summary.outOfCutoff) {
349
+ console.log(
350
+ `\nDECLARED BACKLOG: ${summary.outOfCutoff} state card(s) predating the cutoff are not judged here ` +
351
+ `(${summary.outOfCutoffNoDate} missing a measurement date, ${summary.outOfCutoffNoRoute} missing a recheck route).`
352
+ );
353
+ }
354
+ }
355
+ process.exit(summary.failed > 0 ? 1 : 0);
356
+ }