@sigloch/contracts 10.9.0 → 10.12.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.
@@ -40,5 +40,25 @@ export declare const AF_RULES: readonly [{
40
40
  readonly evaluate: (graph: OntologyGraph) => RuleViolation[];
41
41
  readonly domain: readonly ["graph"];
42
42
  }];
43
+ /** TR-01: a stamped trade study names >=1 existing CR, each carrying a relation(label: decides) edge. */
44
+ export declare function tradeDecisionRecorded(graph: OntologyGraph): RuleViolation[];
45
+ /** IR-01: every CR named by a stamped assumption review exists; an empty list is a legitimate outcome. */
46
+ export declare function assumptionReviewPromoted(graph: OntologyGraph): RuleViolation[];
47
+ /** CR-SM-355: the task-outcome rules — owned by trade / irr (TASK_OWNED_RULES), never in the kern focus. */
48
+ export declare const TASK_OUTCOME_RULES: readonly [{
49
+ readonly id: "TR-01";
50
+ readonly name: "Trade decision recorded as CR";
51
+ readonly severity: "warning";
52
+ readonly evaluate: typeof tradeDecisionRecorded;
53
+ readonly domain: readonly ["graph"];
54
+ }, {
55
+ readonly id: "IR-01";
56
+ readonly name: "Assumption review promoted to CR";
57
+ readonly severity: "warning";
58
+ readonly evaluate: typeof assumptionReviewPromoted;
59
+ readonly domain: readonly ["graph"];
60
+ }];
61
+ /** Evaluate the task-outcome rules (CR-SM-355). */
62
+ export declare function evaluateTaskOutcomeRules(graph: OntologyGraph): RuleViolation[];
43
63
  /** Evaluate all analysis-freshness-presence rules. */
44
64
  export declare function evaluateAFRules(graph: OntologyGraph): RuleViolation[];
@@ -38,6 +38,77 @@ export const AF_RULES = [
38
38
  { id: 'AF-04', name: 'FMEA freshness stamp present', severity: 'warning', evaluate: fmeaFreshnessPresent, domain: ['graph'] },
39
39
  { id: 'AF-05', name: 'Implementation Plan freshness stamp present', severity: 'warning', evaluate: implplanFreshnessPresent, domain: ['graph'] },
40
40
  ];
41
+ /**
42
+ * CR-SM-355: Task-Ausgangsregeln fuer trade und irr — die aus dem Task entstandenen Massnahmen sind
43
+ * als CR dokumentiert. Traeger ist `crRefs` im Frischestempel. Ohne Stempel feuern sie nicht (dann
44
+ * feuert der Eintritt AF-02/AF-03, und der Task ist nicht durch). `label` wird als `Trace.label`
45
+ * gelesen wie bei MS-02 — `decides` ist kein TraceType, sondern das SP-1-Label einer relation-Kante.
46
+ */
47
+ function stampedCrRefs(graph, artifactId) {
48
+ const sys = graph.elements.find(e => e.type === 'SYS');
49
+ if (!sys)
50
+ return null;
51
+ const raw = sys.attributes?.analysisFreshness?.[artifactId];
52
+ const parsed = AnalysisFreshnessStampSchema.safeParse(raw);
53
+ if (!parsed.success)
54
+ return null;
55
+ return { sys, crRefs: parsed.data.crRefs ?? [] };
56
+ }
57
+ /** TR-01: a stamped trade study names >=1 existing CR, each carrying a relation(label: decides) edge. */
58
+ export function tradeDecisionRecorded(graph) {
59
+ const stamped = stampedCrRefs(graph, 'trade');
60
+ if (!stamped)
61
+ return [];
62
+ const { sys, crRefs } = stamped;
63
+ const base = { rule_id: 'TR-01', severity: 'warning', context: { element_type: sys.type, element_name: sys.name } };
64
+ if (crRefs.length === 0) {
65
+ return [{
66
+ ...base,
67
+ element_id: sys.id,
68
+ message: 'Trade Study is stamped but names no decision CR (analysisFreshness.trade.crRefs is empty) — a trade without a recorded decision is a list, not a trade',
69
+ fix_hint: 'Record the decision as a CR with relation(label: decides) edges and list its id in analysisFreshness.trade.crRefs (se-trade)',
70
+ }];
71
+ }
72
+ const byId = new Map(graph.elements.map(e => [e.id, e]));
73
+ const decidesFrom = new Set(graph.traces.filter(t => t.type === 'relation' && t.label === 'decides').map(t => t.source));
74
+ return crRefs.flatMap(id => {
75
+ const cr = byId.get(id);
76
+ if (!cr || cr.type !== 'CR') {
77
+ return [{ ...base, element_id: sys.id, message: `Trade Study names ${id} as its decision, but no CR with that id exists`, fix_hint: 'Point analysisFreshness.trade.crRefs at an existing CR, or add the CR through graph_mutate (se-trade)' }];
78
+ }
79
+ if (!decidesFrom.has(id)) {
80
+ return [{ ...base, element_id: id, message: `${id} is named as a trade decision but carries no relation(label: decides) edge`, fix_hint: 'Add a relation edge from the CR to the element it decides, with label "decides" (se-trade)' }];
81
+ }
82
+ return [];
83
+ });
84
+ }
85
+ /** IR-01: every CR named by a stamped assumption review exists; an empty list is a legitimate outcome. */
86
+ export function assumptionReviewPromoted(graph) {
87
+ const stamped = stampedCrRefs(graph, 'assumption-review');
88
+ if (!stamped)
89
+ return [];
90
+ const { sys, crRefs } = stamped;
91
+ const byId = new Map(graph.elements.map(e => [e.id, e]));
92
+ return crRefs
93
+ .filter(id => byId.get(id)?.type !== 'CR')
94
+ .map(id => ({
95
+ rule_id: 'IR-01',
96
+ severity: 'warning',
97
+ element_id: sys.id,
98
+ message: `Assumption Review names ${id} as a promoted assumption, but no CR with that id exists`,
99
+ fix_hint: 'Point analysisFreshness.assumption-review.crRefs at an existing CR, or add the CR through graph_mutate (se-irr)',
100
+ context: { element_type: sys.type, element_name: sys.name },
101
+ }));
102
+ }
103
+ /** CR-SM-355: the task-outcome rules — owned by trade / irr (TASK_OWNED_RULES), never in the kern focus. */
104
+ export const TASK_OUTCOME_RULES = [
105
+ { id: 'TR-01', name: 'Trade decision recorded as CR', severity: 'warning', evaluate: tradeDecisionRecorded, domain: ['graph'] },
106
+ { id: 'IR-01', name: 'Assumption review promoted to CR', severity: 'warning', evaluate: assumptionReviewPromoted, domain: ['graph'] },
107
+ ];
108
+ /** Evaluate the task-outcome rules (CR-SM-355). */
109
+ export function evaluateTaskOutcomeRules(graph) {
110
+ return TASK_OUTCOME_RULES.flatMap(r => r.evaluate(graph));
111
+ }
41
112
  /** Evaluate all analysis-freshness-presence rules. */
42
113
  export function evaluateAFRules(graph) {
43
114
  return AF_RULES.flatMap(r => r.evaluate(graph));
@@ -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,14 +94,14 @@ 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
- severity: 'error',
104
+ severity: 'warning',
92
105
  element_id: el.id,
93
106
  message: `${el.id} realRef.file '${ref.file}' does not exist on disk`,
94
107
  fix_hint: 'Re-realize the FUNC (graph_realize) against the current source tree, or fix the moved/renamed file path',
@@ -101,7 +114,7 @@ function codeRefMustResolve(graph, facts) {
101
114
  if (!facts.files[ref.file].declaredSymbols.includes(ref.symbol)) {
102
115
  violations.push({
103
116
  rule_id: 'RC-01',
104
- severity: 'error',
117
+ severity: 'warning',
105
118
  element_id: el.id,
106
119
  message: `${el.id} realRef.symbol '${ref.symbol}' is not declared in '${ref.file}'`,
107
120
  fix_hint: 'The symbol was renamed or removed — re-realize the FUNC against the current code',
@@ -125,14 +138,14 @@ 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
- severity: 'error',
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
151
  fix_hint: 'The test file was moved or deleted — rebind the TEST (graph_realize) to the current file, or drop the entry',
@@ -146,7 +159,7 @@ function testRefMustResolve(graph, facts) {
146
159
  if (caseName !== undefined && !facts.files[ref.file].testCases.some((name) => name.includes(caseName))) {
147
160
  violations.push({
148
161
  rule_id: 'RC-02',
149
- severity: 'error',
162
+ severity: 'warning',
150
163
  element_id: el.id,
151
164
  message: `${el.id} testRefs entry case '${ref.case}' is not declared in '${ref.file}'`,
152
165
  fix_hint: 'The test case was renamed or removed — rebind the TEST case name',
@@ -169,14 +182,14 @@ 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
- severity: 'error',
192
+ severity: 'warning',
180
193
  element_id: el.id,
181
194
  message: `${el.id} realRef.file '${ref.file}' does not exist on disk`,
182
195
  fix_hint: 'Re-bind the SCHEMA (graph_realize) to the current source tree, or fix the moved/renamed file path',
@@ -189,7 +202,7 @@ function schemaRefMustResolve(graph, facts) {
189
202
  if (!facts.files[ref.file].declaredSymbols.includes(ref.symbol)) {
190
203
  violations.push({
191
204
  rule_id: 'RC-03',
192
- severity: 'error',
205
+ severity: 'warning',
193
206
  element_id: el.id,
194
207
  message: `${el.id} realRef.symbol '${ref.symbol}' is not a declared export in '${ref.file}'`,
195
208
  fix_hint: 'The schema export was renamed or removed — re-bind the SCHEMA against the current code',
@@ -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)
289
+ const parsed = readRealRef(el.attributes);
290
+ if (parsed.state !== 'bound' || parsed.value.symbol === undefined)
223
291
  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)
230
- 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,14 +568,101 @@ 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
+ }
517
656
  export const CODE_CONFORMANCE_RULES = [
518
- { id: 'RC-01', name: 'FUNC realRef resolves to a declared symbol', severity: 'error', domain: ['FUNC'], evaluate: codeRefMustResolve },
519
- { id: 'RC-02', name: 'testRefs entries resolve to runnable tests', severity: 'error', domain: ['TEST'], evaluate: testRefMustResolve },
520
- { id: 'RC-03', name: 'SCHEMA realRef resolves to a declared export', severity: 'error', domain: ['SCHEMA'], evaluate: schemaRefMustResolve },
657
+ { id: 'RC-01', name: 'FUNC realRef resolves to a declared symbol', severity: 'warning', domain: ['FUNC'], evaluate: codeRefMustResolve },
658
+ { id: 'RC-02', name: 'testRefs entries resolve to runnable tests', severity: 'warning', domain: ['TEST'], evaluate: testRefMustResolve },
659
+ { id: 'RC-03', name: 'SCHEMA realRef resolves to a declared export', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaRefMustResolve },
521
660
  { id: 'RC-04', name: 'SCHEMA realRef is parsed at its interface', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaRefMustBeUsed },
522
661
  { id: 'RC-05', name: 'cross-module import drift', severity: 'warning', domain: ['MOD'], evaluate: importDriftConformance },
523
662
  { id: 'RC-06', name: 'external realRef names a declared dependency', severity: 'warning', domain: ['FUNC', 'MOD', 'SCHEMA'], evaluate: externalRefMustNameDependency },
524
663
  { id: 'RC-07', name: 'CR node agrees with docs/cr', severity: 'warning', domain: ['CR', 'SYS'], evaluate: crNodeMatchesFile },
664
+ { id: 'RC-08', name: 'SCHEMA realRef is a Zod schema', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaRefMustBeZod },
665
+ { id: 'RC-09', name: 'SCHEMA is parsed only at its modelled interface', severity: 'warning', domain: ['SCHEMA'], evaluate: schemaParsedOnlyAtInterface },
525
666
  ];
526
667
  /** Run all RC rules against a graph + extracted code facts. */
527
668
  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[];
@@ -36,8 +36,13 @@ const SCOPE_TYPES = new Set(['FUNC', 'MOD', 'SCHEMA', 'REQ', 'UC']);
36
36
  /**
37
37
  * Abgeschlossen heisst: nicht mehr steuerbar. Alles andere ist Grundgesamtheit.
38
38
  * Exportiert fuer RC-07 (CR-SM-329), das dieselbe Frage gegen `docs/cr/done/` stellt.
39
+ *
40
+ * CR-SM-362 (ITEM-2026-536): nur noch `done`. Die Menge kannte `dropped`/`rejected`, der
41
+ * Element-Vertrag (`OntologyElement.status`) nicht — die Regeln rechneten mit Werten, die der
42
+ * Vertrag verbietet. Verworfen ist eine Begruendung im CR-Text, kein eigener Zustand: das
43
+ * Verzeichnis `done/` schliesst ihn, der Knoten spiegelt `done`.
39
44
  */
40
- export const CLOSED_STATUS = new Set(['done', 'dropped', 'rejected']);
45
+ export const CLOSED_STATUS = new Set(['done']);
41
46
  function crMustTrack(graph) {
42
47
  const typeOf = new Map(graph.elements.map(e => [e.id, e.type]));
43
48
  const crs = graph.elements.filter(e => {
@@ -49,7 +54,7 @@ function crMustTrack(graph) {
49
54
  .filter(cr => !graph.traces.some(t => t.source === cr.id && t.type === 'relation' && SCOPE_TYPES.has(typeOf.get(t.target) ?? '')))
50
55
  .map(cr => ({
51
56
  rule_id: 'CR-R01',
52
- severity: 'error',
57
+ severity: 'warning',
53
58
  element_id: cr.id,
54
59
  message: `${cr.id} names no scope (no relation to FUNC/MOD/SCHEMA/REQ/UC)`,
55
60
  fix_hint: 'Add a relation trace to the FUNC, MOD, SCHEMA, REQ or UC this CR changes — a milestone alone is a date, not a scope',
@@ -76,7 +81,7 @@ function crDoneRequiresCommit(graph) {
76
81
  })
77
82
  .map(cr => ({
78
83
  rule_id: 'CR-R02',
79
- severity: 'error',
84
+ severity: 'warning',
80
85
  element_id: cr.id,
81
86
  message: `${cr.id} is done but has no commitRef`,
82
87
  fix_hint: 'Add commitRef with Git SHA, or set architectureOnly=true',
@@ -176,8 +181,8 @@ function crShouldHaveMilestone(graph) {
176
181
  // Exports
177
182
  // ---------------------------------------------------------------------------
178
183
  export const CR_RULES = [
179
- { id: 'CR-R01', name: 'CR must track', severity: 'error', evaluate: crMustTrack, domain: ['CR'] },
180
- { id: 'CR-R02', name: 'Done requires commit', severity: 'error', evaluate: crDoneRequiresCommit, domain: ['CR'] },
184
+ { id: 'CR-R01', name: 'CR must track', severity: 'warning', evaluate: crMustTrack, domain: ['CR'] },
185
+ { id: 'CR-R02', name: 'Done requires commit', severity: 'warning', evaluate: crDoneRequiresCommit, domain: ['CR'] },
181
186
  // CR-SM-243: domain ist 'all', nicht CR — die Regel meldet am getrackten ZIELKNOTEN
182
187
  // ("dieser Knoten wird von 2 offenen CRs angefasst"), und der kann jeden Typ haben. Eine
183
188
  // Aufzaehlung waere eine Momentaufnahme der heute getrackten Typen, kein Modell; 'all'
@@ -10,7 +10,7 @@ import { CR_RULES, evaluateCRRules } from './cr-quality-rules.js';
10
10
  import { ND_RULES, evaluateNDRules } from './near-duplicate-rules.js';
11
11
  import { AO_RULES, evaluateAORules } from './ao-rules.js';
12
12
  import { BQ_RULES, evaluateBQRules } from './quality-rules.js';
13
- import { AF_RULES, evaluateAFRules } from './analysis-freshness-rules.js';
13
+ import { AF_RULES, evaluateAFRules, TASK_OUTCOME_RULES, evaluateTaskOutcomeRules } 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.
@@ -36,6 +36,7 @@ const CATALOGS = [
36
36
  { profile: 'se', rules: FM_RULES },
37
37
  { profile: 'se', rules: VIEW_RULES },
38
38
  { profile: 'se', rules: AF_RULES },
39
+ { profile: 'se', rules: TASK_OUTCOME_RULES }, // CR-SM-355: Task-Ausgaenge trade / irr
39
40
  // Sprach-/Textqualitaet statt SE-Struktur — das ist die Trennlinie, die die Praefixliste meinte.
40
41
  { profile: 'coding', rules: BQ_RULES },
41
42
  { profile: 'coding', rules: ND_RULES },
@@ -97,5 +98,6 @@ export function evaluateAllRules(graph, policy) {
97
98
  ...evaluateFMRules(graph, policy),
98
99
  ...evaluateViewRules(graph, policy),
99
100
  ...evaluateAFRules(graph),
101
+ ...evaluateTaskOutcomeRules(graph), // CR-SM-355
100
102
  ];
101
103
  }
@@ -7,7 +7,7 @@
7
7
  * by dimension nature: physical budgets (weight/power/cost) live on the part
8
8
  * (MOD); behavioral budgets (timing/memory-throughput) on the FCHAIN/FUNC.
9
9
  */
10
- import { TestRefsSchema } from './ontology.js';
10
+ import { readTestRefs } from './ontology.js';
11
11
  import { actionPriority, apMethod } from './action-priority.js';
12
12
  const PHYSICAL_BUDGET_PAIRS = [
13
13
  ['costBudget', 'measuredCost', 'cost'],
@@ -106,10 +106,10 @@ export function fm03HighRiskUnverified(graph, policy) {
106
106
  graph.elements.some(el => {
107
107
  if (el.id !== t.source || el.type !== 'TEST')
108
108
  return false;
109
- const parsed = TestRefsSchema.safeParse(el.attributes?.testRefs);
110
- if (!parsed.success)
109
+ const parsed = readTestRefs(el.attributes);
110
+ if (parsed.state !== 'bound')
111
111
  return false; // keine Bindung → keine Evidenz
112
- return parsed.data.every(ref => ref.result === 'passed');
112
+ return parsed.value.every(ref => ref.result === 'passed');
113
113
  }));
114
114
  return !hasPassedTest;
115
115
  })
@@ -121,7 +121,7 @@ export function fm03HighRiskUnverified(graph, policy) {
121
121
  // dieselbe Quelle. `rpn-interim` heisst „bestaetigtes AP, wo bekannt; RPN, wo nicht".
122
122
  return {
123
123
  rule_id: 'FM-03',
124
- severity: 'error',
124
+ severity: 'warning',
125
125
  element_id: e.id,
126
126
  message: `${e.id} has Action Priority High (S${s}/O${o}/D${d}, ${method}) without passed test verification`,
127
127
  fix_hint: 'Add a TEST whose every testRefs entry has result=passed, and a verify trace to this risk REQ',
@@ -166,7 +166,7 @@ export function nfr01BudgetOvershoot(graph) {
166
166
  export const FM_RULES = [
167
167
  { id: 'FM-01', name: 'RiskReqFmeaAttributes', severity: 'warning', evaluate: fm01MissingFmeaAttributes, domain: ['REQ'] },
168
168
  { id: 'FM-02', name: 'RiskReqMitigation', severity: 'warning', evaluate: fm02MissingMitigation, domain: ['REQ'] },
169
- { id: 'FM-03', name: 'HighRiskVerification', severity: 'error', evaluate: fm03HighRiskUnverified, domain: ['REQ'] },
169
+ { id: 'FM-03', name: 'HighRiskVerification', severity: 'warning', evaluate: fm03HighRiskUnverified, domain: ['REQ'] },
170
170
  // CR-SM-243: die Regel prueft drei Typen, nicht einen — physische Budgets am MOD
171
171
  // (PHYSICAL_BUDGET_TYPES), Verhaltensbudgets an FUNC und FCHAIN (BEHAVIORAL_BUDGET_TYPES).
172
172
  // Die domain nennt dieselben Mengen wie die beiden Konstanten oben; wer eine davon