@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
|
@@ -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 {
|
|
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
|
+
});
|