@fairfox/polly 0.82.1 → 0.84.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/polly.js +22 -1
- package/dist/cli/polly.js.map +3 -3
- package/dist/tools/bdd/src/args.d.ts +21 -0
- package/dist/tools/bdd/src/bus-driver.d.ts +36 -0
- package/dist/tools/bdd/src/check-verify.d.ts +15 -0
- package/dist/tools/bdd/src/cli.d.ts +2 -0
- package/dist/tools/bdd/src/cli.js +705 -0
- package/dist/tools/bdd/src/cli.js.map +19 -0
- package/dist/tools/bdd/src/config.d.ts +9 -0
- package/dist/tools/bdd/src/extract.d.ts +2 -0
- package/dist/tools/bdd/src/index.d.ts +19 -0
- package/dist/tools/bdd/src/index.js +544 -0
- package/dist/tools/bdd/src/index.js.map +17 -0
- package/dist/tools/bdd/src/parse.d.ts +3 -0
- package/dist/tools/bdd/src/report.d.ts +6 -0
- package/dist/tools/bdd/src/run.d.ts +8 -0
- package/dist/tools/bdd/src/scaffold.d.ts +7 -0
- package/dist/tools/bdd/src/steps.d.ts +55 -0
- package/dist/tools/bdd/src/types.d.ts +152 -0
- package/dist/tools/bdd/src/witness.d.ts +23 -0
- package/dist/tools/quality/src/cli.js +304 -15
- package/dist/tools/quality/src/cli.js.map +6 -4
- package/dist/tools/quality/src/index.d.ts +2 -0
- package/dist/tools/quality/src/index.js +309 -15
- package/dist/tools/quality/src/index.js.map +6 -4
- package/dist/tools/quality/src/no-fixed-waits.d.ts +52 -0
- package/dist/tools/quality/src/no-tautology-ensures.d.ts +67 -0
- package/dist/tools/quality/src/plugins/core.d.ts +1 -1
- package/dist/tools/test/src/tiers/cli.js +21 -1
- package/dist/tools/test/src/tiers/cli.js.map +3 -3
- package/dist/tools/verify/src/cli.js +569 -1
- package/dist/tools/verify/src/cli.js.map +8 -4
- package/package.json +7 -1
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { RunResult } from "./types.ts";
|
|
2
|
+
export interface RunOptions {
|
|
3
|
+
featureFiles: string[];
|
|
4
|
+
stepFiles: string[];
|
|
5
|
+
/** Only run scenarios carrying this tag (without leading @); ~tag negates. */
|
|
6
|
+
tagFilter?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function runFeatures(options: RunOptions): Promise<RunResult>;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The step-binding registry and matcher.
|
|
3
|
+
*
|
|
4
|
+
* `defineStep()` is the consumer-facing keystone: one call registers a binding
|
|
5
|
+
* that the runner executes (`given`/`when`/`then`) AND the verify extractor
|
|
6
|
+
* reads (`message`/`stateExpr`). `defineWorld()` records how to build and
|
|
7
|
+
* cold-reset the real-factory world.
|
|
8
|
+
*
|
|
9
|
+
* Patterns use a small Cucumber-expression subset: `{string}`, `{int}`,
|
|
10
|
+
* `{float}`, `{word}`. Everything else is matched literally. That covers
|
|
11
|
+
* declarative steps without dragging in the full cucumber-expressions library.
|
|
12
|
+
*
|
|
13
|
+
* The registry lives on `globalThis`, not in a module-level array, on purpose:
|
|
14
|
+
* `polly bdd` ships as two bundles (the CLI and the `@fairfox/polly/bdd`
|
|
15
|
+
* library export). `build-lib` runs with `splitting: false`, so this module is
|
|
16
|
+
* *inlined separately into each bundle* — a module-level array would give the
|
|
17
|
+
* runner and the consumer's `defineWorld` two different registries. A single
|
|
18
|
+
* global slot is the standard cross-bundle-singleton fix.
|
|
19
|
+
*/
|
|
20
|
+
import type { StepBinding, StepKeyword, WorldDef } from "./types.ts";
|
|
21
|
+
interface CompiledBinding {
|
|
22
|
+
binding: StepBinding;
|
|
23
|
+
regex: RegExp;
|
|
24
|
+
}
|
|
25
|
+
interface RegistryState {
|
|
26
|
+
bindings: CompiledBinding[];
|
|
27
|
+
worldDef: WorldDef | null;
|
|
28
|
+
}
|
|
29
|
+
declare global {
|
|
30
|
+
var __pollyBddRegistry__: RegistryState | undefined;
|
|
31
|
+
}
|
|
32
|
+
/** Translate a Cucumber-expression pattern into an anchored RegExp. */
|
|
33
|
+
export declare function compilePattern(pattern: string): RegExp;
|
|
34
|
+
export declare function defineStep(binding: StepBinding): void;
|
|
35
|
+
export declare function defineWorld(def: WorldDef): void;
|
|
36
|
+
export declare function getWorldDef(): WorldDef | null;
|
|
37
|
+
/** Drop all registrations — used between isolated runs/tests. */
|
|
38
|
+
export declare function resetRegistry(): void;
|
|
39
|
+
export interface StepMatch {
|
|
40
|
+
binding: StepBinding;
|
|
41
|
+
args: string[];
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Find the binding whose pattern matches `text`. Captured groups become the
|
|
45
|
+
* step arguments (the `{string}` alternation yields two groups per token, so
|
|
46
|
+
* we drop the undefined half).
|
|
47
|
+
*
|
|
48
|
+
* When `keyword` is given, a binding that *has* the callback for that keyword
|
|
49
|
+
* wins over one that merely matches the text — so the same phrase can serve as
|
|
50
|
+
* a `given` precondition in one scenario and a `then` assertion in another.
|
|
51
|
+
*/
|
|
52
|
+
export declare function matchStep(text: string, keyword?: StepKeyword): StepMatch | null;
|
|
53
|
+
/** Snapshot of all registered bindings (for the verify extractor). */
|
|
54
|
+
export declare function registeredBindings(): StepBinding[];
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for the polly BDD runner.
|
|
3
|
+
*
|
|
4
|
+
* The keystone is {@link StepBinding}: one declaration that speaks BOTH layers.
|
|
5
|
+
* Its `given`/`when`/`then` callbacks drive the *runtime* (the real factory bus
|
|
6
|
+
* + state signals); its `message`/`stateExpr` strings are statically extracted
|
|
7
|
+
* for the *verify* cross-link — exactly the dual-use trick `requires()`/
|
|
8
|
+
* `ensures()` already use (a runtime no-op whose argument text is read by the
|
|
9
|
+
* TLA+ codegen). See tools/bdd/README.md.
|
|
10
|
+
*/
|
|
11
|
+
/** The minimal slice of a polly MessageBus the runner drives. */
|
|
12
|
+
export interface BusLike {
|
|
13
|
+
send: (payload: {
|
|
14
|
+
type: string;
|
|
15
|
+
[k: string]: unknown;
|
|
16
|
+
}, options?: {
|
|
17
|
+
target?: string | string[];
|
|
18
|
+
tabId?: number;
|
|
19
|
+
timeout?: number;
|
|
20
|
+
}) => Promise<unknown>;
|
|
21
|
+
}
|
|
22
|
+
/** The minimal slice of a preact-style signal the runner reads/resets. */
|
|
23
|
+
export interface SignalLike<T = unknown> {
|
|
24
|
+
value: T;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Per-scenario execution context handed to every step callback. The bus and
|
|
28
|
+
* signals are the *real* ones the app uses; `vars` is scratch space for a
|
|
29
|
+
* scenario (e.g. an id captured in a `When` and asserted in a `Then`).
|
|
30
|
+
*/
|
|
31
|
+
export interface World {
|
|
32
|
+
bus: BusLike;
|
|
33
|
+
signals: Record<string, SignalLike>;
|
|
34
|
+
vars: Record<string, unknown>;
|
|
35
|
+
/** Response returned by the most recent `bus.send` in a `When`. */
|
|
36
|
+
lastResponse?: unknown;
|
|
37
|
+
/** Error thrown by the most recent `When`, if any (for rejection asserts). */
|
|
38
|
+
lastError?: unknown;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* A step callback: receives the world and the values captured from the text.
|
|
42
|
+
* The return value is captured as `world.lastResponse` when not undefined — so
|
|
43
|
+
* a `when` can simply `return world.bus.send(...)` and a later `then` asserts on
|
|
44
|
+
* the response. `given`/`then` typically return nothing.
|
|
45
|
+
*/
|
|
46
|
+
export type StepFn = (world: World, ...args: string[]) => unknown;
|
|
47
|
+
/**
|
|
48
|
+
* A step binding — declared once, read by two consumers.
|
|
49
|
+
*
|
|
50
|
+
* Runtime: the runner calls `given`/`when`/`then`.
|
|
51
|
+
* Formal: the verify extractor reads `message` (the message type a `when`
|
|
52
|
+
* sends) and `stateExpr` (the TS state expression a `given`/`then`
|
|
53
|
+
* establishes or asserts), turning the scenario into a trace it can
|
|
54
|
+
* check against the verification config and TLA+ model.
|
|
55
|
+
*/
|
|
56
|
+
export interface StepBinding {
|
|
57
|
+
/** Cucumber-expression pattern, e.g. `the user logs in as {string}`. */
|
|
58
|
+
pattern: string;
|
|
59
|
+
given?: StepFn;
|
|
60
|
+
when?: StepFn;
|
|
61
|
+
then?: StepFn;
|
|
62
|
+
/** Formal metadata: the message type a `when` step sends. */
|
|
63
|
+
message?: string;
|
|
64
|
+
/** Formal metadata: the TS state expression a `given`/`then` establishes/asserts. */
|
|
65
|
+
stateExpr?: string;
|
|
66
|
+
}
|
|
67
|
+
/** How the app builds and cold-resets its real-factory world. */
|
|
68
|
+
export interface WorldDef {
|
|
69
|
+
/** Build the world once per runner process (real factory + handlers). */
|
|
70
|
+
create: () => World | Promise<World>;
|
|
71
|
+
/** Cold-start reset run before every scenario (signals back to initial). */
|
|
72
|
+
reset: (world: World) => void | Promise<void>;
|
|
73
|
+
/**
|
|
74
|
+
* Optional cleanup run once after all scenarios, even on failure. Most worlds
|
|
75
|
+
* need nothing (in-process signals die with the runner), but a world that
|
|
76
|
+
* holds real resources — browsers, an embedded relay, sockets — releases them
|
|
77
|
+
* here so the runner exits cleanly instead of hanging on live handles.
|
|
78
|
+
*/
|
|
79
|
+
teardown?: (world: World) => void | Promise<void>;
|
|
80
|
+
}
|
|
81
|
+
export type StepKeyword = "given" | "when" | "then";
|
|
82
|
+
export interface ParsedStep {
|
|
83
|
+
/** Normalized keyword — And/But resolve to the previous concrete keyword. */
|
|
84
|
+
keyword: StepKeyword;
|
|
85
|
+
/** The raw keyword as written (Given/When/Then/And/But), for reporting. */
|
|
86
|
+
rawKeyword: string;
|
|
87
|
+
text: string;
|
|
88
|
+
line: number;
|
|
89
|
+
}
|
|
90
|
+
export interface ParsedScenario {
|
|
91
|
+
name: string;
|
|
92
|
+
tags: string[];
|
|
93
|
+
steps: ParsedStep[];
|
|
94
|
+
line: number;
|
|
95
|
+
/** True when expanded from a Scenario Outline row. */
|
|
96
|
+
fromOutline?: boolean;
|
|
97
|
+
}
|
|
98
|
+
export interface ParsedFeature {
|
|
99
|
+
name: string;
|
|
100
|
+
description: string;
|
|
101
|
+
tags: string[];
|
|
102
|
+
/** Background steps prepended to every scenario, already keyword-normalized. */
|
|
103
|
+
background: ParsedStep[];
|
|
104
|
+
scenarios: ParsedScenario[];
|
|
105
|
+
file: string;
|
|
106
|
+
}
|
|
107
|
+
export type StepOutcome = "pass" | "fail" | "undefined" | "skipped";
|
|
108
|
+
export interface StepResult {
|
|
109
|
+
text: string;
|
|
110
|
+
rawKeyword: string;
|
|
111
|
+
outcome: StepOutcome;
|
|
112
|
+
message?: string;
|
|
113
|
+
}
|
|
114
|
+
export type ScenarioOutcome = "pass" | "fail" | "undefined" | "deferred-formal";
|
|
115
|
+
export interface ScenarioResult {
|
|
116
|
+
feature: string;
|
|
117
|
+
scenario: string;
|
|
118
|
+
tags: string[];
|
|
119
|
+
outcome: ScenarioOutcome;
|
|
120
|
+
steps: StepResult[];
|
|
121
|
+
file: string;
|
|
122
|
+
}
|
|
123
|
+
export interface RunResult {
|
|
124
|
+
scenarios: ScenarioResult[];
|
|
125
|
+
passed: number;
|
|
126
|
+
failed: number;
|
|
127
|
+
undefinedSteps: number;
|
|
128
|
+
deferred: number;
|
|
129
|
+
ok: boolean;
|
|
130
|
+
}
|
|
131
|
+
/** A step matched to its binding's formal metadata. */
|
|
132
|
+
export interface TraceStep {
|
|
133
|
+
text: string;
|
|
134
|
+
keyword: StepKeyword;
|
|
135
|
+
message?: string;
|
|
136
|
+
stateExpr?: string;
|
|
137
|
+
/** True when the step text matched no registered binding. */
|
|
138
|
+
unbound?: boolean;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* A scenario reduced to the same vocabulary the verify config speaks:
|
|
142
|
+
* Given (initial state exprs) → When (message types) → Then (state exprs).
|
|
143
|
+
*/
|
|
144
|
+
export interface ScenarioTrace {
|
|
145
|
+
feature: string;
|
|
146
|
+
scenario: string;
|
|
147
|
+
tags: string[];
|
|
148
|
+
given: TraceStep[];
|
|
149
|
+
when: TraceStep[];
|
|
150
|
+
then: TraceStep[];
|
|
151
|
+
file: string;
|
|
152
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/** A scenario reduced to the post-state it claims, ready for the verify witness. */
|
|
2
|
+
export interface ScenarioWitness {
|
|
3
|
+
feature: string;
|
|
4
|
+
scenario: string;
|
|
5
|
+
tags: string[];
|
|
6
|
+
file: string;
|
|
7
|
+
/**
|
|
8
|
+
* The conjoined Then-predicate (TS, `signal.field` dialect, values
|
|
9
|
+
* substituted) — e.g. `user.loggedIn === true && user.role === "admin"`.
|
|
10
|
+
* `null` when the scenario has no state-observable outcome to witness.
|
|
11
|
+
*/
|
|
12
|
+
predicate: string | null;
|
|
13
|
+
/** Dotted state-field paths the predicate references (for subsystem routing). */
|
|
14
|
+
fields: string[];
|
|
15
|
+
/** Then-step texts skipped: a bare field ref, no binding, or a runtime-only check. */
|
|
16
|
+
skipped: string[];
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Reduce every scenario in the given feature files to a {@link ScenarioWitness}.
|
|
20
|
+
* Step modules are loaded for their `defineStep` side effects (the same import
|
|
21
|
+
* trick {@link extractTraces} uses) so `matchStep` can resolve each Then.
|
|
22
|
+
*/
|
|
23
|
+
export declare function extractWitnesses(featureFiles: string[], stepFiles: string[]): Promise<ScenarioWitness[]>;
|
|
@@ -842,9 +842,109 @@ async function checkNoAsCasting(options) {
|
|
|
842
842
|
}
|
|
843
843
|
return { violations, print: () => printViolations(violations) };
|
|
844
844
|
}
|
|
845
|
-
// tools/quality/src/no-
|
|
845
|
+
// tools/quality/src/no-fixed-waits.ts
|
|
846
846
|
import { readFileSync as readFileSync2 } from "node:fs";
|
|
847
847
|
import { Glob as Glob2 } from "bun";
|
|
848
|
+
var HINT = "wait on a real condition (poll loop, web-first assertion, microtask flush) or a sanctioned delay primitive";
|
|
849
|
+
var RULES = [
|
|
850
|
+
{ pattern: /\.waitForTimeout\s*\(/, message: `waitForTimeout is a fixed sleep — ${HINT}` },
|
|
851
|
+
{ pattern: /\bBun\.sleep\s*\(/, message: `Bun.sleep is a fixed sleep — ${HINT}` },
|
|
852
|
+
{
|
|
853
|
+
pattern: /\bsetTimeout\s*\(\s*(?:resolve|r|res|done)\s*,/,
|
|
854
|
+
message: `setTimeout resolving a promise is a fixed sleep — ${HINT}`
|
|
855
|
+
},
|
|
856
|
+
{
|
|
857
|
+
pattern: /\bsetTimeout\s*\(\s*\(\s*\)\s*=>\s*(?:resolve|r|res|done)\b/,
|
|
858
|
+
message: `setTimeout resolving a promise is a fixed sleep — ${HINT}`
|
|
859
|
+
},
|
|
860
|
+
{
|
|
861
|
+
pattern: /new\s+Promise\b.*\(\s*\(?\s*([A-Za-z_$][\w$]*)\s*\)?\s*=>.*\bsetTimeout\s*\(\s*\1\b/,
|
|
862
|
+
message: `Promise wrapping setTimeout is a fixed sleep — ${HINT}`
|
|
863
|
+
}
|
|
864
|
+
];
|
|
865
|
+
var DEFAULT_EXCLUDE = ["node_modules", "dist", ".git", ".bun", "build", "coverage"];
|
|
866
|
+
function skipString(text, i) {
|
|
867
|
+
const quote = text[i];
|
|
868
|
+
if (quote !== '"' && quote !== "'" && quote !== "`")
|
|
869
|
+
return i;
|
|
870
|
+
let j = i + 1;
|
|
871
|
+
while (j < text.length && text[j] !== quote) {
|
|
872
|
+
if (text[j] === "\\")
|
|
873
|
+
j++;
|
|
874
|
+
j++;
|
|
875
|
+
}
|
|
876
|
+
return j;
|
|
877
|
+
}
|
|
878
|
+
function codeOnly(line) {
|
|
879
|
+
let out = "";
|
|
880
|
+
for (let i = 0;i < line.length; i++) {
|
|
881
|
+
const ch = line[i];
|
|
882
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
883
|
+
const end = skipString(line, i);
|
|
884
|
+
out += " ".repeat(end - i + 1);
|
|
885
|
+
i = end;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (ch === "/" && line[i + 1] === "/")
|
|
889
|
+
break;
|
|
890
|
+
out += ch;
|
|
891
|
+
}
|
|
892
|
+
return out;
|
|
893
|
+
}
|
|
894
|
+
function scanText(content, filePath = "<text>") {
|
|
895
|
+
const violations = [];
|
|
896
|
+
const lines = content.split(`
|
|
897
|
+
`);
|
|
898
|
+
for (let i = 0;i < lines.length; i++) {
|
|
899
|
+
const line = lines[i] ?? "";
|
|
900
|
+
const trimmed = line.trim();
|
|
901
|
+
if (trimmed === "" || trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*")) {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const code = codeOnly(line);
|
|
905
|
+
const rule = RULES.find((r) => r.pattern.test(code));
|
|
906
|
+
if (rule)
|
|
907
|
+
violations.push({ file: filePath, line: i + 1, content: trimmed, message: rule.message });
|
|
908
|
+
}
|
|
909
|
+
return violations;
|
|
910
|
+
}
|
|
911
|
+
function isExcluded(relative3, excludeDirs, excludeFiles) {
|
|
912
|
+
if (relative3.split("/").some((s) => excludeDirs.has(s)))
|
|
913
|
+
return true;
|
|
914
|
+
const normalized = relative3.replace(/\\/g, "/");
|
|
915
|
+
return excludeFiles.some((suffix) => normalized.endsWith(suffix));
|
|
916
|
+
}
|
|
917
|
+
function printViolations2(violations) {
|
|
918
|
+
if (violations.length === 0) {
|
|
919
|
+
logger.log("[no-fixed-waits] ✅ No violations found.");
|
|
920
|
+
return;
|
|
921
|
+
}
|
|
922
|
+
logger.log(`[no-fixed-waits] ❌ ${violations.length} fixed-duration wait(s) found:
|
|
923
|
+
`);
|
|
924
|
+
for (const v of violations) {
|
|
925
|
+
logger.log(` ${v.file}:${v.line}`);
|
|
926
|
+
logger.log(` ${v.content}`);
|
|
927
|
+
logger.log(` \uD83D\uDCA1 ${v.message}`);
|
|
928
|
+
logger.log("");
|
|
929
|
+
}
|
|
930
|
+
}
|
|
931
|
+
async function checkNoFixedWaits(options) {
|
|
932
|
+
const { rootDir } = options;
|
|
933
|
+
const excludeDirs = new Set(options.exclude ?? DEFAULT_EXCLUDE);
|
|
934
|
+
const excludeFiles = [...options.excludeFiles ?? [], "no-fixed-waits.ts"];
|
|
935
|
+
const glob = new Glob2(options.filePatterns ?? "**/*.{ts,tsx}");
|
|
936
|
+
const violations = [];
|
|
937
|
+
for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
938
|
+
const relative3 = file.replace(`${rootDir}/`, "");
|
|
939
|
+
if (isExcluded(relative3, excludeDirs, excludeFiles))
|
|
940
|
+
continue;
|
|
941
|
+
violations.push(...scanText(readFileSync2(file, "utf-8"), relative3));
|
|
942
|
+
}
|
|
943
|
+
return { violations, print: () => printViolations2(violations) };
|
|
944
|
+
}
|
|
945
|
+
// tools/quality/src/no-require.ts
|
|
946
|
+
import { readFileSync as readFileSync3 } from "node:fs";
|
|
947
|
+
import { Glob as Glob3 } from "bun";
|
|
848
948
|
function isLineRequireClean(line) {
|
|
849
949
|
if (!line.includes("require")) {
|
|
850
950
|
return true;
|
|
@@ -912,7 +1012,7 @@ async function checkNoRequire(options) {
|
|
|
912
1012
|
const rootDir = options.rootDir;
|
|
913
1013
|
const excludeDirs = new Set(options.exclude ?? ["node_modules", "dist", ".git", ".bun"]);
|
|
914
1014
|
const pattern = options.filePatterns ?? "**/*.{ts,tsx}";
|
|
915
|
-
const glob = new
|
|
1015
|
+
const glob = new Glob3(pattern);
|
|
916
1016
|
const violations = [];
|
|
917
1017
|
for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
918
1018
|
const relative3 = file.replace(`${rootDir}/`, "");
|
|
@@ -920,7 +1020,7 @@ async function checkNoRequire(options) {
|
|
|
920
1020
|
if (segments.some((s) => excludeDirs.has(s))) {
|
|
921
1021
|
continue;
|
|
922
1022
|
}
|
|
923
|
-
const content =
|
|
1023
|
+
const content = readFileSync3(file, "utf-8");
|
|
924
1024
|
violations.push(...findRequireViolations(relative3, content));
|
|
925
1025
|
}
|
|
926
1026
|
return {
|
|
@@ -928,6 +1028,157 @@ async function checkNoRequire(options) {
|
|
|
928
1028
|
print: () => printRequireViolations(violations)
|
|
929
1029
|
};
|
|
930
1030
|
}
|
|
1031
|
+
// tools/quality/src/no-tautology-ensures.ts
|
|
1032
|
+
import { readFileSync as readFileSync4 } from "node:fs";
|
|
1033
|
+
import { Glob as Glob4 } from "bun";
|
|
1034
|
+
var DEFAULT_PRIMITIVES = ["ensures", "requires"];
|
|
1035
|
+
var DEFAULT_EXCLUDE2 = [
|
|
1036
|
+
"node_modules",
|
|
1037
|
+
"dist",
|
|
1038
|
+
".git",
|
|
1039
|
+
".bun",
|
|
1040
|
+
"coverage",
|
|
1041
|
+
"specs",
|
|
1042
|
+
"tests",
|
|
1043
|
+
"__tests__",
|
|
1044
|
+
"examples"
|
|
1045
|
+
];
|
|
1046
|
+
var LITERAL = /^(true|false|null|undefined|\d+(\.\d+)?|"[^"]*"|'[^']*'|`[^`]*`)$/;
|
|
1047
|
+
function tautologyReason(predicate) {
|
|
1048
|
+
const trimmed = predicate.trim();
|
|
1049
|
+
if (LITERAL.test(trimmed))
|
|
1050
|
+
return `literal '${trimmed}'`;
|
|
1051
|
+
const cmp = trimmed.match(/^(.+?)\s*(===|!==|==|!=)\s*(.+)$/);
|
|
1052
|
+
if (cmp) {
|
|
1053
|
+
const lhs = cmp[1]?.trim() ?? "";
|
|
1054
|
+
const rhs = cmp[3]?.trim() ?? "";
|
|
1055
|
+
if (LITERAL.test(lhs) && LITERAL.test(rhs))
|
|
1056
|
+
return `literal-vs-literal '${trimmed}'`;
|
|
1057
|
+
}
|
|
1058
|
+
return;
|
|
1059
|
+
}
|
|
1060
|
+
function skipString2(text, i) {
|
|
1061
|
+
const quote = text[i];
|
|
1062
|
+
if (quote !== '"' && quote !== "'" && quote !== "`")
|
|
1063
|
+
return i;
|
|
1064
|
+
let j = i + 1;
|
|
1065
|
+
while (j < text.length && text[j] !== quote) {
|
|
1066
|
+
if (text[j] === "\\")
|
|
1067
|
+
j++;
|
|
1068
|
+
j++;
|
|
1069
|
+
}
|
|
1070
|
+
return j;
|
|
1071
|
+
}
|
|
1072
|
+
function findClosingParen(text, openIdx) {
|
|
1073
|
+
let depth = 0;
|
|
1074
|
+
for (let i = openIdx;i < text.length; i++) {
|
|
1075
|
+
const ch = text[i];
|
|
1076
|
+
if (ch === "(") {
|
|
1077
|
+
depth++;
|
|
1078
|
+
} else if (ch === ")") {
|
|
1079
|
+
if (--depth === 0)
|
|
1080
|
+
return i;
|
|
1081
|
+
} else {
|
|
1082
|
+
i = skipString2(text, i);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
return -1;
|
|
1086
|
+
}
|
|
1087
|
+
function splitTopLevelArgs(args) {
|
|
1088
|
+
const parts = [];
|
|
1089
|
+
let depth = 0;
|
|
1090
|
+
let start = 0;
|
|
1091
|
+
for (let i = 0;i < args.length; i++) {
|
|
1092
|
+
const ch = args[i];
|
|
1093
|
+
if (ch === "(" || ch === "[" || ch === "{") {
|
|
1094
|
+
depth++;
|
|
1095
|
+
} else if (ch === ")" || ch === "]" || ch === "}") {
|
|
1096
|
+
depth--;
|
|
1097
|
+
} else if (ch === "," && depth === 0) {
|
|
1098
|
+
parts.push(args.slice(start, i));
|
|
1099
|
+
start = i + 1;
|
|
1100
|
+
} else {
|
|
1101
|
+
i = skipString2(args, i);
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
parts.push(args.slice(start));
|
|
1105
|
+
return parts.map((p) => p.trim());
|
|
1106
|
+
}
|
|
1107
|
+
function isFileExcluded2(relative3, excludeDirs, excludeFiles) {
|
|
1108
|
+
const segments = relative3.split("/");
|
|
1109
|
+
if (segments.some((s) => excludeDirs.has(s)))
|
|
1110
|
+
return true;
|
|
1111
|
+
const basename = segments[segments.length - 1] ?? "";
|
|
1112
|
+
return excludeFiles.has(basename) || excludeFiles.has(relative3);
|
|
1113
|
+
}
|
|
1114
|
+
function isNonCall(line, lineBefore) {
|
|
1115
|
+
const isImport = /\b(import|export)\b/.test(line) && line.includes("{") && !line.includes(";");
|
|
1116
|
+
const isFrom = /\b(import|export)\b.*\bfrom\b/.test(line);
|
|
1117
|
+
return isImport || isFrom || lineBefore.includes("//");
|
|
1118
|
+
}
|
|
1119
|
+
function findViolations2(relative3, content, callPattern) {
|
|
1120
|
+
const results = [];
|
|
1121
|
+
callPattern.lastIndex = 0;
|
|
1122
|
+
let match = callPattern.exec(content);
|
|
1123
|
+
while (match !== null) {
|
|
1124
|
+
const current = match;
|
|
1125
|
+
match = callPattern.exec(content);
|
|
1126
|
+
const openIdx = current.index + current[0].length - 1;
|
|
1127
|
+
const lineStart = content.lastIndexOf(`
|
|
1128
|
+
`, current.index) + 1;
|
|
1129
|
+
const lineEnd = content.indexOf(`
|
|
1130
|
+
`, current.index);
|
|
1131
|
+
const line = content.slice(lineStart, lineEnd === -1 ? content.length : lineEnd);
|
|
1132
|
+
if (isNonCall(line, content.slice(lineStart, current.index)))
|
|
1133
|
+
continue;
|
|
1134
|
+
const closeIdx = findClosingParen(content, openIdx);
|
|
1135
|
+
if (closeIdx === -1)
|
|
1136
|
+
continue;
|
|
1137
|
+
const predicate = splitTopLevelArgs(content.slice(openIdx + 1, closeIdx))[0];
|
|
1138
|
+
const reason = predicate ? tautologyReason(predicate) : undefined;
|
|
1139
|
+
if (reason === undefined)
|
|
1140
|
+
continue;
|
|
1141
|
+
results.push({
|
|
1142
|
+
file: relative3,
|
|
1143
|
+
line: content.slice(0, current.index).split(`
|
|
1144
|
+
`).length,
|
|
1145
|
+
content: line.trim(),
|
|
1146
|
+
reason: `${current[1] ?? "ensures"}(...) predicate is a ${reason}`
|
|
1147
|
+
});
|
|
1148
|
+
}
|
|
1149
|
+
return results;
|
|
1150
|
+
}
|
|
1151
|
+
function printViolations3(violations) {
|
|
1152
|
+
if (violations.length === 0) {
|
|
1153
|
+
logger.log("[no-tautology-ensures] ✅ No violations found.");
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
logger.log(`[no-tautology-ensures] ❌ ${violations.length} violation(s) found:
|
|
1157
|
+
`);
|
|
1158
|
+
for (const v of violations) {
|
|
1159
|
+
logger.log(` ${v.file}:${v.line}`);
|
|
1160
|
+
logger.log(` ${v.content}`);
|
|
1161
|
+
logger.log(` \uD83D\uDCA1 ${v.reason}`);
|
|
1162
|
+
logger.log("");
|
|
1163
|
+
}
|
|
1164
|
+
logger.log("[no-tautology-ensures] A verify predicate must reference state — a literal asserts nothing.");
|
|
1165
|
+
}
|
|
1166
|
+
async function checkNoTautologyEnsures(options) {
|
|
1167
|
+
const { rootDir } = options;
|
|
1168
|
+
const excludeDirs = new Set(options.exclude ?? DEFAULT_EXCLUDE2);
|
|
1169
|
+
const excludeFiles = new Set([...options.excludeFiles ?? [], "no-tautology-ensures.ts"]);
|
|
1170
|
+
const primitives = options.primitives ?? DEFAULT_PRIMITIVES;
|
|
1171
|
+
const callPattern = new RegExp(`\\b(${primitives.join("|")})\\s*\\(`, "g");
|
|
1172
|
+
const glob = new Glob4(options.filePatterns ?? "**/*.{ts,tsx}");
|
|
1173
|
+
const violations = [];
|
|
1174
|
+
for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
1175
|
+
const relative3 = file.replace(`${rootDir}/`, "");
|
|
1176
|
+
if (isFileExcluded2(relative3, excludeDirs, excludeFiles))
|
|
1177
|
+
continue;
|
|
1178
|
+
violations.push(...findViolations2(relative3, readFileSync4(file, "utf-8"), callPattern));
|
|
1179
|
+
}
|
|
1180
|
+
return { violations, print: () => printViolations3(violations) };
|
|
1181
|
+
}
|
|
931
1182
|
// tools/quality/src/secrets.ts
|
|
932
1183
|
import { readFile } from "node:fs/promises";
|
|
933
1184
|
import { join as join3 } from "node:path";
|
|
@@ -1463,7 +1714,7 @@ import { join as join11 } from "node:path";
|
|
|
1463
1714
|
|
|
1464
1715
|
// tools/quality/src/plugins/core.ts
|
|
1465
1716
|
import { join as join10 } from "node:path";
|
|
1466
|
-
import { Glob as
|
|
1717
|
+
import { Glob as Glob7 } from "bun";
|
|
1467
1718
|
|
|
1468
1719
|
// tools/quality/src/plugins/cliche-checks.ts
|
|
1469
1720
|
import { readdir as readdir3 } from "node:fs/promises";
|
|
@@ -1886,7 +2137,7 @@ var additionalCoreChecks = [
|
|
|
1886
2137
|
// tools/quality/src/plugins/extra-checks.ts
|
|
1887
2138
|
import { readdir as readdir5 } from "node:fs/promises";
|
|
1888
2139
|
import { join as join8, relative as relative5 } from "node:path";
|
|
1889
|
-
import { Glob as
|
|
2140
|
+
import { Glob as Glob5 } from "bun";
|
|
1890
2141
|
var SKIP_DIRS_DEFAULT2 = ["node_modules", ".git", "dist", ".bun", ".cache"];
|
|
1891
2142
|
function isCommentLine2(trimmed) {
|
|
1892
2143
|
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
@@ -2024,7 +2275,7 @@ function buildHookRegex(banned) {
|
|
|
2024
2275
|
}
|
|
2025
2276
|
function isAllowedByGlob(rel, allowedFiles) {
|
|
2026
2277
|
for (const pattern of allowedFiles) {
|
|
2027
|
-
if (new
|
|
2278
|
+
if (new Glob5(pattern).match(rel))
|
|
2028
2279
|
return true;
|
|
2029
2280
|
}
|
|
2030
2281
|
return false;
|
|
@@ -2091,7 +2342,7 @@ var DEFAULT_TYPOGRAPHIC = {
|
|
|
2091
2342
|
};
|
|
2092
2343
|
function fileMatchesAnyGlob(rel, globs) {
|
|
2093
2344
|
for (const g of globs) {
|
|
2094
|
-
if (new
|
|
2345
|
+
if (new Glob5(g).match(rel))
|
|
2095
2346
|
return true;
|
|
2096
2347
|
}
|
|
2097
2348
|
return false;
|
|
@@ -2159,7 +2410,7 @@ var extraCoreChecks = [
|
|
|
2159
2410
|
// tools/quality/src/plugins/import-checks.ts
|
|
2160
2411
|
import { readdir as readdir6 } from "node:fs/promises";
|
|
2161
2412
|
import { join as join9, relative as relative6 } from "node:path";
|
|
2162
|
-
import { Glob as
|
|
2413
|
+
import { Glob as Glob6 } from "bun";
|
|
2163
2414
|
var SKIP_DIRS_DEFAULT3 = ["node_modules", ".git", "dist", ".bun", ".cache"];
|
|
2164
2415
|
function isCommentLine3(trimmed) {
|
|
2165
2416
|
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
@@ -2186,7 +2437,7 @@ async function walkScannableFiles2(dir, skipDirs, out) {
|
|
|
2186
2437
|
}
|
|
2187
2438
|
function isAllowedByGlob2(rel, globs) {
|
|
2188
2439
|
for (const g of globs) {
|
|
2189
|
-
if (new
|
|
2440
|
+
if (new Glob6(g).match(rel))
|
|
2190
2441
|
return true;
|
|
2191
2442
|
}
|
|
2192
2443
|
return false;
|
|
@@ -2414,7 +2665,7 @@ async function runTscFor(packagePath, rootDir) {
|
|
|
2414
2665
|
async function findWorkspaces(rootDir, patterns) {
|
|
2415
2666
|
const out = [];
|
|
2416
2667
|
for (const pattern of patterns) {
|
|
2417
|
-
const glob = new
|
|
2668
|
+
const glob = new Glob6(`${pattern}/tsconfig.json`);
|
|
2418
2669
|
for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
2419
2670
|
out.push(file.replace(/\/tsconfig\.json$/, ""));
|
|
2420
2671
|
}
|
|
@@ -2472,7 +2723,7 @@ async function scanFiles(rootDir, cfg) {
|
|
|
2472
2723
|
const excludePackages = new Set(cfg.excludePackages ?? []);
|
|
2473
2724
|
const excludeFiles = new Set(cfg.excludeFiles ?? []);
|
|
2474
2725
|
const pattern = cfg.filePatterns ?? "**/*.{ts,tsx}";
|
|
2475
|
-
const glob = new
|
|
2726
|
+
const glob = new Glob7(pattern);
|
|
2476
2727
|
const out = [];
|
|
2477
2728
|
for await (const file of glob.scan({ cwd: rootDir, absolute: true })) {
|
|
2478
2729
|
const rel = file.replace(`${rootDir}/`, "");
|
|
@@ -2527,6 +2778,42 @@ var noRequire = {
|
|
|
2527
2778
|
};
|
|
2528
2779
|
}
|
|
2529
2780
|
};
|
|
2781
|
+
var noTautologyEnsures = {
|
|
2782
|
+
id: "polly:no-tautology-ensures",
|
|
2783
|
+
description: "Ban ensures/requires whose predicate is a literal (asserts nothing)",
|
|
2784
|
+
filesRead: (cfg, root) => scanFiles(root, resolveScanConfig(cfg)),
|
|
2785
|
+
run: async ({ rootDir, config }) => {
|
|
2786
|
+
const cfg = resolveScanConfig(config);
|
|
2787
|
+
const result = await checkNoTautologyEnsures({
|
|
2788
|
+
rootDir,
|
|
2789
|
+
...cfg.exclude ? { exclude: cfg.exclude } : {},
|
|
2790
|
+
...cfg.excludeFiles ? { excludeFiles: cfg.excludeFiles } : {},
|
|
2791
|
+
...cfg.filePatterns ? { filePatterns: cfg.filePatterns } : {}
|
|
2792
|
+
});
|
|
2793
|
+
return {
|
|
2794
|
+
ok: result.violations.length === 0,
|
|
2795
|
+
messages: result.violations.map((v) => `${v.file}:${v.line}: ${v.content} — ${v.reason}`)
|
|
2796
|
+
};
|
|
2797
|
+
}
|
|
2798
|
+
};
|
|
2799
|
+
var noFixedWaits = {
|
|
2800
|
+
id: "polly:no-fixed-waits",
|
|
2801
|
+
description: "Ban fixed-duration sleeps (waitForTimeout / Bun.sleep / promise-wrapped setTimeout)",
|
|
2802
|
+
filesRead: (cfg, root) => scanFiles(root, resolveScanConfig(cfg)),
|
|
2803
|
+
run: async ({ rootDir, config }) => {
|
|
2804
|
+
const cfg = resolveScanConfig(config);
|
|
2805
|
+
const result = await checkNoFixedWaits({
|
|
2806
|
+
rootDir,
|
|
2807
|
+
exclude: cfg.exclude ?? DEFAULT_EXCLUDES,
|
|
2808
|
+
...cfg.excludeFiles ? { excludeFiles: cfg.excludeFiles } : {},
|
|
2809
|
+
...cfg.filePatterns ? { filePatterns: cfg.filePatterns } : {}
|
|
2810
|
+
});
|
|
2811
|
+
return {
|
|
2812
|
+
ok: result.violations.length === 0,
|
|
2813
|
+
messages: result.violations.map((v) => `${v.file}:${v.line}: ${v.content} — ${v.message}`)
|
|
2814
|
+
};
|
|
2815
|
+
}
|
|
2816
|
+
};
|
|
2530
2817
|
var secrets = {
|
|
2531
2818
|
id: "polly:secrets",
|
|
2532
2819
|
description: "Run `gitleaks detect` against the working tree",
|
|
@@ -2588,13 +2875,15 @@ var gitignoreCrossCheck = {
|
|
|
2588
2875
|
};
|
|
2589
2876
|
}
|
|
2590
2877
|
};
|
|
2591
|
-
var POLLY_CORE_VERSION = "0.
|
|
2878
|
+
var POLLY_CORE_VERSION = "0.49.0";
|
|
2592
2879
|
var pollyCorePlugin = {
|
|
2593
2880
|
name: "polly",
|
|
2594
2881
|
version: POLLY_CORE_VERSION,
|
|
2595
2882
|
checks: [
|
|
2596
2883
|
noAsCasting,
|
|
2597
2884
|
noRequire,
|
|
2885
|
+
noTautologyEnsures,
|
|
2886
|
+
noFixedWaits,
|
|
2598
2887
|
secrets,
|
|
2599
2888
|
gitignoreCrossCheck,
|
|
2600
2889
|
...additionalCoreChecks,
|
|
@@ -2625,14 +2914,14 @@ async function loadQualityConfig(rootDir, explicitPath) {
|
|
|
2625
2914
|
// tools/quality/src/plugins/polly-ui.ts
|
|
2626
2915
|
import { readdir as readdir7 } from "node:fs/promises";
|
|
2627
2916
|
import { join as join12, relative as relative7 } from "node:path";
|
|
2628
|
-
import { Glob as
|
|
2917
|
+
import { Glob as Glob8 } from "bun";
|
|
2629
2918
|
var SKIP_DIRS_DEFAULT4 = ["node_modules", ".git", "dist", ".bun", ".cache"];
|
|
2630
2919
|
function isCommentLine4(trimmed) {
|
|
2631
2920
|
return trimmed.startsWith("//") || trimmed.startsWith("*") || trimmed.startsWith("/*");
|
|
2632
2921
|
}
|
|
2633
2922
|
function isAllowedByGlob3(rel, globs) {
|
|
2634
2923
|
for (const g of globs) {
|
|
2635
|
-
if (new
|
|
2924
|
+
if (new Glob8(g).match(rel))
|
|
2636
2925
|
return true;
|
|
2637
2926
|
}
|
|
2638
2927
|
return false;
|
|
@@ -3050,4 +3339,4 @@ switch (subcommand) {
|
|
|
3050
3339
|
}
|
|
3051
3340
|
process.exit(exitCode);
|
|
3052
3341
|
|
|
3053
|
-
//# debugId=
|
|
3342
|
+
//# debugId=6610E9614AC8E6B464756E2164756E21
|