fix(qualitygate): check the imports test files bring in

The layering graph was built from Imports and Deps only. `go list` keeps a
package's test dependencies in two other fields — TestImports for the in-package
test files, XTestImports for the external test package — and listedPackage did not
declare either, so every denied dependency reached through a _test.go file went
unreported. Ten packages were already through the gap: shortcuts/mail's tests
import internal/auth, internal/vfs and internal/vfs/localfileio, and the rule that
denies exactly those to shortcuts stayed green.

TestLayeringBuildConfigsSelectEveryFile made it worse than a plain omission. It
counts TestGoFiles and XTestGoFiles as selected, on the stated ground that "an
import edge only reaches the rules through a selected file" — so the check that
exists to prove nothing is unscanned was vouching for files the rules never read.

TestPackageLayering now walks a second graph, testDependencyView, built from those
two lists: direct imports for a Direct rule, and for a Transitive one the closure
through each test import's production deps. A package's own import path is dropped,
because `package foo_test` always imports foo and errs-leaf denies this module
wholesale — counting that would fail the leaf on the test that tests it.

The two graphs need different answers, so Rule gains TestExempt. A shortcut's test
builds the runtime the shortcut is handed at run time, which means naming the
credential, auth and filesystem packages that runtime is assembled from; denying
those in tests moves no production import and would only park ten packages in the
exception registry for writing ordinary tests. keychain and client stay denied in
tests too: a test needs neither to construct a RuntimeContext, and reaching for
them means it is talking to the real keyring or issuing real requests. Direction
stays denied everywhere — a test may reach down for scaffolding, never up.

That last part left one real violation, and it was an inversion rather than
scaffolding: internal/output's frozen-oracle test imported shortcuts/common to run
the same fixtures through RuntimeContext.Out*. The Emitter half stays where the
fixtures are; the wiring half moves to the layer that owns those methods, as
shortcuts/common/runner_emitter_wiring_test.go — each Out* has to hand the Emitter
the option its name promises, which the bytes show (Raw decides whether
`<p>a&b</p>` survives). It also covers OutFormatRaw, which the oracle was the only
test to reach. Statement coverage is unchanged in both packages, 83.5% and 72.6%.

Verified by probe, both buckets: an XTest-only and an in-package-test-only import
of a denied package under shortcuts/mail each fail the gate now, reported with
in=test files, and passed it before this change.
This commit is contained in:
shanglei
2026-07-30 16:21:48 +08:00
parent 7e34eccf3a
commit ae56d30ce3
3 changed files with 349 additions and 73 deletions

View File

@@ -3,6 +3,12 @@
// Legacy oracle fixtures are frozen at base SHA 4a56748bfa941ff0ee0bfec92e65acac427732b0.
// Golden regeneration is allowed only from that base, never from the current system under test.
//
// These cases run the Emitter, which is where the formatting lives. The other
// half — that RuntimeContext's Out* methods still reach the Emitter with the
// options each one promises — is asserted from the layer that owns those methods,
// in shortcuts/common/runner_emitter_wiring_test.go: a test here would have made
// internal/output depend upward on shortcuts/common.
package output_test
@@ -18,16 +24,9 @@ import (
"strings"
"testing"
"github.com/spf13/cobra"
"github.com/larksuite/cli/brand"
"github.com/larksuite/cli/errs"
extcs "github.com/larksuite/cli/extension/contentsafety"
"github.com/larksuite/cli/internal/cmdutil"
configpkg "github.com/larksuite/cli/internal/config"
"github.com/larksuite/cli/internal/identity"
"github.com/larksuite/cli/internal/output"
"github.com/larksuite/cli/shortcuts/common"
)
type emitterCapture struct {
@@ -303,15 +302,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
t.Fatalf("frozen golden case %q is missing", tc.name)
}
opts := runtimeOracleOptions{
raw: tc.raw,
ok: tc.ok,
meta: tc.meta,
jq: tc.jq,
format: tc.format,
useFormat: tc.useFormat,
pretty: tc.pretty,
}
current := runEmitterWithRuntimeContextContract(tc.data(), output.EmitterConfig{
CommandPath: "lark-cli fixture +emit",
Identity: "bot",
@@ -325,9 +315,6 @@ func TestEmitterMatchesRuntimeContextLegacyOracle(t *testing.T) {
})
assertEmitterGolden(t, want, current)
integrated := runRuntimeContextOracle(t, tc.data(), opts)
assertEmitterGolden(t, want, integrated)
if tc.safetyMode == "block" {
var safetyErr *errs.ContentSafetyError
if !errors.As(current.err, &safetyErr) {
@@ -382,57 +369,6 @@ func captureEmitterGolden(t *testing.T, capture emitterCapture) emitterCaptureGo
return golden
}
type runtimeOracleOptions struct {
raw bool
ok bool
meta *output.Meta
jq string
format string
useFormat bool
pretty bool
}
func runRuntimeContextOracle(t *testing.T, data interface{}, opts runtimeOracleOptions) emitterCapture {
t.Helper()
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}
parent := &cobra.Command{Use: "lark-cli"}
cmd := &cobra.Command{Use: "fixture"}
leaf := &cobra.Command{Use: "+emit"}
parent.AddCommand(cmd)
cmd.AddCommand(leaf)
factory := &cmdutil.Factory{IOStreams: &cmdutil.IOStreams{Out: stdout, ErrOut: stderr}}
runtime := common.TestNewRuntimeContextForAPI(
context.Background(), leaf, &configpkg.CliConfig{Brand: brand.Feishu}, factory, identity.AsBot,
)
runtime.Format = opts.format
runtime.JqExpr = opts.jq
pretty := func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
}
if !opts.pretty {
pretty = nil
}
var err error
switch {
case opts.useFormat && opts.raw:
runtime.OutFormatRaw(data, opts.meta, pretty)
case opts.useFormat:
runtime.OutFormat(data, opts.meta, pretty)
case !opts.ok:
err = runtime.OutPartialFailure(data, opts.meta)
case opts.raw:
runtime.OutRaw(data, opts.meta)
default:
runtime.Out(data, opts.meta)
}
return emitterCapture{stdout: stdout.String(), stderr: stderr.String(), err: err}
}
func runEmitterSuccess(data interface{}, config output.EmitterConfig, ok bool, opts output.EmitOptions) emitterCapture {
stdout := &bytes.Buffer{}
stderr := &bytes.Buffer{}

View File

@@ -60,6 +60,20 @@ type Rule struct {
// whenever the rule name promises a surface rather than a blocklist, so the
// guarantee cannot drift as the repository grows new top-level trees.
AllowedRepoDeps []string
// TestExempt lists dependency prefixes this rule tolerates in the test view
// only — the graph built from TestImports and XTestImports. Production files
// stay under Denied.
//
// The two views need different answers because a test's reason for importing
// a package is different in kind: a shortcut's test constructs the runtime
// the shortcut receives at run time, which means naming the credential, auth
// and filesystem packages the runtime is assembled from. Denying those in
// tests would not move a single production import; it would only push ten
// packages into the exception registry for writing ordinary tests.
//
// What stays denied in tests is direction: a test may reach down for
// scaffolding, never up at a layer above its own.
TestExempt []string
}
// examplesPrefix is the plugin-SDK example tree. Each subdirectory is a
@@ -130,6 +144,17 @@ var rules = []Rule{
{From: modulePath + "/shortcuts/apps/gitcred", Denied: modulePath + "/internal/keychain"},
{From: modulePath + "/shortcuts/apps/gitcred", Denied: modulePath + "/internal/vfs"},
},
// A shortcut's test builds the runtime the shortcut is handed at run
// time, so it names the pieces that runtime is assembled from: a token
// resolver, an identity, a file on disk to upload. keychain and client
// stay denied — a test needs neither to construct a RuntimeContext, and
// reaching for them means it is talking to the real keyring or issuing
// real requests.
TestExempt: []string{
modulePath + "/internal/auth",
modulePath + "/internal/credential",
modulePath + "/internal/vfs",
},
},
{
Name: "cmd-assembly-only",
@@ -169,6 +194,15 @@ type listedPackage struct {
ImportPath string
Imports []string
Deps []string
// TestImports and XTestImports are what `go list` reports for the in-package
// and external test files. They are separate fields in the toolchain's answer
// and were separate holes in this gate: reading only Imports and Deps let a
// denied dependency reach the tree through a _test.go file unchecked, which
// TestLayeringBuildConfigsSelectEveryFile then counted as covered because it
// compiles those files. testDependencyView turns them into a second graph the
// same rules walk.
TestImports []string
XTestImports []string
// Dir and the file lists are only read by
// TestLayeringBuildConfigsSelectEveryFile, which needs the toolchain's own
// answer to "which files did this configuration compile".
@@ -529,6 +563,11 @@ type layeringEdge struct {
type layeringViolation struct {
layeringEdge
Rule string
// TestOnly marks a violation found in the test view rather than the
// production graph. It is not part of the edge identity: the exception
// registry is keyed by (from, denied), so a row covers the edge whichever
// view found it.
TestOnly bool
}
type seededLayeringEdge struct {
@@ -542,13 +581,17 @@ type seededLayeringEdge struct {
func TestPackageLayering(t *testing.T) {
root := repoRoot(t)
packages := goListPackageGraph(t, root)
testPackages := testDependencyView(packages)
seeded := readLayeringEdges(t, filepath.Join(root, "internal/qualitygate/deptest/layering-edges.txt"))
seededByEdge := indexSeededLayeringEdges(t, seeded)
actualByRule := make(map[string][]layeringViolation, len(rules))
actualEdges := make(map[layeringEdge]struct{})
for _, rule := range rules {
violations := evaluateLayeringRule(packages, rule)
violations := slices.Concat(
evaluateLayeringRule(packages, rule),
evaluateLayeringTestRule(testPackages, rule),
)
actualByRule[rule.Name] = violations
for _, violation := range violations {
actualEdges[violation.layeringEdge] = struct{}{}
@@ -558,11 +601,16 @@ func TestPackageLayering(t *testing.T) {
for _, rule := range rules {
t.Run(rule.Name, func(t *testing.T) {
for _, violation := range findUnseededLayeringViolations(actualByRule[rule.Name], seededByEdge) {
where := "production files"
if violation.TestOnly {
where = "test files"
}
t.Errorf(
"new layering violation: from=%s denied=%s rule=%s; use the approved dependency gate or fix the dependency; do not add rows to layering-edges.txt",
"new layering violation: from=%s denied=%s rule=%s in=%s; use the approved dependency gate or fix the dependency; do not add rows to layering-edges.txt",
violation.From,
violation.Denied,
violation.Rule,
where,
)
}
})
@@ -727,6 +775,97 @@ func TestMatchesPackagePrefix(t *testing.T) {
}
}
// TestTestDependencyViewCarriesTestImports pins what the test view is built from:
// the two import lists `go list` keeps separate from Imports, the transitive
// closure through each one, and the package's own path dropped so an external
// test package's import of the package it tests is not a dependency.
func TestTestDependencyViewCarriesTestImports(t *testing.T) {
packages := []listedPackage{
{
ImportPath: modulePath + "/shortcuts/probe",
Imports: []string{modulePath + "/shortcuts/common"},
Deps: []string{modulePath + "/shortcuts/common"},
TestImports: []string{modulePath + "/internal/httpmock", modulePath + "/shortcuts/probe"},
XTestImports: []string{modulePath + "/internal/client"},
},
{
ImportPath: modulePath + "/internal/httpmock",
Deps: []string{modulePath + "/internal/vfs"},
},
{
ImportPath: modulePath + "/internal/leaf",
},
}
view := testDependencyView(packages)
if len(view) != 1 {
t.Fatalf("testDependencyView returned %d packages, want only the one with test imports", len(view))
}
probe := view[0]
wantImports := []string{modulePath + "/internal/client", modulePath + "/internal/httpmock"}
if !slices.Equal(probe.Imports, wantImports) {
t.Fatalf("test view imports = %q, want %q (self-import dropped, XTestImports included)", probe.Imports, wantImports)
}
if !slices.Contains(probe.Deps, modulePath+"/internal/vfs") {
t.Fatalf("test view deps = %q, want the closure through internal/httpmock to reach internal/vfs", probe.Deps)
}
if slices.Contains(probe.Deps, modulePath+"/shortcuts/probe") {
t.Fatalf("test view deps = %q, want the package's own path dropped", probe.Deps)
}
// Production imports stay out of the test view: they are the other graph's
// business, and mixing them in would report one edge from two views.
if slices.Contains(probe.Imports, modulePath+"/shortcuts/common") {
t.Fatalf("test view imports = %q, want production imports excluded", probe.Imports)
}
}
// TestEvaluateLayeringTestRuleAppliesTestExempt is the reverse check for the hole
// this view closes: a denied dependency that exists only in test files has to be
// reported, and TestExempt has to relax the test view without relaxing production.
func TestEvaluateLayeringTestRuleAppliesTestExempt(t *testing.T) {
rule := Rule{
Name: "test-exempt",
Mode: Direct,
FromPrefix: modulePath + "/shortcuts",
Denied: []string{modulePath + "/internal/vfs", modulePath + "/internal/client"},
TestExempt: []string{modulePath + "/internal/vfs"},
}
// Prefix matching means the exemption covers the subpackage too.
testView := []listedPackage{
{
ImportPath: modulePath + "/shortcuts/probe",
Imports: []string{
modulePath + "/internal/client",
modulePath + "/internal/vfs",
modulePath + "/internal/vfs/localfileio",
},
},
}
violations := evaluateLayeringTestRule(testView, rule)
if len(violations) != 1 {
t.Fatalf("evaluateLayeringTestRule returned %+v, want only the unexempted internal/client edge", violations)
}
if violations[0].Denied != modulePath+"/internal/client" {
t.Fatalf("test-view violation = %s, want internal/client", violations[0].Denied)
}
if !violations[0].TestOnly {
t.Fatal("a test-view violation must be marked TestOnly so the failure names the files to look in")
}
// The same imports in production stay denied: TestExempt is not a way to
// widen the rule for everyone.
production := evaluateLayeringRule(testView, rule)
if len(production) != 3 {
t.Fatalf("evaluateLayeringRule returned %d violations, want all 3 — TestExempt must not apply to production", len(production))
}
for _, violation := range production {
if violation.TestOnly {
t.Fatalf("production violation %s is marked TestOnly", violation.Denied)
}
}
}
func TestEvaluateLayeringRuleUsesExactExceptions(t *testing.T) {
rule := Rule{
Name: "exact-exception",
@@ -798,6 +937,11 @@ func TestLayeringRuleContracts(t *testing.T) {
{From: modulePath + "/shortcuts/apps/gitcred", Denied: modulePath + "/internal/keychain"},
{From: modulePath + "/shortcuts/apps/gitcred", Denied: modulePath + "/internal/vfs"},
},
TestExempt: []string{
modulePath + "/internal/auth",
modulePath + "/internal/credential",
modulePath + "/internal/vfs",
},
},
{
Name: "cmd-assembly-only",
@@ -1200,6 +1344,11 @@ func TestLayeringBuildConfigs(t *testing.T) {
// edge only reaches the rules through a selected file, so an unselected file is
// an unenforced one.
//
// Test files count as selected, and that is now the truth rather than an
// assumption: TestPackageLayering walks a second graph built from TestImports and
// XTestImports, so a denied dependency in a _test.go file is reported like any
// other. Until it did, this test vouched for coverage the rules never provided.
//
// It sweeps `go list` again rather than reusing TestPackageLayering's graph:
// that graph merges each package across every configuration and keeps only
// imports, so it can no longer say which configuration contributed which file.
@@ -2088,6 +2237,8 @@ func goListPackageGraph(t *testing.T, root string) []listedPackage {
merged.ImportPath = pkg.ImportPath
merged.Imports = mergeStrings(merged.Imports, pkg.Imports)
merged.Deps = mergeStrings(merged.Deps, pkg.Deps)
merged.TestImports = mergeStrings(merged.TestImports, pkg.TestImports)
merged.XTestImports = mergeStrings(merged.XTestImports, pkg.XTestImports)
packagesByPath[pkg.ImportPath] = merged
}
}
@@ -2103,6 +2254,54 @@ func goListPackageGraph(t *testing.T, root string) []listedPackage {
return packages
}
// testDependencyView returns the graph the rules walk for test files. Each
// package's Imports become its direct test imports, and its Deps the transitive
// closure of those: importing a package from a test pulls that package in along
// with everything it depends on, so the closure is the union of each test import
// and its production Deps.
//
// A package's own import path is dropped from both. An external test package
// (`package foo_test`) always imports the package it tests, and counting that as
// a dependency would make every leaf rule — errs-leaf denies this module
// wholesale — fail on the test file that exists to test the leaf.
func testDependencyView(packages []listedPackage) []listedPackage {
depsByPath := make(map[string][]string, len(packages))
for _, pkg := range packages {
depsByPath[pkg.ImportPath] = pkg.Deps
}
view := make([]listedPackage, 0, len(packages))
for _, pkg := range packages {
imports := make([]string, 0, len(pkg.TestImports)+len(pkg.XTestImports))
closure := map[string]bool{}
for _, imported := range slices.Concat(pkg.TestImports, pkg.XTestImports) {
if imported == pkg.ImportPath {
continue
}
imports = append(imports, imported)
closure[imported] = true
for _, dep := range depsByPath[imported] {
if dep != pkg.ImportPath {
closure[dep] = true
}
}
}
if len(imports) == 0 {
continue
}
deps := make([]string, 0, len(closure))
for dep := range closure {
deps = append(deps, dep)
}
slices.Sort(imports)
imports = slices.Compact(imports)
slices.Sort(deps)
view = append(view, listedPackage{ImportPath: pkg.ImportPath, Imports: imports, Deps: deps})
}
sort.Slice(view, func(i, j int) bool { return view[i].ImportPath < view[j].ImportPath })
return view
}
func goListPackages(t *testing.T, root string, target goListTarget, tags string) []listedPackage {
t.Helper()
packages, stderr, err := loadPackagesForTarget(root, target, tags, exec.Command)
@@ -2199,7 +2398,25 @@ func sortedKeys(values map[string]struct{}) []string {
return keys
}
// evaluateLayeringRule walks the production graph: pkg.Imports for a Direct rule,
// pkg.Deps for a Transitive one.
func evaluateLayeringRule(packages []listedPackage, rule Rule) []layeringViolation {
return evaluateLayeringRuleWithExemptions(packages, rule, nil, false)
}
// evaluateLayeringTestRule walks the graph testDependencyView built from the test
// files, where the rule's TestExempt prefixes are tolerated. Violations are
// marked TestOnly so the failure says which files to look in.
func evaluateLayeringTestRule(packages []listedPackage, rule Rule) []layeringViolation {
return evaluateLayeringRuleWithExemptions(packages, rule, rule.TestExempt, true)
}
func evaluateLayeringRuleWithExemptions(
packages []listedPackage,
rule Rule,
exempt []string,
testOnly bool,
) []layeringViolation {
var violations []layeringViolation
for _, pkg := range packages {
if !matchesPackagePrefix(rule.FromPrefix, pkg.ImportPath) {
@@ -2217,6 +2434,9 @@ func evaluateLayeringRule(packages []listedPackage, rule Rule) []layeringViolati
if !ruleRejectsDependency(rule, dependency) {
continue
}
if matchesAnyPackagePrefix(exempt, dependency) {
continue
}
if slices.Contains(rule.ExceptEdges, layeringEdge{From: pkg.ImportPath, Denied: dependency}) {
continue
}
@@ -2225,7 +2445,8 @@ func evaluateLayeringRule(packages []listedPackage, rule Rule) []layeringViolati
From: pkg.ImportPath,
Denied: dependency,
},
Rule: rule.Name,
Rule: rule.Name,
TestOnly: testOnly,
})
}
}

View File

@@ -0,0 +1,119 @@
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package common
import (
"encoding/json"
"fmt"
"io"
"strings"
"testing"
"github.com/larksuite/cli/internal/output"
)
// The Out* methods are wiring: each builds an output.EmitOptions and hands it to
// the Emitter, which does the formatting. This file pins the wiring — that the
// option each method promises in its name is the option the Emitter receives.
//
// It lives here rather than beside the Emitter's frozen golden fixtures in
// internal/output because the assertion is about this package's methods, and a
// test in internal/output would have to import shortcuts/common: the upward
// dependency the internal-no-upper rule forbids, which went unnoticed while the
// layering gate read production imports only.
//
// The distinction under test is visible in the bytes: Raw disables HTML escaping,
// so `<p>a&b</p>` either survives or comes back as <p>….
const wiringHTMLPayloadKey = "html"
const (
wiringHTMLRaw = `<p>a&b</p>`
wiringHTMLEscaped = `\u003cp\u003ea\u0026b\u003c/p\u003e`
)
func wiringPayload() map[string]interface{} {
return map[string]interface{}{wiringHTMLPayloadKey: wiringHTMLRaw}
}
func TestRuntimeContextOutEscapesHTML(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "")
rctx.Out(wiringPayload(), nil)
if got := stdout.String(); !strings.Contains(got, wiringHTMLEscaped) {
t.Fatalf("Out() stdout = %s, want the HTML escaped as %s", got, wiringHTMLEscaped)
}
}
func TestRuntimeContextOutRawPreservesHTML(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "")
rctx.OutRaw(wiringPayload(), nil)
got := stdout.String()
if !strings.Contains(got, wiringHTMLRaw) {
t.Fatalf("OutRaw() stdout = %s, want the HTML preserved as %s", got, wiringHTMLRaw)
}
if strings.Contains(got, wiringHTMLEscaped) {
t.Fatalf("OutRaw() stdout = %s, want no escaped HTML — Raw was not passed through", got)
}
}
// TestRuntimeContextOutFormatRawPreservesHTML covers the method the golden oracle
// was the only test to reach: OutFormatRaw has to set both Format and Raw, and
// dropping either one is invisible unless the payload contains HTML.
func TestRuntimeContextOutFormatRawPreservesHTML(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "json")
rctx.OutFormatRaw(wiringPayload(), nil, nil)
got := stdout.String()
if !strings.Contains(got, wiringHTMLRaw) {
t.Fatalf("OutFormatRaw() stdout = %s, want the HTML preserved as %s", got, wiringHTMLRaw)
}
if strings.Contains(got, wiringHTMLEscaped) {
t.Fatalf("OutFormatRaw() stdout = %s, want no escaped HTML — Raw was not passed through", got)
}
}
// TestRuntimeContextOutFormatUsesRuntimeFormat pins that OutFormat reads
// ctx.Format rather than defaulting to the JSON envelope: with --format=pretty the
// renderer supplied by the caller owns stdout.
func TestRuntimeContextOutFormatUsesRuntimeFormat(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "pretty")
rctx.OutFormat(wiringPayload(), nil, func(w io.Writer) {
fmt.Fprintln(w, "pretty:fixture")
})
got := stdout.String()
if strings.TrimSpace(got) != "pretty:fixture" {
t.Fatalf("OutFormat(format=pretty) stdout = %q, want the pretty renderer's output", got)
}
}
// TestRuntimeContextOutCarriesMeta pins the last option the methods forward: a
// non-nil Meta has to reach the envelope, or a batch command silently loses the
// count and rollback hint it reported.
func TestRuntimeContextOutCarriesMeta(t *testing.T) {
rctx, stdout, _ := newJqTestContext("", "")
rctx.Out(wiringPayload(), &output.Meta{Count: 3, Rollback: "lark-cli undo"})
var envelope struct {
Meta *struct {
Count int `json:"count"`
Rollback string `json:"rollback"`
} `json:"meta"`
}
if err := json.Unmarshal(stdout.Bytes(), &envelope); err != nil {
t.Fatalf("Out() stdout is not JSON: %v\n%s", err, stdout.String())
}
if envelope.Meta == nil {
t.Fatalf("Out() dropped meta from the envelope: %s", stdout.String())
}
if envelope.Meta.Count != 3 || envelope.Meta.Rollback != "lark-cli undo" {
t.Fatalf("Out() meta = %+v, want count=3 rollback=%q", envelope.Meta, "lark-cli undo")
}
}