@arjunkhera/atlas 0.3.7 → 0.3.9

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 (67) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/artifact-renderer.md +22 -22
  3. package/agents/verifier.md +29 -0
  4. package/door/cli.mjs +62 -12
  5. package/door/kit-releases.json +4 -0
  6. package/door/lib/design-build.mjs +409 -0
  7. package/door/lib/design.mjs +199 -118
  8. package/door/lib/markdown.mjs +160 -0
  9. package/door/lib/proof.mjs +127 -0
  10. package/door/lib/tests.mjs +190 -0
  11. package/package.json +2 -1
  12. package/skills/lead/SKILL.md +4 -1
  13. package/skills/sdlc-task/SKILL.md +33 -24
  14. package/skills/sdlc-task/design/README.md +187 -0
  15. package/skills/sdlc-task/design/parts/actors.md +26 -0
  16. package/skills/sdlc-task/design/parts/alternatives.md +24 -0
  17. package/skills/sdlc-task/design/parts/build.md +23 -0
  18. package/skills/sdlc-task/design/parts/calls.md +25 -0
  19. package/skills/sdlc-task/design/parts/change.md +27 -0
  20. package/skills/sdlc-task/design/parts/data.md +22 -0
  21. package/skills/sdlc-task/design/parts/done.md +23 -0
  22. package/skills/sdlc-task/design/parts/edges.md +24 -0
  23. package/skills/sdlc-task/design/parts/goals.md +27 -0
  24. package/skills/sdlc-task/design/parts/key.md +25 -0
  25. package/skills/sdlc-task/design/parts/migration.md +22 -0
  26. package/skills/sdlc-task/design/parts/order.md +24 -0
  27. package/skills/sdlc-task/design/parts/problem.md +22 -0
  28. package/skills/sdlc-task/design/parts/proof.md +24 -0
  29. package/skills/sdlc-task/design/parts/proposal.md +24 -0
  30. package/skills/sdlc-task/design/parts/records.md +24 -0
  31. package/skills/sdlc-task/design/parts/repos.md +25 -0
  32. package/skills/sdlc-task/design/parts/risks.md +24 -0
  33. package/skills/sdlc-task/design/parts/rollout.md +24 -0
  34. package/skills/sdlc-task/design/parts/routes.md +24 -0
  35. package/skills/sdlc-task/design/parts/scorecard.md +25 -0
  36. package/skills/sdlc-task/design/parts/security.md +22 -0
  37. package/skills/sdlc-task/design/parts/shared-decisions.md +24 -0
  38. package/skills/sdlc-task/design/parts/states.md +25 -0
  39. package/skills/sdlc-task/design/parts/stories.md +26 -0
  40. package/skills/sdlc-task/design/parts/summary.md +33 -0
  41. package/skills/sdlc-task/design/parts/why.md +22 -0
  42. package/skills/sdlc-task/design/parts/words.md +29 -0
  43. package/skills/sdlc-task/design/parts/yardstick.md +25 -0
  44. package/skills/sdlc-task/design/parts.yaml +306 -0
  45. package/skills/sdlc-task/lifecycle.yaml +2 -2
  46. package/skills/tests/SKILL.md +234 -0
  47. package/tests/contract.mjs +398 -0
  48. package/tests/drivers/function.mjs +30 -0
  49. package/tests/drivers/http.mjs +68 -0
  50. package/tests/drivers/index.mjs +65 -0
  51. package/tests/drivers/mcp-stdio.mjs +174 -0
  52. package/tests/environment.mjs +227 -0
  53. package/tests/errors.mjs +27 -0
  54. package/tests/evidence.mjs +113 -0
  55. package/tests/fresh.mjs +42 -0
  56. package/tests/guards.mjs +159 -0
  57. package/tests/index.mjs +11 -0
  58. package/tests/link-check.mjs +576 -0
  59. package/tests/procs.mjs +43 -0
  60. package/tests/redact.mjs +58 -0
  61. package/tests/scenario.mjs +325 -0
  62. package/tests/stand-in.mjs +74 -0
  63. package/tests/tests-yaml.mjs +258 -0
  64. package/tests/wait.mjs +44 -0
  65. package/tests/yaml.mjs +327 -0
  66. package/agents/artifact-format/walkthrough.html +0 -706
  67. package/skills/sdlc-task/templates/design-doc.md +0 -126
@@ -0,0 +1,174 @@
1
+ // The mcp-stdio driver: a small JSON-RPC 2.0 client for an MCP server that
2
+ // speaks over standard input and output, one JSON message on each line (the
3
+ // MCP stdio transport). Atlas has no dependency, so it does not use the
4
+ // official client library. It does what the tests need: initialize, the
5
+ // initialized notification, tools/list and tools/call.
6
+ //
7
+ // The seven duties of a driver (design section 7.2):
8
+ // 1. Before it starts the server, it checks every address in the env that it
9
+ // gives the server against guards.hosts. A server that gets no address is
10
+ // refused, because it could fall back to a real one. The result is `blocked`.
11
+ // 2. A credential goes only into the env of the server it belongs to. Each
12
+ // distinct env starts its own server process.
13
+ // 3. It records each tool call and each answer.
14
+ // 4. Recorded text has run secrets and named secrets removed, by value.
15
+ // 5. It returns the raw answer: { status, body, isError, raw }.
16
+ // `body` is the result of tools/call, or the JSON-RPC error. `raw` is the whole message.
17
+ // Reading the product's error form is the work of the actions.
18
+ // 6. It signs in as a persona: the env of the spec may hold {persona.credential}.
19
+ // 7. It stops each server it started, on close and when the process ends.
20
+ import { Blocked, Unavailable } from '../errors.mjs';
21
+ import { childEnv, requireLocalAddresses } from '../guards.mjs';
22
+ import { spawnManaged } from '../procs.mjs';
23
+ import { clip } from '../evidence.mjs';
24
+
25
+ const PROTOCOL = '2025-06-18';
26
+ // The most that one line of the server may hold. A longer line is refused.
27
+ export const MAX_LINE_BYTES = 4 * 1024 * 1024;
28
+
29
+ class Session {
30
+ constructor({ proc, timeoutMs }) {
31
+ this.proc = proc;
32
+ this.timeoutMs = timeoutMs;
33
+ this.nextId = 1;
34
+ this.pending = new Map();
35
+ this.buffer = '';
36
+ this.closed = false;
37
+ proc.child.stdout.on('data', (chunk) => this.onData(chunk.toString('utf8')));
38
+ proc.child.stdin.on('error', () => { /* the server stopped; the exit handler reports it */ });
39
+ proc.exited.then(({ code, signal }) => {
40
+ this.closed = true;
41
+ for (const { reject } of this.pending.values()) reject(new Unavailable(`the MCP server stopped (${code ?? signal}). Last output: ${proc.tail().slice(-400)}`));
42
+ this.pending.clear();
43
+ });
44
+ }
45
+
46
+ overflow() {
47
+ this.buffer = '';
48
+ this.closed = true;
49
+ const why = new Unavailable(`the MCP server sent a line over ${MAX_LINE_BYTES / (1024 * 1024)} MB; the line was refused and the server was stopped`);
50
+ for (const { reject, timer } of this.pending.values()) { clearTimeout(timer); reject(why); }
51
+ this.pending.clear();
52
+ this.proc.stop();
53
+ }
54
+
55
+ onData(text) {
56
+ if (this.closed) return;
57
+ this.buffer += text;
58
+ let at;
59
+ while ((at = this.buffer.indexOf('\n')) >= 0) {
60
+ if (at > MAX_LINE_BYTES) return this.overflow();
61
+ const line = this.buffer.slice(0, at).trim();
62
+ this.buffer = this.buffer.slice(at + 1);
63
+ if (!line) continue;
64
+ let message;
65
+ try { message = JSON.parse(line); } catch { continue; }
66
+ this.onMessage(message);
67
+ }
68
+ if (this.buffer.length > MAX_LINE_BYTES) this.overflow();
69
+ }
70
+
71
+ onMessage(message) {
72
+ if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
73
+ const waiting = this.pending.get(message.id);
74
+ if (waiting) { this.pending.delete(message.id); clearTimeout(waiting.timer); waiting.resolve(message); }
75
+ } else if (message.id !== undefined && message.method) {
76
+ // The client offers nothing to a server that asks.
77
+ this.send({ jsonrpc: '2.0', id: message.id, error: { code: -32601, message: 'method not found' } });
78
+ }
79
+ }
80
+
81
+ send(message) {
82
+ if (this.closed) throw new Unavailable('the MCP server is not running');
83
+ this.proc.child.stdin.write(`${JSON.stringify(message)}\n`);
84
+ }
85
+
86
+ request(method, params) {
87
+ const id = this.nextId++;
88
+ return new Promise((resolve, reject) => {
89
+ const timer = setTimeout(() => {
90
+ this.pending.delete(id);
91
+ reject(new Unavailable(`the MCP server did not answer ${method} in ${this.timeoutMs} ms`));
92
+ }, this.timeoutMs);
93
+ this.pending.set(id, { resolve, reject, timer });
94
+ try { this.send({ jsonrpc: '2.0', id, method, params }); } catch (error) { clearTimeout(timer); this.pending.delete(id); reject(error); }
95
+ });
96
+ }
97
+
98
+ notify(method, params) { this.send({ jsonrpc: '2.0', method, params }); }
99
+
100
+ async open() {
101
+ // A server that stops or stays mute before it answers initialize never started: `blocked`.
102
+ let init;
103
+ try { init = await this.request('initialize', { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: 'atlas-tests', version: '1' } }); } catch (error) {
104
+ if (error instanceof Unavailable) throw new Blocked(`the MCP server did not start: ${error.message}`);
105
+ throw error;
106
+ }
107
+ if (init.error) throw new Blocked(`the MCP server refused initialize: ${init.error.message}`);
108
+ this.notify('notifications/initialized');
109
+ return init.result;
110
+ }
111
+
112
+ async close() {
113
+ this.closed = true;
114
+ try { this.proc.child.stdin.end(); } catch { /* it is closed */ }
115
+ await this.proc.stop();
116
+ }
117
+ }
118
+
119
+ export function createMcpStdioDriver({ way, command, env: specEnv = {}, cwd, hosts, redactor, record: rawRecord, timeoutMs = 30000 }) {
120
+ const record = rawRecord && ((call) => rawRecord(redactor ? redactor.deep(call) : call));
121
+ if (!command) throw new Error(`the way in "${way}" has no command`);
122
+ const sessions = new Map();
123
+
124
+ const fill = (credential) => Object.fromEntries(Object.entries(specEnv).map(([k, v]) => [k, String(v).replaceAll('{persona.credential}', credential ?? '')]));
125
+
126
+ async function sessionFor(credential) {
127
+ const given = fill(credential);
128
+ const key = JSON.stringify(given);
129
+ if (sessions.has(key)) return sessions.get(key);
130
+ // Duty 1: check the address before the server starts.
131
+ // The check runs on the whole env the server gets, not only on the part the way in sets.
132
+ const full = childEnv(process.env, given);
133
+ requireLocalAddresses(given, hosts, `the MCP server of "${way}"`, full);
134
+ const proc = spawnManaged({ command, cwd, redactor, stdin: true, env: full });
135
+ const session = new Session({ proc, timeoutMs });
136
+ sessions.set(key, session);
137
+ try { await session.open(); } catch (error) { sessions.delete(key); await session.close(); throw error; }
138
+ return session;
139
+ }
140
+
141
+ // request: { tool, arguments } for tools/call, or { method, params } for any other call.
142
+ async function call(request = {}, { credential = null, persona = null } = {}) {
143
+ const session = await sessionFor(credential);
144
+ const method = request.tool ? 'tools/call' : request.method;
145
+ const params = request.tool ? { name: request.tool, arguments: request.arguments ?? {} } : request.params;
146
+ if (!method) throw new Error('an MCP request names a tool, or a method');
147
+ const message = await session.request(method, params);
148
+ const failed = message.error !== undefined;
149
+ const body = failed ? message.error : message.result;
150
+ const answer = {
151
+ status: failed ? message.error.code : (body?.isError ? 'tool-error' : 'ok'),
152
+ body, isError: failed || body?.isError === true, raw: message,
153
+ };
154
+ record?.({
155
+ way, persona,
156
+ request: { method, params: clip(params, redactor) },
157
+ response: { status: answer.status, body: clip(body, redactor) },
158
+ });
159
+ return answer;
160
+ }
161
+
162
+ async function listTools(opts) {
163
+ const answer = await call({ method: 'tools/list', params: {} }, opts);
164
+ return answer.body?.tools ?? [];
165
+ }
166
+
167
+ async function close() {
168
+ const all = [...sessions.values()];
169
+ sessions.clear();
170
+ await Promise.all(all.map((s) => s.close()));
171
+ }
172
+
173
+ return { name: 'mcp-stdio', way, call, listTools, close };
174
+ }
@@ -0,0 +1,227 @@
1
+ // The run environment: start the stand-ins, then the product; make the run
2
+ // secrets and the vars file new for each run; wait until the product is
3
+ // ready; check that the product called each stand-in; stop everything.
4
+ //
5
+ // const env = await startEnvironment({ root, tests, name: 'local', runId, redactor, adapter });
6
+ // env.url, env.vars, env.standIns.engram.url
7
+ // await env.stop();
8
+ //
9
+ // Every child process starts from an allow-list environment (guards.mjs,
10
+ // childEnv), and each resolved var is set as a real env value on the product.
11
+ // The vars file lives in a new temporary folder and is removed on stop.
12
+ import { mkdtempSync, writeFileSync, rmSync, readFileSync, existsSync } from 'node:fs';
13
+ import { createServer } from 'node:net';
14
+ import { tmpdir } from 'node:os';
15
+ import { dirname, join, parse } from 'node:path';
16
+ import { setTimeout as sleep } from 'node:timers/promises';
17
+ import { spawnManaged } from './procs.mjs';
18
+ import { Blocked } from './errors.mjs';
19
+ import { resolveEnvironments } from './tests-yaml.mjs';
20
+ import { childEnv, hostsOf, newSecret, requireAddressesAllowed, requireAllowed, substitute } from './guards.mjs';
21
+ import { CALLS_PATH, countCalls, LIBRARY_HEADER } from './stand-in.mjs';
22
+ import { parseLimit } from './wait.mjs';
23
+
24
+ const LIBRARY_HEADERS = { [LIBRARY_HEADER]: '1' };
25
+
26
+ function freePort() {
27
+ return new Promise((resolve, reject) => {
28
+ const server = createServer();
29
+ server.once('error', reject);
30
+ server.listen(0, '127.0.0.1', () => { const { port } = server.address(); server.close(() => resolve(port)); });
31
+ });
32
+ }
33
+
34
+ // ---------------------------------------------------------------- ready
35
+
36
+ async function pollReady(spec, ctx, { label, process: proc, hosts, redactor, standInOrigins = [] }) {
37
+ const url = substitute(spec.get ?? '', ctx);
38
+ if (!url) throw new Blocked(`${label}: "ready" has no "get" address`);
39
+ const parsed = requireAllowed(url, hosts, `${label} ready check`);
40
+ // A ready address must not point at a stand-in: a poll there could stand in
41
+ // for the first call of the product.
42
+ if (standInOrigins.includes(parsed.origin)) throw new Blocked(`${label}: the "ready" address ${parsed.origin} is a stand-in; the ready check must ask the product itself`);
43
+ const want = spec.status ?? 200;
44
+ const limit = parseLimit(spec.limit ?? '30s');
45
+ const deadline = Date.now() + limit;
46
+ let last = 'no answer';
47
+ for (;;) {
48
+ if (proc && (proc.child.exitCode !== null || proc.child.signalCode !== null)) {
49
+ throw new Blocked(`${label} stopped before it was ready (${proc.child.exitCode ?? proc.child.signalCode}). Last output: ${proc.tail().slice(-600)}`);
50
+ }
51
+ try {
52
+ const res = await fetch(url, { redirect: 'manual', headers: LIBRARY_HEADERS, signal: AbortSignal.timeout(2000) });
53
+ await res.arrayBuffer();
54
+ if (res.status === want) return;
55
+ last = `status ${res.status}`;
56
+ } catch (error) {
57
+ last = error.cause?.code ?? error.name;
58
+ }
59
+ if (Date.now() >= deadline) {
60
+ throw new Blocked(`${label} was not ready after ${spec.limit ?? '30s'} (${last}). Last output: ${redactor?.text(proc?.tail() ?? '').slice(-600)}`);
61
+ }
62
+ await sleep(150);
63
+ }
64
+ }
65
+
66
+ // ---------------------------------------------------------------- secrets
67
+
68
+ // KEY=value lines of a dotenv file. No expansion.
69
+ export function parseDotenv(text) {
70
+ const out = {};
71
+ for (const line of String(text).split('\n')) {
72
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line);
73
+ if (!m || line.trim().startsWith('#')) continue;
74
+ let value = m[2];
75
+ if (/^(["']).*\1$/.test(value)) value = value.slice(1, -1);
76
+ out[m[1]] = value;
77
+ }
78
+ return out;
79
+ }
80
+
81
+ function findDotenv(from) {
82
+ let dir = from;
83
+ for (;;) {
84
+ const file = join(dir, '.env.test');
85
+ if (existsSync(file)) return file;
86
+ if (existsSync(join(dir, '.git')) || dir === parse(dir).root) return null;
87
+ dir = dirname(dir);
88
+ }
89
+ }
90
+
91
+ // The value of each provided secret that the scenario names. On the Mac it is
92
+ // in .env.test at the repo root. In CI it is a CI secret of the same name.
93
+ // With no value, the result is `blocked` and names the key.
94
+ export function provideSecrets(names, { root, tests, env = process.env }) {
95
+ const out = {};
96
+ if (!names.length) return out;
97
+ const file = findDotenv(root);
98
+ const fromFile = file ? parseDotenv(readFileSync(file, 'utf8')) : {};
99
+ for (const name of names) {
100
+ if (!Object.hasOwn(tests.secrets ?? {}, name)) continue;
101
+ const value = fromFile[name] ?? env[name];
102
+ if (!value) throw new Blocked(`the scenario names the key ${name} and no value exists: add it to .env.test at the repo root, or set the CI secret of the same name`);
103
+ out[name] = value;
104
+ }
105
+ return out;
106
+ }
107
+
108
+ const dotenvLine = (name, value) => `${name}=${/^[A-Za-z0-9_./:@%+=,-]*$/.test(value) ? value : JSON.stringify(value)}\n`;
109
+
110
+ // ---------------------------------------------------------------- start
111
+
112
+ export async function startEnvironment({ root, tests, name, runId, redactor, adapter = {}, provided = {} }) {
113
+ const environments = resolveEnvironments(tests.environments ?? {});
114
+ const spec = environments[name];
115
+ if (!spec) throw new Blocked(`tests.yaml has no environment "${name}"`);
116
+ if (spec.allowed === false) throw new Blocked(`the environment "${name}" is not allowed: "allowed" is false until a test environment and a scoped key exist`);
117
+ // A deployed environment is closed until the owner opens it with `allowed: true`.
118
+ if (name === 'deployed' && spec.allowed !== true) throw new Blocked(`the environment "deployed" is closed: it has no "allowed: true". Only the owner opens it, with a test environment and a scoped key`);
119
+ const hosts = hostsOf(tests);
120
+ const workDir = mkdtempSync(join(tmpdir(), 'atlas-run-'));
121
+ const varsFile = join(workDir, 'vars.env');
122
+ const managed = [];
123
+ const stoppers = [];
124
+ const standIns = {};
125
+ const resolvedVars = {};
126
+ const secretsMade = [];
127
+ for (const value of Object.values(provided)) redactor.add(value);
128
+
129
+ const stop = async () => {
130
+ for (const fn of stoppers.reverse()) { try { await fn(); } catch { /* the stop goes on */ } }
131
+ for (const proc of managed.reverse()) await proc.stop(proc.stopCommand);
132
+ rmSync(workDir, { recursive: true, force: true });
133
+ };
134
+
135
+ try {
136
+ // 1. The stand-ins.
137
+ for (const [standInName, def] of Object.entries(tests['stand-ins'] ?? {})) {
138
+ const label = `stand-in "${standInName}"`;
139
+ if (adapter.standIns?.[standInName]) {
140
+ const made = await adapter.standIns[standInName]({ name: standInName, runId, hosts });
141
+ if (made.stop) stoppers.push(made.stop);
142
+ requireAllowed(made.url, hosts, label);
143
+ standIns[standInName] = { url: made.url, calls: made.calls ?? (async () => countCalls(await (await fetch(`${made.url}${CALLS_PATH}`, { headers: LIBRARY_HEADERS, signal: AbortSignal.timeout(3000) })).json())) };
144
+ continue;
145
+ }
146
+ if (!def?.start) throw new Blocked(`${label} has no "start" command and the adapter does not make it`);
147
+ const port = await freePort();
148
+ const url = `http://127.0.0.1:${port}`;
149
+ const ctx = { id: runId, port, url };
150
+ const proc = spawnManaged({
151
+ command: substitute(def.start, ctx), cwd: root, redactor,
152
+ env: childEnv(process.env, { PORT: port, ATLAS_STAND_IN_PORT: port, ATLAS_RUN_ID: runId }),
153
+ });
154
+ managed.push(proc);
155
+ await pollReady(def.ready ?? { get: `${url}${CALLS_PATH}`, status: 200, limit: '30s' }, ctx, { label, process: proc, hosts, redactor, standInOrigins: Object.values(standIns).map((one) => new URL(one.url).origin) });
156
+ standIns[standInName] = {
157
+ url,
158
+ calls: async () => countCalls(await (await fetch(`${url}${CALLS_PATH}`, { headers: LIBRARY_HEADERS, signal: AbortSignal.timeout(3000) })).json()),
159
+ };
160
+ }
161
+
162
+ // 2. The vars: run secrets are new for each placeholder; references resolve in order.
163
+ const raw = spec.vars ?? {};
164
+ // The product gets a free port too: `{port}` in start, ready, url and vars.
165
+ const productPort = await freePort();
166
+ const ctx = {
167
+ id: runId, varsFile, port: productPort, url: spec.url ? substitute(spec.url, { id: runId, port: productPort }) : undefined,
168
+ secret: () => { const s = newSecret(); secretsMade.push(s); redactor.add(s); return s; },
169
+ standIn: (n) => standIns[n]?.url,
170
+ vars: (n) => resolveVar(n, []),
171
+ };
172
+ function resolveVar(varName, stack) {
173
+ if (Object.hasOwn(resolvedVars, varName)) return resolvedVars[varName];
174
+ if (!Object.hasOwn(raw, varName)) throw new Blocked(`a placeholder names the variable ${varName}, which "vars" does not set`);
175
+ if (stack.includes(varName)) throw new Blocked(`the variables ${[...stack, varName].join(' -> ')} refer to each other in a circle`);
176
+ const value = substitute(String(raw[varName]), { ...ctx, vars: (n) => resolveVar(n, [...stack, varName]) });
177
+ resolvedVars[varName] = value;
178
+ return value;
179
+ }
180
+ for (const varName of Object.keys(raw)) resolveVar(varName, []);
181
+ for (const [varName, value] of Object.entries(resolvedVars)) {
182
+ if (/(TOKEN|SECRET|KEY|PASSWORD|CREDENTIAL)/i.test(varName)) redactor.add(value);
183
+ }
184
+ // Every value that is an address, of any scheme or as host:port, must be on
185
+ // the list, whatever the name of its variable.
186
+ requireAddressesAllowed(resolvedVars, hosts, 'the vars of the environment');
187
+ writeFileSync(varsFile, Object.entries(resolvedVars).map(([n, v]) => dotenvLine(n, v)).join(''), { mode: 0o600 });
188
+
189
+ // 3. The product. In a fault run, ATLAS_PRODUCT_ROOT names the folder of a
190
+ // throwaway copy: the product starts there, while tests.yaml, the support
191
+ // code and the stand-ins stay in the real worktree.
192
+ const productRoot = process.env.ATLAS_PRODUCT_ROOT || root;
193
+ const given = { ...provided, ATLAS_RUN_ID: runId };
194
+ let product = null;
195
+ if (spec.start) {
196
+ // An allow-list environment. Each resolved var is a real value that
197
+ // replaces any parent value of the same name; the vars file stays too.
198
+ product = spawnManaged({
199
+ command: substitute(spec.start, ctx), cwd: productRoot, redactor,
200
+ env: childEnv(process.env, { ...resolvedVars, ...given }),
201
+ });
202
+ product.stopCommand = spec.stop;
203
+ managed.push(product);
204
+ }
205
+ if (spec.ready) await pollReady(spec.ready, ctx, { label: `the product (${name})`, process: product, hosts, redactor, standInOrigins: Object.values(standIns).map((one) => new URL(one.url).origin) });
206
+ if (spec.url) requireAllowed(substitute(spec.url, ctx), hosts, 'the environment url');
207
+
208
+ const env = {
209
+ name, url: spec.url ? substitute(spec.url, ctx) : null, port: productPort, vars: { ...resolvedVars }, varsFile, hosts, standIns, root, productRoot,
210
+ provided: Object.keys(provided), output: () => product?.tail() ?? '', stop,
211
+ };
212
+
213
+ // 4. The first-call rule: the product must have called each stand-in.
214
+ await adapter.warmUp?.(env);
215
+ for (const [standInName, one] of Object.entries(standIns)) {
216
+ let count = 0;
217
+ try { count = await one.calls(); } catch (error) {
218
+ throw new Blocked(`stand-in "${standInName}" does not answer ${CALLS_PATH}: ${error.cause?.code ?? error.message}`);
219
+ }
220
+ if (count < 1) throw new Blocked(`stand-in "${standInName}" saw no first call from the product before step 1: the product may talk to something else`);
221
+ }
222
+ return env;
223
+ } catch (error) {
224
+ await stop();
225
+ throw error;
226
+ }
227
+ }
@@ -0,0 +1,27 @@
1
+ // The kinds of "could not run" and the other stops. Each one has one result.
2
+
3
+ // A setup failed, a guard refused, or a precondition does not hold. The
4
+ // result is `blocked`, never `fail`.
5
+ export class Blocked extends Error {
6
+ constructor(reason) { super(reason); this.name = 'Blocked'; }
7
+ }
8
+
9
+ // A service that the test needs does not answer. The result is `blocked`.
10
+ export class Unavailable extends Error {
11
+ constructor(reason) { super(reason); this.name = 'Unavailable'; }
12
+ }
13
+
14
+ // A wait met its fail state. This is a product result, so it is a `fail`.
15
+ export class WaitFailed extends Error {
16
+ constructor(reason, last) { super(reason); this.name = 'WaitFailed'; this.last = last; }
17
+ }
18
+
19
+ // A wait ran out of time. This is a product result, so it is a `fail`.
20
+ export class WaitTimeout extends Error {
21
+ constructor(reason, last) { super(reason); this.name = 'WaitTimeout'; this.last = last; }
22
+ }
23
+
24
+ // Thrown by a failed gate. It stops the body of the test.
25
+ export class GateFailed extends Error {
26
+ constructor(reason) { super(reason); this.name = 'GateFailed'; }
27
+ }
@@ -0,0 +1,113 @@
1
+ // The evidence file. Schema `evidence: 1`.
2
+ //
3
+ // {
4
+ // "evidence": 1, "run_id": "...", "environment": "local",
5
+ // "scenario": "week-conflict", "title": "...", "source": "scenarios/week-conflict.md",
6
+ // "source_sha256": "...", "fingerprint_method": "fp1",
7
+ // "started": "...", "ended": null, "verdict": "not finished",
8
+ // "ways": { "mcp": { "verdict": "pass", "reason": null, "started": "...", "ended": "..." } },
9
+ // "assertions": [ { "id": "week-conflict/e3#09f598", "way": "mcp", "result": "pass", "proof": { ... } } ],
10
+ // "tables": { "T1": "<fingerprint>" }, "fresh": [ { "rule": "...", "value": "..." } ],
11
+ // "calls": [ { "n": 1, "way": "mcp", "persona": "planner-1", "request": { }, "response": { } } ],
12
+ // "notes": [], "files": [], "provided_secrets": [], "fault_run": null
13
+ // }
14
+ //
15
+ // The file is written when the run starts and after each assertion, so a run
16
+ // that dies leaves a record with the verdict "not finished". One file for each
17
+ // run and scenario. A second attempt in the same run id gets a new file
18
+ // (`<scenario>.2.json`). A file is claimed with an exclusive create, so no
19
+ // earlier run is overwritten. Every string is redacted by value before it is
20
+ // written.
21
+ import { closeSync, mkdirSync, openSync, renameSync, writeFileSync } from 'node:fs';
22
+ import { join, relative } from 'node:path';
23
+
24
+ export const EVIDENCE_SCHEMA = 1;
25
+
26
+ // The results of one assertion for one way in (design section 6.11).
27
+ export const RESULTS = Object.freeze(['pass', 'fail', 'not checked', 'not exercised', 'blocked', 'not here']);
28
+
29
+ const MAX_BODY = 8000;
30
+
31
+ // Redact first, then cut. A cut before the redaction could split a secret, and
32
+ // the half that stays would no longer match its value.
33
+ export function clip(value, redactor = null) {
34
+ const safe = redactor ? redactor.deep(value) : value;
35
+ const text = typeof safe === 'string' ? safe : JSON.stringify(safe);
36
+ if (text === undefined || text.length <= MAX_BODY) return safe;
37
+ return `${text.slice(0, MAX_BODY)}... [${text.length - MAX_BODY} more characters]`;
38
+ }
39
+
40
+ export class Evidence {
41
+ constructor({ evidenceDir, runId, environment, scenario, root, redactor, providedSecrets = [] }) {
42
+ this.dir = join(evidenceDir, runId);
43
+ this.root = root;
44
+ this.redactor = redactor;
45
+ this.record = {
46
+ evidence: EVIDENCE_SCHEMA, run_id: runId, environment,
47
+ scenario: scenario.id, title: scenario.title,
48
+ source: relative(root, scenario.file).split('\\').join('/'), source_sha256: scenario.sha256,
49
+ fingerprint_method: scenario.method,
50
+ started: new Date().toISOString(), ended: null, verdict: 'not finished',
51
+ ways: {}, assertions: [], tables: {}, fresh: [], calls: [], notes: [], files: [],
52
+ provided_secrets: providedSecrets, fault_run: process.env.ATLAS_FAULT_RUN || null,
53
+ };
54
+ for (const way of scenario.through) {
55
+ this.record.ways[way] = { verdict: 'not finished', reason: null, started: null, ended: null };
56
+ for (const a of scenario.assertions) this.record.assertions.push({ id: a.fullId, way, result: 'not checked', proof: null });
57
+ }
58
+ mkdirSync(this.dir, { recursive: true });
59
+ this.path = this.claim(scenario.id);
60
+ this.write();
61
+ }
62
+
63
+ claim(scenarioId) {
64
+ for (let n = 1; ; n += 1) {
65
+ const path = join(this.dir, n === 1 ? `${scenarioId}.json` : `${scenarioId}.${n}.json`);
66
+ try { closeSync(openSync(path, 'wx')); return path; } catch (error) { if (error.code !== 'EEXIST') throw error; }
67
+ }
68
+ }
69
+
70
+ entry(id, way) {
71
+ const found = this.record.assertions.find((a) => a.id === id && a.way === way);
72
+ if (!found) throw new Error(`the evidence has no assertion ${id} for the way in "${way}"`);
73
+ return found;
74
+ }
75
+
76
+ setResult(id, way, result, proof) {
77
+ if (!RESULTS.includes(result)) throw new Error(`"${result}" is not a result`);
78
+ const entry = this.entry(id, way);
79
+ entry.result = result;
80
+ entry.proof = proof ?? null;
81
+ this.write();
82
+ }
83
+
84
+ setWay(way, patch) {
85
+ Object.assign(this.record.ways[way], patch);
86
+ const verdicts = Object.values(this.record.ways).map((w) => w.verdict);
87
+ this.record.verdict = verdicts.includes('fail') ? 'fail'
88
+ : verdicts.includes('blocked') ? 'blocked'
89
+ : verdicts.every((v) => v === 'pass' || v === 'not here') ? (verdicts.every((v) => v === 'not here') ? 'not here' : 'pass') : 'not finished';
90
+ if (!verdicts.includes('not finished')) this.record.ended = new Date().toISOString();
91
+ this.write();
92
+ }
93
+
94
+ addCall(call) {
95
+ const n = this.record.calls.length + 1;
96
+ this.record.calls.push({ n, ...call });
97
+ return n;
98
+ }
99
+
100
+ addFile(path) {
101
+ this.record.files.push(relative(this.dir, path).split('\\').join('/'));
102
+ this.write();
103
+ }
104
+
105
+ note(text) { this.record.notes.push(String(text)); this.write(); }
106
+
107
+ write() {
108
+ const safe = this.redactor.deep(this.record);
109
+ const temp = `${this.path}.tmp`;
110
+ writeFileSync(temp, `${JSON.stringify(safe, null, 2)}\n`);
111
+ renameSync(temp, this.path);
112
+ }
113
+ }
@@ -0,0 +1,42 @@
1
+ // Fresh values. Every value that a test makes carries the run id, so two runs
2
+ // never share data, and a clean-up can find what a killed run left behind.
3
+ // A value that cannot hold text, such as a date, is picked from the run id.
4
+ import { createHash, randomBytes } from 'node:crypto';
5
+
6
+ export function makeRunId(env = process.env) {
7
+ if (env.ATLAS_RUN_ID) return env.ATLAS_RUN_ID;
8
+ if (env.GITHUB_RUN_ID) return `ci${env.GITHUB_RUN_ID}-${env.GITHUB_RUN_ATTEMPT ?? 1}`;
9
+ return `run${randomBytes(3).toString('hex')}`;
10
+ }
11
+
12
+ // The first Monday after 1 January 2035, then a number of whole weeks.
13
+ const FIRST_MONDAY = Date.UTC(2035, 0, 1) + (((8 - new Date(Date.UTC(2035, 0, 1)).getUTCDay()) % 7) || 7) * 86400000;
14
+ const SPREAD_WEEKS = 520;
15
+
16
+ export function mondayFor(runId, count) {
17
+ const weeks = createHash('sha256').update(`${runId}:${count}`).digest().readUInt32BE(0) % SPREAD_WEEKS;
18
+ return new Date(FIRST_MONDAY + weeks * 7 * 86400000).toISOString().slice(0, 10);
19
+ }
20
+
21
+ // A maker of fresh values for one run. `rules` is the `data` map of tests.yaml.
22
+ // A rule whose name holds "monday" makes a Monday. Any other rule makes a
23
+ // name: the rule, the run id and a count.
24
+ export function makeFresh(runId, rules, adapterRules = {}) {
25
+ const counts = new Map();
26
+ const made = [];
27
+ const fresh = (rule) => {
28
+ if (!Object.hasOwn(rules ?? {}, rule) && !Object.hasOwn(adapterRules, rule)) {
29
+ throw new Error(`there is no data rule "${rule}" in tests.yaml`);
30
+ }
31
+ const count = (counts.get(rule) ?? 0) + 1;
32
+ counts.set(rule, count);
33
+ let value;
34
+ if (Object.hasOwn(adapterRules, rule)) value = adapterRules[rule]({ runId, count, rule });
35
+ else if (/monday/i.test(rule)) value = mondayFor(runId, `${rule}:${count}`);
36
+ else value = `${rule.replace(/^fresh-/, '')}-${runId}-${count}`;
37
+ made.push({ rule, value });
38
+ return value;
39
+ };
40
+ fresh.made = made;
41
+ return fresh;
42
+ }