diff --git a/.github/actions/evalforge-skill-gate/README.md b/.github/actions/evalforge-skill-gate/README.md index af18e442e..fb75457d8 100644 --- a/.github/actions/evalforge-skill-gate/README.md +++ b/.github/actions/evalforge-skill-gate/README.md @@ -239,11 +239,12 @@ Changed paths are classified in a **fixed precedence, first match wins**: | # | Changed path | Result | |---|---|---| -| 1 | matches `ignore-globs` | ignored, silently | -| 2 | matches `broad-impact-globs` | **broad impact** → the whole suite is in play | -| 3 | `/DASHBOARD_PAGE.md` | tag `dashboard-page` — lowercase, `_` → `-`, drop `.md` | -| 4 | `/dashboard-page/API_SPEC.md` | tag `dashboard-page` — the directory name is already the tag | -| 5 | any other file under `skill-dir` | **unmapped** — warned about, triggers nothing | +| 1 | matches a `tag-globs` glob | the tags that line names — a script the skill tells the agent to run, inside an ignored directory | +| 2 | matches `ignore-globs` | ignored, silently | +| 3 | matches `broad-impact-globs` | **broad impact** → the whole suite is in play | +| 4 | `/DASHBOARD_PAGE.md` | tag `dashboard-page` — lowercase, `_` → `-`, drop `.md` | +| 5 | `/dashboard-page/API_SPEC.md` | tag `dashboard-page` — the directory name is already the tag | +| 6 | any other file under `skill-dir` | **unmapped** — warned about, triggers nothing | | — | the scenario glob | no tag; changed scenarios are included in the run directly | | — | anything else | ignored | @@ -294,6 +295,7 @@ capability version, no run — so a coverage failure reports in seconds and cost | `reference-dir` | `gate` | `references` | Relative to `skill-dir` | | `ignore-globs` | `gate` | `scripts/**` | Newline-separated, relative to `skill-dir` | | `broad-impact-globs` | `gate` | `SKILL.md` + the six cross-cutting references | Newline-separated, relative to `skill-dir` | +| `tag-globs` | `gate` | none | Newline-separated `glob: tag[, tag]`, relative to `skill-dir`; checked before `ignore-globs` | | `max-scenarios` | `gate` | `25` | Touched scenarios kept first; anything cut is named in the comment | | `runs-per-scenario` | `gate` | `1` | Max `20`. Each scenario repeats this many times **per arm**, so an intermittent failure is visible instead of indistinguishable from a regression — at proportional cost. An intermittent failure counts as a failure | | `base-arm-grace-seconds` | `gate` | `60` | Max `300`. `0` means "don't wait for the base arm at all". Window the base arm gets after the PR arm completes before its poll is cancelled. Exceeding it degrades the comment to "attribution unavailable" without affecting the verdict — the base eval run itself still completes server-side either way | diff --git a/.github/actions/evalforge-skill-gate/action.yml b/.github/actions/evalforge-skill-gate/action.yml index 747f4c3b9..d45407e6e 100644 --- a/.github/actions/evalforge-skill-gate/action.yml +++ b/.github/actions/evalforge-skill-gate/action.yml @@ -47,6 +47,10 @@ inputs: required: false default: | scripts/** + tag-globs: + description: 'Newline-separated "glob: tag[, tag]" lines, relative to skill-dir. A changed file that matches derives those tags; checked before ignore-globs, so it can name a script under scripts/' + required: false + default: '' broad-impact-globs: description: 'Newline-separated globs, relative to skill-dir, whose changes put the whole suite in play. These derive no tag of their own and are never coverage-checked' required: false diff --git a/.github/actions/evalforge-skill-gate/dist/index.js b/.github/actions/evalforge-skill-gate/dist/index.js index 48f6eab17..bedd7ff64 100644 --- a/.github/actions/evalforge-skill-gate/dist/index.js +++ b/.github/actions/evalforge-skill-gate/dist/index.js @@ -30294,12 +30294,24 @@ function collectSkillFiles(root, skillDir, options = {}) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.DEFAULT_BROAD_IMPACT_GLOBS = exports.DEFAULT_IGNORE_GLOBS = exports.DEFAULT_REFERENCE_DIR = void 0; +exports.parseTagGlobs = parseTagGlobs; exports.deriveTags = deriveTags; exports.touchedScenarioPaths = touchedScenarioPaths; const minimatch_1 = __nccwpck_require__(8911); /** Single home for the default: `action.yml`, the config reader and the workflow all defer to it. */ exports.DEFAULT_REFERENCE_DIR = 'references'; exports.DEFAULT_IGNORE_GLOBS = ['scripts/**']; +/** `glob: tag, tag` lines (the `tag-globs` input) → rules. */ +function parseTagGlobs(lines) { + return lines.map(line => { + const at = line.lastIndexOf(':'); + const glob = at > 0 ? line.slice(0, at).trim() : ''; + const tags = at > 0 ? line.slice(at + 1).split(',').map(tag => tag.trim()).filter(Boolean) : []; + if (!glob || tags.length === 0) + throw new Error(`tag-globs line must be "glob: tag[, tag]" (received: ${line})`); + return { glob, tags }; + }); +} /** References that apply across scenarios, so no single tag describes them. */ exports.DEFAULT_BROAD_IMPACT_GLOBS = [ 'SKILL.md', @@ -30324,9 +30336,10 @@ function relativeToSkillDir(changedPath, skillDir) { /** * Changed paths → the tags whose scenarios must run. * - * Order is load-bearing: ignore → broad-impact → reference → unmapped, first match wins. Let - * the reference rule run first and `references/CODE_QUALITY.md` derives a `code-quality` tag - * the coverage guard can never satisfy. + * Order is load-bearing: tag-globs → ignore → broad-impact → reference → unmapped, first match + * wins. Let the reference rule run first and `references/CODE_QUALITY.md` derives a + * `code-quality` tag the coverage guard can never satisfy. A tag glob runs before ignore + * because it names files inside an ignored directory (`scripts/sdk-lookup.cjs`). */ function deriveTags(changedPaths, rules) { const tags = new Set(); @@ -30337,6 +30350,12 @@ function deriveTags(changedPaths, rules) { const relativePath = relativeToSkillDir(changedPath, rules.skillDir); if (relativePath === undefined) continue; + const tagged = (rules.tagGlobs ?? []).filter(rule => matchesAny(relativePath, [rule.glob])); + if (tagged.length > 0) { + for (const rule of tagged) + rule.tags.forEach(tag => tags.add(tag)); + continue; + } if (matchesAny(relativePath, rules.ignoreGlobs)) continue; if (matchesAny(relativePath, rules.broadImpactGlobs)) { @@ -63066,6 +63085,7 @@ function getGateConfig() { referenceDir: core.getInput('reference-dir') || evalforge_core_1.DEFAULT_REFERENCE_DIR, ignoreGlobs: getMultilineList('ignore-globs', evalforge_core_1.DEFAULT_IGNORE_GLOBS), broadImpactGlobs: getMultilineList('broad-impact-globs', evalforge_core_1.DEFAULT_BROAD_IMPACT_GLOBS), + tagGlobs: (0, evalforge_core_1.parseTagGlobs)(getMultilineList('tag-globs', [])), maxScenarios, isBlocking: core.getInput('blocking') === 'true', owner, @@ -63291,6 +63311,7 @@ function deriveChangeScope(changedFiles, config) { referenceDir: config.referenceDir, ignoreGlobs: config.ignoreGlobs, broadImpactGlobs: config.broadImpactGlobs, + tagGlobs: config.tagGlobs, }); const touchedPaths = new Set((0, evalforge_core_1.touchedScenarioPaths)(changedFiles.map(file => ({ path: file.filename, status: file.status })), config.evalsGlob)); return { derived, touchedPaths }; diff --git a/.github/actions/evalforge-skill-gate/src/utils/config.ts b/.github/actions/evalforge-skill-gate/src/utils/config.ts index 2854c3091..8d34c262e 100644 --- a/.github/actions/evalforge-skill-gate/src/utils/config.ts +++ b/.github/actions/evalforge-skill-gate/src/utils/config.ts @@ -4,7 +4,7 @@ import * as github from '@actions/github'; import { DEFAULT_BASE_ARM_GRACE_SECONDS, DEFAULT_BROAD_IMPACT_GLOBS, DEFAULT_IGNORE_GLOBS, DEFAULT_MAX_SCENARIOS, DEFAULT_REFERENCE_DIR, DEFAULT_RUNS_PER_SCENARIO, ensureHttps, safeGetSecret, getPrNumber, - readHeadRepoFullName, + parseTagGlobs, readHeadRepoFullName, type TagGlob, } from '@wix/evalforge-core'; /** Subdirectory the base-SHA checkout lands in, matching the yaml-gate workflows. */ @@ -77,6 +77,7 @@ export type GateConfig = { referenceDir: string; ignoreGlobs: string[]; broadImpactGlobs: string[]; + tagGlobs: TagGlob[]; maxScenarios: number; /** The action input stays `blocking` — that name is the public contract and reads right in YAML. */ isBlocking: boolean; @@ -266,6 +267,7 @@ export function getGateConfig(): GateConfig { referenceDir: core.getInput('reference-dir') || DEFAULT_REFERENCE_DIR, ignoreGlobs: getMultilineList('ignore-globs', DEFAULT_IGNORE_GLOBS), broadImpactGlobs: getMultilineList('broad-impact-globs', DEFAULT_BROAD_IMPACT_GLOBS), + tagGlobs: parseTagGlobs(getMultilineList('tag-globs', [])), maxScenarios, isBlocking: core.getInput('blocking') === 'true', owner, diff --git a/.github/actions/evalforge-skill-gate/src/utils/gate-scope.ts b/.github/actions/evalforge-skill-gate/src/utils/gate-scope.ts index 26fcf14f1..59fe376ec 100644 --- a/.github/actions/evalforge-skill-gate/src/utils/gate-scope.ts +++ b/.github/actions/evalforge-skill-gate/src/utils/gate-scope.ts @@ -83,6 +83,7 @@ function deriveChangeScope( referenceDir: config.referenceDir, ignoreGlobs: config.ignoreGlobs, broadImpactGlobs: config.broadImpactGlobs, + tagGlobs: config.tagGlobs, }); const touchedPaths = new Set(touchedScenarioPaths( changedFiles.map(file => ({ path: file.filename, status: file.status })), diff --git a/.github/actions/evalforge-skill-gate/tests/action-yml.test.ts b/.github/actions/evalforge-skill-gate/tests/action-yml.test.ts index c76f25b03..a7892c405 100644 --- a/.github/actions/evalforge-skill-gate/tests/action-yml.test.ts +++ b/.github/actions/evalforge-skill-gate/tests/action-yml.test.ts @@ -26,7 +26,7 @@ describe('action.yml', () => { 'mode', 'github-token', 'evalforge-url', 'evalforge-project-id', 'evalforge-app-id', 'evalforge-app-secret', 'evals-glob', 'capability-id', 'agent-id', 'skill-dir', 'reference-dir', - 'ignore-globs', 'broad-impact-globs', 'max-scenarios', 'blocking', 'runs-per-scenario', + 'ignore-globs', 'broad-impact-globs', 'tag-globs', 'max-scenarios', 'blocking', 'runs-per-scenario', 'base-arm-grace-seconds', 'eval-run-id', ]) { expect(Object.keys(action.inputs), name).toContain(name); diff --git a/.github/actions/evalforge-skill-gate/tests/comparison-arms.test.ts b/.github/actions/evalforge-skill-gate/tests/comparison-arms.test.ts index 201c5d53e..f593f9ef5 100644 --- a/.github/actions/evalforge-skill-gate/tests/comparison-arms.test.ts +++ b/.github/actions/evalforge-skill-gate/tests/comparison-arms.test.ts @@ -20,6 +20,7 @@ const CONFIG: GateConfig = { referenceDir: 'references', ignoreGlobs: ['scripts/**'], broadImpactGlobs: ['SKILL.md'], + tagGlobs: [], maxScenarios: 25, isBlocking: true, owner: 'wix', diff --git a/.github/actions/evalforge-skill-gate/tests/config.test.ts b/.github/actions/evalforge-skill-gate/tests/config.test.ts index cb28b8927..fe076ac91 100644 --- a/.github/actions/evalforge-skill-gate/tests/config.test.ts +++ b/.github/actions/evalforge-skill-gate/tests/config.test.ts @@ -104,6 +104,24 @@ describe('getGateConfig', () => { expect(getGateConfig().broadImpactGlobs).toEqual(['SKILL.md', 'docs/README.md']); }); + it('parses tag-globs lines into a glob and its tags', async () => { + setInputs({ ...REQUIRED_GATE_INPUTS, 'tag-globs': 'scripts/sdk-lookup.cjs: sdk-module-map, dashboard-page\n' }); + const { getGateConfig } = await import('../src/utils/config'); + expect(getGateConfig().tagGlobs).toEqual([{ glob: 'scripts/sdk-lookup.cjs', tags: ['sdk-module-map', 'dashboard-page'] }]); + }); + + it('defaults tag-globs to none', async () => { + setInputs(REQUIRED_GATE_INPUTS); + const { getGateConfig } = await import('../src/utils/config'); + expect(getGateConfig().tagGlobs).toEqual([]); + }); + + it('rejects a tag-globs line with no tag', async () => { + setInputs({ ...REQUIRED_GATE_INPUTS, 'tag-globs': 'scripts/sdk-lookup.cjs' }); + const { getGateConfig } = await import('../src/utils/config'); + expect(() => getGateConfig()).toThrow(/tag-globs line must be/); + }); + it('treats blocking: true as blocking and anything else as soaking', async () => { setInputs({ ...REQUIRED_GATE_INPUTS, blocking: 'true' }); const { getGateConfig } = await import('../src/utils/config'); diff --git a/.github/actions/evalforge-skill-gate/tests/gate-run.test.ts b/.github/actions/evalforge-skill-gate/tests/gate-run.test.ts index b824511f6..74a098051 100644 --- a/.github/actions/evalforge-skill-gate/tests/gate-run.test.ts +++ b/.github/actions/evalforge-skill-gate/tests/gate-run.test.ts @@ -63,6 +63,7 @@ const CONFIG: GateConfig = { referenceDir: 'references', ignoreGlobs: ['scripts/**'], broadImpactGlobs: ['SKILL.md'], + tagGlobs: [], maxScenarios: 25, isBlocking: true, owner: 'wix', diff --git a/.github/actions/evalforge-skill-gate/tests/run-and-report.test.ts b/.github/actions/evalforge-skill-gate/tests/run-and-report.test.ts index 7441e34e2..68d63d872 100644 --- a/.github/actions/evalforge-skill-gate/tests/run-and-report.test.ts +++ b/.github/actions/evalforge-skill-gate/tests/run-and-report.test.ts @@ -34,6 +34,7 @@ const CONFIG: GateConfig = { referenceDir: 'references', ignoreGlobs: ['scripts/**'], broadImpactGlobs: ['SKILL.md'], + tagGlobs: [], maxScenarios: 25, isBlocking: true, owner: 'wix', diff --git a/.github/actions/evalforge-yaml-gate/dist/index.js b/.github/actions/evalforge-yaml-gate/dist/index.js index 837cfd725..12b711f1f 100644 --- a/.github/actions/evalforge-yaml-gate/dist/index.js +++ b/.github/actions/evalforge-yaml-gate/dist/index.js @@ -34408,12 +34408,24 @@ function collectSkillFiles(root, skillDir, options = {}) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.DEFAULT_BROAD_IMPACT_GLOBS = exports.DEFAULT_IGNORE_GLOBS = exports.DEFAULT_REFERENCE_DIR = void 0; +exports.parseTagGlobs = parseTagGlobs; exports.deriveTags = deriveTags; exports.touchedScenarioPaths = touchedScenarioPaths; const minimatch_1 = __nccwpck_require__(8911); /** Single home for the default: `action.yml`, the config reader and the workflow all defer to it. */ exports.DEFAULT_REFERENCE_DIR = 'references'; exports.DEFAULT_IGNORE_GLOBS = ['scripts/**']; +/** `glob: tag, tag` lines (the `tag-globs` input) → rules. */ +function parseTagGlobs(lines) { + return lines.map(line => { + const at = line.lastIndexOf(':'); + const glob = at > 0 ? line.slice(0, at).trim() : ''; + const tags = at > 0 ? line.slice(at + 1).split(',').map(tag => tag.trim()).filter(Boolean) : []; + if (!glob || tags.length === 0) + throw new Error(`tag-globs line must be "glob: tag[, tag]" (received: ${line})`); + return { glob, tags }; + }); +} /** References that apply across scenarios, so no single tag describes them. */ exports.DEFAULT_BROAD_IMPACT_GLOBS = [ 'SKILL.md', @@ -34438,9 +34450,10 @@ function relativeToSkillDir(changedPath, skillDir) { /** * Changed paths → the tags whose scenarios must run. * - * Order is load-bearing: ignore → broad-impact → reference → unmapped, first match wins. Let - * the reference rule run first and `references/CODE_QUALITY.md` derives a `code-quality` tag - * the coverage guard can never satisfy. + * Order is load-bearing: tag-globs → ignore → broad-impact → reference → unmapped, first match + * wins. Let the reference rule run first and `references/CODE_QUALITY.md` derives a + * `code-quality` tag the coverage guard can never satisfy. A tag glob runs before ignore + * because it names files inside an ignored directory (`scripts/sdk-lookup.cjs`). */ function deriveTags(changedPaths, rules) { const tags = new Set(); @@ -34451,6 +34464,12 @@ function deriveTags(changedPaths, rules) { const relativePath = relativeToSkillDir(changedPath, rules.skillDir); if (relativePath === undefined) continue; + const tagged = (rules.tagGlobs ?? []).filter(rule => matchesAny(relativePath, [rule.glob])); + if (tagged.length > 0) { + for (const rule of tagged) + rule.tags.forEach(tag => tags.add(tag)); + continue; + } if (matchesAny(relativePath, rules.ignoreGlobs)) continue; if (matchesAny(relativePath, rules.broadImpactGlobs)) { diff --git a/.github/actions/skill-eval/dist/index.js b/.github/actions/skill-eval/dist/index.js index d37124476..24650f53b 100644 --- a/.github/actions/skill-eval/dist/index.js +++ b/.github/actions/skill-eval/dist/index.js @@ -34408,12 +34408,24 @@ function collectSkillFiles(root, skillDir, options = {}) { Object.defineProperty(exports, "__esModule", ({ value: true })); exports.DEFAULT_BROAD_IMPACT_GLOBS = exports.DEFAULT_IGNORE_GLOBS = exports.DEFAULT_REFERENCE_DIR = void 0; +exports.parseTagGlobs = parseTagGlobs; exports.deriveTags = deriveTags; exports.touchedScenarioPaths = touchedScenarioPaths; const minimatch_1 = __nccwpck_require__(8911); /** Single home for the default: `action.yml`, the config reader and the workflow all defer to it. */ exports.DEFAULT_REFERENCE_DIR = 'references'; exports.DEFAULT_IGNORE_GLOBS = ['scripts/**']; +/** `glob: tag, tag` lines (the `tag-globs` input) → rules. */ +function parseTagGlobs(lines) { + return lines.map(line => { + const at = line.lastIndexOf(':'); + const glob = at > 0 ? line.slice(0, at).trim() : ''; + const tags = at > 0 ? line.slice(at + 1).split(',').map(tag => tag.trim()).filter(Boolean) : []; + if (!glob || tags.length === 0) + throw new Error(`tag-globs line must be "glob: tag[, tag]" (received: ${line})`); + return { glob, tags }; + }); +} /** References that apply across scenarios, so no single tag describes them. */ exports.DEFAULT_BROAD_IMPACT_GLOBS = [ 'SKILL.md', @@ -34438,9 +34450,10 @@ function relativeToSkillDir(changedPath, skillDir) { /** * Changed paths → the tags whose scenarios must run. * - * Order is load-bearing: ignore → broad-impact → reference → unmapped, first match wins. Let - * the reference rule run first and `references/CODE_QUALITY.md` derives a `code-quality` tag - * the coverage guard can never satisfy. + * Order is load-bearing: tag-globs → ignore → broad-impact → reference → unmapped, first match + * wins. Let the reference rule run first and `references/CODE_QUALITY.md` derives a + * `code-quality` tag the coverage guard can never satisfy. A tag glob runs before ignore + * because it names files inside an ignored directory (`scripts/sdk-lookup.cjs`). */ function deriveTags(changedPaths, rules) { const tags = new Set(); @@ -34451,6 +34464,12 @@ function deriveTags(changedPaths, rules) { const relativePath = relativeToSkillDir(changedPath, rules.skillDir); if (relativePath === undefined) continue; + const tagged = (rules.tagGlobs ?? []).filter(rule => matchesAny(relativePath, [rule.glob])); + if (tagged.length > 0) { + for (const rule of tagged) + rule.tags.forEach(tag => tags.add(tag)); + continue; + } if (matchesAny(relativePath, rules.ignoreGlobs)) continue; if (matchesAny(relativePath, rules.broadImpactGlobs)) { diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fa52307a6..5432d9a27 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,8 +56,9 @@ jobs: match() { echo "$changed" | grep -qE "$1"; } # The wix-app harness itself is included: a change to extract.mjs or its deps can - # break the typecheck just as a reference-doc change can. - if match '^skills/wix-app/|^\.github/wix-app-typecheck/'; then + # break the typecheck just as a reference-doc change can. So is the sdk-lookup + # test and its fixtures, which test a wix-app script. + if match '^skills/wix-app/|^\.github/wix-app-typecheck/|^scripts/test-sdk-lookup\.cjs$|^scripts/fixtures/sdk-lookup/'; then echo "wix_app=true" >> "$GITHUB_OUTPUT" else echo "wix_app=false" >> "$GITHUB_OUTPUT" @@ -272,9 +273,21 @@ jobs: exit 1 fi + # The skill's scripts ship without a build or an eval of their own (derive-tags + # gives scripts/** no tag), so this is what catches a broken sdk-lookup.cjs. + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + if: ${{ needs.detect.outputs.wix_app == 'true' }} + with: + node-version: '22' + + - name: wix-app — sdk-lookup tests + if: ${{ needs.detect.outputs.wix_app == 'true' }} + run: node --test scripts/test-sdk-lookup.cjs + - name: Summary run: | echo "evalforge: ${{ needs.detect.outputs.evalforge }}" - if [ "${{ needs.detect.outputs.evalforge }}" = "false" ]; then + echo "wix-app sdk-lookup: ${{ needs.detect.outputs.wix_app }}" + if [ "${{ needs.detect.outputs.evalforge }}" = "false" ] && [ "${{ needs.detect.outputs.wix_app }}" = "false" ]; then echo "No tested areas changed — nothing to do." fi diff --git a/.github/workflows/evalforge-wix-app-gate.yml b/.github/workflows/evalforge-wix-app-gate.yml index f200f2358..bf3a8924d 100644 --- a/.github/workflows/evalforge-wix-app-gate.yml +++ b/.github/workflows/evalforge-wix-app-gate.yml @@ -102,6 +102,11 @@ jobs: evalforge-app-secret: ${{ secrets.AUTO_SKILLS_PIPELINE_APP_SECRET }} evals-glob: 'yaml/wix-app-evals/**/*.{yml,yaml}' skill-dir: skills/wix-app + # SKILL.md tells the agent to trust the SDK lookup over the typings, so a change to it + # runs the scenarios that call it rather than nothing (scripts/** is ignored). + tag-globs: | + scripts/sdk-lookup.cjs: sdk-module-map, dashboard-page + scripts/sdk-curated.json: sdk-module-map, dashboard-page # Inline rather than repo vars: neither is a secret, and in the workflow file they # appear in the diff, get reviewed when they change, and document what the gate # evaluates against. As repo vars they could be edited silently. diff --git a/packages/evalforge-core/src/derive-tags.ts b/packages/evalforge-core/src/derive-tags.ts index e38d57370..dafb199b6 100644 --- a/packages/evalforge-core/src/derive-tags.ts +++ b/packages/evalforge-core/src/derive-tags.ts @@ -6,8 +6,12 @@ export type TagRules = { referenceDir: string; ignoreGlobs: string[]; broadImpactGlobs: string[]; + /** Non-reference files that a reference tag covers, e.g. a script the skill tells the agent to run. */ + tagGlobs?: TagGlob[]; }; +export type TagGlob = { glob: string; tags: string[] }; + export type DerivedTags = { tags: string[]; /** Drives selection only — feeding it to the guard as a tag union would block on any weak scenario. */ @@ -20,6 +24,17 @@ export const DEFAULT_REFERENCE_DIR = 'references'; export const DEFAULT_IGNORE_GLOBS: string[] = ['scripts/**']; +/** `glob: tag, tag` lines (the `tag-globs` input) → rules. */ +export function parseTagGlobs(lines: string[]): TagGlob[] { + return lines.map(line => { + const at = line.lastIndexOf(':'); + const glob = at > 0 ? line.slice(0, at).trim() : ''; + const tags = at > 0 ? line.slice(at + 1).split(',').map(tag => tag.trim()).filter(Boolean) : []; + if (!glob || tags.length === 0) throw new Error(`tag-globs line must be "glob: tag[, tag]" (received: ${line})`); + return { glob, tags }; + }); +} + /** References that apply across scenarios, so no single tag describes them. */ export const DEFAULT_BROAD_IMPACT_GLOBS: string[] = [ 'SKILL.md', @@ -48,9 +63,10 @@ function relativeToSkillDir(changedPath: string, skillDir: string): string | und /** * Changed paths → the tags whose scenarios must run. * - * Order is load-bearing: ignore → broad-impact → reference → unmapped, first match wins. Let - * the reference rule run first and `references/CODE_QUALITY.md` derives a `code-quality` tag - * the coverage guard can never satisfy. + * Order is load-bearing: tag-globs → ignore → broad-impact → reference → unmapped, first match + * wins. Let the reference rule run first and `references/CODE_QUALITY.md` derives a + * `code-quality` tag the coverage guard can never satisfy. A tag glob runs before ignore + * because it names files inside an ignored directory (`scripts/sdk-lookup.cjs`). */ export function deriveTags(changedPaths: string[], rules: TagRules): DerivedTags { const tags = new Set(); @@ -63,6 +79,12 @@ export function deriveTags(changedPaths: string[], rules: TagRules): DerivedTags const relativePath = relativeToSkillDir(changedPath, rules.skillDir); if (relativePath === undefined) continue; + const tagged = (rules.tagGlobs ?? []).filter(rule => matchesAny(relativePath, [rule.glob])); + if (tagged.length > 0) { + for (const rule of tagged) rule.tags.forEach(tag => tags.add(tag)); + continue; + } + if (matchesAny(relativePath, rules.ignoreGlobs)) continue; if (matchesAny(relativePath, rules.broadImpactGlobs)) { diff --git a/packages/evalforge-core/tests/derive-tags.test.ts b/packages/evalforge-core/tests/derive-tags.test.ts index 247a8324e..d20f4151c 100644 --- a/packages/evalforge-core/tests/derive-tags.test.ts +++ b/packages/evalforge-core/tests/derive-tags.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from 'vitest'; import { - deriveTags, touchedScenarioPaths, + deriveTags, parseTagGlobs, touchedScenarioPaths, DEFAULT_IGNORE_GLOBS, DEFAULT_BROAD_IMPACT_GLOBS, type TagRules, } from '../src/derive-tags'; @@ -35,6 +35,20 @@ describe('deriveTags — precedence', () => { expect(derived).toEqual({ tags: [], broadImpact: false, unmapped: [] }); }); + it('gives a tag-globs file its tags, even inside an ignored directory', () => { + const rules: TagRules = { + ...WIX_APP_RULES, + tagGlobs: [{ glob: 'scripts/sdk-lookup.cjs', tags: ['sdk-module-map', 'dashboard-page'] }], + }; + const derived = deriveTags(['skills/wix-app/scripts/sdk-lookup.cjs', 'skills/wix-app/scripts/other.js'], rules); + expect(derived).toEqual({ tags: ['dashboard-page', 'sdk-module-map'], broadImpact: false, unmapped: [] }); + }); + + it('parses tag-globs lines and rejects one with no tag', () => { + expect(parseTagGlobs(['scripts/a.cjs: x, y'])).toEqual([{ glob: 'scripts/a.cjs', tags: ['x', 'y'] }]); + expect(() => parseTagGlobs(['scripts/a.cjs:'])).toThrow(/tag-globs line must be/); + }); + it('prefers ignore over broad impact when a path matches both', () => { const rules: TagRules = { ...WIX_APP_RULES, ignoreGlobs: ['SKILL.md'], broadImpactGlobs: ['SKILL.md'] }; const derived = deriveTags(['skills/wix-app/SKILL.md'], rules); diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_extended-bookings/package.json b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_extended-bookings/package.json new file mode 100644 index 000000000..c2350897a --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_extended-bookings/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wix/auto_sdk_bookings_extended-bookings", + "version": "1.0.136", + "main": "./build/cjs/index.js", + "types": "./build/cjs/index.d.ts", + "dependencies": { + "@wix/sdk-runtime": "^1.0.24", + "@wix/sdk-types": "^1.17.11" + }, + "wix": { + "artifact": { "artifactId": "auto-sdk-bookings-extended-bookings", "groupId": "com.wixpress.public-sdk-dependencies" }, + "sdkDependency": { "fqdnNamespace": "extendedBookings", "fqdn": "wix.bookings.reader.v2.extended_booking" } + } +} diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_extended-bookings/typings/index.typings.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_extended-bookings/typings/index.typings.d.ts.fixture new file mode 100644 index 000000000..6b695ff16 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_extended-bookings/typings/index.typings.d.ts.fixture @@ -0,0 +1,69 @@ +// Trimmed from @wix/auto_sdk_bookings_extended-bookings@1.0.136 build/cjs/index.typings.d.ts: +// a builder with no filter or sort, a query object with cursor paging, and a +// query spec that lists no field (`wql: []`). +interface ExtendedBooking { + booking?: Booking; +} +interface Booking { + _id?: string | null; + status?: string; +} +interface QueryExtendedBookingsResponse { + extendedBookings?: ExtendedBooking[]; + pagingMetadata?: PagingMetadataV2; +} +interface PagingMetadataV2 { + count?: number | null; + cursors?: Cursors; +} +interface Cursors { + next?: string | null; +} +interface QueryExtendedBookingsOptions { + /** Whether to return information about the actions the customer can perform for the bookings. */ + withBookingAllowedActions?: boolean; +} +/** + * Retrieves a list of bookings, including additional extended information, given the provided paging, filtering, and sorting. + * @public + * @param options - Additional options for performing the query. + * @permissionId BOOKINGS.BOOKING_READ + * @applicableIdentity APP + * @fqn com.wixpress.bookings.reader.v2.BookingsReader.QueryExtendedBookings + */ +declare function queryExtendedBookings(options?: QueryExtendedBookingsOptions): ExtendedBookingsQueryBuilder; +interface ExtendedBookingsQueryResult { + items: ExtendedBooking[]; +} +interface ExtendedBookingsQueryBuilder { + /** @param limit - Number of items to return, which is also the `pageSize` of the results object. */ + limit: (limit: number) => ExtendedBookingsQueryBuilder; + /** @param cursor - A pointer to specific record */ + skipTo: (cursor: string) => ExtendedBookingsQueryBuilder; + find: () => Promise; +} +/** + * @hidden + * @fqn com.wixpress.bookings.reader.v2.BookingsReader.QueryExtendedBookings + * @requiredField query + */ +declare function typedQueryExtendedBookings(query: ExtendedBookingQuery, options?: QueryExtendedBookingsOptions): Promise>; +interface ExtendedBookingQuerySpec extends QuerySpec { + paging: 'cursor'; + wql: []; +} +type CommonQueryWithEntityContext = Query; +type ExtendedBookingQuery = { + /** + Cursor token pointing to a page of results. + */ + cursorPaging?: { + limit?: NonNullable['limit'] | null; + cursor?: NonNullable['cursor'] | null; + }; + filter?: CommonQueryWithEntityContext['filter'] | null; + sort?: { + fieldName?: NonNullable[number]['fieldName']; + order?: NonNullable[number]['order']; + }[]; +}; diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_services/package.json b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_services/package.json new file mode 100644 index 000000000..66078ca5d --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_services/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wix/auto_sdk_bookings_services", + "version": "1.0.289", + "main": "./build/cjs/index.js", + "types": "./build/cjs/index.d.ts", + "dependencies": { + "@wix/sdk-runtime": "^1.0.24", + "@wix/sdk-types": "^1.17.11" + }, + "wix": { + "artifact": { "artifactId": "auto-sdk-bookings-services", "groupId": "com.wixpress.public-sdk-dependencies" }, + "sdkDependency": { "fqdnNamespace": "services", "fqdn": "wix.bookings.services.v2.service" } + } +} diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_services/typings/index.typings.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_services/typings/index.typings.d.ts.fixture new file mode 100644 index 000000000..322788d6c --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_bookings_services/typings/index.typings.d.ts.fixture @@ -0,0 +1,93 @@ +// Trimmed from @wix/auto_sdk_bookings_services@1.0.289 build/cjs/bookings-services-v2-service-services.universal-C8fV-5oa.d.ts: +// a builder that also takes a query object (offset `paging`), the entity's +// query spec, and queryPolicies, a query of another type in the same namespace. +interface Service { + _id?: string | null; + name?: string | null; +} +interface QueryServicesResponse { + services?: Service[]; + pagingMetadata?: PagingMetadataV2; +} +interface PagingMetadataV2 { + count?: number | null; + offset?: number | null; +} +interface BookingPolicyWithServices { + bookingPolicy?: BookingPolicy; + connectedServices?: Service[]; +} +interface BookingPolicy { + _id?: string | null; + name?: string | null; +} +interface QueryPoliciesResponse { + bookingPolicies?: BookingPolicyWithServices[]; + pagingMetadata?: PagingMetadataV2; +} +interface CursorQuery { + filter?: Record | null; +} +interface QueryServicesOptions { +} +/** + * Creates a query to retrieve a list of `service` objects. + * @public + * @permissionId BOOKINGS.SERVICES_READ + * @applicableIdentity APP + * @fqn wix.bookings.services.v2.ServicesService.QueryServices + */ +declare function queryServices(options?: QueryServicesOptions): ServicesQueryBuilder; +interface ServicesQueryBuilder { + /** @param propertyName - Property whose value is compared with `value`. + * @param value - Value to compare against. + */ + eq: (propertyName: '_id' | 'name' | 'category.id', value: any) => ServicesQueryBuilder; + /** @param limit - Number of items to return, which is also the `pageSize` of the results object. */ + limit: (limit: number) => ServicesQueryBuilder; + find: () => Promise; +} +interface ServicesQueryResult { + items: Service[]; +} +/** + * @hidden + * @fqn wix.bookings.services.v2.ServicesService.QueryServices + * @requiredField query + */ +declare function typedQueryServices(query: ServiceQuery, options?: QueryServicesOptions): Promise>; +interface ServiceQuerySpec extends QuerySpec { + paging: 'offset'; + wql: [ + { + fields: ['_id', 'category._id', 'name']; + operators: '*'; + sort: 'BOTH'; + } + ]; +} +type CommonQueryWithEntityContext = Query; +type ServiceQuery = { + /** + Paging options to limit and skip the number of items. + */ + paging?: { + limit?: NonNullable['limit'] | null; + offset?: NonNullable['offset'] | null; + }; + filter?: CommonQueryWithEntityContext['filter'] | null; + sort?: { + fieldName?: NonNullable[number]['fieldName']; + order?: NonNullable[number]['order']; + }[]; +}; +/** + * Retrieves a list of booking policies and how many services are connected to each policy. + * @param query - Information about filters, paging, and sorting. + * @public + * @requiredField query + * @permissionId BOOKINGS.BOOKING_POLICY_READ + * @applicableIdentity APP + * @fqn wix.bookings.services.v2.ServicesService.QueryPolicies + */ +declare function queryPolicies(query: CursorQuery): Promise>; diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/package.json b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/package.json new file mode 100644 index 000000000..c971f6b3c --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wix/auto_sdk_crm_contacts-v-5", + "version": "1.0.22", + "wix": { + "sdkDependency": { + "fqdnNamespace": "contactsV5", + "fqdn": "wix.contacts.v5.contact" + } + }, + "dependencies": { + "@wix/sdk-runtime": "^1.0.24", + "@wix/sdk-types": "^1.17.11" + } +} diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/contacts-v5-contact-contacts-v-5.universal-BJh2oKn7.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/contacts-v5-contact-contacts-v-5.universal-BJh2oKn7.d.ts.fixture new file mode 100644 index 000000000..a2f38d641 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/contacts-v5-contact-contacts-v-5.universal-BJh2oKn7.d.ts.fixture @@ -0,0 +1,186 @@ +import { QuerySpec, SearchSpec, Query, Search, NonNullablePaths } from '@wix/sdk-types'; + +/** A contact represents a person in a site's contact list. */ +interface Contact { + /** + * Contact ID. + * @format GUID + * @readonly + */ + _id?: string | null; + /** Revision number, which increments by 1 each time the contact is updated. */ + revision?: string | null; +} +interface UpdateContact { + _id?: string | null; + revision?: string | null; +} +interface UpdateContactOptions { + overwrite?: boolean; +} +interface ContactQuery { + filter?: Record | null; +} +interface QueryContactsResponse { + contacts?: Contact[]; +} +interface SearchContactsResponse { + /** Retrieved contacts. */ + contacts?: Contact[]; + /** Paging metadata. */ + pagingMetadata?: CursorPagingMetadata; +} +interface CursorPagingMetadata { + /** Number of items returned in the response. */ + count?: number | null; + /** Cursor strings that point to the next page, previous page, or both. */ + cursors?: Cursors; + hasNext?: boolean | null; +} +interface Cursors { + /** Cursor string pointing to the next page in the list of results. */ + next?: string | null; + prev?: string | null; +} +interface SearchContactsOptions { + nameFormat?: string; +} +interface CursorSearch { + filter?: Record | null; + search?: SearchDetails; +} +interface SearchDetails { + expression?: string | null; +} +/** + * Retrieves a single contact by ID. + * @param contactId - ID of the contact to retrieve. + * @public + * @requiredField contactId + * @permissionId CONTACTS.CONTACT_READ + * @applicableIdentity APP + * @returns Retrieved contact. + * @fqn wix.contacts.v5.Contacts.GetContact + */ +declare function getContact(contactId: string): Promise>; +/** + * Updates a contact. + * @param _id - Contact ID. + * @public + * @requiredField _id + * @requiredField contact + * @requiredField contact.revision + * @permissionId CONTACTS.CONTACT_UPDATE + * @permissionId CONTACTS.CONTACT_MEMBER_EMAIL_UPDATE + * @applicableIdentity APP + * @returns Updated contact. + * @fqn wix.contacts.v5.Contacts.UpdateContact + */ +declare function updateContact(_id: string, contact: NonNullablePaths, options?: UpdateContactOptions): Promise>; +/** + * Retrieves a list of up to 100 contacts, given the provided filtering, sorting, and cursor paging. + * @public + * @permissionId CONTACTS.CONTACT_READ + * @applicableIdentity APP + * @fqn wix.contacts.v5.Contacts.QueryContacts + */ +declare function queryContacts(): ContactsQueryBuilder; +interface ContactsQueryResult { + items: Contact[]; + query: ContactsQueryBuilder; +} +interface ContactsQueryBuilder { + /** @param propertyName - Property whose value is compared with `value`. + * @param value - Value to compare against. + */ + eq: (propertyName: '_id' | '_createdDate' | '_updatedDate' | 'externalId' | 'email.email' | 'phone.phone', value: any) => ContactsQueryBuilder; + /** @param propertyName - Property whose value is compared with `value`. + * @param value - Value to compare against. + */ + gt: (propertyName: '_createdDate' | '_updatedDate', value: any) => ContactsQueryBuilder; + in: (propertyName: '_id' | 'externalId' | 'email.email' | 'phone.phone', value: any) => ContactsQueryBuilder; + exists: (propertyName: 'externalId' | 'email.email' | 'phone.phone', value: boolean) => ContactsQueryBuilder; + /** @param propertyNames - Properties used in the sort. To sort by multiple properties, pass properties as additional arguments. */ + ascending: (...propertyNames: Array<'_createdDate' | '_updatedDate'>) => ContactsQueryBuilder; + /** @param limit - Number of items to return, which is also the `pageSize` of the results object. */ + limit: (limit: number) => ContactsQueryBuilder; + find: () => Promise; +} +/** + * @hidden + * @fqn wix.contacts.v5.Contacts.QueryContacts + * @requiredField query + */ +declare function typedQueryContacts(query: ContactQuery): Promise>; +type CommonSearchWithEntityContext = Search; +type ContactSearch = { + /** + Cursor paging options. + */ + cursorPaging?: { + /** + Maximum number of items to return in the results. + @max: 100 + */ + limit?: NonNullable['limit'] | null; + cursor?: NonNullable['cursor'] | null; + }; + /** + Filter object. + */ + filter?: CommonSearchWithEntityContext['filter'] | null; + sort?: { + fieldName?: NonNullable[number]['fieldName']; + order?: NonNullable[number]['order']; + }[]; + search?: { + mode?: NonNullable['mode']; + expression?: NonNullable['expression']; + }; +}; +interface ContactQuerySpec extends QuerySpec { + paging: 'cursor'; + wql: [ + { + fields: ['_id']; + operators: ['$eq', '$in']; + sort: 'NONE'; + }, + { + fields: ['_createdDate', '_updatedDate']; + operators: ['$eq', '$gt', '$gte', '$lt', '$lte']; + sort: 'ASC'; + }, + { + fields: ['email.email', 'externalId', 'phone.phone']; + operators: ['$eq', '$exists', '$in']; + sort: 'NONE'; + } + ]; +} +interface ContactSearchSpec extends SearchSpec { + searchable: [ + 'company.name', + 'email.email', + 'name.full', + 'phone.phone' + ]; + aggregatable: [ + '_createdDate' + ]; + paging: 'cursor'; + wql: [ + { + fields: ['company.name', 'email.email', 'phone.phone', 'name.first']; + operators: ['$eq', '$exists', '$in', '$ne', '$nin', '$startsWith']; + sort: 'BOTH'; + }, + { + fields: ['_createdDate']; + operators: ['$eq', '$gt', '$gte', '$lt', '$lte', '$ne']; + sort: 'BOTH'; + } + ]; +} + +export { type Contact, type ContactQuerySpec, type ContactSearchSpec, type ContactsQueryBuilder, type CursorSearch, type SearchContactsOptions, type SearchContactsResponse, type UpdateContact, type UpdateContactOptions, getContact, queryContacts, typedQueryContacts, updateContact }; diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/index.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/index.d.ts.fixture new file mode 100644 index 000000000..4aa3b7b72 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/index.d.ts.fixture @@ -0,0 +1,25 @@ +import { HttpClient, NonNullablePaths } from '@wix/sdk-types'; +import { Contact, SearchContactsOptions, SearchContactsResponse } from './contacts-v5-contact-contacts-v-5.universal-BJh2oKn7.js'; + +/** @hidden */ +type ContactSearch = {}; + +declare function getContact$1(httpClient: HttpClient): GetContactSignature; +interface GetContactSignature { + /** + * Retrieves a single contact by ID. + * @param - ID of the contact to retrieve. + * @returns Retrieved contact. + */ + (contactId: string): Promise>; +} +declare function searchContacts$1(httpClient: HttpClient): SearchContactsSignature; +interface SearchContactsSignature { + /** + * Retrieves a list of contacts using a free-text search combined with filtering and sorting. + * @param - WQL search expression. + */ + (search: ContactSearch, options?: SearchContactsOptions): Promise>; +} + +export { type ContactSearch, getContact$1 as getContact, searchContacts$1 as searchContacts }; diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/index.typings.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/index.typings.d.ts.fixture new file mode 100644 index 000000000..7c1f0c78d --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/index.typings.d.ts.fixture @@ -0,0 +1,17 @@ +import { CursorSearch, SearchContactsOptions, SearchContactsResponse } from './contacts-v5-contact-contacts-v-5.universal-BJh2oKn7.js'; +import '@wix/sdk-types'; + +/** @hidden */ +type ContactSearch = {}; +/** + * Retrieves a list of contacts using a free-text search combined with filtering and sorting. + * @param search - WQL search expression. + * @public + * @requiredField search + * @permissionId CONTACTS.CONTACT_READ + * @applicableIdentity APP + * @fqn wix.contacts.v5.Contacts.SearchContacts + */ +declare function searchContacts(search: CursorSearch, options: SearchContactsOptions): Promise; + +export { type ContactSearch, CursorSearch, SearchContactsOptions, SearchContactsResponse, searchContacts }; diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/meta.js.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/meta.js.fixture new file mode 100644 index 000000000..6e61e71b4 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts-v-5/typings/meta.js.fixture @@ -0,0 +1,38 @@ +"use strict"; + +function getContact2() { + const payload = { contactId: ":contactId" }; + const getRequestOptions = getContact(payload); + const getUrl = (context) => { + const { url } = getRequestOptions(context); + return url; + }; + return { + getUrl, + httpMethod: "GET", + path: "/v5/contacts/{contactId}", + pathParams: { contactId: "contactId" }, + __requestType: null, + __originalRequestType: null, + __responseType: null, + __originalResponseType: null + }; +} +function searchContacts2() { + const payload = {}; + const getRequestOptions = searchContacts(payload); + const getUrl = (context) => { + const { url } = getRequestOptions(context); + return url; + }; + return { + getUrl, + httpMethod: "GET", + path: "/v5/contacts/search", + pathParams: {}, + __requestType: null, + __originalRequestType: null, + __responseType: null, + __originalResponseType: null + }; +} diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts/package.json b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts/package.json new file mode 100644 index 000000000..3703848b9 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wix/auto_sdk_crm_contacts", + "version": "1.0.1380", + "wix": { + "sdkDependency": { + "fqdnNamespace": "contacts", + "fqdn": "wix.contacts.v4.contact" + } + }, + "dependencies": { + "@wix/sdk-runtime": "^1.0.24", + "@wix/sdk-types": "^1.17.11" + } +} diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts/typings/index.typings.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts/typings/index.typings.d.ts.fixture new file mode 100644 index 000000000..2bb540493 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_crm_contacts/typings/index.typings.d.ts.fixture @@ -0,0 +1,19 @@ +import '@wix/sdk-types'; + +interface Contact { + _id?: string | null; +} +/** + * Retrieves a list of contacts. + * @public + * @permissionId CONTACTS.CONTACT_READ + * @applicableIdentity APP + * @fqn wix.contacts.v4.ContactsService.QueryContacts + */ +declare function queryContacts(): ContactsQueryBuilder; +interface ContactsQueryBuilder { + eq: (propertyName: '_id' | 'info.name.first', value: any) => ContactsQueryBuilder; + find: () => Promise<{ items: Contact[] }>; +} + +export { type Contact, type ContactsQueryBuilder, queryContacts }; diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_members_members/package.json b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_members_members/package.json new file mode 100644 index 000000000..7c591a5ad --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_members_members/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wix/auto_sdk_members_members", + "version": "1.0.138", + "main": "./build/cjs/index.js", + "types": "./build/cjs/index.d.ts", + "dependencies": { + "@wix/sdk-runtime": "^1.0.24", + "@wix/sdk-types": "^1.17.11" + }, + "wix": { + "artifact": { "artifactId": "auto-sdk-members-members", "groupId": "com.wixpress.public-sdk-dependencies" }, + "sdkDependency": { "fqdnNamespace": "members", "fqdn": "wix.members.v1.member" } + } +} diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_members_members/typings/index.typings.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_members_members/typings/index.typings.d.ts.fixture new file mode 100644 index 000000000..c276fd909 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_members_members/typings/index.typings.d.ts.fixture @@ -0,0 +1,64 @@ +// Trimmed from @wix/auto_sdk_members_members@1.0.138 build/cjs/index.typings.d.ts: +// no search method, but a builder whose options take free text +// (`options.search: { expression, fields }`, fields defaulting to one), and +// getCurrentMember, whose JSDoc note says who may call it. +interface Member { + _id?: string | null; + loginEmail?: string | null; + contact?: Contact; +} +interface Contact { + firstName?: string | null; +} +interface Search { + /** + * Search term or expression. + * @minLength 1 + * @maxLength 100 + */ + expression?: string | null; + /** + * Currently supported fields for search: + * + * - `loginEmail` + * - `contact.firstName` + * - `contact.lastName` + * - `profile.title` + * - `profile.nickname` + * - `profile.slug` + * + * Default: `profile.nickname`. + * @maxSize 4 + */ + fields?: string[]; +} +interface QueryMembersOptions { + /** Plain text search. */ + search?: Search | undefined; +} +/** + * Retrieves a list of up to 100 members, given the specified filters, fieldsets, sorting and paging. + * @public + * @permissionId MEMBERS.MEMBER_READ + * @applicableIdentity APP + * @fqn com.wixpress.members.api.Members.QueryMembers + */ +declare function queryMembers(options?: QueryMembersOptions): MembersQueryBuilder; +/** + * Retrieves the current member. + * + * >**Note:** + * >This method requires [visitor or member authentication](https://dev.wix.com/docs/api-reference/articles/authentication/about-identities). + * @public + * @permissionId MEMBERS.MEMBER_READ + * @applicableIdentity APP + * @fqn com.wixpress.members.api.Members.GetMyMember + */ +declare function getCurrentMember(): Promise<{ member?: Member }>; +interface MembersQueryBuilder { + eq: (propertyName: '_id' | 'loginEmail' | 'contact.firstName', value: any) => MembersQueryBuilder; + startsWith: (propertyName: '_id' | 'loginEmail' | 'contact.firstName', value: string) => MembersQueryBuilder; + find: () => Promise<{ items: Member[] }>; +} + +export { type Member, type MembersQueryBuilder, type QueryMembersOptions, type Search, queryMembers, getCurrentMember }; diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_payments_refunds/package.json b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_payments_refunds/package.json new file mode 100644 index 000000000..c11b00cb1 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_payments_refunds/package.json @@ -0,0 +1,14 @@ +{ + "name": "@wix/auto_sdk_payments_refunds", + "version": "1.0.70", + "wix": { + "sdkDependency": { + "fqdnNamespace": "refunds", + "fqdn": "wix.payments.refunds.v1.refund" + } + }, + "dependencies": { + "@wix/sdk-runtime": "^1.0.24", + "@wix/sdk-types": "^1.17.11" + } +} diff --git a/scripts/fixtures/sdk-lookup/@wix/auto_sdk_payments_refunds/typings/index.typings.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_payments_refunds/typings/index.typings.d.ts.fixture new file mode 100644 index 000000000..84424dff8 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/auto_sdk_payments_refunds/typings/index.typings.d.ts.fixture @@ -0,0 +1,35 @@ +import '@wix/sdk-types'; + +interface Refund { + _id?: string | null; +} +interface RefundCreatedEnvelope { + entity: Refund; +} +/** + * Retrieves a refund. + * @param refundId - ID of the refund to retrieve. + * @public + * @documentationMaturity preview + * @requiredField refundId + * @permissionId PAYMENTS.REFUND_READ + * @applicableIdentity APP + * @fqn wix.payments.refunds.v1.RefundService.GetRefund + * @deprecated Use getRefundV2 instead. + * @replacedBy wix.payments.refunds.v1.RefundService.GetRefundV2 + * @targetRemovalDate 2027-01-01 + */ +declare function getRefund(refundId: string): Promise; +/** @permissionScope Manage Refunds + * @permissionScopeId SCOPE.DC-PAYMENTS.MANAGE-REFUNDS + * @permissionScope Read Refunds + * @permissionScopeId SCOPE.DC-PAYMENTS.READ-REFUNDS + * @permissionId PAYMENTS.REFUND_READ + * @webhook + * @eventType wix.payments.refunds.v1.refund_created + * @serviceIdentifier wix.payments.refunds.v1.RefundService + * @slug created + */ +declare function onRefundCreated(handler: (event: RefundCreatedEnvelope) => void | Promise): void; + +export { type Refund, type RefundCreatedEnvelope, getRefund, onRefundCreated }; diff --git a/scripts/fixtures/sdk-lookup/@wix/crm/package.json b/scripts/fixtures/sdk-lookup/@wix/crm/package.json new file mode 100644 index 000000000..73fdb9131 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/crm/package.json @@ -0,0 +1,8 @@ +{ + "name": "@wix/crm", + "version": "1.0.1648", + "dependencies": { + "@wix/auto_sdk_crm_contacts": "1.0.1380", + "@wix/auto_sdk_crm_contacts-v-5": "1.0.22" + } +} diff --git a/scripts/fixtures/sdk-lookup/@wix/crm/typings/index.d.ts.fixture b/scripts/fixtures/sdk-lookup/@wix/crm/typings/index.d.ts.fixture new file mode 100644 index 000000000..5ae9489b1 --- /dev/null +++ b/scripts/fixtures/sdk-lookup/@wix/crm/typings/index.d.ts.fixture @@ -0,0 +1,4 @@ +import * as auto_sdk_crm_contacts from '@wix/auto_sdk_crm_contacts'; +export { auto_sdk_crm_contacts as contacts }; +import * as auto_sdk_crm_contactsV5 from '@wix/auto_sdk_crm_contacts-v-5'; +export { auto_sdk_crm_contactsV5 as contactsV5 }; diff --git a/scripts/test-sdk-lookup.cjs b/scripts/test-sdk-lookup.cjs new file mode 100644 index 000000000..d12a77f63 --- /dev/null +++ b/scripts/test-sdk-lookup.cjs @@ -0,0 +1,275 @@ +// Tests skills/wix-app/scripts/sdk-lookup.cjs against trimmed copies of real SDK +// packages (@wix/crm 1.0.1648 and refunds). Run: node --test scripts/test-sdk-lookup.cjs +// +// The fixtures live under scripts/fixtures/sdk-lookup/@wix//typings/*.fixture +// (the suffix keeps tools from reading them as source); each test run installs them +// into a temporary project as node_modules/@wix//build/cjs/. +const { test, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const { spawnSync } = require('node:child_process'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const LOOKUP = path.join(__dirname, '..', 'skills', 'wix-app', 'scripts', 'sdk-lookup.cjs'); +const FIXTURES = path.join(__dirname, 'fixtures', 'sdk-lookup', '@wix'); +let project; + +before(() => { + project = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-lookup-')); + fs.writeFileSync(path.join(project, 'package.json'), JSON.stringify({ dependencies: { '@wix/crm': '1.0.1648' } })); + for (const name of fs.readdirSync(FIXTURES)) { + const to = path.join(project, 'node_modules', '@wix', name); + fs.mkdirSync(path.join(to, 'build', 'cjs'), { recursive: true }); + fs.copyFileSync(path.join(FIXTURES, name, 'package.json'), path.join(to, 'package.json')); + const typings = path.join(FIXTURES, name, 'typings'); + for (const file of fs.readdirSync(typings)) { + fs.copyFileSync(path.join(typings, file), path.join(to, 'build', 'cjs', file.replace(/\.fixture$/, ''))); + } + } +}); + +after(() => fs.rmSync(project, { recursive: true, force: true })); + +const run = (...args) => { + const r = spawnSync(process.execPath, [LOOKUP, ...args], { cwd: project, encoding: 'utf8' }); + return { code: r.status, out: r.stdout, err: r.stderr }; +}; +const json = (...args) => JSON.parse(run(...args, '--json').out); +const method = (candidate, name) => candidate.record.methods.find((m) => m.name === name); + +test('a contacts search picks contactsV5, the namespace with a search method and the higher version', () => { + const [r] = json('contacts', '--intent', 'search'); + assert.equal(r.status, 'found'); + assert.equal(r.candidates[0].namespace, 'contactsV5'); + assert.equal(r.candidates[0].from, '@wix/crm'); + assert.ok(r.candidates.some((c) => c.namespace === 'contacts'), 'V4 is listed as the other namespace'); +}); + +test('the shape line is one search call with the term in search.expression', () => { + const { code, out } = run('contacts', '--intent', 'search'); + assert.equal(code, 0); + assert.match(out, /^shape contactsV5\.searchContacts\(\{ search: \{ expression: term \} \}\): one call; the term matches [^\n]*name\.full/m); +}); + +test('a method shows its public parameters, not the REST request type', () => { + const [r] = json('contacts', '--intent', 'search'); + assert.deepEqual(method(r.candidates[0], 'searchContacts').params, ['search: ContactSearch', 'options?: SearchContactsOptions']); + assert.match(run('contacts', '--intent', 'search').out, /contactsV5\.searchContacts\(search: ContactSearch, options\?: SearchContactsOptions\)/); +}); + +test('a search method prints the keys of its argument and response, past the empty placeholder', () => { + const { out } = run('contacts', '--intent', 'search'); + assert.match(out, /arg {5}search: \{ cursorPaging: \{ limit, cursor \}, filter, sort: \[\{ fieldName, order \}\], search: \{ mode, expression \} \}/); + assert.match(out, /returns \{ contacts: Contact\[\], pagingMetadata: \{ count, cursors: \{ next, prev \}, hasNext \} \}/); +}); + +test('the search spec gives the closed filter list, with no line for a free-text-only field', () => { + const { out } = run('contacts', '--intent', 'search'); + assert.match(out, /free text: company\.name, email\.email, name\.full, phone\.phone/); + assert.match(out, /filter \$eq \$exists \$in \$ne \$nin \$startsWith: company\.name, email\.email, phone\.phone, name\.first/); + assert.doesNotMatch(out, /filter :/); + assert.match(out, /closed list/); +}); + +test('a query builder lists the fields per operator', () => { + const [r] = json('contacts', '--intent', 'read'); + assert.deepEqual(method(r.candidates[0], 'queryContacts').builder.gt, ['_createdDate', '_updatedDate']); +}); + +test('a builder that also takes a query object prints that form, with its own paging key', () => { + const [r] = json('services', '--intent', 'read'); + const q = method(r.candidates[0], 'queryServices').queryForm; + assert.deepEqual(q.params, ['query: ServiceQuery', 'options?: QueryServicesOptions']); + assert.deepEqual(q.keys, ['paging', 'filter', 'sort']); + const { out } = run('services', '--intent', 'read'); + assert.match(out, /or {6}services\.queryServices\(query: ServiceQuery, options\?: QueryServicesOptions\) → Promise/); + assert.match(out, /or one query object, services\.queryServices\(\{ paging, filter, sort \}\), whose filter also takes \$or/); +}); + +test('a query of another type in the namespace does not get the entity filter list', () => { + const [r] = json('services', '--intent', 'read'); + assert.ok(method(r.candidates[0], 'queryServices').filterable, 'the builder keeps it'); + assert.equal(method(r.candidates[0], 'queryPolicies').filterable, undefined); +}); + +test('a builder with no filter sends the call to the query object, and an empty spec points to the docs', () => { + const read = run('extendedBookings', '--intent', 'read').out; + assert.match(read, /^shape extendedBookings\.queryExtendedBookings\(\{ cursorPaging, filter, sort \}\): the builder has no filter or sort, so pass one query object$/m); + assert.match(read, /filter fields: not in the package \(the method docs page lists them\)/); + assert.doesNotMatch(read, /closed list/); + const search = run('extendedBookings', '--intent', 'search').out; + assert.match(search, /^shape extendedBookings\.queryExtendedBookings\(\{ cursorPaging, filter, sort \}\): no search method, and the builder has no or\(\): pass one query object instead$/m); + assert.doesNotMatch(search, /startsWith on ONE field/); +}); + +test('without free text, a search term goes in $startsWith on the string fields of the filter list, ids left out', () => { + const [r] = json('members', '--intent', 'search'); + assert.deepEqual(method(r.candidates[0], 'queryMembers').textFields, ['loginEmail', 'contact.firstName'], 'a nested string field is followed; _id is left out'); +}); + +test('a string field under operators * is a text field: it takes every operator, $startsWith too', () => { + const [r] = json('services', '--intent', 'search'); + const m = method(r.candidates[0], 'queryServices'); + assert.deepEqual(m.textFields, []); + assert.deepEqual(m.textFieldsAny, ['name']); + const { out } = run('services', '--intent', 'search'); + assert.match(out, /^shape services\.queryServices\(\{ paging, filter, sort \}\): no search method, and the builder has no or\(\): pass one query object instead$/m); + assert.match(out, /the term: filter: \{ \$or: \[\{ 'name': \{ \$startsWith: term \} \}\] \}$/m); + assert.match(out, /text fields with operators '\*' \(every operator, \$startsWith too\): name$/m); + assert.doesNotMatch(out, /exact \$eq/); + assert.doesNotMatch(out, /no text match/); +}); + +test('free text in a query method\'s options is the search, with its field default named', () => { + const [r] = json('members', '--intent', 'search'); + const q = method(r.candidates[0], 'queryMembers'); + assert.deepEqual(q.optionsSearch, { + param: 'options', + fields: ['loginEmail', 'contact.firstName', 'contact.lastName', 'profile.title', 'profile.nickname', 'profile.slug'], + default: 'profile.nickname', + max: 4, + }); + const { out } = run('members', '--intent', 'search'); + assert.match(out, /^shape members\.queryMembers\(\{ search: \{ expression: term, fields \} \}\)\.find\(\): no search method; free text in its options\.search$/m); + assert.match(out, /search\.fields defaults to profile\.nickname alone: name every field the term must match \(at most 4\)/); + assert.doesNotMatch(out, /startsWith on ONE field/); +}); + +test('a filter list with no text field says the API cannot match a term, not to filter in the client', () => { + const [r] = json('contacts', '--intent', 'search'); + const v4 = r.candidates.find((c) => c.namespace === 'contacts'); + assert.deepEqual(method(v4, 'queryContacts').textFields, []); + assert.match(v4.shape[0], /^contacts\.queryContacts\(\): no text match in the package/); + assert.ok(v4.shape.some((l) => /never match only the loaded page, or a capped or cached set/.test(l))); + assert.ok(v4.shape.some((l) => /next: its docs page \(SearchWixSDKDocumentation "queryContacts"\), not the typings/.test(l))); + assert.ok(!v4.shape.some((l) => /types search\.expression/.test(l)), 'a builder has no search argument'); +}); + +test('when the package does not list the filter fields, the term line points to the docs page', () => { + const [r] = json('extendedBookings', '--intent', 'search'); + assert.equal(method(r.candidates[0], 'queryExtendedBookings').textFields, undefined); + const { out } = run('extendedBookings', '--intent', 'search'); + assert.match(out, /the term: filter \$or, one clause per text field — the package has no filter list for this method$/m); + assert.match(out, /next: its docs page \(SearchWixSDKDocumentation "queryExtendedBookings filter"\), not the typings or the compiled JS/); + assert.match(out, /\$contains where a field takes it/); +}); + +test('a write method needs the revision', () => { + const [r] = json('contacts', '--intent', 'write'); + assert.deepEqual(method(r.candidates[0], 'updateContact').required, ['revision']); + assert.match(run('contacts', '--intent', 'write').out, /read the entity first/i); +}); + +test('a method prints its JSDoc notes, which say who may call it where the identity tag does not', () => { + const [r] = json('members', '--intent', 'all'); + const m = method(r.candidates[0], 'getCurrentMember'); + assert.deepEqual(m.identities, ['APP']); + assert.deepEqual(m.notes, ['This method requires visitor or member authentication.']); + assert.match(run('members', '--intent', 'all').out, /^ {16}note {4}This method requires visitor or member authentication\.$/m); +}); + +// A project of its own, for package layouts the shared fixtures must not carry. +const scratchProject = (prefix, wrappers, body) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const pkg = (name, files, pj = {}) => { + const to = path.join(dir, 'node_modules', '@wix', name); + fs.mkdirSync(path.join(to, 'build', 'cjs'), { recursive: true }); + fs.writeFileSync(path.join(to, 'package.json'), JSON.stringify({ name: `@wix/${name}`, ...pj })); + for (const [f, src] of Object.entries(files)) fs.writeFileSync(path.join(to, 'build', 'cjs', f), src); + }; + try { + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ dependencies: Object.fromEntries(wrappers.map((w) => [`@wix/${w}`, '1.0.0'])) })); + return body(pkg, (...args) => spawnSync(process.execPath, [LOOKUP, ...args], { cwd: dir, encoding: 'utf8' })); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } +}; + +test('an export form the lookup does not read exits 4 instead of labeling the package not exported', () => { + scratchProject('sdk-lookup-odd-', ['odd'], (pkg, lookup) => { + pkg('auto_sdk_odd_things', { 'index.typings.d.ts': '' }); + pkg('odd', { 'index.d.ts': "export * as things from '@wix/auto_sdk_odd_things';\n" }, { dependencies: { '@wix/auto_sdk_odd_things': '1.0.0' } }); + const r = lookup('things'); + assert.equal(r.status, 4); + assert.match(r.stderr, /@wix\/odd exports @wix\/auto_sdk_odd_things in a form sdk-lookup does not read/); + }); +}); + +test('across packages that export one namespace, the module map package wins over a higher API version', () => { + scratchProject('sdk-lookup-shared-', ['ecom', 'pricing-plans'], (pkg, lookup) => { + const orders = (vertical, fqdn, doc) => { + const auto = `auto_sdk_${vertical}_orders`; + pkg(auto, { 'index.typings.d.ts': `${doc ? `/**\n * ${doc}\n *\n * More text.\n */\n` : ''}interface Order {\n _id?: string | null;\n}\n` }, { wix: { sdkDependency: { fqdn } } }); + pkg(vertical, { 'index.d.ts': `import * as ${auto.replace(/-/g, '_')} from '@wix/${auto}';\nexport { ${auto.replace(/-/g, '_')} as orders };\n` }, { dependencies: { [`@wix/${auto}`]: '1.0.0' } }); + }; + orders('ecom', 'wix.ecom.v1.order', 'An order represents a completed purchase from a buyer. Orders are created through checkout.'); + orders('pricing-plans', 'wix.pricing_plans.v2.order', 'An order object includes all of the details related to the purchase of a Pricing Plan.'); + const r = lookup('orders'); + assert.equal(r.status, 0, r.stderr); + assert.match(r.stdout, /^use {3}orders \(@wix\/ecom\) — fqdn v1 \(wix\.ecom\.v1\.order\); the module map package — "An order represents a completed purchase from a buyer\."$/m); + assert.match(r.stdout, /^also {2}orders \(@wix\/pricing-plans\) — fqdn v2 [^\n]* — "An order object includes all of the details related to the purchase of a Pricing Plan\."$/m); + assert.match(r.stdout, /several packages export this name/); + }); +}); + +test('with no module map row, the package named after the namespace wins, then one with a description', () => { + const categories = (pkg, vertical, doc) => { + const auto = `auto_sdk_${vertical}_categories`; + const id = auto.replace(/-/g, '_'); + pkg(auto, { 'index.typings.d.ts': `${doc ? `/** ${doc} */\n` : ''}interface Category {\n _id?: string | null;\n}\n` }, { wix: { sdkDependency: { fqdn: `wix.${vertical}.v1.category` } } }); + pkg(vertical, { 'index.d.ts': `import * as ${id} from '@wix/${auto}';\nexport { ${id} as categories };\n` }, { dependencies: { [`@wix/${auto}`]: '1.0.0' } }); + }; + scratchProject('sdk-lookup-namesake-', ['blog', 'categories', 'events'], (pkg, lookup) => { + categories(pkg, 'blog', null); + categories(pkg, 'categories', 'A category for organizing items into a hierarchical structure.'); + categories(pkg, 'events', 'A Category groups related events.'); + const out = lookup('categories').stdout; + assert.match(out, /^use {3}categories \(@wix\/categories\) — [^\n]*the package named after it/m); + assert.ok(out.indexOf('(@wix/events)') < out.indexOf('(@wix/blog)'), 'a package with a description comes before one without'); + }); +}); + +test('one package with two namespaces gets no description line', () => { + assert.doesNotMatch(run('contacts', '--intent', 'search').out, /several packages export this name|no description/); +}); + +test('the scope says where to find it, the other methods say how to see them, the package is named', () => { + const { out } = run('contacts', '--intent', 'search'); + assert.match(out, /scope: not in the package \(the method docs page lists it\)/); + assert.match(out, /other methods \(params, permission, scope: run again with --intent write \(or all\)\): [^\n]*updateContact/); + assert.match(out, /^ {2}package @wix\/auto_sdk_crm_contacts-v-5@1\.0\.22$/m); + assert.doesNotMatch(out, /sdk-index/); +}); + +test('the entity type and package lines come right after the import, before the methods a | head can cut', () => { + const lines = run('contacts', '--intent', 'write').out.split('\n'); + const at = lines.indexOf("import { contactsV5 } from '@wix/crm';"); + assert.ok(at >= 0); + assert.match(lines[at + 1], /^ {2}type Contact {2}\S+:\d+$/); + assert.match(lines[at + 2], /^ {2}package @wix\/auto_sdk_crm_contacts-v-5@/); +}); + +test('an event shows its scope IDs, and a package whose wrapper is not installed gets the install command', () => { + const { code, out } = run('refunds', '--intent', 'event'); + assert.equal(code, 0); + assert.match(out, /refunds\.onRefundCreated\(handler\) · scope SCOPE\.DC-PAYMENTS\.MANAGE-REFUNDS \| SCOPE\.DC-PAYMENTS\.READ-REFUNDS/); + assert.match(out, /npm i @wix\/payments/); +}); + +test('one call answers several entities', () => { + const results = json('contacts', 'refunds', '--intent', 'all'); + assert.deepEqual(results.map((r) => r.status), ['found', 'found']); +}); + +test('an unknown name exits 1 with near matches', () => { + const { code, out } = run('contcts'); + assert.equal(code, 1); + assert.match(out, /near: [^\n]*contacts/); +}); + +test('an entity whose package is not installed exits 3 with the install command', () => { + const { code, out } = run('bookings'); + assert.equal(code, 3); + assert.match(out, /npm i @wix\/bookings/); +}); diff --git a/skills/wix-app/SKILL.md b/skills/wix-app/SKILL.md index 3dc3181eb..2c9f10c56 100644 --- a/skills/wix-app/SKILL.md +++ b/skills/wix-app/SKILL.md @@ -33,8 +33,8 @@ Helps build extensions for Wix CLI applications. Covers all extension types: das - [ ] **🛑 Component Docs Gate (MANDATORY, dashboard UI only):** For each patterns symbol you are about to write, decided **from the lookup output** which single artifact answers the question you actually have — the import line, an example, or the props file — and read only that one, per [Component Selection Order](#component-selection-order)'s "the short version". State which artifact you read per symbol, and why, before the first line of JSX. Reading a doc *and* its bundle for the same symbol, or opening a page for an import line the lookup already gave you, is the failure this gate exists to prevent. For the object `useTableCollection()` returns, read [TABLE_STATE.md](references/dashboard-page/TABLE_STATE.md) — a state object you receive rather than construct, whose members are unobvious and several plausible ones absent. -- [ ] **Step 3:** Checked API references; used MCP discovery only for gaps - - [ ] **Dashboard page over Wix data:** located the method and verified every mapped field against the installed SDK's own declaration first — see [DATA_SOURCES.md](references/dashboard-page/DATA_SOURCES.md), and [QUERY_AND_PAGING.md](references/dashboard-page/QUERY_AND_PAGING.md) before writing `fetchData`. A field marked `@deprecated` still compiles and renders something plausible and wrong. +- [ ] **Step 3:** Checked API references; resolved every vertical entity in **one** `sdk-lookup.cjs` call ([SDK lookup](#sdk-lookup-one-call-for-every-vertical-entity)); used the docs only for what it does not print + - [ ] **Dashboard page over Wix data:** took the method from the lookup and verified every mapped field against the declaration it points to (`type … file:line`) — see [DATA_SOURCES.md](references/dashboard-page/DATA_SOURCES.md), and [QUERY_AND_PAGING.md](references/dashboard-page/QUERY_AND_PAGING.md) before writing `fetchData`. A field marked `@deprecated` still compiles and renders something plausible and wrong. - [ ] **Vertical SDK prerequisites — for every `@wix/*` vertical the page touches, including one added later:** confirmed the package is actually a dependency (installed it if not), and noted the Dev Center permission scope the read needs — a missing scope produces a page that builds, mounts and shows nothing. Both in [DATA_SOURCES.md](references/dashboard-page/DATA_SOURCES.md#two-things-to-settle-before-you-write-the-page); the scope goes under [Manual Steps Required](#-manual-steps-required). **A second vertical added during Step 4b needs this check too, and its failure must not take down the page** — see [A second vertical is a second scope](references/dashboard-page/DATA_SOURCES.md#a-second-vertical-is-a-second-scope). - [ ] **Modelled the call on the SDK, not the REST page:** namespace name, `_id` vs `id`, no `ReturnType` on overloaded methods, no `hasNext` on `PagingMetadataV2` — see [The SDK is not the REST API](references/dashboard-page/DATA_SOURCES.md#the-sdk-is-not-the-rest-api). - [ ] Site/editor extensions only: kept SDK calls in the extension by default, routing out only business-wide methods a visitor genuinely cannot call (see [Identity and Elevation Requirement](#identity-and-elevation-requirement)) @@ -227,7 +227,7 @@ Use a Dashboard Modal for dialogs that neither write nor display a listed record **CRITICAL:** Data owned by an existing Wix business app is read and written through that app's SDK module — NEVER modeled as a new CMS Data Collection. A custom collection for such data starts empty and stays disconnected from the real records (e.g., a "refunds dashboard" built on CMS shows an empty state while refunded orders exist in Wix eCommerce). -Find the entity the user mentioned in the [entity → SDK module map](references/SDK_MODULE_MAP.md) and use that package. If the entity isn't listed or you're unsure, run `SearchWixSDKDocumentation` for it — **never conclude CMS with zero MCP calls**. CMS is only for data your app itself introduces (configuration, rules, app-specific records) that no Wix app manages. +Find the entity the user mentioned in the [entity → SDK module map](references/SDK_MODULE_MAP.md) and use that package; the [SDK lookup](#sdk-lookup-one-call-for-every-vertical-entity) then names the namespace and method. If the entity isn't listed and the lookup finds nothing, run `SearchWixSDKDocumentation` for it — **never conclude CMS without one of the two**. CMS is only for data your app itself introduces (configuration, rules, app-specific records) that no Wix app manages. **SDK types:** access them through the namespace you import, as `.`, using any type name shown in the docs — never import a type by name from the `@wix/` root. @@ -237,6 +237,8 @@ const rows: orders.Order[] = []; // ✅ // import type { Order } from '@wix/ecom'; // ❌ has no exported member 'Order' ``` +**A map keyed by an SDK enum:** fields are typed `WithLiterals` (the enum or its string values). `Partial>` cannot be indexed by a status (TS7053). Use `Record<…WithLiterals, V>` with an entry for every value, or key a partial map by the string values: `` Partial> ``. + --- ## Data Collection Inference @@ -307,14 +309,14 @@ Who the extension runs as decides everything below — the Category column in [E **Default: call the SDK directly from the extension.** Routing a call that didn't need it is not a harmless extra hop — it is how working features break. Sort by who the call acts for, never by its scope name: -- **Acts for the current visitor or member** — their cart, checkout, booking, order, reservation, or profile: `currentCartV2.*`, `cartV2.placeOrder`, `bookings.createBooking`, `members.getMyMember`, and anything else operating on "my" or "the current" entity. These resolve the actor from the caller's session, so elevating runs them as the app and detaches the result from the person who asked — an order with no buyer, a booking with no attendee. +- **Acts for the current visitor or member** — their cart, checkout, booking, order, reservation, or profile: `currentCartV2.*`, `cartV2.placeOrder`, `bookings.createBooking`, `members.getCurrentMember` (REST *Get My Member*), and anything else operating on "my" or "the current" entity. These resolve the actor from the caller's session, so elevating runs them as the app and detaches the result from the person who asked — an order with no buyer, a booking with no attendee. - **The platform filters the result by caller** — an elevated call returns what the direct call withheld, so a "fix" for a sparse result becomes a leak. Wix Data `items.*` follows the collection's `dataPermissions` (scaffolded default: `itemRead: 'ANYONE'`, writes `'PRIVILEGED'` — fix writes with permissions, not routing; see [DATA_COLLECTION.md](references/DATA_COLLECTION.md)). Catalog reads return base fields to anyone, withholding `MERCHANT_DATA` and non-visible products unless the app holds `SCOPE.STORES.PRODUCT_READ_ADMIN`. `members.getMember`/`queryMembers` withhold `PRIVATE` members from visitor and member callers. **Route out only when the method acts on the business as a whole**, which a visitor or member genuinely cannot do: `archiveLocation`, `queryLocations`, catalog and inventory writes, `bookings.confirmBooking`, order management. **When the method's docs show the call elevated, route it out and elevate there** — from any host, dashboard included, because `auth.elevate` only works in backend code, so the endpoint is the only place the documented pattern can run. -**When you're unsure, call it directly and let it fail.** A method a site extension may not call returns a permission error you see immediately; a method wrongly routed and elevated *succeeds* and silently returns the wrong data or acts for the wrong person. Some method pages carry a prose note that settles it — `get-my-member`: "This method requires visitor or member authentication." Authoritative when present, but only a minority of pages have one, so its absence decides nothing. +**When you're unsure, call it directly and let it fail.** A method a site extension may not call returns a permission error you see immediately; a method wrongly routed and elevated *succeeds* and silently returns the wrong data or acts for the wrong person. Some method pages carry a prose note that settles it — `get-my-member`: "This method requires visitor or member authentication." Authoritative when present (the SDK lookup prints it as the method's `note`), but only a minority of pages have one, so its absence decides nothing. -Two signals never settle it: the scope name — `locations.queryLocations` is `SCOPE.DC-MULTILOCATION.READ-LOCATIONS` yet admin-only, while `currentCartV2.addLineItemsToCurrentCart` carries `SCOPE.ECOM.MANAGE-ADMIN` yet is visitor-callable and breaks if elevated — and the SDK schema line's client prefix, which varies by docs channel for the same method, so carries nothing and isn't worth re-deriving. +Two signals never settle it: the scope name — the methods whose scope name points the wrong way are rules in [sdk-curated.json](scripts/sdk-curated.json), which the [SDK lookup](#sdk-lookup-one-call-for-every-vertical-entity) prints as `note` — and the SDK schema line's client prefix, which varies by docs channel for the same method, so carries nothing and isn't worth re-deriving. Routing out means a Backend API endpoint that elevates and is reached with `httpClient.fetchWithAuth()`. Elevation bypasses Wix's permission check, so the endpoint must re-check the caller itself — see [Identity and Authorization](references/BACKEND_API.md#identity-and-authorization) for what each host can actually verify, and why an owner-only operation belongs in a dashboard extension instead. @@ -344,7 +346,7 @@ Use the Extension Types Reference Table and decision content above. State extens ### Step 3: Read Extension Reference, Check API References, Then Discover (if needed) -**Workflow: Read extension reference → Check API references → Use MCP only for gaps.** +**Workflow: Read extension reference → Check API references → SDK lookup for vertical entities → docs only for what the lookup does not print.** 1. **Read the extension reference file** for the chosen extension type from the table above 2. **Identify required APIs** from user requirements @@ -355,23 +357,23 @@ Use the Extension Types Reference Table and decision content above. State extens - Service Plugin SPIs → read `references/SERVICE_PLUGIN.md` together with the matching `references/service-plugin/.md` leaf - App Tools (AI assistant tools) → read `references/APP_TOOLS.md`; it links to `references/app-tools/TOOLS.md` (declaration) and `references/service-plugin/TOOLS_PROVIDER.md` (handler) 4. **Verify the specific method/event exists** in references -5. **ONLY use MCP discovery if NOT found** in reference files +5. **For vertical entities, run the [SDK lookup](#sdk-lookup-one-call-for-every-vertical-entity)** once, with every entity; use MCP discovery only for what neither the references nor the lookup answer **Platform APIs (never discover - in references):** - Wix Data, Dashboard SDK, Event SDK (common events), Service Plugin SPIs **Vertical APIs (discover if needed):** -- Wix Stores (**⚠️ MUST use Stores Versioning reference** — V1/V3 catalog check required), Wix eCommerce, Wix Bookings, Wix Members, Wix Pricing Plans, third-party integrations — find the right `@wix/*` package in the [SDK-First Rule](#sdk-first-rule-existing-wix-app-data-is-never-cms) module map first, then discover methods via MCP +- Wix Stores (**⚠️ MUST use Stores Versioning reference** — V1/V3 catalog check required), Wix eCommerce, Wix Bookings, Wix Members, Wix Pricing Plans, third-party integrations — find the right `@wix/*` package in the [SDK-First Rule](#sdk-first-rule-existing-wix-app-data-is-never-cms) module map first, install it, then get the namespace and method from the [SDK lookup](#sdk-lookup-one-call-for-every-vertical-entity) **Decision table:** | User Requirement | Check References / Discovery Needed? | Reason / Reference File | | ------------------------------------ | ------------------------------------ | --------------------------------------------------- | -| "Display store products" | ✅ YES (MCP discovery) | Wix Stores API — **include Stores Versioning reference** | -| "Dashboard for orders / refunds" | ✅ YES (MCP discovery) | Wix eCommerce API (`@wix/ecom`) — **NEVER a CMS collection** | -| "Show booking calendar" | ✅ YES (MCP discovery) | Wix Bookings API not in reference files | -| "Send emails to users" | ✅ YES (MCP discovery) | Wix Triggered Emails not in reference files | -| "Get member info" | ✅ YES (MCP discovery) | Wix Members API not in reference files | +| "Display store products" | ✅ YES (SDK lookup) | Wix Stores API — **include Stores Versioning reference** | +| "Dashboard for orders / refunds" | ✅ YES (SDK lookup) | Wix eCommerce API (`@wix/ecom`) — **NEVER a CMS collection** | +| "Show booking calendar" | ✅ YES (SDK lookup) | Wix Bookings API (`@wix/bookings`); SPI leaves cover only service plugins | +| "Send emails to users" | ✅ YES (SDK lookup, then MCP) | Wix Triggered Emails not in reference files | +| "Get member info" | ✅ YES (SDK lookup) | Wix Members API not in reference files | | "Listen for cart events" | Check `COMMON-EVENTS.md` | MCP discovery only if event missing in reference | | "Store data in collection" | WIX_DATA.md ✅ Found | ❌ Skip discovery (covered by reference) | | "Create CMS collections for my app" | Data Collection reference | ❌ Skip discovery (covered by dedicated reference) | @@ -381,7 +383,26 @@ Use the Extension Types Reference Table and decision content above. State extens | "Settings page with form inputs" | N/A (UI only, no external API) | ❌ Skip discovery | | "Dashboard page with local state" | N/A (no external API) | ❌ Skip discovery | -**MCP Tools for discovery (when needed):** +#### SDK lookup: one call for every vertical entity + +```bash +node /scripts/sdk-lookup.cjs contacts orders --intent search # search | read | write | event | all +``` + +Run it from the app directory, after the vertical packages are installed, with **every entity the feature touches in one call**. Per entity it prints: + +- the namespace to use and why (a curated rule; within one package, a `search*` method for a search, then the highest API version; across packages that export the same name, the module map's package, else the package named after it), and the other namespaces — across packages, each with its entity description, so switch when the feature matches another one; +- the import line; +- `shape`: the one call to write for the intent, with its reason and, for a search, where the term goes; +- each method for the intent, with its call shape (`search`, `query-builder`, `query`, …), permission, required fields (`revision`) and its docs notes (`note`); for a search, query or list method also `arg` and `returns`, the keys of its argument (`cursorPaging`, `filter`, `sort`, `search`) and of its response (`pagingMetadata.cursors.next`); a filter list only when the package declares one (otherwise the method docs page lists the fields) — not who may call it: that stays with [Identity and Elevation Requirement](#identity-and-elevation-requirement); +- the entity type as `file:line` — the declaration to verify fields against ([DATA_SOURCES.md](references/dashboard-page/DATA_SOURCES.md#confirming-a-field--the-part-that-ships-bugs)); +- the curated rules for the entity (`note`), such as the Stores V1/V3 check. + +Take the namespace, the method and the call shape from its output; do not re-derive them from the typings, and follow the lines under `shape`. When the lookup prints `site`, the choice depends on the site (Stores: call `getCatalogVersion()` and support both). `not installed` prints the install command: install, then run the lookup again. A name it does not know exits 1 with near matches. + +It prints each search and query method's closed filter list (free-text fields, operators per field, sort) from the package's own declarations. It prints a method's **permission scope ID** only when the package declares it; when the output says `scope: not in the package`, get the scope from the method's docs page (the next list). + +**MCP Tools for discovery (for what the lookup does not print):** - `SearchWixSDKDocumentation` - SDK methods and APIs (**Always use maxResults: 5**) - `ReadFullDocsMethodSchema` - Full type schema for a specific SDK method (parameters, return type, permissions) diff --git a/skills/wix-app/references/BACKEND_API.md b/skills/wix-app/references/BACKEND_API.md index 075c7e036..a602e67eb 100644 --- a/skills/wix-app/references/BACKEND_API.md +++ b/skills/wix-app/references/BACKEND_API.md @@ -227,7 +227,7 @@ caller before invoking an elevated method. Use `httpClient.fetchWithAuth()` to send caller identity; a bare `fetch` does not supply that identity. Authentication alone does not grant permission to perform the operation. -Dashboard callers are Wix users. For site/editor callers, `members.getMyMember()` +Dashboard callers are Wix users. For site/editor callers, `members.getCurrentMember()` identifies a logged-in member but does not prove site ownership. Owner-only operations belong in a dashboard extension; do not expose them to site visitors on the assumption that member authentication proves ownership. diff --git a/skills/wix-app/references/SDK_MODULE_MAP.md b/skills/wix-app/references/SDK_MODULE_MAP.md index 436bdd9f8..7fddbadde 100644 --- a/skills/wix-app/references/SDK_MODULE_MAP.md +++ b/skills/wix-app/references/SDK_MODULE_MAP.md @@ -43,4 +43,4 @@ Data owned by an existing Wix business app is read and written through that app' Types from these packages are accessed as `.` — see [SDK types](../SKILL.md#sdk-first-rule-existing-wix-app-data-is-never-cms). -If the entity isn't listed or you're unsure, run `SearchWixSDKDocumentation` for it — **never conclude CMS with zero MCP calls**. CMS is only for data your app itself introduces (configuration, rules, app-specific records) that no Wix app manages. +This map names the package, never the namespace or the method: install the package, then run the [SDK lookup](../SKILL.md#sdk-lookup-one-call-for-every-vertical-entity) for the namespace and method. If the entity isn't listed and the lookup finds nothing, run `SearchWixSDKDocumentation` for it — **never conclude CMS without one of the two**. CMS is only for data your app itself introduces (configuration, rules, app-specific records) that no Wix app manages. diff --git a/skills/wix-app/references/STORES_VERSIONING.md b/skills/wix-app/references/STORES_VERSIONING.md index c25d9d13e..48dc8bf8c 100644 --- a/skills/wix-app/references/STORES_VERSIONING.md +++ b/skills/wix-app/references/STORES_VERSIONING.md @@ -130,4 +130,4 @@ Payload changes: V1 `changedFields` → V3 `modifiedFields`. Top-level `entityId - [stores/INVENTORY.md](stores/INVENTORY.md) — inventory read and write operations - [stores/CATEGORIES.md](stores/CATEGORIES.md) — collections (V1) ↔ categories (V3) -For methods not listed here, use `SearchWixSDKDocumentation` then `ReadFullDocsMethodSchema` (use `ReadFullDocsArticle` only for prose). Always return the required permission scopes to the user. +For methods not listed here, run the [SDK lookup](../SKILL.md#sdk-lookup-one-call-for-every-vertical-entity) for `products` (it prints both namespaces and their methods), then `ReadFullDocsMethodSchema` for its permission scope (and the filter list if the lookup printed none). Always return the required permission scopes to the user. diff --git a/skills/wix-app/references/dashboard-page/DATA_SOURCES.md b/skills/wix-app/references/dashboard-page/DATA_SOURCES.md index 97d036689..70385aaf9 100644 --- a/skills/wix-app/references/dashboard-page/DATA_SOURCES.md +++ b/skills/wix-app/references/dashboard-page/DATA_SOURCES.md @@ -1,8 +1,9 @@ # Finding the real SDK shape behind a dashboard page > **Scope.** [DRAFT_TEMPLATE.md](DRAFT_TEMPLATE.md) gives you the page; this file finds the method -> and *field names* for its data. Read it before Step 3's MCP discovery. The installed package is -> the version your code compiles against. +> and *field names* for its data. The [SDK lookup](../../SKILL.md#sdk-lookup-one-call-for-every-vertical-entity) +> gives the namespace, the method and the entity's `file:line`; this file checks the fields there. +> The installed package is the version your code compiles against. ## Where the types actually live @@ -18,12 +19,10 @@ node_modules/@wix/auto_sdk_ecom_orders/build/es/ ecom-v1-order-orders.universal-.d.mts # the domain entities ``` -Three consequences worth knowing before you start grepping: +Three consequences: -1. **The directory name is not the namespace name.** Namespaces are camelCase, package directories - are kebab-case — `extendedBookings` lives in `auto_sdk_bookings_extended-bookings`, not - `auto_sdk_bookings_extendedBookings`. Don't derive one from the other; list them: - `ls node_modules/@wix | grep `. +1. **The directory name is not the namespace name.** `extendedBookings` lives in + `auto_sdk_bookings_extended-bookings`. Don't derive one from the other; the SDK lookup maps them. 2. **The entity is not always in `index.typings.d.mts`.** `Order`, `Refund`, `Service` live in the hashed `*.universal-*.d.mts` when the package has one. The hash changes between versions, so glob for it, never hard-code it, and fall back to `index.typings.d.mts` when no universal file exists. @@ -34,12 +33,8 @@ Three consequences worth knowing before you start grepping: ## Confirming a method -```bash -E=node_modules/@wix/auto_sdk_ecom_order-transactions/build/es -grep -oE "^declare function [a-zA-Z]+" $E/index.d.mts -``` - -Prefer this to recall: `listTransactionsForMultipleOrders(orderIds: string[])` and +The SDK lookup lists every method of the namespace (`other methods:` outside the intent). Prefer +its list to recall: `listTransactionsForMultipleOrders(orderIds: string[])` and `listTransactionsForSingleOrder` differ by exactly the shape a collection page needs, and only one of them avoids an N+1 across the visible page. @@ -50,6 +45,7 @@ the uncertain ones, **in one pass**. List the types and fields first, then print per file. A grep per question is a round trip each: ```bash +E=node_modules/@wix/auto_sdk_ecom_order-transactions/build/es awk '/^(interface|type) (Payment|RegularPaymentDetails)[ <{=]/,/^}/' $E/index.typings.d.mts ``` diff --git a/skills/wix-app/scripts/sdk-curated.json b/skills/wix-app/scripts/sdk-curated.json new file mode 100644 index 000000000..0adff039b --- /dev/null +++ b/skills/wix-app/scripts/sdk-curated.json @@ -0,0 +1,45 @@ +{ + "schemaVersion": 1, + "about": "Choices no SDK index can make. sdk-lookup.cjs prints a rule when one of its entities matches. `choice: site` means the namespace depends on the site, not on the install.", + "rules": [ + { + "entities": ["product", "products", "catalog"], + "namespaces": ["products", "productsV3"], + "choice": "site", + "note": "Call catalogVersioning.getCatalogVersion() first: V3 sites use productsV3, V1 sites use products. Support both paths, and request both scope sets plus SCOPE.STORES.CATALOG_READ_LIMITED. To search by name, V3 has productsV3.searchProducts() (queryProducts cannot filter on name); V1 has no search method, and its builder takes name only with startsWith.", + "source": "SKILL.md (Stores), references/STORES_VERSIONING.md, references/stores/QUERY.md" + }, + { + "entities": ["inventory"], + "namespaces": ["inventory", "inventoryItemsV3"], + "choice": "site", + "note": "Call catalogVersioning.getCatalogVersion() first: V3 sites use inventoryItemsV3, V1 sites use inventory. Request both scope sets.", + "source": "references/STORES_VERSIONING.md" + }, + { + "entities": ["refund", "refunds"], + "namespaces": ["orders", "orderTransactions", "refunds"], + "choice": { "prefer": "orders", "intents": ["search", "read"] }, + "note": "Refunded orders: orders.searchOrders with a paymentStatus filter, then orderTransactions.listTransactionsForMultipleOrders(ids) once for the visible page. Refund events are the exception: they live in @wix/payments (namespace refunds), not in @wix/ecom.", + "source": "references/dashboard-page/DATA_SOURCES.md, references/backend-event/COMMON-EVENTS.md" + }, + { + "entities": ["location", "locations"], + "namespaces": ["locations"], + "note": "queryLocations is admin-only although its scope is SCOPE.DC-MULTILOCATION.READ-LOCATIONS: route it to a backend endpoint and elevate it there.", + "source": "moved from SKILL.md (Identity and Elevation Requirement), which links here" + }, + { + "entities": ["member", "members"], + "namespaces": ["members"], + "note": "getMember and queryMembers withhold PRIVATE members from visitor and member callers: never elevate them to fill a sparse result.", + "source": "SKILL.md (Identity and Elevation Requirement)" + }, + { + "entities": ["cart", "currentcart"], + "namespaces": ["currentCartV2", "cartV2"], + "note": "currentCartV2.addLineItemsToCurrentCart is visitor-callable although its scope is SCOPE.ECOM.MANAGE-ADMIN. Never elevate it.", + "source": "moved from SKILL.md (Identity and Elevation Requirement), which links here" + } + ] +} diff --git a/skills/wix-app/scripts/sdk-lookup.cjs b/skills/wix-app/scripts/sdk-lookup.cjs new file mode 100644 index 000000000..697a48751 --- /dev/null +++ b/skills/wix-app/scripts/sdk-lookup.cjs @@ -0,0 +1,1083 @@ +#!/usr/bin/env node +// Answers "which SDK call reads or writes this entity" for every entity in one +// call, from the Wix SDK packages installed in the current project. +// +// node sdk-lookup.cjs contacts orders [--intent search|read|write|event|all] [--json] +// +// Per entity it prints the namespace to use and why, the import, the methods +// for the intent with their call shape, permission and required fields, the +// entity type's declaration (file:line) for checking fields, and any curated +// rule from sdk-curated.json. +// +// Each @wix/auto_sdk_* package is one record, derived from the package's .d.ts +// files: the JSDoc tags (@fqn, @permissionId, @permissionScopeId, ...), the +// public call signatures and the search and query specs, which every +// generated package carries. +// +// Exit codes: 0 every entity found · 1 an entity matched nothing (near matches +// printed) · 3 an entity's package is not installed (install command printed) +// · 2 usage · 4 an installed package is in a form the lookup does not read. +const fs = require('fs'); +const path = require('path'); + +class LookupError extends Error {} + +const INTENTS = { + search: ['search', 'query-builder', 'query', 'list'], + read: ['query-builder', 'query', 'search', 'list', 'get', 'count'], + write: ['create', 'update', 'upsert', 'delete', 'bulk'], + event: [], + all: null, +}; + +// --- package resolution ------------------------------------------------------ + +function activatePnp(from) { + for (let dir = from; ; dir = path.dirname(dir)) { + const pnp = path.join(dir, '.pnp.cjs'); + if (fs.existsSync(pnp)) { + try { + require(pnp).setup(); + } catch { + // Not fatal: node_modules resolution below may still work. + } + return; + } + if (path.dirname(dir) === dir) return; + } +} + +function resolveDir(name, from) { + try { + return path.dirname(require.resolve(`${name}/package.json`, { paths: [from] })); + } catch { + for (let dir = from; ; dir = path.dirname(dir)) { + const candidate = path.join(dir, 'node_modules', ...name.split('/')); + if (fs.existsSync(path.join(candidate, 'package.json'))) return candidate; + if (path.dirname(dir) === dir) return null; + } + } +} + +const readJson = (file) => JSON.parse(fs.readFileSync(file, 'utf8')); +// contacts-v-5 → contactsV5, order-transactions → orderTransactions +const camel = (s) => s.replace(/-v-(\d+)$/, 'V$1').replace(/-(\w)/g, (_, c) => c.toUpperCase()); +const isAuto = (name) => name.startsWith('@wix/auto_sdk_'); + +function wixDirsAbove(from) { + const out = []; + for (let dir = from; ; dir = path.dirname(dir)) { + const wix = path.join(dir, 'node_modules', '@wix'); + if (fs.existsSync(wix)) out.push(wix); + if (path.dirname(dir) === dir) return out; + } +} + +// Finds every public SDK package (one that depends on @wix/auto_sdk_* +// packages) and every auto package, with the namespace each is exported as. +function findPackages(project) { + const wrappers = new Map(); // name -> dir + const consider = (name, from) => { + if (wrappers.has(name) || isAuto(name) || !name.startsWith('@wix/')) return; + const dir = resolveDir(name, from); + if (!dir) return; + const deps = Object.keys(readJson(path.join(dir, 'package.json')).dependencies ?? {}); + if (deps.some(isAuto)) wrappers.set(name, dir); + }; + const pj = path.join(project, 'package.json'); + if (fs.existsSync(pj)) { + const { dependencies = {}, devDependencies = {} } = readJson(pj); + for (const name of Object.keys({ ...dependencies, ...devDependencies })) consider(name, project); + } + for (const wix of wixDirsAbove(project)) { + for (const d of fs.readdirSync(wix)) consider(`@wix/${d}`, project); + } + + const autos = new Map(); // name -> { dir, exportedAs: [] } + for (const [wrapper, dir] of wrappers) { + const deps = Object.keys(readJson(path.join(dir, 'package.json')).dependencies ?? {}); + const barrel = ['build/cjs/index.d.ts', 'build/es/index.d.mts'] + .map((f) => path.join(dir, f)) + .find((f) => fs.existsSync(f)); + const src = barrel ? fs.readFileSync(barrel, 'utf8') : ''; + for (const dep of deps.filter(isAuto)) { + const depDir = resolveDir(dep, dir); + if (!depDir) continue; + const entry = autos.get(dep) ?? { dir: depDir, exportedAs: [] }; + const m = src.match(new RegExp(`import \\* as (\\w+) from '${dep}';\\s*export \\{ \\1 as (\\w+) \\}`)); + if (m) entry.exportedAs.push({ from: wrapper, namespace: m[2] }); + // The export file names the package in a form this regex does not read + // (`export * as ns from '…'`): stop, or it is labeled "not exported". A bare + // `import '…'` (app-management's custom-charges) exports nothing. + else if (new RegExp(`(export|import \\* as \\w+)[^;']*from '${dep}'`).test(src)) { + throw new LookupError(`${wrapper} exports ${dep} in a form sdk-lookup does not read (${path.relative(dir, barrel)}); its namespace is unknown, so do not trust this lookup`); + } + autos.set(dep, entry); + } + } + // An auto package on disk that no installed wrapper re-exports is either a + // dependency of another package (its own wrapper is not installed: name it + // and the install command) or an SPI/internal package of an installed + // wrapper (not importable from the wrapper's root). + for (const wix of wixDirsAbove(project)) { + for (const d of fs.readdirSync(wix).filter((d) => d.startsWith('auto_sdk_'))) { + const name = `@wix/${d}`; + if (!autos.has(name)) autos.set(name, { dir: path.join(wix, d), exportedAs: [] }); + } + } + for (const [name, entry] of autos) { + if (entry.exportedAs.length) continue; + const [, vertical, ns] = name.match(/^@wix\/auto_sdk_([^_]+)_(.+)$/) ?? []; + const wrapper = vertical ? `@wix/${vertical}` : null; + if (wrapper && !wrappers.has(wrapper)) { + entry.exportedAs.push({ from: wrapper, namespace: camel(ns), installed: false }); + } + } + return { wrappers, autos }; +} + +// --- deriving a record from .d.ts -------------------------------------------- + +// Reads one `declare function name(...): Ret;` starting at `at`, balancing +// brackets so callback parameters and generic return types stay whole. +function readDeclaration(src, at) { + let depth = 0; + let i = src.indexOf('(', at); + const paramsStart = i + 1; + let paramsEnd = -1; + for (; i < src.length; i++) { + const c = src[i]; + if (c === '(' || c === '<' || c === '{' || c === '[') depth++; + else if (c === ')' || c === '>' || c === '}' || c === ']') { + if (src[i - 1] === '=' && c === '>') continue; // arrow + depth--; + if (depth === 0 && paramsEnd < 0 && c === ')') { + paramsEnd = i; + if (src[i + 1] !== ':') break; + } + } else if (c === ';' && depth === 0 && paramsEnd >= 0) break; + } + const params = src.slice(paramsStart, paramsEnd); + const ret = src.slice(paramsEnd + 1, i).replace(/^:\s*/, '').trim(); + return { params, ret, end: i }; +} + +const tags = (doc, tag) => + [...doc.matchAll(new RegExp(`@${tag}(?:\\s+([^\\n*]*))?`, 'g'))].map((m) => (m[1] ?? '').trim()); + +// The first sentence of the JSDoc right before `at`: what the entity is ("An +// order represents a completed purchase from a buyer."), which tells apart +// the packages that export the same namespace. Null when there is none. +function descriptionBefore(src, at) { + const end = src.lastIndexOf('*/', at); + if (end < 0 || src.slice(end + 2, at).trim()) return null; + const text = src + .slice(src.lastIndexOf('/**', end) + 3, end) + .split('\n') + .map((l) => l.replace(/^\s*\*\s?/, '').trim()) + .join(' ') + .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1') + .replace(/\s+/g, ' ') + .trim(); + if (!text || text.startsWith('@')) return null; + return text.match(/^.*?[.!?](?=\s|$)/)?.[0] ?? text.split(' @')[0]; +} + +// The `> **Note:**` blocks of a method's JSDoc, the same text as its docs page. +// They carry who may call it ("requires visitor or member authentication"), +// which @applicableIdentity does not: getCurrentMember is tagged APP alone. +function notesOf(doc) { + const lines = doc.split('\n').map((l) => l.replace(/^\s*\*\s?/, '')); + const notes = []; + for (let i = 0; i < lines.length; i++) { + const head = /^>\s*\*\*Notes?:\*\*/.exec(lines[i]); + if (!head) continue; + const text = [lines[i].slice(head[0].length)]; + while (i + 1 < lines.length && lines[i + 1].startsWith('>')) text.push(lines[++i].slice(1)); + const note = text.join(' ').replace(/\[([^\]]+)\]\([^)]*\)/g, '$1').replace(/\s+/g, ' ').trim(); + if (note) notes.push(note); + } + return notes; +} + +function kindOf(name, params, ret) { + if (/^on[A-Z]/.test(name)) return 'event'; + if (/^search/.test(name)) return 'search'; + if (/^query/.test(name)) { + if (/QueryBuilder/.test(ret)) return 'query-builder'; + return /^\s*(query|options)\??:/.test(params) || /Query\b/.test(params) ? 'query' : 'other'; + } + for (const k of ['list', 'get', 'count', 'create', 'update', 'upsert', 'bulk']) { + if (name.startsWith(k)) return k; + } + if (/^(delete|remove)/.test(name)) return 'delete'; + return 'other'; +} + +// Drops the noise the generator puts in signatures, so a line stays readable. +function shorten(type, max = 90) { + let t = type + .replace(/NonNullablePaths<\s*([\w.]+)\s*,[^>]*?,\s*\d+\s*>/g, '$1') + .replace(/\s+&\s+\{[\s\S]*$/, '') + .replace(/\s+/g, ' ') + .trim(); + const open = (t.match(//g) ?? []).length; + if (open > 0) t += '>'.repeat(open); + if (t.length > max) t = `${t.slice(0, max - 1)}…`; + return t; +} + +function requiredOf(params) { + const out = new Set(); + for (const m of params.matchAll(/NonNullablePaths<\s*[\w.]+\s*,([^>]*?),\s*\d+\s*>/g)) { + for (const f of m[1].matchAll(/`([^`]+)`/g)) out.add(f[1]); + } + for (const m of params.matchAll(/(?:^|,)\s*(revision)\s*:/g)) out.add(m[1]); + return [...out]; +} + +function paramList(params) { + return splitParams(params).map((p) => { + const m = p.match(/^(\w+\??)\s*:\s*([\s\S]*)$/); + return m ? `${m[1]}: ${shorten(m[2], 40)}` : shorten(p, 40); + }); +} + +// Splits a parameter list on its top-level commas. +function splitParams(params) { + const out = []; + let depth = 0; + let cur = ''; + for (const c of params) { + if ('(<{['.includes(c)) depth++; + if (')>}]'.includes(c)) depth--; + if (c === ',' && depth === 0) { + out.push(cur); + cur = ''; + } else cur += c; + } + if (cur.trim()) out.push(cur); + return out.map((p) => p.trim()).filter(Boolean); +} + +const pascal = (s) => s.replace(/(^|[_-])(\w)/g, (_, __, c) => c.toUpperCase()); + +// The body of `interface Name {…}` or `type Name = {…}`, skipping an empty `{}`. +function typeBody(sources, name) { + const re = new RegExp(`^(?:interface ${name}\\b[^{\\n]*|type ${name}\\s*=\\s*)\\{`, 'gm'); + for (const src of sources) { + for (const m of src.matchAll(re)) { + let depth = 0; + let i = m.index + m[0].length - 1; + for (; i < src.length; i++) { + if (src[i] === '{') depth++; + else if (src[i] === '}' && --depth === 0) break; + } + const body = src.slice(m.index + m[0].length, i); + if (body.trim()) return body; + } + } + return null; +} + +// Top-level members of a type body: [{ name, type }], comments dropped. +function membersOf(body) { + const clean = body.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\$\{[^}]*\}/g, ''); + const out = []; + let depth = 0; + for (const line of clean.split('\n')) { + const m = depth === 0 && line.match(/^\s*(\w+)\??\s*:\s*(.*)$/); + if (m) out.push({ name: m[1], type: m[2] }); + else if (out.length && depth > 0) out[out.length - 1].type += `\n${line}`; + depth += (line.match(/\{/g) ?? []).length - (line.match(/\}/g) ?? []).length; + } + return out; +} + +// `{ a, b: { c, d }, e: [{ f }] }`: the keys of a type, two levels deep. +// Named types are followed, except the entity type (its fields are in `type`). +function typeShape(sources, name, skip, depth = 0) { + const body = typeBody(sources, name); + return body ? shapeOfBody(sources, body, skip, depth) : null; +} + +function shapeOfBody(sources, body, skip, depth) { + const keys = membersOf(body).map(({ name, type }) => { + if (depth >= 2) return name; + const t = type.trim(); + const list = /\[\]\s*;?\s*$/.test(t) || /^Array 8 ? [...keys.slice(0, 8), '…'].join(', ') : keys.join(', ')} }`; +} + +// The declared type of a dotted field path (`contact.firstName`) in a type +// body, with `| null` dropped; a builder's `category.id` is the type's `_id`. +function fieldType(sources, body, dotted) { + const parts = dotted.split('.'); + for (let i = 0; body && i < parts.length; i++) { + const m = membersOf(body).find((x) => x.name === parts[i] || x.name === `_${parts[i]}`); + if (!m) return null; + const t = m.type.trim().replace(/;$/, '').replace(/\s*\|\s*(null|undefined)\b/g, '').trim(); + if (i === parts.length - 1) return t; + body = t.startsWith('{') ? t.slice(1, t.lastIndexOf('}')) : typeBody(sources, t.match(/^(\w+)/)?.[1] ?? ''); + } + return null; +} + +function deriveRecord(name, dir) { + const pj = readJson(path.join(dir, 'package.json')); + const dep = pj.wix?.sdkDependency ?? {}; + const cjs = path.join(dir, 'build', 'cjs'); + const files = fs.existsSync(cjs) + ? fs.readdirSync(cjs).filter((f) => f.endsWith('.d.ts') && f !== 'meta.d.ts') + : []; + const methods = new Map(); + const rawParams = new Map(); // name -> the declaration's full parameter text + const events = []; + let preview = 0; + let typeAt = null; + const entityType = dep.fqdn ? pascal(dep.fqdn.split('.').pop()) : null; + const sources = []; + + for (const f of files) { + const file = path.join(cjs, f); + const src = fs.readFileSync(file, 'utf8'); + sources.push(src); + if (entityType && !typeAt) { + const m = new RegExp(`^interface ${entityType}\\b`, 'm').exec(src); + if (m) typeAt = { name: entityType, file, line: src.slice(0, m.index).split('\n').length, description: descriptionBefore(src, m.index) }; + } + const re = /\/\*\*((?:(?!\*\/)[\s\S])*?)\*\/\s*declare function (\w+?)(?:\$\d+)?\s*\(/g; + let m; + while ((m = re.exec(src))) { + const [, doc, fn] = m; + const { params, ret } = readDeclaration(src, m.index + m[0].length - 1); + if (/^\s*httpClient\s*:/.test(params)) continue; // the client-bound wrapper + if (/@hidden\b|@internal\b/.test(doc)) continue; + const kind = kindOf(fn, params, ret); + if (kind === 'event') { + if (!events.some((e) => e.name === fn)) { + events.push({ name: fn, scopes: [...new Set(tags(doc, 'permissionScopeId'))] }); + } + continue; + } + const maturity = tags(doc, 'documentationMaturity')[0]; + if (maturity === 'preview') preview++; + const prev = methods.get(fn); + const record = { + name: fn, + kind, + fqn: tags(doc, 'fqn')[0] ?? prev?.fqn, + params: paramList(params), + returns: shorten(ret), + permission: tags(doc, 'permissionId')[0] ?? prev?.permission, + // Only events carry @permissionScopeId today; methods get it if the SDK adds it. + scopes: [...new Set([...(prev?.scopes ?? []), ...tags(doc, 'permissionScopeId')])], + identities: [...new Set([...(prev?.identities ?? []), ...tags(doc, 'applicableIdentity')])], + admin: /@adminMethod\b/.test(doc) || !!prev?.admin, + notes: [...new Set([...(prev?.notes ?? []), ...notesOf(doc)])], + required: [...new Set([...(prev?.required ?? []), ...requiredOf(params)])], + maturity: maturity ? maturity.toUpperCase() : prev?.maturity ?? 'GA', + deprecated: /@deprecated\b/.test(doc) + ? { replacedBy: tags(doc, 'replacedBy')[0] ?? null, removal: tags(doc, 'targetRemovalDate')[0] ?? null } + : prev?.deprecated ?? null, + }; + // Overloads: keep the richer signature (a query builder beats `other`). + if (!prev || (prev.kind === 'other' && kind !== 'other')) { + methods.set(fn, record); + rawParams.set(fn, params); + } else methods.set(fn, { ...prev, ...Object.fromEntries(Object.entries(record).filter(([, v]) => v != null)), kind: prev.kind, params: prev.params, returns: prev.returns }); + } + } + + // The public call signature is `interface Signature { (params): Ret }` + // in index.d.ts. The `declare function` in index.typings.d.ts is the REST + // request shape (`search: CursorSearch` where the public call takes + // `ContactSearch`, and `options` required where it is optional), so the + // Signature wins whenever it exists. + for (const meth of methods.values()) { + const sig = `${meth.name[0].toUpperCase()}${meth.name.slice(1)}Signature`; + for (const src of sources) { + const m = new RegExp(`interface ${sig}\\s*\\{[\\s\\S]*?\\n\\s{4}\\(`).exec(src); + if (!m) continue; + const { params, ret } = readDeclaration(src, m.index + m[0].length - 1); + meth.params = paramList(params); + rawParams.set(meth.name, params); + meth.returns = shorten(ret); + meth.required = [...new Set([...meth.required, ...requiredOf(params)])]; + break; + } + } + + // SearchSpec / QuerySpec declare what a search or query + // method accepts: the free-text fields and, per operator group, the fields + // and sort. A closed list, shipped in every generated package that has one. + const spec = (kind) => { + const names = entityType ? [`${entityType}${kind}Spec`] : []; + for (const src of sources) { + const all = [...src.matchAll(new RegExp(`interface (\\w+)${kind}Spec extends ${kind}Spec\\b`, 'g'))].map((x) => `${x[1]}${kind}Spec`); + const name = names.find((n) => all.includes(n)) ?? (all.length === 1 ? all[0] : null); + if (name) { + const fields = parseSpec(src, name); + return fields && { entity: name.slice(0, -`${kind}Spec`.length), fields }; + } + } + return null; + }; + const searchSpec = spec('Search'); + const querySpec = spec('Query'); + // The spec is one entity's (`V3ProductSearchSpec` → V3Product), so a query + // or search returning another type in the same namespace + // (services.queryPolicies) does not get it. A builder is generated per + // entity, so it always does. + const returnsEntity = (meth, entity) => { + const body = typeBody(sources, meth.returns.match(/Promise<(\w+)/)?.[1] ?? ''); + const list = new RegExp(`^(?:Array<\\s*${entity}\\b|${entity}\\s*\\[\\])`); + return !body || membersOf(body).some(({ type }) => list.test(type.trim())); + }; + for (const meth of methods.values()) { + const s = meth.kind === 'search' ? searchSpec : /^query/.test(meth.kind) ? querySpec : null; + if (!s || (meth.kind !== 'query-builder' && !returnsEntity(meth, s.entity))) continue; + if (Object.keys(s.fields).length) meth.filterable = s.fields; + else meth.filterUnlisted = true; + } + + const http = httpOf(path.join(cjs, 'meta.js')); + for (const meth of methods.values()) if (http[meth.name]) meth.http = http[meth.name]; + + // A search method takes free text only when its search object has a + // `search` member (SearchDetails); some search methods take a filter only. + for (const meth of methods.values()) { + if (meth.kind !== 'search') continue; + if (meth.filterable) { + meth.freeText = Object.values(meth.filterable).some((f) => f.search); + continue; + } + const type = meth.params[0]?.match(/:\s*(\w+)/)?.[1]; + for (const src of sources) { + const m = new RegExp(`(?:interface ${type}\\b[^{]*|type ${type}\\s*=\\s*)\\{([\\s\\S]*?)\\n\\}`).exec(src); + if (!m) continue; + meth.freeText = /^\s{4}search\?\s*:/m.test(m[1]); + break; + } + } + + // A query builder's own methods decide what one builder call can express: + // the generated builders chain clauses with AND and most have no or(). + for (const meth of methods.values()) { + if (meth.kind !== 'query-builder') continue; + const builder = meth.returns.match(/(\w+QueryBuilder)/)?.[1]; + for (const src of sources) { + const m = new RegExp(`interface ${builder}\\b[^{]*\\{([\\s\\S]*?)\\n\\}`).exec(src); + if (!m) continue; + // Each member is `op: (propertyName: 'a' | 'b', value) => Builder`: the + // literals are the closed list of fields that operator accepts. + const ops = {}; + for (const line of m[1].split('\n')) { + const mm = line.match(/^\s{4}(\w+)\s*:\s*\(/); + if (!mm) continue; + const params = line.slice(0, line.lastIndexOf('=>')); + const fields = [...params.matchAll(/'([^']+)'/g)].map((x) => x[1]); + ops[mm[1]] = /propertyNames?\s*:\s*string/.test(params) ? ['*'] : fields; + } + meth.builder = ops; + break; + } + // Most builders also take one query object — `queryX({ filter, sort, + // cursorPaging })` — declared as the @hidden `typedQueryX`. Its filter is + // a WQL object, so it has $or where the builder has no or(). + const typed = `typed${meth.name[0].toUpperCase()}${meth.name.slice(1)}`; + for (const src of sources) { + const m = new RegExp(`declare function ${typed}\\s*\\(`).exec(src); + if (!m) continue; + const { params, ret } = readDeclaration(src, m.index + m[0].length - 1); + const form = { params: paramList(params), returns: shorten(ret) }; + const skip = new Set([entityType]); + const arg = params.match(/^\s*(\w+)\??\s*:\s*([A-Z]\w*)/); + const argShape = arg && typeShape(sources, arg[2], skip); + if (argShape) form.arg = `${arg[1]}: ${argShape}`; + const retType = form.returns.match(/Promise<(\w+)/)?.[1]; + const result = retType && typeShape(sources, retType, skip); + if (result) form.result = result; + // `cursorPaging` or `paging`, depending on the method. + form.keys = membersOf(typeBody(sources, arg?.[2] ?? '') ?? '').map((x) => x.name); + meth.queryForm = form; + break; + } + } + + // The fields a search term can go in without a free-text search, declared + // `string` on the entity, ids and numbers in a string (`price.amount`, + // `taxRate`, `revision`) left out. `textFields`: those that list + // $startsWith. `textFieldsAny`: those under `operators: '*'` (every + // operator: orders' `number` takes $startsWith on a real site, although its + // docs page lists fewer). [] means the package lists the operators and none + // fits; no `textFields` means the package does not list them. + const entity = searchSpec?.entity ?? querySpec?.entity ?? entityType; + const entityBody = entity && typeBody(sources, entity); + const notText = (f) => /(^|\.)(_?id|amount|value|revision)$|(Id|Count|Rate)$/.test(f); + const isText = (f) => !notText(f) && (!entityBody || fieldType(sources, entityBody, f) === 'string'); + for (const meth of methods.values()) { + if (!['search', 'query', 'query-builder'].includes(meth.kind)) continue; + const sw = meth.builder?.startsWith; + let fields; + let any = []; + if (meth.filterable) { + const rows = Object.entries(meth.filterable); + fields = rows.filter(([, f]) => f.ops.includes('$startsWith')).map(([k]) => k); + any = rows.filter(([k, f]) => f.ops.includes('*') && !fields.includes(k)).map(([k]) => k); + if (sw && !sw.includes('*')) fields.push(...sw.filter((f) => !fields.includes(f))); + } else if (meth.builder && !meth.filterUnlisted && !sw?.includes('*')) fields = sw ?? []; + if (!fields) continue; + meth.textFields = fields.filter(isText); + any = any.filter((f) => !meth.textFields.includes(f) && isText(f)); + if (any.length) meth.textFieldsAny = any; + } + + // A query or list method can take free text in its options, with no search + // method in the namespace: queryMembers(options) has `options.search: + // { expression, fields }`, whose fields default to one (`profile.nickname`). + for (const meth of methods.values()) { + if (!['query', 'query-builder', 'list'].includes(meth.kind)) continue; + for (const p of splitParams(rawParams.get(meth.name) ?? '')) { + const m = p.match(/^(\w+)\??\s*:\s*([A-Z]\w*)/); + const search = m && membersOf(typeBody(sources, m[2]) ?? '').find((x) => x.name === 'search'); + const type = search?.type.match(/^([A-Z]\w*)/)?.[1]; + const body = type && typeBody(sources, type); + if (!body || !membersOf(body).some((x) => x.name === 'expression')) continue; + const doc = body.match(/\/\*\*((?:(?!\*\/)[\s\S])*)\*\/\s*fields\??\s*:/)?.[1] ?? ''; + const [list, dflt] = doc.split(/Default:/); + meth.optionsSearch = { + param: m[1], + fields: [...list.matchAll(/`([\w.]+)`/g)].map((x) => x[1]), + default: dflt?.match(/`([\w.]+)`/)?.[1] ?? null, + max: Number(doc.match(/@maxSize (\d+)/)?.[1]) || null, + }; + break; + } + } + + // The keys of a search, query or list method's argument and response, so a + // caller does not have to find `ContactSearch` (an empty `{}` placeholder in + // index.typings.d.ts, the real `type ContactSearch = {` elsewhere). + for (const meth of methods.values()) { + if (!['search', 'query', 'list'].includes(meth.kind)) continue; + const skip = new Set([entityType]); + // The first parameter with an object type (`contactId: string` has no keys). + for (const p of splitParams(rawParams.get(meth.name) ?? '')) { + const m = p.match(/^(\w+)\??\s*:\s*(?:NonNullablePaths<\s*)?([A-Z]\w*)/); + const shape = m && typeShape(sources, m[2], skip); + if (shape) { + meth.arg = `${m[1]}: ${shape}`; + break; + } + } + const retType = meth.returns.match(/Promise<(\w+)/)?.[1]; + if (retType) meth.result = typeShape(sources, retType, skip); + } + + return { + package: name, + version: pj.version, + fqdn: dep.fqdn ?? null, + fqdnNamespace: dep.fqdnNamespace ?? null, + maturity: methods.size && preview === methods.size ? 'PREVIEW' : 'GA', + methods: [...methods.values()], + events, + type: typeAt, + }; +} + +// interface XSearchSpec extends SearchSpec { +// searchable: ['a', 'b']; +// wql: [{ operators: ['$eq', …]; fields: ['c', …]; sort: 'BOTH' | 'ASC' | 'DESC' | 'NONE' }, …]; +// } → { field: { ops: [...], sort: bool, search: bool } } +function parseSpec(src, name) { + const m = new RegExp(`interface ${name} extends \\w+Spec\\s*\\{([\\s\\S]*?)\\n\\}`).exec(src); + if (!m) return null; + const body = m[1]; + const list = (s) => [...(s ?? '').matchAll(/'([^']+)'/g)].map((x) => x[1]); + const out = {}; + const field = (f) => (out[f] ??= { ops: [], sort: false, search: false }); + for (const f of list(body.match(/searchable:\s*\[([\s\S]*?)\]/)?.[1])) field(f).search = true; + const wql = body.slice(body.indexOf('wql:')); + for (const block of wql.matchAll(/\{([^{}]*)\}/g)) { + const ops = list(block[1].match(/operators:\s*(\[[^\]]*\]|'[^']*')/)?.[1]); + const sort = block[1].match(/sort:\s*'(\w+)'/)?.[1] ?? 'NONE'; + for (const f of list(block[1].match(/fields:\s*\[([^\]]*)\]/)?.[1])) { + const e = field(f); + e.ops = [...new Set([...e.ops, ...ops])]; + e.sort = e.sort || sort !== 'NONE'; + } + } + // `{}` when the spec is declared but lists no field (`wql: []`): the + // filter fields are on the method's docs page only. + return out; +} + +function httpOf(metaFile) { + if (!fs.existsSync(metaFile)) return {}; + const src = fs.readFileSync(metaFile, 'utf8'); + const out = {}; + const re = /function (\w+?)2\(\) \{[\s\S]*?httpMethod: "(\w+)",\s*path: "([^"]+)"/g; + let m; + while ((m = re.exec(src))) out[m[1]] = `${m[2]} ${m[3]}`; + return out; +} + + +// --- matching and ranking ---------------------------------------------------- + +const norm = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); +function variants(term) { + const t = norm(term); + const out = new Set([t, `${t}s`]); + if (t.endsWith('ies')) out.add(`${t.slice(0, -3)}y`); + if (t.endsWith('s')) out.add(t.slice(0, -1)); + return out; +} +const versionOf = (fqdn) => Number((fqdn ?? '').match(/\.v(\d+)\./)?.[1] ?? 0); +const baseOf = (ns) => norm(ns.replace(/V\d+$/, '')); + +function namespacesOf(name, entry) { + if (entry.exportedAs.length) return entry.exportedAs; + return [{ from: name, namespace: null }]; +} + +function loadCurated() { + const file = path.join(__dirname, 'sdk-curated.json'); + return fs.existsSync(file) ? readJson(file).rules : []; +} + +function moduleMapRow(term) { + const file = path.join(__dirname, '..', 'references', 'SDK_MODULE_MAP.md'); + if (!fs.existsSync(file)) return null; + const v = variants(term); + for (const line of fs.readFileSync(file, 'utf8').split('\n')) { + const m = line.match(/^\|\s*([^|]+?)\s*\|\s*`(@wix\/[\w-]+)`/); + if (!m) continue; + const words = m[1].split(/[\s/,]+/).map(norm); + if (words.some((w) => v.has(w))) return { entities: m[1], package: m[2] }; + } + return null; +} + +function lookup(term, intent, pkgs, curated) { + const v = variants(term); + const primary = []; + const related = []; + for (const [name, entry] of pkgs.autos) { + const pj = readJson(path.join(entry.dir, 'package.json')); + const dep = pj.wix?.sdkDependency ?? {}; + const last = norm((dep.fqdn ?? '').split('.').pop() ?? ''); + for (const ns of namespacesOf(name, entry)) { + const nsBase = ns.namespace ? baseOf(ns.namespace) : norm(name.replace(/^@wix\/auto_sdk_[^_]+_/, '')); + const hay = norm(`${name} ${dep.fqdn ?? ''} ${ns.namespace ?? ''}`); + if (v.has(nsBase) || v.has(last)) primary.push({ name, entry, ns }); + else if ([...v].some((x) => x.length > 3 && hay.includes(x))) related.push({ name, entry, ns }); + } + } + + const rules = curated.filter((r) => r.entities.some((e) => v.has(norm(e)))); + // A curated preference can name a namespace the term itself does not match + // (a refunds table reads orders), so pull that namespace in. + const prefer = rules.find((r) => r.choice?.prefer && (!r.choice.intents || r.choice.intents.includes(intent))); + const siteRule = rules.find((r) => r.choice === 'site'); + const pullIn = [prefer?.choice.prefer, ...(siteRule?.namespaces ?? [])].filter(Boolean); + for (const want of pullIn) { + if (primary.some((c) => c.ns.namespace === want)) continue; + for (const [name, entry] of pkgs.autos) { + const ns = entry.exportedAs.find((x) => x.namespace === want); + if (ns) primary.push({ name, entry, ns }); + } + } + if (!primary.length) { + const row = moduleMapRow(term); + if (row && !pkgs.wrappers.has(row.package)) return { term, status: 'not-installed', row, rules }; + return { term, status: 'unknown', near: nearMatches(term, pkgs), related: related.map(label), rules }; + } + + const candidates = primary.map((c) => ({ ...c, record: deriveRecord(c.name, c.entry.dir) })); + const reasons = new Map(); + const why = (c, r) => reasons.set(c, [...(reasons.get(c) ?? []), r]); + for (const c of candidates) { + why(c, `fqdn v${versionOf(c.record.fqdn)} (${c.record.fqdn})`); + if (c.record.maturity === 'PREVIEW') why(c, 'preview'); + if (c.record.methods.length && c.record.methods.every((m) => m.deprecated)) why(c, 'deprecated'); + if (intent === 'search') why(c, c.record.methods.some((m) => m.kind === 'search') ? 'has search' : 'no search method'); + if (!c.ns.namespace) why(c, 'not exported from a public package root (SPI or internal)'); + if (c.ns.installed === false) why(c, `${c.ns.from} not installed`); + } + + const preferRule = prefer && candidates.some((c) => c.ns.namespace === prefer.choice.prefer) ? prefer : null; + // Several packages can export the same namespace (orders: ecom, pricing-plans, + // events). Across packages the module map's package wins; with no map row, + // the package named after the namespace (@wix/categories, not @wix/blog), + // then a package whose entity has a description; then the package name, so + // the order never depends on which package is read first. The API + // version and a search method rank only the namespaces of one package + // (contacts vs contactsV5): across packages they say nothing about the feature. + const packages = new Set(candidates.filter((c) => c.ns.namespace).map((c) => c.ns.from)); + const mapPackage = packages.size > 1 ? moduleMapRow(term)?.package : null; + const namesake = (c) => + packages.size > 1 && !!c.ns.namespace && norm(c.ns.from.replace(/^@wix\//, '')) === baseOf(c.ns.namespace); + // Per package, not per namespace, so it never reorders one package's namespaces. + const described = new Set(candidates.filter((c) => c.record.type?.description).map((c) => c.ns.from)); + const score = (c) => [ + c.record.maturity === 'PREVIEW' || reasons.get(c).includes('deprecated') ? 0 : 1, + preferRule && c.ns.namespace === preferRule.choice.prefer ? 1 : 0, + c.ns.namespace ? 1 : 0, + mapPackage && c.ns.from === mapPackage ? 1 : 0, + namesake(c) ? 1 : 0, + packages.size > 1 && described.has(c.ns.from) ? 1 : 0, + ]; + const withinPackage = (c) => [ + intent === 'search' && c.record.methods.some((m) => m.kind === 'search') ? 1 : 0, + versionOf(c.record.fqdn), + ]; + const higher = (x, y) => x.findIndex((v, i) => v !== y[i]); + candidates.sort((a, b) => { + let i = higher(score(a), score(b)); + if (i >= 0) return score(b)[i] - score(a)[i]; + if (a.ns.from !== b.ns.from) return (a.ns.from ?? '').localeCompare(b.ns.from ?? ''); + i = higher(withinPackage(a), withinPackage(b)); + if (i >= 0) return withinPackage(b)[i] - withinPackage(a)[i]; + return (a.ns.namespace ?? '').localeCompare(b.ns.namespace ?? ''); + }); + if (preferRule) why(candidates[0], 'curated'); + else if (mapPackage && candidates[0].ns.from === mapPackage) why(candidates[0], 'the module map package'); + else if (namesake(candidates[0])) why(candidates[0], 'the package named after it'); + if (packages.size > 1) { + for (const c of candidates.filter((c) => c.ns.namespace)) c.description = c.record.type?.description ?? null; + } + return { + term, + status: 'found', + siteDependent: !!siteRule, + candidates: candidates.map((c) => ({ + ...c, + reasons: reasons.get(c), + site: !!siteRule && siteRule.namespaces.includes(c.ns.namespace), + })), + related: related.filter((r) => !primary.some((p) => p.name === r.name)).map(label), + rules, + }; +} + +const label = (c) => (c.ns.namespace ? `${c.ns.namespace} (${c.ns.from})` : c.name); + +// The call-shape rule: which one call to write for the intent, and why. +// search: a search method with free text → one call, the term in +// search.search.expression. Otherwise one filter object with $or, +// one clause per field. A query builder chains clauses with AND and +// has no or(): when the method also takes a query object, its +// filter has $or; else one field with startsWith, never one query +// per field merged in the client. +// read: the builder for filters, sort and paging, on the fields its +// operators list (a closed list), else the filter object. A +// builder with no filter or sort (extendedBookings) → the query +// object. +// write: a method that requires revision → read the entity first. +function shapeFor(record, ns, intent) { + const by = (k) => record.methods.filter((m) => m.kind === k && !m.deprecated); + const [search] = by('search'); + const [builder] = by('query-builder'); + const [query] = by('query').filter((m) => m.name.toLowerCase().includes(baseOf(ns ?? ''))); + const call = (m) => `${ns ?? 'sdk'}.${m.name}`; + const textFields = (m) => + m?.filterable ? Object.entries(m.filterable).filter(([, f]) => f.search).map(([k]) => k) : null; + const queryCall = (m) => `${call(m)}({ ${(m.queryForm.keys?.length ? m.queryForm.keys : ['filter', 'sort']).join(', ')} })`; + const out = []; + let opt; + + if (intent === 'search') { + if (search && search.freeText !== false) { + const fields = textFields(search); + out.push(`${call(search)}({ search: { expression: term } }): one call${fields ? `; the term matches ${fields.join(', ')}` : ''}; exact-match facets go in filter`); + } else if ((opt = [builder, query, ...by('list')].find((m) => m?.optionsSearch))) { + // Free text in a query's options, with no search method (queryMembers). + const o = opt.optionsSearch; + const first = splitParams(opt.params.join(', '))[0]?.startsWith(o.param); + const arg = `{ search: { expression: term, fields } }`; + out.push(`${call(opt)}(${first ? arg : `…, ${arg}`})${opt.kind === 'query-builder' ? '.find()' : ''}: no search method; free text in its ${o.param}.search`); + if (o.default) out.push(` search.fields defaults to ${o.default} alone: name every field the term must match${o.max ? ` (at most ${o.max})` : ''}`); + if (o.fields.length) out.push(` searchable fields: ${o.fields.join(', ')}`); + out.push(' never run one query per field and merge the results in the client'); + } else { + // Without free text, a term goes in $startsWith on the text fields: a + // prefix match. None of them → the server cannot match a term at all. + const orBuilder = builder?.builder?.or; + const form = search ?? query ?? (builder?.queryForm && !orBuilder ? builder : null); + const target = form ?? builder; + const listed = target?.textFields; + const any = target?.textFieldsAny ?? []; + const fields = listed && [...listed, ...any]; + const where = form === builder ? queryCall(builder) : target && `${call(target)}${target === builder ? '()' : ''}`; + const merge = ' never run one query per field and merge the results in the client'; + if (fields && !fields.length) { + // A search argument can carry `search: { expression }` (the generic + // search object) while the spec declares no searchable field: the + // type accepts a term the service has no field to match it on. + const typed = search && /\bsearch: \{[^}]*\bexpression\b/.test(search.arg ?? ''); + out.push(`${where}: no text match in the package: no searchable field, and no text field takes $startsWith`); + if (typed) out.push(' its argument types search.expression, but its search spec declares no searchable field: do not rely on it unless the docs page lists searchable fields'); + out.push(` next: its docs page (SearchWixSDKDocumentation "${target.name}"), not the typings: nothing more is in the package`); + out.push(' if the docs list no text filter either, tell the user the API cannot match a search term;'); + out.push(' a search then reads every page (cursor) and matches the values itself, re-read on each search, with no silent cap'); + out.push(' never match only the loaded page, or a capped or cached set, and present it as a search'); + } else if (form) { + const why = form === builder ? 'no search method, and the builder has no or(): pass one query object instead' : search ? 'its search takes no free text' : 'no search method'; + out.push(`${where}: ${why}`); + if (form === search) out.push(' a search.expression here can be ignored with no error (on a real site, searchOrders returned every order): put the term in filter'); + const or = fields && `filter: { $or: [${fields.slice(0, 2).map((f) => `{ '${f}': { $startsWith: term } }`).join(', ')}${fields.length > 2 ? ', …' : ''}] }`; + // The package has nothing more to find when it lists no fields: say so, + // or the agent greps the typings and the compiled JS for operators. + if (!fields) { + out.push(' the term: filter $or, one clause per text field — the package has no filter list for this method'); + out.push(` next: its docs page (SearchWixSDKDocumentation "${target.name} filter"), not the typings or the compiled JS: nothing more is in the package`); + out.push(' the docs page lists the fields and operators: $contains where a field takes it (a match inside a value), else $startsWith (a prefix)'); + } else { + out.push(listed.length ? ` the term: ${or} on the text fields ${capped(listed)}` : ` the term: ${or}`); + if (any.length) { + out.push(` ${listed.length ? 'also ' : ''}text fields with operators '*' (every operator, $startsWith too): ${capped(any)}`); + } + out.push(' $startsWith is a prefix match; the package lists $contains for no field: for a match inside a value, check the method docs page'); + } + if (form === builder) out.push(' an API may implement only part of WQL: if its docs page rules out $or, use ONE field and say so to the user'); + out.push(merge); + } else if (orBuilder) { + out.push(`${where}: no search method; the builder has or(), so chain one .startsWith() per field inside .or()${fields ? ` (${capped(fields)})` : ''}`); + } else if (builder) { + out.push(`${where}: no search method, and the builder chains clauses with AND and has no or()`); + out.push(` so one builder call cannot match a term in any of several fields: use startsWith on ONE field${fields?.length ? ` (${capped(fields)})` : ''}, and say so to the user`); + out.push(merge); + } + } + } else if (intent === 'read') { + if (builder) { + const ops = Object.entries(builder.builder ?? {}).filter(([k, f]) => f.length && !['limit', 'skip', 'skipTo', 'find'].includes(k)); + if (!ops.length && builder.queryForm) { + out.push(`${queryCall(builder)}: the builder has no filter or sort, so pass one query object`); + out.push(` fields and operators: ${builder.filterable ? 'its filter list' : 'its docs page (the package does not list them)'}`); + } else { + out.push(`${call(builder)}(): filters, sort and paging chained on the builder (AND only); fields per operator are a closed list:`); + for (const [op, f] of ops) out.push(` .${op}(${f.includes('*') ? 'any field' : capped(f, 10)})`); + if (builder.queryForm) out.push(` or one query object, ${queryCall(builder)}, whose filter also takes $or`); + } + if (search) out.push(` a search box over several fields → ${call(search)} instead (--intent search)`); + } else if (search || query) { + out.push(`${call(search ?? query)}: one filter object (fields from its filter list or its docs page)`); + } + } else if (intent === 'write') { + const needsRevision = record.methods.filter((m) => /^(update|delete)/.test(m.kind) && m.required.some((r) => /revision$/.test(r))); + if (needsRevision.length) { + out.push(`${needsRevision.map(call).join(', ')} require revision: read the entity first and pass its current revision (a stale one fails)`); + } + } + return out; +} + +function nearMatches(term, pkgs) { + const t = norm(term); + const names = new Set(); + for (const [name, entry] of pkgs.autos) for (const ns of namespacesOf(name, entry)) names.add(ns.namespace ?? name); + const dist = (a, b) => { + const d = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]); + for (let j = 1; j <= b.length; j++) d[0][j] = j; + for (let i = 1; i <= a.length; i++) + for (let j = 1; j <= b.length; j++) + d[i][j] = Math.min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); + return d[a.length][b.length]; + }; + return [...names] + .map((n) => [n, norm(n).includes(t) || t.includes(baseOf(n)) ? 0 : dist(t, baseOf(n))]) + .filter(([, d]) => d <= 3) + .sort((a, b) => a[1] - b[1]) + .slice(0, 5) + .map(([n]) => n); +} + +// --- output ------------------------------------------------------------------ + +// filterable: { field: { ops: [...], sort: bool, search: bool } } — a closed +// list per method, so a field that is not here cannot be filtered on. +function renderFilterable(filterable) { + const rows = Object.entries(filterable); + const text = rows.filter(([, f]) => f.search).map(([k]) => k); + const sort = rows.filter(([, f]) => f.sort).map(([k]) => k); + const byOps = new Map(); + for (const [k, f] of rows) { + const ops = (f.ops ?? []).join(' '); + // A free-text-only field (contacts' `name.full`) has no operators: the free-text line covers it. + if (!ops) continue; + byOps.set(ops, [...(byOps.get(ops) ?? []), k]); + } + const out = []; + if (text.length) out.push(`free text: ${text.join(', ')}`); + for (const [ops, fields] of byOps) out.push(`filter ${ops}: ${capped(fields, 12)}`); + if (sort.length) out.push(`sort: ${capped(sort, 12)}`); + out.push('(closed list: a field not shown cannot be filtered or sorted on)'); + return out; +} + +const capped = (list, n = 8) => (list.length > n ? `${list.slice(0, n).join(', ')}, +${list.length - n} more` : list.join(', ')); + +function render(result, intent, project) { + const out = [`== ${result.term} (intent: ${intent})`]; + const rel = (f) => path.relative(project, f) || f; + if (result.status === 'not-installed') { + out.push(`not installed: "${result.row.entities}" → ${result.row.package}`); + out.push(` npm i ${result.row.package} then run this lookup again`); + } else if (result.status === 'unknown') { + out.push('no installed SDK namespace matches this name'); + if (result.near.length) out.push(` near: ${result.near.join(', ')}`); + if (result.related.length) out.push(` related: ${capped(result.related)}`); + out.push(' if it is a Wix business entity, check references/SDK_MODULE_MAP.md, then search the SDK docs'); + } else { + // A site-dependent entity has one namespace per site kind, all shown in full. + const chosen = result.siteDependent ? result.candidates.filter((c) => c.site) : result.candidates.slice(0, 1); + const lead = result.siteDependent ? 'site' : 'use'; + // Packages that export the same namespace: each one's entity description, + // so the agent switches when the feature matches another package. + const what = (c) => + c.description === undefined ? '' : c.description ? ` — "${c.description}"` : ' — no description in the package'; + for (const c of chosen) out.push(`${lead.padEnd(5)} ${label(c)} — ${c.reasons.join('; ')}${what(c)}`); + for (const c of result.candidates.filter((c) => !chosen.includes(c))) { + out.push(`also ${label(c)} — ${c.reasons.join('; ')}${what(c)}`); + } + if (result.candidates.some((c) => c.description !== undefined)) { + out.push(' several packages export this name: if the feature matches another package\'s description, use that one'); + } + for (const c of chosen) out.push(...renderCandidate(c, intent, rel)); + if (result.related.length) out.push(`related: ${capped(result.related)}`); + } + for (const r of result.rules) out.push(`note ${r.note} [${r.source}]`); + return out.join('\n'); +} + +function renderCandidate(c, intent, rel) { + const r = c.record; + const ns = c.ns.namespace; + const out = []; + if (!ns) out.push(`// ${c.name} is not exported from a public package root; check its wrapper's subpath exports`); + else if (c.ns.installed === false) out.push(`// ${c.ns.from} is not installed (only pulled in by another package): npm i ${c.ns.from}`); + out.push(ns ? `import { ${ns} } from '${c.ns.from}';` : `import * as sdk from '${c.name}';`); + // Before the methods: a long method list must not push the declaration file + // past an agent's `| head`. + if (r.type) out.push(` type ${r.type.name} ${rel(r.type.file)}:${r.type.line}`); + out.push(` package ${r.package}@${r.version}`); + const shape = shapeFor(r, ns, intent); + if (shape.length) out.push(`shape ${shape[0]}`, ...shape.slice(1).map((l) => ` ${l}`)); + const kinds = INTENTS[intent]; + const shown = + intent === 'event' + ? [] + : r.methods + .filter((m) => !kinds || kinds.includes(m.kind)) + .sort((a, b) => (kinds ? kinds.indexOf(a.kind) - kinds.indexOf(b.kind) : 0)); + for (const m of shown) { + const call = `${ns ?? 'sdk'}.${m.name}(${m.params.join(', ')}) → ${m.returns}`; + out.push(` ${m.kind.padEnd(13)} ${call}`); + // @applicableIdentity is APP on visitor methods too (addLineItemsToCurrentCart, + // getCurrentMember), so it says nothing about who may call: not printed. The + // JSDoc notes say it when the docs page does. + const facts = [`permission ${m.permission ?? '?'}`]; + facts.push(m.scopes?.length ? `scope ${m.scopes.join(' | ')}` : 'scope: not in the package (the method docs page lists it)'); + if (m.admin) facts.push('admin method'); + if (m.required.length) facts.push(`required ${m.required.join(', ')}`); + if (m.maturity !== 'GA') facts.push(m.maturity.toLowerCase()); + if (m.deprecated) facts.push(`DEPRECATED${m.deprecated.replacedBy ? ` → ${m.deprecated.replacedBy}` : ''}`); + out.push(` ${''.padEnd(13)} ${facts.join(' · ')}`); + for (const n of m.notes ?? []) out.push(` ${''.padEnd(13)} note ${n}`); + if (m.arg) out.push(` ${''.padEnd(13)} arg ${m.arg}`); + if (m.result) out.push(` ${''.padEnd(13)} returns ${m.result}`); + if (m.queryForm) { + const q = m.queryForm; + out.push(` ${''.padEnd(13)} or ${ns ?? 'sdk'}.${m.name}(${q.params.join(', ')}) → ${q.returns}`); + if (q.arg) out.push(` ${''.padEnd(13)} arg ${q.arg}`); + if (q.result) out.push(` ${''.padEnd(13)} returns ${q.result}`); + } + if (m.filterable) out.push(...renderFilterable(m.filterable).map((l) => ` ${''.padEnd(13)} ${l}`)); + else if (m.filterUnlisted) out.push(` ${''.padEnd(13)} filter fields: not in the package (the method docs page lists them)`); + if (m.fqn) out.push(` ${''.padEnd(13)} fqn ${m.fqn}`); + } + const others = r.methods.filter((m) => !shown.includes(m)).map((m) => m.name); + if (others.length && intent !== 'all') { + const next = intent === 'write' ? '--intent all' : '--intent write (or all)'; + out.push(` other methods (params, permission, scope: run again with ${next}): ${others.join(', ')}`); + } + if (intent === 'event' || intent === 'all') { + for (const e of r.events) out.push(` event ${ns ?? 'sdk'}.${e.name}(handler) · scope ${e.scopes.join(' | ') || '?'}`); + } else if (r.events.length) { + out.push(` events: ${r.events.map((e) => e.name).join(', ')}`); + } + return out; +} + +// --- main -------------------------------------------------------------------- + +function main(argv) { + const args = [...argv]; + const flag = (n) => { + const i = args.indexOf(n); + return i >= 0 ? args.splice(i, 2)[1] : undefined; + }; + const intent = flag('--intent') ?? 'read'; + const project = path.resolve(flag('--project') ?? process.cwd()); + const asJson = args.includes('--json'); + const terms = args.filter((a) => !a.startsWith('--')); + if (!terms.length || !(intent in INTENTS)) { + console.error('usage: sdk-lookup.cjs [ ...] [--intent search|read|write|event|all] [--json]'); + return 2; + } + activatePnp(project); + let pkgs; + try { + pkgs = findPackages(project); + } catch (e) { + if (!(e instanceof LookupError)) throw e; + console.error(`sdk-lookup: ${e.message}`); + return 4; + } + if (!pkgs.autos.size) { + console.error(`no @wix/auto_sdk_* packages found from ${project}; install the vertical's @wix/ first`); + return 3; + } + const curated = loadCurated(); + const results = terms.map((t) => lookup(t, intent, pkgs, curated)); + if (asJson) { + const plain = results.map((r) => ({ + ...r, + candidates: r.candidates?.map((c) => ({ + namespace: c.ns.namespace, + from: c.ns.from, + reasons: c.reasons, + description: c.description, + site: c.site, + shape: shapeFor(c.record, c.ns.namespace, intent), + record: c.record, + })), + })); + console.log(JSON.stringify(plain, null, 2)); + } else { + console.log(results.map((r) => render(r, intent, project)).join('\n\n')); + } + const codes = results.map((r) => ({ found: 0, unknown: 1, 'not-installed': 3 })[r.status]); + return Math.max(...codes); +} + +if (require.main === module) process.exitCode = main(process.argv.slice(2)); + +module.exports = { findPackages, deriveRecord, lookup, loadCurated }; diff --git a/yaml/wix-app-evals/custom-element-widget/filtered-read-not-elevated.yml b/yaml/wix-app-evals/custom-element-widget/filtered-read-not-elevated.yml index a4b908f80..a778ee75e 100644 --- a/yaml/wix-app-evals/custom-element-widget/filtered-read-not-elevated.yml +++ b/yaml/wix-app-evals/custom-element-widget/filtered-read-not-elevated.yml @@ -21,11 +21,11 @@ assertions: This widget needs two different member reads, and they belong to two different classes: - The directory uses `members.queryMembers` / `members.getMember` (scope `SCOPE.DC-MEMBERS.READ-MEMBERS`). The platform **filters these by caller**: a member's default privacy status is `PRIVATE`, and private members are withheld when a site visitor or member identity calls them. Calling directly from the widget is correct — the sparse result on a site with private members is the privacy setting working, not a bug to engineer around. - - The signed-in member's own profile uses `members.getMyMember`, which resolves from the caller's session. + - The signed-in member's own profile uses `members.getCurrentMember` (the REST method *Get My Member*), which resolves from the caller's session. The specific defect: an agent that sees an empty or partial directory and "fixes" it by routing the query to a backend endpoint and elevating it. Elevation runs the call as the app, which is not subject to the privacy filter, so every `PRIVATE` member is returned to any site visitor who can reach that endpoint. This is a data leak, not extra indirection. - Check: (0) The directory reads from the Members API (`members.queryMembers` or `members.getMember`). A widget that instead creates a CMS collection of hand-entered names never touches the privacy filter and does not satisfy this scenario, however polished it looks — score it 3 or below. (1) Both member reads are called directly from the widget. (2) There is **no** `auth.elevate` anywhere in the project. (3) **No endpoint under `src/pages/api/`** proxies either member read. (4) The signed-in member's own profile uses `getMyMember`, not `getMember` with a looked-up ID. (5) The directory degrades honestly when few members are public — an empty state or a note that private members are hidden — rather than a workaround that widens access. + Check: (0) The directory reads from the Members API (`members.queryMembers` or `members.getMember`). A widget that instead creates a CMS collection of hand-entered names never touches the privacy filter and does not satisfy this scenario, however polished it looks — score it 3 or below. (1) Both member reads are called directly from the widget. (2) There is **no** `auth.elevate` anywhere in the project. (3) **No endpoint under `src/pages/api/`** proxies either member read. (4) The signed-in member's own profile uses `getCurrentMember` (the SDK name of *Get My Member*), not `getMember` with a looked-up ID. (5) The directory degrades honestly when few members are public — an empty state or a note that private members are hidden — rather than a workaround that widens access. Score 0-10. Score 1 or below if a member read is elevated: that is a privacy regression and the single defect this scenario exists to catch. Score 4 or below if a member read is proxied through a backend endpoint even without elevation. - type: llm_judge diff --git a/yaml/wix-app-evals/custom-element-widget/session-methods-called-directly.yml b/yaml/wix-app-evals/custom-element-widget/session-methods-called-directly.yml index 318ef2efe..5f2a59852 100644 --- a/yaml/wix-app-evals/custom-element-widget/session-methods-called-directly.yml +++ b/yaml/wix-app-evals/custom-element-widget/session-methods-called-directly.yml @@ -5,7 +5,7 @@ triggerPrompt: |- APP_NAMESPACE: "@mirivo/my-app31ceim", Use this namespace when creating data collections (for idSuffix scoping). CODE_IDENTIFIER: "MirivoMyApp31ceim" When generating editor React component or function library extensions, use this identifier as the type prefix (e.g. type: 'MirivoMyApp31ceim"ComponentName'). templateId: c0ee6fd2-5265-4c57-b083-4f1308ff5ffa -tags: [custom-element-widget, backend-api, data-collection] +tags: [custom-element-widget, backend-api, data-collection, stores-versioning] assertions: - type: skill_was_called negate: false diff --git a/yaml/wix-app-evals/dashboard-page/sdk-lookup-one-call.yml b/yaml/wix-app-evals/dashboard-page/sdk-lookup-one-call.yml new file mode 100644 index 000000000..aa8b33595 --- /dev/null +++ b/yaml/wix-app-evals/dashboard-page/sdk-lookup-one-call.yml @@ -0,0 +1,30 @@ +name: dashboard-page/sdk-lookup-one-call +description: A dashboard page over two verticals (contacts and orders) finds both SDK calls with one run of the skill's sdk-lookup.cjs, takes the namespace it chooses (contactsV5.searchContacts for a search box, orders.searchOrders), and does not crawl the auto_sdk typings to choose a method +triggerPrompt: |- + Build a dashboard page that lists the site's contacts with name, email and phone, with a search box that finds a contact by any of them. Add a second page listing the site's orders, searchable by order number. + APP_NAMESPACE: "@mirivo/my-app31ceim", Use this namespace when creating data collections (for idSuffix scoping). + CODE_IDENTIFIER: "MirivoMyApp31ceim" When generating editor React component or function library extensions, use this identifier as the type prefix (e.g. type: 'MirivoMyApp31ceim"ComponentName'). +templateId: c0ee6fd2-5265-4c57-b083-4f1308ff5ffa +tags: [dashboard-page, sdk-module-map, draft-template, wix-patterns-docs] +assertions: + - type: skill_was_called + negate: false + skillNames: [wix-app] + referenceFiles: + wix-app: [references/DASHBOARD_PAGE.md, references/dashboard-page/DRAFT_TEMPLATE.md] + - type: build_passed + command: npm run build + - type: llm_judge + model: claude-sonnet-4-6 + minScore: 7 + prompt: |- + Evaluate how the agent found the SDK calls, from the trace and the generated code. Check: (1) The agent ran the skill's `scripts/sdk-lookup.cjs` with both entities (contacts and orders) in **one** call, after `@wix/crm` and `@wix/ecom` were installed. A separate call per entity scores at most 6; no call at all scores at most 3. (2) The contacts search box calls `contactsV5.searchContacts` from `@wix/crm` with the term in the free-text `search` expression — not `contacts.queryContacts()`, and not several per-field queries merged in the client (score at most 3 for either). (3) The orders page calls `orders.searchOrders` from `@wix/ecom`. (4) Before the lookup output, the agent did not list or grep `node_modules/@wix/auto_sdk_*` or `@wix/sdk-types` typings to choose a namespace or method; reading the one declaration the lookup names (`type … file:line`) to check fields is expected and fine. Score 0-10, citing the commands in the trace. + - type: llm_judge + model: claude-sonnet-4-6 + minScore: 7 + prompt: |- + Evaluate the permission handling. The lookup prints each method's permission but not its Dev Center scope ID. Check: (1) The final response lists, under manual steps, a Dev Center permission scope for contacts (read contacts) and one for orders (read orders), each taken from a docs page or the lookup output rather than invented. (2) The orders page does not break the contacts page if the orders scope is missing (the second vertical's call is wrapped). Score 0-10. + - type: time_limit + maxDurationMs: 300000 + - type: cost + maxCostUsd: 2 diff --git a/yaml/wix-app-evals/dashboard-page/sdk-lookup-search-term-members.yml b/yaml/wix-app-evals/dashboard-page/sdk-lookup-search-term-members.yml new file mode 100644 index 000000000..312c847b2 --- /dev/null +++ b/yaml/wix-app-evals/dashboard-page/sdk-lookup-search-term-members.yml @@ -0,0 +1,25 @@ +name: dashboard-page/sdk-lookup-search-term-members +description: A members dashboard page with a search box takes the term rule from the sdk-lookup shape line — members has no search method, but queryMembers takes free text in options.search, whose fields default to the nickname alone, so the call names the name and email fields — and does not run one query per field or filter a fetched page in the client +triggerPrompt: |- + Build a dashboard page that lists the site's members with their name, login email and nickname, with a search box that finds a member by any of them. + APP_NAMESPACE: "@mirivo/my-app31ceim", Use this namespace when creating data collections (for idSuffix scoping). + CODE_IDENTIFIER: "MirivoMyApp31ceim" When generating editor React component or function library extensions, use this identifier as the type prefix (e.g. type: 'MirivoMyApp31ceim"ComponentName'). +templateId: c0ee6fd2-5265-4c57-b083-4f1308ff5ffa +tags: [dashboard-page, sdk-module-map, draft-template, wix-patterns-docs] +assertions: + - type: skill_was_called + negate: false + skillNames: [wix-app] + referenceFiles: + wix-app: [references/DASHBOARD_PAGE.md, references/dashboard-page/DRAFT_TEMPLATE.md] + - type: build_passed + command: npm run build + - type: llm_judge + model: claude-sonnet-4-6 + minScore: 7 + prompt: |- + Evaluate how the search box finds members, from the trace and the generated code. Check: (1) The agent ran the skill's `scripts/sdk-lookup.cjs` for members with `--intent search` after `@wix/members` was installed, and the code follows its `shape` line. (2) The search term reaches the server in ONE `members.queryMembers` call, on the name and the email fields (`contact.firstName`, `contact.lastName`, `loginEmail`): either in `options.search` with `expression` and `fields` naming those fields, or in one `filter` with `$or` of `$startsWith` on them. A `search` with no `fields` searches only `profile.nickname`, so it scores at most 4. Several queries merged in the client, or a fetched page filtered in the client with `includes()`, scores at most 3. (3) The query passes `fieldsets: ['FULL']`, so the name and login email columns are filled (the default `PUBLIC` returns neither). Score 0-10, citing the code and the commands in the trace. + - type: time_limit + maxDurationMs: 300000 + - type: cost + maxCostUsd: 2 diff --git a/yaml/wix-app-evals/refunds-dashboard-sdk-not-cms.yml b/yaml/wix-app-evals/refunds-dashboard-sdk-not-cms.yml index 98e18d48a..1c779eb68 100644 --- a/yaml/wix-app-evals/refunds-dashboard-sdk-not-cms.yml +++ b/yaml/wix-app-evals/refunds-dashboard-sdk-not-cms.yml @@ -28,7 +28,7 @@ assertions: model: claude-sonnet-4-6 minScore: 7 prompt: |- - Evaluate the quality of the implementation path. Check: (1) The agent consulted the Wix SDK documentation (MCP search tools such as SearchWixSDKDocumentation / ReadFullDocsMethodSchema) or the skill's SDK-First Rule module map before deciding how to access refund data — it did not guess method names or conclude "no SDK exists" with zero lookups. (2) SDK imports are real `@wix/*` packages with plausible method names (e.g. `orders`, `orderTransactions` from `@wix/ecom`, or `refunds` from `@wix/payments`). (3) The dashboard page UI follows the skill's conventions (@wix/patterns collection page for the table, @wix/design-system only for leaf UI). (4) The agent looked up `@wix/patterns` names via `dist/dts-bundle/index.json` and `dist/docs/index.json` (and the specific `.d.ts`/`.md` files those point at) — not by running `ls`, `find`, `cat`, or `grep` against arbitrary paths under `node_modules`. Any such direct `node_modules` exploration is an automatic low score. Score 0-10. + Evaluate the quality of the implementation path. Check: (1) The agent ran the skill's `sdk-lookup.cjs`, consulted the Wix SDK documentation (MCP search tools such as SearchWixSDKDocumentation / ReadFullDocsMethodSchema), or the skill's SDK-First Rule module map before deciding how to access refund data — it did not guess method names or conclude "no SDK exists" with zero lookups. (2) SDK imports are real `@wix/*` packages with plausible method names (e.g. `orders`, `orderTransactions` from `@wix/ecom`, or `refunds` from `@wix/payments`). (3) The dashboard page UI follows the skill's conventions (@wix/patterns collection page for the table, @wix/design-system only for leaf UI). (4) The agent looked up `@wix/patterns` names via `dist/dts-bundle/index.json` and `dist/docs/index.json` (and the specific `.d.ts`/`.md` files those point at) — not by running `ls`, `find`, `cat`, or `grep` against arbitrary paths under `node_modules`. Any such direct `node_modules` exploration is an automatic low score. Running the skill's own scripts (`sdk-lookup.cjs`, `pkg-root.cjs`, `patterns-lookup.cjs`) is not exploration. Score 0-10. - type: llm_judge model: claude-sonnet-4-6 minScore: 7