@deeeed/metamask-harness 0.51.5 → 0.51.7
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/CHANGELOG.md +33 -1
- package/README.md +39 -0
- package/adapters/extension/ensure-browser.sh +33 -7
- package/adapters/extension/launch-browser.cjs +157 -62
- package/adapters/extension/lib/macos-focus.cjs +216 -12
- package/adapters/extension/lib/validation-process-ownership.cjs +58 -2
- package/adapters/extension/live.sh +0 -2
- package/adapters/extension/stop-viewers.sh +1 -2
- package/dist/adapters/extension/validation-process-ownership.js +3 -2
- package/dist/adapters/slot-ports.js +65 -13
- package/dist/cli-commands.js +5 -1
- package/dist/cli.js +8 -0
- package/dist/command-contract.js +34 -1
- package/dist/commands/config.js +100 -0
- package/dist/commands/domain.js +56 -0
- package/dist/commands/help.js +2 -0
- package/dist/commands/pr-body.js +31 -0
- package/dist/commands/review.js +135 -0
- package/dist/mm-harness-cli.js +93 -3
- package/dist/pr-body/render.js +136 -0
- package/dist/review/knowledge.js +513 -0
- package/package.json +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import {
|
|
2
|
+
changedFiles,
|
|
3
|
+
classifyDomain,
|
|
4
|
+
configPath,
|
|
5
|
+
discoverLibraries,
|
|
6
|
+
readConfig
|
|
7
|
+
} from "../review/knowledge.js";
|
|
8
|
+
import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
|
|
9
|
+
import { adapterFromOptions } from "./review.js";
|
|
10
|
+
async function handleDomain(argv) {
|
|
11
|
+
const parsed = parseArgs(argv, "domain");
|
|
12
|
+
const target = targetPath(parsed.options);
|
|
13
|
+
const json = optionFlag(parsed.options, "json");
|
|
14
|
+
const declared = optionString(parsed.options, "domain") ?? process.env.MM_HARNESS_DOMAIN;
|
|
15
|
+
const detected = adapterFromOptions(parsed.options, target);
|
|
16
|
+
const adapter = detected === "unscoped" ? void 0 : detected;
|
|
17
|
+
if (declared) {
|
|
18
|
+
emit(json, { domain: declared, source: "declared", adapter: adapter ?? null, target });
|
|
19
|
+
return 0;
|
|
20
|
+
}
|
|
21
|
+
if (!adapter) {
|
|
22
|
+
throw usageError("No MetaMask checkout detected: pass --adapter <mobile|extension|core> or --domain <name>.");
|
|
23
|
+
}
|
|
24
|
+
const config = readConfig(configPath());
|
|
25
|
+
const libraries = discoverLibraries({ target, config });
|
|
26
|
+
const { base, files } = changedFiles(target, optionString(parsed.options, "base"));
|
|
27
|
+
const matches = classifyDomain(files, adapter, libraries);
|
|
28
|
+
const best = matches[0];
|
|
29
|
+
emit(json, {
|
|
30
|
+
domain: best?.domain ?? null,
|
|
31
|
+
source: best ? "owned-paths" : "none",
|
|
32
|
+
adapter,
|
|
33
|
+
target,
|
|
34
|
+
base,
|
|
35
|
+
changedFiles: files.length,
|
|
36
|
+
libraries: libraries.map((library) => ({ name: library.name, root: library.root, source: library.source, revision: library.revision ?? null })),
|
|
37
|
+
matches: matches.map((match) => ({ domain: match.domain, library: match.library.root, matched: match.matched }))
|
|
38
|
+
});
|
|
39
|
+
return 0;
|
|
40
|
+
}
|
|
41
|
+
function emit(json, payload) {
|
|
42
|
+
if (json) {
|
|
43
|
+
process.stdout.write(`${JSON.stringify({ command: "domain", ...payload }, null, 2)}
|
|
44
|
+
`);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
process.stdout.write(`${payload.domain ?? "none"}
|
|
48
|
+
`);
|
|
49
|
+
const best = payload.matches?.[0];
|
|
50
|
+
const detail = payload.source === "declared" ? "declared via --domain or MM_HARNESS_DOMAIN" : best ? `owned-paths match in ${best.library} (${best.matched.length} of ${payload.changedFiles} changed files)` : `no library owns the ${payload.changedFiles} changed files (base ${payload.base}; libraries scanned: ${payload.libraries?.length ?? 0})`;
|
|
51
|
+
process.stderr.write(`domain: ${detail}
|
|
52
|
+
`);
|
|
53
|
+
}
|
|
54
|
+
export {
|
|
55
|
+
handleDomain
|
|
56
|
+
};
|
package/dist/commands/help.js
CHANGED
|
@@ -3,6 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { color } from "../cli-color.js";
|
|
4
4
|
import { detectAdapter } from "../harness.js";
|
|
5
5
|
import { optionString, parseArgs, targetPath } from "./parse-args.js";
|
|
6
|
+
import { handleHelpReview } from "./review.js";
|
|
6
7
|
const UNSCOPED_NEXT = "cd into a MetaMask checkout or pass --adapter <mobile|extension|core>";
|
|
7
8
|
function validHelpLine(value) {
|
|
8
9
|
return typeof value === "string" || Boolean(value && typeof value.text === "string" && Array.isArray(value.adapters) && value.adapters.length > 0 && value.adapters.every((adapter) => ["mobile", "extension", "core"].includes(adapter)));
|
|
@@ -70,6 +71,7 @@ function renderRecipeHelp(topic, harnessVersion, adapter) {
|
|
|
70
71
|
}
|
|
71
72
|
function handleHelp(argv, context) {
|
|
72
73
|
const parsed = parseArgs(argv, "help");
|
|
74
|
+
if (parsed.positional[0] === "review") return handleHelpReview(argv, context.harnessVersion);
|
|
73
75
|
const target = targetPath(parsed.options);
|
|
74
76
|
const adapter = optionString(parsed.options, "adapter") ?? detectAdapter(target) ?? "unscoped";
|
|
75
77
|
const topic = loadRecipeHelp(context.packageRoot);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { BODY_FILE, renderPrBody } from "../pr-body/render.js";
|
|
4
|
+
import { optionFlag, optionString, parseArgs, usageError } from "./parse-args.js";
|
|
5
|
+
const USAGE = "Usage: mm-harness pr-body render <task-dir> [--command <text>] [--out <file>] [--json]";
|
|
6
|
+
async function handlePrBody(argv) {
|
|
7
|
+
const parsed = parseArgs(argv, "pr-body");
|
|
8
|
+
const [action, taskArg] = parsed.positional;
|
|
9
|
+
if (action !== "render" || !taskArg) throw usageError(USAGE);
|
|
10
|
+
const taskDir = path.resolve(taskArg);
|
|
11
|
+
if (!fs.existsSync(path.join(taskDir, "artifacts"))) {
|
|
12
|
+
throw usageError(`${taskDir} has no artifacts/ directory; pass the task directory mm-harness task init wrote.`);
|
|
13
|
+
}
|
|
14
|
+
const command = optionString(parsed.options, "command");
|
|
15
|
+
const result = renderPrBody({ taskDir, ...command ? { command } : {} });
|
|
16
|
+
const out = path.resolve(optionString(parsed.options, "out") ?? path.join(taskDir, "artifacts", BODY_FILE));
|
|
17
|
+
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
18
|
+
fs.writeFileSync(out, result.body);
|
|
19
|
+
if (optionFlag(parsed.options, "json")) {
|
|
20
|
+
process.stdout.write(`${JSON.stringify({ command: "pr-body", subcommand: "render", status: "ok", taskDir, out, ...result, body: void 0 }, null, 2)}
|
|
21
|
+
`);
|
|
22
|
+
} else {
|
|
23
|
+
process.stdout.write(`pr body: ${out}
|
|
24
|
+
${result.sections.join("\n ")}
|
|
25
|
+
`);
|
|
26
|
+
}
|
|
27
|
+
return 0;
|
|
28
|
+
}
|
|
29
|
+
export {
|
|
30
|
+
handlePrBody
|
|
31
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { detectAdapter } from "../harness.js";
|
|
4
|
+
import { runnerDir } from "../paths.js";
|
|
5
|
+
import {
|
|
6
|
+
configPath,
|
|
7
|
+
loadDomainKnowledge,
|
|
8
|
+
parityAdapter,
|
|
9
|
+
readConfig,
|
|
10
|
+
referenceHint,
|
|
11
|
+
renderReviewChecklist,
|
|
12
|
+
renderReviewHelp,
|
|
13
|
+
resolveLibrary,
|
|
14
|
+
resolveReference,
|
|
15
|
+
defaultBaseRef
|
|
16
|
+
} from "../review/knowledge.js";
|
|
17
|
+
import { optionFlag, optionString, parseArgs, targetPath, usageError } from "./parse-args.js";
|
|
18
|
+
const EXIT_OK = 0;
|
|
19
|
+
let cachedHarnessVersion;
|
|
20
|
+
function harnessVersion() {
|
|
21
|
+
if (cachedHarnessVersion === void 0) {
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(runnerDir, "package.json"), "utf8"));
|
|
23
|
+
cachedHarnessVersion = pkg.version ?? "unknown";
|
|
24
|
+
}
|
|
25
|
+
return cachedHarnessVersion;
|
|
26
|
+
}
|
|
27
|
+
function adapterFromOptions(options, target) {
|
|
28
|
+
const raw = optionString(options, "adapter") ?? optionString(options, "platform");
|
|
29
|
+
if (raw !== void 0) {
|
|
30
|
+
if (raw !== "mobile" && raw !== "extension" && raw !== "core") throw usageError(`--adapter must be mobile, extension, or core (got ${raw}).`);
|
|
31
|
+
return raw;
|
|
32
|
+
}
|
|
33
|
+
return detectAdapter(target) ?? "unscoped";
|
|
34
|
+
}
|
|
35
|
+
function resolveReviewContext(options, { fetch = true } = {}) {
|
|
36
|
+
const target = targetPath(options);
|
|
37
|
+
const adapter = adapterFromOptions(options, target);
|
|
38
|
+
const domain = optionString(options, "domain") ?? process.env.MM_HARNESS_DOMAIN;
|
|
39
|
+
const config = readConfig(configPath());
|
|
40
|
+
const context = { target, adapter, ...domain ? { domain } : {} };
|
|
41
|
+
if (domain) {
|
|
42
|
+
const library = resolveLibrary(domain, { target, config, fetch });
|
|
43
|
+
if (library) context.knowledge = loadDomainKnowledge(library, adapter === "unscoped" ? void 0 : adapter);
|
|
44
|
+
else if (fetch) throw usageError(`No library found for domain "${domain}". Set RECIPE_LIBRARY_PATH="${domain}=<path>" or run: mm-harness config set libraries.${domain} <path>`);
|
|
45
|
+
}
|
|
46
|
+
const parity = parityAdapter(adapter);
|
|
47
|
+
if (parity) {
|
|
48
|
+
const reference = resolveReference(parity, { target, config });
|
|
49
|
+
if (reference) context.reference = reference;
|
|
50
|
+
else context.referenceMissing = `no ${parity} checkout found (${referenceHint(parity)})`;
|
|
51
|
+
}
|
|
52
|
+
return context;
|
|
53
|
+
}
|
|
54
|
+
function handleHelpReview(argv, harnessVersion2) {
|
|
55
|
+
const parsed = parseArgs(argv, "help");
|
|
56
|
+
const context = resolveReviewContext(parsed.options, { fetch: false });
|
|
57
|
+
const input = {
|
|
58
|
+
adapter: context.adapter,
|
|
59
|
+
harnessVersion: harnessVersion2,
|
|
60
|
+
...context.domain ? { domain: context.domain } : {},
|
|
61
|
+
...context.knowledge ? { knowledge: context.knowledge } : {},
|
|
62
|
+
...context.reference ? { reference: context.reference } : {},
|
|
63
|
+
...context.referenceMissing ? { referenceMissing: context.referenceMissing } : {}
|
|
64
|
+
};
|
|
65
|
+
if (optionFlag(parsed.options, "json")) {
|
|
66
|
+
process.stdout.write(`${JSON.stringify({ command: "help", topic: "review", ...input }, null, 2)}
|
|
67
|
+
`);
|
|
68
|
+
} else {
|
|
69
|
+
process.stdout.write(renderReviewHelp(input));
|
|
70
|
+
}
|
|
71
|
+
return EXIT_OK;
|
|
72
|
+
}
|
|
73
|
+
async function handleReview(argv, version = harnessVersion()) {
|
|
74
|
+
const parsed = parseArgs(argv, "review");
|
|
75
|
+
const subcommand = parsed.positional[0];
|
|
76
|
+
if (subcommand !== "checklist") {
|
|
77
|
+
throw usageError("Usage: mm-harness review checklist [--domain <name>] [--since <sha>] [--base <ref>] [--out <file>] [--json]");
|
|
78
|
+
}
|
|
79
|
+
const context = resolveReviewContext(parsed.options);
|
|
80
|
+
const since = optionString(parsed.options, "since");
|
|
81
|
+
const base = optionString(parsed.options, "base") ?? defaultBaseRef(context.target);
|
|
82
|
+
const checklist = renderReviewChecklist({
|
|
83
|
+
adapter: context.adapter,
|
|
84
|
+
target: context.target,
|
|
85
|
+
base,
|
|
86
|
+
harnessVersion: version,
|
|
87
|
+
...since ? { since } : {},
|
|
88
|
+
...context.domain ? { domain: context.domain } : {},
|
|
89
|
+
...context.knowledge ? { knowledge: context.knowledge } : {},
|
|
90
|
+
...context.reference ? { reference: context.reference } : {},
|
|
91
|
+
...context.referenceMissing ? { referenceMissing: context.referenceMissing } : {}
|
|
92
|
+
});
|
|
93
|
+
const out = optionString(parsed.options, "out");
|
|
94
|
+
if (out) {
|
|
95
|
+
const file = path.resolve(out);
|
|
96
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
97
|
+
fs.writeFileSync(file, checklist);
|
|
98
|
+
}
|
|
99
|
+
if (optionFlag(parsed.options, "json")) {
|
|
100
|
+
process.stdout.write(
|
|
101
|
+
`${JSON.stringify(
|
|
102
|
+
{
|
|
103
|
+
command: "review",
|
|
104
|
+
subcommand: "checklist",
|
|
105
|
+
target: context.target,
|
|
106
|
+
adapter: context.adapter,
|
|
107
|
+
domain: context.domain ?? null,
|
|
108
|
+
library: context.knowledge?.library ?? null,
|
|
109
|
+
reference: context.reference ?? null,
|
|
110
|
+
referenceMissing: context.referenceMissing ?? null,
|
|
111
|
+
base,
|
|
112
|
+
since: since ?? null,
|
|
113
|
+
out: out ? path.resolve(out) : null,
|
|
114
|
+
checklist
|
|
115
|
+
},
|
|
116
|
+
null,
|
|
117
|
+
2
|
|
118
|
+
)}
|
|
119
|
+
`
|
|
120
|
+
);
|
|
121
|
+
} else if (out) {
|
|
122
|
+
process.stdout.write(`review checklist: ${path.resolve(out)}
|
|
123
|
+
`);
|
|
124
|
+
} else {
|
|
125
|
+
process.stdout.write(checklist);
|
|
126
|
+
}
|
|
127
|
+
return EXIT_OK;
|
|
128
|
+
}
|
|
129
|
+
export {
|
|
130
|
+
adapterFromOptions,
|
|
131
|
+
handleHelpReview,
|
|
132
|
+
handleReview,
|
|
133
|
+
harnessVersion,
|
|
134
|
+
resolveReviewContext
|
|
135
|
+
};
|
package/dist/mm-harness-cli.js
CHANGED
|
@@ -35,9 +35,99 @@ const REAL = [
|
|
|
35
35
|
--target <path> Checkout path (default: cwd)
|
|
36
36
|
--json Load the same guide as structured agent context
|
|
37
37
|
|
|
38
|
+
help review [--domain <name>] prints the review guide: the base review every PR
|
|
39
|
+
gets, plus the team library's anti-pattern families, parity rule, and shared
|
|
40
|
+
package notes when --domain names a library (see mm-harness review --help).
|
|
41
|
+
|
|
38
42
|
Example:
|
|
39
43
|
mm-harness help
|
|
40
|
-
mm-harness help --json
|
|
44
|
+
mm-harness help --json
|
|
45
|
+
mm-harness help review --domain perps`
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: "review",
|
|
49
|
+
summary: "Materialize a review checklist: base review + team library patterns + parity + verdict.",
|
|
50
|
+
example: "mm-harness review checklist --domain perps",
|
|
51
|
+
helpText: `mm-harness review checklist [flags]
|
|
52
|
+
|
|
53
|
+
Writes a phase-structured checklist (Setup, Base review, Domain patterns,
|
|
54
|
+
Parity, Verdict) that mm-harness checklist mark can track. Domain patterns are
|
|
55
|
+
one line per "##" section of the library's review/antipatterns.md; Parity names
|
|
56
|
+
the other platform's checkout when one resolves and says "not checked" otherwise.
|
|
57
|
+
|
|
58
|
+
Library resolution for --domain <name>: RECIPE_LIBRARY_PATH entry \u2192 engineer
|
|
59
|
+
config (mm-harness config) \u2192 sibling directory beside the checkout \u2192 shallow
|
|
60
|
+
fetch into <checkout>/.skills-cache/<name> (known libraries only). Reference
|
|
61
|
+
checkout resolution: MM_HARNESS_REF_<ADAPTER> \u2192 engineer config \u2192 sibling
|
|
62
|
+
checkout of that adapter.
|
|
63
|
+
|
|
64
|
+
--domain <name> Team library to compose (default: MM_HARNESS_DOMAIN; none = base review only)
|
|
65
|
+
--since <sha> Incremental re-review: scope the checklist to <sha>..HEAD
|
|
66
|
+
--base <ref> Base ref for the diff range (default: origin/main when present)
|
|
67
|
+
--out <file> Also write the checklist to <file>
|
|
68
|
+
--adapter, --target, --json
|
|
69
|
+
|
|
70
|
+
Example:
|
|
71
|
+
mm-harness review checklist --domain perps
|
|
72
|
+
mm-harness review checklist --domain perps --since 4dd9be9 --out temp/tasks/review/CHECKLIST.md
|
|
73
|
+
mm-harness review checklist --json`
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
name: "domain",
|
|
77
|
+
summary: "Which team library owns the change: declared value first, else owned-paths.json against the changed files.",
|
|
78
|
+
example: "mm-harness domain",
|
|
79
|
+
helpText: `mm-harness domain [flags]
|
|
80
|
+
|
|
81
|
+
Prints the domain name (or "none"). A declared --domain / MM_HARNESS_DOMAIN wins;
|
|
82
|
+
otherwise the changed files (base...HEAD, working tree, untracked) are matched
|
|
83
|
+
against owned-paths.json in every reachable library (RECIPE_LIBRARY_PATH,
|
|
84
|
+
mm-harness config, sibling directories, .skills-cache).
|
|
85
|
+
|
|
86
|
+
--domain <name> Declared domain (skips classification)
|
|
87
|
+
--base <ref> Base ref for the changed-file range (default: origin/main when present)
|
|
88
|
+
--adapter, --target, --json
|
|
89
|
+
|
|
90
|
+
Example:
|
|
91
|
+
mm-harness domain
|
|
92
|
+
mm-harness domain --json`
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "pr-body",
|
|
96
|
+
summary: "Render the publishable PR body: the authored description plus the recipe and run log sections built from artifacts.",
|
|
97
|
+
example: "mm-harness pr-body render temp/tasks/dev/TAT-1",
|
|
98
|
+
helpText: `mm-harness pr-body render <task-dir> [flags]
|
|
99
|
+
|
|
100
|
+
Reads <task-dir>/artifacts/pr-description.md (the PR body the worker wrote in
|
|
101
|
+
the repository PR template shape) and inserts the machine sections rendered
|
|
102
|
+
from artifacts: "Validation Recipe" from artifacts/recipe.json, "Validation
|
|
103
|
+
Logs" from artifacts/recipe-run/report.md, and "Recipe Workflow" from
|
|
104
|
+
artifacts/workflow.mmd when it exists. Any such section the worker pasted by
|
|
105
|
+
hand is replaced. The sections go before the first checklist section, else at
|
|
106
|
+
the end. Fences are always longer than any backtick run in the content and
|
|
107
|
+
close on their own line. Writes <task-dir>/artifacts/pr-body.md.
|
|
108
|
+
|
|
109
|
+
--command <text> Recipe invocation to show above the run log
|
|
110
|
+
--out <file> Write elsewhere than artifacts/pr-body.md
|
|
111
|
+
--json Machine-readable result
|
|
112
|
+
|
|
113
|
+
Example:
|
|
114
|
+
mm-harness pr-body render temp/tasks/dev/TAT-1 --json`
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
name: "config",
|
|
118
|
+
summary: "Per-engineer machine locations for team libraries and reference checkouts.",
|
|
119
|
+
example: "mm-harness config set references.extension ~/dev/metamask-extension",
|
|
120
|
+
helpText: `mm-harness config <list|path|get <key>|set <key> <path>|unset <key>> [--json]
|
|
121
|
+
|
|
122
|
+
Stored outside every repo in ~/.mm-harness/config.json (override: MM_HARNESS_CONFIG).
|
|
123
|
+
Keys: libraries.<name> (team library checkout), references.<mobile|extension|core>
|
|
124
|
+
(the checkout used as the parity reference). Env always wins over config:
|
|
125
|
+
RECIPE_LIBRARY_PATH for libraries, MM_HARNESS_REF_<ADAPTER> for references.
|
|
126
|
+
|
|
127
|
+
Example:
|
|
128
|
+
mm-harness config set libraries.perps ~/dev/experimental-metamask-recipe-perps
|
|
129
|
+
mm-harness config set references.extension ~/dev/metamask-extension
|
|
130
|
+
mm-harness config list`
|
|
41
131
|
},
|
|
42
132
|
{
|
|
43
133
|
name: "tutorial",
|
|
@@ -783,12 +873,12 @@ const HELP_GROUPS = [
|
|
|
783
873
|
{
|
|
784
874
|
title: "DISCOVER",
|
|
785
875
|
blurb: "discover atomic actions and reusable recipes (--json is the agent-primary form)",
|
|
786
|
-
commands: ["help", "tutorial", "actions", "call", "execution-template"]
|
|
876
|
+
commands: ["help", "tutorial", "actions", "call", "execution-template", "domain", "config"]
|
|
787
877
|
},
|
|
788
878
|
{
|
|
789
879
|
title: "PROVE",
|
|
790
880
|
blurb: "run recipes and inspect readiness",
|
|
791
|
-
commands: ["run", "last", "doctor", "check", "checklist", "recipe-quality"]
|
|
881
|
+
commands: ["run", "last", "doctor", "check", "checklist", "review", "pr-body", "recipe-quality"]
|
|
792
882
|
},
|
|
793
883
|
{
|
|
794
884
|
title: "RUNTIME OVERLAY",
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const PROSE_FILE = "pr-description.md";
|
|
4
|
+
const BODY_FILE = "pr-body.md";
|
|
5
|
+
const RECIPE_HEADING = "## **Validation Recipe**";
|
|
6
|
+
const LOGS_HEADING = "## **Validation Logs**";
|
|
7
|
+
const WORKFLOW_HEADING = "## **Recipe Workflow**";
|
|
8
|
+
function headingKey(heading) {
|
|
9
|
+
return heading.replace(/^#+\s*/, "").replace(/[*_`]/g, "").replace(/\s+/g, " ").trim().toLowerCase();
|
|
10
|
+
}
|
|
11
|
+
const MACHINE_KEYS = new Set([RECIPE_HEADING, LOGS_HEADING, WORKFLOW_HEADING].map(headingKey));
|
|
12
|
+
function levelTwoHeadingLines(lines) {
|
|
13
|
+
const found = [];
|
|
14
|
+
lines.forEach((line, index) => {
|
|
15
|
+
if (/^ {0,3}##(?!#)\s+\S/.test(line)) found.push(index);
|
|
16
|
+
});
|
|
17
|
+
return found;
|
|
18
|
+
}
|
|
19
|
+
function fenceFor(content) {
|
|
20
|
+
let longest = 0;
|
|
21
|
+
for (const run of content.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
|
|
22
|
+
return "`".repeat(Math.max(3, longest + 1));
|
|
23
|
+
}
|
|
24
|
+
function fenced(content, info = "") {
|
|
25
|
+
const fence = fenceFor(content);
|
|
26
|
+
return `${fence}${info}
|
|
27
|
+
${content.replace(/\n+$/, "")}
|
|
28
|
+
${fence}`;
|
|
29
|
+
}
|
|
30
|
+
function escapeHtml(text) {
|
|
31
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
32
|
+
}
|
|
33
|
+
function countRecipeNodes(recipe) {
|
|
34
|
+
if (!recipe || typeof recipe !== "object") return 0;
|
|
35
|
+
const record = recipe;
|
|
36
|
+
if (Array.isArray(record.nodes)) return record.nodes.length;
|
|
37
|
+
if (Array.isArray(record.steps)) return record.steps.length;
|
|
38
|
+
const workflow = record.workflow;
|
|
39
|
+
if (!workflow || typeof workflow !== "object") return 0;
|
|
40
|
+
return Object.values(workflow).reduce(
|
|
41
|
+
(total, value) => total + (Array.isArray(value) ? value.length : 0),
|
|
42
|
+
0
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
function recipeSection(file) {
|
|
46
|
+
const recipe = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
47
|
+
const nodes = countRecipeNodes(recipe);
|
|
48
|
+
const record = recipe && typeof recipe === "object" ? recipe : {};
|
|
49
|
+
const title = typeof record.title === "string" ? record.title : typeof record.description === "string" ? record.description : "";
|
|
50
|
+
const summary = escapeHtml(`recipe.json (${nodes} steps${title ? ` \u2014 ${title.slice(0, 120)}` : ""})`);
|
|
51
|
+
return {
|
|
52
|
+
nodes,
|
|
53
|
+
body: [`<details><summary>${summary}</summary>`, "", fenced(JSON.stringify(recipe, null, 2), "json"), "</details>"].join("\n")
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function logsSection(file, command) {
|
|
57
|
+
const report = fs.readFileSync(file, "utf8");
|
|
58
|
+
const status = /^Status:\s*(\S+)/m.exec(report)?.[1];
|
|
59
|
+
const nodes = /^Nodes:\s*(.+?)\s*$/m.exec(report)?.[1];
|
|
60
|
+
const summary = escapeHtml(status || nodes ? `Full output (${[nodes, status].filter(Boolean).join(", ")})` : "Full output");
|
|
61
|
+
const lines = [];
|
|
62
|
+
if (command) lines.push("Command:", "", fenced(command, "bash"), "");
|
|
63
|
+
lines.push(`<details><summary>${summary}</summary>`, "", fenced(report), "</details>");
|
|
64
|
+
return lines.join("\n");
|
|
65
|
+
}
|
|
66
|
+
function trimEnd(lines) {
|
|
67
|
+
let end = lines.length;
|
|
68
|
+
while (end > 0 && !lines[end - 1].trim()) end -= 1;
|
|
69
|
+
return lines.slice(0, end);
|
|
70
|
+
}
|
|
71
|
+
function renderPrBody(input) {
|
|
72
|
+
const artifacts = path.join(input.taskDir, "artifacts");
|
|
73
|
+
const prosePath = path.join(artifacts, PROSE_FILE);
|
|
74
|
+
if (!fs.existsSync(prosePath)) {
|
|
75
|
+
throw new Error(`Missing ${path.relative(input.taskDir, prosePath)}: write the PR description there first.`);
|
|
76
|
+
}
|
|
77
|
+
const recipePath = path.join(artifacts, "recipe.json");
|
|
78
|
+
const reportPath = path.join(artifacts, "recipe-run", "report.md");
|
|
79
|
+
const workflowPath = path.join(artifacts, "workflow.mmd");
|
|
80
|
+
const machine = [];
|
|
81
|
+
let recipe = null;
|
|
82
|
+
if (fs.existsSync(recipePath)) {
|
|
83
|
+
const rendered = recipeSection(recipePath);
|
|
84
|
+
recipe = { nodes: rendered.nodes, path: path.relative(input.taskDir, recipePath) };
|
|
85
|
+
machine.push([RECIPE_HEADING, rendered.body]);
|
|
86
|
+
} else {
|
|
87
|
+
machine.push([RECIPE_HEADING, "Not applicable: this task has no `artifacts/recipe.json`."]);
|
|
88
|
+
}
|
|
89
|
+
const hasReport = fs.existsSync(reportPath);
|
|
90
|
+
machine.push([
|
|
91
|
+
LOGS_HEADING,
|
|
92
|
+
hasReport ? logsSection(reportPath, input.command) : "Not applicable: no recipe run report under `artifacts/recipe-run/`."
|
|
93
|
+
]);
|
|
94
|
+
const hasWorkflow = fs.existsSync(workflowPath);
|
|
95
|
+
if (hasWorkflow) {
|
|
96
|
+
machine.push([
|
|
97
|
+
WORKFLOW_HEADING,
|
|
98
|
+
["<details><summary>workflow.mmd</summary>", "", fenced(fs.readFileSync(workflowPath, "utf8"), "mermaid"), "</details>"].join("\n")
|
|
99
|
+
]);
|
|
100
|
+
}
|
|
101
|
+
const lines = fs.readFileSync(prosePath, "utf8").replace(/\r\n?/g, "\n").split("\n");
|
|
102
|
+
const headings = levelTwoHeadingLines(lines);
|
|
103
|
+
const keep = new Array(lines.length).fill(true);
|
|
104
|
+
let insertAt = lines.length;
|
|
105
|
+
headings.forEach((start, at) => {
|
|
106
|
+
const end = headings[at + 1] ?? lines.length;
|
|
107
|
+
const key = headingKey(lines[start]);
|
|
108
|
+
if (MACHINE_KEYS.has(key)) keep.fill(false, start, end);
|
|
109
|
+
else if (insertAt === lines.length && /checklist/.test(key)) insertAt = start;
|
|
110
|
+
});
|
|
111
|
+
const before = lines.slice(0, insertAt).filter((_, index) => keep[index]);
|
|
112
|
+
const after = lines.slice(insertAt).filter((_, index) => keep[insertAt + index]);
|
|
113
|
+
const generated = machine.flatMap(([heading, body2]) => [heading, "", body2, ""]);
|
|
114
|
+
const body = [...trimEnd(before), "", ...generated, ...after].join("\n").replace(/\n{3,}/g, "\n\n").replace(/\n*$/, "\n");
|
|
115
|
+
return {
|
|
116
|
+
body,
|
|
117
|
+
sections: machine.map(([heading]) => heading),
|
|
118
|
+
recipe,
|
|
119
|
+
run: hasReport ? { path: path.relative(input.taskDir, reportPath) } : null,
|
|
120
|
+
workflow: hasWorkflow ? { path: path.relative(input.taskDir, workflowPath) } : null
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
export {
|
|
124
|
+
BODY_FILE,
|
|
125
|
+
LOGS_HEADING,
|
|
126
|
+
PROSE_FILE,
|
|
127
|
+
RECIPE_HEADING,
|
|
128
|
+
WORKFLOW_HEADING,
|
|
129
|
+
countRecipeNodes,
|
|
130
|
+
escapeHtml,
|
|
131
|
+
fenceFor,
|
|
132
|
+
fenced,
|
|
133
|
+
headingKey,
|
|
134
|
+
levelTwoHeadingLines,
|
|
135
|
+
renderPrBody
|
|
136
|
+
};
|