@hone-ai/cli 1.35.1 → 1.37.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 +132 -18
- package/lib/bundle-mount-follow.js +118 -0
- package/lib/bundle-paths.js +115 -15
- package/lib/emit-pr.js +28 -0
- package/lib/fresh-connection.js +27 -0
- package/lib/layout-detect.js +45 -6
- package/lib/run-context.js +19 -1
- package/lib/server-banner.js +19 -0
- package/lib/setup-dry-run.js +29 -0
- package/lib/verify-patch.js +18 -1
- package/package.json +1 -1
package/hone-cli.js
CHANGED
|
@@ -42,6 +42,14 @@ const { collectIncludePaths } = require('./lib/include-paths');
|
|
|
42
42
|
const { runArgsError, orchestratePath } = require('./lib/run-target'); // HC-GF-270-followup-1
|
|
43
43
|
const program = new Command();
|
|
44
44
|
|
|
45
|
+
// HC-GF-331: spawnSync's default stdout/stderr buffer is 1 MiB. A verification suite over that
|
|
46
|
+
// (this repo's own `make ci`) overflowed it, spawnSync returned status: null with error.code
|
|
47
|
+
// ENOBUFS, and verdictFromRun/classifyGateRun read that null status as a plain test failure —
|
|
48
|
+
// not as "the output could not be captured." Generous relative to any suite measured so far
|
|
49
|
+
// (`make ci` prints low hundreds of KB; 64 MiB is two orders of magnitude of headroom) without
|
|
50
|
+
// holding an unbounded amount of memory for a runaway command.
|
|
51
|
+
const VERIFY_MAX_BUFFER = 64 * 1024 * 1024;
|
|
52
|
+
|
|
45
53
|
// ── Config resolution ─────────────────────────────────────────────────────────
|
|
46
54
|
const RC_PATH = path.join(os.homedir(), '.honerc');
|
|
47
55
|
|
|
@@ -377,7 +385,7 @@ program
|
|
|
377
385
|
verdict: anyFailed ? 'fail' : 'pass',
|
|
378
386
|
exitCode: anyFailed ? 1 : 0,
|
|
379
387
|
detail: `agent-eval ${opts.category}: ${results.filter((r) => r.passed).length}/${results.length} probes passed`,
|
|
380
|
-
});
|
|
388
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
381
389
|
if (opts.output !== 'json') console.log(` → reported agent-eval verdict '${anyFailed ? 'fail' : 'pass'}' to run ${opts.workflowId} (advisory)`);
|
|
382
390
|
} catch (e) {
|
|
383
391
|
if (opts.output !== 'json') console.warn(` ⚠ verdict report failed (non-fatal): ${e.response?.data?.error || e.message}`);
|
|
@@ -928,6 +936,9 @@ program
|
|
|
928
936
|
if (ciGateChoice.source !== 'auto-detect') {
|
|
929
937
|
setupEnv.CI_GATE = ciGateChoice.mode;
|
|
930
938
|
}
|
|
939
|
+
// HC-GF-321: --stack was declared and never read. The script applies it
|
|
940
|
+
// after detection (apply_stack_override) and refuses an unsupported value.
|
|
941
|
+
if (opts.stack) setupEnv.HONE_STACK_OVERRIDE = String(opts.stack).toLowerCase();
|
|
931
942
|
try {
|
|
932
943
|
execSync(`bash "${scriptPath}" ${flags}`, {
|
|
933
944
|
stdio: 'inherit',
|
|
@@ -939,6 +950,25 @@ program
|
|
|
939
950
|
process.exit(1);
|
|
940
951
|
}
|
|
941
952
|
|
|
953
|
+
// HC-GF-320: every phase below writes (CLAUDE.md, config grounding, test
|
|
954
|
+
// packages, GitHub branch protection). A dry run stops here and says what
|
|
955
|
+
// they would do.
|
|
956
|
+
if (opts.dryRun) {
|
|
957
|
+
const { postScriptDryRunPlan } = require('./lib/setup-dry-run');
|
|
958
|
+
const plan = postScriptDryRunPlan({
|
|
959
|
+
claudeMdTemplate: fs.existsSync(path.join(tmpDir, 'CLAUDE.md')),
|
|
960
|
+
claudeMdExists: fs.existsSync(path.join(process.cwd(), 'CLAUDE.md')),
|
|
961
|
+
configExists: fs.existsSync(path.join(process.cwd(), '.github', '.pipeline-config.yml')),
|
|
962
|
+
installTests: !!opts.installTests,
|
|
963
|
+
skipBranchProtection: opts.branchProtection === false ||
|
|
964
|
+
/^(1|true|yes)$/i.test(process.env.HONE_SKIP_BRANCH_PROTECTION || ''),
|
|
965
|
+
});
|
|
966
|
+
for (const line of plan) console.log(` DRY-RUN: ${line}`);
|
|
967
|
+
console.log(' Dry run complete: nothing was written.');
|
|
968
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
971
|
+
|
|
942
972
|
// HC-RC-004: if the adopter chose `local`, run the setup-local-ci
|
|
943
973
|
// scaffold inline so they end the setup command with a working
|
|
944
974
|
// local-CI stack (Makefile + compose) instead of having to know
|
|
@@ -1564,6 +1594,10 @@ async function createTestEnv(repoRoot, opts) {
|
|
|
1564
1594
|
function recommendTestFramework(repoRoot) {
|
|
1565
1595
|
const detected = detectExistingTestFramework(repoRoot);
|
|
1566
1596
|
if (detected.testFw) return; // Already has tests — no recommendation needed
|
|
1597
|
+
// HC-GF-321: every recommendation below is an npm package. Without a root
|
|
1598
|
+
// package.json the stack is not Node (or lives in subdirectories), and a
|
|
1599
|
+
// pytest repository was told to install vitest.
|
|
1600
|
+
if (detected.stack === 'unknown') return;
|
|
1567
1601
|
|
|
1568
1602
|
const pkgs = getInstallPackages(detected.stack);
|
|
1569
1603
|
const installCmd = detected.pkgMgr === 'yarn' ? 'yarn add -D' : detected.pkgMgr === 'pnpm' ? 'pnpm add -D' : 'npm install -D';
|
|
@@ -6139,6 +6173,8 @@ program
|
|
|
6139
6173
|
.option('--kill', 'Kill a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
|
|
6140
6174
|
.option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
|
|
6141
6175
|
.option('--approve <step>', 'Approve a paused gate (e.g., --approve step_0) on a run ID, or on a story ID\'s latest run in this repository. Exit codes: 2 = paused at a human gate, 3 = parked for your editor (execution.mode: editor)')
|
|
6176
|
+
.option('--reject <step>', 'HC-GF-323: send a paused gate BACK for a re-run instead of approving it or killing the whole run (e.g., --reject step_4). Requires --reason. Steps before it are left approved; only this step re-runs, with the reason in its context.')
|
|
6177
|
+
.option('--reason <text>', 'With --reject: why this attempt is being sent back. Required, and reaches the re-run as feedback the same way a code-reviewer\'s FAIL verdict does.')
|
|
6142
6178
|
.option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
|
|
6143
6179
|
.option('--format <fmt>', 'Output format: pretty or json', 'pretty')
|
|
6144
6180
|
.option('--poll-interval <s>', 'Poll interval in seconds', '5')
|
|
@@ -6167,12 +6203,7 @@ program
|
|
|
6167
6203
|
// the 2026-09-08 editor test nearly meaningless; here it costs money rather than time.
|
|
6168
6204
|
try {
|
|
6169
6205
|
const h = await client.get('/health').then(r => r.data).catch(() => null);
|
|
6170
|
-
|
|
6171
|
-
if (llm && llm.local) {
|
|
6172
|
-
console.log(`LOCAL ${config.apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`);
|
|
6173
|
-
} else {
|
|
6174
|
-
console.log(`PROD ${config.apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`);
|
|
6175
|
-
}
|
|
6206
|
+
console.log(require('./lib/server-banner').serverBanner(config.apiUrl, h));
|
|
6176
6207
|
} catch { /* never block a run on a banner */ }
|
|
6177
6208
|
|
|
6178
6209
|
const { resolveRunTarget, isRunId } = require('./lib/run-target');
|
|
@@ -6458,6 +6489,7 @@ program
|
|
|
6458
6489
|
console.log('');
|
|
6459
6490
|
console.log(`Awaiting approval at ${data.currentStep}.`);
|
|
6460
6491
|
console.log(`Approve: hone run-story ${data.runId} --approve ${data.currentStep}`);
|
|
6492
|
+
console.log(`Reject: hone run-story ${data.runId} --reject ${data.currentStep} --reason "<why>"`);
|
|
6461
6493
|
}
|
|
6462
6494
|
console.log('');
|
|
6463
6495
|
// HC-GF-016: at a TERMINAL state, print the report unasked. The whole
|
|
@@ -6527,6 +6559,37 @@ program
|
|
|
6527
6559
|
return;
|
|
6528
6560
|
}
|
|
6529
6561
|
|
|
6562
|
+
// Reject mode (HC-GF-323)
|
|
6563
|
+
if (opts.reject) {
|
|
6564
|
+
if (!opts.reason || !String(opts.reason).trim()) {
|
|
6565
|
+
console.error('--reject requires --reason <text>');
|
|
6566
|
+
process.exit(1);
|
|
6567
|
+
}
|
|
6568
|
+
let rejectId;
|
|
6569
|
+
try {
|
|
6570
|
+
rejectId = await existingRunId(`--reject ${opts.reject}`);
|
|
6571
|
+
await client.post(orchestratePath([rejectId, 'reject']),
|
|
6572
|
+
{ stepKey: opts.reject, reason: opts.reason });
|
|
6573
|
+
console.log(`Gate ${opts.reject} rejected. Re-running with the reason. Workflow resuming.`);
|
|
6574
|
+
} catch (e) {
|
|
6575
|
+
console.error(`Failed: ${e.response?.data?.error || e.message}`);
|
|
6576
|
+
process.exit(1);
|
|
6577
|
+
}
|
|
6578
|
+
// HC-GF-070: same FOLLOW behaviour as --approve — a caller told to poll does not.
|
|
6579
|
+
if (opts.follow === false) {
|
|
6580
|
+
console.log(`Poll: hone run-story ${rejectId} --status`);
|
|
6581
|
+
return;
|
|
6582
|
+
}
|
|
6583
|
+
try {
|
|
6584
|
+
await pollUntilDecision(rejectId);
|
|
6585
|
+
} catch (e) {
|
|
6586
|
+
console.error(`Rejected, but following the run failed: ${e.message}`);
|
|
6587
|
+
console.error(`Poll: hone run-story ${rejectId} --status`);
|
|
6588
|
+
process.exit(1);
|
|
6589
|
+
}
|
|
6590
|
+
return;
|
|
6591
|
+
}
|
|
6592
|
+
|
|
6530
6593
|
// Start workflow (storyId required, runId not accepted for starting)
|
|
6531
6594
|
if (isUuid) {
|
|
6532
6595
|
console.error('Cannot start workflow with a runId. Use a story ID like HC-042.');
|
|
@@ -7860,7 +7923,11 @@ program
|
|
|
7860
7923
|
verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
|
|
7861
7924
|
// best-effort: pull the run's ci_local_command for the default
|
|
7862
7925
|
try {
|
|
7863
|
-
|
|
7926
|
+
// HC-GF-302-followup-1: on a connection of its own. This GET follows the step GET at once, and
|
|
7927
|
+
// on the socket that one pooled a server closing it idle resets the request - a failed config
|
|
7928
|
+
// read, so the provenance gate below refused a trusted run (exit 9). Nothing is retried: a read
|
|
7929
|
+
// that fails on its own connection still refuses.
|
|
7930
|
+
const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
|
|
7864
7931
|
// HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
|
|
7865
7932
|
// `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
|
|
7866
7933
|
// 200 with no config. And a body that is not THIS run is not this run's config.
|
|
@@ -8164,9 +8231,10 @@ program
|
|
|
8164
8231
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8165
8232
|
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8166
8233
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8234
|
+
maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: a >1 MiB suite (this repo's own `make ci`) must not ENOBUFS
|
|
8167
8235
|
});
|
|
8168
8236
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8169
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
8237
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
|
|
8170
8238
|
// HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
|
|
8171
8239
|
if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
|
|
8172
8240
|
const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
|
|
@@ -8222,6 +8290,7 @@ program
|
|
|
8222
8290
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8223
8291
|
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8224
8292
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8293
|
+
maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: shares the shape at :8184 — same fix
|
|
8225
8294
|
});
|
|
8226
8295
|
const c = classifyGateRun({
|
|
8227
8296
|
code: gr.status, error: gr.error,
|
|
@@ -8286,7 +8355,7 @@ program
|
|
|
8286
8355
|
attempt: opts.attempt ? Number(opts.attempt) : verifiedAttempt,
|
|
8287
8356
|
headSha,
|
|
8288
8357
|
gates: reportGates.length ? reportGates : undefined,
|
|
8289
|
-
});
|
|
8358
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
8290
8359
|
if (opts.format !== 'json') {
|
|
8291
8360
|
// HC-GF-070: name the gate that is actually configured. This said "(advisory)"
|
|
8292
8361
|
// unconditionally, and printed it on a run where `enforce` was auto-approving
|
|
@@ -8451,12 +8520,26 @@ program
|
|
|
8451
8520
|
const { resolveCommandDetailed, verdictFromRun, verifyCommandEnv } = require('./lib/verify-patch');
|
|
8452
8521
|
const { verifyStateFor } = require('./lib/emit-pr');
|
|
8453
8522
|
const {
|
|
8454
|
-
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish,
|
|
8523
|
+
EXIT, buildBranchName, buildCommitMessage, buildPrBody, decidePublish, recordPr,
|
|
8455
8524
|
} = require('./lib/emit-pr');
|
|
8456
8525
|
const { gitEnv } = require('./lib/git-env');
|
|
8457
8526
|
const config = getConfig();
|
|
8458
8527
|
const client = api(config);
|
|
8459
8528
|
const sameRun = (id) => String(id).toLowerCase() === String(workflowId).toLowerCase();
|
|
8529
|
+
// HC-GF-302: record the PR on the run. The POST goes out after the verify, push and `gh` steps,
|
|
8530
|
+
// which block the event loop for minutes, and a keep-alive socket the server closed meanwhile
|
|
8531
|
+
// fails `write EPIPE` when reused (HC-GF-326). A fresh connection per record avoids that.
|
|
8532
|
+
//
|
|
8533
|
+
// F3 (review): baseSha and attempt are the CALLER's to supply, not this closure's defaults —
|
|
8534
|
+
// the two call sites below have different bases for trusting each one (see their own
|
|
8535
|
+
// comments). recordPr itself stays pure; this only wires up the transport.
|
|
8536
|
+
const recordPrOnRun = ({ prUrl, branch: prBranch, baseSha: recBaseSha = null, attempt: recAttempt }) => recordPr({
|
|
8537
|
+
post: (p, body) => client.post(p, body, require('./lib/fresh-connection').freshConnection()),
|
|
8538
|
+
path: orchestratePath([workflowId, 'pr-record']),
|
|
8539
|
+
prUrl, branch: prBranch, baseSha: recBaseSha, attempt: recAttempt,
|
|
8540
|
+
});
|
|
8541
|
+
const recordWarning = (rec) => (rec.recorded || opts.format === 'json'
|
|
8542
|
+
? '' : `\n ⚠ the run's PR record was not saved (non-fatal; the morning report may miss this link): ${rec.reason}`);
|
|
8460
8543
|
|
|
8461
8544
|
// --open-pr implies --push (cannot open a PR against an unpushed branch).
|
|
8462
8545
|
const doPush = Boolean(opts.push || opts.openPr);
|
|
@@ -8477,14 +8560,19 @@ program
|
|
|
8477
8560
|
// branch naming, and 'story' is not all digits — so assessing it would trust a run whose id
|
|
8478
8561
|
// never arrived.
|
|
8479
8562
|
let runStoryId = null;
|
|
8563
|
+
// HC-GF-302: the attempt the server resolved (the latest one unless --attempt named it), which
|
|
8564
|
+
// orders this run's PR records. Not opts.attempt: that is absent in the default case.
|
|
8565
|
+
let stepAttempt = null;
|
|
8480
8566
|
try {
|
|
8481
8567
|
const url = orchestratePath([workflowId, 'step', opts.stepKey], { attempt: opts.attempt });
|
|
8482
8568
|
const r = await client.get(url);
|
|
8483
8569
|
// The step output must be THIS run's: nothing else ties the patch to the config assessed below.
|
|
8484
8570
|
if (r.data?.runId && !sameRun(r.data.runId)) throw new Error(`the server returned ${opts.stepKey} for a different run`);
|
|
8485
8571
|
output = r.data?.output;
|
|
8572
|
+
stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
|
|
8486
8573
|
try {
|
|
8487
|
-
|
|
8574
|
+
// HC-GF-302-followup-1: its own connection, as verify-patch's (see there).
|
|
8575
|
+
const runR = await client.get(orchestratePath([workflowId]), require('./lib/fresh-connection').freshConnection());
|
|
8488
8576
|
// HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
|
|
8489
8577
|
// is not THIS run does not count as its config.
|
|
8490
8578
|
if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
|
|
@@ -8587,8 +8675,24 @@ program
|
|
|
8587
8675
|
}
|
|
8588
8676
|
const decision = decidePublish({ branchExists, openPrUrl });
|
|
8589
8677
|
if (decision.action === 'already_open') {
|
|
8590
|
-
|
|
8591
|
-
|
|
8678
|
+
// HC-GF-302 (review F3): record here too — hone follow-batch sends a completed run's emit
|
|
8679
|
+
// to the branch its draft already opened, so this is the path that retries a record the
|
|
8680
|
+
// draft failed to save. But this path never verified anything against a specific attempt:
|
|
8681
|
+
// it found an already-open PR without running check-patch/verify-patch here, so it has no
|
|
8682
|
+
// basis to claim `headSha` is this attempt's base — baseSha is always null on this path.
|
|
8683
|
+
// And `stepAttempt` above is whichever attempt the server considers LATEST, which is not
|
|
8684
|
+
// necessarily the attempt the open PR's branch was built from — so this only records when
|
|
8685
|
+
// the caller named the attempt explicitly with --attempt. `hone follow-batch` always does
|
|
8686
|
+
// (cli/lib/batch-follow.js buildArgs pushes `--attempt N` for every emit-pr call it makes);
|
|
8687
|
+
// a bare `hone emit-pr --open-pr` with no --attempt has no basis to order a record either,
|
|
8688
|
+
// so it records nothing rather than guessing.
|
|
8689
|
+
const explicitAttempt = opts.attempt !== undefined ? Number(opts.attempt) : null;
|
|
8690
|
+
const rec = explicitAttempt != null
|
|
8691
|
+
? await recordPrOnRun({ prUrl: decision.prUrl, branch, baseSha: null, attempt: explicitAttempt })
|
|
8692
|
+
: { recorded: false, reason: 'the open PR\'s attempt is unknown; pass --attempt' };
|
|
8693
|
+
emit({ ok: true, verdict: 'already_open', prUrl: decision.prUrl, branch, recorded: rec.recorded,
|
|
8694
|
+
...(rec.recorded ? {} : { recordError: rec.reason }) },
|
|
8695
|
+
`✓ a draft PR is already open for ${branch}: ${decision.prUrl}${recordWarning(rec)}`);
|
|
8592
8696
|
process.exit(EXIT.PASS);
|
|
8593
8697
|
}
|
|
8594
8698
|
if (decision.action === 'branch_conflict') {
|
|
@@ -8683,9 +8787,10 @@ program
|
|
|
8683
8787
|
cwd: wt, shell: true, encoding: 'utf8',
|
|
8684
8788
|
timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
|
|
8685
8789
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
8790
|
+
maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: emit-pr's verify shares verify-patch's spawnSync shape
|
|
8686
8791
|
});
|
|
8687
8792
|
const timedOut = run.error && run.error.code === 'ETIMEDOUT';
|
|
8688
|
-
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced }, provAll);
|
|
8793
|
+
const v = verdictFromRun({ code: run.status, timedOut, stdout: run.stdout, stderr: run.stderr, command, source: commandSource, replaced: commandReplaced, error: run.error }, provAll);
|
|
8689
8794
|
// HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
|
|
8690
8795
|
// narrowed verdict fell into the else below, which printed "verify failed, no branch
|
|
8691
8796
|
// emitted" and then exited with that verdict's own exit code — zero. A failure path that
|
|
@@ -8761,8 +8866,13 @@ program
|
|
|
8761
8866
|
|
|
8762
8867
|
if (exitCode === EXIT.PASS) {
|
|
8763
8868
|
const tier = prUrl ? 'pr' : pushed ? 'pushed' : 'dry_run';
|
|
8869
|
+
// HC-GF-302: only a PR is recorded; a pushed or local branch has no link to show. This
|
|
8870
|
+
// is the path that just verified the diff and committed it: headSha is the commit the
|
|
8871
|
+
// patch was verified against, so it is a true baseSha here (unlike the already_open
|
|
8872
|
+
// path above, which never ran that verification and always records baseSha: null).
|
|
8873
|
+
const rec = prUrl ? await recordPrOnRun({ prUrl, branch, baseSha: headSha || null, attempt: stepAttempt }) : null;
|
|
8764
8874
|
const human = prUrl
|
|
8765
|
-
? `✓ draft PR opened: ${prUrl}`
|
|
8875
|
+
? `✓ draft PR opened: ${prUrl}${recordWarning(rec)}`
|
|
8766
8876
|
: pushed
|
|
8767
8877
|
? `✓ branch pushed to origin/${branch}\n → open a PR: gh pr create --head ${branch} --draft`
|
|
8768
8878
|
: `✓ branch '${branch}' built locally (dry run — nothing pushed)\n` +
|
|
@@ -8770,7 +8880,11 @@ program
|
|
|
8770
8880
|
` → push it: hone emit-pr ${workflowId} --push\n` +
|
|
8771
8881
|
` → push + PR: hone emit-pr ${workflowId} --open-pr\n` +
|
|
8772
8882
|
` → discard it: git branch -D ${branch}`;
|
|
8773
|
-
|
|
8883
|
+
// F8 (review): --format json previously dropped the failure reason entirely once
|
|
8884
|
+
// `recorded` was false — a reader had no way to tell why without re-running in
|
|
8885
|
+
// --format pretty. Same object, one extra field, omitted once recorded:true.
|
|
8886
|
+
emit({ ok: true, verdict: tier, branch, verifyState, prUrl, pushed, headSha,
|
|
8887
|
+
...(rec ? { recorded: rec.recorded, ...(rec.recorded ? {} : { recordError: rec.reason }) } : {}) }, human);
|
|
8774
8888
|
}
|
|
8775
8889
|
}
|
|
8776
8890
|
}
|
|
@@ -8912,7 +9026,7 @@ program
|
|
|
8912
9026
|
verdict: verdict.verdict,
|
|
8913
9027
|
exitCode: verdict.exitCode,
|
|
8914
9028
|
detail: verdict.detail,
|
|
8915
|
-
});
|
|
9029
|
+
}, require('./lib/fresh-connection').freshConnection());
|
|
8916
9030
|
reported = true;
|
|
8917
9031
|
if (opts.format !== 'json') console.log(` → reported CI-gate verdict '${verdict.verdict}' to run ${opts.workflowId} (advisory)`);
|
|
8918
9032
|
} catch (e) {
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* bundle-mount-follow.js — HC-GF-325.
|
|
4
|
+
*
|
|
5
|
+
* `cli/lib/bundle-paths.js` bundles only paths the story NAMES — it has no mount, prefix or
|
|
6
|
+
* import following. step_2 (API test writer) then has no way to know a router file's routes are
|
|
7
|
+
* served under a prefix, because nothing in the bundle says so. Measured live: step_2 wrote 7
|
|
8
|
+
* tests against `/properties` for an app that mounts every router under `/api/v1`
|
|
9
|
+
* (`app.include_router(properties.router, prefix=settings.api_prefix)`); all 7 failed on a 404.
|
|
10
|
+
*
|
|
11
|
+
* This module answers one question given a bundled file's path and content: "if this file
|
|
12
|
+
* defines routes, which OTHER tracked file mounts it, and which existing test already exercises
|
|
13
|
+
* a router mounted the same way?" Both answers are returned as extra paths for the caller to
|
|
14
|
+
* bundle alongside the file the story named — it does not read the filesystem itself, so it stays
|
|
15
|
+
* as pure/testable as the rest of `cli/lib`.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const path = require('path');
|
|
19
|
+
|
|
20
|
+
// A file DEFINES routes when it either builds a router object (FastAPI's `APIRouter()`, Express's
|
|
21
|
+
// `Router()`) or decorates/registers at least one HTTP verb against one (`@router.get(...)`,
|
|
22
|
+
// `router.get(...)`) — the object alone (an empty router) is not enough to justify following it.
|
|
23
|
+
const DEFINES_ROUTER_OBJECT = /\bAPIRouter\s*\(|\b(?:express\.)?Router\s*\(\s*\)/;
|
|
24
|
+
const REGISTERS_A_ROUTE = /@\w+\.(get|post|put|delete|patch)\s*\(|\b\w+\.(get|post|put|delete|patch)\s*\(\s*['"`]/i;
|
|
25
|
+
|
|
26
|
+
function definesRoutes(content) {
|
|
27
|
+
const text = String(content || '');
|
|
28
|
+
return DEFINES_ROUTER_OBJECT.test(text) && REGISTERS_A_ROUTE.test(text);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Where an app typically registers its routers/routes — bounded, so following a mount never
|
|
32
|
+
// means reading every tracked file's content. FastAPI/Flask/Express conventions only; a repo
|
|
33
|
+
// using neither simply gets no mount file, same as today.
|
|
34
|
+
const ENTRYPOINT_NAME_RE = /(^|\/)(main|app|asgi|wsgi|server|index|__init__)\.(py|js|mjs|cjs|ts)$/i;
|
|
35
|
+
|
|
36
|
+
// `app.include_router(...)` (FastAPI/Starlette) or `app.use(...)` (Express) — the call that
|
|
37
|
+
// registers a router built elsewhere. Requires the SAME file to also reference the router
|
|
38
|
+
// file's module name, so a coincidental `app.use(` for an unrelated router does not match.
|
|
39
|
+
const MOUNT_CALL_RE = /\b\w+\.include_router\s*\(|\b\w+\.use\s*\(/;
|
|
40
|
+
|
|
41
|
+
function baseModuleName(filePath) {
|
|
42
|
+
return path.posix.basename(String(filePath || ''), path.posix.extname(String(filePath || '')));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function mentionsModule(content, moduleName) {
|
|
46
|
+
if (!moduleName) return false;
|
|
47
|
+
const re = new RegExp(`\\b${moduleName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
|
|
48
|
+
return re.test(String(content || ''));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {object} o
|
|
53
|
+
* @param {string} o.routeFilePath the bundled file being checked
|
|
54
|
+
* @param {string} o.routeFileContent
|
|
55
|
+
* @param {string[]} o.repoFiles every tracked path (the caller's git ls-files list)
|
|
56
|
+
* @param {(p:string)=>string} o.readFile reads a candidate mount file; the caller's disk I/O
|
|
57
|
+
* @returns {string|null} the path of the file that mounts routeFilePath's router, or null
|
|
58
|
+
*/
|
|
59
|
+
function findMountFile({ routeFilePath, routeFileContent, repoFiles, readFile }) {
|
|
60
|
+
if (!definesRoutes(routeFileContent)) return null;
|
|
61
|
+
const moduleName = baseModuleName(routeFilePath);
|
|
62
|
+
const candidates = (Array.isArray(repoFiles) ? repoFiles : [])
|
|
63
|
+
.filter((p) => p !== routeFilePath && ENTRYPOINT_NAME_RE.test(p));
|
|
64
|
+
for (const candidate of candidates) {
|
|
65
|
+
let content;
|
|
66
|
+
try { content = readFile(candidate); } catch { continue; }
|
|
67
|
+
if (MOUNT_CALL_RE.test(content) && mentionsModule(content, moduleName)) return candidate;
|
|
68
|
+
}
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// The same directory-prefix + name convention `cli/lib/layout-detect.js` uses for a test root —
|
|
73
|
+
// kept independent (not imported) because this module's job is narrower: ONE existing file to
|
|
74
|
+
// show the test writer the mounted-prefix convention, not a directory computation.
|
|
75
|
+
const TEST_PATH_RE = /(^|\/)(tests?|__tests__|spec|specs|e2e)(\/|$)|\.(test|spec)\.[a-z]+$/i;
|
|
76
|
+
// A test that actually drives an HTTP client — the convention worth copying — over one that
|
|
77
|
+
// merely lives in a test directory (a fixtures or conftest file, say).
|
|
78
|
+
const API_TEST_SIGNAL_RE = /\bTestClient\s*\(|\bsupertest\s*\(|\brequest\s*\(\s*app\s*\)|\bclient\.(get|post|put|delete|patch)\s*\(/;
|
|
79
|
+
|
|
80
|
+
function topLevelDir(filePath) {
|
|
81
|
+
const norm = String(filePath || '').replace(/^\.\//, '');
|
|
82
|
+
return norm.includes('/') ? norm.split('/')[0] : '';
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {object} o
|
|
87
|
+
* @param {string} o.routeFilePath
|
|
88
|
+
* @param {string[]} o.repoFiles
|
|
89
|
+
* @param {(p:string)=>string} o.readFile
|
|
90
|
+
* @returns {string|null} an existing test file demonstrating the same kind of route testing
|
|
91
|
+
*/
|
|
92
|
+
function findExistingApiTest({ routeFilePath, repoFiles, readFile }) {
|
|
93
|
+
const dir = topLevelDir(routeFilePath);
|
|
94
|
+
const candidates = (Array.isArray(repoFiles) ? repoFiles : [])
|
|
95
|
+
.filter((p) => p !== routeFilePath && (!dir || p.startsWith(dir + '/')) && TEST_PATH_RE.test(p));
|
|
96
|
+
for (const candidate of candidates) {
|
|
97
|
+
let content;
|
|
98
|
+
try { content = readFile(candidate); } catch { continue; }
|
|
99
|
+
if (API_TEST_SIGNAL_RE.test(content)) return candidate;
|
|
100
|
+
}
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The one entry point `run-context.js` calls per already-bundled ("present") file.
|
|
106
|
+
* @returns {string[]} zero, one or two extra paths to bundle (mount file, existing API test)
|
|
107
|
+
*/
|
|
108
|
+
function followRouterMount({ routeFilePath, routeFileContent, repoFiles, readFile }) {
|
|
109
|
+
if (!definesRoutes(routeFileContent)) return [];
|
|
110
|
+
const extra = [];
|
|
111
|
+
const mountFile = findMountFile({ routeFilePath, routeFileContent, repoFiles, readFile });
|
|
112
|
+
if (mountFile) extra.push(mountFile);
|
|
113
|
+
const testFile = findExistingApiTest({ routeFilePath, repoFiles, readFile });
|
|
114
|
+
if (testFile) extra.push(testFile);
|
|
115
|
+
return extra;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { definesRoutes, findMountFile, findExistingApiTest, followRouterMount };
|
package/lib/bundle-paths.js
CHANGED
|
@@ -315,13 +315,25 @@ function anchorLineForStory(content, storyText) {
|
|
|
315
315
|
* The fallback is the OLD behaviour on purpose: a term that is only ever mentioned still
|
|
316
316
|
* gets the operator a window near it, which beats none.
|
|
317
317
|
*/
|
|
318
|
+
/**
|
|
319
|
+
* HC-GF-206-followup-1: a collapsed hit is a real site — the collapse only means it EXPECTS to
|
|
320
|
+
* share its neighbour's window, not that it is covered. The final window's radius is chosen
|
|
321
|
+
* later (as low as 200 chars, `planFileExcerpt`) and can land under the 40-line threshold that
|
|
322
|
+
* collapsed it, so a hit collapsed at 36 lines from its neighbour can fall outside the window
|
|
323
|
+
* that was supposed to carry it, with nothing reporting the loss. `collapsed` returns each
|
|
324
|
+
* dropped hit with `near`, the kept site's line it was folded into, so the caller can extend
|
|
325
|
+
* that site's window to actually reach it.
|
|
326
|
+
*
|
|
327
|
+
* @returns {{sites: Array<{line: number, anchor: string}>,
|
|
328
|
+
* collapsed: Array<{line: number, anchor: string, near: number}>}}
|
|
329
|
+
*/
|
|
318
330
|
function anchorSitesForStory(content, storyText, max = 6) {
|
|
319
331
|
// HC-GF-204: every region a story's terms point at, best-first. `anchorLineForStory`
|
|
320
332
|
// answers "where does this live"; a story also needs "where is it USED", and on run
|
|
321
333
|
// ad4a561f the planner had the definition and refused for want of the call sites.
|
|
322
334
|
const text = String(content || '');
|
|
323
335
|
const story = String(storyText || '');
|
|
324
|
-
if (!text || !story) return [];
|
|
336
|
+
if (!text || !story) return { sites: [], collapsed: [] };
|
|
325
337
|
const candidates = [...story.matchAll(BACKTICKED_RE)]
|
|
326
338
|
.map((m) => m[1].trim())
|
|
327
339
|
.filter((c) => c.length >= 3
|
|
@@ -331,6 +343,7 @@ function anchorSitesForStory(content, storyText, max = 6) {
|
|
|
331
343
|
|
|
332
344
|
const seenLine = new Set();
|
|
333
345
|
const sites = [];
|
|
346
|
+
const collapsed = [];
|
|
334
347
|
for (const anchor of candidates) {
|
|
335
348
|
const def = definitionIndexOf(text, anchor);
|
|
336
349
|
if (def === -1) continue;
|
|
@@ -339,14 +352,16 @@ function anchorSitesForStory(content, storyText, max = 6) {
|
|
|
339
352
|
for (const m of text.matchAll(new RegExp(`(?<![\\w$])${t}(?![\\w$])`, 'g'))) offsets.push(m.index);
|
|
340
353
|
for (const off of offsets) {
|
|
341
354
|
const line = text.slice(0, off).split('\n').length;
|
|
342
|
-
// Collapse near-duplicates: two hits 20 lines apart share one window.
|
|
343
|
-
|
|
355
|
+
// Collapse near-duplicates: two hits 20 lines apart share one window. `near` records
|
|
356
|
+
// WHICH kept site it shares with, so a caller can pull it back in later.
|
|
357
|
+
const near = [...seenLine].find((l) => Math.abs(l - line) < 40);
|
|
358
|
+
if (near !== undefined) { collapsed.push({ line, anchor, near }); continue; }
|
|
344
359
|
seenLine.add(line);
|
|
345
360
|
sites.push({ line, anchor });
|
|
346
|
-
if (sites.length >= max) return sites;
|
|
361
|
+
if (sites.length >= max) return { sites, collapsed };
|
|
347
362
|
}
|
|
348
363
|
}
|
|
349
|
-
return sites;
|
|
364
|
+
return { sites, collapsed };
|
|
350
365
|
}
|
|
351
366
|
|
|
352
367
|
function definitionIndexOf(text, term) {
|
|
@@ -428,7 +443,7 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
428
443
|
// hence budgeted first, and `mergeIntersectingWindows` unions it with any inferred
|
|
429
444
|
// window it overlaps. No sort, no priority field — array order already says it.
|
|
430
445
|
// HC-GF-288: every stated line seeds it, in the caller's order.
|
|
431
|
-
const inferred = anchorSitesForStory(text, storyText);
|
|
446
|
+
const { sites: inferred, collapsed } = anchorSitesForStory(text, storyText);
|
|
432
447
|
const sites = [...stated.map((l) => ({ line: l, anchor: null })), ...inferred];
|
|
433
448
|
const chosen = sites.length ? sites : [hit];
|
|
434
449
|
const why = (a, l) => (a ? `anchored on \`${a}\`` : `centered on referenced line ${l}`);
|
|
@@ -449,8 +464,19 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
449
464
|
// The radius is sized before any window exists, so it prices the marker at its widest:
|
|
450
465
|
// the longest label among the sites, with line numbers at the file's own width.
|
|
451
466
|
const widest = { startLine: totalLines, endLine: totalLines, totalLines };
|
|
467
|
+
// HC-GF-334: the footer repeats `filePath` in EVERY kept window (see `footer` above), so
|
|
468
|
+
// sizing `radius` off its real length made window boundaries — and therefore which sites
|
|
469
|
+
// and collapsed hits are reachable — depend on how long the file's path happens to be.
|
|
470
|
+
// Measured: with real files and roadmap rows, a fake path fixed at length 80 or 90 dropped
|
|
471
|
+
// a hit that lengths 10-70 and 100-200 all kept, for the SAME content and story — a path a
|
|
472
|
+
// few characters different flips whether a window's char-radius snaps past a line boundary.
|
|
473
|
+
// Reserve a fixed, generous footer cost for SIZING the radius instead of the real one, so
|
|
474
|
+
// the estimate — and the windows it produces — no longer shift with path length. `costOf`
|
|
475
|
+
// and `budget()` below still charge the REAL footer length, so the cap is still never
|
|
476
|
+
// exceeded even for a path longer than this reserve; it only stops falling short of it.
|
|
477
|
+
const FOOTER_SIZING_RESERVE_CHARS = 300;
|
|
452
478
|
const markerCost = Math.max(...chosen.map((s) => header(widest, s.anchor, totalLines).length))
|
|
453
|
-
+ footer.length + 1;
|
|
479
|
+
+ Math.max(footer.length, FOOTER_SIZING_RESERVE_CHARS) + 1;
|
|
454
480
|
// HC-GF-288: the 800 covers ONE window's marker, and every further window brings its
|
|
455
481
|
// own. Sized from `limit - 800` alone, n windows overran the cap from n = 3 — so the
|
|
456
482
|
// LAST region was always dropped however much room the rest left. Measured on
|
|
@@ -515,15 +541,15 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
515
541
|
// only ever dropped when the stated regions alone exceed the cap.
|
|
516
542
|
const budget = (ord) => {
|
|
517
543
|
const kept = [];
|
|
544
|
+
const skipped = [];
|
|
518
545
|
let spent = 0;
|
|
519
|
-
let skipped = 0;
|
|
520
546
|
for (const site of ord) {
|
|
521
547
|
const cost = costOf(site);
|
|
522
548
|
if (spent + cost > limit) {
|
|
523
549
|
// A dropped stated region is reported by LINE below, not counted here: the
|
|
524
550
|
// advice attached to this count — "name them as path:<line>" — is advice the
|
|
525
551
|
// operator has already followed for those.
|
|
526
|
-
if (!site.stated) skipped
|
|
552
|
+
if (!site.stated) skipped.push(site);
|
|
527
553
|
continue;
|
|
528
554
|
}
|
|
529
555
|
spent += cost;
|
|
@@ -551,6 +577,12 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
551
577
|
// considered: a layout with no window sends the result to the fallback, which overruns
|
|
552
578
|
// the cap by construction. Third review: preferring width alone discarded a 1,066-char
|
|
553
579
|
// layout for a 33,002-char fallback at cap 12,000.
|
|
580
|
+
//
|
|
581
|
+
// HC-GF-188-followup-1: so a layout that kept nothing has not succeeded, stated lines or
|
|
582
|
+
// not. With none stated, "honours every stated line" was vacuously true at the estimate,
|
|
583
|
+
// the narrower retry never ran, and the fallback returned 30,502 chars at cap 12,000 —
|
|
584
|
+
// the first inferred anchor's long line — while the second anchor's window, 411 chars at
|
|
585
|
+
// radius 200, was never tried. With a stated line, honouring it already means a window.
|
|
554
586
|
let best = null;
|
|
555
587
|
const better = (a, b) => (a.honoured - b.honoured)
|
|
556
588
|
|| (Number(a.plan.kept.length > 0) - Number(b.plan.kept.length > 0))
|
|
@@ -560,10 +592,10 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
560
592
|
const honoured = stated.filter((l) => plan.kept.some(({ w }) => covers(w, l))).length;
|
|
561
593
|
const cand = { r, plan, honoured };
|
|
562
594
|
if (!best || better(cand, best) > 0) best = cand;
|
|
563
|
-
return honoured === stated.length;
|
|
595
|
+
return honoured === stated.length && plan.kept.length > 0;
|
|
564
596
|
};
|
|
565
597
|
if (!tryRadius(radius) && radius > 200 && tryRadius(200)) {
|
|
566
|
-
//
|
|
598
|
+
// It fits at 200 but not at the estimate: binary-search between them.
|
|
567
599
|
// Bounded by log2(radius) layouts — measured at most 12 layouts and 228 ms on a
|
|
568
600
|
// 1.6 MB file of long lines, a few ms on ordinary source.
|
|
569
601
|
let lo = 200;
|
|
@@ -575,7 +607,6 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
575
607
|
}
|
|
576
608
|
radius = best.r;
|
|
577
609
|
windows.push(...best.plan.kept);
|
|
578
|
-
const dropped = best.plan.skipped;
|
|
579
610
|
if (!windows.length) {
|
|
580
611
|
// Nothing fits at any radius tried — one line alone is longer than the cap. Build the
|
|
581
612
|
// window at the MINIMUM radius, so the overrun is that line and no more: at the
|
|
@@ -583,6 +614,66 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
583
614
|
const w = windowAroundLine(text, chosen[0].line, 200);
|
|
584
615
|
windows.push({ w, anchor: chosen[0].anchor, line: chosen[0].line });
|
|
585
616
|
}
|
|
617
|
+
// HC-GF-188-followup-1: a region is dropped when NOTHING bundled reaches it — counted
|
|
618
|
+
// against what is returned, not what the budget skipped. The fallback above returns one
|
|
619
|
+
// region the budget had skipped, and the notice still called it dropped: "2 further
|
|
620
|
+
// region(s) DROPPED" with one missing. Every window grows with the radius around the
|
|
621
|
+
// same line, so the fallback's window lies inside that region's; and the layout's
|
|
622
|
+
// regions are disjoint once merged, so no other skipped region intersects a kept one.
|
|
623
|
+
const dropped = best.plan.skipped.filter((s) =>
|
|
624
|
+
!windows.some(({ w }) => w.startLine <= s.w.endLine && s.w.startLine <= w.endLine)).length;
|
|
625
|
+
|
|
626
|
+
// HC-GF-206-followup-1: a collapsed hit shares its neighbour's window in EXPECTATION only.
|
|
627
|
+
// `radius` was chosen for the whole layout and can end up under the 40-line threshold that
|
|
628
|
+
// collapsed the hit in the first place, cutting it out of the window that was supposed to
|
|
629
|
+
// carry it. Pull each missed hit back in by widening its neighbour's window to reach it,
|
|
630
|
+
// then re-merge — widening can make two previously-separate kept windows intersect.
|
|
631
|
+
let unreached = 0;
|
|
632
|
+
if (collapsed.length) {
|
|
633
|
+
for (const hit of collapsed) {
|
|
634
|
+
if (windows.some(({ w }) => covers(w, hit.line))) continue; // already inside some window
|
|
635
|
+
// The neighbour is the kept window whose site.line this hit collapsed into — falling
|
|
636
|
+
// back to nearest-by-distance when that site's own window was dropped for budget.
|
|
637
|
+
let target = windows.find(({ line: siteLine }) => siteLine === hit.near);
|
|
638
|
+
if (!target) {
|
|
639
|
+
let bestDist = Infinity;
|
|
640
|
+
for (const entry of windows) {
|
|
641
|
+
const dist = hit.line < entry.w.startLine ? entry.w.startLine - hit.line
|
|
642
|
+
: hit.line > entry.w.endLine ? hit.line - entry.w.endLine : 0;
|
|
643
|
+
if (dist < bestDist) { bestDist = dist; target = entry; }
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
if (!target) continue; // no window exists at all — reported as unreached below
|
|
647
|
+
// THE BUDGET PROMISE STILL HOLDS: widen, re-merge (an overlap with another kept window
|
|
648
|
+
// is a real possibility once widened), price the result, and roll back rather than
|
|
649
|
+
// overrun the cap. A hit that cannot be reached within budget is unreached, not free.
|
|
650
|
+
const before = windows.map((e) => ({ ...e }));
|
|
651
|
+
target.w = windowBetweenLines(text,
|
|
652
|
+
Math.min(target.w.startLine, hit.line), Math.max(target.w.endLine, hit.line));
|
|
653
|
+
const merged = mergeIntersectingWindows(text, windows);
|
|
654
|
+
const cost = merged.reduce((s, e) => s + costOf(e), 0);
|
|
655
|
+
if (cost > limit) {
|
|
656
|
+
windows.length = 0;
|
|
657
|
+
windows.push(...before);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
windows.length = 0;
|
|
661
|
+
windows.push(...merged);
|
|
662
|
+
}
|
|
663
|
+
// Re-sort stated windows back to the front — merging can reorder when a widened window
|
|
664
|
+
// now overlaps one processed earlier, and `windows[0]` below is read as THE region (its
|
|
665
|
+
// anchor and line drive `because`). Mirrors `layout()`'s own sort exactly: `rankOf` returns
|
|
666
|
+
// -1 for a non-stated window, and sorting on that raw value would put it AHEAD of a stated
|
|
667
|
+
// one (found-index 0) rather than behind every stated window — map it to Infinity first.
|
|
668
|
+
const resorted = windows
|
|
669
|
+
.map((c, i) => { const k = rankOf(c); return { c, k: k === -1 ? Infinity : k, i }; })
|
|
670
|
+
.sort((a, b) => (a.k - b.k) || (a.i - b.i))
|
|
671
|
+
.map(({ c }) => c);
|
|
672
|
+
windows.length = 0;
|
|
673
|
+
windows.push(...resorted);
|
|
674
|
+
unreached = collapsed.filter((hit) => !windows.some(({ w }) => covers(w, hit.line))).length;
|
|
675
|
+
}
|
|
676
|
+
|
|
586
677
|
// HC-GF-288: which stated lines no kept window contains. Only the budget can do that
|
|
587
678
|
// here — every stated line seeded a window, and a merge never shrinks one.
|
|
588
679
|
const lost = stated.filter((l) => !windows.some(({ w }) => covers(w, l)));
|
|
@@ -600,6 +691,11 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
600
691
|
: '')
|
|
601
692
|
+ (lost.length > 0
|
|
602
693
|
? ` — stated line(s) ${lost.join(', ')} DROPPED for budget; name fewer regions in ${filePath}`
|
|
694
|
+
: '')
|
|
695
|
+
// HC-GF-206-followup-1: a hit collapsed into a neighbour whose final window still missed
|
|
696
|
+
// it — counted rather than silent, the same shape as `dropped` and `lost` above.
|
|
697
|
+
+ (unreached > 0
|
|
698
|
+
? ` — ${unreached} near-duplicate hit(s) collapsed into a window that did not reach them`
|
|
603
699
|
: '');
|
|
604
700
|
|
|
605
701
|
return {
|
|
@@ -613,12 +709,16 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
|
|
|
613
709
|
};
|
|
614
710
|
}
|
|
615
711
|
|
|
616
|
-
|
|
712
|
+
// HC-GF-188-followup-1: the marker is inside the cap, as a window's header and footer are.
|
|
713
|
+
// Appended after `limit` chars, it took cli/hone-cli.js to 8,077 chars at cap 8,000. It
|
|
714
|
+
// names the chars KEPT, which is at most `limit`, so its own length can only shrink.
|
|
715
|
+
const marker = (n) => `\n\n# [HC-019n-followup-12: truncated at ${n} chars; full file is ${text.length} chars]`;
|
|
716
|
+
const keep = Math.max(0, limit - marker(limit).length);
|
|
717
|
+
const dropped = text.length - keep;
|
|
617
718
|
return {
|
|
618
719
|
mode: 'truncate',
|
|
619
720
|
unhonoured,
|
|
620
|
-
content: text.slice(0,
|
|
621
|
-
+ `\n\n# [HC-019n-followup-12: truncated at ${limit} chars; full file is ${text.length} chars]`,
|
|
721
|
+
content: text.slice(0, keep) + marker(keep),
|
|
622
722
|
notice: ` ⚠ ${filePath} is ${text.length} chars (cap ${limit}) — head-truncated, ${dropped} chars `
|
|
623
723
|
+ `DROPPED and nothing in the story text matched inside the file, so the region you care about `
|
|
624
724
|
+ `may not be in the bundle. Name it as ${filePath}:<line>, or quote the code, to anchor it.`,
|
package/lib/emit-pr.js
CHANGED
|
@@ -188,7 +188,35 @@ function decidePublish({ branchExists, openPrUrl }) {
|
|
|
188
188
|
return { action: 'proceed', exitCode: EXIT.PASS };
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* HC-GF-302: tell the server which PR this emit opened or found already open, so the batch's
|
|
193
|
+
* morning report links it instead of hoping an agent wrote the URL into its output.
|
|
194
|
+
*
|
|
195
|
+
* Best-effort, and the caller never changes its exit code on the result: the PR exists either way.
|
|
196
|
+
* It only reports whether the server now knows about it. No PR URL (a dry run, or a push without
|
|
197
|
+
* --open-pr) records nothing: the report has no link to show for a branch. The attempt is the one
|
|
198
|
+
* the step GET resolved; without it the server cannot order records, so nothing is sent.
|
|
199
|
+
*
|
|
200
|
+
* @param {{ post: (path: string, body: object) => Promise<unknown>, path: string, prUrl?: string|null,
|
|
201
|
+
* branch: string, baseSha?: string|null, attempt?: number|null }} args
|
|
202
|
+
* @returns {Promise<{ recorded: boolean, reason?: string }>}
|
|
203
|
+
*/
|
|
204
|
+
async function recordPr({ post, path, prUrl, branch, baseSha, attempt }) {
|
|
205
|
+
if (!prUrl) return { recorded: false, reason: 'no PR URL to record' };
|
|
206
|
+
if (!Number.isInteger(attempt) || attempt < 1) {
|
|
207
|
+
return { recorded: false, reason: 'the server did not say which step_4 attempt this is' };
|
|
208
|
+
}
|
|
209
|
+
try {
|
|
210
|
+
await post(path, { branch, prUrl, baseSha: baseSha || null, attempt });
|
|
211
|
+
return { recorded: true };
|
|
212
|
+
} catch (e) {
|
|
213
|
+
const detail = (e && e.response && e.response.data && e.response.data.error) || (e && (e.code || e.message)) || String(e);
|
|
214
|
+
return { recorded: false, reason: e && e.response ? `HTTP ${e.response.status}: ${detail}` : detail };
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
191
218
|
module.exports = {
|
|
219
|
+
recordPr,
|
|
192
220
|
verifyStateFor,
|
|
193
221
|
EXIT,
|
|
194
222
|
HONEST_LIMIT,
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HC-GF-326: request options that open a NEW connection for one request.
|
|
5
|
+
*
|
|
6
|
+
* Node's default agent keeps sockets alive. verify-patch GETs the run, then runs the
|
|
7
|
+
* test suite with spawnSync, which blocks the event loop for minutes — far past the
|
|
8
|
+
* server's keep-alive timeout (5 s by default). The server closes the idle socket, the
|
|
9
|
+
* blocked client never processes the close, and the verdict POST is written to the dead
|
|
10
|
+
* pooled socket: `read ECONNRESET`, 5 reports out of 5, each labelled non-fatal. Every
|
|
11
|
+
* verdict a verify produced was lost to the server.
|
|
12
|
+
*
|
|
13
|
+
* Pass these to the one request that follows a long synchronous block. What makes the
|
|
14
|
+
* connection fresh is the new agent: its pool is empty, so the request opens a socket.
|
|
15
|
+
* `keepAlive: false` only stops that socket being pooled afterwards.
|
|
16
|
+
*/
|
|
17
|
+
const http = require('http');
|
|
18
|
+
const https = require('https');
|
|
19
|
+
|
|
20
|
+
function freshConnection() {
|
|
21
|
+
return {
|
|
22
|
+
httpAgent: new http.Agent({ keepAlive: false }),
|
|
23
|
+
httpsAgent: new https.Agent({ keepAlive: false }),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
module.exports = { freshConnection };
|
package/lib/layout-detect.js
CHANGED
|
@@ -20,6 +20,14 @@ const EXCLUDE = /(^|\/)(node_modules|dist|build|coverage|\.git|\.next|vendor|__p
|
|
|
20
20
|
const SOURCE_EXT = /\.(js|mjs|cjs|jsx|ts|tsx|py|java|kt|rb|go|rs|php|cs|scala|swift|cls|trigger|apex)$/i;
|
|
21
21
|
const TEST_HINT = /(^|\/)(tests?|__tests__|spec|specs|e2e)(\/|$)|\.(test|spec)\.[a-z]+$/i;
|
|
22
22
|
|
|
23
|
+
// HC-GF-020-followup-1: a documentation directory routinely carries a handful of genuinely
|
|
24
|
+
// source-extension files (Sphinx's conf.py, a docs-site build script) that clear MIN_FILES on
|
|
25
|
+
// extension count alone — measured live: a docs/ of mostly .html/.svg pages qualified as a
|
|
26
|
+
// source dir because it also held two .py files. Extension MIX, not source-extension COUNT, is
|
|
27
|
+
// what tells a docs directory apart from a package: if a top-level dir's tracked files are more
|
|
28
|
+
// documentation than source, it is documentation, however many stray source-ext files it holds.
|
|
29
|
+
const DOC_EXT = /\.(md|mdx|rst|adoc|txt|html?|svg|png|jpe?g|gif|ico|pdf)$/i;
|
|
30
|
+
|
|
23
31
|
// Directories whose NAME means "this is where the code is". A repo that puts code directly at the
|
|
24
32
|
// root of a package still gets that package dir, via the manifest-dir fallback below.
|
|
25
33
|
const SOURCE_DIR_NAMES = /^(src|lib|app|source|server|client|api|packages|apps|core)$/i;
|
|
@@ -61,6 +69,27 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
|
|
|
61
69
|
const srcFiles = paths.filter(p => SOURCE_EXT.test(p) && !TEST_HINT.test(p));
|
|
62
70
|
const testFiles = paths.filter(p => SOURCE_EXT.test(p) && TEST_HINT.test(p));
|
|
63
71
|
|
|
72
|
+
// Extension mix per top-level dir, over EVERY tracked path (not just the source-ext-filtered
|
|
73
|
+
// srcFiles/testFiles) — a docs/ directory's defining feature is the files roll() never looks
|
|
74
|
+
// at otherwise (its .html/.svg pages), so the doc-vs-source comparison needs the full list.
|
|
75
|
+
const extCountsByTop = new Map();
|
|
76
|
+
for (const p of paths) {
|
|
77
|
+
const top = p.includes('/') ? p.split('/')[0] : '.';
|
|
78
|
+
if (!extCountsByTop.has(top)) extCountsByTop.set(top, { doc: 0, src: 0 });
|
|
79
|
+
const c = extCountsByTop.get(top);
|
|
80
|
+
if (DOC_EXT.test(p)) c.doc += 1;
|
|
81
|
+
else if (SOURCE_EXT.test(p)) c.src += 1;
|
|
82
|
+
}
|
|
83
|
+
function isDocumentationOnly(top) {
|
|
84
|
+
// A dotdir (.github, .storybook, .devcontainer, ...) is tooling/config by convention, never
|
|
85
|
+
// a published-docs directory, even when it holds far more Markdown records (pipeline history,
|
|
86
|
+
// learnings) than source-ext scripts — hone-server's OWN .github/scripts/ is real code that
|
|
87
|
+
// must stay visible (HC-DF-005) alongside .github/pipeline/'s hundreds of Markdown records.
|
|
88
|
+
if (top.startsWith('.')) return false;
|
|
89
|
+
const c = extCountsByTop.get(top);
|
|
90
|
+
return !!c && c.doc > c.src;
|
|
91
|
+
}
|
|
92
|
+
|
|
64
93
|
function roll(files, nameRe = SOURCE_DIR_NAMES) {
|
|
65
94
|
const byTop = new Map();
|
|
66
95
|
for (const f of files) {
|
|
@@ -71,6 +100,7 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
|
|
|
71
100
|
const out = [];
|
|
72
101
|
for (const [top, list] of byTop) {
|
|
73
102
|
if (top === '.') continue; // root-level scripts are not a source ROOT
|
|
103
|
+
if (isDocumentationOnly(top)) continue; // docs/ with a stray conf.py is still docs/
|
|
74
104
|
const qualifies = nameRe.test(top) || manifestDirs.has(top) || list.length >= minFiles;
|
|
75
105
|
if (!qualifies) continue;
|
|
76
106
|
// Descend one level when this is a container of packages, or when a conventional source
|
|
@@ -83,14 +113,23 @@ function detectLayout({ root, trackedPaths, minFiles = 2 } = {}) {
|
|
|
83
113
|
// Files sitting DIRECTLY in this dir (cli/hone-cli.js). If any exist, the dir itself is
|
|
84
114
|
// the source root — descending would silently drop them, which is how an early cut of this
|
|
85
115
|
// rule emitted `cli/lib` and lost cli/hone-cli.js (5.8k lines, the whole CLI surface).
|
|
86
|
-
// Descend ONLY into a package container (packages/, apps/). Anywhere else, prefer the
|
|
87
|
-
// PARENT: a wider scope is the safe error for grounding, a narrower one hides real files.
|
|
88
|
-
// An earlier rule descended into conventional children and emitted `server/src`, which
|
|
89
|
-
// dropped `server/scripts/` — the exact residual the followup-42 dogfood run hit, where the
|
|
90
|
-
// planner could not see the agent prompts and proposed inventing them elsewhere.
|
|
91
116
|
const direct = list.filter(f => f.split('/').length === 2).length;
|
|
92
117
|
const container = /^(packages|apps|services|modules)$/i.test(top);
|
|
93
|
-
|
|
118
|
+
// HC-GF-020-followup-1: TEST dirs alone also descend into a sole, unambiguous child (a repo's
|
|
119
|
+
// real test dir was reported as `backend` because every test sat under `backend/tests`, a name
|
|
120
|
+
// this rule never recognized). Deliberately NOT extended to source dirs: an earlier rule
|
|
121
|
+
// descended into ANY conventional child regardless of ambiguity and emitted `server/src`,
|
|
122
|
+
// which dropped `server/scripts/` — the exact residual the followup-42 dogfood run hit, where
|
|
123
|
+
// the planner could not see the agent prompts and proposed inventing them elsewhere. `server/`
|
|
124
|
+
// has both `server/src` and `server/scripts` (2 kids) so it is unaffected either way, but a
|
|
125
|
+
// repo where `server/` holds ONLY `server/src` must still stay at `server` for source dirs —
|
|
126
|
+
// measured by this file's own regression pins — so the single-kid case is test-dirs-only.
|
|
127
|
+
// ...and only when `top` itself is NOT already a conventionally-named test root: a `tests/`
|
|
128
|
+
// dir with a single `tests/integration` child is intentionally left at `tests` — descending
|
|
129
|
+
// would report a narrower, equally-arbitrary name in place of the convention that already
|
|
130
|
+
// says exactly what this directory is.
|
|
131
|
+
const soleUnambiguousTestChild = nameRe === TEST_DIR_NAMES && !nameRe.test(top) && kids.size === 1 && direct === 0;
|
|
132
|
+
if ((container || soleUnambiguousTestChild) && kids.size > 0 && direct === 0) { out.push(...[...kids.keys()]); continue; }
|
|
94
133
|
out.push(top);
|
|
95
134
|
}
|
|
96
135
|
return [...new Set(out)].sort();
|
package/lib/run-context.js
CHANGED
|
@@ -30,6 +30,7 @@ const {
|
|
|
30
30
|
extractPathRefs, extractBareRefs, resolveBareName, alreadyCovered, planFileExcerpt,
|
|
31
31
|
} = require('./bundle-paths');
|
|
32
32
|
const { selectBundle, auditLines } = require('./bundle-context');
|
|
33
|
+
const { followRouterMount } = require('./bundle-mount-follow');
|
|
33
34
|
const { parseIncludePaths, partitionIncludeTargets, statedAnchorsFor, anchorWarnings } = require('./include-paths');
|
|
34
35
|
const { buildRepoTree, allDirs, MAX_TRANSPORT_PATHS, MAX_TRANSPORT_DIRS } = require('./repo-tree');
|
|
35
36
|
const { classifyStory } = require('./story-classifier');
|
|
@@ -497,7 +498,24 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
497
498
|
// HC-GF-073: what exists, what is pending, plus repo-level signal files and existing
|
|
498
499
|
// neighbours of the pending paths. Order is priority order: the char cap cuts from the end.
|
|
499
500
|
const selection = selectBundle({ named: [...candidates], repoFiles: repoCtx.repoFiles, exists: io.exists });
|
|
500
|
-
|
|
501
|
+
// HC-GF-325: a present file that DEFINES routes says nothing about where they are MOUNTED —
|
|
502
|
+
// step_2 wrote 7 tests against `/properties` for an app that serves every router under
|
|
503
|
+
// `/api/v1`. Right after `present`, so a mount file and an existing API test of the same
|
|
504
|
+
// kind get priority over signal files and siblings within the char budget, same as a path
|
|
505
|
+
// the story named directly would.
|
|
506
|
+
const alreadySelected = new Set([...selection.present, ...selection.signal, ...selection.siblings]);
|
|
507
|
+
const mountFollowed = [];
|
|
508
|
+
for (const p of selection.present) {
|
|
509
|
+
let content;
|
|
510
|
+
try { content = io.readFile(p); } catch { continue; }
|
|
511
|
+
for (const extra of followRouterMount({ routeFilePath: p, routeFileContent: content, repoFiles: repoCtx.repoFiles, readFile: io.readFile })) {
|
|
512
|
+
if (alreadySelected.has(extra)) continue;
|
|
513
|
+
alreadySelected.add(extra);
|
|
514
|
+
mountFollowed.push(extra);
|
|
515
|
+
pre.push(info(` → bundled ${extra} — mounts/tests the router in ${p} (HC-GF-325)`));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
const ordered = [...selection.present, ...mountFollowed, ...selection.signal, ...selection.siblings];
|
|
501
519
|
const codebaseFiles = [];
|
|
502
520
|
let totalChars = 0;
|
|
503
521
|
let budgetSpent = false; // HC-GF-288: so a not-bundled warning can say WHY
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HC-LC-004 banner, with a third state (HC-GF-328).
|
|
5
|
+
*
|
|
6
|
+
* `health` is the parsed `/health` body, or null when the server did not answer. The
|
|
7
|
+
* banner used to print PROD / THIS RUN COSTS MONEY for null, so an unreachable
|
|
8
|
+
* 127.0.0.1 was announced as a paid production run, and the run then failed with
|
|
9
|
+
* ECONNREFUSED. A server that did not answer is named as such: nothing about its cost
|
|
10
|
+
* or provider is known, so the line keeps HC-LC-004's safe default: assume it costs money.
|
|
11
|
+
*/
|
|
12
|
+
function serverBanner(apiUrl, health) {
|
|
13
|
+
if (!health) return `UNREACHABLE ${apiUrl} · /health did not answer · provider unknown · if this run starts, assume it COSTS MONEY`;
|
|
14
|
+
const llm = health.llm || null;
|
|
15
|
+
if (llm && llm.local) return `LOCAL ${apiUrl} · provider claude_cli · no paid fallback · this run costs $0.00`;
|
|
16
|
+
return `PROD ${apiUrl} · provider ${llm ? llm.primary : 'unknown'} · THIS RUN COSTS MONEY`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
module.exports = { serverBanner };
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* HC-GF-320: what `hone setup --dry-run` would do AFTER the setup script.
|
|
5
|
+
*
|
|
6
|
+
* The script honours --dry-run itself; the CLI phases that follow it did not.
|
|
7
|
+
* A dry run installed CLAUDE.md, rewrote an existing .pipeline-config.yml
|
|
8
|
+
* (platform grounding), installed test packages (--install-tests) and PUT
|
|
9
|
+
* branch protection on the default branch through the GitHub API. The CLI
|
|
10
|
+
* now stops after the script on a dry run and prints these lines instead.
|
|
11
|
+
*
|
|
12
|
+
* Pure: every fact is passed in, so the plan is testable without a server.
|
|
13
|
+
*/
|
|
14
|
+
function postScriptDryRunPlan({ claudeMdTemplate, claudeMdExists, configExists, installTests, skipBranchProtection }) {
|
|
15
|
+
const lines = [];
|
|
16
|
+
if (claudeMdTemplate) {
|
|
17
|
+
lines.push(claudeMdExists
|
|
18
|
+
? 'CLAUDE.md already exists — would skip it'
|
|
19
|
+
: 'Would install CLAUDE.md (Claude Code pipeline context)');
|
|
20
|
+
}
|
|
21
|
+
if (configExists) lines.push('Would re-run platform grounding on .github/.pipeline-config.yml');
|
|
22
|
+
if (installTests) lines.push('Would install a unit test framework (--install-tests)');
|
|
23
|
+
lines.push(skipBranchProtection
|
|
24
|
+
? 'Would skip branch protection (opted out)'
|
|
25
|
+
: 'Would install GitHub branch protection on the default branch (pass --no-branch-protection to skip)');
|
|
26
|
+
return lines;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
module.exports = { postScriptDryRunPlan };
|
package/lib/verify-patch.js
CHANGED
|
@@ -196,7 +196,7 @@ function verifyCommandEnv(base = process.env) {
|
|
|
196
196
|
* Pass the `command` that ran and the resolver's `source` for it: without them an absent command is
|
|
197
197
|
* indistinguishable from a failing one (HC-GF-008), and the CLI's two executors both do.
|
|
198
198
|
*
|
|
199
|
-
* @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string, command?: string, source?: string }} run
|
|
199
|
+
* @param {{ code: number|null, timedOut: boolean, stdout?: string, stderr?: string, command?: string, source?: string, error?: { code?: string } }} run
|
|
200
200
|
* @returns {{ verdict: 'pass'|'fail'|'timeout'|'unresolved', exitCode: number, detail: string }}
|
|
201
201
|
*/
|
|
202
202
|
function verdictFromRun(run, provisioning = null) {
|
|
@@ -216,6 +216,18 @@ function verdictFromRun(run, provisioning = null) {
|
|
|
216
216
|
if (run.timedOut) {
|
|
217
217
|
return { verdict: 'timeout', exitCode: EXIT.TIMEOUT, detail: 'test command exceeded the timeout' };
|
|
218
218
|
}
|
|
219
|
+
// HC-GF-331: spawnSync kills the child and reports status: null with error.code ENOBUFS when
|
|
220
|
+
// its stdout/stderr exceed maxBuffer. A null status otherwise falls through to the generic
|
|
221
|
+
// "tests failed (exit null)" path below — the suite may have passed; nothing conclusive was
|
|
222
|
+
// observed about it either way, so this is its own outcome, never a reported failure.
|
|
223
|
+
if (run.error && run.error.code === 'ENOBUFS') {
|
|
224
|
+
return {
|
|
225
|
+
verdict: 'unresolved',
|
|
226
|
+
exitCode: EXIT.UNRESOLVED,
|
|
227
|
+
detail: 'test output exceeded the capture buffer (ENOBUFS) — nothing conclusive was observed '
|
|
228
|
+
+ 'about this change; raise maxBuffer or have the command print less',
|
|
229
|
+
};
|
|
230
|
+
}
|
|
219
231
|
if (run.code === 0) {
|
|
220
232
|
// HC-GF-223: a pass under an operator-supplied command is evidence about a SUBSET. Measured on
|
|
221
233
|
// run `437246a7`: `--command "node --test <3 files>"` reported `pass` to the server and the same
|
|
@@ -401,6 +413,11 @@ function classifyGateRun(run) {
|
|
|
401
413
|
return { verdict: 'unavailable', detail: 'command not found — gate NOT run' };
|
|
402
414
|
}
|
|
403
415
|
if (run.timedOut) return { verdict: 'unavailable', detail: 'timed out — gate NOT run to completion' };
|
|
416
|
+
// HC-GF-331: shares verdictFromRun's shape — a null status from an ENOBUFS overflow is not a
|
|
417
|
+
// finding, it is the gate not having run to a readable conclusion.
|
|
418
|
+
if (run.error && run.error.code === 'ENOBUFS') {
|
|
419
|
+
return { verdict: 'unavailable', detail: 'output exceeded the capture buffer (ENOBUFS) — gate NOT run to completion' };
|
|
420
|
+
}
|
|
404
421
|
if (run.code === 0) return { verdict: 'pass', detail: 'clean' };
|
|
405
422
|
const sum = summarizeFailure(run, { maxLines: 6 });
|
|
406
423
|
// A linter prints its findings from the TOP, so unlike a test runner the most
|