Skip to content

Commit 71cb5cb

Browse files
committed
Replace JsonSchemaObject alias with AnySchemaObject and validate \$metadata on load
- Remove local `JsonSchemaObject = Record<string, unknown>` type alias in favour of AnySchemaObject from ajv, consistent with schema-refs.ts - Add a compiled Ajv validator for OST_TOOLS_METADATA_SCHEMA at module level; readTopLevelMetadata now throws with a clear error on malformed \$metadata rather than silently casting to MetadataContract — this also covers the loadMetadata path which previously had no validation - Remove the redundant registry cast in loadSchema (already Map<string, AnySchemaObject>)
1 parent bff8703 commit 71cb5cb

1 file changed

Lines changed: 32 additions & 24 deletions

File tree

src/schema/schema.ts

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,19 +18,19 @@ import { isObject, resolveJsonPointer } from './schema-refs';
1818

1919
const packageDir = dirname(fileURLToPath(import.meta.url));
2020
export const bundledSchemasDir = join(packageDir, '..', '..', 'schemas');
21-
/** Parsed JSON schema object — always a plain object (never a boolean schema). */
22-
type JsonSchemaObject = Record<string, unknown>;
2321

24-
export function readRawSchema(schemaPath: string): JsonSchemaObject {
25-
return JSON5.parse(readFileSync(resolve(schemaPath), 'utf-8')) as JsonSchemaObject;
22+
const validateMetadataContract = new Ajv().compile(OST_TOOLS_METADATA_SCHEMA);
23+
24+
export function readRawSchema(schemaPath: string): AnySchemaObject {
25+
return JSON5.parse(readFileSync(resolve(schemaPath), 'utf-8')) as AnySchemaObject;
2626
}
2727

2828
/**
2929
* Build a registry of all schemas in the given directory, keyed by $id.
3030
* Only loads "partial" schemas (starting with _) and an optional target file.
3131
*/
32-
function buildSchemaRegistry(dir: string, targetFile?: string): Map<string, JsonSchemaObject> {
33-
const registry = new Map<string, JsonSchemaObject>();
32+
function buildSchemaRegistry(dir: string, targetFile?: string): Map<string, AnySchemaObject> {
33+
const registry = new Map<string, AnySchemaObject>();
3434
if (!existsSync(dir)) return registry;
3535
for (const file of readdirSync(dir)) {
3636
if (!file.endsWith('.json')) continue;
@@ -50,12 +50,12 @@ function buildSchemaRegistry(dir: string, targetFile?: string): Map<string, Json
5050
* - Layer 2: schema's own dir (partials + target file) — overrides layer 1
5151
* Does not throw on $id collision; layer 2 silently wins.
5252
*/
53-
export function buildFullRegistry(schemaPath: string): Map<string, JsonSchemaObject> {
53+
export function buildFullRegistry(schemaPath: string): Map<string, AnySchemaObject> {
5454
const absPath = resolve(schemaPath);
5555
const targetFile = basename(absPath);
5656
const targetDir = dirname(absPath);
5757

58-
const registry = new Map<string, JsonSchemaObject>();
58+
const registry = new Map<string, AnySchemaObject>();
5959

6060
// Layer 1: bundled schemas/ dir (partials only)
6161
for (const [id, schema] of buildSchemaRegistry(bundledSchemasDir)) {
@@ -79,16 +79,16 @@ export function buildFullRegistry(schemaPath: string): Map<string, JsonSchemaObj
7979
return registry;
8080
}
8181

82-
function compileValidator(targetSchema: JsonSchemaObject, registry: Map<string, JsonSchemaObject>): ValidateFunction {
82+
function compileValidator(targetSchema: AnySchemaObject, registry: Map<string, AnySchemaObject>): ValidateFunction {
8383
const ajv = new Ajv();
8484
ajv.addKeyword({
8585
keyword: '$metadata',
8686
schemaType: 'object',
87-
metaSchema: OST_TOOLS_METADATA_SCHEMA as unknown as JsonSchemaObject,
87+
metaSchema: OST_TOOLS_METADATA_SCHEMA as unknown as AnySchemaObject,
8888
valid: true,
8989
errors: false,
9090
});
91-
const metaSchema = OST_TOOLS_DIALECT_META_SCHEMA as unknown as JsonSchemaObject;
91+
const metaSchema = OST_TOOLS_DIALECT_META_SCHEMA as unknown as AnySchemaObject;
9292
ajv.addSchema(metaSchema, OST_TOOLS_SCHEMA_META_ID);
9393

9494
// Register all except target schema (AJV compiles targetSchema explicitly)
@@ -110,23 +110,31 @@ export function resolveNodeType(type: string, typeAliases: Record<string, string
110110

111111
interface MetadataProvider {
112112
schemaId: string;
113-
schema: JsonSchemaObject;
113+
schema: AnySchemaObject;
114114
metadata: MetadataContract;
115115
}
116116

117117
const RULE_CATEGORIES = new Set<RuleCategory>(['validation', 'coherence', 'workflow', 'best-practice']);
118118
const RULE_ALLOWED_KEYS = new Set(['id', 'category', 'description', 'check', 'type', 'scope', 'override']);
119119

120-
function readTopLevelMetadata(schema: JsonSchemaObject): MetadataContract | undefined {
120+
function readTopLevelMetadata(schema: AnySchemaObject): MetadataContract | undefined {
121121
const metadata = schema.$metadata;
122-
return isObject(metadata) ? (metadata as MetadataContract) : undefined;
122+
if (!isObject(metadata)) return undefined;
123+
if (!validateMetadataContract(metadata)) {
124+
const schemaId = typeof schema.$id === 'string' ? schema.$id : '(unknown schema)';
125+
const errors =
126+
validateMetadataContract.errors?.map((e) => `${e.instancePath || '(root)'} ${e.message}`).join('; ') ??
127+
'unknown error';
128+
throw new Error(`Invalid $metadata in schema "${schemaId}": ${errors}`);
129+
}
130+
return metadata as MetadataContract;
123131
}
124132

125133
function resolveRefTargetForRule(
126134
ref: string,
127-
currentRootSchema: JsonSchemaObject,
128-
registry: Map<string, JsonSchemaObject>,
129-
): { value: unknown; rootSchema: JsonSchemaObject; refKey: string } {
135+
currentRootSchema: AnySchemaObject,
136+
registry: Map<string, AnySchemaObject>,
137+
): { value: unknown; rootSchema: AnySchemaObject; refKey: string } {
130138
if (ref.startsWith('#')) {
131139
const pointer = ref.slice(1);
132140
const rootId = typeof currentRootSchema.$id === 'string' ? currentRootSchema.$id : '(root schema)';
@@ -176,13 +184,13 @@ function collectExternalRefIdsInOrder(schema: unknown): string[] {
176184
}
177185

178186
function collectMetadataProviders(
179-
rootSchema: JsonSchemaObject,
180-
registry: Map<string, JsonSchemaObject>,
187+
rootSchema: AnySchemaObject,
188+
registry: Map<string, AnySchemaObject>,
181189
): MetadataProvider[] {
182190
const providers: MetadataProvider[] = [];
183191
const visitedSchemaIds = new Set<string>();
184192

185-
const walk = (schema: JsonSchemaObject): void => {
193+
const walk = (schema: AnySchemaObject): void => {
186194
const refs = collectExternalRefIdsInOrder(schema);
187195
for (const schemaId of refs) {
188196
if (visitedSchemaIds.has(schemaId)) continue;
@@ -239,7 +247,7 @@ function isMetadataRule(value: unknown): value is Rule {
239247
function resolveRuleEntries(
240248
ruleEntry: RuleEntry,
241249
provider: MetadataProvider,
242-
registry: Map<string, JsonSchemaObject>,
250+
registry: Map<string, AnySchemaObject>,
243251
stack: Set<string>,
244252
): Rule[] {
245253
if (isMetadataRule(ruleEntry)) {
@@ -313,7 +321,7 @@ function areRulesEquivalent(left: Rule, right: Rule): boolean {
313321
return isDeepStrictEqual(normalizeRule(left), normalizeRule(right));
314322
}
315323

316-
function extractMetadata(schema: JsonSchemaObject, registry: Map<string, JsonSchemaObject>): SchemaMetadata {
324+
function extractMetadata(schema: AnySchemaObject, registry: Map<string, AnySchemaObject>): SchemaMetadata {
317325
const metadataProviders = collectMetadataProviders(schema, registry);
318326

319327
let hierarchyProvider: string | undefined;
@@ -420,10 +428,10 @@ export interface LoadedSchema {
420428
export function loadSchema(schemaPath: string): LoadedSchema {
421429
const rawSchema = readRawSchema(schemaPath);
422430
const registry = buildFullRegistry(schemaPath);
423-
const schema: SchemaWithMetadata = { ...rawSchema, metadata: extractMetadata(rawSchema, registry) };
431+
const schema = { ...rawSchema, metadata: extractMetadata(rawSchema, registry) } as unknown as SchemaWithMetadata;
424432
return {
425433
schema,
426-
registry: registry as Map<string, AnySchemaObject>,
434+
registry,
427435
validator: compileValidator(rawSchema, registry),
428436
};
429437
}

0 commit comments

Comments
 (0)