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.
- package/.apm/hooks/scripts/ears-validator.sh +56 -17
- package/.apm/prompts/specky-api.prompt.md +1 -1
- package/.apm/prompts/specky-research.prompt.md +2 -2
- package/.apm/skills/research-analyst/SKILL.md +2 -2
- package/.apm/skills/specky-onboarding/SKILL.md +6 -4
- package/CHANGELOG.md +52 -0
- package/README.md +44 -8
- package/SECURITY.md +19 -9
- package/apm.yml +1 -1
- package/config.yml +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/doctor.js +5 -0
- package/dist/cli/commands/doctor.js.map +1 -1
- package/dist/cli/commands/status.d.ts.map +1 -1
- package/dist/cli/commands/status.js +17 -2
- package/dist/cli/commands/status.js.map +1 -1
- package/dist/cli/index.js +32 -0
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/lib/update-check.d.ts +36 -0
- package/dist/cli/lib/update-check.d.ts.map +1 -0
- package/dist/cli/lib/update-check.js +165 -0
- package/dist/cli/lib/update-check.js.map +1 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +14 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +18 -0
- package/dist/index.js.map +1 -1
- package/dist/services/diagram-generator.d.ts +53 -4
- package/dist/services/diagram-generator.d.ts.map +1 -1
- package/dist/services/diagram-generator.js +565 -178
- package/dist/services/diagram-generator.js.map +1 -1
- package/dist/services/doc-generator.d.ts +44 -0
- package/dist/services/doc-generator.d.ts.map +1 -1
- package/dist/services/doc-generator.js +352 -50
- package/dist/services/doc-generator.js.map +1 -1
- package/dist/services/document-converter.d.ts +10 -7
- package/dist/services/document-converter.d.ts.map +1 -1
- package/dist/services/document-converter.js +113 -38
- package/dist/services/document-converter.js.map +1 -1
- package/dist/services/iac-generator.d.ts +31 -3
- package/dist/services/iac-generator.d.ts.map +1 -1
- package/dist/services/iac-generator.js +509 -41
- package/dist/services/iac-generator.js.map +1 -1
- package/dist/services/pbt-generator.d.ts +12 -0
- package/dist/services/pbt-generator.d.ts.map +1 -1
- package/dist/services/pbt-generator.js +296 -145
- package/dist/services/pbt-generator.js.map +1 -1
- package/dist/services/state-machine.d.ts +15 -1
- package/dist/services/state-machine.d.ts.map +1 -1
- package/dist/services/state-machine.js +21 -3
- package/dist/services/state-machine.js.map +1 -1
- package/dist/services/test-generator.d.ts +8 -0
- package/dist/services/test-generator.d.ts.map +1 -1
- package/dist/services/test-generator.js +134 -33
- package/dist/services/test-generator.js.map +1 -1
- package/dist/services/test-traceability-mapper.d.ts.map +1 -1
- package/dist/services/test-traceability-mapper.js +8 -3
- package/dist/services/test-traceability-mapper.js.map +1 -1
- package/dist/services/work-item-exporter.d.ts +70 -9
- package/dist/services/work-item-exporter.d.ts.map +1 -1
- package/dist/services/work-item-exporter.js +115 -53
- package/dist/services/work-item-exporter.js.map +1 -1
- package/dist/templates/checklist.md +2 -3
- package/dist/templates/cross-analysis.md +2 -4
- package/dist/tools/documentation.d.ts.map +1 -1
- package/dist/tools/documentation.js +9 -3
- package/dist/tools/documentation.js.map +1 -1
- package/dist/tools/environment.d.ts.map +1 -1
- package/dist/tools/environment.js +75 -12
- package/dist/tools/environment.js.map +1 -1
- package/dist/tools/infrastructure.d.ts.map +1 -1
- package/dist/tools/infrastructure.js +7 -39
- package/dist/tools/infrastructure.js.map +1 -1
- package/dist/tools/input.js +4 -4
- package/dist/tools/input.js.map +1 -1
- package/dist/tools/integration.js +4 -4
- package/dist/tools/integration.js.map +1 -1
- package/dist/tools/pipeline.d.ts.map +1 -1
- package/dist/tools/pipeline.js +42 -4
- package/dist/tools/pipeline.js.map +1 -1
- package/dist/tools/quality.d.ts.map +1 -1
- package/dist/tools/quality.js +36 -9
- package/dist/tools/quality.js.map +1 -1
- package/dist/tools/testing.d.ts +10 -0
- package/dist/tools/testing.d.ts.map +1 -1
- package/dist/tools/testing.js +66 -13
- package/dist/tools/testing.js.map +1 -1
- package/dist/tools/transcript.d.ts.map +1 -1
- package/dist/tools/transcript.js +66 -41
- package/dist/tools/transcript.js.map +1 -1
- package/dist/tools/utility.d.ts.map +1 -1
- package/dist/tools/utility.js +22 -12
- package/dist/tools/utility.js.map +1 -1
- package/dist/tools/visualization.d.ts +21 -0
- package/dist/tools/visualization.d.ts.map +1 -1
- package/dist/tools/visualization.js +130 -29
- package/dist/tools/visualization.js.map +1 -1
- package/package.json +5 -2
- package/templates/checklist.md +2 -3
- 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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
142
|
+
return this.generateHypothesisTest(propId, description, requirementText, reqId, propertyType, usedNames);
|
|
128
143
|
}
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
135
|
-
`
|
|
136
|
-
`
|
|
137
|
-
`
|
|
138
|
-
`
|
|
139
|
-
`
|
|
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
|
-
|
|
144
|
-
` );`,
|
|
145
|
-
` });`,
|
|
196
|
+
...close,
|
|
146
197
|
].join("\n");
|
|
147
198
|
case "round_trip":
|
|
148
199
|
return [
|
|
149
|
-
|
|
150
|
-
`
|
|
151
|
-
`
|
|
152
|
-
` //
|
|
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
|
-
|
|
159
|
-
` );`,
|
|
160
|
-
` });`,
|
|
205
|
+
...close,
|
|
161
206
|
].join("\n");
|
|
162
207
|
case "idempotence":
|
|
163
208
|
return [
|
|
164
|
-
|
|
165
|
-
`
|
|
166
|
-
`
|
|
167
|
-
` //
|
|
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
|
-
|
|
174
|
-
` );`,
|
|
175
|
-
` });`,
|
|
214
|
+
...close,
|
|
176
215
|
].join("\n");
|
|
177
216
|
case "state_transition":
|
|
178
217
|
return [
|
|
179
|
-
|
|
180
|
-
`
|
|
181
|
-
`
|
|
182
|
-
` //
|
|
183
|
-
`
|
|
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
|
-
|
|
188
|
-
` );`,
|
|
189
|
-
` });`,
|
|
224
|
+
...close,
|
|
190
225
|
].join("\n");
|
|
191
226
|
case "negative":
|
|
192
227
|
return [
|
|
193
|
-
|
|
194
|
-
` fc.assert(`,
|
|
228
|
+
...open("negative — "),
|
|
195
229
|
` fc.property(fc.string(), (maliciousInput) => {`,
|
|
196
|
-
`
|
|
197
|
-
` const result =
|
|
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
|
-
|
|
202
|
-
` );`,
|
|
203
|
-
` });`,
|
|
235
|
+
...close,
|
|
204
236
|
].join("\n");
|
|
205
237
|
case "conditional":
|
|
206
238
|
return [
|
|
207
|
-
|
|
208
|
-
`
|
|
209
|
-
`
|
|
210
|
-
`
|
|
211
|
-
`
|
|
212
|
-
`
|
|
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
|
-
|
|
217
|
-
` );`,
|
|
218
|
-
` });`,
|
|
246
|
+
...close,
|
|
219
247
|
].join("\n");
|
|
220
248
|
case "commutativity":
|
|
221
249
|
return [
|
|
222
|
-
|
|
223
|
-
` fc.assert(`,
|
|
250
|
+
...open("commutativity — "),
|
|
224
251
|
` fc.property(fc.string(), fc.string(), (a, b) => {`,
|
|
225
|
-
` //
|
|
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
|
-
|
|
230
|
-
` );`,
|
|
231
|
-
` });`,
|
|
254
|
+
...close,
|
|
232
255
|
].join("\n");
|
|
233
256
|
case "monotonicity":
|
|
234
257
|
return [
|
|
235
|
-
|
|
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(
|
|
241
|
-
` return true;`,
|
|
261
|
+
` expect(scale(x)).toBeLessThanOrEqual(scale(y)); // monotone: x <= y implies f(x) <= f(y)`,
|
|
242
262
|
` }),`,
|
|
243
|
-
|
|
244
|
-
` );`,
|
|
245
|
-
` });`,
|
|
263
|
+
...close,
|
|
246
264
|
].join("\n");
|
|
247
265
|
}
|
|
248
266
|
}
|
|
249
|
-
generateHypothesisTest(propId, description, requirementText, propertyType) {
|
|
250
|
-
|
|
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
|
-
`
|
|
277
|
+
` # Traces to: ${reqId}`,
|
|
278
|
+
` @given(input1=${this.inferHypothesisStrategy(requirementText)}, input2=st.integers())`,
|
|
255
279
|
` @settings(max_examples=100)`,
|
|
256
|
-
` def
|
|
257
|
-
` """${
|
|
258
|
-
|
|
259
|
-
`
|
|
260
|
-
`
|
|
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
|
-
`
|
|
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
|
|
267
|
-
` """${
|
|
268
|
-
|
|
269
|
-
`
|
|
270
|
-
`
|
|
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
|
-
`
|
|
301
|
+
` # Traces to: ${reqId}`,
|
|
302
|
+
` @given(raw=st.text())`,
|
|
276
303
|
` @settings(max_examples=100)`,
|
|
277
|
-
` def
|
|
278
|
-
` """${
|
|
279
|
-
|
|
280
|
-
` once =
|
|
281
|
-
`
|
|
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
|
|
289
|
-
` """${
|
|
290
|
-
|
|
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
|
|
299
|
-
` """${
|
|
300
|
-
|
|
301
|
-
` result =
|
|
302
|
-
` assert "
|
|
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
|
-
`
|
|
336
|
+
` # Traces to: ${reqId}`,
|
|
337
|
+
` @given(condition=st.booleans(), data=${this.inferHypothesisStrategy(requirementText)})`,
|
|
307
338
|
` @settings(max_examples=100)`,
|
|
308
|
-
` def
|
|
309
|
-
` """${
|
|
310
|
-
` assume(condition) #
|
|
311
|
-
|
|
312
|
-
`
|
|
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
|
|
319
|
-
` """${
|
|
320
|
-
|
|
321
|
-
` assert
|
|
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
|
|
328
|
-
` """${
|
|
329
|
-
` assume(x <= y) #
|
|
330
|
-
|
|
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
|
|
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
|
|
401
|
+
`import fc from "fast-check";`,
|
|
352
402
|
`import { describe, it, expect } from "vitest";`,
|
|
353
403
|
].join("\n");
|
|
354
|
-
|
|
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
|
|
416
|
+
`Replace the in-file model stubs with real system calls.`,
|
|
366
417
|
`"""`,
|
|
367
418
|
].join("\n");
|
|
368
|
-
const
|
|
369
|
-
|
|
370
|
-
`from hypothesis import
|
|
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
|
-
]
|
|
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 {
|