@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/dist/index.js CHANGED
@@ -1,95 +1,45 @@
1
- // src/findings.ts
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 as z2 } from "zod";
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
- var IntentSourceSchema = z2.enum(["author", "inferred", "inferred, curated"]).default("author").describe(
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 = z2.enum([
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 = z2.lazy(
137
- () => z2.union([
138
- z2.object({ type: PrimitiveTypeName }),
139
- z2.object({ type: z2.literal("array"), items: ShapeSchema.optional() }),
140
- z2.object({
141
- type: z2.literal("object"),
142
- properties: z2.record(z2.string(), ShapeSchema).optional(),
143
- required: z2.array(z2.string()).optional()
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 = z2.union([
104
+ var BodyShapeSchema = z.union([
148
105
  ShapeSchema,
149
- z2.object({
150
- properties: z2.record(z2.string(), ShapeSchema).optional(),
151
- required: z2.array(z2.string()).optional()
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: z2.boolean().default(false).describe("Whether the boundary needs this field.")
112
+ required: z.boolean().default(false).describe("Whether the boundary needs this field.")
156
113
  };
157
- var InputFieldSchema = z2.union([
158
- z2.strictObject({ ...REQUIRED, type: PrimitiveTypeName }),
159
- z2.strictObject({
114
+ var InputFieldSchema = z.union([
115
+ z.strictObject({ ...REQUIRED, type: PrimitiveTypeName }),
116
+ z.strictObject({
160
117
  ...REQUIRED,
161
- type: z2.literal("array"),
118
+ type: z.literal("array"),
162
119
  items: ShapeSchema.optional()
163
120
  }),
164
- z2.strictObject({
121
+ z.strictObject({
165
122
  ...REQUIRED,
166
- type: z2.literal("object"),
167
- properties: z2.record(z2.string(), ShapeSchema).optional()
123
+ type: z.literal("object"),
124
+ properties: z.record(z.string(), ShapeSchema).optional()
168
125
  }),
169
- z2.strictObject(REQUIRED)
126
+ z.strictObject(REQUIRED)
170
127
  ]);
171
- var ReceivesSchema = z2.record(z2.string().min(1), InputFieldSchema).describe(
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 = z2.strictObject({
175
- headers: z2.record(z2.string().min(1), InputFieldSchema).describe("The request headers the boundary depends on, by name.").optional(),
176
- query: z2.record(z2.string().min(1), InputFieldSchema).describe("The query-string parameters the boundary depends on, by name.").optional(),
177
- params: z2.record(z2.string().min(1), InputFieldSchema).describe("The path parameters the boundary depends on, by name.").optional(),
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 = z2.strictObject({
183
- transport: z2.literal("http").default("http"),
184
- semantics: z2.literal("rest"),
185
- method: z2.string().min(1).describe("The HTTP method the route handles."),
186
- path: z2.string().min(1).describe("The route path, written the way the framework declares it."),
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 = z2.strictObject({
190
- transport: z2.string().default("in-process"),
191
- semantics: z2.literal("function-call"),
192
- module: z2.string().describe(
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: z2.string().describe("The name the module or package exports the function under.").optional(),
196
- package: z2.string().describe(
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: z2.array(z2.string()).describe(
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 = z2.strictObject({
205
- semantics: z2.literal("message-bus"),
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 = z2.strictObject({
215
- semantics: z2.literal("storage"),
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 = z2.strictObject({
231
- semantics: z2.literal("unit-invocation"),
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 = z2.discriminatedUnion("semantics", [
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 = z2.object({
248
- status: z2.number().int().min(100).max(599).describe("The HTTP status code the response carries."),
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 = z2.object({
208
+ var ReturnsOutcomeSchema = z.object({
252
209
  body: BodyShapeSchema.describe("The shape of the returned value.").optional()
253
210
  });
254
- var ThrowsOutcomeSchema = z2.object({
255
- errorType: z2.string().describe("The name of the error class this outcome raises.").optional()
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 z2.preprocess((v) => v === null ? {} : v, schema).meta({ [ACCEPTS_NULL]: true });
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 ONE_OR_MORE = z2.union([
262
- z2.string().min(1),
263
- z2.array(z2.string().min(1)).min(1)
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
- var EFFECT_BY_VERB = EffectRelationSchema.options.map(
266
- (verb) => z2.strictObject({
267
- [verb]: z2.string().min(1),
268
- fields: z2.array(z2.string().min(1)).min(1).optional(),
269
- by: ONE_OR_MORE.optional()
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: z2.enum(["nothing", "something"]),
255
+ finds: z.enum(["nothing", "something"]),
276
256
  /** What state the value was in: `set`, `missing`, `null`, `a string`. */
277
- is: z2.string().min(1),
257
+ is: z.string().min(1),
278
258
  /** The value it was equal to. */
279
- equals: z2.union([z2.string(), z2.number(), z2.boolean(), z2.null()]),
259
+ equals: z.union([z.string(), z.number(), z.boolean(), z.null()]),
280
260
  /** A property it had. */
281
- has: z2.string().min(1)
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) => z2.strictObject({
289
- [key]: z2.string().min(1),
290
- where: z2.string().min(1).optional(),
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 = z2.union([
307
- z2.string().min(1),
308
- z2.array(z2.union([z2.string().min(1), ...WHEN_CLAUSE_BY_SUBJECT])).min(1)
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 = z2.strictObject({
311
- id: z2.string().min(1).describe(
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
- results: z2.array(EffectOutcomeSchema).min(1).describe(
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 throws"
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((o) => o !== void 0);
310
+ return [t.response, t.returns, t.throws, t.exits].filter(
311
+ (o) => o !== void 0
312
+ );
330
313
  }
331
- var BoundaryIntentSchema = z2.strictObject({
332
- kind: z2.literal("boundary").describe("Makes this document boundary intent for one boundary."),
333
- name: z2.string().min(1).describe(
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: z2.string().min(1).describe("What the boundary is for, in the author's own words."),
337
- audience: z2.string().min(1).describe("Who calls this boundary and depends on what it does."),
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: z2.array(BoundaryTransitionSchema).min(1).describe("Every outcome the boundary can produce, one entry each.")
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
- var PrdScenarioSchema = z2.strictObject({
345
- title: z2.string().min(1).describe("A short name for the scenario.").optional(),
346
- when: z2.string().min(1).describe("The condition the scenario covers, in the author's own words."),
347
- expect: z2.string().min(1).describe("What should happen then, in the author's own words."),
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
- * Optional structured link(s) to system-intent outcomes
350
- * (`<intent-name>.<outcome-id>`). A scenario without `link` is a
351
- * valid pending-link state: fully human-readable, not yet machine-
352
- * linked. The link is filled in later by a facilitator (a person, a
353
- * platform, or an LLM at authoring time), never required to author.
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
- * (The human-readable parts are `when` / `expect`; the field is named
356
- * `link`, not `then`, because a data object with a `then` property is
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: z2.union([z2.string().min(1), z2.array(z2.string().min(1)).min(1)]).describe(
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 PrdSchema = z2.strictObject({
364
- kind: z2.literal("prd").describe("Makes this document a PRD, a set of scenarios for a feature."),
365
- title: z2.string().min(1).describe("What this document is called."),
366
- purpose: z2.string().min(1).describe("What the feature is for, in the author's own words."),
367
- audience: z2.string().min(1).describe("Who the feature is for."),
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: z2.array(PrdScenarioSchema).min(1).describe("The situations the feature covers, one entry each.")
405
+ scenarios: z.array(PrdScenarioSchema).min(1).describe("The situations the feature covers, one entry each.")
370
406
  });
371
- var IntentDocSchema = z2.discriminatedUnion("kind", [
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: s.link === void 0 ? [] : Array.isArray(s.link) ? s.link : [s.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 toEffect(declared) {
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(toEffect);
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
  };