builder-assistant-engineer 0.2.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/LICENSE +21 -0
- package/README.md +250 -0
- package/dist/analyst/prompt.js +19 -0
- package/dist/analyst/retry.js +38 -0
- package/dist/artifacts/diff.js +47 -0
- package/dist/artifacts/lessons.js +32 -0
- package/dist/artifacts/merge.js +70 -0
- package/dist/artifacts/write.js +44 -0
- package/dist/backends/agent-cli.js +174 -0
- package/dist/backends/api.js +171 -0
- package/dist/backends/index.js +10 -0
- package/dist/backends/manual.js +52 -0
- package/dist/backends/sse.js +24 -0
- package/dist/backends/types.js +1 -0
- package/dist/bin.js +3 -0
- package/dist/cli.js +104 -0
- package/dist/commands/context.js +12 -0
- package/dist/commands/init.js +199 -0
- package/dist/commands/next.js +103 -0
- package/dist/commands/plan.js +71 -0
- package/dist/commands/replan.js +37 -0
- package/dist/commands/review.js +37 -0
- package/dist/commands/shared.js +40 -0
- package/dist/commands/status.js +95 -0
- package/dist/config/commands.js +85 -0
- package/dist/config/schema.js +43 -0
- package/dist/config/settings.js +8 -0
- package/dist/config/store.js +38 -0
- package/dist/core/bash.js +105 -0
- package/dist/core/errors.js +14 -0
- package/dist/core/fs.js +35 -0
- package/dist/core/git.js +104 -0
- package/dist/core/gitignore.js +14 -0
- package/dist/core/json.js +13 -0
- package/dist/core/paths.js +14 -0
- package/dist/core/process.js +56 -0
- package/dist/core/prompt-loader.js +27 -0
- package/dist/core/state.js +27 -0
- package/dist/core/which.js +24 -0
- package/dist/detect/agents.js +17 -0
- package/dist/detect/mode.js +11 -0
- package/dist/digest/baseline.js +192 -0
- package/dist/digest/budget.js +31 -0
- package/dist/digest/files.js +167 -0
- package/dist/digest/format.js +18 -0
- package/dist/digest/git.js +19 -0
- package/dist/digest/index.js +100 -0
- package/dist/digest/manifests.js +133 -0
- package/dist/digest/read.js +28 -0
- package/dist/digest/redact.js +37 -0
- package/dist/digest/render.js +138 -0
- package/dist/digest/select.js +116 -0
- package/dist/digest/todos.js +16 -0
- package/dist/digest/tree.js +55 -0
- package/dist/digest/walk.js +72 -0
- package/dist/gates/capture.js +145 -0
- package/dist/gates/contract.js +339 -0
- package/dist/gates/enforce.js +143 -0
- package/dist/gates/findings.js +17 -0
- package/dist/gates/gate.js +315 -0
- package/dist/gates/ignore-rules.js +104 -0
- package/dist/gates/protected-files.js +311 -0
- package/dist/gates/regression.js +219 -0
- package/dist/gates/reports.js +349 -0
- package/dist/gates/results.js +295 -0
- package/dist/gates/runners.js +189 -0
- package/dist/gates/state-guard.js +56 -0
- package/dist/i18n/en.js +288 -0
- package/dist/i18n/es.js +288 -0
- package/dist/i18n/index.js +18 -0
- package/dist/interview/adaptive.js +69 -0
- package/dist/interview/brief.js +27 -0
- package/dist/interview/questions.js +18 -0
- package/dist/interview/render.js +19 -0
- package/dist/interview/reply.js +34 -0
- package/dist/next/attempts.js +151 -0
- package/dist/next/start.js +179 -0
- package/dist/plan/continuation.js +40 -0
- package/dist/plan/evidence.js +239 -0
- package/dist/plan/filter.js +20 -0
- package/dist/plan/parser.js +283 -0
- package/dist/plan/prior.js +32 -0
- package/dist/plan/questions.js +48 -0
- package/dist/plan/repair.js +33 -0
- package/dist/plan/report.js +37 -0
- package/dist/plan/run.js +191 -0
- package/dist/prompts/analyst.md +101 -0
- package/dist/prompts/continue.md +11 -0
- package/dist/prompts/fix-format.md +13 -0
- package/dist/prompts/fix-paths.md +23 -0
- package/dist/prompts/lesson.md +18 -0
- package/dist/prompts/retry.md +9 -0
- package/dist/prompts/review.md +36 -0
- package/dist/prompts/task.md +9 -0
- package/dist/review/changes.js +177 -0
- package/dist/review/diff.js +115 -0
- package/dist/review/integrity.js +150 -0
- package/dist/review/mechanical.js +62 -0
- package/dist/review/parse.js +40 -0
- package/dist/review/reviewer.js +23 -0
- package/dist/review/run.js +134 -0
- package/dist/review/scope.js +55 -0
- package/dist/review/secrets.js +163 -0
- package/dist/review/snapshot.js +38 -0
- package/dist/review/tests.js +140 -0
- package/dist/tasks/attempts.js +71 -0
- package/dist/tasks/checks.js +181 -0
- package/dist/tasks/frontmatter.js +58 -0
- package/dist/tasks/graph.js +27 -0
- package/dist/tasks/handoff.js +62 -0
- package/dist/tasks/learn.js +128 -0
- package/dist/tasks/load.js +37 -0
- package/dist/tasks/metrics.js +40 -0
- package/dist/tasks/runs.js +12 -0
- package/dist/tasks/schema.js +96 -0
- package/dist/tasks/select.js +15 -0
- package/dist/tasks/shell-words.js +176 -0
- package/dist/tasks/status.js +13 -0
- package/dist/tasks/verify.js +181 -0
- package/dist/ui/clack.js +46 -0
- package/dist/ui/prompter.js +1 -0
- package/package.json +64 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { isTestFile } from "../digest/baseline.js";
|
|
2
|
+
import { isLockfile, languageOf } from "../digest/files.js";
|
|
3
|
+
import { accept, findingId } from "../gates/findings.js";
|
|
4
|
+
import { executed } from "../gates/results.js";
|
|
5
|
+
import { t } from "../i18n/index.js";
|
|
6
|
+
import { inScope, scopePaths } from "./scope.js";
|
|
7
|
+
import { annotatedCount, assertionCount, EXPECTED_FAILURES, exclusionKeys, isExpectedOutput, isRunnerConfig, isTestChange, markerCounts, suppressionLines, testNames, } from "./tests.js";
|
|
8
|
+
export function staticIntegrity(task, changes, acceptance) {
|
|
9
|
+
const scope = scopePaths(task);
|
|
10
|
+
const listed = (file) => inScope(file, scope);
|
|
11
|
+
const texts = new Map(changes.added.map((item) => [item.path, item.text]));
|
|
12
|
+
const before = new Map(changes.removed.map((item) => [item.path, item.text]));
|
|
13
|
+
const findings = [
|
|
14
|
+
...changes.deleted
|
|
15
|
+
.filter((file) => isTestFile(file) && !movedElsewhere(file, changes))
|
|
16
|
+
.map((file) => scoped(listed(file), blocker(file, t("integrity.deleted"), "deleted"))),
|
|
17
|
+
...changes.added.filter(isTestChange).flatMap((item) => addedMarkers(item.text, before.get(item.path) ?? "").map((marker) => {
|
|
18
|
+
const finding = blocker(item.path, t("integrity.skipMarker", { marker }), marker);
|
|
19
|
+
return EXPECTED_FAILURES.has(marker) ? finding : scoped(listed(item.path), finding);
|
|
20
|
+
})),
|
|
21
|
+
...removedTests(changes).map((finding) => scoped(listed(finding.file ?? ""), finding)),
|
|
22
|
+
...lostAssertions(changes, listed),
|
|
23
|
+
...changes.added.flatMap((item) => suppressionFindings(item, before.get(item.path) ?? "")),
|
|
24
|
+
...changes.files.filter(isExpectedOutput).map((file) => {
|
|
25
|
+
const finding = blocker(file, t("integrity.expectedOutput"), "expected-output");
|
|
26
|
+
if (listed(file))
|
|
27
|
+
return scoped(true, finding);
|
|
28
|
+
return changes.untracked.includes(file)
|
|
29
|
+
? { ...finding, severity: "major" }
|
|
30
|
+
: finding;
|
|
31
|
+
}),
|
|
32
|
+
...changes.untracked.filter(isRunnerConfig).flatMap((path) => {
|
|
33
|
+
const keys = exclusionKeys(texts.get(path) ?? "");
|
|
34
|
+
if (keys.length === 0)
|
|
35
|
+
return [];
|
|
36
|
+
return [blocker(path, t("integrity.exclusion", { keys: keys.join(", ") }), keys.join(","))];
|
|
37
|
+
}),
|
|
38
|
+
];
|
|
39
|
+
return findings.map((finding) => finding.severity === "blocker"
|
|
40
|
+
? accept(acceptance, finding, listed(finding.file ?? ""))
|
|
41
|
+
: finding);
|
|
42
|
+
}
|
|
43
|
+
export function countIntegrity(checks, acceptance, planned = false) {
|
|
44
|
+
const findings = comparable(checks).flatMap(({ check, now, before }) => {
|
|
45
|
+
const vars = { command: check.command, now: describe(now), before: describe(before) };
|
|
46
|
+
return [
|
|
47
|
+
...(executed(now) < executed(before)
|
|
48
|
+
? [counted(check, "fewer", t("integrity.fewerTests", vars))]
|
|
49
|
+
: []),
|
|
50
|
+
...(now.skipped > before.skipped
|
|
51
|
+
? [counted(check, "skipped", t("integrity.moreSkipped", vars))]
|
|
52
|
+
: []),
|
|
53
|
+
];
|
|
54
|
+
});
|
|
55
|
+
return findings.map((finding) => planned
|
|
56
|
+
? {
|
|
57
|
+
...finding,
|
|
58
|
+
severity: "major",
|
|
59
|
+
message: `${finding.message} ${t("integrity.planned")}`,
|
|
60
|
+
}
|
|
61
|
+
: accept(acceptance, finding, true));
|
|
62
|
+
}
|
|
63
|
+
export function testsGrew(checks) {
|
|
64
|
+
return comparable(checks).some(({ now, before }) => executed(now) > executed(before));
|
|
65
|
+
}
|
|
66
|
+
function comparable(checks) {
|
|
67
|
+
return checks.flatMap((check) => check.key === "test" && check.counts && check.before?.counts
|
|
68
|
+
? [{ check, now: check.counts, before: check.before.counts }]
|
|
69
|
+
: []);
|
|
70
|
+
}
|
|
71
|
+
function movedElsewhere(file, changes) {
|
|
72
|
+
const removed = changes.removed.find((item) => item.path === file);
|
|
73
|
+
const names = testNames(removed?.text ?? "");
|
|
74
|
+
if (names.length === 0)
|
|
75
|
+
return false;
|
|
76
|
+
const kept = new Set(changes.added.filter(isTestChange).flatMap((item) => testNames(item.text)));
|
|
77
|
+
return names.every((name) => kept.has(name));
|
|
78
|
+
}
|
|
79
|
+
function removedTests(changes) {
|
|
80
|
+
const kept = new Set(changes.added.filter(isTestChange).flatMap((item) => testNames(item.text)));
|
|
81
|
+
const added = new Map(changes.added.map((item) => [item.path, annotatedCount(item.text)]));
|
|
82
|
+
return changes.removed
|
|
83
|
+
.filter((item) => isTestChange(item) || isTestFile(item.path))
|
|
84
|
+
.flatMap((item) => {
|
|
85
|
+
const names = [...new Set(testNames(item.text))].filter((name) => !kept.has(name));
|
|
86
|
+
const annotated = annotatedCount(item.text) - (added.get(item.path) ?? 0);
|
|
87
|
+
if (names.length === 0 && annotated <= 0)
|
|
88
|
+
return [];
|
|
89
|
+
const what = names.length > 0 ? names.join(", ") : String(annotated);
|
|
90
|
+
return [blocker(item.path, t("integrity.removedTests", { tests: what }), `removed:${what}`)];
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function lostAssertions(changes, listed) {
|
|
94
|
+
const tally = (items) => new Map(items.filter(isTestChange).map((item) => [item.path, assertionCount(item.text)]));
|
|
95
|
+
const removed = tally(changes.removed);
|
|
96
|
+
const added = tally(changes.added);
|
|
97
|
+
return [...removed.entries()]
|
|
98
|
+
.filter(([path, count]) => count > (added.get(path) ?? 0))
|
|
99
|
+
.map(([path, count]) => {
|
|
100
|
+
const lost = count - (added.get(path) ?? 0);
|
|
101
|
+
const finding = blocker(path, t("integrity.lostAssertions", { count: lost }), `assertions:${lost}`);
|
|
102
|
+
return scoped(listed(path), finding);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function addedMarkers(added, removed) {
|
|
106
|
+
const now = markerCounts(added);
|
|
107
|
+
const then = markerCounts(removed);
|
|
108
|
+
return [...now.entries()]
|
|
109
|
+
.filter(([name, count]) => count > (then.get(name) ?? 0))
|
|
110
|
+
.map(([name]) => name);
|
|
111
|
+
}
|
|
112
|
+
function suppressionFindings(item, removed) {
|
|
113
|
+
if (isLockfile(item.path) || !languageOf(item.path))
|
|
114
|
+
return [];
|
|
115
|
+
const net = (specific) => {
|
|
116
|
+
const count = (text) => suppressionLines(text).filter((line) => line.specific === specific);
|
|
117
|
+
const names = count(item.text).map((line) => line.name);
|
|
118
|
+
const gone = count(removed).map((line) => line.name);
|
|
119
|
+
return names.length > gone.length ? [...new Set(names)] : [];
|
|
120
|
+
};
|
|
121
|
+
const blanket = net(false);
|
|
122
|
+
const specific = net(true);
|
|
123
|
+
const found = (names) => blocker(item.path, t("integrity.suppression", { markers: names.join(", ") }), `suppress:${names.join(", ")}`);
|
|
124
|
+
return [
|
|
125
|
+
...(blanket.length > 0 ? [found(blanket)] : []),
|
|
126
|
+
...(specific.length > 0 ? [{ ...found(specific), severity: "major" }] : []),
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
function scoped(authorized, finding) {
|
|
130
|
+
if (!authorized)
|
|
131
|
+
return finding;
|
|
132
|
+
return {
|
|
133
|
+
...finding,
|
|
134
|
+
severity: "major",
|
|
135
|
+
justify: true,
|
|
136
|
+
message: `${finding.message} ${t("integrity.scoped")}`,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
export function hasComparableCounts(checks) {
|
|
140
|
+
return comparable(checks).length > 0;
|
|
141
|
+
}
|
|
142
|
+
function blocker(file, message, detail) {
|
|
143
|
+
return { severity: "blocker", id: findingId("integrity", file, detail), file, message };
|
|
144
|
+
}
|
|
145
|
+
function counted(check, kind, message) {
|
|
146
|
+
return { severity: "blocker", id: findingId("integrity", check.command, kind), message };
|
|
147
|
+
}
|
|
148
|
+
function describe(counts) {
|
|
149
|
+
return `${executed(counts)} run, ${counts.skipped} skipped`;
|
|
150
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { isTestFile } from "../digest/baseline.js";
|
|
2
|
+
import { isBuildOutput, isLockfile } from "../digest/files.js";
|
|
3
|
+
import { accept, findingId, newAcceptance } from "../gates/findings.js";
|
|
4
|
+
import { t } from "../i18n/index.js";
|
|
5
|
+
import { staticIntegrity } from "./integrity.js";
|
|
6
|
+
import { inScope, scopePaths } from "./scope.js";
|
|
7
|
+
import { secretFindings } from "./secrets.js";
|
|
8
|
+
import { assertionCount, isTestChange } from "./tests.js";
|
|
9
|
+
const ALWAYS_IN_SCOPE = new Set([".gitignore"]);
|
|
10
|
+
export function mechanicalReview(task, changes, acceptance = newAcceptance(), facts = { testsGrew: false }) {
|
|
11
|
+
const own = sourceChanges(changes);
|
|
12
|
+
const findings = [
|
|
13
|
+
...secretFindings(changes, facts.secrets ?? { allow: [] }, facts.history).map((finding) => accept(acceptance, finding, true)),
|
|
14
|
+
...staticIntegrity(task, own, acceptance),
|
|
15
|
+
...testFindings(task, own, facts).map((finding) => accept(acceptance, finding, true)),
|
|
16
|
+
...scopeFindings(task, own),
|
|
17
|
+
];
|
|
18
|
+
return { passed: !findings.some((finding) => finding.severity === "blocker"), findings };
|
|
19
|
+
}
|
|
20
|
+
function sourceChanges(changes) {
|
|
21
|
+
const keep = (path) => !isBuildOutput(path);
|
|
22
|
+
return {
|
|
23
|
+
files: changes.files.filter(keep),
|
|
24
|
+
added: changes.added.filter((item) => keep(item.path)),
|
|
25
|
+
removed: changes.removed.filter((item) => keep(item.path)),
|
|
26
|
+
deleted: changes.deleted.filter(keep),
|
|
27
|
+
untracked: changes.untracked.filter(keep),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function testFindings(task, changes, facts) {
|
|
31
|
+
if (task.meta.tests !== "required" || facts.testsGrew)
|
|
32
|
+
return [];
|
|
33
|
+
if (netAssertions(changes, facts.counted ?? false) > 0)
|
|
34
|
+
return [];
|
|
35
|
+
return [
|
|
36
|
+
{
|
|
37
|
+
severity: "blocker",
|
|
38
|
+
id: findingId("integrity", task.path, "tests-required"),
|
|
39
|
+
message: t("mechanical.noTests"),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
}
|
|
43
|
+
function scopeFindings(task, changes) {
|
|
44
|
+
const patterns = scopePaths(task);
|
|
45
|
+
if (patterns.length === 0)
|
|
46
|
+
return [];
|
|
47
|
+
const outside = changes.files.filter((file) => !inScope(file, patterns) &&
|
|
48
|
+
!isTestFile(file) &&
|
|
49
|
+
!isLockfile(file) &&
|
|
50
|
+
!ALWAYS_IN_SCOPE.has(file));
|
|
51
|
+
if (outside.length === 0)
|
|
52
|
+
return [];
|
|
53
|
+
return [
|
|
54
|
+
{ severity: "major", message: t("mechanical.outOfScope", { files: outside.join(", ") }) },
|
|
55
|
+
];
|
|
56
|
+
}
|
|
57
|
+
function netAssertions(changes, counted) {
|
|
58
|
+
const fresh = new Set(changes.untracked);
|
|
59
|
+
const total = (items) => items.filter(isTestChange).reduce((sum, item) => sum + assertionCount(item.text), 0);
|
|
60
|
+
const added = changes.added.filter((item) => !(counted && fresh.has(item.path)));
|
|
61
|
+
return total(added) - total(changes.removed);
|
|
62
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { FormatError } from "../core/errors.js";
|
|
3
|
+
const reviewSchema = z.object({
|
|
4
|
+
verdict: z.enum(["pass", "fail"]),
|
|
5
|
+
findings: z
|
|
6
|
+
.array(z.object({
|
|
7
|
+
severity: z.enum(["blocker", "major", "minor"]),
|
|
8
|
+
id: z.string().optional(),
|
|
9
|
+
file: z.string().optional(),
|
|
10
|
+
message: z.string().min(1),
|
|
11
|
+
justify: z.boolean().optional(),
|
|
12
|
+
}))
|
|
13
|
+
.default([]),
|
|
14
|
+
});
|
|
15
|
+
export const REVIEW_FORMAT = 'JSON only: {"verdict": "pass" | "fail", "findings": [{"severity": "blocker" | "major" | "minor", "file": "optional path", "message": "..."}]}';
|
|
16
|
+
export function parseReview(text) {
|
|
17
|
+
const start = text.indexOf("{");
|
|
18
|
+
const end = text.lastIndexOf("}");
|
|
19
|
+
if (start === -1 || end < start)
|
|
20
|
+
throw new FormatError("no JSON object found");
|
|
21
|
+
let data;
|
|
22
|
+
try {
|
|
23
|
+
data = JSON.parse(text.slice(start, end + 1));
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new FormatError(`invalid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
27
|
+
}
|
|
28
|
+
const result = reviewSchema.safeParse(data);
|
|
29
|
+
if (!result.success)
|
|
30
|
+
throw new FormatError(z.prettifyError(result.error));
|
|
31
|
+
const object = text.slice(start, end + 1);
|
|
32
|
+
if ((object.match(/"verdict"\s*:/g) ?? []).length > 1) {
|
|
33
|
+
throw new FormatError("more than one verdict");
|
|
34
|
+
}
|
|
35
|
+
const outside = `${text.slice(0, start)} ${text.slice(end + 1)}`;
|
|
36
|
+
if (result.data.verdict === "pass" && /\bverdict\b[^\n{]{0,20}\bfail/i.test(outside)) {
|
|
37
|
+
throw new FormatError("the text around the JSON contradicts its verdict");
|
|
38
|
+
}
|
|
39
|
+
return result.data;
|
|
40
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readTextIfExists } from "../core/fs.js";
|
|
4
|
+
const AGENT_DIRS = [".claude/agents", ".opencode/agent"];
|
|
5
|
+
const DEFAULT_REVIEWER = "A strict senior code reviewer. Checks that the changes meet every acceptance criterion, follow the project conventions, include tests, stay within the task scope and introduce no security issues.";
|
|
6
|
+
export async function findReviewer(cwd, backend) {
|
|
7
|
+
const dirs = backend === "opencode" ? [...AGENT_DIRS].reverse() : AGENT_DIRS;
|
|
8
|
+
for (const dir of dirs) {
|
|
9
|
+
const path = join(cwd, ...dir.split("/"));
|
|
10
|
+
const name = (await listNames(path)).find((entry) => /review/i.test(entry) && entry.endsWith(".md"));
|
|
11
|
+
if (name)
|
|
12
|
+
return (await readTextIfExists(join(path, name))) ?? DEFAULT_REVIEWER;
|
|
13
|
+
}
|
|
14
|
+
return DEFAULT_REVIEWER;
|
|
15
|
+
}
|
|
16
|
+
async function listNames(dir) {
|
|
17
|
+
try {
|
|
18
|
+
return (await readdir(dir)).sort();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { LANGUAGE_NAMES } from "../analyst/prompt.js";
|
|
3
|
+
import { runWithFormatRetry } from "../analyst/retry.js";
|
|
4
|
+
import { readTextIfExists } from "../core/fs.js";
|
|
5
|
+
import { renderPrompt } from "../core/prompt-loader.js";
|
|
6
|
+
import { truncateText } from "../digest/format.js";
|
|
7
|
+
import { capturedPrompt } from "../gates/capture.js";
|
|
8
|
+
import { withoutLog } from "../gates/contract.js";
|
|
9
|
+
import { newAcceptance } from "../gates/findings.js";
|
|
10
|
+
import { t } from "../i18n/index.js";
|
|
11
|
+
import { logLines } from "../tasks/handoff.js";
|
|
12
|
+
import { parseTask } from "../tasks/schema.js";
|
|
13
|
+
import { committedText, taskChanges } from "./changes.js";
|
|
14
|
+
import { reviewDiff } from "./diff.js";
|
|
15
|
+
import { mechanicalReview } from "./mechanical.js";
|
|
16
|
+
import { parseReview, REVIEW_FORMAT } from "./parse.js";
|
|
17
|
+
import { scopePaths } from "./scope.js";
|
|
18
|
+
const MAX_AGENTS_MD = 20_000;
|
|
19
|
+
const REASONS = {
|
|
20
|
+
noGit: "review.noGit",
|
|
21
|
+
noBase: "review.noBase",
|
|
22
|
+
gitError: "review.gitError",
|
|
23
|
+
};
|
|
24
|
+
export function capturedTask(capture) {
|
|
25
|
+
return parseTask(capture.path, capture.task);
|
|
26
|
+
}
|
|
27
|
+
export async function reviewTask(ctx, capture, notes = [], acceptance = newAcceptance(), facts = { testsGrew: false }, evidence = "") {
|
|
28
|
+
const task = capturedTask(capture);
|
|
29
|
+
const view = await taskChanges(ctx.cwd, capture);
|
|
30
|
+
if (!view.ok) {
|
|
31
|
+
const reason = t(REASONS[view.reason]);
|
|
32
|
+
return view.reason === "noGit"
|
|
33
|
+
? { status: "skipped", findings: [], reason }
|
|
34
|
+
: { status: "fail", findings: [{ severity: "blocker", message: reason }], reason };
|
|
35
|
+
}
|
|
36
|
+
const log = await currentLog(ctx.cwd, capture);
|
|
37
|
+
const scanned = {
|
|
38
|
+
...view.changes,
|
|
39
|
+
added: [...view.changes.added, ...view.late, ...(log ? [log] : [])],
|
|
40
|
+
};
|
|
41
|
+
const history = await committedText(ctx.cwd, view.ref);
|
|
42
|
+
if (!history) {
|
|
43
|
+
const reason = t("review.gitError");
|
|
44
|
+
return { status: "fail", findings: [{ severity: "blocker", message: reason }], reason };
|
|
45
|
+
}
|
|
46
|
+
const mechanical = mechanicalReview(task, scanned, acceptance, { ...facts, history });
|
|
47
|
+
if (mechanical.passed && view.changes.files.length === 0) {
|
|
48
|
+
return { status: "pass", findings: mechanical.findings, reason: t("review.noChanges") };
|
|
49
|
+
}
|
|
50
|
+
if (!mechanical.passed) {
|
|
51
|
+
return {
|
|
52
|
+
status: "fail",
|
|
53
|
+
findings: mechanical.findings,
|
|
54
|
+
reason: t("mechanical.failed"),
|
|
55
|
+
stage: "mechanical",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const diff = await reviewDiff(ctx.cwd, view.ref, view.changes, scopePaths(task), view.late.map((item) => item.path));
|
|
59
|
+
if (!diff) {
|
|
60
|
+
const reason = t("review.gitError");
|
|
61
|
+
return { status: "fail", findings: [{ severity: "blocker", message: reason }], reason };
|
|
62
|
+
}
|
|
63
|
+
const prompt = renderPrompt(capturedPrompt(capture, "review"), {
|
|
64
|
+
reviewer: capture.reviewer,
|
|
65
|
+
agents_md: truncateText(capture.agentsMd ?? "(none)", MAX_AGENTS_MD),
|
|
66
|
+
task: withoutLog(capture.task),
|
|
67
|
+
diff: diff.text,
|
|
68
|
+
checks: formatFindings([...mechanical.findings, ...notes]) || "(none)",
|
|
69
|
+
evidence: evidence || t("review.noEvidence"),
|
|
70
|
+
output_language: LANGUAGE_NAMES[capture.config.lang],
|
|
71
|
+
});
|
|
72
|
+
if (ctx.flags.dryRun) {
|
|
73
|
+
ctx.print(`${prompt}\n`);
|
|
74
|
+
return { status: "skipped", findings: [], reason: t("review.dryRun") };
|
|
75
|
+
}
|
|
76
|
+
const backend = ctx.createBackend(capture.config.backend);
|
|
77
|
+
const reply = await ctx.prompter.spinner(t("review.running", { id: task.meta.id }), () => runWithFormatRetry({
|
|
78
|
+
backend,
|
|
79
|
+
prompt,
|
|
80
|
+
options: {
|
|
81
|
+
cwd: ctx.cwd,
|
|
82
|
+
access: "read",
|
|
83
|
+
timeoutMs: capture.config.agent.timeoutMinutes * 60_000,
|
|
84
|
+
},
|
|
85
|
+
parse: parseReview,
|
|
86
|
+
format: REVIEW_FORMAT,
|
|
87
|
+
fixPrompt: capturedPrompt(capture, "fix-format"),
|
|
88
|
+
onRetry: () => ctx.prompter.warn(t("format.retrying")),
|
|
89
|
+
}));
|
|
90
|
+
const blocked = reply.findings.some((finding) => finding.severity === "blocker");
|
|
91
|
+
const unjustified = mechanical.findings.filter((finding) => finding.justify && !reply.findings.some((answer) => sameFile(answer.file, finding.file)));
|
|
92
|
+
const missing = reply.verdict === "pass" && unjustified.length > 0
|
|
93
|
+
? [
|
|
94
|
+
{
|
|
95
|
+
severity: "blocker",
|
|
96
|
+
message: t("review.unjustified", {
|
|
97
|
+
files: [...new Set(unjustified.map((finding) => finding.file ?? ""))].join(", "),
|
|
98
|
+
}),
|
|
99
|
+
},
|
|
100
|
+
]
|
|
101
|
+
: [];
|
|
102
|
+
return {
|
|
103
|
+
status: reply.verdict === "pass" && !blocked && missing.length === 0 ? "pass" : "fail",
|
|
104
|
+
findings: [...mechanical.findings, ...reply.findings, ...missing],
|
|
105
|
+
stage: "reviewer",
|
|
106
|
+
...(missing[0] ? { reason: missing[0].message } : {}),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function sameFile(answer, file) {
|
|
110
|
+
if (!answer || !file)
|
|
111
|
+
return false;
|
|
112
|
+
const clean = (path) => path
|
|
113
|
+
.replace(/\\/g, "/")
|
|
114
|
+
.replace(/^\.\//, "")
|
|
115
|
+
.replace(/:\d+.*$/, "");
|
|
116
|
+
return clean(answer) === clean(file);
|
|
117
|
+
}
|
|
118
|
+
async function currentLog(cwd, capture) {
|
|
119
|
+
const text = await readTextIfExists(join(cwd, ...capture.path.split("/")));
|
|
120
|
+
if (text === undefined)
|
|
121
|
+
return undefined;
|
|
122
|
+
try {
|
|
123
|
+
const lines = logLines(parseTask(capture.path, text));
|
|
124
|
+
return lines.length > 0 ? { path: capture.path, text: lines.join("\n") } : undefined;
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
export function formatFindings(findings) {
|
|
131
|
+
return findings
|
|
132
|
+
.map((finding) => `- [${finding.severity}]${finding.id ? ` (${finding.id})` : ""} ${finding.file ? `${finding.file}: ` : ""}${finding.message}`)
|
|
133
|
+
.join("\n");
|
|
134
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { sectionText } from "../tasks/schema.js";
|
|
2
|
+
const OUT_MARKER = /^\W*(out|fuera|excluded|excluido|not in scope|do not (?:change|touch|edit)|don't (?:change|touch|edit)|no (?:cambiar|tocar|modificar))\b/i;
|
|
3
|
+
const CODE_SPAN = /`([^`\n]+)`/g;
|
|
4
|
+
const LIST_PATH = /^\s*[-*+]\s+([\w@.[\]()*?/-]+\/[\w@.[\]()*?/-]*|[\w@-]+\.[A-Za-z]\w*)(?=\s|$|[,;:])/;
|
|
5
|
+
const NEW_MARK = /\s*\((?:new|nuevo|nueva)\)$/i;
|
|
6
|
+
const REDUCTION_MARK = /\s*\((?:delete|deleted|remove|removed|remove tests|borrar|eliminar)\)$/i;
|
|
7
|
+
export function scopePaths(task) {
|
|
8
|
+
const scope = sectionText(task.body, "scope") ?? "";
|
|
9
|
+
const lines = scope.split(/\r?\n/);
|
|
10
|
+
const out = lines.findIndex((line) => OUT_MARKER.test(line.replace(/[`*_]/g, "")));
|
|
11
|
+
const inScope = out === -1 ? lines : lines.slice(0, out);
|
|
12
|
+
const paths = inScope.flatMap((line) => [
|
|
13
|
+
...Array.from(line.matchAll(CODE_SPAN), (match) => match[1] ?? ""),
|
|
14
|
+
LIST_PATH.exec(line)?.[1] ?? "",
|
|
15
|
+
]);
|
|
16
|
+
return [...new Set(paths.map(normalize).filter((path) => path !== undefined))];
|
|
17
|
+
}
|
|
18
|
+
export function reductionPaths(task) {
|
|
19
|
+
const scope = sectionText(task.body, "scope") ?? "";
|
|
20
|
+
return [
|
|
21
|
+
...scope.matchAll(/`([^`\n]+)`\s*\((?:delete|deleted|remove|removed|remove tests|borrar|eliminar)\)|`([^`\n]+?)\s*\((?:delete|deleted|remove|removed|remove tests|borrar|eliminar)\)`/gi),
|
|
22
|
+
]
|
|
23
|
+
.map((match) => normalize(match[1] ?? match[2] ?? ""))
|
|
24
|
+
.filter((path) => path !== undefined);
|
|
25
|
+
}
|
|
26
|
+
export function inScope(file, patterns) {
|
|
27
|
+
return patterns.some((pattern) => {
|
|
28
|
+
if (/[*?]/.test(pattern))
|
|
29
|
+
return globToRegExp(pattern).test(file);
|
|
30
|
+
const dir = pattern.endsWith("/") ? pattern : `${pattern}/`;
|
|
31
|
+
return file === pattern.replace(/\/$/, "") || file.startsWith(dir);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
function normalize(raw) {
|
|
35
|
+
const path = raw.trim().replace(NEW_MARK, "").replace(REDUCTION_MARK, "").replace(/^\.\//, "");
|
|
36
|
+
if (path === "" || /\s|:\/\//.test(path))
|
|
37
|
+
return undefined;
|
|
38
|
+
if (/[*?]/.test(path) && globToRegExp(path).test("a") && globToRegExp(path).test("x/y/z.q")) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return path.includes("/") || /\.[A-Za-z]\w*$/.test(path) ? path : undefined;
|
|
42
|
+
}
|
|
43
|
+
function globToRegExp(pattern) {
|
|
44
|
+
const source = pattern
|
|
45
|
+
.split("**/")
|
|
46
|
+
.map((part) => part
|
|
47
|
+
.split("**")
|
|
48
|
+
.map((piece) => piece
|
|
49
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&")
|
|
50
|
+
.replace(/\*/g, "[^/]*")
|
|
51
|
+
.replace(/\?/g, "[^/]"))
|
|
52
|
+
.join(".*"))
|
|
53
|
+
.join("(?:.*/)?");
|
|
54
|
+
return new RegExp(`^${source}$`);
|
|
55
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import { isTestFile } from "../digest/baseline.js";
|
|
2
|
+
import { isBinaryPath, isLockfile, isSecretPath } from "../digest/files.js";
|
|
3
|
+
import { SECRET_TOKENS } from "../digest/redact.js";
|
|
4
|
+
import { findingId } from "../gates/findings.js";
|
|
5
|
+
import { t } from "../i18n/index.js";
|
|
6
|
+
import { inScope } from "./scope.js";
|
|
7
|
+
const EXAMPLE_VALUES = new Set([
|
|
8
|
+
"AKIAIOSFODNN7EXAMPLE",
|
|
9
|
+
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
|
10
|
+
]);
|
|
11
|
+
const TEST_PREFIXES = /^[spr]k_test_/;
|
|
12
|
+
const KEYWORD = "(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]{0,40}(?:passw(?:or)?d|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential)[A-Za-z0-9_.-]{0,40}";
|
|
13
|
+
const ASSIGNED = new RegExp(`${KEYWORD}["']?[ \\t]*[:=][ \\t]*(["'])([^"'\\s]{8,})\\1`, "gi");
|
|
14
|
+
const UNQUOTED = new RegExp(`^[ \\t]*(?:export[ \\t]+)?${KEYWORD}[ \\t]*[:=][ \\t]*()([^\\s"'#,;]{8,})[ \\t]*(?:#.*)?$`, "gim");
|
|
15
|
+
const SECRET_CONTEXT = new RegExp(`${KEYWORD}["']?[ \\t]*[:=]`, "i");
|
|
16
|
+
const PUBLIC_CONTEXT = /public|pub[_-]?key|vapid|checksum|sha\d*|digest|hash|integrity|fingerprint|etag|commit|revision|nonce|uuid|id["']?\s*[:=]\s*$/i;
|
|
17
|
+
const LOCAL_HOSTS = /@(?:localhost|127\.0\.0\.1|0\.0\.0\.0|\[::1\]|db|database|postgres|mysql|redis|mongo|host\.docker\.internal|example\.(?:com|org|net))(?:[:/]|$)/i;
|
|
18
|
+
const SAMPLE_JWT = "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c";
|
|
19
|
+
const IDENTIFIER = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$|^[a-z]{1,15}(_[a-z]{1,15})+$|^\d+$/;
|
|
20
|
+
const CONNECTION = /\b[a-z][a-z0-9+.-]*:\/\/([^\s:@/'"]+):([^\s@/'"]+)@[^\s'"]+/gi;
|
|
21
|
+
const QUOTED_BLOB = /(["'`])([A-Za-z0-9+/_-]{40,}={0,2}|[a-f0-9]{48,})\1/g;
|
|
22
|
+
const LABEL = /^[\p{L}\s.,:;!?¿¡'-]+$/u;
|
|
23
|
+
const PLACEHOLDER = /^(?:\$|<|\{|%|\*+$)|^(?:x+|changeme|change[-_]me|example\w*|placeholder|password\d*|pass\d*|secret\d*|dummy|fake|test\w*|your[-_]\w+|my\w*|redacted|postgres|root|admin|dev|local|mysql|guest|user|sa|app)$/i;
|
|
24
|
+
const MIN_ENTROPY = 4.5;
|
|
25
|
+
export function secretFindings(changes, options, history = []) {
|
|
26
|
+
const allowed = (path) => options.allow.length > 0 && inScope(path, options.allow);
|
|
27
|
+
const deleted = new Set(changes.deleted);
|
|
28
|
+
const fileFinding = (file, key) => ({
|
|
29
|
+
severity: "blocker",
|
|
30
|
+
id: findingId("secret", file),
|
|
31
|
+
file,
|
|
32
|
+
message: t(key),
|
|
33
|
+
});
|
|
34
|
+
const valueFindings = (items, key) => items.flatMap((item) => {
|
|
35
|
+
if (secretFile(item.path) || allowed(item.path))
|
|
36
|
+
return [];
|
|
37
|
+
const kinds = secretKinds(item);
|
|
38
|
+
if (kinds.length === 0)
|
|
39
|
+
return [];
|
|
40
|
+
const kind = kinds.join(", ");
|
|
41
|
+
return [
|
|
42
|
+
{
|
|
43
|
+
severity: "blocker",
|
|
44
|
+
id: findingId("secret", item.path, kind),
|
|
45
|
+
file: item.path,
|
|
46
|
+
message: t(key, { kind }),
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
});
|
|
50
|
+
const found = [
|
|
51
|
+
...changes.files
|
|
52
|
+
.filter((file) => secretFile(file) && !allowed(file) && !deleted.has(file))
|
|
53
|
+
.map((file) => fileFinding(file, "mechanical.secretFile")),
|
|
54
|
+
...valueFindings(changes.added, "mechanical.secretValue"),
|
|
55
|
+
...[...new Set(history.map((item) => item.path))]
|
|
56
|
+
.filter((file) => secretFile(file) && !allowed(file))
|
|
57
|
+
.map((file) => fileFinding(file, "mechanical.secretFileHistory")),
|
|
58
|
+
...valueFindings(history, "mechanical.secretHistory"),
|
|
59
|
+
];
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
return found.filter((finding) => {
|
|
62
|
+
if (seen.has(finding.id ?? ""))
|
|
63
|
+
return false;
|
|
64
|
+
seen.add(finding.id ?? "");
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
export function secretFile(path) {
|
|
69
|
+
if (/\.(pem|crt|cer|tfvars)$/i.test(path))
|
|
70
|
+
return false;
|
|
71
|
+
if (/(^|\/)\.env\.(development|dev|test|ci|defaults)$/i.test(path))
|
|
72
|
+
return false;
|
|
73
|
+
return isSecretPath(path);
|
|
74
|
+
}
|
|
75
|
+
export function secretKinds(item) {
|
|
76
|
+
const kinds = SECRET_TOKENS.filter(([, pattern]) => [...item.text.matchAll(new RegExp(pattern.source, "g"))].some((match) => !isExample(match[0]))).map(([kind]) => kind);
|
|
77
|
+
if (item.path.startsWith("(commit "))
|
|
78
|
+
return [...new Set(kinds)];
|
|
79
|
+
const loose = !isTestName(item.path) &&
|
|
80
|
+
!isFixture(item.path) &&
|
|
81
|
+
!isLockfile(item.path) &&
|
|
82
|
+
!isBinaryPath(item.path);
|
|
83
|
+
if (loose && hasAssignedSecret(item.text))
|
|
84
|
+
kinds.push("hardcoded password or key");
|
|
85
|
+
if (hasCredentialUrl(item.text))
|
|
86
|
+
kinds.push("credentials in a connection string");
|
|
87
|
+
if (loose && !/\.(svg|map|snap)$/i.test(item.path) && hasHighEntropyBlob(item.text)) {
|
|
88
|
+
kinds.push("long random string");
|
|
89
|
+
}
|
|
90
|
+
return [...new Set(kinds)];
|
|
91
|
+
}
|
|
92
|
+
function isExample(value) {
|
|
93
|
+
if (EXAMPLE_VALUES.has(value) || TEST_PREFIXES.test(value) || value.endsWith(SAMPLE_JWT)) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
const body = value.replace(/^(?:AKIA|ASIA|gh[pousr]_|github_pat_|glpat-|xox[abprs]-|sk-|[rs]k_live_|AIza|npm_)/, "");
|
|
97
|
+
if (/example|sample|fake|dummy|placeholder|test|your|redacted|changeme|xxxx|0000/i.test(body)) {
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
return new Set(body).size <= 6 || /(.)\1{5,}/.test(body);
|
|
101
|
+
}
|
|
102
|
+
function isFixture(path) {
|
|
103
|
+
return (/(^|\/)(fixtures?|__fixtures__|testdata|mocks?|__mocks__)\//.test(path) ||
|
|
104
|
+
/\.(md|mdx|rst|txt)$/i.test(path));
|
|
105
|
+
}
|
|
106
|
+
function looksPublic(value) {
|
|
107
|
+
return (/^arn:/.test(value) ||
|
|
108
|
+
/^\^|\(\?[=!:<]|\.\{\d|\\[dws]|\[[A-Za-z]-[A-Za-z]\]/.test(value) ||
|
|
109
|
+
!/[A-Za-z0-9]/.test(value));
|
|
110
|
+
}
|
|
111
|
+
function hasAssignedSecret(text) {
|
|
112
|
+
return [...text.matchAll(ASSIGNED), ...text.matchAll(UNQUOTED)].some((match) => {
|
|
113
|
+
const value = match[2] ?? "";
|
|
114
|
+
if (PLACEHOLDER.test(value) || EXAMPLE_VALUES.has(value) || LABEL.test(value))
|
|
115
|
+
return false;
|
|
116
|
+
if (looksPublic(value) || isExample(value))
|
|
117
|
+
return false;
|
|
118
|
+
if (IDENTIFIER.test(value) || /^[$<{%]/.test(value))
|
|
119
|
+
return false;
|
|
120
|
+
return !value.includes("/") && !/^[\w.-]+\.[a-z]{2,4}$/i.test(value);
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
function isTestName(path) {
|
|
124
|
+
return (isTestFile(path) &&
|
|
125
|
+
/(\.|_)(test|spec|cy|e2e)\.|(^|\/)test_[^/]+$|_test\.\w+$|Tests?\.\w+$/.test(path));
|
|
126
|
+
}
|
|
127
|
+
function hasCredentialUrl(text) {
|
|
128
|
+
return [...text.matchAll(CONNECTION)].some((match) => {
|
|
129
|
+
const password = match[2] ?? "";
|
|
130
|
+
if (LOCAL_HOSTS.test(match[0].slice(match[0].indexOf("@"))))
|
|
131
|
+
return false;
|
|
132
|
+
return !PLACEHOLDER.test(password) && !/^\$\{?\w+\}?$/.test(password);
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
function hasHighEntropyBlob(text) {
|
|
136
|
+
return [...text.matchAll(QUOTED_BLOB)].some((match) => {
|
|
137
|
+
const value = match[2] ?? "";
|
|
138
|
+
const before = text.slice(Math.max(0, (match.index ?? 0) - 16), match.index ?? 0);
|
|
139
|
+
const context = text.slice(Math.max(0, (match.index ?? 0) - 60), match.index ?? 0);
|
|
140
|
+
const line = context.split("\n").at(-1) ?? "";
|
|
141
|
+
if (/^sha\d+-/i.test(value) || /(data:[^,]*,|integrity["']?\s*[:=]\s*)$/i.test(before)) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
if (PUBLIC_CONTEXT.test(line) && !SECRET_CONTEXT.test(line))
|
|
145
|
+
return false;
|
|
146
|
+
if (/^[a-f0-9]+$/.test(value)) {
|
|
147
|
+
return SECRET_CONTEXT.test(line) && value.length >= 32 && entropy(value) >= 3.5;
|
|
148
|
+
}
|
|
149
|
+
return (/[A-Z]/.test(value) &&
|
|
150
|
+
/[a-z]/.test(value) &&
|
|
151
|
+
/\d/.test(value) &&
|
|
152
|
+
entropy(value) >= MIN_ENTROPY);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
function entropy(value) {
|
|
156
|
+
const counts = new Map();
|
|
157
|
+
for (const char of value)
|
|
158
|
+
counts.set(char, (counts.get(char) ?? 0) + 1);
|
|
159
|
+
return [...counts.values()].reduce((sum, count) => {
|
|
160
|
+
const share = count / value.length;
|
|
161
|
+
return sum - share * Math.log2(share);
|
|
162
|
+
}, 0);
|
|
163
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { excluded, git, gitPaths, isGitRepo } from "../core/git.js";
|
|
4
|
+
const HASH_BATCH = 100;
|
|
5
|
+
export async function takeSnapshot(cwd, exclude) {
|
|
6
|
+
if (!(await isGitRepo(cwd)))
|
|
7
|
+
return {};
|
|
8
|
+
const pathspec = ["--", ".", ...exclude.map(excluded)];
|
|
9
|
+
const [changed, untracked] = await Promise.all([
|
|
10
|
+
gitPaths(cwd, ["diff", "--name-only", "--no-renames", "HEAD", ...pathspec]),
|
|
11
|
+
gitPaths(cwd, ["ls-files", "--others", "--exclude-standard", ...pathspec]),
|
|
12
|
+
]);
|
|
13
|
+
return hashPaths(cwd, [...new Set([...(changed ?? []), ...(untracked ?? [])])]);
|
|
14
|
+
}
|
|
15
|
+
export async function unchangedSince(cwd, snapshot, files) {
|
|
16
|
+
const known = files.filter((file) => Object.hasOwn(snapshot, file));
|
|
17
|
+
const current = await hashPaths(cwd, known);
|
|
18
|
+
return new Set(known.filter((file) => current[file] === snapshot[file]));
|
|
19
|
+
}
|
|
20
|
+
export async function hashPaths(cwd, paths) {
|
|
21
|
+
const snapshot = {};
|
|
22
|
+
const present = [];
|
|
23
|
+
for (const path of paths) {
|
|
24
|
+
const info = await stat(join(cwd, ...path.split("/"))).catch(() => undefined);
|
|
25
|
+
if (info?.isFile())
|
|
26
|
+
present.push(path);
|
|
27
|
+
else
|
|
28
|
+
snapshot[path] = null;
|
|
29
|
+
}
|
|
30
|
+
for (let start = 0; start < present.length; start += HASH_BATCH) {
|
|
31
|
+
const batch = present.slice(start, start + HASH_BATCH);
|
|
32
|
+
const hashes = (await git(cwd, ["hash-object", "--", ...batch]))?.split(/\r?\n/) ?? [];
|
|
33
|
+
batch.forEach((path, index) => {
|
|
34
|
+
snapshot[path] = hashes[index]?.trim() || null;
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return snapshot;
|
|
38
|
+
}
|