Skip to content

Commit 0afa3fb

Browse files
Dumbriselectrolobzikclaude
authored
fix(generate-types): re-sync TS output + CRLF-safe drift test (supersedes #472) (#475)
* fix(generate-types): re-sync hardcoded TS output with contracts.ts The TypeScript-as-Go-string literal in cmd/generate-types/main.go drifted from frontend/src/types/contracts.ts when PR #424 (Server Config tab parity) and PR #463 (per-tool enable/disable) edited contracts.ts directly without updating the generator. Running `go run ./cmd/generate-types` (invoked by Makefile's `frontend-build` target) silently reverts those fields, producing a dirty working tree on every `make build`: - Server.isolation_defaults - IsolationConfig.network_mode, IsolationConfig.extra_args - IsolationDefaults (entire interface) - Tool.disabled, Tool.approval_status The reverted contracts.ts also feeds back into Vite's bundle hashes, which is the likely reason web/frontend/dist/* also churns on rebuilds. This commit catches the generator up to the actual contracts.ts content. After this, `go run ./cmd/generate-types` is idempotent against HEAD. Verified: generator output is byte-identical to contracts.ts. * test(generate-types): catch future contracts.ts drift in CI Adds TestContractsInSync, which runs the generator's content function and asserts byte-equality with the committed frontend/src/types/contracts.ts. The next time a contributor hand-edits contracts.ts (or hand-edits the hardcoded TS string in main.go) without updating both sides, CI fails with a clear message pointing at the fix: Either run \`go run ./cmd/generate-types\` from the module root (if the generator is the source of truth) or update the string literals in main.go (if contracts.ts is the source of truth). The drift this test guards against is what allowed PRs #424 and #463 to silently leave the generator out of sync. Refactors main.go to factor out generateFileContent() so the test can compare without re-implementing the header concat. * fix(generate-types): make TestContractsInSync CRLF-safe on Windows The new TestContractsInSync did a raw byte comparison of the committed contracts.ts against generator output. On Windows CI (core.autocrlf=true) git checks out contracts.ts with CRLF endings while the generator emits LF, so the test failed on windows-amd64 even though the contract was in sync (observed on PR #472). Two-layer fix: - .gitattributes pins frontend/src/types/contracts.ts to `text eol=lf` so it is checked out identically on every platform (the real fix). - The test now normalizes CRLF->LF before comparing, keeping it green regardless of a contributor's local git config (defense in depth). Verified: test passes with both LF and CRLF checkouts of contracts.ts; `go run ./cmd/generate-types` remains idempotent. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Roman Chernyak <electrolobzik@gmail.com> Co-authored-by: Claude Code <noreply@anthropic.com>
1 parent b1faef4 commit 0afa3fb

3 files changed

Lines changed: 99 additions & 14 deletions

File tree

.gitattributes

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Pin generated/source files that are byte-compared in tests to LF so they are
2+
# checked out identically on Windows (core.autocrlf=true would otherwise yield
3+
# CRLF and break cmd/generate-types TestContractsInSync).
4+
frontend/src/types/contracts.ts text eol=lf

cmd/generate-types/main.go

Lines changed: 40 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -8,30 +8,34 @@ import (
88
"strings"
99
)
1010

11-
func main() {
12-
// Define the TypeScript types based on our Go contracts
13-
typeDefinitions := generateTypeDefinitions()
11+
// contractsRelPath is the location of the generated TypeScript file
12+
// relative to the module root.
13+
const contractsRelPath = "frontend/src/types/contracts.ts"
14+
15+
// generateFileContent returns the full contents that should be written to
16+
// contracts.ts (header + generated type definitions). Exposed so tests can
17+
// verify the committed file matches the generator output and catch drift
18+
// without writing to disk.
19+
func generateFileContent() string {
20+
return fmt.Sprintf(`// Generated TypeScript types from Go contracts
21+
// DO NOT EDIT - This file is auto-generated by cmd/generate-types
22+
23+
%s`, generateTypeDefinitions())
24+
}
1425

15-
// Create output directory if it doesn't exist
16-
outputDir := "frontend/src/types"
26+
func main() {
27+
outputDir := filepath.Dir(contractsRelPath)
1728
if err := os.MkdirAll(outputDir, 0755); err != nil {
1829
fmt.Printf("Error creating output directory: %v\n", err)
1930
os.Exit(1)
2031
}
2132

22-
// Write the TypeScript file
23-
outputFile := filepath.Join(outputDir, "contracts.ts")
24-
content := fmt.Sprintf(`// Generated TypeScript types from Go contracts
25-
// DO NOT EDIT - This file is auto-generated by cmd/generate-types
26-
27-
%s`, typeDefinitions)
28-
29-
if err := os.WriteFile(outputFile, []byte(content), 0600); err != nil {
33+
if err := os.WriteFile(contractsRelPath, []byte(generateFileContent()), 0600); err != nil {
3034
fmt.Printf("Error writing TypeScript file: %v\n", err)
3135
os.Exit(1)
3236
}
3337

34-
fmt.Printf("Successfully generated TypeScript types: %s\n", outputFile)
38+
fmt.Printf("Successfully generated TypeScript types: %s\n", contractsRelPath)
3539
}
3640

3741
func generateTypeDefinitions() string {
@@ -117,6 +121,7 @@ export interface HealthStatus {
117121
created: string; // ISO date string
118122
updated: string; // ISO date string
119123
isolation?: IsolationConfig;
124+
isolation_defaults?: IsolationDefaults; // Resolved baseline values (read-only, used as placeholders)
120125
oauth_status?: 'authenticated' | 'expired' | 'error' | 'none'; // OAuth authentication status
121126
token_expires_at?: string; // ISO date string when OAuth token expires
122127
user_logged_out?: boolean; // True if user explicitly logged out (prevents auto-reconnection)
@@ -135,12 +140,27 @@ export interface OAuthConfig {
135140
export interface IsolationConfig {
136141
enabled: boolean;
137142
image?: string;
143+
network_mode?: string;
144+
extra_args?: string[];
138145
memory_limit?: string;
139146
cpu_limit?: string;
140147
working_dir?: string;
141148
timeout?: string;
142149
}
143150
151+
// IsolationDefaults reports the resolved baseline Docker isolation
152+
// values the backend will apply when no per-server override is set.
153+
// Populated on server-list / server-get responses; the Web UI uses these
154+
// as placeholders so "empty = inherit" is discoverable instead of
155+
// mysterious. Never sent back on PATCH requests.
156+
export interface IsolationDefaults {
157+
runtime_type?: string;
158+
image?: string;
159+
network_mode?: string;
160+
extra_args?: string[];
161+
working_dir?: string;
162+
}
163+
144164
`)
145165

146166
// Tool types
@@ -151,6 +171,12 @@ export interface IsolationConfig {
151171
schema?: Record<string, any>;
152172
usage: number;
153173
last_used?: string; // ISO date string
174+
// Mirrors contracts.Tool.Disabled on the Go side — present when an
175+
// approval record exists for this tool. Absent means "enabled" (default).
176+
disabled?: boolean;
177+
// Tool-level quarantine status surfaced by the same approval record.
178+
// Optional because non-quarantined tools simply omit the field.
179+
approval_status?: string;
154180
}
155181
156182
export interface SearchResult {

cmd/generate-types/main_test.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package main
2+
3+
import (
4+
"bytes"
5+
"os"
6+
"path/filepath"
7+
"testing"
8+
)
9+
10+
// normalizeEOL strips carriage returns so the comparison is robust on Windows,
11+
// where git may check out contracts.ts with CRLF endings (core.autocrlf=true)
12+
// even though the generator always emits LF. .gitattributes pins this file to
13+
// LF, but normalizing here keeps the test green regardless of git config.
14+
func normalizeEOL(b []byte) []byte {
15+
return bytes.ReplaceAll(b, []byte("\r\n"), []byte("\n"))
16+
}
17+
18+
// TestContractsInSync fails when frontend/src/types/contracts.ts has drifted
19+
// from what cmd/generate-types would produce today. Catches the failure mode
20+
// where a contributor hand-edits contracts.ts (or hand-edits the generator's
21+
// hardcoded string literals) without updating the other side: the next
22+
// `make build` / `go run ./cmd/generate-types` silently reverts their work
23+
// and leaves a dirty working tree.
24+
//
25+
// To fix a failure of this test:
26+
// 1. Decide which side is correct (usually: the generator).
27+
// 2. Run `go run ./cmd/generate-types` from the module root, OR update the
28+
// string literals in main.go to match contracts.ts.
29+
// 3. Commit both files in the same change.
30+
func TestContractsInSync(t *testing.T) {
31+
// cmd/generate-types tests run with cwd = the package directory.
32+
// Walk up two levels to reach the module root.
33+
contractsPath := filepath.Join("..", "..", contractsRelPath)
34+
35+
committed, err := os.ReadFile(contractsPath)
36+
if err != nil {
37+
t.Fatalf("reading %s: %v", contractsPath, err)
38+
}
39+
40+
generated := []byte(generateFileContent())
41+
42+
if bytes.Equal(normalizeEOL(committed), normalizeEOL(generated)) {
43+
return
44+
}
45+
46+
t.Fatalf(
47+
"%s is out of sync with cmd/generate-types/main.go.\n"+
48+
"\nThe TypeScript string literals in main.go must produce a byte-identical\n"+
49+
"copy of contracts.ts. To fix: either run `go run ./cmd/generate-types`\n"+
50+
"from the module root (if the generator is the source of truth) or update\n"+
51+
"the string literals in main.go (if contracts.ts is the source of truth).\n"+
52+
"\ncommitted size: %d bytes\ngenerated size: %d bytes",
53+
contractsRelPath, len(committed), len(generated),
54+
)
55+
}

0 commit comments

Comments
 (0)