@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,158 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* skill-audit-render.js — SA-002 / #137 (architect plan story 2/2):
|
|
4
|
+
* pure renderer for the Step 5b skill-audit artifact.
|
|
5
|
+
*
|
|
6
|
+
* Takes the output of `runAssertions()` (cli/lib/skill-assertions.js) and
|
|
7
|
+
* produces a markdown artifact that satisfies the 3 structural markers
|
|
8
|
+
* from the synthetic-pipeline `story-that-violates-skill` fixture:
|
|
9
|
+
*
|
|
10
|
+
* 1. `^## Skill audit` heading (canonical artifact section, anchored)
|
|
11
|
+
* 2. skillId + assertionId co-occurrence on a single line
|
|
12
|
+
* 3. severity (BLOCKER|WARN|INFO) co-occurring with matchedFile path
|
|
13
|
+
*
|
|
14
|
+
* Pure helper, Category C (Transformation) per cli/lib/README.md taxonomy:
|
|
15
|
+
* takes structured input → returns transformed string output. No I/O.
|
|
16
|
+
*
|
|
17
|
+
* Architect's spec:
|
|
18
|
+
* - Empty findings produce a VALID artifact (not a missing file)
|
|
19
|
+
* - Severity carried through unchanged from the engine
|
|
20
|
+
* - Exit-code-0 contract is enforced by the CLI shell, not the renderer
|
|
21
|
+
*
|
|
22
|
+
* Issue: #137 (story 2/2).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Render a Step 5b skill-audit artifact from runAssertions() output.
|
|
27
|
+
*
|
|
28
|
+
* @param {object} opts
|
|
29
|
+
* @param {object} opts.result - return value of runAssertions(); must carry
|
|
30
|
+
* `findings`, `scannedSkills`, `skipped`, `warnings`
|
|
31
|
+
* @param {string} opts.storyId - e.g. "SA-002"
|
|
32
|
+
* @param {string} opts.branchName - e.g. "feat/SA-002-step-5b-runner"
|
|
33
|
+
* @param {string} opts.baseBranch - e.g. "develop"
|
|
34
|
+
* @param {string[]} [opts.referencedSkills] - story's referenced_skill list
|
|
35
|
+
* (LC-001 cross-ref); empty/undefined → "(all skills evaluated)"
|
|
36
|
+
* @param {string} [opts.generatedAt] - ISO timestamp; defaults to now
|
|
37
|
+
* @returns {string} markdown artifact text
|
|
38
|
+
*/
|
|
39
|
+
function renderStep5bArtifact(opts = {}) {
|
|
40
|
+
const result = opts.result || {};
|
|
41
|
+
const findings = Array.isArray(result.findings) ? result.findings : [];
|
|
42
|
+
const scannedSkills = Array.isArray(result.scannedSkills) ? result.scannedSkills : [];
|
|
43
|
+
const skipped = Array.isArray(result.skipped) ? result.skipped : [];
|
|
44
|
+
const warnings = Array.isArray(result.warnings) ? result.warnings : [];
|
|
45
|
+
|
|
46
|
+
const storyId = opts.storyId || '<unknown-story>';
|
|
47
|
+
const branchName = opts.branchName || '<unknown-branch>';
|
|
48
|
+
const baseBranch = opts.baseBranch || 'develop';
|
|
49
|
+
const generatedAt = opts.generatedAt || new Date().toISOString();
|
|
50
|
+
const referenced = Array.isArray(opts.referencedSkills) ? opts.referencedSkills : [];
|
|
51
|
+
|
|
52
|
+
const lines = [];
|
|
53
|
+
lines.push(`# ${storyId} — Step 5b: Skill Audit`);
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push(`> Branch: \`${branchName}\` vs \`${baseBranch}\``);
|
|
56
|
+
lines.push(`> Generated: ${generatedAt}`);
|
|
57
|
+
lines.push(`> Engine: \`cli/lib/skill-assertions.js\` (SA-001 v1)`);
|
|
58
|
+
lines.push(`> Gating: **informational only** — exit-code-0 by contract; severity-to-gate mapping deferred to OptionsFlow E29-G.`);
|
|
59
|
+
lines.push('');
|
|
60
|
+
|
|
61
|
+
// ─── Marker 1: ^## Skill audit (canonical heading) ───
|
|
62
|
+
lines.push('## Skill audit');
|
|
63
|
+
lines.push('');
|
|
64
|
+
|
|
65
|
+
// Counts header — concise summary
|
|
66
|
+
const counts = countBySeverity(findings);
|
|
67
|
+
lines.push(`- skills scanned: **${scannedSkills.length}**${scannedSkills.length > 0 ? ' — ' + scannedSkills.map(s => '`' + s + '`').join(', ') : ''}`);
|
|
68
|
+
lines.push(`- skills skipped: **${skipped.length}**`);
|
|
69
|
+
lines.push(`- findings: **${findings.length}** (${counts.BLOCKER} BLOCKER · ${counts.WARN} WARN · ${counts.INFO} INFO)`);
|
|
70
|
+
lines.push(`- parse warnings: **${warnings.length}**`);
|
|
71
|
+
if (referenced.length > 0) {
|
|
72
|
+
lines.push(`- referenced_skill scope: ${referenced.map(s => '`' + s + '`').join(', ')}`);
|
|
73
|
+
} else {
|
|
74
|
+
lines.push(`- referenced_skill scope: _(none — all skills evaluated)_`);
|
|
75
|
+
}
|
|
76
|
+
lines.push('');
|
|
77
|
+
|
|
78
|
+
// ─── Findings table (Markers 2 + 3 satisfied per row) ───
|
|
79
|
+
if (findings.length === 0) {
|
|
80
|
+
lines.push('**0 assertions matched the diff.** Either no skill defined `## Assertions`, or no added line in this PR triggered any assertion.');
|
|
81
|
+
lines.push('');
|
|
82
|
+
} else {
|
|
83
|
+
lines.push('### Findings');
|
|
84
|
+
lines.push('');
|
|
85
|
+
lines.push('| skill | assertion | severity | file:line | description |');
|
|
86
|
+
lines.push('|---|---|---|---|---|');
|
|
87
|
+
for (const f of findings) {
|
|
88
|
+
const skillId = f.skillId || '?';
|
|
89
|
+
const assertionId = f.assertionId || '?';
|
|
90
|
+
const severity = f.severity || 'INFO';
|
|
91
|
+
const file = f.matchedFile || '?';
|
|
92
|
+
const line = f.matchedLine != null ? String(f.matchedLine) : '?';
|
|
93
|
+
const desc = (f.description || '').replace(/\|/g, '\\|');
|
|
94
|
+
// Marker 2: skill + assertion on same line ✓
|
|
95
|
+
// Marker 3: severity + file path on same line ✓
|
|
96
|
+
lines.push(`| \`${skillId}\` | \`${assertionId}\` | **${severity}** | \`${file}\`:${line} | ${desc} |`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('');
|
|
99
|
+
|
|
100
|
+
// Per-finding details with the matched text (bounded for artifact size)
|
|
101
|
+
lines.push('### Matched text');
|
|
102
|
+
lines.push('');
|
|
103
|
+
for (const f of findings) {
|
|
104
|
+
const matched = (f.matchedText || '').replace(/`/g, '\\`');
|
|
105
|
+
lines.push(`- \`${f.skillId}\` / \`${f.assertionId}\` at \`${f.matchedFile}\`:${f.matchedLine}`);
|
|
106
|
+
lines.push(` - pattern: \`${f.forbidAddedSrc || '?'}\``);
|
|
107
|
+
lines.push(` - matched: \`${matched.slice(0, 200)}\``);
|
|
108
|
+
}
|
|
109
|
+
lines.push('');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ─── Skipped skills (informational) ───
|
|
113
|
+
if (skipped.length > 0) {
|
|
114
|
+
lines.push('### Skipped');
|
|
115
|
+
lines.push('');
|
|
116
|
+
for (const s of skipped) {
|
|
117
|
+
lines.push(`- \`${s.skillId}\` — ${s.reason}`);
|
|
118
|
+
}
|
|
119
|
+
lines.push('');
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Parse warnings (skill-author errors, surfaced for fixing) ───
|
|
123
|
+
if (warnings.length > 0) {
|
|
124
|
+
lines.push('### Parse warnings');
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push('Skill authors: these are likely errors in your `## Assertions` block.');
|
|
127
|
+
lines.push('');
|
|
128
|
+
for (const w of warnings) {
|
|
129
|
+
lines.push(`- \`${w.skillId || '?'}\` — **${w.kind}**: ${w.message}`);
|
|
130
|
+
}
|
|
131
|
+
lines.push('');
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Footer: contract reference
|
|
135
|
+
lines.push('---');
|
|
136
|
+
lines.push('');
|
|
137
|
+
lines.push('Generated by `hone step-5b`. See [#137](https://github.com/subbareddyvani/hone-server/issues/137) for the runtime audit contract; SA-001 / SA-002 for the engine + runner.');
|
|
138
|
+
lines.push('');
|
|
139
|
+
|
|
140
|
+
return lines.join('\n');
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Count findings by severity. Returns { BLOCKER, WARN, INFO }.
|
|
145
|
+
*/
|
|
146
|
+
function countBySeverity(findings) {
|
|
147
|
+
const out = { BLOCKER: 0, WARN: 0, INFO: 0 };
|
|
148
|
+
for (const f of findings) {
|
|
149
|
+
const s = f && f.severity;
|
|
150
|
+
if (s === 'BLOCKER' || s === 'WARN' || s === 'INFO') out[s] += 1;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
module.exports = {
|
|
156
|
+
renderStep5bArtifact,
|
|
157
|
+
countBySeverity,
|
|
158
|
+
};
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* skill-audit.js — H-085 pure-helper-with-injected-I/O for skill auditing.
|
|
4
|
+
*
|
|
5
|
+
* Audits skills against the current codebase + learnings. Reports drift in
|
|
6
|
+
* three categories:
|
|
7
|
+
* - PATH_BROKEN: skill mentions a file path that doesn't exist
|
|
8
|
+
* - CROSS_REF_BROKEN: skill references "see skill X" where X doesn't exist
|
|
9
|
+
* - SKILL_CANDIDATE: learnings tag `candidate_for: enterprise/skills/X`
|
|
10
|
+
* where X doesn't exist (new skill overdue)
|
|
11
|
+
*
|
|
12
|
+
* LC-002 (#58 G2 partial, story 2/3): SKILL_CANDIDATE threshold is
|
|
13
|
+
* WEIGHTED by `category`. Only `discovered-new-pattern` learnings count
|
|
14
|
+
* toward the ≥2 threshold; `applied-existing-rule` and
|
|
15
|
+
* `exception-with-rationale` are excluded (they are compliance evidence
|
|
16
|
+
* and documented deviations, NOT promotion candidates). Missing category
|
|
17
|
+
* defaults to counted (back-compat for pre-LC-001 learnings).
|
|
18
|
+
*
|
|
19
|
+
* M scope. L scope deferred:
|
|
20
|
+
* - Symbol-validity check (requires AST tooling)
|
|
21
|
+
* - Origin-story freshness (requires git log scan)
|
|
22
|
+
*
|
|
23
|
+
* Issue: #85 (H-085) + #58 (LC-002).
|
|
24
|
+
*/
|
|
25
|
+
const yaml = require('js-yaml');
|
|
26
|
+
const { LEARNING_CATEGORIES } = require('./learnings-parse');
|
|
27
|
+
|
|
28
|
+
// AU-001 (#159): path-candidate verdicts. Each candidate string
|
|
29
|
+
// extracted from skill markdown gets one of these verdicts; only
|
|
30
|
+
// is_file_path triggers an existence check.
|
|
31
|
+
const VERDICTS = Object.freeze({
|
|
32
|
+
IS_FILE_PATH: 'is_file_path',
|
|
33
|
+
IS_URL: 'is_url',
|
|
34
|
+
IS_PYTHON_DOTTED: 'is_python_dotted',
|
|
35
|
+
IS_CODE_BLOCK: 'is_code_block',
|
|
36
|
+
IS_INLINE_PROSE: 'is_inline_prose',
|
|
37
|
+
UNCLASSIFIED: 'unclassified',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Known file-tree prefixes — paths starting with one of these are
|
|
41
|
+
// strong-positive for is_file_path. Adopters with custom prefixes
|
|
42
|
+
// can extend via .hone/audit-skills.ignore.yml (allow-list).
|
|
43
|
+
const KNOWN_PATH_PREFIXES = [
|
|
44
|
+
'src/', 'lib/', 'tests/', 'docs/', 'scripts/',
|
|
45
|
+
'.github/', '.hone/', 'server/', 'cli/',
|
|
46
|
+
'pages/', 'app/', 'public/', 'assets/',
|
|
47
|
+
'config/', 'enterprise/', 'enterprise-assets/',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Common file extensions — paths ending in one of these are
|
|
51
|
+
// strong-positive for is_file_path.
|
|
52
|
+
const KNOWN_FILE_EXTENSIONS = new Set([
|
|
53
|
+
'js', 'ts', 'jsx', 'tsx', 'mjs', 'cjs',
|
|
54
|
+
'py', 'rb', 'go', 'rs', 'java', 'kt',
|
|
55
|
+
'md', 'mdx', 'rst', 'txt',
|
|
56
|
+
'yml', 'yaml', 'json', 'toml', 'ini', 'env',
|
|
57
|
+
'sh', 'bash', 'zsh', 'fish',
|
|
58
|
+
'css', 'scss', 'sass', 'less',
|
|
59
|
+
'html', 'xml', 'svg',
|
|
60
|
+
'sql', 'dockerfile', 'makefile',
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* AU-001: classify a candidate path string extracted from skill markdown.
|
|
65
|
+
*
|
|
66
|
+
* Verdicts (first match wins):
|
|
67
|
+
* 1. is_code_block — ctx.inCodeBlock === true (skip path-checking)
|
|
68
|
+
* 2. is_url — http(s)://, ://-scheme, system paths,
|
|
69
|
+
* or HTTP-shaped (module.X/Y like requests.get/post)
|
|
70
|
+
* 3. is_python_dotted — has dotted name (foo.bar) — Python module ref
|
|
71
|
+
* 4. is_inline_prose — contains " / " (space + slash) → prose
|
|
72
|
+
* 5. is_file_path — known prefix OR known extension
|
|
73
|
+
* 6. unclassified — none of the above; not flagged but no positive
|
|
74
|
+
* evidence. Caller can treat as low-signal.
|
|
75
|
+
*
|
|
76
|
+
* @param {string} p - the candidate path
|
|
77
|
+
* @param {object} ctx - { inCodeBlock?: boolean }
|
|
78
|
+
* @returns {{ verdict: string, reason: string }}
|
|
79
|
+
*/
|
|
80
|
+
function classifyPathCandidate(p, ctx = {}) {
|
|
81
|
+
if (typeof p !== 'string' || !p) {
|
|
82
|
+
return { verdict: VERDICTS.UNCLASSIFIED, reason: 'empty input' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// 1. In-code-block check (architect AC-4): never path-check fenced content
|
|
86
|
+
if (ctx.inCodeBlock === true) {
|
|
87
|
+
return { verdict: VERDICTS.IS_CODE_BLOCK, reason: 'inside fenced code block' };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 2a. URL / scheme / system path checks (existing behavior, retained)
|
|
91
|
+
if (p.startsWith('http://') || p.startsWith('https://') || p.includes('://')) {
|
|
92
|
+
return { verdict: VERDICTS.IS_URL, reason: 'starts with URL scheme' };
|
|
93
|
+
}
|
|
94
|
+
if (/^(usr|etc|tmp|var|opt)\//.test(p)) {
|
|
95
|
+
return { verdict: VERDICTS.IS_URL, reason: 'system path (treated as non-repo)' };
|
|
96
|
+
}
|
|
97
|
+
// Placeholders
|
|
98
|
+
if (/<[A-Z_a-z]\w*>|\$\{|\.\.\./.test(p)) {
|
|
99
|
+
return { verdict: VERDICTS.UNCLASSIFIED, reason: 'placeholder' };
|
|
100
|
+
}
|
|
101
|
+
if (/^path\/to\/|^example\/|^foo\/bar/.test(p)) {
|
|
102
|
+
return { verdict: VERDICTS.UNCLASSIFIED, reason: 'documentation placeholder' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// 2b. HTTP-shape check (NEW): module.X/Y style → API endpoint
|
|
106
|
+
// Examples:
|
|
107
|
+
// tradier./markets/quotes ← starts with module-name then dot then /
|
|
108
|
+
// requests.get/post ← module.method / method
|
|
109
|
+
// api.v1/users ← module.subpath / resource
|
|
110
|
+
if (/^[a-z][a-z0-9_]*\.(?:[a-zA-Z_][a-zA-Z0-9_]*)?\/[a-zA-Z]/.test(p)) {
|
|
111
|
+
return { verdict: VERDICTS.IS_URL, reason: 'HTTP-shape (module.X/Y looks like API endpoint)' };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 3. Python-dotted-name check (NEW): symbol references
|
|
115
|
+
// Examples:
|
|
116
|
+
// src/indicators.detect_trap (path/module.symbol)
|
|
117
|
+
// src/session_phase.NO_TRADE_PHASES (path/module.CONST)
|
|
118
|
+
// Pattern: trailing `.<identifier>` after a slash-segment, where the
|
|
119
|
+
// identifier is NOT a recognized file extension.
|
|
120
|
+
const trailingDotMatch = p.match(/^(.+\/)?([a-z_][a-zA-Z0-9_]*)\.([a-zA-Z_][a-zA-Z0-9_]*)$/);
|
|
121
|
+
if (trailingDotMatch) {
|
|
122
|
+
const ext = trailingDotMatch[3].toLowerCase();
|
|
123
|
+
if (!KNOWN_FILE_EXTENSIONS.has(ext)) {
|
|
124
|
+
// Not a known file extension → this is a dotted symbol reference,
|
|
125
|
+
// not a file path with extension.
|
|
126
|
+
return { verdict: VERDICTS.IS_PYTHON_DOTTED, reason: `trailing .${trailingDotMatch[3]} is not a known file extension` };
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// 4. Forward-slash-with-space check (NEW): prose
|
|
131
|
+
if (/\s\/\s|\/\s|\s\//.test(p)) {
|
|
132
|
+
return { verdict: VERDICTS.IS_INLINE_PROSE, reason: 'contains space-adjacent slash (prose)' };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 5a. Known prefix → is_file_path
|
|
136
|
+
for (const prefix of KNOWN_PATH_PREFIXES) {
|
|
137
|
+
if (p.startsWith(prefix)) {
|
|
138
|
+
return { verdict: VERDICTS.IS_FILE_PATH, reason: `starts with known prefix "${prefix}"` };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// 5b. Known extension → is_file_path
|
|
143
|
+
const extMatch = p.match(/\.([a-zA-Z0-9]+)$/);
|
|
144
|
+
if (extMatch && KNOWN_FILE_EXTENSIONS.has(extMatch[1].toLowerCase())) {
|
|
145
|
+
return { verdict: VERDICTS.IS_FILE_PATH, reason: `ends with known extension .${extMatch[1]}` };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// 6. Default
|
|
149
|
+
return { verdict: VERDICTS.UNCLASSIFIED, reason: 'no positive evidence; not flagged' };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* AU-001: collect all path candidates from a skill's markdown content,
|
|
154
|
+
* tracking whether each candidate was inside a fenced code block.
|
|
155
|
+
*
|
|
156
|
+
* Returns: Array<{ path: string, inCodeBlock: boolean }>
|
|
157
|
+
*/
|
|
158
|
+
function collectPathCandidates(content) {
|
|
159
|
+
const candidates = [];
|
|
160
|
+
if (typeof content !== 'string' || !content) return candidates;
|
|
161
|
+
const lines = content.split('\n');
|
|
162
|
+
let inCodeBlock = false;
|
|
163
|
+
// Inline-backtick path pattern (must contain a slash, must look path-shaped)
|
|
164
|
+
const inlineRe = /`([a-zA-Z][a-zA-Z0-9._\-/]*\/[a-zA-Z0-9._\-/]+(?:\.[a-zA-Z0-9]+)?)`/g;
|
|
165
|
+
// Bare-line path pattern (within code blocks; no backticks needed)
|
|
166
|
+
const bareLineRe = /\b([a-zA-Z][a-zA-Z0-9._\-/]*\/[a-zA-Z0-9._\-/]+\.[a-zA-Z0-9]+)\b/g;
|
|
167
|
+
for (const line of lines) {
|
|
168
|
+
// Track triple-backtick fences
|
|
169
|
+
if (/^\s*```/.test(line)) {
|
|
170
|
+
inCodeBlock = !inCodeBlock;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (inCodeBlock) {
|
|
174
|
+
// Code-block lines: gather bare paths too (no backticks required;
|
|
175
|
+
// people write `src/foo.py` style without backticks inside fences)
|
|
176
|
+
let m;
|
|
177
|
+
while ((m = bareLineRe.exec(line)) !== null) {
|
|
178
|
+
candidates.push({ path: m[1], inCodeBlock: true });
|
|
179
|
+
}
|
|
180
|
+
bareLineRe.lastIndex = 0;
|
|
181
|
+
} else {
|
|
182
|
+
// Non-code-block lines: only inline-backtick-wrapped paths
|
|
183
|
+
let m;
|
|
184
|
+
while ((m = inlineRe.exec(line)) !== null) {
|
|
185
|
+
candidates.push({ path: m[1], inCodeBlock: false });
|
|
186
|
+
}
|
|
187
|
+
inlineRe.lastIndex = 0;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return candidates;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Simple glob matcher: `*` → any chars, `?` → one char. AU-001. */
|
|
194
|
+
function globMatch(pattern, candidate) {
|
|
195
|
+
if (typeof pattern !== 'string' || typeof candidate !== 'string') return false;
|
|
196
|
+
const re = '^' + pattern
|
|
197
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&') // escape regex specials
|
|
198
|
+
.replace(/\*/g, '.*')
|
|
199
|
+
.replace(/\?/g, '.') + '$';
|
|
200
|
+
try { return new RegExp(re).test(candidate); }
|
|
201
|
+
catch { return false; }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Audit skills against codebase + learnings.
|
|
206
|
+
*
|
|
207
|
+
* @param {object} opts
|
|
208
|
+
* @param {string[]} opts.skillFiles - SKILL.md content per skill (array of strings)
|
|
209
|
+
* @param {string[]} opts.skillNames - matching skill names
|
|
210
|
+
* @param {string[]} opts.learningsFiles - learnings YAML content per file
|
|
211
|
+
* @param {(relativePath: string) => boolean} opts.fileExists - filesystem check
|
|
212
|
+
* @returns {{ findings: Array<{severity, category, skill, message}>, summary: object }}
|
|
213
|
+
*/
|
|
214
|
+
function auditSkills(opts = {}) {
|
|
215
|
+
const { skillFiles = [], skillNames = [], learningsFiles = [], fileExists } = opts;
|
|
216
|
+
const findings = [];
|
|
217
|
+
|
|
218
|
+
if (typeof fileExists !== 'function') {
|
|
219
|
+
return { findings: [], summary: { error: 'missing fileExists callback' } };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const knownSkillSet = new Set(skillNames);
|
|
223
|
+
|
|
224
|
+
// AU-001 (#159): adopter-supplied allow-list. Entries match LITERALLY
|
|
225
|
+
// unless they contain * or ? (then treated as glob).
|
|
226
|
+
const ignoreList = Array.isArray(opts.ignoreList)
|
|
227
|
+
? opts.ignoreList.filter(x => typeof x === 'string')
|
|
228
|
+
: [];
|
|
229
|
+
function isIgnored(p) {
|
|
230
|
+
for (const pattern of ignoreList) {
|
|
231
|
+
if (pattern === p) return true;
|
|
232
|
+
if ((pattern.includes('*') || pattern.includes('?')) && globMatch(pattern, p)) return true;
|
|
233
|
+
}
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── 1. Path validity per skill ───
|
|
238
|
+
for (let i = 0; i < skillFiles.length; i++) {
|
|
239
|
+
const content = skillFiles[i] || '';
|
|
240
|
+
const skillName = skillNames[i] || `<skill-${i}>`;
|
|
241
|
+
// AU-001 (#159): walk markdown line-by-line so we can track triple-backtick
|
|
242
|
+
// code-block state. Paths inside fenced code blocks are illustrative, not
|
|
243
|
+
// assertions about the repo layout.
|
|
244
|
+
const candidates = collectPathCandidates(content);
|
|
245
|
+
const seen = new Set();
|
|
246
|
+
for (const cand of candidates) {
|
|
247
|
+
const p = cand.path;
|
|
248
|
+
// De-dup
|
|
249
|
+
if (seen.has(p)) continue;
|
|
250
|
+
seen.add(p);
|
|
251
|
+
|
|
252
|
+
// AU-001 (#159): allow-list short-circuit
|
|
253
|
+
if (isIgnored(p)) continue;
|
|
254
|
+
|
|
255
|
+
// AU-001: classify the candidate. Verdicts other than is_file_path
|
|
256
|
+
// skip the existence check (URLs, code-block content, Python-dotted
|
|
257
|
+
// names, prose) without flagging.
|
|
258
|
+
const verdict = classifyPathCandidate(p, { inCodeBlock: cand.inCodeBlock }).verdict;
|
|
259
|
+
if (verdict !== 'is_file_path') continue;
|
|
260
|
+
|
|
261
|
+
// Skip cross-skill references (handled by category 2 — CROSS_REF_BROKEN)
|
|
262
|
+
// Pattern: `<some-skill>/SKILL.md` — don't double-flag.
|
|
263
|
+
if (/^[a-z-]+\/SKILL\.md$/.test(p)) continue;
|
|
264
|
+
|
|
265
|
+
if (!fileExists(p)) {
|
|
266
|
+
findings.push({
|
|
267
|
+
severity: 'ERROR',
|
|
268
|
+
category: 'PATH_BROKEN',
|
|
269
|
+
skill: skillName,
|
|
270
|
+
classifierVerdict: verdict, // AU-001 AC-5: surface the call
|
|
271
|
+
message: `path "${p}" does not exist in repo`,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// ─── 2. Cross-skill-ref validity ───
|
|
278
|
+
for (let i = 0; i < skillFiles.length; i++) {
|
|
279
|
+
const content = skillFiles[i] || '';
|
|
280
|
+
const skillName = skillNames[i] || `<skill-${i}>`;
|
|
281
|
+
// Match "skill-name/SKILL.md" mentions in skill bodies
|
|
282
|
+
const refPattern = /`([a-z-]+)\/SKILL\.md`/g;
|
|
283
|
+
const seen = new Set();
|
|
284
|
+
let m;
|
|
285
|
+
while ((m = refPattern.exec(content)) !== null) {
|
|
286
|
+
const target = m[1];
|
|
287
|
+
if (target === skillName) continue; // self-reference is fine
|
|
288
|
+
if (seen.has(target)) continue;
|
|
289
|
+
seen.add(target);
|
|
290
|
+
if (!knownSkillSet.has(target)) {
|
|
291
|
+
findings.push({
|
|
292
|
+
severity: 'ERROR',
|
|
293
|
+
category: 'CROSS_REF_BROKEN',
|
|
294
|
+
skill: skillName,
|
|
295
|
+
message: `references "${target}/SKILL.md" but no such skill exists`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// ─── 3. Untagged-pattern detection (new-skill candidates from learnings) ───
|
|
302
|
+
//
|
|
303
|
+
// LC-002: weight by `category` (LC-001 schema field). Only items that
|
|
304
|
+
// are categorized as `discovered-new-pattern` — OR have no category at
|
|
305
|
+
// all (back-compat for pre-LC-001 learnings) — count toward the ≥2
|
|
306
|
+
// threshold. `applied-existing-rule` and `exception-with-rationale`
|
|
307
|
+
// are excluded: they are compliance/deviation evidence, NOT signals
|
|
308
|
+
// that a new skill is overdue.
|
|
309
|
+
//
|
|
310
|
+
// Implementation: parse YAML once and walk items in any of the 3
|
|
311
|
+
// schemas; pick up `referenced_skill` (LC-001) || `candidate_for`
|
|
312
|
+
// (Schema B) || `skill_update` (Schema A) as the candidate skill.
|
|
313
|
+
const skillCandidates = new Map(); // name → { weighted, total, byCategory }
|
|
314
|
+
for (const lf of learningsFiles) {
|
|
315
|
+
if (!lf) continue;
|
|
316
|
+
let parsed;
|
|
317
|
+
try { parsed = yaml.load(lf); } catch { continue; }
|
|
318
|
+
if (!parsed) continue;
|
|
319
|
+
|
|
320
|
+
// Walk items in whichever schema this file uses.
|
|
321
|
+
const items = [];
|
|
322
|
+
if (Array.isArray(parsed)) {
|
|
323
|
+
items.push(...parsed); // Schema A
|
|
324
|
+
} else if (typeof parsed === 'object') {
|
|
325
|
+
if (Array.isArray(parsed.enterprise_candidates)) items.push(...parsed.enterprise_candidates); // Schema B
|
|
326
|
+
if (Array.isArray(parsed.learnings)) items.push(...parsed.learnings); // Schema C
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
for (const item of items) {
|
|
330
|
+
if (!item || typeof item !== 'object') continue;
|
|
331
|
+
// Pick the candidate skill ref (in priority order).
|
|
332
|
+
const ref = item.referenced_skill || item.candidate_for || item.skill_update;
|
|
333
|
+
if (!ref || typeof ref !== 'string') continue;
|
|
334
|
+
// Strip enterprise/skills/ prefix and /SKILL.md suffix.
|
|
335
|
+
const candidate = ref.replace(/^enterprise\/skills\//, '').replace(/\/SKILL\.md$/, '');
|
|
336
|
+
if (!candidate || candidate.includes('/') || candidate.length < 3) continue;
|
|
337
|
+
if (knownSkillSet.has(candidate)) continue;
|
|
338
|
+
|
|
339
|
+
const cat = (typeof item.category === 'string' && item.category) || null;
|
|
340
|
+
// counts toward threshold: explicit discovered-new-pattern OR uncategorized
|
|
341
|
+
const counts = (cat === null || cat === LEARNING_CATEGORIES.DISCOVERED_NEW_PATTERN);
|
|
342
|
+
|
|
343
|
+
let entry = skillCandidates.get(candidate);
|
|
344
|
+
if (!entry) {
|
|
345
|
+
entry = { weighted: 0, total: 0, byCategory: { discovered: 0, applied: 0, exception: 0, uncategorized: 0 } };
|
|
346
|
+
skillCandidates.set(candidate, entry);
|
|
347
|
+
}
|
|
348
|
+
entry.total += 1;
|
|
349
|
+
if (counts) entry.weighted += 1;
|
|
350
|
+
if (cat === LEARNING_CATEGORIES.DISCOVERED_NEW_PATTERN) entry.byCategory.discovered += 1;
|
|
351
|
+
else if (cat === LEARNING_CATEGORIES.APPLIED_EXISTING_RULE) entry.byCategory.applied += 1;
|
|
352
|
+
else if (cat === LEARNING_CATEGORIES.EXCEPTION_WITH_RATIONALE) entry.byCategory.exception += 1;
|
|
353
|
+
else entry.byCategory.uncategorized += 1;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
for (const [candidate, entry] of skillCandidates) {
|
|
358
|
+
if (entry.weighted >= 2) { // Threshold: ≥2 weighted learnings → flag
|
|
359
|
+
const bc = entry.byCategory;
|
|
360
|
+
const detail = `${bc.discovered} discovered-new-pattern, ${bc.uncategorized} uncategorized, ${bc.applied} applied-existing (excluded), ${bc.exception} exception (excluded)`;
|
|
361
|
+
findings.push({
|
|
362
|
+
severity: 'INFO',
|
|
363
|
+
category: 'SKILL_CANDIDATE',
|
|
364
|
+
skill: candidate,
|
|
365
|
+
message: `${entry.weighted} of ${entry.total} learnings reference "${candidate}" as a promotion candidate but no such skill exists — consider creating it (${detail})`,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ─── Summary ───
|
|
371
|
+
const summary = {
|
|
372
|
+
skills_audited: skillFiles.length,
|
|
373
|
+
learnings_scanned: learningsFiles.length,
|
|
374
|
+
error_count: findings.filter((f) => f.severity === 'ERROR').length,
|
|
375
|
+
warn_count: findings.filter((f) => f.severity === 'WARN').length,
|
|
376
|
+
info_count: findings.filter((f) => f.severity === 'INFO').length,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
return { findings, summary };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
module.exports = {
|
|
383
|
+
auditSkills,
|
|
384
|
+
// AU-001 (#159): exported for unit tests + future consumers
|
|
385
|
+
classifyPathCandidate,
|
|
386
|
+
collectPathCandidates,
|
|
387
|
+
globMatch,
|
|
388
|
+
VERDICTS,
|
|
389
|
+
KNOWN_PATH_PREFIXES,
|
|
390
|
+
KNOWN_FILE_EXTENSIONS,
|
|
391
|
+
};
|