@hone-ai/cli 1.4.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.
Files changed (60) hide show
  1. package/bin/hone.js +2 -0
  2. package/hone-cli.js +4006 -0
  3. package/lib/README.md +119 -0
  4. package/lib/adversarial-negative-lint.js +149 -0
  5. package/lib/audit.js +156 -0
  6. package/lib/auto-detect.js +213 -0
  7. package/lib/autofix-guardrails.js +124 -0
  8. package/lib/branch-protection.js +256 -0
  9. package/lib/ci-classifier.js +150 -0
  10. package/lib/ci-failures.js +173 -0
  11. package/lib/claude-md-tokens.js +71 -0
  12. package/lib/compliance-check.js +62 -0
  13. package/lib/config-augment.js +133 -0
  14. package/lib/config-update.js +70 -0
  15. package/lib/dependency-audit.js +108 -0
  16. package/lib/derive-domain.js +185 -0
  17. package/lib/doc-registry.js +63 -0
  18. package/lib/doctor-admin-merge.js +185 -0
  19. package/lib/doctor-bind-default.js +118 -0
  20. package/lib/doctor-docs.js +205 -0
  21. package/lib/doctor-placeholders.js +144 -0
  22. package/lib/doctor-skill-staleness.js +122 -0
  23. package/lib/domain-skill-template.md +114 -0
  24. package/lib/editor-detect.js +169 -0
  25. package/lib/fast-track-ratify.js +133 -0
  26. package/lib/git-helpers.js +109 -0
  27. package/lib/hook-templates/pre-commit.sh +54 -0
  28. package/lib/hook-templates/pre-push.sh +72 -0
  29. package/lib/install-hooks.js +205 -0
  30. package/lib/knowledge-graph.js +188 -0
  31. package/lib/learnings-audit.js +254 -0
  32. package/lib/learnings-parse.js +331 -0
  33. package/lib/learnings-sync.js +75 -0
  34. package/lib/mcp-detect.js +154 -0
  35. package/lib/metrics-collect.js +214 -0
  36. package/lib/overlay-merge.js +267 -0
  37. package/lib/performance-analyzer.js +142 -0
  38. package/lib/pipeline-config.js +83 -0
  39. package/lib/pipeline-status.js +207 -0
  40. package/lib/pipeline-validate.js +322 -0
  41. package/lib/platform-detect.js +86 -0
  42. package/lib/platform-discover.js +334 -0
  43. package/lib/publish-learning.js +160 -0
  44. package/lib/python-install.js +84 -0
  45. package/lib/refresh-check.js +67 -0
  46. package/lib/refresh-knowledge.js +360 -0
  47. package/lib/rule-resolver.js +146 -0
  48. package/lib/security-scanner.js +168 -0
  49. package/lib/setup-grounding.js +138 -0
  50. package/lib/skill-assertions.js +276 -0
  51. package/lib/skill-audit-render.js +158 -0
  52. package/lib/skill-audit.js +391 -0
  53. package/lib/stack-detect.js +170 -0
  54. package/lib/stack-paths.js +285 -0
  55. package/lib/story-classifier-extract.js +203 -0
  56. package/lib/story-classifier.js +282 -0
  57. package/lib/sync-overwrite.js +47 -0
  58. package/lib/synthetic-pipeline.js +299 -0
  59. package/lib/validate-metadata.js +175 -0
  60. package/package.json +41 -0
@@ -0,0 +1,168 @@
1
+ 'use strict';
2
+ /**
3
+ * security-scanner.js — HC-022 static analysis for hardcoded secrets,
4
+ * injection patterns, XSS, and platform-aware security rules.
5
+ *
6
+ * Pure helper — no I/O. Caller provides file contents.
7
+ * Platform-aware: loads SF sharing/CRUD rules, NS eval rules.
8
+ *
9
+ * Architecture: docs/architecture/master-roadmap.md (Epic 2)
10
+ */
11
+
12
+ // ── Core Rules ─────────────────────────────────────────────────
13
+
14
+ const SECRET_PATTERNS = [
15
+ {
16
+ id: 'hardcoded-secret-apikey',
17
+ pattern: /(?:api[_-]?key|apikey|api[_-]?secret)\s*[:=]\s*["']([A-Za-z0-9/+=]{20,})["']/i,
18
+ severity: 'HIGH',
19
+ message: 'Hardcoded API key detected. Use environment variables instead.',
20
+ },
21
+ {
22
+ id: 'hardcoded-secret-password',
23
+ pattern: /(?:password|passwd|pwd|secret|token)\s*[:=]\s*["']([A-Za-z0-9@#$%^&*!]{16,})["']/i,
24
+ severity: 'HIGH',
25
+ message: 'Hardcoded password/secret detected. Use environment variables or a vault.',
26
+ },
27
+ {
28
+ id: 'hardcoded-secret-aws',
29
+ pattern: /AKIA[0-9A-Z]{16}/,
30
+ severity: 'HIGH',
31
+ message: 'AWS Access Key ID detected. Rotate immediately and use IAM roles.',
32
+ },
33
+ {
34
+ id: 'hardcoded-secret-private-key',
35
+ pattern: /-----BEGIN (?:RSA |EC |DSA )?PRIVATE KEY-----/,
36
+ severity: 'HIGH',
37
+ message: 'Private key detected in source code. Move to secure storage.',
38
+ },
39
+ ];
40
+
41
+ const INJECTION_PATTERNS = [
42
+ {
43
+ id: 'eval-injection',
44
+ pattern: /\beval\s*\(/,
45
+ severity: 'HIGH',
46
+ message: 'eval() usage detected. Avoid eval with dynamic input — use safe alternatives.',
47
+ },
48
+ {
49
+ id: 'function-constructor',
50
+ pattern: /new\s+Function\s*\(/,
51
+ severity: 'HIGH',
52
+ message: 'new Function() is equivalent to eval(). Avoid with dynamic input.',
53
+ },
54
+ {
55
+ id: 'sql-injection',
56
+ pattern: /(?:SELECT|INSERT|UPDATE|DELETE|DROP)\s+.*(?:\+\s*\w|\$\{)/i,
57
+ severity: 'HIGH',
58
+ message: 'Potential SQL injection — string concatenation in query. Use parameterized queries.',
59
+ },
60
+ ];
61
+
62
+ // ── Platform Rules ─────────────────────────────────────────────
63
+
64
+ const SALESFORCE_RULES = [
65
+ {
66
+ id: 'sf-without-sharing',
67
+ pattern: /\bwithout\s+sharing\b/i,
68
+ severity: 'MEDIUM',
69
+ message: 'Class uses "without sharing". Document justification. Default should be "with sharing".',
70
+ },
71
+ {
72
+ id: 'sf-soql-in-loop',
73
+ pattern: /for\s*\([\s\S]{0,200}\[SELECT\b/i,
74
+ severity: 'HIGH',
75
+ message: 'SOQL query inside a loop — governor limit violation. Move query before loop.',
76
+ },
77
+ ];
78
+
79
+ const NETSUITE_RULES = [
80
+ {
81
+ id: 'ns-eval-injection',
82
+ pattern: /\beval\s*\(/,
83
+ severity: 'HIGH',
84
+ message: 'eval() in SuiteScript is a security risk. Use N/search or JSON.parse instead.',
85
+ },
86
+ {
87
+ id: 'ns-function-constructor',
88
+ pattern: /new\s+Function\s*\(/,
89
+ severity: 'HIGH',
90
+ message: 'new Function() in SuiteScript — equivalent to eval(). Avoid.',
91
+ },
92
+ ];
93
+
94
+ const PLATFORM_RULES = {
95
+ salesforce: SALESFORCE_RULES,
96
+ netsuite: NETSUITE_RULES,
97
+ };
98
+
99
+ // ── Scanner ────────────────────────────────────────────────────
100
+
101
+ /**
102
+ * Scan files for security findings.
103
+ *
104
+ * @param {object} opts
105
+ * @param {object} opts.files — { path: content } map
106
+ * @param {string} [opts.platform] — 'salesforce' | 'netsuite' | etc.
107
+ * @returns {{ findings: Array<{file, line, rule, severity, message}>, summary: string }}
108
+ */
109
+ function scanFiles(opts = {}) {
110
+ const files = opts.files || {};
111
+ const platform = opts.platform || null;
112
+ const findings = [];
113
+
114
+ // Combine core rules + platform rules
115
+ const rules = [...SECRET_PATTERNS, ...INJECTION_PATTERNS];
116
+ if (platform && PLATFORM_RULES[platform]) {
117
+ rules.push(...PLATFORM_RULES[platform]);
118
+ }
119
+
120
+ for (const [filePath, content] of Object.entries(files)) {
121
+ if (typeof content !== 'string') continue;
122
+
123
+ const lines = content.split('\n');
124
+ for (let i = 0; i < lines.length; i++) {
125
+ const line = lines[i];
126
+
127
+ // Skip env var references (not hardcoded)
128
+ if (/process\.env\.|os\.environ|System\.getenv|ENV\[/i.test(line)) continue;
129
+ // Skip comments
130
+ if (/^\s*(\/\/|#|--|\/\*|\*)/.test(line)) continue;
131
+
132
+ for (const rule of rules) {
133
+ if (rule.pattern.test(line)) {
134
+ // For secrets: verify the matched value is long enough (not a short string)
135
+ if (rule.id.startsWith('hardcoded-secret-') && !rule.id.includes('aws') && !rule.id.includes('private-key')) {
136
+ const match = line.match(rule.pattern);
137
+ if (match && match[1] && match[1].length < 16) continue;
138
+ }
139
+
140
+ findings.push({
141
+ file: filePath,
142
+ line: i + 1,
143
+ rule: rule.id,
144
+ severity: rule.severity,
145
+ message: rule.message,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // Summary
153
+ const high = findings.filter(f => f.severity === 'HIGH').length;
154
+ const medium = findings.filter(f => f.severity === 'MEDIUM').length;
155
+ const low = findings.filter(f => f.severity === 'LOW').length;
156
+ const summary = findings.length === 0
157
+ ? 'No security findings.'
158
+ : `${findings.length} finding(s): ${high} HIGH, ${medium} MEDIUM, ${low} LOW.`;
159
+
160
+ return { findings, summary };
161
+ }
162
+
163
+ module.exports = {
164
+ scanFiles,
165
+ SECRET_PATTERNS,
166
+ INJECTION_PATTERNS,
167
+ PLATFORM_RULES,
168
+ };
@@ -0,0 +1,138 @@
1
+ 'use strict';
2
+ /**
3
+ * setup-grounding.js — HC-013b-setup Phase 1c platform grounding.
4
+ *
5
+ * Called by hone-cli.js after the bash setup script finishes.
6
+ * Reads the just-written .pipeline-config.yml, runs platform discovery
7
+ * + MCP detection + doc registry selection, then augments the config.
8
+ *
9
+ * Pure orchestration helper — I/O injected via opts.
10
+ *
11
+ * Architecture: docs/architecture/platform-auto-discovery-v1.md
12
+ */
13
+
14
+ const { discoverPlatformMetadata } = require('./platform-discover');
15
+ const { detectMCPServer } = require('./mcp-detect');
16
+ const { selectRegistryUrls } = require('./doc-registry');
17
+ const { augmentPlatformConfig } = require('./config-augment');
18
+
19
+ /**
20
+ * Run platform grounding for all detected platforms.
21
+ *
22
+ * @param {object} opts
23
+ * @param {string} opts.repoRoot — absolute path to repo root
24
+ * @param {string} opts.configText — raw .pipeline-config.yml content
25
+ * @param {object} opts.parsedConfig — parsed YAML (for reading platform.detected)
26
+ * @param {(rel: string) => string|null} opts.readFile — read file content
27
+ * @param {(rel: string) => string[]} opts.listDir — list directory entries
28
+ * @param {(cmd: string) => {stdout: string, exitCode: number}} opts.exec — shell exec
29
+ * @param {(platform: string) => object|null} opts.loadRegistry — load platform doc registry YAML
30
+ * @returns {{ augmentedConfig: string, platforms: object, skipped: boolean }}
31
+ */
32
+ function groundPlatformConfig(opts) {
33
+ const { repoRoot, configText, parsedConfig, readFile, listDir, exec, loadRegistry } = opts;
34
+
35
+ // Guard: no config or no platform section
36
+ if (!configText || !parsedConfig) {
37
+ return { augmentedConfig: configText, platforms: {}, skipped: true, reason: 'no config' };
38
+ }
39
+
40
+ // Check if already grounded (idempotency)
41
+ if (parsedConfig.platform?.discovered_at && !opts.refresh) {
42
+ return {
43
+ augmentedConfig: configText,
44
+ platforms: {},
45
+ skipped: true,
46
+ reason: 'already grounded (use --refresh to re-scan)',
47
+ };
48
+ }
49
+
50
+ const detectedPlatforms = parsedConfig.platform?.detected || [];
51
+ if (detectedPlatforms.length === 0) {
52
+ return { augmentedConfig: configText, platforms: {}, skipped: true, reason: 'no platforms detected' };
53
+ }
54
+
55
+ // Run discovery + MCP detection + doc registry for each platform
56
+ const allResults = {};
57
+ const allMetadataTypes = { code: [], config: [], test: [] };
58
+ const allConfigPaths = [];
59
+ const allSourceRoots = [];
60
+ const allDocRegistries = [];
61
+ const allWarnings = [];
62
+
63
+ for (const platform of detectedPlatforms) {
64
+ // 1. Discover metadata types
65
+ const discovery = discoverPlatformMetadata({
66
+ platform,
67
+ repoRoot,
68
+ readFile,
69
+ listDir,
70
+ });
71
+
72
+ // Merge metadata types
73
+ for (const cat of ['code', 'config', 'test']) {
74
+ allMetadataTypes[cat].push(...discovery.metadata_types[cat]);
75
+ }
76
+ allConfigPaths.push(...discovery.config_paths);
77
+ allSourceRoots.push(...discovery.source_roots);
78
+ allWarnings.push(...discovery.warnings);
79
+
80
+ // 2. Detect MCP server
81
+ const mcp = exec ? detectMCPServer({ platform, repoRoot, exec }) : {
82
+ available: false, server: null, auth_method: null,
83
+ auth_status: 'unknown', capabilities: [], warnings: [],
84
+ };
85
+
86
+ // 3. Select doc registry URLs
87
+ const registry = loadRegistry ? loadRegistry(platform) : null;
88
+ const typeNames = [
89
+ ...discovery.metadata_types.code.map(t => t.type),
90
+ ...discovery.metadata_types.config.map(t => t.type),
91
+ ...discovery.metadata_types.test.map(t => t.type),
92
+ ];
93
+ const selectedDocs = registry ? selectRegistryUrls(registry, typeNames) : [];
94
+
95
+ if (registry) allDocRegistries.push(platform);
96
+
97
+ allResults[platform] = { discovery, mcp, selectedDocs };
98
+ }
99
+
100
+ // Determine MCP config from first platform with MCP available
101
+ let mcpConfig = { enabled: false };
102
+ for (const [, result] of Object.entries(allResults)) {
103
+ if (result.mcp.available) {
104
+ mcpConfig = {
105
+ enabled: true,
106
+ server: result.mcp.server,
107
+ capabilities: result.mcp.capabilities,
108
+ };
109
+ break;
110
+ }
111
+ }
112
+ // If no MCP available, include install hint from first platform
113
+ if (!mcpConfig.enabled) {
114
+ const firstMcp = Object.values(allResults)[0]?.mcp;
115
+ if (firstMcp?.install_hint) {
116
+ mcpConfig.install_hint = firstMcp.install_hint;
117
+ }
118
+ }
119
+
120
+ // Augment config
121
+ const augmentedConfig = augmentPlatformConfig(configText, {
122
+ discovered_at: new Date().toISOString(),
123
+ source_roots: [...new Set(allSourceRoots)],
124
+ metadata_types: allMetadataTypes,
125
+ config_paths: [...new Set(allConfigPaths)],
126
+ doc_registry: allDocRegistries.length > 0 ? allDocRegistries : detectedPlatforms,
127
+ mcp: mcpConfig,
128
+ });
129
+
130
+ return {
131
+ augmentedConfig,
132
+ platforms: allResults,
133
+ skipped: false,
134
+ warnings: allWarnings,
135
+ };
136
+ }
137
+
138
+ module.exports = { groundPlatformConfig };
@@ -0,0 +1,276 @@
1
+ 'use strict';
2
+ /**
3
+ * skill-assertions.js — SA-001 / #137 (architect plan story 1/2): pure helper for
4
+ * the eventual Step 5b runtime audit agent.
5
+ *
6
+ * Story 1 ships the ENGINE only. Story 2 (CLI command + Code Reviewer
7
+ * agent prompt) is gated on ≥2 real skills authoring `## Assertions`
8
+ * blocks OR OptionsFlow E29-G design landing.
9
+ *
10
+ * Why ship the engine first (LC-001-L1 pattern): the assertion shape
11
+ * needs to soak against real skill-author intent before the runtime
12
+ * harness wires it into CI. Skills without an `## Assertions` block
13
+ * produce zero findings → opt-in, no false-positive blast radius.
14
+ *
15
+ * Contract (versioned via `version: 1` marker):
16
+ * ```markdown
17
+ * ## Assertions
18
+ *
19
+ * ```yaml
20
+ * version: 1
21
+ * assertions:
22
+ * - id: ERR-001
23
+ * severity: BLOCKER # advisory; no escalation logic in v1
24
+ * description: "console.log forbidden in src/"
25
+ * applies_to: "^src/" # regex matched against file paths
26
+ * forbid_added: "console\\.log" # regex matched against added lines
27
+ * ```
28
+ * ```
29
+ *
30
+ * E29-G may later define a richer DSL; v1 stays parseable so existing
31
+ * skill assertions keep working until adopters explicitly migrate.
32
+ *
33
+ * Pure helper — no fs / child_process imports. Caller injects:
34
+ * - parseAssertions(skillMarkdown) → { version, assertions, warnings }
35
+ * - runAssertions({ diff, skills, referencedSkills? }) → { findings, scannedSkills, skipped }
36
+ *
37
+ * Issue: #137 (story 1/2).
38
+ */
39
+ const yaml = require('js-yaml');
40
+
41
+ const ASSERTION_VERSION = 1;
42
+ const ASSERTIONS_HEADING_RE = /^##\s+Assertions\s*$/m;
43
+
44
+ /** Severity labels. v1 carries them through findings; no escalation logic. */
45
+ const SEVERITY = Object.freeze({
46
+ BLOCKER: 'BLOCKER',
47
+ WARN: 'WARN',
48
+ INFO: 'INFO',
49
+ });
50
+ const VALID_SEVERITIES = new Set(Object.values(SEVERITY));
51
+
52
+ /**
53
+ * Parse the `## Assertions` block out of a skill's markdown.
54
+ *
55
+ * Returns { version, assertions, warnings }. Skills without the heading
56
+ * return { version: null, assertions: [], warnings: [] } — no error.
57
+ *
58
+ * Malformed YAML / regex / missing required fields produce structured
59
+ * warnings rather than throwing; the assertion is dropped from the
60
+ * output but the surrounding skill is still parseable.
61
+ *
62
+ * @param {string} skillMarkdown
63
+ * @returns {{ version: number|null, assertions: Assertion[], warnings: Warning[] }}
64
+ */
65
+ function parseAssertions(skillMarkdown) {
66
+ const out = { version: null, assertions: [], warnings: [] };
67
+ if (typeof skillMarkdown !== 'string' || !skillMarkdown) return out;
68
+ if (!ASSERTIONS_HEADING_RE.test(skillMarkdown)) return out;
69
+
70
+ // Find the YAML fenced block under `## Assertions`. Allow zero or more
71
+ // blank/comment lines between the heading and the fence.
72
+ const headingIdx = skillMarkdown.search(ASSERTIONS_HEADING_RE);
73
+ if (headingIdx < 0) return out;
74
+ const after = skillMarkdown.slice(headingIdx);
75
+ // Scope: stop at the next `^## ` heading so we don't consume sibling sections.
76
+ const stopMatch = after.slice(1).search(/^##\s+/m);
77
+ const section = stopMatch >= 0 ? after.slice(0, stopMatch + 1) : after;
78
+ const fenceMatch = section.match(/```(?:ya?ml)?\s*\n([\s\S]*?)\n```/);
79
+ if (!fenceMatch) {
80
+ out.warnings.push({ kind: 'missing_yaml_fence', message: '## Assertions section has no ```yaml block' });
81
+ return out;
82
+ }
83
+
84
+ let parsed;
85
+ try { parsed = yaml.load(fenceMatch[1]); }
86
+ catch (e) {
87
+ out.warnings.push({ kind: 'yaml_parse_error', message: e.message });
88
+ return out;
89
+ }
90
+ if (!parsed || typeof parsed !== 'object') {
91
+ out.warnings.push({ kind: 'yaml_not_object', message: 'expected YAML mapping' });
92
+ return out;
93
+ }
94
+
95
+ out.version = (typeof parsed.version === 'number') ? parsed.version : null;
96
+ if (out.version !== null && out.version !== ASSERTION_VERSION) {
97
+ out.warnings.push({
98
+ kind: 'unsupported_version',
99
+ message: `assertion block version ${out.version}; this engine only supports version ${ASSERTION_VERSION} — block ignored`,
100
+ });
101
+ return out;
102
+ }
103
+
104
+ const list = Array.isArray(parsed.assertions) ? parsed.assertions : [];
105
+ for (let i = 0; i < list.length; i++) {
106
+ const raw = list[i];
107
+ if (!raw || typeof raw !== 'object') {
108
+ out.warnings.push({ kind: 'assertion_not_object', message: `assertions[${i}] is not an object` });
109
+ continue;
110
+ }
111
+ const id = typeof raw.id === 'string' && raw.id ? raw.id : null;
112
+ const description = typeof raw.description === 'string' ? raw.description : '';
113
+ const severity = (typeof raw.severity === 'string' && VALID_SEVERITIES.has(raw.severity)) ? raw.severity : SEVERITY.WARN;
114
+ const appliesToSrc = typeof raw.applies_to === 'string' ? raw.applies_to : null;
115
+ const forbidAddedSrc = typeof raw.forbid_added === 'string' ? raw.forbid_added : null;
116
+
117
+ if (!id) {
118
+ out.warnings.push({ kind: 'assertion_missing_id', message: `assertions[${i}] missing required field 'id'` });
119
+ continue;
120
+ }
121
+ if (!forbidAddedSrc) {
122
+ out.warnings.push({ kind: 'assertion_missing_pattern', message: `assertion ${id} missing required field 'forbid_added'` });
123
+ continue;
124
+ }
125
+
126
+ let appliesTo = null;
127
+ if (appliesToSrc) {
128
+ try { appliesTo = new RegExp(appliesToSrc); }
129
+ catch (e) {
130
+ out.warnings.push({ kind: 'invalid_applies_to', message: `assertion ${id}: invalid applies_to regex — ${e.message}` });
131
+ continue;
132
+ }
133
+ }
134
+
135
+ let forbidAdded;
136
+ try { forbidAdded = new RegExp(forbidAddedSrc); }
137
+ catch (e) {
138
+ out.warnings.push({ kind: 'invalid_forbid_added', message: `assertion ${id}: invalid forbid_added regex — ${e.message}` });
139
+ continue;
140
+ }
141
+
142
+ out.assertions.push({
143
+ id,
144
+ severity,
145
+ description,
146
+ appliesTo, // RegExp | null
147
+ appliesToSrc, // string | null (preserved for findings)
148
+ forbidAdded, // RegExp
149
+ forbidAddedSrc, // string (preserved for findings)
150
+ });
151
+ }
152
+ return out;
153
+ }
154
+
155
+ /**
156
+ * Walk a unified diff and run assertions against added lines.
157
+ *
158
+ * @param {object} opts
159
+ * @param {string} opts.diff - unified diff text (output of `git diff origin/develop...HEAD`)
160
+ * @param {Array<{id: string, content: string}>} opts.skills - skill objects with markdown content
161
+ * @param {string[]} [opts.referencedSkills] - if provided, only these skills are evaluated
162
+ * @returns {{ findings: Finding[], scannedSkills: string[], skipped: Skip[], warnings: Warning[] }}
163
+ */
164
+ function runAssertions(opts = {}) {
165
+ const diff = typeof opts.diff === 'string' ? opts.diff : '';
166
+ const skills = Array.isArray(opts.skills) ? opts.skills : [];
167
+ const filter = Array.isArray(opts.referencedSkills) && opts.referencedSkills.length > 0
168
+ ? new Set(opts.referencedSkills)
169
+ : null;
170
+
171
+ const findings = [];
172
+ const scannedSkills = [];
173
+ const skipped = [];
174
+ const warnings = [];
175
+
176
+ // Parse the diff into per-file added-line sets ONCE (re-used across skills).
177
+ const filesAddedLines = parseDiffAddedLines(diff);
178
+
179
+ for (const skill of skills) {
180
+ if (!skill || typeof skill !== 'object') continue;
181
+ const skillId = skill.id || '<unknown>';
182
+ if (filter && !filter.has(skillId)) {
183
+ skipped.push({ skillId, reason: 'not in referencedSkills filter' });
184
+ continue;
185
+ }
186
+ const parsed = parseAssertions(skill.content || '');
187
+ for (const w of parsed.warnings) {
188
+ warnings.push({ skillId, ...w });
189
+ }
190
+ if (parsed.assertions.length === 0) {
191
+ skipped.push({ skillId, reason: 'no assertions defined' });
192
+ continue;
193
+ }
194
+ scannedSkills.push(skillId);
195
+
196
+ for (const a of parsed.assertions) {
197
+ for (const [filePath, addedLines] of filesAddedLines) {
198
+ if (a.appliesTo && !a.appliesTo.test(filePath)) continue;
199
+ for (const { lineNumber, text } of addedLines) {
200
+ if (a.forbidAdded.test(text)) {
201
+ findings.push({
202
+ skillId,
203
+ assertionId: a.id,
204
+ severity: a.severity,
205
+ description: a.description,
206
+ matchedFile: filePath,
207
+ matchedLine: lineNumber,
208
+ matchedText: text.slice(0, 200), // bounded for artifact size
209
+ forbidAddedSrc: a.forbidAddedSrc,
210
+ appliesToSrc: a.appliesToSrc,
211
+ });
212
+ }
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ return { findings, scannedSkills, skipped, warnings };
219
+ }
220
+
221
+ /**
222
+ * Parse a unified diff into a map: filePath → array of added-line tuples.
223
+ *
224
+ * Tracks the new-file line counter (right side of @@ -A,B +C,D @@) so
225
+ * findings cite real line numbers in the post-change file.
226
+ *
227
+ * @param {string} diff
228
+ * @returns {Map<string, Array<{ lineNumber: number, text: string }>>}
229
+ */
230
+ function parseDiffAddedLines(diff) {
231
+ const out = new Map();
232
+ if (!diff) return out;
233
+ const lines = diff.split('\n');
234
+ let currentFile = null;
235
+ let newLineNumber = 0;
236
+
237
+ for (const raw of lines) {
238
+ // New file header: "+++ b/path/to/file" (the "b/" prefix is git's convention)
239
+ const fileHeader = raw.match(/^\+\+\+\s+b\/(.+?)(?:\s|$)/);
240
+ if (fileHeader) {
241
+ currentFile = fileHeader[1];
242
+ if (!out.has(currentFile)) out.set(currentFile, []);
243
+ newLineNumber = 0;
244
+ continue;
245
+ }
246
+ // Hunk header: "@@ -A,B +C,D @@" — reset the new-side line counter to C.
247
+ const hunkHeader = raw.match(/^@@\s+-\d+(?:,\d+)?\s+\+(\d+)(?:,\d+)?\s+@@/);
248
+ if (hunkHeader) {
249
+ newLineNumber = parseInt(hunkHeader[1], 10);
250
+ continue;
251
+ }
252
+ if (currentFile === null) continue;
253
+
254
+ if (raw.startsWith('+') && !raw.startsWith('+++')) {
255
+ out.get(currentFile).push({
256
+ lineNumber: newLineNumber,
257
+ text: raw.slice(1), // strip the leading '+'
258
+ });
259
+ newLineNumber += 1;
260
+ } else if (raw.startsWith('-') && !raw.startsWith('---')) {
261
+ // Removed line; doesn't advance the new-side counter.
262
+ } else if (!raw.startsWith('\\')) {
263
+ // Context / unchanged line; advances the new-side counter.
264
+ newLineNumber += 1;
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+
270
+ module.exports = {
271
+ parseAssertions,
272
+ runAssertions,
273
+ parseDiffAddedLines,
274
+ ASSERTION_VERSION,
275
+ SEVERITY,
276
+ };