@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.
- package/README.md +1 -1
- package/adapters/laravel/registries/codegen.registry.json +9 -9
- package/bin/flowgrid.mjs +326 -115
- package/bin/lib/agent-mcp.mjs +4 -0
- package/bin/lib/agent-profiles.mjs +30 -6
- package/bin/lib/audit-run.mjs +1 -1
- package/bin/lib/cli-update.mjs +48 -8
- package/bin/lib/doctor.mjs +90 -3
- package/bin/lib/harness-overlay.mjs +12 -5
- package/bin/lib/harness-sync.mjs +17 -3
- package/bin/lib/init-adapters.mjs +75 -0
- package/bin/lib/init-scaffold.mjs +9 -0
- package/bin/lib/inject-consumer-scripts.mjs +115 -0
- package/bin/lib/merge-stack-config.mjs +89 -0
- package/bin/lib/project-gitignore.mjs +1 -0
- package/bin/lib/repo-maps-align.mjs +203 -0
- package/dist/graph/config/load-config.js +7 -2
- package/dist/graph/config/load-config.js.map +1 -1
- package/dist/graph/mcp/tools.js +1 -1
- package/dist/graph/mcp/tools.js.map +1 -1
- package/dist/graph/registry/load-registries.d.ts +1 -0
- package/dist/graph/registry/load-registries.js +14 -1
- package/dist/graph/registry/load-registries.js.map +1 -1
- package/engines/cases/render-cases.mjs +67 -5
- package/engines/docs/lib/qa-item.mjs +91 -0
- package/engines/docs/lib/render-api-summary-markdown.mjs +28 -0
- package/engines/docs/lib/render-bundle-markdown.mjs +77 -2
- package/engines/docs/lib/render-data-model-markdown.mjs +171 -0
- package/engines/docs/lib/render-design-tables.mjs +89 -7
- package/engines/docs/lib/render-qa-list.mjs +123 -25
- package/engines/docs/lib/render-template.mjs +6 -0
- package/engines/docs/render-docs.mjs +6 -2
- package/engines/docs/vitepress/config.ts +7 -7
- package/engines/docs/vitepress/surfaces-nav.mjs +45 -14
- package/engines/openapi/check-backend-spec.mjs +2 -2
- package/engines/openapi/lib/markdown-table.mjs +8 -0
- package/engines/openapi/lib/render-backend-spec-markdown.mjs +78 -0
- package/engines/registry-sync/be-capabilities-sync.mjs +118 -0
- package/engines/registry-sync/fe-design-sync.mjs +258 -0
- package/engines/registry-sync/run-registry-sync.mjs +107 -0
- package/engines/shared/e2e-output-layout.mjs +68 -0
- package/engines/shared/flowgrid-e2e-root.mjs +19 -0
- package/engines/shared/resolve-flowgrid-context.mjs +176 -0
- package/engines/spec/lib/audit-api-gaps.mjs +1 -1
- package/engines/spec/lib/audit-bundle-gaps.mjs +92 -3
- package/engines/spec/lib/audit-db-tables.mjs +529 -0
- package/engines/spec/lib/audit-e2e-coverage.mjs +88 -30
- package/engines/spec/lib/bundle-schema.mjs +4 -1
- package/engines/spec/lib/open-qa.mjs +71 -21
- package/engines/spec/split-bundle.mjs +11 -1
- package/engines/testcase/runners/generate-api.mjs +23 -23
- package/engines/testcase/runners/generate.mjs +19 -17
- package/engines/testcase/runners/lib/bootstrap-context.mjs +44 -0
- package/engines/testcase/runners/lib/write-files.mjs +37 -9
- package/harness/agents/antigravity/rules/antigravity-mcp.mdc +12 -0
- package/harness/agents/gemini/rules/gemini-mcp.mdc +11 -0
- package/harness/agents/gemini_antigravity/rules/antigravity-mcp.mdc +8 -6
- package/harness/be/skills/{grill-api → audit-api}/SKILL.md +8 -7
- package/harness/common/extracts/artifact-graph.md +2 -2
- package/harness/common/extracts/artifactgraph-phase-hooks.md +2 -2
- package/harness/common/extracts/docs-mark-detect.md +2 -2
- package/harness/common/extracts/entity-relationship.md +23 -0
- package/harness/common/rules/flowgrid-ux-common.mdc +3 -3
- package/harness/common/skills/configure-repo-maps/SKILL.md +4 -2
- package/harness/docs/extracts/agent-execution-protocol.md +3 -3
- package/harness/docs/extracts/api-codegen-readiness.md +34 -0
- package/harness/docs/extracts/api-codegen-tags.md +30 -0
- package/harness/docs/extracts/api-contract.md +43 -0
- package/harness/docs/extracts/api-spec-sync.md +35 -0
- package/harness/docs/extracts/artifactgraph-hooks-docs.md +1 -1
- package/harness/docs/extracts/call-external.md +16 -0
- package/harness/docs/extracts/common-scope.md +9 -10
- package/harness/docs/extracts/db-audit-wizard.md +45 -0
- package/harness/docs/extracts/derived-data.md +18 -0
- package/harness/docs/extracts/design-leaf-signoff.md +16 -0
- package/harness/docs/extracts/extract-registry.docs.json +11 -2
- 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 +7 -3
- package/harness/docs/extracts/spec-evolution.md +21 -0
- package/harness/docs/extracts/spec-prd-lite.md +19 -0
- package/harness/docs/extracts/spec-requirement.md +6 -2
- package/harness/docs/extracts/spec-ssot-prep.md +25 -0
- package/harness/docs/extracts/tpl-module.md +12 -0
- package/harness/docs/extracts/verify-gate.md +33 -0
- package/harness/docs/extracts/wire-spec-feedback.md +31 -0
- package/harness/docs/rules/agent-compliance.mdc +1 -1
- package/harness/docs/rules/team-flow-spec.mdc +3 -4
- package/harness/docs/schemas/flowgrid-docs/qa-item.schema.json +65 -0
- package/harness/docs/skills/adopt/SKILL.md +2 -0
- package/harness/docs/skills/api/SKILL.md +4 -5
- package/harness/docs/skills/api-spec/SKILL.md +19 -6
- package/harness/docs/skills/api-update/SKILL.md +4 -4
- package/harness/docs/skills/architecture/SKILL.md +1 -1
- package/harness/docs/skills/business-process/SKILL.md +2 -0
- package/harness/docs/skills/common/SKILL.md +2 -2
- package/harness/docs/skills/common-spec/SKILL.md +10 -47
- package/harness/docs/skills/db-erd/SKILL.md +26 -0
- package/harness/docs/skills/grill/SKILL.md +28 -22
- package/harness/docs/skills/grill-api/SKILL.md +4 -6
- package/harness/docs/skills/grill-api-spec/SKILL.md +44 -24
- package/harness/docs/skills/grill-bqa/SKILL.md +28 -13
- package/harness/docs/skills/grill-common-spec/SKILL.md +10 -35
- package/harness/docs/skills/grill-dev/SKILL.md +8 -7
- package/harness/docs/skills/grill-docs/SKILL.md +11 -4
- package/harness/docs/skills/module/SKILL.md +3 -1
- package/harness/docs/skills/openapi/SKILL.md +2 -1
- package/harness/docs/skills/overview/SKILL.md +7 -1
- package/harness/docs/skills/qa-resolve/SKILL.md +13 -12
- package/harness/docs/skills/qa-review/SKILL.md +45 -0
- package/harness/docs/skills/spec/SKILL.md +43 -10
- package/harness/docs/skills/update-spec/SKILL.md +5 -2
- package/harness/fe/extracts/wire-audit-loop.md +72 -0
- package/harness/fe/extracts/wire-phase.md +45 -0
- package/harness/fe/rules/platform-design-vocabulary.mdc +1 -1
- package/harness/fe/rules/team-flow-prototype.mdc +8 -4
- package/harness/fe/skills/gen-common/SKILL.md +11 -84
- package/harness/fe/skills/grill-prototype/SKILL.md +51 -25
- package/harness/fe/skills/grill-test/SKILL.md +78 -20
- package/harness/fe/skills/grill-wire/SKILL.md +81 -0
- package/harness/fe/skills/prototype/SKILL.md +3 -2
- package/harness/fe/skills/wire/SKILL.md +8 -3
- package/harness/shared/AGENTS.md +3 -3
- package/harness/shared/SSOT_AGENT_PROTOCOL.md +3 -3
- package/harness/tests/extracts/grill-api-hook.md +69 -0
- package/harness/tests/extracts/grill-scenario-flow.md +39 -0
- package/harness/tests/extracts/grill-screen-tc.md +40 -0
- package/harness/tests/extracts/testcase-gen-cli.md +57 -0
- package/harness/tests/extracts/testcase-plan.md +29 -0
- package/harness/tests/extracts/tests-verify-gate.md +29 -0
- package/harness/tests/extracts/wire-test-handoff.md +37 -0
- package/harness/tests/skills/grill-testcase/SKILL.md +1 -0
- package/harness/tests/skills/test-api/SKILL.md +14 -6
- package/harness/tests/skills/testcase/SKILL.md +3 -1
- package/harness/tests/templates/TC.example-api.yaml +7 -1
- package/harness/tests/templates/TC.example.yaml +4 -1
- package/harness/tests/templates/tpl-testcase-plan.md +75 -0
- package/package.json +1 -1
- package/stacks/fastapi.json +1 -0
- package/stacks/laravel.json +1 -0
- package/stacks/nestjs.json +72 -0
- package/stacks/nextjs-nest.json +1 -0
- package/stacks/nuxt4-nest.json +1 -0
- package/templates/project-skeleton/architecture/03-business-process/FLOW-template.md +2 -0
- package/templates/project-skeleton/overview/index.md +68 -2
- package/templates/project-skeleton/overview/operational-areas/_template.md +37 -0
- package/templates/project-skeleton/qa/README.md +4 -8
- package/templates/project-skeleton/surfaces/common/data-model/index.md +10 -2
- package/templates/schemas/qa-item.schema.json +98 -0
- package/templates/shared/api-03-mock.stub.yaml +14 -0
- package/templates/shared/backend-api.bundle.yaml +3 -0
- package/templates/shared/backend-api.yaml +4 -0
- package/templates/shared/be-capabilities.registry.base.json +8 -0
- package/templates/shared/bundle-authoring.md +44 -7
- package/templates/shared/default-layout.ejs +131 -18
- package/templates/shared/design-spec.yaml +2 -3
- package/templates/shared/design.registry.base.json +38 -0
- package/templates/shared/feature.bundle.yaml +16 -9
- package/templates/shared/ir/generated/spec.md +281 -0
- 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 +133 -0
- package/templates/shared/tpl-screen-data-model.md +76 -0
- package/templates/tests-skeleton/cases/README.md +4 -0
- package/templates/tests-skeleton/catalog/locale.yaml +9 -0
- package/templates/tests-skeleton/tpl-testcase-plan.md +9 -0
- package/harness/docs/skills/api-integration/SKILL.md +0 -110
- 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
|
-
|
|
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(
|
|
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
|
|
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
|