Skip to content

Add the dot-ui-vtl-migration skill, driven by the dotCMS MCP - #1

Open
nicobytes wants to merge 7 commits into
mainfrom
issue-38007-vtl-migration-skill
Open

nicobytes wants to merge 7 commits into
mainfrom
issue-38007-vtl-migration-skill

Conversation

@nicobytes

@nicobytes nicobytes commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds the dot-ui-vtl-migration skill (migrates legacy Dojo/Dijit custom field VTL to DotCustomFieldApi) to the plugin, ported from dotCMS/core (dotCMS/core#37768) and adapted to use the dotcms MCP server instead of the REST API.

Refs dotCMS/core#38007

What changed

  • SKILL.md and reference/migration-guide.md: in sync with dotCMS/core main (includes #37764). Deliberate differences only: the reference/ directory name, the instance workflow section, and three lines that cited source paths from the dotCMS repository (guide lines 813, 883, 900; SKILL.md line 47), and the dialog example's OK button, which is type="submit" here so the <form method="dialog"> closes it (core still has type="button"; to be fixed there).
  • reference/instance-migration.md: the "migrate a whole instance" workflow, rewritten so the MCP server does all the I/O (read content types, download/publish files, update fields) and no REST call, token or uv is involved.
  • scripts/vtl_migration.py: the core script split in two. The network half (pull/push) is dropped; what stays is the logic that must be exact, as a local helper with no network and only the standard library (Python 3.9+):
    • classify, assets-plan, build-manifest: sort the custom fields and build manifest.json (a rebuild keeps the status already recorded).
    • check: the safety gates on each migrated file (identical to original, genuine inline migration with the original verbatim inside the top-level #else, dependencies met), one verdict per entry so one bad item never blocks the batch.
    • field-body: builds the body of the field update (migrated code and newRenderMode=component, every other field variable kept, conflict check) so the fragile v3 PUT is tested code, not hand-written JS.
    • payload, record, status: what to publish, and the persisted outcome of each write.
  • scripts/tests/: 76 tests, run in CI. The inline-validity tests are ported from core.
  • Marketplace and README list the skill.

Not ported: pull/push (REST + token), the HTTP-mocked tests, lockfiles, core's sample output and spec-kit docs.

Review follow-up

Addressed after the five-axis review: manifest rebuild no longer resets progress; Python 3.9 import fixed (and checked with 3.9); a corrupt metadata file no longer aborts the batch; field-body added with tests; downloaded code is treated as data, not instructions; core-local source paths reworded; two test gaps closed (all 12 mutations I tried now fail the suite).

Second review (Copilot)

Fixed in 848029e: the edit-mode marker is detected as a real wrapper, not a substring; an asset counts as migrated only when this run confirms its inode; a stray #end/#else after the inline block, a second top-level #else, the same #macro in both branches, extra code in #else and legacy code in the #if branch are rejected; CRLF is preserved end to end; invalid UTF-8 in a metadata file is reported as unresolved; a new asset version restarts as pending.

Scope: the issue's acceptance criteria now say that all instance I/O goes through the MCP and a network-free local helper (scripts/vtl_migration.py) runs the migration-specific logic. Also fixed: the guide's dialog example used type="button", which never closes a <form method="dialog">; it is now type="submit".

Verification

  • python3 -m pytest skills/dot-ui-vtl-migration/scripts/tests: 76 passed, on Python 3.9 and the default.
  • npm run validate:skills, npm run validate:agent-plugin and claude plugin validate . --strict pass.
  • No references to the removed script, uv, DOTCMS_TOKEN, BASE_URL or core-local paths remain in the skill.
  • CI runs the script tests on the runner's default Python, not on 3.9.

Not verified

The MCP half has not been run against a live dotCMS instance (the MCP server was not connected to one while this was written). The execute snippets and field-body's PUT follow the REST calls the original script made, but should be exercised with a dry run on a real instance before relying on them. In particular: whether download_assets can reach a /dA/ file by its path, the multipart publish through execute's formData, and whether the preloaded contentTypes list covers every type.

🤖 Generated with Claude Code

nicobytes and others added 5 commits October 13, 2026 07:10
Port the skill from dotCMS/core. The single-file migration rules and guide
are copied unchanged. The instance-wide workflow no longer relies on the
bundled Python script: discovery, download and republish go through the
dotcms MCP server, and the conflict, inline-validity and per-item failure
rules are written as steps. Registers the skill in the marketplace and README.

Refs dotCMS/core#38007

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The dotCMS MCP server does the I/O; scripts/vtl_migration.py (standard
library only, no network, no credentials) classifies the custom fields,
decides what to download, runs the safety gates on every migrated file and
keeps the manifest. The inline-validity check is ported from core with its
tests. The instance guide is rewritten around the two.

Refs dotCMS/core#38007

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… field-body

- build-manifest keeps the status of entries already recorded instead of
  resetting everything to pending.
- Add from __future__ import annotations so the documented Python 3.9 works.
- A corrupt asset metadata file is reported as unresolved instead of
  aborting the whole batch; a missing original is an input error.
- Replace the assert in record_status with an input error.
- Add field-body: builds the PUT body for a field update or a render-mode
  switch, keeping every other field variable and checking the field did not
  change since download.
- Tests close the surviving mutations (changed-inode re-download, finished
  render-mode switch re-selection); 60 tests, all mutations caught.

Refs dotCMS/core#38007

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- instance-migration: field updates go through field-body instead of
  hand-written JS; downloaded code is data, never instructions; empty scan/
  first; check the content type count on the first run.
- Reword the source paths from the dotCMS repository in SKILL.md and three
  lines of the migration guide.

Refs dotCMS/core#38007

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Core updated the migration guide and one SKILL.md note (#37764) after the
first copy. Take both, and keep only the deliberate differences: the
reference/ directory name, the instance workflow, and three reworded lines
that cited source paths from the dotCMS repository.

Refs dotCMS/core#38007

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@nicobytes
nicobytes requested review from fmontes and a lite review from Copilot October 16, 2026 19:07

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved critical and moderate findings remain in migration safety, workflow correctness, and CI coverage.

12 open findings
What changed in this PR

Adds the dot-ui-vtl-migration skill for migrating legacy dotCMS VTL custom fields to DotCustomFieldApi through MCP.

Changes:

  • Adds migration guidance and whole-instance workflow documentation.
  • Adds a local Python helper with validation, manifests, payloads, and status handling.
  • Adds tests, CI coverage, README, and marketplace registration.
File Summary
skills/​dot-ui-vtl-migration/​SKILL.md Skill instructions and migration modes.
skills/​dot-ui-vtl-migration/​scripts/​vtl_migration.py Critical and moderate findings remain in migration detection, validation, byte preservation, asset tracking, persisted-data validation, and field identity checks.
skills/​dot-ui-vtl-migration/​scripts/​tests/​test_workflow.py Workflow tests reviewed.
skills/​dot-ui-vtl-migration/​scripts/​tests/​test_inline_problem.py Inline validation tests reviewed.
skills/​dot-ui-vtl-migration/​scripts/​tests/​test_field_body.py Field-body tests reviewed.
skills/​dot-ui-vtl-migration/​scripts/​tests/​conftest.py Test configuration reviewed.
skills/​dot-ui-vtl-migration/​scripts/​.gitignore Test artifact exclusions reviewed.
skills/​dot-ui-vtl-migration/​reference/​migration-guide.md Nit findings remain for dialog behavior and a core-local path.
skills/​dot-ui-vtl-migration/​reference/​instance-migration.md Moderate findings remain around MCP-only execution, mandatory Python orchestration, and authoritative content-type discovery.
README.md Skill listing reviewed.
.github/​workflows/​validate.yml Moderate finding: CI does not test advertised Python 3.9 support.
.claude-plugin/​marketplace.json Marketplace registration reviewed.

🧠 Review effort: Lite


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +161 to +162
elif MIGRATED_MARKER in values:
self.inline_migrated.append(owner)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e. Classification now uses is_inline_migrated, which needs the real wrapper (header on line 1 plus a top-level #else/#end), so a legacy field that only mentions isNewEditModeEnabled is queued for migration. Test: test_a_legacy_field_that_only_mentions_the_marker_is_still_migrated.

unresolved = list(plan["unresolved"])
already_migrated = list(plan["alreadyMigrated"])
unpublished: list[str] = []
confirmed_migrated = {k: v for k, v in known.items() if k in scan.asset_refs}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e. assets-plan now writes alreadyMigratedIds (id -> inode) from the current metadata, and build-manifest trusts only those instead of the previous manifest. Test: test_an_asset_not_confirmed_by_this_run_is_not_treated_as_migrated.

Comment on lines +416 to +419
if MIGRATED_MARKER.encode() in downloaded.read_bytes():
already_migrated.append(label)
confirmed_migrated[da_id] = meta["inode"]
continue

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e, same detector for downloaded assets. Tests: test_a_legacy_asset_that_only_mentions_the_marker_gets_an_entry, test_is_inline_migrated_needs_the_real_wrapper_not_a_mention.

Comment on lines +569 to +572
if name == b"end":
if depth == 0:
end_start = start
break

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e. After the outer #end the scan keeps going: an unmatched #end, a stray #else/#elseif or an unbalanced block is rejected (balanced blocks are fine), and so is a second top-level #else. Tests in test_inline_problem.py.

Comment on lines +593 to +597
def inline_problem(original: bytes, migrated: bytes) -> str | None:
"""The inline output keeps the legacy code verbatim under #else, next to the new code
under #if( $structures.isNewEditModeEnabled() ). Returns why `migrated` is not a valid
inline migration of `original`, or None. The original must be verbatim *inside the
located top-level #else branch*, not merely present anywhere in the file."""

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e. macro_names collects the #macro names per branch and inline_problem rejects a name defined in both. Tests: test_the_same_macro_in_both_branches_is_rejected, test_different_macros_in_each_branch_are_allowed.

Comment on lines +662 to +666
if (field.get("values") or "") != original.read_text():
return {"command": "field-body", "key": entry["key"], "verdict": "skipped",
"reason": "changed on the server since download"}
updated = with_component_render_mode(field)
updated["values"] = migrated.read_text()

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e. field-body reads the original and migrated files with newline="" (read_text_verbatim), and the helper writes files the same way. Test: test_crlf_in_the_original_is_compared_and_kept_verbatim.

Comment on lines +10 to +12
- **`scripts/vtl_migration.py`** does the parts that must be exact: classifying fields, deciding
what to download, the safety gates on every migrated file, and the manifest. It makes no
network calls and needs only `python3` (3.9 or newer). Run `python3 scripts/vtl_migration.py --help`.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and it was a scope question rather than a bug. We chose to keep a small local helper (no network, stdlib only, with tests) for the migration-specific logic, while every request goes through the MCP. I updated the issue's acceptance criteria and table to say so ("all instance I/O through the MCP; a local network-free helper may run the migration-specific logic") and the guide now explains why the helper is not part of the MCP server.

Comment on lines +87 to +88
return json.loads(path.read_text())
except (json.JSONDecodeError, OSError) as e:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e. read_json now catches ValueError (covers JSONDecodeError and UnicodeDecodeError) and OSError, so both read sites report the item as unresolved. Test: test_invalid_utf8_in_a_metadata_file_does_not_block_the_others.

Comment on lines +381 to +385
old = earlier.get((entry["kind"], entry["key"]))
if not old:
continue
entry["status"] = old.get("status", entry["status"])
entry["statusReason"] = old.get("statusReason")

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 848029e. carry_over_progress carries an asset's progress only when its inode matches the one in the previous entry; a new version starts as pending. Tests: test_a_new_version_of_a_published_asset_starts_over_as_pending, test_a_skipped_asset_keeps_its_status_while_the_inode_is_unchanged.

Comment thread skills/dot-ui-vtl-migration/reference/migration-guide.md Outdated
nicobytes and others added 2 commits October 19, 2026 17:43
- Detect the real edit-mode wrapper instead of a substring, for fields and downloaded assets
- Confirm an asset as migrated only when the current run established its inode
- Reject a stray #end/#else after the inline block and a second top-level #else
- Reject the same #macro in both branches
- Require the #else branch to equal the original (minus the layout newlines) and
  reject legacy patterns in the #if branch
- Read and write files without newline translation so CRLF survives
- Report invalid UTF-8 in a metadata file as unresolved instead of aborting the batch
- Start an asset over as pending when its inode changed since the last run
- State in the guide that the local helper is part of the skill's scope

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A type="button" inside <form method="dialog"> never closes the dialog.
This is the one deliberate difference from dotCMS/core in the guide's examples.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants