agent-rules-init 0.6.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,38 +33,59 @@ function printArgs(assistant, model) {
33
33
  "-",
34
34
  ];
35
35
  }
36
- // A hung assistant (expired session, dead network) must not hang the CLI forever;
37
- // 10 minutes comfortably covers real enrichment runs, and on expiry the rejection
38
- // lands in the normal fallback path that keeps the deterministic files.
39
- const EXEC_TIMEOUT_MS = 600_000;
36
+ // A hung assistant (expired session, dead network) must not hang the CLI forever.
37
+ // Five minutes covers the verified real runs while still placing a useful upper bound;
38
+ // callers can lower or raise it explicitly.
39
+ export const DEFAULT_EXEC_TIMEOUT_MS = 300_000;
40
+ const MAX_ASSISTANT_OUTPUT_CHARS = 2_000_000;
40
41
  const SAFE_WINDOWS_SHELL_ARG = /^[A-Za-z0-9._:/@+,-]+$/;
41
- export const defaultExecFn = (command, args, stdin, cwd) => new Promise((resolve, reject) => {
42
- if (process.platform === "win32" && args.some((arg) => !SAFE_WINDOWS_SHELL_ARG.test(arg))) {
43
- reject(new Error(`refusing an unsafe shell argument for ${command}`));
44
- return;
45
- }
46
- // shell:true is only needed on Windows, to resolve npm-installed .cmd/.ps1 shims.
47
- // cwd matters for enrichment: the assistant explores the repo it runs in with its
48
- // own read tools, so it must be spawned at the target repo root, not wherever the
49
- // CLI process happens to live.
50
- const child = spawn(command, args, { shell: process.platform === "win32", cwd, timeout: EXEC_TIMEOUT_MS });
51
- let stdout = "";
52
- child.stdout?.on("data", (chunk) => (stdout += chunk.toString()));
53
- child.on("error", reject);
54
- child.on("close", (exitCode) => {
55
- if (exitCode === 0)
56
- resolve({ stdout, exitCode });
57
- else if (child.killed)
58
- reject(new Error(`${command} timed out after ${EXEC_TIMEOUT_MS / 1000}s`));
59
- else
60
- reject(new Error(`${command} exited with code ${exitCode}`));
42
+ export function createDefaultExecFn(timeoutMs = DEFAULT_EXEC_TIMEOUT_MS) {
43
+ return (command, args, stdin, cwd) => new Promise((resolve, reject) => {
44
+ if (process.platform === "win32" && args.some((arg) => !SAFE_WINDOWS_SHELL_ARG.test(arg))) {
45
+ reject(new Error(`refusing an unsafe shell argument for ${command}`));
46
+ return;
47
+ }
48
+ // shell:true is only needed on Windows, to resolve npm-installed .cmd/.ps1 shims.
49
+ // cwd matters for enrichment: the assistant explores the repo it runs in with its
50
+ // own read tools, so it must be spawned at the target repo root, not wherever the
51
+ // CLI process happens to live.
52
+ const child = spawn(command, args, { shell: process.platform === "win32", cwd, timeout: timeoutMs });
53
+ let stdout = "";
54
+ let stderr = "";
55
+ let outputLimitExceeded = false;
56
+ child.stdout?.on("data", (chunk) => {
57
+ stdout += chunk.toString();
58
+ if (stdout.length > MAX_ASSISTANT_OUTPUT_CHARS) {
59
+ outputLimitExceeded = true;
60
+ child.kill();
61
+ }
62
+ });
63
+ child.stderr?.on("data", (chunk) => {
64
+ stderr += chunk.toString();
65
+ if (stderr.length > MAX_ASSISTANT_OUTPUT_CHARS)
66
+ stderr = stderr.slice(-MAX_ASSISTANT_OUTPUT_CHARS);
67
+ });
68
+ child.on("error", reject);
69
+ child.on("close", (exitCode) => {
70
+ if (outputLimitExceeded)
71
+ reject(new Error(`${command} exceeded the assistant output limit`));
72
+ else if (exitCode === 0)
73
+ resolve({ stdout, stderr, exitCode });
74
+ else if (child.killed)
75
+ reject(new Error(`${command} timed out after ${timeoutMs / 1000}s`));
76
+ else {
77
+ const detail = stderr.trim().slice(-2_000).replace(/[\r\n]+/g, " ");
78
+ reject(new Error(`${command} exited with code ${exitCode}${detail ? `: ${detail}` : ""}`));
79
+ }
80
+ });
81
+ // Content always goes through stdin, never as a CLI argument: on Windows, spawn's
82
+ // shell:true routes the command through cmd.exe, which cannot reliably carry a
83
+ // multi-line, multi-KB argument (embedded newlines truncate/corrupt it). Piping
84
+ // via stdin has no such limit and needs no shell-quoting at all.
85
+ child.stdin?.end(stdin ?? "");
61
86
  });
62
- // Content always goes through stdin, never as a CLI argument: on Windows, spawn's
63
- // shell:true routes the command through cmd.exe, which cannot reliably carry a
64
- // multi-line, multi-KB argument (embedded newlines truncate/corrupt it). Piping
65
- // via stdin has no such limit and needs no shell-quoting at all.
66
- child.stdin?.end(stdin ?? "");
67
- });
87
+ }
88
+ export const defaultExecFn = createDefaultExecFn();
68
89
  export async function detectAvailableAssistants(execFn = defaultExecFn) {
69
90
  const candidates = ["claude", "codex"];
70
91
  const results = await Promise.all(candidates.map(async (id) => {
@@ -131,6 +152,35 @@ function parseAssistantBatch(stdout, originals) {
131
152
  export function estimateEnrichment(files) {
132
153
  return { characters: JSON.stringify(files).length, batches: makeBatches(files).length };
133
154
  }
155
+ export function summarizeEnrichmentChanges(originals, enriched) {
156
+ let changedFiles = 0;
157
+ let addedLines = 0;
158
+ let removedLines = 0;
159
+ for (let index = 0; index < originals.length; index++) {
160
+ const before = originals[index].content.split("\n");
161
+ const after = enriched[index]?.content.split("\n") ?? [];
162
+ if (originals[index].content === enriched[index]?.content)
163
+ continue;
164
+ changedFiles++;
165
+ const beforeCounts = new Map();
166
+ const afterCounts = new Map();
167
+ for (const line of before)
168
+ beforeCounts.set(line, (beforeCounts.get(line) ?? 0) + 1);
169
+ for (const line of after)
170
+ afterCounts.set(line, (afterCounts.get(line) ?? 0) + 1);
171
+ for (const [line, count] of afterCounts)
172
+ addedLines += Math.max(0, count - (beforeCounts.get(line) ?? 0));
173
+ for (const [line, count] of beforeCounts)
174
+ removedLines += Math.max(0, count - (afterCounts.get(line) ?? 0));
175
+ }
176
+ return { changedFiles, addedLines, removedLines };
177
+ }
178
+ class SecurityValidationError extends Error {
179
+ constructor(message) {
180
+ super(message);
181
+ this.name = "SecurityValidationError";
182
+ }
183
+ }
134
184
  // The assistant is free-form: it may drop the one command CI actually runs and invent a
135
185
  // plausible replacement. Any lost must-keep command invalidates the whole batch.
136
186
  function assertMustKeepSurvive(enriched, originals, mustKeep) {
@@ -138,7 +188,7 @@ function assertMustKeepSurvive(enriched, originals, mustKeep) {
138
188
  const enrichedText = enriched.map((f) => f.content).join("\n");
139
189
  for (const command of mustKeep) {
140
190
  if (originalText.includes(command) && !enrichedText.includes(command)) {
141
- throw new Error(`assistant dropped a canonical command: ${command}`);
191
+ throw new SecurityValidationError(`assistant dropped a canonical command: ${command}`);
142
192
  }
143
193
  }
144
194
  }
@@ -149,7 +199,7 @@ function assertNoNewDangerousInstructions(enriched, originals) {
149
199
  for (const line of enriched.flatMap((file) => file.content.split("\n"))) {
150
200
  const dangerous = line.match(DANGEROUS_INSTRUCTION)?.[0];
151
201
  if (dangerous && !NEGATED_DANGER.test(line) && !originalText.includes(dangerous)) {
152
- throw new Error(`assistant introduced a dangerous instruction: ${dangerous}`);
202
+ throw new SecurityValidationError(`assistant introduced a dangerous instruction: ${dangerous}`);
153
203
  }
154
204
  }
155
205
  }
@@ -158,8 +208,54 @@ function assertNewBulletClaimsCiteEvidence(enriched, originals) {
158
208
  const originalLines = new Set(originals[index].content.split("\n").map((line) => line.trim()));
159
209
  for (const line of enriched[index].content.split("\n")) {
160
210
  const trimmed = line.trim();
161
- if (/^[-*] /.test(trimmed) && !originalLines.has(trimmed) && !HAS_EVIDENCE_GROUP.test(trimmed)) {
162
- throw new Error("assistant introduced a bullet claim without cited evidence");
211
+ if (/^[-*] /.test(trimmed) && !originalLines.has(trimmed) && checkableCitedPaths(trimmed).length === 0) {
212
+ throw new SecurityValidationError("assistant introduced a bullet claim without a checkable evidence path");
213
+ }
214
+ }
215
+ }
216
+ }
217
+ const PROMPT_INJECTION_LANGUAGE = /(?:ignore (?:all )?(?:previous|prior) instructions|system prompt|developer message|bypass (?:the )?(?:sandbox|permissions|safety)|disable (?:the )?(?:guardrails|validation|security)|exfiltrat|upload (?:the )?(?:secret|credential|token)|lee (?:las )?(?:variables de entorno|credenciales)|ignora (?:todas )?(?:las )?instrucciones (?:anteriores|previas)|mensaje (?:del sistema|de desarrollador))/i;
218
+ const COMMAND_TOKEN = /`((?:npm|pnpm|yarn|bun|go|cargo|python|pytest|ruff|mvn|gradle|\.\/gradlew|dotnet|mix|composer|bundle|phpunit|make|cmake|ctest|swift|dart|flutter|sbt|Rscript)\b[^`]*)`/gi;
219
+ const PLAIN_COMMAND = /\b((?:npm|pnpm|yarn|bun)\s+(?:run|test|install|build|lint|exec)\b[^.;\n]*|go\s+(?:test|build|run|vet|fmt|generate|get)\b[^.;\n]*|cargo\s+(?:test|build|run|clippy|fmt)\b[^.;\n]*|python\s+-m\s+\S+[^.;\n]*|(?:pytest|ruff|mvn|gradle|dotnet|mix|composer|bundle|phpunit|make|cmake|ctest|swift|dart|flutter|sbt|Rscript)\s+[^.;\n]+)/i;
220
+ function assertNoPromptInjectionLanguage(enriched, originals) {
221
+ for (let index = 0; index < enriched.length; index++) {
222
+ const originalLines = new Set(originals[index].content.split("\n").map((line) => line.trim()));
223
+ for (const line of enriched[index].content.split("\n")) {
224
+ if (!originalLines.has(line.trim()) && PROMPT_INJECTION_LANGUAGE.test(line)) {
225
+ throw new SecurityValidationError("assistant introduced prompt-injection or safety-bypass language");
226
+ }
227
+ }
228
+ }
229
+ }
230
+ function assertNoUnverifiedCommands(enriched, originals, allowedCommands) {
231
+ const originalText = originals.map((file) => file.content).join("\n");
232
+ const allowed = new Set(allowedCommands.map((command) => command.trim()));
233
+ for (const file of enriched) {
234
+ for (const match of file.content.matchAll(COMMAND_TOKEN)) {
235
+ const command = match[1].trim();
236
+ if (!originalText.includes(`\`${command}\``) && !allowed.has(command)) {
237
+ throw new SecurityValidationError(`assistant introduced an unverified command: ${command}`);
238
+ }
239
+ }
240
+ }
241
+ for (let index = 0; index < enriched.length; index++) {
242
+ const originalLines = new Set(originals[index].content.split("\n").map((line) => line.trim()));
243
+ for (const line of enriched[index].content.split("\n")) {
244
+ if (originalLines.has(line.trim()))
245
+ continue;
246
+ const command = line.replace(/`[^`]*`/g, "").match(PLAIN_COMMAND)?.[1]?.replace(/[*_]/g, "").trim();
247
+ if (!command)
248
+ continue;
249
+ throw new SecurityValidationError(`assistant introduced an unquoted command: ${command}`);
250
+ }
251
+ }
252
+ }
253
+ function assertNoNewHeadings(enriched, originals) {
254
+ for (let index = 0; index < enriched.length; index++) {
255
+ const originalHeadings = new Set(originals[index].content.split("\n").filter((line) => /^#{1,6}\s+/.test(line.trim())).map((line) => line.trim()));
256
+ for (const heading of enriched[index].content.split("\n").filter((line) => /^#{1,6}\s+/.test(line.trim()))) {
257
+ if (!originalHeadings.has(heading.trim())) {
258
+ throw new SecurityValidationError(`assistant introduced an unapproved section: ${heading.trim()}`);
163
259
  }
164
260
  }
165
261
  }
@@ -169,10 +265,15 @@ function assistantFailureDetail(assistant, error) {
169
265
  if (/unknown (?:option|argument)|unrecognized (?:option|argument)|unexpected argument/i.test(message)) {
170
266
  return `${assistant} does not support the required read-only safety flags; update the assistant CLI`;
171
267
  }
268
+ if (/not (?:logged|signed) in|unauthenticated|authentication|login required|401\b/i.test(message)) {
269
+ return `${assistant} is installed but not authenticated; sign in with the assistant CLI and retry`;
270
+ }
271
+ if (/enotfound|econnrefused|network|timed? out|connection/i.test(message)) {
272
+ return `${assistant} could not reach its service; check connectivity or lower the enrichment timeout`;
273
+ }
172
274
  return message;
173
275
  }
174
276
  const EVIDENCE_GROUP = /\((?:evidencia|evidence):([^)]*)\)/gi;
175
- const HAS_EVIDENCE_GROUP = /\((?:evidencia|evidence):[^)]*\)/i;
176
277
  // A checkable citation is a plain relative path; tokens with spaces, URLs or globs are
177
278
  // left alone rather than guessed at.
178
279
  const PATH_TOKEN = /^[\w.@~-]+(?:[\\/][\w.@~-]+)*[\\/]?$/;
@@ -193,12 +294,25 @@ function checkableCitedPaths(line) {
193
294
  // prose lines are kept (removing them would mangle paragraphs) but still reported.
194
295
  function dropUnverifiableClaims(files, rootPath) {
195
296
  const missing = new Set();
297
+ const root = path.resolve(rootPath);
298
+ const evidenceExists = (relativePath) => {
299
+ const absolutePath = path.resolve(root, relativePath);
300
+ if (absolutePath === root || !absolutePath.startsWith(`${root}${path.sep}`))
301
+ return false;
302
+ try {
303
+ const stat = fs.lstatSync(absolutePath);
304
+ return stat.isFile() && !stat.isSymbolicLink();
305
+ }
306
+ catch {
307
+ return false;
308
+ }
309
+ };
196
310
  const checked = files.map((file) => {
197
311
  const kept = file.content.split("\n").filter((line) => {
198
312
  const cited = checkableCitedPaths(line);
199
313
  if (cited.length === 0)
200
314
  return true;
201
- if (cited.some((p) => fs.existsSync(path.join(rootPath, p))))
315
+ if (cited.some(evidenceExists))
202
316
  return true;
203
317
  for (const p of cited)
204
318
  missing.add(p);
@@ -214,7 +328,7 @@ function dropUnverifiableClaims(files, rootPath) {
214
328
  * process; only very large outputs are split. Falls back to the originals per batch.
215
329
  */
216
330
  export async function enrichFilesWithAssistant(assistant, files, options = {}) {
217
- const { execFn = defaultExecFn, lang = "es", cwd, mustKeep = [], existingDocs = [], model, onMetrics } = options;
331
+ const { execFn = defaultExecFn, lang = "es", cwd, mustKeep = [], existingDocs = [], model, maxAttempts = 2, onMetrics, } = options;
218
332
  const existingDocsJson = existingDocs.length > 0 ? JSON.stringify(existingDocs) : undefined;
219
333
  const enriched = [];
220
334
  const batches = makeBatches(files);
@@ -223,12 +337,13 @@ export async function enrichFilesWithAssistant(assistant, files, options = {}) {
223
337
  let fallbackBatches = 0;
224
338
  let inputChars = 0;
225
339
  let outputChars = 0;
340
+ let securityRejections = 0;
226
341
  for (const batch of batches) {
227
342
  const input = UI[lang].enrichPrompt(JSON.stringify(batch), mustKeep, existingDocsJson);
228
343
  let attemptInput = input;
229
344
  // Contract violations (invalid JSON, changed paths, dropped commands) are stochastic,
230
345
  // especially with smaller models; one bounded retry recovers most of them.
231
- let attemptsLeft = 2;
346
+ let attemptsLeft = Math.max(1, Math.min(3, maxAttempts));
232
347
  while (attemptsLeft > 0) {
233
348
  attemptsLeft--;
234
349
  attempts++;
@@ -240,6 +355,9 @@ export async function enrichFilesWithAssistant(assistant, files, options = {}) {
240
355
  assertMustKeepSurvive(parsed, batch, mustKeep);
241
356
  assertNoNewDangerousInstructions(parsed, batch);
242
357
  assertNewBulletClaimsCiteEvidence(parsed, batch);
358
+ assertNoPromptInjectionLanguage(parsed, batch);
359
+ assertNoUnverifiedCommands(parsed, batch, mustKeep);
360
+ assertNoNewHeadings(parsed, batch);
243
361
  if (cwd) {
244
362
  const verified = dropUnverifiableClaims(parsed, cwd);
245
363
  if (verified.missing.length > 0)
@@ -250,6 +368,8 @@ export async function enrichFilesWithAssistant(assistant, files, options = {}) {
250
368
  break;
251
369
  }
252
370
  catch (err) {
371
+ if (err instanceof SecurityValidationError)
372
+ securityRejections++;
253
373
  if (attemptsLeft > 0) {
254
374
  const detail = assistantFailureDetail(assistant, err);
255
375
  attemptInput = `${input}\n\nYour previous response was rejected: ${detail}. Correct that exact problem and return the required JSON array only.`;
@@ -263,6 +383,7 @@ export async function enrichFilesWithAssistant(assistant, files, options = {}) {
263
383
  }
264
384
  }
265
385
  }
386
+ const changes = summarizeEnrichmentChanges(files, enriched);
266
387
  onMetrics?.({
267
388
  assistant,
268
389
  model,
@@ -272,6 +393,9 @@ export async function enrichFilesWithAssistant(assistant, files, options = {}) {
272
393
  inputChars,
273
394
  outputChars,
274
395
  durationMs: Date.now() - startedAt,
396
+ cacheHit: false,
397
+ ...changes,
398
+ securityRejections,
275
399
  });
276
400
  return enriched;
277
401
  }
@@ -57,7 +57,8 @@ export function applyProjectExcludes(signals, patterns) {
57
57
  };
58
58
  }
59
59
  function withoutLocation(manifest) {
60
- const { path: _path, ...packageJson } = manifest;
60
+ const packageJson = { ...manifest };
61
+ delete packageJson.path;
61
62
  return packageJson;
62
63
  }
63
64
  /**
@@ -0,0 +1,4 @@
1
+ import type { RepoSignals } from "./types.js";
2
+ import type { ScanOptions } from "./scanner.js";
3
+ /** Runs full repository discovery off the main event loop in the published CLI. */
4
+ export declare function scanRepoInWorker(rootPath: string, options?: ScanOptions, timeoutMs?: number): Promise<RepoSignals>;
@@ -0,0 +1,55 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { Worker } from "node:worker_threads";
4
+ function restoreSignals(signals) {
5
+ const normalizedFiles = new Set(signals.files.map((file) => file.split(path.sep).join("/")));
6
+ return {
7
+ ...signals,
8
+ hasFile: (relativePath) => normalizedFiles.has(relativePath.split(path.sep).join("/")),
9
+ hasDir: (relativeDir) => {
10
+ try {
11
+ return fs.statSync(path.join(signals.rootPath, relativeDir)).isDirectory();
12
+ }
13
+ catch {
14
+ return false;
15
+ }
16
+ },
17
+ };
18
+ }
19
+ /** Runs full repository discovery off the main event loop in the published CLI. */
20
+ export function scanRepoInWorker(rootPath, options = {}, timeoutMs = 30_000) {
21
+ return new Promise((resolve, reject) => {
22
+ let settled = false;
23
+ const worker = new Worker(new URL("./scanner-worker.js", import.meta.url), {
24
+ workerData: { rootPath, options },
25
+ });
26
+ const finish = (action) => {
27
+ if (settled)
28
+ return;
29
+ settled = true;
30
+ clearTimeout(timer);
31
+ action();
32
+ };
33
+ const timer = setTimeout(() => {
34
+ void worker.terminate();
35
+ finish(() => reject(new Error(`repository scanner worker timed out after ${timeoutMs / 1000}s`)));
36
+ }, timeoutMs);
37
+ worker.once("message", (message) => {
38
+ finish(() => {
39
+ if (message.ok) {
40
+ const restored = restoreSignals(message.signals);
41
+ if (restored.scanStats)
42
+ restored.scanStats.mode = "worker";
43
+ resolve(restored);
44
+ }
45
+ else
46
+ reject(new Error(`repository scanner worker failed: ${message.error}`));
47
+ });
48
+ });
49
+ worker.once("error", (error) => finish(() => reject(error)));
50
+ worker.once("exit", (code) => {
51
+ if (code !== 0)
52
+ finish(() => reject(new Error(`repository scanner worker exited with code ${code}`)));
53
+ });
54
+ });
55
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ import { parentPort, workerData } from "node:worker_threads";
2
+ import { scanRepo } from "./scanner.js";
3
+ if (!parentPort)
4
+ throw new Error("scanner worker must run inside a worker thread");
5
+ try {
6
+ const input = workerData;
7
+ const serializable = { ...scanRepo(input.rootPath, input.options) };
8
+ delete serializable.hasFile;
9
+ delete serializable.hasDir;
10
+ parentPort.postMessage({ ok: true, signals: serializable });
11
+ }
12
+ catch (error) {
13
+ parentPort.postMessage({
14
+ ok: false,
15
+ error: error instanceof Error ? error.message : String(error),
16
+ });
17
+ }
@@ -1,2 +1,6 @@
1
1
  import type { RepoSignals } from "./types.js";
2
- export declare function scanRepo(rootPath: string): RepoSignals;
2
+ export interface ScanOptions {
3
+ maxDepth?: number;
4
+ maxFiles?: number;
5
+ }
6
+ export declare function scanRepo(rootPath: string, options?: ScanOptions): RepoSignals;
@@ -8,12 +8,20 @@ const IGNORED_DIRS = new Set([
8
8
  ".gradle", ".dart_tool",
9
9
  ".agent-rules-init",
10
10
  ]);
11
- const MAX_DEPTH = 4;
12
- function walk(rootPath) {
11
+ const DEFAULT_MAX_DEPTH = 12;
12
+ const DEFAULT_MAX_FILES = 100_000;
13
+ function walk(rootPath, options) {
13
14
  const results = [];
15
+ const warnings = new Set();
16
+ const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
17
+ const maxFiles = options.maxFiles ?? DEFAULT_MAX_FILES;
14
18
  function recurse(dir, depth) {
15
- if (depth > MAX_DEPTH)
19
+ if (results.length >= maxFiles)
16
20
  return;
21
+ if (depth > maxDepth) {
22
+ warnings.add(`Repository scan reached maxDepth=${maxDepth}; some nested files were skipped.`);
23
+ return;
24
+ }
17
25
  let entries;
18
26
  try {
19
27
  entries = fs.readdirSync(dir, { withFileTypes: true });
@@ -34,11 +42,15 @@ function walk(rootPath) {
34
42
  if (entry.name.includes(".generated."))
35
43
  continue;
36
44
  results.push(path.relative(rootPath, path.join(dir, entry.name)));
45
+ if (results.length >= maxFiles) {
46
+ warnings.add(`Repository scan reached maxFiles=${maxFiles}; remaining files were skipped.`);
47
+ return;
48
+ }
37
49
  }
38
50
  }
39
51
  }
40
52
  recurse(rootPath, 0);
41
- return results;
53
+ return { files: results, warnings: [...warnings] };
42
54
  }
43
55
  // Windows editors (Notepad, PowerShell's Set-Content, some IDE defaults) save UTF-8
44
56
  // with a leading BOM, which JSON.parse rejects and line-anchored regexes trip over.
@@ -58,15 +70,20 @@ function readJsonIfExists(filePath) {
58
70
  function toPackageJsonManifest(raw, relativePath) {
59
71
  return {
60
72
  path: relativePath.split(path.sep).join("/"),
61
- name: raw.name,
73
+ name: typeof raw.name === "string" ? raw.name : undefined,
62
74
  main: typeof raw.main === "string" ? raw.main : undefined,
63
- dependencies: raw.dependencies ?? {},
64
- devDependencies: raw.devDependencies ?? {},
65
- scripts: raw.scripts ?? {},
75
+ dependencies: stringRecord(raw.dependencies),
76
+ devDependencies: stringRecord(raw.devDependencies),
77
+ scripts: stringRecord(raw.scripts),
66
78
  moduleType: raw.type === "module" ? "module" : "commonjs",
67
79
  packageManager: parsePackageManager(raw.packageManager),
68
80
  };
69
81
  }
82
+ function stringRecord(value) {
83
+ if (typeof value !== "object" || value === null || Array.isArray(value))
84
+ return {};
85
+ return Object.fromEntries(Object.entries(value).filter((entry) => typeof entry[1] === "string"));
86
+ }
70
87
  function parsePackageManager(value) {
71
88
  if (typeof value !== "string")
72
89
  return undefined;
@@ -166,8 +183,10 @@ function readAllConcatenated(rootPath, relativePaths) {
166
183
  function pickShallowest(paths) {
167
184
  return shallowest(paths.filter((p) => p !== undefined));
168
185
  }
169
- export function scanRepo(rootPath) {
170
- const files = walk(rootPath);
186
+ export function scanRepo(rootPath, options = {}) {
187
+ const startedAt = performance.now();
188
+ const walked = walk(rootPath, options);
189
+ const files = walked.files;
171
190
  const fileSet = new Set(files.map((f) => f.split(path.sep).join("/")));
172
191
  // Keep every project package manifest, not only the root workspace manifest. Root
173
192
  // package.json files are often workspace glue and contain none of the dependencies
@@ -278,6 +297,12 @@ export function scanRepo(rootPath) {
278
297
  const normalized = file.split(path.sep).join("/");
279
298
  return guidanceNames.has(path.posix.basename(normalized));
280
299
  });
300
+ const scanStats = {
301
+ files: files.length,
302
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
303
+ truncated: walked.warnings.length > 0,
304
+ mode: "sync",
305
+ };
281
306
  return {
282
307
  rootPath,
283
308
  files,
@@ -318,5 +343,7 @@ export function scanRepo(rootPath) {
318
343
  guidanceFiles: guidancePaths
319
344
  .map((p) => ({ path: p.split(path.sep).join("/"), content: readTextIfExists(path.join(rootPath, p)) }))
320
345
  .filter((f) => f.content !== undefined),
346
+ scanWarnings: walked.warnings,
347
+ scanStats,
321
348
  };
322
349
  }
@@ -53,6 +53,14 @@ export interface RepoSignals {
53
53
  path: string;
54
54
  content: string;
55
55
  }[];
56
+ /** Non-fatal conditions that may make repository detection incomplete. */
57
+ scanWarnings?: string[];
58
+ scanStats?: {
59
+ files: number;
60
+ durationMs: number;
61
+ truncated: boolean;
62
+ mode?: "sync" | "worker" | "sync-fallback";
63
+ };
56
64
  }
57
65
  export type Confidence = "high" | "low";
58
66
  export interface DetectionField<T> {
@@ -0,0 +1,3 @@
1
+ import type { Pack } from "../core/types.js";
2
+ export declare const ALL_PACKS: readonly Pack[];
3
+ export declare function findPack(id: string): Pack;
@@ -0,0 +1,25 @@
1
+ import { cppPack } from "./cpp.js";
2
+ import { csharpPack } from "./csharp.js";
3
+ import { dartPack } from "./dart.js";
4
+ import { elixirPack } from "./elixir.js";
5
+ import { goPack } from "./go.js";
6
+ import { javaPack } from "./java.js";
7
+ import { jsTsPack } from "./js-ts.js";
8
+ import { kotlinPack } from "./kotlin.js";
9
+ import { phpPack } from "./php.js";
10
+ import { pythonPack } from "./python.js";
11
+ import { rPack } from "./r.js";
12
+ import { rubyPack } from "./ruby.js";
13
+ import { rustPack } from "./rust.js";
14
+ import { scalaPack } from "./scala.js";
15
+ import { swiftPack } from "./swift.js";
16
+ export const ALL_PACKS = [
17
+ jsTsPack, pythonPack, javaPack, phpPack, rubyPack, goPack, rustPack, csharpPack,
18
+ kotlinPack, swiftPack, dartPack, cppPack, elixirPack, scalaPack, rPack,
19
+ ];
20
+ export function findPack(id) {
21
+ const pack = ALL_PACKS.find((candidate) => candidate.id === id);
22
+ if (!pack)
23
+ throw new Error(`Unknown pack: ${id}`);
24
+ return pack;
25
+ }
@@ -17,7 +17,7 @@ function detect(signals) {
17
17
  if ((signals.buildGradle && /\bkotlin\b/i.test(signals.buildGradle)) ||
18
18
  (signals.pomXml && /\bkotlin\b/i.test(signals.pomXml)))
19
19
  return null;
20
- const framework = /spring/i.test(source)
20
+ const framework = /(?:org\.springframework|spring-boot|springframework)/i.test(source)
21
21
  ? { value: "spring", confidence: "high" }
22
22
  : { value: "none", confidence: "low" };
23
23
  const packageManager = signals.pomXml
@@ -10,9 +10,12 @@ const TEST_RUNNERS = [
10
10
  ["unittest", "unittest"],
11
11
  ];
12
12
  function findIn(haystack, table) {
13
- const lower = haystack.toLowerCase();
14
13
  for (const [needle, label] of table) {
15
- if (lower.includes(needle))
14
+ const escaped = needle.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
15
+ // Match dependency identifiers, not substrings in project names such as
16
+ // `fastapi-utils` or prose mentioning "unittest-like" behavior.
17
+ const dependency = new RegExp(`(?:^|["'\\s])${escaped}(?=$|["'\\s,;<>=!~\\[])`, "im");
18
+ if (dependency.test(haystack))
16
19
  return { value: label, confidence: "high" };
17
20
  }
18
21
  return undefined;