@noctcore/lint-meta-rules 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/README.md +65 -0
- package/dist/index.cjs +748 -0
- package/dist/index.d.cts +395 -0
- package/dist/index.d.ts +395 -0
- package/dist/index.js +706 -0
- package/docs/rules/agents-doc-presence.md +40 -0
- package/docs/rules/canonical-helpers-single-home.md +35 -0
- package/docs/rules/file-size-ratchet.md +72 -0
- package/docs/rules/layer-rank.md +44 -0
- package/docs/rules/no-cloned-component-folders.md +40 -0
- package/docs/rules/no-warn-severity.md +34 -0
- package/docs/rules/package-shape.md +49 -0
- package/docs/rules/test-runner-segregation.md +40 -0
- package/docs/rules/test-sibling-enforcement.md +35 -0
- package/docs/rules/test-workspace-enrollment.md +41 -0
- package/docs/rules/ui-primitive-shape.md +41 -0
- package/docs/rules/workspace-graph-parity.md +41 -0
- package/package.json +56 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __defProp = Object.defineProperty;
|
|
3
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
4
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
5
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
6
|
+
var __export = (target, all) => {
|
|
7
|
+
for (var name in all)
|
|
8
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
9
|
+
};
|
|
10
|
+
var __copyProps = (to, from, except, desc) => {
|
|
11
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
12
|
+
for (let key of __getOwnPropNames(from))
|
|
13
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
14
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
15
|
+
}
|
|
16
|
+
return to;
|
|
17
|
+
};
|
|
18
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
19
|
+
|
|
20
|
+
// src/index.ts
|
|
21
|
+
var index_exports = {};
|
|
22
|
+
__export(index_exports, {
|
|
23
|
+
RULE_FACTORIES: () => RULE_FACTORIES,
|
|
24
|
+
RULE_IDS: () => RULE_IDS,
|
|
25
|
+
countLines: () => countLines,
|
|
26
|
+
createAgentsDocPresenceRule: () => createAgentsDocPresenceRule,
|
|
27
|
+
createAllRules: () => createAllRules,
|
|
28
|
+
createCanonicalHelpersSingleHomeRule: () => createCanonicalHelpersSingleHomeRule,
|
|
29
|
+
createFileSizeRatchetRule: () => createFileSizeRatchetRule,
|
|
30
|
+
createLayerRankRule: () => createLayerRankRule,
|
|
31
|
+
createNoClonedComponentFoldersRule: () => createNoClonedComponentFoldersRule,
|
|
32
|
+
createNoWarnSeverityRule: () => createNoWarnSeverityRule,
|
|
33
|
+
createPackageShapeRule: () => createPackageShapeRule,
|
|
34
|
+
createTestRunnerSegregationRule: () => createTestRunnerSegregationRule,
|
|
35
|
+
createTestSiblingEnforcementRule: () => createTestSiblingEnforcementRule,
|
|
36
|
+
createTestWorkspaceEnrollmentRule: () => createTestWorkspaceEnrollmentRule,
|
|
37
|
+
createUiPrimitiveShapeRule: () => createUiPrimitiveShapeRule,
|
|
38
|
+
createWorkspaceGraphParityRule: () => createWorkspaceGraphParityRule
|
|
39
|
+
});
|
|
40
|
+
module.exports = __toCommonJS(index_exports);
|
|
41
|
+
|
|
42
|
+
// src/rules/shared.ts
|
|
43
|
+
function dirOf(rel, basename = "package.json") {
|
|
44
|
+
return rel.replace(new RegExp(`/${escapeRegExp(basename)}$`), "");
|
|
45
|
+
}
|
|
46
|
+
function baseName(pathLike) {
|
|
47
|
+
const parts = pathLike.split("/").filter(Boolean);
|
|
48
|
+
return parts[parts.length - 1] ?? pathLike;
|
|
49
|
+
}
|
|
50
|
+
function escapeRegExp(value) {
|
|
51
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
52
|
+
}
|
|
53
|
+
function countLines(text) {
|
|
54
|
+
const lines = text.split("\n");
|
|
55
|
+
if (lines[lines.length - 1] === "") lines.pop();
|
|
56
|
+
return lines.length;
|
|
57
|
+
}
|
|
58
|
+
function recursiveGlobs(roots, extensions) {
|
|
59
|
+
return roots.flatMap((root) => extensions.map((ext) => `${root}/**/*${ext}`));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/rules/agents-doc-presence.ts
|
|
63
|
+
function createAgentsDocPresenceRule(options = {}) {
|
|
64
|
+
const docFile = options.docFile ?? "AGENTS.md";
|
|
65
|
+
const requireAtRoot = options.requireAtRoot ?? true;
|
|
66
|
+
const surfaceGlobs = options.surfaceGlobs ?? ["apps/*/package.json"];
|
|
67
|
+
const packageGlobs = options.packageGlobs ?? ["packages/*/package.json"];
|
|
68
|
+
const optOut = new Set(options.optOut ?? []);
|
|
69
|
+
const ciCritical = options.ciCritical ?? true;
|
|
70
|
+
return {
|
|
71
|
+
id: "agents-doc-presence",
|
|
72
|
+
category: "source-text",
|
|
73
|
+
ciCritical,
|
|
74
|
+
description: `${docFile} must exist at the repo root, every surface, and every non-opted-out package.`,
|
|
75
|
+
run(ctx) {
|
|
76
|
+
const violations = [];
|
|
77
|
+
const requireDoc = (dir, label) => {
|
|
78
|
+
const rel = dir === "" ? docFile : `${dir}/${docFile}`;
|
|
79
|
+
if (!ctx.exists(rel)) {
|
|
80
|
+
violations.push({
|
|
81
|
+
file: rel,
|
|
82
|
+
rule: "agents-doc-presence",
|
|
83
|
+
message: `${label} is missing an ${docFile} agent contract. Add one (or, for a trivial leaf package, add it to the opt-out list with justification).`
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
if (requireAtRoot) requireDoc("", "The repo root");
|
|
88
|
+
for (const pkg of surfaceGlobs.flatMap((g) => ctx.glob(g))) {
|
|
89
|
+
requireDoc(dirOf(pkg), "Surface");
|
|
90
|
+
}
|
|
91
|
+
for (const pkg of packageGlobs.flatMap((g) => ctx.glob(g))) {
|
|
92
|
+
const dir = dirOf(pkg);
|
|
93
|
+
if (optOut.has(dir)) continue;
|
|
94
|
+
requireDoc(dir, "Package");
|
|
95
|
+
}
|
|
96
|
+
return violations;
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// src/rules/canonical-helpers-single-home.ts
|
|
102
|
+
var EXPORT_DECL_RE = /\bexport\s+(?:async\s+)?(?:function|const|let|var)\s+([A-Za-z_$][A-Za-z0-9_$]*)/g;
|
|
103
|
+
var EXPORT_LIST_RE = /\bexport\s*\{\s*([^}]+?)\s*\}/g;
|
|
104
|
+
function extractExportedNames(content) {
|
|
105
|
+
const names = /* @__PURE__ */ new Set();
|
|
106
|
+
let m;
|
|
107
|
+
EXPORT_DECL_RE.lastIndex = 0;
|
|
108
|
+
while ((m = EXPORT_DECL_RE.exec(content)) !== null) {
|
|
109
|
+
if (m[1] !== void 0) names.add(m[1]);
|
|
110
|
+
}
|
|
111
|
+
EXPORT_LIST_RE.lastIndex = 0;
|
|
112
|
+
while ((m = EXPORT_LIST_RE.exec(content)) !== null) {
|
|
113
|
+
const list = m[1] ?? "";
|
|
114
|
+
for (const part of list.split(",")) {
|
|
115
|
+
const ident = part.trim().split(/\s+as\s+/i)[0]?.trim() ?? "";
|
|
116
|
+
if (/^[A-Za-z_$]/.test(ident)) names.add(ident);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return Array.from(names);
|
|
120
|
+
}
|
|
121
|
+
function createCanonicalHelpersSingleHomeRule(options = {}) {
|
|
122
|
+
const include = options.include ?? ["apps/web/src/**/*.utils.ts"];
|
|
123
|
+
const excludeContains = options.excludeContains ?? ["/lib/"];
|
|
124
|
+
const ciCritical = options.ciCritical ?? true;
|
|
125
|
+
return {
|
|
126
|
+
id: "canonical-helpers-single-home",
|
|
127
|
+
category: "source-text",
|
|
128
|
+
ciCritical,
|
|
129
|
+
description: "Pure helpers must live in one canonical home (flag the same exported symbol appearing in multiple homes).",
|
|
130
|
+
run(ctx) {
|
|
131
|
+
const violations = [];
|
|
132
|
+
const files = include.flatMap((g) => ctx.glob(g)).filter((f) => !excludeContains.some((frag) => f.includes(frag)));
|
|
133
|
+
const nameToHomes = {};
|
|
134
|
+
for (const file of files) {
|
|
135
|
+
const content = ctx.read(file) ?? "";
|
|
136
|
+
for (const name of extractExportedNames(content)) {
|
|
137
|
+
(nameToHomes[name] ??= []).push(file);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const [name, homes] of Object.entries(nameToHomes)) {
|
|
141
|
+
const first = homes[0];
|
|
142
|
+
if (homes.length > 1 && first !== void 0) {
|
|
143
|
+
violations.push({
|
|
144
|
+
file: first,
|
|
145
|
+
rule: "canonical-helpers-single-home",
|
|
146
|
+
message: `Helper '${name}' lives in multiple homes (${homes.join(", ")}) \u2014 consolidate to the canonical one.`
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return violations;
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/rules/file-size-ratchet.ts
|
|
156
|
+
var import_harness = require("@noctcore/harness");
|
|
157
|
+
var DEFAULT_EXCLUDE_CONTAINS = [".test.", ".spec.", ".stories."];
|
|
158
|
+
function createFileSizeRatchetRule(options = {}) {
|
|
159
|
+
const id = options.id ?? "file-size-ratchet";
|
|
160
|
+
const roots = options.roots ?? [];
|
|
161
|
+
const extensions = options.extensions ?? [".ts", ".tsx"];
|
|
162
|
+
const cap = options.cap ?? 400;
|
|
163
|
+
const excludeContains = options.excludeContains ?? DEFAULT_EXCLUDE_CONTAINS;
|
|
164
|
+
const excludePrefixes = options.excludePrefixes ?? [];
|
|
165
|
+
const tightenRatio = options.tightenRatio ?? 0.85;
|
|
166
|
+
const baselineDir = options.baselineDir ?? import_harness.DEFAULT_BASELINE_DIR;
|
|
167
|
+
const ciCritical = options.ciCritical ?? true;
|
|
168
|
+
function sourceFiles(ctx) {
|
|
169
|
+
return recursiveGlobs(roots, extensions).flatMap((pattern) => ctx.glob(pattern)).filter(
|
|
170
|
+
(rel) => !excludeContains.some((frag) => rel.includes(frag)) && !excludePrefixes.some((prefix) => rel.startsWith(prefix))
|
|
171
|
+
).sort();
|
|
172
|
+
}
|
|
173
|
+
function currentOffenders(ctx) {
|
|
174
|
+
const map = {};
|
|
175
|
+
for (const file of sourceFiles(ctx)) {
|
|
176
|
+
const text = ctx.read(file);
|
|
177
|
+
if (text === null) continue;
|
|
178
|
+
const n = countLines(text);
|
|
179
|
+
if (n > cap) map[file] = n;
|
|
180
|
+
}
|
|
181
|
+
return map;
|
|
182
|
+
}
|
|
183
|
+
return {
|
|
184
|
+
id,
|
|
185
|
+
category: "source-text",
|
|
186
|
+
ciCritical,
|
|
187
|
+
description: `Source files stay at or under ${cap} raw lines. Today's offenders are grandfathered by ${baselineDir}/${id}.json; a new/grown offender fails, and a stale/shrunk baseline entry demands tightening.`,
|
|
188
|
+
baseline(ctx) {
|
|
189
|
+
return currentOffenders(ctx);
|
|
190
|
+
},
|
|
191
|
+
run(ctx) {
|
|
192
|
+
const baseline = (0, import_harness.loadBaseline)(ctx, id, baselineDir);
|
|
193
|
+
const violations = [];
|
|
194
|
+
for (const [file, n] of Object.entries(currentOffenders(ctx))) {
|
|
195
|
+
if ((0, import_harness.isGrandfathered)(baseline, file, n)) {
|
|
196
|
+
console.error(
|
|
197
|
+
`[grandfathered] ${id} (${file}): ${n} lines frozen by baseline (cap ${cap}) \u2014 split to ratchet down.`
|
|
198
|
+
);
|
|
199
|
+
} else {
|
|
200
|
+
violations.push({
|
|
201
|
+
file,
|
|
202
|
+
rule: id,
|
|
203
|
+
message: `file exceeds the ${cap}-line cap: ${n} lines. New files never join the baseline and frozen files may not grow \u2014 split it.`
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
for (const [file, frozen] of Object.entries(baseline)) {
|
|
208
|
+
const text = ctx.read(file);
|
|
209
|
+
if (text === null) {
|
|
210
|
+
violations.push({
|
|
211
|
+
file,
|
|
212
|
+
rule: id,
|
|
213
|
+
message: `baseline entry is stale \u2014 the file no longer exists. Remove it and update the baseline.`
|
|
214
|
+
});
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const n = countLines(text);
|
|
218
|
+
if (n <= cap) {
|
|
219
|
+
violations.push({
|
|
220
|
+
file,
|
|
221
|
+
rule: id,
|
|
222
|
+
message: `baseline entry is stale \u2014 the file is now within the ${cap}-line cap (${n} lines). Remove it and update the baseline.`
|
|
223
|
+
});
|
|
224
|
+
} else if (n < frozen * tightenRatio) {
|
|
225
|
+
violations.push({
|
|
226
|
+
file,
|
|
227
|
+
rule: id,
|
|
228
|
+
message: `baseline entry is over-generous \u2014 the file shrank below its frozen ${frozen} (now ${n} lines). Tighten it and update the baseline.`
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return violations;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// src/rules/layer-rank.ts
|
|
238
|
+
var DEFAULT_SOURCE_GLOBS = [
|
|
239
|
+
"packages/*/src/**/*.ts",
|
|
240
|
+
"packages/*/src/**/*.tsx",
|
|
241
|
+
"apps/*/src/**/*.ts",
|
|
242
|
+
"apps/*/src/**/*.tsx"
|
|
243
|
+
];
|
|
244
|
+
function createLayerRankRule(options = {}) {
|
|
245
|
+
const scope = options.scope ?? "@nightcore";
|
|
246
|
+
const ranks = options.ranks ?? {};
|
|
247
|
+
const surfacePrefix = options.surfacePrefix ?? "apps/";
|
|
248
|
+
const surfaceRank = options.surfaceRank;
|
|
249
|
+
const dirPattern = new RegExp(options.packageDirPattern ?? "^packages/([^/]+)/");
|
|
250
|
+
const sourceGlobs = options.sourceGlobs ?? DEFAULT_SOURCE_GLOBS;
|
|
251
|
+
const ciCritical = options.ciCritical ?? true;
|
|
252
|
+
const importRe = new RegExp(`from\\s+['"]${escapeRegExp(scope)}\\/([a-z0-9-]+)`, "g");
|
|
253
|
+
function importerRank(rel) {
|
|
254
|
+
if (surfaceRank !== void 0 && rel.startsWith(surfacePrefix)) return surfaceRank;
|
|
255
|
+
const m = dirPattern.exec(rel);
|
|
256
|
+
const key = m?.[1];
|
|
257
|
+
if (key === void 0) return null;
|
|
258
|
+
return ranks[key] ?? null;
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
id: "layer-rank",
|
|
262
|
+
category: "source-text",
|
|
263
|
+
ciCritical,
|
|
264
|
+
description: "Fixed dependency direction by rank: a module imports only strictly-lower-ranked <scope> packages (equal/upward forbidden).",
|
|
265
|
+
run(ctx) {
|
|
266
|
+
const violations = [];
|
|
267
|
+
const files = sourceGlobs.flatMap((g) => ctx.glob(g));
|
|
268
|
+
for (const f of files) {
|
|
269
|
+
if (f.endsWith(".test.ts") || f.endsWith(".test.tsx")) continue;
|
|
270
|
+
const fromRank = importerRank(f);
|
|
271
|
+
if (fromRank === null) continue;
|
|
272
|
+
const body = ctx.read(f) ?? "";
|
|
273
|
+
for (const m of body.matchAll(importRe)) {
|
|
274
|
+
const target = m[1];
|
|
275
|
+
if (target === void 0) continue;
|
|
276
|
+
const toRank = ranks[target];
|
|
277
|
+
if (toRank === void 0) continue;
|
|
278
|
+
if (toRank >= fromRank) {
|
|
279
|
+
const kind = toRank === fromRank ? "sideways" : "upward";
|
|
280
|
+
violations.push({
|
|
281
|
+
file: f,
|
|
282
|
+
rule: "layer-rank",
|
|
283
|
+
message: `Forbidden ${kind} import: ${scope}/${target} (rank ${toRank}) from a rank-${fromRank} module. A module may import only strictly-lower-ranked ${scope} packages. Add a fa\xE7ade/bridge seam instead of a new edge.`
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return violations;
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// src/rules/no-cloned-component-folders.ts
|
|
294
|
+
function createNoClonedComponentFoldersRule(options = {}) {
|
|
295
|
+
const componentsRoot = options.componentsRoot ?? "apps/web/src/components";
|
|
296
|
+
const barrelFile = options.barrelFile ?? "index.ts";
|
|
297
|
+
const excludedFeatures = new Set(options.excludedFeatures ?? ["ui", "app"]);
|
|
298
|
+
const allowedClones = new Set(options.allowedClones ?? []);
|
|
299
|
+
const sharedDest = options.sharedDest ?? "components/ui";
|
|
300
|
+
const ciCritical = options.ciCritical ?? true;
|
|
301
|
+
return {
|
|
302
|
+
id: "no-cloned-component-folders",
|
|
303
|
+
category: "source-text",
|
|
304
|
+
ciCritical,
|
|
305
|
+
description: "A component folder name may exist under only ONE feature. Shared surfaces are hoisted; divergent ones get a divergent name. Today\u2019s clones are frozen in a shrinking allowlist.",
|
|
306
|
+
run(ctx) {
|
|
307
|
+
const violations = [];
|
|
308
|
+
const byName = /* @__PURE__ */ new Map();
|
|
309
|
+
for (const rel of ctx.glob(`${componentsRoot}/*/*/${barrelFile}`)) {
|
|
310
|
+
const segments = rel.split("/");
|
|
311
|
+
const name = segments[segments.length - 2];
|
|
312
|
+
const feature = segments[segments.length - 3];
|
|
313
|
+
if (name === void 0 || feature === void 0) continue;
|
|
314
|
+
if (excludedFeatures.has(feature)) continue;
|
|
315
|
+
const features = byName.get(name) ?? [];
|
|
316
|
+
features.push(feature);
|
|
317
|
+
byName.set(name, features);
|
|
318
|
+
}
|
|
319
|
+
for (const [name, features] of byName) {
|
|
320
|
+
if (features.length < 2 || allowedClones.has(name)) continue;
|
|
321
|
+
const sorted = [...features].sort();
|
|
322
|
+
violations.push({
|
|
323
|
+
file: `${componentsRoot}/{${sorted.join(",")}}/${name}`,
|
|
324
|
+
rule: "no-cloned-component-folders",
|
|
325
|
+
message: `Component folder '${name}' is cloned across ${features.length} features (${sorted.join(", ")}) \u2014 clone drift in the making. Hoist it to ${sharedDest} or rename to reflect divergent semantics.`
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
for (const name of allowedClones) {
|
|
329
|
+
const features = byName.get(name) ?? [];
|
|
330
|
+
if (features.length < 2) {
|
|
331
|
+
violations.push({
|
|
332
|
+
file: `${componentsRoot}/*/${name}`,
|
|
333
|
+
rule: "no-cloned-component-folders",
|
|
334
|
+
message: `Allowlist entry '${name}' is stale \u2014 the clone group no longer exists. Remove it from allowedClones (the allowlist only shrinks).`
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return violations;
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// src/rules/no-warn-severity.ts
|
|
344
|
+
var DEFAULT_CONFIG_FILES = ["eslint.config.js", "eslint.config.mjs", "eslint.config.cjs"];
|
|
345
|
+
function createNoWarnSeverityRule(options = {}) {
|
|
346
|
+
const configFiles = options.configFiles ?? DEFAULT_CONFIG_FILES;
|
|
347
|
+
const ciCritical = options.ciCritical ?? true;
|
|
348
|
+
return {
|
|
349
|
+
id: "no-warn-severity",
|
|
350
|
+
category: "config",
|
|
351
|
+
ciCritical,
|
|
352
|
+
description: "ESLint severity is 'error' or 'off', never 'warn'. A rule that matters is an error; a failure is fixed, not silenced.",
|
|
353
|
+
run(ctx) {
|
|
354
|
+
const violations = [];
|
|
355
|
+
for (const configFile of configFiles) {
|
|
356
|
+
const config = ctx.read(configFile);
|
|
357
|
+
if (config === null) continue;
|
|
358
|
+
config.split(/\r?\n/).forEach((line, i) => {
|
|
359
|
+
const code = line.replace(/\r$/, "").replace(/\/\/.*$/, "");
|
|
360
|
+
if (/['"]warn['"]/.test(code)) {
|
|
361
|
+
violations.push({
|
|
362
|
+
file: configFile,
|
|
363
|
+
rule: "no-warn-severity",
|
|
364
|
+
message: `Line ${i + 1}: ESLint 'warn' severity is forbidden \u2014 use 'error' or 'off'.`,
|
|
365
|
+
line: i + 1
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
return violations;
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// src/rules/package-shape.ts
|
|
376
|
+
function createPackageShapeRule(options = {}) {
|
|
377
|
+
const scope = options.scope ?? "@nightcore";
|
|
378
|
+
const libraryGlobs = options.libraryGlobs ?? ["packages/*/package.json"];
|
|
379
|
+
const appGlobs = options.appGlobs ?? ["apps/*/package.json"];
|
|
380
|
+
const externalNames = options.externalNames ?? {};
|
|
381
|
+
const barrelPath = options.barrelPath ?? "src/index.ts";
|
|
382
|
+
const distMarker = options.distMarker ?? "dist/";
|
|
383
|
+
const distFields = options.distFields ?? ["main", "module", "types"];
|
|
384
|
+
const ciCritical = options.ciCritical ?? true;
|
|
385
|
+
return {
|
|
386
|
+
id: "package-shape",
|
|
387
|
+
category: "config",
|
|
388
|
+
ciCritical,
|
|
389
|
+
description: "Every workspace is named <scope>/<dir>; library packages expose a barrel and point main/module/types/exports at the built output.",
|
|
390
|
+
run(ctx) {
|
|
391
|
+
const violations = [];
|
|
392
|
+
const groups = [
|
|
393
|
+
{ globs: libraryGlobs, library: true },
|
|
394
|
+
{ globs: appGlobs, library: false }
|
|
395
|
+
];
|
|
396
|
+
for (const group of groups) {
|
|
397
|
+
const rels = group.globs.flatMap((g) => ctx.glob(g));
|
|
398
|
+
for (const rel of rels) {
|
|
399
|
+
const raw = ctx.read(rel);
|
|
400
|
+
if (raw === null) continue;
|
|
401
|
+
let pkg;
|
|
402
|
+
try {
|
|
403
|
+
pkg = JSON.parse(raw);
|
|
404
|
+
} catch {
|
|
405
|
+
violations.push({ file: rel, rule: "package-shape", message: "package.json is not valid JSON." });
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
const dir = dirOf(rel);
|
|
409
|
+
const expected = externalNames[dir] ?? `${scope}/${baseName(dir)}`;
|
|
410
|
+
if (pkg.name !== expected) {
|
|
411
|
+
violations.push({
|
|
412
|
+
file: rel,
|
|
413
|
+
rule: "package-shape",
|
|
414
|
+
message: `Workspace name '${String(pkg.name)}' must equal '${expected}' (the directory it lives in).`
|
|
415
|
+
});
|
|
416
|
+
}
|
|
417
|
+
if (!group.library) continue;
|
|
418
|
+
if (!ctx.exists(`${dir}/${barrelPath}`)) {
|
|
419
|
+
violations.push({
|
|
420
|
+
file: rel,
|
|
421
|
+
rule: "package-shape",
|
|
422
|
+
message: `Library package must expose a single barrel at ${dir}/${barrelPath}.`
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
for (const field of distFields) {
|
|
426
|
+
const val = pkg[field];
|
|
427
|
+
if (typeof val === "string" && !val.includes(distMarker)) {
|
|
428
|
+
violations.push({
|
|
429
|
+
file: rel,
|
|
430
|
+
rule: "package-shape",
|
|
431
|
+
message: `package.json "${field}" ('${val}') must point at the built output containing '${distMarker}'.`
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
if (pkg.exports && !JSON.stringify(pkg.exports).includes(distMarker)) {
|
|
436
|
+
violations.push({
|
|
437
|
+
file: rel,
|
|
438
|
+
rule: "package-shape",
|
|
439
|
+
message: `package.json "exports" must reference the built output containing '${distMarker}'.`
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
return violations;
|
|
445
|
+
}
|
|
446
|
+
};
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/rules/test-runner-segregation.ts
|
|
450
|
+
function createTestRunnerSegregationRule(options = {}) {
|
|
451
|
+
const bunPackageGlobs = options.bunPackageGlobs ?? ["packages/*/package.json"];
|
|
452
|
+
const bunExtraDirs = options.bunExtraDirs ?? [];
|
|
453
|
+
const vitestDirs = options.vitestDirs ?? [];
|
|
454
|
+
const bunRunnerImport = options.bunRunnerImport ?? "bun:test";
|
|
455
|
+
const foreignRunnerImport = options.foreignRunnerImport ?? "vitest";
|
|
456
|
+
const ciCritical = options.ciCritical ?? true;
|
|
457
|
+
const importRe = (spec) => new RegExp(`from\\s+['"]${escapeRegExp(spec)}['"]`);
|
|
458
|
+
const bunRe = importRe(bunRunnerImport);
|
|
459
|
+
const foreignRe = importRe(foreignRunnerImport);
|
|
460
|
+
return {
|
|
461
|
+
id: "test-runner-segregation",
|
|
462
|
+
category: "testing",
|
|
463
|
+
ciCritical,
|
|
464
|
+
description: `Bun-side packages use '${bunRunnerImport}'; foreign-side packages use '${foreignRunnerImport}'. Never mix runners.`,
|
|
465
|
+
run(ctx) {
|
|
466
|
+
const violations = [];
|
|
467
|
+
const vitestSet = new Set(vitestDirs);
|
|
468
|
+
const pkgDirs = bunPackageGlobs.flatMap((g) => ctx.glob(g)).map((p) => dirOf(p));
|
|
469
|
+
const bunDirs = [...pkgDirs.filter((d) => !vitestSet.has(d)), ...bunExtraDirs];
|
|
470
|
+
const testsIn = (dir) => [
|
|
471
|
+
...ctx.glob(`${dir}/**/*.test.ts`),
|
|
472
|
+
...ctx.glob(`${dir}/**/*.test.tsx`)
|
|
473
|
+
];
|
|
474
|
+
for (const dir of bunDirs) {
|
|
475
|
+
for (const f of testsIn(dir)) {
|
|
476
|
+
const body = ctx.read(f) ?? "";
|
|
477
|
+
if (foreignRe.test(body)) {
|
|
478
|
+
violations.push({
|
|
479
|
+
file: f,
|
|
480
|
+
rule: "test-runner-segregation",
|
|
481
|
+
message: `Bun-side test imports '${foreignRunnerImport}' \u2014 use '${bunRunnerImport}'.`
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
if (!bunRe.test(body)) {
|
|
485
|
+
violations.push({
|
|
486
|
+
file: f,
|
|
487
|
+
rule: "test-runner-segregation",
|
|
488
|
+
message: `Bun-side test must import its runner from '${bunRunnerImport}'.`
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
for (const dir of vitestDirs) {
|
|
494
|
+
for (const f of testsIn(dir)) {
|
|
495
|
+
const body = ctx.read(f) ?? "";
|
|
496
|
+
if (bunRe.test(body)) {
|
|
497
|
+
violations.push({
|
|
498
|
+
file: f,
|
|
499
|
+
rule: "test-runner-segregation",
|
|
500
|
+
message: `Foreign-side test imports '${bunRunnerImport}' \u2014 use '${foreignRunnerImport}'.`
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return violations;
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
// src/rules/test-sibling-enforcement.ts
|
|
511
|
+
function createTestSiblingEnforcementRule(options = {}) {
|
|
512
|
+
const include = options.include ?? ["apps/web/src/**/*.utils.ts"];
|
|
513
|
+
const testExtensions = options.testExtensions ?? [".test.ts", ".test.tsx"];
|
|
514
|
+
const ciCritical = options.ciCritical ?? true;
|
|
515
|
+
return {
|
|
516
|
+
id: "test-sibling-enforcement",
|
|
517
|
+
category: "source-text",
|
|
518
|
+
ciCritical,
|
|
519
|
+
description: "Every source file matched by `include` must have a colocated sibling test. Pure helpers must ship a test.",
|
|
520
|
+
run(ctx) {
|
|
521
|
+
const violations = [];
|
|
522
|
+
for (const file of include.flatMap((g) => ctx.glob(g))) {
|
|
523
|
+
const base = file.replace(/\.tsx?$/, "");
|
|
524
|
+
const siblings = testExtensions.map((ext) => `${base}${ext}`);
|
|
525
|
+
if (!siblings.some((sib) => ctx.exists(sib))) {
|
|
526
|
+
violations.push({
|
|
527
|
+
file,
|
|
528
|
+
rule: "test-sibling-enforcement",
|
|
529
|
+
message: `'${file}' is missing a colocated sibling test (${siblings.join(" or ")}). Pure helpers must ship a colocated test.`
|
|
530
|
+
});
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return violations;
|
|
534
|
+
}
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/rules/test-workspace-enrollment.ts
|
|
539
|
+
function createTestWorkspaceEnrollmentRule(options = {}) {
|
|
540
|
+
const scriptName = options.scriptName ?? "test:node";
|
|
541
|
+
const manifestPath = options.manifestPath ?? "package.json";
|
|
542
|
+
const packageGlobs = options.packageGlobs ?? ["packages/*/package.json"];
|
|
543
|
+
const extraDirs = options.extraDirs ?? [];
|
|
544
|
+
const excludeDirs = new Set(options.excludeDirs ?? []);
|
|
545
|
+
const testGlobSuffix = options.testGlobSuffix ?? "**/*.test.ts";
|
|
546
|
+
const ciCritical = options.ciCritical ?? true;
|
|
547
|
+
return {
|
|
548
|
+
id: "test-workspace-enrollment",
|
|
549
|
+
category: "testing",
|
|
550
|
+
ciCritical,
|
|
551
|
+
description: `Every candidate package with test files must be enumerated in the root '${scriptName}' script.`,
|
|
552
|
+
run(ctx) {
|
|
553
|
+
const root = ctx.read(manifestPath);
|
|
554
|
+
if (root === null) return [];
|
|
555
|
+
let scripts = {};
|
|
556
|
+
try {
|
|
557
|
+
scripts = JSON.parse(root).scripts ?? {};
|
|
558
|
+
} catch {
|
|
559
|
+
return [];
|
|
560
|
+
}
|
|
561
|
+
const script = scripts[scriptName] ?? "";
|
|
562
|
+
const violations = [];
|
|
563
|
+
const dirs = [
|
|
564
|
+
...packageGlobs.flatMap((g) => ctx.glob(g)).map((p) => dirOf(p)),
|
|
565
|
+
...extraDirs
|
|
566
|
+
];
|
|
567
|
+
for (const dir of dirs) {
|
|
568
|
+
if (excludeDirs.has(dir)) continue;
|
|
569
|
+
const hasTests = ctx.glob(`${dir}/${testGlobSuffix}`).length > 0;
|
|
570
|
+
if (!hasTests) continue;
|
|
571
|
+
if (!script.includes(dir)) {
|
|
572
|
+
violations.push({
|
|
573
|
+
file: manifestPath,
|
|
574
|
+
rule: "test-workspace-enrollment",
|
|
575
|
+
message: `Package '${dir}' has test files but is not listed in the '${scriptName}' script \u2014 its tests run in no CI gate. Add '${dir}' to '${scriptName}'.`
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
return violations;
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// src/rules/ui-primitive-shape.ts
|
|
585
|
+
function createUiPrimitiveShapeRule(options = {}) {
|
|
586
|
+
const uiRoot = options.uiRoot ?? "apps/web/src/components/ui";
|
|
587
|
+
const barrelFile = options.barrelFile ?? "index.ts";
|
|
588
|
+
const roles = options.roles ?? ["test", "stories"];
|
|
589
|
+
const extension = options.extension ?? ".tsx";
|
|
590
|
+
const ciCritical = options.ciCritical ?? true;
|
|
591
|
+
return {
|
|
592
|
+
id: "ui-primitive-shape",
|
|
593
|
+
category: "source-text",
|
|
594
|
+
ciCritical,
|
|
595
|
+
description: `A folder primitive under the ui root must ship its proof siblings (${roles.join(", ")}); a flat primitive must not carry sibling proof files at the root.`,
|
|
596
|
+
run(ctx) {
|
|
597
|
+
const violations = [];
|
|
598
|
+
for (const barrel of ctx.glob(`${uiRoot}/*/${barrelFile}`)) {
|
|
599
|
+
const dir = barrel.replace(new RegExp(`/${barrelFile}$`), "");
|
|
600
|
+
const name = baseName(dir);
|
|
601
|
+
for (const role of roles) {
|
|
602
|
+
const rel = `${dir}/${name}.${role}${extension}`;
|
|
603
|
+
if (!ctx.exists(rel)) {
|
|
604
|
+
violations.push({
|
|
605
|
+
file: dir,
|
|
606
|
+
rule: "ui-primitive-shape",
|
|
607
|
+
message: `ui folder-primitive '${name}' is missing ${name}.${role}${extension}. A ui primitive complex enough to be a folder must ship its proof siblings (or stay a flat presentational file).`
|
|
608
|
+
});
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
for (const flat of ctx.glob(`${uiRoot}/[A-Z]*${extension}`)) {
|
|
613
|
+
const name = baseName(flat).replace(new RegExp(`${extension.replace(/\./g, "\\.")}$`), "");
|
|
614
|
+
for (const role of roles) {
|
|
615
|
+
const sibling = `${uiRoot}/${name}.${role}${extension}`;
|
|
616
|
+
if (ctx.exists(sibling)) {
|
|
617
|
+
violations.push({
|
|
618
|
+
file: flat,
|
|
619
|
+
rule: "ui-primitive-shape",
|
|
620
|
+
message: `ui flat primitive '${name}${extension}' has a sibling ${name}.${role}${extension} at the ui root \u2014 move both into a '${name}/' folder with ${barrelFile}.`
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
return violations;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// src/rules/workspace-graph-parity.ts
|
|
631
|
+
function createWorkspaceGraphParityRule(options = {}) {
|
|
632
|
+
const scope = options.scope ?? "@nightcore";
|
|
633
|
+
const packageGlobs = options.packageGlobs ?? ["packages/*/package.json", "apps/*/package.json"];
|
|
634
|
+
const srcDir = options.srcDir ?? "src";
|
|
635
|
+
const ciCritical = options.ciCritical ?? true;
|
|
636
|
+
const esc = escapeRegExp(scope);
|
|
637
|
+
return {
|
|
638
|
+
id: "workspace-graph-parity",
|
|
639
|
+
category: "config",
|
|
640
|
+
ciCritical,
|
|
641
|
+
description: "Imported <scope>/* specifiers must be declared workspace:* deps, and tsconfig references must mirror those deps.",
|
|
642
|
+
run(ctx) {
|
|
643
|
+
const violations = [];
|
|
644
|
+
const declaredRe = new RegExp(`"(${esc}\\/[a-z0-9-]+)"\\s*:\\s*"workspace:[^"]*"`, "g");
|
|
645
|
+
const importRe = new RegExp(`from\\s+['"](${esc}\\/[a-z0-9-]+)`, "g");
|
|
646
|
+
for (const rel of packageGlobs.flatMap((g) => ctx.glob(g))) {
|
|
647
|
+
const raw = ctx.read(rel);
|
|
648
|
+
if (raw === null) continue;
|
|
649
|
+
const dir = dirOf(rel);
|
|
650
|
+
const self = `${scope}/${baseName(dir)}`;
|
|
651
|
+
const declared = new Set(
|
|
652
|
+
Array.from(raw.matchAll(declaredRe), (m) => m[1]).filter(
|
|
653
|
+
(s) => s !== void 0
|
|
654
|
+
)
|
|
655
|
+
);
|
|
656
|
+
const imported = /* @__PURE__ */ new Set();
|
|
657
|
+
const srcFiles = [
|
|
658
|
+
...ctx.glob(`${dir}/${srcDir}/**/*.ts`),
|
|
659
|
+
...ctx.glob(`${dir}/${srcDir}/**/*.tsx`)
|
|
660
|
+
];
|
|
661
|
+
for (const f of srcFiles) {
|
|
662
|
+
if (f.endsWith(".test.ts") || f.endsWith(".test.tsx")) continue;
|
|
663
|
+
const body = ctx.read(f) ?? "";
|
|
664
|
+
for (const m of body.matchAll(importRe)) {
|
|
665
|
+
const spec = m[1];
|
|
666
|
+
if (spec !== void 0 && spec !== self) imported.add(spec);
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
for (const spec of imported) {
|
|
670
|
+
if (!declared.has(spec)) {
|
|
671
|
+
violations.push({
|
|
672
|
+
file: rel,
|
|
673
|
+
rule: "workspace-graph-parity",
|
|
674
|
+
message: `${self} imports ${spec} but does not declare it as a "workspace:*" dependency. Add it to package.json.`
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
const tsconfig = ctx.read(`${dir}/tsconfig.json`);
|
|
679
|
+
if (tsconfig !== null) {
|
|
680
|
+
const refDirs = new Set(
|
|
681
|
+
Array.from(tsconfig.matchAll(/"path"\s*:\s*"([^"]+)"/g), (m) => {
|
|
682
|
+
const parts = (m[1] ?? "").split("/").filter(Boolean);
|
|
683
|
+
return `${scope}/${parts[parts.length - 1] ?? ""}`;
|
|
684
|
+
})
|
|
685
|
+
);
|
|
686
|
+
for (const dep of declared) {
|
|
687
|
+
if (!refDirs.has(dep)) {
|
|
688
|
+
violations.push({
|
|
689
|
+
file: `${dir}/tsconfig.json`,
|
|
690
|
+
rule: "workspace-graph-parity",
|
|
691
|
+
message: `tsconfig "references" is missing ${dep}, a declared workspace:* dependency. References must mirror deps.`
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
for (const ref of refDirs) {
|
|
696
|
+
if (!declared.has(ref)) {
|
|
697
|
+
violations.push({
|
|
698
|
+
file: `${dir}/tsconfig.json`,
|
|
699
|
+
rule: "workspace-graph-parity",
|
|
700
|
+
message: `tsconfig references ${ref} but it is not a declared workspace:* dependency. References must mirror deps.`
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
return violations;
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/rules/index.ts
|
|
712
|
+
var RULE_FACTORIES = {
|
|
713
|
+
"agents-doc-presence": createAgentsDocPresenceRule,
|
|
714
|
+
"canonical-helpers-single-home": createCanonicalHelpersSingleHomeRule,
|
|
715
|
+
"file-size-ratchet": createFileSizeRatchetRule,
|
|
716
|
+
"layer-rank": createLayerRankRule,
|
|
717
|
+
"no-cloned-component-folders": createNoClonedComponentFoldersRule,
|
|
718
|
+
"no-warn-severity": createNoWarnSeverityRule,
|
|
719
|
+
"package-shape": createPackageShapeRule,
|
|
720
|
+
"test-runner-segregation": createTestRunnerSegregationRule,
|
|
721
|
+
"test-sibling-enforcement": createTestSiblingEnforcementRule,
|
|
722
|
+
"test-workspace-enrollment": createTestWorkspaceEnrollmentRule,
|
|
723
|
+
"ui-primitive-shape": createUiPrimitiveShapeRule,
|
|
724
|
+
"workspace-graph-parity": createWorkspaceGraphParityRule
|
|
725
|
+
};
|
|
726
|
+
var RULE_IDS = Object.keys(RULE_FACTORIES);
|
|
727
|
+
function createAllRules() {
|
|
728
|
+
return Object.values(RULE_FACTORIES).map((factory) => factory());
|
|
729
|
+
}
|
|
730
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
731
|
+
0 && (module.exports = {
|
|
732
|
+
RULE_FACTORIES,
|
|
733
|
+
RULE_IDS,
|
|
734
|
+
countLines,
|
|
735
|
+
createAgentsDocPresenceRule,
|
|
736
|
+
createAllRules,
|
|
737
|
+
createCanonicalHelpersSingleHomeRule,
|
|
738
|
+
createFileSizeRatchetRule,
|
|
739
|
+
createLayerRankRule,
|
|
740
|
+
createNoClonedComponentFoldersRule,
|
|
741
|
+
createNoWarnSeverityRule,
|
|
742
|
+
createPackageShapeRule,
|
|
743
|
+
createTestRunnerSegregationRule,
|
|
744
|
+
createTestSiblingEnforcementRule,
|
|
745
|
+
createTestWorkspaceEnrollmentRule,
|
|
746
|
+
createUiPrimitiveShapeRule,
|
|
747
|
+
createWorkspaceGraphParityRule
|
|
748
|
+
});
|