@slope-dev/slope 1.61.0 → 1.62.0
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/dist/cli/commands/pr.d.ts +11 -3
- package/dist/cli/commands/pr.d.ts.map +1 -1
- package/dist/cli/commands/pr.js +81 -31
- package/dist/cli/commands/pr.js.map +1 -1
- package/dist/cli/commands/review-run.d.ts +40 -6
- package/dist/cli/commands/review-run.d.ts.map +1 -1
- package/dist/cli/commands/review-run.js +235 -105
- package/dist/cli/commands/review-run.js.map +1 -1
- package/dist/cli/commands/review-state.d.ts.map +1 -1
- package/dist/cli/commands/review-state.js +16 -3
- package/dist/cli/commands/review-state.js.map +1 -1
- package/dist/cli/commands/roadmap.d.ts.map +1 -1
- package/dist/cli/commands/roadmap.js +93 -3
- package/dist/cli/commands/roadmap.js.map +1 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/registry.js +6 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli/review-diff.d.ts +72 -0
- package/dist/cli/review-diff.d.ts.map +1 -0
- package/dist/cli/review-diff.js +416 -0
- package/dist/cli/review-diff.js.map +1 -0
- package/dist/cli/roadmap-source-migration.d.ts +113 -0
- package/dist/cli/roadmap-source-migration.d.ts.map +1 -0
- package/dist/cli/roadmap-source-migration.js +721 -0
- package/dist/cli/roadmap-source-migration.js.map +1 -0
- package/dist/cli/roadmap-source-store.d.ts +7 -0
- package/dist/cli/roadmap-source-store.d.ts.map +1 -1
- package/dist/cli/roadmap-source-store.js +11 -2
- package/dist/cli/roadmap-source-store.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +1 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/roadmap-migration.d.ts +101 -0
- package/dist/core/roadmap-migration.d.ts.map +1 -0
- package/dist/core/roadmap-migration.js +630 -0
- package/dist/core/roadmap-migration.js.map +1 -0
- package/dist/core/roadmap-sources.d.ts.map +1 -1
- package/dist/core/roadmap-sources.js +5 -1
- package/dist/core/roadmap-sources.js.map +1 -1
- package/package.json +1 -1
- package/templates/codex/plugins/slope/.codex-plugin/plugin.json +1 -1
|
@@ -0,0 +1,630 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { ROADMAP_TERMINAL_STATUSES, compareRoadmapSprintIds, getRoadmapTicketKey, validateRoadmap, } from './roadmap.js';
|
|
3
|
+
import { normalizeRoadmapSourcePath, serializeRoadmapProjection, } from './roadmap-sources.js';
|
|
4
|
+
export const ROADMAP_MIGRATION_DIAGNOSTIC_LIMIT = 100;
|
|
5
|
+
export const ROADMAP_MIGRATION_ABSENT = Object.freeze({ $slope_migration: 'absent' });
|
|
6
|
+
export class RoadmapMigrationError extends Error {
|
|
7
|
+
constructor(message) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'RoadmapMigrationError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
const CLUBS = new Set(['driver', 'long_iron', 'short_iron', 'wedge', 'putter']);
|
|
13
|
+
const COMPLEXITIES = new Set(['trivial', 'small', 'standard', 'moderate', 'multi_package', 'risky']);
|
|
14
|
+
const CLUB_ALIASES = {
|
|
15
|
+
'long iron': 'long_iron',
|
|
16
|
+
'long-iron': 'long_iron',
|
|
17
|
+
'short iron': 'short_iron',
|
|
18
|
+
'short-iron': 'short_iron',
|
|
19
|
+
};
|
|
20
|
+
const COMPLEXITY_ALIASES = {
|
|
21
|
+
medium: 'standard',
|
|
22
|
+
large: 'risky',
|
|
23
|
+
'multi package': 'multi_package',
|
|
24
|
+
'multi-package': 'multi_package',
|
|
25
|
+
};
|
|
26
|
+
const CLUB_TO_COMPLEXITY = {
|
|
27
|
+
putter: 'trivial',
|
|
28
|
+
wedge: 'small',
|
|
29
|
+
short_iron: 'standard',
|
|
30
|
+
long_iron: 'multi_package',
|
|
31
|
+
driver: 'risky',
|
|
32
|
+
};
|
|
33
|
+
const COMPLEXITY_TO_CLUB = {
|
|
34
|
+
trivial: 'putter',
|
|
35
|
+
small: 'wedge',
|
|
36
|
+
standard: 'short_iron',
|
|
37
|
+
moderate: 'long_iron',
|
|
38
|
+
multi_package: 'long_iron',
|
|
39
|
+
risky: 'driver',
|
|
40
|
+
};
|
|
41
|
+
const CORE_TOP_LEVEL = new Set(['name', 'description', 'phases', 'sprints']);
|
|
42
|
+
function canonicalize(value) {
|
|
43
|
+
if (Array.isArray(value))
|
|
44
|
+
return value.map(canonicalize);
|
|
45
|
+
if (value && typeof value === 'object') {
|
|
46
|
+
return Object.fromEntries(Object.entries(value)
|
|
47
|
+
.sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
48
|
+
.map(([key, child]) => [key, canonicalize(child)]));
|
|
49
|
+
}
|
|
50
|
+
return value;
|
|
51
|
+
}
|
|
52
|
+
export function computeRoadmapMigrationDigest(value) {
|
|
53
|
+
const bytes = typeof value === 'string' ? value : JSON.stringify(canonicalize(value));
|
|
54
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
55
|
+
}
|
|
56
|
+
function isRecord(value) {
|
|
57
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
58
|
+
}
|
|
59
|
+
function clone(value) {
|
|
60
|
+
return structuredClone(value);
|
|
61
|
+
}
|
|
62
|
+
function pointerPart(value) {
|
|
63
|
+
return value.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
64
|
+
}
|
|
65
|
+
function sprintKey(value) {
|
|
66
|
+
return Number.isInteger(value) ? String(value) : String(value);
|
|
67
|
+
}
|
|
68
|
+
function parsePositiveSprintKey(value, label) {
|
|
69
|
+
if (!/^\d+(?:\.\d+)?$/.test(value) || Number(value) <= 0) {
|
|
70
|
+
throw new RoadmapMigrationError(`${label} key must be a positive sprint ID: ${value}`);
|
|
71
|
+
}
|
|
72
|
+
return Number(value);
|
|
73
|
+
}
|
|
74
|
+
function rejectUnknownKeys(record, allowed, label) {
|
|
75
|
+
const unknown = Object.keys(record).filter(key => !allowed.has(key));
|
|
76
|
+
if (unknown.length > 0)
|
|
77
|
+
throw new RoadmapMigrationError(`${label} contains unknown field: ${unknown[0]}`);
|
|
78
|
+
}
|
|
79
|
+
export function parseRoadmapMigrationMapping(input) {
|
|
80
|
+
if (!isRecord(input))
|
|
81
|
+
throw new RoadmapMigrationError('migration mapping must be an object');
|
|
82
|
+
rejectUnknownKeys(input, new Set(['version', 'source_sha256', 'ownership', 'ticket_repairs', 'phase_kinds', 'scorecards']), 'migration mapping');
|
|
83
|
+
if (input.version !== 1 && input.version !== '1')
|
|
84
|
+
throw new RoadmapMigrationError('migration mapping version must be 1');
|
|
85
|
+
if (typeof input.source_sha256 !== 'string' || !/^[a-f0-9]{64}$/i.test(input.source_sha256)) {
|
|
86
|
+
throw new RoadmapMigrationError('migration mapping source_sha256 must be a SHA-256 digest');
|
|
87
|
+
}
|
|
88
|
+
const ownership = {};
|
|
89
|
+
if (input.ownership != null) {
|
|
90
|
+
if (!isRecord(input.ownership))
|
|
91
|
+
throw new RoadmapMigrationError('migration mapping ownership must be an object');
|
|
92
|
+
for (const [id, raw] of Object.entries(input.ownership).sort(([a], [b]) => Number(a) - Number(b))) {
|
|
93
|
+
parsePositiveSprintKey(id, 'ownership');
|
|
94
|
+
if (!isRecord(raw))
|
|
95
|
+
throw new RoadmapMigrationError(`ownership.${id} must be an object`);
|
|
96
|
+
rejectUnknownKeys(raw, new Set(['phase_index', 'phase_name']), `ownership.${id}`);
|
|
97
|
+
if (!Number.isInteger(raw.phase_index) || Number(raw.phase_index) <= 0) {
|
|
98
|
+
throw new RoadmapMigrationError(`ownership.${id}.phase_index must be a positive 1-based integer`);
|
|
99
|
+
}
|
|
100
|
+
if (raw.phase_name != null && (typeof raw.phase_name !== 'string' || !raw.phase_name.trim())) {
|
|
101
|
+
throw new RoadmapMigrationError(`ownership.${id}.phase_name must be a non-empty string when present`);
|
|
102
|
+
}
|
|
103
|
+
ownership[id] = {
|
|
104
|
+
phase_index: Number(raw.phase_index),
|
|
105
|
+
...(raw.phase_name ? { phase_name: raw.phase_name.trim() } : {}),
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
const ticketRepairs = {};
|
|
110
|
+
if (input.ticket_repairs != null) {
|
|
111
|
+
if (!isRecord(input.ticket_repairs))
|
|
112
|
+
throw new RoadmapMigrationError('migration mapping ticket_repairs must be an object');
|
|
113
|
+
for (const [ticket, raw] of Object.entries(input.ticket_repairs).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
114
|
+
if (!ticket.trim() || !isRecord(raw))
|
|
115
|
+
throw new RoadmapMigrationError(`ticket_repairs.${ticket} must be an object`);
|
|
116
|
+
rejectUnknownKeys(raw, new Set(['club', 'complexity']), `ticket_repairs.${ticket}`);
|
|
117
|
+
if (raw.club != null && !CLUBS.has(String(raw.club)))
|
|
118
|
+
throw new RoadmapMigrationError(`ticket_repairs.${ticket}.club is invalid`);
|
|
119
|
+
if (raw.complexity != null && !COMPLEXITIES.has(String(raw.complexity)))
|
|
120
|
+
throw new RoadmapMigrationError(`ticket_repairs.${ticket}.complexity is invalid`);
|
|
121
|
+
if (raw.club == null && raw.complexity == null)
|
|
122
|
+
throw new RoadmapMigrationError(`ticket_repairs.${ticket} must set club or complexity`);
|
|
123
|
+
ticketRepairs[ticket] = {
|
|
124
|
+
...(raw.club ? { club: raw.club } : {}),
|
|
125
|
+
...(raw.complexity ? { complexity: raw.complexity } : {}),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const phaseKinds = {};
|
|
130
|
+
if (input.phase_kinds != null) {
|
|
131
|
+
if (!isRecord(input.phase_kinds))
|
|
132
|
+
throw new RoadmapMigrationError('migration mapping phase_kinds must be an object');
|
|
133
|
+
for (const [index, kind] of Object.entries(input.phase_kinds).sort(([a], [b]) => Number(a) - Number(b))) {
|
|
134
|
+
if (!/^\d+$/.test(index) || Number(index) <= 0 || (kind !== 'phase' && kind !== 'backlog')) {
|
|
135
|
+
throw new RoadmapMigrationError(`phase_kinds.${index} must be phase or backlog at a positive 1-based index`);
|
|
136
|
+
}
|
|
137
|
+
phaseKinds[index] = kind;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const scorecards = {};
|
|
141
|
+
if (input.scorecards != null) {
|
|
142
|
+
if (!isRecord(input.scorecards))
|
|
143
|
+
throw new RoadmapMigrationError('migration mapping scorecards must be an object');
|
|
144
|
+
for (const [id, path] of Object.entries(input.scorecards).sort(([a], [b]) => Number(a) - Number(b))) {
|
|
145
|
+
parsePositiveSprintKey(id, 'scorecards');
|
|
146
|
+
if (typeof path !== 'string' || !path.trim())
|
|
147
|
+
throw new RoadmapMigrationError(`scorecards.${id} must be a non-empty path`);
|
|
148
|
+
scorecards[id] = normalizeRoadmapSourcePath(path, `scorecards.${id}`);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return {
|
|
152
|
+
version: '1',
|
|
153
|
+
source_sha256: input.source_sha256.toLowerCase(),
|
|
154
|
+
ownership,
|
|
155
|
+
ticket_repairs: ticketRepairs,
|
|
156
|
+
phase_kinds: phaseKinds,
|
|
157
|
+
scorecards,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function auditChange(audit, path, rule, record, field, after) {
|
|
161
|
+
const present = Object.prototype.hasOwnProperty.call(record, field);
|
|
162
|
+
const before = present ? record[field] : ROADMAP_MIGRATION_ABSENT;
|
|
163
|
+
if (JSON.stringify(before) === JSON.stringify(after))
|
|
164
|
+
return;
|
|
165
|
+
audit.push({ path: `${path}/${pointerPart(field)}`, rule, before: clone(before), after: clone(after) });
|
|
166
|
+
record[field] = after;
|
|
167
|
+
}
|
|
168
|
+
function targetSchemaError(diagnostics, message, details = {}) {
|
|
169
|
+
diagnostics.push({ severity: 'error', code: 'target_schema', message, ...details });
|
|
170
|
+
}
|
|
171
|
+
function validateTargetTicket(ticket, sprint, diagnostics) {
|
|
172
|
+
const key = getRoadmapTicketKey(ticket) ?? undefined;
|
|
173
|
+
let valid = true;
|
|
174
|
+
const reject = (message) => {
|
|
175
|
+
valid = false;
|
|
176
|
+
targetSchemaError(diagnostics, message, { sprint, ...(key ? { ticket: key } : {}) });
|
|
177
|
+
};
|
|
178
|
+
if (!key)
|
|
179
|
+
reject(`Sprint S${sprint} has a ticket without a non-empty key or id.`);
|
|
180
|
+
if (typeof ticket.title !== 'string' || !ticket.title.trim())
|
|
181
|
+
reject(`${key ?? `Sprint S${sprint} ticket`} title must be a non-empty string.`);
|
|
182
|
+
if (!CLUBS.has(String(ticket.club)))
|
|
183
|
+
reject(`${key ?? `Sprint S${sprint} ticket`} club must be canonical.`);
|
|
184
|
+
if (!COMPLEXITIES.has(String(ticket.complexity)))
|
|
185
|
+
reject(`${key ?? `Sprint S${sprint} ticket`} complexity must be canonical.`);
|
|
186
|
+
if (ticket.depends_on != null
|
|
187
|
+
&& (!Array.isArray(ticket.depends_on)
|
|
188
|
+
|| ticket.depends_on.some(value => typeof value !== 'string' || !value.trim()))) {
|
|
189
|
+
reject(`${key ?? `Sprint S${sprint} ticket`} depends_on must contain non-empty ticket keys.`);
|
|
190
|
+
}
|
|
191
|
+
if (ticket.github_issue != null) {
|
|
192
|
+
const issues = Array.isArray(ticket.github_issue) ? ticket.github_issue : [ticket.github_issue];
|
|
193
|
+
if (issues.length === 0 || issues.some(value => !Number.isSafeInteger(value) || Number(value) <= 0)) {
|
|
194
|
+
reject(`${key ?? `Sprint S${sprint} ticket`} github_issue must be a positive integer or non-empty list of them.`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (ticket.note != null && typeof ticket.note !== 'string')
|
|
198
|
+
reject(`${key ?? `Sprint S${sprint} ticket`} note must be a string.`);
|
|
199
|
+
return valid;
|
|
200
|
+
}
|
|
201
|
+
function validateTargetSprint(sprint, diagnostics) {
|
|
202
|
+
const id = sprint.id;
|
|
203
|
+
let valid = true;
|
|
204
|
+
const reject = (message) => {
|
|
205
|
+
valid = false;
|
|
206
|
+
targetSchemaError(diagnostics, message, { sprint: id });
|
|
207
|
+
};
|
|
208
|
+
if (typeof sprint.theme !== 'string' || !sprint.theme.trim())
|
|
209
|
+
reject(`Sprint S${id} theme must be a non-empty string.`);
|
|
210
|
+
if (![3, 4, 5].includes(sprint.par))
|
|
211
|
+
reject(`Sprint S${id} par must be 3, 4, or 5.`);
|
|
212
|
+
if (typeof sprint.slope !== 'number' || !Number.isFinite(sprint.slope))
|
|
213
|
+
reject(`Sprint S${id} slope must be numeric.`);
|
|
214
|
+
if (typeof sprint.type !== 'string' || !sprint.type.trim())
|
|
215
|
+
reject(`Sprint S${id} type must be a non-empty string.`);
|
|
216
|
+
if (sprint.depends_on != null
|
|
217
|
+
&& (!Array.isArray(sprint.depends_on)
|
|
218
|
+
|| sprint.depends_on.some(value => typeof value !== 'number' || !Number.isFinite(value) || value <= 0))) {
|
|
219
|
+
reject(`Sprint S${id} depends_on must contain positive numeric sprint IDs.`);
|
|
220
|
+
}
|
|
221
|
+
for (const field of ['status', 'note', 'outcome', 'phase', 'wave']) {
|
|
222
|
+
if (sprint[field] != null && typeof sprint[field] !== 'string')
|
|
223
|
+
reject(`Sprint S${id} ${field} must be a string.`);
|
|
224
|
+
}
|
|
225
|
+
for (const field of ['research', 'artifacts', 'expected_artifacts']) {
|
|
226
|
+
if (sprint[field] != null
|
|
227
|
+
&& (!Array.isArray(sprint[field]) || sprint[field].some(value => typeof value !== 'string'))) {
|
|
228
|
+
reject(`Sprint S${id} ${field} must contain string paths.`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (!Array.isArray(sprint.tickets)) {
|
|
232
|
+
reject(`Sprint S${id} tickets must be an array.`);
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
for (const ticket of sprint.tickets) {
|
|
236
|
+
if (!isRecord(ticket)) {
|
|
237
|
+
reject(`Sprint S${id} tickets must contain mappings.`);
|
|
238
|
+
}
|
|
239
|
+
else if (!validateTargetTicket(ticket, id, diagnostics)) {
|
|
240
|
+
valid = false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return valid;
|
|
244
|
+
}
|
|
245
|
+
function validateTargetPhase(phase, index, diagnostics) {
|
|
246
|
+
let valid = true;
|
|
247
|
+
for (const field of ['description', 'status', 'note']) {
|
|
248
|
+
if (phase[field] != null && typeof phase[field] !== 'string') {
|
|
249
|
+
valid = false;
|
|
250
|
+
targetSchemaError(diagnostics, `Phase ${index} ${field} must be a string.`, { phase_index: index });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return valid;
|
|
254
|
+
}
|
|
255
|
+
function normalizeTicket(ticket, path, mapping, usedRepairs, audit, diagnostics, unresolved, sprint) {
|
|
256
|
+
const key = getRoadmapTicketKey(ticket);
|
|
257
|
+
if (!key) {
|
|
258
|
+
diagnostics.push({ severity: 'error', code: 'ticket_key_missing', sprint, message: `Ticket at ${path} has no key or id.` });
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
if (typeof ticket.club === 'string') {
|
|
262
|
+
const alias = CLUB_ALIASES[ticket.club.trim().toLowerCase()];
|
|
263
|
+
if (alias)
|
|
264
|
+
auditChange(audit, path, 'club_alias', ticket, 'club', alias);
|
|
265
|
+
}
|
|
266
|
+
if (typeof ticket.complexity === 'string') {
|
|
267
|
+
const alias = COMPLEXITY_ALIASES[ticket.complexity.trim().toLowerCase()];
|
|
268
|
+
if (alias)
|
|
269
|
+
auditChange(audit, path, 'complexity_alias', ticket, 'complexity', alias);
|
|
270
|
+
}
|
|
271
|
+
const repair = mapping?.ticket_repairs[key];
|
|
272
|
+
let needsClub = !CLUBS.has(String(ticket.club));
|
|
273
|
+
let needsComplexity = !COMPLEXITIES.has(String(ticket.complexity));
|
|
274
|
+
if (needsClub && repair?.club) {
|
|
275
|
+
auditChange(audit, path, 'explicit_ticket_repair', ticket, 'club', repair.club);
|
|
276
|
+
usedRepairs.add(key);
|
|
277
|
+
needsClub = false;
|
|
278
|
+
}
|
|
279
|
+
if (needsComplexity && repair?.complexity) {
|
|
280
|
+
auditChange(audit, path, 'explicit_ticket_repair', ticket, 'complexity', repair.complexity);
|
|
281
|
+
usedRepairs.add(key);
|
|
282
|
+
needsComplexity = false;
|
|
283
|
+
}
|
|
284
|
+
if (needsComplexity && !needsClub) {
|
|
285
|
+
auditChange(audit, path, 'derive_complexity_from_club', ticket, 'complexity', CLUB_TO_COMPLEXITY[String(ticket.club)]);
|
|
286
|
+
needsComplexity = false;
|
|
287
|
+
}
|
|
288
|
+
if (needsClub && !needsComplexity) {
|
|
289
|
+
auditChange(audit, path, 'derive_club_from_complexity', ticket, 'club', COMPLEXITY_TO_CLUB[String(ticket.complexity)]);
|
|
290
|
+
needsClub = false;
|
|
291
|
+
}
|
|
292
|
+
if (needsClub || needsComplexity) {
|
|
293
|
+
const fields = [needsClub ? 'club' : '', needsComplexity ? 'complexity' : ''].filter(Boolean).join(' and ');
|
|
294
|
+
diagnostics.push({ severity: 'error', code: 'ticket_repair_required', sprint, ticket: key, message: `${key} requires an explicit ${fields} repair.` });
|
|
295
|
+
unresolved.push({ kind: 'ticket', key, message: `Set canonical ${fields} for ${key}.` });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function slugify(value) {
|
|
299
|
+
const slug = value.normalize('NFKD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
|
|
300
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 48).replace(/-$/g, '');
|
|
301
|
+
return slug || 'phase';
|
|
302
|
+
}
|
|
303
|
+
function migrationPath(classification, index, width, name) {
|
|
304
|
+
const file = `${String(index).padStart(width, '0')}-${slugify(name)}.yaml`;
|
|
305
|
+
if (classification === 'archive')
|
|
306
|
+
return `archive/${file}`;
|
|
307
|
+
if (classification === 'backlog')
|
|
308
|
+
return `backlog/${file}`;
|
|
309
|
+
return `phases/${classification === 'live' ? 'live' : 'history-unverified'}/${file}`;
|
|
310
|
+
}
|
|
311
|
+
function mappingTemplate(sourceSha, unresolved) {
|
|
312
|
+
const ownership = {};
|
|
313
|
+
const ticketRepairs = {};
|
|
314
|
+
for (const item of unresolved) {
|
|
315
|
+
if (item.kind === 'ownership')
|
|
316
|
+
ownership[item.key] = null;
|
|
317
|
+
else
|
|
318
|
+
ticketRepairs[item.key] = {};
|
|
319
|
+
}
|
|
320
|
+
return { version: '1', source_sha256: sourceSha, ownership, ticket_repairs: ticketRepairs, phase_kinds: {}, scorecards: {} };
|
|
321
|
+
}
|
|
322
|
+
export function serializeRoadmapMigrationMappingTemplate(plan) {
|
|
323
|
+
return `${JSON.stringify(plan.mapping_template, null, 2)}\n`;
|
|
324
|
+
}
|
|
325
|
+
export function planRoadmapMigration(sourceText, options = {}) {
|
|
326
|
+
const sourceSha = computeRoadmapMigrationDigest(sourceText);
|
|
327
|
+
if (options.mapping && options.mapping.source_sha256 !== sourceSha) {
|
|
328
|
+
throw new RoadmapMigrationError(`migration mapping targets ${options.mapping.source_sha256}, but input is ${sourceSha}`);
|
|
329
|
+
}
|
|
330
|
+
let parsed;
|
|
331
|
+
try {
|
|
332
|
+
parsed = JSON.parse(sourceText);
|
|
333
|
+
}
|
|
334
|
+
catch (error) {
|
|
335
|
+
throw new RoadmapMigrationError(`roadmap is not valid JSON: ${error.message}`);
|
|
336
|
+
}
|
|
337
|
+
if (!isRecord(parsed) || typeof parsed.name !== 'string' || !parsed.name.trim()) {
|
|
338
|
+
throw new RoadmapMigrationError('roadmap must be an object with a non-empty name');
|
|
339
|
+
}
|
|
340
|
+
if (!Array.isArray(parsed.phases) || !Array.isArray(parsed.sprints)) {
|
|
341
|
+
throw new RoadmapMigrationError('roadmap phases and sprints must be arrays');
|
|
342
|
+
}
|
|
343
|
+
const audit = [];
|
|
344
|
+
const diagnostics = [];
|
|
345
|
+
const unresolved = [];
|
|
346
|
+
const invalidDescription = parsed.description != null && typeof parsed.description !== 'string';
|
|
347
|
+
if (invalidDescription) {
|
|
348
|
+
diagnostics.push({
|
|
349
|
+
severity: 'error',
|
|
350
|
+
code: 'invalid_description',
|
|
351
|
+
message: 'Roadmap description must be a string when present; migration will not drop or coerce it.',
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const phases = clone(parsed.phases);
|
|
355
|
+
const sprints = clone(parsed.sprints);
|
|
356
|
+
const phaseRecords = [];
|
|
357
|
+
const memberships = new Map();
|
|
358
|
+
let targetShapeValid = !invalidDescription;
|
|
359
|
+
for (const [phaseOffset, value] of phases.entries()) {
|
|
360
|
+
if (!isRecord(value) || typeof value.name !== 'string' || !value.name.trim() || !Array.isArray(value.sprints)) {
|
|
361
|
+
throw new RoadmapMigrationError(`phases[${phaseOffset}] must have a non-empty name and sprint array`);
|
|
362
|
+
}
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
for (const id of value.sprints) {
|
|
365
|
+
if (typeof id !== 'number' || !Number.isFinite(id) || id <= 0) {
|
|
366
|
+
throw new RoadmapMigrationError(`phases[${phaseOffset}].sprints contains an invalid sprint ID`);
|
|
367
|
+
}
|
|
368
|
+
if (!seen.has(id)) {
|
|
369
|
+
const owners = memberships.get(id) ?? [];
|
|
370
|
+
owners.push(phaseOffset + 1);
|
|
371
|
+
memberships.set(id, owners);
|
|
372
|
+
seen.add(id);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (seen.size !== value.sprints.length) {
|
|
376
|
+
auditChange(audit, `/phases/${phaseOffset}`, 'deduplicate_local_membership', value, 'sprints', [...seen]);
|
|
377
|
+
}
|
|
378
|
+
if (!validateTargetPhase(value, phaseOffset + 1, diagnostics))
|
|
379
|
+
targetShapeValid = false;
|
|
380
|
+
phaseRecords.push(value);
|
|
381
|
+
}
|
|
382
|
+
const sprintRecords = [];
|
|
383
|
+
const definitions = new Map();
|
|
384
|
+
const originalSprintIndex = new Map();
|
|
385
|
+
const usedTicketRepairs = new Set();
|
|
386
|
+
const ticketKeys = new Set();
|
|
387
|
+
for (const [sprintOffset, value] of sprints.entries()) {
|
|
388
|
+
if (!isRecord(value) || typeof value.id !== 'number' || !Number.isFinite(value.id) || value.id <= 0) {
|
|
389
|
+
throw new RoadmapMigrationError(`sprints[${sprintOffset}] must be an object with a positive numeric id`);
|
|
390
|
+
}
|
|
391
|
+
const id = value.id;
|
|
392
|
+
if (definitions.has(id)) {
|
|
393
|
+
diagnostics.push({ severity: 'error', code: 'duplicate_sprint_definition', sprint: id, message: `Sprint S${id} is defined more than once.` });
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
definitions.set(id, value);
|
|
397
|
+
originalSprintIndex.set(id, sprintOffset);
|
|
398
|
+
for (const field of ['phase', 'wave']) {
|
|
399
|
+
if (typeof value[field] === 'number' && Number.isFinite(value[field])) {
|
|
400
|
+
auditChange(audit, `/sprints/${sprintOffset}`, 'numeric_label_to_string', value, field, String(value[field]));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
for (const field of ['research', 'artifacts', 'expected_artifacts']) {
|
|
404
|
+
if (typeof value[field] === 'string') {
|
|
405
|
+
auditChange(audit, `/sprints/${sprintOffset}`, 'scalar_path_to_list', value, field, [value[field]]);
|
|
406
|
+
}
|
|
407
|
+
else if (value[field] != null && (!Array.isArray(value[field]) || value[field].some(item => typeof item !== 'string'))) {
|
|
408
|
+
diagnostics.push({ severity: 'error', code: 'invalid_path_list', sprint: id, message: `Sprint S${id} ${field} must be a string or string array.` });
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
if (value.tickets === null)
|
|
412
|
+
auditChange(audit, `/sprints/${sprintOffset}`, 'null_tickets_to_empty', value, 'tickets', []);
|
|
413
|
+
if (!Array.isArray(value.tickets)) {
|
|
414
|
+
diagnostics.push({ severity: 'error', code: 'invalid_ticket_collection', sprint: id, message: `Sprint S${id} tickets must be an array or null.` });
|
|
415
|
+
}
|
|
416
|
+
else {
|
|
417
|
+
for (const [ticketOffset, ticketValue] of value.tickets.entries()) {
|
|
418
|
+
if (!isRecord(ticketValue)) {
|
|
419
|
+
diagnostics.push({ severity: 'error', code: 'invalid_ticket', sprint: id, message: `Sprint S${id} ticket ${ticketOffset + 1} must be an object.` });
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
normalizeTicket(ticketValue, `/sprints/${sprintOffset}/tickets/${ticketOffset}`, options.mapping, usedTicketRepairs, audit, diagnostics, unresolved, id);
|
|
423
|
+
const key = getRoadmapTicketKey(ticketValue);
|
|
424
|
+
if (key && ticketKeys.has(key))
|
|
425
|
+
diagnostics.push({ severity: 'error', code: 'duplicate_ticket', sprint: id, ticket: key, message: `Ticket ${key} is defined more than once.` });
|
|
426
|
+
if (key)
|
|
427
|
+
ticketKeys.add(key);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
if (!validateTargetSprint(value, diagnostics))
|
|
431
|
+
targetShapeValid = false;
|
|
432
|
+
sprintRecords.push(value);
|
|
433
|
+
}
|
|
434
|
+
for (const [id, owners] of [...memberships.entries()].sort(([a], [b]) => a - b)) {
|
|
435
|
+
if (!definitions.has(id))
|
|
436
|
+
diagnostics.push({ severity: 'error', code: 'missing_sprint_definition', sprint: id, message: `Phase membership S${id} has no sprint definition.` });
|
|
437
|
+
}
|
|
438
|
+
const ownerBySprint = new Map();
|
|
439
|
+
const usedOwnership = new Set();
|
|
440
|
+
const orderedDefinitionIds = [...definitions.keys()].sort((a, b) => a - b);
|
|
441
|
+
for (const id of orderedDefinitionIds) {
|
|
442
|
+
const key = sprintKey(id);
|
|
443
|
+
const owners = memberships.get(id) ?? [];
|
|
444
|
+
if (owners.length === 1) {
|
|
445
|
+
ownerBySprint.set(id, owners[0]);
|
|
446
|
+
if (options.mapping?.ownership[key]) {
|
|
447
|
+
diagnostics.push({ severity: 'error', code: 'unused_ownership_mapping', sprint: id, message: `S${id} already has exactly one phase owner.` });
|
|
448
|
+
}
|
|
449
|
+
continue;
|
|
450
|
+
}
|
|
451
|
+
const selected = options.mapping?.ownership[key];
|
|
452
|
+
if (!selected) {
|
|
453
|
+
const reason = owners.length === 0 ? 'has no phase owner' : `has multiple phase owners (${owners.join(', ')})`;
|
|
454
|
+
diagnostics.push({ severity: 'error', code: owners.length === 0 ? 'orphan_sprint_definition' : 'multiple_phase_membership', sprint: id, message: `Sprint S${id} ${reason}; explicit ownership is required.` });
|
|
455
|
+
unresolved.push({ kind: 'ownership', key, message: `Select one 1-based phase index for S${id}.`, ...(owners.length ? { candidates: owners } : {}) });
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
if (selected.phase_index > phaseRecords.length) {
|
|
459
|
+
diagnostics.push({ severity: 'error', code: 'invalid_ownership_mapping', sprint: id, message: `S${id} maps to missing phase index ${selected.phase_index}.` });
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
const selectedName = String(phaseRecords[selected.phase_index - 1].name);
|
|
463
|
+
if (selected.phase_name && selected.phase_name !== selectedName) {
|
|
464
|
+
diagnostics.push({ severity: 'error', code: 'ownership_phase_mismatch', sprint: id, message: `S${id} expected phase "${selected.phase_name}" at index ${selected.phase_index}, found "${selectedName}".` });
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (owners.length > 1 && !owners.includes(selected.phase_index)) {
|
|
468
|
+
diagnostics.push({ severity: 'error', code: 'invalid_ownership_mapping', sprint: id, message: `S${id} duplicate ownership must select one existing candidate (${owners.join(', ')}).` });
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
ownerBySprint.set(id, selected.phase_index);
|
|
472
|
+
usedOwnership.add(key);
|
|
473
|
+
}
|
|
474
|
+
for (const [id] of Object.entries(options.mapping?.ownership ?? {}).sort(([a], [b]) => Number(a) - Number(b))) {
|
|
475
|
+
if (!definitions.has(Number(id)))
|
|
476
|
+
diagnostics.push({ severity: 'error', code: 'stale_ownership_mapping', sprint: Number(id), message: `Ownership mapping references undefined Sprint S${id}.` });
|
|
477
|
+
else if (!usedOwnership.has(id) && (memberships.get(Number(id))?.length ?? 0) !== 1) {
|
|
478
|
+
diagnostics.push({ severity: 'error', code: 'unused_ownership_mapping', sprint: Number(id), message: `Ownership mapping for S${id} could not be applied.` });
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
for (const ticket of Object.keys(options.mapping?.ticket_repairs ?? {}).sort((a, b) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
482
|
+
if (!ticketKeys.has(ticket))
|
|
483
|
+
diagnostics.push({ severity: 'error', code: 'stale_ticket_repair', ticket, message: `Ticket repair references undefined ticket ${ticket}.` });
|
|
484
|
+
else if (!usedTicketRepairs.has(ticket))
|
|
485
|
+
diagnostics.push({ severity: 'error', code: 'unused_ticket_repair', ticket, message: `Ticket repair for ${ticket} was not needed.` });
|
|
486
|
+
}
|
|
487
|
+
for (const index of Object.keys(options.mapping?.phase_kinds ?? {}).sort((a, b) => Number(a) - Number(b))) {
|
|
488
|
+
if (Number(index) > phaseRecords.length)
|
|
489
|
+
diagnostics.push({ severity: 'error', code: 'stale_phase_kind', phase_index: Number(index), message: `Phase kind references missing phase index ${index}.` });
|
|
490
|
+
}
|
|
491
|
+
for (const [phaseOffset, phase] of phaseRecords.entries()) {
|
|
492
|
+
const before = [...phase.sprints];
|
|
493
|
+
const retained = before.filter(id => ownerBySprint.get(id) === phaseOffset + 1);
|
|
494
|
+
for (const id of orderedDefinitionIds) {
|
|
495
|
+
if (ownerBySprint.get(id) === phaseOffset + 1 && !retained.includes(id))
|
|
496
|
+
retained.push(id);
|
|
497
|
+
}
|
|
498
|
+
auditChange(audit, `/phases/${phaseOffset}`, 'repair_phase_ownership', phase, 'sprints', retained);
|
|
499
|
+
}
|
|
500
|
+
const preliminary = {
|
|
501
|
+
name: parsed.name,
|
|
502
|
+
...(typeof parsed.description === 'string' ? { description: parsed.description } : {}),
|
|
503
|
+
phases: phaseRecords,
|
|
504
|
+
sprints: sprintRecords,
|
|
505
|
+
};
|
|
506
|
+
const originalOrder = preliminary.sprints.map(sprint => sprint.id);
|
|
507
|
+
const canResolveEncodedIdentity = preliminary.sprints.every(sprint => Array.isArray(sprint.tickets));
|
|
508
|
+
preliminary.sprints.sort((a, b) => canResolveEncodedIdentity
|
|
509
|
+
? compareRoadmapSprintIds(preliminary, a.id, b.id)
|
|
510
|
+
: a.id - b.id);
|
|
511
|
+
const sortedOrder = preliminary.sprints.map(sprint => sprint.id);
|
|
512
|
+
if (JSON.stringify(originalOrder) !== JSON.stringify(sortedOrder)) {
|
|
513
|
+
audit.push({ path: '/sprints', rule: 'compiler_sprint_order', before: originalOrder, after: sortedOrder });
|
|
514
|
+
}
|
|
515
|
+
if (targetShapeValid) {
|
|
516
|
+
const validation = validateRoadmap(preliminary);
|
|
517
|
+
for (const issue of validation.errors) {
|
|
518
|
+
diagnostics.push({
|
|
519
|
+
severity: 'error',
|
|
520
|
+
code: 'target_roadmap_validation',
|
|
521
|
+
message: issue.message,
|
|
522
|
+
...(issue.sprint != null ? { sprint: issue.sprint } : {}),
|
|
523
|
+
...(issue.ticket ? { ticket: issue.ticket } : {}),
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
for (const issue of validation.warnings) {
|
|
527
|
+
diagnostics.push({
|
|
528
|
+
severity: 'warning',
|
|
529
|
+
code: 'target_roadmap_validation',
|
|
530
|
+
message: issue.message,
|
|
531
|
+
...(issue.sprint != null ? { sprint: issue.sprint } : {}),
|
|
532
|
+
...(issue.ticket ? { ticket: issue.ticket } : {}),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
const evidence = { ...(options.evidence ?? {}) };
|
|
537
|
+
for (const [id, path] of Object.entries(options.mapping?.scorecards ?? {}).sort(([a], [b]) => Number(a) - Number(b))) {
|
|
538
|
+
const sprint = definitions.get(Number(id));
|
|
539
|
+
const card = evidence[id];
|
|
540
|
+
if (!sprint) {
|
|
541
|
+
diagnostics.push({ severity: 'error', code: 'stale_scorecard_mapping', sprint: Number(id), message: `Scorecard mapping references undefined Sprint S${id}.` });
|
|
542
|
+
}
|
|
543
|
+
else if (sprint.status !== 'complete') {
|
|
544
|
+
diagnostics.push({ severity: 'error', code: 'unused_scorecard_mapping', sprint: Number(id), message: `Scorecard mapping for S${id} is unused because the sprint is not complete.` });
|
|
545
|
+
}
|
|
546
|
+
else if (!card) {
|
|
547
|
+
diagnostics.push({ severity: 'error', code: 'unverified_scorecard_mapping', sprint: Number(id), message: `Scorecard mapping for S${id} has no verified filesystem evidence.` });
|
|
548
|
+
}
|
|
549
|
+
else if (card.path.replace(/\\/g, '/') !== path) {
|
|
550
|
+
diagnostics.push({ severity: 'error', code: 'scorecard_mapping_mismatch', sprint: Number(id), message: `Scorecard mapping for S${id} does not match verified evidence path ${card.path.replace(/\\/g, '/')}.` });
|
|
551
|
+
}
|
|
552
|
+
else if (!card.valid) {
|
|
553
|
+
diagnostics.push({ severity: 'error', code: 'invalid_scorecard_mapping', sprint: Number(id), message: `Scorecard mapping for S${id} failed evidence validation${card.reason ? `: ${card.reason}` : '.'}` });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
const width = Math.max(3, String(phaseRecords.length).length);
|
|
557
|
+
const sources = phaseRecords.map((phase, offset) => {
|
|
558
|
+
const index = offset + 1;
|
|
559
|
+
const owned = preliminary.sprints.filter(sprint => ownerBySprint.get(sprint.id) === index);
|
|
560
|
+
const reasons = [];
|
|
561
|
+
const phaseTerminal = ROADMAP_TERMINAL_STATUSES.has(String(phase.status ?? ''));
|
|
562
|
+
if (!phaseTerminal)
|
|
563
|
+
reasons.push(`phase status "${String(phase.status ?? 'unset')}" is not terminal`);
|
|
564
|
+
const nonterminal = owned.filter(sprint => !ROADMAP_TERMINAL_STATUSES.has(sprint.status ?? ''));
|
|
565
|
+
if (nonterminal.length > 0)
|
|
566
|
+
reasons.push(`nonterminal sprints: ${nonterminal.map(sprint => `S${sprint.id}`).join(', ')}`);
|
|
567
|
+
const unverified = owned.filter(sprint => sprint.status === 'complete' && !evidence[sprintKey(sprint.id)]?.valid);
|
|
568
|
+
if (unverified.length > 0)
|
|
569
|
+
reasons.push(`complete sprints without valid scorecard evidence: ${unverified.map(sprint => `S${sprint.id}`).join(', ')}`);
|
|
570
|
+
const override = options.mapping?.phase_kinds[String(index)];
|
|
571
|
+
let classification;
|
|
572
|
+
if (override === 'backlog')
|
|
573
|
+
classification = 'backlog';
|
|
574
|
+
else if (override === 'phase') {
|
|
575
|
+
reasons.push('explicit phase mapping prevents archive classification');
|
|
576
|
+
classification = nonterminal.length > 0 ? 'live' : 'history_unverified';
|
|
577
|
+
}
|
|
578
|
+
else if (phaseTerminal && nonterminal.length === 0 && unverified.length === 0)
|
|
579
|
+
classification = 'archive';
|
|
580
|
+
else if (owned.some(sprint => !ROADMAP_TERMINAL_STATUSES.has(sprint.status ?? '')))
|
|
581
|
+
classification = 'live';
|
|
582
|
+
else
|
|
583
|
+
classification = 'history_unverified';
|
|
584
|
+
const scorecards = {};
|
|
585
|
+
for (const sprint of owned) {
|
|
586
|
+
const card = evidence[sprintKey(sprint.id)];
|
|
587
|
+
if (sprint.status === 'complete' && card?.valid)
|
|
588
|
+
scorecards[sprintKey(sprint.id)] = card.path.replace(/\\/g, '/');
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
phase_index: index,
|
|
592
|
+
phase_name: String(phase.name),
|
|
593
|
+
classification,
|
|
594
|
+
kind: classification === 'archive' ? 'archive' : classification === 'backlog' ? 'backlog' : 'phase',
|
|
595
|
+
path: migrationPath(classification, index, width, String(phase.name)),
|
|
596
|
+
phase: phase,
|
|
597
|
+
sprints: owned,
|
|
598
|
+
scorecards,
|
|
599
|
+
classification_reasons: classification === 'archive' ? ['terminal status and scorecard evidence verified'] : reasons,
|
|
600
|
+
};
|
|
601
|
+
});
|
|
602
|
+
const nonCoreFields = Object.fromEntries(Object.entries(parsed).filter(([key]) => !CORE_TOP_LEVEL.has(key)));
|
|
603
|
+
const nonCore = {
|
|
604
|
+
path: 'migration/non-core.json',
|
|
605
|
+
fields: clone(nonCoreFields),
|
|
606
|
+
sha256: computeRoadmapMigrationDigest(nonCoreFields),
|
|
607
|
+
};
|
|
608
|
+
const template = mappingTemplate(sourceSha, unresolved);
|
|
609
|
+
const mappingSha = options.mapping ? computeRoadmapMigrationDigest(options.mapping) : undefined;
|
|
610
|
+
const boundedDiagnostics = diagnostics.slice(0, ROADMAP_MIGRATION_DIAGNOSTIC_LIMIT);
|
|
611
|
+
const expectedProjectionSha = computeRoadmapMigrationDigest(serializeRoadmapProjection(preliminary));
|
|
612
|
+
const base = {
|
|
613
|
+
version: '1',
|
|
614
|
+
source_sha256: sourceSha,
|
|
615
|
+
...(mappingSha ? { mapping_sha256: mappingSha } : {}),
|
|
616
|
+
expected_projection_sha256: expectedProjectionSha,
|
|
617
|
+
applicable: diagnostics.every(diagnostic => diagnostic.severity !== 'error'),
|
|
618
|
+
normalized_roadmap: preliminary,
|
|
619
|
+
sources,
|
|
620
|
+
audit,
|
|
621
|
+
diagnostics: boundedDiagnostics,
|
|
622
|
+
diagnostics_total: diagnostics.length,
|
|
623
|
+
diagnostics_omitted: diagnostics.length - boundedDiagnostics.length,
|
|
624
|
+
unresolved,
|
|
625
|
+
mapping_template: template,
|
|
626
|
+
non_core: nonCore,
|
|
627
|
+
};
|
|
628
|
+
return { ...base, plan_sha256: computeRoadmapMigrationDigest(base) };
|
|
629
|
+
}
|
|
630
|
+
//# sourceMappingURL=roadmap-migration.js.map
|