@suss/checker-intent 0.33.0 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -20,16 +20,243 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/index.ts
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
+ accessDetail: () => accessDetail,
23
24
  applyIntentSuppressions: () => applyIntentSuppressions,
24
25
  checkIntentAgreement: () => checkIntentAgreement,
26
+ codeEffectsOf: () => codeEffectsOf,
27
+ effectMatches: () => effectMatches,
28
+ endingOf: () => endingOf,
29
+ outcomeMatches: () => outcomeMatches,
25
30
  whatWouldKeyIt: () => whatWouldKeyIt
26
31
  });
27
32
  module.exports = __toCommonJS(index_exports);
28
- var import_behavioral_ir2 = require("@suss/behavioral-ir");
33
+ var import_behavioral_ir4 = require("@suss/behavioral-ir");
29
34
  var import_ir_core = require("@suss/ir-core");
30
35
 
31
- // src/receivedInput.ts
36
+ // src/coveringTests.ts
32
37
  var import_behavioral_ir = require("@suss/behavioral-ir");
38
+ var import_checker = require("@suss/checker");
39
+ function checkCoveringTests(claims, lookup) {
40
+ const verdicts = [];
41
+ const waiting = [];
42
+ for (const [at, claim] of claims.entries()) {
43
+ const found = lookup.test(claim.spelled);
44
+ if (!found.found) {
45
+ verdicts.push(finding(claim, "missingCoveringTest", found.message));
46
+ continue;
47
+ }
48
+ if ((0, import_behavioral_ir.readTestMetadata)(found.unit)?.skipped === true) {
49
+ verdicts.push(
50
+ finding(
51
+ claim,
52
+ "coveringTestSkipped",
53
+ "which is marked to be skipped, so it does not run"
54
+ )
55
+ );
56
+ continue;
57
+ }
58
+ const subject = subjectOf(claim, lookup);
59
+ if ("message" in subject) {
60
+ verdicts.push(finding(claim, "testMissesSubject", subject.message));
61
+ continue;
62
+ }
63
+ verdicts.push(null);
64
+ waiting.push({
65
+ at,
66
+ unit: found.unit,
67
+ test: (0, import_checker.functionOf)(found.unit),
68
+ subject
69
+ });
70
+ }
71
+ if (waiting.length === 0) {
72
+ return verdicts.map((verdict) => verdict ?? { kind: "covered" });
73
+ }
74
+ const honouring = lookup.facts.reachedFromEach(
75
+ waiting.map((one) => one.test),
76
+ { pastMocks: true }
77
+ );
78
+ const missed = waiting.filter(
79
+ (one) => pathInto(one.test, honouring.get(one.test), one.subject) === null && callOnSubjectClass(one.test, one.subject, lookup, true) === null
80
+ );
81
+ const ignoring = missed.length === 0 ? /* @__PURE__ */ new Map() : lookup.facts.reachedFromEach(missed.map((one) => one.test));
82
+ for (const one of waiting) {
83
+ const claim = claims[one.at];
84
+ if (!missed.includes(one)) {
85
+ verdicts[one.at] = { kind: "covered" };
86
+ continue;
87
+ }
88
+ const reached = ignoring.get(one.test);
89
+ const throughMock = pathInto(one.test, reached, one.subject) ?? callOnSubjectClass(one.test, one.subject, lookup, false);
90
+ if (throughMock !== null) {
91
+ verdicts[one.at] = finding(
92
+ claim,
93
+ "testMissesSubject",
94
+ `which reaches ${one.subject.label} only through a call its mocks replace (${mocksOf(lookup, one.test).join(", ")}), by ${(0, import_checker.callSpellings)(throughMock).join(" -> ")}`
95
+ );
96
+ continue;
97
+ }
98
+ const unfollowed = unfollowedOnTheWay(one.unit, one.test, reached, lookup);
99
+ const nameMatches = unfollowedToSubject(one.unit, one.subject, lookup);
100
+ verdicts[one.at] = unfollowed.length === 0 && nameMatches.length === 0 ? finding(
101
+ claim,
102
+ "testMissesSubject",
103
+ `which never reaches ${one.subject.label}`
104
+ ) : notChecked(claim, one.subject, nameMatches, unfollowed);
105
+ }
106
+ return verdicts.map((verdict) => verdict ?? { kind: "covered" });
107
+ }
108
+ var CALLS_LISTED = 3;
109
+ function notChecked(claim, subject, nameMatches, unfollowed) {
110
+ const calls = nameMatches.length > 0 ? nameMatches : unfollowed;
111
+ const listed = calls.slice(0, CALLS_LISTED).join(", ");
112
+ const more = calls.length > CALLS_LISTED ? `, and ${calls.length - CALLS_LISTED} more` : "";
113
+ const why = nameMatches.length > 0 ? `it calls ${listed}${more}, and suss could not follow that call to ${subject.label}` : `it never reaches ${subject.label} through the calls suss followed, and suss could not follow ${listed}${more}`;
114
+ return {
115
+ kind: "unchecked",
116
+ unchecked: {
117
+ intent: claim.prd.title,
118
+ reason: "unfollowedCall",
119
+ scenario: claim.label,
120
+ coveredBy: claim.spelled.spelledAs,
121
+ detail: `Scenario ${claim.label} lists the test "${claim.spelled.spelledAs}": ${why}.`
122
+ }
123
+ };
124
+ }
125
+ function unfollowedOnTheWay(test, key, reached, lookup) {
126
+ const units = [
127
+ test,
128
+ ...[...reached?.functions.keys() ?? []].filter((fn) => fn !== key).flatMap((fn) => lookup.facts.units.get(fn) ?? [])
129
+ ];
130
+ const found = /* @__PURE__ */ new Set();
131
+ for (const unit of units) {
132
+ for (const gap of unit.gaps) {
133
+ if (gap.type === "unfollowedCall" && gap.callee !== void 0) {
134
+ found.add(gap.callee);
135
+ }
136
+ }
137
+ }
138
+ return [...found];
139
+ }
140
+ function subjectOf(claim, lookup) {
141
+ if (claim.subjects.length === 0) {
142
+ return {
143
+ message: "and the PRD links to no boundary a test could be checked against; say what it has to reach under about"
144
+ };
145
+ }
146
+ const found = [];
147
+ for (const spelledAs of claim.subjects) {
148
+ const one = lookup.subject(spelledAs);
149
+ if (!one.found) {
150
+ return {
151
+ message: `and what it has to reach, ${spelledAs}, is nothing in these summaries: ${one.message.replace(/\.$/, "")}`
152
+ };
153
+ }
154
+ found.push({ ...one, classes: one.classes ?? [] });
155
+ }
156
+ return {
157
+ target: {
158
+ functions: found.flatMap((one) => one.target.functions),
159
+ keys: found.flatMap((one) => one.target.keys),
160
+ at: found.flatMap((one) => one.target.at ?? [])
161
+ },
162
+ label: found.map((one) => one.label).join(" or "),
163
+ classes: found.flatMap((one) => one.classes)
164
+ };
165
+ }
166
+ function pathInto(test, reached, subject) {
167
+ const into = [...subject.target.functions, ...subject.target.at ?? []];
168
+ if (into.includes(test)) {
169
+ return [];
170
+ }
171
+ const paths = [
172
+ ...into.map((fn) => reached?.functions.get(fn)),
173
+ ...subject.target.keys.map((key) => reached?.keys.get(key))
174
+ ].filter((path) => path !== void 0);
175
+ if (paths.length === 0) {
176
+ return null;
177
+ }
178
+ return paths.reduce(
179
+ (shortest, path) => path.length < shortest.length ? path : shortest
180
+ );
181
+ }
182
+ function callOnSubjectClass(test, subject, lookup, honourMocks) {
183
+ const classes = new Set(subject.classes.map(classKey));
184
+ if (classes.size === 0) {
185
+ return null;
186
+ }
187
+ const units = lookup.facts.units.get(test) ?? [];
188
+ const mocks = honourMocks ? units.flatMap((unit) => (0, import_behavioral_ir.readTestMetadata)(unit)?.mocks ?? []) : [];
189
+ for (const effect of units.flatMap(
190
+ (unit) => unit.transitions.flatMap((transition) => transition.effects)
191
+ )) {
192
+ if (effect.type !== "invocation" || effect.receiverClass === void 0 || !classes.has(classKey(effect.receiverClass))) {
193
+ continue;
194
+ }
195
+ const method = lastName(effect.callee);
196
+ if (mocks.some((mock) => replaces(mock, effect.receiverClass, method))) {
197
+ continue;
198
+ }
199
+ return [{ callee: effect.callee, to: null, recorded: "written" }];
200
+ }
201
+ return null;
202
+ }
203
+ function classKey(sentTo) {
204
+ return `${sentTo.file}#${sentTo.name}`;
205
+ }
206
+ function replaces(mock, sentTo, method) {
207
+ if (mock.module === void 0) {
208
+ return mock.name === method;
209
+ }
210
+ return mock.module === sentTo?.file && (mock.name === void 0 || mock.name === method);
211
+ }
212
+ function unfollowedToSubject(test, subject, lookup) {
213
+ const names = /* @__PURE__ */ new Set([
214
+ ...[...subject.target.functions, ...subject.target.at ?? []].flatMap(
215
+ (fn) => (lookup.facts.units.get(fn) ?? []).map(
216
+ (unit) => lastName(unit.identity.name)
217
+ )
218
+ ),
219
+ ...subject.target.keys.map(lastName)
220
+ ]);
221
+ const unfollowed = test.transitions.flatMap(
222
+ (transition) => transition.effects.flatMap(
223
+ (effect) => effect.type === "invocation" && effect.summary === void 0 && names.has(lastName(effect.callee)) ? [effect.callee] : []
224
+ )
225
+ );
226
+ return [...new Set(unfollowed)];
227
+ }
228
+ function lastName(spelled) {
229
+ return spelled.split(/::|\./).at(-1) ?? spelled;
230
+ }
231
+ function mocksOf(lookup, test) {
232
+ const units = lookup.facts.units.get(test) ?? [];
233
+ return [
234
+ ...new Set(
235
+ units.flatMap(
236
+ (unit) => ((0, import_behavioral_ir.readTestMetadata)(unit)?.mocks ?? []).map((mock) => mock.written)
237
+ )
238
+ )
239
+ ];
240
+ }
241
+ function finding(claim, kind, why) {
242
+ return {
243
+ kind: "finding",
244
+ finding: {
245
+ kind,
246
+ severity: "warning",
247
+ boundary: `prd:${claim.prd.title}`,
248
+ intent: { name: claim.prd.title },
249
+ scenario: {
250
+ ...claim.scenarioTitle !== null ? { title: claim.scenarioTitle } : {},
251
+ coveredBy: claim.spelled.spelledAs
252
+ },
253
+ message: `Scenario ${claim.label} in PRD "${claim.prd.title}" lists the test "${claim.spelled.spelledAs}", ${why}.`
254
+ }
255
+ };
256
+ }
257
+
258
+ // src/receivedInput.ts
259
+ var import_behavioral_ir2 = require("@suss/behavioral-ir");
33
260
  function overlaps(a, b) {
34
261
  const left = comparable(a);
35
262
  const right = comparable(b);
@@ -54,7 +281,7 @@ function checkReceivesBlock(pairing) {
54
281
  if (declared.length === 0) {
55
282
  return [];
56
283
  }
57
- const result = (0, import_behavioral_ir.boundaryInputReads)(
284
+ const result = (0, import_behavioral_ir2.boundaryInputReads)(
58
285
  pairing.impl,
59
286
  pairing.intent.boundary,
60
287
  pairing.wrappers
@@ -63,7 +290,7 @@ function checkReceivesBlock(pairing) {
63
290
  return [];
64
291
  }
65
292
  const read = result.reads.paths;
66
- const unread = declared.filter(
293
+ const unread2 = declared.filter(
67
294
  (field) => !read.some((path) => overlaps(path, field.path))
68
295
  );
69
296
  const alreadyDescribed = DESCRIBED_WITHOUT_LISTING[pairing.intent.boundary.semantics.name];
@@ -71,7 +298,7 @@ function checkReceivesBlock(pairing) {
71
298
  (path) => !declared.some((field) => overlaps(path, field.path)) && !alreadyDescribed(declared, path)
72
299
  );
73
300
  return [
74
- ...unread.map((field) => unreadFinding(pairing, field)),
301
+ ...unread2.map((field) => unreadFinding(pairing, field)),
75
302
  ...undeclaredFindings(pairing, undeclared)
76
303
  ];
77
304
  }
@@ -85,7 +312,8 @@ var DESCRIBED_WITHOUT_LISTING = {
85
312
  "graphql-resolver": LISTS_EVERYTHING,
86
313
  "graphql-operation": LISTS_EVERYTHING,
87
314
  "runtime-config": LISTS_EVERYTHING,
88
- metric: LISTS_EVERYTHING
315
+ metric: LISTS_EVERYTHING,
316
+ io: LISTS_EVERYTHING
89
317
  };
90
318
  function unreadFinding(pairing, field) {
91
319
  const needsIt = field.required ? " and needs it" : "";
@@ -95,14 +323,14 @@ function unreadFinding(pairing, field) {
95
323
  boundary: pairing.boundary,
96
324
  intent: { name: pairing.intent.name },
97
325
  code: pairing.code,
98
- message: `Intent "${pairing.intent.name}" says ${pairing.boundary} receives ${(0, import_behavioral_ir.formatPath)(field.path)}${needsIt}; ${pairing.impl.identity.name} never reads it.`
326
+ message: `Intent "${pairing.intent.name}" says ${pairing.boundary} receives ${(0, import_behavioral_ir2.formatPath)(field.path)}${needsIt}; ${pairing.impl.identity.name} never reads it.`
99
327
  };
100
328
  }
101
329
  function undeclaredFindings(pairing, undeclared) {
102
330
  const said = /* @__PURE__ */ new Set();
103
331
  const findings = [];
104
332
  for (const path of undeclared) {
105
- const spelled = (0, import_behavioral_ir.formatPath)(path);
333
+ const spelled = (0, import_behavioral_ir2.formatPath)(path);
106
334
  if (said.has(spelled)) {
107
335
  continue;
108
336
  }
@@ -119,20 +347,117 @@ function undeclaredFindings(pairing, undeclared) {
119
347
  return findings;
120
348
  }
121
349
 
350
+ // src/valueSources.ts
351
+ var import_behavioral_ir3 = require("@suss/behavioral-ir");
352
+ function checkValueSources(claim, matched) {
353
+ const checked = { findings: [], unchecked: [] };
354
+ for (const source of claim.effect.from ?? []) {
355
+ const found = matched.flatMap(
356
+ (made) => slotsFor(claim.effect, source.column, made).flatMap(
357
+ (slot) => slot.from.map((written) => {
358
+ const ref = unconverted(written);
359
+ return { ref, path: pathOf(claim, ref), line: made.line };
360
+ })
361
+ )
362
+ );
363
+ if (found.some((one) => samePath(one.path, source.path))) {
364
+ continue;
365
+ }
366
+ const known = found.filter(
367
+ (one) => one.path !== null || one.ref.type === "literal"
368
+ );
369
+ if (known.length > 0) {
370
+ checked.findings.push(elsewhere(claim, source, known));
371
+ continue;
372
+ }
373
+ checked.unchecked.push(unread(claim, source, found));
374
+ }
375
+ return checked;
376
+ }
377
+ function slotsFor(effect, column, made) {
378
+ const kinds = /* @__PURE__ */ new Set([
379
+ ...effect.by.includes(column) ? ["selector"] : [],
380
+ ...effect.fields.includes(column) ? ["field"] : []
381
+ ]);
382
+ return (made.slots ?? []).filter(
383
+ (slot) => slot.at.name === column && kinds.has(slot.at.slot)
384
+ );
385
+ }
386
+ function unconverted(ref) {
387
+ return ref.type === "derived" && ref.derivation.type === "methodCall" ? unconverted(ref.from) : ref;
388
+ }
389
+ function pathOf(claim, ref) {
390
+ const binding = claim.intent.boundary;
391
+ return ref.type === "input" ? (0, import_behavioral_ir3.boundarySourcePathOf)(claim.impl, binding, ref) : null;
392
+ }
393
+ function samePath(code, declared) {
394
+ if (code === null || code.length !== declared.length) {
395
+ return false;
396
+ }
397
+ const left = comparable(code);
398
+ const right = comparable(declared);
399
+ return left.every((segment, at) => segment === right[at]);
400
+ }
401
+ function spell(one) {
402
+ if (one.path !== null) {
403
+ return `input.${(0, import_behavioral_ir3.formatPath)(one.path)}`;
404
+ }
405
+ return one.ref.type === "literal" ? `the literal ${JSON.stringify(one.ref.value)}` : "somewhere it could not follow";
406
+ }
407
+ function doing(claim) {
408
+ const { effect, outcome } = claim;
409
+ return `${outcome.id} ${effect.does} ${effect.names}`;
410
+ }
411
+ function elsewhere(claim, source, known) {
412
+ const said = [
413
+ ...new Set(
414
+ known.map(
415
+ (one) => one.line === void 0 ? spell(one) : `${spell(one)} (in the transition at line ${one.line})`
416
+ )
417
+ )
418
+ ];
419
+ return {
420
+ kind: "valueFromElsewhere",
421
+ severity: "error",
422
+ boundary: claim.boundary,
423
+ intent: { name: claim.intent.name, outcomeId: claim.outcome.id },
424
+ code: claim.code,
425
+ message: `Intent "${claim.intent.name}" says ${doing(claim)} with ${source.column} taken from input.${(0, import_behavioral_ir3.formatPath)(source.path)}; ${claim.impl.identity.name} takes it from ${said.join(" and ")}. Take the value from the source the intent gives, or correct the document.`
426
+ };
427
+ }
428
+ function unread(claim, source, found) {
429
+ const stopped = [
430
+ ...new Set(
431
+ found.flatMap(
432
+ (one) => one.ref.type === "unresolved" ? [one.ref.sourceText] : []
433
+ )
434
+ )
435
+ ];
436
+ const why = stopped.length === 0 ? `nothing in the summary says where ${source.column} comes from` : `the walk from ${source.column} stopped at ${stopped.map((text) => `\`${text}\``).join(" and ")}, which it cannot follow`;
437
+ return {
438
+ intent: claim.intent.name,
439
+ reason: "unreadValue",
440
+ outcomeId: claim.outcome.id,
441
+ detail: `${doing(claim)} with ${source.column} taken from input.${(0, import_behavioral_ir3.formatPath)(source.path)}: ${why}.`
442
+ };
443
+ }
444
+
122
445
  // src/index.ts
123
- function checkIntentAgreement(intents, code) {
446
+ function checkIntentAgreement(intents, code, tests) {
124
447
  const findings = [];
125
448
  const checked = [];
126
449
  const unchecked = [];
450
+ const claims = [];
127
451
  const codeByBoundary = indexCodeByBoundary(code);
128
452
  const boundaryByName = indexBoundaryIntentsByName(intents);
129
- const deploymentOfUnit = (0, import_behavioral_ir2.deploymentOf)(code);
130
- const wrappersOfUnit = (0, import_behavioral_ir2.wrappersAround)(code);
453
+ const deploymentOfUnit = (0, import_behavioral_ir4.deploymentOf)(code);
454
+ const wrappersOfUnit = (0, import_behavioral_ir4.wrappersAround)(code);
131
455
  for (const intent of intents) {
132
456
  if (intent.kind === "prd") {
133
457
  const result2 = checkPrdCoverage(intent, boundaryByName);
134
458
  findings.push(...withProvenance(result2.findings, intent.source));
135
459
  checked.push(result2.checked);
460
+ claims.push(...result2.claims);
136
461
  continue;
137
462
  }
138
463
  const result = checkBoundaryIntent(
@@ -145,9 +470,44 @@ function checkIntentAgreement(intents, code) {
145
470
  checked.push(...result.checked);
146
471
  unchecked.push(...result.unchecked);
147
472
  }
473
+ if (tests !== void 0) {
474
+ const scenarioTests = checkScenarioTests(claims, tests);
475
+ findings.push(...scenarioTests.findings);
476
+ unchecked.push(...scenarioTests.unchecked);
477
+ }
148
478
  findings.push(...checkOutcomesDescribed(intents));
149
479
  return { findings, checked, unchecked };
150
480
  }
481
+ function checkScenarioTests(claims, tests) {
482
+ const verdicts = checkCoveringTests(
483
+ claims.map((one) => one.claim),
484
+ tests
485
+ );
486
+ const failed = /* @__PURE__ */ new Set();
487
+ const findings = [];
488
+ const unchecked = [];
489
+ for (const [at, verdict] of verdicts.entries()) {
490
+ if (verdict.kind === "covered") {
491
+ continue;
492
+ }
493
+ const { claim, checked, scenario } = claims[at];
494
+ failed.add(`${checked.intent}\0${scenario}`);
495
+ if (verdict.kind === "finding") {
496
+ findings.push(...withProvenance([verdict.finding], claim.prd.source));
497
+ continue;
498
+ }
499
+ unchecked.push(verdict.unchecked);
500
+ }
501
+ const counted = /* @__PURE__ */ new Set();
502
+ for (const { checked, scenario } of claims) {
503
+ const key = `${checked.intent}\0${scenario}`;
504
+ if (!failed.has(key) && !counted.has(key)) {
505
+ counted.add(key);
506
+ checked.covered += 1;
507
+ }
508
+ }
509
+ return { findings, unchecked };
510
+ }
151
511
  function checkOutcomesDescribed(intents) {
152
512
  const prds = intents.filter((intent) => intent.kind === "prd");
153
513
  if (prds.length === 0) {
@@ -222,7 +582,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
222
582
  message: `Intent "${intent.name}" declares boundary ${label} with ${intent.outcomes.length} outcome(s); no code produces this boundary.`
223
583
  }
224
584
  ],
225
- // The comparison ran: finding no implementation IS the result.
585
+ // The comparison ran, and finding no implementation is its result.
226
586
  checked: [
227
587
  {
228
588
  kind: "boundary",
@@ -235,6 +595,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
235
595
  };
236
596
  }
237
597
  const findings = [];
598
+ const unchecked = [];
238
599
  for (const impl of impls) {
239
600
  findings.push(
240
601
  ...compareIntentToImpl(
@@ -242,7 +603,8 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
242
603
  impl,
243
604
  label,
244
605
  deploymentOfUnit(impl),
245
- wrappersOfUnit(impl)
606
+ wrappersOfUnit(impl),
607
+ unchecked
246
608
  )
247
609
  );
248
610
  }
@@ -256,58 +618,84 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
256
618
  implementations: impls.map(codeRef)
257
619
  }
258
620
  ],
259
- unchecked: []
621
+ unchecked
260
622
  };
261
623
  }
262
624
  function codeRef(impl) {
263
- return (0, import_behavioral_ir2.summaryRef)(impl);
625
+ return (0, import_behavioral_ir4.summaryRef)(impl);
264
626
  }
265
627
  function checkPrdCoverage(prd, boundaryByName) {
266
628
  const findings = [];
267
- let resolved = 0;
268
- let unlinked = 0;
629
+ const claims = [];
630
+ const checked = {
631
+ kind: "prd",
632
+ intent: prd.title,
633
+ scenarios: prd.scenarios.length,
634
+ resolved: 0,
635
+ covered: 0,
636
+ unlinked: 0
637
+ };
638
+ const fallbackSubjects = linkedBoundaryKeys(prd, boundaryByName);
269
639
  prd.scenarios.forEach((scenario, index) => {
270
640
  const label = scenarioLabel(scenario.title, index);
271
- if (scenario.link.length === 0) {
272
- unlinked += 1;
641
+ for (const spelled of scenario.coveredBy) {
642
+ claims.push({
643
+ claim: {
644
+ prd,
645
+ scenarioTitle: scenario.title,
646
+ label,
647
+ spelled,
648
+ subjects: scenario.about.length > 0 ? scenario.about : fallbackSubjects
649
+ },
650
+ checked,
651
+ scenario: index
652
+ });
653
+ }
654
+ if (scenario.link.length === 0 && scenario.coveredBy.length === 0) {
655
+ checked.unlinked += 1;
273
656
  findings.push({
274
657
  kind: "unlinkedScenario",
275
- severity: "info",
658
+ severity: "warning",
276
659
  boundary: prdBoundaryLabel(prd),
277
660
  intent: { name: prd.title },
278
661
  ...scenario.title !== null ? { scenario: { title: scenario.title } } : {},
279
- message: `Scenario ${label} in PRD "${prd.title}" has no structured link to a system-intent outcome; it reads on its own, but its coverage can't be checked until a link is added.`
662
+ message: `Scenario ${label} in PRD "${prd.title}" has neither a link to a boundary outcome nor a covering test, so nothing checks it; add a link, or list the test that covers it under coveredBy.`
280
663
  });
281
664
  return;
282
665
  }
666
+ if (scenario.link.length === 0) {
667
+ return;
668
+ }
283
669
  let allResolved = true;
284
670
  for (const ref of scenario.link) {
285
- const finding = resolveScenarioLink(
671
+ const finding2 = resolveScenarioLink(
286
672
  prd,
287
673
  scenario.title,
288
674
  label,
289
675
  ref,
290
676
  boundaryByName
291
677
  );
292
- if (finding !== null) {
293
- findings.push(finding);
678
+ if (finding2 !== null) {
679
+ findings.push(finding2);
294
680
  allResolved = false;
295
681
  }
296
682
  }
297
683
  if (allResolved) {
298
- resolved += 1;
684
+ checked.resolved += 1;
299
685
  }
300
686
  });
301
- return {
302
- findings,
303
- checked: {
304
- kind: "prd",
305
- intent: prd.title,
306
- scenarios: prd.scenarios.length,
307
- resolved,
308
- unlinked
687
+ return { findings, checked, claims };
688
+ }
689
+ function linkedBoundaryKeys(prd, boundaryByName) {
690
+ const keys = /* @__PURE__ */ new Set();
691
+ for (const ref of prd.scenarios.flatMap((scenario) => scenario.link)) {
692
+ const matches = boundaryByName.get(ref.split(".")[0]) ?? [];
693
+ const key = matches.length === 1 ? (0, import_ir_core.boundaryKey)(matches[0].boundary) : null;
694
+ if (key !== null) {
695
+ keys.add(key);
309
696
  }
310
- };
697
+ }
698
+ return [...keys];
311
699
  }
312
700
  function resolveScenarioLink(prd, scenarioTitle, label, ref, boundaryByName) {
313
701
  const dot = ref.indexOf(".");
@@ -344,8 +732,8 @@ function resolveScenarioLink(prd, scenarioTitle, label, ref, boundaryByName) {
344
732
  return {
345
733
  kind: "danglingScenarioLink",
346
734
  severity: "warning",
347
- // The intent resolved even though the outcome didn't, key on its
348
- // boundary so a narrow .sussignore rule can target this finding.
735
+ // The intent resolved, so the finding is keyed on its boundary,
736
+ // where a narrow .sussignore rule can match it.
349
737
  boundary: (0, import_ir_core.boundaryKey)(target.boundary) ?? prdBoundaryLabel(prd),
350
738
  intent: { name: prd.title },
351
739
  scenario: scenarioRef,
@@ -393,19 +781,22 @@ function applyIntentSuppressions(findings, rules, opts = {}) {
393
781
  return (0, import_ir_core.applySuppressionsToFindings)(
394
782
  findings,
395
783
  rules,
396
- (rule, finding) => {
784
+ (rule, finding2) => {
397
785
  if (rule.consumer !== void 0 || rule.provider !== void 0) {
398
786
  return false;
399
787
  }
400
- return rule.boundary === void 0 || (0, import_ir_core.ruleBoundaryMatchesKey)(rule.boundary, finding.boundary);
788
+ if (rule.scenario !== void 0 && rule.scenario !== finding2.scenario?.title) {
789
+ return false;
790
+ }
791
+ return rule.boundary === void 0 || (0, import_ir_core.ruleBoundaryMatchesKey)(rule.boundary, finding2.boundary);
401
792
  },
402
793
  opts
403
794
  );
404
795
  }
405
796
  function indexCodeByBoundary(code) {
406
797
  const byKey = /* @__PURE__ */ new Map();
407
- for (const summary of (0, import_behavioral_ir2.withDeclaredDelivery)(code)) {
408
- if (import_behavioral_ir2.BOUNDARY_ROLE[summary.kind] !== "provider") {
798
+ for (const summary of (0, import_behavioral_ir4.withDeclaredDelivery)(code)) {
799
+ if (import_behavioral_ir4.BOUNDARY_ROLE[summary.kind] !== "provider") {
409
800
  continue;
410
801
  }
411
802
  if (summary.confidence.source === "declared") {
@@ -428,11 +819,12 @@ function indexCodeByBoundary(code) {
428
819
  }
429
820
  return byKey;
430
821
  }
431
- function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
822
+ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers, unchecked) {
432
823
  const findings = [];
433
824
  const ref = codeRef(impl);
434
- const calls = (0, import_behavioral_ir2.boundaryCalls)(impl);
435
- const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls, deployment)).filter((o) => o !== null);
825
+ const calls = (0, import_behavioral_ir4.boundaryCalls)(impl);
826
+ const returnIsExitCode = (0, import_behavioral_ir4.readProcessMetadata)(impl)?.exitCodeFrom === "return";
827
+ const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls, deployment, returnIsExitCode)).filter((o) => o !== null);
436
828
  const everyEffect = impl.transitions.flatMap(
437
829
  (t) => codeEffectsOf(t, deployment)
438
830
  );
@@ -441,10 +833,27 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
441
833
  for (const outcome of intent.outcomes) {
442
834
  const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
443
835
  for (const effect of outcome.effects) {
444
- if (reached.some((made) => effectMatches(effect, made))) {
836
+ const matched = reached.filter((made) => effectMatches(effect, made));
837
+ if (matched.length > 0) {
838
+ if (!writesDeclaredShape(effect, matched)) {
839
+ findings.push({
840
+ kind: "outcomeShapeMismatch",
841
+ severity: "error",
842
+ boundary,
843
+ intent: { name: intent.name, outcomeId: outcome.id },
844
+ code: ref,
845
+ message: `Intent "${intent.name}" says ${outcome.id} results in ${describeEffect(effect)} at ${boundary} with a declared shape; what ${impl.identity.name} writes there has an incompatible shape.`
846
+ });
847
+ }
848
+ const sources = checkValueSources(
849
+ { intent, outcome, effect, impl, boundary, code: ref },
850
+ matched
851
+ );
852
+ findings.push(...sources.findings);
853
+ unchecked.push(...sources.unchecked);
445
854
  continue;
446
855
  }
447
- const finding = {
856
+ const finding2 = {
448
857
  kind: "uncoveredOutcome",
449
858
  severity: "error",
450
859
  boundary,
@@ -452,10 +861,10 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
452
861
  code: ref,
453
862
  message: `Intent "${intent.name}" declares that ${outcome.id} results in ${describeEffect(effect)} at ${boundary}; no transition of ${impl.identity.name} does that.${unsettledNote(reached, effect, deployment)}`
454
863
  };
455
- findings.push(finding);
864
+ findings.push(finding2);
456
865
  if (neverTouchesBoundary(effect.names, everyEffect)) {
457
866
  vanished.push({
458
- finding,
867
+ finding: finding2,
459
868
  boundary: effect.names,
460
869
  does: effect.does,
461
870
  kind: "effect",
@@ -479,9 +888,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
479
888
  });
480
889
  continue;
481
890
  }
482
- const stated = outcome.conditions.filter(
483
- (c) => c.at !== null
484
- );
891
+ const stated = boundaryClauses(outcome);
485
892
  const matches = stated.length === 0 ? ending : ending.filter(
486
893
  (co) => stated.every((c) => conditionMet(c, co.turnsOn))
487
894
  );
@@ -489,7 +896,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
489
896
  const unmet = stated.find(
490
897
  (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
491
898
  );
492
- const finding = {
899
+ const finding2 = {
493
900
  kind: "uncoveredOutcome",
494
901
  severity: "error",
495
902
  boundary,
@@ -504,10 +911,10 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
504
911
  everyEffect
505
912
  )
506
913
  };
507
- findings.push(finding);
914
+ findings.push(finding2);
508
915
  if (unmet !== void 0 && neverTouchesBoundary(unmet.at.names, everyEffect)) {
509
916
  vanished.push({
510
- finding,
917
+ finding: finding2,
511
918
  boundary: unmet.at.names,
512
919
  does: unmet.at.does,
513
920
  kind: "condition",
@@ -560,7 +967,20 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
560
967
  message: `${impl.identity.name} produces status ${status} at ${boundary}; intent "${intent.name}" does not declare it.`
561
968
  });
562
969
  }
563
- const declaredEffects = intent.outcomes.flatMap((o) => o.effects);
970
+ const always = checkAlwaysEffects(
971
+ intent,
972
+ impl,
973
+ boundary,
974
+ codeOutcomes,
975
+ everyEffect,
976
+ deployment
977
+ );
978
+ findings.push(...always.findings);
979
+ vanished.push(...always.vanished);
980
+ const declaredEffects = [
981
+ ...intent.outcomes.flatMap((o) => o.effects),
982
+ ...intent.always.map((a) => a.effect)
983
+ ];
564
984
  const said = /* @__PURE__ */ new Set();
565
985
  for (const made of everyEffect) {
566
986
  const spelled = `${made.does} ${made.label}`;
@@ -568,7 +988,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
568
988
  continue;
569
989
  }
570
990
  said.add(spelled);
571
- const finding = {
991
+ const finding2 = {
572
992
  kind: "undeclaredOutcome",
573
993
  severity: "info",
574
994
  boundary,
@@ -576,8 +996,8 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
576
996
  code: ref,
577
997
  message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
578
998
  };
579
- findings.push(finding);
580
- undeclared.push({ finding, boundary: made.label, does: made.does });
999
+ findings.push(finding2);
1000
+ undeclared.push({ finding: finding2, boundary: made.label, does: made.does });
581
1001
  }
582
1002
  findings.push(
583
1003
  ...checkReceivesBlock({ intent, impl, wrappers, boundary, code: ref })
@@ -643,7 +1063,7 @@ function foldRenamedBoundaries(intent, impl, boundary, ref, findings, vanished,
643
1063
  message: `Intent "${intent.name}" declares ${pairing.from}; ${impl.identity.name} ${joinVerbs(pairing.does)} ${pairing.to} instead, with the same outcomes. If the store was renamed, update the intent.`
644
1064
  });
645
1065
  }
646
- return [...findings.filter((finding) => !explained.has(finding)), ...renamed];
1066
+ return [...findings.filter((finding2) => !explained.has(finding2)), ...renamed];
647
1067
  }
648
1068
  function matchRenamedBoundaries(vanishedByBoundary, undeclaredByBoundary) {
649
1069
  const candidatesFor = /* @__PURE__ */ new Map();
@@ -747,6 +1167,16 @@ function unsettledNote(reached, declared, deployment) {
747
1167
  function effectMatches(declared, made) {
748
1168
  return declared.does === made.does && (0, import_ir_core.namesBoundary)(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
749
1169
  }
1170
+ function writesDeclaredShape(declared, matched) {
1171
+ if (declared.shape === void 0) {
1172
+ return true;
1173
+ }
1174
+ const shape = declared.shape;
1175
+ const stated = matched.flatMap(
1176
+ (made) => made.payload === void 0 ? [] : [made.payload]
1177
+ );
1178
+ return stated.length === 0 || stated.some((payload) => (0, import_ir_core.bodyShapesMatch)(payload, shape) !== "nomatch");
1179
+ }
750
1180
  function statesAll(declared, made) {
751
1181
  if (declared.length === 0 || made.length === 0 || made.includes(import_ir_core.EVERY_FIELD)) {
752
1182
  return true;
@@ -758,22 +1188,81 @@ function conditionMet(declared, turnsOn) {
758
1188
  (guard) => guard.does === declared.at.does && (0, import_ir_core.namesBoundary)(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
759
1189
  );
760
1190
  }
761
- function toCodeOutcome(t, calls, deployment) {
1191
+ function toCodeOutcome(t, calls, deployment, returnIsExitCode) {
1192
+ const ending = endingOf(t, returnIsExitCode);
1193
+ if (ending === null) {
1194
+ return null;
1195
+ }
1196
+ return {
1197
+ ...ending,
1198
+ effects: codeEffectsOf(t, deployment),
1199
+ turnsOn: (0, import_behavioral_ir4.boundaryGuardsOf)(t, calls).map((guard) => ({
1200
+ ...guard,
1201
+ binding: (0, import_behavioral_ir4.groundBinding)(guard.binding, deployment)
1202
+ })),
1203
+ line: t.location.start
1204
+ };
1205
+ }
1206
+ function boundaryClauses(outcome) {
1207
+ return outcome.conditions.filter((c) => c.at !== null);
1208
+ }
1209
+ function producesOutcome(outcome, co) {
1210
+ if (outcome.kind === "effect") {
1211
+ return true;
1212
+ }
1213
+ return outcomeMatches(outcome, co) && boundaryClauses(outcome).every((c) => conditionMet(c, co.turnsOn));
1214
+ }
1215
+ function checkAlwaysEffects(intent, impl, boundary, codeOutcomes, everyEffect, deployment) {
1216
+ const findings = [];
1217
+ const vanished = [];
1218
+ if (intent.always.length === 0) {
1219
+ return { findings, vanished };
1220
+ }
1221
+ for (const co of codeOutcomes) {
1222
+ const produced = intent.outcomes.filter((o) => producesOutcome(o, co));
1223
+ if (produced.length === 0) {
1224
+ continue;
1225
+ }
1226
+ for (const always of intent.always) {
1227
+ if (produced.some((o) => always.except.includes(o.id))) {
1228
+ continue;
1229
+ }
1230
+ if (co.effects.some((made) => effectMatches(always.effect, made))) {
1231
+ continue;
1232
+ }
1233
+ const outcome = produced.find((o) => o.kind !== "effect") ?? produced[0];
1234
+ const finding2 = {
1235
+ kind: "pathWithoutEffect",
1236
+ severity: "error",
1237
+ boundary,
1238
+ intent: { name: intent.name, outcomeId: outcome.id },
1239
+ code: codeRef(impl),
1240
+ message: `Intent "${intent.name}" says every outcome results in ${describeEffect(always.effect)} at ${boundary}; the transition of ${impl.identity.name} at line ${co.line}, which produces ${describeProduced(outcome)}, does not. Add the effect on that path, or list ${outcome.id} under except.${unsettledNote(co.effects, always.effect, deployment)}`
1241
+ };
1242
+ findings.push(finding2);
1243
+ if (neverTouchesBoundary(always.effect.names, everyEffect)) {
1244
+ vanished.push({
1245
+ finding: finding2,
1246
+ boundary: always.effect.names,
1247
+ does: always.effect.does,
1248
+ kind: "effect",
1249
+ effect: always.effect,
1250
+ reached: co.effects
1251
+ });
1252
+ }
1253
+ }
1254
+ }
1255
+ return { findings, vanished };
1256
+ }
1257
+ function endingOf(t, returnIsExitCode = false) {
762
1258
  const output = t.output;
763
- const effects = codeEffectsOf(t, deployment);
764
- const turnsOn = (0, import_behavioral_ir2.boundaryGuardsOf)(t, calls).map((guard) => ({
765
- ...guard,
766
- binding: (0, import_behavioral_ir2.groundBinding)(guard.binding, deployment)
767
- }));
768
1259
  if (output.type === "response") {
769
- const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
770
1260
  return {
771
1261
  kind: "response",
772
- status: status !== null && Number.isFinite(status) ? status : null,
1262
+ status: literalNumber(output.statusCode),
773
1263
  body: output.body ?? null,
774
1264
  errorType: null,
775
- effects,
776
- turnsOn
1265
+ exitCode: null
777
1266
  };
778
1267
  }
779
1268
  if (output.type === "return") {
@@ -782,8 +1271,7 @@ function toCodeOutcome(t, calls, deployment) {
782
1271
  status: null,
783
1272
  body: output.value,
784
1273
  errorType: null,
785
- effects,
786
- turnsOn
1274
+ exitCode: returnIsExitCode ? returnedNumber(output.value) : null
787
1275
  };
788
1276
  }
789
1277
  if (output.type === "throw") {
@@ -792,31 +1280,63 @@ function toCodeOutcome(t, calls, deployment) {
792
1280
  status: null,
793
1281
  body: null,
794
1282
  errorType: output.exceptionType,
795
- effects,
796
- turnsOn
1283
+ exitCode: null
1284
+ };
1285
+ }
1286
+ if (output.type === "exit") {
1287
+ return {
1288
+ kind: "exit",
1289
+ status: literalNumber(output.code),
1290
+ body: null,
1291
+ errorType: null,
1292
+ exitCode: null
797
1293
  };
798
1294
  }
799
1295
  return null;
800
1296
  }
1297
+ function literalNumber(ref) {
1298
+ if (ref === null || ref.type !== "literal") {
1299
+ return null;
1300
+ }
1301
+ const value = Number(ref.value);
1302
+ return Number.isFinite(value) ? value : null;
1303
+ }
1304
+ function returnedNumber(shape) {
1305
+ return shape !== null && shape.type === "literal" && typeof shape.value === "number" ? shape.value : null;
1306
+ }
801
1307
  function codeEffectsOf(t, deployment) {
802
1308
  const reached = [];
803
- for (const effect of t.effects) {
1309
+ for (const [at, effect] of t.effects.entries()) {
804
1310
  if (effect.type !== "interaction") {
805
1311
  continue;
806
1312
  }
807
- if ((0, import_behavioral_ir2.goesThroughRelation)(effect.interaction)) {
1313
+ if ((0, import_behavioral_ir4.goesThroughRelation)(effect.interaction)) {
808
1314
  continue;
809
1315
  }
810
- const binding = (0, import_behavioral_ir2.groundBinding)(effect.binding, deployment);
1316
+ const binding = (0, import_behavioral_ir4.groundBinding)(effect.binding, deployment);
811
1317
  const label = (0, import_ir_core.displayLabel)(binding);
812
1318
  const touched = accessDetail(effect.interaction);
813
- for (const does of (0, import_behavioral_ir2.relationsOf)(effect.interaction)) {
814
- reached.push({ does, binding, label, ...touched });
1319
+ const slots = (t.provenance ?? []).filter((one) => one.at.effect === at);
1320
+ for (const does of (0, import_behavioral_ir4.relationsOf)(effect.interaction)) {
1321
+ reached.push({
1322
+ does,
1323
+ binding,
1324
+ label,
1325
+ ...touched,
1326
+ ...slots.length === 0 ? {} : { slots },
1327
+ line: t.location.start
1328
+ });
815
1329
  }
816
1330
  }
817
1331
  return reached;
818
1332
  }
819
1333
  function accessDetail(interaction) {
1334
+ if (interaction.class === "config-read") {
1335
+ return { fields: [interaction.name], by: [] };
1336
+ }
1337
+ if (interaction.class === "stream-write") {
1338
+ return interaction.payload === null ? { fields: [], by: [] } : { fields: [], by: [], payload: interaction.payload };
1339
+ }
820
1340
  if (interaction.class !== "storage-access") {
821
1341
  return { fields: [], by: [] };
822
1342
  }
@@ -826,6 +1346,9 @@ function accessDetail(interaction) {
826
1346
  };
827
1347
  }
828
1348
  function outcomeMatches(intent, code) {
1349
+ if (intent.kind === "exit") {
1350
+ return exitsWith(intent.status, code);
1351
+ }
829
1352
  if (intent.kind !== code.kind) {
830
1353
  return false;
831
1354
  }
@@ -837,19 +1360,29 @@ function outcomeMatches(intent, code) {
837
1360
  }
838
1361
  return true;
839
1362
  }
1363
+ function exitsWith(code, ending) {
1364
+ if (code === null) {
1365
+ return false;
1366
+ }
1367
+ if (ending.kind === "exit") {
1368
+ return ending.status === code;
1369
+ }
1370
+ return ending.kind === "return" && ending.exitCode === code;
1371
+ }
840
1372
  function whatWouldKeyIt(protocol) {
841
1373
  return WHAT_KEYS[protocol];
842
1374
  }
843
1375
  var WHAT_KEYS = {
844
1376
  rest: "a REST boundary needs a method and a path",
845
- "function-call": "a function-call boundary needs package + exportPath",
1377
+ "function-call": "a function-call boundary needs package + exportPath, or module + exportName where the module is one suss.json lists",
846
1378
  "message-bus": "a message-bus boundary needs a channel",
847
1379
  storage: "a store has no key at all: write it as `- writes: <store>` on an outcome of the boundary that touches it instead",
848
1380
  "graphql-resolver": "a resolver needs a type name and a field name",
849
1381
  "graphql-operation": "an operation pairs by document rather than by key",
850
1382
  "runtime-config": "a runtime-config boundary needs a deployment target and an instance name",
851
1383
  metric: "a metric needs a system and a type",
852
- "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by"
1384
+ "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by",
1385
+ io: "a stream is never a boundary of its own: write it as `- writes: io:stdout` on an outcome of the unit that prints"
853
1386
  };
854
1387
  function describeOutcome(outcome) {
855
1388
  if (outcome.kind === "response") {
@@ -858,8 +1391,17 @@ function describeOutcome(outcome) {
858
1391
  if (outcome.kind === "throw") {
859
1392
  return outcome.errorType !== null ? `throw ${outcome.errorType}` : "a thrown error";
860
1393
  }
1394
+ if (outcome.kind === "exit") {
1395
+ return `exit code ${outcome.status}`;
1396
+ }
861
1397
  return "a return value";
862
1398
  }
1399
+ function describeProduced(outcome) {
1400
+ if (outcome.kind === "effect") {
1401
+ return outcome.id;
1402
+ }
1403
+ return `${outcome.id} (${describeOutcome(outcome)})`;
1404
+ }
863
1405
  function describeEffect(effect) {
864
1406
  const detail = [
865
1407
  effect.fields.length > 0 ? `of ${effect.fields.join(", ")}` : null,
@@ -874,7 +1416,12 @@ var EFFECT_PHRASE = {
874
1416
  };
875
1417
  // Annotate the CommonJS export names for ESM import in node:
876
1418
  0 && (module.exports = {
1419
+ accessDetail,
877
1420
  applyIntentSuppressions,
878
1421
  checkIntentAgreement,
1422
+ codeEffectsOf,
1423
+ effectMatches,
1424
+ endingOf,
1425
+ outcomeMatches,
879
1426
  whatWouldKeyIt
880
1427
  });