@praneeth_54/agentdoctor 0.1.0-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
@@ -7,22 +7,45 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
- ### Changed
10
+ ### Planned
11
+
12
+ - Deterministic readiness scoring
13
+ - Safe automatic fixes
14
+ - Packaged GitHub Action
11
15
 
12
- - Publish as scoped package `@praneeth_54/agentdoctor` (unscoped `agentdoctor` blocked by npm as too similar to `agent-doctor`)
16
+ ## [0.1.2-beta] — 2026-07-29
17
+
18
+ Backward-compatible quality patch.
13
19
 
14
20
  ### Fixed
15
21
 
16
- - Track fake credential fixtures required by CI (were excluded by `.gitignore`)
17
- - Align package and docs URLs with the public GitHub repository
18
- - Release workflow selects notes by tag and attaches the npm tarball
19
- - CLI help text matches shipped capabilities (`--min-score` no-op until scoring)
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
20
29
 
21
- ### Planned
30
+ ### Testing
22
31
 
23
- - Deterministic readiness scoring
24
- - Safe automatic fixes
25
- - Packaged GitHub Action
32
+ - Added real-world multi-app, generated-directory, credential, and CLI bin fixtures/tests
33
+ - Total test suite is now 104 tests
34
+
35
+ ## [0.1.1-beta] — 2026-07-29
36
+
37
+ Patch release.
38
+
39
+ ### Fixed
40
+
41
+ - False missing-path warnings for repository-root paths referenced from nested instruction files
42
+ - CSS-like backtick tokens such as `.content` being interpreted as filesystem paths
43
+
44
+ ### Testing
45
+
46
+ - Added real-world regression fixtures
47
+ - Added 17 regression tests
48
+ - Total test suite is now 91 tests
26
49
 
27
50
  ## [0.1.0-beta] — 2026-07-29
28
51
 
@@ -40,6 +63,10 @@ First public beta.
40
63
  - Programmatic `scan()` API
41
64
  - Fixture-based unit and integration tests
42
65
 
66
+ ### Changed
67
+
68
+ - Published npm package as `@praneeth_54/agentdoctor` (unscoped `agentdoctor` blocked by npm as too similar to `agent-doctor`)
69
+
43
70
  ### Security
44
71
 
45
72
  - Conservative wording for exposure findings
@@ -54,5 +81,7 @@ First public beta.
54
81
  - Not a complete secret scanner
55
82
  - Git “tracked secret” detection deferred
56
83
 
57
- [Unreleased]: https://github.com/pranee54/AgentDoctor/compare/v0.1.0-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
86
+ [0.1.1-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.1-beta
58
87
  [0.1.0-beta]: https://github.com/pranee54/AgentDoctor/releases/tag/v0.1.0-beta
package/README.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # AgentDoctor
2
2
 
3
+ [![npm](https://img.shields.io/npm/v/@praneeth_54/agentdoctor)](https://www.npmjs.com/package/@praneeth_54/agentdoctor)
4
+ [![License](https://img.shields.io/github/license/pranee54/AgentDoctor)](LICENSE)
5
+ [![GitHub Stars](https://img.shields.io/github/stars/pranee54/AgentDoctor)](https://github.com/pranee54/AgentDoctor/stargazers)
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
+ [![Node](https://img.shields.io/node/v/@praneeth_54/agentdoctor)](https://nodejs.org)
8
+ [![Release](https://img.shields.io/badge/release-v0.1.2--beta-orange)](CHANGELOG.md)
9
+
3
10
  **Lighthouse for AI coding agents.**
4
11
 
5
12
  AgentDoctor audits a repository’s AI coding agent configuration — instructions, ignore rules, permissions, and MCP setup — using local static analysis. No API key. No upload by default.
@@ -15,12 +22,6 @@ npx @praneeth_54/agentdoctor
15
22
  | **Install** | `npx @praneeth_54/agentdoctor` or `npm install -g @praneeth_54/agentdoctor` |
16
23
  | **Requires** | Node.js 20+ |
17
24
 
18
- [![CI](https://img.shields.io/github/actions/workflow/status/pranee54/AgentDoctor/ci.yml?branch=main&label=CI)](https://github.com/pranee54/AgentDoctor/actions)
19
- [![npm](https://img.shields.io/npm/v/@praneeth_54/agentdoctor?label=npm)](https://www.npmjs.com/package/@praneeth_54/agentdoctor)
20
- [![Node](https://img.shields.io/node/v/@praneeth_54/agentdoctor)](https://nodejs.org)
21
- [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
22
- [![Release](https://img.shields.io/badge/release-v0.1.0--beta-orange)](CHANGELOG.md)
23
-
24
25
  ---
25
26
 
26
27
  ## Demo
@@ -28,7 +29,7 @@ npx @praneeth_54/agentdoctor
28
29
  ```text
29
30
  $ npx @praneeth_54/agentdoctor
30
31
 
31
- AgentDoctor v0.1.0-beta
32
+ AgentDoctor v0.1.2-beta
32
33
 
33
34
  Repository
34
35
  Framework: Next.js
@@ -87,13 +88,13 @@ AgentDoctor analyzes configuration files. It does not run agents or edit your pr
87
88
  ### One-shot
88
89
 
89
90
  ```bash
90
- npx @praneeth_54/agentdoctor
91
+ npx @praneeth_54/agentdoctor@0.1.2-beta
91
92
  ```
92
93
 
93
94
  ### Global (optional)
94
95
 
95
96
  ```bash
96
- npm install -g @praneeth_54/agentdoctor
97
+ npm install -g @praneeth_54/agentdoctor@0.1.2-beta
97
98
  agentdoctor
98
99
  ```
99
100
 
@@ -224,7 +225,7 @@ npx @praneeth_54/agentdoctor --json
224
225
 
225
226
  ```json
226
227
  {
227
- "version": "0.1.0-beta",
228
+ "version": "0.1.2-beta",
228
229
  "repository": {
229
230
  "primaryFramework": "nextjs",
230
231
  "primaryLanguage": "typescript",
package/dist/cli/index.js CHANGED
File without changes
@@ -1,4 +1,4 @@
1
- export declare const PACKAGE_VERSION = "0.1.0-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.0-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
  };
@@ -1,2 +1,22 @@
1
1
  import type { RuleDefinition } from "../types.js";
2
+ export type ResolvedPathReference = {
3
+ status: "ok";
4
+ pathsToCheck: string[];
5
+ primaryRelative: string;
6
+ } | {
7
+ status: "escape";
8
+ attempted: string;
9
+ };
10
+ /**
11
+ * Conservative local path classification for markdown links / backticks.
12
+ * Precision is more important than recall for this rule.
13
+ */
14
+ export declare function isLikelyLocalPathReference(value: string): boolean;
15
+ export declare function extractPathCandidates(text: string): string[];
16
+ /**
17
+ * Resolve a candidate path reference with repository-aware semantics:
18
+ * - `./` and `../` → relative to the instruction file directory
19
+ * - otherwise → repository-root relative (avoids nesting under `.cursor/rules/`)
20
+ */
21
+ export declare function resolveInstructionPathReference(instructionRelativePath: string, candidate: string): ResolvedPathReference;
2
22
  export declare const missingPathReferenceRule: RuleDefinition;
@@ -10,49 +10,97 @@ const INSTRUCTION_KINDS = new Set([
10
10
  "claude-local-md",
11
11
  "claude-rule-md",
12
12
  ]);
13
- /**
14
- * Conservative local path extraction:
15
- * - Markdown links: [text](relative/path.ext)
16
- * - Backticked paths with separators or extensions
17
- */
18
- function extractPathCandidates(text) {
19
- const found = new Set();
20
- for (const match of text.matchAll(/\[[^\]]*]\(([^)]+)\)/g)) {
21
- const target = (match[1] ?? "").trim();
22
- if (isConservativeLocalPath(target)) {
23
- found.add(stripAnchor(target));
24
- }
25
- }
26
- for (const match of text.matchAll(/`([^`]+)`/g)) {
27
- const target = (match[1] ?? "").trim();
28
- if (isConservativeLocalPath(target)) {
29
- found.add(stripAnchor(target));
30
- }
31
- }
32
- return [...found];
33
- }
13
+ /** Extensions accepted for bare filenames (no `/`) — precision over recall. */
14
+ const BARE_FILE_EXTENSION = /\.(mdc?|txt|tsx?|jsx?|mjs|cjs|json|ya?ml|toml|php|py|rb|go|rs|java|kt|css|scss|html|vue|svelte|sh|bash|zsh|sql|xml|svg|png|jpe?g|gif|webp|wasm|lock)$/i;
34
15
  function stripAnchor(value) {
35
16
  return value.split("#")[0] ?? value;
36
17
  }
37
- function isConservativeLocalPath(value) {
18
+ function isExplicitRelativePath(value) {
19
+ return value.startsWith("./") || value.startsWith("../");
20
+ }
21
+ /**
22
+ * Conservative local path classification for markdown links / backticks.
23
+ * Precision is more important than recall for this rule.
24
+ */
25
+ export function isLikelyLocalPathReference(value) {
38
26
  if (!value || value.length > 200)
39
27
  return false;
40
28
  if (/^(https?:|mailto:|file:)/i.test(value))
41
29
  return false;
42
30
  if (value.startsWith("#"))
43
31
  return false;
32
+ if (value.startsWith("--"))
33
+ return false;
44
34
  if (value.includes("://"))
45
35
  return false;
46
36
  if (value.includes("\n") || value.includes(" "))
47
37
  return false;
48
- // Must look like a path: has / or a file extension
49
- if (!value.includes("/") && !/\.[a-z0-9]{1,8}$/i.test(value))
50
- return false;
51
- // Reject shell-looking fragments
52
38
  if (/[;&|$]/.test(value))
53
39
  return false;
54
- // Reject traversal escape attempts — still check but flag separately
55
- return true;
40
+ if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value))
41
+ return false;
42
+ // CSS class / id-like bare selectors: `.content`, `.button`
43
+ if (/^\.[A-Za-z_][\w-]*$/.test(value))
44
+ return false;
45
+ if (value.includes("/")) {
46
+ // Reject empty segments like `foo//bar` while allowing trailing slash dirs
47
+ const normalized = value.replace(/\/+$/, "");
48
+ if (!normalized || normalized.split("/").some((part) => part === "")) {
49
+ return false;
50
+ }
51
+ return true;
52
+ }
53
+ // Bare filename: require a known file extension (not `.content`-style tokens)
54
+ return BARE_FILE_EXTENSION.test(value) && !value.startsWith(".");
55
+ }
56
+ export function extractPathCandidates(text) {
57
+ const found = new Set();
58
+ for (const match of text.matchAll(/\[[^\]]*]\(([^)]+)\)/g)) {
59
+ const target = stripAnchor((match[1] ?? "").trim());
60
+ if (isLikelyLocalPathReference(target)) {
61
+ found.add(target);
62
+ }
63
+ }
64
+ for (const match of text.matchAll(/`([^`]+)`/g)) {
65
+ const target = stripAnchor((match[1] ?? "").trim());
66
+ if (isLikelyLocalPathReference(target)) {
67
+ found.add(target);
68
+ }
69
+ }
70
+ return [...found];
71
+ }
72
+ function isEscapingRepoRelative(relativePath) {
73
+ return relativePath === ".." || relativePath.startsWith("../");
74
+ }
75
+ /**
76
+ * Resolve a candidate path reference with repository-aware semantics:
77
+ * - `./` and `../` → relative to the instruction file directory
78
+ * - otherwise → repository-root relative (avoids nesting under `.cursor/rules/`)
79
+ */
80
+ export function resolveInstructionPathReference(instructionRelativePath, candidate) {
81
+ const instructionDir = path.posix.dirname(instructionRelativePath);
82
+ if (isExplicitRelativePath(candidate)) {
83
+ const joined = instructionDir === "."
84
+ ? path.posix.normalize(candidate)
85
+ : path.posix.normalize(path.posix.join(instructionDir, candidate));
86
+ if (isEscapingRepoRelative(joined)) {
87
+ return { status: "escape", attempted: joined };
88
+ }
89
+ return {
90
+ status: "ok",
91
+ pathsToCheck: [joined],
92
+ primaryRelative: joined,
93
+ };
94
+ }
95
+ const rootRelative = path.posix.normalize(candidate);
96
+ if (isEscapingRepoRelative(rootRelative) || rootRelative.startsWith("/")) {
97
+ return { status: "escape", attempted: rootRelative };
98
+ }
99
+ return {
100
+ status: "ok",
101
+ pathsToCheck: [rootRelative],
102
+ primaryRelative: rootRelative,
103
+ };
56
104
  }
57
105
  export const missingPathReferenceRule = {
58
106
  id: "instructions/missing-path-reference",
@@ -81,10 +129,8 @@ export const missingPathReferenceRule = {
81
129
  }
82
130
  const candidates = extractPathCandidates(cached.text);
83
131
  for (const candidate of candidates) {
84
- // Resolve relative to instruction file directory, stay inside root
85
- const baseDir = path.posix.dirname(file.relativePath);
86
- const resolvedRelative = path.posix.normalize(baseDir === "." ? candidate : path.posix.join(baseDir, candidate));
87
- if (resolvedRelative.startsWith("../") || resolvedRelative === "..") {
132
+ const resolved = resolveInstructionPathReference(file.relativePath, candidate);
133
+ if (resolved.status === "escape") {
88
134
  findings.push({
89
135
  ruleId: "instructions/missing-path-reference",
90
136
  category: "instructions",
@@ -101,11 +147,17 @@ export const missingPathReferenceRule = {
101
147
  });
102
148
  continue;
103
149
  }
104
- const absolute = path.resolve(context.root, resolvedRelative);
105
- if (!isPathInsideRoot(context.root, absolute)) {
106
- continue;
150
+ let exists = false;
151
+ for (const relativePath of resolved.pathsToCheck) {
152
+ const absolute = path.resolve(context.root, relativePath);
153
+ if (!isPathInsideRoot(context.root, absolute)) {
154
+ continue;
155
+ }
156
+ if (await pathExistsInsideRoot(context.root, relativePath)) {
157
+ exists = true;
158
+ break;
159
+ }
107
160
  }
108
- const exists = await pathExistsInsideRoot(context.root, resolvedRelative);
109
161
  if (exists) {
110
162
  continue;
111
163
  }
@@ -120,7 +172,10 @@ export const missingPathReferenceRule = {
120
172
  affectedAgents: context.agents
121
173
  .filter((a) => a.configPaths.includes(file.relativePath))
122
174
  .map((a) => a.id),
123
- evidence: { path: file.relativePath, detail: `missing=${resolvedRelative}` },
175
+ evidence: {
176
+ path: file.relativePath,
177
+ detail: `missing=${resolved.primaryRelative}`,
178
+ },
124
179
  fixability: "manual",
125
180
  });
126
181
  }
@@ -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.0-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": [