@asrunjs/asrun 0.10.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.
- 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 +59 -64
- package/.claude/skills/exploring-cases/references/case-rules.md +5 -0
- package/.claude/skills/exploring-cases/references/notes.md +87 -0
- package/.claude/skills/exploring-cases/references/open-questions.md +8 -0
- package/.claude/skills/exploring-cases/references/readiness.md +3 -2
- package/.claude/skills/exploring-cases/references/tools.md +34 -0
- package/.claude/skills/exploring-cases/references/user-handoff.md +13 -9
- package/.claude/skills/generating-specs/SKILL.md +61 -47
- 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 +30 -14
- package/README.md +16 -11
- package/bin/asrun.mjs +1 -0
- 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/explore-notes.d.ts +74 -0
- package/dist/lib/explore-notes.js +242 -0
- package/dist/lib/fixtures.d.ts +1 -1
- package/dist/lib/fixtures.js +7 -4
- package/dist/lib/paths.d.ts +2 -0
- package/dist/lib/paths.js +2 -0
- 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 +17 -2
- package/dist/scripts/doctor.js +27 -48
- package/dist/scripts/explore-notes.d.ts +1 -0
- package/dist/scripts/explore-notes.js +131 -0
- package/dist/scripts/lint-specs.js +23 -1
- package/dist/scripts/rules.d.ts +1 -0
- package/dist/scripts/rules.js +148 -0
- package/docs/agents/case-format.md +9 -5
- package/docs/agents/framework-dev.md +4 -0
- package/docs/ai-workflow.md +30 -13
- package/docs/cases.md +14 -10
- package/docs/commands.md +33 -4
- package/docs/install.md +2 -1
- package/docs/theme-guide.html +79 -2
- package/docs/troubleshooting.md +11 -2
- package/docs/web-ui.md +40 -12
- package/package.json +3 -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 +5 -0
- package/ui/dist/assets/{dist-BmWaNjqO.js → dist-BX88bMYN.js} +1 -1
- package/ui/dist/assets/{dist-2msWI-5M.js → dist-Bfv586Or.js} +1 -1
- package/ui/dist/assets/{dist-e-KhTWHJ.js → dist-BjlbPf3t.js} +1 -1
- package/ui/dist/assets/{dist-BVvHVJ3N.js → dist-CJnqQMvX.js} +1 -1
- package/ui/dist/assets/{dist-BMfJatHo.js → dist-CaAcCNRS.js} +1 -1
- package/ui/dist/assets/{dist-sfZKBSMm.js → dist-Clj0wGFy.js} +1 -1
- package/ui/dist/assets/{dist-J36xayOv.js → dist-D8TUm3Ai.js} +1 -1
- package/ui/dist/assets/{dist-4U_gPHXf.js → dist-DUDqh_3e.js} +1 -1
- package/ui/dist/assets/{dist-BwNz_xW2.js → dist-DkAx5a-Y.js} +1 -1
- package/ui/dist/assets/{dist-wjB7g6OJ.js → dist-cuboTVh-.js} +1 -1
- package/ui/dist/assets/index-DaUQORCy.js +353 -0
- package/ui/dist/assets/index-j3W4BBCj.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-CGbRJT3b.js +0 -348
- package/ui/dist/assets/index-CI0i0LWc.css +0 -2
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { isValidCaseId } from './case-id.js';
|
|
5
|
+
import { CASES_DIR, EXPLORE_DIR } from './paths.js';
|
|
6
|
+
/**
|
|
7
|
+
* The local exploration note: what `/explore-case` saw on the page, in a form `/gen-spec` can read.
|
|
8
|
+
*
|
|
9
|
+
* It lives under `.explore/notes/<case-id>/<profile-id>.json`, one file per profile, so a run with `--profiles` never
|
|
10
|
+
* has to merge with an earlier one. `.explore/` is git-ignored local state: a note is optional, may be deleted at any
|
|
11
|
+
* time, and a fresh checkout simply has none (generation then replays the case as before).
|
|
12
|
+
*
|
|
13
|
+
* A note holds only what the case does not: locators the agent confirmed on the page, the data-call paths of the
|
|
14
|
+
* actions that load something, and the match count of a target card's element. Expected values stay in the case's
|
|
15
|
+
* `## Values`, ready signals in the case's `Ready when:` lines, and assertions nowhere near this file.
|
|
16
|
+
*/
|
|
17
|
+
export const NOTES_VERSION = 1;
|
|
18
|
+
const HASH = /^[a-f0-9]{64}$/;
|
|
19
|
+
const PROFILE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
20
|
+
const STEP_KEY = /^[1-9]\d*$/;
|
|
21
|
+
const TARGET_KEY = /^T[1-9]\d*$/;
|
|
22
|
+
// A request pattern is a path, never a URL with a host or a query string that could carry a token.
|
|
23
|
+
const UNSAFE_REQUEST = /[?#]|:\/\//;
|
|
24
|
+
const SECRET_PARAM = /(?:^|[/&])(?:token|cookie|password|authorization|secret|signature|sig|session)=/i;
|
|
25
|
+
const MAX_LOCATOR = 400;
|
|
26
|
+
const MAX_PURPOSE = 200;
|
|
27
|
+
const MAX_REQUEST = 200;
|
|
28
|
+
const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
29
|
+
const isOneLine = (value) => !/[\r\n]/.test(value);
|
|
30
|
+
/** The folder that holds one case's notes; `<case-id>` may contain its group folder (`documents/landing-page`). */
|
|
31
|
+
export function exploreNotesDir(caseId) {
|
|
32
|
+
if (!isValidCaseId(caseId))
|
|
33
|
+
throw new Error(`"${caseId}" is not a valid case id`);
|
|
34
|
+
return path.join(EXPLORE_DIR, 'notes', ...caseId.split('/'));
|
|
35
|
+
}
|
|
36
|
+
/** The note of one profile: `.explore/notes/<case-id>/<profile-id>.json`. */
|
|
37
|
+
export function exploreNotesFile(caseId, profileId) {
|
|
38
|
+
if (!PROFILE_FILE.test(profileId))
|
|
39
|
+
throw new Error(`"${profileId}" is not a valid profile id`);
|
|
40
|
+
return path.join(exploreNotesDir(caseId), `${profileId}.json`);
|
|
41
|
+
}
|
|
42
|
+
/** sha256 of the case document, or null when it does not exist. */
|
|
43
|
+
export function caseSha256(caseId) {
|
|
44
|
+
if (!isValidCaseId(caseId))
|
|
45
|
+
return null;
|
|
46
|
+
try {
|
|
47
|
+
return crypto.createHash('sha256').update(fs.readFileSync(path.join(CASES_DIR, `${caseId}.md`))).digest('hex');
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Whether a note still describes the case. A stale note is not an error: `/gen-spec` replays the case and ignores it.
|
|
55
|
+
*/
|
|
56
|
+
export function noteFreshness(note) {
|
|
57
|
+
const current = caseSha256(note.caseId);
|
|
58
|
+
if (current === null)
|
|
59
|
+
return { state: 'no-case', reason: 'the case file does not exist' };
|
|
60
|
+
if (current !== note.caseSha256)
|
|
61
|
+
return { state: 'stale', reason: 'the case file changed after the note was written' };
|
|
62
|
+
return { state: 'fresh' };
|
|
63
|
+
}
|
|
64
|
+
function checkLocator(value, where, problems) {
|
|
65
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
66
|
+
problems.push(`${where} must be a locator string`);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if (!isOneLine(value))
|
|
70
|
+
problems.push(`${where} must be one line`);
|
|
71
|
+
if (value.length > MAX_LOCATOR)
|
|
72
|
+
problems.push(`${where} is longer than ${MAX_LOCATOR} characters`);
|
|
73
|
+
if (!value.startsWith('page.'))
|
|
74
|
+
problems.push(`${where} must start with "page." (use browser_generate_locator)`);
|
|
75
|
+
if (value.includes('=>'))
|
|
76
|
+
problems.push(`${where} must be a locator, not code`);
|
|
77
|
+
}
|
|
78
|
+
function checkRequest(value, where, problems) {
|
|
79
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
80
|
+
problems.push(`${where} must be a request path`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!isOneLine(value))
|
|
84
|
+
problems.push(`${where} must be one line`);
|
|
85
|
+
if (value.length > MAX_REQUEST)
|
|
86
|
+
problems.push(`${where} is longer than ${MAX_REQUEST} characters`);
|
|
87
|
+
if (!value.startsWith('/') || value.startsWith('//'))
|
|
88
|
+
problems.push(`${where} must start with "/" (a path, not a URL)`);
|
|
89
|
+
if (UNSAFE_REQUEST.test(value))
|
|
90
|
+
problems.push(`${where} must have no host, query string or fragment`);
|
|
91
|
+
if (SECRET_PARAM.test(value))
|
|
92
|
+
problems.push(`${where} must not carry a token, cookie or session`);
|
|
93
|
+
}
|
|
94
|
+
function checkPurpose(value, where, problems) {
|
|
95
|
+
if (typeof value !== 'string' || value.trim() === '')
|
|
96
|
+
problems.push(`${where} must be a short description`);
|
|
97
|
+
else if (!isOneLine(value))
|
|
98
|
+
problems.push(`${where} must be one line`);
|
|
99
|
+
else if (value.length > MAX_PURPOSE)
|
|
100
|
+
problems.push(`${where} is longer than ${MAX_PURPOSE} characters`);
|
|
101
|
+
}
|
|
102
|
+
function checkSteps(value, problems) {
|
|
103
|
+
if (!isRecord(value)) {
|
|
104
|
+
problems.push('steps must be an object keyed by step number');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const [step, body] of Object.entries(value)) {
|
|
108
|
+
const where = `steps["${step}"]`;
|
|
109
|
+
if (!STEP_KEY.test(step))
|
|
110
|
+
problems.push(`${where}: the key must be a positive step number`);
|
|
111
|
+
if (!isRecord(body)) {
|
|
112
|
+
problems.push(`${where} must be an object`);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (body.url !== undefined) {
|
|
116
|
+
const url = body.url;
|
|
117
|
+
if (typeof url !== 'string' || !url.startsWith('/'))
|
|
118
|
+
problems.push(`${where}.url must start with "/"`);
|
|
119
|
+
else if (UNSAFE_REQUEST.test(url))
|
|
120
|
+
problems.push(`${where}.url must have no host, query string or fragment`);
|
|
121
|
+
}
|
|
122
|
+
if (body.locators !== undefined) {
|
|
123
|
+
if (!Array.isArray(body.locators))
|
|
124
|
+
problems.push(`${where}.locators must be a list`);
|
|
125
|
+
else {
|
|
126
|
+
for (const [index, locator] of body.locators.entries()) {
|
|
127
|
+
const item = `${where}.locators[${index}]`;
|
|
128
|
+
if (!isRecord(locator)) {
|
|
129
|
+
problems.push(`${item} must be an object with "purpose" and "locator"`);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
checkPurpose(locator.purpose, `${item}.purpose`, problems);
|
|
133
|
+
checkLocator(locator.locator, `${item}.locator`, problems);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
if (body.requests !== undefined) {
|
|
138
|
+
if (!Array.isArray(body.requests))
|
|
139
|
+
problems.push(`${where}.requests must be a list`);
|
|
140
|
+
else
|
|
141
|
+
body.requests.forEach((request, index) => checkRequest(request, `${where}.requests[${index}]`, problems));
|
|
142
|
+
}
|
|
143
|
+
const known = new Set(['url', 'locators', 'requests']);
|
|
144
|
+
for (const key of Object.keys(body))
|
|
145
|
+
if (!known.has(key))
|
|
146
|
+
problems.push(`${where}.${key} is not a known field`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function checkTargets(value, problems) {
|
|
150
|
+
if (!isRecord(value)) {
|
|
151
|
+
problems.push('targets must be an object keyed by target id');
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
for (const [id, target] of Object.entries(value)) {
|
|
155
|
+
const where = `targets["${id}"]`;
|
|
156
|
+
if (!TARGET_KEY.test(id))
|
|
157
|
+
problems.push(`${where}: the key must be a target id such as "T1"`);
|
|
158
|
+
if (!isRecord(target)) {
|
|
159
|
+
problems.push(`${where} must be an object`);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
checkLocator(target.locator, `${where}.locator`, problems);
|
|
163
|
+
if (target.matches !== undefined && (!Number.isInteger(target.matches) || target.matches < 1)) {
|
|
164
|
+
problems.push(`${where}.matches must be a positive whole number`);
|
|
165
|
+
}
|
|
166
|
+
const known = new Set(['locator', 'matches']);
|
|
167
|
+
for (const key of Object.keys(target))
|
|
168
|
+
if (!known.has(key))
|
|
169
|
+
problems.push(`${where}.${key} is not a known field`);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/** Every reason the note is not usable, in plain words. An empty list means it is valid. */
|
|
173
|
+
export function validateExploreNote(value, expected) {
|
|
174
|
+
const problems = [];
|
|
175
|
+
if (!isRecord(value))
|
|
176
|
+
return ['the note is not a JSON object'];
|
|
177
|
+
if (value.version !== NOTES_VERSION)
|
|
178
|
+
problems.push(`version must be ${NOTES_VERSION}`);
|
|
179
|
+
if (value.caseId !== expected.caseId)
|
|
180
|
+
problems.push(`caseId must be "${expected.caseId}"`);
|
|
181
|
+
if (value.profile !== expected.profile)
|
|
182
|
+
problems.push(`profile must be "${expected.profile}"`);
|
|
183
|
+
if (typeof value.profile !== 'string' || !PROFILE_FILE.test(value.profile)) {
|
|
184
|
+
problems.push('profile must be a profile id (letters, digits, ".", "_" and "-")');
|
|
185
|
+
}
|
|
186
|
+
if (typeof value.caseSha256 !== 'string' || !HASH.test(value.caseSha256)) {
|
|
187
|
+
problems.push('caseSha256 must be the sha256 of the case file (run "explore:notes <case-id> --hash")');
|
|
188
|
+
}
|
|
189
|
+
if (typeof value.capturedAt !== 'string' || value.capturedAt.trim() === '') {
|
|
190
|
+
problems.push('capturedAt must be an ISO date-time');
|
|
191
|
+
}
|
|
192
|
+
if (value.updatedAt !== undefined && typeof value.updatedAt !== 'string')
|
|
193
|
+
problems.push('updatedAt must be a string');
|
|
194
|
+
if (value.env !== undefined && (typeof value.env !== 'string' || value.env.trim() === '')) {
|
|
195
|
+
problems.push('env must be a non-empty string when it is present');
|
|
196
|
+
}
|
|
197
|
+
checkSteps(value.steps, problems);
|
|
198
|
+
if (value.targets !== undefined)
|
|
199
|
+
checkTargets(value.targets, problems);
|
|
200
|
+
const known = new Set(['version', 'caseId', 'profile', 'env', 'caseSha256', 'capturedAt', 'updatedAt', 'steps', 'targets']);
|
|
201
|
+
for (const key of Object.keys(value))
|
|
202
|
+
if (!known.has(key))
|
|
203
|
+
problems.push(`${key} is not a known field`);
|
|
204
|
+
return problems;
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Reads every note of a case (or one profile's), parses and validates it. A malformed file is reported, never thrown:
|
|
208
|
+
* the caller decides whether it matters, and generation treats an unreadable note as a missing one.
|
|
209
|
+
*/
|
|
210
|
+
export function readExploreNotes(caseId, profileId) {
|
|
211
|
+
const errors = [];
|
|
212
|
+
const notes = [];
|
|
213
|
+
if (!isValidCaseId(caseId))
|
|
214
|
+
return { notes, errors: [{ file: caseId, problems: ['not a valid case id'] }] };
|
|
215
|
+
let files;
|
|
216
|
+
try {
|
|
217
|
+
files = fs.readdirSync(exploreNotesDir(caseId)).filter((file) => file.endsWith('.json'));
|
|
218
|
+
}
|
|
219
|
+
catch {
|
|
220
|
+
return { notes, errors };
|
|
221
|
+
}
|
|
222
|
+
for (const file of files.sort()) {
|
|
223
|
+
const profile = path.basename(file, '.json');
|
|
224
|
+
if (profileId !== undefined && profile !== profileId)
|
|
225
|
+
continue;
|
|
226
|
+
const full = path.join(exploreNotesDir(caseId), file);
|
|
227
|
+
let parsed;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(fs.readFileSync(full, 'utf8'));
|
|
230
|
+
}
|
|
231
|
+
catch (error) {
|
|
232
|
+
errors.push({ file: full, problems: [`not valid JSON: ${error.message}`] });
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
const problems = validateExploreNote(parsed, { caseId, profile });
|
|
236
|
+
if (problems.length > 0)
|
|
237
|
+
errors.push({ file: full, problems });
|
|
238
|
+
else
|
|
239
|
+
notes.push(parsed);
|
|
240
|
+
}
|
|
241
|
+
return { notes, errors };
|
|
242
|
+
}
|
package/dist/lib/fixtures.d.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { type Profile } from './profiles.js';
|
|
|
4
4
|
export interface TrackedData {
|
|
5
5
|
/** What it is, e.g. "Document draft". */
|
|
6
6
|
kind: string;
|
|
7
|
-
/** Its name in the product. Must start with
|
|
7
|
+
/** Its name in the product. Must start with the project's prefix, so cleanup can never touch real data. */
|
|
8
8
|
name: string;
|
|
9
9
|
/** Where a person finds it by hand if the cleanup fails, e.g. "/app/documents". */
|
|
10
10
|
where?: string;
|
package/dist/lib/fixtures.js
CHANGED
|
@@ -2,7 +2,8 @@ import { expect, test as base } from '@playwright/test';
|
|
|
2
2
|
import { getProfile } from './profiles.js';
|
|
3
3
|
import { caseWrites, caseWritesIn, hasCaseFile, readCaseMeta } from './cases.js';
|
|
4
4
|
import { acquireAccountLock } from './account-lock.js';
|
|
5
|
-
import {
|
|
5
|
+
import { testDataPrefix } from './config.js';
|
|
6
|
+
import { ledgerFile, writeLedger } from './records.js';
|
|
6
7
|
import { caseIdFromFile } from './run-summary.js';
|
|
7
8
|
import { ALLOW_WRITES_ENV, isProtectedEnv } from './write-gate.js';
|
|
8
9
|
import { checkSession, isLoggedOutUrl, parseNotLoggedIn, sessionProblemMessage, signinCommand, } from './session.js';
|
|
@@ -76,9 +77,11 @@ export const test = base.extend({
|
|
|
76
77
|
const save = () => writeLedger(file, entries.map((item) => item.entry));
|
|
77
78
|
await use({
|
|
78
79
|
async track(data, remove) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
const prefix = testDataPrefix();
|
|
81
|
+
if (!data.name.startsWith(prefix)) {
|
|
82
|
+
throw new Error(`Test data must be named with the project's prefix "${prefix}" (got "${data.name}"). ` +
|
|
83
|
+
`Set "asrun.testDataPrefix" in package.json when the project uses another marker, and build the name ` +
|
|
84
|
+
'with testDataName() so two runs never share it.');
|
|
82
85
|
}
|
|
83
86
|
entries.push({
|
|
84
87
|
entry: {
|
package/dist/lib/paths.d.ts
CHANGED
|
@@ -25,6 +25,8 @@ export declare const CASES_DIR: string;
|
|
|
25
25
|
export declare const TESTS_DIR: string;
|
|
26
26
|
export declare const REPORTS_DIR: string;
|
|
27
27
|
export declare const TMP_DIR: string;
|
|
28
|
+
/** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
|
|
29
|
+
export declare const EXPLORE_DIR: string;
|
|
28
30
|
export declare const TEMPLATES_DIR: string;
|
|
29
31
|
export declare const SKILLS_DIR: string;
|
|
30
32
|
/**
|
package/dist/lib/paths.js
CHANGED
|
@@ -82,6 +82,8 @@ export const CASES_DIR = path.join(ROOT, 'cases');
|
|
|
82
82
|
export const TESTS_DIR = path.join(ROOT, 'tests');
|
|
83
83
|
export const REPORTS_DIR = path.join(ROOT, 'reports');
|
|
84
84
|
export const TMP_DIR = path.join(ROOT, '.tmp');
|
|
85
|
+
/** Local agent output (reference screenshots, exploration notes), git-ignored and safe to delete. */
|
|
86
|
+
export const EXPLORE_DIR = path.join(ROOT, '.explore');
|
|
85
87
|
export const TEMPLATES_DIR = path.join(FRAMEWORK_ROOT, 'templates');
|
|
86
88
|
export const SKILLS_DIR = path.join(FRAMEWORK_ROOT, '.claude', 'skills');
|
|
87
89
|
/**
|
package/dist/lib/records.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
|
-
/**
|
|
1
|
+
/**
|
|
2
|
+
* The default prefix of data a test creates. The project's `package.json` may set another one
|
|
3
|
+
* (`"asrun": { "testDataPrefix": "[autotest]" }`); always read the effective value with `testDataPrefix()`.
|
|
4
|
+
*/
|
|
2
5
|
export declare const TEST_DATA_PREFIX = "[asrun]";
|
|
3
6
|
export type RecordStatus = 'open' | 'cleaned' | 'leftover';
|
|
4
7
|
/** One piece of test data a case created, as written to the ledger. */
|
|
@@ -10,7 +13,7 @@ export interface LedgerEntry {
|
|
|
10
13
|
caseId: string;
|
|
11
14
|
/** What kind of thing it is, e.g. "Document draft". */
|
|
12
15
|
kind: string;
|
|
13
|
-
/** Its name in the product; always starts with
|
|
16
|
+
/** Its name in the product; always starts with the project's test-data prefix (`testDataPrefix()`). */
|
|
14
17
|
name: string;
|
|
15
18
|
/** Where a person can find and remove it by hand, e.g. "/app/documents". */
|
|
16
19
|
where: string | null;
|
|
@@ -20,6 +23,18 @@ export interface LedgerEntry {
|
|
|
20
23
|
createdAt: string;
|
|
21
24
|
}
|
|
22
25
|
export declare const RECORDS_DIR: string;
|
|
26
|
+
/** The run stamp appended to every name a test creates: the run id's UTC time (`2026-11-09 12-34-56`). */
|
|
27
|
+
export declare function testDataStamp(now?: Date): string;
|
|
28
|
+
/**
|
|
29
|
+
* The name of one piece of test data: `<base> (<run stamp>)`, for example
|
|
30
|
+
* `[autotest] Draft CMA (2026-11-09 12-34-56)`.
|
|
31
|
+
*
|
|
32
|
+
* The stamp is the run id's UTC time, so it matches the report folder and the ledger entry, and two runs never
|
|
33
|
+
* 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
|
|
34
|
+
* the case's literal from `## Values`; it must start with the project's prefix. Calling this twice with the same base
|
|
35
|
+
* (a title before and after an edit, a retry) returns the same name.
|
|
36
|
+
*/
|
|
37
|
+
export declare function testDataName(base: string): string;
|
|
23
38
|
/** The ledger of one test: `.tmp/records/<runId>/<file>.json`. One file per test, so parallel workers never share a file. */
|
|
24
39
|
export declare const ledgerFile: (runId: string, testId: string) => string;
|
|
25
40
|
export declare function writeLedger(file: string, entries: LedgerEntry[]): void;
|
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,23 +32,38 @@ 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
|
+
}
|
|
41
|
+
if (command === 'explore:notes') {
|
|
42
|
+
// The script reads `process.argv` itself; drop the subcommand so it sees only its own arguments.
|
|
43
|
+
process.argv.splice(2, 1);
|
|
44
|
+
await import('./explore-notes.js');
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
35
47
|
if (command === 'help' || command === '--help-init') {
|
|
36
48
|
console.log(USAGE);
|
|
37
49
|
return;
|
|
38
50
|
}
|
|
39
51
|
await import('./run.js');
|
|
40
52
|
}
|
|
41
|
-
const USAGE = `Usage: pnpm asrun [init|doctor|reset|upgrade|migrate] [options]
|
|
53
|
+
const USAGE = `Usage: pnpm asrun [init|doctor|rules|reset|upgrade|migrate|explore:notes] [options]
|
|
42
54
|
|
|
43
55
|
(no subcommand) Run the tests of one environment. See "pnpm asrun --help".
|
|
44
56
|
init Create the files a workspace needs (never overwrites; --update refreshes generated files,
|
|
45
57
|
--root <dir> targets another folder, --force overwrites every file init writes,
|
|
46
58
|
--force <path> overwrites one file).
|
|
47
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).
|
|
48
62
|
reset Remove node_modules and the files init writes, for a clean reinstall (keeps cases, specs,
|
|
49
63
|
profiles.yaml, package.json and edited scaffold files; see "pnpm asrun reset --help").
|
|
50
64
|
upgrade Install the newest asrun, refresh the workspace files and apply its migrations, after checking the
|
|
51
65
|
target release for breaking changes (see "pnpm asrun upgrade --help").
|
|
52
66
|
migrate Apply the workspace migrations of the installed release, or list them (see "pnpm asrun migrate
|
|
53
|
-
--help"). "upgrade" runs it; use it after a manual install
|
|
67
|
+
--help"). "upgrade" runs it; use it after a manual install.
|
|
68
|
+
explore:notes Print or validate the local exploration note of a case (see "pnpm asrun explore:notes --help").`;
|
|
54
69
|
await main();
|