@danielblomma/cortex-mcp 1.7.1 → 2.0.2

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.
Files changed (79) hide show
  1. package/bin/cortex.mjs +679 -32
  2. package/bin/style.mjs +349 -0
  3. package/package.json +4 -3
  4. package/scaffold/mcp/package-lock.json +834 -671
  5. package/scaffold/mcp/package.json +1 -1
  6. package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
  7. package/scaffold/mcp/src/cli/govern.ts +987 -0
  8. package/scaffold/mcp/src/cli/run.ts +306 -0
  9. package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
  10. package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
  11. package/scaffold/mcp/src/core/audit/query.ts +81 -0
  12. package/scaffold/mcp/src/core/audit/writer.ts +68 -0
  13. package/scaffold/mcp/src/core/config.ts +329 -0
  14. package/scaffold/mcp/src/core/index.ts +34 -0
  15. package/scaffold/mcp/src/core/license.ts +202 -0
  16. package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
  17. package/scaffold/mcp/src/core/policy/injection.ts +229 -0
  18. package/scaffold/mcp/src/core/policy/store.ts +197 -0
  19. package/scaffold/mcp/src/core/rbac/check.ts +40 -0
  20. package/scaffold/mcp/src/core/telemetry/collector.ts +234 -0
  21. package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
  22. package/scaffold/mcp/src/core/validators/config.ts +47 -0
  23. package/scaffold/mcp/src/core/validators/engine.ts +199 -0
  24. package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
  25. package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
  26. package/scaffold/mcp/src/daemon/client.ts +155 -0
  27. package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
  28. package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
  29. package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
  30. package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
  31. package/scaffold/mcp/src/daemon/main.ts +300 -0
  32. package/scaffold/mcp/src/daemon/paths.ts +41 -0
  33. package/scaffold/mcp/src/daemon/protocol.ts +101 -0
  34. package/scaffold/mcp/src/daemon/server.ts +227 -0
  35. package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
  36. package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
  37. package/scaffold/mcp/src/embed.ts +1 -1
  38. package/scaffold/mcp/src/embeddings.ts +1 -1
  39. package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
  40. package/scaffold/mcp/src/enterprise/index.ts +415 -0
  41. package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
  42. package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
  43. package/scaffold/mcp/src/enterprise/privacy/boundary.ts +212 -0
  44. package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
  45. package/scaffold/mcp/src/enterprise/telemetry/sync.ts +72 -0
  46. package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
  47. package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
  48. package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
  49. package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
  50. package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
  51. package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
  52. package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
  53. package/scaffold/mcp/src/hooks/session-end.ts +73 -0
  54. package/scaffold/mcp/src/hooks/session-start.ts +78 -0
  55. package/scaffold/mcp/src/hooks/shared.ts +134 -0
  56. package/scaffold/mcp/src/hooks/stop.ts +60 -0
  57. package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
  58. package/scaffold/mcp/src/plugin.ts +150 -0
  59. package/scaffold/mcp/src/server.ts +218 -7
  60. package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
  61. package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
  62. package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
  63. package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
  64. package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
  65. package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
  66. package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
  67. package/scaffold/mcp/tests/govern.test.mjs +74 -0
  68. package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
  69. package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
  70. package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
  71. package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
  72. package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
  73. package/scaffold/mcp/tests/run.test.mjs +109 -0
  74. package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
  75. package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
  76. package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
  77. package/scaffold/scripts/bootstrap.sh +0 -11
  78. package/scaffold/scripts/doctor.sh +24 -4
  79. package/types.js +5 -0
@@ -0,0 +1,47 @@
1
+ export type ValidatorsConfig = Record<string, Record<string, unknown>>;
2
+
3
+ /**
4
+ * Parse the validators section from the simple YAML fields map.
5
+ *
6
+ * Fields arrive as flat "validators.max-file-size.max_bytes" = "500000" entries.
7
+ * We reconstruct them into nested config: { "max-file-size": { max_bytes: 500000 } }.
8
+ */
9
+ export function parseValidatorsConfig(fields: Record<string, string>): ValidatorsConfig {
10
+ const config: ValidatorsConfig = {};
11
+ const prefix = "validators.";
12
+
13
+ for (const [key, value] of Object.entries(fields)) {
14
+ if (!key.startsWith(prefix)) continue;
15
+
16
+ const rest = key.slice(prefix.length);
17
+ const dotIndex = rest.indexOf(".");
18
+ if (dotIndex < 0) continue;
19
+
20
+ const validatorId = rest.slice(0, dotIndex);
21
+ const optionKey = rest.slice(dotIndex + 1);
22
+
23
+ if (!config[validatorId]) {
24
+ config[validatorId] = {};
25
+ }
26
+
27
+ config[validatorId][optionKey] = coerceValue(value);
28
+ }
29
+
30
+ return config;
31
+ }
32
+
33
+ function coerceValue(value: string): unknown {
34
+ if (value === "true") return true;
35
+ if (value === "false") return false;
36
+ const num = Number(value);
37
+ if (!Number.isNaN(num) && value.trim() !== "") return num;
38
+ // Handle YAML-style arrays: ["a", "b", "c"]
39
+ if (value.startsWith("[") && value.endsWith("]")) {
40
+ try {
41
+ return JSON.parse(value);
42
+ } catch {
43
+ // Fall through to string
44
+ }
45
+ }
46
+ return value;
47
+ }
@@ -0,0 +1,199 @@
1
+ export type ValidatorContext = {
2
+ contextDir: string;
3
+ projectRoot: string;
4
+ changedFiles?: string[];
5
+ };
6
+
7
+ export type ValidatorResult = {
8
+ pass: boolean;
9
+ severity: "error" | "warning" | "info";
10
+ message: string;
11
+ detail?: string;
12
+ };
13
+
14
+ export type ValidatorDef = {
15
+ policyId: string;
16
+ check: (ctx: ValidatorContext, options: Record<string, unknown>) => Promise<ValidatorResult>;
17
+ };
18
+
19
+ // Generic evaluators are keyed by `type`, not by policyId. One evaluator
20
+ // can execute many policies (e.g. a single RegexEvaluator runs every
21
+ // custom regex rule the user defines in cortex-web). Used when a policy
22
+ // declares `type` + `config`; name-based validators are the fallback for
23
+ // predefined rules that ship with the plugin.
24
+ export type GenericEvaluatorDef = {
25
+ type: string;
26
+ check: (ctx: ValidatorContext, config: Record<string, unknown>) => Promise<ValidatorResult>;
27
+ };
28
+
29
+ // An enforced policy as passed to runValidators. `type` + `config` are
30
+ // optional — predefined rules leave them null and fall back to the
31
+ // name-based validator registry.
32
+ export type EnforcedPolicy = {
33
+ id: string;
34
+ type?: string | null;
35
+ config?: Record<string, unknown> | null;
36
+ severity?: "info" | "warning" | "error" | "block" | null;
37
+ };
38
+
39
+ const registry = new Map<string, ValidatorDef>();
40
+ const genericRegistry = new Map<string, GenericEvaluatorDef>();
41
+
42
+ export function registerValidator(def: ValidatorDef): void {
43
+ registry.set(def.policyId, def);
44
+ }
45
+
46
+ export function getValidator(policyId: string): ValidatorDef | undefined {
47
+ return registry.get(policyId);
48
+ }
49
+
50
+ export function getRegisteredPolicyIds(): string[] {
51
+ return [...registry.keys()];
52
+ }
53
+
54
+ export function registerGenericEvaluator(def: GenericEvaluatorDef): void {
55
+ genericRegistry.set(def.type, def);
56
+ }
57
+
58
+ export function getGenericEvaluator(type: string): GenericEvaluatorDef | undefined {
59
+ return genericRegistry.get(type);
60
+ }
61
+
62
+ export function getRegisteredEvaluatorTypes(): string[] {
63
+ return [...genericRegistry.keys()];
64
+ }
65
+
66
+ export type ReviewResult = {
67
+ policy_id: string;
68
+ pass: boolean;
69
+ severity: "error" | "warning" | "info";
70
+ message: string;
71
+ detail?: string;
72
+ };
73
+
74
+ export type ReviewSummary = {
75
+ total: number;
76
+ passed: number;
77
+ failed: number;
78
+ warnings: number;
79
+ };
80
+
81
+ export type ReviewOutput = {
82
+ results: ReviewResult[];
83
+ summary: ReviewSummary;
84
+ };
85
+
86
+ function resolvePolicySeverity(
87
+ policy: EnforcedPolicy,
88
+ fallback: ReviewResult["severity"],
89
+ ): ReviewResult["severity"] {
90
+ if (!policy.severity) return fallback;
91
+ return policy.severity === "block" ? "error" : policy.severity;
92
+ }
93
+
94
+ /**
95
+ * Run validators for every enforced policy. Dispatch order per policy:
96
+ * 1. If the policy has a `type`, look it up in the generic evaluator
97
+ * registry (cortex-web custom rules use this path).
98
+ * 2. Otherwise, look up a name-based validator by policy id
99
+ * (predefined rules shipped with the plugin use this path).
100
+ * 3. If neither path yields an implementation, emit a warning so the
101
+ * gap is visible instead of silent.
102
+ *
103
+ * Accepts either `Set<string>` (legacy id-only callers) or an array of
104
+ * `EnforcedPolicy` objects carrying `type` + `config` from the
105
+ * cortex-web policy sync. Set inputs are normalized to entries with
106
+ * null type/config, so they always route to the name-based registry.
107
+ */
108
+ export async function runValidators(
109
+ enforced: Set<string> | EnforcedPolicy[],
110
+ ctx: ValidatorContext,
111
+ validatorConfigs: Record<string, Record<string, unknown>>,
112
+ ): Promise<ReviewOutput> {
113
+ const policies: EnforcedPolicy[] =
114
+ enforced instanceof Set
115
+ ? [...enforced].map((id) => ({ id }))
116
+ : enforced;
117
+
118
+ const results: ReviewResult[] = [];
119
+
120
+ for (const policy of policies) {
121
+ if (policy.type) {
122
+ const evaluator = genericRegistry.get(policy.type);
123
+ if (!evaluator) {
124
+ results.push({
125
+ policy_id: policy.id,
126
+ pass: false,
127
+ severity: "warning",
128
+ message: `No evaluator registered for type "${policy.type}"`,
129
+ detail:
130
+ "This policy declares a generic evaluator type that is not " +
131
+ "implemented in this version of the enterprise plugin. " +
132
+ "Upgrade the plugin or change the rule type.",
133
+ });
134
+ continue;
135
+ }
136
+ try {
137
+ const result = await evaluator.check(ctx, policy.config ?? {});
138
+ results.push({
139
+ policy_id: policy.id,
140
+ pass: result.pass,
141
+ severity: resolvePolicySeverity(policy, result.severity),
142
+ message: result.message,
143
+ detail: result.detail,
144
+ });
145
+ } catch (err) {
146
+ results.push({
147
+ policy_id: policy.id,
148
+ pass: false,
149
+ severity: "error",
150
+ message: `Evaluator error: ${err instanceof Error ? err.message : String(err)}`,
151
+ });
152
+ }
153
+ continue;
154
+ }
155
+
156
+ const def = registry.get(policy.id);
157
+ if (!def) {
158
+ results.push({
159
+ policy_id: policy.id,
160
+ pass: false,
161
+ severity: "warning",
162
+ message: "No validator implementation registered for this policy",
163
+ detail:
164
+ "This policy is enforced but the server-side check is missing. " +
165
+ "Either install an enterprise plugin that provides it, or disable " +
166
+ "enforcement in the policy dashboard.",
167
+ });
168
+ continue;
169
+ }
170
+
171
+ const options = validatorConfigs[policy.id] ?? {};
172
+ try {
173
+ const result = await def.check(ctx, options);
174
+ results.push({
175
+ policy_id: policy.id,
176
+ pass: result.pass,
177
+ severity: resolvePolicySeverity(policy, result.severity),
178
+ message: result.message,
179
+ detail: result.detail,
180
+ });
181
+ } catch (err) {
182
+ results.push({
183
+ policy_id: policy.id,
184
+ pass: false,
185
+ severity: "error",
186
+ message: `Validator error: ${err instanceof Error ? err.message : String(err)}`,
187
+ });
188
+ }
189
+ }
190
+
191
+ const summary: ReviewSummary = {
192
+ total: results.length,
193
+ passed: results.filter((r) => r.pass).length,
194
+ failed: results.filter((r) => !r.pass && r.severity === "error").length,
195
+ warnings: results.filter((r) => !r.pass && r.severity === "warning").length,
196
+ };
197
+
198
+ return { results, summary };
199
+ }
@@ -0,0 +1,294 @@
1
+ import { statSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ registerGenericEvaluator,
5
+ type ValidatorContext,
6
+ type ValidatorResult,
7
+ } from "../engine.js";
8
+
9
+ // Per "parser parity" — every supported language is a first-class citizen.
10
+ // Adding a new language means adding an entry to LANGUAGES plus a positive
11
+ // and a negative test. Extensions listed here are the only files scanned.
12
+
13
+ type EndStyle = "braces" | "indent";
14
+
15
+ type LanguageSpec = {
16
+ name: string;
17
+ extensions: string[];
18
+ functionPatterns: RegExp[];
19
+ lineCommentPrefix: string;
20
+ // Block comment support is optional; leave empty to disable.
21
+ blockCommentStart: string;
22
+ blockCommentEnd: string;
23
+ endStyle: EndStyle;
24
+ // Python-style: a docstring as the first statement in the body counts as
25
+ // a comment. Only relevant for indent-style languages right now.
26
+ allowsDocstring?: boolean;
27
+ };
28
+
29
+ const LANGUAGES: LanguageSpec[] = [
30
+ {
31
+ name: "TypeScript/JavaScript",
32
+ extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"],
33
+ functionPatterns: [
34
+ /^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s*(\w+)\s*(?:<[^>]*>)?\s*\(/,
35
+ /^\s*(?:export\s+)?(?:const|let|var)\s+(\w+)\s*(?::\s*[\w<>,\s[\]|&]+)?\s*=\s*(?:async\s*)?(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[\w<>,\s[\]|&]+\s*)?=>/,
36
+ /^\s*(?:public|private|protected)?\s*(?:static\s+)?(?:readonly\s+)?(?:async\s+)?(?:\*\s*)?(\w+)\s*(?:<[^>]*>)?\s*\([^)]*\)\s*(?::\s*[\w<>,\s[\]|&]+\s*)?\{/,
37
+ ],
38
+ lineCommentPrefix: "//",
39
+ blockCommentStart: "/*",
40
+ blockCommentEnd: "*/",
41
+ endStyle: "braces",
42
+ },
43
+ {
44
+ name: "Python",
45
+ extensions: [".py"],
46
+ functionPatterns: [
47
+ /^(\s*)(?:async\s+)?def\s+(\w+)\s*\(/,
48
+ ],
49
+ lineCommentPrefix: "#",
50
+ blockCommentStart: "",
51
+ blockCommentEnd: "",
52
+ endStyle: "indent",
53
+ allowsDocstring: true,
54
+ },
55
+ {
56
+ name: "Go",
57
+ extensions: [".go"],
58
+ functionPatterns: [
59
+ /^func\s+(?:\([^)]+\)\s+)?(\w+)\s*\(/,
60
+ ],
61
+ lineCommentPrefix: "//",
62
+ blockCommentStart: "/*",
63
+ blockCommentEnd: "*/",
64
+ endStyle: "braces",
65
+ },
66
+ {
67
+ name: "Rust",
68
+ extensions: [".rs"],
69
+ functionPatterns: [
70
+ /^\s*(?:pub(?:\s*\([^)]*\))?\s+)?(?:async\s+)?(?:unsafe\s+)?(?:extern\s+(?:"[^"]*"\s+)?)?fn\s+(\w+)/,
71
+ ],
72
+ lineCommentPrefix: "//",
73
+ blockCommentStart: "/*",
74
+ blockCommentEnd: "*/",
75
+ endStyle: "braces",
76
+ },
77
+ {
78
+ name: "C#",
79
+ extensions: [".cs"],
80
+ functionPatterns: [
81
+ // Require at least one access/modifier keyword so we don't match
82
+ // arbitrary `name(args)` calls. Return type is optional to also
83
+ // match constructors.
84
+ /^\s*(?:(?:public|private|protected|internal|static|async|override|virtual|sealed|abstract|new|partial)\s+)+(?:[\w<>\[\],?\s]+?\s+)?(\w+)\s*\([^)]*\)\s*(?:\{|where|:|=>|$)/,
85
+ ],
86
+ lineCommentPrefix: "//",
87
+ blockCommentStart: "/*",
88
+ blockCommentEnd: "*/",
89
+ endStyle: "braces",
90
+ },
91
+ {
92
+ name: "Java",
93
+ extensions: [".java"],
94
+ functionPatterns: [
95
+ /^\s*(?:(?:public|private|protected|static|final|abstract|synchronized|native)\s+)+(?:[\w<>\[\],?\s]+?\s+)?(\w+)\s*\([^)]*\)\s*(?:\{|throws|$)/,
96
+ ],
97
+ lineCommentPrefix: "//",
98
+ blockCommentStart: "/*",
99
+ blockCommentEnd: "*/",
100
+ endStyle: "braces",
101
+ },
102
+ ];
103
+
104
+ function pickLanguage(file: string): LanguageSpec | null {
105
+ const lower = file.toLowerCase();
106
+ for (const lang of LANGUAGES) {
107
+ if (lang.extensions.some((ext) => lower.endsWith(ext))) {
108
+ return lang;
109
+ }
110
+ }
111
+ return null;
112
+ }
113
+
114
+ // Scan for a preceding comment within `lookback` non-blank lines above
115
+ // `startLine` (exclusive). Returns true if a comment is found.
116
+ function hasPrecedingComment(
117
+ lines: string[],
118
+ startLine: number,
119
+ lang: LanguageSpec,
120
+ lookback: number,
121
+ ): boolean {
122
+ let checked = 0;
123
+ for (let i = startLine - 1; i >= 0 && checked < lookback; i -= 1) {
124
+ const trimmed = lines[i].trim();
125
+ if (trimmed === "") continue;
126
+ checked += 1;
127
+
128
+ if (trimmed.startsWith(lang.lineCommentPrefix)) return true;
129
+ if (lang.blockCommentStart) {
130
+ if (trimmed.endsWith(lang.blockCommentEnd)) return true;
131
+ if (trimmed.startsWith(lang.blockCommentStart)) return true;
132
+ }
133
+ // First non-blank non-comment line above → no leading comment.
134
+ return false;
135
+ }
136
+ return false;
137
+ }
138
+
139
+ function isDocstringLine(line: string): boolean {
140
+ const t = line.trim();
141
+ return t.startsWith('"""') || t.startsWith("'''") || t.startsWith('"') || t.startsWith("'");
142
+ }
143
+
144
+ // Walk forward from `startLine` (the function declaration line) and return
145
+ // the (exclusive) end-of-function line. Handles both brace and indent
146
+ // styles. Naive — comments/strings may contain braces and skew the
147
+ // counter; acceptable tradeoff without per-language AST parsing.
148
+ function findFunctionEnd(
149
+ lines: string[],
150
+ startLine: number,
151
+ lang: LanguageSpec,
152
+ indentMatch?: string,
153
+ ): number {
154
+ if (lang.endStyle === "indent") {
155
+ const baseIndent = (indentMatch ?? "").length;
156
+ for (let i = startLine + 1; i < lines.length; i += 1) {
157
+ const line = lines[i];
158
+ if (line.trim() === "") continue;
159
+ const indent = line.length - line.trimStart().length;
160
+ if (indent <= baseIndent) return i;
161
+ }
162
+ return lines.length;
163
+ }
164
+
165
+ // Braces
166
+ let depth = 0;
167
+ let seenOpen = false;
168
+ for (let i = startLine; i < lines.length; i += 1) {
169
+ const line = lines[i];
170
+ for (let j = 0; j < line.length; j += 1) {
171
+ const c = line[j];
172
+ if (c === "{") {
173
+ depth += 1;
174
+ seenOpen = true;
175
+ } else if (c === "}") {
176
+ depth -= 1;
177
+ if (seenOpen && depth === 0) return i + 1;
178
+ }
179
+ }
180
+ }
181
+ return lines.length;
182
+ }
183
+
184
+ type Violation = {
185
+ file: string;
186
+ line: number;
187
+ name: string;
188
+ lineCount: number;
189
+ };
190
+
191
+ function scanFile(
192
+ content: string,
193
+ file: string,
194
+ lang: LanguageSpec,
195
+ minLines: number,
196
+ ): Violation[] {
197
+ const lines = content.split("\n");
198
+ const hits: Violation[] = [];
199
+
200
+ for (let i = 0; i < lines.length; i += 1) {
201
+ const line = lines[i];
202
+ for (const pattern of lang.functionPatterns) {
203
+ const m = line.match(pattern);
204
+ if (!m) continue;
205
+
206
+ // For Python, capture group 1 is indent, 2 is name; for others, 1 is name.
207
+ const name = lang.endStyle === "indent" ? m[2] ?? "<anonymous>" : m[1] ?? "<anonymous>";
208
+ const indent = lang.endStyle === "indent" ? m[1] ?? "" : undefined;
209
+
210
+ const endLine = findFunctionEnd(lines, i, lang, indent);
211
+ const funcLineCount = endLine - i;
212
+
213
+ if (funcLineCount < minLines) break;
214
+
215
+ if (hasPrecedingComment(lines, i, lang, 3)) break;
216
+
217
+ if (lang.allowsDocstring && lines[i + 1] && isDocstringLine(lines[i + 1])) break;
218
+
219
+ hits.push({ file, line: i + 1, name, lineCount: funcLineCount });
220
+ break; // one match per line is enough
221
+ }
222
+ }
223
+
224
+ return hits;
225
+ }
226
+
227
+ registerGenericEvaluator({
228
+ type: "code_comments",
229
+ async check(ctx: ValidatorContext, config: Record<string, unknown>): Promise<ValidatorResult> {
230
+ const files = ctx.changedFiles ?? [];
231
+ if (files.length === 0) {
232
+ return { pass: true, severity: "info", message: "No changed files to scan" };
233
+ }
234
+
235
+ const minLines = typeof config.min_lines === "number" && config.min_lines > 0 ? config.min_lines : 15;
236
+ const severity =
237
+ config.severity === "error" || config.severity === "warning" || config.severity === "info"
238
+ ? config.severity
239
+ : "warning";
240
+ const allowlist = Array.isArray(config.allowlist_paths)
241
+ ? config.allowlist_paths.filter((p): p is string => typeof p === "string")
242
+ : ["tests/", "test/", "__tests__/", "fixtures/", "docs/"];
243
+ const maxBytes = typeof config.max_scan_bytes === "number" ? config.max_scan_bytes : 2_000_000;
244
+
245
+ // Optional language filter (by name, case-insensitive). Absent = all.
246
+ const wantedLanguages = Array.isArray(config.languages)
247
+ ? new Set(
248
+ config.languages.filter((l): l is string => typeof l === "string").map((l) => l.toLowerCase()),
249
+ )
250
+ : null;
251
+
252
+ const allHits: Violation[] = [];
253
+ let scanned = 0;
254
+
255
+ for (const file of files) {
256
+ if (allowlist.some((p) => file.includes(p))) continue;
257
+ const lang = pickLanguage(file);
258
+ if (!lang) continue;
259
+ if (wantedLanguages && !wantedLanguages.has(lang.name.toLowerCase())) continue;
260
+
261
+ const abs = join(ctx.projectRoot, file);
262
+ try {
263
+ const stat = statSync(abs);
264
+ if (stat.size > maxBytes) continue;
265
+ const content = readFileSync(abs, "utf8");
266
+ scanned += 1;
267
+ allHits.push(...scanFile(content, file, lang, minLines));
268
+ } catch {
269
+ // unreadable — skip
270
+ }
271
+ }
272
+
273
+ if (allHits.length === 0) {
274
+ return {
275
+ pass: true,
276
+ severity: "info",
277
+ message: `No undocumented functions (${minLines}+ lines) in ${scanned} changed file${scanned === 1 ? "" : "s"}`,
278
+ };
279
+ }
280
+
281
+ const detail =
282
+ allHits
283
+ .slice(0, 30)
284
+ .map((h) => `${h.file}:${h.line} — ${h.name} (${h.lineCount} lines)`)
285
+ .join("\n") + (allHits.length > 30 ? `\n... and ${allHits.length - 30} more` : "");
286
+
287
+ return {
288
+ pass: false,
289
+ severity,
290
+ message: `${allHits.length} function${allHits.length === 1 ? "" : "s"} of ${minLines}+ lines without preceding comment`,
291
+ detail,
292
+ };
293
+ },
294
+ });
@@ -0,0 +1,144 @@
1
+ import { statSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import {
4
+ registerGenericEvaluator,
5
+ type ValidatorContext,
6
+ type ValidatorResult,
7
+ } from "../engine.js";
8
+
9
+ const BINARY_SNIFF_BYTES = 512;
10
+ const DEFAULT_MAX_BYTES = 2_000_000;
11
+
12
+ const SEVERITIES = new Set(["error", "warning", "info"]);
13
+
14
+ type RegexConfig = {
15
+ pattern: string;
16
+ flags?: string;
17
+ file_pattern?: string;
18
+ severity?: "error" | "warning" | "info";
19
+ message?: string;
20
+ max_matches_per_file?: number;
21
+ max_scan_bytes?: number;
22
+ allowlist_paths?: string[];
23
+ };
24
+
25
+ function parseConfig(raw: Record<string, unknown>): RegexConfig | { error: string } {
26
+ const pattern = raw.pattern;
27
+ if (typeof pattern !== "string" || pattern.length === 0) {
28
+ return { error: "config.pattern must be a non-empty string" };
29
+ }
30
+ try {
31
+ new RegExp(pattern, typeof raw.flags === "string" ? raw.flags : undefined);
32
+ } catch (err) {
33
+ return { error: `config.pattern is not a valid regex: ${err instanceof Error ? err.message : String(err)}` };
34
+ }
35
+
36
+ const severity = raw.severity;
37
+ if (severity !== undefined && (typeof severity !== "string" || !SEVERITIES.has(severity))) {
38
+ return { error: 'config.severity must be one of "error", "warning", "info"' };
39
+ }
40
+
41
+ if (raw.file_pattern !== undefined && typeof raw.file_pattern !== "string") {
42
+ return { error: "config.file_pattern must be a string" };
43
+ }
44
+ if (raw.file_pattern) {
45
+ try {
46
+ new RegExp(raw.file_pattern);
47
+ } catch (err) {
48
+ return { error: `config.file_pattern is not a valid regex: ${err instanceof Error ? err.message : String(err)}` };
49
+ }
50
+ }
51
+
52
+ return {
53
+ pattern,
54
+ flags: typeof raw.flags === "string" ? raw.flags : undefined,
55
+ file_pattern: typeof raw.file_pattern === "string" ? raw.file_pattern : undefined,
56
+ severity: severity as "error" | "warning" | "info" | undefined,
57
+ message: typeof raw.message === "string" ? raw.message : undefined,
58
+ max_matches_per_file:
59
+ typeof raw.max_matches_per_file === "number" ? raw.max_matches_per_file : undefined,
60
+ max_scan_bytes:
61
+ typeof raw.max_scan_bytes === "number" ? raw.max_scan_bytes : undefined,
62
+ allowlist_paths: Array.isArray(raw.allowlist_paths)
63
+ ? raw.allowlist_paths.filter((p): p is string => typeof p === "string")
64
+ : undefined,
65
+ };
66
+ }
67
+
68
+ registerGenericEvaluator({
69
+ type: "regex",
70
+ async check(ctx: ValidatorContext, rawConfig: Record<string, unknown>): Promise<ValidatorResult> {
71
+ const parsed = parseConfig(rawConfig);
72
+ if ("error" in parsed) {
73
+ return { pass: false, severity: "error", message: `Invalid regex config: ${parsed.error}` };
74
+ }
75
+
76
+ const files = ctx.changedFiles ?? [];
77
+ if (files.length === 0) {
78
+ return { pass: true, severity: "info", message: "No changed files to scan" };
79
+ }
80
+
81
+ const severity = parsed.severity ?? "warning";
82
+ const allowlist = parsed.allowlist_paths ?? ["tests/", "test/", "__tests__/", "fixtures/", "docs/"];
83
+ const maxBytes = parsed.max_scan_bytes ?? DEFAULT_MAX_BYTES;
84
+ const maxMatches = parsed.max_matches_per_file ?? 20;
85
+
86
+ // A regex used purely to filter file paths should be anchored by the
87
+ // caller if needed; we compile as-is to give them full control.
88
+ const fileRe = parsed.file_pattern ? new RegExp(parsed.file_pattern) : null;
89
+ const contentRe = new RegExp(parsed.pattern, parsed.flags);
90
+
91
+ const hits: string[] = [];
92
+ let scanned = 0;
93
+
94
+ for (const file of files) {
95
+ if (allowlist.some((p) => file.includes(p))) continue;
96
+ if (fileRe && !fileRe.test(file)) continue;
97
+
98
+ const abs = join(ctx.projectRoot, file);
99
+ try {
100
+ const stat = statSync(abs);
101
+ if (stat.size > maxBytes) continue;
102
+
103
+ const buf = readFileSync(abs);
104
+ const sniff = buf.subarray(0, Math.min(buf.length, BINARY_SNIFF_BYTES));
105
+ if (sniff.includes(0)) continue;
106
+
107
+ const content = buf.toString("utf8");
108
+ scanned += 1;
109
+
110
+ const lines = content.split("\n");
111
+ let fileMatches = 0;
112
+ for (let i = 0; i < lines.length; i += 1) {
113
+ // Compile a per-line test with the `g` flag stripped so we don't
114
+ // hop across repeated state; callers supply single-line patterns.
115
+ const lineRe = new RegExp(contentRe.source, (contentRe.flags || "").replace(/g/g, ""));
116
+ if (lineRe.test(lines[i])) {
117
+ hits.push(`${file}:${i + 1}`);
118
+ fileMatches += 1;
119
+ if (fileMatches >= maxMatches) break;
120
+ }
121
+ }
122
+ } catch {
123
+ // Unreadable file — skip
124
+ }
125
+ }
126
+
127
+ if (hits.length === 0) {
128
+ return {
129
+ pass: true,
130
+ severity: "info",
131
+ message: `No regex matches in ${scanned} changed file${scanned === 1 ? "" : "s"}`,
132
+ };
133
+ }
134
+
135
+ const messageStem = parsed.message ?? "Pattern match";
136
+ return {
137
+ pass: false,
138
+ severity,
139
+ message: `${messageStem}: ${hits.length} match${hits.length === 1 ? "" : "es"} in ${scanned} file${scanned === 1 ? "" : "s"}`,
140
+ detail:
141
+ hits.slice(0, 30).join("\n") + (hits.length > 30 ? `\n... and ${hits.length - 30} more` : ""),
142
+ };
143
+ },
144
+ });