pabst-checker 0.12.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/LICENSE +21 -0
- package/README.md +232 -0
- package/dist/build-spec.d.ts +2 -0
- package/dist/build-spec.js +46 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +119 -0
- package/dist/codegen.d.ts +6 -0
- package/dist/codegen.js +30 -0
- package/dist/desugar.d.ts +5 -0
- package/dist/desugar.js +136 -0
- package/dist/discover.d.ts +16 -0
- package/dist/discover.js +113 -0
- package/dist/domains.d.ts +47 -0
- package/dist/domains.js +130 -0
- package/dist/emit.d.ts +2 -0
- package/dist/emit.js +74 -0
- package/dist/envelope.d.ts +29 -0
- package/dist/envelope.js +28 -0
- package/dist/equations.d.ts +8 -0
- package/dist/equations.js +256 -0
- package/dist/errors.d.ts +9 -0
- package/dist/errors.js +9 -0
- package/dist/extract.d.ts +21 -0
- package/dist/extract.js +177 -0
- package/dist/formula-ast.d.ts +26 -0
- package/dist/formula-ast.js +18 -0
- package/dist/formula-lexer.d.ts +31 -0
- package/dist/formula-lexer.js +191 -0
- package/dist/formula-parser.d.ts +2 -0
- package/dist/formula-parser.js +122 -0
- package/dist/free-idents.d.ts +6 -0
- package/dist/free-idents.js +60 -0
- package/dist/ir.d.ts +45 -0
- package/dist/ir.js +1 -0
- package/dist/issue.d.ts +26 -0
- package/dist/issue.js +18 -0
- package/dist/lower.d.ts +12 -0
- package/dist/lower.js +37 -0
- package/dist/parse-formula.d.ts +12 -0
- package/dist/parse-formula.js +78 -0
- package/dist/prefix-parser.d.ts +7 -0
- package/dist/prefix-parser.js +188 -0
- package/dist/qualified-name.d.ts +5 -0
- package/dist/qualified-name.js +11 -0
- package/dist/range.d.ts +5 -0
- package/dist/range.js +152 -0
- package/dist/regex-guard.d.ts +28 -0
- package/dist/regex-guard.js +100 -0
- package/dist/run.d.ts +26 -0
- package/dist/run.js +50 -0
- package/dist/runtime.d.ts +25 -0
- package/dist/runtime.js +62 -0
- package/dist/seed.d.ts +7 -0
- package/dist/seed.js +20 -0
- package/package.json +67 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { PabstError } from "./errors.js";
|
|
3
|
+
import { lexFormula, scanTokens, sliceText, } from "./formula-lexer.js";
|
|
4
|
+
/**
|
|
5
|
+
* Validate an atom's JS and desugar its equation (see README, "Equations"):
|
|
6
|
+
* a depth-0 `A ≡ B` becomes `Object.is(A, B)` and `A ≢ B` becomes
|
|
7
|
+
* `!Object.is(A, B)`. The glyphs are available only at the atom's top
|
|
8
|
+
* level — in nested positions (callbacks, arguments, template
|
|
9
|
+
* substitutions) call Object.is directly.
|
|
10
|
+
*/
|
|
11
|
+
export function desugarEquations(text) {
|
|
12
|
+
const toks = lexFormula(text);
|
|
13
|
+
// --- Token-level rules (single tokens are visible at every depth) ---
|
|
14
|
+
let sawPlainAssign = false;
|
|
15
|
+
let sawAssignMaterial = false;
|
|
16
|
+
for (let i = 0; i < toks.length; i++) {
|
|
17
|
+
const t = toks[i];
|
|
18
|
+
if (t.text === "≠") {
|
|
19
|
+
throw new PabstError(`≠ is not pabst syntax: write ≢ for negated identity (in: ${text})`);
|
|
20
|
+
}
|
|
21
|
+
if (isGlyph(t)) {
|
|
22
|
+
const sub = t.text === "≡" ? "==" : "!=";
|
|
23
|
+
const prev = toks[i - 1];
|
|
24
|
+
const next = toks[i + 1];
|
|
25
|
+
const fused = (prev && prev.end === t.start && scansAsOne(prev.text + sub[0])) ||
|
|
26
|
+
(next && next.start === t.end && !scanSplitsAt(sub, next.text));
|
|
27
|
+
if (fused) {
|
|
28
|
+
throw new PabstError(`an equation glyph fused with an adjacent operator (e.g. ≡= scans ` +
|
|
29
|
+
`as ===): write the equation as A ≡ B with the glyph standing ` +
|
|
30
|
+
`alone (in: ${text})`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
if (t.text === "=") {
|
|
34
|
+
sawPlainAssign = true;
|
|
35
|
+
sawAssignMaterial = true;
|
|
36
|
+
}
|
|
37
|
+
else if (ASSIGN_TOKENS.has(t.text)) {
|
|
38
|
+
sawAssignMaterial = true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// --- Locate depth-0 glyphs and classify the atom's shape ---
|
|
42
|
+
const depth0 = depthZeroIndex(toks);
|
|
43
|
+
const glyphs = [...depth0.keys()].filter((i) => isGlyph(toks[i]));
|
|
44
|
+
if (glyphs.length > 0) {
|
|
45
|
+
// Splitting on the glyph splices each side into Object.is(l, r), so a
|
|
46
|
+
// depth-0 neighbor binding looser than ≡ would silently regroup.
|
|
47
|
+
for (const i of depth0.keys()) {
|
|
48
|
+
const t = toks[i].text;
|
|
49
|
+
if (t === "?" || t === ":") {
|
|
50
|
+
throw new PabstError(`an equation cannot sit beside an unparenthesized ternary: write ` +
|
|
51
|
+
`a ≡ (b ? c : d), or call Object.is in the ternary's branches ` +
|
|
52
|
+
`(in: ${text})`);
|
|
53
|
+
}
|
|
54
|
+
if (t === ",") {
|
|
55
|
+
throw new PabstError(`an equation cannot sit beside a depth-0 comma: parenthesize ` +
|
|
56
|
+
`the comma expression, e.g. (a, b) ≡ x (in: ${text})`);
|
|
57
|
+
}
|
|
58
|
+
if (t === "=>") {
|
|
59
|
+
throw new PabstError(`an equation cannot sit beside an unparenthesized arrow ` +
|
|
60
|
+
`function: parenthesize it, e.g. f ≡ (x => x) (in: ${text})`);
|
|
61
|
+
}
|
|
62
|
+
if (t === "&&") {
|
|
63
|
+
throw new PabstError(`use ∧ for conjunction at the property's top level, not JS && ` +
|
|
64
|
+
`(note: ≡ binds tighter than &&) (in: ${text})`);
|
|
65
|
+
}
|
|
66
|
+
if (t === "||") {
|
|
67
|
+
throw new PabstError(`use ∨ for disjunction at the property's top level, not JS || ` +
|
|
68
|
+
`(note: ≡ binds tighter than ||) (in: ${text})`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (glyphs.length > 1) {
|
|
73
|
+
throw new PabstError(`chained equations are not supported: split into conjuncts, ` +
|
|
74
|
+
`e.g. a ≡ b ∧ b ≡ c, or parenthesize (in: ${text})`);
|
|
75
|
+
}
|
|
76
|
+
// --- Loose equality (any depth) ---
|
|
77
|
+
for (const t of toks) {
|
|
78
|
+
if (t.text === "==") {
|
|
79
|
+
throw new PabstError(`loose equality (==) is not allowed: use ≡ for identity ` +
|
|
80
|
+
`(Object.is) or === for JS strict equality (in: ${text})`);
|
|
81
|
+
}
|
|
82
|
+
if (t.text === "!=") {
|
|
83
|
+
throw new PabstError(`loose inequality (!=) is not allowed: use ≢ for negated ` +
|
|
84
|
+
`identity or !== for JS strict inequality (in: ${text})`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// --- Split the equation and guard its sides ---
|
|
88
|
+
let js = text;
|
|
89
|
+
if (glyphs.length === 1) {
|
|
90
|
+
const g = glyphs[0];
|
|
91
|
+
const left = toks.slice(0, g);
|
|
92
|
+
const right = toks.slice(g + 1);
|
|
93
|
+
if (left.length === 0 || right.length === 0) {
|
|
94
|
+
throw new PabstError(`cannot parse atom: ${text}`);
|
|
95
|
+
}
|
|
96
|
+
guardSide(left, "left", text);
|
|
97
|
+
guardSide(right, "right", text);
|
|
98
|
+
const l = sliceText(text, left).trim();
|
|
99
|
+
const r = sliceText(text, right).trim();
|
|
100
|
+
const call = `Object.is(${l}, ${r})`;
|
|
101
|
+
js = toks[g].text === "≡" ? call : `!${call}`;
|
|
102
|
+
}
|
|
103
|
+
// --- TS-AST validation ---
|
|
104
|
+
const sf = ts.createSourceFile("__atom.ts", `(${js});`, ts.ScriptTarget.Latest, true);
|
|
105
|
+
const stmt = sf.statements[0];
|
|
106
|
+
const diags = sf
|
|
107
|
+
.parseDiagnostics;
|
|
108
|
+
const root = stmt &&
|
|
109
|
+
ts.isExpressionStatement(stmt) &&
|
|
110
|
+
ts.isParenthesizedExpression(stmt.expression)
|
|
111
|
+
? stmt.expression.expression
|
|
112
|
+
: null;
|
|
113
|
+
if (!root || diags.length > 0) {
|
|
114
|
+
if (sawPlainAssign)
|
|
115
|
+
throw new PabstError(assignmentMessage(text));
|
|
116
|
+
const nested = nestedGlyph(toks, depth0);
|
|
117
|
+
if (nested) {
|
|
118
|
+
throw new PabstError(`${nested.text} is only available at an atom's top level — in a ` +
|
|
119
|
+
`nested position, call Object.is(A, B) (or !Object.is(A, B)) ` +
|
|
120
|
+
`directly (in: ${text})`);
|
|
121
|
+
}
|
|
122
|
+
if (glyphs.length > 0 || sawAssignMaterial) {
|
|
123
|
+
throw new PabstError(`cannot parse atom: ${text}`);
|
|
124
|
+
}
|
|
125
|
+
// Not TS-parseable and no formula material: leave the atom for the
|
|
126
|
+
// generated test code to diagnose.
|
|
127
|
+
return js;
|
|
128
|
+
}
|
|
129
|
+
banAssignments(root, text);
|
|
130
|
+
// A desugared ≢ is our own `!Object.is(…)` — the leaf rule judges only
|
|
131
|
+
// atoms the user wrote, so it applies when no equation was split.
|
|
132
|
+
if (glyphs.length === 0)
|
|
133
|
+
enforceLeafRule(root, text);
|
|
134
|
+
return js;
|
|
135
|
+
}
|
|
136
|
+
function assignmentMessage(original) {
|
|
137
|
+
return (`= is JS assignment, and assignments are not allowed in a formula atom: ` +
|
|
138
|
+
`if you meant equality, write A ≡ B for identity (Object.is), or call ` +
|
|
139
|
+
`Object.is(A, B) directly (in: ${original})`);
|
|
140
|
+
}
|
|
141
|
+
const ASSIGN_TOKENS = new Set([
|
|
142
|
+
"=",
|
|
143
|
+
"+=",
|
|
144
|
+
"-=",
|
|
145
|
+
"*=",
|
|
146
|
+
"/=",
|
|
147
|
+
"%=",
|
|
148
|
+
"**=",
|
|
149
|
+
"<<=",
|
|
150
|
+
">>=",
|
|
151
|
+
">>>=",
|
|
152
|
+
"&=",
|
|
153
|
+
"|=",
|
|
154
|
+
"^=",
|
|
155
|
+
"&&=",
|
|
156
|
+
"||=",
|
|
157
|
+
"??=",
|
|
158
|
+
]);
|
|
159
|
+
function isGlyph(t) {
|
|
160
|
+
return t.text === "≡" || t.text === "≢";
|
|
161
|
+
}
|
|
162
|
+
/** True when `s` scans as a single token — a glyph neighbor with this
|
|
163
|
+
* property merges with the substituted ==/!= (e.g. ! + == scans as !==). */
|
|
164
|
+
function scansAsOne(s) {
|
|
165
|
+
let count = 0;
|
|
166
|
+
let end = 0;
|
|
167
|
+
for (const t of scanTokens(s)) {
|
|
168
|
+
count++;
|
|
169
|
+
end = t.end;
|
|
170
|
+
}
|
|
171
|
+
return count === 1 && end === s.length;
|
|
172
|
+
}
|
|
173
|
+
/** True when `left + right` scans with a token boundary after `left`. */
|
|
174
|
+
function scanSplitsAt(left, right) {
|
|
175
|
+
const first = scanTokens(left + right).next().value;
|
|
176
|
+
return first !== undefined && first.end === left.length;
|
|
177
|
+
}
|
|
178
|
+
/** The set of token indices at depth 0 (template substitutions nest). */
|
|
179
|
+
function depthZeroIndex(toks) {
|
|
180
|
+
const at = new Set();
|
|
181
|
+
let depth = 0;
|
|
182
|
+
for (let i = 0; i < toks.length; i++) {
|
|
183
|
+
const t = toks[i];
|
|
184
|
+
if (t.kind === "open")
|
|
185
|
+
depth++;
|
|
186
|
+
else if (t.kind === "close")
|
|
187
|
+
depth = Math.max(0, depth - 1);
|
|
188
|
+
else if (depth === 0)
|
|
189
|
+
at.add(i);
|
|
190
|
+
}
|
|
191
|
+
return at;
|
|
192
|
+
}
|
|
193
|
+
/** The first equation glyph NOT at depth 0, if any. */
|
|
194
|
+
function nestedGlyph(toks, depth0) {
|
|
195
|
+
for (let i = 0; i < toks.length; i++) {
|
|
196
|
+
if (isGlyph(toks[i]) && !depth0.has(i))
|
|
197
|
+
return toks[i];
|
|
198
|
+
}
|
|
199
|
+
return undefined;
|
|
200
|
+
}
|
|
201
|
+
/** An equation side may not carry a depth-0 ===/!== (ambiguous chain) or
|
|
202
|
+
* ?? (parenthesize the intended grouping). */
|
|
203
|
+
function guardSide(side, which, text) {
|
|
204
|
+
for (const i of depthZeroIndex(side)) {
|
|
205
|
+
const t = side[i];
|
|
206
|
+
if (t.text === "===" || t.text === "!==") {
|
|
207
|
+
throw new PabstError(`chained equations are not supported: split into conjuncts, ` +
|
|
208
|
+
`e.g. a ≡ b ∧ b ≡ c, or parenthesize (in: ${text})`);
|
|
209
|
+
}
|
|
210
|
+
if (t.text === "??") {
|
|
211
|
+
throw which === "right"
|
|
212
|
+
? new PabstError(`parenthesize the ?? expression: ≡ binds tighter than ?? , so ` +
|
|
213
|
+
`a ≡ b ?? c means (a ≡ b) ?? c (in: ${text})`)
|
|
214
|
+
: new PabstError(`?? at an atom's top level over an equation is dead code — ` +
|
|
215
|
+
`Object.is results are never nullish: parenthesize the ` +
|
|
216
|
+
`intended grouping (in: ${text})`);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** Assignments cannot appear in an atom at any depth. A parameter default
|
|
221
|
+
* ((x = 1) => …) is an initializer, not a BinaryExpression, so it passes. */
|
|
222
|
+
function banAssignments(root, text) {
|
|
223
|
+
const visit = (node) => {
|
|
224
|
+
if (ts.isBinaryExpression(node)) {
|
|
225
|
+
const op = node.operatorToken.kind;
|
|
226
|
+
if (op >= ts.SyntaxKind.FirstAssignment &&
|
|
227
|
+
op <= ts.SyntaxKind.LastAssignment) {
|
|
228
|
+
throw new PabstError(op === ts.SyntaxKind.EqualsToken
|
|
229
|
+
? assignmentMessage(text)
|
|
230
|
+
: `${ts.tokenToString(op)} is JS assignment: assignments are ` +
|
|
231
|
+
`not allowed in a formula atom (in: ${text})`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
ts.forEachChild(node, visit);
|
|
235
|
+
};
|
|
236
|
+
visit(root);
|
|
237
|
+
}
|
|
238
|
+
/** Formula connectives may not surface at the atom's root (the leaf rule):
|
|
239
|
+
* use ∧ ∨ ¬, not JS && || !. Deeper occurrences are ordinary JS. */
|
|
240
|
+
function enforceLeafRule(root, text) {
|
|
241
|
+
let expr = root;
|
|
242
|
+
while (ts.isParenthesizedExpression(expr))
|
|
243
|
+
expr = expr.expression;
|
|
244
|
+
if (ts.isBinaryExpression(expr)) {
|
|
245
|
+
if (expr.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
|
|
246
|
+
throw new PabstError(`use ∧ for conjunction at the property's top level, not JS && (in: ${text})`);
|
|
247
|
+
}
|
|
248
|
+
if (expr.operatorToken.kind === ts.SyntaxKind.BarBarToken) {
|
|
249
|
+
throw new PabstError(`use ∨ for disjunction at the property's top level, not JS || (in: ${text})`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (ts.isPrefixUnaryExpression(expr) &&
|
|
253
|
+
expr.operator === ts.SyntaxKind.ExclamationToken) {
|
|
254
|
+
throw new PabstError(`use ¬ for negation at the property's top level, not JS ! (in: ${text})`);
|
|
255
|
+
}
|
|
256
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-facing error: the input (an annotation, formula, or reference) is at
|
|
3
|
+
* fault, not pabst. The CLI catches these and reports them as exit 2 with a
|
|
4
|
+
* one-line diagnostic. Anything else escaping the compile front-end is an
|
|
5
|
+
* internal bug and crashes loudly.
|
|
6
|
+
*/
|
|
7
|
+
export declare class PabstError extends Error {
|
|
8
|
+
name: string;
|
|
9
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A user-facing error: the input (an annotation, formula, or reference) is at
|
|
3
|
+
* fault, not pabst. The CLI catches these and reports them as exit 2 with a
|
|
4
|
+
* one-line diagnostic. Anything else escaping the compile front-end is an
|
|
5
|
+
* internal bug and crashes loudly.
|
|
6
|
+
*/
|
|
7
|
+
export class PabstError extends Error {
|
|
8
|
+
name = "PabstError";
|
|
9
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface RawAnnotation {
|
|
2
|
+
propertyName: string;
|
|
3
|
+
functionName: string;
|
|
4
|
+
className?: string;
|
|
5
|
+
isStatic?: boolean;
|
|
6
|
+
formula: string;
|
|
7
|
+
line: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ExtractResult {
|
|
10
|
+
file: string;
|
|
11
|
+
exports: Set<string>;
|
|
12
|
+
annotations: RawAnnotation[];
|
|
13
|
+
}
|
|
14
|
+
/** Read a source file from disk and extract its annotations. */
|
|
15
|
+
export declare function extract(file: string): ExtractResult;
|
|
16
|
+
/**
|
|
17
|
+
* Extract `@ensures` annotations and exported names from already-loaded source
|
|
18
|
+
* text. `file` is only a label — for diagnostics and the returned `file` field —
|
|
19
|
+
* so this is pure and testable without touching disk.
|
|
20
|
+
*/
|
|
21
|
+
export declare function extractFromSource(text: string, file: string): ExtractResult;
|
package/dist/extract.js
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import * as fs from "node:fs";
|
|
3
|
+
import { qualifiedName } from "./qualified-name.js";
|
|
4
|
+
import { PabstError } from "./errors.js";
|
|
5
|
+
// The `@ensures` tag name is matched via the JSDoc AST; this only peels the
|
|
6
|
+
// `{name}` prefix off the tag's comment text, leaving the formula as the rest.
|
|
7
|
+
const ENSURES_NAME = /^\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}([\s\S]*)$/;
|
|
8
|
+
/** Read a source file from disk and extract its annotations. */
|
|
9
|
+
export function extract(file) {
|
|
10
|
+
return extractFromSource(fs.readFileSync(file, "utf8"), file);
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Extract `@ensures` annotations and exported names from already-loaded source
|
|
14
|
+
* text. `file` is only a label — for diagnostics and the returned `file` field —
|
|
15
|
+
* so this is pure and testable without touching disk.
|
|
16
|
+
*/
|
|
17
|
+
export function extractFromSource(text, file) {
|
|
18
|
+
const sf = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
|
|
19
|
+
const exportsSet = collectExports(sf);
|
|
20
|
+
const annotations = [];
|
|
21
|
+
const seen = new Map();
|
|
22
|
+
const record = (a, key, subject) => {
|
|
23
|
+
const set = seen.get(key) ?? new Set();
|
|
24
|
+
if (set.has(a.propertyName)) {
|
|
25
|
+
throw new PabstError(`duplicate property name '${a.propertyName}' on ${subject} in ${file}`);
|
|
26
|
+
}
|
|
27
|
+
set.add(a.propertyName);
|
|
28
|
+
seen.set(key, set);
|
|
29
|
+
annotations.push(a);
|
|
30
|
+
};
|
|
31
|
+
for (const stmt of sf.statements) {
|
|
32
|
+
if (ts.isClassDeclaration(stmt)) {
|
|
33
|
+
collectClassAnnotations(stmt, sf, exportsSet, file, record);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const fnName = functionNameOf(stmt);
|
|
37
|
+
if (!fnName)
|
|
38
|
+
continue;
|
|
39
|
+
for (const m of ensuresComments(stmt, sf)) {
|
|
40
|
+
record({
|
|
41
|
+
propertyName: m.propertyName,
|
|
42
|
+
functionName: fnName,
|
|
43
|
+
formula: m.formula,
|
|
44
|
+
line: m.line,
|
|
45
|
+
}, fnName, `function '${fnName}'`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return { file, exports: exportsSet, annotations };
|
|
49
|
+
}
|
|
50
|
+
function ensuresComments(node, sf) {
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const tag of ts.getJSDocTags(node)) {
|
|
53
|
+
if (tag.tagName.escapedText !== "ensures")
|
|
54
|
+
continue;
|
|
55
|
+
const comment = ts.getTextOfJSDocComment(tag.comment)?.trim() ?? "";
|
|
56
|
+
const m = ENSURES_NAME.exec(comment);
|
|
57
|
+
if (!m)
|
|
58
|
+
continue;
|
|
59
|
+
out.push({
|
|
60
|
+
propertyName: m[1],
|
|
61
|
+
formula: m[2].trim(),
|
|
62
|
+
line: sf.getLineAndCharacterOfPosition(tag.pos).line + 1,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
function collectClassAnnotations(cls, sf, exportsSet, file, record) {
|
|
68
|
+
const className = cls.name?.text;
|
|
69
|
+
for (const member of cls.members) {
|
|
70
|
+
const matches = ensuresComments(member, sf);
|
|
71
|
+
if (matches.length === 0)
|
|
72
|
+
continue;
|
|
73
|
+
const label = memberLabel(member);
|
|
74
|
+
if (!className) {
|
|
75
|
+
throw new PabstError(`@ensures on method '${label}' of an anonymous class in ${file}`);
|
|
76
|
+
}
|
|
77
|
+
if (!isEligibleMethod(member)) {
|
|
78
|
+
throw new PabstError(ineligibleMessage(member, label, className, file));
|
|
79
|
+
}
|
|
80
|
+
if (!exportsSet.has(className)) {
|
|
81
|
+
throw new PabstError(`@ensures on method '${label}' of class '${className}', which is not exported from ${file}`);
|
|
82
|
+
}
|
|
83
|
+
const isStatic = hasModifier(member, ts.SyntaxKind.StaticKeyword);
|
|
84
|
+
const key = qualifiedName(label, className, isStatic);
|
|
85
|
+
for (const m of matches) {
|
|
86
|
+
record({
|
|
87
|
+
propertyName: m.propertyName,
|
|
88
|
+
functionName: label,
|
|
89
|
+
className,
|
|
90
|
+
isStatic,
|
|
91
|
+
formula: m.formula,
|
|
92
|
+
line: m.line,
|
|
93
|
+
}, key, `method '${key}'`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function isEligibleMethod(member) {
|
|
98
|
+
if (!ts.isMethodDeclaration(member))
|
|
99
|
+
return false;
|
|
100
|
+
if (!ts.isIdentifier(member.name))
|
|
101
|
+
return false; // computed name or #private
|
|
102
|
+
if (hasModifier(member, ts.SyntaxKind.PrivateKeyword))
|
|
103
|
+
return false;
|
|
104
|
+
if (hasModifier(member, ts.SyntaxKind.ProtectedKeyword))
|
|
105
|
+
return false;
|
|
106
|
+
if (hasModifier(member, ts.SyntaxKind.AbstractKeyword))
|
|
107
|
+
return false;
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
function ineligibleMessage(member, label, className, file) {
|
|
111
|
+
const nonPublic = ts.isMethodDeclaration(member) &&
|
|
112
|
+
(hasModifier(member, ts.SyntaxKind.PrivateKeyword) ||
|
|
113
|
+
hasModifier(member, ts.SyntaxKind.ProtectedKeyword) ||
|
|
114
|
+
ts.isPrivateIdentifier(member.name));
|
|
115
|
+
if (nonPublic) {
|
|
116
|
+
return `@ensures on non-public method '${label}' of class '${className}' in ${file}`;
|
|
117
|
+
}
|
|
118
|
+
return (`@ensures on unsupported member '${label}' of class '${className}' in ${file} ` +
|
|
119
|
+
`(accessors, constructors, abstract, and computed-name members are not supported)`);
|
|
120
|
+
}
|
|
121
|
+
function memberLabel(member) {
|
|
122
|
+
const name = member.name;
|
|
123
|
+
if (name &&
|
|
124
|
+
(ts.isIdentifier(name) ||
|
|
125
|
+
ts.isPrivateIdentifier(name) ||
|
|
126
|
+
ts.isStringLiteral(name))) {
|
|
127
|
+
return name.text;
|
|
128
|
+
}
|
|
129
|
+
if (ts.isConstructorDeclaration(member))
|
|
130
|
+
return "constructor";
|
|
131
|
+
return "<computed>";
|
|
132
|
+
}
|
|
133
|
+
function hasModifier(node, kind) {
|
|
134
|
+
const mods = ts.canHaveModifiers(node) ? ts.getModifiers(node) : undefined;
|
|
135
|
+
return mods?.some((m) => m.kind === kind) ?? false;
|
|
136
|
+
}
|
|
137
|
+
function functionNameOf(stmt) {
|
|
138
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name)
|
|
139
|
+
return stmt.name.text;
|
|
140
|
+
if (ts.isVariableStatement(stmt)) {
|
|
141
|
+
const decl = stmt.declarationList.declarations[0];
|
|
142
|
+
if (decl &&
|
|
143
|
+
ts.isIdentifier(decl.name) &&
|
|
144
|
+
decl.initializer &&
|
|
145
|
+
(ts.isArrowFunction(decl.initializer) ||
|
|
146
|
+
ts.isFunctionExpression(decl.initializer))) {
|
|
147
|
+
return decl.name.text;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return undefined;
|
|
151
|
+
}
|
|
152
|
+
function collectExports(sf) {
|
|
153
|
+
const out = new Set();
|
|
154
|
+
for (const stmt of sf.statements) {
|
|
155
|
+
const mods = ts.canHaveModifiers(stmt) ? ts.getModifiers(stmt) : undefined;
|
|
156
|
+
const isExported = mods?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword);
|
|
157
|
+
if (isExported) {
|
|
158
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name)
|
|
159
|
+
out.add(stmt.name.text);
|
|
160
|
+
else if (ts.isClassDeclaration(stmt) && stmt.name)
|
|
161
|
+
out.add(stmt.name.text);
|
|
162
|
+
else if (ts.isVariableStatement(stmt)) {
|
|
163
|
+
for (const d of stmt.declarationList.declarations) {
|
|
164
|
+
if (ts.isIdentifier(d.name))
|
|
165
|
+
out.add(d.name.text);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (ts.isExportDeclaration(stmt) &&
|
|
170
|
+
stmt.exportClause &&
|
|
171
|
+
ts.isNamedExports(stmt.exportClause)) {
|
|
172
|
+
for (const e of stmt.exportClause.elements)
|
|
173
|
+
out.add(e.name.text);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export type Formula = {
|
|
2
|
+
kind: "atom";
|
|
3
|
+
text: string;
|
|
4
|
+
js: string;
|
|
5
|
+
} | {
|
|
6
|
+
kind: "not";
|
|
7
|
+
arg: Formula;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "and";
|
|
10
|
+
left: Formula;
|
|
11
|
+
right: Formula;
|
|
12
|
+
} | {
|
|
13
|
+
kind: "or";
|
|
14
|
+
left: Formula;
|
|
15
|
+
right: Formula;
|
|
16
|
+
} | {
|
|
17
|
+
kind: "iff";
|
|
18
|
+
left: Formula;
|
|
19
|
+
right: Formula;
|
|
20
|
+
} | {
|
|
21
|
+
kind: "implication";
|
|
22
|
+
antecedents: Formula[];
|
|
23
|
+
consequent: Formula;
|
|
24
|
+
};
|
|
25
|
+
/** All atom executable JS expressions (equation-desugared), left-to-right. */
|
|
26
|
+
export declare function collectAtoms(f: Formula): string[];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/** All atom executable JS expressions (equation-desugared), left-to-right. */
|
|
2
|
+
export function collectAtoms(f) {
|
|
3
|
+
switch (f.kind) {
|
|
4
|
+
case "atom":
|
|
5
|
+
return [f.js];
|
|
6
|
+
case "not":
|
|
7
|
+
return collectAtoms(f.arg);
|
|
8
|
+
case "and":
|
|
9
|
+
case "or":
|
|
10
|
+
case "iff":
|
|
11
|
+
return [...collectAtoms(f.left), ...collectAtoms(f.right)];
|
|
12
|
+
case "implication":
|
|
13
|
+
return [
|
|
14
|
+
...f.antecedents.flatMap(collectAtoms),
|
|
15
|
+
...collectAtoms(f.consequent),
|
|
16
|
+
];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
export type FTokenKind = "not" | "and" | "or" | "implies" | "iff" | "open" | "close" | "js";
|
|
3
|
+
export interface FToken {
|
|
4
|
+
kind: FTokenKind;
|
|
5
|
+
text: string;
|
|
6
|
+
start: number;
|
|
7
|
+
end: number;
|
|
8
|
+
}
|
|
9
|
+
export interface ScannedToken {
|
|
10
|
+
kind: ts.SyntaxKind;
|
|
11
|
+
text: string;
|
|
12
|
+
start: number;
|
|
13
|
+
end: number;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Scan `text` with the context fixes the standalone TS scanner does not
|
|
17
|
+
* apply on its own: `/` regex-vs-division, `>` re-merged into >=, >>, … (the
|
|
18
|
+
* base scan splits them for generic-closing-`>` handling), and template
|
|
19
|
+
* continuation after a `${…}` substitution closes (the scanner would
|
|
20
|
+
* otherwise leave template mode and corrupt the middle/tail text).
|
|
21
|
+
*/
|
|
22
|
+
export declare function scanTokens(text: string, start?: number): Generator<ScannedToken>;
|
|
23
|
+
/** Tokenize a formula body. Glyphs/fallbacks become connective tokens; the rest is js. */
|
|
24
|
+
export declare function lexFormula(body: string): FToken[];
|
|
25
|
+
/** The source text spanned by toks (empty when toks is empty). */
|
|
26
|
+
export declare function sliceText(source: string, toks: readonly {
|
|
27
|
+
start: number;
|
|
28
|
+
end: number;
|
|
29
|
+
}[]): string;
|
|
30
|
+
/** A `/` can begin a regex unless the previous token ends a value (then it's division). */
|
|
31
|
+
export declare function regexCanFollow(prev: ts.SyntaxKind | null): boolean;
|