@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 +8 -1
- package/dist/index.cjs +226 -20
- package/dist/index.js +229 -20
- package/package.json +4 -4
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
51
|
-
"@suss/intent-ir": "0.
|
|
52
|
-
"@suss/ir-core": "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": "*",
|