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 +4 -4
- package/dist/api.d.ts +3 -2
- package/dist/api.js +2 -1
- package/dist/commands/validate-file.js +2 -2
- package/dist/commands/validate.d.ts +26 -2
- package/dist/commands/validate.js +58 -81
- package/dist/types.d.ts +58 -12
- package/dist/types.js +11 -0
- package/dist/validate.d.ts +29 -40
- package/dist/validate.js +73 -52
- package/docs/concepts.md +1 -3
- package/docs/rules.md +2 -2
- package/package.json +10 -2
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
|
-
|
|
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,
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
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
|
|
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
|
|
63
|
-
console.log(` ${
|
|
56
|
+
for (const { nodeId } of result.orphans)
|
|
57
|
+
console.log(` ${nodeId}`);
|
|
64
58
|
}
|
|
65
|
-
if (
|
|
59
|
+
if (contentLinks.length > 0) {
|
|
66
60
|
console.log(`\nUnresolved content links (may be outside-space or broken):`);
|
|
67
|
-
const
|
|
68
|
-
for (const
|
|
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
|
|
64
|
+
for (const { target } of links)
|
|
76
65
|
console.log(` ${target}`);
|
|
77
66
|
}
|
|
78
67
|
}
|
|
79
|
-
if (
|
|
68
|
+
if (parseIssues.length > 0) {
|
|
80
69
|
console.log(`\nExcluded during parsing:`);
|
|
81
|
-
for (const issue of
|
|
82
|
-
|
|
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 (
|
|
74
|
+
if (schemaIssues.length > 0) {
|
|
88
75
|
console.log(`\nSchema validation errors:`);
|
|
89
|
-
|
|
90
|
-
console.log(`\n ${
|
|
91
|
-
|
|
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 (
|
|
82
|
+
if (brokenLinks.length > 0) {
|
|
97
83
|
console.log(`\nBroken links:`);
|
|
98
|
-
|
|
99
|
-
console.log(` ${
|
|
100
|
-
});
|
|
84
|
+
for (const issue of brokenLinks)
|
|
85
|
+
console.log(` ${where(issue)}: ${issue.message}`);
|
|
101
86
|
}
|
|
102
|
-
if (
|
|
87
|
+
if (duplicates.length > 0) {
|
|
103
88
|
console.log(`\nDuplicate ids (same id on multiple nodes):`);
|
|
104
|
-
|
|
105
|
-
console.log(` "${
|
|
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 (
|
|
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 =
|
|
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 (
|
|
114
|
+
if (hierarchyViolations.length > 0) {
|
|
131
115
|
console.log(`\nHierarchy violations:`);
|
|
132
|
-
for (const v of
|
|
133
|
-
console.log(` ${v.nodeId}: ${v.
|
|
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',
|
|
159
|
-
console.log(fmt(' Broken links',
|
|
160
|
-
console.log(fmt(' Duplicate ids',
|
|
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',
|
|
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)',
|
|
167
|
-
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
|
266
|
-
issues:
|
|
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
package/dist/validate.d.ts
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
14
|
-
|
|
16
|
+
/** Hierarchy nodes without a parent: informational, not issues. */
|
|
17
|
+
orphans: Array<{
|
|
15
18
|
nodeId: NodeId;
|
|
16
|
-
|
|
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:
|
|
104
|
-
|
|
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
|
-
|
|
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[]):
|
|
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
|
-
*
|
|
141
|
-
*
|
|
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
|
|
134
|
+
export declare function issuesOf(result: SpaceValidationResult, parseIssues: ParseIssue[], options: {
|
|
146
135
|
includeGated?: boolean;
|
|
147
|
-
}):
|
|
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
|
|
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
|
-
|
|
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
|
-
*
|
|
329
|
-
*
|
|
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
|
|
334
|
-
const
|
|
335
|
-
const add = (
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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(
|
|
355
|
+
add({
|
|
351
356
|
kind: 'duplicate',
|
|
352
|
-
|
|
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(
|
|
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 {
|
|
360
|
-
add(
|
|
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
|
-
|
|
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
|
|
371
|
-
add(
|
|
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
|
|
374
|
-
|
|
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
|
-
|
|
406
|
+
nodeId,
|
|
407
|
+
target,
|
|
378
408
|
});
|
|
379
409
|
}
|
|
380
|
-
return
|
|
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
|
|
413
|
-
const
|
|
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:
|
|
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
|
|
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`** —
|
|
239
|
-
- **`sctx validate-file --json`** —
|
|
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.
|
|
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
|
-
"
|
|
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"
|