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