@asrunjs/asrun 0.11.0 → 0.13.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.
- package/.claude/commands/explore-case.md +2 -1
- package/.claude/commands/gen-spec.md +2 -1
- package/.claude/commands/heal-spec.md +2 -1
- package/.claude/commands/make-case.md +2 -1
- package/.claude/skills/exploring-cases/SKILL.md +14 -6
- package/.claude/skills/exploring-cases/references/case-rules.md +5 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +8 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +12 -8
- package/.claude/skills/generating-specs/SKILL.md +17 -10
- package/.claude/skills/generating-specs/references/conventions.md +6 -2
- package/.claude/skills/making-cases/SKILL.md +12 -5
- package/.claude/skills/making-cases/references/writes-and-email.md +5 -2
- package/AGENTS.md +20 -5
- package/README.md +10 -6
- package/dist/lib/case-doc.d.ts +1 -1
- package/dist/lib/config.d.ts +26 -0
- package/dist/lib/config.js +51 -0
- package/dist/lib/fixtures.d.ts +1 -1
- package/dist/lib/fixtures.js +7 -4
- package/dist/lib/records.d.ts +17 -2
- package/dist/lib/records.js +32 -2
- package/dist/lib/rules.d.ts +57 -0
- package/dist/lib/rules.js +140 -0
- package/dist/scripts/cleanup.js +10 -1
- package/dist/scripts/cli.js +9 -1
- package/dist/scripts/doctor.js +27 -48
- package/dist/scripts/lint-specs.js +23 -1
- package/dist/scripts/rules.d.ts +1 -0
- package/dist/scripts/rules.js +148 -0
- package/dist/ui/server/ai-stream.d.ts +2 -2
- package/dist/ui/server/ai-stream.js +190 -30
- package/dist/ui/shared/log-text.d.ts +13 -0
- package/dist/ui/shared/log-text.js +45 -0
- package/dist/ui/shared/types.d.ts +6 -1
- package/docs/agents/case-format.md +8 -5
- package/docs/ai-workflow.md +21 -12
- package/docs/cases.md +10 -6
- package/docs/commands.md +22 -4
- package/docs/install.md +2 -1
- package/docs/theme-guide.html +24 -3
- package/docs/troubleshooting.md +11 -2
- package/docs/web-ui.md +30 -9
- package/package.json +4 -1
- package/templates/.claude/commands/explore-case.md +3 -1
- package/templates/.claude/commands/gen-spec.md +3 -1
- package/templates/.claude/commands/heal-spec.md +3 -1
- package/templates/.claude/commands/make-case.md +3 -1
- package/templates/AGENTS.md +17 -8
- package/templates/cases/_template.md +3 -3
- package/templates/package.json +4 -0
- package/ui/dist/assets/{dist-BMmNFIUH.js → dist-BB2cF3XN.js} +1 -1
- package/ui/dist/assets/{dist-ByLbEOlF.js → dist-C46EmZuI.js} +1 -1
- package/ui/dist/assets/{dist-CQS0So-i.js → dist-D9GmSkjm.js} +1 -1
- package/ui/dist/assets/{dist-BXNaYHeh.js → dist-DGzg2gyA.js} +1 -1
- package/ui/dist/assets/{dist-DTYj2vUD.js → dist-DMiOs9Qj.js} +1 -1
- package/ui/dist/assets/{dist-mV0_nQZe.js → dist-Db_XhDui.js} +1 -1
- package/ui/dist/assets/{dist-CTrwRc3Q.js → dist-DrFzCwMc.js} +1 -1
- package/ui/dist/assets/{dist--BXJInTq.js → dist-Dy45nXf8.js} +1 -1
- package/ui/dist/assets/{dist-CK8mow8u.js → dist-P9roUi1O.js} +1 -1
- package/ui/dist/assets/{dist-C9ra6_BD.js → dist-_0g6iC9K.js} +1 -1
- package/ui/dist/assets/index-BN4ucmmn.js +376 -0
- package/ui/dist/assets/index-Bo4XKJvP.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-BzMqkmQ9.js +0 -348
- package/ui/dist/assets/index-DcJR-8Mt.css +0 -2
package/dist/lib/records.js
CHANGED
|
@@ -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
|
-
/**
|
|
5
|
-
|
|
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
|
+
}
|
package/dist/scripts/cleanup.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/scripts/cli.js
CHANGED
|
@@ -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
|
package/dist/scripts/doctor.js
CHANGED
|
@@ -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
|
|
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
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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);
|
|
@@ -53,8 +53,8 @@ export declare class AiStreamFormatter {
|
|
|
53
53
|
/** A call the agent has started and not finished yet (Claude reports both ends). */
|
|
54
54
|
private start;
|
|
55
55
|
/**
|
|
56
|
-
* The end of a call, plus
|
|
57
|
-
*
|
|
56
|
+
* The end of a call, plus what it returned. `fallback` is for a CLI that reports a call only when it is over
|
|
57
|
+
* (OpenCode), which still knows when it started.
|
|
58
58
|
*/
|
|
59
59
|
private finishCall;
|
|
60
60
|
/** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
|