@jlcpcb/cli 0.4.0 → 0.4.1

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.
@@ -1,4 +1,4 @@
1
- import React, { useState } from 'react';
1
+ import React, { useEffect, useRef, useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import { createLibraryService } from '@jlcpcb/core';
4
4
  import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
@@ -9,13 +9,19 @@ import { Divider } from '../components/Divider.js';
9
9
  const libraryService = createLibraryService();
10
10
 
11
11
  export function LibrarySetupScreen() {
12
- const { push, pop } = useNavigation();
12
+ const { replace, pop } = useNavigation();
13
13
  const { params } = useCurrentScreen() as { screen: 'library-setup'; params: LibrarySetupParams };
14
14
  const { columns: terminalWidth } = useTerminalSize();
15
15
 
16
16
  const [selectedOption, setSelectedOption] = useState<'install' | 'cancel'>('install');
17
17
  const [isInstalling, setIsInstalling] = useState(false);
18
18
  const [error, setError] = useState<string | null>(null);
19
+ const activeRef = useRef(true);
20
+
21
+ useEffect(() => {
22
+ activeRef.current = true;
23
+ return () => { activeRef.current = false; };
24
+ }, []);
19
25
 
20
26
  useInput((input, key) => {
21
27
  if (isInstalling) return;
@@ -31,19 +37,20 @@ export function LibrarySetupScreen() {
31
37
  libraryService
32
38
  .ensureGlobalTables()
33
39
  .then(() => {
40
+ if (!activeRef.current) return;
34
41
  // Success - continue to install the component
35
- push('install', {
42
+ replace('install', {
36
43
  componentId: params.componentId,
37
44
  component: params.component,
45
+ installOptions: params.installOptions,
38
46
  });
39
47
  })
40
48
  .catch((err) => {
49
+ if (!activeRef.current) return;
41
50
  setError(err instanceof Error ? err.message : 'Unknown error');
42
51
  setIsInstalling(false);
43
52
  });
44
53
  }
45
- } else if (key.escape) {
46
- pop();
47
54
  }
48
55
  });
49
56
 
@@ -1,58 +1,55 @@
1
1
  import React, { useState } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
- import { createComponentService, type ComponentSearchResult } from '@jlcpcb/core';
3
+ import { createComponentService } from '@jlcpcb/core';
4
4
  import { useNavigation, useCurrentScreen } from '../navigation/NavigationContext.js';
5
5
  import type { SearchParams } from '../navigation/types.js';
6
- import { useAppState } from '../state/AppStateContext.js';
7
6
  import { useTerminalSize } from '../hooks/useTerminalSize.js';
8
7
  import { ListView } from '../components/ListView.js';
9
8
 
10
9
  const componentService = createComponentService();
11
10
 
12
11
  export function SearchScreen() {
13
- const { push } = useNavigation();
12
+ const { push, replace } = useNavigation();
14
13
  const { params } = useCurrentScreen() as { screen: 'search'; params: SearchParams };
15
- const { selectedIndex, setSelectedIndex, isFiltered, setIsFiltered, resetSelection } = useAppState();
16
14
  const { columns: terminalWidth } = useTerminalSize();
17
15
 
18
- const [results, setResults] = useState<ComponentSearchResult[]>(params.results);
16
+ const { results, selectedIndex = 0 } = params;
17
+ const isFiltered = params.searchOptions?.basicOnly ?? false;
19
18
  const [isSearching, setIsSearching] = useState(false);
20
19
 
21
20
  useInput(async (input, key) => {
22
21
  if (isSearching) return;
23
22
 
24
23
  if (key.upArrow) {
25
- setSelectedIndex(Math.max(0, selectedIndex - 1));
24
+ replace('search', { ...params, selectedIndex: Math.max(0, selectedIndex - 1) });
26
25
  } else if (key.downArrow) {
27
- setSelectedIndex(Math.min(results.length - 1, selectedIndex + 1));
26
+ replace('search', { ...params, selectedIndex: Math.max(0, Math.min(results.length - 1, selectedIndex + 1)) });
28
27
  } else if (key.return && results[selectedIndex]) {
29
28
  const selected = results[selectedIndex];
30
29
  if (selected.idType === 'easyeda_uuid') {
31
30
  push('easyeda-info', {
32
31
  uuid: selected.easyedaUuid ?? selected.id,
32
+ installOptions: params.installOptions,
33
33
  });
34
34
  } else {
35
35
  push('info', {
36
36
  componentId: selected.lcscId ?? selected.id,
37
37
  component: selected,
38
+ installOptions: params.installOptions,
38
39
  });
39
40
  }
40
41
  } else if (key.tab) {
41
42
  setIsSearching(true);
42
43
  const newFiltered = !isFiltered;
43
44
  try {
44
- let newResults = await componentService.search(params.query, {
45
- limit: 20,
46
- basicOnly: newFiltered,
47
- });
45
+ const searchOptions = { ...params.searchOptions, basicOnly: newFiltered };
46
+ let newResults = await componentService.search(params.query, searchOptions);
48
47
  newResults = newResults.sort((a, b) => {
49
48
  if (a.libraryType === 'basic' && b.libraryType !== 'basic') return -1;
50
49
  if (a.libraryType !== 'basic' && b.libraryType === 'basic') return 1;
51
50
  return 0;
52
51
  });
53
- setResults(newResults);
54
- resetSelection();
55
- setIsFiltered(newFiltered);
52
+ replace('search', { ...params, results: newResults, searchOptions, selectedIndex: 0 });
56
53
  } catch {
57
54
  // Keep existing results on error
58
55
  }
@@ -2,15 +2,10 @@ import React, { createContext, useContext, useState, useCallback, type ReactNode
2
2
 
3
3
  export interface AppState {
4
4
  selectedIndex: number;
5
- isFiltered: boolean;
6
- isLoading: boolean;
7
5
  }
8
6
 
9
7
  export interface AppStateActions {
10
8
  setSelectedIndex: (index: number) => void;
11
- setIsFiltered: (filtered: boolean) => void;
12
- setIsLoading: (loading: boolean) => void;
13
- resetSelection: () => void;
14
9
  }
15
10
 
16
11
  export type AppStateContextValue = AppState & AppStateActions;
@@ -23,33 +18,15 @@ interface AppStateProviderProps {
23
18
 
24
19
  export function AppStateProvider({ children }: AppStateProviderProps) {
25
20
  const [selectedIndex, setSelectedIndexState] = useState(0);
26
- const [isFiltered, setIsFilteredState] = useState(false);
27
- const [isLoading, setIsLoadingState] = useState(false);
28
21
 
29
22
  const setSelectedIndex = useCallback((index: number) => {
30
23
  setSelectedIndexState(index);
31
24
  }, []);
32
25
 
33
- const setIsFiltered = useCallback((filtered: boolean) => {
34
- setIsFilteredState(filtered);
35
- }, []);
36
-
37
- const setIsLoading = useCallback((loading: boolean) => {
38
- setIsLoadingState(loading);
39
- }, []);
40
-
41
- const resetSelection = useCallback(() => {
42
- setSelectedIndexState(0);
43
- }, []);
44
26
 
45
27
  const value: AppStateContextValue = {
46
28
  selectedIndex,
47
- isFiltered,
48
- isLoading,
49
29
  setSelectedIndex,
50
- setIsFiltered,
51
- setIsLoading,
52
- resetSelection,
53
30
  };
54
31
 
55
32
  return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>;
@@ -0,0 +1,67 @@
1
+ import { mock } from 'bun:test';
2
+ import * as core from '@jlcpcb/core';
3
+
4
+ const scenario = process.argv[2];
5
+ const manufacturing = scenario.startsWith('footprint-');
6
+ const symbolScenario = scenario === 'disabled-footprint' || scenario.startsWith('symbol-');
7
+ const symbolReference = `<svg>
8
+ <g c_partid="part_pin" c_spicepin="1" c_origin="-10,0" c_elec="1" ${scenario === 'symbol-unverified' ? '' : 'c_rotation="180"'}>
9
+ ${scenario === 'symbol-unverified' ? '' : '<path d="M-10,0h5"/>'}<text>IN</text><text>1</text>
10
+ </g>
11
+ <g c_partid="part_pin" c_spicepin="2" c_origin="10,0" c_elec="2" c_rotation="0">
12
+ <path d="M10,0h-5"/><text>OUT</text><text>2</text>
13
+ </g>
14
+ </svg>`;
15
+ const reference = manufacturing
16
+ ? {
17
+ footprintSvg: `<svg><g c_partid="part_pad" number="1" c_origin="0,0" ${scenario === 'footprint-unverified' ? '' : 'layerid="1"'}>
18
+ <rect x="-2" y="-1" width="4" height="2" />
19
+ <circle c_padhole="1" cx="0" cy="0" r="0" />
20
+ </g></svg>`,
21
+ symbolSvg: null,
22
+ }
23
+ : symbolScenario
24
+ ? { footprintSvg: null, symbolSvg: symbolReference }
25
+ : { footprintSvg: null, symbolSvg: null };
26
+
27
+ mock.module('@jlcpcb/core', () => ({
28
+ ...core,
29
+ fetchReferenceSVG: async () => reference,
30
+ easyedaClient: { getComponentData: async () => ({
31
+ info: { name: scenario === 'footprint-identity-mpn' ? 'DifferentPart' : 'Part' },
32
+ footprint: { name: scenario === 'footprint-identity-package' ? 'DifferentPackage' : 'PKG' },
33
+ }) },
34
+ footprintConverter: {
35
+ convert: () => `(footprint "Part" (pad "1" smd rect (at ${scenario === 'footprint-moved' ? 4 : 0} 0) (size 1.016 0.508) (layers "F.Cu")))`,
36
+ },
37
+ symbolConverter: {
38
+ convert: () => `(symbol "Part" (symbol "Part_1_1"
39
+ (pin input line (at -2.54 0 0) (length 1.27) (name "IN") (number "1"))
40
+ (pin output line (at ${scenario === 'symbol-moved' ? 4.54 : 2.54} 0 180) (length 1.27) (name "OUT") (number "2"))))`,
41
+ },
42
+ getAllTestComponents: () => [{ lcsc: 'C123', name: 'Part', expectedMpn: 'Part', expectedPackage: 'PKG' }],
43
+ createLibraryService: () => ({
44
+ listInstalled: async () => [{ lcscId: 'C123', name: 'Part' }],
45
+ regenerate: async () => ({
46
+ total: 1,
47
+ success: 0,
48
+ failed: 1,
49
+ components: [{ id: 'C123', name: 'Part', status: 'failed', error: 'conversion failed' }],
50
+ }),
51
+ }),
52
+ }));
53
+
54
+ // Commands must load after the subprocess-specific dependency mocks.
55
+ if (scenario === 'regenerate') {
56
+ const { regenerateCommand } = await import('../library.js');
57
+ await regenerateCommand({});
58
+ } else {
59
+ const { validateCommand } = await import('../validate.js');
60
+ const batch = scenario === 'batch-missing' || scenario.startsWith('footprint-identity-');
61
+ await validateCommand(batch ? undefined : 'C123', {
62
+ json: scenario !== 'text-missing',
63
+ all: batch,
64
+ symbolOnly: symbolScenario,
65
+ footprintOnly: manufacturing,
66
+ });
67
+ }
@@ -1,6 +1,4 @@
1
1
  import { describe, expect, it, mock, spyOn } from 'bun:test';
2
- import { readFileSync } from 'fs';
3
- import { join } from 'path';
4
2
 
5
3
  const mockDetails = {
6
4
  lcscId: 'C123',
@@ -193,11 +191,4 @@ describe('agent JSON CLI output', () => {
193
191
  write.mockRestore();
194
192
  });
195
193
 
196
- it('validate --json has a quiet JSON path before human output', () => {
197
- const source = readFileSync(join(import.meta.dir, 'validate.ts'), 'utf-8');
198
-
199
- expect(source).toContain('const spinner = options.json ? null : p.spinner()');
200
- expect(source).toContain('printJson({');
201
- expect(source.indexOf('if (options.json)')).toBeLessThan(source.indexOf('console.log(chalk.bold'));
202
- });
203
194
  });
@@ -120,7 +120,7 @@ export async function easyedaInstallCommand(
120
120
 
121
121
  try {
122
122
  // Ensure libraries are set up
123
- await libraryService.ensureGlobalTables()
123
+ if (!options.projectPath) await libraryService.ensureGlobalTables()
124
124
 
125
125
  // Install the component
126
126
  const result = await libraryService.install(uuid, {
@@ -161,10 +161,10 @@ export async function easyedaInstallCommand(
161
161
  return
162
162
  }
163
163
 
164
- // If UUID provided (without --force), launch TUI to fetch and display
164
+ // If UUID provided, launch TUI to fetch and display.
165
165
  if (uuid) {
166
166
  // Launch TUI at EasyEDA info screen - let it fetch the component
167
- await renderApp('easyeda-info', { uuid })
167
+ await renderApp('easyeda-info', { uuid, installOptions: options })
168
168
  return
169
169
  }
170
170
 
@@ -198,5 +198,5 @@ export async function easyedaInstallCommand(
198
198
 
199
199
  // Clear the "Searching..." line and launch interactive UI
200
200
  process.stdout.write('\x1b[1A\x1b[2K')
201
- await renderApp('search', { query: query as string, results })
201
+ await renderApp('search', { query: query as string, results, searchOptions, installOptions: options })
202
202
  }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { join } from 'node:path';
4
+
5
+ function run(scenario: string) {
6
+ // Isolate module mocks and process.exit from other CLI/core tests.
7
+ return spawnSync(process.execPath, [join(import.meta.dir, '__fixtures__/failure-scenarios.ts'), scenario], {
8
+ encoding: 'utf8',
9
+ timeout: 15_000,
10
+ });
11
+ }
12
+
13
+ describe('command failure exit status', () => {
14
+ it('reports failed regenerations and exits unsuccessfully', () => {
15
+ const result = run('regenerate');
16
+ expect(result.status).toBe(1);
17
+ expect(result.stdout).toContain('C123');
18
+ expect(result.stdout).toContain('conversion failed');
19
+ });
20
+
21
+ it('does not pass absent requested reference SVGs in JSON', () => {
22
+ const result = run('missing');
23
+ expect(result.status).toBe(1);
24
+ expect(JSON.parse(result.stdout)).toMatchObject({
25
+ success: false,
26
+ result: { passed: false, error: expect.stringContaining('footprint, symbol') },
27
+ });
28
+ });
29
+
30
+ it('reports absent requested references in text', () => {
31
+ const result = run('text-missing');
32
+ expect(result.status).toBe(1);
33
+ expect(result.stdout).toContain('Missing reference SVG: footprint, symbol');
34
+ });
35
+
36
+ it('counts missing references as batch errors instead of passes', () => {
37
+ const result = run('batch-missing');
38
+ expect(result.status).toBe(1);
39
+ expect(JSON.parse(result.stdout)).toMatchObject({
40
+ success: false,
41
+ summary: { total: 1, passed: 0, failed: 0, errors: 1 },
42
+ });
43
+ });
44
+
45
+ it('allows an absent footprint reference when its check was disabled', () => {
46
+ const result = run('disabled-footprint');
47
+ expect(result.status).toBe(0);
48
+ expect(JSON.parse(result.stdout)).toMatchObject({
49
+ success: true,
50
+ result: { passed: true, footprint: null, symbol: { passed: true } },
51
+ });
52
+ });
53
+
54
+ it('rejects symbol pin displacement through the real comparator', () => {
55
+ const result = run('symbol-moved');
56
+ expect(result.status).toBe(1);
57
+ const output = JSON.parse(result.stdout);
58
+ expect(output.success).toBe(false);
59
+ expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
60
+ });
61
+
62
+ it('does not pass symbol pins with unknown reference direction', () => {
63
+ const result = run('symbol-unverified');
64
+ expect(result.status).toBe(1);
65
+ const output = JSON.parse(result.stdout);
66
+ expect(output.success).toBe(false);
67
+ expect(output.result.symbol.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
68
+ });
69
+
70
+ it('rejects a manufacturing position mismatch through the real comparator', () => {
71
+ const result = run('footprint-moved');
72
+ expect(result.status).toBe(1);
73
+ const output = JSON.parse(result.stdout);
74
+ expect(output.success).toBe(false);
75
+ expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'position')).toBe(true);
76
+ });
77
+
78
+ it('does not claim success for an unverified reference copper layer', () => {
79
+ const result = run('footprint-unverified');
80
+ expect(result.status).toBe(1);
81
+ const output = JSON.parse(result.stdout);
82
+ expect(output.success).toBe(false);
83
+ expect(output.result.footprint.errors.some((e: { field: string }) => e.field === 'coverage')).toBe(true);
84
+ });
85
+
86
+ it('validates matching, fully described manufacturing geometry', () => {
87
+ const result = run('footprint-matching');
88
+ expect(result.status).toBe(0);
89
+ expect(JSON.parse(result.stdout)).toMatchObject({
90
+ success: true,
91
+ result: { passed: true, footprint: { passed: true, errors: [] } },
92
+ });
93
+ });
94
+
95
+ it.each(['mpn', 'package'])('rejects a changed fixture %s during batch validation', (field) => {
96
+ const result = run(`footprint-identity-${field}`);
97
+ expect(result.status).toBe(1);
98
+ const output = JSON.parse(result.stdout);
99
+ expect(output).toMatchObject({
100
+ success: false,
101
+ summary: { total: 1, passed: 0, failed: 0, errors: 1 },
102
+ });
103
+ expect(output.results[0].error).toMatch(field === 'mpn' ? /Fixture MPN differs/ : /Fixture package differs/);
104
+ });
105
+ });
@@ -56,7 +56,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
56
56
 
57
57
  try {
58
58
  // Ensure libraries are set up
59
- await libraryService.ensureGlobalTables();
59
+ if (!options.projectPath) await libraryService.ensureGlobalTables();
60
60
 
61
61
  // Install the component
62
62
  const result = await libraryService.install(id, {
@@ -97,7 +97,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
97
97
  return;
98
98
  }
99
99
 
100
- // If ID provided (without --force), fetch component and launch TUI for install
100
+ // If ID provided, fetch component and launch TUI for install.
101
101
  if (id) {
102
102
  console.log(`Fetching component ${id}...`);
103
103
 
@@ -105,7 +105,7 @@ export async function installCommand(id: string | undefined, options: InstallOpt
105
105
  const details = await componentService.getDetails(id);
106
106
  // Clear the "Fetching..." line and launch interactive UI
107
107
  process.stdout.write('\x1b[1A\x1b[2K');
108
- await renderApp('info', { componentId: id, component: details as any });
108
+ await renderApp('info', { componentId: id, component: details, installOptions: options });
109
109
  } catch (error) {
110
110
  console.error(`Failed to fetch component: ${error instanceof Error ? error.message : 'Unknown error'}`);
111
111
  process.exit(1);
@@ -147,5 +147,5 @@ export async function installCommand(id: string | undefined, options: InstallOpt
147
147
 
148
148
  // Clear the "Searching..." line and launch interactive UI
149
149
  process.stdout.write('\x1b[1A\x1b[2K');
150
- await renderApp('search', { query: query as string, results });
150
+ await renderApp('search', { query: query as string, results, searchOptions, installOptions: options });
151
151
  }
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from 'bun:test';
2
+ import { spawnSync } from 'node:child_process';
3
+ import { mkdtempSync, rmSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { join } from 'node:path';
6
+
7
+ describe('interactive install artifacts', () => {
8
+ for (const [scenario, behavior] of [
9
+ ['lcsc', 'reinstalls the selected LCSC project artifact with downloaded 3D bytes through no-ID search'],
10
+ ['community', 'ignores global community installation status and installs/reinstalls locally without 3D through no-ID search'],
11
+ ]) {
12
+ it(behavior, () => {
13
+ // Core services and Ink are real; only upstream clients and terminal/prompt IO are substituted.
14
+ const root = mkdtempSync(join(tmpdir(), 'jlc-interactive-install-'));
15
+ try {
16
+ const result = spawnSync(process.execPath, [
17
+ join(import.meta.dir, '../app/__fixtures__/interactive-install-scenarios.tsx'), scenario, root,
18
+ ], {
19
+ encoding: 'utf8',
20
+ timeout: 15_000,
21
+ env: { ...process.env, HOME: join(root, 'home'), USERPROFILE: join(root, 'home'), XDG_CONFIG_HOME: join(root, 'config') },
22
+ });
23
+ expect({ status: result.status, stderr: result.stderr }).toEqual({ status: 0, stderr: '' });
24
+ } finally {
25
+ rmSync(root, { recursive: true, force: true });
26
+ }
27
+ }, 20_000);
28
+ }
29
+ });
@@ -91,5 +91,6 @@ export async function regenerateCommand(options: RegenerateOptions): Promise<voi
91
91
  for (const comp of result.components.filter(c => c.status === 'failed')) {
92
92
  console.log(chalk.red(` • ${comp.name} (${comp.id}): ${comp.error}`));
93
93
  }
94
+ process.exitCode = 1;
94
95
  }
95
96
  }
@@ -58,7 +58,7 @@ export async function searchCommand(query: string, options: SearchCommandOptions
58
58
 
59
59
  // Clear the "Searching..." line and launch interactive UI
60
60
  process.stdout.write('\x1b[1A\x1b[2K');
61
- await renderApp('search', { query, results });
61
+ await renderApp('search', { query, results, searchOptions: options });
62
62
  } catch (error) {
63
63
  if (options.json) {
64
64
  printJsonError('search_failed', getErrorMessage(error), { retryable: true });
@@ -18,7 +18,6 @@ import {
18
18
  formatSymbolComparisonResult,
19
19
  generateValidationReport,
20
20
  generateBatchReport,
21
- TEST_CATEGORIES,
22
21
  getAllTestComponents,
23
22
  getTestComponentsByCategory,
24
23
  getCategoryNames,
@@ -26,6 +25,7 @@ import {
26
25
  renderSymbolSvg,
27
26
  type ValidationResult,
28
27
  type ReportSvgs,
28
+ type TestComponent,
29
29
  easyedaClient,
30
30
  footprintConverter,
31
31
  symbolConverter,
@@ -46,7 +46,8 @@ interface ValidateOptions {
46
46
  */
47
47
  async function validateComponent(
48
48
  lcscCode: string,
49
- options: { footprint: boolean; symbol: boolean }
49
+ options: { footprint: boolean; symbol: boolean },
50
+ identity?: Pick<TestComponent, 'expectedMpn' | 'expectedPackage'>
50
51
  ): Promise<ValidationResult> {
51
52
  const startTime = Date.now();
52
53
  const normalizedCode = lcscCode.replace(/^C/i, '');
@@ -70,9 +71,18 @@ async function validateComponent(
70
71
  error: 'Failed to fetch component data from EasyEDA',
71
72
  };
72
73
  }
74
+ if (identity?.expectedMpn !== undefined && componentData.info.name !== identity.expectedMpn) {
75
+ throw new Error(`Fixture MPN differs: expected ${identity.expectedMpn}, got ${componentData.info.name}`);
76
+ }
77
+ if (identity?.expectedPackage !== undefined && componentData.footprint.name !== identity.expectedPackage) {
78
+ throw new Error(`Fixture package differs: expected ${identity.expectedPackage}, got ${componentData.footprint.name}`);
79
+ }
73
80
 
74
81
  let footprintResult = null;
75
82
  let symbolResult = null;
83
+ const missingReferences: string[] = [];
84
+ if (options.footprint && !reference.footprintSvg) missingReferences.push('footprint');
85
+ if (options.symbol && !reference.symbolSvg) missingReferences.push('symbol');
76
86
 
77
87
  // Validate footprint
78
88
  if (options.footprint && reference.footprintSvg) {
@@ -81,10 +91,7 @@ async function validateComponent(
81
91
  const refData = extractFromReferenceSVG(reference.footprintSvg);
82
92
  const genData = extractFromKiCadFootprint(kicadContent);
83
93
 
84
- footprintResult = compareFootprints(refData, genData, {
85
- sizeWarningsOnly: true,
86
- positionTolerance: 50,
87
- });
94
+ footprintResult = compareFootprints(refData, genData);
88
95
  }
89
96
 
90
97
  // Validate symbol
@@ -96,12 +103,11 @@ async function validateComponent(
96
103
  const refData = extractSymbolFromReferenceSVG(reference.symbolSvg);
97
104
  const genData = extractFromKiCadSymbol(kicadContent);
98
105
 
99
- symbolResult = compareSymbols(refData, genData, {
100
- positionTolerance: 50,
101
- });
106
+ symbolResult = compareSymbols(refData, genData);
102
107
  }
103
108
 
104
109
  const passed =
110
+ missingReferences.length === 0 &&
105
111
  (!footprintResult || footprintResult.passed) && (!symbolResult || symbolResult.passed);
106
112
 
107
113
  return {
@@ -112,6 +118,9 @@ async function validateComponent(
112
118
  symbol: symbolResult,
113
119
  timestamp: new Date(),
114
120
  durationMs: Date.now() - startTime,
121
+ ...(missingReferences.length > 0
122
+ ? { error: `Missing reference SVG: ${missingReferences.join(', ')}` }
123
+ : {}),
115
124
  };
116
125
  } catch (error) {
117
126
  return {
@@ -287,13 +296,13 @@ export async function validateCommand(
287
296
  const result = await validateComponent(component.lcsc, {
288
297
  footprint: validateFootprint,
289
298
  symbol: validateSymbol,
290
- });
299
+ }, component);
291
300
 
292
301
  results.push(result);
293
302
 
294
303
  if (result.error) {
295
304
  errors++;
296
- if (!options.json) console.log(chalk.yellow('⚠ ERROR'));
305
+ if (!options.json) console.log(chalk.yellow(`ERROR: ${result.error}`));
297
306
  } else if (result.passed) {
298
307
  passed++;
299
308
  if (!options.json) console.log(chalk.green('✓ PASS'));