diff --git a/package-lock.json b/package-lock.json index 108c331..0c364ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1745,7 +1745,7 @@ }, "packages/cli": { "name": "@directededges/specs-cli", - "version": "0.15.0", + "version": "0.15.1", "license": "MIT", "dependencies": { "@directededges/specs-from-figma": "^0.18.0", diff --git a/packages/cli/CHANGELOG.md b/packages/cli/CHANGELOG.md index 24a1f52..dda1e4d 100644 --- a/packages/cli/CHANGELOG.md +++ b/packages/cli/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to `@directededges/specs-cli` are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.15.1] - 2026-05-20 + +Patch release fixing the `scan` → `generate` round-trip. `generate` now accepts the v2 (markdown-table) manifests that `scan` has emitted since 0.15.0, restoring the documented workflow. + +### Fixed + +- **`generate` now accepts v2 (table) manifests** — `generate`'s source auto-detection only recognized the legacy v1 checkbox-list format (`- [`), so any manifest produced by `specs scan` in 0.15.0 failed with `Error: Unrecognized source format`, breaking the documented `scan && generate` round-trip. `generate` now detects v2 manifests via the `**Scan format version:**` header and dispatches to `ManifestParserV2`, while continuing to support v1 manifests and raw JSON files. ([#101](https://github.com/DirectedEdges/specs/issues/101)) +- **Escaped pipes in component names round-trip** — `scan` escapes `|` as `\|` in manifest table cells; `ManifestParserV2` now unescapes them so names like `Toggle | On/Off` parse back to their literal form. + +### Dependency updates + +- No upstream dependency changes since 0.15.0. Continues to reference `@directededges/specs-schema ^0.20.0` and `@directededges/specs-from-figma ^0.18.0`. + ## [0.15.0] - 2026-05-15 `scan` now drives curation from Figma's **Ready for Dev** signal and merges intelligently with prior manifests, preserving manual edits except where Figma's devStatus has changed. Introduces a new v2 manifest format with automatic migration from v1. diff --git a/packages/cli/package.json b/packages/cli/package.json index 7ce591c..5100596 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@directededges/specs-cli", - "version": "0.15.0", + "version": "0.15.1", "description": "Command-line interface for Specs design system operations", "type": "module", "main": "./dist/index.js", diff --git a/packages/cli/src/commands/GenerateCommand.ts b/packages/cli/src/commands/GenerateCommand.ts index 6a85944..f6d7f4f 100644 --- a/packages/cli/src/commands/GenerateCommand.ts +++ b/packages/cli/src/commands/GenerateCommand.ts @@ -17,6 +17,7 @@ import type { ProgressEvent, RestLicenseInput } from '@directededges/specs-from- import { ConfigLoader } from '../Config/ConfigLoader.js'; import { loadFoundations } from '../utilities/loadFoundations.js'; import { ManifestParser } from '../utilities/ManifestParser.js'; +import { ManifestParserV2 } from '../utilities/ManifestParserV2.js'; import { LicenseStatus } from '../utilities/LicenseStatus.js'; import { FileManifest } from '../Writers/FileManifest.js'; import { SingleFileWriter } from '../Writers/SingleFileWriter.js'; @@ -129,10 +130,15 @@ export const Generate = new Command('generate') process.exit(ERROR_CODES.FILE_ERROR); } - // Auto-detect mode by content + // Auto-detect mode by content. + // - v2 manifest: markdown table emitted by `specs scan` (declares **Scan format version:** 2) + // - v1 manifest: checkbox bullet list emitted by `specs audit` + // - JSON: raw Figma file (file mode) const sourceContent = await fs.readFile(sourcePath, 'utf-8'); const trimmed = sourceContent.trimStart(); - const isManifest = trimmed.includes('- ['); + const isV2Manifest = ManifestParserV2.isV2(sourceContent); + const isV1Manifest = trimmed.includes('- ['); + const isManifest = isV2Manifest || isV1Manifest; const isJson = trimmed.startsWith('{'); if (!isManifest && !isJson) { @@ -158,7 +164,9 @@ export const Generate = new Command('generate') process.exit(ERROR_CODES.INVALID_ARGS); } - const { components, metadata } = ManifestParser.parse(sourceContent); + const { components, metadata } = isV2Manifest + ? ManifestParserV2.parse(sourceContent) + : ManifestParser.parse(sourceContent); if (components.length === 0) { console.error('Error: No components found in manifest'); diff --git a/packages/cli/src/utilities/ManifestParserV2.ts b/packages/cli/src/utilities/ManifestParserV2.ts index f78794e..871ea42 100644 --- a/packages/cli/src/utilities/ManifestParserV2.ts +++ b/packages/cli/src/utilities/ManifestParserV2.ts @@ -79,7 +79,9 @@ export class ManifestParserV2 { const [, checkbox, name, id, type, devStatus] = match; components.push({ id, - name: name.trim(), + // `specs scan` escapes pipes in cell values (`escapeCell`: | → \|). + // Undo that here so names round-trip back to their literal form. + name: name.trim().replace(/\\\|/g, '|'), type: type.toUpperCase() as 'COMPONENT' | 'COMPONENT_SET', included: checkbox.toLowerCase() === 'x', devStatus: devStatus.toUpperCase() as DevStatus diff --git a/packages/cli/tests/unit/commands/GenerateCommand.test.ts b/packages/cli/tests/unit/commands/GenerateCommand.test.ts index 9c29099..79ca4ca 100644 --- a/packages/cli/tests/unit/commands/GenerateCommand.test.ts +++ b/packages/cli/tests/unit/commands/GenerateCommand.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from 'vitest'; import { Generate } from '../../../src/commands/GenerateCommand.js'; import { ManifestParser } from '../../../src/utilities/ManifestParser.js'; +import { ManifestParserV2 } from '../../../src/utilities/ManifestParserV2.js'; import { LicenseStatus } from '../../../src/utilities/LicenseStatus.js'; import type { ComponentsData } from '@directededges/specs-from-figma'; @@ -275,6 +276,90 @@ describe('source auto-detection', () => { }); }); +// ============================================================================ +// V1 + V2 MANIFEST DETECTION & DISPATCH (issue #101) +// ============================================================================ + +describe('manifest format detection (v1 + v2)', () => { + // Mirrors the detection logic in GenerateCommand.action: + // const isV2Manifest = ManifestParserV2.isV2(sourceContent); + // const isV1Manifest = trimmed.includes('- ['); + // const isManifest = isV2Manifest || isV1Manifest; + // const isJson = trimmed.startsWith('{'); + function detect(content: string) { + const trimmed = content.trimStart(); + const isV2Manifest = ManifestParserV2.isV2(content); + const isV1Manifest = trimmed.includes('- ['); + return { + isV2Manifest, + isV1Manifest, + isManifest: isV2Manifest || isV1Manifest, + isJson: trimmed.startsWith('{'), + }; + } + + const V2_TABLE = [ + '# Component Manifest', + '', + '**Scan format version:** 2 ', + '**File:** data/specs-testing.file.json', + '', + '## Components', + '', + '| ✓ | Name | ID | Type | Dev Status |', + '|------|------|------|------|------------|', + '| [x] | DS Button | 639:11013 | COMPONENT_SET | NONE |', + ].join('\n'); + + const V1_LIST = [ + '# Component Manifest', + '', + '**File:** data/specs-testing.file.json', + '', + '- [x] DS Button (639:11013, COMPONENT_SET)', + ].join('\n'); + + it('detects a v2 table manifest as a manifest (was the #101 regression)', () => { + const d = detect(V2_TABLE); + expect(d.isV2Manifest).toBe(true); + expect(d.isManifest).toBe(true); + expect(d.isJson).toBe(false); + }); + + it('still detects a v1 checkbox-list manifest as a manifest', () => { + const d = detect(V1_LIST); + expect(d.isV1Manifest).toBe(true); + expect(d.isManifest).toBe(true); + expect(d.isJson).toBe(false); + }); + + it('detects raw JSON as file mode, not manifest', () => { + const d = detect('{ "name": "library", "components": {} }'); + expect(d.isManifest).toBe(false); + expect(d.isJson).toBe(true); + }); + + it('rejects unrecognized content (neither manifest nor JSON)', () => { + const d = detect('just some prose without checkboxes or a version header'); + expect(d.isManifest).toBe(false); + expect(d.isJson).toBe(false); + }); + + it('dispatches v2 content to ManifestParserV2', () => { + const { components, metadata } = ManifestParserV2.parse(V2_TABLE); + expect(metadata.scanFormatVersion).toBe(2); + expect(components).toHaveLength(1); + expect(components[0]).toMatchObject({ id: '639:11013', name: 'DS Button', included: true }); + }); + + it('dispatches v1 content to the legacy ManifestParser', () => { + const { components, metadata } = ManifestParser.parse(V1_LIST); + expect(metadata.file).toBe('data/specs-testing.file.json'); + expect(components).toHaveLength(1); + expect(components[0]).toMatchObject({ id: '639:11013', name: 'DS Button', included: true }); + }); +}); + // ============================================================================ // LICENSE KEY RESOLUTION (T04) // ============================================================================ diff --git a/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts b/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts index ca71205..b1bba7a 100644 --- a/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts +++ b/packages/cli/tests/unit/utilities/ManifestParserV2.test.ts @@ -63,4 +63,22 @@ describe('ManifestParserV2', () => { const { components } = ManifestParserV2.parse(empty); expect(components).toEqual([]); }); + + it('unescapes escaped pipes in names (round-trips scan escapeCell)', () => { + const fixture = [ + '**Scan format version:** 2', + '', + '## Components', + '', + '| ✓ | Name | ID | Type | Dev Status |', + '|------|------|------|------|------------|', + '| [x] | Toggle \\| On/Off | 1:23 | COMPONENT_SET | NONE |', + '' + ].join('\n'); + + const { components } = ManifestParserV2.parse(fixture); + expect(components).toHaveLength(1); + expect(components[0].name).toBe('Toggle | On/Off'); + expect(components[0].id).toBe('1:23'); + }); });