@promptci/core 0.0.1 → 0.1.0
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/LICENSE +201 -201
- package/README.md +29 -30
- package/dist/agent-practices.d.ts +2 -0
- package/dist/agent-practices.d.ts.map +1 -1
- package/dist/agent-practices.js +91 -51
- package/dist/agent-practices.js.map +1 -1
- package/dist/ai-config.d.ts +139 -0
- package/dist/ai-config.d.ts.map +1 -0
- package/dist/ai-config.js +523 -0
- package/dist/ai-config.js.map +1 -0
- package/dist/baseline.d.ts +17 -1
- package/dist/baseline.d.ts.map +1 -1
- package/dist/baseline.js +5 -1
- package/dist/baseline.js.map +1 -1
- package/dist/buried-critical.d.ts +17 -0
- package/dist/buried-critical.d.ts.map +1 -0
- package/dist/buried-critical.js +112 -0
- package/dist/buried-critical.js.map +1 -0
- package/dist/canonical-owner.d.ts.map +1 -1
- package/dist/canonical-owner.js +18 -8
- package/dist/canonical-owner.js.map +1 -1
- package/dist/command-validity.d.ts.map +1 -1
- package/dist/command-validity.js +53 -74
- package/dist/command-validity.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +1 -42
- package/dist/conflicts.js.map +1 -1
- package/dist/context-analysis.d.ts.map +1 -1
- package/dist/context-analysis.js +13 -2
- package/dist/context-analysis.js.map +1 -1
- package/dist/context-bloat.d.ts +19 -0
- package/dist/context-bloat.d.ts.map +1 -1
- package/dist/context-bloat.js +82 -9
- package/dist/context-bloat.js.map +1 -1
- package/dist/context-optimizer.d.ts.map +1 -1
- package/dist/context-optimizer.js +15 -54
- package/dist/context-optimizer.js.map +1 -1
- package/dist/copilot-instructions-detector.d.ts +24 -0
- package/dist/copilot-instructions-detector.d.ts.map +1 -0
- package/dist/copilot-instructions-detector.js +145 -0
- package/dist/copilot-instructions-detector.js.map +1 -0
- package/dist/cursor-rules-detector.d.ts +17 -0
- package/dist/cursor-rules-detector.d.ts.map +1 -0
- package/dist/cursor-rules-detector.js +110 -0
- package/dist/cursor-rules-detector.js.map +1 -0
- package/dist/custom-rules.d.ts +52 -0
- package/dist/custom-rules.d.ts.map +1 -0
- package/dist/custom-rules.js +298 -0
- package/dist/custom-rules.js.map +1 -0
- package/dist/dead-references.js +11 -9
- package/dist/dead-references.js.map +1 -1
- package/dist/detectors.d.ts +1 -3
- package/dist/detectors.d.ts.map +1 -1
- package/dist/detectors.js +46 -53
- package/dist/detectors.js.map +1 -1
- package/dist/duplicates.d.ts.map +1 -1
- package/dist/duplicates.js +10 -5
- package/dist/duplicates.js.map +1 -1
- package/dist/evidence.d.ts +28 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +38 -0
- package/dist/evidence.js.map +1 -0
- package/dist/fix-engine.d.ts +11 -0
- package/dist/fix-engine.d.ts.map +1 -1
- package/dist/fix-engine.js +88 -71
- package/dist/fix-engine.js.map +1 -1
- package/dist/frameworks/python.d.ts.map +1 -1
- package/dist/frameworks/python.js +5 -2
- package/dist/frameworks/python.js.map +1 -1
- package/dist/health-score.d.ts.map +1 -1
- package/dist/health-score.js +1 -0
- package/dist/health-score.js.map +1 -1
- package/dist/hooks-settings-detector.d.ts +17 -0
- package/dist/hooks-settings-detector.d.ts.map +1 -0
- package/dist/hooks-settings-detector.js +228 -0
- package/dist/hooks-settings-detector.js.map +1 -0
- package/dist/index.d.ts +22 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -6
- package/dist/index.js.map +1 -1
- package/dist/manifest-consistency.d.ts +6 -0
- package/dist/manifest-consistency.d.ts.map +1 -1
- package/dist/manifest-consistency.js +26 -5
- package/dist/manifest-consistency.js.map +1 -1
- package/dist/markdown-fences.d.ts +112 -0
- package/dist/markdown-fences.d.ts.map +1 -0
- package/dist/markdown-fences.js +174 -0
- package/dist/markdown-fences.js.map +1 -0
- package/dist/mcp-config-detector.d.ts +20 -0
- package/dist/mcp-config-detector.d.ts.map +1 -0
- package/dist/mcp-config-detector.js +237 -0
- package/dist/mcp-config-detector.js.map +1 -0
- package/dist/missing-context.d.ts.map +1 -1
- package/dist/missing-context.js +2 -52
- package/dist/missing-context.js.map +1 -1
- package/dist/model-budgets.d.ts +69 -0
- package/dist/model-budgets.d.ts.map +1 -0
- package/dist/model-budgets.js +110 -0
- package/dist/model-budgets.js.map +1 -0
- package/dist/negative-instructions.d.ts +26 -0
- package/dist/negative-instructions.d.ts.map +1 -0
- package/dist/negative-instructions.js +174 -0
- package/dist/negative-instructions.js.map +1 -0
- package/dist/path-containment.d.ts +12 -0
- package/dist/path-containment.d.ts.map +1 -0
- package/dist/path-containment.js +53 -0
- package/dist/path-containment.js.map +1 -0
- package/dist/project-type.d.ts +12 -2
- package/dist/project-type.d.ts.map +1 -1
- package/dist/project-type.js +25 -12
- package/dist/project-type.js.map +1 -1
- package/dist/prompt-cache.d.ts.map +1 -1
- package/dist/prompt-cache.js +26 -19
- package/dist/prompt-cache.js.map +1 -1
- package/dist/promptci-gitignore.d.ts +34 -0
- package/dist/promptci-gitignore.d.ts.map +1 -0
- package/dist/promptci-gitignore.js +42 -0
- package/dist/promptci-gitignore.js.map +1 -0
- package/dist/repo-context.d.ts +27 -0
- package/dist/repo-context.d.ts.map +1 -1
- package/dist/repo-context.js +42 -11
- package/dist/repo-context.js.map +1 -1
- package/dist/report.d.ts +1 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +132 -61
- package/dist/report.js.map +1 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +7 -3
- package/dist/scan.js.map +1 -1
- package/dist/scanner.d.ts +13 -1
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +49 -37
- package/dist/scanner.js.map +1 -1
- package/dist/security-pack.d.ts +20 -0
- package/dist/security-pack.d.ts.map +1 -1
- package/dist/security-pack.js +27 -10
- package/dist/security-pack.js.map +1 -1
- package/dist/skills-detector.d.ts +19 -0
- package/dist/skills-detector.d.ts.map +1 -0
- package/dist/skills-detector.js +203 -0
- package/dist/skills-detector.js.map +1 -0
- package/dist/stale-instructions.d.ts +4 -1
- package/dist/stale-instructions.d.ts.map +1 -1
- package/dist/stale-instructions.js +51 -54
- package/dist/stale-instructions.js.map +1 -1
- package/dist/subagents-detector.d.ts +19 -0
- package/dist/subagents-detector.d.ts.map +1 -0
- package/dist/subagents-detector.js +155 -0
- package/dist/subagents-detector.js.map +1 -0
- package/dist/suppression.d.ts.map +1 -1
- package/dist/suppression.js +11 -45
- package/dist/suppression.js.map +1 -1
- package/dist/types.d.ts +28 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +25 -1
- package/dist/types.js.map +1 -1
- package/dist/vague-guidance.d.ts +15 -2
- package/dist/vague-guidance.d.ts.map +1 -1
- package/dist/vague-guidance.js +42 -40
- package/dist/vague-guidance.js.map +1 -1
- package/dist/windsurf-detector.d.ts +22 -0
- package/dist/windsurf-detector.d.ts.map +1 -0
- package/dist/windsurf-detector.js +87 -0
- package/dist/windsurf-detector.js.map +1 -0
- package/dist/within-section-dedup.d.ts +16 -0
- package/dist/within-section-dedup.d.ts.map +1 -0
- package/dist/within-section-dedup.js +153 -0
- package/dist/within-section-dedup.js.map +1 -0
- package/package.json +11 -8
- package/dist/cheap-model-routing.d.ts +0 -4
- package/dist/cheap-model-routing.d.ts.map +0 -1
- package/dist/cheap-model-routing.js +0 -43
- package/dist/cheap-model-routing.js.map +0 -1
- package/dist/cli-output-filter.d.ts +0 -16
- package/dist/cli-output-filter.d.ts.map +0 -1
- package/dist/cli-output-filter.js +0 -88
- package/dist/cli-output-filter.js.map +0 -1
- package/dist/context-cost.d.ts +0 -4
- package/dist/context-cost.d.ts.map +0 -1
- package/dist/context-cost.js +0 -70
- package/dist/context-cost.js.map +0 -1
- package/dist/context-recommender.d.ts +0 -3
- package/dist/context-recommender.d.ts.map +0 -1
- package/dist/context-recommender.js +0 -329
- package/dist/context-recommender.js.map +0 -1
- package/dist/llm.d.ts +0 -6
- package/dist/llm.d.ts.map +0 -1
- package/dist/llm.js +0 -67
- package/dist/llm.js.map +0 -1
- package/dist/mcp-detector.d.ts +0 -4
- package/dist/mcp-detector.d.ts.map +0 -1
- package/dist/mcp-detector.js +0 -131
- package/dist/mcp-detector.js.map +0 -1
- package/dist/native-tool-preference.d.ts +0 -20
- package/dist/native-tool-preference.d.ts.map +0 -1
- package/dist/native-tool-preference.js +0 -94
- package/dist/native-tool-preference.js.map +0 -1
- package/dist/output-verbosity.d.ts +0 -3
- package/dist/output-verbosity.d.ts.map +0 -1
- package/dist/output-verbosity.js +0 -83
- package/dist/output-verbosity.js.map +0 -1
- package/dist/product-boundary.d.ts +0 -11
- package/dist/product-boundary.d.ts.map +0 -1
- package/dist/product-boundary.js +0 -301
- package/dist/product-boundary.js.map +0 -1
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copilot Instructions Detector (issue #66)
|
|
3
|
+
*
|
|
4
|
+
* Audits GitHub Copilot per-file instruction files (`.github/instructions/**\/*.md`).
|
|
5
|
+
* These use `applyTo:` frontmatter globs to scope which files they apply to —
|
|
6
|
+
* analogous to Cursor's `.mdc` `globs`. This mirrors `detectCursorRules`:
|
|
7
|
+
*
|
|
8
|
+
* - Frontmatter validity: present, closed, structurally sound.
|
|
9
|
+
* - `applyTo:` absent — the file then applies to *every* Copilot request by
|
|
10
|
+
* default, which is often unintentional.
|
|
11
|
+
* - `applyTo:` globs that match zero files in the repo — a scope keyed to a
|
|
12
|
+
* path that no longer exists never fires.
|
|
13
|
+
* - `applyTo:` globs that match only ignored / build-output files — informational.
|
|
14
|
+
*
|
|
15
|
+
* The legacy single-file `.github/copilot-instructions.md` is always-on and has
|
|
16
|
+
* no `applyTo` support, so discovery (see AI_CONFIG_GLOBS) scopes to
|
|
17
|
+
* `.github/instructions/` only and never reaches this detector.
|
|
18
|
+
*
|
|
19
|
+
* Deterministic and offline; findings are cautiously worded.
|
|
20
|
+
*/
|
|
21
|
+
import micromatch from 'micromatch';
|
|
22
|
+
import { parseFrontmatter, readTextWithinRoot, listFiles, shortHash, asStringList, withScannerPaths, aiConfigIssue as base, frontmatterStructureIssues, } from './ai-config.js';
|
|
23
|
+
const SURFACE = {
|
|
24
|
+
idPrefix: 'copilot',
|
|
25
|
+
noun: 'Copilot instructions file',
|
|
26
|
+
why: 'A `.instructions.md` file uses an `applyTo:` frontmatter glob to control which files it applies to.',
|
|
27
|
+
recommendation: 'Add a frontmatter block with an `applyTo:` glob (use `applyTo: "**"` to apply to every request).',
|
|
28
|
+
// A frontmatter-less instructions file still loads (Copilot treats it as
|
|
29
|
+
// apply-to-everything), so this is softer news than a skill that cannot register.
|
|
30
|
+
noFrontmatterTitle: 'Copilot instructions file is missing frontmatter',
|
|
31
|
+
noFrontmatterSeverity: 'warning',
|
|
32
|
+
noFrontmatterConfidence: 0.7,
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* "Does this glob match any real file" excludes only dependency/VCS trees — a
|
|
36
|
+
* scope may legitimately target `dist/` or a source dir named `build/`.
|
|
37
|
+
*/
|
|
38
|
+
const GLOB_TEST_IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
39
|
+
/**
|
|
40
|
+
* Trees that are ignored or build output: a glob matching only these is scoped
|
|
41
|
+
* to generated/vendored files, which is worth an info but not a warning.
|
|
42
|
+
*/
|
|
43
|
+
const IGNORED_TREES = [
|
|
44
|
+
'**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**',
|
|
45
|
+
'**/.next/**', '**/out/**', '**/coverage/**', '**/worktrees/**', '**/.worktrees/**',
|
|
46
|
+
];
|
|
47
|
+
function id(kind, key) {
|
|
48
|
+
return `ai-config-copilot-${kind}-${shortHash(key)}`;
|
|
49
|
+
}
|
|
50
|
+
/** A glob we can meaningfully test for zero matches (skip negations/vars/placeholders). */
|
|
51
|
+
function isTestableGlob(glob) {
|
|
52
|
+
const g = glob.trim();
|
|
53
|
+
if (!g)
|
|
54
|
+
return false;
|
|
55
|
+
if (g.startsWith('!'))
|
|
56
|
+
return false;
|
|
57
|
+
if (g.includes('$') || g.includes('<') || g.includes('>'))
|
|
58
|
+
return false;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
61
|
+
/** A glob of `**` (or `** slash *`) means "every file" — always valid, never dead. */
|
|
62
|
+
function matchesEverything(glob) {
|
|
63
|
+
const g = glob.trim();
|
|
64
|
+
return g === '**' || g === '**/*';
|
|
65
|
+
}
|
|
66
|
+
export function detectCopilotInstructions(context) {
|
|
67
|
+
const issues = [];
|
|
68
|
+
const files = context.aiConfig.copilotInstructions;
|
|
69
|
+
// TWO repo walks shared across every glob test, built lazily so repos with no
|
|
70
|
+
// testable globs never pay for them. `broad` includes build/vendored output
|
|
71
|
+
// (only node_modules/.git excluded); `source` excludes ignored trees, so a
|
|
72
|
+
// glob that matches `broad` but not `source` hits only ignored files.
|
|
73
|
+
let broad = null;
|
|
74
|
+
let source = null;
|
|
75
|
+
const matchesBroad = (patterns) => {
|
|
76
|
+
broad ??= listFiles(context.repoRoot, ['**/*'], GLOB_TEST_IGNORE);
|
|
77
|
+
return micromatch.some(broad, patterns, { dot: true });
|
|
78
|
+
};
|
|
79
|
+
const matchesSource = (patterns) => {
|
|
80
|
+
source ??= listFiles(context.repoRoot, ['**/*'], IGNORED_TREES);
|
|
81
|
+
return micromatch.some(source, patterns, { dot: true });
|
|
82
|
+
};
|
|
83
|
+
for (const filePath of files) {
|
|
84
|
+
const content = readTextWithinRoot(context.repoRoot, filePath);
|
|
85
|
+
if (content === undefined)
|
|
86
|
+
continue;
|
|
87
|
+
const fm = parseFrontmatter(content);
|
|
88
|
+
issues.push(...frontmatterStructureIssues(filePath, content, fm, SURFACE));
|
|
89
|
+
if (!fm.present)
|
|
90
|
+
continue;
|
|
91
|
+
const applyTo = asStringList(fm.data.applyTo);
|
|
92
|
+
// No `applyTo` at all → the file applies to every request.
|
|
93
|
+
if (applyTo.length === 0) {
|
|
94
|
+
issues.push(base({
|
|
95
|
+
id: id('no-applyto', filePath),
|
|
96
|
+
title: 'Copilot instructions file has no applyTo glob',
|
|
97
|
+
summary: `${filePath} defines no \`applyTo\` glob, so Copilot applies it to every request by default — often unintentional.`,
|
|
98
|
+
filePaths: [filePath],
|
|
99
|
+
locations: [{ filePath, startLine: 1, endLine: fm.fenceEndLine > 0 ? fm.fenceEndLine : 1 }],
|
|
100
|
+
evidence: [`Keys present: ${Object.keys(fm.data).join(', ') || '(none)'}`],
|
|
101
|
+
recommendation: 'Add an `applyTo:` glob scoping the file to the paths it is about, or set `applyTo: "**"` to make the always-on scope explicit.',
|
|
102
|
+
confidence: 0.7,
|
|
103
|
+
}));
|
|
104
|
+
continue; // no globs to test
|
|
105
|
+
}
|
|
106
|
+
const globLine = fm.keyLines.applyTo ?? 1;
|
|
107
|
+
for (const glob of applyTo) {
|
|
108
|
+
if (!isTestableGlob(glob) || matchesEverything(glob))
|
|
109
|
+
continue;
|
|
110
|
+
const normalized = glob.replace(/^\//, '');
|
|
111
|
+
// A slashless pattern like `*.ts` is intended as "any depth"; fast-glob
|
|
112
|
+
// only matches it at the root, so also try a `**/`-anchored variant
|
|
113
|
+
// before declaring the glob dead, to avoid false positives.
|
|
114
|
+
const candidates = normalized.includes('/') ? [normalized] : [normalized, `**/${normalized}`];
|
|
115
|
+
if (!matchesBroad(candidates)) {
|
|
116
|
+
issues.push(base({
|
|
117
|
+
id: id('dead-glob', `${filePath}|${glob}`),
|
|
118
|
+
title: 'Copilot instructions applyTo glob matches no files',
|
|
119
|
+
summary: `${filePath}: the \`applyTo\` glob \`${glob}\` matches no files in the repository, so this instructions file never applies.`,
|
|
120
|
+
filePaths: [filePath],
|
|
121
|
+
locations: [{ filePath, startLine: globLine, endLine: globLine }],
|
|
122
|
+
evidence: [`Glob: ${glob}`],
|
|
123
|
+
recommendation: 'Update the glob to match real paths, or remove the file if the code it targeted is gone.',
|
|
124
|
+
confidence: 0.6,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
else if (!matchesSource(candidates)) {
|
|
128
|
+
issues.push(base({
|
|
129
|
+
id: id('ignored-glob', `${filePath}|${glob}`),
|
|
130
|
+
severity: 'info',
|
|
131
|
+
title: 'Copilot instructions applyTo glob matches only ignored files',
|
|
132
|
+
summary: `${filePath}: the \`applyTo\` glob \`${glob}\` matches only ignored or build-output files, so it rarely applies to files you edit.`,
|
|
133
|
+
filePaths: [filePath],
|
|
134
|
+
locations: [{ filePath, startLine: globLine, endLine: globLine }],
|
|
135
|
+
evidence: [`Glob: ${glob}`],
|
|
136
|
+
recommendation: 'Point the glob at source paths, or remove the file if it was meant to target generated output.',
|
|
137
|
+
confidence: 0.55,
|
|
138
|
+
}));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Scanner-form paths so inline suppressions can match (see withScannerPaths).
|
|
143
|
+
return withScannerPaths(context.repoRoot, issues);
|
|
144
|
+
}
|
|
145
|
+
//# sourceMappingURL=copilot-instructions-detector.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"copilot-instructions-detector.js","sourceRoot":"","sources":["../src/copilot-instructions-detector.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Rules Detector (issue #23)
|
|
3
|
+
*
|
|
4
|
+
* Audits Cursor project rules (`.cursor/rules/**\/*.mdc`):
|
|
5
|
+
*
|
|
6
|
+
* - Frontmatter validity: present, closed, structurally sound.
|
|
7
|
+
* - `globs` that match zero files in the repo — an Auto-Attached rule keyed to
|
|
8
|
+
* a path that no longer exists never fires.
|
|
9
|
+
* - Rules with no trigger at all (no `description`, no `globs`, not
|
|
10
|
+
* `alwaysApply`) which can never activate.
|
|
11
|
+
*
|
|
12
|
+
* Deterministic and offline; findings are cautiously worded.
|
|
13
|
+
*/
|
|
14
|
+
import type { RepoContext } from './repo-context.js';
|
|
15
|
+
import type { PromptCiIssue } from './types.js';
|
|
16
|
+
export declare function detectCursorRules(context: RepoContext): PromptCiIssue[];
|
|
17
|
+
//# sourceMappingURL=cursor-rules-detector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cursor-rules-detector.d.ts","sourceRoot":"","sources":["../src/cursor-rules-detector.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAGH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AACrD,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AA8ChD,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,WAAW,GAAG,aAAa,EAAE,CAkEvE"}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cursor Rules Detector (issue #23)
|
|
3
|
+
*
|
|
4
|
+
* Audits Cursor project rules (`.cursor/rules/**\/*.mdc`):
|
|
5
|
+
*
|
|
6
|
+
* - Frontmatter validity: present, closed, structurally sound.
|
|
7
|
+
* - `globs` that match zero files in the repo — an Auto-Attached rule keyed to
|
|
8
|
+
* a path that no longer exists never fires.
|
|
9
|
+
* - Rules with no trigger at all (no `description`, no `globs`, not
|
|
10
|
+
* `alwaysApply`) which can never activate.
|
|
11
|
+
*
|
|
12
|
+
* Deterministic and offline; findings are cautiously worded.
|
|
13
|
+
*/
|
|
14
|
+
import micromatch from 'micromatch';
|
|
15
|
+
import { parseFrontmatter, readTextWithinRoot, listFiles, shortHash, asStringList, withScannerPaths, aiConfigIssue as base, frontmatterStructureIssues, } from './ai-config.js';
|
|
16
|
+
const SURFACE = {
|
|
17
|
+
idPrefix: 'cursor',
|
|
18
|
+
noun: 'Cursor rule',
|
|
19
|
+
why: 'A .mdc rule needs metadata (`description`, `globs`, or `alwaysApply`) to control when it applies.',
|
|
20
|
+
recommendation: 'Add a frontmatter block describing the rule and its trigger (globs or alwaysApply).',
|
|
21
|
+
// A metadata-less .mdc still loads as a manual rule, so this is softer news
|
|
22
|
+
// than a skill/subagent that cannot register at all.
|
|
23
|
+
noFrontmatterTitle: 'Cursor rule is missing frontmatter',
|
|
24
|
+
noFrontmatterSeverity: 'warning',
|
|
25
|
+
noFrontmatterConfidence: 0.7,
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Glob-existence testing needs different visibility than config discovery: a
|
|
29
|
+
* rule may legitimately target `dist/` output or a *source* directory named
|
|
30
|
+
* `build/`, so only dependency/VCS trees are excluded when asking "does this
|
|
31
|
+
* glob match anything".
|
|
32
|
+
*/
|
|
33
|
+
const GLOB_TEST_IGNORE = ['**/node_modules/**', '**/.git/**'];
|
|
34
|
+
function id(kind, key) {
|
|
35
|
+
return `ai-config-cursor-${kind}-${shortHash(key)}`;
|
|
36
|
+
}
|
|
37
|
+
/** A glob we can meaningfully test for zero matches (skip negations/vars/placeholders). */
|
|
38
|
+
function isTestableGlob(glob) {
|
|
39
|
+
const g = glob.trim();
|
|
40
|
+
if (!g)
|
|
41
|
+
return false;
|
|
42
|
+
if (g.startsWith('!'))
|
|
43
|
+
return false;
|
|
44
|
+
if (g.includes('$') || g.includes('<') || g.includes('>'))
|
|
45
|
+
return false;
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
export function detectCursorRules(context) {
|
|
49
|
+
const issues = [];
|
|
50
|
+
const files = context.aiConfig.cursorRules;
|
|
51
|
+
// ONE repo walk shared by every dead-glob test, built lazily so repos
|
|
52
|
+
// without testable globs never pay for it. Testing each glob with its own
|
|
53
|
+
// fg.sync call would walk the full tree once per glob per rule.
|
|
54
|
+
let repoListing = null;
|
|
55
|
+
const globMatchesAnything = (patterns) => {
|
|
56
|
+
repoListing ??= listFiles(context.repoRoot, ['**/*'], GLOB_TEST_IGNORE);
|
|
57
|
+
return micromatch.some(repoListing, patterns, { dot: true });
|
|
58
|
+
};
|
|
59
|
+
for (const filePath of files) {
|
|
60
|
+
const content = readTextWithinRoot(context.repoRoot, filePath);
|
|
61
|
+
if (content === undefined)
|
|
62
|
+
continue;
|
|
63
|
+
const fm = parseFrontmatter(content);
|
|
64
|
+
issues.push(...frontmatterStructureIssues(filePath, content, fm, SURFACE));
|
|
65
|
+
if (!fm.present)
|
|
66
|
+
continue;
|
|
67
|
+
const description = typeof fm.data.description === 'string' ? fm.data.description.trim() : '';
|
|
68
|
+
const globs = asStringList(fm.data.globs);
|
|
69
|
+
const alwaysApply = fm.data.alwaysApply === true ||
|
|
70
|
+
(typeof fm.data.alwaysApply === 'string' && fm.data.alwaysApply.trim().toLowerCase() === 'true');
|
|
71
|
+
// A rule with no trigger can never activate.
|
|
72
|
+
if (!description && globs.length === 0 && !alwaysApply) {
|
|
73
|
+
issues.push(base({
|
|
74
|
+
id: id('no-trigger', filePath),
|
|
75
|
+
title: 'Cursor rule has no trigger',
|
|
76
|
+
summary: `${filePath} defines no \`description\`, no \`globs\`, and \`alwaysApply\` is not set, so Cursor has no signal for when to apply it.`,
|
|
77
|
+
filePaths: [filePath],
|
|
78
|
+
locations: [{ filePath, startLine: 1, endLine: fm.fenceEndLine > 0 ? fm.fenceEndLine : 1 }],
|
|
79
|
+
evidence: [`Keys present: ${Object.keys(fm.data).join(', ') || '(none)'}`],
|
|
80
|
+
recommendation: 'Add a `description` (agent-requested), `globs` (auto-attached), or `alwaysApply: true`.',
|
|
81
|
+
confidence: 0.7,
|
|
82
|
+
}));
|
|
83
|
+
}
|
|
84
|
+
// Globs that match zero files.
|
|
85
|
+
for (const glob of globs) {
|
|
86
|
+
if (!isTestableGlob(glob))
|
|
87
|
+
continue;
|
|
88
|
+
const normalized = glob.replace(/^\//, '');
|
|
89
|
+
// Cursor treats a slashless pattern like `*.tsx` as "any depth", but
|
|
90
|
+
// fast-glob only matches it at the root — so also try a `**/`-anchored
|
|
91
|
+
// variant before declaring the glob dead, to avoid false positives.
|
|
92
|
+
const candidates = normalized.includes('/') ? [normalized] : [normalized, `**/${normalized}`];
|
|
93
|
+
if (!globMatchesAnything(candidates)) {
|
|
94
|
+
issues.push(base({
|
|
95
|
+
id: id('dead-glob', `${filePath}|${glob}`),
|
|
96
|
+
title: 'Cursor rule glob matches no files',
|
|
97
|
+
summary: `${filePath}: the \`globs\` pattern \`${glob}\` matches no files in the repository, so this auto-attached rule never fires.`,
|
|
98
|
+
filePaths: [filePath],
|
|
99
|
+
locations: [{ filePath, startLine: fm.keyLines.globs ?? 1, endLine: fm.keyLines.globs ?? 1 }],
|
|
100
|
+
evidence: [`Glob: ${glob}`],
|
|
101
|
+
recommendation: 'Update the glob to match real paths, or remove the rule if the code it targeted is gone.',
|
|
102
|
+
confidence: 0.6,
|
|
103
|
+
}));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Scanner-form paths so inline suppressions can match (see withScannerPaths).
|
|
108
|
+
return withScannerPaths(context.repoRoot, issues);
|
|
109
|
+
}
|
|
110
|
+
//# sourceMappingURL=cursor-rules-detector.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cursor-rules-detector.js","sourceRoot":"","sources":["../src/cursor-rules-detector.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-rules engine (Decision D10) — a deterministic interpreter for
|
|
3
|
+
* `.promptci/custom-rules.json`. It lets a repo add its own instruction checks
|
|
4
|
+
* without shipping code, and those findings behave exactly like built-in ones:
|
|
5
|
+
* they carry `custom:*` ids, participate in suppression and the baseline/ratchet,
|
|
6
|
+
* and run identically in a local scan and the GitHub Action (both go through
|
|
7
|
+
* `buildRepoContext`).
|
|
8
|
+
*
|
|
9
|
+
* Four rule types:
|
|
10
|
+
* - `forbiddenPattern` — flag each file whose text matches a regex.
|
|
11
|
+
* - `absentPattern` — flag the repo when a required regex is absent from all targeted files.
|
|
12
|
+
* - `requiredSection` — flag the repo when a required heading appears in no targeted file.
|
|
13
|
+
* - `crossFileConflict` — flag when a regex's captured value differs across files.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is deterministic: no clock, network, or randomness. Malformed
|
|
16
|
+
* config is rejected with a clear, actionable error naming the offending key —
|
|
17
|
+
* never a crash or a silent skip.
|
|
18
|
+
*/
|
|
19
|
+
import type { InstructionFile, IssueSeverity, PromptCiIssue } from './types.js';
|
|
20
|
+
export declare const CUSTOM_RULES_FILE: string;
|
|
21
|
+
export declare const CUSTOM_RULE_TYPES: readonly ["forbiddenPattern", "absentPattern", "requiredSection", "crossFileConflict"];
|
|
22
|
+
export type CustomRuleType = (typeof CUSTOM_RULE_TYPES)[number];
|
|
23
|
+
/** A validated custom rule. `severity` is resolved (default `warning`). */
|
|
24
|
+
export type CustomRule = {
|
|
25
|
+
id: string;
|
|
26
|
+
type: CustomRuleType;
|
|
27
|
+
message: string;
|
|
28
|
+
severity: IssueSeverity;
|
|
29
|
+
files?: string[];
|
|
30
|
+
pattern?: string;
|
|
31
|
+
flags?: string;
|
|
32
|
+
heading?: string;
|
|
33
|
+
};
|
|
34
|
+
/** All validation failures throw this so callers can present them cleanly. */
|
|
35
|
+
export declare class CustomRulesError extends Error {
|
|
36
|
+
constructor(message: string);
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Validate a parsed `.promptci/custom-rules.json` value into typed rules.
|
|
40
|
+
* Accepts either `{ "rules": [...] }` or a bare `[...]` array. Throws a
|
|
41
|
+
* {@link CustomRulesError} naming the offending key on any problem.
|
|
42
|
+
*/
|
|
43
|
+
export declare function parseCustomRules(raw: unknown): CustomRule[];
|
|
44
|
+
/**
|
|
45
|
+
* Load and validate `.promptci/custom-rules.json` from a repo root. A missing
|
|
46
|
+
* file yields no rules; invalid JSON or an invalid schema throws a
|
|
47
|
+
* {@link CustomRulesError} with an actionable message.
|
|
48
|
+
*/
|
|
49
|
+
export declare function loadCustomRules(repoRoot: string): Promise<CustomRule[]>;
|
|
50
|
+
/** Run all validated custom rules over the scanned instruction files. */
|
|
51
|
+
export declare function runCustomRules(files: InstructionFile[], rules: CustomRule[], repoRoot: string): PromptCiIssue[];
|
|
52
|
+
//# sourceMappingURL=custom-rules.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"custom-rules.d.ts","sourceRoot":"","sources":["../src/custom-rules.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAMH,OAAO,KAAK,EAAE,eAAe,EAAE,aAAa,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAGhF,eAAO,MAAM,iBAAiB,QAA8C,CAAC;AAE7E,eAAO,MAAM,iBAAiB,wFAKpB,CAAC;AACX,MAAM,MAAM,cAAc,GAAG,CAAC,OAAO,iBAAiB,CAAC,CAAC,MAAM,CAAC,CAAC;AAKhE,2EAA2E;AAC3E,MAAM,MAAM,UAAU,GAAG;IACvB,EAAE,EAAE,MAAM,CAAC;IACX,IAAI,EAAE,cAAc,CAAC;IACrB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,aAAa,CAAC;IACxB,KAAK,CAAC,EAAE,MAAM,EAAE,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB,CAAC;AAIF,8EAA8E;AAC9E,qBAAa,gBAAiB,SAAQ,KAAK;gBAC7B,OAAO,EAAE,MAAM;CAI5B;AA8BD;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,OAAO,GAAG,UAAU,EAAE,CAwE3D;AAED;;;;GAIG;AACH,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,UAAU,EAAE,CAAC,CAkB7E;AAoID,yEAAyE;AACzE,wBAAgB,cAAc,CAC5B,KAAK,EAAE,eAAe,EAAE,EACxB,KAAK,EAAE,UAAU,EAAE,EACnB,QAAQ,EAAE,MAAM,GACf,aAAa,EAAE,CAoBjB"}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom-rules engine (Decision D10) — a deterministic interpreter for
|
|
3
|
+
* `.promptci/custom-rules.json`. It lets a repo add its own instruction checks
|
|
4
|
+
* without shipping code, and those findings behave exactly like built-in ones:
|
|
5
|
+
* they carry `custom:*` ids, participate in suppression and the baseline/ratchet,
|
|
6
|
+
* and run identically in a local scan and the GitHub Action (both go through
|
|
7
|
+
* `buildRepoContext`).
|
|
8
|
+
*
|
|
9
|
+
* Four rule types:
|
|
10
|
+
* - `forbiddenPattern` — flag each file whose text matches a regex.
|
|
11
|
+
* - `absentPattern` — flag the repo when a required regex is absent from all targeted files.
|
|
12
|
+
* - `requiredSection` — flag the repo when a required heading appears in no targeted file.
|
|
13
|
+
* - `crossFileConflict` — flag when a regex's captured value differs across files.
|
|
14
|
+
*
|
|
15
|
+
* Everything here is deterministic: no clock, network, or randomness. Malformed
|
|
16
|
+
* config is rejected with a clear, actionable error naming the offending key —
|
|
17
|
+
* never a crash or a silent skip.
|
|
18
|
+
*/
|
|
19
|
+
import * as crypto from 'node:crypto';
|
|
20
|
+
import * as fs from 'node:fs/promises';
|
|
21
|
+
import * as path from 'node:path';
|
|
22
|
+
import micromatch from 'micromatch';
|
|
23
|
+
import { snippet } from './evidence.js';
|
|
24
|
+
export const CUSTOM_RULES_FILE = path.join('.promptci', 'custom-rules.json');
|
|
25
|
+
export const CUSTOM_RULE_TYPES = [
|
|
26
|
+
'forbiddenPattern',
|
|
27
|
+
'absentPattern',
|
|
28
|
+
'requiredSection',
|
|
29
|
+
'crossFileConflict',
|
|
30
|
+
];
|
|
31
|
+
const VALID_SEVERITIES = ['info', 'warning', 'high', 'critical'];
|
|
32
|
+
const VALID_REGEX_FLAGS = /^[gimsuy]*$/;
|
|
33
|
+
// ── Validation ──────────────────────────────────────────────────────────────
|
|
34
|
+
/** All validation failures throw this so callers can present them cleanly. */
|
|
35
|
+
export class CustomRulesError extends Error {
|
|
36
|
+
constructor(message) {
|
|
37
|
+
super(`Invalid ${CUSTOM_RULES_FILE}: ${message}`);
|
|
38
|
+
this.name = 'CustomRulesError';
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function requireString(obj, key, where) {
|
|
42
|
+
const value = obj[key];
|
|
43
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
44
|
+
throw new CustomRulesError(`${where} is missing a non-empty string "${key}".`);
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
function requireStringArray(value, key, where) {
|
|
49
|
+
if (!Array.isArray(value) || !value.every((v) => typeof v === 'string')) {
|
|
50
|
+
throw new CustomRulesError(`${where}: "${key}" must be an array of strings.`);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
}
|
|
54
|
+
function validateRegex(pattern, flags, where) {
|
|
55
|
+
if (flags !== undefined && !VALID_REGEX_FLAGS.test(flags)) {
|
|
56
|
+
throw new CustomRulesError(`${where}: "flags" may only contain regex flags (gimsuy).`);
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
void new RegExp(pattern, flags);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
throw new CustomRulesError(`${where}: "pattern" is not a valid regular expression: ${err instanceof Error ? err.message : String(err)}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Validate a parsed `.promptci/custom-rules.json` value into typed rules.
|
|
67
|
+
* Accepts either `{ "rules": [...] }` or a bare `[...]` array. Throws a
|
|
68
|
+
* {@link CustomRulesError} naming the offending key on any problem.
|
|
69
|
+
*/
|
|
70
|
+
export function parseCustomRules(raw) {
|
|
71
|
+
let rawRules;
|
|
72
|
+
if (Array.isArray(raw)) {
|
|
73
|
+
rawRules = raw;
|
|
74
|
+
}
|
|
75
|
+
else if (raw && typeof raw === 'object' && 'rules' in raw) {
|
|
76
|
+
rawRules = raw.rules;
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
throw new CustomRulesError('top level must be an object with a "rules" array (or a bare array of rules).');
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(rawRules)) {
|
|
82
|
+
throw new CustomRulesError('"rules" must be an array.');
|
|
83
|
+
}
|
|
84
|
+
const rules = [];
|
|
85
|
+
const seenIds = new Set();
|
|
86
|
+
for (let i = 0; i < rawRules.length; i++) {
|
|
87
|
+
const entry = rawRules[i];
|
|
88
|
+
const where = `rule at index ${i}`;
|
|
89
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
|
|
90
|
+
throw new CustomRulesError(`${where} must be an object.`);
|
|
91
|
+
}
|
|
92
|
+
const obj = entry;
|
|
93
|
+
const id = requireString(obj, 'id', where);
|
|
94
|
+
const ruleWhere = `rule "${id}"`;
|
|
95
|
+
if (seenIds.has(id)) {
|
|
96
|
+
throw new CustomRulesError(`${ruleWhere}: duplicate rule id (ids must be unique).`);
|
|
97
|
+
}
|
|
98
|
+
seenIds.add(id);
|
|
99
|
+
const type = requireString(obj, 'type', ruleWhere);
|
|
100
|
+
if (!CUSTOM_RULE_TYPES.includes(type)) {
|
|
101
|
+
throw new CustomRulesError(`${ruleWhere}: "type" must be one of ${CUSTOM_RULE_TYPES.join(', ')} (got "${type}").`);
|
|
102
|
+
}
|
|
103
|
+
const message = requireString(obj, 'message', ruleWhere);
|
|
104
|
+
let severity = 'warning';
|
|
105
|
+
if ('severity' in obj) {
|
|
106
|
+
if (typeof obj.severity !== 'string' || !VALID_SEVERITIES.includes(obj.severity)) {
|
|
107
|
+
throw new CustomRulesError(`${ruleWhere}: "severity" must be one of ${VALID_SEVERITIES.join(', ')}.`);
|
|
108
|
+
}
|
|
109
|
+
severity = obj.severity;
|
|
110
|
+
}
|
|
111
|
+
const rule = { id, type: type, message, severity };
|
|
112
|
+
if ('files' in obj)
|
|
113
|
+
rule.files = requireStringArray(obj.files, 'files', ruleWhere);
|
|
114
|
+
if ('flags' in obj) {
|
|
115
|
+
if (typeof obj.flags !== 'string') {
|
|
116
|
+
throw new CustomRulesError(`${ruleWhere}: "flags" must be a string.`);
|
|
117
|
+
}
|
|
118
|
+
rule.flags = obj.flags;
|
|
119
|
+
}
|
|
120
|
+
if (type === 'requiredSection') {
|
|
121
|
+
rule.heading = requireString(obj, 'heading', ruleWhere);
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
// pattern-based types
|
|
125
|
+
rule.pattern = requireString(obj, 'pattern', ruleWhere);
|
|
126
|
+
validateRegex(rule.pattern, rule.flags, ruleWhere);
|
|
127
|
+
}
|
|
128
|
+
rules.push(rule);
|
|
129
|
+
}
|
|
130
|
+
return rules;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Load and validate `.promptci/custom-rules.json` from a repo root. A missing
|
|
134
|
+
* file yields no rules; invalid JSON or an invalid schema throws a
|
|
135
|
+
* {@link CustomRulesError} with an actionable message.
|
|
136
|
+
*/
|
|
137
|
+
export async function loadCustomRules(repoRoot) {
|
|
138
|
+
const filePath = path.join(repoRoot, CUSTOM_RULES_FILE);
|
|
139
|
+
let raw;
|
|
140
|
+
try {
|
|
141
|
+
raw = await fs.readFile(filePath, 'utf-8');
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err.code === 'ENOENT')
|
|
145
|
+
return [];
|
|
146
|
+
throw err;
|
|
147
|
+
}
|
|
148
|
+
let parsed;
|
|
149
|
+
try {
|
|
150
|
+
parsed = JSON.parse(raw);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
throw new CustomRulesError('file is not valid JSON.');
|
|
154
|
+
}
|
|
155
|
+
return parseCustomRules(parsed);
|
|
156
|
+
}
|
|
157
|
+
// ── Interpreter ─────────────────────────────────────────────────────────────
|
|
158
|
+
function shortHash(value) {
|
|
159
|
+
return crypto.createHash('sha1').update(value).digest('hex').slice(0, 12);
|
|
160
|
+
}
|
|
161
|
+
/** 1-based line number of a character offset within `content`. */
|
|
162
|
+
function lineOfOffset(content, offset) {
|
|
163
|
+
let line = 1;
|
|
164
|
+
for (let i = 0; i < offset && i < content.length; i++) {
|
|
165
|
+
if (content[i] === '\n')
|
|
166
|
+
line++;
|
|
167
|
+
}
|
|
168
|
+
return line;
|
|
169
|
+
}
|
|
170
|
+
/** Files a rule targets: all instruction files, or those matching its globs. */
|
|
171
|
+
function targetedFiles(files, rule, repoRoot) {
|
|
172
|
+
if (!rule.files || rule.files.length === 0)
|
|
173
|
+
return files;
|
|
174
|
+
return files.filter((f) => {
|
|
175
|
+
const rel = path.relative(repoRoot, f.path).replace(/\\/g, '/');
|
|
176
|
+
return micromatch.isMatch(rel, rule.files, { dot: true });
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
function freshRegex(rule) {
|
|
180
|
+
// A fresh instance per use so a shared `g` flag never leaks lastIndex.
|
|
181
|
+
return new RegExp(rule.pattern, rule.flags);
|
|
182
|
+
}
|
|
183
|
+
function baseIssue(rule) {
|
|
184
|
+
return { severity: rule.severity, category: 'custom', confidence: 1.0, tags: ['custom-rule'] };
|
|
185
|
+
}
|
|
186
|
+
function runForbiddenPattern(files, rule) {
|
|
187
|
+
const issues = [];
|
|
188
|
+
for (const file of files) {
|
|
189
|
+
const match = freshRegex(rule).exec(file.content);
|
|
190
|
+
if (!match)
|
|
191
|
+
continue;
|
|
192
|
+
const line = lineOfOffset(file.content, match.index);
|
|
193
|
+
issues.push({
|
|
194
|
+
id: `custom:${rule.id}:${shortHash(file.path)}`,
|
|
195
|
+
...baseIssue(rule),
|
|
196
|
+
title: rule.message,
|
|
197
|
+
summary: `Custom rule "${rule.id}": a forbidden pattern was found in this file.`,
|
|
198
|
+
filePaths: [file.path],
|
|
199
|
+
locations: [{ filePath: file.path, startLine: line, endLine: line }],
|
|
200
|
+
evidence: [`Matched text: "${snippet(match[0], 120)}" (line ${line})`],
|
|
201
|
+
recommendation: `Remove or revise the flagged content to satisfy custom rule "${rule.id}".`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
return issues;
|
|
205
|
+
}
|
|
206
|
+
function runAbsentPattern(files, rule) {
|
|
207
|
+
const present = files.some((f) => freshRegex(rule).test(f.content));
|
|
208
|
+
if (present)
|
|
209
|
+
return [];
|
|
210
|
+
return [
|
|
211
|
+
{
|
|
212
|
+
id: `custom:${rule.id}`,
|
|
213
|
+
...baseIssue(rule),
|
|
214
|
+
title: rule.message,
|
|
215
|
+
summary: `Custom rule "${rule.id}": a required pattern is absent from all checked instruction files.`,
|
|
216
|
+
filePaths: files.map((f) => f.path),
|
|
217
|
+
locations: files.map((f) => ({ filePath: f.path })),
|
|
218
|
+
evidence: [`Required pattern was not found in any of ${files.length} checked file(s).`],
|
|
219
|
+
recommendation: `Add content matching custom rule "${rule.id}" to one of the instruction files.`,
|
|
220
|
+
},
|
|
221
|
+
];
|
|
222
|
+
}
|
|
223
|
+
function normalizeHeading(h) {
|
|
224
|
+
return h.toLowerCase().replace(/^#+\s*/, '').replace(/\s+/g, ' ').trim();
|
|
225
|
+
}
|
|
226
|
+
function runRequiredSection(files, rule) {
|
|
227
|
+
const wanted = normalizeHeading(rule.heading);
|
|
228
|
+
const found = files.some((f) => f.sections.some((s) => s.heading !== undefined && normalizeHeading(s.heading).includes(wanted)));
|
|
229
|
+
if (found)
|
|
230
|
+
return [];
|
|
231
|
+
return [
|
|
232
|
+
{
|
|
233
|
+
id: `custom:${rule.id}`,
|
|
234
|
+
...baseIssue(rule),
|
|
235
|
+
title: rule.message,
|
|
236
|
+
summary: `Custom rule "${rule.id}": a required section heading ("${rule.heading}") is missing from all checked files.`,
|
|
237
|
+
filePaths: files.map((f) => f.path),
|
|
238
|
+
locations: files.map((f) => ({ filePath: f.path })),
|
|
239
|
+
evidence: [`No section heading matching "${rule.heading}" was found in ${files.length} checked file(s).`],
|
|
240
|
+
recommendation: `Add a "${rule.heading}" section to one of the instruction files to satisfy custom rule "${rule.id}".`,
|
|
241
|
+
},
|
|
242
|
+
];
|
|
243
|
+
}
|
|
244
|
+
function runCrossFileConflict(files, rule) {
|
|
245
|
+
// value → the files (and the line) where it was captured.
|
|
246
|
+
const byValue = new Map();
|
|
247
|
+
for (const file of files) {
|
|
248
|
+
const match = freshRegex(rule).exec(file.content);
|
|
249
|
+
if (!match)
|
|
250
|
+
continue;
|
|
251
|
+
// Prefer the first capture group when present, else the whole match.
|
|
252
|
+
const value = (match[1] ?? match[0]).trim();
|
|
253
|
+
const line = lineOfOffset(file.content, match.index);
|
|
254
|
+
const list = byValue.get(value) ?? [];
|
|
255
|
+
list.push({ filePath: file.path, line });
|
|
256
|
+
byValue.set(value, list);
|
|
257
|
+
}
|
|
258
|
+
if (byValue.size < 2)
|
|
259
|
+
return [];
|
|
260
|
+
const filePaths = [...new Set([...byValue.values()].flat().map((v) => v.filePath))];
|
|
261
|
+
const locations = [...byValue.values()].flat().map((v) => ({ filePath: v.filePath, startLine: v.line, endLine: v.line }));
|
|
262
|
+
const evidence = [...byValue.entries()].map(([value, where]) => `"${snippet(value, 60)}" in ${where.map((w) => path.basename(w.filePath)).join(', ')}`);
|
|
263
|
+
return [
|
|
264
|
+
{
|
|
265
|
+
id: `custom:${rule.id}`,
|
|
266
|
+
...baseIssue(rule),
|
|
267
|
+
title: rule.message,
|
|
268
|
+
summary: `Custom rule "${rule.id}": the tracked value differs across files (${byValue.size} distinct values).`,
|
|
269
|
+
filePaths,
|
|
270
|
+
locations,
|
|
271
|
+
evidence,
|
|
272
|
+
recommendation: `Reconcile the conflicting values so custom rule "${rule.id}" sees a single consistent value.`,
|
|
273
|
+
},
|
|
274
|
+
];
|
|
275
|
+
}
|
|
276
|
+
/** Run all validated custom rules over the scanned instruction files. */
|
|
277
|
+
export function runCustomRules(files, rules, repoRoot) {
|
|
278
|
+
const issues = [];
|
|
279
|
+
for (const rule of rules) {
|
|
280
|
+
const scoped = targetedFiles(files, rule, repoRoot);
|
|
281
|
+
switch (rule.type) {
|
|
282
|
+
case 'forbiddenPattern':
|
|
283
|
+
issues.push(...runForbiddenPattern(scoped, rule));
|
|
284
|
+
break;
|
|
285
|
+
case 'absentPattern':
|
|
286
|
+
issues.push(...runAbsentPattern(scoped, rule));
|
|
287
|
+
break;
|
|
288
|
+
case 'requiredSection':
|
|
289
|
+
issues.push(...runRequiredSection(scoped, rule));
|
|
290
|
+
break;
|
|
291
|
+
case 'crossFileConflict':
|
|
292
|
+
issues.push(...runCrossFileConflict(scoped, rule));
|
|
293
|
+
break;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return issues;
|
|
297
|
+
}
|
|
298
|
+
//# sourceMappingURL=custom-rules.js.map
|