@shanyucoder/flowgrid 0.1.8 → 0.1.10
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/flowgrid.mjs +43 -9
- package/bin/lib/audit-run.mjs +5 -1
- package/bin/lib/docs-hub-locale.mjs +9 -0
- package/bin/lib/init-scaffold.mjs +30 -2
- package/dist/docs/mcp/tools.js +4 -4
- package/dist/docs/mcp/tools.js.map +1 -1
- package/dist/docs/scan/ids.d.ts +1 -1
- package/dist/docs/scan/ids.js +6 -6
- package/dist/docs/scan/ids.js.map +1 -1
- package/dist/docs/scan/route.js +2 -2
- package/dist/docs/scan/route.js.map +1 -1
- package/engines/cases/render-cases.mjs +33 -25
- package/engines/docs/lib/audit-hub-prd.mjs +136 -0
- package/engines/docs/lib/audit-risks-catalog.mjs +142 -0
- package/engines/docs/lib/docs-hub-locale.mjs +100 -0
- package/engines/docs/lib/qa-item.mjs +91 -0
- package/engines/docs/lib/render-bundle-markdown.mjs +8 -1
- package/engines/docs/lib/render-qa-list.mjs +123 -25
- package/engines/docs/vitepress/config.ts +4 -4
- package/engines/spec/lib/audit-bundle-gaps.mjs +38 -7
- package/engines/spec/lib/audit-flow-gaps.mjs +2 -2
- package/engines/spec/lib/bundle-schema.mjs +3 -1
- package/engines/spec/lib/open-qa.mjs +71 -21
- package/engines/testcase/runners/lib/resolve-hub-id.mjs +3 -3
- package/harness/common/skills/legacy/SKILL.md +2 -2
- package/harness/docs/extracts/agent-execution-protocol.md +2 -2
- package/harness/docs/extracts/api-codegen-readiness.md +1 -1
- package/harness/docs/extracts/api-spec-sync.md +1 -1
- package/harness/docs/extracts/call-external.md +1 -1
- package/harness/docs/extracts/common-scope.md +8 -8
- package/harness/docs/extracts/design-leaf-signoff.md +2 -2
- package/harness/docs/extracts/extract-registry.docs.json +2 -1
- package/harness/docs/extracts/qa-inbox.md +19 -10
- package/harness/docs/extracts/qa-team.md +32 -0
- package/harness/docs/extracts/spec-core.md +1 -1
- package/harness/docs/extracts/spec-evolution.md +1 -1
- package/harness/docs/extracts/spec-prd-lite.md +11 -13
- package/harness/docs/extracts/tpl-module.md +9 -40
- package/harness/docs/extracts/tpl-overview-prd.md +11 -0
- package/harness/docs/extracts/tpl-risk-register.md +28 -0
- package/harness/docs/extracts/tpl-surface-prd.md +7 -0
- package/harness/docs/rules/docs-hub.mdc +1 -1
- package/harness/docs/rules/flowgrid-process.mdc +1 -1
- package/harness/docs/schemas/flowgrid-docs/qa-item.schema.json +65 -0
- package/harness/docs/skills/adopt/SKILL.md +1 -1
- package/harness/docs/skills/api-spec/SKILL.md +1 -1
- package/harness/docs/skills/api-update/SKILL.md +1 -1
- package/harness/docs/skills/background-logic/SKILL.md +1 -1
- package/harness/docs/skills/common-spec/SKILL.md +1 -1
- package/harness/docs/skills/cross-service/SKILL.md +1 -1
- package/harness/docs/skills/db-erd/SKILL.md +1 -1
- package/harness/docs/skills/grill/SKILL.md +2 -0
- package/harness/docs/skills/grill-bqa/SKILL.md +2 -2
- package/harness/docs/skills/grill-dev/SKILL.md +1 -1
- package/harness/docs/skills/grill-docs/SKILL.md +2 -2
- package/harness/docs/skills/grill-hub-prd/SKILL.md +38 -0
- package/harness/docs/skills/module/SKILL.md +8 -5
- package/harness/docs/skills/overview/SKILL.md +7 -6
- package/harness/docs/skills/qa-resolve/SKILL.md +13 -12
- package/harness/docs/skills/qa-review/SKILL.md +45 -0
- package/harness/docs/skills/risk-register/SKILL.md +29 -0
- package/harness/docs/skills/spec/SKILL.md +4 -4
- package/harness/docs/skills/surfaces/SKILL.md +3 -1
- package/harness/docs/skills/update-spec/SKILL.md +2 -2
- package/harness/docs/skills/{business-process → user-flow}/SKILL.md +8 -8
- package/harness/fe/extracts/wire-audit-loop.md +1 -1
- package/harness/fe/skills/gen-common/SKILL.md +1 -1
- package/harness/fe/skills/grill-wire/SKILL.md +1 -1
- package/harness/fe/skills/wire/SKILL.md +1 -1
- package/harness/tests/extracts/grill-scenario-flow.md +2 -2
- package/harness/tests/skills/grill-testcase/SKILL.md +1 -1
- package/harness/tests/skills/scenario/SKILL.md +11 -11
- package/harness/tests/skills/testcase/SKILL.md +1 -1
- package/harness/tests/templates/SC.example.md +16 -16
- package/lexicon/registry-tags.en.txt +2 -2
- package/package.json +1 -1
- package/templates/project-skeleton/architecture/03-business-process/index.md +3 -0
- package/templates/project-skeleton/architecture/{03-business-process → 03-user-flows}/FLOW-login.md +6 -6
- package/templates/project-skeleton/architecture/{03-business-process → 03-user-flows}/FLOW-template.md +6 -7
- package/templates/project-skeleton/architecture/11-risks/index.md +7 -17
- package/templates/project-skeleton/architecture/11-risks/risk-register.md +34 -0
- package/templates/project-skeleton/architecture/12-glossary/index.md +2 -1
- package/templates/project-skeleton/overview/index.md +21 -43
- package/templates/project-skeleton/overview/operational-areas/_template.md +15 -22
- package/templates/project-skeleton/qa/README.md +4 -8
- package/templates/project-skeleton/surfaces/_module-index.template.md +40 -0
- package/templates/project-skeleton/surfaces/_surface-index.template.md +43 -0
- package/templates/schemas/qa-item.schema.json +98 -0
- package/templates/shared/bundle-authoring.md +10 -6
- package/templates/shared/default-layout.ejs +108 -62
- package/templates/shared/feature.bundle.yaml +15 -6
- package/templates/shared/ir/generated/spec.md +24 -24
- package/templates/shared/ir-spec.yaml +1 -1
- package/templates/shared/qa-authoring.md +78 -0
- package/templates/shared/qa-item.yaml +35 -14
- package/templates/shared/tpl-api-contract.md +6 -6
- package/templates/tests-skeleton/catalog/locale.yaml +16 -15
- package/templates/tests-skeleton/tpl-testcase-plan.md +6 -6
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* PRD hub Markdown audit — section titles must be English (structureLocale: en).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
|
|
10
|
+
const PLACEHOLDER_RE = /\[[^\]]{4,}\]/;
|
|
11
|
+
|
|
12
|
+
function hubKind(filePath) {
|
|
13
|
+
const norm = filePath.split(path.sep).join('/');
|
|
14
|
+
if (norm.endsWith('overview/index.md')) return 'overview';
|
|
15
|
+
if (norm.endsWith('_surface-index.template.md')) return 'surface';
|
|
16
|
+
if (norm.endsWith('_module-index.template.md')) return 'module';
|
|
17
|
+
if (/\/surfaces\/[^/]+\/index\.md$/.test(norm) && !norm.includes('/CMP-')) return 'surface';
|
|
18
|
+
if (/\/CMP-[^/]+\/index\.md$/.test(norm)) return 'module';
|
|
19
|
+
if (norm.includes('/operational-areas/') && norm.endsWith('.md')) return 'operational';
|
|
20
|
+
return 'unknown';
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function hasEnglishGoals(text) {
|
|
24
|
+
return /##\s+Goals\b/i.test(text);
|
|
25
|
+
}
|
|
26
|
+
function hasEnglishBackground(text) {
|
|
27
|
+
return /##\s+Background\b/i.test(text);
|
|
28
|
+
}
|
|
29
|
+
function hasEnglishScope(text) {
|
|
30
|
+
return /##\s+Scope\b/i.test(text);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function auditHubPrdContent(rawText, filePath) {
|
|
34
|
+
const gaps = [];
|
|
35
|
+
const warnings = [];
|
|
36
|
+
const kind = hubKind(filePath);
|
|
37
|
+
|
|
38
|
+
function addGap(code, fieldPath, message, suggestedFix) {
|
|
39
|
+
gaps.push({ code, severity: 'critical', path: fieldPath, message, suggestedFix });
|
|
40
|
+
}
|
|
41
|
+
function addWarning(code, fieldPath, message, suggestedFix) {
|
|
42
|
+
warnings.push({ code, path: fieldPath, message, suggestedFix });
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const needsGoals = kind === 'overview' || kind === 'surface' || kind === 'module';
|
|
46
|
+
const needsBackground = kind === 'overview' || kind === 'surface';
|
|
47
|
+
const needsScope = kind !== 'unknown';
|
|
48
|
+
|
|
49
|
+
if (needsGoals && !hasEnglishGoals(rawText)) {
|
|
50
|
+
addGap(
|
|
51
|
+
'HUB_MISSING_GOALS',
|
|
52
|
+
'section.goals',
|
|
53
|
+
'Missing `## Goals` (English section key).',
|
|
54
|
+
'Add ## Goals {#goals} with business bullets (prose in contentLocale).'
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (needsBackground && !hasEnglishBackground(rawText)) {
|
|
59
|
+
addGap(
|
|
60
|
+
'HUB_MISSING_BACKGROUND',
|
|
61
|
+
'section.background',
|
|
62
|
+
'Missing `## Background`.',
|
|
63
|
+
'Add ## Background {#background}.'
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (needsScope && !hasEnglishScope(rawText)) {
|
|
68
|
+
addGap(
|
|
69
|
+
'HUB_MISSING_SCOPE',
|
|
70
|
+
'section.scope',
|
|
71
|
+
'Missing `## Scope`.',
|
|
72
|
+
'Add ## Scope {#scope} with In scope / Out of scope bullets.'
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (/##\s+Mục tiêu/i.test(rawText) || /##\s+Bối cảnh/i.test(rawText) || /##\s+Phạm vi/i.test(rawText)) {
|
|
77
|
+
addWarning(
|
|
78
|
+
'HUB_VI_SECTION_TITLE',
|
|
79
|
+
'section.locale',
|
|
80
|
+
'Vietnamese section titles detected — use English keys (Goals, Background, Scope).',
|
|
81
|
+
'Rename headings to English; keep Vietnamese in body only.'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (kind === 'module' && !/Features overview|Goals/i.test(rawText)) {
|
|
86
|
+
addWarning(
|
|
87
|
+
'HUB_MODULE_FEATURES_THIN',
|
|
88
|
+
'section.features',
|
|
89
|
+
'Module index missing Features overview / Goals.',
|
|
90
|
+
'Add ## Features overview and link function folders.'
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (PLACEHOLDER_RE.test(rawText)) {
|
|
95
|
+
addWarning(
|
|
96
|
+
'HUB_PLACEHOLDER',
|
|
97
|
+
'body',
|
|
98
|
+
'Template placeholders `[...]` remain.',
|
|
99
|
+
'Replace with real copy in contentLocale before sign-off.'
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
if (/##\s+Personas/i.test(rawText) || /Success metrics/i.test(rawText)) {
|
|
104
|
+
addWarning(
|
|
105
|
+
'HUB_DEPRECATED_SECTION',
|
|
106
|
+
'body',
|
|
107
|
+
'Personas or Success metrics sections — not SSOT on hub.',
|
|
108
|
+
'Remove or manage outside hub.'
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
target: filePath,
|
|
114
|
+
hubKind: kind,
|
|
115
|
+
structureLocale: 'en',
|
|
116
|
+
totalGaps: gaps.length,
|
|
117
|
+
totalWarnings: warnings.length,
|
|
118
|
+
gaps,
|
|
119
|
+
warnings,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const args = process.argv.slice(2);
|
|
124
|
+
if (args.length === 0) {
|
|
125
|
+
console.log('Usage: node engines/docs/lib/audit-hub-prd.mjs <path-to-hub.md>');
|
|
126
|
+
process.exit(0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const targetFile = path.resolve(process.cwd(), args[0]);
|
|
130
|
+
if (!fs.existsSync(targetFile)) {
|
|
131
|
+
console.error(JSON.stringify({ error: `File not found: ${targetFile}` }));
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const rawText = fs.readFileSync(targetFile, 'utf8');
|
|
136
|
+
console.log(JSON.stringify(auditHubPrdContent(rawText, targetFile), null, 2));
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Audit risk SSOT: architecture/11-risks/risk-register.md (member register)
|
|
5
|
+
* or legacy short catalog on index.md.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
|
|
11
|
+
function isRiskRegister(filePath) {
|
|
12
|
+
return path.basename(filePath) === 'risk-register.md';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function auditRisksCatalogContent(rawText, filePath) {
|
|
16
|
+
const gaps = [];
|
|
17
|
+
const warnings = [];
|
|
18
|
+
const register = isRiskRegister(filePath);
|
|
19
|
+
|
|
20
|
+
function addGap(code, fieldPath, message, suggestedFix) {
|
|
21
|
+
gaps.push({ code, severity: 'warning', path: fieldPath, message, suggestedFix });
|
|
22
|
+
}
|
|
23
|
+
function addWarning(code, fieldPath, message, suggestedFix) {
|
|
24
|
+
warnings.push({ code, path: fieldPath, message, suggestedFix });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const hasRiskCol = /\|\s*Rủi ro\s*\|/i.test(rawText) || /\|\s*Mô tả\s*\|/i.test(rawText);
|
|
28
|
+
const hasTable =
|
|
29
|
+
/\|\s*Risk\s*\|/i.test(rawText) ||
|
|
30
|
+
/\|\s*ID\s*\|/i.test(rawText) ||
|
|
31
|
+
hasRiskCol;
|
|
32
|
+
|
|
33
|
+
if (!hasTable) {
|
|
34
|
+
addGap(
|
|
35
|
+
'RISK_CATALOG_NO_TABLE',
|
|
36
|
+
'risks.table',
|
|
37
|
+
register
|
|
38
|
+
? 'Chưa có bảng sổ rủi ro (risk register).'
|
|
39
|
+
: 'Chưa có bảng rủi ro (cột Risk/Rủi ro + Phương án).',
|
|
40
|
+
register
|
|
41
|
+
? 'Dùng template risk-register.md: cột Hạn mức, Nhu cầu, Chênh lệch.'
|
|
42
|
+
: 'Thêm bảng markdown hoặc link sang risk-register.md.'
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (register) {
|
|
47
|
+
const needCols = [
|
|
48
|
+
{ re: /\bLimit\b/i, code: 'RISK_REG_MISSING_LIMIT_COL', label: 'Limit' },
|
|
49
|
+
{ re: /\bNeed\b/i, code: 'RISK_REG_MISSING_NEED_COL', label: 'Need' },
|
|
50
|
+
{ re: /\bGap\b/i, code: 'RISK_REG_MISSING_GAP_COL', label: 'Gap' },
|
|
51
|
+
{ re: /Mitigation/i, code: 'RISK_REG_MISSING_MITIGATION_COL', label: 'Mitigation' },
|
|
52
|
+
];
|
|
53
|
+
for (const col of needCols) {
|
|
54
|
+
if (!col.re.test(rawText)) {
|
|
55
|
+
addGap(
|
|
56
|
+
col.code,
|
|
57
|
+
'riskRegister.columns',
|
|
58
|
+
`Thiếu cột «${col.label}» trong bảng risk register.`,
|
|
59
|
+
`Thêm cột ${col.label} để so sánh quota/hạn mức với nhu cầu peak.`
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const hasRiskId = /RISK-[A-Z0-9_-]+/i.test(rawText);
|
|
65
|
+
if (!hasRiskId) {
|
|
66
|
+
addWarning(
|
|
67
|
+
'RISK_REG_NO_ID',
|
|
68
|
+
'riskRegister.ids',
|
|
69
|
+
'Chưa có mã RISK-* trong sổ (chỉ placeholder mẫu?).',
|
|
70
|
+
'Thêm ít nhất một dòng rủi ro thật với mã RISK-….'
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const rows = rawText.match(/^\|[^|]+\|[^|]+\|[^|]+\|/gm) || [];
|
|
75
|
+
const dataRows = rows.filter(
|
|
76
|
+
(r) => !r.includes('---') && !/^\|\s*[-:]+\s*\|/.test(r) && !/^\|\s*Mã\s*\|/i.test(r)
|
|
77
|
+
);
|
|
78
|
+
const realRows = dataRows.filter((r) => /RISK-/i.test(r) && !/RISK-EXAMPLE/i.test(r));
|
|
79
|
+
if (hasTable && realRows.length < 1) {
|
|
80
|
+
addWarning(
|
|
81
|
+
'RISK_REG_EMPTY',
|
|
82
|
+
'riskRegister.rows',
|
|
83
|
+
'Sổ rủi ro chưa có dòng nghiệp vụ (ngoài ví dụ RISK-EXAMPLE).',
|
|
84
|
+
'Thêm rủi ro thật — vd. quota email, rate limit API, license.'
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
} else {
|
|
88
|
+
const rows = rawText.match(/^\|[^|]+\|[^|]+\|/gm) || [];
|
|
89
|
+
const dataRows = rows.filter((r) => !r.includes('---') && !/^\|\s*[-:]+\s*\|/.test(r));
|
|
90
|
+
if (hasTable && dataRows.length < 2) {
|
|
91
|
+
addWarning(
|
|
92
|
+
'RISK_CATALOG_EMPTY',
|
|
93
|
+
'risks.rows',
|
|
94
|
+
'Bảng rủi ro chưa có dòng nghiệp vụ (chỉ header).',
|
|
95
|
+
'Ưu tiên điền architecture/11-risks/risk-register.md.'
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (!/risk-register\.md/i.test(rawText)) {
|
|
99
|
+
addWarning(
|
|
100
|
+
'RISK_INDEX_NO_REGISTER_LINK',
|
|
101
|
+
'risks.index',
|
|
102
|
+
'index.md chưa trỏ sang risk-register.md.',
|
|
103
|
+
'Link [risk-register.md](./risk-register.md) là SSOT member.'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (/\[(?:\.\.\.|TBD|Tên|VD:)/i.test(rawText)) {
|
|
109
|
+
addWarning(
|
|
110
|
+
'RISK_PLACEHOLDER',
|
|
111
|
+
'body',
|
|
112
|
+
'Còn placeholder template trong file rủi ro.',
|
|
113
|
+
'Điền rủi ro thật hoặc defer qa/.'
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
target: filePath,
|
|
119
|
+
documentKind: register ? 'risk-register' : 'risks-index',
|
|
120
|
+
totalGaps: gaps.length,
|
|
121
|
+
totalWarnings: warnings.length,
|
|
122
|
+
gaps,
|
|
123
|
+
warnings,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const args = process.argv.slice(2);
|
|
128
|
+
if (args.length === 0) {
|
|
129
|
+
console.log(
|
|
130
|
+
'Usage: node engines/docs/lib/audit-risks-catalog.mjs <architecture/11-risks/risk-register.md|index.md>'
|
|
131
|
+
);
|
|
132
|
+
process.exit(0);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const targetFile = path.resolve(process.cwd(), args[0]);
|
|
136
|
+
if (!fs.existsSync(targetFile)) {
|
|
137
|
+
console.error(JSON.stringify({ error: `File not found: ${targetFile}` }));
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const rawText = fs.readFileSync(targetFile, 'utf8');
|
|
142
|
+
console.log(JSON.stringify(auditRisksCatalogContent(rawText, targetFile), null, 2));
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { parse } from 'yaml';
|
|
4
|
+
|
|
5
|
+
/** Headings / YAML keys / audit anchors — always English. */
|
|
6
|
+
export const STRUCTURE_LOCALE = 'en';
|
|
7
|
+
|
|
8
|
+
/** @param {string} [code] @param {string} [fallback] */
|
|
9
|
+
export function normalizeContentLocale(code, fallback = 'vi') {
|
|
10
|
+
const s = String(code ?? '').trim();
|
|
11
|
+
if (!s) return fallback;
|
|
12
|
+
return s.replace(/\s+/g, '-').toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @param {{ text: Function, isCancel: Function, cancel: Function }} clack
|
|
17
|
+
* @returns {Promise<string>}
|
|
18
|
+
*/
|
|
19
|
+
export async function promptDocsContentLocale(clack) {
|
|
20
|
+
const { text, isCancel, cancel } = clack;
|
|
21
|
+
const ans = await text({
|
|
22
|
+
message:
|
|
23
|
+
'Member prose locale for document spec + test documents (BCP-47 tag, e.g. vi, en, ja — keys/headings stay English):',
|
|
24
|
+
defaultValue: 'vi',
|
|
25
|
+
placeholder: 'vi',
|
|
26
|
+
});
|
|
27
|
+
if (isCancel(ans)) {
|
|
28
|
+
cancel('Cancelled.');
|
|
29
|
+
process.exit(0);
|
|
30
|
+
}
|
|
31
|
+
return normalizeContentLocale(ans, 'vi');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} docsRootAbs
|
|
36
|
+
* @param {{ structureLocale: string, contentLocale: string }} locales
|
|
37
|
+
*/
|
|
38
|
+
export function writeDocsHubLocaleManifest(docsRootAbs, locales) {
|
|
39
|
+
const file = path.join(docsRootAbs, 'docs-hub.locale.yaml');
|
|
40
|
+
const body = `# SSOT — structureLocale is always en (headings/keys). Prose follows contentLocale.\nstructureLocale: ${locales.structureLocale}\ncontentLocale: ${locales.contentLocale}\n`;
|
|
41
|
+
fs.writeFileSync(file, body, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** English section labels for tests-docs render (never translated per locale). */
|
|
45
|
+
export const TESTS_HUB_HEADINGS_EN = {
|
|
46
|
+
home: 'Home',
|
|
47
|
+
cases: 'Test cases',
|
|
48
|
+
scenarios: 'Scenarios',
|
|
49
|
+
plans: 'Test plans',
|
|
50
|
+
preconditions: 'Preconditions',
|
|
51
|
+
steps: 'Steps',
|
|
52
|
+
expected: 'Expected results',
|
|
53
|
+
traceability: 'Traceability (docs SSOT)',
|
|
54
|
+
testMatrix: 'Test matrix (equivalence)',
|
|
55
|
+
crossRefDocs: 'Docs hub cross-reference',
|
|
56
|
+
technical: 'Technical details',
|
|
57
|
+
testData: 'Test data',
|
|
58
|
+
coverage: 'Coverage',
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* @param {string} testsRootAbs
|
|
63
|
+
* @param {{ structureLocale: string, contentLocale: string }} locales
|
|
64
|
+
*/
|
|
65
|
+
export function writeTestsHubLocaleCatalog(testsRootAbs, locales) {
|
|
66
|
+
const catalogDir = path.join(testsRootAbs, 'catalog');
|
|
67
|
+
if (!fs.existsSync(catalogDir)) {
|
|
68
|
+
fs.mkdirSync(catalogDir, { recursive: true });
|
|
69
|
+
}
|
|
70
|
+
const file = path.join(catalogDir, 'locale.yaml');
|
|
71
|
+
const lines = [
|
|
72
|
+
'# SSOT — headings are English; TC prose in YAML follows contentLocale.',
|
|
73
|
+
`structureLocale: ${locales.structureLocale}`,
|
|
74
|
+
`contentLocale: ${locales.contentLocale}`,
|
|
75
|
+
'headings:',
|
|
76
|
+
...Object.entries(TESTS_HUB_HEADINGS_EN).map(([k, v]) => ` ${k}: ${v}`),
|
|
77
|
+
];
|
|
78
|
+
fs.writeFileSync(file, `${lines.join('\n')}\n`, 'utf8');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @param {string} projectRoot
|
|
83
|
+
* @param {string} [docsRootRel]
|
|
84
|
+
*/
|
|
85
|
+
export function loadDocsHubLocale(projectRoot, docsRootRel) {
|
|
86
|
+
const rel = docsRootRel || process.env.FLOWGRID_DOCS_ROOT || 'docs';
|
|
87
|
+
const manifest = path.join(path.resolve(projectRoot), rel, 'docs-hub.locale.yaml');
|
|
88
|
+
if (!fs.existsSync(manifest)) {
|
|
89
|
+
return { structureLocale: STRUCTURE_LOCALE, contentLocale: 'vi' };
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const data = parse(fs.readFileSync(manifest, 'utf8')) ?? {};
|
|
93
|
+
return {
|
|
94
|
+
structureLocale: data.structureLocale || STRUCTURE_LOCALE,
|
|
95
|
+
contentLocale: normalizeContentLocale(data.contentLocale, 'vi'),
|
|
96
|
+
};
|
|
97
|
+
} catch {
|
|
98
|
+
return { structureLocale: STRUCTURE_LOCALE, contentLocale: 'vi' };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { fileURLToPath } from 'node:url'
|
|
4
|
+
import Ajv2020 from 'ajv/dist/2020.js'
|
|
5
|
+
import { parse } from 'yaml'
|
|
6
|
+
|
|
7
|
+
export const QA_ITEM_SCHEMA_ID = 'flowgrid-qa-item/v1'
|
|
8
|
+
|
|
9
|
+
const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..')
|
|
10
|
+
|
|
11
|
+
let compiledValidator = null
|
|
12
|
+
|
|
13
|
+
function loadSchema() {
|
|
14
|
+
const schemaPath = path.join(packageRoot, 'templates/schemas/qa-item.schema.json')
|
|
15
|
+
return JSON.parse(readFileSync(schemaPath, 'utf8'))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Date} [date]
|
|
20
|
+
* @returns {string} YYYYMMDD HH:mm
|
|
21
|
+
*/
|
|
22
|
+
export function formatQaAt(date = new Date()) {
|
|
23
|
+
const d = date
|
|
24
|
+
const y = d.getFullYear()
|
|
25
|
+
const m = String(d.getMonth() + 1).padStart(2, '0')
|
|
26
|
+
const day = String(d.getDate()).padStart(2, '0')
|
|
27
|
+
const h = String(d.getHours()).padStart(2, '0')
|
|
28
|
+
const min = String(d.getMinutes()).padStart(2, '0')
|
|
29
|
+
return `${y}${m}${day} ${h}:${min}`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* @param {string} screen e.g. W-HOTEL-LIST
|
|
34
|
+
*/
|
|
35
|
+
export function suggestQaShort(screen) {
|
|
36
|
+
const s = String(screen ?? '').trim()
|
|
37
|
+
if (!s) return 'QA'
|
|
38
|
+
if (/^W-/i.test(s)) return s.slice(2).toUpperCase()
|
|
39
|
+
return s.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-|-$/g, '').toUpperCase()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {string} hubRoot
|
|
44
|
+
* @param {string} shortSlug e.g. HOTEL-LIST
|
|
45
|
+
* @returns {string} four-digit sequence
|
|
46
|
+
*/
|
|
47
|
+
export function nextQaSequence(hubRoot, shortSlug) {
|
|
48
|
+
const qaDir = path.join(path.resolve(hubRoot), 'qa')
|
|
49
|
+
const prefix = `${shortSlug}_`
|
|
50
|
+
let max = 0
|
|
51
|
+
const scan = (dir) => {
|
|
52
|
+
if (!existsSync(dir)) return
|
|
53
|
+
for (const name of readdirSync(dir)) {
|
|
54
|
+
if (!name.toLowerCase().startsWith(prefix.toLowerCase())) continue
|
|
55
|
+
const m = name.match(/_(\d{4})\.ya?ml$/i)
|
|
56
|
+
if (m) max = Math.max(max, Number.parseInt(m[1], 10))
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
scan(qaDir)
|
|
60
|
+
scan(path.join(qaDir, 'open'))
|
|
61
|
+
return String(max + 1).padStart(4, '0')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* @param {string} hubRoot
|
|
66
|
+
* @param {string} shortSlug
|
|
67
|
+
*/
|
|
68
|
+
export function nextQaId(hubRoot, shortSlug) {
|
|
69
|
+
return `${shortSlug}_${nextQaSequence(hubRoot, shortSlug)}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @param {unknown} doc parsed YAML object
|
|
74
|
+
* @returns {{ ok: true } | { ok: false, errors: import('ajv').ErrorObject[] }}
|
|
75
|
+
*/
|
|
76
|
+
export function validateQaItem(doc) {
|
|
77
|
+
if (!compiledValidator) {
|
|
78
|
+
compiledValidator = new Ajv2020({ allErrors: true }).compile(loadSchema())
|
|
79
|
+
}
|
|
80
|
+
const ok = compiledValidator(doc)
|
|
81
|
+
if (ok) return { ok: true }
|
|
82
|
+
return { ok: false, errors: compiledValidator.errors ?? [] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* @param {string} filePath
|
|
87
|
+
*/
|
|
88
|
+
export function validateQaItemFile(filePath) {
|
|
89
|
+
const doc = parse(readFileSync(filePath, 'utf8'))
|
|
90
|
+
return validateQaItem(doc)
|
|
91
|
+
}
|
|
@@ -6,6 +6,7 @@ import { renderSpecMarkdown } from './render-spec-markdown.mjs'
|
|
|
6
6
|
import { renderDataModelMarkdown } from './render-data-model-markdown.mjs'
|
|
7
7
|
import { renderApiSummaryMarkdown } from './render-api-summary-markdown.mjs'
|
|
8
8
|
import { resolveBackendSpecForBundle } from '../../spec/lib/audit-db-tables.mjs'
|
|
9
|
+
import { loadDocsHubLocale } from './docs-hub-locale.mjs'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Flatten bundle → spec shape (tests / fallback). Prefer ir/spec.yaml for site MD.
|
|
@@ -17,7 +18,11 @@ export function bundleToSpecShape(bundle) {
|
|
|
17
18
|
status: bundle.status,
|
|
18
19
|
owner: bundle.owner,
|
|
19
20
|
summary: bundle.summary ?? bundle.review?.summary,
|
|
20
|
-
|
|
21
|
+
scopeIn: bundle.scopeIn,
|
|
22
|
+
nonGoals: bundle.nonGoals,
|
|
23
|
+
userFlows: bundle.userFlows,
|
|
24
|
+
nfr: bundle.nfr,
|
|
25
|
+
userStories: bundle.userStories ?? bundle.spec?.userStories,
|
|
21
26
|
}
|
|
22
27
|
return {
|
|
23
28
|
...meta,
|
|
@@ -158,11 +163,13 @@ export async function writeIrSpecMarkdownFile(specFile, mdOut, context = {}) {
|
|
|
158
163
|
const design = existsSync(designFile) ? parse(await readFile(designFile, 'utf8')) ?? {} : {}
|
|
159
164
|
const specShape = mergeDesignIntoSpecForRender(spec, design)
|
|
160
165
|
const projectRoot = context.projectRoot ?? process.cwd()
|
|
166
|
+
const { contentLocale: docsContentLocale } = loadDocsHubLocale(projectRoot)
|
|
161
167
|
const markdown = renderIrSpecMarkdown(specShape, {
|
|
162
168
|
testcases: [],
|
|
163
169
|
output: { specFile: path.basename(mdOut), testcasesDir: 'testcases' },
|
|
164
170
|
devAppBaseUrl: context.devAppBaseUrl ?? '',
|
|
165
171
|
projectRoot,
|
|
172
|
+
docsContentLocale,
|
|
166
173
|
})
|
|
167
174
|
await mkdir(path.dirname(mdOut), { recursive: true })
|
|
168
175
|
await writeFile(mdOut, markdown, 'utf8')
|
|
@@ -4,10 +4,23 @@ import { parse } from 'yaml'
|
|
|
4
4
|
|
|
5
5
|
export const QA_LIST_FILE = 'qa/index.md'
|
|
6
6
|
|
|
7
|
-
function
|
|
7
|
+
function listQaYamlFiles(qaDir) {
|
|
8
|
+
if (!existsSync(qaDir)) return []
|
|
9
|
+
const out = []
|
|
10
|
+
for (const name of readdirSync(qaDir)) {
|
|
11
|
+
if (!/\.ya?ml$/i.test(name)) continue
|
|
12
|
+
if (name === 'index.yaml') continue
|
|
13
|
+
out.push(path.join(qaDir, name))
|
|
14
|
+
}
|
|
15
|
+
return out.sort()
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function listLegacyOpenYaml(qaDir) {
|
|
19
|
+
const openDir = path.join(qaDir, 'open')
|
|
8
20
|
if (!existsSync(openDir)) return []
|
|
9
21
|
return readdirSync(openDir)
|
|
10
|
-
.filter((n) =>
|
|
22
|
+
.filter((n) => /\.ya?ml$/i.test(n))
|
|
23
|
+
.map((n) => path.join(openDir, n))
|
|
11
24
|
.sort()
|
|
12
25
|
}
|
|
13
26
|
|
|
@@ -16,41 +29,126 @@ function oneLine(text) {
|
|
|
16
29
|
return String(text).trim().split(/\n/)[0].trim()
|
|
17
30
|
}
|
|
18
31
|
|
|
32
|
+
function escapeCell(s) {
|
|
33
|
+
return String(s ?? '')
|
|
34
|
+
.replace(/\|/g, '\\|')
|
|
35
|
+
.replace(/\n/g, ' ')
|
|
36
|
+
.trim()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lastUpdate(doc) {
|
|
40
|
+
const updates = Array.isArray(doc?.updates) ? doc.updates : []
|
|
41
|
+
if (updates.length) return updates[updates.length - 1]
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function summaryFromDoc(doc) {
|
|
46
|
+
const last = lastUpdate(doc)
|
|
47
|
+
if (last?.text) return oneLine(last.text)
|
|
48
|
+
if (doc?.question) return oneLine(doc.question)
|
|
49
|
+
if (doc?.resolution?.summary) return oneLine(doc.resolution.summary)
|
|
50
|
+
return oneLine(doc?.kind) || '—'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function lastAt(doc) {
|
|
54
|
+
const last = lastUpdate(doc)
|
|
55
|
+
if (last?.at) return String(last.at)
|
|
56
|
+
if (doc?.resolution?.decidedAt) return String(doc.resolution.decidedAt)
|
|
57
|
+
return '—'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isOpen(doc) {
|
|
61
|
+
if (doc?.status === 'closed') return false
|
|
62
|
+
if (doc?.status === 'open') return true
|
|
63
|
+
if (doc?.resolution) return false
|
|
64
|
+
return true
|
|
65
|
+
}
|
|
66
|
+
|
|
19
67
|
/**
|
|
20
|
-
*
|
|
68
|
+
* @param {string} full — absolute path to yaml
|
|
69
|
+
* @param {string} linkName — basename for markdown link
|
|
70
|
+
*/
|
|
71
|
+
function catalogRow(full, linkName) {
|
|
72
|
+
let id = linkName.replace(/\.ya?ml$/i, '')
|
|
73
|
+
let screen = ''
|
|
74
|
+
let kind = ''
|
|
75
|
+
let summary = ''
|
|
76
|
+
let lastAtStr = '—'
|
|
77
|
+
let state = 'open'
|
|
78
|
+
try {
|
|
79
|
+
const doc = parse(readFileSync(full, 'utf8')) ?? {}
|
|
80
|
+
if (doc.id) id = String(doc.id)
|
|
81
|
+
if (doc.screen) screen = String(doc.screen)
|
|
82
|
+
if (doc.kind) kind = String(doc.kind)
|
|
83
|
+
summary = summaryFromDoc(doc)
|
|
84
|
+
lastAtStr = lastAt(doc)
|
|
85
|
+
state = isOpen(doc) ? 'open' : 'closed'
|
|
86
|
+
} catch {
|
|
87
|
+
/* still list */
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const relLink = full.includes(`${path.sep}open${path.sep}`)
|
|
91
|
+
? `open/${linkName}`
|
|
92
|
+
: linkName
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
link: `[${id}](${relLink})`,
|
|
97
|
+
state,
|
|
98
|
+
screen: screen || '—',
|
|
99
|
+
lastAt: lastAtStr,
|
|
100
|
+
summary: summary || kind || '—',
|
|
101
|
+
sortKey: `${lastAtStr}\t${id}`,
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Always write qa/index.md — catalog table only (SSOT = per-id YAML files).
|
|
21
107
|
*/
|
|
22
108
|
export function writeQaList(root = process.cwd()) {
|
|
23
109
|
const hub = path.resolve(root)
|
|
24
110
|
const qaDir = path.join(hub, 'qa')
|
|
25
|
-
const openDir = path.join(qaDir, 'open')
|
|
26
111
|
mkdirSync(qaDir, { recursive: true })
|
|
27
112
|
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
113
|
+
const seen = new Set()
|
|
114
|
+
const rows = []
|
|
115
|
+
for (const full of [...listQaYamlFiles(qaDir), ...listLegacyOpenYaml(qaDir)]) {
|
|
116
|
+
const name = path.basename(full)
|
|
117
|
+
if (seen.has(name)) continue
|
|
118
|
+
seen.add(name)
|
|
119
|
+
rows.push(catalogRow(full, name))
|
|
120
|
+
}
|
|
121
|
+
rows.sort((a, b) => a.sortKey.localeCompare(b.sortKey))
|
|
122
|
+
|
|
123
|
+
const lines = [
|
|
124
|
+
'# QA — catalog',
|
|
125
|
+
'',
|
|
126
|
+
'_Sinh bởi `flowgrid render` — **chỉ trạng thái + link**. Nội dung SSOT: một file YAML / mã QA._',
|
|
127
|
+
'',
|
|
128
|
+
'Workflow: [qa-team.md](../workflows/qa-team.md) · `/qa-resolve` · `/qa-review`',
|
|
129
|
+
'',
|
|
130
|
+
'| Mã | Status | Screen | Cập nhật cuối | Tóm tắt |',
|
|
131
|
+
'| --- | --- | --- | --- | --- |',
|
|
132
|
+
]
|
|
133
|
+
|
|
134
|
+
if (!rows.length) {
|
|
135
|
+
lines.push('| _—_ | _empty_ | — | — | Không có QA file |', '')
|
|
32
136
|
} else {
|
|
33
|
-
for (const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
let question = ''
|
|
38
|
-
try {
|
|
39
|
-
const doc = parse(readFileSync(full, 'utf8')) ?? {}
|
|
40
|
-
if (doc.id) id = String(doc.id)
|
|
41
|
-
if (doc.kind) kind = String(doc.kind)
|
|
42
|
-
question = oneLine(doc.question)
|
|
43
|
-
} catch {
|
|
44
|
-
/* list the file anyway */
|
|
45
|
-
}
|
|
46
|
-
const bits = [`[${id}](open/${name})`]
|
|
47
|
-
if (kind) bits.push(kind)
|
|
48
|
-
if (question) bits.push(question)
|
|
49
|
-
lines.push(`- ${bits.join(' — ')}`)
|
|
137
|
+
for (const r of rows) {
|
|
138
|
+
lines.push(
|
|
139
|
+
`| ${r.link} | ${escapeCell(r.state)} | ${escapeCell(r.screen)} | ${escapeCell(r.lastAt)} | ${escapeCell(r.summary)} |`,
|
|
140
|
+
)
|
|
50
141
|
}
|
|
51
142
|
lines.push('')
|
|
52
143
|
}
|
|
53
144
|
|
|
145
|
+
lines.push(
|
|
146
|
+
'## Đọc timeline',
|
|
147
|
+
'',
|
|
148
|
+
'Mở file YAML — đọc `updates[]` từ trên xuống (append-only). Không copy full log vào index.',
|
|
149
|
+
'',
|
|
150
|
+
)
|
|
151
|
+
|
|
54
152
|
const out = path.join(hub, ...QA_LIST_FILE.split('/'))
|
|
55
153
|
writeFileSync(out, lines.join('\n'))
|
|
56
154
|
return out
|