main
Colby Knox Merge PR #279: 0.2.13: Altium library refuses a symbol spec without pin types bc2975b
"""altium-lane: the Altium lane's pure helpers (no bridge, no run state), run in front of altium-project.py
and tested on their own (tests/altium-lane.py).

  Symbols and pin types (the Altium half of `schematic check`, issue #39 items 1 and 2):
    check_symbols(placed, specs, prims, spec) judges the drawn sheet as Altium reads it back
    (altium_schematic_data: every placed part's pins with their electrical type and place), each used
    symbol's SchLib record (altium_symbol_prims: pins, rectangles, display modes, body rectangle) and the
    symbol spec the library step imported (altium-symbol-modes output, kept in <run>/altium/symbols/):
      - pins on the body: a pin's x,y is its BODY end and must sit on the body rectangle's edge, pointing
        away from it, in every display mode (the spec) and on the sheet (the drawn mode, read back);
      - pin electrical types on active devices (U/IC references, op-amps and comparators), with the KiCad
        check's rules: an amplifier input or an output declared passive is an error, a passive power pin
        an error on op-amps and comparators and a warning on other ICs;
      - polarity drawings of the small parts drawn as their own symbol (the spec's native mode): the diode
        triangle and cathode bar at the pin named K, a BJT's emitter arrow on the pin named E.
  Project-library parts (issue #66 on the Altium lane):
    part_origin(), single_symbol_lib(), stage_project_part(): a part with no wiki component page that the
    board's project library gives (`parts --project-lib`) is staged as the lane's pages/<key>/ and goes
    through the same converters as a wiki part.
"""
import math, re, shutil
from pathlib import Path

MIL = 0.0254  # mm per mil

# Altium's TPinElectrical ordinals (altium_schematic_data reports the ordinal) and the KiCad names the
# rules use; Altium has one Power type (no power_in / power_out)
ALTIUM_ETYPE = {0: 'input', 1: 'bidirectional', 2: 'output', 3: 'open_collector', 4: 'passive', 5: 'tri_state', 6: 'open_emitter', 7: 'power'}
# Altium's pin Orientation ordinal (eRotate0..eRotate270): the direction the pin runs away from the body
ALTIUM_ORIENT = {0: 'right', 1: 'up', 2: 'left', 3: 'down'}
DIRS = {'right': (1, 0), 'up': (0, 1), 'left': (-1, 0), 'down': (0, -1)}

LIMITS = ("Altium: pins checked on the body rectangle (every display mode from the imported spec, the drawn mode read back "
          "from the sheet); pin types read from the placed parts (Altium's Power has no in/out); polarity only on small parts "
          "drawn as their own symbol (diodes with pins named K/A, BJTs with pins named E/C), arcs ignored; op-amp +/- are the "
          "box symbol's shown pin names (no drawn signs to contradict them); sheet readability not checked (altium_build_schematic "
          "draws one wire stub and one net label per pin)")


# ---------------------------------------------------------------- symbol specs

def parse_symbol_spec(text):
    """An altium-symbol-modes spec (META, MODE, RECT, PIN, LINE, ARC lines; mils, y up) as
    {libref, desc, designator, modes: {i: {name, rects, lines, arcs, pins: [{number, name, x, y, orient, len, hidden}]}}}."""
    sym = {'libref': '', 'desc': '', 'designator': 'U', 'modes': {}}

    def mode(i):
        return sym['modes'].setdefault(i, {'name': '', 'rects': [], 'lines': [], 'arcs': [], 'pins': []})
    for raw in (text or '').splitlines():
        f = raw.rstrip('\r').split('|')
        try:
            if f[0] == 'META': sym['libref'], sym['desc'], sym['designator'] = (f + ['', '', ''])[1:4]
            elif f[0] == 'MODE': mode(int(f[1]))['name'] = f[2] if len(f) > 2 else ''
            elif f[0] == 'RECT': mode(int(f[1]))['rects'].append(tuple(float(v) for v in f[2:6]))
            elif f[0] == 'LINE': mode(int(f[1]))['lines'].append(tuple(float(v) for v in f[2:6]))
            elif f[0] == 'ARC': mode(int(f[1]))['arcs'].append(tuple(float(v) for v in f[2:7]))
            elif f[0] == 'PIN':
                mode(int(f[1]))['pins'].append({'number': f[2], 'name': f[3], 'x': float(f[4]), 'y': float(f[5]), 'orient': f[6],
                                                 'len': float(f[7]), 'hidden': len(f) > 8 and f[8] == 'hide', 'etype': f[9] if len(f) > 9 else None})
        except (ValueError, IndexError):
            continue
    return sym


def _box(r):
    return min(r[0], r[2]), min(r[1], r[3]), max(r[0], r[2]), max(r[1], r[3])


def pin_edge_problems(pins, rect, tol=1.0):
    """The pins whose body end (x, y) is not on the rectangle's edge, or that point into the body:
    [(pin, 'off', distance in mil) | (pin, 'inward', 0)]."""
    x0, y0, x1, y1 = _box(rect)
    out = []
    for p in pins:
        x, y = p['x'], p['y']
        on_l, on_r, on_b, on_t = abs(x - x0) <= tol, abs(x - x1) <= tol, abs(y - y0) <= tol, abs(y - y1) <= tol
        in_y, in_x = y0 - tol <= y <= y1 + tol, x0 - tol <= x <= x1 + tol
        if not (((on_l or on_r) and in_y) or ((on_b or on_t) and in_x)):
            dx = max(x0 - x, 0, x - x1); dy = max(y0 - y, 0, y - y1)
            d = math.hypot(dx, dy) if (dx or dy) else min(x - x0, x1 - x, y - y0, y1 - y)
            out.append((p, 'off', round(d)))
            continue
        ux, uy = DIRS.get(p.get('orient'), (0, 0))
        away = (on_l and in_y and ux < 0) or (on_r and in_y and ux > 0) or (on_b and in_x and uy < 0) or (on_t and in_x and uy > 0)
        if not away: out.append((p, 'inward', 0))
    return out


def _edge_line(who, what, probs, total, native):
    eg = []
    for p, kind, d in probs[:3]:
        nm = ' (%s)' % p['name'] if p.get('name') else ''
        eg.append('pin %s%s %s' % (p['number'], nm, ('%d mil off the body edge' % d) if kind == 'off' else 'points into the body'))
    more = '' if len(probs) <= 3 else ', ...'
    why = ("a small part's pin should end on its drawing" if native else
           "a pin's x,y is its body end, on the body rectangle's edge (pins one pin length off the box, names in the gap, was the symbol_modes bug fixed 2026-10-19)")
    return '%s %s: %d of %d pin(s) not on the body edge: %s%s; %s' % (who, what, len(probs), total, '; '.join(eg), more, why)


# ---------------------------------------------------------------- names (the KiCad check's rules)

def norm(name):
    n = (name or '').strip().upper()
    for ch in '{}~ _': n = n.replace(ch, '')
    return n.replace('−', '-').replace('–', '-')


def amp_input(name):
    """'+' or '-' for a pin named as an amplifier input, else None."""
    n = norm(name)
    if n in ('+', '-'): return n
    if 'IN' in n and not n.startswith('V'):
        if '+' in n: return '+'
        if '-' in n: return '-'
        if n.endswith('INP') or n == 'NONINV': return '+'
        if n.endswith('INN'): return '-'
    return None


def is_output(name):
    s = norm(name).lstrip('0123456789')
    return s in ('OUT', 'VOUT') or (s.startswith('OUT') and len(s) == 4)


def is_power(name):
    n = norm(name)
    return (n in ('V+', 'V-', '+V', '-V', 'VS', 'VS+', 'VS-', '+VS', '-VS', 'AVDD', 'DVDD', 'AVSS', 'DVSS', 'AVCC')
            or any(n.startswith(p) for p in ('VCC', 'VDD', 'VEE', 'VSS')) or (n.endswith('GND') and len(n) <= 5))


def ref_prefix(r):
    return re.match(r'[A-Za-z]*', r or '').group(0).upper()


def amp_like(info, pin_names):
    i = (info or '').lower()
    return any(k in i for k in ('opamp', 'op amp', 'op-amp', 'operational', 'comparator')) or (
        any(amp_input(n) == '+' for n in pin_names) and any(amp_input(n) == '-' for n in pin_names))


def pin_type_findings(pins, amp):
    """[(severity 'error'|'warn', pin, message)] for one symbol's pins ({number, name, etype})."""
    out, seen = [], set()
    ok_out = ('output', 'tri_state', 'open_collector', 'open_emitter', 'power', 'power_out', 'bidirectional')
    for p in pins:
        k = (p['number'], p['name'])
        if k in seen: continue
        seen.add(k)
        et, s = p['etype'], amp_input(p['name'])
        if s:
            if et == 'passive': out.append(('error', p, 'amplifier input `%s` (%s) is passive; declare it Input' % (p['name'], s)))
            elif et != 'input': out.append(('warn', p, 'amplifier input `%s` is %s; amplifier inputs are Input' % (p['name'], et)))
        elif is_output(p['name']):
            if et == 'passive': out.append(('error', p, 'output `%s` is passive; declare it Output (Power for a regulator output)' % p['name']))
            elif et not in ok_out: out.append(('warn', p, 'output `%s` is %s' % (p['name'], et)))
        elif is_power(p['name']):
            if et == 'passive': out.append(('error' if amp else 'warn', p, 'power pin `%s` is passive; declare it Power' % p['name']))
            elif et not in ('power', 'power_in', 'power_out'): out.append(('warn', p, 'power pin `%s` is %s; power pins are Power' % (p['name'], et)))
    return out


def passive_allowed(spec, ref, pin):
    """spec.schematic.allowPassive [{ref, pin?, why}]: True (allowed with a datasheet why), False (named
    without a why), None (not named)."""
    for e in ((spec or {}).get('schematic') or {}).get('allowPassive') or []:
        if e.get('ref') == ref and (e.get('pin') is None or e.get('pin') in (pin['number'], pin['name'])):
            return len((e.get('why') or '').strip()) >= 8
    return None


def polarity_reviewed(spec, libref, refs):
    for e in ((spec or {}).get('schematic') or {}).get('polarityReviewed') or []:
        if (e.get('symbol') == libref or e.get('symbol') in refs) and len((e.get('why') or '').strip()) >= 8: return True
    return False


# ---------------------------------------------------------------- polarity (the KiCad check, on LINE segments)

def _d(a, b): return math.hypot(a[0] - b[0], a[1] - b[1])


def _same(a, b): return _d(a, b) < 0.01


def _triangles(segs):
    """Closed triangles drawn as three LINE segments (a KiCad closed polyline becomes segments in the spec)."""
    tris, used = [], set()
    n = len(segs)
    for i in range(n):
        for j in range(i + 1, n):
            for k in range(j + 1, n):
                ends = [segs[i][0], segs[i][1], segs[j][0], segs[j][1], segs[k][0], segs[k][1]]
                pts = []
                for e in ends:
                    if not any(_same(e, q) for q in pts): pts.append(e)
                if len(pts) == 3 and all(sum(1 for e in ends if _same(e, q)) == 2 for q in pts) and all(not _same(s[0], s[1]) for s in (segs[i], segs[j], segs[k])):
                    tris.append(pts); used |= {i, j, k}
    return tris, used


def _vees(segs, used, max_len=1.5):
    """Two short segments sharing an end (an arrow head whose fill the converter dropped): their 3 points."""
    out = []
    for i in range(len(segs)):
        for j in range(i + 1, len(segs)):
            if i in used or j in used: continue
            a, b = segs[i], segs[j]
            if _d(*a) > max_len or _d(*b) > max_len: continue
            for p in a:
                for q in b:
                    if _same(p, q): out.append([a[0] if a[1] is p else a[1], p, b[0] if b[1] is q else b[1]])
    return out


def _named(pins, names):
    return [p for p in pins if norm(p['name']) in names]


def diode_finding(pins, tris, bars_open, bars_closed):
    """The KiCad check's diode rule: (severity, message) or None. pins carry 'at' (outer end) in mm."""
    k, a = _named(pins, ('K', 'CATHODE', 'KATHODE')), _named(pins, ('A', 'ANODE'))
    if len(k) != 1 or len(a) != 1: return None
    k, a = k[0], a[0]
    l = _d(a['at'], k['at'])
    if l < 0.1: return None
    u = ((k['at'][0] - a['at'][0]) / l, (k['at'][1] - a['at'][1]) / l)
    t = lambda p: ((p[0] - a['at'][0]) * u[0] + (p[1] - a['at'][1]) * u[1]) / l
    s = lambda p: -(p[0] - a['at'][0]) * u[1] + (p[1] - a['at'][1]) * u[0]
    who = 'pin %s (%s) is the cathode, pin %s (%s) the anode' % (k['number'], k['name'], a['number'], a['name'])
    tri = None
    for pts in tris:
        for i in range(3):
            ap, pj, pk = pts[i], pts[(i + 1) % 3], pts[(i + 2) % 3]
            width = abs(s(pj) - s(pk))
            if width < 0.3 or abs(t(pj) - t(pk)) * l > 0.25 * width or abs(s(ap)) > 0.25 * width or abs((s(pj) + s(pk)) / 2) > 0.25 * width: continue
            if tri is None or width > tri[2]: tri = ((t(pj) + t(pk)) / 2, t(ap), width)
    bars = []
    for pts, closed in [(b, False) for b in bars_open] + [(b, True) for b in bars_closed]:
        ts, ss = [t(p) for p in pts], [s(p) for p in pts]
        along = (max(ts) - min(ts)) * l
        smin, smax = min(ss), max(ss)
        across = smax - smin
        if across < 0.5 or across < (3.0 if closed else 1.5) * along or smin > -0.2 or smax < 0.2: continue
        n = len(pts)
        i = max(range(n if closed else n - 1), key=lambda i: _d(pts[i], pts[(i + 1) % n]))
        bars.append((t(((pts[i][0] + pts[(i + 1) % n][0]) / 2, (pts[i][1] + pts[(i + 1) % n][1]) / 2)), across))
    if tri: bar = min(bars, key=lambda b: abs(b[0] - tri[1]))[0] if bars else None
    else: bar = max(bars, key=lambda b: b[1])[0] if bars else None
    if tri:
        base, apex = tri[0], tri[1]
        if apex < base:
            if bar is None or abs(bar - apex) < abs(bar - base):
                return ('error', 'the diode is drawn pointing at the anode: the triangle and cathode bar are at the pin %s end, but %s' % (a['number'], who))
            return ('warn', 'the diode triangle points at pin %s (named %s) while the bar is at the other end; %s' % (a['number'], a['name'], who))
        if bar is not None and bar < base - 0.02: return ('warn', 'the cathode bar is on the anode side of the triangle; %s' % who)
        return None
    if bar is None: return ('warn', 'no cathode bar or triangle recognised in the drawing; check by eye that the bar is at the cathode (%s)' % who)
    if bar < 0.4: return ('error', 'the cathode bar is drawn at the pin %s end, but %s' % (a['number'], who))
    if bar < 0.6: return ('warn', 'the cathode bar is near the middle, so the drawing does not show which end is the cathode; %s' % who)
    return None


def bjt_finding(pins, arrows):
    """The KiCad check's BJT rule on arrow heads (point lists, mm): (severity, message) or None."""
    e, c = _named(pins, ('E', 'EMITTER')), _named(pins, ('C', 'COLLECTOR'))
    if len(e) != 1 or len(c) != 1: return None
    e, c = e[0], c[0]
    small = [pts for pts in arrows if math.hypot(max(p[0] for p in pts) - min(p[0] for p in pts), max(p[1] for p in pts) - min(p[1] for p in pts)) < 2.5]
    if not small: return ('warn', 'a transistor with E and C pins but no emitter arrow recognised in the drawing')
    cen = [(sum(p[0] for p in pts) / len(pts), sum(p[1] for p in pts) / len(pts)) for pts in small]
    best = min(cen, key=lambda x: min(_d(x, e['inner']), _d(x, c['inner'])))
    de, dc = _d(best, e['inner']), _d(best, c['inner'])
    who = 'pin %s is named %s and pin %s %s' % (e['number'], e['name'], c['number'], c['name'])
    if dc * 1.5 < de: return ('error', 'the emitter arrow is drawn on the collector lead; %s' % who)
    if dc < de: return ('warn', 'the emitter arrow is closer to the collector than to the emitter; %s' % who)
    return None


def polarity_findings(mode):
    """Polarity of one spec mode (a small part drawn as its symbol): [(severity, message)]."""
    mm = lambda v: v * MIL
    pins = []
    for p in mode['pins']:
        ux, uy = DIRS.get(p['orient'], (0, 0))
        inner = (mm(p['x']), mm(p['y']))
        pins.append(dict(p, inner=inner, at=(inner[0] + ux * mm(p['len']), inner[1] + uy * mm(p['len']))))
    segs = [((mm(l[0]), mm(l[1])), (mm(l[2]), mm(l[3]))) for l in mode['lines']]
    tris, used = _triangles(segs)
    bars_open = [[s[0], s[1]] for i, s in enumerate(segs) if i not in used]
    bars_closed = []
    for r in mode['rects']:
        x0, y0, x1, y1 = (mm(v) for v in _box(r))
        bars_closed.append([(x0, y0), (x1, y0), (x1, y1), (x0, y1)])
    out = [f for f in (diode_finding(pins, tris, bars_open, bars_closed), bjt_finding(pins, tris + _vees(segs, used))) if f]
    return out


# ---------------------------------------------------------------- the check

def _rel(pin, comp):
    """A placed pin's place in its symbol's frame (mils): the sheet position less the part's, rotated back."""
    dx, dy = pin.get('xMil', 0) - comp.get('xMil', 0), pin.get('yMil', 0) - comp.get('yMil', 0)
    q = int(comp.get('orientation') or 0) % 4
    for _ in range(q): dx, dy = dy, -dx  # undo one 90 degree counterclockwise turn
    orient = DIRS_ROT(ALTIUM_ORIENT.get(int(pin.get('orientation') or 0) % 4, 'right'), -q)
    return {'number': str(pin.get('number', '')), 'name': pin.get('name') or '', 'x': dx, 'y': dy, 'orient': orient}


def DIRS_ROT(orient, quarter_turns):
    order = ['right', 'up', 'left', 'down']
    return order[(order.index(orient) + quarter_turns) % 4] if orient in order else orient


def check_symbols(placed, specs, prims, spec=None):
    """The Altium schematic's symbols and pin types (see the module doc). placed: altium_schematic_data
    components; specs: {libref: spec text or parse_symbol_spec()}; prims: {libref: altium_symbol_prims data};
    spec: the run's spec (schematic.allowPassive, schematic.polarityReviewed). Returns the report the
    `altium schematic` proof carries: {ok, errors, warnings, symbols, librarySymbols, checked, limits}."""
    errors, warnings = [], []
    by_lib = {}
    for c in placed or []:
        d = by_lib.setdefault(c.get('libReference') or '', {'refs': [], 'comps': []})
        d['refs'].append(c.get('designator') or '?'); d['comps'].append(c)
    spec_up = {k.upper(): v for k, v in (specs or {}).items()}
    prim_up = {k.upper(): v for k, v in (prims or {}).items()}
    checked = {'pinTypes': 0, 'bodyEdgeModes': 0, 'drawnSymbols': 0, 'polarity': 0, 'noSpec': []}
    for lr, d in sorted(by_lib.items()):
        refs, comp = d['refs'], d['comps'][0]
        who = '%s (%s)' % (','.join(refs), lr)
        sp = spec_up.get(lr.upper())
        sp = parse_symbol_spec(sp) if isinstance(sp, str) else sp
        pr = prim_up.get(lr.upper())
        live = [_rel(p, comp) for p in comp.get('pins') or []]
        # 1. the SchLib symbol is the one its imported spec describes (else the spec says nothing about it)
        if sp and pr:
            n_pins = sum(len(m['pins']) for m in sp['modes'].values()); n_rect = sum(len(m['rects']) for m in sp['modes'].values())
            diffs = []
            if pr.get('displayModeCount') is not None and int(pr['displayModeCount']) != len(sp['modes']): diffs.append('%s display modes, the spec %d' % (pr['displayModeCount'], len(sp['modes'])))
            if pr.get('pins') is not None and int(pr['pins']) != n_pins: diffs.append('%s pins, the spec %d' % (pr['pins'], n_pins))
            if pr.get('rectangles') is not None and int(pr['rectangles']) != n_rect: diffs.append('%s rectangles, the spec %d' % (pr['rectangles'], n_rect))
            if diffs:
                warnings.append('library: %s: the SchLib symbol is not the one its kept spec describes (%s); only the drawn mode was checked. `altium library --only <MPN>` imports it again' % (who, '; '.join(diffs)))
                sp = None
        if not sp:
            checked['noSpec'].append(lr)
        elif any(p['etype'] is None for m in sp['modes'].values() for p in m['pins']):
            # an altium-symbol-modes older than altium-bridge 0.2.53 wrote no pin types: every pin went in Passive
            warnings.append('library: %s: its kept spec carries no pin types (altium-symbol-modes older than altium-bridge 0.2.53), so its pins are Passive in Altium; `adom-wiki pkg install adom/altium-bridge`, then `altium library --only <MPN>` imports it again with them' % who)
        # 2. pins on the body edge, every display mode of the spec
        if sp:
            for i, m in sorted(sp['modes'].items()):
                if len(m['rects']) != 1 or not m['pins']: continue
                checked['bodyEdgeModes'] += 1
                native = m['name'] == 'native'
                probs = pin_edge_problems(m['pins'], m['rects'][0])
                if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'mode %d (%s)' % (i, m['name'] or '?'), probs, len(m['pins']), native))
        # ... and the drawn mode, read back from the sheet
        body = None
        if sp and sp['modes'].get(0) and len(sp['modes'][0]['rects']) == 1: body = sp['modes'][0]['rects'][0]
        elif not sp and pr and int(pr.get('rectangles') or 0) >= 1 and pr.get('bodyRectMil'): body = tuple(pr['bodyRectMil'])
        if body and live:
            checked['drawnSymbols'] += 1
            native = (sp['modes'][0]['name'] == 'native') if sp else len(live) <= 3
            probs = pin_edge_problems(live, body)
            if probs: (warnings if native else errors).append('pins on the body: ' + _edge_line(who, 'as drawn on the sheet', probs, len(live), native))
        if sp and sp['modes'].get(0) and live:
            want = {p['number']: p for p in sp['modes'][0]['pins']}
            moved = [p for p in live if p['number'] in want and (abs(p['x'] - want[p['number']]['x']) > 1 or abs(p['y'] - want[p['number']]['y']) > 1)]
            if moved: warnings.append('library: %s: %d drawn pin(s) are not where the spec puts mode 0 (e.g. pin %s at %d,%d mil, the spec %d,%d)' % (
                who, len(moved), moved[0]['number'], moved[0]['x'], moved[0]['y'], want[moved[0]['number']]['x'], want[moved[0]['number']]['y']))
        # 3. polarity of a small part drawn as its own symbol
        if sp and sp['modes'].get(0) and sp['modes'][0]['name'] == 'native':
            checked['polarity'] += 1
            reviewed = polarity_reviewed(spec, lr, refs)
            for sev, msg in polarity_findings(sp['modes'][0]):
                line = '%s: %s' % (who, msg)
                if sev == 'error' and not reviewed: errors.append('polarity drawing: ' + line)
                else: warnings.append('polarity drawing: ' + line + (' [reviewed in spec.schematic.polarityReviewed]' if sev == 'error' else ''))
        # 4. pin electrical types on active devices, as Altium holds them on the placed parts
        pins = [{'number': str(p.get('number', '')), 'name': p.get('name') or '', 'etype': ALTIUM_ETYPE.get(p.get('electrical'), str(p.get('electrical')))} for p in comp.get('pins') or []]
        info = ' '.join([lr, comp.get('comment') or '', (sp or {}).get('desc') or ''])
        amp = amp_like(info, [p['name'] for p in pins])
        active = [r for r in refs if ref_prefix(r) in ('U', 'IC')] if not amp else list(refs)
        if active:
            checked['pinTypes'] += 1
            for sev, pin, msg in pin_type_findings(pins, amp):
                if sev == 'warn':
                    warnings.append('pin type: %s pin %s (%s): %s' % (','.join(active), pin['number'], lr, msg)); continue
                blocked = []
                for r in active:
                    a = passive_allowed(spec, r, pin)
                    if a is True: warnings.append('pin type: %s pin %s (%s): %s [allowed by spec.schematic.allowPassive]' % (r, pin['number'], lr, msg))
                    elif a is False: warnings.append('pin type: spec.schematic.allowPassive names %s pin %s without a datasheet `why`; it does not count' % (r, pin['number'])); blocked.append(r)
                    else: blocked.append(r)
                if blocked: errors.append('pin type: %s pin %s (%s): %s' % (','.join(blocked), pin['number'], lr, msg))
    if checked['noSpec']:
        warnings.append('no kept symbol spec for %s (imported before AI Flow 0.2.10, or the SchLib differs): only the drawn mode was checked, not the other display modes or the polarity drawing; `altium library --only <MPN>` imports it again and keeps its spec' % ', '.join(checked['noSpec']))
    return {'ok': not errors, 'errors': errors, 'warnings': warnings, 'symbols': len(placed or []), 'librarySymbols': len(by_lib),
            'checked': checked, 'limits': LIMITS}


# ---------------------------------------------------------------- project-library parts

def mpn_key(m):
    return (m or '').strip().upper()


def parts_rows(run_data):
    """The `parts` step's rows (each MPN's wiki page or project-library files) by MPN."""
    rows = (((run_data or {}).get('outcomes') or {}).get('parts') or {}).get('parts') or []
    return {mpn_key(r.get('mpn')): r for r in rows if isinstance(r, dict) and r.get('mpn')}


def part_origin(comp, row):
    """Where one netlist part's KiCad files come from: ('wiki', slug) or ('project-lib', projectLib).
    A page named in the netlist wins, then the page `parts` matched, then the board's project library
    (`parts --project-lib`, already checked complete there), then the MPN as the page slug."""
    if comp.get('page'): return 'wiki', comp['page']
    if (row or {}).get('wiki'): return 'wiki', row['wiki']
    if isinstance((row or {}).get('projectLib'), dict): return 'project-lib', row['projectLib']
    return 'wiki', (comp.get('mpn') or '').lower()


def _top_blocks(text):
    """The top-level child blocks of an s-expression file (strings with parentheses respected)."""
    out, depth, start, in_str, i, n = [], 0, None, False, 0, len(text)
    while i < n:
        ch = text[i]
        if in_str:
            if ch == '\\': i += 2; continue
            if ch == '"': in_str = False
        elif ch == '"': in_str = True
        elif ch == '(':
            depth += 1
            if depth == 2: start = i
        elif ch == ')':
            if depth == 2 and start is not None: out.append(text[start:i + 1]); start = None
            depth -= 1
        i += 1
    return out


def _sym_name(block):
    m = re.match(r'\(symbol\s+"((?:[^"\\]|\\.)*)"', block)
    return m.group(1) if m else None


def single_symbol_lib(text, name):
    """A .kicad_sym with just the symbol `name` (and the symbol it extends): the converter reads every pin
    in its file, and a project library holds many symbols. None when the library has no such symbol."""
    syms = {}
    for b in _top_blocks(text):
        n = _sym_name(b)
        if n is not None: syms[n] = b
    key = name.split(':')[-1]
    hit = syms.get(key) or next((b for n, b in syms.items() if n.upper() == key.upper()), None)
    if hit is None: return None
    want = [hit]
    ext = re.search(r'\(extends\s+"((?:[^"\\]|\\.)*)"', hit)
    if ext and ext.group(1) in syms: want.insert(0, syms[ext.group(1)])
    v = re.search(r'\(version\s+(\d+)\)', text)
    return '(kicad_symbol_lib (version %s) (generator "adom-aiflow")\n  %s\n)\n' % (v.group(1) if v else '20241209', '\n  '.join(want))


def is_step(path):
    try:
        return Path(path).read_bytes()[:64].decode('latin-1').lstrip().startswith('ISO-10303-21')
    except OSError:
        return False


def stage_project_part(key, pl, dest):
    """One project-library part's KiCad files staged where a wiki part's page is cloned (the lane's
    pages/<key>/: the board render takes its courtyard from the footprint there): its one symbol, its
    footprint and its STEP model. Returns ({sym, outline, fp, step}, []) or (None, [what is wrong])."""
    sf, ff, mf = Path(pl.get('symbolFile') or ''), Path(pl.get('footprintFile') or ''), Path(pl.get('model') or '')
    problems = ['%s %s is missing' % (what, f) for what, f in (('symbol file', sf), ('footprint', ff), ('STEP model', mf)) if not f.is_file()]
    if problems: return None, problems
    name = pl.get('symbol') or key
    text = single_symbol_lib(sf.read_text(errors='ignore'), name)
    if text is None: problems.append('no symbol %s in %s' % (name, sf))
    if not is_step(mf): problems.append('%s is not a STEP file (no ISO-10303-21 header)' % mf)
    if problems: return None, problems
    dest = Path(dest)
    shutil.rmtree(dest, ignore_errors=True); dest.mkdir(parents=True)
    sym = dest / (re.sub(r'[^A-Za-z0-9_.-]', '_', name.split(':')[-1]) + '.kicad_sym')
    sym.write_text(text)
    fp = dest / ff.name; shutil.copy(ff, fp)
    step = dest / mf.name; shutil.copy(mf, step)
    return {'sym': sym, 'outline': None, 'fp': fp, 'step': step}, []


def project_part_facts(pl, row):
    """What a project-library part says about itself for the Altium parameters: description, manufacturer,
    package and any distributor numbers named in its provenance.json entry."""
    prov = pl.get('provenance') or {}
    links = dict(prov.get('distributor_links') or {})
    for k in ('mouser', 'digikey', 'lcsc'):
        if prov.get(k) and not links.get(k): links[k] = prov[k]
    desc = prov.get('description') or (row or {}).get('value') or pl.get('footprint') or ''
    return {'desc': str(desc)[:120].replace('\n', ' '), 'mfr': prov.get('manufacturer') or (row or {}).get('manufacturer') or '',
            'package': prov.get('package') or pl.get('footprint') or '', 'links': links, 'source': pl.get('source') or prov.get('source') or ''}