cabloy 5.1.191 → 5.1.192
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/.cabloy-version +1 -1
- package/.github/workflows/agent-governance.yml +39 -0
- package/.github/workflows/docs-pages.yml +1 -0
- package/.gitignore +3 -0
- package/CHANGELOG.md +7 -0
- package/package.json +11 -5
- package/repo-agent-governance/adapters/claude/contract-loop-gate.ts +199 -0
- package/{.claude → repo-agent-governance}/commands/release.md +20 -20
- package/repo-agent-governance/managed-assets.json +887 -0
- package/repo-agent-governance/manifest.json +56 -0
- package/{CLAUDE.md → repo-agent-governance/policies/repository.md} +4 -2
- package/repo-agent-governance/scripts/governance.mjs +570 -0
- package/repo-agent-governance/scripts/pack-check.mjs +43 -0
- package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/contract-loop-map.md +4 -4
- package/{.claude → repo-agent-governance}/skills/cabloy-master-detail/SKILL.md +1 -1
- package/{.claude → repo-agent-governance}/skills/cabloy-module-removal/SKILL.md +1 -1
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/SKILL.md +1 -1
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/references/execution-protocol.md +8 -8
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/references/status-and-evidence.md +9 -9
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/SKILL.md +2 -2
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/repo-aware-discovery.md +7 -7
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/repo-specs-document-set.md +30 -30
- package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/references/traceability-and-status-rules.md +9 -9
- package/{.claude → repo-agent-governance}/skills/cabloy-workflow/SKILL.md +7 -8
- package/{.claude → repo-agent-governance}/skills/cabloy-worktree-environment/SKILL.md +9 -9
- package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/SKILL.md +5 -5
- package/repo-agent-governance/tests/governance.test.mjs +295 -0
- package/repo-agent-governance/tools/contract-loop/check.mjs +71 -0
- package/repo-agent-governance/tools/contract-loop/core.mjs +189 -0
- package/repo-agent-governance/tools/spec-charts/generate-implementation-charts.mjs +858 -0
- package/{.claude/skills/cabloy-spec-generation/scripts → repo-agent-governance/tools/spec-charts}/generate-implementation-charts.test.mjs +95 -20
- package/repo-docs/.vitepress/config.mjs +1 -0
- package/repo-docs/ai/agent-governance.md +80 -0
- package/repo-docs/ai/cli-to-skill-map.md +3 -3
- package/repo-docs/ai/docs-skills-rules-mapping.md +27 -26
- package/repo-docs/ai/edition-consistency-checklist.md +8 -6
- package/repo-docs/ai/future-skill-roadmap.md +2 -2
- package/repo-docs/ai/introduction.md +7 -6
- package/repo-docs/ai/repo-guidance.md +3 -2
- package/repo-docs/ai/rules-and-config.md +23 -15
- package/repo-docs/ai/skills.md +7 -7
- package/repo-docs/ai/verification.md +2 -1
- package/repo-docs/fullstack/contract-loop-playbook.md +1 -1
- package/repo-docs/fullstack/quickstart.md +1 -1
- package/repo-docs/reference/package-map.md +3 -2
- package/repo-docs/reference/repo-scripts.md +17 -2
- package/scripts/bootstrapAgentGovernance.mjs +81 -0
- package/scripts/init.ts +1 -0
- package/scripts/release.ts +15 -0
- package/scripts/upgrade.ts +66 -7
- package/zova/pnpm-lock.yaml +3 -3
- package/.claude/hooks/contract-loop-gate.ts +0 -421
- package/.claude/scheduled_tasks.lock +0 -1
- package/.claude/skills/cabloy-spec-generation/scripts/generate-implementation-charts.mjs +0 -385
- /package/{.claude → repo-agent-governance/adapters/claude}/settings.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/references/backend-thread-map.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-backend-scaffold/references/follow-up-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/resource-custom-state-pattern.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-contract-loop/references/verification-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-domain-planning/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/references/follow-up-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-frontend-scaffold/references/frontend-thread-map.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/SKILL.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/custom-renderer-demo-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/field-update-decision-tree.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/follow-up-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-resource-field-update/references/verification-checklist.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-spec-execution/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-spec-generation/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/references/cli-strategy.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-workflow/references/edition-detection.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-worktree-environment/evals/evals.json +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/references/analysis-modes.md +0 -0
- /package/{.claude → repo-agent-governance}/skills/cabloy-zova-source-reading/references/core-reading-paths.md +0 -0
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import {
|
|
4
|
+
cpSync,
|
|
5
|
+
existsSync,
|
|
6
|
+
mkdirSync,
|
|
7
|
+
mkdtempSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
rmSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { dirname, resolve } from 'node:path';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
|
|
16
|
+
import { check, GOVERNANCE_DIR, ROOT_DIR } from '../scripts/governance.mjs';
|
|
17
|
+
import {
|
|
18
|
+
analyze,
|
|
19
|
+
buildMessages,
|
|
20
|
+
isCodeFile,
|
|
21
|
+
resolveEdition,
|
|
22
|
+
} from '../tools/contract-loop/core.mjs';
|
|
23
|
+
|
|
24
|
+
function createFixture() {
|
|
25
|
+
const root = mkdtempSync(resolve(tmpdir(), 'cabloy-agent-governance-'));
|
|
26
|
+
cpSync(GOVERNANCE_DIR, resolve(root, 'repo-agent-governance'), { recursive: true });
|
|
27
|
+
writeFileSync(resolve(root, '__CABLOY_BASIC__'), '');
|
|
28
|
+
return root;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function runNode(root, executable, ...args) {
|
|
32
|
+
const result = spawnSync(process.execPath, [executable, ...args], {
|
|
33
|
+
cwd: root,
|
|
34
|
+
encoding: 'utf8',
|
|
35
|
+
env: {
|
|
36
|
+
...process.env,
|
|
37
|
+
CABLOY_GOVERNANCE_ROOT: root,
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
status: result.status,
|
|
42
|
+
output: `${result.stdout}${result.stderr}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function runGovernance(root, ...args) {
|
|
47
|
+
return runNode(root, resolve(root, 'repo-agent-governance/scripts/governance.mjs'), ...args);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function readState(root) {
|
|
51
|
+
return JSON.parse(readFileSync(resolve(root, '.cabloy-agent-governance-state.json'), 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function modifyPolicy(root, addition) {
|
|
55
|
+
const policyPath = resolve(root, 'repo-agent-governance/policies/repository.md');
|
|
56
|
+
writeFileSync(policyPath, `${readFileSync(policyPath, 'utf8').trimEnd()}\n\n${addition}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function removePolicyTarget(root, target) {
|
|
60
|
+
const manifestPath = resolve(root, 'repo-agent-governance/manifest.json');
|
|
61
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
62
|
+
manifest.policy.targets = manifest.policy.targets.filter(item => item.target !== target);
|
|
63
|
+
writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function withFixture(callback) {
|
|
67
|
+
const root = createFixture();
|
|
68
|
+
try {
|
|
69
|
+
callback(root);
|
|
70
|
+
} finally {
|
|
71
|
+
rmSync(root, { recursive: true, force: true });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
test('committed governance adapters match their canonical sources', () => {
|
|
76
|
+
const result = check();
|
|
77
|
+
assert.deepEqual(result.problems, []);
|
|
78
|
+
assert.ok(result.assets.length > 0);
|
|
79
|
+
assert.ok(existsSync(resolve(GOVERNANCE_DIR, 'managed-assets.json')));
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('all generated skill bundles retain their source identity', () => {
|
|
83
|
+
for (const asset of check().assets.filter(
|
|
84
|
+
asset => asset.category === 'skill' && asset.target.endsWith('/SKILL.md'),
|
|
85
|
+
)) {
|
|
86
|
+
const source = `${readFileSync(resolve(GOVERNANCE_DIR, asset.source), 'utf8')
|
|
87
|
+
.replace(/\r\n?/g, '\n')
|
|
88
|
+
.replace(/\n*$/, '')}\n`;
|
|
89
|
+
const target = readFileSync(resolve(ROOT_DIR, asset.target), 'utf8');
|
|
90
|
+
assert.equal(target, source, asset.target);
|
|
91
|
+
assert.match(target, /^---\nname: [^\n]+\ndescription: [^\n]+\n---\n/);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('governance adoption creates state and is idempotent', () => {
|
|
96
|
+
withFixture(root => {
|
|
97
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
98
|
+
const state = readState(root);
|
|
99
|
+
assert.ok(state.assets.length > 0);
|
|
100
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
101
|
+
assert.match(runGovernance(root, 'adopt', '--apply').output, /No governance adoption changes/);
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('initializer adopts canonical adapters for a fresh transition package', () => {
|
|
106
|
+
withFixture(root => {
|
|
107
|
+
const scriptsDirectory = resolve(root, 'scripts');
|
|
108
|
+
mkdirSync(scriptsDirectory);
|
|
109
|
+
const bootstrap = resolve(ROOT_DIR, 'scripts/bootstrapAgentGovernance.mjs');
|
|
110
|
+
const fixtureBootstrap = resolve(scriptsDirectory, 'bootstrapAgentGovernance.mjs');
|
|
111
|
+
const content = readFileSync(bootstrap, 'utf8').replace(
|
|
112
|
+
"const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');",
|
|
113
|
+
'const ROOT_DIR = process.env.CABLOY_GOVERNANCE_ROOT;',
|
|
114
|
+
);
|
|
115
|
+
writeFileSync(fixtureBootstrap, content);
|
|
116
|
+
const result = runNode(root, fixtureBootstrap);
|
|
117
|
+
assert.equal(result.status, 0, result.output);
|
|
118
|
+
assert.ok(existsSync(resolve(root, 'CLAUDE.md')));
|
|
119
|
+
assert.equal(readState(root).assets.length, check().assets.length);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('initializer preserves a legacy-customized adapter during a staged upgrade', () => {
|
|
124
|
+
withFixture(root => {
|
|
125
|
+
const scriptsDirectory = resolve(root, 'scripts');
|
|
126
|
+
mkdirSync(scriptsDirectory);
|
|
127
|
+
const bootstrap = resolve(ROOT_DIR, 'scripts/bootstrapAgentGovernance.mjs');
|
|
128
|
+
const fixtureBootstrap = resolve(scriptsDirectory, 'bootstrapAgentGovernance.mjs');
|
|
129
|
+
const content = readFileSync(bootstrap, 'utf8').replace(
|
|
130
|
+
"const ROOT_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..');",
|
|
131
|
+
'const ROOT_DIR = process.env.CABLOY_GOVERNANCE_ROOT;',
|
|
132
|
+
);
|
|
133
|
+
writeFileSync(fixtureBootstrap, content);
|
|
134
|
+
writeFileSync(resolve(root, 'CLAUDE.md'), 'legacy project customization\n');
|
|
135
|
+
writeFileSync(resolve(root, 'package.json'), readFileSync(resolve(ROOT_DIR, 'package.json')));
|
|
136
|
+
|
|
137
|
+
const upgradeRoot = resolve(root, 'node_modules/.cabloy-upgrade');
|
|
138
|
+
cpSync(GOVERNANCE_DIR, resolve(upgradeRoot, 'repo-agent-governance'), { recursive: true });
|
|
139
|
+
writeFileSync(
|
|
140
|
+
resolve(upgradeRoot, 'package.json'),
|
|
141
|
+
readFileSync(resolve(ROOT_DIR, 'package.json')),
|
|
142
|
+
);
|
|
143
|
+
modifyPolicy(upgradeRoot, 'Staged governance source.');
|
|
144
|
+
|
|
145
|
+
const result = runNode(root, fixtureBootstrap);
|
|
146
|
+
assert.equal(result.status, 0, result.output);
|
|
147
|
+
assert.match(result.output, /locally modified or legacy adapter outputs/);
|
|
148
|
+
assert.equal(
|
|
149
|
+
readFileSync(resolve(root, 'CLAUDE.md'), 'utf8'),
|
|
150
|
+
'legacy project customization\n',
|
|
151
|
+
);
|
|
152
|
+
assert.match(
|
|
153
|
+
readFileSync(resolve(root, 'repo-agent-governance/policies/repository.md'), 'utf8'),
|
|
154
|
+
/Staged governance source/,
|
|
155
|
+
);
|
|
156
|
+
assert.match(readFileSync(resolve(root, 'AGENTS.md'), 'utf8'), /Staged governance source/);
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test('governance adoption adopts byte-identical legacy outputs', () => {
|
|
161
|
+
withFixture(root => {
|
|
162
|
+
assert.equal(runGovernance(root, 'render').status, 0);
|
|
163
|
+
assert.equal(existsSync(resolve(root, '.cabloy-agent-governance-state.json')), false);
|
|
164
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
165
|
+
assert.equal(readState(root).assets.length, check().assets.length);
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('governance adoption preserves modified legacy outputs and records other ownership', () => {
|
|
170
|
+
withFixture(root => {
|
|
171
|
+
const policyPath = resolve(root, 'CLAUDE.md');
|
|
172
|
+
writeFileSync(policyPath, 'project-owned policy\n');
|
|
173
|
+
const result = runGovernance(root, 'adopt', '--apply');
|
|
174
|
+
assert.equal(result.status, 2);
|
|
175
|
+
assert.match(result.output, /conflicts: CLAUDE\.md/);
|
|
176
|
+
assert.equal(readFileSync(policyPath, 'utf8'), 'project-owned policy\n');
|
|
177
|
+
const state = readState(root);
|
|
178
|
+
assert.equal(
|
|
179
|
+
state.assets.some(asset => asset.target === 'CLAUDE.md'),
|
|
180
|
+
false,
|
|
181
|
+
);
|
|
182
|
+
assert.equal(
|
|
183
|
+
state.assets.some(asset => asset.target === 'AGENTS.md'),
|
|
184
|
+
true,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('governance adoption updates unchanged owned outputs and preserves later user edits', () => {
|
|
190
|
+
withFixture(root => {
|
|
191
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
192
|
+
modifyPolicy(root, 'Fixture-owned change.');
|
|
193
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
194
|
+
assert.match(readFileSync(resolve(root, 'CLAUDE.md'), 'utf8'), /Fixture-owned change/);
|
|
195
|
+
|
|
196
|
+
writeFileSync(resolve(root, 'CLAUDE.md'), 'locally customized adapter\n');
|
|
197
|
+
modifyPolicy(root, 'A newer canonical change.');
|
|
198
|
+
const result = runGovernance(root, 'adopt', '--apply');
|
|
199
|
+
assert.equal(result.status, 2);
|
|
200
|
+
assert.match(result.output, /conflicts: CLAUDE\.md/);
|
|
201
|
+
assert.equal(readFileSync(resolve(root, 'CLAUDE.md'), 'utf8'), 'locally customized adapter\n');
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('governance adoption deletes only unchanged retired outputs', () => {
|
|
206
|
+
withFixture(root => {
|
|
207
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
208
|
+
removePolicyTarget(root, 'AGENTS.md');
|
|
209
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
210
|
+
assert.equal(existsSync(resolve(root, 'AGENTS.md')), false);
|
|
211
|
+
assert.equal(
|
|
212
|
+
readState(root).assets.some(asset => asset.target === 'AGENTS.md'),
|
|
213
|
+
false,
|
|
214
|
+
);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('governance adoption preserves modified retired outputs', () => {
|
|
219
|
+
withFixture(root => {
|
|
220
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
221
|
+
writeFileSync(resolve(root, 'AGENTS.md'), 'locally customized retired adapter\n');
|
|
222
|
+
removePolicyTarget(root, 'AGENTS.md');
|
|
223
|
+
const result = runGovernance(root, 'adopt', '--apply');
|
|
224
|
+
assert.equal(result.status, 2);
|
|
225
|
+
assert.match(result.output, /conflicts: AGENTS\.md/);
|
|
226
|
+
assert.equal(
|
|
227
|
+
readFileSync(resolve(root, 'AGENTS.md'), 'utf8'),
|
|
228
|
+
'locally customized retired adapter\n',
|
|
229
|
+
);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('governance adoption preserves unmanaged siblings and rejects malformed state', () => {
|
|
234
|
+
withFixture(root => {
|
|
235
|
+
const siblingPath = resolve(root, '.claude/skills/project-local/SKILL.md');
|
|
236
|
+
mkdirSync(dirname(siblingPath), { recursive: true });
|
|
237
|
+
writeFileSync(siblingPath, 'project-local skill\n');
|
|
238
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 0);
|
|
239
|
+
assert.equal(readFileSync(siblingPath, 'utf8'), 'project-local skill\n');
|
|
240
|
+
|
|
241
|
+
writeFileSync(
|
|
242
|
+
resolve(root, '.cabloy-agent-governance-state.json'),
|
|
243
|
+
'{"schemaVersion":1,"assets":[{"target":"../../outside","sha256":"bad"}]}\n',
|
|
244
|
+
);
|
|
245
|
+
const result = runGovernance(root, 'adopt', '--apply');
|
|
246
|
+
assert.equal(result.status, 1);
|
|
247
|
+
assert.match(result.output, /Unable to parse governance installer state/);
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('governance adoption force-updates one explicitly named target', () => {
|
|
252
|
+
withFixture(root => {
|
|
253
|
+
writeFileSync(resolve(root, 'CLAUDE.md'), 'locally customized adapter\n');
|
|
254
|
+
assert.equal(runGovernance(root, 'adopt', '--apply').status, 2);
|
|
255
|
+
assert.equal(runGovernance(root, 'adopt', '--apply', '--force', 'CLAUDE.md').status, 0);
|
|
256
|
+
assert.notEqual(
|
|
257
|
+
readFileSync(resolve(root, 'CLAUDE.md'), 'utf8'),
|
|
258
|
+
'locally customized adapter\n',
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test('contract-loop analysis preserves forward and reverse guidance', () => {
|
|
264
|
+
const resolution = resolveEdition(ROOT_DIR);
|
|
265
|
+
assert.equal(resolution.kind, 'resolved');
|
|
266
|
+
const forward = analyze(
|
|
267
|
+
'/repo/vona/src/module/demo/src/controller/demo.ts',
|
|
268
|
+
'@Api.field()',
|
|
269
|
+
resolution.edition,
|
|
270
|
+
);
|
|
271
|
+
assert.equal(forward.forwardReason, 'Backend contract source may have changed.');
|
|
272
|
+
assert.equal(forward.reverseReason, null);
|
|
273
|
+
const reverse = analyze(
|
|
274
|
+
'/repo/zova/src/suite/demo/modules/demo/src/.metadata/index.ts',
|
|
275
|
+
'',
|
|
276
|
+
resolution.edition,
|
|
277
|
+
);
|
|
278
|
+
assert.equal(reverse.forwardReason, null);
|
|
279
|
+
assert.equal(
|
|
280
|
+
reverse.reverseReason,
|
|
281
|
+
'Frontend-owned resources or metadata may affect backend consumers.',
|
|
282
|
+
);
|
|
283
|
+
assert.match(buildMessages(reverse, resolution), /npm run build:zova:admin/);
|
|
284
|
+
assert.match(buildMessages(reverse, resolution), /npm run deps:vona/);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('contract-loop recognizes supported vendor and suite source paths', () => {
|
|
288
|
+
assert.equal(isCodeFile('/repo/zova/src/module-vendor/demo/src/bean/demo.ts'), true);
|
|
289
|
+
assert.equal(
|
|
290
|
+
isCodeFile('/repo/zova/src/suite-vendor/demo/modules/demo/src/component/demo.tsx'),
|
|
291
|
+
true,
|
|
292
|
+
);
|
|
293
|
+
assert.equal(isCodeFile('/repo/vona/src/module/demo/src/service/demo.ts'), true);
|
|
294
|
+
assert.equal(isCodeFile('/repo/repo-docs/ai/agent-governance.md'), false);
|
|
295
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
analyze,
|
|
7
|
+
buildMessages,
|
|
8
|
+
hasSignal,
|
|
9
|
+
isCodeFile,
|
|
10
|
+
normalizePath,
|
|
11
|
+
resolveEdition,
|
|
12
|
+
} from './core.mjs';
|
|
13
|
+
|
|
14
|
+
const SCRIPT_FILE = fileURLToPath(import.meta.url);
|
|
15
|
+
const ROOT_DIR = resolve(dirname(SCRIPT_FILE), '..', '..', '..');
|
|
16
|
+
|
|
17
|
+
function parseArgs(args) {
|
|
18
|
+
let file;
|
|
19
|
+
let format = 'text';
|
|
20
|
+
for (let index = 0; index < args.length; index++) {
|
|
21
|
+
if (args[index] === '--file') {
|
|
22
|
+
file = args[++index];
|
|
23
|
+
} else if (args[index] === '--format') {
|
|
24
|
+
format = args[++index];
|
|
25
|
+
} else {
|
|
26
|
+
throw new Error(`Unknown argument: ${args[index]}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!file) throw new Error('Usage: contract-loop/check.mjs --file <path> [--format text|json]');
|
|
30
|
+
if (!['text', 'json'].includes(format)) throw new Error(`Unsupported format: ${format}`);
|
|
31
|
+
return { file, format };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function main() {
|
|
35
|
+
const { file, format } = parseArgs(process.argv.slice(2));
|
|
36
|
+
const filePath = normalizePath(ROOT_DIR, file);
|
|
37
|
+
if (!filePath || !isCodeFile(filePath)) {
|
|
38
|
+
throw new Error(`File is not a supported Vona/Zova source path: ${file}`);
|
|
39
|
+
}
|
|
40
|
+
const content = readFileSync(filePath, 'utf8');
|
|
41
|
+
const resolution = resolveEdition(ROOT_DIR);
|
|
42
|
+
const result = analyze(
|
|
43
|
+
filePath,
|
|
44
|
+
content,
|
|
45
|
+
resolution.kind === 'resolved' ? resolution.edition : null,
|
|
46
|
+
);
|
|
47
|
+
const output = {
|
|
48
|
+
edition: resolution.kind === 'resolved' ? resolution.edition.id : resolution.kind,
|
|
49
|
+
file: filePath,
|
|
50
|
+
signal: hasSignal(result),
|
|
51
|
+
...result,
|
|
52
|
+
message: hasSignal(result) ? buildMessages(result, resolution) : null,
|
|
53
|
+
};
|
|
54
|
+
if (format === 'json') {
|
|
55
|
+
// eslint-disable-next-line no-console
|
|
56
|
+
console.log(JSON.stringify(output, null, 2));
|
|
57
|
+
} else if (output.message) {
|
|
58
|
+
// eslint-disable-next-line no-console
|
|
59
|
+
console.log(output.message);
|
|
60
|
+
} else {
|
|
61
|
+
// eslint-disable-next-line no-console
|
|
62
|
+
console.log(`Contract-loop check: no signal for ${file}.`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
main();
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error(`Contract-loop check failed: ${error.message}`);
|
|
70
|
+
process.exitCode = 1;
|
|
71
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
export const TARGET_PATTERNS = [
|
|
5
|
+
['zova/src/module/', '.ts'],
|
|
6
|
+
['zova/src/module/', '.tsx'],
|
|
7
|
+
['zova/src/module/', '.jsx'],
|
|
8
|
+
['zova/src/module-vendor/', '.ts'],
|
|
9
|
+
['zova/src/module-vendor/', '.tsx'],
|
|
10
|
+
['zova/src/module-vendor/', '.jsx'],
|
|
11
|
+
['zova/src/suite/', '.ts'],
|
|
12
|
+
['zova/src/suite/', '.tsx'],
|
|
13
|
+
['zova/src/suite/', '.jsx'],
|
|
14
|
+
['zova/src/suite-vendor/', '.ts'],
|
|
15
|
+
['zova/src/suite-vendor/', '.tsx'],
|
|
16
|
+
['zova/src/suite-vendor/', '.jsx'],
|
|
17
|
+
['vona/src/', '.ts'],
|
|
18
|
+
['vona/src/', '.tsx'],
|
|
19
|
+
['vona/src/', '.jsx'],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const FORWARD_PATH_MARKERS = ['/controller/', '/dto/', '/entity/'];
|
|
23
|
+
const FORWARD_CONTENT_MARKERS = ['@Web.', '@Api.field', '@Api.body', 'v.openapi(', '@Dto<'];
|
|
24
|
+
const SHARED_REVERSE_VONA_CONTENT_MARKERS = ['ZovaRender.', 'tableActionRow(', 'tableActionBulk('];
|
|
25
|
+
const REVERSE_ZOVA_PATH_MARKERS = ['/src/bean/', '/src/component/', '/src/.metadata/'];
|
|
26
|
+
const REVERSE_ZOVA_CONTENT_MARKERS = [
|
|
27
|
+
"declare module 'zova-module-a-openapi'",
|
|
28
|
+
'IResourceTableActionRowRecord',
|
|
29
|
+
'@TableCell<',
|
|
30
|
+
'@Component(',
|
|
31
|
+
'@Component<',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
export const REVERSE_AUTO_SYNC_COMMANDS = [
|
|
35
|
+
{ args: ['run', 'build:zova:admin'], display: 'npm run build:zova:admin' },
|
|
36
|
+
{ args: ['run', 'deps:vona'], display: 'npm run deps:vona' },
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export const EDITION_CONFIGS = {
|
|
40
|
+
basic: {
|
|
41
|
+
id: 'basic',
|
|
42
|
+
label: 'Cabloy Basic',
|
|
43
|
+
reverseVonaContentMarkers: ['zova-rest-cabloy-basic-admin'],
|
|
44
|
+
reverseAutoSyncCommands: REVERSE_AUTO_SYNC_COMMANDS,
|
|
45
|
+
reverseWebBuildCommand: 'npm run build:zova:web',
|
|
46
|
+
},
|
|
47
|
+
start: {
|
|
48
|
+
id: 'start',
|
|
49
|
+
label: 'Cabloy Start',
|
|
50
|
+
reverseVonaContentMarkers: ['zova-rest-cabloy-start-admin'],
|
|
51
|
+
reverseAutoSyncCommands: REVERSE_AUTO_SYNC_COMMANDS,
|
|
52
|
+
reverseWebBuildCommand: 'npm run build:zova:web',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const FALLBACK_REVERSE_VONA_CONTENT_MARKERS = [
|
|
57
|
+
...SHARED_REVERSE_VONA_CONTENT_MARKERS,
|
|
58
|
+
...EDITION_CONFIGS.basic.reverseVonaContentMarkers,
|
|
59
|
+
...EDITION_CONFIGS.start.reverseVonaContentMarkers,
|
|
60
|
+
];
|
|
61
|
+
|
|
62
|
+
export function toPosixPath(value) {
|
|
63
|
+
return value.split(path.sep).join('/');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function normalizePath(root, value) {
|
|
67
|
+
if (!value) return null;
|
|
68
|
+
try {
|
|
69
|
+
const candidate = path.isAbsolute(value) ? value : path.join(root, value);
|
|
70
|
+
return toPosixPath(path.resolve(candidate));
|
|
71
|
+
} catch {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function resolveEdition(root) {
|
|
77
|
+
const hasBasic = existsSync(path.resolve(root, '__CABLOY_BASIC__'));
|
|
78
|
+
const hasStart = existsSync(path.resolve(root, '__CABLOY_START__'));
|
|
79
|
+
if (hasBasic && hasStart) return { kind: 'ambiguous' };
|
|
80
|
+
if (!hasBasic && !hasStart) return { kind: 'missing' };
|
|
81
|
+
return { kind: 'resolved', edition: hasBasic ? EDITION_CONFIGS.basic : EDITION_CONFIGS.start };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function containsAny(text, needles) {
|
|
85
|
+
return needles.some(needle => text.includes(needle));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function pathContainsAny(filePath, needles) {
|
|
89
|
+
return needles.some(needle => filePath.includes(needle));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getReverseVonaContentMarkers(edition) {
|
|
93
|
+
if (!edition) return FALLBACK_REVERSE_VONA_CONTENT_MARKERS;
|
|
94
|
+
return [...SHARED_REVERSE_VONA_CONTENT_MARKERS, ...edition.reverseVonaContentMarkers];
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function isCodeFile(filePath) {
|
|
98
|
+
return TARGET_PATTERNS.some(
|
|
99
|
+
([prefix, suffix]) => filePath.includes(prefix) && filePath.endsWith(suffix),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function detectForward(filePath, content) {
|
|
104
|
+
if (!filePath.includes('/vona/src/')) return null;
|
|
105
|
+
if (
|
|
106
|
+
pathContainsAny(filePath, FORWARD_PATH_MARKERS) ||
|
|
107
|
+
containsAny(content, FORWARD_CONTENT_MARKERS)
|
|
108
|
+
) {
|
|
109
|
+
return 'Backend contract source may have changed.';
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function detectReverse(filePath, content, reverseVonaContentMarkers) {
|
|
115
|
+
if (filePath.includes('/vona/src/') && containsAny(content, reverseVonaContentMarkers)) {
|
|
116
|
+
return 'Vona code is consuming frontend metadata or render resources.';
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
filePath.includes('/zova/src/') &&
|
|
120
|
+
(pathContainsAny(filePath, REVERSE_ZOVA_PATH_MARKERS) ||
|
|
121
|
+
containsAny(content, REVERSE_ZOVA_CONTENT_MARKERS))
|
|
122
|
+
) {
|
|
123
|
+
return 'Frontend-owned resources or metadata may affect backend consumers.';
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function analyze(filePath, content, edition) {
|
|
129
|
+
return {
|
|
130
|
+
forwardReason: detectForward(filePath, content),
|
|
131
|
+
reverseReason: detectReverse(filePath, content, getReverseVonaContentMarkers(edition)),
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function hasSignal(result) {
|
|
136
|
+
return Boolean(result.forwardReason || result.reverseReason);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function isHighConfidenceReverseSource(filePath) {
|
|
140
|
+
return filePath.includes('/zova/src/') && pathContainsAny(filePath, REVERSE_ZOVA_PATH_MARKERS);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function buildReverseGuidance(resolution) {
|
|
144
|
+
if (resolution.kind !== 'resolved') {
|
|
145
|
+
const resolutionMessage =
|
|
146
|
+
resolution.kind === 'ambiguous'
|
|
147
|
+
? 'Both Cabloy edition markers are present, so stop before choosing an edition-specific build or generated-output path.'
|
|
148
|
+
: 'No Cabloy edition marker is present, so inspect the active package scripts and repository shape before choosing an edition-specific build or generated-output path.';
|
|
149
|
+
return `If backend tooling or backend metadata will consume this handoff, refresh generated metadata when applicable. ${resolutionMessage} Then run \`npm run deps:vona\` once the active edition is resolved.`;
|
|
150
|
+
}
|
|
151
|
+
const edition = resolution.edition;
|
|
152
|
+
const autoSyncCommands = edition.reverseAutoSyncCommands
|
|
153
|
+
.map(command => `\`${command.display}\``)
|
|
154
|
+
.join(', then ');
|
|
155
|
+
return `If backend tooling or backend metadata will consume this handoff, refresh generated metadata when applicable, run ${autoSyncCommands}, and also run \`${edition.reverseWebBuildCommand}\` when the Web flavor is affected in this ${edition.label} repo.`;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function buildMessages(
|
|
159
|
+
result,
|
|
160
|
+
resolution,
|
|
161
|
+
reverseSyncOutcome = { kind: 'not-applicable', message: '' },
|
|
162
|
+
) {
|
|
163
|
+
const messages = [
|
|
164
|
+
"Contract-loop gate: this change may affect Cabloy's bidirectional contract loop.",
|
|
165
|
+
];
|
|
166
|
+
if (resolution.kind === 'ambiguous') {
|
|
167
|
+
messages.push(
|
|
168
|
+
'Both Cabloy edition markers are present, so stop before choosing an edition-specific build, generated-output path, or auto-sync action.',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
if (result.forwardReason) {
|
|
172
|
+
messages.push(
|
|
173
|
+
`Forward chain: ${result.forwardReason} If backend contract truth changed, verify the emitted OpenAPI/contract output and regenerate the frontend consumer path before considering the task done.`,
|
|
174
|
+
);
|
|
175
|
+
messages.push(
|
|
176
|
+
'After forward regeneration, keep frontend follow-up thin: prefer semantic model facades and reuse the existing resource-owner when the custom API still belongs to the same resource.',
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (result.reverseReason) {
|
|
180
|
+
messages.push(`Reverse chain: ${result.reverseReason} ${buildReverseGuidance(resolution)}`);
|
|
181
|
+
if (reverseSyncOutcome.message) messages.push(reverseSyncOutcome.message);
|
|
182
|
+
if (reverseSyncOutcome.kind === 'failure') {
|
|
183
|
+
messages.push(
|
|
184
|
+
'Please review the failure before continuing. If generated artifacts already contain the expected changes but consumers still behave stale, suspect local dependency drift before making more source edits.',
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return messages.join(' ');
|
|
189
|
+
}
|