feat(publish): rescue buildScaffoldMap() — openbuild-app repos are installable standalone apps - #117
Conversation
…stallable standalone apps RESCUED FROM CODEBERG. ExportService::buildScaffoldMap() landed on codeberg.org/Conduction/openbuild as 4a47d797 (2026-07-26) and never reached GitHub. Codeberg is being retired, so it would have been destroyed with the remote. Searched all 230 origin refs for 'buildScaffoldMap' in lib/Service/ExportService.php — zero hits. Three of the four Codeberg-only openbuild commits DID land on GitHub, via merge 001ea41 'import Codeberg feature line onto GitHub'. Blob-identity checked at both tips for PageDesignerHost.vue, BuilderHost.vue, AgentsController.php, GeneratedDocumentController.php, the three listeners and CHANGELOG.md — all IDENTICAL. This method is what that import missed. The port is purely additive — 73 insertions, 0 deletions across the three files — so GitHub's copies were strict subsets and taking the Codeberg blobs reproduces them exactly. All six helpers the method calls (prepareScratchDir, copyTemplate, resolvePlaceholders, bundleDataRegisterSchemas, listFilesSorted, rrmdir) are verified present on GitHub's ExportService. ONE DELIBERATE CHANGE from the Codeberg original. Its docblock read: @SPEC openspec/changes/federated-config-sharing/specs/federated-config-sharing/spec.md That path does not exist on GitHub — and does not exist on the CODEBERG tip either. It was a dangling anchor from the moment it was written, and would fail gate-58 (spec-anchor-existence). Re-pointed at the github-app-sync spec, which is the change this method actually serves (GitHubAppSyncService folds the scaffold into the config-set repo publish) and which does exist here.
Blocked on a design decision I should not make blind
phpmd is green on What the finding is actually pointing atOnly two of the 13 public methods have a production caller: The other nine — Two ways forward, both for the service's owner to pick
I have deliberately done neither. The rescued code itself is verified good — purely additive (73 insertions, 0 deletions), all six helpers present,
|
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ❌ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-08-04 07:55 UTC
Download the full PDF report from the workflow artifacts.
… calls phpmd's TooManyPublicMethods fired on this class the moment buildScaffoldMap() was rescued, and the finding was pointing at something real: of 12 public methods, only two — generateAppZip() and scratchTreeDir() — had a production caller (both RunExportJob). The other nine were public purely so the two test files could reach them. Nine methods are now private: copyTemplate, resolvePlaceholders, packageZip, listFilesSorted, isBinary, prepareScratchDir, getOrCreateAppDataDir, rrmdir, bundleDataRegisterSchemas. Both test files now drive the real entry points instead. Every behaviour the old tests asserted directly on a step is now asserted through its effect on what generateAppZip() / buildScaffoldMap() produce — placeholder resolution, ASCII entry ordering, the snapshot helper-file skip, data-register bundling, scratch-tree cleanup — plus three assertions the old tests did not make at all. Suite: 743 -> 746 tests, 2245 -> 2342 assertions. No baseline entry was added: phpmd is now clean on this class, and the report set-diffs empty against development.
Quality Report — ConductionNL/openbuild @
|
| Check | PHP | Vue | Security | License | Tests |
|---|---|---|---|---|---|
| lint | ✅ | ||||
| phpcs | ✅ | ||||
| phpmd | ✅ | ||||
| psalm | ✅ | ||||
| phpstan | ✅ | ||||
| phpmetrics | ✅ | ||||
| eslint | ✅ | ||||
| stylelint | ✅ | ||||
| build | ✅ | ||||
| composer | ✅ | ✅ 101/101 | |||
| npm | ✅ | ✅ 654/654 | |||
| PHPUnit | ✅ | ||||
| Newman | ⏭️ | ||||
| Playwright | ⏭️ |
Quality workflow — 2026-08-04 08:25 UTC
Download the full PDF report from the workflow artifacts.
Merged after narrowing the public surface (not baselining the finding)The Caller analysis (re-measured,
|
| method | production caller |
|---|---|
generateAppZip() |
RunExportJob.php:178 |
scratchTreeDir() |
RunExportJob.php:267 |
buildScaffoldMap() |
none on this remote — see #118 |
copyTemplate(), resolvePlaceholders(), packageZip(), listFilesSorted(), isBinary(), prepareScratchDir(), getOrCreateAppDataDir(), rrmdir(), bundleDataRegisterSchemas() |
none — self-calls only; public solely so the two test files could reach them |
Checked for ReflectionMethod / setAccessible / dynamic dispatch across lib/ and tests/ first: 14 sites, none of them touching ExportService. The class is not registered in AppInfo/Application.php or any event wiring; its only injection point is RunExportJob's constructor.
Those nine are now private. Public surface: constructor + 3.
The tests now drive the real entry points
Both files were rewritten. Every behaviour previously asserted by calling a step directly is now asserted through what generateAppZip() / buildScaffoldMap() actually produce:
- placeholder resolution →
appinfo/info.xmlin the archive listFilesSorted→ archive entries equal their own ASCII-sorted copy (REQ-OBEX-008)copyTemplate's skip list →.snapshot-meta.json/.path-manifest.txtabsent from the archive (new — the old suite never asserted this)- data-register bundling (all five cases) →
lib/Settings/data-registers/*entries in the archive rrmdirinbuildScaffoldMap'sfinally→ no scratch tree left underopenbuild-work/(new)scratchTreeDir→ deterministic, job-scoped, and does not create the directory (new)ExporterEndToEndTest::testReExportIsByteEquivalentnow runs the whole pipeline twice instead of re-packaging one tree, so copy + resolve determinism is in scope too
Suite: 743 → 746 tests, 2245 → 2342 assertions. The e2e loop's binary-file skip is now the test's own extension list, not $service->isBinary() — asking the service what to skip made that assertion agree with itself.
Positive control
Broke two private behaviours the narrowed API still exposes — $skip = [] in copyTemplate() and sort() → rsort() in listFilesSorted():
1) ExportServiceTest::testGenerateAppZipOrdersArchiveEntriesLexicographically
2) ExportServiceTest::testGenerateAppZipOmitsSnapshotHelperFiles
Failed asserting that an array does not have the key '.snapshot-meta.json'.
FAILURES! Tests: 13, Assertions: 200, Failures: 2.
Reverted via edit (not git revert); git status --porcelain empty and git diff --exit-code 0 confirm it applied. Green again: OK (746 tests, 2342 assertions), phpmd exit 0.
phpmd set-diff, not totals
Full report compared line-for-line against development in a second worktree at 4115de22f, same vendor, same --baseline-file:
development: 0 findings, exit 0- this branch: 0 findings, exit 0
- this branch before the narrowing: exactly 1 —
ExportService.php:67 TooManyPublicMethods … has 11 public methods, exit 2 (that run is the positive control for the phpmd invocation itself)
Nothing new appeared elsewhere from making methods private.
Job sets
31 jobs in the PR's Code Quality run vs 31 on development's, identical name sets. The only conclusion differences are event-shaped: SBOM + Features Extract run on push and skip on PRs, Features Check the reverse. No gate went missing.
One thing this PR's description got wrong
GitHubAppSyncServicefolds the scaffold into the config-set repo publish
It does on Codeberg — GitHubAppSyncService.php:294 there. It does not here: that call site was never rescued, so buildScaffoldMap() lands dead. It cannot be a file copy either, because GitHub's GitHubAppSyncService is strictly newer (AppChannelApplier, OUTCOME_FORBIDDEN, warning-level broker logging — all absent from Codeberg's). Tracked as #118. The method stays public as a designed-but-unwired entry point, and its docblock says exactly that.
Rescued from Codeberg before the remote is removed.
ExportService::buildScaffoldMap()landed oncodeberg.org/Conduction/openbuildas4a47d797(2026-07-26) and never reached GitHub.Absence verified across all 230
originrefs, not justdevelopment: zero hits forbuildScaffoldMapinlib/Service/ExportService.php.Why only this one, when four commits were Codeberg-only
Three of the four did land, via merge
001ea414f"import Codeberg feature line onto GitHub". Blob-identity checked at both tips forPageDesignerHost.vue,BuilderHost.vue,AgentsController.php,GeneratedDocumentController.php, the three listeners andCHANGELOG.md— all IDENTICAL. This method is precisely what that import missed.(The fourth Codeberg-only commit,
97c31714, untracks 559 MB ofdocs/build/artefacts. GitHub still tracks 304 such files; the.gitignorerules are already identical on both sides, GitHub just never ran thegit rm --cached. Trivially reproducible, no content at risk — left for a separate hygiene PR.)The port is purely additive
0 deletions means GitHub's copies were strict subsets, so taking the Codeberg blobs reproduces them exactly with no risk of reverting newer GitHub work. All six helpers the method calls (
prepareScratchDir,copyTemplate,resolvePlaceholders,bundleDataRegisterSchemas,listFilesSorted,rrmdir) verified present on GitHub'sExportService.One deliberate change from the original
The Codeberg docblock read:
That path does not exist on GitHub — and does not exist on the Codeberg tip either. It was a dangling anchor from the moment it was written and would fail gate-58 (spec-anchor-existence). Re-pointed at the
github-app-syncspec, which is the change this method actually serves (GitHubAppSyncServicefolds the scaffold into the config-set repo publish) and which does exist here.