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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// HONESTY GUARDRAIL, JUDGED BY EFFECT — no memory in the archive may contain
|
|
3
|
+
// an instruction that, IF FOLLOWED, would make a real problem stop being
|
|
4
|
+
// raised. A person who tells their agent "you can disagree and push back" one
|
|
5
|
+
// day, then writes a memory born of one frustrated afternoon ("stop
|
|
6
|
+
// questioning my numbers", "no more pushback") the next, silently overturns
|
|
7
|
+
// the first instruction in every future session — nobody notices until an
|
|
8
|
+
// error that should have been flagged wasn't.
|
|
9
|
+
//
|
|
10
|
+
// THE CRITERION IS THE EFFECT, NOT THE WORDING. An instruction stays caught
|
|
11
|
+
// even when it arrives softened ("only flag it if it's really bad"), scoped
|
|
12
|
+
// to one topic ("don't question me about the budget numbers"), justified
|
|
13
|
+
// ("skip the caveats, we're in a hurry") or dressed up as a preference about
|
|
14
|
+
// format, tone, pacing, or efficiency ("to save time, skip the risk
|
|
15
|
+
// warnings"). So this detector never looks for one forbidden sentence: it
|
|
16
|
+
// looks for the STRUCTURE suppressor + honesty act, in any of the families
|
|
17
|
+
// below.
|
|
18
|
+
//
|
|
19
|
+
// WHAT PASSES (the declared boundary, and the half that matters): a
|
|
20
|
+
// legitimate preference about FORM — length ("keep it short"), tone ("blunt,
|
|
21
|
+
// no pleasantries"), how direct to be ("skip the preamble", "get to the
|
|
22
|
+
// point"), and how much of a draft to touch ("only change the part I asked
|
|
23
|
+
// about"). None of these make a problem stop being raised, and none of them
|
|
24
|
+
// trip this detector.
|
|
25
|
+
//
|
|
26
|
+
// MENTION IS NOT USE: text inside a backtick span or a fenced code block is a
|
|
27
|
+
// literal quotation and is out of judgment — a memory that explains this
|
|
28
|
+
// detector needs to be able to show the phrase it catches. Quotation marks
|
|
29
|
+
// are deliberately NOT exempted the same way: a real instruction fits inside
|
|
30
|
+
// quotes just as well, and exempting them would only move the false positive,
|
|
31
|
+
// not remove it. See `maskCode` (shared with the other detectors here).
|
|
32
|
+
//
|
|
33
|
+
// A NOTE ON REMEDIATION (for whoever reads the verdict, not for this code): a
|
|
34
|
+
// finding here is not fixed by writing a softer version of the same memory.
|
|
35
|
+
// Needing a softer version IS the signal that the line shouldn't be recorded
|
|
36
|
+
// at all — and editing someone else's already-recorded memory is their call,
|
|
37
|
+
// not this tool's.
|
|
38
|
+
//
|
|
39
|
+
// This module does not parse files itself: frontmatter, body, CRLF and BOM
|
|
40
|
+
// handling all come from the shared collector (lib/collector.js). Only the
|
|
41
|
+
// VERDICT lives here.
|
|
42
|
+
//
|
|
43
|
+
// Exit: 0 nothing found | 1 a suppression instruction found | 2 archive not
|
|
44
|
+
// found (not checked)
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const { readCollection } = require('../collector');
|
|
48
|
+
const { locateArchive } = require('../locate-archive');
|
|
49
|
+
const { maskCode } = require('../mask-code');
|
|
50
|
+
|
|
51
|
+
// --- vocabulary of the criterion --------------------------------------------
|
|
52
|
+
// Not a list of forbidden phrases (a forbidden phrase is defeated by swapping
|
|
53
|
+
// one word for a synonym): it's the pair suppressor x honesty act.
|
|
54
|
+
|
|
55
|
+
// Suppressor: what cancels the act. Includes the softened forms ("avoid",
|
|
56
|
+
// "quit") and the scoped one ("no more"), because the criterion is the effect.
|
|
57
|
+
const NEG = '(?:not|never|don\'t|do not|dont|stop|quit|forget|skip|avoid|no more|please don\'t)';
|
|
58
|
+
const RE_NEG = new RegExp('\\b' + NEG + '\\b', 'g');
|
|
59
|
+
|
|
60
|
+
// FORM OF INSTRUCTION ONLY, never descriptive prose. An earlier version of
|
|
61
|
+
// this class of detector matched any inflection of the verb and flagged
|
|
62
|
+
// sentences that DEFEND honesty ("the criticism was fair", "the failure
|
|
63
|
+
// wasn't hidden") — a defect in the detector, not a real hit. Only the
|
|
64
|
+
// address-the-agent forms (imperative, "please + verb") enter below;
|
|
65
|
+
// descriptive third-person and noun forms are left out on purpose.
|
|
66
|
+
|
|
67
|
+
// A direct honesty act, complete on its own.
|
|
68
|
+
const RE_ACT_DIRECT =
|
|
69
|
+
/\b(?:disagree|question|challenge|criticize|object|contradict|correct me|push back|pushback|call it out)\b/g;
|
|
70
|
+
|
|
71
|
+
// An honesty act that only counts with an object: signal/flag/raise/mention
|
|
72
|
+
// WHAT. "let me know when it's done" is not honesty; "flag the risk" is.
|
|
73
|
+
const RE_ACT_SIGNAL = /\b(?:warn|flag|raise|mention|bring up|point out|report|surface|note)\b/g;
|
|
74
|
+
const RE_OBJECT =
|
|
75
|
+
/\b(?:risks?|problems?|errors?|issues?|flaws?|mistakes?|findings?|concerns?|objections?|criticism|bugs?|downsides?|caveats?|red flags?)\b/;
|
|
76
|
+
|
|
77
|
+
// A cut straight to the noun ("no more pushback", "zero criticism") — the
|
|
78
|
+
// construction already IS the order; it needs no verb.
|
|
79
|
+
const RE_CUT_NOUN =
|
|
80
|
+
/\b(?:no more|zero|none of the)\s+(?:pushback|criticism|objections?|caveats?|questions?|risk warnings?|red flags?)\b/g;
|
|
81
|
+
|
|
82
|
+
// A silencing verb in address-the-agent form: already carries the
|
|
83
|
+
// suppression, needs no negator. The bare infinitive does NOT enter here:
|
|
84
|
+
// "or suppress a finding" inside a list of things not to do is prose, not an
|
|
85
|
+
// order.
|
|
86
|
+
const RE_SILENCE =
|
|
87
|
+
/\b(?:hide|suppress|bury|omit|ignore|downplay|soften|sugarcoat|swallow|mute|gloss over|brush aside|sweep .{0,20} under the rug)\b/g;
|
|
88
|
+
|
|
89
|
+
// When a silencing verb is the SUBJECT of a requirement ("suppressing a
|
|
90
|
+
// finding requires an owner and a ticket"), the sentence is a rule AGAINST
|
|
91
|
+
// suppression, not an order to suppress. Without this inversion the detector
|
|
92
|
+
// would flag exactly the memories that defend honesty.
|
|
93
|
+
const RE_REQUIREMENT =
|
|
94
|
+
/\b(?:requires?|needs?|takes?|depends on|only with|isn't enough|is not enough)\b/;
|
|
95
|
+
|
|
96
|
+
// Forced agreement: the suppression stated from the other direction ("just
|
|
97
|
+
// comply", "accept without question"). Some already fall under NEG+ACT;
|
|
98
|
+
// these are the ones that don't.
|
|
99
|
+
const RE_FORCED =
|
|
100
|
+
/\b(?:just execute|just comply|just agree|always agree|accept without question|follow without question|never contradict me|just say yes|only agree|just approve)\b/g;
|
|
101
|
+
|
|
102
|
+
// Family E: SEVERITY THRESHOLD. Here the honesty act isn't negated — it's
|
|
103
|
+
// RESTRICTED to a level: "only warn me if it's really serious", "only push
|
|
104
|
+
// back if you're absolutely certain", "skip the caveats unless it's
|
|
105
|
+
// critical". The effect is the same as the other families, and it blocks the
|
|
106
|
+
// same way: the next time there's a REAL problem BELOW the threshold,
|
|
107
|
+
// following the line to the letter means not raising it.
|
|
108
|
+
const SEVERITY =
|
|
109
|
+
'(?:seri\\w*|critical\\w*|urgent\\w*|catastrophic\\w*|disastr\\w*|irreversib\\w*|absolut\\w*|certain|major|huge|blocking|really|truly|genuinely)';
|
|
110
|
+
// "actually" is deliberately left OUT — it also matches phrasing that DEFENDS
|
|
111
|
+
// a gate ("only valid if the check actually runs"), the opposite of
|
|
112
|
+
// suppression.
|
|
113
|
+
|
|
114
|
+
// "only/just ... if/when ... <severity>". The restrictor can come before the
|
|
115
|
+
// act ("Only warn me if it's serious") or after ("Warn me only when it's
|
|
116
|
+
// really serious"): this family judges the whole SENTENCE, not a left-hand
|
|
117
|
+
// window like the families above. The negated punctuation classes stop the
|
|
118
|
+
// construction from crossing a period/semicolon and matching pieces of two
|
|
119
|
+
// different sentences.
|
|
120
|
+
const RE_THRESHOLD_ONLY = new RegExp(
|
|
121
|
+
'\\b(?:only|just|solely)\\b[^.;:!?]{0,60}?\\b(?:if|when|unless)\\b[^.;:!?]{0,45}?' + SEVERITY,
|
|
122
|
+
'g'
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// "unless / except when ... <severity>" — the same restriction stated from
|
|
126
|
+
// the exception side.
|
|
127
|
+
const RE_THRESHOLD_UNLESS = new RegExp(
|
|
128
|
+
'\\b(?:unless|except if|except when|save for|other than when)\\b[^.;:!?]{0,45}?' + SEVERITY,
|
|
129
|
+
'g'
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
const WINDOW_NEG = 45; // "not", "avoid", "no more" further away than this is a different clause
|
|
133
|
+
const WINDOW_OBJ = 40; // "flag the risk" / "raise the problem"
|
|
134
|
+
const WINDOW_REQ = 70; // "suppressing a finding requires an owner"
|
|
135
|
+
const WINDOW_THRESHOLD = 50; // "only ... if it's serious" far from the act is a different topic
|
|
136
|
+
|
|
137
|
+
function findAll(re, line) {
|
|
138
|
+
const out = [];
|
|
139
|
+
re.lastIndex = 0;
|
|
140
|
+
let m;
|
|
141
|
+
while ((m = re.exec(line))) out.push({ text: m[0], start: m.index, end: m.index + m[0].length });
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `RegExp.test` with the /g flag carries `lastIndex` between calls — a
|
|
146
|
+
// classic bug that makes a detector start lying after its first hit. Always
|
|
147
|
+
// zero it before and after.
|
|
148
|
+
function testWindow(re, snippet) {
|
|
149
|
+
re.lastIndex = 0;
|
|
150
|
+
const r = re.test(snippet);
|
|
151
|
+
re.lastIndex = 0;
|
|
152
|
+
return r;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Slices a line into sentences on strong punctuation, keeping each one's
|
|
156
|
+
// offset. The threshold family must judge WITHIN one sentence — otherwise
|
|
157
|
+
// "only publish if it's big. flag the risk" would match by proximity, which
|
|
158
|
+
// is exactly the defect this split exists to prevent: the sentence has to
|
|
159
|
+
// block on its own, without another sentence's suppressor carrying the verdict.
|
|
160
|
+
function sentences(line) {
|
|
161
|
+
const out = [];
|
|
162
|
+
let start = 0;
|
|
163
|
+
for (let i = 0; i <= line.length; i++) {
|
|
164
|
+
if (i === line.length || '.;:!?'.includes(line[i])) {
|
|
165
|
+
const text = line.slice(start, i);
|
|
166
|
+
if (text.trim()) out.push({ text, start, end: i });
|
|
167
|
+
start = i + 1;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
return out;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Judges ONE already-masked line. Returns the list of hits (family + excerpt),
|
|
174
|
+
// never just a boolean — the reader needs to know WHICH structure matched to
|
|
175
|
+
// know what to fix.
|
|
176
|
+
function findSuppression(maskedLine, rawLine) {
|
|
177
|
+
const line = maskedLine.toLowerCase();
|
|
178
|
+
const found = [];
|
|
179
|
+
|
|
180
|
+
const mark = (family, start, end, reason) => {
|
|
181
|
+
found.push({
|
|
182
|
+
family,
|
|
183
|
+
reason,
|
|
184
|
+
excerpt: rawLine.slice(Math.max(0, start - 20), Math.min(rawLine.length, end + 20)).trim(),
|
|
185
|
+
});
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// Family A: negator + direct honesty act.
|
|
189
|
+
for (const a of findAll(RE_ACT_DIRECT, line)) {
|
|
190
|
+
const before = line.slice(Math.max(0, a.start - WINDOW_NEG), a.start);
|
|
191
|
+
if (testWindow(RE_NEG, before)) {
|
|
192
|
+
mark('negated_act', a.start, a.end, `honesty act "${a.text}" under a negator`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Family B: negator + flag/warn/raise WITH an honesty object.
|
|
197
|
+
for (const a of findAll(RE_ACT_SIGNAL, line)) {
|
|
198
|
+
const before = line.slice(Math.max(0, a.start - WINDOW_NEG), a.start);
|
|
199
|
+
if (!testWindow(RE_NEG, before)) continue;
|
|
200
|
+
const near = line.slice(a.end, a.end + WINDOW_OBJ);
|
|
201
|
+
if (!testWindow(RE_OBJECT, near)) continue;
|
|
202
|
+
mark('negated_signal', a.start, a.end + WINDOW_OBJ, `"${a.text}" a problem/risk under a negator`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Family C: silencing verb + honesty object, NOT negated and NOT the
|
|
206
|
+
// subject of a requirement.
|
|
207
|
+
for (const s of findAll(RE_SILENCE, line)) {
|
|
208
|
+
const before = line.slice(Math.max(0, s.start - WINDOW_NEG), s.start);
|
|
209
|
+
if (testWindow(RE_NEG, before)) continue; // "never hide the risk" = a requirement of honesty
|
|
210
|
+
const near = line.slice(s.end, s.end + WINDOW_OBJ);
|
|
211
|
+
if (!testWindow(RE_OBJECT, near)) continue;
|
|
212
|
+
const after = line.slice(s.end, s.end + WINDOW_REQ);
|
|
213
|
+
if (testWindow(RE_REQUIREMENT, after)) continue; // "suppressing a finding requires an owner"
|
|
214
|
+
mark('silencing', s.start, s.end + WINDOW_OBJ, `"${s.text}" with a problem/risk object`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Family C2: a direct cut of the noun ("no more pushback"). The
|
|
218
|
+
// construction is already the order; no verb needed.
|
|
219
|
+
for (const c of findAll(RE_CUT_NOUN, line)) {
|
|
220
|
+
mark('noun_cut', c.start, c.end, `"${c.text}"`);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Family E: honesty act restricted by a severity threshold. The act can be
|
|
224
|
+
// directed ("push back", "flag it") or an object ("the criticism", "the
|
|
225
|
+
// caveats"); what blocks is the threshold's company in the SAME sentence.
|
|
226
|
+
for (const sent of sentences(line)) {
|
|
227
|
+
const thresholds = findAll(RE_THRESHOLD_ONLY, sent.text).concat(findAll(RE_THRESHOLD_UNLESS, sent.text));
|
|
228
|
+
for (const t of thresholds) {
|
|
229
|
+
// The act has to be NEAR the threshold, not just in the same sentence:
|
|
230
|
+
// without this window, a long sentence defending the gate ("only valid
|
|
231
|
+
// if CI actually runs..., not the absence of an error") would match on
|
|
232
|
+
// "error" at the far end — the same class of false positive that broke
|
|
233
|
+
// an earlier version of this detector.
|
|
234
|
+
const near = sent.text.slice(Math.max(0, t.start - WINDOW_THRESHOLD), t.end + WINDOW_THRESHOLD);
|
|
235
|
+
const hasAct =
|
|
236
|
+
testWindow(RE_ACT_DIRECT, near) || testWindow(RE_ACT_SIGNAL, near) || testWindow(RE_OBJECT, near);
|
|
237
|
+
if (!hasAct) continue;
|
|
238
|
+
mark('severity_threshold', sent.start + t.start, sent.start + t.end, 'honesty act restricted to a severity threshold');
|
|
239
|
+
break; // one hit per sentence is enough for the verdict
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Family D: forced agreement.
|
|
244
|
+
for (const f of findAll(RE_FORCED, line)) {
|
|
245
|
+
mark('forced_agreement', f.start, f.end, `"${f.text}"`);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return found;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Text under judgment = frontmatter `description` + body. A suppression fits
|
|
252
|
+
// in the description as much as the body, and the description is what loads
|
|
253
|
+
// at boot.
|
|
254
|
+
function judgeableText(file) {
|
|
255
|
+
const desc =
|
|
256
|
+
file.frontmatter && file.frontmatter.data && typeof file.frontmatter.data.description === 'string'
|
|
257
|
+
? file.frontmatter.data.description
|
|
258
|
+
: '';
|
|
259
|
+
return (desc ? desc + '\n' : '') + String(file.body || '');
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Judges ONE already-collected file. Pure: struct in, verdict out.
|
|
263
|
+
function judgeFile(file) {
|
|
264
|
+
const text = judgeableText(file);
|
|
265
|
+
const rawLines = text.split('\n');
|
|
266
|
+
// Mask the WHOLE text before slicing into lines: a code span that opens on
|
|
267
|
+
// one line and closes on the next only shows up correctly with the whole
|
|
268
|
+
// document in hand.
|
|
269
|
+
const maskedLines = maskCode(text).split('\n');
|
|
270
|
+
const occurrences = [];
|
|
271
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
272
|
+
for (const a of findSuppression(maskedLines[i], rawLines[i])) {
|
|
273
|
+
occurrences.push({ line: i + 1, ...a });
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
if (occurrences.length) return { name: file.name, state: 'suppression', occurrences };
|
|
277
|
+
return { name: file.name, state: 'ok', occurrences: [] };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function auditHonesty(files) {
|
|
281
|
+
return files.map(judgeFile);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Summary from the SAME list the gate reads, never a second parallel count.
|
|
285
|
+
function summarizeHonesty(lines) {
|
|
286
|
+
const suppressions = lines.filter((l) => l.state === 'suppression');
|
|
287
|
+
return {
|
|
288
|
+
total: lines.length,
|
|
289
|
+
ok: lines.length - suppressions.length,
|
|
290
|
+
suppressions: suppressions.length,
|
|
291
|
+
failed: suppressions.length,
|
|
292
|
+
namesSuppression: suppressions.map((l) => l.name),
|
|
293
|
+
occurrences: suppressions.reduce((n, l) => n + l.occurrences.length, 0),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
module.exports = {
|
|
298
|
+
findSuppression,
|
|
299
|
+
judgeableText,
|
|
300
|
+
judgeFile,
|
|
301
|
+
auditHonesty,
|
|
302
|
+
summarizeHonesty,
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// --- CLI ---------------------------------------------------------------
|
|
306
|
+
// Usage: node lib/detectors/honesty.js [--json]
|
|
307
|
+
// Exit: 0 nothing found | 1 a suppression instruction found | 2 archive not
|
|
308
|
+
// found (not checked).
|
|
309
|
+
if (require.main === module) {
|
|
310
|
+
const json = process.argv.includes('--json');
|
|
311
|
+
const { dir, source } = locateArchive();
|
|
312
|
+
|
|
313
|
+
if (!fs.existsSync(dir)) {
|
|
314
|
+
const msg = `HONESTY: NOT CHECKED - ${dir} not found (path from ${source}).`;
|
|
315
|
+
if (json) console.log(JSON.stringify({ dir, source, state: 'absent', summary: null, lines: null }, null, 2));
|
|
316
|
+
else console.log(msg);
|
|
317
|
+
console.error(msg);
|
|
318
|
+
process.exit(2);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
const r = readCollection(dir);
|
|
322
|
+
const lines = auditHonesty(r.files);
|
|
323
|
+
const summary = summarizeHonesty(lines);
|
|
324
|
+
const state = summary.failed > 0 ? 'failed' : 'ok';
|
|
325
|
+
|
|
326
|
+
if (json) {
|
|
327
|
+
console.log(
|
|
328
|
+
JSON.stringify(
|
|
329
|
+
{ dir, source, state, summary, lines: lines.filter((l) => l.state === 'suppression') },
|
|
330
|
+
null,
|
|
331
|
+
2
|
|
332
|
+
)
|
|
333
|
+
);
|
|
334
|
+
} else {
|
|
335
|
+
console.log(`archive: ${dir} (${source})`);
|
|
336
|
+
console.log(`total: ${summary.total} ok: ${summary.ok} suppression found: ${summary.suppressions}`);
|
|
337
|
+
if (summary.failed) {
|
|
338
|
+
console.log(`\nHONESTY: ${summary.failed} file(s) with a suppression instruction.`);
|
|
339
|
+
for (const l of lines.filter((x) => x.state === 'suppression')) {
|
|
340
|
+
for (const o of l.occurrences) {
|
|
341
|
+
console.log(` ${l.name}:${o.line} [${o.family}] ${o.reason}`);
|
|
342
|
+
console.log(` ... ${o.excerpt} ...`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
console.log('\nThis is a FINDING to report, not something this tool edits on its own.');
|
|
346
|
+
} else {
|
|
347
|
+
console.log('\nHONESTY: nothing found.');
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
process.exit(summary.failed > 0 ? 1 : 0);
|
|
351
|
+
}
|