t1k:cocos:playable:modularize-reuse-scan
| Field | Value |
|---|---|
| Module | playable |
| Version | 2.14.4 |
| Effort | high |
| Tools | — |
Keywords: reuse, reuse-scan, subsystem
How to invoke
Section titled “How to invoke”/t1k:cocos:playable:modularize-reuse-scan<project-root> [--apply|--json]reuse-scan (Pillar B)
Section titled “reuse-scan (Pillar B)”Classify every local framework-ish subsystem into one of four verdicts. Part of the
t1k-cocos-playable-modularize set — obeys the
shared CLI contract.
| Verdict | Meaning | Consumed by |
|---|---|---|
| REUSE | Duplicates an upstream package; local is equivalent or worse → swap for a package import. | refactor Tier 2 |
| EXTEND | An 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) |
| KEEP | Game-specific glue; stays in the project. | — |
| CONTRIBUTE | Novel, 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.
Executed by
Section titled “Executed by”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”):
| Step | Agent |
|---|---|
Run enumerate-subsystems.cjs and tabulate the cpmAdoption / installedVersions rollups it already computes offline | t1k-metrics-reporter |
Pull ONE named CPM listing (npm view @playablelabs/<name> --registry <host>) to resolve a package’s latest version | t1k-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 install | stays 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.
When to invoke
Section titled “When to invoke”- After
structure-auditflags embedded framework (R1 DRIFT): decide which subsystems to reuse. - “Which of our local systems already exist as
@playablelabspackages?” - 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).
discovery | Subsystem = | Read it as |
|---|---|---|
manifest | a module from the design doc, with an explicit file list | real boundaries; a module spanning three directories enumerates as one subsystem |
readdir-fallback | a top-level directory | a 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.
- 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. - Cocos Package Manager — https://cpm.playablelabs.ai/ web UI, the CLI, the editor extension’s
list-packageshandler, ornpm 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-onlyso 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@playablelabsnpm 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.
Module contract (do not re-derive)
Section titled “Module contract (do not re-derive)”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.
Workflow
Section titled “Workflow”- 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’sdiscoveryfield. - Enumerate:
node scripts/enumerate-subsystems.cjs <project-root> [--manifest docs/design-manifest.json]→ per-subsystem JSON. Pass--manifestwhenever one exists (see § Manifest input); check the report’sdiscoveryfield to know which definition of “subsystem” you actually got. Also carries a self-containment /reuseLikelihoodheuristic (game-coupled = low, self-contained = high), an offlinedivergence/verdictHintsignal against any already-installed package counterpart (see below), plus a top-levelcpmAdoption: { onCpm, totalCpmImports, subsystemsImportingCpm }rollup, plus aninstalledVersionsblock (one entry per vendored@playablelabs/*package, withinstalledread offline andlatestleft null for step 5 to fill). ReadcpmAdoption.onCpmFIRST — it answers “is this project already on CPM?”. When true, subsystems withimports["external-cpm"] > 0are already consuming upstream packages, so their doc_search result confirms an existing install rather than proposing a new one. - Query per subsystem: one behaviour-keyword
doc_searcheach (see the contract). Read the top hits’assembly; a high-similarity hit on a known foundation subsystem = “exists upstream”. - 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:verdictHintfrom 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). WhenverdictHintisnull(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 socontributecan carry it upstream. Equivalent or worse → REUSE. - Resolve
installedVersions(REQUIRED when anything is installed).enumerate-subsystems.cjsemits one entry per vendoredassets/packages/@playablelabs/*withinstalledread offline andlatest: null. Fill in eachlatestfrom CPM —https://cpm.playablelabs.ai/-/verdaccio/data/packages, authenticated with the~/.npmrctoken, or the package-manager extension’slist-packages. Carry the block through to the persisted report unchanged in shape (see § “installedVersions” below). Leavelatestnull where you could not resolve it — never guess, never copyinstalledacross;refactordistinguishes 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.
- 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:refactorTier 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 torefactor(andcontributereads CONTRIBUTE). - Who performs the install. The install itself is
refactorTier 1.5, runninginstall-package.cjs— a headless, self-verifying script, gated per unit, ordered before the Tier 2 swap thatdependsOnit. Prefer that path: it is the one an unattended--implementwalk can execute, and it only installs for a swap the plan has actually decided to make. (--applyonly) 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 isrefactor.
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" }]| Field | Owner | Meaning |
|---|---|---|
package | script | Scoped package name, from the vendored directory name. |
installed | script (offline) | version from the vendored package.json; null when that file is missing or unreadable. |
latest | skill body (CPM, network + auth) | Registry latest. null = NOT RESOLVED — never “up to date”. |
source | script | vendored-package-json (version read) · unreadable-package-json (manifest present but unusable) · no-package-json (directory is not a package). |
locallyModified | skill 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. |
removedImportPaths | skill 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" } ]}| Field | Meaning |
|---|---|
checked | true only when the comparison actually ran. Omitting the whole block, or checked: false, both read as “nobody compared” — never as “nothing is patched”. |
method | How the comparison was made, so a reader can judge what it could and could not see. |
note | Known 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.
Scripts
Section titled “Scripts”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.References
Section titled “References”references/doc-search-contract.md— VERIFIED corpus reality, query/result shape, REUSE-signal rule, .NET-noise filter, MCP-absence guard.references/classification-rubric.md— REUSE/KEEP/CONTRIBUTE decision table + guards.references/playablesync-ssot.md—playableSyncinstall SSOT, real package-manager handlers, package↔subsystem folder map.references/import-path-check.md— theimportPathCheck+removedImportPathscontract (#294): JSON shape, field table, and why an absent list is not an answer.references/reachability-contract.md— thet1k-cocos-reachability/2contract: the four checks, the(verdict, transitivelyReachable)matrix, and thedynamicLoadCallSitescaveat.scripts/verify-reachability.cjs— the reachability scanner and SSOT for thet1k-cocos-reachability/2schema. Documented inreferences/reachability-contract.md; consumed byt1k-cocos-playable-modularize/references/decision-policy.md§ Rule 3 and by the orchestrator’s unattended--implementwalk.
Gotchas
Section titled “Gotchas”- 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
vendoredDivergencestands between the two, andrefactorcan 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: LIVEdoes 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, isLIVEand genuinely dead. Decide on(verdict, transitivelyReachable)and citerootedIn:LIVE+falseis delete-eligible,LIVE+trueis protected.transitivelyReachable: nullis notfalse. Undeterminable is not permission — manual queue. The commonest cause is a non-emptydynamicLoadCallSites, which makes every unreached asset underassets/resources/unprovable; the minifiedAssetsManagerputs a real project in exactly that state, and it is what stoppedAudioEmitter.prefabfrom 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 itsassemblyis a known Cocos foundation subsystem. Useassembly_prefixto constrain — it is exact-segment (use the exact full assembly name), and check for byte-identical duplicates before trusting a thin-looking page: siblingCocos*PLAprojects 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
latestis not a current package. Leavinglatest: nullcosts one honestversionDrift: partialin the plan; copyinginstalledinto 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/*andPlayableParamterTool/*are mostly REUSE (they mirrorgame-foundation/parameter-tool);scripts/AnimalHunterPLA/wiring is KEEP.