@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
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs/promises';
2
+ import { resolveCanonicalPath } from './paths.js';
3
+ function extractSection(markdown, heading) {
4
+ const normalized = markdown.replace(/\r\n/g, '\n');
5
+ const match = new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s+|\\Z)`, 'm').exec(normalized);
6
+ return match?.[1]?.trim() ?? '';
7
+ }
8
+ function normalizePlanUnits(section) {
9
+ const lines = section.split('\n');
10
+ const units = [];
11
+ let currentId = null;
12
+ let currentDescription = '';
13
+ for (const rawLine of lines) {
14
+ const line = rawLine.trim();
15
+ const idMatch = /^-\s+Step ID:\s+(.+)$/.exec(line);
16
+ if (idMatch) {
17
+ if (currentId) {
18
+ units.push({
19
+ id: currentId,
20
+ description: currentDescription
21
+ });
22
+ }
23
+ currentId = idMatch[1].trim();
24
+ currentDescription = '';
25
+ continue;
26
+ }
27
+ if (!currentId) {
28
+ continue;
29
+ }
30
+ const descriptionMatch = /^-\s+Description:\s+(.+)$/.exec(line);
31
+ if (descriptionMatch) {
32
+ currentDescription = descriptionMatch[1].trim();
33
+ }
34
+ }
35
+ if (currentId) {
36
+ units.push({
37
+ id: currentId,
38
+ description: currentDescription
39
+ });
40
+ }
41
+ return units;
42
+ }
43
+ export async function readPlanUnits(repoRoot) {
44
+ const planPath = resolveCanonicalPath(repoRoot, '.prodify/artifacts/04-plan.md');
45
+ const markdown = await fs.readFile(planPath, 'utf8');
46
+ return normalizePlanUnits(extractSection(markdown, 'Step Breakdown'));
47
+ }
48
+ export async function readSelectedRefactorStep(repoRoot) {
49
+ const refactorPath = resolveCanonicalPath(repoRoot, '.prodify/artifacts/05-refactor.md');
50
+ const markdown = await fs.readFile(refactorPath, 'utf8');
51
+ const section = extractSection(markdown, 'Selected Step');
52
+ if (!section) {
53
+ return null;
54
+ }
55
+ const id = /-\s+Step ID:\s+(.+)/.exec(section)?.[1]?.trim() ?? null;
56
+ const description = /-\s+Description:\s+(.+)/.exec(section)?.[1]?.trim() ?? '';
57
+ if (!id) {
58
+ return null;
59
+ }
60
+ return {
61
+ id,
62
+ description
63
+ };
64
+ }
@@ -39,15 +39,24 @@ export async function resolveRepoRoot(options = {}) {
39
39
  }
40
40
  return explicitRepo;
41
41
  }
42
- const prodifyRoot = await searchUpwards(cwd, async (candidate) => directoryHas(candidate, '.prodify'));
43
- if (prodifyRoot) {
44
- return prodifyRoot;
45
- }
46
- if (allowBootstrap) {
47
- const gitRoot = await searchUpwards(cwd, async (candidate) => directoryHas(candidate, '.git'));
48
- if (gitRoot) {
49
- return gitRoot;
42
+ let current = cwd;
43
+ while (true) {
44
+ const hasProdify = await directoryHas(current, '.prodify');
45
+ if (hasProdify) {
46
+ return current;
47
+ }
48
+ const hasGit = await directoryHas(current, '.git');
49
+ if (hasGit) {
50
+ if (allowBootstrap) {
51
+ return current;
52
+ }
53
+ break;
50
54
  }
55
+ const parent = path.dirname(current);
56
+ if (parent === current) {
57
+ break;
58
+ }
59
+ current = parent;
51
60
  }
52
61
  throw new ProdifyError('Could not resolve repository root from the current working directory.', {
53
62
  code: 'REPO_ROOT_NOT_FOUND'
@@ -2,6 +2,8 @@ import fs from 'node:fs/promises';
2
2
  import { ProdifyError } from './errors.js';
3
3
  import { pathExists, writeFileEnsuringDir } from './fs.js';
4
4
  import { resolveCanonicalPath } from './paths.js';
5
+ import { writeRefactorBaselineSnapshot } from './diff-validator.js';
6
+ import { syncScoreArtifactsForRuntimeState } from '../scoring/model.js';
5
7
  export const RUNTIME_STATE_SCHEMA_VERSION = '2';
6
8
  export const RUNTIME_STATUS = {
7
9
  NOT_BOOTSTRAPPED: 'not_bootstrapped',
@@ -217,4 +219,8 @@ export async function readRuntimeState(repoRoot, { allowMissing = false, presetM
217
219
  export async function writeRuntimeState(repoRoot, state) {
218
220
  const statePath = resolveCanonicalPath(repoRoot, '.prodify/state.json');
219
221
  await writeFileEnsuringDir(statePath, serializeRuntimeState(state));
222
+ if (state.runtime.current_state === 'refactor_pending') {
223
+ await writeRefactorBaselineSnapshot(repoRoot);
224
+ }
225
+ await syncScoreArtifactsForRuntimeState(repoRoot, state);
220
226
  }
@@ -11,6 +11,7 @@ import { readRuntimeState, RUNTIME_STATUS } from './state.js';
11
11
  import { inspectVersionStatus } from './version-checks.js';
12
12
  import { buildBootstrapPrompt, hasManualBootstrapGuidance } from './prompt-builder.js';
13
13
  import { getRuntimeProfile } from './targets.js';
14
+ import { readScoreDelta } from '../scoring/model.js';
14
15
  function describeCanonicalHealth(missingPaths) {
15
16
  if (missingPaths.length === 0) {
16
17
  return 'healthy';
@@ -126,6 +127,14 @@ function describeStageValidation(report) {
126
127
  ? `last pass at ${runtime.last_validation.stage} (contract ${runtime.last_validation.contract_version})`
127
128
  : `failed at ${runtime.last_validation.stage}`;
128
129
  }
130
+ function describeImpactScore(scoreDelta) {
131
+ if (!scoreDelta) {
132
+ return 'not available';
133
+ }
134
+ const threshold = scoreDelta.min_impact_score !== undefined ? `, threshold=${scoreDelta.min_impact_score}` : '';
135
+ const verdict = scoreDelta.passed === undefined ? '' : `, passed=${scoreDelta.passed}`;
136
+ return `${scoreDelta.baseline_score} -> ${scoreDelta.final_score} (delta ${scoreDelta.delta}${threshold}${verdict})`;
137
+ }
129
138
  async function checkManualBootstrapGuidance(repoRoot) {
130
139
  const agentsPath = resolveCanonicalPath(repoRoot, '.prodify/AGENTS.md');
131
140
  if (!(await pathExists(agentsPath))) {
@@ -192,6 +201,7 @@ export async function inspectRepositoryStatus(repoRoot, options = {}) {
192
201
  bootstrapProfile,
193
202
  bootstrapPrompt,
194
203
  stageSkillResolution: null,
204
+ scoreDelta: null,
195
205
  recommendedNextAction: 'prodify init',
196
206
  presetMetadata: preset.metadata
197
207
  };
@@ -207,6 +217,7 @@ export async function inspectRepositoryStatus(repoRoot, options = {}) {
207
217
  let runtimeState = null;
208
218
  let runtimeStateError = null;
209
219
  let stageSkillResolution = null;
220
+ let scoreDelta = null;
210
221
  try {
211
222
  runtimeState = await readRuntimeState(repoRoot, {
212
223
  presetMetadata: preset.metadata
@@ -216,6 +227,7 @@ export async function inspectRepositoryStatus(repoRoot, options = {}) {
216
227
  runtimeStateError = error instanceof Error ? error : new Error(String(error));
217
228
  }
218
229
  const manualBootstrapReady = await checkManualBootstrapGuidance(repoRoot);
230
+ scoreDelta = await readScoreDelta(repoRoot);
219
231
  const canonicalOk = missingPaths.length === 0;
220
232
  if (canonicalOk && contractInventory.ok) {
221
233
  const skillStage = runtimeState?.runtime.current_stage
@@ -252,6 +264,7 @@ export async function inspectRepositoryStatus(repoRoot, options = {}) {
252
264
  bootstrapProfile,
253
265
  bootstrapPrompt,
254
266
  stageSkillResolution,
267
+ scoreDelta,
255
268
  recommendedNextAction: deriveNextAction({
256
269
  initialized,
257
270
  canonicalOk,
@@ -280,6 +293,7 @@ export function renderStatusReport(report) {
280
293
  `Skills active: ${describeActiveSkills(report)}`,
281
294
  `Execution state: ${describeRuntime(report.runtimeState?.runtime ?? null)}`,
282
295
  `Stage validation: ${describeStageValidation(report)}`,
296
+ `Impact score: ${describeImpactScore(report.scoreDelta)}`,
283
297
  `Manual bootstrap: ${report.manualBootstrapReady ? 'ready' : 'repair .prodify/AGENTS.md guidance'}`,
284
298
  `Bootstrap profile: ${report.bootstrapProfile}`,
285
299
  `Bootstrap prompt: ${report.bootstrapPrompt}`,
@@ -1,6 +1,9 @@
1
1
  import fs from 'node:fs/promises';
2
2
  import { loadCompiledContract } from '../contracts/compiler.js';
3
+ import { diffAgainstRefactorBaseline } from './diff-validator.js';
3
4
  import { normalizeRepoRelativePath, resolveRepoPath } from './paths.js';
5
+ import { readPlanUnits, readSelectedRefactorStep } from './plan-units.js';
6
+ import { calculateCurrentImpactDelta } from '../scoring/model.js';
4
7
  function pathIsWithin(pathToCheck, root) {
5
8
  const normalizedPath = normalizeRepoRelativePath(pathToCheck);
6
9
  const normalizedRoot = normalizeRepoRelativePath(root);
@@ -35,6 +38,12 @@ function containsAllStatements(sectionContent, statements) {
35
38
  function buildRule(rule, message, path) {
36
39
  return { rule, message, path };
37
40
  }
41
+ function hasRequiredStructuralChanges(diffResult, requiredChanges) {
42
+ if (requiredChanges.length === 0) {
43
+ return true;
44
+ }
45
+ return requiredChanges.every((requiredChange) => diffResult.structuralChanges.structural_change_flags.includes(requiredChange));
46
+ }
38
47
  async function validateArtifact(repoRoot, contract, artifact) {
39
48
  const artifactPath = resolveRepoPath(repoRoot, artifact.path);
40
49
  const issues = [];
@@ -43,9 +52,6 @@ async function validateArtifact(repoRoot, contract, artifact) {
43
52
  try {
44
53
  const content = await fs.readFile(artifactPath, 'utf8');
45
54
  diagnostics.push(`validated artifact ${artifact.path}`);
46
- if (!contract.allowed_write_roots.some((root) => pathIsWithin(artifact.path, root))) {
47
- issues.push(buildRule('artifact/outside-allowed-roots', `Required artifact ${artifact.path} is outside allowed write roots.`, artifact.path));
48
- }
49
55
  if (artifact.format === 'markdown') {
50
56
  const sections = collectMarkdownSections(content);
51
57
  for (const section of artifact.required_sections) {
@@ -81,11 +87,15 @@ async function validateArtifact(repoRoot, contract, artifact) {
81
87
  diagnostics
82
88
  };
83
89
  }
84
- export async function validateStageOutputs(repoRoot, { contract, runtimeState, touchedPaths = [] }) {
90
+ export async function validateStageOutputs(repoRoot, { contract, runtimeState, touchedPaths = [], diffResult = null }) {
85
91
  const violatedRules = [];
86
92
  const missingArtifacts = [];
87
93
  const diagnostics = [];
88
94
  const warnings = [];
95
+ const effectiveDiffResult = diffResult ?? await diffAgainstRefactorBaseline(repoRoot);
96
+ const impactDelta = contract.min_impact_score > 0
97
+ ? await calculateCurrentImpactDelta(repoRoot)
98
+ : null;
89
99
  if (runtimeState.runtime.current_stage !== contract.stage) {
90
100
  violatedRules.push(buildRule('runtime/stage-mismatch', `Runtime stage ${runtimeState.runtime.current_stage ?? 'none'} does not match contract stage ${contract.stage}.`));
91
101
  }
@@ -106,6 +116,70 @@ export async function validateStageOutputs(repoRoot, { contract, runtimeState, t
106
116
  if (touchedPaths.length === 0) {
107
117
  warnings.push('No touched paths were provided; forbidden-write checks are limited to required artifacts.');
108
118
  }
119
+ if (contract.enforce_plan_units) {
120
+ try {
121
+ const [planUnits, selectedStep] = await Promise.all([
122
+ readPlanUnits(repoRoot),
123
+ readSelectedRefactorStep(repoRoot)
124
+ ]);
125
+ if (!selectedStep) {
126
+ violatedRules.push(buildRule('plan/selected-step-missing', 'Refactor artifact does not declare the selected plan unit.'));
127
+ }
128
+ else if (!planUnits.some((unit) => unit.id === selectedStep.id)) {
129
+ violatedRules.push(buildRule('plan/selected-step-invalid', `Selected refactor step ${selectedStep.id} does not exist in 04-plan.md.`));
130
+ }
131
+ else {
132
+ diagnostics.push(`validated selected plan unit ${selectedStep.id}`);
133
+ }
134
+ }
135
+ catch {
136
+ violatedRules.push(buildRule('plan/unreadable', 'Plan-unit validation could not read 04-plan.md or 05-refactor.md.'));
137
+ }
138
+ }
139
+ if (effectiveDiffResult && (contract.diff_validation_rules.minimum_files_modified > 0
140
+ || contract.diff_validation_rules.minimum_lines_changed > 0
141
+ || contract.diff_validation_rules.must_create_files
142
+ || contract.diff_validation_rules.required_structural_changes.length > 0)) {
143
+ const changedFiles = effectiveDiffResult.filesModified + effectiveDiffResult.filesAdded + effectiveDiffResult.filesDeleted;
144
+ const changedLines = effectiveDiffResult.linesAdded + effectiveDiffResult.linesRemoved;
145
+ const formattingOnly = effectiveDiffResult.filesModified > 0
146
+ && effectiveDiffResult.filesModified === effectiveDiffResult.formattingOnlyPaths.length
147
+ && effectiveDiffResult.filesAdded === 0
148
+ && effectiveDiffResult.filesDeleted === 0;
149
+ if (changedFiles < contract.diff_validation_rules.minimum_files_modified) {
150
+ violatedRules.push(buildRule('diff/minimum-files-modified', `Refactor changed ${changedFiles} files but requires at least ${contract.diff_validation_rules.minimum_files_modified}.`));
151
+ }
152
+ if (changedLines < contract.diff_validation_rules.minimum_lines_changed) {
153
+ violatedRules.push(buildRule('diff/minimum-lines-changed', `Refactor changed ${changedLines} lines but requires at least ${contract.diff_validation_rules.minimum_lines_changed}.`));
154
+ }
155
+ if (contract.diff_validation_rules.must_create_files && effectiveDiffResult.filesAdded === 0) {
156
+ violatedRules.push(buildRule('diff/must-create-files', 'Refactor must create at least one new file.'));
157
+ }
158
+ if (formattingOnly) {
159
+ violatedRules.push(buildRule('diff/formatting-only', 'Refactor changes are formatting-only and do not count as meaningful code change.'));
160
+ }
161
+ if (!hasRequiredStructuralChanges(effectiveDiffResult, contract.diff_validation_rules.required_structural_changes)) {
162
+ violatedRules.push(buildRule('diff/required-structural-changes', `Refactor is missing required structural changes: ${contract.diff_validation_rules.required_structural_changes.join(', ')}.`));
163
+ }
164
+ diagnostics.push(`validated diff: files=${changedFiles}, lines=${changedLines}, structural=${effectiveDiffResult.structuralChanges.structural_change_flags.join(',') || 'none'}`);
165
+ }
166
+ else if (contract.diff_validation_rules.minimum_files_modified > 0
167
+ || contract.diff_validation_rules.minimum_lines_changed > 0
168
+ || contract.diff_validation_rules.must_create_files
169
+ || contract.diff_validation_rules.required_structural_changes.length > 0) {
170
+ violatedRules.push(buildRule('diff/baseline-missing', 'Diff validation rules are configured but no refactor baseline snapshot was available.'));
171
+ }
172
+ if (contract.min_impact_score > 0) {
173
+ if (!impactDelta) {
174
+ violatedRules.push(buildRule('impact-score/missing-baseline', 'Impact score threshold is configured but no baseline score is available.'));
175
+ }
176
+ else if (impactDelta.delta < contract.min_impact_score) {
177
+ violatedRules.push(buildRule('impact-score/minimum-threshold', `Impact score delta ${impactDelta.delta} is below the required threshold ${contract.min_impact_score}.`));
178
+ }
179
+ else {
180
+ diagnostics.push(`validated impact score delta ${impactDelta.delta}`);
181
+ }
182
+ }
109
183
  return {
110
184
  stage: contract.stage,
111
185
  contract_version: contract.contract_version,
@@ -113,10 +187,12 @@ export async function validateStageOutputs(repoRoot, { contract, runtimeState, t
113
187
  violated_rules: violatedRules,
114
188
  missing_artifacts: [...new Set(missingArtifacts)].sort((left, right) => left.localeCompare(right)),
115
189
  warnings,
116
- diagnostics
190
+ diagnostics,
191
+ ...(effectiveDiffResult ? { diff_result: effectiveDiffResult } : {}),
192
+ ...(impactDelta ? { impact_score_delta: impactDelta.delta } : {})
117
193
  };
118
194
  }
119
- export async function validateStageOutputsForCurrentState(repoRoot, { runtimeState, touchedPaths = [] }) {
195
+ export async function validateStageOutputsForCurrentState(repoRoot, { runtimeState, touchedPaths = [], diffResult = null }) {
120
196
  const stage = runtimeState.runtime.current_stage;
121
197
  if (!stage) {
122
198
  throw new Error('Cannot validate stage outputs when no stage is active.');
@@ -125,14 +201,16 @@ export async function validateStageOutputsForCurrentState(repoRoot, { runtimeSta
125
201
  return validateStageOutputs(repoRoot, {
126
202
  contract,
127
203
  runtimeState,
128
- touchedPaths
204
+ touchedPaths,
205
+ diffResult
129
206
  });
130
207
  }
131
- export async function validateStageOutputsForStage(repoRoot, { stage, runtimeState, touchedPaths = [] }) {
208
+ export async function validateStageOutputsForStage(repoRoot, { stage, runtimeState, touchedPaths = [], diffResult = null }) {
132
209
  const contract = await loadCompiledContract(repoRoot, stage);
133
210
  return validateStageOutputs(repoRoot, {
134
211
  contract,
135
212
  runtimeState,
136
- touchedPaths
213
+ touchedPaths,
214
+ diffResult
137
215
  });
138
216
  }