@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 +54 -4
- package/lib/bundle-mount-follow.js +118 -0
- package/lib/bundle-paths.js +12 -1
- package/lib/layout-detect.js +45 -6
- package/lib/run-context.js +19 -1
- 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
|
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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 };
|
package/lib/bundle-paths.js
CHANGED
|
@@ -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
|
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
|
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
|