@praneeth_54/agentdoctor 0.1.1-beta → 0.1.3-beta

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.
@@ -4,6 +4,9 @@ function hasDep(deps, name) {
4
4
  function hasFile(paths, ...names) {
5
5
  return names.some((name) => paths.has(name.toLowerCase()));
6
6
  }
7
+ function hasPythonDep(names, dep) {
8
+ return Boolean(names?.includes(dep.toLowerCase()));
9
+ }
7
10
  /**
8
11
  * Detect frameworks from config markers and dependency names.
9
12
  * Does not fail when nothing matches — returns unknown.
@@ -18,6 +21,7 @@ export function detectFrameworks(input) {
18
21
  ...(input.packageJsonDependencies ?? {}),
19
22
  };
20
23
  const composer = input.composerJsonRequire ?? {};
24
+ const pythonDeps = input.pythonDependencyNames ?? [];
21
25
  const detected = [];
22
26
  const isNext = hasFile(basenames, "next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs") ||
23
27
  hasDep(deps, "next");
@@ -63,15 +67,12 @@ export function detectFrameworks(input) {
63
67
  !isVue &&
64
68
  !isSvelte &&
65
69
  !isReact &&
66
- (hasFile(basenames, "package.json") || hasDep(deps, "express") === false) &&
67
70
  hasFile(basenames, "package.json")) {
68
- // Generic Node.js project with package.json but no web framework
69
71
  if (!detected.includes("nodejs")) {
70
72
  detected.push("nodejs");
71
73
  }
72
74
  }
73
75
  if (hasFile(basenames, "pubspec.yaml") || input.pubspecName !== undefined) {
74
- // Flutter: pubspec presence implies Flutter/Dart project context for detection
75
76
  if (hasFile(pathSet, "pubspec.yaml") || basenames.has("pubspec.yaml")) {
76
77
  detected.push("flutter");
77
78
  }
@@ -79,17 +80,19 @@ export function detectFrameworks(input) {
79
80
  if (hasDep(composer, "laravel/framework") || hasFile(basenames, "artisan")) {
80
81
  detected.push("laravel");
81
82
  }
82
- if (hasFile(basenames, "manage.py") ||
83
- [...pathSet].some((p) => p.endsWith("/settings.py") || p === "settings.py")) {
83
+ const isDjango = hasFile(basenames, "manage.py") ||
84
+ hasPythonDep(pythonDeps, "django") ||
85
+ [...pathSet].some((p) => p.endsWith("/requirements-django.txt"));
86
+ if (isDjango) {
84
87
  detected.push("django");
85
88
  }
86
- // FastAPI: path heuristics only (requirements/pyproject often lack a stable marker)
87
- if ([...pathSet].some((p) => p.includes("fastapi") || p.endsWith("requirements-fastapi.txt") || p === "app/main.py")) {
89
+ const hasFastApiDep = hasPythonDep(pythonDeps, "fastapi") ||
90
+ [...pathSet].some((p) => p.includes("fastapi") || p.endsWith("requirements-fastapi.txt"));
91
+ const hasFastApiAppLayout = [...pathSet].some((p) => p === "app/main.py" || p.endsWith("/app/main.py"));
92
+ if (hasFastApiDep && hasFastApiAppLayout) {
88
93
  detected.push("fastapi");
89
94
  }
90
- // Deduplicate while preserving order
91
95
  const unique = [...new Set(detected)];
92
- // Priority for primary framework
93
96
  const priority = [
94
97
  "nextjs",
95
98
  "nuxt",
@@ -146,3 +149,10 @@ export function formatFramework(framework) {
146
149
  return "Unknown";
147
150
  }
148
151
  }
152
+ export function formatFrameworks(ids) {
153
+ const unique = [...new Set(ids)].filter((id) => id !== "unknown");
154
+ if (unique.length === 0) {
155
+ return "Unknown";
156
+ }
157
+ return unique.map((id) => formatFramework(id)).join(", ");
158
+ }
@@ -12,3 +12,4 @@ export interface LanguageDetectionResult {
12
12
  */
13
13
  export declare function detectLanguages(input: LanguageDetectionInput): LanguageDetectionResult;
14
14
  export declare function formatLanguage(language: LanguageId): string;
15
+ export declare function formatLanguages(ids: LanguageId[]): string;
@@ -120,3 +120,10 @@ export function formatLanguage(language) {
120
120
  return "Unknown";
121
121
  }
122
122
  }
123
+ export function formatLanguages(ids) {
124
+ const unique = [...new Set(ids)].filter((id) => id !== "unknown");
125
+ if (unique.length === 0) {
126
+ return "Unknown";
127
+ }
128
+ return unique.map((id) => formatLanguage(id)).join(", ");
129
+ }
@@ -7,7 +7,8 @@ export interface MonorepoDetectionResult {
7
7
  monorepo: MonorepoToolId;
8
8
  }
9
9
  /**
10
- * Detect common monorepo tooling from config markers.
10
+ * Detect common monorepo tooling from config markers, or multi-project layouts
11
+ * with multiple independently buildable manifests.
11
12
  */
12
13
  export declare function detectMonorepo(input: MonorepoDetectionInput): MonorepoDetectionResult;
13
14
  export declare function formatMonorepo(id: MonorepoToolId): string;
@@ -1,5 +1,15 @@
1
+ const PROJECT_MANIFESTS = new Set([
2
+ "package.json",
3
+ "pubspec.yaml",
4
+ "composer.json",
5
+ "cargo.toml",
6
+ "go.mod",
7
+ "pyproject.toml",
8
+ "gemfile",
9
+ ]);
1
10
  /**
2
- * Detect common monorepo tooling from config markers.
11
+ * Detect common monorepo tooling from config markers, or multi-project layouts
12
+ * with multiple independently buildable manifests.
3
13
  */
4
14
  export function detectMonorepo(input) {
5
15
  const basenames = new Set(input.relativePaths.map((p) => {
@@ -26,6 +36,25 @@ export function detectMonorepo(input) {
26
36
  return { monorepo: "npm-workspaces" };
27
37
  }
28
38
  }
39
+ const projectRoots = new Set();
40
+ for (const relativePath of input.relativePaths) {
41
+ const parts = relativePath.split("/");
42
+ const base = (parts[parts.length - 1] ?? relativePath).toLowerCase();
43
+ if (!PROJECT_MANIFESTS.has(base)) {
44
+ continue;
45
+ }
46
+ if (parts.length === 1) {
47
+ projectRoots.add(".");
48
+ continue;
49
+ }
50
+ // Count only first-level project directories (e.g. mobile-apps/pubspec.yaml)
51
+ if (parts.length === 2) {
52
+ projectRoots.add(parts[0] ?? relativePath);
53
+ }
54
+ }
55
+ if (projectRoots.size >= 2) {
56
+ return { monorepo: "multi-project" };
57
+ }
29
58
  return { monorepo: "none" };
30
59
  }
31
60
  export function formatMonorepo(id) {
@@ -38,6 +67,8 @@ export function formatMonorepo(id) {
38
67
  return "Turborepo";
39
68
  case "nx":
40
69
  return "Nx";
70
+ case "multi-project":
71
+ return "Multi-project";
41
72
  default:
42
73
  return "None";
43
74
  }
@@ -1,6 +1,10 @@
1
1
  import type { PackageManagerId } from "../types/index.js";
2
2
  export interface PackageManagerDetectionInput {
3
3
  relativePaths: string[];
4
+ /** True when a pyproject.toml contains a [tool.poetry] table. */
5
+ poetryToolDetected?: boolean;
6
+ /** True when a non-Poetry pyproject.toml (or similar) indicates a Python project. */
7
+ genericPyprojectDetected?: boolean;
4
8
  }
5
9
  export interface PackageManagerDetectionResult {
6
10
  packageManagers: PackageManagerId[];
@@ -8,7 +12,9 @@ export interface PackageManagerDetectionResult {
8
12
  }
9
13
  /**
10
14
  * Detect package managers from lockfiles and manifests.
11
- * Lockfiles take precedence over generic manifests for primary selection.
15
+ * Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
16
+ * Poetry requires poetry.lock and/or [tool.poetry] — not bare pyproject.toml.
12
17
  */
13
18
  export declare function detectPackageManagers(input: PackageManagerDetectionInput): PackageManagerDetectionResult;
14
19
  export declare function formatPackageManager(id: PackageManagerId): string;
20
+ export declare function formatPackageManagers(ids: PackageManagerId[]): string;
@@ -1,32 +1,121 @@
1
1
  const MARKERS = [
2
- { id: "pnpm", files: ["pnpm-lock.yaml", "pnpm-workspace.yaml"] },
3
- { id: "yarn", files: ["yarn.lock", ".yarnrc.yml"] },
4
- { id: "bun", files: ["bun.lockb", "bun.lock"] },
5
- { id: "npm", files: ["package-lock.json", "npm-shrinkwrap.json"] },
6
- { id: "composer", files: ["composer.lock", "composer.json"] },
7
- { id: "poetry", files: ["poetry.lock", "pyproject.toml"] },
8
- { id: "pip", files: ["requirements.txt", "Pipfile", "Pipfile.lock"] },
9
- { id: "cargo", files: ["Cargo.lock", "Cargo.toml"] },
10
- { id: "gradle", files: ["gradlew", "build.gradle", "build.gradle.kts", "settings.gradle"] },
2
+ { id: "pnpm", files: ["pnpm-lock.yaml", "pnpm-workspace.yaml"], lockfiles: ["pnpm-lock.yaml"] },
3
+ { id: "yarn", files: ["yarn.lock", ".yarnrc.yml"], lockfiles: ["yarn.lock"] },
4
+ { id: "bun", files: ["bun.lockb", "bun.lock"], lockfiles: ["bun.lockb", "bun.lock"] },
5
+ {
6
+ id: "npm",
7
+ files: ["package-lock.json", "npm-shrinkwrap.json", "package.json"],
8
+ lockfiles: ["package-lock.json", "npm-shrinkwrap.json"],
9
+ },
10
+ { id: "composer", files: ["composer.lock", "composer.json"], lockfiles: ["composer.lock"] },
11
+ { id: "pub", files: ["pubspec.yaml", "pubspec.lock"], lockfiles: ["pubspec.lock"] },
12
+ { id: "poetry", files: ["poetry.lock"], lockfiles: ["poetry.lock"] },
13
+ {
14
+ id: "pip",
15
+ files: ["requirements.txt", "Pipfile", "Pipfile.lock"],
16
+ lockfiles: ["Pipfile.lock"],
17
+ },
18
+ { id: "cargo", files: ["Cargo.lock", "Cargo.toml"], lockfiles: ["Cargo.lock"] },
19
+ {
20
+ id: "gradle",
21
+ files: ["gradlew", "build.gradle", "build.gradle.kts", "settings.gradle"],
22
+ lockfiles: [],
23
+ },
11
24
  ];
25
+ const NODE_IDS = new Set(["npm", "pnpm", "yarn", "bun"]);
26
+ function basename(relativePath) {
27
+ const parts = relativePath.split("/");
28
+ return (parts[parts.length - 1] ?? relativePath).toLowerCase();
29
+ }
30
+ function isRootPath(relativePath) {
31
+ return !relativePath.includes("/");
32
+ }
12
33
  /**
13
34
  * Detect package managers from lockfiles and manifests.
14
- * Lockfiles take precedence over generic manifests for primary selection.
35
+ * Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
36
+ * Poetry requires poetry.lock and/or [tool.poetry] — not bare pyproject.toml.
15
37
  */
16
38
  export function detectPackageManagers(input) {
17
- const basenames = new Set(input.relativePaths.map((p) => {
18
- const parts = p.split("/");
19
- return (parts[parts.length - 1] ?? p).toLowerCase();
20
- }));
39
+ const paths = input.relativePaths;
40
+ const basenames = new Set(paths.map(basename));
41
+ const rootBasenames = new Set(paths.filter(isRootPath).map(basename));
21
42
  const found = [];
43
+ const rootFound = [];
44
+ const lockFound = [];
45
+ const rootLockFound = [];
22
46
  for (const marker of MARKERS) {
23
- if (marker.files.some((f) => basenames.has(f.toLowerCase()))) {
24
- found.push(marker.id);
47
+ if (marker.id === "npm") {
48
+ const hasNpmLock = marker.lockfiles.some((f) => basenames.has(f.toLowerCase()));
49
+ const hasRootPackage = rootBasenames.has("package.json");
50
+ const hasAnyPackage = paths.some((p) => basename(p) === "package.json");
51
+ if (!hasNpmLock && !hasAnyPackage) {
52
+ continue;
53
+ }
54
+ found.push("npm");
55
+ if (hasRootPackage || hasNpmLock) {
56
+ if (hasRootPackage) {
57
+ rootFound.push("npm");
58
+ }
59
+ if (hasNpmLock) {
60
+ lockFound.push("npm");
61
+ }
62
+ if (marker.lockfiles.some((f) => rootBasenames.has(f.toLowerCase()))) {
63
+ rootLockFound.push("npm");
64
+ }
65
+ }
66
+ continue;
67
+ }
68
+ if (marker.id === "poetry") {
69
+ const hasLock = basenames.has("poetry.lock");
70
+ const hasTool = input.poetryToolDetected === true;
71
+ if (!hasLock && !hasTool) {
72
+ continue;
73
+ }
74
+ found.push("poetry");
75
+ if (hasLock || (hasTool && rootBasenames.has("pyproject.toml"))) {
76
+ rootFound.push("poetry");
77
+ }
78
+ if (hasLock) {
79
+ lockFound.push("poetry");
80
+ if (rootBasenames.has("poetry.lock")) {
81
+ rootLockFound.push("poetry");
82
+ }
83
+ }
84
+ continue;
85
+ }
86
+ if (marker.id === "pip") {
87
+ const hasMarker = marker.files.some((f) => basenames.has(f.toLowerCase()));
88
+ const hasGenericPyproject = input.genericPyprojectDetected === true;
89
+ if (!hasMarker && !hasGenericPyproject) {
90
+ continue;
91
+ }
92
+ found.push("pip");
93
+ if (marker.files.some((f) => rootBasenames.has(f.toLowerCase())) ||
94
+ (hasGenericPyproject && rootBasenames.has("pyproject.toml"))) {
95
+ rootFound.push("pip");
96
+ }
97
+ if (marker.lockfiles.some((f) => basenames.has(f.toLowerCase()))) {
98
+ lockFound.push("pip");
99
+ }
100
+ if (marker.lockfiles.some((f) => rootBasenames.has(f.toLowerCase()))) {
101
+ rootLockFound.push("pip");
102
+ }
103
+ continue;
104
+ }
105
+ const hasAny = marker.files.some((f) => basenames.has(f.toLowerCase()));
106
+ if (!hasAny) {
107
+ continue;
108
+ }
109
+ found.push(marker.id);
110
+ if (marker.files.some((f) => rootBasenames.has(f.toLowerCase()))) {
111
+ rootFound.push(marker.id);
112
+ }
113
+ if (marker.lockfiles.some((f) => basenames.has(f.toLowerCase()))) {
114
+ lockFound.push(marker.id);
115
+ }
116
+ if (marker.lockfiles.some((f) => rootBasenames.has(f.toLowerCase()))) {
117
+ rootLockFound.push(marker.id);
25
118
  }
26
- }
27
- if (basenames.has("package.json") &&
28
- !found.some((id) => ["npm", "pnpm", "yarn", "bun"].includes(id))) {
29
- found.push("npm");
30
119
  }
31
120
  const unique = [...new Set(found)];
32
121
  const lockPriority = [
@@ -34,19 +123,43 @@ export function detectPackageManagers(input) {
34
123
  "yarn",
35
124
  "bun",
36
125
  "npm",
37
- "poetry",
38
126
  "composer",
127
+ "pub",
128
+ "poetry",
39
129
  "cargo",
40
130
  "gradle",
41
131
  "pip",
42
132
  ];
43
133
  let primary = "unknown";
44
134
  for (const id of lockPriority) {
45
- if (unique.includes(id)) {
135
+ if (rootLockFound.includes(id)) {
46
136
  primary = id;
47
137
  break;
48
138
  }
49
139
  }
140
+ if (primary === "unknown") {
141
+ for (const id of lockPriority) {
142
+ if (rootFound.includes(id)) {
143
+ primary = id;
144
+ break;
145
+ }
146
+ }
147
+ }
148
+ if (primary === "unknown") {
149
+ const ecosystems = new Set(unique.map((id) => (NODE_IDS.has(id) ? "node" : id === "unknown" ? "unknown" : id)));
150
+ ecosystems.delete("unknown");
151
+ if (ecosystems.size > 1) {
152
+ primary = "unknown";
153
+ }
154
+ else {
155
+ for (const id of lockPriority) {
156
+ if (lockFound.includes(id) || unique.includes(id)) {
157
+ primary = id;
158
+ break;
159
+ }
160
+ }
161
+ }
162
+ }
50
163
  return {
51
164
  packageManagers: unique.length > 0 ? unique : ["unknown"],
52
165
  primaryPackageManager: primary,
@@ -64,6 +177,8 @@ export function formatPackageManager(id) {
64
177
  return "Bun";
65
178
  case "composer":
66
179
  return "Composer";
180
+ case "pub":
181
+ return "Pub";
67
182
  case "pip":
68
183
  return "pip";
69
184
  case "poetry":
@@ -76,3 +191,13 @@ export function formatPackageManager(id) {
76
191
  return "Unknown";
77
192
  }
78
193
  }
194
+ export function formatPackageManagers(ids) {
195
+ const unique = [...new Set(ids)];
196
+ if (unique.length === 0 || (unique.length === 1 && unique[0] === "unknown")) {
197
+ return "Unknown";
198
+ }
199
+ return unique
200
+ .filter((id) => id !== "unknown")
201
+ .map((id) => formatPackageManager(id))
202
+ .join(", ");
203
+ }
@@ -1,12 +1,17 @@
1
1
  import path from "node:path";
2
- import { DEFAULT_MAX_FILE_SIZE_BYTES } from "../constants.js";
2
+ import { DEFAULT_IGNORE_DIRECTORIES, DEFAULT_MAX_FILE_SIZE_BYTES } from "../constants.js";
3
3
  import { discoverFiles } from "../discovery/files.js";
4
4
  import { detectFrameworks } from "./framework.js";
5
5
  import { detectLanguages } from "./language.js";
6
6
  import { detectMonorepo } from "./monorepo.js";
7
7
  import { detectPackageManagers } from "./package-manager.js";
8
- import { isDirectory, readJsonFile } from "../utils/fs.js";
8
+ import { extractPythonDependencyNames, pyprojectHasPoetryTool } from "./python-deps.js";
9
+ import { isDirectory, readJsonFile, readTextFile } from "../utils/fs.js";
9
10
  import { resolveRepoRoot } from "../utils/path.js";
11
+ function basename(relativePath) {
12
+ const parts = relativePath.split("/");
13
+ return parts[parts.length - 1] ?? relativePath;
14
+ }
10
15
  /**
11
16
  * Discover files and detect repository characteristics.
12
17
  */
@@ -19,31 +24,86 @@ export async function detectProject(cwd, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZ
19
24
  const discovery = await discoverFiles({ root, maxFileSizeBytes });
20
25
  const relativePaths = discovery.files.map((f) => f.relativePath);
21
26
  for (const err of discovery.permissionErrors) {
27
+ const relative = err.split(":")[0] ?? err;
28
+ const underIgnored = relative
29
+ .split("/")
30
+ .some((segment) => segment.length > 0 && DEFAULT_IGNORE_DIRECTORIES.has(segment));
31
+ if (underIgnored) {
32
+ continue;
33
+ }
22
34
  diagnostics.push(`Permission/read issue: ${err}`);
23
35
  }
24
- let packageJson;
25
- const packageJsonPath = path.join(root, "package.json");
26
- const packageResult = await readJsonFile(packageJsonPath, maxFileSizeBytes);
27
- if (packageResult.ok) {
28
- packageJson = packageResult.data;
36
+ const mergedPackageDeps = {};
37
+ let rootWorkspaces;
38
+ const packageJsonPaths = relativePaths.filter((p) => basename(p).toLowerCase() === "package.json");
39
+ for (const relative of packageJsonPaths) {
40
+ const absolute = path.join(root, relative);
41
+ const packageResult = await readJsonFile(absolute, maxFileSizeBytes);
42
+ if (!packageResult.ok) {
43
+ if (relative === "package.json") {
44
+ diagnostics.push(`Malformed package.json: ${packageResult.error}`);
45
+ }
46
+ continue;
47
+ }
48
+ Object.assign(mergedPackageDeps, packageResult.data.dependencies ?? {}, packageResult.data.devDependencies ?? {}, packageResult.data.peerDependencies ?? {});
49
+ if (relative === "package.json") {
50
+ rootWorkspaces = packageResult.data.workspaces;
51
+ }
29
52
  }
30
- else if (relativePaths.some((p) => p === "package.json" || p.endsWith("/package.json"))) {
31
- diagnostics.push(`Malformed package.json: ${packageResult.error}`);
53
+ const composerRequire = {};
54
+ const composerPaths = relativePaths.filter((p) => basename(p).toLowerCase() === "composer.json");
55
+ for (const relative of composerPaths) {
56
+ const absolute = path.join(root, relative);
57
+ const result = await readJsonFile(absolute, maxFileSizeBytes);
58
+ if (!result.ok) {
59
+ continue;
60
+ }
61
+ Object.assign(composerRequire, result.data.require ?? {}, result.data["require-dev"] ?? {});
62
+ }
63
+ const pythonDependencyNames = new Set();
64
+ let poetryToolDetected = false;
65
+ let genericPyprojectDetected = false;
66
+ for (const relative of relativePaths) {
67
+ const base = basename(relative).toLowerCase();
68
+ if (base !== "pyproject.toml" &&
69
+ base !== "requirements.txt" &&
70
+ !/^requirements(-[a-z0-9_-]+)?\.txt$/i.test(base)) {
71
+ continue;
72
+ }
73
+ const text = await readTextFile(path.join(root, relative), maxFileSizeBytes);
74
+ if (!text) {
75
+ continue;
76
+ }
77
+ for (const name of extractPythonDependencyNames(text)) {
78
+ pythonDependencyNames.add(name);
79
+ }
80
+ if (base === "pyproject.toml") {
81
+ if (pyprojectHasPoetryTool(text)) {
82
+ poetryToolDetected = true;
83
+ }
84
+ else {
85
+ genericPyprojectDetected = true;
86
+ }
87
+ }
88
+ }
89
+ if (relativePaths.some((p) => basename(p).toLowerCase() === "pipfile")) {
90
+ genericPyprojectDetected = true;
32
91
  }
33
- const deps = {
34
- ...(packageJson?.dependencies ?? {}),
35
- ...(packageJson?.devDependencies ?? {}),
36
- ...(packageJson?.peerDependencies ?? {}),
37
- };
38
92
  const languages = detectLanguages({ relativePaths });
39
93
  const frameworks = detectFrameworks({
40
94
  relativePaths,
41
- packageJsonDependencies: deps,
95
+ packageJsonDependencies: mergedPackageDeps,
96
+ composerJsonRequire: composerRequire,
97
+ pythonDependencyNames: [...pythonDependencyNames],
98
+ });
99
+ const packageManagers = detectPackageManagers({
100
+ relativePaths,
101
+ poetryToolDetected,
102
+ genericPyprojectDetected,
42
103
  });
43
- const packageManagers = detectPackageManagers({ relativePaths });
44
104
  const monorepo = detectMonorepo({
45
105
  relativePaths,
46
- packageJsonWorkspaces: packageJson?.workspaces,
106
+ packageJsonWorkspaces: rootWorkspaces,
47
107
  });
48
108
  const repository = {
49
109
  root,
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Lightweight Python dependency name extraction from manifests.
3
+ * Filename/table semantics only — does not evaluate environments.
4
+ */
5
+ export declare function pyprojectHasPoetryTool(text: string): boolean;
6
+ export declare function extractPythonDependencyNames(text: string): string[];
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Lightweight Python dependency name extraction from manifests.
3
+ * Filename/table semantics only — does not evaluate environments.
4
+ */
5
+ const SKIP_KEYS = new Set([
6
+ "python",
7
+ "package-mode",
8
+ "packages",
9
+ "include",
10
+ "exclude",
11
+ "readme",
12
+ "homepage",
13
+ "repository",
14
+ "documentation",
15
+ "keywords",
16
+ "classifiers",
17
+ "license",
18
+ "authors",
19
+ "maintainers",
20
+ "dependencies",
21
+ "dev-dependencies",
22
+ "group",
23
+ ]);
24
+ export function pyprojectHasPoetryTool(text) {
25
+ return /\[tool\.poetry(?:\.|\])/m.test(text);
26
+ }
27
+ export function extractPythonDependencyNames(text) {
28
+ const names = new Set();
29
+ for (const match of text.matchAll(/^\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*=\s*["'{[]/gm)) {
30
+ const name = (match[1] ?? "").toLowerCase().replace(/_/g, "-");
31
+ if (name && !SKIP_KEYS.has(name) && !name.startsWith("tool.")) {
32
+ names.add(name);
33
+ }
34
+ }
35
+ for (const match of text.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?(?:\s*[><=! ~^,;]|["'])/g)) {
36
+ const name = (match[1] ?? "").toLowerCase().replace(/_/g, "-");
37
+ if (name && !SKIP_KEYS.has(name)) {
38
+ names.add(name);
39
+ }
40
+ }
41
+ for (const rawLine of text.split(/\r?\n/)) {
42
+ const line = rawLine.trim();
43
+ if (!line || line.startsWith("#") || line.startsWith("-")) {
44
+ continue;
45
+ }
46
+ const name = line
47
+ .split(/[><=![;@\s]/)[0]
48
+ ?.toLowerCase()
49
+ .replace(/_/g, "-");
50
+ if (name && /^[a-z0-9][a-z0-9._-]*$/.test(name) && !SKIP_KEYS.has(name)) {
51
+ names.add(name);
52
+ }
53
+ }
54
+ return [...names];
55
+ }
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  export { scan } from "./core/scanner/scan.js";
2
2
  export { PACKAGE_VERSION } from "./constants.js";
3
- export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, CliOptions, ExitCode, } from "./types/index.js";
3
+ export type { ScanOptions, ScanResult, RepositoryInfo, Finding, FindingsSummary, Scores, AgentPresence, AgentDetectionResult, AgentSecurityAnalysisMode, CliOptions, ExitCode, } from "./types/index.js";
4
4
  export { EXIT_CODES } from "./types/index.js";
5
5
  export { agentRegistry } from "./agents/registry.js";
6
6
  export { detectAgents } from "./agents/detect-agents.js";
@@ -74,6 +74,7 @@ export function renderJsonReport(result) {
74
74
  summary: result.summary,
75
75
  scoringAvailable: result.scoringAvailable,
76
76
  scores: result.scores,
77
+ agentSecurityAnalysis: result.agentSecurityAnalysis,
77
78
  timing: result.timing,
78
79
  diagnostics: {
79
80
  warnings: result.diagnostics.warnings.map(sanitizeForOutput),
@@ -1,12 +1,13 @@
1
1
  import { PACKAGE_VERSION } from "../../constants.js";
2
2
  import { AGENT_DISPLAY_NAMES } from "../../constants.js";
3
- import { formatFramework } from "../../detectors/framework.js";
4
- import { formatLanguage } from "../../detectors/language.js";
3
+ import { formatFramework, formatFrameworks } from "../../detectors/framework.js";
4
+ import { formatLanguage, formatLanguages } from "../../detectors/language.js";
5
5
  import { formatMonorepo } from "../../detectors/monorepo.js";
6
- import { formatPackageManager } from "../../detectors/package-manager.js";
6
+ import { formatPackageManager, formatPackageManagers } from "../../detectors/package-manager.js";
7
7
  import { colors, symbolFail, symbolInfo, symbolOk, symbolWarn } from "../../utils/colors.js";
8
8
  import { sanitizeTerminalText } from "../../security/redaction.js";
9
9
  const DEFAULT_FINDING_LIMIT = 12;
10
+ const LIMITED_ANALYSIS_NOTE = "No supported coding-agent configuration detected; agent-specific security exposure checks are limited.";
10
11
  function groupFindings(findings) {
11
12
  return {
12
13
  critical: findings.filter((f) => f.severity === "critical"),
@@ -26,6 +27,14 @@ function agentGlyph(configured, detected) {
26
27
  function formatAgents(agents) {
27
28
  return agents.map((id) => AGENT_DISPLAY_NAMES[id] ?? id).join(", ");
28
29
  }
30
+ function isMultiStack(result) {
31
+ if (result.repository.monorepo === "multi-project") {
32
+ return true;
33
+ }
34
+ const frameworks = result.repository.frameworks.filter((id) => id !== "unknown");
35
+ const managers = result.repository.packageManagers.filter((id) => id !== "unknown");
36
+ return frameworks.length > 1 || managers.length > 1;
37
+ }
29
38
  function renderFindingBlock(finding, verbose) {
30
39
  const lines = [];
31
40
  const icon = finding.severity === "critical"
@@ -62,9 +71,20 @@ export function renderTerminalReport(result, options = {}) {
62
71
  lines.push(colors.dim("Scanning repository..."));
63
72
  lines.push("");
64
73
  lines.push(colors.bold("Repository"));
65
- lines.push(` Framework: ${sanitizeTerminalText(formatFramework(result.repository.primaryFramework))}`);
66
- lines.push(` Language: ${sanitizeTerminalText(formatLanguage(result.repository.primaryLanguage))}`);
67
- lines.push(` Package manager: ${sanitizeTerminalText(formatPackageManager(result.repository.primaryPackageManager))}`);
74
+ if (isMultiStack(result)) {
75
+ lines.push(` Languages: ${sanitizeTerminalText(formatLanguages(result.repository.languages))}`);
76
+ lines.push(` Frameworks: ${sanitizeTerminalText(formatFrameworks(result.repository.frameworks))}`);
77
+ lines.push(` Package managers: ${sanitizeTerminalText(formatPackageManagers(result.repository.packageManagers))}`);
78
+ }
79
+ else {
80
+ lines.push(` Framework: ${sanitizeTerminalText(formatFramework(result.repository.primaryFramework))}`);
81
+ lines.push(` Language: ${sanitizeTerminalText(formatLanguage(result.repository.primaryLanguage))}`);
82
+ const packageManagerLabel = result.repository.packageManagers.length > 1 ||
83
+ result.repository.primaryPackageManager === "unknown"
84
+ ? formatPackageManagers(result.repository.packageManagers)
85
+ : formatPackageManager(result.repository.primaryPackageManager);
86
+ lines.push(` Package manager: ${sanitizeTerminalText(packageManagerLabel)}`);
87
+ }
68
88
  if (result.repository.monorepo !== "none") {
69
89
  lines.push(` Monorepo: ${sanitizeTerminalText(formatMonorepo(result.repository.monorepo))}`);
70
90
  }
@@ -86,10 +106,20 @@ export function renderTerminalReport(result, options = {}) {
86
106
  lines.push("");
87
107
  const grouped = groupFindings(result.findings);
88
108
  const total = result.summary.total;
109
+ const limited = result.agentSecurityAnalysis === "limited";
89
110
  lines.push(colors.bold("Findings"));
90
111
  lines.push("");
112
+ if (limited) {
113
+ lines.push(` ${symbolWarn()} ${sanitizeTerminalText(LIMITED_ANALYSIS_NOTE)}`);
114
+ lines.push("");
115
+ }
91
116
  if (total === 0) {
92
- lines.push(` ${symbolOk()} No findings`);
117
+ if (limited) {
118
+ lines.push(` ${symbolOk()} No agent-configuration findings`);
119
+ }
120
+ else {
121
+ lines.push(` ${symbolOk()} No findings`);
122
+ }
93
123
  lines.push("");
94
124
  }
95
125
  else {