@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
@@ -0,0 +1,194 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { listFilesRecursive } from '../core/fs.js';
5
+ import { normalizeRepoRelativePath } from '../core/paths.js';
6
+ import type { ScoreBreakdown, ScoreSignals, ScoreSnapshot } from '../types.js';
7
+
8
+ const SCORE_WEIGHTS: ScoreBreakdown = {
9
+ structure: 30,
10
+ maintainability: 30,
11
+ complexity: 20,
12
+ testability: 20
13
+ };
14
+
15
+ const CODE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.py', '.cs']);
16
+
17
+ interface SourceFile {
18
+ path: string;
19
+ content: string;
20
+ }
21
+
22
+ function round(value: number): number {
23
+ return Math.round(value * 100) / 100;
24
+ }
25
+
26
+ function clamp(value: number, min = 0, max = 100): number {
27
+ return Math.max(min, Math.min(max, value));
28
+ }
29
+
30
+ function scoreInverse(value: number, ideal: number, tolerance: number): number {
31
+ if (value <= ideal) {
32
+ return 100;
33
+ }
34
+
35
+ return clamp(100 - (((value - ideal) / tolerance) * 100));
36
+ }
37
+
38
+ function extractImports(content: string): string[] {
39
+ return content
40
+ .split('\n')
41
+ .map((line) => line.trim())
42
+ .filter((line) => line.startsWith('import ') || line.startsWith('export ') || line.includes('require('));
43
+ }
44
+
45
+ function extractDependencyDepth(imports: string[]): number {
46
+ const relativeImports = imports
47
+ .map((line) => /from\s+['"](.+?)['"]/.exec(line)?.[1] ?? /require\(['"](.+?)['"]\)/.exec(line)?.[1] ?? '')
48
+ .filter((specifier) => specifier.startsWith('./') || specifier.startsWith('../'));
49
+
50
+ if (relativeImports.length === 0) {
51
+ return 0;
52
+ }
53
+
54
+ return Math.max(...relativeImports.map((specifier) => specifier.split('/').filter((segment) => segment === '..').length));
55
+ }
56
+
57
+ function extractFunctionLengths(content: string): number[] {
58
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
59
+ const lengths: number[] = [];
60
+ let captureDepth = 0;
61
+ let currentLength = 0;
62
+ let tracking = false;
63
+
64
+ for (const line of lines) {
65
+ const trimmed = line.trim();
66
+ const startsFunction = /^(export\s+)?(async\s+)?function\s+\w+/.test(trimmed)
67
+ || /^(export\s+)?const\s+\w+\s*=\s*(async\s*)?\(/.test(trimmed)
68
+ || /^(public\s+|private\s+|protected\s+)?(async\s+)?\w+\s*\(.*\)\s*\{?$/.test(trimmed);
69
+
70
+ if (startsFunction && trimmed.includes('{')) {
71
+ tracking = true;
72
+ currentLength = 1;
73
+ captureDepth = (trimmed.match(/\{/g) ?? []).length - (trimmed.match(/\}/g) ?? []).length;
74
+ if (captureDepth <= 0) {
75
+ lengths.push(currentLength);
76
+ tracking = false;
77
+ currentLength = 0;
78
+ captureDepth = 0;
79
+ }
80
+ continue;
81
+ }
82
+
83
+ if (!tracking) {
84
+ continue;
85
+ }
86
+
87
+ currentLength += 1;
88
+ captureDepth += (trimmed.match(/\{/g) ?? []).length;
89
+ captureDepth -= (trimmed.match(/\}/g) ?? []).length;
90
+ if (captureDepth <= 0) {
91
+ lengths.push(currentLength);
92
+ tracking = false;
93
+ currentLength = 0;
94
+ captureDepth = 0;
95
+ }
96
+ }
97
+
98
+ return lengths;
99
+ }
100
+
101
+ async function collectSourceFiles(repoRoot: string): Promise<SourceFile[]> {
102
+ const allFiles = await listFilesRecursive(repoRoot);
103
+ const sourceFiles: SourceFile[] = [];
104
+
105
+ for (const file of allFiles) {
106
+ const relativePath = normalizeRepoRelativePath(file.relativePath);
107
+ if (relativePath.startsWith('.git/')
108
+ || relativePath.startsWith('.prodify/')
109
+ || relativePath.startsWith('.agent/')
110
+ || relativePath.startsWith('.codex/')
111
+ || relativePath.startsWith('dist/')
112
+ || relativePath.startsWith('node_modules/')) {
113
+ continue;
114
+ }
115
+
116
+ if (!CODE_EXTENSIONS.has(path.extname(relativePath))) {
117
+ continue;
118
+ }
119
+
120
+ sourceFiles.push({
121
+ path: relativePath,
122
+ content: await fs.readFile(file.fullPath, 'utf8')
123
+ });
124
+ }
125
+
126
+ return sourceFiles.sort((left, right) => left.path.localeCompare(right.path));
127
+ }
128
+
129
+ function deriveSignals(sourceFiles: SourceFile[]): ScoreSignals {
130
+ const functionLengths = sourceFiles.flatMap((file) => extractFunctionLengths(file.content));
131
+ const allImports = sourceFiles.map((file) => extractImports(file.content));
132
+ const moduleCount = sourceFiles.length;
133
+ const averageFunctionLength = functionLengths.length === 0
134
+ ? 0
135
+ : functionLengths.reduce((sum, value) => sum + value, 0) / functionLengths.length;
136
+ const averageDirectoryDepth = moduleCount === 0
137
+ ? 0
138
+ : sourceFiles
139
+ .map((file) => path.posix.dirname(file.path))
140
+ .map((directory) => directory === '.' ? 0 : directory.split('/').length)
141
+ .reduce((sum, depth) => sum + depth, 0) / moduleCount;
142
+ const dependencyDepth = allImports.length === 0
143
+ ? 0
144
+ : Math.max(...allImports.map((imports) => extractDependencyDepth(imports)));
145
+ const averageImportsPerModule = moduleCount === 0
146
+ ? 0
147
+ : allImports.reduce((sum, imports) => sum + imports.length, 0) / moduleCount;
148
+ const testFiles = sourceFiles.filter((file) => file.path.startsWith('tests/') || /\.test\./.test(file.path)).length;
149
+ const sourceModules = Math.max(1, sourceFiles.filter((file) => file.path.startsWith('src/')).length);
150
+ const testFileRatio = testFiles / sourceModules;
151
+
152
+ return {
153
+ average_function_length: round(averageFunctionLength),
154
+ module_count: moduleCount,
155
+ average_directory_depth: round(averageDirectoryDepth),
156
+ dependency_depth: dependencyDepth,
157
+ average_imports_per_module: round(averageImportsPerModule),
158
+ test_file_ratio: round(testFileRatio)
159
+ };
160
+ }
161
+
162
+ function buildBreakdown(signals: ScoreSignals): ScoreBreakdown {
163
+ const structure = round(((clamp(signals.module_count * 5, 0, 60)) + clamp(signals.average_directory_depth * 15, 0, 40)));
164
+ const maintainability = round((scoreInverse(signals.average_function_length, 18, 40) * 0.7) + (scoreInverse(signals.average_imports_per_module, 4, 8) * 0.3));
165
+ const complexity = round((scoreInverse(signals.dependency_depth, 1, 5) * 0.6) + (scoreInverse(signals.average_imports_per_module, 4, 8) * 0.4));
166
+ const testability = round(clamp(signals.test_file_ratio * 100, 0, 100));
167
+
168
+ return {
169
+ structure: clamp(structure),
170
+ maintainability: clamp(maintainability),
171
+ complexity: clamp(complexity),
172
+ testability: clamp(testability)
173
+ };
174
+ }
175
+
176
+ export async function calculateRepositoryQuality(repoRoot: string): Promise<Pick<ScoreSnapshot, 'breakdown' | 'signals' | 'total_score' | 'max_score' | 'weights'>> {
177
+ const sourceFiles = await collectSourceFiles(repoRoot);
178
+ const signals = deriveSignals(sourceFiles);
179
+ const breakdown = buildBreakdown(signals);
180
+ const total = round(
181
+ (breakdown.structure * (SCORE_WEIGHTS.structure / 100))
182
+ + (breakdown.maintainability * (SCORE_WEIGHTS.maintainability / 100))
183
+ + (breakdown.complexity * (SCORE_WEIGHTS.complexity / 100))
184
+ + (breakdown.testability * (SCORE_WEIGHTS.testability / 100))
185
+ );
186
+
187
+ return {
188
+ breakdown,
189
+ signals,
190
+ total_score: total,
191
+ max_score: 100,
192
+ weights: SCORE_WEIGHTS
193
+ };
194
+ }
package/src/types.ts CHANGED
@@ -119,6 +119,34 @@ export interface StageSkillRouting {
119
119
  conditional_skills: StageSkillRoutingRule[];
120
120
  }
121
121
 
122
+ export interface DiffValidationRules {
123
+ minimum_files_modified: number;
124
+ minimum_lines_changed: number;
125
+ must_create_files: boolean;
126
+ required_structural_changes: string[];
127
+ }
128
+
129
+ export interface StructuralChangeSummary {
130
+ new_directories: string[];
131
+ new_layer_directories: string[];
132
+ files_with_reduced_responsibility: string[];
133
+ new_modules: string[];
134
+ structural_change_flags: string[];
135
+ }
136
+
137
+ export interface DiffResult {
138
+ filesModified: number;
139
+ filesAdded: number;
140
+ filesDeleted: number;
141
+ linesAdded: number;
142
+ linesRemoved: number;
143
+ modifiedPaths: string[];
144
+ addedPaths: string[];
145
+ deletedPaths: string[];
146
+ formattingOnlyPaths: string[];
147
+ structuralChanges: StructuralChangeSummary;
148
+ }
149
+
122
150
  export interface CompiledStageContract {
123
151
  schema_version: string;
124
152
  contract_version: string;
@@ -132,6 +160,9 @@ export interface CompiledStageContract {
132
160
  policy_rules: string[];
133
161
  success_criteria: string[];
134
162
  skill_routing: StageSkillRouting;
163
+ diff_validation_rules: DiffValidationRules;
164
+ min_impact_score: number;
165
+ enforce_plan_units: boolean;
135
166
  }
136
167
 
137
168
  export interface ContractSourceDocument {
@@ -163,6 +194,8 @@ export interface StageValidationResult {
163
194
  missing_artifacts: string[];
164
195
  warnings: string[];
165
196
  diagnostics: string[];
197
+ diff_result?: DiffResult;
198
+ impact_score_delta?: number;
166
199
  }
167
200
 
168
201
  export interface RuntimeFailureMetadata {
@@ -265,6 +298,7 @@ export interface StatusReport {
265
298
  bootstrapProfile: RuntimeProfileName;
266
299
  bootstrapPrompt: string;
267
300
  stageSkillResolution: StageSkillResolution | null;
301
+ scoreDelta: ScoreDelta | null;
268
302
  recommendedNextAction: string;
269
303
  presetMetadata: VersionMetadata;
270
304
  }
@@ -300,12 +334,31 @@ export interface ScoreMetric {
300
334
  details: string;
301
335
  }
302
336
 
337
+ export interface ScoreBreakdown {
338
+ structure: number;
339
+ maintainability: number;
340
+ complexity: number;
341
+ testability: number;
342
+ }
343
+
344
+ export interface ScoreSignals {
345
+ average_function_length: number;
346
+ module_count: number;
347
+ average_directory_depth: number;
348
+ dependency_depth: number;
349
+ average_imports_per_module: number;
350
+ test_file_ratio: number;
351
+ }
352
+
303
353
  export interface ScoreSnapshot {
304
354
  schema_version: string;
305
355
  kind: ScoreSnapshotKind;
306
356
  ecosystems: string[];
307
357
  total_score: number;
308
358
  max_score: number;
359
+ breakdown: ScoreBreakdown;
360
+ weights: ScoreBreakdown;
361
+ signals: ScoreSignals;
309
362
  metrics: ScoreMetric[];
310
363
  }
311
364
 
@@ -314,6 +367,8 @@ export interface ScoreDelta {
314
367
  baseline_score: number;
315
368
  final_score: number;
316
369
  delta: number;
370
+ min_impact_score?: number;
371
+ passed?: boolean;
317
372
  }
318
373
 
319
374
  export interface AgentSetupRecord {
@@ -91,6 +91,7 @@ test('status becomes the primary user-facing summary after init', async () => {
91
91
  assert.match(result.stdout, /Skills active: codebase-scanning/);
92
92
  assert.match(result.stdout, /Execution state: not bootstrapped/);
93
93
  assert.match(result.stdout, /Stage validation: not run yet/);
94
+ assert.match(result.stdout, /Impact score: not available/);
94
95
  assert.match(result.stdout, /Manual bootstrap: ready/);
95
96
  assert.match(result.stdout, /Bootstrap prompt: Read \.prodify\/AGENTS\.md/);
96
97
  assert.match(result.stdout, /Recommended next action: prodify setup-agent <agent>/);
@@ -0,0 +1,28 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import { captureRepoSnapshot, diffSnapshots } from '../../dist/core/diff-validator.js';
7
+ import { createTempRepo } from './helpers.js';
8
+
9
+ test('diff validator detects added modules, line changes, and structural improvements', async () => {
10
+ const repoRoot = await createTempRepo();
11
+ await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true });
12
+ await fs.writeFile(path.join(repoRoot, 'src', 'routes.ts'), `export function routeUser() {\n const user = loadUser();\n return user;\n}\n`, 'utf8');
13
+
14
+ const before = await captureRepoSnapshot(repoRoot);
15
+
16
+ await fs.mkdir(path.join(repoRoot, 'src', 'services'), { recursive: true });
17
+ await fs.writeFile(path.join(repoRoot, 'src', 'routes.ts'), `import { loadUserService } from './services/user-service.js';\n\nexport function routeUser() {\n return loadUserService();\n}\n`, 'utf8');
18
+ await fs.writeFile(path.join(repoRoot, 'src', 'services', 'user-service.ts'), `export function loadUserService() {\n return loadUser();\n}\n`, 'utf8');
19
+
20
+ const after = await captureRepoSnapshot(repoRoot);
21
+ const diff = diffSnapshots(before, after);
22
+
23
+ assert.equal(diff.filesAdded, 1);
24
+ assert.equal(diff.filesModified, 1);
25
+ assert.match(diff.structuralChanges.structural_change_flags.join(','), /module-boundary-created/);
26
+ assert.match(diff.structuralChanges.structural_change_flags.join(','), /responsibility-reduced/);
27
+ assert.deepEqual(diff.structuralChanges.new_layer_directories, ['src/services']);
28
+ });
@@ -5,7 +5,7 @@ import path from 'node:path';
5
5
 
6
6
  import { runCli } from '../../dist/cli.js';
7
7
  import { bootstrapFlowState, startFlowExecution } from '../../dist/core/flow-state.js';
8
- import { readRuntimeState } from '../../dist/core/state.js';
8
+ import { readRuntimeState, writeRuntimeState } from '../../dist/core/state.js';
9
9
  import { writeScoreDelta, writeScoreSnapshot } from '../../dist/scoring/model.js';
10
10
  import { createTempRepo, memoryStream } from './helpers.js';
11
11
 
@@ -58,8 +58,49 @@ test('baseline, final, and delta score artifacts are written under .prodify/metr
58
58
 
59
59
  assert.equal(typeof baseline.total_score, 'number');
60
60
  assert.equal(typeof final.total_score, 'number');
61
+ assert.equal(typeof baseline.breakdown.structure, 'number');
62
+ assert.equal(typeof final.signals.average_function_length, 'number');
61
63
  assert.equal(delta.delta, Number((final.total_score - baseline.total_score).toFixed(2)));
62
64
  await fs.access(path.join(repoRoot, '.prodify', 'metrics', 'baseline.score.json'));
63
65
  await fs.access(path.join(repoRoot, '.prodify', 'metrics', 'final.score.json'));
64
66
  await fs.access(path.join(repoRoot, '.prodify', 'metrics', 'delta.json'));
65
67
  });
68
+
69
+ test('runtime state writes refresh baseline and final scoring artifacts automatically', async () => {
70
+ const repoRoot = await createTempRepo();
71
+ await execCli(repoRoot, ['init']);
72
+
73
+ const state = await readRuntimeState(repoRoot, {
74
+ presetMetadata: {
75
+ name: 'default',
76
+ version: '4.0.0',
77
+ schemaVersion: '4'
78
+ }
79
+ });
80
+
81
+ const bootstrapped = bootstrapFlowState(state, {
82
+ agent: 'codex',
83
+ mode: 'interactive'
84
+ });
85
+ await writeRuntimeState(repoRoot, bootstrapped);
86
+
87
+ const finalState = startFlowExecution(bootstrapped);
88
+ finalState.runtime.current_state = 'validate_complete';
89
+ finalState.runtime.current_stage = 'validate';
90
+ finalState.runtime.current_task_id = '06-validate';
91
+ finalState.runtime.last_validation = {
92
+ stage: 'validate',
93
+ contract_version: '1.0.0',
94
+ passed: true,
95
+ violated_rules: [],
96
+ missing_artifacts: [],
97
+ warnings: [],
98
+ diagnostics: ['ok']
99
+ };
100
+ finalState.runtime.last_validation_result = 'pass';
101
+ await writeRuntimeState(repoRoot, finalState);
102
+
103
+ await fs.access(path.join(repoRoot, '.prodify', 'metrics', 'baseline.score.json'));
104
+ await fs.access(path.join(repoRoot, '.prodify', 'metrics', 'final.score.json'));
105
+ await fs.access(path.join(repoRoot, '.prodify', 'metrics', 'delta.json'));
106
+ });
@@ -6,7 +6,8 @@ import path from 'node:path';
6
6
  import { runCli } from '../../dist/cli.js';
7
7
  import { loadCompiledContract } from '../../dist/contracts/compiler.js';
8
8
  import { bootstrapFlowState, startFlowExecution } from '../../dist/core/flow-state.js';
9
- import { readRuntimeState } from '../../dist/core/state.js';
9
+ import { diffAgainstRefactorBaseline } from '../../dist/core/diff-validator.js';
10
+ import { readRuntimeState, writeRuntimeState } from '../../dist/core/state.js';
10
11
  import { validateStageOutputs } from '../../dist/core/validation.js';
11
12
  import { createTempRepo, memoryStream } from './helpers.js';
12
13
 
@@ -71,3 +72,80 @@ test('missing artifacts and forbidden writes fail contract validation determinis
71
72
  assert.match(result.violated_rules.map((issue) => issue.rule).join(','), /artifact\/missing/);
72
73
  assert.match(result.violated_rules.map((issue) => issue.rule).join(','), /writes\/forbidden/);
73
74
  });
75
+
76
+ test('refactor validation enforces plan units, diff thresholds, and structural changes', async () => {
77
+ const repoRoot = await createTempRepo();
78
+ await execCli(repoRoot, ['init']);
79
+ await fs.mkdir(path.join(repoRoot, 'src'), { recursive: true });
80
+ await fs.writeFile(path.join(repoRoot, 'src', 'legacy.ts'), `export function legacyFlow() {\n const record = loadLegacy();\n const normalized = normalizeLegacy(record);\n const payload = buildLegacyPayload(normalized);\n return payload;\n}\n`, 'utf8');
81
+
82
+ const state = await readRuntimeState(repoRoot, {
83
+ presetMetadata: {
84
+ name: 'default',
85
+ version: '4.0.0',
86
+ schemaVersion: '4'
87
+ }
88
+ });
89
+
90
+ const refactorState = bootstrapFlowState(state, {
91
+ agent: 'codex',
92
+ mode: 'interactive'
93
+ });
94
+ refactorState.runtime.current_state = 'refactor_pending';
95
+ refactorState.runtime.current_stage = 'refactor';
96
+ refactorState.runtime.current_task_id = '05-refactor';
97
+ await writeRuntimeState(repoRoot, refactorState);
98
+
99
+ await fs.writeFile(path.join(repoRoot, '.prodify', 'artifacts', '04-plan.md'), `# 04-plan\n\n## Policy Checks\n- Keep the plan deterministic and minimal.\n- Map every step back to a diagnosed issue or architecture rule.\n\n## Risks\n- low\n\n## Step Breakdown\n- Step ID: step-01-extract-service\n - Description: extract service module from legacy flow.\n - Files: src/legacy.ts, src/services/legacy-service.ts\n - Risk: 2\n - Validation: npm test\n\n## Success Criteria\n- The plan enumerates executable steps.\n- Verification is defined before refactoring starts.\n\n## Verification\n- npm test\n`, 'utf8');
100
+ await fs.writeFile(path.join(repoRoot, '.prodify', 'artifacts', '05-refactor.md'), `# 05-refactor\n\n## Behavior Guardrails\n- keep the change scoped to one plan unit.\n\n## Changed Files\n- src/legacy.ts\n- src/services/legacy-service.ts\n\n## Policy Checks\n- Execute exactly one selected step.\n- Keep the diff minimal and behavior-preserving unless the plan says otherwise.\n\n## Selected Step\n- Step ID: step-01-extract-service\n- Description: extract service module from legacy flow.\n\n## Success Criteria\n- The selected plan step is implemented fully.\n- Unrelated files remain untouched.\n- The refactor introduces measurable structural improvement.\n`, 'utf8');
101
+
102
+ await fs.mkdir(path.join(repoRoot, 'src', 'services'), { recursive: true });
103
+ await fs.writeFile(path.join(repoRoot, 'src', 'legacy.ts'), `import { loadLegacyService } from './services/legacy-service.js';\n\nexport function legacyFlow() {\n return loadLegacyService();\n}\n`, 'utf8');
104
+ await fs.writeFile(path.join(repoRoot, 'src', 'services', 'legacy-service.ts'), `export function loadLegacyService() {\n const record = loadLegacy();\n const normalized = normalizeLegacy(record);\n return buildLegacyPayload(normalized);\n}\n`, 'utf8');
105
+
106
+ const diffResult = await diffAgainstRefactorBaseline(repoRoot);
107
+ const result = await validateStageOutputs(repoRoot, {
108
+ contract: await loadCompiledContract(repoRoot, 'refactor'),
109
+ runtimeState: refactorState,
110
+ touchedPaths: ['src/legacy.ts', 'src/services/legacy-service.ts', '.prodify/artifacts/05-refactor.md'],
111
+ diffResult
112
+ });
113
+
114
+ assert.equal(result.passed, true);
115
+ assert.equal(result.diff_result?.filesAdded, 1);
116
+ assert.match(result.diff_result?.structuralChanges.structural_change_flags.join(',') ?? '', /module-boundary-created/);
117
+ });
118
+
119
+ test('validate stage fails when impact score delta is below the configured threshold', async () => {
120
+ const repoRoot = await createTempRepo();
121
+ await execCli(repoRoot, ['init']);
122
+
123
+ const state = await readRuntimeState(repoRoot, {
124
+ presetMetadata: {
125
+ name: 'default',
126
+ version: '4.0.0',
127
+ schemaVersion: '4'
128
+ }
129
+ });
130
+
131
+ const bootstrapped = bootstrapFlowState(state, {
132
+ agent: 'codex',
133
+ mode: 'interactive'
134
+ });
135
+ await writeRuntimeState(repoRoot, bootstrapped);
136
+
137
+ const validateState = startFlowExecution(bootstrapped);
138
+ validateState.runtime.current_state = 'validate_pending';
139
+ validateState.runtime.current_stage = 'validate';
140
+ validateState.runtime.current_task_id = '06-validate';
141
+ await fs.writeFile(path.join(repoRoot, '.prodify', 'artifacts', '06-validate.md'), `# 06-validate\n\n## Policy Checks\n- Validation must follow every refactor step.\n- Critical regressions block forward progress.\n\n## Regressions\n- none observed\n\n## Success Criteria\n- Validation records whether regressions were found.\n- The result is strong enough to gate the next runtime transition.\n- The measured impact score exceeds the minimum threshold.\n\n## Validation Results\n- baseline and current score compared\n`, 'utf8');
142
+
143
+ const result = await validateStageOutputs(repoRoot, {
144
+ contract: await loadCompiledContract(repoRoot, 'validate'),
145
+ runtimeState: validateState,
146
+ touchedPaths: ['.prodify/artifacts/06-validate.md']
147
+ });
148
+
149
+ assert.equal(result.passed, false);
150
+ assert.match(result.violated_rules.map((issue) => issue.rule).join(','), /impact-score\/minimum-threshold/);
151
+ });