@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.
Files changed (36) hide show
  1. package/.prodify/contracts/architecture.contract.json +9 -1
  2. package/.prodify/contracts/diagnose.contract.json +9 -1
  3. package/.prodify/contracts/plan.contract.json +9 -1
  4. package/.prodify/contracts/refactor.contract.json +13 -2
  5. package/.prodify/contracts/understand.contract.json +9 -1
  6. package/.prodify/contracts/validate.contract.json +11 -2
  7. package/.prodify/contracts-src/refactor.contract.md +7 -0
  8. package/.prodify/contracts-src/validate.contract.md +2 -0
  9. package/assets/presets/default/canonical/contracts-src/refactor.contract.md +7 -0
  10. package/assets/presets/default/canonical/contracts-src/validate.contract.md +2 -0
  11. package/dist/contracts/compiled-schema.js +10 -1
  12. package/dist/contracts/source-schema.js +42 -1
  13. package/dist/core/diff-validator.js +183 -0
  14. package/dist/core/plan-units.js +64 -0
  15. package/dist/core/state.js +6 -0
  16. package/dist/core/status.js +14 -0
  17. package/dist/core/validation.js +87 -9
  18. package/dist/scoring/model.js +94 -213
  19. package/dist/scoring/scoring-engine.js +158 -0
  20. package/docs/diff-validator-design.md +44 -0
  21. package/docs/impact-scoring-design.md +38 -0
  22. package/package.json +1 -1
  23. package/src/contracts/compiled-schema.ts +10 -1
  24. package/src/contracts/source-schema.ts +51 -1
  25. package/src/core/diff-validator.ts +230 -0
  26. package/src/core/plan-units.ts +82 -0
  27. package/src/core/state.ts +6 -0
  28. package/src/core/status.ts +17 -0
  29. package/src/core/validation.ts +136 -15
  30. package/src/scoring/model.ts +101 -250
  31. package/src/scoring/scoring-engine.ts +194 -0
  32. package/src/types.ts +55 -0
  33. package/tests/integration/cli-flows.test.js +1 -0
  34. package/tests/unit/diff-validator.test.js +28 -0
  35. package/tests/unit/scoring.test.js +42 -1
  36. package/tests/unit/validation.test.js +79 -1
@@ -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
  }
@@ -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 { listFilesRecursive, pathExists, writeFileEnsuringDir } from '../core/fs.js';
4
+ import { pathExists, writeFileEnsuringDir } from '../core/fs.js';
6
5
  import { resolveRepoPath } from '../core/paths.js';
7
- const SCORE_SCHEMA_VERSION = '1';
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 createMetric(options) {
12
- const ratio = Math.max(0, Math.min(1, options.ratio));
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 buildEcosystemMetrics(repoRoot, ecosystems) {
40
- const metrics = [];
41
- const toolOutputs = [];
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
- async function buildRepoHygieneMetric(repoRoot) {
115
- const requiredSignals = ['README.md', 'LICENSE', 'tests', '.prodify/contracts-src'];
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
- metric: createMetric({
120
- id: 'repo-hygiene',
121
- label: 'Repository hygiene signals',
122
- tool: 'filesystem',
123
- weight: 20,
124
- ratio,
125
- details: requiredSignals.map((entry, index) => `${entry}=${available[index]}`).join(', ')
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 buildRuntimeMetric(runtimeState) {
134
- const healthyState = runtimeState.runtime.current_state !== 'failed' && runtimeState.runtime.current_state !== 'blocked';
135
- const ratio = healthyState ? 1 : 0;
136
- return {
137
- metric: createMetric({
138
- id: 'runtime-state',
139
- label: 'Contract-driven runtime state',
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
- schema_version: SCORE_SCHEMA_VERSION,
224
- kind,
225
- ecosystems,
226
- total_score: totalScore,
227
- max_score: maxScore,
228
- metrics
229
- },
230
- toolOutputs
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 serializeJson(value) {
234
- return `${JSON.stringify(value, null, 2)}\n`;
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: roundScore(final.total_score - baseline.total_score)
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
+ }