Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
2291 lines (2117 loc) · 116 KB
/
Copy pathMakefile
File metadata and controls
2291 lines (2117 loc) · 116 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# mcp-data-platform Makefile
# Variables
BINARY_NAME := mcp-data-platform
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
BUILD_TIME := $(shell date -u '+%Y-%m-%d_%H:%M:%S')
GO_VERSION := $(shell go version | cut -d ' ' -f 3)
LDFLAGS := -ldflags "-X github.com/txn2/mcp-data-platform/internal/buildinfo.Version=$(VERSION)"
# Directories
CMD_DIR := ./cmd/mcp-data-platform
BUILD_DIR := ./build
DIST_DIR := ./_dist
UI_DIR := ./ui
UI_EMBED_DIR := ./internal/ui/dist
CV_EMBED_DIR := ./internal/contentviewer/dist
# Tool versions — keep in sync with .github/workflows/ci.yml
GOLANGCI_LINT_VERSION := v2.14.0
# gosec is pinned to a commit on master: v2.29.0, the latest release, cannot
# load packages built by Go 1.27.2 (securego/gosec#1771, export data version 5).
GOSEC_VERSION := v2.29.1-0.20261015133444-707f2271f665
GREMLINS_VERSION := v0.6.0
# govulncheck is pinned for the same reason the others are: its report is judged
# by scripts/govulncheck-gate.py, which reads the -format json message stream, so
# local and CI must run the same scanner against the same schema. .github/workflows/ci.yml
# installs this version.
GOVULNCHECK_VERSION := v1.9.0
# osv-scanner reads the GitHub advisory database, which carries advisories the
# Go vulnerability database govulncheck reads does not (the 15 excelize
# advisories of #2143 were in one and not the other), and it reads the UI's npm
# lockfile, which nothing else scans. .github/workflows/ci.yml installs this version.
OSV_SCANNER_VERSION := v2.5.1
# Total-coverage floor. This is the single source of truth for the project
# coverage gate: .github/workflows/ci.yml, codecov.yml (project target) and
# CONTRIBUTING.md must all state this same figure, and TestGateFiguresAgree
# (test/structure/pins_test.go) fails when they drift apart. Patch coverage (changed lines)
# is a separate gate at PATCH_COVERAGE_MIN.
COVERAGE_MIN := 82
PATCH_COVERAGE_MIN := 80
# Where `make security` parks the govulncheck JSON report the gate judges. It
# is a build artifact, removed by the target that writes it.
GOVULN_REPORT := build/govulncheck-report.json
# Go commands
GO := go
GOTEST := $(GO) test
GOBUILD := $(GO) build
GOMOD := $(GO) mod
GOVET := $(GO) vet
GOFMT := gofmt
GOLINT := golangci-lint
.PHONY: all migrate-check-diff build test lint lint-full fmt clean install help docs-serve docs-build docs-images-check verify verify-release alert-rules-test builtin-pages-check \
tools-check dead-code mutate patch-coverage patch-coverage-check coverage-summary doc-check acceptance acceptance-release acceptance-check acceptance-release-check release-tag-check schedule-lane schedule-lane-ui realdb-lane state-readers-check e2e-copy-check route-manifest-check posture-check preverify preverify-fast swagger swagger-check verify-checks verify-go verify-static verify-docker verify-ui verify-static-ui vet-tags \
semgrep semgrep-diff semgrep-changed codeql sast osv embed-clean migrate-check \
frontend-install frontend-build frontend-build-content-viewer content-viewer-embed \
frontend-dev frontend-mock frontend-test frontend-lint frontend-e2e \
frontend-e2e-public-viewer \
e2e-up e2e-down e2e-seed e2e-test e2e e2e-logs e2e-clean \
dev dev-info dev-up dev-stop dev-down dev-kill-hosts mock-check \
preview-apps preview-platform-info
## all: Build and test
all: build test lint
## build: Build the binary
build: swagger
@echo "Building $(BINARY_NAME)..."
@mkdir -p $(BUILD_DIR)
$(GOBUILD) $(LDFLAGS) -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_DIR)
@echo "Binary built: $(BUILD_DIR)/$(BINARY_NAME)"
## test: Run tests
test:
@echo "Running tests..."
@# -p 6 bounds how many packages are built and run at once.
@#
@# The default is one per core, which on an 18-core machine means this
@# target alone tries to own the whole machine — and inside `verify` it is
@# one of four lanes doing that. Measured there: this lane finished 93s
@# before the slowest one, so its unbounded appetite bought slack it did
@# not need while inflating the lane that actually sets the wall clock
@# (the e2e suite ran 109s alone and ~235s beside it). Spending some of
@# that slack to stop starving the pole is the trade.
@#
@# -race multiplies the memory and CPU each package costs, which is why
@# the bound matters more here than on an ordinary test run.
$(GOTEST) -v -race -p 6 -coverprofile=coverage.out ./...
@echo "Tests complete."
## test-short: Run tests without race detection (faster)
test-short:
@echo "Running tests (short)..."
$(GOTEST) -v ./...
## test-integration: Run integration tests
test-integration:
@echo "Running integration tests..."
$(GOTEST) -v -tags=integration ./...
# Real-DB round-trip gate for tool write paths. Runs every integration test
# named *RealDB* against a self-provisioned pgvector container (testcontainers),
# exercising the actual schema (NOT NULL constraints, defaults, column types)
# that sqlmock cannot. This is the gate that would have caught the prompt-create
# defect that shipped to production (pq.Array(nil) -> NULL into NOT NULL tags).
# Convention: name any tool write-path round-trip test *RealDB* so it runs here.
# Requires Docker. Part of `verify`, alongside migrate-check.
## test-realdb: Real-Postgres round-trip gate for store write paths
test-realdb:
@echo "Running real-DB round-trip gate..."
@# One Postgres for the whole gate, not one per test.
@#
@# internal/testdb.New is called at 163 sites and per test, not per
@# package. A container plus a full replay of the migration set for each
@# of them cost ~420s here and made this the slowest lane in `verify` by
@# roughly eight times. Instead: start one server, apply the migrations
@# once into a template database, and let each test clone it with
@# CREATE DATABASE ... TEMPLATE, which Postgres does as a file copy.
@#
@# Isolation is unchanged. Every test still gets a private database that
@# nothing else writes to; what is shared is one postgres process. The
@# harness falls back to a container per test when TESTDB_DSN is unset,
@# so a bare `go test -tags=integration ./...` still works.
@#
@# -p 4 still bounds package concurrency: it is what keeps 34 packages
@# from opening pools faster than one server will accept them.
@# The sampler reports the peak connections the run held, in total and for
@# the packages that held the most, beside REALDB_MAX_CONNECTIONS (#2249).
@set -e; \
report=$$(mktemp); sampler=; \
finish() { \
if [ -n "$$sampler" ]; then kill -TERM $$sampler 2>/dev/null; wait $$sampler 2>/dev/null || true; cat "$$report"; fi; \
rm -f "$$report"; \
docker rm -f $(REALDB_PG_CONTAINER) >/dev/null 2>&1 || true; \
}; \
trap finish EXIT; \
docker rm -f $(REALDB_PG_CONTAINER) >/dev/null 2>&1 || true; \
docker run -d --name $(REALDB_PG_CONTAINER) \
-e POSTGRES_USER=test -e POSTGRES_PASSWORD=test -e POSTGRES_DB=postgres \
-p 127.0.0.1::5432 $(REALDB_PG_IMAGE) \
-c max_connections=$(REALDB_MAX_CONNECTIONS) -c fsync=off -c full_page_writes=off \
-c synchronous_commit=off >/dev/null; \
pg_port=$$(docker port $(REALDB_PG_CONTAINER) 5432/tcp | head -1 | sed 's/.*://'); \
if [ -z "$$pg_port" ]; then echo "FAIL: could not read the published Postgres port" >&2; exit 1; fi; \
echo " waiting for Postgres on :$$pg_port..."; \
for i in $$(seq 1 60); do \
docker exec $(REALDB_PG_CONTAINER) pg_isready -h localhost -p 5432 -U test >/dev/null 2>&1 && break; \
if [ "$$i" = "60" ]; then echo "FAIL: Postgres did not become ready" >&2; exit 1; fi; \
sleep 1; \
done; \
echo " building the migrated template once..."; \
docker exec $(REALDB_PG_CONTAINER) psql -U test -d postgres -q \
-c 'CREATE DATABASE $(REALDB_TEMPLATE)'; \
MIGRATE_TEST_DSN="postgres://test:test@localhost:$$pg_port/$(REALDB_TEMPLATE)?sslmode=disable" \
$(GOTEST) -count=1 -run TestMigrationsAgainstRealPostgres ./pkg/database/migrate/ >/dev/null; \
python3 scripts/realdb-connections.py $(REALDB_PG_CONTAINER) "$$report" & sampler=$$!; \
TESTDB_DSN="postgres://test:test@localhost:$$pg_port/postgres?sslmode=disable" \
TESTDB_TEMPLATE="$(REALDB_TEMPLATE)" \
$(GOTEST) -count=1 -p 4 -tags=integration -run '$(REALDB_RUN)' $(REALDB_PKGS)
@echo "Real-DB gate passed."
# Live post-deploy smoke: connects to a RUNNING MCP server as a real MCP client
# (admin API key) and exercises every user-facing write tool end to end, then
# cleans up. This is the layer that catches deployment drift / config the
# in-process gates cannot see. Skips unless MCP_API_KEY is set.
## smoke: Live smoke against a running MCP (env: MCP_BASE_URL, MCP_API_KEY)
smoke:
@echo "Running live smoke against $${MCP_BASE_URL:-http://localhost:8099}..."
$(GOTEST) -count=1 -tags=integration ./test/smoke/ -run Smoke -v
# Real-Postgres migration gate. Applies every embedded migration + the dev seed
# to a disposable pgvector instance (up -> seed -> down -> up), catching SQL the
# planner only rejects against a live engine (e.g. a non-IMMUTABLE function in an
# index expression), down-migration dependency-order bugs, and dev-seed rot.
# sqlmock and the embedded-file presence checks cannot catch these. Provisions
# its own container on its own port so it never touches the dev DB.
# Run against every PostgreSQL major a deployment may be on, because the engine
# is what decides whether this SQL is legal and the majors disagree. PostgreSQL
# 17 restricts search_path to pg_catalog and pg_temp during maintenance
# operations, so an index expression whose function body calls a function of
# ours builds on 16 and fails on 17. A single-major gate reported that as green
# and it reached a release (000102, fixed in 000111). One major is not a gate.
# Real-DB gate: one shared Postgres. fsync/full_page_writes/synchronous_commit
# are off because this database exists for the duration of one gate run and is
# thrown away; durability settings only buy crash recovery nobody wants here,
# and they dominate the cost of 163 schema clones.
#
# Neither gate names a host port. This machine runs many projects at once, and a
# fixed port is a standing appointment to collide with one of them: 55432 was
# already held by another project's Postgres and migrate-check died on the bind
# rather than on anything in the diff. `-p 127.0.0.1::5432` hands the choice to
# the kernel, which picks one that is free at that instant; `docker port` reads
# back which. Nothing outside the gate needs to reach these containers -- the
# DSN is built here and handed to the test process -- so the port never has to
# be predictable, only free.
REALDB_PG_CONTAINER := mcpdp-realdb-pg
REALDB_PG_IMAGE := pgvector/pgvector:pg16
REALDB_TEMPLATE := testdb_template
# REALDB_PKGS is what test-realdb runs: every package, unless realdb-lane
# narrows it to the packages a branch can break (#1947).
REALDB_PKGS ?= ./...
# REALDB_RUN is the -run pattern: every RealDB test, unless route-template-diff
# names the one gate it needs (#2186).
REALDB_RUN ?= RealDB
# REALDB_MAX_CONNECTIONS is the lane server's max_connections, set from what the
# packages open (#2249). -p 4 runs four packages at once, so the four largest
# package peaks bound the lane: 8+8+6+5 measured on 2026-10-21, plus the
# template and admin sessions, about 30. Before the migration wrapper returned
# its connection, pkg/oauth/postgres alone held 268 and a 400 limit was
# exhausted under make verify's load. The sampler test-realdb runs prints each
# run's peak and its largest packages.
REALDB_MAX_CONNECTIONS ?= 100
MIGRATE_PG_IMAGES := \
pgvector/pgvector:pg16@sha256:00ba258a66dac104fd5171074a0084462a64a1369d8513f3d0a634e2f24d15bc \
pgvector/pgvector:pg17@sha256:cf134a767f474095eeba57e0117be8e568e011a63f33fbf252f14c9b760f8e6f
MIGRATE_PG_CONTAINER := mcp-migrate-check-pg
## migrate-check: Apply all migrations + seed to a throwaway real Postgres, per major
migrate-check:
@echo "Running real-Postgres migration gate..."
@set -e; \
trap 'docker rm -f $(MIGRATE_PG_CONTAINER) >/dev/null 2>&1 || true' EXIT; \
for img in $(MIGRATE_PG_IMAGES); do \
echo " === $$img ==="; \
docker rm -f $(MIGRATE_PG_CONTAINER) >/dev/null 2>&1 || true; \
docker run -d --name $(MIGRATE_PG_CONTAINER) \
-e POSTGRES_USER=migrate -e POSTGRES_PASSWORD=migrate -e POSTGRES_DB=migrate_check \
-p 127.0.0.1::5432 $$img >/dev/null; \
pg_port=$$(docker port $(MIGRATE_PG_CONTAINER) 5432/tcp | head -1 | sed 's/.*://'); \
if [ -z "$$pg_port" ]; then echo "FAIL: could not read the published Postgres port" >&2; exit 1; fi; \
echo " waiting for Postgres on :$$pg_port..."; \
for i in $$(seq 1 60); do \
docker exec $(MIGRATE_PG_CONTAINER) pg_isready -h localhost -p 5432 -U migrate -d migrate_check >/dev/null 2>&1 && break; \
if [ "$$i" = "60" ]; then echo "FAIL: Postgres did not become ready" >&2; exit 1; fi; \
sleep 1; \
done; \
MIGRATE_TEST_DSN="postgres://migrate:migrate@localhost:$$pg_port/migrate_check?sslmode=disable" \
$(GOTEST) -count=1 -run TestMigrationsAgainstRealPostgres ./pkg/database/migrate/; \
docker rm -f $(MIGRATE_PG_CONTAINER) >/dev/null 2>&1 || true; \
done
@echo "Migration gate passed on every pinned major."
## coverage: Generate coverage report
coverage: test
@echo "Generating coverage report..."
$(GO) tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
## lint: Run patch-scoped linter (matches CI's only-new-issues=true exactly)
##
## CI's golangci-lint-action runs with only-new-issues=true on every PR,
## reporting findings only on lines changed in the PR. This target
## mirrors that scope so local fails when CI would fail.
##
## CRITICAL: golangci-lint's --new-from-rev flag only sees COMMITTED
## changes, so before any commits the patch is empty and lint
## early-exits as a no-op — letting bad code reach the commit gate.
## This target generates a unified-diff patch from the merge-base
## that includes BOTH committed changes AND working-tree changes
## (staged + unstaged), then passes it via --new-from-patch. The
## patch-based path catches the same issues CI would, AND issues in
## uncommitted code, so `make verify` is a true pre-commit gate.
##
## Merge-base resolution: prefer origin/main (matches CI's PR base
## ref). Falls back to local main only if origin/main is not
## reachable (detached HEAD, fresh clone before fetch). If neither
## is reachable the patch lint warns and skips rather than silently
## passing.
##
## Use `make lint-full` to scan the entire codebase (housekeeping;
## not part of `make verify`).
lint:
@echo "Running patch-scoped lint (matches CI only-new-issues, includes uncommitted changes)..."
@# The cache is cleaned before the run: golangci-lint can serve a
@# previously FILTERED result for an unchanged package, so a warm cache
@# reports 0 issues on lines CI (which always runs cold) rejects. That
@# cost us a red Lint job on PR 1303 after a green `make verify`, which
@# is the same class of failure as #393 — a local gate that passes on
@# something CI does not.
@#
@# Auto-fetch so a fresh clone or a stale local mirror doesn't bypass
@# the gate. The fetch is shallow + quiet and tolerates network
@# absence; if BOTH origin/main and main remain unreachable, we
@# HARD FAIL rather than silently skip — silent skipping is exactly
@# how a clean local make verify let lint issues reach CI in #393.
@git fetch --quiet origin main 2>/dev/null || true
@if git rev-parse origin/main >/dev/null 2>&1; then \
BASE=origin/main; \
elif git rev-parse main >/dev/null 2>&1; then \
BASE=main; \
else \
echo "ERROR: neither origin/main nor main is reachable."; \
echo " Run \`git fetch origin main\` and retry."; \
echo " (lint MUST run against a base; silent-skip is a CI-parity hole.)"; \
exit 1; \
fi; \
MERGE_BASE=$$(git merge-base $$BASE HEAD 2>/dev/null); \
if [ -z "$$MERGE_BASE" ]; then \
echo "ERROR: could not compute merge-base against $$BASE."; \
echo " Ensure the current branch shares history with $$BASE."; \
exit 1; \
fi; \
PATCH=$$(mktemp -t mcpdp-lint-patch.XXXXXX); \
trap "rm -f $$PATCH" EXIT; \
git diff $$MERGE_BASE > $$PATCH; \
if [ ! -s $$PATCH ]; then \
echo "No changes vs merge-base ($$BASE); nothing to lint."; \
echo " (If you expected changes, confirm \`git log $$BASE..HEAD\` is non-empty.)"; \
exit 0; \
fi; \
echo "Linting against merge-base $$MERGE_BASE (from $$BASE) — includes uncommitted changes"; \
$(GOLINT) cache clean; \
$(GOLINT) run --new-from-patch=$$PATCH ./...
## lint-full: Run linter against the ENTIRE codebase (not chained into verify)
##
## CI does not enforce findings on pre-existing code, so neither does
## `make verify`. This target exists for housekeeping passes.
lint-full:
@echo "Running full-codebase linter (informational; not enforced by CI)..."
$(GOLINT) run ./...
## lint-fix: Run linter with auto-fix
lint-fix:
@echo "Running linter with auto-fix..."
$(GOLINT) run --fix ./...
## vet-tags: Compile-check the build tags no default build sees
#
# A file behind `//go:build integration` is compiled by nothing cheap. Every
# other consumer of the tag wants a machine: test-integration wants the whole
# suite, test-realdb wants Docker and a migrated Postgres, smoke wants a
# running server, acceptance wants a dev stack and a renderer, frontend-e2e
# wants a stack. The cheap gates (lint, patch-coverage, schedule-lane, gosec,
# npm run build) all compile with the default tags, and .golangci.yml sets no
# build tags either, so none of them reads these files.
#
# The result was that a compile error in a tagged file was first reported by
# verify-docker, after the daemon had started and the migrations had replayed.
# On #1794 three tagged files referenced constants that had been removed; every
# cheap gate passed and the Docker lane failed six minutes in. This target
# answers the same question in about a second, which is why it sits in the
# serial preamble of `verify` rather than in a lane beside that Docker work.
#
# It is a compile, not a test: no container, no database, no stack.
vet-tags:
@echo "Vetting the integration build..."
@$(GOVET) -tags=integration ./...
## fmt: Format code
fmt:
@echo "Formatting code..."
$(GOFMT) -s -w .
## clean: Clean build artifacts
clean:
@echo "Cleaning..."
@rm -rf $(BUILD_DIR) $(DIST_DIR)
@rm -f coverage.out coverage.html
@rm -rf $(UI_DIR)/dist $(UI_DIR)/dist-content-viewer $(UI_DIR)/dist-content-viewer-css $(UI_DIR)/node_modules
@# Reset embed dirs but keep .gitkeep
@find $(UI_EMBED_DIR) -not -name '.gitkeep' -not -path $(UI_EMBED_DIR) -delete 2>/dev/null || true
@find $(CV_EMBED_DIR) -not -name '.gitkeep' -not -path $(CV_EMBED_DIR) -delete 2>/dev/null || true
@echo "Clean complete."
## install: Install the binary
install: build
@echo "Installing $(BINARY_NAME)..."
$(GO) install $(LDFLAGS) $(CMD_DIR)
@echo "Installed."
## mod-tidy: Tidy go modules
mod-tidy:
@echo "Tidying modules..."
$(GOMOD) tidy
## mod-download: Download modules
mod-download:
@echo "Downloading modules..."
$(GOMOD) download
## mod-verify: Verify modules
mod-verify:
@echo "Verifying modules..."
$(GOMOD) verify
## security: Run security checks (gosec + govulncheck + osv-scanner)
security:
@echo "Running gosec..."
@# GOOS=linux reads the code with the types CI's Linux runner sees: a
@# field typed differently by platform (syscall.Statfs_t.Bsize is int64 on
@# Linux, uint32 on darwin) is otherwise judged locally on darwin's.
@# Not -quiet: with it, gosec exits 0 when it cannot load a package, so a
@# scan that analyzed nothing passes. CI runs it the same way.
GOOS=linux gosec ./...
@echo "Running govulncheck..."
@# govulncheck exits 3 when our code calls a vulnerable symbol, whether or
@# not a fixed version exists, and has no way to accept a finding. The gate
@# judges the report against .govulncheck-allow.txt, where an accepted
@# advisory carries the reason it is accepted and expires the moment a fix
@# ships or the advisory stops being reported. CI runs the same two
@# commands, so local and CI cannot disagree.
@govulncheck -format json ./... > $(GOVULN_REPORT) 2>/dev/null; \
status=$$?; \
if [ $$status -ne 0 ] && [ $$status -ne 3 ]; then \
echo "ERROR: govulncheck failed to run (exit $$status)"; \
rm -f $(GOVULN_REPORT); \
exit 1; \
fi
@python3 scripts/govulncheck-gate.py $(GOVULN_REPORT); \
status=$$?; \
rm -f $(GOVULN_REPORT); \
exit $$status
@$(MAKE) --no-print-directory osv
## osv: Fail on any known advisory against a manifest the platform ships from
## osv-scanner judges go.mod, the UI's npm lockfile and the docs site's
## requirements against the GitHub advisory database. Its Go call analysis
## drops an advisory whose symbols the module never calls; an npm or pip
## advisory counts whatever the code uses. An accepted advisory is listed in osv-scanner.toml with its
## reason and an ignoreUntil date; past that date the entry stops applying and
## the advisory fails the gate again, and an entry that no longer matches any
## finding fails it too, so the file cannot hold a stale acceptance. CI runs
## this target. bench/ and test/load/ are separate modules the release never
## builds and are not scanned.
OSV_MANIFESTS := -L go.mod -L $(UI_DIR)/package-lock.json -L docs/requirements.txt
OSV_LOG := build/osv-scanner.log
osv:
@echo "Running osv-scanner..."
@mkdir -p $(dir $(OSV_LOG))
@osv-scanner scan source --config osv-scanner.toml $(OSV_MANIFESTS) > $(OSV_LOG) 2>&1; \
status=$$?; \
cat $(OSV_LOG); \
if grep -q 'has unused ignores' $(OSV_LOG); then \
echo "ERROR: osv-scanner.toml accepts an advisory no longer reported; delete that entry."; \
status=1; \
fi; \
rm -f $(OSV_LOG); \
exit $$status
## semgrep: Run Semgrep SAST with standard and custom rules
semgrep:
@echo "Running Semgrep..."
semgrep scan --config p/golang --config .semgrep/ --error --quiet .
## semgrep-diff: Run the diff-scoped Semgrep rules against changed lines
##
## .semgrep/ holds rules that are defects wherever they sit, so `semgrep` runs
## them over the whole tree. .semgrep-diff/ holds rules that refuse a SHAPE a
## diff-scoped CI check rejects with reasoning Semgrep cannot reproduce: the
## allocation-size rule matches 55 sites here and CodeQL flags none of them,
## because CodeQL knows which operands can be large and Semgrep does not.
##
## Scoping those to the diff is what CI already does with them -- Code Scanning
## reports against a pull request's changes -- and what `make lint` does with
## golangci-lint's --new-from-patch, off the same merge-base. Semgrep's own
## --baseline-commit aborts on an unstaged change, and `make verify` is a
## pre-commit gate run on exactly that, so the scoping lives in the script.
## Every rule file in the directory runs; test/gates/semgrep_diff_test.go holds
## each rule to the shape it was written for.
semgrep-diff:
@echo "Running diff-scoped Semgrep..."
@python3 scripts/semgrep-diff.py .semgrep-diff/
## semgrep-changed: Run the .semgrep/ rules over the Go files this branch changed (#2145)
##
## `semgrep` holds the whole tree to .semgrep/ in verify-static, one of the
## parallel lanes, so a finding there used to fail verify after the Go, Docker
## and UI lanes had run. The tree passes them on main, so a finding in a file
## the branch touched is the branch's: this reports it whole-file, in seconds,
## in preverify-fast.
semgrep-changed:
@echo "Running .semgrep/ rules on changed files..."
@python3 scripts/semgrep-diff.py --whole-files .semgrep/
## codeql: Run CodeQL analysis (requires codeql CLI)
codeql:
@echo "Running CodeQL analysis..."
@rm -rf /tmp/mcp-dp-codeql-db
codeql database create /tmp/mcp-dp-codeql-db --language=go --source-root=. --overwrite
@codeql database analyze /tmp/mcp-dp-codeql-db \
--format=sarif-latest --output=codeql-results.sarif \
codeql/go-queries:codeql-suites/go-security-and-quality.qls
@# Gate logic lives in scripts/codeql-gate.py — it counts results
@# with sarif level=error OR security-severity >= 7.0. The
@# security-severity check matches what GitHub Code Scanning
@# treats as a blocking alert in CI: without it, low-confidence
@# taint findings (go/request-forgery, go/sql-injection,
@# go/log-injection) surface as `level=note` locally but block
@# the CodeQL step in CI. Local CI parity is the whole point of
@# `make verify`.
@python3 scripts/codeql-gate.py codeql-results.sarif
## sast: Run all SAST scanners (semgrep + codeql)
sast: semgrep codeql
## docker-build: Build Docker image
docker-build:
@echo "Building Docker image..."
docker build -t txn2/mcp-data-platform:$(VERSION) .
docker tag txn2/mcp-data-platform:$(VERSION) txn2/mcp-data-platform:latest
## run: Run the server
run: build
@echo "Running $(BINARY_NAME)..."
$(BUILD_DIR)/$(BINARY_NAME)
## version: Show version
version:
@echo "Version: $(VERSION)"
@echo "Go Version: $(GO_VERSION)"
@echo "Build Time: $(BUILD_TIME)"
## dead-code: Report unreachable functions (informational, not blocking)
dead-code:
@echo "Checking for dead code..."
@OUTPUT=$$(deadcode ./... 2>&1 | grep -v "^$$") || true; \
if [ -n "$$OUTPUT" ]; then \
echo "Dead code detected (review for false positives):"; \
echo "$$OUTPUT"; \
else \
echo "No dead code found."; \
fi
## mutate: Run mutation testing with 60% efficacy threshold
mutate:
@echo "Running mutation testing..."
gremlins unleash --workers 1 --timeout-coefficient 3 --threshold-efficacy 60 ./pkg/...
## coverage-report: Print coverage summary (fails below COVERAGE_MIN)
coverage-report: test
@$(MAKE) --no-print-directory coverage-summary
## coverage-summary: Print the summary of the coverage.out already written (fails below COVERAGE_MIN)
## coverage-report runs the tests first. verify-go runs `test` once and then
## this, so a verify runs the race unit suite once rather than once per reader
## of its profile (#2053).
coverage-summary:
@echo ""
@echo "=== Coverage Summary ==="
@$(GO) tool cover -func=coverage.out | tail -1
@echo ""
@TOTAL=$$($(GO) tool cover -func=coverage.out | tail -1 | awk '{gsub(/%/,"",$$3); print $$3}'); \
if [ "$$(echo "$$TOTAL < $(COVERAGE_MIN).0" | bc -l)" = "1" ]; then \
echo "FAIL: Total coverage $$TOTAL% is below $(COVERAGE_MIN)% threshold"; \
exit 1; \
fi
@echo "Functions with 0% coverage:"
@$(GO) tool cover -func=coverage.out | awk '{gsub(/%/,"",$$3); if ($$3+0 == 0 && $$1 != "total:") print $$0}' || true
@echo ""
@echo "Functions below $(PATCH_COVERAGE_MIN)% coverage:"
@$(GO) tool cover -func=coverage.out | awk '{gsub(/%/,"",$$3); if ($$3+0 < $(PATCH_COVERAGE_MIN).0 && $$3+0 > 0 && $$1 != "total:") print $$0}' || true
@echo "=== End Coverage ==="
## patch-coverage: Check coverage of changed lines vs main (fails below PATCH_COVERAGE_MIN)
patch-coverage: test
@$(MAKE) --no-print-directory patch-coverage-check
## patch-coverage-check: Check the changed lines against the coverage.out already written
## The half of patch-coverage verify-go runs after its one `test` (#2053).
patch-coverage-check:
@echo "Checking patch coverage..."
@PATCH_COVERAGE_THRESHOLD=$(PATCH_COVERAGE_MIN) ./scripts/patch-coverage.sh
## docs-images-check: Resolve every image the docs pin to a digest against its registry (network and Docker; run by the E2E Nightly)
docs-images-check:
@./scripts/docs-image-digests.sh docs
## doc-check: Fail on orphaned docs, unresolved doc links, or unregistered tool refs; warn on undocumented changes
doc-check:
@./scripts/doc-check.sh
## acceptance-check: Warn when production Go changed with no test/acceptance file beside it
acceptance-check:
@./scripts/acceptance-check.sh
## alert-rules-test: Run the promtool unit tests over deployments/observability/alert-rules.yaml (#1892)
## The rules ship as a ConfigMap, so the script extracts the groups first and
## runs promtool from the Prometheus image dev/docker-compose.yml pins (or a
## local promtool). Not in verify: it needs Docker or promtool.
alert-rules-test:
@./scripts/alert-rules-test.sh
## state-readers-check: Warn when an API contract field or swagger.json changed and nothing under ui/src did (#1709)
state-readers-check:
@python3 scripts/state-readers-check.py
## builtin-pages-check: Warn when a diff changes source a built-in knowledge page describes and not the page (#2200)
## Each page's source is listed in internal/platform/knowledgebuiltin/covers.txt.
## Every deployment serves the pages on its next start, and nothing compiles
## their prose. A warning for a human, blocking for an AI agent.
builtin-pages-check:
@python3 scripts/builtin-pages-check.py
## e2e-copy-check: Fail when an e2e spec asserts UI copy the diff removed from ui/src (#1977)
## The Playwright suite runs only inside verify, after the full unit run, so a
## string a diff changed under ui/src and a spec still asserts used to fail
## verify fifteen minutes in. This reads both in under a second.
e2e-copy-check:
@python3 scripts/e2e-copy-check.py
## docs-build: Build the documentation site with mkdocs --strict (#2138)
## The deploy workflow builds the site only after a merge to main, so a broken
## nav entry used to stop the site deploying with every gate green. This builds
## it the same way, in a virtualenv installed from docs/requirements.txt with
## --require-hashes (the deploy workflow's install), rebuilt when that file
## changes, so local and CI build with the same mkdocs and theme.
DOCS_VENV := build/docs-venv
DOCS_STAMP := $(DOCS_VENV)/.requirements.sha256
docs-build:
@want=$$(shasum -a 256 docs/requirements.txt | cut -d' ' -f1); \
if [ ! -x $(DOCS_VENV)/bin/mkdocs ] || [ "$$(cat $(DOCS_STAMP) 2>/dev/null)" != "$$want" ]; then \
echo "Installing docs/requirements.txt into $(DOCS_VENV)..."; \
rm -rf $(DOCS_VENV) && python3 -m venv $(DOCS_VENV) && \
$(DOCS_VENV)/bin/pip install --quiet --require-hashes -r docs/requirements.txt && \
echo "$$want" > $(DOCS_STAMP) || exit 1; \
fi
@$(DOCS_VENV)/bin/mkdocs build --strict --site-dir build/docs-site
## route-manifest-check: Fail when a portal route has no screenshot manifest entry (#2055)
## route-sync.test.ts reads AppShell's pageTitles and the screenshot route
## manifest and finishes in milliseconds, but the full vitest suite inside
## verify's UI lane was the only thing running it, so a new portal section
## missing its entry failed verify minutes in. This runs that one file.
route-manifest-check:
cd $(UI_DIR) && npx vitest run e2e/screenshots/route-sync.test.ts
## route-conformance-check: Fail when a mocked UI route is absent from swagger.json (#2186)
## route-conformance.test.ts reads the MSW handlers and internal/apidocs/swagger.json
## in under a second; like route-sync.test.ts, only the full vitest suite in verify's
## UI lane ran it, so a mock for a route the API does not describe failed verify.
route-conformance-check:
cd $(UI_DIR) && npx vitest run src/mocks/route-conformance.test.ts
## schedule-lane: Run the tests the diff touched at -race -cpu=1,2 -count=5 (ordering-dependent tests, #1711, #2039)
## `test` runs each test once at this machine's CPU count, which is not the
## schedule a loaded CI runner chooses. TestWithRevocations_WiredLate passed
## two verify runs on 18 cores and fails 140 of 300 runs at -cpu=1. The lane
## runs the Test functions in the changed _test.go files, not their whole
## packages: ten passes of pkg/platform ran an hour on one core and then hit
## the lane's timeout in CI (#2039). Each package has ten minutes. CI runs
## this same target on every pull request.
schedule-lane:
@python3 scripts/schedule-lane.py go
## schedule-lane-ui: Run the vitest suite with every changed *.test.ts(x) file run five times beside it (#1711)
## An assertion on a count recorded in a useEffect passed on an idle machine
## and failed CI's Frontend Build job; the repeats run while the suite loads
## the machine. It runs the full suite, so verify-ui calls it in place of
## frontend-test.
schedule-lane-ui:
@python3 scripts/schedule-lane.py ui
## acceptance: Run the per-ticket acceptance suite as a real MCP client against a running server (make dev)
## Each test/acceptance/issue_<n>_test.go executes one ticket's acceptance
## criteria through the tool surface a user calls. It fails, rather than
## skips, when no server answers. Override the target with MCP_BASE_URL and
## MCP_API_KEY (defaults: the proxy in front of the dev stack's two replicas on
## DEV_PROXY_PORT, or the single dev server on DEV_API_PORT under
## DEV_REPLICAS=1, and the dev API key; #1708).
## ACCEPTANCE_TIMEOUT bounds the whole binary, not one test. Go's default is
## 10 minutes and the suite runs for most of that against a healthy stack --
## several criteria wait past a cadence that fires every minute, and several
## wait on a background index -- so the default kills the run
## partway through and reports the tests that were still on the clock as
## failures. A run that was cut short is not a suite that failed (#1632).
## The whole suite runs for well over two hours (#2218): #2111 alone writes
## four 1,500-file backfills at the tool-call limiter's pace of 4 a second.
ifeq ($(ISSUE),)
ACCEPTANCE_TIMEOUT ?= 240m
else
ACCEPTANCE_TIMEOUT ?= 40m
endif
## ISSUE=<n> runs one ticket's criteria (-run TestIssue<n>_) instead of the
## whole suite. Either way the run is recorded as a go test -json stream at
## build/<n>/acceptance.jsonl per ticket, which is the evidence make
## acceptance-check and make verify-release read: a criterion that did not run
## leaves no pass event, and no prose can supply one (#1680).
## Criteria that need a stack `make dev` does not make are named in
## test/acceptance/environments.txt (#2219): the whole suite leaves them out
## and says which, and ACCEPTANCE_ENV=<name> runs one environment's criteria
## instead (scripts/release-acceptance.sh starts the stack it needs).
## ACCEPTANCE_SHARD=i/N runs group i of N of the plain stack's criteria, split
## by ticket number, which is how the E2E Nightly runs the suite in N jobs at
## once (#2218).
# The recipe sets pipefail so a failed `go test` is not hidden by the evidence
# splitter it pipes into. Make runs recipes with /bin/sh, which is bash on
# macOS and dash on the Ubuntu runners, and dash has no pipefail, so every
# nightly group refused to start the suite (#2266).
acceptance: SHELL := /bin/bash
acceptance:
@# dev/start.sh relocates the stack when the default ports are busy and
@# records where it went; the suite follows it unless MCP_BASE_URL is set.
@set -a; [ -f dev/.dev-ports.env ] && . ./dev/.dev-ports.env; set +a; \
echo "Running acceptance suite against $${MCP_BASE_URL:-http://localhost:$${DEV_PROXY_PORT:-$${DEV_API_PORT:-8080}}}..."; \
if [ -n "$(ISSUE)" ]; then run_flag="-run TestIssue$(ISSUE)_"; echo " limited to #$(ISSUE)"; \
elif [ -n "$(ACCEPTANCE_SHARD)" ]; then run_flag=$$(python3 scripts/acceptance-env.py flags "$(ACCEPTANCE_ENV)" --shard "$(ACCEPTANCE_SHARD)") || exit 1; echo " group $(ACCEPTANCE_SHARD)"; \
else run_flag=$$(python3 scripts/acceptance-env.py flags "$(ACCEPTANCE_ENV)") || exit 1; fi; \
set -o pipefail; \
eval "$(GOTEST) -count=1 -timeout $(ACCEPTANCE_TIMEOUT) -tags=integration ./test/acceptance/ -v -json $$run_flag" \
| python3 scripts/acceptance-evidence.py split
## dev-smoke: One call through the platform per service the dev stack runs, in under a minute (#2248)
## A managed resource read back by digest (the TLS store), a portal asset read
## back (the plain store), a query on each scratch catalog, a Keycloak sign-in,
## a search hit on a just-embedded asset (Ollama) and a drawn tile (renderer):
## test/acceptance/devsmoke_test.go. With no stack answering it fails and says so.
dev-smoke:
@set -a; [ -f dev/.dev-ports.env ] && . ./dev/.dev-ports.env; set +a; \
base="$${MCP_BASE_URL:-http://localhost:$${DEV_PROXY_PORT:-$${DEV_API_PORT:-8080}}}"; \
if ! curl -sf -o /dev/null --max-time 5 "$$base/healthz"; then \
echo "dev-smoke: no dev stack answers at $$base; start one with make dev" >&2; exit 1; fi; \
echo "dev-smoke: checking the dev stack at $$base"; \
$(GOTEST) -count=1 -timeout 2m -tags=integration ./test/acceptance/ -run '^TestDevSmoke_'
## dev-smoke-diff: Run dev-smoke when the diff against main changes a file under dev/ (#2248)
DEV_DIR := dev
dev-smoke-diff:
@base=$$(git merge-base origin/main HEAD 2>/dev/null || git merge-base main HEAD); \
changed=$$( { git diff --name-only $$base -- $(DEV_DIR); git ls-files --others --exclude-standard -- $(DEV_DIR); } | sort -u); \
if [ -z "$$changed" ]; then echo "dev-smoke-diff: nothing under dev/ changed; skipped"; exit 0; fi; \
echo "dev-smoke-diff: $$(echo $$changed | wc -w | tr -d ' ') file(s) under dev/ changed"; \
$(MAKE) --no-print-directory dev-smoke
## acceptance-release: The whole acceptance suite against the dev stack, whether or not one is up (#1969)
## The E2E Nightly runs it (in groups, ACCEPTANCE_SHARD) and a tag requires
## that nightly green; verify-release does not run it, because the suite runs
## for well over an hour and a release waits on nothing that long (#2218). A
## stack already answering is used as it is and left running; with none, one
## is started, the suite runs, and the stack it started is stopped (data kept).
acceptance-release:
@bash scripts/release-acceptance.sh
## acceptance-release-check: Refuse a release whose tickets have no passing acceptance run
## Every test/acceptance/issue_<n>_test.go changed since the last tag must have
## a recorded run at build/<n>/acceptance.jsonl in which every criterion passed.
acceptance-release-check:
@python3 scripts/acceptance-evidence.py release
## posture-check: Fail when README/llms.txt engineering-posture claims go stale
posture-check:
@./scripts/posture-check.sh
## release-tag-check: Check a release tag against the release-candidate and E2E Nightly rules before pushing it (TAG=v1.139.0)
release-tag-check:
@test -n "$(TAG)" || { echo "usage: make release-tag-check TAG=vX.Y.Z or TAG=vX.Y.Z-rcN"; exit 1; }
@python3 scripts/release-tag.py check "$(TAG)"
## release-check: Validate build, Docker, and release config
release-check:
@echo "Running GoReleaser dry-run..."
goreleaser release --snapshot --clean --skip=publish,sign,sbom
## swagger: Generate OpenAPI/Swagger documentation from annotations
swagger:
@echo "Generating Swagger docs..."
@rm -f internal/apidocs/docs.go internal/apidocs/swagger.json internal/apidocs/swagger.yaml
swag init --generalInfo pkg/admin/handler.go --dir . --output internal/apidocs --parseDependency
@echo "Injecting tag descriptions and x-tagGroups..."
@python3 scripts/swagger-tag-groups.py internal/apidocs
@echo "Swagger docs generated in internal/apidocs/"
## swagger-check: Verify Swagger docs are up to date
swagger-check: swagger
@if git diff --quiet internal/apidocs/; then \
echo "Swagger docs are up to date"; \
else \
echo "ERROR: Swagger docs are out of date. Run 'make swagger' and commit."; \
exit 1; \
fi
## apidocs-openapi3-check: Convert internal/apidocs/swagger.json to OpenAPI 3, as the UI's type generation does
## A document swag writes can still be one swagger2openapi refuses (an optional
## path parameter, #2022), and ui/src/api/generated is gitignored, so nothing
## else converts it. This runs the conversion `npm run generate-api-types` runs.
apidocs-openapi3-check:
@out=$$(mktemp -t openapi3.XXXXXX) && \
(cd $(UI_DIR) && npx --no-install swagger2openapi ../internal/apidocs/swagger.json -o "$$out") ; \
rc=$$? ; rm -f "$$out" ; \
if [ $$rc -ne 0 ]; then echo "ERROR: internal/apidocs/swagger.json does not convert to OpenAPI 3 (npm run generate-api-types would fail)."; exit 1; fi ; \
echo "internal/apidocs/swagger.json converts to OpenAPI 3"
## tools-check: Verify all required tools are installed AND pinned to CI versions
##
## Local-vs-CI tool version drift is the most insidious parity gap: different
## golangci-lint or gosec versions enable different rules with different
## defaults, so `make verify` can pass locally while CI rejects the same
## diff. Concrete incident on 2026-05-08: local gosec 2.26.1 silently dropped
## the G704 SSRF taint rule that CI's pinned v2.22.0 enforces, letting an
## actual SSRF bug ship to PR #377. See feedback_gate_metric.md.
##
## A matching version is not enough: CI installs with GOTOOLCHAIN=local, so a
## pin whose module needs a newer Go than CI sets up fails CI's install while a
## local GOTOOLCHAIN=auto install builds it. scripts/tools-go-check.sh refuses a
## pinned tool built with a newer Go than ci.yml's go-version (#2147), and every
## install command printed here sets GOTOOLCHAIN=local, as CI does.
tools-check:
@echo "Checking required tools (presence AND pinned versions)..."
@missing=""; mismatch=""; \
if ! which golangci-lint > /dev/null 2>&1; then \
missing="$$missing golangci-lint: GOTOOLCHAIN=local go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)\n"; \
else \
v=$$(go version -m $$(which golangci-lint) 2>/dev/null | awk '$$1=="mod" && $$2 ~ /golangci-lint/ {print $$3}'); \
if [ -z "$$v" ] || [ "$$v" = "(devel)" ]; then \
v=$$(golangci-lint version 2>&1 | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
case "$$v" in v*) ;; *) v="v$$v";; esac; \
fi; \
if [ "$$v" != "$(GOLANGCI_LINT_VERSION)" ]; then \
mismatch="$$mismatch golangci-lint: have $$v, want $(GOLANGCI_LINT_VERSION) — GOTOOLCHAIN=local go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)\n"; \
fi; \
fi; \
if ! which gosec > /dev/null 2>&1; then \
missing="$$missing gosec: GOTOOLCHAIN=local go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION)\n"; \
else \
v=$$(go version -m $$(which gosec) 2>/dev/null | awk '$$1=="mod" && $$2 ~ /gosec/ {print $$3}'); \
if [ -z "$$v" ] || [ "$$v" = "(devel)" ]; then \
v=$$(gosec --version 2>&1 | grep -oE 'Version: v?[0-9]+\.[0-9]+\.[0-9]+' | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
case "$$v" in v*) ;; *) v="v$$v";; esac; \
fi; \
if [ "$$v" != "$(GOSEC_VERSION)" ]; then \
mismatch="$$mismatch gosec: have $$v, want $(GOSEC_VERSION) — GOTOOLCHAIN=local go install github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION)\n"; \
fi; \
fi; \
if ! which govulncheck > /dev/null 2>&1; then \
missing="$$missing govulncheck: GOTOOLCHAIN=local go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)\n"; \
else \
v=$$(go version -m $$(which govulncheck) 2>/dev/null | awk '$$1=="mod" && $$2=="golang.org/x/vuln" {print $$3}'); \
if [ -n "$$v" ] && [ "$$v" != "$(GOVULNCHECK_VERSION)" ]; then \
mismatch="$$mismatch govulncheck: have $$v, want $(GOVULNCHECK_VERSION) — GOTOOLCHAIN=local go install golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION)\n"; \
fi; \
fi; \
if ! which osv-scanner > /dev/null 2>&1; then \
missing="$$missing osv-scanner: GOTOOLCHAIN=local go install github.com/google/osv-scanner/v2/cmd/osv-scanner@$(OSV_SCANNER_VERSION)\n"; \
else \
v=$$(osv-scanner --version 2>&1 | grep -oE 'version: v?[0-9]+\.[0-9]+\.[0-9]+' | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
if [ "v$$v" != "$(OSV_SCANNER_VERSION)" ]; then \
mismatch="$$mismatch osv-scanner: have v$$v at $$(which osv-scanner), want $(OSV_SCANNER_VERSION) — GOTOOLCHAIN=local go install github.com/google/osv-scanner/v2/cmd/osv-scanner@$(OSV_SCANNER_VERSION), ahead of any other copy on PATH\n"; \
fi; \
fi; \
if ! newer=$$(sh scripts/tools-go-check.sh .github/workflows/ci.yml \
golangci-lint=github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION) \
gosec=github.com/securego/gosec/v2/cmd/gosec@$(GOSEC_VERSION) \
govulncheck=golang.org/x/vuln/cmd/govulncheck@$(GOVULNCHECK_VERSION) \
osv-scanner=github.com/google/osv-scanner/v2/cmd/osv-scanner@$(OSV_SCANNER_VERSION) \
gremlins=github.com/go-gremlins/gremlins/cmd/gremlins@$(GREMLINS_VERSION)); then \
mismatch="$$mismatch$$newer\n"; \
fi; \
which semgrep > /dev/null 2>&1 || missing="$$missing semgrep: pip3 install semgrep\n"; \
which codeql > /dev/null 2>&1 || missing="$$missing codeql: brew install codeql\n"; \
which deadcode > /dev/null 2>&1 || missing="$$missing deadcode: GOTOOLCHAIN=local go install golang.org/x/tools/cmd/deadcode@latest\n"; \
if ! which gremlins > /dev/null 2>&1; then \
missing="$$missing gremlins: GOTOOLCHAIN=local go install github.com/go-gremlins/gremlins/cmd/gremlins@$(GREMLINS_VERSION)\n"; \
else \
v=$$(go version -m $$(which gremlins) 2>/dev/null | awk '$$1=="mod" && $$2 ~ /gremlins/ {print $$3}'); \
if [ -z "$$v" ] || [ "$$v" = "(devel)" ]; then \
v=$$(gremlins --version 2>&1 | grep -oE 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1); \
case "$$v" in v*) ;; *) v="v$$v";; esac; \
fi; \
if [ "$$v" != "$(GREMLINS_VERSION)" ]; then \
mismatch="$$mismatch gremlins: have $$v, want $(GREMLINS_VERSION) — GOTOOLCHAIN=local go install github.com/go-gremlins/gremlins/cmd/gremlins@$(GREMLINS_VERSION)\n"; \
fi; \
fi; \
which goreleaser > /dev/null 2>&1 || missing="$$missing goreleaser: brew install goreleaser\n"; \
node_want=$$(tr -d ' \n' < $(UI_DIR)/.nvmrc); \
if ! which node > /dev/null 2>&1; then \
missing="$$missing node: nvm install $$node_want (the major in $(UI_DIR)/.nvmrc, which CI installs)\n"; \
else \
node_have=$$(node --version | sed -E 's/^v([0-9]+).*/\1/'); \
if [ "$$node_have" != "$$node_want" ]; then \
mismatch="$$mismatch node: have major $$node_have, want $$node_want (the major in $(UI_DIR)/.nvmrc, which CI installs) — nvm install $$node_want && nvm use $$node_want\n"; \
fi; \
fi; \
which swag > /dev/null 2>&1 || missing="$$missing swag: GOTOOLCHAIN=local go install github.com/swaggo/swag/cmd/swag@latest\n"; \
if [ -n "$$missing" ]; then \
echo ""; \
echo "FAIL: Missing required tools:"; \
printf '%b' "$$missing"; \
echo ""; \
echo "Install all missing tools before running make verify."; \
exit 1; \
fi; \
if [ -n "$$mismatch" ]; then \
echo ""; \
echo "FAIL: Tool version mismatch (local differs from CI-pinned)."; \
echo "Local versions that drift from CI's create silent parity gaps:"; \
echo "make verify can pass while CI rejects the same diff."; \
echo ""; \
printf '%b' "$$mismatch"; \
echo ""; \
echo "Pin local tools to the CI versions before running make verify."; \
echo "(Override with TOOLS_CHECK_STRICT=0 only if you know what you are doing.)"; \
if [ "$(TOOLS_CHECK_STRICT)" != "0" ]; then exit 1; fi; \
echo "WARN: proceeding with mismatched tool versions (TOOLS_CHECK_STRICT=0)."; \
else \
echo "All required tools found at pinned CI versions."; \
fi
## embed-clean: Reset UI embed dirs to .gitkeep only (matches CI clean checkout)
embed-clean:
@echo "Cleaning UI embed directories..."
@find $(UI_EMBED_DIR) -not -name '.gitkeep' -not -path $(UI_EMBED_DIR) -delete 2>/dev/null || true
@find $(CV_EMBED_DIR) -not -name '.gitkeep' -not -path $(CV_EMBED_DIR) -delete 2>/dev/null || true
## verify-release: Full verify PLUS CodeQL and mutation testing — run only before cutting a release
## Both are expensive and must NOT run per-revision; CI runs each on the PR.
## The release's own tickets must have a passing acceptance run
## (acceptance-release-check); the whole suite is the E2E Nightly's, which the
## tag check requires green (#2218).
verify-release: verify codeql mutate acceptance-release-check
@echo ""
@echo "=== Release verification complete (incl. CodeQL, mutation testing and the release's acceptance runs) ==="
## verify: Run the CI-equivalent per-commit suite (test, lint, security, SAST, coverage, release)
## NOTE: mutation testing and CodeQL are intentionally excluded — both live in
## verify-release, and CI runs each on the pull request. Do not add `mutate` or
## `codeql` back to this per-commit target; see the comment in the recipe.
verify:
@# The steps that REWRITE the working tree run first, one at a time,
@# because everything after them reads what they produce: fmt rewrites
@# sources, embed-clean empties the UI embed directories, and
@# swagger-check, the first step of preverify-fast below, regenerates
@# internal/apidocs before any gate reads it. Each is its own $(MAKE)
@# line, so they stay ordered even when the outer make was given -j.
@$(MAKE) --no-print-directory tools-check
@$(MAKE) --no-print-directory fmt
@$(MAKE) --no-print-directory embed-clean
@# Then the tagged build, before the lanes fan out. It is here rather than
@# inside verify-static because the lanes start together under -j4: a failure
@# reported from a lane does not stop verify-docker from having already
@# brought up the daemon and replayed the migrations. One second of serial
@# wall clock buys a broken integration-tagged file being reported before
@# any of that begins (#1799).
@$(MAKE) --no-print-directory vet-tags
@# Then the diff-scoped gates that read the change rather than a coverage
@# profile (#1856): about fifteen seconds together. They used to run at the
@# END of verify-go, behind the full race+coverage unit run, so a finding
@# in one was reported thirteen minutes in and cost a second full run to
@# learn whether the rest of the lane passed.
@$(MAKE) --no-print-directory preverify-fast
@# Then lint, still serial and before any lane starts. It used to run in
@# the lint lane beside the full unit run, so a lint finding was
@# reported only after every lane had been started, and the run it cost
@# was the whole of verify. Two minutes here answers it first.
@$(MAKE) --no-print-directory lint
@# CodeQL is deliberately NOT here. It cannot join the concurrent phase:
@# its Go extractor uses autobuild, which finds this Makefile and runs the
@# default goal, so the step quietly regenerates swagger, runs the whole
@# test suite and lints a second time. That is harmless alone and
@# destructive beside anything else — it deleted internal/apidocs/swagger.json
@# while another group was compiling. Narrowing the extractor with
@# --command would shrink what is analysed and split local from CI.
@#
@# So it could only ever run alone, ahead of everything, and measured on
@# this repo that is ~4 minutes of serial wall clock added to every single
@# commit — the largest cost in the target by a wide margin, and more than
@# the whole concurrent phase.
@#
@# It buys nothing that is not already bought. .github/workflows/codeql.yml
@# runs the same security-and-quality suite on every pull_request to main
@# and blocks the merge, so a finding is caught before anything ships; the
@# local copy only changes whether you learn about it now or at PR time.
@# This is the same trade already made for mutation testing, and it lives
@# in the same place: verify-release, plus `make codeql` on demand.
@$(MAKE) --no-print-directory -j4 verify-checks
@echo ""
@echo "=== All checks passed ==="
@# Write the gate sentinel: the id of a tree built from the working copy at
@# the moment verify completed. The pre-commit review gate
@# (~/.claude/hooks/review-gate.sh) recomputes it at commit time — if they
@# match, this verify run is proof CI-equivalent checks passed on the exact
@# code being committed.
@#
@# It is a tree id rather than a hash of `git diff`, and that is not a style
@# choice: a diff does not mention an untracked file, so a brand-new source
@# file written after a green run left the old hash unchanged and was waved
@# through unverified, while `git add`-ing that same file changed the hash
@# and rejected a tree verify had passed on. A tree built with `git add -A`
@# sees the file either way.
@#
@# Hash computation MUST stay byte-identical to compute_diff_hash() in
@# review-gate.sh. When the two drift, the sentinel becomes a value the two
@# readers disagree about and the gate rejects EVERY commit.
@mkdir -p .claude
@dir=$$(mktemp -d); \
git read-tree --index-output=$$dir/index HEAD 2>/dev/null; \
GIT_INDEX_FILE=$$dir/index git add -A 2>/dev/null; \
GIT_INDEX_FILE=$$dir/index git write-tree 2>/dev/null \
| cut -c1-16 > .claude/.last-verify-passed; \
rm -rf $$dir
@echo "Wrote .claude/.last-verify-passed (gate sentinel)"
## verify-checks: the read-only half of `verify`, run concurrently by it.
##
## Each lane prints a "[lane start/done]" marker. The wall clock of `verify` is
## the slowest lane, so those two lines are what tell you which lane to attack
## when the cycle gets slow — without them the lanes interleave into one