diff --git a/libs/core/package.json b/libs/core/package.json index 6e1be3dbb..d37573299 100644 --- a/libs/core/package.json +++ b/libs/core/package.json @@ -61,6 +61,7 @@ "generate": "stencil generate", "lint": "run-p lint.*", "lint.eslint": "eslint src", + "lint.figma": "node scripts/check-figma-coverage.mjs", "lint.status": "node scripts/check-component-status.mjs", "lint.styles": "stylelint \"./src/**/*.*css\"", "prettier": "prettier \"./src/**/*.{html,ts,tsx,js,jsx}\"", @@ -126,7 +127,15 @@ "webpack": "^5.74.0" }, "nx": { + "implicitDependencies": ["@pine-ds/figma"], "targets": { + "lint": { + "inputs": [ + "default", + "{workspaceRoot}/libs/figma/code-connect-waivers.json", + "{workspaceRoot}/libs/figma/components/**/*.figma.ts" + ] + }, "start.stencil": { "dependsOn": [ { diff --git a/libs/core/scripts/check-component-status.mjs b/libs/core/scripts/check-component-status.mjs index 6ca579fb0..afb5f12f9 100644 --- a/libs/core/scripts/check-component-status.mjs +++ b/libs/core/scripts/check-component-status.mjs @@ -16,49 +16,11 @@ */ import fs from 'node:fs'; import path from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { CORE_ROOT, topLevelComponents, allComponentDirs } from './lib/components.mjs'; -const CORE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); -const COMPONENTS_DIR = path.join(CORE_ROOT, 'src', 'components'); const MANIFEST_PATH = path.join(CORE_ROOT, 'src', 'stories', 'resources', 'component-status.json'); const MANIFEST_REL = path.relative(CORE_ROOT, MANIFEST_PATH); -/** Directory names under `src/components` matching `pds-*`. */ -function pdsDirs(dir) { - return fs - .readdirSync(dir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory() && entry.name.startsWith('pds-')) - .map((entry) => entry.name); -} - -/** Top-level components — these each require a status entry. */ -function topLevelComponents() { - return pdsDirs(COMPONENTS_DIR).sort(); -} - -/** - * Every `pds-*` directory, including nested subcomponents. Used to validate - * manifest entries so a deliberate subcomponent entry (e.g. `pds-filter`) is - * accepted while a typo or removed component is not. - */ -function allComponentDirs() { - const names = new Set(); - // Top-level pds-* components. - for (const top of topLevelComponents()) { - names.add(top); - } - // Nested pds-* subcomponents under ANY top-level directory — including - // non-pds-* parents like `_internal/` (e.g. `_internal/pds-label`) — so a - // deliberate subcomponent manifest entry is not falsely reported as stale. - for (const entry of fs.readdirSync(COMPONENTS_DIR, { withFileTypes: true })) { - if (!entry.isDirectory()) continue; - for (const nested of pdsDirs(path.join(COMPONENTS_DIR, entry.name))) { - names.add(nested); - } - } - return names; -} - function readManifestKeys() { let raw; try { diff --git a/libs/core/scripts/check-figma-coverage.mjs b/libs/core/scripts/check-figma-coverage.mjs new file mode 100644 index 000000000..3c1ae6771 --- /dev/null +++ b/libs/core/scripts/check-figma-coverage.mjs @@ -0,0 +1,109 @@ +/** + * Figma Code Connect coverage check. + * + * Guards that every top-level `pds-*` component is either: + * - mapped: has a `libs/figma/components/.figma.ts`, or + * - explicitly waived: listed in `libs/figma/code-connect-waivers.json`. + * + * This stops a new component from silently shipping with no Code Connect + * mapping and no deliberate decision. It also flags stale waivers (a waived + * component that has since been mapped, or that no longer exists) so the + * waiver list cannot rot. + * + * Runs as `lint.figma` via `run-p lint.*`, gated through the existing + * `nx affected --target=lint` CI job. `@pine-ds/core` declares an implicit + * dependency on `@pine-ds/figma` so figma-only PRs still schedule core lint, + * and the core `lint` target inputs include waiver/mapping paths so Nx cache + * invalidates when those files change. + * + * Exits 0 when coverage is accounted for, 1 (with an actionable report) otherwise. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { CORE_ROOT, topLevelComponents } from './lib/components.mjs'; + +const FIGMA_ROOT = path.resolve(CORE_ROOT, '..', 'figma'); +const FIGMA_COMPONENTS_DIR = path.join(FIGMA_ROOT, 'components'); +const WAIVERS_PATH = path.join(FIGMA_ROOT, 'code-connect-waivers.json'); +const WAIVERS_REL = path.relative(path.resolve(CORE_ROOT, '..', '..'), WAIVERS_PATH); + +/** Component names with a `.figma.ts` under libs/figma/components. */ +function mappedComponents() { + if (!fs.existsSync(FIGMA_COMPONENTS_DIR)) return new Set(); + const names = fs + .readdirSync(FIGMA_COMPONENTS_DIR, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name.endsWith('.figma.ts')) + .map((entry) => entry.name.replace(/\.figma\.ts$/, '')); + return new Set(names); +} + +function readWaivers() { + let raw; + try { + raw = fs.readFileSync(WAIVERS_PATH, 'utf8'); + } catch { + throw new Error(`Could not read waivers at ${WAIVERS_REL}`); + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (err) { + throw new Error(`Waivers file ${WAIVERS_REL} is not valid JSON: ${err.message}`); + } + if (!parsed.waived || typeof parsed.waived !== 'object' || Array.isArray(parsed.waived)) { + throw new Error(`Waivers file ${WAIVERS_REL} is missing a "waived" object`); + } + return new Set(Object.keys(parsed.waived)); +} + +function main() { + let waived; + try { + waived = readWaivers(); + } catch (err) { + console.error(`✖ figma-coverage check failed: ${err.message}`); + process.exit(1); + } + + const components = topLevelComponents(); + const mapped = mappedComponents(); + const componentSet = new Set(components); + + // A component with neither a mapping nor a waiver is an unaccounted gap. + const uncovered = components.filter((name) => !mapped.has(name) && !waived.has(name)); + // A waiver for a component that is now mapped should be removed. + const redundant = [...waived].filter((name) => mapped.has(name)).sort(); + // A waiver for a component that no longer exists is stale. + const orphaned = [...waived].filter((name) => !componentSet.has(name)).sort(); + + if (uncovered.length === 0 && redundant.length === 0 && orphaned.length === 0) { + console.log( + `✔ figma code connect coverage accounted for ` + + `(${mapped.size} mapped, ${waived.size} waived, ${components.length} components).`, + ); + return; + } + + console.error(`✖ figma code connect coverage is unaccounted for.\n`); + if (uncovered.length > 0) { + console.error(`Components with no .figma.ts and no waiver:`); + uncovered.forEach((name) => console.error(` - ${name}`)); + console.error( + ` → add libs/figma/components/.figma.ts, ` + + `or add an entry to ${WAIVERS_REL} with a reason.\n`, + ); + } + if (redundant.length > 0) { + console.error(`Waivers for components that are already mapped (remove the waiver):`); + redundant.forEach((name) => console.error(` - ${name}`)); + console.error(''); + } + if (orphaned.length > 0) { + console.error(`Waivers for components that no longer exist (remove or rename):`); + orphaned.forEach((name) => console.error(` - ${name}`)); + console.error(''); + } + process.exit(1); +} + +main(); diff --git a/libs/core/scripts/lib/components.mjs b/libs/core/scripts/lib/components.mjs new file mode 100644 index 000000000..f0d153ecf --- /dev/null +++ b/libs/core/scripts/lib/components.mjs @@ -0,0 +1,50 @@ +/** + * Shared component-enumeration helpers for the maturity lint checks + * (`check-component-status.mjs`, `check-figma-coverage.mjs`). + * + * Single source for "what are the components" so the guards cannot diverge on + * their globbing logic. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** Repo `libs/core` root (this file lives at `libs/core/scripts/lib/`). */ +export const CORE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..'); +export const COMPONENTS_DIR = path.join(CORE_ROOT, 'src', 'components'); + +/** Directory names directly under `dir` matching `pds-*`. */ +function pdsDirs(dir) { + return fs + .readdirSync(dir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('pds-')) + .map((entry) => entry.name); +} + +/** Top-level components — the public surface that needs status + Figma coverage. */ +export function topLevelComponents() { + return pdsDirs(COMPONENTS_DIR).sort(); +} + +/** + * Every `pds-*` directory, including nested subcomponents (e.g. `pds-filter`, + * `pds-table-cell`). Used to accept deliberate subcomponent references while + * rejecting typos or removed components. + */ +export function allComponentDirs() { + const names = new Set(); + // Top-level pds-* components. + for (const top of topLevelComponents()) { + names.add(top); + } + // Nested pds-* subcomponents under ANY top-level directory — including + // non-pds-* parents like `_internal/` (e.g. `_internal/pds-label`) — so a + // deliberate subcomponent reference is not falsely rejected. + for (const entry of fs.readdirSync(COMPONENTS_DIR, { withFileTypes: true })) { + if (!entry.isDirectory()) continue; + for (const nested of pdsDirs(path.join(COMPONENTS_DIR, entry.name))) { + names.add(nested); + } + } + return names; +} diff --git a/libs/figma/code-connect-waivers.json b/libs/figma/code-connect-waivers.json new file mode 100644 index 000000000..a1e753655 --- /dev/null +++ b/libs/figma/code-connect-waivers.json @@ -0,0 +1,20 @@ +{ + "$comment": "Machine-readable source of truth for INTENTIONALLY unmapped Pine components. A top-level pds-* component passes the Figma Code Connect coverage guard (libs/core/scripts/check-figma-coverage.mjs) when it has a matching libs/figma/components/.figma.ts OR an entry here. Remove a component's entry once it is mapped. Keep CODE_CONNECT_COVERAGE.md in sync (a future change can render that doc from this file).", + "waived": { + "pds-accordion": "No Figma Code Connect mapping yet; add components/pds-accordion.figma.ts when the Figma node is ready.", + "pds-box": "Layout primitive; may map to layout frames rather than a single component node.", + "pds-combobox": "No Figma Code Connect mapping yet; add components/pds-combobox.figma.ts when the Figma node is ready.", + "pds-container": "No Figma Code Connect mapping yet; add components/pds-container.figma.ts when the Figma node is ready.", + "pds-copytext": "No Figma Code Connect mapping yet; add components/pds-copytext.figma.ts when the Figma node is ready.", + "pds-dropdown-menu": "No Figma Code Connect mapping yet; add components/pds-dropdown-menu.figma.ts when the Figma node is ready.", + "pds-icon": "Icon set lives in @pine-ds/icons; map if Figma documents a dedicated icon wrapper.", + "pds-image": "No Figma Code Connect mapping yet; add components/pds-image.figma.ts when the Figma node is ready.", + "pds-multiselect": "No Figma Code Connect mapping yet; add components/pds-multiselect.figma.ts when the Figma node is ready.", + "pds-popover": "No Figma Code Connect mapping yet; add components/pds-popover.figma.ts when the Figma node is ready.", + "pds-row": "Often used with pds-box; may share layout documentation.", + "pds-sortable": "Figma substitutions exist for sortable list patterns; wire components/pds-sortable.figma.ts when aligned.", + "pds-table": "Composite; consider per-subcomponent mappings (row, cell, head) if needed.", + "pds-text": "No Figma Code Connect mapping yet; add components/pds-text.figma.ts when the Figma node is ready.", + "pds-tooltip": "No Figma Code Connect mapping yet; add components/pds-tooltip.figma.ts when the Figma node is ready." + } +} diff --git a/libs/figma/package.json b/libs/figma/package.json new file mode 100644 index 000000000..ba4fa348f --- /dev/null +++ b/libs/figma/package.json @@ -0,0 +1,6 @@ +{ + "name": "@pine-ds/figma", + "version": "0.0.0", + "private": true, + "description": "Figma Code Connect sources for Pine (not published)" +} diff --git a/package-lock.json b/package-lock.json index 1e747a395..82cc8286a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -890,6 +890,10 @@ "loose-envify": "^1.1.0" } }, + "libs/figma": { + "name": "@pine-ds/figma", + "version": "0.0.0" + }, "libs/react": { "name": "@pine-ds/react", "version": "3.26.1", @@ -6834,6 +6838,10 @@ "resolved": "libs/doc-components", "link": true }, + "node_modules/@pine-ds/figma": { + "resolved": "libs/figma", + "link": true + }, "node_modules/@pine-ds/icons": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/@pine-ds/icons/-/icons-10.0.0.tgz",