diff --git a/.changeset/vscode-snippets-parse-gate.md b/.changeset/vscode-snippets-parse-gate.md new file mode 100644 index 0000000000..e65f7deeea --- /dev/null +++ b/.changeset/vscode-snippets-parse-gate.md @@ -0,0 +1,42 @@ +--- +"objectstack-vscode": patch +--- + +fix(vscode): every contributed snippet expands to metadata the spec accepts — and a gate that keeps it that way (#4917) + +The extension's snippets are a metadata **producer**: whatever `os-view-grid` +expands to is the first `.view.ts` an author (human or AI) ever writes. Nothing +in this repo has ever parsed that output, so the snippets drifted out of the +spec in silence. An audit of all eight found **five** broken against +`@objectstack/spec` 17: + +| snippet | what was rejected | canonical form now | +|---|---|---| +| `os-view-grid` | `list.defaultSort`, `list.pageSize` (never declared on `ListViewSchema`); plus `type` / `objectName` on the **container**, which is the flat-view-where-a-container-goes mistake `ViewSchema`'s own guidance names | `defineView({ object, list: { …, sort: [{ field, order }], pagination: { pageSize } } })` | +| `os-flow` | node `name` / `next` (the keys are `label` + an `edges` array), and a top-level `trigger` block | `defineFlow` with the object binding on the START node's `config: { objectName, triggerType }` and an explicit `edges: []` | +| `os-agent` | `tools` — removed in protocol 17 (#3894) | `skills: []` | +| `os-stack` | `manifest` missing the required `id` and `type` | `{ id, namespace, version, type, name, engines }` | +| `os-field-lookup` | `reference: { object, labelField }` — `reference` is a plain object name | `reference: 'target_object'` + `displayField` | + +Separately, **all five** module snippets imported `{ Data }` / `{ UI }` / +`{ Automation }` / `{ AI }` from the package root. Those namespace re-exports +were removed for being untree-shakeable (see `packages/spec/src/index.ts`), so +the very first line of each scaffold did not resolve. They now import from the +subpath and author through the domain's validating factory — `ObjectSchema.create`, +`defineView`, `defineFlow`, `defineAgent`, `defineStack` — which parses at +authoring time and, being a *value* import, fails loudly instead of degrading +to `any` (issue #2035's rationale, applied to the scaffolds themselves). + +**The recurrence is what actually got fixed.** `os-view-grid` broke because +#4001 closed `ListViewSchema` for unknown keys and no gate anywhere could see a +snippet body; the next strictness batch would have broken another one the same +way. The package now has a `test` script that expands every snippet, evaluates +it against the real spec, and `safeParse`s the authored literal with the schema +the runtime uses. Three independent failure modes are covered — the expansion +does not evaluate, the literal does not parse, or an import names a binding the +spec no longer exports — with a negative control asserting the pre-fix shape is +still rejected, a plan table that fails when a snippet arrives ungated, and a +lockstep check on the `engines.protocol` major so that stamp cannot rot either. + +No authoring change is required of anyone: this only replaces snippet output +that never validated. diff --git a/packages/vscode-objectstack/README.md b/packages/vscode-objectstack/README.md index 989538d6fc..825fd0da3e 100644 --- a/packages/vscode-objectstack/README.md +++ b/packages/vscode-objectstack/README.md @@ -15,16 +15,30 @@ ## Snippets -| Prefix | Description | -|--------|-------------| -| `os-object` | Define a new business object | -| `os-field-text` | Add a text field | -| `os-field-select` | Add a select (picklist) field | -| `os-field-lookup` | Add a lookup (reference) field | -| `os-view-grid` | Define a grid list view | -| `os-flow` | Define an automation flow | -| `os-stack` | Full `defineStack` boilerplate | -| `os-agent` | Define an AI agent | +Every snippet scaffolds through the spec's own authoring factory +(`ObjectSchema.create`, `defineView`, `defineFlow`, `defineAgent`, +`defineStack`), so what you tab out of the IDE validates against +`@objectstack/spec` the moment it runs — never a bare `: Type` literal that +type-checks over a shape nothing ever parses. + +| Prefix | Scaffolds | Validated by | +|--------|-----------|--------------| +| `os-object` | A new business object | `ObjectSchema.create` | +| `os-field-text` | A text field (paste inside `fields: { … }`) | `FieldSchema` | +| `os-field-select` | A select (picklist) field | `FieldSchema` | +| `os-field-lookup` | A lookup (reference) field | `FieldSchema` | +| `os-view-grid` | A grid list view container | `defineView` | +| `os-flow` | A record-change automation flow | `defineFlow` | +| `os-stack` | Full `defineStack` boilerplate | `defineStack` | +| `os-agent` | An AI agent | `defineAgent` | + +That claim is enforced, not advertised: `pnpm test` in this package expands +every snippet, evaluates it against the real `@objectstack/spec`, and +`safeParse`s the authored literal with the same schema the runtime uses — plus +a check that each import binding still exists on the spec's export surface. A +snippet that goes stale (as `os-view-grid` did when `ListViewSchema` closed +`defaultSort` / `pageSize`) fails CI instead of shipping. See +`test/snippets.test.ts`. ## Installation @@ -67,6 +81,9 @@ npm run build # Watch for changes npm run watch +# Verify every contributed snippet still parses against @objectstack/spec +npm test + # Package as .vsix npm run package ``` diff --git a/packages/vscode-objectstack/package.json b/packages/vscode-objectstack/package.json index 074133e3ef..7360ada987 100644 --- a/packages/vscode-objectstack/package.json +++ b/packages/vscode-objectstack/package.json @@ -57,12 +57,16 @@ "build": "tsc -p ./tsconfig.json", "watch": "tsc -watch -p ./tsconfig.json", "package": "vsce package", - "typecheck": "tsc --noEmit" + "test": "vitest run", + "typecheck": "tsc --noEmit && tsc -p ./tsconfig.test.json" }, "devDependencies": { + "@objectstack/spec": "workspace:*", + "@types/node": "^26.1.2", "@types/vscode": "^1.125.0", "@vscode/vsce": "^3.9.2", - "typescript": "^6.0.3" + "typescript": "^6.0.3", + "vitest": "^4.1.10" }, "keywords": [ "objectstack", diff --git a/packages/vscode-objectstack/snippets/objectstack.json b/packages/vscode-objectstack/snippets/objectstack.json index 1fa53ae197..3c31cf3c57 100644 --- a/packages/vscode-objectstack/snippets/objectstack.json +++ b/packages/vscode-objectstack/snippets/objectstack.json @@ -3,9 +3,9 @@ "prefix": "os-object", "description": "Define a new ObjectStack business object", "body": [ - "import { Data } from '@objectstack/spec';", + "import { ObjectSchema } from '@objectstack/spec/data';", "", - "const ${1:myObject}: Data.Object = {", + "export const ${1:MyObject} = ObjectSchema.create({", " name: '${2:my_object}',", " label: '${3:My Object}',", " pluralLabel: '${3:My Object}s',", @@ -18,9 +18,7 @@ " },", " $0", " },", - "};", - "", - "export default ${1:myObject};" + "});" ] }, "ObjectStack: Text Field": { @@ -59,10 +57,8 @@ " type: 'lookup',", " label: '${2:Related Object}',", " required: ${3|false,true|},", - " reference: {", - " object: '${4:target_object}',", - " labelField: '${5:name}',", - " },", + " reference: '${4:target_object}',", + " displayField: '${5:name}',", "}," ] }, @@ -70,55 +66,56 @@ "prefix": "os-view-grid", "description": "Define a grid list view for an object", "body": [ - "import { UI } from '@objectstack/spec';", + "import { defineView } from '@objectstack/spec';", "", - "const ${1:myObject}ListView: UI.View = {", - " name: '${2:my_object}_list',", - " label: '${3:My Object} List',", - " type: 'list',", - " objectName: '${2:my_object}',", + "export const ${1:MyObject}Views = defineView({", + " object: '${2:my_object}',", " list: {", + " label: '${3:My Object} List',", " type: 'grid',", + " data: { provider: 'object', object: '${2:my_object}' },", " columns: [", " { field: 'name', width: 200 },", " $0", " ],", - " defaultSort: { field: 'name', direction: 'asc' },", - " pageSize: 25,", + " sort: [{ field: 'name', order: 'asc' }],", + " pagination: { pageSize: 25 },", " },", - "};", - "", - "export default ${1:myObject}ListView;" + "});" ] }, "ObjectStack: Automation Flow": { "prefix": "os-flow", - "description": "Define an automation flow", + "description": "Define a record-change automation flow", "body": [ - "import { Automation } from '@objectstack/spec';", + "import { defineFlow } from '@objectstack/spec';", "", - "const ${1:myFlow}: Automation.Flow = {", + "export const ${1:MyFlow} = defineFlow({", " name: '${2:my_flow}',", " label: '${3:My Flow}',", - " type: '${4|autolaunched,screen,schedule|}',", + " type: 'record_change',", " status: 'draft',", - " trigger: {", - " type: 'record_change',", - " object: '${5:my_object}',", - " events: ['after_insert', 'after_update'],", - " },", " nodes: [", " {", " id: 'start',", " type: 'start',", - " name: 'Start',", - " next: '${6:end}',", + " label: 'Start',", + " config: {", + " objectName: '${4:my_object}',", + " triggerType: '${5|record-after-write,record-after-insert,record-after-update,record-after-delete|}',", + " },", " },", " $0", + " {", + " id: 'end',", + " type: 'end',", + " label: 'End',", + " },", " ],", - "};", - "", - "export default ${1:myFlow};" + " edges: [", + " { id: 'e1', source: 'start', target: 'end' },", + " ],", + "});" ] }, "ObjectStack: defineStack Boilerplate": { @@ -129,9 +126,12 @@ "", "export default defineStack({", " manifest: {", - " name: '${1:my_app}',", - " version: '${2:0.1.0}',", - " label: '${3:My Application}',", + " id: '${1:com.example.my_app}',", + " namespace: '${2:my_app}',", + " version: '${3:0.1.0}',", + " type: 'app',", + " name: '${4:My Application}',", + " engines: { protocol: '^17' },", " },", " objects: [", " $0", @@ -145,9 +145,9 @@ "prefix": "os-agent", "description": "Define an AI agent", "body": [ - "import { AI } from '@objectstack/spec';", + "import { defineAgent } from '@objectstack/spec';", "", - "const ${1:myAgent}: AI.Agent = {", + "export const ${1:MyAgent} = defineAgent({", " name: '${2:my_agent}',", " label: '${3:My Agent}',", " role: '${4:Assistant}',", @@ -156,12 +156,10 @@ " provider: '${6|openai,anthropic,google|}',", " model: '${7:gpt-4o}',", " },", - " tools: [", + " skills: [", " $0", " ],", - "};", - "", - "export default ${1:myAgent};" + "});" ] } } diff --git a/packages/vscode-objectstack/test/snippet-harness.ts b/packages/vscode-objectstack/test/snippet-harness.ts new file mode 100644 index 0000000000..4ddc5f2eb2 --- /dev/null +++ b/packages/vscode-objectstack/test/snippet-harness.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. + +/** + * Snippet harness — turns a VS Code snippet body into the value a metadata + * author would actually get, so a schema can judge it. + * + * Why this exists (#4917): `snippets/objectstack.json` is a *metadata producer* + * — the very first `.object.ts` / `.view.ts` an author (human or AI) writes is + * whatever the IDE expanded for them. Nothing in this repo ever parsed that + * output, so when #4001 closed `ListViewSchema` for unknown keys the + * `os-view-grid` snippet went from "silently drops `defaultSort`/`pageSize`" to + * "hard parse error", and stayed broken because no gate could see it. Four more + * snippets were found equally stale by the audit that opened this file. + * + * The harness is deliberately boring: expand the placeholders, transpile the TS, + * run it with the REAL `@objectstack/spec` behind `require`, and hand the + * authored literal back so the test can `safeParse` it. No stubbing of the spec, + * because a stub is exactly how a gate stops tracking the contract it guards. + */ + +import { createRequire } from 'node:module'; +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import ts from 'typescript'; + +const require = createRequire(import.meta.url); + +/** One snippet as it appears in `snippets/objectstack.json`. */ +export interface RawSnippet { + prefix: string; + description: string; + body: string[]; +} + +/** A call the snippet made into a validating spec factory. */ +export interface RecordedFactoryCall { + /** e.g. `defineView`, `ObjectSchema.create`. */ + factory: string; + /** The literal the author wrote — pre-parse, pre-defaults. */ + config: unknown; +} + +export interface EvaluatedSnippet { + /** The expanded TypeScript source, exactly as the IDE would insert it. */ + source: string; + /** Every validating factory the snippet called, in call order. */ + calls: RecordedFactoryCall[]; + /** The module's single export (default, or the one named export). */ + exported: unknown; +} + +/** + * Expand VS Code snippet placeholder syntax into concrete text. + * + * The four forms the LSP snippet grammar allows in this file, and what an + * author gets if they tab straight through without typing: + * + * | form | expands to | + * |-----------------|----------------------| + * | `${1:default}` | `default` | + * | `${1\|a,b\|}` | `a` (the first choice, which is what VS Code preselects) | + * | `${1}` | `p1` (a syntactically valid placeholder identifier) | + * | `$0` | `` (the final cursor stop — no text) | + * + * Expansion is therefore *non-trivial but total*: every form has one defined + * result, so "what the author gets" is a single well-defined string and the + * gate has something concrete to parse. A snippet that used a form outside this + * table would silently expand to the wrong thing, so + * {@link assertKnownPlaceholderSyntax} rejects one instead. + */ +export function expandSnippetBody(body: string[]): string { + const source = body.join('\n'); + assertKnownPlaceholderSyntax(source); + return source + .replace(/\$\{\d+\|([^|]*)\|\}/g, (_m, choices: string) => choices.split(',')[0]) + .replace(/\$\{(\d+):([^}]*)\}/g, (_m, _n: string, dflt: string) => dflt) + .replace(/\$\{(\d+)\}/g, (_m, n: string) => `p${n}`) + .replace(/\$\d+/g, ''); +} + +/** + * Reject any placeholder form {@link expandSnippetBody} does not model — + * variables (`$TM_FILENAME`), transforms (`${1/…/…/}`), nested placeholders. + * Without this the expander would silently produce text no author would ever + * see, and the gate would be validating a fiction. + */ +export function assertKnownPlaceholderSyntax(source: string): void { + const unsupported = [...source.matchAll(/\$\{[^}]*\}|\$[A-Za-z_]\w*/g)] + .map((m) => m[0]) + .filter((tok) => !/^\$\{\d+(:[^}]*)?\}$/.test(tok) && !/^\$\{\d+\|[^|]*\|\}$/.test(tok)); + if (unsupported.length > 0) { + throw new Error( + `Unsupported snippet placeholder syntax: ${unsupported.join(', ')}. ` + + 'The harness models ${n:default}, ${n|a,b|}, ${n} and $n only — teach ' + + 'expandSnippetBody() the new form before using it, or the gate silently ' + + 'validates text no author would ever see.', + ); + } +} + +/** Factories whose argument is the authored metadata literal. */ +const RECORDED_FACTORIES = new Set([ + 'defineStack', 'defineView', 'defineForm', 'defineFlow', 'defineAgent', + 'defineApp', 'defineTool', 'defineSkill', 'defineReport', 'defineAction', +]); + +/** Schema objects whose `.create()` is the authoring entry point. */ +const RECORDED_CREATE_SCHEMAS = new Set(['ObjectSchema']); + +/** + * Wrap a spec module so every authoring factory the snippet calls records the + * literal it received. The call still goes through to the real factory, so the + * factory's own `.parse()` stays part of the proof. + */ +function recordingModule(mod: Record, sink: RecordedFactoryCall[]): unknown { + return new Proxy(mod, { + get(target, prop, receiver) { + const value = Reflect.get(target, prop, receiver); + if (typeof prop !== 'string') return value; + + if (RECORDED_FACTORIES.has(prop) && typeof value === 'function') { + return (...args: unknown[]) => { + sink.push({ factory: prop, config: args[0] }); + return (value as (...a: unknown[]) => unknown)(...args); + }; + } + + // A spec schema built by `lazySchema()` is a *callable* proxy, not a + // plain object — checking only for `typeof === 'object'` silently skipped + // `ObjectSchema` and let `os-object` through unrecorded. + if (RECORDED_CREATE_SCHEMAS.has(prop) && value + && (typeof value === 'object' || typeof value === 'function')) { + const schema = value as Record; + if (typeof schema.create !== 'function') return value; + return new Proxy(schema, { + get(schemaTarget, schemaProp) { + const member = Reflect.get(schemaTarget, schemaProp); + if (schemaProp === 'create' && typeof member === 'function') { + return (...args: unknown[]) => { + sink.push({ factory: `${prop}.create`, config: args[0] }); + return (member as (...a: unknown[]) => unknown).apply(schemaTarget, args); + }; + } + return typeof member === 'function' ? member.bind(schemaTarget) : member; + }, + }); + } + + return value; + }, + }); +} + +/** + * Transpile the expanded snippet and run it against the real spec. + * + * `ts.transpileModule` (not the full program) is deliberate: it is the same + * single-file, types-erased transform a bundler applies, it needs no tsconfig, + * and it keeps this gate cheap enough to live in the package's own `pnpm test`. + * What it cannot see — that an imported *name* exists — is covered separately by + * the import-surface check below, which is the half that catches a snippet + * importing a binding the spec no longer exports. + */ +export function evaluateSnippetModule(source: string): EvaluatedSnippet { + const js = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + + const calls: RecordedFactoryCall[] = []; + const moduleObject: { exports: Record } = { exports: {} }; + const shimmedRequire = (id: string): unknown => { + const resolved = require(id) as Record; + return id.startsWith('@objectstack/spec') ? recordingModule(resolved, calls) : resolved; + }; + + const run = new Function('exports', 'require', 'module', js) as ( + exports: unknown, req: unknown, mod: unknown, + ) => void; + run(moduleObject.exports, shimmedRequire, moduleObject); + + return { source, calls, exported: soleExport(moduleObject.exports) }; +} + +/** + * A snippet scaffolds exactly one metadata item, so its module has exactly one + * export. Insisting on that (rather than groping for `.default`) keeps the + * harness honest about what it validated. + */ +function soleExport(exports: Record): unknown { + const names = Object.keys(exports).filter((k) => k !== '__esModule'); + if (names.length !== 1) { + throw new Error( + `Expected the snippet module to export exactly one value, got ${names.length}: ` + + `[${names.join(', ')}].`, + ); + } + return exports[names[0]]; +} + +/** A single `import … from '…'` statement found in a snippet. */ +export interface SnippetImport { + specifier: string; + /** Named bindings, by their name in the source module (pre-`as`). */ + named: string[]; + /** True for `import * as NS from '…'`. */ + namespace: boolean; +} + +/** Parse a snippet's import statements with the TypeScript parser. */ +export function collectImports(source: string): SnippetImport[] { + const sf = ts.createSourceFile('snippet.ts', source, ts.ScriptTarget.ES2022, true); + const imports: SnippetImport[] = []; + for (const statement of sf.statements) { + if (!ts.isImportDeclaration(statement)) continue; + if (!ts.isStringLiteral(statement.moduleSpecifier)) continue; + const entry: SnippetImport = { + specifier: statement.moduleSpecifier.text, + named: [], + namespace: false, + }; + const bindings = statement.importClause?.namedBindings; + if (bindings && ts.isNamespaceImport(bindings)) entry.namespace = true; + if (bindings && ts.isNamedImports(bindings)) { + for (const element of bindings.elements) { + entry.named.push((element.propertyName ?? element.name).text); + } + } + imports.push(entry); + } + return imports; +} + +/** + * Every name a module exports — runtime values plus the type-only names its + * entry `.d.ts` declares. + * + * The type half is the point. `import { Data } from '@objectstack/spec'` was in + * five of these snippets; `Data` was a *type* namespace, so no runtime check + * could see it disappear when the root namespace re-exports were removed for + * being untree-shakeable (see `packages/spec/src/index.ts`). Reading the + * declaration file is what makes a vanished type binding a red test. + */ +export function declaredExportSurface(specifier: string): Set { + const names = new Set(Object.keys(require(specifier) as object)); + + const jsEntry = require.resolve(specifier); + const dtsEntry = join(dirname(jsEntry), `${basenameWithoutExt(jsEntry)}.d.ts`); + const declaration = readFileSync(dtsEntry, 'utf8'); // loud if the build is missing + const sf = ts.createSourceFile(dtsEntry, declaration, ts.ScriptTarget.ES2022, true); + + for (const statement of sf.statements) { + if (ts.isExportDeclaration(statement) && statement.exportClause + && ts.isNamedExports(statement.exportClause)) { + for (const element of statement.exportClause.elements) names.add(element.name.text); + continue; + } + const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined; + if (!modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) continue; + if (ts.isVariableStatement(statement)) { + for (const d of statement.declarationList.declarations) { + if (ts.isIdentifier(d.name)) names.add(d.name.text); + } + } else if ('name' in statement && statement.name && ts.isIdentifier(statement.name as ts.Node)) { + names.add((statement.name as ts.Identifier).text); + } + } + return names; +} + +function basenameWithoutExt(file: string): string { + return file.split('/').pop()!.replace(/\.(c|m)?js$/, ''); +} + +/** Read the snippet contribution file this extension actually ships. */ +export function readSnippets(): Record { + const file = new URL('../snippets/objectstack.json', import.meta.url); + return JSON.parse(readFileSync(file, 'utf8')) as Record; +} diff --git a/packages/vscode-objectstack/test/snippets.test.ts b/packages/vscode-objectstack/test/snippets.test.ts new file mode 100644 index 0000000000..36b7691022 --- /dev/null +++ b/packages/vscode-objectstack/test/snippets.test.ts @@ -0,0 +1,231 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. + +/** + * The snippet gate (#4917). + * + * Every snippet this extension contributes is a metadata *producer*: whatever + * it expands to is the first `.object.ts` / `.view.ts` an author ever writes. + * Until this file existed nothing parsed that output, so the snippets drifted + * out of the spec in silence — `os-view-grid` shipped `list.defaultSort` / + * `list.pageSize` (keys `ListViewSchema` never declared), and the #4001 + * strictness batches turned that from a silent strip into a hard 422 without + * anything going red. Four more snippets were equally stale. + * + * So the contract here is: **expand each snippet, then judge it with the same + * schema the runtime uses.** Three independent ways to fail: + * + * 1. the expansion does not evaluate (a factory rejected it, or it is not + * valid TypeScript); + * 2. the authored literal does not `safeParse` against its spec schema; + * 3. it imports a binding `@objectstack/spec` does not export — the failure a + * runtime check cannot see, and the one five snippets were carrying. + * + * Plus two structural guards: every shipped snippet must be planned here (a new + * snippet cannot arrive ungated), and the negative case must actually fail (a + * gate nobody proved red is a gate that is green for the wrong reason). + */ + +import { createRequire } from 'node:module'; +import { describe, expect, it } from 'vitest'; + +import { + collectImports, + declaredExportSurface, + evaluateSnippetModule, + expandSnippetBody, + readSnippets, + type RawSnippet, +} from './snippet-harness.js'; + +/** + * Just enough of a Zod schema to judge a snippet. Structural rather than + * `import type { ZodType } from 'zod'`: the extension has no runtime dependency + * on the spec (it ships snippets and a language client), and this gate should + * not be the reason it acquires zod's type surface as a build input. + */ +interface ParsableSchema { + safeParse(value: unknown): { success: true } | { + success: false; + error: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> }; + }; +} + +const require = createRequire(import.meta.url); +const specRoot = require('@objectstack/spec') as Record; +const specData = require('@objectstack/spec/data') as Record; +const specUi = require('@objectstack/spec/ui') as Record; +const specAutomation = require('@objectstack/spec/automation') as Record; +const specAi = require('@objectstack/spec/ai') as Record; + +/** + * What each snippet claims to scaffold, and which spec schema judges it. + * + * `kind: 'module'` — the snippet is a whole file; it must call a validating + * factory, and the literal handed to that factory is what gets parsed. + * `kind: 'field'` — the snippet is a fragment pasted inside an object's + * `fields: { … }`; it is wrapped into a module and its one value parsed. + */ +interface SnippetPlan { + kind: 'module' | 'field'; + schema: ParsableSchema; + /** Human-readable name of the schema, for failure messages. */ + schemaName: string; + /** For `module` snippets: the factory the snippet must go through. */ + factory?: string; +} + +const PLANS: Record = { + 'os-object': { + kind: 'module', schema: specData.ObjectSchema, schemaName: 'Data.ObjectSchema', + factory: 'ObjectSchema.create', + }, + 'os-field-text': { kind: 'field', schema: specData.FieldSchema, schemaName: 'Data.FieldSchema' }, + 'os-field-select': { kind: 'field', schema: specData.FieldSchema, schemaName: 'Data.FieldSchema' }, + 'os-field-lookup': { kind: 'field', schema: specData.FieldSchema, schemaName: 'Data.FieldSchema' }, + 'os-view-grid': { + kind: 'module', schema: specUi.ViewSchema, schemaName: 'UI.ViewSchema', factory: 'defineView', + }, + 'os-flow': { + kind: 'module', schema: specAutomation.FlowSchema, schemaName: 'Automation.FlowSchema', + factory: 'defineFlow', + }, + 'os-stack': { + kind: 'module', schema: specRoot.ObjectStackDefinitionSchema, + schemaName: 'ObjectStackDefinitionSchema', factory: 'defineStack', + }, + 'os-agent': { + kind: 'module', schema: specAi.AgentSchema, schemaName: 'AI.AgentSchema', factory: 'defineAgent', + }, +}; + +const snippets = readSnippets(); +const entries = Object.entries(snippets) as Array<[string, RawSnippet]>; + +/** Wrap a `fields: { … }` fragment into a module the harness can evaluate. */ +function asFieldModule(expanded: string): string { + return `export default {\n${expanded}\n};`; +} + +function formatIssues(schemaName: string, error: { issues: ReadonlyArray<{ path: PropertyKey[]; message: string }> }): string { + return `${schemaName} rejected the expanded snippet:\n` + + error.issues.map((i) => ` · ${i.path.join('.') || '(root)'}: ${i.message}`).join('\n'); +} + +describe('VS Code snippets are valid ObjectStack metadata (#4917)', () => { + it('plans every snippet the extension ships — a new snippet cannot arrive ungated', () => { + const shipped = entries.map(([, s]) => s.prefix).sort(); + expect(Object.keys(PLANS).sort()).toEqual(shipped); + }); + + describe.each(entries)('%s', (_name, snippet) => { + const plan = PLANS[snippet.prefix]; + const expanded = expandSnippetBody(snippet.body); + + it(`[${snippet.prefix}] expands to metadata ${plan.schemaName} accepts`, () => { + const source = plan.kind === 'field' ? asFieldModule(expanded) : expanded; + const evaluated = evaluateSnippetModule(source); + + let authored: unknown; + if (plan.kind === 'field') { + const fields = evaluated.exported as Record; + const keys = Object.keys(fields); + expect(keys, 'a field snippet declares exactly one field').toHaveLength(1); + authored = fields[keys[0]]; + } else { + // The snippet must route through a validating factory, not a bare + // `: Type` literal. A bare literal is precisely what `os-view-grid` + // used to be — it type-annotated a shape nothing ever parsed, so the + // spec could close two keys underneath it without a single diagnostic. + const call = evaluated.calls.find((c) => c.factory === plan.factory); + expect( + call, + `expected the snippet to author through \`${plan.factory}\`, but it called ` + + `[${evaluated.calls.map((c) => c.factory).join(', ') || 'nothing'}]`, + ).toBeDefined(); + authored = call!.config; + } + + const result = plan.schema.safeParse(authored); + if (!result.success) throw new Error(formatIssues(plan.schemaName, result.error)); + expect(result.success).toBe(true); + }); + + it(`[${snippet.prefix}] imports only bindings @objectstack/spec still exports`, () => { + for (const imported of collectImports(expanded)) { + expect( + imported.specifier, + 'snippets may only import from @objectstack/spec', + ).toMatch(/^@objectstack\/spec(\/|$)/); + + const surface = declaredExportSurface(imported.specifier); + for (const binding of imported.named) { + expect( + surface.has(binding), + `\`${binding}\` is not exported by '${imported.specifier}'. Root namespace ` + + 're-exports (Data / UI / AI / Automation …) were removed as untree-shakeable — ' + + 'import from the subpath instead (see packages/spec/src/index.ts).', + ).toBe(true); + } + } + }); + }); + + /** + * The `os-stack` scaffold stamps the protocol handshake range + * (ADR-0087 D1), and a hardcoded major is exactly the kind of literal that + * rots the way this whole issue rotted. `create-objectstack`'s template is + * kept in lockstep by `scripts/sync-template-versions.mjs`, which does not + * know about this file — so the lockstep is asserted here instead. + */ + it('os-stack stamps the CURRENT protocol major in engines.protocol', () => { + const major = (require('@objectstack/spec/package.json') as { version: string }) + .version.split('.')[0]; + const stack = entries.find(([, s]) => s.prefix === 'os-stack')![1]; + expect(stack.body.join('\n')).toContain(`engines: { protocol: '^${major}' }`); + }); +}); + +/** + * Bidirectional proof. A gate is only worth its runtime if it goes red on the + * thing it claims to catch, so re-introduce the exact #4917 defect — the keys + * #4001 closed — and require a rejection. If this test starts passing "for + * free", the gate above has stopped judging anything. + */ +describe('the gate rejects a stale snippet (negative control, #4917)', () => { + const STALE_VIEW_BODY = [ + "import { defineView } from '@objectstack/spec';", + '', + 'export const ${1:MyObject}Views = defineView({', + " object: '${2:my_object}',", + ' list: {', + " type: 'grid',", + " columns: [{ field: 'name', width: 200 }],", + " defaultSort: { field: 'name', direction: 'asc' },", + ' pageSize: 25,', + ' },', + '});', + ]; + + it('rejects `list.defaultSort` / `list.pageSize` — the keys #4001 closed', () => { + const source = expandSnippetBody(STALE_VIEW_BODY); + // `defineView` parses eagerly, so the stale shape throws before we even get + // to safeParse — which is the behaviour an author sees, and the proof that + // the positive tests above are not passing on an inert code path. + expect(() => evaluateSnippetModule(source)).toThrowError(/defaultSort|pageSize/); + }); + + it('rejects an import binding the spec no longer exports', () => { + const source = expandSnippetBody([ + "import { Data } from '@objectstack/spec';", + '', + 'export const Broken = Data;', + ]); + const [imported] = collectImports(source); + expect(declaredExportSurface(imported.specifier).has('Data')).toBe(false); + }); + + it('refuses a placeholder form the expander does not model', () => { + expect(() => expandSnippetBody(['const f = ${TM_FILENAME};'])) + .toThrowError(/Unsupported snippet placeholder syntax/); + }); +}); diff --git a/packages/vscode-objectstack/tsconfig.test.json b/packages/vscode-objectstack/tsconfig.test.json new file mode 100644 index 0000000000..d221c59819 --- /dev/null +++ b/packages/vscode-objectstack/tsconfig.test.json @@ -0,0 +1,25 @@ +{ + // The extension itself is CommonJS (VS Code's host requires it), but the + // snippet gate in `test/` is ESM — it uses `import.meta.url` to locate the + // shipped `snippets/objectstack.json` and resolves `@objectstack/spec` + // subpath exports. Two module systems cannot share one `compilerOptions`, so + // the tests get their own project rather than being dropped from type + // checking (AGENTS.md: never hide a package's tests from `tsc --noEmit`). + "compilerOptions": { + // ESM + bundler resolution mirrors how vitest actually loads these files + // (it transforms them as modules regardless of the package's CJS `type`), + // so the type check judges the same program the test run executes. + "module": "esnext", + "moduleResolution": "bundler", + "target": "ES2022", + "lib": ["ES2022"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "types": ["node"], + "noEmit": true + }, + "include": ["test"] +} diff --git a/packages/vscode-objectstack/vitest.config.ts b/packages/vscode-objectstack/vitest.config.ts new file mode 100644 index 0000000000..fa69665ca7 --- /dev/null +++ b/packages/vscode-objectstack/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['test/**/*.test.ts'], + environment: 'node', + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c738827d34..9939e71ae7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2446,6 +2446,12 @@ importers: packages/vscode-objectstack: devDependencies: + '@objectstack/spec': + specifier: workspace:* + version: link:../spec + '@types/node': + specifier: ^26.1.2 + version: 26.1.2 '@types/vscode': specifier: ^1.125.0 version: 1.125.0 @@ -2455,6 +2461,9 @@ importers: typescript: specifier: ^6.0.3 version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.10(@opentelemetry/api@1.9.1)(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(happy-dom@20.10.2)(jsdom@30.0.1(@noble/hashes@2.2.0))(msw@2.14.6(@types/node@26.1.2)(typescript@6.0.3))(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.1)(yaml@2.9.0)) packages: