plan-verify 0.0.0-stage → 0.1.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 (222) hide show
  1. package/.github/workflows/ci.yml +48 -0
  2. package/README.md +1431 -2
  3. package/assets/icon.png +0 -0
  4. package/assets/logo-mark.png +0 -0
  5. package/assets/logo.png +0 -0
  6. package/examples/checkout.ears.md +74 -0
  7. package/examples/order-always.ears.md +3 -0
  8. package/examples/order-awkward.ears.md +8 -0
  9. package/examples/order-bad.json +1 -0
  10. package/examples/order-chain.ears.md +9 -0
  11. package/examples/order-chosen.ears.md +7 -0
  12. package/examples/order-clear.ears.md +8 -0
  13. package/examples/order-combined.ears.md +10 -0
  14. package/examples/order-conflict.ears.md +8 -0
  15. package/examples/order-covered.ears.md +9 -0
  16. package/examples/order-duplicate.ears.md +8 -0
  17. package/examples/order-fail.json +5 -0
  18. package/examples/order-misuse.ears.md +7 -0
  19. package/examples/order-negation.ears.md +7 -0
  20. package/examples/order-pass.json +5 -0
  21. package/examples/order-quantity.ears.md +8 -0
  22. package/examples/order-redundant.ears.md +9 -0
  23. package/examples/order-repair.ears.md +9 -0
  24. package/examples/order-repeat.ears.md +13 -0
  25. package/examples/order-same.ears.md +3 -0
  26. package/examples/order-status.ears.md +7 -0
  27. package/examples/order-unanswered.ears.md +8 -0
  28. package/examples/order-unhandled.ears.md +8 -0
  29. package/examples/order-unmeasured.ears.md +10 -0
  30. package/examples/order-unused.ears.md +13 -0
  31. package/examples/order-vacuous.ears.md +9 -0
  32. package/examples/order-vague.ears.md +7 -0
  33. package/examples/order-wide.ears.md +5 -0
  34. package/examples/order-wording.ears.md +8 -0
  35. package/examples/order.ears.md +7 -0
  36. package/examples/payment-if.ears.md +7 -0
  37. package/examples/plan-to-problems/README.md +54 -0
  38. package/examples/plan-to-problems/expected-problems.md +25 -0
  39. package/examples/plan-to-problems/generate.mjs +186 -0
  40. package/examples/plan-to-problems/generated.ears.md +27 -0
  41. package/examples/plan-to-problems/lint.json +576 -0
  42. package/examples/plan-to-problems/plan.md +5 -0
  43. package/examples/plan-to-problems/planted.txt +5 -0
  44. package/examples/plan-to-problems/problems.md +112 -0
  45. package/examples/status-placed.ears.md +3 -0
  46. package/package.json +25 -4
  47. package/scripts/install-scriptc.sh +16 -0
  48. package/skills/plan-verify/SKILL.md +1515 -0
  49. package/src/check.ts +250 -0
  50. package/src/decide.ts +1324 -0
  51. package/src/ears.ts +1520 -0
  52. package/src/jsonvalue.ts +228 -0
  53. package/src/lint.ts +1245 -0
  54. package/src/main.ts +273 -0
  55. package/src/policy.ts +148 -0
  56. package/src/repair.ts +567 -0
  57. package/src/report.ts +329 -0
  58. package/src/scenarios.ts +149 -0
  59. package/src/smt.ts +97 -0
  60. package/src/solver.ts +868 -0
  61. package/src/spec.ts +307 -0
  62. package/src/term.ts +331 -0
  63. package/src/text.ts +95 -0
  64. package/test/fixtures/malformed.ears.md +3 -0
  65. package/test/fixtures/unknown-phrase.ears.md +5 -0
  66. package/test/golden/check-fail-json.exit +1 -0
  67. package/test/golden/check-fail-json.stderr +0 -0
  68. package/test/golden/check-fail-json.stdout +13 -0
  69. package/test/golden/check-fail.exit +1 -0
  70. package/test/golden/check-fail.stderr +0 -0
  71. package/test/golden/check-fail.stdout +7 -0
  72. package/test/golden/check-pass-json.exit +1 -0
  73. package/test/golden/check-pass-json.stderr +0 -0
  74. package/test/golden/check-pass-json.stdout +6 -0
  75. package/test/golden/check-pass.exit +1 -0
  76. package/test/golden/check-pass.stderr +0 -0
  77. package/test/golden/check-pass.stdout +3 -0
  78. package/test/golden/emit-smt-json-usage.exit +1 -0
  79. package/test/golden/emit-smt-json-usage.stderr +0 -0
  80. package/test/golden/emit-smt-json-usage.stdout +4 -0
  81. package/test/golden/emit-smt-json.exit +1 -0
  82. package/test/golden/emit-smt-json.stderr +0 -0
  83. package/test/golden/emit-smt-json.stdout +5 -0
  84. package/test/golden/emit-smt.exit +1 -0
  85. package/test/golden/emit-smt.stderr +0 -0
  86. package/test/golden/emit-smt.stdout +10 -0
  87. package/test/golden/help.exit +1 -0
  88. package/test/golden/help.stderr +0 -0
  89. package/test/golden/help.stdout +8 -0
  90. package/test/golden/lint-awkward.exit +1 -0
  91. package/test/golden/lint-awkward.stderr +0 -0
  92. package/test/golden/lint-awkward.stdout +29 -0
  93. package/test/golden/lint-chain.exit +1 -0
  94. package/test/golden/lint-chain.stderr +0 -0
  95. package/test/golden/lint-chain.stdout +56 -0
  96. package/test/golden/lint-checkout.exit +1 -0
  97. package/test/golden/lint-checkout.stderr +0 -0
  98. package/test/golden/lint-checkout.stdout +301 -0
  99. package/test/golden/lint-clean.exit +1 -0
  100. package/test/golden/lint-clean.stderr +0 -0
  101. package/test/golden/lint-clean.stdout +9 -0
  102. package/test/golden/lint-clear.exit +1 -0
  103. package/test/golden/lint-clear.stderr +0 -0
  104. package/test/golden/lint-clear.stdout +9 -0
  105. package/test/golden/lint-conflict-json.exit +1 -0
  106. package/test/golden/lint-conflict-json.stderr +0 -0
  107. package/test/golden/lint-conflict-json.stdout +80 -0
  108. package/test/golden/lint-conflict.exit +1 -0
  109. package/test/golden/lint-conflict.stderr +0 -0
  110. package/test/golden/lint-conflict.stdout +41 -0
  111. package/test/golden/lint-duplicate.exit +1 -0
  112. package/test/golden/lint-duplicate.stderr +0 -0
  113. package/test/golden/lint-duplicate.stdout +14 -0
  114. package/test/golden/lint-gap.exit +1 -0
  115. package/test/golden/lint-gap.stderr +0 -0
  116. package/test/golden/lint-gap.stdout +12 -0
  117. package/test/golden/lint-misuse.exit +1 -0
  118. package/test/golden/lint-misuse.stderr +0 -0
  119. package/test/golden/lint-misuse.stdout +18 -0
  120. package/test/golden/lint-negation.exit +1 -0
  121. package/test/golden/lint-negation.stderr +0 -0
  122. package/test/golden/lint-negation.stdout +13 -0
  123. package/test/golden/lint-quantity.exit +1 -0
  124. package/test/golden/lint-quantity.stderr +0 -0
  125. package/test/golden/lint-quantity.stdout +41 -0
  126. package/test/golden/lint-redundant.exit +1 -0
  127. package/test/golden/lint-redundant.stderr +0 -0
  128. package/test/golden/lint-redundant.stdout +14 -0
  129. package/test/golden/lint-repair-json.exit +1 -0
  130. package/test/golden/lint-repair-json.stderr +0 -0
  131. package/test/golden/lint-repair-json.stdout +127 -0
  132. package/test/golden/lint-repair.exit +1 -0
  133. package/test/golden/lint-repair.stderr +0 -0
  134. package/test/golden/lint-repair.stdout +54 -0
  135. package/test/golden/lint-repeat.exit +1 -0
  136. package/test/golden/lint-repeat.stderr +0 -0
  137. package/test/golden/lint-repeat.stdout +48 -0
  138. package/test/golden/lint-status.exit +1 -0
  139. package/test/golden/lint-status.stderr +0 -0
  140. package/test/golden/lint-status.stdout +44 -0
  141. package/test/golden/lint-unanswered.exit +1 -0
  142. package/test/golden/lint-unanswered.stderr +0 -0
  143. package/test/golden/lint-unanswered.stdout +12 -0
  144. package/test/golden/lint-unhandled.exit +1 -0
  145. package/test/golden/lint-unhandled.stderr +0 -0
  146. package/test/golden/lint-unhandled.stdout +12 -0
  147. package/test/golden/lint-unmeasured.exit +1 -0
  148. package/test/golden/lint-unmeasured.stderr +0 -0
  149. package/test/golden/lint-unmeasured.stdout +13 -0
  150. package/test/golden/lint-unused-json.exit +1 -0
  151. package/test/golden/lint-unused-json.stderr +0 -0
  152. package/test/golden/lint-unused-json.stdout +37 -0
  153. package/test/golden/lint-unused.exit +1 -0
  154. package/test/golden/lint-unused.stderr +0 -0
  155. package/test/golden/lint-unused.stdout +25 -0
  156. package/test/golden/lint-vacuous-json.exit +1 -0
  157. package/test/golden/lint-vacuous-json.stderr +0 -0
  158. package/test/golden/lint-vacuous-json.stdout +27 -0
  159. package/test/golden/lint-vacuous.exit +1 -0
  160. package/test/golden/lint-vacuous.stderr +0 -0
  161. package/test/golden/lint-vacuous.stdout +19 -0
  162. package/test/golden/lint-vague.exit +1 -0
  163. package/test/golden/lint-vague.stderr +0 -0
  164. package/test/golden/lint-vague.stdout +21 -0
  165. package/test/golden/lint-wide.exit +1 -0
  166. package/test/golden/lint-wide.stderr +0 -0
  167. package/test/golden/lint-wide.stdout +9 -0
  168. package/test/golden/lint-wording.exit +1 -0
  169. package/test/golden/lint-wording.stderr +0 -0
  170. package/test/golden/lint-wording.stdout +21 -0
  171. package/test/golden/malformed-scenario.exit +1 -0
  172. package/test/golden/malformed-scenario.stderr +1 -0
  173. package/test/golden/malformed-scenario.stdout +0 -0
  174. package/test/golden/malformed-spec.exit +1 -0
  175. package/test/golden/malformed-spec.stderr +1 -0
  176. package/test/golden/malformed-spec.stdout +0 -0
  177. package/test/golden/policy-counterexample.exit +1 -0
  178. package/test/golden/policy-counterexample.stderr +0 -0
  179. package/test/golden/policy-counterexample.stdout +13 -0
  180. package/test/golden/policy-covered.exit +1 -0
  181. package/test/golden/policy-covered.stderr +0 -0
  182. package/test/golden/policy-covered.stdout +19 -0
  183. package/test/golden/policy-holds.exit +1 -0
  184. package/test/golden/policy-holds.stderr +0 -0
  185. package/test/golden/policy-holds.stdout +3 -0
  186. package/test/golden/policy-json.exit +1 -0
  187. package/test/golden/policy-json.stderr +0 -0
  188. package/test/golden/policy-json.stdout +34 -0
  189. package/test/golden/policy-status.exit +1 -0
  190. package/test/golden/policy-status.stderr +0 -0
  191. package/test/golden/policy-status.stdout +16 -0
  192. package/test/golden/policy-vacuous.exit +1 -0
  193. package/test/golden/policy-vacuous.stderr +0 -0
  194. package/test/golden/policy-vacuous.stdout +3 -0
  195. package/test/golden/policy-wide.exit +1 -0
  196. package/test/golden/policy-wide.stderr +0 -0
  197. package/test/golden/policy-wide.stdout +15 -0
  198. package/test/golden/policy.exit +1 -0
  199. package/test/golden/policy.stderr +0 -0
  200. package/test/golden/policy.stdout +19 -0
  201. package/test/golden/scenarios-json.exit +1 -0
  202. package/test/golden/scenarios-json.stderr +0 -0
  203. package/test/golden/scenarios-json.stdout +75 -0
  204. package/test/golden/scenarios-status.exit +1 -0
  205. package/test/golden/scenarios-status.stderr +0 -0
  206. package/test/golden/scenarios-status.stdout +24 -0
  207. package/test/golden/scenarios.exit +1 -0
  208. package/test/golden/scenarios.stderr +0 -0
  209. package/test/golden/scenarios.stdout +26 -0
  210. package/test/golden/unit.exit +1 -0
  211. package/test/golden/unit.stderr +0 -0
  212. package/test/golden/unit.stdout +2 -0
  213. package/test/golden/unknown-phrase.exit +1 -0
  214. package/test/golden/unknown-phrase.stderr +1 -0
  215. package/test/golden/unknown-phrase.stdout +0 -0
  216. package/test/golden/usage.exit +1 -0
  217. package/test/golden/usage.stderr +9 -0
  218. package/test/golden/usage.stdout +0 -0
  219. package/test/run-all.ts +499 -0
  220. package/test/soundness.ts +375 -0
  221. package/test/unit.ts +1352 -0
  222. package/tsconfig.json +15 -0
package/src/lint.ts ADDED
@@ -0,0 +1,1245 @@
1
+ /**
2
+ * Lint a spec for contradictions, uncovered outputs, events nobody handles,
3
+ * error conditions with no response, EARS pattern mistakes, words a test
4
+ * cannot pin down, conditions that can never be true, and schema the
5
+ * requirements never use. Contradiction reports name the smallest set, the
6
+ * situation, an example, and candidate repairs that do not pick a winner.
7
+ */
8
+
9
+ import { describeAssignment, displayCode, isDriver, literalShown, reportBool, requirementMentions, speakBound, termMentions, type Requirement, type Spec, type SymbolDecl, type Term } from "./spec.ts";
10
+ import { satisfiable } from "./decide.ts";
11
+ import { conflictKey, findRepairs, type Repair } from "./repair.ts";
12
+ import { bestGap, conflictingSets, modelFor, redundantFlags, scanRequirements, spaceSize, type ConflictGroup } from "./solver.ts";
13
+ import { Binding, evalAssertion, evalBool, formatScenario, formatTerm, termsEqual, T_FALSE, T_TRUE, T_UNKNOWN } from "./term.ts";
14
+ import { displayQuote, englishList, formatInt, pastVoice } from "./text.ts";
15
+
16
+ interface WordRule {
17
+ word: string;
18
+ kind: string;
19
+ }
20
+
21
+ const WORD_RULES: WordRule[] = [
22
+ { word: "as needed", kind: "vague" },
23
+ { word: "user-friendly", kind: "vague" },
24
+ { word: "appropriate", kind: "vague" },
25
+ { word: "efficient", kind: "vague" },
26
+ { word: "flexible", kind: "vague" },
27
+ { word: "quickly", kind: "vague" },
28
+ { word: "robust", kind: "vague" },
29
+ { word: "large", kind: "vague" },
30
+ { word: "small", kind: "vague" },
31
+ { word: "fast", kind: "vague" },
32
+ { word: "slow", kind: "vague" },
33
+ { word: "easy", kind: "vague" },
34
+ { word: "microservice", kind: "solution" },
35
+ { word: "algorithm", kind: "solution" },
36
+ { word: "database", kind: "solution" },
37
+ { word: "function", kind: "solution" },
38
+ { word: "server", kind: "solution" },
39
+ { word: "class", kind: "solution" },
40
+ { word: "api", kind: "solution" },
41
+ ];
42
+
43
+ export interface NameValue {
44
+ name: string;
45
+ value: string;
46
+ }
47
+
48
+ export interface Conflict {
49
+ ids: string[];
50
+ lines: number[];
51
+ sentences: string[];
52
+ minimal: boolean;
53
+ everySituation: boolean;
54
+ situation: NameValue[];
55
+ requires: NameValue[];
56
+ example: NameValue[];
57
+ exampleJson: string;
58
+ keeps: string[];
59
+ breaks: string;
60
+ summary: string;
61
+ clash: string;
62
+ fix: string;
63
+ keepsSentence: string;
64
+ repairs: Repair[];
65
+ }
66
+
67
+ export interface Gap {
68
+ output: string;
69
+ situation: NameValue[];
70
+ text: string;
71
+ }
72
+
73
+ export interface Misuse {
74
+ id: string;
75
+ line: number;
76
+ symbols: string[];
77
+ sentence: string;
78
+ text: string;
79
+ }
80
+
81
+ export interface Wording {
82
+ line: number;
83
+ word: string;
84
+ kind: string;
85
+ text: string;
86
+ context: string;
87
+ }
88
+
89
+ export interface Redundant {
90
+ id: string;
91
+ line: number;
92
+ sentence: string;
93
+ text: string;
94
+ fix: string;
95
+ }
96
+
97
+ export interface Duplicate {
98
+ id: string;
99
+ line: number;
100
+ sameAs: string;
101
+ sentence: string;
102
+ text: string;
103
+ fix: string;
104
+ }
105
+
106
+ export interface Vacuous {
107
+ id: string;
108
+ line: number;
109
+ sentence: string;
110
+ text: string;
111
+ fix: string;
112
+ }
113
+
114
+ export interface Unused {
115
+ name: string;
116
+ kind: string;
117
+ text: string;
118
+ fix: string;
119
+ }
120
+
121
+ export interface LintReport {
122
+ conflicts: Conflict[];
123
+ gaps: Gap[];
124
+ misuse: Misuse[];
125
+ wording: Wording[];
126
+ redundant: Redundant[];
127
+ duplicate: Duplicate[];
128
+ vacuous: Vacuous[];
129
+ unused: Unused[];
130
+ }
131
+
132
+ export type LintOutcome = { kind: "report"; report: LintReport } | { kind: "error"; message: string };
133
+
134
+ export function lintSpec(spec: Spec): LintOutcome {
135
+ if (spaceSize(spec) < 0) {
136
+ return {
137
+ kind: "error",
138
+ message: "a symbol has an empty domain. Declare at least one allowed value.",
139
+ };
140
+ }
141
+ const conflicts: Conflict[] = [];
142
+ const groups = conflictingSets(spec);
143
+ const originalKeys: string[] = [];
144
+ for (let i = 0; i < groups.length; i++) originalKeys.push(conflictKey(spec, groups[i].indexes));
145
+ const earlier = earlierDuplicate(spec);
146
+ for (let i = 0; i < groups.length; i++) conflicts.push(explainConflict(spec, groups[i], originalKeys));
147
+ const gaps: Gap[] = [];
148
+ for (let i = 0; i < spec.symbols.length; i++) {
149
+ if (isDriver(spec.symbols[i])) continue;
150
+ const situation = bestGap(spec, i);
151
+ if (situation === null) continue;
152
+ gaps.push(explainGap(spec, i, situation));
153
+ }
154
+ for (let i = 0; i < spec.symbols.length; i++) {
155
+ if (spec.symbols[i].role !== "event") continue;
156
+ if (eventMentioned(spec, i)) continue;
157
+ gaps.push(explainUnhandled(spec, i));
158
+ }
159
+ return {
160
+ kind: "report",
161
+ report: {
162
+ conflicts: conflicts,
163
+ gaps: gaps,
164
+ misuse: findMisuse(spec),
165
+ wording: findWording(spec),
166
+ redundant: findRedundant(spec, groups, earlier),
167
+ duplicate: findDuplicates(spec, earlier),
168
+ vacuous: findVacuous(spec),
169
+ unused: findUnused(spec),
170
+ },
171
+ };
172
+ }
173
+
174
+ function findVacuous(spec: Spec): Vacuous[] {
175
+ const found: Vacuous[] = [];
176
+ for (let i = 0; i < spec.requirements.length; i++) {
177
+ const req = spec.requirements[i];
178
+ if (!requirementVacuous(spec, req)) continue;
179
+ found.push({
180
+ id: req.id,
181
+ line: req.line,
182
+ sentence: req.sentence,
183
+ text: req.id + " can never trigger. Its condition is impossible for every allowed value.",
184
+ fix:
185
+ "Suggested fix: rewrite the condition in " +
186
+ req.id +
187
+ " so a real situation can make it true, or remove the requirement. Do not invent a response.",
188
+ });
189
+ }
190
+ return found;
191
+ }
192
+
193
+ function requirementVacuous(spec: Spec, req: Requirement): boolean {
194
+ if (req.antecedent === null) return false;
195
+ return !conditionPossible(spec, req.antecedent);
196
+ }
197
+
198
+ function conditionPossible(spec: Spec, term: Term): boolean {
199
+ const probe: Requirement = {
200
+ id: "probe",
201
+ line: 0,
202
+ sentence: "",
203
+ system: "",
204
+ antecedent: null,
205
+ consequent: term,
206
+ hasWhen: false,
207
+ hasIf: false,
208
+ whenTerm: null,
209
+ };
210
+ const holds: Requirement[] = [];
211
+ holds.push(probe);
212
+ return satisfiable(spec, holds);
213
+ }
214
+
215
+ function findUnused(spec: Spec): Unused[] {
216
+ const found: Unused[] = [];
217
+ for (let i = 0; i < spec.symbols.length; i++) {
218
+ const sym = spec.symbols[i];
219
+ if (sym.role === "event") continue;
220
+ const mentioned = symbolMentioned(spec, i);
221
+ if (sym.role === "output") {
222
+ if (!mentioned) continue;
223
+ if (!consequentMentions(spec, i)) {
224
+ const label = symbolLabel(sym);
225
+ found.push({
226
+ name: sym.name,
227
+ kind: "output",
228
+ text: "Nothing sets " + label + ". Requirements mention it, but no response says what it shall be.",
229
+ fix:
230
+ "Suggested fix: add a shall requirement that sets " +
231
+ sym.name +
232
+ ", or declare it as an input if the environment provides it. Do not choose the value.",
233
+ });
234
+ }
235
+ continue;
236
+ }
237
+ if (mentioned) continue;
238
+ const label = symbolLabel(sym);
239
+ found.push({
240
+ name: sym.name,
241
+ kind: sym.role,
242
+ text: label + " is " + article(sym.role) + " " + sym.role + " and no requirement mentions it.",
243
+ fix: "Suggested fix: use " + sym.name + " in a requirement, or remove it from the schema.",
244
+ });
245
+ }
246
+ for (let e = 0; e < spec.enums.length; e++) {
247
+ const decl = spec.enums[e];
248
+ for (let v = 0; v < decl.variants.length; v++) {
249
+ const variant = decl.variants[v];
250
+ if (variantMentioned(spec, decl.name, variant)) continue;
251
+ const name = decl.name + "." + variant;
252
+ found.push({
253
+ name: name,
254
+ kind: "variant",
255
+ text: name + " is never mentioned.",
256
+ fix: "Suggested fix: use " + variant + " in a requirement, or remove it from " + decl.name + ".",
257
+ });
258
+ }
259
+ }
260
+ return found;
261
+ }
262
+
263
+ function article(role: string): string {
264
+ if (role === "input") return "an";
265
+ return "a";
266
+ }
267
+
268
+ function symbolLabel(sym: SymbolDecl): string {
269
+ if (sym.phrase.length > 0) return sym.name + " (" + displayQuote(sym.phrase) + ")";
270
+ return sym.name;
271
+ }
272
+
273
+ function symbolMentioned(spec: Spec, index: number): boolean {
274
+ for (let r = 0; r < spec.requirements.length; r++) {
275
+ if (requirementMentions(spec.requirements[r], index)) return true;
276
+ }
277
+ return false;
278
+ }
279
+
280
+ function consequentMentions(spec: Spec, index: number): boolean {
281
+ for (let r = 0; r < spec.requirements.length; r++) {
282
+ if (termMentions(spec.requirements[r].consequent, index)) return true;
283
+ }
284
+ return false;
285
+ }
286
+
287
+ function variantMentioned(spec: Spec, enumName: string, variant: string): boolean {
288
+ for (let r = 0; r < spec.requirements.length; r++) {
289
+ const req = spec.requirements[r];
290
+ if (req.antecedent !== null && termHasVariant(req.antecedent, enumName, variant)) return true;
291
+ if (termHasVariant(req.consequent, enumName, variant)) return true;
292
+ }
293
+ return false;
294
+ }
295
+
296
+ function termHasVariant(term: Term, enumName: string, variant: string): boolean {
297
+ if (term.kind === "enum") return term.enumName === enumName && term.variant === variant;
298
+ if (term.kind === "not") return termHasVariant(term.term, enumName, variant);
299
+ if (
300
+ term.kind === "and" ||
301
+ term.kind === "or" ||
302
+ term.kind === "eq" ||
303
+ term.kind === "neq" ||
304
+ term.kind === "lt" ||
305
+ term.kind === "le" ||
306
+ term.kind === "gt" ||
307
+ term.kind === "ge" ||
308
+ term.kind === "add" ||
309
+ term.kind === "sub"
310
+ ) {
311
+ return termHasVariant(term.left, enumName, variant) || termHasVariant(term.right, enumName, variant);
312
+ }
313
+ return false;
314
+ }
315
+
316
+ function findRedundant(spec: Spec, groups: ConflictGroup[], earlier: number[]): Redundant[] {
317
+ const eligible: boolean[] = [];
318
+ for (let i = 0; i < spec.requirements.length; i++) eligible.push(earlier[i] < 0);
319
+ for (let g = 0; g < groups.length; g++) {
320
+ const indexes = groups[g].indexes;
321
+ for (let k = 0; k < indexes.length; k++) eligible[indexes[k]] = false;
322
+ }
323
+ const narrowed = groups.length > 0;
324
+ const flags = redundantFlags(spec, eligible);
325
+ const found: Redundant[] = [];
326
+ for (let i = 0; i < flags.length; i++) {
327
+ if (!flags[i]) continue;
328
+ const req = spec.requirements[i];
329
+ if (requirementVacuous(spec, req)) continue;
330
+ const held = narrowed
331
+ ? "Whenever the requirements outside a conflict hold, "
332
+ : "Whenever the other requirements hold, ";
333
+ found.push({
334
+ id: req.id,
335
+ line: req.line,
336
+ sentence: req.sentence,
337
+ text: req.id + " adds nothing. " + held + req.id + " holds too.",
338
+ fix:
339
+ "Suggested fix: remove " +
340
+ req.id +
341
+ ", or ask whether it was meant to say something the others do not already require. Do not delete it until the PM agrees.",
342
+ });
343
+ }
344
+ return found;
345
+ }
346
+
347
+ function findDuplicates(spec: Spec, earlier: number[]): Duplicate[] {
348
+ const found: Duplicate[] = [];
349
+ for (let i = 0; i < earlier.length; i++) {
350
+ if (earlier[i] < 0) continue;
351
+ const req = spec.requirements[i];
352
+ const prior = spec.requirements[earlier[i]];
353
+ if (requirementVacuous(spec, req) || requirementVacuous(spec, prior)) continue;
354
+ found.push({
355
+ id: req.id,
356
+ line: req.line,
357
+ sameAs: prior.id,
358
+ sentence: req.sentence,
359
+ text: req.id + " duplicates " + prior.id + ". The condition and the response are the same.",
360
+ fix:
361
+ "Suggested fix: remove " +
362
+ req.id +
363
+ ", or ask whether it was meant to differ from " +
364
+ prior.id +
365
+ ". Do not delete it until the PM agrees.",
366
+ });
367
+ }
368
+ return found;
369
+ }
370
+
371
+ function earlierDuplicate(spec: Spec): number[] {
372
+ const earlier: number[] = [];
373
+ const reqs = spec.requirements;
374
+ for (let i = 0; i < reqs.length; i++) earlier.push(-1);
375
+ for (let i = 0; i < reqs.length; i++) {
376
+ for (let j = 0; j < i; j++) {
377
+ if (sameRequirement(reqs[j], reqs[i])) {
378
+ earlier[i] = j;
379
+ break;
380
+ }
381
+ }
382
+ }
383
+ return earlier;
384
+ }
385
+
386
+ function sameRequirement(left: Requirement, right: Requirement): boolean {
387
+ return termsEqual(guardTerm(left), guardTerm(right)) && termsEqual(left.consequent, right.consequent);
388
+ }
389
+
390
+ function guardTerm(req: Requirement): Term {
391
+ if (req.antecedent === null) return { kind: "bool", value: true };
392
+ return req.antecedent;
393
+ }
394
+
395
+ function eventMentioned(spec: Spec, index: number): boolean {
396
+ for (let r = 0; r < spec.requirements.length; r++) {
397
+ if (requirementMentions(spec.requirements[r], index)) return true;
398
+ }
399
+ return false;
400
+ }
401
+
402
+ function explainUnhandled(spec: Spec, index: number): Gap {
403
+ const sym = spec.symbols[index];
404
+ const label = sym.phrase.length > 0 ? displayQuote(sym.phrase) : sym.name;
405
+ if (sym.unwanted) {
406
+ return {
407
+ output: sym.name,
408
+ situation: [],
409
+ text:
410
+ "No requirement states a response when " +
411
+ label +
412
+ ". Suggested fix: add an if ... then requirement, and ask what the system shall do. Do not choose the response.",
413
+ };
414
+ }
415
+ return {
416
+ output: sym.name,
417
+ situation: [],
418
+ text:
419
+ "No requirement handles " +
420
+ label +
421
+ ". Suggested fix: add a requirement that says what the system shall do, or remove the event.",
422
+ };
423
+ }
424
+
425
+ function explainConflict(spec: Spec, group: ConflictGroup, originalKeys: string[]): Conflict {
426
+ const ids: string[] = [];
427
+ const lines: number[] = [];
428
+ const sentences: string[] = [];
429
+ for (let i = 0; i < group.indexes.length; i++) {
430
+ const req = spec.requirements[group.indexes[i]];
431
+ ids.push(req.id);
432
+ lines.push(req.line);
433
+ sentences.push(req.sentence);
434
+ }
435
+ const scan = scanRequirements(spec, group.indexes);
436
+ const situation = scan.unsat === null ? new Binding(spec.symbols.length) : scan.unsat;
437
+ const everySituation = !scan.sawSat;
438
+ const situationPairs = driverPairs(spec, situation);
439
+ const active = activeRequires(spec, group.indexes, situation);
440
+ const lastPos = group.indexes.length - 1;
441
+ const keptIndexes: number[] = [];
442
+ for (let i = 0; i < lastPos; i++) keptIndexes.push(group.indexes[i]);
443
+ const exampleEnv = modelFor(spec, keptIndexes, situation);
444
+ const examplePairs: NameValue[] = [];
445
+ let exampleJson = "";
446
+ let keeps: string[] = [];
447
+ let keepsSentence = "";
448
+ const breaks = ids[lastPos];
449
+ if (exampleEnv !== null) {
450
+ for (let i = 0; i < spec.symbols.length; i++) {
451
+ examplePairs.push({ name: spec.symbols[i].name, value: displayCode(spec, spec.symbols[i], exampleEnv.code[i]) });
452
+ }
453
+ exampleJson = formatScenario(spec, exampleEnv);
454
+ let faithful = true;
455
+ for (let i = 0; i < keptIndexes.length; i++) {
456
+ const req = spec.requirements[keptIndexes[i]];
457
+ if (evalAssertion(req.antecedent, req.consequent, spec, exampleEnv) !== T_TRUE) faithful = false;
458
+ }
459
+ const broken = spec.requirements[group.indexes[lastPos]];
460
+ if (evalAssertion(broken.antecedent, broken.consequent, spec, exampleEnv) !== T_FALSE) faithful = false;
461
+ if (faithful) {
462
+ for (let i = 0; i < lastPos; i++) keeps.push(ids[i]);
463
+ keepsSentence = keepsText(keeps, breaks);
464
+ }
465
+ }
466
+ const summary = summaryText(ids, group.minimal);
467
+ const clash = clashText(spec, everySituation, situationPairs);
468
+ const fix = fixText(ids, group.minimal);
469
+ const repairs = findRepairs(spec, group.indexes, originalKeys);
470
+ return {
471
+ ids: ids,
472
+ lines: lines,
473
+ sentences: sentences,
474
+ minimal: group.minimal,
475
+ everySituation: everySituation,
476
+ situation: situationPairs,
477
+ requires: active,
478
+ example: examplePairs,
479
+ exampleJson: exampleJson,
480
+ keeps: keeps,
481
+ breaks: breaks,
482
+ summary: summary,
483
+ clash: clash,
484
+ fix: fix,
485
+ keepsSentence: keepsSentence,
486
+ repairs: repairs,
487
+ };
488
+ }
489
+
490
+ function activeRequires(spec: Spec, indexes: number[], situation: Binding): NameValue[] {
491
+ const chosen: number[] = [];
492
+ let sawTrue = false;
493
+ for (let i = 0; i < indexes.length; i++) {
494
+ const req = spec.requirements[indexes[i]];
495
+ const guard = req.antecedent === null ? T_TRUE : evalBool(req.antecedent, spec, situation);
496
+ if (guard === T_TRUE) {
497
+ if (!sawTrue) {
498
+ chosen.length = 0;
499
+ sawTrue = true;
500
+ }
501
+ chosen.push(indexes[i]);
502
+ } else if (!sawTrue && guard === T_UNKNOWN) {
503
+ chosen.push(indexes[i]);
504
+ }
505
+ }
506
+ if (chosen.length === 0) {
507
+ for (let i = 0; i < indexes.length; i++) chosen.push(indexes[i]);
508
+ }
509
+ const pairs: NameValue[] = [];
510
+ for (let i = 0; i < chosen.length; i++) {
511
+ const req = spec.requirements[chosen[i]];
512
+ pairs.push({ name: req.id, value: obligationValue(spec, req.consequent) });
513
+ }
514
+ return pairs;
515
+ }
516
+
517
+ function obligationValue(spec: Spec, term: Term): string {
518
+ const plain = formatTerm(term);
519
+ const voiced = voiceObligation(spec, term);
520
+ if (voiced !== plain) return "that " + voiced;
521
+ return plain;
522
+ }
523
+
524
+ function voiceObligation(spec: Spec, term: Term): string {
525
+ if (term.kind === "sym") {
526
+ const sym = spec.symbols[term.index];
527
+ if (sym.sort.kind === "bool" && (sym.phrase.length > 0 || sym.outcomeYes.length > 0)) return reportBool(sym, true);
528
+ return formatTerm(term);
529
+ }
530
+ if (term.kind === "not") {
531
+ const inner = term.term;
532
+ if (inner.kind === "sym") {
533
+ const sym = spec.symbols[inner.index];
534
+ if (sym.sort.kind === "bool" && (sym.phrase.length > 0 || sym.outcomeYes.length > 0)) return reportBool(sym, false);
535
+ }
536
+ }
537
+ const spoken = voiceBound(spec, term);
538
+ if (spoken.length > 0) return spoken;
539
+ return pastVoice(formatTerm(term));
540
+ }
541
+
542
+ function voiceBound(spec: Spec, term: Term): string {
543
+ if (
544
+ term.kind !== "eq" &&
545
+ term.kind !== "neq" &&
546
+ term.kind !== "lt" &&
547
+ term.kind !== "le" &&
548
+ term.kind !== "gt" &&
549
+ term.kind !== "ge"
550
+ ) {
551
+ return "";
552
+ }
553
+ if (term.left.kind !== "sym") return "";
554
+ const sym = spec.symbols[term.left.index];
555
+ if (sym.sort.kind === "bool") return "";
556
+ const shown = literalShown(term.right);
557
+ if (shown.length > 0) return speakBound(sym, shown, term.kind, false);
558
+ return speakBound(sym, formatTerm(term.right), term.kind, true);
559
+ }
560
+
561
+ function explainGap(spec: Spec, outputIndex: number, situation: Binding): Gap {
562
+ const sym = spec.symbols[outputIndex];
563
+ const name = sym.name;
564
+ const pairs = driverPairs(spec, situation);
565
+ const voiced = sym.sort.kind === "bool" && (sym.phrase.length > 0 || sym.outcomeYes.length > 0);
566
+ let text = voiced ? "Nothing says whether " + reportBool(sym, true) : "Nothing covers " + (sym.phrase.length > 0 ? sym.phrase : name);
567
+ if (pairs.length === 0) text += ".";
568
+ else text += " when " + pairClause(spec, pairs) + ".";
569
+ text += " Suggested fix: add a requirement for this situation, and ask what the system shall do. Do not choose the response.";
570
+ return { output: name, situation: pairs, text: text };
571
+ }
572
+
573
+ function findMisuse(spec: Spec): Misuse[] {
574
+ const found: Misuse[] = [];
575
+ for (let r = 0; r < spec.requirements.length; r++) {
576
+ const req = spec.requirements[r];
577
+ if (!req.hasWhen || req.hasIf || req.whenTerm === null) continue;
578
+ const names: string[] = [];
579
+ const labels: string[] = [];
580
+ for (let s = 0; s < spec.symbols.length; s++) {
581
+ if (!spec.symbols[s].unwanted) continue;
582
+ if (!mentions(req.whenTerm, s)) continue;
583
+ names.push(spec.symbols[s].name);
584
+ const phrase = spec.symbols[s].phrase;
585
+ labels.push(phrase.length > 0 ? displayQuote(phrase) : spec.symbols[s].name);
586
+ }
587
+ if (names.length === 0) continue;
588
+ const label = labels.length === 1 ? labels[0] + " is an unwanted event" : englishList(labels) + " are unwanted events";
589
+ found.push({
590
+ id: req.id,
591
+ line: req.line,
592
+ symbols: names,
593
+ sentence: req.sentence,
594
+ text: label + ". Write if ... then, not when.",
595
+ });
596
+ }
597
+ return found;
598
+ }
599
+
600
+ function findWording(spec: Spec): Wording[] {
601
+ const found: Wording[] = [];
602
+ for (let i = 0; i < spec.symbols.length; i++) {
603
+ collectWords(null, spec.symbols[i].description, spec.symbols[i].line, spec.symbols[i].source, found);
604
+ }
605
+ for (let i = 0; i < spec.requirements.length; i++) {
606
+ collectWords(spec, spec.requirements[i].sentence, spec.requirements[i].line, spec.requirements[i].sentence, found);
607
+ collectForm(spec, spec.requirements[i].sentence, spec.requirements[i].line, found);
608
+ }
609
+ return found;
610
+ }
611
+
612
+ function collectForm(spec: Spec, text: string, line: number, found: Wording[]): void {
613
+ const lower = lowerAscii(text);
614
+ if (hasAwkwardShall(lower)) {
615
+ found.push({
616
+ line: line,
617
+ word: "shall",
618
+ kind: "form",
619
+ context: text,
620
+ text:
621
+ '"shall the ... is ..." is awkward. Write the response as a verb phrase, such as "bill at most 2 seats", and bind it with as.',
622
+ });
623
+ }
624
+ if (hasBareWhen(spec, lower)) {
625
+ found.push({
626
+ line: line,
627
+ word: "when",
628
+ kind: "form",
629
+ context: text,
630
+ text: '"When" needs a condition with "is", not a bare verb. "When the subscription is started" is the form.',
631
+ });
632
+ }
633
+ if (hasMisplacedNot(lower)) {
634
+ found.push({
635
+ line: line,
636
+ word: "not",
637
+ kind: "form",
638
+ context: text,
639
+ text: 'Put "not" after "is": "the customer is not cancelling", not "not the customer is cancelling".',
640
+ });
641
+ }
642
+ if (hasBareNegation(lower)) {
643
+ found.push({
644
+ line: line,
645
+ word: "not",
646
+ kind: "negation",
647
+ context: text,
648
+ text:
649
+ 'The trigger is a bare negation. Write a positive condition, such as "When a non-member asks", or declare a positive event.',
650
+ });
651
+ }
652
+ }
653
+
654
+ function hasAwkwardShall(lower: string): boolean {
655
+ const at = wordIndex(lower, "shall", 0);
656
+ if (at < 0) return false;
657
+ if (wordAt(lower, at + 5) !== "the") return false;
658
+ return wordIndex(lower, "is", at + 5) >= 0;
659
+ }
660
+
661
+ function hasBareWhen(spec: Spec, lower: string): boolean {
662
+ let from = 0;
663
+ while (from < lower.length) {
664
+ const at = wordIndex(lower, "when", from);
665
+ if (at < 0) return false;
666
+ const stop = clauseEnd(lower, at + 4);
667
+ if (clauseHasBareVerb(spec, lower.slice(at + 4, stop))) return true;
668
+ from = stop + 1;
669
+ }
670
+ return false;
671
+ }
672
+
673
+ function clauseHasBareVerb(spec: Spec, clause: string): boolean {
674
+ const parts = splitConjuncts(clause);
675
+ for (let i = 0; i < parts.length; i++) {
676
+ let part = trimSpaces(parts[i]);
677
+ if (startsWithWord(part, "not")) {
678
+ const notAt = wordIndex(part, "not", 0);
679
+ part = trimSpaces(part.slice(notAt + 3));
680
+ }
681
+ if (wordIndex(part, "is", 0) >= 0) continue;
682
+ if (matchesResponsePhrase(spec, part)) return true;
683
+ }
684
+ return false;
685
+ }
686
+
687
+ function matchesResponsePhrase(spec: Spec, text: string): boolean {
688
+ const target = trimSpaces(text);
689
+ if (target.length === 0) return false;
690
+ for (let i = 0; i < spec.symbols.length; i++) {
691
+ const sym = spec.symbols[i];
692
+ if (sym.action.length > 0 && lowerAscii(sym.action) === target) return true;
693
+ if (sym.role === "output" && sym.phrase.length > 0 && wordIndex(lowerAscii(sym.phrase), "is", 0) < 0 && lowerAscii(sym.phrase) === target) {
694
+ return true;
695
+ }
696
+ }
697
+ return false;
698
+ }
699
+
700
+ function splitConjuncts(clause: string): string[] {
701
+ const parts: string[] = [];
702
+ let start = 0;
703
+ let i = 0;
704
+ while (i < clause.length) {
705
+ const atAnd = wordIndex(clause, "and", i);
706
+ const atOr = wordIndex(clause, "or", i);
707
+ let at = -1;
708
+ if (atAnd >= 0 && (atOr < 0 || atAnd <= atOr)) at = atAnd;
709
+ else at = atOr;
710
+ if (at < 0) break;
711
+ parts.push(clause.slice(start, at));
712
+ let j = at;
713
+ while (j < clause.length && isWordChar(clause.charCodeAt(j))) j++;
714
+ start = j;
715
+ i = j;
716
+ }
717
+ parts.push(clause.slice(start));
718
+ return parts;
719
+ }
720
+
721
+ function startsWithWord(text: string, word: string): boolean {
722
+ const at = wordIndex(text, word, 0);
723
+ if (at < 0) return false;
724
+ for (let i = 0; i < at; i++) {
725
+ if (text.charCodeAt(i) !== 32) return false;
726
+ }
727
+ return true;
728
+ }
729
+
730
+ function trimSpaces(text: string): string {
731
+ let start = 0;
732
+ let end = text.length;
733
+ while (start < end && text.charCodeAt(start) === 32) start++;
734
+ while (end > start && text.charCodeAt(end - 1) === 32) end--;
735
+ return text.slice(start, end);
736
+ }
737
+
738
+ function hasBareNegation(lower: string): boolean {
739
+ let kind = 0;
740
+ while (kind < 4) {
741
+ const key = triggerKey(kind);
742
+ let from = 0;
743
+ while (from < lower.length) {
744
+ const at = wordIndex(lower, key, from);
745
+ if (at < 0) break;
746
+ const stop = clauseEnd(lower, at + key.length);
747
+ if (clauseHasBareNot(lower.slice(at + key.length, stop))) return true;
748
+ from = stop + 1;
749
+ }
750
+ kind++;
751
+ }
752
+ return false;
753
+ }
754
+
755
+ function triggerKey(kind: number): string {
756
+ if (kind === 0) return "when";
757
+ if (kind === 1) return "while";
758
+ if (kind === 2) return "where";
759
+ return "if";
760
+ }
761
+
762
+ function clauseHasBareNot(clause: string): boolean {
763
+ const parts = splitConjuncts(clause);
764
+ for (let i = 0; i < parts.length; i++) {
765
+ const part = trimSpaces(parts[i]);
766
+ if (!startsWithWord(part, "not")) continue;
767
+ const notAt = wordIndex(part, "not", 0);
768
+ const rest = trimSpaces(part.slice(notAt + 3));
769
+ if (wordIndex(rest, "is", 0) < 0) return true;
770
+ }
771
+ return false;
772
+ }
773
+
774
+ function hasMisplacedNot(lower: string): boolean {
775
+ let from = 0;
776
+ while (from < lower.length) {
777
+ const at = wordIndex(lower, "not", from);
778
+ if (at < 0) return false;
779
+ if (previousWord(lower, at) !== "is") {
780
+ const stop = notSpanEnd(lower, at + 3);
781
+ const span = lower.slice(at + 3, stop);
782
+ const isAt = wordIndex(span, "is", 0);
783
+ if (isAt >= 0 && wordAt(span, isAt + 2) !== "not") return true;
784
+ }
785
+ from = at + 3;
786
+ }
787
+ return false;
788
+ }
789
+
790
+ function clauseEnd(text: string, from: number): number {
791
+ for (let i = from; i < text.length; i++) {
792
+ if (text.charCodeAt(i) === 44) return i;
793
+ }
794
+ return text.length;
795
+ }
796
+
797
+ function notSpanEnd(text: string, from: number): number {
798
+ let i = from;
799
+ while (i < text.length) {
800
+ if (text.charCodeAt(i) === 44) return i;
801
+ if (isWordChar(text.charCodeAt(i))) {
802
+ const start = i;
803
+ while (i < text.length && isWordChar(text.charCodeAt(i))) i++;
804
+ const word = text.slice(start, i);
805
+ if (word === "and" || word === "or") return start;
806
+ continue;
807
+ }
808
+ i++;
809
+ }
810
+ return text.length;
811
+ }
812
+
813
+ function wordIndex(text: string, word: string, from: number): number {
814
+ let i = from;
815
+ while (i < text.length) {
816
+ while (i < text.length && !isWordChar(text.charCodeAt(i))) i++;
817
+ if (i >= text.length) return -1;
818
+ const start = i;
819
+ while (i < text.length && isWordChar(text.charCodeAt(i))) i++;
820
+ if (text.slice(start, i) === word) return start;
821
+ }
822
+ return -1;
823
+ }
824
+
825
+ function wordAt(text: string, from: number): string {
826
+ let i = from;
827
+ while (i < text.length && !isWordChar(text.charCodeAt(i))) i++;
828
+ if (i >= text.length) return "";
829
+ const start = i;
830
+ while (i < text.length && isWordChar(text.charCodeAt(i))) i++;
831
+ return text.slice(start, i);
832
+ }
833
+
834
+ function previousWord(text: string, index: number): string {
835
+ let i = index - 1;
836
+ while (i >= 0 && !isWordChar(text.charCodeAt(i))) i--;
837
+ if (i < 0) return "";
838
+ const end = i + 1;
839
+ while (i >= 0 && isWordChar(text.charCodeAt(i))) i--;
840
+ return text.slice(i + 1, end);
841
+ }
842
+
843
+ function collectWords(spec: Spec | null, text: string, line: number, context: string, found: Wording[]): void {
844
+ const lower = lowerAscii(text);
845
+ let i = 0;
846
+ while (i < lower.length) {
847
+ if (!boundaryBefore(lower, i)) {
848
+ i++;
849
+ continue;
850
+ }
851
+ let matched = -1;
852
+ for (let w = 0; w < WORD_RULES.length; w++) {
853
+ const word = WORD_RULES[w].word;
854
+ if (lower.slice(i, i + word.length) !== word) continue;
855
+ if (!boundaryAfter(lower, i + word.length)) continue;
856
+ if (matched < 0 || word.length > WORD_RULES[matched].word.length) matched = w;
857
+ }
858
+ if (matched < 0) {
859
+ i++;
860
+ continue;
861
+ }
862
+ const rule = WORD_RULES[matched];
863
+ const end = i + rule.word.length;
864
+ if (spec !== null && coveredBySchema(spec, lower, i, end)) {
865
+ i = end;
866
+ continue;
867
+ }
868
+ const message =
869
+ rule.kind === "vague"
870
+ ? "\"" + rule.word + "\" is too vague to test. Replace it with something a scenario can check."
871
+ : "\"" + rule.word + "\" names a solution. Say what the system must do, not how to build it.";
872
+ found.push({ line: line, word: rule.word, kind: rule.kind, context: context, text: message });
873
+ i = end;
874
+ }
875
+ }
876
+
877
+ function coveredBySchema(spec: Spec, lower: string, start: number, end: number): boolean {
878
+ for (let s = 0; s < spec.symbols.length; s++) {
879
+ const sym = spec.symbols[s];
880
+ if (insidePhrase(lower, start, end, sym.phrase)) return true;
881
+ if (insidePhrase(lower, start, end, sym.action)) return true;
882
+ if (sym.phrase.length === 0 && insidePhrase(lower, start, end, sym.name)) return true;
883
+ }
884
+ return false;
885
+ }
886
+
887
+ function insidePhrase(lower: string, start: number, end: number, phrase: string): boolean {
888
+ const target = lowerAscii(phrase);
889
+ if (target.length === 0) return false;
890
+ let from = 0;
891
+ while (from < lower.length) {
892
+ const at = lower.indexOf(target, from);
893
+ if (at < 0) return false;
894
+ const after = at + target.length;
895
+ if (boundaryBefore(lower, at) && boundaryAfter(lower, after) && start >= at && end <= after) return true;
896
+ from = at + 1;
897
+ }
898
+ return false;
899
+ }
900
+
901
+ function mentions(term: import("./spec.ts").Term, index: number): boolean {
902
+ if (term.kind === "sym") return term.index === index;
903
+ if (term.kind === "not") return mentions(term.term, index);
904
+ if (
905
+ term.kind === "and" ||
906
+ term.kind === "or" ||
907
+ term.kind === "eq" ||
908
+ term.kind === "neq" ||
909
+ term.kind === "lt" ||
910
+ term.kind === "le" ||
911
+ term.kind === "gt" ||
912
+ term.kind === "ge" ||
913
+ term.kind === "add" ||
914
+ term.kind === "sub"
915
+ ) {
916
+ return mentions(term.left, index) || mentions(term.right, index);
917
+ }
918
+ return false;
919
+ }
920
+
921
+ function driverPairs(spec: Spec, env: Binding): NameValue[] {
922
+ const pairs: NameValue[] = [];
923
+ for (let i = 0; i < spec.symbols.length; i++) {
924
+ if (!isDriver(spec.symbols[i]) || !env.known[i]) continue;
925
+ pairs.push({ name: spec.symbols[i].name, value: displayCode(spec, spec.symbols[i], env.code[i]) });
926
+ }
927
+ return pairs;
928
+ }
929
+
930
+ function pairClause(spec: Spec, pairs: NameValue[]): string {
931
+ const parts: string[] = [];
932
+ for (let i = 0; i < pairs.length; i++) parts.push(phrasePair(spec, pairs[i]));
933
+ return englishList(parts);
934
+ }
935
+
936
+ function phrasePair(spec: Spec, pair: NameValue): string {
937
+ for (let i = 0; i < spec.symbols.length; i++) {
938
+ const sym = spec.symbols[i];
939
+ if (sym.name !== pair.name) continue;
940
+ return describeAssignment(sym, pair.value, pair.value === "true");
941
+ }
942
+ return pair.name + " is " + pair.value;
943
+ }
944
+
945
+ function summaryText(ids: string[], minimal: boolean): string {
946
+ if (!minimal) return englishList(ids) + " cannot all hold.";
947
+ if (ids.length === 1) return ids[0] + " cannot hold.";
948
+ if (ids.length === 2) return ids[0] + " and " + ids[1] + " cannot both hold.";
949
+ return englishList(ids) + " cannot all hold.";
950
+ }
951
+
952
+ function clashText(spec: Spec, everySituation: boolean, situation: NameValue[]): string {
953
+ if (everySituation || situation.length === 0) return "They clash in every situation.";
954
+ return "They clash when " + pairClause(spec, situation) + ".";
955
+ }
956
+
957
+ function fixText(ids: string[], minimal: boolean): string {
958
+ if (!minimal) {
959
+ return (
960
+ "Suggested fix: ask which requirements should change. There are more than 12 requirements, so this is not yet the smallest conflicting set."
961
+ );
962
+ }
963
+ if (ids.length === 1) {
964
+ return (
965
+ "Suggested fix: rewrite " +
966
+ ids[0] +
967
+ ". It cannot be satisfied for any allowed values. Ask what should happen instead of choosing a behavior on your own."
968
+ );
969
+ }
970
+ if (ids.length === 2) {
971
+ return (
972
+ "Suggested fix: ask which requirement should win. To keep " +
973
+ ids[0] +
974
+ ", remove " +
975
+ ids[1] +
976
+ ". To keep " +
977
+ ids[1] +
978
+ ", remove " +
979
+ ids[0] +
980
+ "."
981
+ );
982
+ }
983
+ return "Suggested fix: ask which requirement should win. Removing any one of " + englishList(ids) + " leaves the rest consistent.";
984
+ }
985
+
986
+ function keepsText(keeps: string[], breaks: string): string {
987
+ if (keeps.length === 0) return "This scenario breaks " + breaks + ".";
988
+ if (keeps.length === 1) return "This scenario keeps " + keeps[0] + " and breaks " + breaks + ".";
989
+ return "This scenario keeps " + englishList(keeps) + " and breaks " + breaks + ".";
990
+ }
991
+
992
+ export function formatLintText(specPath: string, report: LintReport): string {
993
+ const lines: string[] = [];
994
+ lines.push("spec: " + specPath);
995
+ lines.push("conflicts: " + formatInt(report.conflicts.length));
996
+ lines.push("gaps: " + formatInt(report.gaps.length));
997
+ lines.push("misuse: " + formatInt(report.misuse.length));
998
+ lines.push("wording: " + formatInt(report.wording.length));
999
+ lines.push("redundant: " + formatInt(report.redundant.length));
1000
+ lines.push("duplicate: " + formatInt(report.duplicate.length));
1001
+ lines.push("vacuous: " + formatInt(report.vacuous.length));
1002
+ lines.push("unused: " + formatInt(report.unused.length));
1003
+ for (let i = 0; i < report.conflicts.length; i++) {
1004
+ const conflict = report.conflicts[i];
1005
+ lines.push("");
1006
+ lines.push("conflict " + brace(conflict.ids));
1007
+ lines.push(conflict.summary);
1008
+ if (!conflict.minimal) {
1009
+ lines.push("There are more than 12 requirements, so this set may be bigger than the smallest clash.");
1010
+ }
1011
+ for (let r = 0; r < conflict.ids.length; r++) {
1012
+ lines.push("");
1013
+ lines.push(conflict.ids[r] + " line " + formatInt(conflict.lines[r]));
1014
+ lines.push(conflict.sentences[r]);
1015
+ }
1016
+ lines.push("");
1017
+ lines.push(conflict.clash);
1018
+ for (let r = 0; r < conflict.requires.length; r++) {
1019
+ lines.push(conflict.requires[r].name + " requires " + conflict.requires[r].value + ".");
1020
+ }
1021
+ if (conflict.exampleJson.length > 0) {
1022
+ lines.push("");
1023
+ lines.push("Example scenario:");
1024
+ lines.push(conflict.exampleJson);
1025
+ if (conflict.keepsSentence.length > 0) lines.push(conflict.keepsSentence);
1026
+ }
1027
+ lines.push("");
1028
+ lines.push(conflict.fix);
1029
+ pushRepairLines(lines, conflict.repairs);
1030
+ }
1031
+ for (let i = 0; i < report.gaps.length; i++) {
1032
+ lines.push("");
1033
+ lines.push("gap " + report.gaps[i].output);
1034
+ lines.push(report.gaps[i].text);
1035
+ }
1036
+ for (let i = 0; i < report.misuse.length; i++) {
1037
+ const item = report.misuse[i];
1038
+ lines.push("");
1039
+ lines.push("misuse " + item.id + " line " + formatInt(item.line));
1040
+ lines.push(item.text);
1041
+ lines.push(item.sentence);
1042
+ }
1043
+ for (let i = 0; i < report.wording.length; i++) {
1044
+ const item = report.wording[i];
1045
+ lines.push("");
1046
+ lines.push("wording line " + formatInt(item.line));
1047
+ lines.push(item.text);
1048
+ lines.push(item.context);
1049
+ }
1050
+ for (let i = 0; i < report.redundant.length; i++) {
1051
+ const item = report.redundant[i];
1052
+ lines.push("");
1053
+ lines.push("redundant " + item.id + " line " + formatInt(item.line));
1054
+ lines.push(item.text);
1055
+ lines.push(item.sentence);
1056
+ lines.push(item.fix);
1057
+ }
1058
+ for (let i = 0; i < report.duplicate.length; i++) {
1059
+ const item = report.duplicate[i];
1060
+ lines.push("");
1061
+ lines.push("duplicate " + item.id + " line " + formatInt(item.line));
1062
+ lines.push(item.text);
1063
+ lines.push(item.sentence);
1064
+ lines.push(item.fix);
1065
+ }
1066
+ for (let i = 0; i < report.vacuous.length; i++) {
1067
+ const item = report.vacuous[i];
1068
+ lines.push("");
1069
+ lines.push("vacuous " + item.id + " line " + formatInt(item.line));
1070
+ lines.push(item.text);
1071
+ lines.push(item.sentence);
1072
+ lines.push(item.fix);
1073
+ }
1074
+ for (let i = 0; i < report.unused.length; i++) {
1075
+ const item = report.unused[i];
1076
+ lines.push("");
1077
+ lines.push("unused " + item.name);
1078
+ lines.push(item.text);
1079
+ lines.push(item.fix);
1080
+ }
1081
+ return lines.join("\n");
1082
+ }
1083
+
1084
+ export function formatLintJson(specPath: string, report: LintReport): string {
1085
+ const conflicts = [];
1086
+ for (let i = 0; i < report.conflicts.length; i++) {
1087
+ const item = report.conflicts[i];
1088
+ const requirements = [];
1089
+ for (let r = 0; r < item.ids.length; r++) {
1090
+ requirements.push({ id: item.ids[r], line: item.lines[r], sentence: item.sentences[r] });
1091
+ }
1092
+ const requires = [];
1093
+ for (let r = 0; r < item.requires.length; r++) {
1094
+ requires.push({ id: item.requires[r].name, text: item.requires[r].value });
1095
+ }
1096
+ conflicts.push({
1097
+ ids: item.ids,
1098
+ minimal: item.minimal,
1099
+ everySituation: item.everySituation,
1100
+ summary: item.summary,
1101
+ clash: item.clash,
1102
+ requirements: requirements,
1103
+ situation: item.situation,
1104
+ requires: requires,
1105
+ example: item.example,
1106
+ keeps: item.keeps,
1107
+ breaks: item.breaks,
1108
+ fix: item.fix,
1109
+ repairs: repairJson(item.repairs),
1110
+ });
1111
+ }
1112
+ const gaps = [];
1113
+ for (let i = 0; i < report.gaps.length; i++) {
1114
+ gaps.push({ output: report.gaps[i].output, situation: report.gaps[i].situation, text: report.gaps[i].text });
1115
+ }
1116
+ const misuse = [];
1117
+ for (let i = 0; i < report.misuse.length; i++) {
1118
+ const item = report.misuse[i];
1119
+ misuse.push({ id: item.id, line: item.line, symbols: item.symbols, sentence: item.sentence, text: item.text });
1120
+ }
1121
+ const wording = [];
1122
+ for (let i = 0; i < report.wording.length; i++) {
1123
+ const item = report.wording[i];
1124
+ wording.push({ line: item.line, word: item.word, kind: item.kind, text: item.text, context: item.context });
1125
+ }
1126
+ const redundant = [];
1127
+ for (let i = 0; i < report.redundant.length; i++) {
1128
+ const item = report.redundant[i];
1129
+ redundant.push({ id: item.id, line: item.line, sentence: item.sentence, text: item.text, fix: item.fix });
1130
+ }
1131
+ const duplicate = [];
1132
+ for (let i = 0; i < report.duplicate.length; i++) {
1133
+ const item = report.duplicate[i];
1134
+ duplicate.push({
1135
+ id: item.id,
1136
+ line: item.line,
1137
+ sameAs: item.sameAs,
1138
+ sentence: item.sentence,
1139
+ text: item.text,
1140
+ fix: item.fix,
1141
+ });
1142
+ }
1143
+ const vacuous = [];
1144
+ for (let i = 0; i < report.vacuous.length; i++) {
1145
+ const item = report.vacuous[i];
1146
+ vacuous.push({ id: item.id, line: item.line, sentence: item.sentence, text: item.text, fix: item.fix });
1147
+ }
1148
+ const unused = [];
1149
+ for (let i = 0; i < report.unused.length; i++) {
1150
+ const item = report.unused[i];
1151
+ unused.push({ name: item.name, kind: item.kind, text: item.text, fix: item.fix });
1152
+ }
1153
+ const ok =
1154
+ conflicts.length === 0 &&
1155
+ gaps.length === 0 &&
1156
+ misuse.length === 0 &&
1157
+ wording.length === 0 &&
1158
+ redundant.length === 0 &&
1159
+ duplicate.length === 0 &&
1160
+ vacuous.length === 0 &&
1161
+ unused.length === 0;
1162
+ return JSON.stringify(
1163
+ {
1164
+ ok: ok,
1165
+ spec: specPath,
1166
+ conflicts: conflicts,
1167
+ gaps: gaps,
1168
+ misuse: misuse,
1169
+ wording: wording,
1170
+ redundant: redundant,
1171
+ duplicate: duplicate,
1172
+ vacuous: vacuous,
1173
+ unused: unused,
1174
+ },
1175
+ null,
1176
+ 2,
1177
+ );
1178
+ }
1179
+
1180
+ function repairJson(repairs: Repair[]): { kind: string; id: string; text: string; sentence: string }[] {
1181
+ const out: { kind: string; id: string; text: string; sentence: string }[] = [];
1182
+ for (let i = 0; i < repairs.length; i++) {
1183
+ const item = repairs[i];
1184
+ out.push({ kind: item.kind, id: item.id, text: item.text, sentence: item.sentence });
1185
+ }
1186
+ return out;
1187
+ }
1188
+
1189
+ function pushRepairLines(lines: string[], repairs: Repair[]): void {
1190
+ if (repairs.length === 0) return;
1191
+ lines.push("");
1192
+ lines.push("Candidate repairs follow. This report does not choose one.");
1193
+ for (let i = 0; i < repairs.length; i++) {
1194
+ const item = repairs[i];
1195
+ lines.push("");
1196
+ lines.push("repair " + item.kind + " " + item.id);
1197
+ lines.push(item.text);
1198
+ pushPlainLines(lines, item.sentence);
1199
+ }
1200
+ }
1201
+
1202
+ function pushPlainLines(lines: string[], sentence: string): void {
1203
+ if (sentence.length === 0) return;
1204
+ let start = 0;
1205
+ for (let i = 0; i <= sentence.length; i++) {
1206
+ if (i === sentence.length || sentence.charCodeAt(i) === 10) {
1207
+ const part = sentence.slice(start, i);
1208
+ if (part.length > 0) lines.push(part);
1209
+ start = i + 1;
1210
+ }
1211
+ }
1212
+ }
1213
+
1214
+ function brace(ids: string[]): string {
1215
+ let out = "{";
1216
+ for (let i = 0; i < ids.length; i++) {
1217
+ if (i > 0) out += ", ";
1218
+ out += ids[i];
1219
+ }
1220
+ return out + "}";
1221
+ }
1222
+
1223
+ function boundaryBefore(text: string, index: number): boolean {
1224
+ if (index === 0) return true;
1225
+ return !isWordChar(text.charCodeAt(index - 1));
1226
+ }
1227
+
1228
+ function boundaryAfter(text: string, index: number): boolean {
1229
+ if (index >= text.length) return true;
1230
+ return !isWordChar(text.charCodeAt(index));
1231
+ }
1232
+
1233
+ function isWordChar(code: number): boolean {
1234
+ return (code >= 48 && code <= 57) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95;
1235
+ }
1236
+
1237
+ function lowerAscii(text: string): string {
1238
+ let out = "";
1239
+ for (let i = 0; i < text.length; i++) {
1240
+ const code = text.charCodeAt(i);
1241
+ if (code >= 65 && code <= 90) out += String.fromCharCode(code + 32);
1242
+ else out += text.slice(i, i + 1);
1243
+ }
1244
+ return out;
1245
+ }