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