@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.
- package/bin/hone.js +2 -0
- package/hone-cli.js +4006 -0
- package/lib/README.md +119 -0
- package/lib/adversarial-negative-lint.js +149 -0
- package/lib/audit.js +156 -0
- package/lib/auto-detect.js +213 -0
- package/lib/autofix-guardrails.js +124 -0
- package/lib/branch-protection.js +256 -0
- package/lib/ci-classifier.js +150 -0
- package/lib/ci-failures.js +173 -0
- package/lib/claude-md-tokens.js +71 -0
- package/lib/compliance-check.js +62 -0
- package/lib/config-augment.js +133 -0
- package/lib/config-update.js +70 -0
- package/lib/dependency-audit.js +108 -0
- package/lib/derive-domain.js +185 -0
- package/lib/doc-registry.js +63 -0
- package/lib/doctor-admin-merge.js +185 -0
- package/lib/doctor-bind-default.js +118 -0
- package/lib/doctor-docs.js +205 -0
- package/lib/doctor-placeholders.js +144 -0
- package/lib/doctor-skill-staleness.js +122 -0
- package/lib/domain-skill-template.md +114 -0
- package/lib/editor-detect.js +169 -0
- package/lib/fast-track-ratify.js +133 -0
- package/lib/git-helpers.js +109 -0
- package/lib/hook-templates/pre-commit.sh +54 -0
- package/lib/hook-templates/pre-push.sh +72 -0
- package/lib/install-hooks.js +205 -0
- package/lib/knowledge-graph.js +188 -0
- package/lib/learnings-audit.js +254 -0
- package/lib/learnings-parse.js +331 -0
- package/lib/learnings-sync.js +75 -0
- package/lib/mcp-detect.js +154 -0
- package/lib/metrics-collect.js +214 -0
- package/lib/overlay-merge.js +267 -0
- package/lib/performance-analyzer.js +142 -0
- package/lib/pipeline-config.js +83 -0
- package/lib/pipeline-status.js +207 -0
- package/lib/pipeline-validate.js +322 -0
- package/lib/platform-detect.js +86 -0
- package/lib/platform-discover.js +334 -0
- package/lib/publish-learning.js +160 -0
- package/lib/python-install.js +84 -0
- package/lib/refresh-check.js +67 -0
- package/lib/refresh-knowledge.js +360 -0
- package/lib/rule-resolver.js +146 -0
- package/lib/security-scanner.js +168 -0
- package/lib/setup-grounding.js +138 -0
- package/lib/skill-assertions.js +276 -0
- package/lib/skill-audit-render.js +158 -0
- package/lib/skill-audit.js +391 -0
- package/lib/stack-detect.js +170 -0
- package/lib/stack-paths.js +285 -0
- package/lib/story-classifier-extract.js +203 -0
- package/lib/story-classifier.js +282 -0
- package/lib/sync-overwrite.js +47 -0
- package/lib/synthetic-pipeline.js +299 -0
- package/lib/validate-metadata.js +175 -0
- 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
|
+
};
|