Files
claude-engineering-plugin/plugins/compound-engineering/skills/excalidraw-png-export/scripts/validate.mjs
John Lamb fe3b1eee16 Merge upstream v2.67.0 with fork customizations preserved
Synced 79 commits from EveryInc/compound-engineering-plugin upstream while
preserving fork-specific customizations (Python/FastAPI pivot, Zoominfo-internal
review agents, deploy-wiring operational lessons, custom personas).

## Triage decisions (15 conflicts resolved)

Keep deleted (7) -- fork already removed these in prior cleanups:
- agents/design/{design-implementation-reviewer,design-iterator,figma-design-sync}
  (no fork successor; backend-Python focus doesn't need UI/Figma agents)
- agents/docs/ankane-readme-writer (replaced by python-package-readme-writer)
- agents/review/{data-migration-expert,performance-oracle,security-sentinel}
  (replaced by *-reviewer naming convention: data-migrations-reviewer,
  performance-reviewer, security-reviewer)

Keep local (1):
- agents/workflow/lint.md (Python tooling: ruff/mypy/djlint/bandit; upstream
  deleted the file). Fixed pre-existing duplicate "2." numbering bug.

Restore from upstream (1):
- agents/review/data-integrity-guardian.md (kept for GDPR/CCPA privacy
  compliance angle not covered by data-migrations-reviewer)

Merge both (6) -- upstream structural wins layered with fork intent:
- agents/research/best-practices-researcher.md (upstream <examples> removal +
  fork's Rails/Ruby -> Python/FastAPI translations)
- skills/ce-brainstorm/SKILL.md (universal-brainstorming routing + Slack
  context + non-obvious angles + fork's Deploy wiring flag)
- skills/ce-plan/SKILL.md (universal-planning routing + planning-bootstrap +
  fork's two Deploy wiring check bullets)
- skills/ce-review/SKILL.md (Run ID, model tiering haiku->sonnet, compact-JSON
  artifact contract, file-type awareness, cli-readiness-reviewer + fork's
  zip-agent-validator, design-conformance-reviewer, Stage 6 Zip Agent
  Validation)
- skills/ce-review/references/persona-catalog.md (cli-readiness row + adversarial
  refinement + fork's Language & Framework Conditional layer; 22 personas total)
- skills/ce-work/SKILL.md (Parallel Safety Check, parallel-subagent constraints,
  Phase 3-4 compression + fork's deploy-values self-review row, with duplicate
  checklist bullet collapsed to single occurrence)

## Auto-applied (no triage needed)

- 225 remote-only files: accepted as-is (new docs, brainstorms, plans,
  upstream skills, tests, scripts)
- 70 local-only files: 46 preserved as-is (kieran-python, tiangolo-fastapi,
  zip-agent-validator, design-conformance-reviewer, essay/proof commands,
  excalidraw-png-export, etc.); 24 stayed deleted (dhh-rails-style,
  andrew-kane-gem-writer, dspy-ruby Ruby skills no longer needed)

## README updated

- Removed Design section (3 deleted agents)
- Removed deleted Review entries (data-migration-expert, dhh-rails-reviewer,
  kieran-rails-reviewer, performance-oracle, security-sentinel)
- Added new Review entries: design-conformance-reviewer, previous-comments-reviewer,
  tiangolo-fastapi-reviewer, zip-agent-validator
- Workflow: added lint
- Docs: replaced ankane-readme-writer with python-package-readme-writer

## Known issues (not introduced by merge decisions)

- 9 detect-project-type.sh tests fail on macOS bash 3.2 (script uses
  `declare -A` which requires bash 4+). Upstream regression in commit 070092d
  (#568). Resolution: install bash 4+ via `brew install bash` locally;
  upstream fix tracked separately.
- 2 review-skill-contract tests reference deleted agents (dhh-rails-reviewer,
  data-migration-expert). Pre-existing fork inconsistency, not new.

bun run release:validate: passes (46 agents, 51 skills, 0 MCP servers)
2026-04-17 17:24:41 -05:00

174 lines
6.2 KiB
JavaScript
Executable File

#!/usr/bin/env node
/**
* Spatial validation for .excalidraw files.
* Checks text overflow, arrow-text collisions, and element overlap.
* Usage: node validate.mjs <input.excalidraw>
*/
import { readFileSync } from 'fs';
const MIN_PADDING = 15;
const inputFile = process.argv[2];
if (!inputFile) {
console.error('Usage: node validate.mjs <input.excalidraw>');
process.exit(1);
}
const data = JSON.parse(readFileSync(inputFile, 'utf8'));
const elements = data.elements || data;
// Build element map
const elMap = new Map();
for (const el of elements) {
if (el.isDeleted) continue;
elMap.set(el.id, el);
}
let warnings = 0;
let errors = 0;
const checked = elements.filter(el => !el.isDeleted).length;
// --- Check 1: Text overflow within containers ---
// Skip arrow-bound labels — arrows are lines, not spatial containers.
for (const el of elements) {
if (el.isDeleted || el.type !== 'text' || !el.containerId) continue;
const parent = elMap.get(el.containerId);
if (!parent || parent.type === 'arrow') continue;
const textRight = el.x + el.width;
const textBottom = el.y + el.height;
const parentRight = parent.x + parent.width;
const parentBottom = parent.y + parent.height;
const paddingLeft = el.x - parent.x;
const paddingRight = parentRight - textRight;
const paddingTop = el.y - parent.y;
const paddingBottom = parentBottom - textBottom;
const overflows = [];
if (paddingLeft < MIN_PADDING) overflows.push(`left=${paddingLeft.toFixed(1)}px (need ${MIN_PADDING}px)`);
if (paddingRight < MIN_PADDING) overflows.push(`right=${paddingRight.toFixed(1)}px (need ${MIN_PADDING}px)`);
if (paddingTop < MIN_PADDING) overflows.push(`top=${paddingTop.toFixed(1)}px (need ${MIN_PADDING}px)`);
if (paddingBottom < MIN_PADDING) overflows.push(`bottom=${paddingBottom.toFixed(1)}px (need ${MIN_PADDING}px)`);
if (overflows.length > 0) {
const label = (el.text || '').replace(/\n/g, '\\n');
const truncated = label.length > 40 ? label.slice(0, 37) + '...' : label;
console.log(`WARN: text "${truncated}" (id=${el.id}) tight/overflow in container (id=${el.containerId})`);
console.log(` text_bbox=[${el.x.toFixed(0)},${el.y.toFixed(0)}]->[${textRight.toFixed(0)},${textBottom.toFixed(0)}]`);
console.log(` container_bbox=[${parent.x.toFixed(0)},${parent.y.toFixed(0)}]->[${parentRight.toFixed(0)},${parentBottom.toFixed(0)}]`);
console.log(` insufficient padding: ${overflows.join(', ')}`);
console.log();
warnings++;
}
}
// --- Check 2: Arrow-text collisions ---
/** Check if line segment (p1->p2) intersects axis-aligned rectangle. */
function segmentIntersectsRect(p1, p2, rect) {
// rect = {x, y, w, h} -> min/max
const rxMin = rect.x;
const rxMax = rect.x + rect.w;
const ryMin = rect.y;
const ryMax = rect.y + rect.h;
// Cohen-Sutherland-style clipping
let [x1, y1] = [p1[0], p1[1]];
let [x2, y2] = [p2[0], p2[1]];
function outcode(x, y) {
let code = 0;
if (x < rxMin) code |= 1;
else if (x > rxMax) code |= 2;
if (y < ryMin) code |= 4;
else if (y > ryMax) code |= 8;
return code;
}
let code1 = outcode(x1, y1);
let code2 = outcode(x2, y2);
for (let i = 0; i < 20; i++) {
if (!(code1 | code2)) return true; // both inside
if (code1 & code2) return false; // both outside same side
const codeOut = code1 || code2;
let x, y;
if (codeOut & 8) { y = ryMax; x = x1 + (x2 - x1) * (ryMax - y1) / (y2 - y1); }
else if (codeOut & 4) { y = ryMin; x = x1 + (x2 - x1) * (ryMin - y1) / (y2 - y1); }
else if (codeOut & 2) { x = rxMax; y = y1 + (y2 - y1) * (rxMax - x1) / (x2 - x1); }
else { x = rxMin; y = y1 + (y2 - y1) * (rxMin - x1) / (x2 - x1); }
if (codeOut === code1) { x1 = x; y1 = y; code1 = outcode(x1, y1); }
else { x2 = x; y2 = y; code2 = outcode(x2, y2); }
}
return false;
}
// Collect text bounding boxes (excluding arrow-bound labels for their own arrow)
const textBoxes = [];
for (const el of elements) {
if (el.isDeleted || el.type !== 'text') continue;
textBoxes.push({
id: el.id,
containerId: el.containerId,
text: (el.text || '').replace(/\n/g, '\\n'),
rect: { x: el.x, y: el.y, w: el.width, h: el.height },
});
}
for (const el of elements) {
if (el.isDeleted || el.type !== 'arrow') continue;
if (!el.points || el.points.length < 2) continue;
// Compute absolute points
const absPoints = el.points.map(p => [el.x + p[0], el.y + p[1]]);
for (const tb of textBoxes) {
// Skip this arrow's own label
if (tb.containerId === el.id) continue;
for (let i = 0; i < absPoints.length - 1; i++) {
if (segmentIntersectsRect(absPoints[i], absPoints[i + 1], tb.rect)) {
const truncated = tb.text.length > 30 ? tb.text.slice(0, 27) + '...' : tb.text;
const seg = `[${absPoints[i].map(n => n.toFixed(0)).join(',')}]->[${absPoints[i + 1].map(n => n.toFixed(0)).join(',')}]`;
console.log(`WARN: arrow (id=${el.id}) segment ${seg} crosses text "${truncated}" (id=${tb.id})`);
console.log(` text_bbox=[${tb.rect.x.toFixed(0)},${tb.rect.y.toFixed(0)}]->[${(tb.rect.x + tb.rect.w).toFixed(0)},${(tb.rect.y + tb.rect.h).toFixed(0)}]`);
console.log();
warnings++;
break; // one warning per arrow-text pair
}
}
}
}
// --- Check 3: Element overlap (non-child, same depth) ---
const topLevel = elements.filter(el =>
!el.isDeleted && !el.containerId && el.type !== 'text' && el.type !== 'arrow'
);
for (let i = 0; i < topLevel.length; i++) {
for (let j = i + 1; j < topLevel.length; j++) {
const a = topLevel[i];
const b = topLevel[j];
const aRight = a.x + a.width;
const aBottom = a.y + a.height;
const bRight = b.x + b.width;
const bBottom = b.y + b.height;
if (a.x < bRight && aRight > b.x && a.y < bBottom && aBottom > b.y) {
const overlapX = Math.min(aRight, bRight) - Math.max(a.x, b.x);
const overlapY = Math.min(aBottom, bBottom) - Math.max(a.y, b.y);
console.log(`WARN: overlap between (id=${a.id}) and (id=${b.id}): ${overlapX.toFixed(0)}x${overlapY.toFixed(0)}px`);
console.log();
warnings++;
}
}
}
// --- Summary ---
console.log(`OK: ${checked} elements checked, ${warnings} warning(s), ${errors} error(s)`);
process.exit(warnings > 0 ? 1 : 0);