@devrik-tools/claude-gates 0.2.0 → 0.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/.claude-plugin/marketplace.json +2 -2
- package/README.es.md +7 -6
- package/README.md +16 -7
- package/cli/index.mjs +4 -0
- package/cli/init.mjs +18 -1
- package/cli/install.mjs +26 -1
- package/package.json +5 -2
- package/plugins/gates/.claude-plugin/plugin.json +1 -1
- package/plugins/gates/hooks/gates/autonomous-mode/index.mjs +8 -6
- package/plugins/gates/hooks/gates/autonomous-mode/stop.mjs +78 -0
- package/plugins/gates/hooks/gates/bash-commands/index.mjs +11 -81
- package/plugins/gates/hooks/gates/block-remote-publish/index.mjs +140 -0
- package/plugins/gates/hooks/gates/dependency-skills/index.mjs +130 -126
- package/plugins/gates/hooks/gates/neutral-spanish/index.mjs +104 -79
- package/plugins/gates/hooks/gates/no-memory-dependency/index.mjs +138 -123
- package/plugins/gates/hooks/gates/root-whitelist/index.mjs +59 -44
- package/plugins/gates/hooks/gates/test-after-implementation/index.mjs +124 -101
- package/plugins/gates/hooks/hooks.json +365 -346
- package/plugins/gates/hooks/lib/hook-io.mjs +39 -0
- package/plugins/tasks/.claude-plugin/plugin.json +1 -1
- package/plugins/tasks/hooks/register-requests.mjs +7 -3
- package/registry.json +738 -793
- package/cli/__tests__/config.test.mjs +0 -113
- package/cli/__tests__/init-flags.test.mjs +0 -111
- package/cli/__tests__/install.test.mjs +0 -102
- package/cli/__tests__/materialize.test.mjs +0 -95
- package/cli/__tests__/registry-gates-consistency.test.mjs +0 -52
- package/cli/__tests__/selection.test.mjs +0 -200
- package/cli/__tests__/task.test.mjs +0 -124
- package/cli/__tests__/version-consistency.test.mjs +0 -44
- package/plugins/gates/hooks/__tests__/ask-adoption.test.mjs +0 -83
- package/plugins/gates/hooks/__tests__/doctor.test.mjs +0 -85
- package/plugins/gates/hooks/__tests__/wiring-check.test.mjs +0 -65
- package/plugins/gates/hooks/gates/audit-before-build/audit-before-build.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/audit-before-build/test.mjs +0 -117
- package/plugins/gates/hooks/gates/autonomous-mode/autonomous.edge.test.mjs +0 -104
- package/plugins/gates/hooks/gates/autonomous-mode/test.mjs +0 -58
- package/plugins/gates/hooks/gates/bash-commands/bash-commands.edge.test.mjs +0 -165
- package/plugins/gates/hooks/gates/bash-commands/test.mjs +0 -126
- package/plugins/gates/hooks/gates/brief-before-delegate/brief-before-delegate.edge.test.mjs +0 -151
- package/plugins/gates/hooks/gates/brief-before-delegate/test.mjs +0 -111
- package/plugins/gates/hooks/gates/circuit-breaker/circuit-breaker.edge.test.mjs +0 -207
- package/plugins/gates/hooks/gates/circuit-breaker/test.mjs +0 -161
- package/plugins/gates/hooks/gates/dependency-skills/dependency-skills.edge.test.mjs +0 -69
- package/plugins/gates/hooks/gates/dependency-skills/test.mjs +0 -109
- package/plugins/gates/hooks/gates/diagnosis-before-patch/diagnosis-before-patch.edge.test.mjs +0 -68
- package/plugins/gates/hooks/gates/diagnosis-before-patch/test.mjs +0 -68
- package/plugins/gates/hooks/gates/feature-catalog/edge.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/feature-catalog/test.mjs +0 -97
- package/plugins/gates/hooks/gates/force-parallel/test.mjs +0 -88
- package/plugins/gates/hooks/gates/forge-flow/forge-flow.edge.test.mjs +0 -155
- package/plugins/gates/hooks/gates/forge-flow/test.mjs +0 -135
- package/plugins/gates/hooks/gates/implementation-pipeline/edge.edge.test.mjs +0 -66
- package/plugins/gates/hooks/gates/implementation-pipeline/test.mjs +0 -86
- package/plugins/gates/hooks/gates/intent-flow/intent-flow.edge.test.mjs +0 -96
- package/plugins/gates/hooks/gates/intent-flow/test.mjs +0 -165
- package/plugins/gates/hooks/gates/lint-commit/test.mjs +0 -85
- package/plugins/gates/hooks/gates/mandatory-flow/edge.edge.test.mjs +0 -100
- package/plugins/gates/hooks/gates/mandatory-flow/test.mjs +0 -119
- package/plugins/gates/hooks/gates/neutral-spanish/neutral-spanish.edge.test.mjs +0 -80
- package/plugins/gates/hooks/gates/neutral-spanish/test.mjs +0 -65
- package/plugins/gates/hooks/gates/never-assume/never-assume.edge.test.mjs +0 -71
- package/plugins/gates/hooks/gates/never-assume/test.mjs +0 -95
- package/plugins/gates/hooks/gates/no-blocking/no-blocking.edge.test.mjs +0 -109
- package/plugins/gates/hooks/gates/no-blocking/test.mjs +0 -108
- package/plugins/gates/hooks/gates/no-memory-dependency/no-memory-dependency.edge.test.mjs +0 -83
- package/plugins/gates/hooks/gates/no-memory-dependency/test.mjs +0 -106
- package/plugins/gates/hooks/gates/no-reconfirm/no-reconfirm.edge.test.mjs +0 -91
- package/plugins/gates/hooks/gates/no-reconfirm/test.mjs +0 -131
- package/plugins/gates/hooks/gates/protected-paths/protected-paths.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/protected-paths/test.mjs +0 -75
- package/plugins/gates/hooks/gates/recurrence-lock/recurrence-lock.edge.test.mjs +0 -102
- package/plugins/gates/hooks/gates/recurrence-lock/test.mjs +0 -99
- package/plugins/gates/hooks/gates/reuse-before-build/edge.edge.test.mjs +0 -94
- package/plugins/gates/hooks/gates/reuse-before-build/test.mjs +0 -101
- package/plugins/gates/hooks/gates/risk-level/risk-level.edge.test.mjs +0 -116
- package/plugins/gates/hooks/gates/risk-level/test.mjs +0 -153
- package/plugins/gates/hooks/gates/root-cause-first/root-cause-first.edge.test.mjs +0 -58
- package/plugins/gates/hooks/gates/root-cause-first/test.mjs +0 -73
- package/plugins/gates/hooks/gates/root-whitelist/root-whitelist.edge.test.mjs +0 -97
- package/plugins/gates/hooks/gates/root-whitelist/test.mjs +0 -80
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/rule-skill-autodiscovery.edge.test.mjs +0 -126
- package/plugins/gates/hooks/gates/rule-skill-autodiscovery/test.mjs +0 -78
- package/plugins/gates/hooks/gates/sdd-specs/edge.edge.test.mjs +0 -130
- package/plugins/gates/hooks/gates/sdd-specs/test.mjs +0 -163
- package/plugins/gates/hooks/gates/stop-pending/test.mjs +0 -114
- package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs +0 -72
- package/plugins/gates/hooks/gates/test-after-implementation/test.mjs +0 -81
- package/plugins/gates/hooks/gates/test-matrix/edge.edge.test.mjs +0 -63
- package/plugins/gates/hooks/gates/test-matrix/test.mjs +0 -114
- package/plugins/gates/hooks/gates/tool-map/edge.edge.test.mjs +0 -62
- package/plugins/gates/hooks/gates/tool-map/test.mjs +0 -87
- package/plugins/gates/hooks/lib/__tests__/config.test.mjs +0 -173
- package/plugins/gates/hooks/lib/__tests__/hook-io.test.mjs +0 -210
- package/plugins/gates/hooks/lib/__tests__/signals.test.mjs +0 -114
- package/plugins/gates/hooks/lib/hook-io-config.edge.test.mjs +0 -189
- package/plugins/tasks/hooks/__tests__/register-requests.test.mjs +0 -100
- package/plugins/tasks/hooks/__tests__/session-tasks.test.mjs +0 -95
- package/plugins/tasks/hooks/lib/__tests__/task-store.test.mjs +0 -180
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
// Edge-case probes for sdd-specs. Previously: (1) an Edit (new_string) bypassed the
|
|
2
|
-
// content check the same way as feature-catalog; (2) malformed JSON content failed
|
|
3
|
-
// open (parseJsonOrNull -> null -> features=[] -> nothing to check); (3) the catalog
|
|
4
|
-
// lookup was relative to process.cwd() only, so a monorepo subpackage's own
|
|
5
|
-
// .ai/feature_list.json living outside cwd was invisible to findCatalog. All three are
|
|
6
|
-
// now fixed: writtenContentOf()/writtenPathOf() read every field shape, invalid JSON on
|
|
7
|
-
// a catalog write now denies (fail-closed) instead of silently allowing, and
|
|
8
|
-
// findCatalog also resolves catalogLocations relative to the written file's own
|
|
9
|
-
// directory.
|
|
10
|
-
import assert from 'node:assert/strict';
|
|
11
|
-
import { execFileSync } from 'node:child_process';
|
|
12
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
13
|
-
import { tmpdir } from 'node:os';
|
|
14
|
-
import { dirname, join } from 'node:path';
|
|
15
|
-
import { test } from 'node:test';
|
|
16
|
-
import { fileURLToPath } from 'node:url';
|
|
17
|
-
|
|
18
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
19
|
-
|
|
20
|
-
function makeProject() {
|
|
21
|
-
const project = mkdtempSync(join(tmpdir(), 'sdd-specs-edge-'));
|
|
22
|
-
mkdirSync(join(project, '.git'));
|
|
23
|
-
return project;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
function writeProjectConfig(project, config) {
|
|
27
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
28
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function runGate(project, payload) {
|
|
32
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
33
|
-
input: JSON.stringify(payload),
|
|
34
|
-
encoding: 'utf8',
|
|
35
|
-
cwd: project,
|
|
36
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
37
|
-
});
|
|
38
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isDeny(result) {
|
|
42
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function enableGate(project) {
|
|
46
|
-
writeProjectConfig(project, { gates: { requireSpecBeforeImplementing: true } });
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
test('FIXED: an Edit (new_string) moving a feature to spec_ready with no contract is now detected', () => {
|
|
50
|
-
const project = makeProject();
|
|
51
|
-
enableGate(project);
|
|
52
|
-
writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
|
|
53
|
-
mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
|
|
54
|
-
const payload = {
|
|
55
|
-
tool_name: 'Edit',
|
|
56
|
-
tool_input: {
|
|
57
|
-
file_path: join(project, '.ai', 'feature_list.json'),
|
|
58
|
-
old_string: 'x',
|
|
59
|
-
new_string: JSON.stringify({
|
|
60
|
-
features: [{ name: 'checkout', status: 'spec_ready' }],
|
|
61
|
-
}),
|
|
62
|
-
},
|
|
63
|
-
};
|
|
64
|
-
assert.ok(
|
|
65
|
-
isDeny(runGate(project, payload)),
|
|
66
|
-
'writtenContentOf reads new_string and catches the spec_ready-with-no-contract write',
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('FIXED: malformed/invalid JSON content on a catalog write now denies (fail-closed)', () => {
|
|
71
|
-
const project = makeProject();
|
|
72
|
-
enableGate(project);
|
|
73
|
-
writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
|
|
74
|
-
mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
|
|
75
|
-
const payload = {
|
|
76
|
-
tool_name: 'Write',
|
|
77
|
-
tool_input: {
|
|
78
|
-
file_path: join(project, '.ai', 'feature_list.json'),
|
|
79
|
-
// deliberately invalid JSON (unquoted keys): the gate can no longer verify the
|
|
80
|
-
// spec-contract invariant for this write, so it denies instead of allowing.
|
|
81
|
-
content:
|
|
82
|
-
'{features: [{name: "checkout", status: "spec_ready"}]}',
|
|
83
|
-
},
|
|
84
|
-
};
|
|
85
|
-
assert.ok(
|
|
86
|
-
isDeny(runGate(project, payload)),
|
|
87
|
-
'malformed JSON targeting the catalog must deny, not fail open',
|
|
88
|
-
);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test('FIXED: a catalog living outside process.cwd() is now found via the written path', () => {
|
|
92
|
-
const project = makeProject();
|
|
93
|
-
enableGate(project);
|
|
94
|
-
// The catalog lives under sub/.ai/feature_list.json, not the project root's .ai/ --
|
|
95
|
-
// findCatalog now also resolves catalogLocations relative to dirname(writtenPath).
|
|
96
|
-
mkdirSync(join(project, 'sub', '.ai', 'features', 'checkout'), { recursive: true });
|
|
97
|
-
writeFileSync(
|
|
98
|
-
join(project, 'sub', '.ai', 'feature_list.json'),
|
|
99
|
-
JSON.stringify({ features: [{ name: 'checkout', status: 'spec_ready' }] }),
|
|
100
|
-
);
|
|
101
|
-
const payload = {
|
|
102
|
-
tool_name: 'Write',
|
|
103
|
-
tool_input: {
|
|
104
|
-
file_path: join(project, 'sub', '.ai', 'feature_list.json'),
|
|
105
|
-
content: JSON.stringify({
|
|
106
|
-
features: [{ name: 'checkout', status: 'spec_ready' }],
|
|
107
|
-
}),
|
|
108
|
-
},
|
|
109
|
-
};
|
|
110
|
-
assert.ok(
|
|
111
|
-
isDeny(runGate(project, payload)),
|
|
112
|
-
'a subpackage catalog reached via its own written path must now be inspected',
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
|
|
116
|
-
test('OK: the equivalent Write-tool payload at the project root IS caught (control)', () => {
|
|
117
|
-
const project = makeProject();
|
|
118
|
-
enableGate(project);
|
|
119
|
-
writeFileSync(join(project, '.ai', 'feature_list.json'), JSON.stringify({ features: [] }));
|
|
120
|
-
const payload = {
|
|
121
|
-
tool_name: 'Write',
|
|
122
|
-
tool_input: {
|
|
123
|
-
file_path: join(project, '.ai', 'feature_list.json'),
|
|
124
|
-
content: JSON.stringify({
|
|
125
|
-
features: [{ name: 'checkout', status: 'spec_ready' }],
|
|
126
|
-
}),
|
|
127
|
-
},
|
|
128
|
-
};
|
|
129
|
-
assert.ok(isDeny(runGate(project, payload)), 'control failed');
|
|
130
|
-
});
|
|
@@ -1,163 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { test } from 'node:test';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
|
|
9
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
-
|
|
11
|
-
function makeProject() {
|
|
12
|
-
const project = mkdtempSync(join(tmpdir(), 'sdd-specs-'));
|
|
13
|
-
mkdirSync(join(project, '.git'));
|
|
14
|
-
return project;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
function writeProjectConfig(project, config) {
|
|
18
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
19
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
function runGate(project, payload) {
|
|
23
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
24
|
-
input: JSON.stringify(payload),
|
|
25
|
-
encoding: 'utf8',
|
|
26
|
-
cwd: project,
|
|
27
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
28
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
29
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
30
|
-
});
|
|
31
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function delegate(prompt, subagentType = 'backend') {
|
|
35
|
-
return {
|
|
36
|
-
tool_name: 'Agent',
|
|
37
|
-
tool_input: { prompt, subagent_type: subagentType },
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
function isDeny(result) {
|
|
42
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
function enableGate(project) {
|
|
46
|
-
writeProjectConfig(project, {
|
|
47
|
-
gates: { requireSpecBeforeImplementing: true },
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
test('auto-off: no catalog anywhere allows implementation delegation', () => {
|
|
52
|
-
const project = makeProject();
|
|
53
|
-
enableGate(project);
|
|
54
|
-
const prompt =
|
|
55
|
-
'Nivel: STANDARD\nImplementá el checkout citando .ai/features/checkout/tasks/t1/';
|
|
56
|
-
assert.equal(runGate(project, delegate(prompt)), null);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
test('denies an implementation delegation citing a feature with no contract on disk', () => {
|
|
60
|
-
const project = makeProject();
|
|
61
|
-
enableGate(project);
|
|
62
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
63
|
-
writeFileSync(
|
|
64
|
-
join(project, '.ai', 'feature_list.json'),
|
|
65
|
-
JSON.stringify({ features: [] }),
|
|
66
|
-
);
|
|
67
|
-
// features tree exists but the cited feature has no contract file
|
|
68
|
-
mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
|
|
69
|
-
const prompt =
|
|
70
|
-
'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
|
|
71
|
-
assert.ok(isDeny(runGate(project, delegate(prompt))));
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('allows an implementation delegation citing a feature with a non-empty contract', () => {
|
|
75
|
-
const project = makeProject();
|
|
76
|
-
enableGate(project);
|
|
77
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
78
|
-
writeFileSync(
|
|
79
|
-
join(project, '.ai', 'feature_list.json'),
|
|
80
|
-
JSON.stringify({ features: [] }),
|
|
81
|
-
);
|
|
82
|
-
mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
|
|
83
|
-
writeFileSync(
|
|
84
|
-
join(project, '.ai', 'features', 'checkout', 'brief.md'),
|
|
85
|
-
'# Checkout\n\nObjetivo: cobrar.',
|
|
86
|
-
);
|
|
87
|
-
const prompt =
|
|
88
|
-
'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
|
|
89
|
-
assert.equal(runGate(project, delegate(prompt)), null);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('disabled by config: the gate does not run', () => {
|
|
93
|
-
const project = makeProject();
|
|
94
|
-
writeProjectConfig(project, {
|
|
95
|
-
gates: { requireSpecBeforeImplementing: false },
|
|
96
|
-
});
|
|
97
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
98
|
-
writeFileSync(
|
|
99
|
-
join(project, '.ai', 'feature_list.json'),
|
|
100
|
-
JSON.stringify({ features: [] }),
|
|
101
|
-
);
|
|
102
|
-
mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
|
|
103
|
-
const prompt =
|
|
104
|
-
'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
|
|
105
|
-
assert.equal(runGate(project, delegate(prompt)), null);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test('a prompt that only describes, does not order, is exempt via QUESTION level', () => {
|
|
109
|
-
const project = makeProject();
|
|
110
|
-
enableGate(project);
|
|
111
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
112
|
-
writeFileSync(
|
|
113
|
-
join(project, '.ai', 'feature_list.json'),
|
|
114
|
-
JSON.stringify({ features: [] }),
|
|
115
|
-
);
|
|
116
|
-
mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
|
|
117
|
-
const prompt =
|
|
118
|
-
'Nivel: QUESTION\nExplicá cómo funciona .ai/features/checkout/ hoy.';
|
|
119
|
-
assert.equal(runGate(project, delegate(prompt)), null);
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
test('exemptSubagents param override exempts a custom subagent type', () => {
|
|
123
|
-
const project = makeProject();
|
|
124
|
-
writeProjectConfig(project, {
|
|
125
|
-
gates: {
|
|
126
|
-
requireSpecBeforeImplementing: {
|
|
127
|
-
enabled: true,
|
|
128
|
-
exemptSubagents: ['worker'],
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
133
|
-
writeFileSync(
|
|
134
|
-
join(project, '.ai', 'feature_list.json'),
|
|
135
|
-
JSON.stringify({ features: [] }),
|
|
136
|
-
);
|
|
137
|
-
mkdirSync(join(project, '.ai', 'features', 'checkout'), { recursive: true });
|
|
138
|
-
const prompt =
|
|
139
|
-
'Nivel: STANDARD\nImplementá .ai/features/checkout/ el flujo de pago.';
|
|
140
|
-
assert.equal(runGate(project, delegate(prompt, 'worker')), null);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('a catalog write moving a feature to spec_ready without contract is denied', () => {
|
|
144
|
-
const project = makeProject();
|
|
145
|
-
enableGate(project);
|
|
146
|
-
mkdirSync(join(project, '.ai', 'features'), { recursive: true });
|
|
147
|
-
// The catalog already exists on disk (an Edit to an existing file); the write below
|
|
148
|
-
// is the new content about to replace it.
|
|
149
|
-
writeFileSync(
|
|
150
|
-
join(project, '.ai', 'feature_list.json'),
|
|
151
|
-
JSON.stringify({ features: [] }),
|
|
152
|
-
);
|
|
153
|
-
const write = {
|
|
154
|
-
tool_name: 'Write',
|
|
155
|
-
tool_input: {
|
|
156
|
-
file_path: join(project, '.ai', 'feature_list.json'),
|
|
157
|
-
content: JSON.stringify({
|
|
158
|
-
features: [{ name: 'checkout', status: 'spec_ready' }],
|
|
159
|
-
}),
|
|
160
|
-
},
|
|
161
|
-
};
|
|
162
|
-
assert.ok(isDeny(runGate(project, write)));
|
|
163
|
-
});
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { test } from 'node:test';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
|
|
9
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
-
|
|
11
|
-
function setupProject({ enabled = true, tasks, allowStopWithBlockedTasks } = {}) {
|
|
12
|
-
const project = mkdtempSync(join(tmpdir(), 'stop-pending-'));
|
|
13
|
-
mkdirSync(join(project, '.git'), { recursive: true });
|
|
14
|
-
mkdirSync(join(project, '.ai'), { recursive: true });
|
|
15
|
-
|
|
16
|
-
const gateEntry = { enabled };
|
|
17
|
-
if (allowStopWithBlockedTasks !== undefined) {
|
|
18
|
-
gateEntry.allowStopWithBlockedTasks = allowStopWithBlockedTasks;
|
|
19
|
-
}
|
|
20
|
-
writeFileSync(
|
|
21
|
-
join(project, '.ai', 'config.json'),
|
|
22
|
-
JSON.stringify({ gates: { blockStopWithPendingTasks: gateEntry } }),
|
|
23
|
-
);
|
|
24
|
-
|
|
25
|
-
if (tasks) {
|
|
26
|
-
mkdirSync(join(project, '.ai', 'tasks'), { recursive: true });
|
|
27
|
-
writeFileSync(
|
|
28
|
-
join(project, '.ai', 'tasks', 'active.json'),
|
|
29
|
-
JSON.stringify({ tasks }),
|
|
30
|
-
);
|
|
31
|
-
}
|
|
32
|
-
return project;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function runGate(project, payload) {
|
|
36
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
37
|
-
input: JSON.stringify(payload),
|
|
38
|
-
encoding: 'utf8',
|
|
39
|
-
cwd: project,
|
|
40
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
41
|
-
});
|
|
42
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
test('an open active task blocks the stop', () => {
|
|
46
|
-
const project = setupProject({
|
|
47
|
-
tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
|
|
48
|
-
});
|
|
49
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
50
|
-
assert.equal(result.decision, 'block');
|
|
51
|
-
assert.match(result.reason, /t-1/);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('no active tasks: stop is allowed', () => {
|
|
55
|
-
const project = setupProject({ tasks: [] });
|
|
56
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
57
|
-
assert.equal(result, null);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('no .ai/tasks directory at all: stop is allowed', () => {
|
|
61
|
-
const project = setupProject({});
|
|
62
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
63
|
-
assert.equal(result, null);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test('stop_hook_active=true: always allow, even with an open task (loop guard)', () => {
|
|
67
|
-
const project = setupProject({
|
|
68
|
-
tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
|
|
69
|
-
});
|
|
70
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: true });
|
|
71
|
-
assert.equal(result, null);
|
|
72
|
-
});
|
|
73
|
-
|
|
74
|
-
test('a blocked task with allowStopWithBlockedTasks default (true): stop is allowed', () => {
|
|
75
|
-
const project = setupProject({
|
|
76
|
-
tasks: [{ id: 't-2', title: 'waiting on X', status: 'blocked', blockedReason: 'waiting on X' }],
|
|
77
|
-
});
|
|
78
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
79
|
-
assert.equal(result, null);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
test('a blocked task with allowStopWithBlockedTasks:false: stop is blocked', () => {
|
|
83
|
-
const project = setupProject({
|
|
84
|
-
tasks: [{ id: 't-2', title: 'waiting on X', status: 'blocked', blockedReason: 'waiting on X' }],
|
|
85
|
-
allowStopWithBlockedTasks: false,
|
|
86
|
-
});
|
|
87
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
88
|
-
assert.equal(result.decision, 'block');
|
|
89
|
-
assert.match(result.reason, /t-2/);
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
test('gate disabled: stop is allowed even with an open task', () => {
|
|
93
|
-
const project = setupProject({
|
|
94
|
-
tasks: [{ id: 't-1', title: 'finish the thing', status: 'open' }],
|
|
95
|
-
enabled: false,
|
|
96
|
-
});
|
|
97
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
98
|
-
assert.equal(result, null);
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('a done task in active.json (should not normally happen) never blocks', () => {
|
|
102
|
-
const project = setupProject({
|
|
103
|
-
tasks: [{ id: 't-3', title: 'already closed', status: 'done' }],
|
|
104
|
-
});
|
|
105
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
106
|
-
assert.equal(result, null);
|
|
107
|
-
});
|
|
108
|
-
|
|
109
|
-
test('corrupt active.json: fail-safe allow', () => {
|
|
110
|
-
const project = setupProject({ tasks: [] });
|
|
111
|
-
writeFileSync(join(project, '.ai', 'tasks', 'active.json'), '{not json');
|
|
112
|
-
const result = runGate(project, { session_id: 's1', stop_hook_active: false });
|
|
113
|
-
assert.equal(result, null);
|
|
114
|
-
});
|
package/plugins/gates/hooks/gates/test-after-implementation/test-after-implementation.edge.test.mjs
DELETED
|
@@ -1,72 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { test } from 'node:test';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
|
|
9
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
-
|
|
11
|
-
function newProject({ config, git = true } = {}) {
|
|
12
|
-
const project = mkdtempSync(join(tmpdir(), 'test-after-implementation-edge-'));
|
|
13
|
-
if (git) {
|
|
14
|
-
execFileSync('git', ['init', '-q'], { cwd: project });
|
|
15
|
-
execFileSync('git', ['config', 'user.email', 'a@b.c'], { cwd: project });
|
|
16
|
-
execFileSync('git', ['config', 'user.name', 'a'], { cwd: project });
|
|
17
|
-
}
|
|
18
|
-
if (config) {
|
|
19
|
-
mkdirSync(join(project, '.ai'));
|
|
20
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
21
|
-
}
|
|
22
|
-
return project;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function runGateIn(project, payload) {
|
|
26
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
27
|
-
input: JSON.stringify(payload),
|
|
28
|
-
encoding: 'utf8',
|
|
29
|
-
cwd: project,
|
|
30
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
31
|
-
});
|
|
32
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function writeTest(filePath) {
|
|
36
|
-
return { tool_name: 'Write', tool_input: { file_path: filePath, content: 'test content' } };
|
|
37
|
-
}
|
|
38
|
-
function isWarn(result) {
|
|
39
|
-
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const ENABLE = { gates: { warnTestWrittenAfterImplementation: true } };
|
|
43
|
-
|
|
44
|
-
// EDGE CASE (BUG): the paired-implementation match requires the changed file's directory
|
|
45
|
-
// (relative to cwd) to equal the test file's directory exactly (index.mjs line 83-86). A
|
|
46
|
-
// common layout keeps tests in a sibling __tests__/ directory next to the implementation —
|
|
47
|
-
// this gate then never pairs them, silently missing the exact case (tests-after-impl) it
|
|
48
|
-
// exists to catch.
|
|
49
|
-
test('BUG: false negative — implementation and test in different conventional directories (src/ vs __tests__/) are never paired', () => {
|
|
50
|
-
const project = newProject({ config: ENABLE });
|
|
51
|
-
mkdirSync(join(project, 'src'));
|
|
52
|
-
mkdirSync(join(project, '__tests__'));
|
|
53
|
-
writeFileSync(join(project, 'src', 'thing.js'), 'export const thing = 1;');
|
|
54
|
-
const testPath = join(project, '__tests__', 'thing.test.js');
|
|
55
|
-
const result = runGateIn(project, writeTest(testPath));
|
|
56
|
-
assert.equal(result, null, 'gate misses the exact case it targets because dirname(test) !== dirname(impl) under a __tests__/ layout');
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// EDGE CASE (BUG): only `git status --porcelain` (uncommitted changes) is checked. If the
|
|
60
|
-
// implementation was written AND COMMITTED before writing the test (a slightly worse
|
|
61
|
-
// practice: commit-then-test rather than just write-then-test), git status shows nothing
|
|
62
|
-
// changed and the gate never warns — the temporal violation it targets still happened but
|
|
63
|
-
// leaves no trace once committed.
|
|
64
|
-
test('BUG: false negative — once the paired implementation change is committed, the warning never fires even though tests were still written strictly after', () => {
|
|
65
|
-
const project = newProject({ config: ENABLE });
|
|
66
|
-
writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
|
|
67
|
-
execFileSync('git', ['add', '-A'], { cwd: project });
|
|
68
|
-
execFileSync('git', ['commit', '-q', '-m', 'implement thing'], { cwd: project });
|
|
69
|
-
const testPath = join(project, 'thing.test.js');
|
|
70
|
-
const result = runGateIn(project, writeTest(testPath));
|
|
71
|
-
assert.equal(result, null, 'committing the implementation first launders the same temporal violation past a git-status-based check');
|
|
72
|
-
});
|
|
@@ -1,81 +0,0 @@
|
|
|
1
|
-
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { tmpdir } from 'node:os';
|
|
5
|
-
import { dirname, join } from 'node:path';
|
|
6
|
-
import { test } from 'node:test';
|
|
7
|
-
import { fileURLToPath } from 'node:url';
|
|
8
|
-
|
|
9
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
10
|
-
|
|
11
|
-
function newProject({ config, git = true } = {}) {
|
|
12
|
-
const project = mkdtempSync(join(tmpdir(), 'test-after-implementation-'));
|
|
13
|
-
if (git) {
|
|
14
|
-
execFileSync('git', ['init', '-q'], { cwd: project });
|
|
15
|
-
execFileSync('git', ['config', 'user.email', 'a@b.c'], { cwd: project });
|
|
16
|
-
execFileSync('git', ['config', 'user.name', 'a'], { cwd: project });
|
|
17
|
-
}
|
|
18
|
-
if (config) {
|
|
19
|
-
mkdirSync(join(project, '.ai'));
|
|
20
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
21
|
-
}
|
|
22
|
-
return project;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function runGateIn(project, payload) {
|
|
26
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
27
|
-
input: JSON.stringify(payload),
|
|
28
|
-
encoding: 'utf8',
|
|
29
|
-
cwd: project,
|
|
30
|
-
// Isolate from the user's real global config: point homedir() at the temp
|
|
31
|
-
// project so the global-config fallback finds nothing (registry default).
|
|
32
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
33
|
-
});
|
|
34
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function writeTest(filePath) {
|
|
38
|
-
return {
|
|
39
|
-
tool_name: 'Write',
|
|
40
|
-
tool_input: { file_path: filePath, content: 'test content' },
|
|
41
|
-
};
|
|
42
|
-
}
|
|
43
|
-
function isWarn(result) {
|
|
44
|
-
return result?.hookSpecificOutput?.additionalContext !== undefined;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const ENABLE = { gates: { warnTestWrittenAfterImplementation: true } };
|
|
48
|
-
|
|
49
|
-
test('warns when creating a test whose paired implementation was just modified (uncommitted)', () => {
|
|
50
|
-
const project = newProject({ config: ENABLE });
|
|
51
|
-
writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
|
|
52
|
-
const testPath = join(project, 'thing.test.js');
|
|
53
|
-
assert.ok(isWarn(runGateIn(project, writeTest(testPath))));
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('allows creating a test with no paired uncommitted implementation', () => {
|
|
57
|
-
const project = newProject({ config: ENABLE });
|
|
58
|
-
const testPath = join(project, 'unrelated.test.js');
|
|
59
|
-
assert.equal(runGateIn(project, writeTest(testPath)), null);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
test('allows editing (not creating) an existing test file', () => {
|
|
63
|
-
const project = newProject({ config: ENABLE });
|
|
64
|
-
writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
|
|
65
|
-
const testPath = join(project, 'thing.test.js');
|
|
66
|
-
writeFileSync(testPath, 'existing test');
|
|
67
|
-
assert.equal(runGateIn(project, writeTest(testPath)), null);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('degrades to allow when not a git repo', () => {
|
|
71
|
-
const project = newProject({ config: ENABLE, git: false });
|
|
72
|
-
const testPath = join(project, 'thing.test.js');
|
|
73
|
-
assert.equal(runGateIn(project, writeTest(testPath)), null);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
test('disabled by default (registry default is false)', () => {
|
|
77
|
-
const project = newProject();
|
|
78
|
-
writeFileSync(join(project, 'thing.js'), 'export const thing = 1;');
|
|
79
|
-
const testPath = join(project, 'thing.test.js');
|
|
80
|
-
assert.equal(runGateIn(project, writeTest(testPath)), null);
|
|
81
|
-
});
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
// Edge-case probe for test-matrix. Previously the prompt was read only from
|
|
2
|
-
// toolInput.prompt/Prompt/task, the same root cause shared by implementation-pipeline,
|
|
3
|
-
// mandatory-flow and the sdd-specs delegation check: a Task delegation carrying its
|
|
4
|
-
// brief in `description` was invisible. Migrated to delegationPromptOf()
|
|
5
|
-
// (hook-io.mjs), so this is now caught.
|
|
6
|
-
import assert from 'node:assert/strict';
|
|
7
|
-
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
-
import { tmpdir } from 'node:os';
|
|
10
|
-
import { dirname, join } from 'node:path';
|
|
11
|
-
import { test } from 'node:test';
|
|
12
|
-
import { fileURLToPath } from 'node:url';
|
|
13
|
-
|
|
14
|
-
const GATE = join(dirname(fileURLToPath(import.meta.url)), 'index.mjs');
|
|
15
|
-
|
|
16
|
-
function runGate(payload, { config } = {}) {
|
|
17
|
-
const project = mkdtempSync(join(tmpdir(), 'test-matrix-edge-'));
|
|
18
|
-
mkdirSync(join(project, '.git'));
|
|
19
|
-
if (config) {
|
|
20
|
-
mkdirSync(join(project, '.ai'));
|
|
21
|
-
writeFileSync(join(project, '.ai', 'config.json'), JSON.stringify(config));
|
|
22
|
-
}
|
|
23
|
-
const out = execFileSync(process.execPath, [GATE], {
|
|
24
|
-
input: JSON.stringify(payload),
|
|
25
|
-
encoding: 'utf8',
|
|
26
|
-
cwd: project,
|
|
27
|
-
env: { ...process.env, HOME: project, USERPROFILE: project },
|
|
28
|
-
});
|
|
29
|
-
return out.trim() ? JSON.parse(out.trim()) : null;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function isDeny(result) {
|
|
33
|
-
return result?.hookSpecificOutput?.permissionDecision === 'deny';
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function enabledConfig() {
|
|
37
|
-
return { gates: { requireTestMatrixWhenImplementing: true } };
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
test('FIXED: a Task delegation carrying its brief in `description` is now checked', () => {
|
|
41
|
-
const payload = {
|
|
42
|
-
tool_name: 'Task',
|
|
43
|
-
tool_input: {
|
|
44
|
-
description: 'Nivel: STANDARD\nImplementá el cobro de la cuota.',
|
|
45
|
-
subagent_type: 'backend',
|
|
46
|
-
},
|
|
47
|
-
};
|
|
48
|
-
assert.ok(
|
|
49
|
-
isDeny(runGate(payload, { config: enabledConfig() })),
|
|
50
|
-
'delegationPromptOf reads description and the missing-test-matrix deny fires',
|
|
51
|
-
);
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
test('OK: the equivalent payload using `prompt` IS caught (control)', () => {
|
|
55
|
-
const payload = {
|
|
56
|
-
tool_name: 'Task',
|
|
57
|
-
tool_input: {
|
|
58
|
-
prompt: 'Nivel: STANDARD\nImplementá el cobro de la cuota.',
|
|
59
|
-
subagent_type: 'backend',
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
assert.ok(isDeny(runGate(payload, { config: enabledConfig() })), 'control failed');
|
|
63
|
-
});
|