@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 +40 -11
- package/README.md +11 -10
- package/dist/cli/index.js +0 -0
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/rules/build-context.js +36 -5
- package/dist/core/rules/context/generated-directory.js +33 -43
- package/dist/core/rules/ignore.d.ts +5 -0
- package/dist/core/rules/ignore.js +51 -9
- package/dist/core/rules/instructions/missing-path-reference.d.ts +20 -0
- package/dist/core/rules/instructions/missing-path-reference.js +92 -37
- package/dist/core/rules/security/private-key-file.js +5 -1
- package/dist/detectors/monorepo.d.ts +2 -1
- package/dist/detectors/monorepo.js +32 -1
- package/dist/detectors/package-manager.d.ts +2 -1
- package/dist/detectors/package-manager.js +109 -22
- package/dist/detectors/project.js +18 -8
- package/dist/reporters/terminal/report.js +6 -2
- package/dist/types/index.d.ts +2 -2
- package/package.json +4 -3
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
|
-
###
|
|
10
|
+
### Planned
|
|
11
|
+
|
|
12
|
+
- Deterministic readiness scoring
|
|
13
|
+
- Safe automatic fixes
|
|
14
|
+
- Packaged GitHub Action
|
|
11
15
|
|
|
12
|
-
-
|
|
16
|
+
## [0.1.2-beta] — 2026-07-29
|
|
17
|
+
|
|
18
|
+
Backward-compatible quality patch.
|
|
13
19
|
|
|
14
20
|
### Fixed
|
|
15
21
|
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
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
|
-
###
|
|
30
|
+
### Testing
|
|
22
31
|
|
|
23
|
-
-
|
|
24
|
-
-
|
|
25
|
-
|
|
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.
|
|
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
|
+
[](https://www.npmjs.com/package/@praneeth_54/agentdoctor)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://github.com/pranee54/AgentDoctor/stargazers)
|
|
6
|
+
[](https://github.com/pranee54/AgentDoctor/actions)
|
|
7
|
+
[](https://nodejs.org)
|
|
8
|
+
[](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
|
-
[](https://github.com/pranee54/AgentDoctor/actions)
|
|
19
|
-
[](https://www.npmjs.com/package/@praneeth_54/agentdoctor)
|
|
20
|
-
[](https://nodejs.org)
|
|
21
|
-
[](LICENSE)
|
|
22
|
-
[](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.
|
|
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.
|
|
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
|
package/dist/constants.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export declare const PACKAGE_VERSION = "0.1.
|
|
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
|
+
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
|
|
7
|
-
const
|
|
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
|
|
10
|
-
cursorignorePatterns
|
|
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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
67
|
-
if (
|
|
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
|
|
72
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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: {
|
|
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
|
-
*
|
|
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
|
-
{
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
{ id: "
|
|
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
|
-
*
|
|
35
|
+
* Primary selection prefers root-level evidence and avoids inventing certainty for mixed ecosystems.
|
|
15
36
|
*/
|
|
16
37
|
export function detectPackageManagers(input) {
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
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.
|
|
24
|
-
|
|
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 (
|
|
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
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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": [
|