@arjunkhera/atlas 0.3.31 → 0.3.32

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "atlas",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, for any repository.",
5
5
  "author": {
6
6
  "name": "Arjun Khera"
package/door/cli.mjs CHANGED
@@ -30,6 +30,7 @@ import { loadPrivateTerms } from './lib/privacy.mjs';
30
30
  import { checkCommand as testsCheck, writeCommand as testsWrite } from './lib/tests.mjs';
31
31
  import { proofCommand as testsProof } from './lib/proof.mjs';
32
32
  import { verdictCommand as testsVerdict, namedCommand as testsNamed, rerunCommand as testsRerun } from '../tests/verdict.mjs';
33
+ import { runKept } from '../tests/kept.mjs';
33
34
  import { sweepCommand as testsSweep, coversCommand as testsCovers } from '../tests/sweep.mjs';
34
35
  import { issuesCommand as testsIssues } from './lib/issues.mjs';
35
36
  import { upkeepCommand } from './lib/upkeep.mjs';
@@ -97,6 +98,10 @@ const HELP = `atlas — the door into a repo's Atlas files
97
98
  --dry-run print what it would open; change nothing
98
99
  The token is only GITHUB_TOKEN. The base URL is GITHUB_API_URL (default https://api.github.com)
99
100
 
101
+ atlas tests kept --root <area> path 1: run the kept tests that a scenario names in kept-tests, and write their evidence
102
+ --evidence <dir> where the evidence files go; default <area>/test/evidence
103
+ --scenario <id> run one scenario only
104
+
100
105
  atlas tests rerun --root <area> --run <id> list the scenario test files whose first attempt did not end well
101
106
  --tests <dir> the test folder of the area; default test. The test files are in <dir>/scenarios
102
107
  --evidence <dir> where the evidence files are; default <area>/test/evidence
@@ -131,7 +136,7 @@ export const FLAGS = Object.freeze({
131
136
  ste: { share: 'switch', terms: 'value' },
132
137
  design: { tracker: 'value', out: 'value', draft: 'switch', root: 'optional' },
133
138
  view: { product: 'value', name: 'value', out: 'value' },
134
- tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch' },
139
+ tests: { root: 'optional', halves: 'value', evidence: 'value', 'guards-from': 'value', tests: 'value', 'dry-run': 'switch', run: 'value', own: 'value', base: 'value', covers: 'switch', findings: 'value', repo: 'value', max: 'value', bot: 'value', since: 'value', out: 'value', text: 'switch', json: 'switch', scenario: 'value' },
135
140
  upkeep: { root: 'optional', event: 'value', json: 'switch' },
136
141
  install: { local: 'switch', from: 'value', 'skip-global': 'switch', yes: 'switch' },
137
142
  upgrade: { version: 'optional', yes: 'switch' },
@@ -330,9 +335,10 @@ async function testsCommand(chosen) {
330
335
  if (what === 'named') return testsNamed({ base: chosen.base, covers: Boolean(chosen.covers) }, root, line);
331
336
  if (what === 'sweep') return testsSweep({ evidence: chosen.evidence, since: chosen.since, base: chosen.base, out: chosen.out, text: Boolean(chosen.text) }, root, line);
332
337
  if (what === 'covers') return testsCovers({ itemId: chosen._[2], evidence: chosen.evidence, json: Boolean(chosen.json) }, root, line);
338
+ if (what === 'kept') return (await runKept({ root, evidenceDir: chosen.evidence, only: chosen.scenario ?? null, write: (text) => process.stdout.write(text) })).exit;
333
339
  if (what === 'rerun') return testsRerun({ run: chosen.run, evidence: chosen.evidence, tests: chosen.tests }, root, line);
334
340
  if (what === 'issues') return testsIssues({ findings: chosen.findings, repo: chosen.repo, max: chosen.max, bot: chosen.bot, dryRun: Boolean(chosen['dry-run']), args: [...chosen._, ...Object.values(chosen).filter((v) => typeof v === 'string')] }, line);
335
- throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, sweep, covers, issues or rerun.`);
341
+ throw new Error(`there is no tests verb "${what ?? ''}". Use check, write, proof, verdict, named, sweep, covers, kept, issues or rerun.`);
336
342
  }
337
343
 
338
344
  function upkeepVerb(chosen) {
@@ -24,6 +24,10 @@
24
24
  {
25
25
  "kit_sha256": "1e62be9a3fb632161c593d44ac7ad404ef04186fe2fbcd108645fbe1559bf860",
26
26
  "first_package": "0.3.28"
27
+ },
28
+ {
29
+ "kit_sha256": "08c868aa39e6c9f471da2571e9a1d5939932a59856cea9c4176e3173f93454db",
30
+ "first_package": "0.3.30"
27
31
  }
28
32
  ]
29
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arjunkhera/atlas",
3
- "version": "0.3.31",
3
+ "version": "0.3.32",
4
4
  "description": "Atlas: the delivery lifecycle, its crews and the Atlas tools, as a Claude Code plugin for any repository.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -100,7 +100,7 @@ set. A test keeps the two lists equal.
100
100
  | `atlas kind-drift` | When the repo record and `atlas.yaml` may disagree on the kind; this command retires in stage 2, the onboarding stage |
101
101
  | `atlas code-read` | To resolve the citations of a code digest |
102
102
  | `atlas ste` | Before each publish; `--share` before a page goes to anyone else |
103
- | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
103
+ | `atlas tests` | `write` puts the test kit in an area. `check` runs before a merge. `kept` runs the kept tests of a path 1 area and writes their evidence. `proof`, `verdict`, `named`, `covers` and `rerun` read the results of a run. `sweep` and `issues` run the daily sweep |
104
104
  | `atlas design` | `folder` to learn where designs live; `check` before you show a design; `build` to make its page |
105
105
  | `atlas view` | `build --product <id>` makes the project view page of one product, when the owner asks for it or after an Atlas release changes it. Publish it with only the grant the command prints |
106
106
  | `atlas upkeep` | `check` before you ask for a merge: the docs index, dead doc links, and the tracker item a pull request names. The daily upkeep run calls it on the main branch |
@@ -192,10 +192,12 @@ CI runs the tests from the main branch, so a pull request cannot change its own
192
192
 
193
193
  ## Onboard an area
194
194
 
195
- Path 1 is for an area that has integration tests which call its routes,
196
- and which people keep using. Old unit tests that call functions do not make path 1.
197
- Use path 2: the area has no integration test that calls its routes. If it has
198
- route tests that people keep, stop. Tell the owner that path 1 comes later.
195
+ Pick the path at step 1 of the map.
196
+
197
+ - Path 1: the area has integration tests that people keep using. They call
198
+ its routes, or start its tool or its server. Follow "Path 1: adopt the
199
+ kept tests" below. Old unit tests that only call functions do not make path 1.
200
+ - Path 2: the area has no such tests. Follow the steps here.
199
201
 
200
202
  1. Send the `atlas:code-explorer` crew to map the area. Ask for routes,
201
203
  tools, pages, storage, calls to outside services and existing tests.
@@ -230,6 +232,56 @@ a note service:
230
232
  and two scenarios.
231
233
  3. Output: one pull request. The owner reads two scenarios and three lines of guards.
232
234
 
235
+ ## Path 1: adopt the kept tests
236
+
237
+ The kept tests stay as they are. Each kept test file gets a scenario: the
238
+ scenario holds the words, and the kept test stays the code that checks them.
239
+ `atlas tests kept` runs the kept runner, reads its JUnit report and writes
240
+ the evidence. The design: `docs/design-docs/kept-tests/` in the Atlas repo.
241
+
242
+ 1. Send the `atlas:code-explorer` crew to map the area. Ask for each kept
243
+ test file and its test case names. Ask for the runner command and the env
244
+ variables it reads. Ask for each outside address that a kept test uses.
245
+ 2. Run the kept tests once as they are. Record the count. Each one must be
246
+ green before you start, and stays green to the end.
247
+ 3. Run `atlas tests write --root <repo>`.
248
+ 4. Find the JUnit form of the runner. Examples: `node --test
249
+ --test-reporter=junit --test-reporter-destination={report} {file}`,
250
+ `pytest --junitxml={report} {file}`, and a TestNG suite file with the
251
+ JUnit reporter.
252
+ 5. Draft `atlas/tests.yaml` with one way in of driver `kept`. Give it the
253
+ `command` with `{file}` and `{report}`, the `files` patterns, and the
254
+ `env` the kept tests need. Give it a local address in the env, or
255
+ `offline: true` when the tests reach no network.
256
+ 6. For each kept file, write one scenario with `through: [kept]` and
257
+ `kept-tests: <file>`. Write the assertions: what the file proves, in
258
+ plain words. Never claim more than its test cases check.
259
+ 7. Write the `## Kept checks` section. Map each assertion to the test cases
260
+ that check it: `- e1: "case name"`. Name each case of the file once.
261
+ List a case that proves no promise as `- not mapped: "case name" (why)`.
262
+ 8. Review the words (the section below). Give the crew the kept test file
263
+ too, so it can check each assertion against the code of its cases.
264
+ 9. Run `atlas tests kept --root <repo>`, then `atlas tests check --root <repo>`.
265
+ 10. Plant a fault in the product for each assertion, in a throwaway copy.
266
+ Run `atlas tests kept` there. Each assertion must go red once.
267
+ 11. Run the kept tests as they are once more. The count and the result
268
+ must match step 2.
269
+ 12. Add a CI step that runs `node test/atlas/kept.mjs --root .` from the
270
+ kit copy of the main branch, with `ATLAS_GUARDS_FROM` set.
271
+ 13. Open one pull request. List the outside addresses that step 1 found.
272
+ Give the guard parts in plain words, and one scenario with its proof
273
+ table. A person merges it.
274
+
275
+ Worked run, on a CLI area with 14 kept test files:
276
+
277
+ 1. Input: the map finds `test/status.test.mjs` with 4 test cases. They start
278
+ the tool as a child process. No test reaches a network.
279
+ 2. Process: one kept way in with `offline: true`. The scenario
280
+ `thread-status` has two assertions. e1 maps 2 cases and e2 maps 1 case.
281
+ The help-text case is not mapped.
282
+ 3. Output: `atlas tests kept` prints `thread-status pass`. A planted fault
283
+ in the state code turns e1 red, and e2 stays green.
284
+
233
285
  ## Write assertions
234
286
 
235
287
  Use these steps when you write or change an assertion.
@@ -64,6 +64,8 @@ export async function makeDriver(ctx) {
64
64
  env: Object.fromEntries(Object.entries(spec.env ?? {}).map(([k, v]) => [k, fill(String(v))])),
65
65
  offline: spec.offline === true, hosts, root, productRoot, runId, redactor, record,
66
66
  });
67
+ case 'kept':
68
+ throw new Blocked(`the way in "${way}" is a kept runner; its scenarios run with atlas tests kept, not in a scenario test`);
67
69
  case 'function': {
68
70
  let target = adapter.functions?.[way] ?? adapter.functions?.function ?? adapter.functions;
69
71
  // No module from the adapter: the `module` of the way in is a path under the
@@ -0,0 +1,64 @@
1
+ // The JUnit report reader, for path 1 of onboarding: an area whose kept tests
2
+ // run on their own runner (design docs/design-docs/kept-tests). Most runners
3
+ // write this report: node --test (--test-reporter=junit), pytest
4
+ // (--junitxml), TestNG, JUnit, Vitest and Jest.
5
+ //
6
+ // readJunit(xml) -> [{ name, classname, file, suite, result, message }]
7
+ // result: "pass", "fail" or "skipped". An <error> is a fail too.
8
+ //
9
+ // The reader has no dependency. It reads only the parts it needs: the
10
+ // <testsuite> nesting, each <testcase> and its <failure>, <error> and
11
+ // <skipped> children. It refuses text that holds no <testsuites> or
12
+ // <testsuite> element, so a broken report is not read as "no tests".
13
+ export class ReportError extends Error {}
14
+
15
+ const ENTITIES = { lt: '<', gt: '>', amp: '&', quot: '"', apos: "'" };
16
+ export const unescapeXml = (text) => String(text).replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
17
+ if (code[0] === '#') return String.fromCodePoint(code[1].toLowerCase() === 'x' ? parseInt(code.slice(2), 16) : Number(code.slice(1)));
18
+ return ENTITIES[code.toLowerCase()] ?? whole;
19
+ });
20
+
21
+ function attributes(text) {
22
+ const out = {};
23
+ for (const m of String(text).matchAll(/([A-Za-z_:][\w:.-]*)\s*=\s*("([^"]*)"|'([^']*)')/g)) out[m[1]] = unescapeXml(m[3] ?? m[4]);
24
+ return out;
25
+ }
26
+
27
+ // One tag at a time: comments, the XML head and CDATA are skipped.
28
+ const TAG = /<!--[\s\S]*?-->|<\?[\s\S]*?\?>|<!\[CDATA\[[\s\S]*?\]\]>|<(\/?)([A-Za-z_][\w:.-]*)((?:\s+[^\s=/>]+\s*=\s*(?:"[^"]*"|'[^']*'))*)\s*(\/?)>/g;
29
+
30
+ export function readJunit(xml) {
31
+ const text = String(xml ?? '');
32
+ if (!/<testsuites?[\s>/]/.test(text)) throw new ReportError('the report holds no <testsuites> or <testsuite> element');
33
+ const cases = [];
34
+ const suites = [];
35
+ let current = null;
36
+ for (const m of text.matchAll(TAG)) {
37
+ const [, closing, tag, attrs, selfClosing] = m;
38
+ if (!tag) continue;
39
+ if (tag === 'testsuite') {
40
+ if (closing) suites.pop();
41
+ else if (!selfClosing) suites.push(attributes(attrs));
42
+ continue;
43
+ }
44
+ if (tag === 'testcase') {
45
+ if (closing) { current = null; continue; }
46
+ const a = attributes(attrs);
47
+ const suite = suites.map((s) => s.name).filter(Boolean).join(' > ');
48
+ const file = a.file ?? suites.map((s) => s.file).filter(Boolean).at(-1) ?? null;
49
+ const one = { name: a.name ?? '', classname: a.classname ?? null, file, suite: suite || null, result: 'pass', message: null };
50
+ cases.push(one);
51
+ current = selfClosing ? null : one;
52
+ continue;
53
+ }
54
+ if (!current || closing) continue;
55
+ if (tag === 'failure' || tag === 'error') {
56
+ current.result = 'fail';
57
+ current.message ??= attributes(attrs).message ?? tag;
58
+ } else if (tag === 'skipped' && current.result !== 'fail') {
59
+ current.result = 'skipped';
60
+ current.message ??= attributes(attrs).message ?? null;
61
+ }
62
+ }
63
+ return cases;
64
+ }
package/tests/kept.mjs ADDED
@@ -0,0 +1,185 @@
1
+ #!/usr/bin/env node
2
+ // Path 1 of onboarding: the tests an area already keeps (design
3
+ // docs/design-docs/kept-tests). The kept tests are never edited. For each
4
+ // scenario with `kept-tests: <file>` in its front matter, this:
5
+ // 1. runs the kept runner of the scenario's `kept` way in, once, for that
6
+ // file. The way in's command holds {file} and {report}. The run goes
7
+ // through the command driver, so the runner gets the allow-list env, the
8
+ // env of the way in, a throwaway HOME, and the address guards;
9
+ // 2. reads the JUnit report that the runner wrote (junit.mjs);
10
+ // 3. sets the result of each assertion from the test cases that its
11
+ // "Kept checks" lines name: every case passed is `pass`; a failed case is
12
+ // `fail`; a skipped case, a case that is not in the report, or an
13
+ // assertion that names no case is `not checked`;
14
+ // 4. writes one evidence file for the scenario, in the same schema and run
15
+ // folder as a scenario test. `atlas tests check`, `proof`, `verdict`,
16
+ // `covers` and `sweep` read it as they read any other.
17
+ // A case of the report that no line maps, and that no "not mapped" line lists,
18
+ // is a finding: every kept test case is named once.
19
+ //
20
+ // Usage:
21
+ // node kept.mjs --root <area> [--evidence <dir>] [--scenario <id>]
22
+ // ATLAS_ENV, ATLAS_RUN_ID, ATLAS_EVIDENCE_DIR and ATLAS_GUARDS_FROM work as for a scenario test.
23
+ // Exit 0 when every assertion passed and there is no finding, 1 otherwise, 2 for a bad call.
24
+ import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
25
+ import { tmpdir } from 'node:os';
26
+ import { join, relative, resolve } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { Blocked } from './errors.mjs';
29
+ import { Redactor } from './redact.mjs';
30
+ import { readTestsYaml, checkTestsYaml, compareGuardParts } from './tests-yaml.mjs';
31
+ import { readScenario, addFingerprints, normalize } from './scenario.mjs';
32
+ import { Evidence } from './evidence.mjs';
33
+ import { makeRunId } from './fresh.mjs';
34
+ import { hostsOf } from './guards.mjs';
35
+ import { parseLimit } from './wait.mjs';
36
+ import { makeDriver } from './drivers/index.mjs';
37
+ import { shellWord } from './drivers/command.mjs';
38
+ import { readJunit } from './junit.mjs';
39
+
40
+ function scenarioFiles(dir) {
41
+ if (!existsSync(dir)) return [];
42
+ const out = [];
43
+ for (const name of readdirSync(dir).sort()) {
44
+ const full = join(dir, name);
45
+ if (statSync(full).isDirectory()) { if (name !== 'node_modules') out.push(...scenarioFiles(full)); continue; }
46
+ if (name.endsWith('.md') && name !== 'map.md' && name !== 'README.md') out.push(full);
47
+ }
48
+ return out;
49
+ }
50
+
51
+ // The result of one assertion from the cases it names.
52
+ export function resultOf(named, byName) {
53
+ if (!named.length) return { result: 'not checked', cases: [], why: 'the assertion names no kept test case' };
54
+ const cases = named.map(({ name }) => {
55
+ const found = byName.get(normalize(name)) ?? [];
56
+ if (!found.length) return { name, result: 'missing', message: 'not in the report' };
57
+ const failed = found.find((c) => c.result === 'fail');
58
+ const skipped = found.find((c) => c.result === 'skipped');
59
+ return { name, result: failed ? 'fail' : skipped ? 'skipped' : 'pass', message: (failed ?? skipped)?.message ?? null };
60
+ });
61
+ if (cases.some((c) => c.result === 'fail')) return { result: 'fail', cases };
62
+ if (cases.some((c) => c.result !== 'pass')) return { result: 'not checked', cases, why: 'a case was skipped or is not in the report' };
63
+ return { result: 'pass', cases };
64
+ }
65
+
66
+ export async function runKept({ root, evidenceDir, environment, runId = makeRunId(), guardsFrom = process.env.ATLAS_GUARDS_FROM, only = null, adapter = {}, write = () => {} } = {}) {
67
+ root = resolve(root);
68
+ evidenceDir = resolve(evidenceDir ?? process.env.ATLAS_EVIDENCE_DIR ?? join(root, 'test', 'evidence'));
69
+ environment ??= process.env.ATLAS_ENV ?? (process.env.CI ? 'ci' : 'local');
70
+ if (!existsSync(join(root, 'atlas', 'tests.yaml'))) {
71
+ write(`there is no atlas/tests.yaml under ${root}; path 1 needs a kept way in there\n`);
72
+ return { runId, results: [], findings: ['no atlas/tests.yaml'], exit: 1 };
73
+ }
74
+ const yaml = readTestsYaml(join(root, 'atlas', 'tests.yaml'));
75
+ const bad = checkTestsYaml(yaml);
76
+ if (bad.length) throw new Error(`atlas/tests.yaml: ${bad.map((f) => `line ${f.line}: ${f.message}`).join('; ')}`);
77
+ const tests = yaml.value;
78
+ let refusal = null;
79
+ if (guardsFrom) {
80
+ try {
81
+ const changes = compareGuardParts(readFileSync(guardsFrom, 'utf8'), tests);
82
+ if (changes.length) refusal = `the guard parts of atlas/tests.yaml differ from the main branch copy (${changes.map((c) => c.part).join(', ')}); the run is refused`;
83
+ } catch (error) { refusal = `ATLAS_GUARDS_FROM names a file that cannot be read as tests.yaml (${error.code ?? error.message}); the run is refused`; }
84
+ }
85
+ const findings = [];
86
+ const results = [];
87
+ for (const file of scenarioFiles(join(root, 'scenarios'))) {
88
+ const sc = readScenario(file);
89
+ if (!sc.keptFile || (only && sc.id !== only)) continue;
90
+ const rel = relative(root, file);
91
+ if (sc.problems.length) { findings.push(`${rel}: ${sc.problems.map((p) => `line ${p.line}: ${p.message}`).join('; ')}`); continue; }
92
+ addFingerprints(sc, tests);
93
+ const way = sc.through.find((w) => tests['ways-in']?.[w]?.driver === 'kept');
94
+ if (!way) { findings.push(`${rel}: through names no way in with driver kept`); continue; }
95
+ const spec = tests['ways-in'][way];
96
+ const redactor = new Redactor();
97
+ const evidence = new Evidence({ evidenceDir, runId, environment, scenario: sc, root, redactor });
98
+ const one = { scenario: sc.id, way, verdict: null, assertions: [] };
99
+ results.push(one);
100
+ const started = new Date().toISOString();
101
+ const block = (reason) => {
102
+ evidence.setWay(way, { verdict: 'blocked', reason, started, ended: new Date().toISOString() });
103
+ for (const a of sc.assertions) one.assertions.push({ id: a.fullId, result: 'blocked' });
104
+ one.verdict = 'blocked';
105
+ findings.push(`${rel}: blocked: ${reason}`);
106
+ };
107
+ if (refusal) { block(refusal); continue; }
108
+ if ([].concat(sc.fields['runs-in'] ?? []).map(String).includes(environment) === false) {
109
+ for (const a of sc.assertions) for (const w of a.ways ?? sc.through) evidence.setResult(a.fullId, w, 'not here', { why: `runs-in does not list ${environment}` });
110
+ evidence.setWay(way, { verdict: 'not here', reason: null, started, ended: new Date().toISOString() });
111
+ one.verdict = 'not here';
112
+ continue;
113
+ }
114
+ const reportDir = mkdtempSync(join(tmpdir(), `atlas-${runId}-kept-`));
115
+ const report = join(reportDir, 'report.xml');
116
+ const command = String(spec.command).replaceAll('{file}', shellWord(sc.keptFile)).replaceAll('{report}', shellWord(report));
117
+ let driver = null;
118
+ try {
119
+ driver = await makeDriver({
120
+ way, spec: { driver: 'command', command, env: spec.env, offline: spec.offline, cwd: spec.cwd },
121
+ env: { vars: {}, standIns: {} }, root, hosts: hostsOf(tests), redactor, runId, adapter,
122
+ record: (call) => evidence.addCall(call),
123
+ });
124
+ const answer = await driver.call({ timeoutMs: Math.max(1, Math.round(parseLimit(sc.fields.limit ?? '5m'))) });
125
+ if (answer.body.timedOut) { block(`the kept runner did not end within ${sc.fields.limit ?? '5m'}`); continue; }
126
+ if (!existsSync(report)) { block(`the kept runner wrote no report (exit ${answer.body.code}). Last output: ${redactor.text(String(answer.body.stderr || answer.body.stdout).trim().slice(-300))}`); continue; }
127
+ let cases;
128
+ try { cases = readJunit(readFileSync(report, 'utf8')); } catch (error) { block(`the report of the kept runner cannot be read: ${error.message}`); continue; }
129
+ const byName = new Map();
130
+ for (const c of cases) { const k = normalize(c.name); if (!byName.has(k)) byName.set(k, []); byName.get(k).push(c); }
131
+ for (const a of sc.assertions) {
132
+ const r = resultOf(sc.keptChecks.get(a.id) ?? [], byName);
133
+ for (const w of a.ways ?? sc.through) evidence.setResult(a.fullId, w, r.result, { kept: sc.keptFile, cases: r.cases, ...(r.why ? { why: r.why } : {}) });
134
+ one.assertions.push({ id: a.fullId, result: r.result });
135
+ for (const c of r.cases) if (c.result === 'missing') findings.push(`${rel}: ${a.id} names "${c.name}", which the report of ${sc.keptFile} does not hold`);
136
+ }
137
+ const named = new Set([...[...sc.keptChecks.values()].flat(), ...sc.notMapped].map((k) => normalize(k.name)));
138
+ for (const k of [...byName.keys()].filter((k) => !named.has(k))) {
139
+ findings.push(`${rel}: the case "${byName.get(k)[0].name}" of ${sc.keptFile} is not mapped; map it to an assertion, or list it under not mapped with a reason`);
140
+ evidence.note(`case not mapped: ${byName.get(k)[0].name}`);
141
+ }
142
+ const all = one.assertions.map((x) => x.result);
143
+ one.verdict = all.every((x) => x === 'pass') ? 'pass' : 'fail';
144
+ evidence.setWay(way, { verdict: one.verdict, reason: one.verdict === 'pass' ? null : 'an assertion did not pass', started, ended: new Date().toISOString() });
145
+ } catch (error) {
146
+ if (!(error instanceof Blocked)) throw error;
147
+ block(error.message);
148
+ } finally {
149
+ await driver?.close();
150
+ rmSync(reportDir, { recursive: true, force: true });
151
+ }
152
+ }
153
+ for (const one of results) {
154
+ write(`${one.scenario} ${one.verdict}\n`);
155
+ for (const a of one.assertions) write(` ${a.id} ${a.result}\n`);
156
+ }
157
+ for (const f of findings) write(`finding ${f}\n`);
158
+ if (!results.length) write('no scenario names kept-tests\n');
159
+ const green = !findings.length && results.every((r) => r.verdict === 'pass' || r.verdict === 'not here');
160
+ return { runId, results, findings, exit: green ? 0 : 1 };
161
+ }
162
+
163
+ export function parseArgs(argv) {
164
+ const out = {};
165
+ for (let i = 0; i < argv.length; i += 1) {
166
+ const m = /^--(root|evidence|scenario)(?:=(.*))?$/.exec(argv[i]);
167
+ if (!m) throw new Error(`unknown argument ${argv[i]}`);
168
+ out[m[1]] = m[2] ?? argv[++i];
169
+ if (out[m[1]] === undefined) throw new Error(`--${m[1]} needs a value`);
170
+ }
171
+ if (!out.root) throw new Error('--root <area> is needed');
172
+ return out;
173
+ }
174
+
175
+ export async function main(argv, write = (text) => process.stdout.write(text)) {
176
+ let args;
177
+ try { args = parseArgs(argv); } catch (error) { write(`${error.message}\nUse: node kept.mjs --root <area> [--evidence <dir>] [--scenario <id>]\n`); return 2; }
178
+ const { exit, runId } = await runKept({ root: args.root, evidenceDir: args.evidence, only: args.scenario ?? null, write });
179
+ write(`run ${runId}\n`);
180
+ return exit;
181
+ }
182
+
183
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
184
+ main(process.argv.slice(2)).then((code) => { process.exitCode = code; }, (error) => { process.stderr.write(`${error.message}\n`); process.exitCode = 2; });
185
+ }
@@ -289,11 +289,73 @@ function checkLinks(scenarios, root, testsDir, findings, rel) {
289
289
  for (const s of scenarios) {
290
290
  for (const a of s.assertions) {
291
291
  const named = (by.get(`${s.id}/${a.id}`) ?? []).filter((r) => r.kind !== 'waitFor');
292
+ // Path 1: the kept test cases of the "Kept checks" section are the test of the assertion.
293
+ if (!named.length && s.keptFile) continue;
292
294
  if (!named.length) findings.push({ file: rel(s.file), line: a.line, code: 'no-test', message: `no test names ${s.id}/${a.id}#${a.fp ?? '??????'}` });
293
295
  }
294
296
  }
295
297
  }
296
298
 
299
+ // Path 1 (docs/design-docs/kept-tests). A scenario with kept-tests names a kept file that
300
+ // exists, goes through a kept way in, maps each assertion to at least one test case, and
301
+ // names each case once. A kept way in with `files` has a scenario for each file it matches.
302
+ const keptCodes = { file: 'kept-file-missing', way: 'kept-no-way', unmapped: 'kept-not-mapped', twice: 'kept-mapped-twice' };
303
+ function checkKept(s, tests, root, findings, rel) {
304
+ if (!s.keptFile) return;
305
+ const at = s.fieldLine('kept-tests');
306
+ if (!existsSync(join(root, s.keptFile))) findings.push({ file: rel(s.file), line: at, code: keptCodes.file, message: `kept-tests names ${s.keptFile}, which does not exist under the area` });
307
+ if (tests && !s.through.some((w) => tests['ways-in']?.[w]?.driver === 'kept')) findings.push({ file: rel(s.file), line: s.fieldLine('through'), code: keptCodes.way, message: 'a scenario with kept-tests goes through a way in with driver kept' });
308
+ for (const a of s.assertions) {
309
+ if (!(s.keptChecks.get(a.id) ?? []).length) findings.push({ file: rel(s.file), line: a.line, code: keptCodes.unmapped, message: `${s.id}/${a.id} names no kept test case in "Kept checks"` });
310
+ }
311
+ const seen = new Map();
312
+ for (const [id, list] of s.keptChecks) {
313
+ if (!s.assertions.some((a) => a.id === id)) findings.push({ file: rel(s.file), line: list[0].line, code: 'unknown-assertion', message: `a kept check names ${id}, which ${s.id} does not have` });
314
+ for (const k of list) {
315
+ const key = k.name.normalize('NFC').replace(/\s+/g, ' ').trim();
316
+ if (seen.has(key)) findings.push({ file: rel(s.file), line: k.line, code: keptCodes.twice, message: `the case "${k.name}" is named twice; name each kept case once` });
317
+ seen.set(key, k.line);
318
+ }
319
+ }
320
+ for (const k of s.notMapped) {
321
+ const key = k.name.normalize('NFC').replace(/\s+/g, ' ').trim();
322
+ if (seen.has(key)) findings.push({ file: rel(s.file), line: k.line, code: keptCodes.twice, message: `the case "${k.name}" is mapped and also listed as not mapped` });
323
+ seen.set(key, k.line);
324
+ }
325
+ }
326
+
327
+ // The files that a pattern of a kept way in matches: "*" stays inside one folder, "**" crosses folders.
328
+ export function globToRegExp(pattern) {
329
+ let out = '';
330
+ const text = String(pattern).replace(/^\.\//, '');
331
+ for (let i = 0; i < text.length; i += 1) {
332
+ const c = text[i];
333
+ if (c === '*' && text[i + 1] === '*') { out += text[i + 2] === '/' ? '(?:.+/)?' : '.*'; i += text[i + 2] === '/' ? 2 : 1; }
334
+ else if (c === '*') out += '[^/]*';
335
+ else if (c === '?') out += '[^/]';
336
+ else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
337
+ }
338
+ return new RegExp(`^${out}$`);
339
+ }
340
+
341
+ export function keptFilesOf(root, pattern) {
342
+ const re = globToRegExp(pattern);
343
+ const relOf = (f) => relative(root, f).split('\\').join('/');
344
+ return walk(root, { accept: (f) => re.test(relOf(f)) }).map(relOf);
345
+ }
346
+
347
+ function checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath) {
348
+ const mapped = new Set(scenarios.filter((s) => s.keptFile).map((s) => s.keptFile.replace(/^\.\//, '')));
349
+ for (const [name, way] of Object.entries(tests?.['ways-in'] ?? {})) {
350
+ if (way?.driver !== 'kept' || !Array.isArray(way.files)) continue;
351
+ for (const pattern of way.files) {
352
+ for (const file of keptFilesOf(root, pattern)) {
353
+ if (!mapped.has(file)) findings.push({ file: rel(yamlPath), line: 1, code: 'kept-file-no-scenario', message: `${file} matches files of the kept way in "${name}", and no scenario names it in kept-tests` });
354
+ }
355
+ }
356
+ }
357
+ }
358
+
297
359
  export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
298
360
  const yamlPath = join(root, 'atlas', 'tests.yaml');
299
361
  let tests = null;
@@ -329,6 +391,8 @@ export function checkStatic({ root, testsDir, guardsFrom, rel, findings }) {
329
391
  }
330
392
  }
331
393
  for (const s of scenarios) checkWaitsAndWhens(s, findings, rel);
394
+ for (const s of scenarios) checkKept(s, tests, root, findings, rel);
395
+ checkKeptFiles(scenarios, tests, root, findings, rel, yamlPath);
332
396
  checkLinks(scenarios, root, testsDir, findings, rel);
333
397
  return { tests, scenarios };
334
398
  }
@@ -271,6 +271,31 @@ export function parseScenario(source, file = 'scenario.md') {
271
271
  seen.add(m[1]);
272
272
  out.assertions.push({ id: m[1], kind: m[2], gate: m[3] === 'gate', words: m[4].trim(), line: item.line, names: names(m[4]), fp: null });
273
273
  }
274
+ // Path 1 (docs/design-docs/kept-tests): `kept-tests` names one kept test file, and the
275
+ // "Kept checks" section maps each assertion to the test cases of that file that check it.
276
+ // - e1: "status prints one JSON line"
277
+ // - not mapped: "the help text lists status" (why it maps to no assertion)
278
+ out.keptFile = typeof f['kept-tests'] === 'string' && f['kept-tests'].trim() ? f['kept-tests'].trim() : null;
279
+ if (f['kept-tests'] !== undefined && !out.keptFile) problem(out.fieldLine('kept-tests'), 'kept-tests names one kept test file, as a path under the area');
280
+ out.keptChecks = new Map();
281
+ out.notMapped = [];
282
+ const keptLines = sectionLines.get('kept checks') ?? [];
283
+ if (keptLines.length && !out.keptFile) problem(out.sections.get('kept checks'), 'a "Kept checks" section needs kept-tests in the front matter');
284
+ if (out.keptFile && !out.sections.has('kept checks')) problem(out.fieldLine('kept-tests'), 'a scenario with kept-tests needs a "## Kept checks" section');
285
+ for (const { text: raw, number } of keptLines) {
286
+ if (!raw.trim()) continue;
287
+ const m = /^-\s+(e\d+|not mapped):\s*"((?:[^"\\]|\\.)+)"\s*(.*)$/.exec(raw);
288
+ if (!m) { problem(number, `a kept check reads - eN: "test case name", or - not mapped: "test case name" (why); found "${raw.trim().slice(0, 60)}"`); continue; }
289
+ const name = m[2].replace(/\\(.)/g, '$1');
290
+ if (m[1] === 'not mapped') {
291
+ if (!m[3].trim()) problem(number, `"${name}" is not mapped and says no reason; add it in brackets`);
292
+ out.notMapped.push({ name, why: m[3].trim().replace(/^\(|\)$/g, ''), line: number });
293
+ continue;
294
+ }
295
+ if (!out.keptChecks.has(m[1])) out.keptChecks.set(m[1], []);
296
+ out.keptChecks.get(m[1]).push({ name, line: number });
297
+ }
298
+
274
299
  if (!out.assertions.length) problem(out.sections.get('assertions') ?? 1, 'the scenario has no assertions');
275
300
  else if (!out.sections.has('assertions')) problem(1, 'the scenario has no "## Assertions" section');
276
301
  return out;
@@ -345,6 +370,9 @@ export function addFingerprints(scenario, tests) {
345
370
  lines.push(`through=${[...scenario.through].sort().join(',')}`, `runs-in=${runsIn(scenario).sort().join(',')}`);
346
371
  // A weaker pass rule is a weaker test, so `repeat` is in the print. It adds a line only when set.
347
372
  if (scenario.repeat) lines.push(`repeat=${scenario.repeat.pass}/${scenario.repeat.runs}`);
373
+ // The kept test cases that check the assertion are part of what it means (path 1).
374
+ const keptNames = scenario.keptChecks?.get(a.id);
375
+ if (scenario.keptFile && keptNames?.length) lines.push(`kept=${scenario.keptFile}:${keptNames.map((k) => normalize(k.name)).sort().join('|')}`);
348
376
  a.fp = fingerprintOf([a.words, ...lines].join('\n'));
349
377
  a.fullId = `${scenario.id}/${a.id}#${a.fp}`;
350
378
  a.ways = waysOf(a, scenario, kept);
@@ -38,7 +38,7 @@ const SCHEMA = {
38
38
  runner: ['language', 'command'],
39
39
  environment: ENV_KEYS,
40
40
  ready: ['get', 'status', 'limit'],
41
- wayIn: ['driver', 'base', 'command', 'module', 'env', 'cwd', 'model', 'key', 'tools-from', 'tools', 'system-prompt', 'offline', 'why'],
41
+ wayIn: ['driver', 'base', 'command', 'module', 'env', 'cwd', 'model', 'key', 'tools-from', 'tools', 'system-prompt', 'offline', 'files', 'why'],
42
42
  standIn: ['start', 'why', 'ready'],
43
43
  persona: ['role', 'secret', 'same-identity', 'sign-in', 'make', 'of', 'why'],
44
44
  setup: ['with', 'steps', 'gives', 'why'],
@@ -170,6 +170,12 @@ export function checkTestsYaml(parsed) {
170
170
  if (!way.driver) say('tests-yaml-missing-field', [...path, 'driver'], `the field "${[...path, 'driver'].join('.')}" is needed`);
171
171
  if (way.key !== undefined && !secretNamed(way.key)) say('unknown-name', [...path, 'key'], `"key" names ${way.key}, which "secrets" does not list`);
172
172
  if (way.driver === 'command' && way.command === undefined) say('tests-yaml-missing-field', [...path, 'command'], 'a command way in needs "command"');
173
+ // Path 1: the kept runner. Its command holds {file} and {report} (docs/design-docs/kept-tests).
174
+ if (way.driver === 'kept') {
175
+ if (typeof way.command !== 'string') say('tests-yaml-missing-field', [...path, 'command'], 'a kept way in needs "command": the kept runner, with {file} and {report}');
176
+ else for (const hole of ['{file}', '{report}']) if (!way.command.includes(hole)) say('tests-yaml', [...path, 'command'], `the command of a kept way in needs ${hole}`);
177
+ if (way.files !== undefined && (!Array.isArray(way.files) || !way.files.length || way.files.some((f) => typeof f !== 'string'))) say('tests-yaml', [...path, 'files'], '"files" is a list of paths or patterns, such as "test/*.test.mjs"');
178
+ } else if (way.files !== undefined) say('tests-yaml', [...path, 'files'], '"files" belongs to a kept way in');
173
179
  if (way.offline !== undefined && way.offline !== true) say('tests-yaml', [...path, 'offline'], '"offline" is true, or it is left out');
174
180
  if (way.driver === 'agent') {
175
181
  for (const key of ['model', 'tools-from', 'tools']) if (way[key] === undefined) say('tests-yaml-missing-field', [...path, key], `an agent way in needs "${key}"`);