@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
@@ -38,5 +38,13 @@
38
38
  "default_skills": [],
39
39
  "allowed_skills": [],
40
40
  "conditional_skills": []
41
- }
41
+ },
42
+ "diff_validation_rules": {
43
+ "minimum_files_modified": 0,
44
+ "minimum_lines_changed": 0,
45
+ "must_create_files": false,
46
+ "required_structural_changes": []
47
+ },
48
+ "min_impact_score": 0,
49
+ "enforce_plan_units": false
42
50
  }
@@ -38,5 +38,13 @@
38
38
  "default_skills": [],
39
39
  "allowed_skills": [],
40
40
  "conditional_skills": []
41
- }
41
+ },
42
+ "diff_validation_rules": {
43
+ "minimum_files_modified": 0,
44
+ "minimum_lines_changed": 0,
45
+ "must_create_files": false,
46
+ "required_structural_changes": []
47
+ },
48
+ "min_impact_score": 0,
49
+ "enforce_plan_units": false
42
50
  }
@@ -38,5 +38,13 @@
38
38
  "default_skills": [],
39
39
  "allowed_skills": [],
40
40
  "conditional_skills": []
41
- }
41
+ },
42
+ "diff_validation_rules": {
43
+ "minimum_files_modified": 0,
44
+ "minimum_lines_changed": 0,
45
+ "must_create_files": false,
46
+ "required_structural_changes": []
47
+ },
48
+ "min_impact_score": 0,
49
+ "enforce_plan_units": false
42
50
  }
@@ -4,7 +4,7 @@
4
4
  "stage": "refactor",
5
5
  "task_id": "05-refactor",
6
6
  "source_path": ".prodify/contracts-src/refactor.contract.md",
7
- "source_hash": "0445b0239d888c292430f8fe5ddba557813f0e19a6bafdb9c7a7ad7d10c627b1",
7
+ "source_hash": "3e752b6d837af065cd8eb623a2cfdd3aa3eaf48e71ed4e4513058af2e8f86f81",
8
8
  "required_artifacts": [
9
9
  {
10
10
  "path": ".prodify/artifacts/05-refactor.md",
@@ -34,6 +34,7 @@
34
34
  "Keep the diff minimal and behavior-preserving unless the plan says otherwise."
35
35
  ],
36
36
  "success_criteria": [
37
+ "The refactor introduces measurable structural improvement.",
37
38
  "The selected plan step is implemented fully.",
38
39
  "Unrelated files remain untouched."
39
40
  ],
@@ -41,5 +42,15 @@
41
42
  "default_skills": [],
42
43
  "allowed_skills": [],
43
44
  "conditional_skills": []
44
- }
45
+ },
46
+ "diff_validation_rules": {
47
+ "minimum_files_modified": 1,
48
+ "minimum_lines_changed": 10,
49
+ "must_create_files": false,
50
+ "required_structural_changes": [
51
+ "module-boundary-created"
52
+ ]
53
+ },
54
+ "min_impact_score": 0,
55
+ "enforce_plan_units": true
45
56
  }
@@ -38,5 +38,13 @@
38
38
  "default_skills": [],
39
39
  "allowed_skills": [],
40
40
  "conditional_skills": []
41
- }
41
+ },
42
+ "diff_validation_rules": {
43
+ "minimum_files_modified": 0,
44
+ "minimum_lines_changed": 0,
45
+ "must_create_files": false,
46
+ "required_structural_changes": []
47
+ },
48
+ "min_impact_score": 0,
49
+ "enforce_plan_units": false
42
50
  }
@@ -4,7 +4,7 @@
4
4
  "stage": "validate",
5
5
  "task_id": "06-validate",
6
6
  "source_path": ".prodify/contracts-src/validate.contract.md",
7
- "source_hash": "10c9f32b2bbed054884a3c9c5ad5f195954ca0c702f14a783835d79a07aca2ed",
7
+ "source_hash": "4e1d5d6b56b147cbd6e0c98d35604e04e96eae0c6ae38353b7181da5359b3469",
8
8
  "required_artifacts": [
9
9
  {
10
10
  "path": ".prodify/artifacts/06-validate.md",
@@ -41,6 +41,7 @@
41
41
  "Validation must follow every refactor step."
42
42
  ],
43
43
  "success_criteria": [
44
+ "The measured impact score exceeds the minimum threshold.",
44
45
  "The result is strong enough to gate the next runtime transition.",
45
46
  "Validation records whether regressions were found."
46
47
  ],
@@ -48,5 +49,13 @@
48
49
  "default_skills": [],
49
50
  "allowed_skills": [],
50
51
  "conditional_skills": []
51
- }
52
+ },
53
+ "diff_validation_rules": {
54
+ "minimum_files_modified": 0,
55
+ "minimum_lines_changed": 0,
56
+ "must_create_files": false,
57
+ "required_structural_changes": []
58
+ },
59
+ "min_impact_score": 1,
60
+ "enforce_plan_units": false
52
61
  }
@@ -20,12 +20,19 @@ allowed_write_roots:
20
20
  - tests/
21
21
  forbidden_writes:
22
22
  - .prodify/contracts/
23
+ minimum_files_modified: 1
24
+ minimum_lines_changed: 10
25
+ must_create_files: false
26
+ required_structural_changes:
27
+ - module-boundary-created
28
+ enforce_plan_units: true
23
29
  policy_rules:
24
30
  - Execute exactly one selected step.
25
31
  - Keep the diff minimal and behavior-preserving unless the plan says otherwise.
26
32
  success_criteria:
27
33
  - The selected plan step is implemented fully.
28
34
  - Unrelated files remain untouched.
35
+ - The refactor introduces measurable structural improvement.
29
36
  ---
30
37
  # Refactor Contract
31
38
 
@@ -23,12 +23,14 @@ allowed_write_roots:
23
23
  - .prodify/metrics/
24
24
  forbidden_writes:
25
25
  - src/
26
+ min_impact_score: 1
26
27
  policy_rules:
27
28
  - Validation must follow every refactor step.
28
29
  - Critical regressions block forward progress.
29
30
  success_criteria:
30
31
  - Validation records whether regressions were found.
31
32
  - The result is strong enough to gate the next runtime transition.
33
+ - The measured impact score exceeds the minimum threshold.
32
34
  ---
33
35
  # Validate Contract
34
36
 
package/README.md CHANGED
@@ -54,7 +54,7 @@ Use `$prodify-execute --auto` to continue without pausing between stages, or `$p
54
54
  5. Tell the agent to read `.prodify/AGENTS.md`.
55
55
  6. Run `$prodify-init`, then continue with `$prodify-execute` or `$prodify-execute --auto`.
56
56
 
57
- Prodify keeps repository initialization agent-agnostic. The active runtime is resolved when `$prodify-init` runs inside the opened agent, not when `prodify init` creates `.prodify/`.
57
+ Repo initialization stays agent-agnostic. The active runtime is resolved when `$prodify-init` runs inside the opened agent, not when `prodify init` creates `.prodify/`.
58
58
 
59
59
  ## How It Works
60
60
 
@@ -106,7 +106,7 @@ Stage order:
106
106
  - Stage outputs live under `.prodify/artifacts/`.
107
107
  - Skill definitions live under `.prodify/skills/`.
108
108
  - Local baseline, final, and delta scoring artifacts live under `.prodify/metrics/`.
109
- - Fresh product users do not need root-level agent files.
109
+ - No root-level agent files are required in the default product flow.
110
110
 
111
111
  ## Contracts And Validation
112
112
 
@@ -136,6 +136,8 @@ Prodify users and Prodify contributors follow different entrypoints:
136
136
  - Product users start from `.prodify/AGENTS.md` inside the repository they want to improve.
137
137
  - Contributors working on the Prodify source repository follow the root [AGENTS.md](/Users/urielsh/projects/prodify/AGENTS.md).
138
138
 
139
+ In this repository, root `AGENTS.md` is repository-local contributor guidance. `.prodify/AGENTS.md` remains the runtime entrypoint for product users.
140
+
139
141
  For this self-hosting repository, the checked-in repo-root `.prodify/` directory is a development workspace for Prodify itself, not a byte-for-byte snapshot of fresh `prodify init` output.
140
142
 
141
143
  Run the source-repo test suite with:
@@ -20,12 +20,19 @@ allowed_write_roots:
20
20
  - tests/
21
21
  forbidden_writes:
22
22
  - .prodify/contracts/
23
+ minimum_files_modified: 1
24
+ minimum_lines_changed: 10
25
+ must_create_files: false
26
+ required_structural_changes:
27
+ - module-boundary-created
28
+ enforce_plan_units: true
23
29
  policy_rules:
24
30
  - Execute exactly one selected step.
25
31
  - Keep the diff minimal and behavior-preserving unless the plan says otherwise.
26
32
  success_criteria:
27
33
  - The selected plan step is implemented fully.
28
34
  - Unrelated files remain untouched.
35
+ - The refactor introduces measurable structural improvement.
29
36
  skill_routing:
30
37
  default_skills:
31
38
  - refactoring-method
@@ -23,12 +23,14 @@ allowed_write_roots:
23
23
  - .prodify/metrics/
24
24
  forbidden_writes:
25
25
  - src/
26
+ min_impact_score: 1
26
27
  policy_rules:
27
28
  - Validation must follow every refactor step.
28
29
  - Critical regressions block forward progress.
29
30
  success_criteria:
30
31
  - Validation records whether regressions were found.
31
32
  - The result is strong enough to gate the next runtime transition.
33
+ - The measured impact score exceeds the minimum threshold.
32
34
  skill_routing:
33
35
  default_skills:
34
36
  - test-hardening
@@ -17,6 +17,9 @@ export async function runSetupAgentCommand(args, context) {
17
17
  context.stdout.write(`Status: ${result.alreadyConfigured ? 'already configured globally; refreshed' : 'configured globally'}\n`);
18
18
  context.stdout.write(`Configured agents: ${result.configuredAgents.join(', ')}\n`);
19
19
  context.stdout.write(`Registry: ${result.statePath}\n`);
20
+ if (result.installedPaths.length > 0) {
21
+ context.stdout.write(`Installed runtime commands: ${result.installedPaths.join(', ')}\n`);
22
+ }
20
23
  context.stdout.write('Repo impact: none\n');
21
24
  context.stdout.write('Next step: run `prodify init` in a repository, then open that agent and use `$prodify-init`.\n');
22
25
  return 0;
@@ -15,6 +15,9 @@ export function validateCompiledContractShape(contract) {
15
15
  });
16
16
  }
17
17
  const record = contract;
18
+ const diffValidation = typeof record.diff_validation_rules === 'object' && record.diff_validation_rules !== null
19
+ ? record.diff_validation_rules
20
+ : {};
18
21
  return normalizeSourceContractDocument({
19
22
  document: {
20
23
  frontmatter: {
@@ -27,7 +30,13 @@ export function validateCompiledContractShape(contract) {
27
30
  forbidden_writes: record.forbidden_writes,
28
31
  policy_rules: record.policy_rules,
29
32
  success_criteria: record.success_criteria,
30
- skill_routing: record.skill_routing
33
+ skill_routing: record.skill_routing,
34
+ minimum_files_modified: diffValidation.minimum_files_modified ?? record.minimum_files_modified,
35
+ minimum_lines_changed: diffValidation.minimum_lines_changed ?? record.minimum_lines_changed,
36
+ must_create_files: diffValidation.must_create_files ?? record.must_create_files,
37
+ required_structural_changes: diffValidation.required_structural_changes ?? record.required_structural_changes,
38
+ min_impact_score: record.min_impact_score,
39
+ enforce_plan_units: record.enforce_plan_units
31
40
  },
32
41
  body: 'compiled-contract'
33
42
  },
@@ -26,6 +26,39 @@ function asOptionalStringArray(value, fieldName) {
26
26
  }
27
27
  return asStringArray(value, fieldName);
28
28
  }
29
+ function asOptionalNonNegativeInteger(value, fieldName, fallback = 0) {
30
+ if (value === undefined || value === null || value === '') {
31
+ return fallback;
32
+ }
33
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
34
+ throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a non-negative integer.`, {
35
+ code: 'CONTRACT_SCHEMA_INVALID'
36
+ });
37
+ }
38
+ return value;
39
+ }
40
+ function asOptionalNonNegativeNumber(value, fieldName, fallback = 0) {
41
+ if (value === undefined || value === null || value === '') {
42
+ return fallback;
43
+ }
44
+ if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
45
+ throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a non-negative number.`, {
46
+ code: 'CONTRACT_SCHEMA_INVALID'
47
+ });
48
+ }
49
+ return value;
50
+ }
51
+ function asOptionalBoolean(value, fieldName, fallback = false) {
52
+ if (value === undefined || value === null || value === '') {
53
+ return fallback;
54
+ }
55
+ if (typeof value !== 'boolean') {
56
+ throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a boolean.`, {
57
+ code: 'CONTRACT_SCHEMA_INVALID'
58
+ });
59
+ }
60
+ return value;
61
+ }
29
62
  function asStage(value) {
30
63
  const stage = asString(value, 'stage');
31
64
  if (!CONTRACT_STAGE_NAMES.includes(stage)) {
@@ -106,6 +139,14 @@ export function normalizeSourceContractDocument(options) {
106
139
  : [],
107
140
  policy_rules: asStringArray(document.frontmatter.policy_rules, 'policy_rules'),
108
141
  success_criteria: asStringArray(document.frontmatter.success_criteria, 'success_criteria'),
109
- skill_routing: normalizeStageSkillRouting(document.frontmatter.skill_routing)
142
+ skill_routing: normalizeStageSkillRouting(document.frontmatter.skill_routing),
143
+ diff_validation_rules: {
144
+ minimum_files_modified: asOptionalNonNegativeInteger(document.frontmatter.minimum_files_modified, 'minimum_files_modified'),
145
+ minimum_lines_changed: asOptionalNonNegativeInteger(document.frontmatter.minimum_lines_changed, 'minimum_lines_changed'),
146
+ must_create_files: asOptionalBoolean(document.frontmatter.must_create_files, 'must_create_files'),
147
+ required_structural_changes: asOptionalStringArray(document.frontmatter.required_structural_changes, 'required_structural_changes')
148
+ },
149
+ min_impact_score: asOptionalNonNegativeNumber(document.frontmatter.min_impact_score, 'min_impact_score'),
150
+ enforce_plan_units: asOptionalBoolean(document.frontmatter.enforce_plan_units, 'enforce_plan_units')
110
151
  };
111
152
  }
@@ -6,6 +6,117 @@ import { pathExists, writeFileEnsuringDir } from './fs.js';
6
6
  import { getRuntimeProfile } from './targets.js';
7
7
  export const GLOBAL_AGENT_SETUP_SCHEMA_VERSION = '1';
8
8
  export const PRODIFY_RUNTIME_COMMANDS = ['$prodify-init', '$prodify-execute', '$prodify-resume'];
9
+ function resolveCodexHome(env = process.env) {
10
+ const explicit = env.CODEX_HOME?.trim();
11
+ if (explicit) {
12
+ return path.resolve(explicit);
13
+ }
14
+ return path.join(os.homedir(), '.codex');
15
+ }
16
+ function resolveCodexSkillsRoot(env = process.env) {
17
+ return path.join(resolveCodexHome(env), 'skills');
18
+ }
19
+ function renderCodexSkill(name) {
20
+ if (name === 'prodify-init') {
21
+ return `---
22
+ name: "prodify-init"
23
+ description: "Bootstrap Prodify inside the current repository."
24
+ metadata:
25
+ short-description: "Bootstrap Prodify inside the current repository."
26
+ ---
27
+
28
+ <codex_skill_adapter>
29
+ ## A. Skill Invocation
30
+ - This skill is invoked by mentioning \`$prodify-init\`.
31
+ - Ignore trailing arguments unless the repository-specific runtime instructions require them.
32
+ </codex_skill_adapter>
33
+
34
+ # prodify-init
35
+
36
+ Use this runtime bridge to bootstrap Prodify inside the current repository.
37
+
38
+ Load and follow, in this order:
39
+ - \`.prodify/AGENTS.md\`
40
+ - \`.prodify/runtime-commands.md\`
41
+ - \`.prodify/state.json\`
42
+
43
+ Keep the runtime anchored to \`.prodify/\`.
44
+ Do not substitute compatibility files for the canonical \`.prodify/AGENTS.md\` entrypoint.
45
+
46
+ Available runtime commands:
47
+ - \`$prodify-init\`
48
+ - \`$prodify-execute\`
49
+ - \`$prodify-execute --auto\`
50
+ - \`$prodify-resume\`
51
+ `;
52
+ }
53
+ if (name === 'prodify-execute') {
54
+ return `---
55
+ name: "prodify-execute"
56
+ description: "Execute the next Prodify workflow stage inside the current repository."
57
+ metadata:
58
+ short-description: "Execute the next Prodify workflow stage."
59
+ ---
60
+
61
+ <codex_skill_adapter>
62
+ ## A. Skill Invocation
63
+ - This skill is invoked by mentioning \`$prodify-execute\`.
64
+ - Treat all user text after \`$prodify-execute\` as \`{{PRODIFY_EXECUTE_ARGS}}\`.
65
+ - If no arguments are present, treat \`{{PRODIFY_EXECUTE_ARGS}}\` as empty.
66
+ </codex_skill_adapter>
67
+
68
+ # prodify-execute
69
+
70
+ Use this runtime bridge to execute the next Prodify workflow stage.
71
+
72
+ Load and follow, in this order:
73
+ - \`.prodify/runtime-commands.md\`
74
+ - \`.prodify/state.json\`
75
+ - \`.prodify/AGENTS.md\`
76
+
77
+ Interpret \`{{PRODIFY_EXECUTE_ARGS}}\` as the runtime command arguments.
78
+ - empty: run \`$prodify-execute\`
79
+ - \`--auto\`: run \`$prodify-execute --auto\`
80
+
81
+ Keep all execution state, artifacts, contracts, and validation anchored to \`.prodify/\`.
82
+ `;
83
+ }
84
+ return `---
85
+ name: "prodify-resume"
86
+ description: "Resume a paused Prodify run from saved runtime state."
87
+ metadata:
88
+ short-description: "Resume a paused Prodify run."
89
+ ---
90
+
91
+ <codex_skill_adapter>
92
+ ## A. Skill Invocation
93
+ - This skill is invoked by mentioning \`$prodify-resume\`.
94
+ - Treat trailing arguments as optional runtime-specific hints only when the repository guidance explicitly supports them.
95
+ </codex_skill_adapter>
96
+
97
+ # prodify-resume
98
+
99
+ Use this runtime bridge to resume Prodify from saved runtime state.
100
+
101
+ Load and follow, in this order:
102
+ - \`.prodify/runtime-commands.md\`
103
+ - \`.prodify/state.json\`
104
+ - \`.prodify/AGENTS.md\`
105
+
106
+ Resume from the current state recorded under \`.prodify/state.json\`.
107
+ Preserve validation checkpoints and stop clearly if the state is corrupt or non-resumable.
108
+ `;
109
+ }
110
+ async function installCodexRuntimeCommands(env = process.env) {
111
+ const skillsRoot = resolveCodexSkillsRoot(env);
112
+ const installedFiles = [];
113
+ for (const command of ['prodify-init', 'prodify-execute', 'prodify-resume']) {
114
+ const skillPath = path.join(skillsRoot, command, 'SKILL.md');
115
+ await writeFileEnsuringDir(skillPath, renderCodexSkill(command));
116
+ installedFiles.push(skillPath);
117
+ }
118
+ return installedFiles;
119
+ }
9
120
  function asRecord(value) {
10
121
  return typeof value === 'object' && value !== null ? value : {};
11
122
  }
@@ -102,10 +213,14 @@ export async function setupAgentIntegration(agent, { now = new Date().toISOStrin
102
213
  configured_at: now,
103
214
  commands: [...PRODIFY_RUNTIME_COMMANDS]
104
215
  };
216
+ const installedPaths = profile.name === 'codex'
217
+ ? await installCodexRuntimeCommands(env)
218
+ : [];
105
219
  const statePath = await writeGlobalAgentSetupState(nextState, { env });
106
220
  return {
107
221
  statePath,
108
222
  configuredAgents: listConfiguredAgents(nextState),
109
- alreadyConfigured
223
+ alreadyConfigured,
224
+ installedPaths
110
225
  };
111
226
  }
@@ -0,0 +1,183 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { listFilesRecursive, pathExists, writeFileEnsuringDir } from './fs.js';
4
+ import { normalizeRepoRelativePath, resolveRepoPath } from './paths.js';
5
+ const DIFF_SNAPSHOT_SCHEMA_VERSION = '1';
6
+ const BASELINE_SNAPSHOT_PATH = '.prodify/metrics/refactor-baseline.snapshot.json';
7
+ const TRACKED_PREFIXES = ['src/', 'tests/', 'assets/'];
8
+ const TRACKED_FILE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.py', '.cs', '.css', '.scss', '.html']);
9
+ const LAYER_DIRECTORY_NAMES = new Set(['application', 'domain', 'services', 'service', 'modules', 'module', 'adapters', 'infrastructure', 'core']);
10
+ function isTrackedPath(relativePath) {
11
+ const normalized = normalizeRepoRelativePath(relativePath);
12
+ if (!TRACKED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
13
+ return false;
14
+ }
15
+ return TRACKED_FILE_EXTENSIONS.has(path.extname(normalized));
16
+ }
17
+ function normalizeWhitespace(content) {
18
+ return content
19
+ .replace(/\s+/g, '')
20
+ .trim();
21
+ }
22
+ function toMap(snapshot) {
23
+ return new Map(snapshot.files.map((file) => [file.path, file]));
24
+ }
25
+ function diffLines(before, after) {
26
+ const beforeLines = before.replace(/\r\n/g, '\n').split('\n');
27
+ const afterLines = after.replace(/\r\n/g, '\n').split('\n');
28
+ const rows = beforeLines.length;
29
+ const cols = afterLines.length;
30
+ const dp = Array.from({ length: rows + 1 }, () => Array(cols + 1).fill(0));
31
+ for (let row = rows - 1; row >= 0; row -= 1) {
32
+ for (let col = cols - 1; col >= 0; col -= 1) {
33
+ if (beforeLines[row] === afterLines[col]) {
34
+ dp[row][col] = dp[row + 1][col + 1] + 1;
35
+ }
36
+ else {
37
+ dp[row][col] = Math.max(dp[row + 1][col], dp[row][col + 1]);
38
+ }
39
+ }
40
+ }
41
+ const common = dp[0][0];
42
+ return {
43
+ added: Math.max(0, afterLines.length - common),
44
+ removed: Math.max(0, beforeLines.length - common)
45
+ };
46
+ }
47
+ function collectDirectories(paths) {
48
+ return [...new Set(paths
49
+ .map((relativePath) => path.posix.dirname(relativePath))
50
+ .filter((directory) => directory !== '.' && directory !== ''))]
51
+ .sort((left, right) => left.localeCompare(right));
52
+ }
53
+ function detectStructuralChanges(options) {
54
+ const newDirectories = collectDirectories(options.addedPaths);
55
+ const newLayers = newDirectories.filter((directory) => LAYER_DIRECTORY_NAMES.has(path.posix.basename(directory)));
56
+ const filesWithReducedResponsibility = options.modifiedPaths
57
+ .filter((relativePath) => (options.removedLineCounts.get(relativePath) ?? 0) > 0)
58
+ .sort((left, right) => left.localeCompare(right));
59
+ const newModules = options.addedPaths
60
+ .filter((relativePath) => relativePath.startsWith('src/'))
61
+ .sort((left, right) => left.localeCompare(right));
62
+ const flags = new Set();
63
+ if (newDirectories.length > 0) {
64
+ flags.add('new-directories');
65
+ }
66
+ if (newLayers.length > 0) {
67
+ flags.add('new-layer-directories');
68
+ flags.add('module-boundary-created');
69
+ }
70
+ if (newModules.length > 0) {
71
+ flags.add('new-modules');
72
+ flags.add('module-boundary-created');
73
+ }
74
+ if (filesWithReducedResponsibility.length > 0) {
75
+ flags.add('responsibility-reduced');
76
+ }
77
+ return {
78
+ new_directories: newDirectories,
79
+ new_layer_directories: newLayers,
80
+ files_with_reduced_responsibility: filesWithReducedResponsibility,
81
+ new_modules: newModules,
82
+ structural_change_flags: [...flags].sort((left, right) => left.localeCompare(right))
83
+ };
84
+ }
85
+ function serializeSnapshot(snapshot) {
86
+ return `${JSON.stringify(snapshot, null, 2)}\n`;
87
+ }
88
+ export async function captureRepoSnapshot(repoRoot) {
89
+ const repoFiles = await listFilesRecursive(repoRoot);
90
+ const files = [];
91
+ for (const file of repoFiles) {
92
+ if (!isTrackedPath(file.relativePath)) {
93
+ continue;
94
+ }
95
+ files.push({
96
+ path: normalizeRepoRelativePath(file.relativePath),
97
+ content: await fs.readFile(file.fullPath, 'utf8')
98
+ });
99
+ }
100
+ files.sort((left, right) => left.path.localeCompare(right.path));
101
+ return {
102
+ schema_version: DIFF_SNAPSHOT_SCHEMA_VERSION,
103
+ files
104
+ };
105
+ }
106
+ export async function writeRefactorBaselineSnapshot(repoRoot) {
107
+ const snapshot = await captureRepoSnapshot(repoRoot);
108
+ await writeFileEnsuringDir(resolveRepoPath(repoRoot, BASELINE_SNAPSHOT_PATH), serializeSnapshot(snapshot));
109
+ return snapshot;
110
+ }
111
+ export async function readRefactorBaselineSnapshot(repoRoot) {
112
+ const baselinePath = resolveRepoPath(repoRoot, BASELINE_SNAPSHOT_PATH);
113
+ if (!(await pathExists(baselinePath))) {
114
+ return null;
115
+ }
116
+ return JSON.parse(await fs.readFile(baselinePath, 'utf8'));
117
+ }
118
+ export function diffSnapshots(before, after) {
119
+ const beforeMap = toMap(before);
120
+ const afterMap = toMap(after);
121
+ const allPaths = [...new Set([...beforeMap.keys(), ...afterMap.keys()])].sort((left, right) => left.localeCompare(right));
122
+ const modifiedPaths = [];
123
+ const addedPaths = [];
124
+ const deletedPaths = [];
125
+ const formattingOnlyPaths = [];
126
+ let linesAdded = 0;
127
+ let linesRemoved = 0;
128
+ const removedLineCounts = new Map();
129
+ for (const relativePath of allPaths) {
130
+ const beforeFile = beforeMap.get(relativePath);
131
+ const afterFile = afterMap.get(relativePath);
132
+ if (!beforeFile && afterFile) {
133
+ addedPaths.push(relativePath);
134
+ linesAdded += afterFile.content.replace(/\r\n/g, '\n').split('\n').length;
135
+ continue;
136
+ }
137
+ if (beforeFile && !afterFile) {
138
+ deletedPaths.push(relativePath);
139
+ const removed = beforeFile.content.replace(/\r\n/g, '\n').split('\n').length;
140
+ linesRemoved += removed;
141
+ removedLineCounts.set(relativePath, removed);
142
+ continue;
143
+ }
144
+ if (!beforeFile || !afterFile || beforeFile.content === afterFile.content) {
145
+ continue;
146
+ }
147
+ modifiedPaths.push(relativePath);
148
+ if (normalizeWhitespace(beforeFile.content) === normalizeWhitespace(afterFile.content)) {
149
+ formattingOnlyPaths.push(relativePath);
150
+ continue;
151
+ }
152
+ const lineDiff = diffLines(beforeFile.content, afterFile.content);
153
+ linesAdded += lineDiff.added;
154
+ linesRemoved += lineDiff.removed;
155
+ removedLineCounts.set(relativePath, lineDiff.removed);
156
+ }
157
+ return {
158
+ filesModified: modifiedPaths.length,
159
+ filesAdded: addedPaths.length,
160
+ filesDeleted: deletedPaths.length,
161
+ linesAdded,
162
+ linesRemoved,
163
+ modifiedPaths,
164
+ addedPaths,
165
+ deletedPaths,
166
+ formattingOnlyPaths,
167
+ structuralChanges: detectStructuralChanges({
168
+ addedPaths,
169
+ modifiedPaths,
170
+ beforeMap,
171
+ afterMap,
172
+ removedLineCounts
173
+ })
174
+ };
175
+ }
176
+ export async function diffAgainstRefactorBaseline(repoRoot) {
177
+ const baseline = await readRefactorBaselineSnapshot(repoRoot);
178
+ if (!baseline) {
179
+ return null;
180
+ }
181
+ const current = await captureRepoSnapshot(repoRoot);
182
+ return diffSnapshots(baseline, current);
183
+ }