arkgate 4.8.3 → 4.8.4

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 (54) hide show
  1. package/CHANGELOG.md +242 -0
  2. package/README.md +10 -3
  3. package/bin/ark-check-runtime.mjs +340 -5
  4. package/bin/ark-layer-match.mjs +170 -13
  5. package/bin/ark-mcp-runtime.mjs +9 -2
  6. package/bin/lib/analysis-completeness.mjs +86 -0
  7. package/bin/lib/analysis-engine.mjs +5 -5
  8. package/bin/lib/architecture-scan.mjs +2 -0
  9. package/bin/lib/arkrules-contract.mjs +8 -1
  10. package/bin/lib/check-args.mjs +66 -0
  11. package/bin/lib/config-contract.mjs +26 -0
  12. package/bin/lib/design-smells.mjs +85 -0
  13. package/bin/lib/diagnostic-catalog.mjs +6 -1
  14. package/bin/lib/first-run-help.mjs +12 -0
  15. package/bin/lib/invariant-coverage-io.mjs +175 -19
  16. package/bin/lib/invariant-coverage.mjs +110 -7
  17. package/bin/lib/literal-path-drift-io.mjs +569 -0
  18. package/bin/lib/literal-path-drift.mjs +761 -0
  19. package/bin/lib/policy-delta-io.mjs +5 -0
  20. package/bin/lib/remediation.mjs +15 -0
  21. package/bin/lib/rules-under-contract.mjs +5 -0
  22. package/bin/lib/scan-files.mjs +54 -0
  23. package/bin/lib/sensor-promote-cli.mjs +372 -0
  24. package/bin/lib/sensor-promote-io.mjs +246 -0
  25. package/bin/lib/sensor-promotion.mjs +363 -0
  26. package/dist/{configTypes-dNJ2C0yx.d.ts → configTypes-dy5PfTqS.d.ts} +31 -0
  27. package/dist/{diagnosticCatalog-C5GgeyEE.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +75 -7
  28. package/dist/eslint/index.cjs +6 -6
  29. package/dist/eslint/index.d.ts +34 -1
  30. package/dist/eslint/index.js +6 -6
  31. package/dist/index.cjs +32 -32
  32. package/dist/index.d.ts +65 -4
  33. package/dist/index.js +29 -29
  34. package/dist/nestjs/index.cjs +5 -5
  35. package/dist/nestjs/index.d.ts +3 -3
  36. package/dist/nestjs/index.js +5 -5
  37. package/dist/runtime/index.cjs +15 -15
  38. package/dist/runtime/index.d.ts +6 -6
  39. package/dist/runtime/index.js +15 -15
  40. package/dist/{types-dK24fDZa.d.ts → types-BuM8WNqe.d.ts} +1 -1
  41. package/dist/{types-DeK7SYGC.d.ts → types-D95drJ3_.d.ts} +1 -1
  42. package/docs/README.md +1 -1
  43. package/docs/agent-guide.md +182 -0
  44. package/docs/configuration.md +77 -1
  45. package/docs/develop.md +1 -0
  46. package/docs/diagnostics.md +70 -1
  47. package/docs/package-surface.md +32 -2
  48. package/package.json +2 -2
  49. package/schemas/ark.config.schema.json +63 -0
  50. package/server.json +3 -3
  51. package/templates/agent-skills/ark-adopt/SKILL.md +5 -0
  52. package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
  53. package/templates/skills/ark-adopt.md +5 -0
  54. package/templates/skills/ark-coverage.md +1 -0
@@ -0,0 +1,569 @@
1
+ /**
2
+ * Tooling I/O for literal path drift (LPD).
3
+ *
4
+ * Pure detection lives in Domain (`src/domain/literalPathDrift.ts`, generated
5
+ * to `./literal-path-drift.mjs`). This module is the side of it that touches
6
+ * the world: the bounded text walk, the git rename set, the existence probe,
7
+ * and the `--write` pass. Hand-written — it is NOT generated.
8
+ *
9
+ * Two things it deliberately does differently from the other content passes:
10
+ *
11
+ * - **File types.** `resolved-candidate-facts.mjs` gates its extractors to
12
+ * TS/TSX because they parse TypeScript. A path in a comment is not code, and
13
+ * the field sample found one in `src/app/globals.css`, so this walk reads
14
+ * every text format where a repo path is written by hand (see
15
+ * `LITERAL_PATH_SCAN_EXTENSIONS`). Markdown included: a stale path in a
16
+ * runbook misleads exactly the same way.
17
+ * - **No silent discards.** Every file the walk refuses is counted by reason
18
+ * and reported, the same doctrine as the coverage scan.
19
+ *
20
+ * @see docs/diagnostics.md#LITERAL_PATH_DRIFT
21
+ */
22
+ import { spawnSync } from 'node:child_process';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+
26
+ import {
27
+ DEFAULT_INCLUDE_ROOTS,
28
+ applyLiteralPathDrift,
29
+ findLiteralPathDrift,
30
+ isGeneratedLiteralPathFile,
31
+ isLiteralPathScannable,
32
+ } from './literal-path-drift.mjs';
33
+
34
+ /** Max files read into the drift scan. */
35
+ export const DEFAULT_MAX_DRIFT_FILES = 4000;
36
+ /** Max bytes per file. A larger file is counted, not read. */
37
+ const MAX_FILE_BYTES = 512 * 1024;
38
+ /**
39
+ * Max total bytes held from the walk.
40
+ *
41
+ * A per-file cap and a file count do not bound their product: 4000 x 512KB is
42
+ * two gigabytes retained before detection starts. This is the bound that
43
+ * actually holds, and like every other refusal it is counted and reported.
44
+ */
45
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
46
+ /** Max directory depth. Deeper directories are counted, not silent. */
47
+ const MAX_WALK_DEPTH = 12;
48
+ /** Max bytes of a tsconfig read for alias discovery. */
49
+ const MAX_TSCONFIG_BYTES = 1024 * 1024;
50
+ const SPAWN_TIMEOUT_MS = 10_000;
51
+ /** git rename output is ~100 bytes per rename; Node's 1MB default caps at ~10k. */
52
+ const GIT_MAX_BUFFER = 64 * 1024 * 1024;
53
+
54
+ /** Directories the walk never enters. */
55
+ const SKIP_DIRS = new Set([
56
+ 'node_modules',
57
+ 'dist',
58
+ 'build',
59
+ 'out',
60
+ 'coverage',
61
+ '.git',
62
+ 'vendor',
63
+ 'tmp',
64
+ ]);
65
+
66
+ function toPosix(value) {
67
+ return String(value).split(path.sep).join('/');
68
+ }
69
+
70
+ /**
71
+ * Run git with the repository's own config unable to name a program for git to
72
+ * execute. This tool is pointed at repositories the operator did not write, and
73
+ * `core.fsmonitor` / `diff.external` / a hooks path are program paths git will
74
+ * run during a plain `git diff`. The user's global config is left alone on
75
+ * purpose: `safe.directory` lives there and dropping it breaks real workflows.
76
+ */
77
+ function runGit(cwd, args) {
78
+ return spawnSync(
79
+ 'git',
80
+ ['-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null', '-c', 'diff.external=', ...args],
81
+ {
82
+ cwd,
83
+ encoding: 'utf8',
84
+ stdio: ['ignore', 'pipe', 'pipe'],
85
+ timeout: SPAWN_TIMEOUT_MS,
86
+ maxBuffer: GIT_MAX_BUFFER,
87
+ env: { ...process.env, GIT_EXTERNAL_DIFF: '' },
88
+ }
89
+ );
90
+ }
91
+
92
+ function safeRef(value) {
93
+ return (
94
+ typeof value === 'string' &&
95
+ /^[A-Za-z0-9][A-Za-z0-9._/^~-]{0,200}$/.test(value) &&
96
+ !value.includes('..')
97
+ );
98
+ }
99
+
100
+ /**
101
+ * Renames between `baseRef` and the working tree.
102
+ *
103
+ * `git diff <ref>` (no second ref) compares the ref against the tree on disk, so
104
+ * a rename that is staged but not yet committed is in the set too — the moment
105
+ * the drift is cheapest to fix. (A bare `mv` without `git add` cannot be seen as
106
+ * a rename by anyone: the destination is untracked.) Returns `[]` when git is
107
+ * unavailable or the ref is unknown;
108
+ * an empty rename set is not an error, it just means anchored mode has nothing
109
+ * to anchor on.
110
+ *
111
+ * @param {string} root
112
+ * @param {string | null | undefined} baseRef
113
+ * @returns {{ renames: Array<{ from: string, to: string }>, available: boolean, reason: string | null }}
114
+ */
115
+ export function gitRenameSet(root, baseRef) {
116
+ if (!safeRef(baseRef)) return { renames: [], available: false, reason: 'no-base-ref' };
117
+ const top = runGit(root, ['rev-parse', '--show-toplevel']);
118
+ if (top.status !== 0) return { renames: [], available: false, reason: 'not-a-git-repository' };
119
+ const repoTop = top.stdout.trim();
120
+ const verified = runGit(repoTop, ['rev-parse', '--verify', `${baseRef}^{commit}`, '--']);
121
+ if (verified.status !== 0) return { renames: [], available: false, reason: 'unknown-base-ref' };
122
+ const diff = runGit(repoTop, [
123
+ 'diff',
124
+ '--find-renames',
125
+ '--diff-filter=R',
126
+ '--name-status',
127
+ '-z',
128
+ baseRef,
129
+ '--',
130
+ ]);
131
+ if (diff.error?.code === 'ENOBUFS') {
132
+ // Say which limit was hit. "diff-failed" would read as a git problem when
133
+ // it is our buffer, and the caller must not take the resulting empty
134
+ // rename set for "no renames".
135
+ return { renames: [], available: false, reason: 'rename-set-too-large' };
136
+ }
137
+ if (diff.status !== 0) return { renames: [], available: false, reason: 'diff-failed' };
138
+
139
+ // -z record layout for a rename: "R<score>\0<from>\0<to>\0"
140
+ const fields = diff.stdout.split('\0');
141
+ const renames = [];
142
+ const prefix = repoRelativePrefix(repoTop, root);
143
+ for (let i = 0; i < fields.length; i += 1) {
144
+ if (!/^R\d*$/.test(fields[i] ?? '')) continue;
145
+ const from = fields[i + 1];
146
+ const to = fields[i + 2];
147
+ i += 2;
148
+ if (!from || !to) continue;
149
+ const rebasedFrom = rebaseIntoRoot(from, prefix);
150
+ const rebasedTo = rebaseIntoRoot(to, prefix);
151
+ if (rebasedFrom === null || rebasedTo === null) continue;
152
+ renames.push({ from: rebasedFrom, to: rebasedTo });
153
+ }
154
+ return { renames, available: true, reason: null };
155
+ }
156
+
157
+ /** Path of `root` relative to the repository top, POSIX, '' when they match. */
158
+ function repoRelativePrefix(repoTop, root) {
159
+ const relative = toPosix(path.relative(path.resolve(repoTop), path.resolve(root)));
160
+ return relative === '' || relative.startsWith('..') ? '' : relative;
161
+ }
162
+
163
+ /** Re-express a repo-top-relative path as root-relative; null when outside root. */
164
+ function rebaseIntoRoot(repoPath, prefix) {
165
+ const normalized = toPosix(repoPath);
166
+ if (prefix === '') return normalized;
167
+ if (normalized === prefix) return '';
168
+ return normalized.startsWith(`${prefix}/`) ? normalized.slice(prefix.length + 1) : null;
169
+ }
170
+
171
+ /**
172
+ * Alias prefixes from the project's tsconfig `paths` (the `alias` form).
173
+ *
174
+ * Only single-target `X/*` → `Y/*` entries are used: a multi-target alias has
175
+ * no one-directional rewrite, so it is left out rather than guessed at.
176
+ *
177
+ * @param {string} root
178
+ * @param {string} [tsconfigPath]
179
+ * @returns {Record<string, string>}
180
+ */
181
+ export function aliasPrefixesFromTsconfig(root, tsconfigPath) {
182
+ const rootResolved = path.resolve(root);
183
+ const file = tsconfigPath
184
+ ? path.resolve(rootResolved, tsconfigPath)
185
+ : path.join(rootResolved, 'tsconfig.json');
186
+ /** @type {Record<string, string>} */
187
+ const out = {};
188
+ try {
189
+ // --tsconfig takes a path from the caller: keep it inside the tree.
190
+ if (file !== rootResolved && !file.startsWith(rootResolved + path.sep)) {
191
+ throw new Error('tsconfig outside root');
192
+ }
193
+ // Bounded: this file is the project's, and the project may be hostile.
194
+ if (fs.statSync(file).size > MAX_TSCONFIG_BYTES) throw new Error('tsconfig too large');
195
+ const raw = fs.readFileSync(file, 'utf8');
196
+ // tsconfig allows comments and trailing commas; strip the common cases.
197
+ // The block-comment pattern is anchored (no lazy rescan) so a file full of
198
+ // unterminated `/*` cannot make this quadratic.
199
+ const stripped = raw
200
+ .replace(/\/\*[^*]*\*+(?:[^/*][^*]*\*+)*\//g, '')
201
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
202
+ .replace(/,(\s*[}\]])/g, '$1');
203
+ const parsed = JSON.parse(stripped);
204
+ const baseUrl = toPosix(parsed?.compilerOptions?.baseUrl ?? '.').replace(/^\.\/?/, '');
205
+ const paths = parsed?.compilerOptions?.paths ?? {};
206
+ for (const [alias, targets] of Object.entries(paths)) {
207
+ if (!Array.isArray(targets) || targets.length !== 1) continue;
208
+ if (!alias.endsWith('/*')) continue;
209
+ const target = toPosix(String(targets[0] ?? ''));
210
+ if (!target.endsWith('/*')) continue;
211
+ const root_ = joinPosix(baseUrl, target.slice(0, -1).replace(/^\.\//, ''));
212
+ out[alias.slice(0, -1)] = root_;
213
+ }
214
+ } catch {
215
+ /* no tsconfig, or not readable: fall through to the default below */
216
+ }
217
+ if (Object.keys(out).length === 0 && fs.existsSync(path.join(root, 'src'))) {
218
+ out['@/'] = 'src/';
219
+ }
220
+ return out;
221
+ }
222
+
223
+ function joinPosix(left, right) {
224
+ const l = toPosix(left).replace(/\/+$/, '');
225
+ const r = toPosix(right).replace(/^\.\//, '');
226
+ if (l === '' || l === '.') return r;
227
+ if (r === '') return l;
228
+ return `${l}/${r}`;
229
+ }
230
+
231
+ /**
232
+ * First segments that make an unprefixed literal look like a repo path (the
233
+ * `rootless` and `prose` forms).
234
+ * Top-level directories of the root, plus the direct children of each include
235
+ * root — `components/...` is drift-shaped precisely because `src/components`
236
+ * exists.
237
+ *
238
+ * @param {string} root
239
+ * @param {string[]} includeRoots
240
+ */
241
+ export function deriveScanRoots(root, includeRoots) {
242
+ const roots = new Set();
243
+ const addChildren = (dir) => {
244
+ let entries;
245
+ try {
246
+ entries = fs.readdirSync(dir, { withFileTypes: true });
247
+ } catch {
248
+ return;
249
+ }
250
+ for (const entry of entries) {
251
+ if (!entry.isDirectory()) continue;
252
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
253
+ roots.add(entry.name);
254
+ }
255
+ };
256
+ addChildren(root);
257
+ for (const include of includeRoots) addChildren(path.join(root, include));
258
+ return [...roots].sort();
259
+ }
260
+
261
+ /**
262
+ * Bounded text walk. Counts every refusal by reason — a file that is not read
263
+ * is a file the scan cannot speak about, and that must be visible.
264
+ *
265
+ * @param {string} root
266
+ * @param {{ maxFiles?: number }} [opts]
267
+ */
268
+ export function collectDriftFiles(root, opts = {}) {
269
+ const maxFiles = Number.isInteger(opts.maxFiles) && opts.maxFiles > 0 ? opts.maxFiles : DEFAULT_MAX_DRIFT_FILES;
270
+ /** @type {Array<{ path: string, text: string }>} */
271
+ const files = [];
272
+ const discarded = {
273
+ budget: 0,
274
+ byteBudget: 0,
275
+ oversize: 0,
276
+ unreadable: 0,
277
+ depthLimited: 0,
278
+ generated: 0,
279
+ symlink: 0,
280
+ symlinkDir: 0,
281
+ };
282
+ let totalBytes = 0;
283
+ const rootResolved = path.resolve(root);
284
+ const seenDirs = new Set();
285
+
286
+ const walk = (dir, depth) => {
287
+ if (depth > MAX_WALK_DEPTH) {
288
+ discarded.depthLimited += 1;
289
+ return;
290
+ }
291
+ let entries;
292
+ try {
293
+ entries = fs.readdirSync(dir, { withFileTypes: true });
294
+ } catch {
295
+ discarded.unreadable += 1;
296
+ return;
297
+ }
298
+ for (const entry of entries) {
299
+ const absolute = path.join(dir, entry.name);
300
+ if (entry.isSymbolicLink()) {
301
+ // A link is not proof this tree holds the file, and following one can
302
+ // walk out of the root entirely. Counted, not silent — separately for a
303
+ // linked file and a linked DIRECTORY, because the second one drops a
304
+ // whole subtree and a monorepo is full of them.
305
+ const rel = toPosix(path.relative(rootResolved, absolute));
306
+ let linkedDirectory = false;
307
+ try {
308
+ linkedDirectory = fs.statSync(absolute).isDirectory();
309
+ } catch {
310
+ /* broken link: neither a file we could read nor a subtree */
311
+ }
312
+ if (linkedDirectory) discarded.symlinkDir += 1;
313
+ else if (isLiteralPathScannable(rel)) discarded.symlink += 1;
314
+ continue;
315
+ }
316
+ if (entry.isDirectory()) {
317
+ if (entry.name.startsWith('.') || SKIP_DIRS.has(entry.name)) continue;
318
+ let real;
319
+ try {
320
+ real = fs.realpathSync(absolute);
321
+ } catch {
322
+ discarded.unreadable += 1;
323
+ continue;
324
+ }
325
+ if (seenDirs.has(real)) continue;
326
+ seenDirs.add(real);
327
+ walk(absolute, depth + 1);
328
+ continue;
329
+ }
330
+ if (!entry.isFile()) continue;
331
+ const relative = toPosix(path.relative(rootResolved, absolute));
332
+ if (!isLiteralPathScannable(relative)) continue;
333
+ if (isGeneratedLiteralPathFile(relative)) {
334
+ discarded.generated += 1;
335
+ continue;
336
+ }
337
+ if (files.length >= maxFiles) {
338
+ discarded.budget += 1;
339
+ continue;
340
+ }
341
+ let stat;
342
+ try {
343
+ stat = fs.statSync(absolute);
344
+ } catch {
345
+ discarded.unreadable += 1;
346
+ continue;
347
+ }
348
+ if (stat.size > MAX_FILE_BYTES) {
349
+ discarded.oversize += 1;
350
+ continue;
351
+ }
352
+ if (totalBytes + stat.size > MAX_TOTAL_BYTES) {
353
+ discarded.byteBudget += 1;
354
+ continue;
355
+ }
356
+ try {
357
+ files.push({ path: relative, text: fs.readFileSync(absolute, 'utf8') });
358
+ totalBytes += stat.size;
359
+ } catch {
360
+ discarded.unreadable += 1;
361
+ }
362
+ }
363
+ };
364
+
365
+ walk(rootResolved, 0);
366
+ files.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
367
+ return { files, discarded, maxFiles, totalBytes, maxTotalBytes: MAX_TOTAL_BYTES };
368
+ }
369
+
370
+ /**
371
+ * The root as the filesystem sees it. Comparing a realpath against a lexical
372
+ * root is a guaranteed mismatch wherever the root itself sits behind a link —
373
+ * `/var` on macOS, a symlinked checkout, a container bind mount — and every
374
+ * containment test would then answer "outside".
375
+ */
376
+ function realRoot(root) {
377
+ const resolved = path.resolve(root);
378
+ try {
379
+ return fs.realpathSync.native(resolved);
380
+ } catch {
381
+ return resolved;
382
+ }
383
+ }
384
+
385
+ /** True when `real` is the root or lives under it. */
386
+ function isInsideRoot(real, rootReal) {
387
+ return real === rootReal || real.startsWith(rootReal + path.sep);
388
+ }
389
+
390
+ /** Existence probe over repo-relative paths, files and directories alike. */
391
+ export function makeExistsProbe(root) {
392
+ const rootResolved = path.resolve(root);
393
+ const rootReal = realRoot(root);
394
+ const cache = new Map();
395
+ return (relative) => {
396
+ if (typeof relative !== 'string' || relative.length === 0) return false;
397
+ const cached = cache.get(relative);
398
+ if (cached !== undefined) return cached;
399
+ const absolute = path.resolve(rootResolved, relative);
400
+ // Never let a literal escape the root and answer about someone else's tree.
401
+ // The lexical test is not enough on its own: existsSync follows symlinks,
402
+ // so an in-root link to /etc would answer "yes" about /etc. Decide on the
403
+ // REAL path — and a link that escapes the root answers no, which is the
404
+ // conservative direction (a false "exists" would suppress a real finding).
405
+ let answer = false;
406
+ if (isInsideRoot(absolute, rootResolved)) {
407
+ try {
408
+ answer = isInsideRoot(fs.realpathSync.native(absolute), rootReal);
409
+ } catch {
410
+ answer = false;
411
+ }
412
+ }
413
+ cache.set(relative, answer);
414
+ return answer;
415
+ };
416
+ }
417
+
418
+ /**
419
+ * Full scan: walk, rename set, detect. Report only — nothing is written here.
420
+ *
421
+ * @param {string} root
422
+ * @param {{ include?: string[] } | null | undefined} config
423
+ * @param {{ baseRef?: string | null, tsconfig?: string, maxFiles?: number }} [opts]
424
+ */
425
+ export function scanLiteralPathDrift(root, config, opts = {}) {
426
+ const includeRoots = (config?.include ?? DEFAULT_INCLUDE_ROOTS).filter(
427
+ (entry) => typeof entry === 'string' && entry.length > 0 && entry !== '.'
428
+ );
429
+ const scan = collectDriftFiles(root, { maxFiles: opts.maxFiles });
430
+ const { files, discarded, maxFiles } = scan;
431
+ const rename = gitRenameSet(root, opts.baseRef);
432
+ const report = findLiteralPathDrift({
433
+ files,
434
+ exists: makeExistsProbe(root),
435
+ renames: rename.renames,
436
+ aliases: aliasPrefixesFromTsconfig(root, opts.tsconfig),
437
+ roots: deriveScanRoots(root, includeRoots),
438
+ rootlessPrefixes: includeRoots,
439
+ });
440
+ return {
441
+ ...report,
442
+ baseRef: opts.baseRef ?? null,
443
+ renameSet: {
444
+ available: rename.available,
445
+ reason: rename.reason,
446
+ renames: rename.renames.length,
447
+ },
448
+ scan: {
449
+ maxFiles,
450
+ discarded,
451
+ totalBytes: scan.totalBytes,
452
+ maxTotalBytes: scan.maxTotalBytes,
453
+ },
454
+ };
455
+ }
456
+
457
+ /**
458
+ * Write the anchored fixes. Unanchored findings are never written — there is no
459
+ * destination to write.
460
+ *
461
+ * Each file is re-read and re-verified at the token, so a file that changed
462
+ * since the scan is skipped rather than corrupted.
463
+ *
464
+ * @param {string} root
465
+ * @param {Array<object>} anchored
466
+ */
467
+ export function writeLiteralPathDrift(root, anchored) {
468
+ /** @type {Map<string, object[]>} */
469
+ const byFile = new Map();
470
+ for (const finding of anchored) {
471
+ if (!finding || typeof finding.file !== 'string' || finding.suggestedToken == null) continue;
472
+ const list = byFile.get(finding.file) ?? [];
473
+ list.push(finding);
474
+ byFile.set(finding.file, list);
475
+ }
476
+ const rootResolved = path.resolve(root);
477
+ const rootReal = realRoot(root);
478
+ const written = [];
479
+ const skipped = [];
480
+ for (const [relative, findings] of [...byFile].sort((a, b) => (a[0] < b[0] ? -1 : 1))) {
481
+ const absolute = path.resolve(rootResolved, relative);
482
+ if (!isInsideRoot(absolute, rootResolved)) {
483
+ skipped.push({ file: relative, reason: 'outside-root', count: findings.length });
484
+ continue;
485
+ }
486
+ // The parent must still be inside the root as the filesystem sees it: a
487
+ // symlinked directory component would otherwise carry the write out of the
488
+ // tree even though the leaf is a regular file.
489
+ let realParent;
490
+ try {
491
+ realParent = fs.realpathSync.native(path.dirname(absolute));
492
+ } catch {
493
+ skipped.push({ file: relative, reason: 'unreadable', count: findings.length });
494
+ continue;
495
+ }
496
+ if (!isInsideRoot(realParent, rootReal)) {
497
+ skipped.push({ file: relative, reason: 'outside-root', count: findings.length });
498
+ continue;
499
+ }
500
+
501
+ // One descriptor for the whole read-modify-write. Resolving the name twice
502
+ // is the TOCTOU: between a check and a later `writeFileSync` the file can
503
+ // become a link to somewhere else. O_NOFOLLOW refuses a symlinked leaf at
504
+ // open time, and nlink refuses a hardlink planted to a file outside the
505
+ // tree — lstat reports one as an ordinary file and the write would land on
506
+ // the shared inode.
507
+ let fd;
508
+ try {
509
+ fd = fs.openSync(absolute, fs.constants.O_RDWR | fs.constants.O_NOFOLLOW);
510
+ } catch (error) {
511
+ const code = error?.code;
512
+ skipped.push({
513
+ file: relative,
514
+ reason: code === 'ELOOP' || code === 'EMLINK' ? 'symlink' : 'unwritable',
515
+ count: findings.length,
516
+ });
517
+ continue;
518
+ }
519
+ try {
520
+ const stat = fs.fstatSync(fd);
521
+ if (stat.nlink > 1) {
522
+ skipped.push({ file: relative, reason: 'hard-link', count: findings.length });
523
+ continue;
524
+ }
525
+ const buffer = Buffer.alloc(stat.size);
526
+ fs.readSync(fd, buffer, 0, stat.size, 0);
527
+ const text = buffer.toString('utf8');
528
+ // Reading as utf8 turns an invalid byte into U+FFFD, and writing the whole
529
+ // string back would destroy it — anywhere in the file, nowhere near the
530
+ // finding. Round-tripping the buffer is the exact test.
531
+ if (!Buffer.from(text, 'utf8').equals(buffer)) {
532
+ skipped.push({ file: relative, reason: 'not-utf8', count: findings.length });
533
+ continue;
534
+ }
535
+ const result = applyLiteralPathDrift(text, findings);
536
+ if (result.applied.length === 0) {
537
+ skipped.push({ file: relative, reason: 'token-moved', count: findings.length });
538
+ continue;
539
+ }
540
+ const out = Buffer.from(result.text, 'utf8');
541
+ fs.ftruncateSync(fd, 0);
542
+ fs.writeSync(fd, out, 0, out.length, 0);
543
+ written.push({
544
+ file: relative,
545
+ applied: result.applied.length,
546
+ // Identities, not just a count: the caller has to know WHICH findings
547
+ // are gone from disk to report what is left.
548
+ appliedFindings: result.applied.map((finding) => ({
549
+ file: finding.file,
550
+ line: finding.line,
551
+ column: finding.column,
552
+ token: finding.token,
553
+ })),
554
+ });
555
+ if (result.skipped.length > 0) {
556
+ skipped.push({ file: relative, reason: 'token-moved', count: result.skipped.length });
557
+ }
558
+ } catch {
559
+ skipped.push({ file: relative, reason: 'unwritable', count: findings.length });
560
+ } finally {
561
+ try {
562
+ fs.closeSync(fd);
563
+ } catch {
564
+ /* already closed */
565
+ }
566
+ }
567
+ }
568
+ return { written, skipped };
569
+ }