structured-context 0.18.0 → 0.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -10,8 +10,8 @@ export interface DumpOptions {
10
10
  /**
11
11
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
12
12
  *
13
- * Unfiltered dumps include schema-invalid nodes, since dump is a debugging aid.
14
- * Filtered dumps match show/render: the filter runs over the graph of schema-valid nodes.
13
+ * Unfiltered dumps include nodes of an unknown type, since dump is a debugging aid.
14
+ * Filtered dumps match show/render: the filter runs over the graph of nodes with a known type.
15
15
  */
16
16
  export declare function buildDump(context: SpaceContext, options?: DumpOptions): Promise<ReadSpaceResult>;
17
17
  export declare function dump(context: SpaceContext, options?: DumpOptions): Promise<void>;
@@ -4,8 +4,8 @@ import { exportSpaceGraph } from '../space-graph-data';
4
4
  /**
5
5
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
6
6
  *
7
- * Unfiltered dumps include schema-invalid nodes, since dump is a debugging aid.
8
- * Filtered dumps match show/render: the filter runs over the graph of schema-valid nodes.
7
+ * Unfiltered dumps include nodes of an unknown type, since dump is a debugging aid.
8
+ * Filtered dumps match show/render: the filter runs over the graph of nodes with a known type.
9
9
  */
10
10
  export async function buildDump(context, options = {}) {
11
11
  const readResult = await readSpace(context);
@@ -5,9 +5,8 @@ import { judgeForRun } from '../judge/factory';
5
5
  import { resolveSpacePluginSchemas } from '../plugins/util';
6
6
  import { parseRuleFilter } from '../schema/rule-filter';
7
7
  import { bundledSchemasDir } from '../schema/schema';
8
- import { formatErrors, formatSource, ruleViolationMessage, validateSpace } from '../validate';
8
+ import { formatSource, ruleViolationMessage, validateSpace } from '../validate';
9
9
  export async function validate(context, options = {}) {
10
- const { schema, schemaRefRegistry } = context;
11
10
  const result = await validateSpace(context, {
12
11
  depth: options.depth,
13
12
  includeGated: options.includeGated,
@@ -25,9 +24,8 @@ export async function validate(context, options = {}) {
25
24
  errorsByFile[file] = {};
26
25
  errorsByFile[file][key] = severity ? { kind, message, severity } : { kind, message };
27
26
  };
28
- for (const { nodeId, errors: ajvErrors, nodeData } of result.nodeErrors) {
29
- const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, nodeData);
30
- for (const { message, dedupeKey } of formatted) {
27
+ for (const { nodeId, issues } of result.nodeErrors) {
28
+ for (const { message, dedupeKey } of issues) {
31
29
  addError(nodeId, `schema:${dedupeKey}`, 'schema', message);
32
30
  }
33
31
  }
@@ -119,10 +117,9 @@ export async function validate(context, options = {}) {
119
117
  }
120
118
  if (result.nodeErrors.length > 0) {
121
119
  console.log(`\nSchema validation errors:`);
122
- result.nodeErrors.forEach(({ nodeId, source, errors, nodeData }) => {
120
+ result.nodeErrors.forEach(({ nodeId, source, issues }) => {
123
121
  console.log(`\n ${source ? formatSource(source) : nodeId}:`);
124
- const formatted = formatErrors(errors, schema, schemaRefRegistry, nodeData);
125
- formatted.forEach(({ message }) => {
122
+ issues.forEach(({ message }) => {
126
123
  console.log(` ${message}`);
127
124
  });
128
125
  });
@@ -10,14 +10,14 @@ export interface AssembleSpaceGraphOptions {
10
10
  readResult?: ReadSpaceResult;
11
11
  }
12
12
  /**
13
- * Assemble a SpaceGraph from a space context: read the space, drop nodes that
14
- * fail schema validation, build the graph for the selected hierarchy (the main
15
- * hierarchy by default), and apply an optional view/filter expression.
13
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes whose type the schema
14
+ * does not define, build the graph for the selected hierarchy (the main hierarchy by default),
15
+ * and apply an optional view/filter expression.
16
16
  *
17
- * Nodes that fail schema validation are silently excluded so the graph is
18
- * well-formed enough to render and traverse. This is NOT a validation pass —
19
- * schema-valid nodes may still have broken references, rule violations, or
20
- * duplicates. Use validateSpace for a full validation report.
17
+ * A node with schema field errors (for example an invalid enum value) stays in the graph, since
18
+ * it still has a type and a place in the hierarchy. Only nodes that cannot be placed are left
19
+ * out. This is NOT a validation pass — nodes may have field errors, broken references, rule
20
+ * violations, or duplicates. Use validateSpace for a full validation report.
21
21
  *
22
22
  * Shared by the render pipeline and the public loadSpaceGraph API so their
23
23
  * graph-assembly behaviour cannot drift.
@@ -1,25 +1,29 @@
1
1
  import { filterNodes } from './filter/filter-nodes';
2
2
  import { readSpace } from './read/read-space';
3
+ import { extractSchemaTypeNames } from './schema/schema';
3
4
  import { createSpaceContext } from './space-context';
4
5
  import { buildSpaceGraph, selectHierarchy } from './space-graph';
5
6
  /**
6
- * Assemble a SpaceGraph from a space context: read the space, drop nodes that
7
- * fail schema validation, build the graph for the selected hierarchy (the main
8
- * hierarchy by default), and apply an optional view/filter expression.
7
+ * Assemble a SpaceGraph from a space context: read the space, drop nodes whose type the schema
8
+ * does not define, build the graph for the selected hierarchy (the main hierarchy by default),
9
+ * and apply an optional view/filter expression.
9
10
  *
10
- * Nodes that fail schema validation are silently excluded so the graph is
11
- * well-formed enough to render and traverse. This is NOT a validation pass —
12
- * schema-valid nodes may still have broken references, rule violations, or
13
- * duplicates. Use validateSpace for a full validation report.
11
+ * A node with schema field errors (for example an invalid enum value) stays in the graph, since
12
+ * it still has a type and a place in the hierarchy. Only nodes that cannot be placed are left
13
+ * out. This is NOT a validation pass — nodes may have field errors, broken references, rule
14
+ * violations, or duplicates. Use validateSpace for a full validation report.
14
15
  *
15
16
  * Shared by the render pipeline and the public loadSpaceGraph API so their
16
17
  * graph-assembly behaviour cannot drift.
17
18
  */
18
19
  export async function assembleSpaceGraph(context, options = {}) {
19
20
  const { nodes: allNodes } = options.readResult ?? (await readSpace(context));
20
- const { schemaValidator } = context;
21
- const validNodes = allNodes.filter((node) => schemaValidator(node.schemaData));
22
- let graph = buildSpaceGraph(validNodes, selectHierarchy(context.schema.metadata, options.hierarchy));
21
+ const { schema, schemaRefRegistry } = context;
22
+ const schemaTypes = Array.isArray(schema.oneOf) ? extractSchemaTypeNames(schema, schemaRefRegistry) : undefined;
23
+ const placeableNodes = schemaTypes
24
+ ? allNodes.filter((node) => schemaTypes.has(String(node.schemaData.type)))
25
+ : allNodes;
26
+ let graph = buildSpaceGraph(placeableNodes, selectHierarchy(context.schema.metadata, options.hierarchy));
23
27
  if (options.filter) {
24
28
  const expression = context.space.views?.[options.filter]?.expression ?? options.filter;
25
29
  graph = await filterNodes(expression, graph);
@@ -17,6 +17,12 @@ export type PluginSchemaSource = {
17
17
  * Throws if a lower-precedence schema collides with a reserved $id or violates namespace rules.
18
18
  */
19
19
  export declare function buildFullRegistry(schemaPath: string, pluginSchemas?: PluginSchemaSource[]): Map<string, AnySchemaObject>;
20
+ /**
21
+ * Validator for one node type: the schema's shared `allOf` plus only that type's `oneOf` branch.
22
+ * Errors then come only from the node's own type, not from every branch that did not match.
23
+ * Returns undefined when the schema has no such branch, so callers fall back to the full validator.
24
+ */
25
+ export declare function typeValidatorFor(fullValidator: ValidateFunction, type: string): ValidateFunction | undefined;
20
26
  export declare function createValidator(schemaPath: string, pluginSchemas?: PluginSchemaSource[]): ValidateFunction;
21
27
  export declare function resolveNodeType(type: string, typeAliases: Record<string, string> | undefined): string;
22
28
  export interface EntityInfo {
@@ -234,7 +234,68 @@ function compileValidator(targetSchema, schemaRefRegistry) {
234
234
  continue;
235
235
  ajv.addSchema(schema);
236
236
  }
237
- return ajv.compile(targetSchema);
237
+ const validator = ajv.compile(targetSchema);
238
+ typeValidators.set(validator, createTypeValidatorLookup(ajv, targetSchema, schemaRefRegistry));
239
+ return validator;
240
+ }
241
+ /** Root keywords that do not constrain a node: a root with any other keyword cannot be split into per-type checks. */
242
+ const NON_CONSTRAINING_ROOT_KEYWORDS = new Set([
243
+ '$schema',
244
+ '$id',
245
+ '$metadata',
246
+ '$defs',
247
+ 'title',
248
+ 'description',
249
+ 'allOf',
250
+ 'oneOf',
251
+ ]);
252
+ /** Per-type lookups, keyed by the full-schema validator they were compiled alongside. */
253
+ const typeValidators = new WeakMap();
254
+ /**
255
+ * Validator for one node type: the schema's shared `allOf` plus only that type's `oneOf` branch.
256
+ * Errors then come only from the node's own type, not from every branch that did not match.
257
+ * Returns undefined when the schema has no such branch, so callers fall back to the full validator.
258
+ */
259
+ export function typeValidatorFor(fullValidator, type) {
260
+ return typeValidators.get(fullValidator)?.(type);
261
+ }
262
+ /**
263
+ * Compiled on first use. The wrapper references the branches inside the already-compiled root,
264
+ * so a branch's relative `$ref`s still resolve against the root.
265
+ */
266
+ function createTypeValidatorLookup(ajv, rootSchema, schemaRefRegistry) {
267
+ const rootId = rootSchema.$id;
268
+ if (typeof rootId !== 'string' ||
269
+ !Array.isArray(rootSchema.oneOf) ||
270
+ !Object.keys(rootSchema).every((k) => NON_CONSTRAINING_ROOT_KEYWORDS.has(k))) {
271
+ return () => undefined;
272
+ }
273
+ const branchByType = new Map();
274
+ rootSchema.oneOf.forEach((branch, index) => {
275
+ const typeDef = mergeVariantProperties(branch, rootSchema, schemaRefRegistry).properties.type;
276
+ const types = typeDef?.const !== undefined ? [typeDef.const] : Array.isArray(typeDef?.enum) ? typeDef.enum : [];
277
+ for (const t of types)
278
+ if (!branchByType.has(String(t)))
279
+ branchByType.set(String(t), index);
280
+ });
281
+ const shared = Array.isArray(rootSchema.allOf)
282
+ ? rootSchema.allOf.map((_, i) => ({ $ref: `${rootId}#/allOf/${i}` }))
283
+ : [];
284
+ const compiled = new Map();
285
+ return (type) => {
286
+ const index = branchByType.get(type);
287
+ if (index === undefined)
288
+ return undefined;
289
+ let validator = compiled.get(index);
290
+ if (!validator) {
291
+ validator = ajv.compile({
292
+ $id: `${rootId}/__type/${index}`,
293
+ allOf: [...shared, { $ref: `${rootId}#/oneOf/${index}` }],
294
+ });
295
+ compiled.set(index, validator);
296
+ }
297
+ return validator;
298
+ };
238
299
  }
239
300
  export function createValidator(schemaPath, pluginSchemas) {
240
301
  return compileValidator(readRawSchema(schemaPath), buildFullRegistry(schemaPath, pluginSchemas));
@@ -14,8 +14,8 @@ export interface ValidationResult {
14
14
  nodeErrors: Array<{
15
15
  nodeId: NodeId;
16
16
  source?: SourceLocation;
17
- errors: ErrorObject[];
18
- nodeData: Record<string, unknown>;
17
+ /** Formatted, deduplicated schema problems for the node, one per field problem. */
18
+ issues: FormattedError[];
19
19
  }>;
20
20
  refErrors: Array<{
21
21
  nodeId: NodeId;
package/dist/validate.js CHANGED
@@ -2,7 +2,7 @@ import { isAbsolute, relative, resolve } from 'node:path';
2
2
  import { readSpace } from './read/read-space';
3
3
  import { computeHierarchyDepths, evaluateRulesWithGating } from './schema/gating';
4
4
  import { filterRules } from './schema/rule-filter';
5
- import { extractEntityInfo } from './schema/schema';
5
+ import { extractEntityInfo, typeValidatorFor } from './schema/schema';
6
6
  import { validateGraph } from './schema/validate-graph';
7
7
  import { validateRules } from './schema/validate-rules';
8
8
  import { createSpaceContext } from './space-context';
@@ -177,31 +177,31 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
177
177
  contextTrimmed: { nodeCount: 0, ruleIds: [] },
178
178
  };
179
179
  for (const node of nodes) {
180
- // Early type validation - check before full AJV validation to prevent cascading errors
180
+ // Check the type first: an unknown type reports one error instead of one per oneOf branch.
181
181
  const nodeType = node.schemaData.type;
182
182
  if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
183
183
  const sortedTypes = [...validTypes].sort();
184
184
  result.nodeFailures.push({
185
185
  node,
186
- invalidType: { validTypes },
187
- errors: [
186
+ issues: [
188
187
  {
189
- instancePath: '/type',
190
- keyword: 'enum',
191
188
  message: `Invalid type "${nodeType}". Valid types are: ${sortedTypes.join(', ')}`,
192
- params: { allowedValues: sortedTypes },
193
- schemaPath: '#/oneOf',
194
- data: nodeType,
189
+ dedupeKey: `type:${sortedTypes.join(',')}`,
195
190
  },
196
191
  ],
197
192
  });
198
193
  continue;
199
194
  }
200
- if (schemaValidator(node.schemaData)) {
195
+ // A valid type is checked against its own branch only, so errors come from that type alone.
196
+ const validate = (nodeType !== undefined && typeValidatorFor(schemaValidator, nodeType)) || schemaValidator;
197
+ if (validate(node.schemaData)) {
201
198
  result.validCount++;
202
199
  }
203
200
  else {
204
- result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
201
+ result.nodeFailures.push({
202
+ node,
203
+ issues: formatErrors(validate.errors || [], schema, schemaRefRegistry, node.schemaData),
204
+ });
205
205
  }
206
206
  }
207
207
  result.duplicateErrors.push(...findDuplicateIds(nodes));
@@ -276,11 +276,10 @@ export async function validateSpace(context, options = {}) {
276
276
  return {
277
277
  validCount: result.validCount,
278
278
  nodeErrorCount: result.nodeFailures.length,
279
- nodeErrors: result.nodeFailures.map(({ node, errors }) => ({
279
+ nodeErrors: result.nodeFailures.map(({ node, issues }) => ({
280
280
  nodeId: node.id,
281
281
  ...(node.source !== undefined && { source: node.source }),
282
- errors,
283
- nodeData: node.schemaData,
282
+ issues,
284
283
  })),
285
284
  refErrors: result.refErrors,
286
285
  duplicateErrors: result.duplicateErrors,
@@ -312,27 +311,17 @@ export function resolveFileSpace(filePath, config) {
312
311
  * Select one file's entries from a space's validation result: issues keyed and formatted, errors split from warnings.
313
312
  * Pure — a file's entries are exactly its slice of the result.
314
313
  */
315
- function issuesForFile(result, label, context, includeGated) {
316
- const { schema, schemaRefRegistry } = context;
314
+ function issuesForFile(result, label, includeGated) {
317
315
  const errors = {};
318
316
  const warnings = {};
319
- for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
317
+ for (const { node, issues } of result.nodeFailures) {
320
318
  if (node.id !== label)
321
319
  continue;
322
320
  const position = {
323
321
  ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
324
322
  ...(node.source?.column !== undefined ? { column: node.source.column } : {}),
325
323
  };
326
- if (invalidType) {
327
- errors[`schema:type:${invalidType.validTypes.join(',')}`] = {
328
- kind: 'schema',
329
- message: ajvErrors[0].message,
330
- ...position,
331
- };
332
- continue;
333
- }
334
- const formatted = formatErrors(ajvErrors, schema, schemaRefRegistry, node.schemaData);
335
- for (const { message, dedupeKey } of formatted) {
324
+ for (const { message, dedupeKey } of issues) {
336
325
  errors[`schema:${dedupeKey}`] = { kind: 'schema', message, ...position };
337
326
  }
338
327
  }
@@ -409,7 +398,7 @@ export async function validateResolvedFile(filePath, label, context, options = {
409
398
  judge: options.judge,
410
399
  judgeTarget: label,
411
400
  });
412
- const { errors, warnings } = issuesForFile(result, label, context, options.includeGated);
401
+ const { errors, warnings } = issuesForFile(result, label, options.includeGated);
413
402
  return {
414
403
  file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
415
404
  label,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-context",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Keep your content clean and your thinking clear with assistive tools for structured knowledge",
5
5
  "module": "./dist/index.js",
6
6
  "type": "module",