workflow-ai 1.0.62 → 1.0.63

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.
@@ -0,0 +1,406 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * migrate-backlog-to-tests.js - Триаж и миграция CHG-записей беклога в тест-кейсы
5
+ *
6
+ * Usage:
7
+ * node migrate-backlog-to-tests.js --backlog <path> # один беклог
8
+ * node migrate-backlog-to-tests.js --backlog <path> --category A # только кат. A
9
+ * node migrate-backlog-to-tests.js --dry-run # не создавать файлы
10
+ *
11
+ * Output (---RESULT---):
12
+ * 1. Триаж-таблица: CHG-ID | Категория | Обоснование
13
+ * 2. Метаданные категории A (для копирования)
14
+ * 3. Список уникальных принципов/тегов для дедупликации
15
+ */
16
+
17
+ import fs from "fs";
18
+ import path from "path";
19
+ import { execSync } from "child_process";
20
+ import { findProjectRoot } from "workflow-ai/lib/find-root.mjs";
21
+ import { printResult } from "workflow-ai/lib/utils.mjs";
22
+
23
+ const PROJECT_DIR = findProjectRoot();
24
+
25
+ const CATEGORIES = {
26
+ A: {
27
+ name: "Durable behavior",
28
+ description: "правка добавляет проверяемое правило",
29
+ action: "→ метаданные для теста (high priority)",
30
+ },
31
+ B: {
32
+ name: "Structural refactor",
33
+ description: "одноразовая реструктуризация",
34
+ action: "→ pointer в беклоге, без теста",
35
+ },
36
+ C: {
37
+ name: "Config/data fix",
38
+ description: "замена конкретного значения в файле",
39
+ action: "→ L0 static-only, одна строка",
40
+ },
41
+ D: {
42
+ name: "Obsolete",
43
+ description: "секция с тех пор переписана",
44
+ action: "skip",
45
+ },
46
+ E: {
47
+ name: "Out-of-scope",
48
+ description: "OOS-записи",
49
+ action: "skip",
50
+ },
51
+ };
52
+
53
+ function parseArgs() {
54
+ const args = process.argv.slice(2);
55
+ let backlogPath = null;
56
+ let categoryFilter = null;
57
+ let dryRun = false;
58
+
59
+ for (let i = 0; i < args.length; i++) {
60
+ if (args[i] === "--backlog" && i + 1 < args.length) {
61
+ backlogPath = args[i + 1];
62
+ i++;
63
+ } else if (args[i] === "--category" && i + 1 < args.length) {
64
+ categoryFilter = args[i + 1].toUpperCase();
65
+ i++;
66
+ } else if (args[i] === "--dry-run") {
67
+ dryRun = true;
68
+ }
69
+ }
70
+
71
+ return { backlogPath, categoryFilter, dryRun };
72
+ }
73
+
74
+ function parseBacklogYaml(filePath) {
75
+ const content = fs.readFileSync(filePath, "utf8");
76
+
77
+ const appliedChangesStart = content.indexOf("applied_changes:");
78
+ if (appliedChangesStart === -1) {
79
+ return [];
80
+ }
81
+
82
+ const afterSection = content.substring(appliedChangesStart);
83
+ const lines = afterSection.split(/\r?\n/);
84
+
85
+ const changes = [];
86
+ let currentChange = null;
87
+ let currentField = null;
88
+ let currentValue = [];
89
+ let inAppliedChanges = false;
90
+ let baseIndent = 0;
91
+
92
+ for (let i = 0; i < lines.length; i++) {
93
+ const line = lines[i];
94
+ const trimmed = line.trim();
95
+
96
+ if (trimmed === "applied_changes:") {
97
+ inAppliedChanges = true;
98
+ baseIndent = line.indexOf("applied_changes");
99
+ continue;
100
+ }
101
+
102
+ if (!inAppliedChanges) continue;
103
+
104
+ const indent = line.length - line.trimStart().length;
105
+
106
+ if (trimmed === "" || (indent <= baseIndent && !trimmed.startsWith("-"))) {
107
+ if (currentChange && currentField) {
108
+ currentChange[currentField] = currentValue.join("\n").trim();
109
+ }
110
+ if (currentChange && Object.keys(currentChange).length > 0) {
111
+ changes.push(currentChange);
112
+ }
113
+ currentChange = null;
114
+ currentField = null;
115
+ currentValue = [];
116
+
117
+ if (trimmed !== "" && !trimmed.startsWith("- change_id")) {
118
+ inAppliedChanges = false;
119
+ continue;
120
+ }
121
+ }
122
+
123
+ if (trimmed.startsWith("- change_id:")) {
124
+ if (currentChange && Object.keys(currentChange).length > 0) {
125
+ changes.push(currentChange);
126
+ }
127
+ currentChange = {};
128
+ const match = trimmed.match(/- change_id:\s*["']?([^"']+)["']?/);
129
+ if (match) {
130
+ currentChange.change_id = match[1];
131
+ }
132
+ continue;
133
+ }
134
+
135
+ if (currentChange === null) continue;
136
+
137
+ const fieldMatch = trimmed.match(/^(\w+):\s*(.*)$/);
138
+ if (fieldMatch) {
139
+ if (currentField) {
140
+ currentChange[currentField] = currentValue.join("\n").trim();
141
+ }
142
+ currentField = fieldMatch[1];
143
+ const value = fieldMatch[2];
144
+ if (value && !value.startsWith("|")) {
145
+ currentChange[currentField] = value.replace(/^["']|["']$/g, "");
146
+ }
147
+ currentValue = [];
148
+ continue;
149
+ }
150
+
151
+ if (trimmed.startsWith("- ") && !trimmed.startsWith("- change_id")) {
152
+ if (currentField === "changed_files" || currentField === "based_on_tickets") {
153
+ if (!currentChange[currentField]) {
154
+ currentChange[currentField] = [];
155
+ }
156
+ const item = trimmed.substring(2).replace(/^["']|["']$/g, "");
157
+ currentChange[currentField].push(item);
158
+ }
159
+ continue;
160
+ }
161
+
162
+ if (currentField && indent > baseIndent + 2) {
163
+ currentValue.push(trimmed);
164
+ }
165
+ }
166
+
167
+ if (currentChange && Object.keys(currentChange).length > 0) {
168
+ changes.push(currentChange);
169
+ }
170
+
171
+ return changes;
172
+ }
173
+
174
+ function getGitHistory(backlogPath) {
175
+ try {
176
+ const absPath = path.isAbsolute(backlogPath)
177
+ ? backlogPath
178
+ : path.join(PROJECT_DIR, backlogPath);
179
+
180
+ const gitLog = execSync(
181
+ `git log --all -p -- "${absPath}"`,
182
+ { encoding: "utf8", maxBuffer: 10 * 1024 * 1024 }
183
+ );
184
+ return gitLog;
185
+ } catch (err) {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ function categorizeChange(change) {
191
+ const changeId = change.change_id || "";
192
+ const description = change.description || change.summary || "";
193
+ const targetSkill = change.target_skill || change.target_skills?.[0] || "";
194
+ const changeType = change.change_type || "";
195
+ const changedFiles = change.changed_files || [];
196
+
197
+ const hasStructuralKeywords = [
198
+ "restructur", "реструктуризац", "переработк", "извлечён", "модульн",
199
+ "refactor", "rename", "переимен", "удалён", "упрощен", "consolidat"
200
+ ].some(kw => description.toLowerCase().includes(kw.toLowerCase()));
201
+
202
+ const hasConfigKeywords = [
203
+ "config", "конфиг", "settings", "настройк", "значение", "value",
204
+ "replace", "замен", "фикс", "исправлен", "update path"
205
+ ].some(kw => description.toLowerCase().includes(kw.toLowerCase()));
206
+
207
+ const hasTestKeywords = [
208
+ "test", "тест", "проверк", "assert", "case", "валидац", "верификац"
209
+ ].some(kw => description.toLowerCase().includes(kw.toLowerCase()));
210
+
211
+ const hasSkillKeywords = [
212
+ "skill", "скил", "workflow", "воркфлоу", "knowledge", "алгоритм"
213
+ ].some(kw => description.toLowerCase().includes(kw.toLowerCase()));
214
+
215
+ const isObsolete = changeId.includes("CHG-001..") || changeId.includes("CHG-010..");
216
+ const isOOS = description.toLowerCase().includes("out-of-scope") ||
217
+ description.toLowerCase().includes("oos-");
218
+
219
+ if (isObsolete) {
220
+ return { category: "D", reason: "consolidated change, likely obsolete structure" };
221
+ }
222
+
223
+ if (isOOS) {
224
+ return { category: "E", reason: "explicitly marked as out-of-scope" };
225
+ }
226
+
227
+ if (hasTestKeywords && hasSkillKeywords) {
228
+ return { category: "A", reason: "adds verifiable rule to skill/workflow (testable)" };
229
+ }
230
+
231
+ if (hasStructuralKeywords && !hasTestKeywords) {
232
+ return { category: "B", reason: "one-time structural refactor, no persistent rule" };
233
+ }
234
+
235
+ if (hasConfigKeywords && changedFiles.length <= 2) {
236
+ return { category: "C", reason: "config/data fix, single value replacement" };
237
+ }
238
+
239
+ return { category: "A", reason: "default: skill modification = durable behavior" };
240
+ }
241
+
242
+ function extractMetadata(change) {
243
+ const changeId = change.change_id || "";
244
+ const targetSkill = change.target_skill || change.target_skills?.[0] || "";
245
+ const changeType = change.change_type || "unknown";
246
+ const description = change.description || change.summary || "";
247
+ const basedOnTickets = change.based_on_tickets || [];
248
+ const changedFiles = Array.isArray(change.changed_files)
249
+ ? change.changed_files
250
+ : [change.changed_files].filter(Boolean);
251
+ const coachTicket = change.coach_ticket || "";
252
+
253
+ const metadata = {
254
+ change_id: changeId,
255
+ target_skill: targetSkill,
256
+ change_type: changeType,
257
+ description: description.split("\n").slice(0, 3).join(" ").substring(0, 200),
258
+ based_on_tickets: Array.isArray(basedOnTickets) ? basedOnTickets : [basedOnTickets].filter(Boolean),
259
+ changed_files: changedFiles,
260
+ log_ref: coachTicket.includes("log_file") ? coachTicket : null,
261
+ };
262
+
263
+ return metadata;
264
+ }
265
+
266
+ function extractPrinciples(changes) {
267
+ const principles = new Set();
268
+ const tags = new Set();
269
+
270
+ const principleKeywords = [
271
+ "principle", "принцип", "isolation", "изоляц", "root cause", "корневая",
272
+ "self-correct", "самокоррект", "context budget", "контекст", "universal",
273
+ "универсальн"
274
+ ];
275
+
276
+ const tagKeywords = [
277
+ "skill", "workflow", "knowledge", "algorithm", "test", "template",
278
+ "refactor", "fix", "improve", "add", "remove"
279
+ ];
280
+
281
+ for (const change of changes) {
282
+ const text = (change.description || change.summary || "").toLowerCase();
283
+
284
+ for (const kw of principleKeywords) {
285
+ if (text.includes(kw)) {
286
+ principles.add(kw);
287
+ }
288
+ }
289
+
290
+ for (const kw of tagKeywords) {
291
+ if (text.includes(kw)) {
292
+ tags.add(kw);
293
+ }
294
+ }
295
+ }
296
+
297
+ return {
298
+ principles: Array.from(principles),
299
+ tags: Array.from(tags),
300
+ };
301
+ }
302
+
303
+ function formatTriageTable(triageResults) {
304
+ const header = "| CHG-ID | Категория | Обоснование |";
305
+ const separator = "|--------|-----------|-------------|";
306
+
307
+ const rows = triageResults.map(r => {
308
+ const cat = CATEGORIES[r.category];
309
+ const name = cat ? cat.name : r.category;
310
+ return `| ${r.changeId} | ${r.category}. ${name} | ${r.reason.substring(0, 50)} |`;
311
+ });
312
+
313
+ return [header, separator, ...rows].join("\n");
314
+ }
315
+
316
+ function formatMetadataA(changes) {
317
+ if (changes.length === 0) return "Нет записей категории A";
318
+
319
+ const lines = [];
320
+
321
+ for (const change of changes) {
322
+ const meta = change.metadata;
323
+ lines.push(`\n### ${meta.change_id}`);
324
+ lines.push(`- **target_skill**: ${meta.target_skill}`);
325
+ lines.push(`- **change_type**: ${meta.change_type}`);
326
+ lines.push(`- **description**: ${meta.description}`);
327
+ lines.push(`- **based_on_tickets**: ${meta.based_on_tickets.join(", ")}`);
328
+ lines.push(`- **changed_files**: ${meta.changed_files.join(", ")}`);
329
+ if (meta.log_ref) {
330
+ lines.push(`- **log_ref**: ${meta.log_ref}`);
331
+ }
332
+ }
333
+
334
+ return lines.join("\n");
335
+ }
336
+
337
+ function main() {
338
+ const { backlogPath, categoryFilter, dryRun } = parseArgs();
339
+
340
+ if (!backlogPath) {
341
+ console.error("Usage:");
342
+ console.error(" node migrate-backlog-to-tests.js --backlog <path>");
343
+ console.error(" node migrate-backlog-to-tests.js --backlog <path> --category A");
344
+ console.error(" node migrate-backlog-to-tests.js --dry-run");
345
+ printResult({
346
+ status: "error",
347
+ error: "Missing required argument: --backlog <path>",
348
+ });
349
+ process.exit(1);
350
+ }
351
+
352
+ const resolvedPath = path.isAbsolute(backlogPath)
353
+ ? backlogPath
354
+ : path.join(PROJECT_DIR, backlogPath);
355
+
356
+ if (!fs.existsSync(resolvedPath)) {
357
+ printResult({
358
+ status: "error",
359
+ error: `Backlog file not found: ${resolvedPath}`,
360
+ });
361
+ process.exit(1);
362
+ }
363
+
364
+ const changes = parseBacklogYaml(resolvedPath);
365
+
366
+ const gitHistory = getGitHistory(resolvedPath);
367
+
368
+ const triageResults = changes.map(change => {
369
+ const { category, reason } = categorizeChange(change);
370
+ return {
371
+ changeId: change.change_id,
372
+ category,
373
+ reason,
374
+ metadata: extractMetadata(change),
375
+ };
376
+ });
377
+
378
+ const filteredResults = categoryFilter
379
+ ? triageResults.filter(r => r.category === categoryFilter)
380
+ : triageResults;
381
+
382
+ const categoryA = triageResults.filter(r => r.category === "A");
383
+ const principles = extractPrinciples(changes);
384
+
385
+ const output = {
386
+ status: "success",
387
+ triage_table: formatTriageTable(filteredResults),
388
+ category_a_metadata: categoryA.length > 0 ? formatMetadataA(categoryA) : "Нет записей категории A",
389
+ unique_principles: principles.principles,
390
+ unique_tags: principles.tags,
391
+ stats: {
392
+ total_changes: changes.length,
393
+ category_a: triageResults.filter(r => r.category === "A").length,
394
+ category_b: triageResults.filter(r => r.category === "B").length,
395
+ category_c: triageResults.filter(r => r.category === "C").length,
396
+ category_d: triageResults.filter(r => r.category === "D").length,
397
+ category_e: triageResults.filter(r => r.category === "E").length,
398
+ },
399
+ git_history_available: gitHistory !== null,
400
+ dry_run: dryRun,
401
+ };
402
+
403
+ printResult(output);
404
+ }
405
+
406
+ main();