@fantastic.dev/repo-gates 0.2.1-bootstrap.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.
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Per-repo policy for @fantastic.dev/repo-gates.
3
+ *
4
+ * The engine is repo-agnostic; everything that varies between repos lives
5
+ * here. A consumer repo drops a partial `repo-gates.config.json` at its
6
+ * root; anything it omits falls back to {@link DEFAULT_CONFIG} (tuned for
7
+ * a pnpm + turbo + vitest monorepo). The loader always resolves policy
8
+ * relative to the *consumer* repo root (default `process.cwd()`), never
9
+ * the package's own location — that is the whole point of extracting this
10
+ * into a package.
11
+ */
12
+ type GateSpec = {
13
+ /** The package.json script name, e.g. "typecheck" or "check:size". */
14
+ name: string;
15
+ /** Conditional gates run only when the consumer's package.json defines
16
+ * them; non-conditional gates in the selected manifest run even when the
17
+ * consumer does not define a matching script, so a missing required gate
18
+ * fails loudly. Consumers that replace the default manifest own that
19
+ * policy (for example, a coverage-backed test gate may replace `test`). */
20
+ conditional: boolean;
21
+ };
22
+ type RepoGatesConfig = {
23
+ /** How a gate script is invoked, e.g. "pnpm run" or "bun run". */
24
+ runner: string;
25
+ /** Ordered gate manifest — cheap gates first, the CI-parity meta-gate last. */
26
+ gates: GateSpec[];
27
+ /** Roots the file-size and debt-marker guards walk. */
28
+ scanRoots: string[];
29
+ /** Directory names pruned during scans (matched as a path segment). */
30
+ excludeDirSegments: string[];
31
+ /** Repo-relative posix path prefixes excluded from scans. */
32
+ excludePathPrefixes: string[];
33
+ /** Extensions the size/debt guards treat as source. */
34
+ sourceExtensions: string[];
35
+ fileSize: {
36
+ /** Default per-file line ceiling for files without an explicit budget. */
37
+ threshold: number;
38
+ /** Repo-relative path to the grandfathered per-file budgets JSON. */
39
+ budgetsPath: string;
40
+ };
41
+ debt: {
42
+ /** Repo-relative path to the grandfathered untracked-marker allowlist. */
43
+ allowlistPath: string;
44
+ /** Marker tokens that require a tracker reference on the same line. */
45
+ markerTokens: string[];
46
+ /** Source strings for the RegExps that count as a "tracker reference". */
47
+ trackerPatterns: string[];
48
+ };
49
+ circular: {
50
+ /** Repo-relative path to the grandfathered circular-import allowlist. */
51
+ allowlistPath: string;
52
+ };
53
+ secrets: {
54
+ /** Repo-relative path to the grandfathered secret-finding allowlist. */
55
+ allowlistPath: string;
56
+ /** Source strings for the RegExps matched against every git-tracked
57
+ * line (well-known credential shapes — AWS/GitHub/Slack/Stripe/npm/
58
+ * Google API keys, PEM private-key headers). Findings are reported as
59
+ * a redacted fingerprint, never the matched text. */
60
+ patterns: string[];
61
+ /** File extensions (with the dot) skipped as binary/non-text. */
62
+ binaryExtensions: string[];
63
+ };
64
+ coverage: {
65
+ /** Repo-relative path to the coverage floor JSON. */
66
+ budgetsPath: string;
67
+ /** Globs (repo-relative) matching each package's vitest
68
+ * `coverage-summary.json`. */
69
+ summaryGlobs: string[];
70
+ };
71
+ ciParity: {
72
+ /** Repo-relative path to the parity escape-hatch config. */
73
+ configPath: string;
74
+ /** The root gate every CI invocation must be reachable from. */
75
+ rootGate: string;
76
+ /** Entry points seeded into the reachability walk alongside the gates. */
77
+ entryGates: string[];
78
+ /** Only workflow files whose basename starts with this are gate workflows. */
79
+ workflowPrefix: string;
80
+ };
81
+ bundleSize: {
82
+ /** Repo-relative path to the bundle-size budgets JSON. */
83
+ budgetsPath: string;
84
+ /** Build+measure targets. Empty → the gate is a no-op (nothing to measure). */
85
+ targets: {
86
+ /** Budget key + display name, e.g. "web". */
87
+ name: string;
88
+ /** turbo `--filter` selector to build before measuring, e.g. "@x/web". */
89
+ filter: string;
90
+ /** Repo-relative dir of built assets to scan (hashed filenames ok). */
91
+ distDir: string;
92
+ /** Bucket name → file extensions, e.g. { "js": [".js"], "css": [".css"] }. */
93
+ buckets: Record<string, string[]>;
94
+ }[];
95
+ };
96
+ report: {
97
+ /** Globs matching per-package junit XML (report:test-timing). Empty ⇒ no-op. */
98
+ junitGlobs: string[];
99
+ /** How many slowest tests to list. */
100
+ topN: number;
101
+ };
102
+ agents: {
103
+ /** Repo-relative agent-doc files to validate (e.g. ["AGENTS.md"]). Empty ⇒ no-op. */
104
+ targets: string[];
105
+ /** Backticked paths in the docs that intentionally don't exist on disk
106
+ * (generated artifacts, runtime files, illustrative names). */
107
+ knownMissingPaths: string[];
108
+ /** Package-manager command whose script invocations are validated (e.g. "pnpm"). */
109
+ runnerCommand: string;
110
+ /** Subcommands of runnerCommand that are builtins, not scripts (ignored). */
111
+ ignoredSubcommands: string[];
112
+ };
113
+ docsCoverage: {
114
+ /** Globs (repo-relative) a PR must touch for a triggered surface to
115
+ * count as documented. Empty `surfaces` ⇒ the gate is a no-op. */
116
+ docsGlobs: string[];
117
+ /** User-facing surfaces that require docs when changed. */
118
+ surfaces: {
119
+ /** Human-readable label shown in the failure/notice output. */
120
+ label: string;
121
+ /** Glob matched against the PR's changed-file paths. */
122
+ glob: string;
123
+ /** "added" — only a brand-new file triggers; "changed" — added,
124
+ * modified, renamed, or copied all trigger. */
125
+ on: "added" | "changed";
126
+ }[];
127
+ /** Globs removed from BOTH surface and docs matching (tests, fixtures). */
128
+ exclude: string[];
129
+ };
130
+ };
131
+ declare const DEFAULT_CONFIG: RepoGatesConfig;
132
+ declare const CONFIG_FILENAMES: readonly ["repo-gates.config.json"];
133
+ type Ctx = {
134
+ repoRoot: string;
135
+ config: RepoGatesConfig;
136
+ };
137
+ type DeepPartial<T> = {
138
+ [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
139
+ };
140
+ /** One-level-deep merge: overlay's top-level object sections are merged
141
+ * key-by-key over the base; scalars and arrays replace wholesale. */
142
+ declare function mergeConfig(base: RepoGatesConfig, overlay: DeepPartial<RepoGatesConfig>): RepoGatesConfig;
143
+ declare function findConfigFile(repoRoot: string): string | undefined;
144
+ declare function loadConfig(repoRoot?: string): RepoGatesConfig;
145
+ declare function loadContext(repoRoot?: string): Ctx;
146
+
147
+ export { CONFIG_FILENAMES, type Ctx, DEFAULT_CONFIG, type GateSpec, type RepoGatesConfig, findConfigFile, loadConfig, loadContext, mergeConfig };
package/dist/config.js ADDED
@@ -0,0 +1,178 @@
1
+ // src/config.ts
2
+ import { existsSync, readFileSync } from "fs";
3
+ import { resolve } from "path";
4
+ var DEFAULT_CONFIG = {
5
+ runner: "pnpm run",
6
+ gates: [
7
+ { name: "lint", conditional: false },
8
+ { name: "check:design-system", conditional: true },
9
+ { name: "format:check", conditional: false },
10
+ { name: "typecheck", conditional: false },
11
+ { name: "check:scripts", conditional: true },
12
+ { name: "check:deps", conditional: true },
13
+ { name: "check:dups", conditional: true },
14
+ { name: "check:size", conditional: false },
15
+ { name: "check:debt", conditional: false },
16
+ { name: "check:circular", conditional: true },
17
+ { name: "check:secrets", conditional: true },
18
+ { name: "check:agents", conditional: true },
19
+ { name: "check:shadscan", conditional: true },
20
+ { name: "check:docs-coverage", conditional: true },
21
+ { name: "check:bundle-size", conditional: true },
22
+ { name: "test", conditional: false },
23
+ { name: "check:coverage", conditional: true },
24
+ { name: "check:ci-parity", conditional: false }
25
+ ],
26
+ scanRoots: ["apps", "packages", "scripts"],
27
+ excludeDirSegments: [
28
+ "node_modules",
29
+ "dist",
30
+ "dist-e2e",
31
+ "out",
32
+ ".turbo",
33
+ ".wrangler",
34
+ "__golden__",
35
+ "_generated",
36
+ "coverage"
37
+ ],
38
+ excludePathPrefixes: [],
39
+ sourceExtensions: [".ts", ".tsx"],
40
+ fileSize: { threshold: 600, budgetsPath: "gates/file-size-budgets.json" },
41
+ debt: {
42
+ allowlistPath: "gates/debt-marker-allowlist.json",
43
+ markerTokens: ["TODO", "FIXME", "HACK", "XXX"],
44
+ trackerPatterns: ["\\b[A-Z]{2,}-\\d+\\b", "#\\d+\\b", "https?:\\/\\/\\S+"]
45
+ },
46
+ circular: { allowlistPath: "gates/circular-imports-allowlist.json" },
47
+ secrets: {
48
+ allowlistPath: "gates/secrets-allowlist.json",
49
+ patterns: [
50
+ "\\bAKIA[0-9A-Z]{16}\\b",
51
+ // AWS access key
52
+ "\\bASIA[0-9A-Z]{16}\\b",
53
+ // AWS temporary access key
54
+ "\\bgh[pousr]_[A-Za-z0-9]{36,}\\b",
55
+ // GitHub personal/OAuth/app/refresh token
56
+ "\\bxox[baprs]-[A-Za-z0-9-]{10,}\\b",
57
+ // Slack token
58
+ "\\bsk_(?:live|test)_[A-Za-z0-9]{16,}\\b",
59
+ // Stripe secret key
60
+ "\\bnpm_[A-Za-z0-9]{36}\\b",
61
+ // npm access token
62
+ "\\bAIza[0-9A-Za-z_-]{35}\\b",
63
+ // Google API key
64
+ "-----BEGIN(?: RSA| EC| OPENSSH| DSA)? PRIVATE KEY-----",
65
+ // PEM private key
66
+ "://[^/\\s:@]+:[^/\\s:@]+@"
67
+ // credentials embedded in a URL
68
+ ],
69
+ binaryExtensions: [
70
+ ".png",
71
+ ".jpg",
72
+ ".jpeg",
73
+ ".gif",
74
+ ".ico",
75
+ ".webp",
76
+ ".pdf",
77
+ ".zip",
78
+ ".gz",
79
+ ".woff",
80
+ ".woff2",
81
+ ".ttf",
82
+ ".eot",
83
+ ".mp4",
84
+ ".mp3",
85
+ ".wasm"
86
+ ]
87
+ },
88
+ coverage: {
89
+ budgetsPath: "gates/coverage-budgets.json",
90
+ summaryGlobs: [
91
+ "apps/*/coverage/coverage-summary.json",
92
+ "packages/*/coverage/coverage-summary.json"
93
+ ]
94
+ },
95
+ ciParity: {
96
+ configPath: "gates/ci-parity-config.json",
97
+ rootGate: "check:all",
98
+ entryGates: ["check:all", "verify"],
99
+ workflowPrefix: "ci"
100
+ },
101
+ bundleSize: { budgetsPath: "gates/bundle-size-budgets.json", targets: [] },
102
+ report: { junitGlobs: [], topN: 20 },
103
+ agents: {
104
+ targets: [],
105
+ knownMissingPaths: [],
106
+ runnerCommand: "pnpm",
107
+ ignoredSubcommands: [
108
+ "run",
109
+ "install",
110
+ "i",
111
+ "add",
112
+ "remove",
113
+ "rm",
114
+ "up",
115
+ "update",
116
+ "exec",
117
+ "dlx",
118
+ "why",
119
+ "store",
120
+ "prune",
121
+ "audit",
122
+ "outdated",
123
+ "list",
124
+ "ls",
125
+ "link",
126
+ "unlink",
127
+ "publish",
128
+ "pack",
129
+ "rebuild",
130
+ "approve-builds",
131
+ "config",
132
+ "dedupe",
133
+ "fetch",
134
+ "import",
135
+ "patch",
136
+ "setup",
137
+ "create"
138
+ ]
139
+ },
140
+ docsCoverage: { docsGlobs: [], surfaces: [], exclude: [] }
141
+ };
142
+ var CONFIG_FILENAMES = ["repo-gates.config.json"];
143
+ function isPlainObject(value) {
144
+ return value !== null && typeof value === "object" && !Array.isArray(value);
145
+ }
146
+ function mergeConfig(base, overlay) {
147
+ const out = { ...base };
148
+ for (const [key, value] of Object.entries(overlay)) {
149
+ if (value === void 0) continue;
150
+ const baseValue = base[key];
151
+ out[key] = isPlainObject(baseValue) && isPlainObject(value) ? { ...baseValue, ...value } : value;
152
+ }
153
+ return out;
154
+ }
155
+ function findConfigFile(repoRoot) {
156
+ for (const name of CONFIG_FILENAMES) {
157
+ const candidate = resolve(repoRoot, name);
158
+ if (existsSync(candidate)) return candidate;
159
+ }
160
+ return void 0;
161
+ }
162
+ function loadConfig(repoRoot = process.cwd()) {
163
+ const file = findConfigFile(repoRoot);
164
+ if (!file) return DEFAULT_CONFIG;
165
+ const overlay = JSON.parse(readFileSync(file, "utf8"));
166
+ return mergeConfig(DEFAULT_CONFIG, overlay);
167
+ }
168
+ function loadContext(repoRoot = process.cwd()) {
169
+ return { repoRoot, config: loadConfig(repoRoot) };
170
+ }
171
+ export {
172
+ CONFIG_FILENAMES,
173
+ DEFAULT_CONFIG,
174
+ findConfigFile,
175
+ loadConfig,
176
+ loadContext,
177
+ mergeConfig
178
+ };
@@ -0,0 +1,17 @@
1
+ /** Shared policy for consumer ESLint and Oxlint configs. No linter runtime dependency. */
2
+ declare const designSystemRules: {
3
+ readonly "shadcn/no-restyle": readonly ["error", {
4
+ readonly allow: readonly ["layout"];
5
+ }];
6
+ readonly "shadcn/no-raw-colors": "error";
7
+ readonly "shadcn/no-arbitrary-values": "error";
8
+ readonly "shadcn/no-inline-styles": "error";
9
+ readonly "shadcn/no-unknown-classes": "error";
10
+ readonly "shadcn/require-static-classes": "error";
11
+ };
12
+ /** Component implementations may define their own styling. Other checks still apply. */
13
+ declare const componentDefinitionRules: {
14
+ readonly "shadcn/no-restyle": "off";
15
+ };
16
+
17
+ export { componentDefinitionRules, designSystemRules };
@@ -0,0 +1,14 @@
1
+ // src/design-system.ts
2
+ var designSystemRules = {
3
+ "shadcn/no-restyle": ["error", { allow: ["layout"] }],
4
+ "shadcn/no-raw-colors": "error",
5
+ "shadcn/no-arbitrary-values": "error",
6
+ "shadcn/no-inline-styles": "error",
7
+ "shadcn/no-unknown-classes": "error",
8
+ "shadcn/require-static-classes": "error"
9
+ };
10
+ var componentDefinitionRules = { "shadcn/no-restyle": "off" };
11
+ export {
12
+ componentDefinitionRules,
13
+ designSystemRules
14
+ };
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Import-boundary → ESLint flat-config generator (repo-agnostic).
3
+ *
4
+ * The *rules* (which import specifiers are forbidden in which files) are
5
+ * DATA the consumer supplies from its own `repo-gates.config.json`; this
6
+ * module only shapes that data into `@typescript-eslint/no-restricted-imports`
7
+ * flat-config entries. repo-gates never learns any repo's specific packages.
8
+ *
9
+ * IMPORTANT — ESLint flat config does NOT merge rule options across matching
10
+ * configs; the LAST matching config wins outright. So a file must get ALL its
11
+ * forbidden patterns from a SINGLE config. Hence each boundary is ONE file
12
+ * scope carrying a LIST of pattern groups (each with its own `allowTypeImports`
13
+ * / message). When scopes nest (e.g. `apps/x/renderer/**` ⊂ `apps/x/**`), the
14
+ * narrower boundary must carry the broader patterns too. Rather than copy-paste
15
+ * them, give it `extends: ["<broader-boundary>"]` — this module merges the named
16
+ * boundaries' patterns in ahead of its own, so each rule is authored ONCE.
17
+ */
18
+ type BoundaryPattern = {
19
+ /** Forbidden import-specifier glob patterns (minimatch on the specifier). */
20
+ forbid: string[];
21
+ /** Allow `import type { … }` through (blocks only value imports). */
22
+ allowTypeImports?: boolean;
23
+ /** Custom violation message for this group. */
24
+ message?: string;
25
+ };
26
+ type Boundary = {
27
+ /** Stable id, surfaced in the ESLint config name. */
28
+ name: string;
29
+ /** File globs the rule applies to (ESLint `files`). */
30
+ files: string[];
31
+ /** File globs excluded from this boundary (ESLint `ignores`) — e.g. test
32
+ * files, which run in Node and may touch anything for setup. */
33
+ ignores?: string[];
34
+ /** One or more forbidden-pattern groups applied to those files. */
35
+ patterns: BoundaryPattern[];
36
+ /** Names of other boundaries whose `patterns` are merged in ahead of this
37
+ * boundary's own. Lets a nested scope inherit a broader scope's rules
38
+ * without copy-pasting them (only `patterns` are inherited, never `files`). */
39
+ extends?: string[];
40
+ };
41
+ type BoundaryEslintConfig = {
42
+ name: string;
43
+ files: string[];
44
+ ignores?: string[];
45
+ rules: Record<string, unknown>;
46
+ };
47
+ declare function boundariesToEslintConfigs(boundaries: Boundary[]): BoundaryEslintConfig[];
48
+
49
+ export { type Boundary, type BoundaryEslintConfig, type BoundaryPattern, boundariesToEslintConfigs };
@@ -0,0 +1,40 @@
1
+ // src/eslint-boundaries.ts
2
+ function resolvePatterns(b, byName, seen) {
3
+ if (seen.has(b.name)) {
4
+ throw new Error(
5
+ `repo-gates boundaries: circular \`extends\` involving "${b.name}" (${[...seen, b.name].join(" \u2192 ")}).`
6
+ );
7
+ }
8
+ const inherited = [];
9
+ for (const parentName of b.extends ?? []) {
10
+ const parent = byName.get(parentName);
11
+ if (!parent) {
12
+ throw new Error(`repo-gates boundary "${b.name}" extends unknown boundary "${parentName}".`);
13
+ }
14
+ inherited.push(...resolvePatterns(parent, byName, /* @__PURE__ */ new Set([...seen, b.name])));
15
+ }
16
+ return [...inherited, ...b.patterns];
17
+ }
18
+ function boundariesToEslintConfigs(boundaries) {
19
+ const byName = new Map(boundaries.map((b) => [b.name, b]));
20
+ return boundaries.map((b) => ({
21
+ name: `repo-gates/boundary/${b.name}`,
22
+ files: b.files,
23
+ ...b.ignores ? { ignores: b.ignores } : {},
24
+ rules: {
25
+ "@typescript-eslint/no-restricted-imports": [
26
+ "error",
27
+ {
28
+ patterns: resolvePatterns(b, byName, /* @__PURE__ */ new Set()).map((p) => ({
29
+ group: p.forbid,
30
+ allowTypeImports: p.allowTypeImports ?? false,
31
+ message: p.message ?? `Import boundary "${b.name}" violated.`
32
+ }))
33
+ }
34
+ ]
35
+ }
36
+ }));
37
+ }
38
+ export {
39
+ boundariesToEslintConfigs
40
+ };