@praneeth_54/agentdoctor 0.1.1-beta → 0.1.2-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.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
13
13
  - Safe automatic fixes
14
14
  - Packaged GitHub Action
15
15
 
16
+ ## [0.1.2-beta] — 2026-07-29
17
+
18
+ Backward-compatible quality patch.
19
+
20
+ ### Fixed
21
+
22
+ - More reliable CLI/package binary execution (local bin shim + executable CLI entry)
23
+ - Nested `context/generated-directory` ignore handling and precise evidence paths
24
+ - Multi-project repository detection without requiring Node workspace tooling
25
+ - Mixed Composer / Pub / npm package-manager detection
26
+ - False malformed-package diagnostic when root `package.json` is absent
27
+ - Conservative credential-file detection for `.der` and service-account JSON names
28
+ - Quieter permission diagnostics under already-skipped directories
29
+
30
+ ### Testing
31
+
32
+ - Added real-world multi-app, generated-directory, credential, and CLI bin fixtures/tests
33
+ - Total test suite is now 104 tests
34
+
16
35
  ## [0.1.1-beta] — 2026-07-29
17
36
 
18
37
  Patch release.
@@ -62,6 +81,7 @@ First public beta.
62
81
  - Not a complete secret scanner
63
82
  - Git “tracked secret” detection deferred
64
83
 
65
- [Unreleased]: https://github.com/pranee54/AgentDoctor/compare/v0.1.1-beta...HEAD
84
+ [Unreleased]: https://github.com/pranee54/AgentDoctor/compare/v0.1.2-beta...HEAD
85
+ [0.1.2-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.2-beta
66
86
  [0.1.1-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.1-beta
67
87
  [0.1.0-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.0-beta
package/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  [![GitHub Stars](https://img.shields.io/github/stars/pranee54/AgentDoctor)](https://github.com/pranee54/AgentDoctor/stargazers)
6
6
  [![CI](https://img.shields.io/github/actions/workflow/status/pranee54/AgentDoctor/ci.yml?branch=main&label=CI)](https://github.com/pranee54/AgentDoctor/actions)
7
7
  [![Node](https://img.shields.io/node/v/@praneeth_54/agentdoctor)](https://nodejs.org)
8
- [![Release](https://img.shields.io/badge/release-v0.1.1--beta-orange)](CHANGELOG.md)
8
+ [![Release](https://img.shields.io/badge/release-v0.1.2--beta-orange)](CHANGELOG.md)
9
9
 
10
10
  **Lighthouse for AI coding agents.**
11
11
 
@@ -29,7 +29,7 @@ npx @praneeth_54/agentdoctor
29
29
  ```text
30
30
  $ npx @praneeth_54/agentdoctor
31
31
 
32
- AgentDoctor v0.1.1-beta
32
+ AgentDoctor v0.1.2-beta
33
33
 
34
34
  Repository
35
35
  Framework: Next.js
@@ -88,13 +88,13 @@ AgentDoctor analyzes configuration files. It does not run agents or edit your pr
88
88
  ### One-shot
89
89
 
90
90
  ```bash
91
- npx @praneeth_54/agentdoctor@0.1.1-beta
91
+ npx @praneeth_54/agentdoctor@0.1.2-beta
92
92
  ```
93
93
 
94
94
  ### Global (optional)
95
95
 
96
96
  ```bash
97
- npm install -g @praneeth_54/agentdoctor@0.1.1-beta
97
+ npm install -g @praneeth_54/agentdoctor@0.1.2-beta
98
98
  agentdoctor
99
99
  ```
100
100
 
@@ -225,7 +225,7 @@ npx @praneeth_54/agentdoctor --json
225
225
 
226
226
  ```json
227
227
  {
228
- "version": "0.1.1-beta",
228
+ "version": "0.1.2-beta",
229
229
  "repository": {
230
230
  "primaryFramework": "nextjs",
231
231
  "primaryLanguage": "typescript",
package/dist/cli/index.js CHANGED
File without changes
@@ -1,4 +1,4 @@
1
- export declare const PACKAGE_VERSION = "0.1.1-beta";
1
+ export declare const PACKAGE_VERSION = "0.1.2-beta";
2
2
  export declare const DEFAULT_MAX_FILE_SIZE_BYTES: number;
3
3
  /** Directories skipped during normal discovery (unless a rule needs them later). */
4
4
  export declare const DEFAULT_IGNORE_DIRECTORIES: Set<string>;
package/dist/constants.js CHANGED
@@ -1,4 +1,4 @@
1
- export const PACKAGE_VERSION = "0.1.1-beta";
1
+ export const PACKAGE_VERSION = "0.1.2-beta";
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([
@@ -1,13 +1,44 @@
1
1
  import { parseProjectMcpConfigs } from "../mcp/parse.js";
2
- import { createIgnoreIndex, parseIgnoreFile } from "./ignore.js";
2
+ import { createIgnoreIndex, parseIgnoreFile, relativizeIgnorePatterns } from "./ignore.js";
3
3
  import { TextCache } from "./text-cache.js";
4
4
  export async function buildRuleContext(options) {
5
5
  const textCache = new TextCache(options.root, options.maxFileSizeBytes);
6
- const gitignore = await textCache.read(".gitignore");
7
- const cursorignore = await textCache.read(".cursorignore");
6
+ const gitignorePatterns = [];
7
+ const cursorignorePatterns = [];
8
+ const rootGitignore = await textCache.read(".gitignore");
9
+ if (rootGitignore.text) {
10
+ gitignorePatterns.push(...parseIgnoreFile(rootGitignore.text));
11
+ }
12
+ const rootCursorignore = await textCache.read(".cursorignore");
13
+ if (rootCursorignore.text) {
14
+ cursorignorePatterns.push(...parseIgnoreFile(rootCursorignore.text));
15
+ }
16
+ for (const file of options.discovery.files) {
17
+ const base = file.relativePath.split("/").pop() ?? file.relativePath;
18
+ if (base !== ".gitignore" && base !== ".cursorignore") {
19
+ continue;
20
+ }
21
+ if (file.relativePath === ".gitignore" || file.relativePath === ".cursorignore") {
22
+ continue;
23
+ }
24
+ const cached = await textCache.read(file.relativePath);
25
+ if (!cached.text) {
26
+ continue;
27
+ }
28
+ const dir = file.relativePath.includes("/")
29
+ ? file.relativePath.slice(0, file.relativePath.lastIndexOf("/"))
30
+ : "";
31
+ const patterns = relativizeIgnorePatterns(dir, parseIgnoreFile(cached.text));
32
+ if (base === ".gitignore") {
33
+ gitignorePatterns.push(...patterns);
34
+ }
35
+ else {
36
+ cursorignorePatterns.push(...patterns);
37
+ }
38
+ }
8
39
  const ignore = createIgnoreIndex({
9
- gitignorePatterns: gitignore.text ? parseIgnoreFile(gitignore.text) : [],
10
- cursorignorePatterns: cursorignore.text ? parseIgnoreFile(cursorignore.text) : [],
40
+ gitignorePatterns,
41
+ cursorignorePatterns,
11
42
  });
12
43
  const mcp = await parseProjectMcpConfigs(options.root, options.maxFileSizeBytes);
13
44
  const instructionPaths = new Set();
@@ -9,6 +9,13 @@ const GENERATED = [
9
9
  { name: ".dart_tool", label: "Dart tooling cache" },
10
10
  { name: "target", label: "Rust/Java build output" },
11
11
  ];
12
+ function isGeneratedPath(relativePath, name) {
13
+ return relativePath === name || relativePath.endsWith(`/${name}`);
14
+ }
15
+ function isExcluded(context, relativePath) {
16
+ const variants = [relativePath, `${relativePath}/`];
17
+ return variants.some((candidate) => context.ignore.matchesGitignore(candidate) || context.ignore.matchesCursorignore(candidate));
18
+ }
12
19
  export const generatedDirectoryRule = {
13
20
  id: "context/generated-directory",
14
21
  title: "Generated directory may enter agent context",
@@ -20,66 +27,49 @@ export const generatedDirectoryRule = {
20
27
  recommendation: "Ensure generated directories are listed in .gitignore and agent ignore configuration where applicable.",
21
28
  async check(context) {
22
29
  const findings = [];
23
- const skipped = new Set(context.discovery.directoriesSkipped.map((d) => d.split("/")[0] ?? d));
24
- // directoriesSkipped stores relative paths like "dist" or "packages/app/dist"
25
- const present = new Set();
26
- for (const dir of context.discovery.directoriesSkipped) {
27
- const top = dir.split("/")[0] ?? dir;
28
- present.add(top);
29
- present.add(dir);
30
- }
31
- void skipped;
32
30
  const affected = context.agents.filter((a) => a.configured || a.detected).map((a) => a.id);
33
31
  if (affected.length === 0) {
34
32
  return [];
35
33
  }
36
34
  for (const entry of GENERATED) {
37
- const found = present.has(entry.name) ||
38
- [...present].some((p) => p === entry.name || p.endsWith(`/${entry.name}`));
39
- if (!found) {
40
- continue;
41
- }
42
- // If .gitignore or .cursorignore already exclude it, skip (already appropriately excluded)
43
- const samplePath = `${entry.name}/`;
44
- const excluded = context.ignore.matchesGitignore(samplePath) ||
45
- context.ignore.matchesGitignore(entry.name) ||
46
- context.ignore.matchesCursorignore(samplePath) ||
47
- context.ignore.matchesCursorignore(entry.name);
48
- if (excluded) {
49
- continue;
35
+ const matches = context.discovery.directoriesSkipped.filter((dir) => isGeneratedPath(dir, entry.name));
36
+ for (const relativePath of matches) {
37
+ if (isExcluded(context, relativePath)) {
38
+ continue;
39
+ }
40
+ findings.push({
41
+ ruleId: "context/generated-directory",
42
+ category: "context",
43
+ severity: "info",
44
+ title: "Generated directory may enter agent context",
45
+ message: `${relativePath}/ (${entry.label}) is present and no project ignore pattern was detected`,
46
+ whyItMatters: "Generated directories are usually low-value for coding agents and can bloat indexing/context when not excluded.",
47
+ recommendation: `Add an ignore pattern covering ${relativePath}/ to .gitignore and, for Cursor, .cursorignore if you need agent-specific exclusion beyond gitignore.`,
48
+ affectedAgents: affected,
49
+ evidence: { path: relativePath },
50
+ fixability: "safe",
51
+ });
50
52
  }
51
- // Discovery already skips these dirs by default — absence from ignore files is still useful info
52
- // when agents are configured. Keep severity info.
53
- findings.push({
54
- ruleId: "context/generated-directory",
55
- category: "context",
56
- severity: "info",
57
- title: "Generated directory may enter agent context",
58
- message: `${entry.name}/ (${entry.label}) is present and no project ignore pattern was detected`,
59
- whyItMatters: "Generated directories are usually low-value for coding agents and can bloat indexing/context when not excluded.",
60
- recommendation: `Add ${entry.name}/ to .gitignore and, for Cursor, .cursorignore if you need agent-specific exclusion beyond gitignore.`,
61
- affectedAgents: affected,
62
- evidence: { path: entry.name },
63
- fixability: "safe",
64
- });
65
53
  }
66
- // vendor/ — only flag for non-PHP/Composer ecosystems
67
- if ((present.has("vendor") ||
68
- [...present].some((p) => p === "vendor" || p.endsWith("/vendor"))) &&
54
+ const vendorPaths = context.discovery.directoriesSkipped.filter((dir) => isGeneratedPath(dir, "vendor"));
55
+ if (vendorPaths.length > 0 &&
69
56
  context.repository.primaryPackageManager !== "composer" &&
57
+ !context.repository.packageManagers.includes("composer") &&
70
58
  context.repository.primaryLanguage !== "php") {
71
- const excluded = context.ignore.matchesGitignore("vendor/") || context.ignore.matchesCursorignore("vendor/");
72
- if (!excluded) {
59
+ for (const relativePath of vendorPaths) {
60
+ if (isExcluded(context, relativePath)) {
61
+ continue;
62
+ }
73
63
  findings.push({
74
64
  ruleId: "context/generated-directory",
75
65
  category: "context",
76
66
  severity: "info",
77
67
  title: "Generated directory may enter agent context",
78
- message: "vendor/ is present outside a PHP/Composer project and no ignore pattern was detected",
68
+ message: `${relativePath}/ is present outside a PHP/Composer project and no ignore pattern was detected`,
79
69
  whyItMatters: "In non-PHP projects, vendor/ is often third-party or generated content that adds noise to agent context.",
80
70
  recommendation: "Confirm whether vendor/ should be ignored for git and AI tooling.",
81
71
  affectedAgents: affected,
82
- evidence: { path: "vendor" },
72
+ evidence: { path: relativePath },
83
73
  fixability: "safe",
84
74
  });
85
75
  }
@@ -11,6 +11,11 @@ export interface IgnoreIndex {
11
11
  isExcludedForCursor(relativePath: string): boolean;
12
12
  }
13
13
  export declare function parseIgnoreFile(content: string): string[];
14
+ /**
15
+ * Rewrite patterns from a nested ignore file so they apply relative to repository root.
16
+ * `baseDir` is the directory containing the ignore file (POSIX, no trailing slash), or "".
17
+ */
18
+ export declare function relativizeIgnorePatterns(baseDir: string, patterns: string[]): string[];
14
19
  /**
15
20
  * Match a relative POSIX path against a single gitignore-like pattern.
16
21
  * Negation (`!`) is not fully supported for re-inclusion of parent-excluded paths.
@@ -15,6 +15,30 @@ export function parseIgnoreFile(content) {
15
15
  .map(normalizePattern)
16
16
  .filter((p) => p !== null);
17
17
  }
18
+ /**
19
+ * Rewrite patterns from a nested ignore file so they apply relative to repository root.
20
+ * `baseDir` is the directory containing the ignore file (POSIX, no trailing slash), or "".
21
+ */
22
+ export function relativizeIgnorePatterns(baseDir, patterns) {
23
+ if (!baseDir || baseDir === ".") {
24
+ return patterns;
25
+ }
26
+ return patterns.map((pattern) => {
27
+ const negated = pattern.startsWith("!");
28
+ const body = negated ? pattern.slice(1) : pattern;
29
+ let rewritten;
30
+ if (body.startsWith("/")) {
31
+ rewritten = `${baseDir}${body}`;
32
+ }
33
+ else if (body.startsWith("**/")) {
34
+ rewritten = `${baseDir}/${body.slice(3)}`;
35
+ }
36
+ else {
37
+ rewritten = `${baseDir}/${body}`;
38
+ }
39
+ return negated ? `!${rewritten}` : rewritten;
40
+ });
41
+ }
18
42
  /**
19
43
  * Match a relative POSIX path against a single gitignore-like pattern.
20
44
  * Negation (`!`) is not fully supported for re-inclusion of parent-excluded paths.
@@ -24,25 +48,44 @@ export function matchIgnorePattern(relativePath, pattern) {
24
48
  if (pat.startsWith("!")) {
25
49
  pat = pat.slice(1);
26
50
  }
27
- const pathNorm = relativePath.replace(/^\/+/, "");
51
+ const pathNorm = relativePath.replace(/^\/+/, "").replace(/\/+$/, "");
28
52
  let matched = false;
29
53
  if (pat.endsWith("/")) {
30
54
  const dir = pat.slice(0, -1);
31
- matched =
32
- pathNorm === dir ||
33
- pathNorm.startsWith(`${dir}/`) ||
34
- globMatch(pathNorm, dir) ||
35
- pathNorm.split("/").some((_, i, parts) => globMatch(parts.slice(0, i + 1).join("/"), dir));
55
+ if (!dir.includes("/")) {
56
+ // `build/` matches build at any directory level (gitignore semantics)
57
+ matched =
58
+ pathNorm === dir ||
59
+ pathNorm.endsWith(`/${dir}`) ||
60
+ pathNorm.split("/").includes(dir) ||
61
+ globMatch(pathNorm, `**/${dir}`) ||
62
+ globMatch(pathNorm, `**/${dir}/**`);
63
+ }
64
+ else {
65
+ matched =
66
+ pathNorm === dir ||
67
+ pathNorm.startsWith(`${dir}/`) ||
68
+ globMatch(pathNorm, dir) ||
69
+ globMatch(pathNorm, `${dir}/**`);
70
+ }
36
71
  }
37
72
  else if (pat.startsWith("/")) {
38
73
  matched = globMatch(pathNorm, pat.slice(1));
39
74
  }
40
75
  else if (pat.includes("/")) {
41
- matched = globMatch(pathNorm, pat) || pathNorm.endsWith(`/${pat}`);
76
+ matched =
77
+ globMatch(pathNorm, pat) ||
78
+ globMatch(pathNorm, `${pat}/**`) ||
79
+ pathNorm.endsWith(`/${pat}`) ||
80
+ pathNorm.startsWith(`${pat}/`);
42
81
  }
43
82
  else {
44
83
  const base = pathNorm.split("/").pop() ?? pathNorm;
45
- matched = globMatch(base, pat) || globMatch(pathNorm, pat) || globMatch(pathNorm, `**/${pat}`);
84
+ matched =
85
+ globMatch(base, pat) ||
86
+ globMatch(pathNorm, pat) ||
87
+ globMatch(pathNorm, `**/${pat}`) ||
88
+ pathNorm.split("/").includes(pat);
46
89
  }
47
90
  return matched;
48
91
  }
@@ -90,7 +133,6 @@ export function createIgnoreIndex(options) {
90
133
  if (pathMatchesAny(relativePath, gitignorePatterns)) {
91
134
  return true;
92
135
  }
93
- // Documented default ignore list includes .env*
94
136
  return pathMatchesAny(relativePath, CURSOR_DEFAULT_ENV_PATTERNS);
95
137
  },
96
138
  };
@@ -6,10 +6,14 @@ const PRIVATE_KEY_PATTERNS = [
6
6
  test: (b) => /\.pem$/i.test(b) && !/cert|certificate|public|ca-bundle/i.test(b),
7
7
  label: "PEM private key",
8
8
  },
9
+ {
10
+ test: (b) => /\.der$/i.test(b) && !/cert|certificate|public|ca-bundle/i.test(b),
11
+ label: "DER-encoded key or credential material",
12
+ },
9
13
  { test: (b) => /\.p12$/i.test(b), label: "PKCS#12 credential bundle" },
10
14
  { test: (b) => /\.pfx$/i.test(b), label: "PKCS#12 credential bundle" },
11
15
  { test: (b) => /^credentials\.json$/i.test(b), label: "credentials file" },
12
- { test: (b) => /service-account.*\.json$/i.test(b), label: "service account key" },
16
+ { test: (b) => /service[-_]?account.*\.json$/i.test(b), label: "service account key" },
13
17
  {
14
18
  test: (b) => /\.key$/i.test(b) && !/public|pub/i.test(b),
15
19
  label: "private key file",
@@ -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
  }
@@ -8,7 +8,8 @@ export interface PackageManagerDetectionResult {
8
8
  }
9
9
  /**
10
10
  * Detect package managers from lockfiles and manifests.
11
- * Lockfiles take precedence over generic manifests for primary selection.
11
+ * Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
12
12
  */
13
13
  export declare function detectPackageManagers(input: PackageManagerDetectionInput): PackageManagerDetectionResult;
14
14
  export declare function formatPackageManager(id: PackageManagerId): string;
15
+ export declare function formatPackageManagers(ids: PackageManagerId[]): string;
@@ -1,32 +1,83 @@
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", "pyproject.toml"], 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.
15
36
  */
16
37
  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
- }));
38
+ const paths = input.relativePaths;
39
+ const basenames = new Set(paths.map(basename));
40
+ const rootBasenames = new Set(paths.filter(isRootPath).map(basename));
21
41
  const found = [];
42
+ const rootFound = [];
43
+ const lockFound = [];
44
+ const rootLockFound = [];
22
45
  for (const marker of MARKERS) {
23
- if (marker.files.some((f) => basenames.has(f.toLowerCase()))) {
24
- found.push(marker.id);
46
+ if (marker.id === "npm") {
47
+ const hasNpmLock = marker.lockfiles.some((f) => basenames.has(f.toLowerCase()));
48
+ const hasRootPackage = rootBasenames.has("package.json");
49
+ const hasAnyPackage = paths.some((p) => basename(p) === "package.json");
50
+ if (!hasNpmLock && !hasAnyPackage) {
51
+ continue;
52
+ }
53
+ found.push("npm");
54
+ if (hasRootPackage || hasNpmLock) {
55
+ if (hasRootPackage) {
56
+ rootFound.push("npm");
57
+ }
58
+ if (hasNpmLock) {
59
+ lockFound.push("npm");
60
+ }
61
+ if (marker.lockfiles.some((f) => rootBasenames.has(f.toLowerCase()))) {
62
+ rootLockFound.push("npm");
63
+ }
64
+ }
65
+ continue;
66
+ }
67
+ const hasAny = marker.files.some((f) => basenames.has(f.toLowerCase()));
68
+ if (!hasAny) {
69
+ continue;
70
+ }
71
+ found.push(marker.id);
72
+ if (marker.files.some((f) => rootBasenames.has(f.toLowerCase()))) {
73
+ rootFound.push(marker.id);
74
+ }
75
+ if (marker.lockfiles.some((f) => basenames.has(f.toLowerCase()))) {
76
+ lockFound.push(marker.id);
77
+ }
78
+ if (marker.lockfiles.some((f) => rootBasenames.has(f.toLowerCase()))) {
79
+ rootLockFound.push(marker.id);
25
80
  }
26
- }
27
- if (basenames.has("package.json") &&
28
- !found.some((id) => ["npm", "pnpm", "yarn", "bun"].includes(id))) {
29
- found.push("npm");
30
81
  }
31
82
  const unique = [...new Set(found)];
32
83
  const lockPriority = [
@@ -34,19 +85,43 @@ export function detectPackageManagers(input) {
34
85
  "yarn",
35
86
  "bun",
36
87
  "npm",
37
- "poetry",
38
88
  "composer",
89
+ "pub",
90
+ "poetry",
39
91
  "cargo",
40
92
  "gradle",
41
93
  "pip",
42
94
  ];
43
95
  let primary = "unknown";
44
96
  for (const id of lockPriority) {
45
- if (unique.includes(id)) {
97
+ if (rootLockFound.includes(id)) {
46
98
  primary = id;
47
99
  break;
48
100
  }
49
101
  }
102
+ if (primary === "unknown") {
103
+ for (const id of lockPriority) {
104
+ if (rootFound.includes(id)) {
105
+ primary = id;
106
+ break;
107
+ }
108
+ }
109
+ }
110
+ if (primary === "unknown") {
111
+ const ecosystems = new Set(unique.map((id) => (NODE_IDS.has(id) ? "node" : id === "unknown" ? "unknown" : id)));
112
+ ecosystems.delete("unknown");
113
+ if (ecosystems.size > 1) {
114
+ primary = "unknown";
115
+ }
116
+ else {
117
+ for (const id of lockPriority) {
118
+ if (lockFound.includes(id) || unique.includes(id)) {
119
+ primary = id;
120
+ break;
121
+ }
122
+ }
123
+ }
124
+ }
50
125
  return {
51
126
  packageManagers: unique.length > 0 ? unique : ["unknown"],
52
127
  primaryPackageManager: primary,
@@ -64,6 +139,8 @@ export function formatPackageManager(id) {
64
139
  return "Bun";
65
140
  case "composer":
66
141
  return "Composer";
142
+ case "pub":
143
+ return "Pub";
67
144
  case "pip":
68
145
  return "pip";
69
146
  case "poetry":
@@ -76,3 +153,13 @@ export function formatPackageManager(id) {
76
153
  return "Unknown";
77
154
  }
78
155
  }
156
+ export function formatPackageManagers(ids) {
157
+ const unique = [...new Set(ids)];
158
+ if (unique.length === 0 || (unique.length === 1 && unique[0] === "unknown")) {
159
+ return "Unknown";
160
+ }
161
+ return unique
162
+ .filter((id) => id !== "unknown")
163
+ .map((id) => formatPackageManager(id))
164
+ .join(", ");
165
+ }
@@ -1,11 +1,11 @@
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 { isDirectory, pathExists, readJsonFile } from "../utils/fs.js";
9
9
  import { resolveRepoRoot } from "../utils/path.js";
10
10
  /**
11
11
  * Discover files and detect repository characteristics.
@@ -19,16 +19,26 @@ export async function detectProject(cwd, maxFileSizeBytes = DEFAULT_MAX_FILE_SIZ
19
19
  const discovery = await discoverFiles({ root, maxFileSizeBytes });
20
20
  const relativePaths = discovery.files.map((f) => f.relativePath);
21
21
  for (const err of discovery.permissionErrors) {
22
+ const relative = err.split(":")[0] ?? err;
23
+ const underIgnored = relative
24
+ .split("/")
25
+ .some((segment) => segment.length > 0 && DEFAULT_IGNORE_DIRECTORIES.has(segment));
26
+ if (underIgnored) {
27
+ continue;
28
+ }
22
29
  diagnostics.push(`Permission/read issue: ${err}`);
23
30
  }
24
31
  let packageJson;
25
32
  const packageJsonPath = path.join(root, "package.json");
26
- const packageResult = await readJsonFile(packageJsonPath, maxFileSizeBytes);
27
- if (packageResult.ok) {
28
- packageJson = packageResult.data;
29
- }
30
- else if (relativePaths.some((p) => p === "package.json" || p.endsWith("/package.json"))) {
31
- diagnostics.push(`Malformed package.json: ${packageResult.error}`);
33
+ const rootPackageExists = await pathExists(packageJsonPath);
34
+ if (rootPackageExists) {
35
+ const packageResult = await readJsonFile(packageJsonPath, maxFileSizeBytes);
36
+ if (packageResult.ok) {
37
+ packageJson = packageResult.data;
38
+ }
39
+ else {
40
+ diagnostics.push(`Malformed package.json: ${packageResult.error}`);
41
+ }
32
42
  }
33
43
  const deps = {
34
44
  ...(packageJson?.dependencies ?? {}),
@@ -3,7 +3,7 @@ import { AGENT_DISPLAY_NAMES } from "../../constants.js";
3
3
  import { formatFramework } from "../../detectors/framework.js";
4
4
  import { formatLanguage } 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;
@@ -64,7 +64,11 @@ export function renderTerminalReport(result, options = {}) {
64
64
  lines.push(colors.bold("Repository"));
65
65
  lines.push(` Framework: ${sanitizeTerminalText(formatFramework(result.repository.primaryFramework))}`);
66
66
  lines.push(` Language: ${sanitizeTerminalText(formatLanguage(result.repository.primaryLanguage))}`);
67
- lines.push(` Package manager: ${sanitizeTerminalText(formatPackageManager(result.repository.primaryPackageManager))}`);
67
+ const packageManagerLabel = result.repository.packageManagers.length > 1 ||
68
+ result.repository.primaryPackageManager === "unknown"
69
+ ? formatPackageManagers(result.repository.packageManagers)
70
+ : formatPackageManager(result.repository.primaryPackageManager);
71
+ lines.push(` Package manager: ${sanitizeTerminalText(packageManagerLabel)}`);
68
72
  if (result.repository.monorepo !== "none") {
69
73
  lines.push(` Monorepo: ${sanitizeTerminalText(formatMonorepo(result.repository.monorepo))}`);
70
74
  }
@@ -7,8 +7,8 @@ export type Severity = "critical" | "warning" | "info";
7
7
  export type RuleCategory = "security" | "context" | "instructions" | "mcp" | "performance" | "compatibility";
8
8
  export type LanguageId = "typescript" | "javascript" | "python" | "dart" | "php" | "go" | "rust" | "java" | "kotlin" | "unknown";
9
9
  export type FrameworkId = "nextjs" | "react" | "vue" | "nuxt" | "svelte" | "nodejs" | "express" | "nestjs" | "flutter" | "laravel" | "django" | "fastapi" | "unknown";
10
- export type PackageManagerId = "npm" | "pnpm" | "yarn" | "bun" | "composer" | "pip" | "poetry" | "cargo" | "gradle" | "unknown";
11
- export type MonorepoToolId = "npm-workspaces" | "pnpm-workspaces" | "turborepo" | "nx" | "none";
10
+ export type PackageManagerId = "npm" | "pnpm" | "yarn" | "bun" | "composer" | "pub" | "pip" | "poetry" | "cargo" | "gradle" | "unknown";
11
+ export type MonorepoToolId = "npm-workspaces" | "pnpm-workspaces" | "turborepo" | "nx" | "multi-project" | "none";
12
12
  export type AgentId = "cursor" | "claude-code" | "codex";
13
13
  export interface DiscoveredFile {
14
14
  /** Absolute path */
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "@praneeth_54/agentdoctor",
3
- "version": "0.1.1-beta",
3
+ "version": "0.1.2-beta",
4
4
  "description": "Audit AI coding agent configuration in a repository — local, deterministic, no API key.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "agentdoctor": "dist/cli/index.js"
7
+ "agentdoctor": "./dist/cli/index.js"
8
8
  },
9
9
  "exports": {
10
10
  ".": {
@@ -24,7 +24,7 @@
24
24
  "node": ">=20"
25
25
  },
26
26
  "scripts": {
27
- "build": "tsc -p tsconfig.build.json",
27
+ "build": "tsc -p tsconfig.build.json && node scripts/ensure-cli-bin.mjs",
28
28
  "dev": "tsx src/cli/index.ts",
29
29
  "typecheck": "tsc -p tsconfig.json --noEmit && tsc -p tsconfig.build.json --noEmit",
30
30
  "lint": "eslint .",
@@ -34,6 +34,7 @@
34
34
  "test": "vitest run",
35
35
  "test:watch": "vitest",
36
36
  "verify": "npm run typecheck && npm run lint && npm run format:check && npm test && npm run build",
37
+ "prepare": "node scripts/ensure-cli-bin.mjs",
37
38
  "prepublishOnly": "npm run verify"
38
39
  },
39
40
  "keywords": [