@educa-corp/sdd-framework 0.9.2 → 0.9.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 (37) hide show
  1. package/core/FRAMEWORK_VERSION +1 -1
  2. package/core/commands/qc-analyze.md +189 -66
  3. package/core/commands/qc-design-test.md +141 -2
  4. package/core/commands/qc-plan.md +153 -7
  5. package/core/commands/qc-review.md +134 -1
  6. package/core/commands/qc-run-test.md +134 -1
  7. package/core/modules/qc-playwright/stack-profile.yaml +3 -3
  8. package/core/skills/qc/qa-analyst/DOC_GAP.template.md +47 -17
  9. package/core/skills/qc/qa-analyst/acceptance-criteria.md +1 -1
  10. package/core/skills/qc/qa-analyst/business-rules.md +2 -2
  11. package/core/skills/qc/qa-analyst/data-flow.md +2 -2
  12. package/core/skills/qc/qa-analyst/spec-breakdown.md +2 -2
  13. package/core/skills/qc/qa-analyst/spec-issue-reporter.md +14 -2
  14. package/core/skills/qc/qa-designer/e2e/journey.md +1 -1
  15. package/core/skills/qc/qa-designer/exploratory/explore-to-functional.md +1 -1
  16. package/core/skills/qc/qa-designer/functional/api.md +1 -1
  17. package/core/skills/qc/qa-designer/functional/gui-feature.md +1 -1
  18. package/core/skills/qc/qa-designer/functional/gui-screen.md +1 -1
  19. package/core/skills/qc/qa-designer/integration/api.md +1 -1
  20. package/core/skills/qc/qa-designer/integration/db.md +1 -1
  21. package/core/skills/qc/qa-designer/integration/gui.md +1 -1
  22. package/core/skills/qc/qa-designer/integration/kafka.md +1 -1
  23. package/core/skills/qc/qa-designer/non-functional.md +1 -1
  24. package/core/skills/qc/qa-planner/test-plan.md +24 -13
  25. package/core/skills/qc/qa-runner/exploratory/session.md +1 -1
  26. package/core/steps/context-loader.md +1 -1
  27. package/core/steps/qc-scope.md +119 -0
  28. package/core/templates/project-context.yaml +3 -1
  29. package/docs/02-concepts/pipeline-steps/08-qc-automation.md +1 -1
  30. package/docs/04-reference/configuration.md +146 -146
  31. package/docs/explain/15-qc-analyze.md +1 -1
  32. package/docs/explain/16-qc-plan.md +1 -1
  33. package/docs/explain/17-qc-design-test.md +1 -1
  34. package/docs/plans/qc-implementation-log.md +145 -4
  35. package/docs/plans/qc-sync-command.md +2 -1
  36. package/package.json +1 -1
  37. package/scripts/migrate-qc-docs.js +261 -0
@@ -0,0 +1,261 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * migrate-qc-docs — move per-UC QC working docs into the per-PRD layout.
4
+ *
5
+ * docs/{TICKET-ID}-UC{N}/{platform}/…
6
+ * → docs/{TICKET-ID}/{platform}/…
7
+ *
8
+ * The per-UC layout shattered one feature across N sibling folders (10 UCs = 10 root
9
+ * folders, 30 files). It was the framework's own imposition when the QC skills were
10
+ * ported: upstream has always been PRD-scoped — their real file is `DOC_GAP_FEAT-02-3.md`
11
+ * (no UC suffix), the gap-ID format `GAP-<UC>-001` only means anything when ONE file holds
12
+ * several UCs, and `qa-planner/test-plan.md` says "Test Plan cho một feature". See B11.
13
+ *
14
+ * WHY THIS SCRIPT DOES NOT MERGE. Collapsing N UC folders into one makes UC1's
15
+ * REQUIREMENT_ANALYSIS.md and UC2's collide on the same target path, and two prose
16
+ * documents cannot be merged mechanically. So:
17
+ *
18
+ * test-cases/* → MOVED (filenames carry <FEATURE>, they do not collide)
19
+ * the 3 top docs → ARCHIVED to {qc}/_archive-per-uc/{UC-ID}/{platform}/
20
+ * and listed as NEEDS REGENERATE
21
+ *
22
+ * Regenerating beats hand-merging: a PRD-level pass reads the PRD, design-spec and
23
+ * tech-doc ONCE instead of once per UC, and it sees cross-UC contradictions that N
24
+ * separate passes structurally cannot. Nothing is deleted — the archive is the way back,
25
+ * and the only way to check whether the PRD-level run caught anything the old ones missed.
26
+ *
27
+ * Also folds in the pending `DOC_GAPS.md` → `DOC_GAP.md` rename (B9), in the same pass,
28
+ * including for folders that are ALREADY in the new layout. Without it `/qc-plan` reports
29
+ * "DOC_GAP not found" while the analysis sits right there under the old name.
30
+ *
31
+ * Reports (does NOT guess) two conditions that need a human:
32
+ * - NO PLATFORM : files sit directly under {qc}/{UC-ID}/ with no platform level. The
33
+ * platform cannot be inferred from a QC artifact, so they are left alone.
34
+ * - OCCUPIED : the target path already holds a file — moving would overwrite it.
35
+ *
36
+ * DRY-RUN by default (prints the plan, changes nothing). Pass --apply to execute.
37
+ * Tracked files move with `git mv` to preserve history; the rest with fs.rename.
38
+ *
39
+ * Usage (from the consumer project root):
40
+ * node scripts/migrate-qc-docs.js # dry-run, prints plan
41
+ * node scripts/migrate-qc-docs.js --apply # execute
42
+ * node scripts/migrate-qc-docs.js --qc docs --root .
43
+ */
44
+
45
+ 'use strict';
46
+
47
+ const fs = require('fs');
48
+ const path = require('path');
49
+ const { execSync } = require('child_process');
50
+
51
+ // ── args ──────────────────────────────────────────────────────────────────────
52
+ const argv = process.argv.slice(2);
53
+ const has = f => argv.includes(f);
54
+ const flag = (f, d) => { const i = argv.indexOf(f); return i !== -1 ? argv[i + 1] : d; };
55
+
56
+ const APPLY = has('--apply');
57
+ const ROOT = path.resolve(flag('--root', '.'));
58
+ const QC = flag('--qc', 'docs');
59
+
60
+ const qcAbs = path.join(ROOT, QC);
61
+ const ARCHIVE = '_archive-per-uc';
62
+
63
+ // The three PRD-level documents. Anything else at that level is archived too (safe
64
+ // default) but called out separately so nobody loses a file without seeing its name.
65
+ const TOP_DOCS = ['REQUIREMENT_ANALYSIS.md', 'DOC_GAP.md', 'DOC_GAPS.md', 'TEST_PLAN.md'];
66
+
67
+ // `{TICKET-ID}-UC{N}` — the framework's UC-ID contract (steps/gate.md Bước 1: TICKET-ID is
68
+ // the part before `-UC`). Anchored at both ends so `FEAT-01-2-UC1` matches but a folder
69
+ // that merely contains the letters "uc" does not.
70
+ const UC_DIR = /^(.+)-UC(\d+)$/i;
71
+
72
+ // ── git tracked set (for `git mv`) ────────────────────────────────────────────
73
+ let tracked = null;
74
+ try {
75
+ const out = execSync('git ls-files', { cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
76
+ tracked = new Set(out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')));
77
+ } catch { tracked = null; }
78
+
79
+ const rel = abs => path.relative(ROOT, abs).replace(/\\/g, '/');
80
+ const isTracked = abs => !!(tracked && tracked.has(rel(abs)));
81
+ const isDir = p => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
82
+
83
+ function walk(dir) {
84
+ let out = [];
85
+ if (!isDir(dir)) return out;
86
+ for (const n of fs.readdirSync(dir)) {
87
+ const p = path.join(dir, n);
88
+ out = out.concat(isDir(p) ? walk(p) : [p]);
89
+ }
90
+ return out;
91
+ }
92
+
93
+ // ── plan ──────────────────────────────────────────────────────────────────────
94
+ const moves = []; // { from, to, kind }
95
+ const noPlatform = []; // { ucDir, files }
96
+ const occupied = []; // { from, to }
97
+ const regen = new Map();// "TICKET|platform" → { ticket, platform, ucs:Set }
98
+
99
+ if (!isDir(qcAbs)) {
100
+ console.log(`\n${QC}/ does not exist under ${ROOT} — nothing to migrate.\n`);
101
+ process.exit(0);
102
+ }
103
+
104
+ const ucDirs = fs.readdirSync(qcAbs)
105
+ .filter(n => n !== ARCHIVE)
106
+ .map(n => ({ name: n, abs: path.join(qcAbs, n) }))
107
+ .filter(d => isDir(d.abs) && UC_DIR.test(d.name));
108
+
109
+ function plan(from, to, kind) {
110
+ // Never overwrite, and never let two sources land on one target.
111
+ if (fs.existsSync(to) || moves.some(m => m.to === to)) { occupied.push({ from, to }); return; }
112
+ moves.push({ from, to, kind });
113
+ }
114
+
115
+ for (const d of ucDirs) {
116
+ const m = UC_DIR.exec(d.name);
117
+ const ticket = m[1];
118
+ const ucNum = m[2];
119
+ const ucId = d.name;
120
+
121
+ // Files sitting directly under {qc}/{UC-ID}/ mean the platform level is absent. A QC
122
+ // artifact carries no @trace.platform, so there is nothing to infer it from — leave them.
123
+ const loose = fs.readdirSync(d.abs).filter(x => !isDir(path.join(d.abs, x)));
124
+ if (loose.length) noPlatform.push({ ucDir: ucId, files: loose });
125
+
126
+ for (const platform of fs.readdirSync(d.abs).filter(x => isDir(path.join(d.abs, x)))) {
127
+ const src = path.join(d.abs, platform);
128
+
129
+ const key = `${ticket}|${platform}`;
130
+ if (!regen.has(key)) regen.set(key, { ticket, platform, ucs: new Set() });
131
+ regen.get(key).ucs.add(`UC${ucNum}`);
132
+
133
+ for (const abs of walk(src)) {
134
+ const within = path.relative(src, abs).replace(/\\/g, '/');
135
+ const base = path.basename(abs);
136
+
137
+ if (within.startsWith('test-cases/')) {
138
+ // Survives the merge — the filename carries <FEATURE>, and the Trace SC column
139
+ // is what says which UC a TC belongs to.
140
+ plan(abs, path.join(qcAbs, ticket, platform, within), 'test-case');
141
+ } else {
142
+ // Archive, renaming DOC_GAPS.md → DOC_GAP.md so the archive uses one name.
143
+ const outName = base === 'DOC_GAPS.md' ? 'DOC_GAP.md' : base;
144
+ const outRel = path.join(path.dirname(within), outName);
145
+ plan(abs, path.join(qcAbs, ARCHIVE, ucId, platform, outRel),
146
+ TOP_DOCS.includes(base) ? 'archive' : 'archive-other');
147
+ }
148
+ }
149
+ }
150
+ }
151
+
152
+ // ── second pass — DOC_GAPS.md → DOC_GAP.md for folders ALREADY in the new layout ──
153
+ for (const abs of walk(qcAbs)) {
154
+ if (path.basename(abs) !== 'DOC_GAPS.md') continue;
155
+ if (rel(abs).includes(`/${ARCHIVE}/`)) continue;
156
+ if (moves.some(mv => mv.from === abs)) continue; // already handled above
157
+ plan(abs, path.join(path.dirname(abs), 'DOC_GAP.md'), 'rename');
158
+ }
159
+
160
+ // ── report ────────────────────────────────────────────────────────────────────
161
+ const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length));
162
+ console.log('');
163
+ console.log('╔════════════════════════════════════════════════╗');
164
+ console.log(`║ migrate-qc-docs — ${pad(APPLY ? 'APPLY' : 'DRY RUN', 25)}║`);
165
+ console.log('╚════════════════════════════════════════════════╝');
166
+ console.log(`Root : ${ROOT}`);
167
+ console.log(`QC : ${QC}/ Git: ${tracked ? 'yes (git mv)' : 'no (fs move)'}`);
168
+ console.log('');
169
+
170
+ if (!moves.length && !noPlatform.length && !occupied.length) {
171
+ console.log('Nothing to migrate — no per-UC folder found, and no DOC_GAPS.md to rename.');
172
+ console.log(`(Scanned ${QC}/ for folders matching {TICKET-ID}-UC{N}.)`);
173
+ console.log('');
174
+ process.exit(0);
175
+ }
176
+
177
+ const byKind = k => moves.filter(m => m.kind === k);
178
+ const show = (title, list) => {
179
+ if (!list.length) return;
180
+ console.log(`${title} (${list.length})`);
181
+ for (const m of list) console.log(` ${rel(m.from)}\n -> ${rel(m.to)}`);
182
+ console.log('');
183
+ };
184
+
185
+ show('📦 MOVE — test cases (survive the merge)', byKind('test-case'));
186
+ show('🗄 ARCHIVE — the 3 PRD-level docs (regenerate instead of merging)', byKind('archive'));
187
+ show('🗄 ARCHIVE — other files found at that level', byKind('archive-other'));
188
+ show('✏️ RENAME — DOC_GAPS.md -> DOC_GAP.md (B9, already-new layout)', byKind('rename'));
189
+
190
+ if (noPlatform.length) {
191
+ console.log(`⚠️ NO PLATFORM — left in place (${noPlatform.length})`);
192
+ console.log(' Files sit directly under the UC folder with no web/app/system level.');
193
+ console.log(' A QC artifact carries no @trace.platform, so the platform cannot be');
194
+ console.log(' inferred — move these by hand into the right platform folder.');
195
+ for (const x of noPlatform) console.log(` ${QC}/${x.ucDir}/ -> ${x.files.join(', ')}`);
196
+ console.log('');
197
+ }
198
+
199
+ if (occupied.length) {
200
+ console.log(`❌ OCCUPIED — NOT moved, target already exists (${occupied.length})`);
201
+ console.log(' Moving would overwrite. Resolve by hand, then re-run.');
202
+ for (const x of occupied) console.log(` ${rel(x.from)}\n x ${rel(x.to)}`);
203
+ console.log('');
204
+ }
205
+
206
+ if (regen.size) {
207
+ console.log('🔄 NEEDS REGENERATE — run these after the move:');
208
+ for (const r of regen.values()) {
209
+ console.log(` /qc-analyze ${r.ticket} ${r.platform} <- was ${[...r.ucs].sort().join(' + ')}`);
210
+ console.log(` /qc-plan ${r.ticket} ${r.platform}`);
211
+ }
212
+ console.log('');
213
+ console.log(' The archived per-UC docs are the way back, and the only way to check');
214
+ console.log(' whether the PRD-level run caught cross-UC contradictions the old ones');
215
+ console.log(' could not see. Compare, then delete the archive when you are satisfied.');
216
+ console.log('');
217
+ }
218
+
219
+ if (!APPLY) {
220
+ console.log('DRY RUN — nothing changed. Re-run with --apply to execute.');
221
+ console.log('');
222
+ process.exit(0);
223
+ }
224
+
225
+ // ── execute ───────────────────────────────────────────────────────────────────
226
+ function mv(from, to) {
227
+ fs.mkdirSync(path.dirname(to), { recursive: true });
228
+ if (isTracked(from)) execSync(`git mv -k "${rel(from)}" "${rel(to)}"`, { cwd: ROOT, stdio: ['ignore', 'ignore', 'pipe'] });
229
+ else fs.renameSync(from, to);
230
+ }
231
+
232
+ let moved = 0, failed = 0;
233
+ for (const m of moves) {
234
+ try { mv(m.from, m.to); moved++; }
235
+ catch (err) {
236
+ console.log(` ❌ ${rel(m.from)} — ${err.message.split('\n')[0]}`);
237
+ failed++;
238
+ }
239
+ }
240
+
241
+ // Prune folders the move emptied — deepest first, and only if genuinely empty.
242
+ let pruned = 0;
243
+ const dirsDeepFirst = [];
244
+ (function collect(dir) {
245
+ if (!isDir(dir)) return;
246
+ for (const n of fs.readdirSync(dir)) collect(path.join(dir, n));
247
+ dirsDeepFirst.push(dir);
248
+ })(qcAbs);
249
+ for (const d of dirsDeepFirst) {
250
+ if (d === qcAbs) continue;
251
+ try { if (fs.readdirSync(d).length === 0) { fs.rmdirSync(d); pruned++; } } catch { /* keep */ }
252
+ }
253
+
254
+ console.log(`✅ Moved ${moved} file(s)${failed ? `, ${failed} FAILED` : ''}.`);
255
+ console.log(`✅ Pruned ${pruned} empty folder(s).`);
256
+ console.log('');
257
+ console.log('Next:');
258
+ console.log(' 1. git status — review the moves (nothing was deleted)');
259
+ console.log(' 2. Run the /qc-analyze + /qc-plan commands listed above');
260
+ console.log(` 3. Compare against ${QC}/${ARCHIVE}/, then remove the archive when satisfied`);
261
+ console.log('');