@grvmithas/create-aies-kit 1.3.0 → 2.3.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 +96 -0
- package/README.md +82 -29
- package/bin/create-aies-kit.js +143 -41
- package/lib/audit.js +367 -0
- package/lib/kit-core/apply.js +143 -0
- package/lib/kit-core/embedded-corpus.js +79 -67
- package/lib/kit-core/evals.js +256 -0
- package/lib/kit-core/generate.js +984 -412
- package/lib/kit-core/install-api.js +154 -106
- package/lib/kit-core/mcp-catalog.js +95 -40
- package/lib/kit-core/options.js +20 -3
- package/lib/kit-core/repo-scan.js +158 -0
- package/package.json +12 -3
package/lib/audit.js
ADDED
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import { access, readFile, writeFile, mkdir, readdir } from 'node:fs/promises';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import fs, { constants } from 'node:fs';
|
|
4
|
+
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { generateStarterKit, layoutFor, VERIFY_PLACEHOLDER } from './kit-core/generate.js';
|
|
6
|
+
import { normalizeIde } from './kit-core/options.js';
|
|
7
|
+
import { detectVerifyCommands } from './kit-core/repo-scan.js';
|
|
8
|
+
import { CONFLICT_SUFFIX } from './kit-core/apply.js';
|
|
9
|
+
|
|
10
|
+
/** Hook formats whose completion event can refuse "done" (see hooks/aies-done.mjs). */
|
|
11
|
+
const DONE_HOOK_FORMATS = new Set(['claude', 'cursor', 'codex', 'gemini', 'devin']);
|
|
12
|
+
|
|
13
|
+
const SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage', '.venv', 'venv', 'target', 'bin', 'obj']);
|
|
14
|
+
|
|
15
|
+
/** Paths (relative) of files under cwd whose name ends with `suffix`, bounded depth. */
|
|
16
|
+
async function findBySuffix(cwd, suffix, dir = '', depth = 0, out = []) {
|
|
17
|
+
if (depth > 6) return out;
|
|
18
|
+
let entries = [];
|
|
19
|
+
try {
|
|
20
|
+
entries = await readdir(join(cwd, dir), { withFileTypes: true });
|
|
21
|
+
} catch {
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
for (const e of entries) {
|
|
25
|
+
const rel = dir ? `${dir}/${e.name}` : e.name;
|
|
26
|
+
if (e.isDirectory()) {
|
|
27
|
+
if (!SKIP_DIRS.has(e.name)) await findBySuffix(cwd, suffix, rel, depth + 1, out);
|
|
28
|
+
} else if (e.name.endsWith(suffix)) {
|
|
29
|
+
out.push(rel);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function verifySection(agentsMd) {
|
|
36
|
+
return agentsMd.match(/## Verify before claiming done([\s\S]*?)(\n## |$)/)?.[1] ?? '';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function exists(path) {
|
|
40
|
+
try {
|
|
41
|
+
await access(path, constants.F_OK);
|
|
42
|
+
return true;
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function read(path) {
|
|
49
|
+
try {
|
|
50
|
+
return await readFile(path, 'utf8');
|
|
51
|
+
} catch {
|
|
52
|
+
return '';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Write bundle files matching `predicate` that do not already exist — fixes never overwrite user edits. */
|
|
57
|
+
async function writeMissing(cwd, profile, predicate) {
|
|
58
|
+
const bundle = generateStarterKit(profile, { verify: detectVerifyCommands(cwd, fs) });
|
|
59
|
+
const created = [];
|
|
60
|
+
for (const [relPath, content] of Object.entries(bundle.files)) {
|
|
61
|
+
if (!predicate(relPath)) continue;
|
|
62
|
+
const abs = join(cwd, relPath);
|
|
63
|
+
if (await exists(abs)) continue;
|
|
64
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
65
|
+
await writeFile(abs, content, 'utf8');
|
|
66
|
+
created.push(relPath);
|
|
67
|
+
}
|
|
68
|
+
return created.length ? `Created ${created.length} file(s): ${created.slice(0, 6).join(', ')}${created.length > 6 ? ', …' : ''}` : 'Nothing to create';
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function runAudit(cwd, { fix = false, profile = null } = {}) {
|
|
72
|
+
const effectiveProfile = {
|
|
73
|
+
goal: 'existing-codebase',
|
|
74
|
+
role: 'fullstack',
|
|
75
|
+
maturity: 'intermediate',
|
|
76
|
+
ide: 'cursor',
|
|
77
|
+
model: 'claude',
|
|
78
|
+
dataClass: 'internal',
|
|
79
|
+
stack: 'nextjs',
|
|
80
|
+
...Object.fromEntries(Object.entries(profile ?? {}).filter(([, v]) => v)),
|
|
81
|
+
};
|
|
82
|
+
effectiveProfile.ide = normalizeIde(effectiveProfile.ide);
|
|
83
|
+
const L = layoutFor(effectiveProfile.ide);
|
|
84
|
+
|
|
85
|
+
const checks = [
|
|
86
|
+
{
|
|
87
|
+
id: 'aies-manifest',
|
|
88
|
+
name: 'AIES manifest (AIES.md)',
|
|
89
|
+
weight: 10,
|
|
90
|
+
check: async () => {
|
|
91
|
+
const content = await read(join(cwd, 'AIES.md'));
|
|
92
|
+
if (!content) return { ok: false, msg: 'Missing AIES.md at repository root' };
|
|
93
|
+
const ok = content.includes('Profile:');
|
|
94
|
+
return { ok, msg: ok ? 'AIES.md declares a profile' : 'AIES.md is missing the profile declaration' };
|
|
95
|
+
},
|
|
96
|
+
fixAction: p => writeMissing(cwd, p, f => f === 'AIES.md'),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
id: 'agents-md',
|
|
100
|
+
name: 'Canonical instructions (AGENTS.md)',
|
|
101
|
+
weight: 15,
|
|
102
|
+
check: async () => {
|
|
103
|
+
const content = await read(join(cwd, 'AGENTS.md'));
|
|
104
|
+
if (!content) return { ok: false, msg: 'Missing AGENTS.md — the cross-runtime instruction file' };
|
|
105
|
+
const lines = content.split('\n').length;
|
|
106
|
+
if (lines > 300) return { ok: false, msg: `AGENTS.md is ${lines} lines; keep it under 300 and move procedures into skills` };
|
|
107
|
+
return { ok: true, msg: `AGENTS.md present (${lines} lines)` };
|
|
108
|
+
},
|
|
109
|
+
fixAction: p => writeMissing(cwd, p, f => f === 'AGENTS.md'),
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
id: 'verify-commands',
|
|
113
|
+
name: 'Verify commands declared (AGENTS.md)',
|
|
114
|
+
weight: 10,
|
|
115
|
+
check: async () => {
|
|
116
|
+
const section = verifySection(await read(join(cwd, 'AGENTS.md')));
|
|
117
|
+
if (!section) return { ok: false, msg: 'AGENTS.md has no "Verify before claiming done" section — agents cannot prove completion' };
|
|
118
|
+
const open = [...section.matchAll(/^#\s*(\w+)\s*→\s*\(fill in\)/gm)].map(m => m[1]);
|
|
119
|
+
if (open.length) return { ok: false, msg: `Placeholder verify command(s): ${open.join(', ')} — replace with the exact command or "none"` };
|
|
120
|
+
return { ok: true, msg: 'Verify commands declared' };
|
|
121
|
+
},
|
|
122
|
+
fixAction: async () => {
|
|
123
|
+
const path = join(cwd, 'AGENTS.md');
|
|
124
|
+
const md = await read(path);
|
|
125
|
+
const { commands } = detectVerifyCommands(cwd, fs);
|
|
126
|
+
let filled = 0;
|
|
127
|
+
const patched = md.replace(/^#\s*(\w+)\s*→\s*\(fill in\)\s*$/gm, (line, key) => {
|
|
128
|
+
if (!commands[key]) return line;
|
|
129
|
+
filled += 1;
|
|
130
|
+
return `${commands[key]} # ${key}`;
|
|
131
|
+
});
|
|
132
|
+
if (filled) await writeFile(path, patched, 'utf8');
|
|
133
|
+
return filled ? `Filled ${filled} detected command(s)` : `Nothing detected — edit the ${VERIFY_PLACEHOLDER} lines by hand`;
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'rules-file',
|
|
138
|
+
name: `Runtime rules file (${L.rulesFile ?? 'AGENTS.md'})`,
|
|
139
|
+
weight: 10,
|
|
140
|
+
check: async () => {
|
|
141
|
+
if (!L.rulesFile || L.rulesFile === 'AGENTS.md') return { ok: true, msg: `${effectiveProfile.ide} reads AGENTS.md directly` };
|
|
142
|
+
const content = await read(join(cwd, L.rulesFile));
|
|
143
|
+
if (!content) return { ok: false, msg: `Missing ${L.rulesFile}` };
|
|
144
|
+
if (L.rulesFile === 'CLAUDE.md' && !content.includes('@AGENTS.md')) {
|
|
145
|
+
return { ok: false, msg: 'CLAUDE.md does not import @AGENTS.md — instructions will drift' };
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, msg: `${L.rulesFile} present` };
|
|
148
|
+
},
|
|
149
|
+
fixAction: p => writeMissing(cwd, p, f => f === L.rulesFile),
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'skills',
|
|
153
|
+
name: 'Agent Skills (.agents/skills/*/SKILL.md)',
|
|
154
|
+
weight: 15,
|
|
155
|
+
check: async () => {
|
|
156
|
+
const dir = join(cwd, '.agents/skills');
|
|
157
|
+
if (!(await exists(dir))) return { ok: false, msg: 'Missing .agents/skills — skills are not discoverable by any runtime' };
|
|
158
|
+
const names = (await readdir(dir, { withFileTypes: true })).filter(d => d.isDirectory()).map(d => d.name);
|
|
159
|
+
const bad = [];
|
|
160
|
+
for (const name of names) {
|
|
161
|
+
const md = await read(join(dir, name, 'SKILL.md'));
|
|
162
|
+
const fm = md.match(/^---\r?\n([\s\S]*?)\r?\n---/)?.[1] ?? '';
|
|
163
|
+
if (!new RegExp(`^name:\\s*["']?${name}["']?\\s*$`, 'm').test(fm) || !/^description:/m.test(fm)) bad.push(name);
|
|
164
|
+
}
|
|
165
|
+
if (!names.length) return { ok: false, msg: '.agents/skills is empty' };
|
|
166
|
+
return bad.length
|
|
167
|
+
? { ok: false, msg: `Skills with missing/mismatched name or description: ${bad.join(', ')}` }
|
|
168
|
+
: { ok: true, msg: `${names.length} valid skill(s)` };
|
|
169
|
+
},
|
|
170
|
+
fixAction: p => writeMissing(cwd, p, f => f.startsWith('.agents/skills/') || (L.skillMirrorDir && f.startsWith(`${L.skillMirrorDir}/`))),
|
|
171
|
+
},
|
|
172
|
+
{
|
|
173
|
+
id: 'native-agents',
|
|
174
|
+
name: 'Native subagents and commands',
|
|
175
|
+
weight: 10,
|
|
176
|
+
check: async () => {
|
|
177
|
+
const hasCommands = await exists(join(cwd, L.commandsDir));
|
|
178
|
+
const hasAgents = !L.agentsDir || (await exists(join(cwd, L.agentsDir)));
|
|
179
|
+
const ok = hasCommands && hasAgents;
|
|
180
|
+
return {
|
|
181
|
+
ok,
|
|
182
|
+
msg: ok ? `${L.commandsDir}${L.agentsDir ? ` and ${L.agentsDir}` : ''} present` : `Missing ${!hasCommands ? L.commandsDir : L.agentsDir}`,
|
|
183
|
+
};
|
|
184
|
+
},
|
|
185
|
+
fixAction: p => writeMissing(cwd, p, f => f.startsWith(`${L.commandsDir}/`) || (L.agentsDir && f.startsWith(`${L.agentsDir}/`))),
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
id: 'native-hooks',
|
|
189
|
+
name: 'Enforcing hooks (guard wired into the runtime)',
|
|
190
|
+
weight: 15,
|
|
191
|
+
check: async () => {
|
|
192
|
+
const guard = join(cwd, 'hooks/aies-guard.mjs');
|
|
193
|
+
if (!(await exists(guard))) return { ok: false, msg: 'Missing hooks/aies-guard.mjs' };
|
|
194
|
+
const run = spawnSync(process.execPath, [guard, '--self-test'], { cwd, input: '', encoding: 'utf8', timeout: 15000 });
|
|
195
|
+
if (!/self-test: \d+ cases passed/.test(run.stdout ?? '')) {
|
|
196
|
+
const why = /self-test:/.test(run.stdout ?? '') ? (run.stderr || run.stdout).trim().split('\n')[0] : 'guard predates --self-test — run enhance';
|
|
197
|
+
return { ok: false, msg: `Guard self-test failed: ${why}` };
|
|
198
|
+
}
|
|
199
|
+
if (!L.hooksFile) return { ok: true, msg: `Guard self-test passed; ${effectiveProfile.ide} has no native hook API — enforce via CI` };
|
|
200
|
+
const cfg = await read(join(cwd, L.hooksFile));
|
|
201
|
+
const ok = cfg.includes('aies-guard.mjs');
|
|
202
|
+
return {
|
|
203
|
+
ok,
|
|
204
|
+
msg: ok ? `Guard self-test passed; ${L.hooksFile} invokes it` : `${L.hooksFile} does not invoke hooks/aies-guard.mjs — guard is advisory only`,
|
|
205
|
+
};
|
|
206
|
+
},
|
|
207
|
+
fixAction: p => writeMissing(cwd, p, f => f.startsWith('hooks/') || f === L.hooksFile),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
id: 'done-gate',
|
|
211
|
+
name: 'Definition-of-done gate (agent cannot finish on failing checks)',
|
|
212
|
+
weight: 10,
|
|
213
|
+
check: async () => {
|
|
214
|
+
if (!(await exists(join(cwd, 'hooks/aies-done.mjs')))) return { ok: false, msg: 'Missing hooks/aies-done.mjs — run enhance' };
|
|
215
|
+
let commands = [];
|
|
216
|
+
try {
|
|
217
|
+
commands = JSON.parse(await read(join(cwd, 'hooks/aies-done.json'))).commands ?? [];
|
|
218
|
+
} catch {
|
|
219
|
+
return { ok: false, msg: 'hooks/aies-done.json missing or not valid JSON' };
|
|
220
|
+
}
|
|
221
|
+
if (!commands.length) return { ok: false, msg: 'hooks/aies-done.json lists no commands — the gate allows every completion' };
|
|
222
|
+
if (!DONE_HOOK_FORMATS.has(L.hookFormat)) return { ok: true, msg: `${commands.length} command(s); ${effectiveProfile.ide} has no blocking completion hook — the CI verify job is the gate` };
|
|
223
|
+
const wired = (await read(join(cwd, L.hooksFile))).includes('aies-done.mjs');
|
|
224
|
+
return { ok: wired, msg: wired ? `${commands.length} command(s) gate completion via ${L.hooksFile}` : `${L.hooksFile} does not invoke hooks/aies-done.mjs` };
|
|
225
|
+
},
|
|
226
|
+
fixAction: async p => {
|
|
227
|
+
const path = join(cwd, 'hooks/aies-done.json');
|
|
228
|
+
const raw = await read(path);
|
|
229
|
+
let cfg = null;
|
|
230
|
+
try {
|
|
231
|
+
cfg = raw ? JSON.parse(raw) : null;
|
|
232
|
+
} catch {
|
|
233
|
+
return 'hooks/aies-done.json is invalid JSON — fix it by hand';
|
|
234
|
+
}
|
|
235
|
+
if (cfg && !cfg.commands?.length) {
|
|
236
|
+
const { commands } = detectVerifyCommands(cwd, fs);
|
|
237
|
+
const detected = ['lint', 'typecheck', 'test'].map(k => commands[k]).filter(Boolean);
|
|
238
|
+
if (!detected.length) return `Nothing detected — add commands to hooks/aies-done.json by hand`;
|
|
239
|
+
await writeFile(path, `${JSON.stringify({ ...cfg, commands: detected }, null, 2)}\n`, 'utf8');
|
|
240
|
+
return `Filled ${detected.length} detected command(s)`;
|
|
241
|
+
}
|
|
242
|
+
return writeMissing(cwd, p, f => f === 'hooks/aies-done.mjs' || f === 'hooks/aies-done.json');
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
id: 'plans',
|
|
247
|
+
name: 'Ephemeral plans and task contracts',
|
|
248
|
+
weight: 10,
|
|
249
|
+
check: async () => {
|
|
250
|
+
const hasTemplate = await exists(join(cwd, 'plans/_TEMPLATE.md'));
|
|
251
|
+
const hasContract = await exists(join(cwd, 'plans/_TASK_CONTRACT.md'));
|
|
252
|
+
const index = await read(join(cwd, 'implementation_plan.md'));
|
|
253
|
+
const ok = hasTemplate && hasContract && /ephemeral/i.test(index);
|
|
254
|
+
return {
|
|
255
|
+
ok,
|
|
256
|
+
msg: ok ? 'Plan template, task contract, and ephemeral index present' : 'Missing plans/_TEMPLATE.md, plans/_TASK_CONTRACT.md, or ephemeral implementation_plan.md',
|
|
257
|
+
};
|
|
258
|
+
},
|
|
259
|
+
fixAction: p => writeMissing(cwd, p, f => f.startsWith('plans/') || f.startsWith('reviews/') || f === 'implementation_plan.md' || f === 'scripts/aies-worktree.mjs'),
|
|
260
|
+
},
|
|
261
|
+
{
|
|
262
|
+
id: 'mcp',
|
|
263
|
+
name: 'MCP hygiene (config present, nothing enabled by default)',
|
|
264
|
+
weight: 10,
|
|
265
|
+
check: async () => {
|
|
266
|
+
const recommended = await exists(join(cwd, 'mcps/recommended.json'));
|
|
267
|
+
if (!recommended) return { ok: false, msg: 'Missing mcps/recommended.json' };
|
|
268
|
+
if (!L.mcpFile || L.mcpFile.endsWith('.toml')) return { ok: true, msg: 'MCP recommendations present' };
|
|
269
|
+
const raw = await read(join(cwd, L.mcpFile));
|
|
270
|
+
if (!raw) return { ok: false, msg: `Missing ${L.mcpFile}` };
|
|
271
|
+
try {
|
|
272
|
+
const cfg = JSON.parse(raw);
|
|
273
|
+
const servers = cfg.mcpServers ?? cfg.mcp ?? cfg.servers ?? {};
|
|
274
|
+
const tokens = JSON.stringify(servers).match(/(ghp_|github_pat_|sk-[A-Za-z0-9]{20}|xox[bp]-)/);
|
|
275
|
+
if (tokens) return { ok: false, msg: `${L.mcpFile} contains a literal credential — use OAuth or env refs` };
|
|
276
|
+
return { ok: true, msg: `${L.mcpFile} valid (${Object.keys(servers).length} server(s) enabled)` };
|
|
277
|
+
} catch {
|
|
278
|
+
return { ok: false, msg: `${L.mcpFile} is not valid JSON` };
|
|
279
|
+
}
|
|
280
|
+
},
|
|
281
|
+
fixAction: p => writeMissing(cwd, p, f => f.startsWith('mcps/') || f === L.mcpFile),
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
id: 'evals',
|
|
285
|
+
name: 'Eval gate (evals/promptfooconfig.yaml)',
|
|
286
|
+
weight: 10,
|
|
287
|
+
check: async () => {
|
|
288
|
+
const cfg = await read(join(cwd, 'evals/promptfooconfig.yaml'));
|
|
289
|
+
if (!cfg) return { ok: false, msg: 'Missing evals/promptfooconfig.yaml — behavior changes have no regression gate' };
|
|
290
|
+
const tests = (cfg.match(/^\s*- description:/gm) ?? []).length + (cfg.match(/^\s*- file:\/\/cases/gm) ?? []).length;
|
|
291
|
+
if (!/^providers:/m.test(cfg) || !tests) return { ok: false, msg: 'evals/promptfooconfig.yaml has no provider or no tests' };
|
|
292
|
+
return { ok: true, msg: `${tests} eval case(s) configured` };
|
|
293
|
+
},
|
|
294
|
+
fixAction: p => writeMissing(cwd, p, f => f.startsWith('evals/')),
|
|
295
|
+
},
|
|
296
|
+
{
|
|
297
|
+
id: 'hygiene',
|
|
298
|
+
name: 'No unmerged kit updates or unwired scripts',
|
|
299
|
+
weight: 5,
|
|
300
|
+
check: async () => {
|
|
301
|
+
const pending = await findBySuffix(cwd, CONFLICT_SUFFIX);
|
|
302
|
+
if (pending.length) return { ok: false, msg: `Unmerged kit updates: ${pending.slice(0, 4).join(', ')}${pending.length > 4 ? ', …' : ''}` };
|
|
303
|
+
let legacy = [];
|
|
304
|
+
try {
|
|
305
|
+
legacy = (await readdir(join(cwd, 'hooks'))).filter(f => f.endsWith('.hook.sh'));
|
|
306
|
+
} catch {
|
|
307
|
+
legacy = [];
|
|
308
|
+
}
|
|
309
|
+
if (legacy.length) {
|
|
310
|
+
const wiringFiles = ['package.json', 'lefthook.yml', '.pre-commit-config.yaml', '.husky/pre-commit', '.husky/pre-push'];
|
|
311
|
+
let wiring = (await Promise.all(wiringFiles.map(f => read(join(cwd, f))))).join('\n');
|
|
312
|
+
try {
|
|
313
|
+
for (const wf of await readdir(join(cwd, '.github/workflows'))) wiring += await read(join(cwd, '.github/workflows', wf));
|
|
314
|
+
} catch {
|
|
315
|
+
// no workflows
|
|
316
|
+
}
|
|
317
|
+
const unwired = legacy.filter(f => !wiring.includes(f));
|
|
318
|
+
if (unwired.length) return { ok: false, msg: `hooks/${unwired[0]}${unwired.length > 1 ? ` and ${unwired.length - 1} more` : ''} not run by any git hook or CI — wire or delete (enhance removes untouched copies)` };
|
|
319
|
+
}
|
|
320
|
+
return { ok: true, msg: 'No pending *.aies-new files; every hook script is wired' };
|
|
321
|
+
},
|
|
322
|
+
fixAction: async () => 'Merge or delete *.aies-new files and unwired hooks/*.hook.sh by hand',
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
id: 'reference',
|
|
326
|
+
name: 'Reference material (cookbook/, guides/)',
|
|
327
|
+
weight: 5,
|
|
328
|
+
check: async () => {
|
|
329
|
+
const ok = (await exists(join(cwd, 'cookbook'))) || (await exists(join(cwd, 'guides')));
|
|
330
|
+
return { ok, msg: ok ? 'Cookbook/guides present' : 'Missing cookbook/ and guides/' };
|
|
331
|
+
},
|
|
332
|
+
fixAction: p => writeMissing(cwd, p, f => f.startsWith('cookbook/') || f.startsWith('guides/')),
|
|
333
|
+
},
|
|
334
|
+
];
|
|
335
|
+
|
|
336
|
+
const results = [];
|
|
337
|
+
const fixesApplied = [];
|
|
338
|
+
let earnedWeight = 0;
|
|
339
|
+
let totalWeight = 0;
|
|
340
|
+
|
|
341
|
+
for (const checkObj of checks) {
|
|
342
|
+
totalWeight += checkObj.weight;
|
|
343
|
+
let res = await checkObj.check();
|
|
344
|
+
|
|
345
|
+
if (!res.ok && fix) {
|
|
346
|
+
try {
|
|
347
|
+
const fixMsg = await checkObj.fixAction(effectiveProfile);
|
|
348
|
+
fixesApplied.push(`${checkObj.name}: ${fixMsg}`);
|
|
349
|
+
res = await checkObj.check();
|
|
350
|
+
} catch (err) {
|
|
351
|
+
fixesApplied.push(`${checkObj.name}: Fix failed — ${err.message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (res.ok) earnedWeight += checkObj.weight;
|
|
356
|
+
results.push({ id: checkObj.id, name: checkObj.name, weight: checkObj.weight, ok: res.ok, msg: res.msg });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const score = Math.round((earnedWeight / totalWeight) * 100);
|
|
360
|
+
let grade = 'F';
|
|
361
|
+
if (score >= 95) grade = 'A+';
|
|
362
|
+
else if (score >= 85) grade = 'A';
|
|
363
|
+
else if (score >= 70) grade = 'B';
|
|
364
|
+
else if (score >= 50) grade = 'C';
|
|
365
|
+
|
|
366
|
+
return { score, grade, earnedWeight, totalWeight, results, fixesApplied };
|
|
367
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write a generated bundle without ever destroying user edits.
|
|
3
|
+
*
|
|
4
|
+
* `.aies-kit.json` records the SHA-256 of every file the kit last wrote. On re-run:
|
|
5
|
+
* - missing file → created
|
|
6
|
+
* - identical to the new version → unchanged
|
|
7
|
+
* - matches the recorded hash (untouched) → updated in place
|
|
8
|
+
* - edited, and the kit version is unchanged → left alone (customized)
|
|
9
|
+
* - edited, and the kit version changed → left alone; new version written to `<file>.aies-new`
|
|
10
|
+
* Files the kit no longer generates are deleted only if untouched; edited ones are left in place.
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
export const MANIFEST_FILE = '.aies-kit.json';
|
|
17
|
+
export const CONFLICT_SUFFIX = '.aies-new';
|
|
18
|
+
|
|
19
|
+
export function hashContent(text) {
|
|
20
|
+
return createHash('sha256').update(String(text).replace(/\r\n/g, '\n')).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function readManifest(cwd) {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(fs.readFileSync(path.join(cwd, MANIFEST_FILE), 'utf8'));
|
|
26
|
+
return { kitVersion: raw.kitVersion ?? null, profile: raw.profile ?? null, files: raw.files ?? {} };
|
|
27
|
+
} catch {
|
|
28
|
+
return { kitVersion: null, profile: null, files: {} };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function readText(abs) {
|
|
33
|
+
try {
|
|
34
|
+
return fs.readFileSync(abs, 'utf8');
|
|
35
|
+
} catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function removeEmptyParents(cwd, abs) {
|
|
41
|
+
let dir = path.dirname(abs);
|
|
42
|
+
const root = path.resolve(cwd);
|
|
43
|
+
while (dir.startsWith(root) && dir !== root) {
|
|
44
|
+
try {
|
|
45
|
+
if (fs.readdirSync(dir).length) return;
|
|
46
|
+
fs.rmdirSync(dir);
|
|
47
|
+
} catch {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
dir = path.dirname(dir);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {string} cwd
|
|
56
|
+
* @param {Record<string, string>} files
|
|
57
|
+
* @param {{ force?: boolean, dryRun?: boolean, retired?: Record<string, string>, kitVersion?: string, profile?: object }} [opts]
|
|
58
|
+
*/
|
|
59
|
+
export function applyBundle(cwd, files, opts = {}) {
|
|
60
|
+
const { force = false, dryRun = false, retired = {}, kitVersion = null, profile = null } = opts;
|
|
61
|
+
const previous = readManifest(cwd);
|
|
62
|
+
const next = {};
|
|
63
|
+
const result = { created: [], updated: [], unchanged: [], customized: [], conflicts: [], removed: [], kept: [] };
|
|
64
|
+
|
|
65
|
+
const write = (rel, content) => {
|
|
66
|
+
if (dryRun) return;
|
|
67
|
+
const abs = path.join(cwd, rel);
|
|
68
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
69
|
+
fs.writeFileSync(abs, content, 'utf8');
|
|
70
|
+
};
|
|
71
|
+
const clearConflict = rel => {
|
|
72
|
+
if (dryRun) return;
|
|
73
|
+
fs.rmSync(path.join(cwd, `${rel}${CONFLICT_SUFFIX}`), { force: true });
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
77
|
+
const newHash = hashContent(content);
|
|
78
|
+
const disk = readText(path.join(cwd, rel));
|
|
79
|
+
if (disk === null) {
|
|
80
|
+
write(rel, content);
|
|
81
|
+
next[rel] = newHash;
|
|
82
|
+
result.created.push(rel);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const diskHash = hashContent(disk);
|
|
86
|
+
if (diskHash === newHash) {
|
|
87
|
+
next[rel] = newHash;
|
|
88
|
+
clearConflict(rel);
|
|
89
|
+
result.unchanged.push(rel);
|
|
90
|
+
} else if (force || previous.files[rel] === diskHash) {
|
|
91
|
+
write(rel, content);
|
|
92
|
+
next[rel] = newHash;
|
|
93
|
+
clearConflict(rel);
|
|
94
|
+
result.updated.push(rel);
|
|
95
|
+
} else if (previous.files[rel] === newHash) {
|
|
96
|
+
next[rel] = newHash;
|
|
97
|
+
clearConflict(rel);
|
|
98
|
+
result.customized.push(rel);
|
|
99
|
+
} else {
|
|
100
|
+
write(`${rel}${CONFLICT_SUFFIX}`, content);
|
|
101
|
+
if (previous.files[rel]) next[rel] = previous.files[rel];
|
|
102
|
+
result.conflicts.push(rel);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const candidates = new Set([...Object.keys(previous.files), ...Object.keys(retired)]);
|
|
107
|
+
for (const rel of candidates) {
|
|
108
|
+
if (rel in files) continue;
|
|
109
|
+
const abs = path.join(cwd, rel);
|
|
110
|
+
const disk = readText(abs);
|
|
111
|
+
if (disk === null) continue;
|
|
112
|
+
const diskHash = hashContent(disk);
|
|
113
|
+
const untouched = previous.files[rel] === diskHash || (rel in retired && hashContent(retired[rel]) === diskHash);
|
|
114
|
+
if (untouched) {
|
|
115
|
+
if (!dryRun) {
|
|
116
|
+
fs.rmSync(abs, { force: true });
|
|
117
|
+
removeEmptyParents(cwd, abs);
|
|
118
|
+
}
|
|
119
|
+
result.removed.push(rel);
|
|
120
|
+
} else {
|
|
121
|
+
result.kept.push(rel);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!dryRun) {
|
|
126
|
+
const sorted = Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b)));
|
|
127
|
+
fs.writeFileSync(
|
|
128
|
+
path.join(cwd, MANIFEST_FILE),
|
|
129
|
+
`${JSON.stringify(
|
|
130
|
+
{
|
|
131
|
+
$comment: 'Written by create-aies-kit. Hashes of kit-generated files so upgrades never overwrite your edits. Commit this file.',
|
|
132
|
+
kitVersion,
|
|
133
|
+
profile,
|
|
134
|
+
files: sorted,
|
|
135
|
+
},
|
|
136
|
+
null,
|
|
137
|
+
2,
|
|
138
|
+
)}\n`,
|
|
139
|
+
'utf8',
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
return result;
|
|
143
|
+
}
|