Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,16 @@ This applies broadly, including:
- test-scope inference from test-library APIs
- effect or state boundary evidence derived from known dependency APIs

Call-graph expansion is an adapter contract, not a per-rule convenience. Apply it uniformly at the fact site so every consumer of that fact sees the expanded evidence. For property-test facts, the expansion root is the whole property construction that reaches the test runner, not only the final assertion callback. That includes:

- generator expressions and helper bindings used to build generated inputs
- named helper functions and local function literals
- non-function top-level bindings such as generator values
- higher-order property builders when the language structure can trace a callback or returned property function
- operation-sequence operations and assertions derived through helpers

Do not add a narrow expansion path only for the newest failing fixture. Prefer a single closure helper that all fact emitters for that evidence category call through. If full expansion is not possible in a language adapter without semantic analysis, implement the strongest structural expansion available, document the remaining gap in the adapter test or issue, and avoid pretending a bare identifier match proves reachability.

The intent is to reject forgery by incidental names while accepting normal local factoring. A test file should not fail just because it wraps `QCheck`, `Crowbar`, `testing/quick`, or a Swift property-check helper in a local helper. Conversely, a file should not pass merely because it contains a local variable or unrelated declaration with a trusted-looking name.

For generated property tests, adapters should emit structurally rich generated-input evidence rather than policy booleans. Report the generated inputs and syntactic uses where each generated value participates in the property body. These uses are anti-vacuity evidence only; they do not need to prove value flow to an assertion or decision API. Constant properties such as unit generators with `fun ()` or ignored closure arguments may be useful regression assertions, but they should not be emitted as generated-input-backed coverage.
Expand Down
130 changes: 128 additions & 2 deletions go/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,10 +289,14 @@ func goArchitectureFileFact(path string, loadedFile goLoadedFileContext) (archit
imports := goImports(syntaxFile)
identifiers := goIdentifiers(syntaxFile)
apiReferences := goAPIReferences(syntaxFile)
callableArgumentReferences := goSemanticCallableArgumentReferences(syntaxFile, loadedFile.typesInfo)
for reference := range callableArgumentReferences {
apiReferences[reference] = struct{}{}
}
decisionSurface := declaredDecisionSurface(syntaxFile)
decisionProducts := declaredDecisionProducts(syntaxFile)
decisionReferences := declaredDecisionReferences(syntaxFile)
propertyEvidence := goPropertyEvidence(syntaxFile)
propertyEvidence := goPropertyEvidence(syntaxFile, callableArgumentReferences)
if metadata.moduleType != "stateTest" {
for index := range propertyEvidence.checks {
propertyEvidence.checks[index].operationSequences = []goOperationSequenceEvidence{}
Expand Down Expand Up @@ -398,6 +402,36 @@ func goSemanticQualifiedReferences(syntaxFile *ast.File, info *types.Info, selfP
return references
}

// goSemanticCallableArgumentReferences records function symbols that are passed
// as values to another call, e.g. quick.Check(makeProp(Decide), nil). They are
// real API references even though they are not syntactic calls themselves.
func goSemanticCallableArgumentReferences(syntaxFile *ast.File, info *types.Info) map[string]struct{} {
references := map[string]struct{}{}
if info == nil {
return references
}
ast.Inspect(syntaxFile, func(node ast.Node) bool {
call, ok := node.(*ast.CallExpr)
if !ok {
return true
}
for _, argument := range call.Args {
ast.Inspect(argument, func(argumentNode ast.Node) bool {
identifier, ok := argumentNode.(*ast.Ident)
if !ok {
return true
}
if _, ok := info.Uses[identifier].(*types.Func); ok {
references[identifier.Name] = struct{}{}
}
return true
})
}
return true
})
return references
}

func importPath(importSpec *ast.ImportSpec) string {
path, err := strconv.Unquote(importSpec.Path.Value)
if err != nil {
Expand Down Expand Up @@ -816,7 +850,10 @@ type goOperationSequenceEvidence struct {
assertions map[string]struct{}
}

func goPropertyEvidence(syntaxFile *ast.File) goPropertyEvidenceResult {
func goPropertyEvidence(
syntaxFile *ast.File,
callableArgumentReferences map[string]struct{},
) goPropertyEvidenceResult {
globalFunctions := goFunctionDeclarationsByName(syntaxFile)
quickNames := goImportNamesByPath(syntaxFile)["testing/quick"]
result := goPropertyEvidenceResult{}
Expand All @@ -841,6 +878,43 @@ func goPropertyEvidence(syntaxFile *ast.File) goPropertyEvidenceResult {
references[reference] = struct{}{}
}
}
// Also harvest every identifier in the call-site argument
// expressions and expand it through the call graph. A property built
// by a higher-order helper — e.g. quick.Check(makeProp(decide), nil) —
// names `decide` only as a value passed at the call site (not a call),
// so it is invisible to call/type reference collection; capture it
// here and follow `makeProp`'s body through the property functions.
for _, argument := range call.Args {
ast.Inspect(argument, func(node ast.Node) bool {
name := ""
switch typed := node.(type) {
case *ast.Ident:
name = typed.Name
case *ast.SelectorExpr:
name = typed.Sel.Name
}
if name == "" {
return true
}
if goPredeclaredIdentifier(name) {
return true
}
_, isPropertyFunction := propertyFunctions[name]
_, isCallableArgument := callableArgumentReferences[name]
if !isPropertyFunction && !isCallableArgument {
return true
}
if isCallableArgument {
references[name] = struct{}{}
}
if referencedFunction, ok := propertyFunctions[name]; ok {
for nested := range goReachablePropertyReferences(&referencedFunction, propertyFunctions, map[string]bool{}) {
references[nested] = struct{}{}
}
}
return true
})
}
result.checks = append(result.checks, goPropertyCheckEvidence{
references: references,
generatedInputs: goGeneratedInputEvidenceForProperty(propertyFunction, propertyFunctions),
Expand All @@ -852,6 +926,19 @@ func goPropertyEvidence(syntaxFile *ast.File) goPropertyEvidenceResult {
return result
}

func goPredeclaredIdentifier(name string) bool {
switch name {
case "nil", "true", "false", "iota":
return true
case "append", "cap", "clear", "close", "complex", "copy", "delete", "imag", "len", "make", "max", "min", "new", "panic", "print", "println", "real", "recover":
return true
case "any", "bool", "byte", "comparable", "complex64", "complex128", "error", "float32", "float64", "int", "int8", "int16", "int32", "int64", "rune", "string", "uint", "uint8", "uint16", "uint32", "uint64", "uintptr":
return true
default:
return false
}
}

func goPropertyCheckFacts(evidence goPropertyEvidenceResult) []propertyCheckFact {
facts := make([]propertyCheckFact, 0, len(evidence.checks))
for _, check := range evidence.checks {
Expand Down Expand Up @@ -966,11 +1053,50 @@ func goQuickCheckPropertyFunction(
return &propertyFunction
}
return nil
case *ast.CallExpr:
identifier, ok := firstArgument.Fun.(*ast.Ident)
if !ok {
return nil
}
propertyBuilder, ok := propertyFunctions[identifier.Name]
if !ok {
return nil
}
return goReturnedPropertyFunction(&propertyBuilder)
default:
return nil
}
}

func goReturnedPropertyFunction(propertyBuilder *goPropertyFunction) *goPropertyFunction {
if propertyBuilder == nil {
return nil
}
var returned *goPropertyFunction
ast.Inspect(propertyBuilder.body, func(node ast.Node) bool {
if returned != nil {
return false
}
returnStatement, ok := node.(*ast.ReturnStmt)
if !ok {
return true
}
for _, result := range returnStatement.Results {
functionLiteral, ok := result.(*ast.FuncLit)
if !ok {
continue
}
returned = &goPropertyFunction{
functionType: functionLiteral.Type,
body: functionLiteral.Body,
}
return false
}
return true
})
return returned
}

func goReachablePropertyReferences(
propertyFunction *goPropertyFunction,
propertyFunctions map[string]goPropertyFunction,
Expand Down
34 changes: 34 additions & 0 deletions go/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,40 @@ func DecideSend() string {
}
}

func TestLintBackendArchitectureCoversDecisionReachedThroughPropertyHelper(t *testing.T) {
backendRoot := newBackendFixture(t, map[string]string{
"internal/routing/decision.go": `// @archlint.module core
// @archlint.domain routing
package routing

func Decide(x int) bool { return x > 0 }
`,
"internal/routing/decision_test.go": `// @archlint.module test
// @archlint.domain routing
package routing

import (
"testing"
"testing/quick"
)

// makeProp builds the property behind a higher-order helper, so Decide is named
// only at the quick.Check call site, not inside the resolved property body.
func makeProp(decide func(int) bool) func(int) bool {
return func(x int) bool { return decide(x) || x <= 0 }
}

func TestDecideProperty(t *testing.T) {
if err := quick.Check(makeProp(Decide), nil); err != nil {
t.Fatal(err)
}
}
`,
})

assertNoViolations(t, lintBackendArchitecture(backendRoot))
}

func TestLintBackendArchitectureRejectsHandlerWithOnlyArbitraryCoreTypeReference(t *testing.T) {
backendRoot := newBackendFixture(t, map[string]string{
"internal/httpapi/http_decision.go": `// @archlint.module core
Expand Down
17 changes: 9 additions & 8 deletions ocaml/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -150,22 +150,23 @@ cat > "$TMPDIR/test/test_closure.ml" <<'ML'
@archlint.domain demo.closure *)

let gen_b =
QCheck2.Gen.map (fun x -> ignore (Closure_core.decide_b x); x) QCheck2.Gen.int
ignore (Closure_core.decide_b 0);
QCheck2.Gen.small_int

let totality name f =
QCheck2.Test.make ~name QCheck2.Gen.int (fun x -> ignore (f x); true)
QCheck2.Test.make ~name QCheck2.Gen.small_int (fun x -> ignore (f x); true)

let prop_a =
QCheck2.Test.make ~name:"a" QCheck2.Gen.int (fun x ->
QCheck2.Test.make ~name:"a" QCheck2.Gen.small_int (fun x ->
Closure_core.decide_a x || x >= 0)

let prop_b = QCheck2.Test.make ~name:"b" gen_b (fun x -> x = x)
let prop_b = QCheck2.Test.make ~name:"b" gen_b (fun (x : int) -> x = x)
let prop_c = totality "c" (fun x -> Closure_core.decide_c x)

let () =
QCheck2.Test.check_exn prop_a;
QCheck2.Test.check_exn prop_b;
QCheck2.Test.check_exn prop_c
ignore prop_a;
ignore prop_b;
ignore prop_c
ML

eval "$(opam env --switch "${ARCHLINT_OPAM_SWITCH:-$ROOT/ocaml}" --set-switch --shell=sh)"
Expand Down Expand Up @@ -338,6 +339,6 @@ let prop =
ignore Linkonly_core.decide_link;
true)

let () = QCheck2.Test.check_exn prop
let () = ignore prop
ML
expect_violation "core module property tests must reference every decision API: decide_link" constant_lint
13 changes: 3 additions & 10 deletions swift/Sources/SwiftArchLint/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -513,7 +513,7 @@ final class ArchitectureVisitor: SyntaxVisitor {
guard isPropertyCheckCall(node) else {
return .visitChildren
}
let referenceVisitor: APIReferenceVisitor = propertyClosureReferenceVisitor(for: node)
let referenceVisitor: APIReferenceVisitor = propertyConstructionReferenceVisitor(for: node)
let expandedReferences: [String] = expandedAPIReferences(
from: referenceVisitor.apiReferences,
visited: []
Expand Down Expand Up @@ -674,18 +674,11 @@ final class ArchitectureVisitor: SyntaxVisitor {
}
}

private func propertyClosureReferenceVisitor(
private func propertyConstructionReferenceVisitor(
for node: FunctionCallExprSyntax
) -> APIReferenceVisitor {
let referenceVisitor: APIReferenceVisitor = APIReferenceVisitor(viewMode: .sourceAccurate)
if let trailingClosure: ClosureExprSyntax = node.trailingClosure {
referenceVisitor.walk(trailingClosure)
}
for argument: LabeledExprSyntax in node.arguments {
if let closure: ClosureExprSyntax = argument.expression.as(ClosureExprSyntax.self) {
referenceVisitor.walk(closure)
}
}
referenceVisitor.walk(node)
return referenceVisitor
}

Expand Down
55 changes: 55 additions & 0 deletions swift/test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,61 @@ struct SQLiteMailSyncStateSchema {
EOF
assert_passes "$static_property_decision_surface_fixture"

closure_fixture="$(new_fixture closure)"
cat > "$closure_fixture/apps/ios/MailApp/Backend/ClosureDecider.swift" <<'EOF'
// @archlint.module core
// @archlint.domain backend.closure
enum ClosureDecider {
static func decideA(_ shard: Int) -> Bool {
shard >= 0
}

static func decideB(_ shard: Int) -> Bool {
shard < 0
}

static func decideC(_ shard: Int) -> Bool {
shard == 0
}
}
EOF
cat > "$closure_fixture/apps/ios/MailAppTests/ClosureDeciderTests.swift" <<'EOF'
// @archlint.module test
// @archlint.domain backend.closure
import PropertyBased
import Testing

func helper(_ shard: Int) -> Bool {
ClosureDecider.decideC(shard)
}

@Test
func closureProperty() async {
await propertyCheck(
input: Gen.int(in: 0...10).map { shard in
_ = ClosureDecider.decideB(shard)
return shard
}
) { shard in
#expect(ClosureDecider.decideA(shard) || helper(shard) || shard >= 0)
}
}
EOF
cat > "$closure_fixture/apps/ios/MailApp/Backend/ClosureClient.swift" <<'EOF'
// @archlint.module shell
// @archlint.domain backend.closure
import Foundation

struct ClosureClient {
func handle() -> URLRequest {
let path = ClosureDecider.decideA(1) ? "/v1/accounts" : "/v1/accounts"
let url = URL(string: "http://localhost" + path)!
return URLRequest(url: url)
}
}
EOF
assert_passes "$closure_fixture"

arbitrary_core_type_reference_fixture="$(new_fixture arbitrary-core-type-reference)"
cat > "$arbitrary_core_type_reference_fixture/apps/ios/MailApp/Backend/HTTPMailBackendDecider.swift" <<'EOF'
// @archlint.module core
Expand Down
Loading
Loading