@educa-corp/sdd-framework 0.3.0 → 0.4.2
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/bin/build.js +9 -0
- package/bin/index.js +115 -4
- package/bin/self-check.js +236 -0
- package/bin/trace-schema.json +692 -0
- package/commands/debug.md +82 -17
- package/commands/define-product.md +82 -17
- package/commands/dev-gen-test.md +82 -17
- package/commands/dev-run-test.md +84 -18
- package/commands/dev-run-test.tmpl +2 -1
- package/commands/dev-smoke-test.md +82 -17
- package/commands/fix-bug.md +137 -20
- package/commands/fix-bug.tmpl +29 -3
- package/commands/generate-architecture.md +82 -17
- package/commands/generate-bdd.md +187 -44
- package/commands/generate-bdd.tmpl +92 -17
- package/commands/generate-code.md +115 -20
- package/commands/generate-code.tmpl +33 -3
- package/commands/generate-design-spec.md +82 -17
- package/commands/generate-prd.md +82 -17
- package/commands/generate-spec-manifest.md +82 -17
- package/commands/generate-tech-docs.md +85 -20
- package/commands/generate-tech-docs.tmpl +2 -2
- package/commands/learn.md +82 -17
- package/commands/map-testids.md +82 -17
- package/commands/propose-scenario.md +102 -19
- package/commands/propose-scenario.tmpl +20 -2
- package/commands/qc-analyze.md +82 -17
- package/commands/qc-design-test.md +82 -17
- package/commands/qc-plan.md +82 -17
- package/commands/qc-report.md +82 -17
- package/commands/qc-review.md +82 -17
- package/commands/qc-run-test.md +104 -19
- package/commands/qc-run-test.tmpl +22 -2
- package/commands/refine-prd.md +82 -17
- package/commands/report-bug.md +82 -17
- package/commands/review-code.md +122 -19
- package/commands/review-code.tmpl +40 -2
- package/commands/review-context.md +124 -21
- package/commands/review-context.tmpl +42 -4
- package/commands/review-tech-docs.md +113 -19
- package/commands/review-tech-docs.tmpl +31 -2
- package/commands/setup-ai-first.md +35 -16
- package/commands/setup-ai-first.tmpl +19 -6
- package/commands/sync.md +15 -4
- package/commands/sync.tmpl +12 -2
- package/commands/update-framework.md +40 -2
- package/commands/update-framework.tmpl +37 -0
- package/commands/validate-traces.md +231 -25
- package/commands/validate-traces.tmpl +149 -8
- package/core/FRAMEWORK_VERSION +1 -1
- package/core/README.md +56 -0
- package/core/commands/debug.md +82 -17
- package/core/commands/define-product.md +82 -17
- package/core/commands/dev-gen-test.md +82 -17
- package/core/commands/dev-run-test.md +84 -18
- package/core/commands/dev-smoke-test.md +82 -17
- package/core/commands/fix-bug.md +137 -20
- package/core/commands/generate-architecture.md +82 -17
- package/core/commands/generate-bdd.md +187 -44
- package/core/commands/generate-code.md +115 -20
- package/core/commands/generate-design-spec.md +82 -17
- package/core/commands/generate-prd.md +82 -17
- package/core/commands/generate-spec-manifest.md +82 -17
- package/core/commands/generate-tech-docs.md +85 -20
- package/core/commands/learn.md +82 -17
- package/core/commands/map-testids.md +82 -17
- package/core/commands/propose-scenario.md +102 -19
- package/core/commands/qc-analyze.md +82 -17
- package/core/commands/qc-design-test.md +82 -17
- package/core/commands/qc-plan.md +82 -17
- package/core/commands/qc-report.md +82 -17
- package/core/commands/qc-review.md +82 -17
- package/core/commands/qc-run-test.md +104 -19
- package/core/commands/refine-prd.md +82 -17
- package/core/commands/report-bug.md +82 -17
- package/core/commands/review-code.md +122 -19
- package/core/commands/review-context.md +124 -21
- package/core/commands/review-tech-docs.md +113 -19
- package/core/commands/setup-ai-first.md +35 -16
- package/core/commands/sync.md +15 -4
- package/core/commands/update-framework.md +40 -2
- package/core/commands/validate-traces.md +231 -25
- package/core/modules/android-compose/stack-profile.yaml +1 -1
- package/core/modules/flutter/stack-profile.yaml +1 -1
- package/core/modules/ios-swiftui/stack-profile.yaml +1 -1
- package/core/modules/java-spring/stack-profile.yaml +1 -1
- package/core/modules/nextjs/stack-profile.yaml +1 -1
- package/core/modules/nuxt/stack-profile.yaml +1 -1
- package/core/modules/phaser-game/stack-profile.yaml +1 -1
- package/core/modules/php-laravel/stack-profile.yaml +1 -1
- package/core/modules/qc-playwright/stack-profile.yaml +1 -1
- package/core/modules/react/stack-profile.yaml +1 -1
- package/core/modules/react-native/stack-profile.yaml +1 -1
- package/core/modules/vue/stack-profile.yaml +1 -1
- package/core/rules/workflow.md +11 -0
- package/core/steps/context-loader.md +66 -7
- package/core/steps/gate.md +13 -8
- package/core/steps/report-footer.md +3 -2
- package/core/templates/README.md +47 -0
- package/core/templates/feature.template +13 -10
- package/core/templates/project-context.yaml +49 -17
- package/core/templates/tech-design.template.md +1 -1
- package/docs/02-concepts/traceability.md +29 -6
- package/docs/04-reference/trace-schema.md +128 -37
- package/modules/android-compose/stack-profile.yaml +1 -1
- package/modules/flutter/stack-profile.yaml +1 -1
- package/modules/ios-swiftui/stack-profile.yaml +1 -1
- package/modules/java-spring/stack-profile.yaml +1 -1
- package/modules/nextjs/stack-profile.yaml +1 -1
- package/modules/nuxt/stack-profile.yaml +1 -1
- package/modules/phaser-game/stack-profile.yaml +1 -1
- package/modules/php-laravel/stack-profile.yaml +1 -1
- package/modules/qc-playwright/stack-profile.yaml +1 -1
- package/modules/react/stack-profile.yaml +1 -1
- package/modules/react-native/stack-profile.yaml +1 -1
- package/modules/vue/stack-profile.yaml +1 -1
- package/package.json +50 -49
- package/rules/workflow.md +11 -0
- package/scripts/migrate-bdd-platform.js +286 -0
- package/steps/context-loader.md +66 -7
- package/steps/gate.md +13 -8
- package/steps/report-footer.md +3 -2
- package/templates/README.md +47 -0
- package/templates/feature.template +13 -10
- package/templates/project-context.yaml +49 -17
- package/templates/tech-design.template.md +1 -1
package/bin/build.js
CHANGED
|
@@ -215,6 +215,15 @@ for (const { src, dest, filter } of coreMappings) {
|
|
|
215
215
|
fs.writeFileSync(path.join(CORE_DIR, 'FRAMEWORK_VERSION'), VERSION + '\n', 'utf8');
|
|
216
216
|
console.log(` ✅ core/FRAMEWORK_VERSION (v${VERSION})`);
|
|
217
217
|
|
|
218
|
+
// .agent/README.md — which paths under .agent/ a project may edit, and which the
|
|
219
|
+
// upgrade overwrites (see GAPS.md G24). Source lives at repo root so it does not
|
|
220
|
+
// land inside .agent/templates/.
|
|
221
|
+
const agentReadmeSrc = path.join(ROOT, 'AGENT_README.md');
|
|
222
|
+
if (fs.existsSync(agentReadmeSrc)) {
|
|
223
|
+
fs.copyFileSync(agentReadmeSrc, path.join(CORE_DIR, 'README.md'));
|
|
224
|
+
console.log(' ✅ core/README.md (← AGENT_README.md)');
|
|
225
|
+
}
|
|
226
|
+
|
|
218
227
|
// ── 4. Sync local dogfood .agent/ (dev repo only) ────────────────────────────
|
|
219
228
|
//
|
|
220
229
|
// In THIS framework repo, .agent/ is a pure mirror of core/ used to dogfood the
|
package/bin/index.js
CHANGED
|
@@ -9,8 +9,15 @@ const ROOT = path.join(__dirname, '..');
|
|
|
9
9
|
const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
|
|
10
10
|
const VERSION = pkg.version;
|
|
11
11
|
|
|
12
|
+
// Records the hash of exactly what the last install wrote into .agent/, so the next
|
|
13
|
+
// install can tell a user-edited file from a framework-changed one (see G24 below).
|
|
14
|
+
// Declared up here, not next to installCore(): that function runs before the bottom
|
|
15
|
+
// of this file is evaluated, and `const` does not hoist.
|
|
16
|
+
const MANIFEST_NAME = '.install-manifest.json';
|
|
17
|
+
|
|
12
18
|
const args = process.argv.slice(2);
|
|
13
19
|
const isMigrateSpecs = args.includes('--migrate-specs');
|
|
20
|
+
const isMigrateBddPf = args.includes('--migrate-bdd-platform');
|
|
14
21
|
const isRenamePrd = args.includes('--rename-prd-files');
|
|
15
22
|
const isInit = args.includes('--init');
|
|
16
23
|
const isProject = args.includes('--project');
|
|
@@ -85,6 +92,13 @@ if (showHelp) {
|
|
|
85
92
|
console.log(' spec layout (specs/prd, specs/bdd, ...) to the feature-package');
|
|
86
93
|
console.log(' layout (specs/{domain}/{prd-slug}/{ {TICKET-ID}-{prd-slug}.md,bdd/,...}).');
|
|
87
94
|
console.log(' DRY-RUN by default; add --apply to execute. Uses git mv when possible.');
|
|
95
|
+
console.log(' --migrate-bdd-platform Move flat BDD files into the per-platform layout:');
|
|
96
|
+
console.log(' specs/{domain}/{slug}/bdd/*.feature → bdd/{platform}/*.feature.');
|
|
97
|
+
console.log(' For projects whose umbrella-mode runs produced the flat layout');
|
|
98
|
+
console.log(' (pre-v0.4.1). Resolves platform from @trace.platform, else @trace.module,');
|
|
99
|
+
console.log(' else step vocabulary; inserts @trace.platform when missing. Reports');
|
|
100
|
+
console.log(' filename COLLISION and LOST SPEC instead of guessing.');
|
|
101
|
+
console.log(' DRY-RUN by default; add --apply to execute. Uses git mv when possible.');
|
|
88
102
|
console.log(' --rename-prd-files Rename feature-package PRD files from the old fixed name prd.md to the');
|
|
89
103
|
console.log(' {TICKET-ID}-{prd-slug}.md convention (e.g. SEG01-segment-scoring-service.md).');
|
|
90
104
|
console.log(' For projects already on the feature-package layout. DRY-RUN by default;');
|
|
@@ -122,6 +136,13 @@ if (isMigrateSpecs) {
|
|
|
122
136
|
return; // migrate-specs.js calls process.exit itself
|
|
123
137
|
}
|
|
124
138
|
|
|
139
|
+
// ── --migrate-bdd-platform: flat bdd/*.feature → bdd/{platform}/*.feature ──
|
|
140
|
+
// Also operates on the user's specs, so it runs BEFORE the build step. See GAPS.md G3.
|
|
141
|
+
if (isMigrateBddPf) {
|
|
142
|
+
require(path.join(__dirname, '..', 'scripts', 'migrate-bdd-platform.js'));
|
|
143
|
+
return; // migrate-bdd-platform.js calls process.exit itself
|
|
144
|
+
}
|
|
145
|
+
|
|
125
146
|
// ── --rename-prd-files: rename feature-package prd.md → {TICKET-ID}-{slug}.md ──
|
|
126
147
|
if (isRenamePrd) {
|
|
127
148
|
require(path.join(__dirname, '..', 'scripts', 'rename-prd-files.js'));
|
|
@@ -165,18 +186,20 @@ if (isInit) {
|
|
|
165
186
|
if (moduleName) console.log(`Module : ${moduleName}`);
|
|
166
187
|
console.log('');
|
|
167
188
|
|
|
168
|
-
// 1. Copy core/** → .agent/**
|
|
189
|
+
// 1. Copy core/** → .agent/** (backs up + reports locally edited files — G24)
|
|
169
190
|
console.log('Installing framework files to .agent/ ...');
|
|
170
191
|
console.log('');
|
|
171
|
-
|
|
192
|
+
const agentInstall = installCore(coreDir, agentDir, frameworkVersion);
|
|
172
193
|
|
|
173
194
|
const agentSubdirs = fs.readdirSync(agentDir, { withFileTypes: true })
|
|
174
195
|
.filter(e => e.isDirectory())
|
|
196
|
+
.filter(e => !e.name.startsWith('.overwritten-'))
|
|
175
197
|
.map(e => e.name);
|
|
176
198
|
for (const sub of agentSubdirs) {
|
|
177
199
|
console.log(` ✅ .agent/${sub}/`);
|
|
178
200
|
}
|
|
179
201
|
console.log(` ✅ .agent/FRAMEWORK_VERSION (v${frameworkVersion})`);
|
|
202
|
+
reportOverwritten(agentInstall, '.agent/');
|
|
180
203
|
|
|
181
204
|
// 2. Create .claude/commands/ shortcuts that delegate to .agent/commands/
|
|
182
205
|
console.log('');
|
|
@@ -340,9 +363,10 @@ if (isInit) {
|
|
|
340
363
|
// Create service dir if needed
|
|
341
364
|
fs.mkdirSync(svcDir, { recursive: true });
|
|
342
365
|
|
|
343
|
-
// Copy core → service/.agent/
|
|
344
|
-
|
|
366
|
+
// Copy core → service/.agent/ (backs up + reports locally edited files — G24)
|
|
367
|
+
const svcInstall = installCore(coreDir, svcAgentDir, frameworkVersion);
|
|
345
368
|
console.log(` ✅ ${svc.name}/.agent/`);
|
|
369
|
+
reportOverwritten(svcInstall, `${svc.name}/.agent/`);
|
|
346
370
|
|
|
347
371
|
// Install module
|
|
348
372
|
if (svc.module) {
|
|
@@ -595,6 +619,93 @@ function copyDirRecursive(src, dest) {
|
|
|
595
619
|
}
|
|
596
620
|
}
|
|
597
621
|
|
|
622
|
+
// ── Overwrite-aware install of core/ → .agent/ ───────────────────────────────
|
|
623
|
+
// .agent/ is a GENERATED mirror: --init (and therefore /update-framework) copies
|
|
624
|
+
// core/ over it unconditionally. Anything a project edited in there is destroyed
|
|
625
|
+
// silently. This wrapper makes that visible: it backs up the edited files and
|
|
626
|
+
// reports them. It does NOT change the overwrite behaviour. See GAPS.md G24.
|
|
627
|
+
//
|
|
628
|
+
// The hard part is telling "the USER edited this file" apart from "the FRAMEWORK
|
|
629
|
+
// changed this file between versions" — a plain core-vs-.agent content diff flags
|
|
630
|
+
// both, so every upgrade would report dozens of files that nobody touched.
|
|
631
|
+
// Hence a manifest: each install records the hash of exactly what it wrote, so
|
|
632
|
+
// the next install can tell a pristine file (hash matches) from an edited one.
|
|
633
|
+
function sha1(file) {
|
|
634
|
+
return require('crypto').createHash('sha1').update(fs.readFileSync(file)).digest('hex');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function readManifest(agentDir) {
|
|
638
|
+
try {
|
|
639
|
+
const raw = JSON.parse(fs.readFileSync(path.join(agentDir, MANIFEST_NAME), 'utf8'));
|
|
640
|
+
return raw && raw.files ? raw : null;
|
|
641
|
+
} catch { return null; }
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Copy core/ → .agent/, backing up any file the project edited since the last install.
|
|
646
|
+
* @returns {{ edited: string[], backupDir: string|null, firstRun: boolean }}
|
|
647
|
+
* edited — repo-relative paths (posix) that were overwritten AND had local edits
|
|
648
|
+
* firstRun — no manifest existed, so edits could not be detected this time
|
|
649
|
+
*/
|
|
650
|
+
function installCore(coreDir, agentDir, version) {
|
|
651
|
+
const manifest = readManifest(agentDir);
|
|
652
|
+
const firstRun = !manifest && fs.existsSync(path.join(agentDir, 'commands'));
|
|
653
|
+
const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
|
|
654
|
+
const backupDir = path.join(agentDir, `.overwritten-${version}-${stamp}`);
|
|
655
|
+
const edited = [];
|
|
656
|
+
const written = {};
|
|
657
|
+
|
|
658
|
+
const walk = (src, dst) => {
|
|
659
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
660
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
661
|
+
const s = path.join(src, entry.name);
|
|
662
|
+
const d = path.join(dst, entry.name);
|
|
663
|
+
if (entry.isDirectory()) { walk(s, d); continue; }
|
|
664
|
+
|
|
665
|
+
const rel = path.relative(agentDir, d).replace(/\\/g, '/');
|
|
666
|
+
// Locally edited == exists, we have a record of what we last wrote, and the
|
|
667
|
+
// file no longer matches that record. Framework-only changes never match here.
|
|
668
|
+
if (manifest && fs.existsSync(d) && manifest.files[rel] && sha1(d) !== manifest.files[rel]) {
|
|
669
|
+
const bak = path.join(backupDir, rel);
|
|
670
|
+
fs.mkdirSync(path.dirname(bak), { recursive: true });
|
|
671
|
+
fs.copyFileSync(d, bak);
|
|
672
|
+
edited.push(rel);
|
|
673
|
+
}
|
|
674
|
+
fs.copyFileSync(s, d);
|
|
675
|
+
written[rel] = sha1(d);
|
|
676
|
+
}
|
|
677
|
+
};
|
|
678
|
+
walk(coreDir, agentDir);
|
|
679
|
+
|
|
680
|
+
fs.writeFileSync(
|
|
681
|
+
path.join(agentDir, MANIFEST_NAME),
|
|
682
|
+
JSON.stringify({ version, generated_at: new Date().toISOString(), files: written }, null, 2),
|
|
683
|
+
'utf8'
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
return { edited, backupDir: edited.length ? backupDir : null, firstRun };
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function reportOverwritten(result, agentLabel) {
|
|
690
|
+
if (result.firstRun) {
|
|
691
|
+
console.log('');
|
|
692
|
+
console.log(` ℹ️ ${agentLabel}${MANIFEST_NAME} created. Từ lần nâng cấp SAU, mọi file bạn`);
|
|
693
|
+
console.log(` sửa trong ${agentLabel} mà bị ghi đè sẽ được backup + liệt kê.`);
|
|
694
|
+
return;
|
|
695
|
+
}
|
|
696
|
+
if (!result.edited.length) return;
|
|
697
|
+
|
|
698
|
+
const rel = path.relative(process.cwd(), result.backupDir).replace(/\\/g, '/');
|
|
699
|
+
console.log('');
|
|
700
|
+
console.log(` ⚠️ ${result.edited.length} file trong ${agentLabel} đã bị bạn sửa và VỪA BỊ GHI ĐÈ:`);
|
|
701
|
+
for (const f of result.edited.slice(0, 12)) console.log(` ${agentLabel}${f}`);
|
|
702
|
+
if (result.edited.length > 12) console.log(` … và ${result.edited.length - 12} file nữa`);
|
|
703
|
+
console.log(` Bản cũ đã lưu ở: ${rel}/`);
|
|
704
|
+
console.log(` ${agentLabel} là bản mirror SINH RA — sửa trực tiếp ở đó không bền.`);
|
|
705
|
+
console.log(` Muốn giữ thay đổi: chuyển nó vào repo framework rồi phát hành,`);
|
|
706
|
+
console.log(` hoặc áp lại thủ công sau khi xem diff. Xoá thư mục backup khi xong.`);
|
|
707
|
+
}
|
|
708
|
+
|
|
598
709
|
function installDataGuardHook() {
|
|
599
710
|
const projectRoot = process.cwd();
|
|
600
711
|
const hooksDir = path.join(projectRoot, '.claude', 'hooks');
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* self-check — đối chiếu bin/trace-schema.json với các file lệnh/step/template thật.
|
|
4
|
+
*
|
|
5
|
+
* Vì sao tồn tại: 19/23 GAP trong GAPS.md là CÙNG một lỗi — contract (field, path,
|
|
6
|
+
* giá trị enum) được phát biểu lại bằng prose ở nhiều lệnh rồi lệch nhau qua từng
|
|
7
|
+
* lần sửa, và KHÔNG có gì phát hiện. Ca thuần khiết nhất là @trace.sc_version:
|
|
8
|
+
* 3 consumer, 0 producer, sống qua nhiều version mà không lệnh nào, không cổng
|
|
9
|
+
* nào, không test nào bắt được. Đây là cái bắt lớp lỗi đó một cách mechanical.
|
|
10
|
+
*
|
|
11
|
+
* 6 rule:
|
|
12
|
+
* R1 field có read_by nhưng KHÔNG có producer → ERROR (bắt được G1)
|
|
13
|
+
* R2 field có producer nhưng KHÔNG ai đọc → WARN (bắt được G15, fe_phase)
|
|
14
|
+
* R3 actor được khai là producer/consumer mà file của
|
|
15
|
+
* nó KHÔNG nhắc tên field → ERROR (bắt được G5, G12, G13)
|
|
16
|
+
* R4 {paths.X} dùng trong lệnh mà X không có trong
|
|
17
|
+
* project-context.yaml, và ngược lại key chết → ERROR / WARN (bắt được G17)
|
|
18
|
+
* R5 pattern bị cấm xuất hiện trở lại → ERROR (bắt được G3, G8, G10, G18, G21)
|
|
19
|
+
* R6 giá trị enum khai trong vocabulary mà không actor
|
|
20
|
+
* nào dùng → WARN (bắt được G19: integration vs integrated)
|
|
21
|
+
*
|
|
22
|
+
* Chạy: node bin/self-check.js (exit 1 nếu có ERROR)
|
|
23
|
+
* node bin/self-check.js --warn-only (luôn exit 0)
|
|
24
|
+
* Được `npm run build` gọi sau khi assemble template.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const fs = require('fs');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
const ROOT = path.join(__dirname, '..');
|
|
31
|
+
const WARN_ONLY = process.argv.includes('--warn-only');
|
|
32
|
+
|
|
33
|
+
const schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'trace-schema.json'), 'utf8'));
|
|
34
|
+
|
|
35
|
+
const errors = [];
|
|
36
|
+
const warns = [];
|
|
37
|
+
const err = (rule, msg, hint) => errors.push({ rule, msg, hint });
|
|
38
|
+
const warn = (rule, msg, hint) => warns.push({ rule, msg, hint });
|
|
39
|
+
|
|
40
|
+
// ── nạp nội dung actor ────────────────────────────────────────────────────────
|
|
41
|
+
const actorText = {};
|
|
42
|
+
const missingActors = [];
|
|
43
|
+
for (const [name, rel] of Object.entries(schema.actors)) {
|
|
44
|
+
if (name.startsWith('$')) continue;
|
|
45
|
+
const abs = path.join(ROOT, rel);
|
|
46
|
+
if (!fs.existsSync(abs)) { missingActors.push(`${name} → ${rel}`); continue; }
|
|
47
|
+
actorText[name] = fs.readFileSync(abs, 'utf8');
|
|
48
|
+
}
|
|
49
|
+
if (missingActors.length) {
|
|
50
|
+
err('R0', `${missingActors.length} actor trong schema trỏ tới file không tồn tại`,
|
|
51
|
+
missingActors.join('\n '));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Mọi file .tmpl + step + template — dùng cho R4/R5/R6 (quét rộng). */
|
|
55
|
+
function allSourceFiles() {
|
|
56
|
+
const out = [];
|
|
57
|
+
const push = (dir, filter) => {
|
|
58
|
+
if (!fs.existsSync(dir)) return;
|
|
59
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
60
|
+
const p = path.join(dir, e.name);
|
|
61
|
+
if (e.isDirectory()) push(p, filter);
|
|
62
|
+
else if (filter(e.name)) out.push(p);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
push(path.join(ROOT, 'commands'), f => f.endsWith('.tmpl'));
|
|
66
|
+
push(path.join(ROOT, 'steps'), f => f.endsWith('.md'));
|
|
67
|
+
push(path.join(ROOT, 'templates'), () => true);
|
|
68
|
+
push(path.join(ROOT, 'rules'), f => f.endsWith('.md'));
|
|
69
|
+
push(path.join(ROOT, 'modules'), f => f.endsWith('.yaml') || f.endsWith('.md'));
|
|
70
|
+
return out;
|
|
71
|
+
}
|
|
72
|
+
const sources = allSourceFiles().map(p => ({
|
|
73
|
+
rel: path.relative(ROOT, p).replace(/\\/g, '/'),
|
|
74
|
+
text: fs.readFileSync(p, 'utf8'),
|
|
75
|
+
}));
|
|
76
|
+
const allText = sources.map(s => s.text).join('\n');
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Field `name` có được nhắc trong `text` như một TOKEN TRỌN VẸN không?
|
|
80
|
+
*
|
|
81
|
+
* Phải khớp theo ranh giới token, không phải substring. Hai bẫy thật:
|
|
82
|
+
* - `@trace.XXsc_version` CHỨA chuỗi `sc_version` → một phép so substring sẽ coi là
|
|
83
|
+
* "có nhắc" và R3 im lặng đúng lúc contract vừa bị gỡ producer. (Chính test tái
|
|
84
|
+
* tạo G1 đã lộ ra bug này trong bản đầu của checker.)
|
|
85
|
+
* - `dev_selftest_at` CHỨA `dev_selftest` → lệnh chỉ nhắc cột này sẽ được coi là
|
|
86
|
+
* đã implement cả cột kia.
|
|
87
|
+
*/
|
|
88
|
+
function mentions(text, name) {
|
|
89
|
+
const esc = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
90
|
+
// Trước: không được là phần đuôi của một token dài hơn (chặn `XXsc_version`).
|
|
91
|
+
// Sau: không được là phần đầu của token dài hơn (chặn `dev_selftest` khớp `..._at`).
|
|
92
|
+
const before = name.startsWith('@') ? '' : '(?<![A-Za-z0-9_.])';
|
|
93
|
+
return new RegExp(`${before}${esc}(?![A-Za-z0-9_])`).test(text);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ── R1 + R2 + R3 — producer / consumer / prose khớp ───────────────────────────
|
|
97
|
+
function checkField(kind, name, def) {
|
|
98
|
+
const producers = [...(def.written_by || []), ...(def.bumped_by || [])];
|
|
99
|
+
const consumers = def.read_by || [];
|
|
100
|
+
|
|
101
|
+
// R1 — có người đọc mà không ai ghi
|
|
102
|
+
if (consumers.length && !producers.length) {
|
|
103
|
+
err('R1', `${kind} \`${name}\`: có ${consumers.length} consumer nhưng KHÔNG có producer`,
|
|
104
|
+
`Ai ghi/bump nó? Thêm written_by hoặc bumped_by vào schema, và implement ở lệnh đó.\n`
|
|
105
|
+
+ ` Đây chính là hình dạng của G1 (@trace.sc_version) — DRIFT chết mà không ai báo.`);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// R2 — có người ghi mà không ai đọc
|
|
109
|
+
if (producers.length && !consumers.length && !def.allow_no_reader) {
|
|
110
|
+
warn('R2', `${kind} \`${name}\`: có producer nhưng KHÔNG ai đọc — field chết?`,
|
|
111
|
+
`Hoặc thêm consumer, hoặc bỏ field, hoặc khai allow_no_reader kèm lý do.`);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// R3 — actor được khai nhưng file của nó không nhắc field
|
|
115
|
+
for (const actor of new Set([...producers, ...consumers])) {
|
|
116
|
+
const text = actorText[actor];
|
|
117
|
+
if (text === undefined) continue; // đã báo ở R0
|
|
118
|
+
if (!mentions(text, name)) {
|
|
119
|
+
err('R3', `${kind} \`${name}\`: schema khai \`${actor}\` là producer/consumer nhưng ${schema.actors[actor]} KHÔNG nhắc tới nó`,
|
|
120
|
+
`Hoặc lệnh đó thiếu implement, hoặc schema khai sai. Prose lệch schema chính là\n`
|
|
121
|
+
+ ` cách 19/23 GAP phát sinh.`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const t of schema.tags) checkField('tag', t.name, t);
|
|
127
|
+
for (const c of schema.tsv_columns) checkField('cột TSV', c.name, c);
|
|
128
|
+
|
|
129
|
+
// ── R4 — {paths.X} vs project-context.yaml ────────────────────────────────────
|
|
130
|
+
const cfgPath = path.join(ROOT, schema.path_keys.config_file);
|
|
131
|
+
if (!fs.existsSync(cfgPath)) {
|
|
132
|
+
err('R4', `Không tìm thấy ${schema.path_keys.config_file}`, 'path_keys.config_file trong schema trỏ sai?');
|
|
133
|
+
} else {
|
|
134
|
+
const cfg = fs.readFileSync(cfgPath, 'utf8');
|
|
135
|
+
// key khai: dòng thụt 2 space dưới `paths:` dạng ` key: "value"` (bỏ comment)
|
|
136
|
+
const declared = new Set();
|
|
137
|
+
let inPaths = false;
|
|
138
|
+
for (const line of cfg.split(/\r?\n/)) {
|
|
139
|
+
if (/^paths:\s*$/.test(line)) { inPaths = true; continue; }
|
|
140
|
+
if (inPaths && /^\S/.test(line)) break; // sang section khác
|
|
141
|
+
const m = inPaths && line.match(/^ {2}([a-z_][a-z0-9_]*):/);
|
|
142
|
+
if (m) declared.add(m[1]);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// CHỈ quét commands/ + steps/ — đó là những file thực sự phân giải {paths.X} lúc chạy.
|
|
146
|
+
// templates/*.md là prose (vd templates/README.md giải thích các key ĐÃ BỎ) — tính nó
|
|
147
|
+
// vào "used" sẽ báo oan chính đoạn văn nói rằng key đó không còn nữa.
|
|
148
|
+
const runtimeText = sources
|
|
149
|
+
.filter(s => s.rel.startsWith('commands/') || s.rel.startsWith('steps/'))
|
|
150
|
+
.map(s => s.text).join('\n');
|
|
151
|
+
const used = new Set();
|
|
152
|
+
for (const m of runtimeText.matchAll(/\{paths\.([a-z_][a-z0-9_]*)\}/g)) used.add(m[1]);
|
|
153
|
+
// context-loader tham chiếu vài key không qua ngoặc nhọn (vd `paths.lessons_file`)
|
|
154
|
+
for (const m of runtimeText.matchAll(/\bpaths\.([a-z_][a-z0-9_]*)\b/g)) used.add(m[1]);
|
|
155
|
+
|
|
156
|
+
const undeclared = [...used].filter(k => !declared.has(k));
|
|
157
|
+
if (undeclared.length) {
|
|
158
|
+
err('R4', `${undeclared.length} key \`{paths.X}\` được dùng nhưng KHÔNG khai trong project-context.yaml`,
|
|
159
|
+
undeclared.join(', '));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const ignore = new Set(schema.path_keys.ignore_unused || []);
|
|
163
|
+
const dead = [...declared].filter(k => !used.has(k) && !ignore.has(k));
|
|
164
|
+
if (dead.length) {
|
|
165
|
+
warn('R4', `${dead.length} key khai trong project-context.yaml mà KHÔNG lệnh nào dùng — key chết?`,
|
|
166
|
+
`${dead.join(', ')}\n (Đây là hình dạng của G17. Nếu cố ý giữ, thêm vào path_keys.ignore_unused.)`);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── R5 — pattern bị cấm ───────────────────────────────────────────────────────
|
|
171
|
+
for (const fp of schema.forbidden_patterns) {
|
|
172
|
+
// `except` = file được phép chứa pattern vì nó đang TRÍCH DẪN pattern trong một cảnh
|
|
173
|
+
// báo ("đừng glob X"). Mỗi entry phải kèm lý do trong schema — không phải ignore mù.
|
|
174
|
+
const except = new Set(Object.keys(fp.except || {}));
|
|
175
|
+
const hits = sources.filter(s => s.text.includes(fp.pattern) && !except.has(s.rel));
|
|
176
|
+
if (hits.length) {
|
|
177
|
+
err('R5', `Pattern bị cấm quay lại: \`${fp.pattern}\` (${fp.gap})`,
|
|
178
|
+
`${fp.reason}\n Thấy ở: ${hits.map(h => h.rel).join(', ')}`);
|
|
179
|
+
}
|
|
180
|
+
// Cảnh báo nếu một except trở nên vô nghĩa — cảnh báo đã bị xoá thì except cũng nên bỏ
|
|
181
|
+
for (const f of except) {
|
|
182
|
+
const src = sources.find(s => s.rel === f);
|
|
183
|
+
if (!src || !src.text.includes(fp.pattern)) {
|
|
184
|
+
warn('R5', `\`${fp.pattern}\`: except \`${f}\` không còn chứa pattern — bỏ except này khỏi schema`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── R6 — giá trị enum chết ────────────────────────────────────────────────────
|
|
190
|
+
// GIỚI HẠN ĐÃ BIẾT của R6: nó bắt "giá trị khai mà KHÔNG xuất hiện ở đâu" — tức là ca
|
|
191
|
+
// một giá trị bị đổi tên/xoá khỏi toàn bộ codebase. Nó KHÔNG bắt được typo ở MỘT chỗ
|
|
192
|
+
// khi giá trị đúng vẫn còn ở chỗ khác (vd `fe_phase: integration` lọt vào một lệnh trong
|
|
193
|
+
// khi các lệnh còn lại vẫn ghi `integrated`). Cho lớp đó, dùng forbidden_patterns — chính
|
|
194
|
+
// xác hơn và không có false positive.
|
|
195
|
+
// Khớp theo ranh giới token: `fe_integrated` KHÔNG được coi là đã dùng `integrated`.
|
|
196
|
+
for (const [vocab, def] of Object.entries(schema.vocabularies)) {
|
|
197
|
+
if (vocab.startsWith('$')) continue;
|
|
198
|
+
const unused = (def.values || []).filter(v => !mentions(allText, v));
|
|
199
|
+
if (unused.length) {
|
|
200
|
+
warn('R6', `vocabulary \`${vocab}\`: ${unused.length} giá trị không xuất hiện ở đâu — sai chính tả hay giá trị chết?`,
|
|
201
|
+
`${unused.join(', ')}\n (G19 là ca này: doc ghi fe_phase "integration" còn lệnh ghi "integrated".)`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── report ────────────────────────────────────────────────────────────────────
|
|
206
|
+
const tagCount = schema.tags.length;
|
|
207
|
+
const colCount = schema.tsv_columns.length;
|
|
208
|
+
console.log('');
|
|
209
|
+
console.log('Self-check (bin/trace-schema.json) ...');
|
|
210
|
+
console.log('');
|
|
211
|
+
|
|
212
|
+
const show = (list, icon) => {
|
|
213
|
+
for (const e of list) {
|
|
214
|
+
console.log(` ${icon} [${e.rule}] ${e.msg}`);
|
|
215
|
+
if (e.hint) console.log(` ${e.hint}`);
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
show(errors, '❌');
|
|
219
|
+
show(warns, '⚠️ ');
|
|
220
|
+
|
|
221
|
+
if (!errors.length && !warns.length) {
|
|
222
|
+
console.log(` ✅ ${tagCount} tag + ${colCount} cột TSV + ${Object.keys(schema.vocabularies).length - 1} vocabulary — contract khớp mọi lệnh`);
|
|
223
|
+
}
|
|
224
|
+
console.log('');
|
|
225
|
+
|
|
226
|
+
if (errors.length && !WARN_ONLY) {
|
|
227
|
+
console.log(`❌ Self-check FAIL: ${errors.length} error, ${warns.length} warning.`);
|
|
228
|
+
console.log(' Sửa contract ở bin/trace-schema.json TRƯỚC, rồi sửa lệnh cho khớp.');
|
|
229
|
+
console.log(' (Bỏ qua tạm: node bin/self-check.js --warn-only)');
|
|
230
|
+
console.log('');
|
|
231
|
+
process.exit(1);
|
|
232
|
+
}
|
|
233
|
+
if (errors.length) {
|
|
234
|
+
console.log(`⚠️ ${errors.length} error bị bỏ qua do --warn-only.`);
|
|
235
|
+
console.log('');
|
|
236
|
+
}
|