@leonardo-marziali/ad-lfl-kit 1.5.1 → 1.6.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.
Files changed (4) hide show
  1. package/README.md +78 -0
  2. package/index.js +1 -0
  3. package/package.json +2 -1
  4. package/scope.js +334 -0
package/README.md CHANGED
@@ -118,6 +118,55 @@ failing → block with a message Claude can act on, up to 3 attempts by
118
118
  default; still failing at the cap → `onCap` runs once, then the stop is
119
119
  allowed anyway.
120
120
 
121
+ ## Scoping to changes against HEAD
122
+
123
+ Tracking a file is not the same as being answerable for all of it. By
124
+ default `runFixLoop` checks every touched file whole, which means a one-line
125
+ edit to a 400-line README makes every pre-existing violation in it the
126
+ session's problem. `scope.js` narrows that to the lines the working tree
127
+ actually changed:
128
+
129
+ ```js
130
+ const { scopesFor, partitionByScope, fixableFiles } = require('@leonardo-marziali/ad-lfl-kit');
131
+
132
+ const scopes = scopesFor(files); // → Map<absPath, scope>, vs HEAD, ±2 lines
133
+ const { inScope, outOfScope, outOfScopeFiles } = partitionByScope(violations, {
134
+ scopes,
135
+ });
136
+ ```
137
+
138
+ The gate this produces is **"uncommitted work must not introduce lint
139
+ errors"**, not "this session must not". `HEAD` is the last known-clean
140
+ state, so anything dirty is fair game regardless of what dirtied it, and
141
+ anything committed is somebody else's problem — worth reporting once and
142
+ leaving to whatever escape hatch the plugin offers.
143
+
144
+ Scoping against a ref rather than a session-start snapshot is deliberate:
145
+ the verdict is reproducible from a shell (`git diff HEAD`), it survives
146
+ context compaction and session restarts, and it sees every write —
147
+ including the ones no `PostToolUse` hook ever hears about, like a `sed -i`,
148
+ a formatter run from another hook, or the user's own editor.
149
+
150
+ **Ordering.** Because `fixableFiles` needs to know what's out of scope, an
151
+ auto-fixing consumer runs check → fix → re-check rather than fix → check:
152
+ lint once to get the findings, partition them, fix only the files with
153
+ nothing out of scope, then lint again for the result `runFixLoop` blocks on.
154
+
155
+ **Everything fails open.** No repo, no git, an untracked or ignored path, a
156
+ ref that doesn't resolve, a diff that won't parse — each resolves to
157
+ `all: true`, the whole-file behaviour callers had before this module
158
+ existed. A scope that couldn't be computed must never be the reason a
159
+ linter goes quiet. The corollary is that unversioned trees get no narrowing
160
+ at all.
161
+
162
+ **What it won't catch.** Whole-document rules report the _other_ end of a
163
+ relationship an edit changed — delete the last use of a link reference and
164
+ markdownlint's MD053 fires on the definition hundreds of lines away, which
165
+ is out of scope. This is inherent to line-scoped review (GitHub PR
166
+ annotations, reviewdog and `lint-staged` line filters all share it); the
167
+ context margin covers the near misses, and a consumer should say what it
168
+ excluded rather than drop it silently.
169
+
121
170
  ## API
122
171
 
123
172
  ### Hook I/O (`hook-input.js`)
@@ -168,6 +217,35 @@ allowed anyway.
168
217
  shell ever parses a file path on any other platform. `options` also
169
218
  accepts `extraDirs`, forwarded to `resolveBin`.
170
219
 
220
+ ### Scoping (`scope.js`)
221
+
222
+ Narrows a check from _this file_ to _the lines in this file that differ from
223
+ a git ref_, so a one-line edit to a long, already-messy document doesn't hand
224
+ Claude every pre-existing violation in it. See
225
+ [Scoping to changes against HEAD](#scoping-to-changes-against-head).
226
+
227
+ - **`scopesFor(files, { ref, context })`** — the entry point: a `Map` of
228
+ absolute path → scope, with the context margin already applied. `ref`
229
+ defaults to `HEAD`, `context` to `DEFAULT_CONTEXT_LINES` (2). A scope is
230
+ `{ file, all, ranges, includes(line) }`, where `all` means the whole file
231
+ is in scope and `ranges` are inclusive, 1-based `[start, end]` pairs in
232
+ current-file coordinates. `includes()` called without a line number
233
+ returns `all`.
234
+ - **`changedRangesByFile(files, { ref })`** — the same answer before the
235
+ margin is applied, as `{ all, ranges }`. Exposed for callers that want to
236
+ do their own widening.
237
+ - **`partitionByScope(items, { scopes, getFile, getLine })`** — splits a
238
+ linter's findings into `{ inScope, outOfScope, outOfScopeFiles }`.
239
+ `getFile`/`getLine` default to reading `file`/`fileName` and
240
+ `line`/`lineNumber`, which covers most JSON reporters.
241
+ - **`fixableFiles({ files, outOfScopeFiles })`** — the files an auto-fixer
242
+ may safely rewrite, being those with nothing out of scope. A `--fix` pass
243
+ rewrites whole files, so running it on a file that also carries
244
+ pre-existing violations would quietly "fix" lines that match the ref.
245
+ - **`mergeRanges(ranges)`** / **`widenRanges(ranges, context)`** — the range
246
+ arithmetic, exposed for testing and for callers with their own ranges.
247
+ - **`DEFAULT_CONTEXT_LINES`** — `2`.
248
+
171
249
  ### The loop (`loop.js`)
172
250
 
173
251
  - **`runFixLoop({ input, dir, stateFiles, attemptsFile, maxAttempts, files,
package/index.js CHANGED
@@ -11,5 +11,6 @@ module.exports = {
11
11
  ...require('./hook-input'),
12
12
  ...require('./state'),
13
13
  ...require('./track'),
14
+ ...require('./scope'),
14
15
  ...require('./loop'),
15
16
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@leonardo-marziali/ad-lfl-kit",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Agentic development lint-fix-loop kit: session-scoped file tracking and a capped Stop-hook fix loop for Claude Code plugins.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -28,6 +28,7 @@
28
28
  "hook-input.js",
29
29
  "state.js",
30
30
  "track.js",
31
+ "scope.js",
31
32
  "loop.js"
32
33
  ],
33
34
  "engines": {
package/scope.js ADDED
@@ -0,0 +1,334 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ * Narrows a check from "this file" to "the lines in this file that differ
5
+ * from a git ref" — by default HEAD, the last known-clean state.
6
+ *
7
+ * The loop already knows which files a session touched; what it couldn't tell
8
+ * was which parts of them the working tree is actually responsible for. So a
9
+ * one-line edit to a long, already-messy document used to hand Claude every
10
+ * pre-existing violation in it.
11
+ *
12
+ * Scoping against HEAD rather than a session-start snapshot is deliberate:
13
+ * the verdict is then reproducible from a shell (`git diff HEAD`), it
14
+ * survives compaction and session restarts, and it sees every write —
15
+ * including the ones no PostToolUse hook ever hears about, like a `sed -i`,
16
+ * a formatter run from another hook, or the user's own editor.
17
+ *
18
+ * Everything here fails OPEN. No repo, no git, an untracked path, a diff we
19
+ * can't parse — each resolves to "the whole file is in scope", which is
20
+ * exactly the behaviour callers had before this module existed. A scope we
21
+ * failed to compute must never be the reason a linter goes quiet.
22
+ */
23
+
24
+ const path = require('node:path');
25
+ const { runBin } = require('./exec');
26
+
27
+ const DEFAULT_CONTEXT_LINES = 2;
28
+
29
+ /*
30
+ * A diff over a large change set can be many megabytes; spawnSync's 1 MiB
31
+ * default would truncate it, and a truncated diff reads as "fewer changed
32
+ * lines" — the one failure this module must not have.
33
+ */
34
+ const MAX_BUFFER = 64 * 1024 * 1024;
35
+
36
+ const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
37
+
38
+ function git(args, cwd) {
39
+ return runBin('git', args, { cwd, encoding: 'utf8', maxBuffer: MAX_BUFFER });
40
+ }
41
+
42
+ function ok(result) {
43
+ return Boolean(result) && !result.error && result.status === 0;
44
+ }
45
+
46
+ function repoRoot(dir) {
47
+ const result = git(['rev-parse', '--show-toplevel'], dir);
48
+ if (!ok(result)) return null;
49
+ return result.stdout.trim() || null;
50
+ }
51
+
52
+ /*
53
+ * Files are grouped by repository so each repo costs a fixed three git
54
+ * invocations however many files it holds, rather than three per file. The
55
+ * root lookup is cached per directory, which is where the repeats are.
56
+ */
57
+ function groupByRepo(files) {
58
+ const rootFor = new Map();
59
+ const groups = new Map();
60
+ const outsideRepo = [];
61
+
62
+ for (const file of files) {
63
+ const absPath = path.resolve(file);
64
+ const dir = path.dirname(absPath);
65
+ if (!rootFor.has(dir)) rootFor.set(dir, repoRoot(dir));
66
+
67
+ const root = rootFor.get(dir);
68
+ if (!root) {
69
+ outsideRepo.push(absPath);
70
+ continue;
71
+ }
72
+ if (!groups.has(root)) groups.set(root, []);
73
+ groups.get(root).push(absPath);
74
+ }
75
+
76
+ return { groups, outsideRepo };
77
+ }
78
+
79
+ /*
80
+ * NUL-separated so paths with spaces or newlines survive. Returns null when
81
+ * git couldn't answer, which callers treat as "assume nothing is tracked" and
82
+ * therefore scope whole files.
83
+ */
84
+ function readPathSet(result, root) {
85
+ if (!ok(result)) return null;
86
+ const paths = new Set();
87
+ for (const rel of (result.stdout || '').split('\0')) {
88
+ if (rel) paths.add(path.resolve(root, rel));
89
+ }
90
+ return paths;
91
+ }
92
+
93
+ /*
94
+ * Maps one `@@` header to a range of line numbers in the NEW file.
95
+ *
96
+ * `+c,0` is a pure deletion: git points at the line before the splice, so
97
+ * both sides of it go in scope — a violation caused by removing a line (the
98
+ * blank line under a heading, say) is reported against whatever now sits
99
+ * there.
100
+ */
101
+ function rangeFromHunk(header) {
102
+ const match = HUNK_HEADER.exec(header);
103
+ if (!match) return null;
104
+
105
+ const start = Number(match[1]);
106
+ const count = match[2] === undefined ? 1 : Number(match[2]);
107
+ if (count > 0) return [start, start + count - 1];
108
+
109
+ const at = Math.max(start, 1);
110
+ return [at, at + 1];
111
+ }
112
+
113
+ /*
114
+ * Parses `git diff --unified=0 --no-prefix` into ranges per absolute path.
115
+ *
116
+ * The trailing-whitespace strip on the `+++` line is load-bearing: git
117
+ * appends a TAB to that line when the path contains a space, and without it
118
+ * every such file would resolve to a path that exists nowhere and silently
119
+ * drop out of scope.
120
+ */
121
+ function parseDiff(text, root) {
122
+ const byFile = new Map();
123
+ let current = null;
124
+
125
+ for (const line of (text || '').split('\n')) {
126
+ if (line.startsWith('+++ ')) {
127
+ const target = line.slice(4).trimEnd();
128
+ current = target === '/dev/null' ? null : path.resolve(root, target);
129
+ if (current && !byFile.has(current)) byFile.set(current, []);
130
+ continue;
131
+ }
132
+
133
+ if (!current || !line.startsWith('@@')) continue;
134
+ const range = rangeFromHunk(line);
135
+ if (range) byFile.get(current).push(range);
136
+ }
137
+
138
+ return byFile;
139
+ }
140
+
141
+ function scopeWholeFile() {
142
+ return { all: true, ranges: [] };
143
+ }
144
+
145
+ /*
146
+ * Resolves one repository's worth of files. `--name-only` is read separately
147
+ * from the unified diff on purpose: it is the authority on WHICH files
148
+ * changed, so a file that changed but whose hunks we failed to parse (an
149
+ * exotically quoted path, a binary file) is caught and scoped whole rather
150
+ * than mistaken for unchanged.
151
+ */
152
+ function resolveRepoGroup(root, files, ref, into) {
153
+ const tracked = readPathSet(git(['ls-files', '-z', '--', ...files], root), root);
154
+ const trackedFiles = [];
155
+
156
+ for (const file of files) {
157
+ if (tracked?.has(file)) trackedFiles.push(file);
158
+ else into.set(file, scopeWholeFile());
159
+ }
160
+ if (trackedFiles.length === 0) return;
161
+
162
+ const changed = readPathSet(
163
+ git(['diff', ref, '--name-only', '-z', '--', ...trackedFiles], root),
164
+ root,
165
+ );
166
+ const diff = git(
167
+ [
168
+ '-c',
169
+ 'core.quotePath=false',
170
+ 'diff',
171
+ ref,
172
+ '--unified=0',
173
+ '--no-color',
174
+ '--no-ext-diff',
175
+ '--no-renames',
176
+ '--no-prefix',
177
+ '--',
178
+ ...trackedFiles,
179
+ ],
180
+ root,
181
+ );
182
+
183
+ if (!changed || !ok(diff)) {
184
+ for (const file of trackedFiles) into.set(file, scopeWholeFile());
185
+ return;
186
+ }
187
+
188
+ const ranges = parseDiff(diff.stdout, root);
189
+ for (const file of trackedFiles) {
190
+ if (!changed.has(file)) {
191
+ into.set(file, { all: false, ranges: [] });
192
+ continue;
193
+ }
194
+ const parsed = ranges.get(file);
195
+ into.set(file, parsed && parsed.length > 0 ? { all: false, ranges: parsed } : scopeWholeFile());
196
+ }
197
+ }
198
+
199
+ /*
200
+ * The raw per-file answer, before any context margin is applied:
201
+ * { all, ranges } keyed by absolute path, where `all` means the whole file
202
+ * is in scope.
203
+ */
204
+ function changedRangesByFile(files, options = {}) {
205
+ const { ref = 'HEAD' } = options;
206
+ const result = new Map();
207
+ if (!files || files.length === 0) return result;
208
+
209
+ const { groups, outsideRepo } = groupByRepo(files);
210
+ for (const file of outsideRepo) result.set(file, scopeWholeFile());
211
+ for (const [root, group] of groups) resolveRepoGroup(root, group, ref, result);
212
+
213
+ return result;
214
+ }
215
+
216
+ function mergeRanges(ranges) {
217
+ const sorted = [...ranges].sort((a, b) => a[0] - b[0] || a[1] - b[1]);
218
+ const merged = [];
219
+
220
+ for (const [start, end] of sorted) {
221
+ const last = merged.at(-1);
222
+ // `start - 1` so ranges that merely touch collapse into one.
223
+ if (last && start - 1 <= last[1]) {
224
+ if (end > last[1]) last[1] = end;
225
+ continue;
226
+ }
227
+ merged.push([start, end]);
228
+ }
229
+
230
+ return merged;
231
+ }
232
+
233
+ /*
234
+ * Widens each range by `context` lines on either side.
235
+ *
236
+ * Linters routinely report a violation an edit caused against a line the
237
+ * edit didn't touch — append a paragraph under a heading and MD022 fires on
238
+ * the heading above. Only the lower bound is clamped; a range running past
239
+ * the end of the file is harmless, since nothing is ever reported there, and
240
+ * clamping the top would mean reading every file just to count its lines.
241
+ */
242
+ function widenRanges(ranges, context) {
243
+ if (context <= 0) return mergeRanges(ranges);
244
+ return mergeRanges(ranges.map(([start, end]) => [Math.max(start - context, 1), end + context]));
245
+ }
246
+
247
+ function makeScope(file, changed, context) {
248
+ const all = changed.all;
249
+ const ranges = all ? [] : widenRanges(changed.ranges, context);
250
+
251
+ return {
252
+ file,
253
+ all,
254
+ ranges,
255
+ /*
256
+ * A violation with no line number is in scope only when the whole file
257
+ * is — the linter couldn't place it, so we can't claim the working tree
258
+ * introduced it.
259
+ */
260
+ includes(line) {
261
+ if (all) return true;
262
+ if (typeof line !== 'number' || !Number.isFinite(line)) return false;
263
+ return ranges.some(([start, end]) => line >= start && line <= end);
264
+ },
265
+ };
266
+ }
267
+
268
+ /*
269
+ * The entry point callers want: absolute path → scope, context margin
270
+ * already applied.
271
+ */
272
+ function scopesFor(files, options = {}) {
273
+ const { ref = 'HEAD', context = DEFAULT_CONTEXT_LINES } = options;
274
+ const scopes = new Map();
275
+
276
+ for (const [file, changed] of changedRangesByFile(files, { ref })) {
277
+ scopes.set(file, makeScope(file, changed, context));
278
+ }
279
+
280
+ return scopes;
281
+ }
282
+
283
+ const defaultGetFile = (item) => item.file ?? item.fileName;
284
+ const defaultGetLine = (item) => item.line ?? item.lineNumber;
285
+
286
+ /*
287
+ * Splits a linter's findings into the ones the working tree is answerable
288
+ * for and the ones that were already there. `outOfScopeFiles` is handed back
289
+ * so callers don't have to re-derive it for fixableFiles.
290
+ *
291
+ * A finding whose file has no scope recorded was never scoped at all, so it
292
+ * counts as in scope — fail open, as everywhere else here.
293
+ */
294
+ function partitionByScope(items, options = {}) {
295
+ const { scopes, getFile = defaultGetFile, getLine = defaultGetLine } = options;
296
+ const inScope = [];
297
+ const outOfScope = [];
298
+ const outOfScopeFiles = new Set();
299
+
300
+ for (const item of items) {
301
+ const file = path.resolve(getFile(item));
302
+ const scope = scopes.get(file);
303
+
304
+ if (!scope || scope.includes(getLine(item))) {
305
+ inScope.push(item);
306
+ continue;
307
+ }
308
+ outOfScope.push(item);
309
+ outOfScopeFiles.add(file);
310
+ }
311
+
312
+ return { inScope, outOfScope, outOfScopeFiles };
313
+ }
314
+
315
+ /*
316
+ * The files an auto-fixer may safely rewrite: those with nothing out of
317
+ * scope. A `--fix` pass rewrites whole files, so running it on a file that
318
+ * also carries pre-existing violations would quietly "fix" lines that match
319
+ * HEAD — the diff noise this whole module exists to avoid.
320
+ */
321
+ function fixableFiles(options = {}) {
322
+ const { files = [], outOfScopeFiles = new Set() } = options;
323
+ return files.filter((file) => !outOfScopeFiles.has(path.resolve(file)));
324
+ }
325
+
326
+ module.exports = {
327
+ DEFAULT_CONTEXT_LINES,
328
+ changedRangesByFile,
329
+ scopesFor,
330
+ partitionByScope,
331
+ fixableFiles,
332
+ mergeRanges,
333
+ widenRanges,
334
+ };