@synovitec/trellis 0.0.0-stage → 0.6.0-rc.2

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.
Files changed (50) hide show
  1. package/CHANGELOG.md +393 -0
  2. package/LICENSE +190 -0
  3. package/README.md +206 -2
  4. package/SECURITY.md +104 -0
  5. package/bin/trellis.mjs +5 -0
  6. package/docs/attestation/v0.1.md +96 -0
  7. package/package.json +78 -4
  8. package/schema/trellis.config.schema.json +349 -0
  9. package/src/agents/adapters.mjs +155 -0
  10. package/src/agents/git.mjs +241 -0
  11. package/src/agents/glob.mjs +41 -0
  12. package/src/agents/hooks.mjs +780 -0
  13. package/src/agents/install-adapters.mjs +172 -0
  14. package/src/agents/install.mjs +302 -0
  15. package/src/agents/manifests.mjs +127 -0
  16. package/src/agents/rules.mjs +330 -0
  17. package/src/agents/shell.mjs +253 -0
  18. package/src/agents/state.mjs +233 -0
  19. package/src/agents/transcript.mjs +120 -0
  20. package/src/cli/main.mjs +191 -0
  21. package/src/commands/attest.mjs +207 -0
  22. package/src/commands/checkpoint-state.mjs +62 -0
  23. package/src/commands/doctor.mjs +98 -0
  24. package/src/commands/guide.mjs +62 -0
  25. package/src/commands/init.mjs +129 -0
  26. package/src/commands/key.mjs +31 -0
  27. package/src/commands/scan.mjs +51 -0
  28. package/src/commands/uninstall.mjs +36 -0
  29. package/src/commands/verify.mjs +509 -0
  30. package/src/core/abatty.mjs +25 -0
  31. package/src/core/agents-block.mjs +65 -0
  32. package/src/core/attestation.mjs +213 -0
  33. package/src/core/config.mjs +135 -0
  34. package/src/core/context.mjs +280 -0
  35. package/src/core/diff.mjs +189 -0
  36. package/src/core/evidence.mjs +167 -0
  37. package/src/core/mcp.mjs +51 -0
  38. package/src/index.mjs +11 -0
  39. package/src/mcp/client.mjs +164 -0
  40. package/src/mcp/launch.mjs +57 -0
  41. package/src/mcp/relay.mjs +111 -0
  42. package/src/mcp/server.mjs +210 -0
  43. package/src/mcp/trust.mjs +142 -0
  44. package/src/tools/registry.mjs +370 -0
  45. package/src/tools/roles.mjs +17 -0
  46. package/src/verify/deps.mjs +382 -0
  47. package/src/verify/gitleaks-default.toml +3211 -0
  48. package/src/verify/rules.mjs +163 -0
  49. package/src/verify/secrets.mjs +169 -0
  50. package/src/verify/test-commands.mjs +116 -0
@@ -0,0 +1,213 @@
1
+ // The attestation a commit carries: an in-toto Statement v1 about the commit and its tree, in a
2
+ // DSSE envelope signed with the developer's ed25519 key, stored as a git note under
3
+ // refs/notes/trellis. It says which rules applied, what the checkpoint concluded, and which
4
+ // checks were seen: file paths, blob ids, tool names and the test command line in clear, what a
5
+ // check was about and each finding's message as SHA-256, never a prompt or a file's content.
6
+ //
7
+ // Trust: a developer's signature proves the attestation was not altered after Trellis wrote it.
8
+ // It cannot prove an agent running as that developer did not write it: CI re-runs the
9
+ // deterministic checks itself, and is the authority (docs/design/0001-evidence-v2.md).
10
+ import { createHash, createPrivateKey, createPublicKey, generateKeyPairSync, sign, verify } from "node:crypto";
11
+ import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { spawnSync } from "node:child_process";
14
+ import { keyFile } from "../agents/state.mjs";
15
+ import { VERSION } from "./config.mjs";
16
+
17
+ export const STATEMENT_TYPE = "https://in-toto.io/Statement/v1";
18
+ export const PAYLOAD_TYPE = "application/vnd.in-toto+json";
19
+ export const PREDICATE_TYPE = "https://github.com/Synovitec/trellis/blob/main/docs/attestation/v0.1.md";
20
+ export const NOTES_REF = "refs/notes/trellis";
21
+
22
+ /** @param {string} text */
23
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
24
+
25
+ /**
26
+ * @param {object} p
27
+ * @param {string} p.commit
28
+ * @param {string} p.tree
29
+ * @param {"agent" | "person"} p.actor
30
+ * @param {string | null} p.agent
31
+ * @param {string} p.preset
32
+ * @param {"pass" | "fail" | "unverified"} p.verdict unverified: the commit skipped the checkpoint
33
+ * @param {Array<{ rule: string, mode: string, message: string }>} p.findings
34
+ * @param {import("./evidence.mjs").Event[]} p.events
35
+ */
36
+ export function buildStatement(p) {
37
+ return {
38
+ _type: STATEMENT_TYPE,
39
+ subject: [{ name: "git commit", digest: { gitCommit: p.commit, gitTree: p.tree } }],
40
+ predicateType: PREDICATE_TYPE,
41
+ predicate: {
42
+ verifier: { name: "trellis", version: VERSION },
43
+ actor: p.actor,
44
+ agent: p.agent,
45
+ preset: p.preset,
46
+ verdict: p.verdict,
47
+ findings: p.findings.map((f) => ({ rule: f.rule, mode: f.mode, messageSha256: sha256(f.message) })),
48
+ events: p.events.map((e) => ({
49
+ kind: e.kind,
50
+ tool: e.tool,
51
+ agent: e.agent,
52
+ at: e.at,
53
+ ...(e.result !== undefined ? { result: e.result } : {}),
54
+ ...(e.blobs ? { blobs: e.blobs } : {}),
55
+ ...(e.tree ? { tree: e.tree } : {}),
56
+ ...(e.subject ? { subjectSha256: sha256(String(e.subject)) } : {}),
57
+ })),
58
+ },
59
+ };
60
+ }
61
+
62
+ /** The developer's ed25519 key, beside the evidence key, created once, readable by its owner only. @param {NodeJS.ProcessEnv} env */
63
+ function privateKey(env) {
64
+ const file = join(dirname(keyFile(env)), "ed25519.pem");
65
+ if (!existsSync(file)) {
66
+ mkdirSync(dirname(file), { recursive: true });
67
+ const { privateKey: key } = generateKeyPairSync("ed25519");
68
+ writeFileSync(file, key.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
69
+ try {
70
+ chmodSync(file, 0o600);
71
+ } catch {
72
+ /* Windows keeps its own ACLs */
73
+ }
74
+ }
75
+ return createPrivateKey(readFileSync(file, "utf8"));
76
+ }
77
+
78
+ /** The public half, as PEM: what a developer commits under .trellis/keys/. @param {NodeJS.ProcessEnv} [env] */
79
+ export const publicKeyPem = (env = process.env) => String(createPublicKey(privateKey(env)).export({ type: "spki", format: "pem" }));
80
+
81
+ /** A key's id: the SHA-256 of its public key, as DSSE's keyid hint. @param {string} pem */
82
+ export const keyIdOf = (pem) => sha256(createPublicKey(pem).export({ type: "spki", format: "der" }).toString("base64")).slice(0, 32);
83
+
84
+ /** DSSE's pre-authentication encoding. @param {string} type @param {Buffer} body */
85
+ const pae = (type, body) => Buffer.concat([Buffer.from(`DSSEv1 ${Buffer.byteLength(type)} ${type} ${body.length} `), body]);
86
+
87
+ /**
88
+ * @param {object} statement
89
+ * @param {NodeJS.ProcessEnv} [env]
90
+ * @returns {{ payloadType: string, payload: string, signatures: Array<{ keyid: string, sig: string }> }}
91
+ */
92
+ export function signEnvelope(statement, env = process.env) {
93
+ const body = Buffer.from(JSON.stringify(statement));
94
+ const key = privateKey(env);
95
+ const sig = sign(null, pae(PAYLOAD_TYPE, body), key).toString("base64");
96
+ return { payloadType: PAYLOAD_TYPE, payload: body.toString("base64"), signatures: [{ keyid: keyIdOf(publicKeyPem(env)), sig }] };
97
+ }
98
+
99
+ /**
100
+ * Checks an envelope against the trusted public keys, by keyid.
101
+ * @param {any} envelope
102
+ * @param {Map<string, string>} keys keyid -> PEM
103
+ * @returns {{ ok: true, keyid: string, statement: any } | { ok: false, reason: string }}
104
+ */
105
+ export function verifyEnvelope(envelope, keys) {
106
+ if (envelope?.payloadType !== PAYLOAD_TYPE || typeof envelope?.payload !== "string") return { ok: false, reason: "not an in-toto envelope" };
107
+ const body = Buffer.from(envelope.payload, "base64");
108
+ for (const s of envelope.signatures ?? []) {
109
+ const pem = keys.get(String(s?.keyid));
110
+ if (!pem) continue;
111
+ let good = false;
112
+ try {
113
+ good = verify(null, pae(PAYLOAD_TYPE, body), createPublicKey(pem), Buffer.from(String(s.sig), "base64"));
114
+ } catch {
115
+ good = false;
116
+ }
117
+ if (!good) return { ok: false, reason: `the signature by key ${s.keyid} does not match the content` };
118
+ let statement;
119
+ try {
120
+ statement = JSON.parse(body.toString("utf8"));
121
+ } catch {
122
+ return { ok: false, reason: "the signed payload is not JSON" };
123
+ }
124
+ if (statement?._type !== STATEMENT_TYPE || statement?.predicateType !== PREDICATE_TYPE) return { ok: false, reason: "not a Trellis statement" };
125
+ return { ok: true, keyid: s.keyid, statement };
126
+ }
127
+ return { ok: false, reason: `signed by an unknown key (${(envelope.signatures ?? []).map((/** @type {any} */ s) => s.keyid).join(", ") || "none"}): add the developer's public key under .trellis/keys/` };
128
+ }
129
+
130
+ /**
131
+ * The public keys a repository trusts: every PEM file under .trellis/keys/, in the work tree, or
132
+ * as it stands at commit `rev`. CI reads them at the base: a key a pull request adds is not
133
+ * trusted by that same pull request.
134
+ * @param {string} root
135
+ * @param {string} [rev]
136
+ */
137
+ export function trustedKeys(root, rev) {
138
+ /** @type {Map<string, string>} */
139
+ const keys = new Map();
140
+ const add = (/** @type {string} */ pem) => {
141
+ try {
142
+ keys.set(keyIdOf(pem), pem);
143
+ } catch {
144
+ /* not a key: ignored */
145
+ }
146
+ };
147
+ if (rev) {
148
+ const ls = spawnSync("git", ["ls-tree", "-r", "-z", "--name-only", rev, "--", ".trellis/keys"], { cwd: root, encoding: "utf8", windowsHide: true });
149
+ if (ls.status !== 0) throw new Error(`the trusted keys at ${rev} could not be read: ${(ls.stderr || "").trim()}`);
150
+ for (const path of ls.stdout.split("\0").filter(Boolean)) {
151
+ const show = spawnSync("git", ["show", `${rev}:${path}`], { cwd: root, encoding: "utf8", windowsHide: true });
152
+ if (show.status === 0) add(show.stdout);
153
+ }
154
+ return keys;
155
+ }
156
+ const dir = join(root, ".trellis", "keys");
157
+ if (!existsSync(dir)) return keys;
158
+ for (const f of readdirSync(dir)) {
159
+ try {
160
+ add(readFileSync(join(dir, f), "utf8"));
161
+ } catch {
162
+ /* unreadable: ignored */
163
+ }
164
+ }
165
+ return keys;
166
+ }
167
+
168
+ /**
169
+ * Adds an envelope to a commit's note, one envelope per line. A note is never rewritten: two
170
+ * developers' notes on the same commit merge line by line (`git notes merge -s cat_sort_uniq`),
171
+ * and each line stands or falls on its own signature.
172
+ * @param {string} root @param {string} commit @param {object} envelope
173
+ */
174
+ export function writeNote(root, commit, envelope) {
175
+ const line = JSON.stringify(envelope);
176
+ const kept = noteText(root, commit).split(/\r?\n/).filter((l) => l.trim() && l !== line);
177
+ const r = spawnSync("git", ["notes", "--ref", NOTES_REF, "add", "-f", "-F", "-", commit], { cwd: root, input: `${[...kept, line].join("\n")}\n`, encoding: "utf8", windowsHide: true });
178
+ if (r.status !== 0) throw new Error(`git notes could not record the attestation: ${(r.stderr || "").trim()}`);
179
+ }
180
+
181
+ /** @param {string} root @param {string} commit */
182
+ function noteText(root, commit) {
183
+ const r = spawnSync("git", ["notes", "--ref", NOTES_REF, "show", commit], { cwd: root, encoding: "utf8", windowsHide: true });
184
+ return r.status === 0 ? r.stdout : "";
185
+ }
186
+
187
+ /** Every envelope a commit's note holds; a line that is not JSON is skipped. @param {string} root @param {string} commit */
188
+ export function readNotes(root, commit) {
189
+ return noteText(root, commit)
190
+ .split(/\r?\n/)
191
+ .filter((l) => l.trim())
192
+ .flatMap((l) => {
193
+ try {
194
+ return [JSON.parse(l)];
195
+ } catch {
196
+ return [];
197
+ }
198
+ });
199
+ }
200
+
201
+ /** The first envelope a commit carries, or null. @param {string} root @param {string} commit */
202
+ export const readNote = (root, commit) => readNotes(root, commit)[0] ?? null;
203
+
204
+ /** This developer's public key if one exists already, without creating one (for doctor). @param {NodeJS.ProcessEnv} [env] */
205
+ export function existingPublicKeyPem(env = process.env) {
206
+ const file = join(dirname(keyFile(env)), "ed25519.pem");
207
+ if (!existsSync(file)) return null;
208
+ try {
209
+ return String(createPublicKey(createPrivateKey(readFileSync(file, "utf8"))).export({ type: "spki", format: "pem" }));
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
@@ -0,0 +1,135 @@
1
+ // trellis.config.json: which tools this repository uses, and their options. A tool set to `false`
2
+ // is a decision on record (it does not apply here), not a gap the doctor reports.
3
+ import { TOOLS, toolById } from "../tools/registry.mjs";
4
+ import { PRESETS } from "../verify/rules.mjs";
5
+ import { RULES } from "../agents/rules.mjs";
6
+ import { parseJson } from "./context.mjs";
7
+ import { readFileSync } from "node:fs";
8
+
9
+ export const CONFIG_FILE = "trellis.config.json";
10
+ export const VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version;
11
+ /** Pinned to the version that wrote the file, so an old install validates against its own schema. */
12
+ export const SCHEMA_URL = `https://unpkg.com/@synovitec/trellis@${VERSION}/schema/trellis.config.schema.json`;
13
+
14
+ /** @typedef {import("./context.mjs").Context} Context */
15
+ /** @typedef {{ $schema?: string, tools: Record<string, boolean | Record<string, any>>, agents?: import("../agents/rules.mjs").AgentConfig }} Config */
16
+
17
+ /** @type {Record<string, Record<string, any>>} */
18
+ const DEFAULT_OPTIONS = {
19
+ "playwright-mcp": { origin: "http://localhost:3000" },
20
+ "postgres-mcp": { uriEnv: "MCP_PG_URI" },
21
+ };
22
+
23
+ /** @param {Context} ctx @returns {Config | null} */
24
+ export function loadConfig(ctx) {
25
+ return parseJson(ctx.read(CONFIG_FILE));
26
+ }
27
+
28
+ /** Tools init leaves off even when present: what the commit checkpoint already covers. */
29
+ const OFF_BY_DEFAULT = new Set(["osv-scanner"]);
30
+
31
+ /**
32
+ * A config for this repository. By default, every tool that applies to its stack (what `doctor`
33
+ * checks without a config). With `detected`, only the tools that apply and are present here, so
34
+ * `init` leaves a repository green and proposes the rest instead of enabling what it cannot run.
35
+ * @param {Context} ctx
36
+ * @param {{ detected?: boolean }} [opts]
37
+ * @returns {Config}
38
+ */
39
+ export function defaultConfig(ctx, opts = {}) {
40
+ /** @type {Config["tools"]} */
41
+ const tools = {};
42
+ for (const t of TOOLS) {
43
+ // init does not turn on what trellis verify already does: OSV-Scanner scans the whole lockfile,
44
+ // while the commit checkpoint checks each added dependency against OSV. It stays one setting away.
45
+ const on = t.applies(ctx) && (!opts.detected || (t.check(ctx, DEFAULT_OPTIONS[t.id] ?? {}).installed !== false && !OFF_BY_DEFAULT.has(t.id)));
46
+ tools[t.id] = on ? (DEFAULT_OPTIONS[t.id] ?? true) : false;
47
+ }
48
+ return { $schema: SCHEMA_URL, tools };
49
+ }
50
+
51
+ /**
52
+ * Tools that apply here but are not present: what init proposes, with how to get each.
53
+ * @param {Context} ctx
54
+ */
55
+ export function proposals(ctx) {
56
+ return TOOLS.filter((t) => t.applies(ctx) && t.check(ctx, DEFAULT_OPTIONS[t.id] ?? {}).installed === false);
57
+ }
58
+
59
+ /**
60
+ * The tools a config turns on, with their options. Without a config, every tool that applies.
61
+ * @param {Context} ctx
62
+ * @param {Config | null} config
63
+ */
64
+ export function enabledTools(ctx, config) {
65
+ const cfg = config ?? defaultConfig(ctx);
66
+ return TOOLS.flatMap((tool) => {
67
+ const v = cfg.tools?.[tool.id];
68
+ if (!v) return [];
69
+ return [{ tool, opts: typeof v === "object" ? v : (DEFAULT_OPTIONS[tool.id] ?? {}) }];
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Problems with a config, as sentences. An unknown tool id is an error, not ignored: a typo
75
+ * would otherwise switch a tool off silently.
76
+ * @param {any} cfg
77
+ * @returns {string[]}
78
+ */
79
+ export function validateConfig(cfg) {
80
+ if (cfg == null || typeof cfg !== "object" || Array.isArray(cfg)) return [`${CONFIG_FILE} is not a JSON object`];
81
+ // No "tools" is no tool switched on: `{}` is a valid config, the checkpoint alone.
82
+ if (cfg.tools !== undefined && (cfg.tools === null || typeof cfg.tools !== "object" || Array.isArray(cfg.tools))) return [`${CONFIG_FILE}: "tools" must be an object`];
83
+ const errors = [];
84
+ for (const [id, v] of Object.entries(cfg.tools ?? {})) {
85
+ if (!toolById(id)) errors.push(`unknown tool "${id}" (known: ${TOOLS.map((t) => t.id).join(", ")})`);
86
+ else if (typeof v !== "boolean" && (v === null || typeof v !== "object" || Array.isArray(v))) errors.push(`tool "${id}" must be true, false or an options object`);
87
+ }
88
+ if (cfg.agents !== undefined) errors.push(...validateAgents(cfg.agents));
89
+ if (cfg.verify !== undefined) errors.push(...validateVerify(cfg.verify));
90
+ return errors;
91
+ }
92
+
93
+ const MODES = ["block", "warn", "off"];
94
+
95
+ /**
96
+ * The commit checkpoint's settings: a preset, and per-rule modes. An unknown rule is an error, so
97
+ * a typo cannot switch a rule off silently.
98
+ * @param {any} verify
99
+ */
100
+ function validateVerify(verify) {
101
+ if (verify === null || typeof verify !== "object" || Array.isArray(verify)) return [`"verify" must be an object`];
102
+ const errors = [];
103
+ if (verify.preset !== undefined && !(verify.preset in PRESETS)) errors.push(`verify.preset must be one of ${Object.keys(PRESETS).join(", ")}`);
104
+ const known = Object.keys(PRESETS.recommended);
105
+ for (const [id, mode] of Object.entries(verify.rules ?? {})) {
106
+ if (!known.includes(id)) errors.push(`unknown verify rule "${id}" (known: ${known.join(", ")})`);
107
+ else if (!MODES.includes(/** @type {string} */ (mode))) errors.push(`verify.rules.${id} must be one of ${MODES.join(", ")}`);
108
+ }
109
+ if (verify.tests !== undefined && (!Array.isArray(verify.tests) || !verify.tests.length || verify.tests.some((t) => typeof t !== "string" || !t.trim()))) errors.push("verify.tests must be a list of test commands");
110
+ if (verify.unattested !== undefined && !["refuse", "judge"].includes(verify.unattested)) errors.push('verify.unattested must be "refuse" or "judge"');
111
+ for (const key of Object.keys(verify)) if (!["preset", "rules", "tests", "unattested"].includes(key)) errors.push(`unknown verify option "${key}"`);
112
+ return errors;
113
+ }
114
+
115
+ /** @param {any} agents */
116
+ function validateAgents(agents) {
117
+ if (agents === null || typeof agents !== "object" || Array.isArray(agents)) return [`"agents" must be an object`];
118
+ const errors = [];
119
+ if (agents.mode !== undefined && !MODES.includes(agents.mode)) errors.push(`agents.mode must be one of ${MODES.join(", ")}`);
120
+ for (const [id, mode] of Object.entries(agents.rules ?? {})) {
121
+ if (!RULES.some((r) => r.id === id)) errors.push(`unknown agent rule "${id}" (known: ${RULES.map((r) => r.id).join(", ")})`);
122
+ else if (!MODES.includes(/** @type {string} */ (mode))) errors.push(`agents.rules.${id} must be one of ${MODES.join(", ")}`);
123
+ }
124
+ for (const key of ["uiPaths", "uiIgnore", "agentSetupPaths", "codePaths", "codeIgnore", "schemaPaths"]) {
125
+ const v = agents[key];
126
+ if (v !== undefined && (!Array.isArray(v) || v.some((g) => typeof g !== "string"))) errors.push(`agents.${key} must be a list of globs`);
127
+ }
128
+ for (const key of ["graphAfterSearches", "maxBlocks"]) {
129
+ const v = agents[key];
130
+ if (v !== undefined && !(Number.isInteger(v) && v > 0)) errors.push(`agents.${key} must be a positive integer`);
131
+ }
132
+ const known = ["mode", "rules", "uiPaths", "uiIgnore", "agentSetupPaths", "codePaths", "codeIgnore", "schemaPaths", "graphAfterSearches", "maxBlocks"];
133
+ for (const key of Object.keys(agents)) if (!known.includes(key)) errors.push(`unknown agents option "${key}"`);
134
+ return errors;
135
+ }
@@ -0,0 +1,280 @@
1
+ // The repository and the machine as every check sees them. One object, so a test can point it
2
+ // at a temporary directory and a fake PATH and get the same answers a real run would.
3
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join, delimiter, resolve } from "node:path";
6
+ import { MANIFESTS, dependenciesOf } from "../agents/manifests.mjs";
7
+ import { commandsIn, selfDescribing } from "../agents/shell.mjs";
8
+
9
+ /** Files above this size are not read for call sites: a call lives in a script, not a bundle. */
10
+ const MAX_SCAN_BYTES = 256 * 1024;
11
+
12
+ /**
13
+ * @typedef {object} Context
14
+ * @property {string} root the repository root
15
+ * @property {string} home the user's home directory
16
+ * @property {NodeJS.Platform} platform
17
+ * @property {(name: string) => string | null} which absolute path of a binary on PATH, or null
18
+ * @property {(rel: string) => string | null} read a repository file's text, or null
19
+ * @property {(rel: string) => string | null} readHome a home file's text, or null
20
+ * @property {NodeJS.ProcessEnv} env the environment the tools would run in
21
+ * @property {(rel: string) => boolean} exists
22
+ * @property {(rel: string) => boolean} existsHome
23
+ * @property {(rel: string) => string[]} list entry names of a repository directory
24
+ * @property {() => any} pkg package.json, or {}
25
+ * @property {(runs: (c: import("../agents/shell.mjs").Command) => boolean) => string[]} callers files that run a command `runs` accepts
26
+ */
27
+
28
+ /**
29
+ * Builds a context. Every field can be overridden, which is how the tests fake a machine.
30
+ * @param {Partial<Context> & { root: string, env?: NodeJS.ProcessEnv }} opts
31
+ * @returns {Context}
32
+ */
33
+ export function createContext(opts) {
34
+ const root = opts.root;
35
+ const home = opts.home ?? homedir();
36
+ const platform = opts.platform ?? process.platform;
37
+ const env = opts.env ?? process.env;
38
+ const readAt = (/** @type {string} */ base, /** @type {string} */ rel) => {
39
+ try {
40
+ return readFileSync(resolve(base, rel), "utf8");
41
+ } catch {
42
+ return null;
43
+ }
44
+ };
45
+ const read = (/** @type {string} */ rel) => readAt(root, rel);
46
+ const list = (/** @type {string} */ rel) => {
47
+ try {
48
+ return readdirSync(resolve(root, rel)).sort();
49
+ } catch {
50
+ return [];
51
+ }
52
+ };
53
+ /** @type {any} */
54
+ let pkgCache;
55
+ const pkg = () => {
56
+ if (pkgCache === undefined) pkgCache = parseJson(read("package.json")) ?? {};
57
+ return pkgCache;
58
+ };
59
+ /** @type {Map<string, import("../agents/shell.mjs").Command[]> | undefined} */
60
+ let sites;
61
+ const callers = (/** @type {(c: import("../agents/shell.mjs").Command) => boolean} */ runs) => {
62
+ sites ??= callSites(root, pkg(), list);
63
+ return [...sites].filter(([, cmds]) => cmds.some(runs)).map(([file]) => file);
64
+ };
65
+ return {
66
+ root,
67
+ home,
68
+ platform,
69
+ env,
70
+ which: opts.which ?? ((name) => whichOnPath(name, env, platform)),
71
+ read,
72
+ readHome: (rel) => readAt(home, rel),
73
+ exists: (rel) => existsSync(resolve(root, rel)),
74
+ existsHome: (rel) => existsSync(join(home, rel)),
75
+ list,
76
+ pkg,
77
+ callers,
78
+ ...stripUndefined(opts, ["which"]),
79
+ };
80
+ }
81
+
82
+ /**
83
+ * The commands a repository actually runs, per file: package scripts, git hooks, shell scripts,
84
+ * CI steps, Makefile and justfile recipes, Jenkins `sh` steps. A tool that is installed and
85
+ * configured but run by none of these is configuration that never runs, so "wired" is decided
86
+ * here, by a parsed command, never by a mention: a string in a JavaScript file, a step's name, an
87
+ * `echo` or a comment runs nothing.
88
+ * @param {string} root
89
+ * @param {any} pkg
90
+ * @param {(rel: string) => string[]} list
91
+ * @returns {Map<string, import("../agents/shell.mjs").Command[]>}
92
+ */
93
+ function callSites(root, pkg, list) {
94
+ /** @type {Map<string, import("../agents/shell.mjs").Command[]>} */
95
+ const out = new Map();
96
+ const ran = (/** @type {string[]} */ lines) => lines.flatMap((l) => commandsIn(l)).filter((c) => !selfDescribing(c));
97
+ const scripts = pkg?.scripts ?? {};
98
+ for (const [name, cmd] of Object.entries(scripts)) out.set(`package.json#scripts.${name}`, ran([String(cmd)]));
99
+ const add = (/** @type {string} */ rel) => {
100
+ try {
101
+ const full = join(root, rel);
102
+ const st = statSync(full);
103
+ if (!st.isFile() || st.size > MAX_SCAN_BYTES) return;
104
+ out.set(rel, ran(commandLines(rel, readFileSync(full, "utf8"))));
105
+ } catch {
106
+ /* unreadable files call nothing */
107
+ }
108
+ };
109
+ for (const dir of [".githooks", ".husky"]) for (const f of list(dir)) add(`${dir}/${f}`);
110
+ // The default hooks folder runs too; its `.sample` files are git's examples and run nothing.
111
+ for (const f of list(".git/hooks")) if (!f.endsWith(".sample")) add(`.git/hooks/${f}`);
112
+ const ROOT_FILES = [".woodpecker.yml", ".gitlab-ci.yml", ".pre-commit-config.yaml", "lefthook.yml", "Makefile", "makefile", "justfile", "Justfile", "Taskfile.yml", "Jenkinsfile", "azure-pipelines.yml", "bitbucket-pipelines.yml", ".drone.yml"];
113
+ for (const f of ROOT_FILES) add(f);
114
+ for (const dir of [".woodpecker", ".github/workflows", ".circleci", ".buildkite"]) for (const f of list(dir)) add(`${dir}/${f}`);
115
+ walk(root, "scripts", 3, add);
116
+ // `.claude/settings.json` is left out on purpose: a permission that allows a command is not a
117
+ // call to it, and counting it would report a scanner nobody runs as wired.
118
+ return out;
119
+ }
120
+
121
+ /**
122
+ * The command lines in a file, by what kind of file it is. YAML: the value of each step, run
123
+ * line or list item (a block scalar's lines included). Makefile and justfile: the recipe lines.
124
+ * Jenkinsfile: the strings given to `sh`. Anything else (a hook, a shell script): the file.
125
+ * @param {string} rel
126
+ * @param {string} text
127
+ * @returns {string[]}
128
+ */
129
+ export function commandLines(rel, text) {
130
+ const name = rel.slice(rel.lastIndexOf("/") + 1);
131
+ const lines = text.split(/\r?\n/);
132
+ if (/\.ya?ml$/i.test(name)) return yamlCommands(lines);
133
+ if (/^(GNU)?[Mm]akefile$|\.mk$|^[Jj]ustfile$/.test(name)) return lines.filter((l) => /^(\t| {2,})\S/.test(l)).map((l) => l.trim().replace(/^[@-]+/, ""));
134
+ if (name === "Jenkinsfile") return [...text.matchAll(/\b(?:sh|bat|powershell)\s*\(?\s*(?:script:\s*)?(['"]{1,3})([\s\S]*?)\1/g)].map((m) => m[2]);
135
+ return [text];
136
+ }
137
+
138
+ /** Keys whose value is a command in the CI and hook formats Trellis reads. */
139
+ const CMD_KEYS = new Set(["run", "script", "scripts", "before_script", "after_script", "command", "commands", "cmd", "cmds", "entry", "bash", "sh", "pwsh", "powershell"]);
140
+
141
+ /**
142
+ * The commands of a YAML step file: the value of a command key (`run: x`), the items of a
143
+ * command list (`commands:` then `- x`), and the lines of a block scalar under either (`run: |`).
144
+ * A step's `name:` or any other key is not a command, whatever words it holds.
145
+ * @param {string[]} lines
146
+ * @returns {string[]}
147
+ */
148
+ function yamlCommands(lines) {
149
+ const out = [];
150
+ /** The indentation under which lines are commands, and how: a block's lines, or a list's items. */
151
+ let scope = /** @type {{ indent: number, block: boolean } | null} */ (null);
152
+ for (const raw of lines) {
153
+ if (!raw.trim() || /^\s*#/.test(raw)) continue;
154
+ const indent = raw.length - raw.trimStart().length;
155
+ if (scope && indent <= scope.indent) scope = null;
156
+ const text = raw.trim();
157
+ if (scope?.block) {
158
+ out.push(text);
159
+ continue;
160
+ }
161
+ const item = text.startsWith("- ") || text === "-";
162
+ const body = item ? text.slice(1).trim() : text;
163
+ const kv = body.match(/^["']?([\w.-]+)["']?\s*:(?:\s+(.*))?$/);
164
+ if (kv) {
165
+ const value = (kv[2] ?? "").trim();
166
+ if (!CMD_KEYS.has(kv[1])) continue;
167
+ const at = item ? indent + 2 : indent;
168
+ if (!value) scope = { indent: at, block: false };
169
+ else if (/^[|>][+-]?\d*$/.test(value)) scope = { indent: at, block: true };
170
+ else out.push(value.replace(/^["']|["']$/g, ""));
171
+ } else if (scope && item) {
172
+ // An item of a command list: itself a command, or the start of a block scalar.
173
+ if (/^[|>][+-]?\d*$/.test(body)) scope = { indent, block: true };
174
+ else out.push(body.replace(/^["']|["']$/g, ""));
175
+ }
176
+ }
177
+ return out;
178
+ }
179
+
180
+ /**
181
+ * @param {string} root
182
+ * @param {string} rel
183
+ * @param {number} depth
184
+ * @param {(rel: string) => void} add
185
+ */
186
+ function walk(root, rel, depth, add) {
187
+ if (depth < 0) return;
188
+ let entries;
189
+ try {
190
+ entries = readdirSync(join(root, rel), { withFileTypes: true });
191
+ } catch {
192
+ return;
193
+ }
194
+ for (const e of entries) {
195
+ const child = `${rel}/${e.name}`;
196
+ if (e.isDirectory() && e.name !== "node_modules") walk(root, child, depth - 1, add);
197
+ // Shell scripts and step files; a JavaScript file's strings are not commands it runs.
198
+ else if (/\.(sh|bash|ps1|cmd|bat|ya?ml)$/.test(e.name)) add(child);
199
+ }
200
+ }
201
+
202
+ /**
203
+ * @param {string} name
204
+ * @param {NodeJS.ProcessEnv} env
205
+ * @param {NodeJS.Platform} platform
206
+ */
207
+ function whichOnPath(name, env, platform) {
208
+ const dirs = (env.PATH ?? env.Path ?? "").split(delimiter).filter(Boolean);
209
+ const exts = platform === "win32" ? ["", ...(env.PATHEXT ?? ".EXE;.CMD;.BAT").split(";").map((e) => e.toLowerCase())] : [""];
210
+ for (const dir of dirs) {
211
+ for (const ext of exts) {
212
+ const full = join(dir, name + ext);
213
+ try {
214
+ if (statSync(full).isFile()) return full;
215
+ } catch {
216
+ /* not here */
217
+ }
218
+ }
219
+ }
220
+ return null;
221
+ }
222
+
223
+ /** @param {string | null} text */
224
+ export function parseJson(text) {
225
+ if (text == null) return null;
226
+ try {
227
+ // A leading byte-order mark is no part of the JSON: npm and editors read past it.
228
+ return JSON.parse(String(text).replace(/^\uFEFF/, ""));
229
+ } catch {
230
+ return null;
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @param {Record<string, any>} obj
236
+ * @param {string[]} skip
237
+ */
238
+ function stripUndefined(obj, skip) {
239
+ /** @type {Record<string, any>} */
240
+ const out = {};
241
+ for (const [k, v] of Object.entries(obj)) if (v !== undefined && !skip.includes(k) && k !== "env") out[k] = v;
242
+ return out;
243
+ }
244
+
245
+ /**
246
+ * Every dependency the repository declares, in any ecosystem Trellis reads (npm, Python, Go, Rust,
247
+ * PHP, Ruby), at its root and in its workspaces: a monorepo's front end lives in `apps/web`, not
248
+ * in the root package.json.
249
+ * @param {Context} ctx
250
+ */
251
+ export function dependencyNames(ctx) {
252
+ const names = new Set();
253
+ for (const dir of ["", ...workspaces(ctx)]) {
254
+ for (const file of MANIFESTS) {
255
+ const rel = dir ? `${dir}/${file}` : file;
256
+ const text = ctx.read(rel);
257
+ if (text != null) for (const d of dependenciesOf(rel, text) ?? []) names.add(d);
258
+ }
259
+ }
260
+ return names;
261
+ }
262
+
263
+ /**
264
+ * Workspace folders, from package.json `workspaces` and pnpm-workspace.yaml. Patterns of the form
265
+ * `dir/*` and exact folders are read; deeper globs are left out rather than guessed.
266
+ * @param {Context} ctx
267
+ */
268
+ function workspaces(ctx) {
269
+ const p = ctx.pkg();
270
+ const patterns = [...(Array.isArray(p.workspaces) ? p.workspaces : (p.workspaces?.packages ?? []))];
271
+ for (const m of (ctx.read("pnpm-workspace.yaml") ?? "").matchAll(/^\s*-\s*['"]?([^'"\n#]+?)['"]?\s*$/gm)) patterns.push(m[1]);
272
+ const dirs = [];
273
+ for (const pat of patterns) {
274
+ const clean = String(pat).replace(/\/$/, "");
275
+ if (clean.startsWith("!")) continue;
276
+ if (clean.endsWith("/*")) for (const d of ctx.list(clean.slice(0, -2))) dirs.push(`${clean.slice(0, -2)}/${d}`);
277
+ else if (!clean.includes("*")) dirs.push(clean);
278
+ }
279
+ return dirs.slice(0, 200);
280
+ }