
Migration
- 566 installs
- 158 repo stars
- Updated August 4, 2026
- adobe/skills
migration is an agent skill that plans Adobe design asset, SLICC project, and creative workflow migrations for developers preserving specs while moving to updated formats and platforms.
About
migration is an Adobe agent skill in adobe/skills for planning and executing moves of design assets, SLICC projects, and creative workflows into updated formats, platforms, or team structures without breaking specifications. It helps developers inventory source files, map target platforms, sequence cutover steps, and validate that tokens, components, and brand rules survive the transition. Teams reach for migration when upgrading design systems, consolidating libraries, or moving production creative pipelines to new Adobe tooling. The skill supports multi-step efforts spanning discovery, mapping, pilot migrations, and verification rather than one-off file copies. Use it when agents must coordinate designers and engineers around spec-safe asset transfers instead of improvising folder moves. It emphasizes rollback planning, stakeholder checkpoints, and post-migration QA on typography, color tokens, and component parity. Developers load migration when creative debt blocks platform upgrades and ad hoc scripts would risk silent spec drift across brand-critical deliverables.
- Maps source-to-target asset structures
- Preserves design tokens and component parity
- Reduces breakage during platform or format upgrades
- Documents rollback and validation checkpoints
Migration by the numbers
- 566 all-time installs (skills.sh)
- Ranked #544 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/adobe/skills --skill migrationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 566 |
|---|---|
| repo stars | ★ 158 |
| Last updated | August 4, 2026 |
| Repository | adobe/skills ↗ |
How do you migrate Adobe design assets safely?
Plan and execute migrations of Adobe design assets, SLICC projects, or creative workflows into updated formats, platforms, or team structures without breaking specs.
Who is it for?
Design-system and creative-tooling developers managing Adobe asset upgrades who need spec-safe migration plans before cutover.
Skip if: Backend engineers migrating databases or cloud infra with no Adobe creative assets should skip migration.
When should I use this skill?
User asks to migrate Adobe assets, SLICC projects, or creative workflows to new formats, platforms, or team structures.
What you get
Migration plan, mapped asset inventory, and spec-validated creative workflow on the target platform.
- Migration plan and asset inventory
- Spec mapping to target platform
- Validated post-migration creative workflow
Files
AEM as a Cloud Service — Code Migration
Source → target: Legacy AEM 6.x / AMS / on-prem → AEM as a Cloud Service. Scoped under skills/aem/cloud-service/skills/migration/ 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 `code-assessment` skill — once a finding's pattern is identified, hand off to {code-assessment}/<pattern>/SKILL.md (or the relevant shared reference under {code-assessment}/references/).
Setup: Use the `aem-cloud-service` install (see repository root README) so both migration and code-assessment paths are available. If you already have the monorepo open with resolvable {code-assessment} paths, no separate install step is required.
Quick start (for the person driving the agent)
One pattern per chat/session — if you ask to "fix everything," the skill will ask you to pick first (e.g. scheduler vs replication vs htlLint).
| You have… | Say something like… | What happens |
|---|---|---|
| A BPA CSV | "Fix scheduler findings using `./path/to/bpa.csv`" | Fastest path: CSV → cached collection → files |
| CAM + MCP only | "Get scheduler findings from CAM; I'll pick the project when you list them." | Agent lists projects → you confirm → MCP fetch (cam-mcp.md) |
| Just a few files | "Migrate scheduler in `core/.../MyJob.java`" | Manual flow: no BPA required |
| OSGi → Cloud Manager | "Scan my config files and create Cloud Manager environment secrets or variables." | Agent auto-reads references/osgi-cfg-json-cloud-manager.md (full Adobe-aligned rules inlined there); no BPA pattern id |
| HTL lint warnings | "Fix htlLint issues in `ui.apps`" | Proactive discovery via rg → fix per the HTL lint reference |
Starter prompts (copy-paste):
- "Use the migration skill: scheduler only, BPA CSV at `./reports/bpa.csv`, then apply the code-assessment pattern guide before editing."
- "Replication only from CAM; list projects first, I'll pick one."
- "Manual: event listener migration for `.../Listener.java` — read the code-assessment pattern guide first."
- "Scan my config files and create Cloud Manager environment secrets or variables."
- "Fix htlLint in `ui.apps` — scan for `data-sly-test` redundant constant warnings and fix them."
Path convention (Adobe Skills monorepo)
From the repository root (parent of the skills/ directory):
| Symbol | Path |
|---|---|
| `{code-assessment}` | skills/aem/cloud-service/skills/code-assessment/ |
Examples: {code-assessment}/SKILL.md, {code-assessment}/scheduler/SKILL.md, {code-assessment}/references/scr-to-osgi-ds.md.
Workspace scope (IDE) — user code only
Applies to finding and editing the user's AEM project (Java, bundles, config, HTL), not to reading installed skill files under {code-assessment}.
- Treat the current IDE workspace root folder(s) (single- or multi-root) as the only boundary for searches, globs,
grep, and file reads/writes for migration targets. - Do not search parent directories, sibling folders on disk,
~, other clones, or arbitrary absolute paths to "discover" sources unless the user explicitly names those paths or asks you to include them. - BPA CSV / CAM targets: If a
filePathor class-to-file mapping does not resolve under a workspace root, stop 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. - Manual flow: 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.
Required delegation (do this first)
Branch A — OSGi configs → Cloud Manager (no Java BPA pattern this session): If the user asks to scan config files, create / set up Cloud Manager environment secrets or variables, move passwords or secrets out of OSGi / `.cfg.json` / `ui.config`, or mentions `$[secret:]` / `$[env:]` for AEM CS, then read [references/osgi-cfg-json-cloud-manager.md](references/osgi-cfg-json-cloud-manager.md) immediately and follow the product rules and workflow 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 — no need to name the reference file. Skip branch B for that work.
Branch B — Java / HTL / BPA pattern migration:
1. Read `{code-assessment}/SKILL.md` — critical rules, Java baseline links, Pattern Guides table, Manual Pattern Hints. 2. Read the pattern guide (or reference) for the single active pattern:
scheduler→ `{code-assessment}/scheduler/SKILL.md` (pattern guide)resourceChangeListener→ `{code-assessment}/resource-change-listener/SKILL.md` (pattern guide)replication→ `{code-assessment}/replication/SKILL.md` (pattern guide)eventListener/eventHandler→ `{code-assessment}/event-migration/SKILL.md` (pattern guide — both JCR and OSGi Event Admin paths)assetApi→ `{code-assessment}/asset-manager/SKILL.md` (pattern guide)htlLint→ `{code-assessment}/references/data-sly-test-redundant-constant.md` (reference — HTL lint is a single shared reference, not a dedicated pattern guide)
3. When code uses SCR, ResourceResolver, or console logging, read `{code-assessment}/references/scr-to-osgi-ds.md` and `{code-assessment}/references/resource-resolver-logging.md` (or the hub `{code-assessment}/references/aem-cloud-service-pattern-prerequisites.md`).
Do not transform Java or HTL until the pattern guide (or reference) is read (branch B). Branch A does not require {code-assessment} pattern guidance.
When to Use This Skill
- Migrate legacy AEM Java toward Cloud Service–compatible patterns
- Fix HTL (Sightly) lint warnings (
data-sly-test: redundant constant value comparison) across component templates - Drive work from BPA (CSV or cached collection) or CAM via MCP
- Enforce one pattern type per session
- OSGi → Cloud Manager: Branch A — scan scoped `.cfg.json`, apply `$[secret:…]` / `$[env:…]` per rules in [references/osgi-cfg-json-cloud-manager.md](references/osgi-cfg-json-cloud-manager.md); gitignored handoff; no secret values in chat.
OSGi configs and Cloud Manager (no BPA pattern id)
Sleek user prompts are enough (see Quick start). Agent: Branch A → read the reference → One-prompt workflow; obey the inlined Adobe AEM CS rules in that file (value types, placeholders, CM API/CLI, custom-properties-only, repoinit, runmode context, local SDK secrets). Ambiguous or Adobe-owned PIDs → `needs_user_review`, not guesses.
Prerequisites
- Project source and Maven/Gradle build
- BPA CSV or MCP access optional but recommended
- For htlLint:
ui.appsor equivalent content package with.htmlHTL templates
BPA findings — flow
Scripts run via `getBpaFindings` (see Calling the helper); do not reimplement collection logic by hand unless the helper is unavailable.
The helper has two independent paths, chosen by what the caller configures:
1. MCP configured (mcpFetcher + projectId passed) → first call fetches all findings from MCP and caches them to <collectionsDir>/mcp/<projectId>/<pattern>.json. Every call (first and subsequent) reads from the MCP cache and returns one batch. 2. MCP not configured, BPA CSV provided → first call parses the CSV and writes the unified-collection JSON to <collectionsDir>/unified-collection.json. Every call reads from the CSV cache and returns one batch.
The two caches are disjoint — MCP sessions and CSV sessions never shadow each other. If neither is configured, the helper reports no-source and the agent asks for one.
Batching is mandatory on every path. getBpaFindings returns findings in batches of 5 by default with a paging envelope:
result.targets // this batch (length <= limit)
result.paging // { total, returned, offset, limit, nextOffset, hasMore }Process one batch at a time; stop after each batch and report progress to the user; resume on the user's go-ahead by re-calling the helper with offset: paging.nextOffset. See Batched processing (batch size 5) below.
Note: htlLint does not appear in BPA CSV — it uses proactive rg discovery instead. See htlLint flow below.
CAM via MCP (summary)
Use `fetch-cam-bpa-findings-by-pattern` for code-transformer pattern flows (scheduler, assetApi, eventListener, resourceChangeListener, eventHandler) and `fetch-cam-bpa-findings-by-importance` when the user instead asks "what are the critical/major/advisory/info findings?" (returns the latest BPA report's authoritative _COUNT_<code> rows at one importance level, sorted by descending count). Either tool requires explicit user confirmation of the project before being called — ask the user for their CAM project name or ID; the tools resolve it internally (prefer `projectId` when known). Do not pass an unconfirmed project name string. Full tool schemas, REST notes, retries, and error handling: references/cam-mcp.md.
What the user might say
- "Fix scheduler using ./reports/bpa.csv" → CSV path known
- "Fix scheduler" → collection → MCP → ask for CSV
- "Migrate `core/.../Foo.java`" → manual flow
- "Fix htlLint in ui.apps" → proactive discovery flow
Calling the helper
Scripts live under `./scripts/` (next to this SKILL.md).
const { getBpaFindings } = require('./scripts/bpa-findings-helper.js');
// First batch (defaults: limit=5, offset=0)
const result = await getBpaFindings(pattern, {
bpaFilePath: './cleaned_file6.csv',
collectionsDir: './unified-collections',
projectId: '...',
mcpFetcher: mcpFunction
// limit: 5, // implicit default
// offset: 0, // implicit default
});
// Next batch — only after the user says to continue
if (result.paging?.hasMore) {
const next = await getBpaFindings(pattern, {
bpaFilePath: './cleaned_file6.csv',
collectionsDir: './unified-collections',
projectId: '...',
mcpFetcher: mcpFunction,
offset: result.paging.nextOffset
});
}`result`:
success,source('unified-collection' | 'bpa-file' | 'mcp-server' | …)message(includes a human-readable batch status)targets— the current batch (length<= limit)paging: { total, returned, offset, limit, nextOffset, hasMore }— always present on
successful calls
To disable batching for a one-off programmatic caller, pass limit: null. The skill workflow itself never does this.
Collection caching
Collections live under `./unified-collections/`. If a collection exists and the user supplies a new CSV, ask whether to reuse or re-process.
Reading a BPA CSV
Filter rows where `pattern` matches the session pattern. Typical columns: pattern, filePath, message.
MCP errors and fallback
Critical: On MCP failure, stop the workflow immediately and give the user the exact tool error message (verbatim), including "not found" / 404-style project errors. Do not continue with migration steps, infer a different CAM project from the workspace, or switch to manual/local migration on your own.
Exception: enablement restriction errors (prefix documented in references/cam-mcp.md) must be shown verbatim with no paraphrase and no automatic fallback until the user addresses them.
After stopping, you may summarize what failed in plain language and, if helpful, re-show projects from `list-projects`. Only continue when the user explicitly directs the next step (e.g. correct project id/name from the list, BPA CSV path, or specific Java files for manual flow).
For retries, error categories, and when user-directed CSV/manual paths are allowed, follow references/cam-mcp.md; still no silent fallback. Never hide tool errors from the user.
Optional prompt after stop (user must reply): "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."
Pattern guides
Do not duplicate the pattern table here. Use `{code-assessment}/SKILL.md` → Pattern Guides — five patterns each have a pattern guide ({code-assessment}/<pattern>/SKILL.md); shared topics (SCR→DS, ResourceResolver/SLF4J, HTL lint, prerequisites hub) stay as references ({code-assessment}/references/<file>.md). See Branch B step 2 above for the per-pattern routing table.
Workflow
One pattern per session
If the user asks to fix everything or BPA mixes patterns, ask which pattern first. Prefer one commit per pattern session.
Step 1: Pattern id
If the request is OSGi configs → Cloud Manager (see Required delegation, branch A), do not map to a BPA pattern — follow references/osgi-cfg-json-cloud-manager.md instead.
Otherwise map the request to a pattern id: scheduler, resourceChangeListener, replication, eventListener, eventHandler, assetApi, htlLint. If unclear, use Manual Pattern Hints in `{code-assessment}/SKILL.md` or ask the user to pick one of those.
Step 2: Availability
If the id is missing from the code-assessment catalog (`{code-assessment}/references/patterns.md`), say the pattern is not supported yet.
Step 3: Targets
For BPA patterns (scheduler, resourceChangeListener, replication, eventListener, eventHandler, assetApi): Run `getBpaFindings` (with bpaFilePath when provided). Internally: cache → CSV → MCP → manual only when each step is applicable and succeeds; if MCP fails, obey MCP errors and fallback (stop; no silent chain). For MCP details, references/cam-mcp.md.
getBpaFindings returns a batch of 5 findings (default limit=5) along with a paging envelope. The agent processes that batch only; it does not request the next batch until the user says to continue. See Batched processing (batch size 5) below.
For `htlLint`: Skip BPA/CSV/MCP — targets come from proactive rg discovery. See htlLint flow below.
Step 4: Read before edits
STOP. Read `{code-assessment}/SKILL.md` and the pattern guide (or reference) for the active pattern — see Branch B step 2 above for the pattern → file routing table.
Step 5: Process the batch
For each finding in the returned batch only (up to 5):
1. Resolve the target inside the IDE workspace (see Workspace scope (IDE)). 2. Read source → classify with the pattern guide (or reference) → apply steps in order → check lints → next file.
Do not request the next batch mid-processing. Never hold more than one batch of findings in working memory at a time.
Step 6: Report batch and wait
After finishing the batch, summarise for this batch only:
paging.returnedfindings processed (ofpaging.total), with class names.- Any files touched, plus any skips / failures.
- If
paging.hasMore === true, tell the user:
"Processed batch of N (offset {offset}–{offset + returned − 1} of {total}). Reply `continue` to process the next batch, or name specific classes to focus on."
- If
paging.hasMore === false, say the pattern is done and move to the overall session report.
Stop and wait for the user. Do not automatically start the next batch. Only call getBpaFindings (or fetch-cam-bpa-findings-by-pattern) again when the user explicitly requests it, and pass offset: paging.nextOffset unchanged.
Manual flow (no BPA)
User-named files → classify (code-assessment Manual Pattern Hints or ask) → confirm the pattern guide or reference exists → read `{code-assessment}/SKILL.md` + the pattern guide (or reference) — see Branch B step 2 routing — → transform → report.
OSGi → Cloud Manager flow
Does not use BPA CSV, CAM/MCP, or code-assessment pattern guides for collection. Follow Branch A in Required delegation and the One-prompt workflow in references/osgi-cfg-json-cloud-manager.md.
htlLint flow
htlLint does not use BPA CSV or CAM/MCP. Instead:
1. Read `{code-assessment}/references/data-sly-test-redundant-constant.md` — it contains the Workflow, Proactive Discovery rg patterns, and all 4 fix patterns. (HTL lint lives as a shared reference, not a dedicated pattern guide.) 2. Discover targets using the rg commands from the reference's Proactive Discovery table (scope: ui.apps/**/jcr_root/**/*.html or the user's content package paths). 3. Group hits by file, classify each by pattern (boolean literal, raw string, numeric, split expression). 4. Fix each hit per the matching pattern section in the reference. 5. Report and recommend the user run mvn clean install or HTL validate to confirm no warnings remain.
Batched processing (batch size 5)
Findings are served to the agent in batches of 5 by default, regardless of source (MCP or CSV). Batching happens client-side — 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.
Rules
1. Default `limit` is 5. Pass limit: 5 (or accept the helper default). The skill never requests a larger batch unless the user has explicitly asked for one. 2. Offset starts at 0 and advances by result.paging.nextOffset from the previous call. Do not compute offsets from offset + limit — read nextOffset from the previous response; it is authoritative. 3. Stable ordering. Each cache file is written once with a deterministic order; every slice from it is therefore stable and contiguous. 4. One batch per call. One batch in memory at a time. Process, report, stop. No pre-fetching, no merging across batches. 5. Resume is stateless. The skill does not maintain its own progress file. Resuming means "call the helper again with offset: previous.paging.nextOffset". If the session ends, a later session calls with the same pattern and offset and gets the same batch. 6. Done when `paging.hasMore === false` (or paging.nextOffset === null). 7. To refresh source data, delete the relevant cache file:
- CSV:
<collectionsDir>/unified-collection.json - MCP:
<collectionsDir>/mcp/<projectId>/<pattern>.json
Agent-visible flow (CSV path)
[User] "Fix scheduler findings using ./reports/bpa.csv"
[Agent] getBpaFindings('scheduler', { bpaFilePath, limit: 5, offset: 0 })
// first call parses CSV → writes <dir>/unified-collection.json → slices
→ paging: { total: 137, returned: 5, offset: 0, nextOffset: 5, hasMore: true }
Processes 5 findings.
Reports: "Processed 5 of 137 (offset 0–4). Reply `continue` for the next batch."
[User] "continue"
[Agent] getBpaFindings('scheduler', { bpaFilePath, limit: 5, offset: 5 })
// reads cached JSON — no CSV re-parse
→ paging: { ..., offset: 5, nextOffset: 10, hasMore: true }
Processes next 5.
...Agent-visible flow (MCP path)
[User] "Fix scheduler findings from CAM project <id>"
[Agent] getBpaFindings('scheduler', { mcpFetcher, projectId, limit: 5, offset: 0 })
// first call: one MCP fetch → writes <dir>/mcp/<projectId>/scheduler.json → slices
→ paging: { total: 137, returned: 5, offset: 0, nextOffset: 5, hasMore: true }
Processes 5 findings.
Reports and stops.
[User] "continue"
[Agent] getBpaFindings('scheduler', { mcpFetcher, projectId, limit: 5, offset: 5 })
// reads cached MCP JSON — NO additional MCP call
→ paging: { ..., offset: 5, nextOffset: 10, hasMore: true }
...Do not
- Do not call
getBpaFindingswithlimit: nullinside the skill flow. That option exists
only for programmatic callers that deliberately want the full list.
- Do not invent a next batch offset. Always read
paging.nextOffsetfrom the previous
response.
- Do not accumulate
targetsacross batches in memory. - Do not call the MCP tool for every batch; the first call caches, subsequent batches read
the cache.
Quick reference
Source priority (when choosing how to obtain targets): unified collection → BPA CSV → MCP → manual paths. Not an automatic cascade after MCP errors — if MCP fails, stop and wait for user direction (see MCP errors and fallback). For htlLint, use proactive rg discovery (no BPA/MCP). For OSGi → Cloud Manager, use references/osgi-cfg-json-cloud-manager.md only (no BPA/MCP).
Batch size: 5 (default) on every BPA source. See Batched processing above.
User-facing snippets: "Using existing BPA collection (N findings)…" / "Processing your BPA report…" / "Fetched findings from CAM." / "Scanning HTL templates for data-sly-test lint issues…" / optional prompt after MCP stop above.
CLI (development only)
From this skill's directory:
# First batch (default offset=0, limit=5)
node scripts/bpa-findings-helper.js scheduler ./unified-collections
node scripts/bpa-findings-helper.js scheduler ./unified-collections ./cleaned_file6.csv
# Next batch: offset=5, limit=5
node scripts/bpa-findings-helper.js scheduler ./unified-collections ./cleaned_file6.csv 5 5
# Full unbounded listing (development / debugging only — skill never does this)
node scripts/bpa-findings-helper.js scheduler ./unified-collections ./cleaned_file6.csv 0 all
# Same batching on the low-level reader
node scripts/unified-collection-reader.js all ./unified-collections 0 5AEM as a Cloud Service — Code Migration
This skill drives migration from legacy AEM (6.x, AMS, or on-prem) to AEM as a Cloud Service: Best Practices Analyzer (BPA) data, Cloud Acceleration Manager (CAM) via MCP when available, and a one-pattern-per-session workflow.
Target platform is always AEM as a Cloud Service. Source is legacy AEM; ambiguous top-level “migration” is avoided by scoping this under skills/aem/cloud-service/skills/migration/.
Requires code-assessment
This skill is not standalone. It drives BPA/CAM and target discovery; step-by-step refactors live only in the [`code-assessment`](../code-assessment/) skill. Five major patterns each have a pattern guide (scheduler/, resource-change-listener/, replication/, event-migration/, asset-manager/); shared topics (SCR→DS, ResourceResolver/SLF4J, HTL lint, prerequisites hub) live as references under references/. For any code change, the agent must read the relevant pattern guide or reference — migration does not copy those procedures here.
- You need both: use migration for workflow and targets; use code-assessment for how to edit Java/OSGi and apply each pattern.
- Install once, get both: the umbrella `aem-cloud-service` plugin (path
skills/aem/cloud-service) includesmigration/andcode-assessment/together. Do not rely on migration alone unless the samecode-assessmentfiles are already on disk (for example fulladobe/skillscheckout with working{code-assessment}links).
Skills
migration
- BPA collection, CSV, and CAM/MCP flows (CAM tool schemas and retries:
references/cam-mcp.md) - Manual flow and pattern auto-detection
- Points to `code-assessment` for all detailed transformation steps
First run: In chat, name one BPA pattern (e.g. scheduler) and either a CSV path, CAM/MCP, or concrete Java files. See Quick start in SKILL.md for copy-paste prompts and the CAM happy path in references/cam-mcp.md.
Installation
Use the root Adobe Skills README: install `aem-cloud-service` (Claude /plugin), or add `skills/aem/cloud-service` with npx skills / gh upskill --path — not the migration/ or code-assessment/ subfolders alone.
Prerequisites
- AEM project with Maven/Gradle
- Access to sources to migrate
- BPA results recommended (CSV or CAM)
For issues, see the main Adobe Skills repository.
CAM / MCP (Cloud Adoption Service)
Read this file when fetching BPA targets via MCP instead of a CSV or cached collection. Parent skill: ../SKILL.md.
Happy path (what the user should see)
1. Agent asks the user for their CAM project name or ID. 2. You confirm the project name or ID (the agent should not guess or infer it). 3. Agent calls `fetch-cam-bpa-findings-by-pattern` once with the confirmed project and the one pattern for this session (scheduler, assetApi, etc., or all then filtered). The MCP server returns all findings for that pattern. 4. bpa-findings-helper.js caches the response to <collectionsDir>/mcp/<projectId>/<pattern>.json and applies the batch slice (default 5) client-side. 5. Agent processes that batch, reports progress (returned / total), then stops. The user says whether to continue. 6. On continue, the agent re-invokes getBpaFindings with offset = paging.nextOffset; the helper reads the cached MCP fetch — no additional MCP calls — and returns the next batch.
See Batching below for the full contract.
Project name and ID — non-negotiable
- Never call `fetch-cam-bpa-findings-by-pattern` or `fetch-cam-bpa-findings-by-importance` with a
projectIdorprojectNamethat the user has not explicitly confirmed. - If the tool returns a "project not found" error, quote the error verbatim, and ask the user to provide the correct project name or ID — do not guess or retry with a fuzzy match.
- Do not infer the CAM project from the open workspace, repository name, or sample code (e.g. WKND) when using MCP.
MCP errors — stop first (especially project-not-found)
On any MCP failure, stop the migration workflow immediately. Quote the tool error verbatim in your reply to the user (including 404-style messages such as No project found matching "…"). Do not continue with BPA processing, manual file migration, or "local codebase" assumptions on your own.
Exception: enablement restriction errors below — follow that section exactly (verbatim to user; no retry; no silent fallback).
After that verbatim report, you may briefly explain what went wrong (e.g. unknown project name). Only if the user explicitly asks to switch approach (e.g. provides a BPA CSV path, picks another project, or names specific Java files for manual flow) may you proceed — that is a new user-directed step, not an automatic fallback.
For other failures (auth, timeout, 5xx), still quote errors verbatim; use retries only where the table below allows, then stop and ask how the user wants to continue (CSV, different project, or manual files) — do not silently pivot.
Below: tool shapes and maintainer notes for the agent. You can skip the TypeScript until you need parameter details.
---
Enablement restriction errors (mandatory handling)
Some AEM Cloud Service Migration MCP deployments return an error when the server is not enabled for the requested org, project, or operation. When the tool error starts with:
The MCP Server is restricted and isn't able to operate on the given
You must:
1. Output that error message to the user verbatim — same text, in full, including any contact or enablement details the server appended. Do not paraphrase, summarize, or "translate" it into your own words. 2. Do not retry the same tool call to "work around" this response. 3. Do not silently fall back to CSV or manual paths as if MCP had merely failed — the user may need to complete enablement or follow the instructions embedded in the error first. After they confirm they have addressed it, you may continue (including retrying MCP if appropriate).
If your MCP server documentation adds other error prefixes or codes with the same "no paraphrase / no silent fallback" rule, treat those the same way and keep this file aligned with that documentation.
---
Rules before any tool call
1. Ask the user for their CAM project name or ID. Do not guess from the workspace or sample code. 2. Wait for explicit confirmation, then call `fetch-cam-bpa-findings-by-pattern` (for code-transformer pattern flows) or `fetch-cam-bpa-findings-by-importance` (for "what are the critical/major findings?" requests) using the confirmed projectId (preferred) or projectName. 3. For pattern flows: map the session's single pattern to the tool's pattern argument (scheduler, assetApi, eventListener, resourceChangeListener, eventHandler, or all). If you used all, filter targets to the active pattern.
Tool: fetch-cam-bpa-findings-by-pattern
Pattern-axis tool registered by the server. It can also resolve a project name to a project ID internally (via the CAM projects API), so a separate list-projects call is not needed — pass projectName or projectId.
Request (illustrative — confirm against live MCP tool schema):
{
projectId?: string; // CAM project ID; either this or projectName must be provided
projectName?: string; // human-readable name; resolved to projectId via CAM /projects API
pattern?: "scheduler" | "assetApi" | "eventListener" | "resourceChangeListener" | "eventHandler" | "all";
environment?: "dev" | "stage" | "prod"; // defaults to "prod"
}The MCP server is not required to implement paging; the helper batches client-side.
Success response (shape may vary by server version):
{
success: true;
environment?: "dev" | "stage" | "prod";
projectId: string;
targets: Array<{
pattern: string;
className: string;
identifier: string;
issue: string;
severity?: string;
}>;
summary?: Record<string, number>;
/**
* Present when a specific pattern was requested but has no findings in the
* latest BPA report (e.g. "No 'scheduler' findings in the latest BPA report
* for project <id>."). `targets` will be an empty array. This is a successful
* response — the report exists, there are just no findings for this pattern.
*/
message?: string;
}Error response:
{
success: false;
error: string;
errorDetails?: { message: string; name: string; code?: string };
troubleshooting?: string[];
suggestion?: string[];
}Example:
// One-shot fetch per (projectId, pattern). The helper caches this and pages
// subsequent batches from the cache — do not call the MCP tool for every batch.
const resp = await fetchCamBpaFindings({
projectId: "<user-confirmed-cam-project-id>",
pattern: "scheduler",
environment: "prod"
});
// resp.targets — full list for this pattern---
Tool: fetch-cam-bpa-findings-by-importance
Importance-axis tool registered by the server. Returns all findings at one importance level (CRITICAL, MAJOR, ADVISORY, or INFO) from the latest BPA report uploaded to the project, sourced from the BPA's authoritative _COUNT_<code> rollup rows and pre-sorted by descending count.
Use this tool when the user asks open-ended questions like "what are the critical findings?" or "show me the major issues", rather than a code-transformer migration flow (those use fetch-cam-bpa-findings-by-pattern). The same project-confirmation guardrails apply: never call it with an unconfirmed projectId / projectName.
Request (illustrative — confirm against live MCP tool schema):
{
importance: "CRITICAL" | "MAJOR" | "ADVISORY" | "INFO"; // required
projectId?: string; // CAM project ID; either this or projectName must be provided
projectName?: string; // human-readable name; resolved to projectId via CAM /projects API
environment?: "dev" | "stage" | "prod"; // defaults to "prod"
}Backed by: GET /projects/{projectId}/bpaReportCodeTransformerData/findings/{importance}
Success response:
{
success: true;
importance: "CRITICAL" | "MAJOR" | "ADVISORY" | "INFO";
environment?: "dev" | "stage" | "prod";
projectId: string;
reportId: string | null;
reportTime: number | null; // epoch millis from BpaOverview, when available
findings: Array<{
code: string; // e.g. "_COUNT_ACV"
type: string; // e.g. "_count.assets.health"
subtype: string; // e.g. "missing.original.rendition"
importance: "CRITICAL" | "MAJOR" | "ADVISORY" | "INFO";
count: number;
}>;
}A 404 from the backend (no BPA report uploaded yet) is mapped to success: true with findings: [] and reportId: null.
Error response: same shape as fetch-cam-bpa-findings-by-pattern (success: false, error, optional troubleshooting / suggestion). Apply the same retry table.
---
Batching
The migration skill processes BPA findings in batches of 5 by default. Batching is entirely client-side — the MCP server is called once per (projectId, pattern); the helper caches the response to disk and slices from the cache on subsequent calls.
Contract
- The MCP tool does not need to support `limit` or `offset`. It is called once and
returns the full list for the requested pattern.
- Ordering should be stable across calls. If the underlying BPA run changes between
fetches and the user wants fresh data, they (or the agent) delete <collectionsDir>/mcp/<projectId>/<pattern>.json to trigger a re-fetch.
- No severity filtering. Within a single pattern all findings are equal rank; the server
must not silently reorder or filter by severity.
- Cache location:
<collectionsDir>/mcp/<projectId>/<pattern>.json. Disjoint from the
CSV path's cache at <collectionsDir>/unified-collection.json, so MCP and CSV cannot shadow each other.
Agent rules
1. Call fetch-cam-bpa-findings-by-pattern once per (projectId, pattern) — the helper does this the first time the agent invokes getBpaFindings on the MCP path. 2. After each batch the helper returns, process only those findings, then stop: "Processed 5 of 137 scheduler findings. Reply `continue` for the next batch, or name specific classes to focus on." 3. On continue, re-invoke getBpaFindings with offset = paging.nextOffset. The helper reads the local cache — no additional MCP call. 4. Stop when paging.hasMore === false (or paging.nextOffset === null). 5. Never accumulate more than one batch in working memory. Each batch is independent. 6. To refresh MCP data, delete the cache file for that (projectId, pattern) and re-invoke.
---
Retries and agent behavior
MCP tool: The server implements its own retry and timeout logic internally. Do not add agent-side retries on top unless the table below says otherwise.
Agent:
1. If the failure matches Enablement restriction errors, handle it only as described there (verbatim output; no retry; no silent CSV/manual fallback). 2. Check result.success before using result.targets. 3. If pattern was all, filter targets to the one pattern chosen for this session. 4. Use className (and any file paths the server returns) to locate Java sources only under the current IDE workspace root(s). If a path does not exist there, report it and ask the user — do not search outside the open project. 5. On other failures, stop; quote the error verbatim. Use retries only per the table. Do not automatically continue with CSV or manual migration — wait for the user to choose the next step after they have seen the error.
| Situation | Retry? | Action |
|---|---|---|
Error starts with The MCP Server is restricted and isn't able to operate on the given | No | Verbatim to user; stop automatic fallback |
| Auth 401 / 403 | No | Quote error verbatim; stop. Ask how to proceed (credentials, CSV, or named files) only after stopping. |
404 / "no project found" / unknown projectId | No | Quote error verbatim; stop. Require user to confirm the correct project name/ID or choose another source (CSV / explicit file list). No automatic "local workspace" migration. |
| Network / timeout | Once | Retry after ~2s, then quote error verbatim and stop if still failing. |
| 5xx | Once | Retry after ~2s, then quote error verbatim and stop if still failing. |
| 400 | No | Quote error verbatim; stop; ask user to fix parameters or pick another path. |
success: true, empty targets, message present (specific pattern, no findings) | No | Show the message verbatim (e.g. "No 'scheduler' findings in the latest BPA report for project X."). Offer options — other pattern, CSV, explicit files — only as choices; do not start editing the repo without BPA targets unless the user picks manual files. |
success: false, empty targets (pattern: "all" or error) | No | Quote error verbatim; stop. Offer options (other pattern, CSV, explicit files) only as choices for the user. |
OSGi configs: scan → Cloud Manager environment secrets / variables
Agent: The parent skill loads this file for prompts such as "scan my config files and create Cloud Manager environment secrets or variables." Users do not name this path.
The sections below through Cloud Manager and deployment reproduce the rules from Adobe Experience Manager as a Cloud Service product documentation for configuring OSGi (deploying topic: OSGi configuration with secret and environment-specific values). Follow them when editing configs or advising on Cloud Manager.
---
OSGi in the AEM project
OSGi manages bundles and their configurations. Settings are defined in configuration files that are part of the AEM code project. Cloud Manager is used to configure environment variables that back OSGi placeholders.
Configuration files (.cfg.json)
- Configuration changes belong in the project's code packages (`ui.config`) as `.cfg.json` files under runmode-specific config folders, for example under paths like `/apps/<appId>/config.<runmode>/` (in the content tree; in Maven this is commonly under `ui.config` / `osgiconfig`).
- The format is JSON, using the `.cfg.json` format defined by the Apache Sling OSGi configuration installer.
- OSGi configurations target components by Persistent Identity (PID). The PID usually matches the fully qualified Java class name of the OSGi component implementation. Example file path:
.../config/com.example.workflow.impl.ApprovalWorkflow.cfg.json
- Factory configurations use the `<factoryPID>-<name>.cfg.json` naming convention.
Superseded formats: Older AEM versions allowed `.cfg`, `.config`, and XML `sling:OsgiConfig`. On AEM as a Cloud Service these are superseded by `.cfg.json`. See Phase 0 — Legacy format conversion below for conversion rules.
Cloud runtime note: On AEM as a Cloud Service, effective OSGi configuration is not held like a classic on-prem `/apps`-only model; use the environment's Developer Console (Status → Configurations in the status dump) to inspect what is applied.
Runmodes (context for configs)
- AEM 6.x allowed custom runmodes; AEM as a Cloud Service does not. Only the documented Cloud Service runmode set applies. Differences between Cloud environments that runmodes cannot express are handled with OSGi configuration environment variables (
$[env:…]/$[secret:…]). - Runmode-specific folders live under `/apps/<appId>/` using names like `config.<author|publish>.<dev|stage|prod>` (and combinations such as `config.author`, `config.author.dev`). Configs apply when the folder's runmodes match the instance.
- If multiple configs apply to the same PID, the one with the highest number of matching runmodes wins. Resolution is at PID level: you cannot split properties for the same PID across two folders—one winning file applies to the whole PID.
- Preview: A `config.preview` folder is not declared like `config.publish`. The preview tier inherits OSGi configuration from publish.
- Local SDK: Runmodes can be set at startup, e.g. `-r publish,dev` on the quickstart JAR.
Valid Cloud Service runmode tokens (exhaustive):
| Token | Meaning |
|---|---|
author | Author tier |
publish | Publish tier |
dev | Development environment |
stage | Stage environment |
prod | Production environment |
Valid folder names use combinations: config, config.author, config.publish, config.dev, config.stage, config.prod, config.author.dev, config.author.stage, config.author.prod, config.publish.dev, config.publish.stage, config.publish.prod. Any folder whose name does not match one of these (e.g. config.qa, config.integration, config.local, config.ams) is invalid on Cloud Service.
Verifying effective config: In Cloud Service, use Developer Console → select Pod → Status → Status Dump → Configurations → Get Status. Match `pid` to the `.cfg.json` filename and compare `properties` to the repo for the runmode under review.
---
Types of OSGi configuration values
Three kinds (a single `.cfg.json` may mix them):
1. Inline values — hard-coded in JSON and stored in Git, e.g. { "connection.timeout": 1000 } 2. Secret values — must not be stored in Git, e.g. { "api-key": "$[secret:server-api-key]" } 3. Environment-specific values — vary between development environments in ways runmodes cannot target (Cloud Service has a single `dev` runmode), e.g. { "url": "$[env:server-url]" }
Example combining all three:
{
"connection.timeout": 1000,
"api-key": "$[secret:server-api-key]",
"url": "$[env:server-url]"
}When to use which type
- Inline is the default. Prefer inline when possible: values live in Git with history, deploy with code, and need no extra CM coordination. Start with inline; use secrets or env-specific placeholders only when the use case requires it.
`$[env:ENV_VAR_NAME]` (non-secret) — Use only when values differ for preview vs publish or across development environments (including local SDK and Cloud dev). For Stage and Production, avoid non-secret $[env:…] except where preview must differ from publish; use inline values in `config.stage` / `config.prod` for non-secrets. Do not use $[env:…] to push routine runtime changes to Stage/Prod without source control.
`$[secret:SECRET_VAR_NAME]` — Required for any secret OSGi value (passwords, private API keys, anything that must not be in Git). Use for all Cloud environments including Stage and Production.
Custom code only — no Adobe override
`$[env:…]` must be used only for OSGi properties related to customer custom code. It must not be used to override Adobe-defined OSGi configuration. Treat `$[secret:…]` the same way for this skill: do not introduce placeholders on Adobe/product PIDs unless the user explicitly confirms an allowed exception.
Repoinit
Placeholders cannot be used in repoinit statements. Do not add $[secret:…] or $[env:…] to Repository Initializer content. Skip *`org.apache.sling.jcr.repoinit.RepositoryInitializer`** files for placeholder injection.
Placeholder syntax
- Environment (non-secret): `$[env:ENV_VAR_NAME]`
- Secret: `$[secret:SECRET_VAR_NAME]`
If no value is set in Cloud Manager, the placeholder may remain unreplaced. Default (for both env and secret placeholders):
$[env:ENV_VAR_NAME;default=<value>](with the same pattern for secrets when a default is appropriate per product behavior).
Variable names and values (Cloud Manager)
Applies to both $[env:…] and $[secret:…] variable names:
| Rule | Requirement |
|---|---|
| Name length | 2–100 characters |
| Name pattern | *`[a-zA-Z_][a-zA-Z_0-9]`** |
| Value length | Values must not exceed 2048 characters |
| Count | Up to 200 variables per environment |
Reserved prefixes: Names starting with `INTERNAL_`, `ADOBE_`, or `CONST_` are reserved—customer variables with those prefixes are ignored. Customers must not reference `INTERNAL_` or `ADOBE_` variables.
`AEM_`: Variables with prefix `AEM_` are product-defined public API. Customers may use and set those Adobe provides but must not define new custom variables with the `AEM_` prefix.
Local development
- Non-secret `$[env:…]`: Define normal process environment variables before starting AEM (e.g.
export ENV_VAR_NAME=my_value). A small shell script run before startup is recommended; non-secret values may be shared in source control if appropriate. - `$[secret:…]`: Each secret needs a plain text file named exactly after the variable (e.g. for
$[secret:server_password]a file `server_password`). No file extension. Store all such files in one directory and set Sling `org.apache.felix.configadmin.plugin.interpolation.secretsdir` to that directory in `crx-quickstart/conf/sling.properties` (framework property, not Felix web console), e.g.
org.apache.felix.configadmin.plugin.interpolation.secretsdir=${sling.home}/secretsdir
Author vs publish (same PID, different values)
Use separate `config.author` and `config.publish` folders. Prefer the same variable name in both with `$[env:ENV_VAR_NAME;default=<value>` where the default matches the tier, and bind values per tier in Cloud Manager using the API `service` parameter (author, publish, or preview). Alternatively use distinct names such as `author_<name>` and `publish_<name>`.
---
Cloud Manager API and CLI
- API role: The Cloud Manager API caller needs Deployment Manager - Cloud Service (other roles may not run all operations).
- Set variables: `PATCH /program/{programId}/environment/{environmentId}/variables` — deploys variables like a pipeline deploy; author and publish restart and pick up values after a few minutes. Body is a JSON array of objects with `name`, `value`, and `type`: use `string` (default) for `$[env:…]`, `secretString` for `$[secret:…]`.
- Default values for interpolation are not set via this API—they belong in the OSGi placeholder (e.g.
;default=…). - List: `GET` the same `…/variables` path. Delete: `PATCH` with the variable included and an empty value.
- CLI:
aio cloudmanager:list-environment-variables ENVIRONMENT_ID
aio cloudmanager:set-environment-variables ENVIRONMENT_ID --variable NAME "value" --secret NAME "value" aio cloudmanager:set-environment-variables ENVIRONMENT_ID --delete NAME …
Environment variables can also be maintained in the Cloud Manager UI (Environment variables).
Deployment and governance
Secret and env-specific values live outside Git. Customers should govern them as part of the release process. Variable API calls do not run the same quality gates as a full code pipeline. Set variables before or when deploying code that depends on them. The API may fail while a pipeline is running; errors may be non-specific.
Additive changes: Prefer new variable names when rotating values so older deployments never pick up wrong values; remove old names only after releases are stable. This also helps rollbacks and disaster recovery when redeploying older code.
---
This skill: repository scope and workflow
In scope for automated edits
- `.cfg.json` files under `ui.config` or `ui.apps/.../jcr_root/...` where the file's parent directory name starts with
config. - Legacy `.cfg`, `.config`, and XML `sling:OsgiConfig` nodes — conversion to `.cfg.json` only (Phase 0).
Out of scope for automated edits
- Repoinit and Adobe-owned OSGi override (see above).
- Reorganizing runmode folder structure (invalid folders are flagged, not moved).
---
Phase 0 — Legacy format conversion
Glob for `.cfg`, `.config`, and `.xml` files under config folders (same scope as .cfg.json). For each legacy file found, convert it to .cfg.json and delete the original.
`.cfg` (Java properties format)
Line-based key=value (or key = value). Lines starting with # or ! are comments. Multi-line values use trailing \. Convert:
# .cfg file
server.url=https://example.com
connection.timeout=1000
enabled=true→
{
"server.url": "https://example.com",
"connection.timeout": 1000,
"enabled": true
}Rules: unquoted numeric values → JSON numbers; true/false → JSON booleans; everything else → JSON strings. Preserve the PID from the filename.
`.config` (Apache Felix format)
Similar to .cfg but supports typed values with suffixes: I (int), L (long), F (float), D (double), B (byte), S (short), C (char). Arrays use square brackets [ "a", "b" ]. Booleans are B"true" or unquoted true/false. Convert types to their JSON equivalents (numbers, booleans, arrays). Drop type suffixes.
XML `sling:OsgiConfig` nodes
JCR content XML (.content.xml) with jcr:primaryType="sling:OsgiConfig". Each @property attribute is a config property. Type hints in curly braces: {Long}, {Boolean}, etc. Arrays use []. Convert to JSON, derive PID from the node name, write as <PID>.cfg.json. Do not delete the .content.xml if it contains non-OSGi nodes — only remove the sling:OsgiConfig node from it.
After conversion: Proceed with the converted .cfg.json files in Phase 1+. Mention converted files in the report.
---
Phase 1 — Cleanup and consolidation (flag-only)
This phase flags issues for user review. Do not auto-delete or auto-merge — list findings in the handoff file under a "cleanup" array.
1a. Invalid runmode folders
Compare every config.* folder name against the valid Cloud Service runmode tokens table above. Flag any folder that does not match (e.g. config.qa, config.integration, config.local, config.ams, config.nosamplecontent). Report: folder path, suggested action ("review and remove or remap to valid runmode").
1b. Archetype / boilerplate configs
Flag .cfg.json files whose PID matches the list below — these are commonly generated by the AEM archetype and are often unnecessary or need review before deploying to Cloud Service:
| PID | Why flag |
|---|---|
org.apache.sling.commons.log.LogManager.factory.config* | Custom logger factories — review log levels; Cloud Service uses centralized logging |
org.apache.sling.jcr.davex.impl.servlets.SlingDavExServlet | WebDAV — disabled on Cloud Service |
org.apache.sling.servlets.get.DefaultGetServlet | Default GET servlet — usually archetype boilerplate |
com.day.cq.wcm.core.impl.AuthoringUIMode | Touch UI default — unnecessary on Cloud Service |
com.adobe.granite.auth.saml.SamlAuthenticationHandler.factory* | SAML — must be validated against Cloud Service IDP setup |
org.apache.sling.security.impl.ReferrerFilter | Referrer filter — Cloud Service has its own; review carefully |
org.apache.sling.engine.impl.SlingMainServlet | Sling main servlet — rarely needs override on Cloud Service |
Action: flag with "needs_user_review": true and reason. Do not delete.
1c. Duplicate configs across runmode folders
For each PID, if the exact same JSON content appears in multiple runmode folders (e.g. config.author.dev/ and config.author.stage/ have identical files), flag as a consolidation candidate. Suggest moving to the least-specific common parent folder (e.g. config.author/). Do not auto-merge.
1d. Environment-specific URLs
Scan .cfg.json string values for patterns that look like environment-specific URLs: values matching https?:// that contain hostname fragments suggesting a specific environment (substrings: dev, stage, stg, qa, uat, prod, preprod, localhost, local, or IP addresses like 10.*, 192.168.*). Flag each with the property key, current value, and "needs_user_review": true, "reason": "Possible environment-specific URL — consider $[env:VAR_NAME]". Do not auto-replace — URL classification requires human judgment.
1e. Deprecated / Cloud Service–incompatible configs
Flag .cfg.json files whose PID matches known deprecated or incompatible services:
| PID pattern | Reason |
|---|---|
com.day.cq.replication.impl.AgentManagerImpl | Replication agents — Cloud Service uses Sling content distribution |
com.day.cq.replication.impl.TransportHandler* | Transport handlers — not applicable on Cloud Service |
org.apache.sling.jcr.webdav.impl.servlets.SimpleWebDavServlet | WebDAV servlet — disabled on Cloud Service |
com.day.cq.mailer.DefaultMailService | Mail service — review; Cloud Service uses different mail configuration |
org.apache.felix.http.jetty* | Jetty tuning — managed by Adobe on Cloud Service |
Flag with reason. Do not delete.
---
Phase 2 — Placeholder injection (secrets and env vars)
This is the original core workflow, now applied after Phase 0 and Phase 1.
1. Glob all `.cfg.json` (including newly converted ones from Phase 0). 2. For custom PIDs only: replace high-confidence secrets with `"$[secret:VAR]"`; replace eligible non-secrets with `"$[env:VAR]"` only when Adobe's rules above allow. Skip ambiguous or Adobe-owned configs → `needs_user_review` in the handoff file. 3. Do not print secret values in chat. Remind user: set CM variables, then delete the handoff file; git history may still contain old secrets.
---
Phase 3 — Handoff file
Write gitignored `cloudmanager-osgi-secrets.local.json` at the AEM repo root. Structure:
{
"_do_not_commit": "DELETE this file after applying variables in Cloud Manager",
"variables": [
{
"name": "VAR_NAME",
"value": "<original value>",
"cm_type": "secretString | string",
"placeholder": "$[secret:VAR_NAME]",
"cfg_json_path": "relative/path/to/file.cfg.json",
"json_property": "property.key",
"needs_user_review": false
}
],
"cleanup": [
{
"type": "invalid_runmode | archetype_default | duplicate_config | env_specific_url | deprecated_config",
"path": "relative/path",
"detail": "human-readable description",
"suggestion": "what to do"
}
],
"conversions": [
{
"original": "relative/path/to/legacy.config",
"converted": "relative/path/to/PID.cfg.json"
}
]
}- Add `.gitignore` entry if missing.
- Respect 200 variables and 2048-char values.
- User deletes file after Cloud Manager is updated and cleanup items are resolved.
Detection heuristics (agent)
Secrets: Treat as secret candidates when keys suggest sensitivity (password, secret, apikey, token, clientsecret, credential, privatekey, etc.) or values are obviously secret; exclude hostnames, public IDs, and non-secret flags unless keys indicate otherwise.
Env-specific URLs: Match https?:// values against hostname fragments (dev, stage, stg, qa, uat, prod, preprod, localhost, local, or private IPs). Flag only — do not auto-replace.
Archetype / deprecated PIDs: Match filenames (minus .cfg.json) against the PID tables in Phases 1b and 1e. Use prefix matching for factory configs (PID ending in * in the tables).
---
One-line summary
Phase 0: convert legacy formats → Phase 1: flag invalid runmodes, archetype defaults, duplicates, env-specific URLs, deprecated configs → Phase 2: inject $[secret:] / $[env:] placeholders on custom PIDs → Phase 3: gitignored handoff file with variables + cleanup items → no secrets in chat.
/**
* BPA Findings Helper
*
* Two independent execution paths — the helper picks one based on what the
* caller configured, never mixes them, and applies the same batching to both:
*
* 1. MCP configured (mcpFetcher + projectId)
* → first call: fetch all findings from MCP, cache to
* `<collectionsDir>/mcp/<projectId>/<pattern>.json`.
* → subsequent calls: read that cache.
* → every call applies `paginate(...)` and returns one batch.
*
* 2. MCP not configured, CSV path provided (or cached CSV collection exists)
* → first call: parse CSV, write the unified-collection JSON to
* `<collectionsDir>/unified-collection.json`.
* → subsequent calls: read that cache.
* → every call applies `paginate(...)` and returns one batch.
*
* The two cache locations are disjoint so the paths never shadow each other.
* Batch size defaults to `DEFAULT_BATCH_SIZE` (5); pass `limit: null` to
* disable batching.
*/
const path = require('path');
const fs = require('fs');
const {
hasUnifiedCollection,
fetchUnifiedBpaFindings,
getAvailablePatterns,
getUnifiedCollectionSummary,
paginate,
DEFAULT_BATCH_SIZE
} = require('./unified-collection-reader.js');
const { validateBpaFile, parseBpaFile, createUnifiedCollection } = require('./bpa-local-parser.js');
const DEFAULT_COLLECTIONS_DIR = './unified-collections';
/** Path to the MCP cache file for (projectId, pattern). */
function mcpCachePath(collectionsDir, projectId, pattern) {
return path.join(collectionsDir, 'mcp', projectId, `${pattern}.json`);
}
/** Read a cached MCP fetch from disk; returns `null` if absent or invalid. */
function readMcpCache(collectionsDir, projectId, pattern) {
const file = mcpCachePath(collectionsDir, projectId, pattern);
if (!fs.existsSync(file)) return null;
try {
return JSON.parse(fs.readFileSync(file, 'utf8'));
} catch {
return null;
}
}
/** Persist an MCP fetch to disk as the read model for subsequent paging. */
function writeMcpCache(collectionsDir, projectId, pattern, environment, targets, mcpMessage) {
const file = mcpCachePath(collectionsDir, projectId, pattern);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify({
source: 'mcp',
projectId,
pattern,
environment,
fetchedAt: new Date().toISOString(),
targets,
...(mcpMessage ? { mcpMessage } : {})
}, null, 2));
}
/**
* Get BPA findings, batched. See module header for the two-path contract.
*
* Returns `{ success, source, message, targets, paging, … }` where
* `paging = { total, returned, offset, limit, nextOffset, hasMore }`.
*/
async function getBpaFindings(pattern = 'all', options = {}) {
const {
bpaFilePath,
collectionsDir = DEFAULT_COLLECTIONS_DIR,
projectId,
environment = 'prod',
mcpFetcher,
offset = 0,
limit = DEFAULT_BATCH_SIZE
} = options;
// ── Path A: MCP (one fetch per (projectId, pattern), cached, then paged) ──
if (mcpFetcher && projectId) {
return getFromMcp({
pattern, projectId, environment, mcpFetcher, collectionsDir, offset, limit
});
}
// ── Path B: CSV / cached CSV collection ──
if (bpaFilePath || hasUnifiedCollection(collectionsDir)) {
return getFromCsvOrCache({
pattern, bpaFilePath, collectionsDir, offset, limit
});
}
// ── Nothing configured ──
return {
success: false,
source: 'no-source',
error: 'No BPA findings source available',
message: 'No BPA data found. Configure MCP (mcpFetcher + projectId) or provide a BPA CSV file path.',
troubleshooting: [
'Configure MCP access with mcpFetcher and projectId',
'Or provide the path to your BPA CSV report',
'Or point to specific Java files for manual migration'
]
};
}
/**
* MCP path: first call fetches & caches; subsequent calls read cache.
* Every call returns exactly one batch.
*/
async function getFromMcp({ pattern, projectId, environment, mcpFetcher, collectionsDir, offset, limit }) {
let cache = readMcpCache(collectionsDir, projectId, pattern);
if (!cache) {
try {
const mcp = await mcpFetcher({ projectId, pattern, environment });
if (!mcp || mcp.success === false) {
return {
...(mcp || {}),
success: false,
source: 'mcp-server',
message: `MCP fetch did not succeed (project: ${projectId}).`
};
}
const targets = Array.isArray(mcp.targets) ? mcp.targets : [];
writeMcpCache(collectionsDir, projectId, pattern, environment, targets, mcp.message);
cache = readMcpCache(collectionsDir, projectId, pattern);
} catch (error) {
return {
success: false,
source: 'mcp-error',
error: `MCP server error: ${error.message}`,
message: `Could not fetch from MCP server: ${error.message}`,
troubleshooting: [
'Check MCP server connectivity',
'Verify project ID and credentials',
'Provide a BPA CSV file path as an alternative'
]
};
}
}
const { targets, paging } = paginate(cache.targets, { offset, limit });
const defaultMessage = `Using MCP cache for project ${projectId} (fetched ${formatTimestamp(cache.fetchedAt)}).`;
return {
success: true,
source: 'mcp-server',
projectId,
environment,
message: (cache.targets.length === 0 && cache.mcpMessage) ? cache.mcpMessage : defaultMessage,
targets,
paging
};
}
/**
* CSV / cached-collection path: first call parses CSV and writes the unified
* collection; subsequent calls read it. Every call returns exactly one batch.
*/
function getFromCsvOrCache({ pattern, bpaFilePath, collectionsDir, offset, limit }) {
// Create the cache on first invocation if it doesn't exist yet.
if (!hasUnifiedCollection(collectionsDir)) {
if (!bpaFilePath) {
return {
success: false,
source: 'bpa-file',
error: 'No BPA CSV path provided and no cached collection exists',
message: `No cached collection at ${collectionsDir}; provide bpaFilePath to create one.`
};
}
try {
validateBpaFile(bpaFilePath);
createUnifiedCollection(parseBpaFile(bpaFilePath), collectionsDir);
} catch (error) {
return {
success: false,
source: 'bpa-file-error',
error: `Failed to process BPA file: ${error.message}`,
message: `Could not process BPA file at ${bpaFilePath}: ${error.message}`,
troubleshooting: [
'Verify the file exists and is a valid BPA CSV',
'Expected CSV headers: code, type, subtype, importance, identifier, message, context'
]
};
}
}
const patterns = getAvailablePatterns(collectionsDir);
const hasPattern = pattern === 'all' ? patterns.length > 0 : patterns.includes(pattern);
if (!hasPattern) {
return {
success: false,
source: 'bpa-file',
error: `Pattern '${pattern}' not found in BPA report`,
message: `Pattern '${pattern}' not found. Available patterns: ${patterns.join(', ')}`,
availablePatterns: patterns
};
}
const summary = getUnifiedCollectionSummary(collectionsDir);
const result = fetchUnifiedBpaFindings(pattern, collectionsDir, { offset, limit });
result.source = 'bpa-file';
result.message = `Using CSV collection (${summary?.totalFindings ?? result.paging.total} findings, ${formatTimestamp(summary?.timestamp)}).`;
return result;
}
/**
* Check what BPA sources are currently available.
*/
function checkAvailableSources(options = {}) {
const {
bpaFilePath,
collectionsDir = DEFAULT_COLLECTIONS_DIR,
mcpFetcher,
projectId
} = options;
const sources = {
unifiedCollection: {
available: hasUnifiedCollection(collectionsDir),
patterns: [],
path: collectionsDir,
summary: null
},
bpaFile: {
available: false,
path: bpaFilePath || null
},
mcpServer: {
available: !!(mcpFetcher && projectId),
projectId: projectId || null,
cached: !!(projectId && readMcpCache(collectionsDir, projectId, 'all'))
}
};
if (sources.unifiedCollection.available) {
sources.unifiedCollection.patterns = getAvailablePatterns(collectionsDir);
sources.unifiedCollection.summary = getUnifiedCollectionSummary(collectionsDir);
}
if (bpaFilePath) {
try {
validateBpaFile(bpaFilePath);
sources.bpaFile.available = true;
} catch (e) {
sources.bpaFile.available = false;
sources.bpaFile.error = e.message;
}
}
return sources;
}
/**
* Format an ISO timestamp into a human-readable relative string.
*/
function formatTimestamp(isoTimestamp) {
if (!isoTimestamp) return 'unknown date';
try {
const date = new Date(isoTimestamp);
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'just now';
if (diffMins < 60) return `${diffMins} minute${diffMins > 1 ? 's' : ''} ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours} hour${diffHours > 1 ? 's' : ''} ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays} day${diffDays > 1 ? 's' : ''} ago`;
} catch {
return isoTimestamp;
}
}
/**
* CLI interface for testing
*
* Usage: node bpa-findings-helper.js <pattern> [collectionsDir] [bpaFilePath] [offset] [limit]
* offset defaults to 0
* limit defaults to 5 (use 'all' to disable batching)
*/
async function main() {
const args = process.argv.slice(2);
const pattern = args[0] || 'all';
const collectionsDir = args[1] || DEFAULT_COLLECTIONS_DIR;
const bpaFilePath = args[2];
const offset = args[3] !== undefined ? Number(args[3]) : 0;
const limit = args[4] === 'all' ? null
: args[4] !== undefined ? Number(args[4])
: DEFAULT_BATCH_SIZE;
console.log('BPA Findings Helper');
console.log('==================');
console.log(`Pattern: ${pattern}`);
console.log(`Collections Dir: ${collectionsDir}`);
if (bpaFilePath) console.log(`BPA File: ${bpaFilePath}`);
console.log(`Offset: ${offset}`);
console.log(`Limit: ${limit === null ? 'all' : limit}`);
console.log('');
const sources = checkAvailableSources({ collectionsDir, bpaFilePath });
console.log('Available Sources:');
console.log(` Unified Collection: ${sources.unifiedCollection.available ? '✅' : '❌'}`);
if (sources.unifiedCollection.available) {
console.log(` Patterns: ${sources.unifiedCollection.patterns.join(', ')}`);
console.log(` Created: ${sources.unifiedCollection.summary?.timestamp || 'unknown'}`);
}
console.log(` BPA File: ${sources.bpaFile.available ? '✅' : '❌'} ${sources.bpaFile.path || '(not provided)'}`);
console.log(` MCP Server: ${sources.mcpServer.available ? '✅' : '❌'}`);
console.log('');
const result = await getBpaFindings(pattern, { collectionsDir, bpaFilePath, offset, limit });
console.log(`Source: ${result.source}`);
console.log(`Message: ${result.message}`);
console.log('');
if (result.success) {
const p = result.paging;
console.log(p
? `✅ Batch ${p.returned}/${p.total} (offset ${p.offset}, next ${p.nextOffset ?? 'none'}, hasMore=${p.hasMore})`
: `✅ Loaded ${result.targets.length} findings`);
if (result.summary) {
Object.entries(result.summary).forEach(([key, value]) => {
console.log(` ${key}: ${value}`);
});
}
} else {
console.error(`❌ ${result.error}`);
if (result.troubleshooting?.length > 0) {
console.error('');
console.error('Troubleshooting:');
result.troubleshooting.forEach(tip => console.error(` - ${tip}`));
}
}
}
if (require.main === module) {
main();
}
module.exports = {
getBpaFindings,
checkAvailableSources
};
/**
* Unit tests for BPA findings helper (node --test).
*/
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { getBpaFindings, checkAvailableSources } = require('./bpa-findings-helper.js');
const { paginate, DEFAULT_BATCH_SIZE } = require('./unified-collection-reader.js');
function tempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'bpa-helper-test-'));
}
const MANY_CSV = path.join(__dirname, 'fixtures', 'many-scheduler-bpa.csv');
const MANY_CSV_TOTAL = 12; // classes in many-scheduler-bpa.csv
test('getBpaFindings with no CSV, collection, or MCP returns no-source', async () => {
const dir = tempDir();
const result = await getBpaFindings('scheduler', { collectionsDir: dir });
assert.equal(result.success, false);
assert.equal(result.source, 'no-source');
});
test('getBpaFindings uses mcpFetcher when projectId is set', async () => {
const dir = tempDir();
const mcpFetcher = async () => ({
success: true,
targets: [
{
pattern: 'scheduler',
className: 'com.example.Job',
identifier: 'org.apache.sling.commons.scheduler',
issue: 'test'
}
]
});
const result = await getBpaFindings('scheduler', {
collectionsDir: dir,
projectId: 'proj-1',
mcpFetcher
});
assert.equal(result.success, true);
assert.equal(result.source, 'mcp-server');
assert.equal(result.targets.length, 1);
assert.equal(result.targets[0].className, 'com.example.Job');
});
test('getBpaFindings ingests BPA CSV into empty collections dir', async () => {
const dir = tempDir();
const csvPath = path.join(__dirname, 'fixtures', 'minimal-scheduler-bpa.csv');
const result = await getBpaFindings('scheduler', {
collectionsDir: dir,
bpaFilePath: csvPath
});
assert.equal(result.success, true);
assert.equal(result.source, 'bpa-file');
assert.ok(Array.isArray(result.targets));
assert.ok(result.targets.length >= 1);
assert.ok(
result.targets.some(
(t) => t.pattern === 'scheduler' && t.className.includes('SampleJob')
)
);
});
test('checkAvailableSources reflects MCP only when fetcher and projectId present', () => {
const dir = tempDir();
const noMcp = checkAvailableSources({ collectionsDir: dir });
assert.equal(noMcp.mcpServer.available, false);
const withMcp = checkAvailableSources({
collectionsDir: dir,
mcpFetcher: async () => ({}),
projectId: 'p'
});
assert.equal(withMcp.mcpServer.available, true);
});
// ─────────────────────────────────────────────────────────────
// Batching: pure paginate()
// ─────────────────────────────────────────────────────────────
test('paginate() returns a batch of DEFAULT_BATCH_SIZE by default', () => {
assert.equal(DEFAULT_BATCH_SIZE, 5);
const items = Array.from({ length: 12 }, (_, i) => ({ i }));
const { targets, paging } = paginate(items);
assert.equal(targets.length, 5);
assert.deepEqual(paging, {
total: 12, returned: 5, offset: 0, limit: 5, nextOffset: 5, hasMore: true
});
});
test('paginate() advances via nextOffset contiguously', () => {
const items = Array.from({ length: 12 }, (_, i) => ({ i }));
const seen = [];
let offset = 0;
for (let safety = 0; safety < 10; safety++) {
const { targets, paging } = paginate(items, { offset, limit: 5 });
for (const it of targets) seen.push(it.i);
if (!paging.hasMore) break;
offset = paging.nextOffset;
}
assert.deepEqual(seen, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]);
});
test('paginate() handles offset past end without error', () => {
const { targets, paging } = paginate([{ i: 0 }, { i: 1 }], { offset: 5 });
assert.equal(targets.length, 0);
assert.equal(paging.hasMore, false);
assert.equal(paging.nextOffset, null);
});
test('paginate() with limit=null returns everything', () => {
const items = Array.from({ length: 12 }, (_, i) => ({ i }));
const { targets, paging } = paginate(items, { limit: null });
assert.equal(targets.length, 12);
assert.equal(paging.hasMore, false);
assert.equal(paging.limit, null);
});
// ─────────────────────────────────────────────────────────────
// Batching: local BPA CSV path end-to-end
// ─────────────────────────────────────────────────────────────
test('getBpaFindings (CSV path) returns a batch of 5 by default', async () => {
const dir = tempDir();
const r = await getBpaFindings('scheduler', {
collectionsDir: dir,
bpaFilePath: MANY_CSV
});
assert.equal(r.success, true);
assert.equal(r.source, 'bpa-file');
assert.equal(r.targets.length, 5);
assert.ok(r.paging);
assert.equal(r.paging.total, MANY_CSV_TOTAL);
assert.equal(r.paging.returned, 5);
assert.equal(r.paging.offset, 0);
assert.equal(r.paging.limit, 5);
assert.equal(r.paging.nextOffset, 5);
assert.equal(r.paging.hasMore, true);
});
test('CSV path advances by nextOffset with stable contiguous order', async () => {
const dir = tempDir();
// First call parses CSV → writes cache → returns first batch.
const first = await getBpaFindings('scheduler', {
collectionsDir: dir,
bpaFilePath: MANY_CSV
});
assert.equal(first.source, 'bpa-file');
assert.equal(first.targets.length, 5);
// Subsequent calls read the cache — same source, no re-parse.
const second = await getBpaFindings('scheduler', {
collectionsDir: dir,
offset: first.paging.nextOffset
});
assert.equal(second.source, 'bpa-file');
assert.equal(second.targets.length, 5);
assert.equal(second.paging.offset, 5);
assert.equal(second.paging.nextOffset, 10);
assert.equal(second.paging.hasMore, true);
const third = await getBpaFindings('scheduler', {
collectionsDir: dir,
offset: second.paging.nextOffset
});
assert.equal(third.source, 'bpa-file');
assert.equal(third.targets.length, 2);
assert.equal(third.paging.total, MANY_CSV_TOTAL);
assert.equal(third.paging.offset, 10);
assert.equal(third.paging.nextOffset, null);
assert.equal(third.paging.hasMore, false);
// No overlap, no gap.
const allClassNames = [
...first.targets,
...second.targets,
...third.targets
].map((t) => t.className);
assert.equal(new Set(allClassNames).size, allClassNames.length, 'batches must not overlap');
assert.equal(allClassNames.length, MANY_CSV_TOTAL);
});
test('getBpaFindings (collection path) re-issuing the same offset returns the same batch', async () => {
const dir = tempDir();
await getBpaFindings('scheduler', { collectionsDir: dir, bpaFilePath: MANY_CSV });
const a = await getBpaFindings('scheduler', { collectionsDir: dir, offset: 5 });
const b = await getBpaFindings('scheduler', { collectionsDir: dir, offset: 5 });
assert.deepEqual(
a.targets.map((t) => t.className),
b.targets.map((t) => t.className),
'stable ordering: same offset, same batch'
);
});
test('getBpaFindings (collection path) with limit=null returns every finding', async () => {
const dir = tempDir();
await getBpaFindings('scheduler', { collectionsDir: dir, bpaFilePath: MANY_CSV });
const r = await getBpaFindings('scheduler', { collectionsDir: dir, limit: null });
assert.equal(r.targets.length, MANY_CSV_TOTAL);
assert.equal(r.paging.hasMore, false);
assert.equal(r.paging.limit, null);
});
// ─────────────────────────────────────────────────────────────
// Batching: MCP path (one-shot fetch, cached to disk, paged client-side)
// ─────────────────────────────────────────────────────────────
/** MCP fetcher that returns `total` findings and counts how many times it was called. */
function mkCountingMcpFetcher(total) {
const fetcher = async (args) => {
fetcher.calls.push(args);
return {
success: true,
targets: Array.from({ length: total }, (_, i) => ({
pattern: 'scheduler',
className: `com.example.Job${String(i).padStart(3, '0')}`,
identifier: 'org.apache.sling.commons.scheduler',
issue: 'mcp-test'
}))
};
};
fetcher.calls = [];
return fetcher;
}
test('MCP path: first call fetches + caches + returns a batch of 5', async () => {
const dir = tempDir();
const mcpFetcher = mkCountingMcpFetcher(12);
const r = await getBpaFindings('scheduler', {
collectionsDir: dir,
projectId: 'proj-A',
mcpFetcher
});
assert.equal(r.success, true);
assert.equal(r.source, 'mcp-server');
assert.equal(r.targets.length, 5);
assert.equal(r.paging.total, 12);
assert.equal(r.paging.offset, 0);
assert.equal(r.paging.nextOffset, 5);
assert.equal(r.paging.hasMore, true);
// Cache file written in the expected location.
const cacheFile = path.join(dir, 'mcp', 'proj-A', 'scheduler.json');
assert.ok(fs.existsSync(cacheFile), 'MCP cache file should exist after first call');
});
test('MCP path: subsequent batches read the cache — mcpFetcher is called once', async () => {
const dir = tempDir();
const mcpFetcher = mkCountingMcpFetcher(12);
// Drive three batches: 5 + 5 + 2.
const seen = [];
let offset = 0;
for (let safety = 0; safety < 10; safety++) {
const r = await getBpaFindings('scheduler', {
collectionsDir: dir,
projectId: 'proj-A',
mcpFetcher,
offset
});
for (const t of r.targets) seen.push(t.className);
if (!r.paging.hasMore) break;
offset = r.paging.nextOffset;
}
assert.equal(seen.length, 12);
assert.equal(new Set(seen).size, 12, 'no duplicates across batches');
assert.equal(mcpFetcher.calls.length, 1, 'mcpFetcher must be called only once');
});
test('MCP path: mcpFetcher receives projectId/pattern/environment only (no limit/offset)', async () => {
const dir = tempDir();
const mcpFetcher = mkCountingMcpFetcher(3);
await getBpaFindings('scheduler', {
collectionsDir: dir,
projectId: 'proj-A',
environment: 'stage',
mcpFetcher,
offset: 10 // user-supplied offset must NOT be forwarded
});
assert.equal(mcpFetcher.calls.length, 1);
const args = mcpFetcher.calls[0];
assert.equal(args.projectId, 'proj-A');
assert.equal(args.pattern, 'scheduler');
assert.equal(args.environment, 'stage');
assert.equal(args.limit, undefined, 'limit must not be sent to MCP');
assert.equal(args.offset, undefined, 'offset must not be sent to MCP');
});
test('MCP path is preferred over a pre-existing CSV-derived cache', async () => {
const dir = tempDir();
// Seed a CSV-derived cache — simulates a previous CSV session.
await getBpaFindings('scheduler', { collectionsDir: dir, bpaFilePath: MANY_CSV });
// Now call with MCP configured; the CSV cache must NOT shadow MCP.
const mcpFetcher = mkCountingMcpFetcher(3);
const r = await getBpaFindings('scheduler', {
collectionsDir: dir,
projectId: 'proj-A',
mcpFetcher
});
assert.equal(r.source, 'mcp-server');
assert.equal(r.paging.total, 3, 'must read MCP count, not CSV count');
assert.equal(mcpFetcher.calls.length, 1);
});
test('MCP cache files are keyed by (projectId, pattern) — different keys do not collide', async () => {
const dir = tempDir();
const fA = mkCountingMcpFetcher(3);
const fB = mkCountingMcpFetcher(7);
const ra = await getBpaFindings('scheduler', {
collectionsDir: dir, projectId: 'proj-A', mcpFetcher: fA
});
const rb = await getBpaFindings('scheduler', {
collectionsDir: dir, projectId: 'proj-B', mcpFetcher: fB
});
assert.equal(ra.paging.total, 3);
assert.equal(rb.paging.total, 7);
// Both caches present.
assert.ok(fs.existsSync(path.join(dir, 'mcp', 'proj-A', 'scheduler.json')));
assert.ok(fs.existsSync(path.join(dir, 'mcp', 'proj-B', 'scheduler.json')));
});
test('MCP path: success:true with empty targets propagates message and caches', async () => {
const dir = tempDir();
const mcpFetcher = async () => ({
success: true,
targets: [],
summary: { schedulerCount: 0 },
message: "No 'scheduler' findings in the latest BPA report for project proj-X."
});
const r = await getBpaFindings('scheduler', {
collectionsDir: dir,
projectId: 'proj-X',
mcpFetcher
});
assert.equal(r.success, true);
assert.equal(r.source, 'mcp-server');
assert.equal(r.targets.length, 0);
assert.equal(r.paging.total, 0);
assert.equal(r.paging.hasMore, false);
assert.ok(r.message.includes("No 'scheduler' findings"), 'message should reflect empty pattern result');
const cacheFile = path.join(dir, 'mcp', 'proj-X', 'scheduler.json');
assert.ok(fs.existsSync(cacheFile), 'cache file should be written even for empty results');
// Second call should read cache and not invoke fetcher again.
let fetcherCalled = false;
const deadFetcher = async () => { fetcherCalled = true; return { success: true, targets: [] }; };
const r2 = await getBpaFindings('scheduler', {
collectionsDir: dir,
projectId: 'proj-X',
mcpFetcher: deadFetcher
});
assert.equal(fetcherCalled, false, 'fetcher must not be called when cache exists');
assert.equal(r2.success, true);
assert.ok(r2.message.includes("No 'scheduler' findings"), 'cached message should persist');
});
test('MCP path: second session reuses the cache across processes (no fetch)', async () => {
const dir = tempDir();
const fA = mkCountingMcpFetcher(8);
// First session — populates the cache.
await getBpaFindings('scheduler', {
collectionsDir: dir, projectId: 'proj-A', mcpFetcher: fA
});
assert.equal(fA.calls.length, 1);
// Second session with a "dead" fetcher that should never be called.
const fDead = async () => {
throw new Error('mcp fetcher must not be called when cache exists');
};
const r = await getBpaFindings('scheduler', {
collectionsDir: dir, projectId: 'proj-A', mcpFetcher: fDead, offset: 5
});
assert.equal(r.success, true);
assert.equal(r.source, 'mcp-server');
assert.equal(r.paging.offset, 5);
});
#!/usr/bin/env node
/**
* BPA Local Parser Script
*
* Reads BPA CSV files from the local filesystem and creates a unified code transformer collection
* that matches the cloud-adoption-service format.
*
* Usage:
* node bpa-local-parser.js <bpa-csv-file-path> [output-directory]
*
* Example:
* node bpa-local-parser.js ./cleaned_file6.csv ./unified-collections
*/
const fs = require('fs');
const path = require('path');
// Pattern to subtype mapping (matching cam-bpa-fetcher.ts)
const PATTERN_TO_SUBTYPE = {
scheduler: "sling.commons.scheduler",
assetApi: "unsupported.asset.api",
};
// CSV subtype to pattern mapping (based on actual CSV structure)
const CSV_SUBTYPE_TO_PATTERN = {
"unsupported.asset.api": "assetApi",
"javax.jcr.observation.EventListener": "eventListener",
"org.apache.sling.api.resource.observation.ResourceChangeListener": "resourceChangeListener",
"org.osgi.service.event.EventHandler": "eventHandler"
};
// Known scheduler identifier
const SCHEDULER_IDENTIFIER = "org.apache.sling.commons.scheduler";
/**
* Parse command line arguments
*/
function parseArgs() {
const args = process.argv.slice(2);
if (args.length < 1) {
console.error('Usage: node bpa-local-parser.js <bpa-csv-file-path> [output-directory]');
console.error('');
console.error('Examples:');
console.error(' node bpa-local-parser.js ./cleaned_file6.csv');
console.error(' node bpa-local-parser.js ./cleaned_file6.csv ./unified-collections');
process.exit(1);
}
return {
bpaFilePath: args[0],
outputDir: args[1] || './unified-collections'
};
}
/**
* Validate BPA file exists and is readable
*/
function validateBpaFile(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`BPA file not found: ${filePath}`);
}
const stats = fs.statSync(filePath);
if (!stats.isFile()) {
throw new Error(`Path is not a file: ${filePath}`);
}
// Check if it's readable
try {
fs.accessSync(filePath, fs.constants.R_OK);
} catch (error) {
throw new Error(`BPA file is not readable: ${filePath}`);
}
}
/**
* Parse CSV line respecting quoted fields
*/
function parseCSVLine(line) {
const result = [];
let current = '';
let inQuotes = false;
let i = 0;
while (i < line.length) {
const char = line[i];
if (char === '"') {
if (inQuotes && line[i + 1] === '"') {
// Escaped quote
current += '"';
i += 2;
} else {
// Toggle quote state
inQuotes = !inQuotes;
i++;
}
} else if (char === ',' && !inQuotes) {
// Field separator
result.push(current);
current = '';
i++;
} else {
current += char;
i++;
}
}
// Add the last field
result.push(current);
return result;
}
/**
* Parse BPA CSV file
*/
function parseBpaFile(filePath) {
try {
const content = fs.readFileSync(filePath, 'utf8');
const lines = content.split('\n').filter(line => line.trim());
if (lines.length === 0) {
throw new Error('Empty BPA file');
}
// Parse header
const headers = parseCSVLine(lines[0]);
console.log(`CSV Headers: ${headers.join(', ')}`);
// Validate expected headers
const expectedHeaders = ['code', 'type', 'subtype', 'importance', 'identifier', 'message', 'context'];
const hasRequiredHeaders = expectedHeaders.every(header => headers.includes(header));
if (!hasRequiredHeaders) {
console.warn('CSV headers do not match expected format, proceeding with available headers');
}
// Parse data rows
const findings = [];
for (let i = 1; i < lines.length; i++) {
const values = parseCSVLine(lines[i]);
if (values.length >= headers.length) {
const finding = {};
headers.forEach((header, index) => {
finding[header] = values[index] || '';
});
findings.push(finding);
}
}
console.log(`Parsed ${findings.length} findings from CSV`);
return { findings, headers };
} catch (error) {
throw new Error(`Error parsing BPA CSV file: ${error.message}`);
}
}
/**
* Extract findings from BPA CSV data
*/
function extractFindings(bpaData) {
const findings = bpaData.findings || [];
if (findings.length === 0) {
console.warn('No findings found in BPA CSV data');
return [];
}
console.log(`Found ${findings.length} findings in BPA CSV report`);
return findings;
}
/**
* Process scheduler findings from CSV
*/
function processSchedulerFindings(findings) {
const schedulerFindings = findings.filter(finding =>
finding.subtype === 'sling.commons.scheduler'
);
const identifiers = {};
const classNames = [];
schedulerFindings.forEach(finding => {
// Extract class name from identifier
const className = extractClassNameFromCsvFinding(finding);
if (className && !classNames.includes(className)) {
classNames.push(className);
}
});
if (classNames.length > 0) {
identifiers[SCHEDULER_IDENTIFIER] = classNames;
}
return {
subtype: PATTERN_TO_SUBTYPE.scheduler,
identifiers: identifiers
};
}
/**
* Process asset API findings from CSV
*/
function processAssetApiFindings(findings) {
const assetApiFindings = findings.filter(finding =>
finding.subtype === 'unsupported.asset.api'
);
const identifiers = {};
assetApiFindings.forEach(finding => {
// Extract class name from identifier (full path)
const className = finding.identifier && isValidClassName(finding.identifier) ? finding.identifier.trim() : null;
// Extract API method from message
const apiMethod = extractAssetApiMethodFromMessage(finding.message);
if (className && apiMethod) {
if (!identifiers[apiMethod]) {
identifiers[apiMethod] = [];
}
if (!identifiers[apiMethod].includes(className)) {
identifiers[apiMethod].push(className);
}
}
});
return {
subtype: PATTERN_TO_SUBTYPE.assetApi,
identifiers: identifiers
};
}
/**
* Returns true if the value is a valid class name (excludes numeric-only values like "1", "4", "10").
*/
function isValidClassName(value) {
if (!value || typeof value !== 'string') return false;
const trimmed = value.trim();
if (!trimmed) return false;
// Exclude pure numbers (counts, line numbers, etc.)
if (/^\d+$/.test(trimmed)) return false;
return true;
}
/**
* Extract full class identifier from CSV finding (returns complete path, not trimmed).
*/
function extractClassNameFromCsvFinding(finding) {
// Prefer identifier field - return full path as-is
if (finding.identifier && isValidClassName(finding.identifier)) {
return finding.identifier.trim();
}
// Try to extract from message if identifier is not useful
if (finding.message) {
const classMatch = finding.message.match(/class\s+([a-zA-Z0-9_.]+)/);
if (classMatch) {
const className = classMatch[1];
if (isValidClassName(className)) return className;
}
}
return finding.identifier && isValidClassName(finding.identifier) ? finding.identifier.trim() : null;
}
/**
* Extract Asset API method from message
*/
function extractAssetApiMethodFromMessage(message) {
if (!message) return null;
// Look for specific API methods mentioned in the message
const apiMethods = [
'com.day.cq.dam.api.AssetManager.createAsset',
'com.day.cq.dam.api.AssetManager.removeAssetForBinary',
'com.day.cq.dam.api.AssetManager.createAssetForBinary'
];
for (const method of apiMethods) {
if (message.includes(method)) {
return method;
}
}
// Generic fallback
if (message.includes('AssetManager')) {
return 'com.day.cq.dam.api.AssetManager';
}
return null;
}
/**
* Process event listener findings from CSV
*/
function processEventListenerFindings(findings) {
const eventListenerFindings = findings.filter(finding =>
finding.subtype === 'javax.jcr.observation.EventListener'
);
const identifiers = {};
const classNames = [];
eventListenerFindings.forEach(finding => {
const className = extractClassNameFromCsvFinding(finding);
if (className && !classNames.includes(className)) {
classNames.push(className);
}
});
if (classNames.length > 0) {
identifiers['javax.jcr.observation.EventListener'] = classNames;
}
return {
subtype: 'javax.jcr.observation.EventListener',
identifiers: identifiers
};
}
/**
* Process resource change listener findings from CSV
*/
function processResourceChangeListenerFindings(findings) {
const resourceChangeListenerFindings = findings.filter(finding =>
finding.subtype === 'org.apache.sling.api.resource.observation.ResourceChangeListener'
);
const identifiers = {};
const classNames = [];
resourceChangeListenerFindings.forEach(finding => {
const className = extractClassNameFromCsvFinding(finding);
if (className && !classNames.includes(className)) {
classNames.push(className);
}
});
if (classNames.length > 0) {
identifiers['org.apache.sling.api.resource.observation.ResourceChangeListener'] = classNames;
}
return {
subtype: 'org.apache.sling.api.resource.observation.ResourceChangeListener',
identifiers: identifiers
};
}
/**
* Process event handler findings from CSV
*/
function processEventHandlerFindings(findings) {
const eventHandlerFindings = findings.filter(finding =>
finding.subtype === 'org.osgi.service.event.EventHandler'
);
const identifiers = {};
const classNames = [];
eventHandlerFindings.forEach(finding => {
const className = extractClassNameFromCsvFinding(finding);
if (className && !classNames.includes(className)) {
classNames.push(className);
}
});
if (classNames.length > 0) {
identifiers['org.osgi.service.event.EventHandler'] = classNames;
}
return {
subtype: 'org.osgi.service.event.EventHandler',
identifiers: identifiers
};
}
/**
* Convert subtype to MongoDB-safe field name (matching cloud-adoption-service)
*/
function toMongoSafeFieldName(fieldName) {
return fieldName ? fieldName.replace(/\./g, '_') : null;
}
/**
* Convert identifier to MongoDB-safe field name (matching cloud-adoption-service)
*/
function toMongoSafeIdentifier(identifier) {
return identifier ? identifier.replace(/\./g, '_') : null;
}
/**
* Create unified collection structure (matching cloud-adoption-service format)
*/
function createUnifiedCollection(bpaData, outputDir) {
const findings = extractFindings(bpaData);
if (findings.length === 0) {
console.warn('No findings to process');
return;
}
// Ensure output directory exists
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
// Initialize unified subtypes structure
const subtypes = {};
let totalFindings = 0;
// Process scheduler findings
const schedulerCollection = processSchedulerFindings(findings);
if (Object.keys(schedulerCollection.identifiers).length > 0) {
const mongoSafeSubtype = toMongoSafeFieldName(schedulerCollection.subtype);
subtypes[mongoSafeSubtype] = {};
Object.entries(schedulerCollection.identifiers).forEach(([identifier, classNames]) => {
const mongoSafeIdentifier = toMongoSafeIdentifier(identifier);
subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames;
totalFindings += classNames.length;
});
console.log(`Found ${Object.values(schedulerCollection.identifiers).flat().length} scheduler classes`);
}
// Process asset API findings
const assetApiCollection = processAssetApiFindings(findings);
if (Object.keys(assetApiCollection.identifiers).length > 0) {
const mongoSafeSubtype = toMongoSafeFieldName(assetApiCollection.subtype);
subtypes[mongoSafeSubtype] = {};
Object.entries(assetApiCollection.identifiers).forEach(([identifier, classNames]) => {
const mongoSafeIdentifier = toMongoSafeIdentifier(identifier);
subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames;
totalFindings += classNames.length;
});
console.log(`Found ${Object.values(assetApiCollection.identifiers).flat().length} asset API classes`);
}
// Process event listener findings
const eventListenerCollection = processEventListenerFindings(findings);
if (Object.keys(eventListenerCollection.identifiers).length > 0) {
const mongoSafeSubtype = toMongoSafeFieldName(eventListenerCollection.subtype);
subtypes[mongoSafeSubtype] = {};
Object.entries(eventListenerCollection.identifiers).forEach(([identifier, classNames]) => {
const mongoSafeIdentifier = toMongoSafeIdentifier(identifier);
subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames;
totalFindings += classNames.length;
});
console.log(`Found ${Object.values(eventListenerCollection.identifiers).flat().length} event listener classes`);
}
// Process resource change listener findings
const resourceChangeListenerCollection = processResourceChangeListenerFindings(findings);
if (Object.keys(resourceChangeListenerCollection.identifiers).length > 0) {
const mongoSafeSubtype = toMongoSafeFieldName(resourceChangeListenerCollection.subtype);
subtypes[mongoSafeSubtype] = {};
Object.entries(resourceChangeListenerCollection.identifiers).forEach(([identifier, classNames]) => {
const mongoSafeIdentifier = toMongoSafeIdentifier(identifier);
subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames;
totalFindings += classNames.length;
});
console.log(`Found ${Object.values(resourceChangeListenerCollection.identifiers).flat().length} resource change listener classes`);
}
// Process event handler findings
const eventHandlerCollection = processEventHandlerFindings(findings);
if (Object.keys(eventHandlerCollection.identifiers).length > 0) {
const mongoSafeSubtype = toMongoSafeFieldName(eventHandlerCollection.subtype);
subtypes[mongoSafeSubtype] = {};
Object.entries(eventHandlerCollection.identifiers).forEach(([identifier, classNames]) => {
const mongoSafeIdentifier = toMongoSafeIdentifier(identifier);
subtypes[mongoSafeSubtype][mongoSafeIdentifier] = classNames;
totalFindings += classNames.length;
});
console.log(`Found ${Object.values(eventHandlerCollection.identifiers).flat().length} event handler classes`);
}
// Create unified collection structure with metadata
const subtypeKeys = Object.keys(subtypes);
const unifiedCollection = {
subtypes: subtypes,
meta: {
timestamp: new Date().toISOString(),
source: 'local-bpa-parser',
totalFindings: totalFindings,
subtypeCount: subtypeKeys.length
}
};
// Write unified collection file
const unifiedPath = path.join(outputDir, 'unified-collection.json');
fs.writeFileSync(unifiedPath, JSON.stringify(unifiedCollection, null, 2));
console.log(`Created unified collection file: ${unifiedPath}`);
return {
subtypes: subtypeKeys,
totalFindings,
unifiedCollection
};
}
/**
* Main function
*/
function main() {
try {
const { bpaFilePath, outputDir } = parseArgs();
console.log('BPA Local Parser');
console.log('================');
console.log(`BPA File: ${bpaFilePath}`);
console.log(`Output Directory: ${outputDir}`);
console.log('Format: Unified (cloud-adoption-service compatible)');
console.log('');
// Validate input file
validateBpaFile(bpaFilePath);
// Parse BPA file
console.log('Parsing BPA CSV file...');
const bpaData = parseBpaFile(bpaFilePath);
// Create unified collection
console.log('Creating unified collection...');
const summary = createUnifiedCollection(bpaData, outputDir);
console.log('');
console.log('✅ Successfully created unified code transformer collection');
console.log(`📁 Output directory: ${outputDir}`);
console.log(`📊 Total subtypes: ${summary?.subtypes?.length || 0}`);
console.log(`🎯 Total findings: ${summary?.totalFindings || 0}`);
if (summary?.subtypes?.length > 0) {
console.log('');
console.log('Available subtypes:');
summary.subtypes.forEach(subtype => {
const subtypeData = summary.unifiedCollection?.subtypes?.[subtype];
const count = subtypeData ? Object.values(subtypeData).flat().length : 0;
console.log(` - ${subtype}: ${count} classes`);
});
}
} catch (error) {
console.error('❌ Error:', error.message);
process.exit(1);
}
}
// Run the script if called directly
if (require.main === module) {
main();
}
module.exports = {
validateBpaFile,
parseBpaFile,
createUnifiedCollection,
extractFindings
};
code,type,subtype,importance,identifier,message,context
BPA-001,issue,sling.commons.scheduler,high,com.example.jobs.Alpha,Uses Apache Sling scheduler in Alpha,
BPA-002,issue,sling.commons.scheduler,high,com.example.jobs.Bravo,Uses Apache Sling scheduler in Bravo,
BPA-003,issue,sling.commons.scheduler,high,com.example.jobs.Charlie,Uses Apache Sling scheduler in Charlie,
BPA-004,issue,sling.commons.scheduler,high,com.example.jobs.Delta,Uses Apache Sling scheduler in Delta,
BPA-005,issue,sling.commons.scheduler,high,com.example.jobs.Echo,Uses Apache Sling scheduler in Echo,
BPA-006,issue,sling.commons.scheduler,high,com.example.jobs.Foxtrot,Uses Apache Sling scheduler in Foxtrot,
BPA-007,issue,sling.commons.scheduler,high,com.example.jobs.Golf,Uses Apache Sling scheduler in Golf,
BPA-008,issue,sling.commons.scheduler,high,com.example.jobs.Hotel,Uses Apache Sling scheduler in Hotel,
BPA-009,issue,sling.commons.scheduler,high,com.example.jobs.India,Uses Apache Sling scheduler in India,
BPA-010,issue,sling.commons.scheduler,high,com.example.jobs.Juliet,Uses Apache Sling scheduler in Juliet,
BPA-011,issue,sling.commons.scheduler,high,com.example.jobs.Kilo,Uses Apache Sling scheduler in Kilo,
BPA-012,issue,sling.commons.scheduler,high,com.example.jobs.Lima,Uses Apache Sling scheduler in Lima,
code,type,subtype,importance,identifier,message,context
BPA-001,issue,sling.commons.scheduler,high,com.example.myapp.jobs.SampleJob,Uses Apache Sling scheduler in SampleJob,
{
"name": "migration-scripts",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "migration-scripts",
"version": "1.0.0",
"engines": {
"node": ">=14.0.0"
}
}
}
}
{
"name": "migration-scripts",
"version": "1.0.0",
"description": "BPA findings helper and parser for AEM migration skill",
"scripts": {
"parse-bpa": "node bpa-local-parser.js",
"read-unified": "node unified-collection-reader.js",
"check-sources": "node bpa-findings-helper.js",
"test": "node --test bpa-findings-helper.test.js"
},
"engines": {
"node": ">=14.0.0"
}
}
BPA Local Collection Scripts
Scripts for managing BPA (Best Practices Analyzer) findings locally. The skill handles these automatically — you should not need to run them manually during normal use.
How It Works
When the skill needs BPA findings, it calls bpa-findings-helper.js which handles everything:
User provides BPA CSV path?
│
┌────┴────────────────────────────────────┐
│ │
▼ ▼
Collection already exists? No CSV path given
┌────┴────┐ ┌────┴────┐
YES NO YES NO
│ │ │ │
▼ ▼ Collection Try MCP
Use it Parse CSV & exists? server
directly create collection Use it or ask userKey behavior:
- BPA CSV files are parsed once and saved as a unified collection
- Subsequent runs reuse the existing collection instantly
- If a new BPA file is provided when a collection exists, the skill asks the user which to use
Scripts
bpa-findings-helper.js (main entry point)
Orchestrates finding BPA data. Called by the skill internally.
const { getBpaFindings } = require('./scripts/bpa-findings-helper.js');
const result = await getBpaFindings('scheduler', {
bpaFilePath: './cleaned_file6.csv', // optional
collectionsDir: './unified-collections', // default
projectId: '...', // optional, for MCP
mcpFetcher: mcpFunction // optional, for MCP
});
// result.success → true/false
// result.source → 'unified-collection' | 'bpa-file' | 'mcp-server' | error
// result.message → human-readable status string
// result.targets → array of BPA findings (when successful)bpa-local-parser.js
Parses BPA CSV files and creates unified collections. Used internally by the helper, but can also be run directly:
node bpa-local-parser.js <bpa-csv-file-path> [output-directory]unified-collection-reader.js
Reads unified collections and returns findings. Used internally by the helper, but can also be run directly:
node unified-collection-reader.js [pattern] [collections-directory]Testing
# From the scripts directory
cd scripts
npm run parse-bpa -- <bpa-file-path> [output-dir]
npm run read-unified -- [pattern] [collections-dir]File Formats
Input: BPA CSV
code,type,subtype,importance,identifier,message,context
DG,development.guideline,unsupported.asset.api,MAJOR,com.example.MyClass,Uses deprecated API...Output: Unified Collection (unified-collection.json)
Matches the cloud-adoption-service format with MongoDB-safe field names (dots → underscores). Includes metadata (timestamp, totalFindings) for display:
{
"subtypes": {
"unsupported_asset_api": {
"com_day_cq_dam_api_AssetManager_createAsset": [
"com.example.MyClass"
]
}
},
"meta": {
"timestamp": "2026-03-18T06:16:37.755Z",
"source": "local-bpa-parser",
"totalFindings": 1,
"subtypeCount": 1
}
}Supported Patterns
| Pattern | BPA Subtype |
|---|---|
| scheduler | sling.commons.scheduler |
| assetApi | unsupported.asset.api |
| eventListener | javax.jcr.observation.EventListener |
| resourceChangeListener | org.apache.sling.api.resource.observation.ResourceChangeListener |
| eventHandler | org.osgi.service.event.EventHandler |
/**
* Unified Collection Reader
*
* Reads unified code transformer collections (cloud-adoption-service format)
* and returns data in the same format as the CAM BPA fetcher MCP tool.
*/
const fs = require('fs');
const path = require('path');
// Default batch size for paged access to findings. Keep this in sync with
// the documented batch size in migration/SKILL.md and cam-mcp.md.
const DEFAULT_BATCH_SIZE = 5;
// Pattern to subtype mapping (matching cam-bpa-fetcher.ts)
const PATTERN_TO_SUBTYPE = {
scheduler: "sling.commons.scheduler",
assetApi: "unsupported.asset.api",
eventListener: "javax.jcr.observation.EventListener",
resourceChangeListener: "org.apache.sling.api.resource.observation.ResourceChangeListener",
eventHandler: "org.osgi.service.event.EventHandler"
};
// MongoDB-safe to pattern mapping
const MONGO_SAFE_TO_PATTERN = {
"sling_commons_scheduler": "scheduler",
"unsupported_asset_api": "assetApi",
"javax_jcr_observation_EventListener": "eventListener",
"org_apache_sling_api_resource_observation_ResourceChangeListener": "resourceChangeListener",
"org_osgi_service_event_EventHandler": "eventHandler"
};
// Known scheduler identifier
const SCHEDULER_IDENTIFIER = "org.apache.sling.commons.scheduler";
/**
* BPA Target type (matching cam-bpa-fetcher.ts)
*/
class BpaTarget {
constructor(pattern, className, identifier, issue, severity = "high") {
this.pattern = pattern;
this.className = className;
this.identifier = identifier;
this.issue = issue;
this.severity = severity;
}
}
/**
* BPA Result type (matching cam-bpa-fetcher.ts)
*/
class BpaResult {
constructor(success = false) {
this.success = success;
this.environment = 'local';
this.projectId = 'unified-collection';
this.targets = [];
this.summary = {};
this.message = null;
this.error = null;
this.errorDetails = null;
this.troubleshooting = [];
this.suggestion = [];
}
}
/**
* Convert MongoDB-safe field name back to original format
*/
function fromMongoSafeFieldName(mongoSafeFieldName) {
return mongoSafeFieldName ? mongoSafeFieldName.replace(/_/g, '.') : null;
}
/**
* Slice a flat array of findings and return `{ targets, paging }`.
*
* Caller must produce `items` in a stable, deterministic order so that
* incrementing `offset` across calls yields contiguous, non-overlapping batches.
* `limit: null` disables batching (returns everything).
*/
function paginate(items, { offset = 0, limit = DEFAULT_BATCH_SIZE } = {}) {
const list = Array.isArray(items) ? items : [];
const total = list.length;
const start = Math.max(0, Math.floor(Number(offset)) || 0);
const unbounded = limit === null;
const size = unbounded ? total : Math.max(1, Math.floor(Number(limit)) || DEFAULT_BATCH_SIZE);
const targets = list.slice(start, unbounded ? total : start + size);
const reached = start + targets.length;
const nextOffset = reached < total ? reached : null;
return {
targets,
paging: {
total,
returned: targets.length,
offset: start,
limit: unbounded ? null : size,
nextOffset,
hasMore: nextOffset !== null
}
};
}
/**
* Check if unified collection exists
*/
function hasUnifiedCollection(collectionsDir = './unified-collections') {
const unifiedPath = path.join(collectionsDir, 'unified-collection.json');
try {
return fs.existsSync(unifiedPath) && fs.statSync(unifiedPath).isFile();
} catch (error) {
return false;
}
}
/**
* Get available patterns in unified collection
*/
function getAvailablePatterns(collectionsDir = './unified-collections') {
if (!hasUnifiedCollection(collectionsDir)) {
return [];
}
try {
const unifiedCollection = readUnifiedCollection(collectionsDir);
if (!unifiedCollection || !unifiedCollection.subtypes) {
return [];
}
const patterns = Object.keys(unifiedCollection.subtypes)
.map(mongoSafeSubtype => MONGO_SAFE_TO_PATTERN[mongoSafeSubtype])
.filter(pattern => pattern);
return patterns;
} catch (error) {
console.error('Error reading unified collection:', error.message);
return [];
}
}
/**
* Read unified collection file
*/
function readUnifiedCollection(collectionsDir = './unified-collections') {
const unifiedPath = path.join(collectionsDir, 'unified-collection.json');
if (!fs.existsSync(unifiedPath)) {
return null;
}
try {
const content = fs.readFileSync(unifiedPath, 'utf8');
return JSON.parse(content);
} catch (error) {
console.error('Error reading unified collection:', error.message);
return null;
}
}
/**
* Process scheduler data from unified collection
*/
function processSchedulerFromUnified(subtypeData, targets) {
let count = 0;
// Sort identifiers alphabetically so the iteration order is deterministic
// across runs, independent of how the unified-collection JSON was written.
const identifierKeys = Object.keys(subtypeData || {}).sort();
for (const mongoSafeIdentifier of identifierKeys) {
const classNames = subtypeData[mongoSafeIdentifier] || [];
const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);
for (const className of classNames) {
count++;
targets.push(new BpaTarget(
"scheduler",
className,
identifier,
"Uses imperative Scheduler API instead of declarative @SlingScheduled annotation",
"high"
));
}
}
return count;
}
/**
* Process asset API data from unified collection
*/
function processAssetApiFromUnified(subtypeData, targets) {
let count = 0;
// Sort identifiers alphabetically so the iteration order is deterministic
// across runs, independent of how the unified-collection JSON was written.
const identifierKeys = Object.keys(subtypeData || {}).sort();
for (const mongoSafeIdentifier of identifierKeys) {
const classNames = subtypeData[mongoSafeIdentifier] || [];
const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);
for (const className of classNames) {
count++;
targets.push(new BpaTarget(
"assetApi",
className,
identifier,
`Uses unsupported Asset API: ${identifier}`,
"critical"
));
}
}
return count;
}
/**
* Process event listener data from unified collection
*/
function processEventListenerFromUnified(subtypeData, targets) {
let count = 0;
// Sort identifiers alphabetically so the iteration order is deterministic
// across runs, independent of how the unified-collection JSON was written.
const identifierKeys = Object.keys(subtypeData || {}).sort();
for (const mongoSafeIdentifier of identifierKeys) {
const classNames = subtypeData[mongoSafeIdentifier] || [];
const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);
for (const className of classNames) {
count++;
targets.push(new BpaTarget(
"eventListener",
className,
identifier,
`Uses JCR Event Listener: ${identifier}`,
"high"
));
}
}
return count;
}
/**
* Process resource change listener data from unified collection
*/
function processResourceChangeListenerFromUnified(subtypeData, targets) {
let count = 0;
// Sort identifiers alphabetically so the iteration order is deterministic
// across runs, independent of how the unified-collection JSON was written.
const identifierKeys = Object.keys(subtypeData || {}).sort();
for (const mongoSafeIdentifier of identifierKeys) {
const classNames = subtypeData[mongoSafeIdentifier] || [];
const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);
for (const className of classNames) {
count++;
targets.push(new BpaTarget(
"resourceChangeListener",
className,
identifier,
`Uses Resource Change Listener: ${identifier}`,
"high"
));
}
}
return count;
}
/**
* Process event handler data from unified collection
*/
function processEventHandlerFromUnified(subtypeData, targets) {
let count = 0;
// Sort identifiers alphabetically so the iteration order is deterministic
// across runs, independent of how the unified-collection JSON was written.
const identifierKeys = Object.keys(subtypeData || {}).sort();
for (const mongoSafeIdentifier of identifierKeys) {
const classNames = subtypeData[mongoSafeIdentifier] || [];
const identifier = fromMongoSafeFieldName(mongoSafeIdentifier);
for (const className of classNames) {
count++;
targets.push(new BpaTarget(
"eventHandler",
className,
identifier,
`Uses OSGi Event Handler: ${identifier}`,
"high"
));
}
}
return count;
}
/**
* Fetch findings from unified collection (mimics cam-bpa-fetcher behavior).
*
* The full ordered list for the requested `pattern` is assembled, then
* sliced into a batch via {@link paginate}. The returned object contains
* `targets` (the batch) and `paging` (batch metadata for resuming).
*
* Ordering contract: for a given unified-collection.json file and a given
* `pattern`, successive calls with incrementing `offset` return contiguous,
* non-overlapping batches covering the full list.
*
* @param {string} pattern
* @param {string} collectionsDir
* @param {{offset?: number, limit?: number|null|'all'}} [paging]
*/
function fetchUnifiedBpaFindings(pattern = "all", collectionsDir = './unified-collections', paging = {}) {
const result = new BpaResult();
// Check if unified collection exists
if (!hasUnifiedCollection(collectionsDir)) {
result.error = `Unified collection not found: ${collectionsDir}/unified-collection.json`;
result.troubleshooting = [
"Run bpa-local-parser.js with --unified flag to create unified collection",
"Ensure the collections directory path is correct",
"Check that BPA file has been processed successfully"
];
result.suggestion = [
"Use: node bpa-local-parser.js <bpa-file-path> [output-directory] --unified",
"Verify BPA file contains valid findings data"
];
return result;
}
// Read unified collection
const unifiedCollection = readUnifiedCollection(collectionsDir);
if (!unifiedCollection || !unifiedCollection.subtypes) {
result.error = "Invalid unified collection format";
result.troubleshooting = [
"Check that unified-collection.json contains valid JSON",
"Verify the file has 'subtypes' property",
"Re-run bpa-local-parser.js if file is corrupted"
];
return result;
}
// Determine patterns to fetch
const availablePatterns = getAvailablePatterns(collectionsDir);
if (availablePatterns.length === 0) {
result.error = "No valid pattern collections found in unified collection";
result.troubleshooting = [
"Check that unified collection contains supported subtypes",
"Verify pattern files contain valid data",
"Re-run bpa-local-parser.js if data is missing"
];
return result;
}
// Sort patterns alphabetically so "all" returns findings in a deterministic
// order independent of how availablePatterns was produced.
const patternsToFetch = pattern === "all"
? [...availablePatterns].sort()
: availablePatterns.filter(p => p === pattern);
if (patternsToFetch.length === 0) {
result.error = `Pattern '${pattern}' not found in unified collection. Available: ${availablePatterns.join(', ')}`;
result.suggestion = [
`Use one of: ${availablePatterns.join(', ')}, all`,
"Check that the requested pattern was included in the BPA report"
];
return result;
}
console.log(`[Unified Collection Reader] Reading patterns: ${patternsToFetch.join(', ')}`);
// Process each pattern
for (const pat of patternsToFetch) {
const mongoSafeSubtype = Object.keys(MONGO_SAFE_TO_PATTERN).find(key =>
MONGO_SAFE_TO_PATTERN[key] === pat
);
if (!mongoSafeSubtype) {
console.warn(`[Unified Collection Reader] Unknown pattern: ${pat}, skipping`);
continue;
}
const subtypeData = unifiedCollection.subtypes[mongoSafeSubtype];
if (!subtypeData) {
console.warn(`[Unified Collection Reader] No data for pattern: ${pat}, skipping`);
continue;
}
// Process data based on pattern type
let count = 0;
if (pat === "scheduler") {
count = processSchedulerFromUnified(subtypeData, result.targets);
result.summary.schedulerCount = count;
} else if (pat === "assetApi") {
count = processAssetApiFromUnified(subtypeData, result.targets);
result.summary.assetApiCount = count;
} else if (pat === "eventListener") {
count = processEventListenerFromUnified(subtypeData, result.targets);
result.summary.eventListenerCount = count;
} else if (pat === "resourceChangeListener") {
count = processResourceChangeListenerFromUnified(subtypeData, result.targets);
result.summary.resourceChangeListenerCount = count;
} else if (pat === "eventHandler") {
count = processEventHandlerFromUnified(subtypeData, result.targets);
result.summary.eventHandlerCount = count;
}
console.log(`[Unified Collection Reader] Processed ${count} findings for pattern: ${pat}`);
}
if (result.targets.length === 0) {
result.error = "No findings found in unified collection";
result.suggestion = [
"Verify that BPA file contained relevant findings",
"Check that bpa-local-parser.js processed the file correctly",
"Inspect unified collection file for expected data structure"
];
return result;
}
const { targets, paging: p } = paginate(result.targets, paging);
result.targets = targets;
result.paging = p;
result.success = true;
console.log(
`[Unified Collection Reader] Batch ${p.returned}/${p.total} ` +
`(offset ${p.offset}, hasMore=${p.hasMore})`
);
return result;
}
/**
* Get summary of unified collection (derived from unified-collection.json).
* Returns { timestamp, subtypes, totalFindings } for display purposes.
*/
function getUnifiedCollectionSummary(collectionsDir = './unified-collections') {
const unifiedCollection = readUnifiedCollection(collectionsDir);
if (!unifiedCollection || !unifiedCollection.subtypes) {
return null;
}
const subtypes = Object.keys(unifiedCollection.subtypes);
let totalFindings = 0;
for (const subtypeKey of subtypes) {
const subtypeData = unifiedCollection.subtypes[subtypeKey] || {};
for (const classNames of Object.values(subtypeData)) {
totalFindings += (classNames || []).length;
}
}
return {
timestamp: unifiedCollection.meta?.timestamp || null,
subtypes,
totalFindings: unifiedCollection.meta?.totalFindings ?? totalFindings
};
}
/**
* CLI interface for testing
*
* Usage: node unified-collection-reader.js <pattern> [collectionsDir] [offset] [limit]
* offset defaults to 0
* limit defaults to 5 (use 'all' to disable batching)
*/
function main() {
const args = process.argv.slice(2);
const pattern = args[0] || 'all';
const collectionsDir = args[1] || './unified-collections';
const offset = args[2] !== undefined ? Number(args[2]) : 0;
const limit = args[3] !== undefined
? (args[3] === 'all' ? null : Number(args[3]))
: DEFAULT_BATCH_SIZE;
console.log('Unified Collection Reader');
console.log('========================');
console.log(`Pattern: ${pattern}`);
console.log(`Collections Directory: ${collectionsDir}`);
console.log(`Offset: ${offset}`);
console.log(`Limit: ${limit === null ? 'all' : limit}`);
console.log('');
// Check if unified collection exists
if (!hasUnifiedCollection(collectionsDir)) {
console.error(`❌ Unified collection not found: ${collectionsDir}/unified-collection.json`);
console.error('');
console.error('To create unified collection:');
console.error(' node bpa-local-parser.js <bpa-file-path> [output-directory] --unified');
process.exit(1);
}
// Show available patterns
const availablePatterns = getAvailablePatterns(collectionsDir);
console.log(`Available patterns: ${availablePatterns.join(', ')}`);
// Show summary
const summary = getUnifiedCollectionSummary(collectionsDir);
if (summary) {
console.log(`Total subtypes: ${summary.subtypes?.length || 0}`);
console.log(`Total findings: ${summary.totalFindings || 0}`);
console.log(`Created: ${summary.timestamp || '(unknown)'}`);
}
console.log('');
// Fetch findings
const result = fetchUnifiedBpaFindings(pattern, collectionsDir, { offset, limit });
if (result.success) {
const p = result.paging;
console.log(`✅ Batch ${p.returned}/${p.total} (offset ${p.offset}, next ${p.nextOffset ?? 'none'}, hasMore=${p.hasMore})`);
console.log('');
// Group by pattern
const byPattern = result.targets.reduce((acc, target) => {
if (!acc[target.pattern]) acc[target.pattern] = [];
acc[target.pattern].push(target);
return acc;
}, {});
Object.entries(byPattern).forEach(([pat, targets]) => {
console.log(`${pat.toUpperCase()}:`);
targets.forEach(target => {
console.log(` - ${target.className} (${target.severity}): ${target.issue}`);
});
console.log('');
});
} else {
console.error('❌ Error:', result.error);
if (result.troubleshooting?.length > 0) {
console.error('');
console.error('Troubleshooting:');
result.troubleshooting.forEach(tip => console.error(` - ${tip}`));
}
if (result.suggestion?.length > 0) {
console.error('');
console.error('Suggestions:');
result.suggestion.forEach(tip => console.error(` - ${tip}`));
}
process.exit(1);
}
}
// Run CLI if called directly
if (require.main === module) {
main();
}
module.exports = {
hasUnifiedCollection,
getAvailablePatterns,
fetchUnifiedBpaFindings,
getUnifiedCollectionSummary,
readUnifiedCollection,
paginate,
DEFAULT_BATCH_SIZE,
BpaTarget,
BpaResult
};
Related skills
How it compares
Pick migration over generic file-move skills when Adobe creative specs and SLICC project integrity must be preserved.
FAQ
What does the Adobe migration skill migrate?
The Adobe migration skill migrates design assets, SLICC projects, and creative workflows into updated formats, platforms, or team structures while preserving original design specifications.
When should agents use Adobe migration?
Agents should use Adobe migration when users plan spec-safe moves of Adobe libraries, SLICC projects, or creative pipelines during design-system or platform upgrades.