@praneeth_54/agentdoctor 0.2.0-beta → 0.3.0-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 +32 -3
- package/README.md +46 -25
- package/dist/cli/commands/doctor.js +1 -1
- package/dist/cli/commands/explain.js +2 -2
- package/dist/cli/commands/fix.d.ts +7 -5
- package/dist/cli/commands/fix.js +57 -12
- package/dist/cli/commands/verify.d.ts +13 -0
- package/dist/cli/commands/verify.js +56 -0
- package/dist/cli/program.js +53 -30
- package/dist/constants.d.ts +1 -1
- package/dist/constants.js +1 -1
- package/dist/core/fix/apply.d.ts +10 -0
- package/dist/core/fix/apply.js +27 -0
- package/dist/core/fix/patterns.d.ts +7 -0
- package/dist/core/fix/patterns.js +21 -0
- package/dist/core/fix/plan.d.ts +9 -0
- package/dist/core/fix/plan.js +140 -0
- package/dist/core/fix/render.d.ts +6 -0
- package/dist/core/fix/render.js +68 -0
- package/dist/core/fix/run.d.ts +15 -0
- package/dist/core/fix/run.js +54 -0
- package/dist/core/fix/types.d.ts +34 -0
- package/dist/core/fix/types.js +2 -0
- package/dist/core/fix/writers/cursorignore.d.ts +13 -0
- package/dist/core/fix/writers/cursorignore.js +74 -0
- package/dist/core/rules/context/generated-directory.js +10 -0
- package/dist/core/rules/instructions/missing-path-reference.js +90 -1
- package/dist/core/rules/path-kind.d.ts +14 -0
- package/dist/core/rules/path-kind.js +77 -0
- package/dist/core/rules/security/env-file-exposure.js +14 -14
- package/dist/core/rules/security/private-key-file.js +4 -0
- package/dist/core/verify/compare.d.ts +29 -0
- package/dist/core/verify/compare.js +56 -0
- package/dist/core/verify/load-baseline.d.ts +15 -0
- package/dist/core/verify/load-baseline.js +71 -0
- package/dist/core/verify/verify.d.ts +26 -0
- package/dist/core/verify/verify.js +40 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +5 -0
- package/dist/reporters/terminal/report.js +7 -1
- package/dist/reporters/verify/json.d.ts +5 -0
- package/dist/reporters/verify/json.js +51 -0
- package/dist/reporters/verify/terminal.d.ts +2 -0
- package/dist/reporters/verify/terminal.js +45 -0
- package/package.json +1 -1
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readTextFile } from "../../utils/fs.js";
|
|
3
|
+
import { createIgnoreIndex, parseIgnoreFile, pathMatchesAny } from "../rules/ignore.js";
|
|
4
|
+
import { isSafeContextFixRule, patternForFinding } from "./patterns.js";
|
|
5
|
+
const DEFAULT_MAX_IGNORE_BYTES = 512 * 1024;
|
|
6
|
+
function cursorConfigured(result) {
|
|
7
|
+
return result.agents.some((a) => a.id === "cursor" && (a.detected || a.configured));
|
|
8
|
+
}
|
|
9
|
+
async function loadCursorignorePatterns(root) {
|
|
10
|
+
const filePath = path.join(root, ".cursorignore");
|
|
11
|
+
const text = await readTextFile(filePath, DEFAULT_MAX_IGNORE_BYTES);
|
|
12
|
+
if (text === null) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
return parseIgnoreFile(text);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Build a fix plan from a scan result.
|
|
19
|
+
* Week 1: Cursor `.cursorignore` appends for safe context findings only.
|
|
20
|
+
*/
|
|
21
|
+
export async function buildFixPlan(result) {
|
|
22
|
+
const root = result.repository.root;
|
|
23
|
+
const skipped = [];
|
|
24
|
+
const actionsByKey = new Map();
|
|
25
|
+
const existingCursorPatterns = await loadCursorignorePatterns(root);
|
|
26
|
+
const cursorIndex = createIgnoreIndex({
|
|
27
|
+
gitignorePatterns: [],
|
|
28
|
+
cursorignorePatterns: existingCursorPatterns,
|
|
29
|
+
});
|
|
30
|
+
const hasCursor = cursorConfigured(result);
|
|
31
|
+
for (const finding of result.findings) {
|
|
32
|
+
if (finding.fixability !== "safe") {
|
|
33
|
+
skipped.push({
|
|
34
|
+
findingId: finding.id,
|
|
35
|
+
ruleId: finding.ruleId,
|
|
36
|
+
reason: finding.fixability === "review"
|
|
37
|
+
? "Requires human review (not auto-fixed)"
|
|
38
|
+
: finding.fixability === "manual"
|
|
39
|
+
? "Manual remediation required (not auto-fixed)"
|
|
40
|
+
: "Not auto-fixable",
|
|
41
|
+
});
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!isSafeContextFixRule(finding.ruleId)) {
|
|
45
|
+
skipped.push({
|
|
46
|
+
findingId: finding.id,
|
|
47
|
+
ruleId: finding.ruleId,
|
|
48
|
+
reason: "No automated fixer for this safe rule yet",
|
|
49
|
+
});
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const pattern = patternForFinding(finding);
|
|
53
|
+
const evidencePath = finding.evidence?.path?.replace(/\\/g, "/");
|
|
54
|
+
if (!pattern || !evidencePath) {
|
|
55
|
+
skipped.push({
|
|
56
|
+
findingId: finding.id,
|
|
57
|
+
ruleId: finding.ruleId,
|
|
58
|
+
reason: "Finding has no usable evidence path",
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const wantsCursor = finding.affectedAgents.includes("cursor");
|
|
63
|
+
if (wantsCursor && hasCursor) {
|
|
64
|
+
if (cursorIndex.matchesCursorignore(evidencePath) ||
|
|
65
|
+
cursorIndex.matchesCursorignore(pattern)) {
|
|
66
|
+
skipped.push({
|
|
67
|
+
findingId: finding.id,
|
|
68
|
+
ruleId: finding.ruleId,
|
|
69
|
+
reason: "Already excluded by .cursorignore",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
else {
|
|
73
|
+
mergeCursorAction(actionsByKey, finding, pattern, evidencePath);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
else if (wantsCursor && !hasCursor) {
|
|
77
|
+
skipped.push({
|
|
78
|
+
findingId: finding.id,
|
|
79
|
+
ruleId: finding.ruleId,
|
|
80
|
+
reason: "Cursor not detected; skipping .cursorignore fix",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
for (const agent of finding.affectedAgents) {
|
|
84
|
+
if (agent === "cursor") {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
skipped.push({
|
|
88
|
+
findingId: finding.id,
|
|
89
|
+
ruleId: finding.ruleId,
|
|
90
|
+
reason: `Fix writer for ${agent} not implemented yet`,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
root,
|
|
96
|
+
actions: [...actionsByKey.values()].sort((a, b) => a.id.localeCompare(b.id)),
|
|
97
|
+
skipped,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
function mergeCursorAction(actionsByKey, finding, pattern, evidencePath) {
|
|
101
|
+
const key = `cursor:.cursorignore:${pattern}`;
|
|
102
|
+
const existing = actionsByKey.get(key);
|
|
103
|
+
if (existing) {
|
|
104
|
+
if (!existing.findingIds.includes(finding.id)) {
|
|
105
|
+
existing.findingIds.push(finding.id);
|
|
106
|
+
}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
actionsByKey.set(key, {
|
|
110
|
+
id: key,
|
|
111
|
+
kind: "append-ignore-pattern",
|
|
112
|
+
agent: "cursor",
|
|
113
|
+
targetRelativePath: ".cursorignore",
|
|
114
|
+
pattern,
|
|
115
|
+
evidencePath,
|
|
116
|
+
findingIds: [finding.id],
|
|
117
|
+
description: `Exclude ${evidencePath} from Cursor agent context via .cursorignore`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
/** Patterns that would be newly appended given current file contents. */
|
|
121
|
+
export function missingPatternsForCursorignore(currentContent, patterns) {
|
|
122
|
+
const existing = currentContent === null ? [] : parseIgnoreFile(currentContent);
|
|
123
|
+
const missing = [];
|
|
124
|
+
for (const pattern of patterns) {
|
|
125
|
+
if (existing.includes(pattern)) {
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (pathMatchesAny(pattern.replace(/\/$/, ""), existing) &&
|
|
129
|
+
existing.some((p) => p === pattern)) {
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
// Also skip if an existing pattern already excludes this path
|
|
133
|
+
const evidenceGuess = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
|
|
134
|
+
if (pathMatchesAny(evidenceGuess, existing) || pathMatchesAny(pattern, existing)) {
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
missing.push(pattern);
|
|
138
|
+
}
|
|
139
|
+
return missing;
|
|
140
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { previewCursorignoreActions } from "./writers/cursorignore.js";
|
|
2
|
+
export function renderFixPlanTerminal(plan, options) {
|
|
3
|
+
const lines = [];
|
|
4
|
+
lines.push("");
|
|
5
|
+
lines.push(options.dryRun ? "AgentDoctor fix (dry-run)" : "AgentDoctor fix");
|
|
6
|
+
lines.push("");
|
|
7
|
+
if (plan.actions.length === 0) {
|
|
8
|
+
lines.push(" No automatic fixes available for this scan.");
|
|
9
|
+
if (plan.skipped.length > 0) {
|
|
10
|
+
lines.push("");
|
|
11
|
+
lines.push(` ${plan.skipped.length} finding(s) need manual or review action:`);
|
|
12
|
+
const reasons = summarizeSkipReasons(plan.skipped.map((s) => s.reason));
|
|
13
|
+
for (const [reason, count] of reasons) {
|
|
14
|
+
lines.push(` - ${reason} (${count})`);
|
|
15
|
+
}
|
|
16
|
+
lines.push("");
|
|
17
|
+
lines.push(" Next: address remaining findings, then run `agentdoctor verify`.");
|
|
18
|
+
}
|
|
19
|
+
else {
|
|
20
|
+
lines.push(" Scan found nothing that Fix can change.");
|
|
21
|
+
}
|
|
22
|
+
lines.push("");
|
|
23
|
+
return lines.join("\n");
|
|
24
|
+
}
|
|
25
|
+
lines.push(` Proposed actions: ${plan.actions.length}`);
|
|
26
|
+
for (const action of plan.actions) {
|
|
27
|
+
lines.push(` • [${action.agent}] ${action.description}`);
|
|
28
|
+
lines.push(` pattern: ${action.pattern}`);
|
|
29
|
+
lines.push(` findings: ${action.findingIds.length}`);
|
|
30
|
+
}
|
|
31
|
+
const preview = previewCursorignoreActions(options.cursorContent, plan.actions);
|
|
32
|
+
if (preview) {
|
|
33
|
+
lines.push("");
|
|
34
|
+
lines.push(" File changes:");
|
|
35
|
+
lines.push(` ${preview.targetRelativePath} (+${preview.patternsToAdd.length} pattern(s))`);
|
|
36
|
+
lines.push("");
|
|
37
|
+
for (const previewLine of preview.preview.split("\n")) {
|
|
38
|
+
lines.push(` ${previewLine}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
if (options.applyResult && !options.dryRun) {
|
|
42
|
+
lines.push("");
|
|
43
|
+
if (options.applyResult.writtenFiles.length > 0) {
|
|
44
|
+
lines.push(` Wrote: ${options.applyResult.writtenFiles.join(", ")}`);
|
|
45
|
+
}
|
|
46
|
+
else {
|
|
47
|
+
lines.push(" No files written.");
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (options.dryRun) {
|
|
51
|
+
lines.push("");
|
|
52
|
+
lines.push(" No files were modified (dry-run).");
|
|
53
|
+
lines.push(" Re-run without --dry-run to apply.");
|
|
54
|
+
}
|
|
55
|
+
if (plan.skipped.length > 0) {
|
|
56
|
+
lines.push("");
|
|
57
|
+
lines.push(` Skipped: ${plan.skipped.length} finding(s) (writers pending or already fixed)`);
|
|
58
|
+
}
|
|
59
|
+
lines.push("");
|
|
60
|
+
return lines.join("\n");
|
|
61
|
+
}
|
|
62
|
+
function summarizeSkipReasons(reasons) {
|
|
63
|
+
const map = new Map();
|
|
64
|
+
for (const reason of reasons) {
|
|
65
|
+
map.set(reason, (map.get(reason) ?? 0) + 1);
|
|
66
|
+
}
|
|
67
|
+
return [...map.entries()].sort((a, b) => b[1] - a[1]);
|
|
68
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { FixApplyResult, FixPlan } from "./types.js";
|
|
2
|
+
export interface RunFixOptions {
|
|
3
|
+
cwd: string;
|
|
4
|
+
dryRun: boolean;
|
|
5
|
+
yes: boolean;
|
|
6
|
+
}
|
|
7
|
+
export interface RunFixResult {
|
|
8
|
+
plan: FixPlan;
|
|
9
|
+
applyResult: FixApplyResult;
|
|
10
|
+
cancelled: boolean;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Scan → plan → (confirm) → apply Safe Repository Mutation actions.
|
|
14
|
+
*/
|
|
15
|
+
export declare function runFix(options: RunFixOptions): Promise<RunFixResult>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import readline from "node:readline/promises";
|
|
2
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
3
|
+
import { scan } from "../scanner/scan.js";
|
|
4
|
+
import { applyFixPlan, readCursorignore } from "./apply.js";
|
|
5
|
+
import { buildFixPlan } from "./plan.js";
|
|
6
|
+
import { renderFixPlanTerminal } from "./render.js";
|
|
7
|
+
/**
|
|
8
|
+
* Scan → plan → (confirm) → apply Safe Repository Mutation actions.
|
|
9
|
+
*/
|
|
10
|
+
export async function runFix(options) {
|
|
11
|
+
const result = await scan({ cwd: options.cwd });
|
|
12
|
+
const plan = await buildFixPlan(result);
|
|
13
|
+
const cursorContent = await readCursorignore(plan.root);
|
|
14
|
+
if (plan.actions.length === 0 || options.dryRun) {
|
|
15
|
+
const applyResult = await applyFixPlan(plan, { dryRun: true });
|
|
16
|
+
return { plan, applyResult, cancelled: false };
|
|
17
|
+
}
|
|
18
|
+
if (!options.yes) {
|
|
19
|
+
const previewText = renderFixPlanTerminal(plan, {
|
|
20
|
+
dryRun: true,
|
|
21
|
+
cursorContent,
|
|
22
|
+
});
|
|
23
|
+
process.stdout.write(previewText);
|
|
24
|
+
const confirmed = await confirm("Apply these changes?");
|
|
25
|
+
if (!confirmed) {
|
|
26
|
+
return {
|
|
27
|
+
plan,
|
|
28
|
+
applyResult: {
|
|
29
|
+
plan,
|
|
30
|
+
dryRun: true,
|
|
31
|
+
writtenFiles: [],
|
|
32
|
+
changedFiles: [],
|
|
33
|
+
},
|
|
34
|
+
cancelled: true,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const applyResult = await applyFixPlan(plan, { dryRun: false });
|
|
39
|
+
return { plan, applyResult, cancelled: false };
|
|
40
|
+
}
|
|
41
|
+
async function confirm(question) {
|
|
42
|
+
if (!input.isTTY || !output.isTTY) {
|
|
43
|
+
process.stderr.write("Error: confirmation required but stdin is not a TTY. Re-run with --yes or --dry-run.\n");
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
const rl = readline.createInterface({ input, output });
|
|
47
|
+
try {
|
|
48
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
49
|
+
return /^y(es)?$/i.test(answer.trim());
|
|
50
|
+
}
|
|
51
|
+
finally {
|
|
52
|
+
rl.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { AgentId } from "../../types/index.js";
|
|
2
|
+
export type FixKind = "append-ignore-pattern";
|
|
3
|
+
/** Allowlisted relative paths fix may create or modify. */
|
|
4
|
+
export declare const FIX_PATH_ALLOWLIST: readonly [".cursorignore"];
|
|
5
|
+
export type FixAllowlistedPath = (typeof FIX_PATH_ALLOWLIST)[number];
|
|
6
|
+
export interface FixAction {
|
|
7
|
+
id: string;
|
|
8
|
+
kind: FixKind;
|
|
9
|
+
agent: AgentId;
|
|
10
|
+
targetRelativePath: FixAllowlistedPath;
|
|
11
|
+
/** Ignore pattern to ensure is present (gitignore-style). */
|
|
12
|
+
pattern: string;
|
|
13
|
+
/** Evidence path this pattern is meant to exclude. */
|
|
14
|
+
evidencePath: string;
|
|
15
|
+
findingIds: string[];
|
|
16
|
+
description: string;
|
|
17
|
+
}
|
|
18
|
+
export interface SkippedFix {
|
|
19
|
+
findingId: string;
|
|
20
|
+
ruleId: string;
|
|
21
|
+
reason: string;
|
|
22
|
+
}
|
|
23
|
+
export interface FixPlan {
|
|
24
|
+
root: string;
|
|
25
|
+
actions: FixAction[];
|
|
26
|
+
skipped: SkippedFix[];
|
|
27
|
+
}
|
|
28
|
+
export interface FixApplyResult {
|
|
29
|
+
plan: FixPlan;
|
|
30
|
+
dryRun: boolean;
|
|
31
|
+
writtenFiles: string[];
|
|
32
|
+
/** Relative paths that would be / were written */
|
|
33
|
+
changedFiles: string[];
|
|
34
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FixAction } from "../types.js";
|
|
2
|
+
export interface CursorignoreWritePreview {
|
|
3
|
+
targetRelativePath: ".cursorignore";
|
|
4
|
+
patternsToAdd: string[];
|
|
5
|
+
before: string;
|
|
6
|
+
after: string;
|
|
7
|
+
/** Unified-diff-like preview (simplified). */
|
|
8
|
+
preview: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function buildCursorignoreContent(currentContent: string | null, patternsToAdd: string[]): string;
|
|
11
|
+
export declare function previewCursorignoreActions(currentContent: string | null, actions: FixAction[]): CursorignoreWritePreview | null;
|
|
12
|
+
export declare function readCursorignore(root: string): Promise<string | null>;
|
|
13
|
+
export declare function writeCursorignore(root: string, content: string): Promise<void>;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { readTextFile } from "../../../utils/fs.js";
|
|
4
|
+
import { missingPatternsForCursorignore } from "../plan.js";
|
|
5
|
+
const MAX_BYTES = 512 * 1024;
|
|
6
|
+
const BANNER = "# Added by AgentDoctor";
|
|
7
|
+
export function buildCursorignoreContent(currentContent, patternsToAdd) {
|
|
8
|
+
if (patternsToAdd.length === 0) {
|
|
9
|
+
return currentContent ?? "";
|
|
10
|
+
}
|
|
11
|
+
const base = currentContent ?? "";
|
|
12
|
+
const trimmed = base.replace(/\s+$/, "");
|
|
13
|
+
const block = [BANNER, ...patternsToAdd].join("\n");
|
|
14
|
+
if (trimmed.length === 0) {
|
|
15
|
+
return `${block}\n`;
|
|
16
|
+
}
|
|
17
|
+
return `${trimmed}\n\n${block}\n`;
|
|
18
|
+
}
|
|
19
|
+
export function previewCursorignoreActions(currentContent, actions) {
|
|
20
|
+
const cursorActions = actions.filter((a) => a.agent === "cursor" && a.targetRelativePath === ".cursorignore");
|
|
21
|
+
if (cursorActions.length === 0) {
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
const requested = [...new Set(cursorActions.map((a) => a.pattern))];
|
|
25
|
+
const patternsToAdd = missingPatternsForCursorignore(currentContent, requested);
|
|
26
|
+
if (patternsToAdd.length === 0) {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
const before = currentContent ?? "";
|
|
30
|
+
const after = buildCursorignoreContent(currentContent, patternsToAdd);
|
|
31
|
+
const preview = formatSimpleDiff(".cursorignore", before, after);
|
|
32
|
+
return {
|
|
33
|
+
targetRelativePath: ".cursorignore",
|
|
34
|
+
patternsToAdd,
|
|
35
|
+
before,
|
|
36
|
+
after,
|
|
37
|
+
preview,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function readCursorignore(root) {
|
|
41
|
+
return readTextFile(path.join(root, ".cursorignore"), MAX_BYTES);
|
|
42
|
+
}
|
|
43
|
+
export async function writeCursorignore(root, content) {
|
|
44
|
+
const target = path.join(root, ".cursorignore");
|
|
45
|
+
const dir = path.dirname(target);
|
|
46
|
+
await fs.mkdir(dir, { recursive: true });
|
|
47
|
+
const tmp = path.join(dir, `.cursorignore.agentdoctor.${process.pid}.tmp`);
|
|
48
|
+
await fs.writeFile(tmp, content, "utf8");
|
|
49
|
+
await fs.rename(tmp, target);
|
|
50
|
+
}
|
|
51
|
+
function formatSimpleDiff(fileLabel, before, after) {
|
|
52
|
+
const beforeLines = before.length === 0 ? [] : before.split(/\r?\n/);
|
|
53
|
+
const afterLines = after.split(/\r?\n/);
|
|
54
|
+
const lines = [];
|
|
55
|
+
lines.push(`--- a/${fileLabel}`);
|
|
56
|
+
lines.push(`+++ b/${fileLabel}`);
|
|
57
|
+
// Show only newly added trailing block for clarity
|
|
58
|
+
const addedStart = beforeLines.length === 0 ? 0 : beforeLines.length;
|
|
59
|
+
for (let i = addedStart; i < afterLines.length; i++) {
|
|
60
|
+
const line = afterLines[i];
|
|
61
|
+
if (line === undefined) {
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
// Skip pure trailing empty presentation noise except keep structure
|
|
65
|
+
lines.push(`+${line}`);
|
|
66
|
+
}
|
|
67
|
+
if (before.length === 0) {
|
|
68
|
+
lines.splice(2, 0, "@@ new file @@");
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
lines.splice(2, 0, "@@ append @@");
|
|
72
|
+
}
|
|
73
|
+
return lines.join("\n");
|
|
74
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isSampleOrTestPath, isSourceNamedArtifactCollision } from "../path-kind.js";
|
|
1
2
|
/** Directories commonly generated/build-related. vendor/ is ecosystem-aware. */
|
|
2
3
|
const GENERATED = [
|
|
3
4
|
{ name: "dist", label: "build output" },
|
|
@@ -34,6 +35,12 @@ export const generatedDirectoryRule = {
|
|
|
34
35
|
for (const entry of GENERATED) {
|
|
35
36
|
const matches = context.discovery.directoriesSkipped.filter((dir) => isGeneratedPath(dir, entry.name));
|
|
36
37
|
for (const relativePath of matches) {
|
|
38
|
+
if (isSampleOrTestPath(relativePath)) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
if (isSourceNamedArtifactCollision(relativePath)) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
37
44
|
if (isExcluded(context, relativePath)) {
|
|
38
45
|
continue;
|
|
39
46
|
}
|
|
@@ -57,6 +64,9 @@ export const generatedDirectoryRule = {
|
|
|
57
64
|
!context.repository.packageManagers.includes("composer") &&
|
|
58
65
|
context.repository.primaryLanguage !== "php") {
|
|
59
66
|
for (const relativePath of vendorPaths) {
|
|
67
|
+
if (isSampleOrTestPath(relativePath)) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
60
70
|
if (isExcluded(context, relativePath)) {
|
|
61
71
|
continue;
|
|
62
72
|
}
|
|
@@ -12,6 +12,73 @@ const INSTRUCTION_KINDS = new Set([
|
|
|
12
12
|
]);
|
|
13
13
|
/** Extensions accepted for bare filenames (no `/`) — precision over recall. */
|
|
14
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;
|
|
15
|
+
/** Host-like first segments for Go/npm module paths (`github.com/…`, `golang.org/…`). */
|
|
16
|
+
const MODULE_HOST_SEGMENT = /^(?:[a-z0-9-]+\.)+(?:com|org|net|io|dev|ai|app|cloud|co|edu|gov|info|me|tv|xyz|in|to|cc)$/i;
|
|
17
|
+
/** Control-flow / language tokens that appear as `a/b` in prose, not filesystem paths. */
|
|
18
|
+
const CODE_SLASH_TOKEN = /^(?:try\/finally|if\/else|for\/of|for\/in|async\/await|do\/while)$/i;
|
|
19
|
+
/**
|
|
20
|
+
* First path segment of Go standard-library import paths (`io/ioutil`, `net/http`).
|
|
21
|
+
* Kept narrow so `src/utils`-style repo paths still validate.
|
|
22
|
+
*/
|
|
23
|
+
const GO_STDLIB_ROOTS = new Set([
|
|
24
|
+
"archive",
|
|
25
|
+
"bufio",
|
|
26
|
+
"bytes",
|
|
27
|
+
"cmp",
|
|
28
|
+
"compress",
|
|
29
|
+
"container",
|
|
30
|
+
"context",
|
|
31
|
+
"crypto",
|
|
32
|
+
"database",
|
|
33
|
+
"debug",
|
|
34
|
+
"embed",
|
|
35
|
+
"encoding",
|
|
36
|
+
"errors",
|
|
37
|
+
"expvar",
|
|
38
|
+
"flag",
|
|
39
|
+
"fmt",
|
|
40
|
+
"go",
|
|
41
|
+
"hash",
|
|
42
|
+
"html",
|
|
43
|
+
"image",
|
|
44
|
+
"index",
|
|
45
|
+
"io",
|
|
46
|
+
"log",
|
|
47
|
+
"maps",
|
|
48
|
+
"math",
|
|
49
|
+
"mime",
|
|
50
|
+
"net",
|
|
51
|
+
"os",
|
|
52
|
+
"path",
|
|
53
|
+
"plugin",
|
|
54
|
+
"reflect",
|
|
55
|
+
"regexp",
|
|
56
|
+
"runtime",
|
|
57
|
+
"slices",
|
|
58
|
+
"structs",
|
|
59
|
+
"sync",
|
|
60
|
+
"syscall",
|
|
61
|
+
"testing",
|
|
62
|
+
"text",
|
|
63
|
+
"time",
|
|
64
|
+
"unicode",
|
|
65
|
+
"unique",
|
|
66
|
+
"unsafe",
|
|
67
|
+
"weak",
|
|
68
|
+
]);
|
|
69
|
+
/** Optional build-output roots often documented before `npm run build`. */
|
|
70
|
+
const OPTIONAL_BUILD_ROOTS = new Set(["dist", "build", "out", "target", ".next", "coverage"]);
|
|
71
|
+
function isGoStdImportPath(normalized) {
|
|
72
|
+
const parts = normalized.split("/");
|
|
73
|
+
if (parts.length < 2 || parts.length > 4) {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
const root = parts[0] ?? "";
|
|
77
|
+
if (!GO_STDLIB_ROOTS.has(root)) {
|
|
78
|
+
return false;
|
|
79
|
+
}
|
|
80
|
+
return parts.every((part) => /^[a-z][a-z0-9]*$/.test(part));
|
|
81
|
+
}
|
|
15
82
|
function stripAnchor(value) {
|
|
16
83
|
return value.split("#")[0] ?? value;
|
|
17
84
|
}
|
|
@@ -42,12 +109,34 @@ export function isLikelyLocalPathReference(value) {
|
|
|
42
109
|
// CSS class / id-like bare selectors: `.content`, `.button`
|
|
43
110
|
if (/^\.[A-Za-z_][\w-]*$/.test(value))
|
|
44
111
|
return false;
|
|
112
|
+
// npm scoped packages: `@scope/name`
|
|
113
|
+
if (/^@[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+/.test(value))
|
|
114
|
+
return false;
|
|
115
|
+
// Glob / brace patterns are not concrete paths
|
|
116
|
+
if (/[*?[\]{}]/.test(value))
|
|
117
|
+
return false;
|
|
118
|
+
// Language tokens that look like paths (`try/finally`)
|
|
119
|
+
if (CODE_SLASH_TOKEN.test(value))
|
|
120
|
+
return false;
|
|
121
|
+
const withoutDotSlash = value.replace(/^\.\//, "");
|
|
122
|
+
const trimmedDir = withoutDotSlash.replace(/\/+$/, "");
|
|
123
|
+
// Documented build outputs often missing until a local build
|
|
124
|
+
if (OPTIONAL_BUILD_ROOTS.has(trimmedDir))
|
|
125
|
+
return false;
|
|
45
126
|
if (value.includes("/")) {
|
|
46
|
-
// Reject empty segments like `foo//bar` while allowing trailing slash dirs
|
|
47
127
|
const normalized = value.replace(/\/+$/, "");
|
|
48
128
|
if (!normalized || normalized.split("/").some((part) => part === "")) {
|
|
49
129
|
return false;
|
|
50
130
|
}
|
|
131
|
+
const firstSegment = normalized.split("/")[0] ?? "";
|
|
132
|
+
// Go/npm module imports: `github.com/foo/bar`, `golang.org/x/…`
|
|
133
|
+
if (MODULE_HOST_SEGMENT.test(firstSegment)) {
|
|
134
|
+
return false;
|
|
135
|
+
}
|
|
136
|
+
// Go stdlib: `io/ioutil`, `net/http`, `path/filepath`
|
|
137
|
+
if (isGoStdImportPath(normalized)) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
51
140
|
return true;
|
|
52
141
|
}
|
|
53
142
|
// Bare filename: require a known file extension (not `.content`-style tokens)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared path classification for rule precision.
|
|
3
|
+
* Paths under intentional sample/test/demo trees are not production secrets for agent readiness.
|
|
4
|
+
*/
|
|
5
|
+
/**
|
|
6
|
+
* True when any path segment marks fixture, test, example, or sample material.
|
|
7
|
+
* Uses POSIX relative paths (discovery output).
|
|
8
|
+
*/
|
|
9
|
+
export declare function isSampleOrTestPath(relativePath: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* True for paths like `scripts/build` or `internal/build` that are source trees
|
|
12
|
+
* named after a common artifact directory, not generated output.
|
|
13
|
+
*/
|
|
14
|
+
export declare function isSourceNamedArtifactCollision(relativePath: string): boolean;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared path classification for rule precision.
|
|
3
|
+
* Paths under intentional sample/test/demo trees are not production secrets for agent readiness.
|
|
4
|
+
*/
|
|
5
|
+
const SAMPLE_DIRECTORY_NAMES = new Set([
|
|
6
|
+
"example",
|
|
7
|
+
"examples",
|
|
8
|
+
"fixture",
|
|
9
|
+
"fixtures",
|
|
10
|
+
"__fixtures__",
|
|
11
|
+
"integration",
|
|
12
|
+
"integrations",
|
|
13
|
+
"mock",
|
|
14
|
+
"mocks",
|
|
15
|
+
"__mocks__",
|
|
16
|
+
"sample",
|
|
17
|
+
"samples",
|
|
18
|
+
"test",
|
|
19
|
+
"tests",
|
|
20
|
+
"__tests__",
|
|
21
|
+
"testdata",
|
|
22
|
+
"test_data",
|
|
23
|
+
]);
|
|
24
|
+
/** Artifact dir names that commonly collide with source packages (`scripts/build`, `internal/build`). */
|
|
25
|
+
const ARTIFACT_NAMES_WITH_SOURCE_COLLISIONS = new Set(["build", "target"]);
|
|
26
|
+
/**
|
|
27
|
+
* Parent dirs that host source code packages/modules, not generated output trees.
|
|
28
|
+
* `packages/foo/dist` is still treated as generated (parent is the package name).
|
|
29
|
+
*/
|
|
30
|
+
const SOURCE_CODE_PARENT_DIRS = new Set([
|
|
31
|
+
"bin",
|
|
32
|
+
"cmd",
|
|
33
|
+
"internal",
|
|
34
|
+
"lib",
|
|
35
|
+
"pkg",
|
|
36
|
+
"script",
|
|
37
|
+
"scripts",
|
|
38
|
+
"src",
|
|
39
|
+
]);
|
|
40
|
+
/**
|
|
41
|
+
* True when any path segment marks fixture, test, example, or sample material.
|
|
42
|
+
* Uses POSIX relative paths (discovery output).
|
|
43
|
+
*/
|
|
44
|
+
export function isSampleOrTestPath(relativePath) {
|
|
45
|
+
if (!relativePath) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
const normalized = relativePath.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
49
|
+
if (!normalized) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return normalized.split("/").some((segment) => SAMPLE_DIRECTORY_NAMES.has(segment.toLowerCase()));
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* True for paths like `scripts/build` or `internal/build` that are source trees
|
|
56
|
+
* named after a common artifact directory, not generated output.
|
|
57
|
+
*/
|
|
58
|
+
export function isSourceNamedArtifactCollision(relativePath) {
|
|
59
|
+
if (!relativePath) {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
const parts = relativePath
|
|
63
|
+
.replace(/\\/g, "/")
|
|
64
|
+
.replace(/^\.\//, "")
|
|
65
|
+
.replace(/\/+$/, "")
|
|
66
|
+
.split("/")
|
|
67
|
+
.filter(Boolean);
|
|
68
|
+
if (parts.length < 2) {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const leaf = parts[parts.length - 1].toLowerCase();
|
|
72
|
+
if (!ARTIFACT_NAMES_WITH_SOURCE_COLLISIONS.has(leaf)) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
const parent = parts[parts.length - 2].toLowerCase();
|
|
76
|
+
return SOURCE_CODE_PARENT_DIRS.has(parent);
|
|
77
|
+
}
|