specky-sdd 3.5.0 → 3.7.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.
Files changed (101) hide show
  1. package/.apm/hooks/scripts/ears-validator.sh +56 -17
  2. package/.apm/prompts/specky-api.prompt.md +1 -1
  3. package/.apm/prompts/specky-research.prompt.md +2 -2
  4. package/.apm/skills/research-analyst/SKILL.md +2 -2
  5. package/.apm/skills/specky-onboarding/SKILL.md +6 -4
  6. package/CHANGELOG.md +52 -0
  7. package/README.md +44 -8
  8. package/SECURITY.md +19 -9
  9. package/apm.yml +1 -1
  10. package/config.yml +1 -1
  11. package/dist/cli/commands/doctor.d.ts.map +1 -1
  12. package/dist/cli/commands/doctor.js +5 -0
  13. package/dist/cli/commands/doctor.js.map +1 -1
  14. package/dist/cli/commands/status.d.ts.map +1 -1
  15. package/dist/cli/commands/status.js +17 -2
  16. package/dist/cli/commands/status.js.map +1 -1
  17. package/dist/cli/index.js +32 -0
  18. package/dist/cli/index.js.map +1 -1
  19. package/dist/cli/lib/update-check.d.ts +36 -0
  20. package/dist/cli/lib/update-check.d.ts.map +1 -0
  21. package/dist/cli/lib/update-check.js +165 -0
  22. package/dist/cli/lib/update-check.js.map +1 -0
  23. package/dist/config.d.ts +4 -0
  24. package/dist/config.d.ts.map +1 -1
  25. package/dist/config.js +14 -0
  26. package/dist/config.js.map +1 -1
  27. package/dist/index.js +18 -0
  28. package/dist/index.js.map +1 -1
  29. package/dist/services/diagram-generator.d.ts +53 -4
  30. package/dist/services/diagram-generator.d.ts.map +1 -1
  31. package/dist/services/diagram-generator.js +565 -178
  32. package/dist/services/diagram-generator.js.map +1 -1
  33. package/dist/services/doc-generator.d.ts +44 -0
  34. package/dist/services/doc-generator.d.ts.map +1 -1
  35. package/dist/services/doc-generator.js +352 -50
  36. package/dist/services/doc-generator.js.map +1 -1
  37. package/dist/services/document-converter.d.ts +10 -7
  38. package/dist/services/document-converter.d.ts.map +1 -1
  39. package/dist/services/document-converter.js +113 -38
  40. package/dist/services/document-converter.js.map +1 -1
  41. package/dist/services/iac-generator.d.ts +31 -3
  42. package/dist/services/iac-generator.d.ts.map +1 -1
  43. package/dist/services/iac-generator.js +509 -41
  44. package/dist/services/iac-generator.js.map +1 -1
  45. package/dist/services/pbt-generator.d.ts +12 -0
  46. package/dist/services/pbt-generator.d.ts.map +1 -1
  47. package/dist/services/pbt-generator.js +296 -145
  48. package/dist/services/pbt-generator.js.map +1 -1
  49. package/dist/services/state-machine.d.ts +15 -1
  50. package/dist/services/state-machine.d.ts.map +1 -1
  51. package/dist/services/state-machine.js +21 -3
  52. package/dist/services/state-machine.js.map +1 -1
  53. package/dist/services/test-generator.d.ts +8 -0
  54. package/dist/services/test-generator.d.ts.map +1 -1
  55. package/dist/services/test-generator.js +134 -33
  56. package/dist/services/test-generator.js.map +1 -1
  57. package/dist/services/test-traceability-mapper.d.ts.map +1 -1
  58. package/dist/services/test-traceability-mapper.js +8 -3
  59. package/dist/services/test-traceability-mapper.js.map +1 -1
  60. package/dist/services/work-item-exporter.d.ts +70 -9
  61. package/dist/services/work-item-exporter.d.ts.map +1 -1
  62. package/dist/services/work-item-exporter.js +115 -53
  63. package/dist/services/work-item-exporter.js.map +1 -1
  64. package/dist/templates/checklist.md +2 -3
  65. package/dist/templates/cross-analysis.md +2 -4
  66. package/dist/tools/documentation.d.ts.map +1 -1
  67. package/dist/tools/documentation.js +9 -3
  68. package/dist/tools/documentation.js.map +1 -1
  69. package/dist/tools/environment.d.ts.map +1 -1
  70. package/dist/tools/environment.js +75 -12
  71. package/dist/tools/environment.js.map +1 -1
  72. package/dist/tools/infrastructure.d.ts.map +1 -1
  73. package/dist/tools/infrastructure.js +7 -39
  74. package/dist/tools/infrastructure.js.map +1 -1
  75. package/dist/tools/input.js +4 -4
  76. package/dist/tools/input.js.map +1 -1
  77. package/dist/tools/integration.js +4 -4
  78. package/dist/tools/integration.js.map +1 -1
  79. package/dist/tools/pipeline.d.ts.map +1 -1
  80. package/dist/tools/pipeline.js +42 -4
  81. package/dist/tools/pipeline.js.map +1 -1
  82. package/dist/tools/quality.d.ts.map +1 -1
  83. package/dist/tools/quality.js +36 -9
  84. package/dist/tools/quality.js.map +1 -1
  85. package/dist/tools/testing.d.ts +10 -0
  86. package/dist/tools/testing.d.ts.map +1 -1
  87. package/dist/tools/testing.js +66 -13
  88. package/dist/tools/testing.js.map +1 -1
  89. package/dist/tools/transcript.d.ts.map +1 -1
  90. package/dist/tools/transcript.js +66 -41
  91. package/dist/tools/transcript.js.map +1 -1
  92. package/dist/tools/utility.d.ts.map +1 -1
  93. package/dist/tools/utility.js +22 -12
  94. package/dist/tools/utility.js.map +1 -1
  95. package/dist/tools/visualization.d.ts +21 -0
  96. package/dist/tools/visualization.d.ts.map +1 -1
  97. package/dist/tools/visualization.js +130 -29
  98. package/dist/tools/visualization.js.map +1 -1
  99. package/package.json +5 -2
  100. package/templates/checklist.md +2 -3
  101. package/templates/cross-analysis.md +2 -4
@@ -32,12 +32,26 @@ export class PbtGenerator {
32
32
  extractEarsRequirements(text) {
33
33
  const results = [];
34
34
  const lines = text.split("\n");
35
- for (const line of lines) {
36
- if (/^(When|While|Where|If|The)\s+.+shall\s+/im.test(line)) {
37
- const reqMatch = line.match(/REQ-[A-Z]+-\d{3}/);
38
- const reqId = reqMatch ? reqMatch[0] : `REQ-GEN-${String(results.length + 1).padStart(3, "0")}`;
39
- results.push({ line: line.trim(), reqId });
35
+ let currentReq = null;
36
+ for (const rawLine of lines) {
37
+ const line = rawLine.trim();
38
+ // Skip table rows the acceptance-criteria summary table repeats
39
+ // requirement text and would corrupt both tracking and extraction.
40
+ if (line.startsWith("|"))
41
+ continue;
42
+ const reqMatch = line.match(/REQ-[A-Z]+-\d{3}/);
43
+ if (/^(When|While|Where|If|The)\s+.+shall\s+/i.test(line)) {
44
+ // Trace to the inline ID if present, otherwise to the requirement
45
+ // heading ("### REQ-XXX-NNN: ...") this sentence sits under. Never
46
+ // fabricate REQ-GEN-00N IDs — an untraceable sentence is skipped
47
+ // rather than mislabeled.
48
+ const reqId = reqMatch ? reqMatch[0] : currentReq;
49
+ if (reqId)
50
+ results.push({ line, reqId });
51
+ continue;
40
52
  }
53
+ if (reqMatch)
54
+ currentReq = reqMatch[0];
41
55
  }
42
56
  return results;
43
57
  }
@@ -104,12 +118,13 @@ export class PbtGenerator {
104
118
  }
105
119
  return "invariant";
106
120
  }
107
- buildProperties(requirements, framework, featureDir) {
121
+ buildProperties(requirements, framework, _featureDir) {
122
+ const usedNames = new Set();
108
123
  return requirements.map((req, i) => {
109
124
  const propId = `PROP-${String(i + 1).padStart(3, "0")}`;
110
125
  const propertyType = this.classifyPropertyType(req.line);
111
126
  const description = req.line.length > 100 ? req.line.slice(0, 97) + "..." : req.line;
112
- const testCode = this.generateTestCode(propId, description, req.line, propertyType, framework, featureDir);
127
+ const testCode = this.generateTestCode(propId, description, req.line, req.reqId, propertyType, framework, usedNames);
113
128
  return {
114
129
  id: propId,
115
130
  requirement_id: req.reqId,
@@ -119,222 +134,255 @@ export class PbtGenerator {
119
134
  };
120
135
  });
121
136
  }
122
- generateTestCode(propId, description, requirementText, propertyType, framework, _featureDir) {
137
+ generateTestCode(propId, description, requirementText, reqId, propertyType, framework, usedNames) {
123
138
  const safeDesc = description.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
124
139
  if (framework === "fast-check") {
125
- return this.generateFastCheckTest(propId, safeDesc, requirementText, propertyType);
140
+ return this.generateFastCheckTest(propId, safeDesc, requirementText, reqId, propertyType);
126
141
  }
127
- return this.generateHypothesisTest(propId, safeDesc, requirementText, propertyType);
142
+ return this.generateHypothesisTest(propId, description, requirementText, reqId, propertyType, usedNames);
128
143
  }
129
- generateFastCheckTest(propId, description, requirementText, propertyType) {
130
- const safeReq = requirementText.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/\n/g, '\\n').replace(/\r/g, '\\r');
144
+ /**
145
+ * Pick an input arbitrary/strategy informed by the requirement text where
146
+ * feasible; falls back to strings/text.
147
+ */
148
+ inferFcArbitrary(line) {
149
+ const lower = line.toLowerCase();
150
+ if (lower.includes("email"))
151
+ return "fc.emailAddress()";
152
+ if (/\b(count|number|amount|quantity|integer|limit|size|total|id)s?\b/.test(lower))
153
+ return "fc.integer()";
154
+ if (/\b(date|time|timestamp)s?\b/.test(lower))
155
+ return "fc.date()";
156
+ if (/\b(url|uri|link)s?\b/.test(lower))
157
+ return "fc.webUrl()";
158
+ return "fc.string()";
159
+ }
160
+ inferHypothesisStrategy(line) {
161
+ const lower = line.toLowerCase();
162
+ if (lower.includes("email"))
163
+ return "st.emails()";
164
+ if (/\b(count|number|amount|quantity|integer|limit|size|total|id)s?\b/.test(lower))
165
+ return "st.integers()";
166
+ if (/\b(date|time|timestamp)s?\b/.test(lower))
167
+ return "st.datetimes()";
168
+ return "st.text()";
169
+ }
170
+ generateFastCheckTest(propId, description, requirementText, reqId, propertyType) {
171
+ // Every body below is a REAL runnable property against the in-file model
172
+ // stubs defined at the top of the generated file — no undefined helpers,
173
+ // no TODO-only bodies. The model is what the developer replaces.
174
+ const open = (label) => [
175
+ ` // Traces to: ${reqId}`,
176
+ ` it("${propId} [${reqId}]: ${label}${description}", () => {`,
177
+ ` // Requirement: ${requirementText}`,
178
+ ` // Replace the in-file model with your real system call, keeping the property shape.`,
179
+ ` fc.assert(`,
180
+ ];
181
+ const close = [
182
+ ` { numRuns: 100 }`,
183
+ ` );`,
184
+ ` });`,
185
+ ];
131
186
  switch (propertyType) {
132
187
  case "invariant":
133
188
  return [
134
- ` it("${propId}: ${description}", () => {`,
135
- ` fc.assert(`,
136
- ` fc.property(fc.string(), fc.integer(), (input1, input2) => {`,
137
- ` // TODO: Replace with actual system call`,
138
- ` // Property: ${safeReq}`,
139
- ` const result = systemUnderTest(input1, input2);`,
140
- ` expect(result).toBeDefined();`,
141
- ` return true; // Replace with actual invariant check`,
189
+ ...open(""),
190
+ ` fc.property(${this.inferFcArbitrary(requirementText)}, fc.integer(), (input1, input2) => {`,
191
+ ` const first = systemUnderTest(input1, input2);`,
192
+ ` const second = systemUnderTest(input1, input2);`,
193
+ ` expect(first).toBeDefined();`,
194
+ ` expect(second).toEqual(first); // invariant: deterministic for identical inputs`,
142
195
  ` }),`,
143
- ` { numRuns: 100 }`,
144
- ` );`,
145
- ` });`,
196
+ ...close,
146
197
  ].join("\n");
147
198
  case "round_trip":
148
199
  return [
149
- ` it("${propId}: round-trip — ${description}", () => {`,
150
- ` fc.assert(`,
151
- ` fc.property(fc.jsonValue(), (input) => {`,
152
- ` // TODO: Verify round-trip: parse(serialize(x)) === x`,
153
- ` const serialized = serialize(input);`,
154
- ` const parsed = parse(serialized);`,
155
- ` expect(parsed).toEqual(input);`,
156
- ` return true;`,
200
+ ...open("round-trip — "),
201
+ ` fc.property(fc.jsonValue(), (value) => {`,
202
+ ` const restored = parse(serialize(value));`,
203
+ ` expect(restored).toEqual(value); // round-trip: parse(serialize(x)) === x`,
157
204
  ` }),`,
158
- ` { numRuns: 100 }`,
159
- ` );`,
160
- ` });`,
205
+ ...close,
161
206
  ].join("\n");
162
207
  case "idempotence":
163
208
  return [
164
- ` it("${propId}: idempotence — ${description}", () => {`,
165
- ` fc.assert(`,
166
- ` fc.property(fc.anything(), (input) => {`,
167
- ` // TODO: Verify f(f(x)) === f(x)`,
168
- ` const once = operation(input);`,
169
- ` const twice = operation(once);`,
170
- ` expect(twice).toEqual(once);`,
171
- ` return true;`,
209
+ ...open("idempotence — "),
210
+ ` fc.property(fc.string(), (raw) => {`,
211
+ ` const once = normalize(raw);`,
212
+ ` expect(normalize(once)).toEqual(once); // idempotence: f(f(x)) === f(x)`,
172
213
  ` }),`,
173
- ` { numRuns: 100 }`,
174
- ` );`,
175
- ` });`,
214
+ ...close,
176
215
  ].join("\n");
177
216
  case "state_transition":
178
217
  return [
179
- ` it("${propId}: state transition — ${description}", () => {`,
180
- ` fc.assert(`,
181
- ` fc.property(fc.record({ event: fc.string(), state: fc.string() }), ({ event, state }) => {`,
182
- ` // TODO: Verify state transition is valid`,
183
- ` const newState = transition(state, event);`,
184
- ` expect(newState).toBeDefined();`,
185
- ` return true;`,
218
+ ...open("state transition — "),
219
+ ` fc.property(fc.record({ state: fc.string(), event: fc.string() }), ({ state, event }) => {`,
220
+ ` const next = transition(state, event);`,
221
+ ` expect(typeof next).toBe("string"); // total: every event yields a state`,
222
+ ` expect(transition(state, event)).toEqual(next); // deterministic transition`,
186
223
  ` }),`,
187
- ` { numRuns: 100 }`,
188
- ` );`,
189
- ` });`,
224
+ ...close,
190
225
  ].join("\n");
191
226
  case "negative":
192
227
  return [
193
- ` it("${propId}: negative — ${description}", () => {`,
194
- ` fc.assert(`,
228
+ ...open("negative — "),
195
229
  ` fc.property(fc.string(), (maliciousInput) => {`,
196
- ` // TODO: Verify unwanted behavior never occurs`,
197
- ` const result = systemUnderTest(maliciousInput);`,
230
+ ` expect(() => handleUntrusted(maliciousInput)).not.toThrow(); // unwanted behavior never crashes`,
231
+ ` const result = handleUntrusted(maliciousInput);`,
232
+ ` expect(result.ok).toBe(true);`,
198
233
  ` expect(result.error).not.toContain("crash");`,
199
- ` return true;`,
200
234
  ` }),`,
201
- ` { numRuns: 100 }`,
202
- ` );`,
203
- ` });`,
235
+ ...close,
204
236
  ].join("\n");
205
237
  case "conditional":
206
238
  return [
207
- ` it("${propId}: conditional — ${description}", () => {`,
208
- ` fc.assert(`,
209
- ` fc.property(fc.boolean(), fc.anything(), (condition, input) => {`,
210
- ` // TODO: Verify behavior only when condition holds`,
211
- ` fc.pre(condition); // Only test when condition is true`,
212
- ` const result = systemUnderTest(input);`,
213
- ` expect(result).toBeDefined();`,
214
- ` return true;`,
239
+ ...open("conditional — "),
240
+ ` fc.property(fc.boolean(), ${this.inferFcArbitrary(requirementText)}, (condition, input) => {`,
241
+ ` fc.pre(condition); // property applies only while the condition holds`,
242
+ ` const first = systemUnderTest(input);`,
243
+ ` expect(first).toBeDefined();`,
244
+ ` expect(systemUnderTest(input)).toEqual(first); // stable under the condition`,
215
245
  ` }),`,
216
- ` { numRuns: 100 }`,
217
- ` );`,
218
- ` });`,
246
+ ...close,
219
247
  ].join("\n");
220
248
  case "commutativity":
221
249
  return [
222
- ` it("${propId}: commutativity — ${description}", () => {`,
223
- ` fc.assert(`,
250
+ ...open("commutativity — "),
224
251
  ` fc.property(fc.string(), fc.string(), (a, b) => {`,
225
- ` // TODO: Verify f(a, b) === f(b, a)`,
226
- ` expect(operation(a, b)).toEqual(operation(b, a));`,
227
- ` return true;`,
252
+ ` expect(combine(a, b)).toEqual(combine(b, a)); // commutativity: f(a, b) === f(b, a)`,
228
253
  ` }),`,
229
- ` { numRuns: 100 }`,
230
- ` );`,
231
- ` });`,
254
+ ...close,
232
255
  ].join("\n");
233
256
  case "monotonicity":
234
257
  return [
235
- ` it("${propId}: monotonicity — ${description}", () => {`,
236
- ` fc.assert(`,
258
+ ...open("monotonicity — "),
237
259
  ` fc.property(fc.integer(), fc.integer(), (x, y) => {`,
238
- ` // TODO: Verify x <= y implies f(x) <= f(y)`,
239
260
  ` fc.pre(x <= y);`,
240
- ` expect(f(x)).toBeLessThanOrEqual(f(y));`,
241
- ` return true;`,
261
+ ` expect(scale(x)).toBeLessThanOrEqual(scale(y)); // monotone: x <= y implies f(x) <= f(y)`,
242
262
  ` }),`,
243
- ` { numRuns: 100 }`,
244
- ` );`,
245
- ` });`,
263
+ ...close,
246
264
  ].join("\n");
247
265
  }
248
266
  }
249
- generateHypothesisTest(propId, description, requirementText, propertyType) {
250
- const descSnake = this.snakeCase(description);
267
+ generateHypothesisTest(propId, description, requirementText, reqId, propertyType, usedNames) {
268
+ // Every body below is a REAL runnable property against the in-file model
269
+ // stubs defined at module level in the generated file — no undefined
270
+ // helpers, no TODO-only bodies. The model is what the developer replaces.
271
+ const testName = (label) => this.uniqueName(`test_${this.snakeCase(`${propId} ${reqId} ${label} ${description}`)}`, usedNames);
272
+ const docstring = `${propId} [${reqId}]: ${requirementText.replace(/\\/g, "/").replace(/"/g, "'")}`;
273
+ const modelNote = " # Replace the in-file model with your real system call, keeping the property shape.";
251
274
  switch (propertyType) {
252
275
  case "invariant":
253
276
  return [
254
- ` @given(input1=st.text(), input2=st.integers())`,
277
+ ` # Traces to: ${reqId}`,
278
+ ` @given(input1=${this.inferHypothesisStrategy(requirementText)}, input2=st.integers())`,
255
279
  ` @settings(max_examples=100)`,
256
- ` def test_${this.snakeCase(propId)}_${descSnake}(self, input1, input2):`,
257
- ` """${propId}: ${requirementText}"""`,
258
- ` # TODO: Replace with actual system call`,
259
- ` result = system_under_test(input1, input2)`,
260
- ` assert result is not None`,
280
+ ` def ${testName("")}(self, input1, input2):`,
281
+ ` """${docstring}"""`,
282
+ modelNote,
283
+ ` first = system_under_test(input1, input2)`,
284
+ ` second = system_under_test(input1, input2)`,
285
+ ` assert first is not None`,
286
+ ` assert second == first # invariant: deterministic for identical inputs`,
261
287
  ].join("\n");
262
288
  case "round_trip":
263
289
  return [
264
- ` @given(data=st.from_type(dict))`,
290
+ ` # Traces to: ${reqId}`,
291
+ ` @given(data=st.recursive(st.none() | st.booleans() | st.integers() | st.floats(allow_nan=False, allow_infinity=False) | st.text(), lambda children: st.lists(children) | st.dictionaries(st.text(), children), max_leaves=10))`,
265
292
  ` @settings(max_examples=100)`,
266
- ` def test_${this.snakeCase(propId)}_round_trip_${descSnake}(self, data):`,
267
- ` """${propId}: round-trip — ${requirementText}"""`,
268
- ` # TODO: Verify round-trip: parse(serialize(x)) == x`,
269
- ` serialized = serialize(data)`,
270
- ` parsed = parse(serialized)`,
271
- ` assert parsed == data`,
293
+ ` def ${testName("round trip")}(self, data):`,
294
+ ` """${docstring}"""`,
295
+ modelNote,
296
+ ` restored = parse(serialize(data))`,
297
+ ` assert restored == data # round-trip: parse(serialize(x)) == x`,
272
298
  ].join("\n");
273
299
  case "idempotence":
274
300
  return [
275
- ` @given(data=st.text())`,
301
+ ` # Traces to: ${reqId}`,
302
+ ` @given(raw=st.text())`,
276
303
  ` @settings(max_examples=100)`,
277
- ` def test_${this.snakeCase(propId)}_idempotence_${descSnake}(self, data):`,
278
- ` """${propId}: idempotence — ${requirementText}"""`,
279
- ` # TODO: Verify f(f(x)) == f(x)`,
280
- ` once = operation(data)`,
281
- ` twice = operation(once)`,
282
- ` assert twice == once`,
304
+ ` def ${testName("idempotence")}(self, raw):`,
305
+ ` """${docstring}"""`,
306
+ modelNote,
307
+ ` once = normalize(raw)`,
308
+ ` assert normalize(once) == once # idempotence: f(f(x)) == f(x)`,
283
309
  ].join("\n");
284
310
  case "state_transition":
285
311
  return [
312
+ ` # Traces to: ${reqId}`,
286
313
  ` @given(event=st.text(), state=st.text())`,
287
314
  ` @settings(max_examples=100)`,
288
- ` def test_${this.snakeCase(propId)}_transition_${descSnake}(self, event, state):`,
289
- ` """${propId}: ${requirementText}"""`,
290
- ` # TODO: Verify state transition`,
315
+ ` def ${testName("transition")}(self, event, state):`,
316
+ ` """${docstring}"""`,
317
+ modelNote,
291
318
  ` new_state = transition(state, event)`,
292
- ` assert new_state is not None`,
319
+ ` assert new_state is not None # total: every event yields a state`,
320
+ ` assert transition(state, event) == new_state # deterministic transition`,
293
321
  ].join("\n");
294
322
  case "negative":
295
323
  return [
324
+ ` # Traces to: ${reqId}`,
296
325
  ` @given(malicious_input=st.text())`,
297
326
  ` @settings(max_examples=100)`,
298
- ` def test_${this.snakeCase(propId)}_negative_${descSnake}(self, malicious_input):`,
299
- ` """${propId}: ${requirementText}"""`,
300
- ` # TODO: Verify unwanted behavior never occurs`,
301
- ` result = system_under_test(malicious_input)`,
302
- ` assert "crash" not in str(result)`,
327
+ ` def ${testName("negative")}(self, malicious_input):`,
328
+ ` """${docstring}"""`,
329
+ modelNote,
330
+ ` result = handle_untrusted(malicious_input)`,
331
+ ` assert result["ok"] is True # unwanted behavior never crashes the guard`,
332
+ ` assert "crash" not in result["error"]`,
303
333
  ].join("\n");
304
334
  case "conditional":
305
335
  return [
306
- ` @given(condition=st.booleans(), data=st.text())`,
336
+ ` # Traces to: ${reqId}`,
337
+ ` @given(condition=st.booleans(), data=${this.inferHypothesisStrategy(requirementText)})`,
307
338
  ` @settings(max_examples=100)`,
308
- ` def test_${this.snakeCase(propId)}_conditional_${descSnake}(self, condition, data):`,
309
- ` """${propId}: ${requirementText}"""`,
310
- ` assume(condition) # Only test when condition holds`,
311
- ` result = system_under_test(data)`,
312
- ` assert result is not None`,
339
+ ` def ${testName("conditional")}(self, condition, data):`,
340
+ ` """${docstring}"""`,
341
+ ` assume(condition) # property applies only while the condition holds`,
342
+ modelNote,
343
+ ` first = system_under_test(data)`,
344
+ ` assert first is not None`,
345
+ ` assert system_under_test(data) == first # stable under the condition`,
313
346
  ].join("\n");
314
347
  case "commutativity":
315
348
  return [
349
+ ` # Traces to: ${reqId}`,
316
350
  ` @given(a=st.text(), b=st.text())`,
317
351
  ` @settings(max_examples=100)`,
318
- ` def test_${this.snakeCase(propId)}_commutativity_${descSnake}(self, a, b):`,
319
- ` """${propId}: commutativity — ${requirementText}"""`,
320
- ` # TODO: Verify f(a, b) == f(b, a)`,
321
- ` assert operation(a, b) == operation(b, a)`,
352
+ ` def ${testName("commutativity")}(self, a, b):`,
353
+ ` """${docstring}"""`,
354
+ modelNote,
355
+ ` assert combine(a, b) == combine(b, a) # commutativity: f(a, b) == f(b, a)`,
322
356
  ].join("\n");
323
357
  case "monotonicity":
324
358
  return [
359
+ ` # Traces to: ${reqId}`,
325
360
  ` @given(x=st.integers(), y=st.integers())`,
326
361
  ` @settings(max_examples=100)`,
327
- ` def test_${this.snakeCase(propId)}_monotonicity_${descSnake}(self, x, y):`,
328
- ` """${propId}: monotonicity — ${requirementText}"""`,
329
- ` assume(x <= y) # Only test ordered pairs`,
330
- ` assert f(x) <= f(y)`,
362
+ ` def ${testName("monotonicity")}(self, x, y):`,
363
+ ` """${docstring}"""`,
364
+ ` assume(x <= y) # only ordered pairs`,
365
+ modelNote,
366
+ ` assert scale(x) <= scale(y) # monotone: x <= y implies f(x) <= f(y)`,
331
367
  ].join("\n");
332
368
  }
333
369
  }
370
+ /** Deduplicate generated method names — Python silently shadows duplicates. */
371
+ uniqueName(base, used) {
372
+ let candidate = base;
373
+ let counter = 2;
374
+ while (used.has(candidate)) {
375
+ candidate = `${base}_${counter}`;
376
+ counter++;
377
+ }
378
+ used.add(candidate);
379
+ return candidate;
380
+ }
334
381
  renderFile(properties, framework, featureDir) {
335
382
  const featureName = featureDir.replace(/.*\d{3}-/, "").replace(/[^a-zA-Z0-9 -]/g, "") || "Feature";
336
383
  const date = currentDateString();
337
384
  const body = properties.map((p) => p.test_code).join("\n\n");
385
+ const usedTypes = new Set(properties.map((p) => p.property_type));
338
386
  if (framework === "fast-check") {
339
387
  const header = [
340
388
  `/**`,
@@ -344,14 +392,17 @@ export class PbtGenerator {
344
392
  ` * Generated: ${date}`,
345
393
  ` *`,
346
394
  ` * Each property traces to an EARS requirement from SPECIFICATION.md.`,
347
- ` * Replace the TODO placeholders with real system calls.`,
395
+ ` * Replace the in-file model stubs with real system calls.`,
348
396
  ` */`,
349
397
  ].join("\n");
398
+ // fast-check ships a default export named fc — there is no `{ fc }`
399
+ // named export, so the destructured form throws at import time.
350
400
  const imports = [
351
- `import { fc } from "fast-check";`,
401
+ `import fc from "fast-check";`,
352
402
  `import { describe, it, expect } from "vitest";`,
353
403
  ].join("\n");
354
- return `${header}\n\n${imports}\n\ndescribe("Property-Based Tests ${featureName}", () => {\n${body}\n});\n`;
404
+ const models = properties.length > 0 ? `${this.fastCheckModels(usedTypes)}\n\n` : "";
405
+ return `${header}\n\n${imports}\n\n${models}describe("Property-Based Tests — ${featureName}", () => {\n${body}\n});\n`;
355
406
  }
356
407
  // hypothesis (Python)
357
408
  const header = [
@@ -362,22 +413,122 @@ export class PbtGenerator {
362
413
  `Generated: ${date}`,
363
414
  ``,
364
415
  `Each property traces to an EARS requirement from SPECIFICATION.md.`,
365
- `Replace the TODO placeholders with real system calls.`,
416
+ `Replace the in-file model stubs with real system calls.`,
366
417
  `"""`,
367
418
  ].join("\n");
368
- const imports = [
369
- `import hypothesis`,
370
- `from hypothesis import given, assume, settings`,
419
+ const importLines = [
420
+ ...(usedTypes.has("round_trip") ? ["import json", ""] : []),
421
+ `from hypothesis import assume, given, settings`,
371
422
  `from hypothesis import strategies as st`,
372
- ].join("\n");
423
+ ];
424
+ const imports = importLines.join("\n");
425
+ const models = properties.length > 0 ? `\n\n\n${this.hypothesisModels(usedTypes)}` : "";
373
426
  const className = featureName.replace(/[^a-zA-Z0-9]/g, "");
374
- return `${header}\n\n${imports}\n\n\nclass TestPropertyBased_${className}:\n """Property-based tests generated from EARS requirements."""\n\n${body}\n`;
427
+ return `${header}\n\n${imports}${models}\n\n\nclass TestPropertyBased_${className}:\n """Property-based tests generated from EARS requirements."""\n\n${body}\n`;
428
+ }
429
+ /** In-file deterministic models so every generated property runs as-is. */
430
+ fastCheckModels(types) {
431
+ const lines = [
432
+ `// ── In-file model stubs ──────────────────────────────────────────────`,
433
+ `// Every property below is runnable against these deterministic models.`,
434
+ `// Replace each model with a call into your real system under test and`,
435
+ `// keep the property shape — the model is the TODO, not the property.`,
436
+ ];
437
+ if (types.has("invariant") || types.has("conditional")) {
438
+ lines.push(`const systemUnderTest = (...inputs: unknown[]): { ok: boolean; inputs: unknown[] } => ({ ok: true, inputs });`);
439
+ }
440
+ if (types.has("state_transition")) {
441
+ lines.push(`const transition = (state: string, event: string): string => (event.length > 0 ? event : state);`);
442
+ }
443
+ if (types.has("round_trip")) {
444
+ lines.push(`const serialize = (value: unknown): string => JSON.stringify(value);`, `const parse = (text: string): unknown => JSON.parse(text);`);
445
+ }
446
+ if (types.has("idempotence")) {
447
+ lines.push(`const normalize = (value: string): string => value.trim().toLowerCase();`);
448
+ }
449
+ if (types.has("negative")) {
450
+ lines.push(`const handleUntrusted = (payload: string): { ok: boolean; error: string } => ({ ok: typeof payload === "string", error: "" });`);
451
+ }
452
+ if (types.has("commutativity")) {
453
+ lines.push(`const combine = (a: string, b: string): string => [a, b].sort().join("|");`);
454
+ }
455
+ if (types.has("monotonicity")) {
456
+ lines.push(`const scale = (x: number): number => 2 * x + 1;`);
457
+ }
458
+ return lines.join("\n");
459
+ }
460
+ /** In-file deterministic models so every generated property runs as-is. */
461
+ hypothesisModels(types) {
462
+ const blocks = [
463
+ [
464
+ `# ── In-file model stubs ──────────────────────────────────────────────`,
465
+ `# Every property below is runnable against these deterministic models.`,
466
+ `# Replace each model with a call into your real system under test and`,
467
+ `# keep the property shape — the model is the TODO, not the property.`,
468
+ ].join("\n"),
469
+ ];
470
+ if (types.has("invariant") || types.has("conditional")) {
471
+ blocks.push([
472
+ `def system_under_test(*inputs):`,
473
+ ` """Deterministic model — replace with the real system call."""`,
474
+ ` return {"ok": True, "inputs": inputs}`,
475
+ ].join("\n"));
476
+ }
477
+ if (types.has("state_transition")) {
478
+ blocks.push([
479
+ `def transition(state, event):`,
480
+ ` """Pure transition model — replace with the real transition function."""`,
481
+ ` return event if event else state`,
482
+ ].join("\n"));
483
+ }
484
+ if (types.has("round_trip")) {
485
+ blocks.push([
486
+ `def serialize(value):`,
487
+ ` """JSON codec model — replace with the real serializer."""`,
488
+ ` return json.dumps(value)`,
489
+ ].join("\n"));
490
+ blocks.push([
491
+ `def parse(text):`,
492
+ ` """JSON codec model — replace with the real parser."""`,
493
+ ` return json.loads(text)`,
494
+ ].join("\n"));
495
+ }
496
+ if (types.has("idempotence")) {
497
+ blocks.push([
498
+ `def normalize(value):`,
499
+ ` """Normalization model — replace with the real operation."""`,
500
+ ` return value.strip().lower()`,
501
+ ].join("\n"));
502
+ }
503
+ if (types.has("negative")) {
504
+ blocks.push([
505
+ `def handle_untrusted(payload):`,
506
+ ` """Input-guard model — never raises, always returns a structured result."""`,
507
+ ` return {"ok": isinstance(payload, str), "error": ""}`,
508
+ ].join("\n"));
509
+ }
510
+ if (types.has("commutativity")) {
511
+ blocks.push([
512
+ `def combine(a, b):`,
513
+ ` """Order-independent model — replace with the real operation."""`,
514
+ ` return tuple(sorted((a, b)))`,
515
+ ].join("\n"));
516
+ }
517
+ if (types.has("monotonicity")) {
518
+ blocks.push([
519
+ `def scale(x):`,
520
+ ` """Monotone model — replace with the real function."""`,
521
+ ` return 2 * x + 1`,
522
+ ].join("\n"));
523
+ }
524
+ return blocks.join("\n\n\n");
375
525
  }
376
526
  snakeCase(s) {
377
527
  return s
378
528
  .replace(/[^a-zA-Z0-9]+/g, "_")
379
529
  .toLowerCase()
380
- .slice(0, 60);
530
+ .slice(0, 60)
531
+ .replace(/^_+|_+$/g, "");
381
532
  }
382
533
  async safeRead(featureDir, file) {
383
534
  try {