From 8493beffd9d96d9566c5087bf0263072e367317e Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Fri, 22 May 2026 10:17:12 -0500 Subject: [PATCH 1/4] Adding a formio-sdk skill. --- CLAUDE.md | 2 + .../.openspec.yaml | 2 + .../2026-05-22-add-formio-sdk-skill/design.md | 150 ++++++ .../proposal.md | 29 ++ .../specs/api-skills-validation/spec.md | 51 ++ .../specs/formio-sdk-skill/spec.md | 202 ++++++++ .../2026-05-22-add-formio-sdk-skill/tasks.md | 206 ++++++++ openspec/specs/api-skills-validation/spec.md | 50 ++ openspec/specs/formio-sdk-skill/spec.md | 202 ++++++++ .../skills-validator-formio-sdk.test.ts | 457 ++++++++++++++++++ packages/mcp-server/src/skills-validator.ts | 446 +++++++++++++++++ plugin/skills/formio-sdk/SKILL.md | 102 ++++ plugin/skills/formio-sdk/references/auth.md | 131 +++++ plugin/skills/formio-sdk/references/files.md | 127 +++++ plugin/skills/formio-sdk/references/forms.md | 137 ++++++ .../skills/formio-sdk/references/plugins.md | 171 +++++++ .../skills/formio-sdk/references/projects.md | 103 ++++ .../skills/formio-sdk/references/rendering.md | 272 +++++++++++ plugin/skills/formio-sdk/references/roles.md | 103 ++++ plugin/skills/formio-sdk/references/setup.md | 116 +++++ .../formio-sdk/references/submissions.md | 127 +++++ .../formio-sdk/references/utils-conditions.md | 121 +++++ .../formio-sdk/references/utils-evaluator.md | 113 +++++ .../references/utils-form-traversal.md | 128 +++++ .../formio-sdk/references/utils-jsonlogic.md | 121 +++++ .../formio-sdk/references/utils-logic.md | 143 ++++++ .../references/utils-mask-sanitize.md | 96 ++++ .../formio-sdk/references/utils-misc.md | 131 +++++ 28 files changed, 4039 insertions(+) create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/proposal.md create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/specs/api-skills-validation/spec.md create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/specs/formio-sdk-skill/spec.md create mode 100644 openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md create mode 100644 openspec/specs/formio-sdk-skill/spec.md create mode 100644 packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts create mode 100644 plugin/skills/formio-sdk/SKILL.md create mode 100644 plugin/skills/formio-sdk/references/auth.md create mode 100644 plugin/skills/formio-sdk/references/files.md create mode 100644 plugin/skills/formio-sdk/references/forms.md create mode 100644 plugin/skills/formio-sdk/references/plugins.md create mode 100644 plugin/skills/formio-sdk/references/projects.md create mode 100644 plugin/skills/formio-sdk/references/rendering.md create mode 100644 plugin/skills/formio-sdk/references/roles.md create mode 100644 plugin/skills/formio-sdk/references/setup.md create mode 100644 plugin/skills/formio-sdk/references/submissions.md create mode 100644 plugin/skills/formio-sdk/references/utils-conditions.md create mode 100644 plugin/skills/formio-sdk/references/utils-evaluator.md create mode 100644 plugin/skills/formio-sdk/references/utils-form-traversal.md create mode 100644 plugin/skills/formio-sdk/references/utils-jsonlogic.md create mode 100644 plugin/skills/formio-sdk/references/utils-logic.md create mode 100644 plugin/skills/formio-sdk/references/utils-mask-sanitize.md create mode 100644 plugin/skills/formio-sdk/references/utils-misc.md diff --git a/CLAUDE.md b/CLAUDE.md index 5b3b397..fe9c896 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,6 +18,8 @@ The repository ships a Claude skills library at [plugin/skills/](plugin/skills/) The library's default "build me an app" entry point is [`skills/formio-application/`](skills/formio-application/) — a framework-agnostic orchestrator that runs the full pipeline from plain-language intent through `formio-resource-planner`, the `project_import` MCP tool, and handoff to a framework-specific scaffolding skill. Today the only framework implementor is [`skills/formio-angular/`](skills/formio-angular/) (with its sub-skill [`skills/formio-angular/resources/`](skills/formio-angular/resources/) for per-resource NgModule work); `formio-angular` is no longer a top-level build-an-app skill — it is the Angular-specific implementor that `formio-application` delegates to. Future framework skills (`formio-react`, etc.) add themselves as rows in `skills/formio-application/FRAMEWORK.md`'s registry table; no other change to the orchestrator is required. +The library also ships [`skills/formio-sdk/`](plugin/skills/formio-sdk/) — a source-derived reference for the `@formio/js` SDK and `@formio/js/utils` Utilities, authored directly from the Form.io source code (`packages/core/src/sdk`, `packages/core/src/utils`, `packages/formio.js/src/Formio.js`, `packages/formio.js/src/utils`) rather than from drift-prone online docs. Reference docs cover SDK setup, auth, forms, submissions, projects, roles, files, plugins, VanillaJS rendering (`Formio.createForm`), and the Utils surface (Evaluator, form traversal, conditions, logic, JSONLogic, mask/sanitize, misc). The skill mandates ESM imports (`import { Formio } from '@formio/js'`, `import { Utils } from '@formio/js/utils'`) and explicit Hosted-vs-SaaS URL configuration; the validator enforces both. + The router skill's `description` follows a three-clause template: capability statement, a "Use when the user asks to …" trigger clause, and a "Not for: …" negative-trigger clause disambiguating from `formio-application` (orchestrator) and `formio-resource-planner` (planner). Each reference document includes a `## MCP Tool Preference` section instructing Claude to prefer the MCP server's first-party tools (`form_*`, `role_*`, `project_*`, `authenticate`) when they cover the requested operation. Authentication: the MCP server uses a browser-based portal-login flow — a short-lived local Express server renders the Form.io portal login form and captures the returned JWT via a `/callback` endpoint; `formioFetch` then attaches `x-jwt-token` on every request. Skills do NOT use PKCE or API-key auth. diff --git a/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml new file mode 100644 index 0000000..a5aaf30 --- /dev/null +++ b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-tdd +created: 2026-05-22 diff --git a/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md new file mode 100644 index 0000000..bb2ca26 --- /dev/null +++ b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/design.md @@ -0,0 +1,150 @@ +## Context + +`plugin/skills/` already hosts source-derived skills for REST endpoints (`formio-api`), app orchestration (`formio-application`), schema planning (`formio-resource-planner`), and framework implementors (`formio-angular`). What is missing is a skill for the runtime JavaScript surface: the `Formio` SDK (CRUD, auth, projects, files, plugins) and the `Utils` namespace (formula evaluation, condition logic, component traversal, masks, sanitization, date helpers, i18n, JSONLogic). + +The canonical sources live outside this repo at the Form.io source repository (kept outside this repo at authoring time): + +- SDK core: `packages/core/src/sdk/Formio.ts`, `Plugins.ts`, `index.ts` +- SDK renderer extensions: `packages/formio.js/src/Formio.js` (re-exports + adds icon/template/CDN/license/plugin glue) +- Utils core: `packages/core/src/utils/` (`Evaluator.ts`, `conditions.ts`, `logic.ts`, `formUtil/`, `jsonlogic/`, `mask.ts`, `sanitize.ts`, `date.ts`, `dom.ts`, `i18n.ts`, `jwtDecode.ts`, `unwind.ts`, `utils.ts`) +- Utils renderer extensions: `packages/formio.js/src/utils/` (`Evaluator.js`, `formUtils.js`, `builder.js`, `calendarUtils.js`, `ChoicesWrapper.js`, `conditionOperators/`, `i18n.js`, `utils.js`, `index.js`) + +Existing online documentation for these surfaces is known to drift from source; the skill MUST be derived from the source tree, not from `help.form.io` or third-party blog posts. + +The skill must fit the established library conventions: + +- Single activatable `SKILL.md` with three-clause description (capability / "Use when…" / "Not for: …"). +- One reference document per capability group under `references/`, no YAML frontmatter on references. +- Per-reference `## MCP Tool Preference` block where applicable (CRUD methods that overlap `form_*` / `project_*` / `role_*` tools should defer to MCP first). +- Strict terminology: `baseUrl` → `FORMIO_BASE_URL`; `projectUrl` → `FORMIO_PROJECT_URL`. +- Validator coverage in `packages/mcp-server/src/skills-validator.ts` with Vitest enforcement under `pnpm test`. + +## Goals / Non-Goals + +**Goals:** + +- Teach Claude the actual runtime API surface of `@formio/js` and `@formio/js/utils` as it exists in the Form.io source HEAD, with executable examples. +- Make canonical import statements (`import { Formio } from '@formio/js'` and `import { Utils } from '@formio/js/utils'`) non-negotiable — examples MUST use them; deep imports (`@formio/core`, `@formio/js/lib/...`) MUST NOT appear. +- Make Hosted-vs-SaaS URL configuration the first thing the skill asserts. Every code example MUST be preceded (in its reference doc) by an explicit `setBaseUrl` + `setProjectUrl` pair appropriate to the environment. +- Provide enough worked examples that an agent can implement common flows (login, list submissions, create form, upload file, evaluate condition, traverse components, run JSONLogic) without reading source. +- Enforce skill structure mechanically via the existing validator infrastructure. + +**Non-Goals:** + +- Vendoring or building against the Form.io source tree at runtime — source is consulted at authoring time only. +- Documenting the Form.io REST endpoints themselves — that is `formio-api`'s job. References cross-link there for endpoint shape. +- Documenting Angular/React framework wrappers — those are owned by `formio-angular` and future framework skills. +- Eval harness in the first iteration — add later under `plugin/skills/formio-sdk/evals/` once the skill stabilizes. + +## Decisions + +### Decision 1: Single skill, multiple references — NOT a per-method skill + +Adopt the `formio-api` shape: one activatable `SKILL.md` plus reference documents under `references/`. Alternative considered: separate `formio-sdk` and `formio-utils` skills. Rejected because the SDK and Utils are imported from the same package and agents routinely need both in the same edit; splitting forces double-activation and risks divergent guidance on imports / URL config. + +### Decision 2: Reference layout — capability-grouped, source-anchored + +References will be grouped by user-facing capability, not by source file. Proposed initial set (subject to confirmation while reading source): + +SDK references: + +- `references/setup.md` — `setBaseUrl`, `setProjectUrl`, Hosted vs SaaS, token storage, plugins registration +- `references/auth.md` — `Formio.login`, `Formio.logout`, `Formio.currentUser`, `Formio.ssoInit`, JWT handling, OAuth helpers +- `references/forms.md` — form CRUD via `new Formio(url).loadForm()/saveForm()/deleteForm()`, `loadForms` +- `references/submissions.md` — submission CRUD, `loadSubmissions` with query, `availableActions` +- `references/projects.md` — `loadProject`, `saveProject`, project-level helpers +- `references/roles.md` — `loadRoles`, role assignment +- `references/files.md` — `Formio.uploadFile`, `downloadFile`, provider config +- `references/plugins.md` — `Formio.registerPlugin`, plugin lifecycle hooks (`preRequest`, `request`, `wrapRequestPromise`, etc.) +- `references/rendering.md` — VanillaJS form rendering via `Formio.createForm(element, formSrc, options)`; covers prefill, events (`change`, `submit`, `error`, `nextPage`, `prevPage`), wizard, builder (`Formio.builder`), PDF (`Formio.createForm` with PDF source), read-only mode, custom templates/icons, and offline/local-JSON form sources. Behavior coverage is drawn from `formio.github.io/formio.js/app/examples` but ALL examples in this reference use ESM `import { Formio } from '@formio/js'` — never `` inside a fenced block +- **THEN** `validateFormioSdkSkill` SHALL emit a `formio_sdk.forbidden_script_tag` issue diff --git a/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md new file mode 100644 index 0000000..c78b21d --- /dev/null +++ b/openspec/changes/archive/2026-05-22-add-formio-sdk-skill/tasks.md @@ -0,0 +1,206 @@ +## 1. Validator scaffold (`validateFormioSdkSkill`) + + +### Red + +- [x] 1.1 Write failing Vitest test: `validateFormioSdkSkill` exists, is exported from `packages/mcp-server/src/skills-validator.ts`, and returns `[]` when `plugin/skills/formio-sdk/` is absent from a temp library fixture +- [x] 1.2 Write failing Vitest test: when `plugin/skills/formio-sdk/` exists but `SKILL.md` is absent, `validateFormioSdkSkill` emits exactly one issue with `category: "formio_sdk"` and `rule: "skill_missing"` +- [x] 1.3 Write failing Vitest test: `validateLibrary` invokes `validateFormioSdkSkill` and propagates its issues into the aggregated result (assert the new issue surfaces through the public entry point) + +### Green + +- [x] 1.4 Add a `validateFormioSdkSkill(libraryDir)` export with the minimal logic needed to pass 1.1–1.2 (existence check + `skill_missing` emission) +- [x] 1.5 Wire `validateFormioSdkSkill` into `validateLibrary` so 1.3 passes + +### Refactor + +- [x] 1.6 Review implementation and refactor as needed + +## 2. Frontmatter + three-clause description rules + + +### Red + +- [x] 2.1 Write failing test: empty `SKILL.md` (no YAML frontmatter) emits `formio_sdk.frontmatter_missing` +- [x] 2.2 Write failing test: frontmatter with `name: formio-sdk` but a `description` that lacks `Use when the user asks to` emits `formio_sdk.description_clause` with `clause: "trigger"` +- [x] 2.3 Write failing test: description with the trigger clause but no `Not for:` clause emits `formio_sdk.description_clause` with `clause: "negative"` +- [x] 2.4 Write failing test: `Not for:` clause that omits the literal `formio-api` emits `formio_sdk.description_clause` with `clause: "negative"` whose payload names `formio-api` +- [x] 2.5 Write failing test: description with all three clauses and all four sibling skill names returns no `description_clause` issues + +### Green + +- [x] 2.6 Implement frontmatter parsing and the three-clause description checks against the failing tests +- [x] 2.7 Implement the sibling-name check covering `formio-api`, `formio-application`, `formio-resource-planner`, and `formio-angular` + +### Refactor + +- [x] 2.8 Review implementation and refactor as needed + +## 3. Canonical-import + forbidden-import enforcement + + +### Red + +- [x] 3.1 Write failing test: a `SKILL.md` that lacks `import { Formio } from '@formio/js'` emits `formio_sdk.canonical_import_missing` with `which: "sdk"` +- [x] 3.2 Write failing test: a `SKILL.md` that lacks `import { Utils } from '@formio/js/utils'` emits `formio_sdk.canonical_import_missing` with `which: "utils"` +- [x] 3.3 Write failing test: a reference doc with a fenced block containing `import { Formio } from '@formio/core'` emits `formio_sdk.forbidden_import` with `import_path: "@formio/core"` +- [x] 3.4 Write failing test: a fenced block containing `import x from '@formio/js/lib/Formio'` emits `formio_sdk.forbidden_import` with `import_path` beginning `@formio/js/lib/` +- [x] 3.5 Write failing test: a fenced JS block containing `const { Formio } = require('@formio/js');` emits `formio_sdk.forbidden_import` with `import_path: "@formio/js"` +- [x] 3.6 Write failing test: prose mention of `@formio/core` OUTSIDE any code fence emits zero `formio_sdk.forbidden_import` issues +- [x] 3.7 Write failing test: a fenced block containing `` emits `formio_sdk.forbidden_script_tag` +- [x] 3.8 Write failing test: prose mention of `` inside a fenced block +- **THEN** `validateFormioSdkSkill` SHALL emit a `formio_sdk.forbidden_script_tag` issue diff --git a/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts b/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts new file mode 100644 index 0000000..649c1b7 --- /dev/null +++ b/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts @@ -0,0 +1,457 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + SDK_CANONICAL_SDK_IMPORT, + SDK_CANONICAL_UTILS_IMPORT, + SDK_HOSTED_BASE_URL_LITERAL, + SDK_HOSTED_PROJECT_URL_LITERAL, + SDK_REQUIRED_REFERENCES, + SDK_SAAS_BASE_URL_LITERAL, + SDK_SAAS_PROJECT_URL_LITERAL, + SDK_SKILL_DIR, + SKILL_FILENAME, + REFERENCES_DIRNAME, + validateFormioSdkSkill, + validateFormioSdkSkillContent, + validateFormioSdkDescription, + validateFormioSdkCanonicalImports, + validateFormioSdkForbiddenImports, + validateFormioSdkScriptTags, + validateFormioSdkUrlConfigSkill, + validateFormioSdkUrlConfigReference, + validateFormioSdkReferenceLayout, + validateFormioSdkNavigationTable, + validateFormioSdkRenderingReference, + validateLibrary, +} from '../skills-validator.js'; + +const GOOD_DESCRIPTION = + 'Source-derived skill teaching @formio/js and @formio/js/utils. Use when the user asks to call SDK methods or invoke Utils helpers. Not for: REST endpoint shape (see formio-api), building an app (see formio-application), planning resources (see formio-resource-planner), or Angular wrappers (see formio-angular).'; + +function navTable(): string { + const rows = SDK_REQUIRED_REFERENCES.map((r) => `| Something | [${r}](./references/${r}) |`).join( + '\n' + ); + return `| Intent | Reference |\n| --- | --- |\n${rows}\n`; +} + +function makeSkillSource(overrides: { description?: string; navTable?: string } = {}): string { + const description = overrides.description ?? GOOD_DESCRIPTION; + const nav = overrides.navTable ?? navTable(); + return `--- +name: ${SDK_SKILL_DIR} +description: ${JSON.stringify(description)} +--- + +# Form.io SDK + +## Imports + +\`\`\`ts +${SDK_CANONICAL_SDK_IMPORT}; +${SDK_CANONICAL_UTILS_IMPORT}; +\`\`\` + +## URL Configuration + +### Hosted + +\`\`\`ts +${SDK_CANONICAL_SDK_IMPORT}; +Formio.${SDK_HOSTED_BASE_URL_LITERAL}; +Formio.${SDK_HOSTED_PROJECT_URL_LITERAL}; +\`\`\` + +### SaaS + +\`\`\`ts +${SDK_CANONICAL_SDK_IMPORT}; +Formio.${SDK_SAAS_BASE_URL_LITERAL}; +Formio.${SDK_SAAS_PROJECT_URL_LITERAL}; +\`\`\` + +## Navigation + +${nav} +`; +} + +function makeReferenceSource(opts: { utils?: boolean; sourcedFrom?: string } = {}): string { + const sourcedFrom = opts.sourcedFrom ?? 'packages/core/src/sdk/Formio.ts'; + const urlConfig = opts.utils + ? '' + : `## URL Configuration + +\`\`\`ts +${SDK_CANONICAL_SDK_IMPORT}; +Formio.${SDK_HOSTED_BASE_URL_LITERAL}; +Formio.${SDK_HOSTED_PROJECT_URL_LITERAL}; +Formio.${SDK_SAAS_BASE_URL_LITERAL}; +Formio.${SDK_SAAS_PROJECT_URL_LITERAL}; +\`\`\` + +`; + const importLine = opts.utils ? SDK_CANONICAL_UTILS_IMPORT : SDK_CANONICAL_SDK_IMPORT; + return `## Overview + +Sourced from \`${sourcedFrom}\`. + +## Imports + +\`\`\`ts +${importLine}; +\`\`\` + +${urlConfig}## API + +- \`thing()\` — does a thing. + +## Examples + +\`\`\`ts +${importLine}; +\`\`\` +`; +} + +function writeSdkSkill( + root: string, + opts: { + skillContents?: string; + skipSkill?: boolean; + referenceContents?: Partial>; + skipReferences?: string[]; + } = {} +) { + const skillRoot = path.join(root, SDK_SKILL_DIR); + fs.mkdirSync(path.join(skillRoot, REFERENCES_DIRNAME), { recursive: true }); + if (!opts.skipSkill) { + fs.writeFileSync(path.join(skillRoot, SKILL_FILENAME), opts.skillContents ?? makeSkillSource()); + } + const skipped = new Set(opts.skipReferences ?? []); + for (const ref of SDK_REQUIRED_REFERENCES) { + if (skipped.has(ref)) continue; + const override = opts.referenceContents?.[ref]; + const body = + override ?? + makeReferenceSource({ + utils: ref.startsWith('utils-'), + sourcedFrom: ref.startsWith('utils-') + ? 'packages/core/src/utils/utils.ts' + : 'packages/core/src/sdk/Formio.ts', + }); + // rendering.md requires Formio.createForm call + form.on('submit') + submission = + const finalBody = + ref === 'rendering.md' + ? body + + `\n\`\`\`ts +${SDK_CANONICAL_SDK_IMPORT}; +const form = await Formio.createForm(document.getElementById('formio'), 'https://forms.mysite.com/myproject/myform'); +form.on('submit', (submission) => console.log(submission)); +form.submission = { data: { name: 'prefilled' } }; +\`\`\`\n` + : body; + fs.writeFileSync(path.join(skillRoot, REFERENCES_DIRNAME, ref), finalBody); + } +} + +describe('validateFormioSdkSkill — scaffold', () => { + let tmpDir: string; + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-skill-')); + }); + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('returns [] when plugin/skills/formio-sdk/ is absent', () => { + expect(validateFormioSdkSkill(tmpDir)).toEqual([]); + }); + + it('emits formio_sdk.skill_missing when skill dir exists but SKILL.md is absent', () => { + fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true }); + const issues = validateFormioSdkSkill(tmpDir); + expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true); + }); + + it('validateLibrary invokes validateFormioSdkSkill and surfaces its issues', () => { + fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true }); + const issues = validateLibrary(tmpDir); + expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true); + }); + + it('passes on a well-formed scaffolded skill', () => { + writeSdkSkill(tmpDir); + const issues = validateFormioSdkSkill(tmpDir); + if (issues.length > 0) console.error(JSON.stringify(issues, null, 2)); + expect(issues).toEqual([]); + }); +}); + +describe('validateFormioSdkSkill — frontmatter + description clauses', () => { + it('flags empty frontmatter as formio_sdk.frontmatter_missing', () => { + const issues = validateFormioSdkSkillContent( + 'formio-sdk/SKILL.md', + '# heading only, no frontmatter\n' + ); + expect(issues.some((i) => i.rule === 'formio_sdk.frontmatter_missing')).toBe(true); + }); + + it('flags description missing "Use when the user asks to" as trigger', () => { + const issues = validateFormioSdkDescription('SKILL.md', { + name: SDK_SKILL_DIR, + description: + 'Mentions @formio/js and @formio/js/utils. Not for: formio-api formio-application formio-resource-planner formio-angular', + }); + expect( + issues.some( + (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('trigger') + ) + ).toBe(true); + }); + + it('flags description missing "Not for:" as negative', () => { + const issues = validateFormioSdkDescription('SKILL.md', { + name: SDK_SKILL_DIR, + description: 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things.', + }); + expect( + issues.some( + (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('negative') + ) + ).toBe(true); + }); + + it('flags description that omits formio-api in the Not for clause', () => { + const issues = validateFormioSdkDescription('SKILL.md', { + name: SDK_SKILL_DIR, + description: + 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things. Not for: formio-application, formio-resource-planner, formio-angular.', + }); + expect( + issues.some( + (i) => + i.rule === 'formio_sdk.description_clause' && + i.message.includes('negative') && + i.message.includes('formio-api') + ) + ).toBe(true); + }); + + it('returns no description_clause issues for a full three-clause description', () => { + const issues = validateFormioSdkDescription('SKILL.md', { + name: SDK_SKILL_DIR, + description: GOOD_DESCRIPTION, + }); + expect(issues.filter((i) => i.rule === 'formio_sdk.description_clause')).toEqual([]); + }); +}); + +describe('validateFormioSdkSkill — canonical + forbidden imports + script tags', () => { + it('emits canonical_import_missing sdk when SDK import is absent', () => { + const source = `--- +name: ${SDK_SKILL_DIR} +description: ${JSON.stringify(GOOD_DESCRIPTION)} +--- + +\`\`\`ts +${SDK_CANONICAL_UTILS_IMPORT}; +\`\`\` +`; + const issues = validateFormioSdkCanonicalImports('SKILL.md', source); + expect( + issues.some( + (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"sdk"') + ) + ).toBe(true); + }); + + it('emits canonical_import_missing utils when Utils import is absent', () => { + const source = `\`\`\`ts\n${SDK_CANONICAL_SDK_IMPORT};\n\`\`\`\n`; + const issues = validateFormioSdkCanonicalImports('SKILL.md', source); + expect( + issues.some( + (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"utils"') + ) + ).toBe(true); + }); + + it('flags @formio/core import inside fenced block', () => { + const source = `\`\`\`ts\nimport { Formio } from '@formio/core';\n\`\`\`\n`; + const issues = validateFormioSdkForbiddenImports('ref.md', source); + expect( + issues.some( + (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/core') + ) + ).toBe(true); + }); + + it('flags @formio/js/lib deep import', () => { + const source = `\`\`\`ts\nimport x from '@formio/js/lib/Formio';\n\`\`\`\n`; + const issues = validateFormioSdkForbiddenImports('ref.md', source); + expect( + issues.some( + (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/js/lib/') + ) + ).toBe(true); + }); + + it('flags require() of @formio/js', () => { + const source = `\`\`\`js\nconst { Formio } = require('@formio/js');\n\`\`\`\n`; + const issues = validateFormioSdkForbiddenImports('ref.md', source); + expect( + issues.some( + (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('"@formio/js"') + ) + ).toBe(true); + }); + + it('does not flag @formio/core mentioned in prose outside a code fence', () => { + const source = 'The renderer extends @formio/core SDK methods.\n'; + expect(validateFormioSdkForbiddenImports('ref.md', source)).toEqual([]); + }); + + it('flags \n\`\`\`\n`; + const issues = validateFormioSdkScriptTags('ref.md', source); + expect(issues.some((i) => i.rule === 'formio_sdk.forbidden_script_tag')).toBe(true); + }); + + it('does not flag world

', {}); +console.log(safe.toString()); // "

Hello world

" +``` + +### Allow extra tags / attributes + +```ts +import { Utils } from '@formio/js/utils'; + +const safe = Utils.sanitize('
Hi
', { + addTags: ['article'], + addAttr: ['data-tag'], +}); +``` + +### Manipulate the DOM via helpers + +```ts +import { Utils } from '@formio/js/utils'; + +const banner = document.createElement('div'); +banner.className = 'banner'; +banner.textContent = 'Saved!'; +Utils.dom.prependTo(banner, document.getElementById('formio')!); + +setTimeout(() => { + Utils.dom.removeChildFrom(banner, document.getElementById('formio')!); +}, 2000); +``` diff --git a/plugin/skills/formio-sdk/references/utils-misc.md b/plugin/skills/formio-sdk/references/utils-misc.md new file mode 100644 index 0000000..62318ec --- /dev/null +++ b/plugin/skills/formio-sdk/references/utils-misc.md @@ -0,0 +1,131 @@ +## Overview + +Miscellaneous helpers exported from `@formio/js/utils`: date utilities, i18n, JWT decode, submission unwind/rewind, deep clone, and class override. Sourced from `packages/core/src/utils/date.ts`, `packages/core/src/utils/i18n.ts`, `packages/core/src/utils/jwtDecode.ts`, `packages/core/src/utils/unwind.ts`, `packages/core/src/utils/fastCloneDeep.ts`, `packages/core/src/utils/override.ts`, and `packages/formio.js/src/utils/i18n.js` in the Form.io source code. + +## Imports + +```ts +import { Utils } from '@formio/js/utils'; +``` + +## API + +Date (`Utils.date`): + +- `Utils.date.dayjs` — the `dayjs` instance with `utc`, `timezone`, `advancedFormat`, and `customParseFormat` plugins pre-loaded. +- `Utils.date.currentTimezone(): string` — browser/Node timezone via `dayjs.tz.guess()`. +- `Utils.date.convertFormatToMoment(format: string): string` — translate Angular date format tokens (`MM/dd/yyyy`) into moment/dayjs tokens (`MM/DD/YYYY`). + +i18n (`Utils.i18n`): + +- `Utils.i18n.t(key: string, options?): string` — translation marker used by Form.io's translation tooling; in the renderer the live translator is exposed on the `Form` instance as `form.i18next`. +- `Utils.i18n.i18nConfig` — default i18n configuration (separators, default language, baseline resources). +- `Utils.i18n.coreEnTranslation` — the English translation dictionary shipped with `@formio/core`. + +The renderer also exports the `I18n` class (`packages/formio.js/src/utils/i18n.js`): + +- `new I18n(languages?: object)` — manage a language dictionary at runtime. +- `setLanguages(languages)` / `changeLanguage(language)` / `t(key, defaultValue?)`. + +JWT (`Utils.jwtDecode`): + +- `Utils.jwtDecode(token: string, options?: { header?: boolean }): object` — decode the payload (default) or the header (`{ header: true }`). + +Submission unwind / rewind (`Utils.unwind`, `Utils.rewind`) — deprecated but still exported: + +- `Utils.unwind(form, submission): Submission[]` — explode nested array data into one submission per row. +- `Utils.rewind(submissions): Submission` — fold rows back into a nested submission. + +Cloning and override: + +- `Utils.fastCloneDeep(obj: any): any` — `JSON.parse(JSON.stringify(obj))` with error handling; returns `null` on failure. +- `Utils.override(classObj: any, extenders: any): void` — replace prototype methods/properties on a class. Each entry in `extenders` is either a function (replaces the method) or a property descriptor. + +## Examples + +### Decode a JWT payload + +```ts +import { Utils } from '@formio/js/utils'; + +const claims = Utils.jwtDecode(localStorage.getItem('myapp.jwt') ?? ''); +console.log(claims.user, claims.exp); +``` + +### Translate an Angular date format + +```ts +import { Utils } from '@formio/js/utils'; + +const dayjsFormat = Utils.date.convertFormatToMoment('MM/dd/yyyy h:mm a'); +console.log(dayjsFormat); // "MM/DD/YYYY h:mm a" +``` + +### Format the current time in the browser's timezone + +```ts +import { Utils } from '@formio/js/utils'; + +const now = Utils.date.dayjs().tz(Utils.date.currentTimezone()).format('YYYY-MM-DD HH:mm z'); +console.log(now); +``` + +### Switch language at runtime via the renderer's I18n + +```ts +import { Utils } from '@formio/js/utils'; + +const i18n = new Utils.I18n({ + en: { hello: 'Hello' }, + fr: { hello: 'Bonjour' }, +}); +i18n.changeLanguage('fr'); +console.log(i18n.t('hello')); // "Bonjour" +``` + +### Deep-clone a submission safely + +```ts +import { Utils } from '@formio/js/utils'; + +const draft = Utils.fastCloneDeep(submission); +if (draft) { + draft.data.firstName = 'Edited'; +} +``` + +### Override a component class method + +```ts +import { Utils } from '@formio/js/utils'; +import { Formio } from '@formio/js'; + +const TextField = Formio.Components.components.textfield; + +Utils.override(TextField, { + getValue(this: any) { + const v = this._origGetValue(); + return typeof v === 'string' ? v.trim() : v; + }, + _origGetValue: TextField.prototype.getValue, +}); +``` + +### Unwind a submission with a nested array + +```ts +import { Utils } from '@formio/js/utils'; + +const submission = { + data: { + customer: 'Acme', + items: [ + { sku: 'A1', qty: 2 }, + { sku: 'B2', qty: 5 }, + ], + }, +}; + +const rows = Utils.unwind(form, submission); +console.log(rows.length); // 2 (one per item) +``` From db6d9638d7ba7daf3f8da2423dc8b9323b0042a2 Mon Sep 17 00:00:00 2001 From: Travis Tidwell Date: Wed, 27 May 2026 13:45:40 -0500 Subject: [PATCH 2/4] Updated skill to resolve code review comments. --- CLAUDE.md | 4 +- .../2026-04-16-add-user-auth/design.md | 2 +- .../2026-04-16-add-user-auth/proposal.md | 2 +- .../specs/token-validation/spec.md | 10 +- .../archive/2026-04-16-add-user-auth/tasks.md | 2 +- .../specs/formio-application-skill/spec.md | 4 +- .../specs/formio-application-skill/spec.md | 4 +- openspec/specs/token-validation/spec.md | 6 +- .../__tests__/formio-angular-layout.test.ts | 273 ------ .../formio-application-layout.test.ts | 475 --------- .../src/__tests__/skills-library.test.ts | 393 -------- .../skills-validator-formio-sdk.test.ts | 457 --------- packages/mcp-server/src/skills-validator.ts | 922 ------------------ packages/mcp-server/tsconfig.build.json | 2 +- .../formio-api/references/platform-auth.md | 4 +- .../formio-api/references/runtime-auth.md | 8 +- plugin/skills/formio-sdk/SKILL.md | 8 +- plugin/skills/formio-sdk/references/auth.md | 10 +- 18 files changed, 33 insertions(+), 2553 deletions(-) delete mode 100644 packages/mcp-server/src/__tests__/formio-angular-layout.test.ts delete mode 100644 packages/mcp-server/src/__tests__/formio-application-layout.test.ts delete mode 100644 packages/mcp-server/src/__tests__/skills-library.test.ts delete mode 100644 packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts delete mode 100644 packages/mcp-server/src/skills-validator.ts diff --git a/CLAUDE.md b/CLAUDE.md index fe9c896..5d75feb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,13 +18,13 @@ The repository ships a Claude skills library at [plugin/skills/](plugin/skills/) The library's default "build me an app" entry point is [`skills/formio-application/`](skills/formio-application/) — a framework-agnostic orchestrator that runs the full pipeline from plain-language intent through `formio-resource-planner`, the `project_import` MCP tool, and handoff to a framework-specific scaffolding skill. Today the only framework implementor is [`skills/formio-angular/`](skills/formio-angular/) (with its sub-skill [`skills/formio-angular/resources/`](skills/formio-angular/resources/) for per-resource NgModule work); `formio-angular` is no longer a top-level build-an-app skill — it is the Angular-specific implementor that `formio-application` delegates to. Future framework skills (`formio-react`, etc.) add themselves as rows in `skills/formio-application/FRAMEWORK.md`'s registry table; no other change to the orchestrator is required. -The library also ships [`skills/formio-sdk/`](plugin/skills/formio-sdk/) — a source-derived reference for the `@formio/js` SDK and `@formio/js/utils` Utilities, authored directly from the Form.io source code (`packages/core/src/sdk`, `packages/core/src/utils`, `packages/formio.js/src/Formio.js`, `packages/formio.js/src/utils`) rather than from drift-prone online docs. Reference docs cover SDK setup, auth, forms, submissions, projects, roles, files, plugins, VanillaJS rendering (`Formio.createForm`), and the Utils surface (Evaluator, form traversal, conditions, logic, JSONLogic, mask/sanitize, misc). The skill mandates ESM imports (`import { Formio } from '@formio/js'`, `import { Utils } from '@formio/js/utils'`) and explicit Hosted-vs-SaaS URL configuration; the validator enforces both. +The library also ships [`skills/formio-sdk/`](plugin/skills/formio-sdk/) — a source-derived reference for the `@formio/js` SDK and `@formio/js/utils` Utilities, authored directly from the Form.io source code (`packages/core/src/sdk`, `packages/core/src/utils`, `packages/formio.js/src/Formio.js`, `packages/formio.js/src/utils`) rather than from drift-prone online docs. Reference docs cover SDK setup, auth, forms, submissions, projects, roles, files, plugins, VanillaJS rendering (`Formio.createForm`), and the Utils surface (Evaluator, form traversal, conditions, logic, JSONLogic, mask/sanitize, misc). The skill mandates ESM imports (`import { Formio } from '@formio/js'`, `import { Utils } from '@formio/js/utils'`) and explicit Hosted-vs-SaaS URL configuration. The router skill's `description` follows a three-clause template: capability statement, a "Use when the user asks to …" trigger clause, and a "Not for: …" negative-trigger clause disambiguating from `formio-application` (orchestrator) and `formio-resource-planner` (planner). Each reference document includes a `## MCP Tool Preference` section instructing Claude to prefer the MCP server's first-party tools (`form_*`, `role_*`, `project_*`, `authenticate`) when they cover the requested operation. Authentication: the MCP server uses a browser-based portal-login flow — a short-lived local Express server renders the Form.io portal login form and captures the returned JWT via a `/callback` endpoint; `formioFetch` then attaches `x-jwt-token` on every request. Skills do NOT use PKCE or API-key auth. -Skills are validated by [packages/mcp-server/src/skills-validator.ts](packages/mcp-server/src/skills-validator.ts); `pnpm test` fails if the router's frontmatter drifts, the router description is missing its trigger or negative-trigger clause, any required reference file is missing or empty, any reference doc drifts from the required heading layout, the canonical portal-login JWT auth paragraph is missing (except in `server-status.md`), or scope-consistency rules are violated. Terminology is strict: `baseUrl`/`base_url` refers only to `FORMIO_BASE_URL`; `projectUrl`/`project_url` refers only to `FORMIO_PROJECT_URL`. +Skill authoring conventions (not enforced by automated tests): the router's frontmatter and three-clause description, required reference files present and non-empty, the required reference-doc heading layout, the canonical portal-login JWT auth paragraph (except in `server-status.md`), and scope consistency. Terminology is strict: `baseUrl`/`base_url` refers only to `FORMIO_BASE_URL`; `projectUrl`/`project_url` refers only to `FORMIO_PROJECT_URL`. ## Iterating on skills diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/design.md b/openspec/changes/archive/2026-04-16-add-user-auth/design.md index 19a1737..f16bff8 100644 --- a/openspec/changes/archive/2026-04-16-add-user-auth/design.md +++ b/openspec/changes/archive/2026-04-16-add-user-auth/design.md @@ -56,7 +56,7 @@ Structure: `{ "": "" }` **Why `~/.formio/`**: Follows the convention of CLI tools storing config in a dotfile directory in the user's home. Keeps it separate from project-level config. -### 4. Token validation via `GET {projectUrl}/current` +### 4. Token validation via `GET {baseUrl}/current` On startup (after reading config and cache), send a request to the `/current` endpoint. If it returns 200, the token is valid. If 401, clear the cached token and trigger the login flow (or fail in API key mode). diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md b/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md index 57b02f1..26bdec7 100644 --- a/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md +++ b/openspec/changes/archive/2026-04-16-add-user-auth/proposal.md @@ -6,7 +6,7 @@ The MCP server currently authenticates all API requests using a shared admin API - Add an authentication module that spins up an ephemeral Express server, renders the project's login form via the Form.io SDK, captures the user's JWT on successful login, and shuts down - Add token caching to disk (`~/.formio/mcp-tokens.json`) keyed by project URL so users don't re-authenticate on every MCP server restart -- Add a startup token validation step that hits `GET {projectUrl}/current` to check if a cached or provided token is still valid +- Add a startup token validation step that hits `GET {baseUrl}/current` to check if a cached or provided token is still valid - **BREAKING**: `FORMIO_API_KEY` becomes optional instead of required — if not provided, the server triggers the browser login flow - Add `FORMIO_LOGIN_FORM` optional env var to override the default login form URL (`{projectUrl}/user/login`) - Modify `formioFetch` to send `x-jwt-token` header when using a user JWT, falling back to `x-token` when using an API key diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md b/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md index d81f3cc..c7a4bfe 100644 --- a/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md +++ b/openspec/changes/archive/2026-04-16-add-user-auth/specs/token-validation/spec.md @@ -2,31 +2,31 @@ ### Requirement: Token is validated on startup via GET /current -On startup, the MCP server SHALL validate any available token (cached JWT or API key) by sending a request to `GET {projectUrl}/current` with the appropriate auth header. A 200 response means the token is valid. +On startup, the MCP server SHALL validate any available token (cached JWT or API key) by sending a request to `GET {baseUrl}/current` with the appropriate auth header. A 200 response means the token is valid. #### Scenario: Valid JWT token on startup - **WHEN** the server starts with a cached JWT that is still valid -- **THEN** `GET {projectUrl}/current` returns 200 +- **THEN** `GET {baseUrl}/current` returns 200 - **AND** the server proceeds without triggering the login flow #### Scenario: Valid API key on startup - **WHEN** the server starts with `FORMIO_API_KEY` set to a valid key -- **THEN** `GET {projectUrl}/current` with `x-token` header returns 200 +- **THEN** `GET {baseUrl}/current` with `x-token` header returns 200 - **AND** the server proceeds normally #### Scenario: Expired JWT triggers login flow - **WHEN** the server starts with a cached JWT that has expired -- **THEN** `GET {projectUrl}/current` returns 401 +- **THEN** `GET {baseUrl}/current` returns 401 - **AND** the cached token is cleared - **AND** the login flow is triggered #### Scenario: Invalid API key fails with error - **WHEN** the server starts with `FORMIO_API_KEY` set to an invalid key -- **THEN** `GET {projectUrl}/current` returns 401 +- **THEN** `GET {baseUrl}/current` returns 401 - **AND** the server throws an error indicating the API key is invalid #### Scenario: No token and no API key triggers login flow diff --git a/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md b/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md index 48c7116..b8516d4 100644 --- a/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md +++ b/openspec/changes/archive/2026-04-16-add-user-auth/tasks.md @@ -69,7 +69,7 @@ ### Green -- [x] 4.5 Create `src/token-validation.ts` with `validateToken(config)` that sends `GET {projectUrl}/current` with the appropriate auth header and returns a boolean +- [x] 4.5 Create `src/token-validation.ts` with `validateToken(config)` that sends `GET {baseUrl}/current` with the appropriate auth header and returns a boolean ### Refactor diff --git a/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md b/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md index c4bf396..639537a 100644 --- a/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md +++ b/openspec/changes/archive/2026-04-21-add-mcp-config-step-to-application-skill/specs/formio-application-skill/spec.md @@ -10,7 +10,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s - Returns a JSON-serialized text content block whose payload is `{ authenticated: boolean, cached: boolean, projectUrl: string, userEmail?: string }`. The JWT MUST NOT appear in the return payload. - Reads the project URL from the server's `FormioConfig`. The agent does NOT pass a URL. - `cached: true` when the JWT was already present before the call; `cached: false` when the call triggered a fresh login. -- `userEmail` is best-effort — populated from a `GET {projectUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted. +- `userEmail` is best-effort — populated from a `GET {baseUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted. - Is the tool Step 4 of `formio-application` calls explicitly to trigger authentication. It is also available to any other skill that wants to pre-authenticate before a sensitive sequence. #### Scenario: Tool is registered and callable @@ -33,7 +33,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s #### Scenario: Current-user email included when available -- **WHEN** a successful `authenticate` call finishes and `GET {projectUrl}/current` returns a submission with an email field +- **WHEN** a successful `authenticate` call finishes and `GET {baseUrl}/current` returns a submission with an email field - **THEN** the payload contains `userEmail: ` #### Scenario: Current-user fetch failure is swallowed diff --git a/openspec/specs/formio-application-skill/spec.md b/openspec/specs/formio-application-skill/spec.md index faafda5..66b1115 100644 --- a/openspec/specs/formio-application-skill/spec.md +++ b/openspec/specs/formio-application-skill/spec.md @@ -283,7 +283,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s - Returns a JSON-serialized text content block whose payload is `{ authenticated: boolean, cached: boolean, projectUrl: string, userEmail?: string }`. The JWT MUST NOT appear in the return payload. - Reads the project URL from the server's `FormioConfig`. The agent does NOT pass a URL. - `cached: true` when the JWT was already present before the call; `cached: false` when the call triggered a fresh login. -- `userEmail` is best-effort — populated from a `GET {projectUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted. +- `userEmail` is best-effort — populated from a `GET {baseUrl}/current` call when the returned submission has an email field. Any error fetching the current user is swallowed; the field is simply omitted. - Is the tool Step 4 of `formio-application` calls explicitly to trigger authentication. It is also available to any other skill that wants to pre-authenticate before a sensitive sequence. #### Scenario: Tool is registered and callable @@ -306,7 +306,7 @@ The MCP server SHALL register a new tool named `authenticate` at `packages/mcp-s #### Scenario: Current-user email included when available -- **WHEN** a successful `authenticate` call finishes and `GET {projectUrl}/current` returns a submission with an email field +- **WHEN** a successful `authenticate` call finishes and `GET {baseUrl}/current` returns a submission with an email field - **THEN** the payload contains `userEmail: ` #### Scenario: Current-user fetch failure is swallowed diff --git a/openspec/specs/token-validation/spec.md b/openspec/specs/token-validation/spec.md index d81f3cc..66ba2d2 100644 --- a/openspec/specs/token-validation/spec.md +++ b/openspec/specs/token-validation/spec.md @@ -13,20 +13,20 @@ On startup, the MCP server SHALL validate any available token (cached JWT or API #### Scenario: Valid API key on startup - **WHEN** the server starts with `FORMIO_API_KEY` set to a valid key -- **THEN** `GET {projectUrl}/current` with `x-token` header returns 200 +- **THEN** `GET {baseUrl}/current` with `x-token` header returns 200 - **AND** the server proceeds normally #### Scenario: Expired JWT triggers login flow - **WHEN** the server starts with a cached JWT that has expired -- **THEN** `GET {projectUrl}/current` returns 401 +- **THEN** `GET {baseUrl}/current` returns 401 - **AND** the cached token is cleared - **AND** the login flow is triggered #### Scenario: Invalid API key fails with error - **WHEN** the server starts with `FORMIO_API_KEY` set to an invalid key -- **THEN** `GET {projectUrl}/current` returns 401 +- **THEN** `GET {baseUrl}/current` returns 401 - **AND** the server throws an error indicating the API key is invalid #### Scenario: No token and no API key triggers login flow diff --git a/packages/mcp-server/src/__tests__/formio-angular-layout.test.ts b/packages/mcp-server/src/__tests__/formio-angular-layout.test.ts deleted file mode 100644 index eb4d6ac..0000000 --- a/packages/mcp-server/src/__tests__/formio-angular-layout.test.ts +++ /dev/null @@ -1,273 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import matter from 'gray-matter'; -import { describe, expect, it } from 'vitest'; - -const REPO_ROOT = path.resolve(__dirname, '../../../..'); -const SKILLS_DIR = path.join(REPO_ROOT, 'plugin/skills'); - -const PARENT_SKILL = path.join(SKILLS_DIR, 'formio-angular/SKILL.md'); -const PARENT_SETUP = path.join(SKILLS_DIR, 'formio-angular/SETUP.md'); -const PARENT_CONFIG = path.join(SKILLS_DIR, 'formio-angular/CONFIG.md'); -const PARENT_AUTH = path.join(SKILLS_DIR, 'formio-angular/AUTH.md'); -const SUB_SKILL = path.join(SKILLS_DIR, 'formio-angular/resources/SKILL.md'); -const SUB_EVALS = path.join(SKILLS_DIR, 'formio-angular/resources/evals'); - -const OLD_SKILL_DIR = path.join(SKILLS_DIR, 'formio-resource-angular'); - -// Trigger-surface assertions for formio-angular + formio-angular-resources live -// in formio-application-layout.test.ts now — after the `add-formio-application-orchestrator` -// change demoted these two skills to framework-explicit triggers only. -// This file retains only the infrastructure assertions (file layout, symlinks, -// sibling-doc content) that don't depend on trigger-surface wording. - -function exists(p: string): boolean { - try { - fs.lstatSync(p); - return true; - } catch { - return false; - } -} - -function readFrontmatter(file: string): Record { - const raw = fs.readFileSync(file, 'utf8'); - return matter(raw).data as Record; -} - -function readBody(file: string): string { - const raw = fs.readFileSync(file, 'utf8'); - return matter(raw).content; -} - -describe('formio-angular skill layout', () => { - it('parent SKILL.md and sibling reference docs exist, old resource-angular dir removed', () => { - expect(exists(PARENT_SKILL)).toBe(true); - expect(exists(PARENT_SETUP)).toBe(true); - expect(exists(PARENT_CONFIG)).toBe(true); - expect(exists(PARENT_AUTH)).toBe(true); - expect(exists(SUB_SKILL)).toBe(true); - expect(exists(OLD_SKILL_DIR)).toBe(false); - }); -}); - -describe('formio-angular sub-skill frontmatter', () => { - it('sub-skill name is formio-angular-resources', () => { - const fm = readFrontmatter(SUB_SKILL); - expect(fm.name).toBe('formio-angular-resources'); - }); - - it('sub-skill description contains Not-for clause naming parent formio-angular', () => { - const fm = readFrontmatter(SUB_SKILL); - const description = String(fm.description ?? ''); - expect(description).toMatch(/not for:/i); - expect(description).toContain('formio-angular'); - }); -}); - -describe('formio-angular parent frontmatter', () => { - it('parent name and description are set', () => { - const fm = readFrontmatter(PARENT_SKILL); - expect(fm.name).toBe('formio-angular'); - expect(String(fm.description ?? '').length).toBeGreaterThan(0); - }); - - it('parent description negatives point at formio-angular-resources', () => { - const fm = readFrontmatter(PARENT_SKILL); - const description = String(fm.description ?? ''); - expect(description).toContain('formio-angular-resources'); - expect(description).toMatch(/not for:/i); - }); - - it('parent body names the four phases in SETUP, CONFIG, AUTH, Resources order', () => { - const body = readBody(PARENT_SKILL); - const setupIdx = body.search(/\bSETUP\b/); - const configIdx = body.search(/\bCONFIG\b/); - const authIdx = body.search(/\bAUTH\b/); - const resourcesIdx = body.search(/formio-angular-resources/); - expect(setupIdx).toBeGreaterThan(-1); - expect(configIdx).toBeGreaterThan(-1); - expect(authIdx).toBeGreaterThan(-1); - expect(resourcesIdx).toBeGreaterThan(-1); - expect(setupIdx).toBeLessThan(configIdx); - expect(configIdx).toBeLessThan(authIdx); - expect(authIdx).toBeLessThan(resourcesIdx); - }); -}); - -describe('formio-angular SETUP.md', () => { - it('has no YAML frontmatter', () => { - const raw = fs.readFileSync(PARENT_SETUP, 'utf8'); - expect(raw.startsWith('---')).toBe(false); - }); - - it('captures Project URL and Base URL', () => { - const raw = fs.readFileSync(PARENT_SETUP, 'utf8'); - expect(raw).toMatch(/Project URL/); - expect(raw).toMatch(/Base URL/); - }); - - it('names FORMIO_PROJECT_URL and FORMIO_BASE_URL explicitly', () => { - const raw = fs.readFileSync(PARENT_SETUP, 'utf8'); - expect(raw).toContain('FORMIO_PROJECT_URL'); - expect(raw).toContain('FORMIO_BASE_URL'); - }); - - it('batches the URL questions into a single AskUserQuestion', () => { - const raw = fs.readFileSync(PARENT_SETUP, 'utf8'); - expect(raw).toMatch(/AskUserQuestion/); - expect(raw).toMatch(/(single|one|batch)/i); - }); -}); - -describe('formio-angular CONFIG.md', () => { - it('has no frontmatter', () => { - const raw = fs.readFileSync(PARENT_CONFIG, 'utf8'); - expect(raw.startsWith('---')).toBe(false); - }); - - it('references canonical external URLs', () => { - const raw = fs.readFileSync(PARENT_CONFIG, 'utf8'); - expect(raw).toContain('https://help.form.io/developers/introduction/application'); - expect(raw).toContain('https://github.com/formio/angular-demo/blob/master/src/app/config.ts'); - expect(raw).toContain( - 'https://github.com/formio/angular-demo/blob/master/src/app/app.module.ts' - ); - }); - - it('contains a config.ts code template with FormioAppConfig', () => { - const raw = fs.readFileSync(PARENT_CONFIG, 'utf8'); - expect(raw).toContain('AppConfig'); - expect(raw).toContain('FormioAppConfig'); - expect(raw).toContain('appUrl'); - expect(raw).toContain('apiUrl'); - }); - - it('guides AppModule wiring with provider token and FormioModule import', () => { - const raw = fs.readFileSync(PARENT_CONFIG, 'utf8'); - expect(raw).toContain('provide: FormioAppConfig'); - expect(raw).toContain('useValue: AppConfig'); - expect(raw).toContain('FormioModule'); - expect(raw).toContain('@formio/angular'); - }); - - it('describes preview-then-approve gate', () => { - const raw = fs.readFileSync(PARENT_CONFIG, 'utf8'); - expect(raw).toMatch(/preview/i); - expect(raw).toMatch(/approv/i); - }); - - it('describes skip-if-already-wired detection', () => { - const raw = fs.readFileSync(PARENT_CONFIG, 'utf8'); - expect(raw).toMatch(/skip/i); - expect(raw).toMatch(/(already|existing)/i); - }); -}); - -describe('formio-angular AUTH.md', () => { - it('has no frontmatter', () => { - const raw = fs.readFileSync(PARENT_AUTH, 'utf8'); - expect(raw.startsWith('---')).toBe(false); - }); - - it('references canonical external URLs', () => { - const raw = fs.readFileSync(PARENT_AUTH, 'utf8'); - expect(raw).toContain( - 'https://help.form.io/developers/introduction/application#user-authentication' - ); - expect(raw).toContain( - 'https://github.com/formio/angular-demo/blob/master/src/app/auth/auth.module.ts' - ); - expect(raw).toContain( - 'https://github.com/formio/angular-demo/blob/master/src/app/app.module.ts#L71' - ); - }); - - it('documents template.json extraction rules', () => { - const raw = fs.readFileSync(PARENT_AUTH, 'utf8'); - expect(raw).toMatch(/template\.json/); - expect(raw).toMatch(/user resource/i); - expect(raw).toMatch(/login form/i); - expect(raw).toMatch(/register form/i); - expect(raw).toMatch(/role/i); - }); - - it('contains an auth.module.ts template using FormioAuthConfig', () => { - const raw = fs.readFileSync(PARENT_AUTH, 'utf8'); - expect(raw).toContain('FormioAuthConfig'); - expect(raw).toContain('@formio/angular/auth'); - }); - - it('documents no-template fallback TODO pointing at api skill references', () => { - const raw = fs.readFileSync(PARENT_AUTH, 'utf8'); - expect(raw).toMatch(/TODO/); - expect(raw).toContain('runtime-auth'); - expect(raw).toContain('platform-auth'); - }); - - it('describes preview gate and skip-if-already-wired', () => { - const raw = fs.readFileSync(PARENT_AUTH, 'utf8'); - expect(raw).toMatch(/preview/i); - expect(raw).toMatch(/approv/i); - expect(raw).toMatch(/skip/i); - expect(raw).toMatch(/AuthModule/); - }); -}); - -describe('formio-angular eval-artifact paths', () => { - it('evals dir contains no "formio-resource-angular" references', () => { - const files = ['grade.py', 'README.md', 'evals.json']; - for (const f of files) { - const raw = fs.readFileSync(path.join(SUB_EVALS, f), 'utf8'); - expect(raw, `${f} must not reference old skill name`).not.toMatch(/formio-resource-angular/); - } - }); - - it('grade.py references the renamed .eval-artifacts path', () => { - const raw = fs.readFileSync(path.join(SUB_EVALS, 'grade.py'), 'utf8'); - expect(raw).toContain('formio-angular-resources'); - }); -}); - -describe('repo-wide skill-name references', () => { - it('CLAUDE.md names formio-angular and has no formio-resource-angular references', () => { - const raw = fs.readFileSync(path.join(REPO_ROOT, 'CLAUDE.md'), 'utf8'); - expect(raw).toContain('formio-angular'); - expect(raw).not.toMatch(/formio-resource-angular/); - expect(raw).toMatch(/skills\/formio-angular\/resources/); - }); - - it('formio-resource-planner skill has no formio-resource-angular references', () => { - const plannerDir = path.join(SKILLS_DIR, 'formio-resource-planner'); - const stack = [plannerDir]; - const offenders: string[] = []; - while (stack.length > 0) { - const current = stack.pop()!; - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const full = path.join(current, entry.name); - if (entry.isDirectory()) { - stack.push(full); - continue; - } - if (!entry.isFile()) continue; - if (!/\.(md|json|py|ts|txt)$/.test(entry.name)) continue; - const raw = fs.readFileSync(full, 'utf8'); - if (raw.includes('formio-resource-angular')) offenders.push(full); - } - } - expect(offenders, `files still reference old skill name: ${offenders.join(', ')}`).toEqual([]); - }); -}); - -describe('formio-angular SKILL.md files parse as valid markdown+frontmatter', () => { - it('both parent and sub-skill parse cleanly', () => { - for (const file of [PARENT_SKILL, SUB_SKILL]) { - const raw = fs.readFileSync(file, 'utf8'); - const parsed = matter(raw); - expect(parsed.data, `${file} must have frontmatter`).toBeDefined(); - expect(typeof parsed.data.name).toBe('string'); - expect(typeof parsed.data.description).toBe('string'); - expect(parsed.content.length).toBeGreaterThan(0); - } - }); -}); diff --git a/packages/mcp-server/src/__tests__/formio-application-layout.test.ts b/packages/mcp-server/src/__tests__/formio-application-layout.test.ts deleted file mode 100644 index d615d75..0000000 --- a/packages/mcp-server/src/__tests__/formio-application-layout.test.ts +++ /dev/null @@ -1,475 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import matter from 'gray-matter'; -import { describe, expect, it } from 'vitest'; - -const REPO_ROOT = path.resolve(__dirname, '../../../..'); -const SKILLS_DIR = path.join(REPO_ROOT, 'plugin/skills'); - -const APP_SKILL = path.join(SKILLS_DIR, 'formio-application/SKILL.md'); -const APP_INTENT = path.join(SKILLS_DIR, 'formio-application/INTENT.md'); -const APP_DEPLOYMENT = path.join(SKILLS_DIR, 'formio-application/DEPLOYMENT.md'); -const APP_MCP_CONFIG = path.join(SKILLS_DIR, 'formio-application/MCP_CONFIG.md'); -const APP_IMPORT = path.join(SKILLS_DIR, 'formio-application/IMPORT.md'); -const APP_FRAMEWORK = path.join(SKILLS_DIR, 'formio-application/FRAMEWORK.md'); - -const ANGULAR_SKILL = path.join(SKILLS_DIR, 'formio-angular/SKILL.md'); -const ANGULAR_SUB_SKILL = path.join(SKILLS_DIR, 'formio-angular/resources/SKILL.md'); -const PLANNER_SKILL = path.join(SKILLS_DIR, 'formio-resource-planner/SKILL.md'); - -// Plain-language build-new triggers formio-application MUST claim. -const APP_BUILD_NEW_TRIGGERS = ['build me an app', 'create a crm', 'i need a tool to track']; -// Plain-language extend triggers formio-application MUST claim. -const APP_EXTEND_TRIGGERS = ['also track', 'add a way to see']; - -// Generic phrases formio-angular MUST NOT contain (they now belong to formio-application). -const ANGULAR_FORBIDDEN_GENERIC = [ - 'build me an app', - 'build me a tool', - 'spin up an app', - 'i need a tool to track', - 'task manager', - 'help desk', - 'crm', - 'booking system', -]; - -// Angular-explicit triggers formio-angular MUST claim. -const ANGULAR_REQUIRED_EXPLICIT = ['build it in angular', 'angular front-end', 'use angular']; - -// Generic extend phrases formio-angular-resources MUST NOT contain. -const ANGULAR_SUB_FORBIDDEN_GENERIC = [ - 'also track', - 'also let', - 'add a way to see', - 'each x should have a list of y', -]; - -// Angular-explicit extend triggers formio-angular-resources MUST claim. -const ANGULAR_SUB_REQUIRED_EXPLICIT = [ - 'add an angular module', - 'regenerate the angular', - 'in my angular app', -]; - -function exists(p: string): boolean { - try { - fs.lstatSync(p); - return true; - } catch { - return false; - } -} - -function readFrontmatter(file: string): Record { - const raw = fs.readFileSync(file, 'utf8'); - return matter(raw).data as Record; -} - -function readBody(file: string): string { - const raw = fs.readFileSync(file, 'utf8'); - return matter(raw).content; -} - -function sliceStepSection(body: string, stepNumber: number): string { - const startRe = new RegExp(`^#+\\s+Step ${stepNumber}\\b`, 'm'); - const startMatch = startRe.exec(body); - if (!startMatch) return ''; - const start = startMatch.index; - const afterStart = body.slice(start + startMatch[0].length); - const endRe = new RegExp(`^#+\\s+Step ${stepNumber + 1}\\b`, 'm'); - const endMatch = endRe.exec(afterStart); - const end = endMatch ? start + startMatch[0].length + endMatch.index : body.length; - return body.slice(start, end); -} - -describe('formio-application skill layout', () => { - it('parent SKILL.md and sibling docs exist', () => { - expect(exists(APP_SKILL)).toBe(true); - expect(exists(APP_INTENT)).toBe(true); - expect(exists(APP_DEPLOYMENT)).toBe(true); - expect(exists(APP_IMPORT)).toBe(true); - expect(exists(APP_FRAMEWORK)).toBe(true); - }); - - it('sibling docs have no YAML frontmatter', () => { - for (const f of [APP_INTENT, APP_DEPLOYMENT, APP_IMPORT, APP_FRAMEWORK]) { - const raw = fs.readFileSync(f, 'utf8'); - expect(raw.startsWith('---'), `${f} must not have frontmatter`).toBe(false); - } - }); -}); - -describe('formio-application frontmatter', () => { - it('name is formio-application', () => { - const fm = readFrontmatter(APP_SKILL); - expect(fm.name).toBe('formio-application'); - }); - - it('description claims plain-language build-new triggers', () => { - const fm = readFrontmatter(APP_SKILL); - const description = String(fm.description ?? '').toLowerCase(); - for (const phrase of APP_BUILD_NEW_TRIGGERS) { - expect(description, `must contain "${phrase}"`).toContain(phrase); - } - }); - - it('description claims plain-language extend triggers', () => { - const fm = readFrontmatter(APP_SKILL); - const description = String(fm.description ?? '').toLowerCase(); - for (const phrase of APP_EXTEND_TRIGGERS) { - expect(description, `must contain "${phrase}"`).toContain(phrase); - } - }); - - it('description contains Not-for clauses naming sibling skills', () => { - const fm = readFrontmatter(APP_SKILL); - const description = String(fm.description ?? ''); - expect(description).toMatch(/not for:/i); - expect(description).toContain('formio-angular'); - expect(description).toContain('formio-angular-resources'); - expect(description).toContain('formio-resource-planner'); - expect(description).toContain('formio-api'); - }); -}); - -describe('formio-application body', () => { - it('names the six steps in order via section headers', () => { - const body = readBody(APP_SKILL); - const intentIdx = body.search(/^#+\s+Step 1[^\n]*Intent/im); - const planIdx = body.search(/^#+\s+Step 2[^\n]*Plan/im); - const deploymentIdx = body.search(/^#+\s+Step 3[^\n]*Deployment/im); - const mcpConfigIdx = body.search(/^#+\s+Step 4[^\n]*MCP Config/im); - const importIdx = body.search(/^#+\s+Step 5[^\n]*Import/im); - const frameworkIdx = body.search(/^#+\s+Step 6[^\n]*Framework/im); - for (const [name, idx] of [ - ['Step 1 — Intent', intentIdx], - ['Step 2 — Plan', planIdx], - ['Step 3 — Deployment', deploymentIdx], - ['Step 4 — MCP Config', mcpConfigIdx], - ['Step 5 — Import', importIdx], - ['Step 6 — Framework', frameworkIdx], - ] as const) { - expect(idx, `body must have "${name}" section header`).toBeGreaterThan(-1); - } - expect(intentIdx).toBeLessThan(planIdx); - expect(planIdx).toBeLessThan(deploymentIdx); - expect(deploymentIdx).toBeLessThan(mcpConfigIdx); - expect(mcpConfigIdx).toBeLessThan(importIdx); - expect(importIdx).toBeLessThan(frameworkIdx); - }); - - it('references the five sibling docs by relative link', () => { - const body = readBody(APP_SKILL); - expect(body).toContain('INTENT.md'); - expect(body).toContain('DEPLOYMENT.md'); - expect(body).toContain('MCP_CONFIG.md'); - expect(body).toContain('IMPORT.md'); - expect(body).toContain('FRAMEWORK.md'); - }); - - it('description mentions .mcp.json and the restart pause', () => { - const fm = readFrontmatter(APP_SKILL); - const description = String(fm.description ?? '').toLowerCase(); - expect(description).toContain('.mcp.json'); - expect(description).toMatch(/(restart|reconnect)/); - }); - - it('Step 2 (Plan) invokes formio-resource-planner in both modes', () => { - const body = readBody(APP_SKILL); - const step2 = sliceStepSection(body, 2).toLowerCase(); - expect(step2.length, 'Step 2 section must exist').toBeGreaterThan(0); - expect(step2).toContain('formio-resource-planner'); - expect(step2).toMatch(/delta/); - expect(step2).toMatch(/(additive|merge)/); - }); - - it('Step 5 (Import) runs on both branches with implicit auth', () => { - const body = readBody(APP_SKILL); - const step5 = sliceStepSection(body, 5).toLowerCase(); - expect(step5.length, 'Step 5 section must exist').toBeGreaterThan(0); - expect(step5).toContain('project_import'); - expect(step5).toMatch(/additive/); - expect(step5).toMatch(/(implicit|first authenticated|portal.login)/); - }); - - it('Step 4 section documents halt + restart + modify-existing skip', () => { - const body = readBody(APP_SKILL); - const step4 = sliceStepSection(body, 4).toLowerCase(); - expect(step4.length, 'Step 4 section must exist').toBeGreaterThan(0); - expect(step4).toContain('mcp_config.md'); - expect(step4).toMatch(/(halt|stop|pause)/); - expect(step4).toMatch(/(restart|reconnect)/); - expect(step4).toMatch(/(modify-existing|skipped)/); - }); -}); - -describe('INTENT.md content', () => { - it('names AskUserQuestion and covers both branches', () => { - const raw = fs.readFileSync(APP_INTENT, 'utf8'); - expect(raw).toContain('AskUserQuestion'); - expect(raw.toLowerCase()).toMatch(/build/); - expect(raw.toLowerCase()).toMatch(/(modify|extend)/); - }); - - it('documents modify-existing skips only Deployment + MCP Config (still runs planner + import)', () => { - const raw = fs.readFileSync(APP_INTENT, 'utf8').toLowerCase(); - expect(raw).toMatch(/skip/); - expect(raw).toMatch(/(deployment|step 3)/); - expect(raw).toMatch(/(mcp config|mcp_config|mcp-config|step 4)/); - // Modify-existing MUST still mention planner + import - expect(raw).toContain('formio-resource-planner'); - expect(raw).toContain('project_import'); - expect(raw).toMatch(/delta/); - expect(raw).toMatch(/additive/); - }); -}); - -describe('DEPLOYMENT.md content', () => { - it('batches the URL interview and names env-var terms', () => { - const raw = fs.readFileSync(APP_DEPLOYMENT, 'utf8'); - expect(raw).toContain('AskUserQuestion'); - expect(raw.toLowerCase()).toMatch(/(batched|single|one)/); - expect(raw).toContain('FORMIO_PROJECT_URL'); - expect(raw).toContain('FORMIO_BASE_URL'); - }); - - it('contains plain-language URL descriptions with examples', () => { - const raw = fs.readFileSync(APP_DEPLOYMENT, 'utf8'); - expect(raw).toMatch(/Project URL/); - expect(raw).toMatch(/Base URL/); - expect(raw).toMatch(/https:\/\/[\w.-]*form\.io/); - }); - - it('names the next consumer of captured URLs (Step 4 / MCP_CONFIG.md)', () => { - const raw = fs.readFileSync(APP_DEPLOYMENT, 'utf8'); - expect(raw).toMatch(/(Step 4|MCP Config|MCP_CONFIG\.md)/); - }); -}); - -describe('IMPORT.md content', () => { - it('covers import flow and error branches', () => { - const raw = fs.readFileSync(APP_IMPORT, 'utf8'); - expect(raw).toContain('project_import'); - expect(raw.toLowerCase()).toMatch(/merge/); - expect(raw.toLowerCase()).toMatch(/(auth|401|403)/); - expect(raw.toLowerCase()).toMatch(/(not found|404)/); - expect(raw.toLowerCase()).toMatch(/(validation|400)/); - }); - - it('describes browser login and headless fallback', () => { - const raw = fs.readFileSync(APP_IMPORT, 'utf8'); - expect(raw.toLowerCase()).toMatch(/browser/); - expect(raw.toLowerCase()).toMatch(/(headless|portal.login|print)/); - }); - - it('Step 5 describes implicit portal-login on first authenticated call', () => { - const raw = fs.readFileSync(APP_IMPORT, 'utf8').toLowerCase(); - expect(raw).toMatch(/(implicit|first authenticated|portal.login|browser)/); - }); -}); - -describe('MCP_CONFIG.md content', () => { - it('exists and has no frontmatter', () => { - expect(exists(APP_MCP_CONFIG)).toBe(true); - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8'); - expect(raw.startsWith('---')).toBe(false); - }); - - it('names both env-var keys', () => { - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8'); - expect(raw).toContain('FORMIO_PROJECT_URL'); - expect(raw).toContain('FORMIO_BASE_URL'); - }); - - it('documents the FORMIO_BASE_URL ↔ FORMIO_BASE_URL mapping', () => { - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8'); - expect(raw).toContain('FORMIO_BASE_URL'); - expect(raw).toContain('FORMIO_BASE_URL'); - expect(raw.toLowerCase()).toMatch(/(map|same concept|two names)/); - }); - - it('documents collision handling (preserve command/args, preserve other env, preserve other servers)', () => { - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8').toLowerCase(); - expect(raw).toMatch(/preserve/); - expect(raw).toMatch(/command/); - expect(raw).toMatch(/args/); - expect(raw).toMatch(/(unrelated|other) (mcp|server)/); - }); - - it('documents npm-based default command and placeholder warning', () => { - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8'); - expect(raw).toContain('npx'); - expect(raw.toLowerCase()).toMatch(/@formio\/mcp/); - expect(raw.toLowerCase()).toMatch(/(placeholder|aspirational|not.*published|until.*publish)/); - }); - - it('documents the approval gate (preview, approval, write, then restart)', () => { - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8').toLowerCase(); - expect(raw).toMatch(/preview/); - expect(raw).toMatch(/approv/); - expect(raw).toMatch(/(restart|reconnect)/); - }); - - it('documents the skip rule for matching existing entries', () => { - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8').toLowerCase(); - expect(raw).toMatch(/skip/); - expect(raw).toMatch(/match/); - }); - - it('mentions both "restart Claude Code" and "/mcp" reconnect paths', () => { - const raw = fs.readFileSync(APP_MCP_CONFIG, 'utf8'); - expect(raw.toLowerCase()).toMatch(/restart claude code/); - expect(raw).toContain('/mcp'); - }); -}); - -describe('FRAMEWORK.md content', () => { - it('contains a registry table with Angular row', () => { - const raw = fs.readFileSync(APP_FRAMEWORK, 'utf8'); - expect(raw).toMatch(/\| Framework \|/); - expect(raw).toContain('formio-angular'); - expect(raw).toContain('formio-angular-resources'); - expect(raw.toLowerCase()).toMatch(/angular\.json/); - }); - - it('documents single-row silent routing and multi-row picker', () => { - const raw = fs.readFileSync(APP_FRAMEWORK, 'utf8').toLowerCase(); - expect(raw).toMatch(/(silent|directly|without asking)/); - expect(raw).toMatch(/(askuserquestion|picker|ask the user)/); - }); - - it('documents how to add a new framework row', () => { - const raw = fs.readFileSync(APP_FRAMEWORK, 'utf8').toLowerCase(); - expect(raw).toMatch(/add/); - expect(raw).toMatch(/(row|entry|new framework)/); - }); -}); - -describe('formio-angular demotion', () => { - it('description drops generic build-an-app phrases', () => { - const fm = readFrontmatter(ANGULAR_SKILL); - const description = String(fm.description ?? '').toLowerCase(); - for (const phrase of ANGULAR_FORBIDDEN_GENERIC) { - expect(description, `must not contain "${phrase}"`).not.toContain(phrase); - } - }); - - it('description claims Angular-explicit triggers', () => { - const fm = readFrontmatter(ANGULAR_SKILL); - const description = String(fm.description ?? '').toLowerCase(); - for (const phrase of ANGULAR_REQUIRED_EXPLICIT) { - expect(description, `must contain "${phrase}"`).toContain(phrase); - } - }); - - it('description points at formio-application in Not-for clause', () => { - const fm = readFrontmatter(ANGULAR_SKILL); - const description = String(fm.description ?? ''); - expect(description).toContain('formio-application'); - expect(description).toMatch(/not for:/i); - }); -}); - -describe('formio-angular body post-demotion', () => { - it('body does not describe an Inference phase', () => { - const body = readBody(ANGULAR_SKILL); - expect(body).not.toMatch(/Phase 0 — Inference/); - expect(body).not.toMatch(/## Phase 0 — Inference/); - }); - - it('body documents SETUP handoff from formio-application', () => { - const body = readBody(ANGULAR_SKILL); - expect(body).toContain('formio-application'); - expect(body.toLowerCase()).toMatch(/handoff|handed/); - }); - - it("body states Import is not this skill's responsibility", () => { - const body = readBody(ANGULAR_SKILL).toLowerCase(); - expect(body).toContain('project_import'); - expect(body).toMatch(/(not this skill|lives in formio-application|is formio-application)/); - }); -}); - -describe('formio-angular-resources demotion', () => { - it('description drops generic extend phrases', () => { - const fm = readFrontmatter(ANGULAR_SUB_SKILL); - const description = String(fm.description ?? '').toLowerCase(); - for (const phrase of ANGULAR_SUB_FORBIDDEN_GENERIC) { - expect(description, `must not contain "${phrase}"`).not.toContain(phrase); - } - }); - - it('description claims Angular-explicit extend triggers', () => { - const fm = readFrontmatter(ANGULAR_SUB_SKILL); - const description = String(fm.description ?? '').toLowerCase(); - for (const phrase of ANGULAR_SUB_REQUIRED_EXPLICIT) { - expect(description, `must contain "${phrase}"`).toContain(phrase); - } - }); - - it('description points at formio-application in Not-for clause', () => { - const fm = readFrontmatter(ANGULAR_SUB_SKILL); - const description = String(fm.description ?? ''); - expect(description).toContain('formio-application'); - expect(description).toMatch(/not for:/i); - }); -}); - -describe('formio-resource-planner disk-write documentation', () => { - it('Phase B section documents writing template.json to cwd', () => { - const body = readBody(PLANNER_SKILL).toLowerCase(); - expect(body).toMatch(/template\.json/); - expect(body).toMatch(/(write tool|cwd|working directory)/); - expect(body).toMatch(/template-.*timestamp/); - }); - - it('Phase B guidance mentions both standalone and orchestrator invocation', () => { - const body = readBody(PLANNER_SKILL).toLowerCase(); - expect(body).toMatch(/(standalone|directly)/); - expect(body).toMatch(/formio-application/); - }); -}); - -describe('CLAUDE.md updates', () => { - it('names formio-application as build-an-app entry point', () => { - const raw = fs.readFileSync(path.join(REPO_ROOT, 'CLAUDE.md'), 'utf8'); - expect(raw).toContain('formio-application'); - expect(raw).toMatch(/skills\/formio-application/); - }); -}); - -describe('YAML frontmatter regression guard', () => { - it('all SKILL.md files under formio-application and formio-angular parse cleanly', () => { - const files = [APP_SKILL, ANGULAR_SKILL, ANGULAR_SUB_SKILL]; - for (const file of files) { - const raw = fs.readFileSync(file, 'utf8'); - const parsed = matter(raw); - expect(parsed.data, `${file} must have frontmatter`).toBeDefined(); - expect(typeof parsed.data.name).toBe('string'); - expect(typeof parsed.data.description).toBe('string'); - expect(parsed.content.length).toBeGreaterThan(0); - } - }); -}); - -describe('trigger-surface non-overlap', () => { - it('formio-application claims generic phrases that formio-angular does not', () => { - const appDesc = String(readFrontmatter(APP_SKILL).description ?? '').toLowerCase(); - const angularDesc = String(readFrontmatter(ANGULAR_SKILL).description ?? '').toLowerCase(); - for (const phrase of APP_BUILD_NEW_TRIGGERS) { - expect(appDesc).toContain(phrase); - expect(angularDesc, `formio-angular must not claim "${phrase}"`).not.toContain(phrase); - } - }); - - it('formio-angular claims framework-explicit phrases that formio-application does not', () => { - const appDesc = String(readFrontmatter(APP_SKILL).description ?? '').toLowerCase(); - const angularDesc = String(readFrontmatter(ANGULAR_SKILL).description ?? '').toLowerCase(); - for (const phrase of ANGULAR_REQUIRED_EXPLICIT) { - expect(angularDesc).toContain(phrase); - } - // formio-application should not claim the specific "build it in Angular" phrasing. - expect(appDesc).not.toContain('build it in angular'); - }); -}); diff --git a/packages/mcp-server/src/__tests__/skills-library.test.ts b/packages/mcp-server/src/__tests__/skills-library.test.ts deleted file mode 100644 index 9d0ebd2..0000000 --- a/packages/mcp-server/src/__tests__/skills-library.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - CANONICAL_AUTH_PARAGRAPH, - MCP_PREFERENCE_FALLBACK_SENTENCE, - MCP_PREFERENCE_HEADING, - REFERENCES_DIRNAME, - REQUIRED_REFERENCE_GROUPS, - REQUIRED_REFERENCE_HEADINGS, - ROUTER_DIR, - SKILL_FILENAME, - type ReferenceGroup, - GROUP_SCOPE, - validateLibrary, - validateNoRandomIdSuffixes, - validateReferenceContent, - validateRequiredFiles, - validateRouterLinks, - validateRouterSkillContent, -} from '../skills-validator.js'; - -function makeReferenceBody( - group: ReferenceGroup, - overrides: { omit?: string[]; extra?: string; rootUrlBlock?: string } = {} -) { - const omit = new Set(overrides.omit ?? []); - const scope = GROUP_SCOPE[group]; - const rootUrl = scope === 'platform' ? '${FORMIO_BASE_URL}' : '${FORMIO_PROJECT_URL}'; - const sections: string[] = []; - if (!omit.has('Overview')) sections.push('## Overview\n\nExample overview.'); - if (!omit.has('Root URL')) - sections.push( - overrides.rootUrlBlock ?? `## Root URL\n\nAll endpoints rooted at \`${rootUrl}\`.` - ); - if (!omit.has('Authentication')) { - // server-status skips canonical auth paragraph - if (group === 'server-status') { - sections.push('## Authentication\n\nNo auth required — public health endpoints.'); - } else { - sections.push(`## Authentication\n\n${CANONICAL_AUTH_PARAGRAPH}`); - } - } - if (!omit.has('MCP Tool Preference')) { - sections.push(`${MCP_PREFERENCE_HEADING}\n\n${MCP_PREFERENCE_FALLBACK_SENTENCE}`); - } - if (!omit.has('Endpoints')) { - const path = scope === 'pdf' ? `${rootUrl}/pdf-proxy/file` : `${rootUrl}/form`; - sections.push(`## Endpoints\n\n### GET ${path}\n\nList.`); - } - return sections.join('\n\n') + (overrides.extra ? `\n\n${overrides.extra}` : '') + '\n'; -} - -function makeRouterSource( - overrides: { - linksToAllGroups?: boolean; - extraHeadings?: string; - descriptionOverride?: string; - } = {} -) { - const description = - overrides.descriptionOverride ?? - 'Comprehensive Form.io API reference. Use when the user asks about any Form.io REST endpoint. Not for: building an app (see formio-application).'; - const links = - (overrides.linksToAllGroups ?? true) - ? REQUIRED_REFERENCE_GROUPS.map((g) => `- [${g}](./${REFERENCES_DIRNAME}/${g}.md)`).join('\n') - : ''; - return `--- -name: formio-api -description: "${description.replace(/"/g, '\\"')}" ---- - -# Form.io API - -${links} - -${overrides.extraHeadings ?? ''} -`; -} - -function writeLibrary(root: string) { - fs.mkdirSync(path.join(root, ROUTER_DIR, REFERENCES_DIRNAME), { recursive: true }); - fs.writeFileSync(path.join(root, ROUTER_DIR, SKILL_FILENAME), makeRouterSource()); - for (const group of REQUIRED_REFERENCE_GROUPS) { - fs.writeFileSync( - path.join(root, ROUTER_DIR, REFERENCES_DIRNAME, `${group}.md`), - makeReferenceBody(group) - ); - } -} - -describe('skills-validator — required files', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'skills-lib-')); - }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('passes when router SKILL.md and all 17 reference files exist', () => { - writeLibrary(tmpDir); - expect(validateRequiredFiles(tmpDir)).toEqual([]); - }); - - it('fails when router SKILL.md is missing', () => { - writeLibrary(tmpDir); - fs.rmSync(path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME)); - const issues = validateRequiredFiles(tmpDir); - expect( - issues.some((i) => i.rule === 'library.required_file' && i.message.includes('router')) - ).toBe(true); - }); - - it('fails when a required reference file is missing', () => { - writeLibrary(tmpDir); - fs.rmSync(path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'project-forms.md')); - const issues = validateRequiredFiles(tmpDir); - expect(issues.some((i) => i.message.includes('project-forms.md'))).toBe(true); - }); - - it('fails when a required reference file is empty', () => { - writeLibrary(tmpDir); - fs.writeFileSync(path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'pdf-api.md'), ''); - const issues = validateRequiredFiles(tmpDir); - expect(issues.some((i) => i.message.includes('pdf-api.md') && /empty/.test(i.message))).toBe( - true - ); - }); -}); - -describe('skills-validator — router links', () => { - let tmpDir: string; - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'router-links-')); - }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('passes when router links to every reference group', () => { - writeLibrary(tmpDir); - expect(validateRouterLinks(tmpDir)).toEqual([]); - }); - - it('fails when a required group link is absent from router', () => { - writeLibrary(tmpDir); - fs.writeFileSync( - path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME), - makeRouterSource({ linksToAllGroups: false }) - ); - const issues = validateRouterLinks(tmpDir); - expect(issues.some((i) => i.rule === 'index.missing_link')).toBe(true); - }); - - it('fails when router contains endpoint headings', () => { - writeLibrary(tmpDir); - fs.writeFileSync( - path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME), - makeRouterSource({ extraHeadings: '### GET ${FORMIO_PROJECT_URL}/form\n\nNo.' }) - ); - const issues = validateRouterLinks(tmpDir); - expect(issues.some((i) => i.rule === 'index.no_endpoint_docs')).toBe(true); - }); - - it('fails when router links to a non-existent reference file', () => { - writeLibrary(tmpDir); - const routerPath = path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME); - const orig = fs.readFileSync(routerPath, 'utf8'); - fs.writeFileSync( - routerPath, - `${orig}\n- [missing](./${REFERENCES_DIRNAME}/missing-group.md)\n` - ); - const issues = validateRouterLinks(tmpDir); - expect(issues.some((i) => i.rule === 'index.broken_link')).toBe(true); - }); -}); - -describe('skills-validator — router skill content', () => { - it('passes with valid router frontmatter + trigger clauses', () => { - const source = makeRouterSource(); - expect(validateRouterSkillContent('formio-api/SKILL.md', source)).toEqual([]); - }); - - it('fails when router description lacks "use when" trigger', () => { - const source = makeRouterSource({ - descriptionOverride: 'Reference. Not for: building an app (see formio-application).', - }); - const issues = validateRouterSkillContent('formio-api/SKILL.md', source); - expect(issues.some((i) => i.rule === 'description.trigger_phrase')).toBe(true); - }); - - it('fails when router description lacks "not for:" clause', () => { - const source = makeRouterSource({ - descriptionOverride: 'Reference. Use when the user asks about any Form.io endpoint.', - }); - const issues = validateRouterSkillContent('formio-api/SKILL.md', source); - expect(issues.some((i) => i.rule === 'description.negative_trigger')).toBe(true); - }); - - it('fails when router frontmatter.name is wrong', () => { - const source = `--- -name: formio-wrong -description: "Use when testing. Not for: production." ---- -body`; - const issues = validateRouterSkillContent('formio-api/SKILL.md', source); - expect(issues.some((i) => i.rule === 'frontmatter.name')).toBe(true); - }); - - it('fails when router has extra frontmatter keys', () => { - const source = `--- -name: formio-api -description: "Use when asking about Form.io. Not for: plan." -scope: project ---- -body`; - const issues = validateRouterSkillContent('formio-api/SKILL.md', source); - expect(issues.some((i) => i.rule === 'frontmatter.keys')).toBe(true); - }); -}); - -describe('skills-validator — reference content', () => { - it('passes with a well-formed reference doc (no frontmatter)', () => { - const source = makeReferenceBody('project-forms'); - expect(validateReferenceContent('ref.md', source, 'project-forms')).toEqual([]); - }); - - it('fails when reference doc has frontmatter', () => { - const source = `---\nname: something\n---\n\n${makeReferenceBody('project-forms')}`; - const issues = validateReferenceContent('ref.md', source, 'project-forms'); - expect(issues.some((i) => i.rule === 'reference.no_frontmatter')).toBe(true); - }); - - it('fails when a required heading is missing', () => { - const source = makeReferenceBody('project-forms', { omit: ['Overview'] }); - const issues = validateReferenceContent('ref.md', source, 'project-forms'); - expect(issues.some((i) => i.rule === 'headings.missing')).toBe(true); - }); - - it('fails when required headings are out of order', () => { - const body = `## MCP Tool Preference\n\n${MCP_PREFERENCE_FALLBACK_SENTENCE}\n\n## Overview\n\ntext\n\n## Root URL\n\n\`\${FORMIO_PROJECT_URL}\`\n\n## Authentication\n\n${CANONICAL_AUTH_PARAGRAPH}\n\n## Endpoints\n\n### GET \${FORMIO_PROJECT_URL}/x\n\n.`; - const issues = validateReferenceContent('ref.md', body, 'project-forms'); - expect(issues.some((i) => i.rule === 'headings.order')).toBe(true); - }); - - it('requires canonical auth paragraph (except server-status)', () => { - const source = `## Overview\n\nx\n\n## Root URL\n\nx\n\n## Authentication\n\nnope\n\n## MCP Tool Preference\n\n${MCP_PREFERENCE_FALLBACK_SENTENCE}\n\n## Endpoints\n\n### GET x\n\n.`; - const issues = validateReferenceContent('ref.md', source, 'project-forms'); - expect(issues.some((i) => i.rule === 'auth.canonical_paragraph')).toBe(true); - }); - - it('allows server-status without canonical auth paragraph', () => { - const source = makeReferenceBody('server-status'); - const issues = validateReferenceContent('ref.md', source, 'server-status'); - expect(issues.every((i) => i.rule !== 'auth.canonical_paragraph')).toBe(true); - }); - - it('flags forbidden legacy-auth tokens', () => { - const source = makeReferenceBody('project-forms', { extra: 'Include x-token header.' }); - const issues = validateReferenceContent('ref.md', source, 'project-forms'); - expect(issues.some((i) => i.rule === 'forbidden.legacy_auth')).toBe(true); - }); - - it('flags unresolved Postman project placeholder for project-scope refs', () => { - const source = makeReferenceBody('project-forms', { - extra: 'Path: {{baseUrl}}/{{projectName}}/form (needs substitution).', - }); - const issues = validateReferenceContent('ref.md', source, 'project-forms'); - expect(issues.some((i) => i.rule === 'placeholder.project')).toBe(true); - }); - - it('flags bare {{baseUrl}}/ placeholder for platform-scope refs', () => { - const source = makeReferenceBody('platform-auth', { - extra: 'Path: {{baseUrl}}/user (needs substitution).', - }); - const issues = validateReferenceContent('ref.md', source, 'platform-auth'); - expect(issues.some((i) => i.rule === 'placeholder.platform')).toBe(true); - }); - - it('flags pdf endpoints that do not begin with ${FORMIO_PROJECT_URL}/pdf-proxy', () => { - const body = makeReferenceBody('pdf-api', { - omit: ['Endpoints'], - extra: '## Endpoints\n\n### GET ${FORMIO_PROJECT_URL}/file\n\nWrong path.', - }); - // Re-inject endpoints after omit - const source = body.replace('## Endpoints\n\n', '## Endpoints\n\n'); - const issues = validateReferenceContent('ref.md', source, 'pdf-api'); - expect(issues.some((i) => i.rule === 'pdf.proxy_path')).toBe(true); - }); - - it('flags terminology misuse of baseUrl for project endpoint', () => { - const source = makeReferenceBody('project-forms', { - extra: 'The baseUrl is the project endpoint here.', - }); - const issues = validateReferenceContent('ref.md', source, 'project-forms'); - expect(issues.some((i) => i.rule === 'terminology.baseUrl_for_project')).toBe(true); - }); - - it('flags terminology misuse of projectUrl for platform endpoint', () => { - const source = makeReferenceBody('platform-auth', { - extra: 'The projectUrl is the platform deployment URL.', - }); - const issues = validateReferenceContent('ref.md', source, 'platform-auth'); - expect(issues.some((i) => i.rule === 'terminology.projectUrl_for_platform')).toBe(true); - }); -}); - -describe('skills-validator — validateNoRandomIdSuffixes', () => { - it('flags random-id integer suffix in example titles', () => { - const body = '```json\n{"title": "My Form 42"}\n```'; - // code fences are stripped before scanning; regex runs against original body - const issues = validateNoRandomIdSuffixes('ref.md', body); - expect(issues.length).toBeGreaterThan(0); - }); - - it('flags random-id suffix in slug-style fields', () => { - const body = '```json\n{"name": "myform-99"}\n```'; - const issues = validateNoRandomIdSuffixes('ref.md', body); - expect(issues.length).toBeGreaterThan(0); - }); -}); - -describe('skills-validator — validateLibrary integration', () => { - let tmpDir: string; - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'lib-integration-')); - }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('passes on a well-formed library', () => { - writeLibrary(tmpDir); - expect(validateLibrary(tmpDir)).toEqual([]); - }); - - it('collects issues across required-files + router + every reference', () => { - writeLibrary(tmpDir); - // Break: delete a reference + corrupt router + corrupt one reference - fs.rmSync(path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'runtime-reports.md')); - fs.writeFileSync( - path.join(tmpDir, ROUTER_DIR, SKILL_FILENAME), - makeRouterSource({ descriptionOverride: 'missing triggers' }) - ); - fs.writeFileSync( - path.join(tmpDir, ROUTER_DIR, REFERENCES_DIRNAME, 'project-forms.md'), - '## Overview\n\nmissing auth\n' - ); - const issues = validateLibrary(tmpDir); - const rules = new Set(issues.map((i) => i.rule)); - expect(rules.has('library.required_file')).toBe(true); - expect(rules.has('description.trigger_phrase')).toBe(true); - expect(rules.has('headings.missing')).toBe(true); - }); -}); - -describe('skills-validator — REQUIRED_REFERENCE_GROUPS invariants', () => { - it('has 17 groups', () => { - expect(REQUIRED_REFERENCE_GROUPS.length).toBe(17); - }); - - it('every group has a scope mapping', () => { - for (const group of REQUIRED_REFERENCE_GROUPS) { - expect(GROUP_SCOPE[group]).toBeDefined(); - } - }); - - it('REQUIRED_REFERENCE_HEADINGS includes MCP Tool Preference after Authentication', () => { - const authIdx = REQUIRED_REFERENCE_HEADINGS.indexOf('## Authentication'); - const mcpIdx = REQUIRED_REFERENCE_HEADINGS.indexOf(MCP_PREFERENCE_HEADING); - const endpointsIdx = REQUIRED_REFERENCE_HEADINGS.indexOf('## Endpoints'); - expect(authIdx).toBeGreaterThanOrEqual(0); - expect(mcpIdx).toBe(authIdx + 1); - expect(endpointsIdx).toBe(mcpIdx + 1); - }); -}); - -describe('skills-validator — real library on disk', () => { - const REPO_ROOT = path.resolve(__dirname, '../../../..'); - const LIBRARY_DIR = path.join(REPO_ROOT, 'plugin/skills'); - - it('real plugin/skills library validates cleanly', () => { - const issues = validateLibrary(LIBRARY_DIR); - // Print any issues for debugging - if (issues.length > 0) { - console.error('Library validation issues:', JSON.stringify(issues, null, 2)); - } - expect(issues).toEqual([]); - }); -}); diff --git a/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts b/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts deleted file mode 100644 index 649c1b7..0000000 --- a/packages/mcp-server/src/__tests__/skills-validator-formio-sdk.test.ts +++ /dev/null @@ -1,457 +0,0 @@ -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; -import { - SDK_CANONICAL_SDK_IMPORT, - SDK_CANONICAL_UTILS_IMPORT, - SDK_HOSTED_BASE_URL_LITERAL, - SDK_HOSTED_PROJECT_URL_LITERAL, - SDK_REQUIRED_REFERENCES, - SDK_SAAS_BASE_URL_LITERAL, - SDK_SAAS_PROJECT_URL_LITERAL, - SDK_SKILL_DIR, - SKILL_FILENAME, - REFERENCES_DIRNAME, - validateFormioSdkSkill, - validateFormioSdkSkillContent, - validateFormioSdkDescription, - validateFormioSdkCanonicalImports, - validateFormioSdkForbiddenImports, - validateFormioSdkScriptTags, - validateFormioSdkUrlConfigSkill, - validateFormioSdkUrlConfigReference, - validateFormioSdkReferenceLayout, - validateFormioSdkNavigationTable, - validateFormioSdkRenderingReference, - validateLibrary, -} from '../skills-validator.js'; - -const GOOD_DESCRIPTION = - 'Source-derived skill teaching @formio/js and @formio/js/utils. Use when the user asks to call SDK methods or invoke Utils helpers. Not for: REST endpoint shape (see formio-api), building an app (see formio-application), planning resources (see formio-resource-planner), or Angular wrappers (see formio-angular).'; - -function navTable(): string { - const rows = SDK_REQUIRED_REFERENCES.map((r) => `| Something | [${r}](./references/${r}) |`).join( - '\n' - ); - return `| Intent | Reference |\n| --- | --- |\n${rows}\n`; -} - -function makeSkillSource(overrides: { description?: string; navTable?: string } = {}): string { - const description = overrides.description ?? GOOD_DESCRIPTION; - const nav = overrides.navTable ?? navTable(); - return `--- -name: ${SDK_SKILL_DIR} -description: ${JSON.stringify(description)} ---- - -# Form.io SDK - -## Imports - -\`\`\`ts -${SDK_CANONICAL_SDK_IMPORT}; -${SDK_CANONICAL_UTILS_IMPORT}; -\`\`\` - -## URL Configuration - -### Hosted - -\`\`\`ts -${SDK_CANONICAL_SDK_IMPORT}; -Formio.${SDK_HOSTED_BASE_URL_LITERAL}; -Formio.${SDK_HOSTED_PROJECT_URL_LITERAL}; -\`\`\` - -### SaaS - -\`\`\`ts -${SDK_CANONICAL_SDK_IMPORT}; -Formio.${SDK_SAAS_BASE_URL_LITERAL}; -Formio.${SDK_SAAS_PROJECT_URL_LITERAL}; -\`\`\` - -## Navigation - -${nav} -`; -} - -function makeReferenceSource(opts: { utils?: boolean; sourcedFrom?: string } = {}): string { - const sourcedFrom = opts.sourcedFrom ?? 'packages/core/src/sdk/Formio.ts'; - const urlConfig = opts.utils - ? '' - : `## URL Configuration - -\`\`\`ts -${SDK_CANONICAL_SDK_IMPORT}; -Formio.${SDK_HOSTED_BASE_URL_LITERAL}; -Formio.${SDK_HOSTED_PROJECT_URL_LITERAL}; -Formio.${SDK_SAAS_BASE_URL_LITERAL}; -Formio.${SDK_SAAS_PROJECT_URL_LITERAL}; -\`\`\` - -`; - const importLine = opts.utils ? SDK_CANONICAL_UTILS_IMPORT : SDK_CANONICAL_SDK_IMPORT; - return `## Overview - -Sourced from \`${sourcedFrom}\`. - -## Imports - -\`\`\`ts -${importLine}; -\`\`\` - -${urlConfig}## API - -- \`thing()\` — does a thing. - -## Examples - -\`\`\`ts -${importLine}; -\`\`\` -`; -} - -function writeSdkSkill( - root: string, - opts: { - skillContents?: string; - skipSkill?: boolean; - referenceContents?: Partial>; - skipReferences?: string[]; - } = {} -) { - const skillRoot = path.join(root, SDK_SKILL_DIR); - fs.mkdirSync(path.join(skillRoot, REFERENCES_DIRNAME), { recursive: true }); - if (!opts.skipSkill) { - fs.writeFileSync(path.join(skillRoot, SKILL_FILENAME), opts.skillContents ?? makeSkillSource()); - } - const skipped = new Set(opts.skipReferences ?? []); - for (const ref of SDK_REQUIRED_REFERENCES) { - if (skipped.has(ref)) continue; - const override = opts.referenceContents?.[ref]; - const body = - override ?? - makeReferenceSource({ - utils: ref.startsWith('utils-'), - sourcedFrom: ref.startsWith('utils-') - ? 'packages/core/src/utils/utils.ts' - : 'packages/core/src/sdk/Formio.ts', - }); - // rendering.md requires Formio.createForm call + form.on('submit') + submission = - const finalBody = - ref === 'rendering.md' - ? body + - `\n\`\`\`ts -${SDK_CANONICAL_SDK_IMPORT}; -const form = await Formio.createForm(document.getElementById('formio'), 'https://forms.mysite.com/myproject/myform'); -form.on('submit', (submission) => console.log(submission)); -form.submission = { data: { name: 'prefilled' } }; -\`\`\`\n` - : body; - fs.writeFileSync(path.join(skillRoot, REFERENCES_DIRNAME, ref), finalBody); - } -} - -describe('validateFormioSdkSkill — scaffold', () => { - let tmpDir: string; - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'sdk-skill-')); - }); - afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); - }); - - it('returns [] when plugin/skills/formio-sdk/ is absent', () => { - expect(validateFormioSdkSkill(tmpDir)).toEqual([]); - }); - - it('emits formio_sdk.skill_missing when skill dir exists but SKILL.md is absent', () => { - fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true }); - const issues = validateFormioSdkSkill(tmpDir); - expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true); - }); - - it('validateLibrary invokes validateFormioSdkSkill and surfaces its issues', () => { - fs.mkdirSync(path.join(tmpDir, SDK_SKILL_DIR), { recursive: true }); - const issues = validateLibrary(tmpDir); - expect(issues.some((i) => i.rule === 'formio_sdk.skill_missing')).toBe(true); - }); - - it('passes on a well-formed scaffolded skill', () => { - writeSdkSkill(tmpDir); - const issues = validateFormioSdkSkill(tmpDir); - if (issues.length > 0) console.error(JSON.stringify(issues, null, 2)); - expect(issues).toEqual([]); - }); -}); - -describe('validateFormioSdkSkill — frontmatter + description clauses', () => { - it('flags empty frontmatter as formio_sdk.frontmatter_missing', () => { - const issues = validateFormioSdkSkillContent( - 'formio-sdk/SKILL.md', - '# heading only, no frontmatter\n' - ); - expect(issues.some((i) => i.rule === 'formio_sdk.frontmatter_missing')).toBe(true); - }); - - it('flags description missing "Use when the user asks to" as trigger', () => { - const issues = validateFormioSdkDescription('SKILL.md', { - name: SDK_SKILL_DIR, - description: - 'Mentions @formio/js and @formio/js/utils. Not for: formio-api formio-application formio-resource-planner formio-angular', - }); - expect( - issues.some( - (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('trigger') - ) - ).toBe(true); - }); - - it('flags description missing "Not for:" as negative', () => { - const issues = validateFormioSdkDescription('SKILL.md', { - name: SDK_SKILL_DIR, - description: 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things.', - }); - expect( - issues.some( - (i) => i.rule === 'formio_sdk.description_clause' && i.message.includes('negative') - ) - ).toBe(true); - }); - - it('flags description that omits formio-api in the Not for clause', () => { - const issues = validateFormioSdkDescription('SKILL.md', { - name: SDK_SKILL_DIR, - description: - 'Mentions @formio/js and @formio/js/utils. Use when the user asks to do things. Not for: formio-application, formio-resource-planner, formio-angular.', - }); - expect( - issues.some( - (i) => - i.rule === 'formio_sdk.description_clause' && - i.message.includes('negative') && - i.message.includes('formio-api') - ) - ).toBe(true); - }); - - it('returns no description_clause issues for a full three-clause description', () => { - const issues = validateFormioSdkDescription('SKILL.md', { - name: SDK_SKILL_DIR, - description: GOOD_DESCRIPTION, - }); - expect(issues.filter((i) => i.rule === 'formio_sdk.description_clause')).toEqual([]); - }); -}); - -describe('validateFormioSdkSkill — canonical + forbidden imports + script tags', () => { - it('emits canonical_import_missing sdk when SDK import is absent', () => { - const source = `--- -name: ${SDK_SKILL_DIR} -description: ${JSON.stringify(GOOD_DESCRIPTION)} ---- - -\`\`\`ts -${SDK_CANONICAL_UTILS_IMPORT}; -\`\`\` -`; - const issues = validateFormioSdkCanonicalImports('SKILL.md', source); - expect( - issues.some( - (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"sdk"') - ) - ).toBe(true); - }); - - it('emits canonical_import_missing utils when Utils import is absent', () => { - const source = `\`\`\`ts\n${SDK_CANONICAL_SDK_IMPORT};\n\`\`\`\n`; - const issues = validateFormioSdkCanonicalImports('SKILL.md', source); - expect( - issues.some( - (i) => i.rule === 'formio_sdk.canonical_import_missing' && i.message.includes('"utils"') - ) - ).toBe(true); - }); - - it('flags @formio/core import inside fenced block', () => { - const source = `\`\`\`ts\nimport { Formio } from '@formio/core';\n\`\`\`\n`; - const issues = validateFormioSdkForbiddenImports('ref.md', source); - expect( - issues.some( - (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/core') - ) - ).toBe(true); - }); - - it('flags @formio/js/lib deep import', () => { - const source = `\`\`\`ts\nimport x from '@formio/js/lib/Formio';\n\`\`\`\n`; - const issues = validateFormioSdkForbiddenImports('ref.md', source); - expect( - issues.some( - (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('@formio/js/lib/') - ) - ).toBe(true); - }); - - it('flags require() of @formio/js', () => { - const source = `\`\`\`js\nconst { Formio } = require('@formio/js');\n\`\`\`\n`; - const issues = validateFormioSdkForbiddenImports('ref.md', source); - expect( - issues.some( - (i) => i.rule === 'formio_sdk.forbidden_import' && i.message.includes('"@formio/js"') - ) - ).toBe(true); - }); - - it('does not flag @formio/core mentioned in prose outside a code fence', () => { - const source = 'The renderer extends @formio/core SDK methods.\n'; - expect(validateFormioSdkForbiddenImports('ref.md', source)).toEqual([]); - }); - - it('flags \n\`\`\`\n`; - const issues = validateFormioSdkScriptTags('ref.md', source); - expect(issues.some((i) => i.rule === 'formio_sdk.forbidden_script_tag')).toBe(true); - }); - - it('does not flag world

', {}); + const html = safe.toString(); + expect(html.toLowerCase()).not.toContain('world'); + }); + + it('Allow extra tags / attributes', () => { + const safe = Utils.sanitize('
Hi
', { + addTags: ['article'], + addAttr: ['data-tag'], + }); + const html = safe.toString(); + expect(html).toContain(' { + expect(dom).toBeTruthy(); + expect(typeof dom.appendTo).toBe('function'); + expect(typeof dom.prependTo).toBe('function'); + expect(typeof dom.removeChildFrom).toBe('function'); + }); + + it('Manipulate the DOM via @formio/core dom helpers', () => { + const host = document.createElement('div'); + host.id = 'formio'; + document.body.appendChild(host); + + const banner = document.createElement('div'); + banner.className = 'banner'; + banner.textContent = 'Saved!'; + dom.prependTo(banner, host); + expect(host.firstElementChild).toBe(banner); + + dom.removeChildFrom(banner, host); + expect(host.contains(banner)).toBe(false); + + document.body.removeChild(host); + }); +}); diff --git a/packages/skill-tests/src/formio-sdk/utils-misc.test.ts b/packages/skill-tests/src/formio-sdk/utils-misc.test.ts new file mode 100644 index 0000000..24e1ec4 --- /dev/null +++ b/packages/skill-tests/src/formio-sdk/utils-misc.test.ts @@ -0,0 +1,112 @@ +// @ts-nocheck — see utils-evaluator.test.ts for rationale. +// Mirrors every example in +// `plugin/skills/formio-sdk/references/utils-misc.md`. + +import { beforeAll, describe, expect, it } from 'vitest'; +import { Utils } from '@formio/js/utils'; +import { I18n, unwind, override } from '@formio/core'; +import { Formio } from '@formio/js'; + +beforeAll(() => { + Formio.setBaseUrl('https://forms.mysite.com'); + Formio.setProjectUrl('https://forms.mysite.com/myproject'); +}); + +describe('utils-misc.md examples', () => { + it('Utils.fastCloneDeep clones nested submission data', () => { + expect(typeof Utils.fastCloneDeep).toBe('function'); + const submission = { data: { firstName: 'Ada' } }; + const draft = Utils.fastCloneDeep(submission) as typeof submission; + expect(draft).not.toBe(submission); + expect(draft.data.firstName).toBe('Ada'); + draft.data.firstName = 'Edited'; + expect(submission.data.firstName).toBe('Ada'); + }); + + it("Formio.getToken({ decode: true }) decodes the SDK's cached JWT", async () => { + // {"sub":"abc","name":"Ada","exp":9999999999} — unsigned test payload. + // setToken always tries to fetch /current to populate the cached user; + // that fetch fails offline, but the token cache mutation runs first. + const token = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJhYmMiLCJuYW1lIjoiQWRhIiwiZXhwIjo5OTk5OTk5OTk5fQ.sig'; + await Formio.setToken(token).catch(() => undefined); + const claims = Formio.getToken({ decode: true }) as Record; + expect(claims).toBeTruthy(); + expect(claims.sub).toBe('abc'); + expect(claims.name).toBe('Ada'); + await Formio.setToken(null).catch(() => undefined); + }); + + it('Utils.convertFormatToMoment translates Angular date tokens', () => { + const momentFormat = Utils.convertFormatToMoment('MM/dd/yyyy h:mm a'); + expect(momentFormat).toBe('MM/DD/YYYY h:mm A'); + }); + + it('Utils.moment + Utils.currentTimezone format the current time', () => { + expect(typeof Utils.moment).toBe('function'); + expect(typeof Utils.currentTimezone).toBe('function'); + const tz = Utils.currentTimezone(); + expect(typeof tz).toBe('string'); + const out = Utils.moment().tz(tz).format('YYYY-MM-DD HH:mm'); + expect(out).toMatch(/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/); + }); + + it('I18n switches language at runtime (from @formio/core)', () => { + expect(typeof I18n).toBe('function'); + const i18n = new I18n(); + i18n.setLanguages({ + en: { hello: 'Hello' }, + fr: { hello: 'Bonjour' }, + }); + i18n.changeLanguage('fr'); + expect(i18n.t('hello')).toBe('Bonjour'); + }); + + it('unwind explodes a submission with a datagrid (from @formio/core)', () => { + expect(typeof unwind).toBe('function'); + const form = { + components: [ + { type: 'textfield', key: 'customer', input: true }, + { + type: 'datagrid', + key: 'items', + input: true, + components: [ + { type: 'textfield', key: 'sku', input: true }, + { type: 'number', key: 'qty', input: true }, + ], + }, + ], + }; + const submission = { + data: { + customer: 'Acme', + items: [ + { sku: 'A1', qty: 2 }, + { sku: 'B2', qty: 5 }, + ], + }, + }; + const rows = unwind(form, submission); + expect(Array.isArray(rows)).toBe(true); + expect(rows.length).toBeGreaterThan(0); + }); + + it('override replaces a method on a class prototype (from @formio/core)', () => { + expect(typeof override).toBe('function'); + class TextFieldStub { + rawValue = 'raw '; + getValue() { + return this.rawValue; + } + } + override(TextFieldStub, { + getValue(this: TextFieldStub) { + const v = (this as unknown as { _origGetValue: () => string })._origGetValue(); + return typeof v === 'string' ? v.trim() : v; + }, + _origGetValue: TextFieldStub.prototype.getValue, + }); + expect(new TextFieldStub().getValue()).toBe('raw'); + }); +}); diff --git a/packages/skill-tests/src/setup.ts b/packages/skill-tests/src/setup.ts new file mode 100644 index 0000000..924b11d --- /dev/null +++ b/packages/skill-tests/src/setup.ts @@ -0,0 +1,9 @@ +// Vitest setup. Runs before every test file. +// +// `@formio/js` pulls in DOM-dependent modules (dragula, DOMPurify, etc.) at +// import time, so `vitest.config.ts` picks `happy-dom` as the test +// environment. Nothing else needs configuring here — the file exists so we +// have a stable place to hang future cross-cutting setup (e.g. fetch mocks, +// localStorage resets). + +export {}; diff --git a/packages/skill-tests/tsconfig.json b/packages/skill-tests/tsconfig.json new file mode 100644 index 0000000..b8e45e7 --- /dev/null +++ b/packages/skill-tests/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "noEmit": true, + "types": ["node", "vitest/globals"] + }, + "include": ["src", "vitest.config.ts"], + "exclude": ["node_modules"] +} diff --git a/packages/skill-tests/vitest.config.ts b/packages/skill-tests/vitest.config.ts new file mode 100644 index 0000000..40f3149 --- /dev/null +++ b/packages/skill-tests/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + environment: 'jsdom', + globals: true, + include: ['src/**/*.test.ts'], + setupFiles: ['./src/setup.ts'], + testTimeout: 20000, + }, +}); diff --git a/plugin/skills/formio-sdk/SKILL.md b/plugin/skills/formio-sdk/SKILL.md index c5d35fa..1dffffb 100644 --- a/plugin/skills/formio-sdk/SKILL.md +++ b/plugin/skills/formio-sdk/SKILL.md @@ -1,23 +1,32 @@ --- name: formio-sdk description: >- - Source-derived reference for the Form.io JavaScript SDK (`@formio/js`) and the Form.io Utilities (`@formio/js/utils`), grounded in the Form.io source code (`packages/core/src/sdk`, `packages/core/src/utils`, `packages/formio.js/src/Formio.js`, `packages/formio.js/src/utils`) — not online docs, which are known to drift. Covers static SDK methods (`setBaseUrl`, `setProjectUrl`, `setToken`, `currentUser`, `login`, `logout`, `ssoInit`, `request`, `makeRequest`, `accessInfo`, `pluginAlter`, `pluginGet`, `pluginWait`), instance SDK methods on `new Formio(url)` (`loadForm`, `saveForm`, `deleteForm`, `loadForms`, `loadSubmission`, `saveSubmission`, `deleteSubmission`, `loadSubmissions`, `availableActions`, `userPermissions`, `getDownloadUrl`, `getTempToken`, `uploadFile`, `downloadFile`, `deleteFile`), VanillaJS form rendering (`Formio.createForm`, `Formio.builder`, `form.on('submit' | 'change' | 'error' | 'nextPage' | 'prevPage' | 'render' | 'attach')`, `form.submission` prefill), the plugin lifecycle (`preRequest`, `request`, `staticRequest`, `wrapRequestPromise`, `wrapStaticRequestPromise`, `requestOptions`, `requestResponse`), and the full `Utils` surface (Evaluator, form traversal, conditions, logic, JSONLogic, mask, sanitize, date, dom, i18n, jwtDecode, unwind, fastCloneDeep, override). Use when the user asks to call any `Formio.*` static method, work with a `new Formio(...)` instance, render a Form.io form in a VanillaJS or non-Angular consumer, invoke a `Utils.*` helper, register a plugin, configure `setBaseUrl`/`setProjectUrl` for a Hosted or SaaS deployment, evaluate a condition or formula, traverse component trees, mask or sanitize input, or decode a JWT. Not for: documenting Form.io REST endpoint shape (see formio-api); orchestrating a full application build (see formio-application); planning Resource schemas (see formio-resource-planner); Angular `@formio/angular` wrappers, `FormioAppConfig`, or `FormioModule` (see formio-angular). + Reference for the Form.io JavaScript SDK (`@formio/js`), the Form.io Utilities (`@formio/js/utils`), and the small set of helpers exposed only by `@formio/core` (jsonLogic, dom, I18n, override, unwind, the logic processor). Covers static SDK methods (`setBaseUrl`, `setProjectUrl`, `setToken`, `currentUser`, `logout`, `ssoInit`, `accessInfo`, `pluginAlter`, `pluginGet`, `pluginWait`), instance SDK methods on `new Formio(url)` (`loadForm`, `saveForm`, `deleteForm`, `loadForms`, `loadSubmission`, `saveSubmission`, `deleteSubmission`, `loadSubmissions`, `availableActions`, `userPermissions`, `getDownloadUrl`, `getTempToken`, `uploadFile`, `downloadFile`, `deleteFile`), VanillaJS form rendering (`Formio.createForm`, `Formio.builder`, `form.on(…)`, `form.submission` prefill), the plugin lifecycle (`preRequest`, `request`, `staticRequest`, `wrapRequestPromise`, `wrapStaticRequestPromise`, `requestOptions`, `requestResponse`), the `Utils` surface (Evaluator, form traversal, conditions, mask, sanitize, date helpers, fastCloneDeep), and the @formio/core-only surfaces (`jsonLogic`, `dom`, `I18n`, `override`, `unwind`, `logicProcessSync`/`logicProcessInfo`). Token decoding goes through `Formio.getToken({ decode: true })`; logout and token clearing through `Formio.logout()` or `Formio.setToken(null)`. Use when the user asks to call any `Formio.*` static method, work with a `new Formio(...)` instance, render a Form.io form in a VanillaJS or non-Angular consumer, invoke a `Utils.*` helper, evaluate JSONLogic, run component-logic actions, manipulate the DOM via Form.io's helpers, register a plugin, configure `setBaseUrl`/`setProjectUrl` for a Hosted or SaaS deployment, traverse component trees, mask or sanitize input, or decode the cached JWT. Not for: documenting Form.io REST endpoint shape (see formio-api); orchestrating a full application build (see formio-application); planning Resource schemas (see formio-resource-planner); Angular `@formio/angular` wrappers, `FormioAppConfig`, or `FormioModule` (see formio-angular). --- # Form.io SDK Skills -Source-derived reference for `@formio/js` and `@formio/js/utils`. Every fact in this skill comes from the Form.io source code at the paths called out in each reference's `## Overview` — not from public documentation. +Reference for `@formio/js`, `@formio/js/utils`, and the helpers exposed only by `@formio/core`. Covers SDK bootstrap, authentication, form / submission / project / role / file CRUD, plugin lifecycle, VanillaJS rendering, and the full `Utils` surface (Evaluator, traversal, conditions, logic actions, JSONLogic, mask, sanitize, date, DOM, i18n, fastCloneDeep, override, unwind). ## Imports -The only acceptable import paths are: +Prefer the renderer-extended SDK first; fall back to `@formio/core` only when a surface is not re-exported by `@formio/js` or `@formio/js/utils`: ```ts +// Preferred — covers the SDK, rendering, plugins, forms, submissions, projects, +// roles, files, and the bulk of the Utils surface. import { Formio } from '@formio/js'; import { Utils } from '@formio/js/utils'; + +// Acceptable fallbacks — only when @formio/js does not expose the surface. +// Confirmed-needed today: jsonLogic, dom, I18n, override, unwind, the +// runtime logic processor (logicProcessSync), and the canonical DefaultEvaluator +// base class. +import { jsonLogic, dom, I18n, override, unwind, sanitize } from '@formio/core'; +import { logicProcessSync, logicProcessInfo } from '@formio/core/process'; ``` -Never import from `@formio/core` (internal package), `@formio/js/lib/...` (deep import), or via a `