@sigloch/contracts 10.10.0 → 10.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.
@@ -24,6 +24,7 @@ export declare const FileFactsSchema: z.ZodObject<{
24
24
  testCases: z.ZodDefault<z.ZodArray<z.ZodString>>;
25
25
  importedSymbols: z.ZodDefault<z.ZodArray<z.ZodString>>;
26
26
  parsedSymbols: z.ZodDefault<z.ZodArray<z.ZodString>>;
27
+ zodSymbols: z.ZodOptional<z.ZodArray<z.ZodString>>;
27
28
  }, z.core.$strip>;
28
29
  export type FileFacts = z.infer<typeof FileFactsSchema>;
29
30
  /** One file→file import edge (repo-relative paths) — the module-drift evidence (CR-212). */
@@ -44,6 +45,7 @@ export declare const CodeFactsSchema: z.ZodObject<{
44
45
  testCases: z.ZodDefault<z.ZodArray<z.ZodString>>;
45
46
  importedSymbols: z.ZodDefault<z.ZodArray<z.ZodString>>;
46
47
  parsedSymbols: z.ZodDefault<z.ZodArray<z.ZodString>>;
48
+ zodSymbols: z.ZodOptional<z.ZodArray<z.ZodString>>;
47
49
  }, z.core.$strip>>;
48
50
  importEdges: z.ZodOptional<z.ZodArray<z.ZodObject<{
49
51
  from: z.ZodString;
@@ -54,6 +56,10 @@ export declare const CodeFactsSchema: z.ZodObject<{
54
56
  open: "open";
55
57
  done: "done";
56
58
  }>>>;
59
+ fileScope: z.ZodOptional<z.ZodEnum<{
60
+ all: "all";
61
+ referenced: "referenced";
62
+ }>>;
57
63
  }, z.core.$strip>;
58
64
  export type CodeFacts = z.infer<typeof CodeFactsSchema>;
59
65
  /** A conformance rule: pure over (graph, facts) — never touches I/O itself. */
@@ -103,7 +109,6 @@ export interface ImportCoverage {
103
109
  unassigned: string[];
104
110
  }
105
111
  export declare function importCoverage(graph: OntologyGraph, facts: CodeFacts): ImportCoverage;
106
- /** All RC conformance rules — evaluated by executors that can supply CodeFacts. */
107
112
  export declare const CODE_CONFORMANCE_RULES: ConformanceRuleDefinition[];
108
113
  /** Run all RC rules against a graph + extracted code facts. */
109
114
  export declare function evaluateConformanceRules(graph: OntologyGraph, facts: CodeFacts): RuleViolation[];
@@ -15,7 +15,7 @@
15
15
  * @sigloch/contracts/se — single source of truth for SE validation rules.
16
16
  */
17
17
  import { z } from 'zod/v4';
18
- import { RealRefSchema, TestRefsSchema } from './ontology.js';
18
+ import { readTestRefs, readRealRef } from './ontology.js';
19
19
  import { CLOSED_STATUS } from './cr-quality-rules.js';
20
20
  /** Parser facts about one source file (extracted by the executor). */
21
21
  export const FileFactsSchema = z.object({
@@ -29,6 +29,12 @@ export const FileFactsSchema = z.object({
29
29
  importedSymbols: z.array(z.string()).default([]),
30
30
  /** Symbols X invoked as `X.parse(` / `X.safeParse(` — the schema is actually used (CR-211). */
31
31
  parsedSymbols: z.array(z.string()).default([]),
32
+ /**
33
+ * Exported consts whose initializer is a Zod schema (`export const X = z.…`) — for RC-08
34
+ * (CR-SM-358). Optional, ABSENT = silence: the extractor did not look, which is not the
35
+ * same as "this file declares no schema" (same asymmetry as `declaredDependencies`).
36
+ */
37
+ zodSymbols: z.array(z.string()).optional(),
32
38
  });
33
39
  /** One file→file import edge (repo-relative paths) — the module-drift evidence (CR-212). */
34
40
  export const ImportEdgeSchema = z.object({
@@ -67,6 +73,13 @@ export const CodeFactsSchema = z.object({
67
73
  * `docs/cr/` was never looked at, it does not "have no CRs".
68
74
  */
69
75
  crFiles: z.record(z.string(), z.enum(['open', 'done'])).optional(),
76
+ /**
77
+ * Which files `files` covers (CR-SM-358): `referenced` = only files a realRef/testRefs
78
+ * entry names (the contract above), `all` = every source file of the repo. RC-09 asks
79
+ * "who ELSE parses this contract" and can only answer it over `all` — a partial scan
80
+ * would report the absence of what it never looked at. ABSENT = `referenced`.
81
+ */
82
+ fileScope: z.enum(['referenced', 'all']).optional(),
70
83
  });
71
84
  const missingFile = (facts, file) => facts.files[file]?.exists !== true;
72
85
  // RC-01: every valid FUNC realRef must resolve — file on disk, symbol declared in
@@ -81,17 +94,17 @@ function codeRefMustResolve(graph, facts) {
81
94
  continue;
82
95
  if (el.attributes?.concept === true || el.attributes?.external === true)
83
96
  continue;
84
- const parsed = RealRefSchema.safeParse(el.attributes?.realRef);
85
- if (!parsed.success)
97
+ const parsed = readRealRef(el.attributes);
98
+ if (parsed.state !== 'bound')
86
99
  continue; // no/invalid binding → R-20 territory
87
- const ref = parsed.data;
100
+ const ref = parsed.value;
88
101
  if (missingFile(facts, ref.file)) {
89
102
  violations.push({
90
103
  rule_id: 'RC-01',
91
104
  severity: 'warning',
92
105
  element_id: el.id,
93
106
  message: `${el.id} realRef.file '${ref.file}' does not exist on disk`,
94
- fix_hint: 'Re-realize the FUNC (graph_realize) against the current source tree, or fix the moved/renamed file path',
107
+ fix_hint: `Re-bind the FUNC to the current source tree via graph_mutate Format-E \`~ ${el.id} @realRef {"file":…,"symbol":…}\` — or restore the moved/renamed file`,
95
108
  context: { element_type: el.type, element_name: el.name },
96
109
  });
97
110
  continue;
@@ -125,17 +138,17 @@ function testRefMustResolve(graph, facts) {
125
138
  continue;
126
139
  if (el.attributes?.concept === true)
127
140
  continue;
128
- const parsed = TestRefsSchema.safeParse(el.attributes?.testRefs);
129
- if (!parsed.success)
141
+ const parsed = readTestRefs(el.attributes);
142
+ if (parsed.state !== 'bound')
130
143
  continue; // no/invalid binding → R-19 territory
131
- for (const ref of parsed.data) {
144
+ for (const ref of parsed.value) {
132
145
  if (missingFile(facts, ref.file)) {
133
146
  violations.push({
134
147
  rule_id: 'RC-02',
135
148
  severity: 'warning',
136
149
  element_id: el.id,
137
150
  message: `${el.id} testRefs entry file '${ref.file}' does not exist on disk`,
138
- fix_hint: 'The test file was moved or deleted — rebind the TEST (graph_realize) to the current file, or drop the entry',
151
+ fix_hint: `The test file was moved or deleted — rebind the TEST via graph_mutate Format-E \`~ ${el.id} @testRefs [{"file":…,"tool":…}]\` (the patch replaces the whole list — carry the other entries), or drop the entry`,
139
152
  context: { element_type: el.type, element_name: el.name },
140
153
  });
141
154
  continue;
@@ -169,17 +182,17 @@ function schemaRefMustResolve(graph, facts) {
169
182
  continue;
170
183
  if (el.attributes?.concept === true || el.attributes?.external === true)
171
184
  continue;
172
- const parsed = RealRefSchema.safeParse(el.attributes?.realRef);
173
- if (!parsed.success)
185
+ const parsed = readRealRef(el.attributes);
186
+ if (parsed.state !== 'bound')
174
187
  continue; // no/invalid binding → R-26 territory
175
- const ref = parsed.data;
188
+ const ref = parsed.value;
176
189
  if (missingFile(facts, ref.file)) {
177
190
  violations.push({
178
191
  rule_id: 'RC-03',
179
192
  severity: 'warning',
180
193
  element_id: el.id,
181
194
  message: `${el.id} realRef.file '${ref.file}' does not exist on disk`,
182
- fix_hint: 'Re-bind the SCHEMA (graph_realize) to the current source tree, or fix the moved/renamed file path',
195
+ fix_hint: `Re-bind the SCHEMA to the current source tree via graph_mutate Format-E \`~ ${el.id} @realRef {"file":…,"symbol":…}\` — or restore the moved/renamed file`,
183
196
  context: { element_type: el.type, element_name: el.name },
184
197
  });
185
198
  continue;
@@ -199,6 +212,61 @@ function schemaRefMustResolve(graph, facts) {
199
212
  }
200
213
  return violations;
201
214
  }
215
+ // The files the MODEL names as a SCHEMA's interface: realRef files of the FUNCs io-connected
216
+ // (producer or consumer) to a FLOW whose data format is this SCHEMA (FUNC ─io→ FLOW
217
+ // ─relation→ SCHEMA), restricted to files that exist. RC-04 asks whether one of them parses
218
+ // the schema, RC-09 whether anyone ELSE does — one derivation, so both ask the same question.
219
+ function interfaceFiles(graph, schemaId, facts, typeOf) {
220
+ const flowIds = new Set(graph.traces
221
+ .filter(t => t.type === 'relation' && t.target === schemaId && typeOf.get(t.source) === 'FLOW')
222
+ .map(t => t.source));
223
+ if (flowIds.size === 0)
224
+ return [];
225
+ const funcIds = new Set(graph.traces
226
+ .filter(t => t.type === 'io' &&
227
+ ((flowIds.has(t.target) && typeOf.get(t.source) === 'FUNC') ||
228
+ (flowIds.has(t.source) && typeOf.get(t.target) === 'FUNC')))
229
+ .map(t => (flowIds.has(t.target) ? t.source : t.target)));
230
+ const files = [];
231
+ for (const fnId of funcIds) {
232
+ const fn = graph.elements.find(e => e.id === fnId);
233
+ const cr = readRealRef(fn?.attributes);
234
+ if (cr.state === 'bound' && !missingFile(facts, cr.value.file))
235
+ files.push(cr.value.file);
236
+ }
237
+ return files;
238
+ }
239
+ // Does `file` parse the schema? It must call `.parse`/`.safeParse` on the symbol AND have it in
240
+ // scope — imported, or declared right there: the definition file does not import its own schema
241
+ // (CR-SM-358, found by the positive control on graphcode's Format-E door, where the modelled
242
+ // translator lives in the schema's own file).
243
+ function parsesAt(facts, file, ref) {
244
+ const f = facts.files[file];
245
+ if (f?.exists !== true || !f.parsedSymbols.includes(ref.symbol))
246
+ return false;
247
+ return f.importedSymbols.includes(ref.symbol) || file === ref.file;
248
+ }
249
+ // Files outside `allowed` that import AND parse the symbol — the parsers the model does not
250
+ // name. Test files (testCases present) are exempt; consumer ratchets cover them. Empty unless
251
+ // the extractor saw every file (`fileScope: 'all'`): a partial scan cannot name who else parses.
252
+ function foreignParsers(facts, symbol, allowed) {
253
+ if (facts.fileScope !== 'all')
254
+ return [];
255
+ return Object.entries(facts.files)
256
+ .filter(([file, f]) => f.exists && !allowed.has(file) && f.testCases.length === 0 &&
257
+ f.importedSymbols.includes(symbol) && f.parsedSymbols.includes(symbol))
258
+ .map(([file]) => file)
259
+ .sort();
260
+ }
261
+ // A SCHEMA whose realRef symbol is declared but, by the extractor's own account, not a Zod
262
+ // schema. False when the extractor did not report `zodSymbols` (silence, not a verdict) or
263
+ // when the symbol is not declared at all (RC-03's finding, not this one).
264
+ function boundToNonZod(facts, ref) {
265
+ const f = facts.files[ref.file];
266
+ if (f?.exists !== true || f.zodSymbols === undefined)
267
+ return false;
268
+ return f.declaredSymbols.includes(ref.symbol) && !f.zodSymbols.includes(ref.symbol);
269
+ }
202
270
  // RC-04: a bound SCHEMA that the graph says is realized at an interface must
203
271
  // actually be parsed there (CR-211). The graph gives the check LOCATIONS: FUNCs
204
272
  // io-connected to a FLOW whose data format IS this SCHEMA (FUNC ─io→ FLOW
@@ -218,43 +286,29 @@ function schemaRefMustBeUsed(graph, facts) {
218
286
  continue;
219
287
  if (el.attributes?.concept === true)
220
288
  continue;
221
- const parsed = RealRefSchema.safeParse(el.attributes?.realRef);
222
- if (!parsed.success || parsed.data.symbol === undefined)
223
- continue;
224
- const ref = parsed.data;
225
- // The FLOWs whose data format is this SCHEMA (FLOW ─relation→ SCHEMA).
226
- const flowIds = new Set(graph.traces
227
- .filter(t => t.type === 'relation' && t.target === el.id && typeOf.get(t.source) === 'FLOW')
228
- .map(t => t.source));
229
- if (flowIds.size === 0)
289
+ const parsed = readRealRef(el.attributes);
290
+ if (parsed.state !== 'bound' || parsed.value.symbol === undefined)
230
291
  continue;
231
- // FUNCs io-connected to those FLOWs (producer or consumer).
232
- const funcIds = new Set(graph.traces
233
- .filter(t => t.type === 'io' &&
234
- ((flowIds.has(t.target) && typeOf.get(t.source) === 'FUNC') ||
235
- (flowIds.has(t.source) && typeOf.get(t.target) === 'FUNC')))
236
- .map(t => (flowIds.has(t.target) ? t.source : t.target)));
237
- // Realized FUNCs among them: a resolvable realRef whose file exists.
238
- const realizedFiles = [];
239
- for (const fnId of funcIds) {
240
- const fn = graph.elements.find(e => e.id === fnId);
241
- const cr = RealRefSchema.safeParse(fn?.attributes?.realRef);
242
- if (cr.success && !missingFile(facts, cr.data.file))
243
- realizedFiles.push(cr.data.file);
244
- }
292
+ const ref = parsed.value;
293
+ if (boundToNonZod(facts, ref))
294
+ continue; // RC-08 owns this cause (CR-SM-358)
295
+ const realizedFiles = interfaceFiles(graph, el.id, facts, typeOf);
245
296
  if (realizedFiles.length === 0)
246
297
  continue; // nothing realized to check against
247
- const usedSomewhere = realizedFiles.some(file => {
248
- const f = facts.files[file];
249
- return f.importedSymbols.includes(ref.symbol) && f.parsedSymbols.includes(ref.symbol);
250
- });
298
+ const usedSomewhere = realizedFiles.some(file => parsesAt(facts, file, ref));
251
299
  if (!usedSomewhere) {
300
+ // CR-SM-358: where it IS parsed, if the extractor saw every file — then the finding says
301
+ // where the binding has to point, and RC-09 stays quiet about the same cause.
302
+ const actual = foreignParsers(facts, ref.symbol, new Set([ref.file, ...realizedFiles]));
252
303
  violations.push({
253
304
  rule_id: 'RC-04',
254
305
  severity: 'warning',
255
306
  element_id: el.id,
256
- message: `${el.id} schema '${ref.symbol}' is not parsed in any realized FUNC at its modelled interface`,
257
- fix_hint: `Import and call ${ref.symbol}.parse()/.safeParse() in one of the io-connected FUNC's code files — or mark the SCHEMA concept:true if no code realizes it yet`,
307
+ message: `${el.id} schema '${ref.symbol}' is not parsed in any realized FUNC at its modelled interface` +
308
+ (actual.length > 0 ? ` — it is parsed in: ${actual.join(', ')}` : ''),
309
+ fix_hint: actual.length > 0
310
+ ? `The parse sits in ${actual.join(', ')}: point the FUNC realRef at that file, or move the parse into the modelled FUNC`
311
+ : `Import and call ${ref.symbol}.parse()/.safeParse() in one of the io-connected FUNC's code files — or mark the SCHEMA concept:true if no code realizes it yet`,
258
312
  context: { element_type: el.type, element_name: el.name },
259
313
  });
260
314
  }
@@ -295,12 +349,12 @@ function buildModResolver(graph) {
295
349
  for (const el of graph.elements) {
296
350
  if (el.type !== 'FUNC')
297
351
  continue;
298
- const cr = RealRefSchema.safeParse(el.attributes?.realRef);
299
- if (!cr.success)
352
+ const cr = readRealRef(el.attributes);
353
+ if (cr.state !== 'bound')
300
354
  continue;
301
355
  const modTrace = graph.traces.find(t => t.source === el.id && t.type === 'allocate' && typeOf.get(t.target) === 'MOD');
302
356
  if (modTrace)
303
- fileToMod.set(cr.data.file, modTrace.target);
357
+ fileToMod.set(cr.value.file, modTrace.target);
304
358
  }
305
359
  // MOD.path longest-prefix fallback for files with no direct binding.
306
360
  const modPaths = graph.elements
@@ -439,10 +493,10 @@ function externalRefMustNameDependency(graph, facts) {
439
493
  // `concept: true` has no binding that could rot — a concept node claims nothing about code.
440
494
  if (el.attributes?.external !== true || el.attributes?.concept === true)
441
495
  continue;
442
- const parsed = RealRefSchema.safeParse(el.attributes?.realRef);
443
- if (!parsed.success)
496
+ const parsed = readRealRef(el.attributes);
497
+ if (parsed.state !== 'bound')
444
498
  continue;
445
- const match = WORKSPACE_PATH.exec(parsed.data.file);
499
+ const match = WORKSPACE_PATH.exec(parsed.value.file);
446
500
  if (!match)
447
501
  continue; // not the shape this rule can decide
448
502
  const pkg = `${WORKSPACE_SCOPE}/${match[1]}`;
@@ -452,7 +506,7 @@ function externalRefMustNameDependency(graph, facts) {
452
506
  rule_id: 'RC-06',
453
507
  severity: 'warning',
454
508
  element_id: el.id,
455
- message: `${el.id} binds to '${parsed.data.file}', but '${pkg}' is not a declared dependency`,
509
+ message: `${el.id} binds to '${parsed.value.file}', but '${pkg}' is not a declared dependency`,
456
510
  fix_hint: `Add '${pkg}' to dependencies, or re-point the realRef at the package that now owns the symbol`,
457
511
  context: { element_type: el.type, element_name: el.name },
458
512
  });
@@ -514,6 +568,127 @@ function crNodeMatchesFile(graph, facts) {
514
568
  return violations;
515
569
  }
516
570
  /** All RC conformance rules — evaluated by executors that can supply CodeFacts. */
571
+ // ---------------------------------------------------------------------------
572
+ // RC-08 / RC-09 (CR-SM-358) — the contract has a checker, and only the modelled places use it.
573
+ //
574
+ // Why these two replace ND-01 in substance: parallel paths are not similar to each other
575
+ // (spike CR-GC-637: name similarity 0.000 in 7 of 7 documented pairs); they share a CONTRACT.
576
+ // A second translator of the same contract is found by asking who parses it, not what it
577
+ // looks like. RC-01..RC-07 ask model → code ("does the binding resolve?"); RC-09 is the
578
+ // first rule that asks code → model ("does the model know everyone who reads this?").
579
+ // ---------------------------------------------------------------------------
580
+ // RC-08: a bound SCHEMA must bind a Zod schema, not a type. A type is checked by the
581
+ // compiler and gone at runtime: whatever produces the data cannot prove it, and whoever
582
+ // reads it cannot check it. Data that nobody parses yet only has a reader nobody knows yet.
583
+ function schemaRefMustBeZod(graph, facts) {
584
+ const violations = [];
585
+ for (const el of graph.elements) {
586
+ if (el.type !== 'SCHEMA')
587
+ continue;
588
+ if (el.attributes?.concept === true)
589
+ continue;
590
+ const parsed = readRealRef(el.attributes);
591
+ if (parsed.state !== 'bound' || parsed.value.symbol === undefined)
592
+ continue;
593
+ const ref = parsed.value;
594
+ if (!boundToNonZod(facts, ref))
595
+ continue;
596
+ violations.push({
597
+ rule_id: 'RC-08',
598
+ severity: 'warning',
599
+ element_id: el.id,
600
+ message: `${el.id} realRef '${ref.symbol}' in '${ref.file}' is not a Zod schema — the contract cannot be checked at runtime`,
601
+ fix_hint: `Declare the contract as a Zod schema (export const ${ref.symbol}Schema = z.…) and bind the SCHEMA to it — a producer needs a checkable schema even while no reader is known`,
602
+ context: { element_type: el.type, element_name: el.name },
603
+ });
604
+ }
605
+ return violations;
606
+ }
607
+ // RC-09: a Zod-bound SCHEMA may be parsed only where the model says — in the realRef files
608
+ // of its producers/translators (the same set RC-04 checks) or in its own definition file.
609
+ // Any other file that imports AND parses the symbol is a translator the model does not
610
+ // know: a second path. The definition may live in another package (no facts entry) — only a
611
+ // proven type binding (RC-08) silences the rule. One finding per SCHEMA, listing the files — the count must not grow
612
+ // with usage (Gate 7, class CR-GC-315). Test files (testCases present) are exempt; ratchets
613
+ // in the consumer cover them. Runs only when the extractor saw every file (`fileScope:'all'`).
614
+ function schemaParsedOnlyAtInterface(graph, facts) {
615
+ if (facts.fileScope !== 'all')
616
+ return [];
617
+ // (foreignParsers repeats this guard; checking here skips the graph walk entirely.)
618
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
619
+ const violations = [];
620
+ for (const el of graph.elements) {
621
+ if (el.type !== 'SCHEMA')
622
+ continue;
623
+ if (el.attributes?.concept === true)
624
+ continue;
625
+ const parsed = readRealRef(el.attributes);
626
+ if (parsed.state !== 'bound' || parsed.value.symbol === undefined)
627
+ continue;
628
+ const ref = parsed.value;
629
+ // Only a PROVEN type binding silences RC-09 (RC-08 owns it). A definition outside this
630
+ // repo — the family's shared contracts live in @sigloch/contracts — has no facts entry,
631
+ // and those are exactly the contracts reused most; a `.parse()` on the symbol is already
632
+ // the evidence that it is a runtime checker.
633
+ if (boundToNonZod(facts, ref))
634
+ continue;
635
+ const iface = interfaceFiles(graph, el.id, facts, typeOf);
636
+ // One cause, one finding (Gate 4): if the model names an interface and NONE of it parses,
637
+ // the parse merely sits elsewhere — that is RC-04's finding (which names the file), not a
638
+ // second path. A second path needs a first one: a modelled parser AND a foreign one.
639
+ const ifaceParses = iface.some(file => parsesAt(facts, file, ref));
640
+ if (iface.length > 0 && !ifaceParses)
641
+ continue;
642
+ const foreign = foreignParsers(facts, ref.symbol, new Set([ref.file, ...iface]));
643
+ if (foreign.length === 0)
644
+ continue;
645
+ violations.push({
646
+ rule_id: 'RC-09',
647
+ severity: 'warning',
648
+ element_id: el.id,
649
+ message: `${el.id} schema '${ref.symbol}' is parsed in ${foreign.length} file(s) the model does not know as its producer or translator: ${foreign.join(', ')}`,
650
+ fix_hint: 'Route the caller through the modelled translator — or, if it is a translator in its own right, model it: FUNC ─io→ FLOW ─relation→ this SCHEMA, with the FUNC realRef on that file',
651
+ context: { element_type: el.type, element_name: el.name },
652
+ });
653
+ }
654
+ return violations;
655
+ }
656
+ // ---------------------------------------------------------------------------
657
+ // RC-10 (CR-SM-344): a MOD must be resolvable — carry a `path`, or have at least one
658
+ // allocated FUNC with a bound realRef. Those are the only two ways `buildModResolver`
659
+ // maps a file to a MOD; a MOD with neither can never own a file, so RC-05 and the
660
+ // boundary measurement are blind to it BY CONSTRUCTION. Measured at sigllm: 0 RC-05
661
+ // findings at 19 % import coverage, 8 at 81 % — the zero was the absence of a question.
662
+ // The cost in the message comes from `importCoverage` (the same resolution), not from a
663
+ // second count. `concept`/`external` are exempt — the same exemptions as R-20.
664
+ // ABSENT `importEdges` = silence, like RC-05: without an import graph RC-05 has nothing to
665
+ // judge, so there is no blindness to report — and the cost could not be named.
666
+ // ---------------------------------------------------------------------------
667
+ function modMustBeResolvable(graph, facts) {
668
+ if (facts.importEdges === undefined)
669
+ return []; // extractor supplied no import graph — silence
670
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
671
+ const boundFuncs = new Set(graph.elements.filter(e => e.type === 'FUNC' && readRealRef(e.attributes).state === 'bound').map(e => e.id));
672
+ const resolvedByFunc = new Set(graph.traces
673
+ .filter(t => t.type === 'allocate' && boundFuncs.has(t.source) && typeOf.get(t.target) === 'MOD')
674
+ .map(t => t.target));
675
+ const blind = graph.elements.filter(e => e.type === 'MOD' &&
676
+ e.attributes?.concept !== true && e.attributes?.external !== true &&
677
+ !(typeof e.attributes?.path === 'string' && e.attributes.path.length > 0) &&
678
+ !resolvedByFunc.has(e.id));
679
+ if (blind.length === 0)
680
+ return [];
681
+ const coverage = importCoverage(graph, facts);
682
+ const cost = `${coverage.unassigned.length} of ${coverage.endpoints} import endpoints stay unassigned in this repo`;
683
+ return blind.map(el => ({
684
+ rule_id: 'RC-10',
685
+ severity: 'warning',
686
+ element_id: el.id,
687
+ message: `${el.id} has neither a path nor an allocated FUNC with a realRef — no file can resolve to it, RC-05 is blind here (${cost})`,
688
+ fix_hint: `Set the module's source directory via graph_mutate Format-E \`~ ${el.id} @path <dir>\` — or bind one of its allocated FUNCs (\`~ FUNC-x @realRef {"file":…,"symbol":…}\`); mark it concept:true if no code realizes it yet`,
689
+ context: { element_type: el.type, element_name: el.name },
690
+ }));
691
+ }
517
692
  export const CODE_CONFORMANCE_RULES = [
518
693
  { id: 'RC-01', name: 'FUNC realRef resolves to a declared symbol', severity: 'warning', domain: ['FUNC'], evaluate: codeRefMustResolve },
519
694
  { id: 'RC-02', name: 'testRefs entries resolve to runnable tests', severity: 'warning', domain: ['TEST'], evaluate: testRefMustResolve },
@@ -522,6 +697,9 @@ export const CODE_CONFORMANCE_RULES = [
522
697
  { id: 'RC-05', name: 'cross-module import drift', severity: 'warning', domain: ['MOD'], evaluate: importDriftConformance },
523
698
  { id: 'RC-06', name: 'external realRef names a declared dependency', severity: 'warning', domain: ['FUNC', 'MOD', 'SCHEMA'], evaluate: externalRefMustNameDependency },
524
699
  { id: 'RC-07', name: 'CR node agrees with docs/cr', severity: 'warning', domain: ['CR', 'SYS'], evaluate: crNodeMatchesFile },
700
+ { id: 'RC-08', name: 'SCHEMA realRef is a Zod schema', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaRefMustBeZod },
701
+ { id: 'RC-09', name: 'SCHEMA is parsed only at its modelled interface', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaParsedOnlyAtInterface },
702
+ { id: 'RC-10', name: 'MOD has resolvable files', severity: 'warning', domain: ['MOD'], evaluate: modMustBeResolvable },
525
703
  ];
526
704
  /** Run all RC rules against a graph + extracted code facts. */
527
705
  export function evaluateConformanceRules(graph, facts) {
@@ -8,6 +8,11 @@ import type { MetricPolicy } from './policy.js';
8
8
  /**
9
9
  * Abgeschlossen heisst: nicht mehr steuerbar. Alles andere ist Grundgesamtheit.
10
10
  * Exportiert fuer RC-07 (CR-SM-329), das dieselbe Frage gegen `docs/cr/done/` stellt.
11
+ *
12
+ * CR-SM-362 (ITEM-2026-536): nur noch `done`. Die Menge kannte `dropped`/`rejected`, der
13
+ * Element-Vertrag (`OntologyElement.status`) nicht — die Regeln rechneten mit Werten, die der
14
+ * Vertrag verbietet. Verworfen ist eine Begruendung im CR-Text, kein eigener Zustand: das
15
+ * Verzeichnis `done/` schliesst ihn, der Knoten spiegelt `done`.
11
16
  */
12
17
  export declare const CLOSED_STATUS: ReadonlySet<string>;
13
18
  export declare const CR_RULES: RuleDefinition[];
@@ -1,3 +1,4 @@
1
+ import { readRealRef } from './ontology.js';
1
2
  // ---------------------------------------------------------------------------
2
3
  // CR-R01: ein offener CR nennt, WAS er aendert (CR-SM-295)
3
4
  //
@@ -36,8 +37,13 @@ const SCOPE_TYPES = new Set(['FUNC', 'MOD', 'SCHEMA', 'REQ', 'UC']);
36
37
  /**
37
38
  * Abgeschlossen heisst: nicht mehr steuerbar. Alles andere ist Grundgesamtheit.
38
39
  * Exportiert fuer RC-07 (CR-SM-329), das dieselbe Frage gegen `docs/cr/done/` stellt.
40
+ *
41
+ * CR-SM-362 (ITEM-2026-536): nur noch `done`. Die Menge kannte `dropped`/`rejected`, der
42
+ * Element-Vertrag (`OntologyElement.status`) nicht — die Regeln rechneten mit Werten, die der
43
+ * Vertrag verbietet. Verworfen ist eine Begruendung im CR-Text, kein eigener Zustand: das
44
+ * Verzeichnis `done/` schliesst ihn, der Knoten spiegelt `done`.
39
45
  */
40
- export const CLOSED_STATUS = new Set(['done', 'dropped', 'rejected']);
46
+ export const CLOSED_STATUS = new Set(['done']);
41
47
  function crMustTrack(graph) {
42
48
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
43
49
  const crs = graph.elements.filter(e => {
@@ -173,6 +179,76 @@ function crShouldHaveMilestone(graph) {
173
179
  }));
174
180
  }
175
181
  // ---------------------------------------------------------------------------
182
+ // CR-R05: kein Blatt-REQ ohne Bauauftrag (CR-SM-343) — die REQ-Seite von CR-R01.
183
+ //
184
+ // RD-01 fragt nach einem Traeger, R-01 nach einer Verifikation; ob ein REQ einen BAUAUFTRAG
185
+ // hat, fragte niemand. Gemessen am Fremdlauf sigllm: `se-plan` meldete „20 von 20 geordnet",
186
+ // im selben Graphstand hatten 24 von 64 Blatt-REQ keinen Auftrag — genau die, die alle 16
187
+ // offenen FM-03-Risiken mildern.
188
+ //
189
+ // Beauftragt ist ein Blatt-REQ (kein `compose`→REQ-Kind), wenn ein CR eine `relation` (a)
190
+ // direkt darauf traegt oder (b) auf eine FUNC, die es `satisfy`t. **MOD und SYS zaehlen
191
+ // nicht:** ein Modul ist ein Behaelter — mitgerechnet las sigllm 55/64 statt 40/64 und sah
192
+ // gesund aus. Wer ein REQ an einem MOD-/SYS-Traeger beauftragt, zieht die `relation` direkt
193
+ // auf das REQ. FCHAIN ist kein Weg: `CR -relation-> FCHAIN` ist kein TRACE_PATTERN, eine
194
+ // solche Kante waere R-18 — ein FCHAIN-getragenes REQ braucht also ebenfalls die direkte Kante.
195
+ //
196
+ // Jeder CR zaehlt, auch ein abgeschlossener: gebaut ist beauftragt.
197
+ //
198
+ // **Eine gebaute FUNC deckt ohne CR** (CR-SM-375): traegt die FUNC eine gueltige `realRef`
199
+ // oder ist sie `external`, ist der Bauauftrag erledigt — Bestand vor der CR-Disziplin braucht
200
+ // keinen nachgetragenen Auftrag. `concept: true` ist nicht gebaut, auch mit realRef. Fuer
201
+ // MOD/SYS/FCHAIN aendert das nichts: dort deckt nur die direkte `CR -relation-> REQ`.
202
+ //
203
+ // **Ohne CR-Knoten schweigt die Regel** und zaehlt weder im Zaehler noch im Nenner
204
+ // (`RULE_PRECONDITION`, readiness.ts). Ein Repo ohne Plan im Graphen (`cr: docs`) bekaeme
205
+ // sonst auf jedem REQ einen Befund — die Regel wuerde abgeschaltet und waere danach ueberall
206
+ // still (Fail-open-Klasse ND-01/ND-02 vor CR-SM-286).
207
+ // ---------------------------------------------------------------------------
208
+ function funcIsBuilt(e) {
209
+ if (e.attributes?.concept === true)
210
+ return false;
211
+ return e.attributes?.external === true || readRealRef(e.attributes).state === 'bound';
212
+ }
213
+ function leafReqNeedsBuildOrder(graph) {
214
+ const crIds = new Set(graph.elements.filter(e => e.type === 'CR').map(e => e.id));
215
+ if (crIds.size === 0)
216
+ return [];
217
+ const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
218
+ const ordered = new Set(); // CR -relation-> X, oder eine bereits gebaute FUNC
219
+ for (const t of graph.traces) {
220
+ if (t.type === 'relation' && crIds.has(t.source))
221
+ ordered.add(t.target);
222
+ }
223
+ for (const e of graph.elements) {
224
+ if (e.type === 'FUNC' && funcIsBuilt(e))
225
+ ordered.add(e.id);
226
+ }
227
+ const covered = new Set();
228
+ const parents = new Set();
229
+ for (const t of graph.traces) {
230
+ if (t.type === 'relation' && crIds.has(t.source) && typeOf.get(t.target) === 'REQ')
231
+ covered.add(t.target);
232
+ if (t.type === 'satisfy' && typeOf.get(t.source) === 'FUNC' && ordered.has(t.source))
233
+ covered.add(t.target);
234
+ if (t.type === 'compose' && typeOf.get(t.source) === 'REQ' && typeOf.get(t.target) === 'REQ')
235
+ parents.add(t.source);
236
+ }
237
+ return graph.elements
238
+ .filter(e => e.type === 'REQ' && !parents.has(e.id) && !covered.has(e.id))
239
+ .map(req => ({
240
+ rule_id: 'CR-R05',
241
+ severity: 'warning',
242
+ element_id: req.id,
243
+ message: `${req.id} is a leaf REQ with no build order (no CR relation to it or to a FUNC that satisfies it, and no built FUNC satisfies it)`,
244
+ fix_hint: `Add a CR relation through graph_mutate Format-E \`CR-x -relation-> ${req.id}\` — or to the FUNC that satisfies it; a CR on its MOD/SYS carrier does not count`,
245
+ context: {
246
+ element_type: req.type,
247
+ element_name: req.name,
248
+ },
249
+ }));
250
+ }
251
+ // ---------------------------------------------------------------------------
176
252
  // Exports
177
253
  // ---------------------------------------------------------------------------
178
254
  export const CR_RULES = [
@@ -185,6 +261,7 @@ export const CR_RULES = [
185
261
  // Damit ist sie nach R-08/R-18 die dritte 'all'-Regel — bewusst, nicht vergessen.
186
262
  { id: 'CR-R03', name: 'No concurrent mutation', severity: 'warning', evaluate: noConcurrentMutation, domain: ['all'] },
187
263
  { id: 'MS-03', name: 'CR without milestone', severity: 'info', evaluate: crShouldHaveMilestone, domain: ['CR'] },
264
+ { id: 'CR-R05', name: 'Leaf REQ has a build order', severity: 'warning', evaluate: leafReqNeedsBuildOrder, domain: ['REQ'] },
188
265
  ];
189
266
  // CR-SM-236: `policy` wird durchgereicht, auch wo diese Familie heute keine Schwelle hat —
190
267
  // ein Sonderweg je Familie waere genau der zweite Pfad, den der Regelsatz verbietet.
@@ -30,5 +30,12 @@ export declare function getRuleDefsForProfile(profile: ProfileId): typeof ALL_RU
30
30
  * CR-SM-233: `policy` ist **Pflicht und ohne Fallback** — ein Aufruf ohne Policy ist ein
31
31
  * Typfehler, keine stille 0.7. Wer keine eigene Quelle hat (Konfiguration, Host), nimmt
32
32
  * `DEFAULT_METRIC_POLICY` sichtbar an der Aufrufstelle.
33
+ *
34
+ * CR-SM-378: `only` ist ein REGELFILTER, kein zweiter Auswerter — dieselbe Normalisierung,
35
+ * dieselbe Reihenfolge, dieselben `evaluate`-Aufrufe; eine Regel ausserhalb des Filters laeuft nur
36
+ * nicht. Das Ergebnis ist gleich dem Vollergebnis, nachtraeglich auf `only` gefiltert. Grund:
37
+ * der Steuer-Operator (se-engine `bestBySteer`) bewertete jeden Kandidaten mit dem ganzen
38
+ * Katalog, inklusive des quadratischen ND-01 — 8,9 von 17,1 s bei 2000 Knoten fuer Befunde, die
39
+ * er nie las. Ohne `only` laeuft der ganze Katalog.
33
40
  */
34
- export declare function evaluateAllRules(graph: OntologyGraph, policy: MetricPolicy): RuleViolation[];
41
+ export declare function evaluateAllRules(graph: OntologyGraph, policy: MetricPolicy, only?: ReadonlySet<string>): RuleViolation[];
@@ -1,16 +1,16 @@
1
- import { V3_RULES, evaluateRules } from './rules.js';
2
- import { SC_RULES, evaluateSCRules } from './schema-quality-rules.js';
3
- import { UC_RULES, evaluateUCRules } from './uc-quality-rules.js';
4
- import { FC_RULES, evaluateFCRules } from './fchain-quality-rules.js';
5
- import { MT_RULES, evaluateMTRules } from './metric-rules.js';
1
+ import { V3_RULES } from './rules.js';
2
+ import { SC_RULES } from './schema-quality-rules.js';
3
+ import { UC_RULES } from './uc-quality-rules.js';
4
+ import { FC_RULES } from './fchain-quality-rules.js';
5
+ import { MT_RULES } from './metric-rules.js';
6
6
  import { toEvaluableGraph } from './flat-graph.js';
7
- import { FM_RULES, evaluateFMRules } from './fmea-rules.js';
8
- import { VIEW_RULES, evaluateViewRules } from './view-rules.js';
9
- import { CR_RULES, evaluateCRRules } from './cr-quality-rules.js';
10
- import { ND_RULES, evaluateNDRules } from './near-duplicate-rules.js';
11
- import { AO_RULES, evaluateAORules } from './ao-rules.js';
12
- import { BQ_RULES, evaluateBQRules } from './quality-rules.js';
13
- import { AF_RULES, evaluateAFRules, TASK_OUTCOME_RULES, evaluateTaskOutcomeRules } from './analysis-freshness-rules.js';
7
+ import { FM_RULES } from './fmea-rules.js';
8
+ import { VIEW_RULES } from './view-rules.js';
9
+ import { CR_RULES } from './cr-quality-rules.js';
10
+ import { ND_RULES } from './near-duplicate-rules.js';
11
+ import { AO_RULES } from './ao-rules.js';
12
+ import { BQ_RULES } from './quality-rules.js';
13
+ import { AF_RULES, TASK_OUTCOME_RULES } from './analysis-freshness-rules.js';
14
14
  import { CODE_CONFORMANCE_RULES } from './conformance-rules.js';
15
15
  /**
16
16
  * CR-SM-285: das Profil haengt am KATALOG, nicht am ID-Praefix.
@@ -67,14 +67,31 @@ export function getRuleDefsForProfile(profile) {
67
67
  return ALL_RULE_DEFS;
68
68
  return ALL_RULE_DEFS.filter(r => r.profile === profile);
69
69
  }
70
+ /**
71
+ * Die ausgewerteten Kataloge in der Reihenfolge der kanonischen Befund-Sequenz (CR-SM-240).
72
+ * Ohne `CODE_CONFORMANCE_RULES` — die brauchen `CodeFacts` (s. CATALOGS). Die Reihenfolge ist
73
+ * Vertrag: das Golden (`se-rule-output-identity.test.ts`) pinnt sie.
74
+ */
75
+ const EVALUATED_CATALOGS = [
76
+ V3_RULES, BQ_RULES, UC_RULES, FC_RULES, SC_RULES, ND_RULES, MT_RULES, CR_RULES, AO_RULES,
77
+ FM_RULES, VIEW_RULES, AF_RULES,
78
+ TASK_OUTCOME_RULES, // CR-SM-355
79
+ ];
70
80
  /**
71
81
  * Evaluate all rules against a graph. Single call replaces the individual evaluator calls.
72
82
  *
73
83
  * CR-SM-233: `policy` ist **Pflicht und ohne Fallback** — ein Aufruf ohne Policy ist ein
74
84
  * Typfehler, keine stille 0.7. Wer keine eigene Quelle hat (Konfiguration, Host), nimmt
75
85
  * `DEFAULT_METRIC_POLICY` sichtbar an der Aufrufstelle.
86
+ *
87
+ * CR-SM-378: `only` ist ein REGELFILTER, kein zweiter Auswerter — dieselbe Normalisierung,
88
+ * dieselbe Reihenfolge, dieselben `evaluate`-Aufrufe; eine Regel ausserhalb des Filters laeuft nur
89
+ * nicht. Das Ergebnis ist gleich dem Vollergebnis, nachtraeglich auf `only` gefiltert. Grund:
90
+ * der Steuer-Operator (se-engine `bestBySteer`) bewertete jeden Kandidaten mit dem ganzen
91
+ * Katalog, inklusive des quadratischen ND-01 — 8,9 von 17,1 s bei 2000 Knoten fuer Befunde, die
92
+ * er nie las. Ohne `only` laeuft der ganze Katalog.
76
93
  */
77
- export function evaluateAllRules(graph, policy) {
94
+ export function evaluateAllRules(graph, policy, only) {
78
95
  // CR-SM-284: die Hebung der flach committeten SSOT laeuft HIER, nicht beim Aufrufer.
79
96
  //
80
97
  // `toEvaluableGraph` gibt es seit CR-SM-258 — und sie wurde von keinem Produktionspfad und
@@ -84,20 +101,6 @@ export function evaluateAllRules(graph, policy) {
84
101
  //
85
102
  // Idempotent und identitaetserhaltend (s. dort), also eine Normalisierung am einzigen Eingang
86
103
  // und kein Fallback: ein bereits genesteter Graph geht unveraendert und uneingepackt durch.
87
- graph = toEvaluableGraph(graph);
88
- return [
89
- ...evaluateRules(graph, policy),
90
- ...evaluateBQRules(graph, policy),
91
- ...evaluateUCRules(graph, policy),
92
- ...evaluateFCRules(graph, policy),
93
- ...evaluateSCRules(graph, policy),
94
- ...evaluateNDRules(graph),
95
- ...evaluateMTRules(graph, policy),
96
- ...evaluateCRRules(graph, policy),
97
- ...evaluateAORules(graph, policy),
98
- ...evaluateFMRules(graph, policy),
99
- ...evaluateViewRules(graph, policy),
100
- ...evaluateAFRules(graph),
101
- ...evaluateTaskOutcomeRules(graph), // CR-SM-355
102
- ];
104
+ const evaluable = toEvaluableGraph(graph);
105
+ return EVALUATED_CATALOGS.flatMap((rules) => rules.flatMap((rule) => (only === undefined || only.has(rule.id) ? rule.evaluate(evaluable, policy) : [])));
103
106
  }