SOURCE WALKTHROUGH / D2 DIAGRAMS

一份 JSON,兩道決策。

lambda-impact.json 如何由 CLI 產生、怎麼決定 mode,以及為什麼 fallbackReason 不是 constant。

bitbucket.org/letsgo-network/go-modules/lambdabuild/cmd/lets-lambda-build@v1.0.0
TAG lambdabuild/v1.0.0COMMIT b1c5774D2 0.7.1 → SVG × 2PUBLIC / 原始碼導讀
01 / THE ARTIFACT

它是分析結果,不是設定檔。

在業務倉庫執行 lets-lambda-build impact,工具即時掃描 Lambda、分析 Git 差異與 Go 依賴,最後把 model.ImpactResult 寫成 JSON。安裝工具、AWS、CSDA 或 build 都不會自動產生這份分析。

# 在業務 repo 執行;已安裝 @v1.0.0,且準備好 Git / Go / vendor
set -e
lets-lambda-build impact --repo-root .
# 預設寫入 ./build/lambda-impact.json
lets-lambda-build build --repo-root . \
  --impact-file build/lambda-impact.json \
  --version "${BITBUCKET_TAG:-${BITBUCKET_COMMIT:-local-test}}"
先回答 constant 問題:fallbackReason 是 string,某些分支用固定文字,某些用 fmt.Sprintf(...) 插入檔名、ref 或錯誤。沒有 fallback code enum,也不能當穩定的程式判斷介面。
mode 在此版使用三個固定字串,但 Go 欄位本身也是 string;baseStatus 才有命名的 Go 型別與 constants。
all

不縮小範圍

建置所有目前發現的 Lambda。Compute 產生的 all 結果帶有 fallbackReason。

partial

依清單選取

輸出 lambdas + reasons;省略 fallbackReason。即使清單包含全部,也仍叫 partial。

none

沒有選中 Lambda

省略 fallbackReason。通過 build 的基準驗證後,不編譯但仍清除舊 zip。

02 / CLI TRIGGER FLOW

誰寫、誰讀?是兩次 CLI 呼叫。

實線表示呼叫或檔案操作,虛線表示回傳。圖示為成功主路徑;掃描、清檔、讀寫檔等錯誤也會直接終止。impact 與 build 不會在程式內自動串接。

CLI 觸發流程 · 兩次 invocationD2 RENDERED SVG / 1220 × 1742 / 可縮放・可下載
100%
時序圖;從外部命令追到 JSON 寫入,再到 build 讀取與基準驗證。
時序圖;從外部命令追到 JSON 寫入,再到 build 讀取與基準驗證。
圖框內可捲動;點「全圖」看總覽,點「可讀寬度」或「+」看細節。
下載 SVG下載 D2

三個子命令,三個入口

  • inventory → newInventoryCmd():Discover 後輸出 inventory JSON;不是 impact 的必要前置命令。
  • impact → newImpactCmd():Compute 後寫 impact JSON。
  • build → newBuildCmd() → runBuild():三擇一 --all、--lambdas、--impact-file。

外部觸發的證據邊界

本工具沒有 Git hook、排程或自動部署觸發器。README 提供 CI shell 範例;本次查閱的工具倉庫沒有實際業務 pipeline 呼叫。

要確定「你手上的 JSON 是哪個 pipeline step 產生」,仍需該業務 repo 的 pipeline 設定或執行 log。--repo-root 與 --out 也可能改變位置。

舊檔陷阱:impact 開始只清除舊 build/base_version.txt,不先清除舊 impact JSON。若寫檔前失敗,磁碟上仍可能是上次的結果;務必檢查退出碼。writeJSON() 使用 os.WriteFile,不是 atomic rename。
03 / DECISION STATE MACHINE

不是跨次轉移,而是每次重新決定。

程式沒有持久化的 FSM 物件;以下把 Compute() 的控制流程畫成決策狀態機。每次執行從頭檢查,直到回傳一個最終結果;不會讀取上次的 mode。

檢查階段保守全量 all可縮小範圍真正錯誤,非 mode
mode / fallbackReason · 決策狀態機D2 RENDERED SVG / 1224 × 2571 / 可縮放・可下載
100%
依 Compute() 執行順序;同一輪 fallback 一旦成立,不會被後續選取結果推翻。
依 Compute() 執行順序;同一輪 fallback 一旦成立,不會被後續選取結果推翻。
圖框內可捲動;點「全圖」看總覽,點「可讀寬度」或「+」看細節。
下載 SVG下載 D2

「第一個原因」優先

基準、工作區、diff、依賴等前置檢查遇到 fallback 就立即回傳。逐檔分類則繼續掃描,但 fallback 只在原本為空時寫入。

已有 fallback,之後選中再多 Lambda 也不會恢復 partial。JSON 只保留第一個原因,不列出全部問題。

省略欄位 ≠ 空字串欄位

FallbackReason string `json:"fallbackReason,omitempty"`

partial / none 中它為空字串,序列化時整個欄位會省略。reasons 是逐 Lambda 的選取原因,與全量的 fallbackReason 不同。

逐檔分類的安全忽略規則與優先序
  1. 先強制全量:根目錄 go.mod、go.sum、build-lambda.sh、Makefile、bitbucket-pipelines.yml,以及 tracked vendor/**。
  2. 再看刪除 / rename 舊側的 .lambda.csda:直接要求全量,不受無關目錄規則豁免。
  3. 正向歸屬:Lambda 源碼目錄、直接 / 傳遞 Go 依賴、go:embed;找到就累加 selected 與 reasons。
  4. 無歸屬才忽略根目錄:docs / plans / openapi / localstack / stepfunctions / .devcontainer / fargate / php_fargate / saas / cmd / scripts / sidecar;此規則適用新增、修改、刪除及 rename 兩側。
  5. 一般檔案忽略:_test.go、Dockerfile*、.dockerignore 只適用還存在的一側;刪除或 rename 舊側若無其他可歸屬 / 可忽略證據,會 fall back。
  6. 其餘:unclassified change... → all。例如根目錄 README.md 不等同 docs/**,也可能觸發全量。

注意:目前即使 diff 為空,也會先做依賴與 gitignored 建置輸入檢查,通過後才可能 none。

04 / FALLBACK CATALOG

原因是文字,觸發點才是邏輯。

按程式實際順序排列。下列編號是本頁導讀用,不是工具提供的 reason code。%s / %q / %v 代表執行時插入的變動值。

觸發條件fallbackReason 文字 / 格式來源位置
01 自動 base 不可用ResolveBaseTag() 的 reason;例如 no tag exists anywhere in the repository yet 或 repository is a shallow clone: history is incomplete, cannot be trusted to resolve a baseimpact.go:149–163
resolvebase.go:77–134
02 明確 --base 無效could not resolve base %q to a commit: %vimpact.go:165–174
03 工作區查詢失敗could not check working tree status: %vimpact.go:187–198
04 工作區不乾淨working tree has uncommitted or untracked changes not covered by .gitignore, so impact cannot trust that HEAD reflects what would actually be built: %simpact.go:199–208
05 diff 失敗git diff failed: %vimpact.go:210–220
06 依賴分析失敗go list -deps failed: %vimpact.go:222–232
07 ignored paths 查詢失敗could not list gitignored paths: %vimpact.go:238–248
08 ignored 檔案是建置輸入gitignored file ...,分 Go dependency closure、.lambda.csda、Lambda 源碼目錄三類;此檢查豁免 vendor/** 與 build/**impact.go:249–250
impact.go:415–438
09 強制全量檔案 / tracked vendor%s changed (%s)impact.go:293–296
10 刪除 / rename .lambda.csda.lambda.csda removed or renamed: %simpact.go:297–299
11 無法分類unclassified change, cannot prove it is unrelated to any Lambda: %simpact.go:318
自動 base 的 firstRelease / undetermined 分界
  • firstRelease:完整歷史的本地倉庫沒有 tag,或唯一 tag 正好就在 HEAD。
  • undetermined:shallow clone、Git 查詢失敗、HEAD 有多個 tag、候選基準 commit 有多個 tag、有其他 tag 但 first-parent 歷史找不到可用 tag等。
  • found:HEAD 在 tag 上,找前一個 first-parent tag;不在 tag 上,找 first-parent 歷史最近 tag;解析為 commit SHA 並確認唯一 tag。
  • 沒有 tag 名稱 pattern 過濾,也不做網路 fetch;CI 須先準備完整歷史與 tag。@v1.0.0 是工具版本,不是業務 repo 的比較基準。
05 / A SEPARATE BUILD GATE

mode 決定範圍;baseStatus 決定能否繼續。

fallbackReason 只給人讀。build 不靠解析它決策,而是使用 mode、baseStatus,以及重新驗證的 HEAD / base / tag。

baseStatusbuild 的驗證成功後的 base_version.txt
foundHEAD 必須一致;base 仍對應唯一且相同的 tag寫入基準 tag
firstReleaseHEAD 必須一致;重新解析仍須是首次發布不產生
undetermined直接報錯,在編譯前停止不產生
all 不等於 build 一定會執行。例如 shallow clone:impact 可以成功寫出 all + undetermined 並退出 0,但 build 隨後在基準驗證時失敗。即使 all 在選取階段略過 HEAD 檢查,後面的 baseVersionFromImpact() 仍會檢查,因此過期 HEAD 的 all JSON 也不能通過完整 build。

切換案例,看 JSON 與後續結果

下列為原始碼行為的示意資料,不會執行 Go、Git 或建置。SHA 是 placeholder;範例假設 JSON 寫入成功、其他未提及條件均通過。

示意 lambda-impact.json

06 / EVOLUTION & SOURCE MAP

三種 mode 沒變,可信度條件逐步補齊。

初版 → v0.1.0:建立保守全量的安全網

已有 all / partial / none;之後加入 base 自動解析、HEAD 必須等於 checkout、dirty tree、gitignored 建置輸入、tracked vendor 強制全量與無關根目錄規則。

v0.3.0 · 4579c37:統一基準解析

不再用開發分支與 target branch 的 merge-base;改用 first-parent 歷史最近 tag,base 統一記錄 commit SHA。

v0.4.0 · d68a04e:拆出第二條決策軸

新增 baseStatus / baseTag,支援 CSDA 的 base_version.txt。先解析 base,再檢查工作區;即使 scope 降成 all,也保留有效基準。不再從 fallbackReason 文字猜基準是否可信。

v0.5.0 → v1.0.0:批次化與建置體驗優化

impact 的 go list -deps 改為每輪批次執行;三態與主要 fallback 決策分支未再改動。

對照原始碼

固定 commit:b1c5774a5f132684a495bb7c8dee35bc2f8ce03a(lambdabuild/v1.0.0)。連結需 Bitbucket 存取權;行號以該版本為準。

查看 / 下載 D2 原始碼與重現指令

兩張圖都已由本機 D2 0.7.1 編譯為 SVG,HTML 內嵌圖檔,不依賴 CDN 或瀏覽器即時 D2 渲染。

d2 --layout elk --pad 32 --no-xml-tag --salt cli cli-flow.d2 cli-flow.svg
d2 --layout elk --pad 32 --no-xml-tag --salt state impact-state.d2 impact-state.svg

CLI flow

shape: sequence_diagram
style: {fill: "#ffffff"; stroke: "#cbd5e1"; font-size: 18}
shell: "使用者 / CI shell" {style: {fill: "#e0f2fe"; stroke: "#0284c7"; font-color: "#0c4a6e"}}
cli: "CLI · Cobra / main.go" {style: {fill: "#e0f2fe"; stroke: "#0284c7"; font-color: "#0c4a6e"}}
engine: "Discover / Impact / Build" {style: {fill: "#f1f5f9"; stroke: "#94a3b8"; font-color: "#172b46"}}
disk: "業務 repo / build 目錄" {style: {fill: "#dcfce7"; stroke: "#16a34a"; font-color: "#14532d"}}
impact: "第 1 次 CLI 呼叫:產生分析 JSON" {
  shell -> cli: "lets-lambda-build impact\nmain → newRootCmd → newImpactCmd.RunE"
  cli -> disk: "1. clearBaseVersionFile()\n清除舊 build/base_version.txt"
  cli -> engine: "2. discover.Discover(repoRoot)\n掃描 *.lambda.csda,取得清單"
  cli -> engine: "3. impact.Compute(base, head)\nGit 差異 + Go 依賴 + 安全檢查"
  engine -> cli: "ImpactResult:all / partial / none\n若回傳 error,停止且不寫新分析 JSON" {style.stroke-dash: 5}
  cli -> disk: "4. writeJSON()\n預設 build/lambda-impact.json"
}
build: "第 2 次 CLI 呼叫:讀取 JSON,決定建置範圍" {
  shell -> cli: "lets-lambda-build build\n--impact-file build/lambda-impact.json"
  cli -> disk: "5. 清除舊 base_version.txt"
  cli -> engine: "6. Discover:重新掃描當前 Lambda"
  cli -> disk: "7. selectByImpactFile()\nReadFile + Unmarshal → 按 mode 選取"
  cli -> cli: "8. baseVersionFromImpact()\n所有 mode 檢查 HEAD / baseStatus / tag\n不可信 → 編譯前報錯停止"
  cli -> engine: "9. build.Build(selected)\n清舊 zip → 編譯 / 打包\nnone:不編譯,但仍清舊 zip"
  engine -> cli: "建置結果;有失敗就停止" {style.stroke-dash: 5}
  cli -> disk: "10. 全部成功才處理 base_version.txt\nfound:寫入 tag;firstRelease:不寫入"
}

Impact state

direction: down
classes: {
  step: {
    style: {fill: "#f1f5f9"; stroke: "#94a3b8"; font-color: "#172b46"; border-radius: 12; font-size: 18}
  }
  check: {
    style: {fill: "#e0f2fe"; stroke: "#0284c7"; font-color: "#0c4a6e"; border-radius: 12; font-size: 18}
  }
  all: {
    style: {fill: "#fff1d6"; stroke: "#d97706"; font-color: "#78350f"; border-radius: 12; font-size: 18}
  }
  error: {
    style: {fill: "#ffe4e6"; stroke: "#e11d48"; font-color: "#881337"; border-radius: 12; font-size: 18}
  }
  good: {
    style: {fill: "#dcfce7"; stroke: "#16a34a"; font-color: "#14532d"; border-radius: 12; font-size: 18}
  }
}
start: "impact.Compute()\n每次重新分析,不讀取上次結果" {class: check}
head: "① HEAD 可解析,且等於目前 checkout?" {class: check}
base: "② 解析比較基準\n省略 --base:ResolveBaseTag()\n明確 --base:ResolveCommit() + TagAtCommit()" {class: check}
preflight: "③ 依序通過安全檢查\nWorkingTreeStatus → git diff\n→ go list -deps → IgnoredPaths\n→ ignoredBuildInputReason" {class: check}
init: "④ 進入逐檔分類\nselected = {} / reasons = {} / fallback = 空" {class: step}
classify: "每個變更路徑,依優先序分類\n1. 強制全量檔案 / tracked vendor\n2. 被刪除或 rename 舊側的 .lambda.csda\n3. Lambda 目錄 / Go 依賴 / go:embed 歸屬\n4. 無關根目錄 / 安全檔案類型\n5. 無法分類 → fallback\nrename:舊側與新側都分類" {class: step}
latch: "收集結果,處理剩餘變更\n可歸屬 → 累加 selected / reasons\n有 fallback → 只記第一個原因\n已記錄的 fallback 不清除、不覆蓋" {class: step}
finish: "⑤ 全部分類完成\nfallback 是否非空?" {class: check}
selected: "⑥ selected 是否為空?" {class: check}
all: "mode = all\nfallbackReason = 第一個觸發原因\n不輸出 lambdas / reasons\nCompute 回傳 result, nil" {class: all}
none: "mode = none\n不輸出 fallbackReason\n不輸出 lambdas / reasons" {class: good}
partial: "mode = partial\n輸出 lambdas + reasons\n不輸出 fallbackReason\n即使選中全部,也仍是 partial" {class: good}
error: "error(不是 mode)\n非零退出,不寫新結果\n舊 JSON 可能仍留在磁碟" {class: error}
output: "CLI writeJSON()\n寫檔成功 → impact 退出 0\n寫檔失敗 → 非零退出" {class: step}
start -> head
head -> error: "否" {style.stroke: "#e11d48"}
head -> base: "是"
base -> all: "自動:firstRelease / undetermined\n或明確 base 無法解析" {style.stroke: "#d97706"}
base -> preflight: "有可用 commit\n明確 base 無唯一 tag 仍繼續\n但 baseStatus = undetermined"
preflight -> all: "查詢 / 分析失敗、dirty tree\n或 gitignored 建置輸入" {style.stroke: "#d97706"}
preflight -> init: "全部通過"
init -> classify -> latch -> finish
finish -> all: "是(fallback 優先)" {style.stroke: "#d97706"}
finish -> selected: "否"
selected -> none: "是"
selected -> partial: "否"
all -> output
none -> output
partial -> output
D2 圖表