@hone-ai/cli 1.39.0 → 1.40.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/hone-cli.js +152 -48
- package/lib/anthropic-response.js +58 -0
- package/lib/batch-follow.js +230 -9
- package/lib/block-grammar.js +55 -0
- package/lib/branch-protection.js +141 -25
- package/lib/bundle-paths.js +20 -3
- package/lib/judge-provider.js +20 -11
- package/lib/release-review-config.js +3 -2
- package/lib/required-checks.js +354 -0
- package/lib/reviewer-optin.js +115 -0
- package/lib/security-scanner.js +7 -4
- package/lib/verify-patch.js +122 -12
- package/package.json +1 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-348 — the status checks `hone setup` requires are READ from the repository's own workflows.
|
|
4
|
+
*
|
|
5
|
+
* Setup used to require `ai-code-review` on every repository. No workflow Hone serves reports a check
|
|
6
|
+
* by that name (the served reviewer job is `ai-review`, with no `name:`), and since HC-GF-337g that job
|
|
7
|
+
* runs only on `--ai-reviewer`. A required check that never reports leaves every PR waiting, and
|
|
8
|
+
* `enforce_admins: true` stops admins merging around it. So the name is now taken from the installed
|
|
9
|
+
* workflow, and required only when that workflow will run the job.
|
|
10
|
+
*
|
|
11
|
+
* GitHub names an Actions check after the job: its job-level `name:` if it has one, else its key.
|
|
12
|
+
* The parser below reads exactly that and nothing else. Two near-misses it must reject:
|
|
13
|
+
* - the workflow's top-level `name:` (column 0) — that names the workflow, not a check;
|
|
14
|
+
* - a step's `- name:` (deeper, behind a dash) — that names a step.
|
|
15
|
+
* A `name:` built from an expression (`${{ matrix.x }}`) is returned as written. GitHub reports the
|
|
16
|
+
* evaluated value, and a job with a `strategy:` reports one check per matrix leg (`ai-review (x)`),
|
|
17
|
+
* never its bare name. Neither can be REQUIRED (it would never report — the failure this module
|
|
18
|
+
* exists to prevent), so requiredChecksForSetup requires nothing for such a reviewer job. For the
|
|
19
|
+
* prune they are harmless: such a name never equals the legacy one.
|
|
20
|
+
*
|
|
21
|
+
* No YAML library: the CLI bundles none, and reviewer-optin.js reads this same file with a regex.
|
|
22
|
+
*
|
|
23
|
+
* Exports
|
|
24
|
+
* workflowCheckNames(text) → string[] the check names a workflow's jobs report
|
|
25
|
+
* reviewerCheckName(text) → string|null the name the `ai-review` job reports
|
|
26
|
+
* requiredChecksForSetup({...}) → string[] what setup requires: the reviewer check only when it runs
|
|
27
|
+
* reportedChecksInDir(dir) → string[]|null every name reported under dir; null = could not tell
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const REVIEWER_JOB_KEY = 'ai-review';
|
|
31
|
+
const SECURITY_SCAN_JOB_KEY = 'security-scan';
|
|
32
|
+
|
|
33
|
+
function unquote(v) {
|
|
34
|
+
let s = String(v).replace(/\s+#.*$/, '').trim();
|
|
35
|
+
if (s.length >= 2 && ((s[0] === '"' && s.endsWith('"')) || (s[0] === "'" && s.endsWith("'")))) {
|
|
36
|
+
s = s.slice(1, -1);
|
|
37
|
+
}
|
|
38
|
+
return s;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** [{ key, name, matrix }] for each job in the workflow's top-level `jobs:` block; name is null without one. */
|
|
42
|
+
function workflowJobs(text) {
|
|
43
|
+
if (typeof text !== 'string' || !text) return [];
|
|
44
|
+
const lines = text.split(/\r?\n/);
|
|
45
|
+
const start = lines.findIndex((l) => /^jobs:\s*(#.*)?$/.test(l));
|
|
46
|
+
if (start < 0) return [];
|
|
47
|
+
const jobs = [];
|
|
48
|
+
let jobIndent = null; // the column job keys sit at
|
|
49
|
+
let propIndent = null; // the column the current job's own properties sit at
|
|
50
|
+
let current = null;
|
|
51
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
52
|
+
const line = lines[i];
|
|
53
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
54
|
+
const indent = line.match(/^ */)[0].length;
|
|
55
|
+
if (indent === 0) break; // the next top-level key ends `jobs:`
|
|
56
|
+
if (jobIndent == null) jobIndent = indent;
|
|
57
|
+
if (indent <= jobIndent) {
|
|
58
|
+
const m = /^ *([A-Za-z0-9_-]+):\s*(#.*)?$/.exec(line);
|
|
59
|
+
current = m && indent === jobIndent ? { key: m[1], name: null, matrix: false, reusable: false } : null;
|
|
60
|
+
if (current) jobs.push(current);
|
|
61
|
+
propIndent = null;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (!current) continue;
|
|
65
|
+
if (propIndent == null) propIndent = indent;
|
|
66
|
+
// Only the job's own properties count; anything deeper (steps, with:, env:) is not its name.
|
|
67
|
+
if (indent !== propIndent) continue;
|
|
68
|
+
if (/^ *strategy:/.test(line)) current.matrix = true;
|
|
69
|
+
// A job that calls a reusable workflow reports as `caller / callee`, never its own name.
|
|
70
|
+
if (/^ *uses:/.test(line)) current.reusable = true;
|
|
71
|
+
const n = current.name == null && /^ *name:\s*(.+)$/.exec(line);
|
|
72
|
+
if (n) current.name = unquote(n[1]) || null;
|
|
73
|
+
}
|
|
74
|
+
return jobs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function workflowCheckNames(text) {
|
|
78
|
+
return workflowJobs(text).map((j) => j.name || j.key);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function reviewerCheckName(text) {
|
|
82
|
+
const job = workflowJobs(text).find((j) => j.key === REVIEWER_JOB_KEY);
|
|
83
|
+
return job ? (job.name || job.key) : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ── on: — does a workflow run on pull requests to a branch? (HC-GF-354) ────────────────────────────
|
|
87
|
+
|
|
88
|
+
const PR_EVENTS = new Set(['pull_request', 'pull_request_target']);
|
|
89
|
+
|
|
90
|
+
/** `[a, b]` or a bare scalar → list of unquoted items; null when it is neither (an anchor, a placeholder). */
|
|
91
|
+
function flowList(v) {
|
|
92
|
+
const t = String(v).replace(/\s+#.*$/, '').trim();
|
|
93
|
+
if (!t) return null;
|
|
94
|
+
if (t.startsWith('[')) {
|
|
95
|
+
if (!t.endsWith(']')) return null;
|
|
96
|
+
const inner = t.slice(1, -1).trim();
|
|
97
|
+
return inner ? inner.split(',').map((x) => unquote(x)).filter(Boolean) : [];
|
|
98
|
+
}
|
|
99
|
+
if (/^[&*{|>]/.test(t)) return null;
|
|
100
|
+
return [unquote(t)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The pull-request triggers of a workflow: [{ event, branches, branchesIgnore, types, unreadable }], each
|
|
105
|
+
* filter null when absent and 'unreadable' when present but not a list this parser can read
|
|
106
|
+
* (`{{BRANCHES}}` before setup substitutes it, an anchor, a nested mapping). `unreadable: true` marks a
|
|
107
|
+
* trigger with anything else that can skip a PR (`paths`, `paths-ignore`, an inline mapping). Reads
|
|
108
|
+
* `on: x`, `on: [x, y]` and the block form.
|
|
109
|
+
*/
|
|
110
|
+
function workflowPullRequestTriggers(text) {
|
|
111
|
+
if (typeof text !== 'string' || !text) return [];
|
|
112
|
+
const lines = text.split(/\r?\n/);
|
|
113
|
+
const start = lines.findIndex((l) => /^["']?on["']?:/.test(l));
|
|
114
|
+
if (start < 0) return [];
|
|
115
|
+
const inline = lines[start].replace(/^["']?on["']?:/, '').replace(/\s+#.*$/, '').trim();
|
|
116
|
+
if (inline) {
|
|
117
|
+
const events = flowList(inline) || [];
|
|
118
|
+
return events.filter((e) => PR_EVENTS.has(e)).map((event) => ({ event, branches: null, branchesIgnore: null, types: null, unreadable: false }));
|
|
119
|
+
}
|
|
120
|
+
const out = [];
|
|
121
|
+
let eventIndent = null;
|
|
122
|
+
let current = null; // the PR trigger being read
|
|
123
|
+
let pending = null; // { key, indent } of a `branches:` whose list is on the lines below
|
|
124
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
125
|
+
const line = lines[i];
|
|
126
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
127
|
+
const indent = line.match(/^ */)[0].length;
|
|
128
|
+
if (indent === 0) break;
|
|
129
|
+
if (eventIndent == null) eventIndent = indent;
|
|
130
|
+
if (pending && indent >= pending.indent && /^ *- /.test(line)) {
|
|
131
|
+
const item = unquote(line.replace(/^ *- /, ''));
|
|
132
|
+
if (current[pending.key] === 'unreadable') continue;
|
|
133
|
+
if (!item || /^[&*{]/.test(item)) current[pending.key] = 'unreadable';
|
|
134
|
+
else current[pending.key].push(item);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
if (pending && current[pending.key] && current[pending.key].length === 0) current[pending.key] = 'unreadable';
|
|
138
|
+
pending = null;
|
|
139
|
+
if (indent === eventIndent) {
|
|
140
|
+
const m = /^ *([A-Za-z_]+):\s*(.*)$/.exec(line);
|
|
141
|
+
current = m && PR_EVENTS.has(m[1]) ? { event: m[1], branches: null, branchesIgnore: null, types: null, unreadable: false } : null;
|
|
142
|
+
if (current) {
|
|
143
|
+
// `pull_request: { branches: [x] }`, an alias, any inline value: a filter not read here.
|
|
144
|
+
const v = m[2].replace(/\s+#.*$/, '').trim();
|
|
145
|
+
if (v && v !== 'null' && v !== '~') current.unreadable = true;
|
|
146
|
+
out.push(current);
|
|
147
|
+
}
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (!current) continue;
|
|
151
|
+
// A path filter skips some pull requests to the branch, so a check from it is not sure to report.
|
|
152
|
+
if (/^ *(paths|paths-ignore):/.test(line)) { current.unreadable = true; continue; }
|
|
153
|
+
const f = /^ *(branches|branches-ignore|types):\s*(.*)$/.exec(line);
|
|
154
|
+
if (!f) continue;
|
|
155
|
+
const key = { branches: 'branches', 'branches-ignore': 'branchesIgnore', types: 'types' }[f[1]];
|
|
156
|
+
const rest = f[2].replace(/\s+#.*$/, '').trim();
|
|
157
|
+
if (!rest) { current[key] = []; pending = { key, indent }; continue; }
|
|
158
|
+
current[key] = flowList(rest) || 'unreadable';
|
|
159
|
+
}
|
|
160
|
+
if (pending && current && current[pending.key] && current[pending.key].length === 0) current[pending.key] = 'unreadable';
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** GitHub's branch glob, as far as it is read here: `**` anything, `*` anything but `/`. 'unreadable' otherwise. */
|
|
165
|
+
function branchMatchesPattern(pattern, branch) {
|
|
166
|
+
if (typeof pattern !== 'string' || !pattern || /[!?[\]+]/.test(pattern) || pattern.startsWith('/') || pattern.includes('{{')) {
|
|
167
|
+
return 'unreadable';
|
|
168
|
+
}
|
|
169
|
+
const re = pattern.replace(/[.^$()|\\{}]/g, '\\$&').replace(/\*\*/g, '\u0000').replace(/\*/g, '[^/]*').replace(/\u0000/g, '.*');
|
|
170
|
+
return new RegExp(`^${re}$`).test(branch);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function filterMatches(patterns, branch) {
|
|
174
|
+
const r = patterns.map((p) => branchMatchesPattern(p, branch));
|
|
175
|
+
if (r.includes('unreadable')) return 'unreadable';
|
|
176
|
+
return r.includes(true);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* True only when the workflow is SURE to run on a pull request to `branch`. Anything it cannot read
|
|
181
|
+
* answers false: requiring nothing is safe, requiring a check that never reports holds every PR.
|
|
182
|
+
*/
|
|
183
|
+
function workflowTriggersPullRequestTo(text, branch) {
|
|
184
|
+
if (typeof branch !== 'string' || !branch) return false;
|
|
185
|
+
return workflowPullRequestTriggers(text).some((t) => {
|
|
186
|
+
if (t.unreadable || t.branches === 'unreadable' || t.branchesIgnore === 'unreadable' || t.types === 'unreadable') return false;
|
|
187
|
+
// Only a trigger that fires when a PR is opened AND when it is pushed to reports on every PR.
|
|
188
|
+
if (Array.isArray(t.types) && !(t.types.includes('opened') && t.types.includes('synchronize'))) return false;
|
|
189
|
+
if (t.branches && t.branchesIgnore) return false; // GitHub rejects a workflow that sets both
|
|
190
|
+
if (t.branches) return filterMatches(t.branches, branch) === true;
|
|
191
|
+
if (t.branchesIgnore) return filterMatches(t.branchesIgnore, branch) === false;
|
|
192
|
+
return true;
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── ci.required_checks in .pipeline-config.yml (HC-GF-352) ───────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
/** The names listed under `ci: required_checks:`, flow or block form; [] when absent. */
|
|
199
|
+
function configuredRequiredChecks(configText) {
|
|
200
|
+
if (typeof configText !== 'string' || !configText) return [];
|
|
201
|
+
const lines = configText.split(/\r?\n/);
|
|
202
|
+
const start = lines.findIndex((l) => /^ci:\s*(#.*)?$/.test(l));
|
|
203
|
+
if (start < 0) return [];
|
|
204
|
+
let keyIndent = null;
|
|
205
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
206
|
+
const line = lines[i];
|
|
207
|
+
if (!line.trim() || /^\s*#/.test(line)) continue;
|
|
208
|
+
const indent = line.match(/^ */)[0].length;
|
|
209
|
+
if (indent === 0) break;
|
|
210
|
+
if (keyIndent == null) keyIndent = indent;
|
|
211
|
+
if (indent !== keyIndent) continue;
|
|
212
|
+
const m = /^ *required_checks:\s*(.*)$/.exec(line);
|
|
213
|
+
if (!m) continue;
|
|
214
|
+
const rest = m[1].replace(/\s+#.*$/, '').trim();
|
|
215
|
+
if (rest) return flowList(rest) || [];
|
|
216
|
+
const items = [];
|
|
217
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
218
|
+
const l = lines[j];
|
|
219
|
+
if (!l.trim() || /^\s*#/.test(l)) continue;
|
|
220
|
+
if (!/^ *- /.test(l) || l.match(/^ */)[0].length < keyIndent) break;
|
|
221
|
+
const v = unquote(l.replace(/^ *- /, ''));
|
|
222
|
+
if (v) items.push(v);
|
|
223
|
+
}
|
|
224
|
+
return items;
|
|
225
|
+
}
|
|
226
|
+
return [];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ── what setup requires ──────────────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* [{ key, name }] for the jobs that report under ONE fixed name. Not a matrix job (one check per leg,
|
|
233
|
+
* `test (18)`), not a reusable-workflow call (`caller / callee`), not a name built from an expression.
|
|
234
|
+
* Requiring nothing is always safe; requiring a name that never reports holds every PR.
|
|
235
|
+
*/
|
|
236
|
+
function fixedJobs(text) {
|
|
237
|
+
return workflowJobs(text)
|
|
238
|
+
.filter((j) => !j.matrix && !j.reusable && !(j.name || j.key).includes('${{'))
|
|
239
|
+
.map((j) => ({ key: j.key, name: j.name || j.key }));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** The fixed check name a served job reports, or null. */
|
|
243
|
+
function fixedJobCheck(text, key) {
|
|
244
|
+
const job = fixedJobs(text).find((j) => j.key === key);
|
|
245
|
+
return job ? job.name : null;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* What `hone setup` requires on the protected branch, and every candidate it left out with the reason.
|
|
250
|
+
*
|
|
251
|
+
* Candidates: the served reviewer check (only when the installed workflow runs that job — HC-GF-348),
|
|
252
|
+
* the served `security-scan` check (always — HC-GF-354), and the names in `ci.required_checks`
|
|
253
|
+
* (HC-GF-352). Each is required only if a workflow reports it under a fixed name and is sure to run on
|
|
254
|
+
* pull requests to `branch`. `branch` null (not known yet): no trigger test, nothing dropped for it.
|
|
255
|
+
*
|
|
256
|
+
* @returns {{ required: string[], dropped: Array<{ name: string, reason: string }> }}
|
|
257
|
+
*/
|
|
258
|
+
function requiredChecksPlan({
|
|
259
|
+
reviewerState, reviewerWorkflowText, workflowsDir, configText, branch = null,
|
|
260
|
+
fsImpl = require('fs'), pathImpl = require('path'),
|
|
261
|
+
} = {}) {
|
|
262
|
+
const required = [];
|
|
263
|
+
const dropped = [];
|
|
264
|
+
const notOnBranch = `its workflow does not run on pull requests to \`${branch}\``;
|
|
265
|
+
const served = (name) => {
|
|
266
|
+
if (!name || required.includes(name)) return;
|
|
267
|
+
if (branch != null && !workflowTriggersPullRequestTo(reviewerWorkflowText, branch)) dropped.push({ name, reason: notOnBranch });
|
|
268
|
+
else required.push(name);
|
|
269
|
+
};
|
|
270
|
+
if (reviewerState === 'on' || reviewerState === 'legacy') served(fixedJobCheck(reviewerWorkflowText, REVIEWER_JOB_KEY));
|
|
271
|
+
served(fixedJobCheck(reviewerWorkflowText, SECURITY_SCAN_JOB_KEY));
|
|
272
|
+
|
|
273
|
+
const configured = configuredRequiredChecks(configText)
|
|
274
|
+
.filter((n) => !required.includes(n) && !dropped.some((d) => d.name === n));
|
|
275
|
+
if (configured.length) {
|
|
276
|
+
const files = reportedChecksByFileInDir(workflowsDir, { fsImpl, pathImpl });
|
|
277
|
+
for (const name of configured) {
|
|
278
|
+
if (files == null) { dropped.push({ name, reason: "the repository's workflows could not all be read" }); continue; }
|
|
279
|
+
const reporting = files.filter((f) => f.names.includes(name));
|
|
280
|
+
if (!reporting.length) { dropped.push({ name, reason: 'not reported by any workflow' }); continue; }
|
|
281
|
+
const fixed = reporting.filter((f) => fixedJobs(f.text).some((j) => j.name === name));
|
|
282
|
+
if (!fixed.length) {
|
|
283
|
+
dropped.push({ name, reason: 'its job reports per matrix leg or as a reusable-workflow call, never under this name' });
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (branch != null && !fixed.some((f) => workflowTriggersPullRequestTo(f.text, branch))) {
|
|
287
|
+
dropped.push({ name, reason: notOnBranch });
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
required.push(name);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return { required, dropped };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* @param {{ reviewerState: 'on'|'off'|'legacy'|'absent', reviewerWorkflowText: string|null }} args
|
|
298
|
+
* reviewerState is workflowReviewerState() (cli/lib/reviewer-optin.js) of the installed ai-review.yml.
|
|
299
|
+
*/
|
|
300
|
+
function requiredChecksForSetup(args = {}) {
|
|
301
|
+
return requiredChecksPlan(args).required;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* [{ file, text, names }] for each workflow in `dir`. `[]` when there is no such directory. `null` when a
|
|
306
|
+
* workflow file exists but could not be read.
|
|
307
|
+
*/
|
|
308
|
+
function reportedChecksByFileInDir(dir, { fsImpl = require('fs'), pathImpl = require('path') } = {}) {
|
|
309
|
+
if (typeof dir !== 'string' || !dir) return [];
|
|
310
|
+
let entries;
|
|
311
|
+
try {
|
|
312
|
+
entries = fsImpl.readdirSync(dir);
|
|
313
|
+
} catch (e) {
|
|
314
|
+
return e && (e.code === 'ENOENT' || e.code === 'ENOTDIR') ? [] : null;
|
|
315
|
+
}
|
|
316
|
+
const out = [];
|
|
317
|
+
for (const f of entries) {
|
|
318
|
+
if (!/\.ya?ml$/i.test(f)) continue;
|
|
319
|
+
let text;
|
|
320
|
+
try {
|
|
321
|
+
text = fsImpl.readFileSync(pathImpl.join(dir, f), 'utf8');
|
|
322
|
+
} catch {
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
out.push({ file: f, text, names: workflowCheckNames(text) });
|
|
326
|
+
}
|
|
327
|
+
return out;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Every check name the workflows in `dir` report. `[]` when there is no such directory (nothing reports
|
|
332
|
+
* anything). `null` when a workflow file exists but could not be read: the caller cannot tell what is
|
|
333
|
+
* reported, so it must not remove a required check on the strength of this answer.
|
|
334
|
+
*/
|
|
335
|
+
function reportedChecksInDir(dir, opts = {}) {
|
|
336
|
+
const files = reportedChecksByFileInDir(dir, opts);
|
|
337
|
+
if (files == null) return null;
|
|
338
|
+
return [...new Set(files.flatMap((f) => f.names))];
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
module.exports = {
|
|
342
|
+
workflowCheckNames,
|
|
343
|
+
reviewerCheckName,
|
|
344
|
+
requiredChecksForSetup,
|
|
345
|
+
requiredChecksPlan,
|
|
346
|
+
reportedChecksInDir,
|
|
347
|
+
reportedChecksByFileInDir,
|
|
348
|
+
workflowPullRequestTriggers,
|
|
349
|
+
workflowTriggersPullRequestTo,
|
|
350
|
+
branchMatchesPattern,
|
|
351
|
+
configuredRequiredChecks,
|
|
352
|
+
REVIEWER_JOB_KEY,
|
|
353
|
+
SECURITY_SCAN_JOB_KEY,
|
|
354
|
+
};
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* HC-GF-337g — the AI code reviewer is OPT-IN.
|
|
4
|
+
*
|
|
5
|
+
* `hone setup` used to install it in every adopter repository, where it runs in the adopter's CI
|
|
6
|
+
* on the adopter's own ANTHROPIC_API_KEY — so a repository that never chose it, and never set the
|
|
7
|
+
* key, got a red check on every PR. Now its scripts install only on `--ai-reviewer`, and the
|
|
8
|
+
* ai-review job runs only then. .github/workflows/ai-review.yml itself installs either way: its other
|
|
9
|
+
* gates (secret scan, tracker, metrics check, E2E healer) are not the reviewer's to take with it.
|
|
10
|
+
*
|
|
11
|
+
* Commander keeps the default `undefined` only because `--ai-reviewer` is declared BEFORE
|
|
12
|
+
* `--no-ai-reviewer` ("If you define --foo first, adding --no-foo does not change the default
|
|
13
|
+
* value"). Declared the other way round the default becomes `true`, and the always-install comes
|
|
14
|
+
* back with nothing in the diff to show it — which is why the decision is `=== true` here, and why
|
|
15
|
+
* tests/regression/hc-gf-337g-reviewer-optin-default.test.js runs the real CLI with no flag.
|
|
16
|
+
*
|
|
17
|
+
* Pure: no I/O, so it is unit-testable without the CLI.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** True only for an explicit `--ai-reviewer`. No flag, `--no-ai-reviewer`, or no opts at all: false. */
|
|
21
|
+
function shouldInstallReviewer(opts) {
|
|
22
|
+
return Boolean(opts) && opts.aiReviewer === true;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* What an installed .github/workflows/ai-review.yml says about the reviewer job (HC-GF-337g):
|
|
27
|
+
* 'on' / 'off' (setup's AI_REVIEWER stamp), 'legacy' (written before the stamp existed — it runs the
|
|
28
|
+
* reviewer on every PR, red without a key) or 'absent' (no file; pass null).
|
|
29
|
+
*/
|
|
30
|
+
function workflowReviewerState(text) {
|
|
31
|
+
if (text == null) return 'absent';
|
|
32
|
+
const m = /AI_REVIEWER: "([^"]*)"/.exec(text);
|
|
33
|
+
if (!m) return 'legacy';
|
|
34
|
+
return m[1] === 'true' ? 'on' : 'off';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* What the operator is told once the setup script has run.
|
|
39
|
+
*
|
|
40
|
+
* @param {boolean} installed the reviewer script is now in the repository (with dryRun: would be)
|
|
41
|
+
* @param {{ requested?: boolean, alreadyPresent?: boolean, dryRun?: boolean, workflow?: string }} [ctx]
|
|
42
|
+
* requested: `--ai-reviewer` was passed (so `installed: false` means the download failed)
|
|
43
|
+
* alreadyPresent: the repository already has .github/workflows/ai-review.yml from an earlier setup
|
|
44
|
+
* dryRun: `--dry-run` — nothing was written, so nothing may be reported as installed
|
|
45
|
+
* workflow: workflowReviewerState() of the repository's ai-review.yml after the script ran (before,
|
|
46
|
+
* on a dry run). An existing workflow is never regenerated, so an install is claimed only
|
|
47
|
+
* when the workflow actually runs the job.
|
|
48
|
+
* @returns {string} one or more lines, no trailing newline
|
|
49
|
+
*/
|
|
50
|
+
function reviewerNotice(installed, { requested = false, alreadyPresent = false, dryRun = false, workflow } = {}) {
|
|
51
|
+
const LEGACY = [
|
|
52
|
+
' .github/workflows/ai-review.yml predates the opt-in and was kept: it runs the reviewer on every PR,',
|
|
53
|
+
' red without ANTHROPIC_API_KEY. Delete it and re-run `hone setup --ai-reviewer` to get the version that',
|
|
54
|
+
' skips without a key.',
|
|
55
|
+
];
|
|
56
|
+
// Unknown workflow state (a caller that did not read it): the reviewer side assumes the job is on.
|
|
57
|
+
const wf = workflow || 'on';
|
|
58
|
+
if (installed && dryRun) {
|
|
59
|
+
if (wf === 'legacy') {
|
|
60
|
+
return [' DRY-RUN: would install the AI reviewer scripts, but not the job that skips without a key:', ...LEGACY].join('\n');
|
|
61
|
+
}
|
|
62
|
+
return [
|
|
63
|
+
' DRY-RUN: would install the AI code reviewer (the ai-review job of .github/workflows/ai-review.yml).',
|
|
64
|
+
' It runs in your CI on YOUR OWN key: add ANTHROPIC_API_KEY as a repository secret.',
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
if (installed && wf === 'legacy') {
|
|
68
|
+
return [' ⚠ AI reviewer scripts installed, but not the job that skips without a key:', ...LEGACY].join('\n');
|
|
69
|
+
}
|
|
70
|
+
if (installed && wf !== 'on') {
|
|
71
|
+
return [
|
|
72
|
+
` ⚠ AI reviewer scripts installed, but .github/workflows/ai-review.yml ${wf === 'off' ? 'has the reviewer stamped off' : 'is missing'},`,
|
|
73
|
+
' so the ai-review job will not run. Delete .github/workflows/ai-review.yml (if present) and re-run',
|
|
74
|
+
' `hone setup --ai-reviewer`.',
|
|
75
|
+
].join('\n');
|
|
76
|
+
}
|
|
77
|
+
if (installed) {
|
|
78
|
+
return [
|
|
79
|
+
' ✓ AI code reviewer installed (the ai-review job of .github/workflows/ai-review.yml).',
|
|
80
|
+
' It runs in your CI on YOUR OWN key: add ANTHROPIC_API_KEY as a repository secret.',
|
|
81
|
+
' Without it the job is skipped (grey, never blocks a merge). The H-019 learnings gate runs inside it.',
|
|
82
|
+
].join('\n');
|
|
83
|
+
}
|
|
84
|
+
const lines = requested
|
|
85
|
+
? [' ⚠ AI code reviewer requested (--ai-reviewer) but NOT installed — its download failed or setup did not write it. Re-run setup to retry.']
|
|
86
|
+
: [' ℹ AI code reviewer not installed (opt-in: hone setup --ai-reviewer).'];
|
|
87
|
+
// What is said about the workflow's OTHER gates is read from the workflow too (re-review step_5): it is
|
|
88
|
+
// never claimed for a workflow that did not arrive, predates the opt-in, or still has the job on.
|
|
89
|
+
if (workflow === 'absent') {
|
|
90
|
+
lines.push(
|
|
91
|
+
' ⚠ .github/workflows/ai-review.yml is not installed either (its download failed), so its secret scan,',
|
|
92
|
+
' tracker and metrics check are missing too. Re-run setup to retry.',
|
|
93
|
+
);
|
|
94
|
+
} else if (workflow === 'legacy') {
|
|
95
|
+
lines.push(' .github/workflows/ai-review.yml predates the opt-in and was kept: delete it and re-run setup to get the version whose ai-review job runs only when chosen.');
|
|
96
|
+
} else if (workflow === 'on') {
|
|
97
|
+
lines.push(' .github/workflows/ai-review.yml was kept with the ai-review job switched on by an earlier setup; setup never switches it off.');
|
|
98
|
+
} else {
|
|
99
|
+
lines.push(' .github/workflows/ai-review.yml is installed either way: its secret scan, tracker and metrics check run; its ai-review job is skipped.');
|
|
100
|
+
}
|
|
101
|
+
lines.push(
|
|
102
|
+
' It needs your own ANTHROPIC_API_KEY repository secret, and it carries the H-019 learnings gate:',
|
|
103
|
+
' without the reviewer, the learnings gate is not installed either.',
|
|
104
|
+
);
|
|
105
|
+
if (alreadyPresent) {
|
|
106
|
+
lines.push(
|
|
107
|
+
' ⚠ .github/scripts/ai-reviewer.js is still here from an earlier setup and was left in place, and an',
|
|
108
|
+
' existing .github/workflows/ai-review.yml is never regenerated. To opt out, delete both files and re-run',
|
|
109
|
+
' setup; to keep the reviewer, re-run with --ai-reviewer.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return lines.join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { shouldInstallReviewer, reviewerNotice, workflowReviewerState };
|
package/lib/security-scanner.js
CHANGED
|
@@ -14,13 +14,15 @@
|
|
|
14
14
|
const SECRET_PATTERNS = [
|
|
15
15
|
{
|
|
16
16
|
id: 'hardcoded-secret-apikey',
|
|
17
|
-
|
|
17
|
+
// HC-GF-364: `["']?` after the name reads a quoted key — JSON, a dict literal — as the served
|
|
18
|
+
// workflow's scan does since HC-GF-362.
|
|
19
|
+
pattern: /(?:api[_-]?key|apikey|api[_-]?secret)["']?\s*[:=]\s*["']([A-Za-z0-9/+=]{20,})["']/i,
|
|
18
20
|
severity: 'HIGH',
|
|
19
21
|
message: 'Hardcoded API key detected. Use environment variables instead.',
|
|
20
22
|
},
|
|
21
23
|
{
|
|
22
24
|
id: 'hardcoded-secret-password',
|
|
23
|
-
pattern: /(?:password|passwd|pwd|secret|token)
|
|
25
|
+
pattern: /(?:password|passwd|pwd|secret|token)["']?\s*[:=]\s*["']([A-Za-z0-9@#$%^&*!]{16,})["']/i,
|
|
24
26
|
severity: 'HIGH',
|
|
25
27
|
message: 'Hardcoded password/secret detected. Use environment variables or a vault.',
|
|
26
28
|
},
|
|
@@ -126,8 +128,9 @@ function scanFiles(opts = {}) {
|
|
|
126
128
|
|
|
127
129
|
// Skip env var references (not hardcoded)
|
|
128
130
|
if (/process\.env\.|os\.environ|System\.getenv|ENV\[/i.test(line)) continue;
|
|
129
|
-
// Skip comments
|
|
130
|
-
|
|
131
|
+
// Skip comments. `--` is a SQL comment; `-----BEGIN … PRIVATE KEY-----` is not (HC-GF-362: it was
|
|
132
|
+
// skipped as one, so the private-key rule never fired on a key at the start of a line).
|
|
133
|
+
if (/^\s*(\/\/|#|--(?!-)|\/\*|\*)/.test(line)) continue;
|
|
131
134
|
|
|
132
135
|
for (const rule of rules) {
|
|
133
136
|
if (rule.pattern.test(line)) {
|