@asrunjs/asrun 0.11.0 → 0.12.0

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 (60) hide show
  1. package/.claude/commands/explore-case.md +2 -1
  2. package/.claude/commands/gen-spec.md +2 -1
  3. package/.claude/commands/heal-spec.md +2 -1
  4. package/.claude/commands/make-case.md +2 -1
  5. package/.claude/skills/exploring-cases/SKILL.md +14 -6
  6. package/.claude/skills/exploring-cases/references/case-rules.md +5 -0
  7. package/.claude/skills/exploring-cases/references/open-questions.md +8 -0
  8. package/.claude/skills/exploring-cases/references/user-handoff.md +12 -8
  9. package/.claude/skills/generating-specs/SKILL.md +17 -10
  10. package/.claude/skills/generating-specs/references/conventions.md +6 -2
  11. package/.claude/skills/making-cases/SKILL.md +12 -5
  12. package/.claude/skills/making-cases/references/writes-and-email.md +5 -2
  13. package/AGENTS.md +20 -5
  14. package/README.md +10 -6
  15. package/dist/lib/case-doc.d.ts +1 -1
  16. package/dist/lib/config.d.ts +26 -0
  17. package/dist/lib/config.js +51 -0
  18. package/dist/lib/fixtures.d.ts +1 -1
  19. package/dist/lib/fixtures.js +7 -4
  20. package/dist/lib/records.d.ts +17 -2
  21. package/dist/lib/records.js +32 -2
  22. package/dist/lib/rules.d.ts +57 -0
  23. package/dist/lib/rules.js +140 -0
  24. package/dist/scripts/cleanup.js +10 -1
  25. package/dist/scripts/cli.js +9 -1
  26. package/dist/scripts/doctor.js +27 -48
  27. package/dist/scripts/lint-specs.js +23 -1
  28. package/dist/scripts/rules.d.ts +1 -0
  29. package/dist/scripts/rules.js +148 -0
  30. package/docs/agents/case-format.md +8 -5
  31. package/docs/ai-workflow.md +21 -12
  32. package/docs/cases.md +10 -6
  33. package/docs/commands.md +22 -4
  34. package/docs/install.md +2 -1
  35. package/docs/theme-guide.html +24 -3
  36. package/docs/troubleshooting.md +11 -2
  37. package/docs/web-ui.md +12 -5
  38. package/package.json +2 -1
  39. package/templates/.claude/commands/explore-case.md +3 -1
  40. package/templates/.claude/commands/gen-spec.md +3 -1
  41. package/templates/.claude/commands/heal-spec.md +3 -1
  42. package/templates/.claude/commands/make-case.md +3 -1
  43. package/templates/AGENTS.md +17 -8
  44. package/templates/cases/_template.md +3 -3
  45. package/templates/package.json +4 -0
  46. package/ui/dist/assets/{dist--BXJInTq.js → dist-BX88bMYN.js} +1 -1
  47. package/ui/dist/assets/{dist-BMmNFIUH.js → dist-Bfv586Or.js} +1 -1
  48. package/ui/dist/assets/{dist-C9ra6_BD.js → dist-BjlbPf3t.js} +1 -1
  49. package/ui/dist/assets/{dist-ByLbEOlF.js → dist-CJnqQMvX.js} +1 -1
  50. package/ui/dist/assets/{dist-DTYj2vUD.js → dist-CaAcCNRS.js} +1 -1
  51. package/ui/dist/assets/{dist-BXNaYHeh.js → dist-Clj0wGFy.js} +1 -1
  52. package/ui/dist/assets/{dist-CTrwRc3Q.js → dist-D8TUm3Ai.js} +1 -1
  53. package/ui/dist/assets/{dist-CK8mow8u.js → dist-DUDqh_3e.js} +1 -1
  54. package/ui/dist/assets/{dist-CQS0So-i.js → dist-DkAx5a-Y.js} +1 -1
  55. package/ui/dist/assets/{dist-mV0_nQZe.js → dist-cuboTVh-.js} +1 -1
  56. package/ui/dist/assets/index-DaUQORCy.js +353 -0
  57. package/ui/dist/assets/index-j3W4BBCj.css +2 -0
  58. package/ui/dist/index.html +2 -2
  59. package/ui/dist/assets/index-BzMqkmQ9.js +0 -348
  60. package/ui/dist/assets/index-DcJR-8Mt.css +0 -2
@@ -1,9 +1,39 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { DEFAULT_TEST_DATA_PREFIX, testDataPrefix } from './config.js';
3
4
  import { ROOT } from './paths.js';
4
- /** Every piece of data a test creates carries this prefix, so it can never be mistaken for real data. */
5
- export const TEST_DATA_PREFIX = '[asrun]';
5
+ /**
6
+ * The default prefix of data a test creates. The project's `package.json` may set another one
7
+ * (`"asrun": { "testDataPrefix": "[autotest]" }`); always read the effective value with `testDataPrefix()`.
8
+ */
9
+ export const TEST_DATA_PREFIX = DEFAULT_TEST_DATA_PREFIX;
6
10
  export const RECORDS_DIR = path.join(ROOT, '.tmp', 'records');
11
+ /** The run stamp appended to every name a test creates: the run id's UTC time (`2026-11-09 12-34-56`). */
12
+ export function testDataStamp(now = new Date()) {
13
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2})-(\d{2})-(\d{2})/.exec(process.env.ASRUN_RUN_ID ?? '');
14
+ if (match !== null)
15
+ return `${match[1]} ${match[2]}-${match[3]}-${match[4]}`;
16
+ return now.toISOString().slice(0, 19).replace('T', ' ').replaceAll(':', '-');
17
+ }
18
+ let stamp = null;
19
+ /**
20
+ * The name of one piece of test data: `<base> (<run stamp>)`, for example
21
+ * `[autotest] Draft CMA (2026-11-09 12-34-56)`.
22
+ *
23
+ * The stamp is the run id's UTC time, so it matches the report folder and the ledger entry, and two runs never
24
+ * share a name: a person can search the product for the run's own data, edit it or delete it by hand. The base is
25
+ * the case's literal from `## Values`; it must start with the project's prefix. Calling this twice with the same base
26
+ * (a title before and after an edit, a retry) returns the same name.
27
+ */
28
+ export function testDataName(base) {
29
+ const prefix = testDataPrefix();
30
+ if (!base.startsWith(prefix)) {
31
+ throw new Error(`Test data must start with the project's prefix "${prefix}" (got "${base}"). ` +
32
+ `Set "asrun.testDataPrefix" in package.json when the project uses another marker.`);
33
+ }
34
+ const suffix = ` (${(stamp ??= testDataStamp())})`;
35
+ return base.endsWith(suffix) ? base : `${base}${suffix}`;
36
+ }
7
37
  /** The ledger of one test: `.tmp/records/<runId>/<file>.json`. One file per test, so parallel workers never share a file. */
8
38
  export const ledgerFile = (runId, testId) => path.join(RECORDS_DIR, runId, `${testId.replace(/[^\w-]/g, '_')}.json`);
9
39
  export function writeLedger(file, entries) {
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The rule layers a project may change, and how they combine. Read by `pnpm asrun doctor` (validation) and by
3
+ * `pnpm asrun rules` (the digest an AI step reads before it applies them): one parser, so the two never disagree.
4
+ *
5
+ * `AGENTS.md` §2 is a table:
6
+ *
7
+ * ```
8
+ * | Rule | Project rule | Effect |
9
+ * |---|---|---|
10
+ * | F10 | Case prose in Simplified Chinese. | replaces |
11
+ * | P1 | Test data is prefixed "[autotest]". | adds |
12
+ * ```
13
+ *
14
+ * A rule id names a baseline rule the row `narrows` (stricter) or `replaces` (a different default). A rule with no
15
+ * baseline counterpart uses a project id (`P1`, `P2`, ...) and `adds`. Rows outside the table, and rows whose first
16
+ * cell is not a rule id, are documentation and are ignored.
17
+ */
18
+ export interface BaselineRule {
19
+ id: string;
20
+ /** The rule's own sentence, joined from the lines that follow the id. */
21
+ text: string;
22
+ /** `[non-negotiable]`: no project rule may change it. */
23
+ locked: boolean;
24
+ }
25
+ export interface ProjectRule {
26
+ id: string;
27
+ text: string;
28
+ effect: string;
29
+ /** 1-based line in `AGENTS.md`. */
30
+ line: number;
31
+ }
32
+ export interface ProjectRulesCheck {
33
+ rules: ProjectRule[];
34
+ errors: string[];
35
+ warnings: string[];
36
+ }
37
+ /** Every rule the framework baseline defines, in document order. */
38
+ export declare function baselineRules(): BaselineRule[];
39
+ /** The §2 table rows of a project's `AGENTS.md`, with what the enclosing text says about each. */
40
+ export declare function parseProjectRules(agentsText: string): ProjectRule[];
41
+ /** Validates §2 against the baseline. The messages are the ones `doctor` and `rules` print. */
42
+ export declare function checkProjectRules(rules: ProjectRule[], baseline?: BaselineRule[]): ProjectRulesCheck;
43
+ /** Shorthand for the common call: read `AGENTS.md` and validate its §2. */
44
+ export declare function readProjectRules(agentsPath: string): ProjectRulesCheck & {
45
+ missing: boolean;
46
+ };
47
+ /** The project's §2 rows as one-line entries, for a report or the `rules` command. */
48
+ export declare function describeProjectRule(rule: ProjectRule): string;
49
+ /**
50
+ * Bracketed words an `AGENTS.md` line names as the test-data prefix (a line that talks about a prefix or test data).
51
+ * The runtime can only read `package.json` `"asrun.testDataPrefix"`, so a document that names a different one must be
52
+ * reported: a generated spec would otherwise use one prefix and the document assert another.
53
+ */
54
+ export declare function mentionedTestDataPrefixes(agentsText: string): Array<{
55
+ token: string;
56
+ line: number;
57
+ }>;
@@ -0,0 +1,140 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { FRAMEWORK_ROOT } from './paths.js';
4
+ const RULE_ID = /^[A-Z]\d+$/;
5
+ const PROJECT_ID = /^P\d+$/;
6
+ const EFFECTS = new Set(['narrows', 'replaces', 'adds']);
7
+ function read(file) {
8
+ try {
9
+ return fs.readFileSync(file, 'utf8');
10
+ }
11
+ catch {
12
+ return null;
13
+ }
14
+ }
15
+ /** Every rule the framework baseline defines, in document order. */
16
+ export function baselineRules() {
17
+ const text = read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '';
18
+ const rules = [];
19
+ const lines = text.split('\n');
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const match = /^- \*\*([A-Z]\d+)\s+—\s*(.*)$/.exec(lines[i].trim());
22
+ if (match === null)
23
+ continue;
24
+ const body = [match[2]];
25
+ for (let j = i + 1; j < lines.length; j++) {
26
+ const line = lines[j];
27
+ if (line.trim() === '' || /^- /.test(line.trim()))
28
+ break;
29
+ body.push(line.trim());
30
+ }
31
+ rules.push({ id: match[1], text: body.join(' ').replace(/\s+/g, ' ').trim(), locked: lines[i].includes('[non-negotiable]') });
32
+ }
33
+ return rules;
34
+ }
35
+ /** The §2 table rows of a project's `AGENTS.md`, with what the enclosing text says about each. */
36
+ export function parseProjectRules(agentsText) {
37
+ const rules = [];
38
+ let fenced = false;
39
+ agentsText.split('\n').forEach((line, index) => {
40
+ if (line.trim().startsWith('```')) {
41
+ fenced = !fenced;
42
+ return;
43
+ }
44
+ if (fenced || !line.trim().startsWith('|'))
45
+ return;
46
+ const cells = line
47
+ .trim()
48
+ .replace(/^\|/, '')
49
+ .replace(/\|$/, '')
50
+ .split('|')
51
+ .map((cell) => cell.trim());
52
+ if (!RULE_ID.test(cells[0]))
53
+ return;
54
+ rules.push({ id: cells[0], text: cells[1] ?? '', effect: (cells[2] ?? '').toLowerCase(), line: index + 1 });
55
+ });
56
+ return rules;
57
+ }
58
+ /** Validates §2 against the baseline. The messages are the ones `doctor` and `rules` print. */
59
+ export function checkProjectRules(rules, baseline = baselineRules()) {
60
+ const errors = [];
61
+ const warnings = [];
62
+ const known = new Map(baseline.map((rule) => [rule.id, rule]));
63
+ const seen = new Set();
64
+ for (const rule of rules) {
65
+ const where = `AGENTS.md:${rule.line}`;
66
+ if (seen.has(rule.id))
67
+ errors.push(`${where}: rule ${rule.id} appears more than once in §2.`);
68
+ seen.add(rule.id);
69
+ if (rule.text === '')
70
+ errors.push(`${where}: rule ${rule.id} has no text.`);
71
+ if (PROJECT_ID.test(rule.id)) {
72
+ if (rule.effect === '')
73
+ warnings.push(`${where}: project rule ${rule.id} has no Effect column: add "adds".`);
74
+ else if (rule.effect !== 'adds') {
75
+ errors.push(`${where}: project rule ${rule.id} has Effect "${rule.effect}"; use "adds" (there is no baseline rule ${rule.id}).`);
76
+ }
77
+ continue;
78
+ }
79
+ const base = known.get(rule.id);
80
+ if (base === undefined) {
81
+ errors.push(`${where}: ${rule.id} is not a rule of the framework baseline. Use a project id (P1, P2, ...) with "adds" ` +
82
+ 'for a rule the baseline does not have.');
83
+ }
84
+ else if (base.locked)
85
+ errors.push(`${where}: ${rule.id} is [non-negotiable]; a project rule cannot change it.`);
86
+ else if (rule.effect === '')
87
+ warnings.push(`${where}: rule ${rule.id} has no Effect column: add "narrows" or "replaces".`);
88
+ else if (!EFFECTS.has(rule.effect)) {
89
+ errors.push(`${where}: rule ${rule.id} has Effect "${rule.effect}"; use "narrows", "replaces" or "adds".`);
90
+ }
91
+ else if (rule.effect === 'adds') {
92
+ errors.push(`${where}: rule ${rule.id} exists in the baseline; use "narrows" or "replaces", or a project id ` +
93
+ '(P1, P2, ...) with "adds".');
94
+ }
95
+ }
96
+ return { rules, errors, warnings };
97
+ }
98
+ /** Shorthand for the common call: read `AGENTS.md` and validate its §2. */
99
+ export function readProjectRules(agentsPath) {
100
+ const text = read(agentsPath);
101
+ if (text === null)
102
+ return { rules: [], errors: [], warnings: [], missing: true };
103
+ const rules = parseProjectRules(text);
104
+ return { ...checkProjectRules(rules), missing: false };
105
+ }
106
+ /** The project's §2 rows as one-line entries, for a report or the `rules` command. */
107
+ export function describeProjectRule(rule) {
108
+ return `${rule.id.padEnd(4)} ${rule.effect.padEnd(9)} ${rule.text}`;
109
+ }
110
+ /** Structural tokens that look like a prefix but are not test-data names. */
111
+ const STRUCTURAL = new Set(['input', 'writes', 'email']);
112
+ /**
113
+ * Bracketed words an `AGENTS.md` line names as the test-data prefix (a line that talks about a prefix or test data).
114
+ * The runtime can only read `package.json` `"asrun.testDataPrefix"`, so a document that names a different one must be
115
+ * reported: a generated spec would otherwise use one prefix and the document assert another.
116
+ */
117
+ export function mentionedTestDataPrefixes(agentsText) {
118
+ const mentions = [];
119
+ let fenced = false;
120
+ agentsText.split('\n').forEach((line, index) => {
121
+ if (line.trim().startsWith('```')) {
122
+ fenced = !fenced;
123
+ return;
124
+ }
125
+ if (fenced)
126
+ return;
127
+ if (!/\b(prefix|prefixed|test[ -]data)\b/i.test(line))
128
+ return;
129
+ // Documentation and examples name the default or an illustration, not the project's own prefix.
130
+ if (/\b(package\.json|e\.g\.|for example|such as|example)\b/i.test(line))
131
+ return;
132
+ for (const match of line.matchAll(/`?\[([a-z0-9][a-z0-9_-]*)\]`?/gi)) {
133
+ const inner = match[1].toLowerCase();
134
+ if (STRUCTURAL.has(inner))
135
+ continue;
136
+ mentions.push({ token: `[${match[1]}]`, line: index + 1 });
137
+ }
138
+ });
139
+ return mentions;
140
+ }
@@ -1,4 +1,5 @@
1
1
  import { listEnvs } from '../lib/profiles.js';
2
+ import { testDataPrefix } from '../lib/config.js';
2
3
  import { describeLeftovers, findLeftovers, forgetLedgers } from '../lib/records.js';
3
4
  const args = process.argv.slice(2);
4
5
  const envArg = args.find((arg) => arg.startsWith('--env='))?.slice('--env='.length) ?? args[args.indexOf('--env') + 1];
@@ -15,7 +16,15 @@ if (leftovers.length === 0) {
15
16
  console.log(`No test data left behind in ${envArg.toUpperCase()}.`);
16
17
  process.exit(0);
17
18
  }
18
- console.log(`Test data left behind in ${envArg.toUpperCase()} (named "[asrun] ..."):\n${describeLeftovers(leftovers)}`);
19
+ let prefix;
20
+ try {
21
+ prefix = testDataPrefix();
22
+ }
23
+ catch (error) {
24
+ console.error(error.message);
25
+ process.exit(1);
26
+ }
27
+ console.log(`Test data left behind in ${envArg.toUpperCase()} (named "${prefix} ..."):\n${describeLeftovers(leftovers)}`);
19
28
  if (forget) {
20
29
  console.log(`\nForgot ${forgetLedgers(envArg)} ledger file(s). Only do this after the data is deleted.`);
21
30
  }
@@ -32,6 +32,12 @@ async function main() {
32
32
  process.exitCode = await migrate(rest);
33
33
  return;
34
34
  }
35
+ if (command === 'rules') {
36
+ // The script reads `process.argv` itself; drop the subcommand so it sees only its own arguments.
37
+ process.argv.splice(2, 1);
38
+ await import('./rules.js');
39
+ return;
40
+ }
35
41
  if (command === 'explore:notes') {
36
42
  // The script reads `process.argv` itself; drop the subcommand so it sees only its own arguments.
37
43
  process.argv.splice(2, 1);
@@ -44,13 +50,15 @@ async function main() {
44
50
  }
45
51
  await import('./run.js');
46
52
  }
47
- const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate|explore:notes] [options]
53
+ const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explore:notes] [options]
48
54
 
49
55
  (no subcommand) Run the tests of one environment. See "pnpm asrun --help".
50
56
  init Create the files a workspace needs (never overwrites; --update refreshes generated files,
51
57
  --root <dir> targets another folder, --force overwrites every file init writes,
52
58
  --force <path> overwrites one file).
53
59
  doctor Check the workspace: profiles, tsconfig alias, generated files, skill overrides, migrations.
60
+ rules Print the effective rules: the framework baseline, the project's AGENTS.md §2 and the project
61
+ settings (test-data prefix). An AI step reads it before it applies them (--json for machines).
54
62
  reset Remove node_modules and the files init writes, for a clean reinstall (keeps cases, specs,
55
63
  profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help").
56
64
  upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
@@ -3,6 +3,8 @@ import path from 'node:path';
3
3
  import { createRequire } from 'node:module';
4
4
  import { loadProfiles, listEnvs, PROFILES_FILE } from '../lib/profiles.js';
5
5
  import { FRAMEWORK_ROOT, ROOT, SKILLS_DIR } from '../lib/paths.js';
6
+ import { readWorkspaceConfig } from '../lib/config.js';
7
+ import { readProjectRules, mentionedTestDataPrefixes } from '../lib/rules.js';
6
8
  import { spawnTarget } from '../lib/command.js';
7
9
  import { migrationState, pendingMigrations } from '../lib/migrations.js';
8
10
  import { MIGRATIONS } from '../migrations/index.js';
@@ -22,29 +24,6 @@ function read(file) {
22
24
  return null;
23
25
  }
24
26
  }
25
- /** The ids of the framework rules that a project may not narrow, parsed from the framework's AGENTS.md. */
26
- function nonNegotiableRules() {
27
- const ids = new Set();
28
- for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
29
- const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
30
- if (match && line.includes('[non-negotiable]'))
31
- ids.add(match[1]);
32
- }
33
- return ids;
34
- }
35
- /**
36
- * Every rule id the framework baseline defines (`F1`..`F10`, `B1`..`B4`, `A1`..`A6`, `R1`..`R4`), so a project cannot
37
- * name one that does not exist.
38
- */
39
- function frameworkRuleIds() {
40
- const ids = new Set();
41
- for (const line of (read(path.join(FRAMEWORK_ROOT, 'AGENTS.md')) ?? '').split('\n')) {
42
- const match = /^- \*\*([A-Z]\d+)\b/.exec(line.trim());
43
- if (match)
44
- ids.add(match[1]);
45
- }
46
- return ids;
47
- }
48
27
  /**
49
28
  * The framework sources that `pnpm build` compiles into `dist/` and are older than it, or missing from it. Only the
50
29
  * framework repository has those sources; an installed package ships `dist/` alone, so the list is empty there.
@@ -253,8 +232,8 @@ export async function main(args) {
253
232
  warnings.push(`.claude/skills/${name} is a copy of the framework skill; delete it, or it will never see an update.`);
254
233
  }
255
234
  }
256
- // Project rules: a project may narrow or replace a baseline rule, never one of F1–F6. Rows are
257
- // `| <id> | <the project's rule> | <narrows|replaces> |`.
235
+ // Project rules: a project may narrow or replace a baseline rule, never one of F1–F6/R2, and may add its own rule
236
+ // under a project id. Rows are `| <id> | <the project's rule> | <narrows|replaces|adds> |`.
258
237
  const agents = read(path.join(ROOT, 'AGENTS.md'));
259
238
  if (agents === null) {
260
239
  warnings.push('AGENTS.md is missing: run "pnpm asrun init" and fill in the project facts.');
@@ -263,30 +242,30 @@ export async function main(args) {
263
242
  if (!agents.includes(`${framework}/AGENTS.md`)) {
264
243
  errors.push(`AGENTS.md does not point at the framework baseline (${framework}/AGENTS.md).`);
265
244
  }
266
- const locked = nonNegotiableRules();
267
- const known = frameworkRuleIds();
268
- let fenced = false;
269
- for (const line of agents.split('\n')) {
270
- // A fenced block is documentation (an example table), not the project's rules.
271
- if (line.trim().startsWith('```')) {
272
- fenced = !fenced;
273
- continue;
245
+ const project = readProjectRules(path.join(ROOT, 'AGENTS.md'));
246
+ errors.push(...project.errors);
247
+ warnings.push(...project.warnings);
248
+ if (project.rules.length > 0) {
249
+ facts.push(`project rules: ${project.rules.map((rule) => `${rule.id} (${rule.effect || '?'})`).join(', ')}`);
250
+ }
251
+ }
252
+ // The project's own settings (`package.json` "asrun"): a bad prefix makes every write case fail, so it is an error.
253
+ let prefix = null;
254
+ try {
255
+ prefix = readWorkspaceConfig().testDataPrefix;
256
+ facts.push(`test data: ${prefix} <base> (<run stamp>)`);
257
+ }
258
+ catch (error) {
259
+ errors.push(error.message);
260
+ }
261
+ // A document that names another test-data prefix cannot change the runtime: report it instead of letting a generated
262
+ // spec use one prefix while the case document says another.
263
+ if (agents !== null && prefix !== null) {
264
+ for (const mention of mentionedTestDataPrefixes(agents)) {
265
+ if (mention.token !== prefix) {
266
+ errors.push(`AGENTS.md:${mention.line} names ${mention.token} as the test-data prefix, but package.json has ` +
267
+ `"asrun.testDataPrefix": "${prefix}". Set it to "${mention.token}" (the runtime reads package.json, not prose).`);
274
268
  }
275
- if (fenced)
276
- continue;
277
- const cells = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map((cell) => cell.trim());
278
- if (!/^F\d+$/.test(cells[0]))
279
- continue;
280
- const id = cells[0];
281
- const effect = (cells[2] ?? '').toLowerCase();
282
- if (!known.has(id))
283
- errors.push(`AGENTS.md names ${id}, which is not a rule of the framework baseline.`);
284
- else if (locked.has(id))
285
- errors.push(`AGENTS.md changes ${id}, which the framework marks [non-negotiable].`);
286
- else if (effect === '')
287
- warnings.push(`AGENTS.md rule ${id} has no Effect column: add "narrows" or "replaces" (a table written before 0.3.0 predates it).`);
288
- else if (effect !== 'narrows' && effect !== 'replaces')
289
- errors.push(`AGENTS.md rule ${id} has Effect "${cells[2]}"; use "narrows" or "replaces".`);
290
269
  }
291
270
  }
292
271
  // The install and the commands the docs use.
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { listCaseFileIds } from '../lib/cases.js';
3
+ import { caseWrites, hasCaseFile, listCaseFileIds, readCaseMeta } from '../lib/cases.js';
4
4
  import { TESTS_DIR } from '../lib/paths.js';
5
5
  import { parseTargets, readCaseText, TARGET_REF } from '../lib/targets.js';
6
6
  const specFiles = (dir) => fs.existsSync(dir)
@@ -125,6 +125,28 @@ function lint(file) {
125
125
  add('fail', i, 'blocking-target', `${match[1]} still has an open [blocking] question in the case; do not build a check on it (use test.fixme).`);
126
126
  }
127
127
  });
128
+ // Test data names must be unique per run (F11): a writes case names what it creates with testDataName().
129
+ // Old specs predate the rule, so this is a warning: regenerate or heal them to clear it.
130
+ if (caseId !== undefined && hasCaseFile(caseId) && caseWrites(readCaseMeta(caseId))) {
131
+ const text = lines.join('\n');
132
+ const composed = new Set([...text.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(?:await\s+)?testDataName\s*\(/g)].map((match) => match[1]));
133
+ lines.forEach((line, i) => {
134
+ if (!/\brecords\.track\s*\(/.test(line))
135
+ return;
136
+ for (let end = i; end < Math.min(lines.length, i + 15); end++) {
137
+ const match = /^\s*name:\s*(.*?),?\s*$/.exec(lines[end]);
138
+ if (match === null)
139
+ continue;
140
+ const expression = match[1].trim();
141
+ if (/testDataName\s*\(/.test(expression))
142
+ return;
143
+ if (/^[A-Za-z_$][\w$]*$/.test(expression) && composed.has(expression))
144
+ return;
145
+ add('warn', end, 'test-data-name', `records.track() names the data "${expression}"; build it with testDataName() so the run's name carries its timestamp (F11).`);
146
+ return;
147
+ }
148
+ });
149
+ }
128
150
  TARGET_REF.lastIndex = 0;
129
151
  return findings;
130
152
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,148 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { caseSectionText } from '../lib/case-doc.js';
4
+ import { readWorkspaceConfig } from '../lib/config.js';
5
+ import { ROOT } from '../lib/paths.js';
6
+ import { baselineRules, checkProjectRules, describeProjectRule, mentionedTestDataPrefixes, parseProjectRules, } from '../lib/rules.js';
7
+ import { testDataStamp } from '../lib/records.js';
8
+ /**
9
+ * `pnpm asrun rules`: the effective rules of this workspace, as one digest. An AI step reads this before it applies
10
+ * anything, so "§2 wins over the baseline" is a fact it was told rather than a layer it has to reason about. It never
11
+ * writes anything. `--json` is the machine-readable form; `--case <id>` adds that case's `## Rules`.
12
+ */
13
+ const USAGE = `Usage: pnpm asrun rules [--json] [--case <case-id>]
14
+
15
+ Prints the framework baseline, the project's AGENTS.md §2 rules and the project settings
16
+ (test-data prefix, name format). --case <id> adds the case's own "## Rules".
17
+ Exit code 1 when §2 or a setting is invalid (the same errors "pnpm asrun doctor" shows).`;
18
+ const args = process.argv.slice(2);
19
+ if (args.includes('--help') || args.includes('-h')) {
20
+ console.log(USAGE);
21
+ process.exit(0);
22
+ }
23
+ const json = args.includes('--json');
24
+ const caseIndex = args.findIndex((arg) => arg === '--case' || arg.startsWith('--case='));
25
+ const caseId = caseIndex === -1 ? null : (args[caseIndex].includes('=') ? args[caseIndex].split('=')[1] : args[caseIndex + 1]);
26
+ if (caseIndex !== -1 && !caseId) {
27
+ console.error('Usage: pnpm asrun rules [--json] [--case <case-id>]\n --case needs a case id.');
28
+ process.exit(1);
29
+ }
30
+ const agentsFile = path.join(ROOT, 'AGENTS.md');
31
+ let agentsText;
32
+ try {
33
+ agentsText = fs.readFileSync(agentsFile, 'utf8');
34
+ }
35
+ catch {
36
+ console.error(`No AGENTS.md in ${ROOT}: run "pnpm asrun init" first.`);
37
+ process.exit(1);
38
+ }
39
+ const baseline = baselineRules();
40
+ const project = checkProjectRules(parseProjectRules(agentsText), baseline);
41
+ let config = null;
42
+ let configError = null;
43
+ try {
44
+ config = readWorkspaceConfig();
45
+ }
46
+ catch (error) {
47
+ configError = error.message;
48
+ }
49
+ let caseRules = [];
50
+ if (caseId !== null) {
51
+ const caseFile = path.join(ROOT, 'cases', `${caseId}.md`);
52
+ try {
53
+ const text = fs.readFileSync(caseFile, 'utf8');
54
+ const section = caseSectionText(text, 'Rules');
55
+ let current = null;
56
+ const flush = () => {
57
+ if (current !== null && current.length > 0)
58
+ caseRules.push({ text: current.join(' ') });
59
+ current = null;
60
+ };
61
+ for (const raw of section.split('\n')) {
62
+ if (/^-\s+\S/.test(raw.trim())) {
63
+ flush();
64
+ current = [raw.trim()];
65
+ }
66
+ else if (current !== null && /^\s+\S/.test(raw)) {
67
+ current.push(raw.trim());
68
+ }
69
+ else if (raw.trim() === '') {
70
+ flush();
71
+ }
72
+ }
73
+ flush();
74
+ }
75
+ catch {
76
+ console.error(`Case "${caseId}" not found (expected cases/${caseId}.md).`);
77
+ process.exit(1);
78
+ }
79
+ }
80
+ const prefix = config?.testDataPrefix ?? '';
81
+ const stamp = testDataStamp();
82
+ const errors = [...project.errors, ...(configError === null ? [] : [configError])];
83
+ const prefixMentions = mentionedTestDataPrefixes(agentsText);
84
+ if (config !== null) {
85
+ for (const mention of prefixMentions) {
86
+ if (mention.token !== config.testDataPrefix) {
87
+ errors.push(`AGENTS.md:${mention.line} names ${mention.token} as the test-data prefix, but package.json has ` +
88
+ `"asrun.testDataPrefix": "${config.testDataPrefix}". Set it to "${mention.token}" (the runtime reads package.json, not prose).`);
89
+ }
90
+ }
91
+ }
92
+ if (json) {
93
+ console.log(JSON.stringify({
94
+ workspace: ROOT,
95
+ baseline: baseline.map((rule) => ({ id: rule.id, locked: rule.locked, text: rule.text })),
96
+ projectRules: project.rules,
97
+ projectWarnings: project.warnings,
98
+ settings: { testDataPrefix: config?.testDataPrefix ?? null, testDataNameExample: `${prefix} <base> (${stamp})` },
99
+ prefixMentions,
100
+ ...(caseId !== null && { case: { id: caseId, rules: caseRules } }),
101
+ errors,
102
+ }, null, 2));
103
+ }
104
+ else {
105
+ const byLetter = new Map();
106
+ for (const rule of baseline) {
107
+ const letter = rule.id[0];
108
+ byLetter.set(letter, [...(byLetter.get(letter) ?? []), rule.id]);
109
+ }
110
+ console.log(`AsRun rules for ${ROOT}\n`);
111
+ console.log('Framework baseline:');
112
+ for (const [letter, ids] of byLetter) {
113
+ console.log(` ${letter}: ${ids[0]}–${ids[ids.length - 1]}`);
114
+ }
115
+ const locked = baseline.filter((rule) => rule.locked).map((rule) => rule.id);
116
+ console.log(` [non-negotiable]: ${locked.join(', ')}`);
117
+ console.log('\nProject rules (AGENTS.md §2):');
118
+ if (project.rules.length === 0)
119
+ console.log(' (none: the baseline defaults apply unchanged)');
120
+ else
121
+ for (const rule of project.rules)
122
+ console.log(` ${describeProjectRule(rule)}`);
123
+ console.log('\nProject settings:');
124
+ console.log(` testDataPrefix: ${config?.testDataPrefix ?? '(invalid)'} ` +
125
+ `(package.json "asrun.testDataPrefix"; default [asrun])`);
126
+ console.log(` test data name: ${prefix || '[asrun]'} <base> (${stamp}) (UTC, from the run id)`);
127
+ if (caseId !== null) {
128
+ console.log(`\nCase rules (cases/${caseId}.md §Rules):`);
129
+ if (caseRules.length === 0)
130
+ console.log(' (none)');
131
+ else
132
+ for (const rule of caseRules)
133
+ console.log(` - ${rule.text.replace(/^-\s+/, '')}`);
134
+ }
135
+ if (project.warnings.length > 0) {
136
+ console.log('');
137
+ for (const warning of project.warnings)
138
+ console.log(`! ${warning}`);
139
+ }
140
+ if (errors.length > 0) {
141
+ console.log('');
142
+ for (const error of errors)
143
+ console.log(`x ${error}`);
144
+ console.log('\nFix the errors above; the rules are not applied until they are valid.');
145
+ }
146
+ console.log('\nA project rule is applied as written: a difference from the baseline is never a conflict and never a question.');
147
+ }
148
+ process.exit(errors.length > 0 ? 1 : 0);
@@ -89,7 +89,7 @@ Wrong: `- Create a draft named ... and delete it at the end.` (a write → a tag
89
89
  - `- Ready when:` is a sub-line of the step: the positive signal that what the step loads is there. The spec waits
90
90
  for it before reading; it is not an assertion.
91
91
  - `- Recipe (phase):` is a sub-line of an `[input]`/`[writes]` step: what a person did in the browser during an
92
- attended exploration (positions, `[asrun]` names, control labels; no selectors). A tagged step with no recipe is a
92
+ attended exploration (positions, created names, control labels; no selectors). A tagged step with no recipe is a
93
93
  `needs-input` stop for `/gen-spec`.
94
94
  - A question block is indented under the sentence it belongs to: 4 spaces under a numbered step, 2 under a bullet.
95
95
  The parent line is the suggestion, written as a finished sentence; the `TODO` line holds one question and optional
@@ -121,7 +121,8 @@ example-staging:
121
121
  - A profile with no entry and no `default` fails with "Missing values ...". An empty value or one that starts with
122
122
  `TODO` fails with "Incomplete values ...". Fill them before running; never make a spec read the YAML itself.
123
123
  - Values hold literals only: names, labels, options, test-data names. The checks themselves stay in `## Steps` and
124
- `## Expected`, and selectors come from `## Targets`.
124
+ `## Expected`, and selectors come from `## Targets`. A name the case creates is stored here **without** its run
125
+ stamp: the literal is the base, and the spec composes `<base> (<run stamp>)` with `testDataName()` (F11).
125
126
  - No marker or question inside the yaml: it would become part of the expected value. Put it on the prose line above
126
127
  the block and name the key there.
127
128
 
@@ -201,9 +202,11 @@ test.describe('<Case title>', {tag: caseTags('<id>')}, () => {
201
202
  a case limited to some environments is skipped elsewhere; a case that writes elsewhere only is skipped with a
202
203
  notice; a write in a non-staging environment needs the confirmation the CLI sets, never an environment variable
203
204
  that was merely present.
204
- - Write cases: name everything `[asrun] ...`, register it with `records.track` right after creating it, remove it
205
- newest first (also after a failure), turn retries off with `configureCase`, and keep email steps behind
206
- `emailStepAllowed(profile, '<step title>')`.
205
+ - Write cases: name everything `<prefix> <base> (<run stamp>)` through `testDataName(base)`
206
+ (`@asrunjs/asrun/records`), where the prefix is `asrun.testDataPrefix` in `package.json` (default `[asrun]`, printed
207
+ by `pnpm asrun rules`) and the stamp is the run's UTC time; register it with `records.track` right after creating
208
+ it, remove it newest first (also after a failure), turn retries off with `configureCase`, and keep email steps
209
+ behind `emailStepAllowed(profile, '<step title>')`.
207
210
 
208
211
  ## Checks to run before reporting
209
212