@tech-leads-club/harness-toolkit 0.16.5 → 0.16.6
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 +1 -0
- package/bin/tlc-cli.ts +4 -1
- package/dist/compact-before.mjs +91 -91
- package/dist/doctor.mjs +94 -94
- package/dist/init-project.mjs +85 -85
- package/dist/install-runtime.mjs +82 -82
- package/dist/lessons-cli.mjs +91 -91
- package/dist/obs-cli.mjs +88 -88
- package/dist/prompt-submit.mjs +91 -91
- package/dist/refresh-model-prices.mjs +84 -84
- package/dist/response-after.mjs +91 -91
- package/dist/run.mjs +91 -91
- package/dist/session-end.mjs +87 -87
- package/dist/session-start.mjs +91 -91
- package/dist/shim.mjs +82 -82
- package/dist/ship-gate.mjs +95 -94
- package/dist/stop.mjs +94 -93
- package/dist/subagent-start.mjs +91 -91
- package/dist/subagent-stop.mjs +92 -92
- package/dist/support.mjs +88 -88
- package/dist/tlc-cli.mjs +107 -106
- package/dist/tool-after.mjs +87 -87
- package/dist/tool-before.mjs +106 -105
- package/dist/tool-failure.mjs +91 -91
- package/dist/uninstall-runtime.mjs +4 -4
- package/dist/verify.mjs +150 -0
- package/docs/concepts.md +20 -0
- package/docs/diagnose.md +8 -0
- package/package.json +1 -1
- package/src/core/core.facade.ts +4 -0
- package/src/core/gate/gate.verdicts.ts +95 -0
- package/src/core/index.ts +1 -0
- package/src/entrypoints/ship-gate.ts +68 -79
- package/src/entrypoints/stop.ts +12 -0
- package/src/platform/git.ts +59 -1
- package/tools/verify.ts +145 -0
package/package.json
CHANGED
package/src/core/core.facade.ts
CHANGED
|
@@ -56,6 +56,7 @@ import { filesFromOutput } from "./gate/gate.findings.ts";
|
|
|
56
56
|
import { cachedVerdict, computeInputsHash, isCacheHit } from "./gate/gate.inputs.ts";
|
|
57
57
|
import { describeHolder, GATE_LOCK_WAIT_MS, GateLockTimeoutError, withGateLock } from "./gate/gate.lock.ts";
|
|
58
58
|
import { gapsFromArtifact } from "./gate/gate.service.ts";
|
|
59
|
+
import { findShipVerdict, lastShipVerdictFor, recordShipVerdict } from "./gate/gate.verdicts.ts";
|
|
59
60
|
import {
|
|
60
61
|
clearStuckSignals,
|
|
61
62
|
handoffInjectable,
|
|
@@ -317,6 +318,9 @@ export const coreFacade = {
|
|
|
317
318
|
isRecipeRunner,
|
|
318
319
|
isCommandResolutionFailure,
|
|
319
320
|
filesFromOutput,
|
|
321
|
+
findShipVerdict,
|
|
322
|
+
lastShipVerdictFor,
|
|
323
|
+
recordShipVerdict,
|
|
320
324
|
},
|
|
321
325
|
stagnation: {
|
|
322
326
|
computeFingerprint,
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { updateJsonAtomic } from "../../platform/fs-atomic.ts";
|
|
4
|
+
import { linkedWorktreeMainRoot } from "../../platform/git.ts";
|
|
5
|
+
import { projectStateDir } from "../../platform/paths.ts";
|
|
6
|
+
import { trimOutputTail } from "./gate.artifact.ts";
|
|
7
|
+
|
|
8
|
+
const SHIP_VERDICT_SCHEMA = "harness.ship-verdicts.v1";
|
|
9
|
+
export const SHIP_VERDICT_LIMIT = 50;
|
|
10
|
+
|
|
11
|
+
export type ShipVerdict = {
|
|
12
|
+
gate: string;
|
|
13
|
+
command: string[];
|
|
14
|
+
tree: string;
|
|
15
|
+
passed: boolean;
|
|
16
|
+
exitCode: number;
|
|
17
|
+
durationMs: number;
|
|
18
|
+
outputTail: string;
|
|
19
|
+
at: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type ShipVerdictFile = { schema: string; verdicts: ShipVerdict[] };
|
|
23
|
+
|
|
24
|
+
function shipVerdictsPath(root: string): string {
|
|
25
|
+
return join(projectStateDir(linkedWorktreeMainRoot(root) ?? root), "ship-verdicts.json");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sameCommand(left: string[], right: string[]): boolean {
|
|
29
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function readShipVerdicts(root: string): ShipVerdict[] {
|
|
33
|
+
const path = shipVerdictsPath(root);
|
|
34
|
+
if (!existsSync(path)) {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
try {
|
|
38
|
+
const parsed = JSON.parse(readFileSync(path, "utf8")) as Partial<ShipVerdictFile>;
|
|
39
|
+
return Array.isArray(parsed.verdicts) ? parsed.verdicts : [];
|
|
40
|
+
} catch {
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function findShipVerdict(
|
|
46
|
+
root: string,
|
|
47
|
+
gate: string,
|
|
48
|
+
command: string[],
|
|
49
|
+
tree: string,
|
|
50
|
+
): ShipVerdict | null {
|
|
51
|
+
const matches = readShipVerdicts(root).filter(
|
|
52
|
+
(verdict) => verdict.gate === gate && verdict.tree === tree && sameCommand(verdict.command, command),
|
|
53
|
+
);
|
|
54
|
+
return matches.at(-1) ?? null;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function lastShipVerdictFor(root: string, gate: string, command: string[]): ShipVerdict | null {
|
|
58
|
+
const matches = readShipVerdicts(root).filter(
|
|
59
|
+
(verdict) => verdict.gate === gate && sameCommand(verdict.command, command),
|
|
60
|
+
);
|
|
61
|
+
return matches.at(-1) ?? null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function recordShipVerdict(
|
|
65
|
+
root: string,
|
|
66
|
+
input: Omit<ShipVerdict, "at" | "outputTail" | "passed"> & { output: string; at?: string },
|
|
67
|
+
): Promise<ShipVerdict> {
|
|
68
|
+
const verdict: ShipVerdict = {
|
|
69
|
+
gate: input.gate,
|
|
70
|
+
command: input.command,
|
|
71
|
+
tree: input.tree,
|
|
72
|
+
passed: input.exitCode === 0,
|
|
73
|
+
exitCode: input.exitCode,
|
|
74
|
+
durationMs: input.durationMs,
|
|
75
|
+
outputTail: trimOutputTail(input.output),
|
|
76
|
+
at: input.at ?? new Date().toISOString(),
|
|
77
|
+
};
|
|
78
|
+
const path = shipVerdictsPath(root);
|
|
79
|
+
await updateJsonAtomic<ShipVerdictFile>(
|
|
80
|
+
path,
|
|
81
|
+
(current) => {
|
|
82
|
+
const kept = (current?.verdicts ?? []).filter(
|
|
83
|
+
(existing) =>
|
|
84
|
+
!(
|
|
85
|
+
existing.gate === verdict.gate &&
|
|
86
|
+
existing.tree === verdict.tree &&
|
|
87
|
+
sameCommand(existing.command, verdict.command)
|
|
88
|
+
),
|
|
89
|
+
);
|
|
90
|
+
return { schema: SHIP_VERDICT_SCHEMA, verdicts: [...kept, verdict].slice(-SHIP_VERDICT_LIMIT) };
|
|
91
|
+
},
|
|
92
|
+
{ lockPath: `${path}.lock` },
|
|
93
|
+
);
|
|
94
|
+
return verdict;
|
|
95
|
+
}
|
package/src/core/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ export type { CoreFacade } from "./core.facade.ts";
|
|
|
8
8
|
export { coreFacade } from "./core.facade.ts";
|
|
9
9
|
export type { FloorInput, FloorRule } from "./floor/floor.service.ts";
|
|
10
10
|
export type { FailureCategory, GateFinding, GateGap, LastGateArtifact, LockBody } from "./gate/gate.types.ts";
|
|
11
|
+
export type { ShipVerdict } from "./gate/gate.verdicts.ts";
|
|
11
12
|
export type {
|
|
12
13
|
ForeignSlice,
|
|
13
14
|
GateResult,
|
|
@@ -5,16 +5,15 @@ import { readFileSync } from "node:fs";
|
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import type { Decision, HarnessEvent } from "../contracts/index.ts";
|
|
7
7
|
import { coreFacade } from "../core/index.ts";
|
|
8
|
-
import { gitRootOf, listAddedLines, listTrackedFiles, localRepoRemote } from "../platform/git.ts";
|
|
9
|
-
import type { HandlerContext } from "./run.ts";
|
|
10
|
-
import { runLockedGate } from "./stop.ts";
|
|
11
8
|
import {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
} from "
|
|
9
|
+
gitRootOf,
|
|
10
|
+
listAddedLines,
|
|
11
|
+
listTrackedFiles,
|
|
12
|
+
localRepoRemote,
|
|
13
|
+
workingTreeId,
|
|
14
|
+
} from "../platform/git.ts";
|
|
15
|
+
import type { HandlerContext } from "./run.ts";
|
|
16
|
+
import { computeTurnScope, currentGitSha, pendingCommentViolations, shaScopeRoot } from "./support.ts";
|
|
18
17
|
|
|
19
18
|
// why: pr-merge is the moment code actually lands on the shared branch. Usually no local diff exists at merge
|
|
20
19
|
// time, so the battery abstains — the merged commits already paid it at push/pr-open time
|
|
@@ -62,6 +61,54 @@ function gateFailureMessage(
|
|
|
62
61
|
].join("\n");
|
|
63
62
|
}
|
|
64
63
|
|
|
64
|
+
function formatDuration(ms: number): string {
|
|
65
|
+
const seconds = Math.round(ms / 1000);
|
|
66
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function verdictRefusal(
|
|
70
|
+
root: string,
|
|
71
|
+
tree: string,
|
|
72
|
+
required: Array<{ gate: string; command: string[] }>,
|
|
73
|
+
diag: ReturnType<typeof coreFacade.diagnostics.rootDiagnostic>,
|
|
74
|
+
): Decision | null {
|
|
75
|
+
const missing: Array<{ gate: string; command: string[] }> = [];
|
|
76
|
+
for (const { gate, command } of required) {
|
|
77
|
+
const verdict = coreFacade.gate.findShipVerdict(root, gate, command, tree);
|
|
78
|
+
if (verdict === null) {
|
|
79
|
+
missing.push({ gate, command });
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (!verdict.passed) {
|
|
83
|
+
return {
|
|
84
|
+
kind: "deny",
|
|
85
|
+
reason: gateFailureMessage(gate, verdict.command, verdict.outputTail, diag),
|
|
86
|
+
rule: `ship-gate-${gate}`,
|
|
87
|
+
diagnostic: diag.summary,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (missing.length === 0) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
const last = missing
|
|
95
|
+
.map(({ gate, command }) => coreFacade.gate.lastShipVerdictFor(root, gate, command))
|
|
96
|
+
.reduce((total, verdict) => total + (verdict?.durationMs ?? 0), 0);
|
|
97
|
+
const estimate = last > 0 ? ` The last run took ${formatDuration(last)}.` : "";
|
|
98
|
+
return {
|
|
99
|
+
kind: "deny",
|
|
100
|
+
reason: [
|
|
101
|
+
`BLOCKED: no passing ${missing.map(({ gate }) => gate).join(" + ")} run is recorded for this tree, and a hook cannot wait for one.`,
|
|
102
|
+
`TRIED: looked up the verdicts recorded for tree ${tree.slice(0, 12)}.`,
|
|
103
|
+
`NEED: run \`tlc harness verify\` in the shell, let it finish, then retry the commit/push/PR.${estimate} Give the command a timeout long enough for the whole suite, or run it in the background and wait for it.`,
|
|
104
|
+
"",
|
|
105
|
+
diag.footer,
|
|
106
|
+
].join("\n"),
|
|
107
|
+
rule: "ship-gate-unverified",
|
|
108
|
+
diagnostic: diag.summary,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
65
112
|
/**
|
|
66
113
|
* why after comments and before the stop-rules check: cheapest first. A comment violation costs a
|
|
67
114
|
* regex scan; a gate command is a real process; the stop-rules check is a directory read plus, only
|
|
@@ -76,7 +123,6 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
|
|
|
76
123
|
const root = event.projectDir;
|
|
77
124
|
const provider = event.provider;
|
|
78
125
|
const sessionKey = event.sessionKey;
|
|
79
|
-
const session = sessionIdFromKey(event);
|
|
80
126
|
const scope = await computeTurnScope(root, shaRoot, provider, sessionKey, policy);
|
|
81
127
|
const rootDiag = coreFacade.diagnostics.rootDiagnostic(shaRoot);
|
|
82
128
|
|
|
@@ -91,84 +137,27 @@ export async function shipGateVerdict(event: HarnessEvent, ctx: HandlerContext):
|
|
|
91
137
|
};
|
|
92
138
|
}
|
|
93
139
|
|
|
140
|
+
const required: Array<{ gate: string; command: string[] }> = [];
|
|
94
141
|
if (policy.grind.enabled && policy.grind.lintCommand && scope.codeTargets.length > 0) {
|
|
95
|
-
|
|
96
|
-
root,
|
|
97
|
-
shaRoot,
|
|
98
|
-
provider,
|
|
99
|
-
session,
|
|
100
|
-
pendingCredit: scope.pendingCredit,
|
|
101
|
-
sessionKey,
|
|
102
|
-
policy,
|
|
103
|
-
gate: "lint",
|
|
104
|
-
command: policy.grind.lintCommand,
|
|
105
|
-
argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.lintCommand, policy.grind.appendFiles)
|
|
106
|
-
? scope.codeTargets
|
|
107
|
-
: [],
|
|
108
|
-
recordFiles: scope.codeTargets,
|
|
109
|
-
});
|
|
110
|
-
if (run.kind === "ran" && !run.artifact.passed) {
|
|
111
|
-
return {
|
|
112
|
-
kind: "deny",
|
|
113
|
-
reason: gateFailureMessage("lint", run.artifact.command, run.artifact.outputTail, rootDiag),
|
|
114
|
-
rule: "ship-gate-lint",
|
|
115
|
-
diagnostic: rootDiag.summary,
|
|
116
|
-
};
|
|
117
|
-
}
|
|
142
|
+
required.push({ gate: "lint", command: policy.grind.lintCommand });
|
|
118
143
|
}
|
|
119
|
-
|
|
120
144
|
if (
|
|
121
145
|
policy.grind.enabled &&
|
|
122
146
|
policy.grind.testCommand &&
|
|
123
147
|
(scope.testTargets.length > 0 || scope.codeTargets.length > 0)
|
|
124
148
|
) {
|
|
125
|
-
|
|
126
|
-
const run = await runLockedGate({
|
|
127
|
-
root,
|
|
128
|
-
shaRoot,
|
|
129
|
-
provider,
|
|
130
|
-
session,
|
|
131
|
-
pendingCredit: scope.pendingCredit,
|
|
132
|
-
sessionKey,
|
|
133
|
-
policy,
|
|
134
|
-
gate: "test",
|
|
135
|
-
command: policy.grind.testCommand,
|
|
136
|
-
argvFiles: coreFacade.gate.shouldAppendFiles(policy.grind.testCommand, policy.grind.appendFiles)
|
|
137
|
-
? recordFiles
|
|
138
|
-
: [],
|
|
139
|
-
recordFiles,
|
|
140
|
-
});
|
|
141
|
-
if (run.kind === "ran" && !run.artifact.passed) {
|
|
142
|
-
return {
|
|
143
|
-
kind: "deny",
|
|
144
|
-
reason: gateFailureMessage("test", run.artifact.command, run.artifact.outputTail, rootDiag),
|
|
145
|
-
rule: "ship-gate-test",
|
|
146
|
-
diagnostic: rootDiag.summary,
|
|
147
|
-
};
|
|
148
|
-
}
|
|
149
|
+
required.push({ gate: "test", command: policy.grind.testCommand });
|
|
149
150
|
}
|
|
150
|
-
|
|
151
151
|
if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
command: policy.docs.command,
|
|
162
|
-
argvFiles: [],
|
|
163
|
-
recordFiles: scope.changedFiles,
|
|
164
|
-
});
|
|
165
|
-
if (run.kind === "ran" && !run.artifact.passed) {
|
|
166
|
-
return {
|
|
167
|
-
kind: "deny",
|
|
168
|
-
reason: gateFailureMessage("docs", run.artifact.command, run.artifact.outputTail, rootDiag),
|
|
169
|
-
rule: "ship-gate-docs",
|
|
170
|
-
diagnostic: rootDiag.summary,
|
|
171
|
-
};
|
|
152
|
+
required.push({ gate: "docs", command: policy.docs.command });
|
|
153
|
+
}
|
|
154
|
+
if (required.length > 0) {
|
|
155
|
+
const tree = await workingTreeId(shaRoot);
|
|
156
|
+
if (tree !== null) {
|
|
157
|
+
const refusal = verdictRefusal(root, tree, required, rootDiag);
|
|
158
|
+
if (refusal) {
|
|
159
|
+
return refusal;
|
|
160
|
+
}
|
|
172
161
|
}
|
|
173
162
|
}
|
|
174
163
|
|
package/src/entrypoints/stop.ts
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
listAddedLines,
|
|
10
10
|
listChangedRepoFiles,
|
|
11
11
|
listTrackedFiles,
|
|
12
|
+
workingTreeId,
|
|
12
13
|
} from "../platform/git.ts";
|
|
13
14
|
import { flagsDir } from "../platform/paths.ts";
|
|
14
15
|
import { runCommand } from "../platform/process.ts";
|
|
@@ -226,7 +227,18 @@ export async function runLockedGate(args: {
|
|
|
226
227
|
args.provider,
|
|
227
228
|
args.session,
|
|
228
229
|
async () => {
|
|
230
|
+
const treeBefore = await workingTreeId(args.shaRoot);
|
|
229
231
|
const result = await runCommand(args.shaRoot, args.command, args.argvFiles);
|
|
232
|
+
if (treeBefore !== null && (await workingTreeId(args.shaRoot)) === treeBefore) {
|
|
233
|
+
await coreFacade.gate.recordShipVerdict(args.root, {
|
|
234
|
+
gate: args.gate,
|
|
235
|
+
command: args.command,
|
|
236
|
+
tree: treeBefore,
|
|
237
|
+
exitCode: result.exitCode,
|
|
238
|
+
durationMs: result.durationMs,
|
|
239
|
+
output: result.output,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
230
242
|
return coreFacade.gate.writeLastGate({
|
|
231
243
|
root: args.root,
|
|
232
244
|
sessionKey: args.sessionKey,
|
package/src/platform/git.ts
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
mkdtempSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
utimesSync,
|
|
10
|
+
writeFileSync,
|
|
11
|
+
} from "node:fs";
|
|
2
12
|
import { realpath } from "node:fs/promises";
|
|
3
13
|
import { tmpdir } from "node:os";
|
|
4
14
|
import { join, resolve } from "node:path";
|
|
@@ -58,6 +68,54 @@ export async function gitCommonDirOf(dir: string): Promise<string | null> {
|
|
|
58
68
|
}
|
|
59
69
|
}
|
|
60
70
|
|
|
71
|
+
export async function workingTreeId(dir: string): Promise<string | null> {
|
|
72
|
+
let indexPath: string;
|
|
73
|
+
let objectsPath: string;
|
|
74
|
+
try {
|
|
75
|
+
const located = await runProcess({
|
|
76
|
+
command: ["git", "rev-parse", "--path-format=absolute", "--git-path", "index", "--git-path", "objects"],
|
|
77
|
+
cwd: dir,
|
|
78
|
+
});
|
|
79
|
+
const [index, objects] = located.stdout.trim().split("\n");
|
|
80
|
+
if (located.exitCode !== 0 || !index || !objects) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
indexPath = index;
|
|
84
|
+
objectsPath = objects;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
const scratch = mkdtempSync(join(tmpdir(), "tlc-tree-"));
|
|
89
|
+
const env = {
|
|
90
|
+
...process.env,
|
|
91
|
+
GIT_INDEX_FILE: join(scratch, "index"),
|
|
92
|
+
GIT_OBJECT_DIRECTORY: join(scratch, "objects"),
|
|
93
|
+
GIT_ALTERNATE_OBJECT_DIRECTORIES: objectsPath,
|
|
94
|
+
};
|
|
95
|
+
const git = (args: string[]) =>
|
|
96
|
+
runProcess({ command: ["git", "-c", "core.fsmonitor=false", ...args], cwd: dir, env });
|
|
97
|
+
try {
|
|
98
|
+
mkdirSync(env.GIT_OBJECT_DIRECTORY);
|
|
99
|
+
if (existsSync(indexPath)) {
|
|
100
|
+
copyFileSync(indexPath, env.GIT_INDEX_FILE);
|
|
101
|
+
const original = statSync(indexPath);
|
|
102
|
+
utimesSync(env.GIT_INDEX_FILE, original.atime, original.mtime);
|
|
103
|
+
} else if ((await git(["read-tree", "--empty"])).exitCode !== 0) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
if ((await git(["add", "-A"])).exitCode !== 0) {
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const tree = await git(["write-tree"]);
|
|
110
|
+
const id = tree.stdout.trim();
|
|
111
|
+
return tree.exitCode === 0 && id.length > 0 ? id : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
} finally {
|
|
115
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
61
119
|
export function linkedWorktreeMainRoot(root: string): string | null {
|
|
62
120
|
let text: string;
|
|
63
121
|
try {
|
package/tools/verify.ts
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { coreFacade } from "../src/core/index.ts";
|
|
3
|
+
import { emitJson, takeJsonFlag } from "../src/platform/cli-output.ts";
|
|
4
|
+
import { gitRootOf, workingTreeId } from "../src/platform/git.ts";
|
|
5
|
+
import { findProjectRoot } from "../src/platform/paths.ts";
|
|
6
|
+
|
|
7
|
+
const OUTPUT_KEEP_CHARS = 64 * 1024;
|
|
8
|
+
|
|
9
|
+
export type VerifyGate = { gate: string; command: string[] };
|
|
10
|
+
|
|
11
|
+
export type VerifyOutcome = {
|
|
12
|
+
gate: string;
|
|
13
|
+
command: string[];
|
|
14
|
+
exitCode: number;
|
|
15
|
+
durationMs: number;
|
|
16
|
+
recorded: boolean;
|
|
17
|
+
treeChanged: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function gatesToVerify(policy: ReturnType<typeof coreFacade.policy.loadPolicy>): VerifyGate[] {
|
|
21
|
+
const gates: VerifyGate[] = [];
|
|
22
|
+
if (policy.grind.enabled && policy.grind.lintCommand && policy.grind.lintCommand.length > 0) {
|
|
23
|
+
gates.push({ gate: "lint", command: policy.grind.lintCommand });
|
|
24
|
+
}
|
|
25
|
+
if (policy.grind.enabled && policy.grind.testCommand && policy.grind.testCommand.length > 0) {
|
|
26
|
+
gates.push({ gate: "test", command: policy.grind.testCommand });
|
|
27
|
+
}
|
|
28
|
+
if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
|
|
29
|
+
gates.push({ gate: "docs", command: policy.docs.command });
|
|
30
|
+
}
|
|
31
|
+
return gates;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function runStreaming(
|
|
35
|
+
command: string[],
|
|
36
|
+
cwd: string,
|
|
37
|
+
echo: boolean,
|
|
38
|
+
): Promise<{ exitCode: number; output: string; durationMs: number }> {
|
|
39
|
+
const started = Date.now();
|
|
40
|
+
const [file, ...args] = command;
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
let output = "";
|
|
43
|
+
const keep = (chunk: Buffer, sink: NodeJS.WriteStream) => {
|
|
44
|
+
if (echo) {
|
|
45
|
+
sink.write(chunk);
|
|
46
|
+
}
|
|
47
|
+
output = (output + chunk.toString("utf8")).slice(-OUTPUT_KEEP_CHARS);
|
|
48
|
+
};
|
|
49
|
+
const child = spawn(file ?? "", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
|
|
50
|
+
child.stdout.on("data", (chunk: Buffer) => keep(chunk, process.stdout));
|
|
51
|
+
child.stderr.on("data", (chunk: Buffer) => keep(chunk, process.stderr));
|
|
52
|
+
child.on("error", (error) => {
|
|
53
|
+
resolve({ exitCode: 127, output: `${output}\n${error.message}`, durationMs: Date.now() - started });
|
|
54
|
+
});
|
|
55
|
+
child.on("close", (code) => {
|
|
56
|
+
resolve({ exitCode: code ?? 1, output, durationMs: Date.now() - started });
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export async function verify(args: {
|
|
62
|
+
root: string;
|
|
63
|
+
cwd: string;
|
|
64
|
+
gates: VerifyGate[];
|
|
65
|
+
echo?: boolean;
|
|
66
|
+
log?: (line: string) => void;
|
|
67
|
+
}): Promise<{ tree: string | null; outcomes: VerifyOutcome[] }> {
|
|
68
|
+
const log = args.log ?? ((line: string) => console.log(line));
|
|
69
|
+
const gitRoot = (await gitRootOf(args.cwd)) ?? args.cwd;
|
|
70
|
+
const outcomes: VerifyOutcome[] = [];
|
|
71
|
+
let tree = await workingTreeId(gitRoot);
|
|
72
|
+
for (const { gate, command } of args.gates) {
|
|
73
|
+
const before = tree;
|
|
74
|
+
log(`▸ ${gate}: ${command.join(" ")}`);
|
|
75
|
+
const result = await runStreaming(command, gitRoot, args.echo ?? true);
|
|
76
|
+
tree = await workingTreeId(gitRoot);
|
|
77
|
+
const treeChanged = before === null || tree !== before;
|
|
78
|
+
if (!treeChanged && before !== null) {
|
|
79
|
+
await coreFacade.gate.recordShipVerdict(args.root, {
|
|
80
|
+
gate,
|
|
81
|
+
command,
|
|
82
|
+
tree: before,
|
|
83
|
+
exitCode: result.exitCode,
|
|
84
|
+
durationMs: result.durationMs,
|
|
85
|
+
output: result.output,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
outcomes.push({
|
|
89
|
+
gate,
|
|
90
|
+
command,
|
|
91
|
+
exitCode: result.exitCode,
|
|
92
|
+
durationMs: result.durationMs,
|
|
93
|
+
recorded: !treeChanged,
|
|
94
|
+
treeChanged,
|
|
95
|
+
});
|
|
96
|
+
log(
|
|
97
|
+
`${result.exitCode === 0 ? "✔" : "✖"} ${gate} — exit ${result.exitCode} in ${Math.round(result.durationMs / 1000)}s`,
|
|
98
|
+
);
|
|
99
|
+
if (result.exitCode !== 0) {
|
|
100
|
+
break;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return { tree, outcomes };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function main(argv: string[]): Promise<void> {
|
|
107
|
+
const { json } = takeJsonFlag(argv);
|
|
108
|
+
const root = process.env.TLC_PROJECT_DIR ?? findProjectRoot(process.cwd()) ?? process.cwd();
|
|
109
|
+
const policy = coreFacade.policy.loadPolicy(root);
|
|
110
|
+
const gates = gatesToVerify(policy);
|
|
111
|
+
if (gates.length === 0) {
|
|
112
|
+
const message = "verify: no lint, test or docs gate is configured — nothing to verify";
|
|
113
|
+
if (json) {
|
|
114
|
+
emitJson({ tree: null, outcomes: [], message });
|
|
115
|
+
} else {
|
|
116
|
+
console.log(message);
|
|
117
|
+
}
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
const { tree, outcomes } = await verify({
|
|
121
|
+
root,
|
|
122
|
+
cwd: process.cwd(),
|
|
123
|
+
gates,
|
|
124
|
+
echo: !json,
|
|
125
|
+
...(json ? { log: (line: string) => console.error(line) } : {}),
|
|
126
|
+
});
|
|
127
|
+
const failed = outcomes.find((outcome) => outcome.exitCode !== 0);
|
|
128
|
+
const changed = outcomes.find((outcome) => outcome.treeChanged);
|
|
129
|
+
if (json) {
|
|
130
|
+
emitJson({ tree, outcomes });
|
|
131
|
+
} else if (failed) {
|
|
132
|
+
console.log(`verify: ${failed.gate} failed — fix it and run \`tlc harness verify\` again`);
|
|
133
|
+
} else if (changed) {
|
|
134
|
+
console.log(
|
|
135
|
+
`verify: ${changed.gate} changed files while it ran, so its result describes a tree that no longer exists — review the changes and run \`tlc harness verify\` again`,
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
console.log(`verify: all gates passed for tree ${tree?.slice(0, 12)} — commit, push and PR may proceed`);
|
|
139
|
+
}
|
|
140
|
+
process.exit(failed || changed ? 1 : 0);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (import.meta.main) {
|
|
144
|
+
await main(process.argv.slice(2));
|
|
145
|
+
}
|