diff --git a/README.md b/README.md index e932f5e..2515a9e 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Requires Node.js 20+. a8c-integration init ``` -Interactive — it asks for your **vendor name** and **integration name**, always builds from the canonical [VIP Integrations Starter Kit](https://github.com/Automattic/vip-integrations-starter-kit) (its default branch, no git history pulled), rewrites the example prefix set to your names, renames the entry file, and starts a fresh git history. You can also pass the answers as flags: +Interactive — it asks for your **vendor name** and **integration name**, always builds from the canonical [VIP Integrations Starter Kit](https://github.com/Automattic/vip-integrations-starter-kit) (its default branch, no git history pulled), rewrites the example prefix set to your names, and renames the entry file. You can also pass the answers as flags: ```bash a8c-integration init --vendor "WordPress" --name "Content Sync" diff --git a/__tests__/scaffold.test.ts b/__tests__/scaffold.test.ts index e033f7b..8327e9a 100644 --- a/__tests__/scaffold.test.ts +++ b/__tests__/scaffold.test.ts @@ -140,6 +140,43 @@ describe( 'scaffoldTree', () => { expect( result.changed ).toBeGreaterThanOrEqual( 3 ); } ); + it( 'personalizes derivable manifest fields and placeholders the rest', () => { + const root = join( dir, 'manifest' ); + mkdirSync( root, { recursive: true } ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + [ + '# yaml-language-server: $schema=./a8c-manifest.schema.json', + 'integration:', + ' slug: example-integration', + ' summary: Reference integration built from the VIP Integrations Starter Kit.', + ' partner:', + ' support_contact: support@example.com', + 'documentation:', + ' public_url: https://example.com/docs/example-integration', + ' support_url: https://example.com/docs/example-integration/support', + 'release:', + ' changelog: Initial VIP integration starter kit example.', + '', + ].join( '\n' ) + ); + + scaffoldTree( root, 'Acme', 'Content Sync' ); + + const manifest = readFileSync( join( root, 'a8c-manifest.yaml' ), 'utf8' ); + // Derivable fields get real values. + expect( manifest ).toContain( 'summary: Content Sync integration for WordPress VIP.' ); + expect( manifest ).toContain( 'changelog: Initial release.' ); + // Partner-only fields become placeholders so validate fails until filled. + expect( manifest ).toContain( 'support_contact: REPLACE_ME' ); + expect( manifest ).toContain( 'public_url: https://REPLACE_ME' ); + expect( manifest ).toContain( 'support_url: https://REPLACE_ME' ); + // The schema modeline comment survives the edit. + expect( manifest ).toContain( '# yaml-language-server: $schema=./a8c-manifest.schema.json' ); + // The token pass still ran: the example slug was rewritten. + expect( manifest ).toContain( 'slug: content-sync' ); + } ); + it( 'leaves a binary file untouched even when it contains a token', () => { const root = join( dir, 'binary' ); mkdirSync( root, { recursive: true } ); diff --git a/__tests__/validate.test.ts b/__tests__/validate.test.ts index 6a679fb..799d184 100644 --- a/__tests__/validate.test.ts +++ b/__tests__/validate.test.ts @@ -18,6 +18,9 @@ function conformantManifest(): string { ' partner:', ' name: Acme', ' support_contact: support@acme.example', + 'documentation:', + ' public_url: https://acme.example/docs/widget', + ' support_url: https://acme.example/docs/widget/support', 'runtime:', ' wordpress_plugin:', ' folder: acme-widget', @@ -38,6 +41,21 @@ function conformantManifest(): string { ' values:', ' - export', ' - import', + 'telemetry:', + ' prefix: acme_widget_', + ' default_properties:', + ' - plugin_version', + ' events:', + ' - name: acme_widget_sync_started', + ' type: tracks', + ' trigger: A sync starts.', + ' properties:', + ' - trigger', + 'release:', + ' plugin_version: 1.0.0', + ' version_strategy: latest', + ' migration_required: false', + ' changelog: Initial release.', ].join( '\n' ); } @@ -72,7 +90,7 @@ function scaffoldConformant( root: string ): void { ` { const root = join( dir, 'manifest-missing' ); mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); - rmSync( join( root, 'vip-handoff.yaml' ) ); + rmSync( join( root, 'a8c-manifest.yaml' ) ); expect( statusById( root )[ 'handoff-manifest' ] ).toBe( 'fail' ); } ); @@ -319,7 +337,7 @@ describe( 'validateIntegration', () => { scaffoldConformant( root ); // Drop runtime_config.constant_name — VIP needs it to define the config. writeFileSync( - join( root, 'vip-handoff.yaml' ), + join( root, 'a8c-manifest.yaml' ), conformantManifest().replace( ' constant_name: VIP_ACME_WIDGET_CONFIG\n', '' ) ); @@ -327,7 +345,7 @@ describe( 'validateIntegration', () => { result => result.id === 'handoff-manifest' ); expect( rule3?.status ).toBe( 'fail' ); - expect( rule3?.details?.join( '\n' ) ).toMatch( /runtime_config\.constant_name/ ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /constant_name/ ); } ); it( 'fails rule 3 when an enum config field declares no values', () => { @@ -335,7 +353,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'vip-handoff.yaml' ), + join( root, 'a8c-manifest.yaml' ), conformantManifest().replace( /\n {6}values:[\s\S]*$/, '' ) ); @@ -343,7 +361,294 @@ describe( 'validateIntegration', () => { result => result.id === 'handoff-manifest' ); expect( rule3?.status ).toBe( 'fail' ); - expect( rule3?.details?.join( '\n' ) ).toMatch( /enum/ ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /values/ ); + } ); + + it( 'fails rule 3 when a manifest key is misspelled (unknown field)', () => { + const root = join( dir, 'manifest-typo' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( ' entry_file:', ' entryfile:' ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /unknown field "entryfile"/ ); + } ); + + it( 'fails rule 3 when constant_name does not match VIP_*_CONFIG', () => { + const root = join( dir, 'manifest-bad-constant' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( 'VIP_ACME_WIDGET_CONFIG', 'ACME_WIDGET' ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /constant_name is malformed/ ); + } ); + + it( 'fails rule 3 when manifest_kind is wrong', () => { + const root = join( dir, 'manifest-kind' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( 'vip-integration-handoff', 'something-else' ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /manifest_kind must be/ ); + } ); + + it( 'fails rule 3 when the documentation section is missing', () => { + const root = join( dir, 'manifest-no-docs' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( + 'documentation:\n public_url: https://acme.example/docs/widget\n support_url: https://acme.example/docs/widget/support\n', + '' + ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /missing required field "documentation"/ ); + } ); + + it( 'fails rule 3 when documentation.public_url is not a URL', () => { + const root = join( dir, 'manifest-bad-doc-url' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( 'https://acme.example/docs/widget/support', 'not-a-url' ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /support_url is malformed/ ); + } ); + + it( 'fails rule 3 when the telemetry prefix does not end in an underscore', () => { + const root = join( dir, 'manifest-bad-telemetry' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( 'prefix: acme_widget_', 'prefix: acme_widget' ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /prefix is malformed/ ); + } ); + + it( 'passes rule 3 with no telemetry section (telemetry is optional)', () => { + const root = join( dir, 'manifest-no-telemetry' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( + 'telemetry:\n prefix: acme_widget_\n default_properties:\n - plugin_version\n events:\n - name: acme_widget_sync_started\n type: tracks\n trigger: A sync starts.\n properties:\n - trigger\n', + '' + ) + ); + + expect( statusById( root )[ 'handoff-manifest' ] ).toBe( 'pass' ); + } ); + + it( 'fails rule 3 when release.plugin_version is not semver', () => { + const root = join( dir, 'manifest-bad-version' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( 'plugin_version: 1.0.0', 'plugin_version: v1' ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /plugin_version is malformed/ ); + } ); + + it( 'passes rule 3 when a config field declares autogen and note', () => { + const root = join( dir, 'manifest-autogen' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( + ' - key: api_base_url\n label: API base URL\n type: url\n required: true', + ' - key: api_base_url\n label: API base URL\n type: url\n required: true\n autogen: false\n note: Provided by the vendor.' + ) + ); + + expect( statusById( root )[ 'handoff-manifest' ] ).toBe( 'pass' ); + } ); + + it( 'fails rule 3 when autogen is not a boolean', () => { + const root = join( dir, 'manifest-bad-autogen' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( + ' required: true\n - key: sync_mode', + ' required: true\n autogen: not-a-bool\n - key: sync_mode' + ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /autogen/ ); + } ); + + it( 'fails rule 3 when the manifest still has an init placeholder', () => { + const root = join( dir, 'manifest-placeholder' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( + 'support_contact: support@acme.example', + 'support_contact: REPLACE_ME' + ) + ); + + const rule3 = validateIntegration( root ).results.find( + result => result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /support_contact.*REPLACE_ME/ ); + } ); + + it( 'does not flag a real value that merely embeds the placeholder token', () => { + const root = join( dir, 'manifest-placeholder-embedded' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'a8c-manifest.yaml' ), + conformantManifest().replace( + 'changelog: Initial release.', + 'changelog: Removed the REPLACE_ME_TOKEN debug flag.' + ) + ); + + expect( statusById( root )[ 'handoff-manifest' ] ).toBe( 'pass' ); + } ); + + it( 'fails rule 3 when a REQUIRED_FIELDS config key is missing from the manifest', () => { + const root = join( dir, 'manifest-config-missing' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'inc', 'class-config.php' ), + " result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /webhook_secret.*not declared/ ); + } ); + + it( 'fails rule 3 when a SENSITIVE_FIELDS config key is not typed secret', () => { + const root = join( dir, 'manifest-config-secret' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'inc', 'class-config.php' ), + " result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /api_base_url.*secret/ ); + } ); + + it( 'passes rule 3 when the config contract matches the manifest', () => { + const root = join( dir, 'manifest-config-ok' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'inc', 'class-config.php' ), + " { + const root = join( dir, 'manifest-config-comment' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'inc', 'class-config.php' ), + " { + const root = join( dir, 'manifest-config-suffix' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + // Sorts before class-config.php, so first-match order would pick it up. + writeFileSync( + join( root, 'inc', 'aaa-flags.php' ), + " { + const root = join( dir, 'manifest-config-double-quote' ); + mkdirSync( root, { recursive: true } ); + scaffoldConformant( root ); + writeFileSync( + join( root, 'inc', 'class-config.php' ), + ' result.id === 'handoff-manifest' + ); + expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.details?.join( '\n' ) ).toMatch( /webhook_secret.*not declared/ ); } ); it( 'fails rule 7 when compatibility is only prose, with no CI matrix', () => { diff --git a/docs/architecture.md b/docs/architecture.md index 1ee7ab2..3ab7613 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,8 @@ src/ colors.ts Tiny ANSI helper (chalk-shaped, dependency-free) validate/ validate.ts Nine conformance checks (pure, fs-only) - manifest.ts Handoff-manifest (vip-handoff.yaml) validation + manifest.ts Handoff-manifest (a8c-manifest.yaml) validation + manifest.schema.ts JSON Schema: the manifest's fields and constraints report.ts Human and JSON rendering of a report scaffold/ scaffold.ts The Starter Kit prefix rewrite (pure, fs-only) @@ -32,7 +33,9 @@ __tests__/ Jest tests for validate, report, and scaffold Checks if the integration meets the wpvip guidelines. All checks are **static** — they inspect files and config, never execute the integration. `validateIntegration(root)` builds a single `Context` (parsed `composer.json`, concatenated PHP/docs/workflow text, the detected config constant and entry file, and the parsed handoff manifest) and runs each rule against it, so the filesystem is read once. Rules return `pass` / `fail` / `warn` / `not_applicable`; only a `fail` breaks conformance. Two inherently non-static items (config-schema match, security review) are returned as human-review items. -One rule validates the **handoff manifest** (`vip-handoff.yaml`) — the single file a partner fills in so VIP can register and load the integration from the manifest alone. `manifest.ts` parses it and checks that every field VIP consumes (identity, plugin runtime, and the runtime-config schema) is present and well-formed; it is a presence-and-shape check, not a check that the values are correct. +One rule validates the **handoff manifest** (`a8c-manifest.yaml`) — the single file a partner fills in so VIP can register and load the integration from the manifest alone. `manifest.ts` parses it and validates it against `manifest.schema.ts` (a JSON Schema, compiled with Ajv) — the single source of truth for the manifest's fields and constraints. It is a presence-and-shape check that every field VIP consumes (identity, documentation, plugin runtime, the runtime-config schema, telemetry, and release metadata) is present and well-formed, not a check that the values are correct. The Starter Kit ships an identical `a8c-manifest.schema.json` so partners validate against the same contract in their editor. + +Beyond the schema, the same rule enforces two things a raw schema can't. First, it fails while any field still holds the `MANIFEST_PLACEHOLDER` sentinel that `init` leaves in the partner-only fields (contact, docs URLs), so a partner cannot submit a half-filled scaffold. Second, it cross-checks the config keys the plugin declares (`Config::REQUIRED_FIELDS` / `SENSITIVE_FIELDS`) against the manifest's `runtime_config.fields`, so a config field the code reads from the constant can't be missing from — or mis-typed in — the manifest. That cross-check is deterministic for integrations following the Starter Kit Config convention and skipped for any plugin that declares neither array. ## The scaffolder (`lib/scaffold`) @@ -40,4 +43,4 @@ It derives a prefix set (pascal / kebab / snake / upper forms) from the vendor a ## Dependencies -Runtime: [`commander`](https://github.com/tj/commander.js) for argument parsing. Colors are a ~15-line ANSI helper rather than a dependency, which keeps the build a plain CommonJS `tsc` compile with no ESM-only packages. Dev: `typescript`, `jest`, and `ts-jest` for the build and tests, plus `eslint` with [`@automattic/eslint-plugin-wpvip`](https://github.com/Automattic/eslint-plugin-wpvip) and `wp-prettier` for lint/format — the same tooling as [Automattic/commands](https://github.com/Automattic/commands). The package manager is **pnpm** (pinned via `packageManager`). +Runtime: [`commander`](https://github.com/tj/commander.js) for argument parsing, [`js-yaml`](https://github.com/nodeca/js-yaml) to parse the handoff manifest, and [`ajv`](https://ajv.js.org/) to validate it against the manifest JSON Schema. Colors are a ~15-line ANSI helper rather than a dependency, which keeps the build a plain CommonJS `tsc` compile with no ESM-only packages. Dev: `typescript`, `jest`, and `ts-jest` for the build and tests, plus `eslint` with [`@automattic/eslint-plugin-wpvip`](https://github.com/Automattic/eslint-plugin-wpvip) and `wp-prettier` for lint/format — the same tooling as [Automattic/commands](https://github.com/Automattic/commands). The package manager is **pnpm** (pinned via `packageManager`). diff --git a/package.json b/package.json index dd7d6da..e839ac5 100644 --- a/package.json +++ b/package.json @@ -49,6 +49,7 @@ "prepublishOnly": "pnpm run clean && pnpm run build" }, "dependencies": { + "ajv": "^8.20.0", "commander": "^14.0.3", "js-yaml": "^5.2.2" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f318d9f..63c8c12 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + ajv: + specifier: ^8.20.0 + version: 8.20.0 commander: specifier: ^14.0.3 version: 14.0.3 @@ -683,6 +686,9 @@ packages: ajv@6.15.0: resolution: {integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==, tarball: https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz} + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==, tarball: https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz} + ansi-escapes@4.3.2: resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==, tarball: https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz} engines: {node: '>=8'} @@ -1280,6 +1286,9 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, tarball: https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz} + fast-uri@3.1.4: + resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==, tarball: https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz} + fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==, tarball: https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz} @@ -1799,6 +1808,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==, tarball: https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, tarball: https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz} @@ -2114,6 +2126,10 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==, tarball: https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==, tarball: https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz} + engines: {node: '>=0.10.0'} + reserved-identifiers@1.2.0: resolution: {integrity: sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw==, tarball: https://registry.npmjs.org/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz} engines: {node: '>=18'} @@ -3336,6 +3352,13 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.4 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + ansi-escapes@4.3.2: dependencies: type-fest: 0.21.3 @@ -4092,6 +4115,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fast-uri@3.1.4: {} + fb-watchman@2.0.2: dependencies: bser: 2.1.1 @@ -4806,6 +4831,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json5@1.0.2: @@ -5107,6 +5134,8 @@ snapshots: require-directory@2.1.1: {} + require-from-string@2.0.2: {} + reserved-identifiers@1.2.0: {} resolve-cwd@3.0.0: diff --git a/src/commands/init.ts b/src/commands/init.ts index da2f9b7..7ad4e2b 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -102,13 +102,8 @@ export async function initCommand( opts: InitOptions = {} ): Promise< void > { try { laySkeleton( target ); ( { entryFile, prefix } = scaffoldTree( target, vendor, name ) ); - - // Give the fresh project its own clean git history. - try { - execFileSync( 'git', [ 'init', '--quiet' ], { cwd: target, stdio: 'ignore' } ); - } catch { - // git init is a nicety; a scaffold without it is still usable. - } + // The scaffold is left as a plain directory, not a git repo — the partner + // initializes version control themselves when and how they want. } catch ( error ) { // A clone that dies partway or a scaffold that throws would otherwise leave // a half-populated directory that blocks the next run. Remove what we laid diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index c869a40..4509614 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -20,6 +20,9 @@ import { } from 'node:fs'; import { join } from 'node:path'; +import { MANIFEST_FILENAMES } from '../validate/manifest'; +import { MANIFEST_PLACEHOLDER } from '../validate/manifest.schema'; + /** Everything from this heading onward in a file is left un-rewritten: it is a * token table that must keep the example prefix so its mapping stays readable. */ const PRESERVE_MARKER = '## Making it your own'; @@ -233,11 +236,47 @@ export function scaffoldTree( root: string, vendor: string, name: string ): Scaf renameSync( exampleEntry, join( root, entryFile ) ); } + personalizeManifest( root, prefix ); removeRedundantScaffolder( root ); return { changed, entryFile, prefix }; } +/** + * Rewrite the handoff manifest for a fresh scaffold. Two jobs: + * + * 1. Fill the fields `init` can derive from the integration name but the token + * rewrite can't — the Starter-Kit-self-referential `summary` and + * `release.changelog`. + * 2. Blank the fields only the partner can supply — the support contact and the + * documentation URLs — with the `MANIFEST_PLACEHOLDER` sentinel, so + * `a8c-integration validate` fails until the partner replaces them. The + * sentinel is a valid value for each field, so the failure is a clear + * "fill this in", not a schema error. + * + * The rest is either already set by the prefix rewrite (slug, names, namespace, + * constant) or is real integration content (the config fields, telemetry) the + * partner edits as they build. All comment-preserving line edits, so the + * `# yaml-language-server` modeline and inline notes survive. + */ +function personalizeManifest( root: string, prefix: PrefixSet ): void { + const file = MANIFEST_FILENAMES.map( name => join( root, name ) ).find( existsSync ); + if ( ! file ) { + return; + } + const displayName = wordsCase( prefix.nameKebab ); + const original = readFileSync( file, 'utf8' ); + const updated = original + .replace( /^( *)summary: .*/m, `$1summary: ${ displayName } integration for WordPress VIP.` ) + .replace( /^( *)changelog: .*/m, '$1changelog: Initial release.' ) + .replace( /^( *)support_contact: .*/m, `$1support_contact: ${ MANIFEST_PLACEHOLDER }` ) + .replace( /^( *)public_url: .*/m, `$1public_url: https://${ MANIFEST_PLACEHOLDER }` ) + .replace( /^( *)support_url: .*/m, `$1support_url: https://${ MANIFEST_PLACEHOLDER }` ); + if ( updated !== original ) { + writeFileSync( file, updated ); + } +} + /** * `a8c-integration init` replaces the Starter Kit's own `composer setup`, so the * `bin/setup.php` scaffolder and its composer script are dead weight in a diff --git a/src/lib/validate/manifest.schema.ts b/src/lib/validate/manifest.schema.ts new file mode 100644 index 0000000..26dacb0 --- /dev/null +++ b/src/lib/validate/manifest.schema.ts @@ -0,0 +1,332 @@ +/** + * JSON Schema for the VIP integration handoff manifest (`a8c-manifest.yaml`). + * + * This is the single source of truth for what a manifest may contain and the + * constraints on each field. `manifest.ts` compiles it with Ajv and validates a + * parsed manifest against it, so adding or tightening a rule means editing the + * schema here — not hand-written checks. The Starter Kit ships an identical + * `a8c-manifest.schema.json` so partners get the same contract in their editor. + * + * `additionalProperties: false` is set on every defined object on purpose: VIP + * registers the integration from this file alone, so an unexpected or + * mistyped key (`entryfile` for `entry_file`) is a mistake worth failing on, not + * silently ignoring. + * + * The `documentation`, `telemetry`, and `release` sections mirror the shapes in + * the draft VIP Integration Handoff spec, so this schema converges toward it + * rather than forking a third variant. + */ + +/** The `manifest_kind` value that identifies a handoff manifest. */ +export const MANIFEST_KIND = 'vip-integration-handoff'; + +/** + * Sentinel `a8c-integration init` leaves in the manifest fields a partner must + * fill by hand (contact, docs URLs). It is a valid value for its field so the + * schema still passes — `validate` fails separately while any field's value + * still contains it, forcing the partner to replace it before submitting. + */ +export const MANIFEST_PLACEHOLDER = 'REPLACE_ME'; + +/** Field types the Integration Center config form understands. */ +export const FIELD_TYPES = [ + 'string', + 'text', + 'url', + 'email', + 'number', + 'boolean', + 'secret', + 'enum', +] as const; + +const SLUG_PATTERN = '^[a-z0-9]+(-[a-z0-9]+)*$'; +const KEY_PATTERN = '^[a-z0-9]+(_[a-z0-9]+)*$'; +// snake_case identifier used for telemetry event and property names. +const SNAKE_PATTERN = '^[a-z][a-z0-9_]*$'; +// An absolute http(s) URL. A pattern (not `format: uri`) so it is enforced +// without pulling in ajv-formats. +const HTTP_URL_PATTERN = '^https?://.+'; +// Semantic version matching the plugin header, with optional pre-release/build. +const SEMVER_PATTERN = '^[0-9]+\\.[0-9]+\\.[0-9]+([-+][A-Za-z0-9.-]+)?$'; + +export const MANIFEST_SCHEMA = { + $id: 'https://automattic.github.io/vip-integration/a8c-manifest.schema.json', + title: 'WordPress VIP Integration Handoff Manifest', + description: + 'The single file a partner fills in so VIP can register and load their integration without reading the plugin source.', + type: 'object', + additionalProperties: false, + required: [ + 'manifest_version', + 'manifest_kind', + 'integration', + 'documentation', + 'runtime', + 'runtime_config', + 'release', + ], + properties: { + manifest_version: { + const: 1, + description: 'Manifest format version. Only "1" exists today.', + }, + manifest_kind: { + const: MANIFEST_KIND, + description: 'Fixed discriminator identifying this file as a handoff manifest.', + }, + integration: { + type: 'object', + additionalProperties: false, + required: [ 'slug', 'display_name', 'summary', 'partner' ], + properties: { + slug: { + type: 'string', + pattern: SLUG_PATTERN, + minLength: 3, + maxLength: 63, + description: 'Stable kebab-case identifier, unique across the Integration Center.', + }, + display_name: { + type: 'string', + minLength: 1, + maxLength: 60, + description: 'Human-readable name shown in the Integration Center.', + }, + summary: { + type: 'string', + minLength: 1, + maxLength: 200, + description: 'One-line description shown on the catalog card.', + }, + partner: { + type: 'object', + additionalProperties: false, + required: [ 'name', 'support_contact' ], + properties: { + name: { + type: 'string', + minLength: 1, + description: 'Partner / vendor name.', + }, + support_contact: { + type: 'string', + minLength: 1, + description: 'Email address or support URL VIP can reach the partner at.', + }, + }, + }, + }, + }, + documentation: { + type: 'object', + additionalProperties: false, + required: [ 'public_url' ], + description: 'Where VIP and customers find documentation for the integration.', + properties: { + public_url: { + type: 'string', + pattern: HTTP_URL_PATTERN, + description: 'Customer- or administrator-facing documentation URL.', + }, + support_url: { + type: 'string', + pattern: HTTP_URL_PATTERN, + description: 'Optional troubleshooting / partner-support documentation URL.', + }, + }, + }, + runtime: { + type: 'object', + additionalProperties: false, + required: [ 'wordpress_plugin' ], + properties: { + wordpress_plugin: { + type: 'object', + additionalProperties: false, + required: [ 'folder', 'entry_file', 'php_namespace', 'scope' ], + properties: { + folder: { + type: 'string', + pattern: SLUG_PATTERN, + description: 'Plugin folder name VIP installs the integration into.', + }, + entry_file: { + type: 'string', + pattern: '^[A-Za-z0-9._-]+\\.php$', + description: 'Root plugin file (with the "Plugin Name:" header) VIP loads.', + }, + php_namespace: { + type: 'string', + pattern: '^[A-Za-z_][A-Za-z0-9_]*(\\\\[A-Za-z_][A-Za-z0-9_]*)*$', + description: 'Root PHP namespace the plugin autoloads under.', + }, + scope: { + enum: [ 'site', 'network' ], + description: 'Whether the plugin loads per-site or network-wide.', + }, + }, + }, + }, + }, + runtime_config: { + type: 'object', + additionalProperties: false, + required: [ 'constant_name', 'fields' ], + properties: { + constant_name: { + type: 'string', + pattern: '^VIP_[A-Z0-9_]+_CONFIG$', + description: 'The VIP-defined config constant the plugin reads, e.g. VIP_ACME_CONFIG.', + }, + fields: { + type: 'array', + minItems: 1, + description: 'The config fields the Integration Center renders and stores.', + items: { $ref: '#/$defs/configField' }, + }, + }, + }, + telemetry: { + type: 'object', + additionalProperties: false, + required: [ 'prefix', 'default_properties', 'events' ], + description: + 'VIP Tracks events the integration records. Omit this section entirely if it records none.', + properties: { + prefix: { + type: 'string', + pattern: '^[a-z0-9_]+_$', + description: 'Event name prefix, ending in an underscore, e.g. acme_widget_.', + }, + default_properties: { + type: 'array', + uniqueItems: true, + items: { type: 'string', pattern: SNAKE_PATTERN }, + description: 'Property names attached to every event.', + }, + events: { + type: 'array', + minItems: 1, + items: { $ref: '#/$defs/telemetryEvent' }, + description: 'The individual Tracks events. Declares names only, never captured values.', + }, + }, + }, + release: { + type: 'object', + additionalProperties: false, + required: [ 'plugin_version', 'version_strategy', 'migration_required', 'changelog' ], + description: 'Metadata about the submitted plugin version.', + properties: { + plugin_version: { + type: 'string', + pattern: SEMVER_PATTERN, + description: 'Semantic version matching the plugin header.', + }, + version_strategy: { + type: 'string', + pattern: SNAKE_PATTERN, + description: 'Release strategy identifier, e.g. latest or semantic_versioning.', + }, + migration_required: { + type: 'boolean', + description: 'Whether this release requires migration work.', + }, + changelog: { + type: 'string', + minLength: 1, + description: 'Short description of what this release contains.', + }, + }, + }, + }, + $defs: { + configField: { + type: 'object', + additionalProperties: false, + required: [ 'key', 'label', 'type' ], + properties: { + key: { + type: 'string', + pattern: KEY_PATTERN, + description: 'snake_case key this value is stored under in the config array.', + }, + label: { + type: 'string', + minLength: 1, + description: 'Field label shown in the Integration Center form.', + }, + type: { + enum: [ ...FIELD_TYPES ], + description: 'Input type. Use "secret" for values VIP must encrypt at rest.', + }, + required: { + type: 'boolean', + description: 'Whether the field must be filled before the integration can activate.', + }, + help: { + type: 'string', + description: 'Optional helper text shown under the field.', + }, + default: { + type: [ 'string', 'number', 'boolean' ], + description: 'Optional default value pre-filled in the form.', + }, + values: { + type: 'array', + minItems: 1, + items: { type: 'string' }, + description: 'Allowed values. Required when "type" is "enum".', + }, + autogen: { + type: 'boolean', + description: 'Whether the field is autogenerated and not editable by the customer.', + }, + note: { + type: 'string', + minLength: 1, + description: "Optional note on the field's purpose or usage. For internal use.", + }, + }, + allOf: [ + { + if: { properties: { type: { const: 'enum' } } }, + then: { required: [ 'values' ] }, + }, + ], + }, + telemetryEvent: { + type: 'object', + additionalProperties: false, + required: [ 'name', 'type', 'trigger', 'properties' ], + properties: { + name: { + type: 'string', + pattern: SNAKE_PATTERN, + description: 'Full event name, including the telemetry prefix.', + }, + type: { + enum: [ 'tracks' ], + description: 'Telemetry channel. VIP integrations use Tracks only.', + }, + trigger: { + type: 'string', + minLength: 1, + description: 'What causes the event to fire.', + }, + properties: { + type: 'array', + uniqueItems: true, + items: { type: 'string', pattern: SNAKE_PATTERN }, + description: 'Property names recorded with the event (names only, no values).', + }, + note: { + type: 'string', + minLength: 1, + description: 'Optional note on why the event exists.', + }, + }, + }, + }, +} as const; diff --git a/src/lib/validate/manifest.ts b/src/lib/validate/manifest.ts index bbf00db..5e104fc 100644 --- a/src/lib/validate/manifest.ts +++ b/src/lib/validate/manifest.ts @@ -1,84 +1,54 @@ /** * Handoff-manifest validation for VIP partner integrations. * - * The handoff manifest (`vip-handoff.yaml`) is the single file a partner fills + * The handoff manifest (`a8c-manifest.yaml`) is the single file a partner fills * in so VIP can register and load their integration from the manifest alone — * without reading the plugin's code. VIP uses it to wire up the constant-backed * loader, the integration service registration + secret sync, and the * Integration Center catalog entry and config form. * - * This module reads that file and checks that every field VIP needs for that - * registration is present and well-formed. It is deliberately a presence and - * shape check — it does not verify the values are correct (that a URL resolves, - * a secret is real), only that the manifest gives VIP everything it must have - * to register the integration without the plugin source. + * This module reads that file and validates it against `MANIFEST_SCHEMA`, the + * JSON Schema that is the single source of truth for the manifest's shape and + * constraints. It is a presence-and-shape check — it confirms the manifest + * gives VIP everything it must have, in the right form, to register the + * integration; it does not verify the values are correct (that a URL resolves, + * a secret is real). */ +import Ajv from 'ajv'; import { load } from 'js-yaml'; import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; -/** Accepted manifest file names, checked at the integration root in order. */ -export const MANIFEST_FILENAMES = [ 'vip-handoff.yaml', 'vip-handoff.yml' ]; +import { MANIFEST_PLACEHOLDER, MANIFEST_SCHEMA } from './manifest.schema'; -/** The `manifest_kind` value that identifies a handoff manifest. */ -const MANIFEST_KIND = 'vip-integration-handoff'; +import type { ErrorObject, ValidateFunction } from 'ajv'; -/** `runtime.wordpress_plugin.scope` values VIP knows how to load. */ -const VALID_SCOPES = [ 'site', 'network' ]; +/** Accepted manifest file names, checked at the integration root in order. */ +export const MANIFEST_FILENAMES = [ 'a8c-manifest.yaml', 'a8c-manifest.yml' ]; export interface ManifestInspection { /** The manifest file name found at the root, or null when none exists. */ file: string | null; /** Set when the file exists but is not parseable / not a mapping. */ parseError: string | null; - /** Required fields VIP consumes that are absent or empty. */ - missing: string[]; - /** Fields that are present but malformed (wrong shape or value). */ - problems: string[]; + /** Human-readable schema violations. Empty when the manifest is conformant. */ + errors: string[]; + /** The parsed manifest, or null when it was missing / unparseable. */ + parsed: Record< string, unknown > | null; + /** Dotted paths whose string value still contains the init placeholder. */ + placeholders: string[]; } -/** Required scalar paths, as dotted keys into the parsed manifest. */ -const REQUIRED_FIELDS = [ - 'manifest_version', - 'manifest_kind', - 'integration.slug', - 'integration.display_name', - 'integration.summary', - 'integration.partner.name', - 'integration.partner.support_contact', - 'runtime.wordpress_plugin.folder', - 'runtime.wordpress_plugin.entry_file', - 'runtime.wordpress_plugin.php_namespace', - 'runtime.wordpress_plugin.scope', - 'runtime_config.constant_name', -]; +// One compiled validator, reused across every inspection. allowUnionTypes lets +// a field `default` accept a string, number, or boolean. +const ajv = new Ajv( { allErrors: true, allowUnionTypes: true } ); +const validateManifest: ValidateFunction = ajv.compile( MANIFEST_SCHEMA ); function isRecord( value: unknown ): value is Record< string, unknown > { return typeof value === 'object' && value !== null && ! Array.isArray( value ); } -/** Read a dotted path out of a parsed manifest, or undefined if any hop misses. */ -function getPath( root: Record< string, unknown >, path: string ): unknown { - let current: unknown = root; - for ( const key of path.split( '.' ) ) { - if ( ! isRecord( current ) ) { - return undefined; - } - current = current[ key ]; - } - return current; -} - -/** A scalar counts as present only when it is not null/undefined and, for - * strings, not blank. */ -function isPresent( value: unknown ): boolean { - if ( value === undefined || value === null ) { - return false; - } - return typeof value === 'string' ? value.trim() !== '' : true; -} - function findManifestFile( root: string ): string | null { for ( const name of MANIFEST_FILENAMES ) { if ( existsSync( join( root, name ) ) ) { @@ -88,38 +58,58 @@ function findManifestFile( root: string ): string | null { return null; } -/** Check the shape of `runtime_config.fields`, appending any issues found. */ -function inspectConfigFields( manifest: Record< string, unknown >, problems: string[] ): void { - const fields = getPath( manifest, 'runtime_config.fields' ); - if ( fields === undefined ) { - problems.push( 'runtime_config.fields is missing — declare at least one config field.' ); +/** The dotted manifest location an Ajv error points at, e.g. `integration.slug`. */ +function locationOf( error: ErrorObject ): string { + const path = error.instancePath.replace( /^\//, '' ).replace( /\//g, '.' ); + return path === '' ? 'manifest' : path; +} + +/** Turn one Ajv error into a message a partner can act on. */ +function describeError( error: ErrorObject ): string { + const where = locationOf( error ); + switch ( error.keyword ) { + case 'required': + return `${ where } is missing required field "${ error.params.missingProperty }".`; + case 'additionalProperties': + return `${ where } has unknown field "${ error.params.additionalProperty }".`; + case 'const': + return `${ where } must be ${ JSON.stringify( error.params.allowedValue ) }.`; + case 'enum': + return `${ where } must be one of: ${ ( error.params.allowedValues as unknown[] ).join( + ', ' + ) }.`; + case 'pattern': + return `${ where } is malformed (must match ${ error.params.pattern }).`; + case 'minItems': + return `${ where } must have at least ${ error.params.limit } item(s).`; + default: + return `${ where } ${ error.message ?? 'is invalid' }.`; + } +} + +// The sentinel as a whole token, so a real value that merely embeds it as part +// of a longer word (`REPLACE_ME_TOKEN`) isn't flagged as an unfilled placeholder. +// MANIFEST_PLACEHOLDER is a fixed alphabetic sentinel, so interpolation is safe. +// eslint-disable-next-line security/detect-non-literal-regexp +const PLACEHOLDER_TOKEN = new RegExp( String.raw`\b${ MANIFEST_PLACEHOLDER }\b` ); + +/** Collect dotted paths whose string value still holds the init placeholder. */ +function collectPlaceholders( value: unknown, path: string, out: string[] ): void { + if ( typeof value === 'string' ) { + if ( PLACEHOLDER_TOKEN.test( value ) ) { + out.push( path ); + } return; } - if ( ! Array.isArray( fields ) || fields.length === 0 ) { - problems.push( 'runtime_config.fields must be a non-empty list of config fields.' ); + if ( Array.isArray( value ) ) { + value.forEach( ( item, index ) => collectPlaceholders( item, `${ path }[${ index }]`, out ) ); return; } - - fields.forEach( ( field, index ) => { - const label = `runtime_config.fields[${ index }]`; - if ( ! isRecord( field ) ) { - problems.push( `${ label } is not a mapping.` ); - return; + if ( isRecord( value ) ) { + for ( const [ key, child ] of Object.entries( value ) ) { + collectPlaceholders( child, path === '' ? key : `${ path }.${ key }`, out ); } - for ( const key of [ 'key', 'label', 'type' ] as const ) { - if ( ! isPresent( field[ key ] ) ) { - problems.push( `${ label } is missing "${ key }".` ); - } - } - // An enum field is unusable in the Integration Center form without its - // allowed values, so require them explicitly. - if ( field.type === 'enum' ) { - const values = field.values; - if ( ! Array.isArray( values ) || values.length === 0 ) { - problems.push( `${ label } is an enum but declares no "values".` ); - } - } - } ); + } } /** @@ -129,7 +119,7 @@ function inspectConfigFields( manifest: Record< string, unknown >, problems: str export function inspectManifest( root: string ): ManifestInspection { const file = findManifestFile( root ); if ( ! file ) { - return { file: null, parseError: null, missing: [], problems: [] }; + return { file: null, parseError: null, errors: [], parsed: null, placeholders: [] }; } let parsed: unknown; @@ -137,34 +127,26 @@ export function inspectManifest( root: string ): ManifestInspection { parsed = load( readFileSync( join( root, file ), 'utf8' ) ); } catch ( error ) { const reason = error instanceof Error ? error.message.split( '\n' )[ 0 ] : String( error ); - return { file, parseError: reason, missing: [], problems: [] }; + return { file, parseError: reason, errors: [], parsed: null, placeholders: [] }; } if ( ! isRecord( parsed ) ) { - return { file, parseError: 'manifest is not a YAML mapping', missing: [], problems: [] }; + return { + file, + parseError: 'manifest is not a YAML mapping', + errors: [], + parsed: null, + placeholders: [], + }; } - const missing = REQUIRED_FIELDS.filter( path => ! isPresent( getPath( parsed, path ) ) ); + const placeholders: string[] = []; + collectPlaceholders( parsed, '', placeholders ); - const problems: string[] = []; - const kind = getPath( parsed, 'manifest_kind' ); - if ( isPresent( kind ) && kind !== MANIFEST_KIND ) { - problems.push( `manifest_kind must be "${ MANIFEST_KIND }" (got "${ String( kind ) }").` ); - } - const scope = getPath( parsed, 'runtime.wordpress_plugin.scope' ); - if ( isPresent( scope ) && ! VALID_SCOPES.includes( String( scope ) ) ) { - problems.push( - `runtime.wordpress_plugin.scope must be one of ${ VALID_SCOPES.join( '/' ) } (got "${ String( - scope - ) }").` - ); - } - const constant = getPath( parsed, 'runtime_config.constant_name' ); - if ( isPresent( constant ) && ! /^VIP_[A-Z0-9_]+_CONFIG$/.test( String( constant ) ) ) { - problems.push( - `runtime_config.constant_name must match VIP_*_CONFIG (got "${ String( constant ) }").` - ); + if ( validateManifest( parsed ) ) { + return { file, parseError: null, errors: [], parsed, placeholders }; } - inspectConfigFields( parsed, problems ); - return { file, parseError: null, missing, problems }; + // De-duplicate: the `enum`/`if` combo can surface the same underlying issue twice. + const errors = [ ...new Set( ( validateManifest.errors ?? [] ).map( describeError ) ) ]; + return { file, parseError: null, errors, parsed, placeholders }; } diff --git a/src/lib/validate/validate.ts b/src/lib/validate/validate.ts index 53c792f..4144d4b 100644 --- a/src/lib/validate/validate.ts +++ b/src/lib/validate/validate.ts @@ -16,6 +16,7 @@ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; import { extname, join } from 'node:path'; import { MANIFEST_FILENAMES, inspectManifest } from './manifest'; +import { MANIFEST_PLACEHOLDER } from './manifest.schema'; import type { ManifestInspection } from './manifest'; @@ -478,6 +479,113 @@ function checkComposerTest( ctx: Context ): CheckResult { }; } +/** + * Strip PHP comments so a `REQUIRED_FIELDS` / `SENSITIVE_FIELDS` mention inside a + * `//`, `#`, or block comment isn't read as the real config contract. Best-effort + * and does not track string literals — fine for reading a `const` array. The `//` + * and `#` passes require a non-`:` / whitespace lead-in so a `https://` or `#fff` + * inside a string isn't mistaken for a comment start. + */ +function stripPhpComments( source: string ): string { + return source + .replace( /\/\*[\s\S]*?\*\//g, '' ) + .replace( /(^|[^:])\/\/[^\n]*/g, '$1' ) + .replace( /(^|\s)#[^\n]*/g, '$1' ); +} + +/** + * Pull a PHP `const NAME = [ 'a', 'b' ]` string array out of the source. Used to + * read the config contract the Starter Kit's Config class declares. Anchored on a + * `const ` declaration with a word boundary, so an unrelated constant whose + * name merely ends in `` (e.g. `CUSTOM_REQUIRED_FIELDS`) is not mistaken for + * it. Keys may be single- or double-quoted. + */ +function phpConstStringArray( source: string, constName: string ): string[] { + // constName is a fixed alphabetic keyword, so interpolation is safe. + // eslint-disable-next-line security/detect-non-literal-regexp + const re = new RegExp( String.raw`\bconst\s+${ constName }\s*=\s*\[([^\]]*)\]` ); + const match = re.exec( source ); + if ( ! match ) { + return []; + } + return [ ...match[ 1 ].matchAll( /['"]([a-z0-9_]+)['"]/g ) ].map( entry => entry[ 1 ] ); +} + +interface ManifestField { + type?: string; + required: boolean; +} + +/** Index the manifest's declared `runtime_config.fields` by key. */ +function manifestConfigFields( + parsed: Record< string, unknown > | null +): Map< string, ManifestField > { + const byKey = new Map< string, ManifestField >(); + const runtimeConfig = parsed?.runtime_config; + const fields = + runtimeConfig && typeof runtimeConfig === 'object' + ? ( runtimeConfig as Record< string, unknown > ).fields + : undefined; + if ( ! Array.isArray( fields ) ) { + return byKey; + } + for ( const field of fields ) { + if ( field && typeof field === 'object' ) { + const record = field as Record< string, unknown >; + if ( typeof record.key === 'string' ) { + byKey.set( record.key, { + type: typeof record.type === 'string' ? record.type : undefined, + required: record.required === true, + } ); + } + } + } + return byKey; +} + +/** + * Cross-check the config keys the plugin declares (`Config::REQUIRED_FIELDS` and + * `Config::SENSITIVE_FIELDS`) against the manifest's `runtime_config.fields`, so + * a field the code reads from the config constant can't be missing from — or + * mis-typed in — the manifest VIP registers from. Deterministic for integrations + * following the Starter Kit Config convention; skipped (empty) for any plugin + * that declares neither array. + */ +function configFieldMismatches( ctx: Context, fields: Map< string, ManifestField > ): string[] { + const source = stripPhpComments( ctx.phpSource ); + const required = phpConstStringArray( source, 'REQUIRED_FIELDS' ); + const sensitive = phpConstStringArray( source, 'SENSITIVE_FIELDS' ); + const issues: string[] = []; + + for ( const key of required ) { + const field = fields.get( key ); + if ( ! field ) { + issues.push( + `Config field "${ key }" is required by the plugin (Config::REQUIRED_FIELDS) but is not declared in runtime_config.fields.` + ); + } else if ( ! field.required ) { + issues.push( + `Config field "${ key }" is required by the plugin but is not marked "required: true" in the manifest.` + ); + } + } + for ( const key of sensitive ) { + const field = fields.get( key ); + if ( ! field ) { + issues.push( + `Config field "${ key }" is a secret (Config::SENSITIVE_FIELDS) but is not declared in runtime_config.fields.` + ); + } else if ( field.type !== 'secret' ) { + issues.push( + `Config field "${ key }" holds a secret but is declared as type "${ + field.type ?? 'unset' + }" instead of "secret" in the manifest.` + ); + } + } + return issues; +} + function checkHandoffManifest( ctx: Context ): CheckResult { const base = { id: 'handoff-manifest', @@ -506,15 +614,31 @@ function checkHandoffManifest( ctx: Context ): CheckResult { }; } - const issues = [ - ...manifest.missing.map( field => `Missing required field: ${ field }` ), - ...manifest.problems, + if ( manifest.errors.length > 0 ) { + return { + ...base, + status: 'fail', + message: `${ manifest.file } does not match the manifest schema — VIP cannot register the integration from it as-is.`, + details: manifest.errors, + }; + } + + // Beyond schema shape: the manifest must have no unfilled init placeholders, + // and its config fields must cover the keys the plugin reads from the config + // constant. Both are gathered together so one run reports every gap. + const issues: string[] = [ + ...manifest.placeholders.map( + path => + `${ path } still contains the "${ MANIFEST_PLACEHOLDER }" placeholder — replace it with your integration's value.` + ), + ...configFieldMismatches( ctx, manifestConfigFields( manifest.parsed ) ), ]; + if ( issues.length > 0 ) { return { ...base, status: 'fail', - message: `${ manifest.file } is incomplete — VIP cannot register the integration from it as-is.`, + message: `${ manifest.file } is incomplete — resolve the following before submitting.`, details: issues, }; } @@ -522,9 +646,9 @@ function checkHandoffManifest( ctx: Context ): CheckResult { return { ...base, status: 'pass', - message: `${ manifest.file } declares every field VIP needs to register and load the integration.`, + message: `${ manifest.file } is schema-valid, placeholder-free, and its config fields match the plugin.`, details: [ - 'Static check: it confirms the required fields are present and well-formed, not that their values are correct (that is confirmed in human review).', + 'Static check: it confirms the manifest is present, well-formed, and covers the config the code reads, not that the values themselves are correct (that is confirmed in human review).', ], }; }