create-afe-project 0.0.0-stage → 2.0.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/CHANGELOG.md +458 -0
- package/LICENSE +21 -0
- package/README.md +77 -2
- package/bin/create-afe-project.js +123 -0
- package/package.json +43 -4
- package/src/answers.js +147 -0
- package/src/config.js +151 -0
- package/src/project.js +217 -0
- package/src/render.js +51 -0
- package/template/.claude/harness.conf +50 -0
- package/template/.claude/hooks/post-edit-check.sh +41 -0
- package/template/.claude/hooks/protect-docs.sh +315 -0
- package/template/.claude/hooks/session-start.sh +81 -0
- package/template/.claude/hooks/stop-check-decisions.sh +25 -0
- package/template/.claude/hooks/stop-check-docs.sh +29 -0
- package/template/.claude/hooks/stop-check-fast.sh +33 -0
- package/template/.claude/hooks/stop-check-template.sh +23 -0
- package/template/.claude/hooks/stop-run-project-hooks.sh +27 -0
- package/template/.claude/path-classes +102 -0
- package/template/.claude/settings.project.json +5 -0
- package/template/.claude/settings.template.json +92 -0
- package/template/.claude/skills/afe-init/SKILL.md +104 -0
- package/template/.claude/skills/create-pr/SKILL.md.jinja +110 -0
- package/template/.claude/skills/update-template/SKILL.md +40 -0
- package/template/.claude/skills/{% if code_review %}review-code{% endif %}/SKILL.md.jinja +50 -0
- package/template/.claude/skills/{% if docs_review %}docs-review{% endif %}/SKILL.md +60 -0
- package/template/.claude/skills/{% if dual_review %}dual-review{% endif %}/SKILL.md +58 -0
- package/template/.github/workflows/decisions.yml.jinja +57 -0
- package/template/.github/workflows/docs.yml.jinja +40 -0
- package/template/.github/workflows/{% if code_review %}code-review.yml{% endif %}.jinja +54 -0
- package/template/.github/workflows/{% if docs_review %}docs-review.yml{% endif %}.jinja +52 -0
- package/template/.github/workflows/{% if fail_without_change %}fail-without-change.yml{% endif %}.jinja +42 -0
- package/template/.github/workflows/{% if secret_scan %}secret-scan.yml{% endif %}.jinja +47 -0
- package/template/.markdownlint-cli2.jsonc +27 -0
- package/template/.template/owned.txt.jinja +65 -0
- package/template/AGENTS.md.jinja +91 -0
- package/template/CLAUDE.md +3 -0
- package/template/README.md.jinja +53 -0
- package/template/_gitignore +14 -0
- package/template/docs/README.md.jinja +44 -0
- package/template/docs/contributing.md.jinja +206 -0
- package/template/docs/decisions/adr-template.md +38 -0
- package/template/docs/documentation-rules.md.jinja +200 -0
- package/template/docs/testing-rules.md +80 -0
- package/template/scripts/check-decisions.sh +382 -0
- package/template/scripts/check-docs.sh +20 -0
- package/template/scripts/check-links.mjs +142 -0
- package/template/scripts/check-project.sh +27 -0
- package/template/scripts/check-template.sh +88 -0
- package/template/scripts/check.sh +21 -0
- package/template/scripts/env.sh +29 -0
- package/template/scripts/gen-decisions-index.sh +57 -0
- package/template/scripts/gen-settings.sh +14 -0
- package/template/scripts/lib/classes.sh +89 -0
- package/template/scripts/lib/git-base.sh +25 -0
- package/template/scripts/lib/harness-conf.sh +48 -0
- package/template/scripts/lib/json.mjs +160 -0
- package/template/scripts/pr-metrics.sh +80 -0
- package/template/scripts/protect-branch.sh +101 -0
- package/template/scripts/{% if acceptance_check %}check-acceptance.sh{% endif %} +47 -0
- package/template/scripts/{% if fail_without_change %}fail-without-change.sh{% endif %} +88 -0
package/src/project.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Creates a project from the template, and updates a project to the template of this package.
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { chmodSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { dirname, join, resolve } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { UsageError, collectAnswers, compareVersions, readAnswers, writeAnswers } from './answers.js';
|
|
8
|
+
import { LEGACY_ANSWERS_FILE, MIGRATIONS, SEEDED, TASKS, messageAfterCreate } from './config.js';
|
|
9
|
+
import { renderTemplate } from './render.js';
|
|
10
|
+
|
|
11
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
12
|
+
export const TEMPLATE_DIR = join(ROOT, 'template');
|
|
13
|
+
export const VERSION = JSON.parse(readFileSync(join(ROOT, 'package.json'), 'utf8')).version;
|
|
14
|
+
|
|
15
|
+
const OWNED_LIST = '.template/owned.txt';
|
|
16
|
+
const MANIFEST = '.template/owned.sha256';
|
|
17
|
+
const START = '<!-- project:start -->';
|
|
18
|
+
const END = '<!-- project:end -->';
|
|
19
|
+
|
|
20
|
+
function writeFile(dir, rel, { content, mode }) {
|
|
21
|
+
const target = join(dir, rel);
|
|
22
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
23
|
+
writeFileSync(target, content);
|
|
24
|
+
chmodSync(target, mode & 0o777);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function run(dir, command, quiet) {
|
|
28
|
+
const r = spawnSync('bash', ['-c', command], {
|
|
29
|
+
cwd: dir,
|
|
30
|
+
stdio: ['ignore', quiet ? 'pipe' : 'inherit', quiet ? 'pipe' : 'inherit'],
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
});
|
|
33
|
+
if (r.status !== 0) {
|
|
34
|
+
const output = quiet ? `\n${r.stdout ?? ''}${r.stderr ?? ''}` : '';
|
|
35
|
+
throw new Error(`\`${command}\` failed with exit code ${r.status}${output}`);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runTasks(dir, quiet) {
|
|
40
|
+
for (const task of TASKS) run(dir, task, quiet);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listed(text) {
|
|
44
|
+
return text
|
|
45
|
+
.split('\n')
|
|
46
|
+
.map((l) => l.trim())
|
|
47
|
+
.filter((l) => l && !l.startsWith('#'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Lines of AGENTS.md inside its project block; throws unless there is exactly one. */
|
|
51
|
+
function projectBlock(text) {
|
|
52
|
+
const lines = text.split('\n');
|
|
53
|
+
const s = lines.filter((l) => l === START).length;
|
|
54
|
+
const e = lines.filter((l) => l === END).length;
|
|
55
|
+
const si = lines.indexOf(START);
|
|
56
|
+
const ei = lines.indexOf(END);
|
|
57
|
+
if (s !== 1 || e !== 1 || si > ei) {
|
|
58
|
+
throw new UsageError(`AGENTS.md needs exactly one '${START}' line followed by one '${END}' line`);
|
|
59
|
+
}
|
|
60
|
+
return lines.slice(si + 1, ei);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function withBlock(rendered, block) {
|
|
64
|
+
const lines = rendered.split('\n');
|
|
65
|
+
const si = lines.indexOf(START);
|
|
66
|
+
const ei = lines.indexOf(END);
|
|
67
|
+
return [...lines.slice(0, si + 1), ...block, ...lines.slice(ei)].join('\n');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The content that the manifest hash covers, as scripts/check-template.sh computes it. */
|
|
71
|
+
function hashed(rel, text) {
|
|
72
|
+
if (rel !== 'AGENTS.md') return text;
|
|
73
|
+
let skip = false;
|
|
74
|
+
const kept = [];
|
|
75
|
+
for (const line of text.split('\n')) {
|
|
76
|
+
if (line === START) {
|
|
77
|
+
kept.push(line);
|
|
78
|
+
skip = true;
|
|
79
|
+
} else if (line === END) {
|
|
80
|
+
skip = false;
|
|
81
|
+
kept.push(line);
|
|
82
|
+
} else if (!skip) kept.push(line);
|
|
83
|
+
}
|
|
84
|
+
return kept.join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const sha256 = (text) => createHash('sha256').update(text).digest('hex');
|
|
88
|
+
|
|
89
|
+
/** Template-owned files whose content no longer matches the recorded manifest. */
|
|
90
|
+
function editedOwnedFiles(dir) {
|
|
91
|
+
const manifest = join(dir, MANIFEST);
|
|
92
|
+
if (!existsSync(manifest)) return [];
|
|
93
|
+
const edited = [];
|
|
94
|
+
for (const line of listed(readFileSync(manifest, 'utf8'))) {
|
|
95
|
+
const [hash, rel] = line.split(/\s+/, 2);
|
|
96
|
+
const file = join(dir, rel);
|
|
97
|
+
if (existsSync(file) && sha256(hashed(rel, readFileSync(file, 'utf8'))) !== hash) edited.push(rel);
|
|
98
|
+
}
|
|
99
|
+
return edited;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isDirty(dir) {
|
|
103
|
+
try {
|
|
104
|
+
return execFileSync('git', ['status', '--porcelain'], { cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim() !== '';
|
|
105
|
+
} catch {
|
|
106
|
+
return false; // not a Git repository
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Creates a project in `dir`.
|
|
112
|
+
* @param {string} dir
|
|
113
|
+
* @param {{ given?: Record<string, unknown>, useDefaults?: boolean, interactive?: boolean,
|
|
114
|
+
* force?: boolean, quiet?: boolean, log?: (msg: string) => void }} options
|
|
115
|
+
* @returns {Promise<{ answers: Record<string, unknown>, message: string }>}
|
|
116
|
+
*/
|
|
117
|
+
export async function create(dir, { given, useDefaults, interactive, force = false, quiet = false, git = true, log = () => {} }) {
|
|
118
|
+
const empty = !existsSync(dir) || readdirSync(dir).every((n) => n === '.git');
|
|
119
|
+
if (!empty && !force) {
|
|
120
|
+
throw new UsageError(`${dir} is not empty; choose an empty directory, or pass --force to write into it`);
|
|
121
|
+
}
|
|
122
|
+
const { answers, context } = await collectAnswers({ given, useDefaults, interactive });
|
|
123
|
+
mkdirSync(dir, { recursive: true });
|
|
124
|
+
for (const [rel, file] of renderTemplate(TEMPLATE_DIR, context)) writeFile(dir, rel, file);
|
|
125
|
+
writeAnswers(dir, VERSION, answers);
|
|
126
|
+
log('Writing generated files');
|
|
127
|
+
runTasks(dir, quiet);
|
|
128
|
+
let commit = { done: false, reason: 'skipped with --no-git' };
|
|
129
|
+
if (git) {
|
|
130
|
+
log('Making the initial commit');
|
|
131
|
+
commit = empty ? initialCommit(dir) : { done: false, reason: 'the directory was not empty' };
|
|
132
|
+
}
|
|
133
|
+
return { answers, commit, message: messageAfterCreate(answers, commit) };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Commits the generated files as the first commit on `main`: the one direct commit to `main` that
|
|
138
|
+
* docs/contributing.md, section 1, allows. Runs only in a new repository, or in one without
|
|
139
|
+
* commits (an empty clone); never throws.
|
|
140
|
+
* @param {string} dir
|
|
141
|
+
* @returns {{ done: boolean, reason?: string }}
|
|
142
|
+
*/
|
|
143
|
+
export function initialCommit(dir) {
|
|
144
|
+
const git = (...args) => spawnSync('git', args, { cwd: dir, encoding: 'utf8' });
|
|
145
|
+
const failed = (r) => r.error || r.status !== 0;
|
|
146
|
+
const why = (r) => (r.error ? r.error.message : (r.stderr || r.stdout || '').trim().split('\n')[0]);
|
|
147
|
+
if (existsSync(join(dir, '.git'))) {
|
|
148
|
+
if (!failed(git('rev-parse', '--verify', '--quiet', 'HEAD'))) return { done: false, reason: 'the repository already has commits' };
|
|
149
|
+
const r = git('symbolic-ref', 'HEAD', 'refs/heads/main');
|
|
150
|
+
if (failed(r)) return { done: false, reason: why(r) };
|
|
151
|
+
} else {
|
|
152
|
+
const r = git('init', '--quiet', '--initial-branch=main');
|
|
153
|
+
if (failed(r)) return { done: false, reason: `git init failed: ${why(r)}` };
|
|
154
|
+
}
|
|
155
|
+
for (const args of [['add', '--all'], ['commit', '--quiet', '-m', `Template: create the project with create-afe-project ${VERSION}`]]) {
|
|
156
|
+
const r = git(...args);
|
|
157
|
+
if (failed(r)) return { done: false, reason: `git ${args[0]} failed: ${why(r)}` };
|
|
158
|
+
}
|
|
159
|
+
return { done: true };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Updates the project in `dir` to this package's template. Template-owned files are replaced, the
|
|
164
|
+
* project block of AGENTS.md is kept, files the template no longer owns are removed, missing
|
|
165
|
+
* seeded files are created and existing ones never changed, the migrations between the two
|
|
166
|
+
* versions run, and the generated files are rebuilt.
|
|
167
|
+
* @param {string} dir
|
|
168
|
+
* @param {{ given?: Record<string, unknown>, useDefaults?: boolean, interactive?: boolean,
|
|
169
|
+
* allowDirty?: boolean, quiet?: boolean }} options
|
|
170
|
+
*/
|
|
171
|
+
export async function update(dir, { given, useDefaults, interactive, allowDirty = false, quiet = false }) {
|
|
172
|
+
const recorded = readAnswers(dir);
|
|
173
|
+
if (!recorded) throw new UsageError(`${dir} has no answers file; is it a project created by create-afe-project?`);
|
|
174
|
+
if (compareVersions(recorded.version, VERSION) > 0) {
|
|
175
|
+
throw new UsageError(`the project follows template ${recorded.version}, newer than this package (${VERSION}); run npx create-afe-project@latest update`);
|
|
176
|
+
}
|
|
177
|
+
if (!allowDirty && isDirty(dir)) {
|
|
178
|
+
throw new UsageError('the working tree has uncommitted changes; commit or stash them, or pass --allow-dirty');
|
|
179
|
+
}
|
|
180
|
+
const agentsFile = join(dir, 'AGENTS.md');
|
|
181
|
+
const block = existsSync(agentsFile) ? projectBlock(readFileSync(agentsFile, 'utf8')) : null;
|
|
182
|
+
|
|
183
|
+
const { answers, context } = await collectAnswers({ given, previous: recorded.answers, useDefaults, interactive });
|
|
184
|
+
const rendered = renderTemplate(TEMPLATE_DIR, context);
|
|
185
|
+
const newOwned = listed(String(rendered.get(OWNED_LIST).content));
|
|
186
|
+
const oldOwnedFile = join(dir, OWNED_LIST);
|
|
187
|
+
const oldOwned = existsSync(oldOwnedFile) ? listed(readFileSync(oldOwnedFile, 'utf8')) : [];
|
|
188
|
+
const edited = editedOwnedFiles(dir);
|
|
189
|
+
const pending = MIGRATIONS.filter(
|
|
190
|
+
(m) => compareVersions(m.version, recorded.version) > 0 && compareVersions(m.version, VERSION) <= 0,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
for (const m of pending) if (m.before) run(dir, m.before, quiet);
|
|
194
|
+
|
|
195
|
+
const removed = oldOwned.filter((rel) => !newOwned.includes(rel) && existsSync(join(dir, rel)));
|
|
196
|
+
for (const rel of removed) rmSync(join(dir, rel));
|
|
197
|
+
|
|
198
|
+
const seeded = [];
|
|
199
|
+
const unowned = [];
|
|
200
|
+
for (const [rel, file] of rendered) {
|
|
201
|
+
const exists = existsSync(join(dir, rel));
|
|
202
|
+
if (newOwned.includes(rel)) {
|
|
203
|
+
const content = rel === 'AGENTS.md' && block ? withBlock(String(file.content), block) : file.content;
|
|
204
|
+
writeFile(dir, rel, { ...file, content });
|
|
205
|
+
} else if (!exists) {
|
|
206
|
+
writeFile(dir, rel, file);
|
|
207
|
+
(SEEDED.includes(rel) ? seeded : unowned).push(rel);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
writeAnswers(dir, VERSION, answers);
|
|
211
|
+
if (recorded.legacy) rmSync(join(dir, LEGACY_ANSWERS_FILE));
|
|
212
|
+
|
|
213
|
+
for (const m of pending) if (m.after) run(dir, m.after, quiet);
|
|
214
|
+
runTasks(dir, quiet);
|
|
215
|
+
|
|
216
|
+
return { from: recorded.version, to: VERSION, removed, edited, seeded, unowned, migrations: pending.map((m) => m.version), legacy: recorded.legacy };
|
|
217
|
+
}
|
package/src/render.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Renders template/ with the answers: Jinja syntax through Nunjucks, as Copier did. A file whose
|
|
2
|
+
// name ends in .jinja is rendered and loses the suffix; any other file is copied as it is. Every
|
|
3
|
+
// path segment is rendered too, and a segment that renders empty drops the file or directory, so
|
|
4
|
+
// a module's files use a conditional name such as `{% if code_review %}review-code{% endif %}`.
|
|
5
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import nunjucks from 'nunjucks';
|
|
8
|
+
import { RENAMES } from './config.js';
|
|
9
|
+
|
|
10
|
+
const SUFFIX = '.jinja';
|
|
11
|
+
const env = new nunjucks.Environment(null, { autoescape: false, throwOnUndefined: true });
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} text
|
|
15
|
+
* @param {Record<string, unknown>} context
|
|
16
|
+
* @returns {string}
|
|
17
|
+
*/
|
|
18
|
+
export function renderString(text, context) {
|
|
19
|
+
return env.renderString(text, context);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Renders every file of the template directory.
|
|
24
|
+
* @param {string} templateDir
|
|
25
|
+
* @param {Record<string, unknown>} context
|
|
26
|
+
* @returns {Map<string, { content: Buffer | string, mode: number }>} by relative output path
|
|
27
|
+
*/
|
|
28
|
+
export function renderTemplate(templateDir, context) {
|
|
29
|
+
const out = new Map();
|
|
30
|
+
const walk = (srcDir, outPrefix) => {
|
|
31
|
+
for (const entry of readdirSync(srcDir, { withFileTypes: true })) {
|
|
32
|
+
const src = join(srcDir, entry.name);
|
|
33
|
+
const templated = entry.isFile() && entry.name.endsWith(SUFFIX);
|
|
34
|
+
let name = renderString(templated ? entry.name.slice(0, -SUFFIX.length) : entry.name, context);
|
|
35
|
+
if (!name) continue;
|
|
36
|
+
if (entry.isDirectory()) {
|
|
37
|
+
walk(src, outPrefix ? `${outPrefix}/${name}` : name);
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
const { mode } = statSync(src);
|
|
41
|
+
let content = readFileSync(src);
|
|
42
|
+
if (templated) {
|
|
43
|
+
content = renderString(content.toString('utf8'), context);
|
|
44
|
+
}
|
|
45
|
+
name = RENAMES[name] ?? name;
|
|
46
|
+
out.set(outPrefix ? `${outPrefix}/${name}` : name, { content, mode });
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(templateDir, '');
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Harness settings of this project (docs/contributing.md, section 3; docs/testing-rules.md,
|
|
2
|
+
# section 3): one KEY=value per line. Scripts read the values and never run this file; an empty
|
|
3
|
+
# value leaves a setting unset. This file belongs to the project; a change to it is class gate.
|
|
4
|
+
|
|
5
|
+
# Environment commands, run by scripts/env.sh setup|fast|e2e|reset (check is scripts/check.sh):
|
|
6
|
+
# set up a working environment, a fast check, end-to-end tests, return to a known-good state.
|
|
7
|
+
# Once set to something other than scripts/check-docs.sh, CHECK_FAST also runs at each Stop of
|
|
8
|
+
# Claude Code that follows a change to a file other than Markdown (.claude/hooks/stop-check-fast.sh);
|
|
9
|
+
# Claude Code stops a hook after 300 seconds without blocking, so keep it well within that.
|
|
10
|
+
SETUP=
|
|
11
|
+
CHECK_FAST=scripts/check-docs.sh
|
|
12
|
+
E2E=
|
|
13
|
+
RESET=
|
|
14
|
+
|
|
15
|
+
# The check after every edit (.claude/hooks/post-edit-check.sh): a command that gets the changed
|
|
16
|
+
# file as its last argument and takes seconds, such as a formatter check and the linter on that
|
|
17
|
+
# file; and the files it applies to, as shell globs separated by spaces (empty: every file).
|
|
18
|
+
# Empty CHECK_EDIT: no check after edits.
|
|
19
|
+
CHECK_EDIT=
|
|
20
|
+
CHECK_EDIT_GLOBS=
|
|
21
|
+
|
|
22
|
+
# Ralph loop: the most runs of scripts/check.sh before the agent stops and reports the failure.
|
|
23
|
+
RALPH_MAX_ITERATIONS=5
|
|
24
|
+
|
|
25
|
+
# Santa loop (the dual-review skill): the most review rounds for a change of class high before
|
|
26
|
+
# the open blockers go to the owner; 3 to 5.
|
|
27
|
+
REVIEW_MAX_ROUNDS=3
|
|
28
|
+
|
|
29
|
+
# A warning, not a failure, when a pull request changes more lines than this, generated files
|
|
30
|
+
# left out (docs/contributing.md, section 1). Empty: no warning.
|
|
31
|
+
DIFF_WARN_LINES=400
|
|
32
|
+
|
|
33
|
+
# Identifiers that requirements, criteria, and the specifications of checks define, as an
|
|
34
|
+
# extended regular expression. The "Acceptance and test plan" section and the tests carry them.
|
|
35
|
+
ID_PATTERN=(REQ|AC|INV)-[0-9]+
|
|
36
|
+
|
|
37
|
+
# Test files, as shell globs separated by spaces, for example `tests/* src/*_test.go`. Empty: the
|
|
38
|
+
# Acceptance check and the fail-without-change driver skip.
|
|
39
|
+
TEST_GLOBS=
|
|
40
|
+
|
|
41
|
+
# fail-without-change: the files besides the tests to copy onto the base, such as test helpers
|
|
42
|
+
# and runner configuration (shell globs); the command that installs dependencies there (empty:
|
|
43
|
+
# SETUP); the test
|
|
44
|
+
# command, which the driver runs with one test file as its last argument; and an optional
|
|
45
|
+
# command that receives the log file and the test file and exits 0 when the base failed for the
|
|
46
|
+
# expected reason.
|
|
47
|
+
FWC_TOOLCHAIN_GLOBS=
|
|
48
|
+
FWC_INSTALL=
|
|
49
|
+
FWC_TEST=
|
|
50
|
+
FWC_CLASSIFIER=
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# PostToolUse hook (Edit, Write, MultiEdit): the check after every edit (docs/testing-rules.md,
|
|
3
|
+
# section 4). It runs the project's fast per-file check, CHECK_EDIT of .claude/harness.conf, with
|
|
4
|
+
# the changed file as its last argument, and sends a failure back to the agent in the same
|
|
5
|
+
# session (exit 2), before the Stop hooks and CI. CHECK_EDIT_GLOBS (shell globs separated by
|
|
6
|
+
# spaces, * also matches /) limits it to matching files; empty: every file. A relative path is
|
|
7
|
+
# resolved against the input's cwd. An empty CHECK_EDIT, a file outside the project, or a deleted
|
|
8
|
+
# file runs nothing. Keep the command to seconds: it runs after every edit.
|
|
9
|
+
set -uo pipefail
|
|
10
|
+
root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null)}"
|
|
11
|
+
[ -n "$root" ] || exit 0
|
|
12
|
+
cd "$root" || exit 0
|
|
13
|
+
. scripts/lib/harness-conf.sh 2>/dev/null || exit 0
|
|
14
|
+
cmd=$(conf_get CHECK_EDIT)
|
|
15
|
+
[ -n "$cmd" ] || exit 0
|
|
16
|
+
{ IFS= read -r -d '' file; IFS= read -r -d '' cwd; } < <(node scripts/lib/json.mjs fields tool_input.file_path cwd 2>/dev/null)
|
|
17
|
+
[ -n "${file:-}" ] || exit 0
|
|
18
|
+
# A relative path is relative to the session's working directory, which the input names.
|
|
19
|
+
case "$file" in /*) ;; *) file="${cwd:-$root}/$file" ;; esac
|
|
20
|
+
[ -f "$file" ] || exit 0
|
|
21
|
+
root=$(pwd -P)
|
|
22
|
+
abs=$(cd "$(dirname "$file")" 2>/dev/null && printf '%s/%s' "$(pwd -P)" "$(basename "$file")") || exit 0
|
|
23
|
+
case "$abs" in "$root"/*) rel=${abs#"$root"/} ;; *) exit 0 ;; esac
|
|
24
|
+
|
|
25
|
+
globs=$(conf_get CHECK_EDIT_GLOBS)
|
|
26
|
+
if [ -n "$globs" ]; then
|
|
27
|
+
match=
|
|
28
|
+
set -f
|
|
29
|
+
for g in $globs; do
|
|
30
|
+
# shellcheck disable=SC2254
|
|
31
|
+
case "$rel" in $g) match=1; break ;; esac
|
|
32
|
+
done
|
|
33
|
+
set +f
|
|
34
|
+
[ -n "$match" ] || exit 0
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
out=$(bash -c "$cmd \"\$1\"" check-edit "$rel" 2>&1)
|
|
38
|
+
code=$?
|
|
39
|
+
[ "$code" -eq 0 ] && exit 0
|
|
40
|
+
printf 'CHECK_EDIT failed on %s (exit %s); fix it before you go on:\n%s\n' "$rel" "$code" "$(printf '%s\n' "$out" | tail -n 40)" >&2
|
|
41
|
+
exit 2
|