@danielblomma/cortex-mcp 1.7.2 → 2.0.3
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 +4 -24
- package/bin/cortex.mjs +679 -32
- package/bin/style.mjs +349 -0
- package/package.json +4 -3
- package/scaffold/mcp/src/cli/enterprise-setup.ts +124 -0
- package/scaffold/mcp/src/cli/govern.ts +987 -0
- package/scaffold/mcp/src/cli/run.ts +306 -0
- package/scaffold/mcp/src/cli/telemetry-test.ts +158 -0
- package/scaffold/mcp/src/cli/ungoverned-detector.ts +168 -0
- package/scaffold/mcp/src/core/audit/query.ts +81 -0
- package/scaffold/mcp/src/core/audit/writer.ts +68 -0
- package/scaffold/mcp/src/core/config.ts +329 -0
- package/scaffold/mcp/src/core/index.ts +34 -0
- package/scaffold/mcp/src/core/license.ts +202 -0
- package/scaffold/mcp/src/core/policy/enforce.ts +98 -0
- package/scaffold/mcp/src/core/policy/injection.ts +229 -0
- package/scaffold/mcp/src/core/policy/store.ts +197 -0
- package/scaffold/mcp/src/core/rbac/check.ts +40 -0
- package/scaffold/mcp/src/core/telemetry/collector.ts +408 -0
- package/scaffold/mcp/src/core/validators/builtins.ts +711 -0
- package/scaffold/mcp/src/core/validators/config.ts +47 -0
- package/scaffold/mcp/src/core/validators/engine.ts +199 -0
- package/scaffold/mcp/src/core/validators/evaluators/code_comments.ts +294 -0
- package/scaffold/mcp/src/core/validators/evaluators/regex.ts +144 -0
- package/scaffold/mcp/src/daemon/client.ts +155 -0
- package/scaffold/mcp/src/daemon/egress-proxy.ts +331 -0
- package/scaffold/mcp/src/daemon/heartbeat-pusher.ts +147 -0
- package/scaffold/mcp/src/daemon/heartbeat-tracker.ts +223 -0
- package/scaffold/mcp/src/daemon/host-events-pusher.ts +285 -0
- package/scaffold/mcp/src/daemon/main.ts +435 -0
- package/scaffold/mcp/src/daemon/paths.ts +41 -0
- package/scaffold/mcp/src/daemon/protocol.ts +101 -0
- package/scaffold/mcp/src/daemon/server.ts +227 -0
- package/scaffold/mcp/src/daemon/sync-checker.ts +213 -0
- package/scaffold/mcp/src/daemon/ungoverned-scanner.ts +149 -0
- package/scaffold/mcp/src/enterprise/audit/push.ts +84 -0
- package/scaffold/mcp/src/enterprise/index.ts +386 -0
- package/scaffold/mcp/src/enterprise/model/deploy.ts +33 -0
- package/scaffold/mcp/src/enterprise/policy/sync.ts +146 -0
- package/scaffold/mcp/src/enterprise/privacy/boundary.ts +214 -0
- package/scaffold/mcp/src/enterprise/reviews/push.ts +79 -0
- package/scaffold/mcp/src/enterprise/telemetry/sync.ts +73 -0
- package/scaffold/mcp/src/enterprise/tools/enterprise.ts +1031 -0
- package/scaffold/mcp/src/enterprise/tools/walk.ts +79 -0
- package/scaffold/mcp/src/enterprise/violations/push.ts +102 -0
- package/scaffold/mcp/src/enterprise/workflow/push.ts +60 -0
- package/scaffold/mcp/src/enterprise/workflow/state.ts +535 -0
- package/scaffold/mcp/src/hooks/pre-compact.ts +54 -0
- package/scaffold/mcp/src/hooks/pre-tool-use.ts +96 -0
- package/scaffold/mcp/src/hooks/session-end.ts +73 -0
- package/scaffold/mcp/src/hooks/session-start.ts +78 -0
- package/scaffold/mcp/src/hooks/shared.ts +134 -0
- package/scaffold/mcp/src/hooks/stop.ts +60 -0
- package/scaffold/mcp/src/hooks/user-prompt-submit.ts +64 -0
- package/scaffold/mcp/src/loadGraph.ts +2 -0
- package/scaffold/mcp/src/plugin.ts +150 -0
- package/scaffold/mcp/src/server.ts +218 -7
- package/scaffold/mcp/tests/copilot-shim.test.mjs +146 -0
- package/scaffold/mcp/tests/daemon-client.test.mjs +32 -0
- package/scaffold/mcp/tests/egress-proxy.test.mjs +239 -0
- package/scaffold/mcp/tests/enterprise-config.test.mjs +154 -0
- package/scaffold/mcp/tests/govern-install.test.mjs +320 -0
- package/scaffold/mcp/tests/govern-repair.test.mjs +157 -0
- package/scaffold/mcp/tests/govern-status.test.mjs +538 -0
- package/scaffold/mcp/tests/govern.test.mjs +74 -0
- package/scaffold/mcp/tests/heartbeat-pusher.test.mjs +154 -0
- package/scaffold/mcp/tests/heartbeat-tracker.test.mjs +237 -0
- package/scaffold/mcp/tests/host-events-pusher.test.mjs +347 -0
- package/scaffold/mcp/tests/policy-check.test.mjs +220 -0
- package/scaffold/mcp/tests/repo-name.test.mjs +134 -0
- package/scaffold/mcp/tests/run.test.mjs +109 -0
- package/scaffold/mcp/tests/sync-checker.test.mjs +188 -0
- package/scaffold/mcp/tests/telemetry-collector.test.mjs +30 -0
- package/scaffold/mcp/tests/ungoverned-detector.test.mjs +191 -0
- package/scaffold/mcp/tests/ungoverned-scanner.test.mjs +198 -0
- package/scaffold/scripts/bootstrap.sh +0 -11
- package/scaffold/scripts/doctor.sh +24 -4
- package/types.js +5 -0
- package/docs/MCP_MARKETPLACE.md +0 -160
|
@@ -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
|
+
});
|