@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/dist/core/validation.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import { loadCompiledContract } from '../contracts/compiler.js';
|
|
3
|
+
import { diffAgainstRefactorBaseline } from './diff-validator.js';
|
|
3
4
|
import { normalizeRepoRelativePath, resolveRepoPath } from './paths.js';
|
|
5
|
+
import { readPlanUnits, readSelectedRefactorStep } from './plan-units.js';
|
|
6
|
+
import { calculateCurrentImpactDelta } from '../scoring/model.js';
|
|
4
7
|
function pathIsWithin(pathToCheck, root) {
|
|
5
8
|
const normalizedPath = normalizeRepoRelativePath(pathToCheck);
|
|
6
9
|
const normalizedRoot = normalizeRepoRelativePath(root);
|
|
@@ -35,6 +38,12 @@ function containsAllStatements(sectionContent, statements) {
|
|
|
35
38
|
function buildRule(rule, message, path) {
|
|
36
39
|
return { rule, message, path };
|
|
37
40
|
}
|
|
41
|
+
function hasRequiredStructuralChanges(diffResult, requiredChanges) {
|
|
42
|
+
if (requiredChanges.length === 0) {
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
return requiredChanges.every((requiredChange) => diffResult.structuralChanges.structural_change_flags.includes(requiredChange));
|
|
46
|
+
}
|
|
38
47
|
async function validateArtifact(repoRoot, contract, artifact) {
|
|
39
48
|
const artifactPath = resolveRepoPath(repoRoot, artifact.path);
|
|
40
49
|
const issues = [];
|
|
@@ -43,9 +52,6 @@ async function validateArtifact(repoRoot, contract, artifact) {
|
|
|
43
52
|
try {
|
|
44
53
|
const content = await fs.readFile(artifactPath, 'utf8');
|
|
45
54
|
diagnostics.push(`validated artifact ${artifact.path}`);
|
|
46
|
-
if (!contract.allowed_write_roots.some((root) => pathIsWithin(artifact.path, root))) {
|
|
47
|
-
issues.push(buildRule('artifact/outside-allowed-roots', `Required artifact ${artifact.path} is outside allowed write roots.`, artifact.path));
|
|
48
|
-
}
|
|
49
55
|
if (artifact.format === 'markdown') {
|
|
50
56
|
const sections = collectMarkdownSections(content);
|
|
51
57
|
for (const section of artifact.required_sections) {
|
|
@@ -81,11 +87,15 @@ async function validateArtifact(repoRoot, contract, artifact) {
|
|
|
81
87
|
diagnostics
|
|
82
88
|
};
|
|
83
89
|
}
|
|
84
|
-
export async function validateStageOutputs(repoRoot, { contract, runtimeState, touchedPaths = [] }) {
|
|
90
|
+
export async function validateStageOutputs(repoRoot, { contract, runtimeState, touchedPaths = [], diffResult = null }) {
|
|
85
91
|
const violatedRules = [];
|
|
86
92
|
const missingArtifacts = [];
|
|
87
93
|
const diagnostics = [];
|
|
88
94
|
const warnings = [];
|
|
95
|
+
const effectiveDiffResult = diffResult ?? await diffAgainstRefactorBaseline(repoRoot);
|
|
96
|
+
const impactDelta = contract.min_impact_score > 0
|
|
97
|
+
? await calculateCurrentImpactDelta(repoRoot)
|
|
98
|
+
: null;
|
|
89
99
|
if (runtimeState.runtime.current_stage !== contract.stage) {
|
|
90
100
|
violatedRules.push(buildRule('runtime/stage-mismatch', `Runtime stage ${runtimeState.runtime.current_stage ?? 'none'} does not match contract stage ${contract.stage}.`));
|
|
91
101
|
}
|
|
@@ -106,6 +116,70 @@ export async function validateStageOutputs(repoRoot, { contract, runtimeState, t
|
|
|
106
116
|
if (touchedPaths.length === 0) {
|
|
107
117
|
warnings.push('No touched paths were provided; forbidden-write checks are limited to required artifacts.');
|
|
108
118
|
}
|
|
119
|
+
if (contract.enforce_plan_units) {
|
|
120
|
+
try {
|
|
121
|
+
const [planUnits, selectedStep] = await Promise.all([
|
|
122
|
+
readPlanUnits(repoRoot),
|
|
123
|
+
readSelectedRefactorStep(repoRoot)
|
|
124
|
+
]);
|
|
125
|
+
if (!selectedStep) {
|
|
126
|
+
violatedRules.push(buildRule('plan/selected-step-missing', 'Refactor artifact does not declare the selected plan unit.'));
|
|
127
|
+
}
|
|
128
|
+
else if (!planUnits.some((unit) => unit.id === selectedStep.id)) {
|
|
129
|
+
violatedRules.push(buildRule('plan/selected-step-invalid', `Selected refactor step ${selectedStep.id} does not exist in 04-plan.md.`));
|
|
130
|
+
}
|
|
131
|
+
else {
|
|
132
|
+
diagnostics.push(`validated selected plan unit ${selectedStep.id}`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
violatedRules.push(buildRule('plan/unreadable', 'Plan-unit validation could not read 04-plan.md or 05-refactor.md.'));
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (effectiveDiffResult && (contract.diff_validation_rules.minimum_files_modified > 0
|
|
140
|
+
|| contract.diff_validation_rules.minimum_lines_changed > 0
|
|
141
|
+
|| contract.diff_validation_rules.must_create_files
|
|
142
|
+
|| contract.diff_validation_rules.required_structural_changes.length > 0)) {
|
|
143
|
+
const changedFiles = effectiveDiffResult.filesModified + effectiveDiffResult.filesAdded + effectiveDiffResult.filesDeleted;
|
|
144
|
+
const changedLines = effectiveDiffResult.linesAdded + effectiveDiffResult.linesRemoved;
|
|
145
|
+
const formattingOnly = effectiveDiffResult.filesModified > 0
|
|
146
|
+
&& effectiveDiffResult.filesModified === effectiveDiffResult.formattingOnlyPaths.length
|
|
147
|
+
&& effectiveDiffResult.filesAdded === 0
|
|
148
|
+
&& effectiveDiffResult.filesDeleted === 0;
|
|
149
|
+
if (changedFiles < contract.diff_validation_rules.minimum_files_modified) {
|
|
150
|
+
violatedRules.push(buildRule('diff/minimum-files-modified', `Refactor changed ${changedFiles} files but requires at least ${contract.diff_validation_rules.minimum_files_modified}.`));
|
|
151
|
+
}
|
|
152
|
+
if (changedLines < contract.diff_validation_rules.minimum_lines_changed) {
|
|
153
|
+
violatedRules.push(buildRule('diff/minimum-lines-changed', `Refactor changed ${changedLines} lines but requires at least ${contract.diff_validation_rules.minimum_lines_changed}.`));
|
|
154
|
+
}
|
|
155
|
+
if (contract.diff_validation_rules.must_create_files && effectiveDiffResult.filesAdded === 0) {
|
|
156
|
+
violatedRules.push(buildRule('diff/must-create-files', 'Refactor must create at least one new file.'));
|
|
157
|
+
}
|
|
158
|
+
if (formattingOnly) {
|
|
159
|
+
violatedRules.push(buildRule('diff/formatting-only', 'Refactor changes are formatting-only and do not count as meaningful code change.'));
|
|
160
|
+
}
|
|
161
|
+
if (!hasRequiredStructuralChanges(effectiveDiffResult, contract.diff_validation_rules.required_structural_changes)) {
|
|
162
|
+
violatedRules.push(buildRule('diff/required-structural-changes', `Refactor is missing required structural changes: ${contract.diff_validation_rules.required_structural_changes.join(', ')}.`));
|
|
163
|
+
}
|
|
164
|
+
diagnostics.push(`validated diff: files=${changedFiles}, lines=${changedLines}, structural=${effectiveDiffResult.structuralChanges.structural_change_flags.join(',') || 'none'}`);
|
|
165
|
+
}
|
|
166
|
+
else if (contract.diff_validation_rules.minimum_files_modified > 0
|
|
167
|
+
|| contract.diff_validation_rules.minimum_lines_changed > 0
|
|
168
|
+
|| contract.diff_validation_rules.must_create_files
|
|
169
|
+
|| contract.diff_validation_rules.required_structural_changes.length > 0) {
|
|
170
|
+
violatedRules.push(buildRule('diff/baseline-missing', 'Diff validation rules are configured but no refactor baseline snapshot was available.'));
|
|
171
|
+
}
|
|
172
|
+
if (contract.min_impact_score > 0) {
|
|
173
|
+
if (!impactDelta) {
|
|
174
|
+
violatedRules.push(buildRule('impact-score/missing-baseline', 'Impact score threshold is configured but no baseline score is available.'));
|
|
175
|
+
}
|
|
176
|
+
else if (impactDelta.delta < contract.min_impact_score) {
|
|
177
|
+
violatedRules.push(buildRule('impact-score/minimum-threshold', `Impact score delta ${impactDelta.delta} is below the required threshold ${contract.min_impact_score}.`));
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
diagnostics.push(`validated impact score delta ${impactDelta.delta}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
109
183
|
return {
|
|
110
184
|
stage: contract.stage,
|
|
111
185
|
contract_version: contract.contract_version,
|
|
@@ -113,10 +187,12 @@ export async function validateStageOutputs(repoRoot, { contract, runtimeState, t
|
|
|
113
187
|
violated_rules: violatedRules,
|
|
114
188
|
missing_artifacts: [...new Set(missingArtifacts)].sort((left, right) => left.localeCompare(right)),
|
|
115
189
|
warnings,
|
|
116
|
-
diagnostics
|
|
190
|
+
diagnostics,
|
|
191
|
+
...(effectiveDiffResult ? { diff_result: effectiveDiffResult } : {}),
|
|
192
|
+
...(impactDelta ? { impact_score_delta: impactDelta.delta } : {})
|
|
117
193
|
};
|
|
118
194
|
}
|
|
119
|
-
export async function validateStageOutputsForCurrentState(repoRoot, { runtimeState, touchedPaths = [] }) {
|
|
195
|
+
export async function validateStageOutputsForCurrentState(repoRoot, { runtimeState, touchedPaths = [], diffResult = null }) {
|
|
120
196
|
const stage = runtimeState.runtime.current_stage;
|
|
121
197
|
if (!stage) {
|
|
122
198
|
throw new Error('Cannot validate stage outputs when no stage is active.');
|
|
@@ -125,14 +201,16 @@ export async function validateStageOutputsForCurrentState(repoRoot, { runtimeSta
|
|
|
125
201
|
return validateStageOutputs(repoRoot, {
|
|
126
202
|
contract,
|
|
127
203
|
runtimeState,
|
|
128
|
-
touchedPaths
|
|
204
|
+
touchedPaths,
|
|
205
|
+
diffResult
|
|
129
206
|
});
|
|
130
207
|
}
|
|
131
|
-
export async function validateStageOutputsForStage(repoRoot, { stage, runtimeState, touchedPaths = [] }) {
|
|
208
|
+
export async function validateStageOutputsForStage(repoRoot, { stage, runtimeState, touchedPaths = [], diffResult = null }) {
|
|
132
209
|
const contract = await loadCompiledContract(repoRoot, stage);
|
|
133
210
|
return validateStageOutputs(repoRoot, {
|
|
134
211
|
contract,
|
|
135
212
|
runtimeState,
|
|
136
|
-
touchedPaths
|
|
213
|
+
touchedPaths,
|
|
214
|
+
diffResult
|
|
137
215
|
});
|
|
138
216
|
}
|
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
|
+
}
|