From 722f24395c72b2a24d569e1485085d441e017157 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 22 Oct 2026 02:31:54 +0530 Subject: [PATCH 1/3] fix(scaffolds): list only existing PHP paths in the lint/phpstan config --- node-packages/wp-tooling/CHANGELOG.md | 8 ++ .../wp-tooling/docs/ai-orchestration.md | 4 +- .../wp-tooling/docs/authoring-scaffolds.md | 2 + .../scaffolds/lint/phpstan/scaffold.json | 62 ++++++++++ .../templates/phpstan.neon.dist.mustache | 27 ++-- .../wp-tooling/src/scaffolds/registry.js | 61 +++++++--- .../wp-tooling/src/scaffolds/validate.js | 2 + .../tests/scaffolds/bundled-manifests.test.js | 71 +++++++++++ .../tests/scaffolds/discover-sources.test.js | 115 ++++++++++++++++++ 9 files changed, 324 insertions(+), 28 deletions(-) diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 2486894..2ceb7c9 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added + +- `discover_from` sources `entry-file:` and `exists:`. `entry-file:plugin` resolves to the main plugin file's name and `entry-file:theme` to `style.css`, each only for that type of project. `exists:` resolves to the path as written when it exists inside the project, with a trailing `/` matching only a directory; an absolute path or one that climbs out with `..` never resolves. + +### Fixed + +- `lint/phpstan`: the generated `phpstan.neon.dist` lists the project's own PHP as found at scaffold time instead of a hardcoded `src` and `includes`. That is the main plugin file, a theme's `functions.php`, `uninstall.php`, and whichever of `framework/`, `inc/`, `includes/`, `patterns/`, `src/`, `template-parts/` and `templates/` exist. PHPStan stops with `Path … does not exist` on a missing `paths` entry, and `(?)` only works in `excludePaths`, so `composer analyse` failed in every project without both directories, including the rtCamp plugin and theme skeletons, which use `inc/`. The `excludePaths` block is gone: it only filters files inside `paths`, and none of its entries were. + ## [2.0.0] - 2026-10-01 ### Added diff --git a/node-packages/wp-tooling/docs/ai-orchestration.md b/node-packages/wp-tooling/docs/ai-orchestration.md index 2fa1dfa..2dc240f 100644 --- a/node-packages/wp-tooling/docs/ai-orchestration.md +++ b/node-packages/wp-tooling/docs/ai-orchestration.md @@ -60,7 +60,7 @@ The engine commits to the following on a successful run. Skills can rely on thes - `scaffold.kind` is `"package"` for `source: "package"` scaffolds (no files written, only deps and wiring; the bundled examples are `utility/*`) and `"template"` otherwise. Remote (sources) scaffolds also report `kind: "template"` — they render Mustache the same way as local ones; where the scaffold lives is an implementation detail the orchestrator does not need to branch on. Callers branch on `kind` rather than checking `engine.wrote.length === 0`. - `wp-tooling list --json` entries carry an `origin` of `"default"`, `"project"`, or `"remote"`. Remote scaffolds come from a repo's cached index (`sources.json` → each repo's `index.json`), so their `counts` is `null` (unknown until `add`); local scaffolds carry real `counts`. `list` is online-preferred with a cache fallback: it reads the index (cached, ETag-validated), and a `warnings` array reports any source that was unreachable and uncached. The top-level `{ scaffolds, warnings }` shape carries those notes. - The engine core has zero dependency on the TTY UI kit. AI orchestration mode never loads any terminal-UI primitive. Skills can rely on the engine being usable from any context, including non-TTY containers, CI runners, and headless test harnesses. -- File-based `discover_from` (`composer.json:` including `autoload.psr-4` and `autoload-dev.psr-4`, `package.json:`, `plugin-header:` from the plugin or theme entry header, `config:` from `.wp-tooling.json`) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` sources are **not** engine-resolved and remain the skill's responsibility (§6). +- File-based `discover_from` (`composer.json:` including `autoload.psr-4` and `autoload-dev.psr-4`, `package.json:`, `plugin-header:` from the plugin or theme entry header, `config:` from `.wp-tooling.json`, `entry-file:` for the entry file's name, `exists:` for a path the project has) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` sources are **not** engine-resolved and remain the skill's responsibility (§6). - A scaffold may carry an optional `feature` block, marking it a **toggleable feature** (the bundled example is `setup/tailwind`). This block is **invisible to `add`/`execute`**: it never appears in the result, and `add` behaves identically whether or not it is present. Turning features on and off is a separate surface (§13). ## 4. Skill responsibilities @@ -222,7 +222,7 @@ The skill discovers project shape from existing artifacts. No config file is req The engine now resolves the **file-based** `discover_from` sources itself, so the division of labour is: -- **`composer.json:`, `package.json:`, `plugin-header:`, `config:` → the engine resolves these.** The skill does not need to pre-read these files and pass the value; the engine reads them from `--cwd` and fills the input before applying the `default`. The most important case: `namespace` and `tests_namespace` on every `wp/*` scaffold declare `discover_from: composer.json:autoload.psr-4`, so they are **auto-filled from the project's first PSR-4 root** whenever a `composer.json` exists, with the kind sub-namespace preserved: the discovered root replaces only the *first segment* of the manifest default, so a default of `Inc\Cli` and a PSR-4 root of `Acme\Blog` yield `Acme\Blog\Cli` (and `Inc\Tests\Cli` yields `Acme\Blog\Tests\Cli`). A skill that previously always passed `--namespace` no longer has to, though doing so is still safe (see precedence below). +- **`composer.json:`, `package.json:`, `plugin-header:`, `config:`, `entry-file:`, `exists:` → the engine resolves these.** The skill does not need to pre-read these files and pass the value; the engine reads them from `--cwd` and fills the input before applying the `default`. The most important case: `namespace` and `tests_namespace` on every `wp/*` scaffold declare `discover_from: composer.json:autoload.psr-4`, so they are **auto-filled from the project's first PSR-4 root** whenever a `composer.json` exists, with the kind sub-namespace preserved: the discovered root replaces only the *first segment* of the manifest default, so a default of `Inc\Cli` and a PSR-4 root of `Acme\Blog` yield `Acme\Blog\Cli` (and `Inc\Tests\Cli` yields `Acme\Blog\Tests\Cli`). A skill that previously always passed `--namespace` no longer has to, though doing so is still safe (see precedence below). - **`code:*` → still the skill's job.** The engine cannot read a bootstrap class or sample existing patterns. For these the engine returns the manifest `default`; the skill must do the introspection in this section and pass the value in. Rules the skill can rely on: diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index 04b48fa..e791405 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -142,6 +142,8 @@ exactly as if `discover_from` were not set). - `composer.json:autoload-dev.psr-4` — the same graft against the **dev** autoload map, which is where a project declares its test tree. Use it for `tests_path`: a dev map of `Acme\Blog\Tests\` → `tests/php/` turns a default of `tests/Cli` into `tests/php/Cli`, so the generated test lands where the project's PHPUnit testsuite actually looks. Without it a test is written to the manifest default and is silently never collected. - `plugin-header:` — a value from the project's WordPress entry header, named lowercased with spaces hyphenated: `plugin-header:text-domain` reads `Text Domain:`. The engine looks for a root-level `*.php` carrying `Plugin Name:` and falls back to a theme's `style.css` carrying `Theme Name:`, so this resolves for plugins and themes alike. Only the header comment is scanned, so a `Foo: bar` in the code below is never mistaken for a header. - `config:` — a string value from the project's `.wp-tooling.json` (e.g. `config:textDomain`). +- `entry-file:` — the name of the project's entry file when the project is that type: `entry-file:plugin` is the main plugin file (the root-level `*.php` carrying `Plugin Name:`), `entry-file:theme` is `style.css`. A theme resolves nothing for `entry-file:plugin`, and the reverse. +- `exists:` — the path itself, exactly as written, when it exists inside the project; a trailing `/` matches only a directory. Pair it with an empty `default` and a `{{#key}}` section to render a line only for what the project has: `lint/phpstan` lists `inc/`, `src/` and the rest this way, because PHPStan stops on a listed path that does not exist. An absolute path or one that climbs out with `..` never resolves. `validate` rejects a `discover_from` whose prefix is not one of the above. An unrecognised source used to resolve to nothing and fall through to the `default` in silence, which is how diff --git a/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json index e947b8b..451acc7 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json @@ -4,6 +4,68 @@ "name": "PHPStan setup", "description": "Installs PHPStan with rtCamp's shared baseline (includes `vendor/rtcamp/wp-phpstan/phpstan.neon.dist` alongside szepeviktor/phpstan-wordpress) and adds composer analyse scripts.", "source": "template", + "inputs": [ + { + "key": "plugin_file", + "description": "Main plugin file, analysed with the rest of the project. Found by its Plugin Name header; left out for a theme.", + "discover_from": "entry-file:plugin", + "default": "" + }, + { + "key": "functions_file", + "description": "A theme's `functions.php`, analysed when the project has it.", + "discover_from": "exists:functions.php", + "default": "" + }, + { + "key": "uninstall_file", + "description": "`uninstall.php`, analysed when the project has it.", + "discover_from": "exists:uninstall.php", + "default": "" + }, + { + "key": "framework_dir", + "description": "`framework/`, analysed when the project has it.", + "discover_from": "exists:framework/", + "default": "" + }, + { + "key": "inc_dir", + "description": "`inc/`, analysed when the project has it.", + "discover_from": "exists:inc/", + "default": "" + }, + { + "key": "includes_dir", + "description": "`includes/`, analysed when the project has it.", + "discover_from": "exists:includes/", + "default": "" + }, + { + "key": "patterns_dir", + "description": "A theme's `patterns/`, analysed when the project has it.", + "discover_from": "exists:patterns/", + "default": "" + }, + { + "key": "src_dir", + "description": "`src/`, analysed when the project has it.", + "discover_from": "exists:src/", + "default": "" + }, + { + "key": "template_parts_dir", + "description": "A theme's `template-parts/`, analysed when the project has it.", + "discover_from": "exists:template-parts/", + "default": "" + }, + { + "key": "templates_dir", + "description": "`templates/`, analysed when the project has it.", + "discover_from": "exists:templates/", + "default": "" + } + ], "files": [ { "src": "templates/phpstan.neon.dist.mustache", diff --git a/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache b/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache index 4d55c5a..74a3dc1 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache +++ b/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache @@ -1,17 +1,20 @@ -# PHPStan config — extends rtCamp's shared baseline (rtcamp/wp-phpstan). -# Scaffolded by @rtcamp/wp-tooling. Add project-specific overrides under `parameters`. +# https://phpstan.org/config-reference +# Scaffolded by @rtcamp/wp-tooling. includes: - vendor/rtcamp/wp-phpstan/phpstan.neon.dist parameters: - paths: - - src - - includes - excludePaths: - # PHPStan rejects an excluded path that does not exist, so the ones a - # project may not have are marked optional with (?). - - vendor - - node_modules (?) - - build (?) - - tests (?) + # Your own PHP, as found when this file was scaffolded. A path that + # doesn't exist stops PHPStan, so keep the list current. + paths:{{#plugin_file}} + - {{plugin_file}}{{/plugin_file}}{{#functions_file}} + - {{functions_file}}{{/functions_file}}{{#uninstall_file}} + - {{uninstall_file}}{{/uninstall_file}}{{#framework_dir}} + - {{framework_dir}}{{/framework_dir}}{{#inc_dir}} + - {{inc_dir}}{{/inc_dir}}{{#includes_dir}} + - {{includes_dir}}{{/includes_dir}}{{#patterns_dir}} + - {{patterns_dir}}{{/patterns_dir}}{{#src_dir}} + - {{src_dir}}{{/src_dir}}{{#template_parts_dir}} + - {{template_parts_dir}}{{/template_parts_dir}}{{#templates_dir}} + - {{templates_dir}}{{/templates_dir}} diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 66dc867..dfe8f6d 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -25,6 +25,7 @@ 'use strict'; const fs = require('fs/promises'); +const { existsSync } = require('fs'); const path = require('path'); const { validate } = require('./validate'); @@ -886,20 +887,21 @@ const HEADER_LINE_RE = /^[ \t/*#]*([A-Za-z][A-Za-z ]*[A-Za-z]):[ \t]*(\S.*?)[ \t]*$/gm; /** - * Read the WordPress header block of a project's entry file. + * Find a project's WordPress entry file and read its header block. * * Looks for a plugin first — any root-level `*.php` carrying a `Plugin Name:` - * header — then falls back to a theme's `style.css` with `Theme Name:`. Keys are - * the header name lowercased with spaces hyphenated, so `Text Domain:` is read - * as `text-domain`, which is how a manifest names it. + * header — then falls back to a theme's `style.css` with `Theme Name:`. Header + * keys are the header name lowercased with spaces hyphenated, so `Text Domain:` + * is read as `text-domain`, which is how a manifest names it. * * Only the header comment is scanned: parsing stops at its closing `*\/`, so a * `Foo: bar` in the code below can never be mistaken for a header. * * @param {string} cwd - Target project directory. - * @return {Promise} Header map, or null when there is no entry file. + * @return {Promise} `{ type: 'plugin'|'theme', file, headers }`, + * or null when there is no entry file. */ -async function readEntryHeaders(cwd) { +async function readEntry(cwd) { const headerBlock = async (file) => { let raw; try { @@ -945,13 +947,13 @@ async function readEntryHeaders(cwd) { for (const name of entries.filter((f) => f.endsWith('.php')).sort()) { const block = await headerBlock(name); if (block && /^[ \t/*#]*Plugin Name:[ \t]*\S/m.test(block)) { - return parse(block); + return { type: 'plugin', file: name, headers: parse(block) }; } } const style = await headerBlock('style.css'); if (style && /^[ \t/*#]*Theme Name:[ \t]*\S/m.test(style)) { - return parse(style); + return { type: 'theme', file: 'style.css', headers: parse(style) }; } return null; @@ -959,13 +961,13 @@ async function readEntryHeaders(cwd) { /** * Load the project files `discover_from` can read, tolerantly. A missing or - * malformed file becomes `null` (composer/package/plugin-header) or `{}` - * (config) so that discovery silently falls back to each input's `default` — - * preserving the behaviour of projects that have no composer.json / - * .wp-tooling.json / recognisable entry file. + * malformed file becomes `null` (composer/package/plugin-header/entry-file) or + * `{}` (config) so that discovery silently falls back to each input's + * `default` — preserving the behaviour of projects that have no composer.json / + * .wp-tooling.json / recognisable entry file. `cwd` is kept for `exists:`. * * @param {string} cwd - Target project directory. - * @return {Promise} `{ 'composer.json', 'package.json', 'plugin-header', config }`. + * @return {Promise} `{ 'composer.json', 'package.json', 'plugin-header', 'entry-file', config, cwd }`. */ async function loadDiscovery(cwd) { const readJson = async (file) => { @@ -982,11 +984,14 @@ async function loadDiscovery(cwd) { return null; } }; + const entry = await readEntry(cwd); return { 'composer.json': await readJson('composer.json'), 'package.json': await readJson('package.json'), - 'plugin-header': await readEntryHeaders(cwd), + 'plugin-header': entry && entry.headers, + 'entry-file': entry && { type: entry.type, file: entry.file }, config: readConfig(cwd), + cwd, }; } @@ -1007,6 +1012,11 @@ async function loadDiscovery(cwd) { * entry-file header, named lowercased and hyphenated: `plugin-header:text-domain` * reads `Text Domain:`. * - `config:` — string value from `.wp-tooling.json`. + * - `entry-file:` — the entry file's name when the project is that + * type: `entry-file:plugin` is the main plugin file, `entry-file:theme` is + * `style.css`. + * - `exists:` — the path itself, as written, when it exists inside the + * project. A trailing `/` matches only a directory. * * Unknown sources (`input:` is handled by the caller) return `undefined`. * `validate` rejects any other prefix at authoring time, so an unresolvable @@ -1070,9 +1080,32 @@ function discoverFromSource(decl, discovery) { const value = getByPath(discovery.config || {}, selector); return typeof value === 'string' ? value : undefined; } + if (source === 'entry-file') { + const entry = discovery['entry-file']; + return entry && entry.type === spec.slice(colon + 1) + ? entry.file + : undefined; + } + if (source === 'exists') { + return existingPath(discovery.cwd, spec.slice(colon + 1)); + } return undefined; } +// `exists:` resolves only inside the project: an absolute path or one +// that climbs out with `..` never does, so a manifest cannot probe the rest of +// the filesystem. +function existingPath(cwd, rel) { + if ( + typeof cwd !== 'string' || + path.isAbsolute(rel) || + path.normalize(rel).split(/[\\/]/).includes('..') + ) { + return undefined; + } + return existsSync(path.join(cwd, rel)) ? rel : undefined; +} + // Heuristic: does this input key name a filesystem path/dir rather than a namespace? function isPathInput(key) { return /(^|_)(path|dir)$/.test(key) || key === 'base_path'; diff --git a/node-packages/wp-tooling/src/scaffolds/validate.js b/node-packages/wp-tooling/src/scaffolds/validate.js index 3d32c03..da1706b 100644 --- a/node-packages/wp-tooling/src/scaffolds/validate.js +++ b/node-packages/wp-tooling/src/scaffolds/validate.js @@ -85,6 +85,8 @@ const DISCOVER_SOURCES = [ 'package.json', 'plugin-header', 'config', + 'entry-file', + 'exists', 'input', ]; diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index e075506..6ce49ff 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -8,6 +8,8 @@ * - wiring targetFile paths are normalised (no `..` segments), and a * target that still escapes the project is dropped with a warning * - lint/i18n binds a supplied or discovered text domain, never a guessed one + * - lint/phpstan analyses only the project PHP that exists, since PHPStan + * stops on a listed path that does not * - wp-api/speculation renders into the registrable layout, reuses the * registrable wiring anchor, follows a non-`includes/` PSR-4 root, and * rejects an out-of-enum mode/eagerness @@ -752,6 +754,75 @@ describe('lint/i18n ruleset', () => { }); }); +describe('lint/phpstan paths', () => { + // Lays out `entries` in a fresh project (a trailing `/` makes a directory), + // scaffolds lint/phpstan into it, and returns the rendered config. + async function scaffoldInto(entries) { + const target = makeTmpDir(); + for (const [name, contents] of Object.entries(entries)) { + if (name.endsWith('/')) { + fs.mkdirSync(path.join(target, name)); + } else { + fs.writeFileSync(path.join(target, name), contents, 'utf8'); + } + } + await registry.execute('lint/phpstan', {}, { cwd: target }); + return fs.readFileSync(path.join(target, 'phpstan.neon.dist'), 'utf8'); + } + + function analysedPaths(neon) { + const block = neon.match(/^ {4}paths:\n((?: {8}- .*\n)+)/m); + return block[1].trim().split(/\n\s*/); + } + + it('lists the main plugin file and the directories a plugin has', async () => { + const neon = await scaffoldInto({ + 'acme-blog.php': ' { + const neon = await scaffoldInto({ + 'style.css': '/*\nTheme Name: Elementary\n*/\n', + 'functions.php': ' { + // The bug this closes: the config always listed `src` and `includes`, + // and PHPStan stops on a path that does not exist. + const neon = await scaffoldInto({ + 'acme-blog.php': ' { it.each([ ['default directory', {}, '.claude/skills'], diff --git a/node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js b/node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js index c240da8..ed77519 100644 --- a/node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/discover-sources.test.js @@ -2,6 +2,8 @@ * Tests for the `discover_from` sources added alongside the psr-4 resolvers: * - plugin-header: -> a value from the plugin/theme entry header * - composer.json:autoload-dev.psr-4 -> the dev autoload map, for test paths + * - entry-file: -> the plugin's main file, or a theme's style.css + * - exists: -> the path, when it exists in the project * * Plus the authoring-time guard that keeps the set of sources a manifest may * declare in step with the set registry.js can actually resolve — the failure @@ -197,6 +199,117 @@ describe('discover_from: composer.json:autoload-dev.psr-4', () => { }); }); +// Resolves each `[key, discover_from]` pair against `targetDir`, with an empty +// default, and returns the resolved inputs by key. +async function resolve(targetDir, pairs) { + const projectDir = makeTmpDir(); + const sDir = path.join(projectDir, 'wp', 'probe'); + fs.mkdirSync(sDir, { recursive: true }); + fs.writeFileSync( + path.join(sDir, 'scaffold.json'), + JSON.stringify({ + ...SCAFFOLD, + inputs: pairs.map(([key, discoverFrom]) => ({ + key, + description: key, + discover_from: discoverFrom, + default: '', + })), + files: [{ src: 'x.mustache', dest: 'x.php' }], + wiring: [], + }), + 'utf8' + ); + const r = new ScaffoldRegistry({ projectDir }); + await r.scan(); + const result = await r.execute( + 'wp/probe', + {}, + { dryRun: true, cwd: targetDir } + ); + fs.rmSync(projectDir, { recursive: true, force: true }); + return result.engine.inputs; +} + +describe('discover_from: entry-file:', () => { + const PAIRS = [ + ['plugin', 'entry-file:plugin'], + ['theme', 'entry-file:theme'], + ]; + + it('names the main plugin file, not another root PHP file', async () => { + const target = makeTmpDir(); + fs.writeFileSync(path.join(target, 'helpers.php'), ' { + const target = makeTmpDir(); + fs.writeFileSync( + path.join(target, 'style.css'), + '/*\nTheme Name: Elementary\n*/\n' + ); + expect(await resolve(target, PAIRS)).toEqual({ + plugin: '', + theme: 'style.css', + }); + }); + + it('falls back to the default when the directory has no entry file', async () => { + expect(await resolve(makeTmpDir(), PAIRS)).toEqual({ + plugin: '', + theme: '', + }); + }); +}); + +describe('discover_from: exists:', () => { + it('resolves a file or directory that exists, as written', async () => { + const target = makeTmpDir(); + fs.mkdirSync(path.join(target, 'inc')); + fs.writeFileSync(path.join(target, 'uninstall.php'), ' { + expect(await resolve(makeTmpDir(), [['src', 'exists:src/']])).toEqual({ + src: '', + }); + }); + + it('matches only a directory when the path ends in a slash', async () => { + const target = makeTmpDir(); + fs.writeFileSync(path.join(target, 'inc'), ''); + expect(await resolve(target, [['inc', 'exists:inc/']])).toEqual({ + inc: '', + }); + }); + + it('never resolves a path outside the project', async () => { + // Both point at the project itself, which exists: only the way they + // are written keeps them from resolving. + const target = makeTmpDir(); + expect( + await resolve(target, [ + ['parent', `exists:../${path.basename(target)}`], + ['absolute', `exists:${target}`], + ]) + ).toEqual({ parent: '', absolute: '' }); + }); +}); + describe('validate rejects a source the engine cannot resolve', () => { const manifest = (discoverFrom) => ({ slug: 'x', @@ -215,6 +328,8 @@ describe('validate rejects a source the engine cannot resolve', () => { 'package.json:name', 'plugin-header:text-domain', 'config:cssDir', + 'entry-file:plugin', + 'exists:inc/', 'input:name', 'code:bootstrap-class', ])('accepts %s', (spec) => { From 06706b92737cb031e08d5fc3586a15f0376b7268 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 22 Oct 2026 14:27:18 +0530 Subject: [PATCH 2/3] fix(scaffolds): ask for lint/phpstan paths instead of guessing them --- node-packages/wp-tooling/CHANGELOG.md | 6 +- .../wp-tooling/docs/ai-orchestration.md | 4 +- .../wp-tooling/docs/authoring-scaffolds.md | 2 - node-packages/wp-tooling/docs/examples.md | 2 +- .../scaffolds/lint/phpstan/scaffold.json | 61 +--------- .../templates/phpstan.neon.dist.mustache | 19 +-- .../claude-skills/templates/setup-SKILL.md | 2 +- .../wp-tooling/skills/setup/SKILL.md | 2 +- .../wp-tooling/src/scaffolds/registry.js | 61 +++------- .../wp-tooling/src/scaffolds/validate.js | 2 - .../tests/scaffolds/bundled-manifests.test.js | 84 ++++--------- .../tests/scaffolds/discover-sources.test.js | 115 ------------------ 12 files changed, 52 insertions(+), 308 deletions(-) diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index 2ceb7c9..c789cc5 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -6,13 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] -### Added - -- `discover_from` sources `entry-file:` and `exists:`. `entry-file:plugin` resolves to the main plugin file's name and `entry-file:theme` to `style.css`, each only for that type of project. `exists:` resolves to the path as written when it exists inside the project, with a trailing `/` matching only a directory; an absolute path or one that climbs out with `..` never resolves. - ### Fixed -- `lint/phpstan`: the generated `phpstan.neon.dist` lists the project's own PHP as found at scaffold time instead of a hardcoded `src` and `includes`. That is the main plugin file, a theme's `functions.php`, `uninstall.php`, and whichever of `framework/`, `inc/`, `includes/`, `patterns/`, `src/`, `template-parts/` and `templates/` exist. PHPStan stops with `Path … does not exist` on a missing `paths` entry, and `(?)` only works in `excludePaths`, so `composer analyse` failed in every project without both directories, including the rtCamp plugin and theme skeletons, which use `inc/`. The `excludePaths` block is gone: it only filters files inside `paths`, and none of its entries were. +- `lint/phpstan` asks for the PHP to analyse through a required `paths` input (comma-separated, e.g. `--paths='inc/, my-plugin.php'`) instead of hardcoding `src` and `includes`. PHPStan stops with `Path … does not exist` on a missing `paths` entry, and `(?)` only works in `excludePaths`, so `composer analyse` failed in every project without both directories, including the rtCamp plugin and theme skeletons, which use `inc/`. The excludes shrink to optional `vendor` and `node_modules`, which only matter when `paths` includes `.`. ## [2.0.0] - 2026-10-01 diff --git a/node-packages/wp-tooling/docs/ai-orchestration.md b/node-packages/wp-tooling/docs/ai-orchestration.md index 2dc240f..2fa1dfa 100644 --- a/node-packages/wp-tooling/docs/ai-orchestration.md +++ b/node-packages/wp-tooling/docs/ai-orchestration.md @@ -60,7 +60,7 @@ The engine commits to the following on a successful run. Skills can rely on thes - `scaffold.kind` is `"package"` for `source: "package"` scaffolds (no files written, only deps and wiring; the bundled examples are `utility/*`) and `"template"` otherwise. Remote (sources) scaffolds also report `kind: "template"` — they render Mustache the same way as local ones; where the scaffold lives is an implementation detail the orchestrator does not need to branch on. Callers branch on `kind` rather than checking `engine.wrote.length === 0`. - `wp-tooling list --json` entries carry an `origin` of `"default"`, `"project"`, or `"remote"`. Remote scaffolds come from a repo's cached index (`sources.json` → each repo's `index.json`), so their `counts` is `null` (unknown until `add`); local scaffolds carry real `counts`. `list` is online-preferred with a cache fallback: it reads the index (cached, ETag-validated), and a `warnings` array reports any source that was unreachable and uncached. The top-level `{ scaffolds, warnings }` shape carries those notes. - The engine core has zero dependency on the TTY UI kit. AI orchestration mode never loads any terminal-UI primitive. Skills can rely on the engine being usable from any context, including non-TTY containers, CI runners, and headless test harnesses. -- File-based `discover_from` (`composer.json:` including `autoload.psr-4` and `autoload-dev.psr-4`, `package.json:`, `plugin-header:` from the plugin or theme entry header, `config:` from `.wp-tooling.json`, `entry-file:` for the entry file's name, `exists:` for a path the project has) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` sources are **not** engine-resolved and remain the skill's responsibility (§6). +- File-based `discover_from` (`composer.json:` including `autoload.psr-4` and `autoload-dev.psr-4`, `package.json:`, `plugin-header:` from the plugin or theme entry header, `config:` from `.wp-tooling.json`) is resolved by the engine itself, before manifest defaults are applied, with precedence **`supplied → discovered → default`**. A value the skill passes explicitly always wins. A missing or malformed source file is ignored and the input falls back to its `default` — the engine never throws because a project file is absent or unparsable. `code:*` sources are **not** engine-resolved and remain the skill's responsibility (§6). - A scaffold may carry an optional `feature` block, marking it a **toggleable feature** (the bundled example is `setup/tailwind`). This block is **invisible to `add`/`execute`**: it never appears in the result, and `add` behaves identically whether or not it is present. Turning features on and off is a separate surface (§13). ## 4. Skill responsibilities @@ -222,7 +222,7 @@ The skill discovers project shape from existing artifacts. No config file is req The engine now resolves the **file-based** `discover_from` sources itself, so the division of labour is: -- **`composer.json:`, `package.json:`, `plugin-header:`, `config:`, `entry-file:`, `exists:` → the engine resolves these.** The skill does not need to pre-read these files and pass the value; the engine reads them from `--cwd` and fills the input before applying the `default`. The most important case: `namespace` and `tests_namespace` on every `wp/*` scaffold declare `discover_from: composer.json:autoload.psr-4`, so they are **auto-filled from the project's first PSR-4 root** whenever a `composer.json` exists, with the kind sub-namespace preserved: the discovered root replaces only the *first segment* of the manifest default, so a default of `Inc\Cli` and a PSR-4 root of `Acme\Blog` yield `Acme\Blog\Cli` (and `Inc\Tests\Cli` yields `Acme\Blog\Tests\Cli`). A skill that previously always passed `--namespace` no longer has to, though doing so is still safe (see precedence below). +- **`composer.json:`, `package.json:`, `plugin-header:`, `config:` → the engine resolves these.** The skill does not need to pre-read these files and pass the value; the engine reads them from `--cwd` and fills the input before applying the `default`. The most important case: `namespace` and `tests_namespace` on every `wp/*` scaffold declare `discover_from: composer.json:autoload.psr-4`, so they are **auto-filled from the project's first PSR-4 root** whenever a `composer.json` exists, with the kind sub-namespace preserved: the discovered root replaces only the *first segment* of the manifest default, so a default of `Inc\Cli` and a PSR-4 root of `Acme\Blog` yield `Acme\Blog\Cli` (and `Inc\Tests\Cli` yields `Acme\Blog\Tests\Cli`). A skill that previously always passed `--namespace` no longer has to, though doing so is still safe (see precedence below). - **`code:*` → still the skill's job.** The engine cannot read a bootstrap class or sample existing patterns. For these the engine returns the manifest `default`; the skill must do the introspection in this section and pass the value in. Rules the skill can rely on: diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index e791405..04b48fa 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -142,8 +142,6 @@ exactly as if `discover_from` were not set). - `composer.json:autoload-dev.psr-4` — the same graft against the **dev** autoload map, which is where a project declares its test tree. Use it for `tests_path`: a dev map of `Acme\Blog\Tests\` → `tests/php/` turns a default of `tests/Cli` into `tests/php/Cli`, so the generated test lands where the project's PHPUnit testsuite actually looks. Without it a test is written to the manifest default and is silently never collected. - `plugin-header:` — a value from the project's WordPress entry header, named lowercased with spaces hyphenated: `plugin-header:text-domain` reads `Text Domain:`. The engine looks for a root-level `*.php` carrying `Plugin Name:` and falls back to a theme's `style.css` carrying `Theme Name:`, so this resolves for plugins and themes alike. Only the header comment is scanned, so a `Foo: bar` in the code below is never mistaken for a header. - `config:` — a string value from the project's `.wp-tooling.json` (e.g. `config:textDomain`). -- `entry-file:` — the name of the project's entry file when the project is that type: `entry-file:plugin` is the main plugin file (the root-level `*.php` carrying `Plugin Name:`), `entry-file:theme` is `style.css`. A theme resolves nothing for `entry-file:plugin`, and the reverse. -- `exists:` — the path itself, exactly as written, when it exists inside the project; a trailing `/` matches only a directory. Pair it with an empty `default` and a `{{#key}}` section to render a line only for what the project has: `lint/phpstan` lists `inc/`, `src/` and the rest this way, because PHPStan stops on a listed path that does not exist. An absolute path or one that climbs out with `..` never resolves. `validate` rejects a `discover_from` whose prefix is not one of the above. An unrecognised source used to resolve to nothing and fall through to the `default` in silence, which is how diff --git a/node-packages/wp-tooling/docs/examples.md b/node-packages/wp-tooling/docs/examples.md index 48d0bfc..aa730e5 100644 --- a/node-packages/wp-tooling/docs/examples.md +++ b/node-packages/wp-tooling/docs/examples.md @@ -52,7 +52,7 @@ Confirm? npx wp-tooling add setup/editorconfig --non-interactive --json --cwd . npx wp-tooling add setup/psr4 --non-interactive --json --cwd . --namespace='Acme\InventoryManager' --base_path=includes npx wp-tooling add lint/phpcs/full --non-interactive --json --cwd . -npx wp-tooling add lint/phpstan --non-interactive --json --cwd . +npx wp-tooling add lint/phpstan --non-interactive --json --cwd . --paths=includes npx wp-tooling add lint/eslint --non-interactive --json --cwd . npx wp-tooling add setup/phpunit --non-interactive --json --cwd . --source_dir=includes npx wp-tooling add wp/cpt --non-interactive --json --cwd . --slug=product --singular=Product --plural=Products --namespace='Acme\InventoryManager' --base_path=includes diff --git a/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json b/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json index 451acc7..c1fc728 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json +++ b/node-packages/wp-tooling/scaffolds/lint/phpstan/scaffold.json @@ -6,64 +6,9 @@ "source": "template", "inputs": [ { - "key": "plugin_file", - "description": "Main plugin file, analysed with the rest of the project. Found by its Plugin Name header; left out for a theme.", - "discover_from": "entry-file:plugin", - "default": "" - }, - { - "key": "functions_file", - "description": "A theme's `functions.php`, analysed when the project has it.", - "discover_from": "exists:functions.php", - "default": "" - }, - { - "key": "uninstall_file", - "description": "`uninstall.php`, analysed when the project has it.", - "discover_from": "exists:uninstall.php", - "default": "" - }, - { - "key": "framework_dir", - "description": "`framework/`, analysed when the project has it.", - "discover_from": "exists:framework/", - "default": "" - }, - { - "key": "inc_dir", - "description": "`inc/`, analysed when the project has it.", - "discover_from": "exists:inc/", - "default": "" - }, - { - "key": "includes_dir", - "description": "`includes/`, analysed when the project has it.", - "discover_from": "exists:includes/", - "default": "" - }, - { - "key": "patterns_dir", - "description": "A theme's `patterns/`, analysed when the project has it.", - "discover_from": "exists:patterns/", - "default": "" - }, - { - "key": "src_dir", - "description": "`src/`, analysed when the project has it.", - "discover_from": "exists:src/", - "default": "" - }, - { - "key": "template_parts_dir", - "description": "A theme's `template-parts/`, analysed when the project has it.", - "discover_from": "exists:template-parts/", - "default": "" - }, - { - "key": "templates_dir", - "description": "`templates/`, analysed when the project has it.", - "discover_from": "exists:templates/", - "default": "" + "key": "paths", + "description": "Files and directories holding your own PHP, comma-separated, e.g. `inc/, my-plugin.php`. PHPStan stops on one that doesn't exist.", + "required": true } ], "files": [ diff --git a/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache b/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache index 74a3dc1..2e18a0f 100644 --- a/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache +++ b/node-packages/wp-tooling/scaffolds/lint/phpstan/templates/phpstan.neon.dist.mustache @@ -5,16 +5,9 @@ includes: - vendor/rtcamp/wp-phpstan/phpstan.neon.dist parameters: - # Your own PHP, as found when this file was scaffolded. A path that - # doesn't exist stops PHPStan, so keep the list current. - paths:{{#plugin_file}} - - {{plugin_file}}{{/plugin_file}}{{#functions_file}} - - {{functions_file}}{{/functions_file}}{{#uninstall_file}} - - {{uninstall_file}}{{/uninstall_file}}{{#framework_dir}} - - {{framework_dir}}{{/framework_dir}}{{#inc_dir}} - - {{inc_dir}}{{/inc_dir}}{{#includes_dir}} - - {{includes_dir}}{{/includes_dir}}{{#patterns_dir}} - - {{patterns_dir}}{{/patterns_dir}}{{#src_dir}} - - {{src_dir}}{{/src_dir}}{{#template_parts_dir}} - - {{template_parts_dir}}{{/template_parts_dir}}{{#templates_dir}} - - {{templates_dir}}{{/templates_dir}} + # Your own PHP. A path that doesn't exist stops PHPStan. + paths: [{{paths}}] + # Only matters if `paths` includes `.`. + excludePaths: + - vendor (?) + - node_modules (?) diff --git a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md index 2ce5358..b67ebcb 100644 --- a/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md +++ b/node-packages/wp-tooling/scaffolds/setup/claude-skills/templates/setup-SKILL.md @@ -192,7 +192,7 @@ For lint and test setup scaffolds: ```bash npx wp-tooling add lint/phpcs/vip --non-interactive --json --cwd . -npx wp-tooling add lint/phpstan --non-interactive --json --cwd . +npx wp-tooling add lint/phpstan --non-interactive --json --cwd . --paths=includes npx wp-tooling add lint/eslint --non-interactive --json --cwd . npx wp-tooling add setup/phpunit --non-interactive --json --cwd . --source-dir=includes npx wp-tooling add setup/pa11y --non-interactive --json --cwd . --base-url=http://localhost:8888 diff --git a/node-packages/wp-tooling/skills/setup/SKILL.md b/node-packages/wp-tooling/skills/setup/SKILL.md index 2ce5358..b67ebcb 100644 --- a/node-packages/wp-tooling/skills/setup/SKILL.md +++ b/node-packages/wp-tooling/skills/setup/SKILL.md @@ -192,7 +192,7 @@ For lint and test setup scaffolds: ```bash npx wp-tooling add lint/phpcs/vip --non-interactive --json --cwd . -npx wp-tooling add lint/phpstan --non-interactive --json --cwd . +npx wp-tooling add lint/phpstan --non-interactive --json --cwd . --paths=includes npx wp-tooling add lint/eslint --non-interactive --json --cwd . npx wp-tooling add setup/phpunit --non-interactive --json --cwd . --source-dir=includes npx wp-tooling add setup/pa11y --non-interactive --json --cwd . --base-url=http://localhost:8888 diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index dfe8f6d..66dc867 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -25,7 +25,6 @@ 'use strict'; const fs = require('fs/promises'); -const { existsSync } = require('fs'); const path = require('path'); const { validate } = require('./validate'); @@ -887,21 +886,20 @@ const HEADER_LINE_RE = /^[ \t/*#]*([A-Za-z][A-Za-z ]*[A-Za-z]):[ \t]*(\S.*?)[ \t]*$/gm; /** - * Find a project's WordPress entry file and read its header block. + * Read the WordPress header block of a project's entry file. * * Looks for a plugin first — any root-level `*.php` carrying a `Plugin Name:` - * header — then falls back to a theme's `style.css` with `Theme Name:`. Header - * keys are the header name lowercased with spaces hyphenated, so `Text Domain:` - * is read as `text-domain`, which is how a manifest names it. + * header — then falls back to a theme's `style.css` with `Theme Name:`. Keys are + * the header name lowercased with spaces hyphenated, so `Text Domain:` is read + * as `text-domain`, which is how a manifest names it. * * Only the header comment is scanned: parsing stops at its closing `*\/`, so a * `Foo: bar` in the code below can never be mistaken for a header. * * @param {string} cwd - Target project directory. - * @return {Promise} `{ type: 'plugin'|'theme', file, headers }`, - * or null when there is no entry file. + * @return {Promise} Header map, or null when there is no entry file. */ -async function readEntry(cwd) { +async function readEntryHeaders(cwd) { const headerBlock = async (file) => { let raw; try { @@ -947,13 +945,13 @@ async function readEntry(cwd) { for (const name of entries.filter((f) => f.endsWith('.php')).sort()) { const block = await headerBlock(name); if (block && /^[ \t/*#]*Plugin Name:[ \t]*\S/m.test(block)) { - return { type: 'plugin', file: name, headers: parse(block) }; + return parse(block); } } const style = await headerBlock('style.css'); if (style && /^[ \t/*#]*Theme Name:[ \t]*\S/m.test(style)) { - return { type: 'theme', file: 'style.css', headers: parse(style) }; + return parse(style); } return null; @@ -961,13 +959,13 @@ async function readEntry(cwd) { /** * Load the project files `discover_from` can read, tolerantly. A missing or - * malformed file becomes `null` (composer/package/plugin-header/entry-file) or - * `{}` (config) so that discovery silently falls back to each input's - * `default` — preserving the behaviour of projects that have no composer.json / - * .wp-tooling.json / recognisable entry file. `cwd` is kept for `exists:`. + * malformed file becomes `null` (composer/package/plugin-header) or `{}` + * (config) so that discovery silently falls back to each input's `default` — + * preserving the behaviour of projects that have no composer.json / + * .wp-tooling.json / recognisable entry file. * * @param {string} cwd - Target project directory. - * @return {Promise} `{ 'composer.json', 'package.json', 'plugin-header', 'entry-file', config, cwd }`. + * @return {Promise} `{ 'composer.json', 'package.json', 'plugin-header', config }`. */ async function loadDiscovery(cwd) { const readJson = async (file) => { @@ -984,14 +982,11 @@ async function loadDiscovery(cwd) { return null; } }; - const entry = await readEntry(cwd); return { 'composer.json': await readJson('composer.json'), 'package.json': await readJson('package.json'), - 'plugin-header': entry && entry.headers, - 'entry-file': entry && { type: entry.type, file: entry.file }, + 'plugin-header': await readEntryHeaders(cwd), config: readConfig(cwd), - cwd, }; } @@ -1012,11 +1007,6 @@ async function loadDiscovery(cwd) { * entry-file header, named lowercased and hyphenated: `plugin-header:text-domain` * reads `Text Domain:`. * - `config:` — string value from `.wp-tooling.json`. - * - `entry-file:` — the entry file's name when the project is that - * type: `entry-file:plugin` is the main plugin file, `entry-file:theme` is - * `style.css`. - * - `exists:` — the path itself, as written, when it exists inside the - * project. A trailing `/` matches only a directory. * * Unknown sources (`input:` is handled by the caller) return `undefined`. * `validate` rejects any other prefix at authoring time, so an unresolvable @@ -1080,32 +1070,9 @@ function discoverFromSource(decl, discovery) { const value = getByPath(discovery.config || {}, selector); return typeof value === 'string' ? value : undefined; } - if (source === 'entry-file') { - const entry = discovery['entry-file']; - return entry && entry.type === spec.slice(colon + 1) - ? entry.file - : undefined; - } - if (source === 'exists') { - return existingPath(discovery.cwd, spec.slice(colon + 1)); - } return undefined; } -// `exists:` resolves only inside the project: an absolute path or one -// that climbs out with `..` never does, so a manifest cannot probe the rest of -// the filesystem. -function existingPath(cwd, rel) { - if ( - typeof cwd !== 'string' || - path.isAbsolute(rel) || - path.normalize(rel).split(/[\\/]/).includes('..') - ) { - return undefined; - } - return existsSync(path.join(cwd, rel)) ? rel : undefined; -} - // Heuristic: does this input key name a filesystem path/dir rather than a namespace? function isPathInput(key) { return /(^|_)(path|dir)$/.test(key) || key === 'base_path'; diff --git a/node-packages/wp-tooling/src/scaffolds/validate.js b/node-packages/wp-tooling/src/scaffolds/validate.js index da1706b..3d32c03 100644 --- a/node-packages/wp-tooling/src/scaffolds/validate.js +++ b/node-packages/wp-tooling/src/scaffolds/validate.js @@ -85,8 +85,6 @@ const DISCOVER_SOURCES = [ 'package.json', 'plugin-header', 'config', - 'entry-file', - 'exists', 'input', ]; diff --git a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js index 6ce49ff..739de6c 100644 --- a/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/bundled-manifests.test.js @@ -8,8 +8,8 @@ * - wiring targetFile paths are normalised (no `..` segments), and a * target that still escapes the project is dropped with a warning * - lint/i18n binds a supplied or discovered text domain, never a guessed one - * - lint/phpstan analyses only the project PHP that exists, since PHPStan - * stops on a listed path that does not + * - lint/phpstan analyses only the paths it is given, never guessed ones, + * since PHPStan stops on a listed path that does not exist * - wp-api/speculation renders into the registrable layout, reuses the * registrable wiring anchor, follows a non-`includes/` PSR-4 root, and * rejects an out-of-enum mode/eagerness @@ -755,71 +755,33 @@ describe('lint/i18n ruleset', () => { }); describe('lint/phpstan paths', () => { - // Lays out `entries` in a fresh project (a trailing `/` makes a directory), - // scaffolds lint/phpstan into it, and returns the rendered config. - async function scaffoldInto(entries) { + it('analyses exactly the supplied paths', async () => { const target = makeTmpDir(); - for (const [name, contents] of Object.entries(entries)) { - if (name.endsWith('/')) { - fs.mkdirSync(path.join(target, name)); - } else { - fs.writeFileSync(path.join(target, name), contents, 'utf8'); - } - } - await registry.execute('lint/phpstan', {}, { cwd: target }); - return fs.readFileSync(path.join(target, 'phpstan.neon.dist'), 'utf8'); - } - - function analysedPaths(neon) { - const block = neon.match(/^ {4}paths:\n((?: {8}- .*\n)+)/m); - return block[1].trim().split(/\n\s*/); - } - - it('lists the main plugin file and the directories a plugin has', async () => { - const neon = await scaffoldInto({ - 'acme-blog.php': ' { - const neon = await scaffoldInto({ - 'style.css': '/*\nTheme Name: Elementary\n*/\n', - 'functions.php': ' { + it('requires paths rather than guessing them', async () => { // The bug this closes: the config always listed `src` and `includes`, // and PHPStan stops on a path that does not exist. - const neon = await scaffoldInto({ - 'acme-blog.php': ' -> a value from the plugin/theme entry header * - composer.json:autoload-dev.psr-4 -> the dev autoload map, for test paths - * - entry-file: -> the plugin's main file, or a theme's style.css - * - exists: -> the path, when it exists in the project * * Plus the authoring-time guard that keeps the set of sources a manifest may * declare in step with the set registry.js can actually resolve — the failure @@ -199,117 +197,6 @@ describe('discover_from: composer.json:autoload-dev.psr-4', () => { }); }); -// Resolves each `[key, discover_from]` pair against `targetDir`, with an empty -// default, and returns the resolved inputs by key. -async function resolve(targetDir, pairs) { - const projectDir = makeTmpDir(); - const sDir = path.join(projectDir, 'wp', 'probe'); - fs.mkdirSync(sDir, { recursive: true }); - fs.writeFileSync( - path.join(sDir, 'scaffold.json'), - JSON.stringify({ - ...SCAFFOLD, - inputs: pairs.map(([key, discoverFrom]) => ({ - key, - description: key, - discover_from: discoverFrom, - default: '', - })), - files: [{ src: 'x.mustache', dest: 'x.php' }], - wiring: [], - }), - 'utf8' - ); - const r = new ScaffoldRegistry({ projectDir }); - await r.scan(); - const result = await r.execute( - 'wp/probe', - {}, - { dryRun: true, cwd: targetDir } - ); - fs.rmSync(projectDir, { recursive: true, force: true }); - return result.engine.inputs; -} - -describe('discover_from: entry-file:', () => { - const PAIRS = [ - ['plugin', 'entry-file:plugin'], - ['theme', 'entry-file:theme'], - ]; - - it('names the main plugin file, not another root PHP file', async () => { - const target = makeTmpDir(); - fs.writeFileSync(path.join(target, 'helpers.php'), ' { - const target = makeTmpDir(); - fs.writeFileSync( - path.join(target, 'style.css'), - '/*\nTheme Name: Elementary\n*/\n' - ); - expect(await resolve(target, PAIRS)).toEqual({ - plugin: '', - theme: 'style.css', - }); - }); - - it('falls back to the default when the directory has no entry file', async () => { - expect(await resolve(makeTmpDir(), PAIRS)).toEqual({ - plugin: '', - theme: '', - }); - }); -}); - -describe('discover_from: exists:', () => { - it('resolves a file or directory that exists, as written', async () => { - const target = makeTmpDir(); - fs.mkdirSync(path.join(target, 'inc')); - fs.writeFileSync(path.join(target, 'uninstall.php'), ' { - expect(await resolve(makeTmpDir(), [['src', 'exists:src/']])).toEqual({ - src: '', - }); - }); - - it('matches only a directory when the path ends in a slash', async () => { - const target = makeTmpDir(); - fs.writeFileSync(path.join(target, 'inc'), ''); - expect(await resolve(target, [['inc', 'exists:inc/']])).toEqual({ - inc: '', - }); - }); - - it('never resolves a path outside the project', async () => { - // Both point at the project itself, which exists: only the way they - // are written keeps them from resolving. - const target = makeTmpDir(); - expect( - await resolve(target, [ - ['parent', `exists:../${path.basename(target)}`], - ['absolute', `exists:${target}`], - ]) - ).toEqual({ parent: '', absolute: '' }); - }); -}); - describe('validate rejects a source the engine cannot resolve', () => { const manifest = (discoverFrom) => ({ slug: 'x', @@ -328,8 +215,6 @@ describe('validate rejects a source the engine cannot resolve', () => { 'package.json:name', 'plugin-header:text-domain', 'config:cssDir', - 'entry-file:plugin', - 'exists:inc/', 'input:name', 'code:bootstrap-class', ])('accepts %s', (spec) => { From 4bec6c091e09b24f0c118d4d7042d233740d1ff4 Mon Sep 17 00:00:00 2001 From: Aditya Singh Date: Thu, 22 Oct 2026 15:18:05 +0530 Subject: [PATCH 3/3] fix(scaffolds): treat blank required inputs as missing --- node-packages/wp-tooling/CHANGELOG.md | 1 + .../wp-tooling/docs/ai-orchestration.md | 2 +- .../wp-tooling/docs/authoring-scaffolds.md | 2 +- .../wp-tooling/src/scaffolds/prompt-inputs.js | 4 ++++ .../wp-tooling/src/scaffolds/registry.js | 12 ++++++++--- .../tests/scaffolds/prompt-inputs.test.js | 19 +++++++++++++++++ .../tests/scaffolds/registry.test.js | 21 +++++++++++++++++++ 7 files changed, 56 insertions(+), 5 deletions(-) diff --git a/node-packages/wp-tooling/CHANGELOG.md b/node-packages/wp-tooling/CHANGELOG.md index c789cc5..d5b3f6d 100644 --- a/node-packages/wp-tooling/CHANGELOG.md +++ b/node-packages/wp-tooling/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- A required scaffold input given an empty or whitespace-only value (`--paths=`, or Enter at the prompt) counts as missing. Non-interactive runs fail with `EMISSINGINPUT` and the prompt asks again, instead of rendering an empty slug, namespace or path list. - `lint/phpstan` asks for the PHP to analyse through a required `paths` input (comma-separated, e.g. `--paths='inc/, my-plugin.php'`) instead of hardcoding `src` and `includes`. PHPStan stops with `Path … does not exist` on a missing `paths` entry, and `(?)` only works in `excludePaths`, so `composer analyse` failed in every project without both directories, including the rtCamp plugin and theme skeletons, which use `inc/`. The excludes shrink to optional `vendor` and `node_modules`, which only matter when `paths` includes `.`. ## [2.0.0] - 2026-10-01 diff --git a/node-packages/wp-tooling/docs/ai-orchestration.md b/node-packages/wp-tooling/docs/ai-orchestration.md index 2fa1dfa..131c3a1 100644 --- a/node-packages/wp-tooling/docs/ai-orchestration.md +++ b/node-packages/wp-tooling/docs/ai-orchestration.md @@ -100,7 +100,7 @@ The requested `/` is not in the merged catalogue. ### `EMISSINGINPUT` -`--non-interactive` mode and one or more required inputs were not supplied (after the manifest's `default` was applied). +`--non-interactive` mode and one or more required inputs were not supplied, or were blank (after the manifest's `default` was applied). ```json { diff --git a/node-packages/wp-tooling/docs/authoring-scaffolds.md b/node-packages/wp-tooling/docs/authoring-scaffolds.md index 04b48fa..7eeed70 100644 --- a/node-packages/wp-tooling/docs/authoring-scaffolds.md +++ b/node-packages/wp-tooling/docs/authoring-scaffolds.md @@ -126,7 +126,7 @@ The `inputs[]` array is the authoritative declaration. For each input the engine 1. Explicit `--key=value` passed to `add`. 2. `discover_from` (see below). 3. `default`. -4. Error `EMISSINGINPUT` if the field is `required: true`. +4. Error `EMISSINGINPUT` if the field is `required: true` and the value is missing or blank. An empty `--key=` or an Enter at the prompt is not a value. 5. Error `EINVALIDINPUT` if the resolved value falls outside a declared `enum`. An explicit value always wins over discovery, so AI/CLI callers are never overridden. diff --git a/node-packages/wp-tooling/src/scaffolds/prompt-inputs.js b/node-packages/wp-tooling/src/scaffolds/prompt-inputs.js index 550cf5f..3653f5e 100644 --- a/node-packages/wp-tooling/src/scaffolds/prompt-inputs.js +++ b/node-packages/wp-tooling/src/scaffolds/prompt-inputs.js @@ -43,6 +43,10 @@ async function promptMissingInputs({ const answer = await text({ message: `${message}${defaultValue !== undefined ? ` (default: ${defaultValue})` : ''}`, defaultValue, + // The engine rejects a blank required value, so ask again here. + validate: declared.required + ? (value) => (value ? undefined : 'A value is required.') + : undefined, }); next[key] = answer; } diff --git a/node-packages/wp-tooling/src/scaffolds/registry.js b/node-packages/wp-tooling/src/scaffolds/registry.js index 66dc867..576e003 100644 --- a/node-packages/wp-tooling/src/scaffolds/registry.js +++ b/node-packages/wp-tooling/src/scaffolds/registry.js @@ -1194,8 +1194,8 @@ function resolveDeclared(declared, supplied, discovery) { * discovery. * * Throws EMISSINGINPUT (with `missingDetails`) when required inputs are - * not supplied (after `default` is applied), then EINVALIDINPUT (with - * `invalid`) when a resolved value falls outside its declared `enum`. + * not supplied or blank (after `default` is applied), then EINVALIDINPUT + * (with `invalid`) when a resolved value falls outside its declared `enum`. * * @param {Object} scaffold - The scaffold record being executed. * @param {Object} supplied - Caller-supplied input values. @@ -1211,7 +1211,13 @@ function resolveInputs(scaffold, supplied, discovery = {}) { if (declared) { resolved = resolveDeclared(declared, supplied, discovery); for (const decl of declared) { - if (!(decl.key in resolved) && decl.required) { + // A blank value counts as missing: it would render an empty slug, + // namespace or path list, which no generated code means. + const value = resolved[decl.key]; + if ( + decl.required && + (value === undefined || String(value).trim() === '') + ) { missing.push(decl.key); missingDetails.push({ key: decl.key, diff --git a/node-packages/wp-tooling/tests/scaffolds/prompt-inputs.test.js b/node-packages/wp-tooling/tests/scaffolds/prompt-inputs.test.js index 5371b02..4fe3cd3 100644 --- a/node-packages/wp-tooling/tests/scaffolds/prompt-inputs.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/prompt-inputs.test.js @@ -81,6 +81,25 @@ describe('promptMissingInputs', () => { }); expect(ui.__calls.text[0].defaultValue).toBeUndefined(); }); + + it('asks again on a blank answer only for a required input', async () => { + const scaffold = { + inputs: [ + { key: 'name', description: 'Command slug', required: true }, + { key: 'label', description: 'Menu label' }, + ], + }; + await promptMissingInputs({ + scaffold, + missing: ['name', 'label'], + missingDetails: [], + supplied: {}, + }); + const [required, optional] = ui.__calls.text; + expect(required.validate('')).toBe('A value is required.'); + expect(required.validate('export')).toBeUndefined(); + expect(optional.validate).toBeUndefined(); + }); }); describe('confirmRun', () => { diff --git a/node-packages/wp-tooling/tests/scaffolds/registry.test.js b/node-packages/wp-tooling/tests/scaffolds/registry.test.js index cc2b37a..466e779 100644 --- a/node-packages/wp-tooling/tests/scaffolds/registry.test.js +++ b/node-packages/wp-tooling/tests/scaffolds/registry.test.js @@ -495,6 +495,27 @@ describe('execute() error paths', () => { discover_from: null, }); }); + + it.each(['', ' '])( + 'treats a blank required input (%j) as missing', + async (blank) => { + await expect( + registry.execute('wp/cli', { name: blank }, { dryRun: true }) + ).rejects.toMatchObject({ + code: 'EMISSINGINPUT', + missing: ['name'], + }); + } + ); + + it('accepts a blank optional input', async () => { + const result = await registry.execute( + 'wp/cli', + { name: 'export', namespace: '' }, + { dryRun: true, cwd: makeTmpDir() } + ); + expect(result.engine.inputs.namespace).toBe(''); + }); }); describe('execute() passes scripts through to developer block', () => {