@suss/checker-intent 0.33.0 → 0.34.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 +42 -31
- package/dist/index.cjs +626 -79
- package/dist/index.d.cts +170 -30
- package/dist/index.d.ts +170 -30
- package/dist/index.js +608 -65
- package/package.json +6 -5
package/dist/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
deploymentOf,
|
|
7
7
|
goesThroughRelation,
|
|
8
8
|
groundBinding,
|
|
9
|
+
readProcessMetadata,
|
|
9
10
|
relationsOf,
|
|
10
11
|
summaryRef,
|
|
11
12
|
withDeclaredDelivery,
|
|
@@ -24,6 +25,228 @@ import {
|
|
|
24
25
|
semanticsAgree
|
|
25
26
|
} from "@suss/ir-core";
|
|
26
27
|
|
|
28
|
+
// src/coveringTests.ts
|
|
29
|
+
import { readTestMetadata } from "@suss/behavioral-ir";
|
|
30
|
+
import { callSpellings, functionOf } from "@suss/checker";
|
|
31
|
+
function checkCoveringTests(claims, lookup) {
|
|
32
|
+
const verdicts = [];
|
|
33
|
+
const waiting = [];
|
|
34
|
+
for (const [at, claim] of claims.entries()) {
|
|
35
|
+
const found = lookup.test(claim.spelled);
|
|
36
|
+
if (!found.found) {
|
|
37
|
+
verdicts.push(finding(claim, "missingCoveringTest", found.message));
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
if (readTestMetadata(found.unit)?.skipped === true) {
|
|
41
|
+
verdicts.push(
|
|
42
|
+
finding(
|
|
43
|
+
claim,
|
|
44
|
+
"coveringTestSkipped",
|
|
45
|
+
"which is marked to be skipped, so it does not run"
|
|
46
|
+
)
|
|
47
|
+
);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const subject = subjectOf(claim, lookup);
|
|
51
|
+
if ("message" in subject) {
|
|
52
|
+
verdicts.push(finding(claim, "testMissesSubject", subject.message));
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
verdicts.push(null);
|
|
56
|
+
waiting.push({
|
|
57
|
+
at,
|
|
58
|
+
unit: found.unit,
|
|
59
|
+
test: functionOf(found.unit),
|
|
60
|
+
subject
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (waiting.length === 0) {
|
|
64
|
+
return verdicts.map((verdict) => verdict ?? { kind: "covered" });
|
|
65
|
+
}
|
|
66
|
+
const honouring = lookup.facts.reachedFromEach(
|
|
67
|
+
waiting.map((one) => one.test),
|
|
68
|
+
{ pastMocks: true }
|
|
69
|
+
);
|
|
70
|
+
const missed = waiting.filter(
|
|
71
|
+
(one) => pathInto(one.test, honouring.get(one.test), one.subject) === null && callOnSubjectClass(one.test, one.subject, lookup, true) === null
|
|
72
|
+
);
|
|
73
|
+
const ignoring = missed.length === 0 ? /* @__PURE__ */ new Map() : lookup.facts.reachedFromEach(missed.map((one) => one.test));
|
|
74
|
+
for (const one of waiting) {
|
|
75
|
+
const claim = claims[one.at];
|
|
76
|
+
if (!missed.includes(one)) {
|
|
77
|
+
verdicts[one.at] = { kind: "covered" };
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
const reached = ignoring.get(one.test);
|
|
81
|
+
const throughMock = pathInto(one.test, reached, one.subject) ?? callOnSubjectClass(one.test, one.subject, lookup, false);
|
|
82
|
+
if (throughMock !== null) {
|
|
83
|
+
verdicts[one.at] = finding(
|
|
84
|
+
claim,
|
|
85
|
+
"testMissesSubject",
|
|
86
|
+
`which reaches ${one.subject.label} only through a call its mocks replace (${mocksOf(lookup, one.test).join(", ")}), by ${callSpellings(throughMock).join(" -> ")}`
|
|
87
|
+
);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
const unfollowed = unfollowedOnTheWay(one.unit, one.test, reached, lookup);
|
|
91
|
+
const nameMatches = unfollowedToSubject(one.unit, one.subject, lookup);
|
|
92
|
+
verdicts[one.at] = unfollowed.length === 0 && nameMatches.length === 0 ? finding(
|
|
93
|
+
claim,
|
|
94
|
+
"testMissesSubject",
|
|
95
|
+
`which never reaches ${one.subject.label}`
|
|
96
|
+
) : notChecked(claim, one.subject, nameMatches, unfollowed);
|
|
97
|
+
}
|
|
98
|
+
return verdicts.map((verdict) => verdict ?? { kind: "covered" });
|
|
99
|
+
}
|
|
100
|
+
var CALLS_LISTED = 3;
|
|
101
|
+
function notChecked(claim, subject, nameMatches, unfollowed) {
|
|
102
|
+
const calls = nameMatches.length > 0 ? nameMatches : unfollowed;
|
|
103
|
+
const listed = calls.slice(0, CALLS_LISTED).join(", ");
|
|
104
|
+
const more = calls.length > CALLS_LISTED ? `, and ${calls.length - CALLS_LISTED} more` : "";
|
|
105
|
+
const why = nameMatches.length > 0 ? `it calls ${listed}${more}, and suss could not follow that call to ${subject.label}` : `it never reaches ${subject.label} through the calls suss followed, and suss could not follow ${listed}${more}`;
|
|
106
|
+
return {
|
|
107
|
+
kind: "unchecked",
|
|
108
|
+
unchecked: {
|
|
109
|
+
intent: claim.prd.title,
|
|
110
|
+
reason: "unfollowedCall",
|
|
111
|
+
scenario: claim.label,
|
|
112
|
+
coveredBy: claim.spelled.spelledAs,
|
|
113
|
+
detail: `Scenario ${claim.label} lists the test "${claim.spelled.spelledAs}": ${why}.`
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
function unfollowedOnTheWay(test, key, reached, lookup) {
|
|
118
|
+
const units = [
|
|
119
|
+
test,
|
|
120
|
+
...[...reached?.functions.keys() ?? []].filter((fn) => fn !== key).flatMap((fn) => lookup.facts.units.get(fn) ?? [])
|
|
121
|
+
];
|
|
122
|
+
const found = /* @__PURE__ */ new Set();
|
|
123
|
+
for (const unit of units) {
|
|
124
|
+
for (const gap of unit.gaps) {
|
|
125
|
+
if (gap.type === "unfollowedCall" && gap.callee !== void 0) {
|
|
126
|
+
found.add(gap.callee);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...found];
|
|
131
|
+
}
|
|
132
|
+
function subjectOf(claim, lookup) {
|
|
133
|
+
if (claim.subjects.length === 0) {
|
|
134
|
+
return {
|
|
135
|
+
message: "and the PRD links to no boundary a test could be checked against; say what it has to reach under about"
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const found = [];
|
|
139
|
+
for (const spelledAs of claim.subjects) {
|
|
140
|
+
const one = lookup.subject(spelledAs);
|
|
141
|
+
if (!one.found) {
|
|
142
|
+
return {
|
|
143
|
+
message: `and what it has to reach, ${spelledAs}, is nothing in these summaries: ${one.message.replace(/\.$/, "")}`
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
found.push({ ...one, classes: one.classes ?? [] });
|
|
147
|
+
}
|
|
148
|
+
return {
|
|
149
|
+
target: {
|
|
150
|
+
functions: found.flatMap((one) => one.target.functions),
|
|
151
|
+
keys: found.flatMap((one) => one.target.keys),
|
|
152
|
+
at: found.flatMap((one) => one.target.at ?? [])
|
|
153
|
+
},
|
|
154
|
+
label: found.map((one) => one.label).join(" or "),
|
|
155
|
+
classes: found.flatMap((one) => one.classes)
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
function pathInto(test, reached, subject) {
|
|
159
|
+
const into = [...subject.target.functions, ...subject.target.at ?? []];
|
|
160
|
+
if (into.includes(test)) {
|
|
161
|
+
return [];
|
|
162
|
+
}
|
|
163
|
+
const paths = [
|
|
164
|
+
...into.map((fn) => reached?.functions.get(fn)),
|
|
165
|
+
...subject.target.keys.map((key) => reached?.keys.get(key))
|
|
166
|
+
].filter((path) => path !== void 0);
|
|
167
|
+
if (paths.length === 0) {
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
return paths.reduce(
|
|
171
|
+
(shortest, path) => path.length < shortest.length ? path : shortest
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
function callOnSubjectClass(test, subject, lookup, honourMocks) {
|
|
175
|
+
const classes = new Set(subject.classes.map(classKey));
|
|
176
|
+
if (classes.size === 0) {
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
const units = lookup.facts.units.get(test) ?? [];
|
|
180
|
+
const mocks = honourMocks ? units.flatMap((unit) => readTestMetadata(unit)?.mocks ?? []) : [];
|
|
181
|
+
for (const effect of units.flatMap(
|
|
182
|
+
(unit) => unit.transitions.flatMap((transition) => transition.effects)
|
|
183
|
+
)) {
|
|
184
|
+
if (effect.type !== "invocation" || effect.receiverClass === void 0 || !classes.has(classKey(effect.receiverClass))) {
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const method = lastName(effect.callee);
|
|
188
|
+
if (mocks.some((mock) => replaces(mock, effect.receiverClass, method))) {
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
return [{ callee: effect.callee, to: null, recorded: "written" }];
|
|
192
|
+
}
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
function classKey(sentTo) {
|
|
196
|
+
return `${sentTo.file}#${sentTo.name}`;
|
|
197
|
+
}
|
|
198
|
+
function replaces(mock, sentTo, method) {
|
|
199
|
+
if (mock.module === void 0) {
|
|
200
|
+
return mock.name === method;
|
|
201
|
+
}
|
|
202
|
+
return mock.module === sentTo?.file && (mock.name === void 0 || mock.name === method);
|
|
203
|
+
}
|
|
204
|
+
function unfollowedToSubject(test, subject, lookup) {
|
|
205
|
+
const names = /* @__PURE__ */ new Set([
|
|
206
|
+
...[...subject.target.functions, ...subject.target.at ?? []].flatMap(
|
|
207
|
+
(fn) => (lookup.facts.units.get(fn) ?? []).map(
|
|
208
|
+
(unit) => lastName(unit.identity.name)
|
|
209
|
+
)
|
|
210
|
+
),
|
|
211
|
+
...subject.target.keys.map(lastName)
|
|
212
|
+
]);
|
|
213
|
+
const unfollowed = test.transitions.flatMap(
|
|
214
|
+
(transition) => transition.effects.flatMap(
|
|
215
|
+
(effect) => effect.type === "invocation" && effect.summary === void 0 && names.has(lastName(effect.callee)) ? [effect.callee] : []
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
return [...new Set(unfollowed)];
|
|
219
|
+
}
|
|
220
|
+
function lastName(spelled) {
|
|
221
|
+
return spelled.split(/::|\./).at(-1) ?? spelled;
|
|
222
|
+
}
|
|
223
|
+
function mocksOf(lookup, test) {
|
|
224
|
+
const units = lookup.facts.units.get(test) ?? [];
|
|
225
|
+
return [
|
|
226
|
+
...new Set(
|
|
227
|
+
units.flatMap(
|
|
228
|
+
(unit) => (readTestMetadata(unit)?.mocks ?? []).map((mock) => mock.written)
|
|
229
|
+
)
|
|
230
|
+
)
|
|
231
|
+
];
|
|
232
|
+
}
|
|
233
|
+
function finding(claim, kind, why) {
|
|
234
|
+
return {
|
|
235
|
+
kind: "finding",
|
|
236
|
+
finding: {
|
|
237
|
+
kind,
|
|
238
|
+
severity: "warning",
|
|
239
|
+
boundary: `prd:${claim.prd.title}`,
|
|
240
|
+
intent: { name: claim.prd.title },
|
|
241
|
+
scenario: {
|
|
242
|
+
...claim.scenarioTitle !== null ? { title: claim.scenarioTitle } : {},
|
|
243
|
+
coveredBy: claim.spelled.spelledAs
|
|
244
|
+
},
|
|
245
|
+
message: `Scenario ${claim.label} in PRD "${claim.prd.title}" lists the test "${claim.spelled.spelledAs}", ${why}.`
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
27
250
|
// src/receivedInput.ts
|
|
28
251
|
import { boundaryInputReads, formatPath } from "@suss/behavioral-ir";
|
|
29
252
|
function overlaps(a, b) {
|
|
@@ -59,7 +282,7 @@ function checkReceivesBlock(pairing) {
|
|
|
59
282
|
return [];
|
|
60
283
|
}
|
|
61
284
|
const read = result.reads.paths;
|
|
62
|
-
const
|
|
285
|
+
const unread2 = declared.filter(
|
|
63
286
|
(field) => !read.some((path) => overlaps(path, field.path))
|
|
64
287
|
);
|
|
65
288
|
const alreadyDescribed = DESCRIBED_WITHOUT_LISTING[pairing.intent.boundary.semantics.name];
|
|
@@ -67,7 +290,7 @@ function checkReceivesBlock(pairing) {
|
|
|
67
290
|
(path) => !declared.some((field) => overlaps(path, field.path)) && !alreadyDescribed(declared, path)
|
|
68
291
|
);
|
|
69
292
|
return [
|
|
70
|
-
...
|
|
293
|
+
...unread2.map((field) => unreadFinding(pairing, field)),
|
|
71
294
|
...undeclaredFindings(pairing, undeclared)
|
|
72
295
|
];
|
|
73
296
|
}
|
|
@@ -81,7 +304,8 @@ var DESCRIBED_WITHOUT_LISTING = {
|
|
|
81
304
|
"graphql-resolver": LISTS_EVERYTHING,
|
|
82
305
|
"graphql-operation": LISTS_EVERYTHING,
|
|
83
306
|
"runtime-config": LISTS_EVERYTHING,
|
|
84
|
-
metric: LISTS_EVERYTHING
|
|
307
|
+
metric: LISTS_EVERYTHING,
|
|
308
|
+
io: LISTS_EVERYTHING
|
|
85
309
|
};
|
|
86
310
|
function unreadFinding(pairing, field) {
|
|
87
311
|
const needsIt = field.required ? " and needs it" : "";
|
|
@@ -115,11 +339,107 @@ function undeclaredFindings(pairing, undeclared) {
|
|
|
115
339
|
return findings;
|
|
116
340
|
}
|
|
117
341
|
|
|
342
|
+
// src/valueSources.ts
|
|
343
|
+
import { boundarySourcePathOf, formatPath as formatPath2 } from "@suss/behavioral-ir";
|
|
344
|
+
function checkValueSources(claim, matched) {
|
|
345
|
+
const checked = { findings: [], unchecked: [] };
|
|
346
|
+
for (const source of claim.effect.from ?? []) {
|
|
347
|
+
const found = matched.flatMap(
|
|
348
|
+
(made) => slotsFor(claim.effect, source.column, made).flatMap(
|
|
349
|
+
(slot) => slot.from.map((written) => {
|
|
350
|
+
const ref = unconverted(written);
|
|
351
|
+
return { ref, path: pathOf(claim, ref), line: made.line };
|
|
352
|
+
})
|
|
353
|
+
)
|
|
354
|
+
);
|
|
355
|
+
if (found.some((one) => samePath(one.path, source.path))) {
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const known = found.filter(
|
|
359
|
+
(one) => one.path !== null || one.ref.type === "literal"
|
|
360
|
+
);
|
|
361
|
+
if (known.length > 0) {
|
|
362
|
+
checked.findings.push(elsewhere(claim, source, known));
|
|
363
|
+
continue;
|
|
364
|
+
}
|
|
365
|
+
checked.unchecked.push(unread(claim, source, found));
|
|
366
|
+
}
|
|
367
|
+
return checked;
|
|
368
|
+
}
|
|
369
|
+
function slotsFor(effect, column, made) {
|
|
370
|
+
const kinds = /* @__PURE__ */ new Set([
|
|
371
|
+
...effect.by.includes(column) ? ["selector"] : [],
|
|
372
|
+
...effect.fields.includes(column) ? ["field"] : []
|
|
373
|
+
]);
|
|
374
|
+
return (made.slots ?? []).filter(
|
|
375
|
+
(slot) => slot.at.name === column && kinds.has(slot.at.slot)
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
function unconverted(ref) {
|
|
379
|
+
return ref.type === "derived" && ref.derivation.type === "methodCall" ? unconverted(ref.from) : ref;
|
|
380
|
+
}
|
|
381
|
+
function pathOf(claim, ref) {
|
|
382
|
+
const binding = claim.intent.boundary;
|
|
383
|
+
return ref.type === "input" ? boundarySourcePathOf(claim.impl, binding, ref) : null;
|
|
384
|
+
}
|
|
385
|
+
function samePath(code, declared) {
|
|
386
|
+
if (code === null || code.length !== declared.length) {
|
|
387
|
+
return false;
|
|
388
|
+
}
|
|
389
|
+
const left = comparable(code);
|
|
390
|
+
const right = comparable(declared);
|
|
391
|
+
return left.every((segment, at) => segment === right[at]);
|
|
392
|
+
}
|
|
393
|
+
function spell(one) {
|
|
394
|
+
if (one.path !== null) {
|
|
395
|
+
return `input.${formatPath2(one.path)}`;
|
|
396
|
+
}
|
|
397
|
+
return one.ref.type === "literal" ? `the literal ${JSON.stringify(one.ref.value)}` : "somewhere it could not follow";
|
|
398
|
+
}
|
|
399
|
+
function doing(claim) {
|
|
400
|
+
const { effect, outcome } = claim;
|
|
401
|
+
return `${outcome.id} ${effect.does} ${effect.names}`;
|
|
402
|
+
}
|
|
403
|
+
function elsewhere(claim, source, known) {
|
|
404
|
+
const said = [
|
|
405
|
+
...new Set(
|
|
406
|
+
known.map(
|
|
407
|
+
(one) => one.line === void 0 ? spell(one) : `${spell(one)} (in the transition at line ${one.line})`
|
|
408
|
+
)
|
|
409
|
+
)
|
|
410
|
+
];
|
|
411
|
+
return {
|
|
412
|
+
kind: "valueFromElsewhere",
|
|
413
|
+
severity: "error",
|
|
414
|
+
boundary: claim.boundary,
|
|
415
|
+
intent: { name: claim.intent.name, outcomeId: claim.outcome.id },
|
|
416
|
+
code: claim.code,
|
|
417
|
+
message: `Intent "${claim.intent.name}" says ${doing(claim)} with ${source.column} taken from input.${formatPath2(source.path)}; ${claim.impl.identity.name} takes it from ${said.join(" and ")}. Take the value from the source the intent gives, or correct the document.`
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
function unread(claim, source, found) {
|
|
421
|
+
const stopped = [
|
|
422
|
+
...new Set(
|
|
423
|
+
found.flatMap(
|
|
424
|
+
(one) => one.ref.type === "unresolved" ? [one.ref.sourceText] : []
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
];
|
|
428
|
+
const why = stopped.length === 0 ? `nothing in the summary says where ${source.column} comes from` : `the walk from ${source.column} stopped at ${stopped.map((text) => `\`${text}\``).join(" and ")}, which it cannot follow`;
|
|
429
|
+
return {
|
|
430
|
+
intent: claim.intent.name,
|
|
431
|
+
reason: "unreadValue",
|
|
432
|
+
outcomeId: claim.outcome.id,
|
|
433
|
+
detail: `${doing(claim)} with ${source.column} taken from input.${formatPath2(source.path)}: ${why}.`
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
118
437
|
// src/index.ts
|
|
119
|
-
function checkIntentAgreement(intents, code) {
|
|
438
|
+
function checkIntentAgreement(intents, code, tests) {
|
|
120
439
|
const findings = [];
|
|
121
440
|
const checked = [];
|
|
122
441
|
const unchecked = [];
|
|
442
|
+
const claims = [];
|
|
123
443
|
const codeByBoundary = indexCodeByBoundary(code);
|
|
124
444
|
const boundaryByName = indexBoundaryIntentsByName(intents);
|
|
125
445
|
const deploymentOfUnit = deploymentOf(code);
|
|
@@ -129,6 +449,7 @@ function checkIntentAgreement(intents, code) {
|
|
|
129
449
|
const result2 = checkPrdCoverage(intent, boundaryByName);
|
|
130
450
|
findings.push(...withProvenance(result2.findings, intent.source));
|
|
131
451
|
checked.push(result2.checked);
|
|
452
|
+
claims.push(...result2.claims);
|
|
132
453
|
continue;
|
|
133
454
|
}
|
|
134
455
|
const result = checkBoundaryIntent(
|
|
@@ -141,9 +462,44 @@ function checkIntentAgreement(intents, code) {
|
|
|
141
462
|
checked.push(...result.checked);
|
|
142
463
|
unchecked.push(...result.unchecked);
|
|
143
464
|
}
|
|
465
|
+
if (tests !== void 0) {
|
|
466
|
+
const scenarioTests = checkScenarioTests(claims, tests);
|
|
467
|
+
findings.push(...scenarioTests.findings);
|
|
468
|
+
unchecked.push(...scenarioTests.unchecked);
|
|
469
|
+
}
|
|
144
470
|
findings.push(...checkOutcomesDescribed(intents));
|
|
145
471
|
return { findings, checked, unchecked };
|
|
146
472
|
}
|
|
473
|
+
function checkScenarioTests(claims, tests) {
|
|
474
|
+
const verdicts = checkCoveringTests(
|
|
475
|
+
claims.map((one) => one.claim),
|
|
476
|
+
tests
|
|
477
|
+
);
|
|
478
|
+
const failed = /* @__PURE__ */ new Set();
|
|
479
|
+
const findings = [];
|
|
480
|
+
const unchecked = [];
|
|
481
|
+
for (const [at, verdict] of verdicts.entries()) {
|
|
482
|
+
if (verdict.kind === "covered") {
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
const { claim, checked, scenario } = claims[at];
|
|
486
|
+
failed.add(`${checked.intent}\0${scenario}`);
|
|
487
|
+
if (verdict.kind === "finding") {
|
|
488
|
+
findings.push(...withProvenance([verdict.finding], claim.prd.source));
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
unchecked.push(verdict.unchecked);
|
|
492
|
+
}
|
|
493
|
+
const counted = /* @__PURE__ */ new Set();
|
|
494
|
+
for (const { checked, scenario } of claims) {
|
|
495
|
+
const key = `${checked.intent}\0${scenario}`;
|
|
496
|
+
if (!failed.has(key) && !counted.has(key)) {
|
|
497
|
+
counted.add(key);
|
|
498
|
+
checked.covered += 1;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
return { findings, unchecked };
|
|
502
|
+
}
|
|
147
503
|
function checkOutcomesDescribed(intents) {
|
|
148
504
|
const prds = intents.filter((intent) => intent.kind === "prd");
|
|
149
505
|
if (prds.length === 0) {
|
|
@@ -218,7 +574,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
|
|
|
218
574
|
message: `Intent "${intent.name}" declares boundary ${label} with ${intent.outcomes.length} outcome(s); no code produces this boundary.`
|
|
219
575
|
}
|
|
220
576
|
],
|
|
221
|
-
// The comparison ran
|
|
577
|
+
// The comparison ran, and finding no implementation is its result.
|
|
222
578
|
checked: [
|
|
223
579
|
{
|
|
224
580
|
kind: "boundary",
|
|
@@ -231,6 +587,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
|
|
|
231
587
|
};
|
|
232
588
|
}
|
|
233
589
|
const findings = [];
|
|
590
|
+
const unchecked = [];
|
|
234
591
|
for (const impl of impls) {
|
|
235
592
|
findings.push(
|
|
236
593
|
...compareIntentToImpl(
|
|
@@ -238,7 +595,8 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
|
|
|
238
595
|
impl,
|
|
239
596
|
label,
|
|
240
597
|
deploymentOfUnit(impl),
|
|
241
|
-
wrappersOfUnit(impl)
|
|
598
|
+
wrappersOfUnit(impl),
|
|
599
|
+
unchecked
|
|
242
600
|
)
|
|
243
601
|
);
|
|
244
602
|
}
|
|
@@ -252,7 +610,7 @@ function checkBoundaryIntent(intent, codeByBoundary, deploymentOfUnit, wrappersO
|
|
|
252
610
|
implementations: impls.map(codeRef)
|
|
253
611
|
}
|
|
254
612
|
],
|
|
255
|
-
unchecked
|
|
613
|
+
unchecked
|
|
256
614
|
};
|
|
257
615
|
}
|
|
258
616
|
function codeRef(impl) {
|
|
@@ -260,50 +618,76 @@ function codeRef(impl) {
|
|
|
260
618
|
}
|
|
261
619
|
function checkPrdCoverage(prd, boundaryByName) {
|
|
262
620
|
const findings = [];
|
|
263
|
-
|
|
264
|
-
|
|
621
|
+
const claims = [];
|
|
622
|
+
const checked = {
|
|
623
|
+
kind: "prd",
|
|
624
|
+
intent: prd.title,
|
|
625
|
+
scenarios: prd.scenarios.length,
|
|
626
|
+
resolved: 0,
|
|
627
|
+
covered: 0,
|
|
628
|
+
unlinked: 0
|
|
629
|
+
};
|
|
630
|
+
const fallbackSubjects = linkedBoundaryKeys(prd, boundaryByName);
|
|
265
631
|
prd.scenarios.forEach((scenario, index) => {
|
|
266
632
|
const label = scenarioLabel(scenario.title, index);
|
|
267
|
-
|
|
268
|
-
|
|
633
|
+
for (const spelled of scenario.coveredBy) {
|
|
634
|
+
claims.push({
|
|
635
|
+
claim: {
|
|
636
|
+
prd,
|
|
637
|
+
scenarioTitle: scenario.title,
|
|
638
|
+
label,
|
|
639
|
+
spelled,
|
|
640
|
+
subjects: scenario.about.length > 0 ? scenario.about : fallbackSubjects
|
|
641
|
+
},
|
|
642
|
+
checked,
|
|
643
|
+
scenario: index
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
if (scenario.link.length === 0 && scenario.coveredBy.length === 0) {
|
|
647
|
+
checked.unlinked += 1;
|
|
269
648
|
findings.push({
|
|
270
649
|
kind: "unlinkedScenario",
|
|
271
|
-
severity: "
|
|
650
|
+
severity: "warning",
|
|
272
651
|
boundary: prdBoundaryLabel(prd),
|
|
273
652
|
intent: { name: prd.title },
|
|
274
653
|
...scenario.title !== null ? { scenario: { title: scenario.title } } : {},
|
|
275
|
-
message: `Scenario ${label} in PRD "${prd.title}" has
|
|
654
|
+
message: `Scenario ${label} in PRD "${prd.title}" has neither a link to a boundary outcome nor a covering test, so nothing checks it; add a link, or list the test that covers it under coveredBy.`
|
|
276
655
|
});
|
|
277
656
|
return;
|
|
278
657
|
}
|
|
658
|
+
if (scenario.link.length === 0) {
|
|
659
|
+
return;
|
|
660
|
+
}
|
|
279
661
|
let allResolved = true;
|
|
280
662
|
for (const ref of scenario.link) {
|
|
281
|
-
const
|
|
663
|
+
const finding2 = resolveScenarioLink(
|
|
282
664
|
prd,
|
|
283
665
|
scenario.title,
|
|
284
666
|
label,
|
|
285
667
|
ref,
|
|
286
668
|
boundaryByName
|
|
287
669
|
);
|
|
288
|
-
if (
|
|
289
|
-
findings.push(
|
|
670
|
+
if (finding2 !== null) {
|
|
671
|
+
findings.push(finding2);
|
|
290
672
|
allResolved = false;
|
|
291
673
|
}
|
|
292
674
|
}
|
|
293
675
|
if (allResolved) {
|
|
294
|
-
resolved += 1;
|
|
676
|
+
checked.resolved += 1;
|
|
295
677
|
}
|
|
296
678
|
});
|
|
297
|
-
return {
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
679
|
+
return { findings, checked, claims };
|
|
680
|
+
}
|
|
681
|
+
function linkedBoundaryKeys(prd, boundaryByName) {
|
|
682
|
+
const keys = /* @__PURE__ */ new Set();
|
|
683
|
+
for (const ref of prd.scenarios.flatMap((scenario) => scenario.link)) {
|
|
684
|
+
const matches = boundaryByName.get(ref.split(".")[0]) ?? [];
|
|
685
|
+
const key = matches.length === 1 ? boundaryKey(matches[0].boundary) : null;
|
|
686
|
+
if (key !== null) {
|
|
687
|
+
keys.add(key);
|
|
305
688
|
}
|
|
306
|
-
}
|
|
689
|
+
}
|
|
690
|
+
return [...keys];
|
|
307
691
|
}
|
|
308
692
|
function resolveScenarioLink(prd, scenarioTitle, label, ref, boundaryByName) {
|
|
309
693
|
const dot = ref.indexOf(".");
|
|
@@ -340,8 +724,8 @@ function resolveScenarioLink(prd, scenarioTitle, label, ref, boundaryByName) {
|
|
|
340
724
|
return {
|
|
341
725
|
kind: "danglingScenarioLink",
|
|
342
726
|
severity: "warning",
|
|
343
|
-
// The intent resolved
|
|
344
|
-
//
|
|
727
|
+
// The intent resolved, so the finding is keyed on its boundary,
|
|
728
|
+
// where a narrow .sussignore rule can match it.
|
|
345
729
|
boundary: boundaryKey(target.boundary) ?? prdBoundaryLabel(prd),
|
|
346
730
|
intent: { name: prd.title },
|
|
347
731
|
scenario: scenarioRef,
|
|
@@ -389,11 +773,14 @@ function applyIntentSuppressions(findings, rules, opts = {}) {
|
|
|
389
773
|
return applySuppressionsToFindings(
|
|
390
774
|
findings,
|
|
391
775
|
rules,
|
|
392
|
-
(rule,
|
|
776
|
+
(rule, finding2) => {
|
|
393
777
|
if (rule.consumer !== void 0 || rule.provider !== void 0) {
|
|
394
778
|
return false;
|
|
395
779
|
}
|
|
396
|
-
|
|
780
|
+
if (rule.scenario !== void 0 && rule.scenario !== finding2.scenario?.title) {
|
|
781
|
+
return false;
|
|
782
|
+
}
|
|
783
|
+
return rule.boundary === void 0 || ruleBoundaryMatchesKey(rule.boundary, finding2.boundary);
|
|
397
784
|
},
|
|
398
785
|
opts
|
|
399
786
|
);
|
|
@@ -424,11 +811,12 @@ function indexCodeByBoundary(code) {
|
|
|
424
811
|
}
|
|
425
812
|
return byKey;
|
|
426
813
|
}
|
|
427
|
-
function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
814
|
+
function compareIntentToImpl(intent, impl, boundary, deployment, wrappers, unchecked) {
|
|
428
815
|
const findings = [];
|
|
429
816
|
const ref = codeRef(impl);
|
|
430
817
|
const calls = boundaryCalls(impl);
|
|
431
|
-
const
|
|
818
|
+
const returnIsExitCode = readProcessMetadata(impl)?.exitCodeFrom === "return";
|
|
819
|
+
const codeOutcomes = impl.transitions.map((t) => toCodeOutcome(t, calls, deployment, returnIsExitCode)).filter((o) => o !== null);
|
|
432
820
|
const everyEffect = impl.transitions.flatMap(
|
|
433
821
|
(t) => codeEffectsOf(t, deployment)
|
|
434
822
|
);
|
|
@@ -437,10 +825,27 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
437
825
|
for (const outcome of intent.outcomes) {
|
|
438
826
|
const reached = outcome.kind === "effect" ? everyEffect : codeOutcomes.filter((co) => outcomeMatches(outcome, co)).flatMap((co) => co.effects);
|
|
439
827
|
for (const effect of outcome.effects) {
|
|
440
|
-
|
|
828
|
+
const matched = reached.filter((made) => effectMatches(effect, made));
|
|
829
|
+
if (matched.length > 0) {
|
|
830
|
+
if (!writesDeclaredShape(effect, matched)) {
|
|
831
|
+
findings.push({
|
|
832
|
+
kind: "outcomeShapeMismatch",
|
|
833
|
+
severity: "error",
|
|
834
|
+
boundary,
|
|
835
|
+
intent: { name: intent.name, outcomeId: outcome.id },
|
|
836
|
+
code: ref,
|
|
837
|
+
message: `Intent "${intent.name}" says ${outcome.id} results in ${describeEffect(effect)} at ${boundary} with a declared shape; what ${impl.identity.name} writes there has an incompatible shape.`
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
const sources = checkValueSources(
|
|
841
|
+
{ intent, outcome, effect, impl, boundary, code: ref },
|
|
842
|
+
matched
|
|
843
|
+
);
|
|
844
|
+
findings.push(...sources.findings);
|
|
845
|
+
unchecked.push(...sources.unchecked);
|
|
441
846
|
continue;
|
|
442
847
|
}
|
|
443
|
-
const
|
|
848
|
+
const finding2 = {
|
|
444
849
|
kind: "uncoveredOutcome",
|
|
445
850
|
severity: "error",
|
|
446
851
|
boundary,
|
|
@@ -448,10 +853,10 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
448
853
|
code: ref,
|
|
449
854
|
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)}`
|
|
450
855
|
};
|
|
451
|
-
findings.push(
|
|
856
|
+
findings.push(finding2);
|
|
452
857
|
if (neverTouchesBoundary(effect.names, everyEffect)) {
|
|
453
858
|
vanished.push({
|
|
454
|
-
finding,
|
|
859
|
+
finding: finding2,
|
|
455
860
|
boundary: effect.names,
|
|
456
861
|
does: effect.does,
|
|
457
862
|
kind: "effect",
|
|
@@ -475,9 +880,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
475
880
|
});
|
|
476
881
|
continue;
|
|
477
882
|
}
|
|
478
|
-
const stated = outcome
|
|
479
|
-
(c) => c.at !== null
|
|
480
|
-
);
|
|
883
|
+
const stated = boundaryClauses(outcome);
|
|
481
884
|
const matches = stated.length === 0 ? ending : ending.filter(
|
|
482
885
|
(co) => stated.every((c) => conditionMet(c, co.turnsOn))
|
|
483
886
|
);
|
|
@@ -485,7 +888,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
485
888
|
const unmet = stated.find(
|
|
486
889
|
(c) => !ending.some((co) => conditionMet(c, co.turnsOn))
|
|
487
890
|
);
|
|
488
|
-
const
|
|
891
|
+
const finding2 = {
|
|
489
892
|
kind: "uncoveredOutcome",
|
|
490
893
|
severity: "error",
|
|
491
894
|
boundary,
|
|
@@ -500,10 +903,10 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
500
903
|
everyEffect
|
|
501
904
|
)
|
|
502
905
|
};
|
|
503
|
-
findings.push(
|
|
906
|
+
findings.push(finding2);
|
|
504
907
|
if (unmet !== void 0 && neverTouchesBoundary(unmet.at.names, everyEffect)) {
|
|
505
908
|
vanished.push({
|
|
506
|
-
finding,
|
|
909
|
+
finding: finding2,
|
|
507
910
|
boundary: unmet.at.names,
|
|
508
911
|
does: unmet.at.does,
|
|
509
912
|
kind: "condition",
|
|
@@ -556,7 +959,20 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
556
959
|
message: `${impl.identity.name} produces status ${status} at ${boundary}; intent "${intent.name}" does not declare it.`
|
|
557
960
|
});
|
|
558
961
|
}
|
|
559
|
-
const
|
|
962
|
+
const always = checkAlwaysEffects(
|
|
963
|
+
intent,
|
|
964
|
+
impl,
|
|
965
|
+
boundary,
|
|
966
|
+
codeOutcomes,
|
|
967
|
+
everyEffect,
|
|
968
|
+
deployment
|
|
969
|
+
);
|
|
970
|
+
findings.push(...always.findings);
|
|
971
|
+
vanished.push(...always.vanished);
|
|
972
|
+
const declaredEffects = [
|
|
973
|
+
...intent.outcomes.flatMap((o) => o.effects),
|
|
974
|
+
...intent.always.map((a) => a.effect)
|
|
975
|
+
];
|
|
560
976
|
const said = /* @__PURE__ */ new Set();
|
|
561
977
|
for (const made of everyEffect) {
|
|
562
978
|
const spelled = `${made.does} ${made.label}`;
|
|
@@ -564,7 +980,7 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
564
980
|
continue;
|
|
565
981
|
}
|
|
566
982
|
said.add(spelled);
|
|
567
|
-
const
|
|
983
|
+
const finding2 = {
|
|
568
984
|
kind: "undeclaredOutcome",
|
|
569
985
|
severity: "info",
|
|
570
986
|
boundary,
|
|
@@ -572,8 +988,8 @@ function compareIntentToImpl(intent, impl, boundary, deployment, wrappers) {
|
|
|
572
988
|
code: ref,
|
|
573
989
|
message: `${impl.identity.name} ${spelled} at ${boundary}; intent "${intent.name}" does not declare it.`
|
|
574
990
|
};
|
|
575
|
-
findings.push(
|
|
576
|
-
undeclared.push({ finding, boundary: made.label, does: made.does });
|
|
991
|
+
findings.push(finding2);
|
|
992
|
+
undeclared.push({ finding: finding2, boundary: made.label, does: made.does });
|
|
577
993
|
}
|
|
578
994
|
findings.push(
|
|
579
995
|
...checkReceivesBlock({ intent, impl, wrappers, boundary, code: ref })
|
|
@@ -639,7 +1055,7 @@ function foldRenamedBoundaries(intent, impl, boundary, ref, findings, vanished,
|
|
|
639
1055
|
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.`
|
|
640
1056
|
});
|
|
641
1057
|
}
|
|
642
|
-
return [...findings.filter((
|
|
1058
|
+
return [...findings.filter((finding2) => !explained.has(finding2)), ...renamed];
|
|
643
1059
|
}
|
|
644
1060
|
function matchRenamedBoundaries(vanishedByBoundary, undeclaredByBoundary) {
|
|
645
1061
|
const candidatesFor = /* @__PURE__ */ new Map();
|
|
@@ -743,6 +1159,16 @@ function unsettledNote(reached, declared, deployment) {
|
|
|
743
1159
|
function effectMatches(declared, made) {
|
|
744
1160
|
return declared.does === made.does && namesBoundary(declared.names, made.binding) && statesAll(declared.fields, made.fields) && statesAll(declared.by, made.by);
|
|
745
1161
|
}
|
|
1162
|
+
function writesDeclaredShape(declared, matched) {
|
|
1163
|
+
if (declared.shape === void 0) {
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
const shape = declared.shape;
|
|
1167
|
+
const stated = matched.flatMap(
|
|
1168
|
+
(made) => made.payload === void 0 ? [] : [made.payload]
|
|
1169
|
+
);
|
|
1170
|
+
return stated.length === 0 || stated.some((payload) => bodyShapesMatch(payload, shape) !== "nomatch");
|
|
1171
|
+
}
|
|
746
1172
|
function statesAll(declared, made) {
|
|
747
1173
|
if (declared.length === 0 || made.length === 0 || made.includes(EVERY_FIELD)) {
|
|
748
1174
|
return true;
|
|
@@ -754,22 +1180,81 @@ function conditionMet(declared, turnsOn) {
|
|
|
754
1180
|
(guard) => guard.does === declared.at.does && namesBoundary(declared.at.names, guard.binding) && (declared.finds === null || declared.finds === guard.polarity)
|
|
755
1181
|
);
|
|
756
1182
|
}
|
|
757
|
-
function toCodeOutcome(t, calls, deployment) {
|
|
1183
|
+
function toCodeOutcome(t, calls, deployment, returnIsExitCode) {
|
|
1184
|
+
const ending = endingOf(t, returnIsExitCode);
|
|
1185
|
+
if (ending === null) {
|
|
1186
|
+
return null;
|
|
1187
|
+
}
|
|
1188
|
+
return {
|
|
1189
|
+
...ending,
|
|
1190
|
+
effects: codeEffectsOf(t, deployment),
|
|
1191
|
+
turnsOn: boundaryGuardsOf(t, calls).map((guard) => ({
|
|
1192
|
+
...guard,
|
|
1193
|
+
binding: groundBinding(guard.binding, deployment)
|
|
1194
|
+
})),
|
|
1195
|
+
line: t.location.start
|
|
1196
|
+
};
|
|
1197
|
+
}
|
|
1198
|
+
function boundaryClauses(outcome) {
|
|
1199
|
+
return outcome.conditions.filter((c) => c.at !== null);
|
|
1200
|
+
}
|
|
1201
|
+
function producesOutcome(outcome, co) {
|
|
1202
|
+
if (outcome.kind === "effect") {
|
|
1203
|
+
return true;
|
|
1204
|
+
}
|
|
1205
|
+
return outcomeMatches(outcome, co) && boundaryClauses(outcome).every((c) => conditionMet(c, co.turnsOn));
|
|
1206
|
+
}
|
|
1207
|
+
function checkAlwaysEffects(intent, impl, boundary, codeOutcomes, everyEffect, deployment) {
|
|
1208
|
+
const findings = [];
|
|
1209
|
+
const vanished = [];
|
|
1210
|
+
if (intent.always.length === 0) {
|
|
1211
|
+
return { findings, vanished };
|
|
1212
|
+
}
|
|
1213
|
+
for (const co of codeOutcomes) {
|
|
1214
|
+
const produced = intent.outcomes.filter((o) => producesOutcome(o, co));
|
|
1215
|
+
if (produced.length === 0) {
|
|
1216
|
+
continue;
|
|
1217
|
+
}
|
|
1218
|
+
for (const always of intent.always) {
|
|
1219
|
+
if (produced.some((o) => always.except.includes(o.id))) {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (co.effects.some((made) => effectMatches(always.effect, made))) {
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
const outcome = produced.find((o) => o.kind !== "effect") ?? produced[0];
|
|
1226
|
+
const finding2 = {
|
|
1227
|
+
kind: "pathWithoutEffect",
|
|
1228
|
+
severity: "error",
|
|
1229
|
+
boundary,
|
|
1230
|
+
intent: { name: intent.name, outcomeId: outcome.id },
|
|
1231
|
+
code: codeRef(impl),
|
|
1232
|
+
message: `Intent "${intent.name}" says every outcome results in ${describeEffect(always.effect)} at ${boundary}; the transition of ${impl.identity.name} at line ${co.line}, which produces ${describeProduced(outcome)}, does not. Add the effect on that path, or list ${outcome.id} under except.${unsettledNote(co.effects, always.effect, deployment)}`
|
|
1233
|
+
};
|
|
1234
|
+
findings.push(finding2);
|
|
1235
|
+
if (neverTouchesBoundary(always.effect.names, everyEffect)) {
|
|
1236
|
+
vanished.push({
|
|
1237
|
+
finding: finding2,
|
|
1238
|
+
boundary: always.effect.names,
|
|
1239
|
+
does: always.effect.does,
|
|
1240
|
+
kind: "effect",
|
|
1241
|
+
effect: always.effect,
|
|
1242
|
+
reached: co.effects
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
return { findings, vanished };
|
|
1248
|
+
}
|
|
1249
|
+
function endingOf(t, returnIsExitCode = false) {
|
|
758
1250
|
const output = t.output;
|
|
759
|
-
const effects = codeEffectsOf(t, deployment);
|
|
760
|
-
const turnsOn = boundaryGuardsOf(t, calls).map((guard) => ({
|
|
761
|
-
...guard,
|
|
762
|
-
binding: groundBinding(guard.binding, deployment)
|
|
763
|
-
}));
|
|
764
1251
|
if (output.type === "response") {
|
|
765
|
-
const status = output.statusCode !== null && output.statusCode.type === "literal" ? Number(output.statusCode.value) : null;
|
|
766
1252
|
return {
|
|
767
1253
|
kind: "response",
|
|
768
|
-
status:
|
|
1254
|
+
status: literalNumber(output.statusCode),
|
|
769
1255
|
body: output.body ?? null,
|
|
770
1256
|
errorType: null,
|
|
771
|
-
|
|
772
|
-
turnsOn
|
|
1257
|
+
exitCode: null
|
|
773
1258
|
};
|
|
774
1259
|
}
|
|
775
1260
|
if (output.type === "return") {
|
|
@@ -778,8 +1263,7 @@ function toCodeOutcome(t, calls, deployment) {
|
|
|
778
1263
|
status: null,
|
|
779
1264
|
body: output.value,
|
|
780
1265
|
errorType: null,
|
|
781
|
-
|
|
782
|
-
turnsOn
|
|
1266
|
+
exitCode: returnIsExitCode ? returnedNumber(output.value) : null
|
|
783
1267
|
};
|
|
784
1268
|
}
|
|
785
1269
|
if (output.type === "throw") {
|
|
@@ -788,15 +1272,33 @@ function toCodeOutcome(t, calls, deployment) {
|
|
|
788
1272
|
status: null,
|
|
789
1273
|
body: null,
|
|
790
1274
|
errorType: output.exceptionType,
|
|
791
|
-
|
|
792
|
-
|
|
1275
|
+
exitCode: null
|
|
1276
|
+
};
|
|
1277
|
+
}
|
|
1278
|
+
if (output.type === "exit") {
|
|
1279
|
+
return {
|
|
1280
|
+
kind: "exit",
|
|
1281
|
+
status: literalNumber(output.code),
|
|
1282
|
+
body: null,
|
|
1283
|
+
errorType: null,
|
|
1284
|
+
exitCode: null
|
|
793
1285
|
};
|
|
794
1286
|
}
|
|
795
1287
|
return null;
|
|
796
1288
|
}
|
|
1289
|
+
function literalNumber(ref) {
|
|
1290
|
+
if (ref === null || ref.type !== "literal") {
|
|
1291
|
+
return null;
|
|
1292
|
+
}
|
|
1293
|
+
const value = Number(ref.value);
|
|
1294
|
+
return Number.isFinite(value) ? value : null;
|
|
1295
|
+
}
|
|
1296
|
+
function returnedNumber(shape) {
|
|
1297
|
+
return shape !== null && shape.type === "literal" && typeof shape.value === "number" ? shape.value : null;
|
|
1298
|
+
}
|
|
797
1299
|
function codeEffectsOf(t, deployment) {
|
|
798
1300
|
const reached = [];
|
|
799
|
-
for (const effect of t.effects) {
|
|
1301
|
+
for (const [at, effect] of t.effects.entries()) {
|
|
800
1302
|
if (effect.type !== "interaction") {
|
|
801
1303
|
continue;
|
|
802
1304
|
}
|
|
@@ -806,13 +1308,27 @@ function codeEffectsOf(t, deployment) {
|
|
|
806
1308
|
const binding = groundBinding(effect.binding, deployment);
|
|
807
1309
|
const label = displayLabel(binding);
|
|
808
1310
|
const touched = accessDetail(effect.interaction);
|
|
1311
|
+
const slots = (t.provenance ?? []).filter((one) => one.at.effect === at);
|
|
809
1312
|
for (const does of relationsOf(effect.interaction)) {
|
|
810
|
-
reached.push({
|
|
1313
|
+
reached.push({
|
|
1314
|
+
does,
|
|
1315
|
+
binding,
|
|
1316
|
+
label,
|
|
1317
|
+
...touched,
|
|
1318
|
+
...slots.length === 0 ? {} : { slots },
|
|
1319
|
+
line: t.location.start
|
|
1320
|
+
});
|
|
811
1321
|
}
|
|
812
1322
|
}
|
|
813
1323
|
return reached;
|
|
814
1324
|
}
|
|
815
1325
|
function accessDetail(interaction) {
|
|
1326
|
+
if (interaction.class === "config-read") {
|
|
1327
|
+
return { fields: [interaction.name], by: [] };
|
|
1328
|
+
}
|
|
1329
|
+
if (interaction.class === "stream-write") {
|
|
1330
|
+
return interaction.payload === null ? { fields: [], by: [] } : { fields: [], by: [], payload: interaction.payload };
|
|
1331
|
+
}
|
|
816
1332
|
if (interaction.class !== "storage-access") {
|
|
817
1333
|
return { fields: [], by: [] };
|
|
818
1334
|
}
|
|
@@ -822,6 +1338,9 @@ function accessDetail(interaction) {
|
|
|
822
1338
|
};
|
|
823
1339
|
}
|
|
824
1340
|
function outcomeMatches(intent, code) {
|
|
1341
|
+
if (intent.kind === "exit") {
|
|
1342
|
+
return exitsWith(intent.status, code);
|
|
1343
|
+
}
|
|
825
1344
|
if (intent.kind !== code.kind) {
|
|
826
1345
|
return false;
|
|
827
1346
|
}
|
|
@@ -833,19 +1352,29 @@ function outcomeMatches(intent, code) {
|
|
|
833
1352
|
}
|
|
834
1353
|
return true;
|
|
835
1354
|
}
|
|
1355
|
+
function exitsWith(code, ending) {
|
|
1356
|
+
if (code === null) {
|
|
1357
|
+
return false;
|
|
1358
|
+
}
|
|
1359
|
+
if (ending.kind === "exit") {
|
|
1360
|
+
return ending.status === code;
|
|
1361
|
+
}
|
|
1362
|
+
return ending.kind === "return" && ending.exitCode === code;
|
|
1363
|
+
}
|
|
836
1364
|
function whatWouldKeyIt(protocol) {
|
|
837
1365
|
return WHAT_KEYS[protocol];
|
|
838
1366
|
}
|
|
839
1367
|
var WHAT_KEYS = {
|
|
840
1368
|
rest: "a REST boundary needs a method and a path",
|
|
841
|
-
"function-call": "a function-call boundary needs package + exportPath",
|
|
1369
|
+
"function-call": "a function-call boundary needs package + exportPath, or module + exportName where the module is one suss.json lists",
|
|
842
1370
|
"message-bus": "a message-bus boundary needs a channel",
|
|
843
1371
|
storage: "a store has no key at all: write it as `- writes: <store>` on an outcome of the boundary that touches it instead",
|
|
844
1372
|
"graphql-resolver": "a resolver needs a type name and a field name",
|
|
845
1373
|
"graphql-operation": "an operation pairs by document rather than by key",
|
|
846
1374
|
"runtime-config": "a runtime-config boundary needs a deployment target and an instance name",
|
|
847
1375
|
metric: "a metric needs a system and a type",
|
|
848
|
-
"unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by"
|
|
1376
|
+
"unit-invocation": "an invoked unit needs a deployment target and the name the platform knows it by",
|
|
1377
|
+
io: "a stream is never a boundary of its own: write it as `- writes: io:stdout` on an outcome of the unit that prints"
|
|
849
1378
|
};
|
|
850
1379
|
function describeOutcome(outcome) {
|
|
851
1380
|
if (outcome.kind === "response") {
|
|
@@ -854,8 +1383,17 @@ function describeOutcome(outcome) {
|
|
|
854
1383
|
if (outcome.kind === "throw") {
|
|
855
1384
|
return outcome.errorType !== null ? `throw ${outcome.errorType}` : "a thrown error";
|
|
856
1385
|
}
|
|
1386
|
+
if (outcome.kind === "exit") {
|
|
1387
|
+
return `exit code ${outcome.status}`;
|
|
1388
|
+
}
|
|
857
1389
|
return "a return value";
|
|
858
1390
|
}
|
|
1391
|
+
function describeProduced(outcome) {
|
|
1392
|
+
if (outcome.kind === "effect") {
|
|
1393
|
+
return outcome.id;
|
|
1394
|
+
}
|
|
1395
|
+
return `${outcome.id} (${describeOutcome(outcome)})`;
|
|
1396
|
+
}
|
|
859
1397
|
function describeEffect(effect) {
|
|
860
1398
|
const detail = [
|
|
861
1399
|
effect.fields.length > 0 ? `of ${effect.fields.join(", ")}` : null,
|
|
@@ -869,7 +1407,12 @@ var EFFECT_PHRASE = {
|
|
|
869
1407
|
invokes: "an invoke of"
|
|
870
1408
|
};
|
|
871
1409
|
export {
|
|
1410
|
+
accessDetail,
|
|
872
1411
|
applyIntentSuppressions,
|
|
873
1412
|
checkIntentAgreement,
|
|
1413
|
+
codeEffectsOf,
|
|
1414
|
+
effectMatches,
|
|
1415
|
+
endingOf,
|
|
1416
|
+
outcomeMatches,
|
|
874
1417
|
whatWouldKeyIt
|
|
875
1418
|
};
|