Skip to content

t1k:cocos:playable:modularize-reuse-scan

FieldValue
Moduleplayable
Version2.14.4
Efforthigh
Tools—

Keywords: reuse, reuse-scan, subsystem

/t1k:cocos:playable:modularize-reuse-scan
<project-root> [--apply|--json]

Classify every local framework-ish subsystem into one of four verdicts. Part of the t1k-cocos-playable-modularize set — obeys the shared CLI contract.

VerdictMeaningConsumed by
REUSEDuplicates an upstream package; local is equivalent or worse → swap for a package import.refactor Tier 2
EXTENDAn upstream package covers it, but local has a genuine fix or extension → swap and contribute the improvement back as a package update.refactor Tier 2 + contribute (UPDATE mode)
KEEPGame-specific glue; stays in the project.—
CONTRIBUTENovel, self-contained, general; no upstream package → new package.contribute (ADD mode)

EXTEND is the verdict that prevents silent data loss. Without it, a subsystem where local diverged ahead of the package gets tagged REUSE, and the Tier 2 swap deletes the local improvement along with the local code — irreversibly, and invisibly, because the swap compiles clean. Whenever a package match is found, compare versions of the behaviour, not just presence.

Unlike structure-audit’s closed-form R1–R11 grading, this pillar’s REUSE/EXTEND/KEEP/CONTRIBUTE verdict is NOT rubric application over known facts — it turns on judging semantic-similarity doc_search hits, filtering Unity/.NET noise from genuine Cocos-foundation matches, and weighing whether a divergence is loadBearing. Only the surrounding fact-gathering is mechanical (routing SSOT: rules/orchestration-rules.md § “Task-Type → Agent Routing”):

StepAgent
Run enumerate-subsystems.cjs and tabulate the cpmAdoption / installedVersions rollups it already computes offlinet1k-metrics-reporter
Pull ONE named CPM listing (npm view @playablelabs/<name> --registry <host>) to resolve a package’s latest versiont1k-fact-fetcher
Reconcile a vendored package’s tree against its published tarball for vendoredDivergence (byte/line diff, CRLF-normalized)t1k-data-verifier — a stated comparison method, not a judgment
Judge REUSE vs EXTEND vs KEEP vs CONTRIBUTE per subsystem (doc_search similarity, loadBearing divergence, .NET-noise filtering)stays on a reasoning tier — the rubric in classification-rubric.md still requires interpreting an unstructured similarity hit and a behavioral diff, not just applying labels to known values
--apply: perform the AskUserQuestion-gated package installstays on a write-capable, reasoning tier

A block claiming the whole pillar is cheap would be wrong: t1k-list-classifier labels items against an already-decided rule, and here the hard part — was this doc_search hit actually the same subsystem, does this diff matter enough to block a swap — is the decision the rubric depends on, not a lookup into it. Only the deterministic scaffolding around that decision (running the enumerator, tabulating what it already computed, fetching one named version, diffing two known trees) is cheap-tier work.

  • After structure-audit flags embedded framework (R1 DRIFT): decide which subsystems to reuse.
  • “Which of our local systems already exist as @playablelabs packages?”
  • Before refactor: produce the REUSE/EXTEND/KEEP/CONTRIBUTE input its migration plan consumes.

Manifest input — where subsystem boundaries come from

Section titled “Manifest input — where subsystem boundaries come from”

A “subsystem” is either a declared module or a top-level folder, and which one you got changes what the report means. Pass --manifest docs/design-manifest.json (generated by t1k-cocos-base-system-design) and modules come from the design doc’s explicit file lists; omit it and they are readdir(assets/scripts).

discoverySubsystem =Read it as
manifesta module from the design doc, with an explicit file listreal boundaries; a module spanning three directories enumerates as one subsystem
readdir-fallbacka top-level directorya guess at boundaries

The fallback is labelled, never silent — every studio project has no manifest on day one, so a degraded scan must stay distinguishable from a real one. It is also a fully valid report: this pillar grades local subsystems against upstream packages, which is independent of the project’s own architecture target. A project with no design doc still gets a complete reuse-scan.

Why it is worth passing. On a legacy god-class project folder shape is exactly what is wrong. On the one real canonical studio project, readdir returns a single scripts/SniperDuel blob where the design doc declares 12 modules — one doc_search instead of twelve, against a boundary nobody drew.

parse: "partial" propagates. A partially-read module map covers only what was read; the report says so, and you must not present it as complete. parse: "unparseable" falls back to readdir and names the migration helper — modules: [] there means the doc could not be read, not that the project has no modules.

Skipping doc_search. When a module carries upstreamPackage, the subsystem is flagged skipDocSearch: true only if the manifest’s priorArt is "searched" — that is Phase 0’s sweep having actually run. When priorArt is "unavailable" the recorded answer is unverified rather than disproven, so query it yourself; trusting it would launder “never looked” into “already checked” (rules/negative-result-scope.md).

What genuinely gates this scan is discovery (MCP and/or CPM), below — not the design doc.

Library discovery — two sources, MCP is not required

Section titled “Library discovery — two sources, MCP is not required”

Full contract: shared-cpm-facts § Library discovery.

  1. Knowledge MCP (mcp__knowledge-retrieval__doc_search) — hybrid lexical+vector discovery. Best at “does something already do X?” but proves capability, never the package name.
  2. Cocos Package Manager — https://cpm.playablelabs.ai/ web UI, the CLI, the editor extension’s list-packages handler, or npm view @playablelabs/<name> --registry <host>. Authoritative on names, versions, and current contents; only matches what you thought to look up.

Prefer both: MCP to discover, CPM to confirm the concrete package and version. Record which source produced each verdict.

  • MCP absent → degraded, NOT blocked. Drive discovery from the CPM listing instead, and mark the report discovery: cpm-only so a reader knows synonym matches may have been missed. Never report a false “0 packages found” because one source was unavailable — that would falsely mark every subsystem CONTRIBUTE (errors over silent fallbacks).
  • Both sources unavailable → exit 3. With no discovery at all there is no REUSE signal, and every verdict would be a guess.
  • Corpus reality (verified): doc_search indexes the TheOne .NET/Unity API corpus PLUS the Cocos template’s own subsystems (objectPool, signalBus, async, gameControl, …). It is NOT a @playablelabs npm registry — that is exactly why CPM is the naming authority. GameFoundation.* is on the Unity/.NET side despite the name — never a Cocos REUSE signal. Full contract + how to filter .NET noise: references/doc-search-contract.md.

Composition is plain import; there is no register(ctx). A REUSE swap replaces embedded-source imports with the owning package’s import and records the package in playableSync + assets/packages/@playablelabs/. Install SSOT + the package↔subsystem folder map: references/playablesync-ssot.md.

  1. Validate <project-root>; resolve discovery sources — MCP live? CPM reachable? Record which. Neither available → exit 3. MCP-only or CPM-only → proceed, marking the report’s discovery field.
  2. Enumerate: node scripts/enumerate-subsystems.cjs <project-root> [--manifest docs/design-manifest.json] → per-subsystem JSON. Pass --manifest whenever one exists (see § Manifest input); check the report’s discovery field to know which definition of “subsystem” you actually got. Also carries a self-containment / reuseLikelihood heuristic (game-coupled = low, self-contained = high), an offline divergence / verdictHint signal against any already-installed package counterpart (see below), plus a top-level cpmAdoption: { onCpm, totalCpmImports, subsystemsImportingCpm } rollup, plus an installedVersions block (one entry per vendored @playablelabs/* package, with installed read offline and latest left null for step 5 to fill). Read cpmAdoption.onCpm FIRST — it answers “is this project already on CPM?”. When true, subsystems with imports["external-cpm"] > 0 are already consuming upstream packages, so their doc_search result confirms an existing install rather than proposing a new one.
  3. Query per subsystem: one behaviour-keyword doc_search each (see the contract). Read the top hits’ assembly; a high-similarity hit on a known foundation subsystem = “exists upstream”.
  4. Classify via references/classification-rubric.md (exists-upstream × game-coupled × divergence → REUSE / EXTEND / KEEP / CONTRIBUTE). Map REUSE and EXTEND subsystems to their owning package via the folder map. On every upstream match, run the divergence check before settling on REUSE: verdictHint from step 2 is the first signal — 'EXTEND' means the offline export-name diff already found local exporting something the installed package doesn’t (divergence.extraExports). When verdictHint is null (nothing installed locally yet), fall back to comparing against the CPM listing / published contents directly. Local has a fix, an extra case, or a behaviour the package lacks → EXTEND, and record what diverged so contribute can carry it upstream. Equivalent or worse → REUSE.
  5. Resolve installedVersions (REQUIRED when anything is installed). enumerate-subsystems.cjs emits one entry per vendored assets/packages/@playablelabs/* with installed read offline and latest: null. Fill in each latest from CPM — https://cpm.playablelabs.ai/-/verdaccio/data/packages, authenticated with the ~/.npmrc token, or the package-manager extension’s list-packages. Carry the block through to the persisted report unchanged in shape (see § “installedVersions” below). Leave latest null where you could not resolve it — never guess, never copy installed across; refactor distinguishes the two and reports which it got.

5b. Compare each vendored package against its own published tarball, and record the result in vendoredDivergence (see § below). This is the question “was this package patched in place?”, which nothing else in the pipeline asks: the per-subsystem divergence from step 2 diffs export NAMES between local game code and an installed package, and never looks inside the vendored tree. Without this block refactor plans every upgrade as routine, and running one silently deletes the patch (#274). Where the comparison genuinely could not run (no registry access), emit { "checked": false } rather than omitting the block or writing an empty realDivergences — those two say different things and refactor reports them differently. Normalize \r\n -> \n on both sides before diffing, or on a Windows checkout every text file reports as diverged and the one real patch is lost in the noise (#294).

5c. Resolve every imported subpath against the latest tarball, and record the result in importPathCheck + per-entry removedImportPaths (see § below). This is the question “does the version we are about to move to still export what this project imports?”, which neither of the two blocks above asks — and which semver cannot answer, because this registry ships removals under patch bumps. Without it refactor plans a breaking migration as a routine risk: low bump and offers it first (#294). Where the resolution genuinely could not run (no registry access), emit { "checked": false } rather than omitting the block — an absent removedImportPaths under an unchecked report means nobody looked, not that nothing was removed.

  1. Report + name the install → reports/; persist JSON. For each REUSE subsystem the report names the owning @playablelabs/* package — and, wherever the CPM listing gave you one, the version, on the subsystem row (targetVersion). Carry it: refactor Tier 1.5 pins that version onto the install command, and a row without one installs registry latest and has to say in its own evidence that nobody resolved a version (rules/negative-result-scope.md). The REUSE/KEEP/CONTRIBUTE lists are the direct input to refactor (and contribute reads CONTRIBUTE).
  2. Who performs the install. The install itself is refactor Tier 1.5, running install-package.cjs — a headless, self-verifying script, gated per unit, ordered before the Tier 2 swap that dependsOn it. Prefer that path: it is the one an unattended --implement walk can execute, and it only installs for a swap the plan has actually decided to make. (--apply only) reuse-scan may still install a package itself after an AskUserQuestion gate, per package, for the standalone case where no refactor plan exists yet — use the same script, not the package-manager panel handler, which no unattended walk can reach. Either way reuse-scan NEVER rewrites game source imports; that is refactor.

installedVersions — the version-drift contract

Section titled “installedVersions — the version-drift contract”

refactor Tier 0 (upgrade an already-installed package that is behind latest) is sourced entirely from this block, so the report MUST carry it whenever anything is vendored under assets/packages/@playablelabs/. Shape, one entry per installed package:

"installedVersions": [
{ "package": "@playablelabs/game-foundation", "installed": "1.0.13", "latest": "1.1.5", "source": "vendored-package-json", "locallyModified": true },
{ "package": "@playablelabs/motion", "installed": "0.1.3", "latest": null, "source": "vendored-package-json" }
]
FieldOwnerMeaning
packagescriptScoped package name, from the vendored directory name.
installedscript (offline)version from the vendored package.json; null when that file is missing or unreadable.
latestskill body (CPM, network + auth)Registry latest. null = NOT RESOLVED — never “up to date”.
sourcescriptvendored-package-json (version read) · unreadable-package-json (manifest present but unusable) · no-package-json (directory is not a package).
locallyModifiedskill body (optional)true when the vendored tree no longer matches the published tarball. A summary flag — the detail belongs in vendoredDivergence. refactor blocks the upgrade on this alone, so set it only when the comparison actually ran; absent ≠ false.
removedImportPathsskill body (optional)Subpaths this project imports that latest no longer exposes. refactor blocks the upgrade on a non-empty list; see § “importPathCheck” below for the shape and for why an absent list is not an answer.

A note string is not a field refactor can read. Recording “BREAKING despite the patch-level version” in prose while leaving removedImportPaths unset is exactly how #294 happened: the fact was known, written down in the same report, and still planned as the safest step in the run. Every finding that should change a plan goes in a typed field.

The split is deliberate: the installed side is deterministic and offline, so it belongs in the script; the latest side needs the network and the ~/.npmrc token, so it belongs here (rules/ai-driven-design.md). A null latest makes refactor report versionDrift.state: partial/unresolved rather than emitting a row, which is the honest answer — collapsing unresolved into current would report a mature project as drift-free without ever having looked (rules/negative-result-scope.md).

vendoredDivergence — the was-this-package-patched contract

Section titled “vendoredDivergence — the was-this-package-patched contract”

Version drift says a package is behind. This block says whether upgrading it would destroy something. refactor reads both, and an outdated package with a divergence here is planned as a risk: high BLOCKED row pointing at contribute UPDATE mode instead of a routine bump (#274).

"vendoredDivergence": {
"checked": true,
"method": "published tarball vs vendored tree, diff over all non-obfuscated .ts files; cross-checked against git log",
"note": "Every .core.impl.ts differs textually because the publish pipeline obfuscates non-deterministically; that is NOT divergence.",
"realDivergences": [
{
"file": "assets/packages/@playablelabs/game-foundation/gameControl/utilities/AudioSystem/AudioService.ts",
"commit": "7a21a0f",
"delta": "+22 lines: SFX_MAX_PER_SEC sliding-1s-window one-shot SFX rate limiter",
"loadBearing": true,
"loadBearingEvidence": "assets/scripts/PlayableHelper.ts:121 documents the BGM-starvation behaviour this prevents",
"verdict": "EXTEND"
}
]
}
FieldMeaning
checkedtrue only when the comparison actually ran. Omitting the whole block, or checked: false, both read as “nobody compared” — never as “nothing is patched”.
methodHow the comparison was made, so a reader can judge what it could and could not see.
noteKnown non-signal (e.g. non-deterministic obfuscation) that would otherwise inflate the list.
realDivergences[]One entry per genuine local change. file (vendored path — refactor attributes it to a package by that path, or by an explicit package field), commit, delta, loadBearing + loadBearingEvidence, verdict.

A divergence refactor cannot attribute to a package blocks nothing — it becomes a report-only row instead. Give every entry either a path under assets/packages/<scope>/<name>/ or an explicit package field; an entry with neither is a warning nobody can act on.

An empty realDivergences under checked: true is a real result and lets the upgrade proceed as routine. That is the only shape that says so.

Normalize line endings on BOTH sides before comparing — \r\n -> \n, or diff with git diff --ignore-cr-at-eol, or hash CRLF-agnostically. A Windows checkout of the vendored tree is CRLF while the published tarball is LF, so a raw byte or line comparison reports every text file as diverged and the real patch is invisible inside the noise. Measured 2026-08-20 (HexaBugFlowPLA): 524 of 524 .ts files came back diverged, hiding the single genuine AudioService.ts patch above; after normalization the count is exactly 1 (#294). Without this the block’s checked: true is worse than checked: false — it asserts a comparison that in practice answers nothing.

importPathCheck + removedImportPaths — the does-the-target-still-export-this contract

Section titled “importPathCheck + removedImportPaths — the does-the-target-still-export-this contract”

The third and last Tier 0 risk axis (#294), and the only one that is a property of the package alone. It asks whether the version we are about to upgrade TO still exports what this project imports — a question neither installedVersions nor vendoredDivergence asks, and one that semver cannot answer, because this registry ships removals under patch bumps.

A non-empty removedImportPaths makes the Tier 0 row risk: high and BLOCKED. An absent list under an unchecked report means nobody looked, not that nothing was removed.

Full contract — the JSON shape, the field table, and the measured case: references/import-path-check.md.

Reachability — verify-reachability.cjs and the t1k-cocos-reachability/2 contract

Section titled “Reachability — verify-reachability.cjs and the t1k-cocos-reachability/2 contract”

Zero TS importers is evidence of nothing. In Cocos a @ccclass component invocation path is EDITOR ATTACHMENT in a scene or prefab, not a TS import — so the import graph cannot decide whether a file is dead. scripts/verify-reachability.cjs decides it from evidence instead, and it is the check the unattended --implement walk runs before any delete or component re-point.

A delete decision reads the pair (verdict, transitivelyReachable) plus the report-level dynamicLoadCallSites — never verdict alone, never the free-form evidence prose.

Full contract — the CLI, the four checks, the JSON schema, the decision matrix (a <!-- decision-matrix --> table cross-checked against reachabilityDecision() by the refactor skill tests/docs-match-rules.test.cjs), and the dynamicLoadCallSites caveat: references/reachability-contract.md.

Terminal window
node scripts/enumerate-subsystems.cjs <project-root> # subsystem list + heuristic (report-only)
node scripts/enumerate-subsystems.cjs <project-root> --manifest docs/design-manifest.json
# subsystems from the design doc's module map instead of folder shape. Report says
# discovery: "manifest"; without the flag it says "readdir-fallback" — a labelled guess.
node scripts/enumerate-subsystems.cjs <project-root> --roots assets/PLAGameFoundation,assets/PlayableParamterTool
node scripts/verify-reachability.cjs <project-root> --target <rel-path> [--target ...]
node scripts/verify-reachability.cjs <project-root> --all-scripts [--quiet]
# is this asset actually reachable? Evidence-based, NOT import-graph-based (schema
# t1k-cocos-reachability/2). A delete decision reads the PAIR (verdict, transitivelyReachable)
# plus the report-level dynamicLoadCallSites — never verdict alone, never the prose evidence.
# See § Reachability above. Exit 0 on any verdict; 2 on usage/IO only.
  • A package match is not automatically REUSE. Run the divergence check first — local may be ahead. Tagging an improved local subsystem REUSE means the Tier 2 swap deletes the improvement, silently and irreversibly, while compiling clean. When in doubt, tag EXTEND: a needless EXTEND costs one review, a missed one costs the work.
  • The same trap exists one level down, inside the vendored package. A Tier 2 swap deletes an improved local subsystem; a Tier 0 upgrade deletes an improved local package file. Only vendoredDivergence stands between the two, and refactor can block nothing it was never told (#274). Omitting the block on a project with patched vendored code is not a neutral omission — it is what makes the destructive step look routine.
  • verdict: LIVE does not mean “keep”. It means something points at this file, ONE HOP. A component attached only to its own prefab, where nothing nests or loads that prefab, is LIVE and genuinely dead. Decide on (verdict, transitivelyReachable) and cite rootedIn: LIVE + false is delete-eligible, LIVE + true is protected.
  • transitivelyReachable: null is not false. Undeterminable is not permission — manual queue. The commonest cause is a non-empty dynamicLoadCallSites, which makes every unreached asset under assets/resources/ unprovable; the minified AssetsManager puts a real project in exactly that state, and it is what stopped AudioEmitter.prefab from being deleted.
  • CPM is the naming authority; the MCP is not. doc_search proves a capability exists in the corpus but cannot give you the package name or version — resolve those from cpm.playablelabs.ai (or the extension’s list-packages) before writing any install suggestion.
  • A missing MCP degrades the scan; it does not block it. Fall back to CPM-only discovery and mark discovery: cpm-only. Only exit 3 when both sources are unavailable.
  • A doc_search miss is only meaningful when the MCP ran. Never classify CONTRIBUTE off a miss caused by an absent MCP — error instead.
  • Filter .NET noise. The corpus is mostly Unity/.NET TheOne.*; treat a hit as REUSE evidence ONLY when its assembly is a known Cocos foundation subsystem. Use assembly_prefix to constrain — it is exact-segment (use the exact full assembly name), and check for byte-identical duplicates before trusting a thin-looking page: sibling Cocos*PLA projects crowd the top 25 with the same fact (up to ~44% duplicate waste, measured 2026-08-25). Mix one lowercase word into every query to avoid all-caps junk.
  • doc_search proves existence, not the package name. Resolve the @playablelabs/* target from the folder map, not the hit.
  • An unresolved latest is not a current package. Leaving latest: null costs one honest versionDrift: partial in the plan; copying installed into it silently deletes a real upgrade row and tells the next reader the project is on the latest of everything. Resolve it, or leave it null.
  • No register(ctx). If you catch yourself designing a DI seam, stop — it does not exist.
  • AnimalHunter: embedded PLAGameFoundation/* and PlayableParamterTool/* are mostly REUSE (they mirror game-foundation / parameter-tool); scripts/AnimalHunterPLA/ wiring is KEEP.