tempest-react-sdk 0.31.1 → 0.32.1
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/README.md +2 -0
- package/bin/lib/design/collect.mjs +103 -0
- package/bin/lib/design/findings.mjs +70 -0
- package/bin/lib/design/functions.mjs +188 -0
- package/bin/lib/design/functions.test.mjs +127 -0
- package/bin/lib/design/index.mjs +80 -0
- package/bin/lib/design/index.test.mjs +129 -0
- package/bin/lib/design/markers.mjs +76 -0
- package/bin/lib/design/markers.test.mjs +60 -0
- package/bin/lib/design/mask.mjs +209 -0
- package/bin/lib/design/mask.test.mjs +69 -0
- package/bin/lib/design/scan.mjs +229 -0
- package/bin/lib/design/scan.test.mjs +218 -0
- package/bin/lib/doctor/doctor.e2e.test.mjs +79 -2
- package/bin/tempest.mjs +83 -3
- package/dist/imaging/canvas.cjs +2 -0
- package/dist/imaging/canvas.cjs.map +1 -0
- package/dist/imaging/canvas.js +23 -0
- package/dist/imaging/canvas.js.map +1 -0
- package/dist/imaging/compress.cjs +2 -0
- package/dist/imaging/compress.cjs.map +1 -0
- package/dist/imaging/compress.js +43 -0
- package/dist/imaging/compress.js.map +1 -0
- package/dist/imaging/decode.cjs +2 -0
- package/dist/imaging/decode.cjs.map +1 -0
- package/dist/imaging/decode.js +42 -0
- package/dist/imaging/decode.js.map +1 -0
- package/dist/imaging/encode.cjs +2 -0
- package/dist/imaging/encode.cjs.map +1 -0
- package/dist/imaging/encode.js +48 -0
- package/dist/imaging/encode.js.map +1 -0
- package/dist/imaging/exceptions.cjs +2 -0
- package/dist/imaging/exceptions.cjs.map +1 -0
- package/dist/imaging/exceptions.js +26 -0
- package/dist/imaging/exceptions.js.map +1 -0
- package/dist/imaging/thumbnails.cjs +2 -0
- package/dist/imaging/thumbnails.cjs.map +1 -0
- package/dist/imaging/thumbnails.js +34 -0
- package/dist/imaging/thumbnails.js.map +1 -0
- package/dist/imaging/transform.cjs +2 -0
- package/dist/imaging/transform.cjs.map +1 -0
- package/dist/imaging/transform.js +97 -0
- package/dist/imaging/transform.js.map +1 -0
- package/dist/imaging/use-image-processing.cjs +2 -0
- package/dist/imaging/use-image-processing.cjs.map +1 -0
- package/dist/imaging/use-image-processing.js +45 -0
- package/dist/imaging/use-image-processing.js.map +1 -0
- package/dist/imaging.cjs +1 -0
- package/dist/imaging.d.ts +541 -0
- package/dist/imaging.js +9 -0
- package/dist/tabular/assets.cjs +2 -0
- package/dist/tabular/assets.cjs.map +1 -0
- package/dist/tabular/assets.js +19 -0
- package/dist/tabular/assets.js.map +1 -0
- package/dist/tabular/cache.cjs +2 -0
- package/dist/tabular/cache.cjs.map +1 -0
- package/dist/tabular/cache.js +48 -0
- package/dist/tabular/cache.js.map +1 -0
- package/dist/tabular/exceptions.cjs +2 -0
- package/dist/tabular/exceptions.cjs.map +1 -0
- package/dist/tabular/exceptions.js +30 -0
- package/dist/tabular/exceptions.js.map +1 -0
- package/dist/tabular/manifest.cjs +2 -0
- package/dist/tabular/manifest.cjs.map +1 -0
- package/dist/tabular/manifest.js +42 -0
- package/dist/tabular/manifest.js.map +1 -0
- package/dist/tabular/predictor.cjs +2 -0
- package/dist/tabular/predictor.cjs.map +1 -0
- package/dist/tabular/predictor.js +111 -0
- package/dist/tabular/predictor.js.map +1 -0
- package/dist/tabular/use-tabular-predictor.cjs +2 -0
- package/dist/tabular/use-tabular-predictor.cjs.map +1 -0
- package/dist/tabular/use-tabular-predictor.js +51 -0
- package/dist/tabular/use-tabular-predictor.js.map +1 -0
- package/dist/tabular.cjs +1 -0
- package/dist/tabular.d.ts +509 -0
- package/dist/tabular.js +7 -0
- package/package.json +11 -1
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
|
|
6
|
+
import { analyzeDesign } from "./index.mjs";
|
|
7
|
+
|
|
8
|
+
let root;
|
|
9
|
+
|
|
10
|
+
function write(relative, contents) {
|
|
11
|
+
const full = join(root, relative);
|
|
12
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
13
|
+
writeFileSync(full, contents);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
root = mkdtempSync(join(tmpdir(), "tempest-design-"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(root, { recursive: true, force: true });
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
describe("analyzeDesign", () => {
|
|
25
|
+
it("reports nothing for a well-shaped project", () => {
|
|
26
|
+
write(
|
|
27
|
+
"src/features/orders/OrderRow.tsx",
|
|
28
|
+
[
|
|
29
|
+
"interface OrderRowProps {",
|
|
30
|
+
" code: string;",
|
|
31
|
+
"}",
|
|
32
|
+
"",
|
|
33
|
+
"/** One row. */",
|
|
34
|
+
"export function OrderRow({ code }: OrderRowProps) {",
|
|
35
|
+
" return <span>{code}</span>;",
|
|
36
|
+
"}",
|
|
37
|
+
].join("\n"),
|
|
38
|
+
);
|
|
39
|
+
const result = analyzeDesign({ root });
|
|
40
|
+
expect(result.findings).toEqual([]);
|
|
41
|
+
expect(result.stats.files).toBe(1);
|
|
42
|
+
expect(result.counts.warn).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports findings with project-relative paths", () => {
|
|
46
|
+
write(
|
|
47
|
+
"src/pages/List.tsx",
|
|
48
|
+
"export function List() {\n fetch('/api');\n return null;\n}",
|
|
49
|
+
);
|
|
50
|
+
const result = analyzeDesign({ root });
|
|
51
|
+
expect(result.findings).toHaveLength(1);
|
|
52
|
+
expect(result.findings[0].file).toBe(join("src", "pages", "List.tsx"));
|
|
53
|
+
expect(result.findings[0].code).toBe("fetch-in-component");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("walks app/ as well as src/", () => {
|
|
57
|
+
write("app/Widget.tsx", "export function Widget() {\n fetch('/x');\n return null;\n}");
|
|
58
|
+
expect(analyzeDesign({ root }).findings.map((f) => f.code)).toEqual(["fetch-in-component"]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("ignores declaration files and generated code", () => {
|
|
62
|
+
write("src/types.d.ts", "export type Any = any;");
|
|
63
|
+
write("src/icons.generated.ts", "export const icons = { a: 1 as any };");
|
|
64
|
+
write("src/generated/api.ts", "export const client = {} as any;");
|
|
65
|
+
const result = analyzeDesign({ root });
|
|
66
|
+
expect(result.stats.files).toBe(0);
|
|
67
|
+
expect(result.findings).toEqual([]);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("reports statistics over the files it read", () => {
|
|
71
|
+
write("src/a.ts", "export const a = 1;\nexport const b = 2;");
|
|
72
|
+
write("src/b.ts", "export const c = 3;");
|
|
73
|
+
const result = analyzeDesign({ root });
|
|
74
|
+
expect(result.stats.files).toBe(2);
|
|
75
|
+
expect(result.stats.codeLines).toBe(3);
|
|
76
|
+
expect(result.stats.largest.lines).toBe(2);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("collects waivers with their reasons", () => {
|
|
80
|
+
write(
|
|
81
|
+
"src/Crop.tsx",
|
|
82
|
+
[
|
|
83
|
+
"/**",
|
|
84
|
+
" * Cropper.",
|
|
85
|
+
" *",
|
|
86
|
+
" * @tempest-limits file-lines — drag, zoom and canvas export share one",
|
|
87
|
+
" * piece of geometry state.",
|
|
88
|
+
" */",
|
|
89
|
+
"export function Crop() {",
|
|
90
|
+
...Array.from({ length: 200 }, (_, i) => ` const v${i} = ${i};`),
|
|
91
|
+
" return null;",
|
|
92
|
+
"}",
|
|
93
|
+
].join("\n"),
|
|
94
|
+
);
|
|
95
|
+
const result = analyzeDesign({ root });
|
|
96
|
+
expect(result.findings.map((f) => f.code)).not.toContain("file-lines");
|
|
97
|
+
expect(result.waivers).toEqual([
|
|
98
|
+
{
|
|
99
|
+
file: "src/Crop.tsx",
|
|
100
|
+
code: "file-lines",
|
|
101
|
+
reason: expect.stringContaining("geometry state"),
|
|
102
|
+
},
|
|
103
|
+
]);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("sorts warnings before info", () => {
|
|
107
|
+
write("src/a.tsx", "export function A() {\n fetch('/x');\n return null;\n}");
|
|
108
|
+
write("src/a.test.ts", "const mock = {} as any;");
|
|
109
|
+
const result = analyzeDesign({ root });
|
|
110
|
+
expect(result.findings.map((f) => f.severity)).toEqual(["warn", "info"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("flags truncation when the file cap is reached", () => {
|
|
114
|
+
for (let i = 0; i < 5; i += 1) write(`src/f${i}.ts`, "export const v = 1;");
|
|
115
|
+
const result = analyzeDesign({ root, maxFiles: 2 });
|
|
116
|
+
expect(result.stats.files).toBe(2);
|
|
117
|
+
expect(result.truncated).toBe(true);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("returns empty stats when there is nothing to read", () => {
|
|
121
|
+
const result = analyzeDesign({ root });
|
|
122
|
+
expect(result.stats).toEqual({
|
|
123
|
+
files: 0,
|
|
124
|
+
codeLines: 0,
|
|
125
|
+
medianLines: 0,
|
|
126
|
+
largest: null,
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// The escape hatch. A limit you can never exceed stops being a default and
|
|
2
|
+
// becomes a reason to silence the tool, so exceeding one is allowed — out loud.
|
|
3
|
+
//
|
|
4
|
+
// /**
|
|
5
|
+
// * Interactive image cropper.
|
|
6
|
+
// *
|
|
7
|
+
// * @tempest-limits file-lines — pointer drag, wheel zoom, aspect clamping and
|
|
8
|
+
// * canvas export share one piece of geometry state.
|
|
9
|
+
// */
|
|
10
|
+
//
|
|
11
|
+
// The rule id (or `*`) says what is waived; the text after it says why. A marker
|
|
12
|
+
// with no reason is itself reported: an unexplained waiver is the thing this is
|
|
13
|
+
// meant to prevent.
|
|
14
|
+
|
|
15
|
+
/** Minimum characters of prose that count as an actual reason. */
|
|
16
|
+
const MIN_REASON = 12;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Parse every `@tempest-limits` marker out of a file's comment text.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} commentText - Comment text from `maskSource`.
|
|
22
|
+
* @returns {{ waived: Set<string>, reasons: Map<string, string>, unexplained: string[] }}
|
|
23
|
+
* `waived` holds the rule ids (possibly `*`); `reasons` maps id → reason;
|
|
24
|
+
* `unexplained` lists ids whose marker carried no usable reason.
|
|
25
|
+
*/
|
|
26
|
+
export function parseMarkers(commentText) {
|
|
27
|
+
const waived = new Set();
|
|
28
|
+
const reasons = new Map();
|
|
29
|
+
const unexplained = [];
|
|
30
|
+
|
|
31
|
+
const re = /@tempest-limits\s+([\w-]+(?:\s*,\s*[\w-]+)*|\*)([^\n]*(?:\n\s*\*(?!\/)[^\n]*)*)/g;
|
|
32
|
+
let match;
|
|
33
|
+
while ((match = re.exec(commentText))) {
|
|
34
|
+
const ids = match[1]
|
|
35
|
+
.split(",")
|
|
36
|
+
.map((id) => id.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
const reason = cleanReason(match[2] ?? "");
|
|
39
|
+
for (const id of ids) {
|
|
40
|
+
waived.add(id);
|
|
41
|
+
if (reason.length >= MIN_REASON) reasons.set(id, reason);
|
|
42
|
+
else unexplained.push(id);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return { waived, reasons, unexplained };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Strip the JSDoc furniture from the text trailing a marker: leading dashes or
|
|
51
|
+
* colon, and the `*` that opens each continuation line.
|
|
52
|
+
*
|
|
53
|
+
* @param {string} raw
|
|
54
|
+
* @returns {string}
|
|
55
|
+
*/
|
|
56
|
+
function cleanReason(raw) {
|
|
57
|
+
return raw
|
|
58
|
+
.split("\n")
|
|
59
|
+
.map((line) => line.replace(/^\s*\*\s?/, ""))
|
|
60
|
+
.join(" ")
|
|
61
|
+
.replace(/^\s*[—–\-:]+\s*/, "")
|
|
62
|
+
.replace(/\*\/\s*$/, "")
|
|
63
|
+
.replace(/\s+/g, " ")
|
|
64
|
+
.trim();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether a rule is waived for a file.
|
|
69
|
+
*
|
|
70
|
+
* @param {{ waived: Set<string> }} markers
|
|
71
|
+
* @param {string} code
|
|
72
|
+
* @returns {boolean}
|
|
73
|
+
*/
|
|
74
|
+
export function isWaived(markers, code) {
|
|
75
|
+
return markers.waived.has("*") || markers.waived.has(code);
|
|
76
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { maskSource } from "./mask.mjs";
|
|
4
|
+
import { isWaived, parseMarkers } from "./markers.mjs";
|
|
5
|
+
|
|
6
|
+
const markersOf = (source) => parseMarkers(maskSource(source).commentText);
|
|
7
|
+
|
|
8
|
+
describe("parseMarkers", () => {
|
|
9
|
+
it("waives one rule and keeps its reason", () => {
|
|
10
|
+
const markers = markersOf(
|
|
11
|
+
[
|
|
12
|
+
"/**",
|
|
13
|
+
" * Interactive image cropper.",
|
|
14
|
+
" *",
|
|
15
|
+
" * @tempest-limits file-lines — drag, zoom and canvas export share one",
|
|
16
|
+
" * piece of geometry state.",
|
|
17
|
+
" */",
|
|
18
|
+
].join("\n"),
|
|
19
|
+
);
|
|
20
|
+
expect(isWaived(markers, "file-lines")).toBe(true);
|
|
21
|
+
expect(markers.reasons.get("file-lines")).toContain("geometry state");
|
|
22
|
+
expect(markers.unexplained).toEqual([]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("waives several rules from one marker", () => {
|
|
26
|
+
const markers = markersOf(
|
|
27
|
+
"// @tempest-limits file-lines, function-lines: the state machine is one unit",
|
|
28
|
+
);
|
|
29
|
+
expect(isWaived(markers, "file-lines")).toBe(true);
|
|
30
|
+
expect(isWaived(markers, "function-lines")).toBe(true);
|
|
31
|
+
expect(isWaived(markers, "props-count")).toBe(false);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("waives everything with a star", () => {
|
|
35
|
+
const markers = markersOf("// @tempest-limits * — vendored file, regenerated by a script");
|
|
36
|
+
expect(isWaived(markers, "anything-at-all")).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("reports a marker with no reason", () => {
|
|
40
|
+
const markers = markersOf("// @tempest-limits file-lines");
|
|
41
|
+
expect(markers.unexplained).toEqual(["file-lines"]);
|
|
42
|
+
expect(markers.reasons.size).toBe(0);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("reports a marker whose reason is too short to be one", () => {
|
|
46
|
+
const markers = markersOf("// @tempest-limits props-count — big");
|
|
47
|
+
expect(markers.unexplained).toEqual(["props-count"]);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("finds nothing in a file without markers", () => {
|
|
51
|
+
const markers = markersOf("/** Just a component. */");
|
|
52
|
+
expect(markers.waived.size).toBe(0);
|
|
53
|
+
expect(markers.unexplained).toEqual([]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("ignores a marker written inside a string, not a comment", () => {
|
|
57
|
+
const markers = markersOf('const help = "@tempest-limits file-lines — not a comment";');
|
|
58
|
+
expect(markers.waived.size).toBe(0);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// Blank out comments, string literals and regex bodies while keeping every byte
|
|
2
|
+
// offset and newline in place. Every other rule in this folder runs on the mask
|
|
3
|
+
// instead of the raw source, which is what stops the two loudest classes of
|
|
4
|
+
// false positive: a brace inside a string ("}") breaking the body scan, and the
|
|
5
|
+
// word `any` inside a doc comment being reported as a type.
|
|
6
|
+
//
|
|
7
|
+
// A `/` starts a regex only when the previous meaningful character is one that
|
|
8
|
+
// cannot end an expression. That is the standard heuristic; it keeps JSX
|
|
9
|
+
// (`</div>`) and division (`a / b`) out of regex state.
|
|
10
|
+
|
|
11
|
+
const REGEX_PRECEDERS = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", "}", ";", "+"]);
|
|
12
|
+
|
|
13
|
+
/** Last non-whitespace character before `index`, or "" at the start of input. */
|
|
14
|
+
function previousMeaningful(source, index) {
|
|
15
|
+
for (let i = index - 1; i >= 0; i -= 1) {
|
|
16
|
+
if (!/\s/.test(source[i])) return source[i];
|
|
17
|
+
}
|
|
18
|
+
return "";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Replace comment bodies, string contents and regex bodies with spaces.
|
|
23
|
+
*
|
|
24
|
+
* Newlines are preserved everywhere, including inside block comments and
|
|
25
|
+
* template literals, so a line number computed on the mask is the line number in
|
|
26
|
+
* the original file. Template-literal `${…}` holes stay as code, since that is
|
|
27
|
+
* where real expressions live.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} source - Raw file contents.
|
|
30
|
+
* @returns {{ masked: string, commentText: string }} The mask, plus the comment
|
|
31
|
+
* text that was removed (some rules — `@ts-ignore`, the limits marker — only
|
|
32
|
+
* exist in comments).
|
|
33
|
+
*/
|
|
34
|
+
export function maskSource(source) {
|
|
35
|
+
const out = new Array(source.length);
|
|
36
|
+
let comments = "";
|
|
37
|
+
let i = 0;
|
|
38
|
+
/** Depth of `${` holes per nested template literal. */
|
|
39
|
+
const templates = [];
|
|
40
|
+
|
|
41
|
+
const blank = (from, to) => {
|
|
42
|
+
for (let k = from; k < to; k += 1) out[k] = source[k] === "\n" ? "\n" : " ";
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
while (i < source.length) {
|
|
46
|
+
const ch = source[i];
|
|
47
|
+
const next = source[i + 1];
|
|
48
|
+
|
|
49
|
+
if (ch === "/" && next === "/") {
|
|
50
|
+
const end = source.indexOf("\n", i);
|
|
51
|
+
const stop = end === -1 ? source.length : end;
|
|
52
|
+
comments += `${source.slice(i, stop)}\n`;
|
|
53
|
+
blank(i, stop);
|
|
54
|
+
i = stop;
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (ch === "/" && next === "*") {
|
|
59
|
+
const end = source.indexOf("*/", i + 2);
|
|
60
|
+
const stop = end === -1 ? source.length : end + 2;
|
|
61
|
+
comments += `${source.slice(i, stop)}\n`;
|
|
62
|
+
blank(i, stop);
|
|
63
|
+
i = stop;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (ch === '"' || ch === "'") {
|
|
68
|
+
let k = i + 1;
|
|
69
|
+
while (k < source.length && source[k] !== ch) {
|
|
70
|
+
if (source[k] === "\\") k += 1;
|
|
71
|
+
if (source[k] === "\n") break;
|
|
72
|
+
k += 1;
|
|
73
|
+
}
|
|
74
|
+
out[i] = ch;
|
|
75
|
+
blank(i + 1, k);
|
|
76
|
+
out[k] = source[k] === ch ? ch : source[k] === "\n" ? "\n" : " ";
|
|
77
|
+
i = k + 1;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (ch === "`") {
|
|
82
|
+
out[i] = "`";
|
|
83
|
+
i += 1;
|
|
84
|
+
let start = i;
|
|
85
|
+
while (i < source.length) {
|
|
86
|
+
if (source[i] === "\\") {
|
|
87
|
+
i += 2;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (source[i] === "$" && source[i + 1] === "{") {
|
|
91
|
+
blank(start, i);
|
|
92
|
+
out[i] = " ";
|
|
93
|
+
out[i + 1] = "{";
|
|
94
|
+
templates.push(1);
|
|
95
|
+
i += 2;
|
|
96
|
+
let depth = 1;
|
|
97
|
+
while (i < source.length && depth > 0) {
|
|
98
|
+
const inner = maskHole(source, i, out);
|
|
99
|
+
depth += inner.delta;
|
|
100
|
+
i = inner.next;
|
|
101
|
+
if (depth === 0) break;
|
|
102
|
+
}
|
|
103
|
+
templates.pop();
|
|
104
|
+
start = i;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (source[i] === "`") break;
|
|
108
|
+
i += 1;
|
|
109
|
+
}
|
|
110
|
+
blank(start, i);
|
|
111
|
+
if (i < source.length) out[i] = "`";
|
|
112
|
+
i += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (ch === "/" && REGEX_PRECEDERS.has(previousMeaningful(source, i))) {
|
|
117
|
+
let k = i + 1;
|
|
118
|
+
let inClass = false;
|
|
119
|
+
while (k < source.length && source[k] !== "\n") {
|
|
120
|
+
if (source[k] === "\\") {
|
|
121
|
+
k += 2;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (source[k] === "[") inClass = true;
|
|
125
|
+
else if (source[k] === "]") inClass = false;
|
|
126
|
+
else if (source[k] === "/" && !inClass) break;
|
|
127
|
+
k += 1;
|
|
128
|
+
}
|
|
129
|
+
out[i] = "/";
|
|
130
|
+
blank(i + 1, k);
|
|
131
|
+
if (k < source.length && source[k] === "/") out[k] = "/";
|
|
132
|
+
i = k + 1;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
out[i] = ch;
|
|
137
|
+
i += 1;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
for (let k = 0; k < source.length; k += 1) {
|
|
141
|
+
if (out[k] === undefined) out[k] = source[k] === "\n" ? "\n" : " ";
|
|
142
|
+
}
|
|
143
|
+
return { masked: out.join(""), commentText: comments };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Copy one character of a template-literal `${…}` hole into the mask, reporting
|
|
148
|
+
* how it changes brace depth. Nested strings and comments inside the hole are not
|
|
149
|
+
* re-masked here — a hole deep enough to need that is rare, and treating its
|
|
150
|
+
* braces literally keeps the scan terminating.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} source
|
|
153
|
+
* @param {number} index
|
|
154
|
+
* @param {string[]} out - Mask being built, mutated in place.
|
|
155
|
+
* @returns {{ delta: number, next: number }}
|
|
156
|
+
*/
|
|
157
|
+
function maskHole(source, index, out) {
|
|
158
|
+
const ch = source[index];
|
|
159
|
+
out[index] = ch === "\n" ? "\n" : ch;
|
|
160
|
+
if (ch === "{") return { delta: 1, next: index + 1 };
|
|
161
|
+
if (ch === "}") return { delta: -1, next: index + 1 };
|
|
162
|
+
return { delta: 0, next: index + 1 };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Count lines that carry code: non-blank after masking. Comment-only and blank
|
|
167
|
+
* lines do not count, matching `max-lines` with `skipComments` + `skipBlankLines`
|
|
168
|
+
* — the ESLint setting the docs recommend.
|
|
169
|
+
*
|
|
170
|
+
* @param {string} masked - Output of {@link maskSource}.
|
|
171
|
+
* @returns {number}
|
|
172
|
+
*/
|
|
173
|
+
export function countCodeLines(masked) {
|
|
174
|
+
let count = 0;
|
|
175
|
+
for (const line of masked.split("\n")) {
|
|
176
|
+
if (line.trim().length > 0) count += 1;
|
|
177
|
+
}
|
|
178
|
+
return count;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** 1-based line number of a character offset. */
|
|
182
|
+
export function lineAt(text, offset) {
|
|
183
|
+
let line = 1;
|
|
184
|
+
for (let i = 0; i < offset && i < text.length; i += 1) {
|
|
185
|
+
if (text[i] === "\n") line += 1;
|
|
186
|
+
}
|
|
187
|
+
return line;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Index of the delimiter matching the one at `open`, or -1 when unbalanced.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} masked
|
|
194
|
+
* @param {number} open - Offset of the opening delimiter.
|
|
195
|
+
* @param {string} openCh
|
|
196
|
+
* @param {string} closeCh
|
|
197
|
+
* @returns {number}
|
|
198
|
+
*/
|
|
199
|
+
export function matchPair(masked, open, openCh, closeCh) {
|
|
200
|
+
let depth = 0;
|
|
201
|
+
for (let i = open; i < masked.length; i += 1) {
|
|
202
|
+
if (masked[i] === openCh) depth += 1;
|
|
203
|
+
else if (masked[i] === closeCh) {
|
|
204
|
+
depth -= 1;
|
|
205
|
+
if (depth === 0) return i;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return -1;
|
|
209
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { countCodeLines, lineAt, maskSource, matchPair } from "./mask.mjs";
|
|
4
|
+
|
|
5
|
+
describe("maskSource", () => {
|
|
6
|
+
it("blanks line and block comments while keeping newlines", () => {
|
|
7
|
+
const { masked, commentText } = maskSource("const a = 1; // note\n/* two\nlines */\nb;");
|
|
8
|
+
expect(masked).toContain("const a = 1;");
|
|
9
|
+
expect(masked).not.toContain("note");
|
|
10
|
+
expect(masked.split("\n")).toHaveLength(4);
|
|
11
|
+
expect(commentText).toContain("note");
|
|
12
|
+
expect(commentText).toContain("two");
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
it("blanks string contents but keeps the quotes", () => {
|
|
16
|
+
const { masked } = maskSource('const s = "a } b";');
|
|
17
|
+
expect(masked).toMatch(/const s = "\s+";/);
|
|
18
|
+
expect(masked).not.toContain("}");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("keeps template holes as code and blanks the literal text", () => {
|
|
22
|
+
const { masked } = maskSource("const s = `x ${ value } y`;");
|
|
23
|
+
expect(masked).toContain("value");
|
|
24
|
+
expect(masked).not.toContain("x ");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("blanks a regex body so its braces do not unbalance the scan", () => {
|
|
28
|
+
const { masked } = maskSource('const re = /a{2}"/g;');
|
|
29
|
+
expect(masked).not.toContain("{2}");
|
|
30
|
+
expect(masked).not.toContain('"');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("does not treat JSX closing slashes as a regex", () => {
|
|
34
|
+
const { masked } = maskSource("const el = <div>{value}</div>;");
|
|
35
|
+
expect(masked).toContain("value");
|
|
36
|
+
expect(masked).toContain("</div>");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not let an unterminated string swallow the rest of the file", () => {
|
|
40
|
+
const { masked } = maskSource('const broken = "oops\nconst after = 1;');
|
|
41
|
+
expect(masked).toContain("const after = 1;");
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("countCodeLines", () => {
|
|
46
|
+
it("counts only lines with code left after masking", () => {
|
|
47
|
+
const { masked } = maskSource("a;\n\n// comment\nb;\n/* block\nmore */\nc;\n");
|
|
48
|
+
expect(countCodeLines(masked)).toBe(3);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("lineAt", () => {
|
|
53
|
+
it("is 1-based", () => {
|
|
54
|
+
expect(lineAt("a\nb\nc", 0)).toBe(1);
|
|
55
|
+
expect(lineAt("a\nb\nc", 2)).toBe(2);
|
|
56
|
+
expect(lineAt("a\nb\nc", 4)).toBe(3);
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("matchPair", () => {
|
|
61
|
+
it("finds the matching delimiter across nesting", () => {
|
|
62
|
+
const text = "f({ a: { b: 1 } })";
|
|
63
|
+
expect(matchPair(text, 1, "(", ")")).toBe(text.length - 1);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("returns -1 when unbalanced", () => {
|
|
67
|
+
expect(matchPair("f({", 1, "(", ")")).toBe(-1);
|
|
68
|
+
});
|
|
69
|
+
});
|