@tech-leads-club/harness-toolkit 0.16.5 → 0.16.7

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.
@@ -1,4 +1,14 @@
1
- import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
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,52 @@ 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
+ await git(["add", "-A", "--ignore-errors"]);
107
+ const tree = await git(["write-tree"]);
108
+ const id = tree.stdout.trim();
109
+ return tree.exitCode === 0 && id.length > 0 ? id : null;
110
+ } catch {
111
+ return null;
112
+ } finally {
113
+ rmSync(scratch, { recursive: true, force: true });
114
+ }
115
+ }
116
+
61
117
  export function linkedWorktreeMainRoot(root: string): string | null {
62
118
  let text: string;
63
119
  try {
@@ -52,16 +52,18 @@ export async function runProcess(args: {
52
52
  input?: string;
53
53
  env?: NodeJS.ProcessEnv;
54
54
  timeoutMs?: number;
55
- }): Promise<{ exitCode: number; stdout: string; stderr: string }> {
55
+ }): Promise<{ exitCode: number; stdout: string; stderr: string; timedOut?: boolean }> {
56
56
  const [file, ...argv] = args.command;
57
57
  if (file === undefined) {
58
58
  return { exitCode: 0, stdout: "", stderr: "" };
59
59
  }
60
+ const ownGroup = args.timeoutMs !== undefined && process.platform !== "win32";
60
61
  return await new Promise((resolve, reject) => {
61
62
  const child = spawn(file, argv, {
62
63
  cwd: args.cwd,
63
64
  stdio: ["pipe", "pipe", "pipe"] as const,
64
65
  env: args.env ?? process.env,
66
+ detached: ownGroup,
65
67
  });
66
68
  let stdout = "";
67
69
  let stderr = "";
@@ -71,6 +73,12 @@ export async function runProcess(args: {
71
73
  ? undefined
72
74
  : setTimeout(() => {
73
75
  timedOut = true;
76
+ try {
77
+ if (ownGroup && child.pid !== undefined) {
78
+ process.kill(-child.pid, "SIGKILL");
79
+ return;
80
+ }
81
+ } catch {}
74
82
  child.kill("SIGKILL");
75
83
  }, args.timeoutMs);
76
84
 
@@ -87,7 +95,12 @@ export async function runProcess(args: {
87
95
  child.on("close", (code) => {
88
96
  if (timer) clearTimeout(timer);
89
97
  if (timedOut) {
90
- resolve({ exitCode: TIMEOUT_EXIT_CODE, stdout, stderr: `${stderr}\n(process timed out)` });
98
+ resolve({
99
+ exitCode: TIMEOUT_EXIT_CODE,
100
+ stdout,
101
+ stderr: `${stderr}\n(process timed out)`,
102
+ timedOut: true,
103
+ });
91
104
  return;
92
105
  }
93
106
  resolve({ exitCode: code ?? 1, stdout, stderr });
@@ -99,7 +112,7 @@ export async function runProcess(args: {
99
112
  });
100
113
  }
101
114
 
102
- export type CommandResult = { exitCode: number; output: string; durationMs: number };
115
+ export type CommandResult = { exitCode: number; output: string; durationMs: number; timedOut?: boolean };
103
116
 
104
117
  export const NO_OUTPUT_CAPTURED = "(no output captured)";
105
118
 
@@ -114,7 +127,7 @@ export async function runCommand(
114
127
  projectDir: string,
115
128
  command: string[],
116
129
  extraArgs: string[] = [],
117
- options: { env?: NodeJS.ProcessEnv } = {},
130
+ options: { env?: NodeJS.ProcessEnv; timeoutMs?: number } = {},
118
131
  ): Promise<CommandResult> {
119
132
  if (command.length === 0) {
120
133
  return { exitCode: 0, output: "", durationMs: 0 };
@@ -124,6 +137,7 @@ export async function runCommand(
124
137
  command: [...command, ...extraArgs],
125
138
  cwd: projectDir,
126
139
  env: options.env ? { ...process.env, ...options.env } : process.env,
140
+ ...(options.timeoutMs !== undefined ? { timeoutMs: options.timeoutMs } : {}),
127
141
  });
128
142
  const combined = (result.stdout + result.stderr).trim();
129
143
  const output = combined.length === 0 ? NO_OUTPUT_CAPTURED : combined;
@@ -131,5 +145,6 @@ export async function runCommand(
131
145
  exitCode: result.exitCode,
132
146
  output,
133
147
  durationMs: Date.now() - started,
148
+ ...(result.timedOut ? { timedOut: true } : {}),
134
149
  };
135
150
  }
@@ -0,0 +1,154 @@
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
+ unidentified: boolean;
19
+ };
20
+
21
+ export function gatesToVerify(policy: ReturnType<typeof coreFacade.policy.loadPolicy>): VerifyGate[] {
22
+ const gates: VerifyGate[] = [];
23
+ if (policy.grind.enabled && policy.grind.lintCommand && policy.grind.lintCommand.length > 0) {
24
+ gates.push({ gate: "lint", command: policy.grind.lintCommand });
25
+ }
26
+ if (policy.grind.enabled && policy.grind.testCommand && policy.grind.testCommand.length > 0) {
27
+ gates.push({ gate: "test", command: policy.grind.testCommand });
28
+ }
29
+ if (policy.docs.command && policy.docs.command.length > 0 && policy.docs.severity === "deny") {
30
+ gates.push({ gate: "docs", command: policy.docs.command });
31
+ }
32
+ return gates;
33
+ }
34
+
35
+ function runStreaming(
36
+ command: string[],
37
+ cwd: string,
38
+ echo: boolean,
39
+ ): Promise<{ exitCode: number; output: string; durationMs: number }> {
40
+ const started = Date.now();
41
+ const [file, ...args] = command;
42
+ return new Promise((resolve) => {
43
+ let output = "";
44
+ const keep = (chunk: Buffer, sink: NodeJS.WriteStream) => {
45
+ if (echo) {
46
+ sink.write(chunk);
47
+ }
48
+ output = (output + chunk.toString("utf8")).slice(-OUTPUT_KEEP_CHARS);
49
+ };
50
+ const child = spawn(file ?? "", args, { cwd, stdio: ["ignore", "pipe", "pipe"] });
51
+ child.stdout.on("data", (chunk: Buffer) => keep(chunk, process.stdout));
52
+ child.stderr.on("data", (chunk: Buffer) => keep(chunk, process.stderr));
53
+ child.on("error", (error) => {
54
+ resolve({ exitCode: 127, output: `${output}\n${error.message}`, durationMs: Date.now() - started });
55
+ });
56
+ child.on("close", (code) => {
57
+ resolve({ exitCode: code ?? 1, output, durationMs: Date.now() - started });
58
+ });
59
+ });
60
+ }
61
+
62
+ export async function verify(args: {
63
+ root: string;
64
+ cwd: string;
65
+ gates: VerifyGate[];
66
+ echo?: boolean;
67
+ log?: (line: string) => void;
68
+ }): Promise<{ tree: string | null; outcomes: VerifyOutcome[] }> {
69
+ const log = args.log ?? ((line: string) => console.log(line));
70
+ const gitRoot = (await gitRootOf(args.cwd)) ?? args.cwd;
71
+ const outcomes: VerifyOutcome[] = [];
72
+ let tree = await workingTreeId(gitRoot);
73
+ for (const { gate, command } of args.gates) {
74
+ const before = tree;
75
+ log(`▸ ${gate}: ${command.join(" ")}`);
76
+ const result = await runStreaming(command, gitRoot, args.echo ?? true);
77
+ tree = await workingTreeId(gitRoot);
78
+ const unidentified = before === null || tree === null;
79
+ const treeChanged = !unidentified && tree !== before;
80
+ if (!unidentified && !treeChanged && before !== null) {
81
+ await coreFacade.gate.recordShipVerdict(args.root, {
82
+ gate,
83
+ command,
84
+ tree: before,
85
+ exitCode: result.exitCode,
86
+ durationMs: result.durationMs,
87
+ output: result.output,
88
+ source: coreFacade.gate.VERIFY_SOURCE,
89
+ });
90
+ }
91
+ outcomes.push({
92
+ gate,
93
+ command,
94
+ exitCode: result.exitCode,
95
+ durationMs: result.durationMs,
96
+ recorded: !unidentified && !treeChanged,
97
+ treeChanged,
98
+ unidentified,
99
+ });
100
+ log(
101
+ `${result.exitCode === 0 ? "✔" : "✖"} ${gate} — exit ${result.exitCode} in ${Math.round(result.durationMs / 1000)}s`,
102
+ );
103
+ if (result.exitCode !== 0) {
104
+ break;
105
+ }
106
+ }
107
+ return { tree, outcomes };
108
+ }
109
+
110
+ export async function main(argv: string[]): Promise<void> {
111
+ const { json } = takeJsonFlag(argv);
112
+ const root = process.env.TLC_PROJECT_DIR ?? findProjectRoot(process.cwd()) ?? process.cwd();
113
+ const policy = coreFacade.policy.loadPolicy(root);
114
+ const gates = gatesToVerify(policy);
115
+ if (gates.length === 0) {
116
+ const message = "verify: no lint, test or docs gate is configured — nothing to verify";
117
+ if (json) {
118
+ emitJson({ tree: null, outcomes: [], message });
119
+ } else {
120
+ console.log(message);
121
+ }
122
+ return;
123
+ }
124
+ const { tree, outcomes } = await verify({
125
+ root,
126
+ cwd: process.cwd(),
127
+ gates,
128
+ echo: !json,
129
+ ...(json ? { log: (line: string) => console.error(line) } : {}),
130
+ });
131
+ const failed = outcomes.find((outcome) => outcome.exitCode !== 0);
132
+ const changed = outcomes.find((outcome) => outcome.treeChanged);
133
+ const unidentified = outcomes.find((outcome) => outcome.unidentified);
134
+ if (json) {
135
+ emitJson({ tree, outcomes });
136
+ } else if (failed) {
137
+ console.log(`verify: ${failed.gate} failed — fix it and run \`tlc harness verify\` again`);
138
+ } else if (unidentified) {
139
+ console.log(
140
+ "verify: git could not identify the working tree, so no verdict was recorded — run `git status` to see what it cannot read",
141
+ );
142
+ } else if (changed) {
143
+ console.log(
144
+ `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`,
145
+ );
146
+ } else {
147
+ console.log(`verify: all gates passed for tree ${tree?.slice(0, 12)} — commit, push and PR may proceed`);
148
+ }
149
+ process.exit(failed || changed || unidentified ? 1 : 0);
150
+ }
151
+
152
+ if (import.meta.main) {
153
+ await main(process.argv.slice(2));
154
+ }