@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
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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { isValidCaseId } from '../lib/case-id.js';
|
|
3
|
+
import { caseSha256, exploreNotesDir, exploreNotesFile, noteFreshness, readExploreNotes, } from '../lib/explore-notes.js';
|
|
4
|
+
import { ROOT } from '../lib/paths.js';
|
|
5
|
+
import { loadProfiles } from '../lib/profiles.js';
|
|
6
|
+
const USAGE = `Usage: pnpm explore:notes <case-id> [--profile <profile-id>] [--json] [--check] [--hash]
|
|
7
|
+
|
|
8
|
+
Prints the exploration notes /explore-case wrote for a case under .explore/notes/<case-id>/.
|
|
9
|
+
They are local, git-ignored and optional: /gen-spec reads a fresh one to verify instead of rediscovering the page,
|
|
10
|
+
and falls back to a full replay when it is missing or stale.
|
|
11
|
+
|
|
12
|
+
--profile <id> only this profile's note
|
|
13
|
+
--json the parsed notes (and any errors) as JSON, for a run that reads them
|
|
14
|
+
--check validate every note; fails on a malformed file, warns on a stale one
|
|
15
|
+
--hash print the case file's sha256, for "caseSha256" in a note
|
|
16
|
+
|
|
17
|
+
Paths are relative to the workspace root (${ROOT}).`;
|
|
18
|
+
const args = process.argv.slice(2);
|
|
19
|
+
let caseId;
|
|
20
|
+
let profile;
|
|
21
|
+
let json = false;
|
|
22
|
+
let check = false;
|
|
23
|
+
let hash = false;
|
|
24
|
+
let help = false;
|
|
25
|
+
for (let index = 0; index < args.length; index++) {
|
|
26
|
+
const arg = args[index];
|
|
27
|
+
if (arg === '--help' || arg === '-h')
|
|
28
|
+
help = true;
|
|
29
|
+
else if (arg === '--json')
|
|
30
|
+
json = true;
|
|
31
|
+
else if (arg === '--check')
|
|
32
|
+
check = true;
|
|
33
|
+
else if (arg === '--hash')
|
|
34
|
+
hash = true;
|
|
35
|
+
else if (arg === '--profile') {
|
|
36
|
+
const value = args[++index];
|
|
37
|
+
if (value === undefined || value.startsWith('--')) {
|
|
38
|
+
console.error(`--profile needs a profile id.\n\n${USAGE}`);
|
|
39
|
+
process.exit(2);
|
|
40
|
+
}
|
|
41
|
+
profile = value;
|
|
42
|
+
}
|
|
43
|
+
else if (!arg.startsWith('--') && caseId === undefined)
|
|
44
|
+
caseId = arg;
|
|
45
|
+
else {
|
|
46
|
+
console.error(`Unexpected argument: ${arg}\n\n${USAGE}`);
|
|
47
|
+
process.exit(2);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (help) {
|
|
51
|
+
console.log(USAGE);
|
|
52
|
+
process.exit(0);
|
|
53
|
+
}
|
|
54
|
+
if (caseId === undefined) {
|
|
55
|
+
console.error(USAGE);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
if (!isValidCaseId(caseId)) {
|
|
59
|
+
console.error(`"${caseId}" is not a valid case id (one optional group folder, for example documents/landing-page).`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
if (hash) {
|
|
63
|
+
const value = caseSha256(caseId);
|
|
64
|
+
if (value === null) {
|
|
65
|
+
console.error(`cases/${caseId}.md does not exist.`);
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
console.log(value);
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
const { notes, errors } = readExploreNotes(caseId, profile);
|
|
72
|
+
const relative = (file) => path.relative(ROOT, file).split(path.sep).join('/');
|
|
73
|
+
if (json) {
|
|
74
|
+
console.log(JSON.stringify({
|
|
75
|
+
caseId,
|
|
76
|
+
notes: notes.map((note) => ({ ...note, freshness: noteFreshness(note) })),
|
|
77
|
+
errors: errors.map((error) => ({ file: relative(error.file), problems: error.problems })),
|
|
78
|
+
}, null, 2));
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
console.log(`Exploration notes for cases/${caseId}.md (${relative(exploreNotesDir(caseId))})`);
|
|
82
|
+
if (notes.length === 0)
|
|
83
|
+
console.log(' none yet: /gen-spec replays the case.');
|
|
84
|
+
for (const note of notes) {
|
|
85
|
+
console.log(` ${note.profile.padEnd(24)} ${describe(note)}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let failed = false;
|
|
89
|
+
for (const error of errors) {
|
|
90
|
+
failed = true;
|
|
91
|
+
for (const problem of error.problems)
|
|
92
|
+
console.log(`FAIL ${relative(error.file)}: ${problem}`);
|
|
93
|
+
}
|
|
94
|
+
if (check) {
|
|
95
|
+
const value = caseSha256(caseId);
|
|
96
|
+
if (value === null) {
|
|
97
|
+
failed = true;
|
|
98
|
+
console.log(`FAIL cases/${caseId}.md does not exist`);
|
|
99
|
+
}
|
|
100
|
+
const known = new Set(knownProfiles());
|
|
101
|
+
for (const note of notes) {
|
|
102
|
+
const freshness = noteFreshness(note);
|
|
103
|
+
if (freshness.state !== 'fresh') {
|
|
104
|
+
console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: ${freshness.reason}; current caseSha256 is ${value ?? '(unknown)'}`);
|
|
105
|
+
}
|
|
106
|
+
if (!known.has(note.profile)) {
|
|
107
|
+
console.log(`WARN ${relative(exploreNotesFile(caseId, note.profile))}: "${note.profile}" is not in profiles.yaml`);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (failed) {
|
|
111
|
+
console.error('\nA note that fails here is ignored by /gen-spec. Fix it, or delete it and let the case be replayed.');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
function describe(note) {
|
|
116
|
+
const freshness = noteFreshness(note);
|
|
117
|
+
const steps = Object.keys(note.steps)
|
|
118
|
+
.sort((a, b) => Number(a) - Number(b))
|
|
119
|
+
.join(',');
|
|
120
|
+
const targets = Object.keys(note.targets ?? {}).sort().join(',');
|
|
121
|
+
const state = freshness.state === 'fresh' ? 'fresh' : `stale (${freshness.reason})`;
|
|
122
|
+
return `${state} steps ${steps || '-'} targets ${targets || '-'} captured ${note.capturedAt}${note.updatedAt ? ` refreshed ${note.updatedAt}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
function knownProfiles() {
|
|
125
|
+
try {
|
|
126
|
+
return loadProfiles().map((entry) => entry.id);
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
}
|
|
@@ -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,
|
|
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
|
|
205
|
-
|
|
206
|
-
`
|
|
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
|
|
|
@@ -223,5 +226,6 @@ In the framework package:
|
|
|
223
226
|
- `.claude/skills/exploring-cases/references/readiness.md` — wait for the content, optional confirmations, downloads.
|
|
224
227
|
- `.claude/skills/exploring-cases/references/open-questions.md` — question blocks and how answers are folded in.
|
|
225
228
|
- `.claude/skills/exploring-cases/references/user-handoff.md` — attended exploration and the handoff card.
|
|
229
|
+
- `.claude/skills/exploring-cases/references/notes.md` — the local exploration note `/gen-spec` reads (not part of the case).
|
|
226
230
|
- `.claude/skills/generating-specs/references/conventions.md` — spec shape, assertion layers, readable failures.
|
|
227
231
|
- `docs/cases.md` — the same format for people writing a case by hand.
|
|
@@ -53,6 +53,10 @@ is relevant to a project that uses asrun as a dependency.
|
|
|
53
53
|
- Everything the framework reads for a project goes through `ROOT` in `lib/paths.ts` (the workspace root); its own
|
|
54
54
|
assets (templates, docs, skills, its `node_modules`) go through `FRAMEWORK_ROOT`. Never write into
|
|
55
55
|
`FRAMEWORK_ROOT` at run time.
|
|
56
|
+
- `.explore/` is local state (git-ignored): target reference screenshots and the exploration notes that
|
|
57
|
+
`lib/explore-notes.ts` reads (`.explore/notes/<case-id>/<profile-id>.json`). Nothing there is committed, and a
|
|
58
|
+
workspace without it generates specs by replaying the case. `scripts/explore-notes.ts` (`pnpm explore:notes`) prints
|
|
59
|
+
and validates those notes.
|
|
56
60
|
- After changing `lib/`, run `pnpm build` before exercising the demo's specs.
|
|
57
61
|
- `pnpm build` copies `scripts/*.mjs` into `dist/scripts/`, where they sit one folder deeper than in this repository.
|
|
58
62
|
`pnpm verify:pack` (part of CI) checks that layout on a packed tarball; the checkout alone hides such bugs, because
|
package/docs/ai-workflow.md
CHANGED
|
@@ -83,17 +83,21 @@ publishes, shares or deletes anything, in staging or in production. It:
|
|
|
83
83
|
- turns what it cannot know into `🚫 TODO [blocking]` / `❓ TODO [non-blocking]` questions, each under a sentence that
|
|
84
84
|
is its suggestion and can stand in the case as it is, with a last line `- ✍️ Answer:`;
|
|
85
85
|
- lists the candidates instead of guessing a target (`## Targets`);
|
|
86
|
-
- edits the case file
|
|
86
|
+
- edits the case file and the `.explore/` files (reference screenshots and the note below), and nothing else;
|
|
87
|
+
- writes a local **exploration note** (`.explore/notes/<case-id>/<profile-id>.json`, git-ignored) with the locators and
|
|
88
|
+
data-call paths it saw. `/gen-spec` reads it to verify instead of rediscovering, and a page that only exists after a
|
|
89
|
+
write step is written from it. Delete it at any time: without it, generation replays the case.
|
|
87
90
|
|
|
88
91
|
With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated). The others are not
|
|
89
92
|
opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up; the
|
|
90
93
|
case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every profile
|
|
91
94
|
that is logged in in that environment is ticked, and the list you tick is remembered for the next run there.
|
|
92
95
|
|
|
93
|
-
**To
|
|
94
|
-
again. In the web UI
|
|
95
|
-
**Use the suggested defaults** and **Generate spec**. A continued
|
|
96
|
-
can be continued once per turn, only while the UI server has not
|
|
96
|
+
**To keep what the case says, delete the whole block.** **To answer, write after `Answer:`.** Then run the command
|
|
97
|
+
again. In the web UI the panel calls the first action **Keep what the case says**. The run's **What to do next** bar
|
|
98
|
+
offers **Answer**: the question list, an answer box, **Use the suggested defaults** and **Generate spec**. A continued
|
|
99
|
+
job keeps the same conversation and permissions, and can be continued once per turn, only while the UI server has not
|
|
100
|
+
restarted.
|
|
97
101
|
|
|
98
102
|
### Attended exploration (`--attended`)
|
|
99
103
|
|
|
@@ -106,9 +110,10 @@ the browser window opens and the agent waits while you do those steps.
|
|
|
106
110
|
- Recording starts and stops by itself. If Playwright's own recording toolbar shows at the top of the page, leave it
|
|
107
111
|
alone: it can stop the recording.
|
|
108
112
|
- The card records what you did, and the agent writes it into the case under the step as **general recipe lines**
|
|
109
|
-
(`- Recipe (phase): ...`): positions instead of names of changeable data,
|
|
110
|
-
labels but no selectors.
|
|
111
|
-
- Delete the
|
|
113
|
+
(`- Recipe (phase): ...`): positions instead of names of changeable data, created names (project prefix plus the
|
|
114
|
+
run's UTC stamp), control labels but no selectors.
|
|
115
|
+
- Delete the test items you created (they start with the project's test-data prefix and carry this run's timestamp)
|
|
116
|
+
before you press Done on the last step.
|
|
112
117
|
- Without `--attended` the agent never waits: a step that needs a person becomes a `TODO [blocking]`.
|
|
113
118
|
|
|
114
119
|
In the web UI the **Attended** box is ticked by default for Explore.
|
|
@@ -138,6 +143,11 @@ subset adds up: explore A and B first and C later, and `/gen-spec` covers all th
|
|
|
138
143
|
about runs on `default` alone, and the report names it. A profile that is not logged in is not dropped: its part is
|
|
139
144
|
written from the case and marked `not verified on the page`.
|
|
140
145
|
|
|
146
|
+
It reads each profile's **exploration note** first: a fresh note supplies the locators and request patterns, which it
|
|
147
|
+
confirms on the page instead of rediscovering; a missing or stale note means a full replay. A page that only exists
|
|
148
|
+
after a write step is written from a fresh note. `pnpm explore:notes <case-id> [--profile <id>]` shows what was
|
|
149
|
+
recorded, and `--check` validates it.
|
|
150
|
+
|
|
141
151
|
It does **not** run the spec and does **not** create screenshot baselines: running is a separate step (6).
|
|
142
152
|
|
|
143
153
|
- A case must be in the project's artifact language. If it is in another language, `/gen-spec` translates the case
|
|
@@ -213,6 +223,10 @@ Several binding rules hold in exploring, generating and healing. They live in th
|
|
|
213
223
|
- **Case rules.** A `## Rules` bullet says how to run the case, never what it proves. The agent applies it while
|
|
214
224
|
exploring and generating, marks code that exists only because of a rule with `// rule: <short quote>`, and lists every
|
|
215
225
|
rule in its report. It never becomes an assertion and never authorizes a write or a person's step.
|
|
226
|
+
- **Test data is findable per run.** Everything a write case creates is named with the project's test-data prefix
|
|
227
|
+
(`package.json` `"asrun"`, default `[asrun]`) and the run's UTC stamp: `testDataName()` builds
|
|
228
|
+
`[autotest] Draft CMA (2026-11-09 12-34-56)`. Two runs never share a name, the stamp matches `reports/<run-id>`, and
|
|
229
|
+
a person can search the product for one run's data. The case's `## Values` holds the base without the stamp.
|
|
216
230
|
- **Targets.** A check on a logo, image, icon, file, PDF, screenshot or repeated text points to a `[T1]` card under
|
|
217
231
|
`## Targets`: what it is (and what it is not), where it is, how it is identified (never `.first()` or a bare `img`),
|
|
218
232
|
when it is ready, and a highlighted reference screenshot under `.explore/targets/`. When in doubt the agent does not
|
|
@@ -232,9 +246,10 @@ Checks you can run yourself:
|
|
|
232
246
|
|
|
233
247
|
| Command | What it checks |
|
|
234
248
|
|---|---|
|
|
235
|
-
| `pnpm spec:lint [case-id]` | fails on screenshots without a target, `.first()` on images, sleeps, `networkidle`, raw download events, a `%PDF` check on its own |
|
|
249
|
+
| `pnpm spec:lint [case-id]` | fails on screenshots without a target, `.first()` on images, sleeps, `networkidle`, raw download events, a `%PDF` check on its own; warns when a tracked test-data name is not built with `testDataName()` |
|
|
236
250
|
| `pnpm baselines:review [case-id] [--new]` | lists every baseline with the card that says what it must show, so you can look at each new image |
|
|
237
251
|
| `pnpm case:check [case-id]` | fails on an incomplete card, bad values, a marker inside a question or a question under a marked line; warns on an image check with no card and on a marker that does not say what to decide |
|
|
252
|
+
| `pnpm asrun rules [--json] [--case <id>]` | prints the effective rules (baseline, §2, project settings) the AI steps read; fails on an invalid §2 row or `testDataPrefix` |
|
|
238
253
|
| `pnpm markers:confirm --case <id> --env <env> [--report <run-id>]` | after a passing run, removes the `🔍 to be confirmed` markers whose line the spec checks with `// unconfirmed:`; `--dry-run` lists them |
|
|
239
254
|
| `pnpm pdf:render <file.pdf> --out <dir> [--pages all] [--corner top-left]` | renders the pages to images and prints the text, to look at a PDF by hand |
|
|
240
255
|
|
|
@@ -273,10 +288,12 @@ An override is plain Markdown; write only what differs:
|
|
|
273
288
|
- The staging account has no published documents: assert the empty state there instead.
|
|
274
289
|
```
|
|
275
290
|
|
|
276
|
-
The project's `AGENTS.md` §2 is the other place: a table of rule ids (`F7`–`
|
|
277
|
-
`R2` are fixed) with `narrows` (stricter) or `replaces` (a different default).
|
|
278
|
-
|
|
279
|
-
|
|
291
|
+
The project's `AGENTS.md` §2 is the other place: a table of rule ids (`F7`–`F11`, `R1`, `R3`, `R4`; `F1`–`F6` and
|
|
292
|
+
`R2` are fixed) with `narrows` (stricter) or `replaces` (a different default). A rule the baseline does not have gets
|
|
293
|
+
a project id (`P1`, `P2`, ...) and `adds`. For example, a project that writes cases in Simplified Chinese `replaces`
|
|
294
|
+
`F10`. `pnpm asrun doctor` fails on an unknown id, a missing `Effect`, a duplicate id, a non-negotiable rule or a
|
|
295
|
+
project id without `adds`, and `pnpm asrun rules` prints the effective set (the AI steps read it before they apply
|
|
296
|
+
anything). A project rule is applied as written: a difference from the baseline is never a question.
|
|
280
297
|
|
|
281
298
|
An update never overwrites `AGENTS.md`, `CLAUDE.md` or `.claude/skills/**`: `pnpm asrun init --force` refuses them
|
|
282
299
|
without `--reset-rules`. Never edit the framework package itself.
|
package/docs/cases.md
CHANGED
|
@@ -110,13 +110,14 @@ report and the trace read like the case.
|
|
|
110
110
|
choice (an existing document, items), `[email]` for a step that sends email. They are written at the end of the
|
|
111
111
|
step line.
|
|
112
112
|
- **`- Recipe (phase):`** under a tagged step records what a person did in the browser during an attended
|
|
113
|
-
exploration: positions instead of names of changeable data (`select items #1 to #3`),
|
|
114
|
-
|
|
115
|
-
generated: `/gen-spec` stops with `needs-input`.
|
|
113
|
+
exploration: positions instead of names of changeable data (`select items #1 to #3`), created names (project prefix
|
|
114
|
+
plus run stamp), control labels but no selectors. Several lines per step are normal. A tagged step with no recipe is
|
|
115
|
+
not generated: `/gen-spec` stops with `needs-input`.
|
|
116
116
|
- **Open questions** go in a `🚫 TODO [blocking]` or `❓ TODO [non-blocking]` block under the sentence they are about,
|
|
117
117
|
with the agent's suggestion written above and a last line `- ✍️ Answer:`. Accepting a question means deleting the
|
|
118
|
-
whole block; answering means writing after `Answer:`.
|
|
119
|
-
under
|
|
118
|
+
whole block; answering means writing after `Answer:`. In the web panel the two actions are **Keep what the case
|
|
119
|
+
says** and **Write your answer**. Hang the block under the sentence that makes the claim, not under a broader one
|
|
120
|
+
above it, and one claim gets one block.
|
|
120
121
|
- **What the AI saw** is marked by what happens if it is wrong: `(seen on <profile>)` is a plain note the run checks by
|
|
121
122
|
itself, and `(observed on <profile>, 🔍 to be confirmed: <what a person must decide>)` is a line that would pass even
|
|
122
123
|
if it were wrong, so a person settles it. A marker may name a screenshot (`evidence: .explore/.../x.png`) so nobody
|
|
@@ -173,7 +174,10 @@ anything.
|
|
|
173
174
|
|
|
174
175
|
A case may write only in the environments listed under `writes`, and only data of the test account:
|
|
175
176
|
|
|
176
|
-
- everything it creates is named
|
|
177
|
+
- everything it creates is named with the project's prefix and the run stamp, `<prefix> <base> (<run stamp>)`
|
|
178
|
+
(prefix: `asrun.testDataPrefix` in `package.json`, default `[asrun]`; stamp: the run's UTC time). The spec builds
|
|
179
|
+
the name with `testDataName()` (`records` refuses a name without the prefix), so two runs never collide and a
|
|
180
|
+
person can search the product for one run's data. `## Values` holds the base without the stamp;
|
|
177
181
|
- created data is registered with `records.track` and removed after the test, also after a failure;
|
|
178
182
|
- the case's own last step is normally the deletion; `track` is the safety net;
|
|
179
183
|
- staging needs no confirmation. In any other environment a person confirms every run (typing the environment name,
|
|
@@ -207,7 +211,7 @@ that cannot be answered in place.
|
|
|
207
211
|
|
|
208
212
|
## Rename or move a case
|
|
209
213
|
|
|
210
|
-
Use the Cases page (
|
|
211
|
-
spec is rewritten to match. By hand, move the three things together
|
|
212
|
-
`*-snapshots/`), and keep the spec's `// case:` line and its imports
|
|
213
|
-
name, so their folder depth does not change the imports.
|
|
214
|
+
Use the Cases page (open the case, then **Rename or move** in the **⋯** menu at the end of the tab strip). The spec
|
|
215
|
+
and the baseline images move with it and the spec is rewritten to match. By hand, move the three things together
|
|
216
|
+
(`cases/...md`, `tests/...spec.ts`, its `*-snapshots/`), and keep the spec's `// case:` line and its imports
|
|
217
|
+
unchanged: specs import the framework by package name, so their folder depth does not change the imports.
|