AEM as a Cloud Service — Code Migration Source → target: Legacy AEM 6.x / AMS / on-prem → AEM as a Cloud Service . Scoped under so this is not confused with Edge Delivery or 6.5 LTS. This skill drives the migration workflow : BPA data, CAM/MCP, one pattern per session , and target discovery. Transformation rules and steps live in the skill — once a finding's pattern is identified, hand off to (or the relevant shared reference under ). Setup: Use the install (see repository root README ) so both migration and code-assessment paths are available. If you already have the monorepo open with resol…

)[0];\n const file = index.get(outer);\n if (file) {\n const e = bpaByFile.get(file) || { classes: new Set(), apis: new Set() };\n e.classes.add(cls);\n if (t.identifier) e.apis.add(t.identifier);\n bpaByFile.set(file, e);\n if (t.bundle) workspaceBundles.add(t.bundle);\n } else {\n unresolved.push({ t, outer });\n }\n }\n for (const { t, outer } of unresolved) {\n const key = t.bundle ? `bundle ${t.bundle}` : `package ${packageRoot(outer)}`;\n const g = groups.get(key) || { key, bundle: t.bundle || null, classes: new Set(), rows: 0, apis: {} };\n g.classes.add(outer);\n g.rows++;\n if (t.identifier) g.apis[t.identifier] = (g.apis[t.identifier] || 0) + 1;\n groups.set(key, g);\n }\n // Customer code kept elsewhere: the bundle has classes in this workspace,\n // or most of its classes share the workspace's package namespace.\n const workspaceRoots = new Set([...index.keys()].map(namespaceRoot).filter(Boolean));\n for (const g of groups.values()) {\n const shared = [...g.classes].filter(c => workspaceRoots.has(namespaceRoot(c))).length;\n const platform = g.bundle\n ? PLATFORM_PROVIDED_BUNDLES.some(re => re.test(g.bundle))\n : [...g.classes].every(c => PLATFORM_PROVIDED_PACKAGES.test(c));\n if (g.bundle && workspaceBundles.has(g.bundle)) g.kind = 'missing-source';\n else if (platform) g.kind = 'platform-provided';\n else if (shared * 2 >= g.classes.size) g.kind = 'missing-source';\n else g.kind = 'not-in-workspace';\n }\n }\n\n // In bpa mode only the BPA-reported files are in scope; local mode scans everything.\n const perFile = new Map();\n for (const file of mode === 'bpa' ? [...bpaByFile.keys()] : [...sources.keys()]) {\n const sites = sitesOfFile(file);\n if (sites.length > 0) perFile.set(file, sites);\n }\n\n const findings = [];\n const rawFindings = [];\n const add = (finding, raw) => { findings.push(finding); rawFindings.push(raw); };\n\n const fileEntries = [...perFile.entries()].map(([file, sites]) => {\n const actionable = sites.filter(s => s.primary !== 'A');\n const { counts, fix } = summarizeSites(sites);\n const top = actionable.slice().sort((a, b) =>\n (SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]) || (CATEGORY[a.primary].rank - CATEGORY[b.primary].rank) || (a.line - b.line))[0];\n return { file, sites, actionable, counts, fix, top, severity: maxSeverity(actionable.map(s => s.severity)) };\n });\n fileEntries.sort((a, b) =>\n (b.actionable.length > 0) - (a.actionable.length > 0)\n || SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]\n || (a.top && b.top ? CATEGORY[a.top.primary].rank - CATEGORY[b.top.primary].rank : 0)\n || a.file.localeCompare(b.file));\n\n for (const e of fileEntries) {\n const bpa = bpaByFile.get(e.file);\n const anchor = e.top || e.sites[0];\n const countStr = Object.keys(e.counts).sort((a, b) => CATEGORY[a].rank - CATEGORY[b].rank).map(c => `${c}×${e.counts[c]}`).join(' ');\n const fixStr = ['decision', 'review', 'auto'].filter(k => e.fix[k]).map(k => `${k} ${e.fix[k]}`).join(', ');\n const nativeLines = nativeLinesByFile.get(e.file) || [];\n // Reported at file level: a class that starts native processes is a migration\n // concern of its own, separate from any one stream site in it.\n const nativeNote = nativeLines.length > 0\n ? ` · starts a native process (${nativeLines.slice(0, 3).map(l => `L${l}`).join(', ')}): native binaries can't be deployed to AEM as a Cloud Service`\n : '';\n const detail = (e.top\n ? `${countStr} · ${fixStr} · L${e.top.line} ${e.top.primary}: ${e.top.note.split('; ')[0]}${bpa ? ' · BPA ✓' : ''}`\n : `benign (${e.sites.length} site${e.sites.length > 1 ? 's' : ''}): ${e.sites[0].note.split('; ')[0]}${bpa ? ' · BPA ✓' : ''}`) + nativeNote;\n const informational = e.actionable.length === 0;\n add(\n { location: `${e.file}:${anchor.line}`, detail, severity: informational ? 'info' : e.severity, ...(informational ? { informational: true } : {}) },\n {\n pattern: 'inputStream', file: e.file, line: anchor.line, snippet: anchor.snippet, kind: 'source',\n severity: informational ? 'info' : e.severity, fix: e.fix, sites: e.sites,\n ...(nativeLines.length > 0 ? { nativeProcessLines: nativeLines } : {}),\n ...(bpa ? { bpa: { classes: [...bpa.classes].sort(), apis: [...bpa.apis].sort() } } : {}),\n ...(informational ? { informational: true } : {}),\n }\n );\n }\n\n // BPA classes that resolve to a file with no detectable stream usage. The\n // hint names same-directory files that do use streams (scanned on demand).\n const siblingsWithStreams = (file) => {\n const dir = path.dirname(file);\n return [...sources.keys()]\n .filter(f => f !== file && path.dirname(f) === dir && sitesOfFile(f).some(s => s.primary !== 'A'))\n .map(f => path.basename(f))\n .sort()\n .slice(0, 3);\n };\n for (const [file, bpa] of [...bpaByFile.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {\n if (perFile.has(file)) continue;\n const siblings = siblingsWithStreams(file);\n const hint = 'BPA analyses compiled classes, so a use may not be visible in source (for example a stream passed straight from one library call to another)'\n + (siblings.length ? `; also check same-package files that use streams: ${siblings.join(', ')}` : '; if the file no longer uses streams, the report predates the change');\n add(\n { location: file, detail: `BPA flags ${[...bpa.apis].join(', ') || 'java.io stream use'}; no stream usage found in source — ${hint}`, severity: 'info', informational: true },\n { pattern: 'inputStream', file, line: null, snippet: null, kind: 'bpa-unmatched', informational: true, bpa: { classes: [...bpa.classes].sort(), apis: [...bpa.apis].sort() }, hint }\n );\n }\n\n // BPA rows outside the workspace — one row per bundle. Customer code kept\n // elsewhere comes first: it hides real work the scan couldn't see.\n const ordered = [...groups.values()].sort((a, b) => GROUP_ORDER[a.kind] - GROUP_ORDER[b.kind] || b.rows - a.rows || a.key.localeCompare(b.key));\n for (const g of ordered) {\n const apis = Object.entries(g.apis).sort((a, b) => b[1] - a[1]).slice(0, 3).map(([api, n]) => `${api.replace(/^java\\.io\\./, '')}×${n}`).join(', ');\n const size = `${g.classes.size} ${g.classes.size === 1 ? 'class' : 'classes'} / ${g.rows} BPA ${g.rows === 1 ? 'row' : 'rows'} (${apis})`;\n const detail = {\n 'missing-source': `customer code not in this workspace — ${size} · fix it in the repository that builds it: open that repository and re-scan`,\n 'platform-provided': `AEM Forms add-on bundle — ${size} · provided by Adobe on AEM as a Cloud Service when the Forms add-on is enabled in Cloud Manager; don't deploy or edit it`,\n 'not-in-workspace': `third-party code — ${size} · upgrade to a version that supports AEM as a Cloud Service or replace it; don't edit vendored code`,\n }[g.kind];\n add(\n { location: g.key, detail, severity: 'info', informational: true },\n {\n pattern: 'inputStream', file: null, line: null, snippet: null, kind: g.kind, informational: true,\n bundle: g.bundle, group: g.key, classCount: g.classes.size, rowCount: g.rows, sampleClasses: [...g.classes].sort().slice(0, 5),\n }\n );\n }\n\n const allSites = fileEntries.flatMap(e => e.sites);\n const byCategory = {};\n allSites.forEach(s => s.categories.forEach(c => { byCategory[c] = (byCategory[c] || 0) + 1; }));\n const fixTotals = { auto: 0, review: 0, decision: 0 };\n allSites.forEach(s => { if (fixTotals[s.fix] !== undefined) fixTotals[s.fix]++; });\n const rowsOf = kind => ordered.filter(g => g.kind === kind).reduce((n, g) => n + g.rows, 0);\n const missingSourceGroups = ordered.filter(g => g.kind === 'missing-source');\n const stats = {\n mode,\n filesRequested: files.length,\n filesScanned: scanCache.size,\n filesIndexed: sources.size,\n skippedFiles: skippedFiles.map(f => ({ file: f.file, reason: f.reason })),\n filesWithStreams: fileEntries.length,\n actionableFiles: fileEntries.filter(e => e.actionable.length > 0).length,\n sites: allSites.length,\n byCategory,\n fix: fixTotals,\n bpa: {\n rows: mode === 'bpa' ? bpaTargets.length : 0,\n resolvedFiles: bpaByFile.size,\n unmatchedFiles: [...bpaByFile.keys()].filter(f => !perFile.has(f)).length,\n missingSourceRows: rowsOf('missing-source'),\n thirdPartyRows: rowsOf('not-in-workspace'),\n platformProvidedRows: rowsOf('platform-provided'),\n bundleGroups: ordered.length,\n },\n };\n\n const warnings = [];\n for (const f of skippedFiles) {\n warnings.push(`inputStream: ${path.relative(workspaceRoot, f.file) || f.file} was not scanned — ${f.reason}.`);\n }\n if (mode === 'bpa' && files.length === 0) {\n warnings.push(`inputStream: BPA reports ${bpaTargets.length} java.io.inputstream rows but the workspace has no src/main/java sources — open the AEM project root so they can be resolved.`);\n } else if (mode === 'bpa' && bpaByFile.size === 0) {\n warnings.push(`inputStream: none of the ${bpaTargets.length} BPA rows resolve to workspace source — they belong to bundles outside this workspace (or the report is for a different project).`);\n }\n for (const g of missingSourceGroups) {\n const label = g.bundle ? `bundle ${g.bundle}` : g.key;\n const one = g.classes.size === 1;\n warnings.push(`inputStream: ${g.classes.size} BPA-flagged ${one ? 'class' : 'classes'} from ${label} ${one ? 'looks' : 'look'} like customer code but ${one ? 'has' : 'have'} no source in this workspace (e.g. ${[...g.classes].sort()[0]}) — open the repository that builds ${one ? 'it' : 'them'} and re-scan.`);\n }\n\n // A verification re-scan (--files) that couldn't read every requested file is\n // incomplete, so it must not pass as clean.\n if (onlyFiles && skippedFiles.length > 0) {\n return {\n ok: false, findings, rawFindings, warnings, stats,\n error: `${skippedFiles.length} of ${files.length} requested file(s) could not be scanned: `\n + skippedFiles.map(f => `${path.relative(workspaceRoot, f.file) || f.file} (${f.reason})`).join(', '),\n };\n }\n return { ok: true, findings, rawFindings, warnings, stats };\n}\n\n// ── CLI ─────────────────────────────────────────────────────────────────────\n\nfunction parseCliArgs(argv) {\n const args = { workspaceRoot: null, csv: null, bpaJson: null, collections: './unified-collections', files: null, json: false };\n for (let i = 0; i \u003c argv.length; i++) {\n const a = argv[i];\n if (a === '--csv') args.csv = argv[++i];\n else if (a === '--bpa-json') args.bpaJson = argv[++i];\n else if (a === '--collections') args.collections = argv[++i];\n else if (a === '--files') args.files = String(argv[++i] || '').split(',').map(s => s.trim()).filter(Boolean);\n else if (a === '--json') args.json = true;\n else if (!args.workspaceRoot) args.workspaceRoot = a;\n }\n return args;\n}\n\n/** inputStream targets from a `--bpa-json` file: `[targets]` or `{ \"inputStream\": [targets], … }`. */\nfunction readBpaJson(file) {\n const data = JSON.parse(fs.readFileSync(file, 'utf8'));\n const targets = Array.isArray(data) ? data : data && data.inputStream;\n return Array.isArray(targets) ? targets : [];\n}\n\n/**\n * BPA targets in the migration skill's precedence: CAM targets (`--bpa-json`)\n * first, then the BPA CSV. Returns `{ targets, source }`; no targets means the\n * caller falls back to the local scan.\n */\nasync function loadBpaTargets(args) {\n if (args.bpaJson) {\n const targets = readBpaJson(args.bpaJson);\n if (targets.length > 0) return { targets, source: 'cam' };\n }\n if (args.csv) {\n const { getBpaFindings } = require('./bpa-findings-helper.js');\n const log = console.log;\n console.log = () => {};\n let res;\n try {\n res = await getBpaFindings('inputStream', { bpaFilePath: args.csv, collectionsDir: args.collections, limit: null, offset: 0 });\n } finally {\n console.log = log;\n }\n if (res.success && res.targets.length > 0) return { targets: res.targets, source: 'csv' };\n if (!res.success && !res.availablePatterns) console.error(`BPA read failed: ${res.error || res.message}`);\n }\n return { targets: [], source: null };\n}\n\nasync function main() {\n const args = parseCliArgs(process.argv.slice(2));\n if (!args.workspaceRoot) {\n console.error('Usage: node input-stream-runner.js \u003cworkspaceRoot> [--bpa-json \u003ccam-targets.json>] [--csv \u003cbpa.csv>] [--collections \u003cdir>] [--files \u003ca.java,b.java>] [--json]');\n process.exit(1);\n }\n const root = path.resolve(args.workspaceRoot);\n const bpa = args.files ? { targets: [], source: null } : await loadBpaTargets(args);\n const bpaTargets = bpa.targets;\n\n const result = runInputStreamScan(root, { bpaTargets, files: args.files });\n const rel = f => (f ? path.relative(root, f) : f);\n if (args.json) {\n const findings = result.rawFindings.map(r => ({ ...r, file: rel(r.file) }));\n process.stdout.write(`${JSON.stringify({ ok: result.ok, ...(result.error ? { error: result.error } : {}), workspaceRoot: root, source: bpa.source || 'local-scan', stats: result.stats, warnings: result.warnings, findings }, null, 2)}\\n`);\n if (!result.ok) process.exitCode = 2;\n return;\n }\n const s = result.stats;\n console.log(`inputStream scan (${bpa.source ? `BPA ${bpa.source === 'cam' ? 'from CAM' : 'CSV'} first` : 'local — no BPA findings'}) — ${s.filesScanned}${s.filesRequested !== s.filesScanned && args.files ? ` of ${s.filesRequested} requested` : ''} Java files scanned, ${s.actionableFiles} actionable file(s), ${s.sites} stream site(s)`);\n console.log(` fix kinds: decision ${s.fix.decision}, review ${s.fix.review}, auto ${s.fix.auto}`);\n if (s.bpa.rows) {\n console.log(` BPA: ${s.bpa.rows} rows → ${s.bpa.resolvedFiles} workspace file(s); outside the workspace: `\n + `${s.bpa.missingSourceRows} customer, ${s.bpa.thirdPartyRows} third-party, ${s.bpa.platformProvidedRows} platform-provided (${s.bpa.bundleGroups} bundle group(s)); ${s.bpa.unmatchedFiles} unmatched`);\n }\n for (const r of result.rawFindings) {\n if (r.kind === 'source' && !r.informational) {\n console.log(`\\n${rel(r.file)} [${r.severity}]`);\n for (const site of r.sites) console.log(` L${site.line} ${site.categories.join('+')} (${site.fix}) ${site.note}\\n ${site.snippet}`);\n }\n }\n result.warnings.forEach(w => console.log(`\\n⚠️ ${w}`));\n if (!result.ok) {\n console.error(`\\n❌ Incomplete scan — ${result.error}`);\n process.exitCode = 2;\n }\n}\n\nif (require.main === module) {\n main().catch(err => { console.error(err.message); process.exit(1); });\n}\n\nmodule.exports = {\n runInputStreamScan, scanJavaSource, stripCommentsAndStrings, collectJavaFiles, declaredTypes, readBpaJson,\n CATEGORY, JAVA_IO_STREAM_TYPES, PLATFORM_PROVIDED_BUNDLES, PLATFORM_PROVIDED_PACKAGES,\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":63403,"content_sha256":"73c76d09ba8586c874ca3d252505bbe7660ad6d164fc7eb3f13485700e4e1033"},{"filename":"scripts/input-stream-runner.test.js","content":"'use strict';\n\nconst { test } = require('node:test');\nconst assert = require('node:assert');\nconst fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst { execFileSync } = require('child_process');\n\nconst { runInputStreamScan, scanJavaSource, stripCommentsAndStrings } = require('./input-stream-runner.js');\n\nfunction mkworkspace() {\n return fs.mkdtempSync(path.join(os.tmpdir(), 'input-stream-test-'));\n}\nfunction write(root, rel, content) {\n const full = path.join(root, rel);\n fs.mkdirSync(path.dirname(full), { recursive: true });\n fs.writeFileSync(full, content, 'utf8');\n return full;\n}\n\n/** Wrap method bodies in a class with the given imports; returns the site list. */\nfunction sitesOf(body, imports = ['java.io.*', 'org.apache.commons.io.IOUtils']) {\n const src = [\n 'package com.acme;',\n ...imports.map(i => `import ${i};`),\n 'public class T {',\n body,\n '}',\n ].join('\\n');\n return scanJavaSource(src).sites;\n}\nconst only = (sites) => {\n assert.strictEqual(sites.length, 1, `expected one site, got ${JSON.stringify(sites, null, 1)}`);\n return sites[0];\n};\nconst cats = sites => sites.map(s => s.categories.join('+'));\n\n// ── Preparation ──────────────────────────────────────────────────────────────\n\ntest('stripCommentsAndStrings blanks comments/strings/text blocks but keeps offsets and newlines', () => {\n const src = 'a // new FileInputStream(x)\\nb = \"getStream()\"; /* x\\n y */ c = \\'\"\\'; d = \"\"\"\\n new FileInputStream(\\n\"\"\";';\n const out = stripCommentsAndStrings(src);\n assert.strictEqual(out.length, src.length);\n assert.strictEqual(out.split('\\n').length, src.split('\\n').length);\n assert.ok(!/FileInputStream|getStream/.test(out));\n});\n\ntest('stripCommentsAndStrings can keep string contents while dropping comments', () => {\n const out = stripCommentsAndStrings('x(\"java.io.tmpdir\"); // temp\\n', { keepStrings: true });\n assert.ok(out.includes('\"java.io.tmpdir\"'));\n assert.ok(!out.includes('temp\\n') && !/\\/\\/ temp/.test(out));\n});\n\n// ── F: deserialization ───────────────────────────────────────────────────────\n\ntest('F: ObjectInputStream; over request input it is security-critical', () => {\n const sites = sitesOf([\n 'Object a(SlingHttpServletRequest request) throws Exception { ObjectInputStream ois = new ObjectInputStream(request.getInputStream()); return ois.readObject(); }',\n 'Object b(InputStream in) throws Exception { try (ObjectInputStream ois = new ObjectInputStream(in)) { return ois.readObject(); } }',\n ].join('\\n'));\n assert.deepStrictEqual(sites.map(s => s.primary), ['F', 'F']);\n assert.strictEqual(sites[0].severity, 'critical');\n assert.strictEqual(sites[1].severity, 'high');\n assert.ok(/ObjectInputFilter allow-list/.test(sites[1].note), 'suggests an allow-list when the format can\\'t change');\n});\n\ntest('F: an existing ObjectInputFilter allow-list is recognised', () => {\n const s = only(sitesOf('Object a(InputStream in) throws Exception { try (ObjectInputStream o = new ObjectInputStream(in)) { o.setObjectInputFilter(ObjectInputFilter.Config.createFilter(\"java.util.HashSet;java.lang.String;!*\")); return o.readObject(); } }'));\n assert.deepStrictEqual(s.categories, ['F']);\n assert.ok(/filter or allow-list is present/.test(s.note));\n});\n\ntest('F: ObjectInputStream subclasses (any package) are F; an inline getBinary() stream is B', () => {\n const sites = sitesOf([\n 'Object a(Property p, ClassLoader cl) throws Exception { try (InputStream in = p.getBinary().getStream(); DynamicObjectInputStream o = new DynamicObjectInputStream(in, cl)) { return o.readObject(); } }',\n 'Object b(InputStream in) throws Exception { try (ValidatingObjectInputStream v = new ValidatingObjectInputStream(in)) { return v.readObject(); } }',\n ].join('\\n'));\n assert.deepStrictEqual(sites.map(s => `${s.variable}:${s.categories}`), ['in:B', 'o:F', 'v:F']);\n assert.ok(/DynamicObjectInputStream deserialization/.test(sites[1].note));\n assert.ok(/allow-list is present/.test(sites[2].note), 'ValidatingObjectInputStream counts as an allow-list');\n});\n\n// ── E: binary streamed or processed in the JVM ───────────────────────────────\n\ntest('E: DAM binaries read in the JVM (getOriginal().getStream(), a typed Rendition, an asset resource)', () => {\n const sites = sitesOf([\n 'void a(Asset asset) throws Exception { try (InputStream in = asset.getOriginal().getStream()) { use(in); } }',\n 'void b(Rendition r) throws Exception { try (InputStream in = r.getStream()) { use(in); } }',\n 'void c(Resource assetResource) throws Exception { try (InputStream in = assetResource.adaptTo(InputStream.class)) { use(in); } }',\n 'void use(InputStream in) {}',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['E', 'E', 'E']);\n assert.ok(sites.every(s => s.source === 'dam-binary' && s.fix === 'decision'));\n assert.ok(/asset\\.getOriginal\\(\\)\\.getStream\\(\\)/.test(sites[0].note), 'cites Adobe\\'s guideline');\n});\n\ntest('E: binaries streamed to the response — DAM and other repository binaries', () => {\n const sites = sitesOf([\n 'void a(Asset asset, SlingHttpServletResponse response) throws Exception { try (InputStream in = asset.getOriginal().getStream()) { IOUtils.copy(in, response.getOutputStream()); } }',\n 'void b(Resource file, SlingHttpServletResponse response) throws Exception { try (InputStream in = file.adaptTo(InputStream.class)) { IOUtils.copy(in, response.getOutputStream()); } }',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['E', 'E']);\n assert.ok(sites.every(s => /CDN/.test(s.note)));\n});\n\ntest('E: client upload written into DAM hands off to asset-manager and names both tiers', () => {\n const s = only(sitesOf('void b(SlingHttpServletRequest request, AssetManager am) throws Exception { InputStream in = request.getRequestParameter(\"file\").getInputStream(); am.createAsset(\"/content/dam/x\", in, \"image/png\", true); }'));\n assert.deepStrictEqual(s.categories, ['E', 'B']);\n assert.strictEqual(s.source, 'request-upload');\n assert.strictEqual(s.handoff, 'asset-manager');\n assert.ok(/direct binary upload on author/.test(s.note) && /external store/.test(s.note));\n});\n\ntest('E: asset processing in a workflow step points to Asset Compute', () => {\n const s = only(sitesOf('class P implements WorkflowProcess { public void execute(WorkItem item, WorkflowSession s, MetaDataMap m) throws Exception { Rendition r = pick(); try (InputStream in = r.getStream()) { use(in); } } void use(InputStream i) {} Rendition pick() { return null; } }'));\n assert.deepStrictEqual(s.categories, ['E']);\n assert.ok(/Asset Compute/.test(s.note));\n});\n\ntest('E: a class that runs a native process needs redesign, not stream fixes', () => {\n const s = only(sitesOf('void a(File workingDir) throws Exception { new ProcessBuilder(\"ffmpeg\").start(); File out = new File(workingDir, \"o.mp3\"); FileInputStream fis = new FileInputStream(out); use(fis); } void use(InputStream i) {}'));\n assert.deepStrictEqual(s.categories, ['E', 'B']);\n assert.strictEqual(s.fix, 'decision', 'no auto-fix for code that cannot run on AEM as a Cloud Service');\n assert.ok(/native process/.test(s.note));\n});\n\ntest('E: AEM\\'s FFmpeg helpers count as a native process', () => {\n const s = only(sitesOf('void a(File workingDir, ExecutableLocator locator) throws Exception { FFMpegWrapper w = FFMpegWrapper.fromProfile(null, null, workingDir); w.setExecutableLocator(locator); File out = w.transcode(); FileInputStream fis = new FileInputStream(out); use(fis); } void use(InputStream i) {}'));\n assert.ok(s.categories.includes('E'));\n assert.strictEqual(s.fix, 'decision');\n});\n\ntest('E: a stream from a rendition helper (getXmlRenditionInputStream) is a DAM read', () => {\n const s = only(sitesOf('void a(Resource assetResource) throws Exception { InputStream xml = DeckUtils.getXmlRenditionInputStream(assetResource); parse(xml); } void parse(InputStream i) {}'));\n assert.strictEqual(s.source, 'dam-binary');\n assert.ok(s.categories.includes('E'));\n});\n\ntest('a try-with-resources initializer ends at its own expression (a later addRendition is not part of it)', () => {\n const s = only(sitesOf('void a(Asset asset, Executor executor, String url) throws Exception { try (InputStream in = executor.execute(url).asStream()) { asset.addRendition(\"r\", in, \"image/png\"); } }'));\n assert.strictEqual(s.source, 'declared');\n assert.deepStrictEqual(s.categories, ['A']);\n});\n\n// ── D: local filesystem ──────────────────────────────────────────────────────\n\ntest('D: fixed paths are D even under /tmp or next to a \"temp\" comment; scratch files are not', () => {\n const sites = sitesOf([\n 'void a() throws Exception { try (InputStream in = new FileInputStream(\"/opt/data.json\")) { use(in); } }',\n 'void b() throws Exception { try (InputStream in = java.nio.file.Files.newInputStream(java.nio.file.Paths.get(\"/x\"))) { use(in); } }',\n 'void c() throws Exception { // temp workaround until the config moves',\n ' try (InputStream in = new FileInputStream(\"/opt/acme/config.json\")) { use(in); } }',\n 'void d() throws Exception { try (InputStream in = new FileInputStream(\"/tmp/acme/shared-cache.json\")) { use(in); } }',\n 'void e() throws Exception { File t = File.createTempFile(\"x\", \".bin\"); try (InputStream in = new FileInputStream(t)) { use(in); } }',\n 'void f(Asset asset, File workingDir) throws Exception { File out = transcode(workingDir); try (InputStream in = new FileInputStream(out)) { use(in); } finally { java.nio.file.Files.delete(out.toPath()); } }',\n 'void use(InputStream in) {}',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['D', 'D', 'D', 'D', 'A', 'A']);\n assert.strictEqual(sites[0].fix, 'decision');\n assert.ok(/repository \\(author\\) or an external store \\(publish\\)/.test(sites[0].note));\n});\n\ntest('D: the note points at local-filesystem writes in the same file', () => {\n const sites = sitesOf([\n 'void w(byte[] b) throws Exception {',\n ' java.nio.file.Files.write(java.nio.file.Paths.get(\"/opt/acme/cache.bin\"), b); }',\n 'String r() throws Exception { try (InputStream in = new FileInputStream(\"/opt/acme/cache.bin\")) { return IOUtils.toString(in, \"UTF-8\"); } }',\n ].join('\\n'));\n const s = only(sites);\n assert.deepStrictEqual(s.categories, ['D']);\n assert.ok(/writes in this file: L6/.test(s.note), s.note);\n});\n\n// ── C: unbounded in-memory reads ─────────────────────────────────────────────\n\ntest('C: unbounded reads of repository binaries, readers and request bodies; real read limits are not C', () => {\n const sites = sitesOf([\n 'byte[] a(Resource res) throws Exception { try (InputStream in = res.adaptTo(InputStream.class)) { return IOUtils.toByteArray(in); } }',\n 'String b(Resource res) throws Exception { try (BufferedReader br = new BufferedReader(new InputStreamReader(res.adaptTo(InputStream.class)))) { return br.lines().reduce(\"\", String::concat); } }',\n 'String c(SlingHttpServletRequest request) throws Exception { return IOUtils.toString(request.getInputStream(), \"UTF-8\"); }',\n 'String d() throws Exception { try (InputStream in = getClass().getResourceAsStream(\"/small.json\")) { return IOUtils.toString(in, \"UTF-8\"); } }',\n 'byte[] e(SlingHttpServletRequest request) throws Exception { byte[] b = request.getInputStream().readNBytes(1025); return b; }',\n 'byte[] f(Property p) throws Exception { Binary bin = p.getBinary(); try (InputStream in = bin.getStream()) { if (bin.getSize() > 1024) throw new IOException(); return IOUtils.toByteArray(in); } finally { bin.dispose(); } }',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['C', 'C', 'C', 'A', 'A', 'A']);\n});\n\ntest('C: a Content-Length check is not a read limit (it is -1 for chunked requests)', () => {\n const s = only(sitesOf('String a(SlingHttpServletRequest request) throws Exception { if (request.getContentLength() > 1024) throw new IllegalArgumentException(); return IOUtils.toString(request.getInputStream(), \"UTF-8\"); }'));\n assert.deepStrictEqual(s.categories, ['C']);\n assert.ok(/-1 for chunked requests/.test(s.note));\n});\n\ntest('C: BoundedInputStream limits the read, but the note warns that it truncates silently', () => {\n const s = only(sitesOf('byte[] a(SlingHttpServletRequest request) throws Exception { try (InputStream in = new org.apache.commons.io.input.BoundedInputStream(request.getInputStream(), 1024)) { return IOUtils.toByteArray(in); } }'));\n assert.deepStrictEqual(s.categories, ['A']);\n assert.ok(/silently truncated/.test(s.note));\n});\n\ntest('C: a read limit or size check on another stream does not bound this one', () => {\n const sites = sitesOf([\n 'String a(SlingHttpServletRequest request, InputStream other) throws Exception { try (InputStream lim = new org.apache.commons.io.input.BoundedInputStream(other, 10)) { use(lim); } return IOUtils.toString(request.getInputStream(), \"UTF-8\"); }',\n 'byte[] b(Resource res, Rendition other) throws Exception { long n = other.getSize(); try (InputStream in = res.adaptTo(InputStream.class)) { return IOUtils.toByteArray(in); } }',\n 'void use(InputStream i) {}',\n ].join('\\n'));\n assert.deepStrictEqual(sites.map(s => `${s.variable || '-'}:${s.categories}`), ['lim:A', '-:C', 'in:C']);\n assert.ok(!/silently truncated/.test(sites[1].note), 'the BoundedInputStream note belongs to the bounded stream only');\n});\n\ntest('C: a size check or limit on the stream\\'s own source counts', () => {\n const sites = sitesOf([\n 'byte[] a(Property p) throws Exception { Binary bin = p.getBinary(); try (InputStream in = bin.getStream()) { if (bin.getSize() > 1024) throw new IOException(); return IOUtils.toByteArray(in); } finally { bin.dispose(); } }',\n 'byte[] b(Resource res) throws Exception { try (InputStream in = res.adaptTo(InputStream.class)) { return in.readNBytes(1025); } }',\n 'String c(File f) throws Exception { File t = File.createTempFile(\"x\", \"y\"); if (f.length() > 10) throw new IOException(); try (InputStream in = new FileInputStream(f)) { return IOUtils.toString(in, \"UTF-8\"); } }',\n 'String d(String path) throws Exception { File t = File.createTempFile(\"x\", \"y\"); if (path.length() > 10) throw new IOException(); try (InputStream in = new FileInputStream(path)) { return IOUtils.toString(in, \"UTF-8\"); } }',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['A', 'A', 'A', 'C'], 'String.length() is not a size check');\n});\n\ntest('C: an inline HttpEntity.getContent() read is detected (no stream type named in source); the response owns the stream', () => {\n const s = only(sitesOf('String a(org.apache.http.HttpResponse response) throws Exception { return IOUtils.toString(response.getEntity().getContent(), \"UTF-8\"); }', ['org.apache.commons.io.IOUtils']));\n assert.deepStrictEqual(s.categories, ['C']);\n assert.strictEqual(s.source, 'remote');\n});\n\ntest('closing an HTTP entity\\'s content inline is not flagged as a leak', () => {\n const sites = sitesOf('void close(org.apache.http.HttpResponse connection) throws Exception { if (connection.getEntity().getContent() != null) { connection.getEntity().getContent().close(); } }', ['org.apache.commons.io.IOUtils']);\n assert.ok(sites.every(s => !s.categories.includes('B')), JSON.stringify(sites));\n});\n\n// ── B: unclosed streams and undisposed binaries ──────────────────────────────\n\ntest('B: a stream that is opened and never closed', () => {\n const s = only(sitesOf('Properties p(){ InputStream in = getClass().getResourceAsStream(\"/x\"); Properties p = new Properties(); p.load(in); return p; }'));\n assert.deepStrictEqual(s.categories, ['B']);\n assert.strictEqual(s.fix, 'auto');\n assert.strictEqual(s.variable, 'in');\n});\n\ntest('A: try-with-resources, manual close in finally, returned stream, ByteArrayInputStream', () => {\n const sites = sitesOf([\n 'void a(Resource res) throws Exception { try (InputStream in = res.adaptTo(InputStream.class)) { use(in); } }',\n 'void b(Resource res) throws Exception { InputStream in = null; try { in = res.adaptTo(InputStream.class); use(in); } finally { IOUtils.closeQuietly(in); } }',\n 'InputStream c(Resource res) { return res.adaptTo(InputStream.class); }',\n 'InputStream d(Resource res) { InputStream in = res.adaptTo(InputStream.class); return in; }',\n 'InputStream e(byte[] b) { return new ByteArrayInputStream(b); }',\n 'void use(InputStream in) {}',\n ].join('\\n'));\n assert.strictEqual(sites.length, 5);\n for (const s of sites) assert.deepStrictEqual(s.categories, ['A'], `L${s.line}: ${s.note}`);\n});\n\ntest('B is still flagged when only data derived from the stream is returned', () => {\n const s = only(sitesOf('String a(Resource res) throws Exception { InputStream is = res.adaptTo(InputStream.class); return IOUtils.toString(is, \"UTF-8\"); }'));\n assert.deepStrictEqual(s.categories, ['C', 'B']);\n assert.strictEqual(s.fix, 'review', 'most demanding fix kind wins');\n});\n\ntest('a stream assigned to a field without this. is owned by the object, not B', () => {\n const s = only(sitesOf('InputStream held;\\nvoid open(Resource res) { held = res.adaptTo(InputStream.class); }\\nvoid close() throws IOException { held.close(); }'));\n assert.deepStrictEqual(s.categories, ['A']);\n assert.ok(/kept on a field/.test(s.note));\n});\n\ntest('methods of a nested class are separate scopes (a close elsewhere does not hide a leak)', () => {\n const s = only(sitesOf('static class Inner { void leak(Resource res) throws Exception { InputStream in = res.adaptTo(InputStream.class); use(in); } void other(InputStream in) throws Exception { in.close(); } void use(InputStream i) {} }'));\n assert.deepStrictEqual(s.categories, ['B']);\n});\n\ntest('JCR Binary from getBinary() must be disposed; disposing it in finally is enough', () => {\n const sites = sitesOf([\n 'void a(Property p) throws Exception { try (InputStream in = p.getBinary().getStream()) { use(in); } }',\n 'void b(Property p) throws Exception { Binary bin = p.getBinary(); try (InputStream in = bin.getStream()) { use(in); } finally { bin.dispose(); } }',\n 'void c(Property p) throws Exception { Binary bin = p.getBinary(); try (InputStream in = bin.getStream()) { use(in); } }',\n 'void use(InputStream in) {}',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['B', 'A', 'B']);\n assert.ok(/dispose\\(\\)/.test(sites[0].note));\n});\n\ntest('archive entries (ZipFile.getInputStream(entry)) are not client uploads', () => {\n const s = only(sitesOf('void a(java.util.zip.ZipFile zipFile, java.util.zip.ZipEntry e, AssetManager am) throws Exception { try (InputStream in = zipFile.getInputStream(e)) { am.createAsset(\"/content/dam/x\", in, \"image/png\", true); } }'));\n assert.strictEqual(s.source, 'archive');\n assert.ok(!s.categories.includes('E'));\n});\n\ntest('wrappers over a caller-owned stream are not B (closing belongs to the opener)', () => {\n const s = only(sitesOf('int a(InputStream in) throws Exception { BufferedInputStream b = new BufferedInputStream(in); return b.read(); }'));\n assert.deepStrictEqual(s.categories, ['A']);\n});\n\ntest('closing a wrapper counts as closing the stream it wraps', () => {\n const sites = sitesOf('int a(Resource res) throws Exception { InputStream raw = res.adaptTo(InputStream.class); BufferedInputStream b = new BufferedInputStream(raw); try { return b.read(); } finally { b.close(); } }');\n assert.deepStrictEqual(sites.map(s => `${s.variable}:${s.categories}`), ['raw:A', 'b:A']);\n});\n\ntest('multiple try-with-resources resources are separate sites, each closed', () => {\n const sites = sitesOf('void a(Resource x, Resource y) throws Exception { try (InputStream a = x.adaptTo(InputStream.class); InputStream b = y.adaptTo(InputStream.class)) { use(a); } }\\nvoid use(InputStream in) {}');\n assert.deepStrictEqual(sites.map(s => `${s.variable}:${s.categories}`), ['a:A', 'b:A']);\n});\n\ntest('each declarator in `T a = …, b = …` is its own site: a leak of b is not hidden by closing a', () => {\n const sites = sitesOf([\n 'void a(Resource x, Resource y) throws Exception { InputStream a = x.adaptTo(InputStream.class), b = y.adaptTo(InputStream.class); use(a, b); a.close(); }',\n 'void use(InputStream i, InputStream j) {}',\n ].join('\\n'));\n assert.deepStrictEqual(sites.map(s => `${s.variable}:${s.categories}`), ['a:A', 'b:B']);\n});\n\ntest('commas inside generics, calls and constructors do not split a statement', () => {\n const sites = sitesOf([\n 'void a(Resource x) { Map\u003cString, InputStream> m = Collections.singletonMap(\"k\", x.adaptTo(InputStream.class)); }',\n 'int b(Resource x) throws Exception { try (InputStream s = new BufferedInputStream(x.adaptTo(InputStream.class), 8192)) { return s.read(); } }',\n ].join('\\n'), ['java.io.*', 'java.util.*']);\n assert.strictEqual(sites.length, 2, JSON.stringify(sites.map(s => s.snippet)));\n assert.strictEqual(sites[1].variable, 's');\n});\n\ntest('a stream passed straight into a consumer is inline: the result variable is not the stream', () => {\n const s = only(sitesOf('List\u003cString> a(Resource map) throws Exception { List\u003cString> lines = new ArrayList\u003c>(); lines = IOUtils.readLines(map.adaptTo(InputStream.class), \"UTF-8\"); return lines; }'));\n assert.deepStrictEqual(s.categories, ['C', 'B']);\n assert.strictEqual(s.variable, null);\n});\n\ntest('returning data derived from an inline stream is still a leak; returning the (wrapped) stream is not', () => {\n const sites = sitesOf([\n 'String a(Resource res) throws Exception { return IOUtils.toString(res.adaptTo(InputStream.class), \"UTF-8\"); }',\n 'InputStream b(Resource res) { return new BufferedInputStream(res.adaptTo(InputStream.class)); }',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['C+B', 'A']);\n});\n\ntest('handing a fresh stream to a same-file helper that closes it is not B; a throwaway null-check call is', () => {\n const sites = sitesOf([\n 'Set\u003cString> a(RequestParameter data) throws Exception {',\n ' Set\u003cString> paths = new HashSet\u003c>();',\n ' if (data != null && data.getInputStream() != null) {',\n ' paths.addAll(read(data.getInputStream(), \"UTF-8\"));',\n ' }',\n ' return paths;',\n '}',\n 'private static Set\u003cString> read(InputStream is, String enc) throws IOException {',\n ' try (BufferedReader br = new BufferedReader(new InputStreamReader(is, enc))) { return null; }',\n '}',\n ].join('\\n'));\n assert.deepStrictEqual(sites.map(s => `${s.line}:${s.categories}`), ['7:B', '8:A', '13:A']);\n});\n\ntest('a lambda that produces the stream hands it off (no B at the lambda)', () => {\n const s = only(sitesOf('void a(Resource m) { Optional\u003cInputStream> op = Optional.ofNullable(m).map(rm -> rm.adaptTo(InputStream.class)); }'));\n assert.deepStrictEqual(s.categories, ['A']);\n});\n\ntest('this(…) delegating to a constructor that keeps the stream on a field is not B', () => {\n const src = [\n 'package com.acme;',\n 'import java.io.InputStream;',\n 'public class Sheet {',\n ' private InputStream inputStream;',\n ' public Sheet(InputStream file) { inputStream = file; }',\n ' public Sheet(RequestParameter file) throws Exception { this(file.getInputStream()); }',\n '}',\n ].join('\\n');\n const sites = scanJavaSource(src).sites;\n assert.deepStrictEqual(sites.map(s => `${s.line}:${s.categories}`), ['6:A']);\n});\n\ntest('an inline stream handed to an unknown callee is B, and the note names the callee to confirm', () => {\n const s = only(sitesOf('void a(Resource res) throws Exception { Layer layer = new Layer(res.adaptTo(InputStream.class)); }'));\n assert.deepStrictEqual(s.categories, ['B']);\n assert.ok(/new Layer\\(…\\)/.test(s.note), s.note);\n});\n\ntest('an inline stream handed to a setter, map or JCR createBinary is not B', () => {\n const sites = sitesOf([\n 'void a(Object target, java.lang.reflect.Field field, RequestParameter val) throws Exception { FieldUtils.writeField(field, target, val.getInputStream(), true); }',\n 'void b(Upload upload, Resource res) { upload.setInputStream(res.adaptTo(InputStream.class)); }',\n 'void c(Session session, Resource res) throws Exception { Binary bin = session.getValueFactory().createBinary(res.adaptTo(InputStream.class)); bin.dispose(); }',\n ].join('\\n'));\n assert.deepStrictEqual(cats(sites), ['A', 'A', 'A']);\n});\n\ntest('aliases, comments and strings produce no sites', () => {\n const sites = sitesOf([\n 'void a(InputStream in) { InputStream copy = in; }',\n 'void b() { // InputStream x = new FileInputStream(\"y\");',\n ' String s = \"rendition.getStream()\"; /* new FileInputStream(\"z\") */ }',\n ].join('\\n'));\n assert.deepStrictEqual(sites, []);\n});\n\ntest('only java.io stream types count — another package\\'s InputStream is ignored', () => {\n const r = scanJavaSource('package a;\\nimport org.omg.CORBA.portable.InputStream;\\nclass C { void r(InputStream in) {} InputStream x = make(); }');\n assert.deepStrictEqual(r.sites, []);\n assert.strictEqual(r.typeRefLine, null);\n});\n\n// ── Workspace scan + BPA merge ───────────────────────────────────────────────\n\nfunction fixtureWorkspace() {\n const root = mkworkspace();\n write(root, 'core/src/main/java/com/acme/core/Leaky.java', [\n 'package com.acme.core;',\n 'import java.io.InputStream;',\n 'public class Leaky {',\n ' void run(Resource res) { InputStream in = res.adaptTo(InputStream.class); use(in); }',\n ' void use(InputStream in) {}',\n ' static class Inner { }',\n '}',\n ].join('\\n'));\n write(root, 'core/src/main/java/com/acme/core/Danger.java', [\n 'package com.acme.core;',\n 'import java.io.*;',\n 'public class Danger {',\n ' Object run(SlingHttpServletRequest request) throws Exception {',\n ' ObjectInputStream ois = new ObjectInputStream(request.getInputStream());',\n ' return ois.readObject();',\n ' }',\n '}',\n ].join('\\n'));\n write(root, 'core/src/main/java/com/acme/core/PassThrough.java', 'package com.acme.core;\\nimport java.io.InputStream;\\npublic class PassThrough { int size(InputStream in) { return 0; } }\\n');\n write(root, 'core/src/main/java/com/acme/core/Clean.java', 'package com.acme.core;\\npublic class Clean { }\\n');\n write(root, 'core/src/test/java/com/acme/core/LeakyTest.java', 'package com.acme.core;\\nimport java.io.*;\\nclass LeakyTest { void t() throws Exception { new FileInputStream(\"x\"); } }\\n');\n write(root, 'core/target/classes/Gen.java', 'package g;\\nimport java.io.*;\\nclass Gen { void t() throws Exception { new FileInputStream(\"x\"); } }\\n');\n return root;\n}\n\ntest('one finding per file, ordered by risk; benign files are informational; tests and target/ are skipped', () => {\n const root = fixtureWorkspace();\n const r = runInputStreamScan(root);\n assert.ok(r.ok);\n const files = r.rawFindings.map(f => `${path.basename(f.file)}:${f.severity}${f.informational ? ':info' : ''}`);\n assert.deepStrictEqual(files, ['Danger.java:critical', 'Leaky.java:medium', 'PassThrough.java:info:info']);\n assert.strictEqual(r.stats.actionableFiles, 2);\n assert.deepStrictEqual(r.stats.fix, { auto: 1, review: 0, decision: 1 });\n assert.ok(r.findings[0].location.endsWith('Danger.java:5'));\n});\n\ntest('BPA merge: nested classes resolve to their file, unmatched classes get a neutral hint, third-party rows collapse per bundle', () => {\n const root = fixtureWorkspace();\n const r = runInputStreamScan(root, {\n bpaTargets: [\n { className: 'com.acme.core.Leaky$Inner', identifier: 'java.io.InputStream', bundle: 'com.acme.core' },\n { className: 'com.acme.core.Clean', identifier: 'java.io.FileInputStream', bundle: 'com.acme.core' },\n { className: 'acscommons.com.google.common.io.ByteSource', identifier: 'java.io.InputStream', bundle: 'com.adobe.acs.acs-aem-commons-bundle' },\n { className: 'acscommons.com.google.common.io.ByteSource$1', identifier: 'java.io.InputStream', bundle: 'com.adobe.acs.acs-aem-commons-bundle' },\n { className: 'acscommons.com.google.common.io.Files', identifier: 'java.io.FileInputStream', bundle: 'com.adobe.acs.acs-aem-commons-bundle' },\n { className: 'org.apache.xml.Thing', identifier: 'java.io.InputStream' },\n ],\n });\n const leaky = r.rawFindings.find(f => f.file && f.file.endsWith('Leaky.java'));\n assert.deepStrictEqual(leaky.bpa.classes, ['com.acme.core.Leaky$Inner']);\n\n const unmatched = r.rawFindings.find(f => f.kind === 'bpa-unmatched');\n assert.ok(unmatched.file.endsWith('Clean.java'));\n assert.ok(/compiled classes/.test(unmatched.hint));\n assert.ok(/Danger\\.java|Leaky\\.java/.test(unmatched.hint), 'points at same-package files with stream usage');\n\n const groups = r.rawFindings.filter(f => f.file === null);\n assert.deepStrictEqual(groups.map(g => `${g.kind}:${g.group}:${g.classCount}/${g.rowCount}`), [\n 'not-in-workspace:bundle com.adobe.acs.acs-aem-commons-bundle:2/3',\n 'not-in-workspace:package org.apache.xml:1/1',\n ]);\n assert.ok(groups.every(g => g.informational));\n assert.deepStrictEqual(r.stats.bpa, {\n rows: 6, resolvedFiles: 2, unmatchedFiles: 1,\n missingSourceRows: 0, thirdPartyRows: 4, platformProvidedRows: 0, bundleGroups: 2,\n });\n});\n\ntest('BPA merge: three buckets — customer code elsewhere, third-party, platform-provided (AEM Forms add-on)', () => {\n const root = fixtureWorkspace();\n const r = runInputStreamScan(root, {\n bpaTargets: [\n { className: 'com.acme.core.Leaky', identifier: 'java.io.InputStream', bundle: 'com.acme.core' },\n // Same bundle as a workspace class, but its source isn't here.\n { className: 'com.acme.core.other.Gone', identifier: 'java.io.FileInputStream', bundle: 'com.acme.core' },\n // A different bundle in the customer's namespace (another repository).\n { className: 'com.acme.shared.util.Csv', identifier: 'java.io.InputStreamReader', bundle: 'com.acme.shared.core' },\n { className: 'com.adobe.acs.commons.util.Thing', identifier: 'java.io.InputStream', bundle: 'com.adobe.acs.acs-aem-commons-bundle' },\n { className: 'com.adobe.fd.signatures.Sig', identifier: 'java.io.InputStream', bundle: 'adobe-aemfd-signatures' },\n { className: 'com.adobe.xfa.Doc', identifier: 'java.io.ObjectInputStream', bundle: 'xfa4j' },\n ],\n });\n const groups = r.rawFindings.filter(f => f.file === null);\n assert.deepStrictEqual(groups.map(g => `${g.kind}:${g.bundle}`), [\n 'missing-source:com.acme.core',\n 'missing-source:com.acme.shared.core',\n 'not-in-workspace:com.adobe.acs.acs-aem-commons-bundle',\n 'platform-provided:adobe-aemfd-signatures',\n 'platform-provided:xfa4j',\n ]);\n const detail = bundle => r.findings.find(f => f.location === `bundle ${bundle}`).detail;\n assert.ok(/Forms add-on/.test(detail('adobe-aemfd-signatures')));\n assert.ok(/upgrade/.test(detail('com.adobe.acs.acs-aem-commons-bundle')));\n assert.ok(/repository that builds it/.test(detail('com.acme.shared.core')));\n assert.ok(r.warnings.some(w => /bundle com\\.acme\\.shared\\.core looks like customer code but has no source/.test(w)));\n assert.ok(r.warnings.some(w => /com\\.acme\\.core\\.other\\.Gone/.test(w)));\n assert.deepStrictEqual(\n [r.stats.bpa.missingSourceRows, r.stats.bpa.thirdPartyRows, r.stats.bpa.platformProvidedRows, r.stats.bpa.bundleGroups],\n [2, 1, 2, 5]\n );\n});\n\ntest('a BPA report with no workspace matches warns instead of silently reporting clean', () => {\n const root = fixtureWorkspace();\n const r = runInputStreamScan(root, { bpaTargets: [{ className: 'x.y.Z', identifier: 'java.io.InputStream', bundle: 'vendor' }] });\n assert.ok(r.warnings.some(w => /none of the 1 BPA rows resolve/.test(w)));\n});\n\ntest('precedence: with BPA rows only the reported files are in scope; without them every file is scanned', () => {\n const root = fixtureWorkspace();\n const bpa = runInputStreamScan(root, { bpaTargets: [{ className: 'com.acme.core.Leaky', identifier: 'java.io.InputStream' }] });\n assert.strictEqual(bpa.stats.mode, 'bpa');\n assert.deepStrictEqual(bpa.rawFindings.map(f => path.basename(f.file)), ['Leaky.java'], 'Danger.java uses streams but is not in the BPA report');\n assert.ok(bpa.stats.filesScanned \u003c bpa.stats.filesIndexed, 'only the reported files are scanned');\n\n const local = runInputStreamScan(root);\n assert.strictEqual(local.stats.mode, 'local');\n assert.deepStrictEqual(local.rawFindings.map(f => path.basename(f.file)), ['Danger.java', 'Leaky.java', 'PassThrough.java']);\n});\n\ntest('rows without a bundle name (CAM data) group by package; AEM Forms packages are platform-provided', () => {\n const root = fixtureWorkspace();\n const r = runInputStreamScan(root, {\n bpaTargets: [\n { className: 'com.adobe.fd.signatures.SigUtil', identifier: 'java.io.InputStream' },\n { className: 'com.adobe.xfa.Document', identifier: 'java.io.InputStream' },\n { className: 'com.google.common.io.ByteSource', identifier: 'java.io.InputStream' },\n { className: 'com.acme.shared.Csv', identifier: 'java.io.InputStream' },\n ],\n });\n assert.deepStrictEqual(r.rawFindings.filter(f => f.file === null).map(g => `${g.kind}:${g.group}`), [\n 'missing-source:package com.acme.shared',\n 'not-in-workspace:package com.google.common',\n 'platform-provided:package com.adobe.fd',\n 'platform-provided:package com.adobe.xfa',\n ]);\n});\n\ntest('CLI: CAM targets (--bpa-json) come before the CSV, the CSV before the local scan', () => {\n const root = fixtureWorkspace();\n const runner = path.join(__dirname, 'input-stream-runner.js');\n const csv = write(root, 'bpa.csv', [\n 'code,type,subtype,importance,identifier,other,message,moreInfo,context',\n 'DG,development.guideline,java.io.inputstream,ADVISORY,com.acme.core.Danger,java.io.ObjectInputStream,The com.acme.core.Danger class in the com.acme.core bundle uses java.io.ObjectInputStream.,x,ctx',\n ].join('\\n') + '\\n');\n const cam = write(root, 'cam.json', JSON.stringify({ inputStream: [{ pattern: 'inputStream', className: 'com.acme.core.Leaky', identifier: 'java.io.InputStream' }] }));\n const empty = write(root, 'empty.json', JSON.stringify({ inputStream: [] }));\n const run = (...extra) => JSON.parse(execFileSync(process.execPath,\n [runner, root, '--json', '--collections', path.join(root, `uc-${extra.length}-${Math.random()}`), ...extra], { encoding: 'utf8' }));\n const files = out => out.findings.filter(f => f.kind === 'source').map(f => path.basename(f.file));\n\n const camFirst = run('--bpa-json', cam, '--csv', csv);\n assert.strictEqual(camFirst.source, 'cam');\n assert.deepStrictEqual(files(camFirst), ['Leaky.java']);\n\n const csvNext = run('--bpa-json', empty, '--csv', csv);\n assert.strictEqual(csvNext.source, 'csv');\n assert.deepStrictEqual(files(csvNext), ['Danger.java']);\n\n const local = run();\n assert.strictEqual(local.source, 'local-scan');\n assert.deepStrictEqual(files(local), ['Danger.java', 'Leaky.java', 'PassThrough.java']);\n});\n\ntest('--files mode re-scans only the given files (apply-time verification)', () => {\n const root = fixtureWorkspace();\n const leaky = path.join(root, 'core/src/main/java/com/acme/core/Leaky.java');\n const before = runInputStreamScan(root, { files: ['core/src/main/java/com/acme/core/Leaky.java'] });\n assert.deepStrictEqual(before.rawFindings.map(f => path.basename(f.file)), ['Leaky.java']);\n assert.strictEqual(before.stats.fix.auto, 1);\n\n fs.writeFileSync(leaky, fs.readFileSync(leaky, 'utf8').replace(\n 'InputStream in = res.adaptTo(InputStream.class); use(in);',\n 'try (InputStream in = res.adaptTo(InputStream.class)) { use(in); }'\n ));\n const after = runInputStreamScan(root, { files: [leaky] });\n assert.strictEqual(after.stats.fix.auto, 0, 'the B site is gone once try-with-resources is applied');\n assert.strictEqual(after.stats.actionableFiles, 0);\n});\n\ntest('CLI --json emits workspace-relative findings and stats', () => {\n const root = fixtureWorkspace();\n const out = execFileSync(process.execPath, [path.join(__dirname, 'input-stream-runner.js'), root, '--json'], { encoding: 'utf8' });\n const json = JSON.parse(out);\n assert.strictEqual(json.stats.actionableFiles, 2);\n assert.ok(json.findings.every(f => !f.file || !path.isAbsolute(f.file)));\n});\n\ntest('packages named build, target, out or dist inside src/main/java are scanned; module-level target/ is not', () => {\n const root = fixtureWorkspace();\n for (const pkg of ['build', 'target', 'out', 'dist']) {\n write(root, `core/src/main/java/com/acme/${pkg}/Tool.java`, `package com.acme.${pkg};\\nimport java.io.InputStream;\\npublic class Tool { void a(Resource r) { InputStream in = r.adaptTo(InputStream.class); use(in); } void use(InputStream i) {} }\\n`);\n }\n const local = runInputStreamScan(root);\n const files = local.rawFindings.filter(f => f.kind === 'source').map(f => path.relative(root, f.file));\n for (const pkg of ['build', 'target', 'out', 'dist']) {\n assert.ok(files.includes(`core/src/main/java/com/acme/${pkg}/Tool.java`), `${pkg} package scanned`);\n }\n assert.ok(!files.some(f => f.startsWith('core/target/')), 'core/target/ is still skipped');\n\n const bpa = runInputStreamScan(root, { bpaTargets: [{ className: 'com.acme.build.Tool', identifier: 'java.io.InputStream' }] });\n assert.deepStrictEqual(bpa.rawFindings.map(f => f.kind), ['source'], 'the BPA class resolves to its file, not to \"outside the workspace\"');\n});\n\ntest('CLI: --csv rebuilds a cached collection that predates inputStream instead of falling back to the local scan', () => {\n const root = fixtureWorkspace();\n const runner = path.join(__dirname, 'input-stream-runner.js');\n const csv = write(root, 'bpa.csv', [\n 'code,type,subtype,importance,identifier,other,message,moreInfo,context',\n 'DG,development.guideline,java.io.inputstream,ADVISORY,com.acme.core.Leaky,java.io.InputStream,The com.acme.core.Leaky class in the com.acme.core bundle uses java.io.InputStream.,x,ctx',\n 'DG,development.guideline,sling.commons.scheduler,MAJOR,com.acme.core.Job,\\\\N,The com.acme.core.Job class uses the scheduler.,x,ctx',\n ].join('\\n') + '\\n');\n const collections = path.join(root, 'uc');\n // A cache written by a parser from before inputStream support: no inputStream data, no parsedSubtypes.\n write(root, 'uc/unified-collection.json', JSON.stringify({\n subtypes: { sling_commons_scheduler: { org_apache_sling_commons_scheduler: ['com.acme.core.Job'] } },\n meta: { timestamp: '2026-01-01T00:00:00.000Z', source: 'local-bpa-parser', totalFindings: 1, subtypeCount: 1 },\n }));\n const out = JSON.parse(execFileSync(process.execPath, [runner, root, '--csv', csv, '--collections', collections, '--json'], { encoding: 'utf8' }));\n assert.strictEqual(out.source, 'csv');\n assert.deepStrictEqual(out.findings.filter(f => f.kind === 'source').map(f => path.basename(f.file)), ['Leaky.java']);\n});\n\n// ── Review follow-ups: incomplete --files scans and file-wide native context ─\n\ntest('--files: a missing or unreadable path fails verification, is named, and is not counted as scanned', () => {\n const root = fixtureWorkspace();\n const good = 'core/src/main/java/com/acme/core/Leaky.java';\n const r = runInputStreamScan(root, {\n files: [good, 'core/src/main/java/com/acme/core/Typo.java', 'core/src/main/java'],\n });\n assert.strictEqual(r.ok, false, 'a rescan that skipped a requested file must not pass as clean');\n assert.match(r.error, /2 of 3 requested file\\(s\\) could not be scanned/);\n assert.match(r.error, /Typo\\.java \\(not found\\)/);\n assert.match(r.error, /core\\/src\\/main\\/java \\(unreadable \\(EISDIR\\)\\)/);\n assert.strictEqual(r.stats.filesRequested, 3);\n assert.strictEqual(r.stats.filesScanned, 1, 'only the file that was read counts as scanned');\n assert.deepStrictEqual(r.stats.skippedFiles.map(f => path.basename(f.file)), ['Typo.java', 'java']);\n assert.ok(r.warnings.some(w => /Typo\\.java was not scanned — not found/.test(w)));\n assert.deepStrictEqual(r.rawFindings.map(f => path.basename(f.file)), ['Leaky.java'], 'the readable file is still reported');\n});\n\ntest('--files: every requested file readable keeps verification ok', () => {\n const root = fixtureWorkspace();\n const r = runInputStreamScan(root, { files: ['core/src/main/java/com/acme/core/Leaky.java'] });\n assert.strictEqual(r.ok, true);\n assert.strictEqual(r.stats.filesRequested, 1);\n assert.deepStrictEqual(r.stats.skippedFiles, []);\n});\n\ntest('CLI --files with a mistyped path exits non-zero and reports ok:false in the JSON', () => {\n const root = fixtureWorkspace();\n const runner = path.join(__dirname, 'input-stream-runner.js');\n const run = (args) => {\n try {\n return { code: 0, out: execFileSync(process.execPath, [runner, root, ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }) };\n } catch (err) {\n return { code: err.status, out: err.stdout.toString(), err: err.stderr.toString() };\n }\n };\n const bad = run(['--files', 'core/src/main/java/com/acme/core/Leaky.java,core/src/main/java/com/acme/core/Typo.java', '--json']);\n assert.strictEqual(bad.code, 2);\n const json = JSON.parse(bad.out);\n assert.strictEqual(json.ok, false);\n assert.match(json.error, /Typo\\.java \\(not found\\)/);\n assert.deepStrictEqual(json.stats.skippedFiles.map(f => path.basename(f.file)), ['Typo.java']);\n\n const text = run(['--files', 'core/src/main/java/com/acme/core/Typo.java']);\n assert.strictEqual(text.code, 2);\n assert.match(text.err, /Incomplete scan/);\n\n assert.strictEqual(run(['--files', 'core/src/main/java/com/acme/core/Leaky.java', '--json']).code, 0);\n});\n\ntest('a native process marks only the streams in its own method as E; elsewhere in the file it is a file-level note', () => {\n const sites = sitesOf([\n 'void run(File workingDir) throws Exception { new ProcessBuilder(\"ffmpeg\").start(); File out = new File(workingDir, \"o.mp3\"); FileInputStream fis = new FileInputStream(out); use(fis); }',\n 'InputStream mem(byte[] b) { return new ByteArrayInputStream(b); }',\n 'void cfg(Resource r) throws Exception { try (InputStream in = r.adaptTo(InputStream.class)) { use(in); } }',\n 'void use(InputStream i) {}',\n ].join('\\n'));\n assert.deepStrictEqual(sites.map(s => `${s.source}:${s.categories.join('+')}`), ['file:E+B', 'byte-array:A', 'repo-binary:A']);\n assert.ok(/this method runs a native process/.test(sites[0].note));\n assert.ok(sites.slice(1).every(s => s.fix === 'none' && s.severity === 'info'), 'unrelated sites keep their own severity and fix kind');\n});\n\ntest('native process lines are reported at file level and do not inflate the decision counts', () => {\n const root = mkworkspace();\n write(root, 'core/src/main/java/com/acme/Mixed.java', [\n 'package com.acme;',\n 'import java.io.*;',\n 'public class Mixed {',\n ' void transcode() throws Exception { new ProcessBuilder(\"ffmpeg\").start(); }',\n ' InputStream mem(byte[] b) { return new ByteArrayInputStream(b); }',\n ' void load(Resource res) throws Exception { InputStream in = res.adaptTo(InputStream.class); use(in); }',\n ' void use(InputStream i) {}',\n '}',\n ].join('\\n'));\n const r = runInputStreamScan(root);\n const f = r.rawFindings.find(x => x.kind === 'source');\n assert.deepStrictEqual(f.nativeProcessLines, [4]);\n assert.ok(/starts a native process \\(L4\\)/.test(r.findings[0].detail));\n assert.deepStrictEqual(f.sites.map(s => s.categories.join('+')), ['A', 'B'], 'the ByteArrayInputStream is not E, and the unclosed stream is only B');\n assert.deepStrictEqual(r.stats.fix, { auto: 1, review: 0, decision: 0 });\n assert.strictEqual(f.severity, 'medium');\n});\n\ntest('importing an FFmpeg helper type is not a native process; its uses are', () => {\n const r = scanJavaSource([\n 'package com.acme;',\n 'import java.io.*;',\n 'import com.day.cq.dam.handler.ffmpeg.FFMpegWrapper;',\n 'import com.day.cq.dam.handler.ffmpeg.ExecutableLocator;',\n 'public class T {',\n ' InputStream mem(byte[] b) { return new ByteArrayInputStream(b); }',\n ' void run(ExecutableLocator locator) { FFMpegWrapper w = FFMpegWrapper.fromProfile(null, null, null); }',\n '}',\n ].join('\\n'));\n assert.deepStrictEqual(r.nativeLines, [7], 'only the use on L7, not the imports on L3 and L4');\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":44688,"content_sha256":"0b11a9582ca34658d2df49bfea3b1c95f697020580ffcab9caced1dd1e378277"},{"filename":"scripts/legacy-ui-runner.js","content":"/**\n * Legacy UI Runner\n *\n * The `content-scan` detection tier of the runbook for the **lui** (Classic UI\n * dialog) and **cdw** (Custom Design/Classic Widget) patterns. Mirrors the\n * discovery documented in `{migration}/references/legacy-ui/dialog/context.md`\n * and `.../cdw/context.md`.\n *\n * Pure-Node fs walk + attribute regex over `.content.xml` (no external tools).\n * Heuristic — not the BPA analyzer — so hits are candidate matches the agent\n * re-confirms in Branch D before converting.\n */\n\n'use strict';\n\nconst fs = require('fs');\nconst path = require('path');\n\n// xtypes with a direct Coral 3 equivalent (dialog/context.md). Anything else on\n// a cq:Widget is a custom widget → cdw.\nconst KNOWN_SAFE_XTYPES = new Set([\n 'textfield', 'editfield', 'textarea', 'htmleditor', 'richtext', 'checkbox', 'selection',\n 'combobox', 'pathfield', 'browsefield', 'smartfile', 'numberfield', 'datefield',\n 'multifield', 'dialogfieldset', 'tabpanel', 'panel', 'hidden', 'colorfield', 'tags',\n 'tagsfield', 'label', 'static', 'button', 'dialogbuttons', 'userpicker',\n]);\n\n/** Recursively collect `.content.xml` files, skipping vendor/build dirs. */\nfunction collectContentXml(dir, acc = []) {\n let entries;\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }\n for (const e of entries) {\n const full = path.join(dir, e.name);\n if (e.isDirectory()) {\n if (e.name === 'node_modules' || e.name === '.git' || e.name === 'target' || e.name === 'dist') continue;\n collectContentXml(full, acc);\n } else if (e.isFile() && e.name === '.content.xml') {\n acc.push(full);\n }\n }\n return acc;\n}\n\n/** Read a file, returning '' on failure. */\nfunction read(file) {\n try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }\n}\n\n/**\n * CDW — custom Classic UI widgets. Flags `xtype=\"…\"` attributes whose value is\n * not a known-safe Coral 3 equivalent. One finding per (file, custom xtype).\n *\n * @returns {{ ok: boolean, findings: Array, rawFindings: Array, warnings: string[] }}\n */\nfunction runCdwScan(workspaceRoot) {\n if (!workspaceRoot) return { ok: false, findings: [], rawFindings: [], warnings: [], error: 'no workspaceRoot' };\n const findings = [];\n const rawFindings = [];\n for (const file of collectContentXml(workspaceRoot)) {\n const content = read(file);\n if (!content.includes('xtype')) continue;\n const seen = new Set();\n const re = /xtype=\"([^\"]+)\"/g;\n let m;\n while ((m = re.exec(content)) !== null) {\n const xtype = m[1];\n if (KNOWN_SAFE_XTYPES.has(xtype) || seen.has(xtype)) continue;\n seen.add(xtype);\n findings.push({\n location: file,\n detail: `custom-xtype '${xtype}' — no direct Coral 3 equivalent; scaffold a Granite UI component`,\n severity: 'high',\n });\n rawFindings.push({ pattern: 'cdw', file, line: null, snippet: `xtype=\"${xtype}\"`, xtype });\n }\n }\n return { ok: true, findings, rawFindings, warnings: [] };\n}\n\n/**\n * LUI — Classic UI / Coral 2 dialogs. Flags:\n * - `legacy.dialog.classic`: a Classic dialog node (`jcr:primaryType=\"cq:Dialog\"`\n * or `xtype=\"dialog\"`).\n * - `legacy.dialog.coral2`: a Touch dialog using Coral 2 resourceTypes\n * (`granite/ui/components/foundation/…` without the Coral 3 `.../coral/…`).\n * One finding per (file, sub-type).\n *\n * @returns {{ ok: boolean, findings: Array, rawFindings: Array, warnings: string[] }}\n */\nfunction runLuiScan(workspaceRoot) {\n if (!workspaceRoot) return { ok: false, findings: [], rawFindings: [], warnings: [], error: 'no workspaceRoot' };\n const findings = [];\n const rawFindings = [];\n for (const file of collectContentXml(workspaceRoot)) {\n const content = read(file);\n\n const isClassic = /jcr:primaryType=\"cq:Dialog\"/.test(content) || /xtype=\"dialog\"/.test(content);\n const usesFoundation = content.includes('granite/ui/components/foundation/');\n const usesCoral3 = content.includes('granite/ui/components/coral/');\n const isCoral2 = usesFoundation && !usesCoral3;\n\n if (isClassic) {\n findings.push({ location: file, detail: 'legacy.dialog.classic — Classic UI dialog; convert to Coral 3 (Touch UI)', severity: 'high' });\n rawFindings.push({ pattern: 'lui', file, line: null, snippet: 'Classic UI dialog', subType: 'legacy.dialog.classic' });\n }\n if (isCoral2) {\n findings.push({ location: file, detail: 'legacy.dialog.coral2 — Coral 2 resourceTypes; upgrade to Coral 3', severity: 'high' });\n rawFindings.push({ pattern: 'lui', file, line: null, snippet: 'Coral 2 dialog', subType: 'legacy.dialog.coral2' });\n }\n }\n return { ok: true, findings, rawFindings, warnings: [] };\n}\n\nmodule.exports = { runLuiScan, runCdwScan, collectContentXml, KNOWN_SAFE_XTYPES };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4783,"content_sha256":"39680bac97f827db8aa9fa80d3e2c57e60a21490850faf158f025e003d3a9e76"},{"filename":"scripts/osgi-config-runner.js","content":"/**\n * OSGi Config Runner\n *\n * The `config-scan` detection tier of the runbook cascade for the **osgiConfig**\n * pattern — OSGi configuration values that should move to Cloud Manager\n * environment secrets / variables. Mirrors the scan documented in\n * `{migration}/references/osgi-cfg-json-cloud-manager.md`.\n *\n * This is a **heuristic, review-only** detector. It performs the cheap,\n * deterministic part — finding config files and flagging secret-looking keys\n * and `$[secret:]`/`$[env:]` placeholders — and defers all classification\n * (is this really a secret? Adobe-owned PID?) to apply time. Findings are\n * tagged `confidence: 'heuristic'`, `needsReview: true` by the caller.\n *\n * HARD SAFETY RULE: this detector never emits a secret *value*. It reports\n * only the file path, the key *name*, and a `kind` label. The runbook is a\n * file written to disk (often committed), so leaking a plaintext secret into\n * it would defeat the entire migration.\n */\n\n'use strict';\n\nconst fs = require('fs');\nconst path = require('path');\n\n// Keys whose plaintext string values are likely secrets and should move to\n// `$[secret:…]`. Matched case-insensitively against the JSON property name.\nconst SECRET_KEY_RE = /(password|passwd|pwd|secret|token|api[-_]?key|access[-_]?key|private[-_]?key|credential|client[-_]?secret)/i;\n\n// A value already using a Cloud Manager placeholder — not actionable, just\n// informational (\"already templated correctly\").\nconst PLACEHOLDER_RE = /\\$\\[(secret|env):/;\n\n// repoinit files: placeholders are not allowed there, so we do not flag\n// secret keys for placeholder injection (see osgi-cfg-json-cloud-manager.md).\nconst REPOINIT_RE = /RepositoryInitializer/i;\n\n// An OSGi config folder is `config` or a runmode variant `config.\u003crunmode>`\n// (e.g. config.author.dev). Matches the whole segment — not any segment merely\n// starting with \"config\" (which would catch e.g. `configuration`).\nconst CONFIG_FOLDER_RE = /^config(\\.[a-z0-9-]+)*$/i;\n\n/** True if any path segment is a `config` / `config.\u003crunmode>` folder. */\nfunction inConfigFolder(filePath) {\n return filePath.split(path.sep).some(seg => CONFIG_FOLDER_RE.test(seg));\n}\n\n// AEM as a Cloud Service supported run-mode tokens. Custom tokens are\n// unsupported; `preview` inherits from publish and cannot be declared.\nconst TIER_TOKENS = new Set(['author', 'publish']);\nconst ENV_TOKENS = new Set(['dev', 'stage', 'prod']);\n\n// A run-mode-qualified config/install folder: `config` or `install` followed by\n// one or more `.token` segments. Bare `config`/`install` (no suffix) does not\n// match and is always valid. Case-sensitive: AEM run modes are case-sensitive\n// (e.g. `config.Author.dev` is not the same as `config.author.dev`), so the\n// prefix is matched lowercase-only and tokens may be any case — capitalized\n// tokens fall through validation as unsupported rather than being silently\n// treated as their lowercase equivalent.\nconst RUNMODE_FOLDER_RE = /^(config|install)((?:\\.[A-Za-z0-9-]+)+)$/;\n\n/**\n * Validate one OSGi config / bundle install folder name against the AEM CS\n * supported run-mode set and the tier-before-environment ordering rule.\n *\n * @param {string} folderName e.g. 'config.author.dev' or 'install.local'\n * @returns {null | {folder: string, runmode: string, reason: string}}\n * null when valid (or not run-mode-qualified); otherwise the offending\n * run-mode string (dot-joined) and a human-readable reason.\n */\nfunction validateRunmodeFolder(folderName) {\n const m = RUNMODE_FOLDER_RE.exec(folderName);\n if (!m) {\n // Not a well-formed `(config|install).\u003ctoken>(.\u003ctoken>)*` name. If it is\n // still run-mode-qualified with a lowercase `config.`/`install.` prefix\n // (e.g. `config.` with a trailing dot and no token, or `config..dev` with\n // an empty segment), it is a malformed folder name rather than a bare\n // `config`/`install` — flag it. Bare `config`/`install` (no dot),\n // capitalized prefixes, and non-config names (e.g. `configuration`) stay\n // out of scope and remain valid/null.\n if (/^(config|install)\\./.test(folderName)) {\n return { folder: folderName, runmode: folderName, reason: 'malformed run-mode folder name' };\n }\n return null; // bare config/install or not a config/install folder\n }\n // Case-sensitive: tokens are compared as-is against the lowercase\n // TIER_TOKENS/ENV_TOKENS sets, so a capitalized token (e.g. 'Author') falls\n // through to the \"unsupported run mode token\" branch below.\n const tokens = m[2].slice(1).split('.'); // drop leading dot\n const runmode = tokens.join('.');\n\n let tierCount = 0;\n let envCount = 0;\n let seenEnv = false;\n for (const tok of tokens) {\n if (TIER_TOKENS.has(tok)) {\n if (seenEnv) {\n return { folder: folderName, runmode,\n reason: `tier run mode '${tok}' must precede the environment run mode` };\n }\n tierCount += 1;\n } else if (ENV_TOKENS.has(tok)) {\n envCount += 1;\n seenEnv = true;\n } else {\n return { folder: folderName, runmode, reason: `unsupported run mode token '${tok}'` };\n }\n }\n if (tierCount > 1) return { folder: folderName, runmode, reason: 'more than one tier run mode' };\n if (envCount > 1) return { folder: folderName, runmode, reason: 'more than one environment run mode' };\n return null;\n}\n\n// A folder whose name is run-mode-qualified (`config.\u003cx>` / `install.\u003cx>`).\nconst RUNMODE_QUALIFIED_DIR_RE = /^(config|install)\\./i;\n\n/** Recursively collect run-mode-qualified folder paths. Skips build/vcs dirs. */\nfunction collectRunmodeFolders(dir, acc = []) {\n let entries;\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }\n for (const e of entries) {\n if (!e.isDirectory()) continue;\n if (e.name === 'node_modules' || e.name === '.git' || e.name === 'target' || e.name === 'dist') continue;\n const full = path.join(dir, e.name);\n if (RUNMODE_QUALIFIED_DIR_RE.test(e.name)) acc.push(full);\n collectRunmodeFolders(full, acc);\n }\n return acc;\n}\n\n/**\n * Scan a workspace for URC (Unsupported Run modes Configuration): OSGi\n * `config.\u003crunmode>` and bundle `install.\u003crunmode>` folders whose run mode is\n * not supported on AEM as a Cloud Service. Flag-only — never modifies anything.\n * Local fallback for when no BPA report is available.\n *\n * @returns {{ ok: boolean, findings: Array, rawFindings: Array, error?: string }}\n */\nfunction scanUnsupportedRunmodes(workspaceRoot) {\n if (!workspaceRoot) return { ok: false, findings: [], rawFindings: [], error: 'no workspaceRoot' };\n let folders;\n try { folders = collectRunmodeFolders(workspaceRoot); }\n catch (err) { return { ok: false, findings: [], rawFindings: [], error: err.message }; }\n\n const findings = [];\n const rawFindings = [];\n for (const folder of folders) {\n const bad = validateRunmodeFolder(path.basename(folder));\n if (!bad) continue;\n findings.push({\n location: folder,\n detail: `unsupported-runmode — folder '${path.basename(folder)}' (${bad.reason}); rename to a supported run mode or remove`,\n severity: 'high',\n });\n rawFindings.push({\n pattern: 'osgiConfig',\n file: folder,\n line: null,\n snippet: `unsupported run mode '${bad.runmode}'`,\n kind: 'unsupported-runmode',\n runmode: bad.runmode,\n });\n }\n return { ok: true, findings, rawFindings };\n}\n\n/**\n * If `folderName` is a pure ordering violation — every token a known tier/env,\n * at most one of each, currently out of canonical `\u003cprefix>.\u003ctier>.\u003cenv>` order —\n * return the corrected basename. Otherwise (valid, unknown token, or duplicate\n * tier/env) return null. Deterministic and side-effect free.\n *\n * @returns {null | {from: string, to: string}}\n */\nfunction reorderRunmodeFolder(folderName) {\n const m = RUNMODE_FOLDER_RE.exec(folderName);\n if (!m) return null;\n // Case-sensitive: the regex prefix match already guarantees a lowercase\n // `config`/`install` prefix, and tokens are compared as-is (no lowercasing)\n // against the lowercase TIER_TOKENS/ENV_TOKENS sets. A mixed-case token\n // (e.g. 'DEV') therefore will not match either set, so\n // `tiers.length + envs.length !== tokens.length` below routes it to manual\n // instead of silently case-folding it into a \"fixed\" reorder.\n const prefix = m[1];\n const tokens = m[2].slice(1).split('.');\n const tiers = tokens.filter(t => TIER_TOKENS.has(t));\n const envs = tokens.filter(t => ENV_TOKENS.has(t));\n if (tiers.length + envs.length !== tokens.length) return null; // unknown token(s)\n if (tiers.length > 1 || envs.length > 1) return null; // duplicate tier/env\n const canonical = [prefix, ...tiers, ...envs].join('.');\n if (canonical === folderName) return null; // already valid\n return { from: folderName, to: canonical };\n}\n\n/**\n * Plan safe auto-reorder fixes across a workspace. READ-ONLY: writes nothing.\n * For each ordering-only violation whose target folder does not already exist,\n * emits a `git mv` command (paths relative to `workspaceRoot`) that the Branch A\n * apply runs to perform the reorder. Unknown tokens, duplicate tier/env, and\n * collisions (target already exists — renaming would change PID resolution) are\n * routed to `manual` for the handoff cleanup array.\n *\n * NOTE: each reorder's `command` string is a human-readable convenience only\n * (for display in the runbook/handoff file). The apply step must NOT\n * shell-evaluate it — it should perform the rename using the structured\n * `from`/`to` fields directly via argv, e.g. `spawn('git', ['mv', from, to])`,\n * to avoid any shell-quoting/injection risk from path segments.\n *\n * @param {string} workspaceRoot\n * @returns {{ ok: boolean, reorders: Array, manual: Array, error?: string }}\n */\nfunction planRunmodeReorders(workspaceRoot) {\n if (!workspaceRoot) return { ok: false, reorders: [], manual: [], error: 'no workspaceRoot' };\n let folders;\n try { folders = collectRunmodeFolders(workspaceRoot); }\n catch (err) { return { ok: false, reorders: [], manual: [], error: err.message }; }\n\n const reorders = [];\n const manual = [];\n for (const folder of folders) {\n const name = path.basename(folder);\n const bad = validateRunmodeFolder(name);\n if (!bad) continue; // valid folder — nothing to fix\n const relFolder = path.relative(workspaceRoot, folder);\n const fix = reorderRunmodeFolder(name);\n if (!fix) { manual.push({ folder: relFolder, reason: `not auto-fixable (${bad.reason})` }); continue; }\n const targetAbs = path.join(path.dirname(folder), fix.to);\n const relTarget = path.relative(workspaceRoot, targetAbs);\n if (fs.existsSync(targetAbs)) {\n manual.push({ folder: relFolder, target: relTarget, reason: 'target folder already exists — manual merge required' });\n continue;\n }\n // `command` is display-only (human-readable convenience for the runbook/\n // handoff file). The apply step must execute the rename via argv using\n // `from`/`to` directly — not by shell-evaluating this string.\n reorders.push({ from: relFolder, to: relTarget, command: `git mv \"${relFolder}\" \"${relTarget}\"` });\n }\n return { ok: true, reorders, manual };\n}\n\nconst CFG_JSON_RE = /\\.cfg\\.json$/i;\nconst LEGACY_RE = /\\.(cfg|config)$/i;\n\n/** Recursively collect candidate config files under `dir`. Skips node_modules/.git/target. */\nfunction collectConfigFiles(dir, acc = []) {\n let entries;\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }\n for (const e of entries) {\n const full = path.join(dir, e.name);\n if (e.isDirectory()) {\n if (e.name === 'node_modules' || e.name === '.git' || e.name === 'target' || e.name === 'dist') continue;\n collectConfigFiles(full, acc);\n } else if (e.isFile()) {\n if (!inConfigFolder(full)) continue;\n if (CFG_JSON_RE.test(e.name) || LEGACY_RE.test(e.name)) acc.push(full);\n }\n }\n return acc;\n}\n\n/**\n * Extract **every** `\"key\": \"value\"` string property on a line, returning each\n * key name and whether its value is already a placeholder — NEVER the value\n * itself. Uses a global match so compact / multi-property lines (legal for\n * `.cfg.json`, which is JSON) don't hide secrets after the first pair.\n */\nfunction scanCfgJsonLine(line) {\n const re = /\"([^\"]+)\"\\s*:\\s*\"((?:\\\\.|[^\"\\\\])*)\"/g;\n const pairs = [];\n let m;\n while ((m = re.exec(line)) !== null) {\n pairs.push({ key: m[1], isPlaceholder: PLACEHOLDER_RE.test(m[2]) });\n }\n return pairs;\n}\n\n/**\n * Scan config files under `workspaceRoot`.\n *\n * @returns {{\n * ok: boolean,\n * findings: Array\u003c{location: string, detail: string, severity: string}>,\n * rawFindings: Array\u003c{pattern: string, file: string, line: number|null, snippet: string, kind: string}>,\n * warnings: string[],\n * error?: string,\n * }}\n */\nfunction runOsgiConfigScan(workspaceRoot, options = {}) {\n if (!workspaceRoot) {\n return { ok: false, findings: [], rawFindings: [], warnings: [], error: 'no workspaceRoot' };\n }\n\n let files;\n try {\n files = collectConfigFiles(workspaceRoot);\n } catch (err) {\n return { ok: false, findings: [], rawFindings: [], warnings: [], error: err.message };\n }\n\n const findings = [];\n const rawFindings = [];\n const warnings = [];\n\n for (const file of files) {\n const isRepoinit = REPOINIT_RE.test(file);\n\n if (LEGACY_RE.test(file)) {\n // Legacy .cfg/.config → must be converted to .cfg.json (Phase 0).\n findings.push({\n location: file,\n detail: 'legacy-format — convert to .cfg.json (Phase 0)',\n severity: 'high',\n });\n rawFindings.push({ pattern: 'osgiConfig', file, line: null, snippet: 'legacy config format', kind: 'legacy-format' });\n continue;\n }\n\n let content;\n try { content = fs.readFileSync(file, 'utf8'); } catch { continue; }\n const lines = content.split('\\n');\n let filePlaceholdered = false;\n\n lines.forEach((line, i) => {\n for (const { key, isPlaceholder } of scanCfgJsonLine(line)) {\n if (isPlaceholder) { filePlaceholdered = true; continue; }\n if (isRepoinit) continue; // placeholders not allowed in repoinit — do not flag.\n if (SECRET_KEY_RE.test(key)) {\n // Report key NAME and location only — never the value.\n findings.push({\n location: `${file}:${i + 1}`,\n detail: `plaintext-secret — key '${key}' should move to $[secret:…]`,\n severity: 'high',\n });\n rawFindings.push({\n pattern: 'osgiConfig',\n file,\n line: i + 1,\n snippet: `\"${key}\": \u003credacted>`,\n kind: 'plaintext-secret',\n });\n }\n }\n });\n\n if (filePlaceholdered) {\n findings.push({\n location: file,\n detail: 'already-placeholdered — uses $[secret:]/$[env:] (informational)',\n severity: 'info',\n informational: true, // already migrated — does not count as outstanding work\n });\n rawFindings.push({ pattern: 'osgiConfig', file, line: null, snippet: 'uses $[secret:]/$[env:]', kind: 'already-placeholdered', informational: true });\n }\n }\n\n // XML sling:OsgiConfig nodes are in scope per the reference but are not\n // scanned deterministically here — only surface this limitation when the\n // project actually has config folders (otherwise it is noise).\n if (files.length > 0) {\n warnings.push('XML sling:OsgiConfig nodes are not scanned by this detector — review .content.xml config nodes manually.');\n }\n\n return { ok: true, findings, rawFindings, warnings };\n}\n\nmodule.exports = {\n runOsgiConfigScan, collectConfigFiles, SECRET_KEY_RE,\n validateRunmodeFolder, scanUnsupportedRunmodes,\n reorderRunmodeFolder, planRunmodeReorders,\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":15784,"content_sha256":"ba5815c139c766e5933b161d656e5f5b84aafbdadb8a5449a2c4d9b21b0f0fae"},{"filename":"scripts/package.json","content":"{\n \"name\": \"migration-scripts\",\n \"version\": \"1.0.0\",\n \"description\": \"BPA findings helper and parser for AEM migration skill\",\n \"scripts\": {\n \"parse-bpa\": \"node bpa-local-parser.js\",\n \"read-unified\": \"node unified-collection-reader.js\",\n \"check-sources\": \"node bpa-findings-helper.js\",\n \"test\": \"node --test bpa-findings-helper.test.js runbook-generator.test.js input-stream-runner.test.js dispatcher-conversion.test.js dispatcher-crossboundary.test.js dispatcher-report.test.js dispatcher-e2e.test.js\"\n },\n \"engines\": {\n \"node\": \">=14.0.0\"\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":568,"content_sha256":"9fb5ade76c17f03030345d99f1378b8394e4d45f113c723c51125d51a06dca88"},{"filename":"scripts/README.md","content":"# BPA Local Collection Scripts\n\nScripts for managing BPA (Best Practices Analyzer) findings locally. The skill handles these automatically — you should not need to run them manually during normal use.\n\n## How It Works\n\nWhen the skill needs BPA findings, it calls `bpa-findings-helper.js` which handles everything:\n\n```\nUser provides BPA CSV path?\n │\n ┌────┴────────────────────────────────────┐\n │ │\n ▼ ▼\nCollection already exists? No CSV path given\n ┌────┴────┐ ┌────┴────┐\n YES NO YES NO\n │ │ │ │\n ▼ ▼ Collection Try MCP\n Use it Parse CSV & exists? server\n directly create collection Use it or ask user\n```\n\n**Key behavior:**\n- BPA CSV files are parsed **once** and saved as a unified collection\n- Subsequent runs reuse the existing collection instantly\n- If a new BPA file is provided when a collection exists, the skill asks the user which to use\n\n## Scripts\n\n### `runbook-generator.js` (review / scan entry point)\n\nGenerates a read-only `migration-runbook.md` covering **every pattern the migration skill can address**. Triggered by broad prompts like \"review my code for AEMaaCS migration\".\n\nEach pattern declares a **detection strategy** in `PATTERN_META`:\n\n| Pattern(s) | `strategy` | Engine |\n|---|---|---|\n| `scheduler`, `resourceChangeListener`, `event-migration`, `assetApi` | `cascade` | BPA/CAM → CSV → analyzer → LLM scan |\n| `replication` | `cascade` | BPA/CAM → CSV (`replication.agent`) → analyzer → LLM scan |\n| `htlLint` | `html-scan` | `htl-lint-runner.js` (pure-Node regex over `.html`) |\n| `osgiConfig` | `config-scan` | `osgi-config-runner.js` (config-file scan) |\n| `lui`, `cdw`, `templateModernization` | BPA + `content-scan` | BPA/CAM → CSV first (`legacy-ui-runner.js` / `template-scan-runner.js` as fallback) |\n| `dispatcherConversion` | `content-scan` | `dispatcher-inventory.js` (`runDispatcherScan` — heuristic, no BPA subtype) |\n| `guavaCache` | `bpa-only` | BPA/CAM → CSV only (no analyzer, no content-scan) → LLM scan |\n| `inputStream` | `java-scan` | BPA/CAM → CSV first; `input-stream-runner.js` resolves the reported classes to files (rows outside the workspace collapsed per bundle) and scans every `src/main/java` file only when BPA has no `inputStream` findings. Never needs the LLM tier. |\n\n`html-scan` / `config-scan` findings, and the `content-scan` fallback for `lui`/`cdw`/`templateModernization`, are **heuristic** and tagged `confidence: \"heuristic\"` in the cache. BPA-sourced findings are authoritative. `osgiConfig` reports key names + locations only — **never secret values**.\n\n```javascript\nconst { generateRunbook, renderRunbook } = require('./scripts/runbook-generator.js');\n\nconst result = await generateRunbook({\n workspaceRoot: '/path/to/project', // analyzer tier\n bpaFilePath: './reports/bpa.csv', // optional (CSV tier)\n collectionsDir: './unified-collections', // default\n projectId, mcpFetcher, // optional (MCP tier)\n outputPath: './migration-runbook.md', // default\n});\n\n// result.totalFindings → total across patterns\n// result.patternCounts → { scheduler: 12, replication: 1, … }\n// result.needsLlmScan → patterns no deterministic source could scan (tier 4)\n// result.gathered → { findingsByPattern, sourceByPattern, … } for re-render after an LLM scan\n```\n\n**CLI (the canonical Step-0 path — one command, no in-process bridge):**\n```bash\nnode scripts/runbook-generator.js \u003cworkspaceRoot> \\\n [--csv \u003cbpaFilePath>] \\\n [--bpa-json \u003cfile>] \\ # pre-fetched CAM/MCP findings, { \"\u003cslug>\": [ \u003ctarget>, … ] } — no mcpFetcher bridge needed\n [--llm-findings \u003cfile>] \\ # Tier-4 results, { \"\u003cpattern>\": [ { file, line, snippet }, … ] } — merged + re-rendered in one pass\n [--out \u003coutputPath>] [--cache \u003ccachePath>]\n```\n\n- `--bpa-json` lets the agent dump its CAM/MCP fetch to a file instead of passing an in-process `mcpFetcher`; an omitted slug is treated as \"not in report\" (clean).\n- The analyzer is always **unioned** with BPA (BPA authoritative, analyzer fills source-only findings, deduped by class name), so a \"BPA clean\" cascade pattern still surfaces real source code — no manual reconciliation.\n- If the run prints `⚠️ Needs LLM scan`, grep for those patterns, write the hits to a JSON file, and re-run with `--llm-findings` to fold them in.\n\n### `analyzer-runner.js`\n\nWraps the code-assessment deterministic analyzer (`../../code-assessment/scripts/analyze.sh`). Runs it once over a workspace, parses the `{ findings, warnings }` JSON, and normalizes analyzer slugs (`resource-change-listener`, `event-migration`, `asset-manager`, …) into the migration canonical pattern ids. Used internally by `runbook-generator.js` for `cascade` patterns. Returns `{ ok: false }` when the analyzer can't run (missing script, no JDK, compile error) so the cascade falls through to the LLM-scan tier.\n\n### `htl-lint-runner.js`\n\nThe `html-scan` strategy for `htlLint`. Pure-Node fs walk + regex over `.html` files detecting `data-sly-test` redundant-constant anti-patterns (`== true`, `/apps/…` string, split `${} || ${}`, numeric literals) — mirrors the `rg` discovery in `{code-assessment}/references/data-sly-test-redundant-constant.md`, but with **no external `rg` dependency** so it runs anywhere Node does. Heuristic (a regex is not the HTL compiler); findings are re-confirmed before editing.\n\n### `input-stream-runner.js`\n\nThe `java-scan` strategy for `inputStream` (BPA `DG` / `java.io.inputstream`), with the same precedence as the other BPA patterns: BPA findings first (CAM targets via `--bpa-json`, then a BPA CSV via `--csv`), and a scan of every `src/main/java` file only when BPA has no `inputStream` findings. With BPA rows, only the reported classes' files are scanned, since BPA names classes but not lines. It skips tests and `target/` and ignores comments and string literals. It finds each stream acquisition site and pre-classifies it:\n- **F** deserialization (any `*ObjectInputStream`)\n- **E** binary streamed or processed in the JVM: DAM binary reads, binaries copied to the response, client uploads into the repository, asset processing in workflow steps, streams in a method that starts a native process (other streams in the class get a file-level note instead)\n- **D** local filesystem (hard-coded paths always; per-request scratch files excluded), with local-filesystem writes in the same file listed\n- **C** unbounded in-memory read (only a stream-level limit counts; a Content-Length check doesn't)\n- **B** unclosed stream or undisposed JCR `Binary`\n- **A** benign\n\nEach site also gets a fix kind (`decision` / `review` / `auto`). Ownership is tracked through try-with-resources, wrappers, returns, fields (with or without `this.`), lambdas, setters and builders, and same-file helpers or constructors that close or keep the stream; each method, including methods of nested classes, is its own scope. Output is **one finding per file**, ordered by risk.\n\nBPA rows resolve to their files (nested classes to the outer class's file). Rows that don't resolve collapse to one informational row per bundle (per package for CAM rows, which carry no bundle name): `missing-source` for customer code kept elsewhere (the bundle has workspace classes, or shares the workspace's package namespace), `platform-provided` for AEM Forms add-on code, and `not-in-workspace` for third-party code. A class that resolves but has no visible stream usage becomes `bpa-unmatched`, with a hint naming same-package files that do use streams.\n\n```bash\nnode input-stream-runner.js \u003cworkspaceRoot> [--bpa-json \u003ccam-targets.json>] [--csv \u003cbpa.csv>] [--collections \u003cdir>] [--json]\nnode input-stream-runner.js \u003cworkspaceRoot> --files a/Foo.java,b/Bar.java --json # verify after editing; exits 2 (ok: false) if a requested file can't be read\n```\n\n### `osgi-config-runner.js`\n\nThe `config-scan` strategy for `osgiConfig`. Scans OSGi config files (`.cfg.json`, legacy `.cfg`/`.config`) under `config*` folders for secret-looking keys and `$[secret:]`/`$[env:]` placeholders, per `{migration}/references/osgi-cfg-json-cloud-manager.md`. **Reports key names + locations only — never secret values** (the runbook is written to disk / often committed). Heuristic and review-only: classification (real secret? Adobe-owned PID?) is deferred to apply time.\n\n### `legacy-ui-runner.js`\n\nThe `content-scan` **fallback** for `lui` (Classic UI `cq:Dialog`/`xtype=\"dialog\"` and Coral 2 `granite/ui/components/foundation` dialogs) and `cdw` (custom `cq:Widget` xtypes not in the known-safe Coral 3 set). Pure-Node scan of `.content.xml` per `{migration}/references/legacy-ui/`. Used only when no BPA source is available; heuristic and can undercount vs BPA when legacy nodes live in packages (e.g. acs-commons) outside the project source.\n\n### `template-scan-runner.js`\n\nThe `content-scan` **fallback** for `templateModernization` — static templates (`cq:Template`) under `apps/\u003cappId>/templates/*`. Used only when no BPA source is available.\n\n### `dispatcher-inventory.js`\n\nThe `content-scan` strategy for `dispatcherConversion` — detects an AMS or on-premise Dispatcher configuration convertible to AEM as a Cloud Service (Branch E). Walks the workspace for Dispatcher config roots, classifies each via `detectMode` (`standard` / `flexible` / `v1` / `unknown` → convertible; `already-cloud` / `not-dispatcher` → skipped), and builds an inventory (vhost/farm files, filter/rewrite/cache rule counts, AMS markers). Exposes `runDispatcherScan(workspaceRoot)` — the runbook's content-scan entry point — returning `{ ok, findings, rawFindings, warnings }` and silently skipping workspaces that are already Cloud-native or not a Dispatcher at all (no findings, no noise). Heuristic; there is no BPA subtype for dispatcher conversion.\n\n### `dispatcher-verify.js`\n\nOutput verification for a generated Cloud Service Dispatcher config (`verifyOutput`). The filter/ACL allow-list is a **hard gate** — if the source had filter rules and the converted output has none (or fewer), it fails, so a conversion that would weaken security is caught before it is applied. Baseline and output are counted the **same** way via `countFilterRules` (in `dispatcher-inventory.js`): inline farm `/filter` rules **plus** the standalone filter files an inline `$include` pulls in (`filters/*.any`, `*_filters.any`) — so the canonical AMS `$include` layout can't score zero and slip an emptied output past the gate. Adobe-managed immutable SDK files (`default_*.any`) are **excluded** from the count, so a populated SDK `default_filters.any` can't mask an emptied custom `filters.any` — the count is custom-to-custom and the acl-loss gate still fires. (Residual blind spot: filter rules reachable only through an `$include` to a path outside a `filters/` directory aren't resolved — sanity-check `baseline.filter` against the source; see [output-verification.md](../references/dispatcher/output-verification.md).) Also reconciles rewrite/redirect counts (warning — rules may legitimately move to the CDN) and flags mega-inlined vhosts and missing current-SDK conventions (`enabled_farms/farms.any`).\n\n### `dispatcher-run.js`\n\nDrives the end-to-end conversion: writes the tool `config.yaml`, resolves the mode-specific executor, and wraps Adobe's [`@adobe/aem-cs-source-migration-dispatcher-converter`](https://github.com/adobe/aem-cs-source-migration). That Adobe tool is **not a bundled dependency** — it is auto-installed on first use into the gitignored `scripts/dispatcher-tool/node_modules/` (`ensureToolInstalled`), keeping the core scripts zero-dependency.\n\n### `bpa-findings-helper.js` (main entry point)\n\nOrchestrates finding BPA data. Called by the skill internally.\n\n```javascript\nconst { getBpaFindings } = require('./scripts/bpa-findings-helper.js');\n\nconst result = await getBpaFindings('scheduler', {\n bpaFilePath: './cleaned_file6.csv', // optional\n collectionsDir: './unified-collections', // default\n projectId: '...', // optional, for MCP\n mcpFetcher: mcpFunction // optional, for MCP\n});\n\n// result.success → true/false\n// result.source → 'unified-collection' | 'bpa-file' | 'mcp-server' | error\n// result.message → human-readable status string\n// result.targets → array of BPA findings (when successful)\n```\n\n### `bpa-local-parser.js`\n\nParses BPA CSV files and creates unified collections. Used internally by the helper, but can also be run directly:\n\n```bash\nnode bpa-local-parser.js \u003cbpa-csv-file-path> [output-directory]\n```\n\n### `unified-collection-reader.js`\n\nReads unified collections and returns findings. Used internally by the helper, but can also be run directly:\n\n```bash\nnode unified-collection-reader.js [pattern] [collections-directory]\n```\n\n## Testing\n\nThe unit tests use Node's built-in test runner (`node --test`), which requires **Node ≥18**. This is a test-tooling prerequisite only — the scripts themselves run on the `package.json` `engines` floor (Node ≥14); running the suite does not raise that runtime floor.\n\n```bash\n# From the scripts directory\ncd scripts\nnpm test # runs the *.test.js suites (requires Node ≥18)\n\nnpm run parse-bpa -- \u003cbpa-file-path> [output-dir]\nnpm run read-unified -- [pattern] [collections-dir]\n```\n\n## File Formats\n\n### Input: BPA CSV\n\n```csv\ncode,type,subtype,importance,identifier,message,context\nDG,development.guideline,unsupported.asset.api,MAJOR,com.example.MyClass,Uses deprecated API...\n```\n\n### Output: Unified Collection (`unified-collection.json`)\n\nMatches the cloud-adoption-service format with MongoDB-safe field names (dots → underscores). Includes metadata (timestamp, totalFindings) for display, and `parsedSubtypes`: every subtype the parser extracts. `bpa-findings-helper.js` uses it to rebuild, from the CSV, a collection written before a requested pattern was supported (collections without the field count as covering the subtypes parsers handled before it was added):\n\n```json\n{\n \"subtypes\": {\n \"unsupported_asset_api\": {\n \"com_day_cq_dam_api_AssetManager_createAsset\": [\n \"com.example.MyClass\"\n ]\n }\n },\n \"meta\": {\n \"timestamp\": \"2026-03-18T06:16:37.755Z\",\n \"source\": \"local-bpa-parser\",\n \"totalFindings\": 1,\n \"subtypeCount\": 1,\n \"parsedSubtypes\": [\"sling.commons.scheduler\", \"unsupported.asset.api\", \"…\", \"java.io.inputstream\"]\n }\n}\n```\n\n## Supported Patterns\n\n| Pattern | BPA Subtype |\n|---------|------------|\n| scheduler | sling.commons.scheduler |\n| assetApi | unsupported.asset.api |\n| eventListener | javax.jcr.observation.EventListener |\n| resourceChangeListener | org.apache.sling.api.resource.observation.ResourceChangeListener |\n| eventHandler | org.osgi.service.event.EventHandler |\n| cdw | custom.classic.widget |\n| lui | legacy.dialog.classic, legacy.dialog.coral2, legacy.custom.component, legacy.static.template |\n| templateModernization | legacy.static.template, custom.static.template |\n| replication | forward.replication, reverse.replication |\n| urc | unsupported.runmode |\n| inputStream | java.io.inputstream (keyed by the referenced `java.io` API from the `other` column → class names; bundles kept per API and class in `meta.inputStreamBundles`) |\n\nSummary rows (`code` starting with `_`, e.g. `_COUNT_*`, `_STAT`) are excluded. Content/legacy-UI subtypes (cdw/lui/template/replication) are keyed by JCR node path (stored raw, not MongoDB-safed, so underscores like `design_dialog` survive).\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":15964,"content_sha256":"7483397d49f46d97110f9302c52809c13fbbf3265ef8a4804dfcd0289ae51455"},{"filename":"scripts/runbook-generator.js","content":"/**\n * Migration Runbook Generator\n *\n * Produces a read-only `migration-runbook.md` covering every pattern the\n * migration skill can address. Triggered by generic prompts like \"review/scan\n * my code for AEMaaCS migration\" (no pattern named, no CSV required).\n *\n * Per-pattern detection STRATEGY — each pattern declares in `PATTERN_META` how\n * it is detected:\n *\n * 'cascade' — Java code patterns. Highest available source wins:\n * BPA/CAM (MCP, if configured)\n * → CSV (if uploaded / cached)\n * → analyzer (local — code-assessment analyze.sh)\n * → LLM scan (last resort — agent, not this script)\n * `replication` maps to the BPA `replication.agent` subtype;\n * with no BPA source it falls through to the analyzer (or LLM).\n * 'html-scan' — `htlLint`: heuristic regex scan of `.html` templates.\n * 'config-scan' — `osgiConfig`: heuristic scan of OSGi config files for\n * secret-looking keys and `$[secret:]`/`$[env:]` placeholders\n * (key names + locations only — never secret values).\n * 'pom-scan' — `vault-package-dependencies`: prefers Maven's effective\n * POM (`mvn help:effective-pom`, one call per reactor\n * root) so `\u003cpluginManagement>` inheritance and per-\n * execution vs plugin-level `\u003cconfiguration>` are Maven's\n * problem, not ours; falls back to a raw text-scan of the\n * source `pom.xml` when Maven can't resolve a module\n * (dead parent repos, offline). No BPA subtype exists for\n * this pattern — a `pom.xml` install-time dependency\n * declaration is invisible to a deployed-artifact BPA\n * scan — so this scan is the only tier. Every fallback\n * emits a warning so a scan that couldn't reach Maven\n * isn't reported as clean.\n * 'content-scan'— `lui` / `cdw` / `templateModernization`. These ALSO carry\n * `bpaSlugs`, so when a BPA source is present they come from\n * BPA (authoritative); the `.content.xml` scan (Classic/Coral 2\n * dialogs, custom `cq:Widget` xtypes, static templates) is the\n * heuristic fallback when no BPA source is available. Routed to\n * migration Branches D / C, not code-assessment.\n * 'bpa-only' — `guavaCache`, `oakIndex`: BPA/CAM/CSV only, no analyzer, no\n * content-scan fallback.\n * `guavaCache` (subtype `custom.guava.cache`): one finding\n * per bundle. `identifier` on this subtype is a\n * Guava-internal class, not a customer class — BPA reports\n * every Guava-internal class reachable on a bundle's\n * classpath, so raw rows are deduped to the bundle named in\n * the message, not surfaced per row. No analyzer, no\n * content-scan — Guava cache usage does not occur in native\n * AEMaaCS code, so there is deliberately no compiled\n * detector for it.\n * `oakIndex` (subtypes `index.rule.violation` /\n * `standard.index.modification`, beta, Branch F): one\n * finding per Oak index JCR path. Legacy `_oak_index/*.xml`\n * layouts only exist in pre-cloud projects, so — like\n * Dispatcher Conversion — this wraps Adobe's index-converter\n * tool rather than a compiled detector.\n * With no BPA source, either pattern surfaces in\n * `needsLlmScan` like any other unscanned pattern.\n * 'java-scan' — `inputStream` (subtype `java.io.inputstream`). Same\n * precedence as the cascade: BPA/CAM → CSV → local. With\n * BPA rows, those classes define the scope; the scanner\n * (input-stream-runner.js) resolves them to workspace files\n * for line-level sites and collapses classes outside the\n * workspace into one informational row per bundle (customer\n * code kept elsewhere, platform-provided, or third-party).\n * Without BPA rows it scans every `src/main/java` file. Each\n * site is pre-classified (A–F + fix kind); one finding per\n * file. The local scan is always available, so the pattern\n * never needs the LLM tier.\n *\n * `html-scan`/`config-scan`/`content-scan` (fallback)/`java-scan` findings are tagged `confidence: 'heuristic'` in the\n * cache. Patterns no available strategy could scan (e.g. a cascade pattern\n * with no BPA source and no JDK) are returned in `needsLlmScan` for the agent.\n *\n * Alongside `migration-runbook.md`, this also writes a sidecar JSON cache\n * (default `./migration-runbook.json`) holding each pattern's raw\n * `{pattern, file, line, snippet}` findings. The apply handoff\n * (migration/SKILL.md Step 3) reads this cache to avoid re-deriving findings\n * via `getBpaFindings` or re-running the analyzer when the user picks a\n * pattern to fix.\n *\n * Usage (CLI):\n * node scripts/runbook-generator.js \u003cworkspaceRoot> [--csv \u003cbpaFilePath>] [--out \u003coutputPath>] [--cache \u003ccachePath>]\n * [--bpa-json \u003cfile>] [--llm-findings \u003cfile>] [--project-id \u003ccamProjectId> --project-name \u003cname>]\n */\n\n'use strict';\n\nconst fs = require('fs');\nconst path = require('path');\nconst { getBpaFindings, checkAvailableSources } = require('./bpa-findings-helper.js');\nconst { runAnalyzer, isAnalyzerAvailable, DEFAULT_ANALYZE_SCRIPT } = require('./analyzer-runner.js');\nconst { runHtlLint } = require('./htl-lint-runner.js');\nconst { runOsgiConfigScan, scanUnsupportedRunmodes, validateRunmodeFolder } = require('./osgi-config-runner.js');\nconst { runVaultPackageScan } = require('./vault-package-scan-runner.js');\nconst { runLuiScan, runCdwScan } = require('./legacy-ui-runner.js');\nconst { runTemplateScan } = require('./template-scan-runner.js');\nconst { runDispatcherScan } = require('./dispatcher-inventory.js');\nconst { runInputStreamScan } = require('./input-stream-runner.js');\n\n// Canonical pattern taxonomy for the runbook — every pattern the migration\n// skill can address. Each declares a detection `strategy`:\n// 'cascade' — BPA/CAM → CSV → analyzer → LLM (Java code patterns)\n// 'html-scan' — pure-Node regex scan of .html (htlLint)\n// 'config-scan' — config-file heuristic scan (osgiConfig)\n// 'content-scan' — .content.xml / template / dispatcher-config scan (lui, cdw,\n// templateModernization, dispatcherConversion)\n// 'bpa-only' — BPA/CAM/CSV only, no local fallback (guavaCache, oakIndex)\n// 'java-scan' — BPA first; Java source scan only when BPA has none (inputStream)\n// `bpaSlugs` maps a pattern to its BPA subtype(s): the Java 'cascade' patterns,\n// plus replication (replication.agent), lui/cdw/templateModernization,\n// guavaCache (com.google.common.cache), and oakIndex (index.rule.violation /\n// standard.index.modification). When a BPA source is present it is\n// authoritative; html/config/content scans are the local fallback for the\n// patterns that have one. (inject-in-sling-model and outdated-dependencies\n// belong to code-assessment's own runbook, not the migration runbook, so they\n// stay out.)\nconst PATTERN_META = {\n scheduler: {\n label: 'Scheduler',\n severity: 'high',\n strategy: 'cascade',\n bpaSlugs: ['scheduler'],\n description: 'Classes using `org.apache.sling.commons.scheduler.Scheduler` / implementing the legacy `Job` interface. Migrate to OSGi-property scheduling or Sling Jobs via `JobManager`.',\n promptPattern: 'scheduler',\n },\n resourceChangeListener: {\n label: 'Resource Change Listener',\n severity: 'high',\n strategy: 'cascade',\n bpaSlugs: ['resourceChangeListener'],\n description: 'Classes implementing `ResourceChangeListener` / `ExternalResourceChangeListener`. Migrate to a lightweight `ResourceChangeListener` + `JobConsumer` split.',\n promptPattern: 'resourceChangeListener',\n },\n 'event-migration': {\n label: 'Event Migration (EventListener / EventHandler)',\n severity: 'high',\n strategy: 'cascade',\n bpaSlugs: ['eventListener', 'eventHandler'],\n 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.',\n promptPattern: 'eventListener',\n },\n assetApi: {\n label: 'Asset Manager API',\n severity: 'high',\n strategy: 'cascade',\n bpaSlugs: ['assetApi'],\n 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()`.',\n promptPattern: 'assetApi',\n },\n replication: {\n label: 'Replication',\n severity: 'high',\n strategy: 'cascade',\n // BPA reports replication agents under replication.agent / forward|reverse.\n // When no CSV is present, the analyzer detects Replicator usage from source.\n bpaSlugs: ['replication'],\n description: 'Usage of `com.day.cq.replication.Replicator` / Sling Replication Agent. Migrate to the Sling Distribution API (`Distributor` + `SimpleDistributionRequest`).',\n promptPattern: 'replication',\n },\n htlLint: {\n label: 'HTL data-sly-test Lint',\n severity: 'medium',\n strategy: 'html-scan',\n bpaSlugs: [],\n heuristic: true,\n 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.',\n promptPattern: 'htlLint',\n },\n osgiConfig: {\n label: 'OSGi → Cloud Manager Config',\n severity: 'high',\n strategy: 'config-scan',\n bpaSlugs: [],\n heuristic: true,\n needsReview: true,\n 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.',\n promptPattern: 'osgi config',\n // Override — the OSGi → Cloud Manager branch uses a natural-language prompt,\n // not a `\u003cpattern> only` invocation.\n sampleOverride: 'Use the migration skill: scan my config files and create Cloud Manager environment secrets or variables.',\n },\n 'vault-package-dependencies': {\n label: 'Vault Package Dependencies',\n severity: 'high',\n strategy: 'pom-scan',\n // No BPA subtype exists for this pattern at all — a pom.xml install-time\n // dependency declaration (content-package-maven-plugin) is invisible to a\n // deployed-artifact BPA scan. The runner prefers Maven's effective POM\n // (`mvn help:effective-pom`, one call per reactor root) and falls back to\n // a text-scan of the raw pom.xml when Maven can't resolve a module —\n // legacy AEM 6.x/AMS projects (the target audience) often can't build\n // anymore, and the fallback keeps the pattern from silently reporting\n // clean.\n bpaSlugs: [],\n heuristic: true,\n 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.',\n promptPattern: 'vault-package-dependencies',\n },\n lui: {\n label: 'Classic UI / Coral 2 Dialogs (LUI)',\n severity: 'high',\n strategy: 'content-scan',\n // BPA `lui` returns all legacy.user.interface sub-types; the runbook keeps\n // only the dialog ones (custom.component → create-component, static.template\n // → templateModernization, handled elsewhere).\n bpaSlugs: ['lui'],\n bpaSubtypeFilter: ['legacy.dialog.classic', 'legacy.dialog.coral2'],\n heuristic: true,\n 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.',\n promptPattern: 'lui',\n sampleOverride: 'Use the migration skill: fix my LUI dialog findings — convert Classic UI / ExtJS dialogs and upgrade Coral 2 dialogs to Coral 3.',\n },\n cdw: {\n label: 'Custom Classic Widgets (CDW)',\n severity: 'high',\n strategy: 'content-scan',\n bpaSlugs: ['cdw'],\n heuristic: true,\n 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.',\n promptPattern: 'cdw',\n sampleOverride: 'Use the migration skill: fix my CDW findings — migrate custom ExtJS widgets to Coral 3.',\n },\n templateModernization: {\n label: 'Static → Editable Templates',\n severity: 'medium',\n strategy: 'content-scan',\n bpaSlugs: ['templateModernization'],\n heuristic: true,\n // CAM/BPA cannot report static templates at all, so a zero BPA result is\n // not proof of clean — fall through to the local `.content.xml` content-scan.\n bpaIncomplete: true,\n description: 'Static templates under `apps/\u003cappId>/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.',\n promptPattern: 'template modernization',\n sampleOverride: 'Use the migration skill: migrate my static templates to editable templates and generate the AEM Modernize Tools rewrite rules.',\n },\n guavaCache: {\n label: 'Guava Cache → Caffeine',\n severity: 'info',\n strategy: 'bpa-only',\n bpaSlugs: ['guavaCache'],\n // CAM/BPA reports Guava per-bundle and can filter out the project's own\n // bundles, so a zero BPA result is not proof of clean — fall through to the\n // LLM tier (there is no analyzer/content-scan detector for this pattern).\n bpaIncomplete: true,\n 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).',\n promptPattern: 'guavaCache',\n sampleOverride: 'Use the migration skill: fix guavaCache findings using BPA CSV — swap Guava cache for Caffeine.',\n },\n inputStream: {\n label: 'java.io.InputStream Usage',\n severity: 'medium',\n strategy: 'java-scan',\n // BPA first (CAM, then CSV): BPA rows define the scope and the scanner adds\n // line-level sites for them. Local scan only when BPA has no inputStream rows.\n bpaSlugs: ['inputStream'],\n heuristic: true,\n 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.',\n promptPattern: 'inputStream',\n 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.',\n },\n dispatcherConversion: {\n label: 'Dispatcher AMS/On-prem → AEMaaCS Conversion',\n severity: 'high',\n strategy: 'content-scan',\n bpaSlugs: [],\n heuristic: true,\n 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.',\n promptPattern: 'dispatcher conversion',\n sampleOverride: 'Use the migration skill: convert my dispatcher configuration to AEM as a Cloud Service.',\n },\n oakIndex: {\n label: 'Oak Index Migration',\n severity: 'high',\n strategy: 'bpa-only',\n bpaSlugs: ['oakIndex'],\n 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.',\n promptPattern: 'oakIndex',\n sampleOverride: 'Use the migration skill: fix oakIndex findings using BPA CSV at ./reports/bpa.csv.',\n },\n};\n\n// content-scan strategy → the runner that produces that pattern's findings.\nconst CONTENT_SCANNERS = {\n lui: runLuiScan,\n cdw: runCdwScan,\n templateModernization: runTemplateScan,\n dispatcherConversion: runDispatcherScan,\n};\n\nconst CANONICAL_PATTERNS = Object.keys(PATTERN_META);\n\n/** Normalize a BPA target into the runbook's display shape. */\nfunction normalizeBpaTarget(t) {\n return {\n location: t.className || t.filePath || '—',\n detail: t.identifier || t.issue || '',\n severity: t.severity || 'high',\n };\n}\n\n/**\n * Normalize a BPA target into the raw handoff shape. BPA/CSV/MCP sources\n * carry no line number or code snippet — only `pattern` and `file` are\n * populated, so the apply handoff knows it must still run\n * `analyze.sh --files \u003cpath>` once to resolve `line`/`snippet` before\n * building an edit plan (see code-assessment/references/runbook.md,\n * with_findings (pre-resolved) mode).\n */\nfunction rawBpaTarget(pattern, t) {\n return {\n pattern,\n file: t.filePath || t.className || null,\n line: null,\n snippet: null,\n };\n}\n\n/**\n * Build a BPA-sourced URC (unsupported run mode) finding/raw pair in the SAME\n * shape `scanUnsupportedRunmodes` (the local scanner) emits. BPA's URC target\n * carries the JCR folder path as `className` (e.g. `/apps/demo/config.dev.author`)\n * and only the bare subtype string `unsupported.runmode` as `identifier` — not\n * the offending run mode — so `normalizeBpaTarget`/`rawBpaTarget` would lose the\n * useful detail. Re-derive it by running `validateRunmodeFolder` on the folder\n * basename, same as the local scanner does on disk.\n */\nfunction urcBpaTarget(t) {\n const folderPath = t.className || t.filePath || '—';\n const basename = path.basename(folderPath);\n const bad = validateRunmodeFolder(basename);\n const runmode = bad ? bad.runmode : (basename.includes('.') ? basename.slice(basename.indexOf('.') + 1) : basename);\n const detail = bad\n ? `unsupported-runmode — folder '${basename}' (${bad.reason}); rename to a supported run mode or remove`\n : `unsupported-runmode — ${folderPath}`;\n return {\n finding: { location: folderPath, detail, severity: 'high' },\n raw: {\n pattern: 'osgiConfig',\n file: folderPath,\n line: null,\n snippet: `unsupported run mode '${runmode}'`,\n kind: 'unsupported-runmode',\n runmode,\n },\n };\n}\n\n/**\n * Gather findings for every canonical pattern via the per-pattern cascade.\n *\n * @returns {Promise\u003c{\n * findingsByPattern: Record\u003cstring, Array\u003cobject>>,\n * rawFindingsByPattern: Record\u003cstring, Array\u003c{pattern: string, file: string, line: number|null, snippet: string|null}>>,\n * sourceByPattern: Record\u003cstring, string>, // 'mcp' | 'csv' | 'analyzer'\n * needsLlmScan: string[], // patterns no deterministic source could scan\n * analyzerWarnings: string[],\n * bpaMode: string|null,\n * analyzerUsed: boolean,\n * }>}\n */\nasync function gatherFindings(options = {}) {\n const {\n bpaFilePath,\n collectionsDir = './unified-collections',\n projectId,\n mcpFetcher,\n preFetchedBpa,\n workspaceRoot = process.cwd(),\n analyzeScript = DEFAULT_ANALYZE_SCRIPT,\n getEffectivePom,\n } = options;\n\n const sources = checkAvailableSources({ bpaFilePath, collectionsDir, projectId, mcpFetcher });\n // `preFetchedBpa` is a slug→targets map the agent already fetched from CAM/MCP\n // and passed via the CLI's `--bpa-json` (so no in-process `mcpFetcher` bridge\n // is needed). It counts as a BPA source and is treated as CAM-sourced ('mcp').\n const bpaMode = preFetchedBpa\n ? 'mcp'\n : sources.mcpServer.available\n ? 'mcp'\n : (sources.bpaFile.available || sources.unifiedCollection.available)\n ? 'csv'\n : null;\n\n // Single BPA fetch entry point: read from the pre-fetched map when present\n // (an absent slug key = benign \"pattern not in report\"), else fall back to the\n // live helper (CSV cache / in-process MCP fetcher).\n const fetchBpa = async (slug) => {\n if (preFetchedBpa) {\n const targets = preFetchedBpa[slug];\n return Array.isArray(targets) ? { success: true, targets } : { availablePatterns: true };\n }\n return getBpaFindings(slug, { bpaFilePath, collectionsDir, projectId, mcpFetcher, limit: null, offset: 0 });\n };\n\n const findingsByPattern = {};\n const rawFindingsByPattern = {};\n const sourceByPattern = {};\n const scannedBy = {};\n CANONICAL_PATTERNS.forEach(p => { findingsByPattern[p] = []; rawFindingsByPattern[p] = []; });\n\n const cascadePatterns = CANONICAL_PATTERNS.filter(p => PATTERN_META[p].strategy === 'cascade');\n const bpaPatterns = CANONICAL_PATTERNS.filter(p => (PATTERN_META[p].bpaSlugs || []).length > 0);\n const bpaTargetsByPattern = {};\n const scanWarnings = [];\n\n // ── BPA tier (MCP or CSV) — any pattern with bpaSlugs. When a BPA source is\n // available it owns the verdict; the pattern's local detector (analyzer /\n // content scan) only runs as a fallback when no BPA source scanned it. ──\n if (bpaMode) {\n for (const pattern of bpaPatterns) {\n const { bpaSlugs, bpaSubtypeFilter } = PATTERN_META[pattern];\n const merged = [];\n const mergedRaw = [];\n let realError = false; // a genuine fetch failure (not \"pattern absent\")\n for (const slug of bpaSlugs) {\n const res = await fetchBpa(slug);\n if (res.success && Array.isArray(res.targets)) {\n // A pattern may map to a broader BPA pattern (e.g. `lui` returns all\n // legacy.user.interface sub-types); keep only the ones this runbook\n // pattern owns.\n const targets = bpaSubtypeFilter\n ? res.targets.filter(t => bpaSubtypeFilter.includes(t.identifier))\n : res.targets;\n if (PATTERN_META[pattern].strategy === 'java-scan') {\n // Kept raw for the java-scan merge below (needs className/identifier/bundle).\n (bpaTargetsByPattern[pattern] = bpaTargetsByPattern[pattern] || []).push(...targets);\n }\n merged.push(...targets.map(normalizeBpaTarget));\n mergedRaw.push(...targets.map(t => rawBpaTarget(pattern, t)));\n } else if (res.availablePatterns) {\n // Benign: the report parsed fine but this pattern is absent — a\n // legitimate \"clean\" result. Stay silent.\n } else {\n // Real failure (mcp-error / mcp-server / bpa-file-error / no-source /\n // \"no CSV path\"). Do NOT report as clean.\n realError = true;\n scanWarnings.push(\n `BPA fetch failed for \\`${pattern}\\` (slug \\`${slug}\\`, source: ${res.source || 'unknown'}): ${res.error || res.message || 'unknown error'}`\n );\n }\n }\n\n if (realError && merged.length === 0) {\n // Nothing usable from BPA and the fetch genuinely failed — leave the\n // pattern UNSCANNED so its local detector runs as a fallback (and, if\n // that can't run either, it surfaces in needsLlmScan). Never mark it\n // as a clean BPA scan.\n continue;\n }\n if (merged.length === 0 && PATTERN_META[pattern].bpaIncomplete) {\n // A BPA source is present but STRUCTURALLY cannot fully report this\n // pattern (CAM can't see static templates at all; it filters Guava to\n // specific bundles). A zero result is therefore NOT proof of clean —\n // leave it UNSCANNED so the local fallback runs: content-scan for\n // templateModernization, or the LLM tier for bpa-only guavaCache. If\n // BPA *did* return findings they are kept (below) and own the verdict.\n continue;\n }\n // Either the fetch succeeded (possibly zero findings = genuinely clean)\n // or we still got some findings despite a partial error — BPA owns it.\n findingsByPattern[pattern] = merged;\n rawFindingsByPattern[pattern] = mergedRaw;\n sourceByPattern[pattern] = bpaMode;\n scannedBy[pattern] = bpaMode;\n }\n }\n\n // ── Cascade tier 3: analyzer. Runs for EVERY cascade pattern the analyzer can\n // reach and UNIONs with BPA: BPA stays authoritative for what it reported,\n // and the analyzer fills source-only findings BPA's scan scope missed\n // (deduped by class name). This makes the runbook complete in one pass —\n // the agent no longer has to reconcile \"BPA clean\" against real source by\n // hand. Patterns with no BPA verdict are owned outright by the analyzer. ──\n let analyzerUsed = false;\n const analyzerCanRun = isAnalyzerAvailable(analyzeScript) && !!workspaceRoot;\n\n if (analyzerCanRun && cascadePatterns.length > 0) {\n const result = runAnalyzer(workspaceRoot, { analyzeScript });\n if (result.ok) {\n analyzerUsed = true;\n if (result.warnings && result.warnings.length) scanWarnings.push(...result.warnings);\n for (const pattern of cascadePatterns) {\n const aFind = result.findingsByPattern[pattern] || [];\n const aRaw = result.rawFindingsByPattern[pattern] || [];\n if (!scannedBy[pattern]) {\n // No BPA verdict — the analyzer owns the pattern outright.\n findingsByPattern[pattern] = aFind;\n rawFindingsByPattern[pattern] = aRaw;\n sourceByPattern[pattern] = 'analyzer';\n scannedBy[pattern] = 'analyzer';\n } else if (aRaw.length > 0) {\n // BPA already owns the verdict — append only the source-only findings\n // BPA didn't report (deduped by class name), so a \"BPA clean\" pattern\n // still surfaces real code BPA's scan scope missed.\n const seen = new Set(rawFindingsByPattern[pattern].map(r => classKey(r.file)).filter(Boolean));\n let added = 0;\n aRaw.forEach((r, i) => {\n const k = classKey(r.file);\n if (k && !seen.has(k)) { seen.add(k); rawFindingsByPattern[pattern].push(r); findingsByPattern[pattern].push(aFind[i]); added++; }\n });\n if (added > 0 && !String(sourceByPattern[pattern]).includes('analyzer')) {\n sourceByPattern[pattern] = `${sourceByPattern[pattern]}+analyzer`;\n }\n }\n }\n }\n }\n\n // ── Strategy 'html-scan': htlLint (independent of the cascade) ───────────\n if (CANONICAL_PATTERNS.includes('htlLint') && workspaceRoot) {\n const res = runHtlLint(workspaceRoot);\n if (res.ok) {\n findingsByPattern.htlLint = res.findings;\n rawFindingsByPattern.htlLint = res.rawFindings;\n sourceByPattern.htlLint = 'html-scan';\n scannedBy.htlLint = 'html-scan';\n }\n }\n\n // ── Strategy 'config-scan': osgiConfig (independent of the cascade) ──────\n if (CANONICAL_PATTERNS.includes('osgiConfig') && workspaceRoot) {\n const res = runOsgiConfigScan(workspaceRoot);\n if (res.ok) {\n findingsByPattern.osgiConfig = res.findings;\n rawFindingsByPattern.osgiConfig = res.rawFindings;\n sourceByPattern.osgiConfig = 'config-scan';\n scannedBy.osgiConfig = 'config-scan';\n if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);\n\n // URC (unsupported run modes) — report-first, local safety net. Kept under\n // osgiConfig: the secret/legacy scan above is always local; only this URC\n // sub-portion prefers the BPA report. BPA owns the verdict ONLY when it\n // actually reports URC findings (non-empty targets). A report that is\n // present but reports NO URC rows — pattern absent from a CSV, or an MCP\n // fetch that came back with an empty target list — does NOT by itself\n // prove the project is URC-clean (the report may predate URC detection\n // or be scoped to other patterns), so we warn and still run the local\n // config.*/install.* scan as a safety net. The local scanner also runs\n // when there is no BPA source at all, or the fetch genuinely failed.\n let urcFindings = [];\n let urcRaw = [];\n let bpaOwnsUrc = false;\n if (bpaMode) {\n const urc = await fetchBpa('urc');\n if (urc.success && Array.isArray(urc.targets) && urc.targets.length > 0) {\n const built = urc.targets.map(urcBpaTarget);\n urcFindings = built.map(b => b.finding);\n urcRaw = built.map(b => b.raw);\n bpaOwnsUrc = true;\n } else if (urc.availablePatterns || (urc.success && Array.isArray(urc.targets))) {\n // BPA source present but reported no URC rows — not proof of\n // \"clean\"; warn and fall through to the local safety-net scan below.\n scanWarnings.push(\n '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).'\n );\n }\n // else: real fetch error → fall through to the local scanner.\n }\n if (!bpaOwnsUrc) {\n const localUrc = scanUnsupportedRunmodes(workspaceRoot);\n if (localUrc.ok) { urcFindings = localUrc.findings; urcRaw = localUrc.rawFindings; }\n }\n findingsByPattern.osgiConfig.push(...urcFindings);\n rawFindingsByPattern.osgiConfig.push(...urcRaw);\n }\n }\n\n // ── Strategy 'pom-scan': vault-package-dependencies (independent of the cascade) ──\n if (CANONICAL_PATTERNS.includes('vault-package-dependencies') && workspaceRoot) {\n const res = runVaultPackageScan(workspaceRoot, getEffectivePom ? { getEffectivePom } : undefined);\n // Warnings surface effective-pom resolution failures and text-scan\n // fallbacks — propagate them regardless of ok so a degraded scan is\n // never silent.\n if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);\n if (res.ok) {\n findingsByPattern['vault-package-dependencies'] = res.findings;\n rawFindingsByPattern['vault-package-dependencies'] = res.rawFindings;\n sourceByPattern['vault-package-dependencies'] = 'pom-scan';\n scannedBy['vault-package-dependencies'] = 'pom-scan';\n } else if (res.error) {\n // The scan itself couldn't run against any module — surface the reason\n // and leave the pattern UNSCANNED so it falls through to needsLlmScan\n // rather than being reported as clean.\n scanWarnings.push(`vault-package-dependencies scan did not run: ${res.error}`);\n }\n }\n\n // ── Strategy 'content-scan': lui / cdw / templateModernization / dispatcherConversion\n // (fallback when no BPA source scanned the pattern; dispatcherConversion has no BPA\n // subtype, so it always runs here) ──────────────────────────────────────\n if (workspaceRoot) {\n for (const pattern of CANONICAL_PATTERNS) {\n if (PATTERN_META[pattern].strategy !== 'content-scan') continue;\n if (scannedBy[pattern]) continue; // BPA already provided findings\n const scan = CONTENT_SCANNERS[pattern];\n if (!scan) continue;\n const res = scan(workspaceRoot);\n if (res.ok) {\n findingsByPattern[pattern] = res.findings;\n rawFindingsByPattern[pattern] = res.rawFindings;\n sourceByPattern[pattern] = 'content-scan';\n scannedBy[pattern] = 'content-scan';\n if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);\n }\n }\n }\n\n // ── Strategy 'java-scan': inputStream. Same precedence as the other BPA\n // patterns: when the BPA tier above returned inputStream rows (CAM or CSV),\n // BPA owns the pattern and the scanner only resolves those classes to\n // workspace files for line-level sites (classes outside the workspace\n // collapse to one informational row per bundle). Only when BPA has no\n // rows — no source, a failed fetch, or none reported — does the local\n // scan of every src/main/java file run. ──\n if (workspaceRoot) {\n for (const pattern of CANONICAL_PATTERNS) {\n if (PATTERN_META[pattern].strategy !== 'java-scan') continue;\n const bpaTargets = bpaTargetsByPattern[pattern] || [];\n const res = runInputStreamScan(workspaceRoot, { bpaTargets });\n if (res.warnings && res.warnings.length) scanWarnings.push(...res.warnings);\n if (!res.ok) continue;\n findingsByPattern[pattern] = res.findings;\n rawFindingsByPattern[pattern] = res.rawFindings;\n sourceByPattern[pattern] = bpaTargets.length > 0 ? bpaMode : 'source-scan';\n scannedBy[pattern] = sourceByPattern[pattern];\n }\n }\n\n // ── Tier 4: anything still unscanned → LLM scan (agent handles it) ───────\n const needsLlmScan = CANONICAL_PATTERNS.filter(p => !scannedBy[p]);\n\n return { findingsByPattern, rawFindingsByPattern, sourceByPattern, needsLlmScan, analyzerWarnings: scanWarnings, bpaMode, analyzerUsed };\n}\n\n/** Build the per-pattern copy-paste sample prompt. */\nfunction samplePrompt(pattern, ctx) {\n const meta = PATTERN_META[pattern];\n if (meta.sampleOverride) return meta.sampleOverride;\n const csvClause = ctx.bpaFilePath ? ` (BPA CSV at \\`${ctx.bpaFilePath}\\`)` : '';\n return `Use the migration skill: **${meta.promptPattern}** only${csvClause}.`;\n}\n\n/**\n * Strip a leading `workspaceRoot` prefix from a `file` or `file:line` string so\n * the committed runbook shows portable, workspace-relative paths and does not\n * leak the local filesystem layout. Leaves non-path values untouched (e.g. a\n * BPA `className` FQN, or an already-relative path), so it is safe to apply to\n * every source's output.\n */\nfunction relPath(value, workspaceRoot) {\n if (!value || !workspaceRoot || typeof value !== 'string') return value;\n const root = workspaceRoot.endsWith(path.sep) ? workspaceRoot.slice(0, -1) : workspaceRoot;\n // A finding whose path IS the workspace root (e.g. a Dispatcher config at the\n // project root) must not render as the absolute root path — collapse it to '.'\n // so the committed runbook never leaks the local filesystem layout.\n if (value === root || value === root + path.sep) return '.';\n const prefix = root + path.sep;\n return value.startsWith(prefix) ? value.slice(prefix.length) : value;\n}\n\n/**\n * Reduce a BPA `className` (`com.acme.MyJob`) or an analyzer file path\n * (`core/src/main/java/com/acme/MyJob.java`) to a comparable simple class-name\n * key, so BPA and analyzer findings for the same class can be de-duplicated\n * when the two sources are unioned (see the cascade tier in `gatherFindings`).\n */\nfunction classKey(fileOrClass) {\n if (!fileOrClass || typeof fileOrClass !== 'string') return null;\n const noExt = fileOrClass.replace(/\\.java$/i, '');\n const seg = noExt.split(/[\\\\/.]/).filter(Boolean);\n return seg.length ? seg[seg.length - 1].toLowerCase() : null;\n}\n\n/** Findings that represent outstanding work (excludes informational rows). */\nfunction actionable(findings) {\n return findings.filter(f => !f.informational);\n}\n\n/** Render the runbook markdown. */\nfunction renderRunbook(gathered, ctx = {}) {\n const { findingsByPattern, sourceByPattern, needsLlmScan, analyzerWarnings } = gathered;\n const root = ctx.workspaceRoot;\n const lines = [];\n\n const total = CANONICAL_PATTERNS.reduce((n, p) => n + actionable(findingsByPattern[p]).length, 0);\n const withFindings = CANONICAL_PATTERNS.filter(p => actionable(findingsByPattern[p]).length > 0);\n\n lines.push('# AEM Cloud Service — Migration Runbook');\n lines.push('');\n lines.push('> **Read-only planning document.** It lists the migration findings discovered');\n lines.push('> across your project. Use a pattern\\'s sample prompt to start the');\n lines.push('> one-pattern-per-session apply workflow. No code was changed to produce this.');\n lines.push('');\n\n lines.push('## Summary');\n lines.push('');\n lines.push('| Field | Value |');\n lines.push('|-------|-------|');\n lines.push(`| Generated | ${ctx.generatedAt || new Date().toISOString().replace('T', ' ').slice(0, 19)} UTC |`);\n lines.push(`| Total findings | **${total}** |`);\n lines.push(`| Patterns with findings | **${withFindings.length}** of ${CANONICAL_PATTERNS.length} |`);\n lines.push('');\n\n lines.push('### Findings by pattern');\n lines.push('');\n lines.push('| Pattern | Severity | Findings | Detected via |');\n lines.push('|---------|----------|----------|--------------|');\n for (const p of CANONICAL_PATTERNS) {\n const meta = PATTERN_META[p];\n const count = actionable(findingsByPattern[p]).length;\n const src = needsLlmScan.includes(p)\n ? '_needs LLM scan_'\n : sourceLabel(sourceByPattern[p]);\n lines.push(`| ${meta.label} | ${meta.severity} | ${count === 0 ? '—' : `**${count}**`} | ${src} |`);\n }\n lines.push('');\n\n if (needsLlmScan.length > 0) {\n lines.push(`> ⚠️ No deterministic source could scan: **${needsLlmScan.map(p => PATTERN_META[p].label).join(', ')}**. `);\n lines.push('> Provide a BPA CSV, open the project workspace for the analyzer, or ask the agent to run an LLM scan for these.');\n lines.push('');\n }\n\n lines.push('---');\n lines.push('');\n\n for (const p of CANONICAL_PATTERNS) {\n const targets = findingsByPattern[p];\n if (targets.length === 0) continue;\n const meta = PATTERN_META[p];\n\n const actCount = actionable(targets).length;\n const infoCount = targets.length - actCount;\n lines.push(`## ${meta.label} (\\`${p}\\`)`);\n lines.push('');\n lines.push(`**Severity:** ${meta.severity} | **Findings:** ${actCount}${infoCount > 0 ? ` (+${infoCount} informational)` : ''} | **Detected via:** ${sourceLabel(sourceByPattern[p])}`);\n lines.push('');\n lines.push(meta.description);\n lines.push('');\n if (meta.heuristic) {\n lines.push(`> ⚠️ **Heuristic detection** (${sourceLabel(sourceByPattern[p], 'non-deterministic')}) — these are candidate matches, not compiler/BPA-validated findings. Re-confirm each one before editing.`);\n if (meta.needsReview) {\n lines.push('> Secret **values** are intentionally omitted from this runbook — only key names and locations are shown. Classification happens at apply time.');\n }\n lines.push('');\n }\n lines.push('### Affected');\n lines.push('');\n lines.push('| Location | Detail | Severity |');\n lines.push('|----------|--------|----------|');\n for (const t of targets) {\n const detail = (t.detail || '').replace(/\\|/g, '\\\\|');\n const location = relPath(t.location, root);\n lines.push(`| \\`${location}\\` | \\`${detail || '—'}\\` | ${t.severity || 'high'} |`);\n }\n lines.push('');\n lines.push('### Sample prompt');\n lines.push('');\n lines.push('```');\n lines.push(samplePrompt(p, ctx));\n lines.push('```');\n lines.push('');\n lines.push('---');\n lines.push('');\n }\n\n const clean = CANONICAL_PATTERNS.filter(p => findingsByPattern[p].length === 0 && !needsLlmScan.includes(p));\n if (clean.length > 0) {\n lines.push('## Scanned — no findings');\n lines.push('');\n for (const p of clean) lines.push(`- **${PATTERN_META[p].label}** (\\`${p}\\`)`);\n lines.push('');\n }\n\n if (analyzerWarnings && analyzerWarnings.length > 0) {\n lines.push('## Scan warnings');\n lines.push('');\n for (const w of analyzerWarnings) lines.push(`- ${w}`);\n lines.push('');\n }\n\n lines.push('---');\n lines.push('');\n lines.push('*Generated by the AEM Cloud Service migration skill — runbook-generator.js*');\n return lines.join('\\n');\n}\n\nconst SOURCE_LABEL = {\n mcp: 'BPA / CAM',\n csv: 'BPA CSV',\n analyzer: 'analyzer',\n 'html-scan': 'HTL scan',\n 'config-scan': 'config scan',\n 'content-scan': 'content scan',\n 'source-scan': 'Java source scan',\n llm: 'LLM scan',\n};\n\n/**\n * Human label for a `sourceByPattern` value. Handles unioned sources like\n * `mcp+analyzer` (BPA authoritative, analyzer filled source-only gaps) by\n * joining each part's label.\n */\nfunction sourceLabel(src, fallback = '—') {\n if (!src) return fallback;\n return String(src).split('+').map(s => SOURCE_LABEL[s] || s).join(' + ');\n}\n\n/**\n * Default path for the runbook's raw-findings sidecar cache, written next to\n * the markdown so the apply handoff (migration/SKILL.md Step 3) can reuse\n * already-computed findings for a pattern instead of re-deriving them via\n * `getBpaFindings` or re-running the analyzer.\n */\nconst DEFAULT_CACHE_PATH = './migration-runbook.json';\n\n/**\n * Write the raw-findings sidecar cache. Kept as a separate function (rather\n * than inlined in generateRunbook) so a caller re-rendering after an LLM/rg\n * scan (Tier 4) can refresh the cache without re-running the whole cascade.\n */\nfunction writeRunbookCache(gathered, ctx, cachePath = DEFAULT_CACHE_PATH) {\n const root = ctx.workspaceRoot;\n const cache = {\n generatedAt: ctx.generatedAt,\n // `file` fields below are relative to this root — the apply handoff\n // resolves them with `path.join(workspaceRoot, file)`.\n workspaceRoot: root || null,\n project: ctx.projectId ? { id: ctx.projectId, name: ctx.projectName || null } : null,\n sourceByPattern: gathered.sourceByPattern,\n findingsByPattern: {},\n };\n // Copy raw findings, relativizing `file` and tagging heuristic patterns\n // (regex-scanned htlLint, config-scanned osgiConfig) so the apply handoff\n // knows they are candidate matches, not analyzer/BPA-grade findings, and\n // must re-confirm each one.\n for (const [pattern, findings] of Object.entries(gathered.rawFindingsByPattern)) {\n const meta = PATTERN_META[pattern];\n const heuristic = !!(meta && meta.heuristic);\n cache.findingsByPattern[pattern] = findings.map(f => {\n const rel = { ...f, file: relPath(f.file, root) };\n if (heuristic) rel.confidence = 'heuristic';\n return rel;\n });\n }\n fs.writeFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf8');\n return cachePath;\n}\n\n/**\n * Merge agent-supplied LLM-scan findings into a `gathered` object in one shot,\n * so the Tier-4 fallback needs no hand-mutation of `gathered` internals or a\n * second `renderRunbook`/`writeRunbookCache` round-trip. `llmByPattern` maps a\n * canonical pattern id to an array of raw findings `{file, line?, snippet?,\n * detail?, severity?}`; the display shape is derived from each. The pattern is\n * marked `source: 'llm'` and dropped from `needsLlmScan`.\n */\nfunction mergeLlmFindings(gathered, llmByPattern) {\n for (const [pattern, raws] of Object.entries(llmByPattern || {})) {\n if (!CANONICAL_PATTERNS.includes(pattern)) continue;\n const rawArr = Array.isArray(raws) ? raws : [];\n gathered.rawFindingsByPattern[pattern] = rawArr.map(r => ({\n pattern, file: r.file || null, line: r.line ?? null, snippet: r.snippet ?? null,\n }));\n gathered.findingsByPattern[pattern] = rawArr.map(r => ({\n location: (r.file && r.line) ? `${r.file}:${r.line}` : (r.file || '—'),\n detail: r.detail || r.snippet || '',\n severity: r.severity || 'high',\n }));\n gathered.sourceByPattern[pattern] = 'llm';\n gathered.needsLlmScan = gathered.needsLlmScan.filter(p => p !== pattern);\n }\n return gathered;\n}\n\n/**\n * Generate the runbook end to end.\n *\n * @returns {Promise\u003c{\n * success: boolean, outputPath?: string, cachePath?: string, message: string,\n * totalFindings: number, patternCounts: object,\n * needsLlmScan: string[], gathered: object,\n * }>}\n * When `needsLlmScan` is non-empty, LLM-scan those patterns and pass the\n * results back as the `llmByPattern` option (CLI: `--llm-findings \u003cfile>`) —\n * the generator merges, re-renders, and rewrites the cache in one pass.\n */\nasync function generateRunbook(options = {}) {\n const { outputPath = './migration-runbook.md', cachePath = DEFAULT_CACHE_PATH, bpaFilePath, workspaceRoot, llmByPattern, projectId, projectName } = options;\n\n const gathered = await gatherFindings(options);\n if (llmByPattern) mergeLlmFindings(gathered, llmByPattern);\n const ctx = {\n bpaFilePath,\n workspaceRoot,\n projectId,\n projectName,\n generatedAt: new Date().toISOString().replace('T', ' ').slice(0, 19),\n };\n\n const markdown = renderRunbook(gathered, ctx);\n fs.writeFileSync(outputPath, markdown, 'utf8');\n writeRunbookCache(gathered, ctx, cachePath);\n\n // Counts reflect outstanding work — informational findings (e.g. OSGi\n // configs already using placeholders) are excluded from the headline.\n const patternCounts = {};\n let totalFindings = 0;\n for (const p of CANONICAL_PATTERNS) {\n patternCounts[p] = actionable(gathered.findingsByPattern[p]).length;\n totalFindings += patternCounts[p];\n }\n\n return {\n success: true,\n outputPath,\n cachePath,\n message: `Runbook written to ${outputPath} — ${totalFindings} findings across ${CANONICAL_PATTERNS.filter(p => patternCounts[p] > 0).length} pattern(s).`,\n totalFindings,\n patternCounts,\n needsLlmScan: gathered.needsLlmScan,\n gathered,\n };\n}\n\n// CLI\nfunction parseArgs(argv) {\n const out = { workspaceRoot: undefined, bpaFilePath: undefined, outputPath: './migration-runbook.md', cachePath: DEFAULT_CACHE_PATH, bpaJsonPath: undefined, llmFindingsPath: undefined, projectId: undefined, projectName: undefined };\n for (let i = 0; i \u003c argv.length; i++) {\n const a = argv[i];\n if (a === '--csv') out.bpaFilePath = argv[++i];\n else if (a === '--out') out.outputPath = argv[++i];\n else if (a === '--cache') out.cachePath = argv[++i];\n else if (a === '--bpa-json') out.bpaJsonPath = argv[++i];\n else if (a === '--llm-findings') out.llmFindingsPath = argv[++i];\n else if (a === '--project-id') out.projectId = argv[++i];\n else if (a === '--project-name') out.projectName = argv[++i];\n else if (!out.workspaceRoot) out.workspaceRoot = a;\n }\n if (!out.workspaceRoot) out.workspaceRoot = process.cwd();\n return out;\n}\n\nasync function main() {\n const { workspaceRoot, bpaFilePath, outputPath, cachePath, bpaJsonPath, llmFindingsPath, projectId, projectName } = parseArgs(process.argv.slice(2));\n\n // Pre-fetched BPA (agent dumped its CAM/MCP result to JSON) and LLM-scan\n // findings are the two inputs that let the whole runbook run as ONE command,\n // with no in-process require()/mcpFetcher bridge and no Tier-4 re-render.\n let preFetchedBpa;\n if (bpaJsonPath) preFetchedBpa = JSON.parse(fs.readFileSync(bpaJsonPath, 'utf8'));\n let llmByPattern;\n if (llmFindingsPath) llmByPattern = JSON.parse(fs.readFileSync(llmFindingsPath, 'utf8'));\n\n console.log('Migration Runbook Generator');\n console.log('===========================');\n console.log(`Workspace: ${workspaceRoot}`);\n if (bpaFilePath) console.log(`BPA CSV: ${bpaFilePath}`);\n if (bpaJsonPath) console.log(`BPA JSON: ${bpaJsonPath}`);\n if (llmFindingsPath) console.log(`LLM find.: ${llmFindingsPath}`);\n console.log(`Output: ${outputPath}`);\n console.log(`Cache: ${cachePath}`);\n console.log('');\n\n const result = await generateRunbook({ workspaceRoot, bpaFilePath, outputPath, cachePath, preFetchedBpa, llmByPattern, projectId, projectName });\n\n console.log(`✅ ${result.message}`);\n console.log(` Findings cache: ${result.cachePath}`);\n console.log('');\n console.log('Pattern breakdown:');\n for (const [pattern, count] of Object.entries(result.patternCounts)) {\n const src = result.gathered.needsLlmScan.includes(pattern)\n ? 'needs-llm-scan'\n : (result.gathered.sourceByPattern[pattern] || '—');\n console.log(` ${pattern.padEnd(25)} ${String(count).padStart(3)} (${src})`);\n }\n if (result.needsLlmScan.length > 0) {\n console.log('');\n console.log(`⚠️ Needs LLM scan: ${result.needsLlmScan.join(', ')}`);\n console.log(' LLM-scan these, write the results to a JSON file (pattern → [{file, line, snippet}]),');\n console.log(' then re-run with `--llm-findings \u003cfile>` to fold them in — no manual merge needed.');\n }\n}\n\nif (require.main === module) {\n main().catch(err => { console.error(err); process.exit(1); });\n}\n\nmodule.exports = {\n generateRunbook,\n parseArgs,\n gatherFindings,\n renderRunbook,\n writeRunbookCache,\n mergeLlmFindings,\n samplePrompt,\n relPath,\n classKey,\n sourceLabel,\n CANONICAL_PATTERNS,\n PATTERN_META,\n DEFAULT_CACHE_PATH,\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":50764,"content_sha256":"a99e6e74e4695e8cfe2ba1ae820e46a5e9605fb52e9a53f9949a6386b0683c8a"},{"filename":"scripts/runbook-generator.test.js","content":"'use strict';\n\nconst { test } = require('node:test');\nconst assert = require('node:assert');\nconst fs = require('fs');\nconst os = require('os');\nconst path = require('path');\n\nconst { runHtlLint, classify } = require('./htl-lint-runner.js');\nconst { runOsgiConfigScan, validateRunmodeFolder, scanUnsupportedRunmodes, reorderRunmodeFolder, planRunmodeReorders } = require('./osgi-config-runner.js');\nconst { runVaultPackageScan, isLegacyGroup } = require('./vault-package-scan-runner.js');\nconst { runLuiScan, runCdwScan } = require('./legacy-ui-runner.js');\nconst { runTemplateScan, classifyStaticTemplate } = require('./template-scan-runner.js');\nconst { runAnalyzer, isAnalyzerAvailable, DEFAULT_ANALYZE_SCRIPT } = require('./analyzer-runner.js');\nconst { getBpaFindings } = require('./bpa-findings-helper.js');\nconst {\n gatherFindings, generateRunbook, renderRunbook, writeRunbookCache, mergeLlmFindings,\n samplePrompt, relPath, classKey, sourceLabel, CANONICAL_PATTERNS, PATTERN_META,\n} = require('./runbook-generator.js');\n\nfunction mkworkspace() {\n return fs.mkdtempSync(path.join(os.tmpdir(), 'runbook-test-'));\n}\nfunction write(root, rel, content) {\n const full = path.join(root, rel);\n fs.mkdirSync(path.dirname(full), { recursive: true });\n fs.writeFileSync(full, content, 'utf8');\n return full;\n}\n\n// ── Pattern registry ────────────────────────────────────────────────────────\n\ntest('registry includes all 15 migration patterns with a valid strategy', () => {\n const expected = [\n 'scheduler', 'resourceChangeListener', 'event-migration', 'assetApi', 'replication',\n 'vault-package-dependencies', 'htlLint', 'osgiConfig', 'lui', 'cdw', 'templateModernization',\n 'guavaCache', 'inputStream', 'dispatcherConversion', 'oakIndex',\n ];\n assert.strictEqual(CANONICAL_PATTERNS.length, expected.length, 'no unexpected patterns');\n for (const key of expected) {\n assert.ok(CANONICAL_PATTERNS.includes(key), `${key} in CANONICAL_PATTERNS`);\n assert.ok(\n ['cascade', 'html-scan', 'config-scan', 'content-scan', 'pom-scan', 'bpa-only', 'java-scan'].includes(PATTERN_META[key].strategy),\n `${key} has a valid strategy`\n );\n }\n assert.strictEqual(PATTERN_META.dispatcherConversion.strategy, 'content-scan');\n assert.deepStrictEqual(PATTERN_META.dispatcherConversion.bpaSlugs, []);\n});\n\ntest('guavaCache has no analyzer/content-scan fallback — bpaSlugs only, no heuristic flag', () => {\n assert.strictEqual(PATTERN_META.guavaCache.strategy, 'bpa-only');\n assert.deepStrictEqual(PATTERN_META.guavaCache.bpaSlugs, ['guavaCache']);\n assert.ok(!PATTERN_META.guavaCache.heuristic, 'guavaCache findings are BPA-authoritative, not heuristic');\n});\n\ntest('oakIndex has no analyzer/content-scan fallback — bpaSlugs only, no heuristic flag', () => {\n assert.strictEqual(PATTERN_META.oakIndex.strategy, 'bpa-only');\n assert.deepStrictEqual(PATTERN_META.oakIndex.bpaSlugs, ['oakIndex']);\n assert.ok(!PATTERN_META.oakIndex.heuristic, 'oakIndex findings are BPA-authoritative, not heuristic');\n});\n\ntest('inputStream is a java-scan pattern that merges BPA rows (no LLM tier, no bpaIncomplete)', () => {\n assert.strictEqual(PATTERN_META.inputStream.strategy, 'java-scan');\n assert.deepStrictEqual(PATTERN_META.inputStream.bpaSlugs, ['inputStream']);\n assert.ok(PATTERN_META.inputStream.heuristic, 'site classification is regex-based — re-confirmed at apply time');\n assert.ok(!PATTERN_META.inputStream.bpaIncomplete);\n});\n\ntest('inject-in-sling-model and outdated-dependencies stay out of scope', () => {\n assert.ok(!CANONICAL_PATTERNS.includes('inject-in-sling-model'));\n assert.ok(!CANONICAL_PATTERNS.includes('outdated-dependencies'));\n});\n\ntest('vault-package-dependencies is pom-scan with no BPA tier and no analyzer', () => {\n assert.strictEqual(PATTERN_META['vault-package-dependencies'].strategy, 'pom-scan');\n assert.deepStrictEqual(PATTERN_META['vault-package-dependencies'].bpaSlugs, []);\n assert.ok(PATTERN_META['vault-package-dependencies'].heuristic, 'regex/text scan, not compiler-validated');\n});\n\n// ── vault-package-scan-runner ────────────────────────────────────────────────\n//\n// The runner delegates POM resolution to `mvn help:effective-pom`, so tests\n// inject a stub `getEffectivePom` that returns canned effective-POM XML. The\n// source `pom.xml` is still written to the workspace so the runner can look\n// up the source line for the legacy `\u003cgroup>` tag.\n\n/** Build a stub getEffectivePom that returns canned XML per projectDir. */\nfunction stubEffective(byDir) {\n return (projectDir) => {\n if (Object.prototype.hasOwnProperty.call(byDir, projectDir)) {\n return { ok: true, xml: byDir[projectDir] };\n }\n return { ok: false, error: `no stub for ${projectDir}` };\n };\n}\n\ntest('isLegacyGroup matches day/cq60, day/cq560, adobe/cq60 prefixes only', () => {\n assert.ok(isLegacyGroup('day/cq60/product'));\n assert.ok(isLegacyGroup('day/cq560/social/commons'));\n assert.ok(isLegacyGroup('adobe/cq60'));\n assert.ok(!isLegacyGroup('adobe/aem6/sample'));\n assert.ok(!isLegacyGroup('com.example'));\n assert.ok(!isLegacyGroup(null));\n});\n\ntest('runVaultPackageScan flags a legacy \u003cdependencies> block, one finding per block', () => {\n const root = mkworkspace();\n const module = path.join(root, 'ui.apps');\n const pom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003cname>cq-content\u003c/name>\u003c/dependency>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003cname>cq-commerce-content\u003c/name>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'ui.apps/pom.xml', pom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [module]: pom }) });\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.findings.length, 1, 'one finding per \u003cdependencies> block, not per \u003cdependency>');\n assert.match(res.findings[0].detail, /day\\/cq60\\/product/);\n assert.strictEqual(res.rawFindings[0].pattern, 'vault-package-dependencies');\n});\n\ntest('runVaultPackageScan flags a legacy \u003cdependencies> block under filevault-package-maven-plugin', () => {\n const root = mkworkspace();\n const module = path.join(root, 'ui.apps');\n const pom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>filevault-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003cname>cq-content\u003c/name>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'ui.apps/pom.xml', pom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [module]: pom }) });\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.findings.length, 1, 'filevault-package-maven-plugin must be matched, not silently skipped');\n assert.match(res.findings[0].detail, /day\\/cq60\\/product/);\n assert.match(res.findings[0].detail, /^filevault-package-maven-plugin:/, 'snippet must name the plugin that matched');\n});\n\ntest('runVaultPackageScan flags the block after \u003cpluginManagement> is merged by Maven', () => {\n // Source pom declares the plugin in both \u003cpluginManagement> (no deps) and\n // \u003cbuild> (with deps). Maven's effective pom is what the runner sees: a\n // single merged \u003cbuild> entry — no shadow \u003cpluginManagement> occurrence.\n const root = mkworkspace();\n const sourcePom = [\n '\u003cproject>', ' \u003cbuild>',\n ' \u003cpluginManagement>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>\u003cverbose>true\u003c/verbose>\u003c/configuration>',\n ' \u003c/plugin>', ' \u003c/plugins>', ' \u003c/pluginManagement>',\n ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003cname>cq-content\u003c/name>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n const effectivePom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>',\n ' \u003cverbose>true\u003c/verbose>',\n ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003cname>cq-content\u003c/name>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', sourcePom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: effectivePom }) });\n assert.strictEqual(res.findings.length, 1);\n assert.match(res.findings[0].detail, /day\\/cq60\\/product/);\n});\n\ntest('runVaultPackageScan flags plugins that only appear in an activated \u003cprofile>', () => {\n // Profiles are not merged into \u003cbuild> in the effective pom unless activated\n // — the runner must still scan every \u003cplugin> occurrence, including those\n // inside a \u003cprofiles>\u003cprofile>\u003cbuild> section.\n const root = mkworkspace();\n const pom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>',\n ' \u003cprofiles>', ' \u003cprofile>', ' \u003cid>legacy-6x\u003c/id>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>filevault-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq560/social/commons\u003c/group>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', ' \u003c/profile>', ' \u003c/profiles>',\n '\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', pom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: pom }) });\n assert.strictEqual(res.findings.length, 2, 'both \u003cbuild> and \u003cprofiles> plugin blocks must be flagged');\n const groups = res.findings.map(f => f.detail).join(' | ');\n assert.match(groups, /day\\/cq60\\/product/);\n assert.match(groups, /day\\/cq560\\/social\\/commons/);\n});\n\ntest('runVaultPackageScan ignores a per-\u003cexecution> \u003cconfiguration> — only plugin-level counts', () => {\n // Maven's effective pom keeps executions separate from the plugin-level\n // configuration. The runner explicitly scopes to everything before\n // \u003cexecutions>, so a per-execution \u003cconfiguration> with a legacy-looking\n // \u003cdependencies> can't shadow or forge a finding at plugin-level.\n const root = mkworkspace();\n const effectivePom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>',\n ' \u003cverbose>true\u003c/verbose>',\n ' \u003c/configuration>',\n ' \u003cexecutions>', ' \u003cexecution>', ' \u003cid>install-package\u003c/id>',\n ' \u003cconfiguration>',\n ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>',\n ' \u003c/dependencies>',\n ' \u003c/configuration>',\n ' \u003c/execution>', ' \u003c/executions>',\n ' \u003c/plugin>', ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', effectivePom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: effectivePom }) });\n assert.strictEqual(res.findings.length, 0, 'per-execution \u003cconfiguration> is not install-time deps');\n});\n\ntest('runVaultPackageScan reports the source-pom line where the legacy \u003cgroup> appears', () => {\n const root = mkworkspace();\n const lines = [\n '\u003cproject>', // 1\n ' \u003cbuild>', // 2\n ' \u003cplugins>', // 3\n ' \u003cplugin>', // 4\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>', // 5 \u003c- artifactId line\n ' \u003cconfiguration>', // 6\n ' \u003cdependencies>', // 7\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>', // 8 \u003c- legacy group line\n ' \u003c/dependencies>', // 9\n ' \u003c/configuration>', // 10\n ' \u003c/plugin>', // 11\n ' \u003c/plugins>', // 12\n ' \u003c/build>', // 13\n '\u003c/project>', // 14\n ];\n const pom = lines.join('\\n');\n write(root, 'pom.xml', pom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: pom }) });\n assert.strictEqual(res.findings.length, 1);\n assert.strictEqual(res.rawFindings[0].line, 8, 'expected line 8 (the legacy \u003cgroup> tag), not the \u003cartifactId> line');\n});\n\ntest('runVaultPackageScan flags inherited blocks with (inherited) marker and line 1', () => {\n // The customer's own pom.xml doesn't contain the legacy block — it comes\n // from a parent pom Maven pulls in. The runner still detects it via the\n // effective pom and reports the source pom with the (inherited) marker.\n const root = mkworkspace();\n const sourcePom = [\n '\u003cproject>',\n ' \u003cparent>\u003cgroupId>com.example\u003c/groupId>\u003cartifactId>legacy-parent\u003c/artifactId>\u003cversion>1.0\u003c/version>\u003c/parent>',\n ' \u003cartifactId>my-module\u003c/artifactId>',\n '\u003c/project>',\n ].join('\\n');\n const effectivePom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003cname>cq-content\u003c/name>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', sourcePom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: effectivePom }) });\n assert.strictEqual(res.findings.length, 1);\n assert.match(res.findings[0].detail, /\\(inherited\\)/, 'inherited blocks must be marked');\n assert.strictEqual(res.rawFindings[0].line, 1, 'inherited blocks anchor at line 1 of the source pom');\n});\n\ntest('runVaultPackageScan does not flag a legacy group under an unrelated plugin', () => {\n const root = mkworkspace();\n const pom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>some-other-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003cplugin>', ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>\u003cgroup>adobe/aem6/sample\u003c/group>\u003c/configuration>',\n ' \u003c/plugin>', ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', pom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: pom }) });\n assert.strictEqual(res.findings.length, 0, 'legacy group under a different plugin must not match');\n});\n\ntest('runVaultPackageScan returns empty (ok) when no pom.xml has the plugin', () => {\n const root = mkworkspace();\n const pom = '\u003cproject>\u003c/project>\\n';\n write(root, 'pom.xml', pom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: pom }) });\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.findings.length, 0);\n});\n\ntest('runVaultPackageScan surfaces effective-pom resolution failures as warnings, not errors', () => {\n const root = mkworkspace();\n write(root, 'pom.xml', '\u003cproject>\u003cparent>\u003cartifactId>missing\u003c/artifactId>\u003c/parent>\u003c/project>\\n');\n const res = runVaultPackageScan(root, {\n getEffectivePom: () => ({ ok: false, error: 'could not resolve parent com.example:missing:1.0' }),\n });\n assert.strictEqual(res.ok, true, 'a single unresolvable pom does not fail the whole scan');\n assert.strictEqual(res.findings.length, 0);\n assert.strictEqual(res.warnings.length, 1);\n assert.match(res.warnings[0], /could not resolve/);\n});\n\ntest('runVaultPackageScan text-scans raw pom.xml when effective-pom fails, so legacy blocks still surface', () => {\n // Legacy AEM 6.x / AMS projects (the target audience) frequently can't\n // resolve a Maven build anymore. The runner falls back to a raw text\n // scan so the pattern doesn't silently report clean, and emits a warning\n // announcing the degraded coverage.\n const root = mkworkspace();\n const pom = [\n '\u003cproject>', ' \u003cbuild>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003cname>cq-content\u003c/name>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>', ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', pom);\n const res = runVaultPackageScan(root, {\n getEffectivePom: () => ({ ok: false, error: 'offline: cannot download parent' }),\n });\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.findings.length, 1, 'raw text-scan must still detect the legacy block');\n assert.match(res.findings[0].detail, /day\\/cq60\\/product/);\n assert.strictEqual(res.warnings.length, 1);\n assert.match(res.warnings[0], /text-scanning raw pom\\.xml/);\n});\n\ntest('runVaultPackageScan skips \u003cpluginManagement> so effective-pom does not double-report', () => {\n // Effective POM retains \u003cpluginManagement> even after merging its config\n // into \u003cbuild>\u003cplugins>. Without the skip the same block is reported\n // once from pluginManagement and once from the merged \u003cbuild> entry.\n const root = mkworkspace();\n const effectivePom = [\n '\u003cproject>', ' \u003cbuild>',\n ' \u003cpluginManagement>', ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>',\n ' \u003c/plugin>', ' \u003c/plugins>', ' \u003c/pluginManagement>',\n ' \u003cplugins>', ' \u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>', ' \u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>',\n ' \u003c/dependencies>', ' \u003c/configuration>',\n ' \u003c/plugin>',\n ' \u003c/plugins>', ' \u003c/build>', '\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', effectivePom);\n const res = runVaultPackageScan(root, { getEffectivePom: stubEffective({ [root]: effectivePom }) });\n assert.strictEqual(res.findings.length, 1, '\u003cpluginManagement> copy must not create a duplicate finding');\n});\n\ntest('runVaultPackageScan returns ok:false when no module could be scanned (false-clean guard)', () => {\n // Effective POM fails AND the raw pom.xml can't be read — the pattern\n // was NOT scanned, so the runner must return ok:false rather than\n // ok:true / zero findings, otherwise the runbook would mark the\n // pattern as clean.\n const root = mkworkspace();\n const pomPath = path.join(root, 'pom.xml');\n write(root, 'pom.xml', '\u003cproject/>');\n // Break read access so the text-scan fallback can't succeed either.\n const originalRead = fs.readFileSync;\n fs.readFileSync = (p, ...rest) => {\n if (p === pomPath) throw new Error('EACCES: permission denied');\n return originalRead(p, ...rest);\n };\n try {\n const res = runVaultPackageScan(root, {\n getEffectivePom: () => ({ ok: false, error: 'mvn missing' }),\n });\n assert.strictEqual(res.ok, false, 'a scan that ran against 0 modules must not report ok:true');\n assert.match(res.error || '', /not scanned/);\n } finally {\n fs.readFileSync = originalRead;\n }\n});\n\ntest('runVaultPackageScan uses the reactor bulk path when a \u003cmodules> pom is present', () => {\n // A reactor invocation costs one mvn call for every module in the\n // reactor, not one per module. The runner picks up the reactor via\n // the source pom's \u003cmodules> declaration and pairs modules to their\n // effective-pom slice via \u003cartifactId>.\n const root = mkworkspace();\n const rootPom = [\n '\u003cproject>',\n ' \u003cartifactId>myapp-reactor\u003c/artifactId>',\n ' \u003cmodules>\u003cmodule>ui.apps\u003c/module>\u003cmodule>ui.content\u003c/module>\u003c/modules>',\n '\u003c/project>',\n ].join('\\n');\n const uiAppsPom = '\u003cproject>\u003cartifactId>myapp-ui.apps\u003c/artifactId>\u003c/project>\\n';\n const uiContentPom = '\u003cproject>\u003cartifactId>myapp-ui.content\u003c/artifactId>\u003c/project>\\n';\n write(root, 'pom.xml', rootPom);\n write(root, 'ui.apps/pom.xml', uiAppsPom);\n write(root, 'ui.content/pom.xml', uiContentPom);\n const uiAppsEffective = [\n '\u003cproject>', ' \u003cartifactId>myapp-ui.apps\u003c/artifactId>',\n ' \u003cbuild>\u003cplugins>\u003cplugin>',\n ' \u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n ' \u003cconfiguration>\u003cdependencies>',\n ' \u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>',\n ' \u003c/dependencies>\u003c/configuration>',\n ' \u003c/plugin>\u003c/plugins>\u003c/build>', '\u003c/project>',\n ].join('\\n');\n const uiContentEffective = '\u003cproject>\u003cartifactId>myapp-ui.content\u003c/artifactId>\u003c/project>';\n const rootEffective = '\u003cproject>\u003cartifactId>myapp-reactor\u003c/artifactId>\u003c/project>';\n let reactorCalls = 0;\n let perModuleCalls = 0;\n const res = runVaultPackageScan(root, {\n getReactorEffectivePoms: (dir) => {\n reactorCalls++;\n assert.strictEqual(dir, root, 'reactor call must fire at the reactor root, not per-module');\n return { ok: true, byArtifactId: {\n 'myapp-reactor': rootEffective,\n 'myapp-ui.apps': uiAppsEffective,\n 'myapp-ui.content': uiContentEffective,\n } };\n },\n getEffectivePom: () => { perModuleCalls++; return { ok: false, error: 'should not be called' }; },\n });\n assert.strictEqual(reactorCalls, 1, 'exactly one reactor mvn call, not per-module');\n assert.strictEqual(perModuleCalls, 0, 'per-module fallback must not run when reactor bulk covers every module');\n assert.strictEqual(res.findings.length, 1);\n assert.match(res.findings[0].location, /ui\\.apps\\/pom\\.xml/);\n});\n\n// ── htl-lint-runner ───────────────────────────────────────────────────────\n\ntest('classify labels each anti-pattern class', () => {\n assert.strictEqual(classify('\u003cdiv data-sly-test=\"${x == true}\">'), 'boolean-literal');\n assert.strictEqual(classify(\"\u003cdiv data-sly-test='/apps/foo'>\"), 'apps-string-as-test');\n assert.strictEqual(classify('\u003cdiv data-sly-test=\"${a} || ${b}\">'), 'split-logical-across-expr');\n assert.strictEqual(classify('\u003cdiv data-sly-test=\"${x == 5}\">'), 'numeric-literal');\n});\n\ntest('runHtlLint finds data-sly-test anti-patterns in .html', () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/comp/hero.html', '\u003cdiv data-sly-test=\"${properties.x == true}\">hi\u003c/div>\\n');\n write(root, 'ui.apps/jcr_root/apps/comp/clean.html', '\u003cdiv data-sly-test=\"${properties.enabled}\">ok\u003c/div>\\n');\n const res = runHtlLint(root);\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.findings.length, 1);\n assert.match(res.findings[0].detail, /boolean-literal/);\n assert.strictEqual(res.rawFindings[0].pattern, 'htlLint');\n assert.ok(res.rawFindings[0].line > 0);\n});\n\ntest('runHtlLint returns empty (ok) when nothing matches', () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/comp/clean.html', '\u003cdiv data-sly-test=\"${properties.enabled}\">ok\u003c/div>\\n');\n const res = runHtlLint(root);\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.findings.length, 0);\n});\n\n// ── osgi-config-runner ──────────────────────────────────────────────────────\n\ntest('runOsgiConfigScan flags plaintext secret keys WITHOUT leaking the value', () => {\n const root = mkworkspace();\n const secretValue = 'sup3rSecretP@ss';\n write(root, 'ui.config/src/main/content/jcr_root/apps/my/config/com.my.Svc.cfg.json',\n `{\\n \"api-key\": \"${secretValue}\",\\n \"endpoint\": \"https://example.com\"\\n}\\n`);\n const res = runOsgiConfigScan(root);\n assert.strictEqual(res.ok, true);\n const secretFinding = res.rawFindings.find(f => f.kind === 'plaintext-secret');\n assert.ok(secretFinding, 'a plaintext-secret finding is produced');\n assert.match(secretFinding.snippet, /\u003credacted>/);\n // The value must never appear anywhere in the output.\n const blob = JSON.stringify(res);\n assert.ok(!blob.includes(secretValue), 'secret value must not leak into findings');\n});\n\ntest('runOsgiConfigScan flags already-placeholdered files and legacy formats', () => {\n const root = mkworkspace();\n write(root, 'ui.config/src/main/content/jcr_root/apps/my/config.publish/com.my.Placeheld.cfg.json',\n '{\\n \"password\": \"$[secret:my-pw]\"\\n}\\n');\n write(root, 'ui.config/src/main/content/jcr_root/apps/my/config/com.my.Legacy.config',\n 'my.prop=\"value\"\\n');\n const res = runOsgiConfigScan(root);\n const kinds = res.rawFindings.map(f => f.kind);\n assert.ok(kinds.includes('already-placeholdered'));\n assert.ok(kinds.includes('legacy-format'));\n // A key already using a placeholder is NOT flagged as plaintext-secret.\n assert.ok(!kinds.includes('plaintext-secret'));\n});\n\ntest('runOsgiConfigScan ignores non-config folders and repoinit secret keys', () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/components/notaconfig.cfg.json', '{ \"password\": \"x\" }\\n');\n write(root, 'ui.config/jcr_root/apps/my/config/org.apache.sling.jcr.repoinit.RepositoryInitializer-my.cfg.json',\n '{ \"scripts\": [\"create user with password abc\"] }\\n');\n const res = runOsgiConfigScan(root);\n assert.ok(!res.rawFindings.some(f => f.kind === 'plaintext-secret'),\n 'files outside config folders and repoinit files are not flagged for plaintext secrets');\n});\n\n// ── URC: run-mode folder validation ─────────────────────────────────────────\n\ntest('validateRunmodeFolder accepts valid supported run-mode folders', () => {\n for (const name of ['config', 'install', 'config.author', 'config.publish',\n 'config.dev', 'config.stage', 'config.prod', 'config.author.dev',\n 'config.publish.prod', 'install.author', 'install.publish.stage']) {\n assert.strictEqual(validateRunmodeFolder(name), null, `${name} should be valid`);\n }\n});\n\ntest('validateRunmodeFolder flags tier-after-environment ordering violations', () => {\n const bad = validateRunmodeFolder('config.dev.author');\n assert.ok(bad, 'config.dev.author is unsupported');\n assert.strictEqual(bad.runmode, 'dev.author');\n assert.match(bad.reason, /must precede/i);\n});\n\ntest('validateRunmodeFolder flags unknown tokens and preview', () => {\n assert.ok(validateRunmodeFolder('config.preprod'), 'preprod is unknown');\n assert.ok(validateRunmodeFolder('config.author.preprod'), 'preprod after author still unknown');\n assert.ok(validateRunmodeFolder('install.local'), 'local is unknown');\n assert.ok(validateRunmodeFolder('config.preview'), 'preview cannot be declared');\n});\n\ntest('validateRunmodeFolder flags duplicate tier/environment tokens', () => {\n assert.match(validateRunmodeFolder('config.author.publish').reason, /tier/i);\n assert.match(validateRunmodeFolder('config.author.dev.stage').reason, /environment/i);\n});\n\ntest('validateRunmodeFolder is case-sensitive — capitalized tokens are unsupported', () => {\n const bad1 = validateRunmodeFolder('config.Author.dev');\n assert.ok(bad1, 'config.Author.dev is unsupported (case-sensitive)');\n assert.match(bad1.reason, /'Author'/);\n const bad2 = validateRunmodeFolder('config.PUBLISH');\n assert.ok(bad2, 'config.PUBLISH is unsupported (case-sensitive)');\n assert.match(bad2.reason, /'PUBLISH'/);\n});\n\ntest('scanUnsupportedRunmodes flags unsupported config/install folders only', () => {\n const root = mkworkspace();\n write(root, 'ui.config/jcr_root/apps/my/config.dev.author/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n write(root, 'ui.apps/jcr_root/apps/my/install.local/my-bundle.jar', 'x');\n write(root, 'ui.config/jcr_root/apps/my/config.author.dev/com.my.Ok.cfg.json', '{ \"a\": 1 }\\n');\n write(root, 'ui.apps/jcr_root/apps/my/install.publish/ok-bundle.jar', 'x');\n const res = scanUnsupportedRunmodes(root);\n assert.strictEqual(res.ok, true);\n const kinds = res.rawFindings.map(f => f.kind);\n assert.ok(kinds.every(k => k === 'unsupported-runmode'));\n const runmodes = res.rawFindings.map(f => f.runmode).sort();\n assert.deepStrictEqual(runmodes, ['dev.author', 'local']);\n});\n\ntest('validateRunmodeFolder flags malformed run-mode folder names', () => {\n assert.match(validateRunmodeFolder('config.').reason, /malformed/i);\n assert.match(validateRunmodeFolder('config..dev').reason, /malformed/i);\n // Bare config/install (no dot) and non-config names stay out of scope.\n assert.strictEqual(validateRunmodeFolder('config'), null);\n assert.strictEqual(validateRunmodeFolder('install'), null);\n assert.strictEqual(validateRunmodeFolder('configuration'), null);\n});\n\ntest('scanUnsupportedRunmodes flags a malformed run-mode folder on disk', () => {\n const root = mkworkspace();\n write(root, 'ui.config/jcr_root/apps/my/config./com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n const res = scanUnsupportedRunmodes(root);\n assert.strictEqual(res.ok, true);\n const malformedRaw = res.rawFindings.find(f => f.kind === 'unsupported-runmode');\n assert.ok(malformedRaw, 'a finding is produced for the malformed folder');\n const malformedFinding = res.findings.find(f => /malformed/i.test(f.detail));\n assert.ok(malformedFinding, 'reason mentions the malformed folder name');\n});\n\ntest('scanUnsupportedRunmodes returns ok with no findings for a clean tree', () => {\n const root = mkworkspace();\n write(root, 'ui.config/jcr_root/apps/my/config.author.stage/com.my.Ok.cfg.json', '{ \"a\": 1 }\\n');\n const res = scanUnsupportedRunmodes(root);\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.rawFindings.length, 0);\n});\n\n// ── URC: safe auto-reorder fix (emit git mv commands, read-only) ─────────────\n\ntest('reorderRunmodeFolder fixes ordering-only violations and skips the rest', () => {\n assert.deepStrictEqual(reorderRunmodeFolder('config.dev.author'), { from: 'config.dev.author', to: 'config.author.dev' });\n assert.deepStrictEqual(reorderRunmodeFolder('install.stage.publish'), { from: 'install.stage.publish', to: 'install.publish.stage' });\n assert.strictEqual(reorderRunmodeFolder('config.author.dev'), null, 'already valid');\n assert.strictEqual(reorderRunmodeFolder('config.preprod'), null, 'unknown token — not auto-fixable');\n assert.strictEqual(reorderRunmodeFolder('config.author.publish'), null, 'two tiers — not auto-fixable');\n});\n\ntest('reorderRunmodeFolder does not silently case-fold a capitalized token', () => {\n assert.strictEqual(reorderRunmodeFolder('config.DEV.author'), null, 'mixed-case token routed to manual, not auto-folded');\n assert.deepStrictEqual(reorderRunmodeFolder('config.dev.author'), { from: 'config.dev.author', to: 'config.author.dev' },\n 'lowercase ordering violation is still auto-fixed');\n});\n\ntest('planRunmodeReorders emits a git mv command and never touches disk', () => {\n const root = mkworkspace();\n const bad = write(root, 'ui.config/jcr_root/apps/my/config.dev.author/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n const badDir = path.dirname(bad);\n const res = planRunmodeReorders(root);\n assert.strictEqual(res.ok, true);\n assert.strictEqual(res.reorders.length, 1);\n // Relative paths (not absolute) in a runnable git mv command.\n assert.match(res.reorders[0].command, /^git mv \"ui\\.config\\/.*config\\.dev\\.author\" \"ui\\.config\\/.*config\\.author\\.dev\"$/);\n assert.ok(!path.isAbsolute(res.reorders[0].from), 'from path is workspace-relative');\n assert.ok(fs.existsSync(badDir), 'plan is read-only — nothing renamed on disk');\n});\n\ntest('planRunmodeReorders routes collisions and unknown tokens to manual', () => {\n const root = mkworkspace();\n // Collision: valid target already exists next to the bad folder.\n const collidedTarget = write(root, 'ui.config/jcr_root/apps/my/config.author.dev/com.my.Other.cfg.json', '{ \"b\": 2 }\\n');\n write(root, 'ui.config/jcr_root/apps/my/config.dev.author/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n // Unknown token: never a command.\n const unknown = write(root, 'ui.config/jcr_root/apps/my/config.preprod/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n const res = planRunmodeReorders(root);\n assert.strictEqual(res.reorders.length, 0, 'no git mv commands emitted');\n const reasons = res.manual.map(s => s.reason).join(' | ');\n assert.match(reasons, /already exists/i);\n assert.match(reasons, /not auto-fixable/i);\n assert.ok(fs.existsSync(path.dirname(collidedTarget)), 'existing target untouched');\n assert.ok(fs.existsSync(path.dirname(unknown)), 'unknown-token folder untouched');\n});\n\n// ── orchestrator dispatch + cache tagging ────────────────────────────────────\n\ntest('gatherFindings dispatches rg + config-scan + pom-scan and tags heuristic in cache', async () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/comp/hero.html', '\u003cdiv data-sly-test=\"${x == false}\">x\u003c/div>\\n');\n write(root, 'ui.config/jcr_root/apps/my/config/com.my.Svc.cfg.json', '{ \"secret-token\": \"abc123\" }\\n');\n const pom = [\n '\u003cproject>\u003cbuild>\u003cplugins>\u003cplugin>',\n '\u003cartifactId>content-package-maven-plugin\u003c/artifactId>',\n '\u003cconfiguration>\u003cdependencies>',\n '\u003cdependency>\u003cgroup>day/cq60/product\u003c/group>\u003c/dependency>',\n '\u003c/dependencies>\u003c/configuration>\u003c/plugin>\u003c/plugins>\u003c/build>\u003c/project>',\n ].join('\\n');\n write(root, 'pom.xml', pom);\n // No BPA source, no Java project → cascade patterns go to needsLlmScan or analyzer(empty).\n // The vault-package scan delegates POM resolution to Maven; inject a stub that\n // returns the source as the effective pom so this stays a unit test.\n const gathered = await gatherFindings({\n workspaceRoot: root,\n getEffectivePom: (dir) => (dir === root ? { ok: true, xml: pom } : { ok: false, error: 'no stub' }),\n });\n\n assert.strictEqual(gathered.sourceByPattern.htlLint, 'html-scan');\n assert.ok(gathered.findingsByPattern.htlLint.length >= 1);\n assert.strictEqual(gathered.sourceByPattern.osgiConfig, 'config-scan');\n assert.ok(gathered.findingsByPattern.osgiConfig.length >= 1);\n assert.strictEqual(gathered.sourceByPattern['vault-package-dependencies'], 'pom-scan');\n assert.ok(gathered.findingsByPattern['vault-package-dependencies'].length >= 1);\n\n const cachePath = path.join(root, 'cache.json');\n writeRunbookCache(gathered, { generatedAt: 'now' }, cachePath);\n const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));\n for (const f of cache.findingsByPattern.osgiConfig) {\n assert.strictEqual(f.confidence, 'heuristic');\n }\n for (const f of cache.findingsByPattern.htlLint) {\n assert.strictEqual(f.confidence, 'heuristic');\n }\n for (const f of cache.findingsByPattern['vault-package-dependencies']) {\n assert.strictEqual(f.confidence, 'heuristic');\n }\n});\n\ntest('cascade findings are NOT tagged heuristic', async () => {\n const root = mkworkspace();\n const gathered = await gatherFindings({ workspaceRoot: root });\n const cachePath = path.join(root, 'cache.json');\n writeRunbookCache(gathered, { generatedAt: 'now' }, cachePath);\n const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));\n for (const f of cache.findingsByPattern.scheduler || []) {\n assert.notStrictEqual(f.confidence, 'heuristic');\n }\n});\n\ntest('samplePrompt uses the OSGi natural-language override', () => {\n assert.match(samplePrompt('osgiConfig', {}), /scan my config files/i);\n assert.match(samplePrompt('scheduler', {}), /migration skill: \\*\\*scheduler\\*\\* only/);\n});\n\ntest('samplePrompt never leaks the internal code-assessment handoff to the user', () => {\n // The runbook's copy-paste prompts are user-facing; reading the code-assessment\n // pattern guide is the migration skill's own job (Branch B), not the user's.\n for (const pattern of CANONICAL_PATTERNS) {\n assert.doesNotMatch(samplePrompt(pattern, {}), /code-assessment/, pattern);\n assert.doesNotMatch(samplePrompt(pattern, { bpaFilePath: './bpa.csv' }), /code-assessment/, pattern);\n }\n});\n\ntest('renderRunbook shows a heuristic note and never emits secret values', async () => {\n const root = mkworkspace();\n write(root, 'ui.config/jcr_root/apps/my/config/com.my.Svc.cfg.json', '{ \"password\": \"leakme-please-9000\" }\\n');\n const gathered = await gatherFindings({ workspaceRoot: root });\n const md = renderRunbook(gathered, { generatedAt: 'now' });\n assert.match(md, /Heuristic detection/);\n assert.ok(!md.includes('leakme-please-9000'), 'secret value must not appear in the rendered runbook');\n});\n\n// ── regression: compact / multi-property JSON must not hide secrets ─────────\n\ntest('runOsgiConfigScan catches a secret that is not the first property on a line', () => {\n const root = mkworkspace();\n write(root, 'ui.config/jcr_root/apps/my/config/com.my.Svc.cfg.json',\n '{\"endpoint\":\"https://x\",\"apiPassword\":\"HUNTER2_LEAK\",\"token\":\"$[secret:t]\"}');\n const res = runOsgiConfigScan(root);\n assert.ok(res.rawFindings.some(f => f.kind === 'plaintext-secret' && /apiPassword/.test(f.snippet)),\n 'secret after the first property on a compact line must be caught');\n assert.ok(!JSON.stringify(res).includes('HUNTER2_LEAK'), 'secret value must not leak');\n});\n\ntest('runOsgiConfigScan emits no XML warning when there are no config files', () => {\n const root = mkworkspace();\n write(root, 'core/src/main/java/J.java', 'class J {}\\n');\n const res = runOsgiConfigScan(root);\n assert.strictEqual(res.warnings.length, 0);\n});\n\n// ── already-placeholdered is informational, not counted as work ─────────────\n\ntest('already-placeholdered findings do not count as outstanding work', async () => {\n const root = mkworkspace();\n write(root, 'ui.config/jcr_root/apps/my/config/com.my.Done.cfg.json', '{ \"password\": \"$[secret:pw]\" }\\n');\n const out = path.join(root, 'rb.md');\n const cache = path.join(root, 'rb.json');\n const result = await generateRunbook({ workspaceRoot: root, outputPath: out, cachePath: cache });\n // informational only → zero actionable findings for osgiConfig\n assert.strictEqual(result.patternCounts.osgiConfig, 0);\n // but the informational row is still present in the gathered findings + cache\n assert.ok(result.gathered.findingsByPattern.osgiConfig.some(f => f.informational));\n const cached = JSON.parse(fs.readFileSync(cache, 'utf8'));\n assert.ok(cached.findingsByPattern.osgiConfig.some(f => f.kind === 'already-placeholdered' && f.informational));\n});\n\n// ── paths are workspace-relative in both the runbook and the cache ──────────\n\ntest('generateRunbook writes workspace-relative paths, not absolute', async () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/comp/hero.html', '\u003cdiv data-sly-test=\"${x == true}\">x\u003c/div>\\n');\n const out = path.join(root, 'rb.md');\n const cache = path.join(root, 'rb.json');\n await generateRunbook({ workspaceRoot: root, outputPath: out, cachePath: cache });\n\n const md = fs.readFileSync(out, 'utf8');\n assert.ok(!md.includes(root), 'rendered runbook must not contain the absolute workspace path');\n assert.match(md, /ui\\.apps\\/jcr_root\\/apps\\/comp\\/hero\\.html/);\n\n const cached = JSON.parse(fs.readFileSync(cache, 'utf8'));\n assert.strictEqual(cached.workspaceRoot, root);\n for (const f of cached.findingsByPattern.htlLint) {\n assert.ok(!path.isAbsolute(f.file), 'cache file paths must be relative');\n }\n});\n\n// ── htlLint word boundary: identifiers starting with true/false not flagged ─\n\ntest('classify does not flag identifiers like trueFlag / falseHood', () => {\n assert.strictEqual(classify('\u003cdiv data-sly-test=\"${x == trueFlag}\">'), null);\n assert.strictEqual(classify('\u003cdiv data-sly-test=\"${x != falseHood}\">'), null);\n assert.strictEqual(classify('\u003cdiv data-sly-test=\"${x == true}\">'), 'boolean-literal');\n});\n\n// ── content-scan: lui / cdw / template ──────────────────────────────────────\n\ntest('runCdwScan flags custom xtypes but not known-safe ones', () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/components/c/dialog/.content.xml',\n '\u003cjcr:root>\u003citems>\u003ca jcr:primaryType=\"cq:Widget\" xtype=\"textfield\"/>\u003cb jcr:primaryType=\"cq:Widget\" xtype=\"my-colorpicker\"/>\u003c/items>\u003c/jcr:root>');\n const res = runCdwScan(root);\n const xtypes = res.rawFindings.map(f => f.xtype);\n assert.ok(xtypes.includes('my-colorpicker'), 'custom xtype flagged');\n assert.ok(!xtypes.includes('textfield'), 'known-safe xtype not flagged');\n});\n\ntest('runLuiScan flags classic and coral2 dialogs, not coral3', () => {\n const root = mkworkspace();\n write(root, 'a/dialog/.content.xml', '\u003cjcr:root jcr:primaryType=\"cq:Dialog\"/>');\n write(root, 'b/_cq_dialog/.content.xml', '\u003cjcr:root sling:resourceType=\"granite/ui/components/foundation/container\"/>');\n write(root, 'c/_cq_dialog/.content.xml', '\u003cjcr:root sling:resourceType=\"granite/ui/components/coral/foundation/container\"/>');\n const res = runLuiScan(root);\n const subs = res.rawFindings.map(f => f.subType);\n assert.ok(subs.includes('legacy.dialog.classic'));\n assert.ok(subs.includes('legacy.dialog.coral2'));\n assert.strictEqual(res.rawFindings.length, 2, 'coral3 dialog must not be flagged');\n});\n\ntest('runTemplateScan flags static templates under apps/*/templates/*', () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/templates/content-page/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Template\"/>');\n write(root, 'ui.apps/jcr_root/apps/my/components/foo/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Template\"/>'); // not under templates/ → ignored\n const res = runTemplateScan(root);\n assert.strictEqual(res.rawFindings.length, 1);\n // No foundation resource type → project-authored custom template.\n assert.strictEqual(res.rawFindings[0].subType, 'custom.static.template');\n assert.match(res.findings[0].detail, /content-page/);\n assert.match(res.findings[0].detail, /custom/);\n});\n\ntest('runTemplateScan classifies foundation-derived templates as legacy', () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/templates/legacy-page/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Template\">\u003cjcr:content sling:resourceType=\"wcm/foundation/components/page\"/>\u003c/jcr:root>');\n write(root, 'ui.apps/jcr_root/apps/my/templates/custom-page/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Template\">\u003cjcr:content sling:resourceType=\"my/components/structure/page\"/>\u003c/jcr:root>');\n const res = runTemplateScan(root);\n const bySub = Object.fromEntries(res.rawFindings.map(f => [f.subType, f]));\n assert.ok(bySub['legacy.static.template'], 'foundation RT → legacy');\n assert.ok(bySub['custom.static.template'], 'project RT → custom');\n assert.match(bySub['legacy.static.template'].file, /legacy-page/);\n assert.match(bySub['custom.static.template'].file, /custom-page/);\n});\n\ntest('classifyStaticTemplate strips leading /libs and /apps prefixes', () => {\n assert.strictEqual(\n classifyStaticTemplate('\u003cjcr:content sling:resourceType=\"/libs/wcm/foundation/components/page\"/>'),\n 'legacy.static.template');\n assert.strictEqual(\n classifyStaticTemplate('\u003cjcr:content sling:resourceType=\"/apps/my/components/page\"/>'),\n 'custom.static.template');\n});\n\ntest('classifyStaticTemplate prefers jcr:content over a descendant parsys', () => {\n // jcr:content has no RT of its own but a child parsys is foundation-derived;\n // the template is still project-authored → must not be flagged legacy.\n const xml = '\u003cjcr:root jcr:primaryType=\"cq:Template\">' +\n '\u003cjcr:content jcr:primaryType=\"cq:PageContent\">' +\n '\u003cpar sling:resourceType=\"wcm/foundation/components/responsivegrid\"/>' +\n '\u003c/jcr:content>\u003c/jcr:root>';\n assert.strictEqual(classifyStaticTemplate(xml), 'custom.static.template');\n // When jcr:content itself is foundation-derived, it is legacy.\n const legacy = '\u003cjcr:content sling:resourceType=\"wcm/foundation/components/page\">' +\n '\u003cpar sling:resourceType=\"my/components/parsys\"/>\u003c/jcr:content>';\n assert.strictEqual(classifyStaticTemplate(legacy), 'legacy.static.template');\n});\n\ntest('runTemplateScan detects nested static templates', () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/templates/marketing/hero/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Template\"/>');\n const res = runTemplateScan(root);\n assert.strictEqual(res.rawFindings.length, 1, 'nested template must be found');\n assert.match(res.findings[0].detail, /hero/);\n});\n\ntest('gatherFindings dispatches content-scan patterns as heuristic', async () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/components/c/dialog/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Dialog\">\u003citems>\u003cw jcr:primaryType=\"cq:Widget\" xtype=\"my-widget\"/>\u003c/items>\u003c/jcr:root>');\n write(root, 'ui.apps/jcr_root/apps/my/templates/t/.content.xml', '\u003cjcr:root jcr:primaryType=\"cq:Template\"/>');\n // Isolate collectionsDir so no ambient BPA collection pre-empts content-scan.\n const gathered = await gatherFindings({ workspaceRoot: root, collectionsDir: path.join(root, 'no-collections') });\n for (const p of ['lui', 'cdw', 'templateModernization']) {\n assert.strictEqual(gathered.sourceByPattern[p], 'content-scan', `${p} scanned via content-scan`);\n assert.ok(gathered.findingsByPattern[p].length >= 1, `${p} has a finding`);\n }\n const cache = path.join(root, 'c.json');\n writeRunbookCache(gathered, { generatedAt: 'now', workspaceRoot: root }, cache);\n const cached = JSON.parse(fs.readFileSync(cache, 'utf8'));\n for (const p of ['lui', 'cdw', 'templateModernization']) {\n for (const f of cached.findingsByPattern[p]) assert.strictEqual(f.confidence, 'heuristic');\n }\n});\n\ntest('lui/cdw/template sample prompts route to the migration branches', () => {\n assert.match(samplePrompt('lui', {}), /LUI dialog|Coral 3/i);\n assert.match(samplePrompt('cdw', {}), /CDW|custom ExtJS/i);\n assert.match(samplePrompt('templateModernization', {}), /editable templates/i);\n});\n\n// ── analyzer-runner: slug normalization + ok:false branches (stub analyze.sh) ─\n\nfunction writeAnalyzeStub(root, body) {\n const p = path.join(root, 'analyze.sh');\n fs.writeFileSync(p, body, 'utf8');\n return p;\n}\n\ntest('runAnalyzer normalizes analyzer slugs and skips non-migration patterns', () => {\n const root = mkworkspace();\n const payload = { findings: [\n { pattern: 'scheduler', file: 'A.java', line: 3, snippet: 'Scheduler s;' },\n { pattern: 'resource-change-listener', file: 'B.java', line: 5, snippet: 'impl RCL' },\n { pattern: 'asset-manager', file: 'C.java', line: 7, snippet: 'AssetManager.createAsset' },\n { pattern: 'inject-in-sling-model', file: 'D.java', line: 9, snippet: '@Inject' },\n { pattern: 'vault-package-dependencies', file: 'pom.xml', line: 12, snippet: '\u003cgroup>day/cq60/product\u003c/group>' },\n ], warnings: ['w1'] };\n const stub = writeAnalyzeStub(root, `#!/usr/bin/env bash\\ncat \u003c\u003c'JSON'\\n${JSON.stringify(payload)}\\nJSON\\n`);\n const res = runAnalyzer(root, { analyzeScript: stub });\n assert.strictEqual(res.ok, true);\n assert.ok(res.findingsByPattern.scheduler, 'scheduler kept');\n assert.ok(res.findingsByPattern.resourceChangeListener, 'resource-change-listener → resourceChangeListener');\n assert.ok(res.findingsByPattern.assetApi, 'asset-manager → assetApi');\n assert.ok(!res.findingsByPattern['inject-in-sling-model'], 'non-migration slug dropped');\n assert.ok(!res.findingsByPattern['vault-package-dependencies'], 'vault-package-dependencies has no analyzer detector — dropped like non-migration slugs, detected via pom-scan instead');\n assert.deepStrictEqual(res.warnings, ['w1']);\n assert.strictEqual(res.rawFindingsByPattern.scheduler[0].line, 3);\n});\n\ntest('runAnalyzer returns ok:false on non-zero exit', () => {\n const root = mkworkspace();\n const stub = writeAnalyzeStub(root, '#!/usr/bin/env bash\\necho \"compile error\" >&2\\nexit 5\\n');\n const res = runAnalyzer(root, { analyzeScript: stub });\n assert.strictEqual(res.ok, false);\n assert.match(res.error, /compile error|code 5/);\n});\n\ntest('runAnalyzer returns ok:false on unparseable output', () => {\n const root = mkworkspace();\n const stub = writeAnalyzeStub(root, '#!/usr/bin/env bash\\necho \"not json at all\"\\n');\n const res = runAnalyzer(root, { analyzeScript: stub });\n assert.strictEqual(res.ok, false);\n assert.match(res.error, /parse/i);\n});\n\n// ── BPA parsing of the new subtypes (parser + reader) ───────────────────────\n\nfunction writeBpaCsv(root) {\n const rows = [\n 'code,type,subtype,importance,identifier,message,context',\n // CDW — note underscore in path (design_dialog) must survive round-trip.\n 'CDW,custom.dialog.widget,custom.classic.widget,MAJOR,/apps/x/components/c/design_dialog/items/w1,Custom widget,ctx',\n 'CDW,custom.dialog.widget,custom.classic.widget,MAJOR,/apps/x/components/c/dialog/items/w2,Custom widget,ctx',\n // LUI — dialogs + a static-template sub-type (should NOT count under lui).\n 'LUI,legacy.user.interface,legacy.dialog.classic,MAJOR,/apps/x/components/c/dialog,Classic dialog,ctx',\n 'LUI,legacy.user.interface,legacy.dialog.coral2,MAJOR,/apps/x/components/c/_cq_dialog,Coral2 dialog,ctx',\n 'LUI,legacy.user.interface,legacy.static.template,MINOR,/apps/x/templates/t1,Static template,ctx',\n // CTEM — another static template.\n 'CTEM,custom.template,custom.static.template,MINOR,/apps/x/templates/t2,Static template,ctx',\n // REP — one real forward finding + a summary row that must be excluded.\n 'REP,replication.agent,forward.replication,MAJOR,agent-1,Forward replication agent,ctx',\n '_COUNT_REP,_count.replication.agent,forward.replication,INFO,\\\\N,summary,ctx',\n ];\n const p = path.join(root, 'bpa.csv');\n fs.writeFileSync(p, rows.join('\\n') + '\\n', 'utf8');\n return p;\n}\n\ntest('BPA parser extracts cdw/lui/template/replication and excludes _COUNT rows', async () => {\n const root = mkworkspace();\n const csv = writeBpaCsv(root);\n const opts = { bpaFilePath: csv, collectionsDir: path.join(root, 'uc'), limit: null, offset: 0 };\n\n const cdw = await getBpaFindings('cdw', opts);\n assert.strictEqual(cdw.targets.length, 2);\n // Underscore path must survive the unified round-trip (no design_dialog → design.dialog).\n assert.ok(cdw.targets.some(t => t.className.includes('design_dialog')), 'underscore path preserved');\n\n const rep = await getBpaFindings('replication', opts);\n assert.strictEqual(rep.targets.length, 1, '_COUNT_REP summary row excluded');\n\n const tpl = await getBpaFindings('templateModernization', opts);\n assert.strictEqual(tpl.targets.length, 2, 'legacy.static.template + custom.static.template');\n});\n\nfunction writeGuavaCacheBpaCsv(root) {\n const rows = [\n 'code,type,subtype,importance,identifier,message,context',\n // Three Guava-internal-class rows for the SAME bundle — must dedupe to one target.\n 'GC,development.guideline,custom.guava.cache,INFO,com.google.common.cache.AbstractCache,The com.google.common.cache.AbstractCache class in the com.example.bundle-a bundle uses com.google.common.cache.Cache.,ctx',\n 'GC,development.guideline,custom.guava.cache,INFO,com.google.common.cache.CacheBuilder,The com.google.common.cache.CacheBuilder class in the com.example.bundle-a bundle uses com.google.common.cache.CacheBuilder.,ctx',\n 'GC,development.guideline,custom.guava.cache,INFO,com.google.common.cache.LoadingCache,The com.google.common.cache.LoadingCache class in the com.example.bundle-a bundle uses com.google.common.cache.LoadingCache.,ctx',\n // A row that matches the subtype but whose message does not match the\n // \"in the \u003cbundle> bundle\" phrasing — must be dropped, not counted, with a warning.\n 'GC,development.guideline,custom.guava.cache,INFO,com.google.common.cache.Foo,Some unexpected BPA message format with no bundle phrase.,ctx',\n ];\n const p = path.join(root, 'guava.csv');\n fs.writeFileSync(p, rows.join('\\n') + '\\n', 'utf8');\n return p;\n}\n\ntest('guavaCache dedupes multiple rows for one bundle to a single target, and warns (not drops silently) on an unparseable message', async () => {\n const root = mkworkspace();\n const csv = writeGuavaCacheBpaCsv(root);\n const opts = { bpaFilePath: csv, collectionsDir: path.join(root, 'uc'), limit: null, offset: 0 };\n\n const warnings = [];\n const originalWarn = console.warn;\n console.warn = (...args) => warnings.push(args.join(' '));\n let result;\n try {\n result = await getBpaFindings('guavaCache', opts);\n } finally {\n console.warn = originalWarn;\n }\n\n assert.strictEqual(result.targets.length, 1, 'three rows for the same bundle dedupe to one target');\n assert.strictEqual(result.targets[0].className, 'com.example.bundle-a');\n assert.ok(\n warnings.some(w => /could not be parsed for a bundle name/.test(w)),\n 'the unparseable row emits a warning instead of vanishing silently'\n );\n});\n\nfunction writeOakIndexBpaCsv(root) {\n const rows = [\n 'code,type,subtype,importance,identifier,message,context',\n // Two rule-violation rows for the SAME index path — must dedupe to one target.\n 'OID,oak.index.definition,index.rule.violation,CRITICAL,/content/oak:index/enablementResourceName,The /content/oak:index/enablementResourceName Oak index has a rule violation: Custom definition name must follow the pattern \u003cname>-custom-\u003cversion>,ctx',\n 'OID,oak.index.definition,index.rule.violation,CRITICAL,/content/oak:index/enablementResourceName,The /content/oak:index/enablementResourceName Oak index has a rule violation: Path must begin with /oak:/index,ctx',\n // A distinct rule-violation path.\n 'OID,oak.index.definition,index.rule.violation,CRITICAL,/oak:index/wkndId,The /oak:index/wkndId Oak index has a rule violation: Custom definition name must follow the pattern \u003cname>-custom-\u003cversion>,ctx',\n // A standard-index-modification finding (OOTB index modified in place).\n 'OID,oak.index.definition,standard.index.modification,CRITICAL,/oak:index/damAssetLucene,The /oak:index/damAssetLucene Oak index modifies a standard OOTB index definition,ctx',\n // Summary row — bare numeric identifier, must be excluded, not mistaken for a path.\n '_COUNT_OID,_count.oak.index.definition,index.rule.violation,CRITICAL,182,Count: 182,ctx',\n ];\n const p = path.join(root, 'oakindex.csv');\n fs.writeFileSync(p, rows.join('\\n') + '\\n', 'utf8');\n return p;\n}\n\ntest('oakIndex dedupes repeat rows for the same index path, excludes _COUNT_OID, and splits by sub-type', async () => {\n const root = mkworkspace();\n const csv = writeOakIndexBpaCsv(root);\n const opts = { bpaFilePath: csv, collectionsDir: path.join(root, 'uc'), limit: null, offset: 0 };\n\n const result = await getBpaFindings('oakIndex', opts);\n\n assert.strictEqual(result.targets.length, 3, 'two rule-violation rows for the same path dedupe to one target, plus the distinct path and the standard-modification finding');\n assert.strictEqual(\n result.targets.filter(t => t.identifier === 'index.rule.violation').length, 2,\n 'two distinct rule-violation index paths'\n );\n assert.strictEqual(\n result.targets.filter(t => t.identifier === 'standard.index.modification').length, 1,\n 'one standard-index-modification finding'\n );\n assert.ok(\n result.targets.some(t => t.className === '/content/oak:index/enablementResourceName'),\n 'JCR index path preserved as the actionable className'\n );\n assert.ok(\n !result.targets.some(t => t.className === '182'),\n '_COUNT_OID summary row (bare numeric identifier) must not be mistaken for an index path'\n );\n});\n\ntest('a real BPA fetch failure is surfaced (warning + needsLlmScan), NOT reported clean', async () => {\n const root = mkworkspace();\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'uc'), // no cached collection\n projectId: 'proj-1', // + mcpFetcher ⇒ bpaMode='mcp'\n mcpFetcher: async () => { throw new Error('MCP down'); },\n analyzeScript: path.join(root, 'missing-analyze.sh'), // analyzer unavailable\n });\n // scheduler is a cascade BPA pattern; the fetch threw — it must NOT be marked\n // as cleanly scanned by BPA, and must be surfaced for follow-up.\n assert.notStrictEqual(gathered.sourceByPattern.scheduler, 'mcp');\n assert.ok(gathered.needsLlmScan.includes('scheduler'), 'failed pattern surfaced in needsLlmScan');\n assert.ok(\n gathered.analyzerWarnings.some(w => /BPA fetch failed.*scheduler/.test(w)),\n 'a Scan-warnings entry names the failed pattern'\n );\n});\n\ntest('benign \"pattern not in CSV\" stays silent and counts as clean', async () => {\n const root = mkworkspace();\n const csv = writeBpaCsv(root); // has cdw/lui/etc but NOT scheduler\n const gathered = await gatherFindings({\n workspaceRoot: root, bpaFilePath: csv, collectionsDir: path.join(root, 'uc'),\n analyzeScript: path.join(root, 'missing-analyze.sh'),\n });\n // scheduler is absent from the report → genuinely clean, sourced from csv, no warning.\n assert.strictEqual(gathered.sourceByPattern.scheduler, 'csv');\n assert.strictEqual(gathered.findingsByPattern.scheduler.length, 0);\n assert.ok(!gathered.needsLlmScan.includes('scheduler'));\n assert.ok(!gathered.analyzerWarnings.some(w => /scheduler/.test(w)), 'no warning for a benign absence');\n});\n\ntest('runbook lui is filtered to dialog sub-types when sourced from BPA', async () => {\n const root = mkworkspace();\n const csv = writeBpaCsv(root);\n const out = path.join(root, 'rb.md');\n const cache = path.join(root, 'rb.json');\n const result = await generateRunbook({\n workspaceRoot: root, bpaFilePath: csv, collectionsDir: path.join(root, 'uc'),\n outputPath: out, cachePath: cache,\n });\n assert.strictEqual(result.patternCounts.lui, 2, 'only classic + coral2, not static.template');\n assert.strictEqual(result.patternCounts.cdw, 2);\n assert.strictEqual(result.patternCounts.templateModernization, 2);\n assert.strictEqual(result.gathered.sourceByPattern.lui, 'csv');\n});\n\n// ── URC: BPA report mapping ─────────────────────────────────────────────────\n\ntest('getBpaFindings resolves the urc pattern from a BPA CSV, excluding count rows', async () => {\n const dir = mkworkspace();\n const csv = path.join(dir, 'bpa.csv');\n fs.copyFileSync(path.join(__dirname, 'fixtures', 'urc-bpa.csv'), csv);\n const collectionsDir = path.join(dir, 'collections');\n const res = await getBpaFindings('urc', { bpaFilePath: csv, collectionsDir, limit: null, offset: 0 });\n assert.strictEqual(res.success, true);\n assert.strictEqual(res.targets.length, 2, 'two URC detail rows, count row excluded');\n const locations = res.targets.map(t => t.className).sort();\n assert.deepStrictEqual(locations, ['/apps/demo/config.dev.author', '/apps/demo/config.preprod']);\n});\n\n// ── URC: report-first with local fallback (end to end) ──────────────────────\n\ntest('URC comes from the BPA report when a report is present (report owns it)', async () => {\n const root = mkworkspace();\n // Bad folder on disk that the report does NOT list — report ownership means\n // it is treated as clean and the local scanner is not consulted.\n write(root, 'ui.config/jcr_root/apps/my/config.stage.author/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n const csv = path.join(root, 'bpa.csv');\n fs.copyFileSync(path.join(__dirname, 'fixtures', 'urc-bpa.csv'), csv);\n const gathered = await gatherFindings({ workspaceRoot: root, bpaFilePath: csv, collectionsDir: path.join(root, 'collections') });\n const urc = gathered.rawFindingsByPattern.osgiConfig.filter(f => f.kind === 'unsupported-runmode');\n // BPA-sourced URC findings now carry the same `kind`/`runmode` shape as the\n // local scanner — two report rows, not the on-disk bad folder the test\n // planted (which the report does NOT list).\n assert.strictEqual(urc.length, 2, 'both report URC rows enriched with kind: unsupported-runmode');\n const urcFiles = urc.map(f => f.file).sort();\n assert.deepStrictEqual(\n urcFiles,\n ['/apps/demo/config.dev.author', '/apps/demo/config.preprod'],\n 'URC raw findings come from the report paths, not the on-disk config.stage.author folder — proving report ownership'\n );\n const devAuthor = urc.find(f => f.file === '/apps/demo/config.dev.author');\n assert.strictEqual(devAuthor.runmode, 'dev.author', 'runmode derived from the folder basename via validateRunmodeFolder');\n\n // …and the report URC folders are present as osgiConfig findings, with a\n // rich detail carrying the folder basename (not the bare BPA identifier\n // string 'unsupported.runmode').\n const finding = gathered.findingsByPattern.osgiConfig.find(f => String(f.location).includes('config.dev.author'));\n assert.ok(finding, 'URC from report present');\n assert.ok(finding.detail.includes('config.dev.author'), 'detail contains the folder basename');\n assert.ok(!finding.detail.includes('unsupported.runmode'), 'detail is not the bare BPA identifier string');\n});\n\ntest('URC falls back to local detection when no BPA source is present', async () => {\n const root = mkworkspace();\n write(root, 'ui.config/jcr_root/apps/my/config.dev.author/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n const gathered = await gatherFindings({ workspaceRoot: root });\n const urc = gathered.rawFindingsByPattern.osgiConfig.filter(f => f.kind === 'unsupported-runmode');\n assert.strictEqual(urc.length, 1, 'local scanner produced the URC finding');\n assert.strictEqual(urc[0].runmode, 'dev.author');\n});\n\n// ── URC: MCP path (the masking hole also exists via MCP, not just CSV) ──────\n\ntest('URC comes from MCP when the mcpFetcher reports URC targets', async () => {\n const root = mkworkspace();\n const mcpFetcher = async ({ pattern }) => {\n if (pattern === 'urc') {\n return {\n success: true,\n targets: [\n { className: '/apps/demo/config.dev.author', identifier: 'unsupported.runmode', issue: 'bad runmode' },\n ],\n };\n }\n // Any other pattern (scheduler, resourceChangeListener, ...) — report clean.\n return { success: true, targets: [] };\n };\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'mcp-collections'),\n projectId: 'proj-mcp-urc',\n mcpFetcher,\n });\n const urc = gathered.rawFindingsByPattern.osgiConfig.filter(f => f.kind === 'unsupported-runmode');\n assert.strictEqual(urc.length, 1, 'MCP-sourced URC finding surfaces');\n assert.strictEqual(urc[0].file, '/apps/demo/config.dev.author');\n assert.strictEqual(urc[0].runmode, 'dev.author');\n assert.ok(!gathered.analyzerWarnings.some(w => w.includes('safety net')),\n 'no safety-net warning when MCP actually reports URC findings');\n});\n\ntest('URC safety-net scan also runs when the mcpFetcher reports success with EMPTY URC targets', async () => {\n const root = mkworkspace();\n // On-disk unsupported folder the (URC-empty) MCP response does not mention.\n write(root, 'ui.config/jcr_root/apps/my/config.qa/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n const mcpFetcher = async ({ pattern }) => {\n if (pattern === 'urc') return { success: true, targets: [] };\n return { success: true, targets: [] };\n };\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'mcp-collections'),\n projectId: 'proj-mcp-urc-empty',\n mcpFetcher,\n });\n const urc = gathered.rawFindingsByPattern.osgiConfig.filter(f => f.kind === 'unsupported-runmode');\n assert.ok(urc.some(f => String(f.file).includes('config.qa')),\n 'on-disk URC finding surfaces via the local safety-net scan even though MCP is configured');\n assert.ok(\n gathered.analyzerWarnings.some(w => w.includes(\n '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).'\n )),\n 'a safety-net warning is surfaced for the MCP masking path too'\n );\n});\n\ntest('a BPA report present but with NO URC rows warns and still runs the local safety-net scan', async () => {\n const root = mkworkspace();\n // On-disk unsupported folder the (URC-less) BPA report does not mention.\n write(root, 'ui.config/jcr_root/apps/my/config.qa/com.my.Svc.cfg.json', '{ \"a\": 1 }\\n');\n const bpaFilePath = path.join(__dirname, 'fixtures', 'minimal-scheduler-bpa.csv');\n const gathered = await gatherFindings({\n workspaceRoot: root, bpaFilePath, collectionsDir: path.join(root, 'uc'),\n });\n const urc = gathered.rawFindingsByPattern.osgiConfig.filter(f => f.kind === 'unsupported-runmode');\n assert.ok(urc.some(f => String(f.file).includes('config.qa')),\n 'on-disk URC finding surfaces even though a BPA source is present');\n assert.ok(\n gathered.analyzerWarnings.some(w => w.includes(\n '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).'\n )),\n 'a safety-net warning is surfaced'\n );\n});\n\n// ── Performance-fix behaviors: relPath, classKey, sourceLabel, preFetchedBpa, LLM merge ──\n\ntest('relPath collapses a finding AT the workspace root to \".\" (no absolute-path leak)', () => {\n const ws = '/Users/me/project';\n assert.strictEqual(relPath('/Users/me/project', ws), '.'); // config at root — was the leak\n assert.strictEqual(relPath('/Users/me/project/', ws), '.'); // trailing separator\n assert.strictEqual(relPath('/Users/me/project/dispatcher', ws), 'dispatcher');\n assert.strictEqual(relPath('com.acme.MyJob', ws), 'com.acme.MyJob'); // non-path value untouched\n});\n\ntest('classKey normalizes a BPA className and an analyzer file path to the same key', () => {\n assert.strictEqual(classKey('com.acme.jobs.MyJob'), 'myjob');\n assert.strictEqual(classKey('core/src/main/java/com/acme/jobs/MyJob.java'), 'myjob');\n assert.strictEqual(classKey('com.acme.jobs.MyJob'), classKey('core/src/main/java/com/acme/jobs/MyJob.java'));\n assert.strictEqual(classKey(null), null);\n});\n\ntest('sourceLabel renders unioned sources (mcp+analyzer) with both labels', () => {\n assert.strictEqual(sourceLabel('mcp'), 'BPA / CAM');\n assert.strictEqual(sourceLabel('mcp+analyzer'), 'BPA / CAM + analyzer');\n assert.strictEqual(sourceLabel('csv+analyzer'), 'BPA CSV + analyzer');\n assert.strictEqual(sourceLabel(undefined), '—');\n});\n\ntest('preFetchedBpa drives BPA findings with no mcpFetcher bridge (CLI --bpa-json path)', async () => {\n const root = mkworkspace();\n // Pre-fetched CAM/MCP result: slug → raw BPA targets, exactly what the agent dumps to JSON.\n const preFetchedBpa = {\n scheduler: [{ className: 'com.acme.MySchedulerJob', identifier: 'scheduler', severity: 'high' }],\n };\n const gathered = await gatherFindings({ workspaceRoot: root, preFetchedBpa });\n assert.strictEqual(gathered.bpaMode, 'mcp', 'pre-fetched BPA counts as a CAM/MCP source');\n assert.strictEqual(gathered.sourceByPattern.scheduler, 'mcp');\n assert.strictEqual(gathered.findingsByPattern.scheduler.length, 1);\n assert.strictEqual(gathered.findingsByPattern.scheduler[0].location, 'com.acme.MySchedulerJob');\n // A slug absent from the map is a benign \"clean\" — not an error, not needsLlmScan.\n assert.ok(!gathered.needsLlmScan.includes('scheduler'));\n assert.ok(!gathered.analyzerWarnings.some(w => /scheduler/.test(w)), 'absent slug does not raise a fetch error');\n});\n\ntest('mergeLlmFindings folds Tier-4 results in one pass (source llm, dropped from needsLlmScan)', () => {\n const gathered = {\n findingsByPattern: { guavaCache: [] },\n rawFindingsByPattern: { guavaCache: [] },\n sourceByPattern: {},\n needsLlmScan: ['guavaCache', 'replication'],\n };\n mergeLlmFindings(gathered, {\n guavaCache: [{ file: 'core/src/main/java/com/acme/Cache.java', line: 42, snippet: 'import com.google.common.cache.CacheBuilder' }],\n });\n assert.strictEqual(gathered.sourceByPattern.guavaCache, 'llm');\n assert.strictEqual(gathered.findingsByPattern.guavaCache.length, 1);\n assert.strictEqual(gathered.findingsByPattern.guavaCache[0].location, 'core/src/main/java/com/acme/Cache.java:42');\n assert.strictEqual(gathered.rawFindingsByPattern.guavaCache[0].snippet, 'import com.google.common.cache.CacheBuilder');\n assert.deepStrictEqual(gathered.needsLlmScan, ['replication'], 'merged pattern is removed; others remain');\n});\n\ntest('generateRunbook applies llmByPattern end to end (one command, no re-render)', async () => {\n const root = mkworkspace();\n const out = path.join(root, 'runbook.md');\n const cache = path.join(root, 'runbook.json');\n const result = await generateRunbook({\n workspaceRoot: root, outputPath: out, cachePath: cache,\n llmByPattern: { guavaCache: [{ file: 'bundle/Foo.java', line: 3, snippet: 'com.google.common.cache' }] },\n });\n assert.strictEqual(result.gathered.sourceByPattern.guavaCache, 'llm');\n assert.ok(!result.needsLlmScan.includes('guavaCache'));\n const md = fs.readFileSync(out, 'utf8');\n assert.match(md, /LLM scan/); // rendered \"Detected via: LLM scan\"\n assert.match(md, /bundle\\/Foo\\.java:3/);\n});\n\ntest('Fix D: analyzer union fills a BPA-clean cascade pattern and dedups by class name', async (t) => {\n const root = mkworkspace();\n write(root, 'core/src/main/java/com/acme/LegacyJob.java',\n 'package com.acme;\\n' +\n 'import org.apache.sling.commons.scheduler.Scheduler;\\n' +\n 'import org.osgi.service.component.annotations.Component;\\n' +\n '@Component(service = Runnable.class, property = {\"scheduler.expression=0 0 * * * ?\"})\\n' +\n 'public class LegacyJob implements Runnable { private Scheduler scheduler; public void run() {} }\\n');\n\n // Probe: with no BPA source the analyzer owns scheduler. If it can't run here\n // (no JDK / analyzer), skip — this behavior needs the real analyzer.\n const probe = await gatherFindings({ workspaceRoot: root });\n if (!isAnalyzerAvailable(DEFAULT_ANALYZE_SCRIPT) || probe.sourceByPattern.scheduler !== 'analyzer' || probe.findingsByPattern.scheduler.length \u003c 1) {\n t.skip('analyzer/JDK not available in this environment');\n return;\n }\n\n // BPA reports scheduler CLEAN → the union must still surface the analyzer's finding.\n const unioned = await gatherFindings({ workspaceRoot: root, preFetchedBpa: { scheduler: [] } });\n assert.strictEqual(unioned.findingsByPattern.scheduler.length, 1, 'analyzer finding unioned into a BPA-clean pattern');\n assert.strictEqual(unioned.sourceByPattern.scheduler, 'mcp+analyzer');\n\n // BPA already reports the SAME class → deduped by class name, no double count.\n const deduped = await gatherFindings({ workspaceRoot: root, preFetchedBpa: { scheduler: [{ className: 'com.acme.LegacyJob', identifier: 'scheduler' }] } });\n assert.strictEqual(deduped.findingsByPattern.scheduler.length, 1, 'same class is deduped');\n assert.strictEqual(deduped.sourceByPattern.scheduler, 'mcp', 'no analyzer suffix when nothing new was added');\n});\n\n// ── bpaIncomplete: a BPA source that structurally can't report a pattern must not mark it clean ──\n\ntest('bpaIncomplete: BPA-clean templateModernization still runs the content-scan fallback (CAM cannot see templates)', async () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/templates/content-page/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Template\"/>');\n // A BPA source is present (isolated collectionsDir) but reports nothing for\n // templateModernization — CAM can't report templates, so this must NOT be clean.\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'nocollections'),\n preFetchedBpa: { templateModernization: [] },\n });\n assert.strictEqual(gathered.bpaMode, 'mcp');\n assert.strictEqual(gathered.sourceByPattern.templateModernization, 'content-scan',\n 'content-scan ran despite the BPA source');\n assert.strictEqual(gathered.findingsByPattern.templateModernization.length, 1);\n assert.ok(!gathered.needsLlmScan.includes('templateModernization'));\n});\n\ntest('bpaIncomplete: BPA-clean guavaCache falls through to needsLlmScan (own bundle may be filtered out)', async () => {\n const root = mkworkspace();\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'nocollections'),\n preFetchedBpa: { guavaCache: [] },\n });\n assert.ok(gathered.needsLlmScan.includes('guavaCache'),\n 'a zero BPA result for a bpaIncomplete bpa-only pattern is not treated as clean');\n assert.notStrictEqual(gathered.sourceByPattern.guavaCache, 'mcp');\n});\n\ntest('bpaIncomplete does NOT change lui/cdw: CAM reports those reliably, so BPA-clean stays clean', async () => {\n const root = mkworkspace();\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'nocollections'),\n preFetchedBpa: { lui: [], cdw: [] },\n });\n for (const p of ['lui', 'cdw']) {\n assert.strictEqual(gathered.sourceByPattern[p], 'mcp', `${p} owned by BPA`);\n assert.ok(!gathered.needsLlmScan.includes(p), `${p} not pushed to LLM tier`);\n }\n});\n\ntest('bpaIncomplete: when BPA DOES report the pattern, BPA still owns it (no fallback override)', async () => {\n const root = mkworkspace();\n write(root, 'ui.apps/jcr_root/apps/my/templates/content-page/.content.xml',\n '\u003cjcr:root jcr:primaryType=\"cq:Template\"/>');\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'nocollections'),\n preFetchedBpa: { templateModernization: [{ className: '/apps/my/templates/reported', identifier: 'legacy.static.template' }] },\n });\n assert.strictEqual(gathered.sourceByPattern.templateModernization, 'mcp', 'BPA owns it when it reports findings');\n assert.strictEqual(gathered.findingsByPattern.templateModernization.length, 1, 'BPA finding, not the content-scan one');\n});\n\n// ── CAM project handoff to validate-migration ──────────────────────────────\n\ntest('writeRunbookCache records the CAM project in the sidecar when one is given', async () => {\n const root = mkworkspace();\n const gathered = await gatherFindings({ workspaceRoot: root });\n const cache = path.join(root, 'migration-runbook.json');\n writeRunbookCache(gathered, { generatedAt: 'now', projectId: '000000000000000000000001', projectName: 'Example Project' }, cache);\n const cached = JSON.parse(fs.readFileSync(cache, 'utf8'));\n assert.deepStrictEqual(cached.project, { id: '000000000000000000000001', name: 'Example Project' });\n});\n\ntest('writeRunbookCache writes project null when no CAM project is linked', async () => {\n const root = mkworkspace();\n const gathered = await gatherFindings({ workspaceRoot: root });\n const cache = path.join(root, 'migration-runbook.json');\n writeRunbookCache(gathered, { generatedAt: 'now' }, cache);\n assert.strictEqual(JSON.parse(fs.readFileSync(cache, 'utf8')).project, null);\n});\n\ntest('generateRunbook keeps the CAM project in migration-runbook.json and creates no separate context.json', async () => {\n const root = mkworkspace();\n const cachePath = path.join(root, 'r.json');\n await generateRunbook({\n workspaceRoot: root, outputPath: path.join(root, 'r.md'), cachePath,\n projectId: '000000000000000000000001', projectName: 'Example Project',\n });\n assert.deepStrictEqual(JSON.parse(fs.readFileSync(cachePath, 'utf8')).project, { id: '000000000000000000000001', name: 'Example Project' });\n assert.strictEqual(fs.existsSync(path.join(root, '.validate-migration', 'context.json')), false);\n});\n\ntest('CLI parseArgs passes the confirmed CAM project through to generateRunbook', () => {\n const { parseArgs } = require('./runbook-generator');\n const args = parseArgs(['/ws', '--bpa-json', 'b.json', '--project-id', '000000000000000000000001', '--project-name', 'Example Project']);\n assert.strictEqual(args.workspaceRoot, '/ws');\n assert.strictEqual(args.projectId, '000000000000000000000001');\n assert.strictEqual(args.projectName, 'Example Project');\n assert.strictEqual(parseArgs(['/ws']).projectId, undefined, 'no project when the flag is omitted');\n});\n\n// ── inputStream (java.io.inputstream) ───────────────────────────────────────\n\nfunction writeInputStreamBpaCsv(root, { withOther = true } = {}) {\n const header = withOther\n ? 'code,type,subtype,importance,identifier,other,message,moreInfo,context'\n : 'code,type,subtype,importance,identifier,message,moreInfo,context';\n const row = (cls, api, bundle) => {\n const msg = `The ${cls} class in the ${bundle} bundle uses ${api}. This may cause issues if used in AEM as a Cloud Service.`;\n const ctx = `\"{\"\"data\"\":{\"\"toId\"\":\"\"${api}\"\",\"\"bundle\"\":\"\"${bundle}\"\",\"\"class\"\":\"\"${cls}\"\"},\"\"type\"\":\"\"java.io.inputstream\"\"}\"`;\n return withOther\n ? `DG,development.guideline,java.io.inputstream,ADVISORY,${cls},${api},${msg},https://www.adobe.com/go/aem_pd_DG,${ctx}`\n : `DG,development.guideline,java.io.inputstream,ADVISORY,${cls},${msg},https://www.adobe.com/go/aem_pd_DG,${ctx}`;\n };\n const rows = [\n header,\n withOther\n ? '_COUNT_DG,_count.development.guideline,java.io.inputstream,ADVISORY,4,\\\\N,Count: 4,\\\\N,\\\\N'\n : '_COUNT_DG,_count.development.guideline,java.io.inputstream,ADVISORY,4,Count: 4,\\\\N,\\\\N',\n row('com.acme.core.servlets.DownloadServlet', 'java.io.InputStream', 'com.acme.core'),\n row('com.acme.core.servlets.DownloadServlet', 'java.io.InputStream', 'com.acme.core'), // duplicate row\n row('com.acme.core.servlets.DownloadServlet', 'java.io.FileInputStream', 'com.acme.core'),\n row('acscommons.com.google.common.cache.LocalCache$LoadingSerializationProxy', 'java.io.ObjectInputStream', 'com.adobe.acs.acs-aem-commons-bundle'),\n ];\n const p = path.join(root, 'inputstream.csv');\n fs.writeFileSync(p, rows.join('\\n') + '\\n', 'utf8');\n return p;\n}\n\nfor (const withOther of [true, false]) {\n test(`inputStream CSV parsing keys targets by java.io API, dedupes, drops _COUNT rows (other column: ${withOther})`, async () => {\n const root = mkworkspace();\n const csv = writeInputStreamBpaCsv(root, { withOther });\n const result = await getBpaFindings('inputStream', {\n bpaFilePath: csv, collectionsDir: path.join(root, 'uc'), limit: null, offset: 0,\n });\n\n assert.ok(result.success, result.error);\n const got = result.targets.map(t => `${t.identifier}|${t.className}`).sort();\n assert.deepStrictEqual(got, [\n 'java.io.FileInputStream|com.acme.core.servlets.DownloadServlet',\n 'java.io.InputStream|com.acme.core.servlets.DownloadServlet',\n 'java.io.ObjectInputStream|acscommons.com.google.common.cache.LocalCache$LoadingSerializationProxy',\n ]);\n assert.ok(result.targets.every(t => t.pattern === 'inputStream'));\n const acs = result.targets.find(t => t.className.startsWith('acscommons.'));\n assert.strictEqual(acs.bundle, 'com.adobe.acs.acs-aem-commons-bundle', 'bundle survives CSV → unified collection → target');\n });\n}\n\ntest('inputStream: an unparseable row warns and is dropped, not silently counted', async () => {\n const root = mkworkspace();\n const p = path.join(root, 'bad.csv');\n fs.writeFileSync(p, [\n 'code,type,subtype,importance,identifier,message,context',\n 'DG,development.guideline,java.io.inputstream,ADVISORY,com.acme.Foo,Unexpected message format.,ctx',\n 'DG,development.guideline,java.io.inputstream,ADVISORY,com.acme.Bar,The com.acme.Bar class in the b bundle uses java.io.InputStream. x,ctx',\n ].join('\\n') + '\\n', 'utf8');\n\n const warnings = [];\n const originalWarn = console.warn;\n console.warn = (...args) => warnings.push(args.join(' '));\n let result;\n try {\n result = await getBpaFindings('inputStream', { bpaFilePath: p, collectionsDir: path.join(root, 'uc'), limit: null, offset: 0 });\n } finally {\n console.warn = originalWarn;\n }\n assert.deepStrictEqual(result.targets.map(t => t.className), ['com.acme.Bar']);\n assert.ok(warnings.some(w => /java\\.io\\.inputstream row could not be parsed/.test(w)));\n});\n\ntest('inputStream: a class listed under two bundles keeps one target per bundle, and Forms add-on rows are platform-provided', async () => {\n const root = mkworkspace();\n const csv = path.join(root, 'bundles.csv');\n const row = (cls, api, bundle) =>\n `DG,development.guideline,java.io.inputstream,ADVISORY,${cls},${api},The ${cls} class in the ${bundle} bundle uses ${api}. This may cause issues if used in AEM as a Cloud Service.,https://www.adobe.com/go/aem_pd_DG,ctx`;\n fs.writeFileSync(csv, [\n 'code,type,subtype,importance,identifier,other,message,moreInfo,context',\n row('shaded.io.ByteSource', 'java.io.InputStream', 'com.adobe.acs.acs-aem-commons-bundle'),\n row('shaded.io.ByteSource', 'java.io.InputStream', 'com.adobe.acs.acs-aem-tools-bundle'),\n row('com.adobe.fd.signatures.Sig', 'java.io.ObjectInputStream', 'adobe-aemfd-signatures'),\n ].join('\\n') + '\\n', 'utf8');\n\n const targets = (await getBpaFindings('inputStream', { bpaFilePath: csv, collectionsDir: path.join(root, 'uc'), limit: null, offset: 0 })).targets;\n assert.deepStrictEqual(targets.map(t => `${t.className}@${t.bundle}`).sort(), [\n 'com.adobe.fd.signatures.Sig@adobe-aemfd-signatures',\n '[email protected]',\n '[email protected]',\n ]);\n\n const gathered = await gatherFindings({\n workspaceRoot: root, bpaFilePath: csv, collectionsDir: path.join(root, 'uc'),\n analyzeScript: path.join(root, 'missing-analyze.sh'),\n });\n const groups = gathered.rawFindingsByPattern.inputStream.filter(r => r.file === null);\n assert.deepStrictEqual(groups.map(g => `${g.kind}:${g.bundle}:${g.rowCount}`), [\n 'not-in-workspace:com.adobe.acs.acs-aem-commons-bundle:1',\n 'not-in-workspace:com.adobe.acs.acs-aem-tools-bundle:1',\n 'platform-provided:adobe-aemfd-signatures:1',\n ]);\n});\n\nfunction writeDownloadServlet(root) {\n return write(root, 'core/src/main/java/com/acme/core/servlets/DownloadServlet.java', [\n 'package com.acme.core.servlets;',\n '',\n 'import java.io.FileInputStream;',\n 'import java.io.InputStream;',\n 'import org.apache.commons.io.IOUtils;',\n '',\n 'public class DownloadServlet {',\n ' protected void doGet(Asset asset, SlingHttpServletResponse response) throws Exception {',\n ' InputStream in = asset.getOriginal().getStream();',\n ' IOUtils.copy(in, response.getOutputStream());',\n ' }',\n '',\n ' String config() throws Exception {',\n ' try (InputStream in = new FileInputStream(\"/opt/acme/config.json\")) {',\n ' return IOUtils.toString(in, \"UTF-8\");',\n ' }',\n ' }',\n '}',\n '',\n ].join('\\n'));\n}\n\ntest('inputStream: CAM has no rows for the subtype → the Java source scan owns it (no LLM tier)', async () => {\n const root = mkworkspace();\n writeDownloadServlet(root);\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'nocollections'),\n preFetchedBpa: { inputStream: [] },\n analyzeScript: path.join(root, 'missing-analyze.sh'),\n });\n assert.strictEqual(gathered.bpaMode, 'mcp');\n assert.strictEqual(gathered.sourceByPattern.inputStream, 'source-scan');\n assert.ok(!gathered.needsLlmScan.includes('inputStream'));\n assert.strictEqual(gathered.findingsByPattern.inputStream.length, 1, 'one finding per file, not per site');\n});\n\ntest('inputStream: a workspace with no Java sources is clean, not LLM-scanned, and emits no warning', async () => {\n const root = mkworkspace();\n const gathered = await gatherFindings({ workspaceRoot: root, collectionsDir: path.join(root, 'nocollections'), analyzeScript: path.join(root, 'missing-analyze.sh') });\n assert.strictEqual(gathered.sourceByPattern.inputStream, 'source-scan');\n assert.deepStrictEqual(gathered.findingsByPattern.inputStream, []);\n assert.ok(!gathered.needsLlmScan.includes('inputStream'));\n assert.ok(!gathered.analyzerWarnings.some(w => /inputStream/.test(w)));\n});\n\nfunction writeLocalOnlyStreamFile(root) {\n // Uses streams, but isn't in the BPA report.\n return write(root, 'core/src/main/java/com/acme/core/LocalOnly.java', [\n 'package com.acme.core;',\n 'import java.io.InputStream;',\n 'public class LocalOnly {',\n ' void run(Resource res) { InputStream in = res.adaptTo(InputStream.class); use(in); }',\n ' void use(InputStream in) {}',\n '}',\n '',\n ].join('\\n'));\n}\n\ntest('inputStream: BPA rows take precedence — only BPA-reported classes become findings; third-party rows collapse per bundle', async () => {\n const root = mkworkspace();\n writeDownloadServlet(root);\n writeLocalOnlyStreamFile(root);\n const csv = writeInputStreamBpaCsv(root);\n const gathered = await gatherFindings({\n workspaceRoot: root,\n bpaFilePath: csv,\n collectionsDir: path.join(root, 'uc'),\n analyzeScript: path.join(root, 'missing-analyze.sh'),\n });\n assert.strictEqual(gathered.sourceByPattern.inputStream, 'csv', 'BPA owns the pattern, like the other BPA patterns');\n\n const raw = gathered.rawFindingsByPattern.inputStream;\n const sourceFiles = raw.filter(r => r.kind === 'source').map(r => path.basename(r.file));\n assert.deepStrictEqual(sourceFiles, ['DownloadServlet.java'], 'LocalOnly.java is not in the BPA report, so it is out of scope');\n const fileFinding = raw.find(r => r.kind === 'source');\n assert.deepStrictEqual(fileFinding.bpa.apis, ['java.io.FileInputStream', 'java.io.InputStream']);\n const cats = fileFinding.sites.map(s => `${s.line}:${s.categories.join('+')}`);\n assert.deepStrictEqual(cats, ['9:E+B', '14:D'], 'E (proxied binary) + B (unclosed) on L9, D (local file) on L14');\n assert.strictEqual(fileFinding.severity, 'high');\n\n const group = raw.find(r => r.kind === 'not-in-workspace');\n assert.strictEqual(group.bundle, 'com.adobe.acs.acs-aem-commons-bundle');\n assert.strictEqual(group.informational, true);\n\n // Only the workspace file counts as outstanding work.\n const actionable = gathered.findingsByPattern.inputStream.filter(f => !f.informational);\n assert.strictEqual(actionable.length, 1);\n});\n\ntest('inputStream: a BPA report without inputStream rows falls back to the local scan', async () => {\n const root = mkworkspace();\n writeDownloadServlet(root);\n writeLocalOnlyStreamFile(root);\n const csv = path.join(root, 'other.csv');\n fs.writeFileSync(csv, [\n 'code,type,subtype,importance,identifier,message,context',\n 'GC,development.guideline,custom.guava.cache,INFO,com.google.common.cache.Cache,The com.google.common.cache.Cache class in the b bundle uses com.google.common.cache.Cache.,ctx',\n ].join('\\n') + '\\n', 'utf8');\n const gathered = await gatherFindings({\n workspaceRoot: root, bpaFilePath: csv, collectionsDir: path.join(root, 'uc'),\n analyzeScript: path.join(root, 'missing-analyze.sh'),\n });\n assert.strictEqual(gathered.bpaMode, 'csv');\n assert.strictEqual(gathered.sourceByPattern.inputStream, 'source-scan');\n const files = gathered.rawFindingsByPattern.inputStream.filter(r => r.kind === 'source').map(r => path.basename(r.file)).sort();\n assert.deepStrictEqual(files, ['DownloadServlet.java', 'LocalOnly.java']);\n});\n\ntest('inputStream: CAM targets (no bundle names) take precedence and group Forms classes by package', async () => {\n const root = mkworkspace();\n writeDownloadServlet(root);\n writeLocalOnlyStreamFile(root);\n const gathered = await gatherFindings({\n workspaceRoot: root,\n collectionsDir: path.join(root, 'nocollections'),\n preFetchedBpa: {\n inputStream: [\n { pattern: 'inputStream', className: 'com.acme.core.servlets.DownloadServlet', identifier: 'java.io.InputStream' },\n { pattern: 'inputStream', className: 'com.adobe.fd.signatures.SigUtil', identifier: 'java.io.InputStream' },\n { pattern: 'inputStream', className: 'org.jsoup.helper.DataUtil', identifier: 'java.io.InputStream' },\n ],\n },\n analyzeScript: path.join(root, 'missing-analyze.sh'),\n });\n assert.strictEqual(gathered.sourceByPattern.inputStream, 'mcp');\n const raw = gathered.rawFindingsByPattern.inputStream;\n assert.deepStrictEqual(raw.filter(r => r.kind === 'source').map(r => path.basename(r.file)), ['DownloadServlet.java']);\n assert.deepStrictEqual(raw.filter(r => r.file === null).map(g => `${g.kind}:${g.group}`), [\n 'not-in-workspace:package org.jsoup.helper',\n 'platform-provided:package com.adobe.fd',\n ]);\n});\n\ntest('inputStream: rendered runbook + cache stay workspace-relative and carry site-level detail', async () => {\n const root = mkworkspace();\n writeDownloadServlet(root);\n const csv = writeInputStreamBpaCsv(root);\n const out = path.join(root, 'migration-runbook.md');\n const cachePath = path.join(root, 'migration-runbook.json');\n await generateRunbook({\n workspaceRoot: root, bpaFilePath: csv, collectionsDir: path.join(root, 'uc'),\n analyzeScript: path.join(root, 'missing-analyze.sh'), outputPath: out, cachePath,\n });\n const md = fs.readFileSync(out, 'utf8');\n assert.ok(!md.includes(root), 'no absolute workspace path in the runbook');\n assert.ok(md.includes('core/src/main/java/com/acme/core/servlets/DownloadServlet.java:9'));\n assert.ok(/bundle com\\.adobe\\.acs\\.acs-aem-commons-bundle/.test(md));\n\n const cache = JSON.parse(fs.readFileSync(cachePath, 'utf8'));\n const entry = cache.findingsByPattern.inputStream.find(r => r.kind === 'source');\n assert.strictEqual(entry.file, 'core/src/main/java/com/acme/core/servlets/DownloadServlet.java');\n assert.strictEqual(entry.confidence, 'heuristic');\n assert.ok(entry.sites.every(s => Number.isInteger(s.line) && s.fix));\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":92259,"content_sha256":"e1df88914bdc4b36d77aa63ca5b8e6baa9ac5ac79c0028614f0fd395283fc3cc"},{"filename":"scripts/template-scan-runner.js","content":"/**\n * Template Scan Runner\n *\n * The `content-scan` detection tier of the runbook for the\n * **templateModernization** pattern — static templates that should become\n * editable templates. Mirrors the discovery in\n * `{migration}/references/template-modernization/template-modernization-context.md`\n * (static templates live under any `jcr_root/apps/\u003cappId>/templates/` subtree,\n * in a `.content.xml` that declares a `cq:Template`).\n *\n * Pure-Node fs walk (no external tools). Heuristic — the agent runs the full\n * per-template context → execute → validate pipeline (Branch C) to confirm and\n * modernize each one.\n */\n\n'use strict';\n\nconst fs = require('fs');\nconst path = require('path');\n\n// Page-component resource types that mark a static template as legacy/OOTB-derived\n// rather than a project-authored custom template. Mirrors the BPA distinction\n// between `legacy.static.template` and `custom.static.template`.\nconst LEGACY_RT_PREFIXES = [\n 'wcm/foundation',\n 'foundation/components',\n 'wcm/core/components',\n 'cq/',\n 'geometrixx',\n];\n\n/**\n * Recursively find static-template `.content.xml` files: a `.content.xml`\n * that lives at any depth under an `apps/\u003cappId>/templates/` folder (so\n * nested/grouped templates are not missed) and whose content declares a\n * `cq:Template` (the static-template marker).\n */\nfunction collectStaticTemplates(dir, acc = []) {\n let entries;\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }\n for (const e of entries) {\n const full = path.join(dir, e.name);\n if (e.isDirectory()) {\n if (e.name === 'node_modules' || e.name === '.git' || e.name === 'target' || e.name === 'dist') continue;\n collectStaticTemplates(full, acc);\n } else if (e.isFile() && e.name === '.content.xml') {\n // Path shape: .../jcr_root/apps/\u003cappId>/templates/.../\u003ctemplateName>/.content.xml\n // Anchor on `apps/\u003cappId>/templates/` but allow any nesting below it — the\n // `cq:Template` content check below is what actually confirms a template,\n // so we don't need an exact depth clamp (which dropped nested templates).\n const parts = full.split(path.sep);\n const ti = parts.lastIndexOf('templates');\n const inAppsTemplates =\n ti > 0 && parts[ti - 2] === 'apps' && parts.length >= ti + 3;\n if (!inAppsTemplates) continue;\n let content = '';\n try { content = fs.readFileSync(full, 'utf8'); } catch { continue; }\n if (/jcr:primaryType=\"cq:Template\"/.test(content)) acc.push({ file: full, content });\n }\n }\n return acc;\n}\n\n/**\n * Classify a static template as `custom.static.template` (project-authored) or\n * `legacy.static.template` (OOTB/foundation-derived) from its page-component\n * resource type. Heuristic: a template whose page component points at a\n * foundation/OOTB ancestor is legacy; anything else authored under the\n * project's own `apps/\u003cappId>` tree defaults to custom.\n *\n * @returns {'custom.static.template' | 'legacy.static.template'}\n */\nfunction classifyStaticTemplate(content) {\n const xml = content || '';\n // Only the page component on the template's own `jcr:content` node is a\n // reliable signal. Scanning any resourceType in the file would pick up a\n // descendant (e.g. a foundation parsys) and misclassify an otherwise-custom\n // template as legacy, so when jcr:content declares no resourceType we default\n // to custom (the template lives under the project's own apps/\u003cappId> tree).\n const m = /\u003cjcr:content\\b[^>]*\\bsling:resourceType=\"([^\"]+)\"/.exec(xml);\n const rt = m ? m[1].replace(/^\\/(?:apps|libs)\\//, '') : '';\n if (rt && LEGACY_RT_PREFIXES.some((p) => rt.startsWith(p))) {\n return 'legacy.static.template';\n }\n return 'custom.static.template';\n}\n\n/**\n * Scan for static templates under `workspaceRoot`.\n *\n * @returns {{ ok: boolean, findings: Array, rawFindings: Array, warnings: string[] }}\n */\nfunction runTemplateScan(workspaceRoot) {\n if (!workspaceRoot) return { ok: false, findings: [], rawFindings: [], warnings: [], error: 'no workspaceRoot' };\n const findings = [];\n const rawFindings = [];\n for (const { file, content } of collectStaticTemplates(workspaceRoot)) {\n const templateName = file.split(path.sep).slice(-2, -1)[0];\n const subType = classifyStaticTemplate(content);\n const kind = subType === 'custom.static.template' ? 'custom' : 'legacy';\n findings.push({\n location: file,\n detail: `${kind} static-template '${templateName}' — modernize to an editable template (Branch C)`,\n severity: 'medium',\n });\n rawFindings.push({ pattern: 'templateModernization', file, line: null, snippet: `static template (cq:Template, ${kind})`, subType });\n }\n return { ok: true, findings, rawFindings, warnings: [] };\n}\n\nmodule.exports = { runTemplateScan, collectStaticTemplates, classifyStaticTemplate };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4860,"content_sha256":"b7ea39680a3528d863da2a734185a75a3cedfdde257b79449da247b3d14cc071"},{"filename":"scripts/unified-collection-reader.js","content":"/**\n * Unified Collection Reader\n * \n * Reads unified code transformer collections (cloud-adoption-service format)\n * and returns data in the same format as the CAM BPA fetcher MCP tool.\n */\n\nconst fs = require('fs');\nconst path = require('path');\n\n// Default batch size for paged access to findings. Keep this in sync with\n// the documented batch size in migration/SKILL.md and cam-mcp.md.\nconst DEFAULT_BATCH_SIZE = 5;\n\n// Pattern to subtype mapping (matching cam-bpa-fetcher.ts)\nconst PATTERN_TO_SUBTYPE = {\n scheduler: \"sling.commons.scheduler\",\n assetApi: \"unsupported.asset.api\",\n eventListener: \"javax.jcr.observation.EventListener\",\n resourceChangeListener: \"org.apache.sling.api.resource.observation.ResourceChangeListener\",\n eventHandler: \"org.osgi.service.event.EventHandler\",\n guavaCache: \"custom.guava.cache\",\n oakIndex: \"oak.index.definition\",\n inputStream: \"java.io.inputstream\"\n};\n\n// MongoDB-safe to pattern mapping\nconst MONGO_SAFE_TO_PATTERN = {\n \"sling_commons_scheduler\": \"scheduler\",\n \"unsupported_asset_api\": \"assetApi\",\n \"javax_jcr_observation_EventListener\": \"eventListener\",\n \"org_apache_sling_api_resource_observation_ResourceChangeListener\": \"resourceChangeListener\",\n \"org_osgi_service_event_EventHandler\": \"eventHandler\",\n \"custom_guava_cache\": \"guavaCache\",\n \"oak_index_definition\": \"oakIndex\",\n \"java_io_inputstream\": \"inputStream\"\n};\n\n// Pattern → subtype(s), 1:many. Covers the Java patterns above plus the\n// content/legacy-UI patterns whose findings are keyed by JCR path. A subtype\n// may belong to more than one pattern (e.g. legacy.static.template is both a\n// LUI sub-type and a template-modernization candidate).\nconst PATTERN_TO_SUBTYPES = {\n scheduler: [\"sling.commons.scheduler\"],\n assetApi: [\"unsupported.asset.api\"],\n eventListener: [\"javax.jcr.observation.EventListener\"],\n resourceChangeListener: [\"org.apache.sling.api.resource.observation.ResourceChangeListener\"],\n eventHandler: [\"org.osgi.service.event.EventHandler\"],\n cdw: [\"custom.classic.widget\"],\n lui: [\"legacy.dialog.classic\", \"legacy.dialog.coral2\", \"legacy.custom.component\", \"legacy.static.template\"],\n templateModernization: [\"legacy.static.template\", \"custom.static.template\"],\n replication: [\"forward.replication\", \"reverse.replication\"],\n urc: [\"unsupported.runmode\"],\n guavaCache: [\"custom.guava.cache\"],\n oakIndex: [\"oak.index.definition\"],\n inputStream: [\"java.io.inputstream\"],\n};\n\n// Patterns whose findings are keyed by JCR path (raw keys, generic processor).\nconst CONTENT_PATTERNS = new Set([\"cdw\", \"lui\", \"templateModernization\", \"replication\", \"urc\"]);\n\n/** MongoDB-safe subtype key: dots → underscores (matches the parser). */\nfunction toMongoSafeSubtype(subtype) {\n return subtype ? subtype.replace(/\\./g, '_') : null;\n}\n\n// Known scheduler identifier\nconst SCHEDULER_IDENTIFIER = \"org.apache.sling.commons.scheduler\";\n\n/**\n * BPA Target type (matching cam-bpa-fetcher.ts)\n */\nclass BpaTarget {\n constructor(pattern, className, identifier, issue, severity = \"high\") {\n this.pattern = pattern;\n this.className = className;\n this.identifier = identifier;\n this.issue = issue;\n this.severity = severity;\n }\n}\n\n/**\n * BPA Result type (matching cam-bpa-fetcher.ts)\n */\nclass BpaResult {\n constructor(success = false) {\n this.success = success;\n this.environment = 'local';\n this.projectId = 'unified-collection';\n this.targets = [];\n this.summary = {};\n this.message = null;\n this.error = null;\n this.errorDetails = null;\n this.troubleshooting = [];\n this.suggestion = [];\n }\n}\n\n/**\n * Convert MongoDB-safe field name back to original format\n */\nfunction fromMongoSafeFieldName(mongoSafeFieldName) {\n return mongoSafeFieldName ? mongoSafeFieldName.replace(/_/g, '.') : null;\n}\n\n/**\n * Slice a flat array of findings and return `{ targets, paging }`.\n *\n * Caller must produce `items` in a stable, deterministic order so that\n * incrementing `offset` across calls yields contiguous, non-overlapping batches.\n * `limit: null` disables batching (returns everything).\n */\nfunction paginate(items, { offset = 0, limit = DEFAULT_BATCH_SIZE } = {}) {\n const list = Array.isArray(items) ? items : [];\n const total = list.length;\n const start = Math.max(0, Math.floor(Number(offset)) || 0);\n const unbounded = limit === null;\n const size = unbounded ? total : Math.max(1, Math.floor(Number(limit)) || DEFAULT_BATCH_SIZE);\n const targets = list.slice(start, unbounded ? total : start + size);\n const reached = start + targets.length;\n const nextOffset = reached \u003c total ? reached : null;\n return {\n targets,\n paging: {\n total,\n returned: targets.length,\n offset: start,\n limit: unbounded ? null : size,\n nextOffset,\n hasMore: nextOffset !== null\n }\n };\n}\n\n/**\n * Check if unified collection exists\n */\nfunction hasUnifiedCollection(collectionsDir = './unified-collections') {\n const unifiedPath = path.join(collectionsDir, 'unified-collection.json');\n try {\n return fs.existsSync(unifiedPath) && fs.statSync(unifiedPath).isFile();\n } catch (error) {\n return false;\n }\n}\n\n/**\n * Get available patterns in unified collection\n */\nfunction getAvailablePatterns(collectionsDir = './unified-collections') {\n if (!hasUnifiedCollection(collectionsDir)) {\n return [];\n }\n \n try {\n const unifiedCollection = readUnifiedCollection(collectionsDir);\n if (!unifiedCollection || !unifiedCollection.subtypes) {\n return [];\n }\n \n const present = new Set(Object.keys(unifiedCollection.subtypes));\n // A pattern is available if ANY of its subtypes is present in the collection.\n const patterns = Object.keys(PATTERN_TO_SUBTYPES).filter(pattern =>\n PATTERN_TO_SUBTYPES[pattern].some(st => present.has(toMongoSafeSubtype(st)))\n );\n\n return patterns;\n } catch (error) {\n console.error('Error reading unified collection:', error.message);\n return [];\n }\n}\n\n/**\n * Read unified collection file\n */\nfunction readUnifiedCollection(collectionsDir = './unified-collections') {\n const unifiedPath = path.join(collectionsDir, 'unified-collection.json');\n \n if (!fs.existsSync(unifiedPath)) {\n return null;\n }\n \n try {\n const content = fs.readFileSync(unifiedPath, 'utf8');\n return JSON.parse(content);\n } catch (error) {\n console.error('Error reading unified collection:', error.message);\n return null;\n }\n}\n\n/**\n * Process scheduler data from unified collection\n */\nfunction processSchedulerFromUnified(subtypeData, targets) {\n let count = 0;\n \n // Sort identifiers alphabetically so the iteration order is deterministic\n // across runs, independent of how the unified-collection JSON was written.\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const classNames = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n \n for (const className of classNames) {\n count++;\n targets.push(new BpaTarget(\n \"scheduler\",\n className,\n identifier,\n \"Uses imperative Scheduler API instead of declarative @SlingScheduled annotation\",\n \"high\"\n ));\n }\n }\n \n return count;\n}\n\n/**\n * Process asset API data from unified collection\n */\nfunction processAssetApiFromUnified(subtypeData, targets) {\n let count = 0;\n \n // Sort identifiers alphabetically so the iteration order is deterministic\n // across runs, independent of how the unified-collection JSON was written.\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const classNames = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n \n for (const className of classNames) {\n count++;\n targets.push(new BpaTarget(\n \"assetApi\",\n className,\n identifier,\n `Uses unsupported Asset API: ${identifier}`,\n \"critical\"\n ));\n }\n }\n \n return count;\n}\n\n/**\n * Process event listener data from unified collection\n */\nfunction processEventListenerFromUnified(subtypeData, targets) {\n let count = 0;\n \n // Sort identifiers alphabetically so the iteration order is deterministic\n // across runs, independent of how the unified-collection JSON was written.\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const classNames = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n \n for (const className of classNames) {\n count++;\n targets.push(new BpaTarget(\n \"eventListener\",\n className,\n identifier,\n `Uses JCR Event Listener: ${identifier}`,\n \"high\"\n ));\n }\n }\n \n return count;\n}\n\n/**\n * Process resource change listener data from unified collection\n */\nfunction processResourceChangeListenerFromUnified(subtypeData, targets) {\n let count = 0;\n \n // Sort identifiers alphabetically so the iteration order is deterministic\n // across runs, independent of how the unified-collection JSON was written.\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const classNames = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n \n for (const className of classNames) {\n count++;\n targets.push(new BpaTarget(\n \"resourceChangeListener\",\n className,\n identifier,\n `Uses Resource Change Listener: ${identifier}`,\n \"high\"\n ));\n }\n }\n \n return count;\n}\n\n/**\n * Process event handler data from unified collection\n */\nfunction processEventHandlerFromUnified(subtypeData, targets) {\n let count = 0;\n \n // Sort identifiers alphabetically so the iteration order is deterministic\n // across runs, independent of how the unified-collection JSON was written.\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const classNames = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n \n for (const className of classNames) {\n count++;\n targets.push(new BpaTarget(\n \"eventHandler\",\n className,\n identifier,\n `Uses OSGi Event Handler: ${identifier}`,\n \"high\"\n ));\n }\n }\n \n return count;\n}\n\n/**\n * Process Guava cache data from unified collection. One target per\n * **bundle** — the bundle name (the actionable unit) is deduped upstream in\n * `processGuavaCacheFindings` (bpa-local-parser.js), so a bundle that produced\n * hundreds of raw CSV rows (one per Guava-internal class BPA found on its\n * classpath, e.g. `com.google.common.cache.AbstractCache` — never a customer\n * class) still collapses to exactly one migration unit here.\n *\n * Field shape is inverted from every other pattern in this file: `className`\n * carries the actionable bundle name, and `identifier` carries the fixed\n * subtype string `custom.guava.cache` (not a real class) — because BPA's own\n * `identifier` column on this subtype is the Guava-internal class, which is\n * never actionable, so it's discarded rather than round-tripped.\n */\nfunction processGuavaCacheFromUnified(subtypeData, targets) {\n let count = 0;\n\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const bundleNames = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n\n for (const bundleName of bundleNames) {\n count++;\n targets.push(new BpaTarget(\n \"guavaCache\",\n bundleName,\n identifier,\n `Bundle uses Guava cache: ${bundleName}`,\n \"info\"\n ));\n }\n }\n\n return count;\n}\n\n/**\n * Process Oak index data from unified collection. Unlike the content/LUI\n * patterns, the unified data here is keyed by the BPA **sub-type** string\n * (`index.rule.violation` / `standard.index.modification`, MongoDB-safe\n * round-tripped), each mapping to an array of raw JCR index paths — so a\n * generic `processContentFromUnified` pass (keyed by JCR path) doesn't fit.\n *\n * Field shape mirrors `processGuavaCacheFromUnified`: `className` carries the\n * actionable JCR index path, and `identifier` carries the BPA sub-type string\n * (which distinguishes a full rule violation from an OOTB index modified in\n * place — both need the Index Converter tool, but the message differs).\n */\nfunction processOakIndexFromUnified(subtypeData, targets) {\n let count = 0;\n\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const indexPaths = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n\n for (const indexPath of indexPaths) {\n count++;\n targets.push(new BpaTarget(\n \"oakIndex\",\n indexPath,\n identifier,\n `Oak index definition (${identifier}): ${indexPath}`,\n \"high\"\n ));\n }\n }\n\n return count;\n}\n\n/**\n * Process java.io.InputStream data from unified collection. Keyed by the\n * referenced API (`java.io.FileInputStream`, …) → flagged class names, so each\n * target carries the class to locate (`className`) and the API to classify by\n * (`identifier`) — see migration/references/input-stream.md. When the parser\n * recorded bundles (`meta.inputStreamBundles`), there is one target per\n * (API, class, bundle), each with its `bundle`.\n */\nfunction processInputStreamFromUnified(subtypeData, targets, meta = {}) {\n let count = 0;\n const bundlesByApi = meta.inputStreamBundles || {};\n\n const identifierKeys = Object.keys(subtypeData || {}).sort();\n for (const mongoSafeIdentifier of identifierKeys) {\n const classNames = subtypeData[mongoSafeIdentifier] || [];\n const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);\n const bundlesByClass = bundlesByApi[identifier] || {};\n\n for (const className of classNames) {\n for (const bundle of bundlesByClass[className] || [null]) {\n count++;\n const target = new BpaTarget(\n \"inputStream\",\n className,\n identifier,\n `Uses ${identifier} — triage per references/input-stream.md`,\n \"medium\"\n );\n if (bundle) target.bundle = bundle;\n targets.push(target);\n }\n }\n }\n\n return count;\n}\n\n/**\n * Process a content/legacy-UI subtype whose unified data is keyed by RAW JCR\n * path (no MongoDB round-trip). Emits one target per finding, with the JCR path\n * as `className` (so downstream `location`/`file` resolve to the path) and the\n * BPA sub-type as `identifier`.\n */\nfunction processContentFromUnified(subtypeData, targets, pattern, subtype) {\n let count = 0;\n const pathKeys = Object.keys(subtypeData || {}).sort();\n for (const jcrPath of pathKeys) {\n const values = subtypeData[jcrPath] || [];\n for (let i = 0; i \u003c values.length; i++) {\n count++;\n targets.push(new BpaTarget(\n pattern,\n jcrPath, // className → runbook location/file\n subtype, // identifier → runbook detail (the sub-type)\n `${pattern} finding (${subtype}): ${jcrPath}`,\n \"high\"\n ));\n }\n }\n return count;\n}\n\n/**\n * Fetch findings from unified collection (mimics cam-bpa-fetcher behavior).\n *\n * The full ordered list for the requested `pattern` is assembled, then\n * sliced into a batch via {@link paginate}. The returned object contains\n * `targets` (the batch) and `paging` (batch metadata for resuming).\n *\n * Ordering contract: for a given unified-collection.json file and a given\n * `pattern`, successive calls with incrementing `offset` return contiguous,\n * non-overlapping batches covering the full list.\n *\n * @param {string} pattern\n * @param {string} collectionsDir\n * @param {{offset?: number, limit?: number|null|'all'}} [paging]\n */\nfunction fetchUnifiedBpaFindings(pattern = \"all\", collectionsDir = './unified-collections', paging = {}) {\n const result = new BpaResult();\n \n // Check if unified collection exists\n if (!hasUnifiedCollection(collectionsDir)) {\n result.error = `Unified collection not found: ${collectionsDir}/unified-collection.json`;\n result.troubleshooting = [\n \"Run bpa-local-parser.js with --unified flag to create unified collection\",\n \"Ensure the collections directory path is correct\",\n \"Check that BPA file has been processed successfully\"\n ];\n result.suggestion = [\n \"Use: node bpa-local-parser.js \u003cbpa-file-path> [output-directory] --unified\",\n \"Verify BPA file contains valid findings data\"\n ];\n return result;\n }\n \n // Read unified collection\n const unifiedCollection = readUnifiedCollection(collectionsDir);\n if (!unifiedCollection || !unifiedCollection.subtypes) {\n result.error = \"Invalid unified collection format\";\n result.troubleshooting = [\n \"Check that unified-collection.json contains valid JSON\",\n \"Verify the file has 'subtypes' property\",\n \"Re-run bpa-local-parser.js if file is corrupted\"\n ];\n return result;\n }\n \n // Determine patterns to fetch\n const availablePatterns = getAvailablePatterns(collectionsDir);\n \n if (availablePatterns.length === 0) {\n result.error = \"No valid pattern collections found in unified collection\";\n result.troubleshooting = [\n \"Check that unified collection contains supported subtypes\",\n \"Verify pattern files contain valid data\",\n \"Re-run bpa-local-parser.js if data is missing\"\n ];\n return result;\n }\n \n // Sort patterns alphabetically so \"all\" returns findings in a deterministic\n // order independent of how availablePatterns was produced.\n const patternsToFetch = pattern === \"all\"\n ? [...availablePatterns].sort()\n : availablePatterns.filter(p => p === pattern);\n \n if (patternsToFetch.length === 0) {\n result.error = `Pattern '${pattern}' not found in unified collection. Available: ${availablePatterns.join(', ')}`;\n result.suggestion = [\n `Use one of: ${availablePatterns.join(', ')}, all`,\n \"Check that the requested pattern was included in the BPA report\"\n ];\n return result;\n }\n \n console.log(`[Unified Collection Reader] Reading patterns: ${patternsToFetch.join(', ')}`);\n \n // Processors for the Java patterns (keyed by class name). Content/legacy-UI\n // patterns use the generic JCR-path processor.\n const JAVA_PROCESSORS = {\n scheduler: processSchedulerFromUnified,\n assetApi: processAssetApiFromUnified,\n eventListener: processEventListenerFromUnified,\n resourceChangeListener: processResourceChangeListenerFromUnified,\n eventHandler: processEventHandlerFromUnified,\n guavaCache: processGuavaCacheFromUnified,\n oakIndex: processOakIndexFromUnified,\n inputStream: processInputStreamFromUnified,\n };\n\n // Process each pattern — a pattern may map to more than one subtype.\n for (const pat of patternsToFetch) {\n const subtypes = PATTERN_TO_SUBTYPES[pat];\n if (!subtypes) {\n console.warn(`[Unified Collection Reader] Unknown pattern: ${pat}, skipping`);\n continue;\n }\n\n let count = 0;\n for (const subtype of subtypes) {\n const subtypeData = unifiedCollection.subtypes[toMongoSafeSubtype(subtype)];\n if (!subtypeData) continue;\n count += CONTENT_PATTERNS.has(pat)\n ? processContentFromUnified(subtypeData, result.targets, pat, subtype)\n : JAVA_PROCESSORS[pat](subtypeData, result.targets, unifiedCollection.meta || {});\n }\n result.summary[`${pat}Count`] = count;\n\n console.log(`[Unified Collection Reader] Processed ${count} findings for pattern: ${pat}`);\n }\n \n if (result.targets.length === 0) {\n result.error = \"No findings found in unified collection\";\n result.suggestion = [\n \"Verify that BPA file contained relevant findings\",\n \"Check that bpa-local-parser.js processed the file correctly\",\n \"Inspect unified collection file for expected data structure\"\n ];\n return result;\n }\n\n const { targets, paging: p } = paginate(result.targets, paging);\n result.targets = targets;\n result.paging = p;\n result.success = true;\n console.log(\n `[Unified Collection Reader] Batch ${p.returned}/${p.total} ` +\n `(offset ${p.offset}, hasMore=${p.hasMore})`\n );\n return result;\n}\n\n/**\n * Get summary of unified collection (derived from unified-collection.json).\n * Returns { timestamp, subtypes, totalFindings } for display purposes.\n */\nfunction getUnifiedCollectionSummary(collectionsDir = './unified-collections') {\n const unifiedCollection = readUnifiedCollection(collectionsDir);\n if (!unifiedCollection || !unifiedCollection.subtypes) {\n return null;\n }\n\n const subtypes = Object.keys(unifiedCollection.subtypes);\n let totalFindings = 0;\n for (const subtypeKey of subtypes) {\n const subtypeData = unifiedCollection.subtypes[subtypeKey] || {};\n for (const classNames of Object.values(subtypeData)) {\n totalFindings += (classNames || []).length;\n }\n }\n\n return {\n timestamp: unifiedCollection.meta?.timestamp || null,\n subtypes,\n totalFindings: unifiedCollection.meta?.totalFindings ?? totalFindings\n };\n}\n\n/**\n * CLI interface for testing\n *\n * Usage: node unified-collection-reader.js \u003cpattern> [collectionsDir] [offset] [limit]\n * offset defaults to 0\n * limit defaults to 5 (use 'all' to disable batching)\n */\nfunction main() {\n const args = process.argv.slice(2);\n const pattern = args[0] || 'all';\n const collectionsDir = args[1] || './unified-collections';\n const offset = args[2] !== undefined ? Number(args[2]) : 0;\n const limit = args[3] !== undefined\n ? (args[3] === 'all' ? null : Number(args[3]))\n : DEFAULT_BATCH_SIZE;\n\n console.log('Unified Collection Reader');\n console.log('========================');\n console.log(`Pattern: ${pattern}`);\n console.log(`Collections Directory: ${collectionsDir}`);\n console.log(`Offset: ${offset}`);\n console.log(`Limit: ${limit === null ? 'all' : limit}`);\n console.log('');\n \n // Check if unified collection exists\n if (!hasUnifiedCollection(collectionsDir)) {\n console.error(`❌ Unified collection not found: ${collectionsDir}/unified-collection.json`);\n console.error('');\n console.error('To create unified collection:');\n console.error(' node bpa-local-parser.js \u003cbpa-file-path> [output-directory] --unified');\n process.exit(1);\n }\n \n // Show available patterns\n const availablePatterns = getAvailablePatterns(collectionsDir);\n console.log(`Available patterns: ${availablePatterns.join(', ')}`);\n \n // Show summary\n const summary = getUnifiedCollectionSummary(collectionsDir);\n if (summary) {\n console.log(`Total subtypes: ${summary.subtypes?.length || 0}`);\n console.log(`Total findings: ${summary.totalFindings || 0}`);\n console.log(`Created: ${summary.timestamp || '(unknown)'}`);\n }\n \n console.log('');\n \n // Fetch findings\n const result = fetchUnifiedBpaFindings(pattern, collectionsDir, { offset, limit });\n\n if (result.success) {\n const p = result.paging;\n console.log(`✅ Batch ${p.returned}/${p.total} (offset ${p.offset}, next ${p.nextOffset ?? 'none'}, hasMore=${p.hasMore})`);\n console.log('');\n \n // Group by pattern\n const byPattern = result.targets.reduce((acc, target) => {\n if (!acc[target.pattern]) acc[target.pattern] = [];\n acc[target.pattern].push(target);\n return acc;\n }, {});\n \n Object.entries(byPattern).forEach(([pat, targets]) => {\n console.log(`${pat.toUpperCase()}:`);\n targets.forEach(target => {\n console.log(` - ${target.className} (${target.severity}): ${target.issue}`);\n });\n console.log('');\n });\n } else {\n console.error('❌ Error:', result.error);\n if (result.troubleshooting?.length > 0) {\n console.error('');\n console.error('Troubleshooting:');\n result.troubleshooting.forEach(tip => console.error(` - ${tip}`));\n }\n if (result.suggestion?.length > 0) {\n console.error('');\n console.error('Suggestions:');\n result.suggestion.forEach(tip => console.error(` - ${tip}`));\n }\n process.exit(1);\n }\n}\n\n// Run CLI if called directly\nif (require.main === module) {\n main();\n}\n\nmodule.exports = {\n hasUnifiedCollection,\n getAvailablePatterns,\n fetchUnifiedBpaFindings,\n getUnifiedCollectionSummary,\n readUnifiedCollection,\n paginate,\n DEFAULT_BATCH_SIZE,\n PATTERN_TO_SUBTYPES,\n BpaTarget,\n BpaResult\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":24720,"content_sha256":"d971cdfbfada549327476b37d680ec5517498f5848306c5ef30e7bfbd2e77ae4"},{"filename":"scripts/validate-migration/build.js","content":"'use strict';\n\n/**\n * build.js — wraps `mvn clean package -DskipTests` for a validate-migration project.\n *\n * Input : { projectDir }\n * Output: { ok, artifactPath, log, elapsedMs }\n *\n * Uses whatever `java` / JAVA_HOME the customer has. Set AEM_JAVA_HOME to pin\n * a specific JDK for validate-migration builds without touching JAVA_HOME globally.\n */\nconst { execFileSync } = require('child_process');\nconst fs = require('fs');\nconst path = require('path');\n\n// Pick the build output a pattern needs from target/: bundle-runtime patterns\n// deploy an OSGi `.jar`; source-only patterns (legacy-ui, cdw) inspect a\n// content-package `.zip`, falling back to a content-embedding jar. Returns the\n// filename or undefined.\nfunction selectArtifact(files, artifact) {\n const jar = files.find((f) => f.endsWith('.jar') && !/-(sources|javadoc|tests)\\.jar$/.test(f));\n const zip = files.find((f) => f.endsWith('.zip'));\n return artifact === 'content-package' ? (zip || jar) : (jar || zip);\n}\n\nfunction build({ projectDir, artifact = 'bundle' }) {\n const t0 = Date.now();\n if (!fs.existsSync(path.join(projectDir, 'pom.xml'))) {\n return { ok: false, log: `no pom.xml at ${projectDir}` };\n }\n const env = { ...process.env };\n if (process.env.AEM_JAVA_HOME) env.JAVA_HOME = process.env.AEM_JAVA_HOME;\n\n let log;\n try {\n // validate-migration verifies runtime contract on the SDK, not customer test correctness.\n log = execFileSync('mvn', ['-q', '-B', '-DskipTests', 'clean', 'package'],\n { cwd: projectDir, env, encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });\n } catch (e) {\n return { ok: false, log: (e.stdout || '') + (e.stderr || e.message), elapsedMs: Date.now() - t0 };\n }\n\n const targetDir = path.join(projectDir, 'target');\n const chosen = selectArtifact(fs.readdirSync(targetDir), artifact);\n if (!chosen) {\n const want = artifact === 'content-package' ? '.zip content package (or content-embedding .jar)' : '.jar bundle';\n return { ok: false, log: `no ${want} under target/`, elapsedMs: Date.now() - t0 };\n }\n return { ok: true, artifactPath: path.join(targetDir, chosen), log, elapsedMs: Date.now() - t0 };\n}\n\nmodule.exports = { build, selectArtifact };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2197,"content_sha256":"d76ac8c31fe28726b99f0cdc4f2a0373648814f53991bd22c9c1a288e30bf413"},{"filename":"scripts/validate-migration/check.js","content":"#!/usr/bin/env node\n'use strict';\n\n/**\n * validate-migration check — verify one or more migrated patterns on the customer's local Cloud SDK.\n *\n * validate-migration check (auto: diff branch vs main, verify what changed)\n * validate-migration check \u003cpattern> (manual: verify one pattern, flags auto-resolved)\n * validate-migration check \u003cpattern> --finding \u003cid> --project-id \u003cpid> --project \u003cdir>\n *\n * Auto mode diffs the current branch against `main`/`origin/main` (see plan.js),\n * classifies changed files into supported patterns, and runs each as its own\n * build → deploy → verify task — each task produces its own outcome and MCP payload.\n *\n * Config, all optional (never persisted to disk):\n * AEM_SDK_URL SDK base URL (default http://localhost:4602)\n * --sdk CLI override\n *\n * The scripts never contact AEM directly or handle AEM credentials: deploy\n * runs the project's own auto-install Maven profile, and runtime state comes\n * from the AEM Quickstart MCP `diagnose-osgi-bundle` tool.\n *\n * Reads the CAM project (if any) from the nearest migration-runbook.json written by the\n * analyze / migration skill, and \u003ccwd>/pom.xml as the default project root.\n *\n * Pipeline (per task): build → discover class → deploy → verify → emit outcome via MCP.\n * Exits 0 validated and recorded, 1 validation failed, 2 usage/error,\n * 3 incomplete (awaiting MCP diagnosis or reporting acknowledgement).\n * Never modifies customer code.\n */\nif (Number(process.versions.node.split('.')[0]) \u003c 18) {\n console.error('[validate-migration] Node 18+ required.');\n process.exit(2);\n}\nconst fs = require('fs');\nconst path = require('path');\nconst { FAILURE_CLASSES } = require('./failure-classes.js');\nconst { build } = require('./build.js');\nconst { deploy } = require('./deploy.js');\nconst { computeValidationPlan } = require('./plan.js');\nconst { clearDiagnosisMap } = require('./check/lib/diagnosis.js');\nconst { writeLocalReport, finalizeReporting, resolveSdkUrl, camReportingNote, normalizeBundleState, buildMcpPayload, validationChecks } = require('./reporting.js');\n\nconst PATTERNS = require('./check/patterns');\nconst { canonicalPattern } = require('./check/patterns/aliases.js');\n\n// Wall-clock start of this run, captured in main(), used for started_at on\n// every outcome so run duration is honest.\nlet RUN_STARTED_AT = null;\n\nasync function main() {\n RUN_STARTED_AT = new Date().toISOString();\n const args = parseArgs(process.argv.slice(2));\n if (args.pattern && !PATTERNS[args.pattern]) fatal(`unknown pattern: ${args.pattern}\\nsupported: ${Object.keys(PATTERNS).join(', ')}`);\n\n const stage = args.stage || 'all';\n if (!['prepare', 'verify', 'all', 'finalize'].includes(stage)) {\n fatal(`unknown --stage: ${stage}. Use one of: prepare, verify, all, finalize.`);\n }\n\n if (stage === 'finalize') {\n if (!args.report || !args.receipt) fatal('--stage finalize requires --report \u003cjson> and --receipt \u003cjson>');\n const record = JSON.parse(fs.readFileSync(args.report, 'utf8'));\n const receipt = JSON.parse(fs.readFileSync(args.receipt, 'utf8'));\n const finalized = finalizeReporting(record, receipt);\n const report = writeLocalReport(finalized, args.project || process.cwd());\n console.log(`reporting: ${finalized.reporting.status}\\nlocal report: ${report.mdPath}`);\n process.exit(finalized.reporting.status !== 'recorded' ? 3 : finalized.result === 'pass' ? 0 : 1);\n }\n\n const context = readContext(args);\n\n // Local validation does not require a CAM project-id. When one is available\n // (either --project-id or auto-loaded from the nearest migration-runbook.json),\n // we emit the submit-migration-validation-result payload; otherwise we skip that reporting\n // step and just print the local outcome.\n const explicitProject = args['project-id'] || args['project-name'];\n if (!explicitProject && context.projectId) {\n args['project-id'] = context.projectId;\n console.log(`(auto) project-id = ${args['project-id']}`);\n }\n if (!explicitProject && context.projectName) {\n args['project-name'] = context.projectName;\n }\n\n const cwd = args.project || process.cwd();\n const statePath = path.join(cwd, '.validate-migration', 'state.json');\n\n // ---- stage: verify (consume persisted state + agent-supplied MCP diagnosis) ----\n if (stage === 'verify') {\n if (!fs.existsSync(statePath)) {\n fatal(`no ${path.relative(cwd, statePath)} found. Run --stage prepare first.`);\n }\n const state = JSON.parse(fs.readFileSync(statePath, 'utf8'));\n RUN_STARTED_AT = state.started_at || RUN_STARTED_AT;\n const records = [];\n for (const t of state.prepared) {\n records.push(await verifyPreparedTask(t, args));\n }\n return finishAll(records, args);\n }\n\n // ---- stage: prepare | all ----\n // A fresh prepare rebuilds + redeploys, so any diagnosis map from a prior run\n // is stale. Clear the default map so verify can't read stale bundle state\n // (an explicit --diagnosis-map is left as-is — it may be a fixture).\n if (!args['diagnosis-map']) {\n clearDiagnosisMap(cwd);\n }\n\n // Resolve tasks: either the pattern the caller pinned, or an auto-detected\n // set from diffing the branch against main (plan.js).\n let tasks;\n if (args.pattern) {\n if (!args.project && !args.jar) {\n if (fs.existsSync(path.join(process.cwd(), 'pom.xml'))) {\n args.project = process.cwd();\n console.log(`(auto) project = ${args.project}`);\n } else {\n fatal('no --project or --jar and current directory has no pom.xml — cd into your project or pass --project \u003cdir>');\n }\n }\n tasks = [{ module: args.project, jar: args.jar, pattern: args.pattern, fqcn: args.fqcn, template: args.template }];\n } else {\n const plan = computeValidationPlan({ cwd, baseRef: args.base });\n if (plan.error) fatal(`could not auto-detect what to verify: ${plan.error} — pass --pattern \u003cname> to run one pattern manually`);\n if (plan.tasks.length === 0) {\n console.log(`[validate-migration] no validate-migration-relevant changes vs ${plan.baseRef} — nothing to verify.`);\n process.exit(0);\n }\n console.log(`[validate-migration] auto-detected ${plan.tasks.length} task(s) from diff vs ${plan.baseRef}:`);\n for (const t of plan.tasks) console.log(` • ${t.pattern} — ${path.relative(cwd, t.module) || '.'} (${t.files.length} file(s) changed)`);\n tasks = plan.tasks;\n }\n\n const anyNeedsSdk = tasks.some((t) => PATTERNS[t.pattern].mode !== 'source-only');\n const sdkUrl = anyNeedsSdk ? resolveSdkUrl(args) : null;\n if (anyNeedsSdk && !sdkUrl) {\n fatal('no usable Cloud SDK URL; see the diagnostic above and pass --sdk \u003curl>.');\n }\n const prepared = [];\n for (const task of tasks) {\n const p = await prepareTask(task, { sdkUrl });\n if (p.pattern === 'custom-templates') p.requireRuntime = args['template-mode'] === 'runtime' || !!args['template-evidence'];\n if (p.pattern === 'custom-templates' && p.discovered?.templates) {\n for (const template of p.discovered.templates) {\n prepared.push({ ...p, discovered: { template } });\n }\n } else prepared.push(p);\n }\n\n // Persist prepared state so `--stage verify` (potentially re-invoked after\n // the agent gathers MCP diagnosis) doesn't have to rebuild + redeploy.\n fs.mkdirSync(path.dirname(statePath), { recursive: true });\n fs.writeFileSync(statePath, JSON.stringify({ started_at: RUN_STARTED_AT, prepared }, null, 2));\n\n // Bundle-runtime patterns need the agent to gather MCP diagnosis BETWEEN\n // prepare and verify. `--stage all` gives no such window, so when bundle\n // tasks are present (and no explicit map was supplied) we stop after prepare\n // with guidance instead of running — and failing — verify.\n const bundleTasks = prepared.filter((p) => p.mode === 'bundle-runtime' && !p.failure);\n\n // Give DS components time to settle after deploy before the agent diagnoses.\n const settleMs = Number(args['settle-ms'] ?? 5000);\n if (bundleTasks.length && !args['diagnosis-map'] && settleMs > 0) {\n step(`settle ${settleMs}ms for DS activation`);\n await sleep(settleMs);\n }\n\n const { stop: stopAfterPrepare, exitCode: prepareExitCode } = prepareStopDecision(stage, bundleTasks.length, !!args['diagnosis-map']);\n\n if (stopAfterPrepare) {\n console.log(`\\n[validate-migration] prepare complete. ${prepared.length} task(s) staged.`);\n const bsns = [...new Set(prepared.map((p) => p.symbolicName).filter(Boolean))];\n if (bsns.length) {\n const next = verifyGuidance({ projectDir: cwd });\n console.log('Have the coding assistant call the AEM Quickstart MCP tool `diagnose-osgi-bundle` (argument `bundleSymbolicName`) for each of these bundles:');\n for (const b of bsns) console.log(` - ${b}`);\n console.log(`then write the raw tool outputs to ${next.mapPath} as a { \"\u003cBSN>\": \"\u003craw text>\" } map,`);\n console.log(`and finally re-run: ${next.command}`);\n }\n if (stage === 'all' && bundleTasks.length) {\n console.warn(`[validate-migration] --stage all can't verify ${bundleTasks.length} bundle-runtime pattern(s) in one shot — stopped after prepare. Gather MCP diagnosis, then re-run --stage verify.`);\n }\n process.exit(prepareExitCode);\n }\n\n // Only source-only tasks (or an explicit --diagnosis-map): verify now.\n const records = [];\n for (const t of prepared) {\n records.push(await verifyPreparedTask(t, args));\n }\n return finishAll(records, args);\n}\n\n// prepareTask: build → discover → (source-only verify | deploy). Returns a\n// serializable record persisted to .validate-migration/state.json and later\n// consumed by verifyPreparedTask.\nasync function prepareTask(task, { sdkUrl }) {\n const pat = PATTERNS[task.pattern];\n console.log(`\\n=== validate-migration prepare · ${task.pattern} ===`);\n console.log(task.jar ? `jar : ${task.jar}` : `project: ${task.module}`);\n if (pat.mode !== 'source-only') console.log(`sdk : ${sdkUrl}`);\n console.log(`pattern: ${pat.describe}\\n`);\n\n // 1. build (unless a pre-built jar was supplied)\n let artifactPath;\n if (task.jar) {\n if (!fs.existsSync(task.jar)) {\n return { pattern: task.pattern, module: task.module, jar: task.jar,\n failure: { result: 'fail', failure_class: FAILURE_CLASSES.INPUT_JAR_MISSING, verification_level: 'source-only', evidence: `no file at ${task.jar}` } };\n }\n artifactPath = task.jar;\n console.log(`▸ skip build (using --jar)\\n ok (${path.basename(artifactPath)})\\n`);\n } else {\n step('build customer project');\n const b = build({ projectDir: task.module, artifact: pat.mode === 'source-only' ? 'content-package' : 'bundle' });\n if (!b.ok) {\n return { pattern: task.pattern, module: task.module,\n failure: { result: 'fail', failure_class: FAILURE_CLASSES.BUILD_FAILED, verification_level: 'source-only', evidence: b.log } };\n }\n artifactPath = b.artifactPath;\n console.log(` ok (${path.basename(artifactPath)}, ${b.elapsedMs}ms)\\n`);\n }\n\n // 2. auto-discover\n step('discover migrated class');\n const discovered = pat.discover(artifactPath, task);\n if (!discovered) {\n return { pattern: task.pattern, module: task.module, artifactPath,\n failure: { result: 'fail', failure_class: FAILURE_CLASSES.DISCOVERY_NO_MATCH, verification_level: 'source-only', evidence: `no DS component in ${artifactPath} matches the ${task.pattern} signature` } };\n }\n console.log(` ok → ${JSON.stringify(discovered)}\\n`);\n\n // Source-only patterns: no deploy; verify runs off the artifact only.\n if (pat.mode === 'source-only') {\n return { pattern: task.pattern, module: task.module, artifactPath, discovered, mode: 'source-only' };\n }\n\n // 3. deploy the customer bundle\n step('deploy customer bundle');\n const d = await deploy({ projectDir: task.module, artifactPath, sdkUrl });\n if (!d.ok) {\n const unreachable = isSdkUnreachable(d.log);\n return { pattern: task.pattern, module: task.module, artifactPath, discovered, mode: 'bundle-runtime',\n symbolicName: d.symbolicName || null,\n failure: { result: 'fail', failure_class: unreachable ? FAILURE_CLASSES.SDK_UNREACHABLE : FAILURE_CLASSES.DEPLOY_FAILED,\n verification_level: 'runtime',\n evidence: unreachable ? `Cloud SDK not reachable at ${sdkUrl}; start it and rerun prepare.\\n${d.log}` : d.log,\n ...(d.symbolicName ? { bundle: { symbolic_name: d.symbolicName, state: normalizeBundleState() } } : {}) } };\n }\n console.log(` ok (bundle ${d.symbolicName}${d.mode ? ' via ' + d.mode : ''})\\n`);\n return { pattern: task.pattern, module: task.module, artifactPath, discovered, mode: 'bundle-runtime', symbolicName: d.symbolicName, deployMode: d.mode };\n}\n\n// verifyPreparedTask: source-only patterns verify off the artifact; runtime\n// patterns require the agent-supplied MCP diagnosis-map.\nasync function verifyPreparedTask(prepared, args) {\n if (prepared.failure) {\n return { pattern: prepared.pattern, ...prepared.failure, discovered: prepared.discovered };\n }\n const pat = PATTERNS[prepared.pattern];\n if (prepared.mode === 'source-only') {\n step(`source verify · ${prepared.pattern}`);\n const outcome = await pat.verify({ artifactPath: prepared.artifactPath, discovered: prepared.discovered,\n ...(prepared.pattern === 'custom-templates' ? {\n requireRuntime: prepared.requireRuntime || args['template-mode'] === 'runtime' || !!args['template-evidence'],\n runtimeEvidencePath: args['template-evidence'],\n runStartedAt: RUN_STARTED_AT,\n projectDir: prepared.module,\n staticTemplate: args['static-template'],\n baselineRef: args['baseline-ref'],\n } : {}) });\n console.log(` ${outcome.result === 'pass' ? 'ok' : 'FAIL'}${outcome.evidence ? ' — ' + outcome.evidence : ''}\\n`);\n return { pattern: prepared.pattern, ...outcome, verification_level: outcome.verification_level || 'source-only', discovered: prepared.discovered };\n }\n\n step(`runtime verify · ${prepared.pattern}`);\n const outcome = await pat.verify({ bundleBSN: prepared.symbolicName, discovered: prepared.discovered, args });\n console.log(` ${outcome.result === 'pass' ? 'ok' : 'FAIL'} — bundle=${outcome.bundle_state} component=${outcome.component_state}${outcome.evidence ? ' ' + outcome.evidence : ''}\\n`);\n\n return {\n pattern: prepared.pattern,\n ...outcome,\n verification_level: outcome.verification_level || 'runtime',\n discovered: prepared.discovered,\n bundle: { symbolic_name: prepared.symbolicName, state: normalizeBundleState(outcome.bundle_state) },\n };\n}\n\n// ---- orchestration helpers ----\n\nfunction step(name) { console.log(`▸ ${name}`); }\n\nfunction fatal(msg) { console.error(msg); process.exit(2); }\n\nfunction sleep(ms) { return new Promise((r) => setTimeout(r, ms)); }\n\n// Decide whether prepare stops before verify, and the exit code if it does.\n// `--stage prepare` completing is success (0); an `all` run that stops early\n// (bundle-runtime tasks, no diagnosis map) verified nothing → 3 (incomplete).\nfunction prepareStopDecision(stage, bundleTaskCount, hasDiagnosisMap) {\n const stop = stage === 'prepare' || (bundleTaskCount > 0 && !hasDiagnosisMap);\n return { stop, exitCode: stage === 'prepare' ? 0 : 3 };\n}\n\n// Next step printed after prepare. Paths are relative to where the user runs the\n// command, and --project is repeated so verify reads the same state.json.\nfunction verifyGuidance({ projectDir, runDir = process.cwd(), script = __filename }) {\n const quote = (s) => (/^[\\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\\\''`)}'`);\n const rel = path.relative(runDir, path.resolve(runDir, projectDir));\n return {\n mapPath: path.join(rel, '.validate-migration', 'diagnosis-map.json'),\n command: `node ${quote(script)} --stage verify${rel ? ` --project ${quote(rel)}` : ''}`,\n };\n}\n\n// Aggregates every task's outcome record into one run, prints it, emits the\n// MCP payload once for the whole run. Reporting is optional: with no linked CAM\n// project the run is complete locally; with one, a pass awaits acknowledgement.\nfunction finishAll(records, args) {\n const linked = Boolean(args && (args['project-id'] || args['project-name']));\n const initialReporting = linked ? { status: 'pending' } : { status: 'not_reported' };\n // Each template is an independent run/receipt: a failed sibling cannot make\n // another template look passed or prevent its outcome from being submitted.\n if (records.length > 1 || records.some((r) => r.pattern === 'custom-templates')) {\n const finishedAt = new Date().toISOString();\n const runBase = Date.now() * 1000;\n for (const [index, outcome] of records.entries()) {\n const record = { run_id: `vm-${runBase + index}`, started_at: RUN_STARTED_AT || finishedAt, finished_at: finishedAt,\n result: outcome.result, verification_level: outcome.verification_level || 'runtime',\n classes: [{ ...outcome, checks: validationChecks(outcome) }], reporting: initialReporting };\n const report = writeLocalReport(record, args.project || process.cwd());\n console.log(`\\n${outcome.discovered?.template || outcome.pattern}: ${outcome.result}; local report: ${report.mdPath}`);\n if (linked) {\n console.log('\\n=== submit-migration-validation-result payload ===');\n console.log(JSON.stringify(buildMcpPayload(record, args)));\n console.log('=== end payload ===');\n } else console.log(camReportingNote(args));\n }\n process.exit(records.some((r) => r.result === 'fail') ? 1 : linked ? 3 : 0);\n }\n const finishedAt = new Date().toISOString();\n const record = {\n run_id: 'vm-' + Date.now(),\n started_at: RUN_STARTED_AT || finishedAt,\n finished_at: finishedAt,\n result: records.some((r) => r.result === 'fail') ? 'fail' : 'pass',\n verification_level: records.some((r) => r.verification_level === 'runtime') ? 'runtime' : 'source-only',\n classes: records.map((outcome) => ({ ...outcome, checks: validationChecks(outcome) })),\n reporting: initialReporting,\n };\n console.log('=== outcome ===');\n console.log(JSON.stringify(record, null, 2));\n\n // Always leave a local record next to the code, even when no CAM project is linked.\n const cwd = (args && args.project) || process.cwd();\n const report = writeLocalReport(record, cwd);\n console.log(`\\nlocal report: ${report.mdPath}`);\n\n // Emit the submit-migration-validation-result payload when a CAM project (id or name) is available.\n // The agent submits this block through MCP and finalizes with its receipt.\n if (linked) {\n const payload = buildMcpPayload(record, args);\n console.log('\\n=== submit-migration-validation-result payload ===');\n console.log(JSON.stringify(payload));\n console.log('=== end payload ===');\n } else {\n console.log(`\\n${camReportingNote(args)}`);\n }\n\n if (!linked) process.exit(record.result === 'pass' ? 0 : 1);\n console.log('\\ncompletion: incomplete — save the MCP acknowledgement and run --stage finalize');\n process.exit(record.result === 'pass' ? 3 : 1);\n}\n\n// Human- and machine-readable record written to \u003cproject>/.validate-migration/\u003crun_id>.{md,json}.\n\nfunction parseArgs(argv) {\n const out = {};\n if (argv[0] && !argv[0].startsWith('--')) out.pattern = argv[0];\n const rest = out.pattern ? argv.slice(1) : argv;\n for (let i = 0; i \u003c rest.length; i++) {\n if (!rest[i].startsWith('--')) continue;\n const k = rest[i].replace(/^--/, '');\n out[k] = rest[++i];\n }\n if (out.pattern) out.pattern = canonicalPattern(out.pattern);\n return out;\n}\n\n// ---- CAM project link (from migration-runbook.json written by the analyze / migration skill) ----\n// Shape in that file: { project: { id: string, name?: string }, ... }\n// Nearest migration-runbook.json wins, searching up from the project so a module (core/) finds the repo-root one.\nfunction readContext(args) {\n let dir = path.resolve((args && args.project) || process.cwd());\n for (let i = 0; i \u003c 6; i++) {\n const runbookPath = path.join(dir, 'migration-runbook.json');\n if (fs.existsSync(runbookPath)) {\n let parsed;\n try { parsed = JSON.parse(fs.readFileSync(runbookPath, 'utf8')); } catch { return {}; }\n const project = parsed && parsed.project;\n if (!project || !project.id) return {};\n return { projectId: project.id, projectName: project.name || undefined };\n }\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return {};\n}\n\n\nif (require.main === module) {\n main().catch(e => { console.error(e); process.exit(2); });\n}\n\n// A Maven install that cannot open a connection means the SDK is not running at the selected URL.\nfunction isSdkUnreachable(log) {\n return /Connection refused|ConnectException|UnknownHostException|No route to host/i.test(log || '');\n}\n\nmodule.exports = { parseArgs, readContext, prepareStopDecision, isSdkUnreachable, verifyGuidance };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":20958,"content_sha256":"ef521e0d59914387b196e4175c9bfef0c069ef39ada783d9841bca3d1de9485b"},{"filename":"scripts/validate-migration/check.test.js","content":"'use strict';\n\nconst { test } = require('node:test');\nconst assert = require('node:assert');\nconst fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst { execFileSync, spawnSync } = require('child_process');\n\nconst { parseArgs, readContext, prepareStopDecision, isSdkUnreachable, verifyGuidance } = require('./check.js');\nconst PATTERNS = require('./check/patterns');\nconst { parseBundleDiagnosticReport, parseComponentsFromReport, normalizeState, mcpUnavailableOutcome, clearDiagnosisMap } = require('./check/lib/diagnosis.js');\nconst { classifyDialog } = require('./check/lib/dialogs.js');\nconst { manifestImportsPackage, referencesClassName } = require('./check/lib/jar.js');\nconst { topicFromDescriptor, rclFlagsFromDescriptor } = require('./check/lib/descriptor.js');\nconst { buildMcpPayload, renderLocalReport, writeLocalReport, finalizeReporting, toClassEntry, stripEmpty, truncate, camReportingNote, resolveSdkUrl } = require('./reporting.js');\nconst { ALL_FAILURE_CLASSES, FAILURE_CLASSES, isFailureClass } = require('./failure-classes.js');\nconst { changedFiles, RULES, SOURCE_ONLY_PATTERNS } = require('./plan.js');\nconst { selectArtifact } = require('./build.js');\nconst { readBundleSymbolicName } = require('./deploy.js');\n\ntest('scheduler rejects a built component without scheduler.runOn before runtime diagnosis', async (context) => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'scheduler-contract-'));\n context.after(() => fs.rmSync(root, { recursive: true, force: true }));\n fs.mkdirSync(path.join(root, 'OSGI-INF'));\n fs.writeFileSync(path.join(root, 'OSGI-INF', 'job.xml'),\n '\u003cscr:component xmlns:scr=\"http://www.osgi.org/xmlns/scr/v1.3.0\" name=\"com.acme.Job\">' +\n '\u003cimplementation class=\"com.acme.Job\"/>\u003cservice>\u003cprovide interface=\"java.lang.Runnable\"/>\u003c/service>' +\n '\u003cproperty name=\"scheduler.expression\" value=\"0 0 * * * ?\"/>' +\n '\u003c/scr:component>');\n const jar = path.join(root, 'bundle.jar');\n execFileSync('zip', ['-qr', jar, 'OSGI-INF'], { cwd: root });\n const discovered = PATTERNS.scheduler.discover(jar, { fqcn: 'com.acme.Job' });\n const outcome = await PATTERNS.scheduler.verify({ bundleBSN: 'com.acme.scheduler-contract', discovered, args: {} });\n assert.strictEqual(outcome.result, 'fail');\n assert.strictEqual(outcome.failure_class, FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH);\n assert.match(outcome.evidence, /scheduler\\.runOn/);\n});\n\nfunction verifyContractFixture(context, pattern, properties, service, manifest = 'Import-Package: org.osgi.framework\\n', options = {}) {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'migration-contract-'));\n context.after(() => fs.rmSync(root, { recursive: true, force: true }));\n for (const directory of ['OSGI-INF', 'META-INF', '.validate-migration']) fs.mkdirSync(path.join(root, directory));\n const fqcn = 'com.acme.ContractComponent';\n fs.writeFileSync(path.join(root, 'OSGI-INF', 'component.xml'),\n `\u003cscr:component xmlns:scr=\"http://www.osgi.org/xmlns/scr/v1.3.0\" name=\"${fqcn}\">` +\n `\u003cimplementation class=\"${fqcn}\"/>\u003cservice>${[].concat(service).map((serviceName) => `\u003cprovide interface=\"${serviceName}\"/>`).join('')}\u003c/service>${properties}\u003c/scr:component>`);\n fs.writeFileSync(path.join(root, 'META-INF', 'MANIFEST.MF'), 'Bundle-SymbolicName: com.acme.contract\\n' + manifest);\n fs.writeFileSync(path.join(root, 'ContractComponent.class'), Buffer.from('fixture without legacy API references'));\n const jar = path.join(root, 'bundle.jar');\n execFileSync('zip', ['-qr', jar, 'OSGI-INF', 'META-INF', 'ContractComponent.class'], { cwd: root });\n const discovered = PATTERNS[pattern].discover(jar, { fqcn });\n if (options.omitContract) delete discovered.contract;\n fs.writeFileSync(path.join(root, '.validate-migration', 'state.json'), JSON.stringify({\n started_at: '2026-10-08T00:00:00.000Z',\n prepared: [{ pattern, discovered, mode: 'bundle-runtime', symbolicName: 'com.acme.contract', artifactPath: jar }],\n }));\n fs.writeFileSync(path.join(root, '.validate-migration', 'diagnosis-map.json'), JSON.stringify({\n 'com.acme.contract': `Bundle: com.acme.contract\\nState: Active\\n\\nDeclarative Services Components\\nComponent: ${fqcn}\\n State: Active\\n`,\n }));\n const execution = spawnSync(process.execPath, [path.join(__dirname, 'check.js'), '--stage', 'verify', '--pattern', pattern, '--project', root, ...(options.args || [])], { encoding: 'utf8' });\n const reportName = fs.readdirSync(path.join(root, '.validate-migration')).find((name) => /^vm-\\d+\\.json$/.test(name));\n assert.ok(reportName, execution.stderr || execution.stdout);\n const report = JSON.parse(fs.readFileSync(path.join(root, '.validate-migration', reportName), 'utf8'));\n return { outcome: report.classes[0], execution, report };\n}\n\nfor (const example of [\n { pattern: 'scheduler', service: 'com.acme.NotRunnable', properties: '\u003cproperty name=\"scheduler.expression\" value=\"0 0 * * * ?\"/>\u003cproperty name=\"scheduler.runOn\" value=\"SINGLE\"/>', missing: /Runnable/ },\n { pattern: 'scheduler', service: 'java.lang.Runnable', properties: '\u003cproperty name=\"scheduler.expression\" value=\"0 0 * * * ?\"/>\u003cproperty name=\"scheduler.runOn\" value=\"ALL\"/>', missing: /scheduler\\.runOn/ },\n { pattern: 'scheduler', service: 'java.lang.Runnable', properties: '\u003cproperty name=\"scheduler.expression\" value=\"0 0 * * * ?\"/>\u003cproperty name=\"scheduler.runOn\" value=\"LEADER\"/>\u003cproperty name=\"scheduler.concurrent\" value=\"false\"/>', missing: /scheduler\\.concurrent/ },\n { pattern: 'event-migration', service: 'org.osgi.service.event.EventHandler', properties: '\u003cproperty name=\"job.topics\" value=\"acme/job\"/>', missing: /JobConsumer/ },\n { pattern: 'event-migration', service: 'org.apache.sling.event.jobs.consumer.JobConsumer', properties: '\u003cproperty name=\"job.topics\" value=\" \"/>', missing: /job\\.topics/ },\n { pattern: 'resource-change-listener', service: 'com.acme.NotAListener', properties: '\u003cproperty name=\"resource.paths\" value=\"/content\"/>\u003cproperty name=\"resource.change.types\" value=\"ADDED\"/>', missing: /ResourceChangeListener/ },\n { pattern: 'resource-change-listener', service: 'org.apache.sling.api.resource.observation.ResourceChangeListener', properties: '\u003cproperty name=\"resource.paths\" value=\" \"/>\u003cproperty name=\"resource.change.types\" value=\"ADDED\"/>', missing: /resource\\.paths/ },\n { pattern: 'resource-change-listener', service: 'org.apache.sling.api.resource.observation.ResourceChangeListener', properties: '\u003cproperty name=\"resource.paths\" value=\"/content\"/>\u003cproperty name=\"resource.change.types\" value=\"INVALID\"/>', missing: /resource\\.change\\.types/ },\n]) {\n test(`migration contract rejects ${example.pattern}: ${example.missing.source}`, (context) => {\n const { outcome, execution } = verifyContractFixture(context, example.pattern, example.properties, example.service);\n assert.strictEqual(execution.status, 1);\n assert.strictEqual(outcome.result, 'fail');\n assert.strictEqual(outcome.failure_class, FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH);\n assert.match(outcome.evidence, example.missing);\n assert.strictEqual(outcome.checks.business_behavior, 'not_tested');\n });\n}\n\nfor (const example of [\n { pattern: 'scheduler', service: 'java.lang.Runnable', properties: '\u003cproperty value=\"0 0 * * * ?\" name=\"scheduler.expression\"/>\u003cproperty name=\"scheduler.runOn\" value=\"SINGLE\"/>\u003cproperty type=\"Boolean\" name=\"scheduler.concurrent\" value=\"false\"/>' },\n { pattern: 'event-migration', service: 'org.apache.sling.event.jobs.consumer.JobConsumer', properties: '\u003cproperty name=\"job.topics\">\\nacme/job\\nacme/other\\n\u003c/property>' },\n { pattern: 'resource-change-listener', service: 'org.apache.sling.api.resource.observation.ResourceChangeListener', properties: '\u003cproperty name=\"resource.paths\">\\n/content\\n/content/dam\\n\u003c/property>\u003cproperty name=\"resource.change.types\">\\nADDED\\nCHANGED\\nREMOVED\\n\u003c/property>' },\n { pattern: 'asset-manager', service: 'com.acme.AssetService', properties: '' },\n { pattern: 'replication', service: 'com.acme.DistributionService', properties: '', manifest: 'Import-Package: org.apache.sling.distribution\\n' },\n]) {\n test(`migration contract accepts ${example.pattern} without claiming business behavior`, (context) => {\n const { outcome, execution } = verifyContractFixture(context, example.pattern, example.properties, example.service, example.manifest);\n assert.strictEqual(execution.status, 0, execution.stderr || execution.stdout);\n assert.strictEqual(outcome.result, 'pass');\n assert.strictEqual(outcome.checks.business_behavior, 'not_tested');\n assert.strictEqual(outcome.checks.migration_contract, 'pass');\n });\n}\n\nfor (const pattern of Object.keys(PATTERNS)) {\n test(`business behavior stays untested in the ${pattern} MCP outcome`, () => {\n const entry = toClassEntry({ pattern, result: 'pass', checks: { migration_contract: 'pass' } });\n assert.strictEqual(entry.checks.business_behavior, 'not_tested');\n const failure = toClassEntry({ pattern, result: 'fail', failure_class: FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH });\n assert.strictEqual(failure.checks.business_behavior, 'not_tested');\n });\n}\n\ntest('business behavior is explicitly not tested in the local report', () => {\n const report = renderLocalReport({ run_id: 'vm-1', result: 'pass', classes: [{ pattern: 'scheduler', result: 'pass' }] });\n assert.match(report, /business behavior: not tested/i);\n assert.match(report, /migration.contract/i);\n});\n\nfor (const example of [\n { pattern: 'event-migration', service: 'org.apache.sling.event.jobs.consumer.JobConsumer', properties: '\u003cproperty name=\"job.topics\" value=\"acme/job\"/>' },\n { pattern: 'resource-change-listener', service: 'org.apache.sling.api.resource.observation.ResourceChangeListener', properties: '\u003cproperty name=\"resource.paths\" value=\"/content\"/>\u003cproperty name=\"resource.change.types\" value=\"ADDED\"/>' },\n]) {\n test(`migration contract distinguishes built and effective ${example.pattern} properties`, (context) => {\n const { outcome } = verifyContractFixture(context, example.pattern, example.properties, example.service);\n assert.strictEqual(outcome.result, 'pass');\n assert.strictEqual(outcome.restricted, true);\n assert.match(outcome.restricted_reason, /effective.*properties.*not exposed/i);\n });\n}\n\ntest('validation guidance forbids customer business actions and source changes', () => {\n const skill = fs.readFileSync(path.join(__dirname, '..', '..', 'references', 'validation', 'validate-migration.md'), 'utf8');\n const reference = fs.readFileSync(path.join(__dirname, 'README.md'), 'utf8');\n for (const text of [skill, reference]) {\n assert.match(text, /business_behavior.*not_tested/);\n assert.match(text, /do not invoke customer business actions/i);\n assert.match(text, /before deployment/i);\n assert.match(text, /existing customer tests/i);\n }\n});\n\ntest('runtime recovery guidance bounds fresh diagnosis and migration-owned repair', () => {\n const paths = [\n path.join(__dirname, '..', '..', 'references', 'validation', 'validate-migration.md'),\n path.join(__dirname, 'README.md'),\n ];\n for (const filePath of paths) {\n const text = fs.readFileSync(filePath, 'utf8');\n const section = text.match(/(?:^|\\n)## Bounded runtime recovery\\n([\\s\\S]*?)(?=\\n## |$)/);\n assert.ok(section, `${filePath} must define bounded runtime recovery`);\n const recovery = section[1];\n assert.match(recovery, /3 diagnosis checks total/);\n assert.match(recovery, /wait 5 seconds/);\n assert.match(recovery, /fresh[^\\n]*`diagnose-osgi-bundle`/);\n assert.match(recovery, /replace[^\\n]*`diagnosis-map\\.json`/);\n assert.match(recovery, /Do not rebuild or redeploy during activation checks/);\n assert.match(recovery, /at most one repair cycle/);\n assert.match(recovery, /`migration` owns customer source changes/);\n assert.match(recovery, /original scope/);\n assert.match(recovery, /stop[^\\n]*`setup\\.mcp_unavailable`/i);\n assert.match(recovery, /unresolved imports, missing services, or activation exceptions/);\n assert.match(recovery, /Unrelated out-of-scope component errors do not fail a scoped validation/);\n assert.match(recovery, /remove affected BSN entries/);\n assert.match(recovery, /recheck deployment safety[\\s\\S]*?(?:--stage prepare|run prepare)/i);\n assert.match(recovery, /final verification attempt/);\n }\n});\n\ntest('external listener marker alone does not register a ResourceChangeListener', (context) => {\n const properties = '\u003cproperty name=\"resource.paths\" value=\"/content\"/>\u003cproperty name=\"resource.change.types\" value=\"ADDED\"/>';\n const { outcome, execution } = verifyContractFixture(context, 'resource-change-listener', properties, 'org.apache.sling.api.resource.observation.ExternalResourceChangeListener');\n assert.strictEqual(execution.status, 1);\n assert.strictEqual(outcome.failure_class, FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH);\n assert.match(outcome.evidence, /ResourceChangeListener/);\n});\n\ntest('external listener marker supplements ResourceChangeListener registration', (context) => {\n const properties = '\u003cproperty name=\"resource.paths\" value=\"/content\"/>\u003cproperty name=\"resource.change.types\" value=\"ADDED\"/>';\n const { outcome, execution } = verifyContractFixture(context, 'resource-change-listener', properties, [\n 'org.apache.sling.api.resource.observation.ResourceChangeListener',\n 'org.apache.sling.api.resource.observation.ExternalResourceChangeListener',\n ]);\n assert.strictEqual(execution.status, 0);\n assert.strictEqual(outcome.result, 'pass');\n});\n\nfor (const example of [\n { name: 'scheduler.expression', properties: '\u003cproperty name=\"scheduler.expression\">0 0 * * * ?\u003c/property>\u003cproperty name=\"scheduler.runOn\" value=\"SINGLE\"/>' },\n { name: 'scheduler.runOn', properties: '\u003cproperty name=\"scheduler.expression\" value=\"0 0 * * * ?\"/>\u003cproperty name=\"scheduler.runOn\">SINGLE\u003c/property>' },\n { name: 'scheduler.concurrent', properties: '\u003cproperty name=\"scheduler.expression\" value=\"0 0 * * * ?\"/>\u003cproperty name=\"scheduler.runOn\" value=\"SINGLE\"/>\u003cproperty name=\"scheduler.concurrent\" type=\"Boolean\">false\u003c/property>' },\n]) {\n test(`scheduler rejects array-valued ${example.name}`, (context) => {\n const { outcome, execution } = verifyContractFixture(context, 'scheduler', example.properties, 'java.lang.Runnable');\n assert.strictEqual(execution.status, 1);\n assert.strictEqual(outcome.failure_class, FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH);\n assert.match(outcome.evidence, /scalar/);\n });\n}\n\nfor (const example of [\n { pattern: 'scheduler', service: 'java.lang.Runnable', properties: '\u003cproperty name=\"scheduler.expression\" value=\"0 0 * * * ?\"/>\u003cproperty name=\"scheduler.runOn\" value=\"SINGLE\"/>' },\n { pattern: 'event-migration', service: 'org.apache.sling.event.jobs.consumer.JobConsumer', properties: '\u003cproperty name=\"job.topics\" value=\"acme/job\"/>' },\n { pattern: 'resource-change-listener', service: 'org.apache.sling.api.resource.observation.ResourceChangeListener', properties: '\u003cproperty name=\"resource.paths\" value=\"/content\"/>\u003cproperty name=\"resource.change.types\" value=\"ADDED\"/>' },\n]) {\n test(`missing built contract evidence prevents a ${example.pattern} pass`, (context) => {\n const { outcome, execution } = verifyContractFixture(context, example.pattern, example.properties, example.service, undefined, { omitContract: true });\n assert.strictEqual(execution.status, 1);\n assert.strictEqual(outcome.result, 'fail');\n assert.strictEqual(outcome.checks.migration_contract, 'not_verified');\n assert.match(outcome.evidence, /prepare/);\n });\n}\n// ── Failure-class taxonomy ────────────────────────────────────────\n\ntest('every failure_class the skill emits is in the frozen taxonomy', () => {\n for (const value of Object.values(FAILURE_CLASSES)) {\n assert.ok(isFailureClass(value), `${value} in ALL_FAILURE_CLASSES`);\n }\n assert.ok(ALL_FAILURE_CLASSES.includes('setup.mcp_unavailable'));\n});\n\ntest('mcpUnavailableOutcome uses setup.mcp_unavailable with setup guidance', () => {\n const outcome = mcpUnavailableOutcome('com.acme.core');\n assert.strictEqual(outcome.result, 'fail');\n assert.strictEqual(outcome.failure_class, 'setup.mcp_unavailable');\n assert.ok(isFailureClass(outcome.failure_class), 'class is in the frozen taxonomy (MCP parity)');\n assert.match(outcome.evidence, /diagnose-osgi-bundle/);\n assert.match(outcome.evidence, /com\\.acme\\.core/);\n});\n\n// ── diagnose-osgi-bundle report parsing ─────────────────────────────────────\n\ntest('parseBundleDiagnosticReport reads an Active bundle state', () => {\n const report = 'Bundle: com.acme.core\\nState: ACTIVE\\n';\n const r = parseBundleDiagnosticReport(report);\n assert.strictEqual(r.found, true);\n assert.strictEqual(r.bundle_state, 'Active');\n});\n\ntest('parseBundleDiagnosticReport reads a Resolved bundle state', () => {\n const r = parseBundleDiagnosticReport('Bundle: com.acme.core\\nState: RESOLVED\\n');\n assert.strictEqual(r.found, true);\n assert.strictEqual(r.bundle_state, 'Resolved');\n});\n\ntest('parseBundleDiagnosticReport marks a missing bundle not found', () => {\n const r = parseBundleDiagnosticReport('no such bundle: com.acme.core');\n assert.strictEqual(r.found, false);\n assert.strictEqual(r.bundle_state, 'Unknown');\n});\n\ntest('parseComponentsFromReport extracts component states', () => {\n const report = [\n 'Bundle: com.acme.core',\n 'State: ACTIVE',\n '--- Declarative Services Components ---',\n 'Component: com.acme.core.MyJob',\n ' State: ACTIVE',\n 'Component: com.acme.core.Broken',\n ' State: UNSATISFIED',\n ].join('\\n');\n const comps = parseComponentsFromReport(report);\n assert.strictEqual(comps.get('com.acme.core.MyJob').state, 'Active');\n assert.strictEqual(comps.get('com.acme.core.Broken').state, 'Unsatisfied');\n});\n\ntest('normalizeState title-cases OSGi upper-case states', () => {\n assert.strictEqual(normalizeState('ACTIVE'), 'Active');\n assert.strictEqual(normalizeState('resolved'), 'Resolved');\n assert.strictEqual(normalizeState(''), 'Unknown');\n});\n\n// ── Payload builders (submit-migration-validation-result schema) ──────────────────────\n\ntest('toClassEntry keeps failure_class on fail and omits it on pass', () => {\n const fail = toClassEntry({\n result: 'fail',\n pattern: 'scheduler',\n failure_class: 'runtime.bundle_not_active',\n bundle_state: 'Resolved',\n evidence: 'bundle state=Resolved',\n });\n assert.strictEqual(fail.result, 'fail');\n assert.strictEqual(fail.failure_class, 'runtime.bundle_not_active');\n\n const pass = toClassEntry({ result: 'pass', pattern: 'scheduler', discovered: { fqcn: 'com.acme.MyJob' } });\n assert.strictEqual(pass.result, 'pass');\n assert.strictEqual(pass.class_name, 'com.acme.MyJob');\n assert.ok(!('failure_class' in pass), 'no failure_class on a pass entry');\n});\n\ntest('buildMcpPayload derives summary counts and carries a setup.mcp_unavailable class', () => {\n const record = {\n run_id: 'run-1',\n result: 'fail',\n verification_level: 'runtime',\n started_at: '2026-09-23T00:00:00.000Z',\n finished_at: '2026-09-23T00:00:01.000Z',\n classes: [\n { result: 'pass', pattern: 'scheduler', discovered: { fqcn: 'com.acme.Ok' } },\n mcpUnavailableOutcome('com.acme.core'),\n ],\n };\n const payload = buildMcpPayload(record, { 'project-id': 'p1', pattern: 'scheduler' });\n assert.strictEqual(payload.project_id, 'p1');\n assert.strictEqual(payload.summary.classes_total, 2);\n assert.strictEqual(payload.summary.classes_pass, 1);\n assert.strictEqual(payload.summary.classes_fail, 1);\n const setupClass = payload.classes.find((c) => c.failure_class === 'setup.mcp_unavailable');\n assert.ok(setupClass, 'setup.mcp_unavailable class survives into the payload');\n});\n\ntest('stripEmpty removes undefined and null but keeps falsy values', () => {\n const out = stripEmpty({ a: 1, b: undefined, c: null, d: 0, e: '' });\n assert.deepStrictEqual(out, { a: 1, d: 0, e: '' });\n});\n\ntest('truncate caps long strings with an ellipsis', () => {\n assert.strictEqual(truncate('hello', 10), 'hello');\n assert.strictEqual(truncate('hello world', 6), 'hello…');\n});\n\n// ── CLI arg parsing ─────────────────────────────────────────────────────────\n\ntest('parseArgs reads a leading pattern and --flags', () => {\n const args = parseArgs(['scheduler', '--stage', 'verify', '--project-id', 'p1']);\n assert.strictEqual(args.pattern, 'scheduler');\n assert.strictEqual(args.stage, 'verify');\n assert.strictEqual(args['project-id'], 'p1');\n});\n\ntest('parseArgs treats a leading flag as no pattern', () => {\n const args = parseArgs(['--stage', 'prepare']);\n assert.strictEqual(args.pattern, undefined);\n assert.strictEqual(args.stage, 'prepare');\n});\n\n// ── plan.js change scoping (CF0005) ─────────────────────────────────────────\n\nfunction git(cwd, ...a) {\n return execFileSync('git', a, { cwd, encoding: 'utf8' }).trim();\n}\n\ntest('changedFiles unions committed, staged, unstaged, and untracked changes', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-plan-'));\n git(root, 'init', '-q');\n git(root, 'config', 'user.email', '[email protected]');\n git(root, 'config', 'user.name', 't');\n git(root, 'checkout', '-q', '-b', 'main');\n fs.writeFileSync(path.join(root, 'base.txt'), 'base\\n');\n git(root, 'add', 'base.txt');\n git(root, 'commit', '-q', '-m', 'base');\n\n git(root, 'checkout', '-q', '-b', 'feature');\n const write = (rel, c) => {\n const full = path.join(root, rel);\n fs.mkdirSync(path.dirname(full), { recursive: true });\n fs.writeFileSync(full, c);\n };\n // committed on feature\n write('committed.java', 'x');\n git(root, 'add', 'committed.java');\n git(root, 'commit', '-q', '-m', 'committed');\n // staged\n write('staged.java', 'x');\n git(root, 'add', 'staged.java');\n // unstaged (modify a tracked file)\n fs.appendFileSync(path.join(root, 'base.txt'), 'more\\n');\n // untracked\n write('untracked.java', 'x');\n\n const files = changedFiles(root, 'main');\n assert.ok(files.includes('committed.java'), 'committed change detected');\n assert.ok(files.includes('staged.java'), 'staged change detected');\n assert.ok(files.includes('base.txt'), 'unstaged change detected');\n assert.ok(files.includes('untracked.java'), 'untracked change detected');\n\n fs.rmSync(root, { recursive: true, force: true });\n});\n\n// ── Pattern registry parity (plan.js ↔ check.js) ──────────────────\n\ntest('every plan.js RULE pattern is verifiable in check.js PATTERNS', () => {\n for (const { pattern } of RULES) {\n assert.ok(PATTERNS[pattern], `plan.js emits '${pattern}' but check.js PATTERNS has no such verifier`);\n }\n});\n\ntest('SOURCE_ONLY_PATTERNS matches the source-only patterns plan.js can emit', () => {\n const rulePatterns = new Set(RULES.map((r) => r.pattern));\n // SOURCE_ONLY_PATTERNS only governs plan-derived tasks, so it must equal\n // exactly the RULE patterns whose check.js verifier declares source-only mode.\n const expected = new Set(\n [...rulePatterns].filter((p) => PATTERNS[p].mode === 'source-only'),\n );\n assert.deepStrictEqual(\n [...SOURCE_ONLY_PATTERNS].sort(),\n [...expected].sort(),\n 'plan.js SOURCE_ONLY_PATTERNS is out of sync with check.js source-only modes',\n );\n});\n\n// ── Build artifact selection (bundle .jar vs content-package .zip) ──\n\ntest('selectArtifact picks the .jar for bundle patterns', () => {\n const files = ['core-1.0.jar', 'core-1.0-sources.jar'];\n assert.strictEqual(selectArtifact(files, 'bundle'), 'core-1.0.jar');\n});\n\ntest('selectArtifact picks the .zip for content-package (source-only) patterns', () => {\n // ui.apps module: a content package .zip, no bundle jar — the legacy-ui/cdw case.\n const files = ['ui.apps-1.0.zip'];\n assert.strictEqual(selectArtifact(files, 'content-package'), 'ui.apps-1.0.zip');\n});\n\ntest('selectArtifact prefers .zip over .jar for content-package', () => {\n const files = ['ui.apps-1.0.jar', 'ui.apps-1.0.zip'];\n assert.strictEqual(selectArtifact(files, 'content-package'), 'ui.apps-1.0.zip');\n});\n\ntest('selectArtifact falls back to a content-embedding jar when no zip', () => {\n const files = ['bundle-with-content-1.0.jar'];\n assert.strictEqual(selectArtifact(files, 'content-package'), 'bundle-with-content-1.0.jar');\n});\n\ntest('selectArtifact ignores -sources.jar and returns undefined when nothing matches', () => {\n assert.strictEqual(selectArtifact(['app-sources.jar', 'app.pom'], 'bundle'), undefined);\n});\n\n// ── --project path resolution (read paths match write paths) ──\n\ntest('readContext reads the CAM project from migration-runbook.json in args.project', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-ctx-'));\n fs.writeFileSync(\n path.join(root, 'migration-runbook.json'),\n JSON.stringify({ project: { id: 'P123', name: 'WKND Legacy' } }),\n );\n const ctx = readContext({ project: root });\n assert.strictEqual(ctx.projectId, 'P123');\n assert.strictEqual(ctx.projectName, 'WKND Legacy');\n fs.rmSync(root, { recursive: true, force: true });\n});\n\n// ── Review-fix regressions ────────────────────────────────────────\n\ntest('RULES: a ResourceChangeListener that uses ResourceResolverFactory is not classified as asset-manager', () => {\n const text = 'class X implements ResourceChangeListener { @Reference ResourceResolverFactory rrf; }';\n const hit = RULES.find((r) => r.test('X.java', text));\n assert.strictEqual(hit.pattern, 'resource-change-listener');\n});\n\ntest('parseBundleDiagnosticReport reads Active state even when the report mentions a reference not found', () => {\n const report = 'Bundle com.acme.core\\nState: ACTIVE\\n reference com.foo.Bar not found (optional)';\n const r = parseBundleDiagnosticReport(report);\n assert.strictEqual(r.found, true);\n assert.strictEqual(r.bundle_state, 'Active');\n});\n\ntest('parseBundleDiagnosticReport does not read a component State: as the bundle state', () => {\n const report = 'Bundle com.acme.core\\n\\nDeclarative Services Components\\nComponent: com.acme.Comp\\n State: ACTIVE';\n const r = parseBundleDiagnosticReport(report);\n assert.strictEqual(r.bundle_state, 'Unknown');\n});\n\ntest('toClassEntry surfaces restricted in checks on a restricted pass', () => {\n const cls = toClassEntry({\n pattern: 'scheduler',\n result: 'pass',\n bundle_state: 'Active',\n component_state: 'Active',\n restricted: true,\n restricted_reason: 'scheduler DS properties not exposed by diagnose-osgi-bundle',\n });\n assert.strictEqual(cls.result, 'pass');\n assert.strictEqual(cls.checks.restricted, true);\n assert.match(cls.checks.restricted_reason, /scheduler DS properties/);\n});\n\ntest('classifyDialog distinguishes classic, coral 2, and coral 3 by field type', () => {\n assert.strictEqual(classifyDialog('\u003cn xtype=\"textfield\"/>'), 'classic');\n assert.strictEqual(classifyDialog('\u003cn sling:resourceType=\"granite/ui/components/foundation/form/textfield\"/>'), 'coral2');\n assert.strictEqual(classifyDialog('\u003cn sling:resourceType=\"granite/ui/components/coral/foundation/form/textfield\"/>'), 'coral3');\n // Shared Touch UI dialog root alone is not a Coral 2 signal.\n assert.strictEqual(classifyDialog('\u003cn sling:resourceType=\"cq/gui/components/authoring/dialog\"/>'), 'other');\n});\n\ntest('clearDiagnosisMap removes a stale default map', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-map-'));\n fs.mkdirSync(path.join(root, '.validate-migration'), { recursive: true });\n const mapPath = path.join(root, '.validate-migration', 'diagnosis-map.json');\n fs.writeFileSync(mapPath, '{\"com.acme.core\":\"x\"}');\n clearDiagnosisMap(root);\n assert.strictEqual(fs.existsSync(mapPath), false);\n fs.rmSync(root, { recursive: true, force: true });\n});\n\n// ── Phase 0 fixes ─────────────────────────────────────────────────\n\ntest('readBundleSymbolicName strips directives (singleton:=true)', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-bsn-'));\n const metaInf = path.join(root, 'META-INF');\n fs.mkdirSync(metaInf, { recursive: true });\n fs.writeFileSync(path.join(metaInf, 'MANIFEST.MF'), 'Manifest-Version: 1.0\\r\\nBundle-SymbolicName: com.acme.core;singleton:=true\\r\\n');\n const jar = path.join(root, 'b.jar');\n execFileSync('zip', ['-qr', jar, 'META-INF'], { cwd: root });\n assert.strictEqual(readBundleSymbolicName(jar), 'com.acme.core');\n fs.rmSync(root, { recursive: true, force: true });\n});\n\ntest('deploy uses only the project profile, with no console URL or credentials', (context) => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-deploy-'));\n const artifactPath = path.join(root, 'bundle.jar');\n fs.writeFileSync(artifactPath, '');\n const childProcess = require('child_process');\n const deployPath = require.resolve('./deploy.js');\n const cachedDeploy = require.cache[deployPath];\n const commands = [];\n let rejectProfile = false;\n context.after(() => {\n require.cache[deployPath] = cachedDeploy;\n fs.rmSync(root, { recursive: true, force: true });\n });\n context.mock.method(childProcess, 'execFileSync', (command, args) => {\n if (command === 'unzip') return 'Bundle-SymbolicName: com.acme.core\\n';\n assert.strictEqual(command, 'mvn');\n commands.push(args);\n if (rejectProfile && args.includes('install')) throw new Error('profile failed');\n return 'installed';\n });\n delete require.cache[deployPath];\n const { deploy } = require('./deploy.js');\n const scenarios = [\n { profile: 'autoInstallBundle', packaging: 'bundle', reject: false },\n { profile: 'autoInstallPackage', packaging: 'content-package', reject: false },\n { profile: 'autoInstallBundle', packaging: 'bundle', reject: true },\n { profile: null, packaging: 'bundle', reject: false },\n ];\n const targets = [\n { url: 'http://localhost:4602', host: 'localhost', port: '4602' },\n { url: 'http://127.0.0.1:5505/', host: '127.0.0.1', port: '5505' },\n { url: 'https://sdk.example.test', host: 'sdk.example.test', port: '443' },\n { url: 'http://sdk.example.test', host: 'sdk.example.test', port: '80' },\n ];\n for (const scenario of scenarios) {\n const profiles = scenario.profile ? `\u003cprofiles>\u003cprofile>\u003cid>${scenario.profile}\u003c/id>\u003c/profile>\u003c/profiles>` : '';\n fs.writeFileSync(path.join(root, 'pom.xml'), `\u003cproject>\u003cpackaging>${scenario.packaging}\u003c/packaging>${profiles}\u003c/project>`);\n rejectProfile = scenario.reject;\n for (const target of targets) {\n commands.length = 0;\n const result = deploy({ projectDir: root, artifactPath, sdkUrl: target.url });\n if (!scenario.profile) {\n assert.strictEqual(result.ok, false);\n assert.strictEqual(result.mode, 'manual');\n assert.strictEqual(commands.length, 0, 'no Maven call without a project profile');\n continue;\n }\n assert.strictEqual(result.ok, !scenario.reject);\n assert.strictEqual(commands.length, 1, 'no fallback deployment path');\n const args = commands[0];\n assert.ok(args.includes(scenario.profile));\n assert.ok(args.includes(`-Daem.host=${target.host}`), `SDK host must reach Maven: ${args}`);\n assert.ok(args.includes(`-Daem.port=${target.port}`), `SDK port must reach Maven: ${args}`);\n assert.ok(!args.some((a) => /system\\/console|sling\\.url|sling\\.user|sling\\.password|install-file/.test(a)), `no direct console access: ${args}`);\n }\n }\n});\n\ntest('selectArtifact ignores -javadoc.jar and -tests.jar for bundle', () => {\n assert.strictEqual(selectArtifact(['x-javadoc.jar', 'x-tests.jar', 'x.jar'], 'bundle'), 'x.jar');\n assert.strictEqual(selectArtifact(['x-javadoc.jar', 'x-tests.jar', 'x-sources.jar'], 'bundle'), undefined);\n});\n\ntest('prepareStopDecision: --stage all with bundle tasks and no map stops and exits 3', () => {\n assert.deepStrictEqual(prepareStopDecision('all', 1, false), { stop: true, exitCode: 3 });\n});\n\ntest('prepareStopDecision: --stage prepare stops and exits 0', () => {\n assert.deepStrictEqual(prepareStopDecision('prepare', 0, false), { stop: true, exitCode: 0 });\n});\n\ntest('prepareStopDecision: --stage all source-only (no bundle tasks) does not stop', () => {\n assert.strictEqual(prepareStopDecision('all', 0, false).stop, false);\n});\n\ntest('prepareStopDecision: --stage all with an explicit map does not stop', () => {\n assert.strictEqual(prepareStopDecision('all', 2, true).stop, false);\n});\n\ntest('verifyGuidance: --project module keeps the map path and verify command pointed at that module', () => {\n const runDir = path.join(os.tmpdir(), 'repo');\n const g = verifyGuidance({ projectDir: path.join(runDir, 'core'), runDir, script: '/v/check.js' });\n assert.strictEqual(g.mapPath, path.join('core', '.validate-migration', 'diagnosis-map.json'));\n assert.strictEqual(g.command, 'node /v/check.js --stage verify --project core');\n});\n\ntest('verifyGuidance: running from the project root needs no --project', () => {\n const runDir = path.join(os.tmpdir(), 'repo');\n const g = verifyGuidance({ projectDir: runDir, runDir, script: '/v/check.js' });\n assert.strictEqual(g.mapPath, path.join('.validate-migration', 'diagnosis-map.json'));\n assert.strictEqual(g.command, 'node /v/check.js --stage verify');\n});\n\ntest('verifyGuidance: quotes paths that contain spaces', () => {\n const runDir = path.join(os.tmpdir(), 'my repo');\n const g = verifyGuidance({ projectDir: path.join(runDir, 'my module'), runDir, script: '/v w/check.js' });\n assert.strictEqual(g.command, `node '/v w/check.js' --stage verify --project 'my module'`);\n});\n\ntest('every failure_class named in SKILL.md is in the taxonomy and emitted by check.js', () => {\n const skill = fs.readFileSync(path.join(__dirname, '..', '..', 'references', 'validation', 'validate-migration.md'), 'utf8');\n const sources = [path.join(__dirname, 'check.js')];\n const walk = (dir) => fs.readdirSync(dir, { withFileTypes: true }).forEach((e) => {\n const p = path.join(dir, e.name);\n if (e.isDirectory()) walk(p); else if (e.name.endsWith('.js')) sources.push(p);\n });\n walk(path.join(__dirname, 'check'));\n const checkSrc = sources.map((f) => fs.readFileSync(f, 'utf8')).join('\\n');\n const keyOf = Object.fromEntries(Object.entries(FAILURE_CLASSES).map(([k, v]) => [v, k]));\n const named = new Set((skill.match(/`([a-z]+\\.[a-z_]+)`/g) || []).map((s) => s.replace(/`/g, '')));\n for (const token of named) {\n if (!/^(runtime|source|setup|deploy|build|input|discovery|tests|sdk)\\./.test(token)) continue;\n assert.ok(ALL_FAILURE_CLASSES.includes(token), `SKILL.md names '${token}' which is not in the frozen taxonomy`);\n assert.ok(checkSrc.includes(`FAILURE_CLASSES.${keyOf[token]}`), `SKILL.md names '${token}' but check.js never emits it`);\n }\n});\n\ntest('legacy-ui plan rule requires a path boundary (confirmdialog.xml does not match)', () => {\n const rule = RULES.find((r) => r.pattern === 'legacy-ui');\n assert.strictEqual(rule.test('apps/x/comp/_cq_dialog/.content.xml'), true);\n assert.strictEqual(rule.test('apps/x/comp/dialog.xml'), true);\n assert.strictEqual(rule.test('apps/x/clientlibs/confirmdialog.xml'), false);\n assert.strictEqual(rule.test('apps/x/custom-dialog.xml'), false);\n});\n\n// ── offline manifest / bytecode detection ─────────────────────────\n\ntest('manifestImportsPackage matches the package within Import-Package only', () => {\n const mf = 'Import-Package: org.apache.sling.distribution;version=\"[1,2)\",org.osgi.framework\\n';\n assert.strictEqual(manifestImportsPackage(mf, 'org.apache.sling.distribution'), true);\n});\n\ntest('manifestImportsPackage matches a sub-package namespace of the import', () => {\n const mf = 'Import-Package: org.apache.sling.distribution.agent,org.osgi.framework\\n';\n assert.strictEqual(manifestImportsPackage(mf, 'org.apache.sling.distribution'), true);\n});\n\ntest('manifestImportsPackage does not match a sibling package by prefix', () => {\n const mf = 'Import-Package: org.apache.sling.distributionx\\n';\n assert.strictEqual(manifestImportsPackage(mf, 'org.apache.sling.distribution'), false);\n});\n\ntest('manifestImportsPackage ignores packages that appear only in other headers', () => {\n const mf = 'Import-Package: org.osgi.framework\\nExport-Package: org.apache.sling.distribution\\n';\n assert.strictEqual(manifestImportsPackage(mf, 'org.apache.sling.distribution'), false);\n});\n\ntest('referencesClassName detects a class by its internal (slash) name', () => {\n const buf = Buffer.from('....!com/day/cq/dam/api/AssetManager.', 'utf8');\n assert.strictEqual(referencesClassName(buf, 'com.day.cq.dam.api.AssetManager'), true);\n});\n\ntest('referencesClassName does not match the package when the class is absent', () => {\n const buf = Buffer.from('com/day/cq/dam/api/Asset', 'utf8');\n assert.strictEqual(referencesClassName(buf, 'com.day.cq.dam.api.AssetManager'), false);\n});\n\ntest('topicFromDescriptor reads job.topics and returns null when absent', () => {\n assert.strictEqual(topicFromDescriptor('\u003cproperty name=\"job.topics\" value=\"acme/job\"/>'), 'acme/job');\n assert.strictEqual(topicFromDescriptor('\u003cscr:component name=\"x\"/>'), null);\n});\n\ntest('rclFlagsFromDescriptor reads resource.paths and resource.change.types', () => {\n const xml = '\u003cproperty name=\"resource.paths\" value=\"/content\"/>\u003cproperty name=\"resource.change.types\" value=\"ADDED\"/>';\n assert.deepStrictEqual(rclFlagsFromDescriptor(xml), { hasPaths: true, hasChangeTypes: true });\n assert.deepStrictEqual(rclFlagsFromDescriptor('\u003cx/>'), { hasPaths: false, hasChangeTypes: false });\n});\n\n\n\n// ── CAM project linkage (migration-runbook.json) ──────────────────\n\nfunction writeRunbook(dir, project) {\n fs.mkdirSync(dir, { recursive: true });\n fs.writeFileSync(path.join(dir, 'migration-runbook.json'), JSON.stringify({ project }));\n}\n\ntest('readContext finds migration-runbook.json in a parent directory of the module', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-ctx-up-'));\n writeRunbook(root, { id: 'P9', name: null });\n const module = path.join(root, 'core');\n fs.mkdirSync(module);\n assert.strictEqual(readContext({ project: module }).projectId, 'P9');\n fs.rmSync(root, { recursive: true, force: true });\n});\n\ntest('readContext prefers the nearest migration-runbook.json over a parent one', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-ctx-near-'));\n const module = path.join(root, 'core');\n writeRunbook(root, { id: 'PARENT', name: null });\n writeRunbook(module, { id: 'NEAR', name: null });\n assert.strictEqual(readContext({ project: module }).projectId, 'NEAR');\n fs.rmSync(root, { recursive: true, force: true });\n});\n\ntest('readContext returns no project when the runbook has project null or is unreadable', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-ctx-none-'));\n writeRunbook(root, null);\n assert.strictEqual(readContext({ project: root }).projectId, undefined);\n fs.writeFileSync(path.join(root, 'migration-runbook.json'), '{not json');\n assert.deepStrictEqual(readContext({ project: root }), {});\n fs.rmSync(root, { recursive: true, force: true });\n});\n\ntest('camReportingNote tells the user when no CAM project is linked and stays silent when one is', () => {\n assert.match(camReportingNote({}), /not reported to CAM: no project linked/);\n assert.strictEqual(camReportingNote({ 'project-id': 'P1' }), null);\n});\n\n// ── report by project name (developers don't know the 24-char id) ──────────\n\ntest('buildMcpPayload carries project_name when only a name is linked', () => {\n const record = {\n run_id: 'vm-1', result: 'pass', verification_level: 'runtime',\n started_at: 'a', finished_at: 'b',\n classes: [{ result: 'pass', pattern: 'scheduler', discovered: { fqcn: 'com.acme.Ok' } }],\n };\n const payload = buildMcpPayload(record, { 'project-name': 'WKND Legacy', pattern: 'scheduler' });\n assert.strictEqual(payload.project_name, 'WKND Legacy');\n assert.ok(!('project_id' in payload), 'no project_id when only a name is linked');\n});\n\ntest('camReportingNote stays silent when a project name is linked', () => {\n assert.strictEqual(camReportingNote({ 'project-name': 'WKND Legacy' }), null);\n});\n\n// ── local run report (the no-CAM record) ───────────────────────────────────\n\ntest('renderLocalReport includes the run id, result, and each class row', () => {\n const md = renderLocalReport({\n run_id: 'vm-9', result: 'fail', verification_level: 'runtime',\n started_at: 's', finished_at: 'f',\n classes: [\n { pattern: 'scheduler', result: 'pass', bundle: { symbolic_name: 'com.acme.core', state: 'Active' }, component_state: 'Active', discovered: { fqcn: 'com.acme.Job' } },\n { pattern: 'event-migration', result: 'fail', failure_class: 'runtime.component_unsatisfied', component_state: 'Unsatisfied', class_name: 'com.acme.Bad' },\n ],\n });\n assert.match(md, /vm-9/);\n assert.match(md, /fail/);\n assert.match(md, /com\\.acme\\.Job/);\n assert.match(md, /runtime\\.component_unsatisfied/);\n});\n\ntest('renderLocalReport distinguishes a restricted pass from pending reporting', () => {\n const md = renderLocalReport({\n run_id: 'vm-9', result: 'pass', verification_level: 'runtime',\n classes: [{\n pattern: 'scheduler', result: 'pass',\n discovered: { fqcn: 'com.acme.Job' },\n restricted: true,\n restricted_reason: 'scheduler DS properties not exposed by diagnose-osgi-bundle',\n }],\n });\n assert.match(md, /validation result: pass/);\n assert.match(md, /reporting: pending/);\n assert.match(md, /completion: incomplete/);\n assert.match(md, /com\\.acme\\.Job: scheduler DS properties not exposed/);\n});\n\ntest('finalizeReporting requires an acknowledgement for the same run', () => {\n const record = { run_id: 'vm-9', result: 'pass', classes: [] };\n const finalized = finalizeReporting(record, {\n ok: true, run_id: 'vm-9', stored_at: '2026-10-08T00:00:00Z', duplicate: false,\n });\n assert.strictEqual(finalized.reporting.status, 'recorded');\n assert.strictEqual(finalized.reporting.stored_at, '2026-10-08T00:00:00Z');\n assert.match(renderLocalReport(finalized), /completion: complete/);\n assert.ok(!record.reporting, 'the validation record is not mutated');\n});\n\ntest('finalizeReporting accepts the AEM MCP Server result (success: true) as the receipt', () => {\n const record = { run_id: 'vm-9', result: 'pass', classes: [] };\n const finalized = finalizeReporting(record, {\n success: true, run_id: 'vm-9', project_id: '0123456789abcdef01234567', stored_at: '2026-10-08T00:00:00Z', duplicate: true,\n });\n assert.strictEqual(finalized.reporting.status, 'recorded');\n assert.strictEqual(finalized.reporting.duplicate, true);\n assert.match(renderLocalReport(finalized), /completion: complete/);\n});\n\ntest('finalizeReporting leaves failed or mismatched submissions incomplete', () => {\n const record = { run_id: 'vm-9', result: 'pass', classes: [] };\n for (const receipt of [null, {}, { ok: true }, { ok: true, run_id: 'other' }, { success: true, run_id: 'other' },\n { success: 'true', run_id: 'vm-9' }, { error: 'denied', run_id: 'vm-9' },\n { success: false, error: 'Invalid migration validation result payload.', details: ['x'], run_id: 'vm-9' }]) {\n const finalized = finalizeReporting(record, receipt);\n assert.strictEqual(finalized.reporting.status, 'failed');\n assert.match(renderLocalReport(finalized), /completion: incomplete/);\n }\n const finalized = finalizeReporting(record, { ok: false, error: 'backend configuration missing' });\n assert.strictEqual(finalized.result, 'pass', 'submission failure does not change validation');\n assert.strictEqual(finalized.reporting.error, 'MCP submission failed; inspect the tool response without persisting credentials');\n assert.ok(!renderLocalReport(finalized).includes('backend configuration missing'), 'raw tool errors are not persisted');\n});\n\ntest('finalizeReporting does not claim downstream event delivery or persist credentials', () => {\n const finalized = finalizeReporting({ run_id: 'vm-9', result: 'fail', classes: [] }, {\n ok: true, run_id: 'vm-9', apiToken: 'secret',\n });\n assert.strictEqual(finalized.result, 'fail');\n assert.ok(!JSON.stringify(finalized).includes('secret'));\n assert.match(renderLocalReport(finalized), /event delivery: unconfirmed/);\n});\n\ntest('finalizeReporting rejects acknowledgements when the validation run id is missing', () => {\n const finalized = finalizeReporting({ result: 'pass', classes: [] }, { ok: true });\n assert.strictEqual(finalized.reporting.status, 'failed');\n});\n\ntest('writeLocalReport rejects run ids that could escape the report directory', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-safe-report-'));\n try {\n for (const run_id of ['../migration-runbook', 'vm-9/../../outside', undefined]) {\n assert.throws(() => writeLocalReport({ run_id, classes: [] }, root), /invalid run_id/);\n }\n assert.ok(!fs.existsSync(path.join(root, 'migration-runbook.json')));\n } finally {\n fs.rmSync(root, { recursive: true, force: true });\n }\n});\n\ntest('finalizeReporting does not persist potentially sensitive failure text', () => {\n const finalized = finalizeReporting({ run_id: 'vm-9', result: 'pass', classes: [] }, {\n ok: false, error: 'Request rejected: Authorization: Bearer secret-value; password=another-secret',\n });\n assert.strictEqual(finalized.reporting.status, 'failed');\n assert.ok(!JSON.stringify(finalized).includes('secret-value'));\n assert.ok(!renderLocalReport(finalized).includes('another-secret'));\n});\n\ntest('explicit project name is not combined with an inherited runbook project id', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-explicit-project-'));\n try {\n fs.writeFileSync(path.join(root, 'migration-runbook.json'), JSON.stringify({\n project: { id: 'inherited-id', name: 'Inherited Project' },\n }));\n fs.mkdirSync(path.join(root, '.validate-migration'));\n fs.writeFileSync(path.join(root, '.validate-migration', 'state.json'), JSON.stringify({\n prepared: [{ pattern: 'scheduler', failure: { result: 'fail', failure_class: 'build.failed' } }],\n }));\n const execution = spawnSync(process.execPath, [\n path.join(__dirname, 'check.js'), '--stage', 'verify', '--project', root,\n '--project-name', 'Requested Project',\n ], { encoding: 'utf8' });\n assert.strictEqual(execution.status, 1, execution.stderr);\n const payloadText = execution.stdout.split('=== submit-migration-validation-result payload ===')[1].split('=== end payload ===')[0];\n const payload = JSON.parse(payloadText);\n assert.strictEqual(payload.project_name, 'Requested Project');\n assert.ok(!('project_id' in payload));\n } finally {\n fs.rmSync(root, { recursive: true, force: true });\n }\n});\n\ntest('finalize stage persists reporting status and exits according to the acknowledgement', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-finalize-'));\n try {\n const receiptPath = path.join(root, 'receipt.json');\n for (const [result, receipt, exitCode, reportingStatus] of [\n ['pass', { ok: false, error: 'backend unavailable' }, 3, 'failed'],\n ['pass', { ok: true, run_id: 'vm-9', stored_at: '2026-10-08T00:00:00Z' }, 0, 'recorded'],\n ['fail', { ok: true, run_id: 'vm-9' }, 1, 'recorded'],\n ]) {\n const report = writeLocalReport({ run_id: 'vm-9', result, classes: [] }, root);\n fs.writeFileSync(receiptPath, JSON.stringify(receipt));\n const execution = spawnSync(process.execPath, [\n path.join(__dirname, 'check.js'), '--stage', 'finalize',\n '--project', root, '--report', report.jsonPath, '--receipt', receiptPath,\n ], { encoding: 'utf8' });\n assert.strictEqual(execution.status, exitCode, execution.stderr);\n assert.strictEqual(JSON.parse(fs.readFileSync(report.jsonPath, 'utf8')).reporting.status, reportingStatus);\n assert.match(fs.readFileSync(report.mdPath, 'utf8'), new RegExp(`reporting: ${reportingStatus}`));\n }\n } finally {\n fs.rmSync(root, { recursive: true, force: true });\n }\n});\n\nfunction withSdkEnv(context) {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-sdkurl-'));\n const cwd = process.cwd();\n const env = process.env.AEM_SDK_URL;\n delete process.env.AEM_SDK_URL;\n const probes = [];\n context.mock.method(globalThis, 'fetch', async (url) => { probes.push(url); throw new Error('unexpected'); });\n context.after(() => {\n process.chdir(cwd);\n if (env !== undefined) process.env.AEM_SDK_URL = env;\n fs.rmSync(dir, { recursive: true, force: true });\n assert.deepStrictEqual(probes, [], 'resolveSdkUrl never contacts AEM');\n });\n process.chdir(dir);\n return dir;\n}\n\ntest('resolveSdkUrl defaults to the fixed SDK port 4602 without contacting AEM', (context) => {\n withSdkEnv(context);\n assert.strictEqual(resolveSdkUrl({}), 'http://localhost:4602');\n});\n\ntest('resolveSdkUrl reads setup saved at the reactor root when --project points at a module', (context) => {\n const dir = withSdkEnv(context);\n fs.mkdirSync(path.join(dir, '.validate-migration'));\n fs.mkdirSync(path.join(dir, 'core'));\n fs.writeFileSync(path.join(dir, '.validate-migration', 'setup.json'), JSON.stringify({ sdk_url: 'http://localhost:4999' }));\n assert.strictEqual(resolveSdkUrl({ project: path.join(dir, 'core') }), 'http://localhost:4999');\n});\n\ntest('resolveSdkUrl refuses an SDK URL that is the saved or explicit AEM 6.5 source URL', (context) => {\n const dir = withSdkEnv(context);\n fs.mkdirSync(path.join(dir, '.validate-migration'));\n fs.writeFileSync(path.join(dir, '.validate-migration', 'setup.json'), JSON.stringify({\n sdk_url: 'http://localhost:4602', quickstart_url: 'http://localhost:4502',\n }));\n assert.strictEqual(resolveSdkUrl({ project: dir, sdk: 'http://127.0.0.1:4502' }), null);\n assert.strictEqual(resolveSdkUrl({ sdk: 'http://localhost:4602', quickstart: 'http://localhost:4602/' }), null);\n assert.strictEqual(resolveSdkUrl({ project: dir }), 'http://localhost:4602');\n});\n\ntest('resolveSdkUrl allows an SDK on 4502 when no AEM 6.5 source is configured', (context) => {\n withSdkEnv(context);\n assert.strictEqual(resolveSdkUrl({ sdk: 'http://localhost:4502' }), 'http://localhost:4502');\n});\n\ntest('resolveSdkUrl rejects an invalid SDK URL', (context) => {\n withSdkEnv(context);\n assert.strictEqual(resolveSdkUrl({ sdk: 'not a url' }), null);\n});\n\ntest('a Maven install that cannot connect is classified as an unreachable SDK', () => {\n assert.strictEqual(isSdkUnreachable('[ERROR] ... java.net.ConnectException: Connection refused'), true);\n assert.strictEqual(isSdkUnreachable('Connect to localhost:4602 failed: Connection refused'), true);\n assert.strictEqual(isSdkUnreachable('java.net.UnknownHostException: sdk.invalid'), true);\n assert.strictEqual(isSdkUnreachable('[ERROR] Failed to execute goal: HTTP 401 Unauthorized'), false);\n assert.strictEqual(isSdkUnreachable(undefined), false);\n});\n\ntest('custom-templates verifies template definition, structure and initial content offline', async () => {\n const TEMPLATE = '/conf/wknd/settings/wcm/templates/content-page-template';\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-template-'));\n try {\n const write = (rel, body) => {\n const f = path.join(dir, 'jcr_root', TEMPLATE.slice(1), rel);\n fs.mkdirSync(path.dirname(f), { recursive: true });\n fs.writeFileSync(f, body);\n };\n const staticFile = path.join(dir, 'ui.apps/src/main/content/jcr_root/apps/wknd/templates/content-page-template/.content.xml');\n fs.mkdirSync(path.dirname(staticFile), { recursive: true });\n fs.writeFileSync(staticFile, '\u003cjcr:root jcr:primaryType=\"cq:Template\">\u003cjcr:content sling:resourceType=\"wknd/components/page\">'\n + '\u003croot sling:resourceType=\"wknd/components/container\">\u003cpar sling:resourceType=\"wcm/foundation/components/parsys\">'\n + '\u003ctitle sling:resourceType=\"wknd/components/title\"/>\u003ctext sling:resourceType=\"wknd/components/text\"/>\u003c/par>\u003c/root>\u003c/jcr:content>\u003c/jcr:root>');\n const git = (...args) => execFileSync('git', ['-c', 'user.name=t', '-c', '[email protected]', ...args], { cwd: dir });\n git('init', '-q');\n git('add', 'ui.apps');\n git('commit', '-q', '-m', 'static template');\n const page = (children) => '\u003cjcr:root jcr:primaryType=\"cq:Page\">\u003cjcr:content sling:resourceType=\"wknd/components/page\">'\n + `\u003croot sling:resourceType=\"wknd/components/container\">${children}\u003c/root>\u003c/jcr:content>\u003c/jcr:root>`;\n write('.content.xml', '\u003cjcr:root jcr:primaryType=\"cq:Template\"/>');\n write('structure/.content.xml', page('\u003ccontainer sling:resourceType=\"wknd/components/container\" editable=\"{Boolean}true\"/>'));\n execFileSync('zip', ['-q', '-r', 'package.zip', 'jcr_root'], { cwd: dir });\n const pkg = path.join(dir, 'package.zip');\n const pat = PATTERNS['custom-templates'];\n assert.strictEqual(pat.mode, 'source-only');\n assert.deepStrictEqual(pat.discover(pkg, {}), { templates: [TEMPLATE] });\n\n const missing = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE } });\n assert.strictEqual(missing.result, 'fail');\n assert.match(missing.evidence, /missing initial/);\n\n write('initial/.content.xml', page('\u003ccontainer sling:resourceType=\"wknd/components/container\">\u003ctitle sling:resourceType=\"wknd/components/title\"/>\u003c/container>'));\n execFileSync('zip', ['-q', '-r', 'package.zip', 'jcr_root'], { cwd: dir });\n const lost = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir });\n assert.strictEqual(lost.result, 'fail');\n assert.match(lost.evidence, /text \\(wknd\\/components\\/text\\)/);\n\n write('initial/.content.xml', page('\u003ccontainer sling:resourceType=\"wknd/components/container\">'\n + '\u003ctitle sling:resourceType=\"wknd/components/title\"/>\u003ctext sling:resourceType=\"wknd/components/text\"/>\u003c/container>'));\n execFileSync('zip', ['-q', '-r', 'package.zip', 'jcr_root'], { cwd: dir });\n const ok = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir });\n assert.strictEqual(ok.result, 'pass');\n assert.deepStrictEqual(ok.checks, { template_definition: true, structure: true, initial: true, policies: false, component_mapping: true });\n\n const noBaseline = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir, staticTemplate: '/apps/wknd/templates/missing' });\n const optionRef = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir, baselineRef: '--output=/tmp/vm-injected' });\n assert.strictEqual(optionRef.checks.component_mapping, 'not_verified');\n assert.ok(!fs.existsSync('/tmp/vm-injected'));\n assert.strictEqual(noBaseline.result, 'pass');\n assert.strictEqual(noBaseline.restricted, true);\n assert.strictEqual(noBaseline.checks.component_mapping, 'not_verified');\n const noBaselineRuntime = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir,\n staticTemplate: '/apps/wknd/templates/missing', requireRuntime: true });\n assert.strictEqual(noBaselineRuntime.result, 'fail');\n\n const runtime = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir, requireRuntime: true });\n assert.strictEqual(runtime.result, 'fail');\n assert.strictEqual(runtime.checks.runtime_evidence, false);\n\n const { createHash } = require('crypto');\n const evidencePath = path.join(dir, 'template-evidence.json');\n const captures = [\n { viewport: { width: 1440, height: 900 }, source: 'source-desktop.png', target: 'target-desktop.png', result: 'pass', differences: [] },\n { viewport: { width: 390, height: 844 }, source: 'source-mobile.png', target: 'target-mobile.png', result: 'pass', differences: [] },\n ];\n for (const capture of captures) {\n const png = makePng(capture.viewport.width, capture.viewport.height);\n fs.writeFileSync(path.join(dir, capture.source), png);\n fs.writeFileSync(path.join(dir, capture.target), png);\n }\n const evidence = {\n template: TEMPLATE,\n artifact_sha256: createHash('sha256').update(fs.readFileSync(pkg)).digest('hex'),\n captured_at: new Date().toISOString(),\n deployment: { target_origin: 'http://localhost:4602', status: 'succeeded',\n bundles: [{ symbolic_name: 'com.adobe.aem.guides.aem-guides-wknd.core', state: 'Active' }] },\n source: { url: 'http://localhost:4502/content/wknd/test.html?wcmmode=disabled', template: '/apps/wknd/templates/content-page', status: 200,\n components: ['legacy/topnav', 'legacy/hero', 'legacy/hero'], texts: ['WKND Adventures'], images: ['/content/dam/wknd/hero.jpg'] },\n target: { url: 'http://localhost:4602/content/wknd/test.html?wcmmode=disabled', template: TEMPLATE, status: 200,\n components: ['modern/topnav', 'legacy/hero', 'legacy/hero'], texts: ['WKND Adventures'], images: ['/content/dam/wknd/hero.jpg'] },\n component_mapping: { 'legacy/topnav': 'modern/topnav' },\n captures,\n cleanup: { status: 'not_required', created_pages: [] },\n };\n const verifyEvidence = async (value) => {\n fs.writeFileSync(evidencePath, JSON.stringify(value));\n return pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir, requireRuntime: true, runtimeEvidencePath: evidencePath });\n };\n const compared = await verifyEvidence(evidence);\n assert.strictEqual(compared.result, 'pass');\n assert.strictEqual(compared.verification_level, 'runtime');\n assert.strictEqual(compared.checks.component_parity, true);\n assert.strictEqual(compared.checks.bundles_active, true);\n assert.strictEqual(compared.checks.business_behavior, 'not_tested');\n\n const record = { run_id: 'template-runtime-test', result: compared.result, verification_level: compared.verification_level,\n started_at: evidence.captured_at, finished_at: evidence.captured_at,\n classes: [{ pattern: 'custom-templates', discovered: { template: TEMPLATE }, ...compared }] };\n const markdown = renderLocalReport(record, path.join(dir, '.validate-migration'));\n assert.match(markdown, /## Template Comparison/);\n assert.match(markdown, /\\(\\.\\.\\/source-desktop\\.png\\)/);\n assert.match(markdown, /authoring and business behavior not tested/);\n const payload = buildMcpPayload(record, { pattern: 'custom-templates', 'project-id': 'template-test' });\n assert.strictEqual(payload.verification_level, 'runtime');\n assert.ok(JSON.stringify(payload).includes('target-mobile.png'));\n assert.ok(!JSON.stringify(payload).includes(dir) && !JSON.stringify(payload).includes(fs.realpathSync(dir)), 'payload must not leak local paths');\n\n const { spawnSync } = require('child_process');\n const prepare = spawnSync(process.execPath, [require.resolve('./check.js'), '--stage', 'prepare', '--pattern', 'custom-templates',\n '--project', dir, '--jar', pkg, '--template', TEMPLATE, '--template-mode', 'runtime'], { encoding: 'utf8' });\n assert.ok([0, 3].includes(prepare.status), prepare.stderr || prepare.stdout);\n const state = JSON.parse(fs.readFileSync(path.join(dir, '.validate-migration/state.json'), 'utf8'));\n assert.strictEqual(state.prepared[0].requireRuntime, true);\n const withoutEvidence = spawnSync(process.execPath, [require.resolve('./check.js'), '--stage', 'verify', '--project', dir], { encoding: 'utf8' });\n assert.strictEqual(withoutEvidence.status, 1, withoutEvidence.stderr || withoutEvidence.stdout);\n evidence.captured_at = new Date().toISOString();\n for (const capture of captures) {\n const png = makePng(capture.viewport.width, capture.viewport.height);\n fs.writeFileSync(path.join(dir, capture.source), png);\n fs.writeFileSync(path.join(dir, capture.target), png);\n }\n fs.writeFileSync(evidencePath, JSON.stringify(evidence));\n const withEvidence = spawnSync(process.execPath, [require.resolve('./check.js'), '--stage', 'verify', '--project', dir,\n '--template-evidence', evidencePath], { encoding: 'utf8' });\n assert.strictEqual(withEvidence.status, 0, withEvidence.stderr || withEvidence.stdout);\n const reports = fs.readdirSync(path.join(dir, '.validate-migration')).filter((name) => /^vm-.*\\.json$/.test(name));\n assert.ok(reports.some((name) => {\n const report = JSON.parse(fs.readFileSync(path.join(dir, '.validate-migration', name), 'utf8'));\n return report.result === 'pass' && report.verification_level === 'runtime';\n }));\n\n const invalidCases = [\n ['missing duplicate component', (value) => value.target.components.pop()],\n ['missing text', (value) => { value.target.texts = []; }],\n ['missing image', (value) => { value.target.images = []; }],\n ['visual difference', (value) => { value.captures[0].result = 'fail'; value.captures[0].differences = ['Hero overlaps the title']; }],\n ['visual check not performed', (value) => { value.captures[0].result = 'not_verified'; }],\n ['missing mobile capture', (value) => { value.captures.pop(); }],\n ['wrong artifact', (value) => { value.artifact_sha256 = '0'.repeat(64); }],\n ['stale evidence', (value) => { value.captured_at = '2000-01-01T00:00:00.000Z'; }],\n ['wrong template', (value) => { value.target.template = '/conf/wknd/settings/wcm/templates/other'; }],\n ['same instance', (value) => { value.target.url = value.source.url; }],\n ['credentials in URL', (value) => { value.source.url = 'http://user:private@localhost:4502/content/source.html'; }],\n ['failed page render', (value) => { value.target.status = 500; }],\n ['failed deployment', (value) => { value.deployment.status = 'failed'; }],\n ['bundle state not checked', (value) => { delete value.deployment.bundles; }],\n ['different page', (value) => { value.target.url = 'http://localhost:4602/content/wknd/other.html?wcmmode=disabled'; }],\n ['bundle not active', (value) => { value.deployment.bundles[0].state = 'Resolved'; }],\n ['cleanup failure', (value) => { value.cleanup = { status: 'failed', created_pages: ['/content/vm-test/page'] }; }],\n ['unconfirmed cleanup', (value) => { value.cleanup.created_pages = ['/content/vm-test/page']; }],\n ['empty inventory', (value) => { value.source.components = []; }],\n ['malformed source template', (value) => { value.source.template = {}; }],\n ['malformed capture', (value) => { value.captures = [null, null]; }],\n ];\n for (const [name, mutate] of invalidCases) {\n const invalid = structuredClone(evidence);\n mutate(invalid);\n const outcome = await verifyEvidence(invalid);\n assert.strictEqual(outcome.result, 'fail', name);\n }\n await verifyEvidence(evidence);\n const stale = await pat.verify({ artifactPath: pkg, discovered: { template: TEMPLATE }, projectDir: dir, requireRuntime: true,\n runtimeEvidencePath: evidencePath, runStartedAt: new Date(Date.now() + 60_000).toISOString() });\n assert.strictEqual(stale.result, 'fail', 'evidence from before the prepared run');\n const missingCleanup = structuredClone(evidence);\n delete missingCleanup.cleanup;\n const failedCleanup = await verifyEvidence(missingCleanup);\n assert.strictEqual(failedCleanup.result, 'fail');\n assert.doesNotThrow(() => renderLocalReport({ ...record, result: 'fail', classes: [{ pattern: 'custom-templates', ...failedCleanup }] }));\n await verifyEvidence(evidence);\n fs.writeFileSync(path.join(dir, captures[0].source), Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), Buffer.alloc(25)]));\n assert.strictEqual((await verifyEvidence(evidence)).result, 'fail', 'corrupt PNG screenshot');\n const tiny = makePng(1, 1);\n fs.writeFileSync(path.join(dir, captures[0].source), tiny);\n assert.strictEqual((await verifyEvidence(evidence)).result, 'fail', 'screenshot dimensions must match the viewport');\n fs.unlinkSync(path.join(dir, captures[0].source));\n assert.strictEqual((await verifyEvidence(evidence)).result, 'fail', 'missing screenshot artifact');\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n\ntest('discoverTemplates finds and selects editable templates in a content package', () => {\n const { discoverTemplates } = require('./check/patterns/custom-templates/discover.js');\n const TEMPLATE = '/conf/wknd/settings/wcm/templates/content-page-template';\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-discover-'));\n try {\n const entry = path.join(dir, 'jcr_root', TEMPLATE.slice(1), '.content.xml');\n fs.mkdirSync(path.dirname(entry), { recursive: true });\n fs.writeFileSync(entry, '\u003cjcr:root/>');\n execFileSync('zip', ['-q', '-r', 'package.zip', 'jcr_root'], { cwd: dir });\n const pkg = path.join(dir, 'package.zip');\n assert.deepStrictEqual(discoverTemplates(pkg), [TEMPLATE]);\n assert.deepStrictEqual(discoverTemplates(pkg, TEMPLATE), [TEMPLATE]);\n assert.deepStrictEqual(discoverTemplates(pkg, TEMPLATE.replace('content-page', 'other')), []);\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n\ntest('verify emits independent payloads and reports for two templates', () => {\n const TEMPLATE = '/conf/wknd/settings/wcm/templates/content-page-template';\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-template-reports-'));\n try {\n const state = path.join(dir, '.validate-migration');\n fs.mkdirSync(state);\n fs.writeFileSync(path.join(state, 'state.json'), JSON.stringify({ prepared: [TEMPLATE, TEMPLATE.replace('content-page', 'article-page')].map((template) => ({\n pattern: 'custom-templates', discovered: { template }, failure: {\n result: 'fail', failure_class: 'source.contract_mismatch', evidence: 'test fixture', verification_level: 'source-only',\n },\n })) }));\n const execution = spawnSync(process.execPath, [path.join(__dirname, 'check.js'), '--stage', 'verify', '--project', dir, '--project-id', 'test-project'], { encoding: 'utf8' });\n assert.strictEqual(execution.status, 1, execution.stderr);\n const payloads = [...execution.stdout.matchAll(/=== submit-migration-validation-result payload ===\\n([^\\n]+)/g)].map((m) => JSON.parse(m[1]));\n assert.strictEqual(payloads.length, 2);\n assert.notStrictEqual(payloads[0].run_id, payloads[1].run_id);\n assert.strictEqual(payloads[0].skill_pattern, 'custom-templates');\n assert.strictEqual(payloads[0].summary.classes_total, 1);\n assert.strictEqual(fs.readdirSync(state).filter((file) => /^vm-\\d+\\.json$/.test(file)).length, 2);\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n\ntest('writeLocalReport writes \u003crun_id>.md and \u003crun_id>.json under .validate-migration', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-report-'));\n const record = {\n run_id: 'vm-7', result: 'pass', verification_level: 'source-only',\n started_at: 's', finished_at: 'f',\n classes: [{ pattern: 'legacy-ui', result: 'pass' }],\n };\n const out = writeLocalReport(record, root);\n assert.ok(fs.existsSync(out.mdPath), 'markdown report written');\n assert.ok(fs.existsSync(out.jsonPath), 'json report written');\n assert.deepStrictEqual(JSON.parse(fs.readFileSync(out.jsonPath, 'utf8')).run_id, 'vm-7');\n assert.match(fs.readFileSync(out.mdPath, 'utf8'), /vm-7/);\n fs.rmSync(root, { recursive: true, force: true });\n});\n\ntest('local reports accept new vm- run ids and legacy rvc- ids only', () => {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-runid-'));\n try {\n for (const run_id of ['vm-1', 'rvc-1']) {\n assert.ok(fs.existsSync(writeLocalReport({ run_id, result: 'pass', classes: [] }, root).jsonPath), run_id);\n }\n assert.throws(() => writeLocalReport({ run_id: 'xx-1', result: 'pass', classes: [] }, root), /invalid run_id/);\n } finally { fs.rmSync(root, { recursive: true, force: true }); }\n});\n\n// ── Dialog/CDW rules ported from migration/references/legacy-ui/*/validation.md ──\n\nconst ROOT_DIALOG = '\u003cjcr:root jcr:primaryType=\"nt:unstructured\" sling:resourceType=\"cq/gui/components/authoring/dialog\">';\nconst touchDialog = (body) => `${ROOT_DIALOG}${body}\u003c/jcr:root>`;\n\ntest('dialogRuleViolations accepts a clean Coral 3 dialog', () => {\n const { dialogRuleViolations } = require('./check/lib/dialogs.js');\n const xml = touchDialog('\u003ctitle sling:resourceType=\"granite/ui/components/coral/foundation/form/textfield\" name=\"./jcr:title\"/>'\n + '\u003cdate sling:resourceType=\"granite/ui/components/coral/foundation/form/datepicker\" name=\"./date\" valueFormat=\"YYYY-MM-DD\"/>'\n + '\u003cpath sling:resourceType=\"granite/ui/components/coral/foundation/form/pathfield\" name=\"./link\" rootPath=\"/content\"/>');\n assert.deepStrictEqual(dialogRuleViolations(xml), { blocking: [], warnings: [] });\n});\n\ntest('dialogRuleViolations reports each blocking dialog validation.md rule by number', () => {\n const { dialogRuleViolations } = require('./check/lib/dialogs.js');\n const cases = [\n ['1.2', touchDialog('\u003cf xtype=\"textfield\"/>')],\n ['1.3', touchDialog('\u003citems jcr:primaryType=\"cq:WidgetCollection\"/>')],\n ['2.1', touchDialog('\u003cf sling:resourceType=\"granite/ui/components/foundation/form/textfield\" name=\"./t\"/>')],\n ['2.2', '\u003cjcr:root jcr:primaryType=\"nt:unstructured\" sling:resourceType=\"granite/ui/components/coral/foundation/container\">\u003cf name=\"./t\"/>\u003c/jcr:root>'],\n ['2.3', touchDialog('\u003cw jcr:primaryType=\"cq:Widget\"/>')],\n ['3.1', touchDialog('\u003clisteners jcr:primaryType=\"nt:unstructured\"/>')],\n ['5.1', touchDialog('\u003cd sling:resourceType=\"granite/ui/components/coral/foundation/form/datepicker\" name=\"./d\"/>')],\n ['7.1', touchDialog('\u003ct sling:resourceType=\"granite/ui/components/coral/foundation/form/textfield\" name=\"title\"/>')],\n ['8.0', touchDialog('\u003cp sling:resourceType=\"granite/ui/components/coral/foundation/form/pathfield\" name=\"./p\" rootPath=\"/libs/x\"/>')],\n ];\n for (const [rule, xml] of cases) {\n assert.ok(dialogRuleViolations(xml).blocking.includes(rule), `rule ${rule}: ${JSON.stringify(dialogRuleViolations(xml))}`);\n }\n});\n\ntest('dialogRuleViolations reports non-blocking rules 6.1 and 8.1b as warnings only', () => {\n const { dialogRuleViolations } = require('./check/lib/dialogs.js');\n const xml = touchDialog('\u003cu sling:resourceType=\"granite/ui/components/coral/foundation/form/fileupload\" name=\"./file\"/>'\n + '\u003cp sling:resourceType=\"granite/ui/components/coral/foundation/form/pathfield\" name=\"./p\" rootPath=\"/content//site\"/>');\n assert.deepStrictEqual(dialogRuleViolations(xml), { blocking: [], warnings: ['6.1', '8.1b'] });\n});\n\ntest('dialogRuleViolations allows relative form field names that start with ./ or ../', () => {\n const { dialogRuleViolations } = require('./check/lib/dialogs.js');\n const xml = touchDialog('\u003ca sling:resourceType=\"granite/ui/components/coral/foundation/form/textfield\" name=\"../parent\"/>');\n assert.deepStrictEqual(dialogRuleViolations(xml).blocking, []);\n});\n\nfunction contentPackage(files) {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-pkg-'));\n for (const [rel, xml] of Object.entries(files)) {\n fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });\n fs.writeFileSync(path.join(dir, rel), xml);\n }\n execFileSync('zip', ['-q', '-r', 'package.zip', 'jcr_root'], { cwd: dir });\n return { dir, zip: path.join(dir, 'package.zip') };\n}\n\ntest('legacy-ui fails a Coral 3 dialog that breaks a blocking validation.md rule and names the rule', async () => {\n const dialog = 'jcr_root/apps/acme/components/hero/_cq_dialog/.content.xml';\n const { dir, zip } = contentPackage({\n [dialog]: touchDialog('\u003cd sling:resourceType=\"granite/ui/components/coral/foundation/form/datepicker\" name=\"./d\"/>'),\n });\n try {\n const pat = PATTERNS['legacy-ui'];\n const outcome = await pat.verify({ artifactPath: zip, discovered: pat.discover(zip, { files: [`ui.apps/src/main/content/${dialog}`] }) });\n assert.strictEqual(outcome.result, 'fail');\n assert.match(outcome.evidence, /5\\.1/);\n assert.match(outcome.evidence, /hero/);\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n\ntest('legacy-ui passes a clean Coral 3 dialog and records warnings in checks', async () => {\n const dialog = 'jcr_root/apps/acme/components/hero/_cq_dialog/.content.xml';\n const { dir, zip } = contentPackage({\n [dialog]: touchDialog('\u003cu sling:resourceType=\"granite/ui/components/coral/foundation/form/fileupload\" name=\"./file\"/>'),\n });\n try {\n const pat = PATTERNS['legacy-ui'];\n const outcome = await pat.verify({ artifactPath: zip, discovered: pat.discover(zip) });\n assert.strictEqual(outcome.result, 'pass', outcome.evidence);\n assert.strictEqual(outcome.checks.rule_warnings, 1);\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n\ntest('cdw fails a scaffolded Granite form component without the Coral form field supertype (cdw validation.md 2.2)', async () => {\n const { dir, zip } = contentPackage({\n 'jcr_root/apps/acme/components/hero/_cq_dialog/.content.xml': touchDialog('\u003cc sling:resourceType=\"acme/components/granite/form/colorpicker\" name=\"./color\"/>'),\n 'jcr_root/apps/acme/components/granite/form/colorpicker/.content.xml': '\u003cjcr:root jcr:primaryType=\"cq:Component\" sling:resourceSuperType=\"granite/ui/components/coral/foundation/form/textfield\"/>',\n });\n try {\n const pat = PATTERNS.cdw;\n const outcome = await pat.verify({ artifactPath: zip, discovered: pat.discover(zip) });\n assert.strictEqual(outcome.result, 'fail');\n assert.match(outcome.evidence, /2\\.2/);\n assert.match(outcome.evidence, /colorpicker/);\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n\n// ── Migration-skill / BPA pattern names are accepted as aliases ──\n\ntest('canonicalPattern maps migration runbook and BPA pattern names to validator patterns', () => {\n const { canonicalPattern } = require('./check/patterns/aliases.js');\n const expected = {\n assetApi: 'asset-manager', resourceChangeListener: 'resource-change-listener',\n eventListener: 'event-migration', eventHandler: 'event-migration',\n lui: 'legacy-ui', luiCoral2: 'legacy-ui', templateModernization: 'custom-templates',\n scheduler: 'scheduler', replication: 'replication', cdw: 'cdw', 'legacy-ui': 'legacy-ui',\n };\n for (const [name, pattern] of Object.entries(expected)) {\n assert.strictEqual(canonicalPattern(name), pattern, name);\n assert.ok(PATTERNS[pattern], `${pattern} is a registered validator`);\n }\n assert.strictEqual(canonicalPattern('unknownThing'), 'unknownThing');\n});\n\ntest('check.js accepts a migration pattern name for --pattern and as the positional pattern', () => {\n assert.strictEqual(parseArgs(['--pattern', 'assetApi']).pattern, 'asset-manager');\n assert.strictEqual(parseArgs(['lui', '--project', '/p']).pattern, 'legacy-ui');\n});\n\ntest('setup scope accepts migration pattern names', () => {\n const { requiredChecks } = require('./setup.js');\n assert.deepStrictEqual(requiredChecks(['lui', 'cdw']), []);\n assert.deepStrictEqual(requiredChecks(['assetApi']), ['sdk', 'mcp']);\n});\n\n// ── asset-manager follows the code-assessment asset-manager guidance ──\n\nfunction assetJar(context, classBytes) {\n const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-asset-'));\n context.after(() => fs.rmSync(root, { recursive: true, force: true }));\n fs.mkdirSync(path.join(root, 'OSGI-INF'));\n fs.writeFileSync(path.join(root, 'OSGI-INF', 'c.xml'),\n '\u003cscr:component xmlns:scr=\"http://www.osgi.org/xmlns/scr/v1.3.0\" name=\"com.acme.Assets\">\u003cimplementation class=\"com.acme.Assets\"/>'\n + '\u003creference name=\"rrf\" interface=\"org.apache.sling.api.resource.ResourceResolverFactory\"/>\u003c/scr:component>');\n fs.writeFileSync(path.join(root, 'Assets.class'), Buffer.from(classBytes));\n const jar = path.join(root, 'bundle.jar');\n execFileSync('zip', ['-qr', jar, 'OSGI-INF', 'Assets.class'], { cwd: root });\n return jar;\n}\n\ntest('asset-manager keeps supported AssetManager reads (getAsset) as a migrated bundle', (context) => {\n const jar = assetJar(context, 'com/day/cq/dam/api/AssetManager getAsset');\n assert.deepStrictEqual(PATTERNS['asset-manager'].discover(jar).removedAssetManagerCalls, []);\n});\n\ntest('asset-manager reports AssetManager binary-path methods removed on Cloud Service', (context) => {\n const jar = assetJar(context, 'com/day/cq/dam/api/AssetManager createAssetForBinary removeAssetForBinary');\n assert.deepStrictEqual(PATTERNS['asset-manager'].discover(jar).removedAssetManagerCalls, ['createAssetForBinary', 'removeAssetForBinary']);\n});\n\ntest('plan classifies DAM API usage as asset-manager, not any ResourceResolverFactory user', () => {\n const hit = (text) => (RULES.find((r) => r.test('X.java', text)) || {}).pattern;\n assert.strictEqual(hit('class Svc { @Reference ResourceResolverFactory rrf; }'), undefined);\n assert.strictEqual(hit('import com.day.cq.dam.api.Asset;\\nclass Svc {}'), 'asset-manager');\n assert.strictEqual(hit('import com.day.cq.dam.api.AssetManager;\\nclass Svc {}'), 'asset-manager');\n});\n\ntest('outcome payload reports the validator package version', () => {\n const payload = buildMcpPayload({ run_id: 'vm-1', result: 'pass', verification_level: 'runtime', classes: [] }, { 'project-id': 'P1', pattern: 'scheduler' });\n assert.strictEqual(payload.skill_version, require('./package.json').version);\n assert.strictEqual(buildMcpPayload({ run_id: 'vm-1', result: 'pass', classes: [] }, { 'project-id': 'P1', 'skill-version': '9.9' }).skill_version, '9.9');\n});\n\n// ── Built-in PNG reader (no npm dependency) ──\n\nfunction crc32(buf) {\n let c = ~0;\n for (const byte of buf) { c ^= byte; for (let k = 0; k \u003c 8; k++) c = (c >>> 1) ^ (0xEDB88320 & -(c & 1)); }\n return ~c >>> 0;\n}\nfunction pngChunk(type, data) {\n const out = Buffer.alloc(12 + data.length);\n out.writeUInt32BE(data.length, 0);\n out.write(type, 4, 'ascii');\n data.copy(out, 8);\n out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);\n return out;\n}\nfunction makePng(width, height) {\n const ihdr = Buffer.alloc(13);\n ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4); ihdr[8] = 8; ihdr[9] = 6;\n const raw = Buffer.alloc((width * 4 + 1) * height);\n return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),\n pngChunk('IHDR', ihdr), pngChunk('IDAT', require('zlib').deflateSync(raw)), pngChunk('IEND', Buffer.alloc(0))]);\n}\n\ntest('pngSize reads width and height from a valid PNG', () => {\n const { pngSize } = require('./check/lib/png.js');\n assert.deepStrictEqual(pngSize(makePng(390, 844)), { width: 390, height: 844 });\n});\n\ntest('pngSize rejects non-PNG, truncated and corrupted files', () => {\n const { pngSize } = require('./check/lib/png.js');\n const good = makePng(10, 10);\n const corrupt = Buffer.from(good); corrupt[20] ^= 0xff;\n for (const bad of [Buffer.from('not a png'), good.subarray(0, 30), corrupt]) {\n assert.throws(() => pngSize(bad), /PNG/);\n }\n});\n\n// ── CAM reporting is optional ──\n\nconst SCHEDULER_PROPS = '\u003cproperty value=\"0 0 * * * ?\" name=\"scheduler.expression\"/>\u003cproperty name=\"scheduler.runOn\" value=\"SINGLE\"/>\u003cproperty type=\"Boolean\" name=\"scheduler.concurrent\" value=\"false\"/>';\n\ntest('a passing run with no CAM project is complete and marked not reported', (context) => {\n const { execution, report } = verifyContractFixture(context, 'scheduler', SCHEDULER_PROPS, 'java.lang.Runnable');\n assert.strictEqual(execution.status, 0, execution.stderr || execution.stdout);\n assert.strictEqual(report.reporting.status, 'not_reported');\n const md = renderLocalReport(report);\n assert.match(md, /reporting: not_reported/);\n assert.match(md, /completion: complete/);\n assert.doesNotMatch(execution.stdout, /submit-migration-validation-result payload/);\n});\n\ntest('a passing run with a CAM project stays pending until the submission is acknowledged', (context) => {\n const { execution, report } = verifyContractFixture(context, 'scheduler', SCHEDULER_PROPS, 'java.lang.Runnable', undefined, { args: ['--project-id', '000000000000000000000001'] });\n assert.strictEqual(execution.status, 3, execution.stderr || execution.stdout);\n assert.strictEqual(report.reporting.status, 'pending');\n assert.match(execution.stdout, /submit-migration-validation-result payload/);\n});\n\ntest('dialogRuleViolations skips rule 2.2 for a partial overlay dialog with no root resource type', () => {\n const { dialogRuleViolations } = require('./check/lib/dialogs.js');\n const overlay = '\u003cjcr:root jcr:primaryType=\"nt:unstructured\">\u003ccontent>\u003citems>\u003ctitle sling:hideResource=\"{Boolean}true\"/>\u003c/items>\u003c/content>\u003c/jcr:root>';\n assert.deepStrictEqual(dialogRuleViolations(overlay).blocking, []);\n const twoRoots = touchDialog('\u003cinner sling:resourceType=\"cq/gui/components/authoring/dialog\"/>');\n assert.ok(dialogRuleViolations(twoRoots).blocking.includes('2.2'), 'more than one dialog root still fails');\n});\n\ntest('legacy-ui full-package scan reports checklist rules on unchanged dialogs as warnings, not failures', async () => {\n const dialog = 'jcr_root/apps/acme/components/hero/_cq_dialog/.content.xml';\n const { dir, zip } = contentPackage({\n [dialog]: touchDialog('\u003cd sling:resourceType=\"granite/ui/components/coral/foundation/form/datepicker\" name=\"./d\"/>'),\n });\n try {\n const pat = PATTERNS['legacy-ui'];\n const outcome = await pat.verify({ artifactPath: zip, discovered: pat.discover(zip) });\n assert.strictEqual(outcome.result, 'pass', outcome.evidence);\n assert.strictEqual(outcome.checks.rule_warnings, 1);\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":82282,"content_sha256":"872f113cf1494853664df6d315c633b9fa4fdc9d0b53c209d52f199aa3f6c747"},{"filename":"scripts/validate-migration/check/lib/descriptor.js","content":"'use strict';\n\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { jarDsDescriptors } = require('./jar.js');\n\nfunction descriptorAttributes(text) {\n const attributes = {};\n for (const match of text.matchAll(/([\\w.:-]+)\\s*=\\s*([\"'])(.*?)\\2/g)) attributes[match[1]] = match[3];\n return attributes;\n}\n\nfunction descriptorProperty(xml, name) {\n const properties = /\u003c(?:[\\w.-]+:)?property\\b([^>]*?)(?:\\/>|>([\\s\\S]*?)\u003c\\/(?:[\\w.-]+:)?property\\s*>)/g;\n for (const match of xml.matchAll(properties)) {\n const attributes = descriptorAttributes(match[1]);\n if (attributes.name !== name) continue;\n const values = attributes.value === undefined ? (match[2] || '').split(/\\r?\\n/) : [attributes.value];\n return { present: true, array: attributes.value === undefined, type: attributes.type || 'String', values: values.map((value) => value.trim()).filter(Boolean) };\n }\n return { present: false, array: false, type: 'String', values: [] };\n}\n\nfunction descriptorProvides(xml, service) {\n return [...xml.matchAll(/\u003c(?:[\\w.-]+:)?provide\\b([^>]*)>/g)].some((match) => descriptorAttributes(match[1]).interface === service);\n}\n\n// Extract the job.topics value from a single DS component descriptor.\nfunction topicFromDescriptor(xml) {\n const property = descriptorProperty(xml, 'job.topics');\n return property.values.length ? property.values.join(', ') : null;\n}\n\nfunction componentContractFromDescriptor(xml, pattern) {\n const errors = [];\n const checks = {};\n const componentName = descriptorAttributes((xml.match(/\u003c(?:[\\w.-]+:)?component\\b([^>]*)>/) || [])[1] || '').name;\n const services = {\n scheduler: ['java.lang.Runnable'],\n 'event-migration': ['org.apache.sling.event.jobs.consumer.JobConsumer'],\n 'resource-change-listener': ['org.apache.sling.api.resource.observation.ResourceChangeListener'],\n }[pattern];\n if (!services.some((service) => descriptorProvides(xml, service))) errors.push(`component must provide ${services.join(' or ')}`);\n const required = {\n scheduler: ['scheduler.expression', 'scheduler.runOn'],\n 'event-migration': ['job.topics'],\n 'resource-change-listener': ['resource.paths', 'resource.change.types'],\n }[pattern];\n for (const name of required) {\n const property = descriptorProperty(xml, name);\n if (!property.values.length || property.type !== 'String') errors.push(`${name} must contain nonempty String values`);\n checks[name.replace(/\\./g, '_')] = property.values.join(', ');\n }\n if (pattern === 'scheduler') {\n const expression = descriptorProperty(xml, 'scheduler.expression');\n const runOn = descriptorProperty(xml, 'scheduler.runOn');\n const concurrent = descriptorProperty(xml, 'scheduler.concurrent');\n if (expression.array || expression.values.length !== 1) errors.push('scheduler.expression must have one scalar value');\n if (runOn.array || runOn.values.length !== 1 || !['SINGLE', 'LEADER'].includes(runOn.values[0])) errors.push('scheduler.runOn must be scalar SINGLE or LEADER');\n if (concurrent.present && (concurrent.array || concurrent.type !== 'Boolean' || concurrent.values.length !== 1 || !['true', 'false'].includes(concurrent.values[0]))) errors.push('scheduler.concurrent must be a scalar Boolean true or false');\n checks.scheduler_concurrent = concurrent.present ? concurrent.values.join(', ') : 'default';\n }\n if (pattern === 'resource-change-listener') {\n const changeTypes = descriptorProperty(xml, 'resource.change.types').values;\n if (changeTypes.some((value) => !['ADDED', 'CHANGED', 'REMOVED', 'PROVIDER_ADDED', 'PROVIDER_REMOVED'].includes(value))) errors.push('resource.change.types contains an unsupported change type');\n }\n return { errors, checks, componentName };\n}\n\nfunction sourceContractFailure(discovered) {\n if (!discovered.contract) {\n return { result: 'fail', failure_class: FAILURE_CLASSES.UNKNOWN, verification_level: 'source-only', evidence: 'built DS contract evidence unavailable; rerun --stage prepare with the same pattern and class scope' };\n }\n if (!discovered.contract.errors.length) return null;\n return { result: 'fail', failure_class: FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH, verification_level: 'source-only', evidence: discovered.contract.errors.join('; '), checks: discovered.contract.checks };\n}\n\n// Read the ResourceChangeListener contract flags from a DS component descriptor.\nfunction rclFlagsFromDescriptor(xml) {\n return {\n hasPaths: descriptorProperty(xml, 'resource.paths').values.length > 0,\n hasChangeTypes: descriptorProperty(xml, 'resource.change.types').values.length > 0,\n };\n}\n\n// Component name declared by a DS descriptor, or null.\nfunction componentName(xml) {\n const match = xml.match(/\u003cscr:component[^>]*name=\"([^\"]+)\"/) || xml.match(/name=\"([^\"]+)\"/);\n return match ? match[1] : null;\n}\n\n// Return the DS descriptor xml whose component name === fqcn, or '' if none.\nfunction dsDescriptorFor(jarPath, fqcn) {\n for (const [, xml] of jarDsDescriptors(jarPath)) {\n const implementation = descriptorAttributes((xml.match(/\u003c(?:[\\w.-]+:)?implementation\\b([^>]*)>/) || [])[1] || '').class;\n if (componentName(xml) === fqcn || implementation === fqcn) return xml;\n }\n return '';\n}\n\nmodule.exports = { descriptorAttributes, descriptorProperty, descriptorProvides, topicFromDescriptor, componentContractFromDescriptor, sourceContractFailure, rclFlagsFromDescriptor, componentName, dsDescriptorFor };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":5423,"content_sha256":"5e29140e85f70b846a65ecf8054b747113a0e2b36190a1420fff273b12d3e397"},{"filename":"scripts/validate-migration/check/lib/diagnosis.js","content":"'use strict';\n\nconst fs = require('fs');\nconst path = require('path');\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\n\n// ---- MCP-based diagnosis (AEM Quickstart MCP server, diagnose-osgi-bundle) ----\n// The skill (agent) invokes the `diagnose-osgi-bundle` MCP tool on the\n// agent-configured AEM Quickstart MCP server and writes a JSON map\n// { \"\u003cBundle-SymbolicName>\": \"\u003craw tool text output>\" }\n// to `.validate-migration/diagnosis-map.json` (or a path passed via\n// `--diagnosis-map \u003cfile>`). This script does not speak MCP itself — that is\n// the coding-assistant's job.\n//\n// When a BSN is not in the map, verification for that task fails with\n// `setup.mcp_unavailable` and the outcome carries setup guidance. There is no\n// silent Felix Web Console fallback.\nlet diagnosisMap; // undefined = not yet loaded, {} = loaded (may be empty)\n\nconst diagnosisCache = new Map(); // BSN -> parsed diagnosis, per run\n\nfunction loadDiagnosisMap(args) {\n if (diagnosisMap !== undefined) return diagnosisMap;\n const explicit = args && args['diagnosis-map'];\n const baseDir = (args && args.project) || process.cwd();\n const candidates = [\n explicit,\n path.join(baseDir, '.validate-migration', 'diagnosis-map.json'),\n ].filter(Boolean);\n for (const p of candidates) {\n if (!fs.existsSync(p)) continue;\n try {\n const parsed = JSON.parse(fs.readFileSync(p, 'utf8'));\n if (parsed && typeof parsed === 'object') { diagnosisMap = parsed; return diagnosisMap; }\n } catch { /* try next */ }\n }\n diagnosisMap = {};\n return diagnosisMap;\n}\n\n// Returns { bundle_state, found, components: Map\u003cfqcn, {state}>, source, available }.\n// `available: false` means the agent has not supplied MCP diagnosis for this\n// BSN — callers must fail with SETUP_MCP_UNAVAILABLE + setup guidance.\nasync function getBundleDiagnosis({ bundleBSN, args }) {\n if (diagnosisCache.has(bundleBSN)) return diagnosisCache.get(bundleBSN);\n const map = loadDiagnosisMap(args);\n const raw = map[bundleBSN];\n if (raw == null) {\n const missing = { available: false };\n diagnosisCache.set(bundleBSN, missing);\n return missing;\n }\n const text = typeof raw === 'string' ? raw : JSON.stringify(raw);\n const parsed = parseBundleDiagnosticReport(text);\n parsed.available = true;\n parsed.source = 'mcp';\n diagnosisCache.set(bundleBSN, parsed);\n return parsed;\n}\n\nfunction mcpUnavailableOutcome(bundleBSN) {\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.SETUP_MCP_UNAVAILABLE,\n bundle_state: 'Unknown',\n component_state: 'Unknown',\n evidence: `no MCP diagnose-osgi-bundle output for ${bundleBSN}. `\n + `Have the coding assistant call the AEM Quickstart MCP tool `\n + `\\`diagnose-osgi-bundle\\` for this bundle and write the raw text output `\n + `into .validate-migration/diagnosis-map.json as { \"${bundleBSN}\": \"...\" }, `\n + `then re-run \\`node \"$VALIDATOR/check.js\" --stage verify\\`.`,\n };\n}\n\n// The diagnostic tool returns free text, not JSON. Unrecognized shapes map to\n// 'Unknown' rather than a guessed 'Active', so a parse miss fails safe.\n// State is normalized to title case ('Active', 'Installed', 'Resolved') because\n// the OSGi runtime and Sling's diagnostic report both use upper case ('ACTIVE'),\n// while Felix's JSON and the rest of validate-migration use title case.\nfunction parseBundleDiagnosticReport(text) {\n const components = parseComponentsFromReport(text);\n // Bundle State: appears before the DS components section — scope the search\n // there so a component's own State: line isn't read as the bundle's, and so\n // \"reference … not found\" in an Active report isn't misread as not-installed.\n const dsIdx = text.indexOf('Declarative Services Components');\n const bundleText = dsIdx === -1 ? text : text.slice(0, dsIdx);\n const stateLine = bundleText.match(/State:\\s*([A-Za-z]+)/);\n if (stateLine) return { bundle_state: normalizeState(stateLine[1]), found: true, components };\n if (/no such bundle|not found|not installed/i.test(bundleText)) return { bundle_state: 'Unknown', found: false, components };\n if (/INSTALLED but not RESOLVED/i.test(bundleText)) return { bundle_state: 'Installed', found: true, components };\n if (/\\bACTIVE\\b/i.test(bundleText) && !/not RESOLVED|not ACTIVE|unsatisfied/i.test(bundleText)) return { bundle_state: 'Active', found: true, components };\n return { bundle_state: 'Unknown', found: true, components };\n}\n\n// Parses the `--- Declarative Services Components ---` section of the report\n// into a Map\u003cfqcn, {state}>. Only states are exposed today; component\n// properties are a documented MCP gap (tracked upstream).\nfunction parseComponentsFromReport(text) {\n const out = new Map();\n const start = text.indexOf('Declarative Services Components');\n if (start === -1) return out;\n const body = text.slice(start);\n const re = /Component:\\s*([^\\r\\n]+)\\r?\\n\\s*State:\\s*([A-Za-z]+)/g;\n let m;\n while ((m = re.exec(body)) !== null) {\n out.set(m[1].trim(), { state: normalizeState(m[2]) });\n }\n return out;\n}\n\nfunction normalizeState(s) {\n if (!s) return 'Unknown';\n const lc = String(s).toLowerCase();\n return lc.charAt(0).toUpperCase() + lc.slice(1);\n}\n\n// Remove the default diagnosis map so a fresh prepare can't leave stale data.\nfunction clearDiagnosisMap(baseDir) {\n fs.rmSync(path.join(baseDir, '.validate-migration', 'diagnosis-map.json'), { force: true });\n}\n\nmodule.exports = { loadDiagnosisMap, getBundleDiagnosis, mcpUnavailableOutcome, parseBundleDiagnosticReport, parseComponentsFromReport, normalizeState, clearDiagnosisMap };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":5596,"content_sha256":"ed78ff0d8002b297f825b1309077e02329bb5fbb3cc9bbd844e8eb88a38a3847"},{"filename":"scripts/validate-migration/check/lib/dialogs.js","content":"'use strict';\n\nconst { execFileSync } = require('child_process');\n\n// Classic + Touch UI dialogs, folder or single-file serialization.\nconst DIALOG_PATH = /(jcr_root\\/.*\\/(?:_cq_dialog|cq:dialog|_cq_design_dialog|design_dialog|dialog)(?:\\/\\.content\\.xml|\\.xml))$/;\n\n// Dialog files in a built package, scoped to the branch diff in auto mode.\nfunction discoverDialogs(artifactPath, task) {\n const listing = execFileSync('unzip', ['-l', artifactPath], { encoding: 'utf8' });\n const dialogs = listing.split('\\n').map((line) => (line.match(DIALOG_PATH) || [])[1]).filter(Boolean);\n const scoped = scopeToChangedFiles(dialogs, task);\n // changedOnly: dialogs come from this branch's diff, i.e. migration output.\n return scoped.length ? { dialogs: scoped, changedOnly: Boolean(task && Array.isArray(task.files) && task.files.length) } : null;\n}\n\n// Classify one dialog's XML. Coral 2 vs 3 differ by field type, not the shared\n// dialog root: coral 2 = granite/ui/components/foundation/, coral 3 inserts /coral/.\nfunction classifyDialog(xml) {\n if (/\\bxtype\\s*=\\s*\"/.test(xml)) return 'classic';\n if (/sling:resourceType\\s*=\\s*\"granite\\/ui\\/components\\/foundation\\//.test(xml)) return 'coral2';\n if (/sling:resourceType\\s*=\\s*\"granite\\/ui\\/components\\/coral\\/foundation/.test(xml)) return 'coral3';\n return 'other';\n}\n\n// Built-package subset of migration/references/legacy-ui/dialog/validation.md.\n// Rule ids match that checklist, which stays the source of truth; checks that\n// need the source tree (backup folders, sidecars, filter.xml) remain there.\nconst FORM_FIELD = /^granite\\/ui\\/components\\/coral\\/foundation\\/form/;\nfunction startTags(xml) {\n return [...xml.matchAll(/\u003c([A-Za-z_][\\w:.-]*)\\b([^>]*)>/g)].map(([, , body]) =>\n Object.fromEntries([...body.matchAll(/([\\w:.-]+)\\s*=\\s*\"([^\"]*)\"/g)].map(([, k, v]) => [k, v])));\n}\nfunction dialogRuleViolations(xml) {\n const blocking = [];\n const warnings = [];\n const tags = startTags(xml);\n if (/\\bxtype\\s*=\\s*\"/.test(xml)) blocking.push('1.2');\n if (/jcr:primaryType=\"cq:WidgetCollection\"/.test(xml)) blocking.push('1.3');\n if (/sling:resourceType=\"granite\\/ui\\/components\\/foundation\\//.test(xml)) blocking.push('2.1');\n // A partial overlay (no root resource type) inherits the dialog root via Sling Resource Merger.\n const roots = (xml.match(/sling:resourceType=\"cq\\/gui\\/components\\/authoring\\/dialog\"/g) || []).length;\n if (roots > 1 || (roots === 0 && tags[0] && tags[0]['sling:resourceType'])) blocking.push('2.2');\n if (/jcr:primaryType=\"cq:Widget\"/.test(xml)) blocking.push('2.3');\n if (/\u003clisteners\\b/.test(xml)) blocking.push('3.1');\n if (/datepicker/.test(xml) && !/valueFormat=/.test(xml)) blocking.push('5.1');\n if (tags.some((a) => FORM_FIELD.test(a['sling:resourceType'] || '') && a.name && !/^[./]/.test(a.name))) blocking.push('7.1');\n if (/rootPath=\"\\/libs\\//.test(xml)) blocking.push('8.0');\n if (/fileupload/.test(xml) && !/useHTML5=\"\\{Boolean\\}true\"/.test(xml)) warnings.push('6.1');\n if (/rootPath=\"[^\"]*\\/\\/[^\"]*\"/.test(xml)) warnings.push('8.1b');\n return { blocking, warnings };\n}\n\n// Touch UI dialogs carry the Coral 3 contract; Classic `dialog` nodes are reported by classifyDialog.\nfunction isTouchDialog(dialogPath) {\n return /\\/(?:_cq_dialog|cq:dialog|_cq_design_dialog)(?:\\/\\.content\\.xml|\\.xml)$/.test(dialogPath);\n}\n\n// Short `apps/...` label for evidence.\nfunction dialogLabel(dialogPath) {\n return dialogPath.replace(/^jcr_root\\//, '').replace(/\\/\\.content\\.xml$/, '');\n}\n\n// Auto mode passes task.files (the branch diff); scope discovered dialogs to\n// those. Explicit --pattern has no task.files → scan the whole package.\nfunction scopeToChangedFiles(dialogs, task) {\n if (!task || !Array.isArray(task.files) || !task.files.length) return dialogs;\n const changed = task.files.map((f) => f.replace(/\\\\/g, '/'));\n return dialogs.filter((d) => changed.some((f) => f.endsWith(d)));\n}\n\nmodule.exports = { discoverDialogs, classifyDialog, scopeToChangedFiles, dialogRuleViolations, isTouchDialog, dialogLabel };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4030,"content_sha256":"467b2d62b6a9fa9eddb1856f0941cc2613fe4782ddeaa7692c81402f4e0aad4c"},{"filename":"scripts/validate-migration/check/lib/jar.js","content":"'use strict';\n\nconst { execFileSync } = require('child_process');\n\nfunction jarDsDescriptors(jarPath) {\n // read all OSGI-INF/*.xml entries via `unzip -p`\n const listing = execFileSync('unzip', ['-l', jarPath], { encoding: 'utf8' });\n const files = [];\n for (const line of listing.split('\\n')) {\n const m = line.match(/(OSGI-INF\\/[^ ]+\\.xml)$/);\n if (m) files.push(m[1]);\n }\n return files.map(f => [f, execFileSync('unzip', ['-p', jarPath, f], { encoding: 'utf8' })]);\n}\n\n// Read META-INF/MANIFEST.MF from a jar, unfolding continuation lines.\nfunction readJarManifest(jarPath) {\n try {\n const mf = execFileSync('unzip', ['-p', jarPath, 'META-INF/MANIFEST.MF'], { encoding: 'utf8' });\n return mf.replace(/\\r?\\n /g, '');\n } catch { return ''; }\n}\n\n// True when a jar's compiled classes reference `fqcn`. OSGi Import-Package is\n// package-granular (it lists `com.day.cq.dam.api`, not the class), so class\n// usage is detected from the constant-pool internal name in the bytecode.\nfunction jarReferencesClass(jarPath, fqcn) {\n try {\n const buf = execFileSync('unzip', ['-p', jarPath, '*.class'], { maxBuffer: 128 * 1024 * 1024 });\n return referencesClassName(buf, fqcn);\n } catch { return false; }\n}\n\n// Names from `methodNames` present in the bytecode of a jar whose classes\n// reference `ownerFqcn`; method names live in the constant pool as UTF-8.\nfunction jarReferencedMethods(jarPath, ownerFqcn, methodNames) {\n try {\n const buf = execFileSync('unzip', ['-p', jarPath, '*.class'], { maxBuffer: 128 * 1024 * 1024 });\n return referencesClassName(buf, ownerFqcn) ? methodNames.filter((name) => buf.includes(name)) : [];\n } catch { return []; }\n}\n\nfunction referencesClassName(buf, fqcn) {\n const needle = fqcn.replace(/\\./g, '/');\n return Buffer.isBuffer(buf) ? buf.includes(needle) : String(buf).includes(needle);\n}\n\n// True when `pkg` (or a sub-package of it) appears in the manifest's\n// Import-Package header only — never bleeding into Export-Package or other\n// headers, and never matching a sibling package by prefix.\nfunction manifestImportsPackage(manifest, pkg) {\n const header = (manifest.match(/^Import-Package:[ \\t]*(.*)$/m) || [])[1] || '';\n const escaped = pkg.replace(/[.]/g, '\\\\.');\n return new RegExp(`(^|,)\\\\s*${escaped}(\\\\.[A-Za-z0-9_$.]+)?\\\\s*(;|,|$)`).test(header);\n}\n\nmodule.exports = { jarDsDescriptors, readJarManifest, jarReferencesClass, jarReferencedMethods, referencesClassName, manifestImportsPackage };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2474,"content_sha256":"fe0f53d540a4fda420dec8d0e7ad8b4f2980cbe037de49d6259a278da925ce86"},{"filename":"scripts/validate-migration/check/lib/png.js","content":"'use strict';\n\n// Minimal PNG reader for screenshot evidence: verifies the signature, the IHDR\n// chunk and its CRC, and the IEND trailer, then returns the image dimensions.\nconst SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);\nconst IEND = Buffer.from([0, 0, 0, 0, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82]);\n\nconst CRC_TABLE = Array.from({ length: 256 }, (_, n) => {\n let c = n;\n for (let k = 0; k \u003c 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;\n return c >>> 0;\n});\n\nfunction crc32(buf) {\n let c = 0xffffffff;\n for (const byte of buf) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);\n return (c ^ 0xffffffff) >>> 0;\n}\n\n/** @returns {{width: number, height: number}} @throws when the buffer is not a complete PNG */\nfunction pngSize(buf) {\n if (!Buffer.isBuffer(buf) || buf.length \u003c 45 || !buf.subarray(0, 8).equals(SIGNATURE)) {\n throw new Error('not a PNG file');\n }\n if (buf.readUInt32BE(8) !== 13 || buf.toString('ascii', 12, 16) !== 'IHDR'\n || crc32(buf.subarray(12, 29)) !== buf.readUInt32BE(29)) {\n throw new Error('invalid PNG header');\n }\n if (!buf.subarray(buf.length - 12).equals(IEND)) throw new Error('truncated PNG file');\n return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };\n}\n\nmodule.exports = { pngSize };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":1304,"content_sha256":"8f91c64ab933c65f586cd1a91277e6b0ee5b4f354ec0fa31d88c9bf2c5f3dcc5"},{"filename":"scripts/validate-migration/check/lib/runtime-gate.js","content":"'use strict';\n\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { mcpUnavailableOutcome } = require('./diagnosis.js');\n\nfunction fail(failure_class, bundle_state, component_state, evidence) {\n return { result: 'fail', failure_class, bundle_state, component_state, evidence };\n}\n\n/** Fail outcome when MCP has no report or the bundle is not Active; null otherwise. */\nfunction bundleGate(diagnosis, bundleBSN) {\n if (!diagnosis.available) return mcpUnavailableOutcome(bundleBSN);\n if (!diagnosis.found) return fail(FAILURE_CLASSES.BUNDLE_NOT_INSTALLED, diagnosis.bundle_state, 'Unknown', `bundle ${bundleBSN} not found on SDK`);\n if (diagnosis.bundle_state !== 'Active') return fail(FAILURE_CLASSES.BUNDLE_NOT_ACTIVE, diagnosis.bundle_state, 'Unknown', `bundle state=${diagnosis.bundle_state}`);\n return null;\n}\n\n/** On an Active bundle: `{ failure }` when the component is missing or inactive, else `{ state }`. */\nfunction componentGate(diagnosis, name, { allowSatisfied = false } = {}) {\n const component = diagnosis.components.get(name);\n if (!component) {\n return { failure: fail(FAILURE_CLASSES.COMPONENT_UNSATISFIED, 'Active', 'Unknown', `component ${name} not present in MCP diagnose-osgi-bundle report`) };\n }\n const state = component.state || 'Unknown';\n if (state === 'Active' || (allowSatisfied && state === 'Satisfied')) return { state };\n return { failure: fail(FAILURE_CLASSES.ACTIVATION_ERROR, 'Active', state, `component_state=${state}`) };\n}\n\nmodule.exports = { bundleGate, componentGate };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":1544,"content_sha256":"6ddc0ebc05b8d844d19d3def396eaa980e62e03182962965314b4146fb7c9aa6"},{"filename":"scripts/validate-migration/check/patterns/aliases.js","content":"'use strict';\n\n// Pattern names used by the migration runbook and the CAM BPA tools, mapped to\n// validator patterns. Validator names stay canonical: they are the values the\n// submit-migration-validation-result tool accepts.\nconst ALIASES = Object.freeze({\n assetApi: 'asset-manager',\n resourceChangeListener: 'resource-change-listener',\n eventListener: 'event-migration',\n eventHandler: 'event-migration',\n lui: 'legacy-ui',\n luiCoral2: 'legacy-ui',\n templateModernization: 'custom-templates',\n});\n\n/** Returns the validator pattern for a migration/BPA pattern name; other names pass through. */\nfunction canonicalPattern(name) {\n return Object.hasOwn(ALIASES, name) ? ALIASES[name] : name;\n}\n\nmodule.exports = { ALIASES, canonicalPattern };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":752,"content_sha256":"7ba066be943290c8949e19f56027da95980f2e84c702a4871c6bacc959c0eea1"},{"filename":"scripts/validate-migration/check/patterns/asset-manager.js","content":"'use strict';\n\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { jarDsDescriptors, jarReferencedMethods } = require('../lib/jar.js');\nconst { componentName } = require('../lib/descriptor.js');\nconst { getBundleDiagnosis } = require('../lib/diagnosis.js');\nconst { bundleGate, componentGate } = require('../lib/runtime-gate.js');\n\n// AssetManager stays supported on Cloud Service; only its binary-path methods\n// were removed (see code-assessment/asset-manager/SKILL.md).\nconst ASSET_MANAGER = 'com.day.cq.dam.api.AssetManager';\nconst REMOVED_METHODS = ['createAssetForBinary', 'getAssetForBinary', 'removeAssetForBinary'];\n\nmodule.exports = {\n describe: 'AssetManager binary-path methods → Cloud Service asset APIs',\n discover: (jar, args) => {\n // Removed AssetManager calls are read from the compiled classes; the\n // component under test is the DS service that uses ResourceResolverFactory.\n const removedAssetManagerCalls = jarReferencedMethods(jar, ASSET_MANAGER, REMOVED_METHODS);\n if (args && args.fqcn) return { fqcn: args.fqcn, removedAssetManagerCalls };\n for (const [, xml] of jarDsDescriptors(jar)) {\n if (!/reference\\s+[^>]*interface=\"org\\.apache\\.sling\\.api\\.resource\\.ResourceResolverFactory\"/.test(xml)) continue;\n const fqcn = componentName(xml);\n if (fqcn) return { fqcn, removedAssetManagerCalls };\n }\n return { fqcn: null, removedAssetManagerCalls };\n },\n async verify({ bundleBSN, discovered, args }) {\n const diagnosis = await getBundleDiagnosis({ bundleBSN, args });\n const bundleFailure = bundleGate(diagnosis, bundleBSN);\n if (bundleFailure) return bundleFailure;\n\n // Class references are read offline from the built artifact — not from\n // the SDK — so this stays MCP-independent.\n const removed = discovered.removedAssetManagerCalls || [];\n if (removed.length) {\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.CONTRACT_MISMATCH,\n bundle_state: 'Active', component_state: 'Active',\n evidence: `bundle still calls AssetManager methods removed on Cloud Service: ${removed.join(', ')}`,\n };\n }\n\n // Satisfied is fine for a service-only component that nothing has activated yet.\n const component = discovered.fqcn ? componentGate(diagnosis, discovered.fqcn, { allowSatisfied: true }) : { state: 'Active' };\n if (component.failure) return component.failure;\n\n return { result: 'pass', bundle_state: 'Active', component_state: component.state, checks: { removed_asset_manager_calls: 0 } };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2574,"content_sha256":"47c6b02c07f881b23e4b4d1df4f651981769959216d64d4d427918b2ce006837"},{"filename":"scripts/validate-migration/check/patterns/cdw.js","content":"'use strict';\n\nconst { execFileSync } = require('child_process');\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { discoverDialogs, dialogLabel } = require('../lib/dialogs.js');\n\nconst SCAFFOLD = /^jcr_root\\/apps\\/[^/]+\\/components\\/granite\\/form\\/[^/]+\\/\\.content\\.xml$/;\nconst FIELD_SUPERTYPE = /sling:resourceSuperType=\"granite\\/ui\\/components\\/coral\\/foundation\\/form\\/field\"/;\n\n// Custom Classic Widget removal. Overlaps legacy-ui at the file level (both\n// live in dialog XMLs), but asserts the CDW-specific contract: no\n// `cq:Widget` nodes and no custom `xtype=` remain. Invoke explicitly\n// (`check.js cdw`) — at branch-diff time it is path-indistinguishable from\n// legacy-ui, so plan.js auto-classifies shared dialog changes as legacy-ui\n// (which already flags remaining xtypes).\nmodule.exports = {\n describe: 'Custom Classic Widgets (ExtJS xtypes) → Coral 3 (offline source check)',\n mode: 'source-only',\n discover: discoverDialogs,\n async verify({ artifactPath, discovered }) {\n const widgetDialogs = [];\n const xtypeDialogs = [];\n for (const p of discovered.dialogs) {\n const xml = execFileSync('unzip', ['-p', artifactPath, p], { encoding: 'utf8' });\n if (/jcr:primaryType\\s*=\\s*\"cq:Widget\"/.test(xml)) widgetDialogs.push(p);\n if (/\\bxtype\\s*=\\s*\"/.test(xml)) xtypeDialogs.push(p);\n }\n // migration/references/legacy-ui/cdw/validation.md 2.2: scaffolded Granite\n // form components must extend the Coral 3 form field.\n const badScaffolds = execFileSync('unzip', ['-Z1', artifactPath], { encoding: 'utf8' }).split('\\n')\n .filter((entry) => SCAFFOLD.test(entry))\n .filter((entry) => !FIELD_SUPERTYPE.test(execFileSync('unzip', ['-p', artifactPath, entry], { encoding: 'utf8' })))\n .map(dialogLabel);\n const remaining = new Set([...widgetDialogs, ...xtypeDialogs]).size;\n if (remaining > 0 || badScaffolds.length > 0) {\n const evidence = [];\n if (remaining > 0) evidence.push(`${widgetDialogs.length} dialog(s) still declare cq:Widget nodes, ${xtypeDialogs.length} still carry xtype= — custom widgets not fully migrated to Coral 3`);\n if (badScaffolds.length) evidence.push(`cdw validation.md 2.2 failed — scaffolded component(s) without the Coral form field supertype: ${badScaffolds.join(', ')}`);\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH,\n bundle_state: null, component_state: null,\n evidence: evidence.join('; '),\n checks: { dialogs_total: discovered.dialogs.length, cq_widget_dialogs: widgetDialogs.length, xtype_dialogs: xtypeDialogs.length,\n scaffold_supertype_failures: badScaffolds.length },\n };\n }\n return {\n result: 'pass',\n bundle_state: null, component_state: null,\n checks: { dialogs_total: discovered.dialogs.length, cq_widget_dialogs: 0, xtype_dialogs: 0 },\n };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2928,"content_sha256":"01bbc958fe18bd990888078a1326010696b5c1ff9d3c75465b1ad85fadc975a2"},{"filename":"scripts/validate-migration/check/patterns/custom-templates/discover.js","content":"'use strict';\n\nconst { execFileSync } = require('child_process');\n\nconst TEMPLATE_PREFIX = /^\\/conf\\/[a-zA-Z0-9_-]+\\/settings\\/wcm\\/templates\\/[a-zA-Z0-9_-]+$/;\n\n// Lists editable template roots in a built content package, optionally narrowed to one path.\nfunction discoverTemplates(artifactPath, selected) {\n const entries = execFileSync('unzip', ['-Z1', artifactPath], { encoding: 'utf8' });\n const templates = [...new Set([...entries.matchAll(/(?:^|\\n)(?:jcr_root\\/)?(conf\\/[^/\\s]+\\/settings\\/wcm\\/templates\\/[^/\\s]+)\\/\\.content\\.xml(?=\\n|$)/g)]\n .map((m) => '/' + m[1]))].sort();\n if (selected && !TEMPLATE_PREFIX.test(selected)) throw new Error('Invalid --template: expected /conf/\u003csite>/settings/wcm/templates/\u003cname>');\n return selected ? templates.filter((p) => p === selected) : templates;\n}\n\nmodule.exports = { discoverTemplates };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":848,"content_sha256":"965f59da244010b3ac97c04ea37047228d7ed793572d8656691e72630b67c3e9"},{"filename":"scripts/validate-migration/check/patterns/custom-templates/evidence.js","content":"'use strict';\n\nconst fs = require('fs');\nconst path = require('path');\nconst { createHash } = require('crypto');\nconst { FAILURE_CLASSES } = require('../../../failure-classes.js');\nconst { pngSize } = require('../../lib/png.js');\n\n/** Verify tool-captured template comparison evidence without contacting AEM. */\nfunction verifyTemplateEvidence({ artifactPath, template, evidencePath, runStartedAt }) {\n const unavailable = (reason) => ({\n result: 'fail', verification_level: 'source-only', failure_class: FAILURE_CLASSES.UNKNOWN,\n evidence: `${template}: ${reason}`, checks: { runtime_evidence: false },\n });\n if (!evidencePath) return unavailable('template runtime comparison requires captured browser evidence');\n let evidence;\n try {\n const document = JSON.parse(fs.readFileSync(evidencePath, 'utf8'));\n evidence = document.templates ? document.templates[template] : document;\n } catch {\n return unavailable('cannot read template evidence JSON; provide the actual credential-free browser observations');\n }\n if (!evidence || evidence.template !== template) return unavailable('runtime evidence does not select this template');\n const digest = createHash('sha256').update(fs.readFileSync(artifactPath)).digest('hex');\n const captured = Date.parse(evidence.captured_at);\n const runStart = runStartedAt ? Date.parse(runStartedAt) : 0;\n if (evidence.artifact_sha256 !== digest || !Number.isFinite(captured)\n || !Number.isFinite(runStart) || captured \u003c runStart\n || captured \u003c fs.statSync(artifactPath).mtimeMs - 1000 || captured > Date.now() + 5000) {\n return unavailable('runtime evidence is stale or belongs to a different built package; repeat deployment and comparison');\n }\n const { source, target, captures, cleanup } = evidence;\n let sourceUrl;\n let targetUrl;\n try {\n sourceUrl = pageUrl(source?.url);\n targetUrl = pageUrl(target?.url);\n } catch {\n return unavailable('provide credential-free source and target /content page URLs');\n }\n const identity = (url) => `${['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ? 'loopback' : url.hostname}:${url.port || (url.protocol === 'https:' ? '443' : '80')}`;\n if (identity(sourceUrl) === identity(targetUrl)) return unavailable('source and target must be different AEM instances');\n if (sourceUrl.pathname !== targetUrl.pathname) return unavailable('compare the same page path: the target page must be the converted copy of the source page');\n if (typeof source?.template !== 'string' || !source.template.startsWith('/apps/') || target?.template !== template) {\n return unavailable('the observed source must use a static /apps template and the target must use the selected /conf template');\n }\n if (evidence.deployment?.status !== 'succeeded' || evidence.deployment.target_origin !== targetUrl.origin) {\n return unavailable('successful package deployment to the selected target has not been confirmed');\n }\n const bundles = evidence.deployment.bundles;\n if (!Array.isArray(bundles) || !bundles.length\n || !bundles.every((bundle) => typeof bundle?.symbolic_name === 'string' && bundle.symbolic_name && typeof bundle.state === 'string')) {\n return unavailable('MCP bundle state on the target is required before page comparison');\n }\n const inactive = bundles.filter((bundle) => bundle.state !== 'Active');\n if (inactive.length) {\n return {\n result: 'fail', verification_level: 'runtime', failure_class: FAILURE_CLASSES.BUNDLE_NOT_ACTIVE,\n evidence: `${template}: bundle not active on target: ${inactive.map((bundle) => `${bundle.symbolic_name}=${bundle.state}`).join(', ')}`,\n checks: { runtime_evidence: true, bundles_active: false },\n };\n }\n const stringArray = (value) => Array.isArray(value) && value.every((entry) => typeof entry === 'string' && entry.trim().length > 0);\n if (![source, target].every((page) => ['components', 'texts', 'images'].every((key) => stringArray(page[key])))\n || !source.components.length || !target.components.length) {\n return unavailable('both pages require captured component, text and image inventories; empty component inventories are not coverage');\n }\n const mapping = evidence.component_mapping || {};\n if (typeof mapping !== 'object' || Array.isArray(mapping) || !Object.values(mapping).every((value) => typeof value === 'string' && value.length)) {\n return unavailable('component_mapping must contain explicit source-to-target resource-type mappings');\n }\n if (!Array.isArray(captures) || !captures.some((capture) => capture?.viewport?.width >= 1024)\n || !captures.some((capture) => capture?.viewport?.width \u003c= 480 && capture?.viewport?.width > 0)) {\n return unavailable('actual desktop and mobile browser comparisons are both required');\n }\n const screenshotRoot = path.dirname(path.resolve(evidencePath));\n const screenshots = new Set();\n const verifiedCaptures = [];\n try {\n for (const capture of captures) {\n if (!Number.isInteger(capture?.viewport?.width) || !Number.isInteger(capture?.viewport?.height)\n || capture.viewport.width \u003c= 0 || capture.viewport.height \u003c= 0\n || !['pass', 'fail', 'not_verified'].includes(capture.result)\n || !Array.isArray(capture.differences) || !capture.differences.every((difference) => typeof difference === 'string')) {\n return unavailable('each browser comparison needs viewport dimensions, an actual review result and a differences array');\n }\n const verified = { ...capture };\n for (const key of ['source', 'target']) {\n const relative = capture[key];\n if (typeof relative !== 'string' || path.isAbsolute(relative)) return unavailable('screenshot paths must be relative to the evidence file');\n const screenshot = fs.realpathSync(path.resolve(screenshotRoot, relative));\n if (!screenshot.startsWith(fs.realpathSync(screenshotRoot) + path.sep) || screenshots.has(screenshot)) {\n return unavailable('each viewport and instance needs its own screenshot inside the evidence directory');\n }\n const image = pngSize(fs.readFileSync(screenshot));\n const scale = capture.device_scale_factor ?? 1;\n if (typeof scale !== 'number' || !Number.isFinite(scale) || scale \u003c= 0 || scale > 4\n || image.width !== capture.viewport.width * scale || image.height \u003c capture.viewport.height * scale\n || fs.statSync(screenshot).mtimeMs + 1000 \u003c runStart) {\n return unavailable('screenshot dimensions or freshness do not match this run and viewport');\n }\n screenshots.add(screenshot);\n verified[key] = screenshot;\n }\n verifiedCaptures.push(verified);\n }\n } catch {\n return unavailable('a screenshot is missing, invalid or unreadable');\n }\n const differences = [];\n const missing = (kind, expected, actual) => {\n const counts = new Map();\n for (const value of actual) counts.set(value, (counts.get(value) || 0) + 1);\n for (const value of expected) {\n const remaining = counts.get(value) || 0;\n if (remaining) counts.set(value, remaining - 1);\n else differences.push(`${kind}: missing on target: ${value}`);\n }\n return !differences.some((difference) => difference.startsWith(`${kind}:`));\n };\n const normalizeText = (text) => text.replace(/\\s+/g, ' ').trim();\n const checks = {\n runtime_evidence: true,\n bundles_active: true,\n source_renders: source.status === 200,\n target_renders: target.status === 200,\n component_parity: missing('component', source.components.map((component) => Object.hasOwn(mapping, component) ? mapping[component] : component), target.components),\n text_preserved: missing('text', source.texts.map(normalizeText), target.texts.map(normalizeText)),\n images_preserved: missing('image', source.images, target.images),\n visual_comparison: captures.every((capture) => capture.result === 'pass' && !capture.differences.length),\n cleanup: Array.isArray(cleanup?.created_pages)\n && (cleanup.created_pages.length ? cleanup.status === 'completed' : cleanup.status === 'not_required'),\n business_behavior: 'not_tested',\n };\n for (const capture of captures) {\n for (const difference of capture.differences) differences.push(`visual ${capture.viewport.width}x${capture.viewport.height}: ${difference}`);\n }\n const failed = Object.entries(checks).filter(([key, value]) => key !== 'business_behavior' && value !== true).map(([key]) => key);\n checks.template_comparison = {\n source_url: source.url, target_url: target.url, captured_at: evidence.captured_at,\n artifact_sha256: digest, component_mapping: mapping, captures: verifiedCaptures, differences,\n cleanup: { status: cleanup?.status || 'not_verified', created_pages: Array.isArray(cleanup?.created_pages) ? cleanup.created_pages : [] },\n coverage: 'Source-to-target component counts, rendered text, images and reviewed desktop/mobile screenshots; authoring and business behavior not tested.',\n };\n return {\n result: failed.length ? 'fail' : 'pass', verification_level: 'runtime', checks,\n ...(failed.length ? {\n failure_class: FAILURE_CLASSES.CONTRACT_MISMATCH,\n evidence: `${template}: failed ${failed.join(', ')}${differences.length ? '; ' + differences.join('; ') : ''}`,\n } : {}),\n };\n}\n\nfunction pageUrl(value) {\n const url = new URL(value);\n if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.hash\n || !/^\\/content\\/.+\\.html$/.test(url.pathname)\n || [...url.searchParams].some(([key, entry]) => key !== 'wcmmode' || entry !== 'disabled')) throw new Error('Invalid page URL');\n return url;\n}\n\nmodule.exports = { verifyTemplateEvidence };","content_type":"text/javascript; charset=utf-8","language":"javascript","size":9711,"content_sha256":"5d4b1afcca1bed1211b753800ad5319800c706802d82a55ac3a25345f7a3cb68"},{"filename":"scripts/validate-migration/check/patterns/custom-templates/index.js","content":"'use strict';\n\nconst { execFileSync } = require('child_process');\nconst { FAILURE_CLASSES } = require('../../../failure-classes.js');\nconst { verifyTemplateEvidence } = require('./evidence.js');\nconst { verifyComponentMapping } = require('./mapping.js');\nconst { discoverTemplates } = require('./discover.js');\n\nmodule.exports = {\n describe: 'Editable templates (offline source check of the built content package)',\n mode: 'source-only',\n discover: (pkg, task) => {\n const templates = discoverTemplates(pkg, task.template);\n const changed = task.files?.map((f) => f.match(/(?:^|\\/)conf\\/[^/]+\\/settings\\/wcm\\/templates\\/[^/]+/))\n .filter(Boolean).map((m) => '/' + m[0].replace(/^\\//, ''));\n const scoped = changed && !task.template ? templates.filter((t) => changed.includes(t)) : templates;\n return scoped.length ? { templates: scoped } : null;\n },\n async verify({ artifactPath, discovered, requireRuntime = false, runtimeEvidencePath, runStartedAt, projectDir, staticTemplate, baselineRef }) {\n const entries = execFileSync('unzip', ['-Z1', artifactPath], { encoding: 'utf8' }).split('\\n');\n const find = (p) => entries.find((e) => e.replace(/^jcr_root\\//, '') === p);\n const node = (t, child) => find(`${t.slice(1)}/${child}/.content.xml`) || find(`${t.slice(1)}/${child}.xml`);\n const t = discovered.template;\n const definition = find(`${t.slice(1)}/.content.xml`);\n const isTemplate = !!definition\n && /jcr:primaryType\\s*=\\s*\"cq:Template\"/.test(execFileSync('unzip', ['-p', artifactPath, definition], { encoding: 'utf8' }));\n const checks = { template_definition: isTemplate, structure: !!node(t, 'structure'), initial: !!node(t, 'initial'), policies: !!node(t, 'policies') };\n const missing = ['template_definition', 'structure', 'initial'].filter((k) => !checks[k]);\n if (missing.length) {\n return { result: 'fail', failure_class: FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH, bundle_state: null, component_state: null,\n evidence: `${t}: missing ${missing.join(', ')} in built content package`, checks };\n }\n const mapping = verifyComponentMapping({ artifactPath, template: t, projectDir, staticTemplate, baselineRef });\n checks.component_mapping = mapping.verified ? mapping.ok : 'not_verified';\n if (mapping.verified && !mapping.ok) {\n return { result: 'fail', failure_class: FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH, bundle_state: null, component_state: null,\n evidence: `${t}: static template ${mapping.staticPath} components missing from editable template: ${mapping.missing.join(', ')}`, checks };\n }\n if (!mapping.verified && requireRuntime) {\n return { result: 'fail', failure_class: FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH, bundle_state: null, component_state: null,\n evidence: `${t}: ${mapping.reason}; pass --static-template and --baseline-ref`, checks };\n }\n if (requireRuntime || runtimeEvidencePath) {\n const comparison = verifyTemplateEvidence({ artifactPath, template: t, evidencePath: runtimeEvidencePath, runStartedAt });\n return { ...comparison, checks: { ...checks, ...comparison.checks } };\n }\n return { result: 'pass', bundle_state: null, component_state: null, checks,\n ...(!mapping.verified ? { restricted: true, restricted_reason: `component mapping not verified: ${mapping.reason}` } : {}) };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":3366,"content_sha256":"3be189dc7487d747b1b12bbaf89e982ece9f4132ab0014282f671866f7ec7fa6"},{"filename":"scripts/validate-migration/check/patterns/custom-templates/mapping.js","content":"'use strict';\n\nconst { execFileSync } = require('child_process');\n\nconst LAYOUT_KINDS = new Set(['parsys', 'responsivegrid', 'container']);\n\nfunction componentKind(resourceType) {\n const segments = resourceType.split('/').filter((segment) => !/^v\\d+$/.test(segment));\n return segments[segments.length - 1];\n}\n\n// Elements carrying sling:resourceType, in document order.\nfunction resourceNodes(xml) {\n return [...xml.matchAll(/\u003c([\\w:.-]+)\\s([^>]*?)\\/?>/g)].flatMap(([, name, attrs]) => {\n const type = attrs.match(/sling:resourceType=\"([^\"]+)\"/);\n return type ? [{ name, resourceType: type[1], editable: /editable=\"\\{Boolean\\}true\"/.test(attrs) }] : [];\n });\n}\n\nfunction components(nodes) {\n return nodes.filter((node) => node.name !== 'jcr:content' && !LAYOUT_KINDS.has(componentKind(node.resourceType)));\n}\n\n/**\n * Compare the pre-migration static template (read from Git, never the working tree)\n * with the packaged editable template's structure and initial content.\n */\nfunction verifyComponentMapping({ artifactPath, template, projectDir, staticTemplate, baselineRef = 'HEAD' }) {\n const staticPath = staticTemplate || template.replace(/^\\/conf\\/([^/]+)\\/settings\\/wcm\\/templates\\//, '/apps/$1/templates/');\n if (!/^\\/apps\\/[\\w.-]+(\\/[\\w.-]+)*$/.test(staticPath)) return { verified: false, reason: `invalid --static-template ${JSON.stringify(staticPath)}` };\n let staticXml;\n if (typeof baselineRef !== 'string' || !baselineRef || baselineRef.startsWith('-')) {\n return { verified: false, reason: `invalid --baseline-ref ${JSON.stringify(baselineRef)}` };\n }\n try {\n const git = (...args) => execFileSync('git', args, { cwd: projectDir || process.cwd(), encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });\n const commit = git('rev-parse', '--verify', `${baselineRef}^{commit}`).trim();\n const suffix = `/jcr_root${staticPath}/.content.xml`;\n const file = git('ls-tree', '-r', '--full-tree', '--name-only', commit).split('\\n').find((name) => name.endsWith(suffix));\n if (!file) return { verified: false, reason: `static template ${staticPath} not found at ${baselineRef}` };\n staticXml = git('show', `${commit}:${file}`);\n } catch {\n return { verified: false, reason: `cannot read static template ${staticPath} from Git ref ${baselineRef}` };\n }\n const read = (child) => {\n try {\n return execFileSync('unzip', ['-p', artifactPath, `jcr_root${template}/${child}/.content.xml`], { encoding: 'utf8' });\n } catch {\n return '';\n }\n };\n const staticNodes = resourceNodes(staticXml);\n const structureNodes = resourceNodes(read('structure'));\n const editableTypes = components([...structureNodes, ...resourceNodes(read('initial'))]).map((node) => componentKind(node.resourceType));\n const missing = [];\n for (const node of components(staticNodes)) {\n const index = editableTypes.indexOf(componentKind(node.resourceType));\n if (index >= 0) editableTypes.splice(index, 1);\n else missing.push(`${node.name} (${node.resourceType})`);\n }\n const staticHasParsys = staticNodes.some((node) => ['parsys', 'responsivegrid'].includes(componentKind(node.resourceType)));\n if (staticHasParsys && !structureNodes.some((node) => node.editable)) missing.push('editable container for the static parsys');\n return { verified: true, ok: !missing.length, staticPath, missing };\n}\n\nmodule.exports = { verifyComponentMapping };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":3390,"content_sha256":"a9ba6af2c1fc9c8ce639025a589d1a946c9125d4e0f86f20a1dd47637a78766b"},{"filename":"scripts/validate-migration/check/patterns/event-migration.js","content":"'use strict';\n\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { jarDsDescriptors } = require('../lib/jar.js');\nconst { descriptorProperty, topicFromDescriptor, componentContractFromDescriptor, sourceContractFailure, componentName, dsDescriptorFor } = require('../lib/descriptor.js');\nconst { getBundleDiagnosis } = require('../lib/diagnosis.js');\nconst { bundleGate, componentGate } = require('../lib/runtime-gate.js');\n\nmodule.exports = {\n describe: 'OSGi EventHandler → Sling JobConsumer',\n discover: (jar, args) => {\n // Migrated code declares a JobConsumer with job.topics property.\n // When the caller pins a class, read that component's descriptor so the\n // topic check still runs instead of being silently skipped.\n if (args && args.fqcn) {\n const xml = dsDescriptorFor(jar, args.fqcn);\n return { fqcn: args.fqcn, topic: topicFromDescriptor(xml), contract: componentContractFromDescriptor(xml, 'event-migration') };\n }\n for (const [, xml] of jarDsDescriptors(jar)) {\n if (!descriptorProperty(xml, 'job.topics').present) continue;\n const fqcn = componentName(xml);\n if (fqcn) return { fqcn, topic: topicFromDescriptor(xml), contract: componentContractFromDescriptor(xml, 'event-migration') };\n }\n return null;\n },\n async verify({ bundleBSN, discovered, args }) {\n const contractFailure = sourceContractFailure(discovered);\n if (contractFailure) return contractFailure;\n const diagnosis = await getBundleDiagnosis({ bundleBSN, args });\n const bundleFailure = bundleGate(diagnosis, bundleBSN);\n if (bundleFailure) return bundleFailure;\n const component = componentGate(diagnosis, discovered.contract?.componentName || discovered.fqcn);\n if (component.failure) return component.failure;\n\n // job.topics is read at discovery time from the built DS descriptor\n // (offline), so no Felix property lookup is needed.\n if (!discovered.topic) {\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.CONTRACT_MISMATCH,\n bundle_state: 'Active', component_state: 'Active',\n evidence: 'JobConsumer contract missing job.topics property in DS descriptor',\n };\n }\n return {\n result: 'pass', bundle_state: 'Active', component_state: 'Active',\n checks: { ...discovered.contract?.checks, topic: discovered.topic },\n restricted: true,\n restricted_reason: 'effective JobConsumer properties not exposed by diagnose-osgi-bundle; job processing and business effects not tested',\n };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2554,"content_sha256":"de4f1c9a2f8925ccea4ef571cc2bb3a3c6e4ef7d80ccc6a94619ca3c1fe1f73a"},{"filename":"scripts/validate-migration/check/patterns/index.js","content":"'use strict';\n\n// Each pattern defines how to find its migrated artifact (`discover`) and how to\n// check its contract (`verify`). Key order is the order patterns are listed.\nmodule.exports = {\n 'custom-templates': require('./custom-templates'),\n scheduler: require('./scheduler.js'),\n 'asset-manager': require('./asset-manager.js'),\n 'event-migration': require('./event-migration.js'),\n 'resource-change-listener': require('./resource-change-listener.js'),\n replication: require('./replication.js'),\n 'legacy-ui': require('./legacy-ui.js'),\n cdw: require('./cdw.js'),\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":580,"content_sha256":"38e1db644e08a8accf447a003a8c4a9985acae420831d5921ec292056929ec4c"},{"filename":"scripts/validate-migration/check/patterns/legacy-ui.js","content":"'use strict';\n\nconst { execFileSync } = require('child_process');\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { classifyDialog, discoverDialogs, dialogRuleViolations, isTouchDialog, dialogLabel } = require('../lib/dialogs.js');\n\nmodule.exports = {\n describe: 'Classic UI / Coral 2 dialogs → Coral 3 (offline source check)',\n mode: 'source-only',\n // Auto mode verifies only dialogs the branch changed, so a partial migration or a\n // kept Classic backup elsewhere doesn't fail the run; explicit --pattern scans all.\n discover: discoverDialogs,\n async verify({ artifactPath, discovered }) {\n const classic = [];\n const coral2 = [];\n const coral3 = [];\n const ruleFailures = [];\n let ruleWarnings = 0;\n for (const p of discovered.dialogs) {\n const xml = execFileSync('unzip', ['-p', artifactPath, p], { encoding: 'utf8' });\n const kind = classifyDialog(xml);\n if (kind === 'classic') classic.push(p);\n else if (kind === 'coral2') coral2.push(p);\n else if (kind === 'coral3') coral3.push(p);\n // The checklist gates dialogs the migration produced (branch diff); in a\n // full-package scan, hits on other dialogs are surfaced as warnings only.\n if (kind !== 'classic' && isTouchDialog(p)) {\n const { blocking, warnings } = dialogRuleViolations(xml);\n if (blocking.length && discovered.changedOnly) ruleFailures.push(`${dialogLabel(p)}: ${blocking.join(', ')}`);\n ruleWarnings += warnings.length + (discovered.changedOnly ? 0 : blocking.length);\n }\n }\n const bad = classic.length + coral2.length + ruleFailures.length;\n if (bad > 0) {\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH,\n bundle_state: null, component_state: null,\n evidence: `${classic.length} Classic UI + ${coral2.length} Coral 2 dialogs remain (Coral 3 ok: ${coral3.length})`\n + (ruleFailures.length ? `; migration dialog validation.md rules failed — ${ruleFailures.join('; ')}` : ''),\n checks: { dialogs_total: discovered.dialogs.length, classic: classic.length, coral2: coral2.length, coral3: coral3.length,\n rule_failures: ruleFailures.length, rule_warnings: ruleWarnings },\n };\n }\n return {\n result: 'pass',\n bundle_state: null, component_state: null,\n checks: { dialogs_total: discovered.dialogs.length, coral3: coral3.length, rule_warnings: ruleWarnings },\n };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2490,"content_sha256":"4d62be0dfe6e77f904d83167ddfd548fc2f949bedcad1b3aed27f272848581c6"},{"filename":"scripts/validate-migration/check/patterns/replication.js","content":"'use strict';\n\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { readJarManifest, manifestImportsPackage } = require('../lib/jar.js');\nconst { getBundleDiagnosis } = require('../lib/diagnosis.js');\nconst { bundleGate } = require('../lib/runtime-gate.js');\n\nmodule.exports = {\n describe: 'CQ Replicator / Sling Replicator → Sling Distribution API',\n discover: (jar, args) => {\n // Discovery on manifest: bundle imports org.apache.sling.distribution\n // (migrated) and NOT com.day.cq.replication (legacy). Class detection\n // is optional; we verify at the bundle level.\n const manifest = readJarManifest(jar);\n const importsDistribution = manifestImportsPackage(manifest, 'org.apache.sling.distribution');\n const importsLegacy = manifestImportsPackage(manifest, 'com.day.cq.replication');\n return { importsDistribution, importsLegacy, fqcn: (args && args.fqcn) || null };\n },\n async verify({ bundleBSN, discovered, args }) {\n // Bundle state via MCP. Manifest headers are inspected offline from the\n // built artifact (see discover); the Distributor service list is not\n // exposed by diagnose-osgi-bundle today — tracked as a restricted check.\n const diagnosis = await getBundleDiagnosis({ bundleBSN, args });\n const bundleFailure = bundleGate(diagnosis, bundleBSN);\n if (bundleFailure) return bundleFailure;\n\n if (discovered.importsLegacy) {\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.CONTRACT_MISMATCH,\n bundle_state: 'Active', component_state: 'Active',\n evidence: 'bundle still imports com.day.cq.replication — migration incomplete',\n };\n }\n if (!discovered.importsDistribution) {\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.CONTRACT_MISMATCH,\n bundle_state: 'Active', component_state: 'Active',\n evidence: 'bundle does not import org.apache.sling.distribution — migration incomplete',\n };\n }\n\n return {\n result: 'pass',\n bundle_state: 'Active', component_state: 'Active',\n restricted: true,\n restricted_reason: 'Distributor service registration not exposed by diagnose-osgi-bundle',\n checks: { imports_distribution: true, imports_legacy: false },\n };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2287,"content_sha256":"6a14ea123902b041ee307c694e61fac1101228be68bd7623b4e18f784dafd32c"},{"filename":"scripts/validate-migration/check/patterns/resource-change-listener.js","content":"'use strict';\n\nconst { FAILURE_CLASSES } = require('../../failure-classes.js');\nconst { jarDsDescriptors } = require('../lib/jar.js');\nconst { componentContractFromDescriptor, sourceContractFailure, rclFlagsFromDescriptor, componentName, dsDescriptorFor } = require('../lib/descriptor.js');\nconst { getBundleDiagnosis } = require('../lib/diagnosis.js');\nconst { bundleGate, componentGate } = require('../lib/runtime-gate.js');\n\nmodule.exports = {\n describe: 'JCR EventListener / resource EventHandler → Sling ResourceChangeListener',\n discover: (jar, args) => {\n // Migrated code registers a ResourceChangeListener DS\n // service with resource.paths + resource.change.types properties.\n // When the caller pins a class, read that component's descriptor so the\n // contract check still runs instead of being silently skipped.\n if (args && args.fqcn) {\n const xml = dsDescriptorFor(jar, args.fqcn);\n const flags = rclFlagsFromDescriptor(xml);\n return { fqcn: args.fqcn, hasPaths: flags.hasPaths, hasChangeTypes: flags.hasChangeTypes, contract: componentContractFromDescriptor(xml, 'resource-change-listener') };\n }\n for (const [, xml] of jarDsDescriptors(jar)) {\n const providesRcl = /provide\\s+interface=\"org\\.apache\\.sling\\.api\\.resource\\.observation\\.(External)?ResourceChangeListener\"/.test(xml);\n const flags = rclFlagsFromDescriptor(xml);\n if (!providesRcl && !(flags.hasPaths && flags.hasChangeTypes)) continue;\n const fqcn = componentName(xml);\n if (fqcn) return { fqcn, hasPaths: flags.hasPaths, hasChangeTypes: flags.hasChangeTypes, contract: componentContractFromDescriptor(xml, 'resource-change-listener') };\n }\n return null;\n },\n async verify({ bundleBSN, discovered, args }) {\n const contractFailure = sourceContractFailure(discovered);\n if (contractFailure) return contractFailure;\n const diagnosis = await getBundleDiagnosis({ bundleBSN, args });\n const bundleFailure = bundleGate(diagnosis, bundleBSN);\n if (bundleFailure) return bundleFailure;\n const component = componentGate(diagnosis, discovered.contract?.componentName || discovered.fqcn);\n if (component.failure) return component.failure;\n\n // resource.paths + resource.change.types are read from the built DS descriptor,\n // not the effective properties of the running component.\n if (!discovered.hasPaths || !discovered.hasChangeTypes) {\n const missing = [!discovered.hasPaths && 'resource.paths', !discovered.hasChangeTypes && 'resource.change.types'].filter(Boolean).join(' + ');\n return {\n result: 'fail',\n failure_class: FAILURE_CLASSES.CONTRACT_MISMATCH,\n bundle_state: 'Active', component_state: 'Active',\n evidence: `ResourceChangeListener contract incomplete: ${missing} missing in DS descriptor`,\n };\n }\n return {\n result: 'pass', bundle_state: 'Active', component_state: 'Active',\n checks: { ...discovered.contract?.checks, resource_paths: true, resource_change_types: true },\n restricted: true,\n restricted_reason: 'effective ResourceChangeListener properties not exposed by diagnose-osgi-bundle; listener callbacks and business effects not tested',\n };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":3221,"content_sha256":"a3120ff0a72f048b18ea0ee656e10b48e6669762b3f6f619d8458c7238539f09"},{"filename":"scripts/validate-migration/check/patterns/scheduler.js","content":"'use strict';\n\nconst path = require('path');\nconst { jarDsDescriptors } = require('../lib/jar.js');\nconst { descriptorProperty, componentContractFromDescriptor, sourceContractFailure, componentName, dsDescriptorFor } = require('../lib/descriptor.js');\nconst { getBundleDiagnosis } = require('../lib/diagnosis.js');\nconst { bundleGate, componentGate } = require('../lib/runtime-gate.js');\n\nmodule.exports = {\n describe: 'Sling Scheduler (Cloud Service contract)',\n discover: (jar, args) => {\n // If the caller pinned a class, take it verbatim.\n if (args && args.fqcn) return { fqcn: args.fqcn, contract: componentContractFromDescriptor(dsDescriptorFor(jar, args.fqcn), 'scheduler') };\n // Otherwise match any DS descriptor with scheduler.expression. Prefer the\n // one that ALSO declares scheduler.runOn (the migrated one).\n const candidates = [];\n for (const [fname, xml] of jarDsDescriptors(jar)) {\n if (!descriptorProperty(xml, 'scheduler.expression').present) continue;\n const hasRunOn = descriptorProperty(xml, 'scheduler.runOn').present;\n candidates.push({ fqcn: componentName(xml) || path.basename(fname, '.xml'), hasRunOn });\n }\n if (candidates.length === 0) return null;\n const migrated = candidates.find((c) => c.hasRunOn);\n if (!migrated && candidates.length > 1) {\n console.log(` note: ${candidates.length} scheduler classes in jar, none declare scheduler.runOn; picking ${candidates[0].fqcn} — pass --fqcn to override`);\n }\n const fqcn = (migrated || candidates[0]).fqcn;\n return { fqcn, contract: componentContractFromDescriptor(dsDescriptorFor(jar, fqcn), 'scheduler') };\n },\n async verify({ bundleBSN, discovered, args }) {\n const contractFailure = sourceContractFailure(discovered);\n if (contractFailure) return contractFailure;\n\n const diagnosis = await getBundleDiagnosis({ bundleBSN, args });\n const bundleFailure = bundleGate(diagnosis, bundleBSN);\n if (bundleFailure) return bundleFailure;\n const component = componentGate(diagnosis, discovered.contract?.componentName || discovered.fqcn);\n if (component.failure) return component.failure;\n\n // Contract properties (scheduler.expression, scheduler.runOn, etc.) are\n // not exposed by diagnose-osgi-bundle today. Report as restricted so the\n // agent knows the bundle+component check passed but the DS-property\n // contract could not be verified. Track as an MCP feature request.\n return {\n result: 'pass',\n bundle_state: 'Active',\n component_state: 'Active',\n checks: { ...discovered.contract?.checks, migration_contract: 'pass', business_behavior: 'not_tested' },\n restricted: true,\n restricted_reason: 'effective scheduler DS properties not exposed by diagnose-osgi-bundle; scheduled execution and cluster behavior not verified',\n };\n },\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2850,"content_sha256":"5278143e41122d5a89186c32b33fe6a6a8e96bc175c9f2b2cdea8f6a0e712b8c"},{"filename":"scripts/validate-migration/deploy.js","content":"'use strict';\n\n/**\n * deploy.js — install an OSGi bundle on a running AEM Cloud SDK.\n *\n * Uses only the customer project's own `autoInstallBundle` (or\n * `autoInstallPackage`) Maven profile — the AEM archetype standard. The\n * project's pom/settings own the install endpoint and credentials; this skill\n * never talks to the SDK console or holds credentials. Only the target host\n * and port are passed so the project deploys to the selected local SDK.\n *\n * If no such profile exists, deploy returns `ok: false` and the developer\n * deploys the bundle themselves before the MCP diagnosis step.\n *\n * Bundle/component *state* is not asserted here; that's the runtime verify\n * step's job via the diagnose-osgi-bundle MCP tool.\n *\n * Input : { projectDir, artifactPath, sdkUrl, timeoutMs=180000 }\n * Output: { ok, symbolicName, mode, elapsedMs, log }\n */\nconst fs = require('fs');\nconst path = require('path');\nconst { execFileSync } = require('child_process');\n\nfunction deploy({ projectDir, artifactPath, sdkUrl, timeoutMs = 180_000 } = {}) {\n const t0 = Date.now();\n if (!fs.existsSync(artifactPath)) return { ok: false, log: `artifact missing: ${artifactPath}` };\n const symbolicName = readBundleSymbolicName(artifactPath);\n if (!symbolicName) return { ok: false, log: 'could not read Bundle-SymbolicName from artifact' };\n\n const profile = projectDir ? detectAutoInstallProfile(projectDir) : null;\n if (!profile) {\n return {\n ok: false, symbolicName, mode: 'manual', elapsedMs: Date.now() - t0,\n log: 'No autoInstallBundle/autoInstallPackage Maven profile found in the project. '\n + `Deploy ${symbolicName} to the local SDK with your project's usual deployment, then re-run.`,\n };\n }\n\n const sdk = new URL(sdkUrl);\n const args = [\n '-q', '-B', '-DskipTests',\n '-P', profile,\n '-Daem.host=' + sdk.hostname,\n '-Daem.port=' + (sdk.port || (sdk.protocol === 'https:' ? '443' : '80')),\n 'install',\n ];\n try {\n const log = execFileSync('mvn', args, { cwd: projectDir, encoding: 'utf8', timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 });\n return { ok: true, symbolicName, mode: 'profile:' + profile, log, elapsedMs: Date.now() - t0 };\n } catch (e) {\n return { ok: false, symbolicName, mode: 'profile:' + profile, log: (e.stdout || '') + (e.stderr || e.message), elapsedMs: Date.now() - t0 };\n }\n}\n\n// Walks up from projectDir looking for the AEM archetype's install profile in\n// any pom.xml. Prefers `autoInstallBundle` for jar packaging and\n// `autoInstallPackage` for content-package packaging. Returns null when\n// neither is defined anywhere in the ancestor chain.\nfunction detectAutoInstallProfile(projectDir) {\n const modulePkg = readPackagingType(path.join(projectDir, 'pom.xml'));\n const preferred = modulePkg === 'content-package' || modulePkg === 'zip' ? 'autoInstallPackage' : 'autoInstallBundle';\n let dir = projectDir;\n for (let i = 0; i \u003c 6; i++) {\n const pom = path.join(dir, 'pom.xml');\n if (fs.existsSync(pom)) {\n const xml = fs.readFileSync(pom, 'utf8');\n if (new RegExp(`\u003cid>\\\\s*${preferred}\\\\s*\u003c/id>`).test(xml)) return preferred;\n }\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\nfunction readPackagingType(pomPath) {\n try {\n const xml = fs.readFileSync(pomPath, 'utf8');\n const m = xml.match(/\u003cpackaging>\\s*([\\w-]+)\\s*\u003c\\/packaging>/);\n return m ? m[1] : 'jar';\n } catch { return 'jar'; }\n}\n\n// Reads Bundle-SymbolicName from the jar's MANIFEST.MF via `unzip -p`.\nfunction readBundleSymbolicName(jarPath) {\n try {\n const mf = execFileSync('unzip', ['-p', jarPath, 'META-INF/MANIFEST.MF'], { encoding: 'utf8' });\n // MANIFEST folds long lines with \"\\r\\n \" continuations — unfold before matching\n const unfolded = mf.replace(/\\r?\\n /g, '');\n const m = unfolded.match(/^Bundle-SymbolicName:\\s*(.+?)\\s*$/m);\n // Strip directives/attributes: `com.foo;singleton:=true` → `com.foo`.\n return m ? m[1].split(';')[0].trim() : null;\n } catch { return null; }\n}\n\nmodule.exports = { deploy, readBundleSymbolicName };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4109,"content_sha256":"ef49ceee6bc1f7199820de2167fb64278645fb5201fdc9a4534f8c9a32173f01"},{"filename":"scripts/validate-migration/failure-classes.js","content":"'use strict';\n\n/**\n * Failure taxonomy — frozen enum.\n *\n * The `submit-migration-validation-result` MCP tool accepts exactly this set of\n * `failure_class` strings. Changing it changes the outcome payload contract.\n */\n\nconst FAILURE_CLASSES = Object.freeze({\n INPUT_JAR_MISSING: 'input.jar_missing',\n BUILD_FAILED: 'build.failed',\n DISCOVERY_NO_MATCH: 'discovery.no_pattern_match',\n SDK_UNREACHABLE: 'sdk.unreachable',\n SETUP_MCP_UNAVAILABLE: 'setup.mcp_unavailable',\n DEPLOY_FAILED: 'deploy.failed',\n\n BUNDLE_NOT_INSTALLED: 'runtime.bundle_not_installed',\n BUNDLE_NOT_ACTIVE: 'runtime.bundle_not_active',\n COMPONENT_UNSATISFIED: 'runtime.component_unsatisfied',\n ACTIVATION_ERROR: 'runtime.activation_error',\n CONTRACT_MISMATCH: 'runtime.contract_mismatch',\n\n TESTS_FAILED: 'tests.failed',\n SOURCE_CONTRACT_MISMATCH: 'source.contract_mismatch',\n\n UNKNOWN: 'unknown',\n});\n\nconst ALL_FAILURE_CLASSES = Object.freeze(Object.values(FAILURE_CLASSES));\n\nfunction isFailureClass(value) {\n return typeof value === 'string' && ALL_FAILURE_CLASSES.includes(value);\n}\n\nmodule.exports = { FAILURE_CLASSES, ALL_FAILURE_CLASSES, isFailureClass };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":1259,"content_sha256":"af19fcf0577b4315d8ed273b8abc784455c66af76f0e323126e4f4c9be47932c"},{"filename":"scripts/validate-migration/init.js","content":"#!/usr/bin/env node\n'use strict';\n\n/**\n * validate-migration init — one-time setup. Run once before validation.\n *\n * Runtime patterns need the Cloud SDK (default http://localhost:4602) and its\n * MCP server, confirmed by the coding assistant. Template comparison also needs\n * AEM 6.5 (default http://localhost:4502); offline dialog checks need neither.\n * Setup never contacts AEM and never handles AEM credentials: it records the\n * selected URLs, rejects a source URL that equals the SDK URL, and checks local\n * tools. The developer starts the instances; deployment uses the project's\n * Maven profile and runtime state comes only from the MCP diagnosis.\n *\n * --pattern \u003ca,b> scope (otherwise changed patterns vs main)\n * --sdk \u003curl> --quickstart \u003curl> override the fixed ports\n */\n\nif (Number(process.versions.node.split('.')[0]) \u003c 18) {\n console.error('[validate-migration] Node 18+ required.');\n process.exit(2);\n}\n\nconst path = require('path');\nconst { execFileSync } = require('child_process');\nconst { REQUIRED_TOOL } = require('./mcp.js');\nconst { readSetup, writeSetup, resolveInstances, setupPath, requiredChecks, sameInstance } = require('./setup.js');\nconst { computeValidationPlan } = require('./plan.js');\nconst { canonicalPattern } = require('./check/patterns/aliases.js');\n\nfunction parseArgs(argv) {\n const out = {};\n for (let i = 0; i \u003c argv.length; i++) {\n if (!argv[i].startsWith('--')) continue;\n const k = argv[i].replace(/^--/, '');\n out[k] = argv[i + 1] && !argv[i + 1].startsWith('--') ? argv[++i] : true;\n }\n return out;\n}\n\nfunction hasCommand(cmd) {\n try { execFileSync('sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }); return true; }\n catch { return false; }\n}\n\nasync function main() {\n const args = parseArgs(process.argv.slice(2));\n const cwd = args.project || process.cwd();\n let patterns;\n if (typeof args.pattern === 'string') {\n patterns = args.pattern.split(',').map((pattern) => canonicalPattern(pattern.trim())).filter(Boolean);\n if (!patterns.length) throw new Error('--pattern must name at least one supported pattern');\n } else {\n const plan = computeValidationPlan({ cwd, baseRef: args.base });\n if (plan.error) throw new Error(`${plan.error} — pass --pattern \u003cname> to select setup scope`);\n patterns = [...new Set(plan.tasks.map((task) => task.pattern))];\n }\n const required = new Set(requiredChecks(patterns));\n const saved = readSetup(cwd);\n const inst = resolveInstances(args, saved);\n\n const items = [\n { name: 'mvn and unzip on PATH', ok: hasCommand('mvn') && hasCommand('unzip'), detail: 'install Maven 3.8+ and unzip' },\n ];\n const selected = {};\n for (const kind of ['sdk', 'quickstart']) {\n if (!required.has(kind)) continue;\n const url = inst[`${kind}_url`];\n let valid = true;\n try { new URL(url); } catch { valid = false; }\n items.push({ name: `${kind === 'sdk' ? 'Cloud SDK' : 'AEM 6.5 source'} URL ${url}`, ok: valid, detail: 'not a valid URL' });\n if (valid) selected[`${kind}_url`] = url;\n }\n if (selected.sdk_url && selected.quickstart_url && sameInstance(selected.sdk_url, selected.quickstart_url)) {\n items.push({ name: 'AEM 6.5 source and Cloud SDK are different instances', ok: false,\n detail: 'pass distinct --sdk and --quickstart URLs' });\n }\n\n console.log(`[validate-migration] setup — scope: ${patterns.join(', ') || 'no relevant changes'}`);\n for (const it of items) console.log(` ${it.ok ? '✓' : '✗'} ${it.name}${it.ok ? '' : ` — ${it.detail}`}`);\n if (required.size) console.log(' • start the instances above yourself; init.js does not contact them');\n if (required.has('mcp')) {\n console.log(` • MCP: your coding assistant must confirm ${REQUIRED_TOOL} on ${inst.sdk_url}/bin/mcp; init.js does not check it`);\n }\n\n const blocking = items.filter((it) => !it.ok);\n if (blocking.length) {\n console.error(`\\n[validate-migration] setup not ready: ${blocking.length} item(s) above. Fix and rerun init.js.`);\n process.exit(1);\n }\n if (Object.keys(selected).length) {\n writeSetup(cwd, { ...saved, ...selected });\n console.log(`\\n saved ${path.relative(cwd, setupPath(cwd))} (URLs only)`);\n }\n console.log('\\n[validate-migration] setup ready. Next: node \u003cvalidator>/check.js --stage prepare');\n}\n\nmain().catch((e) => { console.error(`[validate-migration] ${e.stack || e.message}`); process.exit(1); });\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":4423,"content_sha256":"2abeab263e2771d029e6c2f9c148c536b1b9b78227d5cdb3ad9a01ee727a1e12"},{"filename":"scripts/validate-migration/mcp.js","content":"'use strict';\n\n/**\n * mcp.js — AEM Quickstart MCP server constants. The server exposes\n * `diagnose-osgi-bundle` at \u003csdk>/bin/mcp; every runtime pattern needs it. The user\n * adds it to their coding assistant, which confirms readiness by listing the tool.\n */\nconst REQUIRED_TOOL = 'diagnose-osgi-bundle';\n\nmodule.exports = { REQUIRED_TOOL };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":345,"content_sha256":"62f8f3ce0be5275faf6378789998fe835fecd6f776a424a05dec6b58a4c631db"},{"filename":"scripts/validate-migration/package.json","content":"{\n \"name\": \"aem-cs-validate-migration\",\n \"version\": \"0.3.0\",\n \"private\": true,\n \"type\": \"commonjs\",\n \"description\": \"validate-migration — deploy migrated AEM Cloud Service bundles to a local SDK and confirm they run\",\n \"scripts\": {\n \"test\": \"node --test\"\n },\n \"engines\": {\n \"node\": \">=18.0.0\"\n }\n}\n","content_type":"application/json; charset=utf-8","language":"json","size":315,"content_sha256":"540f2429677dcd0cdfe968317892f88b4114ae69bd4a24311c6fbe2ccc636dab"},{"filename":"scripts/validate-migration/plan.js","content":"'use strict';\n\n/**\n * plan.js — auto-scopes an validate-migration run by diffing the current branch against\n * `main`, classifying each changed file into a supported pattern, and\n * grouping the hits by Maven module (nearest ancestor dir with a pom.xml).\n *\n * This is what lets `validate-migration-check` run with no pattern argument: \"run validate-migration on\n * this branch\" instead of \"run validate-migration on the scheduler pattern in this dir\".\n */\nconst fs = require('fs');\nconst path = require('path');\nconst { execFileSync } = require('child_process');\n\n// Patterns auto-detected from the branch diff, keyed by a content/path signal cheap\n// enough to run on a raw diff (check.js's own `discover()` needs a *built*\n// artifact, which isn't available yet at planning time). `cdw` is explicit-only:\n// its dialog paths are indistinguishable from legacy-ui here.\n// Order matters: RULES.find takes the FIRST match, so the broad DAM API marker\n// comes after the specific runtime patterns.\nconst RULES = [\n { pattern: 'scheduler', test: (file, text) => /\\.java$/.test(file) && /scheduler\\.expression|scheduler\\.runOn/.test(text) },\n { pattern: 'resource-change-listener', test: (file, text) => /\\.java$/.test(file) && /ResourceChangeListener|resource\\.change\\.types/.test(text) },\n { pattern: 'event-migration', test: (file, text) => /\\.java$/.test(file) && /job\\.topics|JobConsumer|EventHandler/.test(text) },\n { pattern: 'replication', test: (file, text) => /\\.java$/.test(file) && /org\\.apache\\.sling\\.distribution|com\\.day\\.cq\\.replication/.test(text) },\n { pattern: 'asset-manager', test: (file, text) => /\\.java$/.test(file) && /com\\.day\\.cq\\.dam\\.api\\./.test(text) },\n { pattern: 'legacy-ui', test: (file) => /(?:^|\\/)(?:_cq_dialog|cq:dialog|_cq_design_dialog|design_dialog|dialog)(?:\\/\\.content\\.xml|\\.xml)$/.test(file) },\n { pattern: 'custom-templates', test: (file) => /(?:^|\\/)conf\\/[^/]+\\/settings\\/wcm\\/templates\\/[^/]+\\/(?:\\.content\\.xml|(?:structure|initial|policies)\\/.*\\.content\\.xml)$/.test(file) },\n];\n\n// Patterns verified from the built package without a deployed bundle.\nconst SOURCE_ONLY_PATTERNS = new Set(['legacy-ui', 'custom-templates']);\n\nfunction resolveBaseRef(cwd) {\n for (const ref of ['origin/main', 'main']) {\n try {\n execFileSync('git', ['rev-parse', '--verify', ref], { cwd, stdio: 'ignore' });\n return ref;\n } catch { /* try next */ }\n }\n return null;\n}\n\nfunction changedFiles(cwd, baseRef) {\n // Union of everything that differs from baseRef in the working tree:\n // • committed vs baseRef merge-base (baseRef...HEAD)\n // • staged (index vs HEAD)\n // • unstaged (working tree vs HEAD)\n // • untracked (respects .gitignore)\n // The migration skill often edits files without committing, so committed-only\n // scoping would report \"nothing to verify\" right after a migration.\n const runs = [\n ['diff', '--name-only', '--diff-filter=ACMR', `${baseRef}...HEAD`],\n ['diff', '--name-only', '--diff-filter=ACMR', '--cached', 'HEAD'],\n ['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'],\n ['ls-files', '--others', '--exclude-standard'],\n ];\n const set = new Set();\n for (const argv of runs) {\n let out;\n try { out = execFileSync('git', argv, { cwd, encoding: 'utf8' }); }\n catch { continue; }\n for (const line of out.split('\\n')) {\n const f = line.trim();\n if (f) set.add(f);\n }\n }\n return [...set];\n}\n\n// Walks up from a changed file's directory to find the nearest pom.xml —\n// that's the Maven module validate-migration check needs to build for this task.\nfunction findModule(cwd, file) {\n let dir = path.dirname(path.join(cwd, file));\n const root = path.parse(cwd).root;\n while (dir && dir !== root) {\n if (fs.existsSync(path.join(dir, 'pom.xml'))) return dir;\n const parent = path.dirname(dir);\n if (parent === dir) break;\n dir = parent;\n }\n return null;\n}\n\n/**\n * @param {{cwd: string, baseRef?: string}} opts\n * @returns {{ tasks: Array\u003c{module: string, pattern: string, files: string[]}>, needsSdk: boolean, baseRef: string|null }}\n */\nfunction computeValidationPlan({ cwd, baseRef }) {\n const resolvedBase = baseRef || resolveBaseRef(cwd);\n if (!resolvedBase) {\n return { tasks: [], needsSdk: false, baseRef: null, error: 'no main/origin-main ref found to diff against' };\n }\n\n const files = changedFiles(cwd, resolvedBase);\n const byModuleAndPattern = new Map(); // \"moduleDir::pattern\" -> { module, pattern, files: [] }\n\n for (const file of files) {\n const abs = path.join(cwd, file);\n if (!fs.existsSync(abs)) continue; // deleted files can't be classified by content\n const text = /\\.java$/.test(file) ? safeRead(abs) : '';\n const hit = RULES.find((r) => r.test(file, text));\n if (!hit) continue;\n\n const module = findModule(cwd, file) || cwd;\n const key = `${module}::${hit.pattern}`;\n if (!byModuleAndPattern.has(key)) byModuleAndPattern.set(key, { module, pattern: hit.pattern, files: [] });\n byModuleAndPattern.get(key).files.push(file);\n }\n\n const tasks = [...byModuleAndPattern.values()];\n const needsSdk = tasks.some((t) => !SOURCE_ONLY_PATTERNS.has(t.pattern));\n return { tasks, needsSdk, baseRef: resolvedBase };\n}\n\nfunction safeRead(file) {\n try { return fs.readFileSync(file, 'utf8'); } catch { return ''; }\n}\n\nmodule.exports = { computeValidationPlan, changedFiles, RULES, SOURCE_ONLY_PATTERNS };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":5415,"content_sha256":"06c07d4e34f8b0fed2583764ba6cd4c82071a835a56f1473502207d3e8f72acb"},{"filename":"scripts/validate-migration/README.md","content":"# validate-migration\n\nDeploys migrated AEM Cloud Service bundles to a local Cloud SDK and confirms they\nactually run — bundle Active, OSGi component Active, Cloud Service contract\nproperties correct.\n\nCompanion to the `migration` skill: `migration` applies the fix,\n`validate-migration` proves it works.\n\n> Status: Beta. Verified end-to-end against AEM SDK 2026.8+ with the\n> [AEM Quickstart MCP content package](https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/ai-in-aem/local-development-with-ai-tools#aem-quickstart-mcp-server).\n\n## Validation and reporting flow\n\nThe migration skill's Branch G ([validate-migration.md](../../references/validation/validate-migration.md))\ndrives these scripts by absolute path (`$VALIDATOR` is this directory) — there is no `npm link` and no\nglobal bin.\n\n```bash\n# From your project root — scoped setup (or pass --pattern scheduler, etc.).\n# Offline dialog checks need no instances. Runtime checks need the Cloud SDK;\n# template comparison also needs AEM 6.5. Start them yourself; setup saves their\n# URLs and never contacts AEM or asks for AEM credentials.\nnode \"$VALIDATOR/init.js\"\n\n# Per branch, from your project root:\n# 1. build + deploy, list the Bundle-SymbolicNames to diagnose\nnode \"$VALIDATOR/check.js\" --stage prepare\n\n# 2. the coding assistant now calls the AEM Quickstart MCP tool\n# `diagnose-osgi-bundle` (argument `bundleSymbolicName`) for each BSN and writes the raw text outputs to\n# \u003cproject>/.validate-migration/diagnosis-map.json as { \"\u003cBSN>\": \"...\" }\n\n# 3. consume the diagnosis + emit outcome\nnode \"$VALIDATOR/check.js\" --stage verify\n\n# 4. the assistant submits the emitted payload through submit-migration-validation-result\n# and saves the actual MCP response to .validate-migration/\u003crun_id>-receipt.json\n\n# 5. persist the acknowledgement or submission error in both local reports\nnode \"$VALIDATOR/check.js\" --stage finalize \\\n --report .validate-migration/\u003crun_id>.json \\\n --receipt .validate-migration/\u003crun_id>-receipt.json\n```\n\nResolve script paths relative to this directory, not the customer's working\ndirectory. Run commands from the selected project/module root or pass\n`--project \u003cmodule>`. For a single Java class, use\n`--pattern scheduler --fqcn com.customer.Scheduler --project \u003cbundle-module>`\non prepare and verify. The entire bundle is deployed; only that class is evaluated.\nThe build uses `-DskipTests`, so this flow does not establish unit-test success.\n\nAuto-diff mode diffs `HEAD` against `origin/main` (fallback `main`),\n**including staged, unstaged, and untracked changes** so runs immediately after\nthe sibling `migration` skill (which edits files without committing) still see\nthe pending work. Manual override: `--pattern \u003cname>` runs one pattern only.\n\n`--stage all` (the default) stops after prepare with exit `3` when runtime\ndiagnosis is needed. With an explicit diagnosis map it can run prepare + verify\nin one invocation; that does not replace outcome submission and finalization.\nAny BSN missing from the map is\nreported with `failure_class: setup.mcp_unavailable` and setup guidance; there\nis no silent fallback to the Felix Web Console.\n\n## Bounded runtime recovery\n\nThe agent, not `check.js` or the outcome-reporting tool, coordinates recovery via\nthe AEM SDK diagnosis MCP. Keep the original scope and the same project/module,\nSDK, pattern, and class filters.\nUnrelated out-of-scope component errors do not fail a scoped validation or trigger\nrepair unless evidence proves they block a selected class's required dependency.\n\n- For `runtime.bundle_not_active` without a diagnosed code blocker, allow at most\n 3 diagnosis checks total per affected BSN per deployment, including the initial\n check. Between checks, wait 5 seconds, then obtain a fresh `diagnose-osgi-bundle`\n report and replace the affected entry in `diagnosis-map.json`, preserving other\n BSNs. Rerun verify using saved prepared state. Do not rebuild or redeploy during activation checks.\n Stop waiting when Active or when diagnosis shows a blocker; still verify the\n components and migration contract.\n- After exhausted checks or another runtime failure, analyze MCP evidence for\n unresolved imports, missing services, or activation exceptions. Allow\n at most one repair cycle for the entire validation invocation, not per class.\n `migration` owns customer source changes; validation hands it the failed class,\n diagnosis, scope, and evidence, and never edits customer source itself. Only\n clear, minimal fixes within authorized scope qualify. Do not guess dependencies,\n change credentials, broaden scope, or invoke business actions. Stop for unclear\n causes, out-of-scope fixes, or unavailable tools; stop recovery immediately on `setup.mcp_unavailable`.\n If diagnosis becomes unavailable after prepare, remove affected BSN entries\n from the map and rerun verify to produce a final missing-diagnosis outcome,\n then continue the normal final steps. Do not reuse stale diagnosis. If unavailable\n before prepare, stop with setup guidance without fabricating a run.\n- After a repair, recheck deployment safety and affected existing customer tests,\n then run prepare again through the same Maven profile, obtain new diagnosis,\n and verify again. The new deployment retains the activation-check limit but\n permits no second repair cycle. Never reuse the old artifact or diagnosis.\n- Preserve credential-free attempt summaries in `.validate-migration/recovery.md`\n with the original failure, counts, repair rationale, report run_ids, final result,\n and restrictions. Preserve intermediate reports without submitting them; only\n the payload and run_id from the final verification attempt proceed to submission\n and finalization. Unresolved failures stop with the remaining cause and next action.\n\nFor a timeout-ending `deploy.failed`, permit one deployment retry after 5 seconds.\nDo not blindly retry runtime/source contract failures. Source-only failures return\nto migration without automatic repair in this runtime recovery loop.\n\n## Completion contract\n\nVerify always writes `.validate-migration/\u003crun_id>.md` and `.json`. Validation\nand reporting are separate: a restricted local pass is not end-to-end completion.\nWith no linked CAM project, reporting is `not_reported` and the run is complete:\npassing verification exits `0`, failed validation `1`. With a linked project,\npassing verification exits `3` while awaiting reporting; failed validation exits\n`1` but its outcome must still be submitted.\n\nWith a linked project, confirm `submit-migration-validation-result` is in the coding\nassistant's tools; runtime scope also requires `diagnose-osgi-bundle` against the\nselected SDK. Offline `legacy-ui`/`cdw` checks need no diagnosis MCP.\nNever test reporting with a fabricated payload. Load `project.id` (and optional\n`project.name`) from the nearest `migration-runbook.json`, or use an explicit\nflag, which takes precedence. If none is linked, ask for the actual CAM project\nname. Context files are not consumed. Only skip submission for an explicit local-only\nrequest, and do not label reporting complete.\n\nForward the emitted payload once to `submit-migration-validation-result` (the AEM MCP Server\nrecipe, or the deprecated tool), then save the actual result as the receipt. Wrap\nerrors as `{ \"success\": false, \"error\": \"\u003ccredential-free summary>\" }`, not raw\npotentially sensitive text. Never put credentials in receipts. Finalization\nkeeps a safe error summary rather than copying arbitrary error text to reports.\nFinalization requires `success: true` (AEM MCP Server) or `ok: true` (deprecated tool) and the\nSAME `run_id`; it does not make a network\nrequest or resubmit the outcome. It persists reporting status, errors, and\nrestricted-check explanations in the reports. Exit codes:\n\n| Stage/result | Exit |\n|---|---|\n| Finalize: validation passed and outcome recorded | `0` |\n| Validation failed (including after acknowledged reporting) | `1` |\n| Usage or malformed input | `2` |\n| Diagnosis or reporting incomplete | `3` |\n\nAn acknowledged outcome means the backend accepted/persisted it. It is not proof\nof a separate downstream event being delivered; the report leaves event delivery\nunconfirmed. A failed submission does not turn a local validation pass into a\ncode failure, and must not be silently retried or presented as recorded.\n\n## Prerequisites\n\n- Offline `legacy-ui`/`cdw` checks require no running instances or diagnosis MCP.\n- Bundle-runtime checks require AEM Cloud SDK (default `http://localhost:4602`).\n Side-by-side `custom-templates` validation additionally requires source AEM 6.5\n Quickstart (default `http://localhost:4502`). Override with `--quickstart \u003curl>` /\n `--sdk \u003curl>`; scoped `init.js --pattern \u003ca,b>` records only the required URLs,\n or infers the changed patterns versus main when no scope is supplied.\n Setup never contacts AEM: it rejects a source URL that is the SDK URL and saves\n the selected URLs. The coding assistant's `diagnose-osgi-bundle` tool on\n `\u003csdk>/bin/mcp` confirms the SDK is running. If deployment cannot connect,\n prepare reports `sdk.unreachable`; start the SDK and rerun. Setup never\n searches for, boots, or downloads Quickstart JARs. Only offer\n [SDK download guidance](https://experience.adobe.com/#/downloads/content/software-distribution/en/aemcloud.html)\n after the user confirms no local SDK exists. Never skip runtime validation.\n- [AEM Quickstart MCP server](https://github.com/adobe/cq-quickstart-mcp-server/)\n (`com.adobe.aem:com.adobe.aem.mcp-server-contribs-content`, on Maven Central) installed\n on the SDK for runtime checks and added by you to the coding assistant's MCP config. Ready when the\n assistant lists `diagnose-osgi-bundle`; `init.js` does not probe it. See the\n [Adobe setup docs](https://experienceleague.adobe.com/en/docs/experience-manager-cloud-service/content/ai-in-aem/local-development-with-ai-tools#aem-quickstart-mcp-server).\n- `mvn` and `unzip` on `PATH`.\n- Node 18+.\n\n## Config\n\nZero config for standard local dev. Overrides via env or CLI:\n\n| Env | CLI | Default | Notes |\n|---|---|---|---|\n| `AEM_SDK_URL` | `--sdk \u003curl>` | saved setup, else `http://localhost:4602` | never falls back to AEM 6.5 on 4502 |\n| — | `--quickstart \u003curl>` | saved setup, else `http://localhost:4502` | source URL for scoped `init.js` template comparison setup |\n| — | `--pattern \u003ca,b>` | changed patterns versus main | scope for `init.js`; `check.js` accepts one explicit pattern |\n| — | `--project-id \u003cid>` | auto-loaded from nearest `migration-runbook.json` | explicit selection overrides inherited identity |\n| — | `--project-name \u003cname>` | user-confirmed CAM project name | reporting MCP resolves the name; payload can be emitted without an ID |\n| — | `--diagnosis-map \u003cfile>` | `\u003cproject>/.validate-migration/diagnosis-map.json` | agent-supplied BSN → raw MCP text output |\n| — | `--fqcn \u003cclass>` | inferred | pin the Java class for patterns that support it, including scheduler |\n| — | `--stage prepare\\|verify\\|all\\|finalize` | `all` | validation and acknowledged-report finalization |\n| — | `--report \u003cjson>` | required for finalize | saved validation record |\n| — | `--receipt \u003cjson>` | required for finalize | actual reporting MCP response |\n\nNo global validator configuration or credentials are persisted to `$HOME`.\nNo `~/.validate-migration/*`.\n\n### Missing local instances\n\nOnly instances required by the selected patterns are needed, at saved/explicit URLs\nor the defaults (4502 for AEM 6.5, 4602 for Cloud SDK). The scripts never probe\nthem: if `diagnose-osgi-bundle` cannot reach the SDK or prepare reports\n`sdk.unreachable`, ask the developer to start the instance and rerun.\nDo not ask a generic SDK-isolation question during startup; before deployment,\nraise specific risks found in project configuration in plain language.\n\n## Deploy strategy\n\nUses only the customer project's `-PautoInstallBundle` (or `-PautoInstallPackage`)\nMaven profile — the AEM archetype standard. The project's own pom and Maven\nsettings own the install endpoint and credentials; the validator never handles\ncredentials or calls AEM directly. Only `-Daem.host`/`-Daem.port` are\npassed so the deployment targets the selected local SDK. If Maven cannot connect,\nprepare reports `sdk.unreachable`. If the project has no such profile, prepare\nreports `deploy.failed` and the developer deploys the bundle with their usual\nworkflow before re-running.\n\n## Runtime verification via MCP\n\nBundle + component state come from the coding-assistant's own MCP session with\nthe [AEM Quickstart MCP server](https://github.com/adobe/cq-quickstart-mcp-server)\n(`diagnose-osgi-bundle` tool). **This script does not speak MCP** — it consumes\na JSON map of `{ \"\u003cBSN>\": \"\u003craw tool text output>\" }` written to\n`.validate-migration/diagnosis-map.json` by the coding assistant between\n`--stage prepare` and `--stage verify`.\n\nSignals the MCP tool does not expose today (component DS properties like\n`scheduler.expression`, `scheduler.runOn`, `job.topics` on the running\ncomponent; `Distributor` service registration) are reported as\n`restricted: true` on an otherwise-passing outcome and tracked as upstream\nfeature requests against\n[`apache/sling-org-apache-sling-mcp-server-contributions`](https://github.com/apache/sling-org-apache-sling-mcp-server-contributions).\nThere is no fallback to direct Felix Web Console access.\n\n## Pattern contracts\n\n| Pattern | Verification | Unverified signals |\n|---|---|---|\n| `scheduler` | Built Runnable service, scalar nonempty expression, scalar SINGLE/LEADER runOn, scalar Boolean concurrency if declared; Active bundle/component via MCP | effective properties, cron interpretation, scheduled execution, and cluster behavior |\n| `asset-manager` | Active bundle/component; offline absence of AssetManager references | customer asset operations and business effects |\n| `event-migration` | Built JobConsumer service and nonempty String `job.topics`; Active bundle/component | effective properties, job processing, and business effects |\n| `resource-change-listener` | Built listener service, nonempty String paths and supported change types; Active bundle/component | effective properties, listener callbacks, and business effects |\n| `replication` | Active bundle; distribution import, no CQ replication import | Distributor service registration, distribution execution, and business effects |\n| `legacy-ui` | Source-only dialog inspection for Classic UI and Coral 2, plus the package-checkable rules of `migration/references/legacy-ui/dialog/validation.md` (rule ids in evidence; blocking for dialogs changed on the branch, warnings in a full-package scan) | source-tree checks in that file (backups, sidecars, filter.xml); runtime rendering and interactions |\n| `cdw` | Source-only inspection for classic widget types and custom xtypes, plus `migration/references/legacy-ui/cdw/validation.md` 2.2 | session-specific checks in that file; runtime rendering and interactions |\n\n### Validation boundary\n\nThese are reusable migration-contract checks, not customer-specific business tests.\nDS checks inspect the built descriptor, including scalar and multiline properties;\nthey do not establish effective SDK configuration. Invalid supported source contracts\nfail with `source.contract_mismatch` before runtime diagnosis. Reports and MCP class\nentries separate `checks.migration_contract` (`pass`, `fail`, or `not_verified`) from\n`checks.business_behavior=not_tested`. A pass is limited to the supported contract;\nrestrictions and reporting status remain separate. It is not a claim of business\ncorrectness or cluster behavior.\nOlder prepared state without built DS contract evidence is not verified and cannot\npass; rerun `--stage prepare` with the same pattern and class scope. An external\nlistener marker does not replace registration under `ResourceChangeListener`.\n\nDo not invoke customer business actions, send email, submit jobs, mutate customer\ncontent, change schedules, or add test endpoints/instrumentation. The only exception\nis an explicitly approved disposable page under a dedicated test parent for\n`custom-templates`; cleanup is mandatory. Do not modify\ncustomer source. Existing customer tests can be run separately and unchanged only\nwith explicit authorization and a safe test setup; this validator does not run them.\nBefore deployment, confirm the SDK is isolated from production side effects because\nbundle activation can itself start customer work. If safety cannot be established,\nstop for safe SDK configuration; configuration changes require approval.\n\n`cdw` must be selected explicitly; auto-diff classifies shared dialog paths as\n`legacy-ui`. Dispatcher, unsupported-runmodes, and filevault-deps\nare not wired into this validator. Source-only CLI checks do not touch the SDK.\nBy default, `custom-templates` checks each editable template in the built\ncontent package for a `cq:Template` definition plus `structure` and `initial`\ncontent (`policies` is reported but optional). Optionally pass\n`--template /conf/\u003csite>/settings/wcm/templates/\u003cname>` on prepare to narrow\nselection. Package-only checks do not create or render pages.\nFor static-to-editable runtime comparison the agent orchestrates two-instance\nreadiness, approved target-only package deployment, equivalent source/target pages,\ncontent preservation and reviewed desktop/mobile screenshots. Pass\n`--template-mode runtime` on prepare and `--template-evidence \u003cabsolute-json-path>`\non verify. The runtime requirement persists in state, and missing evidence cannot\nsilently pass as source-only. The CLI validates browser evidence; it does not run\na browser, hold SDK credentials, create fixtures or deploy content ZIPs itself.\nSee [Template runtime validation](../../references/validation/custom-templates.md)\nfor setup, approval, evidence schema and cleanup. Each template gets its own payload,\nreport, receipt and finalize invocation. Other patterns are unchanged.\nThe validator has no npm dependencies; screenshot dimensions are read with the\nbuilt-in `check/lib/png.js`.\n\n## Files that ship with this plugin\n\n- `init.js` — scoped setup: checks local tools, rejects a source URL that is the\n SDK URL, saves the selected URLs to `.validate-migration/setup.json`, and reminds\n the coding assistant to check diagnosis MCP for runtime scope. It makes no HTTP calls.\n- `setup.js` — per-pattern prerequisites and the saved setup file.\n- `mcp.js` — the required diagnosis tool name (`diagnose-osgi-bundle`). It does not\n probe the SDK or edit the IDE configuration; the user configures the server\n and the coding assistant confirms `diagnose-osgi-bundle`.\n- `check.js` — orchestrator CLI: prepare = build/discover/deploy; verify = consume\n diagnosis and run each pattern's check; finalize = persist the reporting\n acknowledgement or error.\n- `check/patterns/` — one module per pattern, each exporting `discover` and `verify`;\n `index.js` registers them. Add a pattern by adding one module and registering it.\n - `custom-templates/` — `index.js` (pattern), `discover.js` (templates in a package),\n `mapping.js` (offline check that every component of the pre-migration static\n template, read from Git via `--static-template` / `--baseline-ref`, defaults\n `/apps/\u003csite>/templates/\u003cname>` and `HEAD`, exists in the editable template),\n `evidence.js` (agent-captured runtime evidence, PNG screenshots, content\n preservation, run/package binding and fixture cleanup).\n- `check/lib/` — shared helpers: `jar.js` (jar contents and manifest),\n `descriptor.js` (DS descriptors and contracts), `diagnosis.js` (MCP diagnosis map),\n `runtime-gate.js` (shared bundle/component state checks), `dialogs.js` (dialog\n discovery and classification).\n- `plan.js` — branch-diff scoping. Includes committed, staged, unstaged, and\n untracked changes.\n- `build.js`, `deploy.js` — reusable helpers.\n- `failure-classes.js` — frozen failure taxonomy accepted by the\n `submit-migration-validation-result` MCP tool.\n","content_type":"text/markdown; charset=utf-8","language":"markdown","size":20091,"content_sha256":"88b3faf68fb82ac2cfe5e2ef9664adb7f274dfdaa574005ae6cdca22be33db15"},{"filename":"scripts/validate-migration/reporting.js","content":"'use strict';\n\n/** Reporting + MCP-outcome builders for validate-migration (extracted from check.js). */\nconst fs = require('fs');\nconst path = require('path');\nconst { FAILURE_CLASSES } = require('./failure-classes.js');\nconst { readSetup, sameInstance, SDK_URL } = require('./setup.js');\n\nfunction writeLocalReport(record, cwd) {\n // vm-\u003ctimestamp>; rvc-\u003ctimestamp> is accepted so earlier reports can still be finalized.\n if (typeof record.run_id !== 'string' || !/^(?:vm|rvc)-[0-9]+$/.test(record.run_id)) {\n throw new Error('invalid run_id: expected vm-\u003ctimestamp>');\n }\n const dir = path.join(cwd, '.validate-migration');\n fs.mkdirSync(dir, { recursive: true });\n const mdPath = path.join(dir, `${record.run_id}.md`);\n const jsonPath = path.join(dir, `${record.run_id}.json`);\n fs.writeFileSync(mdPath, renderLocalReport(record, dir), 'utf8');\n fs.writeFileSync(jsonPath, JSON.stringify(record, null, 2), 'utf8');\n return { mdPath, jsonPath };\n}\n\nfunction renderLocalReport(record, reportDir) {\n const reporting = record.reporting || { status: 'pending' };\n const lines = [\n '# validate-migration report',\n '',\n `- run: ${record.run_id}`,\n `- validation result: ${record.result}`,\n `- verification level: ${record.verification_level}`,\n record.classes.some((c) => c.pattern === 'custom-templates')\n ? record.classes.some((c) => c.checks?.template_comparison)\n ? '- validation scope: template package contracts and source-to-target content/rendering comparison'\n : '- validation scope: editable-template package contracts only; rendering not verified'\n : '- validation scope: migration contracts and activation only',\n '- business behavior: not tested',\n `- reporting: ${reporting.status}`,\n `- completion: ${['recorded', 'not_reported'].includes(reporting.status) ? 'complete' : 'incomplete'}`,\n '- event delivery: unconfirmed (outcome acknowledgement does not confirm downstream delivery)',\n ...(reporting.stored_at ? [`- recorded at: ${reporting.stored_at}`] : []),\n ...(reporting.error ? [`- reporting error: ${reporting.error}`] : []),\n `- started: ${record.started_at}`,\n `- finished: ${record.finished_at}`,\n '',\n '| pattern | class | result | bundle | component | failure |',\n '|---|---|---|---|---|---|',\n ];\n for (const c of record.classes || []) {\n const cls = (c.discovered && (c.discovered.fqcn || c.discovered.template)) || c.class_name || '—';\n const bundle = (c.bundle && c.bundle.state) || c.bundle_state || '—';\n lines.push(\n `| ${c.pattern || '—'} | ${cls} | ${c.result || '—'} | ${bundle} | ${c.component_state || '—'} | ${c.failure_class || '—'} |`,\n );\n }\n for (const entry of record.classes.filter((item) => item.pattern === 'custom-templates')) {\n const comparison = entry.checks?.template_comparison;\n lines.push('', '## Template Comparison', '', `- template: ${entry.discovered?.template || entry.class_name}`);\n if (!comparison) {\n lines.push('- runtime comparison: not verified', ...(entry.evidence ? [`- evidence: ${entry.evidence}`] : []));\n continue;\n }\n lines.push(`- source: ${comparison.source_url}`, `- target: ${comparison.target_url}`,\n `- captured: ${comparison.captured_at}`, `- cleanup: ${comparison.cleanup?.status || 'not_verified'}`, `- coverage: ${comparison.coverage}`);\n for (const capture of comparison.captures) {\n const directory = path.resolve(reportDir || process.cwd());\n const canonicalDirectory = fs.existsSync(directory) ? fs.realpathSync(directory)\n : path.join(fs.realpathSync(path.dirname(directory)), path.basename(directory));\n const screenshotLink = (file) => encodeURI(path.relative(canonicalDirectory, file).split(path.sep).join('/'));\n lines.push(`- ${capture.viewport.width}x${capture.viewport.height}: ${capture.result}; [source screenshot](${screenshotLink(capture.source)}); [target screenshot](${screenshotLink(capture.target)})`);\n }\n for (const difference of comparison.differences) lines.push(`- ${difference}`);\n }\n const restricted = (record.classes || []).filter((entry) => entry.restricted || entry.checks?.restricted);\n if (restricted.length) {\n lines.push('', '## Unverified Checks', '');\n for (const entry of restricted) {\n const className = entry.discovered?.fqcn || entry.class_name || entry.pattern;\n const reason = entry.restricted_reason || entry.checks?.restricted_reason || 'runtime contract not fully verified';\n lines.push(`- ${className}: ${reason}`);\n }\n }\n return lines.join('\\n') + '\\n';\n}\n\n// The receipt is the actual submit result: `ok: true` from the AEM Cloud Service Migration MCP\n// tool, `success: true` from the AEM MCP Server recipe. Either flag must be strictly true.\nfunction finalizeReporting(record, receipt) {\n const acknowledged = typeof record.run_id === 'string' && record.run_id.length > 0\n && (receipt?.ok === true || receipt?.success === true) && receipt.run_id === record.run_id;\n const error = receipt?.error\n ? 'MCP submission failed; inspect the tool response without persisting credentials'\n : 'missing or mismatched MCP acknowledgement';\n const reporting = acknowledged\n ? stripEmpty({ status: 'recorded', run_id: receipt.run_id, stored_at: receipt.stored_at, duplicate: receipt.duplicate })\n : { status: 'failed', error };\n return { ...record, reporting };\n}\n\n// ---- SDK config (never persisted to disk) ----\n// URL: honors --sdk / AEM_SDK_URL, then the saved setup, then the fixed SDK port 4602.\n// The URL only targets the Maven deployment; AEM is never contacted from here.\n// Refuses the AEM 6.5 source URL when one was given or saved.\nfunction resolveSdkUrl(args) {\n const explicit = args.sdk || process.env.AEM_SDK_URL;\n // init.js may have run from the reactor root while check.js targets a module.\n const setups = [readSetup(args.project || process.cwd()), readSetup(process.cwd())];\n const saved = setups.map((s) => s.sdk_url).find(Boolean);\n const source = args.quickstart || setups.map((s) => s.quickstart_url).find(Boolean);\n const url = String(explicit || saved || SDK_URL).replace(/\\/$/, '');\n try { new URL(url); } catch {\n console.error(`[validate-migration] invalid SDK URL: ${url}`);\n return null;\n }\n if (source && sameInstance(url, source)) {\n console.error(`[validate-migration] SDK URL ${url} is the AEM 6.5 source instance; pass the Cloud SDK URL with --sdk \u003curl>.`);\n return null;\n }\n console.log(`(${explicit ? 'explicit' : saved ? 'setup' : 'default'}) SDK = ${url}`);\n return url;\n}\n\nfunction camReportingNote(args) {\n if (args && (args['project-id'] || args['project-name'])) return null;\n return 'not reported to CAM: no project linked (run analyze against a CAM project, or pass --project-id).';\n}\n\n// ---- MCP telemetry ----\n// The version reported with each outcome is the validator's package version.\nfunction declaredSkillVersion() {\n return require('./package.json').version;\n}\n\nconst EVIDENCE_MAX = 2048;\nconst BUNDLE_STATE_ENUM = new Set(['Installed', 'Resolved', 'Active', 'Fragment', 'Unknown']);\n\nfunction normalizeBundleState(s) {\n if (!s) return 'Unknown';\n const v = String(s);\n return BUNDLE_STATE_ENUM.has(v) ? v : 'Unknown';\n}\n\n// The submit-migration-validation-result tool accepts `checks` as a flat map of\n// scalar values whose keys contain no dots. verify() may stash nested objects\n// (e.g. `properties`) — flatten one level, drop non-scalar values, and replace\n// `.` with `_` in keys.\nfunction flattenChecks(checks) {\n if (!checks || typeof checks !== 'object') return undefined;\n const safeKey = (k) => String(k).replace(/\\./g, '_');\n const out = {};\n for (const [k, v] of Object.entries(checks)) {\n if (['string', 'number', 'boolean'].includes(typeof v)) out[safeKey(k)] = v;\n else if (v && typeof v === 'object') {\n for (const [k2, v2] of Object.entries(v)) {\n if (['string', 'number', 'boolean'].includes(typeof v2)) out[`${safeKey(k)}_${safeKey(k2)}`] = v2;\n }\n }\n }\n return Object.keys(out).length ? out : undefined;\n}\n\nfunction buildMcpPayload(record, args) {\n const classes = record.classes.map(toClassEntry);\n const bundleEntry = record.classes.find((r) => r.bundle);\n const patterns = [...new Set(record.classes.map((r) => r.pattern).filter(Boolean))];\n\n const payload = {\n run_id: record.run_id,\n project_id: args['project-id'],\n project_name: args['project-name'],\n skill_pattern: args.pattern || patterns.join('+'),\n skill_version: args['skill-version'] || declaredSkillVersion(),\n result: record.result,\n verification_level: record.verification_level,\n started_at: record.started_at,\n finished_at: record.finished_at,\n summary: {\n classes_total: classes.length,\n classes_pass: record.classes.filter((r) => r.result === 'pass').length,\n classes_fail: record.classes.filter((r) => r.result === 'fail').length,\n },\n classes,\n };\n if (bundleEntry) payload.bundle = bundleEntry.bundle;\n if (record.skill_decisions) payload.skill_decisions = record.skill_decisions;\n return stripEmpty(payload);\n}\n\n// Builds one `classes[]` entry (submit-migration-validation-result schema) from one task's outcome.\nfunction validationChecks(outcome) {\n const contractFailed = [FAILURE_CLASSES.SOURCE_CONTRACT_MISMATCH, FAILURE_CLASSES.CONTRACT_MISMATCH].includes(outcome.failure_class);\n return {\n ...outcome.checks,\n ...(outcome.checks?.template_comparison ? {\n template_screenshots: JSON.stringify(outcome.checks.template_comparison.captures.map((capture) => (\n { ...capture, source: path.basename(capture.source), target: path.basename(capture.target) }))),\n template_differences: JSON.stringify(outcome.checks.template_comparison.differences),\n template_cleanup: JSON.stringify(outcome.checks.template_comparison.cleanup),\n template_component_mapping: JSON.stringify(outcome.checks.template_comparison.component_mapping),\n } : {}),\n migration_contract: outcome.result === 'pass' ? 'pass' : contractFailed ? 'fail' : 'not_verified',\n business_behavior: 'not_tested',\n };\n}\n\nfunction toClassEntry(outcome) {\n const className = (outcome.discovered && (outcome.discovered.fqcn || outcome.discovered.template)) || `(${outcome.pattern})`;\n const cls = { class_name: className, result: outcome.result };\n\n if (outcome.result === 'fail') {\n cls.failure_class = outcome.failure_class || FAILURE_CLASSES.UNKNOWN;\n if (outcome.bundle_state) cls.bundle_state = outcome.bundle_state;\n if (outcome.component_state) cls.component_state = outcome.component_state;\n if (outcome.unsatisfied_references && outcome.unsatisfied_references.length) cls.unsatisfied_references = outcome.unsatisfied_references;\n if (outcome.activation_error) cls.activation_error = outcome.activation_error;\n if (outcome.evidence) cls.evidence = truncate(outcome.evidence, EVIDENCE_MAX);\n } else {\n if (outcome.bundle_state) cls.bundle_state = outcome.bundle_state;\n if (outcome.component_state) cls.component_state = outcome.component_state;\n }\n const checks = flattenChecks(validationChecks(outcome)) || {};\n // restricted = a pass the MCP tool couldn't fully verify (e.g. scheduler DS\n // props). Record it in checks so it isn't lost as a clean pass.\n if (outcome.restricted) {\n checks.restricted = true;\n if (outcome.restricted_reason) checks.restricted_reason = outcome.restricted_reason;\n }\n if (Object.keys(checks).length) cls.checks = checks;\n return stripEmpty(cls);\n}\n\nfunction truncate(s, max) {\n if (typeof s !== 'string' || s.length \u003c= max) return s;\n return s.slice(0, max - 1) + '…';\n}\n\n// Strip both undefined and null so optional fields are omitted rather than\n// sent as null, which the tool's schema rejects.\nfunction stripEmpty(obj) {\n return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined && v !== null));\n}\n\nmodule.exports = {\n writeLocalReport,\n renderLocalReport,\n finalizeReporting,\n resolveSdkUrl,\n camReportingNote,\n normalizeBundleState,\n flattenChecks,\n buildMcpPayload,\n validationChecks,\n toClassEntry,\n truncate,\n stripEmpty,\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":12185,"content_sha256":"db4a58793a714059824e9c6e6d22b242c00b9ded74d0aac45f64a91afb40d628"},{"filename":"scripts/validate-migration/setup.js","content":"'use strict';\n\n/**\n * setup.js — phase 1 of validate-migration: remember the local instance URLs once.\n * AEM 6.5 Quickstart runs on 4502 and the Cloud SDK on 4602 unless overridden.\n * Nothing here contacts AEM: deployment goes through the project's Maven profile\n * and runtime state comes only from the AEM Quickstart MCP.\n */\nconst fs = require('fs');\nconst path = require('path');\nconst { RULES } = require('./plan.js');\nconst { canonicalPattern } = require('./check/patterns/aliases.js');\n\nconst QUICKSTART_URL = 'http://localhost:4502';\nconst SDK_URL = 'http://localhost:4602';\nconst SAVED_KEYS = ['sdk_url', 'quickstart_url'];\n\nconst trimUrl = (url) => (url ? String(url).replace(/\\/$/, '') : null);\n\n/** True when both URLs address the same host and port (loopback aliases count as one host). */\nfunction sameInstance(a, b) {\n const key = (value) => {\n const url = new URL(value);\n const host = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) ? 'loopback' : url.hostname;\n return `${host}:${url.port || (url.protocol === 'https:' ? '443' : '80')}`;\n };\n try { return key(a) === key(b); } catch { return false; }\n}\n\n/** Select prerequisites for the supported patterns, rejecting misspelled scopes. */\nfunction requiredChecks(names) {\n const patterns = names.map(canonicalPattern);\n const supported = new Set([...RULES.map((rule) => rule.pattern), 'cdw']);\n for (const pattern of patterns) {\n if (!supported.has(pattern)) throw new Error(`unknown validation pattern: ${pattern}`);\n }\n if (patterns.every((pattern) => ['legacy-ui', 'cdw'].includes(pattern))) return [];\n return ['sdk', 'mcp', ...(patterns.includes('custom-templates') ? ['quickstart'] : [])];\n}\n\nfunction setupPath(projectDir) {\n return path.join(projectDir, '.validate-migration', 'setup.json');\n}\n\nfunction readSetup(projectDir) {\n try { return JSON.parse(fs.readFileSync(setupPath(projectDir), 'utf8')); } catch { return {}; }\n}\n\n/** Saves instance URLs only; any other key (credentials) is dropped. */\nfunction writeSetup(projectDir, values) {\n const saved = Object.fromEntries(SAVED_KEYS.filter((k) => values[k]).map((k) => [k, values[k]]));\n fs.mkdirSync(path.dirname(setupPath(projectDir)), { recursive: true });\n fs.writeFileSync(setupPath(projectDir), JSON.stringify({ ...saved, confirmed_at: new Date().toISOString() }, null, 2));\n}\n\n/** Explicit flags win, then the saved setup, then the fixed ports (SDK 4602, AEM 6.5 4502). */\nfunction resolveInstances(args, saved) {\n return {\n sdk_url: trimUrl(args.sdk) || saved.sdk_url || SDK_URL,\n quickstart_url: trimUrl(args.quickstart) || saved.quickstart_url || QUICKSTART_URL,\n };\n}\n\nmodule.exports = { readSetup, writeSetup, resolveInstances, setupPath, requiredChecks, sameInstance, SDK_URL, QUICKSTART_URL };\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":2788,"content_sha256":"ba1fad28e7bbc052257d2b97e1bb37bf9a94a0b9becf2ab46fac0318e5a02148"},{"filename":"scripts/validate-migration/setup.test.js","content":"'use strict';\n\nconst { test } = require('node:test');\nconst assert = require('node:assert');\nconst fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst { spawnSync } = require('child_process');\nconst { readSetup, writeSetup, resolveInstances, sameInstance } = require('./setup.js');\n\n// Runs init.js with fetch and child-process spawning instrumented: setup must\n// never contact AEM or start an instance.\nfunction runSetup(context, pattern, { sdk = 'http://sdk.invalid:4602', quickstart = 'http://source.invalid:4502' } = {}) {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-init-'));\n context.after(() => fs.rmSync(dir, { recursive: true, force: true }));\n fs.writeFileSync(path.join(dir, 'aem-sdk-quickstart-2026.8.0.jar'), '');\n const calls = path.join(dir, 'calls.log');\n const preload = path.join(dir, 'fixture.cjs');\n fs.writeFileSync(preload, `\n const fs = require('node:fs');\n const childProcess = require('node:child_process');\n const execFileSync = childProcess.execFileSync;\n childProcess.execFileSync = (command, args, options) => {\n if (command === 'sh' && args[0] === '-c' && /^command -v (mvn|unzip)$/.test(args[1])) return '';\n return execFileSync(command, args, options);\n };\n childProcess.spawn = (command) => { fs.appendFileSync(${JSON.stringify(calls)}, 'spawn ' + command + '\\\\n'); return { unref() {} }; };\n global.fetch = async (url) => { fs.appendFileSync(${JSON.stringify(calls)}, 'fetch ' + url + '\\\\n'); throw new Error('unexpected'); };\n `);\n const execution = spawnSync(process.execPath, [\n '--require', preload, path.join(__dirname, 'init.js'), '--project', dir,\n '--pattern', pattern, '--sdk', sdk, '--quickstart', quickstart,\n ], { encoding: 'utf8' });\n const external = fs.existsSync(calls) ? fs.readFileSync(calls, 'utf8') : '';\n assert.strictEqual(external, '', 'setup never contacts AEM or starts an instance');\n const { confirmed_at: _confirmedAt, ...saved } = readSetup(dir);\n return { execution, saved };\n}\n\ntest('offline setup succeeds without either AEM instance or saved instance URLs', (context) => {\n const { execution, saved } = runSetup(context, 'legacy-ui,cdw');\n assert.strictEqual(execution.status, 0, execution.stderr);\n assert.deepStrictEqual(saved, {});\n});\n\ntest('scheduler setup saves only the SDK URL, without contacting it', (context) => {\n const { execution, saved } = runSetup(context, 'scheduler');\n assert.strictEqual(execution.status, 0, execution.stderr);\n assert.deepStrictEqual(saved, { sdk_url: 'http://sdk.invalid:4602' });\n assert.match(execution.stdout, /diagnose-osgi-bundle on http:\\/\\/sdk\\.invalid:4602\\/bin\\/mcp/);\n assert.match(execution.stdout, /does not contact them/);\n});\n\ntest('template comparison setup saves both the source and SDK URLs', (context) => {\n const { execution, saved } = runSetup(context, 'custom-templates');\n assert.strictEqual(execution.status, 0, execution.stderr);\n assert.deepStrictEqual(saved, { sdk_url: 'http://sdk.invalid:4602', quickstart_url: 'http://source.invalid:4502' });\n});\n\ntest('template comparison setup rejects a source URL that is the SDK URL', (context) => {\n const { execution, saved } = runSetup(context, 'custom-templates',\n { sdk: 'http://localhost:4502', quickstart: 'http://127.0.0.1:4502/' });\n assert.strictEqual(execution.status, 1);\n assert.match(execution.stderr, /setup not ready/);\n assert.deepStrictEqual(saved, {});\n});\n\ntest('mixed offline and runtime setup keeps the runtime prerequisites', (context) => {\n const { execution, saved } = runSetup(context, 'legacy-ui,scheduler');\n assert.strictEqual(execution.status, 0, execution.stderr);\n assert.deepStrictEqual(saved, { sdk_url: 'http://sdk.invalid:4602' });\n assert.match(execution.stdout, /MCP: your coding assistant must confirm diagnose-osgi-bundle/);\n});\n\ntest('setup rejects an invalid SDK URL', (context) => {\n const { execution, saved } = runSetup(context, 'scheduler', { sdk: 'not a url' });\n assert.strictEqual(execution.status, 1);\n assert.deepStrictEqual(saved, {});\n});\n\ntest('setup rejects an unknown pattern rather than reporting readiness', (context) => {\n const { execution } = runSetup(context, 'typo');\n assert.strictEqual(execution.status, 1);\n assert.match(execution.stderr, /unknown.*pattern/i);\n});\n\ntest('sameInstance compares host and port, treating loopback aliases as one host', () => {\n assert.strictEqual(sameInstance('http://localhost:4502', 'http://127.0.0.1:4502/'), true);\n assert.strictEqual(sameInstance('http://localhost', 'http://localhost:80'), true);\n assert.strictEqual(sameInstance('http://localhost:4602', 'http://localhost:4502'), false);\n assert.strictEqual(sameInstance('http://sdk.example:4502', 'http://localhost:4502'), false);\n assert.strictEqual(sameInstance('not a url', 'http://localhost:4502'), false);\n});\n\ntest('writeSetup saves only instance URLs, never credentials', () => {\n const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'vm-setup-'));\n try {\n assert.deepStrictEqual(readSetup(dir), {});\n writeSetup(dir, { sdk_url: 'http://localhost:4602', quickstart_url: 'http://localhost:4502', auth: 'u:p', AEM_TARGET_AUTH: 'u:p' });\n const file = path.join(dir, '.validate-migration', 'setup.json');\n assert.ok(!fs.readFileSync(file, 'utf8').includes('u:p'));\n const saved = readSetup(dir);\n assert.strictEqual(saved.sdk_url, 'http://localhost:4602');\n assert.strictEqual(saved.quickstart_url, 'http://localhost:4502');\n assert.ok(saved.confirmed_at);\n } finally { fs.rmSync(dir, { recursive: true, force: true }); }\n});\n\ntest('resolveInstances defaults to SDK on 4602 and AEM 6.5 on 4502', () => {\n assert.deepStrictEqual(resolveInstances({}, {}),\n { sdk_url: 'http://localhost:4602', quickstart_url: 'http://localhost:4502' });\n});\n\ntest('resolveInstances prefers explicit flags, then the saved setup, then the fixed ports', () => {\n assert.deepStrictEqual(resolveInstances({ sdk: 'http://h:1/', quickstart: 'http://h:2' }, { sdk_url: 'x' }),\n { sdk_url: 'http://h:1', quickstart_url: 'http://h:2' });\n assert.deepStrictEqual(resolveInstances({}, { sdk_url: 'http://s:9', quickstart_url: 'http://q:9' }),\n { sdk_url: 'http://s:9', quickstart_url: 'http://q:9' });\n assert.deepStrictEqual(resolveInstances({ sdk: 'http://h:1' }, {}),\n { sdk_url: 'http://h:1', quickstart_url: 'http://localhost:4502' });\n});\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":6409,"content_sha256":"2da9d6b7b8dee81a5562c27c4bbc33c8bf564697121631224511bee25947bcd6"},{"filename":"scripts/vault-package-scan-runner.js","content":"/**\n * Vault Package Dependencies Scan Runner\n *\n * The `pom-scan` detection tier of the runbook cascade for the\n * **vault-package-dependencies** pattern — legacy AEM 6.x Vault install-time\n * `\u003cdependencies>` declared inside the vault package plugin's `\u003cconfiguration>`\n * (either `content-package-maven-plugin` or the modern\n * `filevault-package-maven-plugin`) that CRX refuses to resolve on AEM as a\n * Cloud Service (`day/cq60/*`, `day/cq560/*`, `adobe/cq60`).\n *\n * There is no BPA subtype for this pattern at all — a `pom.xml` install-time\n * dependency declaration is invisible to a deployed-artifact BPA scan — so\n * this scan is the **only** detection tier (no cascade, no analyzer fallback).\n *\n * Detection strategy — two paths:\n *\n * 1. Preferred: ask Maven for the **effective POM** via\n * `mvn help:effective-pom`. This resolves parent inheritance,\n * `\u003cpluginManagement>` merging, and per-execution vs plugin-level\n * `\u003cconfiguration>` — the three classes of ambiguity we don't have to\n * reason about textually. Runs **once per reactor root** (not per\n * module) — the reactor invocation produces a single wrapper file\n * containing every module's effective POM, avoiding N network-bound\n * subprocesses. Requires `mvn` on PATH and, for legacy AEM 6.x/AMS\n * codebases, resolvable parent poms (often network-reachable).\n *\n * 2. Fallback: when `mvn help:effective-pom` fails for a module (dead\n * parent repo, missing artifacts, offline — the pattern's target\n * audience is legacy projects that often can't build cleanly anymore),\n * the runner text-scans the module's raw `pom.xml` as-is. This mirrors\n * the pure-Node scan that predated the Maven delegation: coverage is\n * degraded (inherited-only deps in a parent's `\u003cpluginManagement>` are\n * not detected without Maven merging them) but the scan is not silent —\n * a `warnings[]` entry is emitted for every module that fell back so\n * the customer sees the degradation.\n *\n * Scanner scope: `findVaultDependenciesInEffectivePom` skips any `\u003cplugin>`\n * nested inside `\u003cpluginManagement>`. The effective POM retains\n * pluginManagement blocks even after merging their configuration into\n * `\u003cbuild>\u003cplugins>`, so scanning both would report the same block twice.\n *\n * Source-pom line reporting: after a legacy block is detected, the runner\n * searches the customer's own `pom.xml` for the offending `\u003cgroup>` tag to\n * report an actionable line. If the block was inherited from a parent pom\n * and doesn't appear in the source, the finding is reported with line 1\n * and marked `(inherited)` in the snippet.\n *\n * Failure-mode contract: if the runner collects one or more Maven project\n * roots but produces **no scan target** for any of them (effective-POM\n * failed AND the raw pom.xml was unreadable), it returns `ok: false`. The\n * runbook dispatcher treats that as \"pattern not scanned\" and falls through\n * to the LLM-scan tier rather than reporting a clean bill of health.\n */\n\n'use strict';\n\nconst fs = require('fs');\nconst os = require('os');\nconst path = require('path');\nconst { execFileSync } = require('child_process');\n\n// Group-path prefixes whose packages don't exist on AEMaaCS.\nconst LEGACY_PREFIXES = ['day/cq60/', 'day/cq560/', 'adobe/cq60'];\n\n// Both artifactIds package the same install-time \u003cdependencies> mechanism.\n// Modern migrated projects use filevault-package-maven-plugin, so both must\n// be matched or those poms' legacy blocks are silently skipped.\nconst PLUGIN_ARTIFACTS = ['content-package-maven-plugin', 'filevault-package-maven-plugin'];\n\nfunction isLegacyGroup(group) {\n if (!group) return false;\n return LEGACY_PREFIXES.some(p => group.startsWith(p) || group === p.replace(/\\/$/, ''));\n}\n\n/** Recursively collect Maven project roots (dirs containing a `pom.xml`). */\nfunction collectMavenProjectRoots(dir, acc = []) {\n let entries;\n try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return acc; }\n const hasPom = entries.some((e) => e.isFile() && e.name === 'pom.xml');\n if (hasPom) acc.push(dir);\n for (const e of entries) {\n if (!e.isDirectory()) continue;\n if (e.name === 'node_modules' || e.name === '.git' || e.name === 'target' || e.name === 'dist') continue;\n collectMavenProjectRoots(path.join(dir, e.name), acc);\n }\n return acc;\n}\n\n/** 1-indexed line number of `index` within `content`. */\nfunction lineAt(content, index) {\n return content.slice(0, index).split('\\n').length;\n}\n\n/**\n * Ask Maven to emit the effective POM for a single `projectDir` via\n * `mvn -N help:effective-pom`. Used only as the fallback when the reactor\n * bulk invocation didn't produce output for this module — normal scans go\n * through `getReactorEffectivePoms` and cost one mvn call per reactor, not\n * per module.\n *\n * Requires `mvn` on PATH and (usually) network-reachable dependencies to\n * resolve the parent chain. Returns `{ ok: true, xml }` on success, or\n * `{ ok: false, error }` with the tail of the Maven output.\n */\nfunction getEffectivePom(projectDir) {\n const pom = path.join(projectDir, 'pom.xml');\n if (!fs.existsSync(pom)) return { ok: false, error: `no pom.xml at ${projectDir}` };\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vault-eff-'));\n const outFile = path.join(tmpDir, 'effective-pom.xml');\n try {\n execFileSync('mvn', ['-q', '-B', '-N', 'help:effective-pom', `-Doutput=${outFile}`],\n { cwd: projectDir, stdio: 'pipe', encoding: 'utf8' });\n const xml = fs.readFileSync(outFile, 'utf8');\n return { ok: true, xml };\n } catch (err) {\n const combined = String(err.stdout || '') + String(err.stderr || err.message || '');\n return { ok: false, error: combined.trim().split('\\n').slice(-8).join('\\n') };\n } finally {\n try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }\n }\n}\n\n/**\n * Ask Maven to emit effective POMs for every module in a reactor in a\n * single `mvn help:effective-pom` invocation (no `-N`). Maven writes one\n * output file that wraps every module's `\u003cproject>` in a `\u003cprojects>` root\n * element; this splits that file into a map keyed by each module's\n * artifactId so `runVaultPackageScan` can pair modules to their effective\n * POM without an additional mvn call per module.\n *\n * @returns {{ok: true, byArtifactId: Record\u003cstring, string>} | {ok: false, error: string}}\n */\nfunction getReactorEffectivePoms(reactorRoot) {\n const pom = path.join(reactorRoot, 'pom.xml');\n if (!fs.existsSync(pom)) return { ok: false, error: `no pom.xml at ${reactorRoot}` };\n const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'vault-eff-reactor-'));\n const outFile = path.join(tmpDir, 'effective-pom.xml');\n try {\n execFileSync('mvn', ['-q', '-B', 'help:effective-pom', `-Doutput=${outFile}`],\n { cwd: reactorRoot, stdio: 'pipe', encoding: 'utf8' });\n const bulk = fs.readFileSync(outFile, 'utf8');\n return { ok: true, byArtifactId: splitReactorEffectivePom(bulk) };\n } catch (err) {\n const combined = String(err.stdout || '') + String(err.stderr || err.message || '');\n return { ok: false, error: combined.trim().split('\\n').slice(-8).join('\\n') };\n } finally {\n try { fs.rmSync(tmpDir, { recursive: true, force: true }); } catch { /* best effort */ }\n }\n}\n\n/**\n * Split a reactor `mvn help:effective-pom` output (a `\u003cprojects>`-wrapped\n * list of top-level `\u003cproject>` children, or a single `\u003cproject>` when only\n * one module) into per-module effective POM XML keyed by artifactId. The\n * artifactId picked is the FIRST `\u003cartifactId>` in each `\u003cproject>` after\n * stripping any `\u003cparent>` block.\n */\nfunction splitReactorEffectivePom(bulkXml) {\n const byArtifactId = {};\n const re = /\u003cproject\\b[\\s\\S]*?\u003c\\/project>/g;\n const projects = bulkXml.match(re) || [];\n for (const projectXml of projects) {\n const stripped = projectXml.replace(/\u003cparent[\\s\\S]*?\u003c\\/parent>/g, '');\n const m = stripped.match(/\u003cartifactId>([^\u003c]+)\u003c\\/artifactId>/);\n if (m) byArtifactId[m[1].trim()] = projectXml;\n }\n return byArtifactId;\n}\n\n/**\n * Read the module's own artifactId from a raw source `pom.xml`. Returns\n * null if the file can't be read or no artifactId is declared (rare — an\n * artifactId is mandatory in a Maven project).\n */\nfunction readSourceArtifactId(pomFile) {\n try {\n const xml = fs.readFileSync(pomFile, 'utf8');\n const withoutParent = xml.replace(/\u003cparent[\\s\\S]*?\u003c\\/parent>/g, '');\n const m = withoutParent.match(/\u003cartifactId>([^\u003c]+)\u003c\\/artifactId>/);\n return m ? m[1].trim() : null;\n } catch { return null; }\n}\n\n/** True iff the source pom declares a `\u003cmodules>` list (reactor root). */\nfunction isReactorRoot(projectDir) {\n try {\n const xml = fs.readFileSync(path.join(projectDir, 'pom.xml'), 'utf8');\n return /\u003cmodules>[\\s\\S]*?\u003cmodule>[\\s\\S]*?\u003c\\/module>[\\s\\S]*?\u003c\\/modules>/.test(xml);\n } catch { return false; }\n}\n\n/**\n * Find every legacy `\u003cdependencies>` block inside a vault package plugin's\n * plugin-level `\u003cconfiguration>` in the given effective POM XML (also used\n * as the text-scan fallback against a raw source pom.xml).\n *\n * The effective POM has already merged `\u003cpluginManagement>` inheritance and\n * separated per-execution configs from plugin-level configs, so this scan\n * only needs to look at each `\u003cplugin>` occurrence's direct\n * `\u003cconfiguration>` (everything before `\u003cexecutions>` inside the plugin) —\n * AND must **skip** `\u003cplugin>` entries nested under `\u003cpluginManagement>`.\n * Maven retains pluginManagement blocks in the effective POM even after\n * merging their config into `\u003cbuild>\u003cplugins>`; without this skip the same\n * block is reported once from pluginManagement and once from the merged\n * `\u003cbuild>\u003cplugins>` entry.\n *\n * Profile-scoped plugins live under `\u003cprofiles>\u003cprofile>\u003cbuild>\u003cplugins>` and\n * appear as separate `\u003cplugin>` occurrences here — no special handling\n * needed since we iterate every artifactId match in the whole document.\n *\n * @returns {Array\u003c{block: string, artifact: string}>}\n */\nfunction findVaultDependenciesInEffectivePom(effectiveXml) {\n const occurrences = [];\n for (const artifact of PLUGIN_ARTIFACTS) {\n const needle = `\u003cartifactId>${artifact}\u003c/artifactId>`;\n let from = 0;\n for (;;) {\n const idx = effectiveXml.indexOf(needle, from);\n if (idx === -1) break;\n occurrences.push({ artifactIdx: idx, artifact });\n from = idx + needle.length;\n }\n }\n occurrences.sort((a, b) => a.artifactIdx - b.artifactIdx);\n\n const results = [];\n for (const { artifactIdx, artifact } of occurrences) {\n const pluginStart = effectiveXml.lastIndexOf('\u003cplugin>', artifactIdx);\n const pluginEnd = effectiveXml.indexOf('\u003c/plugin>', artifactIdx);\n if (pluginStart === -1 || pluginEnd === -1) continue;\n\n // Skip \u003cplugin>s nested inside \u003cpluginManagement>. The effective POM\n // retains pluginManagement blocks even after merging their config into\n // \u003cbuild>\u003cplugins>, so without this the same block is reported twice.\n const pmStart = effectiveXml.lastIndexOf('\u003cpluginManagement>', pluginStart);\n if (pmStart !== -1) {\n const pmEnd = effectiveXml.indexOf('\u003c/pluginManagement>', pmStart);\n if (pmEnd !== -1 && pmEnd > pluginEnd) continue;\n }\n\n const pluginBlock = effectiveXml.slice(pluginStart, pluginEnd);\n\n // Plugin-level \u003cconfiguration> is anything before \u003cexecutions>. Per-\n // execution configs live under \u003cexecutions> and don't own install-time\n // \u003cdependencies> — those are a plugin-level concern.\n const executionsAt = pluginBlock.indexOf('\u003cexecutions>');\n const pluginLevel = executionsAt === -1 ? pluginBlock : pluginBlock.slice(0, executionsAt);\n\n const configStart = pluginLevel.indexOf('\u003cconfiguration>');\n const configEnd = pluginLevel.indexOf('\u003c/configuration>', configStart);\n if (configStart === -1 || configEnd === -1) continue;\n const configBlock = pluginLevel.slice(configStart, configEnd);\n\n const depsStart = configBlock.indexOf('\u003cdependencies>');\n const depsEnd = configBlock.indexOf('\u003c/dependencies>');\n if (depsStart === -1 || depsEnd === -1) continue;\n\n results.push({ block: configBlock.slice(depsStart, depsEnd), artifact });\n }\n return results;\n}\n\n/**\n * Report the source `pom.xml` line where `legacyGroup` first appears. When\n * the block was inherited from a parent pom and the group isn't in the\n * source, returns `{ line: 1, inherited: true }` so the finding still has an\n * anchor the customer can open.\n */\nfunction locateInSourcePom(sourceXml, legacyGroup) {\n const idx = sourceXml.indexOf(`\u003cgroup>${legacyGroup}\u003c/group>`);\n if (idx === -1) return { line: 1, inherited: true };\n return { line: lineAt(sourceXml, idx), inherited: false };\n}\n\n/**\n * Scan Maven projects under `workspaceRoot` for legacy Vault install-time\n * package dependencies. Emits one finding per `\u003cdependencies>` block (not\n * per `\u003cdependency>` entry) — the fix removes the entire block.\n *\n * Preferred source for the scan is the effective POM (Maven has already\n * done pluginManagement / parent inheritance / per-execution config merging\n * for us). To keep this efficient the runner:\n *\n * 1. Groups project roots under reactor roots (a `pom.xml` declaring\n * `\u003cmodules>`) and runs `mvn help:effective-pom` **once per reactor**,\n * splitting the resulting `\u003cprojects>` file back into per-module XML.\n * 2. Falls back to `mvn -N help:effective-pom` per module for standalone\n * poms not in any reactor, and for reactor members that the reactor\n * bulk output didn't cover.\n * 3. Falls back to a raw text-scan of the source `pom.xml` for every\n * module whose effective-POM resolution failed — legacy AEM 6.x/AMS\n * projects frequently can't resolve a build anymore (dead repos,\n * missing parents, offline). Text-scan is degraded (inherited-only\n * pluginManagement config isn't visible without Maven), and every\n * fallback emits a warning so the customer knows the scan was\n * degraded rather than silently clean.\n *\n * If every project root produced no scan target (mvn failed AND the raw\n * pom.xml was unreadable), the runner returns `ok: false`. The runbook\n * dispatcher treats that as \"pattern not scanned\" and falls through to the\n * LLM-scan tier, so the user never sees \"no vault-package findings\" from a\n * scan that couldn't run at all.\n *\n * @param {string} workspaceRoot\n * @param {object} [opts]\n * @param {function(string): {ok: boolean, xml?: string, error?: string}} [opts.getEffectivePom]\n * Injectable per-module Maven effective-pom reader. Defaults to\n * shelling out to `mvn -N help:effective-pom`; tests inject a stub\n * that returns canned XML. Injecting this also disables the reactor\n * bulk path so unit tests don't need to stub two boundaries.\n * @param {function(string): {ok: boolean, byArtifactId?: object, error?: string}} [opts.getReactorEffectivePoms]\n * Injectable reactor-wide Maven effective-pom reader. Defaults to\n * `getReactorEffectivePoms`; overriding lets a caller test the\n * reactor path without a real Maven runtime.\n * @returns {{\n * ok: boolean,\n * findings: Array\u003c{location: string, detail: string, severity: string}>,\n * rawFindings: Array\u003c{pattern: string, file: string, line: number, snippet: string}>,\n * warnings: string[],\n * error?: string,\n * }}\n */\nfunction runVaultPackageScan(workspaceRoot, opts = {}) {\n if (!workspaceRoot) {\n return { ok: false, findings: [], rawFindings: [], warnings: [], error: 'no workspaceRoot' };\n }\n const readEffectivePom = opts.getEffectivePom || getEffectivePom;\n const readReactor = opts.getReactorEffectivePoms || getReactorEffectivePoms;\n // If tests inject only a per-module reader, don't try the reactor path —\n // tests then don't need to stub two boundaries just to keep quiet.\n const reactorEnabled = !opts.getEffectivePom || !!opts.getReactorEffectivePoms;\n\n let projectRoots;\n try {\n projectRoots = collectMavenProjectRoots(workspaceRoot);\n } catch (err) {\n return { ok: false, findings: [], rawFindings: [], warnings: [], error: err.message };\n }\n\n const findings = [];\n const rawFindings = [];\n const warnings = [];\n\n // effByProject maps absolute projectDir → effective (or raw-fallback) XML.\n const effByProject = new Map();\n\n // Group projects by reactor: the topmost projectRoot with \u003cmodules> claims\n // itself and every projectRoot underneath its directory.\n const reactorRoots = reactorEnabled ? projectRoots.filter(isReactorRoot) : [];\n reactorRoots.sort((a, b) => a.length - b.length);\n const claimed = new Set();\n const reactorGroups = [];\n for (const rr of reactorRoots) {\n if (claimed.has(rr)) continue;\n const members = projectRoots.filter(p => p === rr || p.startsWith(rr + path.sep));\n for (const m of members) claimed.add(m);\n reactorGroups.push({ root: rr, members });\n }\n\n // Reactor bulk pass — one mvn per reactor, split output per module.\n for (const { root, members } of reactorGroups) {\n const bulk = readReactor(root);\n if (!bulk.ok) {\n warnings.push(`${root}: reactor mvn help:effective-pom failed (${bulk.error}); falling back to per-module scan`);\n continue;\n }\n for (const m of members) {\n const artifactId = readSourceArtifactId(path.join(m, 'pom.xml'));\n if (artifactId && bulk.byArtifactId[artifactId]) {\n effByProject.set(m, bulk.byArtifactId[artifactId]);\n }\n }\n }\n\n // Per-module pass — anything not covered by a reactor bulk. This includes\n // standalone project dirs AND reactor members the bulk didn't emit for.\n for (const projectDir of projectRoots) {\n if (effByProject.has(projectDir)) continue;\n const eff = readEffectivePom(projectDir);\n if (eff.ok) { effByProject.set(projectDir, eff.xml); continue; }\n\n // Text-scan fallback: use the raw source pom as the scan target.\n // Legitimate for detecting blocks declared IN this pom; blind to\n // parent pluginManagement Maven would have merged in — that trade-off\n // is called out in the warning.\n try {\n const rawXml = fs.readFileSync(path.join(projectDir, 'pom.xml'), 'utf8');\n warnings.push(`${projectDir}: mvn help:effective-pom failed (${eff.error}); text-scanning raw pom.xml (inherited pluginManagement will not be detected)`);\n effByProject.set(projectDir, rawXml);\n } catch (readErr) {\n warnings.push(`${projectDir}: mvn help:effective-pom failed (${eff.error}) and raw pom.xml unreadable (${readErr.message}); pattern not scanned for this module`);\n }\n }\n\n for (const projectDir of projectRoots) {\n const effXml = effByProject.get(projectDir);\n if (!effXml) continue;\n\n const blocks = findVaultDependenciesInEffectivePom(effXml);\n if (blocks.length === 0) continue;\n\n const pomFile = path.join(projectDir, 'pom.xml');\n let sourceXml;\n try { sourceXml = fs.readFileSync(pomFile, 'utf8'); } catch { sourceXml = ''; }\n\n for (const depsBlock of blocks) {\n const groupMatches = depsBlock.block.match(/\u003cgroup>([^\u003c]*)\u003c\\/group>/g) || [];\n const legacyGroup = groupMatches\n .map(m => m.replace(/\u003c\\/?group>/g, '').trim())\n .find(isLegacyGroup);\n if (!legacyGroup) continue;\n\n const { line, inherited } = locateInSourcePom(sourceXml, legacyGroup);\n const source = inherited ? ' (inherited)' : '';\n const snippet = `${depsBlock.artifact}: legacy Vault dependency group=${legacyGroup}${source}`;\n findings.push({ location: `${pomFile}:${line}`, detail: snippet, severity: 'high' });\n rawFindings.push({ pattern: 'vault-package-dependencies', file: pomFile, line, snippet });\n }\n }\n\n // False-clean guard: if we found modules but couldn't produce a scan\n // target for a single one of them, do NOT report ok:true / zero findings —\n // the runbook dispatcher would then mark the pattern as scanned and the\n // user would see \"no vault dependency issues\" when nothing ran. Return\n // ok:false so the pattern falls through to the LLM-scan tier.\n if (projectRoots.length > 0 && effByProject.size === 0) {\n return {\n ok: false,\n findings: [],\n rawFindings: [],\n warnings,\n error: 'mvn help:effective-pom failed for every module and no raw pom.xml was readable — pattern not scanned',\n };\n }\n\n return { ok: true, findings, rawFindings, warnings };\n}\n\nmodule.exports = {\n runVaultPackageScan,\n isLegacyGroup,\n findVaultDependenciesInEffectivePom,\n getEffectivePom,\n getReactorEffectivePoms,\n splitReactorEffectivePom,\n collectMavenProjectRoots,\n isReactorRoot,\n};\n","content_type":"text/javascript; charset=utf-8","language":"javascript","size":20656,"content_sha256":"9bf9f1b4e8119b260732c95659484e1c977b92df1ab058eb1e902aac2791b185"}],"content_json":{"type":"doc","content":[{"type":"heading","attrs":{"level":1},"content":[{"text":"AEM as a Cloud Service — Code Migration","type":"text"}]},{"type":"paragraph","content":[{"text":"Source → target:","type":"text","marks":[{"type":"strong"}]},{"text":" Legacy ","type":"text"},{"text":"AEM 6.x / AMS / on-prem","type":"text","marks":[{"type":"strong"}]},{"text":" → ","type":"text"},{"text":"AEM as a Cloud Service","type":"text","marks":[{"type":"strong"}]},{"text":". Scoped under ","type":"text"},{"text":"skills/aem/cloud-service/skills/migration/","type":"text","marks":[{"type":"code_inline"}]},{"text":" so this is not confused with Edge Delivery or 6.5 LTS.","type":"text"}]},{"type":"paragraph","content":[{"text":"This skill drives the ","type":"text"},{"text":"migration workflow","type":"text","marks":[{"type":"strong"}]},{"text":": BPA data, CAM/MCP, ","type":"text"},{"text":"one pattern per session","type":"text","marks":[{"type":"strong"}]},{"text":", and target discovery. ","type":"text"},{"text":"Transformation rules and steps","type":"text","marks":[{"type":"strong"}]},{"text":" live in the ","type":"text"},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" skill — once a finding's pattern is identified, hand off to ","type":"text"},{"text":"{code-assessment}/\u003cpattern>/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or the relevant shared reference under ","type":"text"},{"text":"{code-assessment}/references/","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"paragraph","content":[{"text":"Setup:","type":"text","marks":[{"type":"strong"}]},{"text":" Use the ","type":"text"},{"text":"aem-cloud-service","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" install (see repository root ","type":"text"},{"text":"README","type":"text","marks":[{"type":"strong"}]},{"text":") so both ","type":"text"},{"text":"migration","type":"text","marks":[{"type":"strong"}]},{"text":" and ","type":"text"},{"text":"code-assessment","type":"text","marks":[{"type":"strong"}]},{"text":" paths are available. If you already have the monorepo open with resolvable ","type":"text"},{"text":"{code-assessment}","type":"text","marks":[{"type":"code_inline"}]},{"text":" paths, no separate install step is required.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick start (for the person driving the agent)","type":"text"}]},{"type":"paragraph","content":[{"text":"One pattern per chat/session","type":"text","marks":[{"type":"strong"}]},{"text":" — if you ask to \"fix everything,\" the skill will ask you to pick first (e.g. scheduler vs replication vs htlLint).","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"You have…","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Say something like…","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"What happens","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A whole project to assess","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Review my code for AEMaaCS migration\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Generates read-only ","type":"text"},{"text":"migration-runbook.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"all","type":"text","marks":[{"type":"strong"}]},{"text":" migration patterns (Java cascade + ","type":"text"},{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"osgiConfig","type":"text","marks":[{"type":"code_inline"}]},{"text":"), affected files, sample prompts. ","type":"text"},{"text":"No edits.","type":"text","marks":[{"type":"strong"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"A ","type":"text"},{"text":"BPA CSV","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"scheduler","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings using ","type":"text","marks":[{"type":"em"}]},{"text":"./path/to/bpa.csv","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":"\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Fastest path: CSV → cached collection → files","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"CAM + MCP","type":"text","marks":[{"type":"strong"}]},{"text":" only","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Get ","type":"text","marks":[{"type":"em"}]},{"text":"scheduler","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings from CAM; I'll pick the project when you list them.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent lists projects → you confirm → MCP fetch (","type":"text"},{"text":"cam-mcp.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/cam-mcp.md","title":null}}]},{"text":")","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Just a few files","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Migrate ","type":"text","marks":[{"type":"em"}]},{"text":"scheduler","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" in ","type":"text","marks":[{"type":"em"}]},{"text":"core/.../MyJob.java","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":"\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Manual flow: no BPA required","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"OSGi → Cloud Manager","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"","type":"text","marks":[{"type":"em"}]},{"text":"Scan my config files and create Cloud Manager environment secrets or variables.","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":"\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent ","type":"text"},{"text":"auto-reads","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"references/osgi-cfg-json-cloud-manager.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/osgi-cfg-json-cloud-manager.md","title":null}}]},{"text":" (full Adobe-aligned rules inlined there); no BPA pattern id","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"HTL lint warnings","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"htlLint","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" issues in ","type":"text","marks":[{"type":"em"}]},{"text":"ui.apps","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":"\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Proactive discovery via ","type":"text"},{"text":"rg","type":"text","marks":[{"type":"code_inline"}]},{"text":" → fix per the HTL lint reference","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Vault package dependencies","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"vault-package-dependencies","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Package install fails on AEMaaCS.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent reads ","type":"text"},{"text":"references/vault-package-dependencies.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/vault-package-dependencies.md","title":null}}]},{"text":" — heuristic ","type":"text"},{"text":"pom.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" scan (no BPA subtype, no analyzer) for ","type":"text"},{"text":"day/cq60/product:*","type":"text","marks":[{"type":"code_inline"}]},{"text":" install-time deps in ","type":"text"},{"text":"content-package-maven-plugin","type":"text","marks":[{"type":"code_inline"}]},{"text":"; removes the whole ","type":"text"},{"text":"\u003cdependencies>","type":"text","marks":[{"type":"code_inline"}]},{"text":" block. Not a ","type":"text"},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern — this dependency shape never occurs in native AEMaaCS code.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Oak index findings (OID)","type":"text","marks":[{"type":"strong"}]},{"text":" [BETA]","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"oakIndex","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings using ","type":"text","marks":[{"type":"em"}]},{"text":"./path/to/bpa.csv","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":"\"","type":"text","marks":[{"type":"em"}]},{"text":" — covers ","type":"text"},{"text":"index.rule.violation","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"standard.index.modification","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Invokes Adobe's ","type":"text"},{"text":"@adobe/aem-cs-source-migration-index-converter","type":"text","marks":[{"type":"code_inline"}]},{"text":" per ","type":"text"},{"text":"references/oak-index.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/oak-index.md","title":null}}]},{"text":"; computes the next available ","type":"text"},{"text":"-custom-N","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead of assuming ","type":"text"},{"text":"-custom-1","type":"text","marks":[{"type":"code_inline"}]},{"text":"; shows diff + type-compatibility check in IDE; validates with ","type":"text"},{"text":"mvn","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"aemanalyser","type":"text","marks":[{"type":"code_inline"}]},{"text":". ","type":"text"},{"text":"Branch F.","type":"text","marks":[{"type":"strong"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Template modernization","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"","type":"text","marks":[{"type":"em"}]},{"text":"Migrate my static templates to editable templates and generate Modernize Tools rules.","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":"\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Create editable templates from my static templates.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Generate AEM Modernize Tools structure/component/policy rules.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent ","type":"text"},{"text":"auto-reads","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"references/template-modernization/template-modernization-context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/template-modernization-context.md","title":null}}]},{"text":" (shared discovery + structured context), produces a ","type":"text"},{"text":"per-template plan table","type":"text","marks":[{"type":"strong"}]},{"text":", then executes the plan using ","type":"text"},{"text":"editable-template-creation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/editable-template-creation.md","title":null}}]},{"text":" and ","type":"text"},{"text":"aem-modernization.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/aem-modernization.md","title":null}}]},{"text":", and validates via ","type":"text"},{"text":"template-modernization-validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/template-modernization-validation.md","title":null}}]},{"text":". No BPA pattern id.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dialog migration","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Convert my Classic UI / ExtJS dialogs to Touch UI.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Upgrade Coral 2 dialogs to Coral 3.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Fix LUI dialog findings.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent reads ","type":"text"},{"text":"references/legacy-ui/dialog/context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/context.md","title":null}}]},{"text":" — filters BPA LUI to dialog sub-types, converts via ","type":"text"},{"text":"extjs-to-coral3.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/extjs-to-coral3.md","title":null}}]},{"text":" or ","type":"text"},{"text":"coral2-to-coral3.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/coral2-to-coral3.md","title":null}}]},{"text":", validates via ","type":"text"},{"text":"validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/validation.md","title":null}}]},{"text":". BPA pattern id: ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Custom widget migration","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Fix my CDW findings.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Migrate custom ExtJS widgets to Coral 3.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent reads ","type":"text"},{"text":"references/legacy-ui/cdw/context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/cdw/context.md","title":null}}]},{"text":" — inventories xtypes, maps or scaffolds Granite UI components via ","type":"text"},{"text":"conversion.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/cdw/conversion.md","title":null}}]},{"text":", validates via ","type":"text"},{"text":"validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/cdw/validation.md","title":null}}]},{"text":". BPA pattern id: ","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":". Run CDW before dialog migration when both are needed.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Guava cache warnings","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"guavaCache","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings using BPA CSV.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Swap Guava cache for Caffeine.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent reads ","type":"text"},{"text":"references/guava-cache.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/guava-cache.md","title":null}}]},{"text":" — BPA is the source of truth (subtype ","type":"text"},{"text":"custom.guava.cache","type":"text","marks":[{"type":"code_inline"}]},{"text":"); one finding per ","type":"text"},{"text":"bundle","type":"text","marks":[{"type":"strong"}]},{"text":", not per Guava-internal class row. BPA pattern id: ","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":". Not a ","type":"text"},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern — Guava cache usage only occurs in pre-migration code, never native AEMaaCS code.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"InputStream advisories","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"inputStream","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Triage the ","type":"text","marks":[{"type":"em"}]},{"text":"java.io.InputStream","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" warnings in my BPA report.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent reads ","type":"text"},{"text":"references/input-stream.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/input-stream.md","title":null}}]},{"text":" and runs the deterministic scanner ","type":"text"},{"text":"scripts/input-stream-runner.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" (pure Node). Adobe documents the risk as binary data streamed through the JVM, fixed by direct binary upload and CDN delivery. The scanner produces one finding per file, ordered by risk, with every stream site pre-classified (F deserialization / E binary streamed or processed in the JVM / D local filesystem / C unbounded read / B unclosed stream or undisposed ","type":"text"},{"text":"Binary","type":"text","marks":[{"type":"code_inline"}]},{"text":" / A benign) and tagged with a fix kind (","type":"text"},{"text":"decision","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"review","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"auto","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Same precedence as the other BPA patterns: BPA findings first (CAM, then a BPA CSV, subtype ","type":"text"},{"text":"java.io.inputstream","type":"text","marks":[{"type":"code_inline"}]},{"text":"), whose classes the scanner resolves to workspace files for line-level sites, with classes outside the workspace collapsed to one informational row per bundle (customer code kept elsewhere, platform-provided AEM Forms add-on, or third-party); the whole workspace is scanned only when BPA has no ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings. The agent proposes tier-aware fixes for D/E/F, fixes unclosed streams, and verifies each edit with a ","type":"text"},{"text":"--files","type":"text","marks":[{"type":"code_inline"}]},{"text":" re-scan. BPA pattern id: ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":". Not a ","type":"text"},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern, since BPA only raises it for code carried over from legacy AEM.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Dispatcher conversion","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Convert my AMS / on-prem Dispatcher config to AEM as a Cloud Service.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent reads ","type":"text"},{"text":"references/dispatcher/context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/context.md","title":null}}]},{"text":" — detects the config ","type":"text"},{"text":"mode","type":"text","marks":[{"type":"strong"}]},{"text":", generates the tool config, runs Adobe's ","type":"text"},{"text":"dispatcher-converter","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"verifies","type":"text","marks":[{"type":"strong"}]},{"text":" output (filter/ACL hard-gate), and validates. ","type":"text"},{"text":"Branch E.","type":"text","marks":[{"type":"strong"}]},{"text":" Runbook pattern id: ","type":"text"},{"text":"dispatcherConversion","type":"text","marks":[{"type":"code_inline"}]},{"text":" (heuristic).","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Validate a migration","type":"text","marks":[{"type":"strong"}]},{"text":" [BETA]","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"\"Validate my migration on the local Cloud SDK.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Verify the scheduler fix before I raise a PR.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Agent reads ","type":"text"},{"text":"references/validation/validate-migration.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/validation/validate-migration.md","title":null}}]},{"text":" — builds, deploys to the running local SDK, checks bundle/component state via the AEM Quickstart MCP, and reports the outcome to CAM when a project is linked. ","type":"text"},{"text":"Branch G.","type":"text","marks":[{"type":"strong"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Starter prompts (copy-paste)","type":"text","marks":[{"type":"strong"}]},{"text":" — the Quick start table above covers each pattern individually; these add the whole-project entry point, source-specific invocations (CSV / CAM / manual), and multi-step combinations:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Review my code for AEMaaCS migration\"","type":"text","marks":[{"type":"em"}]},{"text":" — ","type":"text"},{"text":"start here","type":"text","marks":[{"type":"strong"}]},{"text":" for a full runbook before changing anything.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Use the migration skill: ","type":"text","marks":[{"type":"em"}]},{"text":"scheduler","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" only, BPA CSV at ","type":"text","marks":[{"type":"em"}]},{"text":"./reports/bpa.csv","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":".\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"","type":"text","marks":[{"type":"em"}]},{"text":"Replication","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" only from CAM; list projects first, I'll pick one.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"","type":"text","marks":[{"type":"em"}]},{"text":"Manual:","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" ","type":"text","marks":[{"type":"em"}]},{"text":"event listener","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" migration for ","type":"text","marks":[{"type":"em"}]},{"text":".../Listener.java","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":".\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"oakIndex","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings using BPA CSV at ","type":"text","marks":[{"type":"em"}]},{"text":"./reports/bpa.csv","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":".\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Fix all Classic UI and custom widget findings — CDW first, then dialogs.\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"guavaCache","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings using BPA CSV at ","type":"text","marks":[{"type":"em"}]},{"text":"./reports/bpa.csv","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":".\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"\"Fix ","type":"text","marks":[{"type":"em"}]},{"text":"inputStream","type":"text","marks":[{"type":"strong"},{"type":"em"}]},{"text":" findings using BPA CSV at ","type":"text","marks":[{"type":"em"}]},{"text":"./reports/bpa.csv","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":".\"","type":"text","marks":[{"type":"em"}]}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Path convention (Adobe Skills monorepo)","type":"text"}]},{"type":"paragraph","content":[{"text":"From the ","type":"text"},{"text":"repository root","type":"text","marks":[{"type":"strong"}]},{"text":" (parent of the ","type":"text"},{"text":"skills/","type":"text","marks":[{"type":"code_inline"}]},{"text":" directory):","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Symbol","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Path","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"{code-assessment}","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"skills/aem/cloud-service/skills/code-assessment/","type":"text","marks":[{"type":"code_inline"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Examples: ","type":"text"},{"text":"{code-assessment}/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{code-assessment}/scheduler/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"{code-assessment}/references/scr-to-osgi-ds.md","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workspace scope (IDE) — user code only","type":"text"}]},{"type":"paragraph","content":[{"text":"Applies to ","type":"text"},{"text":"finding and editing the user's AEM project","type":"text","marks":[{"type":"strong"}]},{"text":" (Java, bundles, config, HTL), not to reading installed skill files under ","type":"text"},{"text":"{code-assessment}","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Treat the ","type":"text"},{"text":"current IDE workspace root folder(s)","type":"text","marks":[{"type":"strong"}]},{"text":" (single- or multi-root) as the ","type":"text"},{"text":"only","type":"text","marks":[{"type":"strong"}]},{"text":" boundary for searches, globs, ","type":"text"},{"text":"grep","type":"text","marks":[{"type":"code_inline"}]},{"text":", and file reads/writes for migration targets.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Do not","type":"text","marks":[{"type":"strong"}]},{"text":" search parent directories, sibling folders on disk, ","type":"text"},{"text":"~","type":"text","marks":[{"type":"code_inline"}]},{"text":", other clones, or arbitrary absolute paths to \"discover\" sources unless the user ","type":"text"},{"text":"explicitly","type":"text","marks":[{"type":"strong"}]},{"text":" names those paths or asks you to include them.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BPA CSV / CAM targets:","type":"text","marks":[{"type":"strong"}]},{"text":" If a ","type":"text"},{"text":"filePath","type":"text","marks":[{"type":"code_inline"}]},{"text":" or class-to-file mapping does not resolve under a workspace root, ","type":"text"},{"text":"stop","type":"text","marks":[{"type":"strong"}]},{"text":" and tell the user which paths are missing — do not hunt elsewhere on the filesystem. Ask them to open the correct project in the IDE or adjust paths.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Manual flow:","type":"text","marks":[{"type":"strong"}]},{"text":" Only migrate files the user named that live under the workspace (or paths they explicitly provided). Do not expand scope by searching outside the workspace.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Required delegation (do this first)","type":"text"}]},{"type":"paragraph","content":[{"text":"Branch A — OSGi configs → Cloud Manager","type":"text","marks":[{"type":"strong"}]},{"text":" (no Java BPA pattern this session): If the user asks to ","type":"text"},{"text":"scan config files","type":"text","marks":[{"type":"strong"}]},{"text":", ","type":"text"},{"text":"create / set up Cloud Manager environment secrets or variables","type":"text","marks":[{"type":"strong"}]},{"text":", move ","type":"text"},{"text":"passwords or secrets","type":"text","marks":[{"type":"strong"}]},{"text":" out of ","type":"text"},{"text":"OSGi / ","type":"text","marks":[{"type":"strong"}]},{"text":".cfg.json","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" / ","type":"text","marks":[{"type":"strong"}]},{"text":"ui.config","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", or mentions ","type":"text"},{"text":"$[secret:]","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" / ","type":"text"},{"text":"$[env:]","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for AEM CS, then ","type":"text"},{"text":"read ","type":"text","marks":[{"type":"strong"}]},{"text":"references/osgi-cfg-json-cloud-manager.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/osgi-cfg-json-cloud-manager.md","title":null}},{"type":"strong"}]},{"text":" immediately","type":"text","marks":[{"type":"strong"}]},{"text":" and follow the ","type":"text"},{"text":"product rules and workflow","type":"text","marks":[{"type":"strong"}]},{"text":" defined in that file (Adobe AEM as a Cloud Service OSGi + Cloud Manager behavior is reproduced there—no external doc URL required). Sleek prompts are enough — ","type":"text"},{"text":"no","type":"text","marks":[{"type":"strong"}]},{"text":" need to name the reference file. ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" branch B for that work.","type":"text"}]},{"type":"paragraph","content":[{"text":"Branch B — Java / HTL / BPA pattern migration:","type":"text","marks":[{"type":"strong"}]}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"{code-assessment}/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — critical rules, Java baseline links, ","type":"text"},{"text":"Pattern Guides","type":"text","marks":[{"type":"strong"}]},{"text":" table, ","type":"text"},{"text":"Manual Pattern Hints","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read the ","type":"text"},{"text":"pattern guide","type":"text","marks":[{"type":"strong"}]},{"text":" (or reference) for the ","type":"text"},{"text":"single","type":"text","marks":[{"type":"strong"}]},{"text":" active pattern:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"scheduler","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"{code-assessment}/scheduler/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(pattern guide)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"resourceChangeListener","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"{code-assessment}/resource-change-listener/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(pattern guide)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"replication","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"{code-assessment}/replication/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(pattern guide)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"eventListener","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"eventHandler","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"{code-assessment}/event-migration/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(pattern guide — both JCR and OSGi Event Admin paths)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"assetApi","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"{code-assessment}/asset-manager/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(pattern guide)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"{code-assessment}/references/data-sly-test-redundant-constant.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(reference — HTL lint is a single shared reference, not a dedicated pattern guide)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"vault-package-dependencies","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"references/vault-package-dependencies.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/vault-package-dependencies.md","title":null}},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(reference — heuristic pom.xml scan; lives under ","type":"text","marks":[{"type":"em"}]},{"text":"migration","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" only, not ","type":"text","marks":[{"type":"em"}]},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":", since this dependency shape never occurs in native AEMaaCS code)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"references/guava-cache.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/guava-cache.md","title":null}},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(reference — Guava cache → Caffeine swap; lives under ","type":"text","marks":[{"type":"em"}]},{"text":"migration","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" only, not ","type":"text","marks":[{"type":"em"}]},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":", since Guava cache usage does not occur in native AEMaaCS code, only in code carried over from legacy AEM)","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"references/input-stream.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/input-stream.md","title":null}},{"type":"strong"}]},{"text":" ","type":"text"},{"text":"(reference — ","type":"text","marks":[{"type":"em"}]},{"text":"java.io.InputStream","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" triage and remediation for the ","type":"text","marks":[{"type":"em"}]},{"text":"DG","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" / ","type":"text","marks":[{"type":"em"}]},{"text":"java.io.inputstream","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" advisory; lives under ","type":"text","marks":[{"type":"em"}]},{"text":"migration","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" only, not ","type":"text","marks":[{"type":"em"}]},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":", because BPA raises it only for code carried over from legacy AEM. Detection is the migration-local scanner ","type":"text","marks":[{"type":"em"}]},{"text":"scripts/input-stream-runner.js","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":", not the code-assessment analyzer)","type":"text","marks":[{"type":"em"}]}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"When code uses SCR, ","type":"text"},{"text":"ResourceResolver","type":"text","marks":[{"type":"code_inline"}]},{"text":", or console logging, read ","type":"text"},{"text":"{code-assessment}/references/scr-to-osgi-ds.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" and ","type":"text"},{"text":"{code-assessment}/references/resource-resolver-logging.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (or the hub ","type":"text"},{"text":"{code-assessment}/references/aem-cloud-service-pattern-prerequisites.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":").","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Do not transform ","type":"text"},{"text":"Java or HTL","type":"text","marks":[{"type":"strong"}]},{"text":" until the pattern guide (or reference) is read (branch B). Branch A does not require ","type":"text"},{"text":"{code-assessment}","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern guidance.","type":"text"}]},{"type":"paragraph","content":[{"text":"Branch C — Template Modernization","type":"text","marks":[{"type":"strong"}]},{"text":" (no BPA): static → editable templates and/or AEM Modernize Tools rules (structure/component/policy). Three phases: context → per-template execute → validate. Start at ","type":"text"},{"text":"references/template-modernization/template-modernization-context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/template-modernization-context.md","title":null}}]},{"text":"; generators are ","type":"text"},{"text":"editable-template-creation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/editable-template-creation.md","title":null}}]},{"text":" and ","type":"text"},{"text":"aem-modernization.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/aem-modernization.md","title":null}}]},{"text":"; post-gen checks in ","type":"text"},{"text":"template-modernization-validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/template-modernization-validation.md","title":null}}]},{"text":". ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" branch B.","type":"text"}]},{"type":"paragraph","content":[{"text":"Branch D — Legacy UI Migration","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"legacy-ui/","type":"text","marks":[{"type":"code_inline"}]},{"text":" sub-folders): If the user asks to convert Classic UI / ExtJS dialogs, upgrade Coral 2 dialogs, migrate custom ExtJS widgets, fix LUI or CDW BPA findings, or mentions ","type":"text"},{"text":"cq:Dialog","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"xtype","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"cq:Widget","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For dialog findings","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern, ","type":"text"},{"text":"legacy.dialog.classic","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"legacy.dialog.coral2","type":"text","marks":[{"type":"code_inline"}]},{"text":" only):","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/legacy-ui/dialog/context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/context.md","title":null}}]},{"text":" — ","type":"text"},{"text":"getBpaFindings('lui', …)","type":"text","marks":[{"type":"code_inline"}]},{"text":", filter to dialog sub-types, skip all others with a note.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"convert-extjs","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"references/legacy-ui/dialog/extjs-to-coral3.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/extjs-to-coral3.md","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"upgrade-coral2","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"references/legacy-ui/dialog/coral2-to-coral3.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/coral2-to-coral3.md","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validate: ","type":"text"},{"text":"references/legacy-ui/dialog/validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/validation.md","title":null}}]},{"text":".","type":"text"}]}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For custom widget findings","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern):","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read ","type":"text"},{"text":"references/legacy-ui/cdw/context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/cdw/context.md","title":null}}]},{"text":" — ","type":"text"},{"text":"getBpaFindings('cdw', …)","type":"text","marks":[{"type":"code_inline"}]},{"text":", inventory xtypes.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Per xtype: ","type":"text"},{"text":"references/legacy-ui/cdw/conversion.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/cdw/conversion.md","title":null}}]},{"text":" (apply mapping or scaffold Granite UI component).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validate: ","type":"text"},{"text":"references/legacy-ui/cdw/validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/cdw/validation.md","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the same components also have LUI ","type":"text"},{"text":"legacy.dialog.classic","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings, run dialog migration afterwards — all xtypes are now resolved.","type":"text"}]}]}]}]}]},{"type":"paragraph","content":[{"text":"Run order when both are needed: CDW first, then dialog.","type":"text","marks":[{"type":"strong"}]},{"text":" CDW resolves custom xtypes so dialog conversion can proceed without stops. ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch B. ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch C.","type":"text"}]},{"type":"paragraph","content":[{"text":"Branch E — Dispatcher Conversion","type":"text","marks":[{"type":"strong"}]},{"text":" (AMS / on-premise Apache ","type":"text"},{"text":"httpd","type":"text","marks":[{"type":"code_inline"}]},{"text":" + Dispatcher → AEMaaCS; no Java BPA pattern this session):","type":"text"}]},{"type":"paragraph","content":[{"text":"If the user asks to ","type":"text"},{"text":"convert / migrate a Dispatcher configuration","type":"text","marks":[{"type":"strong"}]},{"text":" to AEM as a Cloud Service, follow the ","type":"text"},{"text":"6-phase flow","type":"text","marks":[{"type":"strong"}]},{"text":". It wraps Adobe's maintained ","type":"text"},{"text":"@adobe/aem-cs-source-migration-dispatcher-converter","type":"text","marks":[{"type":"code_inline"}]},{"text":" as the conversion engine and adds detection, config generation, output verification, judgment, and validation on top. Start by reading ","type":"text"},{"text":"references/dispatcher/context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/context.md","title":null}}]},{"text":". ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch B.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Inventory","type":"text","marks":[{"type":"strong"}]},{"text":" — run ","type":"text"},{"text":"scripts/dispatcher-inventory.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"buildInventory","type":"text","marks":[{"type":"code_inline"}]},{"text":") to detect the ","type":"text"},{"text":"mode","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"standard","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"flexible","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"ams","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"already-cloud","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"not-dispatcher","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"v1","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":") and count filter / rewrite / cache rules. (","type":"text"},{"text":"ams","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a monolithic AMS on-premise config — converted via the same on-prem executor as ","type":"text"},{"text":"flexible","type":"text","marks":[{"type":"code_inline"}]},{"text":", just labelled honestly.) Modes and signals are defined in ","type":"text"},{"text":"references/dispatcher/context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/context.md","title":null}}]},{"text":". If the mode is ","type":"text"},{"text":"already-cloud","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"not-dispatcher","type":"text","marks":[{"type":"code_inline"}]},{"text":", or ","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":", STOP with that finding — the first two have nothing to convert, and ","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" is an ambiguous/incomplete layout to confirm with the user before running the content-blind tool (","type":"text"},{"text":"resolveExecutor","type":"text","marks":[{"type":"code_inline"}]},{"text":" falls ","type":"text"},{"text":"unknown","type":"text","marks":[{"type":"code_inline"}]},{"text":" through to the on-prem executor, so the agent is the gate here).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Plan + generate ","type":"text","marks":[{"type":"strong"}]},{"text":"config.yaml","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — build the converter config per ","type":"text"},{"text":"references/dispatcher/config-generation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/config-generation.md","title":null}}]},{"text":" (per-mode mapping; ","type":"text"},{"text":"variablesToReplace","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a flat mapping, ","type":"text"},{"text":"portsToMap","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a list, ","type":"text"},{"text":"appendToVhosts","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a file path).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Execute","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"ensureToolInstalled","type":"text","marks":[{"type":"code_inline"}]},{"text":" (auto-installs the Adobe tool into the gitignored ","type":"text"},{"text":"scripts/dispatcher-tool/node_modules/","type":"text","marks":[{"type":"code_inline"}]},{"text":" on first use) then ","type":"text"},{"text":"runConverter","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"scripts/dispatcher-run.js","type":"text","marks":[{"type":"code_inline"}]},{"text":"); the executor is selected by mode (","type":"text"},{"text":"standard","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"main.js","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"flexible","type":"text","marks":[{"type":"code_inline"}]},{"text":" / on-prem → ","type":"text"},{"text":"singleFileMain.js","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Verify + normalize","type":"text","marks":[{"type":"strong"}]},{"text":" — run ","type":"text"},{"text":"scripts/dispatcher-verify.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" and apply ","type":"text"},{"text":"references/dispatcher/output-verification.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/output-verification.md","title":null}}]},{"text":". ","type":"text"},{"text":"HARD STOP on ","type":"text","marks":[{"type":"strong"}]},{"text":"filter-acl-loss","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (an empty ","type":"text"},{"text":"filters.any","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the baseline had filter rules): the conversion is not usable until it is resolved.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Judgment + cross-boundary","type":"text","marks":[{"type":"strong"}]},{"text":" — apply the decision catalog in ","type":"text"},{"text":"references/dispatcher/conversion-patterns.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/conversion-patterns.md","title":null}}]},{"text":"; hand any Cloud Manager environment variables to ","type":"text"},{"text":"Branch A","type":"text","marks":[{"type":"strong"}]},{"text":" (OSGi → Cloud Manager); flag CDN-candidate rules. Target end-state conventions are in ","type":"text"},{"text":"references/dispatcher/current-sdk-conventions.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/current-sdk-conventions.md","title":null}}]},{"text":". To operationalize this handoff, run ","type":"text"},{"text":"scripts/dispatcher-crossboundary.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" to build the Cloud Manager variable handoff artifact, then apply ","type":"text"},{"text":"references/dispatcher/cross-boundary.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/cross-boundary.md","title":null}}]},{"text":" to route each concern — CM vars → ","type":"text"},{"text":"Branch A","type":"text","marks":[{"type":"strong"}]},{"text":"; immutable freshness → the ","type":"text"},{"text":"dispatcher","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill's ","type":"text"},{"text":"sdk(diff-baseline)","type":"text","marks":[{"type":"code_inline"}]},{"text":"; security headers / edge → ","type":"text"},{"text":"security-hardening","type":"text","marks":[{"type":"code_inline"}]},{"text":"; validation → ","type":"text"},{"text":"config-authoring","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validate","type":"text","marks":[{"type":"strong"}]},{"text":" — validate the converted ","type":"text"},{"text":"src","type":"text","marks":[{"type":"code_inline"}]},{"text":" per ","type":"text"},{"text":"references/dispatcher/validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/validation.md","title":null}}]},{"text":" (delegates to the ","type":"text"},{"text":"dispatcher","type":"text","marks":[{"type":"code_inline"}]},{"text":" skill's SDK validator + guardrails); iterate until clean. Do not present the result as done on validation failure. After validation, render the consolidated report with ","type":"text"},{"text":"scripts/dispatcher-report.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"renderReport","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"writeReport","type":"text","marks":[{"type":"code_inline"}]},{"text":") as ","type":"text"},{"text":"conversion-report.md","type":"text","marks":[{"type":"code_inline"}]},{"text":", which includes the coverage counts, the CM handoff, and the delegated next-checks checklist.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Branch F — Oak Index Migration","type":"text","marks":[{"type":"strong"}]},{"text":" (legacy ","type":"text"},{"text":"_oak_index/*.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" → AEM as a Cloud Service compatible Oak index definitions; BPA pattern id ","type":"text"},{"text":"oakIndex","type":"text","marks":[{"type":"code_inline"}]},{"text":", category OID):","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Beta","type":"text","marks":[{"type":"strong"}]},{"text":": Oak index migration (Branch F) is in beta and under active development. Review its output carefully — especially the type/datatype compatibility check in step 4 — before using it on production index definitions.","type":"text"}]}]},{"type":"paragraph","content":[{"text":"If the user asks to ","type":"text"},{"text":"fix Oak index findings / convert ","type":"text","marks":[{"type":"strong"}]},{"text":"_oak_index","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" definitions","type":"text","marks":[{"type":"strong"}]},{"text":" to AEM as a Cloud Service, follow the ","type":"text"},{"text":"6-step flow","type":"text","marks":[{"type":"strong"}]},{"text":" in ","type":"text"},{"text":"references/oak-index.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/oak-index.md","title":null}}]},{"text":". It wraps Adobe's maintained ","type":"text"},{"text":"@adobe/aem-cs-source-migration-index-converter","type":"text","marks":[{"type":"code_inline"}]},{"text":" as the conversion engine and adds detection, a dynamic ","type":"text"},{"text":"-custom-N","type":"text","marks":[{"type":"code_inline"}]},{"text":" naming override, a type-compatibility check, and validation on top. ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch B.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Detect","type":"text","marks":[{"type":"strong"}]},{"text":" — locate ","type":"text"},{"text":"_oak_index/","type":"text","marks":[{"type":"code_inline"}]},{"text":" directories; determine ","type":"text"},{"text":"aemVersion","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"63","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"64","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"65","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"Cloud_Services","type":"text","marks":[{"type":"code_inline"}]},{"text":") from the project's ","type":"text"},{"text":"pom.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Determine next ","type":"text","marks":[{"type":"strong"}]},{"text":"-custom-N","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" — scan for existing ","type":"text"},{"text":"\u003cbaseName>-custom-\u003cn>","type":"text","marks":[{"type":"code_inline"}]},{"text":" siblings already in the target state before assuming the tool's default ","type":"text"},{"text":"-custom-1","type":"text","marks":[{"type":"code_inline"}]},{"text":"; a prior migration or a customer's own ","type":"text"},{"text":"-custom-N","type":"text","marks":[{"type":"code_inline"}]},{"text":" index makes reusing ","type":"text"},{"text":"-custom-1","type":"text","marks":[{"type":"code_inline"}]},{"text":" a collision, not a fresh name.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Execute","type":"text","marks":[{"type":"strong"}]},{"text":" — resolve the Index Converter via ","type":"text"},{"text":"npx","type":"text","marks":[{"type":"code_inline"}]},{"text":" and invoke it per ","type":"text"},{"text":"references/oak-index.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/oak-index.md#step-3--invoke-index-converter","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Diff + type-compatibility check","type":"text","marks":[{"type":"strong"}]},{"text":" — show the converted output vs. input; verify ","type":"text"},{"text":"oak:isOakIndexDefinition","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"type","type":"text","marks":[{"type":"code_inline"}]},{"text":" are intact and that per-property JCR value types match between legacy and converted definitions. Flag any mismatch as ","type":"text"},{"text":"needs_manual_review: datatype mismatch on \u003cpropertyName>","type":"text","marks":[{"type":"code_inline"}]},{"text":" — do not silently apply it.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Apply","type":"text","marks":[{"type":"strong"}]},{"text":" (after user confirms) — copy tool output into the repo, applying the Step 2 name override if one was computed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validate","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"mvn -pl ui.apps clean install","type":"text","marks":[{"type":"code_inline"}]},{"text":", then ","type":"text"},{"text":"mvn -pl all aem-analyser:project-analyse","type":"text","marks":[{"type":"code_inline"}]},{"text":" if configured. Report PASS/FAIL with file:line evidence.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Branch G — Validate migration","type":"text","marks":[{"type":"strong"}]},{"text":" (after a migration branch, or on request; no BPA pattern this session):","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Beta","type":"text","marks":[{"type":"strong"}]},{"text":": Validate migration (Branch G) is in beta and under active development. Treat a PASS as a strong signal, not a guarantee — review the report before raising a PR or deploying to a Cloud Manager environment.","type":"text"}]}]},{"type":"paragraph","content":[{"text":"If the user asks to ","type":"text"},{"text":"validate / verify / smoke-test","type":"text","marks":[{"type":"strong"}]},{"text":" migrated code on the local Cloud SDK, read ","type":"text"},{"text":"references/validation/validate-migration.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/validation/validate-migration.md","title":null}}]},{"text":" and follow it. It never edits customer source; failures go back to the branch that owns the pattern. ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch B.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"When to Use This Skill","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Migrate legacy AEM Java toward ","type":"text"},{"text":"Cloud Service–compatible","type":"text","marks":[{"type":"strong"}]},{"text":" patterns (scheduler, ResourceChangeListener, replication, EventListener/EventHandler, AssetManager)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fix ","type":"text"},{"text":"HTL (Sightly)","type":"text","marks":[{"type":"strong"}]},{"text":" lint warnings (","type":"text"},{"text":"data-sly-test: redundant constant value comparison","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fix ","type":"text"},{"text":"Vault package install-time dependencies","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"day/cq60/product:*","type":"text","marks":[{"type":"code_inline"}]},{"text":") blocking package installation on AEMaaCS","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Swap ","type":"text"},{"text":"Guava cache","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"com.google.common.cache.*","type":"text","marks":[{"type":"code_inline"}]},{"text":") for ","type":"text"},{"text":"Caffeine","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Triage and fix ","type":"text"},{"text":"java.io.InputStream","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" advisories (","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":", BPA ","type":"text"},{"text":"DG","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"java.io.inputstream","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"OSGi → Cloud Manager","type":"text","marks":[{"type":"strong"}]},{"text":" secret/variable externalization (Branch A), ","type":"text"},{"text":"Template Modernization","type":"text","marks":[{"type":"strong"}]},{"text":" (Branch C), ","type":"text"},{"text":"Legacy UI","type":"text","marks":[{"type":"strong"}]},{"text":" dialog/CDW migration (Branch D)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Drive work from ","type":"text"},{"text":"BPA","type":"text","marks":[{"type":"strong"}]},{"text":" (CSV or cached collection) or ","type":"text"},{"text":"CAM via MCP","type":"text","marks":[{"type":"strong"}]},{"text":", ","type":"text"},{"text":"one pattern per session","type":"text","marks":[{"type":"strong"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Dispatcher Conversion:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"Branch E","type":"text","marks":[{"type":"strong"}]},{"text":" — convert AMS / on-premise Apache+Dispatcher configs to AEMaaCS via Adobe's ","type":"text"},{"text":"dispatcher-converter","type":"text","marks":[{"type":"code_inline"}]},{"text":", with mode detection, config generation, output verification (filter/ACL hard-gate), cross-boundary handoff, and SDK validation. References: ","type":"text"},{"text":"references/dispatcher/","type":"text","marks":[{"type":"link","attrs":{"href":"references/dispatcher/","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Oak Index Migration:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"Branch F","type":"text","marks":[{"type":"strong"}]},{"text":" (beta) — convert legacy ","type":"text"},{"text":"_oak_index/*.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" definitions via Adobe's ","type":"text"},{"text":"index-converter","type":"text","marks":[{"type":"code_inline"}]},{"text":", with a dynamic ","type":"text"},{"text":"-custom-N","type":"text","marks":[{"type":"code_inline"}]},{"text":" naming override, a type/datatype compatibility check, and validation. BPA pattern id: ","type":"text"},{"text":"oakIndex","type":"text","marks":[{"type":"code_inline"}]},{"text":". Reference: ","type":"text"},{"text":"references/oak-index.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/oak-index.md","title":null}}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Validate migrated code:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"Branch G","type":"text","marks":[{"type":"strong"}]},{"text":" (beta) — build, deploy to the local Cloud SDK, and check the migration contract and bundle/component state. Reference: ","type":"text"},{"text":"references/validation/validate-migration.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/validation/validate-migration.md","title":null}}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"Branch routing and the read-first delegation for each entry above are defined once in ","type":"text"},{"text":"Required delegation","type":"text","marks":[{"type":"strong"}]},{"text":" — this list is only the \"when.\"","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Prerequisites","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Project source and Maven/Gradle build","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"BPA CSV or MCP access optional but recommended","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"htlLint","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"ui.apps","type":"text","marks":[{"type":"code_inline"}]},{"text":" or equivalent content package with ","type":"text"},{"text":".html","type":"text","marks":[{"type":"code_inline"}]},{"text":" HTL templates","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"BPA findings — flow","type":"text"}]},{"type":"paragraph","content":[{"text":"Scripts run via ","type":"text"},{"text":"getBpaFindings","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (see ","type":"text"},{"text":"Calling the helper","type":"text","marks":[{"type":"strong"}]},{"text":"); do not reimplement collection logic by hand unless the helper is unavailable.","type":"text"}]},{"type":"paragraph","content":[{"text":"The helper has ","type":"text"},{"text":"two independent paths","type":"text","marks":[{"type":"strong"}]},{"text":", chosen by what the caller configures:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"MCP configured","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"mcpFetcher","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"projectId","type":"text","marks":[{"type":"code_inline"}]},{"text":" passed) → first call fetches all findings from MCP and caches them to ","type":"text"},{"text":"\u003ccollectionsDir>/mcp/\u003cprojectId>/\u003cpattern>.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". Every call (first and subsequent) reads from the MCP cache and returns one batch.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"MCP not configured, BPA CSV provided","type":"text","marks":[{"type":"strong"}]},{"text":" → first call parses the CSV and writes the unified-collection JSON to ","type":"text"},{"text":"\u003ccollectionsDir>/unified-collection.json","type":"text","marks":[{"type":"code_inline"}]},{"text":". Every call reads from the CSV cache and returns one batch.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The two caches are disjoint — MCP sessions and CSV sessions never shadow each other. If neither is configured, the helper reports ","type":"text"},{"text":"no-source","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the agent asks for one.","type":"text"}]},{"type":"paragraph","content":[{"text":"Batching is mandatory on every path","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"getBpaFindings","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns a batch of 5 (","type":"text"},{"text":"result.targets","type":"text","marks":[{"type":"code_inline"}]},{"text":") plus a ","type":"text"},{"text":"result.paging","type":"text","marks":[{"type":"code_inline"}]},{"text":" envelope ","type":"text"},{"text":"{ total, returned, offset, limit, nextOffset, hasMore }","type":"text","marks":[{"type":"code_inline"}]},{"text":". Process one batch, report, stop, and resume only on the user's go-ahead — full rules in ","type":"text"},{"text":"Batched processing (batch size 5)","type":"text","marks":[{"type":"strong"}]},{"text":" below.","type":"text"}]},{"type":"paragraph","content":[{"text":"Note:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":" does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" appear in BPA CSV — it uses proactive ","type":"text"},{"text":"rg","type":"text","marks":[{"type":"code_inline"}]},{"text":" discovery instead. See ","type":"text"},{"text":"htlLint flow","type":"text","marks":[{"type":"strong"}]},{"text":" below.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"CAM via MCP (summary)","type":"text"}]},{"type":"paragraph","content":[{"text":"Fetch CAM findings through the ","type":"text"},{"text":"AEM MCP Server","type":"text","marks":[{"type":"strong"}]},{"text":" recipes: ","type":"text"},{"text":"list-bpa-findings-by-pattern","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" for code-transformer pattern flows (scheduler, assetApi, eventListener, resourceChangeListener, eventHandler, guavaCache, inputStream, lui, luiCoral2, cdw) and ","type":"text"},{"text":"list-bpa-findings-by-severity","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" when the user asks \"what are the critical/major/advisory/info findings?\". The ","type":"text"},{"text":"AEM Cloud Service Migration MCP","type":"text","marks":[{"type":"strong"}]},{"text":" tools (","type":"text"},{"text":"fetch-cam-bpa-findings-by-*","type":"text","marks":[{"type":"code_inline"}]},{"text":") are ","type":"text"},{"text":"deprecated","type":"text","marks":[{"type":"strong"}]},{"text":"; use them only when the recipes are unavailable. Either way, the user must ","type":"text"},{"text":"explicitly confirm","type":"text","marks":[{"type":"strong"}]},{"text":" the project first — resolve a name to its ID with ","type":"text"},{"text":"list-cam-projects","type":"text","marks":[{"type":"code_inline"}]},{"text":" and pass ","type":"text"},{"text":"projectId","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":". Source choice, schemas, retries, the 500-target cap, and error handling: ","type":"text"},{"text":"references/cam-mcp.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/cam-mcp.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Calling the helper","type":"text"}]},{"type":"paragraph","content":[{"text":"Scripts live under ","type":"text"},{"text":"./scripts/","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (next to this ","type":"text"},{"text":"SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"const { getBpaFindings } = require('./scripts/bpa-findings-helper.js');\n\n// First batch (defaults: limit=5, offset=0)\nconst result = await getBpaFindings(pattern, {\n bpaFilePath: './cleaned_file6.csv',\n collectionsDir: './unified-collections',\n projectId: '...',\n mcpFetcher: mcpFunction\n // limit: 5, // implicit default\n // offset: 0, // implicit default\n});\n\n// Next batch — only after the user says to continue\nif (result.paging?.hasMore) {\n const next = await getBpaFindings(pattern, {\n bpaFilePath: './cleaned_file6.csv',\n collectionsDir: './unified-collections',\n projectId: '...',\n mcpFetcher: mcpFunction,\n offset: result.paging.nextOffset\n });\n}","type":"text"}]},{"type":"paragraph","content":[{"text":"result","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":":","type":"text","marks":[{"type":"strong"}]}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"success","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"source","type":"text","marks":[{"type":"code_inline"}]},{"text":" (","type":"text"},{"text":"'unified-collection' | 'bpa-file' | 'mcp-server' | …","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"message","type":"text","marks":[{"type":"code_inline"}]},{"text":" (includes a human-readable batch status)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"targets","type":"text","marks":[{"type":"code_inline"}]},{"text":" — the ","type":"text"},{"text":"current batch","type":"text","marks":[{"type":"strong"}]},{"text":" (length ","type":"text"},{"text":"\u003c= limit","type":"text","marks":[{"type":"code_inline"}]},{"text":")","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"paging: { total, returned, offset, limit, nextOffset, hasMore }","type":"text","marks":[{"type":"code_inline"}]},{"text":" — always present on successful calls","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"To disable batching for a one-off programmatic caller, pass ","type":"text"},{"text":"limit: null","type":"text","marks":[{"type":"code_inline"}]},{"text":". The skill workflow itself ","type":"text"},{"text":"never","type":"text","marks":[{"type":"strong"}]},{"text":" does this.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Collection caching","type":"text"}]},{"type":"paragraph","content":[{"text":"Collections live under ","type":"text"},{"text":"./unified-collections/","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":". If a collection exists and the user supplies a ","type":"text"},{"text":"new","type":"text","marks":[{"type":"strong"}]},{"text":" CSV, ask whether to reuse or re-process. A collection written before the requested pattern was supported is rebuilt from the CSV automatically (the parser records the subtypes it extracts in ","type":"text"},{"text":"meta.parsedSubtypes","type":"text","marks":[{"type":"code_inline"}]},{"text":"); without a CSV, a request for that one pattern returns an error rather than reporting it absent, while ","type":"text"},{"text":"all","type":"text","marks":[{"type":"code_inline"}]},{"text":" still returns the patterns the collection covers and names the ones it can't answer (","type":"text"},{"text":"staleCache.missingPatterns","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Reading a BPA CSV","type":"text"}]},{"type":"paragraph","content":[{"text":"Filter rows where ","type":"text"},{"text":"pattern","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" matches the session pattern. Typical columns: ","type":"text"},{"text":"pattern","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"filePath","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"message","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"MCP errors and fallback","type":"text"}]},{"type":"paragraph","content":[{"text":"Critical:","type":"text","marks":[{"type":"strong"}]},{"text":" On MCP failure, ","type":"text"},{"text":"stop the workflow immediately","type":"text","marks":[{"type":"strong"}]},{"text":" and give the user the ","type":"text"},{"text":"exact tool error message","type":"text","marks":[{"type":"strong"}]},{"text":" (verbatim), including \"not found\" / 404-style project errors. ","type":"text"},{"text":"Do not","type":"text","marks":[{"type":"strong"}]},{"text":" continue with migration steps, infer a different CAM project from the workspace, or switch to manual/local migration on your own.","type":"text"}]},{"type":"paragraph","content":[{"text":"Exception:","type":"text","marks":[{"type":"strong"}]},{"text":" enablement restriction errors (prefix documented in ","type":"text"},{"text":"references/cam-mcp.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/cam-mcp.md","title":null}}]},{"text":") must be shown ","type":"text"},{"text":"verbatim","type":"text","marks":[{"type":"strong"}]},{"text":" with no paraphrase and no automatic fallback until the user addresses them.","type":"text"}]},{"type":"paragraph","content":[{"text":"After stopping, you may summarize what failed in plain language and, if helpful, re-show projects from ","type":"text"},{"text":"list-projects","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":". ","type":"text"},{"text":"Only","type":"text","marks":[{"type":"strong"}]},{"text":" continue when the user ","type":"text"},{"text":"explicitly","type":"text","marks":[{"type":"strong"}]},{"text":" directs the next step (e.g. correct project id/name from the list, BPA CSV path, or specific Java files for manual flow).","type":"text"}]},{"type":"paragraph","content":[{"text":"For retries, error categories, and when user-directed CSV/manual paths are allowed, follow ","type":"text"},{"text":"references/cam-mcp.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/cam-mcp.md","title":null}}]},{"text":"; still ","type":"text"},{"text":"no silent fallback","type":"text","marks":[{"type":"strong"}]},{"text":". Never hide tool errors from the user.","type":"text"}]},{"type":"paragraph","content":[{"text":"Optional prompt after stop (user must reply):","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"\"Reply with the CAM project to use (id or name from the list), a path to your BPA CSV, or the Java files for a manual migration.\"","type":"text","marks":[{"type":"em"}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Pattern guides","type":"text"}]},{"type":"paragraph","content":[{"text":"Do ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" duplicate the pattern table here. Use ","type":"text"},{"text":"{code-assessment}/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" → Pattern Guides","type":"text","marks":[{"type":"strong"}]},{"text":" — five patterns each have a pattern guide (","type":"text"},{"text":"{code-assessment}/\u003cpattern>/SKILL.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"); shared topics (SCR→DS, ResourceResolver/SLF4J, HTL lint, prerequisites hub) stay as references (","type":"text"},{"text":"{code-assessment}/references/\u003cfile>.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"); ","type":"text"},{"text":"vault-package-dependencies","type":"text","marks":[{"type":"code_inline"}]},{"text":" is a ","type":"text"},{"text":"migration","type":"text","marks":[{"type":"code_inline"}]},{"text":"-only reference (","type":"text"},{"text":"references/vault-package-dependencies.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"), not a ","type":"text"},{"text":"{code-assessment}","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern guide. See ","type":"text"},{"text":"Branch B step 2","type":"text","marks":[{"type":"strong"}]},{"text":" above for the per-pattern routing table.","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Workflow","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 0: Migration runbook (review / scan entry point)","type":"text"}]},{"type":"paragraph","content":[{"text":"When the user opens with a ","type":"text"},{"text":"broad review/scan","type":"text","marks":[{"type":"strong"}]},{"text":" request — ","type":"text"},{"text":"\"review my code for AEMaaCS migration\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"scan my project for AEM migration\"","type":"text","marks":[{"type":"em"}]},{"text":", or similar — and does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" name a single pattern, generate a ","type":"text"},{"text":"read-only migration runbook","type":"text","marks":[{"type":"strong"}]},{"text":" before any apply work.","type":"text"}]},{"type":"paragraph","content":[{"text":"The runbook covers ","type":"text"},{"text":"every pattern the migration skill can address","type":"text","marks":[{"type":"strong"}]},{"text":". Each pattern declares a ","type":"text"},{"text":"detection strategy","type":"text","marks":[{"type":"strong"}]},{"text":" — CSV-eligible patterns run the priority cascade; the others keep their existing discovery behaviour:","type":"text"}]},{"type":"table","attrs":{"layout":null},"content":[{"type":"tr","content":[{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Pattern(s)","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Strategy","type":"text"}]}]},{"type":"th","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"How it's detected","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"scheduler","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"resourceChangeListener","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"event-migration","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assetApi","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cascade","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BPA/CAM → CSV → analyzer → LLM scan (priority list)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"replication","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"cascade","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"analyzer → LLM scan (no BPA/CSV subtype mapping)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"html-scan","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"heuristic regex scan of ","type":"text"},{"text":".html","type":"text","marks":[{"type":"code_inline"}]},{"text":" (pure Node — no ","type":"text"},{"text":"rg","type":"text","marks":[{"type":"code_inline"}]},{"text":" binary needed)","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"osgiConfig","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"config-scan","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"heuristic scan of OSGi config files for secret-looking keys / ","type":"text"},{"text":"$[secret:]","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"$[env:]","type":"text","marks":[{"type":"code_inline"}]},{"text":" placeholders — ","type":"text"},{"text":"key names + locations only, never secret values","type":"text","marks":[{"type":"strong"}]}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"vault-package-dependencies","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"pom-scan","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Prefers Maven's ","type":"text"},{"text":"effective POM","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"mvn help:effective-pom","type":"text","marks":[{"type":"code_inline"}]},{"text":", one call per reactor root — ","type":"text"},{"text":"\u003cpluginManagement>","type":"text","marks":[{"type":"code_inline"}]},{"text":" inheritance and per-execution vs plugin-level ","type":"text"},{"text":"\u003cconfiguration>","type":"text","marks":[{"type":"code_inline"}]},{"text":" are Maven's problem, not ours). Falls back to a raw ","type":"text"},{"text":"pom.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" text-scan when Maven can't resolve a module (dead parent repos, offline — common for legacy AEM 6.x / AMS codebases). No BPA subtype and no analyzer — a ","type":"text"},{"text":"pom.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" install-time dependency declaration is invisible to a deployed-artifact BPA scan and there is no compiled detector for it. Every effective-POM failure surfaces as a warning so a degraded scan is never silently reported as clean.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"templateModernization","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BPA ","type":"text"},{"text":"cascade","type":"text","marks":[{"type":"code_inline"}]},{"text":" → ","type":"text"},{"text":"content-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":" fallback","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"When a BPA CSV/CAM source is present, these come from BPA (subtypes ","type":"text"},{"text":"custom.classic.widget","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"legacy.dialog.classic","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":".coral2","type":"text","marks":[{"type":"code_inline"}]},{"text":"; ","type":"text"},{"text":"legacy.static.template","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"custom.static.template","type":"text","marks":[{"type":"code_inline"}]},{"text":"). With no BPA source, a heuristic ","type":"text"},{"text":".content.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" scan is the fallback — for ","type":"text"},{"text":"templateModernization","type":"text","marks":[{"type":"code_inline"}]},{"text":" it walks ","type":"text"},{"text":"apps/\u003cappId>/templates/**","type":"text","marks":[{"type":"code_inline"}]},{"text":" at ","type":"text"},{"text":"any depth","type":"text","marks":[{"type":"strong"}]},{"text":" (nested/grouped templates included) and classifies each static template as ","type":"text"},{"text":"custom.static.template","type":"text","marks":[{"type":"code_inline"}]},{"text":" or ","type":"text"},{"text":"legacy.static.template","type":"text","marks":[{"type":"code_inline"}]},{"text":" from its page-component resource type, so the custom-vs-legacy distinction survives even without a BPA report. Sample prompts route to ","type":"text"},{"text":"Branch D","type":"text","marks":[{"type":"strong"}]},{"text":" (legacy-ui) / ","type":"text"},{"text":"Branch C","type":"text","marks":[{"type":"strong"}]},{"text":" (templates), not code-assessment","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bpa-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" (no analyzer, no content-scan)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BPA is the ","type":"text"},{"text":"sole","type":"text","marks":[{"type":"strong"}]},{"text":" source of truth (subtype ","type":"text"},{"text":"custom.guava.cache","type":"text","marks":[{"type":"code_inline"}]},{"text":"), one finding per ","type":"text"},{"text":"bundle","type":"text","marks":[{"type":"strong"}]},{"text":" — ","type":"text"},{"text":"identifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" on this subtype is a Guava-internal class, not a customer class, so raw rows are deduped to the bundle named in the message, not surfaced per row. With no BPA source, ","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":" has no deterministic fallback and surfaces under ","type":"text"},{"text":"Tier 4 — LLM scan","type":"text","marks":[{"type":"strong"}]},{"text":": the agent greps ","type":"text"},{"text":".java","type":"text","marks":[{"type":"code_inline"}]},{"text":" files for ","type":"text"},{"text":"import com.google.common.cache","type":"text","marks":[{"type":"code_inline"}]},{"text":" per module, per ","type":"text"},{"text":"references/guava-cache.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/guava-cache.md","title":null}}]},{"text":", and tags the result ","type":"text"},{"text":"confidence: llm","type":"text","marks":[{"type":"code_inline"}]},{"text":". There is deliberately no compiled analyzer detector for this pattern — it does not run inside ","type":"text"},{"text":"code-assessment","type":"text","marks":[{"type":"code_inline"}]},{"text":"'s own discovery.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"java-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":" (BPA first, local scan as fallback)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Same precedence as the cascade: BPA/CAM → CSV (subtype ","type":"text"},{"text":"java.io.inputstream","type":"text","marks":[{"type":"code_inline"}]},{"text":") → local scan. With BPA rows, only the reported classes are in scope: ","type":"text"},{"text":"input-stream-runner.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" resolves them to workspace files and emits ","type":"text"},{"text":"one finding per file","type":"text","marks":[{"type":"strong"}]},{"text":" with line-level sites, ordered by risk; classes outside the workspace collapse to ","type":"text"},{"text":"one informational row per bundle","type":"text","marks":[{"type":"strong"}]},{"text":" (per package for CAM rows, which carry no bundle name): ","type":"text"},{"text":"missing-source","type":"text","marks":[{"type":"code_inline"}]},{"text":" (plus a warning) for customer code kept in another repository or module, ","type":"text"},{"text":"platform-provided","type":"text","marks":[{"type":"code_inline"}]},{"text":" for AEM Forms add-on code that Adobe provides on AEM as a Cloud Service, and ","type":"text"},{"text":"not-in-workspace","type":"text","marks":[{"type":"code_inline"}]},{"text":" for third-party code to upgrade or replace. Without BPA rows, every ","type":"text"},{"text":"src/main/java","type":"text","marks":[{"type":"code_inline"}]},{"text":" file is scanned. Every stream site is pre-classified (A–F) with a fix kind (","type":"text"},{"text":"decision","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"review","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"auto","type":"text","marks":[{"type":"code_inline"}]},{"text":"); files with only benign sites are informational. The local scan is always available, so this pattern never needs the LLM tier.","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"dispatcherConversion","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"content-scan","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"Heuristic scan for an AMS / on-prem Dispatcher config layout (a monolithic ","type":"text"},{"text":"dispatcher.any","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"conf.vhost.d/","type":"text","marks":[{"type":"code_inline"}]},{"text":", or ","type":"text"},{"text":"conf.dispatcher.d/","type":"text","marks":[{"type":"code_inline"}]},{"text":" AMS trees). Detected by ","type":"text"},{"text":"dispatcher-inventory.js","type":"text","marks":[{"type":"code_inline"}]},{"text":"; the sample prompt routes to ","type":"text"},{"text":"Branch E","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]}]}]},{"type":"tr","content":[{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"oakIndex","type":"text","marks":[{"type":"code_inline"}]}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"bpa-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" (no analyzer, no content-scan)","type":"text"}]}]},{"type":"td","attrs":{"colspan":1,"rowspan":1,"colwidth":null,"alignment":""},"content":[{"type":"paragraph","content":[{"text":"BPA is the ","type":"text"},{"text":"sole","type":"text","marks":[{"type":"strong"}]},{"text":" source of truth (subtypes ","type":"text"},{"text":"index.rule.violation","type":"text","marks":[{"type":"code_inline"}]},{"text":" / ","type":"text"},{"text":"standard.index.modification","type":"text","marks":[{"type":"code_inline"}]},{"text":"), one finding per Oak index path. With no BPA source, ","type":"text"},{"text":"oakIndex","type":"text","marks":[{"type":"code_inline"}]},{"text":" has no deterministic fallback and surfaces under ","type":"text"},{"text":"Tier 4 — LLM scan","type":"text","marks":[{"type":"strong"}]},{"text":": the agent locates ","type":"text"},{"text":"_oak_index/*.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" directly per ","type":"text"},{"text":"references/oak-index.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/oak-index.md","title":null}}]},{"text":". Sample prompt routes to ","type":"text"},{"text":"Branch F","type":"text","marks":[{"type":"strong"}]},{"text":" (beta).","type":"text"}]}]}]}]},{"type":"paragraph","content":[{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"osgiConfig","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"vault-package-dependencies","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the content-scan ","type":"text"},{"text":"fallback","type":"text","marks":[{"type":"strong"}]},{"text":" for ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"templateModernization","type":"text","marks":[{"type":"code_inline"}]},{"text":" are ","type":"text"},{"text":"heuristic","type":"text","marks":[{"type":"strong"}]},{"text":" (tagged ","type":"text"},{"text":"confidence: heuristic","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the cache) — candidate matches, not compiler-validated. BPA-sourced ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"templateModernization","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"replication","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"oakIndex","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings are authoritative. Out of scope: ","type":"text"},{"text":"inject-in-sling-model","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"outdated-dependencies","type":"text","marks":[{"type":"code_inline"}]},{"text":" (those belong to code-assessment's own runbook, not migration).","type":"text"}]},{"type":"paragraph","content":[{"text":"BPA is the source of truth when a report is available.","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"templateModernization","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"replication","type":"text","marks":[{"type":"code_inline"}]},{"text":" are read from the BPA CSV/CAM (the parser now extracts these subtypes and excludes ","type":"text"},{"text":"_COUNT_*","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"_STAT","type":"text","marks":[{"type":"code_inline"}]},{"text":" summary rows), so the runbook counts match your BPA report's LUI-dialog / CDW / static-template / REP tallies. ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":" keeps only the dialog sub-types (","type":"text"},{"text":"legacy.custom.component","type":"text","marks":[{"type":"code_inline"}]},{"text":" → create-component; ","type":"text"},{"text":"legacy.static.template","type":"text","marks":[{"type":"code_inline"}]},{"text":" is counted under ","type":"text"},{"text":"templateModernization","type":"text","marks":[{"type":"code_inline"}]},{"text":"). The ","type":"text"},{"text":".content.xml","type":"text","marks":[{"type":"code_inline"}]},{"text":" scan is only the fallback when no BPA source is present — and it can ","type":"text"},{"text":"undercount","type":"text","marks":[{"type":"strong"}]},{"text":" relative to BPA when the flagged legacy nodes live in packages (e.g. acs-commons) not in the project source. ","type":"text"},{"text":"replication","type":"text","marks":[{"type":"code_inline"}]},{"text":": BPA ","type":"text"},{"text":"replication.agent","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings when a report is present, else the analyzer detects ","type":"text"},{"text":"Replicator","type":"text","marks":[{"type":"code_inline"}]},{"text":" usage from source.","type":"text"}]},{"type":"heading","attrs":{"level":4},"content":[{"text":"Run it as ONE command (do this — don't read the generator source)","type":"text"}]},{"type":"paragraph","content":[{"text":"The runbook is produced by a ","type":"text"},{"text":"single CLI command","type":"text","marks":[{"type":"strong"}]},{"text":". It runs the local analyzer + every scanner itself, writes both ","type":"text"},{"text":"migration-runbook.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" and the sidecar cache, and prints the summary line. You do ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" need to ","type":"text"},{"text":"require()","type":"text","marks":[{"type":"code_inline"}]},{"text":" the generator, learn its internals, or write an in-process MCP bridge.","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Base (always):","type":"text","marks":[{"type":"strong"}]}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"node scripts/runbook-generator.js \u003cworkspaceRoot> --out ./migration-runbook.md --cache ./migration-runbook.json","type":"text"}]},{"type":"paragraph","content":[{"text":"This alone covers the local analyzer (Java cascade patterns), ","type":"text"},{"text":"html-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"config-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"pom-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":", and the ","type":"text"},{"text":"content-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":" patterns.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If you have a BPA CSV:","type":"text","marks":[{"type":"strong"}]},{"text":" add ","type":"text"},{"text":"--csv ./reports/bpa.csv","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If CAM/MCP is configured:","type":"text","marks":[{"type":"strong"}]},{"text":" make the ","type":"text"},{"text":"one","type":"text","marks":[{"type":"strong"}]},{"text":" BPA fetch you would make anyway — call the CAM MCP tool for each relevant slug (","type":"text"},{"text":"scheduler","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"resourceChangeListener","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"eventListener","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"eventHandler","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assetApi","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"replication","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"templateModernization","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"urc","type":"text","marks":[{"type":"code_inline"}]},{"text":"), write the raw targets to a JSON file keyed by slug, and pass it:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# bpa.json shape: { \"\u003cslug>\": [ \u003craw BPA target>, ... ], ... } (a slug you omit = \"not in report\", treated as clean)\nnode scripts/runbook-generator.js \u003cworkspaceRoot> --bpa-json ./bpa.json --project-id \u003cprojectId> --project-name \"\u003cconfirmed name>\" --out ./migration-runbook.md --cache ./migration-runbook.json","type":"text"}]},{"type":"paragraph","content":[{"text":"Pass the ","type":"text"},{"text":"resolved ","type":"text","marks":[{"type":"strong"}]},{"text":"projectId","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" from the CAM tool response and the ","type":"text"},{"text":"project name the user confirmed","type":"text","marks":[{"type":"strong"}]},{"text":". They are recorded under ","type":"text"},{"text":"project","type":"text","marks":[{"type":"code_inline"}]},{"text":" in ","type":"text"},{"text":"migration-runbook.json","type":"text","marks":[{"type":"code_inline"}]},{"text":", so ","type":"text"},{"text":"validate-migration","type":"text","marks":[{"type":"code_inline"}]},{"text":" can report its outcome to the same CAM project. For local-CSV runs omit both; the cache records ","type":"text"},{"text":"project: null","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"The analyzer is always unioned with BPA","type":"text","marks":[{"type":"strong"}]},{"text":" — when a BPA source reports a cascade pattern ","type":"text"},{"text":"clean","type":"text","marks":[{"type":"em"}]},{"text":" but real source code exists, the local analyzer's finding is added anyway (deduped by class name, shown as ","type":"text"},{"text":"Detected via: BPA / CAM + analyzer","type":"text","marks":[{"type":"code_inline"}]},{"text":"). So you ","type":"text"},{"text":"don't","type":"text","marks":[{"type":"strong"}]},{"text":" reconcile \"BPA clean\" against the source by hand — the script already does it.","type":"text"}]},{"type":"paragraph","content":[{"text":"Tier 4 — LLM scan (only if the command prints ","type":"text","marks":[{"type":"strong"}]},{"text":"⚠️ Needs LLM scan: …","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":").","type":"text","marks":[{"type":"strong"}]},{"text":" That happens for a ","type":"text"},{"text":"cascade","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"bpa-only","type":"text","marks":[{"type":"code_inline"}]},{"text":" pattern nothing deterministic could scan (e.g. ","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":" with no BPA source and no JDK). Grep for it per the pattern guide's hints under ","type":"text"},{"text":"{code-assessment}/\u003cpattern>/","type":"text","marks":[{"type":"code_inline"}]},{"text":" (for ","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"import com.google.common.cache","type":"text","marks":[{"type":"code_inline"}]},{"text":" — see ","type":"text"},{"text":"references/guava-cache.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/guava-cache.md","title":null}}]},{"text":"), write the hits to a JSON file, and ","type":"text"},{"text":"re-run the same command","type":"text","marks":[{"type":"strong"}]},{"text":" with ","type":"text"},{"text":"--llm-findings","type":"text","marks":[{"type":"code_inline"}]},{"text":":","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# llm.json shape: { \"\u003cpattern>\": [ { \"file\": \"...\", \"line\": 42, \"snippet\": \"...\" }, ... ] }\nnode scripts/runbook-generator.js \u003cworkspaceRoot> --bpa-json ./bpa.json --llm-findings ./llm.json --out ./migration-runbook.md --cache ./migration-runbook.json","type":"text"}]},{"type":"paragraph","content":[{"text":"The generator merges, re-renders, and rewrites the cache in that one pass — no hand-editing of internal objects.","type":"text"}]},{"type":"paragraph","content":[{"text":"After the command finishes, tell the user (numbers come from the printed summary line):","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"\"I've written ","type":"text"},{"text":"migration-runbook.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"{totalFindings} findings","type":"text","marks":[{"type":"strong"}]},{"text":" across ","type":"text"},{"text":"{N} patterns","type":"text","marks":[{"type":"strong"}]},{"text":" (detected via {sources}). It's read-only. Reply with the pattern you want to migrate first (e.g. ","type":"text"},{"text":"scheduler","type":"text","marks":[{"type":"code_inline"}]},{"text":") and I'll reuse the findings already discovered for that pattern — no re-scan needed — and run the one-pattern-per-session apply workflow.\"","type":"text"}]}]},{"type":"paragraph","content":[{"text":"The command also writes the sidecar findings cache (default ","type":"text"},{"text":"./migration-runbook.json","type":"text","marks":[{"type":"code_inline"}]},{"text":") alongside the markdown, holding each pattern's raw findings and their source. ","type":"text"},{"text":"Step 3","type":"text","marks":[{"type":"strong"}]},{"text":" below reads this cache first before falling back to a live BPA/analyzer/scan lookup.","type":"text"}]},{"type":"paragraph","content":[{"text":"Skip Step 0","type":"text","marks":[{"type":"strong"}]},{"text":" when the user names a ","type":"text"},{"text":"specific pattern","type":"text","marks":[{"type":"strong"}]},{"text":" up front (e.g. ","type":"text"},{"text":"\"fix scheduler findings\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"fix htlLint in ui.apps\"","type":"text","marks":[{"type":"em"}]},{"text":", ","type":"text"},{"text":"\"scan my config files for Cloud Manager secrets\"","type":"text","marks":[{"type":"em"}]},{"text":") — go straight to the relevant apply flow. Step 0 is only for a ","type":"text"},{"text":"broad review/scan","type":"text","marks":[{"type":"strong"}]},{"text":" request with no single pattern named.","type":"text"}]},{"type":"blockquote","content":[{"type":"paragraph","content":[{"text":"Advanced (programmatic).","type":"text","marks":[{"type":"strong"}]},{"text":" The same behavior is available as an API — ","type":"text"},{"text":"generateRunbook({ workspaceRoot, bpaFilePath, preFetchedBpa, llmByPattern, outputPath, cachePath })","type":"text","marks":[{"type":"code_inline"}]},{"text":" and ","type":"text"},{"text":"mergeLlmFindings(gathered, llmByPattern)","type":"text","marks":[{"type":"code_inline"}]},{"text":" from ","type":"text"},{"text":"./scripts/runbook-generator.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" — for callers that need to drive it in-process (e.g. an in-session ","type":"text"},{"text":"mcpFetcher","type":"text","marks":[{"type":"code_inline"}]},{"text":" instead of ","type":"text"},{"text":"--bpa-json","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Prefer the CLI above; reach for the API only when the CLI can't express what you need.","type":"text"}]}]},{"type":"hr","attrs":{"markup":"---"}},{"type":"heading","attrs":{"level":3},"content":[{"text":"One pattern per session","type":"text"}]},{"type":"paragraph","content":[{"text":"If the user asks to fix everything or BPA mixes patterns, ","type":"text"},{"text":"ask which pattern first","type":"text","marks":[{"type":"strong"}]},{"text":". Prefer one commit per pattern session.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 1: Pattern id","type":"text"}]},{"type":"paragraph","content":[{"text":"First check the non-Java branches (routed in full under ","type":"text"},{"text":"Required delegation","type":"text","marks":[{"type":"strong"}]},{"text":"), which take ","type":"text"},{"text":"no BPA pattern id","type":"text","marks":[{"type":"strong"}]},{"text":":","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"OSGi configs → Cloud Manager","type":"text","marks":[{"type":"strong"}]},{"text":" → Branch A.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Template modernization","type":"text","marks":[{"type":"strong"}]},{"text":" (\"create editable templates\", \"generate ","type":"text"},{"text":"/conf","type":"text","marks":[{"type":"code_inline"}]},{"text":" templates\", \"static to editable\", \"structure/component/policy rewrite rules\", \"parsys to container\", \"AEM Modernize Tools\") → Branch C.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Legacy UI","type":"text","marks":[{"type":"strong"}]},{"text":" (Classic UI/Coral 2 dialogs, custom ExtJS widgets, LUI/CDW findings) → Branch D (","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":" → dialog, ","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":" → cdw).","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"If the request is ","type":"text"},{"text":"dispatcher conversion","type":"text","marks":[{"type":"strong"}]},{"text":" — convert or migrate an AMS or on-premise Dispatcher configuration to AEM as a Cloud Service — follow ","type":"text"},{"text":"Branch E","type":"text","marks":[{"type":"strong"}]},{"text":". No Java pattern module is needed. ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch B.","type":"text"}]},{"type":"paragraph","content":[{"text":"If the request is ","type":"text"},{"text":"oak index migration","type":"text","marks":[{"type":"strong"}]},{"text":" — fix ","type":"text"},{"text":"oakIndex","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings, or convert ","type":"text"},{"text":"_oak_index","type":"text","marks":[{"type":"code_inline"}]},{"text":" definitions to AEM as a Cloud Service — follow ","type":"text"},{"text":"Branch F","type":"text","marks":[{"type":"strong"}]},{"text":" (beta). No Java pattern module is needed. ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch B.","type":"text"}]},{"type":"paragraph","content":[{"text":"If the request is to ","type":"text"},{"text":"validate / verify","type":"text","marks":[{"type":"strong"}]},{"text":" already-migrated code on the local Cloud SDK, follow ","type":"text"},{"text":"Branch G","type":"text","marks":[{"type":"strong"}]},{"text":" (beta). ","type":"text"},{"text":"Skip","type":"text","marks":[{"type":"strong"}]},{"text":" Branch B.","type":"text"}]},{"type":"paragraph","content":[{"text":"Otherwise map the request to a pattern id: ","type":"text"},{"text":"scheduler","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"resourceChangeListener","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"replication","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"eventListener","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"eventHandler","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assetApi","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":". If unclear, use ","type":"text"},{"text":"Manual Pattern Hints","type":"text","marks":[{"type":"strong"}]},{"text":" in ","type":"text"},{"text":"{code-assessment}/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" or ask the user to pick one of those.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 2: Availability","type":"text"}]},{"type":"paragraph","content":[{"text":"If the id is missing from the code-assessment catalog (","type":"text"},{"text":"{code-assessment}/references/patterns.md","type":"text","marks":[{"type":"link","attrs":{"href":"../code-assessment/references/patterns.md","title":null}},{"type":"code_inline"}]},{"text":"), say the pattern is not supported yet. ","type":"text"},{"text":"Exception:","type":"text","marks":[{"type":"strong"}]},{"text":" the migration-only references routed in ","type":"text"},{"text":"Branch B step 2","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"vault-package-dependencies","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"guavaCache","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":") are supported even though they aren't in that catalog.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 3: Targets","type":"text"}]},{"type":"paragraph","content":[{"text":"Check for a cached runbook first.","type":"text","marks":[{"type":"strong"}]},{"text":" If ","type":"text"},{"text":"./migration-runbook.json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or the path passed to ","type":"text"},{"text":"generateRunbook","type":"text","marks":[{"type":"code_inline"}]},{"text":"'s ","type":"text"},{"text":"cachePath","type":"text","marks":[{"type":"code_inline"}]},{"text":" option) exists and its ","type":"text"},{"text":"findingsByPattern[\u003cactive pattern>]","type":"text","marks":[{"type":"code_inline"}]},{"text":" array is non-empty, reuse it instead of re-deriving findings:","type":"text"}]},{"type":"bullet_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Load ","type":"text"},{"text":"{ generatedAt, workspaceRoot, sourceByPattern, findingsByPattern }","type":"text","marks":[{"type":"code_inline"}]},{"text":" from the cache file.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Take ","type":"text"},{"text":"allFindings = findingsByPattern[\u003cactive pattern>]","type":"text","marks":[{"type":"code_inline"}]},{"text":". Each entry is ","type":"text"},{"text":"{ pattern, file, line, snippet }","type":"text","marks":[{"type":"code_inline"}]},{"text":" — ","type":"text"},{"text":"file","type":"text","marks":[{"type":"code_inline"}]},{"text":" is ","type":"text"},{"text":"relative to the cache's ","type":"text","marks":[{"type":"strong"}]},{"text":"workspaceRoot","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (resolve with ","type":"text"},{"text":"path.join(workspaceRoot, file)","type":"text","marks":[{"type":"code_inline"}]},{"text":"); ","type":"text"},{"text":"line","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"snippet","type":"text","marks":[{"type":"code_inline"}]},{"text":" are ","type":"text"},{"text":"null","type":"text","marks":[{"type":"code_inline"}]},{"text":" when the pattern's source was ","type":"text"},{"text":"mcp","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"csv","type":"text","marks":[{"type":"code_inline"}]},{"text":" (BPA-sourced findings never carry line/snippet; only the ","type":"text"},{"text":"analyzer","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"html-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":", and ","type":"text"},{"text":"config-scan","type":"text","marks":[{"type":"code_inline"}]},{"text":" sources do). ","type":"text"},{"text":"osgiConfig","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings additionally carry a ","type":"text"},{"text":"kind","type":"text","marks":[{"type":"code_inline"}]},{"text":" and, for ","type":"text"},{"text":"already-placeholdered","type":"text","marks":[{"type":"code_inline"}]},{"text":" rows, ","type":"text"},{"text":"informational: true","type":"text","marks":[{"type":"code_inline"}]},{"text":" — skip those when building the fix work list. The same applies to ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":" informational rows (benign-only files, ","type":"text"},{"text":"bpa-unmatched","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"missing-source","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"platform-provided","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"not-in-workspace","type":"text","marks":[{"type":"code_inline"}]},{"text":"); its file findings carry ","type":"text"},{"text":"line","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"snippet","type":"text","marks":[{"type":"code_inline"}]},{"text":" plus a ","type":"text"},{"text":"sites","type":"text","marks":[{"type":"code_inline"}]},{"text":" array, so no re-detection is needed.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Slice the batch with the same paginate helper used elsewhere in this file:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"javascript"},"content":[{"text":"const { paginate } = require('./scripts/unified-collection-reader.js');\nconst { targets, paging } = paginate(allFindings, { offset, limit: 5 });","type":"text"}]},{"type":"paragraph","content":[{"text":"This keeps the exact same ","type":"text"},{"text":"{ targets, paging }","type":"text","marks":[{"type":"code_inline"}]},{"text":" envelope, batch-of-5 default, and ","type":"text"},{"text":"paging.nextOffset","type":"text","marks":[{"type":"code_inline"}]},{"text":" semantics as the live ","type":"text"},{"text":"getBpaFindings","type":"text","marks":[{"type":"code_inline"}]},{"text":" path below — only the data source differs. Do not bypass the batch-of-5 discipline just because the whole array is already in memory.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Tell the user: ","type":"text"},{"text":"\"Reusing N findings for ","type":"text","marks":[{"type":"em"}]},{"text":"\u003cpattern>","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" from the existing runbook (generated at ","type":"text","marks":[{"type":"em"}]},{"text":"\u003cgeneratedAt>","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":").\"","type":"text","marks":[{"type":"em"}]}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Hand the batch to code-assessment as a ","type":"text"},{"text":"with_findings (pre-resolved)","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" invocation (see ","type":"text"},{"text":"{code-assessment}/references/runbook.md","type":"text","marks":[{"type":"code_inline"}]},{"text":"). Findings with ","type":"text"},{"text":"line","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"snippet","type":"text","marks":[{"type":"code_inline"}]},{"text":" already populated skip the analyzer re-run entirely; findings with only ","type":"text"},{"text":"file","type":"text","marks":[{"type":"code_inline"}]},{"text":" populated (BPA-sourced) still trigger one ","type":"text"},{"text":"analyze.sh --files \u003cpaths>","type":"text","marks":[{"type":"code_inline"}]},{"text":" call inside code-assessment to resolve ","type":"text"},{"text":"line","type":"text","marks":[{"type":"code_inline"}]},{"text":"/","type":"text"},{"text":"snippet","type":"text","marks":[{"type":"code_inline"}]},{"text":" before the edit plan is built.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"If the cache is missing, has no entries for the active pattern, or the user explicitly says \"re-scan\" / \"refresh\", fall through to the live flow below unchanged.","type":"text"}]}]}]},{"type":"paragraph","content":[{"text":"For BPA patterns","type":"text","marks":[{"type":"strong"}]},{"text":" (","type":"text"},{"text":"scheduler","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"resourceChangeListener","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"replication","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"eventListener","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"eventHandler","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"assetApi","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":", ","type":"text"},{"text":"cdw","type":"text","marks":[{"type":"code_inline"}]},{"text":") ","type":"text"},{"text":"when no usable cache exists","type":"text","marks":[{"type":"strong"}]},{"text":": Run ","type":"text"},{"text":"getBpaFindings","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (with ","type":"text"},{"text":"bpaFilePath","type":"text","marks":[{"type":"code_inline"}]},{"text":" when provided). Internally: cache → CSV → MCP → manual ","type":"text"},{"text":"only when each step is applicable and succeeds","type":"text","marks":[{"type":"strong"}]},{"text":"; if MCP fails, obey ","type":"text"},{"text":"MCP errors and fallback","type":"text","marks":[{"type":"strong"}]},{"text":" (stop; no silent chain). For MCP details, ","type":"text"},{"text":"references/cam-mcp.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/cam-mcp.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings, the ","type":"text"},{"text":"identifier","type":"text","marks":[{"type":"code_inline"}]},{"text":" in each target is the ","type":"text"},{"text":"JCR component path","type":"text","marks":[{"type":"strong"}]},{"text":" (e.g. ","type":"text"},{"text":"/apps/myapp/components/content/mycomp","type":"text","marks":[{"type":"code_inline"}]},{"text":") — not a Java class name. Resolve it to the filesystem path using the ","type":"text"},{"text":"JCR → filesystem mapping","type":"text","marks":[{"type":"link","attrs":{"href":"references/legacy-ui/dialog/context.md#jcr-path--filesystem-path","title":null}}]},{"text":" before opening files. ","type":"text"},{"text":"Note:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"luiCoral2","type":"text","marks":[{"type":"code_inline"}]},{"text":" is not a standalone BPA pattern id — Coral 2 dialogs appear as the ","type":"text"},{"text":"legacy.dialog.coral2","type":"text","marks":[{"type":"code_inline"}]},{"text":" sub-type within ","type":"text"},{"text":"lui","type":"text","marks":[{"type":"code_inline"}]},{"text":" results. Do not call ","type":"text"},{"text":"getBpaFindings('luiCoral2', …)","type":"text","marks":[{"type":"code_inline"}]},{"text":" independently; call ","type":"text"},{"text":"getBpaFindings('lui', …)","type":"text","marks":[{"type":"code_inline"}]},{"text":" and filter by sub-type inside Branch D.","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"dispatcherConversion","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", targets are the Dispatcher config root(s) and detected mode from the ","type":"text"},{"text":"dispatcher-inventory.js","type":"text","marks":[{"type":"code_inline"}]},{"text":" scan (surfaced in the Step 0 runbook), not from BPA — go straight to ","type":"text"},{"text":"Branch E","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":", the precedence is the same as for the other BPA patterns: the runbook cache (above), then BPA findings — CAM via ","type":"text"},{"text":"list-bpa-findings-by-pattern","type":"text","marks":[{"type":"code_inline"}]},{"text":" (or the deprecated ","type":"text"},{"text":"fetch-cam-bpa-findings-by-pattern","type":"text","marks":[{"type":"code_inline"}]},{"text":") with ","type":"text"},{"text":"pattern: \"inputStream\"","type":"text","marks":[{"type":"code_inline"}]},{"text":", then a BPA CSV — and the local scan only when BPA has no ","type":"text"},{"text":"inputStream","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings. BPA names classes, not lines, so always run the scanner once to resolve them: write the CAM targets to a JSON file and run ","type":"text"},{"text":"node scripts/input-stream-runner.js \u003cworkspaceRoot> --bpa-json \u003ccam-targets.json> [--csv \u003cbpa.csv>] --json","type":"text","marks":[{"type":"code_inline"}]},{"text":" (CAM targets win over the CSV; with neither, it scans the whole workspace). Do ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" hand BPA rows to ","type":"text"},{"text":"analyze.sh","type":"text","marks":[{"type":"code_inline"}]},{"text":". With BPA rows, only the reported classes are in scope: the scanner resolves them to workspace files with line-level sites and collapses classes outside the workspace per bundle (per package for CAM rows, which carry no bundle name). A batch is ","type":"text"},{"text":"five non-informational file findings","type":"text","marks":[{"type":"strong"}]},{"text":" in the scanner's (risk) order. After editing a batch, verify with ","type":"text"},{"text":"--files \u003ctouched files>","type":"text","marks":[{"type":"code_inline"}]},{"text":" (a missing or unreadable path fails the re-scan). Full loop: ","type":"text"},{"text":"references/input-stream.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/input-stream.md","title":null}}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"getBpaFindings","type":"text","marks":[{"type":"code_inline"}]},{"text":" returns ","type":"text"},{"text":"a batch of 5 findings","type":"text","marks":[{"type":"strong"}]},{"text":" (default ","type":"text"},{"text":"limit=5","type":"text","marks":[{"type":"code_inline"}]},{"text":") along with a ","type":"text"},{"text":"paging","type":"text","marks":[{"type":"code_inline"}]},{"text":" envelope. The agent processes that batch only; it does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" request the next batch until the user says to continue. See ","type":"text"},{"text":"Batched processing (batch size 5)","type":"text","marks":[{"type":"strong"}]},{"text":" below.","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text","marks":[{"type":"strong"}]},{"text":"htlLint","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":": Skip BPA/CSV/MCP. If a cached runbook entry already has ","type":"text"},{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":" findings (they carry ","type":"text"},{"text":"\"confidence\": \"heuristic\"","type":"text","marks":[{"type":"code_inline"}]},{"text":" — regex matches, not compiler-validated), reuse them per the cache-first rule above, but ","type":"text"},{"text":"re-open and re-confirm each hit","type":"text","marks":[{"type":"strong"}]},{"text":" against the patterns in ","type":"text"},{"text":"{code-assessment}/references/data-sly-test-redundant-constant.md","type":"text","marks":[{"type":"code_inline"}]},{"text":" before editing. If there is no cache, targets come from proactive ","type":"text"},{"text":"rg","type":"text","marks":[{"type":"code_inline"}]},{"text":" discovery. See ","type":"text"},{"text":"htlLint flow","type":"text","marks":[{"type":"strong"}]},{"text":" below.","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text","marks":[{"type":"strong"}]},{"text":"osgiConfig","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (OSGi → Cloud Manager): the cache holds heuristic, review-only findings (","type":"text"},{"text":"\"confidence\": \"heuristic\"","type":"text","marks":[{"type":"code_inline"}]},{"text":", key names + locations only — ","type":"text"},{"text":"no secret values","type":"text","marks":[{"type":"strong"}]},{"text":"). Use them as a starting checklist, then follow ","type":"text"},{"text":"Branch A","type":"text","marks":[{"type":"strong"}]},{"text":" and ","type":"text"},{"text":"references/osgi-cfg-json-cloud-manager.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/osgi-cfg-json-cloud-manager.md","title":null}}]},{"text":" to classify each value (real secret? Adobe-owned PID → ","type":"text"},{"text":"needs_user_review","type":"text","marks":[{"type":"code_inline"}]},{"text":") — never trust the heuristic ","type":"text"},{"text":"plaintext-secret","type":"text","marks":[{"type":"code_inline"}]},{"text":" label without confirming.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 4: Read before edits","type":"text"}]},{"type":"paragraph","content":[{"text":"STOP.","type":"text","marks":[{"type":"strong"}]},{"text":" Read ","type":"text"},{"text":"{code-assessment}/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" and the pattern guide (or reference) for the active pattern — see ","type":"text"},{"text":"Branch B step 2","type":"text","marks":[{"type":"strong"}]},{"text":" above for the pattern → file routing table.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 5: Process the batch","type":"text"}]},{"type":"paragraph","content":[{"text":"For ","type":"text"},{"text":"each finding in the returned batch only","type":"text","marks":[{"type":"strong"}]},{"text":" (up to 5):","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Resolve the target ","type":"text"},{"text":"inside the IDE workspace","type":"text","marks":[{"type":"strong"}]},{"text":" (see ","type":"text"},{"text":"Workspace scope (IDE)","type":"text","marks":[{"type":"strong"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read source → classify with the pattern guide (or reference) → apply steps ","type":"text"},{"text":"in order","type":"text","marks":[{"type":"strong"}]},{"text":" → check lints → next file.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Step 6: Report batch and wait","type":"text"}]},{"type":"paragraph","content":[{"text":"After finishing the batch, summarise ","type":"text"},{"text":"for this batch only","type":"text","marks":[{"type":"strong"}]},{"text":": ","type":"text"},{"text":"paging.returned","type":"text","marks":[{"type":"code_inline"}]},{"text":" of ","type":"text"},{"text":"paging.total","type":"text","marks":[{"type":"code_inline"}]},{"text":" processed (with class names), files touched, and any skips/failures. If ","type":"text"},{"text":"paging.hasMore","type":"text","marks":[{"type":"code_inline"}]},{"text":", tell the user ","type":"text"},{"text":"\"Processed batch of N (offset {offset}–{offset + returned − 1} of {total}). Reply ","type":"text","marks":[{"type":"em"}]},{"text":"continue","type":"text","marks":[{"type":"code_inline"},{"type":"em"}]},{"text":" for the next batch, or name specific classes.\"","type":"text","marks":[{"type":"em"}]},{"text":"; otherwise say the pattern is done and move to the session report.","type":"text"}]},{"type":"paragraph","content":[{"text":"Then ","type":"text"},{"text":"stop and wait","type":"text","marks":[{"type":"strong"}]},{"text":" — resume only when the user explicitly asks, per the Batched-processing rules.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Manual flow (no BPA)","type":"text"}]},{"type":"paragraph","content":[{"text":"User-named files → classify (code-assessment Manual Pattern Hints or ask) → confirm the pattern guide or reference exists → read ","type":"text"},{"text":"{code-assessment}/SKILL.md","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" + the pattern guide (or reference) — see Branch B step 2 routing — → transform → report.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"OSGi → Cloud Manager flow","type":"text"}]},{"type":"paragraph","content":[{"text":"Does ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" use BPA CSV, CAM/MCP, or code-assessment pattern guides for collection. Follow ","type":"text"},{"text":"Branch A","type":"text","marks":[{"type":"strong"}]},{"text":" in ","type":"text"},{"text":"Required delegation","type":"text","marks":[{"type":"strong"}]},{"text":" and the ","type":"text"},{"text":"One-prompt workflow","type":"text","marks":[{"type":"strong"}]},{"text":" in ","type":"text"},{"text":"references/osgi-cfg-json-cloud-manager.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/osgi-cfg-json-cloud-manager.md","title":null}}]},{"text":".","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Template modernization flow (Branch C)","type":"text"}]},{"type":"paragraph","content":[{"text":"No BPA / MCP. Three phases — context → per-template execute → validate — fully defined in ","type":"text"},{"text":"references/template-modernization/template-modernization-context.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/template-modernization-context.md","title":null}}]},{"text":". Use the confirmed context and per-template plan table first, execute generators via ","type":"text"},{"text":"references/template-modernization/editable-template-creation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/editable-template-creation.md","title":null}}]},{"text":" and ","type":"text"},{"text":"references/template-modernization/aem-modernization.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/aem-modernization.md","title":null}}]},{"text":", then run ","type":"text"},{"text":"references/template-modernization/template-modernization-validation.md","type":"text","marks":[{"type":"link","attrs":{"href":"references/template-modernization/template-modernization-validation.md","title":null}}]},{"text":". Do not commit on validation failure.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"htlLint flow","type":"text"}]},{"type":"paragraph","content":[{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":" does not use BPA CSV or CAM/MCP. Instead:","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Read","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"{code-assessment}/references/data-sly-test-redundant-constant.md","type":"text","marks":[{"type":"link","attrs":{"href":"../code-assessment/references/data-sly-test-redundant-constant.md","title":null}},{"type":"code_inline"}]},{"text":" — it contains the ","type":"text"},{"text":"Workflow","type":"text","marks":[{"type":"strong"}]},{"text":", ","type":"text"},{"text":"Proactive Discovery","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"rg","type":"text","marks":[{"type":"code_inline"}]},{"text":" patterns, and all 4 fix patterns. (HTL lint lives as a shared reference, not a dedicated pattern guide.)","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Discover","type":"text","marks":[{"type":"strong"}]},{"text":" targets using the ","type":"text"},{"text":"rg","type":"text","marks":[{"type":"code_inline"}]},{"text":" commands from the reference's ","type":"text"},{"text":"Proactive Discovery","type":"text","marks":[{"type":"strong"}]},{"text":" table (scope: ","type":"text"},{"text":"ui.apps/**/jcr_root/**/*.html","type":"text","marks":[{"type":"code_inline"}]},{"text":" or the user's content package paths).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Group","type":"text","marks":[{"type":"strong"}]},{"text":" hits by file, classify each by pattern (boolean literal, raw string, numeric, split expression).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Fix","type":"text","marks":[{"type":"strong"}]},{"text":" each hit per the matching pattern section in the reference.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Report","type":"text","marks":[{"type":"strong"}]},{"text":" and recommend the user run ","type":"text"},{"text":"mvn clean install","type":"text","marks":[{"type":"code_inline"}]},{"text":" or HTL validate to confirm no warnings remain.","type":"text"}]}]}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Batched processing (batch size 5)","type":"text"}]},{"type":"paragraph","content":[{"text":"Findings are served to the agent in batches of ","type":"text"},{"text":"5","type":"text","marks":[{"type":"strong"}]},{"text":" by default, regardless of source (MCP or CSV). Batching happens ","type":"text"},{"text":"client-side","type":"text","marks":[{"type":"strong"}]},{"text":" — the heavy fetch (MCP call or CSV parse) happens once and is materialized to a local JSON cache; every subsequent batch is a cheap slice of that cache.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Rules","type":"text"}]},{"type":"ordered_list","attrs":{"order":1,"listStyle":"number"},"content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Default ","type":"text","marks":[{"type":"strong"}]},{"text":"limit","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" is 5","type":"text","marks":[{"type":"strong"}]},{"text":", and one batch per call — process, report, stop. Never hold more than one batch in memory, pre-fetch, or merge across batches. Never pass ","type":"text"},{"text":"limit: null","type":"text","marks":[{"type":"code_inline"}]},{"text":" in the skill flow (that option is for programmatic callers wanting the full list).","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Offset starts at 0","type":"text","marks":[{"type":"strong"}]},{"text":" and advances by ","type":"text"},{"text":"result.paging.nextOffset","type":"text","marks":[{"type":"code_inline"}]},{"text":" from the previous call — read ","type":"text"},{"text":"nextOffset","type":"text","marks":[{"type":"code_inline"}]},{"text":", never compute ","type":"text"},{"text":"offset + limit","type":"text","marks":[{"type":"code_inline"}]},{"text":" yourself.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Stable ordering.","type":"text","marks":[{"type":"strong"}]},{"text":" Each cache file is written once in deterministic order, so slices are stable and contiguous.","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"Resume is stateless.","type":"text","marks":[{"type":"strong"}]},{"text":" No progress file — resuming means re-calling with ","type":"text"},{"text":"offset: previous.paging.nextOffset","type":"text","marks":[{"type":"code_inline"}]},{"text":"; a later session with the same ","type":"text"},{"text":"pattern","type":"text","marks":[{"type":"code_inline"}]},{"text":" + ","type":"text"},{"text":"offset","type":"text","marks":[{"type":"code_inline"}]},{"text":" gets the same batch. ","type":"text"},{"text":"Done when ","type":"text","marks":[{"type":"strong"}]},{"text":"paging.hasMore === false","type":"text","marks":[{"type":"code_inline"},{"type":"strong"}]},{"text":" (or ","type":"text"},{"text":"nextOffset === null","type":"text","marks":[{"type":"code_inline"}]},{"text":").","type":"text"}]}]},{"type":"list_item","content":[{"type":"paragraph","content":[{"text":"First call caches, later batches read the cache","type":"text","marks":[{"type":"strong"}]},{"text":" — one MCP fetch / CSV parse total, not per batch. To refresh, delete the cache file: CSV ","type":"text"},{"text":"\u003ccollectionsDir>/unified-collection.json","type":"text","marks":[{"type":"code_inline"}]},{"text":"; MCP ","type":"text"},{"text":"\u003ccollectionsDir>/mcp/\u003cprojectId>/\u003cpattern>.json","type":"text","marks":[{"type":"code_inline"}]},{"text":".","type":"text"}]}]}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"Agent-visible flow","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":""},"content":[{"text":"[User] \"Fix scheduler findings using ./reports/bpa.csv\" (MCP path: pass { mcpFetcher, projectId } instead of bpaFilePath)\n[Agent] getBpaFindings('scheduler', { bpaFilePath, limit: 5, offset: 0 })\n // first call parses CSV (or fetches MCP once) → writes cache → slices\n → paging: { total: 137, returned: 5, offset: 0, nextOffset: 5, hasMore: true }\n Processes 5 findings, reports: \"Processed 5 of 137 (offset 0–4). Reply `continue`.\"\n[User] \"continue\"\n[Agent] getBpaFindings('scheduler', { bpaFilePath, limit: 5, offset: 5 }) // reads cache — no re-parse / no new MCP call\n → paging: { ..., offset: 5, nextOffset: 10, hasMore: true }\n...","type":"text"}]},{"type":"heading","attrs":{"level":2},"content":[{"text":"Quick reference","type":"text"}]},{"type":"paragraph","content":[{"text":"Source priority (BPA patterns):","type":"text","marks":[{"type":"strong"}]},{"text":" unified collection → BPA CSV → MCP → manual paths — ","type":"text"},{"text":"not","type":"text","marks":[{"type":"strong"}]},{"text":" an automatic cascade after MCP errors (if MCP fails, stop; see ","type":"text"},{"text":"MCP errors and fallback","type":"text","marks":[{"type":"strong"}]},{"text":"). Batch size 5 on every BPA source. ","type":"text"},{"text":"htlLint","type":"text","marks":[{"type":"code_inline"}]},{"text":", OSGi→Cloud Manager, Template Modernization (C), and Legacy UI (D) do not use BPA/MCP — see their branches in ","type":"text"},{"text":"Required delegation","type":"text","marks":[{"type":"strong"}]},{"text":".","type":"text"}]},{"type":"paragraph","content":[{"text":"User-facing snippets:","type":"text","marks":[{"type":"strong"}]},{"text":" ","type":"text"},{"text":"\"Using existing BPA collection (N findings)…\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Processing your BPA report…\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Fetched findings from CAM.\"","type":"text","marks":[{"type":"em"}]},{"text":" / ","type":"text"},{"text":"\"Scanning HTL templates for data-sly-test lint issues…\"","type":"text","marks":[{"type":"em"}]},{"text":" / optional prompt after MCP stop above.","type":"text"}]},{"type":"heading","attrs":{"level":3},"content":[{"text":"CLI (development only)","type":"text"}]},{"type":"paragraph","content":[{"text":"From this skill's directory:","type":"text"}]},{"type":"code_block","attrs":{"wrap":false,"language":"bash"},"content":[{"text":"# First batch (default offset=0, limit=5)\nnode scripts/bpa-findings-helper.js scheduler ./unified-collections\nnode scripts/bpa-findings-helper.js scheduler ./unified-collections ./cleaned_file6.csv\n\n# Next batch: offset=5, limit=5\nnode scripts/bpa-findings-helper.js scheduler ./unified-collections ./cleaned_file6.csv 5 5\n\n# Full unbounded listing (development / debugging only — skill never does this)\nnode scripts/bpa-findings-helper.js scheduler ./unified-collections ./cleaned_file6.csv 0 all\n\n# Same batching on the low-level reader\nnode scripts/unified-collection-reader.js all ./unified-collections 0 5","type":"text"}]}]},"metadata":{"date":"2026-11-02","name":"migration","author":"@skillopedia","source":{"stars":319,"repo_name":"skills","origin_url":"https://github.com/adobe/skills/blob/HEAD/plugins/aem/cloud-service/skills/migration/SKILL.md","repo_owner":"adobe","body_sha256":"dfcf2546e2c0cb913961a5d4159d001e476b9df4511f82c09470c5fd7aa69e65","cluster_key":"9508ea99acd756fe25f523816aa2cfd856c25750be05231d71b952f0d420305c","clean_bundle":{"format":"clean-skill-bundle-v1","source":"adobe/skills/plugins/aem/cloud-service/skills/migration/SKILL.md","text_bytes":1027330,"source_commit":"7a3600673cc0caffe1b97f56d3e7e26f72deaca5","skills_sh_rank":13641,"attachment_count":89,"text_attachments":89,"attachment_storage":"skill_attachments","binary_attachments":0},"cluster_size":1,"skill_md_path":"plugins/aem/cloud-service/skills/migration/SKILL.md","import_metadata":{"date":"2026-11-02","author":"@skillopedia","version":"v1","category":"software-engineering","category_label":"Engineering"},"exact_dupes_collapsed_into_this":0},"license":"Apache-2.0","version":"v1","category":"software-engineering","import_tag":"skills-sh-top20k-2026-11","description":"Migrates legacy AEM (6.x, AMS, on-prem) to AEM as a Cloud Service via BPA CSV/cache and\nCAM/MCP discovery, one pattern per session. Use to review/scan a project for AEMaaCS\nmigration — generates a read-only migration-runbook.md — or to fix specific Cloud Service\nblockers: scheduler, ResourceChangeListener, replication, EventListener, OSGi EventHandler,\nDAM AssetManager, HTL data-sly-test lint, Classic UI / ExtJS / Coral 2 → Coral 3 dialog migration (lui),\nCustom Design Widgets (cdw), Vault package install-time dependencies (vault-package-dependencies),\nGuava cache → Caffeine swaps (guavaCache), java.io.InputStream usage triage (inputStream),\nOak index conversion (oakIndex, beta), and static→editable template modernization. Also\nexternalizes OSGi config secrets to Cloud Manager (scans ui.config/.cfg.json for $[secret:]/$[env:]\nplaceholders), converts AMS/on-prem Dispatcher configs to AEMaaCS (Branch E), and validates\nmigrated code on a local Cloud SDK (Branch G, beta).\n"}},"renderedAt":1793666049739}

AEM as a Cloud Service — Code Migration Source → target: Legacy AEM 6.x / AMS / on-prem → AEM as a Cloud Service . Scoped under so this is not confused with Edge Delivery or 6.5 LTS. This skill drives the migration workflow : BPA data, CAM/MCP, one pattern per session , and target discovery. Transformation rules and steps live in the skill — once a finding's pattern is identified, hand off to (or the relevant shared reference under ). Setup: Use the install (see repository root README ) so both migration and code-assessment paths are available. If you already have the monorepo open with resol…