@shanyucoder/flowgrid 0.1.10 → 0.1.12
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/adapters/nextjs/codegen/runners/lib/resolve-hub-id.mjs +7 -4
- package/adapters/nextjs/unitgen/runners/README.md +4 -4
- package/adapters/nextjs/unitgen/runners/generate.mjs +1 -1
- package/adapters/nuxt4/codegen/runners/lib/resolve-hub-id.mjs +7 -4
- package/adapters/nuxt4/unitgen/runners/README.md +4 -4
- package/adapters/nuxt4/unitgen/runners/generate.mjs +1 -1
- package/adapters/shared/common-gen.mjs +1 -1
- package/adapters/shared/resolve-hub-id.mjs +12 -5
- package/dist/docs/mcp/tools.js +59 -6
- package/dist/docs/mcp/tools.js.map +1 -1
- package/dist/docs/scan/ids.js +3 -2
- package/dist/docs/scan/ids.js.map +1 -1
- package/dist/docs/scan/screen-to-flows.d.ts +3 -0
- package/dist/docs/scan/screen-to-flows.js +18 -0
- package/dist/docs/scan/screen-to-flows.js.map +1 -0
- package/dist/graph/mcp/tools.js +1 -1
- package/dist/graph/mcp/tools.js.map +1 -1
- package/engines/cases/render-cases.mjs +1 -1
- package/engines/docs/lib/render-bundle-markdown.mjs +19 -0
- package/engines/docs/lib/render-design-markdown.mjs +56 -0
- package/engines/docs/lib/screen-to-flows-index.mjs +121 -0
- package/engines/docs/vitepress/surfaces-nav.mjs +7 -0
- package/engines/spec/lib/audit-bundle-gaps.mjs +15 -3
- package/engines/spec/lib/audit-interaction-cases.mjs +185 -0
- package/engines/spec/lib/audit-legacy-gaps.mjs +10 -0
- package/engines/spec/lib/bundle-ir.mjs +7 -0
- package/engines/spec/lib/bundle-schema.mjs +1 -0
- package/engines/spec/lib/interaction-cases.mjs +46 -0
- package/engines/spec/split-bundle.mjs +1 -1
- package/engines/testcase/_staging-from-portal/runners/README.md +3 -3
- package/engines/testcase/_staging-from-portal/runners/generate.mjs +2 -2
- package/engines/testcase/_staging-from-portal/runners/lib/read-testcase.mjs +1 -1
- package/engines/testcase/runners/README.md +3 -3
- package/engines/testcase/runners/generate.mjs +2 -2
- package/engines/testcase/runners/lib/read-testcase.mjs +1 -1
- package/engines/testcase/runners/lib/resolve-hub-id.mjs +3 -3
- package/harness/be/skills/audit-api/SKILL.md +1 -1
- package/harness/be/skills/grill-api-unit/SKILL.md +1 -1
- package/harness/docs/extracts/agent-design-context.md +124 -0
- package/harness/docs/extracts/architecture-core.md +2 -0
- package/harness/docs/extracts/extract-registry.docs.json +11 -4
- package/harness/docs/extracts/product-id-convention.md +58 -0
- package/harness/docs/extracts/spec-requirement.md +1 -1
- package/harness/docs/extracts/tpl-adoption-inventory.md +32 -0
- package/harness/docs/extracts/tpl-module.md +1 -1
- package/harness/docs/rules/agent-compliance.mdc +1 -1
- package/harness/docs/skills/adopt/SKILL.md +42 -10
- package/harness/docs/skills/api-spec/SKILL.md +4 -0
- package/harness/docs/skills/api-update/SKILL.md +1 -1
- package/harness/docs/skills/grill-dev/SKILL.md +22 -9
- package/harness/docs/skills/module/SKILL.md +1 -1
- package/harness/docs/skills/spec/SKILL.md +10 -6
- package/harness/docs/skills/surfaces/SKILL.md +1 -0
- package/harness/docs/skills/user-flow/SKILL.md +4 -2
- package/harness/fe/skills/grill-prototype/SKILL.md +1 -1
- package/harness/fe/skills/prototype/SKILL.md +28 -16
- package/harness/fe/skills/unit/SKILL.md +4 -4
- package/harness/shared/SSOT_AGENT_PROTOCOL.md +1 -1
- package/harness/tests/skills/grill-testcase/SKILL.md +1 -1
- package/harness/tests/skills/scenario/SKILL.md +2 -2
- package/harness/tests/skills/testcase/SKILL.md +1 -1
- package/harness/tests/templates/SC.example.md +11 -11
- package/harness/tests/templates/TC.example.yaml +3 -3
- package/package.json +1 -1
- package/templates/project-skeleton/architecture/03-user-flows/FLOW-login.md +10 -10
- package/templates/project-skeleton/architecture/04-solution-strategy/index.md +1 -1
- package/templates/project-skeleton/architecture/08-cross-cutting/security.md +1 -1
- package/templates/project-skeleton/architecture/12-glossary/index.md +3 -2
- package/templates/project-skeleton/architecture/model/workspace.dsl +7 -7
- package/templates/project-skeleton/surfaces/_module-index.template.md +2 -2
- package/templates/project-skeleton/surfaces/_surface-index.template.md +1 -0
- package/templates/shared/bundle-authoring.md +3 -2
- package/templates/shared/default-layout.ejs +47 -2
- package/templates/shared/feature.bundle.yaml +29 -1
- package/templates/shared/tpl-runtime-sequence.md +56 -0
- package/templates/tests-skeleton/cases/README.md +1 -1
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ir/generated/design.md — interaction case sequence diagrams only (from ir/design.yaml).
|
|
3
|
+
*/
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
|
|
6
|
+
function stripMermaidFence(text) {
|
|
7
|
+
const t = String(text ?? '').trim()
|
|
8
|
+
const m = t.match(/^```(?:mermaid)?\s*([\s\S]*?)```$/m)
|
|
9
|
+
if (m) return m[1].trim()
|
|
10
|
+
return t
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {object} design ir/design.yaml shape
|
|
15
|
+
* @param {{ pageId?: string, title?: string }} meta
|
|
16
|
+
*/
|
|
17
|
+
export function renderDesignMarkdown(design, meta = {}) {
|
|
18
|
+
const items = design?.interactionCases?.items
|
|
19
|
+
if (!Array.isArray(items) || !items.length) return null
|
|
20
|
+
|
|
21
|
+
const lines = []
|
|
22
|
+
lines.push(`# Design sequences — ${meta.pageId || design.id || 'screen'}`)
|
|
23
|
+
lines.push('')
|
|
24
|
+
if (meta.title) {
|
|
25
|
+
lines.push(`> ${meta.title}`)
|
|
26
|
+
lines.push('')
|
|
27
|
+
}
|
|
28
|
+
lines.push(
|
|
29
|
+
'> Generated from `ir/design.yaml` (`interactionCases`). Prose: [spec.md](./spec.md#interaction-cases). Do not edit this file.',
|
|
30
|
+
)
|
|
31
|
+
lines.push('')
|
|
32
|
+
|
|
33
|
+
for (const item of items) {
|
|
34
|
+
const id = item?.id || 'IC-UNKNOWN'
|
|
35
|
+
lines.push(`## ${id} {#${id.toLowerCase()}}`)
|
|
36
|
+
lines.push('')
|
|
37
|
+
const body = stripMermaidFence(item.sequenceDiagram)
|
|
38
|
+
if (!body.includes('sequenceDiagram')) {
|
|
39
|
+
lines.push('```mermaid')
|
|
40
|
+
lines.push(body)
|
|
41
|
+
lines.push('```')
|
|
42
|
+
} else {
|
|
43
|
+
lines.push('```mermaid')
|
|
44
|
+
lines.push(body)
|
|
45
|
+
lines.push('```')
|
|
46
|
+
}
|
|
47
|
+
lines.push('')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return lines.join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function irGeneratedDesignMarkdownPath(bundleFile) {
|
|
54
|
+
const dir = path.dirname(path.resolve(bundleFile))
|
|
55
|
+
return path.join(dir, 'ir', 'generated', 'design.md')
|
|
56
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reverse index: W-* screen id → FLOW-* ids (from bundle userFlows + FLOW markdown traceability).
|
|
3
|
+
*/
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
const SCREEN_ID_RE = /W-[A-Z0-9][\w-]*/gi
|
|
8
|
+
|
|
9
|
+
function addLink(map, screenId, flowId) {
|
|
10
|
+
const flow = normalizeFlowToken(flowId)
|
|
11
|
+
if (!screenId || !flow) return
|
|
12
|
+
if (!map.has(screenId)) map.set(screenId, new Set())
|
|
13
|
+
map.get(screenId).add(flow)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeFlowToken(raw) {
|
|
17
|
+
const m = raw.match(/FLOW-[A-Z0-9][\w-]*/i)
|
|
18
|
+
if (!m) return null
|
|
19
|
+
const tail = m[0].slice(5)
|
|
20
|
+
return `FLOW-${tail}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse bullets from userFlows block text (YAML literal or list lines). */
|
|
24
|
+
export function parseUserFlowsText(text) {
|
|
25
|
+
const links = []
|
|
26
|
+
if (!text || !text.trim()) return links
|
|
27
|
+
for (const line of text.split('\n')) {
|
|
28
|
+
const trimmed = line.replace(/^\s*-\s*/, '').trim()
|
|
29
|
+
if (!/FLOW-/i.test(trimmed)) continue
|
|
30
|
+
const flowId = normalizeFlowToken(trimmed)
|
|
31
|
+
if (!flowId) continue
|
|
32
|
+
const screens = []
|
|
33
|
+
const screensPart = trimmed.match(/screens:\s*(.+)/i)?.[1] ?? trimmed
|
|
34
|
+
for (const sm of screensPart.match(SCREEN_ID_RE) ?? []) screens.push(sm)
|
|
35
|
+
links.push({ flowId, screenIds: screens })
|
|
36
|
+
}
|
|
37
|
+
return links
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractUserFlowsFromBundleFile(raw) {
|
|
41
|
+
const block = raw.match(/^userFlows:\s*\|\s*\n([\s\S]*?)(?=^[a-zA-Z][\w.-]*:|\s*$)/m)?.[1]
|
|
42
|
+
if (block) return parseUserFlowsText(block)
|
|
43
|
+
const inline = raw.match(/^userFlows:\s*(.+)$/m)?.[1]
|
|
44
|
+
if (inline) return parseUserFlowsText(inline)
|
|
45
|
+
const listBlock = raw.match(/^userFlows:\s*\n((?:\s+-\s+.+\n?)+)/m)?.[1]
|
|
46
|
+
if (listBlock) return parseUserFlowsText(listBlock)
|
|
47
|
+
return []
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function walkBundleFiles(docsRoot, onBundle) {
|
|
51
|
+
const roots = [
|
|
52
|
+
path.join(docsRoot, 'surfaces'),
|
|
53
|
+
path.join(docsRoot, 'product'),
|
|
54
|
+
]
|
|
55
|
+
const visit = (dir) => {
|
|
56
|
+
if (!fs.existsSync(dir)) return
|
|
57
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
58
|
+
if (ent.name.startsWith('.') || ent.name === 'node_modules') continue
|
|
59
|
+
const p = path.join(dir, ent.name)
|
|
60
|
+
if (ent.isDirectory()) visit(p)
|
|
61
|
+
else if (ent.name.endsWith('.bundle.yaml')) onBundle(p)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
for (const r of roots) visit(r)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function walkFlowMarkdown(docsRoot, onFile) {
|
|
68
|
+
const catalog = path.join(docsRoot, 'architecture/03-user-flows')
|
|
69
|
+
if (fs.existsSync(catalog)) {
|
|
70
|
+
for (const n of fs.readdirSync(catalog)) {
|
|
71
|
+
if (n.startsWith('FLOW-') && n.endsWith('.md')) onFile(path.join(catalog, n))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const walk = (dir) => {
|
|
75
|
+
if (!fs.existsSync(dir)) return
|
|
76
|
+
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
77
|
+
if (ent.name.startsWith('.') || ent.name === 'node_modules') continue
|
|
78
|
+
const p = path.join(dir, ent.name)
|
|
79
|
+
if (ent.isDirectory()) {
|
|
80
|
+
if (ent.name === 'user-flows' && path.basename(dir) === 'common') {
|
|
81
|
+
for (const n of fs.readdirSync(p)) {
|
|
82
|
+
if (n.startsWith('FLOW-') && n.endsWith('.md')) onFile(path.join(p, n))
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
walk(p)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
walk(path.join(docsRoot, 'surfaces'))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* @returns {Record<string, string[]>} screenToFlows sorted flow ids per W-*
|
|
94
|
+
*/
|
|
95
|
+
export function buildScreenToFlows(docsRoot) {
|
|
96
|
+
const map = new Map()
|
|
97
|
+
|
|
98
|
+
walkBundleFiles(docsRoot, (bundlePath) => {
|
|
99
|
+
const raw = fs.readFileSync(bundlePath, 'utf8')
|
|
100
|
+
for (const { flowId, screenIds } of extractUserFlowsFromBundleFile(raw)) {
|
|
101
|
+
for (const sid of screenIds) addLink(map, sid, flowId)
|
|
102
|
+
}
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
walkFlowMarkdown(docsRoot, (flowPath) => {
|
|
106
|
+
const flowId = path.basename(flowPath, '.md')
|
|
107
|
+
const body = fs.readFileSync(flowPath, 'utf8')
|
|
108
|
+
for (const sid of body.match(SCREEN_ID_RE) ?? []) addLink(map, sid, flowId)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const out = {}
|
|
112
|
+
for (const [screen, flows] of map) {
|
|
113
|
+
out[screen] = [...flows].sort((a, b) => a.localeCompare(b))
|
|
114
|
+
}
|
|
115
|
+
return out
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function linkedFlowsForScreen(docsRoot, screenId, screenToFlows) {
|
|
119
|
+
const idx = screenToFlows ?? buildScreenToFlows(docsRoot)
|
|
120
|
+
return idx[screenId] ?? []
|
|
121
|
+
}
|
|
@@ -93,6 +93,7 @@ export function leafGeneratedNavNodes(leafDir, toHref) {
|
|
|
93
93
|
const specMd = path.join(leafDir, 'ir', 'generated', 'spec.md')
|
|
94
94
|
const dmMd = path.join(leafDir, 'ir', 'generated', 'data-model.md')
|
|
95
95
|
const apiMd = path.join(leafDir, 'ir', 'generated', 'api.md')
|
|
96
|
+
const designMd = path.join(leafDir, 'ir', 'generated', 'design.md')
|
|
96
97
|
const nodes = []
|
|
97
98
|
if (existsSync(specMd)) {
|
|
98
99
|
nodes.push({
|
|
@@ -112,6 +113,12 @@ export function leafGeneratedNavNodes(leafDir, toHref) {
|
|
|
112
113
|
link: toHref(apiMd),
|
|
113
114
|
})
|
|
114
115
|
}
|
|
116
|
+
if (existsSync(designMd)) {
|
|
117
|
+
nodes.push({
|
|
118
|
+
text: 'Design sequences',
|
|
119
|
+
link: toHref(designMd),
|
|
120
|
+
})
|
|
121
|
+
}
|
|
115
122
|
return nodes
|
|
116
123
|
}
|
|
117
124
|
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import fs from 'fs';
|
|
21
21
|
import path from 'path';
|
|
22
22
|
import { parse } from 'yaml';
|
|
23
|
+
import { auditInteractionCases } from './audit-interaction-cases.mjs';
|
|
23
24
|
import { auditUxAffordance } from './audit-ux-affordance.mjs';
|
|
24
25
|
import { auditDbTables, resolveBackendSpecForBundle } from './audit-db-tables.mjs';
|
|
25
26
|
|
|
@@ -581,11 +582,10 @@ function auditBundleContent(rawText, filePath, pageType) {
|
|
|
581
582
|
addWarning(
|
|
582
583
|
'WARN_NO_USER_FLOWS',
|
|
583
584
|
'userFlows',
|
|
584
|
-
'
|
|
585
|
-
'Declare userFlows: | —
|
|
585
|
+
'Screen has handoff / multi-screen navigation but userFlows is not declared.',
|
|
586
|
+
'Declare userFlows: | — FLOW-<id> — role: … — screens: W-* (this leaf).'
|
|
586
587
|
);
|
|
587
588
|
}
|
|
588
|
-
|
|
589
589
|
if (has(rawText, 'risks:')) {
|
|
590
590
|
addWarning(
|
|
591
591
|
'WARN_BUNDLE_RISKS_FORBIDDEN',
|
|
@@ -618,6 +618,18 @@ function auditBundleContent(rawText, filePath, pageType) {
|
|
|
618
618
|
);
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
// =========================================================================
|
|
622
|
+
// Interaction cases (L2) — two-step confirm + item keys
|
|
623
|
+
// =========================================================================
|
|
624
|
+
|
|
625
|
+
let bundleParsed = {};
|
|
626
|
+
try {
|
|
627
|
+
bundleParsed = parse(rawText) ?? {};
|
|
628
|
+
} catch {
|
|
629
|
+
/* keep empty */
|
|
630
|
+
}
|
|
631
|
+
auditInteractionCases(bundleParsed, { addGap, addConfirm, addWarning });
|
|
632
|
+
|
|
621
633
|
// =========================================================================
|
|
622
634
|
// Result
|
|
623
635
|
// =========================================================================
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-step audit for bundle interactionCases (see harness/docs/extracts/agent-design-context.md).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
function countMutationActions(design) {
|
|
6
|
+
const actions = design?.actions
|
|
7
|
+
if (!Array.isArray(actions)) return 0
|
|
8
|
+
return actions.filter((a) => a && (a.executionContract || a.apiRef)).length
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function interactionCasesComplexityTriggers(bundle) {
|
|
12
|
+
const design = bundle?.design ?? {}
|
|
13
|
+
const statuses = design?.stateMatrix?.recordStatuses
|
|
14
|
+
const statusCount = Array.isArray(statuses) ? statuses.length : 0
|
|
15
|
+
const mutations = countMutationActions(design)
|
|
16
|
+
const scenarios = bundle?.userStories?.scenarios
|
|
17
|
+
const scenarioCount = Array.isArray(scenarios) ? scenarios.length : 0
|
|
18
|
+
const listOnly =
|
|
19
|
+
design?.codegen?.profile === 'list' ||
|
|
20
|
+
String(bundle?.gen?.codegen?.profile || '') === 'list'
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
complex:
|
|
24
|
+
statusCount >= 3 ||
|
|
25
|
+
mutations >= 2 ||
|
|
26
|
+
(!listOnly && scenarioCount >= 4) ||
|
|
27
|
+
Boolean(design?.stateMatrix?.behaviors?.length >= 3),
|
|
28
|
+
statusCount,
|
|
29
|
+
mutations,
|
|
30
|
+
scenarioCount,
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Record<string, unknown>} bundle parsed bundle YAML
|
|
36
|
+
* @param {{ addGap: Function, addConfirm: Function, addWarning: Function }} hooks
|
|
37
|
+
*/
|
|
38
|
+
export function auditInteractionCases(bundle, hooks) {
|
|
39
|
+
const { addGap, addConfirm, addWarning } = hooks
|
|
40
|
+
const ic = bundle?.interactionCases
|
|
41
|
+
const { complex, statusCount, mutations } = interactionCasesComplexityTriggers(bundle)
|
|
42
|
+
|
|
43
|
+
if (ic == null) {
|
|
44
|
+
if (!complex) return
|
|
45
|
+
addConfirm(
|
|
46
|
+
'CONFIRM_INTERACTION_CASES_POLICY',
|
|
47
|
+
'interactionCases',
|
|
48
|
+
'Screen has complex state/actions — declare interactionCases (IC-* + sequenceDiagram)?',
|
|
49
|
+
[
|
|
50
|
+
'(Recommended) Yes — set interactionCases.policy: required and items[]',
|
|
51
|
+
'No — simple screen (set interactionCases.policy: skip + skipReason)',
|
|
52
|
+
'Other — note in qa/',
|
|
53
|
+
],
|
|
54
|
+
0,
|
|
55
|
+
)
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const policy = ic.policy
|
|
60
|
+
if (policy === 'skip') {
|
|
61
|
+
if (!String(ic.skipReason || '').trim()) {
|
|
62
|
+
addWarning(
|
|
63
|
+
'INTERACTION_CASES_SKIP_NO_REASON',
|
|
64
|
+
'interactionCases.skipReason',
|
|
65
|
+
'interactionCases.policy is skip but skipReason is empty.',
|
|
66
|
+
'Declare skipReason explaining why L2 sequences are not needed.',
|
|
67
|
+
)
|
|
68
|
+
}
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const items = Array.isArray(ic.items) ? ic.items : []
|
|
73
|
+
|
|
74
|
+
if (policy === 'required' && items.length === 0) {
|
|
75
|
+
addGap(
|
|
76
|
+
'INTERACTION_CASES_ITEMS_MISSING',
|
|
77
|
+
'critical',
|
|
78
|
+
'interactionCases.items',
|
|
79
|
+
'interactionCases.policy is required but items[] is empty.',
|
|
80
|
+
'Add at least one IC-* item with description and sequenceDiagram.',
|
|
81
|
+
)
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (items.length === 0) return
|
|
86
|
+
|
|
87
|
+
const actionIds = new Set(
|
|
88
|
+
(bundle?.design?.actions || [])
|
|
89
|
+
.map((a) => a?.id)
|
|
90
|
+
.filter(Boolean),
|
|
91
|
+
)
|
|
92
|
+
const statuses = new Set(bundle?.design?.stateMatrix?.recordStatuses || [])
|
|
93
|
+
|
|
94
|
+
for (let i = 0; i < items.length; i++) {
|
|
95
|
+
const item = items[i]
|
|
96
|
+
const base = `interactionCases.items[${i}]`
|
|
97
|
+
if (!item?.id) {
|
|
98
|
+
addGap(
|
|
99
|
+
'INTERACTION_CASES_MISSING_ID',
|
|
100
|
+
'critical',
|
|
101
|
+
`${base}.id`,
|
|
102
|
+
'Interaction case missing id (use IC-*).',
|
|
103
|
+
'Declare id: IC-SUBMIT-HAPPY',
|
|
104
|
+
)
|
|
105
|
+
} else if (!/^IC-[A-Z0-9][A-Z0-9-]*$/i.test(String(item.id))) {
|
|
106
|
+
addWarning(
|
|
107
|
+
'INTERACTION_CASES_ID_FORMAT',
|
|
108
|
+
`${base}.id`,
|
|
109
|
+
`Interaction case id "${item.id}" should match IC-* pattern.`,
|
|
110
|
+
'Rename to IC-<DOMAIN>-<CASE> (e.g. IC-SUBMIT-409).',
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
if (!String(item?.title || '').trim()) {
|
|
114
|
+
addGap(
|
|
115
|
+
'INTERACTION_CASES_MISSING_TITLE',
|
|
116
|
+
'critical',
|
|
117
|
+
`${base}.title`,
|
|
118
|
+
'Interaction case missing title.',
|
|
119
|
+
'Add short title for reviewers and agents.',
|
|
120
|
+
)
|
|
121
|
+
}
|
|
122
|
+
if (!String(item?.description || '').trim()) {
|
|
123
|
+
addGap(
|
|
124
|
+
'INTERACTION_CASES_MISSING_DESCRIPTION',
|
|
125
|
+
'critical',
|
|
126
|
+
`${base}.description`,
|
|
127
|
+
'Interaction case missing description (user case prose).',
|
|
128
|
+
'Describe user steps and expected UI/API feedback.',
|
|
129
|
+
)
|
|
130
|
+
}
|
|
131
|
+
const seq = String(item?.sequenceDiagram || '')
|
|
132
|
+
if (!seq.includes('sequenceDiagram')) {
|
|
133
|
+
addGap(
|
|
134
|
+
'INTERACTION_CASES_MISSING_SEQUENCE',
|
|
135
|
+
'critical',
|
|
136
|
+
`${base}.sequenceDiagram`,
|
|
137
|
+
'Interaction case missing Mermaid sequenceDiagram.',
|
|
138
|
+
'Add sequenceDiagram with alt/else for error paths when applicable.',
|
|
139
|
+
)
|
|
140
|
+
} else if (!/alt\s+/i.test(seq) && complex) {
|
|
141
|
+
addWarning(
|
|
142
|
+
'INTERACTION_CASES_NO_ALT',
|
|
143
|
+
`${base}.sequenceDiagram`,
|
|
144
|
+
'Sequence diagram has no alt/else block — recommend at least one error branch.',
|
|
145
|
+
'Model validation, 409, or timeout with alt/else.',
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
const actionId = item?.links?.actionId
|
|
149
|
+
if (actionId && actionIds.size && !actionIds.has(actionId)) {
|
|
150
|
+
addWarning(
|
|
151
|
+
'INTERACTION_CASES_UNKNOWN_ACTION',
|
|
152
|
+
`${base}.links.actionId`,
|
|
153
|
+
`links.actionId "${actionId}" not found in design.actions.`,
|
|
154
|
+
'Align with an existing action id or add the action.',
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
const st = item?.links?.recordStatus
|
|
158
|
+
if (st && statuses.size && !statuses.has(st)) {
|
|
159
|
+
addWarning(
|
|
160
|
+
'INTERACTION_CASES_UNKNOWN_STATUS',
|
|
161
|
+
`${base}.links.recordStatus`,
|
|
162
|
+
`links.recordStatus "${st}" not in stateMatrix.recordStatuses.`,
|
|
163
|
+
`Use one of: ${[...statuses].join(', ')}`,
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (policy === 'required' && mutations >= 2 && items.length < mutations) {
|
|
169
|
+
addWarning(
|
|
170
|
+
'INTERACTION_CASES_FEW_ITEMS',
|
|
171
|
+
'interactionCases.items',
|
|
172
|
+
`Only ${items.length} interaction case(s) for ${mutations} mutation action(s).`,
|
|
173
|
+
'Consider one IC-* per critical mutation path (happy + conflict).',
|
|
174
|
+
)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (complex && !policy) {
|
|
178
|
+
addWarning(
|
|
179
|
+
'INTERACTION_CASES_POLICY_UNSET',
|
|
180
|
+
'interactionCases.policy',
|
|
181
|
+
'interactionCases present but policy not set to required or skip.',
|
|
182
|
+
'Set policy: required or policy: skip with skipReason.',
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
@@ -42,6 +42,16 @@ function auditLegacyInventory(inventoryPath, targetId) {
|
|
|
42
42
|
|
|
43
43
|
const rawText = fs.readFileSync(inventoryPath, 'utf8');
|
|
44
44
|
|
|
45
|
+
if (targetId?.startsWith('FLOW-') && !/##\s+4\.\s+User flows/i.test(rawText)) {
|
|
46
|
+
addGap(
|
|
47
|
+
'ADOPT_MISSING_USER_FLOW_SECTION',
|
|
48
|
+
'warning',
|
|
49
|
+
'adoption-inventory.md#user-flows',
|
|
50
|
+
'Inventory has no "## 4. User flows" section — cross-surface FLOW-* may be missing.',
|
|
51
|
+
'Re-run /adopt with user-flow discovery; see tpl-adoption-inventory.md (tiers A/B/C).'
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
45
55
|
if (targetId) {
|
|
46
56
|
if (!rawText.includes(targetId)) {
|
|
47
57
|
addGap(
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
import { applyOpenQaField } from './open-qa.mjs'
|
|
21
21
|
import { applyDesignApiFrom01 } from './hydrate-design-api.mjs'
|
|
22
22
|
import { isPlaceholderLegacy, projectBusinessPage } from './project-business-layout.mjs'
|
|
23
|
+
import { applyInteractionCasesToIr } from './interaction-cases.mjs'
|
|
23
24
|
|
|
24
25
|
export function bundlePageId(bundle) {
|
|
25
26
|
const v = bundle?.['page-id'] ?? bundle?.pageId ?? bundle?.id
|
|
@@ -39,6 +40,12 @@ export function buildIrFromBundle(bundle) {
|
|
|
39
40
|
const pageId = bundlePageId(bundle)
|
|
40
41
|
const legacy = { id: pageId, ...(bundle.legacy ?? {}) }
|
|
41
42
|
const design = buildDesignIr(bundle, designSpec, gen)
|
|
43
|
+
if (bundle.interactionCases != null) {
|
|
44
|
+
delete spec.interactionCases
|
|
45
|
+
applyInteractionCasesToIr(spec, design, bundle.interactionCases)
|
|
46
|
+
} else if (spec.interactionCases) {
|
|
47
|
+
delete spec.interactionCases
|
|
48
|
+
}
|
|
42
49
|
|
|
43
50
|
return { spec, legacy, design, designSpec, gen }
|
|
44
51
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Split bundle interactionCases → ir/spec (prose) + ir/design (sequenceDiagram only).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @param {unknown} interactionCases
|
|
7
|
+
* @returns {{ specPart: object|null, designPart: object|null }}
|
|
8
|
+
*/
|
|
9
|
+
export function partitionInteractionCasesForIr(interactionCases) {
|
|
10
|
+
if (!interactionCases || typeof interactionCases !== 'object') {
|
|
11
|
+
return { specPart: null, designPart: null }
|
|
12
|
+
}
|
|
13
|
+
const { policy, skipReason, items } = interactionCases
|
|
14
|
+
const specPart = {
|
|
15
|
+
...(policy != null ? { policy } : {}),
|
|
16
|
+
...(skipReason != null && String(skipReason).trim() ? { skipReason } : {}),
|
|
17
|
+
items: Array.isArray(items)
|
|
18
|
+
? items.map((item) => {
|
|
19
|
+
if (!item || typeof item !== 'object') return item
|
|
20
|
+
const { id, title, description, links } = item
|
|
21
|
+
return { id, title, description, links }
|
|
22
|
+
})
|
|
23
|
+
: [],
|
|
24
|
+
}
|
|
25
|
+
const designItems = Array.isArray(items)
|
|
26
|
+
? items
|
|
27
|
+
.filter((item) => item && typeof item === 'object' && item.sequenceDiagram)
|
|
28
|
+
.map(({ id, sequenceDiagram }) => ({ id, sequenceDiagram }))
|
|
29
|
+
: []
|
|
30
|
+
const designPart = designItems.length ? { items: designItems } : null
|
|
31
|
+
return { specPart, designPart }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {Record<string, unknown>} spec
|
|
36
|
+
* @param {Record<string, unknown>} design
|
|
37
|
+
* @param {unknown} interactionCases from bundle
|
|
38
|
+
*/
|
|
39
|
+
export function applyInteractionCasesToIr(spec, design, interactionCases) {
|
|
40
|
+
const { specPart, designPart } = partitionInteractionCasesForIr(interactionCases)
|
|
41
|
+
if (!specPart && !designPart) return
|
|
42
|
+
if (specPart && (specPart.policy || specPart.skipReason || specPart.items?.length)) {
|
|
43
|
+
spec.interactionCases = specPart
|
|
44
|
+
}
|
|
45
|
+
if (designPart) design.interactionCases = designPart
|
|
46
|
+
}
|
|
@@ -34,7 +34,7 @@ async function main() {
|
|
|
34
34
|
const mdRel = mdOut ? path.relative(process.cwd(), mdOut) : null
|
|
35
35
|
const genDir = mdOut ? path.dirname(mdOut) : null
|
|
36
36
|
const extra = genDir
|
|
37
|
-
? ['data-model.md', 'api.md']
|
|
37
|
+
? ['data-model.md', 'api.md', 'design.md']
|
|
38
38
|
.filter((name) => fs.existsSync(path.join(genDir, name)))
|
|
39
39
|
.map((name) => path.relative(process.cwd(), path.join(genDir, name)))
|
|
40
40
|
: []
|
|
@@ -4,10 +4,10 @@ Reads **plans** from sibling `base-tests/` → writes `tests/e2e/` on this FE re
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pnpm testcase:gen:dry --id TC-LOGIN-VALID
|
|
7
|
-
pnpm testcase:gen --id W-
|
|
7
|
+
pnpm testcase:gen --id W-ADM-AUTH-01
|
|
8
8
|
pnpm testcase:gen --id smoke
|
|
9
|
-
pnpm testcase:gen --testcase ../base-tests/cases/W-
|
|
10
|
-
pnpm testcase:gen --feature W-
|
|
9
|
+
pnpm testcase:gen --testcase ../base-tests/cases/W-ADM-AUTH-01/TC-LOGIN-VALID.yaml
|
|
10
|
+
pnpm testcase:gen --feature W-ADM-AUTH-01 # all TC-* under cases/{id}/
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Author/edit plans on **base-tests** (`/testcase`), not on this code repo.
|
|
@@ -37,8 +37,8 @@ function parseArgs(argv) {
|
|
|
37
37
|
|
|
38
38
|
if (!options.testcase && !options.feature && !options.id) {
|
|
39
39
|
throw new Error(
|
|
40
|
-
'Usage: pnpm testcase:gen --id W-
|
|
41
|
-
' pnpm testcase:gen --testcase <path> | --feature W-
|
|
40
|
+
'Usage: pnpm testcase:gen --id W-ADM-AUTH-01|TC-LOGIN-VALID|smoke|CMP-ADM-AUTH-01|SC-LOGIN\n' +
|
|
41
|
+
' pnpm testcase:gen --testcase <path> | --feature W-ADM-AUTH-01 (screen under base-tests/cases)',
|
|
42
42
|
)
|
|
43
43
|
}
|
|
44
44
|
|
|
@@ -145,7 +145,7 @@ export async function readTestcaseFile(testcasePath, opts = {}) {
|
|
|
145
145
|
/**
|
|
146
146
|
* List TC-*.yaml under base-tests/cases/{screenId}/ (prefer --id).
|
|
147
147
|
* @param {string} root FE repo root
|
|
148
|
-
* @param {string} screenId e.g. W-
|
|
148
|
+
* @param {string} screenId e.g. W-ADM-AUTH-01
|
|
149
149
|
*/
|
|
150
150
|
export async function listFeatureTestcases(root, screenId) {
|
|
151
151
|
const testsRoot = resolveProjectRoot(root, 'base-tests')
|
|
@@ -4,10 +4,10 @@ Reads **plans** from the tests hub → writes `tests/e2e/` in the FE project.
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pnpm testcase:gen:dry --id TC-LOGIN-VALID
|
|
7
|
-
pnpm testcase:gen --id W-
|
|
7
|
+
pnpm testcase:gen --id W-ADM-AUTH-01
|
|
8
8
|
pnpm testcase:gen --id smoke
|
|
9
|
-
pnpm testcase:gen --testcase "$FLOWGRID_TESTS_DOC/cases/W-
|
|
10
|
-
pnpm testcase:gen --feature W-
|
|
9
|
+
pnpm testcase:gen --testcase "$FLOWGRID_TESTS_DOC/cases/W-ADM-AUTH-01/TC-LOGIN-VALID.yaml"
|
|
10
|
+
pnpm testcase:gen --feature W-ADM-AUTH-01 # all TC-* under cases/{id}/
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Author/edit plans in the **tests hub** (`/testcase`), not in the code project.
|
|
@@ -51,8 +51,8 @@ function parseArgs(argv) {
|
|
|
51
51
|
|
|
52
52
|
if (!options.testcase && !options.feature && !options.id && !options.all) {
|
|
53
53
|
throw new Error(
|
|
54
|
-
'Usage: pnpm testcase:gen --id W-
|
|
55
|
-
' pnpm testcase:gen --testcase <path> | --feature W-
|
|
54
|
+
'Usage: pnpm testcase:gen --id W-ADM-AUTH-01|TC-LOGIN-VALID|smoke|CMP-ADM-AUTH-01|SC-LOGIN\n' +
|
|
55
|
+
' pnpm testcase:gen --testcase <path> | --feature W-ADM-AUTH-01 (screen under tests hub cases/)\n' +
|
|
56
56
|
' pnpm testcase:gen --all\n' +
|
|
57
57
|
' Optional: --tests-docs <hub> or FLOWGRID_TESTS_DOC',
|
|
58
58
|
)
|
|
@@ -158,7 +158,7 @@ export async function readTestcaseFile(testcasePath, opts = {}) {
|
|
|
158
158
|
/**
|
|
159
159
|
* List TC-*.yaml under the tests hub cases/{screenId}/ (prefer --id).
|
|
160
160
|
* @param {string} root FE repo root
|
|
161
|
-
* @param {string} screenId e.g. W-
|
|
161
|
+
* @param {string} screenId e.g. W-ADM-AUTH-01
|
|
162
162
|
*/
|
|
163
163
|
export async function listFeatureTestcases(root, screenId) {
|
|
164
164
|
const testsRoot = process.env.FLOWGRID_TESTS_DOC ? path.resolve(process.env.FLOWGRID_TESTS_DOC) : resolveProjectRoot(root, 'tests')
|
|
@@ -263,8 +263,8 @@ export function resolveHubId(repoRoot, id, mode = 'testcase') {
|
|
|
263
263
|
return { kind: 'code', id, paths: [spec], notes, codeDir }
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
// Module (e.g. CMP-ADM-
|
|
267
|
-
if (/^[A-
|
|
266
|
+
// Module (e.g. CMP-ADM-AUTH-01) → all code children
|
|
267
|
+
if (/^CMP-[A-Z0-9]+(?:-[A-Z0-9]+)+$/i.test(id)) {
|
|
268
268
|
const { index: docsIdx } = getDocs()
|
|
269
269
|
const cmp = (docsIdx.modules || []).find(
|
|
270
270
|
(c) => c.id === id || c.id.startsWith(id) || (c.slug && id.toLowerCase().includes(c.slug)),
|
|
@@ -319,6 +319,6 @@ export function resolveHubId(repoRoot, id, mode = 'testcase') {
|
|
|
319
319
|
}
|
|
320
320
|
|
|
321
321
|
throw new Error(
|
|
322
|
-
`Unrecognized id "${id}". Use W-|API-|UI-|FLOW-|DEP-|TC-|SC-* or hierarchical ID (e.g. cmp-adm-
|
|
322
|
+
`Unrecognized id "${id}". Use W-|API-|UI-|FLOW-|DEP-|TC-|SC-* or hierarchical ID (e.g. cmp-adm-auth-01-01-01), or module ID (CMP-ADM-AUTH-01), or suite id (smoke, regression-auth).`,
|
|
323
323
|
)
|
|
324
324
|
}
|
|
@@ -12,7 +12,7 @@ Run after `npm run codegen:api:dry` / `flowgrid api-gen:dry` and after implement
|
|
|
12
12
|
|
|
13
13
|
## Target / ID Resolution Rule
|
|
14
14
|
|
|
15
|
-
- User prompt MAY specify a function ID, API slug, or short name (e.g. `API-AUTH-001`, `login`, `CMP-ADM-
|
|
15
|
+
- User prompt MAY specify a function ID, API slug, or short name (e.g. `API-AUTH-001`, `login`, `CMP-ADM-DEMO-01`).
|
|
16
16
|
- Agent MUST resolve **`…/api/<seq>/01-backend-spec.yaml`** via `FLOWGRID_DOCS_ROOT` or `flowgrid_docs_route` (screen leaf `CMP-*/<NN…>/api/<seq>/`, or `common/yaml/<slug>/`, or external-channel surface `…/api/<seq>/`).
|
|
17
17
|
- **Read the entire `01-backend-spec.yaml`**. Do **not** use `ir/design.yaml` or `ir/spec.yaml` as BE contract.
|
|
18
18
|
- Compare generated routes/code against that 01 file. Missing 01 → STOP, hand off to docs `/grill-api-spec`.
|
|
@@ -13,7 +13,7 @@ before committing generated test code.
|
|
|
13
13
|
|
|
14
14
|
## Target / ID Resolution Rule
|
|
15
15
|
|
|
16
|
-
- User prompt MAY specify a function ID, API slug, or short name (e.g. `API-AUTH-001`, `login`, `CMP-ADM-
|
|
16
|
+
- User prompt MAY specify a function ID, API slug, or short name (e.g. `API-AUTH-001`, `login`, `CMP-ADM-DEMO-01`).
|
|
17
17
|
- Agent MUST resolve **`…/api/<seq>/01-backend-spec.yaml`** via `FLOWGRID_DOCS_ROOT` or `flowgrid_docs_route`.
|
|
18
18
|
- **Read the entire `01-backend-spec.yaml`**. Do **not** use `ir/design.yaml` or `ir/spec.yaml` as BE contract.
|
|
19
19
|
- Compare generated unit tests against that 01 file. Missing 01 → STOP, hand off to docs `/grill-api-spec`.
|