deep-slop 1.4.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/.deep-slop/.deep-slop-ignore +13 -0
- package/LICENSE +21 -0
- package/README.md +1170 -0
- package/dist/arch-constraints-C7s1E_bc.js +450 -0
- package/dist/arch-rules-DI1SYPqu.js +358 -0
- package/dist/ast-slop-BGdr58wZ.js +1839 -0
- package/dist/config-lint-ph3vMUbg.js +371 -0
- package/dist/dead-flow-DHRkyxZT.js +1422 -0
- package/dist/deep-slop-bundled.js +33140 -0
- package/dist/discover-B_S_Fy2S.js +164 -0
- package/dist/dup-detect-DKRXM04q.js +709 -0
- package/dist/file-utils-B_HFXhCs.js +93 -0
- package/dist/format-lint-DeElllNm.js +445 -0
- package/dist/framework-lint-CqdlF9hX.js +782 -0
- package/dist/i18n-lint-CPzx7V8Q.js +605 -0
- package/dist/import-intelligence-SK4F7XpL.js +966 -0
- package/dist/index.d.ts +233 -0
- package/dist/index.js +1030 -0
- package/dist/knip-CgxnnTBZ.js +93 -0
- package/dist/lint-external-ZbW3jGvB.js +326 -0
- package/dist/markup-lint-DKVEDz9M.js +805 -0
- package/dist/mcp.js +35939 -0
- package/dist/meta-quality-Dai1W5iC.js +224 -0
- package/dist/perf-hints-BnWFMFff.js +500 -0
- package/dist/security-deep-DJRINs10.js +1198 -0
- package/dist/syntax-deep-ZQYMutky.js +624 -0
- package/dist/tree-sitter-CM-cP0nl.js +661 -0
- package/dist/type-safety-Dboj2C1t.js +519 -0
- package/package.json +92 -0
|
@@ -0,0 +1,805 @@
|
|
|
1
|
+
import { i as toLines, r as readFileContent } from "./file-utils-B_HFXhCs.js";
|
|
2
|
+
import { extname, join, relative } from "node:path";
|
|
3
|
+
import { readdir } from "node:fs/promises";
|
|
4
|
+
|
|
5
|
+
//#region src/engines/markup-lint/index.ts
|
|
6
|
+
const MARKUP_EXTENSIONS = new Set([
|
|
7
|
+
".json",
|
|
8
|
+
".yaml",
|
|
9
|
+
".yml",
|
|
10
|
+
".css",
|
|
11
|
+
".scss",
|
|
12
|
+
".html",
|
|
13
|
+
".htm",
|
|
14
|
+
".md",
|
|
15
|
+
".markdown"
|
|
16
|
+
]);
|
|
17
|
+
function isMarkupFile(filePath) {
|
|
18
|
+
const ext = extname(filePath);
|
|
19
|
+
return MARKUP_EXTENSIONS.has(ext);
|
|
20
|
+
}
|
|
21
|
+
function fileType(filePath) {
|
|
22
|
+
const ext = extname(filePath);
|
|
23
|
+
if (ext === ".json") return "json";
|
|
24
|
+
if (ext === ".yaml" || ext === ".yml") return "yaml";
|
|
25
|
+
if (ext === ".css" || ext === ".scss") return "css";
|
|
26
|
+
if (ext === ".html" || ext === ".htm") return "html";
|
|
27
|
+
if (ext === ".md" || ext === ".markdown") return "markdown";
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
/** Recursively collect file paths under root, respecting exclude list */
|
|
31
|
+
async function collectMarkupFiles(root, exclude) {
|
|
32
|
+
const results = [];
|
|
33
|
+
async function walk(dir) {
|
|
34
|
+
let entries;
|
|
35
|
+
try {
|
|
36
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
37
|
+
} catch {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
for (const entry of entries) {
|
|
41
|
+
const full = join(dir, entry.name);
|
|
42
|
+
if (exclude.some((pat) => full.includes(pat))) continue;
|
|
43
|
+
if (entry.isDirectory()) await walk(full);
|
|
44
|
+
else if (entry.isFile() && isMarkupFile(full)) results.push(full);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
await walk(root);
|
|
48
|
+
return results;
|
|
49
|
+
}
|
|
50
|
+
/** Make a diagnostic with sensible defaults for markup-lint */
|
|
51
|
+
function makeDiagnostic(overrides) {
|
|
52
|
+
return {
|
|
53
|
+
engine: "markup-lint",
|
|
54
|
+
severity: "info",
|
|
55
|
+
column: 1,
|
|
56
|
+
category: "style",
|
|
57
|
+
fixable: false,
|
|
58
|
+
help: "",
|
|
59
|
+
...overrides
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function detectJsonTrailingComma(content, lines, filePath) {
|
|
63
|
+
const diagnostics = [];
|
|
64
|
+
for (const { num, text } of lines) {
|
|
65
|
+
const trimmed = text.trimEnd();
|
|
66
|
+
if (/,\s*$/.test(trimmed)) {
|
|
67
|
+
const idx = lines.findIndex((l) => l.num === num);
|
|
68
|
+
for (let i = idx + 1; i < lines.length; i++) {
|
|
69
|
+
const nextTrimmed = lines[i].text.trim();
|
|
70
|
+
if (nextTrimmed.length === 0) continue;
|
|
71
|
+
if (/^[}\]]/.test(nextTrimmed)) diagnostics.push(makeDiagnostic({
|
|
72
|
+
filePath,
|
|
73
|
+
rule: "json/trailing-comma",
|
|
74
|
+
message: "Trailing comma before closing bracket — invalid JSON (RFC 8259)",
|
|
75
|
+
line: num,
|
|
76
|
+
column: trimmed.lastIndexOf(",") + 1,
|
|
77
|
+
severity: "error",
|
|
78
|
+
category: "syntax",
|
|
79
|
+
help: "Remove the trailing comma. JSON does not allow trailing commas per the specification.",
|
|
80
|
+
fixable: true,
|
|
81
|
+
suggestion: {
|
|
82
|
+
type: "replace",
|
|
83
|
+
text: trimmed.replace(/,\s*$/, ""),
|
|
84
|
+
confidence: .95,
|
|
85
|
+
reason: "Trailing commas make JSON invalid and cause parse errors in strict parsers"
|
|
86
|
+
}
|
|
87
|
+
}));
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return diagnostics;
|
|
93
|
+
}
|
|
94
|
+
function detectJsonDuplicateKeys(content, lines, filePath) {
|
|
95
|
+
const diagnostics = [];
|
|
96
|
+
const keyPattern = /^\s*"([^"]+)"\s*:/;
|
|
97
|
+
const objectStack = [/* @__PURE__ */ new Map()];
|
|
98
|
+
let braceDepth = 0;
|
|
99
|
+
for (const { num, text } of lines) {
|
|
100
|
+
for (const ch of text) if (ch === "{") {
|
|
101
|
+
braceDepth++;
|
|
102
|
+
objectStack.push(/* @__PURE__ */ new Map());
|
|
103
|
+
} else if (ch === "}") {
|
|
104
|
+
objectStack.pop();
|
|
105
|
+
braceDepth--;
|
|
106
|
+
}
|
|
107
|
+
const match = keyPattern.exec(text);
|
|
108
|
+
if (match) {
|
|
109
|
+
const key = match[1];
|
|
110
|
+
const currentObj = objectStack[objectStack.length - 1];
|
|
111
|
+
if (!currentObj) continue;
|
|
112
|
+
if (currentObj.has(key)) diagnostics.push(makeDiagnostic({
|
|
113
|
+
filePath,
|
|
114
|
+
rule: "json/duplicate-keys",
|
|
115
|
+
message: `Duplicate key "${key}" in same object — last occurrence wins silently`,
|
|
116
|
+
line: num,
|
|
117
|
+
severity: "error",
|
|
118
|
+
category: "syntax",
|
|
119
|
+
help: `Rename or remove the duplicate key. The first occurrence was on line ${currentObj.get(key)}.`,
|
|
120
|
+
fixable: false,
|
|
121
|
+
detail: {
|
|
122
|
+
key,
|
|
123
|
+
firstOccurrence: currentObj.get(key)
|
|
124
|
+
}
|
|
125
|
+
}));
|
|
126
|
+
else currentObj.set(key, num);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return diagnostics;
|
|
130
|
+
}
|
|
131
|
+
function detectJsonInconsistentSpacing(content, lines, filePath) {
|
|
132
|
+
const diagnostics = [];
|
|
133
|
+
let compactLines = 0;
|
|
134
|
+
let expandedLines = 0;
|
|
135
|
+
for (const { num, text } of lines) {
|
|
136
|
+
const trimmed = text.trim();
|
|
137
|
+
if (/^\s*"[^"]+"\s*:\s*[^,]+,\s*"[^"]+"\s*:/.test(trimmed)) compactLines++;
|
|
138
|
+
if (/^\s*"[^"]+"\s*:\s*[^,]+\s*,?\s*$/.test(trimmed) && !trimmed.includes("},")) expandedLines++;
|
|
139
|
+
}
|
|
140
|
+
if (compactLines > 0 && expandedLines > 0 && compactLines >= 2 && expandedLines >= 2) {
|
|
141
|
+
for (const { num, text } of lines) if (/^\s*"[^"]+"\s*:\s*[^,]+,\s*"[^"]+"\s*:/.test(text.trim())) {
|
|
142
|
+
diagnostics.push(makeDiagnostic({
|
|
143
|
+
filePath,
|
|
144
|
+
rule: "json/inconsistent-spacing",
|
|
145
|
+
message: "Mixed compact and expanded object formatting in same file",
|
|
146
|
+
line: num,
|
|
147
|
+
severity: "info",
|
|
148
|
+
category: "style",
|
|
149
|
+
help: "Pick one style: either compact single-line objects or expanded multi-line formatting",
|
|
150
|
+
fixable: false,
|
|
151
|
+
detail: {
|
|
152
|
+
compactLines,
|
|
153
|
+
expandedLines
|
|
154
|
+
}
|
|
155
|
+
}));
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return diagnostics;
|
|
160
|
+
}
|
|
161
|
+
function detectJsonDeepNesting(content, lines, filePath) {
|
|
162
|
+
const diagnostics = [];
|
|
163
|
+
const MAX_DEPTH = 5;
|
|
164
|
+
let depth = 0;
|
|
165
|
+
for (const { num, text } of lines) for (const ch of text) if (ch === "{" || ch === "[") {
|
|
166
|
+
depth++;
|
|
167
|
+
if (depth > MAX_DEPTH) {
|
|
168
|
+
diagnostics.push(makeDiagnostic({
|
|
169
|
+
filePath,
|
|
170
|
+
rule: "json/deep-nesting",
|
|
171
|
+
message: `JSON nested ${depth} levels deep — exceeds max of ${MAX_DEPTH}`,
|
|
172
|
+
line: num,
|
|
173
|
+
severity: "warning",
|
|
174
|
+
category: "architecture",
|
|
175
|
+
help: "Flatten the structure or extract nested objects into separate files/sections",
|
|
176
|
+
fixable: false,
|
|
177
|
+
detail: {
|
|
178
|
+
depth,
|
|
179
|
+
maxDepth: MAX_DEPTH
|
|
180
|
+
}
|
|
181
|
+
}));
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
} else if (ch === "}" || ch === "]") depth--;
|
|
185
|
+
return diagnostics;
|
|
186
|
+
}
|
|
187
|
+
function detectYamlTabIndent(content, lines, filePath) {
|
|
188
|
+
const diagnostics = [];
|
|
189
|
+
let reported = 0;
|
|
190
|
+
for (const { num, text } of lines) if (/^\t/.test(text)) {
|
|
191
|
+
diagnostics.push(makeDiagnostic({
|
|
192
|
+
filePath,
|
|
193
|
+
rule: "yaml/tab-indent",
|
|
194
|
+
message: "Tab character used for indentation — YAML requires spaces",
|
|
195
|
+
line: num,
|
|
196
|
+
severity: "error",
|
|
197
|
+
category: "syntax",
|
|
198
|
+
help: "Replace tabs with spaces. YAML spec requires space indentation for structure.",
|
|
199
|
+
fixable: true,
|
|
200
|
+
suggestion: {
|
|
201
|
+
type: "replace",
|
|
202
|
+
text: text.replace(/^\t+/, (match) => " ".repeat(match.length)),
|
|
203
|
+
confidence: .9,
|
|
204
|
+
reason: "YAML parsers reject tab indentation; spaces are required per the specification"
|
|
205
|
+
}
|
|
206
|
+
}));
|
|
207
|
+
reported++;
|
|
208
|
+
if (reported >= 10) break;
|
|
209
|
+
}
|
|
210
|
+
return diagnostics;
|
|
211
|
+
}
|
|
212
|
+
function detectYamlDuplicateKeys(content, lines, filePath) {
|
|
213
|
+
const diagnostics = [];
|
|
214
|
+
const keyPattern = /^(\s*)([\w][\w.-]*)\s*:/;
|
|
215
|
+
const scopeStack = [{
|
|
216
|
+
indent: -1,
|
|
217
|
+
keys: /* @__PURE__ */ new Map()
|
|
218
|
+
}];
|
|
219
|
+
for (const { num, text } of lines) {
|
|
220
|
+
const match = keyPattern.exec(text);
|
|
221
|
+
if (!match) continue;
|
|
222
|
+
const indent = match[1].length;
|
|
223
|
+
const key = match[2];
|
|
224
|
+
while (scopeStack.length > 1 && scopeStack[scopeStack.length - 1].indent >= indent) scopeStack.pop();
|
|
225
|
+
const currentScope = scopeStack[scopeStack.length - 1];
|
|
226
|
+
if (currentScope.keys.has(key)) diagnostics.push(makeDiagnostic({
|
|
227
|
+
filePath,
|
|
228
|
+
rule: "yaml/duplicate-keys",
|
|
229
|
+
message: `Duplicate key "${key}" in same mapping — last occurrence wins silently`,
|
|
230
|
+
line: num,
|
|
231
|
+
severity: "error",
|
|
232
|
+
category: "syntax",
|
|
233
|
+
help: `Rename or remove the duplicate key. First occurrence was on line ${currentScope.keys.get(key)}.`,
|
|
234
|
+
fixable: false,
|
|
235
|
+
detail: {
|
|
236
|
+
key,
|
|
237
|
+
firstOccurrence: currentScope.keys.get(key)
|
|
238
|
+
}
|
|
239
|
+
}));
|
|
240
|
+
else currentScope.keys.set(key, num);
|
|
241
|
+
scopeStack.push({
|
|
242
|
+
indent,
|
|
243
|
+
keys: /* @__PURE__ */ new Map()
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
return diagnostics;
|
|
247
|
+
}
|
|
248
|
+
function detectYamlComplexAnchor(content, lines, filePath) {
|
|
249
|
+
const diagnostics = [];
|
|
250
|
+
const anchorPattern = /&(\w+)/g;
|
|
251
|
+
const aliasPattern = /\*(\w+)/g;
|
|
252
|
+
const anchors = /* @__PURE__ */ new Map();
|
|
253
|
+
for (const { num, text } of lines) {
|
|
254
|
+
let match;
|
|
255
|
+
anchorPattern.lastIndex = 0;
|
|
256
|
+
while ((match = anchorPattern.exec(text)) !== null) anchors.set(match[1], {
|
|
257
|
+
line: num,
|
|
258
|
+
refCount: 0
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
for (const { text } of lines) {
|
|
262
|
+
aliasPattern.lastIndex = 0;
|
|
263
|
+
let match;
|
|
264
|
+
while ((match = aliasPattern.exec(text)) !== null) {
|
|
265
|
+
const anchor = anchors.get(match[1]);
|
|
266
|
+
if (anchor) anchor.refCount++;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
for (const [name, info] of anchors) if (info.refCount >= 3) diagnostics.push(makeDiagnostic({
|
|
270
|
+
filePath,
|
|
271
|
+
rule: "yaml/complex-anchor",
|
|
272
|
+
message: `Anchor &${name} is aliased ${info.refCount} times — hard to trace data flow`,
|
|
273
|
+
line: info.line,
|
|
274
|
+
severity: "info",
|
|
275
|
+
category: "architecture",
|
|
276
|
+
help: "Consider extracting shared values into a separate config file or reducing alias usage",
|
|
277
|
+
fixable: false,
|
|
278
|
+
detail: {
|
|
279
|
+
anchorName: name,
|
|
280
|
+
refCount: info.refCount
|
|
281
|
+
}
|
|
282
|
+
}));
|
|
283
|
+
return diagnostics;
|
|
284
|
+
}
|
|
285
|
+
function detectYamlMultiDocUnseparated(content, lines, filePath) {
|
|
286
|
+
const diagnostics = [];
|
|
287
|
+
if (lines.some((l) => l.text.trim() === "---")) return diagnostics;
|
|
288
|
+
const topLevelKeyPattern = /^[\w][\w.-]*\s*:/;
|
|
289
|
+
const topLevelKeys = [];
|
|
290
|
+
for (const { num, text } of lines) {
|
|
291
|
+
const match = topLevelKeyPattern.exec(text);
|
|
292
|
+
if (match) topLevelKeys.push({
|
|
293
|
+
key: match[1] ?? match[0],
|
|
294
|
+
line: num
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
if (topLevelKeys.length >= 4) {
|
|
298
|
+
let gapCount = 0;
|
|
299
|
+
for (let i = 1; i < topLevelKeys.length; i++) if (topLevelKeys[i].line - topLevelKeys[i - 1].line > 2) gapCount++;
|
|
300
|
+
if (gapCount >= 2) diagnostics.push(makeDiagnostic({
|
|
301
|
+
filePath,
|
|
302
|
+
rule: "yaml/multi-doc-unseparated",
|
|
303
|
+
message: "Possible multiple YAML documents without --- separator",
|
|
304
|
+
line: topLevelKeys[0].line,
|
|
305
|
+
severity: "warning",
|
|
306
|
+
category: "syntax",
|
|
307
|
+
help: "Add --- separators between documents, or merge into a single document with a top-level key",
|
|
308
|
+
fixable: false,
|
|
309
|
+
detail: {
|
|
310
|
+
topLevelKeyCount: topLevelKeys.length,
|
|
311
|
+
gapCount
|
|
312
|
+
}
|
|
313
|
+
}));
|
|
314
|
+
}
|
|
315
|
+
return diagnostics;
|
|
316
|
+
}
|
|
317
|
+
async function detectCssUnusedSelector(content, lines, filePath, context) {
|
|
318
|
+
const diagnostics = [];
|
|
319
|
+
const classRefs = /* @__PURE__ */ new Set();
|
|
320
|
+
const idRefs = /* @__PURE__ */ new Set();
|
|
321
|
+
const htmlJsxExts = new Set([
|
|
322
|
+
".html",
|
|
323
|
+
".htm",
|
|
324
|
+
".jsx",
|
|
325
|
+
".tsx",
|
|
326
|
+
".js",
|
|
327
|
+
".ts"
|
|
328
|
+
]);
|
|
329
|
+
const htmlJsxFiles = (context.files ?? await collectMarkupFiles(context.rootDirectory, context.config.exclude)).filter((f) => htmlJsxExts.has(extname(f)));
|
|
330
|
+
for (const fp of htmlJsxFiles) try {
|
|
331
|
+
const htmlContent = await readFileContent(fp);
|
|
332
|
+
const classPattern = /(?:class|className)\s*=\s*["']([^"']+)["']/g;
|
|
333
|
+
let match;
|
|
334
|
+
while ((match = classPattern.exec(htmlContent)) !== null) for (const cls of match[1].split(/\s+/)) if (cls) classRefs.add(cls);
|
|
335
|
+
const idPattern = /\bid\s*=\s*["']([^"']+)["']/g;
|
|
336
|
+
while ((match = idPattern.exec(htmlContent)) !== null) if (match[1]) idRefs.add(match[1]);
|
|
337
|
+
} catch {}
|
|
338
|
+
if (htmlJsxFiles.length === 0) return diagnostics;
|
|
339
|
+
for (const { num, text } of lines) {
|
|
340
|
+
const trimmed = text.trim();
|
|
341
|
+
const classMatch = /^\.([a-zA-Z_-][\w-]*)/.exec(trimmed);
|
|
342
|
+
if (classMatch) {
|
|
343
|
+
const className = classMatch[1];
|
|
344
|
+
if (!classRefs.has(className)) diagnostics.push(makeDiagnostic({
|
|
345
|
+
filePath,
|
|
346
|
+
rule: "css/unused-selector",
|
|
347
|
+
message: `CSS class .${className} not found in any HTML/JSX file`,
|
|
348
|
+
line: num,
|
|
349
|
+
severity: "info",
|
|
350
|
+
category: "dead-code",
|
|
351
|
+
help: "Remove unused CSS rules or add the class to an HTML/JSX element",
|
|
352
|
+
fixable: false,
|
|
353
|
+
detail: {
|
|
354
|
+
selector: `.${className}`,
|
|
355
|
+
type: "class"
|
|
356
|
+
}
|
|
357
|
+
}));
|
|
358
|
+
}
|
|
359
|
+
const idMatch = /^#([a-zA-Z_-][\w-]*)/.exec(trimmed);
|
|
360
|
+
if (idMatch) {
|
|
361
|
+
const idName = idMatch[1];
|
|
362
|
+
if (!idRefs.has(idName)) diagnostics.push(makeDiagnostic({
|
|
363
|
+
filePath,
|
|
364
|
+
rule: "css/unused-selector",
|
|
365
|
+
message: `CSS id #${idName} not found in any HTML/JSX file`,
|
|
366
|
+
line: num,
|
|
367
|
+
severity: "info",
|
|
368
|
+
category: "dead-code",
|
|
369
|
+
help: "Remove unused CSS rules or add the id to an HTML element",
|
|
370
|
+
fixable: false,
|
|
371
|
+
detail: {
|
|
372
|
+
selector: `#${idName}`,
|
|
373
|
+
type: "id"
|
|
374
|
+
}
|
|
375
|
+
}));
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return diagnostics.slice(0, 20);
|
|
379
|
+
}
|
|
380
|
+
function detectCssImportantOveruse(content, lines, filePath) {
|
|
381
|
+
const diagnostics = [];
|
|
382
|
+
const MAX_IMPORTANT = 3;
|
|
383
|
+
const importantLines = [];
|
|
384
|
+
for (const { num, text } of lines) if (/!important\b/i.test(text)) importantLines.push({ num });
|
|
385
|
+
if (importantLines.length > MAX_IMPORTANT) diagnostics.push(makeDiagnostic({
|
|
386
|
+
filePath,
|
|
387
|
+
rule: "css/important-overuse",
|
|
388
|
+
message: `${importantLines.length} !important declarations in file — exceeds max of ${MAX_IMPORTANT}`,
|
|
389
|
+
line: importantLines[0].num,
|
|
390
|
+
severity: "warning",
|
|
391
|
+
category: "style",
|
|
392
|
+
help: "Reduce !important usage by increasing selector specificity instead. Overuse indicates specificity conflicts.",
|
|
393
|
+
fixable: false,
|
|
394
|
+
detail: {
|
|
395
|
+
count: importantLines.length,
|
|
396
|
+
max: MAX_IMPORTANT
|
|
397
|
+
}
|
|
398
|
+
}));
|
|
399
|
+
return diagnostics;
|
|
400
|
+
}
|
|
401
|
+
function detectCssDuplicateProperty(content, lines, filePath) {
|
|
402
|
+
const diagnostics = [];
|
|
403
|
+
const propertyPattern = /^\s*([\w-]+)\s*:/;
|
|
404
|
+
const currentProps = /* @__PURE__ */ new Map();
|
|
405
|
+
for (const { num, text } of lines) {
|
|
406
|
+
const trimmed = text.trim();
|
|
407
|
+
if (trimmed.endsWith("{")) {
|
|
408
|
+
currentProps.clear();
|
|
409
|
+
continue;
|
|
410
|
+
}
|
|
411
|
+
if (trimmed.startsWith("}")) {
|
|
412
|
+
currentProps.clear();
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
const match = propertyPattern.exec(text);
|
|
416
|
+
if (match) {
|
|
417
|
+
const prop = match[1].toLowerCase();
|
|
418
|
+
if (currentProps.has(prop)) diagnostics.push(makeDiagnostic({
|
|
419
|
+
filePath,
|
|
420
|
+
rule: "css/duplicate-property",
|
|
421
|
+
message: `Duplicate property "${prop}" in same rule block — first defined on line ${currentProps.get(prop)}`,
|
|
422
|
+
line: num,
|
|
423
|
+
severity: "warning",
|
|
424
|
+
category: "syntax",
|
|
425
|
+
help: "Remove the duplicate property. The last definition wins, which may not be intended.",
|
|
426
|
+
fixable: true,
|
|
427
|
+
suggestion: {
|
|
428
|
+
type: "delete",
|
|
429
|
+
text: "",
|
|
430
|
+
confidence: .8,
|
|
431
|
+
reason: "Duplicate properties are usually accidental; the last one wins silently"
|
|
432
|
+
},
|
|
433
|
+
detail: {
|
|
434
|
+
property: prop,
|
|
435
|
+
firstLine: currentProps.get(prop)
|
|
436
|
+
}
|
|
437
|
+
}));
|
|
438
|
+
else currentProps.set(prop, num);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
return diagnostics;
|
|
442
|
+
}
|
|
443
|
+
function detectCssUniversalSelector(content, lines, filePath) {
|
|
444
|
+
const diagnostics = [];
|
|
445
|
+
let reported = 0;
|
|
446
|
+
for (const { num, text } of lines) {
|
|
447
|
+
const trimmed = text.trim();
|
|
448
|
+
if (/^\*\s*(?:[,:{]|$|::)/.test(trimmed) || /\*\s*:/.test(trimmed)) {
|
|
449
|
+
if (/\*=/.test(trimmed) || trimmed === "*/") continue;
|
|
450
|
+
diagnostics.push(makeDiagnostic({
|
|
451
|
+
filePath,
|
|
452
|
+
rule: "css/universal-selector",
|
|
453
|
+
message: "Universal selector * used — has performance impact on large DOMs",
|
|
454
|
+
line: num,
|
|
455
|
+
severity: "info",
|
|
456
|
+
category: "performance",
|
|
457
|
+
help: "Replace with a more specific selector. Universal selectors force the browser to check every element.",
|
|
458
|
+
fixable: false,
|
|
459
|
+
detail: { selector: "*" }
|
|
460
|
+
}));
|
|
461
|
+
reported++;
|
|
462
|
+
if (reported >= 5) break;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
return diagnostics;
|
|
466
|
+
}
|
|
467
|
+
function detectHtmlMissingAlt(content, lines, filePath) {
|
|
468
|
+
const diagnostics = [];
|
|
469
|
+
for (const { num, text } of lines) {
|
|
470
|
+
const imgPattern = /<img\b[^>]*>/gi;
|
|
471
|
+
let match;
|
|
472
|
+
imgPattern.lastIndex = 0;
|
|
473
|
+
while ((match = imgPattern.exec(text)) !== null) {
|
|
474
|
+
const imgTag = match[0];
|
|
475
|
+
if (!/\balt\s*=/i.test(imgTag)) diagnostics.push(makeDiagnostic({
|
|
476
|
+
filePath,
|
|
477
|
+
rule: "html/missing-alt",
|
|
478
|
+
message: "<img> tag missing alt attribute — accessibility violation (WCAG 1.1.1)",
|
|
479
|
+
line: num,
|
|
480
|
+
column: match.index + 1,
|
|
481
|
+
severity: "error",
|
|
482
|
+
category: "style",
|
|
483
|
+
help: "Add alt=\"description\" for meaningful images, or alt=\"\" for decorative images",
|
|
484
|
+
fixable: true,
|
|
485
|
+
suggestion: {
|
|
486
|
+
type: "insert",
|
|
487
|
+
text: " alt=\"\"",
|
|
488
|
+
confidence: .7,
|
|
489
|
+
reason: "Missing alt attributes fail WCAG 1.1.1 and are inaccessible to screen readers"
|
|
490
|
+
}
|
|
491
|
+
}));
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return diagnostics;
|
|
495
|
+
}
|
|
496
|
+
function detectHtmlMissingLang(content, lines, filePath) {
|
|
497
|
+
const diagnostics = [];
|
|
498
|
+
for (const { num, text } of lines) {
|
|
499
|
+
const htmlPattern = /<html\b[^>]*>/gi;
|
|
500
|
+
let match;
|
|
501
|
+
htmlPattern.lastIndex = 0;
|
|
502
|
+
while ((match = htmlPattern.exec(text)) !== null) {
|
|
503
|
+
const htmlTag = match[0];
|
|
504
|
+
if (!/\blang\s*=/i.test(htmlTag)) diagnostics.push(makeDiagnostic({
|
|
505
|
+
filePath,
|
|
506
|
+
rule: "html/missing-lang",
|
|
507
|
+
message: "<html> tag missing lang attribute — accessibility issue (WCAG 3.1.1)",
|
|
508
|
+
line: num,
|
|
509
|
+
column: match.index + 1,
|
|
510
|
+
severity: "warning",
|
|
511
|
+
category: "style",
|
|
512
|
+
help: "Add lang=\"en\" (or appropriate language code) to the <html> tag",
|
|
513
|
+
fixable: true,
|
|
514
|
+
suggestion: {
|
|
515
|
+
type: "insert",
|
|
516
|
+
text: " lang=\"en\"",
|
|
517
|
+
confidence: .7,
|
|
518
|
+
reason: "Missing lang attribute fails WCAG 3.1.1 and hinders screen readers and search engines"
|
|
519
|
+
}
|
|
520
|
+
}));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return diagnostics;
|
|
524
|
+
}
|
|
525
|
+
function detectHtmlDeprecatedTag(content, lines, filePath) {
|
|
526
|
+
const diagnostics = [];
|
|
527
|
+
const tagPattern = new RegExp(`<(${[
|
|
528
|
+
"font",
|
|
529
|
+
"center",
|
|
530
|
+
"marquee",
|
|
531
|
+
"blink",
|
|
532
|
+
"big",
|
|
533
|
+
"strike",
|
|
534
|
+
"tt",
|
|
535
|
+
"frame",
|
|
536
|
+
"frameset",
|
|
537
|
+
"noframes"
|
|
538
|
+
].join("|")})\\b`, "gi");
|
|
539
|
+
for (const { num, text } of lines) {
|
|
540
|
+
tagPattern.lastIndex = 0;
|
|
541
|
+
const match = tagPattern.exec(text);
|
|
542
|
+
if (match) {
|
|
543
|
+
const tag = match[1].toLowerCase();
|
|
544
|
+
diagnostics.push(makeDiagnostic({
|
|
545
|
+
filePath,
|
|
546
|
+
rule: "html/deprecated-tag",
|
|
547
|
+
message: `Deprecated HTML tag <${tag}> — removed from HTML5 spec`,
|
|
548
|
+
line: num,
|
|
549
|
+
column: match.index + 1,
|
|
550
|
+
severity: "warning",
|
|
551
|
+
category: "syntax",
|
|
552
|
+
help: `Replace <${tag}> with CSS or semantic HTML. Use <span> with CSS for styling, <div> with text-align for centering.`,
|
|
553
|
+
fixable: false,
|
|
554
|
+
detail: { tag }
|
|
555
|
+
}));
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return diagnostics;
|
|
559
|
+
}
|
|
560
|
+
function detectHtmlInlineEventHandler(content, lines, filePath) {
|
|
561
|
+
const diagnostics = [];
|
|
562
|
+
const handlerPattern = new RegExp(`\\b(${[
|
|
563
|
+
"onclick",
|
|
564
|
+
"ondblclick",
|
|
565
|
+
"onmousedown",
|
|
566
|
+
"onmouseup",
|
|
567
|
+
"onmouseover",
|
|
568
|
+
"onmousemove",
|
|
569
|
+
"onmouseout",
|
|
570
|
+
"onkeydown",
|
|
571
|
+
"onkeypress",
|
|
572
|
+
"onkeyup",
|
|
573
|
+
"onload",
|
|
574
|
+
"onunload",
|
|
575
|
+
"onfocus",
|
|
576
|
+
"onblur",
|
|
577
|
+
"onsubmit",
|
|
578
|
+
"onreset",
|
|
579
|
+
"onchange",
|
|
580
|
+
"onselect",
|
|
581
|
+
"oninput",
|
|
582
|
+
"onerror",
|
|
583
|
+
"onresize",
|
|
584
|
+
"onscroll"
|
|
585
|
+
].join("|")})\\s*=`, "gi");
|
|
586
|
+
for (const { num, text } of lines) {
|
|
587
|
+
handlerPattern.lastIndex = 0;
|
|
588
|
+
const match = handlerPattern.exec(text);
|
|
589
|
+
if (match) {
|
|
590
|
+
const handler = match[1].toLowerCase();
|
|
591
|
+
diagnostics.push(makeDiagnostic({
|
|
592
|
+
filePath,
|
|
593
|
+
rule: "html/inline-event-handler",
|
|
594
|
+
message: `Inline event handler ${handler}= — mixes behavior with structure (CSP violation risk)`,
|
|
595
|
+
line: num,
|
|
596
|
+
column: match.index + 1,
|
|
597
|
+
severity: "warning",
|
|
598
|
+
category: "security",
|
|
599
|
+
help: `Move ${handler} to an external JavaScript file using addEventListener(). Inline handlers violate Content Security Policy.`,
|
|
600
|
+
fixable: false,
|
|
601
|
+
detail: { handler }
|
|
602
|
+
}));
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
return diagnostics;
|
|
606
|
+
}
|
|
607
|
+
function detectMdBrokenLink(content, lines, filePath) {
|
|
608
|
+
const diagnostics = [];
|
|
609
|
+
const linkPattern = /\[([^\]]*)\]\(([^)]*)\)/g;
|
|
610
|
+
for (const { num, text } of lines) {
|
|
611
|
+
linkPattern.lastIndex = 0;
|
|
612
|
+
let match;
|
|
613
|
+
while ((match = linkPattern.exec(text)) !== null) {
|
|
614
|
+
const linkText = match[1];
|
|
615
|
+
const url = match[2].trim();
|
|
616
|
+
if (url === "" || url === "#") diagnostics.push(makeDiagnostic({
|
|
617
|
+
filePath,
|
|
618
|
+
rule: "md/broken-link",
|
|
619
|
+
message: `Link "${linkText}" has ${url === "" ? "empty" : "placeholder (#)"} URL`,
|
|
620
|
+
line: num,
|
|
621
|
+
column: match.index + 1,
|
|
622
|
+
severity: "warning",
|
|
623
|
+
category: "syntax",
|
|
624
|
+
help: "Add the correct URL for this link, or remove the link if not needed",
|
|
625
|
+
fixable: false,
|
|
626
|
+
detail: {
|
|
627
|
+
linkText,
|
|
628
|
+
url
|
|
629
|
+
}
|
|
630
|
+
}));
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return diagnostics;
|
|
634
|
+
}
|
|
635
|
+
function detectMdInconsistentHeading(content, lines, filePath) {
|
|
636
|
+
const diagnostics = [];
|
|
637
|
+
let atxHeadings = 0;
|
|
638
|
+
let setextHeadings = 0;
|
|
639
|
+
for (let i = 0; i < lines.length; i++) {
|
|
640
|
+
const { num, text } = lines[i];
|
|
641
|
+
const trimmed = text.trim();
|
|
642
|
+
if (/^#{1,6}\s/.test(trimmed)) atxHeadings++;
|
|
643
|
+
if (i + 1 < lines.length) {
|
|
644
|
+
const nextTrimmed = lines[i + 1].text.trim();
|
|
645
|
+
if (/^=+\s*$/.test(nextTrimmed) || /^-+\s*$/.test(nextTrimmed)) setextHeadings++;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
if (atxHeadings > 0 && setextHeadings > 0) for (let i = 0; i < lines.length; i++) {
|
|
649
|
+
const { num, text } = lines[i];
|
|
650
|
+
if (i + 1 < lines.length) {
|
|
651
|
+
const nextTrimmed = lines[i + 1].text.trim();
|
|
652
|
+
if (/^=+\s*$/.test(nextTrimmed) || /^-+\s*$/.test(nextTrimmed)) {
|
|
653
|
+
diagnostics.push(makeDiagnostic({
|
|
654
|
+
filePath,
|
|
655
|
+
rule: "md/inconsistent-heading",
|
|
656
|
+
message: "Mixed heading styles (ATX # and Setext ===/---) — use one style consistently",
|
|
657
|
+
line: num,
|
|
658
|
+
severity: "info",
|
|
659
|
+
category: "style",
|
|
660
|
+
help: "Pick one heading style: ATX (# ) is more common and supports all heading levels",
|
|
661
|
+
fixable: false,
|
|
662
|
+
detail: {
|
|
663
|
+
atxHeadings,
|
|
664
|
+
setextHeadings
|
|
665
|
+
}
|
|
666
|
+
}));
|
|
667
|
+
break;
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return diagnostics;
|
|
672
|
+
}
|
|
673
|
+
function detectMdTodoInDoc(content, lines, filePath) {
|
|
674
|
+
const diagnostics = [];
|
|
675
|
+
const todoPattern = /\b(TODO|FIXME|HACK|XXX|BUG)\b/gi;
|
|
676
|
+
let reported = 0;
|
|
677
|
+
for (const { num, text } of lines) {
|
|
678
|
+
todoPattern.lastIndex = 0;
|
|
679
|
+
const match = todoPattern.exec(text);
|
|
680
|
+
if (match) {
|
|
681
|
+
const marker = match[1].toUpperCase();
|
|
682
|
+
diagnostics.push(makeDiagnostic({
|
|
683
|
+
filePath,
|
|
684
|
+
rule: "md/todo-in-doc",
|
|
685
|
+
message: `${marker} marker found in documentation — resolve or track in issue tracker`,
|
|
686
|
+
line: num,
|
|
687
|
+
column: match.index + 1,
|
|
688
|
+
severity: "info",
|
|
689
|
+
category: "dead-code",
|
|
690
|
+
help: "Create a tracking issue for this TODO/FIXME and reference it in the document, or resolve it",
|
|
691
|
+
fixable: false,
|
|
692
|
+
detail: { marker }
|
|
693
|
+
}));
|
|
694
|
+
reported++;
|
|
695
|
+
if (reported >= 10) break;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
return diagnostics;
|
|
699
|
+
}
|
|
700
|
+
function detectMdMissingFencedLang(content, lines, filePath) {
|
|
701
|
+
const diagnostics = [];
|
|
702
|
+
for (const { num, text } of lines) {
|
|
703
|
+
const trimmed = text.trim();
|
|
704
|
+
if (/^```+\s*$/.test(trimmed) || /^~~~+\s*$/.test(trimmed)) diagnostics.push(makeDiagnostic({
|
|
705
|
+
filePath,
|
|
706
|
+
rule: "md/missing-fenced-lang",
|
|
707
|
+
message: "Fenced code block without language specification — disables syntax highlighting",
|
|
708
|
+
line: num,
|
|
709
|
+
severity: "suggestion",
|
|
710
|
+
category: "style",
|
|
711
|
+
help: "Add a language identifier after the fence: ```typescript, ```python, ```bash, etc.",
|
|
712
|
+
fixable: true,
|
|
713
|
+
suggestion: {
|
|
714
|
+
type: "replace",
|
|
715
|
+
text: trimmed.replace(/^(```+|~~~+)/, "$1text"),
|
|
716
|
+
confidence: .6,
|
|
717
|
+
reason: "Language-specific syntax highlighting improves readability of code blocks"
|
|
718
|
+
}
|
|
719
|
+
}));
|
|
720
|
+
}
|
|
721
|
+
return diagnostics;
|
|
722
|
+
}
|
|
723
|
+
const markupLintEngine = {
|
|
724
|
+
name: "markup-lint",
|
|
725
|
+
description: "Quality checks for JSON, YAML, CSS, HTML, and Markdown files",
|
|
726
|
+
supportedLanguages: [
|
|
727
|
+
"typescript",
|
|
728
|
+
"javascript",
|
|
729
|
+
"tsx",
|
|
730
|
+
"jsx",
|
|
731
|
+
"python",
|
|
732
|
+
"go",
|
|
733
|
+
"rust",
|
|
734
|
+
"ruby",
|
|
735
|
+
"php",
|
|
736
|
+
"java",
|
|
737
|
+
"csharp",
|
|
738
|
+
"swift"
|
|
739
|
+
],
|
|
740
|
+
async run(context) {
|
|
741
|
+
const start = Date.now();
|
|
742
|
+
const diagnostics = [];
|
|
743
|
+
const { rootDirectory, config, files: specifiedFiles } = context;
|
|
744
|
+
const filePaths = specifiedFiles ? specifiedFiles.filter(isMarkupFile) : await collectMarkupFiles(rootDirectory, config.exclude);
|
|
745
|
+
if (filePaths.length === 0) return {
|
|
746
|
+
engine: this.name,
|
|
747
|
+
diagnostics: [],
|
|
748
|
+
elapsed: Date.now() - start,
|
|
749
|
+
skipped: true,
|
|
750
|
+
skipReason: "No JSON/YAML/CSS/HTML/Markdown files found to analyze"
|
|
751
|
+
};
|
|
752
|
+
for (const fp of filePaths) try {
|
|
753
|
+
const content = await readFileContent(fp);
|
|
754
|
+
const relPath = relative(rootDirectory, fp);
|
|
755
|
+
const lines = toLines(content);
|
|
756
|
+
const type = fileType(fp);
|
|
757
|
+
if (type === "json") {
|
|
758
|
+
diagnostics.push(...detectJsonTrailingComma(content, lines, relPath));
|
|
759
|
+
diagnostics.push(...detectJsonDuplicateKeys(content, lines, relPath));
|
|
760
|
+
diagnostics.push(...detectJsonInconsistentSpacing(content, lines, relPath));
|
|
761
|
+
diagnostics.push(...detectJsonDeepNesting(content, lines, relPath));
|
|
762
|
+
}
|
|
763
|
+
if (type === "yaml") {
|
|
764
|
+
diagnostics.push(...detectYamlTabIndent(content, lines, relPath));
|
|
765
|
+
diagnostics.push(...detectYamlDuplicateKeys(content, lines, relPath));
|
|
766
|
+
diagnostics.push(...detectYamlComplexAnchor(content, lines, relPath));
|
|
767
|
+
diagnostics.push(...detectYamlMultiDocUnseparated(content, lines, relPath));
|
|
768
|
+
}
|
|
769
|
+
if (type === "css") {
|
|
770
|
+
diagnostics.push(...await detectCssUnusedSelector(content, lines, relPath, context));
|
|
771
|
+
diagnostics.push(...detectCssImportantOveruse(content, lines, relPath));
|
|
772
|
+
diagnostics.push(...detectCssDuplicateProperty(content, lines, relPath));
|
|
773
|
+
diagnostics.push(...detectCssUniversalSelector(content, lines, relPath));
|
|
774
|
+
}
|
|
775
|
+
if (type === "html") {
|
|
776
|
+
diagnostics.push(...detectHtmlMissingAlt(content, lines, relPath));
|
|
777
|
+
diagnostics.push(...detectHtmlMissingLang(content, lines, relPath));
|
|
778
|
+
diagnostics.push(...detectHtmlDeprecatedTag(content, lines, relPath));
|
|
779
|
+
diagnostics.push(...detectHtmlInlineEventHandler(content, lines, relPath));
|
|
780
|
+
}
|
|
781
|
+
if (type === "markdown") {
|
|
782
|
+
diagnostics.push(...detectMdBrokenLink(content, lines, relPath));
|
|
783
|
+
diagnostics.push(...detectMdInconsistentHeading(content, lines, relPath));
|
|
784
|
+
diagnostics.push(...detectMdTodoInDoc(content, lines, relPath));
|
|
785
|
+
diagnostics.push(...detectMdMissingFencedLang(content, lines, relPath));
|
|
786
|
+
}
|
|
787
|
+
} catch {}
|
|
788
|
+
const seen = /* @__PURE__ */ new Set();
|
|
789
|
+
const unique = diagnostics.filter((d) => {
|
|
790
|
+
const key = `${d.filePath}:${d.line}:${d.rule}`;
|
|
791
|
+
if (seen.has(key)) return false;
|
|
792
|
+
seen.add(key);
|
|
793
|
+
return true;
|
|
794
|
+
});
|
|
795
|
+
return {
|
|
796
|
+
engine: this.name,
|
|
797
|
+
diagnostics: unique,
|
|
798
|
+
elapsed: Date.now() - start,
|
|
799
|
+
skipped: false
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
//#endregion
|
|
805
|
+
export { markupLintEngine };
|