From 22bcfb046eecbd9562bf5a6c774f57186094abf5 Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Tue, 28 Jul 2026 19:39:26 +0600 Subject: [PATCH 1/7] Validate the handoff manifest against a JSON Schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add manifest.schema.ts — a JSON Schema covering integration identity, documentation, plugin runtime, the runtime-config fields (types, patterns, enums, autogen/note), telemetry, and release — and validate the manifest against it with Ajv, replacing the hand-rolled presence checks. Rule 3 also rejects unfilled init placeholders and cross-checks the config keys the plugin declares (Config::REQUIRED_FIELDS / SENSITIVE_FIELDS) against runtime_config.fields. Renames the manifest convention to a8c-manifest.yaml. --- __tests__/validate.test.ts | 257 ++++++++++++++++++++- docs/architecture.md | 9 +- package.json | 1 + pnpm-lock.yaml | 29 +++ src/lib/validate/manifest.schema.ts | 332 ++++++++++++++++++++++++++++ src/lib/validate/manifest.ts | 182 +++++++-------- src/lib/validate/validate.ts | 113 +++++++++- 7 files changed, 805 insertions(+), 118 deletions(-) create mode 100644 src/lib/validate/manifest.schema.ts diff --git a/__tests__/validate.test.ts b/__tests__/validate.test.ts index 6a679fb..7c6f6da 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,234 @@ 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( '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' ), + " { 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/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..2eefd14 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,52 @@ 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' }.`; + } +} + +/** Collect dotted paths whose string value contains the init placeholder. */ +function collectPlaceholders( value: unknown, path: string, out: string[] ): void { + if ( typeof value === 'string' ) { + if ( value.includes( MANIFEST_PLACEHOLDER ) ) { + 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 +113,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 +121,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..53eca74 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,90 @@ function checkComposerTest( ctx: Context ): CheckResult { }; } +/** 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. */ +function phpConstStringArray( source: string, re: RegExp ): string[] { + 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 required = phpConstStringArray( ctx.phpSource, /REQUIRED_FIELDS\s*=\s*\[([^\]]*)\]/ ); + const sensitive = phpConstStringArray( ctx.phpSource, /SENSITIVE_FIELDS\s*=\s*\[([^\]]*)\]/ ); + 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 +591,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 +623,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).', ], }; } From 1a2267d9af8fa3ef7f9f0ff6ea2bcd3fd74f50da Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Tue, 28 Jul 2026 19:39:26 +0600 Subject: [PATCH 2/7] Scaffold a ready-to-fill manifest on init init fills the manifest fields it can derive from the integration name (summary, release.changelog), seeds the partner-only fields it cannot (support contact, documentation URLs) with a REPLACE_ME placeholder so validate fails until they are filled, and leaves the scaffold as a plain directory rather than initializing a git repo. --- README.md | 2 +- __tests__/scaffold.test.ts | 37 ++++++++++++++++++++++++++++++++ src/commands/init.ts | 9 ++------ src/lib/scaffold/scaffold.ts | 41 ++++++++++++++++++++++++++++++++++++ 4 files changed, 81 insertions(+), 8 deletions(-) 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/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..cba54a5 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -20,6 +20,8 @@ import { } from 'node:fs'; import { join } from 'node:path'; +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 +235,50 @@ export function scaffoldTree( root: string, vendor: string, name: string ): Scaf renameSync( exampleEntry, join( root, entryFile ) ); } + personalizeManifest( root, prefix ); removeRedundantScaffolder( root ); return { changed, entryFile, prefix }; } +/** Handoff-manifest filenames the Starter Kit may ship, in priority order. */ +const MANIFEST_FILENAMES = [ 'a8c-manifest.yaml', 'a8c-manifest.yml' ]; + +/** + * 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 From c9b7f5b5f6a6d21bcf9f2c4808f82e24e26d6b7a Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Tue, 28 Jul 2026 21:13:35 +0600 Subject: [PATCH 3/7] Stop the config cross-check from failing conformant integrations The Rule 3 config cross-check read the plugin's config contract with a substring regex over the raw, concatenated PHP source. A REQUIRED_FIELDS or SENSITIVE_FIELDS mention inside a comment, or an unrelated constant whose name merely ended in one of those, was taken as the real contract and a conformant integration was reported non-conformant. Strip PHP comments first, anchor on a real `const ` declaration, and accept single- or double-quoted keys. Also match the manifest placeholder as a whole token so a value that only embeds REPLACE_ME isn't flagged. --- __tests__/validate.test.ts | 60 ++++++++++++++++++++++++++++++++++++ src/lib/validate/manifest.ts | 10 ++++-- src/lib/validate/validate.ts | 35 +++++++++++++++++---- 3 files changed, 97 insertions(+), 8 deletions(-) diff --git a/__tests__/validate.test.ts b/__tests__/validate.test.ts index 7c6f6da..799d184 100644 --- a/__tests__/validate.test.ts +++ b/__tests__/validate.test.ts @@ -547,6 +547,21 @@ describe( 'validateIntegration', () => { 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 } ); @@ -591,6 +606,51 @@ describe( 'validateIntegration', () => { expect( statusById( root )[ 'handoff-manifest' ] ).toBe( 'pass' ); } ); + it( 'ignores a REQUIRED_FIELDS mention that lives only in a PHP comment', () => { + 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', () => { const root = join( dir, 'no-ci' ); mkdirSync( join( root, 'docs' ), { recursive: true } ); diff --git a/src/lib/validate/manifest.ts b/src/lib/validate/manifest.ts index 2eefd14..5e104fc 100644 --- a/src/lib/validate/manifest.ts +++ b/src/lib/validate/manifest.ts @@ -87,10 +87,16 @@ function describeError( error: ErrorObject ): string { } } -/** Collect dotted paths whose string value contains the init placeholder. */ +// 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 ( value.includes( MANIFEST_PLACEHOLDER ) ) { + if ( PLACEHOLDER_TOKEN.test( value ) ) { out.push( path ); } return; diff --git a/src/lib/validate/validate.ts b/src/lib/validate/validate.ts index 53eca74..4144d4b 100644 --- a/src/lib/validate/validate.ts +++ b/src/lib/validate/validate.ts @@ -479,14 +479,36 @@ function checkComposerTest( ctx: Context ): CheckResult { }; } -/** 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. */ -function phpConstStringArray( source: string, re: RegExp ): string[] { +/** + * 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 ] ); + return [ ...match[ 1 ].matchAll( /['"]([a-z0-9_]+)['"]/g ) ].map( entry => entry[ 1 ] ); } interface ManifestField { @@ -530,8 +552,9 @@ function manifestConfigFields( * that declares neither array. */ function configFieldMismatches( ctx: Context, fields: Map< string, ManifestField > ): string[] { - const required = phpConstStringArray( ctx.phpSource, /REQUIRED_FIELDS\s*=\s*\[([^\]]*)\]/ ); - const sensitive = phpConstStringArray( ctx.phpSource, /SENSITIVE_FIELDS\s*=\s*\[([^\]]*)\]/ ); + 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 ) { From 4c9db3f0a2066cc839f0366353b0559e54ed3336 Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Tue, 28 Jul 2026 21:13:44 +0600 Subject: [PATCH 4/7] Share one MANIFEST_FILENAMES list between validate and init scaffold.ts kept a private copy of the manifest filenames that had to stay identical to the exported list in validate/manifest.ts or init and validate would disagree on what a manifest is called. Import the one source of truth instead. --- src/lib/scaffold/scaffold.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index cba54a5..4509614 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -20,6 +20,7 @@ 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 @@ -241,9 +242,6 @@ export function scaffoldTree( root: string, vendor: string, name: string ): Scaf return { changed, entryFile, prefix }; } -/** Handoff-manifest filenames the Starter Kit may ship, in priority order. */ -const MANIFEST_FILENAMES = [ 'a8c-manifest.yaml', 'a8c-manifest.yml' ]; - /** * Rewrite the handoff manifest for a fresh scaffold. Two jobs: * From 8f0b90a0b023c785f3d27bb77176e6ce81b813cf Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Wed, 29 Jul 2026 19:45:35 +0600 Subject: [PATCH 5/7] Rename a8c-integration CLI to vip-integration --- .prettierignore | 2 +- README.md | 22 +++++++++++----------- bin/{a8c-integration => vip-integration} | 0 docs/architecture.md | 4 ++-- docs/releasing.md | 8 ++++---- docs/setup.md | 16 ++++++++-------- package.json | 4 ++-- src/cli.ts | 4 ++-- src/commands/init.ts | 6 +++--- src/commands/validate.ts | 2 +- src/lib/scaffold/scaffold.ts | 6 +++--- src/lib/validate/manifest.schema.ts | 2 +- 12 files changed, 38 insertions(+), 38 deletions(-) rename bin/{a8c-integration => vip-integration} (100%) diff --git a/.prettierignore b/.prettierignore index 35af0f3..ecf5235 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,4 @@ dist/ coverage/ pnpm-lock.yaml -bin/a8c-integration +bin/vip-integration diff --git a/README.md b/README.md index 2515a9e..e1100e0 100644 --- a/README.md +++ b/README.md @@ -1,22 +1,22 @@ -# a8c-integration +# vip-integration A CLI for building **WordPress VIP Integration Center** add-ons. It does two things: -- **`a8c-integration init`** — scaffold a new integration from the [VIP Integrations Starter Kit](https://github.com/Automattic/vip-integrations-starter-kit), with the example prefix set already rewritten to your names. -- **`a8c-integration validate`** — run the integration conformance checker locally and in CI, so you get an objective _conformant / not-conformant_ answer before you submit. +- **`vip-integration init`** — scaffold a new integration from the [VIP Integrations Starter Kit](https://github.com/Automattic/vip-integrations-starter-kit), with the example prefix set already rewritten to your names. +- **`vip-integration validate`** — run the integration conformance checker locally and in CI, so you get an objective _conformant / not-conformant_ answer before you submit. It is a standalone home for the checker that used to live in `vip-cli`, decoupled so the Integration Center can extend to other parts of Automattic (.com / a4a). ## Install ```bash -npm install -g @automattic/a8c-integration +npm install -g @automattic/vip-integration ``` Or run without installing: ```bash -npx @automattic/a8c-integration validate +npx @automattic/vip-integration validate ``` Requires Node.js 20+. @@ -26,13 +26,13 @@ Requires Node.js 20+. ### Start a new integration ```bash -a8c-integration init +vip-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, and renames the entry file. You can also pass the answers as flags: ```bash -a8c-integration init --vendor "WordPress" --name "Content Sync" +vip-integration init --vendor "WordPress" --name "Content Sync" ``` | Flag | Description | @@ -49,15 +49,15 @@ When it finishes: cd content-sync composer install && npm install # edit your integration, then: -a8c-integration validate +vip-integration validate ``` ### Validate an integration ```bash -a8c-integration validate # checks the current directory -a8c-integration validate ./my-plugin # checks a given directory -a8c-integration validate --format json # machine-readable output for CI +vip-integration validate # checks the current directory +vip-integration validate ./my-plugin # checks a given directory +vip-integration validate --format json # machine-readable output for CI ``` The checker runs nine static conformance rules and prints a per-rule report. It exits `1` when the integration is **not conformant** (any rule failed), so it gates CI. Warnings do not break conformance. Two items — the plugin/platform config-schema match and the security review — are surfaced as _human review required_ rather than automated pass/fail, because they cannot be checked statically. diff --git a/bin/a8c-integration b/bin/vip-integration similarity index 100% rename from bin/a8c-integration rename to bin/vip-integration diff --git a/docs/architecture.md b/docs/architecture.md index 3ab7613..ace022f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,11 +1,11 @@ # Architecture -`a8c-integration` is a small TypeScript CLI. The design goal is that each piece is independently testable and free of framework glue, so the conformance logic and the scaffolding logic can be exercised without spawning the CLI. +`vip-integration` is a small TypeScript CLI. The design goal is that each piece is independently testable and free of framework glue, so the conformance logic and the scaffolding logic can be exercised without spawning the CLI. ## Layout ``` -bin/a8c-integration Launcher shim: requires dist/cli.js and calls run() +bin/vip-integration Launcher shim: requires dist/cli.js and calls run() src/ cli.ts Commander wiring: defines `init` and `validate` commands/ diff --git a/docs/releasing.md b/docs/releasing.md index 15c1e6f..678d700 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,12 +1,12 @@ # Releasing -`a8c-integration` publishes to npm as [`@automattic/a8c-integration`](https://www.npmjs.com/package/@automattic/a8c-integration). Releases are cut from `trunk` and **published by CI, not from a laptop** — the flow mirrors [Automattic/commands](https://github.com/Automattic/commands): bump the version on a release branch, merge it, then trigger the publish workflow, which uses npm **trusted publishing** (OIDC — no long-lived token). +`vip-integration` publishes to npm as [`@automattic/vip-integration`](https://www.npmjs.com/package/@automattic/vip-integration). Releases are cut from `trunk` and **published by CI, not from a laptop** — the flow mirrors [Automattic/commands](https://github.com/Automattic/commands): bump the version on a release branch, merge it, then trigger the publish workflow, which uses npm **trusted publishing** (OIDC — no long-lived token). ## What ships The `files` allowlist in `package.json` limits the published tarball to what a consumer needs: -- `bin/a8c-integration` — the launcher shim +- `bin/vip-integration` — the launcher shim - `dist/` — the compiled CLI - `README.md` and `docs/` @@ -45,8 +45,8 @@ Before the first release, a repo/npm admin needs to: ## Verify ```bash -npm view @automattic/a8c-integration version -npx @automattic/a8c-integration@latest --version +npm view @automattic/vip-integration version +npx @automattic/vip-integration@latest --version ``` ## Notes diff --git a/docs/setup.md b/docs/setup.md index c86c35b..077a609 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -1,6 +1,6 @@ # Setup -How to build and run `a8c-integration` from source. +How to build and run `vip-integration` from source. ## Prerequisites @@ -27,17 +27,17 @@ The CLI is written in TypeScript and compiles to `dist/` with `tsc`: pnpm build ``` -`bin/a8c-integration` is a thin launcher that requires the compiled `dist/cli.js`, so after a build you can run: +`bin/vip-integration` is a thin launcher that requires the compiled `dist/cli.js`, so after a build you can run: ```bash -node bin/a8c-integration --help +node bin/vip-integration --help ``` -To link it as a global `a8c-integration` command while developing: +To link it as a global `vip-integration` command while developing: ```bash pnpm build && pnpm link --global -a8c-integration --help +vip-integration --help ``` ## Type-check, lint, and format @@ -54,8 +54,8 @@ pnpm format # Prettier (wp-prettier) — write; `pnpm format:check` to run clones it from GitHub: ```bash -node bin/a8c-integration init --vendor "WordPress" --name "Content Sync" --dir /tmp/content-sync -node bin/a8c-integration validate /tmp/content-sync +node bin/vip-integration init --vendor "WordPress" --name "Content Sync" --dir /tmp/content-sync +node bin/vip-integration validate /tmp/content-sync ``` ### Offline / test override @@ -67,7 +67,7 @@ variable: ```bash A8C_STARTER_KIT_SOURCE=/path/to/vip-integrations-starter-kit \ - node bin/a8c-integration init --vendor "WordPress" --name "Content Sync" --dir /tmp/content-sync + node bin/vip-integration init --vendor "WordPress" --name "Content Sync" --dir /tmp/content-sync ``` This override is for development only; it is not part of the public interface. diff --git a/package.json b/package.json index e839ac5..f8a8d24 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "@automattic/a8c-integration", + "name": "@automattic/vip-integration", "version": "0.1.0", "description": "CLI to scaffold and validate WordPress VIP Integration Center add-ons.", "keywords": [ @@ -21,7 +21,7 @@ "author": "Automattic Inc.", "license": "MIT", "bin": { - "a8c-integration": "bin/a8c-integration" + "vip-integration": "bin/vip-integration" }, "files": [ "bin", diff --git a/src/cli.ts b/src/cli.ts index 5db847d..0592465 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,5 @@ /** - * a8c-integration — CLI to scaffold and validate WordPress VIP Integration + * vip-integration — CLI to scaffold and validate WordPress VIP Integration * Center add-ons. Two commands: `init` (start a new integration from the * Starter Kit) and `validate` (check an integration for conformance). */ @@ -30,7 +30,7 @@ export function run( argv: string[] = process.argv ): void { const program = new Command(); program - .name( 'a8c-integration' ) + .name( 'vip-integration' ) .description( 'Scaffold and validate WordPress VIP Integration Center add-ons.' ) .version( version(), '-v, --version' ) // Drop the built-in `help ` subcommand — ` --help` covers it. diff --git a/src/commands/init.ts b/src/commands/init.ts index 7ad4e2b..9857e65 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,10 +1,10 @@ /** - * `a8c-integration init` — start a new integration. + * `vip-integration init` — start a new integration. * * Lays down the VIP Integrations Starter Kit as the project skeleton, collects * the same inputs `composer setup` collects (vendor + integration name), then * rewrites the example prefix set to the partner's names. The result is a - * ready-to-edit integration; the developer runs `a8c-integration validate` when + * ready-to-edit integration; the developer runs `vip-integration validate` when * they are ready to check conformance. */ @@ -128,7 +128,7 @@ export async function initCommand( opts: InitOptions = {} ): Promise< void > { `cd ${ target }`, 'composer install && npm install', 'Run it locally: vip dev-env create && vip dev-env start', - 'Edit the integration, then run: a8c-integration validate', + 'Edit the integration, then run: vip-integration validate', ] ) { console.log( ` ${ cyan( '→' ) } ${ step }` ); } diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 8e4c2e2..740f264 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,5 +1,5 @@ /** - * `a8c-integration validate [path]` — run the conformance checker against an + * `vip-integration validate [path]` — run the conformance checker against an * integration directory and print a human or JSON report. Exit code is 1 when * the integration is not conformant, so it gates CI. */ diff --git a/src/lib/scaffold/scaffold.ts b/src/lib/scaffold/scaffold.ts index 4509614..67df523 100644 --- a/src/lib/scaffold/scaffold.ts +++ b/src/lib/scaffold/scaffold.ts @@ -1,5 +1,5 @@ /** - * Scaffolding transforms for `a8c-integration init`. + * Scaffolding transforms for `vip-integration init`. * * Ports the Starter Kit's `composer setup` rewrite (bin/setup.php) into * TypeScript so scaffolding needs no PHP. It rewrites the example prefix set @@ -250,7 +250,7 @@ export function scaffoldTree( root: string, vendor: string, name: string ): Scaf * `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 + * `vip-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. * @@ -278,7 +278,7 @@ function personalizeManifest( root: string, prefix: PrefixSet ): void { } /** - * `a8c-integration init` replaces the Starter Kit's own `composer setup`, so the + * `vip-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 * generated integration — and re-running them would re-mangle the prefix set. * Drop both, leaving nothing that dangles. diff --git a/src/lib/validate/manifest.schema.ts b/src/lib/validate/manifest.schema.ts index 26dacb0..2482fa4 100644 --- a/src/lib/validate/manifest.schema.ts +++ b/src/lib/validate/manifest.schema.ts @@ -21,7 +21,7 @@ export const MANIFEST_KIND = 'vip-integration-handoff'; /** - * Sentinel `a8c-integration init` leaves in the manifest fields a partner must + * Sentinel `vip-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. From 5e64e5c0d03297b7f84c28df15ca49952c2ddc86 Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Wed, 29 Jul 2026 19:46:09 +0600 Subject: [PATCH 6/7] Rename handoff manifest to vip-manifest --- __tests__/scaffold.test.ts | 8 ++++---- __tests__/validate.test.ts | 32 ++++++++++++++--------------- docs/architecture.md | 4 ++-- src/lib/validate/manifest.schema.ts | 6 +++--- src/lib/validate/manifest.ts | 4 ++-- 5 files changed, 27 insertions(+), 27 deletions(-) diff --git a/__tests__/scaffold.test.ts b/__tests__/scaffold.test.ts index 8327e9a..87577c2 100644 --- a/__tests__/scaffold.test.ts +++ b/__tests__/scaffold.test.ts @@ -144,9 +144,9 @@ describe( 'scaffoldTree', () => { const root = join( dir, 'manifest' ); mkdirSync( root, { recursive: true } ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), [ - '# yaml-language-server: $schema=./a8c-manifest.schema.json', + '# yaml-language-server: $schema=./vip-manifest.schema.json', 'integration:', ' slug: example-integration', ' summary: Reference integration built from the VIP Integrations Starter Kit.', @@ -163,7 +163,7 @@ describe( 'scaffoldTree', () => { scaffoldTree( root, 'Acme', 'Content Sync' ); - const manifest = readFileSync( join( root, 'a8c-manifest.yaml' ), 'utf8' ); + const manifest = readFileSync( join( root, 'vip-manifest.yaml' ), 'utf8' ); // Derivable fields get real values. expect( manifest ).toContain( 'summary: Content Sync integration for WordPress VIP.' ); expect( manifest ).toContain( 'changelog: Initial release.' ); @@ -172,7 +172,7 @@ describe( 'scaffoldTree', () => { 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' ); + expect( manifest ).toContain( '# yaml-language-server: $schema=./vip-manifest.schema.json' ); // The token pass still ran: the example slug was rewritten. expect( manifest ).toContain( 'slug: content-sync' ); } ); diff --git a/__tests__/validate.test.ts b/__tests__/validate.test.ts index 799d184..29485b3 100644 --- a/__tests__/validate.test.ts +++ b/__tests__/validate.test.ts @@ -90,7 +90,7 @@ function scaffoldConformant( root: string ): void { ` { const root = join( dir, 'manifest-missing' ); mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); - rmSync( join( root, 'a8c-manifest.yaml' ) ); + rmSync( join( root, 'vip-manifest.yaml' ) ); expect( statusById( root )[ 'handoff-manifest' ] ).toBe( 'fail' ); } ); @@ -337,7 +337,7 @@ describe( 'validateIntegration', () => { scaffoldConformant( root ); // Drop runtime_config.constant_name — VIP needs it to define the config. writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( ' constant_name: VIP_ACME_WIDGET_CONFIG\n', '' ) ); @@ -353,7 +353,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( /\n {6}values:[\s\S]*$/, '' ) ); @@ -369,7 +369,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( ' entry_file:', ' entryfile:' ) ); @@ -385,7 +385,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'VIP_ACME_WIDGET_CONFIG', 'ACME_WIDGET' ) ); @@ -401,7 +401,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'vip-integration-handoff', 'something-else' ) ); @@ -417,7 +417,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'documentation:\n public_url: https://acme.example/docs/widget\n support_url: https://acme.example/docs/widget/support\n', '' @@ -436,7 +436,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'https://acme.example/docs/widget/support', 'not-a-url' ) ); @@ -452,7 +452,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'prefix: acme_widget_', 'prefix: acme_widget' ) ); @@ -468,7 +468,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-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', '' @@ -483,7 +483,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'plugin_version: 1.0.0', 'plugin_version: v1' ) ); @@ -499,7 +499,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-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.' @@ -514,7 +514,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( ' required: true\n - key: sync_mode', ' required: true\n autogen: not-a-bool\n - key: sync_mode' @@ -533,7 +533,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'support_contact: support@acme.example', 'support_contact: REPLACE_ME' @@ -552,7 +552,7 @@ describe( 'validateIntegration', () => { mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); writeFileSync( - join( root, 'a8c-manifest.yaml' ), + join( root, 'vip-manifest.yaml' ), conformantManifest().replace( 'changelog: Initial release.', 'changelog: Removed the REPLACE_ME_TOKEN debug flag.' diff --git a/docs/architecture.md b/docs/architecture.md index ace022f..315a228 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -15,7 +15,7 @@ src/ colors.ts Tiny ANSI helper (chalk-shaped, dependency-free) validate/ validate.ts Nine conformance checks (pure, fs-only) - manifest.ts Handoff-manifest (a8c-manifest.yaml) validation + manifest.ts Handoff-manifest (vip-manifest.yaml) validation manifest.schema.ts JSON Schema: the manifest's fields and constraints report.ts Human and JSON rendering of a report scaffold/ @@ -33,7 +33,7 @@ __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** (`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. +One rule validates the **handoff manifest** (`vip-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 `vip-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. diff --git a/src/lib/validate/manifest.schema.ts b/src/lib/validate/manifest.schema.ts index 2482fa4..a2fcb5c 100644 --- a/src/lib/validate/manifest.schema.ts +++ b/src/lib/validate/manifest.schema.ts @@ -1,11 +1,11 @@ /** - * JSON Schema for the VIP integration handoff manifest (`a8c-manifest.yaml`). + * JSON Schema for the VIP integration handoff manifest (`vip-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. + * `vip-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 @@ -51,7 +51,7 @@ const HTTP_URL_PATTERN = '^https?://.+'; 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', + $id: 'https://automattic.github.io/vip-integration/vip-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.', diff --git a/src/lib/validate/manifest.ts b/src/lib/validate/manifest.ts index 5e104fc..7b0ae10 100644 --- a/src/lib/validate/manifest.ts +++ b/src/lib/validate/manifest.ts @@ -1,7 +1,7 @@ /** * Handoff-manifest validation for VIP partner integrations. * - * The handoff manifest (`a8c-manifest.yaml`) is the single file a partner fills + * The handoff manifest (`vip-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 @@ -25,7 +25,7 @@ import { MANIFEST_PLACEHOLDER, MANIFEST_SCHEMA } from './manifest.schema'; import type { ErrorObject, ValidateFunction } from 'ajv'; /** Accepted manifest file names, checked at the integration root in order. */ -export const MANIFEST_FILENAMES = [ 'a8c-manifest.yaml', 'a8c-manifest.yml' ]; +export const MANIFEST_FILENAMES = [ 'vip-manifest.yaml', 'vip-manifest.yml' ]; export interface ManifestInspection { /** The manifest file name found at the root, or null when none exists. */ From 5986bb2068e7b138f9a431387f526f85aaa6131b Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Thu, 30 Jul 2026 10:41:31 +0600 Subject: [PATCH 7/7] Warn, don't fail, on a heuristic config-field mismatch The Rule 3 config cross-check reads the plugin's REQUIRED_FIELDS / SENSITIVE_FIELDS contract from the concatenated PHP source and takes the first matching const, so it can't be certain it read the real Config class. Blocking conformance on that heuristic risks failing a genuinely conformant integration. Keep unfilled placeholders a hard fail, but surface a config-field mismatch as a non-blocking warning that prompts the developer to verify. --- __tests__/validate.test.ts | 23 +++++++++++---------- src/lib/validate/validate.ts | 40 ++++++++++++++++++++++++------------ 2 files changed, 39 insertions(+), 24 deletions(-) diff --git a/__tests__/validate.test.ts b/__tests__/validate.test.ts index 29485b3..b036b94 100644 --- a/__tests__/validate.test.ts +++ b/__tests__/validate.test.ts @@ -562,7 +562,7 @@ describe( 'validateIntegration', () => { expect( statusById( root )[ 'handoff-manifest' ] ).toBe( 'pass' ); } ); - it( 'fails rule 3 when a REQUIRED_FIELDS config key is missing from the manifest', () => { + it( 'warns without blocking conformance when a REQUIRED_FIELDS key is missing from the manifest', () => { const root = join( dir, 'manifest-config-missing' ); mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); @@ -571,14 +571,15 @@ describe( 'validateIntegration', () => { " result.id === 'handoff-manifest' - ); - expect( rule3?.status ).toBe( 'fail' ); + const report = validateIntegration( root ); + const rule3 = report.results.find( result => result.id === 'handoff-manifest' ); + expect( rule3?.status ).toBe( 'warn' ); expect( rule3?.details?.join( '\n' ) ).toMatch( /webhook_secret.*not declared/ ); + // The cross-check is heuristic, so it must not fail an otherwise-conformant integration. + expect( report.conformant ).toBe( true ); } ); - it( 'fails rule 3 when a SENSITIVE_FIELDS config key is not typed secret', () => { + it( 'warns without blocking conformance when a SENSITIVE_FIELDS key is not typed secret', () => { const root = join( dir, 'manifest-config-secret' ); mkdirSync( root, { recursive: true } ); scaffoldConformant( root ); @@ -587,11 +588,11 @@ describe( 'validateIntegration', () => { " result.id === 'handoff-manifest' - ); - expect( rule3?.status ).toBe( 'fail' ); + const report = validateIntegration( root ); + const rule3 = report.results.find( result => result.id === 'handoff-manifest' ); + expect( rule3?.status ).toBe( 'warn' ); expect( rule3?.details?.join( '\n' ) ).toMatch( /api_base_url.*secret/ ); + expect( report.conformant ).toBe( true ); } ); it( 'passes rule 3 when the config contract matches the manifest', () => { @@ -647,7 +648,7 @@ describe( 'validateIntegration', () => { const rule3 = validateIntegration( root ).results.find( result => result.id === 'handoff-manifest' ); - expect( rule3?.status ).toBe( 'fail' ); + expect( rule3?.status ).toBe( 'warn' ); expect( rule3?.details?.join( '\n' ) ).toMatch( /webhook_secret.*not declared/ ); } ); diff --git a/src/lib/validate/validate.ts b/src/lib/validate/validate.ts index 4144d4b..5932471 100644 --- a/src/lib/validate/validate.ts +++ b/src/lib/validate/validate.ts @@ -623,30 +623,44 @@ function checkHandoffManifest( ctx: Context ): CheckResult { }; } - // 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 ) ), - ]; + // An unfilled init placeholder is a definite, blocking gap — VIP cannot + // register a manifest that still carries one. The config cross-check is + // different: it reads the plugin's contract heuristically from the + // concatenated PHP source and takes the first matching `REQUIRED_FIELDS` / + // `SENSITIVE_FIELDS` const, so it can't be certain it matched the real Config + // class. A mismatch is therefore surfaced as a non-blocking warning to verify, + // not a hard failure that could wrongly mark a conformant integration. + const placeholderIssues = manifest.placeholders.map( + path => + `${ path } still contains the "${ MANIFEST_PLACEHOLDER }" placeholder — replace it with your integration's value.` + ); - if ( issues.length > 0 ) { + if ( placeholderIssues.length > 0 ) { return { ...base, status: 'fail', message: `${ manifest.file } is incomplete — resolve the following before submitting.`, - details: issues, + details: placeholderIssues, + }; + } + + const configIssues = configFieldMismatches( ctx, manifestConfigFields( manifest.parsed ) ); + if ( configIssues.length > 0 ) { + return { + ...base, + status: 'warn', + message: `${ manifest.file } is schema-valid, but its config fields may not line up with what the plugin declares — double-check the following.`, + details: [ + ...configIssues, + "Best-effort cross-check: the plugin's config contract is read heuristically from the PHP source, so treat this as a prompt to verify — not a definitive failure.", + ], }; } return { ...base, status: 'pass', - message: `${ manifest.file } is schema-valid, placeholder-free, and its config fields match the plugin.`, + message: `${ manifest.file } is schema-valid, placeholder-free, and its config fields line up with the plugin.`, details: [ '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).', ],