structured-context 0.18.0 → 0.20.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.
package/dist/api.d.ts CHANGED
@@ -23,6 +23,7 @@ export type { Config, SpaceConfig } from './config';
23
23
  export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
24
24
  export { addSpace } from './config/write';
25
25
  export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
26
+ export { SctxError, type SctxErrorCode } from './errors';
26
27
  export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
27
28
  export type { ParseHook, ParseResult, PluginContext, RenderFileOutput, RenderFormat, RenderHook, RenderMultiFileOutput, RenderOptions, RenderResult, StructuredContextPlugin, TemplateSyncHook, TemplateSyncOptions, } from './plugins/util';
28
29
  export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
package/dist/api.js CHANGED
@@ -22,6 +22,7 @@ export { spaceGraphFrom } from './browser';
22
22
  export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
23
23
  export { addSpace } from './config/write';
24
24
  export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
25
+ export { SctxError } from './errors';
25
26
  export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
26
27
  export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
27
28
  export { readSpace } from './read/read-space';
@@ -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);
@@ -1,3 +1,4 @@
1
+ import { type ValidateFileOptions } from '../validate';
1
2
  export { validateFile } from '../validate';
2
3
  /**
3
4
  * CLI command: validate a file and print results to stdout.
@@ -6,7 +7,4 @@ export { validateFile } from '../validate';
6
7
  */
7
8
  export declare function validateFileCommand(filePath: string, options?: {
8
9
  json?: boolean;
9
- includeGated?: boolean;
10
- semantic?: boolean | string;
11
- ruleFilter?: string[];
12
- }): Promise<number>;
10
+ } & Omit<ValidateFileOptions, 'configDir'>): Promise<number>;
@@ -1,6 +1,4 @@
1
1
  import { loadConfig } from '../config';
2
- import { judgeForRun } from '../judge/factory';
3
- import { parseRuleFilter } from '../schema/rule-filter';
4
2
  import { createSpaceContext } from '../space-context';
5
3
  import { fileNotInSpace, resolveFileSpace, validateResolvedFile } from '../validate';
6
4
  export { validateFile } from '../validate';
@@ -14,12 +12,7 @@ export async function validateFileCommand(filePath, options = {}) {
14
12
  const resolved = resolveFileSpace(filePath, config);
15
13
  let result;
16
14
  if (resolved) {
17
- const judge = judgeForRun(config, resolved.space, options.semantic);
18
- result = await validateResolvedFile(filePath, resolved.label, createSpaceContext(resolved.space.name, config), {
19
- includeGated: options.includeGated,
20
- judge,
21
- ruleFilter: options.ruleFilter && parseRuleFilter(options.ruleFilter),
22
- });
15
+ result = await validateResolvedFile(filePath, resolved.label, createSpaceContext(resolved.space.name, config), options);
23
16
  }
24
17
  else {
25
18
  result = fileNotInSpace(filePath);
@@ -1,9 +1,6 @@
1
1
  import type { SpaceContext } from '../types';
2
+ import { type ValidateSpaceOptions } from '../validate';
2
3
  export declare function validate(context: SpaceContext, options?: {
3
4
  json?: boolean;
4
- depth?: number;
5
- includeGated?: boolean;
6
- semantic?: boolean | string;
7
- ruleFilter?: string[];
8
- }): Promise<number>;
5
+ } & ValidateSpaceOptions): Promise<number>;
9
6
  export declare function watchValidate(context: SpaceContext): Promise<never>;
@@ -1,19 +1,11 @@
1
1
  import { dirname } from 'node:path';
2
2
  import chokidar from 'chokidar';
3
3
  import { getConfigSourceFiles } from '../config';
4
- import { judgeForRun } from '../judge/factory';
5
4
  import { resolveSpacePluginSchemas } from '../plugins/util';
6
- import { parseRuleFilter } from '../schema/rule-filter';
7
5
  import { bundledSchemasDir } from '../schema/schema';
8
- import { formatErrors, formatSource, ruleViolationMessage, validateSpace } from '../validate';
6
+ import { formatSource, ruleViolationMessage, validateSpace } from '../validate';
9
7
  export async function validate(context, options = {}) {
10
- const { schema, schemaRefRegistry } = context;
11
- const result = await validateSpace(context, {
12
- depth: options.depth,
13
- includeGated: options.includeGated,
14
- judge: judgeForRun(context.config, context.space, options.semantic),
15
- ruleFilter: options.ruleFilter && parseRuleFilter(options.ruleFilter),
16
- });
8
+ const result = await validateSpace(context, options);
17
9
  const ruleCounts = { error: 0, warning: 0, info: 0 };
18
10
  for (const v of result.ruleViolations)
19
11
  ruleCounts[v.severity]++;
@@ -25,9 +17,8 @@ export async function validate(context, options = {}) {
25
17
  errorsByFile[file] = {};
26
18
  errorsByFile[file][key] = severity ? { kind, message, severity } : { kind, message };
27
19
  };
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) {
20
+ for (const { nodeId, issues } of result.nodeErrors) {
21
+ for (const { message, dedupeKey } of issues) {
31
22
  addError(nodeId, `schema:${dedupeKey}`, 'schema', message);
32
23
  }
33
24
  }
@@ -119,10 +110,9 @@ export async function validate(context, options = {}) {
119
110
  }
120
111
  if (result.nodeErrors.length > 0) {
121
112
  console.log(`\nSchema validation errors:`);
122
- result.nodeErrors.forEach(({ nodeId, source, errors, nodeData }) => {
113
+ result.nodeErrors.forEach(({ nodeId, source, issues }) => {
123
114
  console.log(`\n ${source ? formatSource(source) : nodeId}:`);
124
- const formatted = formatErrors(errors, schema, schemaRefRegistry, nodeData);
125
- formatted.forEach(({ message }) => {
115
+ issues.forEach(({ message }) => {
126
116
  console.log(` ${message}`);
127
117
  });
128
118
  });
package/dist/errors.d.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Stable codes for configuration and environment problems. Hooks and agent skills map a code to a
3
3
  * fix, so the message wording can change without breaking them.
4
4
  */
5
- export type SctxErrorCode = 'E_CONFIG_NOT_FOUND' | 'E_CONFIG_INVALID' | 'E_INCLUDE_NOT_FOUND' | 'E_NO_SPACES' | 'E_SPACE_PATH_MISSING' | 'E_SCHEMA_MISSING' | 'E_SCHEMA_NOT_FOUND' | 'E_SCHEMA_INVALID' | 'E_SPACE_EXISTS' | 'E_TARGET_REQUIRED' | 'E_CONFIG_NEEDS_MANUAL_EDIT';
5
+ export type SctxErrorCode = 'E_CONFIG_NOT_FOUND' | 'E_CONFIG_INVALID' | 'E_INCLUDE_NOT_FOUND' | 'E_NO_SPACES' | 'E_SPACE_PATH_MISSING' | 'E_SCHEMA_MISSING' | 'E_SCHEMA_NOT_FOUND' | 'E_SCHEMA_INVALID' | 'E_SPACE_EXISTS' | 'E_TARGET_REQUIRED' | 'E_CONFIG_NEEDS_MANUAL_EDIT' | 'E_MODEL_PROVIDER_INVALID';
6
6
  export declare class SctxError extends Error {
7
7
  readonly code: SctxErrorCode;
8
8
  constructor(code: SctxErrorCode, message: string);
package/dist/index.js CHANGED
@@ -17,7 +17,6 @@ import { validateFileCommand } from './commands/validate-file';
17
17
  import { loadConfig, setConfigPath } from './config';
18
18
  import { CLI_NAME } from './constants';
19
19
  import { SctxError } from './errors';
20
- import { JudgeConfigError } from './judge/factory';
21
20
  import { createSpaceContext, SpaceNotFoundError } from './space-context';
22
21
  // Once node:process is loaded (vfile, via unified, loads it), Bun's console.log drops piped output
23
22
  // past 64KB when the reader is slow. process.stdout.write delivers all of it before exit.
@@ -36,18 +35,6 @@ export function buildSpaceContext(spaceName) {
36
35
  throw err;
37
36
  }
38
37
  }
39
- /** Run a validate command, reporting a misconfigured model provider as a plain error rather than a stack trace. */
40
- async function reportJudgeConfigErrors(run) {
41
- try {
42
- return await run();
43
- }
44
- catch (err) {
45
- if (!(err instanceof JudgeConfigError))
46
- throw err;
47
- console.error(`Error: ${err.message}`);
48
- return 1;
49
- }
50
- }
51
38
  const require = createRequire(import.meta.url);
52
39
  const packageJson = require('../package.json');
53
40
  const program = new Command();
@@ -68,12 +55,12 @@ program
68
55
  .option('--rule-filter <dimension=values>', 'Evaluate only matching rules; dimensions: category, severity, type, id (repeatable, e.g. severity=error)', (v, prev = []) => [...prev, v])
69
56
  .option('--semantic [provider]', 'Evaluate semantic rules, with the configured model provider or the one named')
70
57
  .action(async (filePath, options) => {
71
- process.exitCode = await reportJudgeConfigErrors(() => validateFileCommand(filePath, {
58
+ process.exitCode = await validateFileCommand(filePath, {
72
59
  json: options.json,
73
60
  includeGated: options.includeGated,
74
61
  semantic: options.semantic,
75
62
  ruleFilter: options.ruleFilter,
76
- }));
63
+ });
77
64
  });
78
65
  program
79
66
  .command('validate')
@@ -92,13 +79,13 @@ program
92
79
  }
93
80
  else {
94
81
  // exitCode rather than process.exit(): exiting straight away can cut off large piped output
95
- process.exitCode = await reportJudgeConfigErrors(() => validate(context, {
82
+ process.exitCode = await validate(context, {
96
83
  json: options.json,
97
84
  depth: options.depth,
98
85
  includeGated: options.includeGated,
99
86
  semantic: options.semantic,
100
87
  ruleFilter: options.ruleFilter,
101
- }));
88
+ });
102
89
  }
103
90
  });
104
91
  program
@@ -7,8 +7,6 @@ type Env = Record<string, string | undefined>;
7
7
  */
8
8
  export declare const BUNDLED_PROVIDERS: Record<string, ModelProviderConfig>;
9
9
  /** Semantic evaluation is misconfigured (no provider, unknown provider, missing key). Reported as a plain CLI error. */
10
- export declare class JudgeConfigError extends Error {
11
- }
12
10
  /**
13
11
  * Hydrate a provider definition into concrete values, in one place. Per field: the explicit value
14
12
  * wins, then the named environment variable if it is set (a set-but-empty variable is an error, not
@@ -29,7 +27,7 @@ export declare function judgeForRun(config: Parameters<typeof createJudge>[0], s
29
27
  /**
30
28
  * The provider for a run: the one named by `name` (the `--semantic <provider>` override), else the space's
31
29
  * `semantic.provider`, else the top-level one. The name resolves against `modelProviders` first, then
32
- * the bundled providers. Throws {@link JudgeConfigError} when it cannot be built.
30
+ * the bundled providers. Throws {@link SctxError} `E_MODEL_PROVIDER_INVALID` when it cannot be built.
33
31
  */
34
32
  export declare function createJudge(config: {
35
33
  modelProviders?: Record<string, ModelProviderConfig>;
@@ -1,3 +1,4 @@
1
+ import { SctxError } from '../errors';
1
2
  import { AnthropicJudgeProvider, RECOMMENDED_ANTHROPIC_MODEL } from './provider/anthropic';
2
3
  import { FakeJudgeProvider } from './provider/fake';
3
4
  import { JEV_LATEST, JevJudgeProvider } from './provider/jev';
@@ -66,8 +67,6 @@ const DEFAULT_KEY_ENV = {
66
67
  'typesafe-systemone': 'TYPESAFE_API_KEY',
67
68
  };
68
69
  /** Semantic evaluation is misconfigured (no provider, unknown provider, missing key). Reported as a plain CLI error. */
69
- export class JudgeConfigError extends Error {
70
- }
71
70
  /**
72
71
  * Hydrate a provider definition into concrete values, in one place. Per field: the explicit value
73
72
  * wins, then the named environment variable if it is set (a set-but-empty variable is an error, not
@@ -75,7 +74,7 @@ export class JudgeConfigError extends Error {
75
74
  */
76
75
  export function hydrateProvider(name, def, env = process.env) {
77
76
  if (!MODEL_PROVIDER_APIS.includes(def.api)) {
78
- throw new JudgeConfigError(`Model provider "${name}" has unknown api "${def.api}". Use one of: ${MODEL_PROVIDER_APIS.join(', ')}.`);
77
+ throw new SctxError('E_MODEL_PROVIDER_INVALID', `Model provider "${name}" has unknown api "${def.api}". Use one of: ${MODEL_PROVIDER_APIS.join(', ')}.`);
79
78
  }
80
79
  const fromEnv = (value, envName) => {
81
80
  if (value !== undefined)
@@ -84,7 +83,7 @@ export function hydrateProvider(name, def, env = process.env) {
84
83
  return undefined;
85
84
  const envValue = env[envName];
86
85
  if (envValue !== undefined && envValue.trim() === '') {
87
- throw new JudgeConfigError(`Model provider "${name}" has ${envName} set but empty; set a value or unset the variable.`);
86
+ throw new SctxError('E_MODEL_PROVIDER_INVALID', `Model provider "${name}" has ${envName} set but empty; set a value or unset the variable.`);
88
87
  }
89
88
  return envValue;
90
89
  };
@@ -104,7 +103,7 @@ export function createModelProvider(name, def, env = process.env) {
104
103
  // OpenAI-compatible endpoints (Ollama, vLLM, ...) may need no key; the others must resolve one.
105
104
  // A key never borrows another provider's variable: only the named apiKeyEnv (or literal apiKey) counts.
106
105
  if (!h.apiKey && h.api !== 'openai-completions' && h.api !== 'fake') {
107
- throw new JudgeConfigError(`Model provider "${name}" (${h.api}) has no API key. Set ${h.keyEnv} in the environment, or "apiKey" / "apiKeyEnv" on the provider.`);
106
+ throw new SctxError('E_MODEL_PROVIDER_INVALID', `Model provider "${name}" (${h.api}) has no API key. Set ${h.keyEnv} in the environment, or "apiKey" / "apiKeyEnv" on the provider.`);
108
107
  }
109
108
  switch (h.api) {
110
109
  case 'fake':
@@ -132,17 +131,17 @@ export function judgeForRun(config, space, semantic) {
132
131
  /**
133
132
  * The provider for a run: the one named by `name` (the `--semantic <provider>` override), else the space's
134
133
  * `semantic.provider`, else the top-level one. The name resolves against `modelProviders` first, then
135
- * the bundled providers. Throws {@link JudgeConfigError} when it cannot be built.
134
+ * the bundled providers. Throws {@link SctxError} `E_MODEL_PROVIDER_INVALID` when it cannot be built.
136
135
  */
137
136
  export function createJudge(config, space = {}, name, env = process.env) {
138
137
  const chosen = name ?? space.semantic?.provider ?? config.semantic?.provider;
139
138
  if (!chosen) {
140
- throw new JudgeConfigError('--semantic needs a model provider. Set "semantic.provider" in config.json, or pass --semantic <provider>.');
139
+ throw new SctxError('E_MODEL_PROVIDER_INVALID', '--semantic needs a model provider. Set "semantic.provider" in config.json, or pass --semantic <provider>.');
141
140
  }
142
141
  const def = config.modelProviders?.[chosen] ?? BUNDLED_PROVIDERS[chosen];
143
142
  if (!def) {
144
143
  const known = [...Object.keys(BUNDLED_PROVIDERS), ...Object.keys(config.modelProviders ?? {})];
145
- throw new JudgeConfigError(`Model provider "${chosen}" is not configured.${known.length ? ` Known providers: ${known.join(', ')}.` : ''}`);
144
+ throw new SctxError('E_MODEL_PROVIDER_INVALID', `Model provider "${chosen}" is not configured.${known.length ? ` Known providers: ${known.join(', ')}.` : ''}`);
146
145
  }
147
146
  return createModelProvider(chosen, def, env);
148
147
  }
@@ -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;
@@ -62,26 +62,64 @@ export interface ValidationResult {
62
62
  * Groups related errors and extracts helpful context like allowed values.
63
63
  */
64
64
  export declare function formatErrors(errors: ErrorObject[], schema: SchemaWithMetadata, schemaRefRegistry: Parameters<typeof extractEntityInfo>[1], nodeData: Record<string, unknown>): FormattedError[];
65
- export interface ValidateSpaceOptions {
66
- readResult?: ReadSpaceResult;
67
- /** Limit validation to hierarchy nodes within N breadth-first levels from the top. */
68
- depth?: number;
65
+ interface CommonValidateOptions {
69
66
  /** Evaluate rules on gated nodes anyway (gates are still reported). */
70
67
  includeGated?: boolean;
71
- /** Evaluate only rules matching this filter (see `parseRuleFilter`). */
68
+ /** Evaluate only rules matching these `dimension=values` filters (category, severity, type, id). */
69
+ ruleFilter?: string[];
70
+ /**
71
+ * Evaluate `semantic` rules with the model provider named here, or with the configured one for `true`.
72
+ * Without it, a rule runs only its `expression`, and a semantic-only rule is skipped.
73
+ */
74
+ semantic?: boolean | string;
75
+ }
76
+ /** What a run evaluates with: the public options after the filter is parsed and the judge is built. */
77
+ export interface RunOptions {
78
+ includeGated?: boolean;
72
79
  ruleFilter?: RuleFilter;
73
- /** Evaluates `semantic` rules. Without one, a rule runs only its `expression`, and a semantic-only rule is skipped. */
74
80
  judge?: SemanticJudgeProvider;
81
+ /** Limit judging to one file (and the ancestors that can gate it). */
82
+ judgeTarget?: string;
83
+ }
84
+ export interface ValidateSpaceOptions extends CommonValidateOptions {
85
+ readResult?: ReadSpaceResult;
86
+ /** Limit validation to hierarchy nodes within N breadth-first levels from the top. */
87
+ depth?: number;
75
88
  }
76
89
  /** `file`, `file:line` or `file:line:column`, for messages. */
77
90
  export declare function formatSource({ file, line, column }: SourceLocation): string;
78
91
  /** One-line text for a rule violation: rule, description, and any evidence and verdict guidance. */
79
92
  export declare function ruleViolationMessage(v: RuleViolation): string;
93
+ /** A node that failed type or schema validation. */
94
+ interface NodeValidationFailure {
95
+ node: SpaceNode;
96
+ issues: FormattedError[];
97
+ }
98
+ /** The outcome of validating a set of nodes, before either view shapes it. */
99
+ export interface SpaceValidationResult {
100
+ validCount: number;
101
+ nodeFailures: NodeValidationFailure[];
102
+ refErrors: ValidationResult['refErrors'];
103
+ duplicateErrors: ValidationResult['duplicateErrors'];
104
+ ruleViolations: RuleViolation[];
105
+ hierarchyViolations: GraphViolation[];
106
+ orphans: SpaceNode[];
107
+ unresolvedContentLinks: ValidationResult['unresolvedContentLinks'];
108
+ coverageGates: CoverageGate[];
109
+ contextTrimmed: ValidationResult['contextTrimmed'];
110
+ }
80
111
  /**
81
112
  * Nodes that share an id, with where each is defined. A plugin should keep ids unique, so this guards that contract.
82
113
  * Display titles may repeat.
83
114
  */
84
115
  export declare function findDuplicateIds(nodes: SpaceNode[]): ValidationResult['duplicateErrors'];
116
+ /**
117
+ * The one validation pipeline: type and schema checks, duplicate titles, reference and hierarchy
118
+ * checks, cascade-gated rules, and unresolved content links. Returns every entry; `validateSpace`
119
+ * and `validateFile` are views over it, so a file's results match its entry in a full run.
120
+ * `judgeTarget` limits judging to one file (and the ancestors that can gate it).
121
+ */
122
+ export declare function validateNodes(context: SpaceContext, nodes: SpaceNode[], unresolvedRefs: ReadSpaceResult['unresolvedRefs'], options?: RunOptions): Promise<SpaceValidationResult>;
85
123
  /**
86
124
  * Run full validation on a space and return a structured result.
87
125
  * Does not output to console or call process.exit.
@@ -92,14 +130,8 @@ export declare function resolveFileSpace(filePath: string, config: Config): {
92
130
  space: SpaceConfig;
93
131
  label: string;
94
132
  } | null;
95
- export interface ValidateFileOptions {
133
+ export interface ValidateFileOptions extends CommonValidateOptions {
96
134
  configDir?: string;
97
- /** Evaluate rules on gated nodes anyway (gates are still reported). */
98
- includeGated?: boolean;
99
- /** Evaluate only rules matching this filter (see `parseRuleFilter`). */
100
- ruleFilter?: RuleFilter;
101
- /** Evaluates `semantic` rules. Without one, a rule runs only its `expression`, and a semantic-only rule is skipped. */
102
- judge?: SemanticJudgeProvider;
103
135
  }
104
136
  /** The result for a file that belongs to no configured space. */
105
137
  export declare function fileNotInSpace(filePath: string): FileNotInSpaceResult;
@@ -108,6 +140,8 @@ export declare function fileNotInSpace(filePath: string): FileNotInSpaceResult;
108
140
  * correctness), judging only the file, then selects the file's issues from the result.
109
141
  */
110
142
  export declare function validateResolvedFile(filePath: string, label: string, context: SpaceContext, options?: Omit<ValidateFileOptions, 'configDir'>): Promise<FileValidationResult>;
143
+ /** `validateResolvedFile` with the options already resolved, so a caller can supply its own judge. */
144
+ export declare function validateResolvedFileWith(filePath: string, label: string, context: SpaceContext, runOptions: RunOptions): Promise<FileValidationResult>;
111
145
  /**
112
146
  * Validate a single file within its space.
113
147
  *
@@ -118,3 +152,4 @@ export declare function validateResolvedFile(filePath: string, label: string, co
118
152
  * If the file is not in any configured space, returns a result with `inSpace: false`.
119
153
  */
120
154
  export declare function validateFile(filePath: string, config: Config, options?: ValidateFileOptions): Promise<ValidateFileOutput>;
155
+ export {};
package/dist/validate.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { isAbsolute, relative, resolve } from 'node:path';
2
+ import { judgeForRun } from './judge/factory';
2
3
  import { readSpace } from './read/read-space';
3
4
  import { computeHierarchyDepths, evaluateRulesWithGating } from './schema/gating';
4
- import { filterRules } from './schema/rule-filter';
5
- import { extractEntityInfo } from './schema/schema';
5
+ import { filterRules, parseRuleFilter } from './schema/rule-filter';
6
+ import { extractEntityInfo, typeValidatorFor } from './schema/schema';
6
7
  import { validateGraph } from './schema/validate-graph';
7
8
  import { validateRules } from './schema/validate-rules';
8
9
  import { createSpaceContext } from './space-context';
@@ -94,6 +95,14 @@ export function formatErrors(errors, schema, schemaRefRegistry, nodeData) {
94
95
  }
95
96
  return formatted;
96
97
  }
98
+ /** Throws `SctxError` `E_MODEL_PROVIDER_INVALID` when `semantic` names a provider that cannot be built. */
99
+ function runOptionsFrom(context, options) {
100
+ return {
101
+ includeGated: options.includeGated,
102
+ ruleFilter: options.ruleFilter && parseRuleFilter(options.ruleFilter),
103
+ judge: judgeForRun(context.config, context.space, options.semantic),
104
+ };
105
+ }
97
106
  /** `file`, `file:line` or `file:line:column`, for messages. */
98
107
  export function formatSource({ file, line, column }) {
99
108
  return line === undefined ? file : `${file}:${line}${column === undefined ? '' : `:${column}`}`;
@@ -159,7 +168,7 @@ export function findDuplicateIds(nodes) {
159
168
  * and `validateFile` are views over it, so a file's results match its entry in a full run.
160
169
  * `judgeTarget` limits judging to one file (and the ancestors that can gate it).
161
170
  */
162
- async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
171
+ export async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
163
172
  const { schema, schemaRefRegistry, schemaValidator } = context;
164
173
  const metadata = schema.metadata;
165
174
  // Pre-extract valid types for early type validation
@@ -177,31 +186,31 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
177
186
  contextTrimmed: { nodeCount: 0, ruleIds: [] },
178
187
  };
179
188
  for (const node of nodes) {
180
- // Early type validation - check before full AJV validation to prevent cascading errors
189
+ // Check the type first: an unknown type reports one error instead of one per oneOf branch.
181
190
  const nodeType = node.schemaData.type;
182
191
  if (nodeType !== undefined && validTypes.length > 0 && !validTypes.includes(nodeType)) {
183
192
  const sortedTypes = [...validTypes].sort();
184
193
  result.nodeFailures.push({
185
194
  node,
186
- invalidType: { validTypes },
187
- errors: [
195
+ issues: [
188
196
  {
189
- instancePath: '/type',
190
- keyword: 'enum',
191
197
  message: `Invalid type "${nodeType}". Valid types are: ${sortedTypes.join(', ')}`,
192
- params: { allowedValues: sortedTypes },
193
- schemaPath: '#/oneOf',
194
- data: nodeType,
198
+ dedupeKey: `type:${sortedTypes.join(',')}`,
195
199
  },
196
200
  ],
197
201
  });
198
202
  continue;
199
203
  }
200
- if (schemaValidator(node.schemaData)) {
204
+ // A valid type is checked against its own branch only, so errors come from that type alone.
205
+ const validate = (nodeType !== undefined && typeValidatorFor(schemaValidator, nodeType)) || schemaValidator;
206
+ if (validate(node.schemaData)) {
201
207
  result.validCount++;
202
208
  }
203
209
  else {
204
- result.nodeFailures.push({ node, errors: schemaValidator.errors || [] });
210
+ result.nodeFailures.push({
211
+ node,
212
+ issues: formatErrors(validate.errors || [], schema, schemaRefRegistry, node.schemaData),
213
+ });
205
214
  }
206
215
  }
207
216
  result.duplicateErrors.push(...findDuplicateIds(nodes));
@@ -248,6 +257,7 @@ async function validateNodes(context, nodes, unresolvedRefs, options = {}) {
248
257
  */
249
258
  export async function validateSpace(context, options = {}) {
250
259
  const metadata = context.schema.metadata;
260
+ const runOptions = runOptionsFrom(context, options);
251
261
  const readResult = options.readResult ?? (await readSpace(context));
252
262
  let nodes = readResult.nodes;
253
263
  // Depth limiting: evaluate only hierarchy nodes within N breadth-first levels from the
@@ -268,19 +278,14 @@ export async function validateSpace(context, options = {}) {
268
278
  }
269
279
  nodes = included;
270
280
  }
271
- const result = await validateNodes(context, nodes, readResult.unresolvedRefs, {
272
- includeGated: options.includeGated,
273
- ruleFilter: options.ruleFilter,
274
- judge: options.judge,
275
- });
281
+ const result = await validateNodes(context, nodes, readResult.unresolvedRefs, runOptions);
276
282
  return {
277
283
  validCount: result.validCount,
278
284
  nodeErrorCount: result.nodeFailures.length,
279
- nodeErrors: result.nodeFailures.map(({ node, errors }) => ({
285
+ nodeErrors: result.nodeFailures.map(({ node, issues }) => ({
280
286
  nodeId: node.id,
281
287
  ...(node.source !== undefined && { source: node.source }),
282
- errors,
283
- nodeData: node.schemaData,
288
+ issues,
284
289
  })),
285
290
  refErrors: result.refErrors,
286
291
  duplicateErrors: result.duplicateErrors,
@@ -312,27 +317,17 @@ export function resolveFileSpace(filePath, config) {
312
317
  * Select one file's entries from a space's validation result: issues keyed and formatted, errors split from warnings.
313
318
  * Pure — a file's entries are exactly its slice of the result.
314
319
  */
315
- function issuesForFile(result, label, context, includeGated) {
316
- const { schema, schemaRefRegistry } = context;
320
+ function issuesForFile(result, label, includeGated) {
317
321
  const errors = {};
318
322
  const warnings = {};
319
- for (const { node, errors: ajvErrors, invalidType } of result.nodeFailures) {
323
+ for (const { node, issues } of result.nodeFailures) {
320
324
  if (node.id !== label)
321
325
  continue;
322
326
  const position = {
323
327
  ...(node.source?.line !== undefined ? { line: node.source.line } : {}),
324
328
  ...(node.source?.column !== undefined ? { column: node.source.column } : {}),
325
329
  };
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) {
330
+ for (const { message, dedupeKey } of issues) {
336
331
  errors[`schema:${dedupeKey}`] = { kind: 'schema', message, ...position };
337
332
  }
338
333
  }
@@ -401,15 +396,17 @@ export function fileNotInSpace(filePath) {
401
396
  * Validate a file in an already-resolved space. Checks the whole space (required for graph
402
397
  * correctness), judging only the file, then selects the file's issues from the result.
403
398
  */
404
- export async function validateResolvedFile(filePath, label, context, options = {}) {
399
+ export function validateResolvedFile(filePath, label, context, options = {}) {
400
+ return validateResolvedFileWith(filePath, label, context, runOptionsFrom(context, options));
401
+ }
402
+ /** `validateResolvedFile` with the options already resolved, so a caller can supply its own judge. */
403
+ export async function validateResolvedFileWith(filePath, label, context, runOptions) {
405
404
  const readResult = await readSpace(context);
406
405
  const result = await validateNodes(context, readResult.nodes, readResult.unresolvedRefs, {
407
- includeGated: options.includeGated,
408
- ruleFilter: options.ruleFilter,
409
- judge: options.judge,
406
+ ...runOptions,
410
407
  judgeTarget: label,
411
408
  });
412
- const { errors, warnings } = issuesForFile(result, label, context, options.includeGated);
409
+ const { errors, warnings } = issuesForFile(result, label, runOptions.includeGated);
413
410
  return {
414
411
  file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
415
412
  label,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "structured-context",
3
- "version": "0.18.0",
3
+ "version": "0.20.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",
@@ -1,13 +0,0 @@
1
- export { createJudge, createModelProvider, JudgeConfigError } from './factory';
2
- export { buildJudgeSystemPrompt, buildJudgeUserPrompt, parseJudgeResponse } from './prompt';
3
- export type { AnthropicJudgeProviderOptions } from './provider/anthropic';
4
- export { AnthropicJudgeProvider } from './provider/anthropic';
5
- export type { FakeAnswer, FakeJudgeHandler } from './provider/fake';
6
- export { FakeJudgeProvider } from './provider/fake';
7
- export type { JevJudgeProviderOptions } from './provider/jev';
8
- export { JevJudgeProvider } from './provider/jev';
9
- export type { OpenAIJudgeProviderOptions } from './provider/openai';
10
- export { OpenAIJudgeProvider } from './provider/openai';
11
- export { PerRuleJudgeProvider } from './provider/per-rule';
12
- export type { JudgeConfig, ModelProviderConfig, SemanticJudgeInput, SemanticJudgeProvider, SemanticJudgeResult, SemanticJudgeVerdict, SemanticRuleSpec, } from './types';
13
- export { DEFAULT_VERDICTS, isJudgeError, semanticRuleSpec } from './verdicts';
@@ -1,8 +0,0 @@
1
- export { createJudge, createModelProvider, JudgeConfigError } from './factory';
2
- export { buildJudgeSystemPrompt, buildJudgeUserPrompt, parseJudgeResponse } from './prompt';
3
- export { AnthropicJudgeProvider } from './provider/anthropic';
4
- export { FakeJudgeProvider } from './provider/fake';
5
- export { JevJudgeProvider } from './provider/jev';
6
- export { OpenAIJudgeProvider } from './provider/openai';
7
- export { PerRuleJudgeProvider } from './provider/per-rule';
8
- export { DEFAULT_VERDICTS, isJudgeError, semanticRuleSpec } from './verdicts';