unslop-ci 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.
- package/LICENSE +27 -0
- package/README.md +232 -0
- package/dist/action.js +1618 -0
- package/dist/cli.js +1716 -0
- package/dist/index.d.ts +289 -0
- package/dist/index.js +1577 -0
- package/package.json +73 -0
package/dist/action.js
ADDED
|
@@ -0,0 +1,1618 @@
|
|
|
1
|
+
// src/action.ts
|
|
2
|
+
import { appendFileSync as appendFileSync2, writeFileSync } from "fs";
|
|
3
|
+
|
|
4
|
+
// src/config.ts
|
|
5
|
+
import { readFileSync, existsSync } from "fs";
|
|
6
|
+
import { join } from "path";
|
|
7
|
+
var DEFAULT_CONFIG_FILENAME = "unslop.config.json";
|
|
8
|
+
var FAIL_ON_VALUES = ["high+bugs", "high", "medium", "any"];
|
|
9
|
+
var SEVERITIES = ["high", "medium", "low", "off"];
|
|
10
|
+
var SCANNER_NAMES = ["code", "text", "ui"];
|
|
11
|
+
var ConfigError = class extends Error {
|
|
12
|
+
};
|
|
13
|
+
function assertStringArray(value, where) {
|
|
14
|
+
if (!Array.isArray(value) || value.some((v) => typeof v !== "string")) {
|
|
15
|
+
throw new ConfigError(`${where} must be an array of strings`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function validateConfig(raw, source) {
|
|
19
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
20
|
+
throw new ConfigError(`${source}: config must be a JSON object`);
|
|
21
|
+
}
|
|
22
|
+
const cfg = raw;
|
|
23
|
+
const known = /* @__PURE__ */ new Set(["failOn", "exclude", "disableRules", "rules", "scanners", "$schema"]);
|
|
24
|
+
for (const key of Object.keys(cfg)) {
|
|
25
|
+
if (!known.has(key)) throw new ConfigError(`${source}: unknown key "${key}"`);
|
|
26
|
+
}
|
|
27
|
+
if (cfg.failOn !== void 0 && !FAIL_ON_VALUES.includes(cfg.failOn)) {
|
|
28
|
+
throw new ConfigError(`${source}: failOn must be one of ${FAIL_ON_VALUES.join(", ")}`);
|
|
29
|
+
}
|
|
30
|
+
if (cfg.exclude !== void 0) assertStringArray(cfg.exclude, `${source}: exclude`);
|
|
31
|
+
if (cfg.disableRules !== void 0)
|
|
32
|
+
assertStringArray(cfg.disableRules, `${source}: disableRules`);
|
|
33
|
+
if (cfg.rules !== void 0) {
|
|
34
|
+
if (typeof cfg.rules !== "object" || cfg.rules === null || Array.isArray(cfg.rules)) {
|
|
35
|
+
throw new ConfigError(`${source}: rules must be an object`);
|
|
36
|
+
}
|
|
37
|
+
for (const [id, sev] of Object.entries(cfg.rules)) {
|
|
38
|
+
if (!SEVERITIES.includes(sev)) {
|
|
39
|
+
throw new ConfigError(`${source}: rules["${id}"] must be one of ${SEVERITIES.join(", ")}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (cfg.scanners !== void 0) {
|
|
44
|
+
if (typeof cfg.scanners !== "object" || cfg.scanners === null || Array.isArray(cfg.scanners)) {
|
|
45
|
+
throw new ConfigError(`${source}: scanners must be an object`);
|
|
46
|
+
}
|
|
47
|
+
for (const [name, sc] of Object.entries(cfg.scanners)) {
|
|
48
|
+
if (!SCANNER_NAMES.includes(name)) {
|
|
49
|
+
throw new ConfigError(`${source}: unknown scanner "${name}"`);
|
|
50
|
+
}
|
|
51
|
+
if (sc === false) continue;
|
|
52
|
+
if (typeof sc !== "object" || sc === null || Array.isArray(sc)) {
|
|
53
|
+
throw new ConfigError(`${source}: scanners.${name} must be an object or false`);
|
|
54
|
+
}
|
|
55
|
+
const sub = sc;
|
|
56
|
+
for (const key of Object.keys(sub)) {
|
|
57
|
+
if (!["include", "exclude", "disableRules"].includes(key)) {
|
|
58
|
+
throw new ConfigError(`${source}: unknown key "scanners.${name}.${key}"`);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
if (sub.include !== void 0)
|
|
62
|
+
assertStringArray(sub.include, `${source}: scanners.${name}.include`);
|
|
63
|
+
if (sub.exclude !== void 0)
|
|
64
|
+
assertStringArray(sub.exclude, `${source}: scanners.${name}.exclude`);
|
|
65
|
+
if (sub.disableRules !== void 0) {
|
|
66
|
+
assertStringArray(sub.disableRules, `${source}: scanners.${name}.disableRules`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return cfg;
|
|
71
|
+
}
|
|
72
|
+
function loadConfig(cwd, explicitPath) {
|
|
73
|
+
const path = explicitPath ?? join(cwd, DEFAULT_CONFIG_FILENAME);
|
|
74
|
+
if (!existsSync(path)) {
|
|
75
|
+
if (explicitPath) throw new ConfigError(`config file not found: ${explicitPath}`);
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
raw = JSON.parse(readFileSync(path, "utf8"));
|
|
81
|
+
} catch (err) {
|
|
82
|
+
throw new ConfigError(`failed to parse ${path}: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
return validateConfig(raw, path);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/diff.ts
|
|
88
|
+
import { spawnSync } from "child_process";
|
|
89
|
+
var GitError = class extends Error {
|
|
90
|
+
};
|
|
91
|
+
function git(args, cwd) {
|
|
92
|
+
const res = spawnSync("git", args, {
|
|
93
|
+
cwd,
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
maxBuffer: 512 * 1024 * 1024
|
|
96
|
+
});
|
|
97
|
+
if (res.error) throw new GitError(`git ${args[0]} failed: ${res.error.message}`);
|
|
98
|
+
if (res.status !== 0) {
|
|
99
|
+
throw new GitError(`git ${args.join(" ")} exited ${res.status}: ${res.stderr.trim()}`);
|
|
100
|
+
}
|
|
101
|
+
return res.stdout;
|
|
102
|
+
}
|
|
103
|
+
function unquoteGitPath(raw) {
|
|
104
|
+
if (!raw.startsWith('"') || !raw.endsWith('"')) return raw;
|
|
105
|
+
const inner = raw.slice(1, -1);
|
|
106
|
+
const bytes = [];
|
|
107
|
+
for (let i = 0; i < inner.length; i++) {
|
|
108
|
+
const ch = inner[i];
|
|
109
|
+
if (ch !== "\\") {
|
|
110
|
+
for (const b of Buffer.from(ch, "utf8")) bytes.push(b);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const next = inner[i + 1];
|
|
114
|
+
if (next === void 0) break;
|
|
115
|
+
if (/[0-7]/.test(next)) {
|
|
116
|
+
const oct = inner.slice(i + 1, i + 4).match(/^[0-7]{1,3}/)?.[0] ?? next;
|
|
117
|
+
bytes.push(parseInt(oct, 8));
|
|
118
|
+
i += oct.length;
|
|
119
|
+
} else {
|
|
120
|
+
const map = { n: "\n", t: " ", r: "\r", "\\": "\\", '"': '"' };
|
|
121
|
+
for (const b of Buffer.from(map[next] ?? next, "utf8")) bytes.push(b);
|
|
122
|
+
i += 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return Buffer.from(bytes).toString("utf8");
|
|
126
|
+
}
|
|
127
|
+
function parseAddedLines(diffText) {
|
|
128
|
+
const added = /* @__PURE__ */ new Map();
|
|
129
|
+
let currentFile = null;
|
|
130
|
+
for (const line of diffText.split("\n")) {
|
|
131
|
+
if (line.startsWith("+++ ")) {
|
|
132
|
+
const target = line.slice(4).trim();
|
|
133
|
+
if (target === "/dev/null") {
|
|
134
|
+
currentFile = null;
|
|
135
|
+
} else {
|
|
136
|
+
const unquoted = unquoteGitPath(target);
|
|
137
|
+
currentFile = unquoted.startsWith("b/") ? unquoted.slice(2) : unquoted;
|
|
138
|
+
}
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (currentFile && line.startsWith("@@")) {
|
|
142
|
+
const m = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(line);
|
|
143
|
+
if (!m) continue;
|
|
144
|
+
const start = Number(m[1]);
|
|
145
|
+
const count = m[2] === void 0 ? 1 : Number(m[2]);
|
|
146
|
+
if (count === 0) continue;
|
|
147
|
+
let set = added.get(currentFile);
|
|
148
|
+
if (!set) {
|
|
149
|
+
set = /* @__PURE__ */ new Set();
|
|
150
|
+
added.set(currentFile, set);
|
|
151
|
+
}
|
|
152
|
+
for (let l = start; l < start + count; l++) set.add(l);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return added;
|
|
156
|
+
}
|
|
157
|
+
function getAddedLines(opts) {
|
|
158
|
+
const mergeBase = opts.mergeBase ?? true;
|
|
159
|
+
const common = ["diff", "-U0", "--no-color", "--no-ext-diff", "--diff-filter=ACMR"];
|
|
160
|
+
let args;
|
|
161
|
+
if (opts.head) {
|
|
162
|
+
args = [...common, mergeBase ? `${opts.base}...${opts.head}` : `${opts.base}..${opts.head}`];
|
|
163
|
+
return parseAddedLines(git(args, opts.cwd));
|
|
164
|
+
}
|
|
165
|
+
if (mergeBase) {
|
|
166
|
+
try {
|
|
167
|
+
return parseAddedLines(git([...common, "--merge-base", opts.base], opts.cwd));
|
|
168
|
+
} catch {
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return parseAddedLines(git([...common, opts.base], opts.cwd));
|
|
172
|
+
}
|
|
173
|
+
function readFileAt(file, cwd, ref) {
|
|
174
|
+
if (!ref) return null;
|
|
175
|
+
try {
|
|
176
|
+
return git(["show", `${ref}:${file}`], cwd);
|
|
177
|
+
} catch {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// src/report/console.ts
|
|
183
|
+
var codes = {
|
|
184
|
+
reset: "\x1B[0m",
|
|
185
|
+
bold: "\x1B[1m",
|
|
186
|
+
dim: "\x1B[2m",
|
|
187
|
+
red: "\x1B[31m",
|
|
188
|
+
yellow: "\x1B[33m",
|
|
189
|
+
green: "\x1B[32m",
|
|
190
|
+
cyan: "\x1B[36m"
|
|
191
|
+
};
|
|
192
|
+
function makePaint(useColor) {
|
|
193
|
+
return (color, text) => useColor ? `${codes[color]}${text}${codes.reset}` : text;
|
|
194
|
+
}
|
|
195
|
+
var SEV_COLOR = { high: "red", medium: "yellow", low: "dim" };
|
|
196
|
+
function renderConsoleReport(report, opts) {
|
|
197
|
+
const paint = makePaint(opts.color);
|
|
198
|
+
const lines = [];
|
|
199
|
+
const scope = report.mode === "diff" ? `added lines vs ${report.base ?? "?"}${report.head ? `..${report.head}` : " (working tree)"}` : "full tree";
|
|
200
|
+
lines.push("");
|
|
201
|
+
lines.push(
|
|
202
|
+
paint("bold", `unslop-ci ${report.mode}`) + paint("dim", ` (${scope}, fail-on: ${report.failOn})`)
|
|
203
|
+
);
|
|
204
|
+
for (const s of report.scanners) {
|
|
205
|
+
const extra = s.words !== void 0 ? ` words: ${s.words} density: ${s.densityPer1kWords}/1k` : "";
|
|
206
|
+
lines.push(
|
|
207
|
+
paint("dim", ` ${s.scanner.padEnd(5)}`) + `files: ${s.filesScanned} high: ${s.counts.high} medium: ${s.counts.medium} low: ${s.counts.low} score: ${s.score}${extra} ${paint("cyan", s.verdict)}`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
if (report.addedLines !== void 0) {
|
|
211
|
+
lines.push(paint("dim", ` added lines considered: ${report.addedLines}`));
|
|
212
|
+
}
|
|
213
|
+
lines.push("");
|
|
214
|
+
const blocking = report.findings.filter((f) => f.blocking);
|
|
215
|
+
const rest = report.findings.filter((f) => !f.blocking);
|
|
216
|
+
if (blocking.length > 0) {
|
|
217
|
+
lines.push(
|
|
218
|
+
paint(
|
|
219
|
+
"red",
|
|
220
|
+
paint("bold", ` ${blocking.length} blocking finding${blocking.length === 1 ? "" : "s"}`)
|
|
221
|
+
)
|
|
222
|
+
);
|
|
223
|
+
for (const f of blocking) {
|
|
224
|
+
lines.push(
|
|
225
|
+
` ${paint(SEV_COLOR[f.severity], f.severity.toUpperCase().padEnd(6))} ${f.file}:${f.line} ${paint("bold", `[${f.scanner}/${f.rule}]`)} ${f.match}`
|
|
226
|
+
);
|
|
227
|
+
lines.push(paint("dim", ` ${f.snippet}`));
|
|
228
|
+
lines.push(paint("dim", ` fix: ${f.fix}`));
|
|
229
|
+
}
|
|
230
|
+
lines.push("");
|
|
231
|
+
}
|
|
232
|
+
if (rest.length > 0) {
|
|
233
|
+
const byRule = /* @__PURE__ */ new Map();
|
|
234
|
+
for (const f of rest) {
|
|
235
|
+
const key = `${f.scanner}/${f.rule}`;
|
|
236
|
+
const arr = byRule.get(key) ?? [];
|
|
237
|
+
arr.push(f);
|
|
238
|
+
byRule.set(key, arr);
|
|
239
|
+
}
|
|
240
|
+
lines.push(
|
|
241
|
+
paint(
|
|
242
|
+
"yellow",
|
|
243
|
+
` ${rest.length} non-blocking finding${rest.length === 1 ? "" : "s"} (warnings)`
|
|
244
|
+
)
|
|
245
|
+
);
|
|
246
|
+
for (const [key, items] of byRule) {
|
|
247
|
+
const f0 = items[0];
|
|
248
|
+
lines.push(
|
|
249
|
+
` ${paint(SEV_COLOR[f0.severity], f0.severity.toUpperCase().padEnd(6))} ${paint("bold", `[${key}]`)} ${f0.label} (${items.length} hit${items.length === 1 ? "" : "s"})`
|
|
250
|
+
);
|
|
251
|
+
for (const it of items.slice(0, opts.maxExamples)) {
|
|
252
|
+
lines.push(paint("dim", ` ${it.file}:${it.line} ${it.snippet}`));
|
|
253
|
+
}
|
|
254
|
+
if (items.length > opts.maxExamples) {
|
|
255
|
+
lines.push(paint("dim", ` ... +${items.length - opts.maxExamples} more`));
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
lines.push("");
|
|
259
|
+
}
|
|
260
|
+
if (report.findings.length === 0) {
|
|
261
|
+
lines.push(paint("green", " Clean. No surface tells on the scanned lines."));
|
|
262
|
+
lines.push(
|
|
263
|
+
paint(
|
|
264
|
+
"dim",
|
|
265
|
+
" The loudest tells are structural (tutorial shape, hallucinated APIs, over-engineering)"
|
|
266
|
+
)
|
|
267
|
+
);
|
|
268
|
+
lines.push(paint("dim", " and need a human or a compiler, not a regex."));
|
|
269
|
+
lines.push("");
|
|
270
|
+
} else if (blocking.length === 0) {
|
|
271
|
+
lines.push(paint("green", " No blocking findings."));
|
|
272
|
+
lines.push("");
|
|
273
|
+
}
|
|
274
|
+
return lines.join("\n");
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// src/report/github.ts
|
|
278
|
+
import { appendFileSync } from "fs";
|
|
279
|
+
var WARNING_ANNOTATION_CAP = 10;
|
|
280
|
+
function escapeData(value) {
|
|
281
|
+
return value.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(/\n/g, "%0A");
|
|
282
|
+
}
|
|
283
|
+
function escapeProperty(value) {
|
|
284
|
+
return escapeData(value).replace(/:/g, "%3A").replace(/,/g, "%2C");
|
|
285
|
+
}
|
|
286
|
+
function renderAnnotations(report) {
|
|
287
|
+
const out = [];
|
|
288
|
+
let warnings = 0;
|
|
289
|
+
for (const finding of report.findings) {
|
|
290
|
+
const level = finding.blocking ? "error" : "warning";
|
|
291
|
+
if (level === "warning") {
|
|
292
|
+
warnings++;
|
|
293
|
+
if (warnings > WARNING_ANNOTATION_CAP) continue;
|
|
294
|
+
}
|
|
295
|
+
const title = `unslop-ci ${finding.scanner}/${finding.rule} (${finding.severity})`;
|
|
296
|
+
const message = `${finding.label}. Fix: ${finding.fix}`;
|
|
297
|
+
out.push(
|
|
298
|
+
`::${level} file=${escapeProperty(finding.file)},line=${finding.line},title=${escapeProperty(title)}::${escapeData(message)}`
|
|
299
|
+
);
|
|
300
|
+
}
|
|
301
|
+
const skipped = warnings - WARNING_ANNOTATION_CAP;
|
|
302
|
+
if (skipped > 0) {
|
|
303
|
+
out.push(
|
|
304
|
+
`::notice title=unslop-ci::${skipped} more non-blocking finding${skipped === 1 ? "" : "s"} not annotated; see the job log or JSON report.`
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
}
|
|
309
|
+
function renderStepSummary(report) {
|
|
310
|
+
const lines = [];
|
|
311
|
+
const scope = report.mode === "diff" ? `added lines vs \`${report.base ?? "?"}\`${report.head ? ` \u2192 \`${report.head}\`` : ""}` : "full tree";
|
|
312
|
+
lines.push(`## unslop-ci \u2014 ${report.blockingCount > 0 ? "blocking findings" : "clean"}`);
|
|
313
|
+
lines.push("");
|
|
314
|
+
lines.push(`Scope: ${scope}. Gate: \`${report.failOn}\`.`);
|
|
315
|
+
lines.push("");
|
|
316
|
+
lines.push("| scanner | files | high | medium | low | verdict |");
|
|
317
|
+
lines.push("| --- | ---: | ---: | ---: | ---: | --- |");
|
|
318
|
+
for (const s of report.scanners) {
|
|
319
|
+
lines.push(
|
|
320
|
+
`| ${s.scanner} | ${s.filesScanned} | ${s.counts.high} | ${s.counts.medium} | ${s.counts.low} | ${s.verdict} |`
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
lines.push("");
|
|
324
|
+
const blocking = report.findings.filter((f) => f.blocking);
|
|
325
|
+
if (blocking.length > 0) {
|
|
326
|
+
lines.push(`### ${blocking.length} blocking finding${blocking.length === 1 ? "" : "s"}`);
|
|
327
|
+
lines.push("");
|
|
328
|
+
lines.push("| file | line | rule | matched | fix |");
|
|
329
|
+
lines.push("| --- | ---: | --- | --- | --- |");
|
|
330
|
+
for (const f of blocking) {
|
|
331
|
+
const match = f.match.replace(/\|/g, "\\|").replace(/`/g, "");
|
|
332
|
+
const fix = f.fix.replace(/\|/g, "\\|");
|
|
333
|
+
lines.push(`| \`${f.file}\` | ${f.line} | ${f.scanner}/${f.rule} | \`${match}\` | ${fix} |`);
|
|
334
|
+
}
|
|
335
|
+
lines.push("");
|
|
336
|
+
lines.push(
|
|
337
|
+
"A line that is intentional can carry `unslop-ignore` (or put `unslop-ignore-next-line` on the line above)."
|
|
338
|
+
);
|
|
339
|
+
} else {
|
|
340
|
+
const warnings = report.findings.length;
|
|
341
|
+
lines.push(
|
|
342
|
+
warnings > 0 ? `No blocking findings. ${warnings} non-blocking warning${warnings === 1 ? "" : "s"} annotated.` : "No findings on the scanned lines."
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
lines.push("");
|
|
346
|
+
return lines.join("\n");
|
|
347
|
+
}
|
|
348
|
+
function writeStepSummary(report) {
|
|
349
|
+
const path = process.env.GITHUB_STEP_SUMMARY;
|
|
350
|
+
if (!path) return false;
|
|
351
|
+
appendFileSync(path, renderStepSummary(report));
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// src/cli-helpers.ts
|
|
356
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
357
|
+
function resolveBaseFromEnv(env) {
|
|
358
|
+
if (env.GITHUB_EVENT_PATH) {
|
|
359
|
+
try {
|
|
360
|
+
const event = JSON.parse(readFileSync2(env.GITHUB_EVENT_PATH, "utf8"));
|
|
361
|
+
if (event.pull_request?.base?.sha) return event.pull_request.base.sha;
|
|
362
|
+
if (event.merge_group?.base_sha) return event.merge_group.base_sha;
|
|
363
|
+
if (event.before && !/^0+$/.test(event.before)) return event.before;
|
|
364
|
+
} catch {
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
if (env.GITHUB_BASE_REF) return `origin/${env.GITHUB_BASE_REF}`;
|
|
368
|
+
return void 0;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// src/run.ts
|
|
372
|
+
import { readFileSync as readFileSync3, readdirSync, statSync } from "fs";
|
|
373
|
+
import { extname, join as join2, relative } from "path";
|
|
374
|
+
|
|
375
|
+
// src/engine.ts
|
|
376
|
+
var IGNORE_MARKER = "unslop-ignore";
|
|
377
|
+
var IGNORE_NEXT_MARKER = "unslop-ignore-next-line";
|
|
378
|
+
function looksMinified(lines) {
|
|
379
|
+
return lines.length === 1 && (lines[0]?.length ?? 0) > 5e3;
|
|
380
|
+
}
|
|
381
|
+
function isSkippedFilename(name, scanner) {
|
|
382
|
+
if (!scanner.skipMinified) return false;
|
|
383
|
+
return name.endsWith(".min.js") || name.endsWith(".min.css") || name.endsWith(".map");
|
|
384
|
+
}
|
|
385
|
+
function toFinding(rule, line, match, snippet) {
|
|
386
|
+
const finding = {
|
|
387
|
+
rule: rule.id,
|
|
388
|
+
label: rule.label,
|
|
389
|
+
severity: rule.severity,
|
|
390
|
+
class: rule.class,
|
|
391
|
+
fix: rule.fix,
|
|
392
|
+
line,
|
|
393
|
+
match: match.trim().slice(0, 60),
|
|
394
|
+
snippet: snippet.trim().slice(0, 160)
|
|
395
|
+
};
|
|
396
|
+
if (rule.share !== void 0) finding.share = rule.share;
|
|
397
|
+
return finding;
|
|
398
|
+
}
|
|
399
|
+
function matchRules(rules, target, rawLine, lineNo, findings) {
|
|
400
|
+
for (const rule of rules) {
|
|
401
|
+
if (rule.suppress?.test(rawLine)) continue;
|
|
402
|
+
for (const rx of rule.patterns) {
|
|
403
|
+
const m = rx.exec(target);
|
|
404
|
+
if (m) {
|
|
405
|
+
findings.push(toFinding(rule, lineNo, m[0], rawLine));
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function scanSource(scanner, lines) {
|
|
412
|
+
const findings = [];
|
|
413
|
+
let ignoreNext = false;
|
|
414
|
+
for (let i = 0; i < lines.length; i++) {
|
|
415
|
+
const line = lines[i] ?? "";
|
|
416
|
+
const lower = line.toLowerCase();
|
|
417
|
+
const skip = ignoreNext || lower.includes(IGNORE_MARKER);
|
|
418
|
+
ignoreNext = lower.includes(IGNORE_NEXT_MARKER);
|
|
419
|
+
if (skip) continue;
|
|
420
|
+
matchRules(scanner.rules, line, line, i + 1, findings);
|
|
421
|
+
}
|
|
422
|
+
return { findings };
|
|
423
|
+
}
|
|
424
|
+
function stripNoise(line, state) {
|
|
425
|
+
const out = [];
|
|
426
|
+
for (const ch of line) {
|
|
427
|
+
if (state.inCode) {
|
|
428
|
+
if (ch === "`") state.inCode = false;
|
|
429
|
+
out.push(" ");
|
|
430
|
+
} else if (ch === "`") {
|
|
431
|
+
state.inCode = true;
|
|
432
|
+
out.push(" ");
|
|
433
|
+
} else if (ch === '"' || ch === "\u201C" || ch === "\u201D") {
|
|
434
|
+
state.inQuote = !state.inQuote;
|
|
435
|
+
out.push(" ");
|
|
436
|
+
} else {
|
|
437
|
+
out.push(state.inQuote ? " " : ch);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
return out.join("");
|
|
441
|
+
}
|
|
442
|
+
function scanProse(scanner, lines) {
|
|
443
|
+
const findings = [];
|
|
444
|
+
const lineWords = new Array(lines.length + 1).fill(0);
|
|
445
|
+
let fmEnd = 0;
|
|
446
|
+
if (lines.length > 0 && (lines[0] ?? "").trim() === "---") {
|
|
447
|
+
for (let j = 1; j < lines.length; j++) {
|
|
448
|
+
if ((lines[j] ?? "").trim() === "---") {
|
|
449
|
+
fmEnd = j + 1;
|
|
450
|
+
break;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
let inFence = false;
|
|
455
|
+
const state = { inCode: false, inQuote: false };
|
|
456
|
+
let ignoreNext = false;
|
|
457
|
+
for (let i = 0; i < lines.length; i++) {
|
|
458
|
+
const lineNo = i + 1;
|
|
459
|
+
const raw = lines[i] ?? "";
|
|
460
|
+
if (lineNo <= fmEnd) continue;
|
|
461
|
+
const stripped = raw.trim();
|
|
462
|
+
if (!stripped) {
|
|
463
|
+
state.inCode = false;
|
|
464
|
+
state.inQuote = false;
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
if (stripped.startsWith("```") || stripped.startsWith("~~~")) {
|
|
468
|
+
inFence = !inFence;
|
|
469
|
+
state.inCode = false;
|
|
470
|
+
state.inQuote = false;
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
if (inFence) continue;
|
|
474
|
+
lineWords[lineNo] = stripped.split(/\s+/).filter(Boolean).length;
|
|
475
|
+
const lower = raw.toLowerCase();
|
|
476
|
+
const skip = ignoreNext || lower.includes(IGNORE_MARKER);
|
|
477
|
+
ignoreNext = lower.includes(IGNORE_NEXT_MARKER);
|
|
478
|
+
if (skip) {
|
|
479
|
+
stripNoise(raw, state);
|
|
480
|
+
continue;
|
|
481
|
+
}
|
|
482
|
+
const isQuote = stripped.startsWith(">");
|
|
483
|
+
const prose = stripNoise(raw, state);
|
|
484
|
+
for (const rule of scanner.rules) {
|
|
485
|
+
if (!rule.raw && isQuote) continue;
|
|
486
|
+
const target = rule.raw ? raw : prose;
|
|
487
|
+
for (const rx of rule.patterns) {
|
|
488
|
+
const m = rx.exec(target);
|
|
489
|
+
if (m) {
|
|
490
|
+
findings.push(toFinding(rule, lineNo, m[0], stripped));
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
return { findings, lineWords };
|
|
497
|
+
}
|
|
498
|
+
function scanContent(scanner, content) {
|
|
499
|
+
const lines = content.split(/\r?\n/);
|
|
500
|
+
if (scanner.skipMinified && looksMinified(lines)) return { findings: [] };
|
|
501
|
+
return scanner.prose ? scanProse(scanner, lines) : scanSource(scanner, lines);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// src/glob.ts
|
|
505
|
+
var SPECIALS = /[.+^$()|[\]\\]/g;
|
|
506
|
+
function segmentToRegExp(segment) {
|
|
507
|
+
let out = "";
|
|
508
|
+
for (let i = 0; i < segment.length; i++) {
|
|
509
|
+
const ch = segment[i];
|
|
510
|
+
if (ch === "*") out += "[^/]*";
|
|
511
|
+
else if (ch === "?") out += "[^/]";
|
|
512
|
+
else if (ch === "{") {
|
|
513
|
+
const end = segment.indexOf("}", i);
|
|
514
|
+
if (end === -1) {
|
|
515
|
+
out += "\\{";
|
|
516
|
+
} else {
|
|
517
|
+
const alts = segment.slice(i + 1, end).split(",").map((a) => a.replace(SPECIALS, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]"));
|
|
518
|
+
out += `(?:${alts.join("|")})`;
|
|
519
|
+
i = end;
|
|
520
|
+
}
|
|
521
|
+
} else out += ch.replace(SPECIALS, "\\$&");
|
|
522
|
+
}
|
|
523
|
+
return out;
|
|
524
|
+
}
|
|
525
|
+
function globToRegExp(glob) {
|
|
526
|
+
let pattern = glob.replace(/\\/g, "/");
|
|
527
|
+
if (pattern.endsWith("/")) pattern += "**";
|
|
528
|
+
const segments = pattern.split("/");
|
|
529
|
+
const parts = [];
|
|
530
|
+
for (let i = 0; i < segments.length; i++) {
|
|
531
|
+
const seg = segments[i];
|
|
532
|
+
if (seg === "**") {
|
|
533
|
+
parts.push(i === segments.length - 1 ? ".*" : "(?:[^/]+/)*");
|
|
534
|
+
} else {
|
|
535
|
+
parts.push(segmentToRegExp(seg) + (i === segments.length - 1 ? "" : "/"));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return new RegExp(`^${parts.join("")}$`);
|
|
539
|
+
}
|
|
540
|
+
var GlobSet = class {
|
|
541
|
+
regexes;
|
|
542
|
+
basenames;
|
|
543
|
+
constructor(globs) {
|
|
544
|
+
this.regexes = [];
|
|
545
|
+
this.basenames = [];
|
|
546
|
+
for (const g of globs) {
|
|
547
|
+
this.regexes.push(globToRegExp(g));
|
|
548
|
+
if (!g.includes("/")) this.basenames.push(globToRegExp(g));
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
matches(path) {
|
|
552
|
+
const posix = path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
553
|
+
if (this.regexes.some((rx) => rx.test(posix))) return true;
|
|
554
|
+
const base = posix.split("/").pop() ?? posix;
|
|
555
|
+
return this.basenames.some((rx) => rx.test(base));
|
|
556
|
+
}
|
|
557
|
+
get isEmpty() {
|
|
558
|
+
return this.regexes.length === 0;
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
// src/gate.ts
|
|
563
|
+
function isBlocking(finding, failOn) {
|
|
564
|
+
switch (failOn) {
|
|
565
|
+
case "high":
|
|
566
|
+
return finding.severity === "high";
|
|
567
|
+
case "high+bugs":
|
|
568
|
+
return finding.severity === "high" || finding.class === "bug";
|
|
569
|
+
case "medium":
|
|
570
|
+
return finding.severity === "high" || finding.severity === "medium" || finding.class === "bug";
|
|
571
|
+
case "any":
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/rules/code.ts
|
|
577
|
+
var CMT = String.raw`(?://|#|/\*|\*|--|<!--)`;
|
|
578
|
+
var EMOJI = String.raw`\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F000}-\u{1F0FF}\u{2B00}-\u{2BFF}\u{1F900}-\u{1F9FF}\u{2764}`;
|
|
579
|
+
var iu = (src) => new RegExp(src, "iu");
|
|
580
|
+
var u = (src) => new RegExp(src, "u");
|
|
581
|
+
var CODE_RULES = [
|
|
582
|
+
{
|
|
583
|
+
id: "chat-artifact",
|
|
584
|
+
label: "Leftover chat / assistant / markdown artifact in the code",
|
|
585
|
+
severity: "high",
|
|
586
|
+
class: "cosmetic",
|
|
587
|
+
share: "verified 1.2%, but unmistakable when present",
|
|
588
|
+
fix: "Delete the assistant's voice before committing: code fences, 'Here's the updated code', 'As an AI', 'Good catch!', a trailing 'Note:'.",
|
|
589
|
+
patterns: [
|
|
590
|
+
iu("^\\s*```"),
|
|
591
|
+
iu(
|
|
592
|
+
String.raw`\bhere'?s the (updated|complete|full|fixed|revised|new) (code|version|implementation|file)\b`
|
|
593
|
+
),
|
|
594
|
+
iu(String.raw`\bas an? (ai|a\.i\.) (language )?model\b`),
|
|
595
|
+
iu(String.raw`\bas a large language model\b`),
|
|
596
|
+
iu(String.raw`\b(good|great) catch!`),
|
|
597
|
+
iu(String.raw`\byou'?re absolutely right\b`),
|
|
598
|
+
iu(String.raw`^\s*` + CMT + String.raw`\s*(note|remember|important|keep in mind|tip)\s*:`),
|
|
599
|
+
iu(String.raw`\b(certainly|sure)! here('?s| is)\b`),
|
|
600
|
+
iu(String.raw`\bi'?ll (add|update|fix|implement) .{0,40}\bnow\b`),
|
|
601
|
+
iu(String.raw`\bi hope this helps\b`),
|
|
602
|
+
iu(String.raw`\blet me know if you'?d?\s*(like|need|want)\b`)
|
|
603
|
+
]
|
|
604
|
+
},
|
|
605
|
+
{
|
|
606
|
+
id: "placeholder-comment",
|
|
607
|
+
label: 'Placeholder / ellipsis comment left in ("// rest of your code")',
|
|
608
|
+
severity: "high",
|
|
609
|
+
class: "bug",
|
|
610
|
+
share: "verified 1.6%, precision ~100%",
|
|
611
|
+
fix: "Write the actual code. These '... rest of the code' stubs mean the file is unfinished, not just untidy.",
|
|
612
|
+
patterns: [
|
|
613
|
+
iu(CMT + String.raw`\s*\.{2,}\s*(rest|the rest|your|remaining|existing|previous|other)\b`),
|
|
614
|
+
iu(
|
|
615
|
+
CMT + String.raw`\s*(rest|remainder) of (your |the |my )?(code|implementation|logic|function|file|owl)\b`
|
|
616
|
+
),
|
|
617
|
+
iu(CMT + String.raw`\s*(your|the) (code|logic|implementation|stuff) (goes )?here\b`),
|
|
618
|
+
iu(
|
|
619
|
+
CMT + String.raw`\s*(add|insert|implement|put) (your )?(code|logic|implementation) here\b`
|
|
620
|
+
),
|
|
621
|
+
iu(CMT + String.raw`\s*(implementation|code|logic) (goes |go )?here\b`),
|
|
622
|
+
iu(CMT + String.raw`\s*existing code (here|unchanged|stays|remains)\b`),
|
|
623
|
+
iu(CMT + String.raw`\s*\.\.\. ?\((?:rest|your|the|existing)[^)]*\)`),
|
|
624
|
+
iu(CMT + String.raw`\s*TODO:?\s*(implement|add|fill in|finish)\b`)
|
|
625
|
+
]
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
id: "emoji-in-code",
|
|
629
|
+
label: "Emoji in code, comments, strings, logs, or commit text",
|
|
630
|
+
severity: "medium",
|
|
631
|
+
class: "cosmetic",
|
|
632
|
+
share: "verified 3.9%, precision ~77% (the highest-precision cosmetic tell)",
|
|
633
|
+
fix: "Remove emoji from source. They survive from the model\u2019s chat output and read as vibe-coded.",
|
|
634
|
+
patterns: [iu(`[${EMOJI}]`)]
|
|
635
|
+
},
|
|
636
|
+
{
|
|
637
|
+
id: "swallowed-errors",
|
|
638
|
+
label: "Catch-all / swallowed errors (bare except, empty catch, empty Go err block)",
|
|
639
|
+
severity: "medium",
|
|
640
|
+
class: "bug",
|
|
641
|
+
share: "verified 3.1% (try/except wrapped around everything)",
|
|
642
|
+
fix: "Catch specific exceptions and handle them. A bare except, an empty catch, or an empty `if err != nil {}` eats the one clue you needed.",
|
|
643
|
+
patterns: [
|
|
644
|
+
iu(String.raw`^\s*except\s*:`),
|
|
645
|
+
iu(String.raw`^\s*except\s+(Exception|BaseException)\s*:\s*(pass|\.\.\.|$)`),
|
|
646
|
+
iu(String.raw`\bcatch\s*\([^)]*\)\s*\{\s*\}`),
|
|
647
|
+
iu(String.raw`\bcatch\s*\{\s*\}`),
|
|
648
|
+
iu(String.raw`\brescue\s*=>\s*\w+\s*$`),
|
|
649
|
+
iu(String.raw`\bcatch\s*\([^)]*\)\s*\{\s*//`),
|
|
650
|
+
iu(String.raw`\bif\s+err\s*!=\s*nil\s*\{\s*\}`),
|
|
651
|
+
iu(String.raw`\bif\s+err\s*!=\s*nil\s*\{\s*//`)
|
|
652
|
+
]
|
|
653
|
+
},
|
|
654
|
+
{
|
|
655
|
+
id: "narrating-comment",
|
|
656
|
+
label: "Narrating comment that restates the obvious / step-by-step '# Step 1'",
|
|
657
|
+
severity: "medium",
|
|
658
|
+
class: "cosmetic",
|
|
659
|
+
share: "verified 8.5% (over-commenting; the regex catches the obvious-restatement subset)",
|
|
660
|
+
fix: "Cut comments that say what the next line plainly does. Comment why, not what.",
|
|
661
|
+
patterns: [
|
|
662
|
+
iu(CMT + String.raw`\s*(step\s*\d+\b|now we\b|first,|next,|then,|finally,)`),
|
|
663
|
+
iu(
|
|
664
|
+
CMT + String.raw`\s*(increment|decrement|initialize|declare|define|create|instantiate|loop (over|through)|iterate over|return the|set the|get the|assign|call the)\b`
|
|
665
|
+
),
|
|
666
|
+
iu(
|
|
667
|
+
CMT + String.raw`\s*this (function|method|line|loop|variable|class|block) (does|is|will|handles|returns|creates)\b`
|
|
668
|
+
),
|
|
669
|
+
iu(
|
|
670
|
+
CMT + String.raw`\s*(import|importing) (the |required )?(libraries|modules|dependencies)\b`
|
|
671
|
+
)
|
|
672
|
+
]
|
|
673
|
+
},
|
|
674
|
+
{
|
|
675
|
+
id: "generic-naming",
|
|
676
|
+
label: "Generic placeholder function name (process_data, handleData, doStuff)",
|
|
677
|
+
severity: "medium",
|
|
678
|
+
class: "cosmetic",
|
|
679
|
+
share: "verified 1.9%, precision ~100% (process_data() is the canonical example)",
|
|
680
|
+
fix: "Name it for what it actually does in this domain. process_data() that does 11 things is the AI tell.",
|
|
681
|
+
patterns: [
|
|
682
|
+
iu(
|
|
683
|
+
String.raw`\b(def|function|func|fn|fun|sub)\s+(process_?[Dd]ata|handle_?[Dd]ata|do_?[Ss]tuff|do_?[Ss]omething|my_?[Ff]unction|process_?[Ii]tem|process_?[Ii]nput|main_?[Ff]unction)\b`
|
|
684
|
+
),
|
|
685
|
+
iu(String.raw`\b(process_?[Dd]ata|handle_?[Dd]ata|do_?[Ss]tuff|do_?[Ss]omething)\s*\(`)
|
|
686
|
+
]
|
|
687
|
+
},
|
|
688
|
+
{
|
|
689
|
+
id: "verbose-naming",
|
|
690
|
+
label: "Over-verbose, robotically self-documenting identifier",
|
|
691
|
+
severity: "low",
|
|
692
|
+
class: "cosmetic",
|
|
693
|
+
share: "verified 0.4% (inflated; people mostly argue FOR descriptive names)",
|
|
694
|
+
fix: "A name that is a whole sentence (getUserDataFromApiResponseHandler) reads as machine-generated. Trim it.",
|
|
695
|
+
caseSensitive: true,
|
|
696
|
+
patterns: [
|
|
697
|
+
u(String.raw`\b[a-z]+([A-Z][a-z0-9]+){4,}\b`),
|
|
698
|
+
u(String.raw`\b[a-z]+(_[a-z0-9]+){5,}\b`)
|
|
699
|
+
]
|
|
700
|
+
},
|
|
701
|
+
{
|
|
702
|
+
id: "boilerplate-marker",
|
|
703
|
+
label: "Boilerplate / dummy-data marker (placeholder keys, sample data, 'Successfully' logs)",
|
|
704
|
+
severity: "low",
|
|
705
|
+
class: "cosmetic",
|
|
706
|
+
share: "weak proxy for the #1 tell (tutorial-shaped code), which is otherwise not regex-detectable",
|
|
707
|
+
fix: "Replace generated dummy data and placeholders with the real thing. Tutorial-shaped sample data is the loudest tell, but only a human can judge the shape.",
|
|
708
|
+
patterns: [
|
|
709
|
+
iu(String.raw`\blorem ipsum\b`),
|
|
710
|
+
iu(String.raw`\b(your|my)[-_]?api[-_]?key\b`),
|
|
711
|
+
iu(String.raw`\bYOUR_API_KEY\b`),
|
|
712
|
+
iu(String.raw`\bexample\.com\b`),
|
|
713
|
+
iu(String.raw`\b(John|Jane) (Doe|Smith)\b`),
|
|
714
|
+
iu(String.raw`['"]sk-(xxx|your|placeholder|123)`),
|
|
715
|
+
iu(
|
|
716
|
+
String.raw`(console\.log|print|println|fmt\.Print\w*|System\.out\.print\w*)\s*\(\s*['"](\u{2705}|\u{1F680}|Successfully|Done!|Here we go)`
|
|
717
|
+
)
|
|
718
|
+
]
|
|
719
|
+
}
|
|
720
|
+
];
|
|
721
|
+
var codeScanner = {
|
|
722
|
+
name: "code",
|
|
723
|
+
title: "unslop-code",
|
|
724
|
+
exts: /* @__PURE__ */ new Set([
|
|
725
|
+
".py",
|
|
726
|
+
".js",
|
|
727
|
+
".jsx",
|
|
728
|
+
".ts",
|
|
729
|
+
".tsx",
|
|
730
|
+
".java",
|
|
731
|
+
".go",
|
|
732
|
+
".rs",
|
|
733
|
+
".rb",
|
|
734
|
+
".php",
|
|
735
|
+
".c",
|
|
736
|
+
".h",
|
|
737
|
+
".cpp",
|
|
738
|
+
".cc",
|
|
739
|
+
".hpp",
|
|
740
|
+
".cs",
|
|
741
|
+
".kt",
|
|
742
|
+
".kts",
|
|
743
|
+
".swift",
|
|
744
|
+
".scala",
|
|
745
|
+
".m",
|
|
746
|
+
".mm",
|
|
747
|
+
".sh",
|
|
748
|
+
".bash",
|
|
749
|
+
".lua",
|
|
750
|
+
".dart",
|
|
751
|
+
".vue",
|
|
752
|
+
".svelte",
|
|
753
|
+
".sql",
|
|
754
|
+
".r"
|
|
755
|
+
]),
|
|
756
|
+
skipDirs: /* @__PURE__ */ new Set([
|
|
757
|
+
"node_modules",
|
|
758
|
+
".git",
|
|
759
|
+
"dist",
|
|
760
|
+
"build",
|
|
761
|
+
".next",
|
|
762
|
+
"out",
|
|
763
|
+
"vendor",
|
|
764
|
+
"target",
|
|
765
|
+
"coverage",
|
|
766
|
+
".venv",
|
|
767
|
+
"venv",
|
|
768
|
+
"__pycache__",
|
|
769
|
+
".idea",
|
|
770
|
+
".gradle",
|
|
771
|
+
"bin",
|
|
772
|
+
"obj"
|
|
773
|
+
]),
|
|
774
|
+
rules: CODE_RULES,
|
|
775
|
+
skipMinified: true,
|
|
776
|
+
prose: false
|
|
777
|
+
};
|
|
778
|
+
|
|
779
|
+
// src/rules/text.ts
|
|
780
|
+
var EMOJI2 = String.raw`\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{1F000}-\u{1F0FF}\u{2190}-\u{21FF}\u{2B00}-\u{2BFF}\u{FE00}-\u{FE0F}\u{2764}`;
|
|
781
|
+
var iu2 = (src) => new RegExp(src, "iu");
|
|
782
|
+
var TEXT_RULES = [
|
|
783
|
+
{
|
|
784
|
+
id: "em-dash",
|
|
785
|
+
label: "Em dash (the single most-cited AI tell)",
|
|
786
|
+
severity: "high",
|
|
787
|
+
class: "cosmetic",
|
|
788
|
+
share: "cited 7.1% / matched 4.5%",
|
|
789
|
+
raw: true,
|
|
790
|
+
fix: "Cut it. Use a comma, a period, or parentheses. Do not just swap in a colon; people flag that now too.",
|
|
791
|
+
patterns: [iu2("\u2014"), iu2("\u2013\u2013"), iu2(String.raw`\s–\s`)]
|
|
792
|
+
},
|
|
793
|
+
{
|
|
794
|
+
id: "not-just-x-y",
|
|
795
|
+
label: `"It's not just X, it's Y" / "not X, but Y" antithesis cadence`,
|
|
796
|
+
severity: "high",
|
|
797
|
+
class: "cosmetic",
|
|
798
|
+
share: "cited 2.8% / matched 1.9%",
|
|
799
|
+
fix: "State the thing plainly. The negate-then-assert rhythm is the clearest 'AI accent'.",
|
|
800
|
+
patterns: [
|
|
801
|
+
iu2(
|
|
802
|
+
String.raw`\b(it'?s|its|it is|that'?s|this is|they'?re)\s+not\s+(just|only|merely|simply)\b[^.?!\n]{0,60}\bit'?s\b`
|
|
803
|
+
),
|
|
804
|
+
iu2(String.raw`\bnot\s+(just|only|merely|simply)\s+(a |an |the )?[\w-]+,?\s+but\b`),
|
|
805
|
+
iu2(String.raw`\bisn'?t\s+(just|only|merely)\b[^.?!\n]{0,60}\bit'?s\b`)
|
|
806
|
+
]
|
|
807
|
+
},
|
|
808
|
+
{
|
|
809
|
+
id: "assistant-boilerplate",
|
|
810
|
+
label: 'Leftover assistant boilerplate ("as an AI language model", a knowledge-cutoff line, a refusal)',
|
|
811
|
+
severity: "high",
|
|
812
|
+
class: "cosmetic",
|
|
813
|
+
share: "cited 1.2%, the ultimate proof when present",
|
|
814
|
+
fix: "Delete every trace of the assistant voice before publishing: disclaimers, refusals, cutoff dates.",
|
|
815
|
+
patterns: [
|
|
816
|
+
iu2(String.raw`\bas an?\s+(ai|a\.i\.)\s+(language\s+)?model\b`),
|
|
817
|
+
iu2(String.raw`\bas a large language model\b`),
|
|
818
|
+
iu2(
|
|
819
|
+
String.raw`\bi (cannot|can'?t|am unable to)\s+(assist|help|fulfil|fulfill|comply|provide)\b`
|
|
820
|
+
),
|
|
821
|
+
iu2(String.raw`\bknowledge cut[- ]?off\b`),
|
|
822
|
+
iu2(String.raw`\bas of my last (knowledge )?(update|training)\b`),
|
|
823
|
+
iu2(String.raw`\bi (do not|don'?t) have (personal|the ability|access|feelings|opinions)\b`)
|
|
824
|
+
]
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
id: "sycophancy-opener",
|
|
828
|
+
label: `Sycophantic opener ("Great question!", "Certainly!", "I'd be happy to")`,
|
|
829
|
+
severity: "high",
|
|
830
|
+
class: "cosmetic",
|
|
831
|
+
share: "cited 2.5% (sycophancy is the #4 cited tell)",
|
|
832
|
+
fix: "Drop the flattery and the reflexive agreement. Open with the actual point. Disagree when warranted.",
|
|
833
|
+
patterns: [
|
|
834
|
+
iu2(String.raw`\b(great|good|excellent|that'?s a (great|good))\s+question\b`),
|
|
835
|
+
iu2(
|
|
836
|
+
String.raw`(^|["'` + "`" + String.raw`(]\s*)(certainly|absolutely|sure thing|of course)\s*[!,]`
|
|
837
|
+
),
|
|
838
|
+
iu2(String.raw`\bi'?d be (happy|glad|delighted) to\b`),
|
|
839
|
+
iu2(String.raw`\bhappy to help\b`),
|
|
840
|
+
iu2(String.raw`\byou'?re absolutely right\b`),
|
|
841
|
+
iu2(String.raw`\bwhat a (great|fascinating|wonderful)\b`)
|
|
842
|
+
]
|
|
843
|
+
},
|
|
844
|
+
{
|
|
845
|
+
id: "bolded-lead-in",
|
|
846
|
+
label: "Bolded lead-in label (**Word:** then a sentence) / title-case mini-headings",
|
|
847
|
+
severity: "high",
|
|
848
|
+
class: "cosmetic",
|
|
849
|
+
share: "matched 2.8% (n=220), rank 3 by keyword pass",
|
|
850
|
+
fix: "Write a normal sentence without the boldface label. The **Bold:** then clause pattern is a giveaway.",
|
|
851
|
+
patterns: [
|
|
852
|
+
iu2(String.raw`(^|\s)\*\*[^*\n]{2,40}:\*\*\s`),
|
|
853
|
+
iu2(String.raw`(^|\s)\*\*[^*\n]{2,40}\*\*\s*:`),
|
|
854
|
+
iu2(String.raw`(^|\s)__[^_\n]{2,40}:?__\s*:?\s`)
|
|
855
|
+
]
|
|
856
|
+
},
|
|
857
|
+
{
|
|
858
|
+
id: "assistant-offer",
|
|
859
|
+
label: 'Trailing assistant offer ("Would you like me to ...", "Let me know if ...", "I hope this helps!")',
|
|
860
|
+
severity: "high",
|
|
861
|
+
class: "cosmetic",
|
|
862
|
+
share: "cited 0.4% each, but unmistakable when present",
|
|
863
|
+
fix: "Delete the meta-offer and the sign-off. A person finishing a thought does not ask if you want a revision.",
|
|
864
|
+
patterns: [
|
|
865
|
+
iu2(String.raw`\bwould you like me to\b`),
|
|
866
|
+
iu2(String.raw`\blet me know if you'?d?\s*(like|need|want|have)\b`),
|
|
867
|
+
iu2(String.raw`\bi hope this helps\b`),
|
|
868
|
+
iu2(String.raw`\bhope (this|you'?re|it) .{0,20}finds? you well\b`),
|
|
869
|
+
iu2(String.raw`\bfeel free to (ask|reach|let me)\b`),
|
|
870
|
+
iu2(String.raw`\bis there anything else\b`)
|
|
871
|
+
]
|
|
872
|
+
},
|
|
873
|
+
{
|
|
874
|
+
id: "ai-diction",
|
|
875
|
+
label: "AI diction memes (delve, tapestry, leverage, seamless, game-changer, ...)",
|
|
876
|
+
severity: "medium",
|
|
877
|
+
class: "cosmetic",
|
|
878
|
+
share: "cited ~1.3% as a cluster; the keyword pass inflates it (listicle copying)",
|
|
879
|
+
fix: "Swap for the plain word you would actually say. 'delve into' is 'look at'; 'leverage' is 'use'.",
|
|
880
|
+
patterns: [
|
|
881
|
+
iu2(String.raw`\b(delv(e|es|ing|ed))\b`),
|
|
882
|
+
iu2(String.raw`\btapestr(y|ies)\b`),
|
|
883
|
+
iu2(String.raw`\bgame[- ]?chang(er|ers|ing)\b`),
|
|
884
|
+
iu2(String.raw`\bseamless(ly)?\b`),
|
|
885
|
+
iu2(String.raw`\bleverag(e|es|ing|ed)\b`),
|
|
886
|
+
iu2(String.raw`\bunleash(es|ing|ed)?\b`),
|
|
887
|
+
iu2(String.raw`\bunderscore(s|d|ing)?\b`),
|
|
888
|
+
iu2(String.raw`\btestament\b`),
|
|
889
|
+
iu2(String.raw`\bembark(s|ing|ed)?\b`),
|
|
890
|
+
iu2(String.raw`\bmeticulous(ly)?\b`),
|
|
891
|
+
iu2(String.raw`\bnuanc(e|es|ed)\b`),
|
|
892
|
+
iu2(String.raw`\belevat(e|es|ing|ed)\b`),
|
|
893
|
+
iu2(String.raw`\bharness(es|ing|ed)?\b`),
|
|
894
|
+
iu2(String.raw`\bshowcas(e|es|ing|ed)\b`),
|
|
895
|
+
iu2(String.raw`\bcaptivat(e|es|ing|ed)\b`),
|
|
896
|
+
iu2(String.raw`\bever[- ]?(evolving|changing)\b`)
|
|
897
|
+
]
|
|
898
|
+
},
|
|
899
|
+
{
|
|
900
|
+
id: "dive-in",
|
|
901
|
+
label: `"Dive in" / "deep dive" / "let's dive"`,
|
|
902
|
+
severity: "medium",
|
|
903
|
+
class: "cosmetic",
|
|
904
|
+
share: "cited 2.0% / matched 1.6%",
|
|
905
|
+
fix: "Cut the metaphor and just start the topic. You do not need to announce that you are starting.",
|
|
906
|
+
patterns: [iu2(String.raw`\b(deep dive|dives? in(to)?|let'?s dive|diving in|dive deep)\b`)]
|
|
907
|
+
},
|
|
908
|
+
{
|
|
909
|
+
id: "listicle-scaffold",
|
|
910
|
+
label: 'Listicle scaffolding ("5 ways to ...", "7 signs ...", "3 reasons ...")',
|
|
911
|
+
severity: "medium",
|
|
912
|
+
class: "cosmetic",
|
|
913
|
+
share: "cited 1.7% (everything turned into a list)",
|
|
914
|
+
fix: "Write prose paragraphs. Reserve bullets for genuinely list-like content, not as the default shape.",
|
|
915
|
+
patterns: [
|
|
916
|
+
iu2(
|
|
917
|
+
String.raw`(^|\s)#{0,4}\s*\d+\s+(ways|tips|signs|reasons|things|steps|tricks|secrets|lessons|mistakes|rules)\b`
|
|
918
|
+
)
|
|
919
|
+
]
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
id: "fast-paced-opener",
|
|
923
|
+
label: `Hollow scene-setting opener ("In today's fast-paced world ...")`,
|
|
924
|
+
severity: "medium",
|
|
925
|
+
class: "cosmetic",
|
|
926
|
+
share: "cited 0.7%, iconic",
|
|
927
|
+
fix: "Delete it and start with something specific. The opener says nothing.",
|
|
928
|
+
patterns: [
|
|
929
|
+
iu2(
|
|
930
|
+
String.raw`\bin today'?s\s+(fast[- ]?paced|digital|ever[- ]?changing|modern|competitive)?\s*(world|age|landscape|era|society|market)\b`
|
|
931
|
+
),
|
|
932
|
+
iu2(String.raw`\bin (the|this) (modern|digital) (world|age|era)\b`)
|
|
933
|
+
]
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
id: "unlock-potential",
|
|
937
|
+
label: '"Unlock / unleash the power / potential"',
|
|
938
|
+
severity: "medium",
|
|
939
|
+
class: "cosmetic",
|
|
940
|
+
share: "cited 0.8%",
|
|
941
|
+
fix: "Say what the thing actually does. The hype verb plus 'potential' is marketing filler.",
|
|
942
|
+
patterns: [
|
|
943
|
+
iu2(
|
|
944
|
+
String.raw`\b(unlock|unleash|harness|tap into)\w*\s+(the\s+|your\s+|its\s+|their\s+|full\s+)*(power|potential|capabilities|secrets)\b`
|
|
945
|
+
)
|
|
946
|
+
]
|
|
947
|
+
},
|
|
948
|
+
{
|
|
949
|
+
id: "emoji-decoration",
|
|
950
|
+
label: "Emoji used as bullets, icons, or in headings",
|
|
951
|
+
severity: "medium",
|
|
952
|
+
class: "cosmetic",
|
|
953
|
+
share: "cited 0.8% (emoji bullets / headers)",
|
|
954
|
+
fix: "Use plain text headers and real list markers. Decorative emoji in headings reads as templated.",
|
|
955
|
+
patterns: [
|
|
956
|
+
iu2(String.raw`^\s{0,3}#{1,6}\s*[${EMOJI2}]`),
|
|
957
|
+
iu2(String.raw`^\s{0,3}[${EMOJI2}]\s+\S`),
|
|
958
|
+
iu2(String.raw`[${EMOJI2}]\s*\*\*`),
|
|
959
|
+
iu2(String.raw`\*\*\s*[${EMOJI2}]`)
|
|
960
|
+
]
|
|
961
|
+
},
|
|
962
|
+
{
|
|
963
|
+
id: "in-conclusion",
|
|
964
|
+
label: '"In conclusion" / "in summary" / "to summarize" closer',
|
|
965
|
+
severity: "medium",
|
|
966
|
+
class: "cosmetic",
|
|
967
|
+
share: "cited 0.2% / matched 1.0%, a classic giveaway",
|
|
968
|
+
fix: "End on a real last point, not a signposted recap. If the reader needs a summary, the piece is too long.",
|
|
969
|
+
patterns: [
|
|
970
|
+
iu2(String.raw`\bin (conclusion|summary)\b`),
|
|
971
|
+
iu2(String.raw`\bto (summari[sz]e|conclude|wrap (this |it )?up)\b`),
|
|
972
|
+
iu2(String.raw`\bin closing\b`)
|
|
973
|
+
]
|
|
974
|
+
},
|
|
975
|
+
{
|
|
976
|
+
id: "transition-stack",
|
|
977
|
+
label: "Stacked formal connectives (Moreover, Furthermore, Additionally, ...)",
|
|
978
|
+
severity: "low",
|
|
979
|
+
class: "cosmetic",
|
|
980
|
+
share: "matched 1.7%, but the keyword pass over-counts (often the poster\u2019s own prose)",
|
|
981
|
+
fix: "Let ideas connect without scaffolding. As a sentence opener these read as machine-smoothed.",
|
|
982
|
+
patterns: [
|
|
983
|
+
iu2(String.raw`(^|\.\s+|\n)\s*(moreover|furthermore|additionally|consequently)\b`),
|
|
984
|
+
iu2(String.raw`\b(firstly|secondly|thirdly|lastly)\b`)
|
|
985
|
+
]
|
|
986
|
+
},
|
|
987
|
+
{
|
|
988
|
+
id: "generic-diction",
|
|
989
|
+
label: "Inflated generic diction (utilize, comprehensive, robust, crucial, navigate, ...)",
|
|
990
|
+
severity: "low",
|
|
991
|
+
class: "cosmetic",
|
|
992
|
+
share: "matched 1 to 2% each, but mostly the poster\u2019s own prose",
|
|
993
|
+
fix: "Prefer the plain word: 'utilize' is 'use', 'comprehensive' is 'full', 'navigate' is 'handle'.",
|
|
994
|
+
patterns: [
|
|
995
|
+
iu2(String.raw`\butili[sz](e|es|ing|ed|ation)\b`),
|
|
996
|
+
iu2(String.raw`\bcomprehensive\b`),
|
|
997
|
+
iu2(String.raw`\brobust\b`),
|
|
998
|
+
iu2(String.raw`\bfacilitat(e|es|ing|ed)\b`),
|
|
999
|
+
iu2(String.raw`\bstreamlin(e|es|ing|ed)\b`),
|
|
1000
|
+
iu2(String.raw`\bempower(s|ing|ed|ment)?\b`),
|
|
1001
|
+
iu2(String.raw`\bmyriad\b`),
|
|
1002
|
+
iu2(String.raw`\bplethora\b`),
|
|
1003
|
+
iu2(String.raw`\bparamount\b`),
|
|
1004
|
+
iu2(String.raw`\bpivotal\b`),
|
|
1005
|
+
iu2(String.raw`\bholistic\b`),
|
|
1006
|
+
iu2(String.raw`\bsynerg(y|ies|istic)\b`),
|
|
1007
|
+
iu2(String.raw`\bmultifaceted\b`),
|
|
1008
|
+
iu2(String.raw`\bintricac(y|ies)\b`)
|
|
1009
|
+
]
|
|
1010
|
+
},
|
|
1011
|
+
{
|
|
1012
|
+
id: "hype-marketing",
|
|
1013
|
+
label: "Marketing hype (revolutionary, transformative, take it to the next level)",
|
|
1014
|
+
severity: "low",
|
|
1015
|
+
class: "cosmetic",
|
|
1016
|
+
share: "cited 0.3% (broader marketing-language category)",
|
|
1017
|
+
fix: "Strip the promotional adjectives and state plain facts about what it does.",
|
|
1018
|
+
patterns: [
|
|
1019
|
+
iu2(String.raw`\brevolution(ary|i[sz]e)\b`),
|
|
1020
|
+
iu2(String.raw`\btransform(ative|s your| ative)\b`),
|
|
1021
|
+
iu2(String.raw`\btransform your (life|business|workflow)\b`),
|
|
1022
|
+
iu2(String.raw`\bto the next level\b`),
|
|
1023
|
+
iu2(String.raw`\bsupercharge\b`),
|
|
1024
|
+
iu2(String.raw`\bsay goodbye to\b`),
|
|
1025
|
+
iu2(String.raw`\blook no further\b`),
|
|
1026
|
+
iu2(String.raw`\bbuckle up\b`),
|
|
1027
|
+
iu2(String.raw`\bgame[- ]?changer\b`),
|
|
1028
|
+
iu2(String.raw`\bwithout further ado\b`)
|
|
1029
|
+
]
|
|
1030
|
+
},
|
|
1031
|
+
{
|
|
1032
|
+
id: "hedge-cliche",
|
|
1033
|
+
label: 'Hedging / both-sides cliche ("it depends", "on one hand ... on the other")',
|
|
1034
|
+
severity: "low",
|
|
1035
|
+
class: "cosmetic",
|
|
1036
|
+
share: "cited 0.3% (the regex sees the phrase, not the hedging)",
|
|
1037
|
+
fix: "Take a position. Listing every option instead of committing is a tell.",
|
|
1038
|
+
patterns: [
|
|
1039
|
+
iu2(String.raw`\bon (the )?one hand\b[^.\n]{0,120}\bon the other\b`),
|
|
1040
|
+
iu2(String.raw`\bit (really )?depends\b`),
|
|
1041
|
+
iu2(String.raw`\bthere'?s no (one[- ]?size[- ]?fits[- ]?all|right answer)\b`)
|
|
1042
|
+
]
|
|
1043
|
+
},
|
|
1044
|
+
{
|
|
1045
|
+
id: "note-hedge",
|
|
1046
|
+
label: `"It's worth noting" / "it's important to note" filler`,
|
|
1047
|
+
severity: "low",
|
|
1048
|
+
class: "cosmetic",
|
|
1049
|
+
share: "cited 0.4 to 0.6%",
|
|
1050
|
+
fix: "If it is worth noting, just note it. The preamble adds nothing.",
|
|
1051
|
+
patterns: [
|
|
1052
|
+
iu2(String.raw`\bit'?s worth (noting|mentioning|pointing out)\b`),
|
|
1053
|
+
iu2(String.raw`\b(it'?s |it is )?important to (note|remember|understand|recognize)\b`),
|
|
1054
|
+
iu2(String.raw`\bthat being said\b`),
|
|
1055
|
+
iu2(String.raw`\bneedless to say\b`)
|
|
1056
|
+
]
|
|
1057
|
+
},
|
|
1058
|
+
{
|
|
1059
|
+
id: "hr-divider",
|
|
1060
|
+
label: "Horizontal-rule dividers (---, ***) between sections",
|
|
1061
|
+
severity: "low",
|
|
1062
|
+
class: "cosmetic",
|
|
1063
|
+
share: "novel, from a vivid citation ('another obvious AI writing marker')",
|
|
1064
|
+
fix: "Use a paragraph break. The rule between every section reads as generated markdown.",
|
|
1065
|
+
// Upstream's `[\s\1]` char class is a Python octal-escape quirk; the
|
|
1066
|
+
// intent (three or more divider chars, optional trailing filler) is
|
|
1067
|
+
// preserved here with an explicit class.
|
|
1068
|
+
patterns: [iu2(String.raw`^\s{0,3}([-*_])\s*\1\s*\1[-*_\s]*$`)]
|
|
1069
|
+
},
|
|
1070
|
+
{
|
|
1071
|
+
id: "honestly-opener",
|
|
1072
|
+
label: 'Fake-relatability opener ("Honestly, ...", "Look, I get it", "Imagine this")',
|
|
1073
|
+
severity: "low",
|
|
1074
|
+
class: "cosmetic",
|
|
1075
|
+
share: "novel; a top thread is devoted to 'Honestly' being 'zombified by AI'",
|
|
1076
|
+
fix: "Cut the throat-clearing and start with the actual point.",
|
|
1077
|
+
patterns: [
|
|
1078
|
+
iu2(String.raw`(^|\n)\s*honestly,\s`),
|
|
1079
|
+
iu2(String.raw`\blook,\s+i (get it|know)\b`),
|
|
1080
|
+
iu2(String.raw`(^|\n)\s*(imagine|picture) this\b`),
|
|
1081
|
+
iu2(String.raw`\blet'?s be (honest|real)\b`)
|
|
1082
|
+
]
|
|
1083
|
+
}
|
|
1084
|
+
];
|
|
1085
|
+
var textScanner = {
|
|
1086
|
+
name: "text",
|
|
1087
|
+
title: "unslop-text",
|
|
1088
|
+
exts: /* @__PURE__ */ new Set([".md", ".markdown", ".mdx", ".txt", ".text", ".rst", ".html", ".htm"]),
|
|
1089
|
+
skipDirs: /* @__PURE__ */ new Set([
|
|
1090
|
+
"node_modules",
|
|
1091
|
+
".git",
|
|
1092
|
+
"dist",
|
|
1093
|
+
"build",
|
|
1094
|
+
".next",
|
|
1095
|
+
"out",
|
|
1096
|
+
"vendor",
|
|
1097
|
+
"coverage",
|
|
1098
|
+
"__pycache__",
|
|
1099
|
+
".venv",
|
|
1100
|
+
"venv"
|
|
1101
|
+
]),
|
|
1102
|
+
rules: TEXT_RULES,
|
|
1103
|
+
skipMinified: false,
|
|
1104
|
+
prose: true
|
|
1105
|
+
};
|
|
1106
|
+
|
|
1107
|
+
// src/rules/ui.ts
|
|
1108
|
+
var iu3 = (src) => new RegExp(src, "iu");
|
|
1109
|
+
var UI_RULES = [
|
|
1110
|
+
{
|
|
1111
|
+
id: "shadcn-default-card",
|
|
1112
|
+
label: "Untouched shadcn default Card / theme",
|
|
1113
|
+
severity: "high",
|
|
1114
|
+
class: "cosmetic",
|
|
1115
|
+
fix: "Theme the tokens (primary, radius, neutrals, spacing). Stock defaults are the giveaway, not shadcn.",
|
|
1116
|
+
patterns: [
|
|
1117
|
+
iu3(String.raw`rounded-lg\s+border\s+bg-card\s+text-card-foreground\s+shadow-sm`),
|
|
1118
|
+
iu3(String.raw`"baseColor"\s*:\s*"(slate|zinc|gray|neutral|stone)"`),
|
|
1119
|
+
iu3(String.raw`--radius\s*:\s*0\.5rem`)
|
|
1120
|
+
]
|
|
1121
|
+
},
|
|
1122
|
+
{
|
|
1123
|
+
id: "ai-purple",
|
|
1124
|
+
label: "AI purple / indigo / violet as primary color",
|
|
1125
|
+
severity: "high",
|
|
1126
|
+
class: "cosmetic",
|
|
1127
|
+
fix: "Pick a brand color outside the violet/indigo/purple band. It is Tailwind's default, so it reads as 'nobody chose this'.",
|
|
1128
|
+
patterns: [
|
|
1129
|
+
iu3(
|
|
1130
|
+
String.raw`\b(bg|text|from|via|to|border|ring|fill|stroke|decoration|outline)-(indigo|violet|purple|fuchsia)-(400|500|600|700|800)\b`
|
|
1131
|
+
),
|
|
1132
|
+
iu3(
|
|
1133
|
+
String.raw`#(6366f1|4f46e5|818cf8|7c3aed|6d28d9|8b5cf6|a855f7|9333ea|7e22ce|c026d3|d946ef)\b`
|
|
1134
|
+
)
|
|
1135
|
+
]
|
|
1136
|
+
},
|
|
1137
|
+
{
|
|
1138
|
+
id: "gradient-text",
|
|
1139
|
+
label: "Gradient-filled text (heading/hero)",
|
|
1140
|
+
severity: "high",
|
|
1141
|
+
class: "cosmetic",
|
|
1142
|
+
fix: "Solid color on headings and copy. Gradient body text is one of the strongest AI tells.",
|
|
1143
|
+
patterns: [
|
|
1144
|
+
iu3(String.raw`bg-clip-text\s+[^"'` + "`" + String.raw`]*text-transparent`),
|
|
1145
|
+
iu3(String.raw`text-transparent\s+[^"'` + "`" + String.raw`]*bg-clip-text`),
|
|
1146
|
+
iu3(String.raw`-webkit-background-clip\s*:\s*text`),
|
|
1147
|
+
iu3(String.raw`\bbackground-clip\s*:\s*text`)
|
|
1148
|
+
]
|
|
1149
|
+
},
|
|
1150
|
+
{
|
|
1151
|
+
id: "purple-blue-gradient",
|
|
1152
|
+
label: "Purple-to-blue/pink gradient",
|
|
1153
|
+
severity: "high",
|
|
1154
|
+
class: "cosmetic",
|
|
1155
|
+
fix: "Default to solid fills. If you must gradient, keep stops analogous and low-contrast, never the rainbow purple-to-blue.",
|
|
1156
|
+
patterns: [
|
|
1157
|
+
iu3(
|
|
1158
|
+
String.raw`from-(purple|violet|indigo|fuchsia)-\d+\s+(via-[a-z]+-\d+\s+)?to-(blue|indigo|pink|cyan|sky)-\d+`
|
|
1159
|
+
),
|
|
1160
|
+
iu3(String.raw`linear-gradient\([^)]*#(6366f1|7c3aed|8b5cf6|a855f7)[^)]*\)`)
|
|
1161
|
+
]
|
|
1162
|
+
},
|
|
1163
|
+
{
|
|
1164
|
+
id: "claude-default-look",
|
|
1165
|
+
label: "The 'tasteful default' look (cream background + serif display)",
|
|
1166
|
+
severity: "high",
|
|
1167
|
+
class: "cosmetic",
|
|
1168
|
+
fix: "This is the 2026 tell, not the fix. Anchor color and type to the real brand or a reference. If cream + serif is a genuine decision, mark the line unslop-ignore.",
|
|
1169
|
+
patterns: [
|
|
1170
|
+
iu3(String.raw`#(faf8f5|f5f1e8|f3eee3|fdfbf7|f7f3ec|faf6ef|f6f1e7|fbf7f0|f4efe4)\b`),
|
|
1171
|
+
iu3(String.raw`\bbg-(stone|amber|orange)-(50|100)\b`),
|
|
1172
|
+
iu3(
|
|
1173
|
+
String.raw`\b(Instrument\s*Serif|Fraunces|Playfair\s*Display|Cormorant|Spectral|DM\s*Serif)\b`
|
|
1174
|
+
)
|
|
1175
|
+
]
|
|
1176
|
+
},
|
|
1177
|
+
{
|
|
1178
|
+
id: "hero-three-cards",
|
|
1179
|
+
label: "Centered hero + three-feature-card grid skeleton",
|
|
1180
|
+
severity: "medium",
|
|
1181
|
+
class: "cosmetic",
|
|
1182
|
+
fix: "Break the grid. Asymmetric hero with a real screenshot; vary sections instead of stacked 3-up icon cards.",
|
|
1183
|
+
patterns: [iu3(String.raw`grid-cols-1\s+(sm:grid-cols-2\s+)?md:grid-cols-3`)]
|
|
1184
|
+
},
|
|
1185
|
+
{
|
|
1186
|
+
id: "rounded-everything",
|
|
1187
|
+
label: "Large rounded corners / pill buttons everywhere",
|
|
1188
|
+
severity: "medium",
|
|
1189
|
+
class: "cosmetic",
|
|
1190
|
+
fix: "Use a small, intentional radius scale by role. Not everything maximally rounded; pills only occasionally.",
|
|
1191
|
+
patterns: [
|
|
1192
|
+
iu3(String.raw`\brounded-(2xl|3xl|full)\b`),
|
|
1193
|
+
iu3(String.raw`border-radius\s*:\s*(999\d*px|9999px)`)
|
|
1194
|
+
],
|
|
1195
|
+
// rounded-full on a small sized box is a status dot / avatar / icon,
|
|
1196
|
+
// not a pill button. Skip those.
|
|
1197
|
+
suppress: iu3(String.raw`\b[hw]-(\d|10|11|12|14|16)(\.5)?\b`)
|
|
1198
|
+
},
|
|
1199
|
+
{
|
|
1200
|
+
id: "fade-in-animations",
|
|
1201
|
+
label: "Boilerplate fade-in / hover-grow / scroll animation",
|
|
1202
|
+
severity: "medium",
|
|
1203
|
+
class: "cosmetic",
|
|
1204
|
+
fix: "Motion only when it communicates something; gate behind prefers-reduced-motion. Minor tell, noisier signal.",
|
|
1205
|
+
patterns: [
|
|
1206
|
+
iu3(String.raw`initial=\{\{\s*opacity:\s*0`),
|
|
1207
|
+
iu3(String.raw`whileInView`),
|
|
1208
|
+
iu3(String.raw`whileHover=\{\{\s*scale`),
|
|
1209
|
+
iu3(String.raw`data-aos\s*=`),
|
|
1210
|
+
iu3(String.raw`\bhover:scale-1\d{2}\b`)
|
|
1211
|
+
]
|
|
1212
|
+
},
|
|
1213
|
+
{
|
|
1214
|
+
id: "neon-glow",
|
|
1215
|
+
label: "Unprompted neon glow shadow",
|
|
1216
|
+
severity: "medium",
|
|
1217
|
+
class: "cosmetic",
|
|
1218
|
+
fix: "Remove glow you did not deliberately design. Dark mode should rely on contrast, not glow.",
|
|
1219
|
+
patterns: [
|
|
1220
|
+
iu3(String.raw`shadow-\[0_0_`),
|
|
1221
|
+
iu3(String.raw`drop-shadow-\[0_0_`),
|
|
1222
|
+
iu3(String.raw`text-shadow\s*:[^;]*\d+px[^;]*(rgba|#|hsl)`),
|
|
1223
|
+
iu3(String.raw`box-shadow\s*:[^;]*\b0\s+0\s+\d{2,}px`)
|
|
1224
|
+
]
|
|
1225
|
+
},
|
|
1226
|
+
{
|
|
1227
|
+
id: "emoji-as-icons",
|
|
1228
|
+
label: "Emoji used as icons / section bullets",
|
|
1229
|
+
severity: "medium",
|
|
1230
|
+
class: "cosmetic",
|
|
1231
|
+
fix: "Use a real SVG icon set (Lucide/Phosphor/Heroicons) or none. Emoji-as-UI signals low effort.",
|
|
1232
|
+
patterns: [
|
|
1233
|
+
iu3(
|
|
1234
|
+
String.raw`[\u{1F680}\u{2728}\u{26A1}\u{1F525}\u{1F4A1}\u{1F512}\u{2705}\u{1F3AF}\u{1F31F}\u{1F6E1}\u{1F4C8}\u{1F511}\u{1F389}]`
|
|
1235
|
+
)
|
|
1236
|
+
]
|
|
1237
|
+
},
|
|
1238
|
+
{
|
|
1239
|
+
id: "generic-font",
|
|
1240
|
+
label: "Generic default font (Inter / Geist / Roboto / system)",
|
|
1241
|
+
severity: "medium",
|
|
1242
|
+
class: "cosmetic",
|
|
1243
|
+
fix: "Choose a typeface with character and pair a display + body face. The starter font reads as 'no choice made'.",
|
|
1244
|
+
patterns: [
|
|
1245
|
+
iu3(String.raw`font-family\s*:\s*['"]?(Inter|Geist|Roboto)\b`),
|
|
1246
|
+
iu3(String.raw`\b(Inter|Geist|Geist_Mono|Roboto)\s*\(`),
|
|
1247
|
+
iu3(String.raw`fontFamily\s*:\s*\{[^}]*['"](Inter|Geist|Roboto)`)
|
|
1248
|
+
]
|
|
1249
|
+
},
|
|
1250
|
+
{
|
|
1251
|
+
id: "hype-copy",
|
|
1252
|
+
label: "Generated marketing copy cliche",
|
|
1253
|
+
severity: "low",
|
|
1254
|
+
class: "cosmetic",
|
|
1255
|
+
fix: "Say what the product literally does, with specifics. Cut the template hype words.",
|
|
1256
|
+
patterns: [
|
|
1257
|
+
iu3(String.raw`\bTransform your\b`),
|
|
1258
|
+
iu3(String.raw`\bSupercharge\b`),
|
|
1259
|
+
iu3(String.raw`\bUnleash\b`),
|
|
1260
|
+
iu3(String.raw`\bEffortlessly\b`),
|
|
1261
|
+
iu3(String.raw`\breimagined\b`),
|
|
1262
|
+
iu3(String.raw`take your [^.]{0,30}to the next level`),
|
|
1263
|
+
iu3(String.raw`\bGame-?changer\b`)
|
|
1264
|
+
]
|
|
1265
|
+
},
|
|
1266
|
+
{
|
|
1267
|
+
id: "stock-illustration",
|
|
1268
|
+
label: "Generic blob / stock illustration source",
|
|
1269
|
+
severity: "low",
|
|
1270
|
+
class: "cosmetic",
|
|
1271
|
+
fix: "Use real screenshots or commissioned art instead of undraw-style blobs.",
|
|
1272
|
+
patterns: [iu3("undraw"), iu3("storyset"), iu3(String.raw`\bdrawkit\b`)]
|
|
1273
|
+
}
|
|
1274
|
+
];
|
|
1275
|
+
var uiScanner = {
|
|
1276
|
+
name: "ui",
|
|
1277
|
+
title: "unslop-ui",
|
|
1278
|
+
exts: /* @__PURE__ */ new Set([
|
|
1279
|
+
".html",
|
|
1280
|
+
".htm",
|
|
1281
|
+
".css",
|
|
1282
|
+
".scss",
|
|
1283
|
+
".sass",
|
|
1284
|
+
".less",
|
|
1285
|
+
".js",
|
|
1286
|
+
".jsx",
|
|
1287
|
+
".ts",
|
|
1288
|
+
".tsx",
|
|
1289
|
+
".vue",
|
|
1290
|
+
".svelte",
|
|
1291
|
+
".astro",
|
|
1292
|
+
".mdx"
|
|
1293
|
+
]),
|
|
1294
|
+
skipDirs: /* @__PURE__ */ new Set([
|
|
1295
|
+
"node_modules",
|
|
1296
|
+
".git",
|
|
1297
|
+
"dist",
|
|
1298
|
+
"build",
|
|
1299
|
+
".next",
|
|
1300
|
+
"out",
|
|
1301
|
+
"vendor",
|
|
1302
|
+
"coverage",
|
|
1303
|
+
".svelte-kit",
|
|
1304
|
+
".astro",
|
|
1305
|
+
".turbo",
|
|
1306
|
+
".cache",
|
|
1307
|
+
"__pycache__"
|
|
1308
|
+
]),
|
|
1309
|
+
rules: UI_RULES,
|
|
1310
|
+
skipMinified: true,
|
|
1311
|
+
prose: false
|
|
1312
|
+
};
|
|
1313
|
+
|
|
1314
|
+
// src/types.ts
|
|
1315
|
+
var SEVERITY_WEIGHT = { high: 3, medium: 2, low: 1 };
|
|
1316
|
+
|
|
1317
|
+
// src/verdict.ts
|
|
1318
|
+
function countBySeverity(findings) {
|
|
1319
|
+
const counts = { high: 0, medium: 0, low: 0 };
|
|
1320
|
+
for (const f of findings) counts[f.severity]++;
|
|
1321
|
+
return counts;
|
|
1322
|
+
}
|
|
1323
|
+
function weightedScore(counts) {
|
|
1324
|
+
return counts.high * SEVERITY_WEIGHT.high + counts.medium * SEVERITY_WEIGHT.medium + counts.low * SEVERITY_WEIGHT.low;
|
|
1325
|
+
}
|
|
1326
|
+
function densityPer1kWords(weighted, words) {
|
|
1327
|
+
return weighted / Math.max(words, 1) * 1e3;
|
|
1328
|
+
}
|
|
1329
|
+
function verdict(scanner, counts, weighted, words) {
|
|
1330
|
+
if (scanner === "text") {
|
|
1331
|
+
const hi = counts.high;
|
|
1332
|
+
const med = counts.medium;
|
|
1333
|
+
if (weighted === 0) return "Clean, no tells detected";
|
|
1334
|
+
const w = words ?? 0;
|
|
1335
|
+
const dens = densityPer1kWords(weighted, w);
|
|
1336
|
+
if (hi === 0 && med === 0) return "Mostly clean, minor tells";
|
|
1337
|
+
if (hi >= 3 || weighted >= 15 || w >= 300 && dens >= 10) return "STRONG AI-writing tells";
|
|
1338
|
+
if (hi >= 1) return "Some AI tells present";
|
|
1339
|
+
if (weighted >= 6 && !(w >= 600 && dens < 2)) return "Some AI tells present";
|
|
1340
|
+
return "Mostly clean, minor tells";
|
|
1341
|
+
}
|
|
1342
|
+
const strong = scanner === "ui" ? "STRONG AI-default look" : "STRONG AI-written-code tells";
|
|
1343
|
+
const some = scanner === "ui" ? "Some AI defaults present" : "Some AI tells present";
|
|
1344
|
+
const clean = scanner === "ui" ? "Clean, no tells detected" : "Clean, no surface tells detected";
|
|
1345
|
+
if (counts.high >= 3 || weighted >= 15) return strong;
|
|
1346
|
+
if (counts.high >= 1 || weighted >= 6) return some;
|
|
1347
|
+
if (weighted > 0) return "Mostly clean, minor tells";
|
|
1348
|
+
return clean;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
// src/run.ts
|
|
1352
|
+
var ALL_SCANNERS = [codeScanner, textScanner, uiScanner];
|
|
1353
|
+
function effectiveRules(def, cfg) {
|
|
1354
|
+
const scannerCfg = cfg.scanners?.[def.name];
|
|
1355
|
+
const disabled = /* @__PURE__ */ new Set([
|
|
1356
|
+
...cfg.disableRules ?? [],
|
|
1357
|
+
...scannerCfg ? scannerCfg.disableRules ?? [] : []
|
|
1358
|
+
]);
|
|
1359
|
+
const rules = [];
|
|
1360
|
+
for (const rule of def.rules) {
|
|
1361
|
+
if (disabled.has(rule.id)) continue;
|
|
1362
|
+
const override = cfg.rules?.[rule.id];
|
|
1363
|
+
if (override === "off") continue;
|
|
1364
|
+
rules.push(override ? { ...rule, severity: override } : rule);
|
|
1365
|
+
}
|
|
1366
|
+
return rules;
|
|
1367
|
+
}
|
|
1368
|
+
function buildEffectiveScanners(cfg, subset) {
|
|
1369
|
+
const out = [];
|
|
1370
|
+
for (const def of ALL_SCANNERS) {
|
|
1371
|
+
if (subset && !subset.includes(def.name)) continue;
|
|
1372
|
+
const scannerCfg = cfg.scanners?.[def.name];
|
|
1373
|
+
if (scannerCfg === false) continue;
|
|
1374
|
+
const sub = scannerCfg ?? {};
|
|
1375
|
+
out.push({
|
|
1376
|
+
def: { ...def, rules: effectiveRules(def, cfg) },
|
|
1377
|
+
include: new GlobSet(sub.include ?? []),
|
|
1378
|
+
exclude: new GlobSet([...cfg.exclude ?? [], ...sub.exclude ?? []])
|
|
1379
|
+
});
|
|
1380
|
+
}
|
|
1381
|
+
return out;
|
|
1382
|
+
}
|
|
1383
|
+
function inSkippedDir(posixPath, def) {
|
|
1384
|
+
return posixPath.split("/").slice(0, -1).some((seg) => def.skipDirs.has(seg));
|
|
1385
|
+
}
|
|
1386
|
+
function scannerAppliesTo(posixPath, scanner) {
|
|
1387
|
+
const ext = extname(posixPath).toLowerCase();
|
|
1388
|
+
if (!scanner.def.exts.has(ext)) return false;
|
|
1389
|
+
const base = posixPath.split("/").pop() ?? posixPath;
|
|
1390
|
+
if (isSkippedFilename(base, scanner.def)) return false;
|
|
1391
|
+
if (inSkippedDir(posixPath, scanner.def)) return false;
|
|
1392
|
+
if (!scanner.include.isEmpty && !scanner.include.matches(posixPath)) return false;
|
|
1393
|
+
if (scanner.exclude.matches(posixPath)) return false;
|
|
1394
|
+
return true;
|
|
1395
|
+
}
|
|
1396
|
+
function newAccumulators() {
|
|
1397
|
+
return /* @__PURE__ */ new Map([
|
|
1398
|
+
["code", { filesScanned: 0, words: 0, findings: [] }],
|
|
1399
|
+
["text", { filesScanned: 0, words: 0, findings: [] }],
|
|
1400
|
+
["ui", { filesScanned: 0, words: 0, findings: [] }]
|
|
1401
|
+
]);
|
|
1402
|
+
}
|
|
1403
|
+
function scanOneFile(scanner, acc, file, content, addedLines) {
|
|
1404
|
+
const { findings, lineWords } = scanContent(scanner.def, content);
|
|
1405
|
+
acc.filesScanned++;
|
|
1406
|
+
if (lineWords) {
|
|
1407
|
+
for (let line = 1; line < lineWords.length; line++) {
|
|
1408
|
+
if (!addedLines || addedLines.has(line)) acc.words += lineWords[line] ?? 0;
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
for (const f of findings) {
|
|
1412
|
+
if (addedLines && !addedLines.has(f.line)) continue;
|
|
1413
|
+
acc.findings.push({ ...f, scanner: scanner.def.name, file, blocking: false });
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
function walkTree(root, def) {
|
|
1417
|
+
const out = [];
|
|
1418
|
+
const walk = (dir) => {
|
|
1419
|
+
let entries;
|
|
1420
|
+
try {
|
|
1421
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
1422
|
+
} catch {
|
|
1423
|
+
return;
|
|
1424
|
+
}
|
|
1425
|
+
for (const entry of entries) {
|
|
1426
|
+
const full = join2(dir, entry.name);
|
|
1427
|
+
if (entry.isDirectory()) {
|
|
1428
|
+
if (!def.skipDirs.has(entry.name)) walk(full);
|
|
1429
|
+
} else if (entry.isFile()) {
|
|
1430
|
+
out.push(full);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
};
|
|
1434
|
+
const rootStat = statSync(root);
|
|
1435
|
+
if (rootStat.isFile()) return [root];
|
|
1436
|
+
walk(root);
|
|
1437
|
+
return out;
|
|
1438
|
+
}
|
|
1439
|
+
var SEV_RANK = { high: 0, medium: 1, low: 2 };
|
|
1440
|
+
function sortFindings(findings) {
|
|
1441
|
+
return findings.sort((a, b) => {
|
|
1442
|
+
if (a.blocking !== b.blocking) return a.blocking ? -1 : 1;
|
|
1443
|
+
if (a.severity !== b.severity) return SEV_RANK[a.severity] - SEV_RANK[b.severity];
|
|
1444
|
+
if (a.file !== b.file) return a.file < b.file ? -1 : 1;
|
|
1445
|
+
return a.line - b.line;
|
|
1446
|
+
});
|
|
1447
|
+
}
|
|
1448
|
+
function finalizeReport(mode, failOn, accumulators, active, extras) {
|
|
1449
|
+
const allFindings = [];
|
|
1450
|
+
const scannerReports = [];
|
|
1451
|
+
for (const scanner of active) {
|
|
1452
|
+
const acc = accumulators.get(scanner.def.name);
|
|
1453
|
+
for (const f of acc.findings) f.blocking = isBlocking(f, failOn);
|
|
1454
|
+
const counts = countBySeverity(acc.findings);
|
|
1455
|
+
const classCounts = { bug: 0, cosmetic: 0 };
|
|
1456
|
+
for (const f of acc.findings) classCounts[f.class]++;
|
|
1457
|
+
const score = weightedScore(counts);
|
|
1458
|
+
const report2 = {
|
|
1459
|
+
scanner: scanner.def.name,
|
|
1460
|
+
filesScanned: acc.filesScanned,
|
|
1461
|
+
counts,
|
|
1462
|
+
classCounts,
|
|
1463
|
+
score,
|
|
1464
|
+
verdict: verdict(scanner.def.name, counts, score, acc.words)
|
|
1465
|
+
};
|
|
1466
|
+
if (scanner.def.prose) {
|
|
1467
|
+
report2.words = acc.words;
|
|
1468
|
+
report2.densityPer1kWords = Math.round(densityPer1kWords(score, acc.words) * 10) / 10;
|
|
1469
|
+
}
|
|
1470
|
+
scannerReports.push(report2);
|
|
1471
|
+
allFindings.push(...acc.findings);
|
|
1472
|
+
}
|
|
1473
|
+
sortFindings(allFindings);
|
|
1474
|
+
const report = {
|
|
1475
|
+
mode,
|
|
1476
|
+
failOn,
|
|
1477
|
+
filesScanned: new Set(allFindings.map((f) => f.file)).size,
|
|
1478
|
+
findings: allFindings,
|
|
1479
|
+
blockingCount: allFindings.filter((f) => f.blocking).length,
|
|
1480
|
+
scanners: scannerReports
|
|
1481
|
+
};
|
|
1482
|
+
if (extras.base !== void 0) report.base = extras.base;
|
|
1483
|
+
if (extras.head !== void 0) report.head = extras.head;
|
|
1484
|
+
if (extras.addedLines !== void 0) report.addedLines = extras.addedLines;
|
|
1485
|
+
report.filesScanned = Math.max(...scannerReports.map((s) => s.filesScanned), 0);
|
|
1486
|
+
return report;
|
|
1487
|
+
}
|
|
1488
|
+
function run(opts) {
|
|
1489
|
+
const cfg = opts.config ?? loadConfig(opts.cwd, opts.configPath);
|
|
1490
|
+
const failOn = opts.failOn ?? cfg.failOn ?? "high+bugs";
|
|
1491
|
+
const active = buildEffectiveScanners(cfg, opts.scanners);
|
|
1492
|
+
const accumulators = newAccumulators();
|
|
1493
|
+
if (opts.mode === "diff") {
|
|
1494
|
+
if (!opts.base) throw new Error("diff mode requires a base ref");
|
|
1495
|
+
const diffOpts = { base: opts.base, cwd: opts.cwd };
|
|
1496
|
+
if (opts.head !== void 0) diffOpts.head = opts.head;
|
|
1497
|
+
if (opts.mergeBase !== void 0) diffOpts.mergeBase = opts.mergeBase;
|
|
1498
|
+
const added = getAddedLines(diffOpts);
|
|
1499
|
+
let addedTotal = 0;
|
|
1500
|
+
for (const [file, lines] of added) {
|
|
1501
|
+
addedTotal += lines.size;
|
|
1502
|
+
let content = null;
|
|
1503
|
+
let contentLoaded = false;
|
|
1504
|
+
for (const scanner of active) {
|
|
1505
|
+
if (!scannerAppliesTo(file, scanner)) continue;
|
|
1506
|
+
if (!contentLoaded) {
|
|
1507
|
+
contentLoaded = true;
|
|
1508
|
+
content = opts.head ? readFileAt(file, opts.cwd, opts.head) : readWorkingTreeFile(join2(opts.cwd, file));
|
|
1509
|
+
}
|
|
1510
|
+
if (content === null) continue;
|
|
1511
|
+
scanOneFile(
|
|
1512
|
+
scanner,
|
|
1513
|
+
accumulators.get(scanner.def.name),
|
|
1514
|
+
file,
|
|
1515
|
+
content,
|
|
1516
|
+
lines
|
|
1517
|
+
);
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
const extras = {
|
|
1521
|
+
base: opts.base,
|
|
1522
|
+
addedLines: addedTotal
|
|
1523
|
+
};
|
|
1524
|
+
if (opts.head !== void 0) extras.head = opts.head;
|
|
1525
|
+
return finalizeReport("diff", failOn, accumulators, active, extras);
|
|
1526
|
+
}
|
|
1527
|
+
const root = opts.scanPath ?? opts.cwd;
|
|
1528
|
+
for (const scanner of active) {
|
|
1529
|
+
const acc = accumulators.get(scanner.def.name);
|
|
1530
|
+
for (const file of walkTree(root, scanner.def)) {
|
|
1531
|
+
const rel = relative(opts.cwd, file).replace(/\\/g, "/");
|
|
1532
|
+
if (!scannerAppliesTo(rel, scanner)) continue;
|
|
1533
|
+
const content = readWorkingTreeFile(file);
|
|
1534
|
+
if (content === null) continue;
|
|
1535
|
+
scanOneFile(scanner, acc, rel, content);
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
return finalizeReport("scan", failOn, accumulators, active, {});
|
|
1539
|
+
}
|
|
1540
|
+
function readWorkingTreeFile(path) {
|
|
1541
|
+
try {
|
|
1542
|
+
return readFileSync3(path, "utf8");
|
|
1543
|
+
} catch {
|
|
1544
|
+
return null;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// src/action.ts
|
|
1549
|
+
function input(name) {
|
|
1550
|
+
const value = process.env[`INPUT_${name.replace(/ /g, "_").toUpperCase()}`];
|
|
1551
|
+
const trimmed = value?.trim();
|
|
1552
|
+
return trimmed ? trimmed : void 0;
|
|
1553
|
+
}
|
|
1554
|
+
function setOutput(name, value) {
|
|
1555
|
+
const path = process.env.GITHUB_OUTPUT;
|
|
1556
|
+
if (path) appendFileSync2(path, `${name}=${value}
|
|
1557
|
+
`);
|
|
1558
|
+
}
|
|
1559
|
+
function die(message) {
|
|
1560
|
+
process.stderr.write(`::error title=unslop-ci::${message}
|
|
1561
|
+
`);
|
|
1562
|
+
process.exitCode = 2;
|
|
1563
|
+
}
|
|
1564
|
+
function main() {
|
|
1565
|
+
const cwd = input("working-directory") ?? process.cwd();
|
|
1566
|
+
const base = input("base") ?? resolveBaseFromEnv(process.env);
|
|
1567
|
+
if (!base) {
|
|
1568
|
+
die("no base ref: pass the `base` input or run on a pull_request/push event");
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
const failOnRaw = input("fail-on");
|
|
1572
|
+
if (failOnRaw && !["high+bugs", "high", "medium", "any"].includes(failOnRaw)) {
|
|
1573
|
+
die(`invalid fail-on "${failOnRaw}"`);
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
const opts = { cwd, mode: "diff", base };
|
|
1577
|
+
const head = input("head");
|
|
1578
|
+
if (head) opts.head = head;
|
|
1579
|
+
const config = input("config");
|
|
1580
|
+
if (config) opts.configPath = config;
|
|
1581
|
+
if (failOnRaw) opts.failOn = failOnRaw;
|
|
1582
|
+
const scannersRaw = input("scanners");
|
|
1583
|
+
if (scannersRaw) {
|
|
1584
|
+
const scanners = scannersRaw.split(",").map((s) => s.trim());
|
|
1585
|
+
for (const s of scanners) {
|
|
1586
|
+
if (!["code", "text", "ui"].includes(s)) {
|
|
1587
|
+
die(`unknown scanner "${s}"`);
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
}
|
|
1591
|
+
opts.scanners = scanners;
|
|
1592
|
+
}
|
|
1593
|
+
let report;
|
|
1594
|
+
try {
|
|
1595
|
+
report = run(opts);
|
|
1596
|
+
} catch (err) {
|
|
1597
|
+
if (err instanceof ConfigError || err instanceof GitError) {
|
|
1598
|
+
die(err.message);
|
|
1599
|
+
return;
|
|
1600
|
+
}
|
|
1601
|
+
throw err;
|
|
1602
|
+
}
|
|
1603
|
+
process.stdout.write(`${renderConsoleReport(report, { color: false, maxExamples: 10 })}
|
|
1604
|
+
`);
|
|
1605
|
+
if (input("annotations") !== "false") {
|
|
1606
|
+
for (const line of renderAnnotations(report)) process.stdout.write(`${line}
|
|
1607
|
+
`);
|
|
1608
|
+
}
|
|
1609
|
+
if (input("summary") !== "false") writeStepSummary(report);
|
|
1610
|
+
const reportFile = input("report-file");
|
|
1611
|
+
if (reportFile) writeFileSync(reportFile, `${JSON.stringify(report, null, 2)}
|
|
1612
|
+
`);
|
|
1613
|
+
setOutput("blocking-count", String(report.blockingCount));
|
|
1614
|
+
setOutput("finding-count", String(report.findings.length));
|
|
1615
|
+
setOutput("added-lines", String(report.addedLines ?? 0));
|
|
1616
|
+
process.exitCode = report.blockingCount > 0 ? 1 : 0;
|
|
1617
|
+
}
|
|
1618
|
+
main();
|