@urielsh/prodify 0.1.2 → 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/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/contracts/compiled-schema.js +10 -1
- package/dist/contracts/source-schema.js +42 -1
- package/dist/core/diff-validator.js +183 -0
- package/dist/core/plan-units.js +64 -0
- 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/contracts/compiled-schema.ts +10 -1
- package/src/contracts/source-schema.ts +51 -1
- package/src/core/diff-validator.ts +230 -0
- package/src/core/plan-units.ts +82 -0
- 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 +1 -0
- 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/src/core/validation.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
|
|
3
3
|
import { loadCompiledContract } from '../contracts/compiler.js';
|
|
4
|
+
import { diffAgainstRefactorBaseline } from './diff-validator.js';
|
|
4
5
|
import { normalizeRepoRelativePath, resolveRepoPath } from './paths.js';
|
|
5
|
-
import
|
|
6
|
+
import { readPlanUnits, readSelectedRefactorStep } from './plan-units.js';
|
|
7
|
+
import { calculateCurrentImpactDelta } from '../scoring/model.js';
|
|
8
|
+
import type { CompiledStageContract, DiffResult, FlowStage, ProdifyState, StageValidationResult, ValidationIssue } from '../types.js';
|
|
6
9
|
|
|
7
10
|
function pathIsWithin(pathToCheck: string, root: string): boolean {
|
|
8
11
|
const normalizedPath = normalizeRepoRelativePath(pathToCheck);
|
|
@@ -46,6 +49,14 @@ function buildRule(rule: string, message: string, path?: string): ValidationIssu
|
|
|
46
49
|
return { rule, message, path };
|
|
47
50
|
}
|
|
48
51
|
|
|
52
|
+
function hasRequiredStructuralChanges(diffResult: DiffResult, requiredChanges: string[]): boolean {
|
|
53
|
+
if (requiredChanges.length === 0) {
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return requiredChanges.every((requiredChange) => diffResult.structuralChanges.structural_change_flags.includes(requiredChange));
|
|
58
|
+
}
|
|
59
|
+
|
|
49
60
|
async function validateArtifact(
|
|
50
61
|
repoRoot: string,
|
|
51
62
|
contract: CompiledStageContract,
|
|
@@ -60,14 +71,6 @@ async function validateArtifact(
|
|
|
60
71
|
const content = await fs.readFile(artifactPath, 'utf8');
|
|
61
72
|
diagnostics.push(`validated artifact ${artifact.path}`);
|
|
62
73
|
|
|
63
|
-
if (!contract.allowed_write_roots.some((root) => pathIsWithin(artifact.path, root))) {
|
|
64
|
-
issues.push(buildRule(
|
|
65
|
-
'artifact/outside-allowed-roots',
|
|
66
|
-
`Required artifact ${artifact.path} is outside allowed write roots.`,
|
|
67
|
-
artifact.path
|
|
68
|
-
));
|
|
69
|
-
}
|
|
70
|
-
|
|
71
74
|
if (artifact.format === 'markdown') {
|
|
72
75
|
const sections = collectMarkdownSections(content);
|
|
73
76
|
for (const section of artifact.required_sections) {
|
|
@@ -130,17 +133,23 @@ export async function validateStageOutputs(
|
|
|
130
133
|
{
|
|
131
134
|
contract,
|
|
132
135
|
runtimeState,
|
|
133
|
-
touchedPaths = []
|
|
136
|
+
touchedPaths = [],
|
|
137
|
+
diffResult = null
|
|
134
138
|
}: {
|
|
135
139
|
contract: CompiledStageContract;
|
|
136
140
|
runtimeState: ProdifyState;
|
|
137
141
|
touchedPaths?: string[];
|
|
142
|
+
diffResult?: DiffResult | null;
|
|
138
143
|
}
|
|
139
144
|
): Promise<StageValidationResult> {
|
|
140
145
|
const violatedRules: ValidationIssue[] = [];
|
|
141
146
|
const missingArtifacts: string[] = [];
|
|
142
147
|
const diagnostics: string[] = [];
|
|
143
148
|
const warnings: string[] = [];
|
|
149
|
+
const effectiveDiffResult = diffResult ?? await diffAgainstRefactorBaseline(repoRoot);
|
|
150
|
+
const impactDelta = contract.min_impact_score > 0
|
|
151
|
+
? await calculateCurrentImpactDelta(repoRoot)
|
|
152
|
+
: null;
|
|
144
153
|
|
|
145
154
|
if (runtimeState.runtime.current_stage !== contract.stage) {
|
|
146
155
|
violatedRules.push(buildRule(
|
|
@@ -178,6 +187,110 @@ export async function validateStageOutputs(
|
|
|
178
187
|
warnings.push('No touched paths were provided; forbidden-write checks are limited to required artifacts.');
|
|
179
188
|
}
|
|
180
189
|
|
|
190
|
+
if (contract.enforce_plan_units) {
|
|
191
|
+
try {
|
|
192
|
+
const [planUnits, selectedStep] = await Promise.all([
|
|
193
|
+
readPlanUnits(repoRoot),
|
|
194
|
+
readSelectedRefactorStep(repoRoot)
|
|
195
|
+
]);
|
|
196
|
+
if (!selectedStep) {
|
|
197
|
+
violatedRules.push(buildRule(
|
|
198
|
+
'plan/selected-step-missing',
|
|
199
|
+
'Refactor artifact does not declare the selected plan unit.'
|
|
200
|
+
));
|
|
201
|
+
} else if (!planUnits.some((unit) => unit.id === selectedStep.id)) {
|
|
202
|
+
violatedRules.push(buildRule(
|
|
203
|
+
'plan/selected-step-invalid',
|
|
204
|
+
`Selected refactor step ${selectedStep.id} does not exist in 04-plan.md.`
|
|
205
|
+
));
|
|
206
|
+
} else {
|
|
207
|
+
diagnostics.push(`validated selected plan unit ${selectedStep.id}`);
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
violatedRules.push(buildRule(
|
|
211
|
+
'plan/unreadable',
|
|
212
|
+
'Plan-unit validation could not read 04-plan.md or 05-refactor.md.'
|
|
213
|
+
));
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (effectiveDiffResult && (
|
|
218
|
+
contract.diff_validation_rules.minimum_files_modified > 0
|
|
219
|
+
|| contract.diff_validation_rules.minimum_lines_changed > 0
|
|
220
|
+
|| contract.diff_validation_rules.must_create_files
|
|
221
|
+
|| contract.diff_validation_rules.required_structural_changes.length > 0
|
|
222
|
+
)) {
|
|
223
|
+
const changedFiles = effectiveDiffResult.filesModified + effectiveDiffResult.filesAdded + effectiveDiffResult.filesDeleted;
|
|
224
|
+
const changedLines = effectiveDiffResult.linesAdded + effectiveDiffResult.linesRemoved;
|
|
225
|
+
const formattingOnly = effectiveDiffResult.filesModified > 0
|
|
226
|
+
&& effectiveDiffResult.filesModified === effectiveDiffResult.formattingOnlyPaths.length
|
|
227
|
+
&& effectiveDiffResult.filesAdded === 0
|
|
228
|
+
&& effectiveDiffResult.filesDeleted === 0;
|
|
229
|
+
|
|
230
|
+
if (changedFiles < contract.diff_validation_rules.minimum_files_modified) {
|
|
231
|
+
violatedRules.push(buildRule(
|
|
232
|
+
'diff/minimum-files-modified',
|
|
233
|
+
`Refactor changed ${changedFiles} files but requires at least ${contract.diff_validation_rules.minimum_files_modified}.`
|
|
234
|
+
));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (changedLines < contract.diff_validation_rules.minimum_lines_changed) {
|
|
238
|
+
violatedRules.push(buildRule(
|
|
239
|
+
'diff/minimum-lines-changed',
|
|
240
|
+
`Refactor changed ${changedLines} lines but requires at least ${contract.diff_validation_rules.minimum_lines_changed}.`
|
|
241
|
+
));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
if (contract.diff_validation_rules.must_create_files && effectiveDiffResult.filesAdded === 0) {
|
|
245
|
+
violatedRules.push(buildRule(
|
|
246
|
+
'diff/must-create-files',
|
|
247
|
+
'Refactor must create at least one new file.'
|
|
248
|
+
));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (formattingOnly) {
|
|
252
|
+
violatedRules.push(buildRule(
|
|
253
|
+
'diff/formatting-only',
|
|
254
|
+
'Refactor changes are formatting-only and do not count as meaningful code change.'
|
|
255
|
+
));
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!hasRequiredStructuralChanges(effectiveDiffResult, contract.diff_validation_rules.required_structural_changes)) {
|
|
259
|
+
violatedRules.push(buildRule(
|
|
260
|
+
'diff/required-structural-changes',
|
|
261
|
+
`Refactor is missing required structural changes: ${contract.diff_validation_rules.required_structural_changes.join(', ')}.`
|
|
262
|
+
));
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
diagnostics.push(`validated diff: files=${changedFiles}, lines=${changedLines}, structural=${effectiveDiffResult.structuralChanges.structural_change_flags.join(',') || 'none'}`);
|
|
266
|
+
} else if (
|
|
267
|
+
contract.diff_validation_rules.minimum_files_modified > 0
|
|
268
|
+
|| contract.diff_validation_rules.minimum_lines_changed > 0
|
|
269
|
+
|| contract.diff_validation_rules.must_create_files
|
|
270
|
+
|| contract.diff_validation_rules.required_structural_changes.length > 0
|
|
271
|
+
) {
|
|
272
|
+
violatedRules.push(buildRule(
|
|
273
|
+
'diff/baseline-missing',
|
|
274
|
+
'Diff validation rules are configured but no refactor baseline snapshot was available.'
|
|
275
|
+
));
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (contract.min_impact_score > 0) {
|
|
279
|
+
if (!impactDelta) {
|
|
280
|
+
violatedRules.push(buildRule(
|
|
281
|
+
'impact-score/missing-baseline',
|
|
282
|
+
'Impact score threshold is configured but no baseline score is available.'
|
|
283
|
+
));
|
|
284
|
+
} else if (impactDelta.delta < contract.min_impact_score) {
|
|
285
|
+
violatedRules.push(buildRule(
|
|
286
|
+
'impact-score/minimum-threshold',
|
|
287
|
+
`Impact score delta ${impactDelta.delta} is below the required threshold ${contract.min_impact_score}.`
|
|
288
|
+
));
|
|
289
|
+
} else {
|
|
290
|
+
diagnostics.push(`validated impact score delta ${impactDelta.delta}`);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
181
294
|
return {
|
|
182
295
|
stage: contract.stage,
|
|
183
296
|
contract_version: contract.contract_version,
|
|
@@ -185,7 +298,9 @@ export async function validateStageOutputs(
|
|
|
185
298
|
violated_rules: violatedRules,
|
|
186
299
|
missing_artifacts: [...new Set(missingArtifacts)].sort((left, right) => left.localeCompare(right)),
|
|
187
300
|
warnings,
|
|
188
|
-
diagnostics
|
|
301
|
+
diagnostics,
|
|
302
|
+
...(effectiveDiffResult ? { diff_result: effectiveDiffResult } : {}),
|
|
303
|
+
...(impactDelta ? { impact_score_delta: impactDelta.delta } : {})
|
|
189
304
|
};
|
|
190
305
|
}
|
|
191
306
|
|
|
@@ -193,10 +308,12 @@ export async function validateStageOutputsForCurrentState(
|
|
|
193
308
|
repoRoot: string,
|
|
194
309
|
{
|
|
195
310
|
runtimeState,
|
|
196
|
-
touchedPaths = []
|
|
311
|
+
touchedPaths = [],
|
|
312
|
+
diffResult = null
|
|
197
313
|
}: {
|
|
198
314
|
runtimeState: ProdifyState;
|
|
199
315
|
touchedPaths?: string[];
|
|
316
|
+
diffResult?: DiffResult | null;
|
|
200
317
|
}
|
|
201
318
|
): Promise<StageValidationResult> {
|
|
202
319
|
const stage = runtimeState.runtime.current_stage;
|
|
@@ -208,7 +325,8 @@ export async function validateStageOutputsForCurrentState(
|
|
|
208
325
|
return validateStageOutputs(repoRoot, {
|
|
209
326
|
contract,
|
|
210
327
|
runtimeState,
|
|
211
|
-
touchedPaths
|
|
328
|
+
touchedPaths,
|
|
329
|
+
diffResult
|
|
212
330
|
});
|
|
213
331
|
}
|
|
214
332
|
|
|
@@ -217,17 +335,20 @@ export async function validateStageOutputsForStage(
|
|
|
217
335
|
{
|
|
218
336
|
stage,
|
|
219
337
|
runtimeState,
|
|
220
|
-
touchedPaths = []
|
|
338
|
+
touchedPaths = [],
|
|
339
|
+
diffResult = null
|
|
221
340
|
}: {
|
|
222
341
|
stage: FlowStage;
|
|
223
342
|
runtimeState: ProdifyState;
|
|
224
343
|
touchedPaths?: string[];
|
|
344
|
+
diffResult?: DiffResult | null;
|
|
225
345
|
}
|
|
226
346
|
): Promise<StageValidationResult> {
|
|
227
347
|
const contract = await loadCompiledContract(repoRoot, stage);
|
|
228
348
|
return validateStageOutputs(repoRoot, {
|
|
229
349
|
contract,
|
|
230
350
|
runtimeState,
|
|
231
|
-
touchedPaths
|
|
351
|
+
touchedPaths,
|
|
352
|
+
diffResult
|
|
232
353
|
});
|
|
233
354
|
}
|
package/src/scoring/model.ts
CHANGED
|
@@ -1,215 +1,49 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
|
-
import { inspectCompiledContracts } from '../contracts/freshness.js';
|
|
5
4
|
import { ProdifyError } from '../core/errors.js';
|
|
6
|
-
import {
|
|
5
|
+
import { pathExists, writeFileEnsuringDir } from '../core/fs.js';
|
|
7
6
|
import { resolveRepoPath } from '../core/paths.js';
|
|
7
|
+
import { calculateRepositoryQuality } from './scoring-engine.js';
|
|
8
8
|
import type { ProdifyState, ScoreDelta, ScoreMetric, ScoreSnapshot, ScoreSnapshotKind } from '../types.js';
|
|
9
9
|
|
|
10
|
-
const SCORE_SCHEMA_VERSION = '
|
|
11
|
-
|
|
12
|
-
interface ToolOutput {
|
|
13
|
-
adapter: string;
|
|
14
|
-
details: Record<string, unknown>;
|
|
15
|
-
}
|
|
10
|
+
const SCORE_SCHEMA_VERSION = '2';
|
|
16
11
|
|
|
17
12
|
function roundScore(value: number): number {
|
|
18
13
|
return Math.round(value * 100) / 100;
|
|
19
14
|
}
|
|
20
15
|
|
|
21
|
-
function
|
|
22
|
-
|
|
23
|
-
label: string;
|
|
24
|
-
tool: string;
|
|
25
|
-
weight: number;
|
|
26
|
-
ratio: number;
|
|
27
|
-
details: string;
|
|
28
|
-
}): ScoreMetric {
|
|
29
|
-
const ratio = Math.max(0, Math.min(1, options.ratio));
|
|
30
|
-
const points = roundScore(options.weight * ratio);
|
|
31
|
-
return {
|
|
32
|
-
id: options.id,
|
|
33
|
-
label: options.label,
|
|
34
|
-
tool: options.tool,
|
|
35
|
-
weight: options.weight,
|
|
36
|
-
max_points: options.weight,
|
|
37
|
-
points,
|
|
38
|
-
status: ratio === 1 ? 'pass' : ratio === 0 ? 'fail' : 'partial',
|
|
39
|
-
details: options.details
|
|
40
|
-
};
|
|
16
|
+
function serializeJson(value: unknown): string {
|
|
17
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
41
18
|
}
|
|
42
19
|
|
|
43
|
-
async function
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
ecosystems.push('typescript-javascript');
|
|
20
|
+
async function removeIfExists(targetPath: string): Promise<void> {
|
|
21
|
+
if (await pathExists(targetPath)) {
|
|
22
|
+
await fs.rm(targetPath);
|
|
47
23
|
}
|
|
48
|
-
|
|
49
|
-
const repoFiles = await listFilesRecursive(repoRoot);
|
|
50
|
-
if (repoFiles.some((file) => file.relativePath.endsWith('.py'))) {
|
|
51
|
-
ecosystems.push('python');
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
if (repoFiles.some((file) => file.relativePath.endsWith('.cs') || file.relativePath.endsWith('.csproj') || file.relativePath.endsWith('.sln'))) {
|
|
55
|
-
ecosystems.push('csharp');
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
return ecosystems.sort((left, right) => left.localeCompare(right));
|
|
59
24
|
}
|
|
60
25
|
|
|
61
|
-
|
|
62
|
-
const
|
|
63
|
-
const toolOutputs: ToolOutput[] = [];
|
|
64
|
-
|
|
65
|
-
if (ecosystems.includes('typescript-javascript')) {
|
|
66
|
-
const packageJsonPath = resolveRepoPath(repoRoot, 'package.json');
|
|
67
|
-
const tsconfigPath = resolveRepoPath(repoRoot, 'tsconfig.json');
|
|
68
|
-
const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8')) as {
|
|
69
|
-
scripts?: Record<string, string>;
|
|
70
|
-
};
|
|
71
|
-
const scripts = packageJson.scripts ?? {};
|
|
72
|
-
const hasBuild = typeof scripts.build === 'string' && scripts.build.length > 0;
|
|
73
|
-
const hasTest = typeof scripts.test === 'string' && scripts.test.length > 0;
|
|
74
|
-
const hasTsconfig = await pathExists(tsconfigPath);
|
|
75
|
-
const score = [hasBuild, hasTest, hasTsconfig].filter(Boolean).length / 3;
|
|
76
|
-
|
|
77
|
-
metrics.push(createMetric({
|
|
78
|
-
id: 'typescript-javascript',
|
|
79
|
-
label: 'TypeScript/JavaScript local tooling',
|
|
80
|
-
tool: 'package-json',
|
|
81
|
-
weight: 15,
|
|
82
|
-
ratio: score,
|
|
83
|
-
details: `build=${hasBuild}, test=${hasTest}, tsconfig=${hasTsconfig}`
|
|
84
|
-
}));
|
|
85
|
-
toolOutputs.push({
|
|
86
|
-
adapter: 'typescript-javascript',
|
|
87
|
-
details: {
|
|
88
|
-
build_script: hasBuild,
|
|
89
|
-
test_script: hasTest,
|
|
90
|
-
tsconfig: hasTsconfig
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
if (ecosystems.includes('python')) {
|
|
96
|
-
const hasPyproject = await pathExists(resolveRepoPath(repoRoot, 'pyproject.toml'));
|
|
97
|
-
const hasRequirements = await pathExists(resolveRepoPath(repoRoot, 'requirements.txt'));
|
|
98
|
-
const ratio = [hasPyproject, hasRequirements].filter(Boolean).length / 2;
|
|
99
|
-
|
|
100
|
-
metrics.push(createMetric({
|
|
101
|
-
id: 'python',
|
|
102
|
-
label: 'Python local tooling',
|
|
103
|
-
tool: 'filesystem',
|
|
104
|
-
weight: 7.5,
|
|
105
|
-
ratio,
|
|
106
|
-
details: `pyproject=${hasPyproject}, requirements=${hasRequirements}`
|
|
107
|
-
}));
|
|
108
|
-
toolOutputs.push({
|
|
109
|
-
adapter: 'python',
|
|
110
|
-
details: {
|
|
111
|
-
pyproject: hasPyproject,
|
|
112
|
-
requirements: hasRequirements
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (ecosystems.includes('csharp')) {
|
|
118
|
-
const repoFiles = await listFilesRecursive(repoRoot);
|
|
119
|
-
const hasSolution = repoFiles.some((file) => file.relativePath.endsWith('.sln'));
|
|
120
|
-
const hasProject = repoFiles.some((file) => file.relativePath.endsWith('.csproj'));
|
|
121
|
-
const ratio = [hasSolution, hasProject].filter(Boolean).length / 2;
|
|
122
|
-
|
|
123
|
-
metrics.push(createMetric({
|
|
124
|
-
id: 'csharp',
|
|
125
|
-
label: 'C# local tooling',
|
|
126
|
-
tool: 'filesystem',
|
|
127
|
-
weight: 7.5,
|
|
128
|
-
ratio,
|
|
129
|
-
details: `solution=${hasSolution}, project=${hasProject}`
|
|
130
|
-
}));
|
|
131
|
-
toolOutputs.push({
|
|
132
|
-
adapter: 'csharp',
|
|
133
|
-
details: {
|
|
134
|
-
solution: hasSolution,
|
|
135
|
-
project: hasProject
|
|
136
|
-
}
|
|
137
|
-
});
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
return {
|
|
141
|
-
metrics,
|
|
142
|
-
toolOutputs
|
|
143
|
-
};
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function buildRepoHygieneMetric(repoRoot: string): Promise<{ metric: ScoreMetric; toolOutput: ToolOutput }> {
|
|
147
|
-
const requiredSignals = ['README.md', 'LICENSE', 'tests', '.prodify/contracts-src'];
|
|
148
|
-
const available = await Promise.all(requiredSignals.map((relativePath) => pathExists(resolveRepoPath(repoRoot, relativePath))));
|
|
149
|
-
const ratio = available.filter(Boolean).length / requiredSignals.length;
|
|
150
|
-
|
|
151
|
-
return {
|
|
152
|
-
metric: createMetric({
|
|
153
|
-
id: 'repo-hygiene',
|
|
154
|
-
label: 'Repository hygiene signals',
|
|
155
|
-
tool: 'filesystem',
|
|
156
|
-
weight: 20,
|
|
157
|
-
ratio,
|
|
158
|
-
details: requiredSignals.map((entry, index) => `${entry}=${available[index]}`).join(', ')
|
|
159
|
-
}),
|
|
160
|
-
toolOutput: {
|
|
161
|
-
adapter: 'filesystem',
|
|
162
|
-
details: Object.fromEntries(requiredSignals.map((entry, index) => [entry, available[index]]))
|
|
163
|
-
}
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
function buildRuntimeMetric(runtimeState: ProdifyState): { metric: ScoreMetric; toolOutput: ToolOutput } {
|
|
168
|
-
const healthyState = runtimeState.runtime.current_state !== 'failed' && runtimeState.runtime.current_state !== 'blocked';
|
|
169
|
-
const ratio = healthyState ? 1 : 0;
|
|
170
|
-
|
|
26
|
+
function createMetric(label: string, points: number, maxPoints: number, details: string): ScoreMetric {
|
|
27
|
+
const normalizedPoints = roundScore(points);
|
|
171
28
|
return {
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
toolOutput: {
|
|
181
|
-
adapter: 'state-json',
|
|
182
|
-
details: {
|
|
183
|
-
current_state: runtimeState.runtime.current_state,
|
|
184
|
-
status: runtimeState.runtime.status,
|
|
185
|
-
last_validation_result: runtimeState.runtime.last_validation_result
|
|
186
|
-
}
|
|
187
|
-
}
|
|
29
|
+
id: label.toLowerCase().replace(/\s+/g, '-'),
|
|
30
|
+
label,
|
|
31
|
+
tool: 'scoring-engine',
|
|
32
|
+
weight: maxPoints,
|
|
33
|
+
max_points: maxPoints,
|
|
34
|
+
points: normalizedPoints,
|
|
35
|
+
status: normalizedPoints >= maxPoints ? 'pass' : normalizedPoints <= 0 ? 'fail' : 'partial',
|
|
36
|
+
details
|
|
188
37
|
};
|
|
189
38
|
}
|
|
190
39
|
|
|
191
|
-
function
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
id: 'validation-gate',
|
|
199
|
-
label: 'Validated contract completion',
|
|
200
|
-
tool: 'state-json',
|
|
201
|
-
weight: 10,
|
|
202
|
-
ratio,
|
|
203
|
-
details: `passed=${passed}, final_ready=${finalReady}`
|
|
204
|
-
}),
|
|
205
|
-
toolOutput: {
|
|
206
|
-
adapter: 'validation-gate',
|
|
207
|
-
details: {
|
|
208
|
-
passed,
|
|
209
|
-
final_ready: finalReady
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
};
|
|
40
|
+
function toSnapshotMetrics(score: Awaited<ReturnType<typeof calculateRepositoryQuality>>): ScoreMetric[] {
|
|
41
|
+
return [
|
|
42
|
+
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}`),
|
|
43
|
+
createMetric('Maintainability', score.breakdown.maintainability * (score.weights.maintainability / 100), score.weights.maintainability, `avg_function_length=${score.signals.average_function_length}`),
|
|
44
|
+
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}`),
|
|
45
|
+
createMetric('Testability', score.breakdown.testability * (score.weights.testability / 100), score.weights.testability, `test_file_ratio=${score.signals.test_file_ratio}`)
|
|
46
|
+
];
|
|
213
47
|
}
|
|
214
48
|
|
|
215
49
|
export async function calculateLocalScore(
|
|
@@ -221,73 +55,55 @@ export async function calculateLocalScore(
|
|
|
221
55
|
kind: ScoreSnapshotKind;
|
|
222
56
|
runtimeState: ProdifyState;
|
|
223
57
|
}
|
|
224
|
-
): Promise<{ snapshot: ScoreSnapshot; toolOutputs:
|
|
225
|
-
const contractInventory = await inspectCompiledContracts(repoRoot);
|
|
226
|
-
const ecosystems = await detectEcosystems(repoRoot);
|
|
227
|
-
const metrics: ScoreMetric[] = [];
|
|
228
|
-
const toolOutputs: ToolOutput[] = [];
|
|
229
|
-
|
|
230
|
-
metrics.push(createMetric({
|
|
231
|
-
id: 'contracts',
|
|
232
|
-
label: 'Compiled contract health',
|
|
233
|
-
tool: 'contract-compiler',
|
|
234
|
-
weight: 30,
|
|
235
|
-
ratio: contractInventory.ok ? 1 : Math.max(0, 1 - ((contractInventory.missingCompiledStages.length + contractInventory.staleStages.length) / 6)),
|
|
236
|
-
details: `source=${contractInventory.sourceCount}, compiled=${contractInventory.compiledCount}, stale=${contractInventory.staleStages.length}, missing=${contractInventory.missingCompiledStages.length}`
|
|
237
|
-
}));
|
|
238
|
-
toolOutputs.push({
|
|
239
|
-
adapter: 'contract-compiler',
|
|
240
|
-
details: {
|
|
241
|
-
ok: contractInventory.ok,
|
|
242
|
-
sourceCount: contractInventory.sourceCount,
|
|
243
|
-
compiledCount: contractInventory.compiledCount,
|
|
244
|
-
staleStages: contractInventory.staleStages,
|
|
245
|
-
missingCompiledStages: contractInventory.missingCompiledStages,
|
|
246
|
-
missingSourceStages: contractInventory.missingSourceStages,
|
|
247
|
-
invalidStages: contractInventory.invalidStages
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
const runtimeMetric = buildRuntimeMetric(runtimeState);
|
|
252
|
-
metrics.push(runtimeMetric.metric);
|
|
253
|
-
toolOutputs.push(runtimeMetric.toolOutput);
|
|
254
|
-
|
|
255
|
-
const validationMetric = buildValidationMetric(runtimeState);
|
|
256
|
-
metrics.push(validationMetric.metric);
|
|
257
|
-
toolOutputs.push(validationMetric.toolOutput);
|
|
258
|
-
|
|
259
|
-
const hygieneMetric = await buildRepoHygieneMetric(repoRoot);
|
|
260
|
-
metrics.push(hygieneMetric.metric);
|
|
261
|
-
toolOutputs.push(hygieneMetric.toolOutput);
|
|
262
|
-
|
|
263
|
-
const ecosystemMetrics = await buildEcosystemMetrics(repoRoot, ecosystems);
|
|
264
|
-
metrics.push(...ecosystemMetrics.metrics);
|
|
265
|
-
toolOutputs.push(...ecosystemMetrics.toolOutputs);
|
|
266
|
-
|
|
267
|
-
const totalScore = roundScore(metrics.reduce((sum, metric) => sum + metric.points, 0));
|
|
268
|
-
const maxScore = roundScore(metrics.reduce((sum, metric) => sum + metric.max_points, 0));
|
|
269
|
-
|
|
58
|
+
): Promise<{ snapshot: ScoreSnapshot; toolOutputs: Record<string, unknown>[] }> {
|
|
270
59
|
if (kind === 'final' && !(runtimeState.runtime.last_validation?.passed && (runtimeState.runtime.current_state === 'validate_complete' || runtimeState.runtime.current_state === 'completed'))) {
|
|
271
60
|
throw new ProdifyError('Final scoring requires a validated runtime state at validate_complete or completed.', {
|
|
272
61
|
code: 'SCORING_STATE_INVALID'
|
|
273
62
|
});
|
|
274
63
|
}
|
|
275
64
|
|
|
65
|
+
const quality = await calculateRepositoryQuality(repoRoot);
|
|
66
|
+
const snapshot: ScoreSnapshot = {
|
|
67
|
+
schema_version: SCORE_SCHEMA_VERSION,
|
|
68
|
+
kind,
|
|
69
|
+
ecosystems: ['repository'],
|
|
70
|
+
total_score: quality.total_score,
|
|
71
|
+
max_score: quality.max_score,
|
|
72
|
+
breakdown: quality.breakdown,
|
|
73
|
+
weights: quality.weights,
|
|
74
|
+
signals: quality.signals,
|
|
75
|
+
metrics: toSnapshotMetrics(quality)
|
|
76
|
+
};
|
|
77
|
+
|
|
276
78
|
return {
|
|
277
|
-
snapshot
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
79
|
+
snapshot,
|
|
80
|
+
toolOutputs: [{
|
|
81
|
+
adapter: 'scoring-engine',
|
|
82
|
+
details: {
|
|
83
|
+
kind,
|
|
84
|
+
breakdown: quality.breakdown,
|
|
85
|
+
weights: quality.weights,
|
|
86
|
+
signals: quality.signals
|
|
87
|
+
}
|
|
88
|
+
}]
|
|
286
89
|
};
|
|
287
90
|
}
|
|
288
91
|
|
|
289
|
-
function
|
|
290
|
-
|
|
92
|
+
export async function calculateCurrentImpactDelta(repoRoot: string): Promise<ScoreDelta | null> {
|
|
93
|
+
const metricsDir = resolveRepoPath(repoRoot, '.prodify/metrics');
|
|
94
|
+
const baselinePath = path.join(metricsDir, 'baseline.score.json');
|
|
95
|
+
if (!(await pathExists(baselinePath))) {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const baseline = JSON.parse(await fs.readFile(baselinePath, 'utf8')) as ScoreSnapshot;
|
|
100
|
+
const current = await calculateRepositoryQuality(repoRoot);
|
|
101
|
+
return {
|
|
102
|
+
schema_version: SCORE_SCHEMA_VERSION,
|
|
103
|
+
baseline_score: baseline.total_score,
|
|
104
|
+
final_score: current.total_score,
|
|
105
|
+
delta: roundScore(current.total_score - baseline.total_score)
|
|
106
|
+
};
|
|
291
107
|
}
|
|
292
108
|
|
|
293
109
|
export async function writeScoreSnapshot(
|
|
@@ -316,17 +132,52 @@ export async function writeScoreSnapshot(
|
|
|
316
132
|
return snapshot;
|
|
317
133
|
}
|
|
318
134
|
|
|
319
|
-
export async function writeScoreDelta(repoRoot: string): Promise<ScoreDelta> {
|
|
135
|
+
export async function writeScoreDelta(repoRoot: string, options: { minImpactScore?: number } = {}): Promise<ScoreDelta> {
|
|
320
136
|
const metricsDir = resolveRepoPath(repoRoot, '.prodify/metrics');
|
|
321
137
|
const baseline = JSON.parse(await fs.readFile(path.join(metricsDir, 'baseline.score.json'), 'utf8')) as ScoreSnapshot;
|
|
322
138
|
const final = JSON.parse(await fs.readFile(path.join(metricsDir, 'final.score.json'), 'utf8')) as ScoreSnapshot;
|
|
139
|
+
const deltaValue = roundScore(final.total_score - baseline.total_score);
|
|
140
|
+
const threshold = options.minImpactScore;
|
|
323
141
|
const delta: ScoreDelta = {
|
|
324
142
|
schema_version: SCORE_SCHEMA_VERSION,
|
|
325
143
|
baseline_score: baseline.total_score,
|
|
326
144
|
final_score: final.total_score,
|
|
327
|
-
delta:
|
|
145
|
+
delta: deltaValue,
|
|
146
|
+
...(threshold !== undefined ? {
|
|
147
|
+
min_impact_score: threshold,
|
|
148
|
+
passed: deltaValue >= threshold
|
|
149
|
+
} : {})
|
|
328
150
|
};
|
|
329
151
|
|
|
330
152
|
await writeFileEnsuringDir(path.join(metricsDir, 'delta.json'), serializeJson(delta));
|
|
331
153
|
return delta;
|
|
332
154
|
}
|
|
155
|
+
|
|
156
|
+
export async function readScoreDelta(repoRoot: string): Promise<ScoreDelta | null> {
|
|
157
|
+
const deltaPath = resolveRepoPath(repoRoot, '.prodify/metrics/delta.json');
|
|
158
|
+
if (!(await pathExists(deltaPath))) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return JSON.parse(await fs.readFile(deltaPath, 'utf8')) as ScoreDelta;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function syncScoreArtifactsForRuntimeState(repoRoot: string, runtimeState: ProdifyState): Promise<void> {
|
|
166
|
+
if (runtimeState.runtime.current_state === 'bootstrapped' || runtimeState.runtime.current_state === 'understand_pending') {
|
|
167
|
+
await writeScoreSnapshot(repoRoot, {
|
|
168
|
+
kind: 'baseline',
|
|
169
|
+
runtimeState
|
|
170
|
+
});
|
|
171
|
+
await removeIfExists(resolveRepoPath(repoRoot, '.prodify/metrics/final.score.json'));
|
|
172
|
+
await removeIfExists(resolveRepoPath(repoRoot, '.prodify/metrics/final.tools.json'));
|
|
173
|
+
await removeIfExists(resolveRepoPath(repoRoot, '.prodify/metrics/delta.json'));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (runtimeState.runtime.last_validation?.passed && (runtimeState.runtime.current_state === 'validate_complete' || runtimeState.runtime.current_state === 'completed')) {
|
|
177
|
+
await writeScoreSnapshot(repoRoot, {
|
|
178
|
+
kind: 'final',
|
|
179
|
+
runtimeState
|
|
180
|
+
});
|
|
181
|
+
await writeScoreDelta(repoRoot);
|
|
182
|
+
}
|
|
183
|
+
}
|