CtrlK
BlogDocsLog inGet started
Tessl Logo
PluginPublic

adobe/aem-cloud-service

All AEM as a Cloud Service skills: component development, Workflow model design, development, triggering, launchers, debugging, and triaging, Dispatcher config authoring, advisory, incident response, performance tuning, and security hardening, Rapid Development Environment (RDE) deployment, log inspection, snapshots, and troubleshooting via `aio aem rde`, Jackrabbit Oak query/index tuning (property, Lucene index definitions), and extracting/inventorying JCR queries (XPath, SQL2, QueryBuilder) across a codebase.

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide
SecuritybySnyk

Low

Low-risk findings worth noting

Overview
Quality
Evals
Security
Files

runbook-generator.jsskills/migration/scripts/

/**
 * Migration Runbook Generator
 *
 * Produces a read-only `migration-runbook.md` covering every pattern the
 * migration skill can address. Triggered by generic prompts like "review/scan
 * my code for AEMaaCS migration" (no pattern named, no CSV required).
 *
 * Per-pattern detection STRATEGY — each pattern declares in `PATTERN_META` how
 * it is detected:
 *
 *   'cascade'     — Java code patterns. Highest available source wins:
 *                     BPA/CAM (MCP, if configured)
 *                        → CSV (if uploaded / cached)
 *                           → analyzer (local — code-assessment analyze.sh)
 *                              → LLM scan (last resort — agent, not this script)
 *                   `replication` maps to the BPA `replication.agent` subtype;
 *                   with no BPA source it falls through to the analyzer (or LLM).
 *   'html-scan'   — `htlLint`: heuristic regex scan of `.html` templates.
 *   'config-scan' — `osgiConfig`: heuristic scan of OSGi config files for
 *                   secret-looking keys and `$[secret:]`/`$[env:]` placeholders
 *                   (key names + locations only — never secret values).
 *   'pom-scan'    — `vault-package-dependencies`: prefers Maven's effective
 *                   POM (`mvn help:effective-pom`, one call per reactor
 *                   root) so `<pluginManagement>` inheritance and per-
 *                   execution vs plugin-level `<configuration>` are Maven's
 *                   problem, not ours; falls back to a raw text-scan of the
 *                   source `pom.xml` when Maven can't resolve a module
 *                   (dead parent repos, offline). No BPA subtype exists for
 *                   this pattern — a `pom.xml` install-time dependency
 *                   declaration is invisible to a deployed-artifact BPA
 *                   scan — so this scan is the only tier. Every fallback
 *                   emits a warning so a scan that couldn't reach Maven
 *                   isn't reported as clean.
 *   'content-scan'— `lui` / `cdw` / `templateModernization`. These ALSO carry
 *                   `bpaSlugs`, so when a BPA source is present they come from
 *                   BPA (authoritative); the `.content.xml` scan (Classic/Coral 2
 *                   dialogs, custom `cq:Widget` xtypes, static templates) is the
 *                   heuristic fallback when no BPA source is available. Routed to
 *                   migration Branches D / C, not code-assessment.
 *   'bpa-only'    — `guavaCache`, `oakIndex`: BPA/CAM/CSV only, no analyzer, no
 *                   content-scan fallback.
 *                     `guavaCache` (subtype `custom.guava.cache`): one finding
 *                   per bundle. `identifier` on this subtype is a
 *                   Guava-internal class, not a customer class — BPA reports
 *                   every Guava-internal class reachable on a bundle's
 *                   classpath, so raw rows are deduped to the bundle named in
 *                   the message, not surfaced per row. No analyzer, no
 *                   content-scan — Guava cache usage does not occur in native
 *                   AEMaaCS code, so there is deliberately no compiled
 *                   detector for it.
 *                     `oakIndex` (subtypes `index.rule.violation` /
 *                   `standard.index.modification`, beta, Branch F): one
 *                   finding per Oak index JCR path. Legacy `_oak_index/*.xml`
 *                   layouts only exist in pre-cloud projects, so — like
 *                   Dispatcher Conversion — this wraps Adobe's index-converter
 *                   tool rather than a compiled detector.
 *                     With no BPA source, either pattern surfaces in
 *                   `needsLlmScan` like any other unscanned pattern.
 *   'java-scan'   — `inputStream` (subtype `java.io.inputstream`). Same
 *                   precedence as the cascade: BPA/CAM → CSV → local. With
 *                   BPA rows, those classes define the scope; the scanner
 *                   (input-stream-runner.js) resolves them to workspace files
 *                   for line-level sites and collapses classes outside the
 *                   workspace into one informational row per bundle (customer
 *                   code kept elsewhere, platform-provided, or third-party).
 *                   Without BPA rows it scans every `src/main/java` file. Each
 *                   site is pre-classified (A–F + fix kind); one finding per
 *                   file. The local scan is always available, so the pattern
 *                   never needs the LLM tier.
 *
 * `html-scan`/`config-scan`/`content-scan` (fallback)/`java-scan` findings are tagged `confidence: 'heuristic'` in the
 * cache. Patterns no available strategy could scan (e.g. a cascade pattern
 * with no BPA source and no JDK) are returned in `needsLlmScan` for the agent.
 *
 * Alongside `migration-runbook.md`, this also writes a sidecar JSON cache
 * (default `./migration-runbook.json`) holding each pattern's raw
 * `{pattern, file, line, snippet}` findings. The apply handoff
 * (migration/SKILL.md Step 3) reads this cache to avoid re-deriving findings
 * via `getBpaFindings` or re-running the analyzer when the user picks a
 * pattern to fix.
 *
 * Usage (CLI):
 *   node scripts/runbook-generator.js <workspaceRoot> [--csv <bpaFilePath>] [--out <outputPath>] [--cache <cachePath>]
 *     [--bpa-json <file>] [--llm-findings <file>] [--project-id <camProjectId> --project-name <name>]
 */

'use strict';

const fs = require('fs');
const path = require('path');
const { getBpaFindings, checkAvailableSources } = require('./bpa-findings-helper.js');
const { runAnalyzer, isAnalyzerAvailable, DEFAULT_ANALYZE_SCRIPT } = require('./analyzer-runner.js');
const { runHtlLint } = require('./htl-lint-runner.js');
const { runOsgiConfigScan, scanUnsupportedRunmodes, validateRunmodeFolder } = require('./osgi-config-runner.js');
const { runVaultPackageScan } = require('./vault-package-scan-runner.js');
const { runLuiScan, runCdwScan } = require('./legacy-ui-runner.js');
const { runTemplateScan } = require('./template-scan-runner.js');
const { runDispatcherScan } = require('./dispatcher-inventory.js');
const { runInputStreamScan } = require('./input-stream-runner.js');

// Canonical pattern taxonomy for the runbook — every pattern the migration
// skill can address. Each declares a detection `strategy`:
//   'cascade'      — BPA/CAM → CSV → analyzer → LLM (Java code patterns)
//   'html-scan'    — pure-Node regex scan of .html (htlLint)
//   'config-scan'  — config-file heuristic scan (osgiConfig)
//   'content-scan' — .content.xml / template / dispatcher-config scan (lui, cdw,
//                    templateModernization, dispatcherConversion)
//   'bpa-only'     — BPA/CAM/CSV only, no local fallback (guavaCache, oakIndex)
//   'java-scan'    — BPA first; Java source scan only when BPA has none (inputStream)
// `bpaSlugs` maps a pattern to its BPA subtype(s): the Java 'cascade' patterns,
// plus replication (replication.agent), lui/cdw/templateModernization,
// guavaCache (com.google.common.cache), and oakIndex (index.rule.violation /
// standard.index.modification). When a BPA source is present it is
// authoritative; html/config/content scans are the local fallback for the
// patterns that have one. (inject-in-sling-model and outdated-dependencies
// belong to code-assessment's own runbook, not the migration runbook, so they
// stay out.)
const PATTERN_META = {
  scheduler: {
    label: 'Scheduler',
    severity: 'high',
    strategy: 'cascade',
    bpaSlugs: ['scheduler'],
    description: 'Classes using `org.apache.sling.commons.scheduler.Scheduler` / implementing the legacy `Job` interface. Migrate to OSGi-property scheduling or Sling Jobs via `JobManager`.',
    promptPattern: 'scheduler',
  },
  resourceChangeListener: {
    label: 'Resource Change Listener',
    severity: 'high',
    strategy: 'cascade',
    bpaSlugs: ['resourceChangeListener'],
    description: 'Classes implementing `ResourceChangeListener` / `ExternalResourceChangeListener`. Migrate to a lightweight `ResourceChangeListener` + `JobConsumer` split.',
    promptPattern: 'resourceChangeListener',
  },
  'event-migration': {
    label: 'Event Migration (EventListener / EventHandler)',
    severity: 'high',
    strategy: 'cascade',
    bpaSlugs: ['eventListener', 'eventHandler'],
    description: 'Classes implementing JCR `javax.jcr.observation.EventListener` or OSGi `org.osgi.service.event.EventHandler`. Migrate to a lightweight handler + `JobConsumer` split, with `TopologyEventListener` for leader-only work.',
    promptPattern: 'eventListener',
  },
  assetApi: {
    label: 'Asset Manager API',
    severity: 'high',
    strategy: 'cascade',
    bpaSlugs: ['assetApi'],
    description: 'Calls to `com.day.cq.dam.api.AssetManager` create/upload/delete methods not available on Cloud Service. Migrate to Direct Binary Access and in-JVM `resolver.delete()`.',
    promptPattern: 'assetApi',
  },
  replication: {
    label: 'Replication',
    severity: 'high',
    strategy: 'cascade',
    // BPA reports replication agents under replication.agent / forward|reverse.
    // When no CSV is present, the analyzer detects Replicator usage from source.
    bpaSlugs: ['replication'],
    description: 'Usage of `com.day.cq.replication.Replicator` / Sling Replication Agent. Migrate to the Sling Distribution API (`Distributor` + `SimpleDistributionRequest`).',
    promptPattern: 'replication',
  },
  htlLint: {
    label: 'HTL data-sly-test Lint',
    severity: 'medium',
    strategy: 'html-scan',
    bpaSlugs: [],
    heuristic: true,
    description: 'HTL `data-sly-test` redundant constant comparisons (`== true`, `/apps/…` string as test, split `${} || ${}`, numeric literals). Detected heuristically by a regex scan — not the HTL compiler — so re-confirm each hit before editing.',
    promptPattern: 'htlLint',
  },
  osgiConfig: {
    label: 'OSGi → Cloud Manager Config',
    severity: 'high',
    strategy: 'config-scan',
    bpaSlugs: [],
    heuristic: true,
    needsReview: true,
    description: 'OSGi config values that should move to Cloud Manager environment secrets/variables — plaintext secret-looking keys, legacy `.cfg`/`.config` formats, and existing `$[secret:]`/`$[env:]` placeholders. Heuristic, review-only: classification (real secret? Adobe-owned PID?) happens at apply time. Secret values are never written to the runbook.',
    promptPattern: 'osgi config',
    // Override — the OSGi → Cloud Manager branch uses a natural-language prompt,
    // not a `<pattern> only` invocation.
    sampleOverride: 'Use the migration skill: scan my config files and create Cloud Manager environment secrets or variables.',
  },
  'vault-package-dependencies': {
    label: 'Vault Package Dependencies',
    severity: 'high',
    strategy: 'pom-scan',
    // No BPA subtype exists for this pattern at all — a pom.xml install-time
    // dependency declaration (content-package-maven-plugin) is invisible to a
    // deployed-artifact BPA scan. The runner prefers Maven's effective POM
    // (`mvn help:effective-pom`, one call per reactor root) and falls back to
    // a text-scan of the raw pom.xml when Maven can't resolve a module —
    // legacy AEM 6.x/AMS projects (the target audience) often can't build
    // anymore, and the fallback keeps the pattern from silently reporting
    // clean.
    bpaSlugs: [],
    heuristic: true,
    description: 'Legacy AEM 6.x Vault install-time package dependencies (`day/cq60/product:*`, `day/cq560/*`, `adobe/cq60` in `content-package-maven-plugin`) that block package installation on AEMaaCS. Detected by asking Maven for the effective POM (`mvn help:effective-pom`, one call per reactor root) with a raw `pom.xml` text-scan fallback when Maven can\'t resolve a module — not a BPA/CAM pattern and not the code-assessment analyzer, so re-confirm each hit before editing.',
    promptPattern: 'vault-package-dependencies',
  },
  lui: {
    label: 'Classic UI / Coral 2 Dialogs (LUI)',
    severity: 'high',
    strategy: 'content-scan',
    // BPA `lui` returns all legacy.user.interface sub-types; the runbook keeps
    // only the dialog ones (custom.component → create-component, static.template
    // → templateModernization, handled elsewhere).
    bpaSlugs: ['lui'],
    bpaSubtypeFilter: ['legacy.dialog.classic', 'legacy.dialog.coral2'],
    heuristic: true,
    description: 'Classic UI dialogs (`cq:Dialog` / `xtype="dialog"`) and Coral 2 dialogs (`granite/ui/components/foundation/…` resourceTypes) that must move to Coral 3 Touch UI. Detected heuristically by scanning `.content.xml`; the dialog migration (Branch D — legacy-ui/dialog) re-confirms and converts each.',
    promptPattern: 'lui',
    sampleOverride: 'Use the migration skill: fix my LUI dialog findings — convert Classic UI / ExtJS dialogs and upgrade Coral 2 dialogs to Coral 3.',
  },
  cdw: {
    label: 'Custom Classic Widgets (CDW)',
    severity: 'high',
    strategy: 'content-scan',
    bpaSlugs: ['cdw'],
    heuristic: true,
    description: 'Custom `cq:Widget` xtypes with no direct Coral 3 equivalent. Detected heuristically by scanning `.content.xml` for non-standard `xtype` values; the custom-widget migration (Branch D — legacy-ui/cdw) inventories each xtype and maps or scaffolds a Granite UI component. Run CDW before dialog migration when both are present.',
    promptPattern: 'cdw',
    sampleOverride: 'Use the migration skill: fix my CDW findings — migrate custom ExtJS widgets to Coral 3.',
  },
  templateModernization: {
    label: 'Static → Editable Templates',
    severity: 'medium',
    strategy: 'content-scan',
    bpaSlugs: ['templateModernization'],
    heuristic: true,
    // CAM/BPA cannot report static templates at all, so a zero BPA result is
    // not proof of clean — fall through to the local `.content.xml` content-scan.
    bpaIncomplete: true,
    description: 'Static templates under `apps/<appId>/templates/*` (`cq:Template`) that should become editable templates. Detected heuristically by globbing template `.content.xml`; the template modernization pipeline (Branch C) runs per-template context → execute → validate.',
    promptPattern: 'template modernization',
    sampleOverride: 'Use the migration skill: migrate my static templates to editable templates and generate the AEM Modernize Tools rewrite rules.',
  },
  guavaCache: {
    label: 'Guava Cache → Caffeine',
    severity: 'info',
    strategy: 'bpa-only',
    bpaSlugs: ['guavaCache'],
    // CAM/BPA reports Guava per-bundle and can filter out the project's own
    // bundles, so a zero BPA result is not proof of clean — fall through to the
    // LLM tier (there is no analyzer/content-scan detector for this pattern).
    bpaIncomplete: true,
    description: 'Bundles importing `com.google.common.cache.*` (Guava in-process cache). Migrate to Caffeine (`com.github.benmanes.caffeine.cache.*`) — a near 1:1 API swap. Not a Cloud-Service-native pattern — only found in code carried over from legacy AEM — so BPA is the sole source of truth; there is no analyzer or content-scan fallback. BPA reports one finding per bundle (identifier is a Guava-internal class, not a customer class).',
    promptPattern: 'guavaCache',
    sampleOverride: 'Use the migration skill: fix guavaCache findings using BPA CSV — swap Guava cache for Caffeine.',
  },
  inputStream: {
    label: 'java.io.InputStream Usage',
    severity: 'medium',
    strategy: 'java-scan',
    // BPA first (CAM, then CSV): BPA rows define the scope and the scanner adds
    // line-level sites for them. Local scan only when BPA has no inputStream rows.
    bpaSlugs: ['inputStream'],
    heuristic: true,
    description: 'Custom Java code using `java.io.*InputStream` (BPA `DG` / `java.io.inputstream`, ADVISORY). Adobe documents the risk as binary data streamed through the JVM, with direct binary upload and CDN delivery as the fix. BPA findings come first (CAM, then a BPA CSV): the flagged classes are resolved to workspace files; the whole workspace is scanned only when BPA has no inputStream findings. One finding per file, ordered by risk. Each stream site is pre-classified: **F** `ObjectInputStream` deserialization, **E** binary streamed or processed in the JVM (DAM binary reads such as `asset.getOriginal().getStream()`, binaries proxied to responses, client uploads into the repository, native processes), **D** local-filesystem reads, **C** unbounded in-memory reads, **B** unclosed stream or undisposed JCR `Binary` (hygiene), **A** benign (informational). Fix kinds: `decision` (needs the user), `review` (confirm a read limit), `auto` (try-with-resources). BPA rows for classes outside the workspace collapse to one informational row per bundle: customer code kept elsewhere, platform-provided (AEM Forms add-on) or third-party.',
    promptPattern: 'inputStream',
    sampleOverride: 'Use the migration skill: fix inputStream (java.io.InputStream) findings — propose CDN delivery and direct binary upload where binaries pass through the JVM, and fix unclosed streams.',
  },
  dispatcherConversion: {
    label: 'Dispatcher AMS/On-prem → AEMaaCS Conversion',
    severity: 'high',
    strategy: 'content-scan',
    bpaSlugs: [],
    heuristic: true,
    description: 'An AMS or on-premise Dispatcher configuration convertible to AEM as a Cloud Service (Branch E). Detected heuristically; the conversion wraps Adobe\'s aem-cs-source-migration dispatcher-converter with mode detection, config generation, output verification, and validation.',
    promptPattern: 'dispatcher conversion',
    sampleOverride: 'Use the migration skill: convert my dispatcher configuration to AEM as a Cloud Service.',
  },
  oakIndex: {
    label: 'Oak Index Migration',
    severity: 'high',
    strategy: 'bpa-only',
    bpaSlugs: ['oakIndex'],
    description: 'Legacy `_oak_index/*.xml` definitions (BPA subtypes `index.rule.violation` / `standard.index.modification`, category OID) convertible to AEM as a Cloud Service Oak index definitions (Branch F, beta). BPA is the sole source of truth — one finding per Oak index JCR path; there is no analyzer or content-scan fallback. Wraps Adobe\'s aem-cs-source-migration index-converter tool rather than re-implementing transformation rules.',
    promptPattern: 'oakIndex',
    sampleOverride: 'Use the migration skill: fix oakIndex findings using BPA CSV at ./reports/bpa.csv.',
  },
};

// content-scan strategy → the runner that produces that pattern's findings.
const CONTENT_SCANNERS = {
  lui: runLuiScan,
  cdw: runCdwScan,
  templateModernization: runTemplateScan,
  dispatcherConversion: runDispatcherScan,
};

const CANONICAL_PATTERNS = Object.keys(PATTERN_META);

/** Normalize a BPA target into the runbook's display shape. */
function normalizeBpaTarget(t) {
  return {
    location: t.className || t.filePath || '—',
    detail: t.identifier || t.issue || '',
    severity: t.severity || 'high',
  };
}

/**
 * Normalize a BPA target into the raw handoff shape. BPA/CSV/MCP sources
 * carry no line number or code snippet — only `pattern` and `file` are
 * populated, so the apply handoff knows it must still run
 * `analyze.sh --files <path>` once to resolve `line`/`snippet` before
 * building an edit plan (see code-assessment/references/runbook.md,
 * with_findings (pre-resolved) mode).
 */
function rawBpaTarget(pattern, t) {
  return {
    pattern,
    file: t.filePath || t.className || null,
    line: null,
    snippet: null,
  };
}

/**
 * Build a BPA-sourced URC (unsupported run mode) finding/raw pair in the SAME
 * shape `scanUnsupportedRunmodes` (the local scanner) emits. BPA's URC target
 * carries the JCR folder path as `className` (e.g. `/apps/demo/config.dev.author`)
 * and only the bare subtype string `unsupported.runmode` as `identifier` — not
 * the offending run mode — so `normalizeBpaTarget`/`rawBpaTarget` would lose the
 * useful detail. Re-derive it by running `validateRunmodeFolder` on the folder
 * basename, same as the local scanner does on disk.
 */
function urcBpaTarget(t) {
  const folderPath = t.className || t.filePath || '—';
  const basename = path.basename(folderPath);
  const bad = validateRunmodeFolder(basename);
  const runmode = bad ? bad.runmode : (basename.includes('.') ? basename.slice(basename.indexOf('.') + 1) : basename);
  const detail = bad
    ? `unsupported-runmode — folder '${basename}' (${bad.reason}); rename to a supported run mode or remove`
    : `unsupported-runmode — ${folderPath}`;
  return {
    finding: { location: folderPath, detail, severity: 'high' },
    raw: {
      pattern: 'osgiConfig',
      file: folderPath,
      line: null,
      snippet: `unsupported run mode '${runmode}'`,
      kind: 'unsupported-runmode',
      runmode,
    },
  };
}

/**
 * Gather findings for every canonical pattern via the per-pattern cascade.
 *
 * @returns {Promise<{
 *   findingsByPattern: Record<string, Array<object>>,
 *   rawFindingsByPattern: Record<string, Array<{pattern: string, file: string, line: number|null, snippet: string|null}>>,
 *   sourceByPattern: Record<string, string>,   // 'mcp' | 'csv' | 'analyzer'
 *   needsLlmScan: string[],                      // patterns no deterministic source could scan
 *   analyzerWarnings: string[],
 *   bpaMode: string|null,
 *   analyzerUsed: boolean,
 * }>}
 */
async function gatherFindings(options = {}) {
  const {
    bpaFilePath,
    collectionsDir = './unified-collections',
    projectId,
    mcpFetcher,
    preFetchedBpa,
    workspaceRoot = process.cwd(),
    analyzeScript = DEFAULT_ANALYZE_SCRIPT,
    getEffectivePom,
  } = options;

  const sources = checkAvailableSources({ bpaFilePath, collectionsDir, projectId, mcpFetcher });
  // `preFetchedBpa` is a slug→targets map the agent already fetched from CAM/MCP
  // and passed via the CLI's `--bpa-json` (so no in-process `mcpFetcher` bridge
  // is needed). It counts as a BPA source and is treated as CAM-sourced ('mcp').
  const bpaMode = preFetchedBpa
    ? 'mcp'
    : sources.mcpServer.available
      ? 'mcp'
      : (sources.bpaFile.available || sources.unifiedCollection.available)
        ? 'csv'
        : null;

  // Single BPA fetch entry point: read from the pre-fetched map when present
  // (an absent slug key = benign "pattern not in report"), else fall back to the
  // live helper (CSV cache / in-process MCP fetcher).
  const fetchBpa = async (slug) => {
    if (preFetchedBpa) {
      const targets = preFetchedBpa[slug];
      return Array.isArray(targets) ? { success: true, targets } : { availablePatterns: true };
    }
    return getBpaFindings(slug, { bpaFilePath, collectionsDir, projectId, mcpFetcher, limit: null, offset: 0 });
  };

  const findingsByPattern = {};
  const rawFindingsByPattern = {};
  const sourceByPattern = {};
  const scannedBy = {};
  CANONICAL_PATTERNS.forEach(p => { findingsByPattern[p] = []; rawFindingsByPattern[p] = []; });

  const cascadePatterns = CANONICAL_PATTERNS.filter(p => PATTERN_META[p].strategy === 'cascade');
  const bpaPatterns = CANONICAL_PATTERNS.filter(p => (PATTERN_META[p].bpaSlugs || []).length > 0);
  const bpaTargetsByPattern = {};
  const scanWarnings = [];

  // ── BPA tier (MCP or CSV) — any pattern with bpaSlugs. When a BPA source is
  //    available it owns the verdict; the pattern's local detector (analyzer /
  //    content scan) only runs as a fallback when no BPA source scanned it. ──
  if (bpaMode) {
    for (const pattern of bpaPatterns) {
      const { bpaSlugs, bpaSubtypeFilter } = PATTERN_META[pattern];
      const merged = [];
      const mergedRaw = [];
      let realError = false; // a genuine fetch failure (not "pattern absent")
      for (const slug of bpaSlugs) {
        const res = await fetchBpa(slug);
        if (res.success && Array.isArray(res.targets)) {
          // A pattern may map to a broader BPA pattern (e.g. `lui` returns all
          // legacy.user.interface sub-types); keep only the ones this runbook
          // pattern owns.
          const targets = bpaSubtypeFilter
            ? res.targets.filter(t => bpaSubtypeFilter.includes(t.identifier))
            : res.targets;
          if (PATTERN_META[pattern].strategy === 'java-scan') {
            // Kept raw for the java-scan merge below (needs className/identifier/bundle).
            (bpaTargetsByPattern[pattern] = bpaTargetsByPattern[pattern] || []).push(...targets);
          }
          merged.push(...targets.map(normalizeBpaTarget));
          mergedRaw.push(...targets.map(t => rawBpaTarget(pattern, t)));
        } else if (res.availablePatterns) {
          // Benign: the report parsed fine but this pattern is absent — a
          // legitimate "clean" result. Stay silent.
        } else {
          // Real failure (mcp-error / mcp-server / bpa-file-error / no-source /
          // "no CSV path"). Do NOT report as clean.
          realError = true;
          scanWarnings.push(
            `BPA fetch failed for \`${pattern}\` (slug \`${slug}\`, source: ${res.source || 'unknown'}): ${res.error || res.message || 'unknown error'}`
          );
        }
      }

      if (realError && merged.length === 0) {
        // Nothing usable from BPA and the fetch genuinely failed — leave the
        // pattern UNSCANNED so its local detector runs as a fallback (and, if
        // that can't run either, it surfaces in needsLlmScan). Never mark it
        // as a clean BPA scan.
        continue;
      }
      if (merged.length === 0 && PATTERN_META[pattern].bpaIncomplete) {
        // A BPA source is present but STRUCTURALLY cannot fully report this
        // pattern (CAM can't see static templates at all; it filters Guava to
        // specific bundles). A zero result is therefore NOT proof of clean —
        // leave it UNSCANNED so the local fallback runs: content-scan for
        // templateModernization, or the LLM tier for bpa-only guavaCache. If
        // BPA *did* return findings they are kept (below) and own the verdict.
        continue;
      }
      // Either the fetch succeeded (possibly zero findings = genuinely clean)
      // or we still got some findings despite a partial error — BPA owns it.
      findingsByPattern[pattern] = merged;
      rawFindingsByPattern[pattern] = mergedRaw;
      sourceByPattern[pattern] = bpaMode;
      scannedBy[pattern] = bpaMode;
    }
  }

  // ── Cascade tier 3: analyzer. Runs for EVERY cascade pattern the analyzer can
  //    reach and UNIONs with BPA: BPA stays authoritative for what it reported,
  //    and the analyzer fills source-only findings BPA's scan scope missed
  //    (deduped by class name). This makes the runbook complete in one pass —
  //    the agent no longer has to reconcile "BPA clean" against real source by
  //    hand. Patterns with no BPA verdict are owned outright by the analyzer. ──
  let analyzerUsed = false;
  const analyzerCanRun = isAnalyzerAvailable(analyzeScript) && !!workspaceRoot;

  if (analyzerCanRun && cascadePatterns.length > 0) {
    const result = runAnalyzer(workspaceRoot, { analyzeScript });
    if (result.ok) {
      analyzerUsed = true;
      if (result.warnings && result.warnings.length) scanWarnings.push(...result.warnings);
      for (const pattern of cascadePatterns) {
        const aFind = result.findingsByPattern[pattern] || [];
        const aRaw = result.rawFindingsByPattern[pattern] || [];
        if (!scannedBy[pattern]) {
          // No BPA verdict — the analyzer owns the pattern outright.
          findingsByPattern[pattern] = aFind;
          rawFindingsByPattern[pattern] = aRaw;
          sourceByPattern[pattern] = 'analyzer';
          scannedBy[pattern] = 'analyzer';
        } else if (aRaw.length > 0) {
          // BPA already owns the verdict — append only the source-only findings
          // BPA didn't report (deduped by class name), so a "BPA clean" pattern
          // still surfaces real code BPA's scan scope missed.
          const seen = new Set(rawFindingsByPattern[pattern].map(r => classKey(r.file)).filter(Boolean));
          let added = 0;
          aRaw.forEach((r, i) => {
            const k = classKey(r.file);
            if (k && !seen.has(k)) { seen.add(k); rawFindingsByPattern[pattern].push(r); findingsByPattern[pattern].push(aFind[i]); added++; }
          });
          if (added > 0 && !String(sourceByPattern[pattern]).includes('analyzer')) {
            sourceByPattern[pattern] = `${sourceByPattern[pattern]}+analyzer`;
          }
        }
      }
    }
  }

  // ── Strategy 'html-scan': htlLint (independent of the cascade) ───────────
  if (CANONICAL_PATTERNS.includes('htlLint') && workspaceRoot) {
    const res = runHtlLint(workspaceRoot);
    if (res.ok) {
      findingsByPattern.htlLint = res.findings;
      rawFindingsByPattern.htlLint = res.rawFindings;
      sourceByPattern.htlLint = 'html-scan';
      scannedBy.htlLint = 'html-scan';
    }
  }

  // ── Strategy 'config-scan': osgiConfig (independent of the cascade) ──────
  if (CANONICAL_PATTERNS.includes('osgiConfig') && workspaceRoot) {
    const res = runOsgiConfigScan(workspaceRoot);
    if (res.ok) {
      findingsByPattern.osgiConfig = res.findings;
      rawFindingsByPattern.osgiConfig = res.rawFindings;
      sourceByPattern.osgiConfig = 'config-scan';
      scannedBy.osgiConfig = 'config-scan';
      if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);

      // URC (unsupported run modes) — report-first, local safety net. Kept under
      // osgiConfig: the secret/legacy scan above is always local; only this URC
      // sub-portion prefers the BPA report. BPA owns the verdict ONLY when it
      // actually reports URC findings (non-empty targets). A report that is
      // present but reports NO URC rows — pattern absent from a CSV, or an MCP
      // fetch that came back with an empty target list — does NOT by itself
      // prove the project is URC-clean (the report may predate URC detection
      // or be scoped to other patterns), so we warn and still run the local
      // config.*/install.* scan as a safety net. The local scanner also runs
      // when there is no BPA source at all, or the fetch genuinely failed.
      let urcFindings = [];
      let urcRaw = [];
      let bpaOwnsUrc = false;
      if (bpaMode) {
        const urc = await fetchBpa('urc');
        if (urc.success && Array.isArray(urc.targets) && urc.targets.length > 0) {
          const built = urc.targets.map(urcBpaTarget);
          urcFindings = built.map(b => b.finding);
          urcRaw = built.map(b => b.raw);
          bpaOwnsUrc = true;
        } else if (urc.availablePatterns || (urc.success && Array.isArray(urc.targets))) {
          // BPA source present but reported no URC rows — not proof of
          // "clean"; warn and fall through to the local safety-net scan below.
          scanWarnings.push(
            'BPA source present but reported no URC (unsupported.runmode) findings — running the local config.*/install.* run-mode scan as a safety net (the report may predate URC detection or be scoped to other patterns).'
          );
        }
        // else: real fetch error → fall through to the local scanner.
      }
      if (!bpaOwnsUrc) {
        const localUrc = scanUnsupportedRunmodes(workspaceRoot);
        if (localUrc.ok) { urcFindings = localUrc.findings; urcRaw = localUrc.rawFindings; }
      }
      findingsByPattern.osgiConfig.push(...urcFindings);
      rawFindingsByPattern.osgiConfig.push(...urcRaw);
    }
  }

  // ── Strategy 'pom-scan': vault-package-dependencies (independent of the cascade) ──
  if (CANONICAL_PATTERNS.includes('vault-package-dependencies') && workspaceRoot) {
    const res = runVaultPackageScan(workspaceRoot, getEffectivePom ? { getEffectivePom } : undefined);
    // Warnings surface effective-pom resolution failures and text-scan
    // fallbacks — propagate them regardless of ok so a degraded scan is
    // never silent.
    if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);
    if (res.ok) {
      findingsByPattern['vault-package-dependencies'] = res.findings;
      rawFindingsByPattern['vault-package-dependencies'] = res.rawFindings;
      sourceByPattern['vault-package-dependencies'] = 'pom-scan';
      scannedBy['vault-package-dependencies'] = 'pom-scan';
    } else if (res.error) {
      // The scan itself couldn't run against any module — surface the reason
      // and leave the pattern UNSCANNED so it falls through to needsLlmScan
      // rather than being reported as clean.
      scanWarnings.push(`vault-package-dependencies scan did not run: ${res.error}`);
    }
  }

  // ── Strategy 'content-scan': lui / cdw / templateModernization / dispatcherConversion
  //    (fallback when no BPA source scanned the pattern; dispatcherConversion has no BPA
  //     subtype, so it always runs here) ──────────────────────────────────────
  if (workspaceRoot) {
    for (const pattern of CANONICAL_PATTERNS) {
      if (PATTERN_META[pattern].strategy !== 'content-scan') continue;
      if (scannedBy[pattern]) continue; // BPA already provided findings
      const scan = CONTENT_SCANNERS[pattern];
      if (!scan) continue;
      const res = scan(workspaceRoot);
      if (res.ok) {
        findingsByPattern[pattern] = res.findings;
        rawFindingsByPattern[pattern] = res.rawFindings;
        sourceByPattern[pattern] = 'content-scan';
        scannedBy[pattern] = 'content-scan';
        if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);
      }
    }
  }

  // ── Strategy 'java-scan': inputStream. Same precedence as the other BPA
  //    patterns: when the BPA tier above returned inputStream rows (CAM or CSV),
  //    BPA owns the pattern and the scanner only resolves those classes to
  //    workspace files for line-level sites (classes outside the workspace
  //    collapse to one informational row per bundle). Only when BPA has no
  //    rows — no source, a failed fetch, or none reported — does the local
  //    scan of every src/main/java file run. ──
  if (workspaceRoot) {
    for (const pattern of CANONICAL_PATTERNS) {
      if (PATTERN_META[pattern].strategy !== 'java-scan') continue;
      const bpaTargets = bpaTargetsByPattern[pattern] || [];
      const res = runInputStreamScan(workspaceRoot, { bpaTargets });
      if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);
      if (!res.ok) continue;
      findingsByPattern[pattern] = res.findings;
      rawFindingsByPattern[pattern] = res.rawFindings;
      sourceByPattern[pattern] = bpaTargets.length > 0 ? bpaMode : 'source-scan';
      scannedBy[pattern] = sourceByPattern[pattern];
    }
  }

  // ── Tier 4: anything still unscanned → LLM scan (agent handles it) ───────
  const needsLlmScan = CANONICAL_PATTERNS.filter(p => !scannedBy[p]);

  return { findingsByPattern, rawFindingsByPattern, sourceByPattern, needsLlmScan, analyzerWarnings: scanWarnings, bpaMode, analyzerUsed };
}

/** Build the per-pattern copy-paste sample prompt. */
function samplePrompt(pattern, ctx) {
  const meta = PATTERN_META[pattern];
  if (meta.sampleOverride) return meta.sampleOverride;
  const csvClause = ctx.bpaFilePath ? ` (BPA CSV at \`${ctx.bpaFilePath}\`)` : '';
  return `Use the migration skill: **${meta.promptPattern}** only${csvClause}.`;
}

/**
 * Strip a leading `workspaceRoot` prefix from a `file` or `file:line` string so
 * the committed runbook shows portable, workspace-relative paths and does not
 * leak the local filesystem layout. Leaves non-path values untouched (e.g. a
 * BPA `className` FQN, or an already-relative path), so it is safe to apply to
 * every source's output.
 */
function relPath(value, workspaceRoot) {
  if (!value || !workspaceRoot || typeof value !== 'string') return value;
  const root = workspaceRoot.endsWith(path.sep) ? workspaceRoot.slice(0, -1) : workspaceRoot;
  // A finding whose path IS the workspace root (e.g. a Dispatcher config at the
  // project root) must not render as the absolute root path — collapse it to '.'
  // so the committed runbook never leaks the local filesystem layout.
  if (value === root || value === root + path.sep) return '.';
  const prefix = root + path.sep;
  return value.startsWith(prefix) ? value.slice(prefix.length) : value;
}

/**
 * Reduce a BPA `className` (`com.acme.MyJob`) or an analyzer file path
 * (`core/src/main/java/com/acme/MyJob.java`) to a comparable simple class-name
 * key, so BPA and analyzer findings for the same class can be de-duplicated
 * when the two sources are unioned (see the cascade tier in `gatherFindings`).
 */
function classKey(fileOrClass) {
  if (!fileOrClass || typeof fileOrClass !== 'string') return null;
  const noExt = fileOrClass.replace(/\.java$/i, '');
  const seg = noExt.split(/[\\/.]/).filter(Boolean);
  return seg.length ? seg[seg.length - 1].toLowerCase() : null;
}

/** Findings that represent outstanding work (excludes informational rows). */
function actionable(findings) {
  return findings.filter(f => !f.informational);
}

/** Render the runbook markdown. */
function renderRunbook(gathered, ctx = {}) {
  const { findingsByPattern, sourceByPattern, needsLlmScan, analyzerWarnings } = gathered;
  const root = ctx.workspaceRoot;
  const lines = [];

  const total = CANONICAL_PATTERNS.reduce((n, p) => n + actionable(findingsByPattern[p]).length, 0);
  const withFindings = CANONICAL_PATTERNS.filter(p => actionable(findingsByPattern[p]).length > 0);

  lines.push('# AEM Cloud Service — Migration Runbook');
  lines.push('');
  lines.push('> **Read-only planning document.** It lists the migration findings discovered');
  lines.push('> across your project. Use a pattern\'s sample prompt to start the');
  lines.push('> one-pattern-per-session apply workflow. No code was changed to produce this.');
  lines.push('');

  lines.push('## Summary');
  lines.push('');
  lines.push('| Field | Value |');
  lines.push('|-------|-------|');
  lines.push(`| Generated | ${ctx.generatedAt || new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC |`);
  lines.push(`| Total findings | **${total}** |`);
  lines.push(`| Patterns with findings | **${withFindings.length}** of ${CANONICAL_PATTERNS.length} |`);
  lines.push('');

  lines.push('### Findings by pattern');
  lines.push('');
  lines.push('| Pattern | Severity | Findings | Detected via |');
  lines.push('|---------|----------|----------|--------------|');
  for (const p of CANONICAL_PATTERNS) {
    const meta = PATTERN_META[p];
    const count = actionable(findingsByPattern[p]).length;
    const src = needsLlmScan.includes(p)
      ? '_needs LLM scan_'
      : sourceLabel(sourceByPattern[p]);
    lines.push(`| ${meta.label} | ${meta.severity} | ${count === 0 ? '—' : `**${count}**`} | ${src} |`);
  }
  lines.push('');

  if (needsLlmScan.length > 0) {
    lines.push(`> ⚠️ No deterministic source could scan: **${needsLlmScan.map(p => PATTERN_META[p].label).join(', ')}**. `);
    lines.push('> Provide a BPA CSV, open the project workspace for the analyzer, or ask the agent to run an LLM scan for these.');
    lines.push('');
  }

  lines.push('---');
  lines.push('');

  for (const p of CANONICAL_PATTERNS) {
    const targets = findingsByPattern[p];
    if (targets.length === 0) continue;
    const meta = PATTERN_META[p];

    const actCount = actionable(targets).length;
    const infoCount = targets.length - actCount;
    lines.push(`## ${meta.label} (\`${p}\`)`);
    lines.push('');
    lines.push(`**Severity:** ${meta.severity} | **Findings:** ${actCount}${infoCount > 0 ? ` (+${infoCount} informational)` : ''} | **Detected via:** ${sourceLabel(sourceByPattern[p])}`);
    lines.push('');
    lines.push(meta.description);
    lines.push('');
    if (meta.heuristic) {
      lines.push(`> ⚠️ **Heuristic detection** (${sourceLabel(sourceByPattern[p], 'non-deterministic')}) — these are candidate matches, not compiler/BPA-validated findings. Re-confirm each one before editing.`);
      if (meta.needsReview) {
        lines.push('> Secret **values** are intentionally omitted from this runbook — only key names and locations are shown. Classification happens at apply time.');
      }
      lines.push('');
    }
    lines.push('### Affected');
    lines.push('');
    lines.push('| Location | Detail | Severity |');
    lines.push('|----------|--------|----------|');
    for (const t of targets) {
      const detail = (t.detail || '').replace(/\|/g, '\\|');
      const location = relPath(t.location, root);
      lines.push(`| \`${location}\` | \`${detail || '—'}\` | ${t.severity || 'high'} |`);
    }
    lines.push('');
    lines.push('### Sample prompt');
    lines.push('');
    lines.push('```');
    lines.push(samplePrompt(p, ctx));
    lines.push('```');
    lines.push('');
    lines.push('---');
    lines.push('');
  }

  const clean = CANONICAL_PATTERNS.filter(p => findingsByPattern[p].length === 0 && !needsLlmScan.includes(p));
  if (clean.length > 0) {
    lines.push('## Scanned — no findings');
    lines.push('');
    for (const p of clean) lines.push(`- **${PATTERN_META[p].label}** (\`${p}\`)`);
    lines.push('');
  }

  if (analyzerWarnings && analyzerWarnings.length > 0) {
    lines.push('## Scan warnings');
    lines.push('');
    for (const w of analyzerWarnings) lines.push(`- ${w}`);
    lines.push('');
  }

  lines.push('---');
  lines.push('');
  lines.push('*Generated by the AEM Cloud Service migration skill — runbook-generator.js*');
  return lines.join('\n');
}

const SOURCE_LABEL = {
  mcp: 'BPA / CAM',
  csv: 'BPA CSV',
  analyzer: 'analyzer',
  'html-scan': 'HTL scan',
  'config-scan': 'config scan',
  'content-scan': 'content scan',
  'source-scan': 'Java source scan',
  llm: 'LLM scan',
};

/**
 * Human label for a `sourceByPattern` value. Handles unioned sources like
 * `mcp+analyzer` (BPA authoritative, analyzer filled source-only gaps) by
 * joining each part's label.
 */
function sourceLabel(src, fallback = '—') {
  if (!src) return fallback;
  return String(src).split('+').map(s => SOURCE_LABEL[s] || s).join(' + ');
}

/**
 * Default path for the runbook's raw-findings sidecar cache, written next to
 * the markdown so the apply handoff (migration/SKILL.md Step 3) can reuse
 * already-computed findings for a pattern instead of re-deriving them via
 * `getBpaFindings` or re-running the analyzer.
 */
const DEFAULT_CACHE_PATH = './migration-runbook.json';

/**
 * Write the raw-findings sidecar cache. Kept as a separate function (rather
 * than inlined in generateRunbook) so a caller re-rendering after an LLM/rg
 * scan (Tier 4) can refresh the cache without re-running the whole cascade.
 */
function writeRunbookCache(gathered, ctx, cachePath = DEFAULT_CACHE_PATH) {
  const root = ctx.workspaceRoot;
  const cache = {
    generatedAt: ctx.generatedAt,
    // `file` fields below are relative to this root — the apply handoff
    // resolves them with `path.join(workspaceRoot, file)`.
    workspaceRoot: root || null,
    project: ctx.projectId ? { id: ctx.projectId, name: ctx.projectName || null } : null,
    sourceByPattern: gathered.sourceByPattern,
    findingsByPattern: {},
  };
  // Copy raw findings, relativizing `file` and tagging heuristic patterns
  // (regex-scanned htlLint, config-scanned osgiConfig) so the apply handoff
  // knows they are candidate matches, not analyzer/BPA-grade findings, and
  // must re-confirm each one.
  for (const [pattern, findings] of Object.entries(gathered.rawFindingsByPattern)) {
    const meta = PATTERN_META[pattern];
    const heuristic = !!(meta && meta.heuristic);
    cache.findingsByPattern[pattern] = findings.map(f => {
      const rel = { ...f, file: relPath(f.file, root) };
      if (heuristic) rel.confidence = 'heuristic';
      return rel;
    });
  }
  fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');
  return cachePath;
}

/**
 * Merge agent-supplied LLM-scan findings into a `gathered` object in one shot,
 * so the Tier-4 fallback needs no hand-mutation of `gathered` internals or a
 * second `renderRunbook`/`writeRunbookCache` round-trip. `llmByPattern` maps a
 * canonical pattern id to an array of raw findings `{file, line?, snippet?,
 * detail?, severity?}`; the display shape is derived from each. The pattern is
 * marked `source: 'llm'` and dropped from `needsLlmScan`.
 */
function mergeLlmFindings(gathered, llmByPattern) {
  for (const [pattern, raws] of Object.entries(llmByPattern || {})) {
    if (!CANONICAL_PATTERNS.includes(pattern)) continue;
    const rawArr = Array.isArray(raws) ? raws : [];
    gathered.rawFindingsByPattern[pattern] = rawArr.map(r => ({
      pattern, file: r.file || null, line: r.line ?? null, snippet: r.snippet ?? null,
    }));
    gathered.findingsByPattern[pattern] = rawArr.map(r => ({
      location: (r.file && r.line) ? `${r.file}:${r.line}` : (r.file || '—'),
      detail: r.detail || r.snippet || '',
      severity: r.severity || 'high',
    }));
    gathered.sourceByPattern[pattern] = 'llm';
    gathered.needsLlmScan = gathered.needsLlmScan.filter(p => p !== pattern);
  }
  return gathered;
}

/**
 * Generate the runbook end to end.
 *
 * @returns {Promise<{
 *   success: boolean, outputPath?: string, cachePath?: string, message: string,
 *   totalFindings: number, patternCounts: object,
 *   needsLlmScan: string[], gathered: object,
 * }>}
 *   When `needsLlmScan` is non-empty, LLM-scan those patterns and pass the
 *   results back as the `llmByPattern` option (CLI: `--llm-findings <file>`) —
 *   the generator merges, re-renders, and rewrites the cache in one pass.
 */
async function generateRunbook(options = {}) {
  const { outputPath = './migration-runbook.md', cachePath = DEFAULT_CACHE_PATH, bpaFilePath, workspaceRoot, llmByPattern, projectId, projectName } = options;

  const gathered = await gatherFindings(options);
  if (llmByPattern) mergeLlmFindings(gathered, llmByPattern);
  const ctx = {
    bpaFilePath,
    workspaceRoot,
    projectId,
    projectName,
    generatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19),
  };

  const markdown = renderRunbook(gathered, ctx);
  fs.writeFileSync(outputPath, markdown, 'utf8');
  writeRunbookCache(gathered, ctx, cachePath);

  // Counts reflect outstanding work — informational findings (e.g. OSGi
  // configs already using placeholders) are excluded from the headline.
  const patternCounts = {};
  let totalFindings = 0;
  for (const p of CANONICAL_PATTERNS) {
    patternCounts[p] = actionable(gathered.findingsByPattern[p]).length;
    totalFindings += patternCounts[p];
  }

  return {
    success: true,
    outputPath,
    cachePath,
    message: `Runbook written to ${outputPath} — ${totalFindings} findings across ${CANONICAL_PATTERNS.filter(p => patternCounts[p] > 0).length} pattern(s).`,
    totalFindings,
    patternCounts,
    needsLlmScan: gathered.needsLlmScan,
    gathered,
  };
}

// CLI
function parseArgs(argv) {
  const out = { workspaceRoot: undefined, bpaFilePath: undefined, outputPath: './migration-runbook.md', cachePath: DEFAULT_CACHE_PATH, bpaJsonPath: undefined, llmFindingsPath: undefined, projectId: undefined, projectName: undefined };
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i];
    if (a === '--csv') out.bpaFilePath = argv[++i];
    else if (a === '--out') out.outputPath = argv[++i];
    else if (a === '--cache') out.cachePath = argv[++i];
    else if (a === '--bpa-json') out.bpaJsonPath = argv[++i];
    else if (a === '--llm-findings') out.llmFindingsPath = argv[++i];
    else if (a === '--project-id') out.projectId = argv[++i];
    else if (a === '--project-name') out.projectName = argv[++i];
    else if (!out.workspaceRoot) out.workspaceRoot = a;
  }
  if (!out.workspaceRoot) out.workspaceRoot = process.cwd();
  return out;
}

async function main() {
  const { workspaceRoot, bpaFilePath, outputPath, cachePath, bpaJsonPath, llmFindingsPath, projectId, projectName } = parseArgs(process.argv.slice(2));

  // Pre-fetched BPA (agent dumped its CAM/MCP result to JSON) and LLM-scan
  // findings are the two inputs that let the whole runbook run as ONE command,
  // with no in-process require()/mcpFetcher bridge and no Tier-4 re-render.
  let preFetchedBpa;
  if (bpaJsonPath) preFetchedBpa = JSON.parse(fs.readFileSync(bpaJsonPath, 'utf8'));
  let llmByPattern;
  if (llmFindingsPath) llmByPattern = JSON.parse(fs.readFileSync(llmFindingsPath, 'utf8'));

  console.log('Migration Runbook Generator');
  console.log('===========================');
  console.log(`Workspace:  ${workspaceRoot}`);
  if (bpaFilePath) console.log(`BPA CSV:    ${bpaFilePath}`);
  if (bpaJsonPath) console.log(`BPA JSON:   ${bpaJsonPath}`);
  if (llmFindingsPath) console.log(`LLM find.:  ${llmFindingsPath}`);
  console.log(`Output:     ${outputPath}`);
  console.log(`Cache:      ${cachePath}`);
  console.log('');

  const result = await generateRunbook({ workspaceRoot, bpaFilePath, outputPath, cachePath, preFetchedBpa, llmByPattern, projectId, projectName });

  console.log(`✅ ${result.message}`);
  console.log(`   Findings cache: ${result.cachePath}`);
  console.log('');
  console.log('Pattern breakdown:');
  for (const [pattern, count] of Object.entries(result.patternCounts)) {
    const src = result.gathered.needsLlmScan.includes(pattern)
      ? 'needs-llm-scan'
      : (result.gathered.sourceByPattern[pattern] || '—');
    console.log(`  ${pattern.padEnd(25)} ${String(count).padStart(3)}  (${src})`);
  }
  if (result.needsLlmScan.length > 0) {
    console.log('');
    console.log(`⚠️  Needs LLM scan: ${result.needsLlmScan.join(', ')}`);
    console.log('   LLM-scan these, write the results to a JSON file (pattern → [{file, line, snippet}]),');
    console.log('   then re-run with `--llm-findings <file>` to fold them in — no manual merge needed.');
  }
}

if (require.main === module) {
  main().catch(err => { console.error(err); process.exit(1); });
}

module.exports = {
  generateRunbook,
  parseArgs,
  gatherFindings,
  renderRunbook,
  writeRunbookCache,
  mergeLlmFindings,
  samplePrompt,
  relPath,
  classKey,
  sourceLabel,
  CANONICAL_PATTERNS,
  PATTERN_META,
  DEFAULT_CACHE_PATH,
};

skills

migration

README.md

SKILL.md

README.md

tile.json