structured-context 0.15.0 → 0.17.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.
Files changed (45) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +21 -13
  3. package/dist/api.d.ts +5 -2
  4. package/dist/api.js +4 -0
  5. package/dist/browser.d.ts +14 -0
  6. package/dist/browser.js +13 -0
  7. package/dist/commands/dump.d.ts +2 -0
  8. package/dist/commands/dump.js +3 -1
  9. package/dist/commands/schemas.d.ts +3 -1
  10. package/dist/commands/schemas.js +25 -53
  11. package/dist/commands/spaces.js +4 -6
  12. package/dist/commands/validate.js +20 -19
  13. package/dist/config/health.d.ts +10 -6
  14. package/dist/config/health.js +11 -44
  15. package/dist/config/index.d.ts +4 -0
  16. package/dist/config/index.js +15 -6
  17. package/dist/filter/augment-nodes.d.ts +2 -2
  18. package/dist/filter/augment-nodes.js +2 -2
  19. package/dist/filter/expand-include.js +2 -2
  20. package/dist/index.js +12 -3
  21. package/dist/judge/context.js +1 -1
  22. package/dist/load-space-graph.js +1 -13
  23. package/dist/plugins/markdown/parse-embedded.js +4 -4
  24. package/dist/plugins/miro/cache.js +1 -1
  25. package/dist/plugins/miro/sync.js +2 -2
  26. package/dist/read/resolve-graph-edges.js +3 -3
  27. package/dist/schema/list-schemas.d.ts +27 -0
  28. package/dist/schema/list-schemas.js +108 -0
  29. package/dist/schema/metadata-contract.d.ts +34 -0
  30. package/dist/schema/metadata-contract.js +2 -2
  31. package/dist/schema/validate-graph.d.ts +2 -2
  32. package/dist/schema/validate-graph.js +4 -4
  33. package/dist/schemas/generated/space-graph/1.json +216 -0
  34. package/dist/schemas/generated/space-graph/latest.json +216 -0
  35. package/dist/space-context.js +11 -1
  36. package/dist/space-graph-data.d.ts +237 -0
  37. package/dist/space-graph-data.js +129 -0
  38. package/dist/space-graph.d.ts +12 -1
  39. package/dist/space-graph.js +25 -1
  40. package/dist/testing.js +3 -3
  41. package/dist/types.d.ts +2 -2
  42. package/docs/concepts.md +8 -4
  43. package/package.json +5 -1
  44. package/schemas/generated/space-graph/1.json +216 -0
  45. package/schemas/generated/space-graph/latest.json +216 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Roger Barnes
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -21,6 +21,18 @@ Command line tools:
21
21
  ```
22
22
  > sctx validate strategy
23
23
 
24
+ Orphans (hierarchy nodes - no parent):
25
+ Solution looking for a problem.md
26
+
27
+ Broken links:
28
+ Steal underpants.md: [[Gnome goals]] → Link target "Gnome goals" in field "related" not found
29
+
30
+ Rule violations:
31
+ WARNING (1):
32
+ Build Death Star.md: Opportunities should contribute to at least one Outcome [workflow: opportunity-has-outcome]
33
+ INFO (1):
34
+ High tech juice press.md: Explore multiple candidate solutions (aim for at least three) for the target opportunity [best-practice: solution-quantity]
35
+
24
36
  Space Validation Results
25
37
  ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
26
38
  Content and structure
@@ -35,18 +47,6 @@ Content and structure
35
47
  Orphans (hierarchy nodes - no parent) 1
36
48
  Unresolved content links 0
37
49
  Excluded during parsing 0
38
-
39
- Orphans (hierarchy nodes - no parent):
40
- Solution looking for a problem.md
41
-
42
- Broken links:
43
- Steal underpants.md: [[Gnome goals]] → Link target "Gnome goals" in field "related" not found
44
-
45
- Rule violations:
46
- WARNING (1):
47
- Build Death Star.md: Opportunities should contribute to at least one Outcome [workflow: opportunity-has-outcome]
48
- INFO (1):
49
- High tech juice press.md: Explore multiple candidate solutions (aim for at least three) for the target opportunity [best-practice: solution-quantity]
50
50
  ```
51
51
 
52
52
  Rule violations carry a severity (`error`, `warning` or `info`) that defaults from the rule's category. Only errors fail validation — see [docs/rules.md](docs/rules.md#rule-shape).
@@ -188,6 +188,14 @@ Generates a Mermaid `graph TD` diagram from validated space nodes:
188
188
  - Handles orphan nodes (no parent) as a separate cluster
189
189
  - Outputs to file or stdout
190
190
 
191
+ ### List schemas
192
+
193
+ ```bash
194
+ sctx schemas list [--json]
195
+ ```
196
+
197
+ Lists the bundled schemas, schemas contributed by plugins, and any other schema file your config uses, with the spaces that use each. `--json` gives the data that `listSchemas` returns; see [docs/api.md](docs/api.md).
198
+
191
199
  ### Show schema ERD
192
200
 
193
201
  ```bash
@@ -229,7 +237,7 @@ Keeps Obsidian template files in sync with schema examples:
229
237
  ## Programmatic API
230
238
 
231
239
  The package exposes a library entry point at `structured-context/api` for embedding the tooling
232
- in other applications. See
240
+ in other applications, and `structured-context/browser` for loading an exported space graph in a browser. See
233
241
  [docs/api.md](docs/api.md) for the full reference and usage examples.
234
242
 
235
243
  ## Development
package/dist/api.d.ts CHANGED
@@ -18,6 +18,7 @@
18
18
  * const validation = await validateSpace(ctx, { readResult });
19
19
  */
20
20
  export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
21
+ export { type SpaceGraph, type SpaceNode, spaceGraphFrom } from './browser';
21
22
  export type { Config, SpaceConfig } from './config';
22
23
  export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
23
24
  export { addSpace } from './config/write';
@@ -27,10 +28,12 @@ export type { ParseHook, ParseResult, PluginContext, RenderFileOutput, RenderFor
27
28
  export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
28
29
  export { readSpace } from './read/read-space';
29
30
  export { buildTargetIndex, formatWikilink, type ParsedWikilink, parseWikilink, wikilinkToTarget, } from './read/wikilink-utils';
31
+ export { listSchemas } from './schema/list-schemas';
30
32
  export type { SharedEmbeddingFields } from './schema/metadata-contract';
31
33
  export { bundledSchemasDir, loadSchema, type PluginSchemaSource, setBundledSchemasDir } from './schema/schema';
32
34
  export { createSpaceContext, SpaceNotFoundError } from './space-context';
33
- export { buildSpaceGraph, type SpaceGraph } from './space-graph';
34
- export type { BaseNode, ContentLink, EdgeDefinition, FileNotInSpaceResult, FileValidationIssue, FileValidationResult, Hierarchy, HierarchyLevel, ParseIssue, ReadSpaceResult, Relationship, ResolvedContentLink, RuleCategory, RuleSeverity, RuleViolation, SchemaMetadata, SchemaWithMetadata, SourceLocation, SpaceContext, SpaceNode, UnresolvedRef, ValidateFileOutput, } from './types';
35
+ export { buildSpaceGraph } from './space-graph';
36
+ export { exportSpaceGraph } from './space-graph-data';
37
+ 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';
35
38
  export type { ValidationResult } from './validate';
36
39
  export { validateFile, validateSpace } from './validate';
package/dist/api.js CHANGED
@@ -1,3 +1,4 @@
1
+ // Public entry point: do not add exports without the maintainer's agreement (AGENTS.md, "Public API surface").
1
2
  /**
2
3
  * Public API for structured-context.
3
4
  *
@@ -17,6 +18,7 @@
17
18
  * const graph = await assembleSpaceGraph(ctx, { readResult });
18
19
  * const validation = await validateSpace(ctx, { readResult });
19
20
  */
21
+ export { spaceGraphFrom } from './browser';
20
22
  export { loadConfig, resolveConfigPath, resolveSchema, resolveSchemaPath, setConfigPath } from './config';
21
23
  export { addSpace } from './config/write';
22
24
  export { LOCAL_CONFIG_FILE, PLUGIN_PREFIX } from './constants';
@@ -24,7 +26,9 @@ export { assembleSpaceGraph, loadSpaceGraph } from './load-space-graph';
24
26
  export { isRenderMultiFileOutput, resolvePluginDir, resolvePluginSchemasDir, resolveSpacePluginSchemas, } from './plugins/util';
25
27
  export { readSpace } from './read/read-space';
26
28
  export { buildTargetIndex, formatWikilink, parseWikilink, wikilinkToTarget, } from './read/wikilink-utils';
29
+ export { listSchemas } from './schema/list-schemas';
27
30
  export { bundledSchemasDir, loadSchema, setBundledSchemasDir } from './schema/schema';
28
31
  export { createSpaceContext, SpaceNotFoundError } from './space-context';
29
32
  export { buildSpaceGraph } from './space-graph';
33
+ export { exportSpaceGraph } from './space-graph-data';
30
34
  export { validateFile, validateSpace } from './validate';
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Browser-safe API for structured-context: nothing here imports Node built-ins.
3
+ *
4
+ * Rebuild a graph that a server exported with exportSpaceGraph:
5
+ *
6
+ * import { spaceGraphFrom } from 'structured-context/browser';
7
+ * const graph = spaceGraphFrom(await (await fetch('/graph.json')).json());
8
+ *
9
+ * Everything here is also exported from `structured-context/api`. Derive other types from these, e.g.
10
+ * `Parameters<typeof spaceGraphFrom>[0]` for the exported data or `SpaceNode['id']` for a node id.
11
+ */
12
+ export type { SpaceGraph } from './space-graph';
13
+ export { spaceGraphFrom } from './space-graph-data';
14
+ export type { SpaceNode } from './types';
@@ -0,0 +1,13 @@
1
+ // Public entry point: do not add exports without the maintainer's agreement (AGENTS.md, "Public API surface").
2
+ /**
3
+ * Browser-safe API for structured-context: nothing here imports Node built-ins.
4
+ *
5
+ * Rebuild a graph that a server exported with exportSpaceGraph:
6
+ *
7
+ * import { spaceGraphFrom } from 'structured-context/browser';
8
+ * const graph = spaceGraphFrom(await (await fetch('/graph.json')).json());
9
+ *
10
+ * Everything here is also exported from `structured-context/api`. Derive other types from these, e.g.
11
+ * `Parameters<typeof spaceGraphFrom>[0]` for the exported data or `SpaceNode['id']` for a node id.
12
+ */
13
+ export { spaceGraphFrom } from './space-graph-data';
@@ -4,6 +4,8 @@ export interface DumpOptions {
4
4
  filter?: string;
5
5
  /** Hierarchy used when evaluating the filter (default: the main hierarchy). */
6
6
  hierarchy?: string;
7
+ /** Emit SpaceGraphData (nodes plus every hierarchy) instead of nodes, source and diagnostics. */
8
+ graph?: boolean;
7
9
  }
8
10
  /**
9
11
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
@@ -1,5 +1,6 @@
1
1
  import { assembleSpaceGraph } from '../load-space-graph';
2
2
  import { readSpace } from '../read/read-space';
3
+ import { exportSpaceGraph } from '../space-graph-data';
3
4
  /**
4
5
  * Build the dump payload: every parsed node, or only nodes matched by a filter.
5
6
  *
@@ -16,5 +17,6 @@ export async function buildDump(context, options = {}) {
16
17
  }
17
18
  export async function dump(context, options = {}) {
18
19
  const { nodes, source, diagnostics } = await buildDump(context, options);
19
- console.log(JSON.stringify({ nodes, source, diagnostics }, null, 2));
20
+ const output = options.graph ? exportSpaceGraph(nodes, context.schema.metadata) : { nodes, source, diagnostics };
21
+ console.log(JSON.stringify(output, null, 2));
20
22
  }
@@ -1,4 +1,6 @@
1
- export declare function listSchemas(): Promise<void>;
1
+ export declare function printSchemas(options?: {
2
+ json?: boolean;
3
+ }): Promise<void>;
2
4
  export declare function showSchema(file: string | undefined, options: {
3
5
  space?: string;
4
6
  raw: boolean;
@@ -1,16 +1,14 @@
1
1
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
2
- import { dirname, join, resolve } from 'node:path';
3
- import { loadConfig } from '../config';
2
+ import { join, resolve } from 'node:path';
3
+ import { loadConfig, loadConfigOrEmpty } from '../config';
4
4
  import { discoverPlugins } from '../plugins/loader';
5
5
  import { normalizePluginName, resolvePluginSchemasDir, resolveSpacePluginSchemas, shortenPluginName, } from '../plugins/util';
6
+ import { listSchemas } from '../schema/list-schemas';
6
7
  import { ruleSemantic } from '../schema/rule-severity';
7
8
  import { bundledSchemasDir, extractEntityInfo, loadSchema, readRawSchema, } from '../schema/schema';
8
9
  import { mergeVariantProperties, resolveRef } from '../schema/schema-refs';
9
10
  import { DEFAULT_SEVERITY_BY_CATEGORY } from '../schema/validate-rules';
10
11
  import { createSpaceContext } from '../space-context';
11
- function isBundledPath(schemaPath) {
12
- return dirname(schemaPath) === bundledSchemasDir;
13
- }
14
12
  function extractRefs(obj, refs) {
15
13
  if (typeof obj !== 'object' || obj === null)
16
14
  return;
@@ -207,60 +205,34 @@ function ruleAnnotations(rule) {
207
205
  notes.push(`when: ${rule.when}`);
208
206
  return notes.length > 0 ? ` [${notes.join('] [')}]` : '';
209
207
  }
210
- export async function listSchemas() {
211
- const config = loadConfig();
212
- // List all schemas known to config
213
- if (existsSync(bundledSchemasDir)) {
214
- const files = readdirSync(bundledSchemasDir)
215
- .filter((f) => f.endsWith('.json'))
216
- .sort();
217
- if (files.length > 0) {
218
- console.log('Bundled schemas:');
219
- for (const file of files) {
220
- const schema = readRawSchema(join(bundledSchemasDir, file));
221
- const id = typeof schema.$id === 'string' ? schema.$id : '(no $id)';
222
- console.log(` ${file}${file.startsWith('_') ? ' [partial]' : ''} (${id})`);
223
- }
224
- }
208
+ export async function printSchemas(options = {}) {
209
+ const listings = await listSchemas();
210
+ if (options.json) {
211
+ console.log(JSON.stringify(listings, null, 2));
212
+ return;
225
213
  }
226
- // List schemas provided by discovered plugins
227
- const plugins = await discoverPlugins();
228
- for (const p of plugins) {
229
- if (p.schemasDir && existsSync(p.schemasDir)) {
230
- const files = readdirSync(p.schemasDir)
231
- .filter((f) => f.endsWith('.json'))
232
- .sort();
233
- if (files.length > 0) {
234
- console.log(`\nPlugin schemas (${p.name}):`);
235
- for (const file of files) {
236
- const schema = readRawSchema(join(p.schemasDir, file));
237
- const id = typeof schema.$id === 'string' ? schema.$id : '(no $id)';
238
- console.log(` ${file}${file.startsWith('_') ? ' [partial]' : ''} (${id})`);
239
- }
214
+ const sources = [...new Set(listings.map((l) => l.source))].sort((a, b) => Number(b === 'core') - Number(a === 'core') || Number(a === 'configured') - Number(b === 'configured'));
215
+ for (const source of sources) {
216
+ const group = listings.filter((l) => l.source === source);
217
+ if (source === 'core')
218
+ console.log('Bundled schemas:');
219
+ else if (source === 'configured')
220
+ console.log('\nConfigured schemas:');
221
+ else
222
+ console.log(`\nPlugin schemas (${source}):`);
223
+ for (const l of group) {
224
+ if (source === 'configured')
225
+ console.log(` ${l.usedBy.join(', ')}: ${l.path ?? l.schema}${l.error ? ` [${l.error}]` : ''}`);
226
+ else {
227
+ const notes = [l.partial && '[partial]', l.id && `(${l.id})`, l.error && `[${l.error}]`].filter(Boolean);
228
+ console.log(` ${l.schema}${notes.length > 0 ? ` ${notes.join(' ')}` : ''}`);
240
229
  }
241
230
  }
242
231
  }
243
- const configured = [];
244
- const seen = new Set();
245
- if (config.schema && !isBundledPath(config.schema)) {
246
- configured.push({ source: 'global', path: config.schema });
247
- seen.add(config.schema);
248
- }
249
- for (const space of config.spaces) {
250
- if (space.schema && !seen.has(space.schema) && !isBundledPath(space.schema)) {
251
- configured.push({ source: space.name, path: space.schema });
252
- seen.add(space.schema);
253
- }
254
- }
255
- if (configured.length > 0) {
256
- console.log('\nConfigured schemas:');
257
- for (const { source, path } of configured) {
258
- console.log(` ${source}: ${path}`);
259
- }
260
- }
261
232
  }
262
233
  export async function showSchema(file, options) {
263
- const config = loadConfig();
234
+ // A schema named by file needs no config; a space does, so a missing one is worth a warning.
235
+ const config = options.space ? loadConfig() : loadConfigOrEmpty();
264
236
  let schemaPath;
265
237
  let schema;
266
238
  let schemaRefRegistry;
@@ -1,6 +1,5 @@
1
1
  import { basename, resolve } from 'node:path';
2
- import { loadConfig } from '../config';
3
- import { checkConfig, hasBlockingProblem } from '../config/health';
2
+ import { hasBlockingProblem, inspectConfig } from '../config/health';
4
3
  import { addSpace } from '../config/write';
5
4
  import { SctxError } from '../errors';
6
5
  function renderConfigValue(v) {
@@ -9,7 +8,7 @@ function renderConfigValue(v) {
9
8
  return String(v);
10
9
  }
11
10
  export function listSpaces(options = {}) {
12
- const health = checkConfig();
11
+ const { health, config } = inspectConfig();
13
12
  if (options.json) {
14
13
  console.log(JSON.stringify(health));
15
14
  if (hasBlockingProblem(health))
@@ -27,10 +26,9 @@ export function listSpaces(options = {}) {
27
26
  console.log('');
28
27
  if (hasBlockingProblem(health))
29
28
  process.exitCode = 1;
30
- if (!health.config.exists)
31
- return;
32
29
  }
33
- const config = loadConfig();
30
+ if (!config)
31
+ return;
34
32
  if (health.problems.length === 0)
35
33
  console.log(`Config: ${path} (${health.config.source})\n`);
36
34
  for (const space of config.spaces) {
@@ -91,26 +91,7 @@ export async function validate(context, options = {}) {
91
91
  const countStr = String(count).padStart(3);
92
92
  return `${label.padEnd(40)} ${color}${countStr}${reset}`;
93
93
  };
94
- console.log(`\nSpace Validation Results`);
95
- console.log(`━`.repeat(45));
96
- console.log('Content and structure');
97
- console.log(fmt(' Valid', result.validCount));
98
- console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
99
- console.log(fmt(' Broken links', result.refErrors.length, true));
100
- console.log(fmt(' Duplicate ids', result.duplicateErrors.length, true));
101
- console.log(fmt(' Rule violations (error)', ruleCounts.error, true));
102
- console.log(fmt(' Rule violations (warning)', ruleCounts.warning, true, true));
103
- console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
104
- console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
105
- console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
106
- console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
107
94
  const parseIssueErrorCount = result.parseIssues.filter((i) => i.severity === 'error').length;
108
- console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
109
- if (result.depthLimit !== undefined) {
110
- console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
111
- }
112
- const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedFiles.length, 0);
113
- console.log(fmt(' Rule checks gated (cascade)', gatedFileCount, true, true));
114
95
  if (result.orphans.length > 0) {
115
96
  console.log(`\nOrphans (hierarchy nodes - no parent):`);
116
97
  for (const node of result.orphans)
@@ -207,6 +188,26 @@ export async function validate(context, options = {}) {
207
188
  if (result.contextTrimmed.nodeCount > 0) {
208
189
  console.log(`\nContext trimmed for ${result.contextTrimmed.nodeCount} node(s) across rules ${result.contextTrimmed.ruleIds.join(', ')}.`);
209
190
  }
191
+ // Last, so a truncated tail (`| tail`) still shows the counts.
192
+ console.log(`\nSpace Validation Results`);
193
+ console.log(`━`.repeat(45));
194
+ console.log('Content and structure');
195
+ console.log(fmt(' Valid', result.validCount));
196
+ console.log(fmt(' Schema validation errors', result.nodeErrorCount, true));
197
+ console.log(fmt(' Broken links', result.refErrors.length, true));
198
+ console.log(fmt(' Duplicate ids', result.duplicateErrors.length, true));
199
+ console.log(fmt(' Rule violations (error)', ruleCounts.error, true));
200
+ console.log(fmt(' Rule violations (warning)', ruleCounts.warning, true, true));
201
+ console.log(fmt(' Rule violations (info)', ruleCounts.info, true, true));
202
+ console.log(fmt(' Hierarchy violations', result.hierarchyViolations.length, true));
203
+ console.log(fmt(' Orphans (hierarchy nodes - no parent)', result.orphans.length, true, true));
204
+ console.log(fmt(' Unresolved content links', result.unresolvedContentLinks.length, true, true));
205
+ console.log(fmt(' Excluded during parsing', result.parseIssues.length, true, parseIssueErrorCount === 0));
206
+ if (result.depthLimit !== undefined) {
207
+ console.log(fmt(' Skipped by depth limit', result.skippedByDepth.length, true, true));
208
+ }
209
+ const gatedFileCount = result.coverageGates.reduce((sum, g) => sum + g.gatedFiles.length, 0);
210
+ console.log(fmt(' Rule checks gated (cascade)', gatedFileCount, true, true));
210
211
  console.log(`\n`);
211
212
  if (result.nodeErrorCount > 0 ||
212
213
  result.refErrors.length > 0 ||
@@ -1,5 +1,5 @@
1
1
  import { type SctxErrorCode } from '../errors';
2
- import { type ResolvedConfigPath } from '.';
2
+ import { type Config, type ResolvedConfigPath } from '.';
3
3
  export type ConfigProblem = {
4
4
  code: SctxErrorCode;
5
5
  severity: 'error' | 'warning' | 'info';
@@ -21,11 +21,15 @@ export type ConfigHealth = {
21
21
  };
22
22
  export declare function fixFor(code: SctxErrorCode): string;
23
23
  /**
24
- * Check that the config for the working directory can be found and loaded, and that each space's
25
- * path and schema exist. Never throws: every failure becomes a problem with a stable code.
24
+ * Find and load the config for the working directory, reporting failures as problems with stable
25
+ * codes instead of throwing. It reports on the config only: what a space's `path` means belongs to
26
+ * the plugin that reads it, and a schema is checked when it is loaded.
26
27
  */
27
- export declare function checkConfig(options?: {
28
+ export declare function inspectConfig(options?: {
28
29
  startDir?: string;
29
- }): ConfigHealth;
30
- /** True when the health check found something that stops validation from running. */
30
+ }): {
31
+ health: ConfigHealth;
32
+ config?: Config;
33
+ };
34
+ /** True when the config problems stop validation from running. */
31
35
  export declare function hasBlockingProblem(health: ConfigHealth): boolean;
@@ -1,8 +1,7 @@
1
- import { existsSync, readFileSync } from 'node:fs';
2
- import JSON5 from 'json5';
1
+ import { existsSync } from 'node:fs';
3
2
  import { LOCAL_CONFIG_FILE } from '../constants';
4
3
  import { SctxError } from '../errors';
5
- import { getConfigSourceFiles, loadConfig, resolveConfigPath, resolveSchema } from '.';
4
+ import { getConfigSourceFiles, loadConfig, resolveConfigPath } from '.';
6
5
  const FIXES = {
7
6
  E_CONFIG_NOT_FOUND: `Create a config with \`sctx spaces add <name> <path> --schema <schema> --config <folder>/${LOCAL_CONFIG_FILE}\`, or use the setup-space skill.`,
8
7
  E_CONFIG_INVALID: 'Fix the JSON in the config file named in the message; `sctx docs config` describes the shape.',
@@ -11,7 +10,7 @@ const FIXES = {
11
10
  E_SPACE_PATH_MISSING: 'The folder or file was moved or deleted. Correct `path` in the config, or remove the space.',
12
11
  E_SCHEMA_MISSING: 'Set `schema` on the space or at the top level of the config file.',
13
12
  E_SCHEMA_NOT_FOUND: 'Check the schema name or path. `sctx schemas list` shows the bundled schemas.',
14
- E_SCHEMA_INVALID: 'The schema file is not valid JSON5. Open it and fix the syntax.',
13
+ E_SCHEMA_INVALID: 'The schema could not be loaded. The message says what to fix; `sctx docs schema` describes the format.',
15
14
  };
16
15
  export function fixFor(code) {
17
16
  return FIXES[code] ?? '';
@@ -20,10 +19,11 @@ function problem(code, severity, message, space) {
20
19
  return { code, severity, message, fix: fixFor(code), ...(space ? { space } : {}) };
21
20
  }
22
21
  /**
23
- * Check that the config for the working directory can be found and loaded, and that each space's
24
- * path and schema exist. Never throws: every failure becomes a problem with a stable code.
22
+ * Find and load the config for the working directory, reporting failures as problems with stable
23
+ * codes instead of throwing. It reports on the config only: what a space's `path` means belongs to
24
+ * the plugin that reads it, and a schema is checked when it is loaded.
25
25
  */
26
- export function checkConfig(options = {}) {
26
+ export function inspectConfig(options = {}) {
27
27
  const resolved = resolveConfigPath(options.startDir);
28
28
  const exists = existsSync(resolved.path);
29
29
  const health = { config: { ...resolved, exists }, configFiles: [], spaces: [], problems: [] };
@@ -32,7 +32,7 @@ export function checkConfig(options = {}) {
32
32
  ? `No ${LOCAL_CONFIG_FILE} found from the working directory upward, and no global config at ${resolved.path}`
33
33
  : `Config file not found: ${resolved.path}`;
34
34
  health.problems.push(problem('E_CONFIG_NOT_FOUND', resolved.source === 'none' ? 'info' : 'error', detail));
35
- return health;
35
+ return { health };
36
36
  }
37
37
  let config;
38
38
  try {
@@ -41,46 +41,13 @@ export function checkConfig(options = {}) {
41
41
  catch (err) {
42
42
  const code = err instanceof SctxError ? err.code : 'E_CONFIG_INVALID';
43
43
  health.problems.push(problem(code, 'error', err.message));
44
- return health;
44
+ return { health };
45
45
  }
46
46
  health.configFiles = [...getConfigSourceFiles()];
47
47
  health.spaces = config.spaces.map((s) => ({ name: s.name, path: s.path, schema: s.schema ?? config.schema }));
48
- if (config.spaces.length === 0) {
49
- health.problems.push(problem('E_NO_SPACES', 'warning', `Config ${resolved.path} defines no spaces`));
50
- }
51
- for (const space of config.spaces) {
52
- if (!existsSync(space.path)) {
53
- health.problems.push(problem('E_SPACE_PATH_MISSING', 'error', `Space "${space.name}" path does not exist: ${space.path}`, space.name));
54
- }
55
- const schemaRef = space.schema ?? config.schema;
56
- if (!schemaRef) {
57
- health.problems.push(problem('E_SCHEMA_MISSING', 'error', `Space "${space.name}" has no schema`, space.name));
58
- continue;
59
- }
60
- let schemaPath;
61
- try {
62
- schemaPath = resolveSchema(config, space);
63
- }
64
- catch (err) {
65
- health.problems.push(problem('E_SCHEMA_NOT_FOUND', 'error', err.message, space.name));
66
- continue;
67
- }
68
- if (/^https?:\/\//i.test(schemaPath))
69
- continue;
70
- if (!existsSync(schemaPath)) {
71
- health.problems.push(problem('E_SCHEMA_NOT_FOUND', 'error', `Space "${space.name}" schema not found: ${schemaPath}`, space.name));
72
- continue;
73
- }
74
- try {
75
- JSON5.parse(readFileSync(schemaPath, 'utf-8'));
76
- }
77
- catch (err) {
78
- health.problems.push(problem('E_SCHEMA_INVALID', 'error', `Space "${space.name}" schema ${schemaPath} is not valid: ${err.message}`, space.name));
79
- }
80
- }
81
- return health;
48
+ return { health, config };
82
49
  }
83
- /** True when the health check found something that stops validation from running. */
50
+ /** True when the config problems stop validation from running. */
84
51
  export function hasBlockingProblem(health) {
85
52
  return health.problems.some((p) => p.severity === 'error');
86
53
  }
@@ -48,6 +48,10 @@ export declare function configPath(startDir?: string): string;
48
48
  export declare function loadConfig(options?: {
49
49
  startDir?: string;
50
50
  }): Config;
51
+ /** Like `loadConfig`, for commands that work without a config: a missing config is empty, with no warning. */
52
+ export declare function loadConfigOrEmpty(options?: {
53
+ startDir?: string;
54
+ }): Config;
51
55
  /** Get the full space config entry by name. Throws if not found. */
52
56
  export declare function getSpaceConfig(name: string, config: Config): SpaceConfig;
53
57
  /**
@@ -72,7 +72,7 @@ export function setConfigPath(path) {
72
72
  /** Get all config file paths that were loaded (main config + included configs). */
73
73
  export function getConfigSourceFiles() {
74
74
  if (_spaceSourceFiles.size === 0) {
75
- loadConfig();
75
+ loadConfigOrEmpty();
76
76
  }
77
77
  return new Set(_spaceSourceFiles.values());
78
78
  }
@@ -169,12 +169,21 @@ function expandHome(p) {
169
169
  return p === '~' || p.startsWith('~/') ? join(process.env.HOME ?? homedir(), p.slice(1)) : p;
170
170
  }
171
171
  export function loadConfig(options = {}) {
172
- const { path, source } = resolveConfigPath(options.startDir);
172
+ return loadConfigFile(options.startDir, true);
173
+ }
174
+ /** Like `loadConfig`, for commands that work without a config: a missing config is empty, with no warning. */
175
+ export function loadConfigOrEmpty(options = {}) {
176
+ return loadConfigFile(options.startDir, false);
177
+ }
178
+ function loadConfigFile(startDir, warnIfMissing) {
179
+ const { path, source } = resolveConfigPath(startDir);
173
180
  if (!existsSync(path)) {
174
- const where = source === 'none'
175
- ? `no ${LOCAL_CONFIG_FILE} found from ${resolve(options.startDir ?? process.cwd())} upward, and none at ${path}`
176
- : path;
177
- console.warn(`E_CONFIG_NOT_FOUND: Config file not found (${where})`);
181
+ if (warnIfMissing) {
182
+ const where = source === 'none'
183
+ ? `no ${LOCAL_CONFIG_FILE} found from ${resolve(startDir ?? process.cwd())} upward, and none at ${path}`
184
+ : path;
185
+ console.warn(`E_CONFIG_NOT_FOUND: Config file not found (${where})`);
186
+ }
178
187
  return { spaces: [] };
179
188
  }
180
189
  const config = _loadConfig(path);
@@ -2,7 +2,7 @@ import type { NodeId, SpaceNode } from '../types';
2
2
  /** Edge metadata merged into each ancestor/descendant entry. */
3
3
  export type EdgeMetadata = {
4
4
  _field: string;
5
- _source: 'hierarchy' | 'relationship';
5
+ _kind: 'hierarchy' | 'relationship';
6
6
  /** Hierarchy name, present only for hierarchy edges. */
7
7
  _hierarchy?: string;
8
8
  _selfRef: boolean;
@@ -21,6 +21,6 @@ export type AugmentedFlatNode = Record<string, unknown> & {
21
21
  *
22
22
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by id.
23
23
  * - descendants: BFS via childrenIndex, nearest first, deduplicated by id.
24
- * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
24
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _kind, _hierarchy, _selfRef).
25
25
  */
26
26
  export declare function augmentNode(node: SpaceNode, nodeIndex: ReadonlyMap<NodeId, SpaceNode>, childrenIndex: ReadonlyMap<NodeId, readonly SpaceNode[]>): AugmentedFlatNode;
@@ -2,7 +2,7 @@
2
2
  function edgeMetadata(ref) {
3
3
  return {
4
4
  _field: ref.field,
5
- _source: ref.source,
5
+ _kind: ref.kind,
6
6
  ...(ref.hierarchy !== undefined ? { _hierarchy: ref.hierarchy } : {}),
7
7
  _selfRef: ref.selfRef,
8
8
  };
@@ -22,7 +22,7 @@ function flattenData(node) {
22
22
  *
23
23
  * - ancestors: BFS from node via resolvedParents, nearest first, deduplicated by id.
24
24
  * - descendants: BFS via childrenIndex, nearest first, deduplicated by id.
25
- * - Each entry merges the parent/child node's fields with edge metadata (_field, _source, _hierarchy, _selfRef).
25
+ * - Each entry merges the parent/child node's fields with edge metadata (_field, _kind, _hierarchy, _selfRef).
26
26
  */
27
27
  export function augmentNode(node, nodeIndex, childrenIndex) {
28
28
  const ancestors = buildAncestors(node, nodeIndex);
@@ -139,13 +139,13 @@ function applyDirective(node, aug, directive, childrenIndex, addById) {
139
139
  case 'relationships': {
140
140
  // Relationship-sourced ancestors (matched node is the child side)
141
141
  for (const a of aug.ancestors) {
142
- if (a._source === 'relationship' && matchesRelSpec(node, a, directive, true)) {
142
+ if (a._kind === 'relationship' && matchesRelSpec(node, a, directive, true)) {
143
143
  addById(a.id);
144
144
  }
145
145
  }
146
146
  // Relationship-sourced descendants (matched node is the parent side)
147
147
  for (const d of aug.descendants) {
148
- if (d._source === 'relationship' && matchesRelSpec(node, d, directive, false)) {
148
+ if (d._kind === 'relationship' && matchesRelSpec(node, d, directive, false)) {
149
149
  addById(d.id);
150
150
  }
151
151
  }