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