@vibecompass/vibecompass 0.10.0 → 0.10.2
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 +18 -0
- package/README.md +4 -0
- package/package.json +3 -2
- package/src/cli.js +16 -0
- package/src/docs-review.js +348 -7
- package/src/generators/agent-files/template.js +1 -0
- package/src/scaffold.js +1 -0
- package/src/source-inventory.js +915 -0
- package/src/workflows/registry.js +5 -3
|
@@ -0,0 +1,915 @@
|
|
|
1
|
+
import { access, lstat, mkdir, readdir, readFile, realpath, stat, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PACKAGE_VERSION } from './version.js';
|
|
4
|
+
|
|
5
|
+
export const SOURCE_INVENTORY_STATE_VERSION = 1;
|
|
6
|
+
export const SOURCE_INVENTORY_SCANNER_VERSION = 'source-inventory-v1';
|
|
7
|
+
export const SCANNED_UNACCOUNTED_WARNING_CODE = 'scanned_unaccounted';
|
|
8
|
+
export const SOURCE_UNAVAILABLE_WARNING_CODE = 'source_unavailable';
|
|
9
|
+
|
|
10
|
+
const DEFAULT_MAX_FILES = 2000;
|
|
11
|
+
const DEFAULT_MAX_FILE_BYTES = 256 * 1024;
|
|
12
|
+
const IGNORED_DIRS = new Set([
|
|
13
|
+
'.git',
|
|
14
|
+
'.hg',
|
|
15
|
+
'.svn',
|
|
16
|
+
'.next',
|
|
17
|
+
'.nuxt',
|
|
18
|
+
'.turbo',
|
|
19
|
+
'.vercel',
|
|
20
|
+
'node_modules',
|
|
21
|
+
'dist',
|
|
22
|
+
'build',
|
|
23
|
+
'coverage',
|
|
24
|
+
'.cache',
|
|
25
|
+
'tmp',
|
|
26
|
+
'temp',
|
|
27
|
+
]);
|
|
28
|
+
const IGNORED_FILE_NAMES = new Set([
|
|
29
|
+
'.DS_Store',
|
|
30
|
+
'Thumbs.db',
|
|
31
|
+
'desktop.ini',
|
|
32
|
+
]);
|
|
33
|
+
const BINARY_EXTENSIONS = new Set([
|
|
34
|
+
'.png',
|
|
35
|
+
'.jpg',
|
|
36
|
+
'.jpeg',
|
|
37
|
+
'.gif',
|
|
38
|
+
'.webp',
|
|
39
|
+
'.ico',
|
|
40
|
+
'.pdf',
|
|
41
|
+
'.zip',
|
|
42
|
+
'.gz',
|
|
43
|
+
'.tgz',
|
|
44
|
+
'.woff',
|
|
45
|
+
'.woff2',
|
|
46
|
+
'.ttf',
|
|
47
|
+
'.otf',
|
|
48
|
+
'.mp3',
|
|
49
|
+
'.mp4',
|
|
50
|
+
'.mov',
|
|
51
|
+
]);
|
|
52
|
+
const PROVIDERS = [
|
|
53
|
+
'anthropic',
|
|
54
|
+
'clerk',
|
|
55
|
+
'github',
|
|
56
|
+
'google',
|
|
57
|
+
'openai',
|
|
58
|
+
'stripe',
|
|
59
|
+
'supabase',
|
|
60
|
+
'tiktok',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
export async function buildDocsReviewSourceInventory(project, options = {}) {
|
|
64
|
+
const rootDir = path.resolve(options.rootDir ?? process.cwd());
|
|
65
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
66
|
+
const generatedAt = options.generatedAt instanceof Date ? options.generatedAt : new Date();
|
|
67
|
+
const overrides = normalizeSourceRootOverrides(options.sourceRootOverrides);
|
|
68
|
+
const sourceRoots = [];
|
|
69
|
+
const itemsByKey = new Map();
|
|
70
|
+
const warnings = [];
|
|
71
|
+
|
|
72
|
+
for (const repo of normalizeRepos(project?.repos)) {
|
|
73
|
+
const resolved = await resolveRepoSourceRoot({
|
|
74
|
+
repo,
|
|
75
|
+
rootDir,
|
|
76
|
+
cwd,
|
|
77
|
+
overrides,
|
|
78
|
+
projectRepoRootPath: normalizeRepoRootPath(project?.repo_root_path ?? project?.repoRootPath),
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
sourceRoots.push(resolved.sourceRoot);
|
|
82
|
+
warnings.push(...resolved.warnings);
|
|
83
|
+
|
|
84
|
+
if (resolved.sourceRoot.status !== 'scanned') {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const scanResult = await scanRepoRoot({
|
|
89
|
+
repoId: repo.id,
|
|
90
|
+
scanPath: resolved.scanPath,
|
|
91
|
+
sourcePath: resolved.sourceRoot.path,
|
|
92
|
+
maxFiles: options.maxFiles ?? DEFAULT_MAX_FILES,
|
|
93
|
+
maxFileBytes: options.maxFileBytes ?? DEFAULT_MAX_FILE_BYTES,
|
|
94
|
+
});
|
|
95
|
+
warnings.push(...scanResult.warnings);
|
|
96
|
+
for (const item of scanResult.items) {
|
|
97
|
+
mergeItem(itemsByKey, item);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const items = [...itemsByKey.values()]
|
|
102
|
+
.map((item) => ({
|
|
103
|
+
...item,
|
|
104
|
+
evidence: item.evidence.sort((left, right) => left.path.localeCompare(right.path)),
|
|
105
|
+
}))
|
|
106
|
+
.sort((left, right) => left.id.localeCompare(right.id));
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
version: SOURCE_INVENTORY_STATE_VERSION,
|
|
110
|
+
producer: {
|
|
111
|
+
package_version: PACKAGE_VERSION,
|
|
112
|
+
scanner_version: SOURCE_INVENTORY_SCANNER_VERSION,
|
|
113
|
+
generated_at: generatedAt.toISOString(),
|
|
114
|
+
},
|
|
115
|
+
source_roots: sourceRoots,
|
|
116
|
+
summary: {
|
|
117
|
+
item_count: items.length,
|
|
118
|
+
by_kind: countBy(items, 'kind'),
|
|
119
|
+
warning_count: warnings.length,
|
|
120
|
+
},
|
|
121
|
+
items,
|
|
122
|
+
warnings,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function writeDocsReviewSourceInventory(rootDir, inventory) {
|
|
127
|
+
const statePath = docsReviewSourceInventoryPath(rootDir);
|
|
128
|
+
await mkdir(path.dirname(statePath), { recursive: true });
|
|
129
|
+
await writeFile(statePath, `${JSON.stringify(inventory, null, 2)}\n`, 'utf8');
|
|
130
|
+
return statePath;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export async function readDocsReviewSourceInventory(rootDir) {
|
|
134
|
+
try {
|
|
135
|
+
return JSON.parse(await readFile(docsReviewSourceInventoryPath(rootDir), 'utf8'));
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error?.code === 'ENOENT') {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function docsReviewSourceInventoryPath(rootDir) {
|
|
145
|
+
return path.join(rootDir, 'state', 'docs-review-source-inventory.json');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export function summarizeSourceInventory(inventory) {
|
|
149
|
+
if (!inventory) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
path: 'state/docs-review-source-inventory.json',
|
|
155
|
+
scanner_version: inventory.producer?.scanner_version ?? null,
|
|
156
|
+
item_count: Array.isArray(inventory.items) ? inventory.items.length : 0,
|
|
157
|
+
by_kind: inventory.summary?.by_kind ?? countBy(inventory.items ?? [], 'kind'),
|
|
158
|
+
source_roots: Array.isArray(inventory.source_roots)
|
|
159
|
+
? inventory.source_roots.map((sourceRoot) => ({
|
|
160
|
+
repo_id: sourceRoot.repo_id,
|
|
161
|
+
kind: sourceRoot.kind,
|
|
162
|
+
status: sourceRoot.status,
|
|
163
|
+
...(sourceRoot.repo_root_path ? { repo_root_path: sourceRoot.repo_root_path } : {}),
|
|
164
|
+
}))
|
|
165
|
+
: [],
|
|
166
|
+
unavailable_repo_ids: Array.isArray(inventory.source_roots)
|
|
167
|
+
? inventory.source_roots
|
|
168
|
+
.filter((sourceRoot) => sourceRoot.status === 'source_unavailable')
|
|
169
|
+
.map((sourceRoot) => sourceRoot.repo_id)
|
|
170
|
+
: [],
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function reconcileCoverageWithSourceInventory(coverageProjection, inventory) {
|
|
175
|
+
if (!coverageProjection || !inventory || !Array.isArray(inventory.items)) {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const scannedIds = new Set(inventory.items.map((item) => item.id).filter(Boolean));
|
|
180
|
+
const declaredIds = new Set(
|
|
181
|
+
Array.isArray(coverageProjection.completeness_inventory)
|
|
182
|
+
? coverageProjection.completeness_inventory.map((item) => item.id).filter(Boolean)
|
|
183
|
+
: [],
|
|
184
|
+
);
|
|
185
|
+
const unaccountedIds = [...scannedIds].filter((id) => !declaredIds.has(id)).sort();
|
|
186
|
+
const unknownDeclaredIds = [...declaredIds].filter((id) => !scannedIds.has(id)).sort();
|
|
187
|
+
const sourceUnavailableRepoIds = Array.isArray(inventory.source_roots)
|
|
188
|
+
? inventory.source_roots
|
|
189
|
+
.filter((sourceRoot) => sourceRoot.status === 'source_unavailable')
|
|
190
|
+
.map((sourceRoot) => sourceRoot.repo_id)
|
|
191
|
+
.sort()
|
|
192
|
+
: [];
|
|
193
|
+
const warnings = [
|
|
194
|
+
...unaccountedIds.map((id) => ({
|
|
195
|
+
code: SCANNED_UNACCOUNTED_WARNING_CODE,
|
|
196
|
+
inventory_id: id,
|
|
197
|
+
message: `Scanned source inventory item "${id}" is not accounted for in completeness_inventory.`,
|
|
198
|
+
})),
|
|
199
|
+
...sourceUnavailableRepoIds.map((repoId) => ({
|
|
200
|
+
code: SOURCE_UNAVAILABLE_WARNING_CODE,
|
|
201
|
+
repo_id: repoId,
|
|
202
|
+
message: `Repo "${repoId}" had no local source root, so scanned source coverage could not run for it.`,
|
|
203
|
+
})),
|
|
204
|
+
];
|
|
205
|
+
|
|
206
|
+
return {
|
|
207
|
+
scanned_count: scannedIds.size,
|
|
208
|
+
declared_count: declaredIds.size,
|
|
209
|
+
accounted_count: [...scannedIds].filter((id) => declaredIds.has(id)).length,
|
|
210
|
+
unaccounted_ids: unaccountedIds,
|
|
211
|
+
unknown_declared_ids: unknownDeclaredIds,
|
|
212
|
+
source_unavailable_repo_ids: sourceUnavailableRepoIds,
|
|
213
|
+
warnings,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function normalizeRepos(repos) {
|
|
218
|
+
return Array.isArray(repos)
|
|
219
|
+
? repos
|
|
220
|
+
.filter((repo) => repo && typeof repo === 'object' && normalizeOptionalString(repo.id))
|
|
221
|
+
.map((repo) => ({ ...repo, id: normalizeOptionalString(repo.id) }))
|
|
222
|
+
: [];
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function normalizeSourceRootOverrides(value) {
|
|
226
|
+
const overrides = new Map();
|
|
227
|
+
if (!value) {
|
|
228
|
+
return overrides;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const entries = Array.isArray(value)
|
|
232
|
+
? value
|
|
233
|
+
: Object.entries(value).map(([repoId, rootPath]) => ({ repoId, path: rootPath }));
|
|
234
|
+
for (const entry of entries) {
|
|
235
|
+
const repoId = normalizeOptionalString(entry?.repoId ?? entry?.repo_id ?? entry?.id);
|
|
236
|
+
const rootPath = normalizeOptionalString(entry?.path ?? entry?.rootPath ?? entry?.root_path);
|
|
237
|
+
if (repoId && rootPath) {
|
|
238
|
+
overrides.set(repoId, rootPath);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return overrides;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async function resolveRepoSourceRoot(options) {
|
|
245
|
+
const overridePath = options.overrides.get(options.repo.id);
|
|
246
|
+
const repoRootPath = normalizeRepoRootPath(options.repo.root_path ?? options.repo.repo_root_path ?? options.projectRepoRootPath);
|
|
247
|
+
const warnings = [];
|
|
248
|
+
|
|
249
|
+
if (overridePath) {
|
|
250
|
+
return resolveAvailableSourceRoot({
|
|
251
|
+
repo: options.repo,
|
|
252
|
+
kind: 'source_root_override',
|
|
253
|
+
configuredPath: overridePath,
|
|
254
|
+
cwd: options.cwd,
|
|
255
|
+
rootDir: options.rootDir,
|
|
256
|
+
repoRootPath: '/',
|
|
257
|
+
applyRepoRootPath: false,
|
|
258
|
+
warnings,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (options.repo.source === 'local' && normalizeOptionalString(options.repo.path)) {
|
|
263
|
+
return resolveAvailableSourceRoot({
|
|
264
|
+
repo: options.repo,
|
|
265
|
+
kind: 'local_descriptor',
|
|
266
|
+
configuredPath: options.repo.path,
|
|
267
|
+
cwd: options.cwd,
|
|
268
|
+
rootDir: options.rootDir,
|
|
269
|
+
repoRootPath,
|
|
270
|
+
applyRepoRootPath: true,
|
|
271
|
+
warnings,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
sourceRoot: {
|
|
277
|
+
repo_id: options.repo.id,
|
|
278
|
+
kind: 'unavailable',
|
|
279
|
+
status: 'source_unavailable',
|
|
280
|
+
...(normalizeOptionalString(options.repo.remote) ? { remote: normalizeOptionalString(options.repo.remote) } : {}),
|
|
281
|
+
},
|
|
282
|
+
scanPath: null,
|
|
283
|
+
warnings: [
|
|
284
|
+
{
|
|
285
|
+
code: SOURCE_UNAVAILABLE_WARNING_CODE,
|
|
286
|
+
repo_id: options.repo.id,
|
|
287
|
+
message: `Repo "${options.repo.id}" has no local source root; source inventory scanner skipped it.`,
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function resolveAvailableSourceRoot(options) {
|
|
294
|
+
const checkoutPath = await resolveConfiguredPath({
|
|
295
|
+
configuredPath: options.configuredPath,
|
|
296
|
+
cwd: options.cwd,
|
|
297
|
+
rootDir: options.rootDir,
|
|
298
|
+
});
|
|
299
|
+
const scanPath = options.applyRepoRootPath && options.repoRootPath !== '/'
|
|
300
|
+
? path.join(checkoutPath, options.repoRootPath)
|
|
301
|
+
: checkoutPath;
|
|
302
|
+
const sourceRoot = {
|
|
303
|
+
repo_id: options.repo.id,
|
|
304
|
+
kind: options.kind,
|
|
305
|
+
status: 'scanned',
|
|
306
|
+
path: checkoutPath,
|
|
307
|
+
scan_path: scanPath,
|
|
308
|
+
...(options.repoRootPath !== '/' ? { repo_root_path: options.repoRootPath } : {}),
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const scanStat = await stat(scanPath);
|
|
313
|
+
if (!scanStat.isDirectory()) {
|
|
314
|
+
throw new Error('not a directory');
|
|
315
|
+
}
|
|
316
|
+
return { sourceRoot, scanPath, warnings: options.warnings };
|
|
317
|
+
} catch {
|
|
318
|
+
return {
|
|
319
|
+
sourceRoot: {
|
|
320
|
+
...sourceRoot,
|
|
321
|
+
status: 'source_unavailable',
|
|
322
|
+
},
|
|
323
|
+
scanPath: null,
|
|
324
|
+
warnings: [
|
|
325
|
+
...options.warnings,
|
|
326
|
+
{
|
|
327
|
+
code: SOURCE_UNAVAILABLE_WARNING_CODE,
|
|
328
|
+
repo_id: options.repo.id,
|
|
329
|
+
path: scanPath,
|
|
330
|
+
message: `Repo "${options.repo.id}" source root was not readable at ${scanPath}.`,
|
|
331
|
+
},
|
|
332
|
+
],
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
async function resolveConfiguredPath(options) {
|
|
338
|
+
if (path.isAbsolute(options.configuredPath)) {
|
|
339
|
+
return path.normalize(options.configuredPath);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const candidates = [
|
|
343
|
+
path.resolve(options.cwd, options.configuredPath),
|
|
344
|
+
path.resolve(path.dirname(options.rootDir), options.configuredPath),
|
|
345
|
+
path.resolve(options.rootDir, options.configuredPath),
|
|
346
|
+
];
|
|
347
|
+
for (const candidate of candidates) {
|
|
348
|
+
try {
|
|
349
|
+
await access(candidate);
|
|
350
|
+
return candidate;
|
|
351
|
+
} catch {
|
|
352
|
+
// Try the next likely base. The final fallback remains deterministic.
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
return candidates[0];
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async function scanRepoRoot(options) {
|
|
359
|
+
const itemsByKey = new Map();
|
|
360
|
+
const warnings = [];
|
|
361
|
+
const visitedDirectories = new Set();
|
|
362
|
+
let scannedFileCount = 0;
|
|
363
|
+
let rootRealPath;
|
|
364
|
+
|
|
365
|
+
try {
|
|
366
|
+
rootRealPath = await realpath(options.scanPath);
|
|
367
|
+
} catch {
|
|
368
|
+
return {
|
|
369
|
+
items: [],
|
|
370
|
+
warnings: [
|
|
371
|
+
{
|
|
372
|
+
code: 'directory_unreadable',
|
|
373
|
+
repo_id: options.repoId,
|
|
374
|
+
path: '.',
|
|
375
|
+
message: `Skipped unreadable source root for repo "${options.repoId}".`,
|
|
376
|
+
},
|
|
377
|
+
],
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const pending = [rootRealPath];
|
|
382
|
+
|
|
383
|
+
while (pending.length > 0) {
|
|
384
|
+
const currentPath = pending.pop();
|
|
385
|
+
let directoryRealPath;
|
|
386
|
+
try {
|
|
387
|
+
directoryRealPath = await realpath(currentPath);
|
|
388
|
+
} catch {
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (visitedDirectories.has(directoryRealPath)) {
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
visitedDirectories.add(directoryRealPath);
|
|
395
|
+
|
|
396
|
+
let entries;
|
|
397
|
+
try {
|
|
398
|
+
entries = await readdir(currentPath, { withFileTypes: true });
|
|
399
|
+
} catch {
|
|
400
|
+
const relativePath = toPosixPath(path.relative(rootRealPath, currentPath)) || '.';
|
|
401
|
+
warnings.push({
|
|
402
|
+
code: 'directory_unreadable',
|
|
403
|
+
repo_id: options.repoId,
|
|
404
|
+
path: relativePath,
|
|
405
|
+
message: `Skipped unreadable directory ${options.repoId}:${relativePath}.`,
|
|
406
|
+
});
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
410
|
+
|
|
411
|
+
for (const entry of entries) {
|
|
412
|
+
const absolutePath = path.join(currentPath, entry.name);
|
|
413
|
+
const relativePath = toPosixPath(path.relative(rootRealPath, absolutePath));
|
|
414
|
+
|
|
415
|
+
if (IGNORED_FILE_NAMES.has(entry.name)) {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (entry.isDirectory()) {
|
|
420
|
+
if (IGNORED_DIRS.has(entry.name)) {
|
|
421
|
+
continue;
|
|
422
|
+
}
|
|
423
|
+
pending.push(absolutePath);
|
|
424
|
+
continue;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (entry.isSymbolicLink()) {
|
|
428
|
+
let targetStat;
|
|
429
|
+
try {
|
|
430
|
+
targetStat = await stat(absolutePath);
|
|
431
|
+
} catch {
|
|
432
|
+
warnings.push({
|
|
433
|
+
code: 'symlink_skipped',
|
|
434
|
+
repo_id: options.repoId,
|
|
435
|
+
path: relativePath,
|
|
436
|
+
message: `Skipped unreadable symlink ${options.repoId}:${relativePath}.`,
|
|
437
|
+
});
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (targetStat.isDirectory()) {
|
|
441
|
+
if (!IGNORED_DIRS.has(entry.name)) {
|
|
442
|
+
let targetRealPath;
|
|
443
|
+
try {
|
|
444
|
+
targetRealPath = await realpath(absolutePath);
|
|
445
|
+
} catch {
|
|
446
|
+
warnings.push({
|
|
447
|
+
code: 'symlink_skipped',
|
|
448
|
+
repo_id: options.repoId,
|
|
449
|
+
path: relativePath,
|
|
450
|
+
message: `Skipped unreadable symlink ${options.repoId}:${relativePath}.`,
|
|
451
|
+
});
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (!isWithinRoot(rootRealPath, targetRealPath)) {
|
|
455
|
+
warnings.push({
|
|
456
|
+
code: 'symlink_escapes_root',
|
|
457
|
+
repo_id: options.repoId,
|
|
458
|
+
path: relativePath,
|
|
459
|
+
message: `Skipped symlink ${options.repoId}:${relativePath} because its target is outside the declared source root.`,
|
|
460
|
+
});
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
pending.push(targetRealPath);
|
|
464
|
+
}
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
warnings.push({
|
|
468
|
+
code: 'symlink_skipped',
|
|
469
|
+
repo_id: options.repoId,
|
|
470
|
+
path: relativePath,
|
|
471
|
+
message: `Skipped symlink ${options.repoId}:${relativePath}.`,
|
|
472
|
+
});
|
|
473
|
+
continue;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!entry.isFile()) {
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
scannedFileCount += 1;
|
|
481
|
+
if (scannedFileCount > options.maxFiles) {
|
|
482
|
+
warnings.push({
|
|
483
|
+
code: 'scan_cap_reached',
|
|
484
|
+
repo_id: options.repoId,
|
|
485
|
+
limit: options.maxFiles,
|
|
486
|
+
message: `Source inventory scan stopped after ${options.maxFiles} files for repo "${options.repoId}".`,
|
|
487
|
+
});
|
|
488
|
+
return {
|
|
489
|
+
items: [...itemsByKey.values()],
|
|
490
|
+
warnings,
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
if (BINARY_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) {
|
|
495
|
+
warnings.push({
|
|
496
|
+
code: 'binary_file_skipped',
|
|
497
|
+
repo_id: options.repoId,
|
|
498
|
+
path: relativePath,
|
|
499
|
+
message: `Skipped binary-like file ${options.repoId}:${relativePath}.`,
|
|
500
|
+
});
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
let fileStat;
|
|
505
|
+
try {
|
|
506
|
+
fileStat = await lstat(absolutePath);
|
|
507
|
+
} catch {
|
|
508
|
+
warnings.push({
|
|
509
|
+
code: 'file_unreadable',
|
|
510
|
+
repo_id: options.repoId,
|
|
511
|
+
path: relativePath,
|
|
512
|
+
message: `Skipped unreadable file ${options.repoId}:${relativePath}.`,
|
|
513
|
+
});
|
|
514
|
+
continue;
|
|
515
|
+
}
|
|
516
|
+
if (fileStat.size > options.maxFileBytes) {
|
|
517
|
+
warnings.push({
|
|
518
|
+
code: 'large_file_skipped',
|
|
519
|
+
repo_id: options.repoId,
|
|
520
|
+
path: relativePath,
|
|
521
|
+
size_bytes: fileStat.size,
|
|
522
|
+
message: `Skipped large file ${options.repoId}:${relativePath}.`,
|
|
523
|
+
});
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
for (const detected of detectInventoryItems(relativePath)) {
|
|
528
|
+
mergeItem(itemsByKey, createInventoryItem({
|
|
529
|
+
repoId: options.repoId,
|
|
530
|
+
relativePath,
|
|
531
|
+
detected,
|
|
532
|
+
}));
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
return {
|
|
538
|
+
items: [...itemsByKey.values()],
|
|
539
|
+
warnings,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function detectInventoryItems(relativePath) {
|
|
544
|
+
const segments = relativePath.split('/').filter(Boolean);
|
|
545
|
+
const basename = segments.at(-1) ?? relativePath;
|
|
546
|
+
const basenameNoExt = basename.replace(/\.[^.]+$/, '');
|
|
547
|
+
const lowerPath = relativePath.toLowerCase();
|
|
548
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
549
|
+
const hasAuthMarker = lowerSegments.some((segment) => ['auth', 'session', 'sessions', 'login', 'oauth'].includes(segment));
|
|
550
|
+
|
|
551
|
+
const withCrossCuttingTags = (detected) => {
|
|
552
|
+
if (!hasAuthMarker || detected.kind === 'auth_session') {
|
|
553
|
+
return detected;
|
|
554
|
+
}
|
|
555
|
+
return {
|
|
556
|
+
...detected,
|
|
557
|
+
tags: mergeTags(detected.tags, ['auth_session']),
|
|
558
|
+
};
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
const provider = PROVIDERS.find((candidate) => lowerSegments.includes(candidate) || lowerPath.includes(`/${candidate}.`));
|
|
562
|
+
if (provider || lowerSegments.includes('integrations') || lowerSegments.includes('integration')) {
|
|
563
|
+
const logicalName = provider ?? nextSegmentAfter(lowerSegments, segments, ['integrations', 'integration']) ?? basenameNoExt;
|
|
564
|
+
return [withCrossCuttingTags({
|
|
565
|
+
kind: 'integration',
|
|
566
|
+
logicalName,
|
|
567
|
+
label: titleize(logicalName),
|
|
568
|
+
confidence: provider ? 'high' : 'medium',
|
|
569
|
+
reason: 'integration/provider path marker',
|
|
570
|
+
})];
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const apiName = detectApiName(segments);
|
|
574
|
+
if (apiName) {
|
|
575
|
+
return [withCrossCuttingTags({
|
|
576
|
+
kind: 'api_surface',
|
|
577
|
+
logicalName: apiName,
|
|
578
|
+
label: `API ${apiName}`,
|
|
579
|
+
confidence: 'high',
|
|
580
|
+
reason: 'api route marker',
|
|
581
|
+
})];
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const jobName = detectJobName(segments, basenameNoExt);
|
|
585
|
+
if (jobName) {
|
|
586
|
+
return [withCrossCuttingTags({
|
|
587
|
+
kind: 'job_task',
|
|
588
|
+
logicalName: jobName,
|
|
589
|
+
label: titleize(jobName),
|
|
590
|
+
confidence: 'high',
|
|
591
|
+
reason: 'job/task path marker',
|
|
592
|
+
})];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
const dataName = detectDataName(segments, basenameNoExt);
|
|
596
|
+
if (dataName) {
|
|
597
|
+
return [withCrossCuttingTags({
|
|
598
|
+
kind: 'data_boundary',
|
|
599
|
+
logicalName: dataName,
|
|
600
|
+
label: titleize(dataName),
|
|
601
|
+
confidence: 'high',
|
|
602
|
+
reason: 'data/schema path marker',
|
|
603
|
+
})];
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
const routeName = detectRouteName(segments);
|
|
607
|
+
if (routeName) {
|
|
608
|
+
return [withCrossCuttingTags({
|
|
609
|
+
kind: 'route_group',
|
|
610
|
+
logicalName: routeName,
|
|
611
|
+
label: routeName === 'root' ? 'Root route' : `Route ${routeName}`,
|
|
612
|
+
confidence: 'high',
|
|
613
|
+
reason: 'route file marker',
|
|
614
|
+
})];
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
const screenName = detectScreenName(segments, basenameNoExt);
|
|
618
|
+
if (screenName) {
|
|
619
|
+
return [withCrossCuttingTags({
|
|
620
|
+
kind: 'screen_group',
|
|
621
|
+
logicalName: screenName,
|
|
622
|
+
label: titleize(screenName),
|
|
623
|
+
confidence: 'high',
|
|
624
|
+
reason: 'screen path marker',
|
|
625
|
+
})];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const runtimeName = detectRuntimeName(segments, basename);
|
|
629
|
+
if (runtimeName) {
|
|
630
|
+
return [withCrossCuttingTags({
|
|
631
|
+
kind: 'runtime_surface',
|
|
632
|
+
logicalName: runtimeName,
|
|
633
|
+
label: titleize(runtimeName),
|
|
634
|
+
confidence: 'medium',
|
|
635
|
+
reason: 'runtime/deploy config marker',
|
|
636
|
+
})];
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const platformName = detectPlatformName(segments, basenameNoExt);
|
|
640
|
+
if (platformName) {
|
|
641
|
+
return [withCrossCuttingTags({
|
|
642
|
+
kind: 'platform_subsystem',
|
|
643
|
+
logicalName: platformName,
|
|
644
|
+
label: titleize(platformName),
|
|
645
|
+
confidence: 'medium',
|
|
646
|
+
reason: 'platform/config path marker',
|
|
647
|
+
})];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
if (hasAuthMarker) {
|
|
651
|
+
return [{
|
|
652
|
+
kind: 'auth_session',
|
|
653
|
+
logicalName: 'auth session',
|
|
654
|
+
label: 'Auth session',
|
|
655
|
+
confidence: 'high',
|
|
656
|
+
reason: 'auth/session path marker',
|
|
657
|
+
}];
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
return [];
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function detectApiName(segments) {
|
|
664
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
665
|
+
const appIndex = lowerSegments.indexOf('app');
|
|
666
|
+
const apiAfterApp = appIndex >= 0 ? lowerSegments.indexOf('api', appIndex + 1) : -1;
|
|
667
|
+
if (apiAfterApp >= 0) {
|
|
668
|
+
const routeSegments = cleanRouteSegments(segments.slice(apiAfterApp + 1, -1));
|
|
669
|
+
return routeSegments[0] ?? 'api';
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const pagesIndex = lowerSegments.indexOf('pages');
|
|
673
|
+
const apiAfterPages = pagesIndex >= 0 ? lowerSegments.indexOf('api', pagesIndex + 1) : -1;
|
|
674
|
+
if (apiAfterPages >= 0) {
|
|
675
|
+
const routeSegments = cleanRouteSegments(segments.slice(apiAfterPages + 1));
|
|
676
|
+
return routeSegments[0] ?? 'api';
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
const apiIndex = lowerSegments.indexOf('api');
|
|
680
|
+
if (apiIndex >= 0) {
|
|
681
|
+
const routeSegments = cleanRouteSegments(segments.slice(apiIndex + 1, -1));
|
|
682
|
+
return routeSegments[0] ?? 'api';
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
return null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
function detectJobName(segments, basenameNoExt) {
|
|
689
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
690
|
+
const markerIndex = lowerSegments.findIndex((segment) =>
|
|
691
|
+
['jobs', 'job', 'tasks', 'task', 'cron', 'worker', 'workers', 'inngest', 'trigger'].includes(segment)
|
|
692
|
+
);
|
|
693
|
+
if (markerIndex < 0) {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
return segments[markerIndex + 1]?.replace(/\.[^.]+$/, '') ?? basenameNoExt;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
function detectDataName(segments, basenameNoExt) {
|
|
700
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
701
|
+
if (lowerSegments.includes('migrations')) {
|
|
702
|
+
return 'migrations';
|
|
703
|
+
}
|
|
704
|
+
if (
|
|
705
|
+
lowerSegments.includes('schema') ||
|
|
706
|
+
lowerSegments.includes('schemas') ||
|
|
707
|
+
lowerSegments.includes('models') ||
|
|
708
|
+
lowerSegments.includes('db') ||
|
|
709
|
+
lowerSegments.includes('database') ||
|
|
710
|
+
basenameNoExt.toLowerCase() === 'schema'
|
|
711
|
+
) {
|
|
712
|
+
return 'database schema';
|
|
713
|
+
}
|
|
714
|
+
return null;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function detectRouteName(segments) {
|
|
718
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
719
|
+
const basename = lowerSegments.at(-1);
|
|
720
|
+
if (!['page.tsx', 'page.ts', 'page.jsx', 'page.js'].includes(basename)) {
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const appIndex = lowerSegments.indexOf('app');
|
|
725
|
+
if (appIndex >= 0) {
|
|
726
|
+
return clusterRouteGroup(segments.slice(appIndex + 1, -1));
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
const pagesIndex = lowerSegments.indexOf('pages');
|
|
730
|
+
if (pagesIndex >= 0) {
|
|
731
|
+
return clusterRouteGroup(segments.slice(pagesIndex + 1, -1));
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
return null;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function detectScreenName(segments, basenameNoExt) {
|
|
738
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
739
|
+
const screenIndex = lowerSegments.findIndex((segment) => ['screens', 'screen'].includes(segment));
|
|
740
|
+
if (screenIndex >= 0) {
|
|
741
|
+
if (screenIndex + 1 < segments.length - 1) {
|
|
742
|
+
return segments[screenIndex + 1]?.replace(/\.[^.]+$/, '') ?? basenameNoExt;
|
|
743
|
+
}
|
|
744
|
+
return basenameNoExt.replace(/screen$/i, '') || basenameNoExt;
|
|
745
|
+
}
|
|
746
|
+
if (/screen$/i.test(basenameNoExt)) {
|
|
747
|
+
return basenameNoExt.replace(/screen$/i, '') || basenameNoExt;
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function detectRuntimeName(segments, basename) {
|
|
753
|
+
const lowerPath = segments.join('/').toLowerCase();
|
|
754
|
+
const lowerBasename = basename.toLowerCase();
|
|
755
|
+
if (lowerBasename === 'package.json') return 'package runtime';
|
|
756
|
+
if (lowerBasename === 'dockerfile') return 'docker runtime';
|
|
757
|
+
if (lowerBasename === 'vercel.json') return 'vercel runtime';
|
|
758
|
+
if (lowerBasename === 'wrangler.toml') return 'worker runtime';
|
|
759
|
+
if (/^next\.config\./.test(lowerBasename)) return 'next runtime';
|
|
760
|
+
if (lowerPath.startsWith('.github/workflows/')) return 'github actions';
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function detectPlatformName(segments, basenameNoExt) {
|
|
765
|
+
const lowerSegments = segments.map((segment) => segment.toLowerCase());
|
|
766
|
+
if (lowerSegments.includes('cli') || lowerSegments.includes('commands')) {
|
|
767
|
+
return 'cli commands';
|
|
768
|
+
}
|
|
769
|
+
const configIndex = lowerSegments.findIndex((segment) => ['config', 'configs'].includes(segment));
|
|
770
|
+
if (configIndex >= 0) {
|
|
771
|
+
const configGroup = segments[configIndex + 1]?.replace(/\.[^.]+$/, '');
|
|
772
|
+
if (configGroup && configIndex + 1 < segments.length - 1) {
|
|
773
|
+
return formatConfigGroupName(configGroup);
|
|
774
|
+
}
|
|
775
|
+
return basenameNoExt;
|
|
776
|
+
}
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
function formatConfigGroupName(configGroup) {
|
|
781
|
+
const logicalName = normalizeLogicalName(configGroup);
|
|
782
|
+
if (!logicalName) {
|
|
783
|
+
return null;
|
|
784
|
+
}
|
|
785
|
+
if (['font', 'fonts'].includes(logicalName.toLowerCase())) {
|
|
786
|
+
return 'font configuration';
|
|
787
|
+
}
|
|
788
|
+
return `${logicalName} configuration`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function createInventoryItem(options) {
|
|
792
|
+
const logicalName = normalizeLogicalName(options.detected.logicalName);
|
|
793
|
+
const id = `${options.repoId}:${options.detected.kind}:${slugify(logicalName)}`;
|
|
794
|
+
const tags = normalizeTags(options.detected.tags);
|
|
795
|
+
return {
|
|
796
|
+
id,
|
|
797
|
+
repo_id: options.repoId,
|
|
798
|
+
kind: options.detected.kind,
|
|
799
|
+
logical_name: logicalName,
|
|
800
|
+
label: options.detected.label,
|
|
801
|
+
confidence: options.detected.confidence,
|
|
802
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
803
|
+
evidence: [
|
|
804
|
+
{
|
|
805
|
+
path: `${options.repoId}:${options.relativePath}`,
|
|
806
|
+
reason: options.detected.reason,
|
|
807
|
+
...(tags.length > 0 ? { tags } : {}),
|
|
808
|
+
},
|
|
809
|
+
],
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function mergeItem(itemsByKey, item) {
|
|
814
|
+
const existing = itemsByKey.get(item.id);
|
|
815
|
+
if (!existing) {
|
|
816
|
+
itemsByKey.set(item.id, { ...item, evidence: [...item.evidence] });
|
|
817
|
+
return;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
existing.tags = mergeTags(existing.tags, item.tags);
|
|
821
|
+
if (existing.tags.length === 0) {
|
|
822
|
+
delete existing.tags;
|
|
823
|
+
}
|
|
824
|
+
for (const evidence of item.evidence) {
|
|
825
|
+
if (!existing.evidence.some((entry) => entry.path === evidence.path)) {
|
|
826
|
+
existing.evidence.push(evidence);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
if (confidenceRank(item.confidence) > confidenceRank(existing.confidence)) {
|
|
830
|
+
existing.confidence = item.confidence;
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function cleanRouteSegments(segments) {
|
|
835
|
+
return segments
|
|
836
|
+
.map((segment) => segment.replace(/\.[^.]+$/, ''))
|
|
837
|
+
.filter((segment) => segment && !segment.startsWith('(') && !segment.startsWith('@'))
|
|
838
|
+
.map((segment) => segment.replace(/^\[(.+)\]$/, '$1'));
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
function isWithinRoot(rootPath, candidatePath) {
|
|
842
|
+
const relative = path.relative(rootPath, candidatePath);
|
|
843
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
function clusterRouteGroup(segments) {
|
|
847
|
+
const routeSegments = segments
|
|
848
|
+
.map((segment) => segment.replace(/\.[^.]+$/, ''))
|
|
849
|
+
.filter((segment) => segment && !segment.startsWith('(') && !segment.startsWith('@'))
|
|
850
|
+
.filter((segment) => !/^\[.+\]$/.test(segment));
|
|
851
|
+
return routeSegments[0] ?? 'root';
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function normalizeTags(value) {
|
|
855
|
+
return [...new Set(Array.isArray(value) ? value.filter(Boolean) : [])].sort();
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function mergeTags(left, right) {
|
|
859
|
+
return normalizeTags([...(left ?? []), ...(right ?? [])]);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function nextSegmentAfter(lowerSegments, originalSegments, markers) {
|
|
863
|
+
const index = lowerSegments.findIndex((segment) => markers.includes(segment));
|
|
864
|
+
return index >= 0 ? originalSegments[index + 1] : null;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
function normalizeRepoRootPath(value) {
|
|
868
|
+
const normalized = normalizeOptionalString(value);
|
|
869
|
+
if (!normalized || normalized === '/') {
|
|
870
|
+
return '/';
|
|
871
|
+
}
|
|
872
|
+
return normalized.replace(/^\/+/, '').replace(/\/+$/, '') || '/';
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function normalizeOptionalString(value) {
|
|
876
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : null;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function normalizeLogicalName(value) {
|
|
880
|
+
return String(value ?? '').trim().replaceAll('\\', '/').replace(/\/+/g, '/');
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
function slugify(value) {
|
|
884
|
+
return normalizeLogicalName(value)
|
|
885
|
+
.toLowerCase()
|
|
886
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
887
|
+
.replace(/^-+|-+$/g, '') || 'root';
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
function titleize(value) {
|
|
891
|
+
return normalizeLogicalName(value)
|
|
892
|
+
.replace(/[-_/]+/g, ' ')
|
|
893
|
+
.replace(/\b\w/g, (char) => char.toUpperCase());
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function toPosixPath(value) {
|
|
897
|
+
return value.split(path.sep).join('/');
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function confidenceRank(value) {
|
|
901
|
+
if (value === 'high') return 3;
|
|
902
|
+
if (value === 'medium') return 2;
|
|
903
|
+
if (value === 'low') return 1;
|
|
904
|
+
return 0;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function countBy(items, key) {
|
|
908
|
+
return (items ?? []).reduce((counts, item) => {
|
|
909
|
+
const value = item?.[key];
|
|
910
|
+
if (value) {
|
|
911
|
+
counts[value] = (counts[value] ?? 0) + 1;
|
|
912
|
+
}
|
|
913
|
+
return counts;
|
|
914
|
+
}, {});
|
|
915
|
+
}
|