@warren-dean/code-kit 0.3.4
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-plugin/marketplace.json +18 -0
- package/.claude-plugin/plugin.json +23 -0
- package/LICENSE +21 -0
- package/README.md +369 -0
- package/SECURITY.md +7 -0
- package/bin/code-kit.mjs +398 -0
- package/hooks/guard-bash.mjs +223 -0
- package/hooks/guard-paths.mjs +70 -0
- package/hooks/hooks.json +71 -0
- package/hooks/lane-audit.mjs +58 -0
- package/hooks/lib/adapters/context-graph.mjs +34 -0
- package/hooks/lib/adapters/index.mjs +34 -0
- package/hooks/lib/baseline.mjs +23 -0
- package/hooks/lib/checks.mjs +67 -0
- package/hooks/lib/config.mjs +211 -0
- package/hooks/lib/dependencies.mjs +129 -0
- package/hooks/lib/diff.mjs +122 -0
- package/hooks/lib/glob.mjs +42 -0
- package/hooks/lib/hook.mjs +19 -0
- package/hooks/lib/layers.mjs +57 -0
- package/hooks/lib/next.mjs +197 -0
- package/hooks/lib/plan.mjs +206 -0
- package/hooks/lib/rules.mjs +313 -0
- package/hooks/lib/trace.mjs +69 -0
- package/hooks/lib/verify.mjs +146 -0
- package/hooks/post-edit-check.mjs +40 -0
- package/hooks/stop-check.mjs +84 -0
- package/package.json +41 -0
- package/skills/check/SKILL.md +19 -0
- package/skills/dispatch/SKILL.md +83 -0
- package/skills/init/SKILL.md +151 -0
- package/skills/next/SKILL.md +52 -0
- package/skills/review/SKILL.md +57 -0
- package/skills/spec-check/SKILL.md +27 -0
- package/skills/spec-design/SKILL.md +137 -0
- package/skills/status/SKILL.md +42 -0
- package/templates/agent.md +29 -0
- package/templates/brief.md +45 -0
- package/templates/claude-md.md +23 -0
- package/templates/github-workflow.yml +23 -0
- package/templates/plan.md +30 -0
- package/templates/spec.md +52 -0
package/hooks/hooks.json
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
{
|
|
2
|
+
"description": "code-kit: lanes, protected paths with an approval log, spec-check and design gates, layer rules and the project's checks, from .claude/code-kit.json",
|
|
3
|
+
"hooks": {
|
|
4
|
+
"PreToolUse": [
|
|
5
|
+
{
|
|
6
|
+
"matcher": "Edit|MultiEdit|Write|NotebookEdit",
|
|
7
|
+
"hooks": [
|
|
8
|
+
{
|
|
9
|
+
"type": "command",
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-paths.mjs\"",
|
|
11
|
+
"timeout": 10
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"matcher": "Bash",
|
|
17
|
+
"hooks": [
|
|
18
|
+
{
|
|
19
|
+
"type": "command",
|
|
20
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/guard-bash.mjs\"",
|
|
21
|
+
"timeout": 30
|
|
22
|
+
}
|
|
23
|
+
]
|
|
24
|
+
}
|
|
25
|
+
],
|
|
26
|
+
"PostToolUse": [
|
|
27
|
+
{
|
|
28
|
+
"matcher": "Edit|MultiEdit|Write",
|
|
29
|
+
"hooks": [
|
|
30
|
+
{
|
|
31
|
+
"type": "command",
|
|
32
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/post-edit-check.mjs\"",
|
|
33
|
+
"timeout": 90
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
"matcher": "Bash",
|
|
39
|
+
"hooks": [
|
|
40
|
+
{
|
|
41
|
+
"type": "command",
|
|
42
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/lane-audit.mjs\"",
|
|
43
|
+
"timeout": 30
|
|
44
|
+
}
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"Stop": [
|
|
49
|
+
{
|
|
50
|
+
"hooks": [
|
|
51
|
+
{
|
|
52
|
+
"type": "command",
|
|
53
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/stop-check.mjs\"",
|
|
54
|
+
"timeout": 600
|
|
55
|
+
}
|
|
56
|
+
]
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"SubagentStop": [
|
|
60
|
+
{
|
|
61
|
+
"hooks": [
|
|
62
|
+
{
|
|
63
|
+
"type": "command",
|
|
64
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/stop-check.mjs\"",
|
|
65
|
+
"timeout": 600
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// PostToolUse (Bash) and part of Stop: shell commands can write files without going through the
|
|
3
|
+
// path guard, so after each one every uncommitted change in the worktree is checked against the
|
|
4
|
+
// same rules (lib/rules.mjs). Anything the actor may not write is reported for revert.
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { APPROVAL_LOG, LANE_FILE, actorFor, actorRoot, writeProblem } from './lib/rules.mjs';
|
|
9
|
+
|
|
10
|
+
// The commit hook appends to the approval log and stages it; if that commit then fails, the
|
|
11
|
+
// appended lines are left uncommitted. That is the only change to the log that is not a problem.
|
|
12
|
+
function appendedOnly(root, rel) {
|
|
13
|
+
const committed = spawnSync('git', ['-C', root, 'show', `HEAD:${rel}`], { encoding: 'utf8' });
|
|
14
|
+
const before = committed.status === 0 ? committed.stdout : '';
|
|
15
|
+
try {
|
|
16
|
+
return readFileSync(join(root, rel), 'utf8').startsWith(before);
|
|
17
|
+
} catch {
|
|
18
|
+
return false; // deleted
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function changedFiles(root) {
|
|
23
|
+
const out =
|
|
24
|
+
spawnSync('git', ['-C', root, 'status', '--porcelain', '-uall', '--no-renames'], {
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
}).stdout ?? '';
|
|
27
|
+
return out
|
|
28
|
+
.split('\n')
|
|
29
|
+
.filter(Boolean)
|
|
30
|
+
.map((l) => l.slice(3).replace(/^"|"$/g, ''))
|
|
31
|
+
.filter((f) => !f.endsWith('/')) // a nested repository or worktree: its changes are its own
|
|
32
|
+
.filter((f) => f !== LANE_FILE);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function auditProblems(input, config) {
|
|
36
|
+
const root = actorRoot(input);
|
|
37
|
+
if (!root) return [];
|
|
38
|
+
const actor = actorFor(input, root, config);
|
|
39
|
+
return changedFiles(root)
|
|
40
|
+
.filter((rel) => !(rel === APPROVAL_LOG && appendedOnly(root, rel)))
|
|
41
|
+
.map((rel) => [rel, writeProblem(actor, rel, root, config)])
|
|
42
|
+
.filter(([, problem]) => problem)
|
|
43
|
+
.map(([rel, problem]) => `${rel}: ${problem.split('\n')[0]}`);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
47
|
+
const { block, start } = await import('./lib/hook.mjs');
|
|
48
|
+
const { input, config, error } = start();
|
|
49
|
+
if (error) process.exit(0); // guard-paths and stop-check report an invalid config
|
|
50
|
+
const problems = auditProblems(input, config);
|
|
51
|
+
if (problems.length) {
|
|
52
|
+
block(
|
|
53
|
+
`These uncommitted changes break the ownership rules (.claude/code-kit.json). Revert them ` +
|
|
54
|
+
`(git checkout -- <file>, or delete new files) and do the work the permitted way:\n ${problems.join('\n ')}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
process.exit(0);
|
|
58
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Context Graph (ctx) keeps a project's engineering context in `.ctx/`: ratified rules and concepts in
|
|
2
|
+
// graph.ctx, settings in config.toml, the decisions agents record as they work in decisions.ctx, and
|
|
3
|
+
// each file's card (its why) in cards.ctx. Every agent records decisions and cards for the files it
|
|
4
|
+
// edits, so those two files are writable by any actor; the rules and settings
|
|
5
|
+
// are the lead's, and changes to them go through the approval log. Ratifying a rule is a person's act,
|
|
6
|
+
// signed with a commit trailer, so no Claude actor may write that trailer.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
|
|
10
|
+
export default {
|
|
11
|
+
name: 'context-graph',
|
|
12
|
+
detect: (root) => existsSync(join(root, '.ctx')),
|
|
13
|
+
config: {
|
|
14
|
+
lead: ['.ctx/**'],
|
|
15
|
+
anyActor: ['.ctx/decisions.ctx', '.ctx/cards.ctx'],
|
|
16
|
+
protected: [
|
|
17
|
+
{ glob: '.ctx/graph.ctx', approval: 'ctx', why: "Context Graph's rules and concepts" },
|
|
18
|
+
{ glob: '.ctx/config.toml', approval: 'ctx', why: "Context Graph's settings" },
|
|
19
|
+
],
|
|
20
|
+
},
|
|
21
|
+
shell: {
|
|
22
|
+
block: [
|
|
23
|
+
{
|
|
24
|
+
pattern: 'Ctx-Ratified-By\\s*[:=]',
|
|
25
|
+
why: "Ratifying a Context Graph rule is a person's act: they add the Ctx-Ratified-By trailer themselves, with a `!` git command.",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
},
|
|
29
|
+
setup: [
|
|
30
|
+
'.gitignore: .ctx/archive/, .ctx/bench/, .ctx/models/ and .ctx/serve.json (ctx working files, never committed)',
|
|
31
|
+
'.gitattributes: .ctx/decisions.ctx merge=union and .ctx/cards.ctx merge=union (lanes record decisions and cards on parallel branches; merging keeps them all)',
|
|
32
|
+
"CLAUDE.md: keep the Context Graph block and code-kit's section side by side, each pointing to the other",
|
|
33
|
+
],
|
|
34
|
+
};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Adapters: what code-kit needs to know about another tool that shares the project, kept out of the core.
|
|
2
|
+
// Each adapter says how to detect its tool and what that tool contributes to the rules:
|
|
3
|
+
//
|
|
4
|
+
// name the tool, and the key that switches it off in the config: "adapters": { "<name>": false }
|
|
5
|
+
// detect (root) => true when the tool is in use in this project
|
|
6
|
+
// config { lead?, anyActor?, protected? } merged into the effective config, like the kit's defaults
|
|
7
|
+
// shell { block? } commands refused for every Claude actor, merged into shell.block
|
|
8
|
+
// setup lines for init to apply or tell the person: ignore files, merge drivers, instructions
|
|
9
|
+
//
|
|
10
|
+
// The core only ever calls applyAdapters() and activeAdapters(); it never names a tool.
|
|
11
|
+
import contextGraph from './context-graph.mjs';
|
|
12
|
+
|
|
13
|
+
export const ADAPTERS = [contextGraph];
|
|
14
|
+
|
|
15
|
+
/** Adapters detected in `root` and not switched off by the config's `adapters` map. */
|
|
16
|
+
export function activeAdapters(raw, root) {
|
|
17
|
+
return ADAPTERS.filter((a) => raw.adapters?.[a.name] !== false && a.detect(root));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The effective config with every active adapter's contributions merged in. */
|
|
21
|
+
export function applyAdapters(config, raw, root) {
|
|
22
|
+
const active = activeAdapters(raw, root);
|
|
23
|
+
if (!active.length) return { ...config, adapters: [] };
|
|
24
|
+
const merged = structuredClone(config);
|
|
25
|
+
for (const a of active) {
|
|
26
|
+
const c = a.config ?? {};
|
|
27
|
+
merged.lead.paths.push(...(c.lead ?? []));
|
|
28
|
+
merged.anyActor.push(...(c.anyActor ?? []));
|
|
29
|
+
merged.protected.push(...(c.protected ?? []));
|
|
30
|
+
merged.shell.block.push(...(a.shell?.block ?? []));
|
|
31
|
+
}
|
|
32
|
+
merged.adapters = active.map((a) => a.name);
|
|
33
|
+
return merged;
|
|
34
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Brownfield code already breaks some layer rules. The baseline records those violations when the kit
|
|
2
|
+
// is adopted, so only new ones block; it only ever shrinks (`code-kit baseline` rewrites it from the
|
|
3
|
+
// code as it is, and the lead commits it like any other kit change).
|
|
4
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
export const BASELINE_FILE = '.claude/code-kit.baseline.json';
|
|
8
|
+
|
|
9
|
+
/** { layers: { file: [problem, ...] } }; empty when there is no baseline. */
|
|
10
|
+
export function loadBaseline(projectDir) {
|
|
11
|
+
const file = join(projectDir, BASELINE_FILE);
|
|
12
|
+
if (!existsSync(file)) return { layers: {} };
|
|
13
|
+
try {
|
|
14
|
+
const raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
15
|
+
return { layers: raw.layers ?? {} };
|
|
16
|
+
} catch {
|
|
17
|
+
return { layers: {} }; // an unreadable baseline excuses nothing
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The problems in `rel` that the baseline doesn't already record. */
|
|
22
|
+
export const newProblems = (rel, problems, baseline) =>
|
|
23
|
+
problems.filter((p) => !(baseline.layers[rel] ?? []).includes(p));
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// The project's checks (config.checks): each runs when files matching it changed. Shared by the stop
|
|
2
|
+
// hook (before an agent finishes) and `code-kit verify` (in CI, for a branch's changes).
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { existsSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { globToRegExp, matchesAny } from './glob.mjs';
|
|
7
|
+
|
|
8
|
+
/** The directories a check runs in: each changed one matching `each` that still exists, or the worktree. */
|
|
9
|
+
function directories(check, files, root) {
|
|
10
|
+
if (!check.each) return [root];
|
|
11
|
+
const depth = check.each.split('/').length;
|
|
12
|
+
const dirs = files
|
|
13
|
+
.map((f) => f.split('/').slice(0, depth).join('/'))
|
|
14
|
+
.filter((d) => globToRegExp(check.each).test(d));
|
|
15
|
+
return [...new Set(dirs)].map((d) => join(root, d)).filter((d) => existsSync(d));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The first failure as { title, output }, or null. `actor` decides `owners`; the lead always may. */
|
|
19
|
+
function runCheck(check, files, root, actor) {
|
|
20
|
+
if (check.owners && !(actor.kind === 'lead' || check.owners.includes(actor.lane))) {
|
|
21
|
+
return {
|
|
22
|
+
title: `The ${actor.label} changed ${check.name} files it does not own:`,
|
|
23
|
+
output: files.join('\n'),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
for (const cwd of directories(check, files, root)) {
|
|
27
|
+
for (const command of check.run) {
|
|
28
|
+
const res = spawnSync(command, {
|
|
29
|
+
cwd,
|
|
30
|
+
shell: true,
|
|
31
|
+
encoding: 'utf8',
|
|
32
|
+
timeout: (check.timeoutSeconds ?? 240) * 1000,
|
|
33
|
+
});
|
|
34
|
+
const where = cwd === root ? '' : ` in ${cwd.slice(root.length + 1)}`;
|
|
35
|
+
if (res.status === 127) {
|
|
36
|
+
if (check.ifMissing === 'fail')
|
|
37
|
+
return {
|
|
38
|
+
title: `${check.name}: \`${command}\` is not installed${where}. Install it so the check can run.`,
|
|
39
|
+
};
|
|
40
|
+
return null; // not installed yet (fresh clone): the check can't run
|
|
41
|
+
}
|
|
42
|
+
if (res.error || res.status === null)
|
|
43
|
+
return {
|
|
44
|
+
title: `${check.name}: \`${command}\` did not finish${where} (${res.error?.code ?? res.signal}).`,
|
|
45
|
+
};
|
|
46
|
+
if (res.status !== 0)
|
|
47
|
+
return {
|
|
48
|
+
title: `${check.name}: \`${command}\` fails${where}. Fix it:`,
|
|
49
|
+
output: res.stdout + res.stderr,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Runs every check whose files changed, in order; the first failure, or null when all pass. */
|
|
57
|
+
export function runChecks(config, changed, root, actor) {
|
|
58
|
+
for (const check of config.checks) {
|
|
59
|
+
const files = changed.filter(
|
|
60
|
+
(f) => matchesAny(f, check.files) && !matchesAny(f, check.exclude),
|
|
61
|
+
);
|
|
62
|
+
if (!files.length) continue;
|
|
63
|
+
const failure = runCheck(check, files, root, actor);
|
|
64
|
+
if (failure) return failure;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
// The project's rules live in .claude/code-kit.json. The hooks read it from the project the session
|
|
2
|
+
// was opened in; a project without one is not governed by the kit, and an invalid one fails closed.
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { ADAPTERS, applyAdapters } from './adapters/index.mjs';
|
|
6
|
+
|
|
7
|
+
export const CONFIG_FILE = '.claude/code-kit.json';
|
|
8
|
+
// Claude's own memory and session scratchpad, which the lead always writes.
|
|
9
|
+
export const CLAUDE_OWN = [
|
|
10
|
+
'~/.claude/projects/*/memory/**',
|
|
11
|
+
'/private/tmp/claude-*/**',
|
|
12
|
+
'/tmp/claude-*/**',
|
|
13
|
+
];
|
|
14
|
+
const KIT = { glob: '.claude/**', approval: 'kit', why: 'the rules that constrain agents' };
|
|
15
|
+
|
|
16
|
+
/** `{ config }` when the project has a valid config, `{ error }` when it is invalid, `{}` when it has none. */
|
|
17
|
+
export function loadConfig(projectDir) {
|
|
18
|
+
const file = join(projectDir, CONFIG_FILE);
|
|
19
|
+
if (!existsSync(file)) return {};
|
|
20
|
+
let raw;
|
|
21
|
+
try {
|
|
22
|
+
raw = JSON.parse(readFileSync(file, 'utf8'));
|
|
23
|
+
} catch (e) {
|
|
24
|
+
return { error: `${CONFIG_FILE} is not valid JSON: ${e.message}` };
|
|
25
|
+
}
|
|
26
|
+
const problems = validate(raw);
|
|
27
|
+
if (problems.length) return { error: `${CONFIG_FILE} is invalid:\n ${problems.join('\n ')}` };
|
|
28
|
+
return { config: effectiveConfig(raw, projectDir) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The rules the hooks enforce: the project's config, the kit's defaults, and any active adapters. */
|
|
32
|
+
export const effectiveConfig = (raw, root) => applyAdapters(withDefaults(raw), raw, root);
|
|
33
|
+
|
|
34
|
+
/** The engine's fixed rules, merged into the project's: the kit and Claude's own files belong to the lead. */
|
|
35
|
+
export function withDefaults(c) {
|
|
36
|
+
return {
|
|
37
|
+
...c,
|
|
38
|
+
lead: {
|
|
39
|
+
paths: [...c.lead.paths, '.claude/**'],
|
|
40
|
+
outside: [...(c.lead.outside ?? []), ...CLAUDE_OWN],
|
|
41
|
+
},
|
|
42
|
+
anyActor: [...(c.anyActor ?? []), '.claude/state/**'],
|
|
43
|
+
protected: [KIT, ...(c.protected ?? []).filter((p) => p.glob !== KIT.glob)],
|
|
44
|
+
layers: c.layers ?? [],
|
|
45
|
+
importAliases: c.importAliases ?? {},
|
|
46
|
+
shell: { block: c.shell?.block ?? [], restricted: c.shell?.restricted ?? [] },
|
|
47
|
+
postEdit: c.postEdit ?? [],
|
|
48
|
+
checks: c.checks ?? [],
|
|
49
|
+
branches: { protected: c.branches?.protected ?? ['main', 'master'] },
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
54
|
+
const isStr = (v) => typeof v === 'string' && v.length > 0;
|
|
55
|
+
const isList = (v) => Array.isArray(v) && v.every(isStr);
|
|
56
|
+
const isRegExp = (v) => {
|
|
57
|
+
try {
|
|
58
|
+
return isStr(v) && Boolean(new RegExp(v));
|
|
59
|
+
} catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Every problem with a parsed config, as readable sentences; empty when it is valid. */
|
|
65
|
+
export function validate(c) {
|
|
66
|
+
if (!isObj(c)) return ['the config must be a JSON object'];
|
|
67
|
+
const p = [];
|
|
68
|
+
const need = (ok, msg) => {
|
|
69
|
+
if (!ok) p.push(msg);
|
|
70
|
+
return ok;
|
|
71
|
+
};
|
|
72
|
+
const each = (key, fn) => {
|
|
73
|
+
if (c[key] === undefined) return;
|
|
74
|
+
if (!need(Array.isArray(c[key]), `"${key}" must be a list`)) return;
|
|
75
|
+
c[key].forEach((item, i) =>
|
|
76
|
+
isObj(item) ? fn(item, `${key}[${i}]`) : p.push(`${key}[${i}] must be an object`),
|
|
77
|
+
);
|
|
78
|
+
};
|
|
79
|
+
need(c.version === 1, '"version" must be 1');
|
|
80
|
+
// A brownfield project may have no specs yet: then there is no spec-check gate.
|
|
81
|
+
need(c.docs === undefined || isObj(c.docs), '"docs" must be an object');
|
|
82
|
+
for (const k of ['specs', 'principles', 'plan'])
|
|
83
|
+
need(c.docs?.[k] === undefined || isStr(c.docs[k]), `"docs.${k}" must be a path`);
|
|
84
|
+
need(isObj(c.lead) && isList(c.lead?.paths), '"lead.paths" must be a list of globs');
|
|
85
|
+
need(
|
|
86
|
+
c.lead?.outside === undefined || isList(c.lead.outside),
|
|
87
|
+
'"lead.outside" must be a list of globs',
|
|
88
|
+
);
|
|
89
|
+
need(c.anyActor === undefined || isList(c.anyActor), '"anyActor" must be a list of globs');
|
|
90
|
+
validateLanes(c, need);
|
|
91
|
+
const lanes = Object.keys(isObj(c.lanes) ? c.lanes : {});
|
|
92
|
+
each('protected', (e, at) => {
|
|
93
|
+
need(isStr(e.glob) && isStr(e.why), `${at} needs "glob" and "why"`);
|
|
94
|
+
need(
|
|
95
|
+
/^[a-z][a-z0-9-]*$/.test(e.approval ?? ''),
|
|
96
|
+
`${at}.approval must be a short lower-case name`,
|
|
97
|
+
);
|
|
98
|
+
});
|
|
99
|
+
if (c.designGate !== undefined) validateDesignGate(c.designGate, need);
|
|
100
|
+
validateLayers(c, need, each);
|
|
101
|
+
if (c.shell !== undefined) validateShell(c.shell, lanes, need);
|
|
102
|
+
each('postEdit', (e, at) => {
|
|
103
|
+
need(
|
|
104
|
+
isList(e.files) && isStr(e.run),
|
|
105
|
+
`${at} needs "files" (globs) and "run" (a command; {file} is the edited file)`,
|
|
106
|
+
);
|
|
107
|
+
need(e.exclude === undefined || isList(e.exclude), `${at}.exclude must be a list of globs`);
|
|
108
|
+
});
|
|
109
|
+
each('checks', (e, at) => validateCheck(e, at, lanes, need));
|
|
110
|
+
const known = ADAPTERS.map((a) => a.name);
|
|
111
|
+
need(
|
|
112
|
+
c.adapters === undefined ||
|
|
113
|
+
(isObj(c.adapters) &&
|
|
114
|
+
Object.entries(c.adapters).every(([k, v]) => known.includes(k) && typeof v === 'boolean')),
|
|
115
|
+
`"adapters" must map adapter names (${known.join(', ')}) to true or false`,
|
|
116
|
+
);
|
|
117
|
+
need(
|
|
118
|
+
c.branches === undefined || isList(c.branches?.protected),
|
|
119
|
+
'"branches.protected" must be a list of branch names',
|
|
120
|
+
);
|
|
121
|
+
return p;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function validateLanes(c, need) {
|
|
125
|
+
if (!need(isObj(c.lanes), '"lanes" must be an object (it may be empty)')) return;
|
|
126
|
+
const agents = new Set();
|
|
127
|
+
for (const [name, lane] of Object.entries(c.lanes)) {
|
|
128
|
+
if (!need(isObj(lane), `lanes.${name} must be an object`)) continue;
|
|
129
|
+
need(
|
|
130
|
+
isList(lane.paths) && lane.paths.length > 0,
|
|
131
|
+
`lanes.${name}.paths must list the globs the lane owns`,
|
|
132
|
+
);
|
|
133
|
+
need(isStr(lane.agent), `lanes.${name}.agent must name the lane's agent`);
|
|
134
|
+
need(
|
|
135
|
+
lane.exclude === undefined || isList(lane.exclude),
|
|
136
|
+
`lanes.${name}.exclude must be a list of globs`,
|
|
137
|
+
);
|
|
138
|
+
need(!agents.has(lane.agent), `lanes.${name}.agent "${lane.agent}" is used by another lane`);
|
|
139
|
+
agents.add(lane.agent);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function validateDesignGate(g, need) {
|
|
144
|
+
need(
|
|
145
|
+
isObj(g) && isStr(g.register) && isStr(g.howTo),
|
|
146
|
+
'"designGate" needs "register" and "howTo"',
|
|
147
|
+
);
|
|
148
|
+
const ok =
|
|
149
|
+
Array.isArray(g?.screens) &&
|
|
150
|
+
g.screens.every((s) => isStr(s?.glob) && (s.not === undefined || isRegExp(s.not)));
|
|
151
|
+
need(ok, '"designGate.screens" must be a list of { glob, not? (a regular expression) }');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function validateLayers(c, need, each) {
|
|
155
|
+
const names = new Set((Array.isArray(c.layers) ? c.layers : []).map((l) => l?.name));
|
|
156
|
+
each('layers', (l, at) => {
|
|
157
|
+
need(isStr(l.name) && isList(l.paths), `${at} needs "name" and "paths"`);
|
|
158
|
+
need(
|
|
159
|
+
isList(l.mayImport),
|
|
160
|
+
`${at}.mayImport must list the layers it may depend on (may be empty)`,
|
|
161
|
+
);
|
|
162
|
+
for (const n of isList(l.mayImport) ? l.mayImport : [])
|
|
163
|
+
need(names.has(n), `${at}.mayImport names an unknown layer "${n}"`);
|
|
164
|
+
need(
|
|
165
|
+
l.denyPackages === undefined || isList(l.denyPackages),
|
|
166
|
+
`${at}.denyPackages must be a list of globs`,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
const aliases = c.importAliases;
|
|
170
|
+
need(
|
|
171
|
+
aliases === undefined || (isObj(aliases) && Object.values(aliases).every(isStr)),
|
|
172
|
+
'"importAliases" must map import prefixes to repository paths',
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function validateShell(s, lanes, need) {
|
|
177
|
+
if (!need(isObj(s), '"shell" must be an object')) return;
|
|
178
|
+
const rule = (r) => isObj(r) && isRegExp(r.pattern) && isStr(r.why);
|
|
179
|
+
need(
|
|
180
|
+
s.block === undefined || (Array.isArray(s.block) && s.block.every(rule)),
|
|
181
|
+
'"shell.block" must be a list of { pattern, why }',
|
|
182
|
+
);
|
|
183
|
+
const restricted =
|
|
184
|
+
s.restricted === undefined ||
|
|
185
|
+
(Array.isArray(s.restricted) && s.restricted.every((r) => rule(r) && isList(r.lanes)));
|
|
186
|
+
need(restricted, '"shell.restricted" must be a list of { pattern, lanes, why }');
|
|
187
|
+
for (const r of Array.isArray(s.restricted) ? s.restricted : []) {
|
|
188
|
+
for (const l of isList(r?.lanes) ? r.lanes : [])
|
|
189
|
+
need(lanes.includes(l), `"shell.restricted" names an unknown lane "${l}"`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function validateCheck(e, at, lanes, need) {
|
|
194
|
+
need(
|
|
195
|
+
isStr(e.name) && isList(e.files) && isList(e.run) && e.run.length > 0,
|
|
196
|
+
`${at} needs "name", "files" (globs) and "run" (commands)`,
|
|
197
|
+
);
|
|
198
|
+
need(e.exclude === undefined || isList(e.exclude), `${at}.exclude must be a list of globs`);
|
|
199
|
+
need(e.each === undefined || isStr(e.each), `${at}.each must be a directory glob`);
|
|
200
|
+
need(
|
|
201
|
+
e.ifMissing === undefined || ['skip', 'fail'].includes(e.ifMissing),
|
|
202
|
+
`${at}.ifMissing must be "skip" or "fail"`,
|
|
203
|
+
);
|
|
204
|
+
need(
|
|
205
|
+
e.timeoutSeconds === undefined || (Number.isInteger(e.timeoutSeconds) && e.timeoutSeconds > 0),
|
|
206
|
+
`${at}.timeoutSeconds must be a positive whole number`,
|
|
207
|
+
);
|
|
208
|
+
for (const l of isList(e.owners) ? e.owners : [])
|
|
209
|
+
need(lanes.includes(l), `${at}.owners names an unknown lane "${l}"`);
|
|
210
|
+
need(e.owners === undefined || isList(e.owners), `${at}.owners must be a list of lanes`);
|
|
211
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// New dependencies: which packages a shell command adds, and which files installing them changes.
|
|
2
|
+
// Each package needs a person's approval (`dep-<package>`), which also covers those files.
|
|
3
|
+
import { basename } from 'node:path';
|
|
4
|
+
|
|
5
|
+
// What installing a package rewrites: manifests and lockfiles, wherever they sit.
|
|
6
|
+
const DEPENDENCY_FILES = new Set([
|
|
7
|
+
'package.json',
|
|
8
|
+
'package-lock.json',
|
|
9
|
+
'npm-shrinkwrap.json',
|
|
10
|
+
'pnpm-lock.yaml',
|
|
11
|
+
'yarn.lock',
|
|
12
|
+
'bun.lock',
|
|
13
|
+
'bun.lockb',
|
|
14
|
+
'pyproject.toml',
|
|
15
|
+
'uv.lock',
|
|
16
|
+
'poetry.lock',
|
|
17
|
+
'Pipfile',
|
|
18
|
+
'Pipfile.lock',
|
|
19
|
+
'Cargo.toml',
|
|
20
|
+
'Cargo.lock',
|
|
21
|
+
'go.mod',
|
|
22
|
+
'go.sum',
|
|
23
|
+
'Gemfile',
|
|
24
|
+
'Gemfile.lock',
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const isDependencyFile = (rel) => DEPENDENCY_FILES.has(basename(rel));
|
|
28
|
+
|
|
29
|
+
export const DEPENDENCY_APPROVAL = 'dep-';
|
|
30
|
+
|
|
31
|
+
/** The approval a package needs: `dep-<name>`, with `/` as `+` so a scoped name stays one file. */
|
|
32
|
+
export const dependencyApproval = (pkg) =>
|
|
33
|
+
`${DEPENDENCY_APPROVAL}${pkg.replace(/\//g, '+').replace(/[^\w.@+-]/g, '_')}`;
|
|
34
|
+
|
|
35
|
+
const js = (name) => name.replace(/(?<=.)@.*$/, ''); // lodash@4, @scope/pkg@^1
|
|
36
|
+
const py = (name) => name.split(/[[<>=!~;\s]/)[0]; // httpx>=0.27, requests[socks]
|
|
37
|
+
const at = (name) => name.replace(/@.*$/, ''); // serde@1, example.com/m@v1
|
|
38
|
+
|
|
39
|
+
// Each package manager: the subcommands that add packages, the flags that take a value (so the value
|
|
40
|
+
// isn't read as a package), the words that take one argument before the subcommand, and how a version
|
|
41
|
+
// is written onto a name.
|
|
42
|
+
const MANAGERS = {
|
|
43
|
+
npm: {
|
|
44
|
+
adds: ['install', 'i', 'add', 'in'],
|
|
45
|
+
values: ['--workspace', '-w', '--prefix', '--registry', '--tag', '--save-prefix'],
|
|
46
|
+
name: js,
|
|
47
|
+
},
|
|
48
|
+
pnpm: {
|
|
49
|
+
adds: ['add', 'install', 'i'],
|
|
50
|
+
values: ['--filter', '-F', '--dir', '-C', '--registry', '--save-prefix'],
|
|
51
|
+
name: js,
|
|
52
|
+
},
|
|
53
|
+
yarn: { adds: ['add'], values: ['--cwd', '--registry'], before: ['workspace'], name: js },
|
|
54
|
+
bun: { adds: ['add', 'install', 'i'], values: ['--cwd', '--registry'], name: js },
|
|
55
|
+
pip: {
|
|
56
|
+
adds: ['install'],
|
|
57
|
+
values: [
|
|
58
|
+
'-r',
|
|
59
|
+
'--requirement',
|
|
60
|
+
'-c',
|
|
61
|
+
'--constraint',
|
|
62
|
+
'-e',
|
|
63
|
+
'--editable',
|
|
64
|
+
'-i',
|
|
65
|
+
'--index-url',
|
|
66
|
+
'--extra-index-url',
|
|
67
|
+
'-f',
|
|
68
|
+
'--find-links',
|
|
69
|
+
'-t',
|
|
70
|
+
'--target',
|
|
71
|
+
'--prefix',
|
|
72
|
+
'--root',
|
|
73
|
+
],
|
|
74
|
+
name: py,
|
|
75
|
+
local: ['.'],
|
|
76
|
+
},
|
|
77
|
+
uv: { adds: ['add'], values: ['--group', '--optional', '--package', '--directory'], name: py },
|
|
78
|
+
poetry: { adds: ['add'], values: ['--group', '-G', '--directory', '-C'], name: at },
|
|
79
|
+
cargo: {
|
|
80
|
+
adds: ['add'],
|
|
81
|
+
values: ['--features', '-F', '--package', '-p', '--path', '--git', '--rename'],
|
|
82
|
+
name: at,
|
|
83
|
+
},
|
|
84
|
+
go: { adds: ['get'], values: [], name: at },
|
|
85
|
+
bundle: { adds: ['add'], values: ['--version', '-v', '--group', '-g', '--source'], name: at },
|
|
86
|
+
};
|
|
87
|
+
MANAGERS.pip3 = MANAGERS.pip;
|
|
88
|
+
|
|
89
|
+
/** The packages `cmd` adds as dependencies, in order: none for a bare install or a requirements file. */
|
|
90
|
+
export function addedPackages(cmd) {
|
|
91
|
+
const found = [];
|
|
92
|
+
for (const segment of cmd.split(/&&|\|\||[;|\n]/)) {
|
|
93
|
+
const tokens = segment
|
|
94
|
+
.trim()
|
|
95
|
+
.split(/\s+/)
|
|
96
|
+
.map((t) => t.replace(/^["']|["']$/g, ''));
|
|
97
|
+
tokens.forEach((token, i) => {
|
|
98
|
+
const manager = MANAGERS[basename(token)];
|
|
99
|
+
if (manager) found.push(...packagesAfter(tokens.slice(i + 1), manager));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
return [...new Set(found)];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The words after a package manager's name: its subcommand must be one that adds packages, and the
|
|
106
|
+
// words after that, other than flags and their values, are the packages.
|
|
107
|
+
function packagesAfter(words, manager) {
|
|
108
|
+
let i = 0;
|
|
109
|
+
const skipFlag = () => {
|
|
110
|
+
const word = words[i++];
|
|
111
|
+
if (manager.values.includes(word)) i++;
|
|
112
|
+
};
|
|
113
|
+
while (i < words.length && words[i].startsWith('-')) skipFlag();
|
|
114
|
+
if (manager.before?.includes(words[i])) i += 2; // yarn workspace <name> add …
|
|
115
|
+
while (i < words.length && words[i].startsWith('-')) skipFlag();
|
|
116
|
+
if (!manager.adds.includes(words[i])) return [];
|
|
117
|
+
i++;
|
|
118
|
+
const packages = [];
|
|
119
|
+
while (i < words.length) {
|
|
120
|
+
if (words[i].startsWith('-')) skipFlag();
|
|
121
|
+
// a redirect ends the arguments
|
|
122
|
+
else if (/^[>&<]|^\d*>/.test(words[i])) break;
|
|
123
|
+
else {
|
|
124
|
+
const word = words[i++];
|
|
125
|
+
if (!manager.local?.includes(word)) packages.push(manager.name(word));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return packages.filter(Boolean);
|
|
129
|
+
}
|