@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,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
+ };