@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,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
+ }
@@ -0,0 +1,44 @@
1
+ # Diff Validator Design
2
+
3
+ ## Purpose
4
+
5
+ Prodify must reject refactor stages that only move whitespace, touch too few files, or fail to create meaningful structural change.
6
+
7
+ ## Snapshot Model
8
+
9
+ - Capture a tracked repository snapshot before refactor.
10
+ - Compare the current tracked tree against that snapshot.
11
+ - Tracked roots:
12
+ - `src/`
13
+ - `tests/`
14
+ - `assets/`
15
+
16
+ ## Deterministic Outputs
17
+
18
+ - `filesModified`
19
+ - `filesAdded`
20
+ - `filesDeleted`
21
+ - `linesAdded`
22
+ - `linesRemoved`
23
+ - `formattingOnlyPaths`
24
+ - `structuralChanges`
25
+
26
+ ## Structural Change Flags
27
+
28
+ - `new-directories`
29
+ - `new-layer-directories`
30
+ - `new-modules`
31
+ - `module-boundary-created`
32
+ - `responsibility-reduced`
33
+
34
+ ## Validation Rules
35
+
36
+ - minimum files modified
37
+ - minimum lines changed
38
+ - optional file-creation requirement
39
+ - required structural flags
40
+ - formatting-only changes fail
41
+
42
+ ## Plan Coupling
43
+
44
+ Refactor validation also confirms that the selected step in `05-refactor.md` maps to a real plan unit from `04-plan.md`.
@@ -0,0 +1,38 @@
1
+ # Impact Scoring Design
2
+
3
+ ## Purpose
4
+
5
+ Prodify should measure whether a run actually improved repository quality instead of only checking that files changed.
6
+
7
+ ## Breakdown
8
+
9
+ - `structure`
10
+ - `maintainability`
11
+ - `complexity`
12
+ - `testability`
13
+
14
+ ## Formula
15
+
16
+ ```text
17
+ total =
18
+ structure * 0.30 +
19
+ maintainability * 0.30 +
20
+ complexity * 0.20 +
21
+ testability * 0.20
22
+ ```
23
+
24
+ ## Signals
25
+
26
+ - average function length
27
+ - module count
28
+ - average directory depth
29
+ - dependency depth
30
+ - average imports per module
31
+ - test file ratio
32
+
33
+ ## Runtime Integration
34
+
35
+ - Write baseline score when a run is bootstrapped.
36
+ - Write final score and delta after validated completion.
37
+ - Enforce `min_impact_score` during validation.
38
+ - Show stored score delta in `prodify status`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urielsh/prodify",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "license": "Apache-2.0",
5
5
  "private": false,
6
6
  "type": "module",
@@ -22,6 +22,9 @@ export async function runSetupAgentCommand(args: string[], context: CommandConte
22
22
  context.stdout.write(`Status: ${result.alreadyConfigured ? 'already configured globally; refreshed' : 'configured globally'}\n`);
23
23
  context.stdout.write(`Configured agents: ${result.configuredAgents.join(', ')}\n`);
24
24
  context.stdout.write(`Registry: ${result.statePath}\n`);
25
+ if (result.installedPaths.length > 0) {
26
+ context.stdout.write(`Installed runtime commands: ${result.installedPaths.join(', ')}\n`);
27
+ }
25
28
  context.stdout.write('Repo impact: none\n');
26
29
  context.stdout.write('Next step: run `prodify init` in a repository, then open that agent and use `$prodify-init`.\n');
27
30
  return 0;
@@ -20,6 +20,9 @@ export function validateCompiledContractShape(contract: unknown): CompiledStageC
20
20
  }
21
21
 
22
22
  const record = contract as Record<string, unknown>;
23
+ const diffValidation = typeof record.diff_validation_rules === 'object' && record.diff_validation_rules !== null
24
+ ? record.diff_validation_rules as Record<string, unknown>
25
+ : {};
23
26
  return normalizeSourceContractDocument({
24
27
  document: {
25
28
  frontmatter: {
@@ -32,7 +35,13 @@ export function validateCompiledContractShape(contract: unknown): CompiledStageC
32
35
  forbidden_writes: record.forbidden_writes,
33
36
  policy_rules: record.policy_rules,
34
37
  success_criteria: record.success_criteria,
35
- skill_routing: record.skill_routing
38
+ skill_routing: record.skill_routing,
39
+ minimum_files_modified: diffValidation.minimum_files_modified ?? record.minimum_files_modified,
40
+ minimum_lines_changed: diffValidation.minimum_lines_changed ?? record.minimum_lines_changed,
41
+ must_create_files: diffValidation.must_create_files ?? record.must_create_files,
42
+ required_structural_changes: diffValidation.required_structural_changes ?? record.required_structural_changes,
43
+ min_impact_score: record.min_impact_score,
44
+ enforce_plan_units: record.enforce_plan_units
36
45
  },
37
46
  body: 'compiled-contract'
38
47
  },