@suss/checker-intent 0.20.0 → 0.21.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/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");
@@ -32,6 +33,7 @@ function checkIntentAgreement(intents, code) {
32
33
  const unchecked = [];
33
34
  const codeByBoundary = indexCodeByBoundary(code);
34
35
  const boundaryByName = indexBoundaryIntentsByName(intents);
36
+ const deploymentOfUnit = (0, import_behavioral_ir.deploymentOf)(code);
35
37
  for (const intent of intents) {
36
38
  if (intent.kind === "prd") {
37
39
  const result2 = checkPrdCoverage(intent, boundaryByName);
@@ -39,14 +41,54 @@ function checkIntentAgreement(intents, code) {
39
41
  checked.push(result2.checked);
40
42
  continue;
41
43
  }
42
- const result = checkBoundaryIntent(intent, codeByBoundary);
44
+ const result = checkBoundaryIntent(
45
+ intent,
46
+ codeByBoundary,
47
+ deploymentOfUnit
48
+ );
43
49
  findings.push(...withProvenance(result.findings, intent.source));
44
50
  checked.push(...result.checked);
45
51
  unchecked.push(...result.unchecked);
46
52
  }
53
+ findings.push(...checkOutcomesDescribed(intents));
47
54
  return { findings, checked, unchecked };
48
55
  }
49
- function checkBoundaryIntent(intent, codeByBoundary) {
56
+ function checkOutcomesDescribed(intents) {
57
+ const prds = intents.filter((intent) => intent.kind === "prd");
58
+ if (prds.length === 0) {
59
+ return [];
60
+ }
61
+ const linked = new Set(
62
+ prds.flatMap((prd) => prd.scenarios.flatMap((scenario) => scenario.link))
63
+ );
64
+ const findings = [];
65
+ for (const intent of intents) {
66
+ if (intent.kind !== "boundary") {
67
+ continue;
68
+ }
69
+ for (const outcome of intent.outcomes) {
70
+ if (linked.has(`${intent.name}.${outcome.id}`)) {
71
+ continue;
72
+ }
73
+ findings.push(
74
+ ...withProvenance(
75
+ [
76
+ {
77
+ kind: "undescribedOutcome",
78
+ severity: "info",
79
+ boundary: (0, import_ir_core.boundaryKey)(intent.boundary) ?? intent.name,
80
+ intent: { name: intent.name, outcomeId: outcome.id },
81
+ message: `Intent "${intent.name}" declares ${outcome.id} and no PRD scenario says why it is there.`
82
+ }
83
+ ],
84
+ intent.source
85
+ )
86
+ );
87
+ }
88
+ }
89
+ return findings;
90
+ }
91
+ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit) {
50
92
  const key = (0, import_ir_core.pairingKey)(intent.boundary);
51
93
  if (key === null) {
52
94
  return {
@@ -54,9 +96,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
54
96
  {
55
97
  kind: "unkeyableBoundary",
56
98
  severity: "warning",
57
- boundary: unkeyedBoundaryLabel(intent.boundary),
99
+ boundary: (0, import_ir_core.displayLabel)(intent.boundary),
58
100
  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.`
101
+ 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
102
  }
61
103
  ],
62
104
  checked: [],
@@ -99,7 +141,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
99
141
  }
100
142
  const findings = [];
101
143
  for (const impl of impls) {
102
- findings.push(...compareIntentToImpl(intent, impl, label));
144
+ findings.push(
145
+ ...compareIntentToImpl(intent, impl, label, deploymentOfUnit(impl))
146
+ );
103
147
  }
104
148
  return {
105
149
  findings,
@@ -259,10 +303,13 @@ function applyIntentSuppressions(findings, rules, opts = {}) {
259
303
  }
260
304
  function indexCodeByBoundary(code) {
261
305
  const byKey = /* @__PURE__ */ new Map();
262
- for (const summary of code) {
306
+ for (const summary of (0, import_behavioral_ir.withDeclaredDelivery)(code)) {
263
307
  if (import_behavioral_ir.BOUNDARY_ROLE[summary.kind] !== "provider") {
264
308
  continue;
265
309
  }
310
+ if (summary.confidence.source === "declared") {
311
+ continue;
312
+ }
266
313
  const binding = summary.identity.boundaryBinding;
267
314
  if (binding === null) {
268
315
  continue;
@@ -280,13 +327,34 @@ function indexCodeByBoundary(code) {
280
327
  }
281
328
  return byKey;
282
329
  }
283
- function compareIntentToImpl(intent, impl, boundary) {
330
+ function compareIntentToImpl(intent, impl, boundary, deployment) {
284
331
  const findings = [];
285
332
  const ref = codeRef(impl);
286
- const codeOutcomes = impl.transitions.map(toCodeOutcome).filter((o) => o !== null);
333
+ const calls = (0, import_behavioral_ir.boundaryCalls)(impl);
334
+ const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls, deployment)).filter((o) => o !== null);
335
+ const everyEffect = impl.transitions.flatMap(
336
+ (t) => codeEffectsOf(t, deployment)
337
+ );
287
338
  for (const outcome of intent.outcomes) {
288
- const matches = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
289
- if (matches.length === 0) {
339
+ const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
340
+ for (const effect of outcome.effects) {
341
+ if (reached.some((made) => effectMatches(effect, made))) {
342
+ continue;
343
+ }
344
+ findings.push({
345
+ kind: "uncoveredOutcome",
346
+ severity: "error",
347
+ boundary,
348
+ intent: { name: intent.name, outcomeId: outcome.id },
349
+ code: ref,
350
+ 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)}`
351
+ });
352
+ }
353
+ if (outcome.kind === "effect") {
354
+ continue;
355
+ }
356
+ const ending = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
357
+ if (ending.length === 0) {
290
358
  findings.push({
291
359
  kind: "uncoveredOutcome",
292
360
  severity: "error",
@@ -297,6 +365,26 @@ function compareIntentToImpl(intent, impl, boundary) {
297
365
  });
298
366
  continue;
299
367
  }
368
+ const stated = outcome.conditions.filter(
369
+ (c) => c.at !== null
370
+ );
371
+ const matches = stated.length === 0 ? ending : ending.filter(
372
+ (co) => stated.every((c) => conditionMet(c, co.turnsOn))
373
+ );
374
+ if (matches.length === 0) {
375
+ const unmet = stated.find(
376
+ (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
377
+ );
378
+ findings.push({
379
+ kind: "uncoveredOutcome",
380
+ severity: "error",
381
+ boundary,
382
+ intent: { name: intent.name, outcomeId: outcome.id },
383
+ code: ref,
384
+ message: `Intent "${intent.name}" declares ${describeOutcome(outcome)} at ${boundary} when ${unmet?.said ?? outcome.when}; ${impl.identity.name} produces it on a different condition.`
385
+ });
386
+ continue;
387
+ }
300
388
  const declaredBody = outcome.body;
301
389
  if (declaredBody === null) {
302
390
  continue;
@@ -340,17 +428,70 @@ function compareIntentToImpl(intent, impl, boundary) {
340
428
  message: `${impl.identity.name} produces status ${status} at ${boundary}; intent "${intent.name}" does not declare it.`
341
429
  });
342
430
  }
431
+ const declaredEffects = intent.outcomes.flatMap((o) => o.effects);
432
+ const said = /* @__PURE__ */ new Set();
433
+ for (const made of everyEffect) {
434
+ const spelled = `${made.does} ${made.label}`;
435
+ if (said.has(spelled) || declaredEffects.some((effect) => effectMatches(effect, made))) {
436
+ continue;
437
+ }
438
+ said.add(spelled);
439
+ findings.push({
440
+ kind: "undeclaredOutcome",
441
+ severity: "info",
442
+ boundary,
443
+ intent: { name: intent.name },
444
+ code: ref,
445
+ message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
446
+ });
447
+ }
343
448
  return findings;
344
449
  }
345
- function toCodeOutcome(t) {
450
+ function unsettledNote(reached, declared, deployment) {
451
+ const variables = /* @__PURE__ */ new Set();
452
+ for (const made of reached) {
453
+ const reference = (0, import_ir_core.nameReference)(made.binding);
454
+ const variable = reference === null ? null : deployment.variableFor(reference);
455
+ if (made.does === declared.does && variable !== null) {
456
+ variables.add(variable);
457
+ }
458
+ }
459
+ if (variables.size === 0) {
460
+ return "";
461
+ }
462
+ const named = [...variables].sort().join(", ");
463
+ return ` The code says which one through ${named}, and nothing in this run says what that is set to. Read the deployment that sets it in and check again.`;
464
+ }
465
+ function effectMatches(declared, made) {
466
+ return declared.does === made.does && (0, import_ir_core.namesBoundary)(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
467
+ }
468
+ function statesAll(declared, made) {
469
+ if (declared.length === 0 || made.length === 0 || made.includes(import_ir_core.EVERY_FIELD)) {
470
+ return true;
471
+ }
472
+ return declared.every((one) => made.includes(one));
473
+ }
474
+ function conditionMet(declared, turnsOn) {
475
+ return turnsOn.some(
476
+ (guard) => guard.does === declared.at.does && (0, import_ir_core.namesBoundary)(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
477
+ );
478
+ }
479
+ function toCodeOutcome(t, calls, deployment) {
346
480
  const output = t.output;
481
+ const effects = codeEffectsOf(t, deployment);
482
+ const turnsOn = (0, import_behavioral_ir.boundaryGuardsOf)(t, calls).map((guard) => ({
483
+ ...guard,
484
+ binding: (0, import_behavioral_ir.groundBinding)(guard.binding, deployment)
485
+ }));
347
486
  if (output.type === "response") {
348
487
  const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
349
488
  return {
350
489
  kind: "response",
351
490
  status: status !== null && Number.isFinite(status) ? status : null,
352
491
  body: output.body ?? null,
353
- errorType: null
492
+ errorType: null,
493
+ effects,
494
+ turnsOn
354
495
  };
355
496
  }
356
497
  if (output.type === "return") {
@@ -358,7 +499,9 @@ function toCodeOutcome(t) {
358
499
  kind: "return",
359
500
  status: null,
360
501
  body: output.value,
361
- errorType: null
502
+ errorType: null,
503
+ effects,
504
+ turnsOn
362
505
  };
363
506
  }
364
507
  if (output.type === "throw") {
@@ -366,11 +509,40 @@ function toCodeOutcome(t) {
366
509
  kind: "throw",
367
510
  status: null,
368
511
  body: null,
369
- errorType: output.exceptionType
512
+ errorType: output.exceptionType,
513
+ effects,
514
+ turnsOn
370
515
  };
371
516
  }
372
517
  return null;
373
518
  }
519
+ function codeEffectsOf(t, deployment) {
520
+ const reached = [];
521
+ for (const effect of t.effects) {
522
+ if (effect.type !== "interaction") {
523
+ continue;
524
+ }
525
+ if ((0, import_behavioral_ir.goesThroughRelation)(effect.interaction)) {
526
+ continue;
527
+ }
528
+ const binding = (0, import_behavioral_ir.groundBinding)(effect.binding, deployment);
529
+ const label = (0, import_ir_core.displayLabel)(binding);
530
+ const touched = accessDetail(effect.interaction);
531
+ for (const does of (0, import_behavioral_ir.relationsOf)(effect.interaction)) {
532
+ reached.push({ does, binding, label, ...touched });
533
+ }
534
+ }
535
+ return reached;
536
+ }
537
+ function accessDetail(interaction) {
538
+ if (interaction.class !== "storage-access") {
539
+ return { fields: [], by: [] };
540
+ }
541
+ return {
542
+ fields: interaction.fields,
543
+ by: interaction.selector ?? []
544
+ };
545
+ }
374
546
  function outcomeMatches(intent, code) {
375
547
  if (intent.kind !== code.kind) {
376
548
  return false;
@@ -383,14 +555,20 @@ function outcomeMatches(intent, code) {
383
555
  }
384
556
  return true;
385
557
  }
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;
558
+ function whatWouldKeyIt(protocol) {
559
+ return WHAT_KEYS[protocol];
393
560
  }
561
+ var WHAT_KEYS = {
562
+ rest: "a REST boundary needs a method and a path",
563
+ "function-call": "a function-call boundary needs package + exportPath",
564
+ "message-bus": "a message-bus boundary needs a channel",
565
+ storage: "a store has no key at all: write it as `- writes: <store>` on an outcome of the boundary that touches it instead",
566
+ "graphql-resolver": "a resolver needs a type name and a field name",
567
+ "graphql-operation": "an operation pairs by document rather than by key",
568
+ "runtime-config": "a runtime-config boundary needs a deployment target and an instance name",
569
+ metric: "a metric needs a system and a type",
570
+ "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by"
571
+ };
394
572
  function describeOutcome(outcome) {
395
573
  if (outcome.kind === "response") {
396
574
  return `status ${outcome.status}`;
@@ -400,8 +578,21 @@ function describeOutcome(outcome) {
400
578
  }
401
579
  return "a return value";
402
580
  }
581
+ function describeEffect(effect) {
582
+ const detail = [
583
+ effect.fields.length > 0 ? `of ${effect.fields.join(", ")}` : null,
584
+ effect.by.length > 0 ? `by ${effect.by.join(", ")}` : null
585
+ ].filter((part) => part !== null);
586
+ return [EFFECT_PHRASE[effect.does], effect.names, ...detail].join(" ");
587
+ }
588
+ var EFFECT_PHRASE = {
589
+ reads: "a read of",
590
+ writes: "a write to",
591
+ invokes: "an invoke of"
592
+ };
403
593
  // Annotate the CommonJS export names for ESM import in node:
404
594
  0 && (module.exports = {
405
595
  applyIntentSuppressions,
406
- checkIntentAgreement
596
+ checkIntentAgreement,
597
+ whatWouldKeyIt
407
598
  });
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,23 @@
1
1
  // src/index.ts
2
- import { BOUNDARY_ROLE, summaryRef } from "@suss/behavioral-ir";
2
+ import {
3
+ BOUNDARY_ROLE,
4
+ boundaryCalls,
5
+ boundaryGuardsOf,
6
+ deploymentOf,
7
+ goesThroughRelation,
8
+ groundBinding,
9
+ relationsOf,
10
+ summaryRef,
11
+ withDeclaredDelivery
12
+ } from "@suss/behavioral-ir";
3
13
  import {
4
14
  applySuppressionsToFindings,
5
15
  bodyShapesMatch,
6
16
  boundaryKey,
17
+ displayLabel,
18
+ EVERY_FIELD,
19
+ nameReference,
20
+ namesBoundary,
7
21
  pairingKey,
8
22
  ruleBoundaryMatchesKey,
9
23
  semanticsAgree
@@ -14,6 +28,7 @@ function checkIntentAgreement(intents, code) {
14
28
  const unchecked = [];
15
29
  const codeByBoundary = indexCodeByBoundary(code);
16
30
  const boundaryByName = indexBoundaryIntentsByName(intents);
31
+ const deploymentOfUnit = deploymentOf(code);
17
32
  for (const intent of intents) {
18
33
  if (intent.kind === "prd") {
19
34
  const result2 = checkPrdCoverage(intent, boundaryByName);
@@ -21,14 +36,54 @@ function checkIntentAgreement(intents, code) {
21
36
  checked.push(result2.checked);
22
37
  continue;
23
38
  }
24
- const result = checkBoundaryIntent(intent, codeByBoundary);
39
+ const result = checkBoundaryIntent(
40
+ intent,
41
+ codeByBoundary,
42
+ deploymentOfUnit
43
+ );
25
44
  findings.push(...withProvenance(result.findings, intent.source));
26
45
  checked.push(...result.checked);
27
46
  unchecked.push(...result.unchecked);
28
47
  }
48
+ findings.push(...checkOutcomesDescribed(intents));
29
49
  return { findings, checked, unchecked };
30
50
  }
31
- function checkBoundaryIntent(intent, codeByBoundary) {
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: 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
+ }
86
+ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit) {
32
87
  const key = pairingKey(intent.boundary);
33
88
  if (key === null) {
34
89
  return {
@@ -36,9 +91,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
36
91
  {
37
92
  kind: "unkeyableBoundary",
38
93
  severity: "warning",
39
- boundary: unkeyedBoundaryLabel(intent.boundary),
94
+ boundary: displayLabel(intent.boundary),
40
95
  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.`
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.`
42
97
  }
43
98
  ],
44
99
  checked: [],
@@ -81,7 +136,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
81
136
  }
82
137
  const findings = [];
83
138
  for (const impl of impls) {
84
- findings.push(...compareIntentToImpl(intent, impl, label));
139
+ findings.push(
140
+ ...compareIntentToImpl(intent, impl, label, deploymentOfUnit(impl))
141
+ );
85
142
  }
86
143
  return {
87
144
  findings,
@@ -241,10 +298,13 @@ function applyIntentSuppressions(findings, rules, opts = {}) {
241
298
  }
242
299
  function indexCodeByBoundary(code) {
243
300
  const byKey = /* @__PURE__ */ new Map();
244
- for (const summary of code) {
301
+ for (const summary of withDeclaredDelivery(code)) {
245
302
  if (BOUNDARY_ROLE[summary.kind] !== "provider") {
246
303
  continue;
247
304
  }
305
+ if (summary.confidence.source === "declared") {
306
+ continue;
307
+ }
248
308
  const binding = summary.identity.boundaryBinding;
249
309
  if (binding === null) {
250
310
  continue;
@@ -262,13 +322,34 @@ function indexCodeByBoundary(code) {
262
322
  }
263
323
  return byKey;
264
324
  }
265
- function compareIntentToImpl(intent, impl, boundary) {
325
+ function compareIntentToImpl(intent, impl, boundary, deployment) {
266
326
  const findings = [];
267
327
  const ref = codeRef(impl);
268
- const codeOutcomes = impl.transitions.map(toCodeOutcome).filter((o) => o !== null);
328
+ const calls = boundaryCalls(impl);
329
+ const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls, deployment)).filter((o) => o !== null);
330
+ const everyEffect = impl.transitions.flatMap(
331
+ (t) => codeEffectsOf(t, deployment)
332
+ );
269
333
  for (const outcome of intent.outcomes) {
270
- const matches = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
271
- if (matches.length === 0) {
334
+ const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
335
+ for (const effect of outcome.effects) {
336
+ if (reached.some((made) => effectMatches(effect, made))) {
337
+ continue;
338
+ }
339
+ findings.push({
340
+ kind: "uncoveredOutcome",
341
+ severity: "error",
342
+ boundary,
343
+ intent: { name: intent.name, outcomeId: outcome.id },
344
+ code: ref,
345
+ 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)}`
346
+ });
347
+ }
348
+ if (outcome.kind === "effect") {
349
+ continue;
350
+ }
351
+ const ending = codeOutcomes.filter((co) => outcomeMatches(outcome, co));
352
+ if (ending.length === 0) {
272
353
  findings.push({
273
354
  kind: "uncoveredOutcome",
274
355
  severity: "error",
@@ -279,6 +360,26 @@ function compareIntentToImpl(intent, impl, boundary) {
279
360
  });
280
361
  continue;
281
362
  }
363
+ const stated = outcome.conditions.filter(
364
+ (c) => c.at !== null
365
+ );
366
+ const matches = stated.length === 0 ? ending : ending.filter(
367
+ (co) => stated.every((c) => conditionMet(c, co.turnsOn))
368
+ );
369
+ if (matches.length === 0) {
370
+ const unmet = stated.find(
371
+ (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
372
+ );
373
+ findings.push({
374
+ kind: "uncoveredOutcome",
375
+ severity: "error",
376
+ boundary,
377
+ intent: { name: intent.name, outcomeId: outcome.id },
378
+ code: ref,
379
+ message: `Intent "${intent.name}" declares ${describeOutcome(outcome)} at ${boundary} when ${unmet?.said ?? outcome.when}; ${impl.identity.name} produces it on a different condition.`
380
+ });
381
+ continue;
382
+ }
282
383
  const declaredBody = outcome.body;
283
384
  if (declaredBody === null) {
284
385
  continue;
@@ -322,17 +423,70 @@ function compareIntentToImpl(intent, impl, boundary) {
322
423
  message: `${impl.identity.name} produces status ${status} at ${boundary}; intent "${intent.name}" does not declare it.`
323
424
  });
324
425
  }
426
+ const declaredEffects = intent.outcomes.flatMap((o) => o.effects);
427
+ const said = /* @__PURE__ */ new Set();
428
+ for (const made of everyEffect) {
429
+ const spelled = `${made.does} ${made.label}`;
430
+ if (said.has(spelled) || declaredEffects.some((effect) => effectMatches(effect, made))) {
431
+ continue;
432
+ }
433
+ said.add(spelled);
434
+ findings.push({
435
+ kind: "undeclaredOutcome",
436
+ severity: "info",
437
+ boundary,
438
+ intent: { name: intent.name },
439
+ code: ref,
440
+ message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
441
+ });
442
+ }
325
443
  return findings;
326
444
  }
327
- function toCodeOutcome(t) {
445
+ function unsettledNote(reached, declared, deployment) {
446
+ const variables = /* @__PURE__ */ new Set();
447
+ for (const made of reached) {
448
+ const reference = nameReference(made.binding);
449
+ const variable = reference === null ? null : deployment.variableFor(reference);
450
+ if (made.does === declared.does && variable !== null) {
451
+ variables.add(variable);
452
+ }
453
+ }
454
+ if (variables.size === 0) {
455
+ return "";
456
+ }
457
+ const named = [...variables].sort().join(", ");
458
+ return ` The code says which one through ${named}, and nothing in this run says what that is set to. Read the deployment that sets it in and check again.`;
459
+ }
460
+ function effectMatches(declared, made) {
461
+ return declared.does === made.does && namesBoundary(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
462
+ }
463
+ function statesAll(declared, made) {
464
+ if (declared.length === 0 || made.length === 0 || made.includes(EVERY_FIELD)) {
465
+ return true;
466
+ }
467
+ return declared.every((one) => made.includes(one));
468
+ }
469
+ function conditionMet(declared, turnsOn) {
470
+ return turnsOn.some(
471
+ (guard) => guard.does === declared.at.does && namesBoundary(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
472
+ );
473
+ }
474
+ function toCodeOutcome(t, calls, deployment) {
328
475
  const output = t.output;
476
+ const effects = codeEffectsOf(t, deployment);
477
+ const turnsOn = boundaryGuardsOf(t, calls).map((guard) => ({
478
+ ...guard,
479
+ binding: groundBinding(guard.binding, deployment)
480
+ }));
329
481
  if (output.type === "response") {
330
482
  const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
331
483
  return {
332
484
  kind: "response",
333
485
  status: status !== null && Number.isFinite(status) ? status : null,
334
486
  body: output.body ?? null,
335
- errorType: null
487
+ errorType: null,
488
+ effects,
489
+ turnsOn
336
490
  };
337
491
  }
338
492
  if (output.type === "return") {
@@ -340,7 +494,9 @@ function toCodeOutcome(t) {
340
494
  kind: "return",
341
495
  status: null,
342
496
  body: output.value,
343
- errorType: null
497
+ errorType: null,
498
+ effects,
499
+ turnsOn
344
500
  };
345
501
  }
346
502
  if (output.type === "throw") {
@@ -348,11 +504,40 @@ function toCodeOutcome(t) {
348
504
  kind: "throw",
349
505
  status: null,
350
506
  body: null,
351
- errorType: output.exceptionType
507
+ errorType: output.exceptionType,
508
+ effects,
509
+ turnsOn
352
510
  };
353
511
  }
354
512
  return null;
355
513
  }
514
+ function codeEffectsOf(t, deployment) {
515
+ const reached = [];
516
+ for (const effect of t.effects) {
517
+ if (effect.type !== "interaction") {
518
+ continue;
519
+ }
520
+ if (goesThroughRelation(effect.interaction)) {
521
+ continue;
522
+ }
523
+ const binding = groundBinding(effect.binding, deployment);
524
+ const label = displayLabel(binding);
525
+ const touched = accessDetail(effect.interaction);
526
+ for (const does of relationsOf(effect.interaction)) {
527
+ reached.push({ does, binding, label, ...touched });
528
+ }
529
+ }
530
+ return reached;
531
+ }
532
+ function accessDetail(interaction) {
533
+ if (interaction.class !== "storage-access") {
534
+ return { fields: [], by: [] };
535
+ }
536
+ return {
537
+ fields: interaction.fields,
538
+ by: interaction.selector ?? []
539
+ };
540
+ }
356
541
  function outcomeMatches(intent, code) {
357
542
  if (intent.kind !== code.kind) {
358
543
  return false;
@@ -365,14 +550,20 @@ function outcomeMatches(intent, code) {
365
550
  }
366
551
  return true;
367
552
  }
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;
553
+ function whatWouldKeyIt(protocol) {
554
+ return WHAT_KEYS[protocol];
375
555
  }
556
+ var WHAT_KEYS = {
557
+ rest: "a REST boundary needs a method and a path",
558
+ "function-call": "a function-call boundary needs package + exportPath",
559
+ "message-bus": "a message-bus boundary needs a channel",
560
+ storage: "a store has no key at all: write it as `- writes: <store>` on an outcome of the boundary that touches it instead",
561
+ "graphql-resolver": "a resolver needs a type name and a field name",
562
+ "graphql-operation": "an operation pairs by document rather than by key",
563
+ "runtime-config": "a runtime-config boundary needs a deployment target and an instance name",
564
+ metric: "a metric needs a system and a type",
565
+ "unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by"
566
+ };
376
567
  function describeOutcome(outcome) {
377
568
  if (outcome.kind === "response") {
378
569
  return `status ${outcome.status}`;
@@ -382,7 +573,20 @@ function describeOutcome(outcome) {
382
573
  }
383
574
  return "a return value";
384
575
  }
576
+ function describeEffect(effect) {
577
+ const detail = [
578
+ effect.fields.length > 0 ? `of ${effect.fields.join(", ")}` : null,
579
+ effect.by.length > 0 ? `by ${effect.by.join(", ")}` : null
580
+ ].filter((part) => part !== null);
581
+ return [EFFECT_PHRASE[effect.does], effect.names, ...detail].join(" ");
582
+ }
583
+ var EFFECT_PHRASE = {
584
+ reads: "a read of",
585
+ writes: "a write to",
586
+ invokes: "an invoke of"
587
+ };
385
588
  export {
386
589
  applyIntentSuppressions,
387
- checkIntentAgreement
590
+ checkIntentAgreement,
591
+ whatWouldKeyIt
388
592
  };
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.1",
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.1",
51
+ "@suss/intent-ir": "0.21.1",
52
+ "@suss/ir-core": "0.21.1"
53
53
  },
54
54
  "devDependencies": {
55
55
  "tsup": "*",