added check spn script

This commit is contained in:
Or Bauberg committed 2026-07-15 10:30:30 +03:00
1 parent 53aaced40c
commit 7e747df0e5
10 files changed
+1437 -639

No files matched your search

+7 -2
View File
@@ -105,6 +105,11 @@ tasks:
cmds:
- go run ./tools/previewcheck {{.CLI_ARGS}}
check:spn:
desc: Verify each resource/data source IsSPNSupported flag matches the fabric-sdk-go API service principal support
cmds:
- go run ./tools/spncheck {{.CLI_ARGS}}
check:gaps:
desc: Detect SDK control-plane entities with no corresponding Terraform resource
cmds:
@@ -301,9 +306,9 @@ tasks:
# CGO_ENABLED: 1
test:gaptools:
desc: Run unit tests for the gapcheck/previewcheck developer tools
desc: Run unit tests for the gapcheck/previewcheck/spncheck developer tools
cmds:
- go test -count=1 -timeout 5m ./tools/gapcheck/... ./tools/previewcheck/... ./tools/internal/toolutil/...
- go test -count=1 -timeout 5m ./tools/gapcheck/... ./tools/previewcheck/... ./tools/spncheck/... ./tools/internal/toolutil/...
env:
# Fail loudly if the fabric-sdk-go module cannot be resolved instead of
# silently skipping the SDK-backed guard tests.
+51
View File
@@ -0,0 +1,51 @@
// Copyright Microsoft Corporation 2026
// SPDX-License-Identifier: MPL-2.0
package toolutil
import (
"fmt"
"os"
"go.yaml.in/yaml/v3"
)
// Exclusion suppresses a check for a single service package, together with the
// reason the mismatch is intentional.
type Exclusion struct {
Service string `json:"service" yaml:"service"`
Reason string `json:"reason" yaml:"reason"`
}
// ExclusionsFile is the top-level structure of an exclusions YAML file.
type ExclusionsFile struct {
Exclusions []Exclusion `yaml:"exclusions"`
}
// LoadExclusions reads an exclusions YAML file and returns a map of service
// package name to reason. Every entry must set both service and reason.
func LoadExclusions(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading exclusions file %s: %w", path, err)
}
var ef ExclusionsFile
err = yaml.Unmarshal(data, &ef)
if err != nil {
return nil, fmt.Errorf("parsing exclusions file %s: %w", path, err)
}
result := make(map[string]string, len(ef.Exclusions))
for _, e := range ef.Exclusions {
if e.Service == "" || e.Reason == "" {
return nil, fmt.Errorf("exclusion entry missing required field (service, reason): %+v", e)
}
result[e.Service] = e.Reason
}
return result, nil
}
+379
View File
@@ -0,0 +1,379 @@
// Copyright Microsoft Corporation 2026
// SPDX-License-Identifier: MPL-2.0
package toolutil
import (
"fmt"
"go/ast"
"go/token"
"go/types"
"os"
"path/filepath"
"slices"
"strings"
"golang.org/x/tools/go/packages"
)
// SDKCall identifies one distinct fabric-sdk-go function invoked by a service
// package, along with the position of its declaration in the SDK source so the
// caller can inspect the doc comment that documents the API.
type SDKCall struct {
ID string // "pkgName.FuncName", e.g. "core.CreateShortcut"
Pos token.Position // position of the SDK function declaration
}
// CollectSDKCalls walks every call expression in the package and returns the
// distinct fabric-sdk-go functions it invokes. The package must be loaded with
// full type information and dependencies (see LoadServicePackages) so selector
// expressions can be resolved to their declared objects (e.g. SDK client methods
// reached through local variables, parameters, or embedding).
func CollectSDKCalls(pkg *packages.Package) []SDKCall {
seen := map[string]struct{}{}
var calls []SDKCall
for _, file := range pkg.Syntax {
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Only x.Y(...) style calls; SDK funcs are always reached via a selector.
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
// Resolve the selector to its real declared object via the type checker.
fn, ok := pkg.TypesInfo.ObjectOf(sel.Sel).(*types.Func)
if !ok || fn.Pkg() == nil {
return true
}
if !strings.HasPrefix(fn.Pkg().Path(), SDKModulePath) {
return true
}
// Identify the callee as "pkgName.FuncName" and skip duplicates.
id := fn.Pkg().Name() + "." + fn.Name()
if _, dup := seen[id]; dup {
return true
}
seen[id] = struct{}{}
calls = append(calls, SDKCall{ID: id, Pos: pkg.Fset.Position(fn.Pos())})
return true
})
}
return calls
}
// ClientMethodDoc pairs an SDK client method name with the doc comment block
// that immediately precedes its declaration.
type ClientMethodDoc struct {
Name string
Doc string
}
// DocCache lazily reads SDK source files and caches their lines, the doc comment
// above declarations, and per-package CRUD method scans.
type DocCache struct {
files map[string][]string
docs map[string]string
crud map[string][]ClientMethodDoc
}
// NewDocCache returns an empty DocCache ready for use.
func NewDocCache() *DocCache {
return &DocCache{
files: map[string][]string{},
docs: map[string]string{},
crud: map[string][]ClientMethodDoc{},
}
}
// ReadFile returns the lines of path, reading and caching them on first access.
func (d *DocCache) ReadFile(path string) ([]string, error) {
if lines, ok := d.files[path]; ok {
return lines, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
d.files[path] = lines
return lines, nil
}
// DocCommentAt returns the contiguous //-comment block directly above the
// declaration at pos (joined by newlines), or "" if pos is unknown or the file
// cannot be read. Results are cached per position.
func (d *DocCache) DocCommentAt(pos token.Position) string {
if pos.Filename == "" || pos.Line <= 0 {
return ""
}
key := fmt.Sprintf("%s:%d", pos.Filename, pos.Line)
if v, ok := d.docs[key]; ok {
return v
}
doc := ""
lines, err := d.ReadFile(pos.Filename)
if err == nil {
doc = DocCommentAbove(lines, pos.Line)
}
d.docs[key] = doc
return doc
}
// ScanItemCRUD scans the *_client.go files in an SDK package directory and
// returns the item's exported CRUD methods together with their doc comments,
// sorted by method name. Results are cached per (directory, item).
func (d *DocCache) ScanItemCRUD(pkgDir, item string) []ClientMethodDoc {
cacheKey := pkgDir + "\x00" + item
if v, ok := d.crud[cacheKey]; ok {
return v
}
var methods []ClientMethodDoc
entries, err := os.ReadDir(pkgDir)
if err != nil {
d.crud[cacheKey] = nil
return nil
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), "_client.go") {
continue
}
lines, rErr := d.ReadFile(filepath.Join(pkgDir, e.Name()))
if rErr != nil {
continue
}
for i, line := range lines {
_, name := ParseClientMethod(line)
if name == "" || !IsItemCRUD(name, item) {
continue
}
// Line numbers passed to DocCommentAbove are 1-indexed.
methods = append(methods, ClientMethodDoc{Name: name, Doc: DocCommentAbove(lines, i+1)})
}
}
slices.SortFunc(methods, func(a, b ClientMethodDoc) int { return strings.Compare(a.Name, b.Name) })
d.crud[cacheKey] = methods
return methods
}
// DocCommentAbove returns the contiguous //-style comment block that sits
// directly above the 1-indexed declLine, joined by newlines in source order.
func DocCommentAbove(lines []string, declLine int) string {
// declLine is 1-indexed; the line directly above it is at slice index declLine-2.
end := declLine - 2
if end < 0 || end >= len(lines) || !strings.HasPrefix(strings.TrimSpace(lines[end]), "//") {
return ""
}
// Walk up to the top of the contiguous //-comment block.
start := end
for start-1 >= 0 && strings.HasPrefix(strings.TrimSpace(lines[start-1]), "//") {
start--
}
doc := make([]string, 0, end-start+1)
for i := start; i <= end; i++ {
doc = append(doc, strings.TrimSpace(lines[i]))
}
return strings.Join(doc, "\n")
}
// ExtractItemTypeInfoBool returns the value of the named boolean field inside the
// package's ItemTypeInfo composite literal, and whether the field was found with
// a literal true/false value.
func ExtractItemTypeInfoBool(pkg *packages.Package, field string) (bool, bool) { //nolint:revive // value + found
for _, file := range pkg.Syntax {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.VAR {
continue
}
for _, spec := range gen.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || !HasName(vs.Names, "ItemTypeInfo") {
continue
}
for _, val := range vs.Values {
lit, ok := val.(*ast.CompositeLit)
if !ok {
continue
}
if v, ok := extractBoolField(lit, field); ok {
return v, true
}
}
}
}
}
return false, false
}
func extractBoolField(lit *ast.CompositeLit, field string) (bool, bool) { //nolint:revive // value + found
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != field {
continue
}
ident, ok := kv.Value.(*ast.Ident)
if !ok || (ident.Name != "true" && ident.Name != "false") {
continue
}
return ident.Name == "true", true
}
return false, false
}
// FindFabricItemType locates the FabricItemType const (= fabcore.ItemType<Name>)
// in the package and returns the SDK constant name plus a file in the fabcore
// package (used to derive the SDK fabric/ directory).
func FindFabricItemType(pkg *packages.Package) (string, string) { //nolint:revive // SDK const name + fabcore file path
for _, file := range pkg.Syntax {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.CONST {
continue
}
for _, spec := range gen.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || !HasName(vs.Names, "FabricItemType") {
continue
}
for _, val := range vs.Values {
sel, ok := val.(*ast.SelectorExpr)
if !ok {
continue
}
obj := pkg.TypesInfo.ObjectOf(sel.Sel)
if obj == nil || obj.Pkg() == nil {
continue
}
return obj.Name(), pkg.Fset.Position(obj.Pos()).Filename
}
}
}
}
return "", ""
}
// FabricRoot returns the fabric-sdk-go "fabric" directory given a file inside the
// fabric/core package (e.g. .../fabric-sdk-go@v/fabric/core/x.go -> .../fabric).
func FabricRoot(coreFile string) string {
dir := filepath.Dir(coreFile)
if filepath.Base(dir) != "core" {
return ""
}
return filepath.Dir(dir)
}
// SDKPackageDir resolves the package's FabricItemType constant to its dedicated
// fabric-sdk-go package directory, returning the directory and the item name.
// Overrides maps an item name to a non-default SDK package directory name when
// the default lowercase derivation does not match.
func SDKPackageDir(pkg *packages.Package, overrides map[string]string) (string, string, bool) { //nolint:revive // dir + item name + ok
constName, anyFile := FindFabricItemType(pkg)
if constName == "" || anyFile == "" {
return "", "", false
}
// ItemType constants are named ItemType<Name>; the SDK package is fabric/<name>.
item := strings.TrimPrefix(constName, "ItemType")
if item == constName || item == "" {
return "", "", false
}
fabricDir := FabricRoot(anyFile)
if fabricDir == "" {
return "", "", false
}
pkgName := strings.ToLower(item)
if override, has := overrides[item]; has {
pkgName = override
}
return filepath.Join(fabricDir, pkgName), item, true
}
// IsItemCRUD reports whether method is a CRUD operation on the item itself,
// e.g. GetNotebook, BeginCreateNotebook, ListNotebooks, UpdateNotebook.
func IsItemCRUD(method, item string) bool {
if !strings.Contains(strings.ToLower(method), strings.ToLower(item)) {
return false
}
for _, verb := range []string{"Get", "List", "Create", "Update", "Delete"} {
if strings.Contains(method, verb) {
return true
}
}
return false
}
// HasName reports whether target appears among names.
func HasName(names []*ast.Ident, target string) bool {
for _, n := range names {
if n.Name == target {
return true
}
}
return false
}
// PkgName returns the package's name, falling back to the import-path base.
func PkgName(pkg *packages.Package) string {
if pkg.Name != "" {
return pkg.Name
}
return filepath.Base(pkg.PkgPath)
}
+224
View File
@@ -4,6 +4,9 @@
package toolutil_test
import (
"os"
"path/filepath"
"slices"
"testing"
"github.com/microsoft/terraform-provider-fabric/tools/internal/toolutil"
@@ -42,3 +45,224 @@ func TestParseClientMethod(t *testing.T) {
})
}
}
func TestDocCommentAbove(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lines []string
declLine int
want string
}{
{
"contiguous doc block",
[]string{
"// GetFoo retrieves a foo.",
"// This API is currently in preview.",
"func (client *FooClient) GetFoo() {}",
},
3,
"// GetFoo retrieves a foo.\n// This API is currently in preview.",
},
{
"blank line breaks the block",
[]string{
"// GetFoo retrieves a foo.",
"",
"func (client *FooClient) GetFoo() {}",
},
3,
"",
},
{
"non-comment line breaks the block",
[]string{
"import \"context\"",
"func (client *FooClient) GetFoo() {}",
},
2,
"",
},
{
"declaration on first line does not panic",
[]string{"func (client *FooClient) GetFoo() {}"},
1,
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := toolutil.DocCommentAbove(tt.lines, tt.declLine); got != tt.want {
t.Errorf("DocCommentAbove(%d) = %q, want %q", tt.declLine, got, tt.want)
}
})
}
}
func TestIsItemCRUD(t *testing.T) {
t.Parallel()
tests := []struct {
method string
item string
want bool
}{
{"GetNotebook", "Notebook", true},
{"ListNotebooks", "Notebook", true},
{"BeginCreateNotebook", "Notebook", true},
{"UpdateNotebook", "Notebook", true},
{"DeleteNotebook", "Notebook", true},
// Item name matches but the verb is not a CRUD verb.
{"PublishNotebook", "Notebook", false},
// CRUD verb but the method is about a different entity.
{"GetWorkspace", "Notebook", false},
// Neither item nor CRUD verb.
{"ApplyTags", "Notebook", false},
}
for _, tt := range tests {
t.Run(tt.method+"/"+tt.item, func(t *testing.T) {
t.Parallel()
if got := toolutil.IsItemCRUD(tt.method, tt.item); got != tt.want {
t.Errorf("IsItemCRUD(%q, %q) = %v, want %v", tt.method, tt.item, got, tt.want)
}
})
}
}
func TestFabricRoot(t *testing.T) {
t.Parallel()
coreFile := filepath.Join("home", "user", "sdk", "fabric", "core", "zz_generated_models.go")
want := filepath.Join("home", "user", "sdk", "fabric")
if got := toolutil.FabricRoot(coreFile); got != want {
t.Errorf("FabricRoot(%q) = %q, want %q", coreFile, got, want)
}
notCore := filepath.Join("home", "user", "sdk", "fabric", "admin", "client.go")
if got := toolutil.FabricRoot(notCore); got != "" {
t.Errorf("FabricRoot(%q) = %q, want empty (parent dir is not core)", notCore, got)
}
}
func TestScanItemCRUD(t *testing.T) {
t.Parallel()
dir := t.TempDir()
content := "package notebook\n\n" +
"// GetNotebook gets a notebook.\n" +
"// This API is currently in preview.\n" +
"func (client *ItemsClient) GetNotebook(ctx context.Context) (Response, error) {}\n\n" +
"// ListNotebooks lists notebooks.\n" +
"func (client *ItemsClient) ListNotebooks(ctx context.Context) (Response, error) {}\n\n" +
"// ApplyLabel applies a label (not the item).\n" +
"func (client *ItemsClient) ApplyLabel(ctx context.Context) (Response, error) {}\n\n" +
"// getInternal is unexported and must be ignored.\n" +
"func (client *ItemsClient) getInternal() {}\n"
err := os.WriteFile(filepath.Join(dir, "items_client.go"), []byte(content), 0o600)
if err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
methods := toolutil.NewDocCache().ScanItemCRUD(dir, "Notebook")
names := make([]string, 0, len(methods))
for _, m := range methods {
names = append(names, m.Name)
}
wantNames := []string{"GetNotebook", "ListNotebooks"}
if !slices.Equal(names, wantNames) {
t.Fatalf("CRUD methods = %v, want %v", names, wantNames)
}
if got := methods[0].Doc; got != "// GetNotebook gets a notebook.\n// This API is currently in preview." {
t.Errorf("GetNotebook doc = %q, want the preview marker block", got)
}
if got := methods[1].Doc; got != "// ListNotebooks lists notebooks." {
t.Errorf("ListNotebooks doc = %q, want its own doc line", got)
}
}
func TestLoadExclusions(t *testing.T) {
t.Parallel()
t.Run("valid", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions:\n - service: foo\n reason: GA, stale marker\n")
m, err := toolutil.LoadExclusions(path)
if err != nil {
t.Fatalf("LoadExclusions() error = %v", err)
}
if m["foo"] != "GA, stale marker" {
t.Errorf("m[foo] = %q, want the configured reason", m["foo"])
}
})
t.Run("missing service", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions:\n - reason: no service\n")
_, err := toolutil.LoadExclusions(path)
if err == nil {
t.Error("LoadExclusions() error = nil, want error for missing service")
}
})
t.Run("missing reason", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions:\n - service: foo\n")
_, err := toolutil.LoadExclusions(path)
if err == nil {
t.Error("LoadExclusions() error = nil, want error for missing reason")
}
})
t.Run("malformed yaml", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions: [::::\n")
_, err := toolutil.LoadExclusions(path)
if err == nil {
t.Error("LoadExclusions() error = nil, want parse error")
}
})
t.Run("nonexistent path", func(t *testing.T) {
t.Parallel()
_, err := toolutil.LoadExclusions(filepath.Join(t.TempDir(), "nope.yaml"))
if err == nil {
t.Error("LoadExclusions() error = nil, want read error")
}
})
}
func writeExclusions(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "exclusions.yaml")
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
return path
}
+21 -426
View File
@@ -38,17 +38,12 @@ package main
import (
"flag"
"fmt"
"go/ast"
"go/token"
"go/types"
"os"
"path/filepath"
"slices"
"strings"
"golang.org/x/tools/go/packages"
"go.yaml.in/yaml/v3"
"github.com/microsoft/terraform-provider-fabric/tools/internal/toolutil"
)
@@ -81,46 +76,6 @@ var (
exclusionsFlag = flag.String("exclusions", "", "path to exclusions YAML file (default: auto-detected)") //nolint:gochecknoglobals
)
// previewExclusion suppresses the preview check for a single service package,
// typically a GA item whose called SDK API still carries a stale preview marker.
type previewExclusion struct {
Service string `json:"service" yaml:"service"`
Reason string `json:"reason" yaml:"reason"`
}
// exclusionsFile is the top-level YAML structure of the exclusions file.
type exclusionsFile struct {
Exclusions []previewExclusion `yaml:"exclusions"`
}
// loadExclusions reads the exclusions YAML file and returns a map of service
// package name to reason.
func loadExclusions(path string) (map[string]string, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading exclusions file %s: %w", path, err)
}
var ef exclusionsFile
err = yaml.Unmarshal(data, &ef)
if err != nil {
return nil, fmt.Errorf("parsing exclusions file %s: %w", path, err)
}
result := make(map[string]string, len(ef.Exclusions))
for _, e := range ef.Exclusions {
if e.Service == "" || e.Reason == "" {
return nil, fmt.Errorf("exclusion entry missing required field (service, reason): %+v", e)
}
result[e.Service] = e.Reason
}
return result, nil
}
func main() {
flag.Parse()
@@ -147,14 +102,14 @@ func run() int {
exclusionsPath = filepath.Join(root, "tools", "previewcheck", "exclusions.yaml")
}
exclusions, err := loadExclusions(exclusionsPath)
exclusions, err := toolutil.LoadExclusions(exclusionsPath)
if err != nil {
toolutil.Errf("error loading exclusions: %v\n", err)
return exitError
}
dc := newDocCache()
dc := toolutil.NewDocCache()
results := make([]result, 0, len(pkgs))
@@ -380,16 +335,22 @@ func printPreviewAPIs(r result) {
// analyzePackage inspects a service package: it locates ItemTypeInfo.IsPreview
// and collects every fabric-sdk-go function it calls, flagging preview ones.
func analyzePackage(pkg *packages.Package, dc *docCache) (result, bool) {
res := result{service: pkgName(pkg)}
func analyzePackage(pkg *packages.Package, dc *toolutil.DocCache) (result, bool) {
res := result{service: toolutil.PkgName(pkg)}
findIsPreview(pkg, &res)
if v, ok := toolutil.ExtractItemTypeInfoBool(pkg, "IsPreview"); ok {
res.hasDeclaration = true
res.declared = v
}
previewSet := map[string]struct{}{}
seen := map[string]struct{}{}
for _, file := range pkg.Syntax {
collectSDKCalls(pkg, file, dc, &res, previewSet, seen)
for _, call := range toolutil.CollectSDKCalls(pkg) {
res.sdkCalls++
if containsMarker(dc.DocCommentAt(call.Pos)) {
previewSet[call.ID] = struct{}{}
}
}
// Fallback: generic fabricitem resources make no direct SDK calls in-package.
@@ -412,370 +373,24 @@ func analyzePackage(pkg *packages.Package, dc *docCache) (result, bool) {
// determineViaItemType resolves the FabricItemType constant to its dedicated
// fabric-sdk-go package and scans that package's exported client methods for
// preview markers, closing the gap for generic fabricitem resources.
func determineViaItemType(pkg *packages.Package, dc *docCache, res *result, previewSet map[string]struct{}) {
pkgDir, item, ok := sdkPackageDir(pkg)
func determineViaItemType(pkg *packages.Package, dc *toolutil.DocCache, res *result, previewSet map[string]struct{}) {
pkgDir, item, ok := toolutil.SDKPackageDir(pkg, sdkPackageOverrides)
if !ok {
return
}
crud, methods := dc.scanCRUD(pkgDir, item)
if crud == 0 {
methods := dc.ScanItemCRUD(pkgDir, item)
if len(methods) == 0 {
return
}
res.sdkCalls += crud
res.sdkCalls += len(methods)
for _, m := range methods {
previewSet[strings.ToLower(item)+"."+m] = struct{}{}
}
}
// sdkPackageDir resolves a package's FabricItemType constant to its dedicated
// fabric-sdk-go package directory, returning the directory and the item name.
func sdkPackageDir(pkg *packages.Package) (string, string, bool) { //nolint:revive // dir + item name + ok
constName, anyFile := findFabricItemType(pkg)
if constName == "" || anyFile == "" {
return "", "", false
}
// ItemType constants are named ItemType<Name>; the SDK package is fabric/<name>.
item := strings.TrimPrefix(constName, "ItemType")
if item == constName || item == "" {
return "", "", false
}
fabricDir := fabricRoot(anyFile)
if fabricDir == "" {
return "", "", false
}
pkgName := strings.ToLower(item)
if override, ok := sdkPackageOverrides[item]; ok {
pkgName = override
}
return filepath.Join(fabricDir, pkgName), item, true
}
// findFabricItemType locates the FabricItemType const (= fabcore.ItemType<Name>)
// and returns the SDK constant name plus a file in the fabcore package (used to
// derive the SDK fabric/ directory).
func findFabricItemType(pkg *packages.Package) (string, string) { //nolint:revive // two strings: SDK const name and fabcore file path
for _, file := range pkg.Syntax {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.CONST {
continue
}
for _, spec := range gen.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || !hasName(vs.Names, "FabricItemType") {
continue
}
for _, val := range vs.Values {
sel, ok := val.(*ast.SelectorExpr)
if !ok {
continue
}
obj := pkg.TypesInfo.ObjectOf(sel.Sel)
if obj == nil || obj.Pkg() == nil {
continue
}
return obj.Name(), pkg.Fset.Position(obj.Pos()).Filename
}
}
if containsMarker(m.Doc) {
previewSet[strings.ToLower(item)+"."+m.Name] = struct{}{}
}
}
return "", ""
}
// fabricRoot returns the fabric-sdk-go "fabric" directory given a file inside
// the fabric/core package (e.g. .../fabric-sdk-go@v/fabric/core/x.go -> .../fabric).
func fabricRoot(coreFile string) string {
dir := filepath.Dir(coreFile)
if filepath.Base(dir) != "core" {
return ""
}
return filepath.Dir(dir)
}
// findIsPreview locates the IsPreview field inside the ItemTypeInfo composite
// literal and records its value.
func findIsPreview(pkg *packages.Package, res *result) {
for _, file := range pkg.Syntax {
for _, decl := range file.Decls {
gen, ok := decl.(*ast.GenDecl)
if !ok || gen.Tok != token.VAR {
continue
}
for _, spec := range gen.Specs {
vs, ok := spec.(*ast.ValueSpec)
if !ok || !hasName(vs.Names, "ItemTypeInfo") {
continue
}
for _, val := range vs.Values {
lit, ok := val.(*ast.CompositeLit)
if !ok {
continue
}
if extractIsPreview(lit, res) {
return
}
}
}
}
}
}
func extractIsPreview(lit *ast.CompositeLit, res *result) bool {
for _, elt := range lit.Elts {
kv, ok := elt.(*ast.KeyValueExpr)
if !ok {
continue
}
key, ok := kv.Key.(*ast.Ident)
if !ok || key.Name != "IsPreview" {
continue
}
ident, ok := kv.Value.(*ast.Ident)
if !ok || (ident.Name != "true" && ident.Name != "false") {
continue
}
res.hasDeclaration = true
res.declared = ident.Name == "true"
return true
}
return false
}
// collectSDKCalls walks call expressions in a file, resolves each callee, and
// records distinct fabric-sdk-go functions (flagging preview ones). The seen set
// is shared across the package's files so each callee is counted at most once.
func collectSDKCalls(pkg *packages.Package, file *ast.File, dc *docCache, res *result, previewSet, seen map[string]struct{}) {
ast.Inspect(file, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
// Only x.Y(...) style calls; SDK funcs are always reached via a selector.
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
// Resolve the selector to its real declared object via the type checker
// (relies on NeedTypes/NeedTypesInfo/NeedDeps when loading packages).
obj := pkg.TypesInfo.ObjectOf(sel.Sel)
fn, ok := obj.(*types.Func)
if !ok || fn.Pkg() == nil {
return true
}
if !strings.HasPrefix(fn.Pkg().Path(), toolutil.SDKModulePath) {
return true
}
// Identify the callee as "pkgName.FuncName" and skip duplicates.
id := fn.Pkg().Name() + "." + fn.Name()
if _, dup := seen[id]; dup {
return true
}
seen[id] = struct{}{}
res.sdkCalls++
// fn.Pos() points at the SDK declaration; check its doc comment for a
// preview marker and record the func if it is preview.
if dc.isPreview(pkg.Fset.Position(fn.Pos())) {
previewSet[id] = struct{}{}
}
return true
})
}
// docCache reads SDK source files lazily and caches the preview status of the
// doc comment directly above a declaration position.
type docCache struct {
files map[string][]string
results map[string]bool
methods map[string][]string
crud map[string]int
}
func newDocCache() *docCache {
return &docCache{
files: map[string][]string{},
results: map[string]bool{},
methods: map[string][]string{},
crud: map[string]int{},
}
}
// scanCRUD scans the *_client.go files in an SDK package directory and returns
// the count of the item's exported CRUD methods plus the names of those flagged
// preview. A non-zero count means the item status is determinable. Cached per
// directory and item.
func (d *docCache) scanCRUD(pkgDir, item string) (int, []string) {
cacheKey := pkgDir + "\x00" + item
if v, ok := d.methods[cacheKey]; ok {
return d.crud[cacheKey], v
}
entries, err := os.ReadDir(pkgDir)
if err != nil {
d.crud[cacheKey] = 0
d.methods[cacheKey] = nil
return 0, nil
}
var (
crud int
preview []string
)
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), "_client.go") {
continue
}
lines, rErr := d.readFile(filepath.Join(pkgDir, e.Name()))
if rErr != nil {
continue
}
c, p := scanItemMethods(lines, item)
crud += c
preview = append(preview, p...)
}
slices.Sort(preview)
d.crud[cacheKey] = crud
d.methods[cacheKey] = preview
return crud, preview
}
// isPreview reports whether the doc comment immediately above the declaration at
// pos contains a preview marker.
func (d *docCache) isPreview(pos token.Position) bool {
if pos.Filename == "" || pos.Line <= 0 {
return false
}
key := fmt.Sprintf("%s:%d", pos.Filename, pos.Line)
if v, ok := d.results[key]; ok {
return v
}
lines, err := d.readFile(pos.Filename)
if err != nil {
d.results[key] = false
return false
}
doc := docCommentAbove(lines, pos.Line)
preview := containsMarker(doc)
d.results[key] = preview
return preview
}
func (d *docCache) readFile(path string) ([]string, error) {
if lines, ok := d.files[path]; ok {
return lines, nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
d.files[path] = lines
return lines, nil
}
// docCommentAbove returns the contiguous //-style comment block that sits
// directly above the 1-indexed declLine.
func docCommentAbove(lines []string, declLine int) string {
// declLine is 1-indexed; the line above it is at slice index declLine-2.
var doc []string
for i := declLine - 2; i >= 0; i-- {
trimmed := strings.TrimSpace(lines[i])
if strings.HasPrefix(trimmed, "//") {
doc = append(doc, trimmed)
continue
}
break
}
return strings.Join(doc, "\n")
}
// scanItemMethods returns the count of the item's CRUD client methods in the
// file and the names of those whose preceding doc comment is marked preview.
func scanItemMethods(lines []string, item string) (int, []string) {
var (
crud int
preview []string
)
for i, line := range lines {
_, name := toolutil.ParseClientMethod(line)
if name == "" || !isItemCRUD(name, item) {
continue
}
crud++
// Line numbers passed to docCommentAbove are 1-indexed.
if containsMarker(docCommentAbove(lines, i+1)) {
preview = append(preview, name)
}
}
return crud, preview
}
// isItemCRUD reports whether method is a CRUD operation on the item itself,
// e.g. GetNotebook, BeginCreateNotebook, ListNotebooks, UpdateNotebook.
func isItemCRUD(method, item string) bool {
if !strings.Contains(strings.ToLower(method), strings.ToLower(item)) {
return false
}
for _, verb := range []string{"Get", "List", "Create", "Update", "Delete"} {
if strings.Contains(method, verb) {
return true
}
}
return false
}
func containsMarker(doc string) bool {
@@ -789,23 +404,3 @@ func containsMarker(doc string) bool {
return false
}
// loadServicePackages loads all packages under the services directory with full
// type information.
func pkgName(pkg *packages.Package) string {
if pkg.Name != "" {
return pkg.Name
}
return filepath.Base(pkg.PkgPath)
}
func hasName(names []*ast.Ident, target string) bool {
for _, n := range names {
if n.Name == target {
return true
}
}
return false
}
-211
View File
@@ -4,8 +4,6 @@
package main
import (
"os"
"path/filepath"
"slices"
"testing"
)
@@ -37,125 +35,6 @@ func Test_containsMarker(t *testing.T) {
}
}
func Test_docCommentAbove(t *testing.T) {
t.Parallel()
tests := []struct {
name string
lines []string
declLine int
want string
}{
{
"contiguous doc block",
[]string{
"// GetFoo retrieves a foo.",
"// This API is currently in preview.",
"func (client *FooClient) GetFoo() {}",
},
3,
"// This API is currently in preview.\n// GetFoo retrieves a foo.",
},
{
"blank line breaks the block",
[]string{
"// GetFoo retrieves a foo.",
"",
"func (client *FooClient) GetFoo() {}",
},
3,
"",
},
{
"non-comment line breaks the block",
[]string{
"import \"context\"",
"func (client *FooClient) GetFoo() {}",
},
2,
"",
},
{
"declaration on first line does not panic",
[]string{"func (client *FooClient) GetFoo() {}"},
1,
"",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := docCommentAbove(tt.lines, tt.declLine); got != tt.want {
t.Errorf("docCommentAbove(%d) = %q, want %q", tt.declLine, got, tt.want)
}
})
}
}
func Test_isItemCRUD(t *testing.T) {
t.Parallel()
tests := []struct {
method string
item string
want bool
}{
{"GetNotebook", "Notebook", true},
{"ListNotebooks", "Notebook", true},
{"BeginCreateNotebook", "Notebook", true},
{"UpdateNotebook", "Notebook", true},
{"DeleteNotebook", "Notebook", true},
// Item name matches but the verb is not a CRUD verb.
{"PublishNotebook", "Notebook", false},
// CRUD verb but the method is about a different entity.
{"GetWorkspace", "Notebook", false},
// Neither item nor CRUD verb.
{"ApplyTags", "Notebook", false},
}
for _, tt := range tests {
t.Run(tt.method+"/"+tt.item, func(t *testing.T) {
t.Parallel()
if got := isItemCRUD(tt.method, tt.item); got != tt.want {
t.Errorf("isItemCRUD(%q, %q) = %v, want %v", tt.method, tt.item, got, tt.want)
}
})
}
}
func Test_scanItemMethods(t *testing.T) {
t.Parallel()
lines := []string{
"// GetNotebook gets a notebook.",
"// This API is currently in preview.",
"func (client *ItemsClient) GetNotebook(ctx context.Context) (Response, error) {",
"",
"// ListNotebooks lists notebooks.",
"func (client *ItemsClient) ListNotebooks(ctx context.Context) (Response, error) {",
"",
"// ApplyLabel applies a label (not the item).",
"func (client *ItemsClient) ApplyLabel(ctx context.Context) (Response, error) {",
"",
"// getInternal is unexported and must be ignored.",
"func (client *ItemsClient) getInternal() {}",
}
crud, preview := scanItemMethods(lines, "Notebook")
if crud != 2 {
t.Errorf("crud count = %d, want 2 (GetNotebook, ListNotebooks)", crud)
}
want := []string{"GetNotebook"}
if !slices.Equal(preview, want) {
t.Errorf("preview methods = %v, want %v", preview, want)
}
}
func Test_categorize(t *testing.T) {
t.Parallel()
@@ -254,80 +133,6 @@ func Test_classify(t *testing.T) {
}
}
func Test_loadExclusions(t *testing.T) {
t.Parallel()
t.Run("valid", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions:\n - service: foo\n reason: GA, stale marker\n")
m, err := loadExclusions(path)
if err != nil {
t.Fatalf("loadExclusions() error = %v", err)
}
if m["foo"] != "GA, stale marker" {
t.Errorf("m[foo] = %q, want the configured reason", m["foo"])
}
})
t.Run("missing service", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions:\n - reason: no service\n")
_, err := loadExclusions(path)
if err == nil {
t.Error("loadExclusions() error = nil, want error for missing service")
}
})
t.Run("missing reason", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions:\n - service: foo\n")
_, err := loadExclusions(path)
if err == nil {
t.Error("loadExclusions() error = nil, want error for missing reason")
}
})
t.Run("malformed yaml", func(t *testing.T) {
t.Parallel()
path := writeExclusions(t, "exclusions: [::::\n")
_, err := loadExclusions(path)
if err == nil {
t.Error("loadExclusions() error = nil, want parse error")
}
})
t.Run("nonexistent path", func(t *testing.T) {
t.Parallel()
_, err := loadExclusions(filepath.Join(t.TempDir(), "nope.yaml"))
if err == nil {
t.Error("loadExclusions() error = nil, want read error")
}
})
}
func writeExclusions(t *testing.T, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), "exclusions.yaml")
err := os.WriteFile(path, []byte(content), 0o600)
if err != nil {
t.Fatalf("WriteFile() error = %v", err)
}
return path
}
func Test_result_expected_determinable(t *testing.T) {
t.Parallel()
@@ -347,19 +152,3 @@ func Test_result_expected_determinable(t *testing.T) {
t.Error("result with SDK calls should be determinable")
}
}
func Test_fabricRoot(t *testing.T) {
t.Parallel()
coreFile := filepath.Join("home", "user", "sdk", "fabric", "core", "zz_generated_models.go")
want := filepath.Join("home", "user", "sdk", "fabric")
if got := fabricRoot(coreFile); got != want {
t.Errorf("fabricRoot(%q) = %q, want %q", coreFile, got, want)
}
notCore := filepath.Join("home", "user", "sdk", "fabric", "admin", "client.go")
if got := fabricRoot(notCore); got != "" {
t.Errorf("fabricRoot(%q) = %q, want empty (parent dir is not core)", notCore, got)
}
}
+98
View File
@@ -0,0 +1,98 @@
# spncheck
`spncheck` verifies that every Terraform resource/data source is correctly
marked as **supporting service principal (SPN) authentication vs. not**. It
cross-checks each service package's declared support (`IsSPNSupported` in
`base.go`'s `ItemTypeInfo`) against the actual service-principal support of the
[`fabric-sdk-go`](https://github.com/microsoft/fabric-sdk-go) APIs the package
calls.
It shares its scanning engine with [`previewcheck`](../previewcheck/): both tools
resolve SDK call sites, read SDK doc comments, and compare the result against a
declared `ItemTypeInfo` flag via the common
[`tools/internal/toolutil`](../internal/toolutil/) package. Only the marker they
look for (and the declared field they compare) differ.
## How it works
A package should be `IsSPNSupported = true` only when **every** SDK client
function it invokes supports service principal. Each SDK client function
documents its supported identities in a table under this heading in the doc
comment directly above the function:
```text
MICROSOFT ENTRA SUPPORTED IDENTITIES
| Identity | Support | |-|-| | User | Yes | | Service principal ... | Yes |
```
The **Service principal** row's Support cell is one of:
- `Yes` — the API supports service principal
- `No` — the API does **not** support service principal
- a conditional sentence — supported only under stated conditions
`spncheck` resolves each SDK call site to the exact SDK function (using the Go
type checker, so import aliases and receivers resolve correctly), reads its
identity table, and flags the package when **any** called API has a hard `No`.
## Run it
```sh
go run ./tools/spncheck # report findings, exit 1 if any
go run ./tools/spncheck -dir DIR # scan a different services directory
go run ./tools/spncheck -exclusions PATH # use a specific exclusions file
```
Exit codes: `0` = all consistent, `1` = mismatch (or stale exclusion) found,
`2` = error.
## Reading the output
Findings are split by confidence, because the SDK's identity tables are not
always present (a missing table does **not** prove an API supports SPN):
- **OVER-MARKED** (high confidence) — declared `IsSPNSupported = true`, but a
called SDK API is documented as **not** supporting service principal. The item
**should** be `false`. Fix these.
- **REVIEW** (low confidence) — declared `IsSPNSupported = false`, but every
called API supports service principal. *Possibly* promotable to `true`, but
confirm manually.
- **EXCLUDED** — a failing item suppressed via `exclusions.yaml` (see below).
- **STALE EXCLUSIONS** — an excluded item that is no longer a mismatch; remove
the entry. Stale entries also fail the run (exit `1`).
- **UNDETERMINED** — no `fabric-sdk-go` calls found (e.g. generic `fabricitem`
resources whose CRUD runs through the shared abstraction). Informational.
## Acting on a failure
- **OVER-MARKED** → set `IsSPNSupported = false` on the item's `ItemTypeInfo` in
its `base.go`, **or** — if the item genuinely supports SPN and the SDK
annotation is stale/conservative — exclude it (see below).
- **REVIEW** → manually verify against Fabric docs; flip `IsSPNSupported` to
`true` only if every operation truly supports service principal.
If an item calls an SDK package whose directory can't be derived from the item
name automatically, add a mapping to `sdkPackageOverrides` in
[`main.go`](./main.go).
## Excluding items with stale SDK annotations
The SDK sometimes annotates an API more conservatively than reality. That makes
an item show up as **OVER-MARKED** even though keeping it `true` is correct. List
such items in [`exclusions.yaml`](./exclusions.yaml) with a reason:
```yaml
exclusions:
- service: someitem
reason: SPN-supported; someclient.DoThing carries a stale SDK identity annotation
```
`service` is the service package name (the value shown in the report). An
excluded item moves to the **EXCLUDED** section instead of failing the run.
Exclusion is deliberately narrow — a stale entry (the item is no longer a
mismatch, e.g. the SDK corrected the annotation) is reported as **STALE** so it
can be removed. Note that a service-level exclusion also suppresses *future*
genuine non-SPN APIs that item may start calling, so keep the list minimal and
reasoned.
The tool's unit tests run via `task test:gaptools`.
+9
View File
@@ -0,0 +1,9 @@
# Exclusions for spncheck — service packages whose service-principal support
# should NOT be flagged, typically items whose called fabric-sdk-go APIs carry a
# stale or overly conservative identity annotation in their doc comments.
#
# Format: service (the service package name) + reason.
# When the SDK later corrects the annotation (so the service is no longer a
# mismatch), spncheck reports the entry as STALE so it can be removed.
exclusions: []
+456
View File
@@ -0,0 +1,456 @@
// Copyright Microsoft Corporation 2026
// SPDX-License-Identifier: MPL-2.0
// Command spncheck audits every Terraform resource/data source service package
// and verifies that its declared service-principal support (the IsSPNSupported
// field in base.go's ItemTypeInfo) matches the actual service-principal support
// of the Microsoft Fabric Go SDK APIs that the package calls.
//
// An item supports service principal (IsSPNSupported = true) only when ALL of
// the fabric-sdk-go client functions it invokes support service principal. Each
// SDK client function documents its supported identities in a table under the
// following heading in the doc comment directly above the function:
//
// MICROSOFT ENTRA SUPPORTED IDENTITIES
// | Identity | Support | |-|-| | User | Yes | | Service principal ... | Yes |
//
// The "Service principal" row's Support cell is one of:
//
// Yes -> the API supports service principal
// No -> the API does NOT support service principal
// <conditional sentence> -> supported only under certain conditions
//
// spncheck flags an item when ANY called SDK API has a hard "No" in its Service
// principal cell: that item cannot fully support service principal. Because the
// SDK's identity tables are not always present, findings are split by confidence:
//
// - OVER-MARKED (high confidence): declared IsSPNSupported = true but a called
// API is documented as NOT supporting service principal. Should be false.
// - REVIEW (low confidence): declared IsSPNSupported = false but every called
// API supports service principal. Possibly promotable to true, but requires
// manual confirmation because SDK annotations are incomplete.
// - UNDETERMINED: no fabric-sdk-go calls found in the package (e.g. generic
// fabricitem resources whose CRUD runs through the shared abstraction).
//
// Usage:
//
// go run ./tools/spncheck # report findings, exit 1 if any
// go run ./tools/spncheck -dir DIR # scan a different services directory
// go run ./tools/spncheck -exclusions PATH # use a specific exclusions file
//
// An item whose called SDK API carries a stale or overly conservative identity
// annotation can be suppressed by listing its service package in exclusions.yaml
// (see that file).
package main
import (
"flag"
"os"
"path/filepath"
"slices"
"strings"
"golang.org/x/tools/go/packages"
"github.com/microsoft/terraform-provider-fabric/tools/internal/toolutil"
)
// spnSupport is the parsed service-principal support state of a single SDK API,
// derived from the "Service principal" row of its MICROSOFT ENTRA SUPPORTED
// IDENTITIES table.
type spnSupport int
const (
spnUnknown spnSupport = iota // no identity table (or unparsable)
spnYes // "| Yes |"
spnNo // "| No |"
spnConditional // supported only under stated conditions
)
// sdkPackageOverrides maps a FabricItemType item name to its dedicated
// fabric-sdk-go package directory when the default lowercase derivation does not
// match. Add entries here for any item whose SDK package cannot be resolved
// automatically.
var sdkPackageOverrides = map[string]string{ //nolint:gochecknoglobals
"Map": "maps",
}
// exit codes.
const (
exitOK = 0
exitMismatch = 1
exitError = 2
)
// CLI flags.
var (
dirFlag = flag.String("dir", toolutil.DefaultServicesDir, "services directory to scan (relative to the module root)") //nolint:gochecknoglobals
exclusionsFlag = flag.String("exclusions", "", "path to exclusions YAML file (default: auto-detected)") //nolint:gochecknoglobals
)
func main() {
flag.Parse()
os.Exit(run())
}
func run() int {
root, err := toolutil.ModuleRoot()
if err != nil {
toolutil.Errf("error: %v\n", err)
return exitError
}
pkgs, err := toolutil.LoadServicePackages(root, *dirFlag)
if err != nil {
toolutil.Errf("error loading packages: %v\n", err)
return exitError
}
exclusionsPath := *exclusionsFlag
if exclusionsPath == "" {
exclusionsPath = filepath.Join(root, "tools", "spncheck", "exclusions.yaml")
}
exclusions, err := toolutil.LoadExclusions(exclusionsPath)
if err != nil {
toolutil.Errf("error loading exclusions: %v\n", err)
return exitError
}
dc := toolutil.NewDocCache()
results := make([]result, 0, len(pkgs))
for _, pkg := range pkgs {
if len(pkg.Errors) > 0 {
for _, e := range pkg.Errors {
toolutil.Errf("package %s: %v\n", pkg.PkgPath, e)
}
return exitError
}
res, ok := analyzePackage(pkg, dc)
if !ok {
continue
}
results = append(results, res)
}
slices.SortFunc(results, func(a, b result) int { return strings.Compare(a.service, b.service) })
return report(results, exclusions)
}
// result captures the analysis outcome for a single service package.
type result struct {
service string // package name (e.g. "lakehouse")
declared bool // current IsSPNSupported value
hasDeclaration bool // ItemTypeInfo.IsSPNSupported was found
sdkCalls int // number of distinct SDK funcs called
nonSPNAPIs []string // SDK funcs documented as NOT supporting SPN (pkg.Func)
}
// expected returns the IsSPNSupported value implied by the SDK usage: an item
// supports service principal only when none of its called APIs is a hard "No".
func (r result) expected() bool { return len(r.nonSPNAPIs) == 0 }
// determinable is true when at least one SDK API call was found, so the
// expected status can be trusted.
func (r result) determinable() bool { return r.sdkCalls > 0 }
func report(results []result, exclusions map[string]string) int {
b := categorize(results, exclusions)
for _, r := range b.undeclared {
toolutil.Outf("%-40s no ItemTypeInfo.IsSPNSupported field found\n", r.service)
}
printOverMarked(b.overMarked)
printReview(b.review)
printExcluded(b.excluded)
printStaleExclusions(b.staleExcl)
toolutil.Outf("\nScanned %d services: %d over-marked, %d to review, %d undeclared, %d excluded, %d stale, %d undetermined\n",
len(results), len(b.overMarked), len(b.review), len(b.undeclared), len(b.excluded), len(b.staleExcl), b.undetermined)
if len(b.overMarked) > 0 || len(b.review) > 0 || len(b.undeclared) > 0 || len(b.staleExcl) > 0 {
return exitMismatch
}
return exitOK
}
// category is the service-principal-support classification of a single service.
type category int
const (
catConsistent category = iota // declared status matches SDK usage
catUndetermined // no SDK calls, status not determinable
catOverMarked // declared SPN-supported but a called API is not
catReview // declared unsupported but every called API supports SPN
catUndeclared // no ItemTypeInfo.IsSPNSupported field found
)
// classify returns the service-principal-support category for a single result.
func classify(r result) category {
if !r.hasDeclaration {
return catUndeclared
}
if !r.determinable() {
return catUndetermined
}
switch {
case r.declared && !r.expected():
return catOverMarked
case !r.declared && r.expected():
return catReview
default:
return catConsistent
}
}
// excludedResult is a failing service suppressed by the exclusions file.
type excludedResult struct {
res result
reason string
}
// buckets groups categorized services by confidence level.
type buckets struct {
overMarked []result // declared SPN-supported but a called API is not
review []result // declared unsupported but every called API supports SPN
undeclared []result // no ItemTypeInfo.IsSPNSupported field found
excluded []excludedResult // failing but suppressed via exclusions.yaml
staleExcl []string // excluded services that no longer mismatch
undetermined int // no SDK calls, status not determinable
}
// categorize splits services into over-marked, review, undeclared, excluded,
// and undetermined groups, routing failing services listed in exclusions into
// the excluded bucket and flagging exclusions that no longer apply as stale.
func categorize(results []result, exclusions map[string]string) buckets {
var b buckets
used := make(map[string]struct{})
for _, r := range results {
switch cat := classify(r); cat {
case catUndetermined:
b.undetermined++
case catConsistent:
// declared status is correct; nothing to report.
default:
b.addFailing(r, cat, exclusions, used)
}
}
for svc := range exclusions {
if _, ok := used[svc]; !ok {
b.staleExcl = append(b.staleExcl, svc)
}
}
slices.Sort(b.staleExcl)
return b
}
// addFailing routes a failing result into the excluded bucket (if its service
// is listed in exclusions) or its failing-category bucket.
func (b *buckets) addFailing(r result, cat category, exclusions map[string]string, used map[string]struct{}) {
if reason, ok := exclusions[r.service]; ok {
b.excluded = append(b.excluded, excludedResult{res: r, reason: reason})
used[r.service] = struct{}{}
return
}
switch cat {
case catOverMarked:
b.overMarked = append(b.overMarked, r)
case catReview:
b.review = append(b.review, r)
case catUndeclared:
b.undeclared = append(b.undeclared, r)
default:
}
}
func printOverMarked(overMarked []result) {
if len(overMarked) == 0 {
return
}
toolutil.Outf("\nOVER-MARKED — declared SPN-supported but the SDK marks a called API as NOT supporting service principal (should be IsSPNSupported = false):\n")
for _, r := range overMarked {
toolutil.Outf(" ✗ %-38s\n", r.service)
printNonSPNAPIs(r)
}
}
func printReview(review []result) {
if len(review) == 0 {
return
}
toolutil.Outf("\nREVIEW — declared NOT SPN-supported but every called API supports service principal (possibly IsSPNSupported = true, confirm manually):\n")
for _, r := range review {
toolutil.Outf(" ? %-38s\n", r.service)
}
}
func printExcluded(excluded []excludedResult) {
if len(excluded) == 0 {
return
}
toolutil.Outf("\nEXCLUDED — suppressed via exclusions.yaml (confirmed intentional):\n")
for _, e := range excluded {
toolutil.Outf(" - %-38s %s\n", e.res.service, e.reason)
}
}
func printStaleExclusions(stale []string) {
if len(stale) == 0 {
return
}
toolutil.Outf("\nSTALE EXCLUSIONS — no longer mismatched, remove from exclusions.yaml:\n")
for _, svc := range stale {
toolutil.Outf(" %s\n", svc)
}
}
func printNonSPNAPIs(r result) {
if len(r.nonSPNAPIs) == 0 {
return
}
for _, api := range r.nonSPNAPIs {
toolutil.Outf(" non-SPN API: %s\n", api)
}
}
// analyzePackage inspects a service package: it locates ItemTypeInfo.IsSPNSupported
// and collects every fabric-sdk-go function it calls, flagging those that do not
// support service principal.
func analyzePackage(pkg *packages.Package, dc *toolutil.DocCache) (result, bool) {
res := result{service: toolutil.PkgName(pkg)}
if v, ok := toolutil.ExtractItemTypeInfoBool(pkg, "IsSPNSupported"); ok {
res.hasDeclaration = true
res.declared = v
}
nonSPNSet := map[string]struct{}{}
for _, call := range toolutil.CollectSDKCalls(pkg) {
res.sdkCalls++
if parseSPNSupport(dc.DocCommentAt(call.Pos)) == spnNo {
nonSPNSet[call.ID] = struct{}{}
}
}
// Fallback: generic fabricitem resources make no direct SDK calls in-package.
// Resolve their FabricItemType to the dedicated SDK package and scan its
// exported client methods for service-principal support.
if res.sdkCalls == 0 {
determineViaItemType(pkg, dc, &res, nonSPNSet)
}
res.nonSPNAPIs = toolutil.SortedKeys(nonSPNSet)
// Skip packages that have neither a declaration nor any relevance.
if !res.hasDeclaration && res.sdkCalls == 0 {
return res, false
}
return res, true
}
// determineViaItemType resolves the FabricItemType constant to its dedicated
// fabric-sdk-go package and scans that package's exported client methods for
// service-principal support, closing the gap for generic fabricitem resources.
func determineViaItemType(pkg *packages.Package, dc *toolutil.DocCache, res *result, nonSPNSet map[string]struct{}) {
pkgDir, item, ok := toolutil.SDKPackageDir(pkg, sdkPackageOverrides)
if !ok {
return
}
methods := dc.ScanItemCRUD(pkgDir, item)
if len(methods) == 0 {
return
}
res.sdkCalls += len(methods)
for _, m := range methods {
if parseSPNSupport(m.Doc) == spnNo {
nonSPNSet[strings.ToLower(item)+"."+m.Name] = struct{}{}
}
}
}
// parseSPNSupport reads the "Service principal" row of the MICROSOFT ENTRA
// SUPPORTED IDENTITIES table in doc and returns its support state. It normalizes
// the doc by stripping all whitespace (the table wraps across comment lines) and
// inspecting the support cell that immediately follows the "service principal"
// identity label.
func parseSPNSupport(doc string) spnSupport {
// Collapse all whitespace so the wrapped, multi-line table becomes a single
// contiguous string that is trivial to slice on the "|" cell delimiters.
norm := strings.Join(strings.Fields(strings.ToLower(doc)), "")
if !strings.Contains(norm, "microsoftentrasupportedidentities") {
return spnUnknown
}
// Use LastIndex: "service principal" can appear in free-text (e.g.
// PERMISSIONS lines) before the table. The last occurrence is always the
// one inside the actual identity table row.
idx := strings.LastIndex(norm, "serviceprincipal")
if idx < 0 {
return spnUnknown
}
// The support cell is the first "| value |" after the (bracketed) identity
// label. Identity labels and URL references never contain "|".
rest := norm[idx:]
_, after, ok := strings.Cut(rest, "|")
if !ok {
return spnUnknown
}
cell := after
before0, _, ok0 := strings.Cut(cell, "|")
if !ok0 {
return spnUnknown
}
switch before0 {
case "yes":
return spnYes
case "no":
return spnNo
default:
return spnConditional
}
}
+192
View File
@@ -0,0 +1,192 @@
// Copyright Microsoft Corporation 2026
// SPDX-License-Identifier: MPL-2.0
package main
import (
"slices"
"testing"
)
func Test_parseSPNSupport(t *testing.T) {
t.Parallel()
const heading = "// MICROSOFT ENTRA SUPPORTED IDENTITIES This API supports the Microsoft identities listed in this section.\n"
tests := []struct {
name string
doc string
want spnSupport
}{
{
"service principal yes",
heading + "// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] and Managed identities [/entra/bar] | Yes |",
spnYes,
},
{
"service principal no",
heading + "// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] | No |",
spnNo,
},
{
"conditional support",
heading + "// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] and Managed identities [/entra/bar] | When the item type in the call is supported. |",
spnConditional,
},
{
"table wrapped across comment lines",
heading +
"// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo]\n" +
"// and Managed identities\n" +
"// [/entra/bar] | No |",
spnNo,
},
{
"no identity table",
"// GetFoo retrieves a foo. It does not document identities.",
spnUnknown,
},
{
"heading present but no service principal row",
heading + "// | Identity | Support | |-|-| | User | Yes |",
spnUnknown,
},
{
"service principal in free text before table",
"// PERMISSIONS The caller must authenticate using a service principal.\n" +
heading +
"// | Identity | Support | |-|-| | User | Yes | | Service principal [/entra/foo] | Yes |",
spnYes,
},
{
"empty",
"",
spnUnknown,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
if got := parseSPNSupport(tt.doc); got != tt.want {
t.Errorf("parseSPNSupport() = %v, want %v", got, tt.want)
}
})
}
}
func Test_categorize(t *testing.T) {
t.Parallel()
results := []result{
{service: "no-decl", hasDeclaration: false},
{service: "undetermined", hasDeclaration: true, sdkCalls: 0},
{service: "over-marked", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: []string{"core.GetX"}},
{service: "review", hasDeclaration: true, sdkCalls: 2, declared: false, nonSPNAPIs: nil},
{service: "correct-supported", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: nil},
{service: "correct-unsupported", hasDeclaration: true, sdkCalls: 2, declared: false, nonSPNAPIs: []string{"core.GetX"}},
}
b := categorize(results, nil)
if len(b.undeclared) != 1 || b.undeclared[0].service != "no-decl" {
t.Errorf("undeclared = %+v, want [no-decl]", b.undeclared)
}
if b.undetermined != 1 {
t.Errorf("undetermined = %d, want 1", b.undetermined)
}
if len(b.overMarked) != 1 || b.overMarked[0].service != "over-marked" {
t.Errorf("overMarked = %+v, want [over-marked]", b.overMarked)
}
if len(b.review) != 1 || b.review[0].service != "review" {
t.Errorf("review = %+v, want [review]", b.review)
}
if len(b.excluded) != 0 || len(b.staleExcl) != 0 {
t.Errorf("excluded/stale should be empty without exclusions, got %+v / %+v", b.excluded, b.staleExcl)
}
}
func Test_categorize_exclusions(t *testing.T) {
t.Parallel()
results := []result{
{service: "over-marked", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: []string{"core.GetX"}},
{service: "review", hasDeclaration: true, sdkCalls: 2, declared: false, nonSPNAPIs: nil},
{service: "correct-supported", hasDeclaration: true, sdkCalls: 2, declared: true, nonSPNAPIs: nil},
}
exclusions := map[string]string{
"over-marked": "SPN-supported, stale SDK annotation",
"correct-supported": "stale exclusion, service is already consistent",
}
b := categorize(results, exclusions)
if len(b.overMarked) != 0 {
t.Errorf("overMarked = %+v, want empty (excluded)", b.overMarked)
}
if len(b.excluded) != 1 || b.excluded[0].res.service != "over-marked" {
t.Errorf("excluded = %+v, want [over-marked]", b.excluded)
}
if b.excluded[0].reason != "SPN-supported, stale SDK annotation" {
t.Errorf("excluded reason = %q, want the configured reason", b.excluded[0].reason)
}
if len(b.review) != 1 || b.review[0].service != "review" {
t.Errorf("review = %+v, want [review]", b.review)
}
if !slices.Contains(b.staleExcl, "correct-supported") {
t.Errorf("staleExcl = %+v, want to contain correct-supported", b.staleExcl)
}
}
func Test_classify(t *testing.T) {
t.Parallel()
tests := []struct {
name string
res result
want category
}{
{"undeclared", result{hasDeclaration: false}, catUndeclared},
{"undetermined", result{hasDeclaration: true, sdkCalls: 0}, catUndetermined},
{"over-marked", result{hasDeclaration: true, sdkCalls: 1, declared: true, nonSPNAPIs: []string{"core.X"}}, catOverMarked},
{"review", result{hasDeclaration: true, sdkCalls: 1, declared: false}, catReview},
{"consistent-supported", result{hasDeclaration: true, sdkCalls: 1, declared: true}, catConsistent},
{"consistent-unsupported", result{hasDeclaration: true, sdkCalls: 1, declared: false, nonSPNAPIs: []string{"core.X"}}, catConsistent},
}
for _, tt := range tests {
if got := classify(tt.res); got != tt.want {
t.Errorf("%s: classify = %d, want %d", tt.name, got, tt.want)
}
}
}
func Test_result_expected_determinable(t *testing.T) {
t.Parallel()
if !(result{}).expected() {
t.Error("result with no non-SPN APIs should be expected SPN-supported")
}
if (result{nonSPNAPIs: []string{"core.GetX"}}).expected() {
t.Error("result with a non-SPN API should not be expected SPN-supported")
}
if (result{sdkCalls: 0}).determinable() {
t.Error("result with no SDK calls should not be determinable")
}
if !(result{sdkCalls: 1}).determinable() {
t.Error("result with SDK calls should be determinable")
}
}