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,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
+ }