@reneturcios/greenlight 0.4.0 → 0.6.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 +50 -1
- package/dist/babysit.d.ts +132 -0
- package/dist/babysit.js +439 -0
- package/dist/babysit.js.map +1 -0
- package/dist/cli.js +207 -3
- package/dist/cli.js.map +1 -1
- package/dist/digest.d.ts +1 -1
- package/dist/digest.js +1 -1
- package/dist/doctor.d.ts +34 -0
- package/dist/doctor.js +158 -0
- package/dist/doctor.js.map +1 -0
- package/dist/evidence.d.ts +48 -0
- package/dist/evidence.js +254 -0
- package/dist/evidence.js.map +1 -0
- package/dist/github.js +2 -2
- package/dist/github.js.map +1 -1
- package/dist/guard.d.ts +63 -3
- package/dist/guard.js +131 -4
- package/dist/guard.js.map +1 -1
- package/dist/help.d.ts +25 -0
- package/dist/help.js +234 -0
- package/dist/help.js.map +1 -0
- package/dist/index.js +79 -2
- package/dist/index.js.map +1 -1
- package/dist/init.d.ts +61 -0
- package/dist/init.js +298 -0
- package/dist/init.js.map +1 -0
- package/dist/passport.d.ts +52 -0
- package/dist/passport.js +218 -0
- package/dist/passport.js.map +1 -0
- package/dist/verify-comment.d.ts +75 -0
- package/dist/verify-comment.js +149 -0
- package/dist/verify-comment.js.map +1 -0
- package/dist/verify-parsers.d.ts +25 -0
- package/dist/verify-parsers.js +56 -0
- package/dist/verify-parsers.js.map +1 -0
- package/dist/verify.d.ts +52 -0
- package/dist/verify.js +276 -0
- package/dist/verify.js.map +1 -0
- package/integrations/README.md +4 -1
- package/integrations/claude-code/CLAUDE.snippet.md +6 -5
- package/integrations/cline/README.md +1 -1
- package/integrations/copilot/copilot-instructions.snippet.md +6 -3
- package/integrations/cursor/greenlight.mdc +2 -1
- package/integrations/windsurf/README.md +1 -1
- package/package.json +1 -1
- package/server.json +2 -2
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verified-comment mode.
|
|
3
|
+
*
|
|
4
|
+
* Some repos take no outside PRs at all. The proven examples are
|
|
5
|
+
* modelcontextprotocol/inspector ("Pull requests against this repo are
|
|
6
|
+
* opened by the repo maintainers only") and modelcontextprotocol/servers
|
|
7
|
+
* ("We accept issues, not pull requests"): outside PRs are closed on
|
|
8
|
+
* sight, and the contribution that lands is a detailed, verified issue
|
|
9
|
+
* comment instead — a reproduced problem, a located root cause, a
|
|
10
|
+
* fix sketch, and honest notes on what was actually run.
|
|
11
|
+
*
|
|
12
|
+
* This module assembles that comment from contributor-supplied inputs.
|
|
13
|
+
* It never posts anything (there is no post path), and it never invents
|
|
14
|
+
* facts: any section the contributor did not fill renders as an explicit
|
|
15
|
+
* gap marker ("not yet run", "not yet isolated", "not recorded") so a
|
|
16
|
+
* maintainer can see exactly what is verified and what is not. A
|
|
17
|
+
* plausible-looking filled-in section would be a lie; a labeled gap is
|
|
18
|
+
* the product.
|
|
19
|
+
*/
|
|
20
|
+
import type { Gate } from "./types.js";
|
|
21
|
+
export interface VerifiedCommentFields {
|
|
22
|
+
/** One-line claim of the problem (usually the issue title restated). */
|
|
23
|
+
claim: string;
|
|
24
|
+
/** Environment: repo, ref/commit, date, platform. */
|
|
25
|
+
environment: string;
|
|
26
|
+
/** Reproduction steps, in order, exact commands. */
|
|
27
|
+
reproduction: string[];
|
|
28
|
+
/** What was observed when the repro ran. */
|
|
29
|
+
observed: string;
|
|
30
|
+
/** What should have happened instead. */
|
|
31
|
+
expected: string;
|
|
32
|
+
/** Root cause: file paths and line refs when known. */
|
|
33
|
+
rootCause: string;
|
|
34
|
+
/** Proposed fix sketch, not a code dump. */
|
|
35
|
+
fixSketch: string;
|
|
36
|
+
/** What was actually run (commands, counts). */
|
|
37
|
+
ran: string;
|
|
38
|
+
/** Who ran it. */
|
|
39
|
+
verifiedBy: string;
|
|
40
|
+
}
|
|
41
|
+
export type VerifiedCommentInput = Partial<VerifiedCommentFields>;
|
|
42
|
+
export interface BuiltComment {
|
|
43
|
+
markdown: string;
|
|
44
|
+
/** Names of sections that rendered as gap markers. */
|
|
45
|
+
gaps: string[];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Assemble the verified comment. Everything comes from the contributor's
|
|
49
|
+
* inputs (or the --from-file parse); nothing is fetched and nothing is
|
|
50
|
+
* invented. The greenlight version in the footer is a real fact about the
|
|
51
|
+
* tool that assembled the comment, not a fact about the bug.
|
|
52
|
+
*/
|
|
53
|
+
export declare function buildVerifiedComment(issueUrl: string, fields: VerifiedCommentInput, greenlightVersion: string): BuiltComment;
|
|
54
|
+
/**
|
|
55
|
+
* True when the repo class is maintainer-only PRs: outside PRs are
|
|
56
|
+
* refused on sight, so the useful contribution is a verified comment.
|
|
57
|
+
* That is Greenlight's `collaborators_only` gate (inspector and servers
|
|
58
|
+
* both land there: their policy text is matched by the gate parser).
|
|
59
|
+
*/
|
|
60
|
+
export declare function suggestsVerifiedComment(gates: Gate[]): boolean;
|
|
61
|
+
/** The suggestion line check_issue prints for maintainer-only repos. */
|
|
62
|
+
export declare function verifiedCommentSuggestion(issueUrl: string): string;
|
|
63
|
+
export interface ParsedNoteFile {
|
|
64
|
+
fields: VerifiedCommentInput;
|
|
65
|
+
/** Sections that did not map to a known field, kept verbatim. */
|
|
66
|
+
additionalNotes: string;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Parse a markdown notes file into comment fields. Lines under a heading
|
|
70
|
+
* map to that section; unmapped headings are preserved under
|
|
71
|
+
* "Additional notes" instead of being dropped, so contributor content is
|
|
72
|
+
* never silently lost. Unheaded text at the top of the file becomes the
|
|
73
|
+
* claim.
|
|
74
|
+
*/
|
|
75
|
+
export declare function parseNoteFile(text: string): ParsedNoteFile;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const GAP = (label) => `*${label}: not recorded*`;
|
|
2
|
+
function section(title, body, gaps, gapName) {
|
|
3
|
+
if (body.trim().length === 0)
|
|
4
|
+
gaps.push(gapName);
|
|
5
|
+
return `## ${title}\n\n${body.trim().length > 0 ? body.trim() : GAP("not yet filled in")}\n`;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Assemble the verified comment. Everything comes from the contributor's
|
|
9
|
+
* inputs (or the --from-file parse); nothing is fetched and nothing is
|
|
10
|
+
* invented. The greenlight version in the footer is a real fact about the
|
|
11
|
+
* tool that assembled the comment, not a fact about the bug.
|
|
12
|
+
*/
|
|
13
|
+
export function buildVerifiedComment(issueUrl, fields, greenlightVersion) {
|
|
14
|
+
const gaps = [];
|
|
15
|
+
const f = (k) => fields[k] instanceof Array
|
|
16
|
+
? fields[k].join("\n")
|
|
17
|
+
: (fields[k] ?? "").trim();
|
|
18
|
+
const parts = [];
|
|
19
|
+
parts.push(`<!-- greenlight-verified-comment v1 -->`);
|
|
20
|
+
parts.push(`# Verified finding for ${issueUrl}`);
|
|
21
|
+
parts.push(``);
|
|
22
|
+
parts.push(section("Claim of the problem", f("claim"), gaps, "claim"));
|
|
23
|
+
parts.push(section("Environment", f("environment"), gaps, "environment"));
|
|
24
|
+
const repro = fields.reproduction ?? [];
|
|
25
|
+
const reproBody = repro.length > 0
|
|
26
|
+
? repro.map((s, i) => `${i + 1}. ${s.trim()}`).join("\n")
|
|
27
|
+
: `*Repro: not yet run*`;
|
|
28
|
+
if (repro.length === 0)
|
|
29
|
+
gaps.push("reproduction");
|
|
30
|
+
parts.push(`## Reproduction\n\n${reproBody}\n`);
|
|
31
|
+
parts.push(section("Observed vs expected", [f("observed"), f("expected")].some((s) => s.length > 0)
|
|
32
|
+
? `**Observed:** ${f("observed") || "*not recorded*"}\n\n**Expected:** ${f("expected") || "*not recorded*"}`
|
|
33
|
+
: "", gaps, "observed/expected"));
|
|
34
|
+
parts.push(section("Root cause", f("rootCause"), gaps, "root-cause").replace("not yet filled in", "not yet isolated"));
|
|
35
|
+
parts.push(section("Proposed fix", f("fixSketch"), gaps, "fix-sketch").replace("not yet filled in", "not sketched yet"));
|
|
36
|
+
const ran = f("ran");
|
|
37
|
+
if (ran.length === 0)
|
|
38
|
+
gaps.push("verification");
|
|
39
|
+
const by = f("verifiedBy");
|
|
40
|
+
parts.push(`## Verification\n\n` +
|
|
41
|
+
`What was actually run: ${ran.length > 0 ? ran : "*not yet run*"}\n\n` +
|
|
42
|
+
`Verified by: ${by.length > 0 ? by : "*contributor not stated*"}\n\n` +
|
|
43
|
+
`---\n` +
|
|
44
|
+
`*Comment drafted with Greenlight v${greenlightVersion} (verified-comment mode). ` +
|
|
45
|
+
`Assembled locally from the contributor's notes; nothing here was posted automatically.*\n`);
|
|
46
|
+
return { markdown: parts.join("\n"), gaps };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* True when the repo class is maintainer-only PRs: outside PRs are
|
|
50
|
+
* refused on sight, so the useful contribution is a verified comment.
|
|
51
|
+
* That is Greenlight's `collaborators_only` gate (inspector and servers
|
|
52
|
+
* both land there: their policy text is matched by the gate parser).
|
|
53
|
+
*/
|
|
54
|
+
export function suggestsVerifiedComment(gates) {
|
|
55
|
+
return gates.some((g) => g.detected && g.kind === "collaborators_only");
|
|
56
|
+
}
|
|
57
|
+
/** The suggestion line check_issue prints for maintainer-only repos. */
|
|
58
|
+
export function verifiedCommentSuggestion(issueUrl) {
|
|
59
|
+
return `This repo takes verified comments instead of PRs: draft one with greenlight verify-comment ${issueUrl}`;
|
|
60
|
+
}
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// --from-file parsing: headings map onto sections, everything else is kept.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
const HEADING_MAP = [
|
|
65
|
+
{ match: /^(claim|problem|summary|title)$/i, field: "claim" },
|
|
66
|
+
{ match: /^(environment|env|setup|context)$/i, field: "environment" },
|
|
67
|
+
{ match: /^(repro|reproduction|reproduction steps|steps|how to reproduce)$/i, field: "reproduction" },
|
|
68
|
+
{ match: /^(observed)$/i, field: "observed" },
|
|
69
|
+
{ match: /^(expected)$/i, field: "expected" },
|
|
70
|
+
{ match: /^(observed vs expected|observed\/expected|behavior)$/i, field: "observed" },
|
|
71
|
+
{ match: /^(root cause|cause|analysis)$/i, field: "rootCause" },
|
|
72
|
+
{ match: /^(fix|proposed fix|fix sketch|proposal|solution)$/i, field: "fixSketch" },
|
|
73
|
+
{ match: /^(ran|verification|what was run|testing|test results)$/i, field: "ran" },
|
|
74
|
+
{ match: /^(by|verified by|author|contributor)$/i, field: "verifiedBy" },
|
|
75
|
+
];
|
|
76
|
+
/**
|
|
77
|
+
* Parse a markdown notes file into comment fields. Lines under a heading
|
|
78
|
+
* map to that section; unmapped headings are preserved under
|
|
79
|
+
* "Additional notes" instead of being dropped, so contributor content is
|
|
80
|
+
* never silently lost. Unheaded text at the top of the file becomes the
|
|
81
|
+
* claim.
|
|
82
|
+
*/
|
|
83
|
+
export function parseNoteFile(text) {
|
|
84
|
+
const fields = {};
|
|
85
|
+
const extras = [];
|
|
86
|
+
const lines = text.split("\n");
|
|
87
|
+
let current = {
|
|
88
|
+
field: null,
|
|
89
|
+
extraTitle: null,
|
|
90
|
+
};
|
|
91
|
+
const buffers = new Map();
|
|
92
|
+
const push = (key, line) => {
|
|
93
|
+
const b = buffers.get(key) ?? [];
|
|
94
|
+
b.push(line);
|
|
95
|
+
buffers.set(key, b);
|
|
96
|
+
};
|
|
97
|
+
const flushExtra = () => {
|
|
98
|
+
if (current.extraTitle) {
|
|
99
|
+
const b = buffers.get(`extra:${current.extraTitle}`) ?? [];
|
|
100
|
+
if (b.join("\n").trim().length > 0) {
|
|
101
|
+
extras.push(`### ${current.extraTitle}\n\n${b.join("\n").trim()}`);
|
|
102
|
+
}
|
|
103
|
+
current.extraTitle = null;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
const m = /^(#{1,3})\s+(.+?)\s*$/.exec(line);
|
|
108
|
+
if (m) {
|
|
109
|
+
flushExtra();
|
|
110
|
+
const title = m[2].trim();
|
|
111
|
+
const hit = HEADING_MAP.find((h) => h.match.test(title));
|
|
112
|
+
current = hit
|
|
113
|
+
? { field: hit.field, extraTitle: null }
|
|
114
|
+
: { field: null, extraTitle: title };
|
|
115
|
+
if (!hit)
|
|
116
|
+
buffers.set(`extra:${title}`, []);
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (current.field) {
|
|
120
|
+
push(current.field, line);
|
|
121
|
+
}
|
|
122
|
+
else if (current.extraTitle) {
|
|
123
|
+
push(`extra:${current.extraTitle}`, line);
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
// Unheaded leading text: treat as the claim of the problem.
|
|
127
|
+
push("claim", line);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
flushExtra();
|
|
131
|
+
for (const [k, b] of buffers) {
|
|
132
|
+
if (k.startsWith("extra:"))
|
|
133
|
+
continue;
|
|
134
|
+
const value = b.join("\n").trim();
|
|
135
|
+
if (value.length === 0)
|
|
136
|
+
continue;
|
|
137
|
+
if (k === "reproduction") {
|
|
138
|
+
fields.reproduction = value
|
|
139
|
+
.split("\n")
|
|
140
|
+
.map((s) => s.replace(/^\s*(?:\d+[.)]|[-*])\s+/, "").trim())
|
|
141
|
+
.filter((s) => s.length > 0);
|
|
142
|
+
}
|
|
143
|
+
else {
|
|
144
|
+
fields[k] = value;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return { fields, additionalNotes: extras.join("\n\n") };
|
|
148
|
+
}
|
|
149
|
+
//# sourceMappingURL=verify-comment.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify-comment.js","sourceRoot":"","sources":["../src/verify-comment.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-output parsers for `greenlight verify`.
|
|
3
|
+
*
|
|
4
|
+
* The delta (base vs head) is only honest if it is derived from parsed
|
|
5
|
+
* test identities, never from raw log lines. Each parser turns one
|
|
6
|
+
* runner's output into the same shape: named passing tests and named
|
|
7
|
+
* failing tests. Runners slot in by implementing TestRunnerParser; the
|
|
8
|
+
* pipeline picks the first parser whose canParse matches the test
|
|
9
|
+
* command.
|
|
10
|
+
*/
|
|
11
|
+
export interface ParsedTests {
|
|
12
|
+
passed: string[];
|
|
13
|
+
failed: string[];
|
|
14
|
+
}
|
|
15
|
+
export interface TestRunnerParser {
|
|
16
|
+
/** Human name, e.g. "node:test". */
|
|
17
|
+
name: string;
|
|
18
|
+
/** True when this parser claims the given test command. */
|
|
19
|
+
canParse(command: string): boolean;
|
|
20
|
+
/** Parse one full run's stdout+stderr. */
|
|
21
|
+
parse(output: string): ParsedTests;
|
|
22
|
+
}
|
|
23
|
+
export declare const RUNNER_PARSERS: TestRunnerParser[];
|
|
24
|
+
/** Pick the parser for a test command, or null when none claims it. */
|
|
25
|
+
export declare function parserForCommand(command: string): TestRunnerParser | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
function dedupe(xs) {
|
|
2
|
+
return [...new Set(xs)];
|
|
3
|
+
}
|
|
4
|
+
const nodeTest = {
|
|
5
|
+
name: "node:test",
|
|
6
|
+
canParse: (cmd) => /(^|\s)(node\s+--test|tsx?\s+--test)/.test(cmd) || /\bvitest\b/.test(cmd),
|
|
7
|
+
parse(output) {
|
|
8
|
+
const passed = [];
|
|
9
|
+
const failed = [];
|
|
10
|
+
for (const line of output.split("\n")) {
|
|
11
|
+
// node --test: "✔ name (1.23ms)" / "✖ name (1.23ms)"; leading spaces ok.
|
|
12
|
+
// The summary headers "✖ failing tests:" / "✔ passing tests:" are not
|
|
13
|
+
// tests (the live greenlight-verify run on 2026-10-16 caught "failing
|
|
14
|
+
// tests:" being counted as a failure).
|
|
15
|
+
const ok = /^\s*✔\s+(.+?)(?:\s+\(\d[\d.,]*m?s\))?\s*$/.exec(line);
|
|
16
|
+
if (ok && !/^(passing|failing) tests:?$/i.test(ok[1].trim())) {
|
|
17
|
+
passed.push(ok[1].trim());
|
|
18
|
+
continue;
|
|
19
|
+
}
|
|
20
|
+
const bad = /^\s*✖\s+(.+?)(?:\s+\(\d[\d.,]*m?s\))?\s*$/.exec(line);
|
|
21
|
+
if (bad && !/^(passing|failing) tests:?$/i.test(bad[1].trim())) {
|
|
22
|
+
failed.push(bad[1].trim());
|
|
23
|
+
continue;
|
|
24
|
+
}
|
|
25
|
+
// vitest run: "✓ src/foo.test.ts (3 tests)" is file-level; real
|
|
26
|
+
// per-test lines come from node --test above. Skip to avoid mixing
|
|
27
|
+
// granularities.
|
|
28
|
+
}
|
|
29
|
+
return { passed: dedupe(passed), failed: dedupe(failed) };
|
|
30
|
+
},
|
|
31
|
+
};
|
|
32
|
+
const pytest = {
|
|
33
|
+
name: "pytest",
|
|
34
|
+
canParse: (cmd) => /(^|\s)pytest(\s|$)/.test(cmd),
|
|
35
|
+
parse(output) {
|
|
36
|
+
const passed = [];
|
|
37
|
+
const failed = [];
|
|
38
|
+
for (const line of output.split("\n")) {
|
|
39
|
+
// -v output: "tests/x.py::TestY::test_z PASSED" / FAILED / ERROR
|
|
40
|
+
const m = /^(\S+::\S+)\s+(PASSED|FAILED|ERROR)\b/.exec(line.trim());
|
|
41
|
+
if (!m)
|
|
42
|
+
continue;
|
|
43
|
+
if (m[2] === "PASSED")
|
|
44
|
+
passed.push(m[1]);
|
|
45
|
+
else
|
|
46
|
+
failed.push(m[1]);
|
|
47
|
+
}
|
|
48
|
+
return { passed: dedupe(passed), failed: dedupe(failed) };
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
export const RUNNER_PARSERS = [nodeTest, pytest];
|
|
52
|
+
/** Pick the parser for a test command, or null when none claims it. */
|
|
53
|
+
export function parserForCommand(command) {
|
|
54
|
+
return RUNNER_PARSERS.find((p) => p.canParse(command)) ?? null;
|
|
55
|
+
}
|
|
56
|
+
//# sourceMappingURL=verify-parsers.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"verify-parsers.js","sourceRoot":"","sources":["../src/verify-parsers.ts"],"names":[],"mappings":"AAwBA,SAAS,MAAM,CAAC,EAAY;IAC1B,OAAO,CAAC,GAAG,IAAI,GAAG,CAAC,EAAE,CAAC,CAAC,CAAC;AAC1B,CAAC;AAED,MAAM,QAAQ,GAAqB;IACjC,IAAI,EAAE,WAAW;IACjB,QAAQ,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,qCAAqC,CAAC,IAAI,CAAC,GAAG,CAAC,IAAI,YAAY,CAAC,IAAI,CAAC,GAAG,CAAC;IAC5F,KAAK,CAAC,MAAM;QACV,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACtC,yEAAyE;YACzE,sEAAsE;YACtE,sEAAsE;YACtE,uCAAuC;YACvC,MAAM,EAAE,GAAG,2CAA2C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YAClE,IAAI,EAAE,IAAI,CAAC,8BAA8B,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;gBAC7D,MAAM,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;gBAC1B,SAAS;YACX,CAAC;YACD,MAAM,GAAG,GAAG,2CAA2C,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;YACnE,IAAI,GAAG,IAAI,CAAC,8BAA8B,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,EAAE,CAAC;gBAC/D,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC;gBAC3B,SAAS;YACX,CAAC;YACD,gEAAgE;YAChE,mEAAmE;YACnE,iBAAiB;QACnB,CAAC;QACD,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;IAC5D,CAAC;CACF,CAAC;AAEF,MAAM,MAAM,GAAqB;IAC/B,IAAI,EAAE,QAAQ;IACd,QAAQ,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,oBAAoB,CAAC,IAAI,CAAC,GAAG,CAAC;IACjD,KAAK,CAAC,MAAM;QACV,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,MAAM,MAAM,GAAa,EAAE,CAAC;QAC5B,KAAK,MAAM,IAAI,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,EAAE,CAAC;YACtC,iEAAiE;YACjE,MAAM,CAAC,GAAG,uCAAuC,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;YACpE,IAAI,CAAC,CAAC;gBAAE,SAAS;YACjB,IAAI,CAAC,CAAC,CAAC,CAAC,KAAK,QAAQ;gBAAE,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;;gBACpC,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC;QACzB,CAAC;QACD,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC;IAC5D,CAAC;CACF,CAAC;AAEF,MAAM,CAAC,MAAM,cAAc,GAAuB,CAAC,QAAQ,EAAE,MAAM,CAAC,CAAC;AAErE,uEAAuE;AACvE,MAAM,UAAU,gBAAgB,CAAC,OAAe;IAC9C,OAAO,cAAc,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,CAAC,IAAI,IAAI,CAAC;AACjE,CAAC"}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface VerifyOptions {
|
|
2
|
+
/** Base ref for the comparison (default: merge-base with upstream, or origin/main). */
|
|
3
|
+
base?: string;
|
|
4
|
+
/** Override the detected test command. */
|
|
5
|
+
cmd?: string;
|
|
6
|
+
/** Override the parser choice (parser name, e.g. "node:test", "pytest"). */
|
|
7
|
+
parser?: string;
|
|
8
|
+
/** Per-run timeout in seconds. */
|
|
9
|
+
timeoutSecs?: number;
|
|
10
|
+
/** Run even when the working tree has uncommitted changes. */
|
|
11
|
+
allowDirty?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface VerifyDelta {
|
|
14
|
+
newlyFailing: string[];
|
|
15
|
+
newlyPassing: string[];
|
|
16
|
+
stillFailing: string[];
|
|
17
|
+
baseTotal: {
|
|
18
|
+
passed: number;
|
|
19
|
+
failed: number;
|
|
20
|
+
};
|
|
21
|
+
headTotal: {
|
|
22
|
+
passed: number;
|
|
23
|
+
failed: number;
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export interface VerifyResult {
|
|
27
|
+
ok: boolean;
|
|
28
|
+
/** Machine form of the outcome. */
|
|
29
|
+
outcome: "fixed" | "broke" | "clean" | "no-change" | "unverifiable";
|
|
30
|
+
/** "unverifiable" only: why the comparison could not be made. */
|
|
31
|
+
reason?: string;
|
|
32
|
+
command?: string;
|
|
33
|
+
parser?: string;
|
|
34
|
+
base?: string;
|
|
35
|
+
delta?: VerifyDelta;
|
|
36
|
+
markdown: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Detect the repo's test command from its manifest files. Returns null
|
|
40
|
+
* when nothing is detectable; the caller must then report unverifiable
|
|
41
|
+
* rather than guess a command.
|
|
42
|
+
*/
|
|
43
|
+
export declare function detectTestCommand(dir: string): string | null;
|
|
44
|
+
/** Resolve the default base ref: upstream merge-base, else local origin/main. */
|
|
45
|
+
export declare function defaultBase(dir: string): string | null;
|
|
46
|
+
export declare function renderVerifyMarkdown(r: VerifyResult): string;
|
|
47
|
+
/**
|
|
48
|
+
* Run the red-green comparison. All failure paths are explicit: this
|
|
49
|
+
* function would rather say "unverifiable" with a reason than report a
|
|
50
|
+
* delta it cannot defend.
|
|
51
|
+
*/
|
|
52
|
+
export declare function verifyRepo(dir: string, opts?: VerifyOptions): VerifyResult;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Honest verify: the red-green runner.
|
|
3
|
+
*
|
|
4
|
+
* `greenlight verify` answers one question with evidence: did my change
|
|
5
|
+
* fix the failing test, break something new, or neither? It runs the
|
|
6
|
+
* repo's test command on a base worktree and on the current tree, parses
|
|
7
|
+
* both runs into test identities with verify-parsers, and reports the
|
|
8
|
+
* DELTA: newly failing (yours), newly passing (fixed), still failing
|
|
9
|
+
* (pre-existing, not yours). Counts come from parsed output, never raw
|
|
10
|
+
* log lines.
|
|
11
|
+
*
|
|
12
|
+
* CLI only, on purpose: this runs test suites on the machine where the
|
|
13
|
+
* code lives, and suites can take minutes. The MCP server is for agents
|
|
14
|
+
* on other machines; the thing that runs your tests belongs in the CLI.
|
|
15
|
+
*/
|
|
16
|
+
import { execFileSync, spawnSync } from "node:child_process";
|
|
17
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
|
18
|
+
import { tmpdir } from "node:os";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { parserForCommand, RUNNER_PARSERS } from "./verify-parsers.js";
|
|
21
|
+
function sh(dir, args) {
|
|
22
|
+
return execFileSync("git", args, { cwd: dir, encoding: "utf8" }).trim();
|
|
23
|
+
}
|
|
24
|
+
function isGitRepo(dir) {
|
|
25
|
+
try {
|
|
26
|
+
sh(dir, ["rev-parse", "--git-dir"]);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function isTreeClean(dir) {
|
|
34
|
+
try {
|
|
35
|
+
return sh(dir, ["status", "--porcelain"]).length === 0;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Detect the repo's test command from its manifest files. Returns null
|
|
43
|
+
* when nothing is detectable; the caller must then report unverifiable
|
|
44
|
+
* rather than guess a command.
|
|
45
|
+
*/
|
|
46
|
+
export function detectTestCommand(dir) {
|
|
47
|
+
const pkg = join(dir, "package.json");
|
|
48
|
+
if (existsSync(pkg)) {
|
|
49
|
+
try {
|
|
50
|
+
const doc = JSON.parse(readFileSync(pkg, "utf8"));
|
|
51
|
+
// Return the script itself, not "npm test": the parser needs to see
|
|
52
|
+
// the real runner (node --test, vitest, pytest) to claim the output.
|
|
53
|
+
if (doc.scripts?.test)
|
|
54
|
+
return doc.scripts.test;
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
// fall through
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (existsSync(join(dir, "pyproject.toml")) || existsSync(join(dir, "pytest.ini")) || existsSync(join(dir, "setup.cfg"))) {
|
|
61
|
+
return "pytest -q";
|
|
62
|
+
}
|
|
63
|
+
if (existsSync(join(dir, "go.mod")))
|
|
64
|
+
return "go test ./...";
|
|
65
|
+
if (existsSync(join(dir, "Cargo.toml")))
|
|
66
|
+
return "cargo test";
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
/** Resolve the default base ref: upstream merge-base, else local origin/main. */
|
|
70
|
+
export function defaultBase(dir) {
|
|
71
|
+
try {
|
|
72
|
+
const upstream = sh(dir, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"]);
|
|
73
|
+
return sh(dir, ["merge-base", "HEAD", upstream]);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// no upstream tracking
|
|
77
|
+
}
|
|
78
|
+
for (const cand of ["origin/main", "origin/master"]) {
|
|
79
|
+
try {
|
|
80
|
+
sh(dir, ["rev-parse", "--verify", cand]);
|
|
81
|
+
return sh(dir, ["merge-base", "HEAD", cand]);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
// try next
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
/** Run a shell command in dir with a timeout, capturing merged output. */
|
|
90
|
+
function runCommand(dir, command, timeoutMs) {
|
|
91
|
+
try {
|
|
92
|
+
const r = spawnSync("sh", ["-c", command], {
|
|
93
|
+
cwd: dir,
|
|
94
|
+
encoding: "utf8",
|
|
95
|
+
timeout: timeoutMs,
|
|
96
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
97
|
+
});
|
|
98
|
+
if (r.error) {
|
|
99
|
+
const err = r.error;
|
|
100
|
+
if (err.code === "ENOENT")
|
|
101
|
+
return { output: "", exitCode: -1, timedOut: false, spawnFailed: true };
|
|
102
|
+
if (err.killed)
|
|
103
|
+
return { output: r.stdout + r.stderr, exitCode: -1, timedOut: true, spawnFailed: false };
|
|
104
|
+
return { output: String(err.message), exitCode: -1, timedOut: false, spawnFailed: true };
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
output: (r.stdout ?? "") + (r.stderr ?? ""),
|
|
108
|
+
exitCode: r.status ?? -1,
|
|
109
|
+
timedOut: false,
|
|
110
|
+
spawnFailed: false,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
catch (err) {
|
|
114
|
+
return { output: String(err), exitCode: -1, timedOut: false, spawnFailed: true };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
function deltaOf(baseFailed, headFailed) {
|
|
118
|
+
const b = new Set(baseFailed);
|
|
119
|
+
const h = new Set(headFailed);
|
|
120
|
+
return {
|
|
121
|
+
newlyFailing: [...h].filter((t) => !b.has(t)),
|
|
122
|
+
newlyPassing: [...b].filter((t) => !h.has(t)),
|
|
123
|
+
stillFailing: [...h].filter((t) => b.has(t)),
|
|
124
|
+
baseTotal: { passed: 0, failed: baseFailed.length },
|
|
125
|
+
headTotal: { passed: 0, failed: headFailed.length },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
export function renderVerifyMarkdown(r) {
|
|
129
|
+
if (r.outcome === "unverifiable") {
|
|
130
|
+
return `## Verify: unverifiable\n\n${r.reason ?? "no reason given"}\n`;
|
|
131
|
+
}
|
|
132
|
+
const d = r.delta;
|
|
133
|
+
const lines = [
|
|
134
|
+
`## Verify: base \`${r.base}\` vs working tree`,
|
|
135
|
+
``,
|
|
136
|
+
`Test command: \`${r.command}\` (parsed with ${r.parser})`,
|
|
137
|
+
`Base: ${d.baseTotal.failed} failing | Head: ${d.headTotal.failed} failing`,
|
|
138
|
+
``,
|
|
139
|
+
];
|
|
140
|
+
if (d.newlyPassing.length > 0) {
|
|
141
|
+
lines.push(`### Fixed by your change (${d.newlyPassing.length})`);
|
|
142
|
+
for (const t of d.newlyPassing)
|
|
143
|
+
lines.push(`- ${t}`);
|
|
144
|
+
lines.push(``);
|
|
145
|
+
}
|
|
146
|
+
if (d.newlyFailing.length > 0) {
|
|
147
|
+
lines.push(`### Newly failing: these are yours (${d.newlyFailing.length})`);
|
|
148
|
+
for (const t of d.newlyFailing)
|
|
149
|
+
lines.push(`- ${t}`);
|
|
150
|
+
lines.push(``);
|
|
151
|
+
}
|
|
152
|
+
if (d.stillFailing.length > 0) {
|
|
153
|
+
lines.push(`### Still failing: pre-existing, not yours (${d.stillFailing.length})`);
|
|
154
|
+
for (const t of d.stillFailing)
|
|
155
|
+
lines.push(`- ${t}`);
|
|
156
|
+
lines.push(``);
|
|
157
|
+
}
|
|
158
|
+
if (d.newlyPassing.length === 0 && d.newlyFailing.length === 0 && d.stillFailing.length === 0) {
|
|
159
|
+
lines.push(`No failing tests on either side; nothing to report.`);
|
|
160
|
+
}
|
|
161
|
+
return lines.join("\n");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Run the red-green comparison. All failure paths are explicit: this
|
|
165
|
+
* function would rather say "unverifiable" with a reason than report a
|
|
166
|
+
* delta it cannot defend.
|
|
167
|
+
*/
|
|
168
|
+
export function verifyRepo(dir, opts = {}) {
|
|
169
|
+
const timeoutMs = (opts.timeoutSecs ?? 600) * 1000;
|
|
170
|
+
if (!isGitRepo(dir)) {
|
|
171
|
+
return { ok: false, outcome: "unverifiable", reason: `${dir} is not a git repository`, markdown: "" };
|
|
172
|
+
}
|
|
173
|
+
if (!opts.allowDirty && !isTreeClean(dir)) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
outcome: "unverifiable",
|
|
177
|
+
reason: "working tree has uncommitted changes; commit or stash first, or re-run with --allow-dirty (the head run will then include your uncommitted changes)",
|
|
178
|
+
markdown: "",
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
const base = opts.base ?? defaultBase(dir);
|
|
182
|
+
if (!base) {
|
|
183
|
+
return {
|
|
184
|
+
ok: false,
|
|
185
|
+
outcome: "unverifiable",
|
|
186
|
+
reason: "could not determine a base ref (no upstream tracking branch, no local origin/main); pass --base <ref>",
|
|
187
|
+
markdown: "",
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
const command = opts.cmd ?? detectTestCommand(dir);
|
|
191
|
+
if (!command) {
|
|
192
|
+
return {
|
|
193
|
+
ok: false,
|
|
194
|
+
outcome: "unverifiable",
|
|
195
|
+
reason: "no test command detected (checked package.json scripts.test, pytest, go, cargo); pass --cmd \"<test command>\"",
|
|
196
|
+
markdown: "",
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const parser = opts.parser
|
|
200
|
+
? (RUNNER_PARSERS.find((p) => p.name === opts.parser) ?? null)
|
|
201
|
+
: parserForCommand(command);
|
|
202
|
+
if (!parser) {
|
|
203
|
+
return {
|
|
204
|
+
ok: false,
|
|
205
|
+
outcome: "unverifiable",
|
|
206
|
+
reason: `no output parser claims "${command}" (parsers: ${["node:test", "pytest"].join(", ")}); pass --cmd with a supported runner`,
|
|
207
|
+
markdown: "",
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
// Base runs in a detached temp worktree so the working tree is never
|
|
211
|
+
// touched. Removed afterwards even on failure.
|
|
212
|
+
const baseDir = mkdtempSync(join(tmpdir(), "greenlight-verify-base-"));
|
|
213
|
+
try {
|
|
214
|
+
try {
|
|
215
|
+
sh(dir, ["worktree", "add", "--detach", baseDir, base]);
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
return { ok: false, outcome: "unverifiable", reason: `could not create base worktree: ${err instanceof Error ? err.message : String(err)}`, markdown: "" };
|
|
219
|
+
}
|
|
220
|
+
const baseRun = runCommand(baseDir, command, timeoutMs);
|
|
221
|
+
const headRun = runCommand(dir, command, timeoutMs);
|
|
222
|
+
for (const [name, run] of [["base", baseRun], ["head", headRun]]) {
|
|
223
|
+
if (run.spawnFailed) {
|
|
224
|
+
return { ok: false, outcome: "unverifiable", reason: `could not run the test command on ${name}: ${run.output || "spawn failed"}`, markdown: "" };
|
|
225
|
+
}
|
|
226
|
+
if (run.timedOut) {
|
|
227
|
+
return { ok: false, outcome: "unverifiable", reason: `test command timed out on ${name} after ${opts.timeoutSecs ?? 600}s`, markdown: "" };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
const baseParsed = parser.parse(baseRun.output);
|
|
231
|
+
const headParsed = parser.parse(headRun.output);
|
|
232
|
+
if (baseParsed.passed.length + baseParsed.failed.length === 0 || headParsed.passed.length + headParsed.failed.length === 0) {
|
|
233
|
+
return {
|
|
234
|
+
ok: false,
|
|
235
|
+
outcome: "unverifiable",
|
|
236
|
+
reason: `parsed zero tests from the ${parser.name} output (base: ${baseParsed.passed.length + baseParsed.failed.length}, head: ${headParsed.passed.length + headParsed.failed.length}); the parser may not understand this runner's output format`,
|
|
237
|
+
markdown: "",
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
const delta = deltaOf(baseParsed.failed, headParsed.failed);
|
|
241
|
+
delta.baseTotal = { passed: baseParsed.passed.length, failed: baseParsed.failed.length };
|
|
242
|
+
delta.headTotal = { passed: headParsed.passed.length, failed: headParsed.failed.length };
|
|
243
|
+
const outcome = delta.newlyFailing.length > 0
|
|
244
|
+
? "broke"
|
|
245
|
+
: delta.newlyPassing.length > 0
|
|
246
|
+
? "fixed"
|
|
247
|
+
: delta.stillFailing.length > 0
|
|
248
|
+
? "no-change"
|
|
249
|
+
: "clean";
|
|
250
|
+
const result = {
|
|
251
|
+
ok: true,
|
|
252
|
+
outcome,
|
|
253
|
+
command,
|
|
254
|
+
parser: parser.name,
|
|
255
|
+
base,
|
|
256
|
+
delta,
|
|
257
|
+
markdown: "",
|
|
258
|
+
};
|
|
259
|
+
result.markdown = renderVerifyMarkdown(result);
|
|
260
|
+
return result;
|
|
261
|
+
}
|
|
262
|
+
finally {
|
|
263
|
+
try {
|
|
264
|
+
sh(dir, ["worktree", "remove", "--force", baseDir]);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
try {
|
|
268
|
+
rmSync(baseDir, { recursive: true, force: true });
|
|
269
|
+
}
|
|
270
|
+
catch {
|
|
271
|
+
// best effort; a stale worktree dir is cleaned by git gc
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
//# sourceMappingURL=verify.js.map
|