agent-rules-init 0.6.1 → 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.
- package/README.md +30 -19
- package/dist/bin.js +1 -1
- package/dist/cli.d.ts +14 -33
- package/dist/cli.js +144 -158
- package/dist/core/cli-options.d.ts +43 -0
- package/dist/core/cli-options.js +70 -0
- package/dist/core/config.d.ts +12 -0
- package/dist/core/config.js +57 -4
- package/dist/core/enrichment-cache.d.ts +11 -0
- package/dist/core/enrichment-cache.js +93 -0
- package/dist/core/existing-docs.d.ts +2 -0
- package/dist/core/existing-docs.js +26 -0
- package/dist/core/generation-state.d.ts +8 -2
- package/dist/core/generation-state.js +15 -3
- package/dist/core/i18n.d.ts +9 -0
- package/dist/core/i18n.js +64 -48
- package/dist/core/llm-bridge.d.ts +11 -0
- package/dist/core/llm-bridge.js +162 -38
- package/dist/core/project-units.js +2 -1
- package/dist/core/prompt-engine.d.ts +4 -13
- package/dist/core/prompt-engine.js +0 -54
- package/dist/core/scanner-async.d.ts +4 -0
- package/dist/core/scanner-async.js +55 -0
- package/dist/core/scanner-worker.d.ts +1 -0
- package/dist/core/scanner-worker.js +17 -0
- package/dist/core/scanner.d.ts +5 -1
- package/dist/core/scanner.js +37 -10
- package/dist/core/types.d.ts +8 -0
- package/dist/packs/index.d.ts +3 -0
- package/dist/packs/index.js +25 -0
- package/dist/packs/java.js +1 -1
- package/dist/packs/python.js +5 -2
- package/package.json +60 -54
package/dist/core/llm-bridge.js
CHANGED
|
@@ -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
|
-
//
|
|
38
|
-
//
|
|
39
|
-
const
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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
|
|
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) &&
|
|
162
|
-
throw new
|
|
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(
|
|
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 =
|
|
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
|
|
60
|
+
const packageJson = { ...manifest };
|
|
61
|
+
delete packageJson.path;
|
|
61
62
|
return packageJson;
|
|
62
63
|
}
|
|
63
64
|
/**
|
|
@@ -1,15 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
packId: string;
|
|
6
|
-
field: QuestionField;
|
|
7
|
-
message: string;
|
|
8
|
-
}
|
|
9
|
-
export declare function collectLowConfidenceQuestions(detections: DetectionResult[], lang: Lang): Question[];
|
|
1
|
+
/**
|
|
2
|
+
* Kept as a compatibility type for callers that supplied a custom clarification
|
|
3
|
+
* prompt before 0.6.2. Project metadata is no longer requested interactively.
|
|
4
|
+
*/
|
|
10
5
|
export type PromptFn = (message: string) => Promise<string>;
|
|
11
6
|
export declare function hasInteractiveTty(): boolean;
|
|
12
|
-
export declare function makeDefaultPromptFn(lang: Lang): PromptFn;
|
|
13
|
-
export declare const defaultPromptFn: PromptFn;
|
|
14
|
-
export declare function askQuestions(questions: Question[], promptFn?: PromptFn): Promise<Record<string, string>>;
|
|
15
|
-
export declare function applyAnswers(detections: DetectionResult[], answers: Record<string, string>): DetectionResult[];
|
|
@@ -1,57 +1,3 @@
|
|
|
1
|
-
import * as clack from "@clack/prompts";
|
|
2
|
-
import { UI, detectLang } from "./i18n.js";
|
|
3
|
-
const FIELDS = ["framework", "testRunner", "linter", "packageManager"];
|
|
4
|
-
export function collectLowConfidenceQuestions(detections, lang) {
|
|
5
|
-
const ui = UI[lang];
|
|
6
|
-
const questions = [];
|
|
7
|
-
for (const detection of detections) {
|
|
8
|
-
for (const field of FIELDS) {
|
|
9
|
-
const detectionField = detection[field];
|
|
10
|
-
if (detectionField && detectionField.confidence === "low") {
|
|
11
|
-
questions.push({
|
|
12
|
-
packId: detection.packId,
|
|
13
|
-
field,
|
|
14
|
-
message: ui.question(ui.fieldLabels[field], detection.language),
|
|
15
|
-
});
|
|
16
|
-
}
|
|
17
|
-
}
|
|
18
|
-
}
|
|
19
|
-
return questions;
|
|
20
|
-
}
|
|
21
1
|
export function hasInteractiveTty() {
|
|
22
2
|
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
23
3
|
}
|
|
24
|
-
export function makeDefaultPromptFn(lang) {
|
|
25
|
-
return async (message) => {
|
|
26
|
-
if (!hasInteractiveTty()) {
|
|
27
|
-
console.warn(UI[lang].skippedQuestion(message));
|
|
28
|
-
return "";
|
|
29
|
-
}
|
|
30
|
-
const answer = await clack.text({ message });
|
|
31
|
-
if (clack.isCancel(answer)) {
|
|
32
|
-
clack.cancel(UI[lang].cancelled);
|
|
33
|
-
process.exit(1);
|
|
34
|
-
}
|
|
35
|
-
return answer;
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
export const defaultPromptFn = (message) => makeDefaultPromptFn(detectLang())(message);
|
|
39
|
-
export async function askQuestions(questions, promptFn = defaultPromptFn) {
|
|
40
|
-
const answers = {};
|
|
41
|
-
for (const question of questions) {
|
|
42
|
-
answers[`${question.packId}:${question.field}`] = await promptFn(question.message);
|
|
43
|
-
}
|
|
44
|
-
return answers;
|
|
45
|
-
}
|
|
46
|
-
export function applyAnswers(detections, answers) {
|
|
47
|
-
return detections.map((detection) => {
|
|
48
|
-
const updated = { ...detection };
|
|
49
|
-
for (const field of FIELDS) {
|
|
50
|
-
const answer = answers[`${detection.packId}:${field}`];
|
|
51
|
-
if (answer) {
|
|
52
|
-
updated[field] = { value: answer, confidence: "high" };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
return updated;
|
|
56
|
-
});
|
|
57
|
-
}
|
|
@@ -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
|
+
}
|
package/dist/core/scanner.d.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { RepoSignals } from "./types.js";
|
|
2
|
-
export
|
|
2
|
+
export interface ScanOptions {
|
|
3
|
+
maxDepth?: number;
|
|
4
|
+
maxFiles?: number;
|
|
5
|
+
}
|
|
6
|
+
export declare function scanRepo(rootPath: string, options?: ScanOptions): RepoSignals;
|
package/dist/core/scanner.js
CHANGED
|
@@ -8,12 +8,20 @@ const IGNORED_DIRS = new Set([
|
|
|
8
8
|
".gradle", ".dart_tool",
|
|
9
9
|
".agent-rules-init",
|
|
10
10
|
]);
|
|
11
|
-
const
|
|
12
|
-
|
|
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 (
|
|
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
|
|
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
|
}
|
package/dist/core/types.d.ts
CHANGED
|
@@ -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,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
|
+
}
|
package/dist/packs/java.js
CHANGED
|
@@ -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
|