@hone-ai/cli 1.38.0 → 1.39.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 +282 -309
- package/lib/batch-follow.js +418 -51
- package/lib/bundle-paths.js +168 -21
- package/lib/call-sites.js +489 -0
- package/lib/classify-spec.js +68 -0
- package/lib/context-reserve.js +270 -0
- package/lib/detect-test-framework.js +54 -0
- package/lib/doctor-admin-merge.js +11 -3
- package/lib/git-remote.js +103 -0
- package/lib/hard-pause-remedy.js +1 -1
- package/lib/judge-provider.js +39 -34
- package/lib/learnings-sync.js +2 -1
- package/lib/mcp-tools.js +42 -1
- package/lib/run-context.js +25 -3
- package/lib/run-target.js +34 -2
- package/lib/story-line.js +48 -12
- package/lib/story-provenance.js +19 -12
- package/package.json +1 -1
package/lib/mcp-tools.js
CHANGED
|
@@ -124,10 +124,22 @@ async function listWorkflows({ status, limit, cwd } = {}) {
|
|
|
124
124
|
// Right for a person at a terminal; wrong behind a tool whose contract is an explicit run ID, where a
|
|
125
125
|
// story ID is an agent's guess at a target. It used to fail only by accident (the server 404'd it), so
|
|
126
126
|
// the tools that ACT on a run refuse anything that is not a run ID before shelling out.
|
|
127
|
-
const { isRunId } = require('./run-target');
|
|
127
|
+
const { isRunId, runArgsError } = require('./run-target');
|
|
128
128
|
const notARunId = (runId) =>
|
|
129
129
|
`runId must be a run ID (a UUID); ${JSON.stringify(String(runId))} is not. Get one from hone_run_story or hone_list_workflows.`;
|
|
130
130
|
|
|
131
|
+
// HC-GF-270-followup-3: the other tools that take a run id, step key or ref check it the same way, BEFORE the
|
|
132
|
+
// CLI is spawned. The CLI refuses them too, but with exit 2 — which checkPatch reported to the agent as
|
|
133
|
+
// "diff does not apply". Labels are the tool's own input names. Values are checked as the CLI receives them.
|
|
134
|
+
const refused = (checks) => {
|
|
135
|
+
const msg = runArgsError(checks);
|
|
136
|
+
return msg ? { isError: true, detail: msg } : null;
|
|
137
|
+
};
|
|
138
|
+
// An optional input that is absent, null or '' counts as not given: both tools that use this drop such a value
|
|
139
|
+
// before it reaches the CLI (`if (workflowId)`), so there is nothing to check. That is deliberate, unlike runArgsError on its own,
|
|
140
|
+
// which still checks a '' it is handed.
|
|
141
|
+
const asArg = (v) => (v === undefined || v === null || v === '' ? undefined : String(v));
|
|
142
|
+
|
|
131
143
|
// hone_kill_workflow — kill ONE run by explicit id (DESTRUCTIVE, irreversible).
|
|
132
144
|
// runId is schema-required: an agent must never guess a target for a kill —
|
|
133
145
|
// explicit id or nothing (memo D3 refutation). Server 409s non-killable states.
|
|
@@ -150,6 +162,8 @@ async function killWorkflow({ runId, cwd } = {}) {
|
|
|
150
162
|
// written to disk by any Hone code.
|
|
151
163
|
async function stepInstruction({ runId, cwd } = {}) {
|
|
152
164
|
if (!runId) return { isError: true, detail: 'runId is required' };
|
|
165
|
+
const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
|
|
166
|
+
if (bad) return bad;
|
|
153
167
|
const r = await runHone(['step-instruction', String(runId), '--format', 'json'], { cwd });
|
|
154
168
|
if (r.error) return { isError: true, detail: r.error };
|
|
155
169
|
const data = parseLastJson(r.stdout);
|
|
@@ -185,6 +199,12 @@ async function submitStep({ runId, stepKey, output, contractVersion = '1', fromT
|
|
|
185
199
|
if (!fromTree && !hasOutput) {
|
|
186
200
|
return { isError: true, detail: 'output is required (the complete step output text), or set fromTree' };
|
|
187
201
|
}
|
|
202
|
+
// Before the temp file: a refused id writes nothing.
|
|
203
|
+
const bad = refused([
|
|
204
|
+
{ label: 'runId', kind: 'runId', value: String(runId) },
|
|
205
|
+
{ label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
|
|
206
|
+
]);
|
|
207
|
+
if (bad) return bad;
|
|
188
208
|
|
|
189
209
|
const base = ['submit-step', String(runId), String(stepKey), '--contract', String(contractVersion), '--format', 'json'];
|
|
190
210
|
let tmp = null;
|
|
@@ -232,6 +252,8 @@ async function status({ runId, cwd } = {}) {
|
|
|
232
252
|
// execution, in the CLI), optionally report the verdict. Long-running → wide timeout.
|
|
233
253
|
async function verifyPatch({ runId, report = false, cwd } = {}) {
|
|
234
254
|
if (!runId) return { isError: true, detail: 'runId is required' };
|
|
255
|
+
const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
|
|
256
|
+
if (bad) return bad;
|
|
235
257
|
const args = ['verify-patch', String(runId), '--format', 'json'];
|
|
236
258
|
if (report) args.push('--report');
|
|
237
259
|
const r = await runHone(args, { cwd, timeoutMs: 600000 });
|
|
@@ -246,6 +268,11 @@ async function verifyPatch({ runId, report = false, cwd } = {}) {
|
|
|
246
268
|
// hone_check_patch — does step_4's diff apply cleanly? (throwaway git apply --check)
|
|
247
269
|
async function checkPatch({ runId, stepKey = 'step_4', cwd } = {}) {
|
|
248
270
|
if (!runId) return { isError: true, detail: 'runId is required' };
|
|
271
|
+
const bad = refused([
|
|
272
|
+
{ label: 'runId', kind: 'runId', value: String(runId) },
|
|
273
|
+
{ label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
|
|
274
|
+
]);
|
|
275
|
+
if (bad) return bad;
|
|
249
276
|
const r = await runHone(['check-patch', String(runId), '--step-key', String(stepKey), '--format', 'json'], { cwd });
|
|
250
277
|
if (r.error) return { isError: true, detail: r.error };
|
|
251
278
|
const data = parseLastJson(r.stdout);
|
|
@@ -255,6 +282,8 @@ async function checkPatch({ runId, stepKey = 'step_4', cwd } = {}) {
|
|
|
255
282
|
// hone_emit_pr — verified diff → branch → draft PR (staged: --push, --open-pr).
|
|
256
283
|
async function emitPr({ runId, push = false, openPr = false, branch, base, cwd } = {}) {
|
|
257
284
|
if (!runId) return { isError: true, detail: 'runId is required' };
|
|
285
|
+
const bad = refused([{ label: 'runId', kind: 'runId', value: String(runId) }]);
|
|
286
|
+
if (bad) return bad;
|
|
258
287
|
const args = ['emit-pr', String(runId), '--format', 'json'];
|
|
259
288
|
if (openPr) args.push('--open-pr'); else if (push) args.push('--push');
|
|
260
289
|
if (branch) args.push('--branch', String(branch));
|
|
@@ -268,6 +297,11 @@ async function emitPr({ runId, push = false, openPr = false, branch, base, cwd }
|
|
|
268
297
|
// hone_verify_pr — real skill audit + real CI gate on an open PR, optional report.
|
|
269
298
|
async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
|
|
270
299
|
if (!pr) return { isError: true, detail: 'pr (number or branch) is required' };
|
|
300
|
+
const bad = refused([
|
|
301
|
+
{ label: 'pr', kind: 'gitRev', value: String(pr) },
|
|
302
|
+
{ label: 'workflowId', kind: 'runId', value: asArg(workflowId), optional: true },
|
|
303
|
+
]);
|
|
304
|
+
if (bad) return bad;
|
|
271
305
|
const args = ['verify-pr', String(pr), '--format', 'json'];
|
|
272
306
|
if (report) { args.push('--report'); if (workflowId) args.push('--workflow-id', String(workflowId)); }
|
|
273
307
|
const r = await runHone(args, { cwd, timeoutMs: 600000 });
|
|
@@ -280,6 +314,8 @@ async function verifyPr({ pr, report = false, workflowId, cwd } = {}) {
|
|
|
280
314
|
|
|
281
315
|
// hone_agent_eval — test the adopter's own agents (HC-COMM-011), optional report.
|
|
282
316
|
async function agentEval({ category, target, judge = false, provider, report = false, workflowId, cwd } = {}) {
|
|
317
|
+
const bad = refused([{ label: 'workflowId', kind: 'runId', value: asArg(workflowId), optional: true }]);
|
|
318
|
+
if (bad) return bad;
|
|
283
319
|
const args = ['agent-eval', '--output', 'json'];
|
|
284
320
|
if (category) args.push('--category', String(category));
|
|
285
321
|
if (target) args.push('--target', String(target));
|
|
@@ -296,6 +332,11 @@ async function agentEval({ category, target, judge = false, provider, report = f
|
|
|
296
332
|
// hone_show_step — the LLM output of an orchestrator step (readOnly).
|
|
297
333
|
async function showStep({ runId, stepKey, cwd } = {}) {
|
|
298
334
|
if (!runId || !stepKey) return { isError: true, detail: 'runId and stepKey are required' };
|
|
335
|
+
const bad = refused([
|
|
336
|
+
{ label: 'runId', kind: 'runId', value: String(runId) },
|
|
337
|
+
{ label: 'stepKey', kind: 'stepKey', value: String(stepKey) },
|
|
338
|
+
]);
|
|
339
|
+
if (bad) return bad;
|
|
299
340
|
const r = await runHone(['show', 'step', String(runId), String(stepKey)], { cwd });
|
|
300
341
|
if (r.error) return { isError: true, detail: r.error };
|
|
301
342
|
return { isError: !r.ok, exitCode: r.exitCode, output: r.stdout.slice(0, 8000), detail: r.ok ? 'ok' : (r.stderr || 'show step failed').slice(0, 500) };
|
package/lib/run-context.js
CHANGED
|
@@ -38,7 +38,6 @@ const {
|
|
|
38
38
|
extractType, extractSecurityImplications, extractCrossRepoDependency,
|
|
39
39
|
} = require('./story-classifier-extract');
|
|
40
40
|
const { readArchitectConfigFromText } = require('./architect-config');
|
|
41
|
-
const { remoteHostFromUrl } = require('./story-provenance');
|
|
42
41
|
const path = require('path');
|
|
43
42
|
|
|
44
43
|
/**
|
|
@@ -121,7 +120,8 @@ async function issueStorySource(io, issueNumber, issue) {
|
|
|
121
120
|
author: (api.user && api.user.login) || null,
|
|
122
121
|
association: api.author_association,
|
|
123
122
|
html_url: api.html_url,
|
|
124
|
-
|
|
123
|
+
// HC-GF-283: the host BOTH gh calls were told about — an SSH alias already resolved to it.
|
|
124
|
+
remoteHost: (got && got.host) || null,
|
|
125
125
|
};
|
|
126
126
|
return { source, notes: [info(` → story provenance: ${source.author || 'unknown'} (author_association: ${source.association || 'unknown'})`)] };
|
|
127
127
|
} catch (e) {
|
|
@@ -311,7 +311,8 @@ function resolveRepoContext(io, opts = {}) {
|
|
|
311
311
|
* @param {object} io
|
|
312
312
|
* @param {(n: string) => Promise<{title:string, body?:string, url:string}>|{title:string, body?:string, url:string}} io.fetchIssue
|
|
313
313
|
* rejects or throws on failure
|
|
314
|
-
* @param {(n: string) => Promise<{owner:string, repo:string, remoteUrl:string, issue:object}>} io.fetchIssueProvenance
|
|
314
|
+
* @param {(n: string) => Promise<{owner:string, repo:string, remoteUrl:string, host:string, issue:object}>} io.fetchIssueProvenance
|
|
315
|
+
* `host` is the GitHub host the call was made against (HC-GF-283), which the provenance check compares with html_url's
|
|
315
316
|
* HC-GF-270: `gh api repos/<owner>/<repo>/issues/<n>`; rejects with gh's stderr on the error, and
|
|
316
317
|
* `where: {owner, repo}` once the remote was parsed
|
|
317
318
|
* @param {(id: string) => Promise<object>} io.fetchStoryContext GET /stories/:id/context; throws
|
|
@@ -437,6 +438,10 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
437
438
|
// JSON-lines repo).
|
|
438
439
|
const bundle = { files: 0, chars: 0 };
|
|
439
440
|
const includePaths = opts.includePaths || [];
|
|
441
|
+
// HC-GF-351: what the operator named, recorded on the run so follow-batch can carry it into a re-serve. Never
|
|
442
|
+
// inferred: the story's own paths and the defaults are re-derived whenever a story is resolved again.
|
|
443
|
+
const operatorPaths = parseIncludePaths(includePaths);
|
|
444
|
+
if (operatorPaths.length) config.include_paths = operatorPaths;
|
|
440
445
|
const defaultIncludePaths = repoCtx.defaultIncludePaths || [];
|
|
441
446
|
if (rawText || parseIncludePaths(includePaths).length > 0 || defaultIncludePaths.length > 0) {
|
|
442
447
|
try {
|
|
@@ -575,6 +580,23 @@ async function resolveStoryContext(io, repoCtx, storyId, opts = {}) {
|
|
|
575
580
|
pre.push(warn(' Fix: hone run-story <id> --include-paths path/one.js,path/two.js'));
|
|
576
581
|
}
|
|
577
582
|
|
|
583
|
+
// ── Call sites (HC-GF-217) ────────────────────────────────────────────────────────────────
|
|
584
|
+
// The planner is told to enumerate every call site of a shared function it changes, and a server-executed
|
|
585
|
+
// agent has no checkout to search (cwd is a temp dir, deliberately). This CLI has the checkout: it searches for
|
|
586
|
+
// the compound identifiers the story puts in backticks and sends the result for the server to render. It runs
|
|
587
|
+
// whatever the classifier axes turn out to be, because the shared-chokepoint axis is harvested from step_0's
|
|
588
|
+
// output, after this bundle has been sent. Fail-soft: a search that cannot run costs the planner its
|
|
589
|
+
// enumeration (it then says so and pauses), never the queueing of the story.
|
|
590
|
+
if (rawText && typeof io.callSites === 'function') {
|
|
591
|
+
try {
|
|
592
|
+
const found = await io.callSites(rawText);
|
|
593
|
+
if (found && found.callSites) config.call_sites = found.callSites;
|
|
594
|
+
for (const n of (found && found.notes) || []) pre.push(n);
|
|
595
|
+
} catch (e) {
|
|
596
|
+
pre.push(warn(` ⚠ call-site search failed (non-fatal): ${firstLine(e)} — the planner will have no call-site enumeration for this story`));
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
|
|
578
600
|
// ── Classifier axes (HC-COMM-010) ─────────────────────────────────────────────────────────
|
|
579
601
|
// Explicit --axes wins. Otherwise the DETERMINISTIC axes from the extractors; judgment axes
|
|
580
602
|
// come from the groomer at step_0 and are harvested server-side. Fail-open.
|
package/lib/run-target.js
CHANGED
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
* `error` and decides the exit code.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
const path = require('node:path');
|
|
22
|
+
|
|
21
23
|
const RUN_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
22
24
|
|
|
23
25
|
// GET /orchestrate clamps `limit` to 1..100, lists newest first, and is scoped to the caller's org.
|
|
@@ -112,6 +114,19 @@ const isDeriveJobId = (s) => typeof s === 'string' && /^drv_[0-9a-f]{12}$/.test(
|
|
|
112
114
|
// safe; these three it cannot, because `new URL()` resolves `.` and `..` as directory steps.
|
|
113
115
|
const isPathName = (s) => typeof s === 'string' && s !== '' && s !== '.' && s !== '..';
|
|
114
116
|
|
|
117
|
+
// HC-GF-270-followup-3: a story id names a directory (`.github/pipeline/<id>/`, `.github/metrics/<id>.yml`).
|
|
118
|
+
// The branch extractor (pipeline-status.js STORY_ID_PATTERN) emits only `[A-Za-z0-9_-]` but has no length cap,
|
|
119
|
+
// so an id read from a branch is checked too (step-5b, metrics collect, verify-pr); the fallbacks are
|
|
120
|
+
// `unknown-story` and `no-story`. No `.`, `/` or `\`, so `../` cannot leave the directory; 80 characters, the cap
|
|
121
|
+
// server/src/routes/stories.js applies (MAX_STORY_ID_LEN). Its STORY_ID_SHAPE is stricter (no `_`): it decides
|
|
122
|
+
// what the server stores, this decides what may name a local directory.
|
|
123
|
+
const isStoryId = (s) => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9_-]{0,79}$/.test(s);
|
|
124
|
+
// A ref git or gh may receive as ONE argument. `;`, `$(` and quotes are harmless there (and legal in ref names);
|
|
125
|
+
// a leading `-` is not: `git diff --name-only '--output=x...HEAD'` writes a file. `--end-of-options` would
|
|
126
|
+
// also stop it, but only on git 2.24 and later — on an older git every diff would fail and an audit would pass
|
|
127
|
+
// on nothing, so the value is refused instead.
|
|
128
|
+
const isGitRev = (s) => typeof s === 'string' && s !== '' && !s.startsWith('-') && !s.includes('\0');
|
|
129
|
+
|
|
115
130
|
const KINDS = {
|
|
116
131
|
runId: { ok: isRunId, why: 'must be a run ID (a UUID)', hint: ' — run IDs: hone workflows' },
|
|
117
132
|
batchId: { ok: isBatchId, why: 'must be a batch ID (a UUID)', hint: ' — batch IDs: hone queue-stories --list' },
|
|
@@ -119,11 +134,27 @@ const KINDS = {
|
|
|
119
134
|
attempt: { ok: isAttempt, why: 'must be a whole number from 1, like 2', hint: '' },
|
|
120
135
|
deriveJobId: { ok: isDeriveJobId, why: 'must be a derive job ID, like drv_0123456789ab', hint: ' — the ID "hone derive --refresh" printed' },
|
|
121
136
|
pathName: { ok: isPathName, why: 'must be a name, not empty, "." or ".."', hint: '' },
|
|
137
|
+
storyId: { ok: isStoryId, why: 'must be a story ID, like HC-GF-270 (letters, digits, "-" and "_")', hint: '' },
|
|
138
|
+
gitRev: { ok: isGitRev, why: 'must be a git branch or revision, not empty and not starting with "-"', hint: '' },
|
|
139
|
+
count: { ok: isAttempt, why: 'must be a whole number from 1, like 15', hint: '' },
|
|
122
140
|
};
|
|
123
141
|
|
|
142
|
+
/**
|
|
143
|
+
* HC-GF-270-followup-3: the directory a story id names under `baseDir`, refusing any id that is not one — or that
|
|
144
|
+
* resolves anywhere but one level under `baseDir`, checked independently of the pattern. Every verb that writes
|
|
145
|
+
* here refused a bad id first (`runArgsError`, kind `storyId`), so a throw here is a bug, not an input error.
|
|
146
|
+
*/
|
|
147
|
+
function storyDir(baseDir, storyId) {
|
|
148
|
+
const base = path.resolve(String(baseDir));
|
|
149
|
+
if (!isStoryId(storyId)) throw new TypeError(`storyDir: ${JSON.stringify(storyId)} is not a story ID`);
|
|
150
|
+
const dir = path.resolve(base, storyId);
|
|
151
|
+
if (path.dirname(dir) !== base) throw new TypeError(`storyDir: ${JSON.stringify(storyId)} leaves ${base}`);
|
|
152
|
+
return dir;
|
|
153
|
+
}
|
|
154
|
+
|
|
124
155
|
/**
|
|
125
156
|
* The first failed check's message, or null when every check passes.
|
|
126
|
-
* @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName', value: any, optional?: boolean }>} checks
|
|
157
|
+
* @param {Array<{ label: string, kind: 'runId'|'batchId'|'stepKey'|'attempt'|'deriveJobId'|'pathName'|'storyId'|'gitRev'|'count', value: any, optional?: boolean }>} checks
|
|
127
158
|
* `label` is the flag or positional as the operator typed it. An `optional` check passes when the value
|
|
128
159
|
* is undefined (a flag that was not given); an empty string is always checked.
|
|
129
160
|
*/
|
|
@@ -179,5 +210,6 @@ function orchestratePath(segments, query) {
|
|
|
179
210
|
|
|
180
211
|
module.exports = {
|
|
181
212
|
resolveRunTarget, isRunId, LOOKUP_LIMIT,
|
|
182
|
-
isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName,
|
|
213
|
+
isStepKey, isAttempt, isBatchId, isDeriveJobId, isPathName, isStoryId, isGitRev, storyDir,
|
|
214
|
+
runArgsError, apiPath, orchestratePath,
|
|
183
215
|
};
|
package/lib/story-line.js
CHANGED
|
@@ -2,9 +2,15 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* story-line.js — one line of a `hone queue-stories --file` list.
|
|
4
4
|
*
|
|
5
|
-
* `STORY-ID
|
|
6
|
-
*
|
|
7
|
-
* `depends:` yields no `dependsOn` at all — the server
|
|
5
|
+
* `STORY-ID`, optionally followed by `depends:DEP1,DEP2` and/or `paths:a.js,b.js`, in either order. Both
|
|
6
|
+
* tokens are case-sensitive, must come after the storyId, and may each appear once; their lists are
|
|
7
|
+
* comma-separated and whitespace tolerant. A line with no `depends:` yields no `dependsOn` at all — the server
|
|
8
|
+
* validates absence differently from empty — and a line with no `paths:` yields no `paths`.
|
|
9
|
+
*
|
|
10
|
+
* `paths:` (HC-GF-351) is `run-story --include-paths` for one story of a batch: the files the bundler must put
|
|
11
|
+
* in that story's context. A batch cannot carry the flag, and `run-story` runs carry no batch id, so
|
|
12
|
+
* `follow-batch` cannot follow them. The entries are checked where `--include-paths` entries are, by the
|
|
13
|
+
* bundler (cli/lib/run-context.js), not here: this module only splits the line.
|
|
8
14
|
*
|
|
9
15
|
* Extracted by HC-GF-140 stage 2. The regex was pinned by its SOURCE TEXT
|
|
10
16
|
* (`assert.match(cliSrc, /\^\(\\S\+\)\\s\+depends:\(\\S\+\)\\s\*\$/)`), which is the worst shape a
|
|
@@ -19,22 +25,52 @@ function isStoryLine(line) {
|
|
|
19
25
|
return Boolean(t) && !t.startsWith('#');
|
|
20
26
|
}
|
|
21
27
|
|
|
28
|
+
const csv = (s) => s.split(',').map((x) => x.trim()).filter(Boolean);
|
|
29
|
+
const unique = (list) => list.filter((x, i) => list.indexOf(x) === i);
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* @param {string} line
|
|
24
|
-
* @returns {{storyId: string, dependsOn?: string[]}|{error: string}}
|
|
33
|
+
* @returns {{storyId: string, dependsOn?: string[], paths?: string[]}|{error: string}}
|
|
25
34
|
* `error` for a line the caller must reject — a storyId followed by anything unrecognised.
|
|
26
|
-
* Silently treating `STORY-A something` as `STORY-A` loses the operator's intent.
|
|
35
|
+
* Silently treating `STORY-A something` as `STORY-A` loses the operator's intent. So does a
|
|
36
|
+
* `paths:` that names no path: the operator meant to bundle something.
|
|
27
37
|
*/
|
|
28
38
|
function parseStoryLine(line) {
|
|
29
39
|
const t = String(line).trim();
|
|
30
|
-
const
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
const bad = { error: `Malformed line in --file: "${t}"` };
|
|
41
|
+
const [storyId, ...tokens] = t.split(/\s+/);
|
|
42
|
+
const out = { storyId };
|
|
43
|
+
for (const tok of tokens) {
|
|
44
|
+
if (tok.startsWith('depends:') && tok.length > 'depends:'.length && !('dependsOn' in out)) {
|
|
45
|
+
out.dependsOn = csv(tok.slice('depends:'.length));
|
|
46
|
+
} else if (tok.startsWith('paths:') && !('paths' in out)) {
|
|
47
|
+
const paths = unique(csv(tok.slice('paths:'.length)));
|
|
48
|
+
if (paths.length === 0) return bad;
|
|
49
|
+
out.paths = paths;
|
|
50
|
+
} else {
|
|
51
|
+
return bad;
|
|
52
|
+
}
|
|
33
53
|
}
|
|
34
|
-
|
|
35
|
-
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The line `parseStoryLine` reads back as the same story — for a caller that writes a stories file
|
|
59
|
+
* (follow-batch's re-serve). Throws on a value the line cannot carry, rather than writing a line that parses as
|
|
60
|
+
* something else: whitespace splits a token, and a comma splits a list.
|
|
61
|
+
* @param {{storyId: string, dependsOn?: string[], paths?: string[]}} story
|
|
62
|
+
*/
|
|
63
|
+
function formatStoryLine({ storyId, dependsOn, paths } = {}) {
|
|
64
|
+
const carries = (v) => typeof v === 'string' && v !== '' && !/[\s,]/.test(v);
|
|
65
|
+
if (!carries(storyId)) throw new TypeError(`formatStoryLine: ${JSON.stringify(storyId)} is not a story id a line can carry`);
|
|
66
|
+
const parts = [storyId];
|
|
67
|
+
for (const [token, list] of [['depends', dependsOn], ['paths', paths]]) {
|
|
68
|
+
if (!Array.isArray(list) || list.length === 0) continue;
|
|
69
|
+
const bad = list.find((v) => !carries(v));
|
|
70
|
+
if (bad !== undefined) throw new TypeError(`formatStoryLine: ${JSON.stringify(bad)} cannot be written in ${token}:`);
|
|
71
|
+
parts.push(`${token}:${list.join(',')}`);
|
|
36
72
|
}
|
|
37
|
-
return
|
|
73
|
+
return parts.join(' ');
|
|
38
74
|
}
|
|
39
75
|
|
|
40
|
-
module.exports = { parseStoryLine, isStoryLine };
|
|
76
|
+
module.exports = { parseStoryLine, isStoryLine, formatStoryLine };
|
package/lib/story-provenance.js
CHANGED
|
@@ -53,11 +53,12 @@ function isIssueShapedId(storyId) {
|
|
|
53
53
|
/**
|
|
54
54
|
* Is this a HOSTNAME we may compare against `html_url`'s host?
|
|
55
55
|
*
|
|
56
|
-
*
|
|
57
|
-
* remote's host
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
56
|
+
* HC-GF-270's binding decision 2 (operator, step_1 gate of run fa6e00c4) compared hosts ONLY when
|
|
57
|
+
* the remote's host was a dotted domain, because an SSH alias such as `github-work` is not a
|
|
58
|
+
* hostname and comparing it would refuse every collaborator's issue for every operator who uses
|
|
59
|
+
* one. HC-GF-283 removes the reason for the exception: run-story now resolves an alias with
|
|
60
|
+
* `ssh -G` (cli/lib/git-remote.js) and records the REAL host, so the comparison runs always, and a
|
|
61
|
+
* recorded host that is still not a hostname is a host nobody established — untrusted.
|
|
61
62
|
*/
|
|
62
63
|
function isDottedDomain(host) {
|
|
63
64
|
const h = String(host == null ? '' : host).trim();
|
|
@@ -237,13 +238,18 @@ function assessStorySource(source, storyId) {
|
|
|
237
238
|
+ 'or the request redirected to a different repository');
|
|
238
239
|
}
|
|
239
240
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
241
|
+
// HC-GF-283: ALWAYS. An unresolved alias left gh on its default host, where the same owner/repo
|
|
242
|
+
// name can belong to someone else — the case binding decision 2's exception let through.
|
|
243
|
+
const rh = String(source.remoteHost == null ? '' : source.remoteHost).trim().toLowerCase();
|
|
244
|
+
if (!isDottedDomain(rh)) {
|
|
245
|
+
return no('issue', rh
|
|
246
|
+
? `the git remote's host was recorded as '${rh}', which is not a hostname — an SSH alias that was never `
|
|
247
|
+
+ `resolved (a run started before HC-GF-283), so which GitHub host issue ${refLabel(source)} was read from cannot be checked`
|
|
248
|
+
: `no git remote host was recorded for issue ${refLabel(source)}, so which GitHub host it was read from cannot be checked`);
|
|
249
|
+
}
|
|
250
|
+
if (rh !== url.hostname.toLowerCase()) {
|
|
251
|
+
return no('issue', `the git remote's host ${rh} is not ${url.hostname.toLowerCase()} — the issue was read `
|
|
252
|
+
+ 'from a different GitHub host than this repository lives on, where someone else can own the same name');
|
|
247
253
|
}
|
|
248
254
|
|
|
249
255
|
return yes('issue', `issue ${refLabel(source)} was authored by ${source.author || 'an unknown user'} `
|
|
@@ -258,6 +264,7 @@ function assessStorySource(source, storyId) {
|
|
|
258
264
|
module.exports = {
|
|
259
265
|
assessStorySource,
|
|
260
266
|
remoteHostFromUrl,
|
|
267
|
+
rawRemoteHost,
|
|
261
268
|
isIssueShapedId,
|
|
262
269
|
isDottedDomain,
|
|
263
270
|
refLabel,
|