@suss/checker-intent 0.21.1 → 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
@@ -335,20 +335,33 @@ function compareIntentToImpl(intent, impl, boundary, deployment) {
335
335
  const everyEffect = impl.transitions.flatMap(
336
336
  (t) => codeEffectsOf(t, deployment)
337
337
  );
338
+ const vanished = [];
339
+ const undeclared = [];
338
340
  for (const outcome of intent.outcomes) {
339
341
  const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
340
342
  for (const effect of outcome.effects) {
341
343
  if (reached.some((made) => effectMatches(effect, made))) {
342
344
  continue;
343
345
  }
344
- findings.push({
346
+ const finding = {
345
347
  kind: "uncoveredOutcome",
346
348
  severity: "error",
347
349
  boundary,
348
350
  intent: { name: intent.name, outcomeId: outcome.id },
349
351
  code: ref,
350
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)}`
351
- });
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
+ }
352
365
  }
353
366
  if (outcome.kind === "effect") {
354
367
  continue;
@@ -375,14 +388,32 @@ function compareIntentToImpl(intent, impl, boundary, deployment) {
375
388
  const unmet = stated.find(
376
389
  (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
377
390
  );
378
- findings.push({
391
+ const finding = {
379
392
  kind: "uncoveredOutcome",
380
393
  severity: "error",
381
394
  boundary,
382
395
  intent: { name: intent.name, outcomeId: outcome.id },
383
396
  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
- });
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
+ }
386
417
  continue;
387
418
  }
388
419
  const declaredBody = outcome.body;
@@ -436,16 +467,163 @@ function compareIntentToImpl(intent, impl, boundary, deployment) {
436
467
  continue;
437
468
  }
438
469
  said.add(spelled);
439
- findings.push({
470
+ const finding = {
440
471
  kind: "undeclaredOutcome",
441
472
  severity: "info",
442
473
  boundary,
443
474
  intent: { name: intent.name },
444
475
  code: ref,
445
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.`
446
540
  });
447
541
  }
448
- 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;
449
627
  }
450
628
  function unsettledNote(reached, declared, deployment) {
451
629
  const variables = /* @__PURE__ */ new Set();
package/dist/index.js CHANGED
@@ -330,20 +330,33 @@ function compareIntentToImpl(intent, impl, boundary, deployment) {
330
330
  const everyEffect = impl.transitions.flatMap(
331
331
  (t) => codeEffectsOf(t, deployment)
332
332
  );
333
+ const vanished = [];
334
+ const undeclared = [];
333
335
  for (const outcome of intent.outcomes) {
334
336
  const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
335
337
  for (const effect of outcome.effects) {
336
338
  if (reached.some((made) => effectMatches(effect, made))) {
337
339
  continue;
338
340
  }
339
- findings.push({
341
+ const finding = {
340
342
  kind: "uncoveredOutcome",
341
343
  severity: "error",
342
344
  boundary,
343
345
  intent: { name: intent.name, outcomeId: outcome.id },
344
346
  code: ref,
345
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)}`
346
- });
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
+ }
347
360
  }
348
361
  if (outcome.kind === "effect") {
349
362
  continue;
@@ -370,14 +383,32 @@ function compareIntentToImpl(intent, impl, boundary, deployment) {
370
383
  const unmet = stated.find(
371
384
  (c) => !ending.some((co) => conditionMet(c, co.turnsOn))
372
385
  );
373
- findings.push({
386
+ const finding = {
374
387
  kind: "uncoveredOutcome",
375
388
  severity: "error",
376
389
  boundary,
377
390
  intent: { name: intent.name, outcomeId: outcome.id },
378
391
  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
- });
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
+ }
381
412
  continue;
382
413
  }
383
414
  const declaredBody = outcome.body;
@@ -431,16 +462,163 @@ function compareIntentToImpl(intent, impl, boundary, deployment) {
431
462
  continue;
432
463
  }
433
464
  said.add(spelled);
434
- findings.push({
465
+ const finding = {
435
466
  kind: "undeclaredOutcome",
436
467
  severity: "info",
437
468
  boundary,
438
469
  intent: { name: intent.name },
439
470
  code: ref,
440
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.`
441
535
  });
442
536
  }
443
- 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;
444
622
  }
445
623
  function unsettledNote(reached, declared, deployment) {
446
624
  const variables = /* @__PURE__ */ new Set();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suss/checker-intent",
3
- "version": "0.21.1",
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.1",
51
- "@suss/intent-ir": "0.21.1",
52
- "@suss/ir-core": "0.21.1"
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": "*",