Skip to content

feat(scaffolds): add utility/* package scaffolds for framework Utils - #50

Open
Adi-ty wants to merge 2 commits into
mainfrom
feat/utility-package-scaffolds
Open

feat(scaffolds): add utility/* package scaffolds for framework Utils#50
Adi-ty wants to merge 2 commits into
mainfrom
feat/utility-package-scaffolds

Conversation

@Adi-ty

@Adi-ty Adi-ty commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Ships the five source: "package" utility scaffolds — utility/cache, utility/transients, utility/logger, utility/timer, utility/feature-selector — that wire the existing rtcamp/wp-framework Utils\* classes with a Composer dependency and one boot snippet, writing zero files.

Closes

Closes rtCamp/wp-devtools#26

Changes

  • Five new manifests at scaffolds/utility/<slug>/scaffold.json: source: "package", files: [], module_class set to the framework FQCN, composer_dependencies: {"rtcamp/wp-framework": "^1.0"}, and one wiring[] entry (anchor // scaffold:utility/<slug>, target_file: {{base_path}}/Helpers/Util.php, a static memoised accessor as snippet_template).
  • context_slug input: discover_from: composer.json:name, transform: snake-case, default: my_plugin (e.g. rtcamp/acme-blogrtcamp_acme_blog). base_path input defaults to includes.
  • Wiring targets the consumer's static helper (Helpers/Util.php), not a module or Main::CLASSES — see Reviewer notes for why.
  • Test coverage in tests/scaffolds/bundled-manifests.test.js: it.each over all five ids asserting kind, zero writes/skips/tests/secrets, the composer dependency, and the rendered wiring (anchor, targetFile, snippet contents, no leftover {{); plus discovery, supplied-wins-over-discovered, no-composer.json-fallback, base_path override, and Timer-no-arg cases.
  • Docs: docs/wp-framework-contract.md gains a ## Utility classes table (constructor + key methods per class) and extends the anchor-convention section; docs/authoring-scaffolds.md gains a ## Package scaffolds section; skills/setup/SKILL.md and skills/scaffold/SKILL.md (+ their byte-identical template mirrors) updated to route feature requests to the five scaffolds and flag the Helpers/Util.php anchor convention.

How I verified

❯ npm run check

> wp-tooling-monorepo@0.0.0 check
> npm run check --workspaces --if-present


> @rtcamp/eslint-config@0.1.0 check
> npm run lint && npm test


> @rtcamp/eslint-config@0.1.0 lint
> eslint index.js tests


> @rtcamp/eslint-config@0.1.0 test
> jest

 PASS  tests/eslint.test.js
  eslint config
    ✓ exports a non-empty array (1 ms)
    ✓ every entry is a plain object (2 ms)
    ✓ includes the eslint-comments plugin
    ✓ includes jest plugin scoped to test files only

Test Suites: 1 passed, 1 total
Tests:       4 passed, 4 total
Snapshots:   0 total
Time:        0.796 s, estimated 1 s
Ran all test suites.

> @rtcamp/stylelint-config@0.1.0 check
> npm run lint && npm test


> @rtcamp/stylelint-config@0.1.0 lint
> eslint index.js tests


> @rtcamp/stylelint-config@0.1.0 test
> jest

 PASS  tests/stylelint.test.js
  stylelint config
    ✓ exports a plain object (1 ms)
    ✓ extends @wordpress/stylelint-config
    ✓ extends @wordpress/stylelint-config/scss (1 ms)

Test Suites: 1 passed, 1 total
Tests:       3 passed, 3 total
Snapshots:   0 total
Time:        0.072 s, estimated 1 s
Ran all test suites.

> @rtcamp/tailwind-config@0.1.0 check
> npm run lint && npm test


> @rtcamp/tailwind-config@0.1.0 lint
> eslint index.js postcss.js GenerateTailwindThemePlugin.js tests


> @rtcamp/tailwind-config@0.1.0 test
> jest

  console.log
    [GenerateTailwindThemePlugin] Written to /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-2ae734198097/src/css/frontend/_tailwind-theme.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:227:12)

  console.log
    [GenerateTailwindThemePlugin] Scaffolded /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-2ae734198097/src/css/frontend/tailwind.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:239:12)

  console.log
    [GenerateTailwindThemePlugin] Written to /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-e4d63e97fa7a/src/css/frontend/_tailwind-theme.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:227:12)

  console.log
    [GenerateTailwindThemePlugin] Scaffolded /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-e4d63e97fa7a/src/css/frontend/tailwind.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:239:12)

  console.log
    [GenerateTailwindThemePlugin] Written to /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-6540753116dd/src/css/frontend/_tailwind-theme.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:227:12)

  console.log
    [GenerateTailwindThemePlugin] Written to /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-a6bc32e08e32/src/css/frontend/_tailwind-theme.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:227:12)

  console.log
    [GenerateTailwindThemePlugin] Scaffolded /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-a6bc32e08e32/src/css/frontend/tailwind.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:239:12)

  console.log
    [GenerateTailwindThemePlugin] Written to /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-a6bc32e08e32/src/css/frontend/_tailwind-theme.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:227:12)

  console.log
    [GenerateTailwindThemePlugin] Written to /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-f49484f88b8d/src/css/frontend/_tailwind-theme.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:227:12)

  console.log
    [GenerateTailwindThemePlugin] Scaffolded /var/folders/wy/5g6ztjds0z368jw3q77n9vz80000gn/T/wp-tooling-test-f49484f88b8d/src/css/frontend/tailwind.css

      at GenerateTailwindThemePlugin.log [as generate] (GenerateTailwindThemePlugin.js:239:12)

 PASS  tests/GenerateTailwindThemePlugin.test.js
  generateThemeBlock
    preset sections
      ✓ maps color palette to --color-* variables (1 ms)
      ✓ maps fontSizes to --text-* variables
      ✓ maps fontFamilies to --font-* variables
      ✓ maps spacingSizes to --spacing-* variables
      ✓ maps shadow presets to --shadow-* variables (1 ms)
    layout tokens
      ✓ emits both layout variables when contentSize and wideSize are set
      ✓ emits only contentSize when wideSize is absent
      ✓ emits only wideSize when contentSize is absent
      ✓ omits layout block when settings.layout is absent
    edge cases
      ✓ skips entry with missing slug and warns (1 ms)
      ✓ skips preset section when array is empty
      ✓ skips preset section when key is absent
      ✓ returns a valid @theme {} block for empty themeJson
  GenerateTailwindThemePlugin.generate()
    ✓ writes _tailwind-theme.css containing the @theme block (13 ms)
    ✓ scaffolds tailwind.css with layer and theme imports when absent (2 ms)
    ✓ does not overwrite tailwind.css when it already exists (2 ms)
    ✓ updates _tailwind-theme.css when theme.json changes (3 ms)
    ✓ does not rewrite _tailwind-theme.css when content is unchanged (2 ms)
    ✓ logs an error and writes nothing when theme.json is missing (1 ms)
    ✓ logs an error when theme.json contains invalid JSON

Test Suites: 1 passed, 1 total
Tests:       20 passed, 20 total
Snapshots:   0 total
Time:        0.1 s, estimated 1 s
Ran all test suites.

> @rtcamp/wp-tooling@0.1.0 check
> npm run lint && npm test


> @rtcamp/wp-tooling@0.1.0 lint
> eslint src tests


> @rtcamp/wp-tooling@0.1.0 test
> jest

 PASS  tests/cli/index.test.js
  cli main()
    ✓ no args prints top-level usage and exits 0 (4 ms)
    ✓ --help prints top-level usage
    ✓ -h prints top-level usage
    ✓ --version prints package version and exits 0 (1 ms)
    ✓ -v prints package version
    ✓ unknown top-level flag exits 2 with stderr message
    ✓ unknown subcommand exits 2 with stderr message
    ✓ routes detect-changes --help to its runCli (3 ms)
    ✓ routes detect-changes through to its runCli with args (2 ms)
    ✓ detect-changes propagates a usage-error exit code
  cli main() central error handling
    ✓ CancelledError from a subcommand exits 130 with a stderr message
    ✓ non-CancelledError rejections propagate to the bin shim (8 ms)
  cli COMMANDS registry
    ✓ detect-changes is registered with a summary and run handler
    ✓ install-hooks is registered with a summary and run handler
    ✓ scaffold subcommand "add" is registered with a summary and run handler (1 ms)
    ✓ scaffold subcommand "list" is registered with a summary and run handler (1 ms)
    ✓ scaffold subcommand "validate" is registered with a summary and run handler
  cli main() routes scaffold subcommands
    ✓ routes add --help to src/scaffolds/add runCli (17 ms)
    ✓ routes list --help to src/scaffolds/list runCli (1 ms)
    ✓ routes validate --help to src/scaffolds/validate runCli (1 ms)
  cli main() routes install-hooks
    ✓ routes install-hooks --help to its runCli (1 ms)
  cli loadCommands()
    ✓ discovers a valid command module and indexes it by name (97 ms)
    ✓ ignores non-.js files in the commands directory (4 ms)
    ✓ throws a clear error when a module is missing required fields (4 ms)
    ✓ throws when two modules register the same name (10 ms)
    ✓ returns entries in deterministic (sorted) order (8 ms)

 PASS  tests/init/git.test.js
  installGitHooks
    ✓ installs both native hooks and uses no Husky (31 ms)
    ✓ preserves an existing non-Husky prepare by chaining (34 ms)
    ✓ replaces a Husky prepare and is idempotent (76 ms)
    ✓ skips cleanly when the directory is not a git repo

 PASS  tests/scaffolds/add.test.js
  add command argument parsing
    ✓ exits 0 on --help without doing work (1 ms)
    ✓ exits 1 when scaffold id is missing
    ✓ still accepts --flag=value for values starting with --
    ✓ exits 1 (not interactive hang) on a parse error
  formatErrorPayload
    ✓ keeps EBADSCAFFOLD file + errors fields (documented payload) (1 ms)
  add command non-interactive flow
    ✓ writes files in non-interactive mode (28 ms)
    ✓ emits a single JSON line on --json (19 ms)
    ✓ --dry-run does not write files (29 ms)
    ✓ emits structured error JSON on --json for missing input (65 ms)
    ✓ emits ENOSCAFFOLD JSON for unknown id (40 ms)
  engine core is not coupled to TTY UI
    ✓ non-interactive code path does not require the TTY UI kit (2 ms)

 PASS  tests/scaffolds/cli-support.test.js
  requireFlagValue
    ✓ returns a normal value
    ✓ throws when the value is missing (7 ms)
    ✓ throws when the next token is itself a flag
    ✓ points the user at the =value form for dash-leading values (1 ms)
  fetchOptsFrom
    ✓ maps only the set fetch options (1 ms)
  paths + buildRegistry
    ✓ resolves the bundled defaults dir and a project bin/scaffolds dir
    ✓ scans the bundled catalogue (31 ms)

 PASS  tests/scaffolds/bundled-manifests.test.js
  setup/psr4 wiring snippet
    ✓ renders a JSON-valid PSR-4 key for a multi-segment namespace (4 ms)
  wp/cli PSR-4 discovery (grafted sub-namespaces)
    ✓ fills namespace and tests_namespace from composer.json, keeping kind sub-namespaces (2 ms)
  wiring targetFile normalisation
    ✓ emits a `..`-free targetFile for wp/cli (2 ms)
  utility/* package scaffolds
    ✓ utility/cache writes nothing and reports the dep plus one accessor snippet (3 ms)
    ✓ utility/transients writes nothing and reports the dep plus one accessor snippet (3 ms)
    ✓ utility/logger writes nothing and reports the dep plus one accessor snippet (4 ms)
    ✓ utility/timer writes nothing and reports the dep plus one accessor snippet (4 ms)
    ✓ utility/feature-selector writes nothing and reports the dep plus one accessor snippet (1 ms)
    ✓ discovers context_slug from composer.json:name, snake-cased (1 ms)
    ✓ prefers a supplied context_slug over the discovered one (1 ms)
    ✓ falls back to the default slug when there is no composer.json
    ✓ honours a base_path override in the wiring target (1 ms)
    ✓ constructs Timer with no argument — it takes no context

 PASS  tests/scaffolds/list.test.js
  list command help / parsing
    ✓ --help prints usage and exits 0 (6 ms)
    ✓ rejects --origin with invalid value
  list command, human output (default catalogue only)
    ✓ lists all bundled scaffolds with categories (55 ms)
    ✓ groups scaffolds by category in the human output (12 ms)
  list command, --json output
    ✓ emits a single JSON line on stdout (19 ms)
    ✓ JSON entries carry the expected shape and counts (56 ms)
    ✓ reports secrets for the WPORG workflow scaffold (21 ms)
  list command, two-directory merge
    ✓ shows project-local scaffolds alongside defaults (29 ms)
    ✓ --origin=project shows ONLY project scaffolds (44 ms)
    ✓ --category filter narrows the listing (16 ms)
    ✓ empty filter returns an empty scaffolds array (not an error) (39 ms)

 PASS  tests/scaffolds/registry.test.js
  ScaffoldRegistry construction
    ✓ rejects empty args (22 ms)
    ✓ accepts a single string (back-compat for project-only) (2 ms)
    ✓ accepts object form
  scan()
    ✓ discovers scaffolds and tags source (1 ms)
    ✓ returns empty when no scaffolds found (1 ms)
    ✓ throws EBADSCAFFOLD on malformed JSON (1 ms)
    ✓ throws EBADSCAFFOLD on schema-invalid scaffold (1 ms)
  two-directory merge
    ✓ project entries override default entries on category/slug collision (3 ms)
    ✓ non-colliding default and project entries both appear (15 ms)
  get() and filter()
    ✓ finds scaffold by category/slug id (1 ms)
    ✓ finds scaffold by slug alone (no category)
    ✓ returns null for unknown id
    ✓ filter by predicate
  collectDependencies()
    ✓ merges composer/npm deps across selected scaffolds (2 ms)
  execute() result shape
    ✓ returns the four-block shape (2 ms)
    ✓ dry-run writes no files (5 ms)
    ✓ full run writes files (2 ms)
    ✓ skips existing files (no overwrite) (2 ms)
    ✓ treats a test entry sharing a file dest as declarative, on both clean run and idempotent re-run (D2) (4 ms)
    ✓ warns on supplied inputs the scaffold does not declare (1 ms)
    ✓ does not warn when every supplied input is declared
  execute() error paths
    ✓ throws ENOSCAFFOLD with available list (1 ms)
    ✓ throws EMISSINGINPUT with missingDetails when required input is absent
  execute() passes scripts through to developer block
    ✓ passes npm and composer scripts verbatim (2 ms)
    ✓ emits empty script maps when scaffold declares none (1 ms)
    ✓ keeps npm runtime vs dev vs composer-suggest in distinct fields (no merging) (2 ms)
  execute() never embeds secret values
    ✓ passes through declared secrets without any value field (1 ms)
  remote scaffolds (sources + index)
    ✓ scan fetches the index and surfaces a thin remote record (3 ms)
    ✓ remote id colliding with a local scaffold throws EBADSCAFFOLD (4 ms)
    ✓ same id offered by two sources throws EBADSCAFFOLD (2 ms)
    ✓ unreachable index is skipped with a warning, not a hard failure (30 ms)
    ✓ malformed index is skipped with a warning; local scaffolds survive (12 ms)
    ✓ schema-invalid index is skipped with a warning, not a hard failure (3 ms)
    ✓ execute hydrates the manifest then fetches + writes the template (8 ms)
    ✓ renders the fetched manifest inputs into the template (11 ms)
    ✓ ETag cache serves the body on 304 across fresh registries (11 ms)
    ✓ malformed remote manifest throws EBADSCAFFOLD (6 ms)
    ✓ schema-invalid remote manifest throws EBADSCAFFOLD (9 ms)
    ✓ manifest 404 throws EFETCHFAIL (3 ms)
    ✓ index checksum is verified against the fetched manifest (10 ms)
    ✓ checksum mismatch throws EBADSCAFFOLD before any write (7 ms)
    ✓ checksum matches case-insensitively (uppercase hex + SHA256: prefix) (19 ms)
    ✓ remote manifest dest escaping --cwd throws EWRITEFAIL (4 ms)
    ✓ dry-run fetches the manifest but not the template, writes nothing (4 ms)
    ✓ parallel template prefetch: 3 templates fetched concurrently (114 ms)
    ✓ re-run on an existing dest performs no further fetch (offline-safe) (7 ms)

 PASS  tests/release/zip.test.js
  release/zip - helpers
    ✓ crc32 matches known vectors (1 ms)
    ✓ dosTimeDate encodes a known epoch
    ✓ dosTimeDate clamps year >= 1980
    ✓ compileIgnorePattern returns null for comments and blanks (1 ms)
    ✓ compileIgnorePattern matches a simple filename
    ✓ compileIgnorePattern with trailing / matches directories only
    ✓ compileIgnorePattern with single * does not cross /
    ✓ compileIgnorePattern with ** crosses /
    ✓ loadIgnorePatterns falls back to defaults when no .distignore
    ✓ resolveEpoch honours SOURCE_DATE_EPOCH env var
    ✓ resolveEpoch honours explicit option over env
    ✓ FALLBACK_EPOCH is exposed and stable
  release/zip - walkProject
    ✓ honours .distignore and skips .git / dist always (5 ms)
  release/zip - integration
    ✓ builds dist/<slug>-<version>.zip and excludes per .distignore (5 ms)
    ✓ two runs against the same tree produce byte-identical zips (7 ms)
    ✓ refuses to overwrite existing zip without --force (13 ms)
    ✓ overwrites existing zip when --force is set (6 ms)
    ✓ dryRun does not write dist/ (4 ms)
    ✓ default ignore list excludes node_modules when .distignore absent (3 ms)
    ✓ zipPack round-trips a single entry through DEFLATE

 PASS  tests/scaffolds/validate.test.js
  validate base scaffold
    ✓ accepts a minimal valid scaffold (1 ms)
    ✓ rejects non-objects
    ✓ reports missing required fields
    ✓ rejects unknown top-level fields
    ✓ rejects malformed slug
    ✓ accepts optional category in kebab-case
    ✓ accepts nested category with slashes (e.g. lint/phpcs)
    ✓ rejects unknown source value
    ✓ rejects unknown wizard_step value (1 ms)
  validate files block
    ✓ rejects non-array files
    ✓ rejects entry missing src
    ✓ accepts empty files array (source: package case)
  validate inputs block
    ✓ accepts a full inputs entry
    ✓ rejects uppercase input key
    ✓ rejects duplicate input keys
    ✓ rejects invalid transform value
  validate wiring block
    ✓ accepts a wiring entry
    ✓ accepts a wiring entry with no anchor (anchor is advisory per the AI contract)
    ✓ rejects a wiring entry whose anchor is an empty string
    ✓ rejects a wiring entry missing snippet_template
  validate tests block
    ✓ accepts a tests entry (1 ms)
    ✓ rejects unknown framework
    ✓ accepts actionlint for YAML
  validate secrets block
    ✓ accepts a secrets entry
    ✓ rejects lowercase secret key
    ✓ rejects unknown scope
    ✓ rejects extra fields (no value smuggling)
  validate scripts block
    ✓ accepts npm-only scripts
    ✓ accepts composer-only scripts
    ✓ accepts both targets
    ✓ rejects unknown script target
    ✓ rejects non-string command
    ✓ rejects non-object map per target
  validate dependency maps
    ✓ accepts a composer dependency map (1 ms)
    ✓ rejects non-string version
  validate rejects the removed repository field
    ✓ treats 'repository' as an unknown top-level field
    ✓ rejects source: 'repository' (no longer an allowed source)
  validateOne on-disk checks
    ✓ source: template still reports missing files
  validate runCli
    ✓ exits 0 on --help and prints usage (1 ms)
    ✓ validates the whole bundled catalogue with exit 0 (17 ms)
    ✓ filters by single scaffold id (6 ms)
    ✓ exits 1 on unknown scaffold id with available list (7 ms)
    ✓ --json emits machine-readable results (7 ms)
    ✓ --json emits structured error for unknown id on stderr (4 ms)
    ✓ includes project-local scaffolds when --cwd is set (11 ms)
    ✓ rejects unknown flags
    ✓ validates a valid project sources file (offline, no fetch) (11 ms)
    ✓ fails when a source has a bad repository slug (6 ms)
  validate --remote (sources + index)
    ✓ default validate (no --remote, no cache) makes no network call (10 ms)
    ✓ recognises a remote id offline from a cached index (24 ms)
    ✓ --remote fetches the index + schema-validates a sound manifest (ok) (11 ms)
    ✓ --remote fails a manifest whose checksum mismatches the index (11 ms)
    ✓ --remote surfaces EFETCHFAIL when the manifest 404s (11 ms)
    ✓ --remote reports schema errors from a fetched-but-invalid manifest (15 ms)
    ✓ --remote flags a remote id colliding with a local scaffold (28 ms)

 PASS  tests/scaffolds/fetch.test.js
  defaultCacheDir
    ✓ honours XDG_CACHE_HOME when set
    ✓ falls back to ~/.cache when XDG_CACHE_HOME is empty (1 ms)
  composeUrl
    ✓ builds a raw-content URL
    ✓ normalises leading and trailing slashes on each segment
    ✓ rejects a '..' segment in the relative path (12 ms)
    ✓ rejects a '..' segment in the source path with EFETCHFAIL
  fetchRemoteFile
    ✓ cache miss fetches, writes body + ETag sidecar (12 ms)
    ✓ cache hit revalidates with If-None-Match; 304 serves cache (15 ms)
    ✓ 200 with a new body updates the cache (5 ms)
    ✓ --refresh re-fetches without a conditional header (2 ms)
    ✓ --refresh ignores a stray 304 and re-fetches a real body (2 ms)
    ✓ offline (transport error) with a cached copy serves cache + warns (1 ms)
    ✓ 404 with a cached copy still throws (does not serve stale) (1 ms)
    ✓ sends User-Agent and Authorization when token supplied (1 ms)
    ✓ falls back to WP_TOOLING_GITHUB_TOKEN env var
    ✓ non-2xx with no cache throws EFETCHFAIL with statusCode
    ✓ 403 + rate-limit body sets rateLimited (1 ms)
    ✓ transport error with no cache throws EFETCHFAIL with cause
    ✓ response-stream error (reset mid-body) rejects EFETCHFAIL (1 ms)
    ✓ timeout with no cache throws EFETCHFAIL
    ✓ cache write failure records a warning but returns body (1 ms)
  readCached
    ✓ returns null when nothing is cached, never hits the network
    ✓ returns the cached body when present (2 ms)

 PASS  tests/scaffolds/features.test.js
  feature block validation
    ✓ rejects a feature block missing config_key (16 ms)
  status()
    ✓ lists features as disabled when no .wp-tooling.json exists (2 ms)
    ✓ reflects an enabled flag from .wp-tooling.json (2 ms)
  enable()
    ✓ creates files, sets the flag, and adds gitignore lines (10 ms)
    ✓ is idempotent on re-enable (existing files skipped, flag stays true) (14 ms)
    ✓ renders {{placeholder}} gitignore lines against resolved inputs (4 ms)
    ✓ throws ENOTFEATURE for a non-feature scaffold (1 ms)
  disable()
    ✓ removes owned files, clears the flag, and removes gitignore lines (7 ms)
    ✓ keeps a confirm_remove file when confirmRemove is absent (24 ms)
    ✓ removes a confirm_remove file when confirmRemove returns true (36 ms)
    ✓ reports missing files and is a safe no-op when already disabled (10 ms)
  enable()/disable() mirror via persisted feature files
    ✓ persists rendered file lists on enable (14 ms)
    ✓ disable removes the enable-time paths without re-supplying inputs (2 ms)
  recordDeps()
    ✓ records missing deps sorted, preserving tab indentation
    ✓ never overwrites an existing version range
    ✓ is a no-op without a package.json
    ✓ applyChange with install:false, record:true records deps instead of installing (4 ms)
    ✓ applyChange with plain install:false reports deps without touching package.json (12 ms)
  parseArgs()
    ✓ wires the shared value guard for --enable (9 ms)
    ✓ accepts --flag=value and repeated flags
    ✓ parses --record-deps (off by default)
  applyChange disable summary
    ✓ includes the documented `missing` list (6 ms)

 PASS  tests/release/bump.test.js
  release/bump - pure helpers
    ✓ nextVersion patch (1 ms)
    ✓ nextVersion minor zeros patch
    ✓ nextVersion major zeros minor + patch
    ✓ nextVersion explicit override wins
    ✓ nextVersion rejects malformed current (7 ms)
    ✓ nextVersion rejects malformed explicit (1 ms)
    ✓ nextVersion rejects unknown type (1 ms)
    ✓ slugToConstantPrefix converts kebab to UPPER_SNAKE
    ✓ rewritePluginEntry rewrites Version header and constant value
    ✓ rewritePluginEntry also rewrites const-style constants
    ✓ rewriteJsonVersion preserves a 2-space indent (1 ms)
    ✓ rewriteJsonVersion returns null when version key absent (7 ms)
  release/bump - integration against fixture
    ✓ patch bump updates package.json, composer.json, plugin entry, constant (8 ms)
    ✓ --to overrides --type (8 ms)
    ✓ dry-run does not write any file (18 ms)
    ✓ composer.json without version is left untouched (8 ms)
    ✓ throws when plugin entry has no Version header (4 ms)
    ✓ exits non-zero (via thrown error) when plugin entry is missing (5 ms)
    ✓ rejects a malformed config.constantPrefix (6 ms)
    ✓ config.constantPrefix overrides the slug-derived constant name (8 ms)

 PASS  tests/release/cli.test.js
  release CLIs - dispatcher registration
    ✓ release:bump is registered
    ✓ release:changelog is registered
    ✓ release:zip is registered
  release CLIs - parseArgs
    ✓ release:bump parses --type, --to, --dry-run, --help
    ✓ release:bump rejects unknown args (6 ms)
    ✓ release:changelog parses --to, --dry-run, --help
    ✓ release:zip parses --force, --dry-run, --help (1 ms)
  release CLIs - runCli help
    ✓ release:bump --help prints usage and returns 0 (11 ms)
    ✓ release:changelog --help prints usage and returns 0 (7 ms)
    ✓ release:zip --help prints usage and returns 0 (1 ms)
    ✓ release:bump unknown arg returns 2
  release CLIs - runCli end-to-end against fixture
    ✓ release:bump --type patch updates files and returns 0 (7 ms)
    ✓ release:bump --dry-run does not modify files (6 ms)
    ✓ release:bump exits 1 when plugin entry missing (4 ms)
    ✓ release:changelog rewrites CHANGELOG and returns 0 (6 ms)
    ✓ release:changelog exits 1 on empty Unreleased (4 ms)
    ✓ release:zip writes dist/<slug>-<version>.zip and returns 0 (41 ms)
    ✓ release:zip refuses to overwrite without --force (110 ms)

 PASS  tests/release/context.test.js
  release/context
    ✓ findPluginEntry returns the *.php with Plugin Name header (3 ms)
    ✓ findPluginEntry throws when no entry file is present (9 ms)
    ✓ findPluginEntry throws when multiple entry files exist at root (6 ms)
    ✓ findPluginEntry ignores nested *.php with Plugin Name header (11 ms)
    ✓ loadContext returns the full project shape (4 ms)
    ✓ loadContext sets composerJson to null when composer.json is absent (4 ms)
    ✓ loadContext throws when package.json is missing (5 ms)
    ✓ loadContext throws when package.json has no version field (7 ms)
    ✓ loadContext propagates a malformed package.json error (5 ms)

 PASS  tests/init/identity.test.js
  validateName
    ✓ accepts a normal multi-word name
    ✓ rejects an empty name
    ✓ rejects a name starting with a digit
    ✓ rejects a PHP reserved keyword
  generateIdentity
    ✓ derives every case variant and WP convention from a name (1 ms)
    ✓ splits acronym boundaries
  buildIdentityReplacements
    ✓ covers the key case variants and prefixes (1 ms)
    ✓ covers the namespace in single- and double-backslash forms
    ✓ is de-duplicated by source token
    ✓ is sorted longest-source-first so specific tokens win (1 ms)

 PASS  tests/scaffolds/discover-from.test.js
  discover_from: composer.json:autoload.psr-4
    ✓ replaces the first segment of each default, preserving its sub-namespace (5 ms)
    ✓ does NOT override a path input (base_path keeps its default) (6 ms)
    ✓ falls back to default when composer.json is absent (backward compatible) (5 ms)
    ✓ falls back to default when composer.json is malformed (11 ms)
    ✓ lets a supplied value win over discovery (5 ms)
  discover_from: config:<key>
    ✓ resolves a value from .wp-tooling.json (3 ms)
    ✓ falls back to default when .wp-tooling.json is absent (5 ms)
  discover_from: package.json:<dot.path>
    ✓ resolves a string value via a dotted path (7 ms)

 PASS  tests/ui/selects.test.js
  checkbox (non-TTY)
    ✓ should return selected items by number (1 ms)
    ✓ should return unique selections in display order
    ✓ should handle empty input gracefully (1 ms)
  radio (non-TTY)
    ✓ should return a single selected item
    ✓ should default to first choice on invalid input
  flat select validation
    ✓ should throw when checkbox choices is missing (7 ms)
    ✓ should throw when radio choices is empty
  flat select (TTY) -- Ctrl+C
    ✓ should reject with CancelledError on Ctrl+C (1 ms)
  checkboxTree (non-TTY)
    ✓ should return selected items from groups by number
    ✓ should return unique selections in display order (1 ms)
    ✓ should keep pre-checked items when input is empty (keep model)
    ✓ should toggle items off and on from the pre-checked state
    ✓ should handle empty selection (1 ms)
    ✓ should throw when groups is missing (2 ms)
    ✓ should throw when groups is null
    ✓ should throw when a group items is not an array (1 ms)
    ✓ should return an empty array for empty groups without prompting
  checkboxTree (TTY)
    ✓ should resolve selections in display order, not toggle order
    ✓ should reject with CancelledError on Ctrl+C (1 ms)

 PASS  tests/version-monitor/updater.test.js
  applyUpdates
    ✓ rewrites each of the six source types in place (10 ms)
    ✓ skips major bumps unless allowMajor is set (5 ms)
    ✓ rewrites every occurrence of a duplicated scalar pin (3 ms)
    ✓ writes nothing on a dry run (4 ms)
    ✓ records an update whose current value is not found as unmatched (4 ms)

 PASS  tests/version-monitor/semver.test.js
  semver.splitVersion
    ✓ separates a range prefix from the numeric core (2 ms)
    ✓ separates a v tag prefix
    ✓ returns an empty core for non-numeric specs (2 ms)
  semver.parse
    ✓ fills missing minor/patch with zero
    ✓ captures the pre-release tag
  semver.compareStable / gt
    ✓ ranks by major, then minor, then patch (1 ms)
    ✓ ignores prefixes and pre-release tags
  semver.isMajorBump
    ✓ is true only when the major increases
  semver.isPreRelease
    ✓ detects pre-release suffixes
  semver.formatLatest
    ✓ re-attaches the current spec prefix to the latest core

 PASS  tests/release/changelog.test.js
  release/changelog - pure helpers
    ✓ isoDate formats UTC date as YYYY-MM-DD (1 ms)
    ✓ unreleasedHasContent detects bullets
    ✓ unreleasedHasContent detects plain paragraph content
    ✓ unreleasedHasContent rejects empty Unreleased
    ✓ unreleasedHasContent rejects subheadings without bullets (1 ms)
    ✓ rewriteChangelog renames heading + prepends fresh Unreleased
    ✓ rewriteChangelog throws when Unreleased missing (7 ms)
    ✓ rewriteChangelog throws when Unreleased is empty (1 ms)
  release/changelog - integration against fixture
    ✓ reads version from package.json by default (3 ms)
    ✓ --to overrides package.json version (11 ms)
    ✓ dryRun leaves CHANGELOG unchanged (7 ms)
    ✓ refuses to run when Unreleased is empty (8 ms)
    ✓ throws when CHANGELOG.md is missing (5 ms)
    ✓ throws on malformed semver passed via --to (11 ms)

version-monitor: actions lookup for "actions/checkout" failed: rate limited
 PASS  tests/version-monitor/actions.test.js
  actions.parseUses
    ✓ extracts version-tagged actions and skips non-tag refs
  actions detector
    ✓ reports an update when a newer release exists (2 ms)
    ✓ reports nothing when the pinned ref is already latest (1 ms)
    ✓ stops early on a rate-limit error (2 ms)

 PASS  tests/ci/detect-changes.test.js
  detectChanges
    ✓ counts files into the right buckets (1 ms)
    ✓ default ignore excludes docs and .wordpress-org
    ✓ default ignore preserves .github/workflows and .github/actions
    ✓ lockfile changes count under both css and js buckets
    ✓ phpstan.neon and phpstan.neon.dist count as php
    ✓ composer.json and composer.lock count as php (1 ms)
    ✓ string --ignore overrides the default
    ✓ RegExp --ignore is accepted directly
    ✓ null ignore disables filtering
    ✓ empty-string ignore disables filtering
    ✓ invalid ignore type throws TypeError (17 ms)
    ✓ accepts a newline-delimited string for files (1 ms)
    ✓ tolerates Windows line endings in file list
    ✓ invalid files type throws TypeError
    ✓ returns zero counts for an empty list
    ✓ gha bucket excludes nested-directory yml files outside workflows/actions (1 ms)
    ✓ gha bucket still matches nested composite-action paths
    ✓ gha bucket classifies a pathological path in linear time (1 ms)
    ✓ includeFiles adds <bucket>-files arrays alongside counts
    ✓ includeFiles omitted leaves the result counts-only (1 ms)
    ✓ includeFiles preserves the same file in multiple buckets
  exports
    ✓ DEFAULT_PATTERNS has the four expected buckets
    ✓ DEFAULT_IGNORE matches docs/, .wordpress-org/, and .github/ non-workflow paths
  runCli
    ✓ --help prints usage and exits 0 (1 ms)
    ✓ unknown flag exits 2 with stderr message
    ✓ invalid --output exits 2
    ✓ --files <path> with --output json prints valid JSON
    ✓ --output github appends key=value lines to $GITHUB_OUTPUT (1 ms)
    ✓ --output github warns to stderr when GITHUB_OUTPUT is unset (2 ms)
    ✓ --dry-run parses cleanly and exits 0 (1 ms)
    ✓ --dry-run + --output github does not touch $GITHUB_OUTPUT and previews to stdout (1 ms)
    ✓ --dry-run + --output github previews even when $GITHUB_OUTPUT is unset (1 ms)
    ✓ text mode prints key: value lines
    ✓ --ignore overrides default (1 ms)
    ✓ missing --files path exits 1 with stderr message
    ✓ --files followed by another flag exits 2 (does not swallow the flag)
    ✓ --files at end of argv exits 2
    ✓ --ignore followed by another flag exits 2
    ✓ --files - is accepted as the stdin sentinel (parser does not reject lone dash)
    ✓ --include-files in json mode emits <bucket>-files arrays (2 ms)
    ✓ --include-files in text mode prints space-joined paths (1 ms)
    ✓ --include-files in github mode writes heredoc multi-line outputs (2 ms)
    ✓ --include-files dry-run previews heredoc blocks without writing (1 ms)
    ✓ invalid --ignore regex exits 2 with a clean usage error

 PASS  tests/skills/evals-json.test.js
  skills/scaffold/evals/evals.json
    ✓ file exists
    ✓ parses as JSON (1 ms)
    ✓ skill_name matches the directory name (1 ms)
    ✓ evals[] is a non-empty array
    ✓ each eval has id, prompt, expected_output, files, expectations (3 ms)
    ✓ eval ids are unique within the skill
  skills/setup/evals/evals.json
    ✓ file exists
    ✓ parses as JSON (1 ms)
    ✓ skill_name matches the directory name
    ✓ evals[] is a non-empty array
    ✓ each eval has id, prompt, expected_output, files, expectations (7 ms)
    ✓ eval ids are unique within the skill (1 ms)

 PASS  tests/version-monitor/cli.test.js
  runCli arg handling
    ✓ prints usage and exits 0 on --help (1 ms)
    ✓ exits 2 when no mode is given
    ✓ exits 2 on mutually exclusive modes
    ✓ exits 2 on an unknown argument (1 ms)
  runCli --detect
    ✓ loads config, runs detect, prints JSON (1 ms)
    ✓ exits 2 when the config is missing (1 ms)
    ✓ exits 1 but still prints results when a detector errored
  runCli --report / --apply (stdin)
    ✓ reads JSON from stdin and prints markdown for --report
    ✓ reads JSON from stdin and applies for --apply (1 ms)
    ✓ exits 2 when stdin is not valid JSON

 PASS  tests/version-monitor/wp-cli.test.js
  wp-cli detector
    ✓ reports an update when a newer release exists (1 ms)
    ✓ reports nothing when already on the latest release (1 ms)
    ✓ makes no request when no wp-cli version is pinned

 PASS  tests/version-monitor/http.test.js
  getJson
    ✓ resolves parsed JSON on a 2xx response (2 ms)
    ✓ sends a User-Agent and a bearer token when given one (1 ms)
    ✓ rejects on a non-2xx status (10 ms)
    ✓ flags rate-limit responses
    ✓ rejects on invalid JSON
    ✓ rejects on a transport error (1 ms)
    ✓ rejects and tears down the request on timeout
  isClientError
    ✓ is true for a non-rate-limit 4xx
    ✓ is false for rate limits, 5xx, and transport errors

 PASS  tests/init/transform.test.js
  applyReplacements
    ✓ replaces every token in one pass (19 ms)
    ✓ does not re-scan substituted text (no cascade)
    ✓ prefers the longest source token at each position
    ✓ returns the input unchanged with no replacements
  resolveWithin
    ✓ resolves a relative path inside the root
    ✓ allows the root itself
    ✓ rejects a parent-directory escape (13 ms)
    ✓ rejects an absolute path outside the root (1 ms)
  applyVersion
    ✓ stamps the version into json and php-header files (2 ms)

version-monitor: npm lookup for "lodash" failed: network down
version-monitor: npm lookup for "jest" failed: network down
version-monitor: npm lookup for "lodash" failed: rate limited
version-monitor: npm lookup for "lodash" failed: not found
version-monitor: npm lookup for "jest" failed: not found
 PASS  tests/version-monitor/detect.test.js
  detect orchestrator
    ✓ annotates each update with is_major (2 ms)
    ✓ runs no detector when every source is disabled
    ✓ records hard failures so the run is not silently empty (1 ms)
    ✓ treats a rate limit as soft (no recorded error)
    ✓ does not record an expected 404 as a hard failure (1 ms)

 PASS  tests/version-monitor/node.test.js
  node detector
    ✓ bumps .nvmrc to the newest LTS in the same major
    ✓ preserves the range prefix in package.json engines (1 ms)
    ✓ reports nothing when already on the newest LTS

 PASS  tests/ui/wizard.test.js
  Wizard
    ✓ should run all steps in order (1 ms)
    ✓ should honour the skip() predicate
    ✓ should pass context to skip()
    ✓ should default context to empty object
    ✓ should default steps to empty array
    ✓ should handle an empty steps array
    ✓ should throw a clear error when steps is not an array (3 ms)
    ✓ should omit ANSI formatting in non-TTY mode
    ✓ should propagate step errors

 PASS  tests/version-monitor/container.test.js
  container helpers
    ✓ splits image references and skips digest pins
    ✓ maps official images to library/* and rejects other registries
    ✓ picks the newest tag of the same numeric shape (1 ms)
    ✓ collects images past --platform flags and across multi-stage FROMs
  container detector
    ✓ reports updates for Dockerfile and devcontainer images (2 ms)
    ✓ reports nothing when the current tag is newest (1 ms)

 PASS  tests/ui/prompts.test.js
  text
    ✓ should return the user input (1 ms)
    ✓ should return defaultValue when input is empty
    ✓ should trim whitespace from input (1 ms)
    ✓ should retry when validation fails
    ✓ should accept a string message shortcut (1 ms)
    ✓ should propagate CancelledError from readLine (3 ms)
  confirm
    ✓ should return true for "y"
    ✓ should return true for "yes"
    ✓ should return false for "n"
    ✓ should return defaultValue on empty input
    ✓ should default to false when no defaultValue
    ✓ should accept a string message shortcut (1 ms)
    ✓ should propagate CancelledError from readLine (1 ms)
  password
    ✓ should fall back to readLine in non-TTY
    ✓ should accept a string message shortcut
    ✓ should reject with CancelledError on Ctrl+C (2 ms)

 PASS  tests/scaffolds/features-install.test.js
  installDeps
    ✓ runs npm without a shell, passing each spec as a literal argv element (9 ms)
    ✓ raises EINSTALLFAIL with npm stderr attached on failure (4 ms)

 PASS  tests/scaffolds/cache.test.js
  clear
    ✓ removes a populated cache directory (2 ms)
    ✓ is a no-op on a missing directory (4 ms)
  parseArgs
    ✓ extracts the subaction (1 ms)
    ✓ extracts --cache-dir <path>
    ✓ extracts --cache-dir=<path>
    ✓ extracts --dry-run
    ✓ extracts --help
    ✓ throws on unexpected positional after the action (3 ms)
  runCli
    ✓ clear --cache-dir succeeds on a populated dir (1 ms)
    ✓ clear --dry-run reports without removing the dir (2 ms)
    ✓ --help returns 0 and prints usage (1 ms)
    ✓ no subaction returns 1 and prints usage
    ✓ unknown subaction returns 1 and writes to stderr (1 ms)

 PASS  tests/scaffolds/sources.test.js
  parseId
    ✓ splits category/slug
    ✓ handles nested category
    ✓ handles slug with no category
  readSources
    ✓ returns null when the file is absent (1 ms)
    ✓ reads + parses the fixture sources (4 ms)
    ✓ throws EBADSCAFFOLD on invalid JSON (2 ms)
  validateSources
    ✓ accepts a valid sources file (1 ms)
    ✓ accepts the fixture
    ✓ rejects a non-object
    ✓ rejects a missing sources array
    ✓ rejects an unknown top-level field
    ✓ rejects a source missing required fields
    ✓ rejects a bad repository slug
    ✓ rejects an unknown source field
    ✓ flags a duplicate repository + path source
    ✓ same repo + path but different ref is not a duplicate (1 ms)
  validateIndex
    ✓ accepts a valid index
    ✓ accepts an optional checksum
    ✓ rejects a non-object
    ✓ rejects a missing scaffolds array
    ✓ rejects an entry missing required fields
    ✓ rejects a bad slug in id
    ✓ rejects an unknown entry field
    ✓ rejects duplicate ids
  indexEntryToRecord
    ✓ builds a thin remote record with the resolved scaffold path
    ✓ normalises slashes when joining source.path + entry.path

 PASS  tests/version-monitor/reporter.test.js
  report
    ✓ groups updates by source with a heading and a summary (15 ms)
    ✓ adds a Notes column only to groups containing a major bump
    ✓ uses singular wording for a single update
    ✓ reports no updates cleanly

 PASS  tests/ui/terminal.test.js
  ANSI
    ✓ should expose the expected escape sequences (3 ms)
  isTTY
    ✓ should return true when stdout is a TTY
    ✓ should return false when stdout is not a TTY
  write / writeLine
    ✓ should write text without appending a newline
    ✓ should append a newline when writeLine is called
    ✓ should default writeLine to an empty line (4 ms)
  clearLine
    ✓ should write clear-line + carriage return when stdout is a TTY
    ✓ should write nothing when stdout is not a TTY
  moveCursorUp
    ✓ should emit the move-up escape when n > 0 in a TTY
    ✓ should not write anything when n is 0
    ✓ should not write anything in non-TTY
  hideCursor / showCursor
    ✓ should write the hide/show sequences in a TTY
    ✓ should write nothing in non-TTY (1 ms)
  readLine -- TTY mode
    ✓ should resolve with the typed answer
    ✓ should reject with CancelledError on SIGINT
    ✓ should resolve empty when closed without an answer or cancellation
  readLine -- non-TTY mode
    ✓ should buffer lines from stdin and resolve in order (1 ms)
    ✓ should reject queued waiters as cancelled when stdin closes
    ✓ should reject immediately as cancelled after stdin has closed
    ✓ should buffer lines that arrive before a reader is waiting
  onKeypress
    ✓ should return a no-op cleanup in non-TTY (1 ms)
    ✓ should wire up keypress events on stdin in a TTY and clean them up
    ✓ should skip setRawMode when stdin does not support it

 PASS  tests/ui/spinner.test.js
  spinner
    ✓ should return an object with start, succeed, fail, update methods
    ✓ should default to current text when succeed is called without args
    non-TTY mode
      ✓ should print plain text on start in non-TTY (1 ms)
      ✓ should print succeed message in non-TTY (1 ms)
      ✓ should print fail message in non-TTY
    TTY mode
      ✓ should animate frames on an interval (2 ms)
      ✓ should not create multiple intervals when start is called twice (1 ms)
      ✓ should stop animation on succeed (1 ms)
      ✓ should stop animation on fail
      ✓ should update text while running

 PASS  tests/scaffolds/traversal.test.js
  execute() refuses paths outside --cwd
    ✓ rejects a `..` in a path input (3 ms)
    ✓ rejects a `..` in a manifest file dest (4 ms)
    ✓ rejects a `..` in a manifest test dest (2 ms)
    ✓ still allows nested paths inside the target directory (8 ms)
  disable() refuses removals outside --cwd
    ✓ rejects a tampered featureFiles entry pointing outside the project (6 ms)

 PASS  tests/scaffolds/render.test.js
  render
    ✓ substitutes a single placeholder
    ✓ substitutes multiple placeholders
    ✓ substitutes the same placeholder repeatedly (1 ms)
    ✓ accepts whitespace inside braces
    ✓ throws ERENDERFAIL on undefined placeholder (5 ms)
    ✓ does NOT HTML-escape (critical for code generation)
    ✓ treats null vars argument as empty
    ✓ passes through templates with no placeholders
    ✓ throws on non-string template
  collectPlaceholders
    ✓ lists unique placeholders in first-appearance order
    ✓ returns empty array for templates with no placeholders
    ✓ returns empty for non-string input
    ✓ includes section flags and inverted sections
  render — sections
    ✓ keeps inner content when section flag is truthy ("true") (1 ms)
    ✓ drops inner content when section flag is falsy ("false")
    ✓ treats empty string, "no", "0" as falsy
    ✓ treats arbitrary non-empty value as truthy
    ✓ inverted section renders when flag is falsy
    ✓ substitutes variables inside a truthy section
    ✓ throws ERENDERFAIL when section flag is undefined
    ✓ handles multi-line section content (singleton use-case)
    ✓ tolerates whitespace inside section tags
    ✓ leaves an unclosed section tag verbatim
    ✓ resolves a template of unclosed section tags in linear time (9 ms)
  applyTransform
    ✓ pascal-case from kebab
    ✓ pascal-case from snake
    ✓ kebab-case from PascalCase
    ✓ snake-case from kebab
    ✓ upper-snake-case from kebab
    ✓ json-escape doubles backslashes
    ✓ returns value unchanged when no transform
    ✓ returns value unchanged for unknown transform

 PASS  tests/ui/report.test.js
  report helpers
    ✓ prints a success line with a + marker
    ✓ prints warn and error lines with their markers
    ✓ prints a heading with a leading blank line
    ✓ prints an indented info line
    ✓ renders a key/value table with a title and box borders (1 ms)
    ✓ skips empty tables

 PASS  tests/scaffolds/config.test.js
  detectIndent()
    ✓ detects tab indentation (1 ms)
    ✓ detects two-space indentation
    ✓ returns null for single-line / empty input
  writeConfig() indentation
    ✓ preserves tabs when the file was written with tabs (1 ms)
    ✓ defaults to two spaces for a new file
  feature-file persistence
    ✓ setFeatureState sets the flag and round-trips file lists in one write (1 ms)
    ✓ setFeatureState preserves an existing file indentation
    ✓ returns null when nothing was persisted
    ✓ clearFeatureFiles removes the entry and an empty block (1 ms)

 PASS  tests/ui/style.test.js
  style
    ✓ wraps text in ANSI codes when stdout is a TTY (5 ms)
    ✓ returns plain text when stdout is not a TTY (no ANSI leaks into logs) (1 ms)
    ✓ exposes the full set of semantic helpers

 PASS  tests/version-monitor/php.test.js
  php detector
    ✓ targets the latest stable at the current granularity (3 ms)
    ✓ reports nothing when already on the latest branch (1 ms)
    ✓ skips compound constraints
    ✓ ignores pre-release releases when picking the latest

 PASS  tests/version-monitor/config.test.js
  parseConfigYaml
    ✓ parses inline-flow mappings and lists (12 ms)
    ✓ ignores comments and blank lines
  loadConfig
    ✓ loads and normalises the sample config (1 ms)
    ✓ throws naming the path when the config is missing (4 ms)
  validate
    ✓ defaults paths for an enabled source that omits them
    ✓ rejects an unknown source
    ✓ rejects a non-string paths entry
    ✓ rejects a missing sources mapping

 PASS  tests/version-monitor/npm.test.js
  npm detector
    ✓ reports an update when a newer version is published (1 ms)
    ✓ reports nothing when current equals latest (1 ms)
    ✓ skips floating pins like "*" (2 ms)
    ✓ skips pre-release publishes
    ✓ percent-encodes the slash in a scoped package name (4 ms)

 PASS  tests/init/examples.test.js
  stripRegions
    ✓ drops only the marker lines when keeping the body
    ✓ drops the markers and the enclosed body when removing
    ✓ only touches regions tagged with the given marker
    ✓ accepts a punctuated marker when keeping the body (1 ms)
    ✓ accepts a punctuated marker when removing the body
    ✓ accepts "!" as a terminator too
    ✓ accepts "?" as a terminator too
    ✓ treats punctuated and bare markers as the same region
    ✓ does not let the terminator swallow a longer marker
  expandGlob
    ✓ expands a single-segment * glob to matching absolute paths (3 ms)
    ✓ returns nothing when no file matches (1 ms)

 PASS  tests/scaffolds/prompt-inputs.test.js
  promptMissingInputs
    ✓ passes `defaultValue` (not `default`) to text() (1 ms)
    ✓ omits defaultValue when scaffold input has no default
  confirmRun
    ✓ passes `defaultValue: true` (not `default: true`) to confirm()

 PASS  tests/hooks/install.test.js
  installHooks
    ✓ installs both hooks with the executable bit set (50 ms)
    ✓ injects the version header right after the shebang (29 ms)
    ✓ skips when a hook already exists, prints the right reason (74 ms)
    ✓ detects Husky-managed hooks and tags the skip reason (55 ms)
    ✓ --force overwrites existing hooks (73 ms)
    ✓ --dry-run plans without touching the filesystem (76 ms)
    ✓ throws a clear error outside a git repository (39 ms)
    ✓ resolves the hooks dir when .git is a file (separate git dir) (97 ms)
    ✓ onConflict callback receives the conflict info per template (73 ms)
    ✓ onConflict returning true overwrites the existing hook (133 ms)
    ✓ --force bypasses the onConflict callback entirely (88 ms)
    ✓ --dry-run reports skips for conflicts without consulting onConflict (78 ms)
    ✓ onBeforeWrite / onAfterWrite fire around each successful install (72 ms)
  injectVersionHeader
    ✓ inserts the header between the shebang and the body
    ✓ returns the body unchanged when there is no newline
  looksLikeHusky
    ✓ matches the `.husky/` directory marker
    ✓ matches the husky.sh sourcing marker
    ✓ returns false for arbitrary hook scripts (2 ms)
  install-hooks runCli
    ✓ --help exits 0 and prints usage (1 ms)
    ✓ -h exits 0 and prints usage
    ✓ unknown flag exits 2 with a stderr message
    ✓ runs end-to-end inside a git repo, prints `installed` lines (23 ms)
    ✓ --dry-run prints planned actions, writes nothing (26 ms)
    ✓ outside a git repo exits 1 with a clear stderr message (7 ms)
    ✓ non-TTY conflict skips without prompting (CI-safe) (29 ms)

 PASS  tests/hooks/templates.test.js
  commit-msg template
    ✓ accepts "feat: add Logger" (5 ms)
    ✓ accepts "fix: handle empty CHANGELOG"
    ✓ accepts "docs: update README" (1 ms)
    ✓ accepts "feat(utilities): add Logger"
    ✓ accepts "fix(release): handle empty CHANGELOG" (1 ms)
    ✓ accepts "feat(ui)!: breaking change to wizard"
    ✓ accepts "chore(deps): bump eslint"
    ✓ accepts "ci(detect-changes): cover edge case"
    ✓ accepts "refactor(scaffolds): inline registry scan"
    ✓ accepts "revert: feat(ui): add wizard"
    ✓ accepts "revert(release): restore prior tag" (1 ms)
    ✓ rejects "wip"
    ✓ rejects "WIP: x"
    ✓ rejects "feat:"
    ✓ rejects "feat: "
    ✓ rejects "feat(): no scope chars allowed empty"
    ✓ rejects "random commit message"
    ✓ rejects "Feat: capitalised type"
    ✓ rejects "feature: not in type list"
    ✓ shell bypasses cover merge / revert / fixup / squash (1 ms)
  commit-msg template (end-to-end against /bin/sh)
    ✓ exits 0 for a valid Conventional Commit subject (15 ms)
    ✓ exits 1 for an invalid subject and prints guidance (12 ms)
    ✓ exits 0 for merge commits (10 ms)
    ✓ exits 0 for revert commits (42 ms)
    ✓ exits 0 for fixup! commits (10 ms)
  pre-commit template
    ✓ no-ops when package.json is absent (7 ms)
    ✓ no-ops when package.json has no lint:staged script (143 ms)
    ✓ does not false-positive when "lint:staged" appears only as a string (160 ms)
    ✓ runs the lint:staged script when defined (308 ms)
    ✓ propagates a non-zero exit from the lint:staged script (206 ms)
  shellcheck (optional)
    ✓ commit-msg passes shellcheck -s sh (60 ms)
    ✓ pre-commit passes shellcheck -s sh (22 ms)

Test Suites: 50 passed, 50 total
Tests:       702 passed, 702 total
Snapshots:   0 total
Time:        1.375 s, estimated 2 s
Ran all test suites.



❯ npx wp-tooling add utility/cache --non-interactive --json --dry-run --base_path=inc
{"scaffold":{"id":"utility/cache","slug":"cache","kind":"package","dryRun":true},"engine":{"wrote":[],"skipped":[],"inputs":{"base_path":"inc","context_slug":"rtcamp_project_name_features"}},"developer":{"install":{"composer":{"rtcamp/wp-framework":"^1.0"},"composerDev":{},"composerSuggest":{},"npm":{},"npmDev":{}},"scripts":{"npm":{},"composer":{}},"secrets":[]},"ai":{"wiring":[{"targetFile":"inc/Helpers/Util.php","anchor":"// scaffold:utility/cache","snippet":"/**\n * The project's shared framework Cache, namespaced so its groups can never\n * collide with another consumer's.\n *\n * @return \\rtCamp\\WPFramework\\Utils\\Cache Shared cache.\n */\npublic static function cache(): \\rtCamp\\WPFramework\\Utils\\Cache {\n\tstatic $instance = null;\n\n\tif ( null === $instance ) {\n\t\t$instance = new \\rtCamp\\WPFramework\\Utils\\Cache( 'rtcamp_project_name_features' );\n\t}\n\n\treturn $instance;\n}","description":"Add a shared accessor for the framework Cache to the project's static helper class. The snippet is self-contained: paste it inside the existing helper class body and Util::cache()->remember( ... ) works with nothing further to wire. Do not add the framework Cache to a Modules class or to Main::CLASSES — it implements neither Registrable nor Shareable, so the framework Loader would construct it with an empty context. If the project instead shares services through the framework Shareable contract (a Core\\Cache class extending the framework Cache, listed in Main::CLASSES, the way the rtCamp skeleton does for Core\\Logger), translate the snippet to that pattern and add a one-line accessor there. If only one class needs the cache, skip this wiring and construct it inline in that class's constructor. Requires rtcamp/wp-framework to be installed first."}],"tests":[]},"warnings":[]}

Acceptance criteria

Runtime behavior

  • wp-tooling add utility/cache (and transients, logger, timer, feature-selector) writes zero files and returns the composer dependency plus the boot wiring in the four-block result, with scaffold.kind: "package".
  • Each manifest validates via wp-tooling validate; --dry-run returns the expected wiring.
  • No secret values and no package-manager execution.
  • npm test green including the new scaffold tests.

Code quality

  • Lint — zero errors
  • Static analysis — n/a (JS-only change; no PHP shipped, PHPCS run manually against the smoke-tested snippet — see above)
  • Tests pass — 702/702

Housekeeping

  • CHANGELOG.md entry under ## Unreleased

Reviewer notes

  • Deliberate deviation from the issue's implementation plan: the plan sketches wiring "in the consumer's Main/module," but these five classes implement neither Registrable nor Shareable, and the framework Loader instantiates listed classes with new $class_name() — no constructor argument (inc/Contracts/Traits/Loader.php:54). Proved the failure mode directly against the installed framework: new Transients() → ArgumentCountError; new Cache() → silently empty $context. Wiring instead targets a static accessor on Helpers/Util.php, matching that file's own documented pattern.

Ship utility/{cache,transients,logger,timer,feature-selector} as
source: "package" — zero files written, rtcamp/wp-framework: ^1.0 reported
under developer.install.composer, and one accessor snippet for
<base_path>/Helpers/Util.php under anchor // scaffold:utility/<slug>.
context_slug is discovered from composer.json:name and snake-cased.

Wiring targets the consumer's static helper rather than a module or
Main::CLASSES: these utilities implement neither Registrable nor Shareable,
and the framework Loader instantiates with no constructor argument, so
Transients would fatal and Cache would lose its namespace.

Refs rtCamp/wp-devtools#26
@Adi-ty
Adi-ty marked this pull request as ready for review August 5, 2026 11:17
Copilot AI balanced review requested due to automatic review settings August 5, 2026 11:17

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.

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

Adds utility/* package scaffolds that wire rtcamp/wp-framework Utils\* classes via a single accessor snippet (no file generation), and documents/tests the new behavior.

Changes:

  • Added five source: "package" utility scaffolds (cache, transients, logger, timer, feature-selector) that report a Composer dep plus one Helpers/Util.php wiring snippet.
  • Expanded Jest coverage to assert package-scaffold behavior (zero writes, dep reporting, wiring render, input discovery/overrides).
  • Updated skills + docs to describe package scaffolds and the Helpers/Util.php anchor convention.

Reviewed changes

Copilot reviewed 14 out of 14 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js Adds assertions covering the new utility/* package scaffolds and input discovery behavior.
node-packages/wp-tooling/skills/setup/SKILL.md Updates feature→scaffold mapping and adds guidance for applying utility/* wiring safely.
node-packages/wp-tooling/skills/scaffold/SKILL.md Documents the utility/* anchor convention and cautions against wiring Utils\* via modules/Main::CLASSES.
node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json New package scaffold manifest for framework Cache accessor wiring.
node-packages/wp-tooling/scaffolds/utility/transients/scaffold.json New package scaffold manifest for framework Transients accessor wiring.
node-packages/wp-tooling/scaffolds/utility/logger/scaffold.json New package scaffold manifest for framework Logger accessor wiring.
node-packages/wp-tooling/scaffolds/utility/timer/scaffold.json New package scaffold manifest for framework Timer accessor wiring (no ctor args).
node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json New package scaffold manifest for framework FeatureSelector accessor wiring.
node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md Mirrors skills/setup/SKILL.md changes in the template.
node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/scaffold-SKILL.md Mirrors skills/scaffold/SKILL.md changes in the template.
node-packages/wp-tooling/docs/wp-framework-contract.md Documents Utils\* classes and why utility/* wires via Helpers/Util.php.
node-packages/wp-tooling/docs/authoring-scaffolds.md Documents source: "package" behavior and constraints.
node-packages/wp-tooling/docs/ai-orchestration.md Clarifies scaffold.kind: "package" semantics with utility/* as examples.
node-packages/wp-tooling/CHANGELOG.md Adds an Unreleased entry for the new utility/* package scaffolds.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread node-packages/wp-tooling/scaffolds/utility/cache/scaffold.json Outdated
Comment thread node-packages/wp-tooling/scaffolds/utility/feature-selector/scaffold.json Outdated
Comment thread node-packages/wp-tooling/skills/setup/SKILL.md Outdated
Copilot AI review requested due to automatic review settings August 5, 2026 12: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.

Pull request overview

Copilot reviewed 14 out of 14 changed files in this pull request and generated no new comments.

Suppressed comments (2)

node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js:145

  • The PR description says the suite asserts there are “no leftover {{” after rendering, but the parameterized utility test currently checks that only for w.description, not for w.snippet. Add an assertion like expect(w.snippet).not.toContain('{{'); (and/or check for '}}') to ensure rendered snippets never leak unresolved placeholders.
			expect(w.snippet).toContain(
				`\\rtCamp\\WPFramework\\Utils\\${className}`
			);
			// The engine passes `description` through verbatim, so it must not
			// carry a placeholder that would reach the caller unresolved.
			expect(w.description).not.toContain('{{');
		}

node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js:113

  • These tests create multiple temporary directories via makeTmpDir() and never remove them, which can accumulate over repeated local runs/CI retries. Consider tracking created temp dirs and cleaning them in afterEach/afterAll, or wrapping each usage in a try/finally that removes the directory (recursive) once the test completes.
function targetWithComposerName(name = 'rtcamp/project-name-features') {
	const target = makeTmpDir();
	fs.writeFileSync(
		path.join(target, 'composer.json'),
		JSON.stringify({ name }),
		'utf8'
	);
	return target;
}

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