@urielsh/prodify 0.1.1 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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/README.md +4 -2
  10. package/assets/presets/default/canonical/contracts-src/refactor.contract.md +7 -0
  11. package/assets/presets/default/canonical/contracts-src/validate.contract.md +2 -0
  12. package/dist/commands/setup-agent.js +3 -0
  13. package/dist/contracts/compiled-schema.js +10 -1
  14. package/dist/contracts/source-schema.js +42 -1
  15. package/dist/core/agent-setup.js +116 -1
  16. package/dist/core/diff-validator.js +183 -0
  17. package/dist/core/plan-units.js +64 -0
  18. package/dist/core/repo-root.js +17 -8
  19. package/dist/core/state.js +6 -0
  20. package/dist/core/status.js +14 -0
  21. package/dist/core/validation.js +87 -9
  22. package/dist/scoring/model.js +94 -213
  23. package/dist/scoring/scoring-engine.js +158 -0
  24. package/docs/diff-validator-design.md +44 -0
  25. package/docs/impact-scoring-design.md +38 -0
  26. package/package.json +1 -1
  27. package/src/commands/setup-agent.ts +3 -0
  28. package/src/contracts/compiled-schema.ts +10 -1
  29. package/src/contracts/source-schema.ts +51 -1
  30. package/src/core/agent-setup.ts +126 -2
  31. package/src/core/diff-validator.ts +230 -0
  32. package/src/core/plan-units.ts +82 -0
  33. package/src/core/repo-root.ts +21 -8
  34. package/src/core/state.ts +6 -0
  35. package/src/core/status.ts +17 -0
  36. package/src/core/validation.ts +136 -15
  37. package/src/scoring/model.ts +101 -250
  38. package/src/scoring/scoring-engine.ts +194 -0
  39. package/src/types.ts +55 -0
  40. package/tests/integration/cli-flows.test.js +19 -0
  41. package/tests/unit/agent-setup.test.js +9 -3
  42. package/tests/unit/diff-validator.test.js +28 -0
  43. package/tests/unit/scoring.test.js +42 -1
  44. package/tests/unit/validation.test.js +79 -1
@@ -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 type { CompiledStageContract, FlowStage, ProdifyState, StageValidationResult, ValidationIssue } from '../types.js';
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
  }
@@ -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 { listFilesRecursive, pathExists, writeFileEnsuringDir } from '../core/fs.js';
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 = '1';
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 createMetric(options: {
22
- id: string;
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 detectEcosystems(repoRoot: string): Promise<string[]> {
44
- const ecosystems = [];
45
- if (await pathExists(resolveRepoPath(repoRoot, 'package.json'))) {
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
- async function buildEcosystemMetrics(repoRoot: string, ecosystems: string[]): Promise<{ metrics: ScoreMetric[]; toolOutputs: ToolOutput[] }> {
62
- const metrics: ScoreMetric[] = [];
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
- metric: createMetric({
173
- id: 'runtime-state',
174
- label: 'Contract-driven runtime state',
175
- tool: 'state-json',
176
- weight: 25,
177
- ratio,
178
- details: `current_state=${runtimeState.runtime.current_state}, status=${runtimeState.runtime.status}`
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 buildValidationMetric(runtimeState: ProdifyState): { metric: ScoreMetric; toolOutput: ToolOutput } {
192
- const passed = runtimeState.runtime.last_validation?.passed === true;
193
- const finalReady = runtimeState.runtime.current_state === 'validate_complete' || runtimeState.runtime.current_state === 'completed';
194
- const ratio = passed ? (finalReady ? 1 : 0.5) : 0;
195
-
196
- return {
197
- metric: createMetric({
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: ToolOutput[] }> {
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
- schema_version: SCORE_SCHEMA_VERSION,
279
- kind,
280
- ecosystems,
281
- total_score: totalScore,
282
- max_score: maxScore,
283
- metrics
284
- },
285
- toolOutputs
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 serializeJson(value: unknown): string {
290
- return `${JSON.stringify(value, null, 2)}\n`;
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: roundScore(final.total_score - baseline.total_score)
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
+ }