diff --git a/packages/js/src/capability.ts b/packages/js/src/capability.ts new file mode 100644 index 0000000..a3ea902 --- /dev/null +++ b/packages/js/src/capability.ts @@ -0,0 +1,108 @@ +import type { Capability, Context, JSONSchema } from './types.js'; +import { classifyWith } from './classify.js'; +import { ReinsError } from './errors.js'; + +/** + * A capability handler runs one operation. It receives the run's {@link Context} + * (identity, trace id) and the validated argument map, and returns the result (or + * a promise of it). Failures thrown here become errors-as-data the model can see; + * they never abort the loop. + */ +export type Handler = (ctx: Context, args: Record) => unknown | Promise; + +/** A capability's spec plus the function that runs it (the Go/Python `BoundCapability`). */ +export interface BoundCapability { + spec: Capability; + handler: Handler; + /** Whether the handler returns raw rows/blobs — for response linting (§2.15). */ + returnsRaw: boolean; +} + +/** Options for {@link capability}. `parameters` is a JSON Schema object. */ +export interface CapabilityConfig { + description: string; + /** + * A JSON Schema for the arguments. Build one with {@link params}, hand-write it, + * or (with Zod v4) pass `z.toJSONSchema(mySchema)`. + */ + parameters?: JSONSchema; + /** Force READ classification (mutually exclusive with `destructive`). */ + reads?: boolean; + /** Force DESTRUCTIVE classification. */ + destructive?: boolean; + /** Require approval for this write/destructive capability (reads never gate). */ + confirm?: boolean; + /** Mark the capability safe to retry. */ + idempotent?: boolean; + /** Tag the capability for row-level security (only the caller's rows, §2.8). */ + scope?: string; + /** Declare that the handler returns untrimmed raw data (fails the linter). */ + returnsRaw?: boolean; +} + +/** + * Define a capability: a typed, intent-named function plus safety metadata. Access + * is classified from the name unless `reads`/`destructive` overrides it. The + * optional type parameter `Args` types the handler's argument object for the caller; + * at runtime the harness passes the validated argument map. + */ +export function capability = Record>( + name: string, + config: CapabilityConfig, + handler: (ctx: Context, args: Args) => unknown | Promise, +): BoundCapability { + const spec: Capability = { + name, + description: config.description, + access: classifyWith(name, config.reads ?? false, config.destructive ?? false), + confirm: config.confirm ?? false, + idempotent: config.idempotent ?? false, + }; + if (config.parameters !== undefined) { + spec.input_schema = config.parameters; + } + if (config.scope !== undefined) { + spec.scope = config.scope; + } + return { spec, handler: handler as Handler, returnsRaw: config.returnsRaw ?? false }; +} + +const scalarTypes = new Set(['string', 'integer', 'number', 'boolean']); + +/** + * A tiny JSON Schema builder so you never hand-write schemas or reach for Zod for + * simple cases. Each value is a scalar type — `string`, `integer`, `number`, + * `boolean` — optionally suffixed `[]` (array) and/or `?` (optional). Fields + * without `?` are required. + * + * @example + * params({ order_id: 'integer', reason: 'string?', tags: 'string[]' }) + */ +export function params(fields: Record): JSONSchema { + const properties: Record = {}; + const required: string[] = []; + for (const [field, rawType] of Object.entries(fields)) { + let spec = rawType.trim(); + let optional = false; + if (spec.endsWith('?')) { + optional = true; + spec = spec.slice(0, -1); + } + let isArray = false; + if (spec.endsWith('[]')) { + isArray = true; + spec = spec.slice(0, -2); + } + if (!scalarTypes.has(spec)) { + throw new ReinsError( + `unknown parameter type "${rawType}" for field "${field}"`, + 'use string, integer, number, or boolean (optionally with [] or ? — e.g. "string[]", "integer?")', + ); + } + properties[field] = isArray ? { type: 'array', items: { type: spec } } : { type: spec }; + if (!optional) { + required.push(field); + } + } + return { type: 'object', properties, required, additionalProperties: false }; +} diff --git a/packages/js/src/classify.ts b/packages/js/src/classify.ts new file mode 100644 index 0000000..08bcb9f --- /dev/null +++ b/packages/js/src/classify.ts @@ -0,0 +1,95 @@ +import type { Access } from './types.js'; +import { ReinsError } from './errors.js'; + +/** + * Read/write classification by the name's leading verb (the easy-by-design + * heuristic). Ambiguous ⇒ write (invariant §2.4): a wrong guess only ever adds a + * needless approval; it must never let a write run as a read. The verb sets are + * kept identical to the Python and Go ports so classification is language-neutral. + */ + +const readVerbs = new Set([ + 'get', + 'list', + 'find', + 'search', + 'fetch', + 'count', + 'show', + 'read', + 'view', + 'lookup', + 'describe', +]); + +const writeVerbs = new Set([ + 'create', + 'add', + 'update', + 'set', + 'save', + 'send', + 'apply', + 'edit', + 'modify', + 'insert', + 'put', + 'post', + 'make', + 'assign', + 'schedule', + 'approve', + 'register', +]); + +const destructiveVerbs = new Set([ + 'delete', + 'remove', + 'cancel', + 'refund', + 'charge', + 'drop', + 'deactivate', + 'purge', + 'archive', + 'revoke', + 'reset', + 'wipe', +]); + +/** Classify a capability's access from its name's leading verb (ambiguous ⇒ write, §2.4). */ +export function classify(name: string): Access { + return classifyWith(name, false, false); +} + +/** + * Apply explicit overrides (`reads` / `destructive`) over the heuristic. Setting + * both is a developer error and throws — as the Python decorator raises and the Go + * port panics. + */ +export function classifyWith(name: string, reads: boolean, destructive: boolean): Access { + if (reads && destructive) { + throw new ReinsError( + `capability ${name} marked both reads and destructive`, + 'pick one — a capability is either a read or a write/destructive', + ); + } + if (destructive) { + return 'destructive'; + } + if (reads) { + return 'read'; + } + const underscore = name.indexOf('_'); + const verb = (underscore >= 0 ? name.slice(0, underscore) : name).toLowerCase(); + if (destructiveVerbs.has(verb)) { + return 'destructive'; + } + if (readVerbs.has(verb)) { + return 'read'; + } + if (writeVerbs.has(verb)) { + return 'write'; + } + return 'write'; // ambiguous ⇒ write (invariant §2.4) +} diff --git a/packages/js/src/index.ts b/packages/js/src/index.ts index e07371d..cf9d2d6 100644 --- a/packages/js/src/index.ts +++ b/packages/js/src/index.ts @@ -11,3 +11,6 @@ export const VERSION = '0.1.0a1'; export * from './types.js'; export * from './errors.js'; export * from './model.js'; +export * from './classify.js'; +export * from './linter.js'; +export * from './capability.js'; diff --git a/packages/js/src/linter.ts b/packages/js/src/linter.ts new file mode 100644 index 0000000..4a8f805 --- /dev/null +++ b/packages/js/src/linter.ts @@ -0,0 +1,77 @@ +import type { Capability } from './types.js'; + +/** + * The Capability Linter (invariant §2.15): a build-time check that a capability is + * one a model can actually use — intent-level name, a real description, lean + * parameters, trimmed responses. Auto-generated capabilities (the ORM adapters) + * must pass it before exposure. The violation codes are language-neutral + * (`spec/contract.md` §3) and asserted by the shared conformance suite. + */ + +export const VIOLATION_OPAQUE_NAME = 'opaque_name'; +export const VIOLATION_MISSING_DESCRIPTION = 'missing_description'; +export const VIOLATION_TOO_MANY_PARAMS = 'too_many_params'; +export const VIOLATION_RAW_RESPONSE = 'raw_response'; + +/** Keep generated capabilities lean; more than this many params fails the linter. */ +export const MAX_PARAMS = 7; +/** A description shorter than this is treated as missing. */ +export const MIN_DESCRIPTION_CHARS = 10; + +const SNAKE_NAME = /^[a-z][a-z0-9_]*$/; + +/** The result of linting one capability: the violation codes it triggered (sorted). */ +export interface LintReport { + violations: string[]; + ok: boolean; +} + +/** Check a capability's agent-facing surface against the §2.15 quality bar. */ +export function lint( + name: string, + description: string, + paramCount: number, + returnsRaw: boolean, +): LintReport { + const violations: string[] = []; + if (isOpaque(name)) { + violations.push(VIOLATION_OPAQUE_NAME); + } + if (description.trim().length < MIN_DESCRIPTION_CHARS) { + violations.push(VIOLATION_MISSING_DESCRIPTION); + } + if (paramCount > MAX_PARAMS) { + violations.push(VIOLATION_TOO_MANY_PARAMS); + } + if (returnsRaw) { + violations.push(VIOLATION_RAW_RESPONSE); + } + violations.sort(); + return { violations, ok: violations.length === 0 }; +} + +/** Lint a {@link Capability}'s spec (counting its schema's properties). */ +export function lintCapability(cap: Capability, returnsRaw: boolean): LintReport { + let count = 0; + const props = cap.input_schema?.['properties']; + if (typeof props === 'object' && props !== null) { + count = Object.keys(props).length; + } + return lint(cap.name, cap.description, count, returnsRaw); +} + +/** + * Opaque = not clean lowercase snake_case, or built only from short abbreviation- + * like tokens with no real word (`tbl_ord_upd` is opaque; `refund_order` / + * `get_user` are fine). + */ +function isOpaque(name: string): boolean { + if (!SNAKE_NAME.test(name)) { + return true; + } + let longest = 0; + for (const token of name.split('_')) { + longest = Math.max(longest, token.length); + } + return longest < 4; +} diff --git a/packages/js/tests/capability.test.ts b/packages/js/tests/capability.test.ts new file mode 100644 index 0000000..d2a1ebd --- /dev/null +++ b/packages/js/tests/capability.test.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from 'vitest'; +import { + capability, + params, + toolSpec, + lintCapability, + ReinsError, + type Context, +} from '../src/index.js'; + +describe('capability', () => { + it('classifies access from the name and carries safety metadata', () => { + const cap = capability( + 'create_order', + { description: 'Create a new order for a customer.', parameters: params({ sku: 'string' }) }, + () => ({ ok: true }), + ); + expect(cap.spec.access).toBe('write'); + expect(cap.spec.name).toBe('create_order'); + expect(cap.spec.input_schema).toEqual({ + type: 'object', + properties: { sku: { type: 'string' } }, + required: ['sku'], + additionalProperties: false, + }); + expect(cap.returnsRaw).toBe(false); + }); + + it('honors explicit classification overrides and flags', () => { + const cap = capability( + 'get_report', // read verb ... + { + description: 'Generate and email a report.', + destructive: true, + confirm: true, + scope: 'user', + }, + () => undefined, + ); + expect(cap.spec.access).toBe('destructive'); // ... overridden to destructive + expect(cap.spec.confirm).toBe(true); + expect(cap.spec.scope).toBe('user'); + }); + + it('passes the run context and args to the handler', async () => { + let seen: { ctx: Context; args: Record } | undefined; + const cap = capability( + 'find_orders', + { description: 'Find orders for the caller.' }, + (ctx, args) => { + seen = { ctx, args }; + return []; + }, + ); + await cap.handler({ principal: 'u1' }, { status: 'open' }); + expect(seen?.ctx.principal).toBe('u1'); + expect(seen?.args).toEqual({ status: 'open' }); + }); + + it('produces a lint-clean spec and a policy-free tool surface', () => { + const cap = capability( + 'refund_order', + { + description: 'Refund a customer order by its public order number.', + parameters: params({ order_id: 'integer', reason: 'string?' }), + destructive: true, + confirm: true, + }, + () => undefined, + ); + expect(lintCapability(cap.spec, cap.returnsRaw).ok).toBe(true); + const spec = toolSpec(cap.spec); + expect('access' in spec).toBe(false); + expect('confirm' in spec).toBe(false); + }); +}); + +describe('params', () => { + it('marks non-optional fields required and optional fields not', () => { + const schema = params({ id: 'integer', note: 'string?' }); + expect(schema['required']).toEqual(['id']); + const props = schema['properties'] as Record; + expect(props['note']).toEqual({ type: 'string' }); + }); + + it('supports arrays, alone and combined with optional', () => { + const schema = params({ tags: 'string[]', ids: 'integer[]?' }); + const props = schema['properties'] as Record; + expect(props['tags']).toEqual({ type: 'array', items: { type: 'string' } }); + expect(props['ids']).toEqual({ type: 'array', items: { type: 'integer' } }); + expect(schema['required']).toEqual(['tags']); + }); + + it('throws a hinted error on an unknown type', () => { + expect(() => params({ when: 'date' })).toThrow(ReinsError); + expect(() => params({ when: 'date' })).toThrow('→'); + }); +}); diff --git a/packages/js/tests/classify.test.ts b/packages/js/tests/classify.test.ts new file mode 100644 index 0000000..0754219 --- /dev/null +++ b/packages/js/tests/classify.test.ts @@ -0,0 +1,51 @@ +import { describe, it, expect } from 'vitest'; +import { classify, classifyWith, ReinsError } from '../src/index.js'; + +describe('classify', () => { + it('maps leading read verbs to read', () => { + for (const name of [ + 'get_user', + 'list_orders', + 'find_products', + 'count_customers', + 'search_docs', + ]) { + expect(classify(name)).toBe('read'); + } + }); + + it('maps leading write verbs to write', () => { + for (const name of ['create_order', 'update_user', 'send_email', 'assign_ticket']) { + expect(classify(name)).toBe('write'); + } + }); + + it('maps leading destructive verbs to destructive', () => { + for (const name of ['delete_order', 'refund_payment', 'cancel_subscription', 'purge_logs']) { + expect(classify(name)).toBe('destructive'); + } + }); + + it('defaults ambiguous names to write (§2.4 — never read)', () => { + for (const name of ['frobnicate_widget', 'process_order', 'handle_thing', 'sync']) { + expect(classify(name)).toBe('write'); + } + }); + + it('reads the verb from the segment before the first underscore', () => { + expect(classify('get_user_by_email')).toBe('read'); + expect(classify('refund_order_line')).toBe('destructive'); + }); +}); + +describe('classifyWith overrides', () => { + it('lets an explicit override beat the heuristic', () => { + // A read-verb name forced to destructive, and a write-verb name forced to read. + expect(classifyWith('get_report', false, true)).toBe('destructive'); + expect(classifyWith('export_data', true, false)).toBe('read'); + }); + + it('throws when both reads and destructive are set', () => { + expect(() => classifyWith('do_thing', true, true)).toThrow(ReinsError); + }); +}); diff --git a/packages/js/tests/linter.test.ts b/packages/js/tests/linter.test.ts new file mode 100644 index 0000000..0e004c4 --- /dev/null +++ b/packages/js/tests/linter.test.ts @@ -0,0 +1,89 @@ +import { describe, it, expect } from 'vitest'; +import { lint, lintCapability, type Capability } from '../src/index.js'; + +describe('lint', () => { + it('rejects a raw auto-generated capability with all four violations (conformance)', () => { + // Mirrors spec/conformance/cases/linter_rejects_raw_capability.yaml. + const report = lint('tbl_ord_upd', '', 14, true); + expect(report.ok).toBe(false); + expect(report.violations).toEqual([ + 'missing_description', + 'opaque_name', + 'raw_response', + 'too_many_params', + ]); + }); + + it('accepts a clean intent-level capability (conformance)', () => { + // Mirrors spec/conformance/cases/linter_accepts_clean_capability.yaml. + const report = lint( + 'refund_order', + "Refund a customer's order by its public order number.", + 2, + false, + ); + expect(report.ok).toBe(true); + expect(report.violations).toEqual([]); + }); + + it('flags an opaque name built only from short tokens', () => { + expect(lint('a_b_c', 'A long enough description.', 1, false).violations).toEqual([ + 'opaque_name', + ]); + // A standalone verb shorter than 4 chars is opaque; a real word is fine. + expect(lint('get', 'A long enough description.', 1, false).violations).toEqual(['opaque_name']); + expect(lint('get_user', 'Get a user by id.', 1, false).ok).toBe(true); + }); + + it('flags names that are not clean snake_case', () => { + for (const bad of ['GetUser', 'get-user', 'get user', '1get', 'get/user']) { + expect(lint(bad, 'A long enough description.', 1, false).violations).toContain('opaque_name'); + } + }); + + it('treats a short or blank description as missing', () => { + expect(lint('refund_order', 'too short', 1, false).violations).toContain('missing_description'); + expect(lint('refund_order', ' ', 1, false).violations).toContain('missing_description'); + }); + + it('flags more than seven parameters', () => { + expect(lint('refund_order', 'A long enough description.', 8, false).violations).toContain( + 'too_many_params', + ); + expect(lint('refund_order', 'A long enough description.', 7, false).ok).toBe(true); + }); +}); + +describe('lintCapability', () => { + it('counts the schema properties', () => { + const cap: Capability = { + name: 'x', + description: '', + input_schema: { + type: 'object', + properties: Object.fromEntries( + Array.from({ length: 9 }, (_, i) => [`p${i}`, { type: 'string' }]), + ), + }, + access: 'write', + confirm: false, + idempotent: false, + }; + const report = lintCapability(cap, false); + expect(report.violations).toContain('too_many_params'); + expect(report.violations).toContain('opaque_name'); // single-char name + expect(report.violations).toContain('missing_description'); + }); + + it('passes a well-formed capability', () => { + const cap: Capability = { + name: 'refund_order', + description: "Refund a customer's order by its public order number.", + input_schema: { type: 'object', properties: { order_id: { type: 'integer' } } }, + access: 'destructive', + confirm: true, + idempotent: false, + }; + expect(lintCapability(cap, false).ok).toBe(true); + }); +});