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.
- package/CHANGELOG.md +242 -0
- package/README.md +10 -3
- 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 +5 -5
- package/bin/lib/architecture-scan.mjs +2 -0
- package/bin/lib/arkrules-contract.mjs +8 -1
- package/bin/lib/check-args.mjs +66 -0
- package/bin/lib/config-contract.mjs +26 -0
- package/bin/lib/design-smells.mjs +85 -0
- package/bin/lib/diagnostic-catalog.mjs +6 -1
- 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 +15 -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-dNJ2C0yx.d.ts → configTypes-dy5PfTqS.d.ts} +31 -0
- package/dist/{diagnosticCatalog-C5GgeyEE.d.ts → diagnosticCatalog-DgTs0abp.d.ts} +75 -7
- package/dist/eslint/index.cjs +6 -6
- package/dist/eslint/index.d.ts +34 -1
- package/dist/eslint/index.js +6 -6
- package/dist/index.cjs +32 -32
- package/dist/index.d.ts +65 -4
- package/dist/index.js +29 -29
- 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 +15 -15
- package/dist/runtime/index.d.ts +6 -6
- package/dist/runtime/index.js +15 -15
- package/dist/{types-dK24fDZa.d.ts → types-BuM8WNqe.d.ts} +1 -1
- package/dist/{types-DeK7SYGC.d.ts → types-D95drJ3_.d.ts} +1 -1
- package/docs/README.md +1 -1
- package/docs/agent-guide.md +182 -0
- package/docs/configuration.md +77 -1
- package/docs/develop.md +1 -0
- package/docs/diagnostics.md +70 -1
- package/docs/package-surface.md +32 -2
- package/package.json +2 -2
- package/schemas/ark.config.schema.json +63 -0
- package/server.json +3 -3
- package/templates/agent-skills/ark-adopt/SKILL.md +5 -0
- package/templates/agent-skills/ark-coverage/SKILL.md +1 -0
- package/templates/skills/ark-adopt.md +5 -0
- package/templates/skills/ark-coverage.md +1 -0
|
@@ -0,0 +1,761 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/literalPathDrift.ts
|
|
5
|
+
* Regenerate: node scripts/generate-cli-pure.mjs
|
|
6
|
+
* Drift check: node scripts/generate-cli-pure.mjs --check
|
|
7
|
+
*
|
|
8
|
+
* Pure CLI helper (bin/lib/literal-path-drift.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** Anchored drift: a rename explains the dead reference, so a fix is proposable. */
|
|
12
|
+
export const LITERAL_PATH_DRIFT_RULE_ID = 'LITERAL_PATH_DRIFT';
|
|
13
|
+
/** Unanchored drift: the literal does not resolve and nothing says where it went. */
|
|
14
|
+
export const LITERAL_PATH_UNRESOLVED_RULE_ID = 'LITERAL_PATH_UNRESOLVED';
|
|
15
|
+
/**
|
|
16
|
+
* File types the drift scan reads.
|
|
17
|
+
*
|
|
18
|
+
* Deliberately wider than the TS/TSX gate used by the resolved-candidate
|
|
19
|
+
* extractors: form D appeared in a `.css` file in the field sample
|
|
20
|
+
* (`src/app/globals.css` citing a component), and a stale path in a `.md`
|
|
21
|
+
* runbook misleads exactly the same way. These are all text formats where a
|
|
22
|
+
* repo path is written by hand.
|
|
23
|
+
*/
|
|
24
|
+
export const LITERAL_PATH_SCAN_EXTENSIONS = Object.freeze([
|
|
25
|
+
'.ts',
|
|
26
|
+
'.tsx',
|
|
27
|
+
'.mts',
|
|
28
|
+
'.cts',
|
|
29
|
+
'.js',
|
|
30
|
+
'.jsx',
|
|
31
|
+
'.mjs',
|
|
32
|
+
'.cjs',
|
|
33
|
+
'.css',
|
|
34
|
+
'.scss',
|
|
35
|
+
'.json',
|
|
36
|
+
'.md',
|
|
37
|
+
]);
|
|
38
|
+
/** Extensions tried when a reference carries none (trap 2). */
|
|
39
|
+
const RESOLUTION_EXTENSIONS = Object.freeze([
|
|
40
|
+
'.ts',
|
|
41
|
+
'.tsx',
|
|
42
|
+
'.mts',
|
|
43
|
+
'.cts',
|
|
44
|
+
'.js',
|
|
45
|
+
'.jsx',
|
|
46
|
+
'.mjs',
|
|
47
|
+
'.cjs',
|
|
48
|
+
'.d.ts',
|
|
49
|
+
'.css',
|
|
50
|
+
'.scss',
|
|
51
|
+
'.json',
|
|
52
|
+
'.md',
|
|
53
|
+
]);
|
|
54
|
+
/** Index files tried when a reference names a directory (trap 2). */
|
|
55
|
+
const RESOLUTION_INDEX_FILES = Object.freeze([
|
|
56
|
+
'/index.ts',
|
|
57
|
+
'/index.tsx',
|
|
58
|
+
'/index.js',
|
|
59
|
+
'/index.jsx',
|
|
60
|
+
'/index.mjs',
|
|
61
|
+
]);
|
|
62
|
+
/** Trailing characters that prose glues onto a path (trap 3). */
|
|
63
|
+
const TRAILING_PUNCTUATION = new Set([
|
|
64
|
+
'.',
|
|
65
|
+
',',
|
|
66
|
+
';',
|
|
67
|
+
':',
|
|
68
|
+
')',
|
|
69
|
+
']',
|
|
70
|
+
'}',
|
|
71
|
+
'>',
|
|
72
|
+
'"',
|
|
73
|
+
"'",
|
|
74
|
+
'`',
|
|
75
|
+
'!',
|
|
76
|
+
'?',
|
|
77
|
+
'/',
|
|
78
|
+
]);
|
|
79
|
+
// NOTE: '-' is deliberately absent. It is a legal path character, and trimming
|
|
80
|
+
// it turns `src/old-` into a match on `src/old` — the write would then leave a
|
|
81
|
+
// dangling `-` behind and report the replacement as applied.
|
|
82
|
+
/** Longest token the scanner will consider a path. */
|
|
83
|
+
const MAX_TOKEN_LENGTH = 200;
|
|
84
|
+
/**
|
|
85
|
+
* Alias map used when the caller declares none: the `@/` convention over `src/`.
|
|
86
|
+
* The CLI side derives the real map from the project's tsconfig `paths`.
|
|
87
|
+
*/
|
|
88
|
+
export const DEFAULT_ALIASES = Object.freeze({ '@/': 'src/' });
|
|
89
|
+
/** Include roots assumed when the contract declares none. */
|
|
90
|
+
export const DEFAULT_INCLUDE_ROOTS = Object.freeze(['src']);
|
|
91
|
+
/**
|
|
92
|
+
* A replacement must look like a path before it may be written into a file.
|
|
93
|
+
*
|
|
94
|
+
* The destination comes from git's rename output, which is raw bytes: a path
|
|
95
|
+
* may legally contain a quote, a backslash, a newline. Splicing one of those
|
|
96
|
+
* into a source line would not fix a reference, it would edit the program. A
|
|
97
|
+
* replacement that fails this test is not proposed at all — the candidate falls
|
|
98
|
+
* back to the advisory list, which proposes nothing.
|
|
99
|
+
*/
|
|
100
|
+
const SAFE_REPLACEMENT = /^[A-Za-z0-9_@./-]+$/;
|
|
101
|
+
/**
|
|
102
|
+
* Hard ceiling on findings kept in memory, per list.
|
|
103
|
+
*
|
|
104
|
+
* A byte budget on the input text does not bound what is derived from it: 64MB
|
|
105
|
+
* of four-character path-shaped tokens is millions of finding objects. Past the
|
|
106
|
+
* cap the finding is counted and dropped, never silently lost — the totals stay
|
|
107
|
+
* exact.
|
|
108
|
+
*/
|
|
109
|
+
const MAX_FINDINGS = 5000;
|
|
110
|
+
/**
|
|
111
|
+
* A replacement is proposable only if it is a path that still means a path.
|
|
112
|
+
*
|
|
113
|
+
* The charset test alone is not enough: a rename whose destination is the
|
|
114
|
+
* literal's own directory renders as the bare `./`, which is well-formed
|
|
115
|
+
* charset-wise and turns `require("./c")` into `require("./")` — a different
|
|
116
|
+
* module, not a repaired reference. The same shape produces `../` and, in the
|
|
117
|
+
* root-relative form, a bare prefix with no path left in it.
|
|
118
|
+
*/
|
|
119
|
+
function isProposableReplacement(token) {
|
|
120
|
+
if (token.length === 0 || token.length > MAX_TOKEN_LENGTH)
|
|
121
|
+
return false;
|
|
122
|
+
if (!SAFE_REPLACEMENT.test(token))
|
|
123
|
+
return false;
|
|
124
|
+
if (token.endsWith('/'))
|
|
125
|
+
return false;
|
|
126
|
+
// Every literal the scanner accepts contains a separator; a replacement that
|
|
127
|
+
// does not is a different kind of thing.
|
|
128
|
+
if (!token.includes('/'))
|
|
129
|
+
return false;
|
|
130
|
+
const segments = token.split('/');
|
|
131
|
+
let index = 0;
|
|
132
|
+
while (index < segments.length && (segments[index] === '.' || segments[index] === '..')) {
|
|
133
|
+
index += 1;
|
|
134
|
+
}
|
|
135
|
+
const rest = segments.slice(index);
|
|
136
|
+
if (rest.length === 0)
|
|
137
|
+
return false;
|
|
138
|
+
return !rest.some((segment) => segment.length === 0 || segment === '.' || segment === '..');
|
|
139
|
+
}
|
|
140
|
+
/** Directory names that are never product source. */
|
|
141
|
+
const NEVER_PRODUCT_SEGMENTS = new Set(['node_modules', '.git', 'dist', 'coverage', 'build']);
|
|
142
|
+
/**
|
|
143
|
+
* Generated files are regenerated, not corrected (trap 4).
|
|
144
|
+
*
|
|
145
|
+
* Matches the repo-relative path only — a content sniff would need the file
|
|
146
|
+
* body and this stays a pure path predicate. The CLI side may skip more.
|
|
147
|
+
*/
|
|
148
|
+
export function isGeneratedLiteralPathFile(relPath) {
|
|
149
|
+
const normalized = normalizeRelative(relPath);
|
|
150
|
+
if (normalized.length === 0)
|
|
151
|
+
return false;
|
|
152
|
+
const segments = normalized.split('/');
|
|
153
|
+
if (segments.some((segment) => segment === 'generated' || segment === '__generated__')) {
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
const base = segments[segments.length - 1] ?? '';
|
|
157
|
+
return /\.(generated|gen)\.[cm]?[jt]sx?$/i.test(base) || /\.d\.ts$/i.test(base);
|
|
158
|
+
}
|
|
159
|
+
/** True when the file extension is one the drift scan reads. */
|
|
160
|
+
export function isLiteralPathScannable(relPath) {
|
|
161
|
+
const lower = normalizeRelative(relPath).toLowerCase();
|
|
162
|
+
return LITERAL_PATH_SCAN_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Every repo-relative path a reference may legitimately resolve to (trap 2).
|
|
166
|
+
*
|
|
167
|
+
* The bare path comes first so a directory reference resolves as itself.
|
|
168
|
+
*/
|
|
169
|
+
export function resolutionCandidates(target) {
|
|
170
|
+
const normalized = normalizeRelative(target);
|
|
171
|
+
if (normalized.length === 0)
|
|
172
|
+
return [];
|
|
173
|
+
const out = [normalized];
|
|
174
|
+
const base = normalized.split('/').pop() ?? '';
|
|
175
|
+
if (base.includes('.'))
|
|
176
|
+
return out;
|
|
177
|
+
// Only an extensionless reference can mean "a file with an extension" or "a
|
|
178
|
+
// directory with an index". `src/foo.ts` naming a directory that holds an
|
|
179
|
+
// index would be a live path, not this one.
|
|
180
|
+
for (const ext of RESOLUTION_EXTENSIONS)
|
|
181
|
+
out.push(`${normalized}${ext}`);
|
|
182
|
+
for (const index of RESOLUTION_INDEX_FILES)
|
|
183
|
+
out.push(`${normalized}${index}`);
|
|
184
|
+
return out;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Per-character context for one file: code, inside a string literal, or inside
|
|
188
|
+
* a comment. A tiny state machine, not a parser — it only needs to be right
|
|
189
|
+
* about where a path-shaped token was written, and being wrong costs at most a
|
|
190
|
+
* form label (or, in code context, one skipped candidate).
|
|
191
|
+
*
|
|
192
|
+
* Markdown has no code/string/comment structure worth modelling: the whole file
|
|
193
|
+
* is prose.
|
|
194
|
+
*/
|
|
195
|
+
const CTX_CODE = 0;
|
|
196
|
+
const CTX_STRING = 1;
|
|
197
|
+
const CTX_COMMENT = 2;
|
|
198
|
+
/**
|
|
199
|
+
* Per-character context for one file: code, inside a string literal, or inside
|
|
200
|
+
* a comment. A tiny state machine, not a parser — it only needs to be right
|
|
201
|
+
* about where a path-shaped token was written, and being wrong costs at most a
|
|
202
|
+
* form label (or, in code context, one skipped candidate).
|
|
203
|
+
*
|
|
204
|
+
* A `${...}` interpolation inside a template literal is CODE, not string: the
|
|
205
|
+
* text between the braces is an expression, and rewriting a "path" found there
|
|
206
|
+
* would change what the program computes.
|
|
207
|
+
*
|
|
208
|
+
* Markdown has no code/string/comment structure worth modelling: the whole file
|
|
209
|
+
* is prose. It returns `null` rather than a filled array — an allocation the
|
|
210
|
+
* size of the file, for a constant.
|
|
211
|
+
*
|
|
212
|
+
* One byte per character (Uint8Array), not one pointer: on this repo the walk
|
|
213
|
+
* reads ~9.8M characters, and a boxed array is 8x that in transient garbage.
|
|
214
|
+
*/
|
|
215
|
+
function contextMap(relPath, text) {
|
|
216
|
+
if (relPath.toLowerCase().endsWith('.md'))
|
|
217
|
+
return null;
|
|
218
|
+
const out = new Uint8Array(text.length);
|
|
219
|
+
const blockCommentOnly = /\.(css|scss)$/i.test(relPath);
|
|
220
|
+
/** Open template literals, innermost last; each tracks its `{` nesting. */
|
|
221
|
+
const templates = [];
|
|
222
|
+
let i = 0;
|
|
223
|
+
let state = CTX_CODE;
|
|
224
|
+
let quote = '';
|
|
225
|
+
let lineComment = false;
|
|
226
|
+
while (i < text.length) {
|
|
227
|
+
const ch = text[i];
|
|
228
|
+
const next = text[i + 1];
|
|
229
|
+
if (state === CTX_COMMENT) {
|
|
230
|
+
out[i] = CTX_COMMENT;
|
|
231
|
+
if (lineComment) {
|
|
232
|
+
if (ch === '\n') {
|
|
233
|
+
state = CTX_CODE;
|
|
234
|
+
lineComment = false;
|
|
235
|
+
}
|
|
236
|
+
i += 1;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (ch === '*' && next === '/') {
|
|
240
|
+
out[i + 1] = CTX_COMMENT;
|
|
241
|
+
state = CTX_CODE;
|
|
242
|
+
i += 2;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
i += 1;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (state === CTX_STRING) {
|
|
249
|
+
out[i] = CTX_STRING;
|
|
250
|
+
if (ch === '\\') {
|
|
251
|
+
if (i + 1 < text.length)
|
|
252
|
+
out[i + 1] = CTX_STRING;
|
|
253
|
+
i += 2;
|
|
254
|
+
continue;
|
|
255
|
+
}
|
|
256
|
+
if (quote === '`' && ch === '$' && next === '{') {
|
|
257
|
+
// Enter the interpolation: expression text, not literal text.
|
|
258
|
+
out[i + 1] = CTX_CODE;
|
|
259
|
+
templates.push(0);
|
|
260
|
+
state = CTX_CODE;
|
|
261
|
+
i += 2;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (ch === quote) {
|
|
265
|
+
state = CTX_CODE;
|
|
266
|
+
quote = '';
|
|
267
|
+
}
|
|
268
|
+
else if (ch === '\n' && quote !== '`') {
|
|
269
|
+
// An unterminated quote must not swallow the rest of the file.
|
|
270
|
+
state = CTX_CODE;
|
|
271
|
+
quote = '';
|
|
272
|
+
}
|
|
273
|
+
i += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (templates.length > 0) {
|
|
277
|
+
// Inside `${ ... }`: track braces so a nested object literal does not
|
|
278
|
+
// close the interpolation early.
|
|
279
|
+
if (ch === '{') {
|
|
280
|
+
templates[templates.length - 1] = templates[templates.length - 1] + 1;
|
|
281
|
+
i += 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (ch === '}') {
|
|
285
|
+
const depth = templates[templates.length - 1];
|
|
286
|
+
if (depth === 0) {
|
|
287
|
+
templates.pop();
|
|
288
|
+
state = CTX_STRING;
|
|
289
|
+
quote = '`';
|
|
290
|
+
out[i] = CTX_STRING;
|
|
291
|
+
i += 1;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
templates[templates.length - 1] = depth - 1;
|
|
295
|
+
i += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
if (ch === '/' && next === '*') {
|
|
300
|
+
state = CTX_COMMENT;
|
|
301
|
+
lineComment = false;
|
|
302
|
+
out[i] = CTX_COMMENT;
|
|
303
|
+
out[i + 1] = CTX_COMMENT;
|
|
304
|
+
i += 2;
|
|
305
|
+
continue;
|
|
306
|
+
}
|
|
307
|
+
if (!blockCommentOnly && ch === '/' && next === '/') {
|
|
308
|
+
state = CTX_COMMENT;
|
|
309
|
+
lineComment = true;
|
|
310
|
+
out[i] = CTX_COMMENT;
|
|
311
|
+
out[i + 1] = CTX_COMMENT;
|
|
312
|
+
i += 2;
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
if (ch === '"' || ch === "'" || ch === '`') {
|
|
316
|
+
state = CTX_STRING;
|
|
317
|
+
quote = ch;
|
|
318
|
+
out[i] = CTX_STRING;
|
|
319
|
+
i += 1;
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
i += 1;
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
const TOKEN_PATTERN = /[A-Za-z0-9_@./~-]+/g;
|
|
327
|
+
/**
|
|
328
|
+
* Every path-shaped literal in one file, in source order.
|
|
329
|
+
*
|
|
330
|
+
* Tokens written in code context (not in a string, not in a comment) are not
|
|
331
|
+
* literals and are skipped — that is what keeps identifiers and JSX out.
|
|
332
|
+
*/
|
|
333
|
+
export function extractPathLiterals(relPath, text, options = {}) {
|
|
334
|
+
const file = normalizeRelative(relPath);
|
|
335
|
+
const aliasEntries = Object.entries(options.aliases ?? DEFAULT_ALIASES);
|
|
336
|
+
const roots = options.roots instanceof Set
|
|
337
|
+
? options.roots
|
|
338
|
+
: new Set(options.roots);
|
|
339
|
+
const context = contextMap(file, text);
|
|
340
|
+
const dir = file.includes('/') ? file.slice(0, file.lastIndexOf('/')) : '';
|
|
341
|
+
const out = [];
|
|
342
|
+
let offset = 0;
|
|
343
|
+
const lines = text.split('\n');
|
|
344
|
+
for (let lineIndex = 0; lineIndex < lines.length; lineIndex += 1) {
|
|
345
|
+
const line = lines[lineIndex];
|
|
346
|
+
TOKEN_PATTERN.lastIndex = 0;
|
|
347
|
+
let match;
|
|
348
|
+
while ((match = TOKEN_PATTERN.exec(line)) !== null) {
|
|
349
|
+
const start = offset + match.index;
|
|
350
|
+
// A null context map means the whole file is prose (markdown).
|
|
351
|
+
const where = context === null ? CTX_COMMENT : (context[start] ?? CTX_CODE);
|
|
352
|
+
if (where === CTX_CODE)
|
|
353
|
+
continue;
|
|
354
|
+
const token = trimTrailingPunctuation(match[0]);
|
|
355
|
+
if (!isPathShaped(token, aliasEntries))
|
|
356
|
+
continue;
|
|
357
|
+
const resolved = interpretToken(token, dir, aliasEntries, roots, where);
|
|
358
|
+
if (resolved === null)
|
|
359
|
+
continue;
|
|
360
|
+
out.push({
|
|
361
|
+
file,
|
|
362
|
+
line: lineIndex + 1,
|
|
363
|
+
column: match.index + 1,
|
|
364
|
+
token,
|
|
365
|
+
form: resolved.form,
|
|
366
|
+
target: resolved.target,
|
|
367
|
+
...(resolved.aliasPrefix === undefined
|
|
368
|
+
? {}
|
|
369
|
+
: { aliasPrefix: resolved.aliasPrefix, aliasRoot: resolved.aliasRoot }),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
offset += line.length + 1;
|
|
373
|
+
}
|
|
374
|
+
return out;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Anchored + unanchored drift over a set of already-read files.
|
|
378
|
+
*
|
|
379
|
+
* `exists` answers for repo-relative paths and is the only thing standing
|
|
380
|
+
* between a candidate and a false positive (trap 1).
|
|
381
|
+
*/
|
|
382
|
+
export function findLiteralPathDrift(input) {
|
|
383
|
+
const rootlessPrefixes = normalizePrefixes(input.rootlessPrefixes);
|
|
384
|
+
// One memo for the whole run: the same target is probed many times (measured
|
|
385
|
+
// 16k calls over 3.7k distinct targets on this repo), and each miss builds
|
|
386
|
+
// ~19 candidate strings before touching the filesystem.
|
|
387
|
+
const resolvedMemo = new Map();
|
|
388
|
+
const resolvesOnce = (target) => {
|
|
389
|
+
const cached = resolvedMemo.get(target);
|
|
390
|
+
if (cached !== undefined)
|
|
391
|
+
return cached;
|
|
392
|
+
const answer = resolves(target, input.exists);
|
|
393
|
+
resolvedMemo.set(target, answer);
|
|
394
|
+
return answer;
|
|
395
|
+
};
|
|
396
|
+
const anchors = buildAnchors(input.renames ?? [], resolvesOnce);
|
|
397
|
+
const roots = new Set(input.roots ?? []);
|
|
398
|
+
const anchored = [];
|
|
399
|
+
const unanchored = [];
|
|
400
|
+
let candidates = 0;
|
|
401
|
+
let scannedFiles = 0;
|
|
402
|
+
let anchoredCount = 0;
|
|
403
|
+
let unanchoredCount = 0;
|
|
404
|
+
for (const file of input.files) {
|
|
405
|
+
if (!isLiteralPathScannable(file.path))
|
|
406
|
+
continue;
|
|
407
|
+
if (isGeneratedLiteralPathFile(file.path))
|
|
408
|
+
continue;
|
|
409
|
+
scannedFiles += 1;
|
|
410
|
+
for (const candidate of extractPathLiterals(file.path, file.text, {
|
|
411
|
+
aliases: input.aliases,
|
|
412
|
+
roots,
|
|
413
|
+
})) {
|
|
414
|
+
candidates += 1;
|
|
415
|
+
const reads = readings(candidate, rootlessPrefixes);
|
|
416
|
+
if (reads.some((read) => resolvesOnce(read)))
|
|
417
|
+
continue;
|
|
418
|
+
const hit = firstAnchor(reads, anchors.map);
|
|
419
|
+
// Three things must hold before a replacement is proposed:
|
|
420
|
+
// - a rename explains the reference;
|
|
421
|
+
// - the destination itself resolves, or the "fix" just moves the drift;
|
|
422
|
+
// - the destination is path-shaped, or writing it would edit the program
|
|
423
|
+
// rather than repair a reference (git paths are raw bytes).
|
|
424
|
+
const suggestedToken = hit === null ? null : renderToken(candidate, hit.readingIndex, hit.target, rootlessPrefixes);
|
|
425
|
+
if (hit === null ||
|
|
426
|
+
!resolvesOnce(hit.target) ||
|
|
427
|
+
!isProposableReplacement(hit.target) ||
|
|
428
|
+
suggestedToken === null ||
|
|
429
|
+
!isProposableReplacement(suggestedToken)) {
|
|
430
|
+
unanchoredCount += 1;
|
|
431
|
+
if (unanchored.length < MAX_FINDINGS) {
|
|
432
|
+
unanchored.push({
|
|
433
|
+
...candidate,
|
|
434
|
+
ruleId: LITERAL_PATH_UNRESOLVED_RULE_ID,
|
|
435
|
+
suggestedTarget: null,
|
|
436
|
+
suggestedToken: null,
|
|
437
|
+
anchor: null,
|
|
438
|
+
message: unanchoredMessage(candidate),
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
continue;
|
|
442
|
+
}
|
|
443
|
+
anchoredCount += 1;
|
|
444
|
+
if (anchored.length >= MAX_FINDINGS)
|
|
445
|
+
continue;
|
|
446
|
+
anchored.push({
|
|
447
|
+
...candidate,
|
|
448
|
+
ruleId: LITERAL_PATH_DRIFT_RULE_ID,
|
|
449
|
+
suggestedTarget: hit.target,
|
|
450
|
+
suggestedToken,
|
|
451
|
+
anchor: hit.rename,
|
|
452
|
+
message: anchoredMessage(candidate, hit.rename, suggestedToken),
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
scannedFiles,
|
|
458
|
+
candidates,
|
|
459
|
+
anchorsConsidered: anchors.map.size,
|
|
460
|
+
ambiguousAnchors: anchors.ambiguous,
|
|
461
|
+
anchored,
|
|
462
|
+
unanchored,
|
|
463
|
+
anchoredCount,
|
|
464
|
+
unanchoredCount,
|
|
465
|
+
findingCap: MAX_FINDINGS,
|
|
466
|
+
truncated: {
|
|
467
|
+
anchored: anchoredCount > anchored.length,
|
|
468
|
+
unanchored: unanchoredCount > unanchored.length,
|
|
469
|
+
},
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Rewrite one file's text from its own anchored findings.
|
|
474
|
+
*
|
|
475
|
+
* Only the exact token on its own line is replaced, and only when that line
|
|
476
|
+
* still holds it — so a stale finding is skipped rather than corrupting a file,
|
|
477
|
+
* and re-running after a write is a no-op.
|
|
478
|
+
*/
|
|
479
|
+
export function applyLiteralPathDrift(text, findings) {
|
|
480
|
+
const lines = text.split('\n');
|
|
481
|
+
const applied = [];
|
|
482
|
+
const skipped = [];
|
|
483
|
+
// Later columns first, so an earlier replacement cannot move a later one.
|
|
484
|
+
const ordered = [...findings].sort((a, b) => b.line - a.line || b.column - a.column);
|
|
485
|
+
for (const finding of ordered) {
|
|
486
|
+
const suggestion = finding.suggestedToken;
|
|
487
|
+
const index = finding.line - 1;
|
|
488
|
+
const line = lines[index];
|
|
489
|
+
if (suggestion === null || line === undefined) {
|
|
490
|
+
skipped.push(finding);
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
const at = finding.column - 1;
|
|
494
|
+
if (line.slice(at, at + finding.token.length) !== finding.token) {
|
|
495
|
+
skipped.push(finding);
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
lines[index] = line.slice(0, at) + suggestion + line.slice(at + finding.token.length);
|
|
499
|
+
applied.push(finding);
|
|
500
|
+
}
|
|
501
|
+
return { text: lines.join('\n'), applied, skipped };
|
|
502
|
+
}
|
|
503
|
+
// ── internals ──────────────────────────────────────────────────────────────
|
|
504
|
+
function normalizeRelative(value) {
|
|
505
|
+
return value.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '');
|
|
506
|
+
}
|
|
507
|
+
function normalizePrefixes(prefixes) {
|
|
508
|
+
const out = [''];
|
|
509
|
+
for (const prefix of prefixes ?? []) {
|
|
510
|
+
const normalized = normalizeRelative(prefix);
|
|
511
|
+
if (normalized.length > 0 && !out.includes(normalized))
|
|
512
|
+
out.push(normalized);
|
|
513
|
+
}
|
|
514
|
+
return out;
|
|
515
|
+
}
|
|
516
|
+
function trimTrailingPunctuation(token) {
|
|
517
|
+
let end = token.length;
|
|
518
|
+
while (end > 0 && TRAILING_PUNCTUATION.has(token[end - 1]))
|
|
519
|
+
end -= 1;
|
|
520
|
+
return token.slice(0, end);
|
|
521
|
+
}
|
|
522
|
+
function isPathShaped(token, aliasEntries = []) {
|
|
523
|
+
if (token.length < 3 || token.length > MAX_TOKEN_LENGTH)
|
|
524
|
+
return false;
|
|
525
|
+
if (!token.includes('/'))
|
|
526
|
+
return false;
|
|
527
|
+
if (token.startsWith('/') || token.startsWith('~'))
|
|
528
|
+
return false;
|
|
529
|
+
if (token.includes('//'))
|
|
530
|
+
return false;
|
|
531
|
+
// A leading `@` is a scoped npm package unless it is a DECLARED alias prefix:
|
|
532
|
+
// `@radix-ui/react-dialog` is not a repo path, `@app/thing` is when the
|
|
533
|
+
// project's tsconfig says `@app/*` maps into the tree.
|
|
534
|
+
if (token.startsWith('@') &&
|
|
535
|
+
!aliasEntries.some(([prefix]) => token.startsWith(prefix))) {
|
|
536
|
+
return false;
|
|
537
|
+
}
|
|
538
|
+
const segments = token.split('/');
|
|
539
|
+
if (segments.length < 2)
|
|
540
|
+
return false;
|
|
541
|
+
if (segments.some((segment) => segment.length === 0))
|
|
542
|
+
return false;
|
|
543
|
+
if (segments.some((segment) => NEVER_PRODUCT_SEGMENTS.has(segment)))
|
|
544
|
+
return false;
|
|
545
|
+
return true;
|
|
546
|
+
}
|
|
547
|
+
function interpretToken(token, dir, aliasEntries, roots, where) {
|
|
548
|
+
// Longest alias prefix first: `@app/ui/*` must beat `@app/*`.
|
|
549
|
+
let bestPrefix = '';
|
|
550
|
+
let bestReplacement = '';
|
|
551
|
+
for (const [prefix, replacement] of aliasEntries) {
|
|
552
|
+
if (!token.startsWith(prefix) || prefix.length <= bestPrefix.length)
|
|
553
|
+
continue;
|
|
554
|
+
bestPrefix = prefix;
|
|
555
|
+
bestReplacement = replacement;
|
|
556
|
+
}
|
|
557
|
+
if (bestPrefix.length > 0) {
|
|
558
|
+
return {
|
|
559
|
+
form: 'alias',
|
|
560
|
+
target: joinPosix(bestReplacement, token.slice(bestPrefix.length)),
|
|
561
|
+
aliasPrefix: bestPrefix,
|
|
562
|
+
aliasRoot: normalizeRelative(bestReplacement),
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
if (token.startsWith('./') || token.startsWith('../')) {
|
|
566
|
+
const target = resolveRelative(dir, token);
|
|
567
|
+
return target === null ? null : { form: 'relative', target };
|
|
568
|
+
}
|
|
569
|
+
const first = token.split('/')[0];
|
|
570
|
+
if (!roots.has(first))
|
|
571
|
+
return null;
|
|
572
|
+
return {
|
|
573
|
+
form: where === CTX_COMMENT ? 'prose' : 'rootless',
|
|
574
|
+
target: normalizeRelative(token),
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function resolveRelative(dir, token) {
|
|
578
|
+
const segments = dir.length > 0 ? dir.split('/') : [];
|
|
579
|
+
for (const segment of token.split('/')) {
|
|
580
|
+
if (segment === '.')
|
|
581
|
+
continue;
|
|
582
|
+
if (segment === '..') {
|
|
583
|
+
if (segments.length === 0)
|
|
584
|
+
return null;
|
|
585
|
+
segments.pop();
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
segments.push(segment);
|
|
589
|
+
}
|
|
590
|
+
return segments.length === 0 ? null : segments.join('/');
|
|
591
|
+
}
|
|
592
|
+
function joinPosix(left, right) {
|
|
593
|
+
const l = normalizeRelative(left);
|
|
594
|
+
const r = normalizeRelative(right);
|
|
595
|
+
if (l.length === 0)
|
|
596
|
+
return r;
|
|
597
|
+
if (r.length === 0)
|
|
598
|
+
return l;
|
|
599
|
+
return `${l}/${r}`;
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* Every repo-relative reading of one candidate. Index 0 is the plain target;
|
|
603
|
+
* a `rootless` candidate is also read under each declared prefix, because
|
|
604
|
+
* `components/x` in a test that concatenates from the repo root means
|
|
605
|
+
* `src/components/x`.
|
|
606
|
+
*/
|
|
607
|
+
function readings(candidate, rootlessPrefixes) {
|
|
608
|
+
if (candidate.form === 'alias' || candidate.form === 'relative')
|
|
609
|
+
return [candidate.target];
|
|
610
|
+
const out = [];
|
|
611
|
+
for (const prefix of rootlessPrefixes) {
|
|
612
|
+
const joined = joinPosix(prefix, candidate.target);
|
|
613
|
+
if (!out.includes(joined))
|
|
614
|
+
out.push(joined);
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
function resolves(target, exists) {
|
|
619
|
+
return resolutionCandidates(target).some((path) => exists(path));
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* The rename sources that may anchor a finding: file renames plus the directory
|
|
623
|
+
* renames they imply, keeping only sources that really are gone and that map to
|
|
624
|
+
* exactly one destination. A source with two destinations is not a mechanical
|
|
625
|
+
* one-directional fix, so it anchors nothing and its references fall through to
|
|
626
|
+
* the advisory list.
|
|
627
|
+
*/
|
|
628
|
+
function buildAnchors(renames, resolvesOnce) {
|
|
629
|
+
const collected = new Map();
|
|
630
|
+
const add = (from, to) => {
|
|
631
|
+
const key = normalizeRelative(from);
|
|
632
|
+
const value = normalizeRelative(to);
|
|
633
|
+
if (key.length === 0 || value.length === 0 || key === value)
|
|
634
|
+
return;
|
|
635
|
+
// Git paths are raw bytes. A destination that is not path-shaped can never
|
|
636
|
+
// become a replacement, and a SOURCE that is not path-shaped is printed in
|
|
637
|
+
// the ambiguous-anchor line — neither may carry a control character.
|
|
638
|
+
if (!SAFE_REPLACEMENT.test(value) || value.length > MAX_TOKEN_LENGTH)
|
|
639
|
+
return;
|
|
640
|
+
if (!SAFE_REPLACEMENT.test(key) || key.length > MAX_TOKEN_LENGTH)
|
|
641
|
+
return;
|
|
642
|
+
const set = collected.get(key) ?? new Set();
|
|
643
|
+
set.add(value);
|
|
644
|
+
collected.set(key, set);
|
|
645
|
+
};
|
|
646
|
+
for (const rename of renames) {
|
|
647
|
+
add(rename.from, rename.to);
|
|
648
|
+
add(stripExtension(rename.from), stripExtension(rename.to));
|
|
649
|
+
const fromDir = dirnamePosix(rename.from);
|
|
650
|
+
const toDir = dirnamePosix(rename.to);
|
|
651
|
+
if (fromDir.length > 0 && toDir.length > 0 && basename(rename.from) === basename(rename.to)) {
|
|
652
|
+
add(fromDir, toDir);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
const map = new Map();
|
|
656
|
+
const ambiguous = [];
|
|
657
|
+
for (const [from, destinations] of collected) {
|
|
658
|
+
if (destinations.size !== 1) {
|
|
659
|
+
ambiguous.push(from);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
// A source that still exists is not drift — the reference is live.
|
|
663
|
+
if (resolvesOnce(from))
|
|
664
|
+
continue;
|
|
665
|
+
map.set(from, { from, to: [...destinations][0] });
|
|
666
|
+
}
|
|
667
|
+
ambiguous.sort();
|
|
668
|
+
return { map, ambiguous };
|
|
669
|
+
}
|
|
670
|
+
function firstAnchor(reads, anchors) {
|
|
671
|
+
for (let index = 0; index < reads.length; index += 1) {
|
|
672
|
+
const read = reads[index];
|
|
673
|
+
// Walk the reading's own ancestors, longest first: the nearest rename is
|
|
674
|
+
// the most specific one, and this is O(depth) rather than O(anchors) —
|
|
675
|
+
// the field set was 783 renames against ~10k literals.
|
|
676
|
+
const segments = read.split('/');
|
|
677
|
+
for (let cut = segments.length; cut > 0; cut -= 1) {
|
|
678
|
+
const prefix = segments.slice(0, cut).join('/');
|
|
679
|
+
const rename = anchors.get(prefix);
|
|
680
|
+
if (!rename)
|
|
681
|
+
continue;
|
|
682
|
+
const rest = segments.slice(cut).join('/');
|
|
683
|
+
return { rename, target: joinPosix(rename.to, rest), readingIndex: index };
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
return null;
|
|
687
|
+
}
|
|
688
|
+
/** Rewrite the destination back into the form the author wrote. */
|
|
689
|
+
function renderToken(candidate, readingIndex, target, rootlessPrefixes) {
|
|
690
|
+
if (candidate.form === 'alias') {
|
|
691
|
+
// The prefix and its root were recorded when the token was interpreted, so
|
|
692
|
+
// a multi-segment alias (`@app/ui/*`) rewrites correctly.
|
|
693
|
+
const aliasPrefix = candidate.aliasPrefix;
|
|
694
|
+
const aliasRoot = candidate.aliasRoot ?? '';
|
|
695
|
+
if (aliasPrefix === undefined)
|
|
696
|
+
return null;
|
|
697
|
+
if (aliasRoot.length > 0 && !target.startsWith(`${aliasRoot}/`))
|
|
698
|
+
return null;
|
|
699
|
+
const rest = aliasRoot.length > 0 ? target.slice(aliasRoot.length + 1) : target;
|
|
700
|
+
return `${aliasPrefix}${rest}`;
|
|
701
|
+
}
|
|
702
|
+
if (candidate.form === 'relative') {
|
|
703
|
+
const dir = candidate.file.includes('/')
|
|
704
|
+
? candidate.file.slice(0, candidate.file.lastIndexOf('/'))
|
|
705
|
+
: '';
|
|
706
|
+
return relativeFrom(dir, target);
|
|
707
|
+
}
|
|
708
|
+
const prefix = rootlessPrefixes[readingIndex] ?? '';
|
|
709
|
+
if (prefix.length === 0)
|
|
710
|
+
return target;
|
|
711
|
+
return target.startsWith(`${prefix}/`) ? target.slice(prefix.length + 1) : target;
|
|
712
|
+
}
|
|
713
|
+
function relativeFrom(dir, target) {
|
|
714
|
+
const fromParts = dir.length > 0 ? dir.split('/') : [];
|
|
715
|
+
const toParts = target.split('/');
|
|
716
|
+
let shared = 0;
|
|
717
|
+
while (shared < fromParts.length && shared < toParts.length && fromParts[shared] === toParts[shared]) {
|
|
718
|
+
shared += 1;
|
|
719
|
+
}
|
|
720
|
+
const up = fromParts.length - shared;
|
|
721
|
+
const rest = toParts.slice(shared).join('/');
|
|
722
|
+
if (up === 0)
|
|
723
|
+
return `./${rest}`;
|
|
724
|
+
return `${'../'.repeat(up)}${rest}`;
|
|
725
|
+
}
|
|
726
|
+
function stripExtension(value) {
|
|
727
|
+
const normalized = normalizeRelative(value);
|
|
728
|
+
const base = basename(normalized);
|
|
729
|
+
const dot = base.lastIndexOf('.');
|
|
730
|
+
if (dot <= 0)
|
|
731
|
+
return normalized;
|
|
732
|
+
return normalized.slice(0, normalized.length - (base.length - dot));
|
|
733
|
+
}
|
|
734
|
+
function dirnamePosix(value) {
|
|
735
|
+
const normalized = normalizeRelative(value);
|
|
736
|
+
const slash = normalized.lastIndexOf('/');
|
|
737
|
+
return slash <= 0 ? '' : normalized.slice(0, slash);
|
|
738
|
+
}
|
|
739
|
+
function basename(value) {
|
|
740
|
+
const normalized = normalizeRelative(value);
|
|
741
|
+
return normalized.slice(normalized.lastIndexOf('/') + 1);
|
|
742
|
+
}
|
|
743
|
+
function formLabel(form) {
|
|
744
|
+
switch (form) {
|
|
745
|
+
case 'alias':
|
|
746
|
+
return 'alias literal';
|
|
747
|
+
case 'relative':
|
|
748
|
+
return 'relative literal';
|
|
749
|
+
case 'rootless':
|
|
750
|
+
return 'root-relative literal';
|
|
751
|
+
case 'prose':
|
|
752
|
+
return 'comment / docstring';
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
function anchoredMessage(candidate, rename, suggestedToken) {
|
|
756
|
+
const replacement = suggestedToken === null ? rename.to : `${suggestedToken} (${rename.from} → ${rename.to})`;
|
|
757
|
+
return `Literal path "${candidate.token}" (${formLabel(candidate.form)}) does not resolve; the rename set says it moved. Replace with ${replacement}.`;
|
|
758
|
+
}
|
|
759
|
+
function unanchoredMessage(candidate) {
|
|
760
|
+
return `Literal path "${candidate.token}" (${formLabel(candidate.form)}) does not resolve under this root, and no rename explains where it went. Advisory: ArkGate has no destination to propose.`;
|
|
761
|
+
}
|