@arjunkhera/atlas 0.3.6 → 0.3.8
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/.claude-plugin/plugin.json +1 -1
- package/agents/verifier.md +29 -0
- package/door/cli.mjs +27 -2
- package/door/kit-releases.json +4 -0
- package/door/lib/proof.mjs +127 -0
- package/door/lib/tests.mjs +190 -0
- package/package.json +2 -1
- package/skills/lead/SKILL.md +3 -0
- package/skills/tests/SKILL.md +234 -0
- package/tests/contract.mjs +398 -0
- package/tests/drivers/function.mjs +30 -0
- package/tests/drivers/http.mjs +68 -0
- package/tests/drivers/index.mjs +65 -0
- package/tests/drivers/mcp-stdio.mjs +174 -0
- package/tests/environment.mjs +227 -0
- package/tests/errors.mjs +27 -0
- package/tests/evidence.mjs +113 -0
- package/tests/fresh.mjs +42 -0
- package/tests/guards.mjs +159 -0
- package/tests/index.mjs +11 -0
- package/tests/link-check.mjs +576 -0
- package/tests/procs.mjs +43 -0
- package/tests/redact.mjs +58 -0
- package/tests/scenario.mjs +325 -0
- package/tests/stand-in.mjs +74 -0
- package/tests/tests-yaml.mjs +258 -0
- package/tests/wait.mjs +44 -0
- package/tests/yaml.mjs +327 -0
- package/work/lib/child-env.mjs +14 -0
|
@@ -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
|
+
}
|
package/tests/fresh.mjs
ADDED
|
@@ -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
|
+
}
|
package/tests/guards.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Guards: what a test may reach, and what a child process may see.
|
|
2
|
+
import { Blocked } from './errors.mjs';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const LOCAL_HOSTS = Object.freeze(['127.0.0.1', 'localhost', '::1']);
|
|
6
|
+
|
|
7
|
+
const bare = (host) => String(host).toLowerCase().replace(/^\[|\]$/g, '');
|
|
8
|
+
|
|
9
|
+
// The hosts that a test may reach: `guards.hosts`, or this machine only.
|
|
10
|
+
export function hostsOf(tests) {
|
|
11
|
+
const hosts = tests?.guards?.hosts;
|
|
12
|
+
return Array.isArray(hosts) && hosts.length ? hosts.map(String) : [...LOCAL_HOSTS];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function hostAllowed(host, hosts) {
|
|
16
|
+
const h = bare(host);
|
|
17
|
+
return hosts.some((entry) => {
|
|
18
|
+
const e = bare(entry);
|
|
19
|
+
return e === h || (e.startsWith('*.') && h.endsWith(e.slice(1)));
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Throws Blocked when the address is not on the list. The result is `blocked`,
|
|
24
|
+
// never `fail`.
|
|
25
|
+
export function requireAllowed(url, hosts, what = 'a request') {
|
|
26
|
+
let parsed;
|
|
27
|
+
try { parsed = new URL(url); } catch { throw new Blocked(`${what}: "${String(url).slice(0, 80)}" is not an address`); }
|
|
28
|
+
if (!hostAllowed(parsed.hostname, hosts)) {
|
|
29
|
+
throw new Blocked(`${what} to ${parsed.origin} was refused: guards.hosts allows only ${hosts.join(', ')}`);
|
|
30
|
+
}
|
|
31
|
+
return parsed;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const sameOrigin = (a, b) => new URL(a).origin === new URL(b).origin;
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------- child env
|
|
37
|
+
|
|
38
|
+
// A child process starts from an ALLOW-LIST, not from the parent environment
|
|
39
|
+
// minus a few names (design section 15, rule 1). It gets these names from the
|
|
40
|
+
// parent and nothing else: PATH, HOME, LANG, LC_*, TMPDIR, TERM and NODE_*
|
|
41
|
+
// (such as NODE_EXTRA_CA_CERTS). The rule follows packages/atlas/work/lib/child-env.mjs.
|
|
42
|
+
// The proxy variables pass only in a Claude cloud session, and only when the
|
|
43
|
+
// proxy is a loopback address. NODE_TEST_CONTEXT never passes.
|
|
44
|
+
const ALLOWED = /^(?:PATH|HOME|LANG|LC_[A-Z_]+|TMPDIR|TERM)$/;
|
|
45
|
+
const CLOUD_PROXY = ['HTTPS_PROXY', 'https_proxy'];
|
|
46
|
+
const CLOUD_NO_PROXY = ['NO_PROXY', 'no_proxy'];
|
|
47
|
+
const LOOPBACK_PROXY = /^http:\/\/(127\.0\.0\.1|localhost|\[::1\]):\d{1,5}\/?$/;
|
|
48
|
+
|
|
49
|
+
// The environment of a child process: the allow-list from `parent`, then
|
|
50
|
+
// `values` on top. A value in `values` replaces any parent value of the same
|
|
51
|
+
// name, so a parent value for a name that tests.yaml sets never reaches a child.
|
|
52
|
+
export function childEnv(parent = process.env, values = {}) {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const [name, value] of Object.entries(parent)) {
|
|
55
|
+
if (value === undefined) continue;
|
|
56
|
+
if (ALLOWED.test(name) || (name.startsWith('NODE_') && name !== 'NODE_TEST_CONTEXT')) out[name] = value;
|
|
57
|
+
}
|
|
58
|
+
if (parent.CLAUDE_CODE_REMOTE === 'true') {
|
|
59
|
+
const proxies = CLOUD_PROXY.filter((name) => parent[name] !== undefined);
|
|
60
|
+
if (proxies.length && proxies.every((name) => LOOPBACK_PROXY.test(parent[name]))) {
|
|
61
|
+
for (const name of [...proxies, ...CLOUD_NO_PROXY]) if (parent[name] !== undefined) out[name] = parent[name];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const [name, value] of Object.entries(values)) if (value !== undefined) out[name] = String(value);
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ---------------------------------------------------------------- addresses
|
|
69
|
+
|
|
70
|
+
const ADDRESS_NAME = /(?:URL|URI|HOST|ADDR|ADDRESS|ENDPOINT)/i;
|
|
71
|
+
const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\/\S+/i;
|
|
72
|
+
const HOST_PORT = /^(\[[0-9a-f:.]+\]|[a-z0-9][a-z0-9.-]*):(\d{1,5})$/i;
|
|
73
|
+
|
|
74
|
+
// The host of one token, when the token is an address: a URL of any scheme
|
|
75
|
+
// (a file URL has no host and is no address), or host:port. Returns
|
|
76
|
+
// undefined when the token is not an address, and null when it is a URL that
|
|
77
|
+
// does not parse.
|
|
78
|
+
function hostOfToken(token) {
|
|
79
|
+
if (SCHEME_URL.test(token)) {
|
|
80
|
+
try { return new URL(token).hostname || undefined; } catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
const m = HOST_PORT.exec(token);
|
|
83
|
+
if (!m) return undefined;
|
|
84
|
+
const host = m[1];
|
|
85
|
+
// "12:30" is a time, not an address: a host needs a letter, a dot or brackets.
|
|
86
|
+
if (!/[a-z.\[]/i.test(host)) return undefined;
|
|
87
|
+
return host;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The addresses in an env map. A value is an address when it parses as a URL
|
|
91
|
+
// of any scheme or as host:port, whatever its name. A value may hold a list
|
|
92
|
+
// (split on commas, semicolons and white space). With `byName`, the value of
|
|
93
|
+
// a variable whose name says it is an address (URL, HOST, ...) is one too,
|
|
94
|
+
// even when it parses as nothing.
|
|
95
|
+
export function addressesIn(env = {}, { byName = true } = {}) {
|
|
96
|
+
const out = [];
|
|
97
|
+
for (const [key, value] of Object.entries(env)) {
|
|
98
|
+
const text = String(value ?? '').trim();
|
|
99
|
+
let found = false;
|
|
100
|
+
for (const token of text.split(/[\s,;]+/).filter(Boolean)) {
|
|
101
|
+
const host = hostOfToken(token);
|
|
102
|
+
if (host === undefined) continue;
|
|
103
|
+
found = true;
|
|
104
|
+
out.push({ key, value: token, host });
|
|
105
|
+
}
|
|
106
|
+
if (!found && byName && ADDRESS_NAME.test(key)) {
|
|
107
|
+
out.push({ key, value: text, host: text.replace(/:\d+$/, '').trim() || null });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return out;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Every address in `env` must be on the list (`blocked` otherwise). Used for
|
|
114
|
+
// the vars of an environment and for the env of a way in.
|
|
115
|
+
export function requireAddressesAllowed(env, hosts, what) {
|
|
116
|
+
for (const one of addressesIn(env, { byName: false })) {
|
|
117
|
+
if (!one.host) throw new Blocked(`${what}: ${one.key} holds an address that cannot be read`);
|
|
118
|
+
if (!hostAllowed(one.host, hosts)) throw new Blocked(`${what}: ${one.key} points at ${one.host}, which guards.hosts does not allow (it allows only ${hosts.join(', ')})`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// For a process that the library starts for a test, such as an MCP server:
|
|
123
|
+
// every address in the env that the library gives it must be on the list, and
|
|
124
|
+
// there must be one. A server with no address could fall back to a real one.
|
|
125
|
+
// `given` is what the way in sets. `full` is the whole env the server gets;
|
|
126
|
+
// the list check runs on all of it.
|
|
127
|
+
export function requireLocalAddresses(given, hosts, what, full = given) {
|
|
128
|
+
const own = addressesIn(given);
|
|
129
|
+
if (!own.length) throw new Blocked(`${what}: the env gives the server no address, so it could fall back to a real one; give it the local address`);
|
|
130
|
+
for (const one of addressesIn(full)) {
|
|
131
|
+
if (!one.host) throw new Blocked(`${what}: ${one.key} holds no address, so the server could fall back to a real one`);
|
|
132
|
+
if (!hostAllowed(one.host, hosts)) throw new Blocked(`${what}: ${one.key} points at ${one.host}, which guards.hosts does not allow`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---------------------------------------------------------------- templates
|
|
137
|
+
|
|
138
|
+
// Replaces the placeholders of tests.yaml. Known forms:
|
|
139
|
+
// {run.secret} {run.id} {run.vars} {run.vars.NAME} {stand-in.NAME.url} {url} {port}
|
|
140
|
+
// `ctx` gives a function for each: secret(), id, varsFile, vars(name), standIn(name), url, port.
|
|
141
|
+
const PLACEHOLDER = /\{(run\.secret|run\.id|run\.vars(?:\.[A-Za-z0-9_]+)?|stand-in\.[A-Za-z0-9_-]+\.url|url|port)\}/g;
|
|
142
|
+
|
|
143
|
+
export function substitute(text, ctx) {
|
|
144
|
+
return String(text).replace(PLACEHOLDER, (whole, key) => {
|
|
145
|
+
let value;
|
|
146
|
+
if (key === 'run.secret') value = ctx.secret?.();
|
|
147
|
+
else if (key === 'run.id') value = ctx.id;
|
|
148
|
+
else if (key === 'run.vars') value = ctx.varsFile;
|
|
149
|
+
else if (key.startsWith('run.vars.')) value = ctx.vars?.(key.slice(9));
|
|
150
|
+
else if (key.startsWith('stand-in.')) value = ctx.standIn?.(key.slice(9, -4));
|
|
151
|
+
else if (key === 'url') value = ctx.url;
|
|
152
|
+
else if (key === 'port') value = ctx.port;
|
|
153
|
+
if (value === undefined || value === null || value === '') throw new Error(`no value for the placeholder ${whole}`);
|
|
154
|
+
return String(value);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// A fresh run secret: random, 40 characters.
|
|
159
|
+
export const newSecret = () => randomBytes(30).toString('base64url').slice(0, 40);
|
package/tests/index.mjs
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The public face of the kit. A repo imports from here, or from the files directly.
|
|
2
|
+
export { scenario, defineAdapter, waitUntil, RUN_ID } from './contract.mjs';
|
|
3
|
+
export { startEnvironment, parseDotenv, provideSecrets } from './environment.mjs';
|
|
4
|
+
export { createStandIn, serveStandIn, CALLS_PATH, LIBRARY_HEADER } from './stand-in.mjs';
|
|
5
|
+
export { createHttpDriver, createMcpStdioDriver, createFunctionDriver, makeDriver } from './drivers/index.mjs';
|
|
6
|
+
export { parseTestsYaml, readTestsYaml, checkTestsYaml, guardParts, compareGuardParts, GUARD_PARTS, looksLikeSecret } from './tests-yaml.mjs';
|
|
7
|
+
export { parseScenario, readScenario, addFingerprints, FINGERPRINT_METHOD } from './scenario.mjs';
|
|
8
|
+
export { Blocked, Unavailable, WaitFailed, WaitTimeout, GateFailed } from './errors.mjs';
|
|
9
|
+
export { Redactor } from './redact.mjs';
|
|
10
|
+
export { Evidence, EVIDENCE_SCHEMA, RESULTS } from './evidence.mjs';
|
|
11
|
+
export { checkArea } from './link-check.mjs';
|