@shanyucoder/flowgrid 0.1.5 → 0.1.9

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 (169) hide show
  1. package/README.md +1 -1
  2. package/adapters/laravel/registries/codegen.registry.json +9 -9
  3. package/bin/flowgrid.mjs +326 -115
  4. package/bin/lib/agent-mcp.mjs +4 -0
  5. package/bin/lib/agent-profiles.mjs +30 -6
  6. package/bin/lib/audit-run.mjs +1 -1
  7. package/bin/lib/cli-update.mjs +48 -8
  8. package/bin/lib/doctor.mjs +90 -3
  9. package/bin/lib/harness-overlay.mjs +12 -5
  10. package/bin/lib/harness-sync.mjs +17 -3
  11. package/bin/lib/init-adapters.mjs +75 -0
  12. package/bin/lib/init-scaffold.mjs +9 -0
  13. package/bin/lib/inject-consumer-scripts.mjs +115 -0
  14. package/bin/lib/merge-stack-config.mjs +89 -0
  15. package/bin/lib/project-gitignore.mjs +1 -0
  16. package/bin/lib/repo-maps-align.mjs +203 -0
  17. package/dist/graph/config/load-config.js +7 -2
  18. package/dist/graph/config/load-config.js.map +1 -1
  19. package/dist/graph/mcp/tools.js +1 -1
  20. package/dist/graph/mcp/tools.js.map +1 -1
  21. package/dist/graph/registry/load-registries.d.ts +1 -0
  22. package/dist/graph/registry/load-registries.js +14 -1
  23. package/dist/graph/registry/load-registries.js.map +1 -1
  24. package/engines/cases/render-cases.mjs +67 -5
  25. package/engines/docs/lib/qa-item.mjs +91 -0
  26. package/engines/docs/lib/render-api-summary-markdown.mjs +28 -0
  27. package/engines/docs/lib/render-bundle-markdown.mjs +77 -2
  28. package/engines/docs/lib/render-data-model-markdown.mjs +171 -0
  29. package/engines/docs/lib/render-design-tables.mjs +89 -7
  30. package/engines/docs/lib/render-qa-list.mjs +123 -25
  31. package/engines/docs/lib/render-template.mjs +6 -0
  32. package/engines/docs/render-docs.mjs +6 -2
  33. package/engines/docs/vitepress/config.ts +7 -7
  34. package/engines/docs/vitepress/surfaces-nav.mjs +45 -14
  35. package/engines/openapi/check-backend-spec.mjs +2 -2
  36. package/engines/openapi/lib/markdown-table.mjs +8 -0
  37. package/engines/openapi/lib/render-backend-spec-markdown.mjs +78 -0
  38. package/engines/registry-sync/be-capabilities-sync.mjs +118 -0
  39. package/engines/registry-sync/fe-design-sync.mjs +258 -0
  40. package/engines/registry-sync/run-registry-sync.mjs +107 -0
  41. package/engines/shared/e2e-output-layout.mjs +68 -0
  42. package/engines/shared/flowgrid-e2e-root.mjs +19 -0
  43. package/engines/shared/resolve-flowgrid-context.mjs +176 -0
  44. package/engines/spec/lib/audit-api-gaps.mjs +1 -1
  45. package/engines/spec/lib/audit-bundle-gaps.mjs +92 -3
  46. package/engines/spec/lib/audit-db-tables.mjs +529 -0
  47. package/engines/spec/lib/audit-e2e-coverage.mjs +88 -30
  48. package/engines/spec/lib/bundle-schema.mjs +4 -1
  49. package/engines/spec/lib/open-qa.mjs +71 -21
  50. package/engines/spec/split-bundle.mjs +11 -1
  51. package/engines/testcase/runners/generate-api.mjs +23 -23
  52. package/engines/testcase/runners/generate.mjs +19 -17
  53. package/engines/testcase/runners/lib/bootstrap-context.mjs +44 -0
  54. package/engines/testcase/runners/lib/write-files.mjs +37 -9
  55. package/harness/agents/antigravity/rules/antigravity-mcp.mdc +12 -0
  56. package/harness/agents/gemini/rules/gemini-mcp.mdc +11 -0
  57. package/harness/agents/gemini_antigravity/rules/antigravity-mcp.mdc +8 -6
  58. package/harness/be/skills/{grill-api → audit-api}/SKILL.md +8 -7
  59. package/harness/common/extracts/artifact-graph.md +2 -2
  60. package/harness/common/extracts/artifactgraph-phase-hooks.md +2 -2
  61. package/harness/common/extracts/docs-mark-detect.md +2 -2
  62. package/harness/common/extracts/entity-relationship.md +23 -0
  63. package/harness/common/rules/flowgrid-ux-common.mdc +3 -3
  64. package/harness/common/skills/configure-repo-maps/SKILL.md +4 -2
  65. package/harness/docs/extracts/agent-execution-protocol.md +3 -3
  66. package/harness/docs/extracts/api-codegen-readiness.md +34 -0
  67. package/harness/docs/extracts/api-codegen-tags.md +30 -0
  68. package/harness/docs/extracts/api-contract.md +43 -0
  69. package/harness/docs/extracts/api-spec-sync.md +35 -0
  70. package/harness/docs/extracts/artifactgraph-hooks-docs.md +1 -1
  71. package/harness/docs/extracts/call-external.md +16 -0
  72. package/harness/docs/extracts/common-scope.md +9 -10
  73. package/harness/docs/extracts/db-audit-wizard.md +45 -0
  74. package/harness/docs/extracts/derived-data.md +18 -0
  75. package/harness/docs/extracts/design-leaf-signoff.md +16 -0
  76. package/harness/docs/extracts/extract-registry.docs.json +11 -2
  77. package/harness/docs/extracts/qa-inbox.md +19 -10
  78. package/harness/docs/extracts/qa-team.md +32 -0
  79. package/harness/docs/extracts/spec-core.md +7 -3
  80. package/harness/docs/extracts/spec-evolution.md +21 -0
  81. package/harness/docs/extracts/spec-prd-lite.md +19 -0
  82. package/harness/docs/extracts/spec-requirement.md +6 -2
  83. package/harness/docs/extracts/spec-ssot-prep.md +25 -0
  84. package/harness/docs/extracts/tpl-module.md +12 -0
  85. package/harness/docs/extracts/verify-gate.md +33 -0
  86. package/harness/docs/extracts/wire-spec-feedback.md +31 -0
  87. package/harness/docs/rules/agent-compliance.mdc +1 -1
  88. package/harness/docs/rules/team-flow-spec.mdc +3 -4
  89. package/harness/docs/schemas/flowgrid-docs/qa-item.schema.json +65 -0
  90. package/harness/docs/skills/adopt/SKILL.md +2 -0
  91. package/harness/docs/skills/api/SKILL.md +4 -5
  92. package/harness/docs/skills/api-spec/SKILL.md +19 -6
  93. package/harness/docs/skills/api-update/SKILL.md +4 -4
  94. package/harness/docs/skills/architecture/SKILL.md +1 -1
  95. package/harness/docs/skills/business-process/SKILL.md +2 -0
  96. package/harness/docs/skills/common/SKILL.md +2 -2
  97. package/harness/docs/skills/common-spec/SKILL.md +10 -47
  98. package/harness/docs/skills/db-erd/SKILL.md +26 -0
  99. package/harness/docs/skills/grill/SKILL.md +28 -22
  100. package/harness/docs/skills/grill-api/SKILL.md +4 -6
  101. package/harness/docs/skills/grill-api-spec/SKILL.md +44 -24
  102. package/harness/docs/skills/grill-bqa/SKILL.md +28 -13
  103. package/harness/docs/skills/grill-common-spec/SKILL.md +10 -35
  104. package/harness/docs/skills/grill-dev/SKILL.md +8 -7
  105. package/harness/docs/skills/grill-docs/SKILL.md +11 -4
  106. package/harness/docs/skills/module/SKILL.md +3 -1
  107. package/harness/docs/skills/openapi/SKILL.md +2 -1
  108. package/harness/docs/skills/overview/SKILL.md +7 -1
  109. package/harness/docs/skills/qa-resolve/SKILL.md +13 -12
  110. package/harness/docs/skills/qa-review/SKILL.md +45 -0
  111. package/harness/docs/skills/spec/SKILL.md +43 -10
  112. package/harness/docs/skills/update-spec/SKILL.md +5 -2
  113. package/harness/fe/extracts/wire-audit-loop.md +72 -0
  114. package/harness/fe/extracts/wire-phase.md +45 -0
  115. package/harness/fe/rules/platform-design-vocabulary.mdc +1 -1
  116. package/harness/fe/rules/team-flow-prototype.mdc +8 -4
  117. package/harness/fe/skills/gen-common/SKILL.md +11 -84
  118. package/harness/fe/skills/grill-prototype/SKILL.md +51 -25
  119. package/harness/fe/skills/grill-test/SKILL.md +78 -20
  120. package/harness/fe/skills/grill-wire/SKILL.md +81 -0
  121. package/harness/fe/skills/prototype/SKILL.md +3 -2
  122. package/harness/fe/skills/wire/SKILL.md +8 -3
  123. package/harness/shared/AGENTS.md +3 -3
  124. package/harness/shared/SSOT_AGENT_PROTOCOL.md +3 -3
  125. package/harness/tests/extracts/grill-api-hook.md +69 -0
  126. package/harness/tests/extracts/grill-scenario-flow.md +39 -0
  127. package/harness/tests/extracts/grill-screen-tc.md +40 -0
  128. package/harness/tests/extracts/testcase-gen-cli.md +57 -0
  129. package/harness/tests/extracts/testcase-plan.md +29 -0
  130. package/harness/tests/extracts/tests-verify-gate.md +29 -0
  131. package/harness/tests/extracts/wire-test-handoff.md +37 -0
  132. package/harness/tests/skills/grill-testcase/SKILL.md +1 -0
  133. package/harness/tests/skills/test-api/SKILL.md +14 -6
  134. package/harness/tests/skills/testcase/SKILL.md +3 -1
  135. package/harness/tests/templates/TC.example-api.yaml +7 -1
  136. package/harness/tests/templates/TC.example.yaml +4 -1
  137. package/harness/tests/templates/tpl-testcase-plan.md +75 -0
  138. package/package.json +1 -1
  139. package/stacks/fastapi.json +1 -0
  140. package/stacks/laravel.json +1 -0
  141. package/stacks/nestjs.json +72 -0
  142. package/stacks/nextjs-nest.json +1 -0
  143. package/stacks/nuxt4-nest.json +1 -0
  144. package/templates/project-skeleton/architecture/03-business-process/FLOW-template.md +2 -0
  145. package/templates/project-skeleton/overview/index.md +68 -2
  146. package/templates/project-skeleton/overview/operational-areas/_template.md +37 -0
  147. package/templates/project-skeleton/qa/README.md +4 -8
  148. package/templates/project-skeleton/surfaces/common/data-model/index.md +10 -2
  149. package/templates/schemas/qa-item.schema.json +98 -0
  150. package/templates/shared/api-03-mock.stub.yaml +14 -0
  151. package/templates/shared/backend-api.bundle.yaml +3 -0
  152. package/templates/shared/backend-api.yaml +4 -0
  153. package/templates/shared/be-capabilities.registry.base.json +8 -0
  154. package/templates/shared/bundle-authoring.md +44 -7
  155. package/templates/shared/default-layout.ejs +131 -18
  156. package/templates/shared/design-spec.yaml +2 -3
  157. package/templates/shared/design.registry.base.json +38 -0
  158. package/templates/shared/feature.bundle.yaml +16 -9
  159. package/templates/shared/ir/generated/spec.md +281 -0
  160. package/templates/shared/ir-spec.yaml +1 -1
  161. package/templates/shared/qa-authoring.md +78 -0
  162. package/templates/shared/qa-item.yaml +35 -14
  163. package/templates/shared/tpl-api-contract.md +133 -0
  164. package/templates/shared/tpl-screen-data-model.md +76 -0
  165. package/templates/tests-skeleton/cases/README.md +4 -0
  166. package/templates/tests-skeleton/catalog/locale.yaml +9 -0
  167. package/templates/tests-skeleton/tpl-testcase-plan.md +9 -0
  168. package/harness/docs/skills/api-integration/SKILL.md +0 -110
  169. package/harness/docs/skills/grill-integration-spec/SKILL.md +0 -51
@@ -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
+ }
@@ -0,0 +1,28 @@
1
+ import { renderBackendSpecMarkdown } from '../../openapi/lib/render-backend-spec-markdown.mjs'
2
+
3
+ /**
4
+ * Reader-facing API summary for VitePress (SSOT contract remains 01 YAML).
5
+ * @param {Record<string, unknown>} backendSpec Parsed 01-backend-spec.yaml
6
+ * @param {{ pageId?: string, title?: string, backendRelPath?: string }} meta
7
+ */
8
+ export function renderApiSummaryMarkdown(backendSpec, meta = {}) {
9
+ const pageId = meta.pageId ?? backendSpec.feature?.id ?? '—'
10
+ const title = meta.title ?? backendSpec.feature?.title ?? 'Backend API'
11
+ const body = renderBackendSpecMarkdown(backendSpec, {
12
+ specFile: meta.backendRelPath ?? 'api/<seq>/01-backend-spec.yaml',
13
+ slug: title,
14
+ })
15
+ const header = [
16
+ `> **Page ID:** \`${pageId}\` · Tóm tắt đọc trên site. **SSOT codegen:** \`01-backend-spec.yaml\` — sửa qua \`/api-update\`, không edit file này tay.`,
17
+ '',
18
+ `- **Spec nghiệp vụ:** [spec.md](./spec.md) · **Data model:** [data-model.md](./data-model.md) · SSOT file: \`${meta.backendRelPath ?? '01-backend-spec.yaml'}\``,
19
+ '',
20
+ ].join('\n')
21
+ const lines = body.split('\n')
22
+ const h1 = lines.findIndex((l) => l.startsWith('# '))
23
+ if (h1 >= 0) {
24
+ lines.splice(h1 + 1, 0, '', ...header.trimEnd().split('\n'), '')
25
+ return lines.join('\n').endsWith('\n') ? lines.join('\n') : `${lines.join('\n')}\n`
26
+ }
27
+ return `${header}\n${body}`
28
+ }
@@ -3,6 +3,9 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { parse } from 'yaml'
5
5
  import { renderSpecMarkdown } from './render-spec-markdown.mjs'
6
+ import { renderDataModelMarkdown } from './render-data-model-markdown.mjs'
7
+ import { renderApiSummaryMarkdown } from './render-api-summary-markdown.mjs'
8
+ import { resolveBackendSpecForBundle } from '../../spec/lib/audit-db-tables.mjs'
6
9
 
7
10
  /**
8
11
  * Flatten bundle → spec shape (tests / fallback). Prefer ir/spec.yaml for site MD.
@@ -67,6 +70,22 @@ export function irGeneratedMarkdownPath(bundleFile) {
67
70
  return path.join(dir, 'ir', 'generated', 'spec.md')
68
71
  }
69
72
 
73
+ /** Reviewer-facing DB tables — `…/<leaf>/ir/generated/data-model.md` */
74
+ export function irGeneratedDataModelPath(bundleFile) {
75
+ const dir = path.dirname(path.resolve(bundleFile))
76
+ return path.join(dir, 'ir', 'generated', 'data-model.md')
77
+ }
78
+
79
+ /** API summary for readers — `…/<leaf>/ir/generated/api.md` */
80
+ export function irGeneratedApiPath(bundleFile) {
81
+ const dir = path.dirname(path.resolve(bundleFile))
82
+ return path.join(dir, 'ir', 'generated', 'api.md')
83
+ }
84
+
85
+ export function designIrPathForBundle(bundleFile) {
86
+ return path.join(path.dirname(path.resolve(bundleFile)), 'ir', 'design.yaml')
87
+ }
88
+
70
89
  export function irSpecPathForBundle(bundleFile) {
71
90
  return path.join(path.dirname(path.resolve(bundleFile)), 'ir', 'spec.yaml')
72
91
  }
@@ -77,13 +96,69 @@ export function irSpecPathForBundle(bundleFile) {
77
96
  export async function writeBundleMarkdownFile(bundleFile, context = {}) {
78
97
  const specFile = irSpecPathForBundle(bundleFile)
79
98
  if (!existsSync(specFile)) return null
80
- return writeIrSpecMarkdownFile(specFile, irGeneratedMarkdownPath(bundleFile), context)
99
+ const specOut = await writeIrSpecMarkdownFile(specFile, irGeneratedMarkdownPath(bundleFile), context)
100
+ await writeDataModelMarkdownFile(bundleFile)
101
+ await writeApiMarkdownFile(bundleFile)
102
+ return specOut
103
+ }
104
+
105
+ export async function writeApiMarkdownFile(bundleFile) {
106
+ const resolved = path.resolve(bundleFile)
107
+ const backendPath = resolveBackendSpecForBundle(resolved, null)
108
+ if (!backendPath || !existsSync(backendPath)) return null
109
+ const backendSpec = parse(await readFile(backendPath, 'utf8')) ?? {}
110
+ const specFile = irSpecPathForBundle(bundleFile)
111
+ const spec = existsSync(specFile) ? parse(await readFile(specFile, 'utf8')) ?? {} : {}
112
+ const pageId = spec['page-id'] ?? spec.id
113
+ const markdown = renderApiSummaryMarkdown(backendSpec, {
114
+ pageId,
115
+ title: spec.title ?? backendSpec.feature?.title,
116
+ backendRelPath: path.relative(path.dirname(resolved), backendPath).split(path.sep).join('/'),
117
+ })
118
+ const out = irGeneratedApiPath(bundleFile)
119
+ await mkdir(path.dirname(out), { recursive: true })
120
+ await writeFile(out, markdown, 'utf8')
121
+ return out
122
+ }
123
+
124
+ export async function writeDataModelMarkdownFile(bundleFile) {
125
+ const designFile = designIrPathForBundle(bundleFile)
126
+ if (!existsSync(designFile)) return null
127
+ const design = parse(await readFile(designFile, 'utf8')) ?? {}
128
+ const specFile = irSpecPathForBundle(bundleFile)
129
+ const spec = existsSync(specFile) ? parse(await readFile(specFile, 'utf8')) ?? {} : {}
130
+ const markdown = renderDataModelMarkdown({
131
+ pageId: spec.id ?? design.id,
132
+ title: spec.title ?? design.title,
133
+ design,
134
+ spec,
135
+ })
136
+ const out = irGeneratedDataModelPath(bundleFile)
137
+ await mkdir(path.dirname(out), { recursive: true })
138
+ await writeFile(out, markdown, 'utf8')
139
+ return out
140
+ }
141
+
142
+ export function mergeDesignIntoSpecForRender(spec, design) {
143
+ if (!design || typeof design !== 'object') return spec
144
+ const out = { ...spec }
145
+ if (design.ui?.list) {
146
+ out.ui = { ...(out.ui ?? {}), list: { ...(out.ui?.list ?? {}), ...design.ui.list } }
147
+ }
148
+ if (design.sections && !out.sections) out.sections = design.sections
149
+ if (design.actions && !out.actions) out.actions = design.actions
150
+ if (design.stateMatrix && !out.stateMatrix) out.stateMatrix = design.stateMatrix
151
+ if (design.nav && !out.nav) out.nav = design.nav
152
+ return out
81
153
  }
82
154
 
83
155
  export async function writeIrSpecMarkdownFile(specFile, mdOut, context = {}) {
84
156
  const spec = parse(await readFile(specFile, 'utf8')) ?? {}
157
+ const designFile = path.join(path.dirname(specFile), 'design.yaml')
158
+ const design = existsSync(designFile) ? parse(await readFile(designFile, 'utf8')) ?? {} : {}
159
+ const specShape = mergeDesignIntoSpecForRender(spec, design)
85
160
  const projectRoot = context.projectRoot ?? process.cwd()
86
- const markdown = renderIrSpecMarkdown(spec, {
161
+ const markdown = renderIrSpecMarkdown(specShape, {
87
162
  testcases: [],
88
163
  output: { specFile: path.basename(mdOut), testcasesDir: 'testcases' },
89
164
  devAppBaseUrl: context.devAppBaseUrl ?? '',
@@ -0,0 +1,171 @@
1
+ import { renderTable } from './markdown-table.mjs'
2
+ import { collectDesignDbBindings } from '../../spec/lib/audit-db-tables.mjs'
3
+
4
+ function roleLabel(role) {
5
+ const map = {
6
+ read: 'Chỉ đọc',
7
+ write: 'Chỉ ghi',
8
+ 'read-write': 'Đọc & ghi',
9
+ join: 'Join / lookup',
10
+ aggregate: 'Tổng hợp (read)',
11
+ }
12
+ return map[role] || role || '—'
13
+ }
14
+
15
+ function groupBindingsByTable(bindings) {
16
+ const byTable = new Map()
17
+ for (const b of bindings) {
18
+ if (!b.schema || !b.field) continue
19
+ if (!byTable.has(b.schema)) byTable.set(b.schema, [])
20
+ byTable.get(b.schema).push(b)
21
+ }
22
+ return byTable
23
+ }
24
+
25
+ function tableOverviewRows(design, tableNames) {
26
+ const meta = design.dataModel?.tables
27
+ const byName = new Map()
28
+ if (Array.isArray(meta)) {
29
+ for (const t of meta) {
30
+ if (t?.schema) byName.set(String(t.schema), t)
31
+ }
32
+ }
33
+ return tableNames.map((schema) => {
34
+ const m = byName.get(schema) ?? {}
35
+ return [
36
+ `\`${schema}\``,
37
+ roleLabel(m.roleOnScreen),
38
+ m.summary || m.purpose || '—',
39
+ m.erdEntity ? `\`${m.erdEntity}\`` : '—',
40
+ ]
41
+ })
42
+ }
43
+
44
+ /**
45
+ * Human-readable DB view for reviewers (multi-table safe). SSOT remains bundle / ir/design.yaml + 01.
46
+ * @param {{
47
+ * pageId?: string,
48
+ * title?: string,
49
+ * design?: Record<string, unknown>,
50
+ * spec?: Record<string, unknown>,
51
+ * }} input
52
+ */
53
+ export function renderDataModelMarkdown(input = {}) {
54
+ const design = input.design ?? {}
55
+ const spec = input.spec ?? {}
56
+ const pageId = input.pageId ?? design.id ?? '—'
57
+ const title = input.title ?? design.title ?? pageId
58
+ const bundleShape = { design, spec: { entities: design.entities ?? spec.entities, ui: design.ui ?? spec.ui } }
59
+ const { bindings, tables } = collectDesignDbBindings(bundleShape)
60
+ const fullBindings = bindings.filter((b) => b.schema && b.field)
61
+ const byTable = groupBindingsByTable(fullBindings)
62
+ const tableNames = tables.length ? tables : [...byTable.keys()]
63
+
64
+ const parts = [
65
+ `# Data model — ${title}`,
66
+ '',
67
+ `> **Page ID:** \`${pageId}\` · Đọc cùng [spec.md](./spec.md) trên VitePress (sidebar **Data model**). SSOT ghi: bundle + \`ir/design.yaml\` · BE: \`api/*/01-backend-spec.yaml\`.`,
68
+ '',
69
+ ]
70
+
71
+ if (design.dataModel?.erdRef || design.dataModel?.notes) {
72
+ parts.push('## Ghi chú & ERD', '')
73
+ if (design.dataModel.erdRef) {
74
+ parts.push(`- **ERD (Phase 0):** ${design.dataModel.erdRef}`)
75
+ }
76
+ if (design.dataModel.notes) {
77
+ parts.push('', String(design.dataModel.notes), '')
78
+ }
79
+ parts.push('')
80
+ }
81
+
82
+ const entities = design.entities ?? spec.entities
83
+ if (Array.isArray(entities) && entities.length) {
84
+ parts.push('## Entity trên màn (spec.entities)', '')
85
+ for (const e of entities) {
86
+ if (typeof e === 'string') {
87
+ parts.push(`- **${e}**`)
88
+ continue
89
+ }
90
+ const name = e.name ?? e.table ?? 'Entity'
91
+ parts.push(`- **${name}**${e.description ? ` — ${e.description}` : ''}`)
92
+ if (e.relatesTo) parts.push(` - Quan hệ: ${e.relatesTo}`)
93
+ }
94
+ parts.push('')
95
+ }
96
+
97
+ if (Array.isArray(design.relationships) && design.relationships.length) {
98
+ parts.push('## Quan hệ (relationships)', '')
99
+ for (const r of design.relationships) {
100
+ parts.push(`- ${typeof r === 'string' ? r : formatRecord(r)}`)
101
+ }
102
+ parts.push('')
103
+ }
104
+
105
+ if (tableNames.length) {
106
+ parts.push('## Tổng quan bảng (multi-table)', '')
107
+ parts.push(
108
+ renderTable(
109
+ ['Bảng (schema)', 'Vai trò trên màn', 'Mô tả review', 'Entity ER (nếu có)'],
110
+ tableOverviewRows(design, tableNames),
111
+ ),
112
+ '',
113
+ )
114
+ }
115
+
116
+ if (!fullBindings.length) {
117
+ parts.push(
118
+ '_Chưa có `design.sections[].db` / list column `db` — bổ sung trên bundle rồi chạy `flowgrid split`._',
119
+ '',
120
+ )
121
+ return parts.join('\n')
122
+ }
123
+
124
+ for (const schema of tableNames) {
125
+ const rows = (byTable.get(schema) ?? []).map((b) => {
126
+ const enumStr =
127
+ b.enumMapping && typeof b.enumMapping === 'object'
128
+ ? Object.entries(b.enumMapping)
129
+ .map(([k, v]) => `\`${k}\` → ${v}`)
130
+ .join('<br>')
131
+ : '—'
132
+ return [
133
+ b.label || '—',
134
+ b.bindField ? `\`${b.bindField}\`` : '—',
135
+ `\`${b.field}\``,
136
+ b.required ? 'Có' : 'Không',
137
+ b.path?.includes('list') ? 'List' : 'Form / section',
138
+ enumStr,
139
+ ]
140
+ })
141
+ if (!rows.length) continue
142
+ parts.push(`## Bảng \`${schema}\``, '')
143
+ parts.push(
144
+ renderTable(
145
+ ['Nhãn UI', 'bind.field (API/payload)', 'Cột DB', 'Bắt buộc?', 'Nguồn UI', 'enumMapping'],
146
+ rows,
147
+ ),
148
+ '',
149
+ )
150
+ }
151
+
152
+ parts.push(
153
+ '## Backend codegen',
154
+ '',
155
+ '- Map `db.schema` ↔ `01-backend-spec.yaml` → `modules[].entities[].table` (hoặc `name`).',
156
+ '- Map `db.field` ↔ `entities[].fields[].name`; quan hệ ORM theo ERD + `entity-relationship.md` extract.',
157
+ '- `bind.field` ↔ request/response DTO trên endpoint (`executionContract.apiRef`).',
158
+ '',
159
+ )
160
+
161
+ return `${parts.join('\n').trim()}\n`
162
+ }
163
+
164
+ function formatRecord(obj) {
165
+ if (obj == null) return ''
166
+ if (typeof obj !== 'object') return String(obj)
167
+ return Object.entries(obj)
168
+ .filter(([, v]) => v != null && v !== '')
169
+ .map(([k, v]) => `${k}: ${v}`)
170
+ .join('; ')
171
+ }
@@ -120,6 +120,95 @@ export function collectFormFields(nodes = []) {
120
120
  /**
121
121
  * Bảng Từ Điển Dữ Liệu & Quy Tắc Kiểm Tra Hợp Lệ (Data Dictionary Table 6 cột)
122
122
  */
123
+ /**
124
+ * Bảng cột danh sách (list / admin-crud) — từ spec.ui.list, không chỉ form sections.
125
+ * @param {{ columns?: unknown[], filters?: unknown[], pagination?: unknown }} listUi
126
+ */
127
+ /**
128
+ * @param {{ filters?: unknown[], pagination?: unknown }} listUi
129
+ */
130
+ export function renderListFiltersTable(listUi = {}) {
131
+ const filters = listUi?.filters
132
+ if (!Array.isArray(filters) || !filters.length) return ''
133
+
134
+ const rows = filters.map((f) => {
135
+ if (!f || typeof f !== 'object') return null
136
+ return [
137
+ f.label || f.name || '—',
138
+ f.name != null ? `\`${f.name}\`` : '—',
139
+ f.type || '—',
140
+ f.widget || '—',
141
+ f.meaning || '—',
142
+ f.purpose || '—',
143
+ f.placeholder || '—',
144
+ ]
145
+ }).filter(Boolean)
146
+
147
+ if (!rows.length) return ''
148
+
149
+ return renderTable(
150
+ ['Nhãn', 'Tên filter', 'Kiểu', 'Widget', 'Ý nghĩa', 'Mục đích', 'Placeholder'],
151
+ rows,
152
+ )
153
+ }
154
+
155
+ export function renderListPaginationBlock(listUi = {}) {
156
+ const p = listUi?.pagination
157
+ if (!p || typeof p !== 'object') return ''
158
+ const bits = []
159
+ if (p.enabled === false) return '_Phân trang: tắt trên màn này._\n'
160
+ if (p.pageSize != null) bits.push(`Kích thước trang mặc định: **${p.pageSize}**`)
161
+ if (p.pageSizeOptions?.length) {
162
+ bits.push(`Tùy chọn: ${p.pageSizeOptions.map((n) => `\`${n}\``).join(', ')}`)
163
+ }
164
+ if (p.showTotal != null) bits.push(`Hiển thị tổng số bản ghi: ${p.showTotal ? 'Có' : 'Không'}`)
165
+ if (p.infiniteScroll) bits.push('Chế độ: infinite scroll')
166
+ if (!bits.length) {
167
+ return '_Phân trang: bật (chi tiết kỹ thuật trên bundle / design actions)._ \n'
168
+ }
169
+ return bits.map((b) => `- ${b}`).join('\n') + '\n'
170
+ }
171
+
172
+ export function renderListColumnsTable(listUi = {}) {
173
+ const columns = listUi?.columns
174
+ if (!Array.isArray(columns) || !columns.length) return ''
175
+
176
+ const rows = columns.map((col) => {
177
+ if (!col || typeof col !== 'object') return null
178
+ const db = col.db
179
+ let dbCell = '—'
180
+ if (db && typeof db === 'object') {
181
+ if (db.schema && db.field) dbCell = `\`${db.schema}.${db.field}\``
182
+ else if (db.schema) dbCell = `\`${db.schema}\` (thiếu field)`
183
+ else if (db.field) dbCell = `\`${db.field}\` (thiếu schema)`
184
+ }
185
+ return [
186
+ col.title || col.label || '—',
187
+ col.key != null ? `\`${col.key}\`` : '—',
188
+ col.meaning || '—',
189
+ col.purpose || '—',
190
+ [col.widget, col.render].filter(Boolean).join(' / ') || '—',
191
+ col.sortable ? 'Có' : 'Không',
192
+ dbCell,
193
+ ]
194
+ }).filter(Boolean)
195
+
196
+ if (!rows.length) return ''
197
+
198
+ return renderTable(
199
+ [
200
+ 'Nhãn cột',
201
+ 'Key',
202
+ 'Ý nghĩa nghiệp vụ',
203
+ 'Mục đích UI',
204
+ 'Widget / render',
205
+ 'Sort',
206
+ 'DB (schema.field)',
207
+ ],
208
+ rows,
209
+ )
210
+ }
211
+
123
212
  export function renderValidationDictionaryTable(sections = []) {
124
213
  const fields = collectFormFields(sections)
125
214
  if (!fields.length) return ''
@@ -685,11 +774,4 @@ function formatApi(action) {
685
774
  if (action.api) return String(action.api)
686
775
  if (Array.isArray(action.apiRefs)) return action.apiRefs.join(', ')
687
776
  return ''
688
- }
689
-
690
- export {
691
- renderValidationDictionaryTable,
692
- renderStateMatrixTable,
693
- renderActionFlowsDetailed,
694
- renderCustomWidgetSpecs
695
777
  }
@@ -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