/**
 * Faithful skeleton emitter — deterministic SpecVerseFacts to spec yaml
 * (engines 6.23.0+, plan: 2026-05-04-ANALYSE-VIA-FAITHFUL-SKELETON-AND-MICROCALLS).
 *
 * Architectural principle (user-articulated): the analyse phase must
 * produce a spec that is a FAITHFUL MIRROR of the codebase. If a query
 * method is not in source, the spec has no retrieve op. No defaults, no
 * fabrication. The existing inference engine (spv infer) is for
 * from-scratch authoring expansion; it is the wrong building block for
 * analyse output. A future spv enhance command (TODO #48) is where
 * default-filling lives.
 *
 * Forward-logging discipline: every emission records a provenance entry
 * mapping the emitted yaml line range to its source fact + emission
 * rule. The report assembler reads from the provenance ledger; no
 * backward parsing of the yaml is needed to know where each line came
 * from.
 */
/**
 * Build the indexed lookup the emitter walks during emission. Component
 * assignment uses the suggestedComponent's structural sourceDir (when
 * present) as the prefix; classes whose filePath starts with that prefix
 * belong to that component. Classes that don't match any component go to
 * a default bucket.
 */
function buildContext(facts) {
    const classByName = new Map();
    for (const cm of facts.candidateMethods ?? []) {
        classByName.set(cm.entityName, cm);
    }
    const classesByComponent = new Map();
    // Pre-create a bucket for each suggested component (preserves order).
    const components = facts.suggestedComponents ?? [];
    for (const comp of components) {
        classesByComponent.set(comp.suggestedName, []);
    }
    // Plus a fallback bucket when nothing matches.
    classesByComponent.set('_unassigned', []);
    for (const cm of facts.candidateMethods ?? []) {
        let assigned = false;
        for (const comp of components) {
            const sourceDir = comp.structural?.sourceDir;
            if (sourceDir && cm.filePath.startsWith(sourceDir)) {
                classesByComponent.get(comp.suggestedName).push(cm);
                assigned = true;
                break;
            }
        }
        if (!assigned) {
            classesByComponent.get('_unassigned').push(cm);
        }
    }
    // Build the single-home entity mapping. First-named-component-wins;
    // orphans fall to 'Unassigned' (or 'Application' when no components
    // were suggested at all, mirroring the default-component fallback in
    // emission). specName matches the post-strip name used during emission.
    const entityHome = new Map();
    const entityHomeAssignments = [];
    const fallbackHome = components.length === 0 ? 'Application' : 'Unassigned';
    for (const ent of facts.entities ?? []) {
        let matchedComp = null;
        for (const comp of components) {
            if ((comp.entities ?? []).includes(ent.name)) {
                matchedComp = comp;
                break;
            }
        }
        const home = matchedComp
            ? (matchedComp.suggestedName.replace(/Component$/, '') || matchedComp.suggestedName)
            : fallbackHome;
        entityHome.set(ent.name, home);
        entityHomeAssignments.push({
            entityName: ent.name,
            componentSpecName: home,
            reason: matchedComp ? 'first-named-match' : 'fallback',
            matchedComponentSuggestedName: matchedComp?.suggestedName,
        });
    }
    // Engines 6.32.2+ — global single-home for service classes. Walk
    // components in iteration order; first bucket to claim a className
    // owns it. Duplicates in later buckets get skipped at emit time.
    const serviceHome = new Map();
    for (const comp of components) {
        const bucket = classesByComponent.get(comp.suggestedName) ?? [];
        for (const cm of bucket) {
            if (!serviceHome.has(cm.entityName)) {
                serviceHome.set(cm.entityName, comp.suggestedName);
            }
        }
    }
    for (const cm of classesByComponent.get('_unassigned') ?? []) {
        if (!serviceHome.has(cm.entityName)) {
            serviceHome.set(cm.entityName, '_unassigned');
        }
    }
    // Engines 6.32.2+ — global single-home for views. First component
    // whose sourceDir prefixes the view's filePath owns it; duplicates
    // in later components get skipped.
    const viewHome = new Map();
    for (const v of facts.views ?? []) {
        if (viewHome.has(v.name))
            continue;
        let assigned = null;
        for (const comp of components) {
            const sd = comp.structural?.sourceDir;
            if (sd && v.filePath.startsWith(sd)) {
                assigned = comp.suggestedName;
                break;
            }
        }
        viewHome.set(v.name, assigned ?? '_unassigned');
    }
    // Engines 6.96.11+ — per-component declared-name grounding set. Union of
    // model homes (entityHome, already post-strip), service-class homes and
    // view homes (serviceHome/viewHome carry the RAW suggestedName, so strip
    // the `Component` suffix to match the spec-name used at emit time).
    const stripComponent = (n) => n.replace(/Component$/, '') || n;
    const declaredNamesByComponent = new Map();
    const addDeclared = (specName, name) => {
        let set = declaredNamesByComponent.get(specName);
        if (!set) {
            set = new Set();
            declaredNamesByComponent.set(specName, set);
        }
        set.add(name);
    };
    for (const [name, home] of entityHome)
        addDeclared(home, name);
    for (const [name, sugg] of serviceHome)
        addDeclared(stripComponent(sugg), name);
    for (const [name, sugg] of viewHome)
        addDeclared(stripComponent(sugg), name);
    return {
        facts, classByName, classesByComponent, entityHome, serviceHome, viewHome,
        declaredNamesByComponent, entityHomeAssignments, importGroundingDrops: [],
    };
}
/**
 * Stateful builder collecting yaml lines + provenance entries in step.
 * Caller invokes emit() to push lines; the line range is computed
 * automatically based on the current cursor.
 */
class SkeletonBuilder {
    lines = [];
    provenance = [];
    actionStubs = [];
    cursor = 1; // 1-based line number of the next line to be emitted
    emit(text, decision) {
        const lines = text.split('\n');
        const startLine = this.cursor;
        this.lines.push(text);
        // text may contain trailing newline; count actual lines added
        const lineCount = text.endsWith('\n') ? lines.length - 1 : lines.length;
        this.cursor += lineCount;
        const endLine = this.cursor - 1;
        this.provenance.push({ ...decision, lineRange: [startLine, endLine] });
    }
    recordActionStub(stub) {
        this.actionStubs.push(stub);
    }
    toResult(entityHomeAssignments, importGroundingDrops) {
        const skeleton = this.lines.join('');
        const emissionsByRule = {};
        for (const e of this.provenance)
            emissionsByRule[e.rule] = (emissionsByRule[e.rule] ?? 0) + 1;
        return {
            skeleton,
            provenance: {
                schemaVersion: '1.0',
                emittedAt: new Date().toISOString(),
                entityHomeAssignments,
                importGroundingDrops: importGroundingDrops && importGroundingDrops.length > 0 ? importGroundingDrops : undefined,
                emissionCount: this.provenance.length,
                emissionsByRule,
                emissions: this.provenance,
                actionStubs: this.actionStubs,
            },
        };
    }
}
/**
 * Faithfully emit a skeleton spec from prepass facts.
 *
 * The skeleton mirrors source: only what was detected gets emitted.
 * Action bodies are STUBS — name + parameters + return placeholder + a
 * pointer to the candidate-step timeline the per-action LLM micro-call
 * should consume. The orchestrator merges per-action LLM output back
 * into the skeleton afterward.
 */
export function emitFaithfulSkeleton(facts) {
    const ctx = buildContext(facts);
    const b = new SkeletonBuilder();
    b.emit('# SpecVerse skeleton — faithfully emitted from prepass facts.\n', {
        yamlPath: '',
        rule: 'comment-skeleton-marker',
        note: 'No defaults injected. Action bodies filled by per-action LLM micro-calls.',
    });
    b.emit('# Per-line provenance lives in skeleton-provenance.json alongside this file.\n', {
        yamlPath: '',
        rule: 'comment-skeleton-marker',
    });
    b.emit('\n', {
        yamlPath: '',
        rule: 'comment-skeleton-marker',
    });
    b.emit('components:\n', {
        yamlPath: 'components',
        rule: 'comment-skeleton-marker',
    });
    const components = ctx.facts.suggestedComponents ?? [];
    if (components.length === 0) {
        // Adapter-less + structural-detector also empty — emit a single
        // default component so the spec has somewhere to attach walker
        // classes. Logged with the explicit "default-when-none-suggested"
        // rule for transparency.
        b.emit('  Application:\n', {
            yamlPath: 'components.Application',
            rule: 'header-component-default-when-none-suggested',
            note: 'No components suggested by prepass; emitting a single default component.',
        });
        b.emit(`    version: "1.0.0"\n`, {
            yamlPath: 'components.Application.version',
            rule: 'comment-skeleton-marker',
        });
        emitComponentBody(b, ctx, 'Application', '_unassigned', undefined);
    }
    else {
        for (let i = 0; i < components.length; i++) {
            const comp = components[i];
            // Strip "Component" suffix on the spec-name if present (suggestedName
            // is FooComponent; spec convention is FooApp / Foo).
            const specName = comp.suggestedName.replace(/Component$/, '') || comp.suggestedName;
            b.emit(`  ${specName}:\n`, {
                yamlPath: `components.${specName}`,
                rule: 'header-component-from-suggested',
                sourceFactRef: `facts.suggestedComponents[${i}]`,
                sourceLocation: comp.structural?.sourceDir
                    ? { filePath: comp.structural.sourceDir, lineRange: [1, 1] }
                    : undefined,
            });
            b.emit(`    version: "1.0.0"\n`, {
                yamlPath: `components.${specName}.version`,
                rule: 'comment-skeleton-marker',
                note: 'Schema requires version: on every component (default 1.0.0).',
            });
            emitComponentBody(b, ctx, specName, comp.suggestedName, comp);
        }
        const orphanClasses = ctx.classesByComponent.get('_unassigned') ?? [];
        const orphanEntities = [...ctx.entityHome.entries()].filter(([, h]) => h === 'Unassigned');
        // Views are orphan when their filePath doesn't match any suggested
        // component's structural sourceDir. The component-body emitter
        // surfaces these via `bucketKey === '_unassigned'` when it runs
        // for the catch-all; we just need to decide whether to emit the
        // catch-all at all.
        const orphanViews = (ctx.facts.views ?? []).filter((v) => {
            for (const c of ctx.facts.suggestedComponents ?? []) {
                const sd = c.structural?.sourceDir;
                if (sd && v.filePath.startsWith(sd))
                    return false;
            }
            return true;
        });
        if ((orphanClasses?.length ?? 0) > 0 || orphanEntities.length > 0 || orphanViews.length > 0) {
            const noteParts = [];
            if ((orphanClasses?.length ?? 0) > 0) {
                noteParts.push(`${orphanClasses.length} walked class(es) did not match any suggested component's source path`);
            }
            if (orphanEntities.length > 0) {
                noteParts.push(`${orphanEntities.length} entit(ies) not listed in any named component`);
            }
            if (orphanViews.length > 0) {
                noteParts.push(`${orphanViews.length} view(s) not under any suggested component's source path`);
            }
            b.emit('  Unassigned:\n', {
                yamlPath: 'components.Unassigned',
                rule: 'header-component-default-when-none-suggested',
                note: noteParts.join('; '),
            });
            b.emit(`    version: "1.0.0"\n`, {
                yamlPath: 'components.Unassigned.version',
                rule: 'comment-skeleton-marker',
            });
            emitComponentBody(b, ctx, 'Unassigned', '_unassigned', undefined);
        }
    }
    // Project the bookkeeping shapes into stable types for downstream consumers
    // (deployment-emitter). The internal EmitterContext carries richer typing
    // than what consumers need; expose a narrow, stable shape only.
    const classesByComponent = new Map();
    for (const [k, classes] of ctx.classesByComponent) {
        classesByComponent.set(k, (classes ?? []).map((c) => ({ entityName: c.entityName, filePath: c.filePath })));
    }
    return {
        ...b.toResult(ctx.entityHomeAssignments, ctx.importGroundingDrops),
        classesByComponent,
        entityHome: new Map(ctx.entityHome),
    };
}
function emitComponentBody(b, ctx, specName, bucketKey, _comp) {
    // ── import: (engines 6.32.6+) ─────────────────────────────────────
    // Cross-component `import:` blocks emitted from facts.importsByComponent
    // (built by analyse-prepass/imports-graph.ts). For each consumer-target
    // pair, emit one entry under `import:` with the symbols pulled in.
    // Target-component name is the post-strip spec name; symbols are
    // sorted for stable diffs.
    //
    // V2 Phase 2 (2026-05-08): also emit entries for detected non-library
    // dependencies — subprocess executables, service clients, messaging
    // brokers, managed-SDK clients. These come from
    // facts.nonLibraryImportsByComponent and live in the same `import:`
    // block as in-tree component refs (V2's principle: one place for all
    // dependency declarations on the consumer side).
    const importsForThis = ctx.facts.importsByComponent?.[bucketKey];
    const nonLibraryHints = ctx.facts.nonLibraryImportsByComponent?.[bucketKey];
    // Engines 6.96.11+ — GROUND the in-tree import selects before emitting.
    // The imports-graph adapter records raw source symbols; keep only those
    // that resolve to a declared entity (model/service/view) in the provider
    // component. A `from:` entry that HAD selects but whose every select is
    // ungrounded is dropped entirely (it would otherwise be an orphan import
    // realize can't emit a client for — TODO #58 — and a false
    // artifact-coverage miss). Bare deps (no selects, e.g. side-effect
    // imports) are preserved. Dropped symbols are forward-logged.
    const groundedEntries = [];
    if (importsForThis) {
        for (const targetSuggestedName of Object.keys(importsForThis).sort()) {
            const targetSpecName = targetSuggestedName.replace(/Component$/, '') || targetSuggestedName;
            const rawSymbols = importsForThis[targetSuggestedName] ?? [];
            const declared = ctx.declaredNamesByComponent.get(targetSpecName);
            // Dotted selects (Service.op) ground on their head (the entity).
            const grounded = rawSymbols.filter((s) => declared?.has(s.split('.')[0]));
            const dropped = rawSymbols.filter((s) => !declared?.has(s.split('.')[0]));
            if (dropped.length > 0) {
                ctx.importGroundingDrops.push({ consumer: specName, from: targetSpecName, dropped });
            }
            // Had selects but none grounded → pure noise, omit the whole entry.
            if (rawSymbols.length > 0 && grounded.length === 0)
                continue;
            groundedEntries.push({ targetSpecName, symbols: grounded });
        }
    }
    const hasInTreeImports = groundedEntries.length > 0;
    const hasNonLibraryImports = nonLibraryHints && nonLibraryHints.length > 0;
    if (hasInTreeImports || hasNonLibraryImports) {
        b.emit(`    import:\n`, {
            yamlPath: `components.${specName}.import`,
            rule: 'comment-skeleton-marker',
        });
        if (hasInTreeImports) {
            for (const { targetSpecName, symbols } of groundedEntries) {
                b.emit(`      - from: "${targetSpecName}"\n`, {
                    yamlPath: `components.${specName}.import[${targetSpecName}]`,
                    rule: 'import-from-component-imports-graph',
                });
                if (symbols.length > 0) {
                    // Engines 6.32.10+: block-array form when the symbol list is
                    // long. Single-line flow-arrays are valid YAML but unreadable
                    // when dozens of symbols pile up (mythology-mobile imports 28
                    // symbols from Ui).
                    const useBlockForm = symbols.length > 4 || symbols.join(', ').length > 70;
                    if (useBlockForm) {
                        b.emit(`        select:\n`, {
                            yamlPath: `components.${specName}.import[${targetSpecName}].select`,
                            rule: 'import-from-component-imports-graph',
                        });
                        for (const sym of symbols) {
                            b.emit(`          - ${sym}\n`, {
                                yamlPath: `components.${specName}.import[${targetSpecName}].select.${sym}`,
                                rule: 'import-from-component-imports-graph',
                            });
                        }
                    }
                    else {
                        b.emit(`        select: [${symbols.join(', ')}]\n`, {
                            yamlPath: `components.${specName}.import[${targetSpecName}].select`,
                            rule: 'import-from-component-imports-graph',
                        });
                    }
                }
            }
        }
        if (hasNonLibraryImports) {
            for (const hint of nonLibraryHints) {
                b.emit(`      - from: "${hint.from}"\n`, {
                    yamlPath: `components.${specName}.import[${hint.from}]`,
                    rule: `import-from-non-library-${hint.pattern}`,
                });
                if (hint.version) {
                    b.emit(`        version: "${hint.version}"\n`, {
                        yamlPath: `components.${specName}.import[${hint.from}].version`,
                        rule: `import-from-non-library-${hint.pattern}`,
                    });
                }
                if (hint.select && hint.select.length > 0) {
                    const useBlockForm = hint.select.length > 4 || hint.select.join(', ').length > 70;
                    if (useBlockForm) {
                        b.emit(`        select:\n`, {
                            yamlPath: `components.${specName}.import[${hint.from}].select`,
                            rule: `import-from-non-library-${hint.pattern}`,
                        });
                        for (const op of hint.select) {
                            b.emit(`          - ${op}\n`, {
                                yamlPath: `components.${specName}.import[${hint.from}].select.${op}`,
                                rule: `import-from-non-library-${hint.pattern}`,
                            });
                        }
                    }
                    else {
                        b.emit(`        select: [${hint.select.join(', ')}]\n`, {
                            yamlPath: `components.${specName}.import[${hint.from}].select`,
                            rule: `import-from-non-library-${hint.pattern}`,
                        });
                    }
                }
            }
        }
    }
    // ── Models (only when adapters detected entities) ─────────────────
    // Single-home entity mapping (Option A, 2026-05-04): every detected
    // entity maps to EXACTLY ONE component via ctx.entityHome. Eliminates
    // both Unassigned-vs-named and named-vs-named duplication that
    // otherwise triggers `realize` collisions ("models.X declared in
    // multiple components"). The intra-component seenModelNames guard
    // remains as a defense against the TS-walker emitting the same name
    // twice from different source files (still possible in adapter merge).
    const seenModelNames = new Set();
    const componentEntities = (ctx.facts.entities ?? []).filter((e) => {
        if (ctx.entityHome.get(e.name) !== specName)
            return false;
        if (seenModelNames.has(e.name))
            return false;
        seenModelNames.add(e.name);
        return true;
    });
    if (componentEntities.length > 0) {
        b.emit(`    models:\n`, {
            yamlPath: `components.${specName}.models`,
            rule: 'comment-skeleton-marker',
        });
        for (const ent of componentEntities) {
            const entityIdx = (ctx.facts.entities ?? []).indexOf(ent);
            b.emit(`      ${ent.name}:\n`, {
                yamlPath: `components.${specName}.models.${ent.name}`,
                rule: 'model-from-detected-entity',
                sourceFactRef: `facts.entities[${entityIdx}]`,
                sourceLocation: { filePath: ent.filePath, lineRange: [1, 1] },
            });
            // Attributes — emit one line per detected attribute, exactly as detected.
            const attrs = ent.attributes ?? [];
            if (attrs.length > 0) {
                b.emit(`        attributes:\n`, {
                    yamlPath: `components.${specName}.models.${ent.name}.attributes`,
                    rule: 'comment-skeleton-marker',
                });
                // Dedupe attribute names within the model — TS walker may
                // emit the same field twice if declared in two source variants.
                const seenAttrNames = new Set();
                for (let ai = 0; ai < attrs.length; ai++) {
                    const a = attrs[ai];
                    if (seenAttrNames.has(a.name))
                        continue;
                    seenAttrNames.add(a.name);
                    let declaredType = normalizeTypeForSpec(a.declaredType ?? 'String');
                    let auto = a.auto;
                    const isPrimaryId = a.isPrimary === true ||
                        a.name === 'id' ||
                        a.name === '_id';
                    // Phase 2 (identity proposal) — preserve the source PK's identity.
                    // Previously (#94 defect 3) a primary id was coerced to String so
                    // realize's canonical `@default(uuid())` + string `:id` param
                    // matched. But realize FULLY supports a non-String PK: schema-gen
                    // emits `@default(autoincrement())` for an Int id, and the
                    // controller's `parseId`/`needsIntParse` types the route param as
                    // number. So keep the source TYPE + emit the recovered `auto=`
                    // token (autoincrement / uuid4 / cuid / now) → analyse → realize
                    // reproduces the source identity instead of guessing uuid.
                    if (isPrimaryId && !auto) {
                        // Phase 3 (case 7) — a primary id with NO recovered generator.
                        // When the source was parsed from a DEFINITIVE schema adapter
                        // (prisma / drizzle) we KNOW it declares no default → the value is
                        // supplied from outside SpecVerse → emit `auto=none` so realize
                        // requires it (no invented uuid) and the interpreter requires it.
                        // For non-definitive extraction (raw SQL / TS interfaces) we can't
                        // be sure → keep the id-is-auto convention (realize uuid's it),
                        // only coercing a pathological PK type to String.
                        const schemaDefinitive = ent.sourceFramework === 'prisma' || ent.sourceFramework === 'drizzle';
                        if (schemaDefinitive) {
                            auto = 'none';
                        }
                        else if (!['String', 'Integer', 'UUID'].includes(declaredType)) {
                            declaredType = 'String';
                        }
                    }
                    // Schema allows: required | optional | unique | auto=X | min=X
                    // | max=X | default=X | verified | searchable | values=[...].
                    // `isPrimary` is a TS-walker concept, NOT a SpecVerse modifier —
                    // by convention, the `id` field IS the primary key. Skip the flag.
                    const flags = [
                        a.isUnique ? 'unique' : null,
                        a.required === false ? 'optional' : 'required',
                        auto ? `auto=${auto}` : null,
                    ].filter(Boolean).join(' ');
                    b.emit(`          ${a.name}: ${declaredType}${flags ? ' ' + flags : ''}\n`, {
                        yamlPath: `components.${specName}.models.${ent.name}.attributes.${a.name}`,
                        rule: 'model-attribute-from-detected-attribute',
                        sourceFactRef: `facts.entities[${entityIdx}].attributes[${ai}]`,
                    });
                }
            }
            // Relationships matching this model.
            const rels = (ctx.facts.relationships ?? []).filter((r) => r.from === ent.name);
            if (rels.length > 0) {
                b.emit(`        relationships:\n`, {
                    yamlPath: `components.${specName}.models.${ent.name}.relationships`,
                    rule: 'comment-skeleton-marker',
                });
                for (let ri = 0; ri < rels.length; ri++) {
                    const r = rels[ri];
                    const relIdx = (ctx.facts.relationships ?? []).indexOf(r);
                    const cascade = r.cascade ? ' cascade' : '';
                    // Lowercase first letter of target as the field name (convention).
                    const fieldName = r.to.charAt(0).toLowerCase() + r.to.slice(1) + (r.type === 'hasMany' || r.type === 'manyToMany' ? 's' : '');
                    b.emit(`          ${fieldName}: ${r.type} ${r.to}${cascade}\n`, {
                        yamlPath: `components.${specName}.models.${ent.name}.relationships.${fieldName}`,
                        rule: 'model-relationship-from-detected-relationship',
                        sourceFactRef: `facts.relationships[${relIdx}]`,
                    });
                }
            }
            // Lifecycle (if any). Schema shape (SPECVERSE-SCHEMA.json $defs.Model):
            //   lifecycles:                   <- plural wrapper
            //     <statusField>:               <- arbitrary field name
            //       states: ["..."]
            //       transitions:
            //         <action>: "from -> to"
            // Earlier emitter versions used `lifecycle:` (singular wrapper) or
            // `<statusField>:` directly on the Model; both rejected as Unknown
            // property. The plural `lifecycles:` is the canonical container.
            const lc = (ctx.facts.lifecycles ?? []).find((l) => l.model === ent.name);
            if (lc) {
                const lcIdx = (ctx.facts.lifecycles ?? []).indexOf(lc);
                b.emit(`        lifecycles:\n`, {
                    yamlPath: `components.${specName}.models.${ent.name}.lifecycles`,
                    rule: 'model-lifecycle-from-detected-lifecycle',
                    sourceFactRef: `facts.lifecycles[${lcIdx}]`,
                });
                b.emit(`          ${lc.field}:\n`, {
                    yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}`,
                    rule: 'model-lifecycle-from-detected-lifecycle',
                    sourceFactRef: `facts.lifecycles[${lcIdx}].field`,
                });
                b.emit(`            states:\n`, {
                    yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.states`,
                    rule: 'model-lifecycle-from-detected-lifecycle',
                    sourceFactRef: `facts.lifecycles[${lcIdx}].states`,
                });
                for (let si = 0; si < lc.states.length; si++) {
                    b.emit(`              - "${lc.states[si]}"\n`, {
                        yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.states[${si}]`,
                        rule: 'model-lifecycle-from-detected-lifecycle',
                        sourceFactRef: `facts.lifecycles[${lcIdx}].states[${si}]`,
                    });
                }
                // `transitions:` is REQUIRED by the schema's Shape-2 lifecycle
                // (states + transitions). Always emit the key, even when no
                // transitions were inferred — the realize stack treats the
                // absence of mappings as "states-only enum" and doesn't
                // generate transition guards. Empty transitions is preferable
                // to "Unknown property 'states'" schema validation failure.
                if (lc.transitions && lc.transitions.length > 0) {
                    b.emit(`            transitions:\n`, {
                        yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.transitions`,
                        rule: 'comment-skeleton-marker',
                    });
                    for (let ti = 0; ti < lc.transitions.length; ti++) {
                        const t = lc.transitions[ti];
                        // Spec language carries one canonical "from -> to" pair per
                        // transition. Multi-from cases are documented in the
                        // behavior's `requires:` (see nestjs-billing expected.specly).
                        const fromState = t.from[0] ?? 'unknown';
                        b.emit(`              ${t.action}: "${fromState} -> ${t.to}"\n`, {
                            yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.transitions.${t.action}`,
                            rule: 'model-lifecycle-transition-from-detected-transition',
                            sourceFactRef: `facts.lifecycles[${lcIdx}].transitions[${ti}]`,
                        });
                    }
                }
                else {
                    b.emit(`            transitions: {}\n`, {
                        yamlPath: `components.${specName}.models.${ent.name}.lifecycles.${lc.field}.transitions`,
                        rule: 'comment-skeleton-marker',
                        note: 'No transitions detected (states-only enum); emitted as empty mapping for schema compliance.',
                    });
                }
            }
            // Model-level behaviors (TODO #154 — engines 6.25.11+).
            // When the walker captured a class with the same name as this
            // entity, treat its kept methods as model-level behaviors. The
            // schema has `behaviors:` as a first-class slot on models for
            // domain actions (e.g. Invoice.send / Invoice.markPaid). Each
            // method becomes a microcall stub the orchestrator fills.
            const entityClass = ctx.classByName.get(ent.name);
            if (entityClass && entityClass.methods.length > 0) {
                // Dedupe by method name (overloads, multiple source variants).
                const seenMethodNames = new Set();
                const dedupedMethods = entityClass.methods.filter((m) => {
                    if (seenMethodNames.has(m.methodName))
                        return false;
                    seenMethodNames.add(m.methodName);
                    return true;
                });
                if (dedupedMethods.length > 0) {
                    b.emit(`        behaviors:\n`, {
                        yamlPath: `components.${specName}.models.${ent.name}.behaviors`,
                        rule: 'comment-skeleton-marker',
                    });
                    for (let mi = 0; mi < dedupedMethods.length; mi++) {
                        const method = dedupedMethods[mi];
                        // Sanitise to schema-allowed pattern (^[a-z][a-zA-Z0-9_]*$).
                        const safeName = /^[a-z][a-zA-Z0-9_]*$/.test(method.methodName)
                            ? method.methodName
                            : method.methodName.charAt(0).toLowerCase() + method.methodName.slice(1);
                        const stubPath = `components.${specName}.models.${ent.name}.behaviors.${safeName}`;
                        b.emit(`          ${safeName}:\n`, {
                            yamlPath: stubPath,
                            rule: 'action-stub-from-business-method',
                            sourceLocation: { filePath: entityClass.filePath, lineRange: method.sourceLineRange },
                            note: `${method.candidates.length} candidate-step(s); model behavior body filled by LLM micro-call.`,
                        });
                        b.emit(`            # body filled by per-action LLM micro-call (skeleton-emitter stub)\n`, {
                            yamlPath: stubPath,
                            rule: 'action-stub-from-business-method',
                        });
                        // Use 'service' ownerKind for the microcall — model behaviors
                        // and service operations have the same ExecutableProperties
                        // schema (steps/requires/ensures/publishes/etc.); the spec
                        // schema doesn't enforce a different shape per location.
                        b.recordActionStub({
                            componentName: specName,
                            ownerKind: 'service',
                            ownerName: ent.name,
                            actionName: safeName,
                            sourceLocation: { filePath: entityClass.filePath, lineRange: method.sourceLineRange },
                            candidateMethodRef: { className: entityClass.entityName, methodIndex: mi },
                            yamlPath: stubPath,
                        });
                    }
                }
            }
        }
    }
    // ── Controllers + services from walker ────────────────────────────
    // Three-stage dedup:
    //   1. Skip if this class name is ALSO an entity — TS-interfaces and
    //      method walker can both surface the same class; only the model
    //      emission should win.
    //   2. Engines 6.32.2+ — skip if a different component owns this name
    //      via ctx.serviceHome (single-home across components). Without
    //      this, idle_meta produces `services.GameService` in both
    //      IdleApiService AND Editor since both source dirs contain a
    //      file by that name; realize then errors on the collision.
    //   3. Local first-wins inside this component's bucket as a defense
    //      against a single bucket containing the same name twice.
    const allEntityNames = new Set((ctx.facts.entities ?? []).map((e) => e.name));
    const rawClasses = ctx.classesByComponent.get(bucketKey) ?? [];
    const seenOwnerNames = new Set();
    const classes = rawClasses.filter((c) => {
        if (allEntityNames.has(c.entityName))
            return false;
        const home = ctx.serviceHome.get(c.entityName);
        if (home !== undefined && home !== bucketKey)
            return false;
        if (seenOwnerNames.has(c.entityName))
            return false;
        seenOwnerNames.add(c.entityName);
        return true;
    });
    const controllers = classes.filter((c) => /Controller$/.test(c.entityName));
    const services = classes.filter((c) => !/Controller$/.test(c.entityName));
    // ── Views (engines 6.28.0+) ───────────────────────────────────────
    // Detected React/JSX components, attributed by source-tree topology:
    // a view's filePath must start with this component's structural
    // sourceDir to land here. Falls through silently when no structural
    // info exists (community-detection-only suggestion) — those views
    // wind up in the `Unassigned` / `Application` catch-all.
    //
    // Engines 6.32.2+ — single-home check (ctx.viewHome) drops duplicate
    // names that already landed in another component. idle_meta has
    // `LoginScreen` in two RN apps and `ToastProvider` in two `contexts/`
    // dirs; without the global home, both components emit the same view
    // and realize errors.
    const componentViews = (ctx.facts.views ?? []).filter((v) => {
        const sd = _comp?.structural?.sourceDir;
        if (!sd)
            return bucketKey === '_unassigned'; // catch-all only
        return v.filePath.startsWith(sd);
    });
    const seenViewNames = new Set();
    const dedupViews = componentViews.filter((v) => {
        const home = ctx.viewHome.get(v.name);
        if (home !== undefined && home !== bucketKey)
            return false;
        if (seenViewNames.has(v.name))
            return false;
        seenViewNames.add(v.name);
        return true;
    });
    if (dedupViews.length > 0) {
        b.emit(`    views:\n`, {
            yamlPath: `components.${specName}.views`,
            rule: 'comment-skeleton-marker',
        });
        for (const v of dedupViews) {
            const viewIdx = (ctx.facts.views ?? []).indexOf(v);
            b.emit(`      ${v.name}:\n`, {
                yamlPath: `components.${specName}.views.${v.name}`,
                rule: 'view-from-react-component',
                sourceFactRef: `facts.views[${viewIdx}]`,
                sourceLocation: { filePath: v.filePath, lineRange: v.sourceLineRange },
            });
            // Engines 6.30.3+: prefer the source docstring when present,
            // fall back to a humanized component name. The previous
            // "React function component detected in components/X" template
            // was mechanical noise — replace with something a human would write.
            // humanizeViewName already handles screen-suffix; don't double-append.
            const description = v.docstring
                ? v.docstring
                : humanizeViewName(v.name);
            const escapedDescription = description.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
            b.emit(`        description: "${escapedDescription}"\n`, {
                yamlPath: `components.${specName}.views.${v.name}.description`,
                rule: v.docstring
                    ? 'view-description-from-source-docstring'
                    : 'view-description-from-humanized-name',
            });
            // Engines 6.31.0+: emit additional structural facts extracted by
            // the JSX walker — type classification, sub-components used,
            // actions, empty-state branch.
            if (v.viewType) {
                b.emit(`        type: ${v.viewType}\n`, {
                    yamlPath: `components.${specName}.views.${v.name}.type`,
                    rule: 'view-type-from-jsx-classifier',
                });
            }
            // Primary model binding — set for inferred renderable views (e.g. a
            // timeline bound to its event model) so the runtime can render it.
            if (v.model) {
                b.emit(`        model: ${v.model}\n`, {
                    yamlPath: `components.${specName}.views.${v.name}.model`,
                    rule: 'view-model-binding',
                });
            }
            if (v.usesComponents && v.usesComponents.length > 0) {
                // Filter to known view names only — we want a real composition
                // graph, not raw <UpperCaseTag> noise (e.g. <Button>, <Icon> from
                // libraries). Cross-reference against detected views.
                const knownViewNames = new Set((ctx.facts.views ?? []).map((vv) => vv.name));
                const filtered = v.usesComponents.filter((u) => knownViewNames.has(u) && u !== v.name);
                if (filtered.length > 0) {
                    b.emit(`        uses: [${filtered.map((u) => u).join(', ')}]\n`, {
                        yamlPath: `components.${specName}.views.${v.name}.uses`,
                        rule: 'view-uses-from-jsx-composition',
                    });
                }
            }
            if (v.actions && v.actions.length > 0) {
                b.emit(`        actions:\n`, {
                    yamlPath: `components.${specName}.views.${v.name}.actions`,
                    rule: 'comment-skeleton-marker',
                });
                for (const a of v.actions) {
                    // When the action has no label and no variant, emit as `name: {}`
                    // so the YAML node is an object (schema requires object). Without
                    // the inline `{}`, `name:\n` is parsed as null, which fails
                    // validation against `actions.<name>: { type: object }`.
                    const hasBody = !!a.label || !!a.variant;
                    b.emit(`          ${a.name}:${hasBody ? '' : ' {}'}\n`, {
                        yamlPath: `components.${specName}.views.${v.name}.actions.${a.name}`,
                        rule: 'view-action-from-jsx-handler',
                    });
                    if (a.label) {
                        b.emit(`            label: "${a.label.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"\n`, {
                            yamlPath: `components.${specName}.views.${v.name}.actions.${a.name}.label`,
                            rule: 'view-action-from-jsx-handler',
                        });
                    }
                    if (a.variant) {
                        b.emit(`            variant: ${a.variant}\n`, {
                            yamlPath: `components.${specName}.views.${v.name}.actions.${a.name}.variant`,
                            rule: 'view-action-from-jsx-handler',
                        });
                    }
                }
            }
            if (v.emptyState) {
                b.emit(`        emptyState: true\n`, {
                    yamlPath: `components.${specName}.views.${v.name}.emptyState`,
                    rule: 'view-empty-state-from-jsx-conditional',
                });
            }
        }
    }
    if (controllers.length > 0) {
        b.emit(`    controllers:\n`, {
            yamlPath: `components.${specName}.controllers`,
            rule: 'comment-skeleton-marker',
        });
        for (const ctrl of controllers) {
            emitOwner(b, ctx, specName, 'controller', ctrl);
        }
    }
    if (services.length > 0) {
        b.emit(`    services:\n`, {
            yamlPath: `components.${specName}.services`,
            rule: 'comment-skeleton-marker',
        });
        for (const svc of services) {
            emitOwner(b, ctx, specName, 'service', svc);
        }
    }
}
function emitOwner(b, ctx, specName, ownerKind, cls) {
    // Schema: controllers use `actions:`, services use `operations:`.
    const ownerSection = ownerKind === 'controller' ? 'controllers' : 'services';
    const bodyKey = ownerKind === 'controller' ? 'actions' : 'operations';
    b.emit(`      ${cls.entityName}:\n`, {
        yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}`,
        rule: ownerKind === 'controller' ? 'controller-from-walked-class' : 'service-from-walked-class',
        sourceLocation: { filePath: cls.filePath, lineRange: [1, 1] },
    });
    // Controllers must declare `model: <ModelName>` — the validator
    // requires a model reference per controller AND that reference must
    // resolve to a declared entity (otherwise: "Controller X references
    // non-existent model Y"). Resolution priority:
    //   1. Strip "Controller" suffix and match detected entity by name.
    //   2. Scan all method bodies for entity-name occurrences; pick the
    //      most-referenced (handles route-style controllers like
    //      AuthController whose stripped name `Auth` isn't an entity but
    //      whose handlers reference `User` / `Device` / etc.).
    //   3. Fall back to the first entity in the same component
    //      (entityHome lookup) so we always emit a valid reference.
    //   4. As a last resort, omit the line — the validator complains
    //      `missing required` but that's strictly better than referencing
    //      a non-existent model.
    if (ownerKind === 'controller') {
        const stripped = cls.entityName.replace(/Controller$/, '');
        const allEntities = ctx.facts.entities ?? [];
        let modelRef = null;
        let resolveNote = '';
        // (1) Direct stripped-name match.
        const directMatch = allEntities.find((e) => e.name === stripped);
        if (directMatch) {
            modelRef = directMatch.name;
            resolveNote = `Resolved by stripping "Controller" suffix and matching detected entity.`;
        }
        // (2) Scan method bodies for entity-name references.
        if (!modelRef && cls.methods.length > 0) {
            const bodyText = cls.methods.map((m) => m.body ?? '').join('\n');
            const counts = new Map();
            for (const ent of allEntities) {
                const re = new RegExp(`\\b${ent.name}\\b`, 'g');
                const c = (bodyText.match(re) ?? []).length;
                if (c > 0)
                    counts.set(ent.name, c);
            }
            if (counts.size > 0) {
                const top = [...counts.entries()].sort((a, b) => b[1] - a[1])[0];
                modelRef = top[0];
                resolveNote = `Resolved by handler-body entity-reference scan: "${top[0]}" referenced ${top[1]}× across handler bodies.`;
            }
        }
        // (3) Fall back to the first entity assigned to this component.
        if (!modelRef) {
            const sameComponentEntity = [...ctx.entityHome.entries()].find(([, home]) => home === specName);
            if (sameComponentEntity) {
                modelRef = sameComponentEntity[0];
                resolveNote = `Fallback to first entity in component ${specName}.`;
            }
        }
        if (modelRef) {
            b.emit(`        model: ${modelRef}\n`, {
                yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.model`,
                rule: 'controller-from-walked-class',
                note: resolveNote,
            });
        }
    }
    // Track which method names get emitted as cured: shorthand so we can
    // skip them when emitting the actions: block below (avoiding duplication).
    const curedSourceMethods = new Set();
    // Controllers — emit `cured:` shorthand for CRUD-shape methods.
    // Two source streams:
    //   (a) Audit log classified them as level-1-curved-* and dropped from
    //       the kept list (filter judged "realize stack handles via
    //       convention"). These ARE in source as explicit endpoints.
    //   (b) Kept methods whose names canonicalise to a CRUD op (e.g. a
    //       `retrieve(id)` that throws NotFound was classified as a
    //       business-action, but its NAME is canonical CRUD — so it IS
    //       the cured retrieve op, just with a custom body).
    // Maps walker method names to canonical CURVED op names (list →
    // retrieve_many, etc.).
    if (ownerKind === 'controller') {
        const curedOps = new Map();
        // Stream (a) — level-1 dropped methods from audit log.
        if (ctx.facts.auditDecisions) {
            const auditClass = ctx.facts.auditDecisions.classes.find((c) => c.className === cls.entityName);
            if (auditClass) {
                for (const m of auditClass.methods) {
                    // `reason` is only set on dropped methods (kept=false). Express-
                    // routes adapter keeps every handler with no reason — skip those
                    // here since they're not level-1-curved drops by definition.
                    const reason = m.filter.reason;
                    if (!reason || !reason.startsWith('level-1-curved-'))
                        continue;
                    const canonical = canonicalCuredName(m.methodName);
                    if (!canonical)
                        continue;
                    if (!curedOps.has(canonical)) {
                        curedOps.set(canonical, {
                            sourceMethodName: m.methodName,
                            lineRange: m.sourceLineRange,
                        });
                        curedSourceMethods.add(m.methodName);
                    }
                }
            }
        }
        // Stream (b) — kept methods whose names canonicalise to CRUD.
        for (const m of cls.methods) {
            const canonical = canonicalCuredName(m.methodName);
            if (!canonical)
                continue;
            // Skip if same canonical op already emitted from a (level-1) source —
            // first-wins, which prefers the level-1 method (cleanest CRUD shape).
            if (curedOps.has(canonical))
                continue;
            curedOps.set(canonical, {
                sourceMethodName: m.methodName,
                lineRange: m.sourceLineRange,
            });
            curedSourceMethods.add(m.methodName);
        }
        if (curedOps.size > 0) {
            b.emit(`        cured:\n`, {
                yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.cured`,
                rule: 'comment-skeleton-marker',
            });
            for (const [opName, src] of curedOps) {
                b.emit(`          ${opName}: {}\n`, {
                    yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.cured.${opName}`,
                    rule: 'cured-op-from-level1-curved-method',
                    sourceLocation: { filePath: cls.filePath, lineRange: src.lineRange },
                    note: `Source method: ${cls.entityName}.${src.sourceMethodName} (${reasonForCanonical(opName)}).`,
                });
            }
        }
    }
    if (cls.methods.length === 0)
        return;
    // Dedupe by method name within a class — schema disallows duplicate
    // keys in actions/operations. Walker may capture overloaded methods
    // or two same-named files; first-wins keeps the spec valid.
    // Plus: skip methods that were emitted as `cured:` shorthand above
    // (only applies to controllers; services don't have cured:).
    const seenMethodNames = new Set();
    const dedupedMethods = [];
    for (const m of cls.methods) {
        if (seenMethodNames.has(m.methodName))
            continue;
        if (curedSourceMethods.has(m.methodName))
            continue;
        seenMethodNames.add(m.methodName);
        dedupedMethods.push(m);
    }
    // Early return if all methods went to cured: — no actions: block needed.
    if (dedupedMethods.length === 0)
        return;
    // Method names must match schema patterns (^[a-z][a-zA-Z0-9_]*$).
    // Capitalised method names (e.g. "constructor", "MyMethod") get
    // mangled to lowercase-first to satisfy the pattern.
    const sanitiseName = (name) => {
        if (/^[a-z][a-zA-Z0-9_]*$/.test(name))
            return name;
        return name.charAt(0).toLowerCase() + name.slice(1);
    };
    b.emit(`        ${bodyKey}:\n`, {
        yamlPath: `components.${specName}.${ownerSection}.${cls.entityName}.${bodyKey}`,
        rule: 'comment-skeleton-marker',
    });
    // Track yaml-path duplicates across the whole emitter — even after
    // per-class dedup, two classes with the same name in different files
    // could produce conflicting stubs. We skip the duplicate to keep the
    // skeleton valid; provenance still records the first-wins decision.
    for (let mi = 0; mi < dedupedMethods.length; mi++) {
        const method = dedupedMethods[mi];
        const safeName = sanitiseName(method.methodName);
        const stubPath = `components.${specName}.${ownerSection}.${cls.entityName}.${bodyKey}.${safeName}`;
        b.emit(`          ${safeName}:\n`, {
            yamlPath: stubPath,
            rule: 'action-stub-from-business-method',
            sourceLocation: { filePath: cls.filePath, lineRange: method.sourceLineRange },
            note: `${method.candidates.length} candidate-step(s); ${ownerKind === 'controller' ? 'action' : 'operation'} body filled by LLM micro-call.`,
        });
        b.emit(`            # body filled by per-action LLM micro-call (skeleton-emitter stub)\n`, {
            yamlPath: stubPath,
            rule: 'action-stub-from-business-method',
        });
        b.recordActionStub({
            componentName: specName,
            ownerKind,
            ownerName: cls.entityName,
            actionName: safeName,
            sourceLocation: { filePath: cls.filePath, lineRange: method.sourceLineRange },
            candidateMethodRef: { className: cls.entityName, methodIndex: mi },
            yamlPath: stubPath,
        });
    }
}
/**
 * Map TS-flavored declared types to SpecVerse-vocabulary types where
 * possible, and quote anything containing yaml-unsafe characters so the
 * emitted skeleton parses cleanly.
 *
 * Mapping (best-effort, deliberately lossy):
 *   string -> String
 *   number -> Number
 *   boolean -> Boolean
 *   Date -> DateTime
 *   any | unknown -> Json
 *   bigint -> BigInt
 *   T[] / Array<T> -> Array (loses element type — acceptable v1)
 *   Record<X, Y> / Map<X, Y> -> Json
 *   string literal union ('a' | 'b') -> Enum (or quoted)
 *   T | null / T | undefined -> T (the optional flag captures nullability)
 *   Unknown identifier (like `Platform` or `User`) -> kept as-is (presumed reference)
 */
/**
 * Engines 6.30.3+. Humanize a PascalCase / camelCase view name into a
 * readable description fragment. Used as the fallback `description:`
 * when no source docstring is detectable.
 *
 * Examples:
 *   GamePicker            → "Game picker view"
 *   NodeEditorView        → "Node editor"          (Suffix `View` removed)
 *   GameScreen            → "Game screen"
 *   PaymentConfirmation   → "Payment confirmation view"
 */
function humanizeViewName(name) {
    // Strip trailing `View` / `Screen` suffix to avoid "Game screen view".
    const stripped = name.replace(/(?:View|Screen|Component)$/, '');
    if (!stripped)
        return name;
    // Split on uppercase boundaries: GamePicker → ["Game", "Picker"].
    const parts = stripped
        .replace(/([A-Z])/g, ' $1')
        .trim()
        .split(/\s+/);
    if (parts.length === 0)
        return name;
    // First letter capital, rest lowercase, joined with spaces.
    const phrase = parts.map((p, i) => (i === 0 ? p[0].toUpperCase() : p[0].toLowerCase()) + p.slice(1)).join(' ');
    // Append `view` (or `screen`) suffix back so it reads naturally.
    if (/Screen$/.test(name))
        return phrase + ' screen';
    return phrase + ' view';
}
export function normalizeTypeForSpec(raw) {
    let t = raw.trim();
    // Strip trailing nullable unions — captured as optional already.
    t = t.replace(/\s*\|\s*null\s*$/i, '').replace(/\s*\|\s*undefined\s*$/i, '');
    // Strip nullable in any position (handles `null | string` etc).
    t = t.replace(/\s*\|\s*null\b/i, '').replace(/\s*\|\s*undefined\b/i, '');
    t = t.replace(/^null\s*\|\s*/i, '').replace(/^undefined\s*\|\s*/i, '');
    t = t.trim();
    const lower = t.toLowerCase();
    if (lower === 'string')
        return 'String';
    // `int` is a SQL/Prisma primitive, not a SpecVerse type token — map it to
    // the canonical Number (#94 defect 3 — an `id Int` column would otherwise
    // pass through as the invalid token `Int`).
    if (lower === 'number' || lower === 'int')
        return 'Number';
    if (lower === 'boolean')
        return 'Boolean';
    if (lower === 'bigint')
        return 'BigInt';
    if (lower === 'date')
        return 'DateTime';
    if (lower === 'any' || lower === 'unknown')
        return 'Json';
    if (lower === 'object')
        return 'Json';
    // Array forms: T[] or Array<T>
    if (/\[\]$/.test(t) || /^Array</.test(t))
        return 'Array';
    // Generic containers — collapse to Json.
    if (/^(Record|Map|Set|Partial|Readonly|Required|Promise)\s*</.test(t))
        return 'Json';
    // String literal unions like 'a' | 'b' | 'c'  → String
    // Mixed unions with literals + types → Json (lose precision, gain validity)
    if (/['"]/.test(t) || /\|/.test(t)) {
        const allLiterals = /^\s*(?:['"][^'"]*['"]\s*\|\s*)*['"][^'"]*['"]\s*$/.test(t);
        if (allLiterals)
            return 'String';
        return 'Json';
    }
    // Function type, intersection, complex generic — fall back to Json.
    if (/[<>{}()=>&]/.test(t))
        return 'Json';
    // Boolean literal captured from a property initializer with no type
    // annotation (e.g. `headless = true` → declaredType "true"). Valid
    // identifier, but a value not a type.
    if (t === 'true' || t === 'false')
        return 'Boolean';
    // Bareword identifier — accept ONLY a valid SpecVerse type token: a
    // Capitalized simple identifier (the schema ref pattern is
    // `[A-Z][A-Za-z0-9_]*` — no dots, no leading lowercase/`_`/`$`).
    // Deliberately stricter than "any identifier": the analyse prepass can
    // capture method-body locals, member expressions and object-literal RHS
    // as `declaredType` (e.g. `document.body.scrollHeight`, `window.innerHeight`,
    // `title`, `loc`, `deptStr`) — valid JS but invalid SpecVerse types. They
    // MUST coerce, not pass through, or the emitted spec fails `spv validate`.
    if (/^[A-Z][A-Za-z0-9_]*$/.test(t))
        return t;
    // Anything else (lowercase barewords, dotted member expressions, numeric
    // literals, leftover expression fragments) — coerce to String to keep the
    // spec valid.
    return 'String';
}
/** Map a walker method name to a canonical CURVED op name. Returns null
 *  when the name doesn't fit a known CRUD-shape pattern (those methods
 *  stay under `actions:` / `operations:`, not `cured:`). */
function canonicalCuredName(methodName) {
    const m = methodName.toLowerCase();
    if (/^(list|findall|index|getall|search|all)$/.test(m))
        return 'retrieve_many';
    if (/^(retrieve|findone|findbyid|getbyid|get|getone|find|show)$/.test(m))
        return 'retrieve';
    if (/^(create|add|insert|new|post)$/.test(m))
        return 'create';
    if (/^(update|patch|edit|modify)$/.test(m))
        return 'update';
    if (/^(delete|remove|destroy|softdelete|archive)$/.test(m))
        return 'delete';
    return null;
}
/** Human-readable explanation for the `note:` field on a cured emission. */
function reasonForCanonical(canonical) {
    switch (canonical) {
        case 'retrieve_many': return 'list/findAll-shape method -> CURVED retrieve_many';
        case 'retrieve': return 'findOne/get-shape method -> CURVED retrieve';
        case 'create': return 'create/add-shape method -> CURVED create';
        case 'update': return 'update/patch-shape method -> CURVED update';
        case 'delete': return 'delete/remove-shape method -> CURVED delete';
        default: return canonical;
    }
}
//# sourceMappingURL=skeleton-emitter.js.map