@suss/intent-ir 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 +6 -6
- package/dist/index.cjs +543 -214
- package/dist/index.d.cts +364 -104
- package/dist/index.d.ts +364 -104
- package/dist/index.js +516 -198
- package/package.json +3 -3
- package/schema/intent-doc.schema.json +430 -0
package/dist/index.js
CHANGED
|
@@ -1,95 +1,45 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
import { z } from "zod";
|
|
3
|
-
var IntentFindingKindSchema = z.enum([
|
|
4
|
-
// System intent (kind: boundary) vs code:
|
|
5
|
-
"uncoveredOutcome",
|
|
6
|
-
// intent declares an outcome the code never produces
|
|
7
|
-
"unimplementedBoundary",
|
|
8
|
-
// intent boundary has no implementing code at all
|
|
9
|
-
"outcomeShapeMismatch",
|
|
10
|
-
// a matched outcome whose body shapes disagree
|
|
11
|
-
"undeclaredOutcome",
|
|
12
|
-
// code produces a REST status the intent doesn't declare
|
|
13
|
-
"unkeyableBoundary",
|
|
14
|
-
// intent boundary can't be keyed, so it can't be checked
|
|
15
|
-
"renamedBoundary",
|
|
16
|
-
// a declared store vanished and an undeclared one of the same system appeared with the same outcomes
|
|
17
|
-
// What the boundary is handed: the `receives` block against the paths
|
|
18
|
-
// the unit reads off its input.
|
|
19
|
-
"unreadInputField",
|
|
20
|
-
// intent declares a field no transition of the unit reads
|
|
21
|
-
"undeclaredInputRead",
|
|
22
|
-
// the unit reads a path the receives block does not list
|
|
23
|
-
// Outcome intent (kind: prd), scenario link coverage against system
|
|
24
|
-
// intent. These concretise the proposal's "scenario not linked /
|
|
25
|
-
// dangling / ambiguous" set (design/proposals/intent-specs.md);
|
|
26
|
-
// the proposal deferred concrete names to implementation time.
|
|
27
|
-
"unlinkedScenario",
|
|
28
|
-
// scenario carries no structured link (info — a valid pending state)
|
|
29
|
-
"danglingScenarioLink",
|
|
30
|
-
// link names an intent / outcome no boundary intent declares
|
|
31
|
-
"ambiguousScenarioLink",
|
|
32
|
-
// link resolves to two or more boundary intents sharing the name
|
|
33
|
-
// The same coverage question asked the other way: which declared
|
|
34
|
-
// behaviour has nobody written a reason for.
|
|
35
|
-
"undescribedOutcome"
|
|
36
|
-
// a declared outcome no PRD scenario links to (info)
|
|
37
|
-
]);
|
|
38
|
-
var IntentFindingSeveritySchema = z.enum(["error", "warning", "info"]);
|
|
39
|
-
var IntentRefSchema = z.object({
|
|
40
|
-
/** The intent doc's `name` (boundary) or `title` (prd). */
|
|
41
|
-
name: z.string(),
|
|
42
|
-
/** The declared outcome id, when the finding is outcome-specific. */
|
|
43
|
-
outcomeId: z.string().optional()
|
|
44
|
-
});
|
|
45
|
-
var IntentFindingSuppressionSchema = z.object({
|
|
46
|
-
/** The rule's human-written justification. */
|
|
47
|
-
reason: z.string(),
|
|
48
|
-
effect: z.enum(["mark", "downgrade", "hide"]),
|
|
49
|
-
/** Original severity, present only when effect is "downgrade". */
|
|
50
|
-
originalSeverity: IntentFindingSeveritySchema.optional()
|
|
51
|
-
});
|
|
52
|
-
var IntentFindingSchema = z.object({
|
|
53
|
-
kind: IntentFindingKindSchema,
|
|
54
|
-
severity: IntentFindingSeveritySchema,
|
|
55
|
-
/**
|
|
56
|
-
* Human-readable boundary label: e.g. `GET /users/:id` or
|
|
57
|
-
* `fn:@suss/cli::contract`. The key the intent and code were paired on.
|
|
58
|
-
*/
|
|
59
|
-
boundary: z.string(),
|
|
60
|
-
/** The intent side of the finding. */
|
|
61
|
-
intent: IntentRefSchema,
|
|
62
|
-
/**
|
|
63
|
-
* The matched code summary as `${file}::${name}`, when the finding
|
|
64
|
-
* concerns a specific implementation. Absent for unimplementedBoundary.
|
|
65
|
-
*/
|
|
66
|
-
code: z.string().optional(),
|
|
67
|
-
/**
|
|
68
|
-
* PRD-scenario extension: present only on outcome-intent findings
|
|
69
|
-
* (unlinkedScenario / danglingScenarioLink / ambiguousScenarioLink).
|
|
70
|
-
* Identifies the scenario (by its optional title) and the qualified
|
|
71
|
-
* outcome ref (`<intent-name>.<outcome-id>`) that failed to resolve.
|
|
72
|
-
* The peer / boundary findings leave it unset. This is the "intent
|
|
73
|
-
* finding extension" decision 2 of the proposal anticipated.
|
|
74
|
-
*/
|
|
75
|
-
scenario: z.object({
|
|
76
|
-
title: z.string().optional(),
|
|
77
|
-
link: z.string().optional()
|
|
78
|
-
}).optional(),
|
|
79
|
-
message: z.string(),
|
|
80
|
-
/** Present when a .sussignore rule matched this finding. */
|
|
81
|
-
suppressed: IntentFindingSuppressionSchema.optional()
|
|
82
|
-
});
|
|
1
|
+
// src/changeList.ts
|
|
2
|
+
import { z as z2 } from "zod";
|
|
83
3
|
|
|
84
4
|
// src/schema.ts
|
|
85
|
-
import { z
|
|
5
|
+
import { z } from "zod";
|
|
86
6
|
import {
|
|
87
7
|
EffectRelationSchema,
|
|
88
8
|
MessageBusSemanticsSchema,
|
|
89
9
|
StorageSemanticsSchema,
|
|
90
10
|
UnitInvocationSemanticsSchema
|
|
91
11
|
} from "@suss/ir-core";
|
|
92
|
-
|
|
12
|
+
|
|
13
|
+
// src/coveringTest.ts
|
|
14
|
+
import { TEST_TITLE_SEPARATOR } from "@suss/ir-core";
|
|
15
|
+
var NODE_ID_SEPARATOR = "::";
|
|
16
|
+
function toCoveringTest(spelledAs) {
|
|
17
|
+
if (!spelledAs.includes(TEST_TITLE_SEPARATOR)) {
|
|
18
|
+
return nodeIdSpelling(spelledAs);
|
|
19
|
+
}
|
|
20
|
+
const [file, ...titles] = spelledAs.split(TEST_TITLE_SEPARATOR);
|
|
21
|
+
return { spelledAs, file: file.trim(), titles };
|
|
22
|
+
}
|
|
23
|
+
function nodeIdSpelling(spelledAs) {
|
|
24
|
+
const parts = spelledAs.trim().split(NODE_ID_SEPARATOR);
|
|
25
|
+
const last = parts.length - 1;
|
|
26
|
+
parts[last] = parts[last].replace(/\[.*\]$/, "");
|
|
27
|
+
let fileAt = last;
|
|
28
|
+
while (fileAt >= 0 && !/[./]/.test(parts[fileAt])) {
|
|
29
|
+
fileAt -= 1;
|
|
30
|
+
}
|
|
31
|
+
if (fileAt < 0) {
|
|
32
|
+
return { spelledAs, file: spelledAs.trim(), titles: [] };
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
spelledAs,
|
|
36
|
+
file: parts.slice(0, fileAt + 1).join(NODE_ID_SEPARATOR),
|
|
37
|
+
titles: parts.slice(fileAt + 1)
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/schema.ts
|
|
42
|
+
var IntentSourceSchema = z.enum(["author", "inferred", "inferred, curated"]).default("author").describe(
|
|
93
43
|
"Where the document came from: somebody wrote it, suss inferred it, or suss inferred it and somebody has since curated it."
|
|
94
44
|
);
|
|
95
45
|
var UNCURATED_SOURCE = "inferred";
|
|
@@ -125,7 +75,7 @@ function fillBlanks(doc, blanks) {
|
|
|
125
75
|
}));
|
|
126
76
|
return filled;
|
|
127
77
|
}
|
|
128
|
-
var PrimitiveTypeName =
|
|
78
|
+
var PrimitiveTypeName = z.enum([
|
|
129
79
|
"string",
|
|
130
80
|
"integer",
|
|
131
81
|
"number",
|
|
@@ -133,76 +83,83 @@ var PrimitiveTypeName = z2.enum([
|
|
|
133
83
|
"null",
|
|
134
84
|
"unknown"
|
|
135
85
|
]);
|
|
136
|
-
var ShapeSchema =
|
|
137
|
-
() =>
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
type:
|
|
142
|
-
properties:
|
|
143
|
-
required:
|
|
86
|
+
var ShapeSchema = z.lazy(
|
|
87
|
+
() => z.union([
|
|
88
|
+
z.object({ type: PrimitiveTypeName }),
|
|
89
|
+
z.object({ type: z.literal("array"), items: ShapeSchema.optional() }),
|
|
90
|
+
z.object({
|
|
91
|
+
type: z.literal("object"),
|
|
92
|
+
properties: z.record(z.string(), ShapeSchema).optional(),
|
|
93
|
+
required: z.array(z.string()).optional()
|
|
94
|
+
}),
|
|
95
|
+
z.strictObject({
|
|
96
|
+
const: z.union([z.string(), z.number(), z.boolean(), z.null()])
|
|
97
|
+
}),
|
|
98
|
+
z.strictObject({
|
|
99
|
+
properties: z.record(z.string(), ShapeSchema),
|
|
100
|
+
required: z.array(z.string()).optional()
|
|
144
101
|
})
|
|
145
102
|
])
|
|
146
103
|
);
|
|
147
|
-
var BodyShapeSchema =
|
|
104
|
+
var BodyShapeSchema = z.union([
|
|
148
105
|
ShapeSchema,
|
|
149
|
-
|
|
150
|
-
properties:
|
|
151
|
-
required:
|
|
106
|
+
z.object({
|
|
107
|
+
properties: z.record(z.string(), ShapeSchema).optional(),
|
|
108
|
+
required: z.array(z.string()).optional()
|
|
152
109
|
})
|
|
153
110
|
]);
|
|
154
111
|
var REQUIRED = {
|
|
155
|
-
required:
|
|
112
|
+
required: z.boolean().default(false).describe("Whether the boundary needs this field.")
|
|
156
113
|
};
|
|
157
|
-
var InputFieldSchema =
|
|
158
|
-
|
|
159
|
-
|
|
114
|
+
var InputFieldSchema = z.union([
|
|
115
|
+
z.strictObject({ ...REQUIRED, type: PrimitiveTypeName }),
|
|
116
|
+
z.strictObject({
|
|
160
117
|
...REQUIRED,
|
|
161
|
-
type:
|
|
118
|
+
type: z.literal("array"),
|
|
162
119
|
items: ShapeSchema.optional()
|
|
163
120
|
}),
|
|
164
|
-
|
|
121
|
+
z.strictObject({
|
|
165
122
|
...REQUIRED,
|
|
166
|
-
type:
|
|
167
|
-
properties:
|
|
123
|
+
type: z.literal("object"),
|
|
124
|
+
properties: z.record(z.string(), ShapeSchema).optional()
|
|
168
125
|
}),
|
|
169
|
-
|
|
126
|
+
z.strictObject(REQUIRED)
|
|
170
127
|
]);
|
|
171
|
-
var ReceivesSchema =
|
|
128
|
+
var ReceivesSchema = z.record(z.string().min(1), InputFieldSchema).describe(
|
|
172
129
|
"The fields the boundary is handed, by field name. On a function-call boundary a name is a parameter, on a message bus it is a field of the message body."
|
|
173
130
|
).optional();
|
|
174
|
-
var RestReceivesSchema =
|
|
175
|
-
headers:
|
|
176
|
-
query:
|
|
177
|
-
params:
|
|
131
|
+
var RestReceivesSchema = z.strictObject({
|
|
132
|
+
headers: z.record(z.string().min(1), InputFieldSchema).describe("The request headers the boundary depends on, by name.").optional(),
|
|
133
|
+
query: z.record(z.string().min(1), InputFieldSchema).describe("The query-string parameters the boundary depends on, by name.").optional(),
|
|
134
|
+
params: z.record(z.string().min(1), InputFieldSchema).describe("The path parameters the boundary depends on, by name.").optional(),
|
|
178
135
|
body: BodyShapeSchema.describe(
|
|
179
136
|
"The shape of the request body the boundary depends on."
|
|
180
137
|
).optional()
|
|
181
138
|
}).describe("The parts of the request the boundary depends on.").optional();
|
|
182
|
-
var RestBoundarySchema =
|
|
183
|
-
transport:
|
|
184
|
-
semantics:
|
|
185
|
-
method:
|
|
186
|
-
path:
|
|
139
|
+
var RestBoundarySchema = z.strictObject({
|
|
140
|
+
transport: z.literal("http").default("http"),
|
|
141
|
+
semantics: z.literal("rest"),
|
|
142
|
+
method: z.string().min(1).describe("The HTTP method the route handles."),
|
|
143
|
+
path: z.string().min(1).describe("The route path, written the way the framework declares it."),
|
|
187
144
|
receives: RestReceivesSchema
|
|
188
145
|
});
|
|
189
|
-
var FunctionCallBoundarySchema =
|
|
190
|
-
transport:
|
|
191
|
-
semantics:
|
|
192
|
-
module:
|
|
146
|
+
var FunctionCallBoundarySchema = z.strictObject({
|
|
147
|
+
transport: z.string().default("in-process"),
|
|
148
|
+
semantics: z.literal("function-call"),
|
|
149
|
+
module: z.string().describe(
|
|
193
150
|
"The repo-relative module path, when the boundary is a unit inside this repository."
|
|
194
151
|
).optional(),
|
|
195
|
-
exportName:
|
|
196
|
-
package:
|
|
152
|
+
exportName: z.string().describe("The name the module or package exports the function under.").optional(),
|
|
153
|
+
package: z.string().describe(
|
|
197
154
|
"The package name, when the boundary is something a package publishes."
|
|
198
155
|
).optional(),
|
|
199
|
-
exportPath:
|
|
156
|
+
exportPath: z.array(z.string()).describe(
|
|
200
157
|
"The path to the export inside the package: the sub-path, then any nested names."
|
|
201
158
|
).optional(),
|
|
202
159
|
receives: ReceivesSchema
|
|
203
160
|
});
|
|
204
|
-
var MessageBusBoundarySchema =
|
|
205
|
-
semantics:
|
|
161
|
+
var MessageBusBoundarySchema = z.strictObject({
|
|
162
|
+
semantics: z.literal("message-bus"),
|
|
206
163
|
messageBus: MessageBusSemanticsSchema.shape.messageBus.describe(
|
|
207
164
|
"Which bus carries the message, in the name suss gives that bus."
|
|
208
165
|
),
|
|
@@ -211,8 +168,8 @@ var MessageBusBoundarySchema = z2.strictObject({
|
|
|
211
168
|
),
|
|
212
169
|
receives: ReceivesSchema
|
|
213
170
|
});
|
|
214
|
-
var StorageBoundarySchema =
|
|
215
|
-
semantics:
|
|
171
|
+
var StorageBoundarySchema = z.strictObject({
|
|
172
|
+
semantics: z.literal("storage"),
|
|
216
173
|
storageSystem: StorageSemanticsSchema.shape.storageSystem.default(null).describe(
|
|
217
174
|
"Which store this is: postgresql, aws.dynamodb, s3, and so on. Null when the document does not say which engine."
|
|
218
175
|
),
|
|
@@ -227,8 +184,8 @@ var StorageBoundarySchema = z2.strictObject({
|
|
|
227
184
|
),
|
|
228
185
|
receives: ReceivesSchema
|
|
229
186
|
});
|
|
230
|
-
var UnitInvocationBoundarySchema =
|
|
231
|
-
semantics:
|
|
187
|
+
var UnitInvocationBoundarySchema = z.strictObject({
|
|
188
|
+
semantics: z.literal("unit-invocation"),
|
|
232
189
|
deploymentTarget: UnitInvocationSemanticsSchema.shape.deploymentTarget.describe(
|
|
233
190
|
"What sort of deployed thing this is: a Lambda function, an ECS task's container, a plain container, a k8s deployment or an edge worker."
|
|
234
191
|
),
|
|
@@ -237,57 +194,80 @@ var UnitInvocationBoundarySchema = z2.strictObject({
|
|
|
237
194
|
),
|
|
238
195
|
receives: ReceivesSchema
|
|
239
196
|
});
|
|
240
|
-
var BoundarySchema =
|
|
197
|
+
var BoundarySchema = z.discriminatedUnion("semantics", [
|
|
241
198
|
RestBoundarySchema,
|
|
242
199
|
FunctionCallBoundarySchema,
|
|
243
200
|
MessageBusBoundarySchema,
|
|
244
201
|
StorageBoundarySchema,
|
|
245
202
|
UnitInvocationBoundarySchema
|
|
246
203
|
]);
|
|
247
|
-
var ResponseOutcomeSchema =
|
|
248
|
-
status:
|
|
204
|
+
var ResponseOutcomeSchema = z.object({
|
|
205
|
+
status: z.number().int().min(100).max(599).describe("The HTTP status code the response carries."),
|
|
249
206
|
body: BodyShapeSchema.describe("The shape of the response body.").optional()
|
|
250
207
|
});
|
|
251
|
-
var ReturnsOutcomeSchema =
|
|
208
|
+
var ReturnsOutcomeSchema = z.object({
|
|
252
209
|
body: BodyShapeSchema.describe("The shape of the returned value.").optional()
|
|
253
210
|
});
|
|
254
|
-
var ThrowsOutcomeSchema =
|
|
255
|
-
errorType:
|
|
211
|
+
var ThrowsOutcomeSchema = z.object({
|
|
212
|
+
errorType: z.string().describe("The name of the error class this outcome raises.").optional()
|
|
256
213
|
});
|
|
257
214
|
function emptyIfNull(schema) {
|
|
258
|
-
return
|
|
215
|
+
return z.preprocess((v) => v === null ? {} : v, schema).meta({ [ACCEPTS_NULL]: true });
|
|
259
216
|
}
|
|
260
217
|
var ACCEPTS_NULL = "x-suss-accepts-null";
|
|
261
|
-
var
|
|
262
|
-
|
|
263
|
-
|
|
218
|
+
var INPUT_SOURCE_PREFIX = "input.";
|
|
219
|
+
var SourcesSchema = z.record(
|
|
220
|
+
z.string().min(1),
|
|
221
|
+
z.string().regex(/^input\.[^.]+(\.[^.]+)*$/, {
|
|
222
|
+
message: "a source is written input.<path>, such as input.headers.x-tenant-id or input.body.email"
|
|
223
|
+
})
|
|
224
|
+
).describe(
|
|
225
|
+
"Where the value of a column the line lists under fields or by comes from, keyed by the column: input.<path>, spelled the way receives spells it."
|
|
226
|
+
);
|
|
227
|
+
var ONE_OR_MORE = z.union([
|
|
228
|
+
z.string().min(1),
|
|
229
|
+
z.array(z.string().min(1)).min(1)
|
|
264
230
|
]);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
231
|
+
function effectByVerb(extra = {}) {
|
|
232
|
+
return EffectRelationSchema.options.map(
|
|
233
|
+
(verb) => z.strictObject({
|
|
234
|
+
[verb]: z.string().min(1),
|
|
235
|
+
fields: z.array(z.string().min(1)).min(1).optional(),
|
|
236
|
+
by: ONE_OR_MORE.optional(),
|
|
237
|
+
shape: BodyShapeSchema.describe(
|
|
238
|
+
"The shape of what the effect writes, such as the report a command prints."
|
|
239
|
+
).optional(),
|
|
240
|
+
...extra
|
|
241
|
+
})
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
var EffectLineSchema = z.union(effectByVerb());
|
|
245
|
+
var EffectOutcomeSchema = z.union(
|
|
246
|
+
effectByVerb({ from: SourcesSchema.optional() })
|
|
247
|
+
);
|
|
248
|
+
var AlwaysEffectSchema = z.union(
|
|
249
|
+
effectByVerb({
|
|
250
|
+
except: z.array(z.string().min(1)).min(1).describe("The ids of the outcomes that do not have to have the effect.").optional()
|
|
270
251
|
})
|
|
271
252
|
);
|
|
272
|
-
var EffectOutcomeSchema = z2.union(EFFECT_BY_VERB);
|
|
273
253
|
var CHECKS = {
|
|
274
254
|
/** What a lookup came back with. */
|
|
275
|
-
finds:
|
|
255
|
+
finds: z.enum(["nothing", "something"]),
|
|
276
256
|
/** What state the value was in: `set`, `missing`, `null`, `a string`. */
|
|
277
|
-
is:
|
|
257
|
+
is: z.string().min(1),
|
|
278
258
|
/** The value it was equal to. */
|
|
279
|
-
equals:
|
|
259
|
+
equals: z.union([z.string(), z.number(), z.boolean(), z.null()]),
|
|
280
260
|
/** A property it had. */
|
|
281
|
-
has:
|
|
261
|
+
has: z.string().min(1)
|
|
282
262
|
};
|
|
283
263
|
var SUBJECT_KEYS = [
|
|
284
264
|
...EffectRelationSchema.options,
|
|
285
265
|
"input"
|
|
286
266
|
];
|
|
287
267
|
var WHEN_CLAUSE_BY_SUBJECT = SUBJECT_KEYS.map(
|
|
288
|
-
(key) =>
|
|
289
|
-
[key]:
|
|
290
|
-
where:
|
|
268
|
+
(key) => z.strictObject({
|
|
269
|
+
[key]: z.string().min(1),
|
|
270
|
+
where: z.string().min(1).optional(),
|
|
291
271
|
...Object.fromEntries(
|
|
292
272
|
Object.entries(CHECKS).map(([check, schema]) => [
|
|
293
273
|
check,
|
|
@@ -303,12 +283,12 @@ var WHEN_CLAUSE_BY_SUBJECT = SUBJECT_KEYS.map(
|
|
|
303
283
|
}
|
|
304
284
|
)
|
|
305
285
|
);
|
|
306
|
-
var WhenSchema =
|
|
307
|
-
|
|
308
|
-
|
|
286
|
+
var WhenSchema = z.union([
|
|
287
|
+
z.string().min(1),
|
|
288
|
+
z.array(z.union([z.string().min(1), ...WHEN_CLAUSE_BY_SUBJECT])).min(1)
|
|
309
289
|
]);
|
|
310
|
-
var BoundaryTransitionSchema =
|
|
311
|
-
id:
|
|
290
|
+
var BoundaryTransitionSchema = z.strictObject({
|
|
291
|
+
id: z.string().min(1).describe(
|
|
312
292
|
"The outcome's name. A PRD scenario links to it as <intent-name>.<id>."
|
|
313
293
|
),
|
|
314
294
|
when: WhenSchema.describe(
|
|
@@ -317,58 +297,149 @@ var BoundaryTransitionSchema = z2.strictObject({
|
|
|
317
297
|
response: emptyIfNull(ResponseOutcomeSchema).describe("This outcome sends an HTTP response.").optional(),
|
|
318
298
|
returns: emptyIfNull(ReturnsOutcomeSchema).describe("This outcome returns a value to its caller.").optional(),
|
|
319
299
|
throws: emptyIfNull(ThrowsOutcomeSchema).describe("This outcome raises an error.").optional(),
|
|
320
|
-
|
|
300
|
+
exits: z.number().int().min(0).max(255).describe("This outcome ends the process with this exit code.").optional(),
|
|
301
|
+
results: z.array(EffectOutcomeSchema).min(1).describe(
|
|
321
302
|
"The effects this outcome has, written in the same verbs suss ask uses: reads, writes, invokes."
|
|
322
303
|
).optional()
|
|
323
304
|
}).refine((t) => endingsOf(t).length <= 1, {
|
|
324
|
-
message: "a transition ends one way: give it at most one of response, returns, or
|
|
305
|
+
message: "a transition ends one way: give it at most one of response, returns, throws, or exits"
|
|
325
306
|
}).refine((t) => endingsOf(t).length === 1 || t.results !== void 0, {
|
|
326
|
-
message: "each transition must declare an outcome: response, returns, throws, or the effects it results in"
|
|
307
|
+
message: "each transition must declare an outcome: response, returns, throws, exits, or the effects it results in"
|
|
327
308
|
});
|
|
328
309
|
function endingsOf(t) {
|
|
329
|
-
return [t.response, t.returns, t.throws].filter(
|
|
310
|
+
return [t.response, t.returns, t.throws, t.exits].filter(
|
|
311
|
+
(o) => o !== void 0
|
|
312
|
+
);
|
|
330
313
|
}
|
|
331
|
-
var
|
|
332
|
-
kind:
|
|
333
|
-
name:
|
|
314
|
+
var BoundaryIntentFields = z.strictObject({
|
|
315
|
+
kind: z.literal("boundary").describe("Makes this document boundary intent for one boundary."),
|
|
316
|
+
name: z.string().min(1).describe(
|
|
334
317
|
"What this document is called. A PRD scenario links to an outcome of it as <name>.<outcome-id>."
|
|
335
318
|
),
|
|
336
|
-
purpose:
|
|
337
|
-
audience:
|
|
319
|
+
purpose: z.string().min(1).describe("What the boundary is for, in the author's own words."),
|
|
320
|
+
audience: z.string().min(1).describe("Who calls this boundary and depends on what it does."),
|
|
338
321
|
source: IntentSourceSchema,
|
|
339
322
|
boundary: BoundarySchema.describe(
|
|
340
323
|
"Which boundary in the code the document is about."
|
|
341
324
|
),
|
|
342
|
-
transitions:
|
|
325
|
+
transitions: z.array(BoundaryTransitionSchema).min(1).describe("Every outcome the boundary can produce, one entry each."),
|
|
326
|
+
always: z.array(AlwaysEffectSchema).min(1).describe(
|
|
327
|
+
"Effects that every transition producing a declared outcome has, apart from the outcomes listed under except."
|
|
328
|
+
).optional()
|
|
329
|
+
});
|
|
330
|
+
var BoundaryIntentSchema = BoundaryIntentFields.superRefine((doc, ctx) => {
|
|
331
|
+
for (const [line, entry] of (doc.always ?? []).entries()) {
|
|
332
|
+
for (const [at, id] of (entry.except ?? []).entries()) {
|
|
333
|
+
const problem = exceptProblem(doc.transitions, id);
|
|
334
|
+
if (problem !== null) {
|
|
335
|
+
ctx.addIssue({
|
|
336
|
+
code: "custom",
|
|
337
|
+
path: ["always", line, "except", at],
|
|
338
|
+
message: problem
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const [at, transition] of doc.transitions.entries()) {
|
|
344
|
+
for (const [line, effect] of (transition.results ?? []).entries()) {
|
|
345
|
+
for (const column of Object.keys(effect.from ?? {})) {
|
|
346
|
+
if (!listsColumn(effect, column)) {
|
|
347
|
+
ctx.addIssue({
|
|
348
|
+
code: "custom",
|
|
349
|
+
path: ["transitions", at, "results", line, "from", column],
|
|
350
|
+
message: `from says where ${column} comes from, and the line lists ${column} under neither fields nor by`
|
|
351
|
+
});
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
343
356
|
});
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
357
|
+
function listsColumn(effect, column) {
|
|
358
|
+
return (effect.fields ?? []).includes(column) || oneOrMore(effect.by).includes(column);
|
|
359
|
+
}
|
|
360
|
+
function exceptProblem(transitions, id) {
|
|
361
|
+
const outcome = transitions.find((t) => t.id === id);
|
|
362
|
+
if (outcome === void 0) {
|
|
363
|
+
return `except lists ${id}, and no transition has that id (known: ${transitions.map((t) => t.id).join(", ")})`;
|
|
364
|
+
}
|
|
365
|
+
if (endingsOf(outcome).length === 0) {
|
|
366
|
+
return `except lists ${id}, which states only its effects; an outcome with no response, returns, throws or exits matches every transition, so it cannot be exempted`;
|
|
367
|
+
}
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
var PrdScenarioSchema = z.strictObject({
|
|
371
|
+
title: z.string().min(1).describe("A short name for the scenario.").optional(),
|
|
372
|
+
when: z.string().min(1).describe("The condition the scenario covers, in the author's own words."),
|
|
373
|
+
expect: z.string().min(1).describe("What should happen then, in the author's own words."),
|
|
348
374
|
/**
|
|
349
|
-
*
|
|
350
|
-
*
|
|
351
|
-
*
|
|
352
|
-
*
|
|
353
|
-
*
|
|
375
|
+
* Links to system-intent outcomes, each written
|
|
376
|
+
* `<intent-name>.<outcome-id>`. A scenario is backed by a link or by
|
|
377
|
+
* `coveredBy`, and the checker warns about one with neither. The
|
|
378
|
+
* schema still accepts it, so a PRD can be written before either
|
|
379
|
+
* exists.
|
|
354
380
|
*
|
|
355
|
-
*
|
|
356
|
-
*
|
|
357
|
-
* treated as a thenable by Promise resolution, a latent footgun.)
|
|
381
|
+
* The field is `link` and not `then`, because Promise resolution
|
|
382
|
+
* treats an object with a `then` property as a thenable.
|
|
358
383
|
*/
|
|
359
|
-
link:
|
|
384
|
+
link: ONE_OR_MORE.describe(
|
|
360
385
|
"The boundary-intent outcomes this scenario is about, each written <intent-name>.<outcome-id>."
|
|
386
|
+
).optional(),
|
|
387
|
+
/**
|
|
388
|
+
* The tests that exercise this scenario, for a promise about which
|
|
389
|
+
* values come back that no outcome can state. Each is spelled the way
|
|
390
|
+
* the runner prints it, as `toCoveringTest` reads it.
|
|
391
|
+
*/
|
|
392
|
+
coveredBy: ONE_OR_MORE.describe(
|
|
393
|
+
"The tests that exercise this scenario, each written the way its runner prints it: for vitest or RSpec, the test file, then each describe or context title, then the test's own title, joined with ' > '; for pytest, the node id, as in tests/test_orders.py::TestCancel::test_twice."
|
|
394
|
+
).optional(),
|
|
395
|
+
about: ONE_OR_MORE.describe(
|
|
396
|
+
"What a covering test has to reach for the scenario to count as covered: a boundary or a unit, spelled the way suss ask takes one. Without it, a test has to reach one of the boundaries the PRD's other scenarios link to."
|
|
361
397
|
).optional()
|
|
362
398
|
});
|
|
363
|
-
var
|
|
364
|
-
kind:
|
|
365
|
-
title:
|
|
366
|
-
purpose:
|
|
367
|
-
audience:
|
|
399
|
+
var PrdFields = z.strictObject({
|
|
400
|
+
kind: z.literal("prd").describe("Makes this document a PRD, a set of scenarios for a feature."),
|
|
401
|
+
title: z.string().min(1).describe("What this document is called."),
|
|
402
|
+
purpose: z.string().min(1).describe("What the feature is for, in the author's own words."),
|
|
403
|
+
audience: z.string().min(1).describe("Who the feature is for."),
|
|
368
404
|
source: IntentSourceSchema,
|
|
369
|
-
scenarios:
|
|
405
|
+
scenarios: z.array(PrdScenarioSchema).min(1).describe("The situations the feature covers, one entry each.")
|
|
370
406
|
});
|
|
371
|
-
var
|
|
407
|
+
var PrdSchema = PrdFields.superRefine((doc, ctx) => {
|
|
408
|
+
const linksAnything = doc.scenarios.some((s) => s.link !== void 0);
|
|
409
|
+
for (const [at, scenario] of doc.scenarios.entries()) {
|
|
410
|
+
const label = scenario.title === void 0 ? `#${at + 1}` : `"${scenario.title}"`;
|
|
411
|
+
for (const [line, spelled] of oneOrMore(scenario.coveredBy).entries()) {
|
|
412
|
+
if (toCoveringTest(spelled).titles.length === 0) {
|
|
413
|
+
ctx.addIssue({
|
|
414
|
+
code: "custom",
|
|
415
|
+
path: ["scenarios", at, "coveredBy", line],
|
|
416
|
+
message: `scenario ${label} lists the test "${spelled}", which has no title; write the file, then each describe title, then the test's own title, joined with " > ", or for pytest the node id, file::test_name`
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (scenario.about !== void 0 && scenario.coveredBy === void 0) {
|
|
421
|
+
ctx.addIssue({
|
|
422
|
+
code: "custom",
|
|
423
|
+
path: ["scenarios", at, "about"],
|
|
424
|
+
message: `scenario ${label} says what a covering test has to reach, and lists no test under coveredBy`
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
if (scenario.coveredBy !== void 0 && scenario.about === void 0 && !linksAnything) {
|
|
428
|
+
ctx.addIssue({
|
|
429
|
+
code: "custom",
|
|
430
|
+
path: ["scenarios", at, "about"],
|
|
431
|
+
message: `scenario ${label} lists a covering test, and no scenario in this PRD links to an outcome, so there is nothing to check the test reaches; say what it has to reach under about`
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
function oneOrMore(written) {
|
|
437
|
+
if (written === void 0) {
|
|
438
|
+
return [];
|
|
439
|
+
}
|
|
440
|
+
return typeof written === "string" ? [written] : written;
|
|
441
|
+
}
|
|
442
|
+
var IntentDocSchema = z.discriminatedUnion("kind", [
|
|
372
443
|
BoundaryIntentSchema,
|
|
373
444
|
PrdSchema
|
|
374
445
|
]);
|
|
@@ -397,7 +468,11 @@ function boundaryIntentToSummary(doc) {
|
|
|
397
468
|
source: doc.source,
|
|
398
469
|
boundary: toBoundaryBinding(doc.boundary),
|
|
399
470
|
receives: toReceives(doc.boundary),
|
|
400
|
-
outcomes: doc.transitions.map(toOutcome)
|
|
471
|
+
outcomes: doc.transitions.map(toOutcome),
|
|
472
|
+
always: (doc.always ?? []).map((declared) => ({
|
|
473
|
+
effect: toIntentEffect(declared),
|
|
474
|
+
except: declared.except ?? []
|
|
475
|
+
}))
|
|
401
476
|
};
|
|
402
477
|
}
|
|
403
478
|
function prdToSummary(doc) {
|
|
@@ -411,7 +486,9 @@ function prdToSummary(doc) {
|
|
|
411
486
|
title: s.title ?? null,
|
|
412
487
|
when: s.when,
|
|
413
488
|
expect: s.expect,
|
|
414
|
-
link:
|
|
489
|
+
link: oneOrMore(s.link),
|
|
490
|
+
coveredBy: oneOrMore(s.coveredBy).map(toCoveringTest),
|
|
491
|
+
about: oneOrMore(s.about)
|
|
415
492
|
}))
|
|
416
493
|
};
|
|
417
494
|
}
|
|
@@ -514,23 +591,24 @@ function toReceives(boundary) {
|
|
|
514
591
|
return read(boundary);
|
|
515
592
|
}
|
|
516
593
|
var VERBS = EffectRelationSchema2.options;
|
|
517
|
-
function
|
|
594
|
+
function toIntentEffect(declared) {
|
|
518
595
|
const [does, names] = Object.entries(declared).find(
|
|
519
596
|
([key]) => VERBS.includes(key)
|
|
520
597
|
);
|
|
598
|
+
const shape = declared.shape === void 0 ? null : bodyToTypeShape(declared.shape);
|
|
599
|
+
const from = Object.entries(declared.from ?? {}).map(([column, source]) => ({
|
|
600
|
+
column,
|
|
601
|
+
path: source.slice(INPUT_SOURCE_PREFIX.length).split(".")
|
|
602
|
+
}));
|
|
521
603
|
return {
|
|
522
604
|
does,
|
|
523
605
|
names,
|
|
524
606
|
fields: declared.fields ?? [],
|
|
525
|
-
by: oneOrMore(declared.by)
|
|
607
|
+
by: oneOrMore(declared.by),
|
|
608
|
+
...shape === null ? {} : { shape },
|
|
609
|
+
...from.length === 0 ? {} : { from }
|
|
526
610
|
};
|
|
527
611
|
}
|
|
528
|
-
function oneOrMore(written) {
|
|
529
|
-
if (written === void 0) {
|
|
530
|
-
return [];
|
|
531
|
-
}
|
|
532
|
-
return typeof written === "string" ? [written] : written;
|
|
533
|
-
}
|
|
534
612
|
function subjectOf(clause) {
|
|
535
613
|
for (const does of VERBS) {
|
|
536
614
|
const names = clause[does];
|
|
@@ -568,7 +646,7 @@ function whenAsOneLine(when) {
|
|
|
568
646
|
return typeof when === "string" ? when : toConditions(when).map((condition) => condition.said).join(" and ");
|
|
569
647
|
}
|
|
570
648
|
function toOutcome(t) {
|
|
571
|
-
const effects = (t.results ?? []).map(
|
|
649
|
+
const effects = (t.results ?? []).map(toIntentEffect);
|
|
572
650
|
const base = {
|
|
573
651
|
id: t.id,
|
|
574
652
|
when: whenAsOneLine(t.when),
|
|
@@ -602,9 +680,21 @@ function toOutcome(t) {
|
|
|
602
680
|
errorType: t.throws.errorType ?? null
|
|
603
681
|
};
|
|
604
682
|
}
|
|
683
|
+
if (t.exits !== void 0) {
|
|
684
|
+
return {
|
|
685
|
+
...base,
|
|
686
|
+
kind: "exit",
|
|
687
|
+
status: t.exits,
|
|
688
|
+
body: null,
|
|
689
|
+
errorType: null
|
|
690
|
+
};
|
|
691
|
+
}
|
|
605
692
|
return { ...base, kind: "effect", status: null, body: null, errorType: null };
|
|
606
693
|
}
|
|
607
694
|
function bodyToTypeShape(body) {
|
|
695
|
+
if ("const" in body) {
|
|
696
|
+
return constantShape(body.const);
|
|
697
|
+
}
|
|
608
698
|
if (!("type" in body)) {
|
|
609
699
|
if (body.properties === void 0) {
|
|
610
700
|
return null;
|
|
@@ -614,6 +704,12 @@ function bodyToTypeShape(body) {
|
|
|
614
704
|
return shapeToTypeShape(body);
|
|
615
705
|
}
|
|
616
706
|
function shapeToTypeShape(shape) {
|
|
707
|
+
if ("const" in shape) {
|
|
708
|
+
return constantShape(shape.const);
|
|
709
|
+
}
|
|
710
|
+
if (!("type" in shape)) {
|
|
711
|
+
return recordShape(shape.properties);
|
|
712
|
+
}
|
|
617
713
|
if (shape.type === "array") {
|
|
618
714
|
return {
|
|
619
715
|
type: "array",
|
|
@@ -625,6 +721,9 @@ function shapeToTypeShape(shape) {
|
|
|
625
721
|
}
|
|
626
722
|
return PRIMITIVE_TYPE_SHAPES[shape.type];
|
|
627
723
|
}
|
|
724
|
+
function constantShape(value) {
|
|
725
|
+
return value === null ? { type: "null" } : { type: "literal", value };
|
|
726
|
+
}
|
|
628
727
|
function recordShape(authored) {
|
|
629
728
|
const properties = {};
|
|
630
729
|
for (const [name, prop] of Object.entries(authored)) {
|
|
@@ -640,10 +739,223 @@ var PRIMITIVE_TYPE_SHAPES = {
|
|
|
640
739
|
null: { type: "null" },
|
|
641
740
|
unknown: { type: "unknown" }
|
|
642
741
|
};
|
|
742
|
+
|
|
743
|
+
// src/changeList.ts
|
|
744
|
+
var VERBS2 = ["adds", "removes", "changes"];
|
|
745
|
+
var OutcomeWordSchema = z2.union([
|
|
746
|
+
z2.number().int().min(100).max(599),
|
|
747
|
+
z2.enum(["returns", "throws"]),
|
|
748
|
+
z2.strictObject({ throws: z2.string().min(1) })
|
|
749
|
+
]);
|
|
750
|
+
var SubjectSchema = z2.union([z2.string().min(1), EffectLineSchema]);
|
|
751
|
+
var ENTRY_FIELDS = {
|
|
752
|
+
adds: SubjectSchema.optional(),
|
|
753
|
+
removes: SubjectSchema.optional(),
|
|
754
|
+
changes: SubjectSchema.optional(),
|
|
755
|
+
outcomes: z2.array(OutcomeWordSchema).min(1).describe(
|
|
756
|
+
"The outcomes the boundary should have: statuses, returns or throws."
|
|
757
|
+
).optional(),
|
|
758
|
+
at: z2.string().min(1).describe("The boundary an effect happens at, spelled as suss spells it.").optional()
|
|
759
|
+
};
|
|
760
|
+
function verbsOf(entry) {
|
|
761
|
+
return VERBS2.filter((verb) => entry[verb] !== void 0);
|
|
762
|
+
}
|
|
763
|
+
function subjectOf2(entry) {
|
|
764
|
+
const [verb] = verbsOf(entry);
|
|
765
|
+
return verb === void 0 ? void 0 : entry[verb];
|
|
766
|
+
}
|
|
767
|
+
function withEntryRules(schema) {
|
|
768
|
+
return schema.refine((entry) => verbsOf(entry).length === 1, {
|
|
769
|
+
message: "an entry has exactly one of adds, removes or changes"
|
|
770
|
+
}).refine(
|
|
771
|
+
(entry) => entry.at === void 0 || typeof subjectOf2(entry) !== "string",
|
|
772
|
+
{
|
|
773
|
+
message: "at says which boundary an effect happens at, so it goes with an effect; a boundary entry names its boundary itself"
|
|
774
|
+
}
|
|
775
|
+
).refine(
|
|
776
|
+
(entry) => entry.outcomes === void 0 || typeof subjectOf2(entry) === "string",
|
|
777
|
+
{
|
|
778
|
+
message: "outcomes belong to a boundary; an effect entry says what it touches instead"
|
|
779
|
+
}
|
|
780
|
+
);
|
|
781
|
+
}
|
|
782
|
+
var ChangeEntrySchema = withEntryRules(
|
|
783
|
+
z2.strictObject({
|
|
784
|
+
...ENTRY_FIELDS,
|
|
785
|
+
asked: z2.string().min(1).describe(
|
|
786
|
+
"The developer's words that asked for this entry, when they are not the list's."
|
|
787
|
+
).optional(),
|
|
788
|
+
note: z2.string().min(1).describe("What the change is, for a subject suss has no words for.").optional()
|
|
789
|
+
})
|
|
790
|
+
);
|
|
791
|
+
var ExplainedEntrySchema = withEntryRules(
|
|
792
|
+
z2.strictObject({
|
|
793
|
+
...ENTRY_FIELDS,
|
|
794
|
+
why: z2.string().min(1).describe("Why the change stays, for the developer to read.")
|
|
795
|
+
})
|
|
796
|
+
);
|
|
797
|
+
var ChangeListSchema = z2.strictObject({
|
|
798
|
+
asked: z2.string().min(1).describe("The developer's request, quoted.").optional(),
|
|
799
|
+
changes: z2.array(ChangeEntrySchema).default([]).describe("Each behavior change the agent intends, one per entry."),
|
|
800
|
+
explained: z2.array(ExplainedEntrySchema).default([]).describe(
|
|
801
|
+
"Changes nobody asked for that the agent keeps, each with the reason."
|
|
802
|
+
)
|
|
803
|
+
});
|
|
804
|
+
var WORD_OUTCOMES = {
|
|
805
|
+
returns: { kind: "return", status: null, errorType: null },
|
|
806
|
+
throws: { kind: "throw", status: null, errorType: null }
|
|
807
|
+
};
|
|
808
|
+
function toOutcome2(word) {
|
|
809
|
+
if (typeof word === "number") {
|
|
810
|
+
return { kind: "response", status: word, errorType: null };
|
|
811
|
+
}
|
|
812
|
+
if (typeof word === "string") {
|
|
813
|
+
return WORD_OUTCOMES[word];
|
|
814
|
+
}
|
|
815
|
+
return { kind: "throw", status: null, errorType: word.throws };
|
|
816
|
+
}
|
|
817
|
+
function toSubject(written) {
|
|
818
|
+
return typeof written === "string" ? { kind: "boundary", names: written } : { kind: "effect", effect: toIntentEffect(written) };
|
|
819
|
+
}
|
|
820
|
+
function commonOf(entry) {
|
|
821
|
+
const [verb] = verbsOf(entry);
|
|
822
|
+
return {
|
|
823
|
+
verb,
|
|
824
|
+
subject: toSubject(entry[verb]),
|
|
825
|
+
outcomes: (entry.outcomes ?? []).map(toOutcome2),
|
|
826
|
+
at: entry.at ?? null
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
function parseChangeList(raw) {
|
|
830
|
+
const result = ChangeListSchema.safeParse(raw);
|
|
831
|
+
if (result.success) {
|
|
832
|
+
return { ok: true, list: changeListToSummary(result.data) };
|
|
833
|
+
}
|
|
834
|
+
return {
|
|
835
|
+
ok: false,
|
|
836
|
+
problems: result.error.issues.map((issue) => ({
|
|
837
|
+
path: issue.path.join("."),
|
|
838
|
+
message: issue.message
|
|
839
|
+
}))
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
function changeListToSummary(list) {
|
|
843
|
+
const asked = list.asked ?? null;
|
|
844
|
+
return {
|
|
845
|
+
asked,
|
|
846
|
+
changes: list.changes.map((entry) => ({
|
|
847
|
+
...commonOf(entry),
|
|
848
|
+
asked: entry.asked ?? asked,
|
|
849
|
+
note: entry.note ?? null
|
|
850
|
+
})),
|
|
851
|
+
explained: list.explained.map((entry) => ({
|
|
852
|
+
...commonOf(entry),
|
|
853
|
+
why: entry.why
|
|
854
|
+
}))
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// src/findings.ts
|
|
859
|
+
import { z as z3 } from "zod";
|
|
860
|
+
var IntentFindingKindSchema = z3.enum([
|
|
861
|
+
// System intent (kind: boundary) vs code:
|
|
862
|
+
"uncoveredOutcome",
|
|
863
|
+
// intent declares an outcome the code never produces
|
|
864
|
+
"unimplementedBoundary",
|
|
865
|
+
// intent boundary has no implementing code at all
|
|
866
|
+
"outcomeShapeMismatch",
|
|
867
|
+
// a matched outcome whose body shapes disagree
|
|
868
|
+
"undeclaredOutcome",
|
|
869
|
+
// code produces a REST status the intent doesn't declare
|
|
870
|
+
"unkeyableBoundary",
|
|
871
|
+
// intent boundary can't be keyed, so it can't be checked
|
|
872
|
+
"renamedBoundary",
|
|
873
|
+
// a declared store vanished and an undeclared one of the same system appeared with the same outcomes
|
|
874
|
+
"pathWithoutEffect",
|
|
875
|
+
// a transition producing a declared outcome lacks an effect the always block says every outcome has
|
|
876
|
+
"valueFromElsewhere",
|
|
877
|
+
// a column's value comes from somewhere other than the source a results line gives
|
|
878
|
+
// What the boundary is handed: the `receives` block against the paths
|
|
879
|
+
// the unit reads off its input.
|
|
880
|
+
"unreadInputField",
|
|
881
|
+
// intent declares a field no transition of the unit reads
|
|
882
|
+
"undeclaredInputRead",
|
|
883
|
+
// the unit reads a path the receives block does not list
|
|
884
|
+
// Outcome intent (kind: prd): whether each scenario's link resolves to
|
|
885
|
+
// a system-intent outcome.
|
|
886
|
+
"unlinkedScenario",
|
|
887
|
+
// scenario has neither a link nor a covering test
|
|
888
|
+
"danglingScenarioLink",
|
|
889
|
+
// link names an intent / outcome no boundary intent declares
|
|
890
|
+
"ambiguousScenarioLink",
|
|
891
|
+
// link resolves to two or more boundary intents sharing the name
|
|
892
|
+
// The same question for a scenario backed by a test: whether the test
|
|
893
|
+
// it lists exists, runs, and reaches what the scenario is about.
|
|
894
|
+
"missingCoveringTest",
|
|
895
|
+
// coveredBy lists a test no summary here is
|
|
896
|
+
"testMissesSubject",
|
|
897
|
+
// the test never reaches the subject, or reaches it only through a mock
|
|
898
|
+
"coveringTestSkipped",
|
|
899
|
+
// the test is marked to be skipped, so it does not run
|
|
900
|
+
// The same coverage question asked the other way: which declared
|
|
901
|
+
// behaviour has nobody written a reason for.
|
|
902
|
+
"undescribedOutcome"
|
|
903
|
+
// a declared outcome no PRD scenario links to (info)
|
|
904
|
+
]);
|
|
905
|
+
var IntentFindingSeveritySchema = z3.enum(["error", "warning", "info"]);
|
|
906
|
+
var IntentRefSchema = z3.object({
|
|
907
|
+
/** The intent doc's `name` (boundary) or `title` (prd). */
|
|
908
|
+
name: z3.string(),
|
|
909
|
+
/** The declared outcome id, when the finding is outcome-specific. */
|
|
910
|
+
outcomeId: z3.string().optional()
|
|
911
|
+
});
|
|
912
|
+
var IntentFindingSuppressionSchema = z3.object({
|
|
913
|
+
/** The rule's human-written justification. */
|
|
914
|
+
reason: z3.string(),
|
|
915
|
+
effect: z3.enum(["mark", "downgrade", "hide"]),
|
|
916
|
+
/** Original severity, present only when effect is "downgrade". */
|
|
917
|
+
originalSeverity: IntentFindingSeveritySchema.optional()
|
|
918
|
+
});
|
|
919
|
+
var IntentFindingSchema = z3.object({
|
|
920
|
+
kind: IntentFindingKindSchema,
|
|
921
|
+
severity: IntentFindingSeveritySchema,
|
|
922
|
+
/**
|
|
923
|
+
* Human-readable boundary label: e.g. `GET /users/:id` or
|
|
924
|
+
* `fn:@suss/cli::contract`. The key the intent and code were paired on.
|
|
925
|
+
*/
|
|
926
|
+
boundary: z3.string(),
|
|
927
|
+
/** The intent side of the finding. */
|
|
928
|
+
intent: IntentRefSchema,
|
|
929
|
+
/**
|
|
930
|
+
* The matched code summary as `${file}::${name}`, when the finding
|
|
931
|
+
* concerns a specific implementation. Absent for unimplementedBoundary.
|
|
932
|
+
*/
|
|
933
|
+
code: z3.string().optional(),
|
|
934
|
+
/**
|
|
935
|
+
* Present only on outcome-intent findings. It gives the scenario, by
|
|
936
|
+
* its optional title, and the qualified outcome ref
|
|
937
|
+
* (`<intent-name>.<outcome-id>`) that failed to resolve, or the
|
|
938
|
+
* `coveredBy` entry whose test failed the check. Boundary findings
|
|
939
|
+
* leave it unset.
|
|
940
|
+
*/
|
|
941
|
+
scenario: z3.object({
|
|
942
|
+
title: z3.string().optional(),
|
|
943
|
+
link: z3.string().optional(),
|
|
944
|
+
coveredBy: z3.string().optional()
|
|
945
|
+
}).optional(),
|
|
946
|
+
message: z3.string(),
|
|
947
|
+
/** Present when a .sussignore rule matched this finding. */
|
|
948
|
+
suppressed: IntentFindingSuppressionSchema.optional()
|
|
949
|
+
});
|
|
643
950
|
export {
|
|
644
951
|
ACCEPTS_NULL,
|
|
952
|
+
AlwaysEffectSchema,
|
|
645
953
|
BodyShapeSchema,
|
|
646
954
|
BoundarySchema,
|
|
955
|
+
ChangeListSchema,
|
|
956
|
+
EffectLineSchema,
|
|
957
|
+
EffectOutcomeSchema,
|
|
958
|
+
INPUT_SOURCE_PREFIX,
|
|
647
959
|
IntentDocSchema,
|
|
648
960
|
IntentFindingKindSchema,
|
|
649
961
|
IntentFindingSchema,
|
|
@@ -651,9 +963,15 @@ export {
|
|
|
651
963
|
IntentFindingSuppressionSchema,
|
|
652
964
|
IntentRefSchema,
|
|
653
965
|
IntentSourceSchema,
|
|
966
|
+
NODE_ID_SEPARATOR,
|
|
967
|
+
TEST_TITLE_SEPARATOR,
|
|
654
968
|
blanksLeftEmpty,
|
|
969
|
+
changeListToSummary,
|
|
655
970
|
fillBlanks,
|
|
656
971
|
intentDocToSummary,
|
|
972
|
+
parseChangeList,
|
|
657
973
|
toBoundaryBinding,
|
|
974
|
+
toCoveringTest,
|
|
975
|
+
toIntentEffect,
|
|
658
976
|
toReceives
|
|
659
977
|
};
|