@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.
- 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/README.md +4 -2
- 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/commands/setup-agent.js +3 -0
- package/dist/contracts/compiled-schema.js +10 -1
- package/dist/contracts/source-schema.js +42 -1
- package/dist/core/agent-setup.js +116 -1
- package/dist/core/diff-validator.js +183 -0
- package/dist/core/plan-units.js +64 -0
- package/dist/core/repo-root.js +17 -8
- 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/commands/setup-agent.ts +3 -0
- package/src/contracts/compiled-schema.ts +10 -1
- package/src/contracts/source-schema.ts +51 -1
- package/src/core/agent-setup.ts +126 -2
- package/src/core/diff-validator.ts +230 -0
- package/src/core/plan-units.ts +82 -0
- package/src/core/repo-root.ts +21 -8
- 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 +19 -0
- package/tests/unit/agent-setup.test.js +9 -3
- 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
|
@@ -40,6 +40,48 @@ function asOptionalStringArray(value: unknown, fieldName: string): string[] {
|
|
|
40
40
|
return asStringArray(value, fieldName);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
function asOptionalNonNegativeInteger(value: unknown, fieldName: string, fallback = 0): number {
|
|
44
|
+
if (value === undefined || value === null || value === '') {
|
|
45
|
+
return fallback;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
49
|
+
throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a non-negative integer.`, {
|
|
50
|
+
code: 'CONTRACT_SCHEMA_INVALID'
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function asOptionalNonNegativeNumber(value: unknown, fieldName: string, fallback = 0): number {
|
|
58
|
+
if (value === undefined || value === null || value === '') {
|
|
59
|
+
return fallback;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
|
|
63
|
+
throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a non-negative number.`, {
|
|
64
|
+
code: 'CONTRACT_SCHEMA_INVALID'
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function asOptionalBoolean(value: unknown, fieldName: string, fallback = false): boolean {
|
|
72
|
+
if (value === undefined || value === null || value === '') {
|
|
73
|
+
return fallback;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (typeof value !== 'boolean') {
|
|
77
|
+
throw new ProdifyError(`Contract frontmatter field "${fieldName}" must be a boolean.`, {
|
|
78
|
+
code: 'CONTRACT_SCHEMA_INVALID'
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return value;
|
|
83
|
+
}
|
|
84
|
+
|
|
43
85
|
function asStage(value: unknown): FlowStage {
|
|
44
86
|
const stage = asString(value, 'stage') as FlowStage;
|
|
45
87
|
if (!CONTRACT_STAGE_NAMES.includes(stage)) {
|
|
@@ -136,6 +178,14 @@ export function normalizeSourceContractDocument(options: {
|
|
|
136
178
|
: [],
|
|
137
179
|
policy_rules: asStringArray(document.frontmatter.policy_rules, 'policy_rules'),
|
|
138
180
|
success_criteria: asStringArray(document.frontmatter.success_criteria, 'success_criteria'),
|
|
139
|
-
skill_routing: normalizeStageSkillRouting(document.frontmatter.skill_routing)
|
|
181
|
+
skill_routing: normalizeStageSkillRouting(document.frontmatter.skill_routing),
|
|
182
|
+
diff_validation_rules: {
|
|
183
|
+
minimum_files_modified: asOptionalNonNegativeInteger(document.frontmatter.minimum_files_modified, 'minimum_files_modified'),
|
|
184
|
+
minimum_lines_changed: asOptionalNonNegativeInteger(document.frontmatter.minimum_lines_changed, 'minimum_lines_changed'),
|
|
185
|
+
must_create_files: asOptionalBoolean(document.frontmatter.must_create_files, 'must_create_files'),
|
|
186
|
+
required_structural_changes: asOptionalStringArray(document.frontmatter.required_structural_changes, 'required_structural_changes')
|
|
187
|
+
},
|
|
188
|
+
min_impact_score: asOptionalNonNegativeNumber(document.frontmatter.min_impact_score, 'min_impact_score'),
|
|
189
|
+
enforce_plan_units: asOptionalBoolean(document.frontmatter.enforce_plan_units, 'enforce_plan_units')
|
|
140
190
|
};
|
|
141
191
|
}
|
package/src/core/agent-setup.ts
CHANGED
|
@@ -10,6 +10,126 @@ import type { GlobalAgentSetupState, RuntimeProfileName } from '../types.js';
|
|
|
10
10
|
export const GLOBAL_AGENT_SETUP_SCHEMA_VERSION = '1';
|
|
11
11
|
export const PRODIFY_RUNTIME_COMMANDS = ['$prodify-init', '$prodify-execute', '$prodify-resume'] as const;
|
|
12
12
|
|
|
13
|
+
function resolveCodexHome(env: NodeJS.ProcessEnv = process.env): string {
|
|
14
|
+
const explicit = env.CODEX_HOME?.trim();
|
|
15
|
+
if (explicit) {
|
|
16
|
+
return path.resolve(explicit);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return path.join(os.homedir(), '.codex');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveCodexSkillsRoot(env: NodeJS.ProcessEnv = process.env): string {
|
|
23
|
+
return path.join(resolveCodexHome(env), 'skills');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function renderCodexSkill(name: 'prodify-init' | 'prodify-execute' | 'prodify-resume'): string {
|
|
27
|
+
if (name === 'prodify-init') {
|
|
28
|
+
return `---
|
|
29
|
+
name: "prodify-init"
|
|
30
|
+
description: "Bootstrap Prodify inside the current repository."
|
|
31
|
+
metadata:
|
|
32
|
+
short-description: "Bootstrap Prodify inside the current repository."
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
<codex_skill_adapter>
|
|
36
|
+
## A. Skill Invocation
|
|
37
|
+
- This skill is invoked by mentioning \`$prodify-init\`.
|
|
38
|
+
- Ignore trailing arguments unless the repository-specific runtime instructions require them.
|
|
39
|
+
</codex_skill_adapter>
|
|
40
|
+
|
|
41
|
+
# prodify-init
|
|
42
|
+
|
|
43
|
+
Use this runtime bridge to bootstrap Prodify inside the current repository.
|
|
44
|
+
|
|
45
|
+
Load and follow, in this order:
|
|
46
|
+
- \`.prodify/AGENTS.md\`
|
|
47
|
+
- \`.prodify/runtime-commands.md\`
|
|
48
|
+
- \`.prodify/state.json\`
|
|
49
|
+
|
|
50
|
+
Keep the runtime anchored to \`.prodify/\`.
|
|
51
|
+
Do not substitute compatibility files for the canonical \`.prodify/AGENTS.md\` entrypoint.
|
|
52
|
+
|
|
53
|
+
Available runtime commands:
|
|
54
|
+
- \`$prodify-init\`
|
|
55
|
+
- \`$prodify-execute\`
|
|
56
|
+
- \`$prodify-execute --auto\`
|
|
57
|
+
- \`$prodify-resume\`
|
|
58
|
+
`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (name === 'prodify-execute') {
|
|
62
|
+
return `---
|
|
63
|
+
name: "prodify-execute"
|
|
64
|
+
description: "Execute the next Prodify workflow stage inside the current repository."
|
|
65
|
+
metadata:
|
|
66
|
+
short-description: "Execute the next Prodify workflow stage."
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
<codex_skill_adapter>
|
|
70
|
+
## A. Skill Invocation
|
|
71
|
+
- This skill is invoked by mentioning \`$prodify-execute\`.
|
|
72
|
+
- Treat all user text after \`$prodify-execute\` as \`{{PRODIFY_EXECUTE_ARGS}}\`.
|
|
73
|
+
- If no arguments are present, treat \`{{PRODIFY_EXECUTE_ARGS}}\` as empty.
|
|
74
|
+
</codex_skill_adapter>
|
|
75
|
+
|
|
76
|
+
# prodify-execute
|
|
77
|
+
|
|
78
|
+
Use this runtime bridge to execute the next Prodify workflow stage.
|
|
79
|
+
|
|
80
|
+
Load and follow, in this order:
|
|
81
|
+
- \`.prodify/runtime-commands.md\`
|
|
82
|
+
- \`.prodify/state.json\`
|
|
83
|
+
- \`.prodify/AGENTS.md\`
|
|
84
|
+
|
|
85
|
+
Interpret \`{{PRODIFY_EXECUTE_ARGS}}\` as the runtime command arguments.
|
|
86
|
+
- empty: run \`$prodify-execute\`
|
|
87
|
+
- \`--auto\`: run \`$prodify-execute --auto\`
|
|
88
|
+
|
|
89
|
+
Keep all execution state, artifacts, contracts, and validation anchored to \`.prodify/\`.
|
|
90
|
+
`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return `---
|
|
94
|
+
name: "prodify-resume"
|
|
95
|
+
description: "Resume a paused Prodify run from saved runtime state."
|
|
96
|
+
metadata:
|
|
97
|
+
short-description: "Resume a paused Prodify run."
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
<codex_skill_adapter>
|
|
101
|
+
## A. Skill Invocation
|
|
102
|
+
- This skill is invoked by mentioning \`$prodify-resume\`.
|
|
103
|
+
- Treat trailing arguments as optional runtime-specific hints only when the repository guidance explicitly supports them.
|
|
104
|
+
</codex_skill_adapter>
|
|
105
|
+
|
|
106
|
+
# prodify-resume
|
|
107
|
+
|
|
108
|
+
Use this runtime bridge to resume Prodify from saved runtime state.
|
|
109
|
+
|
|
110
|
+
Load and follow, in this order:
|
|
111
|
+
- \`.prodify/runtime-commands.md\`
|
|
112
|
+
- \`.prodify/state.json\`
|
|
113
|
+
- \`.prodify/AGENTS.md\`
|
|
114
|
+
|
|
115
|
+
Resume from the current state recorded under \`.prodify/state.json\`.
|
|
116
|
+
Preserve validation checkpoints and stop clearly if the state is corrupt or non-resumable.
|
|
117
|
+
`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
async function installCodexRuntimeCommands(env: NodeJS.ProcessEnv = process.env): Promise<string[]> {
|
|
121
|
+
const skillsRoot = resolveCodexSkillsRoot(env);
|
|
122
|
+
const installedFiles: string[] = [];
|
|
123
|
+
|
|
124
|
+
for (const command of ['prodify-init', 'prodify-execute', 'prodify-resume'] as const) {
|
|
125
|
+
const skillPath = path.join(skillsRoot, command, 'SKILL.md');
|
|
126
|
+
await writeFileEnsuringDir(skillPath, renderCodexSkill(command));
|
|
127
|
+
installedFiles.push(skillPath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return installedFiles;
|
|
131
|
+
}
|
|
132
|
+
|
|
13
133
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
14
134
|
return typeof value === 'object' && value !== null ? value as Record<string, unknown> : {};
|
|
15
135
|
}
|
|
@@ -116,7 +236,7 @@ export async function setupAgentIntegration(
|
|
|
116
236
|
now?: string;
|
|
117
237
|
env?: NodeJS.ProcessEnv;
|
|
118
238
|
} = {}
|
|
119
|
-
): Promise<{ statePath: string; configuredAgents: RuntimeProfileName[]; alreadyConfigured: boolean }> {
|
|
239
|
+
): Promise<{ statePath: string; configuredAgents: RuntimeProfileName[]; alreadyConfigured: boolean; installedPaths: string[] }> {
|
|
120
240
|
const profile = getRuntimeProfile(agent);
|
|
121
241
|
if (!profile) {
|
|
122
242
|
throw new ProdifyError('setup-agent requires <codex|claude|copilot|opencode>.', {
|
|
@@ -138,10 +258,14 @@ export async function setupAgentIntegration(
|
|
|
138
258
|
commands: [...PRODIFY_RUNTIME_COMMANDS]
|
|
139
259
|
};
|
|
140
260
|
|
|
261
|
+
const installedPaths = profile.name === 'codex'
|
|
262
|
+
? await installCodexRuntimeCommands(env)
|
|
263
|
+
: [];
|
|
141
264
|
const statePath = await writeGlobalAgentSetupState(nextState, { env });
|
|
142
265
|
return {
|
|
143
266
|
statePath,
|
|
144
267
|
configuredAgents: listConfiguredAgents(nextState),
|
|
145
|
-
alreadyConfigured
|
|
268
|
+
alreadyConfigured,
|
|
269
|
+
installedPaths
|
|
146
270
|
};
|
|
147
271
|
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import { listFilesRecursive, pathExists, writeFileEnsuringDir } from './fs.js';
|
|
5
|
+
import { normalizeRepoRelativePath, resolveRepoPath } from './paths.js';
|
|
6
|
+
import type { DiffResult } from '../types.js';
|
|
7
|
+
|
|
8
|
+
const DIFF_SNAPSHOT_SCHEMA_VERSION = '1';
|
|
9
|
+
const BASELINE_SNAPSHOT_PATH = '.prodify/metrics/refactor-baseline.snapshot.json';
|
|
10
|
+
const TRACKED_PREFIXES = ['src/', 'tests/', 'assets/'] as const;
|
|
11
|
+
const TRACKED_FILE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.md', '.py', '.cs', '.css', '.scss', '.html']);
|
|
12
|
+
const LAYER_DIRECTORY_NAMES = new Set(['application', 'domain', 'services', 'service', 'modules', 'module', 'adapters', 'infrastructure', 'core']);
|
|
13
|
+
|
|
14
|
+
interface SnapshotFile {
|
|
15
|
+
path: string;
|
|
16
|
+
content: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface RepoSnapshot {
|
|
20
|
+
schema_version: string;
|
|
21
|
+
files: SnapshotFile[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isTrackedPath(relativePath: string): boolean {
|
|
25
|
+
const normalized = normalizeRepoRelativePath(relativePath);
|
|
26
|
+
if (!TRACKED_PREFIXES.some((prefix) => normalized.startsWith(prefix))) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return TRACKED_FILE_EXTENSIONS.has(path.extname(normalized));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizeWhitespace(content: string): string {
|
|
34
|
+
return content
|
|
35
|
+
.replace(/\s+/g, '')
|
|
36
|
+
.trim();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toMap(snapshot: RepoSnapshot): Map<string, SnapshotFile> {
|
|
40
|
+
return new Map(snapshot.files.map((file) => [file.path, file]));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function diffLines(before: string, after: string): { added: number; removed: number } {
|
|
44
|
+
const beforeLines = before.replace(/\r\n/g, '\n').split('\n');
|
|
45
|
+
const afterLines = after.replace(/\r\n/g, '\n').split('\n');
|
|
46
|
+
const rows = beforeLines.length;
|
|
47
|
+
const cols = afterLines.length;
|
|
48
|
+
const dp = Array.from({ length: rows + 1 }, () => Array<number>(cols + 1).fill(0));
|
|
49
|
+
|
|
50
|
+
for (let row = rows - 1; row >= 0; row -= 1) {
|
|
51
|
+
for (let col = cols - 1; col >= 0; col -= 1) {
|
|
52
|
+
if (beforeLines[row] === afterLines[col]) {
|
|
53
|
+
dp[row][col] = dp[row + 1][col + 1] + 1;
|
|
54
|
+
} else {
|
|
55
|
+
dp[row][col] = Math.max(dp[row + 1][col], dp[row][col + 1]);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const common = dp[0][0];
|
|
61
|
+
return {
|
|
62
|
+
added: Math.max(0, afterLines.length - common),
|
|
63
|
+
removed: Math.max(0, beforeLines.length - common)
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function collectDirectories(paths: string[]): string[] {
|
|
68
|
+
return [...new Set(paths
|
|
69
|
+
.map((relativePath) => path.posix.dirname(relativePath))
|
|
70
|
+
.filter((directory) => directory !== '.' && directory !== ''))]
|
|
71
|
+
.sort((left, right) => left.localeCompare(right));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function detectStructuralChanges(options: {
|
|
75
|
+
addedPaths: string[];
|
|
76
|
+
modifiedPaths: string[];
|
|
77
|
+
beforeMap: Map<string, SnapshotFile>;
|
|
78
|
+
afterMap: Map<string, SnapshotFile>;
|
|
79
|
+
removedLineCounts: Map<string, number>;
|
|
80
|
+
}): DiffResult['structuralChanges'] {
|
|
81
|
+
const newDirectories = collectDirectories(options.addedPaths);
|
|
82
|
+
const newLayers = newDirectories.filter((directory) => LAYER_DIRECTORY_NAMES.has(path.posix.basename(directory)));
|
|
83
|
+
const filesWithReducedResponsibility = options.modifiedPaths
|
|
84
|
+
.filter((relativePath) => (options.removedLineCounts.get(relativePath) ?? 0) > 0)
|
|
85
|
+
.sort((left, right) => left.localeCompare(right));
|
|
86
|
+
const newModules = options.addedPaths
|
|
87
|
+
.filter((relativePath) => relativePath.startsWith('src/'))
|
|
88
|
+
.sort((left, right) => left.localeCompare(right));
|
|
89
|
+
|
|
90
|
+
const flags = new Set<string>();
|
|
91
|
+
if (newDirectories.length > 0) {
|
|
92
|
+
flags.add('new-directories');
|
|
93
|
+
}
|
|
94
|
+
if (newLayers.length > 0) {
|
|
95
|
+
flags.add('new-layer-directories');
|
|
96
|
+
flags.add('module-boundary-created');
|
|
97
|
+
}
|
|
98
|
+
if (newModules.length > 0) {
|
|
99
|
+
flags.add('new-modules');
|
|
100
|
+
flags.add('module-boundary-created');
|
|
101
|
+
}
|
|
102
|
+
if (filesWithReducedResponsibility.length > 0) {
|
|
103
|
+
flags.add('responsibility-reduced');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
new_directories: newDirectories,
|
|
108
|
+
new_layer_directories: newLayers,
|
|
109
|
+
files_with_reduced_responsibility: filesWithReducedResponsibility,
|
|
110
|
+
new_modules: newModules,
|
|
111
|
+
structural_change_flags: [...flags].sort((left, right) => left.localeCompare(right))
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serializeSnapshot(snapshot: RepoSnapshot): string {
|
|
116
|
+
return `${JSON.stringify(snapshot, null, 2)}\n`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function captureRepoSnapshot(repoRoot: string): Promise<RepoSnapshot> {
|
|
120
|
+
const repoFiles = await listFilesRecursive(repoRoot);
|
|
121
|
+
const files: SnapshotFile[] = [];
|
|
122
|
+
|
|
123
|
+
for (const file of repoFiles) {
|
|
124
|
+
if (!isTrackedPath(file.relativePath)) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
files.push({
|
|
129
|
+
path: normalizeRepoRelativePath(file.relativePath),
|
|
130
|
+
content: await fs.readFile(file.fullPath, 'utf8')
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
files.sort((left, right) => left.path.localeCompare(right.path));
|
|
135
|
+
return {
|
|
136
|
+
schema_version: DIFF_SNAPSHOT_SCHEMA_VERSION,
|
|
137
|
+
files
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function writeRefactorBaselineSnapshot(repoRoot: string): Promise<RepoSnapshot> {
|
|
142
|
+
const snapshot = await captureRepoSnapshot(repoRoot);
|
|
143
|
+
await writeFileEnsuringDir(resolveRepoPath(repoRoot, BASELINE_SNAPSHOT_PATH), serializeSnapshot(snapshot));
|
|
144
|
+
return snapshot;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function readRefactorBaselineSnapshot(repoRoot: string): Promise<RepoSnapshot | null> {
|
|
148
|
+
const baselinePath = resolveRepoPath(repoRoot, BASELINE_SNAPSHOT_PATH);
|
|
149
|
+
if (!(await pathExists(baselinePath))) {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return JSON.parse(await fs.readFile(baselinePath, 'utf8')) as RepoSnapshot;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function diffSnapshots(before: RepoSnapshot, after: RepoSnapshot): DiffResult {
|
|
157
|
+
const beforeMap = toMap(before);
|
|
158
|
+
const afterMap = toMap(after);
|
|
159
|
+
const allPaths = [...new Set([...beforeMap.keys(), ...afterMap.keys()])].sort((left, right) => left.localeCompare(right));
|
|
160
|
+
const modifiedPaths: string[] = [];
|
|
161
|
+
const addedPaths: string[] = [];
|
|
162
|
+
const deletedPaths: string[] = [];
|
|
163
|
+
const formattingOnlyPaths: string[] = [];
|
|
164
|
+
let linesAdded = 0;
|
|
165
|
+
let linesRemoved = 0;
|
|
166
|
+
const removedLineCounts = new Map<string, number>();
|
|
167
|
+
|
|
168
|
+
for (const relativePath of allPaths) {
|
|
169
|
+
const beforeFile = beforeMap.get(relativePath);
|
|
170
|
+
const afterFile = afterMap.get(relativePath);
|
|
171
|
+
|
|
172
|
+
if (!beforeFile && afterFile) {
|
|
173
|
+
addedPaths.push(relativePath);
|
|
174
|
+
linesAdded += afterFile.content.replace(/\r\n/g, '\n').split('\n').length;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (beforeFile && !afterFile) {
|
|
179
|
+
deletedPaths.push(relativePath);
|
|
180
|
+
const removed = beforeFile.content.replace(/\r\n/g, '\n').split('\n').length;
|
|
181
|
+
linesRemoved += removed;
|
|
182
|
+
removedLineCounts.set(relativePath, removed);
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (!beforeFile || !afterFile || beforeFile.content === afterFile.content) {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
modifiedPaths.push(relativePath);
|
|
191
|
+
if (normalizeWhitespace(beforeFile.content) === normalizeWhitespace(afterFile.content)) {
|
|
192
|
+
formattingOnlyPaths.push(relativePath);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const lineDiff = diffLines(beforeFile.content, afterFile.content);
|
|
197
|
+
linesAdded += lineDiff.added;
|
|
198
|
+
linesRemoved += lineDiff.removed;
|
|
199
|
+
removedLineCounts.set(relativePath, lineDiff.removed);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
filesModified: modifiedPaths.length,
|
|
204
|
+
filesAdded: addedPaths.length,
|
|
205
|
+
filesDeleted: deletedPaths.length,
|
|
206
|
+
linesAdded,
|
|
207
|
+
linesRemoved,
|
|
208
|
+
modifiedPaths,
|
|
209
|
+
addedPaths,
|
|
210
|
+
deletedPaths,
|
|
211
|
+
formattingOnlyPaths,
|
|
212
|
+
structuralChanges: detectStructuralChanges({
|
|
213
|
+
addedPaths,
|
|
214
|
+
modifiedPaths,
|
|
215
|
+
beforeMap,
|
|
216
|
+
afterMap,
|
|
217
|
+
removedLineCounts
|
|
218
|
+
})
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export async function diffAgainstRefactorBaseline(repoRoot: string): Promise<DiffResult | null> {
|
|
223
|
+
const baseline = await readRefactorBaselineSnapshot(repoRoot);
|
|
224
|
+
if (!baseline) {
|
|
225
|
+
return null;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const current = await captureRepoSnapshot(repoRoot);
|
|
229
|
+
return diffSnapshots(baseline, current);
|
|
230
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
import { resolveCanonicalPath } from './paths.js';
|
|
4
|
+
|
|
5
|
+
export interface PlanUnit {
|
|
6
|
+
id: string;
|
|
7
|
+
description: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function extractSection(markdown: string, heading: string): string {
|
|
11
|
+
const normalized = markdown.replace(/\r\n/g, '\n');
|
|
12
|
+
const match = new RegExp(`^##\\s+${heading}\\s*$([\\s\\S]*?)(?=^##\\s+|\\Z)`, 'm').exec(normalized);
|
|
13
|
+
return match?.[1]?.trim() ?? '';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizePlanUnits(section: string): PlanUnit[] {
|
|
17
|
+
const lines = section.split('\n');
|
|
18
|
+
const units: PlanUnit[] = [];
|
|
19
|
+
let currentId: string | null = null;
|
|
20
|
+
let currentDescription = '';
|
|
21
|
+
|
|
22
|
+
for (const rawLine of lines) {
|
|
23
|
+
const line = rawLine.trim();
|
|
24
|
+
const idMatch = /^-\s+Step ID:\s+(.+)$/.exec(line);
|
|
25
|
+
if (idMatch) {
|
|
26
|
+
if (currentId) {
|
|
27
|
+
units.push({
|
|
28
|
+
id: currentId,
|
|
29
|
+
description: currentDescription
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
currentId = idMatch[1].trim();
|
|
34
|
+
currentDescription = '';
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (!currentId) {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const descriptionMatch = /^-\s+Description:\s+(.+)$/.exec(line);
|
|
43
|
+
if (descriptionMatch) {
|
|
44
|
+
currentDescription = descriptionMatch[1].trim();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (currentId) {
|
|
49
|
+
units.push({
|
|
50
|
+
id: currentId,
|
|
51
|
+
description: currentDescription
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return units;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function readPlanUnits(repoRoot: string): Promise<PlanUnit[]> {
|
|
59
|
+
const planPath = resolveCanonicalPath(repoRoot, '.prodify/artifacts/04-plan.md');
|
|
60
|
+
const markdown = await fs.readFile(planPath, 'utf8');
|
|
61
|
+
return normalizePlanUnits(extractSection(markdown, 'Step Breakdown'));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readSelectedRefactorStep(repoRoot: string): Promise<PlanUnit | null> {
|
|
65
|
+
const refactorPath = resolveCanonicalPath(repoRoot, '.prodify/artifacts/05-refactor.md');
|
|
66
|
+
const markdown = await fs.readFile(refactorPath, 'utf8');
|
|
67
|
+
const section = extractSection(markdown, 'Selected Step');
|
|
68
|
+
if (!section) {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const id = /-\s+Step ID:\s+(.+)/.exec(section)?.[1]?.trim() ?? null;
|
|
73
|
+
const description = /-\s+Description:\s+(.+)/.exec(section)?.[1]?.trim() ?? '';
|
|
74
|
+
if (!id) {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return {
|
|
79
|
+
id,
|
|
80
|
+
description
|
|
81
|
+
};
|
|
82
|
+
}
|
package/src/core/repo-root.ts
CHANGED
|
@@ -56,16 +56,29 @@ export async function resolveRepoRoot(options: ResolveRepoRootOptions = {}): Pro
|
|
|
56
56
|
return explicitRepo;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
59
|
+
let current = cwd;
|
|
60
|
+
|
|
61
|
+
while (true) {
|
|
62
|
+
const hasProdify = await directoryHas(current, '.prodify');
|
|
63
|
+
if (hasProdify) {
|
|
64
|
+
return current;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const hasGit = await directoryHas(current, '.git');
|
|
68
|
+
if (hasGit) {
|
|
69
|
+
if (allowBootstrap) {
|
|
70
|
+
return current;
|
|
71
|
+
}
|
|
63
72
|
|
|
64
|
-
|
|
65
|
-
const gitRoot = await searchUpwards(cwd, async (candidate) => directoryHas(candidate, '.git'));
|
|
66
|
-
if (gitRoot) {
|
|
67
|
-
return gitRoot;
|
|
73
|
+
break;
|
|
68
74
|
}
|
|
75
|
+
|
|
76
|
+
const parent = path.dirname(current);
|
|
77
|
+
if (parent === current) {
|
|
78
|
+
break;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
current = parent;
|
|
69
82
|
}
|
|
70
83
|
|
|
71
84
|
throw new ProdifyError('Could not resolve repository root from the current working directory.', {
|
package/src/core/state.ts
CHANGED
|
@@ -3,6 +3,8 @@ import fs from 'node:fs/promises';
|
|
|
3
3
|
import { ProdifyError } from './errors.js';
|
|
4
4
|
import { pathExists, writeFileEnsuringDir } from './fs.js';
|
|
5
5
|
import { isRuntimeProfileName, resolveCanonicalPath } from './paths.js';
|
|
6
|
+
import { writeRefactorBaselineSnapshot } from './diff-validator.js';
|
|
7
|
+
import { syncScoreArtifactsForRuntimeState } from '../scoring/model.js';
|
|
6
8
|
import type {
|
|
7
9
|
ExecutionMode,
|
|
8
10
|
FlowStage,
|
|
@@ -261,4 +263,8 @@ export async function readRuntimeState(
|
|
|
261
263
|
export async function writeRuntimeState(repoRoot: string, state: ProdifyState): Promise<void> {
|
|
262
264
|
const statePath = resolveCanonicalPath(repoRoot, '.prodify/state.json');
|
|
263
265
|
await writeFileEnsuringDir(statePath, serializeRuntimeState(state));
|
|
266
|
+
if (state.runtime.current_state === 'refactor_pending') {
|
|
267
|
+
await writeRefactorBaselineSnapshot(repoRoot);
|
|
268
|
+
}
|
|
269
|
+
await syncScoreArtifactsForRuntimeState(repoRoot, state);
|
|
264
270
|
}
|
package/src/core/status.ts
CHANGED
|
@@ -12,8 +12,10 @@ import { readRuntimeState, RUNTIME_STATUS } from './state.js';
|
|
|
12
12
|
import { inspectVersionStatus } from './version-checks.js';
|
|
13
13
|
import { buildBootstrapPrompt, hasManualBootstrapGuidance } from './prompt-builder.js';
|
|
14
14
|
import { getRuntimeProfile } from './targets.js';
|
|
15
|
+
import { readScoreDelta } from '../scoring/model.js';
|
|
15
16
|
import type {
|
|
16
17
|
RuntimeProfileName,
|
|
18
|
+
ScoreDelta,
|
|
17
19
|
RuntimeStateBlock,
|
|
18
20
|
StatusReport,
|
|
19
21
|
VersionInspection,
|
|
@@ -164,6 +166,16 @@ function describeStageValidation(report: StatusReport): string {
|
|
|
164
166
|
: `failed at ${runtime.last_validation.stage}`;
|
|
165
167
|
}
|
|
166
168
|
|
|
169
|
+
function describeImpactScore(scoreDelta: ScoreDelta | null): string {
|
|
170
|
+
if (!scoreDelta) {
|
|
171
|
+
return 'not available';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const threshold = scoreDelta.min_impact_score !== undefined ? `, threshold=${scoreDelta.min_impact_score}` : '';
|
|
175
|
+
const verdict = scoreDelta.passed === undefined ? '' : `, passed=${scoreDelta.passed}`;
|
|
176
|
+
return `${scoreDelta.baseline_score} -> ${scoreDelta.final_score} (delta ${scoreDelta.delta}${threshold}${verdict})`;
|
|
177
|
+
}
|
|
178
|
+
|
|
167
179
|
async function checkManualBootstrapGuidance(repoRoot: string): Promise<boolean> {
|
|
168
180
|
const agentsPath = resolveCanonicalPath(repoRoot, '.prodify/AGENTS.md');
|
|
169
181
|
if (!(await pathExists(agentsPath))) {
|
|
@@ -264,6 +276,7 @@ export async function inspectRepositoryStatus(
|
|
|
264
276
|
bootstrapProfile,
|
|
265
277
|
bootstrapPrompt,
|
|
266
278
|
stageSkillResolution: null,
|
|
279
|
+
scoreDelta: null,
|
|
267
280
|
recommendedNextAction: 'prodify init',
|
|
268
281
|
presetMetadata: preset.metadata
|
|
269
282
|
};
|
|
@@ -281,6 +294,7 @@ export async function inspectRepositoryStatus(
|
|
|
281
294
|
let runtimeState = null;
|
|
282
295
|
let runtimeStateError = null;
|
|
283
296
|
let stageSkillResolution = null;
|
|
297
|
+
let scoreDelta = null;
|
|
284
298
|
|
|
285
299
|
try {
|
|
286
300
|
runtimeState = await readRuntimeState(repoRoot, {
|
|
@@ -291,6 +305,7 @@ export async function inspectRepositoryStatus(
|
|
|
291
305
|
}
|
|
292
306
|
|
|
293
307
|
const manualBootstrapReady = await checkManualBootstrapGuidance(repoRoot);
|
|
308
|
+
scoreDelta = await readScoreDelta(repoRoot);
|
|
294
309
|
const canonicalOk = missingPaths.length === 0;
|
|
295
310
|
if (canonicalOk && contractInventory.ok) {
|
|
296
311
|
const skillStage = runtimeState?.runtime.current_stage
|
|
@@ -328,6 +343,7 @@ export async function inspectRepositoryStatus(
|
|
|
328
343
|
bootstrapProfile,
|
|
329
344
|
bootstrapPrompt,
|
|
330
345
|
stageSkillResolution,
|
|
346
|
+
scoreDelta,
|
|
331
347
|
recommendedNextAction: deriveNextAction({
|
|
332
348
|
initialized,
|
|
333
349
|
canonicalOk,
|
|
@@ -357,6 +373,7 @@ export function renderStatusReport(report: StatusReport): string {
|
|
|
357
373
|
`Skills active: ${describeActiveSkills(report)}`,
|
|
358
374
|
`Execution state: ${describeRuntime(report.runtimeState?.runtime ?? null)}`,
|
|
359
375
|
`Stage validation: ${describeStageValidation(report)}`,
|
|
376
|
+
`Impact score: ${describeImpactScore(report.scoreDelta)}`,
|
|
360
377
|
`Manual bootstrap: ${report.manualBootstrapReady ? 'ready' : 'repair .prodify/AGENTS.md guidance'}`,
|
|
361
378
|
`Bootstrap profile: ${report.bootstrapProfile}`,
|
|
362
379
|
`Bootstrap prompt: ${report.bootstrapPrompt}`,
|