arkgate 4.8.2 → 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.
- package/CHANGELOG.md +257 -3
- package/README.md +47 -9
- package/bin/ark-check-runtime.mjs +340 -5
- package/bin/ark-layer-match.mjs +170 -13
- package/bin/ark-mcp-runtime.mjs +9 -2
- package/bin/lib/analysis-completeness.mjs +86 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/ark-order-facts.mjs +59 -0
- package/bin/lib/ark-order-sensors.mjs +31 -2
- package/bin/lib/arkrule-file-hints.mjs +6 -2
- package/bin/lib/arkrules-contract.mjs +9 -1
- package/bin/lib/arkrules-sensors.mjs +22 -2
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/config-extras.mjs +2 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +8 -2
- package/bin/lib/first-run-help.mjs +12 -0
- package/bin/lib/invariant-coverage-io.mjs +175 -19
- package/bin/lib/invariant-coverage.mjs +110 -7
- package/bin/lib/literal-path-drift-io.mjs +569 -0
- package/bin/lib/literal-path-drift.mjs +761 -0
- package/bin/lib/policy-delta-io.mjs +5 -0
- package/bin/lib/remediation.mjs +24 -1
- package/bin/lib/resolved-candidate-facts.mjs +31 -0
- package/bin/lib/rules-under-contract.mjs +5 -0
- package/bin/lib/scan-files.mjs +54 -0
- package/bin/lib/sensor-promote-cli.mjs +372 -0
- package/bin/lib/sensor-promote-io.mjs +246 -0
- package/bin/lib/sensor-promotion.mjs +363 -0
- package/dist/{configTypes-BdCe_gvv.d.ts → configTypes-dy5PfTqS.d.ts} +36 -0
- package/dist/{diagnosticCatalog-CPzH-MLN.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +169 -11
- package/dist/eslint/index.cjs +5 -5
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +5 -5
- package/dist/index.cjs +31 -31
- package/dist/index.d.ts +85 -7
- package/dist/index.js +31 -31
- package/dist/nestjs/index.cjs +5 -5
- package/dist/nestjs/index.d.ts +3 -3
- package/dist/nestjs/index.js +5 -5
- package/dist/runtime/index.cjs +13 -13
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +13 -13
- package/dist/{types-DCSlrRnV.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-C9KApBzX.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +4 -4
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +89 -9
- package/docs/develop.md +24 -2
- package/docs/diagnostics.md +79 -1
- package/docs/enthusiast/README.md +6 -4
- package/docs/package-surface.md +36 -4
- package/docs/product-voice.md +15 -5
- package/docs/use.md +8 -5
- package/package.json +2 -2
- package/schemas/ark.arkrules.schema.json +1 -0
- package/schemas/ark.config.schema.json +72 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -1
- package/server.json +3 -3
- package/templates/agent-skills/README.md +1 -1
- package/templates/agent-skills/ark-adopt/SKILL.md +13 -3
- package/templates/agent-skills/ark-autopilot/SKILL.md +1 -1
- package/templates/agent-skills/ark-contract/SKILL.md +4 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/agent-skills/ark-place/SKILL.md +6 -2
- package/templates/arkrules/ApplicationOrchestration.json +6 -0
- package/templates/skills/ark-adopt.md +13 -3
- package/templates/skills/ark-autopilot.md +1 -1
- package/templates/skills/ark-contract.md +4 -0
- package/templates/skills/ark-coverage.md +1 -0
- package/templates/skills/ark-place.md +6 -2
|
@@ -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
|
+
}
|