@promptci/core 0.0.1 → 0.2.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/action-pinning.d.ts +37 -0
- package/dist/action-pinning.d.ts.map +1 -0
- package/dist/action-pinning.js +277 -0
- package/dist/action-pinning.js.map +1 -0
- package/dist/agent-practices.d.ts +3 -1
- package/dist/agent-practices.d.ts.map +1 -1
- package/dist/agent-practices.js +198 -94
- package/dist/agent-practices.js.map +1 -1
- package/dist/ai-config.d.ts +178 -0
- package/dist/ai-config.d.ts.map +1 -0
- package/dist/ai-config.js +721 -0
- package/dist/ai-config.js.map +1 -0
- package/dist/alias-files.d.ts +33 -0
- package/dist/alias-files.d.ts.map +1 -0
- package/dist/alias-files.js +60 -0
- package/dist/alias-files.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 +107 -0
- package/dist/buried-critical.js.map +1 -0
- package/dist/canonical-owner.d.ts.map +1 -1
- package/dist/canonical-owner.js +60 -30
- package/dist/canonical-owner.js.map +1 -1
- package/dist/ci-alignment.d.ts.map +1 -1
- package/dist/ci-alignment.js +3 -2
- package/dist/ci-alignment.js.map +1 -1
- package/dist/command-validity.d.ts +16 -0
- package/dist/command-validity.d.ts.map +1 -1
- package/dist/command-validity.js +387 -134
- package/dist/command-validity.js.map +1 -1
- package/dist/conflicts.d.ts.map +1 -1
- package/dist/conflicts.js +4 -44
- 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 +20 -1
- package/dist/context-bloat.d.ts.map +1 -1
- package/dist/context-bloat.js +99 -15
- package/dist/context-bloat.js.map +1 -1
- package/dist/context-optimizer.d.ts.map +1 -1
- package/dist/context-optimizer.js +18 -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 +303 -0
- package/dist/custom-rules.js.map +1 -0
- package/dist/dead-references.d.ts +8 -2
- package/dist/dead-references.d.ts.map +1 -1
- package/dist/dead-references.js +315 -68
- package/dist/dead-references.js.map +1 -1
- package/dist/dependency-staleness.d.ts +35 -0
- package/dist/dependency-staleness.d.ts.map +1 -0
- package/dist/dependency-staleness.js +1001 -0
- package/dist/dependency-staleness.js.map +1 -0
- package/dist/detectors.d.ts +1 -3
- package/dist/detectors.d.ts.map +1 -1
- package/dist/detectors.js +74 -56
- package/dist/detectors.js.map +1 -1
- package/dist/duplicates.d.ts.map +1 -1
- package/dist/duplicates.js +15 -9
- package/dist/duplicates.js.map +1 -1
- package/dist/evidence.d.ts +39 -0
- package/dist/evidence.d.ts.map +1 -0
- package/dist/evidence.js +78 -0
- package/dist/evidence.js.map +1 -0
- package/dist/finding-id.d.ts +21 -0
- package/dist/finding-id.d.ts.map +1 -0
- package/dist/finding-id.js +31 -0
- package/dist/finding-id.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/dotnet.js +2 -2
- package/dist/frameworks/dotnet.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/frameworks/rust.d.ts.map +1 -1
- package/dist/frameworks/rust.js +2 -1
- package/dist/frameworks/rust.js.map +1 -1
- package/dist/git-symlinks.d.ts +30 -0
- package/dist/git-symlinks.d.ts.map +1 -0
- package/dist/git-symlinks.js +161 -0
- package/dist/git-symlinks.js.map +1 -0
- package/dist/gitignore.d.ts +22 -0
- package/dist/gitignore.d.ts.map +1 -0
- package/dist/gitignore.js +157 -0
- package/dist/gitignore.js.map +1 -0
- package/dist/health-score.d.ts.map +1 -1
- package/dist/health-score.js +35 -5
- 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 +29 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +21 -6
- package/dist/index.js.map +1 -1
- package/dist/makefile.d.ts +37 -0
- package/dist/makefile.d.ts.map +1 -0
- package/dist/makefile.js +156 -0
- package/dist/makefile.js.map +1 -0
- package/dist/manifest-consistency.d.ts +6 -0
- package/dist/manifest-consistency.d.ts.map +1 -1
- package/dist/manifest-consistency.js +75 -10
- 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 +169 -0
- package/dist/negative-instructions.js.map +1 -0
- package/dist/path-containment.d.ts +41 -0
- package/dist/path-containment.d.ts.map +1 -0
- package/dist/path-containment.js +123 -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 +33 -24
- 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 +80 -0
- package/dist/repo-context.d.ts.map +1 -1
- package/dist/repo-context.js +170 -16
- package/dist/repo-context.js.map +1 -1
- package/dist/report.d.ts +8 -0
- package/dist/report.d.ts.map +1 -1
- package/dist/report.js +163 -63
- package/dist/report.js.map +1 -1
- package/dist/scan.d.ts.map +1 -1
- package/dist/scan.js +47 -4
- package/dist/scan.js.map +1 -1
- package/dist/scanner.d.ts +61 -1
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +367 -70
- 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 +33 -14
- package/dist/security-pack.js.map +1 -1
- package/dist/skill-supply-chain.d.ts +132 -0
- package/dist/skill-supply-chain.d.ts.map +1 -0
- package/dist/skill-supply-chain.js +2954 -0
- package/dist/skill-supply-chain.js.map +1 -0
- package/dist/skills-detector.d.ts +23 -0
- package/dist/skills-detector.d.ts.map +1 -0
- package/dist/skills-detector.js +279 -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 +53 -55
- 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 +2 -0
- package/dist/suppression.d.ts.map +1 -1
- package/dist/suppression.js +16 -47
- package/dist/suppression.js.map +1 -1
- package/dist/types.d.ts +62 -4
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +47 -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 +44 -41
- 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 +96 -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 +148 -0
- package/dist/within-section-dedup.js.map +1 -0
- package/dist/workspace-manifests.d.ts +80 -0
- package/dist/workspace-manifests.d.ts.map +1 -0
- package/dist/workspace-manifests.js +333 -0
- package/dist/workspace-manifests.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,721 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared support for the AI-setup config detectors (issue #23).
|
|
3
|
+
*
|
|
4
|
+
* These detectors audit the non-markdown surfaces of a 2026 AI setup — skills,
|
|
5
|
+
* subagents, hooks/permissions, MCP servers, and Cursor rules — against
|
|
6
|
+
* filesystem reality, the same way `command-validity` verifies documented
|
|
7
|
+
* commands. Everything here is deterministic and offline: no network, no LLM,
|
|
8
|
+
* identical output for identical input.
|
|
9
|
+
*
|
|
10
|
+
* This module holds the pieces every one of those detectors needs: a minimal
|
|
11
|
+
* YAML-frontmatter parser, root-relative path helpers that refuse to escape the
|
|
12
|
+
* repo, a stable file lister, and issue path normalization.
|
|
13
|
+
*/
|
|
14
|
+
import * as fs from 'node:fs';
|
|
15
|
+
import * as path from 'node:path';
|
|
16
|
+
import * as crypto from 'node:crypto';
|
|
17
|
+
import fg from 'fast-glob';
|
|
18
|
+
import micromatch from 'micromatch';
|
|
19
|
+
import { MAX_FILE_SIZE, BINARY_CHECK_BYTES, isBinary } from './scanner.js';
|
|
20
|
+
import { resolveReadableWithinRoot, resolveWithinRoot } from './path-containment.js';
|
|
21
|
+
// ── Path helpers ──────────────────────────────────────────────────────────────
|
|
22
|
+
/** Convert a filesystem path to forward-slash form for stable, portable output. */
|
|
23
|
+
export function toPosix(p) {
|
|
24
|
+
return p.replace(/\\/g, '/');
|
|
25
|
+
}
|
|
26
|
+
// Re-exported so the many `resolveWithinRoot` importers under core keep their
|
|
27
|
+
// import path while the implementation lives in the shared path-containment
|
|
28
|
+
// module. Every filesystem read below routes through it so a hostile config
|
|
29
|
+
// value cannot point the detector at `/etc/passwd`.
|
|
30
|
+
export { resolveWithinRoot, resolveReadableWithinRoot };
|
|
31
|
+
/** True when a repo-relative path exists and is a regular file. */
|
|
32
|
+
export function isFileWithinRoot(repoRoot, relativePath) {
|
|
33
|
+
const abs = resolveReadableWithinRoot(repoRoot, relativePath);
|
|
34
|
+
if (!abs)
|
|
35
|
+
return false;
|
|
36
|
+
try {
|
|
37
|
+
return fs.statSync(abs).isFile();
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** Read a repo-relative text file, or `undefined` if it is absent/unreadable. */
|
|
44
|
+
export function readTextWithinRoot(repoRoot, relativePath) {
|
|
45
|
+
// Symlink-aware: a committed link whose target leaves the repo is not read.
|
|
46
|
+
const abs = resolveReadableWithinRoot(repoRoot, relativePath);
|
|
47
|
+
if (!abs)
|
|
48
|
+
return undefined;
|
|
49
|
+
try {
|
|
50
|
+
return fs.readFileSync(abs, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Directory names excluded when discovering config files: dependency trees and
|
|
58
|
+
* vendored copies whose own `.claude/` must never pollute the scan. This is a
|
|
59
|
+
* *discovery* policy — do not reuse it to answer "does glob X match anything in
|
|
60
|
+
* the repo" (see detectCursorRules, which needs `dist/`/`build/` visible).
|
|
61
|
+
*/
|
|
62
|
+
const DISCOVERY_IGNORE = [
|
|
63
|
+
'**/node_modules/**', '**/.git/**', '**/dist/**', '**/build/**',
|
|
64
|
+
'**/worktrees/**', '**/.worktrees/**',
|
|
65
|
+
];
|
|
66
|
+
/**
|
|
67
|
+
* List files under `repoRoot` matching `patterns`, returned as repo-relative
|
|
68
|
+
* POSIX paths in sorted order. Dotfiles are included (these configs all live in
|
|
69
|
+
* dot-directories). Pass `ignore` to override the default discovery excludes.
|
|
70
|
+
*/
|
|
71
|
+
export function listFiles(repoRoot, patterns, ignore = DISCOVERY_IGNORE) {
|
|
72
|
+
let matches;
|
|
73
|
+
try {
|
|
74
|
+
matches = fg.sync(patterns, {
|
|
75
|
+
cwd: repoRoot,
|
|
76
|
+
dot: true,
|
|
77
|
+
onlyFiles: true,
|
|
78
|
+
followSymbolicLinks: false,
|
|
79
|
+
ignore,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
return [];
|
|
84
|
+
}
|
|
85
|
+
return matches.map(toPosix).sort();
|
|
86
|
+
}
|
|
87
|
+
// ── Config-file discovery ─────────────────────────────────────────────────────
|
|
88
|
+
/**
|
|
89
|
+
* Every location an Agent Skill is installed from: the `.claude/` skills, the
|
|
90
|
+
* cross-tool `.agents/skills/` layout, and a Claude Code plugin's (or plugin
|
|
91
|
+
* marketplace's) root-level `skills/<name>/SKILL.md`. The root-level globs are
|
|
92
|
+
* anchored on purpose: a `**\/skills/**` glob would also sweep up vendored
|
|
93
|
+
* copies and test fixtures.
|
|
94
|
+
*/
|
|
95
|
+
const SKILL_GLOBS = [
|
|
96
|
+
'.claude/**/SKILL.md',
|
|
97
|
+
'.agents/skills/**/SKILL.md',
|
|
98
|
+
'skills/*/SKILL.md',
|
|
99
|
+
'plugins/*/skills/*/SKILL.md',
|
|
100
|
+
];
|
|
101
|
+
/** Repo-relative globs for each config surface the ai_config detectors audit. */
|
|
102
|
+
const AI_CONFIG_GLOBS = {
|
|
103
|
+
// The structural skills detector audits the same locations the supply-chain
|
|
104
|
+
// scan reads (it used to stop at `.claude/`, so `.agents/skills/` and plugin
|
|
105
|
+
// skills never got frontmatter, description or dead-reference checks).
|
|
106
|
+
skills: SKILL_GLOBS,
|
|
107
|
+
allSkills: SKILL_GLOBS,
|
|
108
|
+
agents: ['.claude/agents/**/*.md'],
|
|
109
|
+
settings: ['.claude/settings.json', '.claude/settings.local.json'],
|
|
110
|
+
mcp: ['.mcp.json'],
|
|
111
|
+
cursorRules: ['.cursor/rules/**/*.mdc'],
|
|
112
|
+
// GitHub Copilot per-file instructions. The legacy single-file
|
|
113
|
+
// `.github/copilot-instructions.md` has no `applyTo` support, so it is NOT
|
|
114
|
+
// discovered here — only the per-file format under `.github/instructions/`.
|
|
115
|
+
copilotInstructions: ['.github/instructions/**/*.md'],
|
|
116
|
+
};
|
|
117
|
+
/** An AiConfigFiles with every surface empty — for contexts built without discovery. */
|
|
118
|
+
export function emptyAiConfigFiles() {
|
|
119
|
+
return {
|
|
120
|
+
skills: [], allSkills: [], skillFiles: [], skillFilesOverCap: [], skillBundleSkips: [],
|
|
121
|
+
agents: [], settings: [], mcp: [], cursorRules: [], copilotInstructions: [],
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Exact repo-relative directories that hold skills rather than being one. A
|
|
126
|
+
* SKILL.md sitting directly in one of them is not a skill directory of its own
|
|
127
|
+
* — enumerating "its" bundle would sweep in the whole `.claude/` tree — so
|
|
128
|
+
* bundled-file discovery skips it. Compared as exact paths, never basenames: a
|
|
129
|
+
* skill that is itself NAMED `skills` (`.claude/skills/skills/`) is a real skill
|
|
130
|
+
* whose scripts must be scanned.
|
|
131
|
+
*/
|
|
132
|
+
const SKILL_CONTAINER_DIRS = new Set([
|
|
133
|
+
'.', '.claude', '.agents', '.claude/skills', '.agents/skills', '.claude/plugins', 'skills', 'plugins',
|
|
134
|
+
]);
|
|
135
|
+
export function isSkillContainerDir(dir) {
|
|
136
|
+
return SKILL_CONTAINER_DIRS.has(dir);
|
|
137
|
+
}
|
|
138
|
+
/** Upper bound on bundled files read per skill, so one huge skill cannot stall a scan. */
|
|
139
|
+
export const MAX_BUNDLED_FILES_PER_SKILL = 200;
|
|
140
|
+
/** Directory entries visited per skill before the listing stops (and says so). */
|
|
141
|
+
const MAX_BUNDLE_LISTING = 20_000;
|
|
142
|
+
/** Over-cap file names kept for reporting; the rest are counted. */
|
|
143
|
+
const MAX_OVER_CAP_NAMES = 1_000;
|
|
144
|
+
/**
|
|
145
|
+
* Directories inside a skill that are not walked but ARE reported: dependency
|
|
146
|
+
* trees and VCS/worktree copies (a locally installed `node_modules` would
|
|
147
|
+
* otherwise flood the scan with third-party code). Build output (`dist/`,
|
|
148
|
+
* `build/`) IS walked — inside a skill it is shipped, executable content.
|
|
149
|
+
*/
|
|
150
|
+
const BUNDLE_SKIP_DIRS = new Set(['node_modules', '.git', 'worktrees', '.worktrees']);
|
|
151
|
+
/** Dependency package manifests read per skill. */
|
|
152
|
+
const MAX_DEPENDENCY_MANIFESTS = 100;
|
|
153
|
+
/**
|
|
154
|
+
* Scripts, executables, manifests, shell-like config and extensionless files:
|
|
155
|
+
* listed (and scanned) before documents. Mirrors the supply-chain detector's
|
|
156
|
+
* priority classes by name, without any file I/O.
|
|
157
|
+
*/
|
|
158
|
+
const PRIORITY_BUNDLE_RE = new RegExp(String.raw `(?:^|\/)(?:[^/.]+|package\.json|requirements[^/]{0,40}\.txt|(?:GNU)?makefile|Dockerfile[^/]{0,40}|Containerfile|Justfile)$|` +
|
|
159
|
+
String.raw `\.(?:sh|bash|zsh|ksh|fish|command|js|mjs|cjs|ts|mts|cts|jsx|tsx|py|pyw|rb|ps1|psm1|pl|php|lua|bat|cmd|mk|` +
|
|
160
|
+
String.raw `vbs|vba|vbe|hta|applescript|wsf|jse|reg|exe|dll|so|dylib|wasm|jar|war|class|zip|tar|gz|tgz|bz2|xz|7z|rar|` +
|
|
161
|
+
String.raw `msi|dmg|pkg|deb|rpm|appimage|apk|bin|o|a|lnk|scr|com|pif|scpt|node|env)$`, 'i');
|
|
162
|
+
export function isPriorityBundleFile(rel) {
|
|
163
|
+
return PRIORITY_BUNDLE_RE.test(rel);
|
|
164
|
+
}
|
|
165
|
+
/** `node_modules/<pkg>/package.json` and `node_modules/@scope/<pkg>/package.json`, bounded. */
|
|
166
|
+
function listPackageManifests(repoRoot, nodeModules) {
|
|
167
|
+
const out = [];
|
|
168
|
+
const read = (rel) => {
|
|
169
|
+
const abs = resolveReadableWithinRoot(repoRoot, rel);
|
|
170
|
+
if (!abs)
|
|
171
|
+
return [];
|
|
172
|
+
try {
|
|
173
|
+
return fs.readdirSync(abs, { withFileTypes: true });
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
const tryPkg = (pkgDir) => {
|
|
180
|
+
const manifest = `${pkgDir}/package.json`;
|
|
181
|
+
if (out.length < MAX_DEPENDENCY_MANIFESTS && isFileWithinRoot(repoRoot, manifest))
|
|
182
|
+
out.push(manifest);
|
|
183
|
+
};
|
|
184
|
+
for (const entry of read(nodeModules).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
185
|
+
if (out.length >= MAX_DEPENDENCY_MANIFESTS)
|
|
186
|
+
break;
|
|
187
|
+
if (!entry.isDirectory() || entry.name.startsWith('.'))
|
|
188
|
+
continue;
|
|
189
|
+
if (entry.name.startsWith('@')) {
|
|
190
|
+
for (const scoped of read(`${nodeModules}/${entry.name}`).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
191
|
+
if (scoped.isDirectory())
|
|
192
|
+
tryPkg(`${nodeModules}/${entry.name}/${scoped.name}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
else {
|
|
196
|
+
tryPkg(`${nodeModules}/${entry.name}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return out;
|
|
200
|
+
}
|
|
201
|
+
/** True when `rel` or any ancestor directory matches an `exclude` pattern (glob-ignore semantics). */
|
|
202
|
+
function isExcluded(rel, exclude) {
|
|
203
|
+
if (exclude.length === 0)
|
|
204
|
+
return false;
|
|
205
|
+
const parts = rel.split('/');
|
|
206
|
+
const candidates = [rel];
|
|
207
|
+
for (let i = 1; i < parts.length; i++) {
|
|
208
|
+
const dir = parts.slice(0, i).join('/');
|
|
209
|
+
candidates.push(dir, `${dir}/`);
|
|
210
|
+
}
|
|
211
|
+
return candidates.some((c) => micromatch.isMatch(c, exclude, { dot: true }));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Enumerate the files bundled with each skill with a bounded, deterministic
|
|
215
|
+
* directory walk: everything under the SKILL.md's directory, minus SKILL.md
|
|
216
|
+
* files and anything owned by a nested skill (a file belongs to its nearest
|
|
217
|
+
* enclosing skill directory). Symlinks are not followed. `exclude` always
|
|
218
|
+
* applies; files an `include` list leaves out are counted and reported. The
|
|
219
|
+
* per-skill cap is applied here, before any file is opened, and everything it
|
|
220
|
+
* drops is reported — nothing in a bundle disappears silently.
|
|
221
|
+
*/
|
|
222
|
+
function discoverSkillFiles(repoRoot, skills, policy) {
|
|
223
|
+
const skillDirs = [...new Set(skills.map((s) => path.posix.dirname(s)).filter((dir) => !isSkillContainerDir(dir)))].sort();
|
|
224
|
+
const skillDirSet = new Set(skillDirs);
|
|
225
|
+
const files = [];
|
|
226
|
+
const overCap = [];
|
|
227
|
+
const skips = [];
|
|
228
|
+
for (const dir of skillDirs) {
|
|
229
|
+
const owned = [];
|
|
230
|
+
const dependencyManifests = [];
|
|
231
|
+
let includeDropped = 0;
|
|
232
|
+
let visited = 0;
|
|
233
|
+
let truncated = false;
|
|
234
|
+
const stack = [dir];
|
|
235
|
+
while (stack.length > 0) {
|
|
236
|
+
const current = stack.pop();
|
|
237
|
+
const abs = resolveReadableWithinRoot(repoRoot, current);
|
|
238
|
+
if (!abs)
|
|
239
|
+
continue;
|
|
240
|
+
let entries;
|
|
241
|
+
try {
|
|
242
|
+
entries = fs.readdirSync(abs, { withFileTypes: true });
|
|
243
|
+
}
|
|
244
|
+
catch {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
entries.sort((a, b) => (a.name < b.name ? 1 : a.name > b.name ? -1 : 0)); // reversed: stack pops in order
|
|
248
|
+
for (const entry of entries) {
|
|
249
|
+
if (++visited > MAX_BUNDLE_LISTING) {
|
|
250
|
+
truncated = true;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
const rel = `${current}/${entry.name}`;
|
|
254
|
+
if (entry.isSymbolicLink())
|
|
255
|
+
continue;
|
|
256
|
+
if (isExcluded(rel, policy.exclude))
|
|
257
|
+
continue;
|
|
258
|
+
if (entry.isDirectory()) {
|
|
259
|
+
if (skillDirSet.has(rel))
|
|
260
|
+
continue; // a nested skill owns its own subtree
|
|
261
|
+
if (BUNDLE_SKIP_DIRS.has(entry.name)) {
|
|
262
|
+
skips.push({ path: rel, kind: 'dependency-dir' });
|
|
263
|
+
if (entry.name === 'node_modules')
|
|
264
|
+
dependencyManifests.push(...listPackageManifests(repoRoot, rel));
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
stack.push(rel);
|
|
268
|
+
}
|
|
269
|
+
else if (entry.isFile() && entry.name !== 'SKILL.md') {
|
|
270
|
+
if (policy.include && !micromatch.isMatch(rel, policy.include, { dot: true })) {
|
|
271
|
+
includeDropped++;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
owned.push(rel);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (truncated)
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
// Executable content first, so the cap drops documents before it drops scripts.
|
|
281
|
+
owned.sort((a, b) => Number(isPriorityBundleFile(b)) - Number(isPriorityBundleFile(a)) || (a < b ? -1 : a > b ? 1 : 0));
|
|
282
|
+
files.push(...owned.slice(0, MAX_BUNDLED_FILES_PER_SKILL));
|
|
283
|
+
// Dependency package manifests are read (install scripts, `bin`) even though the tree is not walked.
|
|
284
|
+
files.push(...dependencyManifests.sort().slice(0, MAX_DEPENDENCY_MANIFESTS));
|
|
285
|
+
const dropped = owned.slice(MAX_BUNDLED_FILES_PER_SKILL);
|
|
286
|
+
overCap.push(...dropped.slice(0, MAX_OVER_CAP_NAMES));
|
|
287
|
+
if (dropped.length > MAX_OVER_CAP_NAMES || truncated) {
|
|
288
|
+
skips.push({ path: dir, kind: 'listing-limit', count: Math.max(0, dropped.length - MAX_OVER_CAP_NAMES) });
|
|
289
|
+
}
|
|
290
|
+
if (includeDropped > 0)
|
|
291
|
+
skips.push({ path: dir, kind: 'include', count: includeDropped });
|
|
292
|
+
}
|
|
293
|
+
return { files: files.sort(), overCap: overCap.sort(), skips };
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Same size/binary gate the markdown scanner applies before reading a file
|
|
297
|
+
* (scanner.ts): regular file, at most MAX_FILE_SIZE bytes, no NUL byte in the
|
|
298
|
+
* leading BINARY_CHECK_BYTES.
|
|
299
|
+
*/
|
|
300
|
+
function passesScanGuards(repoRoot, relativePath) {
|
|
301
|
+
const abs = resolveReadableWithinRoot(repoRoot, relativePath);
|
|
302
|
+
if (!abs)
|
|
303
|
+
return false;
|
|
304
|
+
try {
|
|
305
|
+
const stat = fs.statSync(abs);
|
|
306
|
+
if (!stat.isFile() || stat.size > MAX_FILE_SIZE)
|
|
307
|
+
return false;
|
|
308
|
+
const fd = fs.openSync(abs, 'r');
|
|
309
|
+
try {
|
|
310
|
+
const head = Buffer.alloc(Math.min(stat.size, BINARY_CHECK_BYTES));
|
|
311
|
+
const read = fs.readSync(fd, head, 0, head.length, 0);
|
|
312
|
+
return !isBinary(head.subarray(0, read));
|
|
313
|
+
}
|
|
314
|
+
finally {
|
|
315
|
+
fs.closeSync(fd);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
catch {
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Discover the config files the ai_config detectors audit, honoring the same
|
|
324
|
+
* user-facing scan policy the markdown scanner applies: `exclude` patterns
|
|
325
|
+
* always remove files, an explicit `include` list bounds the scan scope (when
|
|
326
|
+
* unset, each surface's own globs are the scope — these configs are not in the
|
|
327
|
+
* scanner's DEFAULT_PATTERNS), and oversized or binary files are skipped.
|
|
328
|
+
*
|
|
329
|
+
* Called from buildRepoContext so detectors consume pre-discovered lists; a
|
|
330
|
+
* `--exclude` that silences a markdown finding for a file silences that file's
|
|
331
|
+
* ai_config findings too, instead of the detectors re-walking the repo with a
|
|
332
|
+
* policy of their own.
|
|
333
|
+
*/
|
|
334
|
+
export function discoverAiConfigFiles(repoRoot, policy = {}) {
|
|
335
|
+
const ignore = [...DISCOVERY_IGNORE, ...(policy.exclude ?? [])];
|
|
336
|
+
const include = policy.include && policy.include.length > 0 ? policy.include : undefined;
|
|
337
|
+
/** include/exclude only — no file I/O. */
|
|
338
|
+
const list = (patterns) => {
|
|
339
|
+
const files = listFiles(repoRoot, [...patterns], ignore);
|
|
340
|
+
return include ? micromatch(files, include, { dot: true }).sort() : files;
|
|
341
|
+
};
|
|
342
|
+
const discover = (patterns) => list(patterns).filter((file) => passesScanGuards(repoRoot, file));
|
|
343
|
+
const allSkills = list(AI_CONFIG_GLOBS.allSkills);
|
|
344
|
+
const bundled = discoverSkillFiles(repoRoot, allSkills, { include, exclude: policy.exclude ?? [] });
|
|
345
|
+
return {
|
|
346
|
+
skills: discover(AI_CONFIG_GLOBS.skills),
|
|
347
|
+
allSkills,
|
|
348
|
+
skillFiles: bundled.files,
|
|
349
|
+
skillFilesOverCap: bundled.overCap,
|
|
350
|
+
skillBundleSkips: bundled.skips,
|
|
351
|
+
agents: discover(AI_CONFIG_GLOBS.agents),
|
|
352
|
+
settings: discover(AI_CONFIG_GLOBS.settings),
|
|
353
|
+
mcp: discover(AI_CONFIG_GLOBS.mcp),
|
|
354
|
+
cursorRules: discover(AI_CONFIG_GLOBS.cursorRules),
|
|
355
|
+
copilotInstructions: discover(AI_CONFIG_GLOBS.copilotInstructions),
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
/** Stable short hash for building per-finding IDs. */
|
|
359
|
+
export function shortHash(input) {
|
|
360
|
+
return crypto.createHash('sha1').update(input).digest('hex').slice(0, 12);
|
|
361
|
+
}
|
|
362
|
+
/**
|
|
363
|
+
* Rewrite an issue's `filePaths`/`locations` from the detectors' repo-relative
|
|
364
|
+
* POSIX form to the absolute form the scanner stores in `InstructionFile.path`.
|
|
365
|
+
* Inline `promptci-ignore` suppressions match on exact path equality with the
|
|
366
|
+
* scanned file, so detector output must speak the same dialect or the
|
|
367
|
+
* `ai_config` category is unsuppressable. Display strings (titles, summaries,
|
|
368
|
+
* evidence) keep the friendly relative form; baseline fingerprints re-normalize
|
|
369
|
+
* absolute paths back to repo-relative, so they are unaffected.
|
|
370
|
+
*/
|
|
371
|
+
export function withScannerPaths(repoRoot, issues) {
|
|
372
|
+
const abs = (p) => path.resolve(repoRoot, p);
|
|
373
|
+
return issues.map((issue) => ({
|
|
374
|
+
...issue,
|
|
375
|
+
filePaths: issue.filePaths.map(abs),
|
|
376
|
+
locations: issue.locations.map((loc) => ({ ...loc, filePath: abs(loc.filePath) })),
|
|
377
|
+
}));
|
|
378
|
+
}
|
|
379
|
+
/** Extensions treated as invokable local scripts in hook/MCP command strings. */
|
|
380
|
+
export const SCRIPT_EXT_RE = /\.(sh|bash|zsh|js|mjs|cjs|ts|py|rb|ps1)$/i;
|
|
381
|
+
/**
|
|
382
|
+
* 1-based line where `needle` first appears in `raw`, or 1 when not found.
|
|
383
|
+
* JSON sources store strings escaped, so a parsed command containing quotes or
|
|
384
|
+
* backslashes never appears verbatim — fall back to the JSON-escaped form
|
|
385
|
+
* before giving up on a real line number.
|
|
386
|
+
*/
|
|
387
|
+
export function lineOf(raw, needle) {
|
|
388
|
+
let idx = raw.indexOf(needle);
|
|
389
|
+
if (idx < 0)
|
|
390
|
+
idx = raw.indexOf(JSON.stringify(needle).slice(1, -1));
|
|
391
|
+
if (idx < 0)
|
|
392
|
+
return 1;
|
|
393
|
+
return raw.slice(0, idx).split(/\r?\n/).length;
|
|
394
|
+
}
|
|
395
|
+
function stripBom(s) {
|
|
396
|
+
return s.charCodeAt(0) === 0xfeff ? s.slice(1) : s;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* A closing fence must sit at column 0. An indented `---` is content — most
|
|
400
|
+
* commonly a markdown horizontal rule inside a `|` block-scalar description —
|
|
401
|
+
* and must not terminate the frontmatter.
|
|
402
|
+
*/
|
|
403
|
+
const FENCE_RE = /^(?:---|\.\.\.)[ \t]*$/;
|
|
404
|
+
/**
|
|
405
|
+
* Strip a YAML trailing comment (` #` onward) from a raw value, tracking quote
|
|
406
|
+
* state so a `#` inside a quoted string survives. A quote only *opens* at the
|
|
407
|
+
* start of the value or after whitespace/`[`/`{`/`(`/`,` — an apostrophe inside
|
|
408
|
+
* a word (o'brien) is content, not a quote.
|
|
409
|
+
*/
|
|
410
|
+
function stripTrailingComment(raw) {
|
|
411
|
+
let quote = '';
|
|
412
|
+
let prev = '';
|
|
413
|
+
for (let i = 0; i < raw.length; i++) {
|
|
414
|
+
const c = raw[i];
|
|
415
|
+
if (quote) {
|
|
416
|
+
if (c === quote)
|
|
417
|
+
quote = '';
|
|
418
|
+
}
|
|
419
|
+
else if ((c === '"' || c === "'") && (prev === '' || /[\s[{(,]/.test(prev))) {
|
|
420
|
+
quote = c;
|
|
421
|
+
}
|
|
422
|
+
else if (c === '#' && (prev === '' || /\s/.test(prev))) {
|
|
423
|
+
return raw.slice(0, i).trimEnd();
|
|
424
|
+
}
|
|
425
|
+
prev = c;
|
|
426
|
+
}
|
|
427
|
+
return raw;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Split on top-level `,` only — commas inside quotes or `[]`/`{}`/`()` are left
|
|
431
|
+
* alone. Without this, a glob with a brace-expansion pattern such as a
|
|
432
|
+
* `{ts,tsx}` suffix would be shredded at the inner comma, producing bogus
|
|
433
|
+
* dead-glob findings. A quote only opens at the start of an item, so an
|
|
434
|
+
* apostrophe inside a path does not swallow the following commas.
|
|
435
|
+
*/
|
|
436
|
+
function splitTopLevelCommas(inner) {
|
|
437
|
+
const parts = [];
|
|
438
|
+
let cur = '';
|
|
439
|
+
let depth = 0;
|
|
440
|
+
let quote = '';
|
|
441
|
+
for (const c of inner) {
|
|
442
|
+
if (quote) {
|
|
443
|
+
cur += c;
|
|
444
|
+
if (c === quote)
|
|
445
|
+
quote = '';
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
if ((c === '"' || c === "'") && cur.trim() === '') {
|
|
449
|
+
quote = c;
|
|
450
|
+
cur += c;
|
|
451
|
+
continue;
|
|
452
|
+
}
|
|
453
|
+
if (c === '[' || c === '{' || c === '(') {
|
|
454
|
+
depth++;
|
|
455
|
+
cur += c;
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (c === ']' || c === '}' || c === ')') {
|
|
459
|
+
if (depth > 0)
|
|
460
|
+
depth--;
|
|
461
|
+
cur += c;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (c === ',' && depth === 0) {
|
|
465
|
+
parts.push(cur);
|
|
466
|
+
cur = '';
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
cur += c;
|
|
470
|
+
}
|
|
471
|
+
parts.push(cur);
|
|
472
|
+
return parts.map((s) => unquote(s.trim())).filter((s) => s !== '');
|
|
473
|
+
}
|
|
474
|
+
function unquote(s) {
|
|
475
|
+
if (s.length >= 2 && ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'")))) {
|
|
476
|
+
return s.slice(1, -1);
|
|
477
|
+
}
|
|
478
|
+
return s;
|
|
479
|
+
}
|
|
480
|
+
function parseScalar(raw) {
|
|
481
|
+
// Comment stripping happens BEFORE dispatch so `globs: ["*.ts"] # note` is
|
|
482
|
+
// still recognized as a sequence, not left behind as a raw bracketed string.
|
|
483
|
+
const value = stripTrailingComment(raw).trim();
|
|
484
|
+
if (value === '')
|
|
485
|
+
return null;
|
|
486
|
+
// Fully-quoted string: contents verbatim (a `#` inside was preserved above).
|
|
487
|
+
const unquoted = unquote(value);
|
|
488
|
+
if (unquoted !== value)
|
|
489
|
+
return unquoted;
|
|
490
|
+
// Inline flow sequence: [a, b, c]
|
|
491
|
+
if (value.startsWith('[') && value.endsWith(']')) {
|
|
492
|
+
const inner = value.slice(1, -1).trim();
|
|
493
|
+
if (inner === '')
|
|
494
|
+
return [];
|
|
495
|
+
return splitTopLevelCommas(inner);
|
|
496
|
+
}
|
|
497
|
+
if (value === 'true')
|
|
498
|
+
return true;
|
|
499
|
+
if (value === 'false')
|
|
500
|
+
return false;
|
|
501
|
+
if (value === 'null' || value === '~')
|
|
502
|
+
return null;
|
|
503
|
+
if (/^-?\d+(\.\d+)?$/.test(value))
|
|
504
|
+
return Number(value);
|
|
505
|
+
return value;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Parse a minimal YAML frontmatter block from the top of a document.
|
|
509
|
+
*
|
|
510
|
+
* Handles the subset these config files actually use: top-level `key: value`
|
|
511
|
+
* scalars, quoted strings, booleans/numbers, inline `[a, b]` sequences, block
|
|
512
|
+
* lists (` - item`), and `|`/`>` block scalars. Nested mappings are recorded
|
|
513
|
+
* as errors rather than silently dropped, since none of the audited surfaces
|
|
514
|
+
* use them and a nested block is far more likely to be a mistake.
|
|
515
|
+
*
|
|
516
|
+
* This is deliberately not a full YAML engine — it exists to answer "is the
|
|
517
|
+
* frontmatter structurally sane and what are its top-level keys", not to load
|
|
518
|
+
* arbitrary YAML.
|
|
519
|
+
*/
|
|
520
|
+
export function parseFrontmatter(content) {
|
|
521
|
+
const text = stripBom(content);
|
|
522
|
+
const lines = text.split(/\r?\n/);
|
|
523
|
+
const result = {
|
|
524
|
+
present: false,
|
|
525
|
+
closed: false,
|
|
526
|
+
data: {},
|
|
527
|
+
keyLines: {},
|
|
528
|
+
fenceEndLine: -1,
|
|
529
|
+
bodyStartLine: 1,
|
|
530
|
+
errors: [],
|
|
531
|
+
};
|
|
532
|
+
if (lines.length === 0 || lines[0].trim() !== '---') {
|
|
533
|
+
return result; // no frontmatter — whole file is body
|
|
534
|
+
}
|
|
535
|
+
result.present = true;
|
|
536
|
+
let lastListKey = null;
|
|
537
|
+
let blockScalarKey = null;
|
|
538
|
+
let blockScalarFold = false;
|
|
539
|
+
let blockScalarLines = [];
|
|
540
|
+
const flushBlockScalar = () => {
|
|
541
|
+
if (blockScalarKey === null)
|
|
542
|
+
return;
|
|
543
|
+
const joined = blockScalarFold ? blockScalarLines.join(' ') : blockScalarLines.join('\n');
|
|
544
|
+
result.data[blockScalarKey] = joined.trim();
|
|
545
|
+
blockScalarKey = null;
|
|
546
|
+
blockScalarLines = [];
|
|
547
|
+
};
|
|
548
|
+
let i = 1;
|
|
549
|
+
for (; i < lines.length; i++) {
|
|
550
|
+
const line = lines[i];
|
|
551
|
+
const trimmed = line.trim();
|
|
552
|
+
if (blockScalarKey !== null) {
|
|
553
|
+
// Block scalar continues while lines are indented or blank; the closing
|
|
554
|
+
// fence (column 0 only) or a non-indented line ends it.
|
|
555
|
+
if (FENCE_RE.test(line)) {
|
|
556
|
+
flushBlockScalar();
|
|
557
|
+
result.closed = true;
|
|
558
|
+
result.fenceEndLine = i + 1;
|
|
559
|
+
result.bodyStartLine = i + 2;
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
if (trimmed === '' || /^\s/.test(line)) {
|
|
563
|
+
blockScalarLines.push(trimmed);
|
|
564
|
+
continue;
|
|
565
|
+
}
|
|
566
|
+
flushBlockScalar();
|
|
567
|
+
// fall through to normal handling of this line
|
|
568
|
+
}
|
|
569
|
+
if (FENCE_RE.test(line)) {
|
|
570
|
+
result.closed = true;
|
|
571
|
+
result.fenceEndLine = i + 1;
|
|
572
|
+
result.bodyStartLine = i + 2;
|
|
573
|
+
break;
|
|
574
|
+
}
|
|
575
|
+
if (trimmed === '' || trimmed.startsWith('#'))
|
|
576
|
+
continue;
|
|
577
|
+
// Block list item belonging to the most recent empty-valued key.
|
|
578
|
+
const listMatch = /^(\s*)-\s+(.*)$/.exec(line);
|
|
579
|
+
if (listMatch && lastListKey) {
|
|
580
|
+
const arr = result.data[lastListKey];
|
|
581
|
+
const item = unquote(stripTrailingComment(listMatch[2]).trim());
|
|
582
|
+
if (item !== '') {
|
|
583
|
+
if (Array.isArray(arr))
|
|
584
|
+
arr.push(item);
|
|
585
|
+
else
|
|
586
|
+
result.data[lastListKey] = [item];
|
|
587
|
+
}
|
|
588
|
+
continue;
|
|
589
|
+
}
|
|
590
|
+
const kvMatch = /^(\s*)([^:\s][^:]*):\s?(.*)$/.exec(line);
|
|
591
|
+
if (!kvMatch) {
|
|
592
|
+
result.errors.push(`Unparseable frontmatter line ${i + 1}: ${trimmed.slice(0, 60)}`);
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
const indent = kvMatch[1].length;
|
|
596
|
+
const key = kvMatch[2].trim();
|
|
597
|
+
const rawValue = kvMatch[3];
|
|
598
|
+
if (indent > 0) {
|
|
599
|
+
// Nested mapping — outside the supported subset.
|
|
600
|
+
result.errors.push(`Nested frontmatter key "${key}" on line ${i + 1} is not supported`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
if (Object.prototype.hasOwnProperty.call(result.data, key)) {
|
|
604
|
+
result.errors.push(`Duplicate frontmatter key "${key}" (line ${i + 1})`);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
result.keyLines[key] = i + 1; // first occurrence
|
|
608
|
+
}
|
|
609
|
+
const valueTrimmed = rawValue.trim();
|
|
610
|
+
if (valueTrimmed === '|' || valueTrimmed === '|-' || valueTrimmed === '|+' ||
|
|
611
|
+
valueTrimmed === '>' || valueTrimmed === '>-' || valueTrimmed === '>+') {
|
|
612
|
+
blockScalarKey = key;
|
|
613
|
+
blockScalarFold = valueTrimmed.startsWith('>');
|
|
614
|
+
blockScalarLines = [];
|
|
615
|
+
lastListKey = null;
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
if (valueTrimmed === '') {
|
|
619
|
+
// Either an empty scalar or the header of a block list on following lines.
|
|
620
|
+
result.data[key] = null;
|
|
621
|
+
lastListKey = key;
|
|
622
|
+
continue;
|
|
623
|
+
}
|
|
624
|
+
result.data[key] = parseScalar(rawValue);
|
|
625
|
+
lastListKey = null;
|
|
626
|
+
}
|
|
627
|
+
if (blockScalarKey !== null)
|
|
628
|
+
flushBlockScalar();
|
|
629
|
+
if (!result.closed) {
|
|
630
|
+
// Unterminated frontmatter — the whole remainder was consumed as fm.
|
|
631
|
+
result.bodyStartLine = lines.length + 1;
|
|
632
|
+
}
|
|
633
|
+
return result;
|
|
634
|
+
}
|
|
635
|
+
/** Coerce a frontmatter value that may be a list or comma/newline string into a string[]. */
|
|
636
|
+
export function asStringList(value) {
|
|
637
|
+
if (value === null || value === undefined)
|
|
638
|
+
return [];
|
|
639
|
+
if (Array.isArray(value))
|
|
640
|
+
return value.map((v) => String(v).trim()).filter(Boolean);
|
|
641
|
+
if (typeof value === 'string') {
|
|
642
|
+
// Top-level commas only — `*.{ts,tsx}` is one glob, not two fragments.
|
|
643
|
+
return value.split('\n').flatMap((line) => splitTopLevelCommas(line));
|
|
644
|
+
}
|
|
645
|
+
return [String(value)];
|
|
646
|
+
}
|
|
647
|
+
// ── Shared finding shapes ─────────────────────────────────────────────────────
|
|
648
|
+
/** Fill in the defaults every ai_config finding shares. */
|
|
649
|
+
export function aiConfigIssue(issue) {
|
|
650
|
+
return {
|
|
651
|
+
severity: 'warning',
|
|
652
|
+
category: 'ai_config',
|
|
653
|
+
confidence: 0.8,
|
|
654
|
+
...issue,
|
|
655
|
+
};
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* The three structural frontmatter findings every frontmatter-bearing surface
|
|
659
|
+
* (skills, subagents, Cursor rules) shares: block missing entirely, block never
|
|
660
|
+
* closed, and parse errors inside the block. When the block is missing the
|
|
661
|
+
* caller should skip its per-surface field checks, keyed off `fm.present` as
|
|
662
|
+
* before — this helper only builds the findings.
|
|
663
|
+
*/
|
|
664
|
+
export function frontmatterStructureIssues(filePath, content, fm, surface) {
|
|
665
|
+
const sid = (kind, key) => `ai-config-${surface.idPrefix}-${kind}-${shortHash(key)}`;
|
|
666
|
+
if (!fm.present) {
|
|
667
|
+
return [aiConfigIssue({
|
|
668
|
+
id: sid('no-frontmatter', filePath),
|
|
669
|
+
severity: surface.noFrontmatterSeverity ?? 'high',
|
|
670
|
+
title: surface.noFrontmatterTitle ?? `${surface.noun} is missing YAML frontmatter`,
|
|
671
|
+
summary: `${filePath} has no \`---\` frontmatter block. ${surface.why}`,
|
|
672
|
+
filePaths: [filePath],
|
|
673
|
+
locations: [{ filePath, startLine: 1, endLine: 1 }],
|
|
674
|
+
evidence: [`First line: ${content.split(/\r?\n/)[0]?.slice(0, 60) ?? '(empty)'}`],
|
|
675
|
+
recommendation: surface.recommendation,
|
|
676
|
+
confidence: surface.noFrontmatterConfidence ?? 0.85,
|
|
677
|
+
})];
|
|
678
|
+
}
|
|
679
|
+
const issues = [];
|
|
680
|
+
if (!fm.closed) {
|
|
681
|
+
issues.push(aiConfigIssue({
|
|
682
|
+
id: sid('unterminated', filePath),
|
|
683
|
+
severity: 'high',
|
|
684
|
+
title: `${surface.noun} frontmatter is not closed`,
|
|
685
|
+
summary: `${filePath} opens a \`---\` frontmatter block that is never closed.`,
|
|
686
|
+
filePaths: [filePath],
|
|
687
|
+
locations: [{ filePath, startLine: 1, endLine: 1 }],
|
|
688
|
+
evidence: ['Opening `---` on line 1 has no closing `---`.'],
|
|
689
|
+
recommendation: 'Close the frontmatter block with a `---` line.',
|
|
690
|
+
confidence: 0.9,
|
|
691
|
+
}));
|
|
692
|
+
}
|
|
693
|
+
for (const err of fm.errors) {
|
|
694
|
+
// BUG-007: surface a clear, user-facing finding that names the offending
|
|
695
|
+
// key and points at the offending line, rather than a generic "structural
|
|
696
|
+
// problem" anchored to the whole fence. The parser embeds the key (in
|
|
697
|
+
// quotes) and the line number in each error string; lift both out so the
|
|
698
|
+
// title and location are specific. Nested maps are the common trigger —
|
|
699
|
+
// e.g. `tools:` followed by an indented `read: true`.
|
|
700
|
+
const offendingKey = /"([^"]+)"/.exec(err)?.[1];
|
|
701
|
+
const errLine = Number(/\bline (\d+)\b/.exec(err)?.[1]);
|
|
702
|
+
const anchorLine = Number.isInteger(errLine) && errLine > 0
|
|
703
|
+
? errLine
|
|
704
|
+
: (fm.fenceEndLine > 0 ? fm.fenceEndLine : 1);
|
|
705
|
+
issues.push(aiConfigIssue({
|
|
706
|
+
id: sid('fm-error', `${filePath}|${err}`),
|
|
707
|
+
title: offendingKey
|
|
708
|
+
? `${surface.noun} frontmatter has an unsupported key "${offendingKey}"`
|
|
709
|
+
: `${surface.noun} frontmatter has a structural problem`,
|
|
710
|
+
summary: `${filePath}: ${err}.`,
|
|
711
|
+
filePaths: [filePath],
|
|
712
|
+
locations: [{ filePath, startLine: anchorLine, endLine: anchorLine }],
|
|
713
|
+
evidence: [err],
|
|
714
|
+
recommendation: 'Fix the frontmatter so it is valid YAML with flat, unique keys. ' +
|
|
715
|
+
'Nested maps are not supported — flatten the offending key or move it into the body.',
|
|
716
|
+
confidence: 0.7,
|
|
717
|
+
}));
|
|
718
|
+
}
|
|
719
|
+
return issues;
|
|
720
|
+
}
|
|
721
|
+
//# sourceMappingURL=ai-config.js.map
|