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,668 @@
1
+ 'use strict';
2
+ // PER-LINE PROVENANCE in the memory archive.
3
+ //
4
+ // The failure mode this detector exists to catch: someone's instruction, a
5
+ // number produced by an actual command, and the model's own guess end up
6
+ // indistinguishable in the same paragraph, and the guess gets cited later as
7
+ // if it had been measured. The convention is one marker per claim, three
8
+ // values (configurable, default English words):
9
+ //
10
+ // (stated) -- a person said so, this session or an earlier one
11
+ // (measured) -- came out of a command that was actually run
12
+ // (inferred) -- the model's hypothesis; not verified
13
+ //
14
+ // -- THE BOUNDARY (what needs a marker) LIVES IN ONE PLACE ------------------
15
+ //
16
+ // `classifyLine` is that place. Reading "every line of content carries a
17
+ // marker" literally would make the file unreadable and fail the gate on
18
+ // plain explanatory prose — which isn't the problem this exists to solve.
19
+ // A marker is required only on a line that ASSERTS A VERIFIABLE FACT: a
20
+ // number or count, a date, a URL, or an id/hash. Headings, blank lines,
21
+ // pointers (an item that only links to another file/note/ticket), rule
22
+ // separators and blockquotes are exempt by design.
23
+ //
24
+ // A NUMBERED LIST MARKER is also exempt: `1. **Note:** ...` is an enumerator,
25
+ // not an assertion. The exemption strips only the marker itself — a number
26
+ // asserted after it still needs a marker.
27
+ //
28
+ // -- MENTION IS NOT USE ------------------------------------------------------
29
+ //
30
+ // Backticks and fenced blocks are quotation: they're excluded from judgment
31
+ // both ways. A whole line inside a code span doesn't need a marker, and a
32
+ // marker quoted inside backticks doesn't count as a real marker. Masking is
33
+ // shared with lib/mask-code.js, not reimplemented here.
34
+ //
35
+ // -- CUT BY LINE, NOT BY FILE -------------------------------------------------
36
+ //
37
+ // Judging by FILE (any file touched after the convention's start date enters
38
+ // judgment whole) drags an entire old file into the gate the moment someone
39
+ // appends three lines to it — the archive is append-only by nature. The cut
40
+ // here is by LINE: a CONTENT BASELINE (a JSON file of per-file line hashes)
41
+ // records which lines have already been seen. A line whose hash isn't in
42
+ // that set is "new" and gets judged; a line already in the baseline is out
43
+ // of scope (declared as a backlog, not silently forgiven).
44
+ //
45
+ // The baseline's per-file index also gets consulted AS ONE POOL across the
46
+ // whole archive, not per file: renaming a note or merging duplicates
47
+ // shouldn't turn byte-identical lines into "new" lines just because they
48
+ // moved.
49
+ //
50
+ // A file that declares a `provenance:` field in its frontmatter is judged
51
+ // whole — it opted into the convention explicitly, so the baseline doesn't
52
+ // exempt it.
53
+ //
54
+ // -- THE UNIT OF JUDGMENT IS THE SENTENCE, NOT THE PHYSICAL LINE -------------
55
+ //
56
+ // Markdown prose wraps wherever the paragraph happens to fit, and the marker
57
+ // conventionally comes at the END of the sentence — so a three-line sentence
58
+ // with the marker on line three would otherwise fail lines one and two for a
59
+ // claim that IS marked. The marker is searched for across the whole sentence
60
+ // (a run of non-blank lines, sliced into sentences by terminal punctuation
61
+ // and by the marker itself, since the marker also closes the sentence it
62
+ // covers). Each sentence keeps the LINE-level pieces that composed it, so an
63
+ // accusation still points at the physical line to fix, even though the
64
+ // verdict is per sentence.
65
+ //
66
+ // -- REWRITING THE BASELINE DOES NOT FORGIVE ANYTHING ------------------------
67
+ //
68
+ // Two guards, both mandatory:
69
+ // 1. `--record-baseline` is INCREMENTAL. It only ADDS the hash of a line
70
+ // the detector would NOT flag — prose, headings, pointers, or an
71
+ // already-marked claim. A line asserting an unmarked fact never enters
72
+ // the baseline, no matter how many times it's rewritten.
73
+ // 2. The from-scratch write is a separate, explicit mode (`--genesis`):
74
+ // the only one that writes the WHOLE baseline unfiltered. It exists
75
+ // because the very first write needs one.
76
+ //
77
+ // A third guard belongs in CI, not here: reject a PR where the baseline file
78
+ // changed but wasn't committed on purpose, so a rewrite is visible in the
79
+ // diff instead of hidden inside a green run. `auditBaselineGit` is what that
80
+ // check calls.
81
+ //
82
+ // Exit: 0 ok | 1 failed | 2 not checked (archive or baseline missing)
83
+
84
+ const fs = require('fs');
85
+ const path = require('path');
86
+ const crypto = require('crypto');
87
+ const { readCollection } = require('../collector');
88
+ const { locateArchive } = require('../locate-archive');
89
+ const { maskCode } = require('../mask-code');
90
+
91
+ const DEFAULT_MARKERS = ['stated', 'measured', 'inferred'];
92
+
93
+ function markerRegex(markers) {
94
+ return new RegExp('\\((' + markers.map(escapeRegex).join('|') + ')\\)', 'i');
95
+ }
96
+ function escapeRegex(s) {
97
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
98
+ }
99
+
100
+ // --- fact-verifiable triggers ------------------------------------------
101
+ const RE_DATE = /\b(\d{1,2}\/\d{1,2}\/\d{2,4}|\d{4}-\d{2}-\d{2})\b/;
102
+ const RE_URL = /\bhttps?:\/\/\S+/i;
103
+ // Hash: 7..40 hex chars WITH at least one letter and one digit. Without both
104
+ // assertions, an 8-digit date and a plain word would each false-match one of
105
+ // the two classes this is meant to catch.
106
+ const RE_ID = /\b(?=[0-9a-f]{7,40}\b)(?=[0-9a-f]*[a-f])(?=[0-9a-f]*[0-9])[0-9a-f]{7,40}\b/i;
107
+ const RE_NUMBER = /\d/;
108
+
109
+ // --- shapes that are NOT a factual assertion -----------------------------
110
+ const RE_HEADING = /^\s{0,3}#{1,6}\s/;
111
+ const RE_RULE = /^\s*(?:[-*_]\s*){3,}$/;
112
+ const RE_TABLE_SEPARATOR = /^\s*\|?[\s:|-]*\|[\s:|-]*$/;
113
+ const RE_BULLET = /^\s*(?:[-*+]|\d+[.)])\s+/;
114
+ // Numbered list marker: `1. `, `2) `, `(3) `. Only the enumerator itself is
115
+ // stripped from the line before the triggers run; a number asserted after it
116
+ // still requires a marker.
117
+ const RE_ENUMERATOR = /^\s*\(?\d+[.)]\s/;
118
+
119
+ // Text left after stripping what only POINTS: list bullet, wikilink,
120
+ // Markdown link (text and target), ticket reference (e.g. #42) and checkbox.
121
+ // Used only to decide whether the line is a pointer.
122
+ function withoutPointers(line) {
123
+ return String(line)
124
+ .replace(RE_BULLET, ' ')
125
+ .replace(/^\s*\[[ xX]\]\s*/, ' ')
126
+ .replace(/\[\[[^\]]*\]\]/g, ' ')
127
+ .replace(/\[[^\]]*\]\([^)]*\)/g, ' ')
128
+ .replace(/#\d+/g, ' ');
129
+ }
130
+
131
+ function findMarker(maskedLine, markers) {
132
+ const m = markerRegex(markers).exec(String(maskedLine));
133
+ return m ? m[1].toLowerCase() : null;
134
+ }
135
+
136
+ // The boundary, in one place. Takes the masked line (quotation already
137
+ // blanked) and returns the shape verdict: does it need a marker, and why.
138
+ function classifyLine(maskedLine, rawLine) {
139
+ const raw = rawLine === undefined ? maskedLine : rawLine;
140
+ const masked = String(maskedLine);
141
+
142
+ if (!masked.trim()) {
143
+ const reason = String(raw).trim() ? 'quotation' : 'blank';
144
+ return { needsMarker: false, reason, triggers: [] };
145
+ }
146
+ if (RE_HEADING.test(masked)) return { needsMarker: false, reason: 'heading', triggers: [] };
147
+ if (RE_RULE.test(masked)) return { needsMarker: false, reason: 'separator', triggers: [] };
148
+ if (RE_TABLE_SEPARATOR.test(masked)) return { needsMarker: false, reason: 'separator', triggers: [] };
149
+
150
+ // Pointer: once what only points is stripped, no assertion is left (fewer
151
+ // than 3 alphanumeric characters). `- [[note]]` and `- [ADR](docs/adr.md)`
152
+ // are map, not territory.
153
+ const rest = withoutPointers(masked);
154
+ if ((rest.match(/[0-9a-zA-ZÀ-ÿ]/g) || []).length < 3) {
155
+ return { needsMarker: false, reason: 'pointer', triggers: [] };
156
+ }
157
+
158
+ const triggers = [];
159
+ // A ticket reference is a pointer, not a count; a numbered-list marker is
160
+ // an enumerator, not a count. Both are stripped BEFORE the trigger tests.
161
+ const withoutTicket = masked.replace(RE_ENUMERATOR, ' ').replace(/#\d+/g, ' ');
162
+ if (RE_DATE.test(withoutTicket)) triggers.push('date');
163
+ if (RE_URL.test(masked)) triggers.push('url');
164
+ if (RE_ID.test(withoutTicket)) triggers.push('id');
165
+ if (RE_NUMBER.test(withoutTicket)) triggers.push('number');
166
+
167
+ if (!triggers.length) return { needsMarker: false, reason: 'prose', triggers: [] };
168
+ return { needsMarker: true, reason: 'verifiable_fact', triggers };
169
+ }
170
+
171
+ // --- the SENTENCE: the unit of judgment ------------------------------------
172
+
173
+ const RE_QUOTE_START = /^\s{0,3}>/;
174
+ const RE_TABLE_LINE = /^\s*\|/;
175
+
176
+ // Builds the "sentence ends here" regex from the configured markers: final
177
+ // punctuation, OR the marker itself (which closes the sentence it covers).
178
+ function sentenceEndRegex(markers) {
179
+ const alt = markers.map(escapeRegex).join('|');
180
+ return new RegExp('(?:[.!?…]+|\\((?:' + alt + ')\\))[)"\'`*_\\]]*(?=\\s|$)', 'gi');
181
+ }
182
+
183
+ // Slices lines into TEXT BLOCKS: a run of non-blank lines. A blank line
184
+ // closes the block; a heading or separator becomes its own one-line block
185
+ // (it doesn't run into the following prose); a list item, blockquote or
186
+ // table row opens a new block but accepts continuation (the next indented
187
+ // line is the same sentence).
188
+ function textBlocks(maskedLines) {
189
+ const blocks = [];
190
+ let current = null;
191
+ for (let i = 0; i < maskedLines.length; i++) {
192
+ const l = String(maskedLines[i]);
193
+ if (!l.trim()) {
194
+ current = null;
195
+ continue;
196
+ }
197
+ if (RE_HEADING.test(l) || RE_RULE.test(l) || RE_TABLE_SEPARATOR.test(l)) {
198
+ blocks.push([i]);
199
+ current = null;
200
+ continue;
201
+ }
202
+ if (!current || RE_BULLET.test(l) || RE_QUOTE_START.test(l) || RE_TABLE_LINE.test(l)) {
203
+ current = [i];
204
+ blocks.push(current);
205
+ continue;
206
+ }
207
+ current.push(i);
208
+ }
209
+ return blocks;
210
+ }
211
+
212
+ // Slices ONE block into sentences. Returns, per sentence, the whole text
213
+ // (where the marker is searched for) and the line-level PIECES that make it
214
+ // up (where triggers are measured, and where the flagged line number comes
215
+ // from). Pure: indices and masked lines in, structure out.
216
+ function sentencesOfBlock(indices, maskedLines, markers) {
217
+ const parts = indices.map((i) => String(maskedLines[i]));
218
+ const text = parts.join('\n');
219
+
220
+ const starts = [];
221
+ let acc = 0;
222
+ for (const p of parts) {
223
+ starts.push(acc);
224
+ acc += p.length + 1;
225
+ }
226
+
227
+ const re = sentenceEndRegex(markers);
228
+ const bounds = [];
229
+ let m;
230
+ while ((m = re.exec(text))) {
231
+ bounds.push(m.index + m[0].length);
232
+ if (re.lastIndex <= m.index) re.lastIndex = m.index + 1;
233
+ }
234
+ if (!bounds.length || bounds[bounds.length - 1] < text.length) bounds.push(text.length);
235
+
236
+ const sentences = [];
237
+ let from = 0;
238
+ for (const to of bounds) {
239
+ if (to <= from) continue;
240
+ const raw = text.slice(from, to);
241
+ if (raw.trim()) {
242
+ const pieces = [];
243
+ for (let k = 0; k < indices.length; k++) {
244
+ const a = Math.max(from, starts[k]);
245
+ const b = Math.min(to, starts[k] + parts[k].length);
246
+ if (b > a) pieces.push({ line: indices[k], text: text.slice(a, b) });
247
+ }
248
+ sentences.push({ text: raw, pieces });
249
+ }
250
+ from = to;
251
+ }
252
+ return sentences;
253
+ }
254
+
255
+ // --- content baseline: what counts as "new line" ---------------------------
256
+
257
+ // Line hash. Normalizes ONLY leading/trailing whitespace: re-indenting isn't
258
+ // a new line, changing a word is.
259
+ function hashLine(line) {
260
+ return crypto.createHash('sha1').update(String(line).trim(), 'utf8').digest('hex').slice(0, 12);
261
+ }
262
+
263
+ function baselineForFile(baseline, name) {
264
+ if (!baseline || !Object.prototype.hasOwnProperty.call(baseline, name)) return null;
265
+ const list = baseline[name];
266
+ return new Set(Array.isArray(list) ? list : []);
267
+ }
268
+
269
+ // What the gate actually consults: every hash in the baseline, from any
270
+ // file. A line that moved between files carries its exemption with it; new
271
+ // text stays judged wherever it lands. `null` only when there is no baseline
272
+ // at all — an empty per-file entry is different: there, everything is new,
273
+ // which is the intended state right after genesis.
274
+ function baselineContentSet(baseline) {
275
+ if (!baseline || typeof baseline !== 'object') return null;
276
+ const all = new Set();
277
+ for (const list of Object.values(baseline)) {
278
+ if (!Array.isArray(list)) continue;
279
+ for (const h of list) all.add(h);
280
+ }
281
+ return all;
282
+ }
283
+
284
+ function baselineEnvelope(map, mode) {
285
+ return {
286
+ generated: new Date().toISOString(),
287
+ mode,
288
+ note:
289
+ 'Content baseline for the per-line provenance gate. Each entry is the set of content-line ' +
290
+ 'hashes already seen in that file; a line outside the set is new and enters the gate. ' +
291
+ (mode === 'genesis'
292
+ ? 'GENESIS (--genesis): written WITHOUT filtering, exempts everything that existed at write ' +
293
+ 'time. The only mode that does not filter — commit this file right after writing it so the ' +
294
+ 'exemption is visible in the diff.'
295
+ : 'INCREMENTAL (--record-baseline): only a line the detector would NOT flag was added. A line ' +
296
+ 'asserting an unmarked verifiable fact never enters, and stays flagged after any rewrite.'),
297
+ files: map,
298
+ };
299
+ }
300
+
301
+ // GENESIS, from already-collected files. Pure: records in, disk object out.
302
+ // Doesn't filter anything — that's why it's a separate, non-default mode.
303
+ function generateBaseline(files) {
304
+ const map = {};
305
+ for (const f of files) {
306
+ const seen = [];
307
+ const set = new Set();
308
+ for (const l of String(f.body || '').split('\n')) {
309
+ if (!String(l).trim()) continue;
310
+ const h = hashLine(l);
311
+ if (set.has(h)) continue;
312
+ set.add(h);
313
+ seen.push(h);
314
+ }
315
+ map[f.name] = seen;
316
+ }
317
+ return baselineEnvelope(map, 'genesis');
318
+ }
319
+
320
+ // INCREMENTAL recording: only adds the hash of a line the detector would not
321
+ // flag — "would not flag" comes from the SAME judgment the gate runs
322
+ // (`judgeFile` against the current baseline), never a second parallel rule.
323
+ // A flagged line stays out and stays dirty. Never shrinks: a hash that was
324
+ // already there stays, and a file no longer in the archive keeps its entry.
325
+ function generateIncrementalBaseline(files, currentBaseline) {
326
+ const current = currentBaseline && typeof currentBaseline === 'object' ? currentBaseline : {};
327
+ const map = {};
328
+ let added = 0;
329
+ let rejected = 0;
330
+ const rejectedByFile = {};
331
+
332
+ for (const f of files) {
333
+ const before = Array.isArray(current[f.name]) ? current[f.name].slice() : [];
334
+ const set = new Set(before);
335
+ const seen = before;
336
+ const result = judgeFile(f, { baseline: current });
337
+ const flagged = new Set(result.occurrences.map((o) => o.line - 1));
338
+ const lines = String(f.body || '').split('\n');
339
+ let rejectedHere = 0;
340
+ for (let i = 0; i < lines.length; i++) {
341
+ if (!String(lines[i]).trim()) continue;
342
+ const h = hashLine(lines[i]);
343
+ if (set.has(h)) continue;
344
+ if (flagged.has(i)) {
345
+ rejected++;
346
+ rejectedHere++;
347
+ continue;
348
+ }
349
+ set.add(h);
350
+ seen.push(h);
351
+ added++;
352
+ }
353
+ if (rejectedHere) rejectedByFile[f.name] = rejectedHere;
354
+ map[f.name] = seen;
355
+ }
356
+
357
+ // The baseline never shrinks: a file no longer in the archive stays as it was.
358
+ for (const name of Object.keys(current)) {
359
+ if (!Object.prototype.hasOwnProperty.call(map, name)) map[name] = current[name];
360
+ }
361
+
362
+ return { doc: baselineEnvelope(map, 'incremental'), added, rejected, rejectedByFile };
363
+ }
364
+
365
+ // --- the guard over the baseline file itself --------------------------------
366
+
367
+ // A baseline changed and NOT committed is an invisible rewrite. This answers
368
+ // one thing: is the baseline file clean in git?
369
+ //
370
+ // `run` is injectable on purpose — tests exercise all four paths (no git, not
371
+ // a clone, clean, dirty) without depending on the machine's git state. Real
372
+ // usage falls back to `spawnSync`. Without git, outside a clone, or on a git
373
+ // failure, the verdict is NOT CHECKED; never "ok".
374
+ function auditBaselineGit(opts) {
375
+ const o = opts || {};
376
+ const file = path.resolve(o.file || o.baselineFile);
377
+ const cwd = o.cwd || path.dirname(file);
378
+ const exists = typeof o.exists === 'function' ? o.exists : (p) => fs.existsSync(p);
379
+ const run =
380
+ o.run ||
381
+ ((args, dir) =>
382
+ require('child_process').spawnSync('git', args, { cwd: dir, encoding: 'utf8', timeout: 30000 }));
383
+
384
+ const base = { file };
385
+
386
+ if (!exists(file)) {
387
+ return { ...base, state: 'not_checked', reason: 'baseline_absent', detail: `${file} does not exist: nothing to check in git.` };
388
+ }
389
+
390
+ const attempt = (args) => {
391
+ try {
392
+ const r = run(args, cwd);
393
+ if (!r || r.error) return { error: (r && r.error && r.error.message) || 'git did not run' };
394
+ return r;
395
+ } catch (e) {
396
+ return { error: (e && e.message) || String(e) };
397
+ }
398
+ };
399
+
400
+ const inside = attempt(['rev-parse', '--is-inside-work-tree']);
401
+ if (inside.error) return { ...base, state: 'not_checked', reason: 'git_missing', detail: inside.error };
402
+ if (inside.status !== 0 || String(inside.stdout || '').trim() !== 'true') {
403
+ return { ...base, state: 'not_checked', reason: 'not_a_clone', detail: `${cwd} is not a git clone: "changed and uncommitted" doesn't apply.` };
404
+ }
405
+
406
+ const st = attempt(['status', '--porcelain', '--', file]);
407
+ if (st.error || st.status !== 0) {
408
+ return { ...base, state: 'not_checked', reason: 'git_failed', detail: st.error || `git status exited ${st.status}: ${String(st.stderr || '').trim()}` };
409
+ }
410
+
411
+ const pending = String(st.stdout || '').split('\n').map((l) => l.trim()).filter(Boolean);
412
+ if (pending.length) return { ...base, state: 'dirty', pending };
413
+ return { ...base, state: 'ok', pending: [] };
414
+ }
415
+
416
+ // --- cut: who enters judgment -----------------------------------------------
417
+
418
+ // A file that declares `provenance:` in its frontmatter opted into the
419
+ // convention: it's judged WHOLE, the baseline doesn't exempt it.
420
+ function declaresProvenance(file) {
421
+ const data = (file && file.frontmatter && file.frontmatter.data) || {};
422
+ return typeof data.provenance === 'string' && data.provenance.trim().length > 0;
423
+ }
424
+
425
+ // --- judgment ----------------------------------------------------------------
426
+
427
+ // Judges ONE already-collected file. Pure: struct in, verdict out.
428
+ //
429
+ // Only the BODY is judged. `description` is a lookup hook, not a factual
430
+ // claim: it's a pointer by definition, and marking it would just be noise in
431
+ // what loads at boot.
432
+ function judgeFile(file, opts) {
433
+ const o = opts || {};
434
+ const baseline = o.baseline || null;
435
+ const markers = o.markers || DEFAULT_MARKERS;
436
+ const declared = declaresProvenance(file);
437
+ const seen = declared ? null : baselineContentSet(baseline);
438
+ const via = declared
439
+ ? 'provenance field'
440
+ : seen
441
+ ? 'content baseline'
442
+ : 'no baseline (judged whole)';
443
+
444
+ const text = String(file.body || '');
445
+ const lines = text.split('\n');
446
+ // Mask the WHOLE text before slicing: a code span can cross a line break.
447
+ const masked = maskCode(text).split('\n');
448
+
449
+ let linesInScope = 0;
450
+ let linesOutOfScope = 0;
451
+ const fresh = new Set();
452
+ for (let i = 0; i < lines.length; i++) {
453
+ const raw = lines[i];
454
+ if (!String(raw).trim()) continue; // blank line counts toward neither
455
+ if (seen && seen.has(hashLine(raw))) {
456
+ linesOutOfScope++;
457
+ continue;
458
+ }
459
+ linesInScope++;
460
+ fresh.add(i);
461
+ }
462
+
463
+ // Judged by SENTENCE: the marker is looked for across the whole sentence;
464
+ // the trigger and the flagged line number come from the piece. One line
465
+ // can belong to two sentences, and only one of them carry a marker.
466
+ const byLine = new Map();
467
+ for (const block of textBlocks(masked)) {
468
+ for (const sentence of sentencesOfBlock(block, masked, markers)) {
469
+ if (findMarker(sentence.text, markers) !== null) continue;
470
+ for (const piece of sentence.pieces) {
471
+ if (!fresh.has(piece.line)) continue; // out of scope
472
+ const raw = lines[piece.line];
473
+ const c = classifyLine(piece.text, raw);
474
+ if (!c.needsMarker) continue;
475
+ const already = byLine.get(piece.line);
476
+ if (already) {
477
+ for (const t of c.triggers) if (!already.triggers.includes(t)) already.triggers.push(t);
478
+ continue;
479
+ }
480
+ byLine.set(piece.line, {
481
+ line: piece.line + 1,
482
+ reason: c.reason,
483
+ triggers: c.triggers.slice(),
484
+ excerpt: String(raw).trim().slice(0, 120),
485
+ });
486
+ }
487
+ }
488
+ }
489
+ const occurrences = [...byLine.keys()].sort((a, b) => a - b).map((k) => byLine.get(k));
490
+
491
+ const base = { name: file.name, via, linesInScope, linesOutOfScope };
492
+ if (occurrences.length) return { ...base, state: 'unmarked', occurrences };
493
+ if (!linesInScope) return { ...base, state: 'no_new_lines', occurrences: [] };
494
+ return { ...base, state: 'ok', occurrences: [] };
495
+ }
496
+
497
+ function auditProvenance(files, opts) {
498
+ return files.map((f) => judgeFile(f, opts));
499
+ }
500
+
501
+ // Summary from the SAME list the gate reads, never a second parallel count.
502
+ function summarizeProvenance(lines) {
503
+ const unmarked = lines.filter((l) => l.state === 'unmarked');
504
+ const noNewLines = lines.filter((l) => l.state === 'no_new_lines');
505
+ const withNewLines = lines.filter((l) => l.state !== 'no_new_lines');
506
+ return {
507
+ total: lines.length,
508
+ filesWithNewLines: withNewLines.length,
509
+ noNewLines: noNewLines.length,
510
+ linesInScope: lines.reduce((n, l) => n + (l.linesInScope || 0), 0),
511
+ linesOutOfScope: lines.reduce((n, l) => n + (l.linesOutOfScope || 0), 0),
512
+ ok: withNewLines.length - unmarked.length,
513
+ unmarked: unmarked.length,
514
+ failed: unmarked.length,
515
+ namesUnmarked: unmarked.map((l) => l.name),
516
+ occurrences: unmarked.reduce((n, l) => n + l.occurrences.length, 0),
517
+ };
518
+ }
519
+
520
+ module.exports = {
521
+ DEFAULT_MARKERS,
522
+ markerRegex,
523
+ withoutPointers,
524
+ findMarker,
525
+ classifyLine,
526
+ textBlocks,
527
+ sentencesOfBlock,
528
+ hashLine,
529
+ baselineForFile,
530
+ baselineContentSet,
531
+ generateBaseline,
532
+ generateIncrementalBaseline,
533
+ auditBaselineGit,
534
+ declaresProvenance,
535
+ judgeFile,
536
+ auditProvenance,
537
+ summarizeProvenance,
538
+ };
539
+
540
+ // --- CLI ---------------------------------------------------------------
541
+ // Usage:
542
+ // node lib/detectors/provenance.js [--json]
543
+ // node lib/detectors/provenance.js --record-baseline
544
+ // node lib/detectors/provenance.js --genesis
545
+ // node lib/detectors/provenance.js --audit-baseline
546
+ // Exit: 0 nothing flagged | 1 some new line unmarked | 2 NOT CHECKED (archive
547
+ // or baseline missing).
548
+ if (require.main === module) {
549
+ const { loadConfig } = require('../config');
550
+ const json = process.argv.includes('--json');
551
+ const record = process.argv.includes('--record-baseline');
552
+ const genesis = process.argv.includes('--genesis');
553
+ const auditBaseline = process.argv.includes('--audit-baseline');
554
+ const { config } = loadConfig();
555
+ const baselineFile = process.env.MEMORY_LINT_BASELINE ||
556
+ path.resolve(process.cwd(), config.provenance.baselineFile);
557
+ const markers = (config.provenance && config.provenance.markers) || DEFAULT_MARKERS;
558
+
559
+ if (auditBaseline) {
560
+ const g = auditBaselineGit({ file: baselineFile });
561
+ const code = g.state === 'ok' ? 0 : g.state === 'dirty' ? 1 : 2;
562
+ const msg =
563
+ g.state === 'ok'
564
+ ? `PROVENANCE: baseline ${g.file} is clean in git.`
565
+ : g.state === 'dirty'
566
+ ? `PROVENANCE: BASELINE CHANGED AND NOT COMMITTED - ${g.file}\n` +
567
+ g.pending.map((p) => ` ${p}`).join('\n') +
568
+ '\nCommit the change (or `git checkout -- <file>` if it was unintentional).'
569
+ : `PROVENANCE: NOT CHECKED (${g.reason}) - ${g.detail}`;
570
+ if (json) console.log(JSON.stringify({ baseline: g.file, state: g.state, reason: g.reason || null, pending: g.pending || null, detail: g.detail || null }, null, 2));
571
+ else console.log(msg);
572
+ if (code !== 0) console.error(msg);
573
+ process.exit(code);
574
+ }
575
+
576
+ const { dir, source } = locateArchive();
577
+ const notChecked = (state, msg) => {
578
+ if (json) console.log(JSON.stringify({ dir, source, baseline: baselineFile, state, summary: null, lines: null }, null, 2));
579
+ else console.log(msg);
580
+ console.error(msg);
581
+ process.exit(2);
582
+ };
583
+
584
+ if (!fs.existsSync(dir)) {
585
+ notChecked('absent', `PROVENANCE: ARCHIVE NOT CHECKED - ${dir} not found (path from ${source}).`);
586
+ }
587
+
588
+ const r = readCollection(dir);
589
+
590
+ if (genesis) {
591
+ const b = generateBaseline(r.files);
592
+ fs.writeFileSync(baselineFile, JSON.stringify(b, null, 2) + '\n', 'utf8');
593
+ const lineCount = Object.values(b.files).reduce((n, l) => n + l.length, 0);
594
+ const msg = `PROVENANCE: GENESIS written to ${baselineFile} - ${r.files.length} file(s), ${lineCount} line(s) exempted, unfiltered. Commit this write.`;
595
+ if (json) console.log(JSON.stringify({ dir, baseline: baselineFile, state: 'genesis_written', mode: 'genesis', files: r.files.length, lines: lineCount }, null, 2));
596
+ else console.log(msg);
597
+ process.exit(0);
598
+ }
599
+
600
+ if (record) {
601
+ let current = {};
602
+ if (fs.existsSync(baselineFile)) {
603
+ try {
604
+ current = JSON.parse(fs.readFileSync(baselineFile, 'utf8')).files || {};
605
+ } catch (e) {
606
+ notChecked('baseline_unreadable', `PROVENANCE: NOT CHECKED - baseline ${baselineFile} is unreadable. Fix the JSON or restore it from git.`);
607
+ }
608
+ }
609
+ const { doc, added, rejected, rejectedByFile } = generateIncrementalBaseline(r.files, current);
610
+ fs.writeFileSync(baselineFile, JSON.stringify(doc, null, 2) + '\n', 'utf8');
611
+ const lineCount = Object.values(doc.files).reduce((n, l) => n + l.length, 0);
612
+ const msg =
613
+ `PROVENANCE: incremental baseline written to ${baselineFile} - ${r.files.length} file(s), ` +
614
+ `${lineCount} line(s) exempt total: +${added} added, ${rejected} REJECTED for asserting a ` +
615
+ `verifiable fact without a marker.` +
616
+ (rejected
617
+ ? `\nRejected lines stay in the gate - mark the source: (${markers.join(') / (')}).\n` +
618
+ Object.entries(rejectedByFile).map(([n, q]) => ` ${n}: ${q} line(s)`).join('\n')
619
+ : '') +
620
+ `\nCommit this rewrite.`;
621
+ if (json) console.log(JSON.stringify({ dir, baseline: baselineFile, state: 'baseline_written', mode: 'incremental', files: r.files.length, lines: lineCount, added, rejected, rejectedByFile }, null, 2));
622
+ else console.log(msg);
623
+ process.exit(0);
624
+ }
625
+
626
+ if (!fs.existsSync(baselineFile)) {
627
+ notChecked('baseline_absent', `PROVENANCE: NOT CHECKED - baseline ${baselineFile} not found. Run --genesis once to create it.`);
628
+ }
629
+
630
+ let baseline = null;
631
+ try {
632
+ baseline = JSON.parse(fs.readFileSync(baselineFile, 'utf8')).files || null;
633
+ } catch (e) {
634
+ baseline = null;
635
+ }
636
+ if (!baseline) {
637
+ notChecked('baseline_absent', `PROVENANCE: NOT CHECKED - baseline ${baselineFile} is unreadable or missing "files".`);
638
+ }
639
+
640
+ const lines = auditProvenance(r.files, { baseline, markers });
641
+ const summary = summarizeProvenance(lines);
642
+ const state = summary.failed > 0 ? 'failed' : 'ok';
643
+
644
+ if (json) {
645
+ console.log(JSON.stringify({ dir, source, baseline: baselineFile, state, summary, lines: lines.filter((l) => l.state === 'unmarked') }, null, 2));
646
+ } else {
647
+ console.log(`archive: ${dir} (${source})`);
648
+ console.log(`baseline: ${baselineFile}`);
649
+ console.log(`files: ${summary.total} with new lines: ${summary.filesWithNewLines}`);
650
+ console.log(`lines in scope: ${summary.linesInScope} out of scope: ${summary.linesOutOfScope} (occurrences: ${summary.occurrences})`);
651
+ if (summary.failed) {
652
+ console.log(`\nPROVENANCE: ${summary.failed} failed.`);
653
+ for (const l of lines.filter((x) => x.state === 'unmarked')) {
654
+ for (const o of l.occurrences) {
655
+ console.log(` ${l.name}:${o.line} [${o.triggers.join('+')}]`);
656
+ console.log(` ... ${o.excerpt} ...`);
657
+ }
658
+ }
659
+ console.log(`\nMark the source: (${markers.join(') / (')}).`);
660
+ } else {
661
+ console.log('\nPROVENANCE: no unmarked new line.');
662
+ }
663
+ if (summary.linesOutOfScope) {
664
+ console.log(`\nDECLARED BACKLOG: ${summary.linesOutOfScope} line(s) predating the baseline are not judged here.`);
665
+ }
666
+ }
667
+ process.exit(summary.failed > 0 ? 1 : 0);
668
+ }
package/lib/index.js ADDED
@@ -0,0 +1,13 @@
1
+ 'use strict';
2
+ module.exports = {
3
+ collector: require('./collector'),
4
+ maskCode: require('./mask-code').maskCode,
5
+ locateArchive: require('./locate-archive').locateArchive,
6
+ config: require('./config'),
7
+ budget: require('./detectors/budget'),
8
+ frontmatter: require('./detectors/frontmatter'),
9
+ provenance: require('./detectors/provenance'),
10
+ perishable: require('./detectors/perishable'),
11
+ pii: require('./detectors/pii'),
12
+ honesty: require('./detectors/honesty'),
13
+ };