@praneeth_54/agentdoctor 3.0.1 → 3.0.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 +23 -1
- package/README.md +10 -9
- package/dist/agent/chat/deterministic.js +22 -4
- package/dist/cli/commands/start.js +15 -6
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/dashboard/server.js +11 -10
- package/dist/product/discovery/roots.js +115 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [3.0.2] — 2026-09-27
|
|
11
|
+
|
|
12
|
+
Patch release: fix current-directory project discovery for `agentdoctor start`.
|
|
13
|
+
|
|
14
|
+
Public relationship:
|
|
15
|
+
|
|
16
|
+
- **3.0.0** and **3.0.1** remain immutable public releases.
|
|
17
|
+
- **3.0.2** does not rewrite prior npm/GitHub history.
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- `agentdoctor start` no longer reports `(none found)` for valid software trees that lack classic manifests (e.g. language-only roots without `package.json` / `.git`).
|
|
22
|
+
- Discovery fallback now reuses the same deterministic `detectProject` engine as `dna`, so `start` and `dna` agree on the project root.
|
|
23
|
+
- Clearer discovery UX (`✓ Project detected`) and empty-state guidance.
|
|
24
|
+
- Reject empty trees that only report `unknown` language; do not silently absorb multi-project parents with 2+ child marker projects.
|
|
25
|
+
- Ownership, containment, HOME/broad-root, `.private`, nested-repo, and symlink protections remain enforced.
|
|
26
|
+
|
|
27
|
+
### Tests
|
|
28
|
+
|
|
29
|
+
- Added `tests/unit/project/start-discovery.test.ts` regression coverage for markerless cwd, nested roots, multi-project parents, `.private`, and home refusal.
|
|
30
|
+
|
|
10
31
|
## [3.0.1] — 2026-09-27
|
|
11
32
|
|
|
12
33
|
Production-hardening follow-up on the reviewed freeze remediation commit
|
|
@@ -601,7 +622,8 @@ First public beta.
|
|
|
601
622
|
- Not a complete secret scanner
|
|
602
623
|
- Git “tracked secret” detection deferred
|
|
603
624
|
|
|
604
|
-
[Unreleased]: https://github.com/pranee54/AgentDoctor/compare/v3.0.
|
|
625
|
+
[Unreleased]: https://github.com/pranee54/AgentDoctor/compare/v3.0.2...HEAD
|
|
626
|
+
[3.0.2]: https://github.com/pranee54/AgentDoctor/compare/v3.0.1...v3.0.2
|
|
605
627
|
[3.0.1]: https://github.com/pranee54/AgentDoctor/compare/v3.0.0...v3.0.1
|
|
606
628
|
[3.0.0]: https://github.com/pranee54/AgentDoctor/compare/v2.1.0...v3.0.0
|
|
607
629
|
[2.1.0]: https://github.com/pranee54/AgentDoctor/compare/v2.0.1...v2.1.0
|
package/README.md
CHANGED
|
@@ -281,15 +281,15 @@ Student path: `learn` → ask → viva → docs → Build With Me (with `--appro
|
|
|
281
281
|
Requires **Node.js 20+**.
|
|
282
282
|
|
|
283
283
|
```bash
|
|
284
|
-
npm install -g @praneeth_54/agentdoctor@3.0.
|
|
285
|
-
agentdoctor --version # 3.0.
|
|
284
|
+
npm install -g @praneeth_54/agentdoctor@3.0.2
|
|
285
|
+
agentdoctor --version # 3.0.2
|
|
286
286
|
agentdoctor --help
|
|
287
287
|
```
|
|
288
288
|
|
|
289
289
|
Or without global install:
|
|
290
290
|
|
|
291
291
|
```bash
|
|
292
|
-
npx @praneeth_54/agentdoctor@3.0.
|
|
292
|
+
npx @praneeth_54/agentdoctor@3.0.2 --help
|
|
293
293
|
```
|
|
294
294
|
|
|
295
295
|
---
|
|
@@ -298,7 +298,7 @@ npx @praneeth_54/agentdoctor@3.0.1 --help
|
|
|
298
298
|
|
|
299
299
|
```bash
|
|
300
300
|
# 1. Install
|
|
301
|
-
npm install -g @praneeth_54/agentdoctor@3.0.
|
|
301
|
+
npm install -g @praneeth_54/agentdoctor@3.0.2
|
|
302
302
|
|
|
303
303
|
# 2. Enter YOUR project (not your home folder)
|
|
304
304
|
cd /path/to/my-project
|
|
@@ -581,11 +581,12 @@ Post-acceptance ideas for later releases: maintainers see [docs/internal/POST_3_
|
|
|
581
581
|
|
|
582
582
|
## Roadmap
|
|
583
583
|
|
|
584
|
-
| Track
|
|
585
|
-
|
|
|
586
|
-
| **3.0.0 (released)**
|
|
587
|
-
| **3.0.1 (
|
|
588
|
-
| **
|
|
584
|
+
| Track | Meaning |
|
|
585
|
+
| -------------------- | ------------------------------------------------------------------------ |
|
|
586
|
+
| **3.0.0 (released)** | Original public release (`v3.0.0` → `c21faf1`) |
|
|
587
|
+
| **3.0.1 (released)** | Freeze remediation / ownership & security hardening follow-up |
|
|
588
|
+
| **3.0.2 (released)** | `start` discovery aligned with deterministic `detectProject` |
|
|
589
|
+
| **Coming soon** | Maturity, language depth, runtime adapters, DX — not promised ship dates |
|
|
589
590
|
|
|
590
591
|
---
|
|
591
592
|
|
|
@@ -5,11 +5,29 @@ import { buildIntelligenceGraph } from "../../intelligence/graph/build.js";
|
|
|
5
5
|
import { resolveRepoRoot } from "../../utils/path.js";
|
|
6
6
|
import { retrieveProjectContext } from "../context/retrieve.js";
|
|
7
7
|
import { buildChatTurnResponse } from "./response.js";
|
|
8
|
-
const ARCH_RE = /architecture|structure|module|layout|
|
|
8
|
+
const ARCH_RE = /architecture|structure|module|layout|what (is|does) (this|my) project|project overview|understand (my |this )?project/i;
|
|
9
9
|
const AUTH_RE = /auth|login|password|session|jwt|oauth/i;
|
|
10
10
|
const DEPS_RE = /depend|package|lockfile|npm|yarn|pnpm/i;
|
|
11
|
-
const START_RE = /where.*(start|entry|boot|main)|application start|entry\s*point/i;
|
|
12
11
|
const DB_RE = /database|db\.|sql|prisma|query\(/i;
|
|
12
|
+
/** ReDoS-safe: avoid `where.*…` / `how.*…` backtracking on uncontrolled chat input. */
|
|
13
|
+
function isStartQuestion(q) {
|
|
14
|
+
if (/application start|entry\s*point/i.test(q))
|
|
15
|
+
return true;
|
|
16
|
+
const lower = q.toLowerCase();
|
|
17
|
+
if (!lower.includes("where"))
|
|
18
|
+
return false;
|
|
19
|
+
return /\b(start|entry|boot|main)\b/i.test(q);
|
|
20
|
+
}
|
|
21
|
+
function isArchitectureQuestion(q) {
|
|
22
|
+
if (ARCH_RE.test(q))
|
|
23
|
+
return true;
|
|
24
|
+
const lower = q.toLowerCase();
|
|
25
|
+
if (lower.includes("how") && /(?:project|repo|codebase)/i.test(q))
|
|
26
|
+
return true;
|
|
27
|
+
if (lower.includes("explain") && /(?:project|repo|codebase|\bthis\b)/i.test(q))
|
|
28
|
+
return true;
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
13
31
|
export async function answerDeterministicProjectQuestion(options) {
|
|
14
32
|
const root = resolveRepoRoot(options.root);
|
|
15
33
|
const q = options.question.trim();
|
|
@@ -61,7 +79,7 @@ export async function answerDeterministicProjectQuestion(options) {
|
|
|
61
79
|
}
|
|
62
80
|
sections.push("");
|
|
63
81
|
}
|
|
64
|
-
if (
|
|
82
|
+
if (isStartQuestion(q)) {
|
|
65
83
|
const startHits = context.citations
|
|
66
84
|
.filter((c) => c.path && /(server|index|main|app)\.[jt]sx?$/i.test(c.path))
|
|
67
85
|
.slice(0, 6);
|
|
@@ -139,7 +157,7 @@ export async function answerDeterministicProjectQuestion(options) {
|
|
|
139
157
|
sections.push(`Lockfiles: ${deps.lockfilesPresent.join(", ") || "none"}`);
|
|
140
158
|
sections.push("");
|
|
141
159
|
}
|
|
142
|
-
if (
|
|
160
|
+
if (isArchitectureQuestion(q)) {
|
|
143
161
|
try {
|
|
144
162
|
const graph = await buildIntelligenceGraph({ root, mode: "auto" });
|
|
145
163
|
sections.push(`[INFERRED] Graph: ${graph.nodes.length} nodes, ${graph.edges.length} edges (${graph.builder})`);
|
|
@@ -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/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.2";
|
|
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.2";
|
|
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([
|
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