@praneeth_54/agentdoctor 3.0.1 → 3.0.3
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 +54 -1
- package/README.md +11 -9
- package/dist/agent/chat/deterministic.d.ts +2 -0
- package/dist/agent/chat/deterministic.js +112 -6
- package/dist/agent/chat/response.js +25 -4
- package/dist/agent/chat/service.d.ts +1 -0
- package/dist/agent/chat/service.js +28 -10
- package/dist/agent/chat/types.d.ts +3 -0
- package/dist/agent/context/brain-evidence.d.ts +26 -0
- package/dist/agent/context/brain-evidence.js +296 -0
- package/dist/agent/context/retrieve.d.ts +7 -1
- package/dist/agent/context/retrieve.js +204 -63
- package/dist/agent/progress.d.ts +10 -0
- package/dist/agent/progress.js +4 -0
- package/dist/cli/ask-progress.d.ts +17 -0
- package/dist/cli/ask-progress.js +85 -0
- package/dist/cli/commands/chat.d.ts +6 -0
- package/dist/cli/commands/chat.js +16 -0
- package/dist/cli/commands/start.js +15 -6
- package/dist/cli/program.js +2 -1
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/understanding/brain/storage/store.js +0 -1
- package/dist/dashboard/server.js +11 -10
- package/dist/product/discovery/roots.js +115 -9
- package/package.json +1 -1
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { colors, symbolFail, symbolOk } from "../utils/colors.js";
|
|
2
|
+
const STAGE_LABELS = {
|
|
3
|
+
understand: "Understanding question",
|
|
4
|
+
search: "Searching project knowledge",
|
|
5
|
+
retrieve: "Retrieving relevant evidence",
|
|
6
|
+
verify: "Verifying evidence",
|
|
7
|
+
prepare: "Preparing answer",
|
|
8
|
+
};
|
|
9
|
+
const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
10
|
+
/**
|
|
11
|
+
* Human-readable ask progress. Stages must be driven by real operations.
|
|
12
|
+
* Animation uses setInterval only for frame updates while work is in flight —
|
|
13
|
+
* never to invent delay.
|
|
14
|
+
*/
|
|
15
|
+
export function createAskProgress(options = {}) {
|
|
16
|
+
const enabled = options.enabled !== false;
|
|
17
|
+
const stream = options.stream ?? process.stderr;
|
|
18
|
+
const tty = options.isTTY ??
|
|
19
|
+
Boolean(stream && typeof stream === "object" && "isTTY" in stream && stream.isTTY);
|
|
20
|
+
let frame = 0;
|
|
21
|
+
let timer;
|
|
22
|
+
let activeLabel;
|
|
23
|
+
let stopped = false;
|
|
24
|
+
const clearLine = () => {
|
|
25
|
+
if (!tty || !enabled)
|
|
26
|
+
return;
|
|
27
|
+
stream.write("\r\x1b[K");
|
|
28
|
+
};
|
|
29
|
+
const stopSpin = () => {
|
|
30
|
+
if (timer) {
|
|
31
|
+
clearInterval(timer);
|
|
32
|
+
timer = undefined;
|
|
33
|
+
}
|
|
34
|
+
clearLine();
|
|
35
|
+
};
|
|
36
|
+
const renderSpin = () => {
|
|
37
|
+
if (!tty || !enabled || !activeLabel || stopped)
|
|
38
|
+
return;
|
|
39
|
+
const glyph = SPINNER_FRAMES[frame % SPINNER_FRAMES.length];
|
|
40
|
+
frame += 1;
|
|
41
|
+
stream.write(`\r${colors.cyan(glyph)} ${activeLabel}...`);
|
|
42
|
+
};
|
|
43
|
+
const writeDone = (ok, label, detail) => {
|
|
44
|
+
if (!enabled || stopped)
|
|
45
|
+
return;
|
|
46
|
+
const mark = ok ? symbolOk() : symbolFail();
|
|
47
|
+
const suffix = detail ? colors.dim(` — ${detail}`) : "";
|
|
48
|
+
stream.write(`${mark} ${label}${suffix}\n`);
|
|
49
|
+
};
|
|
50
|
+
return {
|
|
51
|
+
stage(id, state, detail) {
|
|
52
|
+
if (!enabled || stopped)
|
|
53
|
+
return;
|
|
54
|
+
const label = STAGE_LABELS[id];
|
|
55
|
+
if (state === "start") {
|
|
56
|
+
stopSpin();
|
|
57
|
+
activeLabel = label;
|
|
58
|
+
if (tty) {
|
|
59
|
+
frame = 0;
|
|
60
|
+
renderSpin();
|
|
61
|
+
timer = setInterval(renderSpin, 80);
|
|
62
|
+
timer.unref?.();
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
stream.write(`${label}...\n`);
|
|
66
|
+
}
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
stopSpin();
|
|
70
|
+
activeLabel = undefined;
|
|
71
|
+
writeDone(state === "ok", state === "ok" && id === "prepare" ? "Answer ready" : label, detail);
|
|
72
|
+
},
|
|
73
|
+
stop() {
|
|
74
|
+
stopSpin();
|
|
75
|
+
activeLabel = undefined;
|
|
76
|
+
stopped = true;
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export function silentAskProgress() {
|
|
81
|
+
return {
|
|
82
|
+
stage() { },
|
|
83
|
+
stop() { },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
@@ -5,6 +5,12 @@ export declare function runAskCommand(options: {
|
|
|
5
5
|
json?: boolean;
|
|
6
6
|
/** Test-only: force mock provider */
|
|
7
7
|
useMock?: boolean;
|
|
8
|
+
/** Test override: force progress enabled/disabled */
|
|
9
|
+
progressEnabled?: boolean;
|
|
10
|
+
/** Test override: progress stream */
|
|
11
|
+
progressStream?: NodeJS.WritableStream;
|
|
12
|
+
/** Test override: treat progress stream as TTY */
|
|
13
|
+
progressIsTTY?: boolean;
|
|
8
14
|
}): Promise<ExitCode>;
|
|
9
15
|
export declare function runChatCommand(options: {
|
|
10
16
|
root?: string;
|
|
@@ -5,6 +5,7 @@ import { MockModelProvider, createModelProvider, loadAiConfig } from "../../ai/i
|
|
|
5
5
|
import { EXIT_CODES } from "../../types/index.js";
|
|
6
6
|
import { resolveCliProjectRoot } from "../safe-root.js";
|
|
7
7
|
import { colors } from "../../utils/colors.js";
|
|
8
|
+
import { createAskProgress } from "../ask-progress.js";
|
|
8
9
|
function printBanner(options) {
|
|
9
10
|
const lines = [
|
|
10
11
|
"",
|
|
@@ -58,13 +59,21 @@ export async function runAskCommand(options) {
|
|
|
58
59
|
}
|
|
59
60
|
const root = gated.root;
|
|
60
61
|
const provider = options.useMock ? new MockModelProvider() : createModelProvider(loadAiConfig());
|
|
62
|
+
const progressEnabled = options.progressEnabled ?? !options.json;
|
|
63
|
+
const progress = createAskProgress({
|
|
64
|
+
enabled: progressEnabled,
|
|
65
|
+
...(options.progressStream ? { stream: options.progressStream } : {}),
|
|
66
|
+
...(options.progressIsTTY !== undefined ? { isTTY: options.progressIsTTY } : {}),
|
|
67
|
+
});
|
|
61
68
|
const chat = new ChatService({
|
|
62
69
|
root,
|
|
63
70
|
provider,
|
|
64
71
|
persistAudit: !options.useMock,
|
|
72
|
+
progress,
|
|
65
73
|
});
|
|
66
74
|
try {
|
|
67
75
|
const response = await chat.ask(options.question);
|
|
76
|
+
progress.stop();
|
|
68
77
|
if (options.json) {
|
|
69
78
|
process.stdout.write(`${JSON.stringify(response, null, 2)}\n`);
|
|
70
79
|
}
|
|
@@ -78,7 +87,14 @@ export async function runAskCommand(options) {
|
|
|
78
87
|
}
|
|
79
88
|
return EXIT_CODES.SUCCESS;
|
|
80
89
|
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
progress.stop();
|
|
92
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
93
|
+
console.error(`AgentDoctor error:\n${message}`);
|
|
94
|
+
return EXIT_CODES.INTERNAL_ERROR;
|
|
95
|
+
}
|
|
81
96
|
finally {
|
|
97
|
+
progress.stop();
|
|
82
98
|
await chat.end();
|
|
83
99
|
}
|
|
84
100
|
}
|
|
@@ -2,16 +2,21 @@ import { EXIT_CODES } from "../../types/index.js";
|
|
|
2
2
|
import { initBrainStore, rebuildBrain, getBrainStatus } from "../../core/brain-cli/service.js";
|
|
3
3
|
import { discoverProjectRoots } from "../../product/discovery/roots.js";
|
|
4
4
|
import { buildProjectDna, persistProjectDna } from "../../product/dna/build.js";
|
|
5
|
-
import { resolveRepoRoot } from "../../utils/path.js";
|
|
6
5
|
import { resolveCliProjectRoot } from "../safe-root.js";
|
|
7
6
|
function printJson(value) {
|
|
8
7
|
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
9
8
|
}
|
|
10
9
|
export async function runStartCommand(options) {
|
|
11
10
|
const prefer = options.select ?? options.root;
|
|
11
|
+
// Fail closed before discovery walks anything (home/Desktop refusal must be instant).
|
|
12
|
+
const gated = await resolveCliProjectRoot(prefer);
|
|
13
|
+
if (!gated.ok) {
|
|
14
|
+
console.error(`Error: ${gated.message}`);
|
|
15
|
+
return gated.code;
|
|
16
|
+
}
|
|
12
17
|
const discovery = await discoverProjectRoots({
|
|
13
|
-
cwd:
|
|
14
|
-
|
|
18
|
+
cwd: gated.root,
|
|
19
|
+
prefer: gated.root,
|
|
15
20
|
...(options.maxEntries !== undefined ? { maxEntries: options.maxEntries } : {}),
|
|
16
21
|
autoSelect: !options.listOnly,
|
|
17
22
|
});
|
|
@@ -26,13 +31,16 @@ export async function runStartCommand(options) {
|
|
|
26
31
|
}
|
|
27
32
|
process.stdout.write("Project candidates:\n");
|
|
28
33
|
if (discovery.candidates.length === 0) {
|
|
29
|
-
process.stdout.write(
|
|
34
|
+
process.stdout.write(` (none found under ${gated.root})\n`);
|
|
35
|
+
process.stdout.write(" Tip: open a project directory that contains source code or a manifest (package.json, composer.json, pyproject.toml, .git, …),\n");
|
|
36
|
+
process.stdout.write(` or run: agentdoctor start ${gated.root}\n`);
|
|
37
|
+
process.stdout.write(" Related: agentdoctor dna . # inspect what DNA sees in this folder\n");
|
|
30
38
|
return EXIT_CODES.USAGE_ERROR;
|
|
31
39
|
}
|
|
32
40
|
for (const [i, c] of discovery.candidates.entries()) {
|
|
33
41
|
process.stdout.write(` [${i + 1}] ${c.root}\n score=${c.score} markers=${c.markers.join(", ")} (${c.reason})\n`);
|
|
34
42
|
}
|
|
35
|
-
process.stdout.write("\
|
|
43
|
+
process.stdout.write("\nMultiple projects detected — re-run with --select <path> (or pass an explicit path).\n");
|
|
36
44
|
return EXIT_CODES.SUCCESS;
|
|
37
45
|
}
|
|
38
46
|
const root = discovery.selected.root;
|
|
@@ -50,8 +58,9 @@ export async function runStartCommand(options) {
|
|
|
50
58
|
printJson(payload);
|
|
51
59
|
}
|
|
52
60
|
else {
|
|
61
|
+
process.stdout.write(`✓ Project detected: ${dna.name}\n`);
|
|
62
|
+
process.stdout.write(` Root: ${root}\n`);
|
|
53
63
|
process.stdout.write(`AgentDoctor start\n`);
|
|
54
|
-
process.stdout.write(` root: ${root}\n`);
|
|
55
64
|
process.stdout.write(` fingerprint: ${dna.fingerprint}\n`);
|
|
56
65
|
process.stdout.write(` type: ${dna.projectType}\n`);
|
|
57
66
|
process.stdout.write(` languages: ${dna.languages.join(", ") || "(none)"}\n`);
|
package/dist/cli/program.js
CHANGED
|
@@ -1255,7 +1255,8 @@ export function createProgram() {
|
|
|
1255
1255
|
.argument("<question>", "Question about the project")
|
|
1256
1256
|
.argument("[path]", "Repository path (default: current directory)")
|
|
1257
1257
|
.option("--json", "Emit JSON response", false)
|
|
1258
|
-
.action(async (question, pathArg,
|
|
1258
|
+
.action(async (question, pathArg, _options, command) => {
|
|
1259
|
+
const options = command.optsWithGlobals();
|
|
1259
1260
|
process.exitCode = await runAskCommand({
|
|
1260
1261
|
question,
|
|
1261
1262
|
root: resolveTargetArgument(pathArg),
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentId } from "./types/index.js";
|
|
2
|
-
export declare const PACKAGE_VERSION = "3.0.
|
|
2
|
+
export declare const PACKAGE_VERSION = "3.0.3";
|
|
3
3
|
export declare const DEFAULT_MAX_FILE_SIZE_BYTES: number;
|
|
4
4
|
/** Directories skipped during normal discovery (unless a rule needs them later). */
|
|
5
5
|
export declare const DEFAULT_IGNORE_DIRECTORIES: Set<string>;
|
package/dist/constants.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const PACKAGE_VERSION = "3.0.
|
|
1
|
+
export const PACKAGE_VERSION = "3.0.3";
|
|
2
2
|
export const DEFAULT_MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; // 2 MiB
|
|
3
3
|
/** Directories skipped during normal discovery (unless a rule needs them later). */
|
|
4
4
|
export const DEFAULT_IGNORE_DIRECTORIES = new Set([
|
|
@@ -128,7 +128,6 @@ export class LocalBrainStore {
|
|
|
128
128
|
await fs.mkdir(path.join(this.root, "deltas"), { recursive: true });
|
|
129
129
|
}
|
|
130
130
|
async readMeta() {
|
|
131
|
-
await this.ensureRoot();
|
|
132
131
|
try {
|
|
133
132
|
const raw = await fs.readFile(this.metaPath(), "utf8");
|
|
134
133
|
const parsed = JSON.parse(raw);
|
package/dist/dashboard/server.js
CHANGED
|
@@ -20,7 +20,6 @@ import { listKnowledge } from "../knowledge/store.js";
|
|
|
20
20
|
import { CONTRACTS_VERSION } from "../contracts/index.js";
|
|
21
21
|
import { collectOpsHealth } from "../ops/health.js";
|
|
22
22
|
import { listPolicyPacks } from "../policy/packs.js";
|
|
23
|
-
import { sanitizeForOutput } from "../utils/path.js";
|
|
24
23
|
import { createModelProvider, loadAiConfig } from "../ai/index.js";
|
|
25
24
|
import { ChatService } from "../agent/chat/service.js";
|
|
26
25
|
import { formatChatResponseForCli } from "../agent/chat/response.js";
|
|
@@ -43,11 +42,15 @@ import { analyzeFeatureIntelligence } from "../product/features/intelligence.js"
|
|
|
43
42
|
import { buildSoftwareEvolutionTimeline } from "../product/evolution/timeline.js";
|
|
44
43
|
import { queryMemory } from "../product/memory/institutional.js";
|
|
45
44
|
import { loadDecisionLedger } from "../product/decisions/ledger.js";
|
|
45
|
+
import { ProjectOwnershipError } from "../project/ownership.js";
|
|
46
|
+
import { PathEscapeError } from "../security/paths.js";
|
|
46
47
|
import { htmlPage } from "./page.js";
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
/** HTTP-safe client error — never returns Error.message/stack (CodeQL js/stack-trace-exposure). */
|
|
49
|
+
function clientErrorMessage(error) {
|
|
50
|
+
if (error instanceof ProjectOwnershipError || error instanceof PathEscapeError) {
|
|
51
|
+
return "path outside project ownership";
|
|
52
|
+
}
|
|
53
|
+
return "internal error";
|
|
51
54
|
}
|
|
52
55
|
function pathnameLooksHostile(pathname) {
|
|
53
56
|
const lower = pathname.toLowerCase();
|
|
@@ -386,11 +389,9 @@ export async function startDashboardServer(options) {
|
|
|
386
389
|
sendJson(res, 200, await analyzeWhatIf(root, target));
|
|
387
390
|
}
|
|
388
391
|
catch (error) {
|
|
389
|
-
const
|
|
390
|
-
const ownershipDenied = /ownership|escapes|path escape|PROJECT_OWNERSHIP/i.test(message) ||
|
|
391
|
-
(error instanceof Error && error.name === "ProjectOwnershipError");
|
|
392
|
+
const ownershipDenied = error instanceof ProjectOwnershipError || error instanceof PathEscapeError;
|
|
392
393
|
sendJson(res, ownershipDenied ? 400 : 500, {
|
|
393
|
-
error: ownershipDenied ? "path outside project ownership" :
|
|
394
|
+
error: ownershipDenied ? "path outside project ownership" : "what-if analysis failed",
|
|
394
395
|
});
|
|
395
396
|
}
|
|
396
397
|
return;
|
|
@@ -439,7 +440,7 @@ export async function startDashboardServer(options) {
|
|
|
439
440
|
sendJson(res, 404, { error: "not found" });
|
|
440
441
|
}
|
|
441
442
|
catch (error) {
|
|
442
|
-
sendJson(res, 500, { error:
|
|
443
|
+
sendJson(res, 500, { error: clientErrorMessage(error) });
|
|
443
444
|
}
|
|
444
445
|
});
|
|
445
446
|
await new Promise((resolve, reject) => {
|
|
@@ -2,6 +2,7 @@ import fs from "node:fs/promises";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { DEFAULT_IGNORE_DIRECTORIES } from "../../constants.js";
|
|
5
|
+
import { detectProject } from "../../detectors/project.js";
|
|
5
6
|
import { isDirectory, pathExists } from "../../utils/fs.js";
|
|
6
7
|
import { resolveRepoRoot } from "../../utils/path.js";
|
|
7
8
|
/** Markers that strongly suggest a software project root. */
|
|
@@ -19,6 +20,8 @@ const PROJECT_MARKERS = [
|
|
|
19
20
|
"pubspec.yaml",
|
|
20
21
|
".git",
|
|
21
22
|
];
|
|
23
|
+
/** Directory basenames that must never be treated as a project root candidate. */
|
|
24
|
+
const FORBIDDEN_ROOT_BASENAMES = new Set([".private", "agentdoctoros"]);
|
|
22
25
|
const SUSPICIOUS_DIR_NAMES = new Set([
|
|
23
26
|
"desktop",
|
|
24
27
|
"downloads",
|
|
@@ -32,9 +35,19 @@ const SUSPICIOUS_DIR_NAMES = new Set([
|
|
|
32
35
|
function normalizeHome(home) {
|
|
33
36
|
return path.resolve(home);
|
|
34
37
|
}
|
|
38
|
+
function pathsEqual(a, b) {
|
|
39
|
+
const left = path.resolve(a);
|
|
40
|
+
const right = path.resolve(b);
|
|
41
|
+
return process.platform === "win32" ? left.toLowerCase() === right.toLowerCase() : left === right;
|
|
42
|
+
}
|
|
35
43
|
function isUnderHomeSubtree(abs, home, leaf) {
|
|
36
44
|
const target = path.resolve(home, leaf);
|
|
37
45
|
const resolved = path.resolve(abs);
|
|
46
|
+
if (process.platform === "win32") {
|
|
47
|
+
const t = target.toLowerCase();
|
|
48
|
+
const r = resolved.toLowerCase();
|
|
49
|
+
return r === t || r.startsWith(t + "\\");
|
|
50
|
+
}
|
|
38
51
|
return resolved === target || resolved.startsWith(target + path.sep);
|
|
39
52
|
}
|
|
40
53
|
async function countEntriesShallow(dir, budget) {
|
|
@@ -92,7 +105,7 @@ export function classifyBroadUserScanRoot(cwdInput, homeInput) {
|
|
|
92
105
|
const cwd = path.resolve(cwdInput);
|
|
93
106
|
const home = normalizeHome(homeInput ?? os.homedir());
|
|
94
107
|
const baselower = path.basename(cwd).toLowerCase();
|
|
95
|
-
if (cwd
|
|
108
|
+
if (pathsEqual(cwd, home) ||
|
|
96
109
|
SUSPICIOUS_DIR_NAMES.has(baselower) ||
|
|
97
110
|
isUnderHomeSubtree(cwd, home, "Desktop") ||
|
|
98
111
|
isUnderHomeSubtree(cwd, home, "Downloads") ||
|
|
@@ -113,12 +126,11 @@ export async function discoverProjectRoots(options = {}) {
|
|
|
113
126
|
const home = normalizeHome(os.homedir());
|
|
114
127
|
const maxEntries = options.maxEntries ?? 25_000;
|
|
115
128
|
const limitations = [
|
|
116
|
-
"Discovery uses shallow marker probes
|
|
117
|
-
"Candidates are VERIFIED only when marker files
|
|
129
|
+
"Discovery uses shallow marker probes, bounded entry counts, and detectProject for cwd/prefer when classic markers are absent.",
|
|
130
|
+
"Candidates are VERIFIED only when marker files exist or detectProject finds languages/source under that root.",
|
|
118
131
|
];
|
|
119
132
|
const broad = classifyBroadUserScanRoot(cwd, home);
|
|
120
133
|
if (broad.blocked) {
|
|
121
|
-
const estimatedEntries = await countEntriesShallow(cwd, Math.min(maxEntries, 5_000));
|
|
122
134
|
return {
|
|
123
135
|
cwd,
|
|
124
136
|
home,
|
|
@@ -126,7 +138,8 @@ export async function discoverProjectRoots(options = {}) {
|
|
|
126
138
|
selected: null,
|
|
127
139
|
blocked: true,
|
|
128
140
|
blockReason: broad.reason,
|
|
129
|
-
|
|
141
|
+
// Do not walk the tree — counting home/Desktop can hang CI (esp. Windows).
|
|
142
|
+
estimatedEntries: 0,
|
|
130
143
|
limitations,
|
|
131
144
|
};
|
|
132
145
|
}
|
|
@@ -145,13 +158,19 @@ export async function discoverProjectRoots(options = {}) {
|
|
|
145
158
|
}
|
|
146
159
|
const candidates = [];
|
|
147
160
|
const seen = new Set();
|
|
161
|
+
function rejectForbiddenRoot(resolved) {
|
|
162
|
+
const base = path.basename(resolved).toLowerCase();
|
|
163
|
+
return FORBIDDEN_ROOT_BASENAMES.has(base);
|
|
164
|
+
}
|
|
148
165
|
async function consider(dir, reason) {
|
|
149
166
|
const resolved = path.resolve(dir);
|
|
150
167
|
if (seen.has(resolved))
|
|
151
168
|
return;
|
|
152
169
|
if (!(await isDirectory(resolved)))
|
|
153
170
|
return;
|
|
154
|
-
if (resolved
|
|
171
|
+
if (pathsEqual(resolved, home))
|
|
172
|
+
return;
|
|
173
|
+
if (rejectForbiddenRoot(resolved))
|
|
155
174
|
return;
|
|
156
175
|
const markers = await markersAt(resolved);
|
|
157
176
|
if (markers.length === 0)
|
|
@@ -165,6 +184,46 @@ export async function discoverProjectRoots(options = {}) {
|
|
|
165
184
|
reason,
|
|
166
185
|
});
|
|
167
186
|
}
|
|
187
|
+
/**
|
|
188
|
+
* When classic manifests/.git are absent, reuse the same detectProject path as DNA
|
|
189
|
+
* so `start` agrees with `dna` for valid software trees (e.g. language-only roots).
|
|
190
|
+
* Only applied to prefer/cwd — never to unbounded parent/child walks.
|
|
191
|
+
*/
|
|
192
|
+
async function considerDetectProject(dir, reason) {
|
|
193
|
+
const resolved = path.resolve(dir);
|
|
194
|
+
if (seen.has(resolved))
|
|
195
|
+
return;
|
|
196
|
+
if (!(await isDirectory(resolved)))
|
|
197
|
+
return;
|
|
198
|
+
if (pathsEqual(resolved, home))
|
|
199
|
+
return;
|
|
200
|
+
if (rejectForbiddenRoot(resolved))
|
|
201
|
+
return;
|
|
202
|
+
if (classifyBroadUserScanRoot(resolved, home).blocked)
|
|
203
|
+
return;
|
|
204
|
+
let detection;
|
|
205
|
+
try {
|
|
206
|
+
detection = await detectProject(resolved);
|
|
207
|
+
}
|
|
208
|
+
catch {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// detectProject returns ["unknown"] for empty trees — that is not a project signal.
|
|
212
|
+
const languages = detection.repository.languages.filter((l) => l !== "unknown");
|
|
213
|
+
const sourceFiles = detection.discovery.files.filter((f) => /\.(ts|tsx|js|jsx|mjs|cjs|py|php|go|rs|java|kt|kts|dart|rb|cs)$/i.test(f.relativePath)).length;
|
|
214
|
+
if (languages.length === 0 && sourceFiles === 0)
|
|
215
|
+
return;
|
|
216
|
+
seen.add(resolved);
|
|
217
|
+
const markers = languages.length > 0 ? languages.map((l) => `lang:${l}`) : [`source-files:${sourceFiles}`];
|
|
218
|
+
candidates.push({
|
|
219
|
+
root: resolved,
|
|
220
|
+
// Match strong marker score so a lone detectProject hit auto-selects like package.json.
|
|
221
|
+
score: languages.length > 0 ? 5 : 3,
|
|
222
|
+
markers,
|
|
223
|
+
truth: "VERIFIED",
|
|
224
|
+
reason,
|
|
225
|
+
});
|
|
226
|
+
}
|
|
168
227
|
if (options.prefer) {
|
|
169
228
|
await consider(options.prefer, "explicit prefer path");
|
|
170
229
|
}
|
|
@@ -172,7 +231,7 @@ export async function discoverProjectRoots(options = {}) {
|
|
|
172
231
|
// Walk up a few parents looking for markers (bounded).
|
|
173
232
|
let parent = path.dirname(cwd);
|
|
174
233
|
for (let i = 0; i < 4; i++) {
|
|
175
|
-
if (parent
|
|
234
|
+
if (pathsEqual(parent, home) || parent === path.dirname(parent))
|
|
176
235
|
break;
|
|
177
236
|
await consider(parent, "parent directory with project markers");
|
|
178
237
|
parent = path.dirname(parent);
|
|
@@ -185,16 +244,63 @@ export async function discoverProjectRoots(options = {}) {
|
|
|
185
244
|
continue;
|
|
186
245
|
if (DEFAULT_IGNORE_DIRECTORIES.has(entry.name) || entry.name.startsWith("."))
|
|
187
246
|
continue;
|
|
247
|
+
if (FORBIDDEN_ROOT_BASENAMES.has(entry.name.toLowerCase()))
|
|
248
|
+
continue;
|
|
188
249
|
await consider(path.join(cwd, entry.name), "child directory with project markers");
|
|
189
250
|
}
|
|
190
251
|
}
|
|
191
252
|
catch {
|
|
192
253
|
limitations.push("Could not list child directories for candidate discovery.");
|
|
193
254
|
}
|
|
255
|
+
// detectProject fallback (same engine as DNA) for prefer/cwd when no classic markers
|
|
256
|
+
// apply to that path AND no ancestor marker candidate already covers it (nested Case B).
|
|
257
|
+
// Also skip when child classic-marker projects already exist — otherwise detectProject
|
|
258
|
+
// would absorb sibling packages into a multi-project parent (start always passes prefer=cwd).
|
|
259
|
+
const preferAbs = options.prefer ? path.resolve(options.prefer) : null;
|
|
260
|
+
function ancestorMarkerCovers(target) {
|
|
261
|
+
return candidates.find((c) => {
|
|
262
|
+
if (pathsEqual(c.root, target))
|
|
263
|
+
return true;
|
|
264
|
+
const rootWithSep = c.root.endsWith(path.sep) ? c.root : c.root + path.sep;
|
|
265
|
+
if (process.platform === "win32") {
|
|
266
|
+
return target.toLowerCase().startsWith(rootWithSep.toLowerCase());
|
|
267
|
+
}
|
|
268
|
+
return target.startsWith(rootWithSep);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
function descendantMarkerCandidates(target) {
|
|
272
|
+
const targetWithSep = target.endsWith(path.sep) ? target : target + path.sep;
|
|
273
|
+
return candidates.filter((c) => {
|
|
274
|
+
if (pathsEqual(c.root, target))
|
|
275
|
+
return false;
|
|
276
|
+
if (process.platform === "win32") {
|
|
277
|
+
return c.root.toLowerCase().startsWith(targetWithSep.toLowerCase());
|
|
278
|
+
}
|
|
279
|
+
return c.root.startsWith(targetWithSep);
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
if (preferAbs) {
|
|
283
|
+
// Skip detectProject absorption when 2+ classic child projects exist (multi-project
|
|
284
|
+
// parent). A single nested foreign repo must not block prefer's own language tree.
|
|
285
|
+
if (!ancestorMarkerCovers(preferAbs) && descendantMarkerCandidates(preferAbs).length < 2) {
|
|
286
|
+
await considerDetectProject(preferAbs, "explicit prefer path (detectProject)");
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (candidates.length === 0) {
|
|
290
|
+
await considerDetectProject(cwd, "current working directory (detectProject)");
|
|
291
|
+
}
|
|
194
292
|
candidates.sort((a, b) => b.score - a.score || a.root.localeCompare(b.root));
|
|
195
293
|
let selected = null;
|
|
196
|
-
if (
|
|
197
|
-
|
|
294
|
+
if (preferAbs) {
|
|
295
|
+
const exact = candidates.find((c) => pathsEqual(c.root, preferAbs));
|
|
296
|
+
const covered = ancestorMarkerCovers(preferAbs);
|
|
297
|
+
// Nested dir under a marker project → select the marker root, not the nested leaf.
|
|
298
|
+
if (covered && !pathsEqual(covered.root, preferAbs)) {
|
|
299
|
+
selected = covered;
|
|
300
|
+
}
|
|
301
|
+
else {
|
|
302
|
+
selected = exact ?? covered ?? null;
|
|
303
|
+
}
|
|
198
304
|
}
|
|
199
305
|
else if (options.autoSelect !== false) {
|
|
200
306
|
const strong = candidates.filter((c) => c.score >= 5);
|
package/package.json
CHANGED