structured-context 0.22.0 → 0.23.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/README.md CHANGED
@@ -70,7 +70,7 @@ bunx structured-context --help
70
70
 
71
71
  ## Setup for AI Agents
72
72
 
73
- Claude Code plugin, providing validation hooks, slash commands, and agent skills. In Claude Code versions with mods, it also shows each turn's new issues beside the chat, adds the session's schemas to the system prompt, and gives Claude sctx tools. Install it with:
73
+ Claude Code plugin, providing validation hooks, slash commands, and agent skills. In Claude Code versions with mods, it also shows each turn's new issues beside the chat, opens a map of a space's hierarchy with each node's issues (`/sctx-map`), adds the session's schemas to the system prompt, and gives Claude sctx tools. Install it with:
74
74
 
75
75
  ```
76
76
  claude plugin marketplace add mindsocket/structured-context
@@ -264,11 +264,11 @@ bun link
264
264
 
265
265
  ### Releasing
266
266
  ```bash
267
- npm login # authenticate with npm registry if needed
268
- bun pm version patch # or minor / major — runs lint, tests, then pushes with tags
269
- npm publish
267
+ bun pm version patch # or minor / major — runs lint and tests, then pushes the commit and tag
270
268
  ```
271
269
 
270
+ Pushing the `v<version>` tag runs the Release workflow (`.github/workflows/release.yml`). It reruns the checks and publishes to npm through trusted publishing, with provenance. It also attests the tarball and attaches it to a GitHub release.
271
+
272
272
  ## License
273
273
 
274
274
  MIT
package/dist/api.d.ts CHANGED
@@ -35,6 +35,7 @@ export { bundledSchemasDir, loadSchema, type PluginSchemaSource, setBundledSchem
35
35
  export { createSpaceContext } from './space-context';
36
36
  export { buildSpaceGraph } from './space-graph';
37
37
  export { exportSpaceGraph } from './space-graph-data';
38
- export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, UnresolvedRef, ValidateFileOutput, } from './types';
38
+ export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, UnresolvedRef, ValidateFileOutput, ValidationIssue, ValidationIssueKind, } from './types';
39
+ export { VALIDATION_ISSUE_KINDS } from './types';
39
40
  export type { ValidationResult } from './validate';
40
- export { nodeIssues, validateFile, validateSpace } from './validate';
41
+ export { validateFile, validateSpace } from './validate';
package/dist/api.js CHANGED
@@ -32,4 +32,5 @@ export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/sc
32
32
  export { createSpaceContext } from './space-context';
33
33
  export { buildSpaceGraph } from './space-graph';
34
34
  export { exportSpaceGraph } from './space-graph-data';
35
- export { nodeIssues, validateFile, validateSpace } from './validate';
35
+ export { VALIDATION_ISSUE_KINDS } from './types';
36
+ export { validateFile, validateSpace } from './validate';
@@ -42,7 +42,7 @@ function printHumanReadable(result) {
42
42
  }
43
43
  if (result.errorCount > 0) {
44
44
  console.log(`\n${red}✗${reset} ${result.label} (space: ${result.space}) — ${result.errorCount} error(s)\n`);
45
- for (const issue of Object.values(result.issues).filter((i) => i.severity === 'error')) {
45
+ for (const issue of result.issues.filter((i) => i.severity === 'error')) {
46
46
  console.log(` [${issueLabel(issue)}] ${issue.message}`);
47
47
  }
48
48
  }
@@ -51,7 +51,7 @@ function printHumanReadable(result) {
51
51
  }
52
52
  if (result.warningCount > 0) {
53
53
  console.log(`\n ${yellow}${result.warningCount} warning(s):${reset}`);
54
- for (const issue of Object.values(result.issues).filter((i) => i.severity !== 'error')) {
54
+ for (const issue of result.issues.filter((i) => i.severity !== 'error')) {
55
55
  console.log(` ${yellow}[${issueLabel(issue)}]${reset} ${issue.message}`);
56
56
  }
57
57
  }
@@ -1,6 +1,30 @@
1
- import type { SpaceContext } from '../types';
2
- import { type ValidateSpaceOptions } from '../validate';
1
+ import type { SpaceContext, ValidationIssue } from '../types';
2
+ import { type ValidateSpaceOptions, type ValidationResult } from '../validate';
3
+ /** What `validate --json` prints: the run's `ValidationResult` and a summary. The Claude Code plugin's scripts read it by this type. */
4
+ export type ValidateJsonOutput = ReturnType<typeof validateJson>;
5
+ declare function validateJson(context: SpaceContext, result: ValidationResult): {
6
+ issues: ValidationIssue[];
7
+ validCount: number;
8
+ orphans: Array<{
9
+ nodeId: import("../types").NodeId;
10
+ title: string;
11
+ }>;
12
+ coverageGates: import("../types").CoverageGate[];
13
+ contextTrimmed: {
14
+ nodeCount: number;
15
+ ruleIds: string[];
16
+ };
17
+ skippedByDepth: Array<{
18
+ nodeId: import("../types").NodeId;
19
+ title: string;
20
+ }>;
21
+ depthLimit?: number;
22
+ space: string;
23
+ valid: boolean;
24
+ errorCount: number;
25
+ };
3
26
  export declare function validate(context: SpaceContext, options?: {
4
27
  json?: boolean;
5
28
  } & ValidateSpaceOptions): Promise<number>;
6
29
  export declare function watchValidate(context: SpaceContext): Promise<never>;
30
+ export {};
@@ -3,37 +3,21 @@ import chokidar from 'chokidar';
3
3
  import { getConfigSourceFiles } from '../config';
4
4
  import { resolveSpacePluginSchemas } from '../plugins/util';
5
5
  import { bundledSchemasDir } from '../schema/schema';
6
- import { formatSource, nodeIssues, validateSpace } from '../validate';
6
+ import { formatSource, validateSpace } from '../validate';
7
+ function validateJson(context, result) {
8
+ const errorCount = result.issues.filter((issue) => issue.severity === 'error').length;
9
+ return { space: context.space.name, valid: errorCount === 0, errorCount, ...result };
10
+ }
11
+ /** The issues of one kind, narrowed to that kind's fields */
12
+ function ofKind(result, kind) {
13
+ return result.issues.filter((issue) => issue.kind === kind);
14
+ }
7
15
  export async function validate(context, options = {}) {
8
16
  const result = await validateSpace(context, options);
9
- const ruleCounts = { error: 0, warning: 0, info: 0 };
10
- for (const v of result.ruleViolations)
11
- ruleCounts[v.severity]++;
12
- // JSON output mode
17
+ const errorCount = result.issues.filter((issue) => issue.severity === 'error').length;
13
18
  if (options.json) {
14
- const issues = nodeIssues(result, options);
15
- const nodeErrorCount = Object.values(issues)
16
- .flatMap((byKey) => Object.values(byKey))
17
- .filter((issue) => issue.severity === 'error').length;
18
- const globalRuleErrorCount = result.ruleViolations.filter((v) => !v.nodeId && v.severity === 'error').length;
19
- const parseErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
20
- const errorCount = nodeErrorCount + parseErrorCount + globalRuleErrorCount;
21
- console.log(JSON.stringify({
22
- space: context.space.name,
23
- valid: errorCount === 0,
24
- validCount: result.validCount,
25
- errorCount,
26
- issues,
27
- ruleViolations: result.ruleViolations,
28
- orphanCount: result.orphans.length,
29
- parseIssues: result.parseIssues,
30
- unresolvedContentLinks: result.unresolvedContentLinks,
31
- coverageGates: result.coverageGates,
32
- contextTrimmed: result.contextTrimmed,
33
- skippedByDepth: result.skippedByDepth,
34
- depthLimit: result.depthLimit,
35
- }, null, 2));
36
- return errorCount > 0 ? 1 : 0;
19
+ console.log(JSON.stringify(validateJson(context, result), null, 2));
20
+ return errorCount === 0 ? 0 : 1;
37
21
  }
38
22
  // Human-readable output
39
23
  const reset = '\x1b[0m';
@@ -56,63 +40,63 @@ export async function validate(context, options = {}) {
56
40
  const countStr = String(count).padStart(3);
57
41
  return `${label.padEnd(40)} ${color}${countStr}${reset}`;
58
42
  };
59
- const parseIssueErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
43
+ const where = (issue) => (issue.source ? formatSource(issue.source) : (issue.nodeId ?? ''));
44
+ const parseIssues = ofKind(result, 'parse');
45
+ const schemaIssues = ofKind(result, 'schema');
46
+ const brokenLinks = ofKind(result, 'broken-link');
47
+ const duplicates = ofKind(result, 'duplicate');
48
+ const ruleViolations = ofKind(result, 'rule');
49
+ const hierarchyViolations = ofKind(result, 'hierarchy');
50
+ const contentLinks = ofKind(result, 'content-link');
51
+ const ruleCounts = { error: 0, warning: 0, info: 0 };
52
+ for (const v of ruleViolations)
53
+ ruleCounts[v.severity]++;
60
54
  if (result.orphans.length > 0) {
61
55
  console.log(`\nOrphans (hierarchy nodes - no parent):`);
62
- for (const node of result.orphans)
63
- console.log(` ${node.id}`);
56
+ for (const { nodeId } of result.orphans)
57
+ console.log(` ${nodeId}`);
64
58
  }
65
- if (result.unresolvedContentLinks.length > 0) {
59
+ if (contentLinks.length > 0) {
66
60
  console.log(`\nUnresolved content links (may be outside-space or broken):`);
67
- const byFile = new Map();
68
- for (const { nodeId, target } of result.unresolvedContentLinks) {
69
- if (!byFile.has(nodeId))
70
- byFile.set(nodeId, []);
71
- byFile.get(nodeId).push(target);
72
- }
73
- for (const [nodeId, targets] of byFile) {
61
+ const byNode = Map.groupBy(contentLinks, (link) => link.nodeId);
62
+ for (const [nodeId, links] of byNode) {
74
63
  console.log(` ${nodeId}:`);
75
- for (const target of targets)
64
+ for (const { target } of links)
76
65
  console.log(` ${target}`);
77
66
  }
78
67
  }
79
- if (result.parseIssues.length > 0) {
68
+ if (parseIssues.length > 0) {
80
69
  console.log(`\nExcluded during parsing:`);
81
- for (const issue of result.parseIssues) {
82
- const loc = issue.line !== undefined ? `:${issue.line}${issue.column !== undefined ? `:${issue.column}` : ''}` : '';
83
- const detail = issue.message ? ` - ${issue.message}` : '';
84
- console.log(` ${issue.file}${loc}: ${issue.severity} - ${issue.type}${detail}`);
70
+ for (const issue of parseIssues) {
71
+ console.log(` ${formatSource(issue.source)}: ${issue.severity} - ${issue.type} - ${issue.message}`);
85
72
  }
86
73
  }
87
- if (result.nodeErrors.length > 0) {
74
+ if (schemaIssues.length > 0) {
88
75
  console.log(`\nSchema validation errors:`);
89
- result.nodeErrors.forEach(({ nodeId, source, issues }) => {
90
- console.log(`\n ${source ? formatSource(source) : nodeId}:`);
91
- issues.forEach(({ message }) => {
76
+ for (const [, issues] of Map.groupBy(schemaIssues, (issue) => issue.nodeId)) {
77
+ console.log(`\n ${where(issues[0])}:`);
78
+ for (const { message } of issues)
92
79
  console.log(` ${message}`);
93
- });
94
- });
80
+ }
95
81
  }
96
- if (result.refErrors.length > 0) {
82
+ if (brokenLinks.length > 0) {
97
83
  console.log(`\nBroken links:`);
98
- result.refErrors.forEach(({ nodeId, source, parent, error }) => {
99
- console.log(` ${source ? formatSource(source) : nodeId}: ${parent} → ${error}`);
100
- });
84
+ for (const issue of brokenLinks)
85
+ console.log(` ${where(issue)}: ${issue.message}`);
101
86
  }
102
- if (result.duplicateErrors.length > 0) {
87
+ if (duplicates.length > 0) {
103
88
  console.log(`\nDuplicate ids (same id on multiple nodes):`);
104
- result.duplicateErrors.forEach(({ id, sources }) => {
105
- console.log(` "${id}":`);
106
- for (const source of sources) {
89
+ for (const { nodeId, sources } of duplicates) {
90
+ console.log(` "${nodeId}":`);
91
+ for (const source of sources)
107
92
  console.log(` ${formatSource(source)}`);
108
- }
109
- });
93
+ }
110
94
  }
111
- if (result.ruleViolations.length > 0) {
95
+ if (ruleViolations.length > 0) {
112
96
  console.log(`\nRule violations:`);
113
97
  const severityColor = { error: red, warning: yellow, info: reset };
114
98
  for (const severity of ['error', 'warning', 'info']) {
115
- const violations = result.ruleViolations.filter((v) => v.severity === severity);
99
+ const violations = ruleViolations.filter((v) => v.severity === severity);
116
100
  if (violations.length === 0)
117
101
  continue;
118
102
  console.log(` ${severityColor[severity]}${severity.toUpperCase()}${reset} (${violations.length}):`);
@@ -127,10 +111,10 @@ export async function validate(context, options = {}) {
127
111
  }
128
112
  }
129
113
  }
130
- if (result.hierarchyViolations.length > 0) {
114
+ if (hierarchyViolations.length > 0) {
131
115
  console.log(`\nHierarchy violations:`);
132
- for (const v of result.hierarchyViolations) {
133
- console.log(` ${v.nodeId}: ${v.description}`);
116
+ for (const v of hierarchyViolations) {
117
+ console.log(` ${v.nodeId}: ${v.message}`);
134
118
  }
135
119
  }
136
120
  if (result.coverageGates.length > 0) {
@@ -155,31 +139,24 @@ export async function validate(context, options = {}) {
155
139
  console.log(`━`.repeat(45));
156
140
  console.log('Content and structure');
157
141
  console.log(fmt(' Valid', result.validCount));
158
- console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
159
- console.log(fmt(' Broken links', result.refErrors.length, true));
160
- console.log(fmt(' Duplicate ids', result.duplicateErrors.length, true));
142
+ console.log(fmt(' Schema validation errors', new Set(schemaIssues.map((issue) => issue.nodeId)).size, true));
143
+ console.log(fmt(' Broken links', brokenLinks.length, true));
144
+ console.log(fmt(' Duplicate ids', duplicates.length, true));
161
145
  console.log(fmt(' Rule violations (error)', ruleCounts.error, true));
162
146
  console.log(fmt(' Rule violations (warning)', ruleCounts.warning, true, true));
163
147
  console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
164
- console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
148
+ console.log(fmt(' Hierarchy violations', hierarchyViolations.length, true));
165
149
  console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
166
- console.log(fmt(' Unresolved content links (info)', result.unresolvedContentLinks.length, true, true));
167
- console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
150
+ console.log(fmt(' Unresolved content links (info)', contentLinks.length, true, true));
151
+ const parseErrors = parseIssues.filter((issue) => issue.severity === 'error').length;
152
+ console.log(fmt(' Excluded during parsing', parseIssues.length, true, parseErrors === 0));
168
153
  if (result.depthLimit !== undefined) {
169
154
  console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
170
155
  }
171
156
  const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedNodeIds.length, 0);
172
157
  console.log(fmt(' Rule checks gated (cascade)', gatedFileCount, true, true));
173
158
  console.log(`\n`);
174
- if (result.nodeErrorCount > 0 ||
175
- result.refErrors.length > 0 ||
176
- result.duplicateErrors.length > 0 ||
177
- ruleCounts.error > 0 ||
178
- result.hierarchyViolations.length > 0 ||
179
- parseIssueErrorCount > 0) {
180
- return 1;
181
- }
182
- return 0;
159
+ return errorCount === 0 ? 0 : 1;
183
160
  }
184
161
  export async function watchValidate(context) {
185
162
  const spacePath = context.space.path;
package/dist/types.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
2
2
  import type { Config, SpaceConfig } from './config';
3
+ import type { CoverageGate } from './schema/gating';
3
4
  import type { MetadataContractHierarchyLevel, MetadataContractRelationship, Rule, SharedEdgeFields } from './schema/metadata-contract';
4
5
  /**
5
6
  * Minimal normalized edge for graph resolution.
@@ -247,23 +248,68 @@ export type SpaceContext = {
247
248
  /** Directory of the config file that defines this space. Used for resolving relative paths in plugin configs. */
248
249
  configDir: string;
249
250
  };
250
- export interface FileValidationIssue {
251
- /** The check it came from: `schema`, `duplicate`, `broken-link`, `hierarchy`, `rule`, `gated` or `content-link`. */
252
- kind: string;
253
- message: string;
254
- /** How serious the issue is; consumers read it rather than infer it from `kind`. */
251
+ /** The checks a validation issue can come from. */
252
+ export declare const VALIDATION_ISSUE_KINDS: readonly ["parse", "schema", "duplicate", "broken-link", "hierarchy", "rule", "gated", "content-link"];
253
+ export type ValidationIssueKind = (typeof VALIDATION_ISSUE_KINDS)[number];
254
+ /** What every validation issue has, whatever its kind. */
255
+ type IssueBase<K extends ValidationIssueKind> = {
256
+ /** The check it came from. */
257
+ kind: K;
258
+ /** Names the issue within its node (or file, or space), stably across runs, e.g. `rule:<ruleId>`. */
259
+ key: string;
260
+ /** How serious the issue is; only `error` fails validation. Read it rather than infer it from `kind`. */
255
261
  severity: RuleSeverity;
256
- /** 1-indexed line number in source file, if available. */
257
- line?: number;
258
- /** 1-indexed column number in source file, if available. */
259
- column?: number;
260
- }
262
+ message: string;
263
+ /** The node the issue is about. Absent for parse issues and global rules. */
264
+ nodeId?: NodeId;
265
+ /** The node's source, or the file a parse issue is about. */
266
+ source?: SourceLocation;
267
+ };
268
+ /** One finding of a validation run. `kind` also selects the fields the issue carries. */
269
+ export type ValidationIssue =
270
+ /** A file that produced no nodes; `type` is the plugin's reason, such as `no-type` or `parse`. */
271
+ (IssueBase<'parse'> & {
272
+ source: SourceLocation;
273
+ type: ParseIssue['type'];
274
+ })
275
+ /** A node that failed its type or its schema, one issue per field problem. */
276
+ | (IssueBase<'schema'> & {
277
+ nodeId: NodeId;
278
+ })
279
+ /** An id used by more than one node; `sources` lists where each is defined. */
280
+ | (IssueBase<'duplicate'> & {
281
+ nodeId: NodeId;
282
+ sources: SourceLocation[];
283
+ })
284
+ /** A link in a field that does not resolve to a node; `ref` is the link as written. */
285
+ | (IssueBase<'broken-link'> & {
286
+ nodeId: NodeId;
287
+ ref: string;
288
+ reason: string;
289
+ })
290
+ /** A parent link that breaks the hierarchy. */
291
+ | (IssueBase<'hierarchy'> & Omit<GraphViolation, 'description'>)
292
+ /** A rule violation; `nodeId` is absent for a global rule. */
293
+ | (IssueBase<'rule'> & RuleViolation)
294
+ /** A node whose rules a coverage gate suppressed (or, with `includeGated`, evaluated anyway). */
295
+ | (IssueBase<'gated'> & {
296
+ nodeId: NodeId;
297
+ /** The node whose failure gated this one. */
298
+ gatedBy: NodeId;
299
+ reason: CoverageGate['reason'];
300
+ ruleId?: string;
301
+ })
302
+ /** A content link to a target outside the space's nodes. */
303
+ | (IssueBase<'content-link'> & {
304
+ nodeId: NodeId;
305
+ target: string;
306
+ });
261
307
  export interface FileValidationResult {
262
308
  file: string;
263
309
  label: string;
264
310
  space: string;
265
- /** The file's issues of every severity, keyed by composite id (e.g. `schema:/status:enum:active`, `rule:my-rule-id`). */
266
- issues: Record<string, FileValidationIssue>;
311
+ /** The file's node's issues of every severity. */
312
+ issues: ValidationIssue[];
267
313
  /** Issues at `error` severity: only these fail validation. */
268
314
  errorCount: number;
269
315
  /** Issues at `warning` or `info` severity. */
package/dist/types.js CHANGED
@@ -0,0 +1,11 @@
1
+ /** The checks a validation issue can come from. */
2
+ export const VALIDATION_ISSUE_KINDS = [
3
+ 'parse',
4
+ 'schema',
5
+ 'duplicate',
6
+ 'broken-link',
7
+ 'hierarchy',
8
+ 'rule',
9
+ 'gated',
10
+ 'content-link',
11
+ ];
@@ -3,41 +3,20 @@ import type { Config, SpaceConfig } from './config';
3
3
  import type { SemanticJudgeProvider } from './judge/types';
4
4
  import { type RuleFilter } from './schema/rule-filter';
5
5
  import { extractEntityInfo } from './schema/schema';
6
- import type { CoverageGate, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, GraphViolation, NodeId, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SourceLocation, SpaceContext, SpaceNode, ValidateFileOutput } from './types';
6
+ import type { CoverageGate, FileNotInSpaceResult, FileValidationResult, GraphViolation, NodeId, ParseIssue, ReadSpaceResult, RuleViolation, SchemaWithMetadata, SourceLocation, SpaceContext, SpaceNode, ValidateFileOutput, ValidationIssue } from './types';
7
7
  export interface FormattedError {
8
8
  message: string;
9
9
  dedupeKey: string;
10
10
  }
11
11
  export interface ValidationResult {
12
+ /** Every issue the run found, of every severity. Only `error` issues fail validation. */
13
+ issues: ValidationIssue[];
14
+ /** Nodes that passed their type and schema checks. */
12
15
  validCount: number;
13
- nodeErrorCount: number;
14
- nodeErrors: Array<{
16
+ /** Hierarchy nodes without a parent: informational, not issues. */
17
+ orphans: Array<{
15
18
  nodeId: NodeId;
16
- source?: SourceLocation;
17
- /** Formatted, deduplicated schema problems for the node, one per field problem. */
18
- issues: FormattedError[];
19
- }>;
20
- refErrors: Array<{
21
- nodeId: NodeId;
22
- source?: SourceLocation;
23
- parent: string;
24
- error: string;
25
- }>;
26
- /** Nodes that share an id; `sources` lists where each is defined (a node without a source adds no entry). */
27
- duplicateErrors: Array<{
28
- id: NodeId;
29
- sources: SourceLocation[];
30
- }>;
31
- /** All rule violations with their effective severity. Only `error` severity fails validation. */
32
- ruleViolations: RuleViolation[];
33
- hierarchyViolations: GraphViolation[];
34
- orphans: SpaceNode[];
35
- parseIssues: ParseIssue[];
36
- /** Content links that could not be resolved to a space node, at `info` severity: not counted in errors. */
37
- unresolvedContentLinks: Array<{
38
- nodeId: NodeId;
39
- target: string;
40
- severity: 'info';
19
+ title: string;
41
20
  }>;
42
21
  /**
43
22
  * Cascade gates: nodes whose schema/hierarchy failure or blocking rule violation
@@ -100,20 +79,34 @@ interface NodeValidationFailure {
100
79
  export interface SpaceValidationResult {
101
80
  validCount: number;
102
81
  nodeFailures: NodeValidationFailure[];
103
- refErrors: ValidationResult['refErrors'];
104
- duplicateErrors: ValidationResult['duplicateErrors'];
82
+ refErrors: Array<{
83
+ nodeId: NodeId;
84
+ source?: SourceLocation;
85
+ parent: string;
86
+ error: string;
87
+ }>;
88
+ /** Nodes that share an id; `sources` lists where each is defined (a node without a source adds no entry). */
89
+ duplicateErrors: DuplicateId[];
105
90
  ruleViolations: RuleViolation[];
106
91
  hierarchyViolations: GraphViolation[];
107
92
  orphans: SpaceNode[];
108
- unresolvedContentLinks: ValidationResult['unresolvedContentLinks'];
93
+ /** Content links that could not be resolved to a space node. */
94
+ unresolvedContentLinks: Array<{
95
+ nodeId: NodeId;
96
+ target: string;
97
+ }>;
109
98
  coverageGates: CoverageGate[];
110
99
  contextTrimmed: ValidationResult['contextTrimmed'];
111
100
  }
101
+ type DuplicateId = {
102
+ id: NodeId;
103
+ sources: SourceLocation[];
104
+ };
112
105
  /**
113
106
  * Nodes that share an id, with where each is defined. A plugin should keep ids unique, so this guards that contract.
114
107
  * Display titles may repeat.
115
108
  */
116
- export declare function findDuplicateIds(nodes: SpaceNode[]): ValidationResult['duplicateErrors'];
109
+ export declare function findDuplicateIds(nodes: SpaceNode[]): DuplicateId[];
117
110
  /**
118
111
  * The one validation pipeline: type and schema checks, duplicate titles, reference and hierarchy
119
112
  * checks, cascade-gated rules, and unresolved content links. Returns every entry; `validateSpace`
@@ -134,17 +127,13 @@ export declare function resolveFileSpace(filePath: string, config: Config): {
134
127
  export interface ValidateFileOptions extends CommonValidateOptions {
135
128
  configDir?: string;
136
129
  }
137
- /** The parts of a validation result that `nodeIssues` reads. */
138
- type NodeIssueSources = Pick<ValidationResult, 'nodeErrors' | 'refErrors' | 'duplicateErrors' | 'hierarchyViolations' | 'ruleViolations' | 'coverageGates' | 'unresolvedContentLinks'>;
139
130
  /**
140
- * Every result that belongs to a node, by node id, each keyed by a composite id (e.g.
141
- * `schema:/status:enum:active`, `rule:my-rule-id`) and carrying its severity. Rule violations without
142
- * a node (global scope) and parse issues are not per node, so they stay in their own parts of the result.
143
- * `includeGated` words the note on a gated node as it was validated.
131
+ * The run's findings as issues, each with its severity. This is the one place severity is decided for
132
+ * the checks that have no severity of their own. An issue repeated within its node (or file) is kept once.
144
133
  */
145
- export declare function nodeIssues(result: NodeIssueSources, options?: {
134
+ export declare function issuesOf(result: SpaceValidationResult, parseIssues: ParseIssue[], options: {
146
135
  includeGated?: boolean;
147
- }): Record<string, Record<string, FileValidationIssue>>;
136
+ }): ValidationIssue[];
148
137
  /** The result for a file that belongs to no configured space. */
149
138
  export declare function fileNotInSpace(filePath: string): FileNotInSpaceResult;
150
139
  /**
package/dist/validate.js CHANGED
@@ -259,7 +259,7 @@ export async function validateNodes(context, nodes, read, options = {}) {
259
259
  const key = `${node.id}::${link.target}`;
260
260
  if (!seen.has(key)) {
261
261
  seen.add(key);
262
- result.unresolvedContentLinks.push({ nodeId: node.id, target: link.target, severity: 'info' });
262
+ result.unresolvedContentLinks.push({ nodeId: node.id, target: link.target });
263
263
  }
264
264
  }
265
265
  }
@@ -295,16 +295,9 @@ export async function validateSpace(context, options = {}) {
295
295
  }
296
296
  const result = await validateNodes(context, nodes, readResult, runOptions);
297
297
  return {
298
+ issues: issuesOf(result, readResult.parseIssues, runOptions),
298
299
  validCount: result.validCount,
299
- nodeErrorCount: result.nodeFailures.length,
300
- nodeErrors: toNodeErrors(result.nodeFailures),
301
- refErrors: result.refErrors,
302
- duplicateErrors: result.duplicateErrors,
303
- ruleViolations: result.ruleViolations,
304
- hierarchyViolations: result.hierarchyViolations,
305
- orphans: result.orphans,
306
- parseIssues: readResult.parseIssues,
307
- unresolvedContentLinks: result.unresolvedContentLinks,
300
+ orphans: result.orphans.map((node) => ({ nodeId: node.id, title: node.title })),
308
301
  coverageGates: result.coverageGates,
309
302
  contextTrimmed: result.contextTrimmed,
310
303
  skippedByDepth,
@@ -325,67 +318,96 @@ export function resolveFileSpace(filePath, config) {
325
318
  return null;
326
319
  }
327
320
  /**
328
- * Every result that belongs to a node, by node id, each keyed by a composite id (e.g.
329
- * `schema:/status:enum:active`, `rule:my-rule-id`) and carrying its severity. Rule violations without
330
- * a node (global scope) and parse issues are not per node, so they stay in their own parts of the result.
331
- * `includeGated` words the note on a gated node as it was validated.
321
+ * The run's findings as issues, each with its severity. This is the one place severity is decided for
322
+ * the checks that have no severity of their own. An issue repeated within its node (or file) is kept once.
332
323
  */
333
- export function nodeIssues(result, options = {}) {
334
- const byNode = {};
335
- const add = (nodeId, key, issue) => {
336
- byNode[nodeId] ??= {};
337
- byNode[nodeId][key] = issue;
324
+ export function issuesOf(result, parseIssues, options) {
325
+ const issues = new Map();
326
+ const add = (issue) => {
327
+ const scope = issue.nodeId ?? (issue.kind === 'parse' ? issue.source.file : '');
328
+ issues.set(`${scope}\0${issue.key}`, issue);
338
329
  };
339
- for (const { nodeId, source, issues } of result.nodeErrors) {
340
- const position = {
341
- ...(source?.line !== undefined ? { line: source.line } : {}),
342
- ...(source?.column !== undefined ? { column: source.column } : {}),
343
- };
344
- for (const { message, dedupeKey } of issues) {
345
- add(nodeId, `schema:${dedupeKey}`, { kind: 'schema', message, severity: 'error', ...position });
330
+ const at = (source) => (source ? { source } : {});
331
+ for (const { file, severity, type, message, line, column } of parseIssues) {
332
+ add({
333
+ kind: 'parse',
334
+ key: `parse:${type}`,
335
+ severity,
336
+ message: message ?? (type === 'no-type' ? 'No type field' : `Excluded during parsing (${type})`),
337
+ source: { file, ...(line !== undefined && { line }), ...(column !== undefined && { column }) },
338
+ type,
339
+ });
340
+ }
341
+ for (const { node, issues: problems } of result.nodeFailures) {
342
+ for (const { message, dedupeKey } of problems) {
343
+ add({
344
+ kind: 'schema',
345
+ key: `schema:${dedupeKey}`,
346
+ severity: 'error',
347
+ message,
348
+ nodeId: node.id,
349
+ ...at(node.source),
350
+ });
346
351
  }
347
352
  }
348
353
  for (const { id, sources } of result.duplicateErrors) {
349
354
  const where = sources.length > 0 ? `: ${sources.map(formatSource).join(', ')}` : '';
350
- add(id, `duplicate:${id}`, {
355
+ add({
351
356
  kind: 'duplicate',
352
- message: `Duplicate id "${id}" is used by ${sources.length} nodes${where}`,
357
+ key: `duplicate:${id}`,
353
358
  severity: 'error',
359
+ message: `Duplicate id "${id}" is used by ${sources.length} nodes${where}`,
360
+ nodeId: id,
361
+ sources,
354
362
  });
355
363
  }
356
- for (const { nodeId, parent, error } of result.refErrors) {
357
- add(nodeId, `broken-link:${parent}`, { kind: 'broken-link', message: `${parent} → ${error}`, severity: 'error' });
364
+ for (const { nodeId, source, parent, error } of result.refErrors) {
365
+ add({
366
+ kind: 'broken-link',
367
+ key: `broken-link:${parent}`,
368
+ severity: 'error',
369
+ message: `${parent} → ${error}`,
370
+ nodeId,
371
+ ...at(source),
372
+ ref: parent,
373
+ reason: error,
374
+ });
358
375
  }
359
- for (const { nodeId, description } of result.hierarchyViolations) {
360
- add(nodeId, `hierarchy:${description}`, { kind: 'hierarchy', message: description, severity: 'error' });
376
+ for (const { description, ...violation } of result.hierarchyViolations) {
377
+ add({ kind: 'hierarchy', key: `hierarchy:${description}`, severity: 'error', message: description, ...violation });
361
378
  }
362
379
  for (const v of result.ruleViolations) {
363
- if (v.nodeId)
364
- add(v.nodeId, `rule:${v.ruleId}`, { kind: 'rule', message: ruleViolationMessage(v), severity: v.severity });
380
+ add({ kind: 'rule', key: `rule:${v.ruleId}`, message: ruleViolationMessage(v), ...v });
365
381
  }
366
382
  // A gated node with no violations would otherwise look clean: say that its rules were not checked
367
383
  for (const gate of result.coverageGates) {
368
384
  const cause = gate.reason === 'rule' ? `blocking rule "${gate.ruleId}"` : `${gate.reason} failure`;
369
385
  const message = `Rule checks ${options.includeGated ? 'evaluated anyway' : 'not evaluated'}: gated by ${cause} on ${gate.nodeId}`;
370
- for (const id of gate.gatedNodeIds)
371
- add(id, `gated:${gate.nodeId}`, { kind: 'gated', message, severity: 'info' });
386
+ for (const nodeId of gate.gatedNodeIds) {
387
+ add({
388
+ kind: 'gated',
389
+ key: `gated:${gate.nodeId}`,
390
+ severity: 'info',
391
+ message,
392
+ nodeId,
393
+ gatedBy: gate.nodeId,
394
+ reason: gate.reason,
395
+ ...(gate.ruleId !== undefined && { ruleId: gate.ruleId }),
396
+ });
397
+ }
372
398
  }
373
- for (const { nodeId, target, severity } of result.unresolvedContentLinks) {
374
- add(nodeId, `content-link:${target}`, {
399
+ for (const { nodeId, target } of result.unresolvedContentLinks) {
400
+ // Info: a target outside the space is often valid elsewhere
401
+ add({
375
402
  kind: 'content-link',
403
+ key: `content-link:${target}`,
404
+ severity: 'info',
376
405
  message: `Unresolved content link: ${target}`,
377
- severity,
406
+ nodeId,
407
+ target,
378
408
  });
379
409
  }
380
- return byNode;
381
- }
382
- /** `ValidationResult['nodeErrors']` from the failures `validateNodes` collects */
383
- function toNodeErrors(failures) {
384
- return failures.map(({ node, issues }) => ({
385
- nodeId: node.id,
386
- ...(node.source !== undefined && { source: node.source }),
387
- issues,
388
- }));
410
+ return [...issues.values()];
389
411
  }
390
412
  /** The result for a file that belongs to no configured space. */
391
413
  export function fileNotInSpace(filePath) {
@@ -409,16 +431,15 @@ export async function validateResolvedFileWith(filePath, label, context, runOpti
409
431
  ...runOptions,
410
432
  judgeTarget: label,
411
433
  });
412
- const byNode = nodeIssues({ ...result, nodeErrors: toNodeErrors(result.nodeFailures) }, { includeGated: runOptions.includeGated });
413
- const issues = byNode[label] ?? {};
414
- const errorCount = Object.values(issues).filter((issue) => issue.severity === 'error').length;
434
+ const issues = issuesOf(result, [], runOptions).filter((issue) => issue.nodeId === label);
435
+ const errorCount = issues.filter((issue) => issue.severity === 'error').length;
415
436
  return {
416
437
  file: isAbsolute(filePath) ? filePath : resolve(process.cwd(), filePath),
417
438
  label,
418
439
  space: context.space.name,
419
440
  issues,
420
441
  errorCount,
421
- warningCount: Object.keys(issues).length - errorCount,
442
+ warningCount: issues.length - errorCount,
422
443
  inSpace: true,
423
444
  };
424
445
  }
package/docs/concepts.md CHANGED
@@ -119,9 +119,7 @@ A wikilink is looked up in `linkTargets`, and the result is stored as the target
119
119
 
120
120
  ### Validation results and nodes
121
121
 
122
- A validation result refers to a node by its `id`, in a `nodeId` field. Where the node is defined is a separate `source` (`file`, and `line` / `column` where available). A result may copy a node's identity and location, but not its content. A node can lack a `source` (for example when a plugin reads from a service rather than files), so treat `source` as optional.
123
-
124
- Rule violations on a global rule have no `nodeId`.
122
+ A validation run reports a list of **validation issues**. Each has a **kind**, the check that found it, and a **severity**. An issue about a node refers to it by `nodeId`. A node can lack a `source`, for example when a plugin reads from a service rather than files.
125
123
 
126
124
  ### Folder index
127
125
 
package/docs/rules.md CHANGED
@@ -235,8 +235,8 @@ the next). Excluded nodes are reported under `skippedByDepth`, separate from gat
235
235
  ## Output
236
236
 
237
237
  - **`sctx validate`** — the summary counts rule violations per severity, and details are grouped by severity with each line showing the rule's category and id.
238
- - **`sctx validate --json`** — `issues` holds each node's issues of every severity, keyed by node id and then by a composite id such as `rule:<id>`. Each issue has a `kind`, `message` and `severity`. A `ruleViolations` array lists every violation with `file`, `ruleId`, `category`, `severity` and `description` (`file` is empty for global rules). A violation from a failed `expression` has the evidence `expression failed: <expression>` when the rule also has a `semantic` part, and no verdict.
239
- - **`sctx validate-file --json`** — `issues` holds the file's issues of every severity, each with a `severity` field. `errorCount` counts the `error` ones.
238
+ - **`sctx validate --json`** — prints the run's [`ValidationResult`](api.md#main-building-blocks), with `space`, `valid` and `errorCount`. A violation from a failed `expression` has the evidence `expression failed: <expression>` when the rule also has a `semantic` part, and no verdict.
239
+ - **`sctx validate-file --json`** — prints the file's issues, with `errorCount`.
240
240
 
241
241
  Gated files: see [Single-file validation](#single-file-validation).
242
242
 
package/package.json CHANGED
@@ -1,10 +1,14 @@
1
1
  {
2
2
  "name": "structured-context",
3
- "version": "0.22.0",
3
+ "version": "0.23.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",
7
7
  "license": "MIT",
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/mindsocket/structured-context.git"
11
+ },
8
12
  "bin": {
9
13
  "sctx": "dist/index.js"
10
14
  },
@@ -38,6 +42,7 @@
38
42
  "scripts": {
39
43
  "clean": "rm -rf dist",
40
44
  "generate:schema-meta": "bun run scripts/generate-schema-meta.ts",
45
+ "build:mod-projection": "bun run scripts/build-mod-projection.ts",
41
46
  "typecheck": "tsc --noEmit",
42
47
  "build": "bun run clean && bun run generate:schema-meta && tsc -p tsconfig.build.json && cp -r schemas dist/ && chmod +x dist/index.js",
43
48
  "prepublishOnly": "bun run build",
@@ -62,7 +67,10 @@
62
67
  "@anthropic-ai/claude-agent-sdk": "^0.3.310",
63
68
  "@biomejs/biome": "^2.5.15",
64
69
  "@types/bun": "^1.4.4",
65
- "lefthook": "^2.2.2"
70
+ "dts-bundle-generator": "9.5.1",
71
+ "esbuild": "0.28.2",
72
+ "lefthook": "^2.2.2",
73
+ "structured-context-map-views": "^0.5.0"
66
74
  },
67
75
  "peerDependencies": {
68
76
  "typescript": "^5"