@bytesbrains/pi-contrib-gate 1.6.1 → 1.7.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 +3 -3
- package/package.json +1 -1
- package/src/types.ts +59 -53
- package/src/validate.ts +104 -42
package/README.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Contrib Gate for Pi
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/pi-contrib-gate)
|
|
4
|
-
[](./LICENSE)
|
|
3
|
+
[](https://www.npmjs.com/package/@bytesbrains/pi-contrib-gate)
|
|
4
|
+
[](./LICENSE)
|
|
5
5
|
|
|
6
6
|
> Contribution gateway for AI agents — enforce branch naming, conventional commits, pre-commit quality gates, and PR automation. **Agents don't call `git push` — they call `contrib_submit()`.**
|
|
7
7
|
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
## Install
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
pi install npm
|
|
13
|
+
pi install npm:@bytesbrains/pi-contrib-gate
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
## Tools
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bytesbrains/pi-contrib-gate",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.0",
|
|
4
4
|
"description": "Contribution gateway for AI agents \u2014 enforce branch naming, conventional commits, pre-commit quality gates, and PR automation.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/src/types.ts
CHANGED
|
@@ -1,64 +1,70 @@
|
|
|
1
1
|
export interface BestPracticesConfig {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
shortFrequentCommits: boolean;
|
|
3
|
+
maxLinesPerCommit: number;
|
|
4
|
+
requireAtomic: boolean;
|
|
5
|
+
maxUnrelatedDirs: number;
|
|
6
|
+
guidanceText: string[];
|
|
7
7
|
}
|
|
8
8
|
|
|
9
9
|
export interface ContribConfig {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
10
|
+
branches: {
|
|
11
|
+
featPattern: string;
|
|
12
|
+
fixPattern: string;
|
|
13
|
+
chorePattern: string;
|
|
14
|
+
};
|
|
15
|
+
commits: {
|
|
16
|
+
convention: "conventional" | "simple";
|
|
17
|
+
maxSubjectLength: number;
|
|
18
|
+
scopes: string[];
|
|
19
|
+
bestPractices: BestPracticesConfig;
|
|
20
|
+
};
|
|
21
|
+
quality: {
|
|
22
|
+
lint: boolean;
|
|
23
|
+
typeCheck: boolean;
|
|
24
|
+
doctorAudit: boolean;
|
|
25
|
+
maxFilesChanged: number;
|
|
26
|
+
maxLinesAdded: number;
|
|
27
|
+
/** Block commits if LSP errors exist in staged files (default: true) */
|
|
28
|
+
lensErrors: boolean;
|
|
29
|
+
/** Max allowed LSP errors in staged files (default: 0) */
|
|
30
|
+
maxLensErrors: number;
|
|
31
|
+
};
|
|
32
|
+
/** Validate that the linked Gitea issue actually exists before starting work (default: true) */
|
|
33
|
+
requireIssueValidation: boolean;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
export const BEST_PRACTICES_DEFAULTS: BestPracticesConfig = {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
37
|
+
shortFrequentCommits: true,
|
|
38
|
+
maxLinesPerCommit: 150,
|
|
39
|
+
requireAtomic: true,
|
|
40
|
+
maxUnrelatedDirs: 3,
|
|
41
|
+
guidanceText: [
|
|
42
|
+
"Commit after every logical unit of work.",
|
|
43
|
+
"Aim for < 150 lines per commit.",
|
|
44
|
+
"Each commit should do one thing — keep changes atomic.",
|
|
45
|
+
],
|
|
42
46
|
};
|
|
43
47
|
|
|
44
48
|
export const DEFAULT_CONFIG: ContribConfig = {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
49
|
+
branches: {
|
|
50
|
+
featPattern: "feat/",
|
|
51
|
+
fixPattern: "fix/",
|
|
52
|
+
chorePattern: "chore/",
|
|
53
|
+
},
|
|
54
|
+
commits: {
|
|
55
|
+
convention: "conventional",
|
|
56
|
+
maxSubjectLength: 72,
|
|
57
|
+
scopes: [],
|
|
58
|
+
bestPractices: { ...BEST_PRACTICES_DEFAULTS },
|
|
59
|
+
},
|
|
60
|
+
quality: {
|
|
61
|
+
lint: true,
|
|
62
|
+
typeCheck: true,
|
|
63
|
+
doctorAudit: true,
|
|
64
|
+
maxFilesChanged: 20,
|
|
65
|
+
maxLinesAdded: 500,
|
|
66
|
+
lensErrors: true,
|
|
67
|
+
maxLensErrors: 0,
|
|
68
|
+
},
|
|
69
|
+
requireIssueValidation: true,
|
|
64
70
|
};
|
package/src/validate.ts
CHANGED
|
@@ -2,52 +2,114 @@ import * as path from "node:path";
|
|
|
2
2
|
import type { ContribConfig } from "./types";
|
|
3
3
|
import { exec, scanForConflictMarkers } from "./helpers";
|
|
4
4
|
|
|
5
|
-
export function validateConventionalCommit(
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
export function validateConventionalCommit(
|
|
6
|
+
message: string,
|
|
7
|
+
config: ContribConfig,
|
|
8
|
+
): { ok: true } | { ok: false; error: string } {
|
|
9
|
+
const firstLine = message.split("\n")[0].trim();
|
|
10
|
+
const convPattern =
|
|
11
|
+
/^(feat|fix|chore|docs|style|refactor|test|perf|ci|build|revert)(\([^)]+\))?:\s.+$/;
|
|
12
|
+
if (!convPattern.test(firstLine)) {
|
|
13
|
+
return {
|
|
14
|
+
ok: false,
|
|
15
|
+
error: `Commit message must follow conventional commits format: type(scope): subject\nExamples: feat(api): add endpoint, fix: resolve null pointer, chore: update deps`,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
if (firstLine.length > config.commits.maxSubjectLength) {
|
|
19
|
+
return {
|
|
20
|
+
ok: false,
|
|
21
|
+
error: `Subject line too long (${firstLine.length} > ${config.commits.maxSubjectLength} chars).`,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return { ok: true };
|
|
15
25
|
}
|
|
16
26
|
|
|
17
|
-
export function validateBranchName(
|
|
18
|
-
|
|
19
|
-
|
|
27
|
+
export function validateBranchName(
|
|
28
|
+
branch: string,
|
|
29
|
+
): { ok: true } | { ok: false; error: string } {
|
|
30
|
+
if (/^(feat|fix|chore)\//.test(branch)) return { ok: true };
|
|
31
|
+
return {
|
|
32
|
+
ok: false,
|
|
33
|
+
error: `Branch name "${branch}" does not follow convention: feat/*, fix/*, or chore/*`,
|
|
34
|
+
};
|
|
20
35
|
}
|
|
21
36
|
|
|
22
|
-
export function runQualityGate(
|
|
23
|
-
|
|
37
|
+
export function runQualityGate(
|
|
38
|
+
cwd: string,
|
|
39
|
+
config: ContribConfig,
|
|
40
|
+
): { ok: true } | { ok: false; errors: string[] } {
|
|
41
|
+
const errors: string[] = [];
|
|
24
42
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
43
|
+
// ── Conflict marker check (MUST come first — safety-critical) ──
|
|
44
|
+
const conflictFiles = scanForConflictMarkers(cwd);
|
|
45
|
+
if (conflictFiles.length > 0) {
|
|
46
|
+
errors.push(
|
|
47
|
+
`Unresolved merge conflict markers found in staged files: ${conflictFiles.join(", ")}.\n Remove all <<<<<<<, =======, >>>>>>> markers before committing.`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
32
50
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
51
|
+
const diff = exec("git diff --cached --name-only", cwd);
|
|
52
|
+
if (diff.ok) {
|
|
53
|
+
const files = diff.stdout.split("\n").filter(Boolean);
|
|
54
|
+
if (files.length > config.quality.maxFilesChanged) {
|
|
55
|
+
errors.push(
|
|
56
|
+
`Too many files changed (${files.length} > ${config.quality.maxFilesChanged}).`,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
const loc = exec(
|
|
60
|
+
"git diff --cached --numstat | awk '{s+=$1} END {print s}'",
|
|
61
|
+
cwd,
|
|
62
|
+
);
|
|
63
|
+
if (loc.ok && parseInt(loc.stdout) > config.quality.maxLinesAdded) {
|
|
64
|
+
errors.push(
|
|
65
|
+
`Too many lines added (${loc.stdout} > ${config.quality.maxLinesAdded}).`,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
if (config.quality.typeCheck) {
|
|
70
|
+
const tsc = exec(
|
|
71
|
+
"npx tsc --noEmit 2>&1 || true",
|
|
72
|
+
path.join(cwd, "factory"),
|
|
73
|
+
);
|
|
74
|
+
if (tsc.stdout.includes("error TS"))
|
|
75
|
+
errors.push("TypeScript errors found. Run: npx tsc --noEmit");
|
|
76
|
+
}
|
|
77
|
+
if (config.quality.lensErrors) {
|
|
78
|
+
// Run LSP diagnostics on staged TypeScript/TSX files
|
|
79
|
+
const staged = exec("git diff --cached --name-only --diff-filter=ACM", cwd);
|
|
80
|
+
if (staged.ok) {
|
|
81
|
+
const tsFiles = staged.stdout
|
|
82
|
+
.split("\n")
|
|
83
|
+
.filter((f) => /\.(ts|tsx)$/.test(f));
|
|
84
|
+
if (tsFiles.length > 0) {
|
|
85
|
+
let totalErrors = 0;
|
|
86
|
+
const errorFiles: string[] = [];
|
|
87
|
+
for (const file of tsFiles) {
|
|
88
|
+
const tscCheck = exec(
|
|
89
|
+
`npx tsc --noEmit --pretty false 2>&1 || true`,
|
|
90
|
+
cwd,
|
|
91
|
+
);
|
|
92
|
+
if (tscCheck.ok && tscCheck.stdout.includes(file)) {
|
|
93
|
+
const matches = tscCheck.stdout.match(new RegExp(file, "g"));
|
|
94
|
+
const fileErrors = matches ? matches.length : 0;
|
|
95
|
+
if (fileErrors > 0) {
|
|
96
|
+
totalErrors += fileErrors;
|
|
97
|
+
errorFiles.push(`${file} (${fileErrors} errors)`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (totalErrors > config.quality.maxLensErrors) {
|
|
102
|
+
errors.push(
|
|
103
|
+
`LSP errors in staged files (${totalErrors} > ${config.quality.maxLensErrors} allowed).\n Affected: ${errorFiles.join(", ")}.\n Run: npx tsc --noEmit`,
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (config.quality.lint) {
|
|
110
|
+
const lint = exec("npm run lint 2>&1 || true", path.join(cwd, "factory"));
|
|
111
|
+
if (lint.stdout.includes("error") && !lint.stdout.includes("0 errors"))
|
|
112
|
+
errors.push("Lint errors found.");
|
|
113
|
+
}
|
|
114
|
+
return errors.length === 0 ? { ok: true } : { ok: false, errors };
|
|
53
115
|
}
|