@ahmedalbarghouti/farq 0.0.1
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/LICENSE +21 -0
- package/README.md +169 -0
- package/dist/config.d.ts +18 -0
- package/dist/config.js +74 -0
- package/dist/extract-json.d.ts +5 -0
- package/dist/extract-json.js +60 -0
- package/dist/git.d.ts +36 -0
- package/dist/git.js +277 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +204 -0
- package/dist/open-pr.d.ts +26 -0
- package/dist/open-pr.js +199 -0
- package/dist/providers/claude.d.ts +3 -0
- package/dist/providers/claude.js +88 -0
- package/dist/providers/fake.d.ts +8 -0
- package/dist/providers/fake.js +43 -0
- package/dist/providers/index.d.ts +22 -0
- package/dist/providers/index.js +40 -0
- package/dist/providers/opencode.d.ts +3 -0
- package/dist/providers/opencode.js +33 -0
- package/dist/render/json.d.ts +2 -0
- package/dist/render/json.js +3 -0
- package/dist/render/pr.d.ts +7 -0
- package/dist/render/pr.js +48 -0
- package/dist/render/slack.d.ts +2 -0
- package/dist/render/slack.js +25 -0
- package/dist/schema.d.ts +74 -0
- package/dist/schema.js +39 -0
- package/dist/summarize.d.ts +17 -0
- package/dist/summarize.js +72 -0
- package/dist/template.d.ts +11 -0
- package/dist/template.js +87 -0
- package/dist/title.d.ts +12 -0
- package/dist/title.js +37 -0
- package/dist/visual/chrome.d.ts +12 -0
- package/dist/visual/chrome.js +62 -0
- package/dist/visual/compose.d.ts +16 -0
- package/dist/visual/compose.js +58 -0
- package/dist/visual/diagram.d.ts +17 -0
- package/dist/visual/diagram.js +36 -0
- package/dist/visual/gate.d.ts +11 -0
- package/dist/visual/gate.js +28 -0
- package/dist/visual/mockup.d.ts +26 -0
- package/dist/visual/mockup.js +46 -0
- package/dist/visual/pipeline.d.ts +23 -0
- package/dist/visual/pipeline.js +126 -0
- package/package.json +53 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { truncateTitle, GITHUB_TITLE_MAX } from "../title.js";
|
|
2
|
+
const EMOJI = {
|
|
3
|
+
feature: "\u2728",
|
|
4
|
+
fix: "\uD83D\uDC1B",
|
|
5
|
+
improvement: "\uD83D\uDC8E",
|
|
6
|
+
refactor: "\u267B\uFE0F",
|
|
7
|
+
chore: "\uD83D\uDD27",
|
|
8
|
+
docs: "\uD83D\uDCDD",
|
|
9
|
+
perf: "\u26A1",
|
|
10
|
+
security: "\uD83D\uDD12",
|
|
11
|
+
};
|
|
12
|
+
export function renderPr(options) {
|
|
13
|
+
const { title, overflow } = truncateTitle(options.summary.headline, GITHUB_TITLE_MAX);
|
|
14
|
+
const out = [];
|
|
15
|
+
out.push(options.summary.overview);
|
|
16
|
+
if (overflow) {
|
|
17
|
+
out.push("");
|
|
18
|
+
out.push(overflow);
|
|
19
|
+
}
|
|
20
|
+
if (options.imagePath) {
|
|
21
|
+
out.push("");
|
|
22
|
+
out.push("### Before / After");
|
|
23
|
+
out.push("");
|
|
24
|
+
const alt = options.imageAlt ?? "before / after";
|
|
25
|
+
out.push(``);
|
|
26
|
+
out.push("");
|
|
27
|
+
out.push("_Local image path - attach the file when pasting into GitHub if it does not render._");
|
|
28
|
+
}
|
|
29
|
+
out.push("");
|
|
30
|
+
out.push("### Changes");
|
|
31
|
+
out.push("");
|
|
32
|
+
for (const item of options.summary.items) {
|
|
33
|
+
const emoji = EMOJI[item.category] ?? "\u2022";
|
|
34
|
+
const files = item.files.length > 0
|
|
35
|
+
? ` <sub>${item.files.join(", ")}</sub>`
|
|
36
|
+
: "";
|
|
37
|
+
out.push(`- ${emoji} **${item.title}** \u2014 ${item.description}${files}`);
|
|
38
|
+
}
|
|
39
|
+
if (options.summary.breaking_changes.length > 0) {
|
|
40
|
+
out.push("");
|
|
41
|
+
out.push("### \u26A0\uFE0F Breaking changes");
|
|
42
|
+
out.push("");
|
|
43
|
+
for (const b of options.summary.breaking_changes) {
|
|
44
|
+
out.push(`- ${b}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return `${title}\n\n${out.join("\n").trim()}\n`;
|
|
48
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
const EMOJI = {
|
|
2
|
+
feature: ":sparkles:",
|
|
3
|
+
fix: ":bug:",
|
|
4
|
+
improvement: ":gem:",
|
|
5
|
+
refactor: ":recycle:",
|
|
6
|
+
chore: ":wrench:",
|
|
7
|
+
docs: ":memo:",
|
|
8
|
+
perf: ":zap:",
|
|
9
|
+
security: ":lock:",
|
|
10
|
+
};
|
|
11
|
+
export function renderSlack(summary) {
|
|
12
|
+
const lines = [];
|
|
13
|
+
lines.push(`*${summary.headline}*`);
|
|
14
|
+
lines.push(summary.overview);
|
|
15
|
+
lines.push("");
|
|
16
|
+
for (const item of summary.items) {
|
|
17
|
+
const emoji = EMOJI[item.category] ?? ":small_blue_diamond:";
|
|
18
|
+
lines.push(`${emoji} *${item.title}* — ${item.description}`);
|
|
19
|
+
}
|
|
20
|
+
if (summary.breaking_changes.length > 0) {
|
|
21
|
+
lines.push("");
|
|
22
|
+
lines.push(`:warning: Breaking: ${summary.breaking_changes.join("; ")}`);
|
|
23
|
+
}
|
|
24
|
+
return `${lines.join("\n").trim()}\n`;
|
|
25
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export declare const CATEGORIES: readonly ["feature", "fix", "improvement", "refactor", "chore", "docs", "perf", "security"];
|
|
3
|
+
export declare const ChangeItemSchema: z.ZodObject<{
|
|
4
|
+
category: z.ZodEnum<["feature", "fix", "improvement", "refactor", "chore", "docs", "perf", "security"]>;
|
|
5
|
+
title: z.ZodString;
|
|
6
|
+
description: z.ZodString;
|
|
7
|
+
why_it_matters: z.ZodOptional<z.ZodString>;
|
|
8
|
+
files: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
9
|
+
}, "strip", z.ZodTypeAny, {
|
|
10
|
+
files: string[];
|
|
11
|
+
category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
|
|
12
|
+
title: string;
|
|
13
|
+
description: string;
|
|
14
|
+
why_it_matters?: string | undefined;
|
|
15
|
+
}, {
|
|
16
|
+
category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
|
|
17
|
+
title: string;
|
|
18
|
+
description: string;
|
|
19
|
+
files?: string[] | undefined;
|
|
20
|
+
why_it_matters?: string | undefined;
|
|
21
|
+
}>;
|
|
22
|
+
export declare const ChangeSummarySchema: z.ZodObject<{
|
|
23
|
+
headline: z.ZodString;
|
|
24
|
+
overview: z.ZodString;
|
|
25
|
+
items: z.ZodArray<z.ZodObject<{
|
|
26
|
+
category: z.ZodEnum<["feature", "fix", "improvement", "refactor", "chore", "docs", "perf", "security"]>;
|
|
27
|
+
title: z.ZodString;
|
|
28
|
+
description: z.ZodString;
|
|
29
|
+
why_it_matters: z.ZodOptional<z.ZodString>;
|
|
30
|
+
files: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
31
|
+
}, "strip", z.ZodTypeAny, {
|
|
32
|
+
files: string[];
|
|
33
|
+
category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
|
|
34
|
+
title: string;
|
|
35
|
+
description: string;
|
|
36
|
+
why_it_matters?: string | undefined;
|
|
37
|
+
}, {
|
|
38
|
+
category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
|
|
39
|
+
title: string;
|
|
40
|
+
description: string;
|
|
41
|
+
files?: string[] | undefined;
|
|
42
|
+
why_it_matters?: string | undefined;
|
|
43
|
+
}>, "many">;
|
|
44
|
+
breaking_changes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
45
|
+
visual_notes: z.ZodOptional<z.ZodString>;
|
|
46
|
+
}, "strip", z.ZodTypeAny, {
|
|
47
|
+
headline: string;
|
|
48
|
+
overview: string;
|
|
49
|
+
items: {
|
|
50
|
+
files: string[];
|
|
51
|
+
category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
|
|
52
|
+
title: string;
|
|
53
|
+
description: string;
|
|
54
|
+
why_it_matters?: string | undefined;
|
|
55
|
+
}[];
|
|
56
|
+
breaking_changes: string[];
|
|
57
|
+
visual_notes?: string | undefined;
|
|
58
|
+
}, {
|
|
59
|
+
headline: string;
|
|
60
|
+
overview: string;
|
|
61
|
+
items: {
|
|
62
|
+
category: "feature" | "fix" | "improvement" | "refactor" | "chore" | "docs" | "perf" | "security";
|
|
63
|
+
title: string;
|
|
64
|
+
description: string;
|
|
65
|
+
files?: string[] | undefined;
|
|
66
|
+
why_it_matters?: string | undefined;
|
|
67
|
+
}[];
|
|
68
|
+
breaking_changes?: string[] | undefined;
|
|
69
|
+
visual_notes?: string | undefined;
|
|
70
|
+
}>;
|
|
71
|
+
export type ChangeSummary = z.infer<typeof ChangeSummarySchema>;
|
|
72
|
+
export type ChangeItem = z.infer<typeof ChangeItemSchema>;
|
|
73
|
+
/** Compact schema description embedded in AI prompts. */
|
|
74
|
+
export declare const CHANGE_SUMMARY_SCHEMA_PROMPT = "{\n \"headline\": \"string <=140 \u2014 one-line summary (PR title)\",\n \"overview\": \"string \u2014 1-3 sentences in requested tone\",\n \"items\": [{\n \"category\": \"feature|fix|improvement|refactor|chore|docs|perf|security\",\n \"title\": \"string <=120\",\n \"description\": \"string\",\n \"why_it_matters\": \"string (optional; required for client tone)\",\n \"files\": [\"string\"]\n }],\n \"breaking_changes\": [\"string\"],\n \"visual_notes\": \"string (optional)\"\n}";
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
export const CATEGORIES = [
|
|
3
|
+
"feature",
|
|
4
|
+
"fix",
|
|
5
|
+
"improvement",
|
|
6
|
+
"refactor",
|
|
7
|
+
"chore",
|
|
8
|
+
"docs",
|
|
9
|
+
"perf",
|
|
10
|
+
"security",
|
|
11
|
+
];
|
|
12
|
+
export const ChangeItemSchema = z.object({
|
|
13
|
+
category: z.enum(CATEGORIES),
|
|
14
|
+
title: z.string().min(1).max(120),
|
|
15
|
+
description: z.string().min(1),
|
|
16
|
+
why_it_matters: z.string().min(1).optional(),
|
|
17
|
+
files: z.array(z.string()).default([]),
|
|
18
|
+
});
|
|
19
|
+
export const ChangeSummarySchema = z.object({
|
|
20
|
+
headline: z.string().min(1).max(140),
|
|
21
|
+
overview: z.string().min(1),
|
|
22
|
+
items: z.array(ChangeItemSchema).min(1),
|
|
23
|
+
breaking_changes: z.array(z.string()).default([]),
|
|
24
|
+
visual_notes: z.string().min(1).optional(),
|
|
25
|
+
});
|
|
26
|
+
/** Compact schema description embedded in AI prompts. */
|
|
27
|
+
export const CHANGE_SUMMARY_SCHEMA_PROMPT = `{
|
|
28
|
+
"headline": "string <=140 — one-line summary (PR title)",
|
|
29
|
+
"overview": "string — 1-3 sentences in requested tone",
|
|
30
|
+
"items": [{
|
|
31
|
+
"category": "feature|fix|improvement|refactor|chore|docs|perf|security",
|
|
32
|
+
"title": "string <=120",
|
|
33
|
+
"description": "string",
|
|
34
|
+
"why_it_matters": "string (optional; required for client tone)",
|
|
35
|
+
"files": ["string"]
|
|
36
|
+
}],
|
|
37
|
+
"breaking_changes": ["string"],
|
|
38
|
+
"visual_notes": "string (optional)"
|
|
39
|
+
}`;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type ChangeSummary } from "./schema.js";
|
|
2
|
+
import type { Provider } from "./providers/index.js";
|
|
3
|
+
import type { ToneName } from "./config.js";
|
|
4
|
+
import type { GatherDiffResult } from "./git.js";
|
|
5
|
+
export declare class SummarizeError extends Error {
|
|
6
|
+
readonly exitCode = 2;
|
|
7
|
+
constructor(message: string);
|
|
8
|
+
}
|
|
9
|
+
export type SummarizeOptions = {
|
|
10
|
+
provider: Provider;
|
|
11
|
+
diff: GatherDiffResult;
|
|
12
|
+
tone: ToneName;
|
|
13
|
+
titleConventionBlurb?: string;
|
|
14
|
+
model?: string;
|
|
15
|
+
};
|
|
16
|
+
export declare function summarize(options: SummarizeOptions): Promise<ChangeSummary>;
|
|
17
|
+
export declare function buildPrompt(options: SummarizeOptions): string;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { extractJson } from "./extract-json.js";
|
|
2
|
+
import { CHANGE_SUMMARY_SCHEMA_PROMPT, ChangeSummarySchema, } from "./schema.js";
|
|
3
|
+
export class SummarizeError extends Error {
|
|
4
|
+
exitCode = 2;
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "SummarizeError";
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
export async function summarize(options) {
|
|
11
|
+
const prompt = buildPrompt(options);
|
|
12
|
+
let lastError = "";
|
|
13
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
14
|
+
const fullPrompt = attempt === 0
|
|
15
|
+
? prompt
|
|
16
|
+
: `${prompt}\n\nYour previous output failed validation:\n${lastError}\nReturn corrected JSON only.`;
|
|
17
|
+
let raw;
|
|
18
|
+
try {
|
|
19
|
+
raw = await options.provider.complete(fullPrompt, {
|
|
20
|
+
model: options.model,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
throw new SummarizeError(err instanceof Error ? err.message : String(err));
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
const json = extractJson(raw);
|
|
28
|
+
const parsed = ChangeSummarySchema.parse(json);
|
|
29
|
+
if (options.tone === "client") {
|
|
30
|
+
for (const item of parsed.items) {
|
|
31
|
+
if (!item.why_it_matters) {
|
|
32
|
+
throw new Error(`Item "${item.title}" missing why_it_matters (required for client tone)`);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
lastError = err instanceof Error ? err.message : String(err);
|
|
40
|
+
if (attempt === 1) {
|
|
41
|
+
throw new SummarizeError(`AI output invalid after retry: ${lastError}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw new SummarizeError("AI output invalid after retry");
|
|
46
|
+
}
|
|
47
|
+
export function buildPrompt(options) {
|
|
48
|
+
const toneRules = options.tone === "client"
|
|
49
|
+
? "Tone: plain English for a non-technical client. Avoid jargon. Every item MUST include why_it_matters."
|
|
50
|
+
: "Tone: technical, concise, accurate for engineers.";
|
|
51
|
+
const convention = options.titleConventionBlurb
|
|
52
|
+
? `\nTitle conventions for this repo:\n${options.titleConventionBlurb}\n`
|
|
53
|
+
: "";
|
|
54
|
+
return `You summarize a git diff into a structured change summary.
|
|
55
|
+
|
|
56
|
+
Rules:
|
|
57
|
+
- Return JSON only. No markdown fences. No prose before or after.
|
|
58
|
+
- Group related edits (never one item per file).
|
|
59
|
+
- Report only what the diff shows. Do not invent work.
|
|
60
|
+
- ${toneRules}
|
|
61
|
+
${convention}
|
|
62
|
+
Schema:
|
|
63
|
+
${CHANGE_SUMMARY_SCHEMA_PROMPT}
|
|
64
|
+
|
|
65
|
+
Diff range: ${options.diff.range}
|
|
66
|
+
Commits:
|
|
67
|
+
${options.diff.commits.map((c) => `- ${c}`).join("\n") || "(none)"}
|
|
68
|
+
|
|
69
|
+
Diff:
|
|
70
|
+
${options.diff.diffText}
|
|
71
|
+
`;
|
|
72
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ChangeSummary } from "./schema.js";
|
|
2
|
+
export declare function findPrTemplate(cwd: string): string | null;
|
|
3
|
+
export declare function fillPrTemplate(options: {
|
|
4
|
+
template: string | null;
|
|
5
|
+
summary: ChangeSummary;
|
|
6
|
+
bodyMarkdown: string;
|
|
7
|
+
imageUrl?: string | null;
|
|
8
|
+
}): {
|
|
9
|
+
title: string;
|
|
10
|
+
body: string;
|
|
11
|
+
};
|
package/dist/template.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { truncateTitle, GITHUB_TITLE_MAX } from "./title.js";
|
|
4
|
+
export function findPrTemplate(cwd) {
|
|
5
|
+
const single = join(cwd, ".github", "pull_request_template.md");
|
|
6
|
+
if (existsSync(single))
|
|
7
|
+
return readFileSync(single, "utf8");
|
|
8
|
+
const alt = join(cwd, ".github", "PULL_REQUEST_TEMPLATE.md");
|
|
9
|
+
if (existsSync(alt))
|
|
10
|
+
return readFileSync(alt, "utf8");
|
|
11
|
+
const dir = join(cwd, ".github", "PULL_REQUEST_TEMPLATE");
|
|
12
|
+
if (existsSync(dir)) {
|
|
13
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".md"));
|
|
14
|
+
if (files[0])
|
|
15
|
+
return readFileSync(join(dir, files[0]), "utf8");
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export function fillPrTemplate(options) {
|
|
20
|
+
const { title, overflow } = truncateTitle(options.summary.headline, GITHUB_TITLE_MAX);
|
|
21
|
+
let body = options.template?.trim() || "";
|
|
22
|
+
if (!body) {
|
|
23
|
+
body = options.bodyMarkdown;
|
|
24
|
+
if (overflow)
|
|
25
|
+
body = overflow + "\n\n" + body;
|
|
26
|
+
if (options.imageUrl) {
|
|
27
|
+
const re = /\]\((?:\.\/)?\.farq\/[^)]+\)/;
|
|
28
|
+
if (re.test(body)) {
|
|
29
|
+
body = body.replace(re, "](" + options.imageUrl + ")");
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
body =
|
|
33
|
+
"### Before / After\n\n\n\n" +
|
|
36
|
+
body;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { title, body };
|
|
40
|
+
}
|
|
41
|
+
body = fillSection(body, /summary/i, options.summary.overview);
|
|
42
|
+
const changes = options.summary.items
|
|
43
|
+
.map((i) => "- **" + i.title + "** — " + i.description)
|
|
44
|
+
.join("\n");
|
|
45
|
+
body = fillSection(body, /changes?|what('s| is)? changed/i, changes);
|
|
46
|
+
const testPlan = "_Please add verification steps._\n\n" +
|
|
47
|
+
options.summary.items.map((i) => "- [ ] Verify: " + i.title).join("\n");
|
|
48
|
+
body = fillSection(body, /test\s*plan|testing|how to test/i, testPlan);
|
|
49
|
+
if (overflow)
|
|
50
|
+
body = overflow + "\n\n" + body;
|
|
51
|
+
if (options.imageUrl) {
|
|
52
|
+
if (/before\s*\/\s*after|screenshots?/i.test(body)) {
|
|
53
|
+
body = fillSection(body, /before\s*\/\s*after|screenshots?/i, "");
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
body +=
|
|
57
|
+
"\n\n### Before / After\n\n\n";
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return { title, body };
|
|
63
|
+
}
|
|
64
|
+
function fillSection(template, heading, content) {
|
|
65
|
+
const lines = template.split("\n");
|
|
66
|
+
let inSection = false;
|
|
67
|
+
let start = -1;
|
|
68
|
+
let end = lines.length;
|
|
69
|
+
for (let i = 0; i < lines.length; i++) {
|
|
70
|
+
const h = lines[i].match(/^#{1,3}\s+(.*)$/);
|
|
71
|
+
if (h) {
|
|
72
|
+
if (inSection) {
|
|
73
|
+
end = i;
|
|
74
|
+
break;
|
|
75
|
+
}
|
|
76
|
+
if (heading.test(h[1])) {
|
|
77
|
+
inSection = true;
|
|
78
|
+
start = i;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (start === -1)
|
|
83
|
+
return template;
|
|
84
|
+
const before = lines.slice(0, start + 1);
|
|
85
|
+
const after = lines.slice(end);
|
|
86
|
+
return [...before, "", content, "", ...after].join("\n").replace(/\n{3,}/g, "\n\n");
|
|
87
|
+
}
|
package/dist/title.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type TruncatedTitle = {
|
|
2
|
+
title: string;
|
|
3
|
+
overflow: string;
|
|
4
|
+
};
|
|
5
|
+
export type TitleConvention = {
|
|
6
|
+
kind: "conventional" | "ticket" | "none";
|
|
7
|
+
blurb: string;
|
|
8
|
+
};
|
|
9
|
+
/** GitHub PR title hard limit is 256 characters. */
|
|
10
|
+
export declare const GITHUB_TITLE_MAX = 256;
|
|
11
|
+
export declare function truncateTitle(text: string, max?: number): TruncatedTitle;
|
|
12
|
+
export declare function inferTitleConvention(samples: string[]): TitleConvention;
|
package/dist/title.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** GitHub PR title hard limit is 256 characters. */
|
|
2
|
+
export const GITHUB_TITLE_MAX = 256;
|
|
3
|
+
export function truncateTitle(text, max = GITHUB_TITLE_MAX) {
|
|
4
|
+
if (text.length <= max)
|
|
5
|
+
return { title: text, overflow: "" };
|
|
6
|
+
const ellipsis = "...";
|
|
7
|
+
const budget = max - ellipsis.length;
|
|
8
|
+
let cut = budget;
|
|
9
|
+
const slice = text.slice(0, budget + 1);
|
|
10
|
+
const lastSpace = slice.lastIndexOf(" ");
|
|
11
|
+
if (lastSpace >= Math.floor(budget * 0.6)) {
|
|
12
|
+
cut = lastSpace;
|
|
13
|
+
}
|
|
14
|
+
const title = text.slice(0, cut).trimEnd() + ellipsis;
|
|
15
|
+
const overflow = text.slice(cut).trimStart();
|
|
16
|
+
return { title, overflow };
|
|
17
|
+
}
|
|
18
|
+
export function inferTitleConvention(samples) {
|
|
19
|
+
if (samples.length === 0) {
|
|
20
|
+
return { kind: "none", blurb: "" };
|
|
21
|
+
}
|
|
22
|
+
const conventional = samples.filter((s) => /^(feat|fix|chore|docs|refactor|perf|test|style|ci|build)(\([^)]+\))?:/i.test(s.trim()));
|
|
23
|
+
const ticket = samples.filter((s) => /^\[[A-Z][A-Z0-9]+-\d+\]/.test(s.trim()));
|
|
24
|
+
if (conventional.length >= Math.ceil(samples.length * 0.6)) {
|
|
25
|
+
return {
|
|
26
|
+
kind: "conventional",
|
|
27
|
+
blurb: "Repo PR titles usually follow Conventional Commits (e.g. feat:, fix(scope):). Match that style. Prefer titles around 72 characters when possible.",
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
if (ticket.length >= Math.ceil(samples.length * 0.6)) {
|
|
31
|
+
return {
|
|
32
|
+
kind: "ticket",
|
|
33
|
+
blurb: "Repo PR titles usually start with a ticket prefix like [PROJ-123]. Match that prefix style when a ticket id is available in the branch or commits; otherwise keep a clear plain title.",
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
return { kind: "none", blurb: "" };
|
|
37
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class ChromeError extends Error {
|
|
2
|
+
constructor(message: string);
|
|
3
|
+
}
|
|
4
|
+
export declare function resolveChrome(env?: NodeJS.ProcessEnv): string;
|
|
5
|
+
export type ScreenshotOptions = {
|
|
6
|
+
chromePath?: string;
|
|
7
|
+
url: string;
|
|
8
|
+
outPath: string;
|
|
9
|
+
width?: number;
|
|
10
|
+
height?: number;
|
|
11
|
+
};
|
|
12
|
+
export declare function screenshotHtml(options: ScreenshotOptions): Promise<void>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { execa } from "execa";
|
|
3
|
+
import { platform } from "node:os";
|
|
4
|
+
const TIMEOUT_MS = 30_000;
|
|
5
|
+
export class ChromeError extends Error {
|
|
6
|
+
constructor(message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.name = "ChromeError";
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function resolveChrome(env = process.env) {
|
|
12
|
+
if (env.CHROME_PATH) {
|
|
13
|
+
if (existsSync(env.CHROME_PATH))
|
|
14
|
+
return env.CHROME_PATH;
|
|
15
|
+
throw new ChromeError(`CHROME_PATH not found: ${env.CHROME_PATH}`);
|
|
16
|
+
}
|
|
17
|
+
const candidates = platform() === "win32"
|
|
18
|
+
? [
|
|
19
|
+
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
|
20
|
+
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
|
21
|
+
`${env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
|
|
22
|
+
"C:\\Program Files\\Chromium\\Application\\chrome.exe",
|
|
23
|
+
]
|
|
24
|
+
: platform() === "darwin"
|
|
25
|
+
? [
|
|
26
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
27
|
+
"/Applications/Chromium.app/Contents/MacOS/Chromium",
|
|
28
|
+
]
|
|
29
|
+
: [
|
|
30
|
+
"/usr/bin/google-chrome",
|
|
31
|
+
"/usr/bin/google-chrome-stable",
|
|
32
|
+
"/usr/bin/chromium",
|
|
33
|
+
"/usr/bin/chromium-browser",
|
|
34
|
+
];
|
|
35
|
+
for (const c of candidates) {
|
|
36
|
+
if (c && existsSync(c))
|
|
37
|
+
return c;
|
|
38
|
+
}
|
|
39
|
+
throw new ChromeError("Chrome/Chromium not found — install Google Chrome or set CHROME_PATH");
|
|
40
|
+
}
|
|
41
|
+
export async function screenshotHtml(options) {
|
|
42
|
+
const chrome = options.chromePath ?? resolveChrome();
|
|
43
|
+
const width = options.width ?? 1280;
|
|
44
|
+
const height = options.height ?? 900;
|
|
45
|
+
try {
|
|
46
|
+
await execa(chrome, [
|
|
47
|
+
"--headless=new",
|
|
48
|
+
"--disable-gpu",
|
|
49
|
+
"--hide-scrollbars",
|
|
50
|
+
`--window-size=${width},${height}`,
|
|
51
|
+
`--screenshot=${options.outPath}`,
|
|
52
|
+
options.url,
|
|
53
|
+
], { timeout: TIMEOUT_MS, reject: true });
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
const e = err;
|
|
57
|
+
if (e.timedOut) {
|
|
58
|
+
throw new ChromeError("Chrome screenshot timed out after 30s");
|
|
59
|
+
}
|
|
60
|
+
throw new ChromeError(`Chrome screenshot failed: ${e.message ?? "unknown error"}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolveChrome, ChromeError } from "./chrome.js";
|
|
2
|
+
export type ComposeOptions = {
|
|
3
|
+
cwd?: string;
|
|
4
|
+
outDir?: string;
|
|
5
|
+
beforePath: string;
|
|
6
|
+
afterPath: string;
|
|
7
|
+
badge?: "generated preview" | "before / after";
|
|
8
|
+
chromePath?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function buildComposeHtml(options: {
|
|
11
|
+
beforeBase64: string;
|
|
12
|
+
afterBase64: string;
|
|
13
|
+
badge: string;
|
|
14
|
+
}): string;
|
|
15
|
+
export declare function composeBeforeAfter(options: ComposeOptions): Promise<string>;
|
|
16
|
+
export { ChromeError, resolveChrome };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { pathToFileURL } from "node:url";
|
|
4
|
+
import { resolveChrome, screenshotHtml, ChromeError } from "./chrome.js";
|
|
5
|
+
export function buildComposeHtml(options) {
|
|
6
|
+
const { beforeBase64, afterBase64, badge } = options;
|
|
7
|
+
return `<!doctype html>
|
|
8
|
+
<html><head><meta charset="utf-8" />
|
|
9
|
+
<style>
|
|
10
|
+
html,body{margin:0;background:#1b1d21;color:#e8e8e8;font-family:system-ui,sans-serif}
|
|
11
|
+
.wrap{padding:24px}
|
|
12
|
+
.badge{position:fixed;top:12px;right:12px;font-size:11px;letter-spacing:.04em;text-transform:uppercase;
|
|
13
|
+
background:#2a2e35;border:1px solid #3a3f48;padding:4px 8px;border-radius:4px;opacity:.9}
|
|
14
|
+
.grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
15
|
+
h2{margin:0 0 10px;font-size:14px;font-weight:600;color:#b9c0c9}
|
|
16
|
+
img{width:100%;height:auto;background:#0f1114;border:1px solid #2f343d;border-radius:6px;display:block}
|
|
17
|
+
</style></head>
|
|
18
|
+
<body>
|
|
19
|
+
<div class="badge">${escapeHtml(badge)}</div>
|
|
20
|
+
<div class="wrap"><div class="grid">
|
|
21
|
+
<section><h2>Before</h2><img alt="Before" src="data:image/png;base64,${beforeBase64}" /></section>
|
|
22
|
+
<section><h2>After</h2><img alt="After" src="data:image/png;base64,${afterBase64}" /></section>
|
|
23
|
+
</div></div>
|
|
24
|
+
</body></html>`;
|
|
25
|
+
}
|
|
26
|
+
function escapeHtml(s) {
|
|
27
|
+
return s
|
|
28
|
+
.replaceAll("&", "&")
|
|
29
|
+
.replaceAll("<", "<")
|
|
30
|
+
.replaceAll(">", ">")
|
|
31
|
+
.replaceAll('"', """);
|
|
32
|
+
}
|
|
33
|
+
export async function composeBeforeAfter(options) {
|
|
34
|
+
const cwd = options.cwd ?? process.cwd();
|
|
35
|
+
const outDir = resolve(cwd, options.outDir ?? ".farq");
|
|
36
|
+
mkdirSync(outDir, { recursive: true });
|
|
37
|
+
const before = readFileSync(options.beforePath);
|
|
38
|
+
const after = readFileSync(options.afterPath);
|
|
39
|
+
const badge = options.badge ?? "generated preview";
|
|
40
|
+
const html = buildComposeHtml({
|
|
41
|
+
beforeBase64: before.toString("base64"),
|
|
42
|
+
afterBase64: after.toString("base64"),
|
|
43
|
+
badge,
|
|
44
|
+
});
|
|
45
|
+
const composePath = join(outDir, "compose.html");
|
|
46
|
+
writeFileSync(composePath, html, "utf8");
|
|
47
|
+
const outPng = join(outDir, "before-after.png");
|
|
48
|
+
const chromePath = options.chromePath ?? resolveChrome();
|
|
49
|
+
await screenshotHtml({
|
|
50
|
+
chromePath,
|
|
51
|
+
url: pathToFileURL(composePath).href,
|
|
52
|
+
outPath: outPng,
|
|
53
|
+
width: 1400,
|
|
54
|
+
height: 900,
|
|
55
|
+
});
|
|
56
|
+
return outPng;
|
|
57
|
+
}
|
|
58
|
+
export { ChromeError, resolveChrome };
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ChangeSummary } from "../schema.js";
|
|
2
|
+
import type { Provider } from "../providers/index.js";
|
|
3
|
+
export type DiagramResult = {
|
|
4
|
+
feasible: true;
|
|
5
|
+
htmlPath: string;
|
|
6
|
+
} | {
|
|
7
|
+
feasible: false;
|
|
8
|
+
reason: string;
|
|
9
|
+
};
|
|
10
|
+
export declare function generateDiagram(options: {
|
|
11
|
+
provider: Provider;
|
|
12
|
+
summary: ChangeSummary;
|
|
13
|
+
diffText: string;
|
|
14
|
+
outDir: string;
|
|
15
|
+
model?: string;
|
|
16
|
+
log?: (msg: string) => void;
|
|
17
|
+
}): Promise<DiagramResult>;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { extractJson } from "../extract-json.js";
|
|
4
|
+
export async function generateDiagram(options) {
|
|
5
|
+
const prompt = `You create a conceptual before/after flowchart as one self-contained HTML document.
|
|
6
|
+
|
|
7
|
+
This is the fallback visual when a pixel UI mockup is not appropriate (API, logic, docs, config, etc.). Prefer a small flowchart or labeled before/after boxes that explain the change at a glance.
|
|
8
|
+
|
|
9
|
+
Rules:
|
|
10
|
+
- Pure HTML/CSS, no libraries, no external requests, system fonts.
|
|
11
|
+
- Two labeled columns (Before / After) with simple boxes/arrows (flowchart style).
|
|
12
|
+
- Concept-level only: field/endpoint/step names OK. NO code, NO syntax, NO JSON dumps, NO walls of text.
|
|
13
|
+
- Include a small corner badge text: generated preview
|
|
14
|
+
- If you truly cannot produce a faithful conceptual visual from the diff alone, return {"feasible": false, "reason": "..."}. Prefer a simple flowchart over declining.
|
|
15
|
+
|
|
16
|
+
Return JSON only:
|
|
17
|
+
{"feasible": true, "html": "..."}
|
|
18
|
+
or {"feasible": false, "reason": "..."}
|
|
19
|
+
|
|
20
|
+
Change summary:
|
|
21
|
+
${JSON.stringify(options.summary, null, 2)}
|
|
22
|
+
|
|
23
|
+
Diff:
|
|
24
|
+
${options.diffText}
|
|
25
|
+
`;
|
|
26
|
+
const raw = await options.provider.complete(prompt, { model: options.model });
|
|
27
|
+
const json = extractJson(raw);
|
|
28
|
+
if (!json.feasible || !json.html) {
|
|
29
|
+
options.log?.(json.reason ?? "diagram not feasible");
|
|
30
|
+
return { feasible: false, reason: json.reason ?? "not feasible" };
|
|
31
|
+
}
|
|
32
|
+
mkdirSync(options.outDir, { recursive: true });
|
|
33
|
+
const htmlPath = join(options.outDir, "diagram.html");
|
|
34
|
+
writeFileSync(htmlPath, json.html, "utf8");
|
|
35
|
+
return { feasible: true, htmlPath };
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DiffFile } from "../git.js";
|
|
2
|
+
export type GateDecision = "mockup" | "diagram" | "none";
|
|
3
|
+
/**
|
|
4
|
+
* Prefer a visual whenever possible:
|
|
5
|
+
* - UI markup/style → mockup
|
|
6
|
+
* - everything else with real file changes → diagram (flowchart / concept before-after)
|
|
7
|
+
* - none only when there is nothing to depict
|
|
8
|
+
*
|
|
9
|
+
* The model can still return feasible:false so we never invent a bad visual.
|
|
10
|
+
*/
|
|
11
|
+
export declare function decideGate(files: DiffFile[]): GateDecision;
|