@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,124 @@
1
+ 'use strict';
2
+ /**
3
+ * autofix-guardrails.js — H-016 CI auto-fix safety guardrails.
4
+ *
5
+ * Pure helpers — no I/O. Caller (.github/scripts/check-autofix-safety.js
6
+ * adopter-side) reads the diff via `git diff` + counts recent auto-fix
7
+ * commits via `git log --grep '\\[auto-fix\\]'`; passes both to
8
+ * evaluateAutofixSafety.
9
+ *
10
+ * Three guardrails per OptionsFlow E27-E:
11
+ * - Max-attempts: refuse 3rd auto-fix push on same PR (loop guard)
12
+ * - Snapshot-capitulation: reject `assert` line edits in test files
13
+ * ("just update the snapshot" is the wrong fix)
14
+ * - Scope allowlist: reject diffs outside src/, tests/, scripts/,
15
+ * .github/, README.md, docs/
16
+ *
17
+ * Closes #60 sub-task (a). Sub-tasks (e/f) auto-install + (g) stack-aware
18
+ * lint tokens deferred to follow-up.
19
+ *
20
+ * 14th instance of pure-helpers + thin-CLI-shell pattern.
21
+ */
22
+
23
+ // ─────────────────────────────────────────────────────────────────────
24
+ // Constants — exposed for adopter-side scripts that need to align
25
+ // ─────────────────────────────────────────────────────────────────────
26
+
27
+ // Refuse the 3rd auto-fix push on the same PR (count >= 2 blocks).
28
+ const MAX_AUTOFIX_ATTEMPTS = 2;
29
+
30
+ // Default scope allowlist. Adopters can extend via .pipeline-config.yml
31
+ // (deferred — sub-task D4).
32
+ const ALLOWED_SCOPE_PATTERNS = [
33
+ /^src\//,
34
+ /^tests?\//, // tests/ or test/
35
+ /^scripts\//,
36
+ /^\.github\//,
37
+ /^README\.md$/,
38
+ /^docs\//,
39
+ ];
40
+
41
+ // Detect snapshot-capitulation: a + or - line that starts with `assert`.
42
+ // Only trips when the diff hunk is in a test path.
43
+ const SNAPSHOT_CAPITULATION_RE = /^[+-]\s*assert\b/m;
44
+
45
+ // Test path detector for snapshot-capitulation rule.
46
+ const TEST_PATH_RE = /^\+\+\+ b\/(tests?\/|.*?\/__tests__\/)/m;
47
+
48
+ // ─────────────────────────────────────────────────────────────────────
49
+ // hasSnapshotCapitulation — detect assert-line edits in test diffs
50
+ // ─────────────────────────────────────────────────────────────────────
51
+ // Walks the diff hunk by hunk. For each hunk targeting a test path,
52
+ // check for + or - lines starting with `assert`. Either form is
53
+ // capitulation: removing an assert weakens the test; changing one
54
+ // updates it to match buggy code.
55
+ function hasSnapshotCapitulation(diff) {
56
+ if (typeof diff !== 'string' || diff.length === 0) return false;
57
+ // Split diff into per-file blocks (each `diff --git` starts a new block)
58
+ const blocks = diff.split(/^diff --git /m);
59
+ for (const block of blocks) {
60
+ if (!block || !block.includes('+++')) continue;
61
+ // Get the +++ b/<path> line — that's the destination path
62
+ const pathMatch = block.match(/^\+\+\+ b\/(.+)$/m);
63
+ if (!pathMatch) continue;
64
+ const dstPath = pathMatch[1];
65
+ // Is this a test file?
66
+ const isTestPath = /^tests?\//.test(dstPath) || /\/__tests__\//.test(dstPath) || /\.test\./.test(dstPath) || /\.spec\./.test(dstPath);
67
+ if (!isTestPath) continue;
68
+ // Check the hunk for assert-line edits
69
+ if (SNAPSHOT_CAPITULATION_RE.test(block)) return true;
70
+ }
71
+ return false;
72
+ }
73
+
74
+ // ─────────────────────────────────────────────────────────────────────
75
+ // hasScopeViolation — detect diffs touching paths outside the allowlist
76
+ // ─────────────────────────────────────────────────────────────────────
77
+ function hasScopeViolation(diff) {
78
+ if (typeof diff !== 'string' || diff.length === 0) return false;
79
+ // Extract all `+++ b/<path>` lines
80
+ const matches = [...diff.matchAll(/^\+\+\+ b\/(.+)$/gm)];
81
+ for (const m of matches) {
82
+ const dstPath = m[1].trim();
83
+ // Skip /dev/null (deletion targets)
84
+ if (dstPath === '/dev/null') continue;
85
+ // Check against allowlist
86
+ const allowed = ALLOWED_SCOPE_PATTERNS.some(re => re.test(dstPath));
87
+ if (!allowed) return true; // any out-of-scope path triggers
88
+ }
89
+ return false;
90
+ }
91
+
92
+ // ─────────────────────────────────────────────────────────────────────
93
+ // evaluateAutofixSafety — main entry point
94
+ // ─────────────────────────────────────────────────────────────────────
95
+ // inputs: { recentAutofixCount, diff }
96
+ // returns: { safe: boolean, blocked: string[] }
97
+ //
98
+ // blocked array preserves deterministic order:
99
+ // ['max-attempts', 'snapshot-capitulation', 'scope-allowlist']
100
+ function evaluateAutofixSafety({ recentAutofixCount = 0, diff = '' } = {}) {
101
+ const blocked = [];
102
+
103
+ if (recentAutofixCount >= MAX_AUTOFIX_ATTEMPTS) {
104
+ blocked.push('max-attempts');
105
+ }
106
+ if (hasSnapshotCapitulation(diff)) {
107
+ blocked.push('snapshot-capitulation');
108
+ }
109
+ if (hasScopeViolation(diff)) {
110
+ blocked.push('scope-allowlist');
111
+ }
112
+
113
+ return { safe: blocked.length === 0, blocked };
114
+ }
115
+
116
+ module.exports = {
117
+ MAX_AUTOFIX_ATTEMPTS,
118
+ ALLOWED_SCOPE_PATTERNS,
119
+ SNAPSHOT_CAPITULATION_RE,
120
+ TEST_PATH_RE,
121
+ hasSnapshotCapitulation,
122
+ hasScopeViolation,
123
+ evaluateAutofixSafety,
124
+ };
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+ /**
3
+ * cli/lib/branch-protection.js — H-001
4
+ *
5
+ * Installs GitHub branch protection on a repository's default branch
6
+ * after `hone setup` finishes scaffolding workflows. Prevents
7
+ * admin-merge from silently bypassing the AI Code Review gate the
8
+ * setup just installed.
9
+ *
10
+ * Closes #10 (H-001).
11
+ *
12
+ * Exports
13
+ * -------
14
+ * buildPayload({ contexts, requireReviews? })
15
+ * Pure. Returns the JSON payload for `PUT /repos/:owner/:repo/branches/:branch/protection`.
16
+ *
17
+ * mergeContexts(existing, added)
18
+ * Pure. Dedupes context names and preserves first-seen order so a
19
+ * pre-existing custom check (e.g. 'lint') is preserved when Hone
20
+ * appends 'ai-code-review'.
21
+ *
22
+ * parseGhError(stderr, exitCode)
23
+ * Pure. Classifies a `gh` invocation failure into a stable kind:
24
+ * no-gh / no-auth / no-admin / plan-restricted / org-ruleset / unknown.
25
+ *
26
+ * installBranchProtection({ exec?, log?, opts? })
27
+ * Orchestrator. Returns { status, reason?, branch?, manualCommand? }
28
+ * where status ∈ 'installed' | 'skipped' | 'failed'. Never throws,
29
+ * never exits — degrades gracefully so `hone setup` always returns 0.
30
+ *
31
+ * The `exec` parameter is injectable. Production calls default to a thin
32
+ * wrapper around child_process.spawnSync. Tests inject a stub matching
33
+ * the same { status, stdout, stderr } return shape — no real subprocess
34
+ * invocations during testing.
35
+ */
36
+
37
+ const DEFAULT_REQUIRED_CHECKS = ['ai-code-review'];
38
+
39
+ // ─────────────────────────────────────────────────────────────────────
40
+ // defaultExec — production exec, wraps spawnSync
41
+ // ─────────────────────────────────────────────────────────────────────
42
+ function defaultExec(cmd, args, opts = {}) {
43
+ const { spawnSync } = require('child_process');
44
+ const result = spawnSync(cmd, args, { encoding: 'utf8', ...opts });
45
+ return {
46
+ status: result.status === null ? -1 : result.status,
47
+ stdout: result.stdout || '',
48
+ stderr: result.stderr || '',
49
+ };
50
+ }
51
+
52
+ // ─────────────────────────────────────────────────────────────────────
53
+ // buildPayload — pure
54
+ // ─────────────────────────────────────────────────────────────────────
55
+ function buildPayload({ contexts = [], requireReviews } = {}) {
56
+ // Dedupe contexts while preserving order
57
+ const dedupedContexts = [];
58
+ const seen = new Set();
59
+ for (const c of contexts) {
60
+ if (!seen.has(c)) { seen.add(c); dedupedContexts.push(c); }
61
+ }
62
+ const reviewCount = typeof requireReviews === 'number' && requireReviews > 0
63
+ ? requireReviews
64
+ : 1;
65
+ return {
66
+ required_status_checks: {
67
+ strict: true,
68
+ contexts: dedupedContexts,
69
+ },
70
+ enforce_admins: true,
71
+ required_pull_request_reviews: {
72
+ required_approving_review_count: reviewCount,
73
+ dismiss_stale_reviews: true,
74
+ },
75
+ restrictions: null,
76
+ };
77
+ }
78
+
79
+ // ─────────────────────────────────────────────────────────────────────
80
+ // mergeContexts — pure
81
+ // ─────────────────────────────────────────────────────────────────────
82
+ function mergeContexts(existing, added) {
83
+ const existingArr = Array.isArray(existing) ? existing : [];
84
+ const addedArr = Array.isArray(added) ? added : [];
85
+ const out = [];
86
+ const seen = new Set();
87
+ for (const c of [...existingArr, ...addedArr]) {
88
+ if (!seen.has(c)) { seen.add(c); out.push(c); }
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // ─────────────────────────────────────────────────────────────────────
94
+ // parseGhError — pure
95
+ // ─────────────────────────────────────────────────────────────────────
96
+ function parseGhError(stderr = '', exitCode = 0) {
97
+ const s = String(stderr || '');
98
+ // gh binary missing entirely
99
+ if (exitCode === 127 || /command not found|gh:.*not found/i.test(s)) {
100
+ return { kind: 'no-gh', message: 'gh CLI not installed (or not on PATH)' };
101
+ }
102
+ // gh installed but not authenticated
103
+ if (/gh auth (login|status|required)|not authenticated|authentication required/i.test(s)) {
104
+ return { kind: 'no-auth', message: 'gh CLI not authenticated — run `gh auth login`' };
105
+ }
106
+ // 403 / no admin rights
107
+ if (/resource not accessible|admin permission|admin rights required|403/i.test(s)) {
108
+ return { kind: 'no-admin', message: 'Resource not accessible — admin rights required on this repository' };
109
+ }
110
+ // Plan restriction (private + free)
111
+ if (/upgrade.*github (pro|team|enterprise)|github (pro|team|enterprise).*required|requires.*github (pro|team|enterprise)/i.test(s)) {
112
+ return { kind: 'plan-restricted', message: 'plan-restricted — branch protection requires GitHub Pro for private repos' };
113
+ }
114
+ // Org-level ruleset
115
+ if (/repository ruleset|ruleset bypass|protected by.*ruleset/i.test(s)) {
116
+ return { kind: 'org-ruleset', message: 'org ruleset blocks this change — coordinate with the org admin' };
117
+ }
118
+ // Anything else
119
+ const trimmed = s.trim();
120
+ return {
121
+ kind: 'unknown',
122
+ message: trimmed.length > 0
123
+ ? `gh failed: ${trimmed}`
124
+ : `gh failed with exit code ${exitCode}`,
125
+ };
126
+ }
127
+
128
+ // ─────────────────────────────────────────────────────────────────────
129
+ // buildManualCommand — produces a copy-paste shell snippet for the user
130
+ // ─────────────────────────────────────────────────────────────────────
131
+ function buildManualCommand({ contexts = DEFAULT_REQUIRED_CHECKS, owner = '<owner>', repo = '<repo>', branch = 'main' } = {}) {
132
+ const contextLines = contexts
133
+ .map(c => ` -f 'required_status_checks[contexts][]=${c}' \\`)
134
+ .join('\n');
135
+ return [
136
+ `gh api --method PUT repos/${owner}/${repo}/branches/${branch}/protection \\`,
137
+ ` -f 'required_status_checks[strict]=true' \\`,
138
+ contextLines,
139
+ ` -f 'enforce_admins=true' \\`,
140
+ ` -f 'required_pull_request_reviews[required_approving_review_count]=1' \\`,
141
+ ` -f 'restrictions='`,
142
+ ].join('\n');
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────────
146
+ // installBranchProtection — orchestrator
147
+ // ─────────────────────────────────────────────────────────────────────
148
+ async function installBranchProtection({ exec = defaultExec, log = () => {}, opts = {} } = {}) {
149
+ const requiredChecks = (opts.requiredChecks && opts.requiredChecks.length)
150
+ ? opts.requiredChecks
151
+ : DEFAULT_REQUIRED_CHECKS;
152
+ const requireReviews = opts.requireReviews || 1;
153
+
154
+ // 1. Honor opt-out — short-circuit before any subprocess call
155
+ if (opts.skip) {
156
+ return {
157
+ status: 'skipped',
158
+ reason: '--no-branch-protection (opt-out)',
159
+ };
160
+ }
161
+
162
+ // 2. Resolve git remote
163
+ const remote = exec('git', ['remote', 'get-url', 'origin']);
164
+ if (remote.status !== 0) {
165
+ return {
166
+ status: 'skipped',
167
+ reason: 'no git remote — not a git repo, or remote "origin" missing',
168
+ manualCommand: buildManualCommand({ contexts: requiredChecks }),
169
+ };
170
+ }
171
+ if (!/github\.com/.test(remote.stdout)) {
172
+ return {
173
+ status: 'skipped',
174
+ reason: 'non-GitHub host (only github.com is supported for branch protection install)',
175
+ };
176
+ }
177
+
178
+ // 3. Resolve repo info via gh
179
+ const repoView = exec('gh', ['repo', 'view', '--json', 'owner,name,defaultBranchRef,private']);
180
+ if (repoView.status !== 0) {
181
+ const err = parseGhError(repoView.stderr, repoView.status);
182
+ return {
183
+ status: 'skipped',
184
+ reason: err.message,
185
+ manualCommand: buildManualCommand({ contexts: requiredChecks }),
186
+ };
187
+ }
188
+ let repoInfo;
189
+ try {
190
+ repoInfo = JSON.parse(repoView.stdout || '{}');
191
+ } catch {
192
+ return {
193
+ status: 'failed',
194
+ reason: 'could not parse `gh repo view` JSON output',
195
+ manualCommand: buildManualCommand({ contexts: requiredChecks }),
196
+ };
197
+ }
198
+ const owner = repoInfo.owner?.login;
199
+ const repo = repoInfo.name;
200
+ const branch = repoInfo.defaultBranchRef?.name || 'main';
201
+ if (!owner || !repo) {
202
+ return {
203
+ status: 'failed',
204
+ reason: '`gh repo view` did not return owner/name',
205
+ manualCommand: buildManualCommand({ contexts: requiredChecks }),
206
+ };
207
+ }
208
+
209
+ // 4. GET existing protection (404 = none, 200 = merge)
210
+ const protPath = `repos/${owner}/${repo}/branches/${branch}/protection`;
211
+ const existing = exec('gh', ['api', protPath]);
212
+ let existingContexts = [];
213
+ if (existing.status === 0) {
214
+ try {
215
+ const e = JSON.parse(existing.stdout || '{}');
216
+ existingContexts = (e && e.required_status_checks && Array.isArray(e.required_status_checks.contexts))
217
+ ? e.required_status_checks.contexts
218
+ : [];
219
+ } catch {
220
+ existingContexts = [];
221
+ }
222
+ }
223
+ // (non-zero exit is treated as "no existing protection" — the PUT below will succeed if the user has permissions)
224
+
225
+ // 5. Build merged payload
226
+ const mergedContexts = mergeContexts(existingContexts, requiredChecks);
227
+ const payload = buildPayload({ contexts: mergedContexts, requireReviews });
228
+
229
+ // 6. PUT
230
+ const put = exec(
231
+ 'gh',
232
+ ['api', '--method', 'PUT', protPath, '--input', '-'],
233
+ { input: JSON.stringify(payload) }
234
+ );
235
+ if (put.status === 0) {
236
+ return { status: 'installed', branch };
237
+ }
238
+
239
+ // 7. PUT failed — classify and return graceful failure
240
+ const err = parseGhError(put.stderr, put.status);
241
+ return {
242
+ status: 'failed',
243
+ reason: err.message,
244
+ manualCommand: buildManualCommand({ contexts: mergedContexts, owner, repo, branch }),
245
+ };
246
+ }
247
+
248
+ module.exports = {
249
+ buildPayload,
250
+ mergeContexts,
251
+ parseGhError,
252
+ installBranchProtection,
253
+ // Internal helpers exposed for inspection / future reuse
254
+ buildManualCommand,
255
+ DEFAULT_REQUIRED_CHECKS,
256
+ };
@@ -0,0 +1,150 @@
1
+ 'use strict';
2
+ /**
3
+ * ci-classifier.js — H-061 rule-based CI failure classifier.
4
+ *
5
+ * Reads CI log text and returns named categories with severity ranking.
6
+ * Pure helper — no I/O. Caller provides the log text; we return the
7
+ * classification.
8
+ *
9
+ * Categories form a stable contract that downstream stories switch on:
10
+ * - #19 (H-010 CI → skills feedback loop) consumes `category`
11
+ * - #60 (CI auto-fix workflow templates) consumes `auto_fixable`
12
+ *
13
+ * 6th instance of the pure-helpers + thin-CLI-shell pattern in cli/lib/
14
+ * after auto-detect.js (H-018), learnings-parse.js (H-035),
15
+ * pipeline-status.js (H-009), metrics-collect.js (H-008),
16
+ * config-update.js (H-021).
17
+ *
18
+ * Reference impl: OptionsFlow's E27-C (Python). Ported to Node.js so
19
+ * adopters' CI runners don't require a Python install.
20
+ *
21
+ * Closes #61 (sub-task: classifier as CLI command). Auto-install
22
+ * workflow template deferred to Phase 6.1 (#60 — consumer of these
23
+ * categories).
24
+ */
25
+
26
+ // ─────────────────────────────────────────────────────────────────────
27
+ // CATEGORY_TABLE — stable contract (severity + auto_fixable per category)
28
+ // ─────────────────────────────────────────────────────────────────────
29
+ // Severity ordering: 100 > 50 > 30 > 20 > 15 > 10 > 1
30
+ // Higher severity wins when multiple categories match the same log.
31
+ // Patterns intentionally broad — match common log shapes across
32
+ // stacks (Python, Node, Go, .NET, Salesforce). Adopter-specific
33
+ // patterns can be added in v2 via config.
34
+ const CATEGORY_TABLE = [
35
+ {
36
+ category: 'security',
37
+ severity: 100,
38
+ auto_fixable: false,
39
+ patterns: [
40
+ // Bandit — match in either order (tool-name first OR severity-first).
41
+ // Real bandit output has the header "Run started:" then findings with
42
+ // "Severity: HIGH" lines; sometimes the tool name appears after.
43
+ /\bbandit\b[\s\S]{0,300}?(HIGH|MEDIUM|CRITICAL|severity)/i,
44
+ /(HIGH|MEDIUM|CRITICAL|severity)[\s\S]{0,300}?\bbandit\b/i,
45
+ /\bsafety\b[\s\S]{0,200}?vulnerability/i,
46
+ /npm audit[\s\S]{0,200}?(high|critical) severity/i,
47
+ /\bTrivy\b[\s\S]{0,200}?CRITICAL/i,
48
+ /\bsemgrep\b[\s\S]{0,200}?ERROR/i,
49
+ ],
50
+ },
51
+ {
52
+ category: 'test_logic',
53
+ severity: 50,
54
+ auto_fixable: false,
55
+ patterns: [
56
+ /\bAssertionError\b/,
57
+ /\bexpect\(.*?\)\.to\w+/,
58
+ /\bFAIL(ED)?\b.*?test/i,
59
+ /\bpytest\b.*?failed/i,
60
+ /Tests:\s*\d+\s*failed/i,
61
+ ],
62
+ },
63
+ {
64
+ category: 'e2e_flake',
65
+ severity: 30,
66
+ auto_fixable: false,
67
+ patterns: [
68
+ /\bTimeoutError\b/,
69
+ /selector .*? not found/i,
70
+ /\bPlaywright\b.*?timeout/i,
71
+ /\bCypress\b.*?retry/i,
72
+ /element .*? is not visible/i,
73
+ ],
74
+ },
75
+ {
76
+ category: 'baseline_tripwire',
77
+ severity: 20,
78
+ auto_fixable: true,
79
+ patterns: [
80
+ /bloated to \d+ lines.*?baseline.*?ceiling/i,
81
+ /baseline tripwire/i,
82
+ /size budget exceeded/i,
83
+ ],
84
+ },
85
+ {
86
+ category: 'lint',
87
+ severity: 15,
88
+ auto_fixable: true,
89
+ patterns: [
90
+ /\b[IE]\d{3,}\s+\[.*?\]/, // ruff: "I001 [*] Import block..."
91
+ /\beslint\b[\s\S]{0,200}?(error|warn)/i,
92
+ /\bruff(\s+check)?\b[\s\S]{0,200}?(error|warn)/i,
93
+ /\bgolangci-lint\b/i,
94
+ /\bflake8\b[\s\S]{0,200}?:\d+:\d+:/,
95
+ ],
96
+ },
97
+ {
98
+ category: 'badge_drift',
99
+ severity: 10,
100
+ auto_fixable: true,
101
+ patterns: [
102
+ /README test count drifted/i,
103
+ /badge.*?(mismatch|drift|stale)/i,
104
+ /doc_freshness_check/i,
105
+ ],
106
+ },
107
+ ];
108
+
109
+ const UNKNOWN = { category: 'unknown', severity: 1, auto_fixable: false };
110
+
111
+ // ─────────────────────────────────────────────────────────────────────
112
+ // classifyLogText — main entry point
113
+ // ─────────────────────────────────────────────────────────────────────
114
+ // Returns array of { category, severity, auto_fixable, evidence }
115
+ // sorted desc by severity. Always returns at least one element
116
+ // (`unknown` if no match).
117
+ function classifyLogText(text) {
118
+ if (typeof text !== 'string' || text.length === 0) {
119
+ return [{ ...UNKNOWN, evidence: '' }];
120
+ }
121
+
122
+ const matches = [];
123
+ for (const cat of CATEGORY_TABLE) {
124
+ for (const re of cat.patterns) {
125
+ const m = text.match(re);
126
+ if (m) {
127
+ matches.push({
128
+ category: cat.category,
129
+ severity: cat.severity,
130
+ auto_fixable: cat.auto_fixable,
131
+ evidence: m[0].slice(0, 200),
132
+ });
133
+ break; // one evidence per category is enough — don't double-count
134
+ }
135
+ }
136
+ }
137
+
138
+ if (matches.length === 0) {
139
+ return [{ ...UNKNOWN, evidence: '' }];
140
+ }
141
+
142
+ // Stable sort: desc by severity (CATEGORY_TABLE order is already
143
+ // severity-desc, so push order is stable for tie-breaks)
144
+ return matches.sort((a, b) => b.severity - a.severity);
145
+ }
146
+
147
+ module.exports = {
148
+ CATEGORY_TABLE,
149
+ classifyLogText,
150
+ };