@suss/checker-intent 0.20.0 → 0.21.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/README.md CHANGED
@@ -22,13 +22,63 @@ For each boundary intent, it pairs against the code summaries that share
22
22
  the same boundary key, and emits:
23
23
 
24
24
  - `unimplementedBoundary`: the intent declares a boundary no code produces.
25
- - `uncoveredOutcome`: a declared outcome (response / return / throw) the code never produces.
25
+ - `uncoveredOutcome`: a declared outcome (response / return / throw), or a declared effect, that the code never produces.
26
26
  - `outcomeShapeMismatch`: a matched outcome whose body shape disagrees with intent.
27
- - `undeclaredOutcome`: code produces a REST status the intent doesn't declare (info; intent under-specifies).
27
+ - `undeclaredOutcome`: code produces a REST status, or reaches a boundary, that the intent doesn't declare (info; intent under-specifies).
28
+ - `unkeyableBoundary`: the intent's boundary has no key to pair on, so nothing was compared (warning).
29
+ - `undescribedOutcome`: a declared outcome no PRD scenario links to (info).
28
30
 
29
31
  v0 checks system intent (`kind: boundary`). PRD outcome intent
30
32
  (`kind: prd`), which covers scenarios and links, is a separate pass.
31
33
 
34
+ ## How a declared effect is compared
35
+
36
+ An outcome can declare the effects it has, written the way `suss ask` asks about one:
37
+
38
+ ```yaml
39
+ results:
40
+ - writes: aws.dynamodb:Invoices
41
+ - invokes: unit:lambda ArchiveWorker
42
+ ```
43
+
44
+ Both directions are checked, and both resolve the boundary with `namesBoundary` in `@suss/ir-core`, the same matcher that settles what somebody types at `suss ask`. So `writes: Invoices` and `writes: aws.dynamodb:Invoices` both pick out the table, exactly as the two spellings do at the command line, and there is no second parser here to drift from that one.
45
+
46
+ For each declared effect, the pass walks what the matching code transitions reached, each a verb from `relationsOf` and the binding it reached, and reports `uncoveredOutcome` when nothing there matches what the intent said. An outcome that declares an ending narrows to the transitions that end that way; an outcome that declares only effects is checked against every transition of the unit.
47
+
48
+ For the other direction, the pass walks every boundary the unit's transitions reach and reports `undeclaredOutcome` for each one no outcome declares. An intent listing three writes on a unit doing four has one nobody wrote down. It is info rather than an error for the same reason an undeclared status is: an intent doc declares the floor rather than a closed list, so code beyond it usually means somebody has not written that part down yet.
49
+
50
+ An access whose container the code cannot settle (a wrapper handed its table as an argument) spells a name with a hole in it and matches nothing here, because grounding that name is the storage pass's job and this pass loads no contracts. An invoke that reads its callee out of an env var is in the same position: the code spells it `unit:lambda {ARCHIVE_WORKER_FUNCTION}`, collapsing that chain is the unit-invocation pass's job, and a document has to spell the callee the way the code does for the two to line up.
51
+
52
+ ## How a declared condition is compared
53
+
54
+ A `when` clause that says which boundary the branch read is compared too, which is what makes `when` more than prose.
55
+
56
+ `boundaryGuardsOf` in `@suss/behavioral-ir` says, for each branch of the code, which boundary its guards turned on and whether the guard passed because something was there. A declared outcome then narrows to the branches whose guards match every boundary clause the intent stated, and `uncoveredOutcome` reports one that produces the ending on a different condition. So an intent saying "404 when a read of `aws.dynamodb:Invoices` finds nothing" fails when the code's 404 turns on the row being present.
57
+
58
+ The boundary resolves through `namesBoundary` again, and `finds` has to agree when the clause states it. A clause about an input, and a clause left as a sentence, are prose to this pass and are not compared: the paths and the words have no counterpart in the summary to check against.
59
+
60
+ ## The coverage question, both ways
61
+
62
+ The three scenario kinds ask whether a scenario points at an outcome that exists. `undescribedOutcome` asks it the other way: which declared behaviour has nobody written down a reason for. That is the question a product reader has, and the two artifacts to answer it from are the same two.
63
+
64
+ It stays quiet until at least one PRD is loaded, since before that the answer is every outcome. `suss infer prd` writes a scenario per outcome, so a fresh set of drafts starts with none of these and they appear as the boundary documents grow outcomes past what the PRDs cover.
65
+
66
+ ## Which summaries count as the code
67
+
68
+ Two rules narrow the summaries an intent doc is compared against, and both are about not arguing with a document over something that was never its job.
69
+
70
+ A consumer at the same key is a caller. A client calling `GET /users/{id}` shares the key with the route and provides nothing, so comparing outcomes against its returns would report every declared outcome as uncovered.
71
+
72
+ A summary a manifest produced is a declaration. A CloudFormation queue resource says the channel exists and stops there, so it has no transition that could satisfy an outcome, and the code that does is the handler in the same run. Summaries with `confidence.source: "declared"` are left out for that reason. A boundary with nothing else behind it still reports `unimplementedBoundary`, which is the state it is in: declared and not written yet.
73
+
74
+ The handler behind a queue needs the declaration for its identity even so, because a SAM template decides which queue delivers to it and its own summary has no channel. `withDeclaredDelivery` in `@suss/behavioral-ir` puts the two together by the deployable unit they both give, and both this pass and `suss infer intent` call it before they index anything by boundary key. It fills in only what a summary left null, and only from a declaration, so a channel the code stated for itself stays as it is.
75
+
76
+ ## Which boundaries can be paired
77
+
78
+ `whatWouldKeyIt` says, per protocol, what an intent doc needs before it can be paired, and the message on an `unkeyableBoundary` finding and the reason `suss infer intent` reports for a boundary it skipped both come from it.
79
+
80
+ A store is the case worth knowing: it has no identity key at all, by design in `@suss/ir-core`, so a `kind: boundary` doc for one is authorable and always unkeyable. Naming the store as the target of an effect on the boundary that touches it is what pairs today.
81
+
32
82
  ## Where it fits in suss
33
83
 
34
84
  This package depends on `@suss/intent-ir` (the intent shapes and
package/dist/index.cjs CHANGED
@@ -21,7 +21,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
21
21
  var index_exports = {};
22
22
  __export(index_exports, {
23
23
  applyIntentSuppressions: () => applyIntentSuppressions,
24
- checkIntentAgreement: () => checkIntentAgreement
24
+ checkIntentAgreement: () => checkIntentAgreement,
25
+ whatWouldKeyIt: () => whatWouldKeyIt
25
26
  });
26
27
  module.exports = __toCommonJS(index_exports);
27
28
  var import_behavioral_ir = require("@suss/behavioral-ir");
@@ -44,8 +45,44 @@ function checkIntentAgreement(intents, code) {
44
45
  checked.push(...result.checked);
45
46
  unchecked.push(...result.unchecked);
46
47
  }
48
+ findings.push(...checkOutcomesDescribed(intents));
47
49
  return { findings, checked, unchecked };
48
50
  }
51
+ function checkOutcomesDescribed(intents) {
52
+ const prds = intents.filter((intent) => intent.kind === "prd");
53
+ if (prds.length === 0) {
54
+ return [];
55
+ }
56
+ const linked = new Set(
57
+ prds.flatMap((prd) => prd.scenarios.flatMap((scenario) => scenario.link))
58
+ );
59
+ const findings = [];
60
+ for (const intent of intents) {
61
+ if (intent.kind !== "boundary") {
62
+ continue;
63
+ }
64
+ for (const outcome of intent.outcomes) {
65
+ if (linked.has(`${intent.name}.${outcome.id}`)) {
66
+ continue;
67
+ }
68
+ findings.push(
69
+ ...withProvenance(
70
+ [
71
+ {
72
+ kind: "undescribedOutcome",
73
+ severity: "info",
74
+ boundary: (0, import_ir_core.boundaryKey)(intent.boundary) ?? intent.name,
75
+ intent: { name: intent.name, outcomeId: outcome.id },
76
+ message: `Intent "${intent.name}" declares ${outcome.id} and no PRD scenario says why it is there.`
77
+ }
78
+ ],
79
+ intent.source
80
+ )
81
+ );
82
+ }
83
+ }
84
+ return findings;
85
+ }
49
86
  function checkBoundaryIntent(intent, codeByBoundary) {
50
87
  const key = (0, import_ir_core.pairingKey)(intent.boundary);
51
88
  if (key === null) {
@@ -54,9 +91,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
54
91
  {
55
92
  kind: "unkeyableBoundary",
56
93
  severity: "warning",
57
- boundary: unkeyedBoundaryLabel(intent.boundary),
94
+ boundary: (0, import_ir_core.displayLabel)(intent.boundary),
58
95
  intent: { name: intent.name },
59
- message: `Intent "${intent.name}" has a ${intent.boundary.semantics.name} boundary that can't be keyed for pairing (function-call boundaries need package + exportPath); it was not checked against code.`
96
+ message: `Intent "${intent.name}" has a ${intent.boundary.semantics.name} boundary that can't be keyed for pairing (${whatWouldKeyIt(intent.boundary.semantics.name)}); it was not checked against code.`
60
97
  }
61
98
  ],
62
99
  checked: [],
@@ -259,10 +296,13 @@ function applyIntentSuppressions(findings, rules, opts = {}) {
259
296
  }
260
297
  function indexCodeByBoundary(code) {
261
298
  const byKey = /* @__PURE__ */ new Map();
262
- for (const summary of code) {
299
+ for (const summary of (0, import_behavioral_ir.withDeclaredDelivery)(code)) {
263
300
  if (import_behavioral_ir.BOUNDARY_ROLE[summary.kind] !== "provider") {
264
301
  continue;
265
302
  }
303
+ if (summary.confidence.source === "declared") {
304
+ continue;
305
+ }
266
306
  const binding = summary.identity.boundaryBinding;
267
307
  if (binding === null) {
268
308
  continue;
@@ -283,10 +323,29 @@ function indexCodeByBoundary(code) {
283
323
  function compareIntentToImpl(intent, impl, boundary) {
284
324
  const findings = [];
285
325
  const ref = codeRef(impl);
286
- const codeOutcomes = impl.transitions.map(toCodeOutcome).filter((o) => o !== null);
326
+ const calls = (0, import_behavioral_ir.boundaryCalls)(impl);
327
+ const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls)).filter((o) => o !== null);
328
+ const everyEffect = impl.transitions.flatMap(codeEffectsOf);
287
329
  for (const outcome of intent.outcomes) {
288
- const matches = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
289
- if (matches.length === 0) {
330
+ const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
331
+ for (const effect of outcome.effects) {
332
+ if (reached.some((made) => effectMatches(effect, made))) {
333
+ continue;
334
+ }
335
+ findings.push({
336
+ kind: "uncoveredOutcome",
337
+ severity: "error",
338
+ boundary,
339
+ intent: { name: intent.name, outcomeId: outcome.id },
340
+ code: ref,
341
+ message: `Intent "${intent.name}" declares that ${outcome.id} results in ${describeEffect(effect)} at ${boundary}; no transition of ${impl.identity.name} does that.`
342
+ });
343
+ }
344
+ if (outcome.kind === "effect") {
345
+ continue;
346
+ }
347
+ const ending = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
348
+ if (ending.length === 0) {
290
349
  findings.push({
291
350
  kind: "uncoveredOutcome",
292
351
  severity: "error",
@@ -297,6 +356,26 @@ function compareIntentToImpl(intent, impl, boundary) {
297
356
  });
298
357
  continue;
299
358
  }
359
+ const stated = outcome.conditions.filter(
360
+ (c) => c.at !== null
361
+ );
362
+ const matches = stated.length === 0 ? ending : ending.filter(
363
+ (co) => stated.every((c) => conditionMet(c, co.turnsOn))
364
+ );
365
+ if (matches.length === 0) {
366
+ const unmet = stated.find(
367
+ (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
368
+ );
369
+ findings.push({
370
+ kind: "uncoveredOutcome",
371
+ severity: "error",
372
+ boundary,
373
+ intent: { name: intent.name, outcomeId: outcome.id },
374
+ code: ref,
375
+ message: `Intent "${intent.name}" declares ${describeOutcome(outcome)} at ${boundary} when ${unmet?.said ?? outcome.when}; ${impl.identity.name} produces it on a different condition.`
376
+ });
377
+ continue;
378
+ }
300
379
  const declaredBody = outcome.body;
301
380
  if (declaredBody === null) {
302
381
  continue;
@@ -340,17 +419,52 @@ function compareIntentToImpl(intent, impl, boundary) {
340
419
  message: `${impl.identity.name} produces status ${status} at ${boundary}; intent "${intent.name}" does not declare it.`
341
420
  });
342
421
  }
422
+ const declaredEffects = intent.outcomes.flatMap((o) => o.effects);
423
+ const said = /* @__PURE__ */ new Set();
424
+ for (const made of everyEffect) {
425
+ const spelled = `${made.does} ${made.label}`;
426
+ if (said.has(spelled) || declaredEffects.some((effect) => effectMatches(effect, made))) {
427
+ continue;
428
+ }
429
+ said.add(spelled);
430
+ findings.push({
431
+ kind: "undeclaredOutcome",
432
+ severity: "info",
433
+ boundary,
434
+ intent: { name: intent.name },
435
+ code: ref,
436
+ message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
437
+ });
438
+ }
343
439
  return findings;
344
440
  }
345
- function toCodeOutcome(t) {
441
+ function effectMatches(declared, made) {
442
+ return declared.does === made.does && (0, import_ir_core.namesBoundary)(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
443
+ }
444
+ function statesAll(declared, made) {
445
+ if (declared.length === 0 || made.length === 0 || made.includes(import_ir_core.EVERY_FIELD)) {
446
+ return true;
447
+ }
448
+ return declared.every((one) => made.includes(one));
449
+ }
450
+ function conditionMet(declared, turnsOn) {
451
+ return turnsOn.some(
452
+ (guard) => guard.does === declared.at.does && (0, import_ir_core.namesBoundary)(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
453
+ );
454
+ }
455
+ function toCodeOutcome(t, calls) {
346
456
  const output = t.output;
457
+ const effects = codeEffectsOf(t);
458
+ const turnsOn = (0, import_behavioral_ir.boundaryGuardsOf)(t, calls);
347
459
  if (output.type === "response") {
348
460
  const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
349
461
  return {
350
462
  kind: "response",
351
463
  status: status !== null && Number.isFinite(status) ? status : null,
352
464
  body: output.body ?? null,
353
- errorType: null
465
+ errorType: null,
466
+ effects,
467
+ turnsOn
354
468
  };
355
469
  }
356
470
  if (output.type === "return") {
@@ -358,7 +472,9 @@ function toCodeOutcome(t) {
358
472
  kind: "return",
359
473
  status: null,
360
474
  body: output.value,
361
- errorType: null
475
+ errorType: null,
476
+ effects,
477
+ turnsOn
362
478
  };
363
479
  }
364
480
  if (output.type === "throw") {
@@ -366,11 +482,39 @@ function toCodeOutcome(t) {
366
482
  kind: "throw",
367
483
  status: null,
368
484
  body: null,
369
- errorType: output.exceptionType
485
+ errorType: output.exceptionType,
486
+ effects,
487
+ turnsOn
370
488
  };
371
489
  }
372
490
  return null;
373
491
  }
492
+ function codeEffectsOf(t) {
493
+ const reached = [];
494
+ for (const effect of t.effects) {
495
+ if (effect.type !== "interaction") {
496
+ continue;
497
+ }
498
+ if ((0, import_behavioral_ir.goesThroughRelation)(effect.interaction)) {
499
+ continue;
500
+ }
501
+ const label = (0, import_ir_core.displayLabel)(effect.binding);
502
+ const touched = accessDetail(effect.interaction);
503
+ for (const does of (0, import_behavioral_ir.relationsOf)(effect.interaction)) {
504
+ reached.push({ does, binding: effect.binding, label, ...touched });
505
+ }
506
+ }
507
+ return reached;
508
+ }
509
+ function accessDetail(interaction) {
510
+ if (interaction.class !== "storage-access") {
511
+ return { fields: [], by: [] };
512
+ }
513
+ return {
514
+ fields: interaction.fields,
515
+ by: interaction.selector ?? []
516
+ };
517
+ }
374
518
  function outcomeMatches(intent, code) {
375
519
  if (intent.kind !== code.kind) {
376
520
  return false;
@@ -383,14 +527,20 @@ function outcomeMatches(intent, code) {
383
527
  }
384
528
  return true;
385
529
  }
386
- function unkeyedBoundaryLabel(binding) {
387
- const semantics = binding.semantics;
388
- if (semantics.name === "function-call") {
389
- const target = semantics.module ?? semantics.package ?? "?";
390
- return `fn:${target}::${semantics.exportName ?? "?"}`;
391
- }
392
- return semantics.name;
530
+ function whatWouldKeyIt(protocol) {
531
+ return WHAT_KEYS[protocol];
393
532
  }
533
+ var WHAT_KEYS = {
534
+ rest: "a REST boundary needs a method and a path",
535
+ "function-call": "a function-call boundary needs package + exportPath",
536
+ "message-bus": "a message-bus boundary needs a channel",
537
+ storage: "a store has no key at all: write it as `- writes: <store>` on an outcome of the boundary that touches it instead",
538
+ "graphql-resolver": "a resolver needs a type name and a field name",
539
+ "graphql-operation": "an operation pairs by document rather than by key",
540
+ "runtime-config": "a runtime-config boundary needs a deployment target and an instance name",
541
+ metric: "a metric needs a system and a type",
542
+ "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by"
543
+ };
394
544
  function describeOutcome(outcome) {
395
545
  if (outcome.kind === "response") {
396
546
  return `status ${outcome.status}`;
@@ -400,8 +550,21 @@ function describeOutcome(outcome) {
400
550
  }
401
551
  return "a return value";
402
552
  }
553
+ function describeEffect(effect) {
554
+ const detail = [
555
+ effect.fields.length > 0 ? `of ${effect.fields.join(", ")}` : null,
556
+ effect.by.length > 0 ? `by ${effect.by.join(", ")}` : null
557
+ ].filter((part) => part !== null);
558
+ return [EFFECT_PHRASE[effect.does], effect.names, ...detail].join(" ");
559
+ }
560
+ var EFFECT_PHRASE = {
561
+ reads: "a read of",
562
+ writes: "a write to",
563
+ invokes: "an invoke of"
564
+ };
403
565
  // Annotate the CommonJS export names for ESM import in node:
404
566
  0 && (module.exports = {
405
567
  applyIntentSuppressions,
406
- checkIntentAgreement
568
+ checkIntentAgreement,
569
+ whatWouldKeyIt
407
570
  });
package/dist/index.d.cts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BehavioralSummary } from '@suss/behavioral-ir';
2
2
  import { IntentFinding, IntentSummary } from '@suss/intent-ir';
3
3
  export { IntentFinding } from '@suss/intent-ir';
4
- import { SuppressionRule } from '@suss/ir-core';
4
+ import { SuppressionRule, Semantics } from '@suss/ir-core';
5
5
 
6
6
  /** A boundary intent that was paired and compared against code. */
7
7
  interface CheckedBoundaryIntent {
@@ -81,5 +81,11 @@ declare function checkIntentAgreement(intents: IntentSummary[], code: Behavioral
81
81
  declare function applyIntentSuppressions(findings: IntentFinding[], rules: SuppressionRule[], opts?: {
82
82
  keepHidden?: boolean;
83
83
  }): IntentFinding[];
84
+ /**
85
+ * What each protocol needs before an intent doc written against one of
86
+ * its boundaries can be paired, in the doc author's terms. The
87
+ * drafter says the same thing about a boundary it could not write.
88
+ */
89
+ declare function whatWouldKeyIt(protocol: Semantics["name"]): string;
84
90
 
85
- export { type CheckIntentResult, type CheckedBoundaryIntent, type CheckedIntent, type CheckedPrd, type UncheckedIntent, applyIntentSuppressions, checkIntentAgreement };
91
+ export { type CheckIntentResult, type CheckedBoundaryIntent, type CheckedIntent, type CheckedPrd, type UncheckedIntent, applyIntentSuppressions, checkIntentAgreement, whatWouldKeyIt };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { BehavioralSummary } from '@suss/behavioral-ir';
2
2
  import { IntentFinding, IntentSummary } from '@suss/intent-ir';
3
3
  export { IntentFinding } from '@suss/intent-ir';
4
- import { SuppressionRule } from '@suss/ir-core';
4
+ import { SuppressionRule, Semantics } from '@suss/ir-core';
5
5
 
6
6
  /** A boundary intent that was paired and compared against code. */
7
7
  interface CheckedBoundaryIntent {
@@ -81,5 +81,11 @@ declare function checkIntentAgreement(intents: IntentSummary[], code: Behavioral
81
81
  declare function applyIntentSuppressions(findings: IntentFinding[], rules: SuppressionRule[], opts?: {
82
82
  keepHidden?: boolean;
83
83
  }): IntentFinding[];
84
+ /**
85
+ * What each protocol needs before an intent doc written against one of
86
+ * its boundaries can be paired, in the doc author's terms. The
87
+ * drafter says the same thing about a boundary it could not write.
88
+ */
89
+ declare function whatWouldKeyIt(protocol: Semantics["name"]): string;
84
90
 
85
- export { type CheckIntentResult, type CheckedBoundaryIntent, type CheckedIntent, type CheckedPrd, type UncheckedIntent, applyIntentSuppressions, checkIntentAgreement };
91
+ export { type CheckIntentResult, type CheckedBoundaryIntent, type CheckedIntent, type CheckedPrd, type UncheckedIntent, applyIntentSuppressions, checkIntentAgreement, whatWouldKeyIt };
package/dist/index.js CHANGED
@@ -1,9 +1,20 @@
1
1
  // src/index.ts
2
- import { BOUNDARY_ROLE, summaryRef } from "@suss/behavioral-ir";
2
+ import {
3
+ BOUNDARY_ROLE,
4
+ boundaryCalls,
5
+ boundaryGuardsOf,
6
+ goesThroughRelation,
7
+ relationsOf,
8
+ summaryRef,
9
+ withDeclaredDelivery
10
+ } from "@suss/behavioral-ir";
3
11
  import {
4
12
  applySuppressionsToFindings,
5
13
  bodyShapesMatch,
6
14
  boundaryKey,
15
+ displayLabel,
16
+ EVERY_FIELD,
17
+ namesBoundary,
7
18
  pairingKey,
8
19
  ruleBoundaryMatchesKey,
9
20
  semanticsAgree
@@ -26,8 +37,44 @@ function checkIntentAgreement(intents, code) {
26
37
  checked.push(...result.checked);
27
38
  unchecked.push(...result.unchecked);
28
39
  }
40
+ findings.push(...checkOutcomesDescribed(intents));
29
41
  return { findings, checked, unchecked };
30
42
  }
43
+ function checkOutcomesDescribed(intents) {
44
+ const prds = intents.filter((intent) => intent.kind === "prd");
45
+ if (prds.length === 0) {
46
+ return [];
47
+ }
48
+ const linked = new Set(
49
+ prds.flatMap((prd) => prd.scenarios.flatMap((scenario) => scenario.link))
50
+ );
51
+ const findings = [];
52
+ for (const intent of intents) {
53
+ if (intent.kind !== "boundary") {
54
+ continue;
55
+ }
56
+ for (const outcome of intent.outcomes) {
57
+ if (linked.has(`${intent.name}.${outcome.id}`)) {
58
+ continue;
59
+ }
60
+ findings.push(
61
+ ...withProvenance(
62
+ [
63
+ {
64
+ kind: "undescribedOutcome",
65
+ severity: "info",
66
+ boundary: boundaryKey(intent.boundary) ?? intent.name,
67
+ intent: { name: intent.name, outcomeId: outcome.id },
68
+ message: `Intent "${intent.name}" declares ${outcome.id} and no PRD scenario says why it is there.`
69
+ }
70
+ ],
71
+ intent.source
72
+ )
73
+ );
74
+ }
75
+ }
76
+ return findings;
77
+ }
31
78
  function checkBoundaryIntent(intent, codeByBoundary) {
32
79
  const key = pairingKey(intent.boundary);
33
80
  if (key === null) {
@@ -36,9 +83,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
36
83
  {
37
84
  kind: "unkeyableBoundary",
38
85
  severity: "warning",
39
- boundary: unkeyedBoundaryLabel(intent.boundary),
86
+ boundary: displayLabel(intent.boundary),
40
87
  intent: { name: intent.name },
41
- message: `Intent "${intent.name}" has a ${intent.boundary.semantics.name} boundary that can't be keyed for pairing (function-call boundaries need package + exportPath); it was not checked against code.`
88
+ message: `Intent "${intent.name}" has a ${intent.boundary.semantics.name} boundary that can't be keyed for pairing (${whatWouldKeyIt(intent.boundary.semantics.name)}); it was not checked against code.`
42
89
  }
43
90
  ],
44
91
  checked: [],
@@ -241,10 +288,13 @@ function applyIntentSuppressions(findings, rules, opts = {}) {
241
288
  }
242
289
  function indexCodeByBoundary(code) {
243
290
  const byKey = /* @__PURE__ */ new Map();
244
- for (const summary of code) {
291
+ for (const summary of withDeclaredDelivery(code)) {
245
292
  if (BOUNDARY_ROLE[summary.kind] !== "provider") {
246
293
  continue;
247
294
  }
295
+ if (summary.confidence.source === "declared") {
296
+ continue;
297
+ }
248
298
  const binding = summary.identity.boundaryBinding;
249
299
  if (binding === null) {
250
300
  continue;
@@ -265,10 +315,29 @@ function indexCodeByBoundary(code) {
265
315
  function compareIntentToImpl(intent, impl, boundary) {
266
316
  const findings = [];
267
317
  const ref = codeRef(impl);
268
- const codeOutcomes = impl.transitions.map(toCodeOutcome).filter((o) => o !== null);
318
+ const calls = boundaryCalls(impl);
319
+ const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls)).filter((o) => o !== null);
320
+ const everyEffect = impl.transitions.flatMap(codeEffectsOf);
269
321
  for (const outcome of intent.outcomes) {
270
- const matches = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
271
- if (matches.length === 0) {
322
+ const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
323
+ for (const effect of outcome.effects) {
324
+ if (reached.some((made) => effectMatches(effect, made))) {
325
+ continue;
326
+ }
327
+ findings.push({
328
+ kind: "uncoveredOutcome",
329
+ severity: "error",
330
+ boundary,
331
+ intent: { name: intent.name, outcomeId: outcome.id },
332
+ code: ref,
333
+ message: `Intent "${intent.name}" declares that ${outcome.id} results in ${describeEffect(effect)} at ${boundary}; no transition of ${impl.identity.name} does that.`
334
+ });
335
+ }
336
+ if (outcome.kind === "effect") {
337
+ continue;
338
+ }
339
+ const ending = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
340
+ if (ending.length === 0) {
272
341
  findings.push({
273
342
  kind: "uncoveredOutcome",
274
343
  severity: "error",
@@ -279,6 +348,26 @@ function compareIntentToImpl(intent, impl, boundary) {
279
348
  });
280
349
  continue;
281
350
  }
351
+ const stated = outcome.conditions.filter(
352
+ (c) => c.at !== null
353
+ );
354
+ const matches = stated.length === 0 ? ending : ending.filter(
355
+ (co) => stated.every((c) => conditionMet(c, co.turnsOn))
356
+ );
357
+ if (matches.length === 0) {
358
+ const unmet = stated.find(
359
+ (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
360
+ );
361
+ findings.push({
362
+ kind: "uncoveredOutcome",
363
+ severity: "error",
364
+ boundary,
365
+ intent: { name: intent.name, outcomeId: outcome.id },
366
+ code: ref,
367
+ message: `Intent "${intent.name}" declares ${describeOutcome(outcome)} at ${boundary} when ${unmet?.said ?? outcome.when}; ${impl.identity.name} produces it on a different condition.`
368
+ });
369
+ continue;
370
+ }
282
371
  const declaredBody = outcome.body;
283
372
  if (declaredBody === null) {
284
373
  continue;
@@ -322,17 +411,52 @@ function compareIntentToImpl(intent, impl, boundary) {
322
411
  message: `${impl.identity.name} produces status ${status} at ${boundary}; intent "${intent.name}" does not declare it.`
323
412
  });
324
413
  }
414
+ const declaredEffects = intent.outcomes.flatMap((o) => o.effects);
415
+ const said = /* @__PURE__ */ new Set();
416
+ for (const made of everyEffect) {
417
+ const spelled = `${made.does} ${made.label}`;
418
+ if (said.has(spelled) || declaredEffects.some((effect) => effectMatches(effect, made))) {
419
+ continue;
420
+ }
421
+ said.add(spelled);
422
+ findings.push({
423
+ kind: "undeclaredOutcome",
424
+ severity: "info",
425
+ boundary,
426
+ intent: { name: intent.name },
427
+ code: ref,
428
+ message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
429
+ });
430
+ }
325
431
  return findings;
326
432
  }
327
- function toCodeOutcome(t) {
433
+ function effectMatches(declared, made) {
434
+ return declared.does === made.does && namesBoundary(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
435
+ }
436
+ function statesAll(declared, made) {
437
+ if (declared.length === 0 || made.length === 0 || made.includes(EVERY_FIELD)) {
438
+ return true;
439
+ }
440
+ return declared.every((one) => made.includes(one));
441
+ }
442
+ function conditionMet(declared, turnsOn) {
443
+ return turnsOn.some(
444
+ (guard) => guard.does === declared.at.does && namesBoundary(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
445
+ );
446
+ }
447
+ function toCodeOutcome(t, calls) {
328
448
  const output = t.output;
449
+ const effects = codeEffectsOf(t);
450
+ const turnsOn = boundaryGuardsOf(t, calls);
329
451
  if (output.type === "response") {
330
452
  const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
331
453
  return {
332
454
  kind: "response",
333
455
  status: status !== null && Number.isFinite(status) ? status : null,
334
456
  body: output.body ?? null,
335
- errorType: null
457
+ errorType: null,
458
+ effects,
459
+ turnsOn
336
460
  };
337
461
  }
338
462
  if (output.type === "return") {
@@ -340,7 +464,9 @@ function toCodeOutcome(t) {
340
464
  kind: "return",
341
465
  status: null,
342
466
  body: output.value,
343
- errorType: null
467
+ errorType: null,
468
+ effects,
469
+ turnsOn
344
470
  };
345
471
  }
346
472
  if (output.type === "throw") {
@@ -348,11 +474,39 @@ function toCodeOutcome(t) {
348
474
  kind: "throw",
349
475
  status: null,
350
476
  body: null,
351
- errorType: output.exceptionType
477
+ errorType: output.exceptionType,
478
+ effects,
479
+ turnsOn
352
480
  };
353
481
  }
354
482
  return null;
355
483
  }
484
+ function codeEffectsOf(t) {
485
+ const reached = [];
486
+ for (const effect of t.effects) {
487
+ if (effect.type !== "interaction") {
488
+ continue;
489
+ }
490
+ if (goesThroughRelation(effect.interaction)) {
491
+ continue;
492
+ }
493
+ const label = displayLabel(effect.binding);
494
+ const touched = accessDetail(effect.interaction);
495
+ for (const does of relationsOf(effect.interaction)) {
496
+ reached.push({ does, binding: effect.binding, label, ...touched });
497
+ }
498
+ }
499
+ return reached;
500
+ }
501
+ function accessDetail(interaction) {
502
+ if (interaction.class !== "storage-access") {
503
+ return { fields: [], by: [] };
504
+ }
505
+ return {
506
+ fields: interaction.fields,
507
+ by: interaction.selector ?? []
508
+ };
509
+ }
356
510
  function outcomeMatches(intent, code) {
357
511
  if (intent.kind !== code.kind) {
358
512
  return false;
@@ -365,14 +519,20 @@ function outcomeMatches(intent, code) {
365
519
  }
366
520
  return true;
367
521
  }
368
- function unkeyedBoundaryLabel(binding) {
369
- const semantics = binding.semantics;
370
- if (semantics.name === "function-call") {
371
- const target = semantics.module ?? semantics.package ?? "?";
372
- return `fn:${target}::${semantics.exportName ?? "?"}`;
373
- }
374
- return semantics.name;
522
+ function whatWouldKeyIt(protocol) {
523
+ return WHAT_KEYS[protocol];
375
524
  }
525
+ var WHAT_KEYS = {
526
+ rest: "a REST boundary needs a method and a path",
527
+ "function-call": "a function-call boundary needs package + exportPath",
528
+ "message-bus": "a message-bus boundary needs a channel",
529
+ storage: "a store has no key at all: write it as `- writes: <store>` on an outcome of the boundary that touches it instead",
530
+ "graphql-resolver": "a resolver needs a type name and a field name",
531
+ "graphql-operation": "an operation pairs by document rather than by key",
532
+ "runtime-config": "a runtime-config boundary needs a deployment target and an instance name",
533
+ metric: "a metric needs a system and a type",
534
+ "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by"
535
+ };
376
536
  function describeOutcome(outcome) {
377
537
  if (outcome.kind === "response") {
378
538
  return `status ${outcome.status}`;
@@ -382,7 +542,20 @@ function describeOutcome(outcome) {
382
542
  }
383
543
  return "a return value";
384
544
  }
545
+ function describeEffect(effect) {
546
+ const detail = [
547
+ effect.fields.length > 0 ? `of ${effect.fields.join(", ")}` : null,
548
+ effect.by.length > 0 ? `by ${effect.by.join(", ")}` : null
549
+ ].filter((part) => part !== null);
550
+ return [EFFECT_PHRASE[effect.does], effect.names, ...detail].join(" ");
551
+ }
552
+ var EFFECT_PHRASE = {
553
+ reads: "a read of",
554
+ writes: "a write to",
555
+ invokes: "an invoke of"
556
+ };
385
557
  export {
386
558
  applyIntentSuppressions,
387
- checkIntentAgreement
559
+ checkIntentAgreement,
560
+ whatWouldKeyIt
388
561
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/checker-intent",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "type": "module",
5
5
  "description": "Pair team-authored intent (IntentSummary) against derived code (BehavioralSummary) and report where they diverge.",
6
6
  "keywords": [
@@ -47,9 +47,9 @@
47
47
  "zod": "^4.0.0"
48
48
  },
49
49
  "dependencies": {
50
- "@suss/behavioral-ir": "0.20.0",
51
- "@suss/intent-ir": "0.20.0",
52
- "@suss/ir-core": "0.20.0"
50
+ "@suss/behavioral-ir": "0.21.0",
51
+ "@suss/intent-ir": "0.21.0",
52
+ "@suss/ir-core": "0.21.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "tsup": "*",