@hone-ai/cli 1.36.0 → 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 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
 
@@ -6165,6 +6173,8 @@ program
6165
6173
  .option('--kill', 'Kill a run: pass a run ID, or a story ID for that story\'s latest run in this repository')
6166
6174
  .option('--axes <axes>', 'Comma-separated classifier axes for this run (e.g. shared-chokepoint) — composition input (HC-COMM-010 C0)')
6167
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.')
6168
6178
  .option('--no-verify', 'HC-GF-064: do not run verify-patch automatically when the run pauses at step_4')
6169
6179
  .option('--format <fmt>', 'Output format: pretty or json', 'pretty')
6170
6180
  .option('--poll-interval <s>', 'Poll interval in seconds', '5')
@@ -6479,6 +6489,7 @@ program
6479
6489
  console.log('');
6480
6490
  console.log(`Awaiting approval at ${data.currentStep}.`);
6481
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>"`);
6482
6493
  }
6483
6494
  console.log('');
6484
6495
  // HC-GF-016: at a TERMINAL state, print the report unasked. The whole
@@ -6548,6 +6559,37 @@ program
6548
6559
  return;
6549
6560
  }
6550
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
+
6551
6593
  // Start workflow (storyId required, runId not accepted for starting)
6552
6594
  if (isUuid) {
6553
6595
  console.error('Cannot start workflow with a runId. Use a story ID like HC-042.');
@@ -7881,7 +7923,11 @@ program
7881
7923
  verifiedAttempt = r.data?.attempt != null ? Number(r.data.attempt) : undefined;
7882
7924
  // best-effort: pull the run's ci_local_command for the default
7883
7925
  try {
7884
- const runR = await client.get(orchestratePath([workflowId]));
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());
7885
7931
  // HC-GF-270 (security review BLOCKER-1): the id is ENCODED above, so a crafted
7886
7932
  // `<uuid>/step/step_4?` cannot turn this into a fetch of some other endpoint that answers
7887
7933
  // 200 with no config. And a body that is not THIS run is not this run's config.
@@ -8185,9 +8231,10 @@ program
8185
8231
  cwd: wt, shell: true, encoding: 'utf8',
8186
8232
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8187
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
8188
8235
  });
8189
8236
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8190
- 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);
8191
8238
  // HC-GF-077: nothing was applied when HEAD already held the step — say what the command actually ran on.
8192
8239
  if (alreadyInHead && v.verdict === 'pass') v.detail = "tests passed on HEAD, which already contains the step's files";
8193
8240
  const sym = v.verdict === 'pass' ? '✓' : v.verdict === 'pass_narrowed' ? '⚠' : v.verdict === 'timeout' ? '⏱' : v.verdict === 'unresolved' ? '○' : '✗';
@@ -8243,6 +8290,7 @@ program
8243
8290
  cwd: wt, shell: true, encoding: 'utf8',
8244
8291
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8245
8292
  stdio: ['ignore', 'pipe', 'pipe'],
8293
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: shares the shape at :8184 — same fix
8246
8294
  });
8247
8295
  const c = classifyGateRun({
8248
8296
  code: gr.status, error: gr.error,
@@ -8523,7 +8571,8 @@ program
8523
8571
  output = r.data?.output;
8524
8572
  stepAttempt = Number.isInteger(r.data?.attempt) ? r.data.attempt : null;
8525
8573
  try {
8526
- const runR = await client.get(orchestratePath([workflowId]));
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());
8527
8576
  // HC-GF-270 (security review BLOCKER-1): see verify-patch — encoded id, and a body that
8528
8577
  // is not THIS run does not count as its config.
8529
8578
  if (runR.data?.runId && !sameRun(runR.data.runId)) throw new Error('run id mismatch');
@@ -8738,9 +8787,10 @@ program
8738
8787
  cwd: wt, shell: true, encoding: 'utf8',
8739
8788
  timeout: timeoutMs, env: verifyCommandEnv(gitEnv()),
8740
8789
  stdio: ['ignore', 'pipe', 'pipe'],
8790
+ maxBuffer: VERIFY_MAX_BUFFER, // HC-GF-331: emit-pr's verify shares verify-patch's spawnSync shape
8741
8791
  });
8742
8792
  const timedOut = run.error && run.error.code === 'ETIMEDOUT';
8743
- 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);
8744
8794
  // HC-GF-223: the mapping lives in cli/lib/emit-pr.js so it is testable. Before it, a
8745
8795
  // narrowed verdict fell into the else below, which printed "verify failed, no branch
8746
8796
  // emitted" and then exited with that verdict's own exit code — zero. A failure path that
@@ -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 };
@@ -464,8 +464,19 @@ function planFileExcerpt({ content, cap, line = null, lines = null, storyText =
464
464
  // The radius is sized before any window exists, so it prices the marker at its widest:
465
465
  // the longest label among the sites, with line numbers at the file's own width.
466
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;
467
478
  const markerCost = Math.max(...chosen.map((s) => header(widest, s.anchor, totalLines).length))
468
- + footer.length + 1;
479
+ + Math.max(footer.length, FOOTER_SIZING_RESERVE_CHARS) + 1;
469
480
  // HC-GF-288: the 800 covers ONE window's marker, and every further window brings its
470
481
  // own. Sized from `limit - 800` alone, n windows overran the cap from n = 3 — so the
471
482
  // LAST region was always dropped however much room the rest left. Measured on
@@ -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
- if (container && kids.size > 0 && direct === 0) { out.push(...[...kids.keys()]); continue; }
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();
@@ -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
- const ordered = [...selection.present, ...selection.signal, ...selection.siblings];
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
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hone-ai/cli",
3
- "version": "1.36.0",
3
+ "version": "1.37.0",
4
4
  "description": "Hone AI — Enterprise SDLC Pipeline CLI",
5
5
  "main": "hone-cli.js",
6
6
  "bin": {