@next-generation-collective/factory 0.0.0-stage → 0.3.1

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/lib/config.js ADDED
@@ -0,0 +1,97 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { basename, join } from "node:path";
3
+ import { normaliseAgent } from "./agents.js";
4
+
5
+ export const FACTORY_DIR = ".factory";
6
+ export const CONFIG_FILE = "factory.config.json";
7
+
8
+ export function paths(root) {
9
+ const dir = join(root, FACTORY_DIR);
10
+ return {
11
+ root,
12
+ dir,
13
+ config: join(root, CONFIG_FILE),
14
+ state: join(dir, "state.json"),
15
+ evidence: join(dir, "evidence"),
16
+ lastVerify: join(dir, "last-verify.json"),
17
+ prompts: join(dir, "prompts"),
18
+ roles: join(dir, "roles"),
19
+ templates: join(dir, "templates"),
20
+ idea: join(dir, "idea.txt"),
21
+ report: join(dir, "report.md"),
22
+ };
23
+ }
24
+
25
+ export function ensure(root) {
26
+ const p = paths(root);
27
+ for (const dir of [p.dir, p.evidence, p.prompts, p.roles, p.templates]) {
28
+ mkdirSync(dir, { recursive: true });
29
+ }
30
+ return p;
31
+ }
32
+
33
+ export function defaultConfig(root) {
34
+ return {
35
+ name: basename(root),
36
+ kind: "other",
37
+ whoDepends: [],
38
+ neverBreak: "",
39
+ personalData: "unknown",
40
+ agent: {
41
+ command: "claude",
42
+ args: ["-p", "--permission-mode", "acceptEdits"],
43
+ },
44
+ spec: {
45
+ active: null,
46
+ required: true,
47
+ },
48
+ checks: {
49
+ budgetKb: 1500,
50
+ maxFileKb: 700,
51
+ timeoutMinutes: 10,
52
+ },
53
+ createdAt: new Date().toISOString(),
54
+ };
55
+ }
56
+
57
+ export function readConfig(root) {
58
+ const base = defaultConfig(root);
59
+ const file = paths(root).config;
60
+ if (!existsSync(file)) return base;
61
+ let raw;
62
+ try {
63
+ raw = JSON.parse(readFileSync(file, "utf8"));
64
+ } catch {
65
+ return base;
66
+ }
67
+ return {
68
+ ...base,
69
+ ...raw,
70
+ agent: raw.agent ? normaliseAgent(raw.agent) : base.agent,
71
+ spec: { ...base.spec, ...(raw.spec ?? {}) },
72
+ checks: { ...base.checks, ...(raw.checks ?? {}) },
73
+ };
74
+ }
75
+
76
+ export function writeConfig(root, config) {
77
+ ensure(root);
78
+ writeFileSync(paths(root).config, `${JSON.stringify(config, null, 2)}\n`);
79
+ return config;
80
+ }
81
+
82
+ export function readState(root) {
83
+ const file = paths(root).state;
84
+ if (!existsSync(file)) return { lastVerify: null };
85
+ try {
86
+ return JSON.parse(readFileSync(file, "utf8"));
87
+ } catch {
88
+ return { lastVerify: null };
89
+ }
90
+ }
91
+
92
+ export function writeState(root, patch) {
93
+ const next = { ...readState(root), ...patch };
94
+ ensure(root);
95
+ writeFileSync(paths(root).state, `${JSON.stringify(next, null, 2)}\n`);
96
+ return next;
97
+ }
package/lib/detect.js ADDED
@@ -0,0 +1,184 @@
1
+ import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+
4
+ const WEB = new Set([
5
+ "next", "nuxt", "svelte", "sveltekit", "astro", "remix", "vite", "react",
6
+ "react-dom", "vue", "angular", "@angular/core", "solid-js", "gatsby",
7
+ ]);
8
+
9
+ const API = new Set(["express", "fastify", "koa", "hono", "@hapi/hapi", "nest", "@nestjs/core"]);
10
+
11
+ const BUILD_DIRS = ["dist", "build", "out", ".next/static", "public/build"];
12
+
13
+ // .next/static is left behind by `next dev` too; only a production build writes BUILD_ID.
14
+ export function buildOutputDirs(root, base = "") {
15
+ return BUILD_DIRS.filter((name) => {
16
+ const dir = join(root, base, name);
17
+ if (!existsSync(dir)) return false;
18
+ return name !== ".next/static" || existsSync(join(root, base, ".next", "BUILD_ID"));
19
+ }).map((name) => (base ? `${base}/${name}` : name));
20
+ }
21
+
22
+ function readJson(file) {
23
+ try {
24
+ return JSON.parse(readFileSync(file, "utf8"));
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function isDir(path) {
31
+ try {
32
+ return statSync(path).isDirectory();
33
+ } catch {
34
+ return false;
35
+ }
36
+ }
37
+
38
+ function jsPackageManager(root) {
39
+ if (existsSync(join(root, "pnpm-lock.yaml"))) return "pnpm";
40
+ if (existsSync(join(root, "yarn.lock"))) return "yarn";
41
+ if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock"))) return "bun";
42
+ return "npm";
43
+ }
44
+
45
+ function pythonPackageManager(root) {
46
+ if (existsSync(join(root, "uv.lock"))) return "uv";
47
+ if (existsSync(join(root, "poetry.lock"))) return "poetry";
48
+ return "pip";
49
+ }
50
+
51
+ function pythonTestCommand(root, pm) {
52
+ if (pm === "uv") return "uv run pytest -q";
53
+ if (pm === "poetry") return "poetry run pytest -q";
54
+ const windows = process.platform === "win32";
55
+ const venv = windows ? join(".venv", "Scripts", "python") : join(".venv", "bin", "python");
56
+ if (existsSync(join(root, venv)) || existsSync(join(root, `${venv}.exe`))) return `${venv} -m pytest -q`;
57
+ return `${windows ? "python" : "python3"} -m pytest -q`;
58
+ }
59
+
60
+ function isPlaceholder(script) {
61
+ return !script || /no test specified|no build specified/i.test(script);
62
+ }
63
+
64
+ // One package: what kind of project it is, from its package.json or language files.
65
+ function detectOne(dir, pkg) {
66
+ const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
67
+ const has = (name) => Boolean(deps[name]);
68
+
69
+ if (has("expo")) return { kind: "mobile", framework: "expo", language: "javascript" };
70
+ if (has("react-native")) return { kind: "mobile", framework: "react-native", language: "javascript" };
71
+ if (has("next")) return { kind: "web", framework: "next", language: "javascript" };
72
+ if (has("nuxt")) return { kind: "web", framework: "nuxt", language: "javascript" };
73
+ if (has("@angular/core")) return { kind: "web", framework: "angular", language: "typescript" };
74
+ if (has("svelte") || has("sveltekit")) return { kind: "web", framework: "svelte", language: "javascript" };
75
+ if (has("astro")) return { kind: "web", framework: "astro", language: "javascript" };
76
+ if ([...WEB].some(has)) return { kind: "web", framework: [...WEB].find(has), language: "javascript" };
77
+ if ([...API].some(has)) return { kind: "api", framework: [...API].find(has), language: "javascript" };
78
+ if (pkg) return { kind: pkg.bin ? "cli" : "script", framework: null, language: "javascript" };
79
+
80
+ if (existsSync(join(dir, "index.html"))) return { kind: "web", framework: "static", language: "html" };
81
+ if (existsSync(join(dir, "pyproject.toml")) || existsSync(join(dir, "requirements.txt"))) {
82
+ return { kind: "script", framework: "python", language: "python" };
83
+ }
84
+ if (existsSync(join(dir, "go.mod"))) return { kind: "api", framework: "go", language: "go" };
85
+ if (existsSync(join(dir, "Cargo.toml"))) return { kind: "script", framework: "rust", language: "rust" };
86
+ return { kind: "other", framework: null, language: null };
87
+ }
88
+
89
+ function workspacePatterns(root, pkg) {
90
+ const fromPkg = Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
91
+ if (Array.isArray(fromPkg)) return fromPkg;
92
+ const pnpm = join(root, "pnpm-workspace.yaml");
93
+ if (!existsSync(pnpm)) return [];
94
+ return [...readFileSync(pnpm, "utf8").matchAll(/^\s*-\s*["']?([^"'\n#]+?)["']?\s*$/gm)].map((m) => m[1]);
95
+ }
96
+
97
+ // Expands "apps/*" and "packages/web" style patterns. Deeper globs are skipped.
98
+ export function findWorkspaces(root, pkg) {
99
+ const found = [];
100
+ for (const pattern of workspacePatterns(root, pkg)) {
101
+ if (pattern.startsWith("!")) continue;
102
+ const clean = pattern.replace(/\/+$/, "");
103
+ if (clean.endsWith("/*") && !clean.slice(0, -2).includes("*")) {
104
+ const base = clean.slice(0, -2);
105
+ if (!isDir(join(root, base))) continue;
106
+ for (const name of readdirSync(join(root, base)).sort()) {
107
+ const rel = `${base}/${name}`;
108
+ if (existsSync(join(root, rel, "package.json"))) found.push(rel);
109
+ }
110
+ } else if (!clean.includes("*") && existsSync(join(root, clean, "package.json"))) {
111
+ found.push(clean);
112
+ }
113
+ }
114
+ return [...new Set(found)];
115
+ }
116
+
117
+ const PRIMARY_ORDER = ["mobile", "web", "api", "cli", "script"];
118
+
119
+ export function detectStack(root) {
120
+ const pkg = existsSync(join(root, "package.json")) ? readJson(join(root, "package.json")) : null;
121
+ const scripts = pkg?.scripts ?? {};
122
+
123
+ const workspaces = findWorkspaces(root, pkg).map((path) => {
124
+ const wpkg = readJson(join(root, path, "package.json"));
125
+ return { path, pkg: wpkg, ...detectOne(join(root, path), wpkg) };
126
+ });
127
+
128
+ const own = detectOne(root, pkg);
129
+ let primary = null;
130
+ if (workspaces.length) {
131
+ for (const kind of PRIMARY_ORDER) {
132
+ primary = workspaces.find((w) => w.kind === kind);
133
+ if (primary) break;
134
+ }
135
+ primary = primary ?? workspaces[0];
136
+ }
137
+
138
+ const base = primary ?? own;
139
+ const language = base.language;
140
+ const packageManager =
141
+ language === "python" ? pythonPackageManager(root)
142
+ : language === "go" ? "go"
143
+ : language === "rust" ? "cargo"
144
+ : language ? jsPackageManager(root) : null;
145
+
146
+ let testCommand = null;
147
+ let buildCommand = null;
148
+ let commandCwd = null;
149
+ const primaryScripts = primary?.pkg?.scripts ?? {};
150
+
151
+ if (!isPlaceholder(scripts.test)) testCommand = `${packageManager} test`;
152
+ else if (primary && !isPlaceholder(primaryScripts.test)) {
153
+ testCommand = `${packageManager} test`;
154
+ commandCwd = primary.path;
155
+ } else if (language === "python") {
156
+ const hasTests = ["tests", "test", "pytest.ini"].some((name) => existsSync(join(root, name)));
157
+ if (hasTests) testCommand = pythonTestCommand(root, packageManager);
158
+ } else if (language === "go") testCommand = "go test ./...";
159
+ else if (language === "rust") testCommand = "cargo test";
160
+
161
+ if (!isPlaceholder(scripts.build)) buildCommand = `${packageManager} run build`;
162
+ else if (primary && !isPlaceholder(primaryScripts.build)) {
163
+ buildCommand = `${packageManager} run build`;
164
+ commandCwd = commandCwd ?? primary.path;
165
+ } else if (language === "go") buildCommand = "go build ./...";
166
+ else if (language === "rust") buildCommand = "cargo build";
167
+
168
+ const buildDirs = buildOutputDirs(root);
169
+ if (primary) buildDirs.push(...buildOutputDirs(root, primary.path));
170
+
171
+ return {
172
+ kind: base.kind,
173
+ framework: base.framework,
174
+ language,
175
+ packageManager,
176
+ testCommand,
177
+ buildCommand,
178
+ commandCwd,
179
+ buildDirs,
180
+ hasPackageJson: Boolean(pkg),
181
+ workspaces: workspaces.map(({ path, kind, framework }) => ({ path, kind, framework })),
182
+ primary: primary?.path ?? null,
183
+ };
184
+ }
package/lib/fixes.js ADDED
@@ -0,0 +1,90 @@
1
+ // The first run offers to fix the top problem it found, so a person sees the whole
2
+ // factory work on their own project. Only problems with a clear, small, checkable fix are
3
+ // offered; personal-data and offline findings are for a person to judge.
4
+
5
+ function find(results, id) {
6
+ return results.find((result) => result.id === id);
7
+ }
8
+
9
+ export function pickFix(results) {
10
+ const secrets = find(results, "secrets");
11
+ if (secrets?.status === "fail") {
12
+ const hit = secrets.details.find((detail) => /:\d+\s{2}/.test(detail));
13
+ if (hit) {
14
+ const where = hit.split(/\s{2}/)[0];
15
+ return {
16
+ id: "secret",
17
+ requires: ["secrets"],
18
+ problem: `A credential is written in the code at ${where}`,
19
+ title: "move a credential out of the code",
20
+ job:
21
+ `Move the credential at ${where} out of the code. Read it from an environment variable instead, ` +
22
+ "named for what it is for, and add that name with an empty value to .env.example. Never print, " +
23
+ "log or copy the value, and never put it in the spec, the plan, a test or a commit message. " +
24
+ "Note for the person that the old value must be changed (rotated), because it is in the git history.",
25
+ };
26
+ }
27
+ if (secrets.details.some((detail) => detail.includes(".env is tracked by git"))) {
28
+ return {
29
+ id: "env",
30
+ requires: ["secrets"],
31
+ problem: ".env, which holds secrets, is tracked by git",
32
+ title: "stop tracking .env in git",
33
+ job:
34
+ "Stop tracking .env in git (git rm --cached .env, keeping the file on disk), add .env to " +
35
+ ".gitignore, and add a .env.example with the variable names only, never the values. Note for " +
36
+ "the person that every value in it must be changed (rotated), because it is in the git history.",
37
+ };
38
+ }
39
+ }
40
+
41
+ const proof = find(results, "test-proof");
42
+ if (proof?.status === "fail") {
43
+ const empty = proof.details.find((detail) => /no assertion/.test(detail));
44
+ if (empty) {
45
+ const file = empty.split(":")[0];
46
+ return {
47
+ id: "assertions",
48
+ requires: ["tests", "test-proof"],
49
+ problem: `The tests in ${file} never check anything`,
50
+ title: "make the tests in one file prove something",
51
+ job:
52
+ `Make the tests in ${file} prove something. Each test should check the outcome (the data ` +
53
+ "written, the value returned, the record read back), not only that the code ran or what the " +
54
+ "screen says. Run the tests and show that each new check fails if the code it covers is broken.",
55
+ };
56
+ }
57
+ if (/No tests found/.test(proof.summary)) {
58
+ return {
59
+ id: "first-test",
60
+ requires: ["tests", "test-proof"],
61
+ problem: "This project has no automated tests, so nothing can be proven",
62
+ title: "add the first automated test",
63
+ job:
64
+ "Add the first automated test for the most important thing this project does, with the usual " +
65
+ "test tool for its stack. Add a test command to the project if it has none, so " +
66
+ '"factory verify --deep" can run it. The test checks an outcome, not a label on the screen.',
67
+ };
68
+ }
69
+ }
70
+ return null;
71
+ }
72
+
73
+ // A few lines a person can paste to their team. File names and line numbers only, never
74
+ // a value.
75
+ export function fixSummary({ project, fix, run, approval, diffStat, review }) {
76
+ const proved = run.results.filter((result) => result.kind === "proven" && result.status === "pass");
77
+ const notChecked = run.results.filter((result) => result.status === "na" || result.status === "not-run");
78
+ const lines = [
79
+ `factory: ${project}`,
80
+ `Found: ${fix.problem}.`,
81
+ `Fixed: ${fix.title}${approval ? `, approved by ${approval.approvedBy}` : ""}.`,
82
+ ];
83
+ if (diffStat) lines.push(`Changed: ${diffStat}`);
84
+ if (proved.length) lines.push(`Proved by: ${proved.map((result) => result.title.toLowerCase()).join(", ")}.`);
85
+ if (review) lines.push(`Review: ${review.summary}.`);
86
+ if (review?.unproven?.length) lines.push(`For a person to check: ${review.unproven.join("; ")}`);
87
+ if (notChecked.length) lines.push(`Not checked: ${notChecked.map((result) => result.title.toLowerCase()).join(", ")}.`);
88
+ lines.push("Made with factory: npx @next-generation-collective/factory");
89
+ return `${lines.join("\n")}\n`;
90
+ }
@@ -0,0 +1,64 @@
1
+ export const QUESTIONS = [
2
+ {
3
+ key: "problem",
4
+ q: "What is wrong today? One sentence.",
5
+ hint: "the problem, not the solution",
6
+ },
7
+ {
8
+ key: "who",
9
+ q: "Who feels it, and what do they do instead right now?",
10
+ hint: "the person, not the department",
11
+ },
12
+ {
13
+ key: "outcome",
14
+ q: "How will you know it works? Name one result you can check.",
15
+ hint: "the booking exists, the message arrives, the file is in storage",
16
+ },
17
+ {
18
+ key: "never",
19
+ q: "What must never break while we change this?",
20
+ hint: "the thing you would be called about",
21
+ },
22
+ {
23
+ key: "people",
24
+ q: "Who else sees or depends on this?",
25
+ hint: "a second account, an admin, a client, a funder",
26
+ },
27
+ {
28
+ key: "states",
29
+ q: "Which situations worry you?",
30
+ hint: "offline, slow, two people at once, no permission, small screen",
31
+ },
32
+ {
33
+ key: "out",
34
+ q: "What is out of scope for this change?",
35
+ hint: "what we are deliberately not doing",
36
+ },
37
+ ];
38
+
39
+ // Asks each question in turn. Only "problem" must be answered; the rest may be skipped.
40
+ export async function askList(rl, questions, { log = () => {} } = {}) {
41
+ const answers = {};
42
+ for (const item of questions) {
43
+ log("");
44
+ log(item.q);
45
+ if (item.hint) log(`(${item.hint})`);
46
+ let answer = "";
47
+ while (!answer) {
48
+ answer = (await rl.question("> ")).trim();
49
+ if (!answer) {
50
+ if (item.key === "problem") {
51
+ log("One sentence is enough. The spec needs a problem.");
52
+ continue;
53
+ }
54
+ break;
55
+ }
56
+ }
57
+ answers[item.key] = answer;
58
+ }
59
+ return answers;
60
+ }
61
+
62
+ export function askQuestions(rl, options = {}) {
63
+ return askList(rl, QUESTIONS, options);
64
+ }
@@ -0,0 +1,222 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { closeSync, existsSync, openSync, readFileSync, statSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { isAbsolute, join, resolve } from "node:path";
5
+ import { presetName } from "./agents.js";
6
+
7
+ const WINDOWS = process.platform === "win32";
8
+ const LOCAL = /^(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\])$/i;
9
+
10
+ function readJson(file) {
11
+ try {
12
+ return JSON.parse(readFileSync(file, "utf8"));
13
+ } catch {
14
+ return null;
15
+ }
16
+ }
17
+
18
+ // MCP servers that drive a browser: in the project (.mcp.json, .claude/settings.json) and
19
+ // in Claude Code's own config (~/.claude.json: for every project, and for this one). Only
20
+ // names are read, never their settings' values.
21
+ export function browserServers(root, home = homedir()) {
22
+ const found = new Map();
23
+ const add = (servers) => {
24
+ for (const [name, settings] of Object.entries(servers ?? {})) {
25
+ const text = `${name} ${JSON.stringify(settings ?? {})}`;
26
+ const browser = /chrome|chromium/i.test(text) ? "chrome" : /firefox/i.test(text) ? "firefox" : null;
27
+ if (browser && !found.has(name)) found.set(name, { name, browser });
28
+ }
29
+ };
30
+ add(readJson(join(root, ".mcp.json"))?.mcpServers);
31
+ add(readJson(join(root, ".claude", "settings.json"))?.mcpServers);
32
+ const user = readJson(join(home, ".claude.json"));
33
+ add(user?.projects?.[resolve(root)]?.mcpServers);
34
+ add(user?.mcpServers);
35
+ return [...found.values()];
36
+ }
37
+
38
+ // Which tool the agent uses to work the app like a person, and what it may run.
39
+ export function pickDriver(root, analysis, config, { home } = {}) {
40
+ if (config.journeys?.enabled === false) return { driver: null, reason: "journeys.enabled is false in factory.config.json" };
41
+ const kind = analysis.stack.kind;
42
+ if (kind === "web") {
43
+ if (analysis.drivers.includes("playwright")) return { driver: "playwright", tools: ["Bash(npx playwright:*)"] };
44
+ const servers = browserServers(root, home);
45
+ const pick = servers.find((server) => server.browser === "chrome") ?? servers.find((server) => server.browser === "firefox");
46
+ if (pick) return { driver: `${pick.browser}-devtools-mcp`, server: pick.name, tools: [`mcp__${pick.name}`] };
47
+ return {
48
+ driver: null,
49
+ reason: "No browser tool found. Add the Chrome DevTools or Firefox DevTools MCP server to your agent, or Playwright to the project",
50
+ };
51
+ }
52
+ if (kind === "mobile") {
53
+ if (analysis.drivers.includes("maestro")) return { driver: "maestro", tools: ["Bash(maestro:*)", "Bash(./scripts/e2e.sh:*)"] };
54
+ return { driver: null, reason: "No Maestro flows found (.maestro/); phone journeys need Maestro and a running simulator" };
55
+ }
56
+ return { driver: null, reason: "Not a website or phone app" };
57
+ }
58
+
59
+ // The agent that walks the journeys. Claude Code is given exactly the driver's tools;
60
+ // other agents need agent.journeys set to a command that can drive the browser.
61
+ export function journeyAgent(config, info) {
62
+ if (config.agent.journeys) return { ...config, agent: { ...config.agent.journeys } };
63
+ if (presetName(config.agent.command) !== "claude") return null;
64
+ return { ...config, agent: { ...config.agent, args: [...(config.agent.args ?? []), "--allowedTools", ...info.tools] } };
65
+ }
66
+
67
+ export function appUrlAllowed(url, config) {
68
+ try {
69
+ return LOCAL.test(new URL(url).hostname) || config.checks?.allowRemoteApp === true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ // How to start the app: the dev or start script through the package manager, so local
76
+ // binaries are on the path.
77
+ export function startCommand(root, stack) {
78
+ for (const base of ["", stack.primary].filter((b) => b !== null && b !== undefined)) {
79
+ const scripts = readJson(join(root, base, "package.json"))?.scripts ?? {};
80
+ const pm = stack.packageManager ?? "npm";
81
+ if (scripts.dev) return { command: `${pm} run dev`, cwd: base || null };
82
+ if (scripts.start) return { command: `${pm} run start`, cwd: base || null };
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function sleep(ms) {
88
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
89
+ }
90
+
91
+ export function httpUp(url) {
92
+ const probe = "fetch(process.argv[1]).then((r) => process.exit(r.status < 500 ? 0 : 1), () => process.exit(1))";
93
+ return spawnSync(process.execPath, ["-e", probe, url], { timeout: 5000, stdio: "ignore" }).status === 0;
94
+ }
95
+
96
+ function alive(pid) {
97
+ try {
98
+ process.kill(pid, 0);
99
+ return true;
100
+ } catch {
101
+ return false;
102
+ }
103
+ }
104
+
105
+ // A port nothing is using, so the app under test never collides with one already running
106
+ // (a person's own dev server is often on 3000).
107
+ export function freePort() {
108
+ const script = 'const s=require("net").createServer();s.listen(0,"127.0.0.1",()=>{process.stdout.write(String(s.address().port));s.close()})';
109
+ const run = spawnSync(process.execPath, ["-e", script], { encoding: "utf8", timeout: 5000 });
110
+ const port = Number.parseInt(run.stdout, 10);
111
+ return Number.isFinite(port) ? port : 4317;
112
+ }
113
+
114
+ const LOCAL_URL = /https?:\/\/(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]):\d+/g;
115
+
116
+ // Starts the app in the background on its own port, writing its output to logFile, and
117
+ // waits until it answers. It trusts only the address this app prints, or the port it was
118
+ // given; an address that already answered before the app started belongs to something
119
+ // else, and is never used.
120
+ export function startApp(root, { command, cwd }, { logFile, url = null, timeoutSeconds = 60 } = {}) {
121
+ if (url && httpUp(url)) {
122
+ return {
123
+ error: `something is already answering at ${url}, perhaps another copy of the app. Stop it, or change checks.appUrl`,
124
+ stop: () => {},
125
+ };
126
+ }
127
+ const port = freePort();
128
+ const fd = openSync(logFile, "w");
129
+ const child = spawn(command, {
130
+ cwd: cwd ? join(root, cwd) : root,
131
+ shell: true,
132
+ detached: !WINDOWS,
133
+ stdio: ["ignore", fd, fd],
134
+ env: { ...process.env, BROWSER: "none", PORT: String(port) },
135
+ });
136
+ closeSync(fd);
137
+ child.unref();
138
+
139
+ const stop = () => {
140
+ try {
141
+ if (WINDOWS) spawnSync("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore" });
142
+ else process.kill(-child.pid, "SIGTERM");
143
+ } catch {}
144
+ };
145
+
146
+ const ours = `http://localhost:${port}`;
147
+ const deadline = Date.now() + timeoutSeconds * 1000;
148
+ while (Date.now() < deadline) {
149
+ if (!alive(child.pid)) return { error: "the app stopped before it answered", stop };
150
+ const printed = [...readFileSync(logFile, "utf8").matchAll(LOCAL_URL)].map((m) => m[0].replace("0.0.0.0", "localhost"));
151
+ const candidates = [...new Set([url, ...printed, ours].filter(Boolean))];
152
+ const answering = candidates.find((candidate) => httpUp(candidate));
153
+ if (answering) return { url: answering, port, stop };
154
+ sleep(1000);
155
+ }
156
+ stop();
157
+ return { error: `the app did not answer within ${timeoutSeconds} seconds`, stop };
158
+ }
159
+
160
+ function rows(text) {
161
+ return String(text)
162
+ .split("\n")
163
+ .map((line) => line.trim())
164
+ .filter((line) => line.startsWith("|") && !/^\|[\s|:-]+\|$/.test(line))
165
+ .map((line) =>
166
+ line
167
+ .slice(1, -1)
168
+ .split("|")
169
+ .map((cell) => cell.trim()),
170
+ )
171
+ .filter((cells) => cells.length >= 4 && !/^check$/i.test(cells[0]));
172
+ }
173
+
174
+ const result = (status, summary, details = [], evidence = []) => ({
175
+ id: "journeys",
176
+ title: "Used the app",
177
+ kind: "proven",
178
+ status,
179
+ summary,
180
+ details,
181
+ evidence,
182
+ });
183
+
184
+ export function journeysResultNa(reason) {
185
+ return result("na", reason);
186
+ }
187
+
188
+ export function journeysResultNotRun(reason, details = [], evidence = []) {
189
+ return result("not-run", reason, details, evidence);
190
+ }
191
+
192
+ // Every journey marked PASS must point at a screenshot (or, for Maestro, an output file)
193
+ // that exists. A claim with no picture is not proof.
194
+ export function checkJourneys(root, dir) {
195
+ const file = join(dir, "journeys.md");
196
+ const shown = (path) => path.replace(`${root}/`, "");
197
+ if (!existsSync(file)) return result("fail", "No journeys recorded: the agent did not write journeys.md");
198
+ const all = rows(readFileSync(file, "utf8"));
199
+ if (!all.length) return result("fail", "journeys.md has no journeys in its table", [], [shown(file)]);
200
+ if (all.every((cells) => /^skip$/i.test(cells[3]))) return result("na", "Nothing a person sees changed", [], [shown(file)]);
201
+
202
+ const problems = [];
203
+ let passed = 0;
204
+ for (const [check, , saw, verdict, picture = ""] of all) {
205
+ if (/^skip$/i.test(verdict)) continue;
206
+ if (/^fail$/i.test(verdict)) {
207
+ problems.push(`${check}: FAIL. ${saw}`);
208
+ continue;
209
+ }
210
+ const path = picture.replace(/^`|`$/g, "");
211
+ const full = isAbsolute(path) ? path : existsSync(join(root, path)) ? join(root, path) : join(dir, path);
212
+ let ok = false;
213
+ try {
214
+ ok = Boolean(path) && statSync(full).size > 0;
215
+ } catch {}
216
+ if (/^pass$/i.test(verdict) && ok) passed++;
217
+ else problems.push(`${check}: marked ${verdict || "nothing"} with no screenshot at ${path || "(none given)"}`);
218
+ }
219
+
220
+ if (problems.length) return result("fail", `${problems.length} ${problems.length === 1 ? "journey" : "journeys"} not proven`, problems, [shown(file)]);
221
+ return result("pass", `${passed} ${passed === 1 ? "journey" : "journeys"} worked, each with a screenshot`, [], [shown(file)]);
222
+ }