@suss/checker-intent 0.33.1 → 0.34.1

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.js CHANGED
@@ -6,6 +6,7 @@ import {
6
6
  deploymentOf,
7
7
  goesThroughRelation,
8
8
  groundBinding,
9
+ readProcessMetadata,
9
10
  relationsOf,
10
11
  summaryRef,
11
12
  withDeclaredDelivery,
@@ -24,6 +25,228 @@ import {
24
25
  semanticsAgree
25
26
  } from "@suss/ir-core";
26
27
 
28
+ // src/coveringTests.ts
29
+ import { readTestMetadata } from "@suss/behavioral-ir";
30
+ import { callSpellings, functionOf } from "@suss/checker";
31
+ function checkCoveringTests(claims, lookup) {
32
+ const verdicts = [];
33
+ const waiting = [];
34
+ for (const [at, claim] of claims.entries()) {
35
+ const found = lookup.test(claim.spelled);
36
+ if (!found.found) {
37
+ verdicts.push(finding(claim, "missingCoveringTest", found.message));
38
+ continue;
39
+ }
40
+ if (readTestMetadata(found.unit)?.skipped === true) {
41
+ verdicts.push(
42
+ finding(
43
+ claim,
44
+ "coveringTestSkipped",
45
+ "which is marked to be skipped, so it does not run"
46
+ )
47
+ );
48
+ continue;
49
+ }
50
+ const subject = subjectOf(claim, lookup);
51
+ if ("message" in subject) {
52
+ verdicts.push(finding(claim, "testMissesSubject", subject.message));
53
+ continue;
54
+ }
55
+ verdicts.push(null);
56
+ waiting.push({
57
+ at,
58
+ unit: found.unit,
59
+ test: functionOf(found.unit),
60
+ subject
61
+ });
62
+ }
63
+ if (waiting.length === 0) {
64
+ return verdicts.map((verdict) => verdict ?? { kind: "covered" });
65
+ }
66
+ const honouring = lookup.facts.reachedFromEach(
67
+ waiting.map((one) => one.test),
68
+ { pastMocks: true }
69
+ );
70
+ const missed = waiting.filter(
71
+ (one) => pathInto(one.test, honouring.get(one.test), one.subject) === null && callOnSubjectClass(one.test, one.subject, lookup, true) === null
72
+ );
73
+ const ignoring = missed.length === 0 ? /* @__PURE__ */ new Map() : lookup.facts.reachedFromEach(missed.map((one) => one.test));
74
+ for (const one of waiting) {
75
+ const claim = claims[one.at];
76
+ if (!missed.includes(one)) {
77
+ verdicts[one.at] = { kind: "covered" };
78
+ continue;
79
+ }
80
+ const reached = ignoring.get(one.test);
81
+ const throughMock = pathInto(one.test, reached, one.subject) ?? callOnSubjectClass(one.test, one.subject, lookup, false);
82
+ if (throughMock !== null) {
83
+ verdicts[one.at] = finding(
84
+ claim,
85
+ "testMissesSubject",
86
+ `which reaches ${one.subject.label} only through a call its mocks replace (${mocksOf(lookup, one.test).join(", ")}), by ${callSpellings(throughMock).join(" -> ")}`
87
+ );
88
+ continue;
89
+ }
90
+ const unfollowed = unfollowedOnTheWay(one.unit, one.test, reached, lookup);
91
+ const nameMatches = unfollowedToSubject(one.unit, one.subject, lookup);
92
+ verdicts[one.at] = unfollowed.length === 0 && nameMatches.length === 0 ? finding(
93
+ claim,
94
+ "testMissesSubject",
95
+ `which never reaches ${one.subject.label}`
96
+ ) : notChecked(claim, one.subject, nameMatches, unfollowed);
97
+ }
98
+ return verdicts.map((verdict) => verdict ?? { kind: "covered" });
99
+ }
100
+ var CALLS_LISTED = 3;
101
+ function notChecked(claim, subject, nameMatches, unfollowed) {
102
+ const calls = nameMatches.length > 0 ? nameMatches : unfollowed;
103
+ const listed = calls.slice(0, CALLS_LISTED).join(", ");
104
+ const more = calls.length > CALLS_LISTED ? `, and ${calls.length - CALLS_LISTED} more` : "";
105
+ 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}`;
106
+ return {
107
+ kind: "unchecked",
108
+ unchecked: {
109
+ intent: claim.prd.title,
110
+ reason: "unfollowedCall",
111
+ scenario: claim.label,
112
+ coveredBy: claim.spelled.spelledAs,
113
+ detail: `Scenario ${claim.label} lists the test "${claim.spelled.spelledAs}": ${why}.`
114
+ }
115
+ };
116
+ }
117
+ function unfollowedOnTheWay(test, key, reached, lookup) {
118
+ const units = [
119
+ test,
120
+ ...[...reached?.functions.keys() ?? []].filter((fn) => fn !== key).flatMap((fn) => lookup.facts.units.get(fn) ?? [])
121
+ ];
122
+ const found = /* @__PURE__ */ new Set();
123
+ for (const unit of units) {
124
+ for (const gap of unit.gaps) {
125
+ if (gap.type === "unfollowedCall" && gap.callee !== void 0) {
126
+ found.add(gap.callee);
127
+ }
128
+ }
129
+ }
130
+ return [...found];
131
+ }
132
+ function subjectOf(claim, lookup) {
133
+ if (claim.subjects.length === 0) {
134
+ return {
135
+ message: "and the PRD links to no boundary a test could be checked against; say what it has to reach under about"
136
+ };
137
+ }
138
+ const found = [];
139
+ for (const spelledAs of claim.subjects) {
140
+ const one = lookup.subject(spelledAs);
141
+ if (!one.found) {
142
+ return {
143
+ message: `and what it has to reach, ${spelledAs}, is nothing in these summaries: ${one.message.replace(/\.$/, "")}`
144
+ };
145
+ }
146
+ found.push({ ...one, classes: one.classes ?? [] });
147
+ }
148
+ return {
149
+ target: {
150
+ functions: found.flatMap((one) => one.target.functions),
151
+ keys: found.flatMap((one) => one.target.keys),
152
+ at: found.flatMap((one) => one.target.at ?? [])
153
+ },
154
+ label: found.map((one) => one.label).join(" or "),
155
+ classes: found.flatMap((one) => one.classes)
156
+ };
157
+ }
158
+ function pathInto(test, reached, subject) {
159
+ const into = [...subject.target.functions, ...subject.target.at ?? []];
160
+ if (into.includes(test)) {
161
+ return [];
162
+ }
163
+ const paths = [
164
+ ...into.map((fn) => reached?.functions.get(fn)),
165
+ ...subject.target.keys.map((key) => reached?.keys.get(key))
166
+ ].filter((path) => path !== void 0);
167
+ if (paths.length === 0) {
168
+ return null;
169
+ }
170
+ return paths.reduce(
171
+ (shortest, path) => path.length < shortest.length ? path : shortest
172
+ );
173
+ }
174
+ function callOnSubjectClass(test, subject, lookup, honourMocks) {
175
+ const classes = new Set(subject.classes.map(classKey));
176
+ if (classes.size === 0) {
177
+ return null;
178
+ }
179
+ const units = lookup.facts.units.get(test) ?? [];
180
+ const mocks = honourMocks ? units.flatMap((unit) => readTestMetadata(unit)?.mocks ?? []) : [];
181
+ for (const effect of units.flatMap(
182
+ (unit) => unit.transitions.flatMap((transition) => transition.effects)
183
+ )) {
184
+ if (effect.type !== "invocation" || effect.receiverClass === void 0 || !classes.has(classKey(effect.receiverClass))) {
185
+ continue;
186
+ }
187
+ const method = lastName(effect.callee);
188
+ if (mocks.some((mock) => replaces(mock, effect.receiverClass, method))) {
189
+ continue;
190
+ }
191
+ return [{ callee: effect.callee, to: null, recorded: "written" }];
192
+ }
193
+ return null;
194
+ }
195
+ function classKey(sentTo) {
196
+ return `${sentTo.file}#${sentTo.name}`;
197
+ }
198
+ function replaces(mock, sentTo, method) {
199
+ if (mock.module === void 0) {
200
+ return mock.name === method;
201
+ }
202
+ return mock.module === sentTo?.file && (mock.name === void 0 || mock.name === method);
203
+ }
204
+ function unfollowedToSubject(test, subject, lookup) {
205
+ const names = /* @__PURE__ */ new Set([
206
+ ...[...subject.target.functions, ...subject.target.at ?? []].flatMap(
207
+ (fn) => (lookup.facts.units.get(fn) ?? []).map(
208
+ (unit) => lastName(unit.identity.name)
209
+ )
210
+ ),
211
+ ...subject.target.keys.map(lastName)
212
+ ]);
213
+ const unfollowed = test.transitions.flatMap(
214
+ (transition) => transition.effects.flatMap(
215
+ (effect) => effect.type === "invocation" && effect.summary === void 0 && names.has(lastName(effect.callee)) ? [effect.callee] : []
216
+ )
217
+ );
218
+ return [...new Set(unfollowed)];
219
+ }
220
+ function lastName(spelled) {
221
+ return spelled.split(/::|\./).at(-1) ?? spelled;
222
+ }
223
+ function mocksOf(lookup, test) {
224
+ const units = lookup.facts.units.get(test) ?? [];
225
+ return [
226
+ ...new Set(
227
+ units.flatMap(
228
+ (unit) => (readTestMetadata(unit)?.mocks ?? []).map((mock) => mock.written)
229
+ )
230
+ )
231
+ ];
232
+ }
233
+ function finding(claim, kind, why) {
234
+ return {
235
+ kind: "finding",
236
+ finding: {
237
+ kind,
238
+ severity: "warning",
239
+ boundary: `prd:${claim.prd.title}`,
240
+ intent: { name: claim.prd.title },
241
+ scenario: {
242
+ ...claim.scenarioTitle !== null ? { title: claim.scenarioTitle } : {},
243
+ coveredBy: claim.spelled.spelledAs
244
+ },
245
+ message: `Scenario ${claim.label} in PRD "${claim.prd.title}" lists the test "${claim.spelled.spelledAs}", ${why}.`
246
+ }
247
+ };
248
+ }
249
+
27
250
  // src/receivedInput.ts
28
251
  import { boundaryInputReads, formatPath } from "@suss/behavioral-ir";
29
252
  function overlaps(a, b) {
@@ -59,7 +282,7 @@ function checkReceivesBlock(pairing) {
59
282
  return [];
60
283
  }
61
284
  const read = result.reads.paths;
62
- const unread = declared.filter(
285
+ const unread2 = declared.filter(
63
286
  (field) => !read.some((path) => overlaps(path, field.path))
64
287
  );
65
288
  const alreadyDescribed = DESCRIBED_WITHOUT_LISTING[pairing.intent.boundary.semantics.name];
@@ -67,7 +290,7 @@ function checkReceivesBlock(pairing) {
67
290
  (path) => !declared.some((field) => overlaps(path, field.path)) && !alreadyDescribed(declared, path)
68
291
  );
69
292
  return [
70
- ...unread.map((field) => unreadFinding(pairing, field)),
293
+ ...unread2.map((field) => unreadFinding(pairing, field)),
71
294
  ...undeclaredFindings(pairing, undeclared)
72
295
  ];
73
296
  }
@@ -81,7 +304,8 @@ var DESCRIBED_WITHOUT_LISTING = {
81
304
  "graphql-resolver": LISTS_EVERYTHING,
82
305
  "graphql-operation": LISTS_EVERYTHING,
83
306
  "runtime-config": LISTS_EVERYTHING,
84
- metric: LISTS_EVERYTHING
307
+ metric: LISTS_EVERYTHING,
308
+ io: LISTS_EVERYTHING
85
309
  };
86
310
  function unreadFinding(pairing, field) {
87
311
  const needsIt = field.required ? " and needs it" : "";
@@ -115,11 +339,107 @@ function undeclaredFindings(pairing, undeclared) {
115
339
  return findings;
116
340
  }
117
341
 
342
+ // src/valueSources.ts
343
+ import { boundarySourcePathOf, formatPath as formatPath2 } from "@suss/behavioral-ir";
344
+ function checkValueSources(claim, matched) {
345
+ const checked = { findings: [], unchecked: [] };
346
+ for (const source of claim.effect.from ?? []) {
347
+ const found = matched.flatMap(
348
+ (made) => slotsFor(claim.effect, source.column, made).flatMap(
349
+ (slot) => slot.from.map((written) => {
350
+ const ref = unconverted(written);
351
+ return { ref, path: pathOf(claim, ref), line: made.line };
352
+ })
353
+ )
354
+ );
355
+ if (found.some((one) => samePath(one.path, source.path))) {
356
+ continue;
357
+ }
358
+ const known = found.filter(
359
+ (one) => one.path !== null || one.ref.type === "literal"
360
+ );
361
+ if (known.length > 0) {
362
+ checked.findings.push(elsewhere(claim, source, known));
363
+ continue;
364
+ }
365
+ checked.unchecked.push(unread(claim, source, found));
366
+ }
367
+ return checked;
368
+ }
369
+ function slotsFor(effect, column, made) {
370
+ const kinds = /* @__PURE__ */ new Set([
371
+ ...effect.by.includes(column) ? ["selector"] : [],
372
+ ...effect.fields.includes(column) ? ["field"] : []
373
+ ]);
374
+ return (made.slots ?? []).filter(
375
+ (slot) => slot.at.name === column && kinds.has(slot.at.slot)
376
+ );
377
+ }
378
+ function unconverted(ref) {
379
+ return ref.type === "derived" && ref.derivation.type === "methodCall" ? unconverted(ref.from) : ref;
380
+ }
381
+ function pathOf(claim, ref) {
382
+ const binding = claim.intent.boundary;
383
+ return ref.type === "input" ? boundarySourcePathOf(claim.impl, binding, ref) : null;
384
+ }
385
+ function samePath(code, declared) {
386
+ if (code === null || code.length !== declared.length) {
387
+ return false;
388
+ }
389
+ const left = comparable(code);
390
+ const right = comparable(declared);
391
+ return left.every((segment, at) => segment === right[at]);
392
+ }
393
+ function spell(one) {
394
+ if (one.path !== null) {
395
+ return `input.${formatPath2(one.path)}`;
396
+ }
397
+ return one.ref.type === "literal" ? `the literal ${JSON.stringify(one.ref.value)}` : "somewhere it could not follow";
398
+ }
399
+ function doing(claim) {
400
+ const { effect, outcome } = claim;
401
+ return `${outcome.id} ${effect.does} ${effect.names}`;
402
+ }
403
+ function elsewhere(claim, source, known) {
404
+ const said = [
405
+ ...new Set(
406
+ known.map(
407
+ (one) => one.line === void 0 ? spell(one) : `${spell(one)} (in the transition at line ${one.line})`
408
+ )
409
+ )
410
+ ];
411
+ return {
412
+ kind: "valueFromElsewhere",
413
+ severity: "error",
414
+ boundary: claim.boundary,
415
+ intent: { name: claim.intent.name, outcomeId: claim.outcome.id },
416
+ code: claim.code,
417
+ message: `Intent "${claim.intent.name}" says ${doing(claim)} with ${source.column} taken from input.${formatPath2(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.`
418
+ };
419
+ }
420
+ function unread(claim, source, found) {
421
+ const stopped = [
422
+ ...new Set(
423
+ found.flatMap(
424
+ (one) => one.ref.type === "unresolved" ? [one.ref.sourceText] : []
425
+ )
426
+ )
427
+ ];
428
+ 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`;
429
+ return {
430
+ intent: claim.intent.name,
431
+ reason: "unreadValue",
432
+ outcomeId: claim.outcome.id,
433
+ detail: `${doing(claim)} with ${source.column} taken from input.${formatPath2(source.path)}: ${why}.`
434
+ };
435
+ }
436
+
118
437
  // src/index.ts
119
- function checkIntentAgreement(intents, code) {
438
+ function checkIntentAgreement(intents, code, tests) {
120
439
  const findings = [];
121
440
  const checked = [];
122
441
  const unchecked = [];
442
+ const claims = [];
123
443
  const codeByBoundary = indexCodeByBoundary(code);
124
444
  const boundaryByName = indexBoundaryIntentsByName(intents);
125
445
  const deploymentOfUnit = deploymentOf(code);
@@ -129,6 +449,7 @@ function checkIntentAgreement(intents, code) {
129
449
  const result2 = checkPrdCoverage(intent, boundaryByName);
130
450
  findings.push(...withProvenance(result2.findings, intent.source));
131
451
  checked.push(result2.checked);
452
+ claims.push(...result2.claims);
132
453
  continue;
133
454
  }
134
455
  const result = checkBoundaryIntent(
@@ -141,9 +462,44 @@ function checkIntentAgreement(intents, code) {
141
462
  checked.push(...result.checked);
142
463
  unchecked.push(...result.unchecked);
143
464
  }
465
+ if (tests !== void 0) {
466
+ const scenarioTests = checkScenarioTests(claims, tests);
467
+ findings.push(...scenarioTests.findings);
468
+ unchecked.push(...scenarioTests.unchecked);
469
+ }
144
470
  findings.push(...checkOutcomesDescribed(intents));
145
471
  return { findings, checked, unchecked };
146
472
  }
473
+ function checkScenarioTests(claims, tests) {
474
+ const verdicts = checkCoveringTests(
475
+ claims.map((one) => one.claim),
476
+ tests
477
+ );
478
+ const failed = /* @__PURE__ */ new Set();
479
+ const findings = [];
480
+ const unchecked = [];
481
+ for (const [at, verdict] of verdicts.entries()) {
482
+ if (verdict.kind === "covered") {
483
+ continue;
484
+ }
485
+ const { claim, checked, scenario } = claims[at];
486
+ failed.add(`${checked.intent}\0${scenario}`);
487
+ if (verdict.kind === "finding") {
488
+ findings.push(...withProvenance([verdict.finding], claim.prd.source));
489
+ continue;
490
+ }
491
+ unchecked.push(verdict.unchecked);
492
+ }
493
+ const counted = /* @__PURE__ */ new Set();
494
+ for (const { checked, scenario } of claims) {
495
+ const key = `${checked.intent}\0${scenario}`;
496
+ if (!failed.has(key) && !counted.has(key)) {
497
+ counted.add(key);
498
+ checked.covered += 1;
499
+ }
500
+ }
501
+ return { findings, unchecked };
502
+ }
147
503
  function checkOutcomesDescribed(intents) {
148
504
  const prds = intents.filter((intent) => intent.kind === "prd");
149
505
  if (prds.length === 0) {
@@ -218,7 +574,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
218
574
  message: `Intent "${intent.name}" declares boundary ${label} with ${intent.outcomes.length} outcome(s); no code produces this boundary.`
219
575
  }
220
576
  ],
221
- // The comparison ran: finding no implementation IS the result.
577
+ // The comparison ran, and finding no implementation is its result.
222
578
  checked: [
223
579
  {
224
580
  kind: "boundary",
@@ -231,6 +587,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
231
587
  };
232
588
  }
233
589
  const findings = [];
590
+ const unchecked = [];
234
591
  for (const impl of impls) {
235
592
  findings.push(
236
593
  ...compareIntentToImpl(
@@ -238,7 +595,8 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
238
595
  impl,
239
596
  label,
240
597
  deploymentOfUnit(impl),
241
- wrappersOfUnit(impl)
598
+ wrappersOfUnit(impl),
599
+ unchecked
242
600
  )
243
601
  );
244
602
  }
@@ -252,7 +610,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
252
610
  implementations: impls.map(codeRef)
253
611
  }
254
612
  ],
255
- unchecked: []
613
+ unchecked
256
614
  };
257
615
  }
258
616
  function codeRef(impl) {
@@ -260,50 +618,76 @@ function codeRef(impl) {
260
618
  }
261
619
  function checkPrdCoverage(prd, boundaryByName) {
262
620
  const findings = [];
263
- let resolved = 0;
264
- let unlinked = 0;
621
+ const claims = [];
622
+ const checked = {
623
+ kind: "prd",
624
+ intent: prd.title,
625
+ scenarios: prd.scenarios.length,
626
+ resolved: 0,
627
+ covered: 0,
628
+ unlinked: 0
629
+ };
630
+ const fallbackSubjects = linkedBoundaryKeys(prd, boundaryByName);
265
631
  prd.scenarios.forEach((scenario, index) => {
266
632
  const label = scenarioLabel(scenario.title, index);
267
- if (scenario.link.length === 0) {
268
- unlinked += 1;
633
+ for (const spelled of scenario.coveredBy) {
634
+ claims.push({
635
+ claim: {
636
+ prd,
637
+ scenarioTitle: scenario.title,
638
+ label,
639
+ spelled,
640
+ subjects: scenario.about.length > 0 ? scenario.about : fallbackSubjects
641
+ },
642
+ checked,
643
+ scenario: index
644
+ });
645
+ }
646
+ if (scenario.link.length === 0 && scenario.coveredBy.length === 0) {
647
+ checked.unlinked += 1;
269
648
  findings.push({
270
649
  kind: "unlinkedScenario",
271
- severity: "info",
650
+ severity: "warning",
272
651
  boundary: prdBoundaryLabel(prd),
273
652
  intent: { name: prd.title },
274
653
  ...scenario.title !== null ? { scenario: { title: scenario.title } } : {},
275
- 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.`
654
+ 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.`
276
655
  });
277
656
  return;
278
657
  }
658
+ if (scenario.link.length === 0) {
659
+ return;
660
+ }
279
661
  let allResolved = true;
280
662
  for (const ref of scenario.link) {
281
- const finding = resolveScenarioLink(
663
+ const finding2 = resolveScenarioLink(
282
664
  prd,
283
665
  scenario.title,
284
666
  label,
285
667
  ref,
286
668
  boundaryByName
287
669
  );
288
- if (finding !== null) {
289
- findings.push(finding);
670
+ if (finding2 !== null) {
671
+ findings.push(finding2);
290
672
  allResolved = false;
291
673
  }
292
674
  }
293
675
  if (allResolved) {
294
- resolved += 1;
676
+ checked.resolved += 1;
295
677
  }
296
678
  });
297
- return {
298
- findings,
299
- checked: {
300
- kind: "prd",
301
- intent: prd.title,
302
- scenarios: prd.scenarios.length,
303
- resolved,
304
- unlinked
679
+ return { findings, checked, claims };
680
+ }
681
+ function linkedBoundaryKeys(prd, boundaryByName) {
682
+ const keys = /* @__PURE__ */ new Set();
683
+ for (const ref of prd.scenarios.flatMap((scenario) => scenario.link)) {
684
+ const matches = boundaryByName.get(ref.split(".")[0]) ?? [];
685
+ const key = matches.length === 1 ? boundaryKey(matches[0].boundary) : null;
686
+ if (key !== null) {
687
+ keys.add(key);
305
688
  }
306
- };
689
+ }
690
+ return [...keys];
307
691
  }
308
692
  function resolveScenarioLink(prd, scenarioTitle, label, ref, boundaryByName) {
309
693
  const dot = ref.indexOf(".");
@@ -340,8 +724,8 @@ function resolveScenarioLink(prd, scenarioTitle, label, ref, boundaryByName) {
340
724
  return {
341
725
  kind: "danglingScenarioLink",
342
726
  severity: "warning",
343
- // The intent resolved even though the outcome didn't, key on its
344
- // boundary so a narrow .sussignore rule can target this finding.
727
+ // The intent resolved, so the finding is keyed on its boundary,
728
+ // where a narrow .sussignore rule can match it.
345
729
  boundary: boundaryKey(target.boundary) ?? prdBoundaryLabel(prd),
346
730
  intent: { name: prd.title },
347
731
  scenario: scenarioRef,
@@ -389,11 +773,14 @@ function applyIntentSuppressions(findings, rules, opts = {}) {
389
773
  return applySuppressionsToFindings(
390
774
  findings,
391
775
  rules,
392
- (rule, finding) => {
776
+ (rule, finding2) => {
393
777
  if (rule.consumer !== void 0 || rule.provider !== void 0) {
394
778
  return false;
395
779
  }
396
- return rule.boundary === void 0 || ruleBoundaryMatchesKey(rule.boundary, finding.boundary);
780
+ if (rule.scenario !== void 0 && rule.scenario !== finding2.scenario?.title) {
781
+ return false;
782
+ }
783
+ return rule.boundary === void 0 || ruleBoundaryMatchesKey(rule.boundary, finding2.boundary);
397
784
  },
398
785
  opts
399
786
  );
@@ -424,11 +811,12 @@ function indexCodeByBoundary(code) {
424
811
  }
425
812
  return byKey;
426
813
  }
427
- function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
814
+ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers, unchecked) {
428
815
  const findings = [];
429
816
  const ref = codeRef(impl);
430
817
  const calls = boundaryCalls(impl);
431
- const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls, deployment)).filter((o) => o !== null);
818
+ const returnIsExitCode = readProcessMetadata(impl)?.exitCodeFrom === "return";
819
+ const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls, deployment, returnIsExitCode)).filter((o) => o !== null);
432
820
  const everyEffect = impl.transitions.flatMap(
433
821
  (t) => codeEffectsOf(t, deployment)
434
822
  );
@@ -437,10 +825,27 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
437
825
  for (const outcome of intent.outcomes) {
438
826
  const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
439
827
  for (const effect of outcome.effects) {
440
- if (reached.some((made) => effectMatches(effect, made))) {
828
+ const matched = reached.filter((made) => effectMatches(effect, made));
829
+ if (matched.length > 0) {
830
+ if (!writesDeclaredShape(effect, matched)) {
831
+ findings.push({
832
+ kind: "outcomeShapeMismatch",
833
+ severity: "error",
834
+ boundary,
835
+ intent: { name: intent.name, outcomeId: outcome.id },
836
+ code: ref,
837
+ 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.`
838
+ });
839
+ }
840
+ const sources = checkValueSources(
841
+ { intent, outcome, effect, impl, boundary, code: ref },
842
+ matched
843
+ );
844
+ findings.push(...sources.findings);
845
+ unchecked.push(...sources.unchecked);
441
846
  continue;
442
847
  }
443
- const finding = {
848
+ const finding2 = {
444
849
  kind: "uncoveredOutcome",
445
850
  severity: "error",
446
851
  boundary,
@@ -448,10 +853,10 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
448
853
  code: ref,
449
854
  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)}`
450
855
  };
451
- findings.push(finding);
856
+ findings.push(finding2);
452
857
  if (neverTouchesBoundary(effect.names, everyEffect)) {
453
858
  vanished.push({
454
- finding,
859
+ finding: finding2,
455
860
  boundary: effect.names,
456
861
  does: effect.does,
457
862
  kind: "effect",
@@ -475,9 +880,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
475
880
  });
476
881
  continue;
477
882
  }
478
- const stated = outcome.conditions.filter(
479
- (c) => c.at !== null
480
- );
883
+ const stated = boundaryClauses(outcome);
481
884
  const matches = stated.length === 0 ? ending : ending.filter(
482
885
  (co) => stated.every((c) => conditionMet(c, co.turnsOn))
483
886
  );
@@ -485,7 +888,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
485
888
  const unmet = stated.find(
486
889
  (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
487
890
  );
488
- const finding = {
891
+ const finding2 = {
489
892
  kind: "uncoveredOutcome",
490
893
  severity: "error",
491
894
  boundary,
@@ -500,10 +903,10 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
500
903
  everyEffect
501
904
  )
502
905
  };
503
- findings.push(finding);
906
+ findings.push(finding2);
504
907
  if (unmet !== void 0 && neverTouchesBoundary(unmet.at.names, everyEffect)) {
505
908
  vanished.push({
506
- finding,
909
+ finding: finding2,
507
910
  boundary: unmet.at.names,
508
911
  does: unmet.at.does,
509
912
  kind: "condition",
@@ -556,7 +959,20 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
556
959
  message: `${impl.identity.name} produces status ${status} at ${boundary}; intent "${intent.name}" does not declare it.`
557
960
  });
558
961
  }
559
- const declaredEffects = intent.outcomes.flatMap((o) => o.effects);
962
+ const always = checkAlwaysEffects(
963
+ intent,
964
+ impl,
965
+ boundary,
966
+ codeOutcomes,
967
+ everyEffect,
968
+ deployment
969
+ );
970
+ findings.push(...always.findings);
971
+ vanished.push(...always.vanished);
972
+ const declaredEffects = [
973
+ ...intent.outcomes.flatMap((o) => o.effects),
974
+ ...intent.always.map((a) => a.effect)
975
+ ];
560
976
  const said = /* @__PURE__ */ new Set();
561
977
  for (const made of everyEffect) {
562
978
  const spelled = `${made.does} ${made.label}`;
@@ -564,7 +980,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
564
980
  continue;
565
981
  }
566
982
  said.add(spelled);
567
- const finding = {
983
+ const finding2 = {
568
984
  kind: "undeclaredOutcome",
569
985
  severity: "info",
570
986
  boundary,
@@ -572,8 +988,8 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
572
988
  code: ref,
573
989
  message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
574
990
  };
575
- findings.push(finding);
576
- undeclared.push({ finding, boundary: made.label, does: made.does });
991
+ findings.push(finding2);
992
+ undeclared.push({ finding: finding2, boundary: made.label, does: made.does });
577
993
  }
578
994
  findings.push(
579
995
  ...checkReceivesBlock({ intent, impl, wrappers, boundary, code: ref })
@@ -639,7 +1055,7 @@ function foldRenamedBoundaries(intent, impl, boundary, ref, findings, vanished,
639
1055
  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.`
640
1056
  });
641
1057
  }
642
- return [...findings.filter((finding) => !explained.has(finding)), ...renamed];
1058
+ return [...findings.filter((finding2) => !explained.has(finding2)), ...renamed];
643
1059
  }
644
1060
  function matchRenamedBoundaries(vanishedByBoundary, undeclaredByBoundary) {
645
1061
  const candidatesFor = /* @__PURE__ */ new Map();
@@ -743,6 +1159,16 @@ function unsettledNote(reached, declared, deployment) {
743
1159
  function effectMatches(declared, made) {
744
1160
  return declared.does === made.does && namesBoundary(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
745
1161
  }
1162
+ function writesDeclaredShape(declared, matched) {
1163
+ if (declared.shape === void 0) {
1164
+ return true;
1165
+ }
1166
+ const shape = declared.shape;
1167
+ const stated = matched.flatMap(
1168
+ (made) => made.payload === void 0 ? [] : [made.payload]
1169
+ );
1170
+ return stated.length === 0 || stated.some((payload) => bodyShapesMatch(payload, shape) !== "nomatch");
1171
+ }
746
1172
  function statesAll(declared, made) {
747
1173
  if (declared.length === 0 || made.length === 0 || made.includes(EVERY_FIELD)) {
748
1174
  return true;
@@ -754,22 +1180,81 @@ function conditionMet(declared, turnsOn) {
754
1180
  (guard) => guard.does === declared.at.does && namesBoundary(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
755
1181
  );
756
1182
  }
757
- function toCodeOutcome(t, calls, deployment) {
1183
+ function toCodeOutcome(t, calls, deployment, returnIsExitCode) {
1184
+ const ending = endingOf(t, returnIsExitCode);
1185
+ if (ending === null) {
1186
+ return null;
1187
+ }
1188
+ return {
1189
+ ...ending,
1190
+ effects: codeEffectsOf(t, deployment),
1191
+ turnsOn: boundaryGuardsOf(t, calls).map((guard) => ({
1192
+ ...guard,
1193
+ binding: groundBinding(guard.binding, deployment)
1194
+ })),
1195
+ line: t.location.start
1196
+ };
1197
+ }
1198
+ function boundaryClauses(outcome) {
1199
+ return outcome.conditions.filter((c) => c.at !== null);
1200
+ }
1201
+ function producesOutcome(outcome, co) {
1202
+ if (outcome.kind === "effect") {
1203
+ return true;
1204
+ }
1205
+ return outcomeMatches(outcome, co) && boundaryClauses(outcome).every((c) => conditionMet(c, co.turnsOn));
1206
+ }
1207
+ function checkAlwaysEffects(intent, impl, boundary, codeOutcomes, everyEffect, deployment) {
1208
+ const findings = [];
1209
+ const vanished = [];
1210
+ if (intent.always.length === 0) {
1211
+ return { findings, vanished };
1212
+ }
1213
+ for (const co of codeOutcomes) {
1214
+ const produced = intent.outcomes.filter((o) => producesOutcome(o, co));
1215
+ if (produced.length === 0) {
1216
+ continue;
1217
+ }
1218
+ for (const always of intent.always) {
1219
+ if (produced.some((o) => always.except.includes(o.id))) {
1220
+ continue;
1221
+ }
1222
+ if (co.effects.some((made) => effectMatches(always.effect, made))) {
1223
+ continue;
1224
+ }
1225
+ const outcome = produced.find((o) => o.kind !== "effect") ?? produced[0];
1226
+ const finding2 = {
1227
+ kind: "pathWithoutEffect",
1228
+ severity: "error",
1229
+ boundary,
1230
+ intent: { name: intent.name, outcomeId: outcome.id },
1231
+ code: codeRef(impl),
1232
+ 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)}`
1233
+ };
1234
+ findings.push(finding2);
1235
+ if (neverTouchesBoundary(always.effect.names, everyEffect)) {
1236
+ vanished.push({
1237
+ finding: finding2,
1238
+ boundary: always.effect.names,
1239
+ does: always.effect.does,
1240
+ kind: "effect",
1241
+ effect: always.effect,
1242
+ reached: co.effects
1243
+ });
1244
+ }
1245
+ }
1246
+ }
1247
+ return { findings, vanished };
1248
+ }
1249
+ function endingOf(t, returnIsExitCode = false) {
758
1250
  const output = t.output;
759
- const effects = codeEffectsOf(t, deployment);
760
- const turnsOn = boundaryGuardsOf(t, calls).map((guard) => ({
761
- ...guard,
762
- binding: groundBinding(guard.binding, deployment)
763
- }));
764
1251
  if (output.type === "response") {
765
- const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
766
1252
  return {
767
1253
  kind: "response",
768
- status: status !== null && Number.isFinite(status) ? status : null,
1254
+ status: literalNumber(output.statusCode),
769
1255
  body: output.body ?? null,
770
1256
  errorType: null,
771
- effects,
772
- turnsOn
1257
+ exitCode: null
773
1258
  };
774
1259
  }
775
1260
  if (output.type === "return") {
@@ -778,8 +1263,7 @@ function toCodeOutcome(t, calls, deployment) {
778
1263
  status: null,
779
1264
  body: output.value,
780
1265
  errorType: null,
781
- effects,
782
- turnsOn
1266
+ exitCode: returnIsExitCode ? returnedNumber(output.value) : null
783
1267
  };
784
1268
  }
785
1269
  if (output.type === "throw") {
@@ -788,15 +1272,33 @@ function toCodeOutcome(t, calls, deployment) {
788
1272
  status: null,
789
1273
  body: null,
790
1274
  errorType: output.exceptionType,
791
- effects,
792
- turnsOn
1275
+ exitCode: null
1276
+ };
1277
+ }
1278
+ if (output.type === "exit") {
1279
+ return {
1280
+ kind: "exit",
1281
+ status: literalNumber(output.code),
1282
+ body: null,
1283
+ errorType: null,
1284
+ exitCode: null
793
1285
  };
794
1286
  }
795
1287
  return null;
796
1288
  }
1289
+ function literalNumber(ref) {
1290
+ if (ref === null || ref.type !== "literal") {
1291
+ return null;
1292
+ }
1293
+ const value = Number(ref.value);
1294
+ return Number.isFinite(value) ? value : null;
1295
+ }
1296
+ function returnedNumber(shape) {
1297
+ return shape !== null && shape.type === "literal" && typeof shape.value === "number" ? shape.value : null;
1298
+ }
797
1299
  function codeEffectsOf(t, deployment) {
798
1300
  const reached = [];
799
- for (const effect of t.effects) {
1301
+ for (const [at, effect] of t.effects.entries()) {
800
1302
  if (effect.type !== "interaction") {
801
1303
  continue;
802
1304
  }
@@ -806,13 +1308,27 @@ function codeEffectsOf(t, deployment) {
806
1308
  const binding = groundBinding(effect.binding, deployment);
807
1309
  const label = displayLabel(binding);
808
1310
  const touched = accessDetail(effect.interaction);
1311
+ const slots = (t.provenance ?? []).filter((one) => one.at.effect === at);
809
1312
  for (const does of relationsOf(effect.interaction)) {
810
- reached.push({ does, binding, label, ...touched });
1313
+ reached.push({
1314
+ does,
1315
+ binding,
1316
+ label,
1317
+ ...touched,
1318
+ ...slots.length === 0 ? {} : { slots },
1319
+ line: t.location.start
1320
+ });
811
1321
  }
812
1322
  }
813
1323
  return reached;
814
1324
  }
815
1325
  function accessDetail(interaction) {
1326
+ if (interaction.class === "config-read") {
1327
+ return { fields: [interaction.name], by: [] };
1328
+ }
1329
+ if (interaction.class === "stream-write") {
1330
+ return interaction.payload === null ? { fields: [], by: [] } : { fields: [], by: [], payload: interaction.payload };
1331
+ }
816
1332
  if (interaction.class !== "storage-access") {
817
1333
  return { fields: [], by: [] };
818
1334
  }
@@ -822,6 +1338,9 @@ function accessDetail(interaction) {
822
1338
  };
823
1339
  }
824
1340
  function outcomeMatches(intent, code) {
1341
+ if (intent.kind === "exit") {
1342
+ return exitsWith(intent.status, code);
1343
+ }
825
1344
  if (intent.kind !== code.kind) {
826
1345
  return false;
827
1346
  }
@@ -833,19 +1352,29 @@ function outcomeMatches(intent, code) {
833
1352
  }
834
1353
  return true;
835
1354
  }
1355
+ function exitsWith(code, ending) {
1356
+ if (code === null) {
1357
+ return false;
1358
+ }
1359
+ if (ending.kind === "exit") {
1360
+ return ending.status === code;
1361
+ }
1362
+ return ending.kind === "return" && ending.exitCode === code;
1363
+ }
836
1364
  function whatWouldKeyIt(protocol) {
837
1365
  return WHAT_KEYS[protocol];
838
1366
  }
839
1367
  var WHAT_KEYS = {
840
1368
  rest: "a REST boundary needs a method and a path",
841
- "function-call": "a function-call boundary needs package + exportPath",
1369
+ "function-call": "a function-call boundary needs package + exportPath, or module + exportName where the module is one suss.json lists",
842
1370
  "message-bus": "a message-bus boundary needs a channel",
843
1371
  storage: "a store has no key at all: write it as `- writes: <store>` on an outcome of the boundary that touches it instead",
844
1372
  "graphql-resolver": "a resolver needs a type name and a field name",
845
1373
  "graphql-operation": "an operation pairs by document rather than by key",
846
1374
  "runtime-config": "a runtime-config boundary needs a deployment target and an instance name",
847
1375
  metric: "a metric needs a system and a type",
848
- "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by"
1376
+ "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by",
1377
+ io: "a stream is never a boundary of its own: write it as `- writes: io:stdout` on an outcome of the unit that prints"
849
1378
  };
850
1379
  function describeOutcome(outcome) {
851
1380
  if (outcome.kind === "response") {
@@ -854,8 +1383,17 @@ function describeOutcome(outcome) {
854
1383
  if (outcome.kind === "throw") {
855
1384
  return outcome.errorType !== null ? `throw ${outcome.errorType}` : "a thrown error";
856
1385
  }
1386
+ if (outcome.kind === "exit") {
1387
+ return `exit code ${outcome.status}`;
1388
+ }
857
1389
  return "a return value";
858
1390
  }
1391
+ function describeProduced(outcome) {
1392
+ if (outcome.kind === "effect") {
1393
+ return outcome.id;
1394
+ }
1395
+ return `${outcome.id} (${describeOutcome(outcome)})`;
1396
+ }
859
1397
  function describeEffect(effect) {
860
1398
  const detail = [
861
1399
  effect.fields.length > 0 ? `of ${effect.fields.join(", ")}` : null,
@@ -869,7 +1407,12 @@ var EFFECT_PHRASE = {
869
1407
  invokes: "an invoke of"
870
1408
  };
871
1409
  export {
1410
+ accessDetail,
872
1411
  applyIntentSuppressions,
873
1412
  checkIntentAgreement,
1413
+ codeEffectsOf,
1414
+ effectMatches,
1415
+ endingOf,
1416
+ outcomeMatches,
874
1417
  whatWouldKeyIt
875
1418
  };