@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.
Files changed (98) hide show
  1. package/bin/flowgrid.mjs +43 -9
  2. package/bin/lib/audit-run.mjs +5 -1
  3. package/bin/lib/docs-hub-locale.mjs +9 -0
  4. package/bin/lib/init-scaffold.mjs +30 -2
  5. package/dist/docs/mcp/tools.js +4 -4
  6. package/dist/docs/mcp/tools.js.map +1 -1
  7. package/dist/docs/scan/ids.d.ts +1 -1
  8. package/dist/docs/scan/ids.js +6 -6
  9. package/dist/docs/scan/ids.js.map +1 -1
  10. package/dist/docs/scan/route.js +2 -2
  11. package/dist/docs/scan/route.js.map +1 -1
  12. package/engines/cases/render-cases.mjs +33 -25
  13. package/engines/docs/lib/audit-hub-prd.mjs +136 -0
  14. package/engines/docs/lib/audit-risks-catalog.mjs +142 -0
  15. package/engines/docs/lib/docs-hub-locale.mjs +100 -0
  16. package/engines/docs/lib/qa-item.mjs +91 -0
  17. package/engines/docs/lib/render-bundle-markdown.mjs +8 -1
  18. package/engines/docs/lib/render-qa-list.mjs +123 -25
  19. package/engines/docs/vitepress/config.ts +4 -4
  20. package/engines/spec/lib/audit-bundle-gaps.mjs +38 -7
  21. package/engines/spec/lib/audit-flow-gaps.mjs +2 -2
  22. package/engines/spec/lib/bundle-schema.mjs +3 -1
  23. package/engines/spec/lib/open-qa.mjs +71 -21
  24. package/engines/testcase/runners/lib/resolve-hub-id.mjs +3 -3
  25. package/harness/common/skills/legacy/SKILL.md +2 -2
  26. package/harness/docs/extracts/agent-execution-protocol.md +2 -2
  27. package/harness/docs/extracts/api-codegen-readiness.md +1 -1
  28. package/harness/docs/extracts/api-spec-sync.md +1 -1
  29. package/harness/docs/extracts/call-external.md +1 -1
  30. package/harness/docs/extracts/common-scope.md +8 -8
  31. package/harness/docs/extracts/design-leaf-signoff.md +2 -2
  32. package/harness/docs/extracts/extract-registry.docs.json +2 -1
  33. package/harness/docs/extracts/qa-inbox.md +19 -10
  34. package/harness/docs/extracts/qa-team.md +32 -0
  35. package/harness/docs/extracts/spec-core.md +1 -1
  36. package/harness/docs/extracts/spec-evolution.md +1 -1
  37. package/harness/docs/extracts/spec-prd-lite.md +11 -13
  38. package/harness/docs/extracts/tpl-module.md +9 -40
  39. package/harness/docs/extracts/tpl-overview-prd.md +11 -0
  40. package/harness/docs/extracts/tpl-risk-register.md +28 -0
  41. package/harness/docs/extracts/tpl-surface-prd.md +7 -0
  42. package/harness/docs/rules/docs-hub.mdc +1 -1
  43. package/harness/docs/rules/flowgrid-process.mdc +1 -1
  44. package/harness/docs/schemas/flowgrid-docs/qa-item.schema.json +65 -0
  45. package/harness/docs/skills/adopt/SKILL.md +1 -1
  46. package/harness/docs/skills/api-spec/SKILL.md +1 -1
  47. package/harness/docs/skills/api-update/SKILL.md +1 -1
  48. package/harness/docs/skills/background-logic/SKILL.md +1 -1
  49. package/harness/docs/skills/common-spec/SKILL.md +1 -1
  50. package/harness/docs/skills/cross-service/SKILL.md +1 -1
  51. package/harness/docs/skills/db-erd/SKILL.md +1 -1
  52. package/harness/docs/skills/grill/SKILL.md +2 -0
  53. package/harness/docs/skills/grill-bqa/SKILL.md +2 -2
  54. package/harness/docs/skills/grill-dev/SKILL.md +1 -1
  55. package/harness/docs/skills/grill-docs/SKILL.md +2 -2
  56. package/harness/docs/skills/grill-hub-prd/SKILL.md +38 -0
  57. package/harness/docs/skills/module/SKILL.md +8 -5
  58. package/harness/docs/skills/overview/SKILL.md +7 -6
  59. package/harness/docs/skills/qa-resolve/SKILL.md +13 -12
  60. package/harness/docs/skills/qa-review/SKILL.md +45 -0
  61. package/harness/docs/skills/risk-register/SKILL.md +29 -0
  62. package/harness/docs/skills/spec/SKILL.md +4 -4
  63. package/harness/docs/skills/surfaces/SKILL.md +3 -1
  64. package/harness/docs/skills/update-spec/SKILL.md +2 -2
  65. package/harness/docs/skills/{business-process → user-flow}/SKILL.md +8 -8
  66. package/harness/fe/extracts/wire-audit-loop.md +1 -1
  67. package/harness/fe/skills/gen-common/SKILL.md +1 -1
  68. package/harness/fe/skills/grill-wire/SKILL.md +1 -1
  69. package/harness/fe/skills/wire/SKILL.md +1 -1
  70. package/harness/tests/extracts/grill-scenario-flow.md +2 -2
  71. package/harness/tests/skills/grill-testcase/SKILL.md +1 -1
  72. package/harness/tests/skills/scenario/SKILL.md +11 -11
  73. package/harness/tests/skills/testcase/SKILL.md +1 -1
  74. package/harness/tests/templates/SC.example.md +16 -16
  75. package/lexicon/registry-tags.en.txt +2 -2
  76. package/package.json +1 -1
  77. package/templates/project-skeleton/architecture/03-business-process/index.md +3 -0
  78. package/templates/project-skeleton/architecture/{03-business-process → 03-user-flows}/FLOW-login.md +6 -6
  79. package/templates/project-skeleton/architecture/{03-business-process → 03-user-flows}/FLOW-template.md +6 -7
  80. package/templates/project-skeleton/architecture/11-risks/index.md +7 -17
  81. package/templates/project-skeleton/architecture/11-risks/risk-register.md +34 -0
  82. package/templates/project-skeleton/architecture/12-glossary/index.md +2 -1
  83. package/templates/project-skeleton/overview/index.md +21 -43
  84. package/templates/project-skeleton/overview/operational-areas/_template.md +15 -22
  85. package/templates/project-skeleton/qa/README.md +4 -8
  86. package/templates/project-skeleton/surfaces/_module-index.template.md +40 -0
  87. package/templates/project-skeleton/surfaces/_surface-index.template.md +43 -0
  88. package/templates/schemas/qa-item.schema.json +98 -0
  89. package/templates/shared/bundle-authoring.md +10 -6
  90. package/templates/shared/default-layout.ejs +108 -62
  91. package/templates/shared/feature.bundle.yaml +15 -6
  92. package/templates/shared/ir/generated/spec.md +24 -24
  93. package/templates/shared/ir-spec.yaml +1 -1
  94. package/templates/shared/qa-authoring.md +78 -0
  95. package/templates/shared/qa-item.yaml +35 -14
  96. package/templates/shared/tpl-api-contract.md +6 -6
  97. package/templates/tests-skeleton/catalog/locale.yaml +16 -15
  98. 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
- userStories: bundle.userStories ?? bundle.spec?.userStories
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 listOpenYaml(openDir) {
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) => /^QA-.*\.ya?ml$/i.test(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
- * Always write qa/index.md (empty body when no qa/open files).
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 lines = ['# QA', '', '_Sinh bởi `flowgrid render`. Đóng bằng `/qa-resolve`._', '']
29
- const files = listOpenYaml(openDir)
30
- if (!files.length) {
31
- lines.push('_Không có câu hỏi mở._', '')
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 name of files) {
34
- const full = path.join(openDir, name)
35
- let id = name.replace(/\.ya?ml$/i, '')
36
- let kind = ''
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