structured-context 0.12.0 → 0.13.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 (94) hide show
  1. package/README.md +29 -216
  2. package/dist/api.d.ts +17 -17
  3. package/dist/api.js +12 -12
  4. package/dist/commands/docs.js +1 -0
  5. package/dist/commands/providers.d.ts +4 -0
  6. package/dist/commands/providers.js +52 -0
  7. package/dist/commands/schemas.js +21 -2
  8. package/dist/commands/validate-file.d.ts +3 -0
  9. package/dist/commands/validate-file.js +17 -2
  10. package/dist/commands/validate.d.ts +4 -0
  11. package/dist/commands/validate.js +42 -3
  12. package/dist/config.d.ts +7 -0
  13. package/dist/config.js +26 -3
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +59 -18
  16. package/dist/judge/context.d.ts +20 -0
  17. package/dist/judge/context.js +105 -0
  18. package/dist/judge/factory.d.ts +44 -0
  19. package/dist/judge/factory.js +148 -0
  20. package/dist/judge/index.d.ts +13 -0
  21. package/dist/judge/index.js +8 -0
  22. package/dist/judge/judge-node.d.ts +27 -0
  23. package/dist/judge/judge-node.js +122 -0
  24. package/dist/judge/prompt.d.ts +10 -0
  25. package/dist/judge/prompt.js +92 -0
  26. package/dist/judge/provider/anthropic.d.ts +17 -0
  27. package/dist/judge/provider/anthropic.js +43 -0
  28. package/dist/judge/provider/fake.d.ts +26 -0
  29. package/dist/judge/provider/fake.js +56 -0
  30. package/dist/judge/provider/jev.d.ts +37 -0
  31. package/dist/judge/provider/jev.js +169 -0
  32. package/dist/judge/provider/openai-decisions.d.ts +21 -0
  33. package/dist/judge/provider/openai-decisions.js +84 -0
  34. package/dist/judge/provider/openai.d.ts +20 -0
  35. package/dist/judge/provider/openai.js +53 -0
  36. package/dist/judge/provider/per-rule.d.ts +10 -0
  37. package/dist/judge/provider/per-rule.js +18 -0
  38. package/dist/judge/trim.d.ts +27 -0
  39. package/dist/judge/trim.js +62 -0
  40. package/dist/judge/types.d.ts +92 -0
  41. package/dist/judge/types.js +9 -0
  42. package/dist/judge/verdicts.d.ts +24 -0
  43. package/dist/judge/verdicts.js +82 -0
  44. package/dist/load-space-graph.d.ts +3 -3
  45. package/dist/load-space-graph.js +4 -4
  46. package/dist/plugins/loader.js +1 -1
  47. package/dist/plugins/markdown/parse-embedded.d.ts +2 -0
  48. package/dist/plugins/markdown/parse-embedded.js +161 -24
  49. package/dist/plugins/markdown/read-space.js +4 -9
  50. package/dist/plugins/markdown/render-directory.js +5 -5
  51. package/dist/plugins/markdown/template-sync.js +3 -4
  52. package/dist/plugins/markdown/util.d.ts +26 -5
  53. package/dist/plugins/markdown/util.js +56 -15
  54. package/dist/render/render.d.ts +1 -1
  55. package/dist/render/render.js +1 -1
  56. package/dist/schema/evaluate-rule.d.ts +33 -2
  57. package/dist/schema/evaluate-rule.js +58 -4
  58. package/dist/schema/gating.d.ts +69 -0
  59. package/dist/schema/gating.js +253 -0
  60. package/dist/schema/metadata-contract.d.ts +625 -12
  61. package/dist/schema/metadata-contract.js +110 -4
  62. package/dist/schema/rule-filter.d.ts +12 -0
  63. package/dist/schema/rule-filter.js +46 -0
  64. package/dist/schema/rule-severity.d.ts +13 -0
  65. package/dist/schema/rule-severity.js +33 -0
  66. package/dist/schema/schema.js +31 -1
  67. package/dist/schema/validate-rules.d.ts +17 -8
  68. package/dist/schema/validate-rules.js +91 -48
  69. package/dist/schemas/_knowledge_wiki.json +4 -4
  70. package/dist/schemas/_okf.json +172 -0
  71. package/dist/schemas/_ost_strict.json +15 -6
  72. package/dist/schemas/_sctx_base.json +1 -1
  73. package/dist/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  74. package/dist/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
  75. package/dist/schemas/okf.json +8 -0
  76. package/dist/space-context.d.ts +2 -2
  77. package/dist/space-context.js +3 -3
  78. package/dist/space-graph.d.ts +1 -1
  79. package/dist/testing.d.ts +1 -1
  80. package/dist/types.d.ts +11 -4
  81. package/dist/validate.d.ts +59 -9
  82. package/dist/validate.js +225 -120
  83. package/docs/concepts.md +52 -6
  84. package/docs/config.md +70 -1
  85. package/docs/rules.md +140 -49
  86. package/docs/schemas.md +43 -18
  87. package/package.json +6 -5
  88. package/schemas/_knowledge_wiki.json +4 -4
  89. package/schemas/_okf.json +172 -0
  90. package/schemas/_ost_strict.json +15 -6
  91. package/schemas/_sctx_base.json +1 -1
  92. package/schemas/generated/0.13.0/_structured_context_schema_meta.json +400 -0
  93. package/schemas/generated/latest/_structured_context_schema_meta.json +177 -6
  94. package/schemas/okf.json +8 -0
@@ -43,20 +43,126 @@ const HIERARCHY_LEVEL_SCHEMA = {
43
43
  required: ['type'],
44
44
  additionalProperties: false,
45
45
  };
46
+ const SEVERITY_SCHEMA = { enum: ['error', 'warning', 'info'] };
47
+ /** The verdicts the evaluation reserves besides a rule's own; a verdict map may set a severity for them. */
48
+ export const RESERVED_VERDICTS = ['missing-context'];
49
+ /** Related-node kinds a semantic rule's `scope` can add: main-hierarchy edges only. `branch` is ancestors plus descendants. */
50
+ export const SEMANTIC_SCOPES = ['parent', 'siblings', 'children', 'ancestors', 'descendants', 'branch'];
51
+ const VERDICT_SEVERITY_SCHEMA = { enum: ['error', 'warning', 'info', 'ignore'] };
52
+ /** The semantic part of a rule: a model evaluates the node against a rubric and returns a verdict. */
53
+ const SEMANTIC_SCHEMA = {
54
+ type: 'object',
55
+ properties: {
56
+ /** What the model judges the node against. Falls back to the rule's `description`. */
57
+ rubric: { type: 'string', minLength: 1 },
58
+ /**
59
+ * The closed set of verdicts the model may return. Each is a description (what the verdict means, for the
60
+ * model) or `{ description, guidance }`, where `guidance` is shown to the user when it fires.
61
+ * Defaults to `pass`/`fail`.
62
+ */
63
+ verdicts: {
64
+ type: 'object',
65
+ additionalProperties: {
66
+ oneOf: [
67
+ { type: 'string', minLength: 1 },
68
+ {
69
+ type: 'object',
70
+ properties: { description: { type: 'string', minLength: 1 }, guidance: { type: 'string', minLength: 1 } },
71
+ required: ['description'],
72
+ additionalProperties: false,
73
+ },
74
+ ],
75
+ },
76
+ minProperties: 2,
77
+ },
78
+ /**
79
+ * Severity per verdict in effect (declared, default or reserved). A verdict mapped to `ignore` is judged but
80
+ * not reported, and a verdict left out passes. Required with `verdicts`; with the default verdicts, `fail`
81
+ * takes the rule's own `severity` instead.
82
+ */
83
+ severity: {
84
+ type: 'object',
85
+ additionalProperties: VERDICT_SEVERITY_SCHEMA,
86
+ minProperties: 1,
87
+ },
88
+ /**
89
+ * Related nodes, along main-hierarchy edges, sent with the evaluated node. Additive; left out or empty means
90
+ * the evaluated node alone.
91
+ */
92
+ scope: {
93
+ type: 'array',
94
+ items: { enum: SEMANTIC_SCOPES },
95
+ uniqueItems: true,
96
+ },
97
+ /**
98
+ * Which node bodies are sent: `nodeOnly` (the evaluated node's), `fullScope` (it and every related node's),
99
+ * or `true` for `fullScope`. Left out: no bodies.
100
+ */
101
+ includeContent: { enum: ['nodeOnly', 'fullScope', true] },
102
+ },
103
+ additionalProperties: false,
104
+ };
46
105
  const RULE_SCHEMA = {
47
106
  type: 'object',
48
107
  properties: {
49
108
  id: { type: 'string', minLength: 1 },
50
109
  category: { enum: ['validation', 'coherence', 'workflow', 'best-practice'] },
51
- severity: { enum: ['error', 'warning', 'info'] },
110
+ /**
111
+ * Severity of a failed `expression` and of the default `fail` verdict. Defaults from the category.
112
+ */
113
+ severity: SEVERITY_SCHEMA,
52
114
  description: { type: 'string', minLength: 1 },
53
- check: { type: 'string', minLength: 1 },
115
+ /** JSONata test a node must pass. When the rule also has `semantic`, a failing node is reported without being evaluated by the model. */
116
+ expression: { type: 'string', minLength: 1 },
117
+ /** Optional JSONata applicability condition, evaluated per node before the rule runs. The rule applies only when it returns true. */
118
+ when: { type: 'string', minLength: 1 },
119
+ /** A model evaluates the node against a rubric. `{}` takes every default; `true` means the same. */
120
+ semantic: { oneOf: [{ const: true }, SEMANTIC_SCHEMA] },
54
121
  type: { type: 'string', minLength: 1 },
55
- scope: { enum: ['global'] },
122
+ /** Run once for the whole space instead of once per node. */
123
+ global: { type: 'boolean' },
56
124
  override: { type: 'boolean' },
125
+ /** Whether a failure of this rule gates (suppresses) downstream rules on the node's hierarchy subtree. Defaults to true when the failure's severity is `error`. */
126
+ gatesDescendants: { type: 'boolean' },
57
127
  },
58
- required: ['id', 'category', 'description', 'check'],
128
+ required: ['id', 'category', 'description'],
59
129
  additionalProperties: false,
130
+ allOf: [
131
+ // A rule has an expression, a semantic part, or both.
132
+ { anyOf: [{ required: ['expression'] }, { required: ['semantic'] }] },
133
+ // A global rule has no node to apply to or to evaluate.
134
+ {
135
+ if: { required: ['global'], properties: { global: { const: true } } },
136
+ // biome-ignore lint/suspicious/noThenProperty: JSON Schema `if`/`then` keyword, not a thenable
137
+ then: { not: { anyOf: [{ required: ['when'] }, { required: ['type'] }, { required: ['semantic'] }] } },
138
+ },
139
+ // Custom verdicts need a severity for each failing verdict.
140
+ {
141
+ if: { required: ['semantic'], properties: { semantic: { type: 'object', required: ['verdicts'] } } },
142
+ // biome-ignore lint/suspicious/noThenProperty: JSON Schema `if`/`then` keyword, not a thenable
143
+ then: { properties: { semantic: { type: 'object', required: ['severity'] } } },
144
+ },
145
+ // With the default verdicts, `fail` takes the rule's severity.
146
+ {
147
+ if: { required: ['semantic'], properties: { semantic: { type: 'object', not: { required: ['verdicts'] } } } },
148
+ // biome-ignore lint/suspicious/noThenProperty: JSON Schema `if`/`then` keyword, not a thenable
149
+ then: {
150
+ properties: {
151
+ semantic: { type: 'object', properties: { severity: { type: 'object', not: { required: ['fail'] } } } },
152
+ },
153
+ },
154
+ },
155
+ // A rule with custom verdicts and no expression has no failure for the top-level severity to describe.
156
+ {
157
+ if: {
158
+ required: ['semantic'],
159
+ properties: { semantic: { type: 'object', required: ['verdicts'] } },
160
+ not: { required: ['expression'] },
161
+ },
162
+ // biome-ignore lint/suspicious/noThenProperty: JSON Schema `if`/`then` keyword, not a thenable
163
+ then: { not: { required: ['severity'] } },
164
+ },
165
+ ],
60
166
  };
61
167
  const REF_SCHEMA = {
62
168
  type: 'object',
@@ -0,0 +1,12 @@
1
+ import type { Rule } from '../types';
2
+ export declare const RULE_FILTER_DIMENSIONS: readonly ["category", "severity", "type", "id"];
3
+ export type RuleFilterDimension = (typeof RULE_FILTER_DIMENSIONS)[number];
4
+ /** Narrows which rules are evaluated. Dimensions combine with AND; values within a dimension with OR. */
5
+ export type RuleFilter = Partial<Record<RuleFilterDimension, string[]>>;
6
+ /** Parse `dimension=value[,value]` specs (e.g. from repeated `--rule-filter` flags); repeats of a dimension merge. */
7
+ export declare function parseRuleFilter(specs: string[]): RuleFilter;
8
+ /**
9
+ * Keep the rules matching every dimension of the filter. A rule with no `type` never matches a `type` filter,
10
+ * and a rule with a verdict map matches a `severity` filter on any severity it can report.
11
+ */
12
+ export declare function filterRules(rules: Rule[], filter?: RuleFilter): Rule[];
@@ -0,0 +1,46 @@
1
+ import { ruleSeverities } from './rule-severity';
2
+ export const RULE_FILTER_DIMENSIONS = ['category', 'severity', 'type', 'id'];
3
+ /** Parse `dimension=value[,value]` specs (e.g. from repeated `--rule-filter` flags); repeats of a dimension merge. */
4
+ export function parseRuleFilter(specs) {
5
+ const filter = {};
6
+ for (const spec of specs) {
7
+ const eq = spec.indexOf('=');
8
+ const dimension = spec.slice(0, eq);
9
+ const values = spec
10
+ .slice(eq + 1)
11
+ .split(',')
12
+ .map((v) => v.trim())
13
+ .filter(Boolean);
14
+ if (eq < 0 || values.length === 0 || !RULE_FILTER_DIMENSIONS.includes(dimension)) {
15
+ throw new Error(`Invalid rule filter "${spec}". Use <${RULE_FILTER_DIMENSIONS.join('|')}>=<value>[,<value>...].`);
16
+ }
17
+ filter[dimension] = [...(filter[dimension] ?? []), ...values];
18
+ }
19
+ return filter;
20
+ }
21
+ function dimensionValues(rule, dimension) {
22
+ switch (dimension) {
23
+ case 'severity':
24
+ return ruleSeverities(rule);
25
+ case 'category':
26
+ return [rule.category];
27
+ case 'type':
28
+ return rule.type ? [rule.type] : [];
29
+ case 'id':
30
+ return [rule.id];
31
+ }
32
+ }
33
+ /**
34
+ * Keep the rules matching every dimension of the filter. A rule with no `type` never matches a `type` filter,
35
+ * and a rule with a verdict map matches a `severity` filter on any severity it can report.
36
+ */
37
+ export function filterRules(rules, filter) {
38
+ if (!filter)
39
+ return rules;
40
+ return rules.filter((rule) => RULE_FILTER_DIMENSIONS.every((dim) => {
41
+ const wanted = filter[dim];
42
+ if (!wanted)
43
+ return true;
44
+ return dimensionValues(rule, dim).some((value) => wanted.includes(value));
45
+ }));
46
+ }
@@ -0,0 +1,13 @@
1
+ import type { Rule, RuleCategory, RuleSeverity } from '../types';
2
+ import type { RuleSemantic } from './metadata-contract';
3
+ /** Default severity for each rule category. A rule's own `severity` field overrides this. */
4
+ export declare const DEFAULT_SEVERITY_BY_CATEGORY: Record<RuleCategory, RuleSeverity>;
5
+ /** The rule's semantic part as an object (`semantic: true` means `{}`), or undefined for a rule without one. */
6
+ export declare function ruleSemantic(rule: Rule): RuleSemantic | undefined;
7
+ /** Severity of a failed `expression` and of the default `fail` verdict: the rule's `severity`, else its category default. */
8
+ export declare function ruleSeverity(rule: Rule): RuleSeverity;
9
+ /**
10
+ * Every severity this rule can report: that of its failed `expression` (or default `fail` verdict), plus those
11
+ * its semantic verdict map sets. `ignore` entries report nothing. Most severe first.
12
+ */
13
+ export declare function ruleSeverities(rule: Rule): RuleSeverity[];
@@ -0,0 +1,33 @@
1
+ /** Default severity for each rule category. A rule's own `severity` field overrides this. */
2
+ export const DEFAULT_SEVERITY_BY_CATEGORY = {
3
+ validation: 'error',
4
+ coherence: 'warning',
5
+ workflow: 'warning',
6
+ 'best-practice': 'info',
7
+ };
8
+ const SEVERITY_ORDER = ['error', 'warning', 'info'];
9
+ /** The rule's semantic part as an object (`semantic: true` means `{}`), or undefined for a rule without one. */
10
+ export function ruleSemantic(rule) {
11
+ if (rule.semantic === undefined)
12
+ return undefined;
13
+ return rule.semantic === true ? {} : rule.semantic;
14
+ }
15
+ /** Severity of a failed `expression` and of the default `fail` verdict: the rule's `severity`, else its category default. */
16
+ export function ruleSeverity(rule) {
17
+ return rule.severity ?? DEFAULT_SEVERITY_BY_CATEGORY[rule.category];
18
+ }
19
+ /**
20
+ * Every severity this rule can report: that of its failed `expression` (or default `fail` verdict), plus those
21
+ * its semantic verdict map sets. `ignore` entries report nothing. Most severe first.
22
+ */
23
+ export function ruleSeverities(rule) {
24
+ const semantic = ruleSemantic(rule);
25
+ const found = new Set();
26
+ if (rule.expression !== undefined || (semantic && !semantic.verdicts))
27
+ found.add(ruleSeverity(rule));
28
+ for (const severity of Object.values(semantic?.severity ?? {})) {
29
+ if (severity !== 'ignore')
30
+ found.add(severity);
31
+ }
32
+ return SEVERITY_ORDER.filter((s) => found.has(s));
33
+ }
@@ -183,10 +183,40 @@ export function buildFullRegistry(schemaPath, pluginSchemas) {
183
183
  }
184
184
  return schemaRefRegistry;
185
185
  }
186
+ const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
187
+ const DATE_TIME_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(\.\d+)?)?(Z|[+-](\d{2}):(\d{2}))?$/;
188
+ function isCalendarDate(year, month, day) {
189
+ const d = new Date(Date.UTC(Number(year), Number(month) - 1, Number(day)));
190
+ return d.getUTCMonth() === Number(month) - 1 && d.getUTCDate() === Number(day);
191
+ }
192
+ /** ISO 8601 date, e.g. 2026-03-31. */
193
+ function isDate(value) {
194
+ const m = DATE_PATTERN.exec(value);
195
+ return m !== null && isCalendarDate(m[1], m[2], m[3]);
196
+ }
197
+ /**
198
+ * ISO 8601 datetime. `strict` requires seconds and a UTC offset (e.g. 2026-03-31T09:15:00+10:00);
199
+ * otherwise both are optional, as in the local datetimes Obsidian writes (e.g. 2026-03-31T09:15).
200
+ */
201
+ function isDateTime(value, strict) {
202
+ const m = DATE_TIME_PATTERN.exec(value);
203
+ if (!m || !isCalendarDate(m[1], m[2], m[3]))
204
+ return false;
205
+ const [, , , , hour, minute, second, , offset, offsetHour, offsetMinute] = m;
206
+ if (strict && (second === undefined || offset === undefined))
207
+ return false;
208
+ return (Number(hour) < 24 &&
209
+ Number(minute) < 60 &&
210
+ Number(second ?? 0) < 60 &&
211
+ Number(offsetHour ?? 0) < 24 &&
212
+ Number(offsetMinute ?? 0) < 60);
213
+ }
186
214
  function compileValidator(targetSchema, schemaRefRegistry) {
187
215
  const ajv = new Ajv2020();
188
216
  ajv.addFormat('path', (value) => value.length > 0 && !value.includes('\0'));
189
- ajv.addFormat('date', (value) => /^\d{4}-\d{2}-\d{2}$/.test(value));
217
+ ajv.addFormat('date', isDate);
218
+ ajv.addFormat('date-time', (value) => isDateTime(value, true));
219
+ ajv.addFormat('date-or-date-time', (value) => isDate(value) || isDateTime(value, false));
190
220
  ajv.addFormat('wikilink', (value) => /^\[\[.+\]\]$/.test(value));
191
221
  ajv.addKeyword({
192
222
  keyword: '$metadata',
@@ -1,14 +1,23 @@
1
- import type { Rule, RuleCategory, RuleSeverity, RuleViolation, SpaceNode } from '../types';
2
- /** Default severity for each rule category. A rule's own `severity` field overrides this. */
3
- export declare const DEFAULT_SEVERITY_BY_CATEGORY: Record<RuleCategory, RuleSeverity>;
4
- /** Effective severity of a rule: its explicit `severity`, else its category default. */
5
- export declare function ruleSeverity(rule: Rule): RuleSeverity;
1
+ import type { TrimReport } from '../judge/trim';
2
+ import type { SemanticJudgeProvider } from '../judge/types';
3
+ import type { Rule, RuleViolation, SpaceNode } from '../types';
4
+ export { DEFAULT_SEVERITY_BY_CATEGORY, ruleSeverity } from './rule-severity';
5
+ export interface ValidateRulesOptions {
6
+ /** Evaluates semantic rules. Without one, a rule runs only its `expression`, and a semantic-only rule is skipped. */
7
+ judge?: SemanticJudgeProvider;
8
+ /** Label of the only node to judge (`validate-file`); other nodes' semantic rules are skipped. */
9
+ judgeTarget?: string;
10
+ /** Set of node labels that have already failed upstream structural checks. */
11
+ gatedNodeLabels?: Set<string>;
12
+ trimReport?: TrimReport;
13
+ }
6
14
  /**
7
15
  * Validate nodes against rules metadata.
8
- * Returns a list of rule violations found.
16
+ * Evaluates expressions and (when opt-in) semantic parts.
9
17
  *
10
18
  * @param nodes - All nodes in the space
11
19
  * @param rules - Rules metadata list
12
- * @returns Array of rule violations
20
+ * @param options - Validation options, including the judge for semantic rules
21
+ * @returns Rule violations
13
22
  */
14
- export declare function validateRules(nodes: SpaceNode[], rules: Rule[]): Promise<RuleViolation[]>;
23
+ export declare function validateRules(nodes: SpaceNode[], rules: Rule[], options?: ValidateRulesOptions): Promise<RuleViolation[]>;
@@ -1,25 +1,20 @@
1
- import { buildEvalContext, evaluateExpression } from './evaluate-rule';
2
- /** Default severity for each rule category. A rule's own `severity` field overrides this. */
3
- export const DEFAULT_SEVERITY_BY_CATEGORY = {
4
- validation: 'error',
5
- coherence: 'warning',
6
- workflow: 'warning',
7
- 'best-practice': 'info',
8
- };
9
- /** Effective severity of a rule: its explicit `severity`, else its category default. */
10
- export function ruleSeverity(rule) {
11
- return rule.severity ?? DEFAULT_SEVERITY_BY_CATEGORY[rule.category];
12
- }
1
+ import { judgeNodes } from '../judge/judge-node';
2
+ import { buildSpaceGraph } from '../space-graph';
3
+ import { buildEvalContext, evaluateRuleExpression, expressionErrorViolation, tryEvaluateExpression, } from './evaluate-rule';
4
+ import { ruleSeverities, ruleSeverity } from './rule-severity';
5
+ export { DEFAULT_SEVERITY_BY_CATEGORY, ruleSeverity } from './rule-severity';
13
6
  /**
14
7
  * Validate nodes against rules metadata.
15
- * Returns a list of rule violations found.
8
+ * Evaluates expressions and (when opt-in) semantic parts.
16
9
  *
17
10
  * @param nodes - All nodes in the space
18
11
  * @param rules - Rules metadata list
19
- * @returns Array of rule violations
12
+ * @param options - Validation options, including the judge for semantic rules
13
+ * @returns Rule violations
20
14
  */
21
- export async function validateRules(nodes, rules) {
15
+ export async function validateRules(nodes, rules, options = {}) {
22
16
  const violations = [];
17
+ const judge = options.judge;
23
18
  // Build node index for efficient lookups
24
19
  const nodeIndex = new Map();
25
20
  for (const node of nodes) {
@@ -28,43 +23,91 @@ export async function validateRules(nodes, rules) {
28
23
  nodeIndex.set(title, node);
29
24
  }
30
25
  }
31
- // Evaluate each rule against applicable nodes
32
- for (const rule of rules) {
33
- const severity = ruleSeverity(rule);
34
- if (rule.scope === 'global') {
35
- // Global rules are evaluated once against the full node set.
36
- // A sentinel node provides the evaluation context (nodes array is what matters).
37
- const sentinel = nodes[0];
38
- if (sentinel) {
39
- const context = buildEvalContext(sentinel, nodes, nodeIndex);
40
- const result = await evaluateExpression(rule.check, context);
41
- if (result !== true) {
42
- violations.push({
43
- file: '',
44
- ruleId: rule.id,
45
- category: rule.category,
46
- severity,
47
- description: rule.description,
48
- });
26
+ const sentinel = nodes[0];
27
+ const runnable = rules.filter((r) => r.expression !== undefined || (r.semantic !== undefined && judge));
28
+ // The nodes each per-node rule applies to: matching `type` and passing the applicability condition (`when`).
29
+ const applicable = new Map();
30
+ for (const rule of runnable) {
31
+ if (rule.global)
32
+ continue;
33
+ const matching = [];
34
+ for (const node of nodes) {
35
+ if (rule.type && rule.type !== node.resolvedType)
36
+ continue;
37
+ if (rule.when) {
38
+ const when = await tryEvaluateExpression(rule.when, buildEvalContext(node, nodes, nodeIndex));
39
+ if ('error' in when) {
40
+ violations.push(expressionErrorViolation(rule, node.label, when.error));
41
+ continue;
49
42
  }
43
+ if (when.value !== true)
44
+ continue;
50
45
  }
46
+ matching.push(node);
51
47
  }
52
- else {
53
- const targetNodes = rule.type ? nodes.filter((n) => n.resolvedType === rule.type) : nodes;
54
- for (const node of targetNodes) {
55
- const context = buildEvalContext(node, nodes, nodeIndex);
56
- const result = await evaluateExpression(rule.check, context);
57
- if (result !== true) {
58
- violations.push({
59
- file: node.label,
60
- ruleId: rule.id,
61
- category: rule.category,
62
- severity,
63
- description: rule.description,
64
- });
65
- }
66
- }
48
+ applicable.set(rule, matching);
49
+ }
50
+ // 1. Expressions, in rule order. A global rule is evaluated once against the full node set; a sentinel node
51
+ // provides the evaluation context (the nodes array is what matters).
52
+ const failedNodeLabels = new Set(options.gatedNodeLabels); // nodes that failed an expression (or upstream)
53
+ const notEvaluable = new Set(); // `${rule.id}\0${label}` whose own expression failed or errored
54
+ for (const rule of runnable) {
55
+ if (rule.expression === undefined)
56
+ continue;
57
+ if (rule.global) {
58
+ if (!sentinel)
59
+ continue;
60
+ const result = await evaluateRuleExpression(rule, sentinel, nodes, nodeIndex, '');
61
+ if (!result.passed)
62
+ violations.push(result.violation);
63
+ continue;
67
64
  }
65
+ for (const node of applicable.get(rule) ?? []) {
66
+ const result = await evaluateRuleExpression(rule, node, nodes, nodeIndex);
67
+ if (result.passed)
68
+ continue;
69
+ violations.push(result.violation);
70
+ notEvaluable.add(`${rule.id}\0${node.label}`);
71
+ if (result.failed)
72
+ failedNodeLabels.add(node.label);
73
+ }
74
+ }
75
+ // 2. Semantic parts, for nodes that passed their own expression. A node that failed an expression or
76
+ // upstream is left out (safe cascading default).
77
+ if (judge) {
78
+ const perNode = []; // kept in node order
79
+ const pending = [];
80
+ for (const node of nodes) {
81
+ if (options.judgeTarget !== undefined && node.label !== options.judgeTarget)
82
+ continue;
83
+ const found = [];
84
+ perNode.push(found);
85
+ if (failedNodeLabels.has(node.label))
86
+ continue;
87
+ const semantic = runnable.filter((r) => r.semantic !== undefined && applicable.get(r)?.includes(node) && !notEvaluable.has(`${r.id}\0${node.label}`));
88
+ if (semantic.length > 0)
89
+ pending.push({ node, rules: semantic, found });
90
+ }
91
+ // One provider call for every node, so a provider with `evaluateNodes` can share model calls.
92
+ const judged = await judgeNodes(judge, pending, buildSpaceGraph(nodes, undefined), options.trimReport);
93
+ pending.forEach(({ node, found }, i) => {
94
+ for (const j of judged[i] ?? []) {
95
+ if (!j.failed)
96
+ continue;
97
+ found.push({
98
+ file: node.label,
99
+ ruleId: j.rule.id,
100
+ category: j.rule.category,
101
+ severity: j.severity ?? ruleSeverities(j.rule)[0] ?? ruleSeverity(j.rule),
102
+ description: j.error ? `${j.rule.description} (Semantic evaluation error: ${j.error})` : j.rule.description,
103
+ evidence: j.evidence,
104
+ ...(j.verdict !== undefined && { verdict: j.verdict }),
105
+ ...(j.guidance !== undefined && { guidance: j.guidance }),
106
+ });
107
+ }
108
+ });
109
+ for (const found of perNode)
110
+ violations.push(...found);
68
111
  }
69
112
  return violations;
70
113
  }
@@ -50,14 +50,14 @@
50
50
  "category": "coherence",
51
51
  "description": "Syntheses should reference at least one contributing source in their sources array",
52
52
  "type": "synthesis",
53
- "check": "$exists(current.sources) and $count(current.sources) >= 1"
53
+ "expression": "$exists(current.sources) and $count(current.sources) >= 1"
54
54
  },
55
55
  {
56
56
  "id": "concept-has-summary",
57
57
  "category": "best-practice",
58
58
  "description": "Concept pages should have a short summary for index discoverability",
59
59
  "type": "concept",
60
- "check": "$exists(current.summary) and $length(current.summary) > 0"
60
+ "expression": "$exists(current.summary) and $length(current.summary) > 0"
61
61
  }
62
62
  ]
63
63
  },
@@ -89,7 +89,7 @@
89
89
  },
90
90
  "published_date": {
91
91
  "type": "string",
92
- "format": "date",
92
+ "format": "date-or-date-time",
93
93
  "description": "Original publication date"
94
94
  },
95
95
  "source_type": {
@@ -213,7 +213,7 @@
213
213
  "type": { "const": "note" },
214
214
  "date": {
215
215
  "type": "string",
216
- "format": "date",
216
+ "format": "date-or-date-time",
217
217
  "description": "Date this entry was created or relates to"
218
218
  }
219
219
  },