@geraldmaron/construct 1.3.1 → 1.4.0

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 (50) hide show
  1. package/README.md +2 -1
  2. package/bin/construct +61 -15
  3. package/lib/a11y-audit.mjs +104 -0
  4. package/lib/artifact-completion-states.mjs +35 -0
  5. package/lib/artifact-completion.mjs +66 -0
  6. package/lib/artifact-gate-levels.mjs +69 -0
  7. package/lib/artifact-manifest.mjs +21 -0
  8. package/lib/artifact-release-gate.mjs +19 -1
  9. package/lib/artifact-workflow.mjs +16 -0
  10. package/lib/brand-contrast.mjs +60 -0
  11. package/lib/brand-tokens.mjs +14 -0
  12. package/lib/certification/artifact-gates.mjs +6 -1
  13. package/lib/cli-commands.mjs +5 -3
  14. package/lib/codex-config.mjs +7 -1
  15. package/lib/deck-export-pptx.mjs +223 -18
  16. package/lib/diagram-quality.mjs +161 -0
  17. package/lib/document-export.mjs +1 -1
  18. package/lib/env-config.mjs +8 -8
  19. package/lib/export-validate.mjs +106 -0
  20. package/lib/gates-audit.mjs +34 -0
  21. package/lib/health-check.mjs +23 -9
  22. package/lib/hooks/session-start.mjs +2 -1
  23. package/lib/init-unified.mjs +5 -3
  24. package/lib/mcp/server.mjs +52 -2
  25. package/lib/mcp/tool-recovery.mjs +56 -0
  26. package/lib/mcp/tools/web-search.mjs +94 -0
  27. package/lib/mcp-manager.mjs +33 -21
  28. package/lib/mcp-platform-config.mjs +44 -9
  29. package/lib/models/provider-poll.mjs +32 -6
  30. package/lib/orchestration/readiness.mjs +183 -0
  31. package/lib/output-quality.mjs +90 -0
  32. package/lib/pixel-regression.mjs +172 -0
  33. package/lib/providers/op-run.mjs +6 -5
  34. package/lib/providers/secret-audit-wiring.mjs +32 -0
  35. package/lib/providers/secret-resolver.mjs +90 -20
  36. package/lib/publish.mjs +64 -1
  37. package/lib/render-evidence.mjs +55 -0
  38. package/lib/render-pipeline.mjs +136 -0
  39. package/lib/service-manager.mjs +31 -10
  40. package/lib/templates/doc-presentation.mjs +24 -0
  41. package/lib/tracking-surfaces.mjs +36 -0
  42. package/lib/visual-review.mjs +70 -0
  43. package/package.json +1 -1
  44. package/scripts/sync-specialists.mjs +3 -3
  45. package/specialists/artifact-manifest.json +18 -0
  46. package/specialists/artifact-manifest.schema.json +46 -2
  47. package/templates/distribution/construct-brand.typ +1 -2
  48. package/templates/distribution/construct-deck.html +34 -34
  49. package/templates/distribution/construct-pdf.typ +1 -1
  50. package/templates/distribution/construct-web.html +8 -10
@@ -53,11 +53,16 @@ export function validateAllGoldenArtifactGates({ rootDir } = {}) {
53
53
  return { pass: errors.length === 0, results, errors, matrix: artifactGateMatrix({ rootDir: root }) };
54
54
  }
55
55
 
56
+ // The committed gate matrix is a deterministic projection of the rootDir; no
57
+ // wall-clock field, so regenerating it (the artifact-gates test writes it on every
58
+ // run) yields a byte-identical file instead of a timestamp-only diff on a tracked
59
+ // artifact. The matrix content is the signal; when it changes, the diff is real.
60
+
56
61
  export function writeArtifactGateMatrixDoc({ rootDir } = {}) {
57
62
  const root = findConstructRoot(rootDir);
58
63
  const out = path.join(root, 'tests', 'certification', 'artifacts', 'gate-matrix.json');
59
64
  fs.mkdirSync(path.dirname(out), { recursive: true });
60
- const payload = { generatedAt: new Date().toISOString(), matrix: artifactGateMatrix({ rootDir: root }) };
65
+ const payload = { matrix: artifactGateMatrix({ rootDir: root }) };
61
66
  fs.writeFileSync(out, `${JSON.stringify(payload, null, 2)}\n`);
62
67
  return out;
63
68
  }
@@ -346,6 +346,7 @@ export const CLI_COMMANDS = [
346
346
  { flag: '--recording=<name>', desc: 'Playwright recording manifest (repeatable)' },
347
347
  { flag: '--figures', desc: 'Render d2/mermaid via diagram filter (default on)' },
348
348
  { flag: '--no-figures', desc: 'Skip diagram filter' },
349
+ { flag: '--preview', desc: 'Render the export to images and report what was verified' },
349
350
  { flag: '--no-gate', desc: 'Skip artifact release gate (escape hatch only)' },
350
351
  { flag: '--source-only', desc: 'Write sources only' },
351
352
  { flag: '--strict', desc: 'Exit 2 when toolchain or release gate fails (default)' },
@@ -453,11 +454,12 @@ export const CLI_COMMANDS = [
453
454
  emoji: '🎼',
454
455
  category: 'Models & Integrations',
455
456
  core: false,
456
- description: 'Construct-owned local orchestration runtime, in-process or against the local daemon (--remote)',
457
- usage: 'construct orchestrate <run|status> [options] [--remote]',
457
+ description: 'Construct-owned local orchestration runtime and readiness preflight',
458
+ usage: 'construct orchestrate <run|status|preflight> [options] [--remote]',
458
459
  subcommands: [
459
460
  { name: 'run "<request>" [--strategy S] [--host H] [--worker-backend provider] [--no-construct] [--no-execute] [--json] [--remote]', desc: 'Plan and run a request through a Construct-owned specialist chain; --remote drives the local daemon over HTTP' },
460
461
  { name: 'status [run-id] [--json] [--remote]', desc: 'Inspect a run, or list recent runs (locally or from the daemon)' },
462
+ { name: 'preflight [--host H] [--json] [--no-probe]', desc: 'Verify orchestration tool attachment/readiness and return a typed reason plus recovery step' },
461
463
  ],
462
464
  },
463
465
  {
@@ -1144,7 +1146,7 @@ export const CLI_COMMANDS = [
1144
1146
  { name: 'team:remove', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Remove a team from the unified registry', usage: 'construct team:remove <team-id> [--force]' },
1145
1147
  { name: 'specialist:add', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Add a new specialist to a team', usage: 'construct specialist:add <specialist-id> --team <team-id>' },
1146
1148
  { name: 'specialist:remove', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Remove a specialist from the unified registry', usage: 'construct specialist:remove <specialist-id> [--force]' },
1147
- { name: 'oracle', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Oracle meta-controller — fleet health review and bounded-auto maintenance', usage: 'construct oracle start|status|review|pending|approve|gaps|reconcile' },
1149
+ { name: 'oracle', emoji: '🔮', category: 'Core', core: true, description: 'Oracle meta-controller — fleet health review and bounded-auto maintenance', usage: 'construct oracle start|status|review|pending|approve|gaps|reconcile' },
1148
1150
  { name: 'matrix', category: 'Internal', core: false, internal: true, surface: 'internal', description: 'Living dependency matrix — build/inspect the typed file↔capability↔workflow↔test graph', usage: 'construct matrix build|stat|query <node-id> [--json]' },
1149
1151
  { name: 'impact', category: 'Diagnostics', core: false, internal: false, surface: 'thin-cli', description: 'Change-impact analysis — map changed files to affected tests, capabilities, and workflows', usage: 'construct impact [files…] [--stdin] [--run] [--json]' },
1150
1152
  { name: 'rules', category: 'Diagnostics', core: false, internal: false, surface: 'thin-cli', description: 'Rule and hook reference telemetry rollup', usage: 'construct rules usage [--since=30d]' },
@@ -70,12 +70,18 @@ function resolveArgs(args, resolvedValues) {
70
70
  );
71
71
  }
72
72
 
73
+ // Codex has no runtime env interpolation (see docs/guides/reference/mcp-tools.md),
74
+ // so a stdio `env` block must hold literal values, not host env-ref forms — the
75
+ // value-to-reference flip is deliberately not applied here. An unresolved `__NAME__`
76
+ // template or a 1Password `op://` reference would be passed verbatim to the child and
77
+ // fail, so both are dropped rather than written.
78
+
73
79
  function resolveEnv(env, resolvedValues) {
74
80
  if (!env || typeof env !== 'object' || Array.isArray(env)) return {};
75
81
  return Object.fromEntries(
76
82
  Object.entries(env)
77
83
  .map(([key, value]) => [key, typeof value === 'string' ? resolveTemplateString(value, resolvedValues) : value])
78
- .filter(([, value]) => typeof value !== 'string' || !value.includes('__')),
84
+ .filter(([, value]) => typeof value !== 'string' || (!value.includes('__') && !value.startsWith('op://'))),
79
85
  );
80
86
  }
81
87
 
@@ -15,6 +15,7 @@ import path from 'node:path';
15
15
  import { BRAND_TOKENS, INK } from './brand-tokens.mjs';
16
16
  import { createPptxGenerator, embedBundledSansInPptx, embedBundledMonoInPptx, BRAND_SANS_FAMILY } from './brand-fonts.mjs';
17
17
  import { parseArtifactMetadata } from './publish-template.mjs';
18
+ import { injectMermaidBrandTheme, injectD2DistributionDefaults, buildDistributionDiagramEnv } from './diagram-export.mjs';
18
19
 
19
20
  const require = createRequire(import.meta.url);
20
21
  const MODULE_URL = pathToFileURL(fileURLToPath(import.meta.url)).href;
@@ -42,6 +43,13 @@ const CARD_INK_BAR = 0.045;
42
43
  const CARD_BADGE_W = 0.22;
43
44
  const CARD_MIN_H = 0.28;
44
45
 
46
+ // Readability floors. 8pt is the smallest projectable body size; below it slide text is
47
+ // unreadable at distance. A slide must not stack more list items than a viewer can hold, and
48
+ // must not pack so much that fitting it (pptxgen shrink-to-fit) drives the font under the floor.
49
+
50
+ export const DECK_FONT_FLOOR_PT = 8;
51
+ export const MAX_BULLETS_PER_SLIDE = 8;
52
+
45
53
  function ptSize(token) {
46
54
  return parseFloat(String(token || '').replace('pt', '')) || 10;
47
55
  }
@@ -226,6 +234,11 @@ function estimateListHeight(block, layout) {
226
234
 
227
235
  function estimateBlockHeight(block, layout = { w: CW }) {
228
236
  if (block.type === 'heading') return 0;
237
+ if (block.type === 'diagram') return 2.6;
238
+ if (block.type === 'code') {
239
+ const lineCount = String(block.code || '').split('\n').length;
240
+ return Math.min(3.0, 0.2 + lineCount * lineHeightIn(T.small) * 1.1);
241
+ }
229
242
  if (block.type === 'text') {
230
243
  const len = cellPlainLength(block.text);
231
244
  const fontSize = len > 140 ? T.small : T.body;
@@ -295,6 +308,28 @@ export function auditDeckMarkdownLayout(markdown, metadata = {}) {
295
308
  y += estimateBlockHeight(block, layout);
296
309
  }
297
310
 
311
+ const listItemCount = blocks
312
+ .filter((b) => b.type === 'bullet' || b.type === 'number')
313
+ .reduce((sum, b) => sum + b.items.length, 0);
314
+ if (listItemCount > MAX_BULLETS_PER_SLIDE) {
315
+ slideIssues.push({
316
+ code: 'bullet_density',
317
+ detail: `${listItemCount} list items exceed the ${MAX_BULLETS_PER_SLIDE}-bullet readability limit`,
318
+ });
319
+ }
320
+
321
+ // pptxgen shrinks text to fit its box, so a slide estimated taller than the budget would
322
+ // render at a proportionally smaller font. Flag when that implied font drops below the floor.
323
+
324
+ const fitRatio = y > 0 ? Math.min(1, SLIDE_CONTENT_BUDGET_IN / y) : 1;
325
+ const impliedFont = T.body * fitRatio;
326
+ if (impliedFont < DECK_FONT_FLOOR_PT) {
327
+ slideIssues.push({
328
+ code: 'font_below_floor',
329
+ detail: `fitting this slide implies ~${impliedFont.toFixed(1)}pt, below the ${DECK_FONT_FLOOR_PT}pt floor`,
330
+ });
331
+ }
332
+
298
333
  if (layout.mode === 'ink-panel') {
299
334
  const panelBottom = 0.28 + 0.05 + 0.18 + 0.38 + 0.72;
300
335
  if (panelBottom > SLIDE_CONTENT_BUDGET_IN * 0.55) {
@@ -419,16 +454,39 @@ function hex(hexColor) {
419
454
  return String(hexColor || '').replace(/^#/, '');
420
455
  }
421
456
 
457
+ // A level-1 `# ` heading starts a new slide (pandoc slide-level 1, matching the HTML
458
+ // deck template), as do explicit `---` / `****` rules. Boundaries inside fenced code are
459
+ // ignored so a `#` comment in a d2 block or a `---` in a snippet never splits a slide.
460
+
422
461
  function splitSlides(markdown) {
423
462
  let body = String(markdown || '');
424
463
  if (body.startsWith('---')) {
425
464
  const end = body.indexOf('\n---', 3);
426
465
  if (end !== -1) body = body.slice(end + 4);
427
466
  }
428
- return body
429
- .split(/\n(?:---|\*\*\*\*)\n/)
430
- .map((chunk) => chunk.trim())
431
- .filter(Boolean);
467
+
468
+ const parts = [];
469
+ let current = [];
470
+ let inFence = false;
471
+ const flush = () => {
472
+ if (current.some((line) => line.trim())) parts.push(current.join('\n'));
473
+ current = [];
474
+ };
475
+ for (const line of body.split('\n')) {
476
+ if (/^```/.test(line)) inFence = !inFence;
477
+ if (!inFence && /^#\s+\S/.test(line)) {
478
+ flush();
479
+ current.push(line);
480
+ continue;
481
+ }
482
+ if (!inFence && /^(?:---|\*\*\*\*)\s*$/.test(line)) {
483
+ flush();
484
+ continue;
485
+ }
486
+ current.push(line);
487
+ }
488
+ flush();
489
+ return parts.map((chunk) => chunk.trim()).filter(Boolean);
432
490
  }
433
491
 
434
492
  function parseInlineRuns(text, base = {}) {
@@ -504,6 +562,27 @@ function slideBlocks(chunk) {
504
562
  const raw = lines[i];
505
563
  const line = raw.trimEnd();
506
564
 
565
+ const fence = line.match(/^```\s*([\w-]*)\s*$/);
566
+ if (fence) {
567
+ flushPara();
568
+ flushList();
569
+ const lang = fence[1].toLowerCase();
570
+ i += 1;
571
+ const code = [];
572
+ while (i < lines.length && !/^```\s*$/.test(lines[i].trimEnd())) {
573
+ code.push(lines[i]);
574
+ i += 1;
575
+ }
576
+ i += 1;
577
+ const body = code.join('\n');
578
+ if (lang === 'mermaid' || lang === 'd2') {
579
+ blocks.push({ type: 'diagram', lang, code: body });
580
+ } else {
581
+ blocks.push({ type: 'code', lang, code: body });
582
+ }
583
+ continue;
584
+ }
585
+
507
586
  if (isTableLine(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
508
587
  flushPara();
509
588
  flushList();
@@ -969,7 +1048,104 @@ function addListCards(slide, pptx, block, y, maxY, layout = {}) {
969
1048
  return cy + 0.04;
970
1049
  }
971
1050
 
972
- function addContentSlide(pptx, chunk, slideIndex, totalSlides, deckTitle) {
1051
+ // PNG carries width/height as big-endian uint32 at byte offsets 16 and 20 (the IHDR
1052
+ // chunk). Reading the header avoids an image dependency just to fit a diagram by aspect.
1053
+
1054
+ function pngDimensions(file) {
1055
+ const buf = fs.readFileSync(file);
1056
+ if (buf.length > 24 && buf[0] === 0x89 && buf[1] === 0x50) {
1057
+ return { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
1058
+ }
1059
+ return null;
1060
+ }
1061
+
1062
+ // Diagram fences are rendered to PNG with the same monochrome sketch styling as the
1063
+ // PDF/HTML path: mmdc for mermaid (hand-drawn theme injected), d2 --sketch for d2. A
1064
+ // missing renderer returns a typed error so the caller can record it, never a silent drop.
1065
+
1066
+ function renderDiagramToPng(lang, code, tmpDir, baseEnv, idx) {
1067
+ const env = buildDistributionDiagramEnv(baseEnv);
1068
+ const out = path.join(tmpDir, `diagram-${idx}.png`);
1069
+ if (lang === 'mermaid') {
1070
+ const src = path.join(tmpDir, `diagram-${idx}.mmd`);
1071
+ fs.writeFileSync(src, injectMermaidBrandTheme(code));
1072
+ const args = ['-i', src, '-o', out, '-b', 'transparent', '-s', '2', '-w', '1600'];
1073
+ if (env.CONSTRUCT_MERMAID_PPTR_CONFIG) args.push('-p', env.CONSTRUCT_MERMAID_PPTR_CONFIG);
1074
+ const r = spawnSync('mmdc', args, { encoding: 'utf8', env });
1075
+ if (r.status !== 0 || !fs.existsSync(out)) return { error: (r.stderr || 'mmdc failed').trim().slice(0, 200) };
1076
+ } else {
1077
+ const src = path.join(tmpDir, `diagram-${idx}.d2`);
1078
+ fs.writeFileSync(src, injectD2DistributionDefaults(code));
1079
+ const r = spawnSync('d2', ['--sketch', '--pad', '16', '--theme', '0', src, out], { encoding: 'utf8', env });
1080
+ if (r.status !== 0 || !fs.existsSync(out)) return { error: (r.stderr || 'd2 failed').trim().slice(0, 200) };
1081
+ }
1082
+ const dim = pngDimensions(out) || { w: 1600, h: 900 };
1083
+ return { path: out, w: dim.w, h: dim.h };
1084
+ }
1085
+
1086
+ function addDiagramImage(slide, pptx, block, y, maxY, layout, renderCtx) {
1087
+ const availH = maxY - y - 0.04;
1088
+ if (availH < 0.6) return y;
1089
+ const rendered = renderDiagramToPng(block.lang, block.code, renderCtx.tmpDir, renderCtx.env, renderCtx.seq);
1090
+ renderCtx.seq += 1;
1091
+ renderCtx.attempted += 1;
1092
+
1093
+ if (rendered.error) {
1094
+ renderCtx.errors.push({ lang: block.lang, error: rendered.error });
1095
+ const box = clampBox(layout.x, y, layout.w, Math.min(availH, 0.9));
1096
+ slide.addShape(pptx.shapes.RECTANGLE, {
1097
+ ...box,
1098
+ fill: { color: hex(BRAND_TOKENS.surface.alt) },
1099
+ line: { color: hex(BRAND_TOKENS.line.hairline), width: 0.75 },
1100
+ rectRadius: 0.04,
1101
+ });
1102
+ addWrappedText(slide, `[${block.lang} diagram could not render]`, {
1103
+ x: box.x + 0.14, y: box.y + 0.1, w: box.w - 0.28, h: box.h - 0.16,
1104
+ }, T.small, { color: BRAND_TOKENS.ink.muted, italic: true });
1105
+ return box.y + box.h + 0.1;
1106
+ }
1107
+
1108
+ renderCtx.rendered += 1;
1109
+ const availW = layout.w;
1110
+ const imgAspect = rendered.w / rendered.h;
1111
+ let drawW = availW;
1112
+ let drawH = availW / imgAspect;
1113
+ if (drawH > availH) {
1114
+ drawH = availH;
1115
+ drawW = availH * imgAspect;
1116
+ }
1117
+ const ix = layout.x + (availW - drawW) / 2;
1118
+ slide.addImage({ path: rendered.path, x: ix, y, w: drawW, h: drawH });
1119
+ return y + drawH + 0.1;
1120
+ }
1121
+
1122
+ function addCodeBlock(slide, pptx, block, y, maxY, layout) {
1123
+ const fontSize = T.small;
1124
+ const lineCount = String(block.code || '').split('\n').length;
1125
+ const estH = Math.min(maxY - y - 0.04, 0.2 + lineCount * lineHeightIn(fontSize) * 1.1);
1126
+ const box = clampBox(layout.x, y, layout.w, estH);
1127
+ slide.addShape(pptx.shapes.RECTANGLE, {
1128
+ ...box,
1129
+ fill: { color: hex(BRAND_TOKENS.surface.default) },
1130
+ line: { color: hex(BRAND_TOKENS.line.hairline), width: 0.75 },
1131
+ rectRadius: 0.04,
1132
+ });
1133
+ slide.addText(String(block.code || ''), {
1134
+ x: box.x + 0.12,
1135
+ y: box.y + 0.08,
1136
+ w: box.w - 0.24,
1137
+ h: box.h - 0.16,
1138
+ fontFace: BRAND_TOKENS.typography.fontMono,
1139
+ color: hex(BRAND_TOKENS.ink.strong),
1140
+ fontSize,
1141
+ valign: 'top',
1142
+ wrap: true,
1143
+ fit: 'shrink',
1144
+ });
1145
+ return box.y + box.h + 0.1;
1146
+ }
1147
+
1148
+ function addContentSlide(pptx, chunk, slideIndex, totalSlides, deckTitle, renderCtx) {
973
1149
  const slide = pptx.addSlide();
974
1150
  addSlideChrome(slide, pptx, { slideIndex, totalSlides, title: deckTitle });
975
1151
  addSlideAccent(slide);
@@ -1015,6 +1191,10 @@ function addContentSlide(pptx, chunk, slideIndex, totalSlides, deckTitle) {
1015
1191
  y = addListCards(slide, pptx, block, y, maxY, contentLayout);
1016
1192
  } else if (block.type === 'table') {
1017
1193
  y = addTableBlock(slide, pptx, block, y, maxY);
1194
+ } else if (block.type === 'diagram' && renderCtx) {
1195
+ y = addDiagramImage(slide, pptx, block, y, maxY, contentLayout, renderCtx);
1196
+ } else if (block.type === 'code') {
1197
+ y = addCodeBlock(slide, pptx, block, y, maxY, contentLayout);
1018
1198
  }
1019
1199
  }
1020
1200
  }
@@ -1078,21 +1258,44 @@ export async function exportDeckPptxAsync({
1078
1258
  const fontEmbed = await embedBundledSansInPptx(pptx, repoRoot);
1079
1259
  await embedBundledMonoInPptx(pptx, repoRoot);
1080
1260
 
1081
- addTitleSlide(pptx, metadata, totalSlides);
1082
- contentChunks.forEach((chunk, index) => {
1083
- addContentSlide(pptx, chunk, index + 2, totalSlides, metadata.title || '');
1084
- });
1261
+ const renderCtx = {
1262
+ tmpDir: fs.mkdtempSync(path.join(os.tmpdir(), 'pptx-fig-')),
1263
+ env: process.env,
1264
+ seq: 0,
1265
+ attempted: 0,
1266
+ rendered: 0,
1267
+ errors: [],
1268
+ };
1085
1269
 
1086
1270
  try {
1087
- await pptx.writeFile({ fileName: outputPath });
1088
- } catch (err) {
1089
- return {
1090
- ok: false,
1091
- format: 'pptx',
1092
- inputPath,
1093
- outputPath,
1094
- message: `PPTX export failed: ${err.message}`,
1095
- };
1271
+ addTitleSlide(pptx, metadata, totalSlides);
1272
+ contentChunks.forEach((chunk, index) => {
1273
+ addContentSlide(pptx, chunk, index + 2, totalSlides, metadata.title || '', renderCtx);
1274
+ });
1275
+
1276
+ if (renderCtx.attempted > 0 && renderCtx.rendered === 0) {
1277
+ return {
1278
+ ok: false,
1279
+ format: 'pptx',
1280
+ inputPath,
1281
+ outputPath,
1282
+ message: `PPTX export failed: 0/${renderCtx.attempted} diagram(s) rendered. Ensure d2 and mermaid-cli are installed (mmdc needs Chrome; set PUPPETEER_EXECUTABLE_PATH). First error: ${renderCtx.errors[0]?.error || 'unknown'}`,
1283
+ };
1284
+ }
1285
+
1286
+ try {
1287
+ await pptx.writeFile({ fileName: outputPath });
1288
+ } catch (err) {
1289
+ return {
1290
+ ok: false,
1291
+ format: 'pptx',
1292
+ inputPath,
1293
+ outputPath,
1294
+ message: `PPTX export failed: ${err.message}`,
1295
+ };
1296
+ }
1297
+ } finally {
1298
+ fs.rmSync(renderCtx.tmpDir, { recursive: true, force: true });
1096
1299
  }
1097
1300
 
1098
1301
  return {
@@ -1104,6 +1307,8 @@ export async function exportDeckPptxAsync({
1104
1307
  slideCount: totalSlides,
1105
1308
  layout,
1106
1309
  fontsEmbedded: fontEmbed.embedded,
1310
+ diagramsRendered: renderCtx.rendered,
1311
+ diagramWarnings: renderCtx.errors,
1107
1312
  message: `Wrote ${outputPath}`,
1108
1313
  };
1109
1314
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * lib/diagram-quality.mjs — Diagram quality heuristics beyond syntax/render success.
3
+ *
4
+ * A diagram can render cleanly and still be unreadable: too many nodes to follow, labels too long
5
+ * to fit, a flowchart decision with no branch labels (only the happy path drawn), or a sequence
6
+ * diagram with too few participants to be worth a diagram. analyzeDiagramQuality parses mermaid and
7
+ * d2 source and returns typed findings for each gap. It judges purpose and legibility, not syntax —
8
+ * the render path already proves a diagram compiles. Consumed by the publish diagram preprocessor,
9
+ * which surfaces findings as advisory warnings unless frontmatter opts into strict.
10
+ */
11
+
12
+ export const MAX_NODES = 24;
13
+ export const MAX_LABEL_CHARS = 48;
14
+ export const MIN_SEQUENCE_PARTICIPANTS = 2;
15
+
16
+ // Unlabeled edges are deliberately NOT a finding: a bare arrow is idiomatic in dependency and
17
+ // data-flow diagrams (`A --> B` reads as "A produces B"), so flagging it would punish correct
18
+ // diagrams. Edge labeling only matters at decision branches, which decision_without_branches owns.
19
+
20
+ export const DIAGRAM_QUALITY_CODES = Object.freeze([
21
+ 'node_density_high',
22
+ 'label_too_long',
23
+ 'decision_without_branches',
24
+ 'sequence_too_few_participants',
25
+ ]);
26
+
27
+ // mermaid declares its kind on the first non-directive line; d2 has no header, so anything that is
28
+ // neither a mermaid kind nor empty is treated as d2.
29
+
30
+ export function detectDiagramKind(code, lang) {
31
+ const body = String(code || '').replace(/^%%\{[\s\S]*?\}%%/m, '');
32
+ const first = body.split('\n').map((l) => l.trim()).find(Boolean) || '';
33
+ if (/^sequenceDiagram\b/.test(first)) return 'sequence';
34
+ if (/^(flowchart|graph)\b/.test(first)) return 'flowchart';
35
+ if (lang === 'mermaid') return 'mermaid-other';
36
+ return 'd2';
37
+ }
38
+
39
+ function stripDirectives(code) {
40
+ return String(code || '').replace(/^%%\{[\s\S]*?\}%%/m, '').trim();
41
+ }
42
+
43
+ // Flowchart: node ids appear in `id[label]` / `id{decision}` definitions and on both sides of an
44
+ // edge. Decision nodes use {}; edge labels ride in `-->|label|` or `-- label -->`.
45
+
46
+ function analyzeFlowchart(code) {
47
+ const findings = [];
48
+ const lines = stripDirectives(code).split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
49
+ const nodes = new Set();
50
+ const decisions = new Set();
51
+ const outgoing = new Map();
52
+
53
+ const nodeDef = /([A-Za-z0-9_]+)\s*([[({])([^\])}]*)[\])}]/g;
54
+ const edge = /([A-Za-z0-9_]+)\s*(?:[ox]?[-.=]+[->]*\|([^|]*)\||[-.=]+[->]+)\s*([A-Za-z0-9_]+)/;
55
+
56
+ for (const line of lines) {
57
+ let m;
58
+ while ((m = nodeDef.exec(line))) {
59
+ nodes.add(m[1]);
60
+ if (m[2] === '{') decisions.add(m[1]);
61
+ if ((m[3] || '').trim().length > MAX_LABEL_CHARS) {
62
+ findings.push({ code: 'label_too_long', detail: `node ${m[1]} label is ${m[3].trim().length} chars (max ${MAX_LABEL_CHARS})` });
63
+ }
64
+ }
65
+ const e = edge.exec(line);
66
+ if (e) {
67
+ nodes.add(e[1]);
68
+ nodes.add(e[3]);
69
+ const label = (e[2] || '').trim();
70
+ if (label.length > MAX_LABEL_CHARS) findings.push({ code: 'label_too_long', detail: `edge ${e[1]}->${e[3]} label is ${label.length} chars (max ${MAX_LABEL_CHARS})` });
71
+ outgoing.set(e[1], (outgoing.get(e[1]) || 0) + 1);
72
+ }
73
+ }
74
+
75
+ if (nodes.size > MAX_NODES) findings.push({ code: 'node_density_high', detail: `${nodes.size} nodes exceed the ${MAX_NODES}-node readability limit` });
76
+ for (const d of decisions) {
77
+ if ((outgoing.get(d) || 0) < 2) {
78
+ findings.push({ code: 'decision_without_branches', detail: `decision ${d} has ${(outgoing.get(d) || 0)} outgoing branch(es); a decision needs at least the yes/no paths` });
79
+ }
80
+ }
81
+ return findings;
82
+ }
83
+
84
+ // Sequence: `participant X` declarations plus actors on either side of a message arrow.
85
+
86
+ function analyzeSequence(code) {
87
+ const findings = [];
88
+ const lines = stripDirectives(code).split('\n').slice(1).map((l) => l.trim()).filter(Boolean);
89
+ const participants = new Set();
90
+
91
+ for (const line of lines) {
92
+ const decl = line.match(/^(?:participant|actor)\s+([A-Za-z0-9_]+)/);
93
+ if (decl) { participants.add(decl[1]); continue; }
94
+ const msg = line.match(/^([A-Za-z0-9_]+)\s*-[->x)]+\s*([A-Za-z0-9_]+)\s*:(.*)$/);
95
+ if (msg) {
96
+ participants.add(msg[1]);
97
+ participants.add(msg[2]);
98
+ if (msg[3].trim().length > MAX_LABEL_CHARS) findings.push({ code: 'label_too_long', detail: `message ${msg[1]}->${msg[2]} is ${msg[3].trim().length} chars (max ${MAX_LABEL_CHARS})` });
99
+ }
100
+ }
101
+
102
+ if (participants.size < MIN_SEQUENCE_PARTICIPANTS) {
103
+ findings.push({ code: 'sequence_too_few_participants', detail: `${participants.size} participant(s); a sequence needs at least ${MIN_SEQUENCE_PARTICIPANTS}` });
104
+ }
105
+ if (participants.size > MAX_NODES) findings.push({ code: 'node_density_high', detail: `${participants.size} participants exceed the ${MAX_NODES} limit` });
106
+ return findings;
107
+ }
108
+
109
+ // d2: connections `a -> b`, optional `: label`; standalone `id: label` names a node.
110
+
111
+ function analyzeD2(code) {
112
+ const findings = [];
113
+ const lines = stripDirectives(code).split('\n').map((l) => l.trim()).filter((l) => l && !l.startsWith('#'));
114
+ const nodes = new Set();
115
+
116
+ for (const line of lines) {
117
+ const conn = line.match(/^([A-Za-z0-9_.]+)\s*(?:<->|->|<-|--)\s*([A-Za-z0-9_.]+)\s*(?::(.*))?$/);
118
+ if (conn) {
119
+ nodes.add(conn[1]);
120
+ nodes.add(conn[2]);
121
+ const label = (conn[3] || '').trim();
122
+ if (label.length > MAX_LABEL_CHARS) findings.push({ code: 'label_too_long', detail: `edge ${conn[1]}->${conn[2]} label is ${label.length} chars (max ${MAX_LABEL_CHARS})` });
123
+ continue;
124
+ }
125
+ const node = line.match(/^([A-Za-z0-9_.]+)\s*:\s*(.+)$/);
126
+ if (node) {
127
+ nodes.add(node[1]);
128
+ if (node[2].trim().length > MAX_LABEL_CHARS) findings.push({ code: 'label_too_long', detail: `node ${node[1]} label is ${node[2].trim().length} chars (max ${MAX_LABEL_CHARS})` });
129
+ }
130
+ }
131
+
132
+ if (nodes.size > MAX_NODES) findings.push({ code: 'node_density_high', detail: `${nodes.size} nodes exceed the ${MAX_NODES}-node readability limit` });
133
+ return findings;
134
+ }
135
+
136
+ export function analyzeDiagramQuality(code, { lang } = {}) {
137
+ const kind = detectDiagramKind(code, lang);
138
+ let findings = [];
139
+ if (kind === 'flowchart') findings = analyzeFlowchart(code);
140
+ else if (kind === 'sequence') findings = analyzeSequence(code);
141
+ else if (kind === 'd2') findings = analyzeD2(code);
142
+ return { ok: findings.length === 0, kind, findings };
143
+ }
144
+
145
+ // Every fenced mermaid/d2 block in a document, analyzed and flattened into one advisory list so
146
+ // the publish path can surface legibility warnings. `cx_diagram_quality: strict` in frontmatter
147
+ // is honored by the caller to escalate these from warnings to gate failures.
148
+
149
+ export function lintDocumentDiagrams(markdown) {
150
+ const src = String(markdown || '');
151
+ const warnings = [];
152
+ const fenceRe = /```(mermaid|d2)\n([\s\S]*?)```/g;
153
+ let match;
154
+ let index = 0;
155
+ while ((match = fenceRe.exec(src))) {
156
+ index += 1;
157
+ const { kind, findings } = analyzeDiagramQuality(match[2], { lang: match[1] });
158
+ for (const finding of findings) warnings.push({ diagram: index, kind, ...finding });
159
+ }
160
+ return { ok: warnings.length === 0, warnings };
161
+ }
@@ -175,7 +175,7 @@ export function detectPdfTemplate({
175
175
  };
176
176
  }
177
177
 
178
- function whichBin(name, env = process.env) {
178
+ export function whichBin(name, env = process.env) {
179
179
  const cmd = process.platform === 'win32' ? 'where' : 'which';
180
180
  const result = spawnSync(cmd, [name], { encoding: 'utf8', env });
181
181
  if (result.status !== 0) return null;
@@ -3,9 +3,11 @@
3
3
  *
4
4
  * Provides helpers to load the user config.env (XDG config dir) and a project
5
5
  * .env, merge them with process.env, and persist key/value pairs back to disk.
6
- * Project .env wins over user config.env, which wins over shell exports. Backs
7
- * setup, model-router, and the MCP server when they resolve API keys and model
8
- * overrides without leaking secrets.
6
+ * Project .env wins over user config.env, which wins over shell exports. The
7
+ * on-demand resolver (lib/providers/secret-resolver.mjs) shares this file-tier
8
+ * order project .env over config.env — so a key resolves the same way on both
9
+ * paths. Backs setup, model-router, and the MCP server when they resolve API keys
10
+ * and model overrides without leaking secrets.
9
11
  */
10
12
  import fs from 'node:fs';
11
13
  import os from 'node:os';
@@ -128,9 +130,8 @@ export function loadConstructEnv({ rootDir, homeDir = os.homedir(), env = proces
128
130
  if (rootEnv[key] === userEnv[key]) continue;
129
131
  if (preferInjectedCredential(rootEnv[key], userEnv[key])) continue;
130
132
  process.stderr.write(
131
- `[construct] WARNING: ${key} is set in both project .env (${rootEnv[key].slice(0, 6)}…) ` +
132
- `and ${userEnvPath} (${userEnv[key].slice(0, 6)}…). ` +
133
- `Project .env wins. To silence: remove the key from one file.\n`,
133
+ `[construct] WARNING: ${key} is set to different values in project .env ` +
134
+ `and ${userEnvPath}. Project .env wins. To silence: remove the key from one file.\n`,
134
135
  );
135
136
  }
136
137
 
@@ -145,8 +146,7 @@ export function loadConstructEnv({ rootDir, homeDir = os.homedir(), env = proces
145
146
  if (key in fileEnv && fileEnv[key] !== env[key]) {
146
147
  if (preferInjectedCredential(env[key], fileEnv[key])) continue;
147
148
  process.stderr.write(
148
- `[construct] WARNING: process.env.${key} (${env[key].slice(0, 6)}…) ` +
149
- `differs from the config file value (${fileEnv[key].slice(0, 6)}…). ` +
149
+ `[construct] WARNING: process.env.${key} differs from the config file value. ` +
150
150
  `The config file will be used. To silence: unset the shell variable or ` +
151
151
  `update the config file to match.\n`,
152
152
  );