@suss/checker-intent 0.21.0 → 0.22.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
@@ -27,6 +27,7 @@ the same boundary key, and emits:
27
27
  - `undeclaredOutcome`: code produces a REST status, or reaches a boundary, that the intent doesn't declare (info; intent under-specifies).
28
28
  - `unkeyableBoundary`: the intent's boundary has no key to pair on, so nothing was compared (warning).
29
29
  - `undescribedOutcome`: a declared outcome no PRD scenario links to (info).
30
+ - `renamedBoundary`: a declared store the unit never touches, paired with an undeclared store of the same system it touches with the same verbs on the same outcomes instead.
30
31
 
31
32
  v0 checks system intent (`kind: boundary`). PRD outcome intent
32
33
  (`kind: prd`), which covers scenarios and links, is a separate pass.
@@ -53,10 +54,16 @@ An access whose container the code cannot settle (a wrapper handed its table as
53
54
 
54
55
  A `when` clause that says which boundary the branch read is compared too, which is what makes `when` more than prose.
55
56
 
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
+ `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. When the unit makes no effect against that boundary anywhere, in any transition, the message points at the boundary as the cause instead.
57
58
 
58
59
  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
 
61
+ ## When a store was renamed
62
+
63
+ A store that gets renamed in code without the intent doc catching up produces a pile of findings from one cause: the old name is declared and never touched, the new one is touched and never declared, and both an `uncoveredOutcome` (for the vanished store) and an `undeclaredOutcome` (for the one that appeared) fire for every verb and every outcome that used it. `renamedBoundary` folds all of those into one finding when the pairing is unambiguous: the two boundaries share a system prefix, their verbs match exactly, the new one satisfies every declared use the old one had, and each side has exactly one candidate on the other. It stays an error, same as the findings it replaces: the document and the code still disagree, and folding is a guess about the cause rather than a change in whether that disagreement matters.
64
+
65
+ Pairing only considers reads and writes. A queue channel or a deployed unit is addressed by name, so a different callee produces its own uncoveredOutcome and undeclaredOutcome findings without folding.
66
+
60
67
  ## The coverage question, both ways
61
68
 
62
69
  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.
package/dist/index.cjs CHANGED
@@ -33,6 +33,7 @@ function checkIntentAgreement(intents, code) {
33
33
  const unchecked = [];
34
34
  const codeByBoundary = indexCodeByBoundary(code);
35
35
  const boundaryByName = indexBoundaryIntentsByName(intents);
36
+ const deploymentOfUnit = (0, import_behavioral_ir.deploymentOf)(code);
36
37
  for (const intent of intents) {
37
38
  if (intent.kind === "prd") {
38
39
  const result2 = checkPrdCoverage(intent, boundaryByName);
@@ -40,7 +41,11 @@ function checkIntentAgreement(intents, code) {
40
41
  checked.push(result2.checked);
41
42
  continue;
42
43
  }
43
- const result = checkBoundaryIntent(intent, codeByBoundary);
44
+ const result = checkBoundaryIntent(
45
+ intent,
46
+ codeByBoundary,
47
+ deploymentOfUnit
48
+ );
44
49
  findings.push(...withProvenance(result.findings, intent.source));
45
50
  checked.push(...result.checked);
46
51
  unchecked.push(...result.unchecked);
@@ -83,7 +88,7 @@ function checkOutcomesDescribed(intents) {
83
88
  }
84
89
  return findings;
85
90
  }
86
- function checkBoundaryIntent(intent, codeByBoundary) {
91
+ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit) {
87
92
  const key = (0, import_ir_core.pairingKey)(intent.boundary);
88
93
  if (key === null) {
89
94
  return {
@@ -136,7 +141,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
136
141
  }
137
142
  const findings = [];
138
143
  for (const impl of impls) {
139
- findings.push(...compareIntentToImpl(intent, impl, label));
144
+ findings.push(
145
+ ...compareIntentToImpl(intent, impl, label, deploymentOfUnit(impl))
146
+ );
140
147
  }
141
148
  return {
142
149
  findings,
@@ -320,26 +327,41 @@ function indexCodeByBoundary(code) {
320
327
  }
321
328
  return byKey;
322
329
  }
323
- function compareIntentToImpl(intent, impl, boundary) {
330
+ function compareIntentToImpl(intent, impl, boundary, deployment) {
324
331
  const findings = [];
325
332
  const ref = codeRef(impl);
326
333
  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);
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
+ );
338
+ const vanished = [];
339
+ const undeclared = [];
329
340
  for (const outcome of intent.outcomes) {
330
341
  const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
331
342
  for (const effect of outcome.effects) {
332
343
  if (reached.some((made) => effectMatches(effect, made))) {
333
344
  continue;
334
345
  }
335
- findings.push({
346
+ const finding = {
336
347
  kind: "uncoveredOutcome",
337
348
  severity: "error",
338
349
  boundary,
339
350
  intent: { name: intent.name, outcomeId: outcome.id },
340
351
  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
- });
352
+ 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)}`
353
+ };
354
+ findings.push(finding);
355
+ if (neverTouchesBoundary(effect.names, everyEffect)) {
356
+ vanished.push({
357
+ finding,
358
+ boundary: effect.names,
359
+ does: effect.does,
360
+ kind: "effect",
361
+ effect,
362
+ reached
363
+ });
364
+ }
343
365
  }
344
366
  if (outcome.kind === "effect") {
345
367
  continue;
@@ -366,14 +388,32 @@ function compareIntentToImpl(intent, impl, boundary) {
366
388
  const unmet = stated.find(
367
389
  (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
368
390
  );
369
- findings.push({
391
+ const finding = {
370
392
  kind: "uncoveredOutcome",
371
393
  severity: "error",
372
394
  boundary,
373
395
  intent: { name: intent.name, outcomeId: outcome.id },
374
396
  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
- });
397
+ message: unmetConditionMessage(
398
+ intent,
399
+ outcome,
400
+ boundary,
401
+ impl,
402
+ unmet,
403
+ everyEffect
404
+ )
405
+ };
406
+ findings.push(finding);
407
+ if (unmet !== void 0 && neverTouchesBoundary(unmet.at.names, everyEffect)) {
408
+ vanished.push({
409
+ finding,
410
+ boundary: unmet.at.names,
411
+ does: unmet.at.does,
412
+ kind: "condition",
413
+ unmet,
414
+ ending
415
+ });
416
+ }
377
417
  continue;
378
418
  }
379
419
  const declaredBody = outcome.body;
@@ -427,16 +467,178 @@ function compareIntentToImpl(intent, impl, boundary) {
427
467
  continue;
428
468
  }
429
469
  said.add(spelled);
430
- findings.push({
470
+ const finding = {
431
471
  kind: "undeclaredOutcome",
432
472
  severity: "info",
433
473
  boundary,
434
474
  intent: { name: intent.name },
435
475
  code: ref,
436
476
  message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
477
+ };
478
+ findings.push(finding);
479
+ undeclared.push({ finding, boundary: made.label, does: made.does });
480
+ }
481
+ return foldRenamedBoundaries(
482
+ intent,
483
+ impl,
484
+ boundary,
485
+ ref,
486
+ findings,
487
+ vanished,
488
+ undeclared
489
+ );
490
+ }
491
+ function neverTouchesBoundary(names, everyEffect) {
492
+ return everyEffect.every((made) => !(0, import_ir_core.namesBoundary)(names, made.binding));
493
+ }
494
+ function unmetConditionMessage(intent, outcome, boundary, impl, unmet, everyEffect) {
495
+ const declared = `Intent "${intent.name}" declares ${describeOutcome(outcome)} at ${boundary} when ${unmet?.said ?? outcome.when}`;
496
+ if (unmet !== void 0 && neverTouchesBoundary(unmet.at.names, everyEffect)) {
497
+ return `${declared}; ${impl.identity.name} never ${unmet.at.does} ${unmet.at.names}.`;
498
+ }
499
+ return `${declared}; ${impl.identity.name} produces it on a different condition.`;
500
+ }
501
+ function satisfiedBy(use, candidate) {
502
+ if (use.kind === "effect") {
503
+ return use.reached.some(
504
+ (made) => effectMatches({ ...use.effect, names: candidate }, made)
505
+ );
506
+ }
507
+ return use.ending.some(
508
+ (co) => conditionMet(
509
+ { ...use.unmet, at: { ...use.unmet.at, names: candidate } },
510
+ co.turnsOn
511
+ )
512
+ );
513
+ }
514
+ function foldRenamedBoundaries(intent, impl, boundary, ref, findings, vanished, undeclared) {
515
+ const vanishedByBoundary = groupByBoundary(vanished);
516
+ const undeclaredByBoundary = groupByBoundary(undeclared);
517
+ const pairings = matchRenamedBoundaries(
518
+ vanishedByBoundary,
519
+ undeclaredByBoundary
520
+ );
521
+ if (pairings.length === 0) {
522
+ return findings;
523
+ }
524
+ const explained = /* @__PURE__ */ new Set();
525
+ const renamed = [];
526
+ for (const pairing of pairings) {
527
+ for (const use of pairing.vanishedUses) {
528
+ explained.add(use.finding);
529
+ }
530
+ for (const use of pairing.undeclaredUses) {
531
+ explained.add(use.finding);
532
+ }
533
+ renamed.push({
534
+ kind: "renamedBoundary",
535
+ severity: "error",
536
+ boundary,
537
+ intent: { name: intent.name },
538
+ code: ref,
539
+ 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.`
437
540
  });
438
541
  }
439
- return findings;
542
+ return [...findings.filter((finding) => !explained.has(finding)), ...renamed];
543
+ }
544
+ function matchRenamedBoundaries(vanishedByBoundary, undeclaredByBoundary) {
545
+ const candidatesFor = /* @__PURE__ */ new Map();
546
+ const candidateCountAgainst = /* @__PURE__ */ new Map();
547
+ for (const [from, uses] of vanishedByBoundary) {
548
+ const verbs = new Set(uses.map((use) => use.does));
549
+ if (!isStorageVerbs(verbs)) {
550
+ continue;
551
+ }
552
+ for (const [to, madeUses] of undeclaredByBoundary) {
553
+ if (!sameSystem(from, to)) {
554
+ continue;
555
+ }
556
+ if (!sameVerbs(verbs, new Set(madeUses.map((use) => use.does)))) {
557
+ continue;
558
+ }
559
+ if (!uses.every((use) => satisfiedBy(use, to))) {
560
+ continue;
561
+ }
562
+ pushCandidate(candidatesFor, from, {
563
+ from,
564
+ to,
565
+ does: verbs,
566
+ vanishedUses: uses,
567
+ undeclaredUses: madeUses
568
+ });
569
+ incrementCount(candidateCountAgainst, to);
570
+ }
571
+ }
572
+ const pairings = [];
573
+ for (const [, candidates] of candidatesFor) {
574
+ if (candidates.length !== 1) {
575
+ continue;
576
+ }
577
+ const [pairing] = candidates;
578
+ if (candidateCountAgainst.get(pairing.to) !== 1) {
579
+ continue;
580
+ }
581
+ pairings.push(pairing);
582
+ }
583
+ return pairings;
584
+ }
585
+ function pushCandidate(map, key, value) {
586
+ const existing = map.get(key);
587
+ if (existing === void 0) {
588
+ map.set(key, [value]);
589
+ return;
590
+ }
591
+ existing.push(value);
592
+ }
593
+ function incrementCount(map, key) {
594
+ map.set(key, (map.get(key) ?? 0) + 1);
595
+ }
596
+ function isStorageVerbs(verbs) {
597
+ return [...verbs].every((verb) => verb === "reads" || verb === "writes");
598
+ }
599
+ function sameVerbs(a, b) {
600
+ return a.size === b.size && [...a].every((verb) => b.has(verb));
601
+ }
602
+ function sameSystem(a, b) {
603
+ return a !== b && systemPrefix(a) === systemPrefix(b);
604
+ }
605
+ function systemPrefix(name) {
606
+ const colon = name.indexOf(":");
607
+ return colon === -1 ? name : name.slice(0, colon + 1);
608
+ }
609
+ function joinVerbs(verbs) {
610
+ const list = [...verbs];
611
+ if (list.length < 2) {
612
+ return list.join("");
613
+ }
614
+ return `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
615
+ }
616
+ function groupByBoundary(items) {
617
+ const groups = /* @__PURE__ */ new Map();
618
+ for (const item of items) {
619
+ const bucket = groups.get(item.boundary);
620
+ if (bucket === void 0) {
621
+ groups.set(item.boundary, [item]);
622
+ continue;
623
+ }
624
+ bucket.push(item);
625
+ }
626
+ return groups;
627
+ }
628
+ function unsettledNote(reached, declared, deployment) {
629
+ const variables = /* @__PURE__ */ new Set();
630
+ for (const made of reached) {
631
+ const reference = (0, import_ir_core.nameReference)(made.binding);
632
+ const variable = reference === null ? null : deployment.variableFor(reference);
633
+ if (made.does === declared.does && variable !== null) {
634
+ variables.add(variable);
635
+ }
636
+ }
637
+ if (variables.size === 0) {
638
+ return "";
639
+ }
640
+ const named = [...variables].sort().join(", ");
641
+ 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.`;
440
642
  }
441
643
  function effectMatches(declared, made) {
442
644
  return declared.does === made.does && (0, import_ir_core.namesBoundary)(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
@@ -452,10 +654,13 @@ function conditionMet(declared, turnsOn) {
452
654
  (guard) => guard.does === declared.at.does && (0, import_ir_core.namesBoundary)(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
453
655
  );
454
656
  }
455
- function toCodeOutcome(t, calls) {
657
+ function toCodeOutcome(t, calls, deployment) {
456
658
  const output = t.output;
457
- const effects = codeEffectsOf(t);
458
- const turnsOn = (0, import_behavioral_ir.boundaryGuardsOf)(t, calls);
659
+ const effects = codeEffectsOf(t, deployment);
660
+ const turnsOn = (0, import_behavioral_ir.boundaryGuardsOf)(t, calls).map((guard) => ({
661
+ ...guard,
662
+ binding: (0, import_behavioral_ir.groundBinding)(guard.binding, deployment)
663
+ }));
459
664
  if (output.type === "response") {
460
665
  const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
461
666
  return {
@@ -489,7 +694,7 @@ function toCodeOutcome(t, calls) {
489
694
  }
490
695
  return null;
491
696
  }
492
- function codeEffectsOf(t) {
697
+ function codeEffectsOf(t, deployment) {
493
698
  const reached = [];
494
699
  for (const effect of t.effects) {
495
700
  if (effect.type !== "interaction") {
@@ -498,10 +703,11 @@ function codeEffectsOf(t) {
498
703
  if ((0, import_behavioral_ir.goesThroughRelation)(effect.interaction)) {
499
704
  continue;
500
705
  }
501
- const label = (0, import_ir_core.displayLabel)(effect.binding);
706
+ const binding = (0, import_behavioral_ir.groundBinding)(effect.binding, deployment);
707
+ const label = (0, import_ir_core.displayLabel)(binding);
502
708
  const touched = accessDetail(effect.interaction);
503
709
  for (const does of (0, import_behavioral_ir.relationsOf)(effect.interaction)) {
504
- reached.push({ does, binding: effect.binding, label, ...touched });
710
+ reached.push({ does, binding, label, ...touched });
505
711
  }
506
712
  }
507
713
  return reached;
package/dist/index.js CHANGED
@@ -3,7 +3,9 @@ import {
3
3
  BOUNDARY_ROLE,
4
4
  boundaryCalls,
5
5
  boundaryGuardsOf,
6
+ deploymentOf,
6
7
  goesThroughRelation,
8
+ groundBinding,
7
9
  relationsOf,
8
10
  summaryRef,
9
11
  withDeclaredDelivery
@@ -14,6 +16,7 @@ import {
14
16
  boundaryKey,
15
17
  displayLabel,
16
18
  EVERY_FIELD,
19
+ nameReference,
17
20
  namesBoundary,
18
21
  pairingKey,
19
22
  ruleBoundaryMatchesKey,
@@ -25,6 +28,7 @@ function checkIntentAgreement(intents, code) {
25
28
  const unchecked = [];
26
29
  const codeByBoundary = indexCodeByBoundary(code);
27
30
  const boundaryByName = indexBoundaryIntentsByName(intents);
31
+ const deploymentOfUnit = deploymentOf(code);
28
32
  for (const intent of intents) {
29
33
  if (intent.kind === "prd") {
30
34
  const result2 = checkPrdCoverage(intent, boundaryByName);
@@ -32,7 +36,11 @@ function checkIntentAgreement(intents, code) {
32
36
  checked.push(result2.checked);
33
37
  continue;
34
38
  }
35
- const result = checkBoundaryIntent(intent, codeByBoundary);
39
+ const result = checkBoundaryIntent(
40
+ intent,
41
+ codeByBoundary,
42
+ deploymentOfUnit
43
+ );
36
44
  findings.push(...withProvenance(result.findings, intent.source));
37
45
  checked.push(...result.checked);
38
46
  unchecked.push(...result.unchecked);
@@ -75,7 +83,7 @@ function checkOutcomesDescribed(intents) {
75
83
  }
76
84
  return findings;
77
85
  }
78
- function checkBoundaryIntent(intent, codeByBoundary) {
86
+ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit) {
79
87
  const key = pairingKey(intent.boundary);
80
88
  if (key === null) {
81
89
  return {
@@ -128,7 +136,9 @@ function checkBoundaryIntent(intent, codeByBoundary) {
128
136
  }
129
137
  const findings = [];
130
138
  for (const impl of impls) {
131
- findings.push(...compareIntentToImpl(intent, impl, label));
139
+ findings.push(
140
+ ...compareIntentToImpl(intent, impl, label, deploymentOfUnit(impl))
141
+ );
132
142
  }
133
143
  return {
134
144
  findings,
@@ -312,26 +322,41 @@ function indexCodeByBoundary(code) {
312
322
  }
313
323
  return byKey;
314
324
  }
315
- function compareIntentToImpl(intent, impl, boundary) {
325
+ function compareIntentToImpl(intent, impl, boundary, deployment) {
316
326
  const findings = [];
317
327
  const ref = codeRef(impl);
318
328
  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);
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
+ );
333
+ const vanished = [];
334
+ const undeclared = [];
321
335
  for (const outcome of intent.outcomes) {
322
336
  const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
323
337
  for (const effect of outcome.effects) {
324
338
  if (reached.some((made) => effectMatches(effect, made))) {
325
339
  continue;
326
340
  }
327
- findings.push({
341
+ const finding = {
328
342
  kind: "uncoveredOutcome",
329
343
  severity: "error",
330
344
  boundary,
331
345
  intent: { name: intent.name, outcomeId: outcome.id },
332
346
  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
- });
347
+ 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)}`
348
+ };
349
+ findings.push(finding);
350
+ if (neverTouchesBoundary(effect.names, everyEffect)) {
351
+ vanished.push({
352
+ finding,
353
+ boundary: effect.names,
354
+ does: effect.does,
355
+ kind: "effect",
356
+ effect,
357
+ reached
358
+ });
359
+ }
335
360
  }
336
361
  if (outcome.kind === "effect") {
337
362
  continue;
@@ -358,14 +383,32 @@ function compareIntentToImpl(intent, impl, boundary) {
358
383
  const unmet = stated.find(
359
384
  (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
360
385
  );
361
- findings.push({
386
+ const finding = {
362
387
  kind: "uncoveredOutcome",
363
388
  severity: "error",
364
389
  boundary,
365
390
  intent: { name: intent.name, outcomeId: outcome.id },
366
391
  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
- });
392
+ message: unmetConditionMessage(
393
+ intent,
394
+ outcome,
395
+ boundary,
396
+ impl,
397
+ unmet,
398
+ everyEffect
399
+ )
400
+ };
401
+ findings.push(finding);
402
+ if (unmet !== void 0 && neverTouchesBoundary(unmet.at.names, everyEffect)) {
403
+ vanished.push({
404
+ finding,
405
+ boundary: unmet.at.names,
406
+ does: unmet.at.does,
407
+ kind: "condition",
408
+ unmet,
409
+ ending
410
+ });
411
+ }
369
412
  continue;
370
413
  }
371
414
  const declaredBody = outcome.body;
@@ -419,16 +462,178 @@ function compareIntentToImpl(intent, impl, boundary) {
419
462
  continue;
420
463
  }
421
464
  said.add(spelled);
422
- findings.push({
465
+ const finding = {
423
466
  kind: "undeclaredOutcome",
424
467
  severity: "info",
425
468
  boundary,
426
469
  intent: { name: intent.name },
427
470
  code: ref,
428
471
  message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
472
+ };
473
+ findings.push(finding);
474
+ undeclared.push({ finding, boundary: made.label, does: made.does });
475
+ }
476
+ return foldRenamedBoundaries(
477
+ intent,
478
+ impl,
479
+ boundary,
480
+ ref,
481
+ findings,
482
+ vanished,
483
+ undeclared
484
+ );
485
+ }
486
+ function neverTouchesBoundary(names, everyEffect) {
487
+ return everyEffect.every((made) => !namesBoundary(names, made.binding));
488
+ }
489
+ function unmetConditionMessage(intent, outcome, boundary, impl, unmet, everyEffect) {
490
+ const declared = `Intent "${intent.name}" declares ${describeOutcome(outcome)} at ${boundary} when ${unmet?.said ?? outcome.when}`;
491
+ if (unmet !== void 0 && neverTouchesBoundary(unmet.at.names, everyEffect)) {
492
+ return `${declared}; ${impl.identity.name} never ${unmet.at.does} ${unmet.at.names}.`;
493
+ }
494
+ return `${declared}; ${impl.identity.name} produces it on a different condition.`;
495
+ }
496
+ function satisfiedBy(use, candidate) {
497
+ if (use.kind === "effect") {
498
+ return use.reached.some(
499
+ (made) => effectMatches({ ...use.effect, names: candidate }, made)
500
+ );
501
+ }
502
+ return use.ending.some(
503
+ (co) => conditionMet(
504
+ { ...use.unmet, at: { ...use.unmet.at, names: candidate } },
505
+ co.turnsOn
506
+ )
507
+ );
508
+ }
509
+ function foldRenamedBoundaries(intent, impl, boundary, ref, findings, vanished, undeclared) {
510
+ const vanishedByBoundary = groupByBoundary(vanished);
511
+ const undeclaredByBoundary = groupByBoundary(undeclared);
512
+ const pairings = matchRenamedBoundaries(
513
+ vanishedByBoundary,
514
+ undeclaredByBoundary
515
+ );
516
+ if (pairings.length === 0) {
517
+ return findings;
518
+ }
519
+ const explained = /* @__PURE__ */ new Set();
520
+ const renamed = [];
521
+ for (const pairing of pairings) {
522
+ for (const use of pairing.vanishedUses) {
523
+ explained.add(use.finding);
524
+ }
525
+ for (const use of pairing.undeclaredUses) {
526
+ explained.add(use.finding);
527
+ }
528
+ renamed.push({
529
+ kind: "renamedBoundary",
530
+ severity: "error",
531
+ boundary,
532
+ intent: { name: intent.name },
533
+ code: ref,
534
+ 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.`
429
535
  });
430
536
  }
431
- return findings;
537
+ return [...findings.filter((finding) => !explained.has(finding)), ...renamed];
538
+ }
539
+ function matchRenamedBoundaries(vanishedByBoundary, undeclaredByBoundary) {
540
+ const candidatesFor = /* @__PURE__ */ new Map();
541
+ const candidateCountAgainst = /* @__PURE__ */ new Map();
542
+ for (const [from, uses] of vanishedByBoundary) {
543
+ const verbs = new Set(uses.map((use) => use.does));
544
+ if (!isStorageVerbs(verbs)) {
545
+ continue;
546
+ }
547
+ for (const [to, madeUses] of undeclaredByBoundary) {
548
+ if (!sameSystem(from, to)) {
549
+ continue;
550
+ }
551
+ if (!sameVerbs(verbs, new Set(madeUses.map((use) => use.does)))) {
552
+ continue;
553
+ }
554
+ if (!uses.every((use) => satisfiedBy(use, to))) {
555
+ continue;
556
+ }
557
+ pushCandidate(candidatesFor, from, {
558
+ from,
559
+ to,
560
+ does: verbs,
561
+ vanishedUses: uses,
562
+ undeclaredUses: madeUses
563
+ });
564
+ incrementCount(candidateCountAgainst, to);
565
+ }
566
+ }
567
+ const pairings = [];
568
+ for (const [, candidates] of candidatesFor) {
569
+ if (candidates.length !== 1) {
570
+ continue;
571
+ }
572
+ const [pairing] = candidates;
573
+ if (candidateCountAgainst.get(pairing.to) !== 1) {
574
+ continue;
575
+ }
576
+ pairings.push(pairing);
577
+ }
578
+ return pairings;
579
+ }
580
+ function pushCandidate(map, key, value) {
581
+ const existing = map.get(key);
582
+ if (existing === void 0) {
583
+ map.set(key, [value]);
584
+ return;
585
+ }
586
+ existing.push(value);
587
+ }
588
+ function incrementCount(map, key) {
589
+ map.set(key, (map.get(key) ?? 0) + 1);
590
+ }
591
+ function isStorageVerbs(verbs) {
592
+ return [...verbs].every((verb) => verb === "reads" || verb === "writes");
593
+ }
594
+ function sameVerbs(a, b) {
595
+ return a.size === b.size && [...a].every((verb) => b.has(verb));
596
+ }
597
+ function sameSystem(a, b) {
598
+ return a !== b && systemPrefix(a) === systemPrefix(b);
599
+ }
600
+ function systemPrefix(name) {
601
+ const colon = name.indexOf(":");
602
+ return colon === -1 ? name : name.slice(0, colon + 1);
603
+ }
604
+ function joinVerbs(verbs) {
605
+ const list = [...verbs];
606
+ if (list.length < 2) {
607
+ return list.join("");
608
+ }
609
+ return `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
610
+ }
611
+ function groupByBoundary(items) {
612
+ const groups = /* @__PURE__ */ new Map();
613
+ for (const item of items) {
614
+ const bucket = groups.get(item.boundary);
615
+ if (bucket === void 0) {
616
+ groups.set(item.boundary, [item]);
617
+ continue;
618
+ }
619
+ bucket.push(item);
620
+ }
621
+ return groups;
622
+ }
623
+ function unsettledNote(reached, declared, deployment) {
624
+ const variables = /* @__PURE__ */ new Set();
625
+ for (const made of reached) {
626
+ const reference = nameReference(made.binding);
627
+ const variable = reference === null ? null : deployment.variableFor(reference);
628
+ if (made.does === declared.does && variable !== null) {
629
+ variables.add(variable);
630
+ }
631
+ }
632
+ if (variables.size === 0) {
633
+ return "";
634
+ }
635
+ const named = [...variables].sort().join(", ");
636
+ 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.`;
432
637
  }
433
638
  function effectMatches(declared, made) {
434
639
  return declared.does === made.does && namesBoundary(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
@@ -444,10 +649,13 @@ function conditionMet(declared, turnsOn) {
444
649
  (guard) => guard.does === declared.at.does && namesBoundary(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
445
650
  );
446
651
  }
447
- function toCodeOutcome(t, calls) {
652
+ function toCodeOutcome(t, calls, deployment) {
448
653
  const output = t.output;
449
- const effects = codeEffectsOf(t);
450
- const turnsOn = boundaryGuardsOf(t, calls);
654
+ const effects = codeEffectsOf(t, deployment);
655
+ const turnsOn = boundaryGuardsOf(t, calls).map((guard) => ({
656
+ ...guard,
657
+ binding: groundBinding(guard.binding, deployment)
658
+ }));
451
659
  if (output.type === "response") {
452
660
  const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
453
661
  return {
@@ -481,7 +689,7 @@ function toCodeOutcome(t, calls) {
481
689
  }
482
690
  return null;
483
691
  }
484
- function codeEffectsOf(t) {
692
+ function codeEffectsOf(t, deployment) {
485
693
  const reached = [];
486
694
  for (const effect of t.effects) {
487
695
  if (effect.type !== "interaction") {
@@ -490,10 +698,11 @@ function codeEffectsOf(t) {
490
698
  if (goesThroughRelation(effect.interaction)) {
491
699
  continue;
492
700
  }
493
- const label = displayLabel(effect.binding);
701
+ const binding = groundBinding(effect.binding, deployment);
702
+ const label = displayLabel(binding);
494
703
  const touched = accessDetail(effect.interaction);
495
704
  for (const does of relationsOf(effect.interaction)) {
496
- reached.push({ does, binding: effect.binding, label, ...touched });
705
+ reached.push({ does, binding, label, ...touched });
497
706
  }
498
707
  }
499
708
  return reached;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/checker-intent",
3
- "version": "0.21.0",
3
+ "version": "0.22.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.21.0",
51
- "@suss/intent-ir": "0.21.0",
52
- "@suss/ir-core": "0.21.0"
50
+ "@suss/behavioral-ir": "0.22.0",
51
+ "@suss/intent-ir": "0.22.0",
52
+ "@suss/ir-core": "0.22.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "tsup": "*",