1- import { Ajv2020 } from 'ajv/dist/2020.js' ;
1+ import { Ajv2020 , type SchemaObject , type ValidateFunction } from 'ajv/dist/2020.js' ;
22import { identityKey , type PlanIdentity } from './identity.ts' ;
33import { parseV1 } from './parse-v1.ts' ;
44import registry from '../schema/versions.json' with { type : 'json' } ;
5- import planSchema from '../schema/versions/1/plan.schema.json' with { type : 'json' } ;
6- import editSchema from '../schema/versions/1/plan-edit.schema.json' with { type : 'json' } ;
5+ import planSchemaV1 from '../schema/versions/1/plan.schema.json' with { type : 'json' } ;
6+ import editSchemaV1 from '../schema/versions/1/plan-edit.schema.json' with { type : 'json' } ;
77
88export interface PlanFile {
99 path : string ; kind : 'add' | 'edit' | 'delete' | 'rename' ;
@@ -52,24 +52,139 @@ export class PlanError extends Error {
5252 this . diagnostics = diagnostics ;
5353 }
5454}
55- const ajv = new Ajv2020 ( { allErrors : true , strict : true } ) ;
56- const v1 = registry . versions [ '1' ] ;
57- if ( v1 . validator !== 'v1' || v1 . plan !== 'versions/1/plan.schema.json' ||
58- v1 . edit !== 'versions/1/plan-edit.schema.json' || v1 . semantics !== 'versions/1/semantics.md' )
59- throw new Error ( 'Unsupported v1 registry dispatch.' ) ;
60- const planShape = ajv . compile < Plan > ( planSchema ) ;
61- const replyShape = ajv . compile < EditReply > ( editSchema ) ;
55+ export interface SchemaRegistry {
56+ readonly current : number ;
57+ readonly versions : Readonly < Record < string , { readonly plan : string ; readonly edit : string ; readonly semantics : string ; readonly validator : string } > > ;
58+ }
59+ export type SemanticValidator = ( value : Plan , context : PlanContext ) => Validation ;
60+ /** One registered schema version: its compiled snapshots and the semantic validator its registry key selects. */
61+ export interface SchemaVersion {
62+ readonly version : number ;
63+ readonly paths : { readonly plan : string ; readonly edit : string } ;
64+ readonly plan : ValidateFunction < Plan > ;
65+ readonly edit : ValidateFunction < EditReply > ;
66+ /** The semantic checks. Call it only after this version's `plan` shape check, as `PlanSchemas.validate` does; it need
67+ * not repeat that check (v1's does, through the production registry). Null when no implementation exists for the
68+ * registry's validator key: such a version is unsupported. */
69+ readonly validate : SemanticValidator | null ;
70+ }
71+ export interface PlanSchemas {
72+ readonly current : SchemaVersion ;
73+ readonly versions : ReadonlyMap < number , SchemaVersion > ;
74+ /** The version a value declares, or the current version when it declares none that is registered. */
75+ select ( value : unknown ) : SchemaVersion ;
76+ /** Shape, then semantics, both from one selected version. */
77+ validate ( value : unknown , context : PlanContext ) : Validation ;
78+ /** Shape only, but a version with no validator implementation is refused as unsupported here too. */
79+ assertPlan ( value : unknown ) : asserts value is Plan ;
80+ /** The same for an edit reply, against its version's edit schema. */
81+ assertEditReply ( value : unknown ) : asserts value is EditReply ;
82+ }
83+ const SCHEMA_ID_BASE = 'https://github.com/codeabovelab/codeboost/schema/' ;
84+ /** Every retained snapshot, by registry path. A snapshot is used only when a registry entry names its path. */
85+ const RETAINED_SCHEMAS : Readonly < Record < string , SchemaObject > > = {
86+ 'versions/1/plan.schema.json' : planSchemaV1 , 'versions/1/plan-edit.schema.json' : editSchemaV1 ,
87+ } ;
88+ /** Each semantic validator, by registry key, with the normative document it implements. */
89+ export type SemanticValidators = Readonly < Record < string , { readonly semantics : string ; readonly validate : SemanticValidator } > > ;
90+ const SEMANTIC_VALIDATORS : SemanticValidators = {
91+ v1 : { semantics : 'versions/1/semantics.md' , validate : validateV1 } ,
92+ } ;
93+
94+ /**
95+ * Compile every registered version's plan and edit snapshots into one validator. Each snapshot path is named once
96+ * and each `$id` must equal its path, so every `$id` is registered exactly once. These are build defects and throw:
97+ * a registry key that is not a version number; a registry entry naming a path with no retained snapshot, or a
98+ * snapshot or semantics path already named in the registry; a snapshot whose `$id` does not match its path or whose
99+ * role (plan or edit) differs, or that does not require `schema_version` and accept only its own version; a
100+ * validator key or implementation used by two versions, or bound to another semantics document; a missing current
101+ * version. A validator key with no implementation loads, and validation reports that version as unsupported.
102+ */
103+ export function loadPlanSchemas ( source : SchemaRegistry , snapshots : Readonly < Record < string , SchemaObject > > = RETAINED_SCHEMAS ,
104+ validators : SemanticValidators = SEMANTIC_VALIDATORS ) : PlanSchemas {
105+ const ajv = new Ajv2020 ( { allErrors : true , strict : true } ) ;
106+ const versions = new Map < number , SchemaVersion > ( ) , named = new Set < string > ( ) , keys = new Set < string > ( ) ;
107+ const implementations = new Set < SemanticValidator > ( ) ;
108+ const compile = ( key : string , path : string , version : number , role : 'plan' | 'edit' ) => {
109+ // Each snapshot belongs to one role in one version. (Ajv would return its cached function for a shared one.)
110+ if ( named . has ( path ) ) throw new Error ( `Schema version ${ key } names ${ path } , which is already named in the registry.` ) ;
111+ named . add ( path ) ;
112+ const schema = Object . hasOwn ( snapshots , path ) ? snapshots [ path ] : undefined ;
113+ if ( ! schema ) throw new Error ( `Schema version ${ key } names ${ path } , which is not a retained snapshot.` ) ;
114+ if ( schema . $id !== SCHEMA_ID_BASE + path ) throw new Error ( `The retained snapshot at ${ path } has a different $id.` ) ;
115+ // Selection trusts the declared version only because each snapshot accepts that version and no other.
116+ const declared = ( schema . properties as { schema_version ?: { enum ?: unknown } } | undefined ) ?. schema_version ?. enum ;
117+ if ( ! Array . isArray ( schema . required ) || ! schema . required . includes ( 'schema_version' )
118+ || ! Array . isArray ( declared ) || declared . length !== 1 || declared [ 0 ] !== version )
119+ throw new Error ( `The retained snapshot at ${ path } must require schema_version and accept only ${ version } .` ) ;
120+ // A plan requires its items and not edits, and an edit reply the reverse, so an entry cannot swap the two roles.
121+ const [ own , other ] = role === 'plan' ? [ 'items' , 'edits' ] : [ 'edits' , 'items' ] ;
122+ if ( ! schema . required . includes ( own ) || schema . required . includes ( other ) )
123+ throw new Error ( `Schema version ${ key } names ${ path } as its ${ role } schema, which it is not.` ) ;
124+ return ajv . compile ( schema ) ;
125+ } ;
126+ for ( const [ key , entry ] of Object . entries ( source . versions ) ) {
127+ const version = Number ( key ) ;
128+ if ( ! / ^ [ 1 - 9 ] [ 0 - 9 ] * $ / . test ( key ) || ! Number . isSafeInteger ( version ) ) throw new Error ( `Schema version ${ key } is not a version number.` ) ;
129+ // Each version has a dedicated implementation; a later version never reuses an earlier one's validator.
130+ if ( keys . has ( entry . validator ) ) throw new Error ( `Schema version ${ key } reuses validator ${ entry . validator } .` ) ;
131+ keys . add ( entry . validator ) ;
132+ const implementation = Object . hasOwn ( validators , entry . validator ) ? validators [ entry . validator ] ! : null ;
133+ if ( implementation && implementation . semantics !== entry . semantics )
134+ throw new Error ( `Schema version ${ key } pairs validator ${ entry . validator } with ${ entry . semantics } , but it implements ${ implementation . semantics } .` ) ;
135+ if ( implementation && implementations . has ( implementation . validate ) )
136+ throw new Error ( `Schema version ${ key } reuses another version's validator implementation.` ) ;
137+ if ( implementation ) implementations . add ( implementation . validate ) ;
138+ if ( named . has ( entry . semantics ) ) throw new Error ( `Schema version ${ key } names ${ entry . semantics } , which is already named in the registry.` ) ;
139+ named . add ( entry . semantics ) ;
140+ versions . set ( version , Object . freeze ( { version, paths : Object . freeze ( { plan : entry . plan , edit : entry . edit } ) ,
141+ plan : compile ( key , entry . plan , version , 'plan' ) as ValidateFunction < Plan > ,
142+ edit : compile ( key , entry . edit , version , 'edit' ) as ValidateFunction < EditReply > ,
143+ validate : implementation ?. validate ?? null } ) ) ;
144+ }
145+ const current = versions . get ( source . current ) ;
146+ if ( ! current ) throw new Error ( `The current schema version ${ source . current } is not registered.` ) ;
147+ const select = ( value : unknown ) => {
148+ const declared = value !== null && typeof value === 'object' && Object . hasOwn ( value , 'schema_version' )
149+ ? ( value as { schema_version : unknown } ) . schema_version : undefined ;
150+ // An undeclared or unregistered version gets the current schema, whose diagnostics then name the bad field.
151+ return ( typeof declared === 'number' && versions . get ( declared ) ) || current ;
152+ } ;
153+ return Object . freeze ( { current, versions, select, validate ( value : unknown , context : PlanContext ) : Validation {
154+ const version = select ( value ) ;
155+ if ( ! version . plan ( value ) ) return { errors : shapeErrors ( version . plan , 'schema' ) , warnings : [ ] } ;
156+ if ( ! version . validate ) return { errors : [ unsupported ( version . version ) ] , warnings : [ ] } ;
157+ return version . validate ( value , context ) ;
158+ } , assertPlan ( value : unknown ) : asserts value is Plan {
159+ const version = select ( value ) ;
160+ if ( ! version . plan ( value ) ) throw new PlanError ( shapeErrors ( version . plan , 'schema' ) ) ;
161+ if ( ! version . validate ) throw new PlanError ( [ unsupported ( version . version ) ] ) ;
162+ } , assertEditReply ( value : unknown ) : asserts value is EditReply {
163+ const version = select ( value ) ;
164+ if ( ! version . edit ( value ) ) throw new PlanError ( shapeErrors ( version . edit , 'edit-schema' ) ) ;
165+ if ( ! version . validate ) throw new PlanError ( [ unsupported ( version . version ) ] ) ;
166+ } } ) ;
167+ }
168+ const unsupported = ( version : number ) : Diagnostic =>
169+ ( { code : 'version' , message : `Schema version ${ version } is not supported: codeboost has no semantic validator for it.` } ) ;
170+ function shapeErrors ( shape : ValidateFunction , code : string ) : Diagnostic [ ] {
171+ return ( shape . errors ?? [ ] ) . map ( e => ( { code, message : `${ e . instancePath || '/' } ${ e . message } ` } ) ) ;
172+ }
173+ const PLAN_SCHEMAS : PlanSchemas = loadPlanSchemas ( registry ) ;
174+ /** The registry path of a retained version's plan or edit schema, for passing the exact snapshot to a provider. */
175+ export function retainedSchemaPath ( version : number , kind : 'plan' | 'edit' ) : string {
176+ const entry = PLAN_SCHEMAS . versions . get ( version ) ;
177+ if ( ! entry ) throw new Error ( `Schema version ${ version } is not registered.` ) ;
178+ return entry . paths [ kind ] ;
179+ }
180+ export const CURRENT_SCHEMA_VERSION = PLAN_SCHEMAS . current . version ;
62181
63182function fail ( code : string , message : string ) : never { throw new PlanError ( [ { code, message } ] ) ; }
64183export function assertPlan ( value : unknown ) : asserts value is Plan {
65- if ( ! planShape ( value ) ) throw new PlanError ( ( planShape . errors ?? [ ] ) . map ( e => ( {
66- code : 'schema' , message : `${ e . instancePath || '/' } ${ e . message } ` ,
67- } ) ) ) ;
184+ PLAN_SCHEMAS . assertPlan ( value ) ;
68185}
69186export function assertEditReply ( value : unknown ) : asserts value is EditReply {
70- if ( ! replyShape ( value ) ) throw new PlanError ( ( replyShape . errors ?? [ ] ) . map ( e => ( {
71- code : 'edit-schema' , message : `${ e . instancePath || '/' } ${ e . message } ` ,
72- } ) ) ) ;
187+ PLAN_SCHEMAS . assertEditReply ( value ) ;
73188}
74189/** Canonical paths make duplicate checks reliable and exclude Git metadata. */
75190export function isRepoPath ( path : string ) : boolean {
@@ -128,14 +243,7 @@ function linkTarget(path: string, target: string, key: (path: string) => string,
128243}
129244
130245export function validatePlan ( value : unknown , context : PlanContext ) : Validation {
131- try { assertPlan ( value ) ; } catch ( error ) {
132- if ( error instanceof PlanError ) return { errors : error . diagnostics , warnings : [ ] } ;
133- throw error ;
134- }
135- const validators : Record < string , ( value : Plan , context : PlanContext ) => Validation > = { v1 : validateV1 } ;
136- const validator = validators [ registry . versions [ value . schema_version ] . validator ] ;
137- if ( ! validator ) return { errors : [ { code : 'version' , message : 'Unsupported semantic validator.' } ] , warnings : [ ] } ;
138- return validator ( value , context ) ;
246+ return PLAN_SCHEMAS . validate ( value , context ) ;
139247}
140248
141249/** Validate only work still to run, starting with the runner-audited tree at its checkpoint. */
@@ -313,7 +421,10 @@ export function importPlan(source: string | Uint8Array, format: 'json' | 'yaml',
313421 let data : unknown ;
314422 try { data = parseV1 ( source , format ) ; }
315423 catch ( error ) { fail ( 'parse' , `Cannot parse plan: ${ ( error as Error ) . message } ` ) ; }
316- assertPlan ( data ) ; // No migrations exist yet: only released v1 is accepted.
424+ assertPlan ( data ) ;
425+ // No migrations exist yet: an import must already be in the current version.
426+ if ( data . schema_version !== PLAN_SCHEMAS . current . version )
427+ fail ( 'version' , `Plans are imported in schema version ${ PLAN_SCHEMAS . current . version } ; no migration from version ${ data . schema_version } exists.` ) ;
317428 const plan = { ...data , revision } ;
318429 const result = completedItems === undefined ? validatePlan ( data , context ) : validateContinuationPlan ( plan , context , completedItems , true ) ;
319430 if ( result . errors . length ) throw new PlanError ( result . errors ) ;
@@ -340,6 +451,8 @@ export function applySuggestion(plan: Plan, reply: unknown, index: number, conte
340451 binding . schemaVersion !== plan . schema_version || binding . baseRevision !== reply . base_revision ||
341452 binding . issue !== plan . issue || context . issue !== plan . issue )
342453 fail ( 'suggestion-identity' , 'Suggestion does not belong to this plan context.' ) ;
454+ // The reply was checked by its own version's edit schema, which must be the plan's.
455+ if ( reply . schema_version !== plan . schema_version ) fail ( 'version' , 'Suggestion uses a different schema version than the plan.' ) ;
343456 if ( reply . base_revision !== plan . revision ) fail ( 'stale-revision' , 'Suggestion was drafted against a different revision.' ) ;
344457 if ( ! Number . isInteger ( index ) || ! reply . edits [ index ] ) fail ( 'edit-index' , 'Suggestion index is out of range.' ) ;
345458 const edit = reply . edits [ index ] ! ;
0 commit comments