@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 +52 -2
- package/dist/index.cjs +214 -23
- package/dist/index.d.cts +8 -2
- package/dist/index.d.ts +8 -2
- package/dist/index.js +227 -23
- package/package.json +4 -4
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(
|
|
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
|
|
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:
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
|
289
|
-
|
|
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
|
|
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
|
|
387
|
-
|
|
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 {
|
|
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(
|
|
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
|
|
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:
|
|
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 (
|
|
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(
|
|
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
|
|
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
|
|
271
|
-
|
|
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
|
|
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
|
|
369
|
-
|
|
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.
|
|
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.
|
|
51
|
-
"@suss/intent-ir": "0.
|
|
52
|
-
"@suss/ir-core": "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": "*",
|