@urielsh/prodify 0.1.1 → 0.1.3
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/.prodify/contracts/architecture.contract.json +9 -1
- package/.prodify/contracts/diagnose.contract.json +9 -1
- package/.prodify/contracts/plan.contract.json +9 -1
- package/.prodify/contracts/refactor.contract.json +13 -2
- package/.prodify/contracts/understand.contract.json +9 -1
- package/.prodify/contracts/validate.contract.json +11 -2
- package/.prodify/contracts-src/refactor.contract.md +7 -0
- package/.prodify/contracts-src/validate.contract.md +2 -0
- package/README.md +4 -2
- package/assets/presets/default/canonical/contracts-src/refactor.contract.md +7 -0
- package/assets/presets/default/canonical/contracts-src/validate.contract.md +2 -0
- package/dist/commands/setup-agent.js +3 -0
- package/dist/contracts/compiled-schema.js +10 -1
- package/dist/contracts/source-schema.js +42 -1
- package/dist/core/agent-setup.js +116 -1
- package/dist/core/diff-validator.js +183 -0
- package/dist/core/plan-units.js +64 -0
- package/dist/core/repo-root.js +17 -8
- package/dist/core/state.js +6 -0
- package/dist/core/status.js +14 -0
- package/dist/core/validation.js +87 -9
- package/dist/scoring/model.js +94 -213
- package/dist/scoring/scoring-engine.js +158 -0
- package/docs/diff-validator-design.md +44 -0
- package/docs/impact-scoring-design.md +38 -0
- package/package.json +1 -1
- package/src/commands/setup-agent.ts +3 -0
- package/src/contracts/compiled-schema.ts +10 -1
- package/src/contracts/source-schema.ts +51 -1
- package/src/core/agent-setup.ts +126 -2
- package/src/core/diff-validator.ts +230 -0
- package/src/core/plan-units.ts +82 -0
- package/src/core/repo-root.ts +21 -8
- package/src/core/state.ts +6 -0
- package/src/core/status.ts +17 -0
- package/src/core/validation.ts +136 -15
- package/src/scoring/model.ts +101 -250
- package/src/scoring/scoring-engine.ts +194 -0
- package/src/types.ts +55 -0
- package/tests/integration/cli-flows.test.js +19 -0
- package/tests/unit/agent-setup.test.js +9 -3
- package/tests/unit/diff-validator.test.js +28 -0
- package/tests/unit/scoring.test.js +42 -1
- package/tests/unit/validation.test.js +79 -1
package/dist/scoring/model.js
CHANGED
|
@@ -1,237 +1,87 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import { inspectCompiledContracts } from '../contracts/freshness.js';
|
|
4
3
|
import { ProdifyError } from '../core/errors.js';
|
|
5
|
-
import {
|
|
4
|
+
import { pathExists, writeFileEnsuringDir } from '../core/fs.js';
|
|
6
5
|
import { resolveRepoPath } from '../core/paths.js';
|
|
7
|
-
|
|
6
|
+
import { calculateRepositoryQuality } from './scoring-engine.js';
|
|
7
|
+
const SCORE_SCHEMA_VERSION = '2';
|
|
8
8
|
function roundScore(value) {
|
|
9
9
|
return Math.round(value * 100) / 100;
|
|
10
10
|
}
|
|
11
|
-
function
|
|
12
|
-
|
|
13
|
-
const points = roundScore(options.weight * ratio);
|
|
14
|
-
return {
|
|
15
|
-
id: options.id,
|
|
16
|
-
label: options.label,
|
|
17
|
-
tool: options.tool,
|
|
18
|
-
weight: options.weight,
|
|
19
|
-
max_points: options.weight,
|
|
20
|
-
points,
|
|
21
|
-
status: ratio === 1 ? 'pass' : ratio === 0 ? 'fail' : 'partial',
|
|
22
|
-
details: options.details
|
|
23
|
-
};
|
|
24
|
-
}
|
|
25
|
-
async function detectEcosystems(repoRoot) {
|
|
26
|
-
const ecosystems = [];
|
|
27
|
-
if (await pathExists(resolveRepoPath(repoRoot, 'package.json'))) {
|
|
28
|
-
ecosystems.push('typescript-javascript');
|
|
29
|
-
}
|
|
30
|
-
const repoFiles = await listFilesRecursive(repoRoot);
|
|
31
|
-
if (repoFiles.some((file) => file.relativePath.endsWith('.py'))) {
|
|
32
|
-
ecosystems.push('python');
|
|
33
|
-
}
|
|
34
|
-
if (repoFiles.some((file) => file.relativePath.endsWith('.cs') || file.relativePath.endsWith('.csproj') || file.relativePath.endsWith('.sln'))) {
|
|
35
|
-
ecosystems.push('csharp');
|
|
36
|
-
}
|
|
37
|
-
return ecosystems.sort((left, right) => left.localeCompare(right));
|
|
11
|
+
function serializeJson(value) {
|
|
12
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
38
13
|
}
|
|
39
|
-
async function
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (ecosystems.includes('typescript-javascript')) {
|
|
43
|
-
const packageJsonPath = resolveRepoPath(repoRoot, 'package.json');
|
|
44
|
-
const tsconfigPath = resolveRepoPath(repoRoot, 'tsconfig.json');
|
|
45
|
-
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));
|
|
46
|
-
const scripts = packageJson.scripts ?? {};
|
|
47
|
-
const hasBuild = typeof scripts.build === 'string' && scripts.build.length > 0;
|
|
48
|
-
const hasTest = typeof scripts.test === 'string' && scripts.test.length > 0;
|
|
49
|
-
const hasTsconfig = await pathExists(tsconfigPath);
|
|
50
|
-
const score = [hasBuild, hasTest, hasTsconfig].filter(Boolean).length / 3;
|
|
51
|
-
metrics.push(createMetric({
|
|
52
|
-
id: 'typescript-javascript',
|
|
53
|
-
label: 'TypeScript/JavaScript local tooling',
|
|
54
|
-
tool: 'package-json',
|
|
55
|
-
weight: 15,
|
|
56
|
-
ratio: score,
|
|
57
|
-
details: `build=${hasBuild}, test=${hasTest}, tsconfig=${hasTsconfig}`
|
|
58
|
-
}));
|
|
59
|
-
toolOutputs.push({
|
|
60
|
-
adapter: 'typescript-javascript',
|
|
61
|
-
details: {
|
|
62
|
-
build_script: hasBuild,
|
|
63
|
-
test_script: hasTest,
|
|
64
|
-
tsconfig: hasTsconfig
|
|
65
|
-
}
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
if (ecosystems.includes('python')) {
|
|
69
|
-
const hasPyproject = await pathExists(resolveRepoPath(repoRoot, 'pyproject.toml'));
|
|
70
|
-
const hasRequirements = await pathExists(resolveRepoPath(repoRoot, 'requirements.txt'));
|
|
71
|
-
const ratio = [hasPyproject, hasRequirements].filter(Boolean).length / 2;
|
|
72
|
-
metrics.push(createMetric({
|
|
73
|
-
id: 'python',
|
|
74
|
-
label: 'Python local tooling',
|
|
75
|
-
tool: 'filesystem',
|
|
76
|
-
weight: 7.5,
|
|
77
|
-
ratio,
|
|
78
|
-
details: `pyproject=${hasPyproject}, requirements=${hasRequirements}`
|
|
79
|
-
}));
|
|
80
|
-
toolOutputs.push({
|
|
81
|
-
adapter: 'python',
|
|
82
|
-
details: {
|
|
83
|
-
pyproject: hasPyproject,
|
|
84
|
-
requirements: hasRequirements
|
|
85
|
-
}
|
|
86
|
-
});
|
|
87
|
-
}
|
|
88
|
-
if (ecosystems.includes('csharp')) {
|
|
89
|
-
const repoFiles = await listFilesRecursive(repoRoot);
|
|
90
|
-
const hasSolution = repoFiles.some((file) => file.relativePath.endsWith('.sln'));
|
|
91
|
-
const hasProject = repoFiles.some((file) => file.relativePath.endsWith('.csproj'));
|
|
92
|
-
const ratio = [hasSolution, hasProject].filter(Boolean).length / 2;
|
|
93
|
-
metrics.push(createMetric({
|
|
94
|
-
id: 'csharp',
|
|
95
|
-
label: 'C# local tooling',
|
|
96
|
-
tool: 'filesystem',
|
|
97
|
-
weight: 7.5,
|
|
98
|
-
ratio,
|
|
99
|
-
details: `solution=${hasSolution}, project=${hasProject}`
|
|
100
|
-
}));
|
|
101
|
-
toolOutputs.push({
|
|
102
|
-
adapter: 'csharp',
|
|
103
|
-
details: {
|
|
104
|
-
solution: hasSolution,
|
|
105
|
-
project: hasProject
|
|
106
|
-
}
|
|
107
|
-
});
|
|
14
|
+
async function removeIfExists(targetPath) {
|
|
15
|
+
if (await pathExists(targetPath)) {
|
|
16
|
+
await fs.rm(targetPath);
|
|
108
17
|
}
|
|
109
|
-
return {
|
|
110
|
-
metrics,
|
|
111
|
-
toolOutputs
|
|
112
|
-
};
|
|
113
18
|
}
|
|
114
|
-
|
|
115
|
-
const
|
|
116
|
-
const available = await Promise.all(requiredSignals.map((relativePath) => pathExists(resolveRepoPath(repoRoot, relativePath))));
|
|
117
|
-
const ratio = available.filter(Boolean).length / requiredSignals.length;
|
|
19
|
+
function createMetric(label, points, maxPoints, details) {
|
|
20
|
+
const normalizedPoints = roundScore(points);
|
|
118
21
|
return {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
toolOutput: {
|
|
128
|
-
adapter: 'filesystem',
|
|
129
|
-
details: Object.fromEntries(requiredSignals.map((entry, index) => [entry, available[index]]))
|
|
130
|
-
}
|
|
22
|
+
id: label.toLowerCase().replace(/\s+/g, '-'),
|
|
23
|
+
label,
|
|
24
|
+
tool: 'scoring-engine',
|
|
25
|
+
weight: maxPoints,
|
|
26
|
+
max_points: maxPoints,
|
|
27
|
+
points: normalizedPoints,
|
|
28
|
+
status: normalizedPoints >= maxPoints ? 'pass' : normalizedPoints <= 0 ? 'fail' : 'partial',
|
|
29
|
+
details
|
|
131
30
|
};
|
|
132
31
|
}
|
|
133
|
-
function
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
tool: 'state-json',
|
|
141
|
-
weight: 25,
|
|
142
|
-
ratio,
|
|
143
|
-
details: `current_state=${runtimeState.runtime.current_state}, status=${runtimeState.runtime.status}`
|
|
144
|
-
}),
|
|
145
|
-
toolOutput: {
|
|
146
|
-
adapter: 'state-json',
|
|
147
|
-
details: {
|
|
148
|
-
current_state: runtimeState.runtime.current_state,
|
|
149
|
-
status: runtimeState.runtime.status,
|
|
150
|
-
last_validation_result: runtimeState.runtime.last_validation_result
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
}
|
|
155
|
-
function buildValidationMetric(runtimeState) {
|
|
156
|
-
const passed = runtimeState.runtime.last_validation?.passed === true;
|
|
157
|
-
const finalReady = runtimeState.runtime.current_state === 'validate_complete' || runtimeState.runtime.current_state === 'completed';
|
|
158
|
-
const ratio = passed ? (finalReady ? 1 : 0.5) : 0;
|
|
159
|
-
return {
|
|
160
|
-
metric: createMetric({
|
|
161
|
-
id: 'validation-gate',
|
|
162
|
-
label: 'Validated contract completion',
|
|
163
|
-
tool: 'state-json',
|
|
164
|
-
weight: 10,
|
|
165
|
-
ratio,
|
|
166
|
-
details: `passed=${passed}, final_ready=${finalReady}`
|
|
167
|
-
}),
|
|
168
|
-
toolOutput: {
|
|
169
|
-
adapter: 'validation-gate',
|
|
170
|
-
details: {
|
|
171
|
-
passed,
|
|
172
|
-
final_ready: finalReady
|
|
173
|
-
}
|
|
174
|
-
}
|
|
175
|
-
};
|
|
32
|
+
function toSnapshotMetrics(score) {
|
|
33
|
+
return [
|
|
34
|
+
createMetric('Structure', score.breakdown.structure * (score.weights.structure / 100), score.weights.structure, `modules=${score.signals.module_count}, avg_directory_depth=${score.signals.average_directory_depth}`),
|
|
35
|
+
createMetric('Maintainability', score.breakdown.maintainability * (score.weights.maintainability / 100), score.weights.maintainability, `avg_function_length=${score.signals.average_function_length}`),
|
|
36
|
+
createMetric('Complexity', score.breakdown.complexity * (score.weights.complexity / 100), score.weights.complexity, `dependency_depth=${score.signals.dependency_depth}, avg_imports=${score.signals.average_imports_per_module}`),
|
|
37
|
+
createMetric('Testability', score.breakdown.testability * (score.weights.testability / 100), score.weights.testability, `test_file_ratio=${score.signals.test_file_ratio}`)
|
|
38
|
+
];
|
|
176
39
|
}
|
|
177
40
|
export async function calculateLocalScore(repoRoot, { kind, runtimeState }) {
|
|
178
|
-
const contractInventory = await inspectCompiledContracts(repoRoot);
|
|
179
|
-
const ecosystems = await detectEcosystems(repoRoot);
|
|
180
|
-
const metrics = [];
|
|
181
|
-
const toolOutputs = [];
|
|
182
|
-
metrics.push(createMetric({
|
|
183
|
-
id: 'contracts',
|
|
184
|
-
label: 'Compiled contract health',
|
|
185
|
-
tool: 'contract-compiler',
|
|
186
|
-
weight: 30,
|
|
187
|
-
ratio: contractInventory.ok ? 1 : Math.max(0, 1 - ((contractInventory.missingCompiledStages.length + contractInventory.staleStages.length) / 6)),
|
|
188
|
-
details: `source=${contractInventory.sourceCount}, compiled=${contractInventory.compiledCount}, stale=${contractInventory.staleStages.length}, missing=${contractInventory.missingCompiledStages.length}`
|
|
189
|
-
}));
|
|
190
|
-
toolOutputs.push({
|
|
191
|
-
adapter: 'contract-compiler',
|
|
192
|
-
details: {
|
|
193
|
-
ok: contractInventory.ok,
|
|
194
|
-
sourceCount: contractInventory.sourceCount,
|
|
195
|
-
compiledCount: contractInventory.compiledCount,
|
|
196
|
-
staleStages: contractInventory.staleStages,
|
|
197
|
-
missingCompiledStages: contractInventory.missingCompiledStages,
|
|
198
|
-
missingSourceStages: contractInventory.missingSourceStages,
|
|
199
|
-
invalidStages: contractInventory.invalidStages
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
const runtimeMetric = buildRuntimeMetric(runtimeState);
|
|
203
|
-
metrics.push(runtimeMetric.metric);
|
|
204
|
-
toolOutputs.push(runtimeMetric.toolOutput);
|
|
205
|
-
const validationMetric = buildValidationMetric(runtimeState);
|
|
206
|
-
metrics.push(validationMetric.metric);
|
|
207
|
-
toolOutputs.push(validationMetric.toolOutput);
|
|
208
|
-
const hygieneMetric = await buildRepoHygieneMetric(repoRoot);
|
|
209
|
-
metrics.push(hygieneMetric.metric);
|
|
210
|
-
toolOutputs.push(hygieneMetric.toolOutput);
|
|
211
|
-
const ecosystemMetrics = await buildEcosystemMetrics(repoRoot, ecosystems);
|
|
212
|
-
metrics.push(...ecosystemMetrics.metrics);
|
|
213
|
-
toolOutputs.push(...ecosystemMetrics.toolOutputs);
|
|
214
|
-
const totalScore = roundScore(metrics.reduce((sum, metric) => sum + metric.points, 0));
|
|
215
|
-
const maxScore = roundScore(metrics.reduce((sum, metric) => sum + metric.max_points, 0));
|
|
216
41
|
if (kind === 'final' && !(runtimeState.runtime.last_validation?.passed && (runtimeState.runtime.current_state === 'validate_complete' || runtimeState.runtime.current_state === 'completed'))) {
|
|
217
42
|
throw new ProdifyError('Final scoring requires a validated runtime state at validate_complete or completed.', {
|
|
218
43
|
code: 'SCORING_STATE_INVALID'
|
|
219
44
|
});
|
|
220
45
|
}
|
|
46
|
+
const quality = await calculateRepositoryQuality(repoRoot);
|
|
47
|
+
const snapshot = {
|
|
48
|
+
schema_version: SCORE_SCHEMA_VERSION,
|
|
49
|
+
kind,
|
|
50
|
+
ecosystems: ['repository'],
|
|
51
|
+
total_score: quality.total_score,
|
|
52
|
+
max_score: quality.max_score,
|
|
53
|
+
breakdown: quality.breakdown,
|
|
54
|
+
weights: quality.weights,
|
|
55
|
+
signals: quality.signals,
|
|
56
|
+
metrics: toSnapshotMetrics(quality)
|
|
57
|
+
};
|
|
221
58
|
return {
|
|
222
|
-
snapshot
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
59
|
+
snapshot,
|
|
60
|
+
toolOutputs: [{
|
|
61
|
+
adapter: 'scoring-engine',
|
|
62
|
+
details: {
|
|
63
|
+
kind,
|
|
64
|
+
breakdown: quality.breakdown,
|
|
65
|
+
weights: quality.weights,
|
|
66
|
+
signals: quality.signals
|
|
67
|
+
}
|
|
68
|
+
}]
|
|
231
69
|
};
|
|
232
70
|
}
|
|
233
|
-
function
|
|
234
|
-
|
|
71
|
+
export async function calculateCurrentImpactDelta(repoRoot) {
|
|
72
|
+
const metricsDir = resolveRepoPath(repoRoot, '.prodify/metrics');
|
|
73
|
+
const baselinePath = path.join(metricsDir, 'baseline.score.json');
|
|
74
|
+
if (!(await pathExists(baselinePath))) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
const baseline = JSON.parse(await fs.readFile(baselinePath, 'utf8'));
|
|
78
|
+
const current = await calculateRepositoryQuality(repoRoot);
|
|
79
|
+
return {
|
|
80
|
+
schema_version: SCORE_SCHEMA_VERSION,
|
|
81
|
+
baseline_score: baseline.total_score,
|
|
82
|
+
final_score: current.total_score,
|
|
83
|
+
delta: roundScore(current.total_score - baseline.total_score)
|
|
84
|
+
};
|
|
235
85
|
}
|
|
236
86
|
export async function writeScoreSnapshot(repoRoot, { kind, runtimeState }) {
|
|
237
87
|
const { snapshot, toolOutputs } = await calculateLocalScore(repoRoot, {
|
|
@@ -247,16 +97,47 @@ export async function writeScoreSnapshot(repoRoot, { kind, runtimeState }) {
|
|
|
247
97
|
}));
|
|
248
98
|
return snapshot;
|
|
249
99
|
}
|
|
250
|
-
export async function writeScoreDelta(repoRoot) {
|
|
100
|
+
export async function writeScoreDelta(repoRoot, options = {}) {
|
|
251
101
|
const metricsDir = resolveRepoPath(repoRoot, '.prodify/metrics');
|
|
252
102
|
const baseline = JSON.parse(await fs.readFile(path.join(metricsDir, 'baseline.score.json'), 'utf8'));
|
|
253
103
|
const final = JSON.parse(await fs.readFile(path.join(metricsDir, 'final.score.json'), 'utf8'));
|
|
104
|
+
const deltaValue = roundScore(final.total_score - baseline.total_score);
|
|
105
|
+
const threshold = options.minImpactScore;
|
|
254
106
|
const delta = {
|
|
255
107
|
schema_version: SCORE_SCHEMA_VERSION,
|
|
256
108
|
baseline_score: baseline.total_score,
|
|
257
109
|
final_score: final.total_score,
|
|
258
|
-
delta:
|
|
110
|
+
delta: deltaValue,
|
|
111
|
+
...(threshold !== undefined ? {
|
|
112
|
+
min_impact_score: threshold,
|
|
113
|
+
passed: deltaValue >= threshold
|
|
114
|
+
} : {})
|
|
259
115
|
};
|
|
260
116
|
await writeFileEnsuringDir(path.join(metricsDir, 'delta.json'), serializeJson(delta));
|
|
261
117
|
return delta;
|
|
262
118
|
}
|
|
119
|
+
export async function readScoreDelta(repoRoot) {
|
|
120
|
+
const deltaPath = resolveRepoPath(repoRoot, '.prodify/metrics/delta.json');
|
|
121
|
+
if (!(await pathExists(deltaPath))) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return JSON.parse(await fs.readFile(deltaPath, 'utf8'));
|
|
125
|
+
}
|
|
126
|
+
export async function syncScoreArtifactsForRuntimeState(repoRoot, runtimeState) {
|
|
127
|
+
if (runtimeState.runtime.current_state === 'bootstrapped' || runtimeState.runtime.current_state === 'understand_pending') {
|
|
128
|
+
await writeScoreSnapshot(repoRoot, {
|
|
129
|
+
kind: 'baseline',
|
|
130
|
+
runtimeState
|
|
131
|
+
});
|
|
132
|
+
await removeIfExists(resolveRepoPath(repoRoot, '.prodify/metrics/final.score.json'));
|
|
133
|
+
await removeIfExists(resolveRepoPath(repoRoot, '.prodify/metrics/final.tools.json'));
|
|
134
|
+
await removeIfExists(resolveRepoPath(repoRoot, '.prodify/metrics/delta.json'));
|
|
135
|
+
}
|
|
136
|
+
if (runtimeState.runtime.last_validation?.passed && (runtimeState.runtime.current_state === 'validate_complete' || runtimeState.runtime.current_state === 'completed')) {
|
|
137
|
+
await writeScoreSnapshot(repoRoot, {
|
|
138
|
+
kind: 'final',
|
|
139
|
+
runtimeState
|
|
140
|
+
});
|
|
141
|
+
await writeScoreDelta(repoRoot);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { listFilesRecursive } from '../core/fs.js';
|
|
4
|
+
import { normalizeRepoRelativePath } from '../core/paths.js';
|
|
5
|
+
const SCORE_WEIGHTS = {
|
|
6
|
+
structure: 30,
|
|
7
|
+
maintainability: 30,
|
|
8
|
+
complexity: 20,
|
|
9
|
+
testability: 20
|
|
10
|
+
};
|
|
11
|
+
const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.cs']);
|
|
12
|
+
function round(value) {
|
|
13
|
+
return Math.round(value * 100) / 100;
|
|
14
|
+
}
|
|
15
|
+
function clamp(value, min = 0, max = 100) {
|
|
16
|
+
return Math.max(min, Math.min(max, value));
|
|
17
|
+
}
|
|
18
|
+
function scoreInverse(value, ideal, tolerance) {
|
|
19
|
+
if (value <= ideal) {
|
|
20
|
+
return 100;
|
|
21
|
+
}
|
|
22
|
+
return clamp(100 - (((value - ideal) / tolerance) * 100));
|
|
23
|
+
}
|
|
24
|
+
function extractImports(content) {
|
|
25
|
+
return content
|
|
26
|
+
.split('\n')
|
|
27
|
+
.map((line) => line.trim())
|
|
28
|
+
.filter((line) => line.startsWith('import ') || line.startsWith('export ') || line.includes('require('));
|
|
29
|
+
}
|
|
30
|
+
function extractDependencyDepth(imports) {
|
|
31
|
+
const relativeImports = imports
|
|
32
|
+
.map((line) => /from\s+['"](.+?)['"]/.exec(line)?.[1] ?? /require\(['"](.+?)['"]\)/.exec(line)?.[1] ?? '')
|
|
33
|
+
.filter((specifier) => specifier.startsWith('./') || specifier.startsWith('../'));
|
|
34
|
+
if (relativeImports.length === 0) {
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
return Math.max(...relativeImports.map((specifier) => specifier.split('/').filter((segment) => segment === '..').length));
|
|
38
|
+
}
|
|
39
|
+
function extractFunctionLengths(content) {
|
|
40
|
+
const lines = content.replace(/\r\n/g, '\n').split('\n');
|
|
41
|
+
const lengths = [];
|
|
42
|
+
let captureDepth = 0;
|
|
43
|
+
let currentLength = 0;
|
|
44
|
+
let tracking = false;
|
|
45
|
+
for (const line of lines) {
|
|
46
|
+
const trimmed = line.trim();
|
|
47
|
+
const startsFunction = /^(export\s+)?(async\s+)?function\s+\w+/.test(trimmed)
|
|
48
|
+
|| /^(export\s+)?const\s+\w+\s*=\s*(async\s*)?\(/.test(trimmed)
|
|
49
|
+
|| /^(public\s+|private\s+|protected\s+)?(async\s+)?\w+\s*\(.*\)\s*\{?$/.test(trimmed);
|
|
50
|
+
if (startsFunction && trimmed.includes('{')) {
|
|
51
|
+
tracking = true;
|
|
52
|
+
currentLength = 1;
|
|
53
|
+
captureDepth = (trimmed.match(/\{/g) ?? []).length - (trimmed.match(/\}/g) ?? []).length;
|
|
54
|
+
if (captureDepth <= 0) {
|
|
55
|
+
lengths.push(currentLength);
|
|
56
|
+
tracking = false;
|
|
57
|
+
currentLength = 0;
|
|
58
|
+
captureDepth = 0;
|
|
59
|
+
}
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (!tracking) {
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
currentLength += 1;
|
|
66
|
+
captureDepth += (trimmed.match(/\{/g) ?? []).length;
|
|
67
|
+
captureDepth -= (trimmed.match(/\}/g) ?? []).length;
|
|
68
|
+
if (captureDepth <= 0) {
|
|
69
|
+
lengths.push(currentLength);
|
|
70
|
+
tracking = false;
|
|
71
|
+
currentLength = 0;
|
|
72
|
+
captureDepth = 0;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return lengths;
|
|
76
|
+
}
|
|
77
|
+
async function collectSourceFiles(repoRoot) {
|
|
78
|
+
const allFiles = await listFilesRecursive(repoRoot);
|
|
79
|
+
const sourceFiles = [];
|
|
80
|
+
for (const file of allFiles) {
|
|
81
|
+
const relativePath = normalizeRepoRelativePath(file.relativePath);
|
|
82
|
+
if (relativePath.startsWith('.git/')
|
|
83
|
+
|| relativePath.startsWith('.prodify/')
|
|
84
|
+
|| relativePath.startsWith('.agent/')
|
|
85
|
+
|| relativePath.startsWith('.codex/')
|
|
86
|
+
|| relativePath.startsWith('dist/')
|
|
87
|
+
|| relativePath.startsWith('node_modules/')) {
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (!CODE_EXTENSIONS.has(path.extname(relativePath))) {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
sourceFiles.push({
|
|
94
|
+
path: relativePath,
|
|
95
|
+
content: await fs.readFile(file.fullPath, 'utf8')
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
return sourceFiles.sort((left, right) => left.path.localeCompare(right.path));
|
|
99
|
+
}
|
|
100
|
+
function deriveSignals(sourceFiles) {
|
|
101
|
+
const functionLengths = sourceFiles.flatMap((file) => extractFunctionLengths(file.content));
|
|
102
|
+
const allImports = sourceFiles.map((file) => extractImports(file.content));
|
|
103
|
+
const moduleCount = sourceFiles.length;
|
|
104
|
+
const averageFunctionLength = functionLengths.length === 0
|
|
105
|
+
? 0
|
|
106
|
+
: functionLengths.reduce((sum, value) => sum + value, 0) / functionLengths.length;
|
|
107
|
+
const averageDirectoryDepth = moduleCount === 0
|
|
108
|
+
? 0
|
|
109
|
+
: sourceFiles
|
|
110
|
+
.map((file) => path.posix.dirname(file.path))
|
|
111
|
+
.map((directory) => directory === '.' ? 0 : directory.split('/').length)
|
|
112
|
+
.reduce((sum, depth) => sum + depth, 0) / moduleCount;
|
|
113
|
+
const dependencyDepth = allImports.length === 0
|
|
114
|
+
? 0
|
|
115
|
+
: Math.max(...allImports.map((imports) => extractDependencyDepth(imports)));
|
|
116
|
+
const averageImportsPerModule = moduleCount === 0
|
|
117
|
+
? 0
|
|
118
|
+
: allImports.reduce((sum, imports) => sum + imports.length, 0) / moduleCount;
|
|
119
|
+
const testFiles = sourceFiles.filter((file) => file.path.startsWith('tests/') || /\.test\./.test(file.path)).length;
|
|
120
|
+
const sourceModules = Math.max(1, sourceFiles.filter((file) => file.path.startsWith('src/')).length);
|
|
121
|
+
const testFileRatio = testFiles / sourceModules;
|
|
122
|
+
return {
|
|
123
|
+
average_function_length: round(averageFunctionLength),
|
|
124
|
+
module_count: moduleCount,
|
|
125
|
+
average_directory_depth: round(averageDirectoryDepth),
|
|
126
|
+
dependency_depth: dependencyDepth,
|
|
127
|
+
average_imports_per_module: round(averageImportsPerModule),
|
|
128
|
+
test_file_ratio: round(testFileRatio)
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function buildBreakdown(signals) {
|
|
132
|
+
const structure = round(((clamp(signals.module_count * 5, 0, 60)) + clamp(signals.average_directory_depth * 15, 0, 40)));
|
|
133
|
+
const maintainability = round((scoreInverse(signals.average_function_length, 18, 40) * 0.7) + (scoreInverse(signals.average_imports_per_module, 4, 8) * 0.3));
|
|
134
|
+
const complexity = round((scoreInverse(signals.dependency_depth, 1, 5) * 0.6) + (scoreInverse(signals.average_imports_per_module, 4, 8) * 0.4));
|
|
135
|
+
const testability = round(clamp(signals.test_file_ratio * 100, 0, 100));
|
|
136
|
+
return {
|
|
137
|
+
structure: clamp(structure),
|
|
138
|
+
maintainability: clamp(maintainability),
|
|
139
|
+
complexity: clamp(complexity),
|
|
140
|
+
testability: clamp(testability)
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export async function calculateRepositoryQuality(repoRoot) {
|
|
144
|
+
const sourceFiles = await collectSourceFiles(repoRoot);
|
|
145
|
+
const signals = deriveSignals(sourceFiles);
|
|
146
|
+
const breakdown = buildBreakdown(signals);
|
|
147
|
+
const total = round((breakdown.structure * (SCORE_WEIGHTS.structure / 100))
|
|
148
|
+
+ (breakdown.maintainability * (SCORE_WEIGHTS.maintainability / 100))
|
|
149
|
+
+ (breakdown.complexity * (SCORE_WEIGHTS.complexity / 100))
|
|
150
|
+
+ (breakdown.testability * (SCORE_WEIGHTS.testability / 100)));
|
|
151
|
+
return {
|
|
152
|
+
breakdown,
|
|
153
|
+
signals,
|
|
154
|
+
total_score: total,
|
|
155
|
+
max_score: 100,
|
|
156
|
+
weights: SCORE_WEIGHTS
|
|
157
|
+
};
|
|
158
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# Diff Validator Design
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Prodify must reject refactor stages that only move whitespace, touch too few files, or fail to create meaningful structural change.
|
|
6
|
+
|
|
7
|
+
## Snapshot Model
|
|
8
|
+
|
|
9
|
+
- Capture a tracked repository snapshot before refactor.
|
|
10
|
+
- Compare the current tracked tree against that snapshot.
|
|
11
|
+
- Tracked roots:
|
|
12
|
+
- `src/`
|
|
13
|
+
- `tests/`
|
|
14
|
+
- `assets/`
|
|
15
|
+
|
|
16
|
+
## Deterministic Outputs
|
|
17
|
+
|
|
18
|
+
- `filesModified`
|
|
19
|
+
- `filesAdded`
|
|
20
|
+
- `filesDeleted`
|
|
21
|
+
- `linesAdded`
|
|
22
|
+
- `linesRemoved`
|
|
23
|
+
- `formattingOnlyPaths`
|
|
24
|
+
- `structuralChanges`
|
|
25
|
+
|
|
26
|
+
## Structural Change Flags
|
|
27
|
+
|
|
28
|
+
- `new-directories`
|
|
29
|
+
- `new-layer-directories`
|
|
30
|
+
- `new-modules`
|
|
31
|
+
- `module-boundary-created`
|
|
32
|
+
- `responsibility-reduced`
|
|
33
|
+
|
|
34
|
+
## Validation Rules
|
|
35
|
+
|
|
36
|
+
- minimum files modified
|
|
37
|
+
- minimum lines changed
|
|
38
|
+
- optional file-creation requirement
|
|
39
|
+
- required structural flags
|
|
40
|
+
- formatting-only changes fail
|
|
41
|
+
|
|
42
|
+
## Plan Coupling
|
|
43
|
+
|
|
44
|
+
Refactor validation also confirms that the selected step in `05-refactor.md` maps to a real plan unit from `04-plan.md`.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Impact Scoring Design
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Prodify should measure whether a run actually improved repository quality instead of only checking that files changed.
|
|
6
|
+
|
|
7
|
+
## Breakdown
|
|
8
|
+
|
|
9
|
+
- `structure`
|
|
10
|
+
- `maintainability`
|
|
11
|
+
- `complexity`
|
|
12
|
+
- `testability`
|
|
13
|
+
|
|
14
|
+
## Formula
|
|
15
|
+
|
|
16
|
+
```text
|
|
17
|
+
total =
|
|
18
|
+
structure * 0.30 +
|
|
19
|
+
maintainability * 0.30 +
|
|
20
|
+
complexity * 0.20 +
|
|
21
|
+
testability * 0.20
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Signals
|
|
25
|
+
|
|
26
|
+
- average function length
|
|
27
|
+
- module count
|
|
28
|
+
- average directory depth
|
|
29
|
+
- dependency depth
|
|
30
|
+
- average imports per module
|
|
31
|
+
- test file ratio
|
|
32
|
+
|
|
33
|
+
## Runtime Integration
|
|
34
|
+
|
|
35
|
+
- Write baseline score when a run is bootstrapped.
|
|
36
|
+
- Write final score and delta after validated completion.
|
|
37
|
+
- Enforce `min_impact_score` during validation.
|
|
38
|
+
- Show stored score delta in `prodify status`.
|
package/package.json
CHANGED
|
@@ -22,6 +22,9 @@ export async function runSetupAgentCommand(args: string[], context: CommandConte
|
|
|
22
22
|
context.stdout.write(`Status: ${result.alreadyConfigured ? 'already configured globally; refreshed' : 'configured globally'}\n`);
|
|
23
23
|
context.stdout.write(`Configured agents: ${result.configuredAgents.join(', ')}\n`);
|
|
24
24
|
context.stdout.write(`Registry: ${result.statePath}\n`);
|
|
25
|
+
if (result.installedPaths.length > 0) {
|
|
26
|
+
context.stdout.write(`Installed runtime commands: ${result.installedPaths.join(', ')}\n`);
|
|
27
|
+
}
|
|
25
28
|
context.stdout.write('Repo impact: none\n');
|
|
26
29
|
context.stdout.write('Next step: run `prodify init` in a repository, then open that agent and use `$prodify-init`.\n');
|
|
27
30
|
return 0;
|
|
@@ -20,6 +20,9 @@ export function validateCompiledContractShape(contract: unknown): CompiledStageC
|
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
const record = contract as Record<string, unknown>;
|
|
23
|
+
const diffValidation = typeof record.diff_validation_rules === 'object' && record.diff_validation_rules !== null
|
|
24
|
+
? record.diff_validation_rules as Record<string, unknown>
|
|
25
|
+
: {};
|
|
23
26
|
return normalizeSourceContractDocument({
|
|
24
27
|
document: {
|
|
25
28
|
frontmatter: {
|
|
@@ -32,7 +35,13 @@ export function validateCompiledContractShape(contract: unknown): CompiledStageC
|
|
|
32
35
|
forbidden_writes: record.forbidden_writes,
|
|
33
36
|
policy_rules: record.policy_rules,
|
|
34
37
|
success_criteria: record.success_criteria,
|
|
35
|
-
skill_routing: record.skill_routing
|
|
38
|
+
skill_routing: record.skill_routing,
|
|
39
|
+
minimum_files_modified: diffValidation.minimum_files_modified ?? record.minimum_files_modified,
|
|
40
|
+
minimum_lines_changed: diffValidation.minimum_lines_changed ?? record.minimum_lines_changed,
|
|
41
|
+
must_create_files: diffValidation.must_create_files ?? record.must_create_files,
|
|
42
|
+
required_structural_changes: diffValidation.required_structural_changes ?? record.required_structural_changes,
|
|
43
|
+
min_impact_score: record.min_impact_score,
|
|
44
|
+
enforce_plan_units: record.enforce_plan_units
|
|
36
45
|
},
|
|
37
46
|
body: 'compiled-contract'
|
|
38
47
|
},
|