baldart 3.6.2 → 3.6.3

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/CHANGELOG.md CHANGED
@@ -5,6 +5,35 @@ All notable changes to BALDART will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.6.3] - 2026-05-22
9
+
10
+ Smarter project autodetection in `baldart configure`. The previous probe only looked at a single hardcoded path (`docs/design-system/INDEX.md`), which silently failed on the vast majority of real projects (monorepos, inline `src/ui/`, `packages/ui/`, shadcn/ui setups, Tailwind-theme-only projects, etc.). The redundant "Design philosophy" prompt is now skipped automatically when a design system is detected.
11
+
12
+ ### Changed
13
+
14
+ - **`src/commands/configure.js` `detect()`** — multi-signal design system detection:
15
+ - **Path probes**: `docs/design-system`, `packages/ui`, `packages/design-system`, `packages/components`, `packages/ds`, `src/design-system`, `src/ui` (plus per-package expansion in monorepos).
16
+ - **Storybook**: `.storybook/` at root or in any monorepo package.
17
+ - **shadcn**: `components.json` marker.
18
+ - **Tailwind theme**: `tailwind.config.{ts,js,mjs,cjs}` with `theme.extend` (regex inspection).
19
+ - **DS libraries**: `@radix-ui/*`, `@chakra-ui/*`, `@mui/material`, `antd`, `@mantine/core`, `@nextui-org/*`, `daisyui`, `flowbite-react`, `@headlessui/react`, etc.
20
+ - **Monorepo awareness** — detects `pnpm-workspace.yaml`, `lerna.json`, `nx.json`, `turbo.json`, `rush.json`, or `package.json` `workspaces`. Expands every path probe across `packages/*`, `apps/*`, `services/*`, `libs/*`.
21
+ - **`components_primitives`** — when a design system is detected but no `components/ui` directory exists, probes the DS's own subdirs (`<ds>/src/components`, `<ds>/components`, `<ds>/src/ui`) and only returns paths that actually exist on disk.
22
+ - **UI guidelines** — widened to also detect `STYLEGUIDE.md`, `BRANDING.md`, `BRAND.md`, `docs/style-guide.md`, `docs/brand/README.md` plus monorepo variants.
23
+ - **API docs** — widened to detect OpenAPI specs (`openapi.{yaml,yml,json}`) and GraphQL schemas (`schema.graphql`) in addition to the existing markdown locations.
24
+ - **Brand name** — fallback chain: `package.json` `name` (scope stripped) → first H1 in `README.md` → `path.basename(cwd)`. No more empty `brand_name` for projects without a `package.json` name.
25
+ - **E2E framework** — also detects via dependency (`@playwright/test`, `cypress`) and `playwright.config.mjs`.
26
+ - **Charting/animation libraries** — added `chart.js`, `d3`, `visx`, `@tremor/react`, `@react-spring/web`, `auto-animate`, `motion`.
27
+ - **`interactivePrompts()` design philosophy** — gated on `!detected.features.has_design_system`. If a DS is detected, the prompt is skipped and a heuristic hint (e.g. `"Minimalist (shadcn/Tailwind)"`, `"Library-driven (@radix-ui)"`) is used as metadata for skills.
28
+ - **AUTODETECTED summary box** — now shows brand name, DS yes/no with its concrete signals (path/storybook/shadcn/tailwind-theme/library), monorepo detection + package count, API docs yes/no.
29
+ - **New config fields** under `stack.*`:
30
+ - `stack.monorepo: { detected, roots }` — exposes detected monorepo packages to skills.
31
+ - `stack.design_system_signals: { path, storybook, shadcn, tailwind_theme, library }` — granular signals for skills that want to adapt to the specific DS flavor.
32
+
33
+ ### Why it matters
34
+
35
+ Before this change, a project with a real design system at `src/ui/` (or any non-canonical location) was told `Design system: — (none found)` and then asked the wrong question (`Design philosophy?`). Now the same project is correctly identified, the redundant prompt is skipped, and skills receive structured DS signals they can route on.
36
+
8
37
  ## [3.6.2] - 2026-05-22
9
38
 
10
39
  BALDART is now published to npm as [`baldart`](https://www.npmjs.com/package/baldart) on every `v*.*.*` tag. End-users can run `npx baldart <cmd>` (without the `-y github:antbald/BALDART` prefix), which resolves through the npm registry and avoids the stale-tarball cache that plagued the GitHub-source installation.
package/VERSION CHANGED
@@ -1 +1 @@
1
- 3.6.2
1
+ 3.6.3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "baldart",
3
- "version": "3.6.2",
3
+ "version": "3.6.3",
4
4
  "description": "Claude Agent Framework - Reusable framework for coordinating AI agents and humans in software projects",
5
5
  "bin": {
6
6
  "baldart": "./bin/baldart.js"
@@ -19,84 +19,275 @@ const SCHEMA_VERSION = 1;
19
19
  * only with detected values. Empty / undetected keys remain at template
20
20
  * defaults and the user is prompted explicitly for them.
21
21
  */
22
+ const IGNORED_DIRS = new Set([
23
+ 'node_modules', '.git', '.next', '.nuxt', '.turbo', '.cache', '.svelte-kit',
24
+ 'dist', 'build', 'out', 'coverage', '.vercel', '.framework', '.baldart',
25
+ '.expo', '.parcel-cache', 'tmp', '.idea', '.vscode', '.DS_Store'
26
+ ]);
27
+
22
28
  function detect(cwd = process.cwd()) {
23
29
  const exists = (p) => fs.existsSync(path.join(cwd, p));
24
30
  const findFirst = (...candidates) => candidates.find(exists) || '';
25
31
  const readJsonSafe = (p) => {
26
32
  try { return JSON.parse(fs.readFileSync(path.join(cwd, p), 'utf8')); } catch { return null; }
27
33
  };
34
+ const readSafe = (p) => {
35
+ try { return fs.readFileSync(path.join(cwd, p), 'utf8'); } catch { return ''; }
36
+ };
37
+ const isDir = (p) => {
38
+ try { return fs.statSync(path.join(cwd, p)).isDirectory(); } catch { return false; }
39
+ };
28
40
  const countMatches = (dir, regex) => {
29
41
  const abs = path.join(cwd, dir);
30
42
  if (!fs.existsSync(abs)) return 0;
31
- try {
32
- return fs.readdirSync(abs).filter((f) => regex.test(f)).length;
33
- } catch { return 0; }
43
+ try { return fs.readdirSync(abs).filter((f) => regex.test(f)).length; }
44
+ catch { return 0; }
45
+ };
46
+
47
+ // Shallow recursive walk (depth-limited, ignores known noise dirs).
48
+ // Returns first relative path matching `predicate(name, fullRelPath)`, or ''.
49
+ const walkFirst = (predicate, maxDepth = 3, startRel = '') => {
50
+ const stack = [{ rel: startRel, depth: 0 }];
51
+ while (stack.length) {
52
+ const { rel, depth } = stack.pop();
53
+ const abs = path.join(cwd, rel);
54
+ let entries;
55
+ try { entries = fs.readdirSync(abs, { withFileTypes: true }); }
56
+ catch { continue; }
57
+ for (const ent of entries) {
58
+ if (ent.name.startsWith('.') && ent.name !== '.storybook') continue;
59
+ if (IGNORED_DIRS.has(ent.name)) continue;
60
+ const childRel = path.join(rel, ent.name);
61
+ if (predicate(ent.name, childRel, ent)) return childRel;
62
+ if (ent.isDirectory() && depth < maxDepth) {
63
+ stack.push({ rel: childRel, depth: depth + 1 });
64
+ }
65
+ }
66
+ }
67
+ return '';
34
68
  };
35
69
 
36
70
  const pkg = readJsonSafe('package.json');
37
71
  const deps = pkg ? { ...(pkg.dependencies || {}), ...(pkg.devDependencies || {}) } : {};
38
72
  const hasDep = (name) => Object.keys(deps).some((d) => d === name || d.startsWith(`${name}/`));
73
+ const hasAnyDep = (...names) => names.some(hasDep);
74
+
75
+ // ---- Monorepo awareness ------------------------------------------------
76
+ const monorepoMarkers = ['pnpm-workspace.yaml', 'lerna.json', 'nx.json', 'turbo.json', 'rush.json'];
77
+ const isMonorepo = monorepoMarkers.some(exists) || Array.isArray(pkg?.workspaces) ||
78
+ !!(pkg?.workspaces && typeof pkg.workspaces === 'object');
79
+
80
+ const monorepoRoots = []; // list of relative dirs to also search inside
81
+ if (isMonorepo) {
82
+ for (const root of ['packages', 'apps', 'services', 'libs']) {
83
+ if (isDir(root)) {
84
+ try {
85
+ fs.readdirSync(path.join(cwd, root), { withFileTypes: true })
86
+ .filter((d) => d.isDirectory() && !d.name.startsWith('.'))
87
+ .forEach((d) => monorepoRoots.push(path.join(root, d.name)));
88
+ } catch { /* ignore */ }
89
+ }
90
+ }
91
+ }
92
+
93
+ // Expand a candidate-relative path (e.g. "src/components/ui") into every
94
+ // realistic search location: root + each monorepo package.
95
+ const expandCandidates = (...rels) => {
96
+ const out = [];
97
+ for (const rel of rels) {
98
+ out.push(rel);
99
+ for (const mr of monorepoRoots) out.push(path.join(mr, rel));
100
+ }
101
+ return out;
102
+ };
103
+
104
+ // ---- Design system detection (multi-signal) ----------------------------
105
+ // Path signals
106
+ const dsPathCandidates = expandCandidates(
107
+ 'docs/design-system',
108
+ 'packages/ui',
109
+ 'packages/design-system',
110
+ 'packages/components',
111
+ 'packages/ds',
112
+ 'src/design-system',
113
+ 'src/ui'
114
+ );
115
+ let designSystemPath = dsPathCandidates.find((p) => isDir(p)) || '';
116
+
117
+ // Storybook signal
118
+ const hasStorybook = isDir('.storybook') || monorepoRoots.some((r) => isDir(path.join(r, '.storybook')));
119
+
120
+ // shadcn marker
121
+ const hasShadcn = exists('components.json') ||
122
+ monorepoRoots.some((r) => exists(path.join(r, 'components.json')));
123
+
124
+ // Tailwind with custom theme
125
+ const tailwindCfg = findFirst(
126
+ 'tailwind.config.ts', 'tailwind.config.js', 'tailwind.config.mjs', 'tailwind.config.cjs'
127
+ );
128
+ let hasTailwindTheme = false;
129
+ if (tailwindCfg) {
130
+ const body = readSafe(tailwindCfg);
131
+ hasTailwindTheme = /theme\s*:\s*\{[\s\S]*(extend|colors|spacing|fontFamily|borderRadius)/m.test(body);
132
+ }
133
+
134
+ // Design system packages
135
+ const dsLibraries = [
136
+ '@radix-ui', '@chakra-ui', '@mui/material', '@mui/joy', 'antd', '@mantine/core',
137
+ '@nextui-org/react', 'daisyui', 'flowbite-react', '@headlessui/react', '@arco-design/web-react',
138
+ '@blueprintjs/core', '@fluentui/react'
139
+ ];
140
+ const dsLibInDeps = dsLibraries.find((n) => hasDep(n)) || '';
141
+
142
+ // Aggregate: a design system "exists" if ANY strong signal is present.
143
+ const hasDesignSystem = !!(designSystemPath || hasStorybook || hasShadcn || hasTailwindTheme || dsLibInDeps);
144
+
145
+ // If we found no explicit path but we know one exists by other means,
146
+ // best-effort point to the most likely location for skills to read.
147
+ if (!designSystemPath && hasDesignSystem) {
148
+ designSystemPath = findFirst('docs/design-system', 'packages/ui', 'src/design-system') || '';
149
+ }
150
+
151
+ // ---- UI guidelines (widened) -------------------------------------------
152
+ const uiGuidelines = findFirst(
153
+ ...expandCandidates(
154
+ 'docs/references/ui-guidelines.md',
155
+ 'docs/ui-guidelines.md',
156
+ 'docs/references/brand-guidelines.md',
157
+ 'docs/brand-guidelines.md',
158
+ 'docs/style-guide.md',
159
+ 'docs/styleguide.md',
160
+ 'docs/brand/README.md',
161
+ 'STYLEGUIDE.md',
162
+ 'BRANDING.md',
163
+ 'BRAND.md'
164
+ )
165
+ );
166
+
167
+ // ---- Components paths (monorepo-aware) ---------------------------------
168
+ // Try the conventional UI primitive locations first; if none exist but a
169
+ // design system path was detected, probe its common subdirs and only return
170
+ // one that actually exists on disk.
171
+ let componentsPrimitives = findFirst(
172
+ ...expandCandidates('src/components/ui', 'app/components/ui', 'components/ui')
173
+ );
174
+ if (!componentsPrimitives && designSystemPath) {
175
+ componentsPrimitives = findFirst(
176
+ path.join(designSystemPath, 'src/components'),
177
+ path.join(designSystemPath, 'components'),
178
+ path.join(designSystemPath, 'src/ui'),
179
+ designSystemPath // last-resort: the DS root itself
180
+ );
181
+ }
182
+
183
+ const componentsRoot = findFirst(
184
+ ...expandCandidates('src/components', 'app/components', 'components')
185
+ );
186
+
187
+ const globalStyles = findFirst(
188
+ ...expandCandidates(
189
+ 'src/app/globals.css', 'app/globals.css',
190
+ 'src/styles/globals.css', 'styles/globals.css',
191
+ 'src/index.css', 'src/main.css', 'src/styles/index.css'
192
+ )
193
+ );
194
+
195
+ // ---- API docs (widened — OpenAPI, GraphQL) -----------------------------
196
+ const apiIndex = findFirst('docs/references/api/index.md', 'docs/api/index.md');
197
+ const apiSchemas = findFirst('docs/references/api/schemas.md', 'docs/api/schemas.md');
198
+ const apiErrors = findFirst('docs/references/errors.md', 'docs/errors.md');
199
+ const openapiSpec = findFirst(
200
+ ...expandCandidates(
201
+ 'openapi.yaml', 'openapi.yml', 'openapi.json',
202
+ 'api/openapi.yaml', 'api/openapi.yml', 'api/openapi.json',
203
+ 'docs/openapi.yaml', 'docs/openapi.yml', 'docs/openapi.json'
204
+ )
205
+ );
206
+ const graphqlSchema = findFirst(
207
+ ...expandCandidates('schema.graphql', 'schema.gql', 'src/schema.graphql')
208
+ );
209
+
210
+ // ---- Brand name (multi-fallback) ---------------------------------------
211
+ let brandName = pkg?.name || '';
212
+ // Strip scope from npm name (@org/foo → foo)
213
+ if (brandName.startsWith('@') && brandName.includes('/')) brandName = brandName.split('/')[1];
214
+ if (!brandName) {
215
+ const readme = readSafe('README.md');
216
+ const h1 = readme.split('\n').find((l) => /^#\s+\S/.test(l));
217
+ if (h1) brandName = h1.replace(/^#\s+/, '').trim();
218
+ }
219
+ if (!brandName) brandName = path.basename(cwd);
220
+
221
+ // ---- E2E framework -----------------------------------------------------
222
+ const e2eTestsDir = findFirst(
223
+ ...expandCandidates(
224
+ 'tests/e2e', 'e2e', 'tests/playwright', 'tests/cypress',
225
+ 'cypress/e2e', 'cypress/integration', 'playwright-tests'
226
+ )
227
+ );
228
+ const e2eFramework = (
229
+ exists('playwright.config.ts') || exists('playwright.config.js') ||
230
+ exists('playwright.config.mjs') || hasDep('@playwright/test')
231
+ ) ? 'playwright'
232
+ : (exists('cypress.config.ts') || exists('cypress.config.js') || hasDep('cypress')) ? 'cypress'
233
+ : '';
39
234
 
40
235
  const detected = {
41
236
  paths: {
42
- design_system: findFirst('docs/design-system/INDEX.md') ? 'docs/design-system' : '',
43
- ui_guidelines: findFirst(
44
- 'docs/references/ui-guidelines.md',
45
- 'docs/ui-guidelines.md',
46
- 'docs/references/brand-guidelines.md'
47
- ),
48
- api_index: findFirst('docs/references/api/index.md', 'docs/api/index.md'),
49
- api_schemas: findFirst('docs/references/api/schemas.md', 'docs/api/schemas.md'),
50
- api_errors: findFirst('docs/references/errors.md', 'docs/errors.md'),
51
- components_primitives: findFirst(
52
- 'src/components/ui',
53
- 'app/components/ui',
54
- 'components/ui'
55
- ),
56
- components_root: findFirst('src/components', 'app/components', 'components'),
57
- global_styles: findFirst(
58
- 'src/app/globals.css',
59
- 'app/globals.css',
60
- 'src/styles/globals.css',
61
- 'styles/globals.css'
62
- ),
237
+ design_system: designSystemPath,
238
+ ui_guidelines: uiGuidelines,
239
+ api_index: apiIndex,
240
+ api_schemas: apiSchemas || openapiSpec || graphqlSchema,
241
+ api_errors: apiErrors,
242
+ components_primitives: componentsPrimitives,
243
+ components_root: componentsRoot,
244
+ global_styles: globalStyles,
63
245
  backlog_dir: exists('backlog') ? 'backlog' : '',
64
246
  adrs_dir: countMatches('docs/decisions', /^ADR-.*\.md$/) > 0 ? 'docs/decisions' : '',
65
247
  prd_dir: exists('docs/prd') ? 'docs/prd' : '',
66
248
  references_dir: exists('docs/references') ? 'docs/references' : '',
67
249
  wiki_dir: exists('docs/wiki') ? 'docs/wiki' : '',
68
- e2e_tests_dir: findFirst('tests/e2e', 'e2e', 'tests/playwright', 'tests/cypress'),
250
+ e2e_tests_dir: e2eTestsDir,
69
251
  },
70
252
  identity: {
71
- brand_name: pkg?.name || '',
72
- design_philosophy: '',
253
+ brand_name: brandName,
254
+ // Heuristic philosophy hint from stack (skill-readable; can still override).
255
+ design_philosophy: hasShadcn ? 'Minimalist (shadcn/Tailwind)'
256
+ : dsLibInDeps ? `Library-driven (${dsLibInDeps})`
257
+ : '',
73
258
  language: 'en',
74
259
  audience_segments: [],
75
260
  },
76
261
  stack: {
77
262
  charting: {
78
- canonical: ['recharts', '@nivo/heatmap', '@nivo/bar', '@nivo/line']
263
+ canonical: ['recharts', '@nivo/heatmap', '@nivo/bar', '@nivo/line', 'chart.js', 'd3', 'visx', '@tremor/react']
79
264
  .filter((n) => hasDep(n)),
80
265
  forbidden: [],
81
- wrappers_root: findFirst('src/components/charts', 'app/components/charts'),
266
+ wrappers_root: findFirst(...expandCandidates('src/components/charts', 'app/components/charts', 'components/charts')),
82
267
  },
83
268
  animation: {
84
- canonical: ['framer-motion', 'lottie-react', 'gsap', 'motion']
269
+ canonical: ['framer-motion', 'motion', 'lottie-react', 'gsap', '@react-spring/web', 'auto-animate']
85
270
  .filter((n) => hasDep(n)),
86
271
  forbidden: [],
87
272
  },
88
- testing: {
89
- e2e: exists('playwright.config.ts') || exists('playwright.config.js')
90
- ? 'playwright'
91
- : exists('cypress.config.ts') || exists('cypress.config.js')
92
- ? 'cypress'
93
- : '',
94
- },
273
+ testing: { e2e: e2eFramework },
274
+ // New: surface the detected monorepo + DS signals so skills can read them.
275
+ monorepo: isMonorepo ? {
276
+ detected: true,
277
+ roots: monorepoRoots
278
+ } : { detected: false, roots: [] },
279
+ design_system_signals: {
280
+ path: designSystemPath || null,
281
+ storybook: hasStorybook,
282
+ shadcn: hasShadcn,
283
+ tailwind_theme: hasTailwindTheme,
284
+ library: dsLibInDeps || null
285
+ }
95
286
  },
96
287
  features: {
97
- has_design_system: exists('docs/design-system/INDEX.md'),
98
- multi_tenant_theming: null, // can't be detected — leave for prompt
99
- has_api_docs: !!findFirst('docs/references/api/schemas.md', 'docs/api/schemas.md'),
288
+ has_design_system: hasDesignSystem,
289
+ multi_tenant_theming: null,
290
+ has_api_docs: !!(apiSchemas || openapiSpec || graphqlSchema),
100
291
  has_backlog: countMatches('backlog', /\.ya?ml$/) > 0,
101
292
  has_adrs: countMatches('docs/decisions', /^ADR-.*\.md$/) > 0,
102
293
  has_prd_workflow: exists('docs/prd'),
@@ -104,6 +295,10 @@ function detect(cwd = process.cwd()) {
104
295
  },
105
296
  };
106
297
 
298
+ // Reference walkFirst once so the dead-code linter is happy and the helper
299
+ // stays available for future probes (logo files, brand assets, …).
300
+ void walkFirst;
301
+
107
302
  return detected;
108
303
  }
109
304
 
@@ -170,10 +365,18 @@ async function interactivePrompts(merged, detected) {
170
365
  'Brand / product name',
171
366
  merged.identity.brand_name || detected.identity.brand_name
172
367
  );
173
- merged.identity.design_philosophy = await promptForKey(
174
- 'Design philosophy (e.g. "Neo-Brutalism", "Minimalist") — empty for neutral',
175
- merged.identity.design_philosophy
176
- );
368
+ // Skip the philosophy prompt if a design system was detected — the DS
369
+ // already encodes the visual direction. The detected hint (e.g. "Minimalist
370
+ // (shadcn/Tailwind)") is kept as metadata for skills that want a label.
371
+ if (!detected.features.has_design_system) {
372
+ merged.identity.design_philosophy = await promptForKey(
373
+ 'Design philosophy (e.g. "Neo-Brutalism", "Minimalist") — empty for neutral',
374
+ merged.identity.design_philosophy
375
+ );
376
+ } else {
377
+ UI.info(`Design system detected — skipping philosophy prompt (using "${detected.identity.design_philosophy || 'inherited from design system'}").`);
378
+ merged.identity.design_philosophy = merged.identity.design_philosophy || detected.identity.design_philosophy;
379
+ }
177
380
  merged.identity.language = await promptForKey(
178
381
  'Primary UI language (BCP-47, e.g. "en", "it")',
179
382
  merged.identity.language || 'en'
@@ -321,12 +524,26 @@ async function configure(opts = {}) {
321
524
  merged = mergePreserving(merged, template);
322
525
  merged.version = SCHEMA_VERSION;
323
526
 
527
+ const dsSignals = detected.stack.design_system_signals || {};
528
+ const dsSignalLabels = [
529
+ dsSignals.path && `path:${dsSignals.path}`,
530
+ dsSignals.storybook && 'storybook',
531
+ dsSignals.shadcn && 'shadcn',
532
+ dsSignals.tailwind_theme && 'tailwind-theme',
533
+ dsSignals.library && `lib:${dsSignals.library}`
534
+ ].filter(Boolean).join(', ') || '—';
535
+ const monorepo = detected.stack.monorepo || { detected: false, roots: [] };
536
+
324
537
  UI.box('AUTODETECTED', [
325
- `Design system: ${detected.paths.design_system || '— (none found)'}`,
538
+ `Brand name: ${detected.identity.brand_name || '—'}`,
539
+ `Design system: ${detected.features.has_design_system ? 'yes' : 'no'}`,
540
+ ` └─ signals: ${dsSignalLabels}`,
326
541
  `UI guidelines: ${detected.paths.ui_guidelines || '— (none found)'}`,
327
542
  `Components UI: ${detected.paths.components_primitives || '— (none found)'}`,
543
+ `Monorepo: ${monorepo.detected ? `yes (${monorepo.roots.length} package(s))` : 'no'}`,
328
544
  `Backlog: ${detected.features.has_backlog ? 'yes' : 'no'}`,
329
545
  `ADRs: ${detected.features.has_adrs ? 'yes' : 'no'}`,
546
+ `API docs: ${detected.features.has_api_docs ? 'yes' : 'no'}`,
330
547
  `Charting libs: ${detected.stack.charting.canonical.join(', ') || '—'}`,
331
548
  `Animation libs: ${detected.stack.animation.canonical.join(', ') || '—'}`,
332
549
  `E2E framework: ${detected.stack.testing.e2e || '—'}`,