Skip to content
Open
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
14 changes: 11 additions & 3 deletions .bumper/RULES.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
# Where Architecture Rules

`BumperBowling.swift` turns the module boundaries already documented in
`Where/**/AGENTS.md` into source-level checks. It scans production sources only;
tests and generated files are outside the architecture graph.
`BumperBowling.swift` turns documented module boundaries and classified-event authoring rules into source-level checks. The Where architecture rules scan production sources. The Periscope authoring rules also scan affected test targets.

## Classified Periscope events

Repository scopes use `@LogScope`. Their direct nested event structs use `@LogEvent`, and `@LogField` appears only in those event structs.

The rules reject manual `LogEvent` and `LogScopeDefinition` conformances. They also reject the removed remote-field API names.

Macro expansion strings are ordinary test data, not source declarations. The typed syntax rules inspect declarations and identifiers, so they need no path exceptions for macro tests.

Repair a violation with the macro authoring API. Do not add a file exception. Mutation tests in `PeriscopeAuthoringRulesTests` cover each rule.

## Layer boundaries

Expand Down
219 changes: 219 additions & 0 deletions .bumper/Sources/PeriscopeAuthoringRules.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,219 @@
import BumperBowlingCore
import SwiftSyntax

let periscopeAuthoringRules = RuleSet {
eventMacroRule
scopeMacroRule
manualEventConformanceRule
manualScopeConformanceRule
legacyRemoteAPIRule
logFieldPlacementRule
}

private let eventMacroRule = Rules.files(
"periscope.structured_events_use_macro",
severity: .error,
summary: "Every event nested in a Periscope scope uses @LogEvent.",
) { file in
SyntaxQuery<StructDeclSyntax>()
.filter { match in
guard let parent = nearestNominalParent(of: match.node)?.as(EnumDeclSyntax.self) else {
return false
}
return hasAttribute("LogScope", in: parent.attributes)
&& !hasAttribute("LogEvent", in: match.node.attributes)
}
.matches(in: file)
.map { match in
match.failure(
message: "A structured Periscope event does not use @LogEvent.",
evidence: ViolationEvidence(
observed: match.node.name.text,
expectation: "a direct @LogEvent struct in its @LogScope namespace",
),
)
}
}

private let scopeMacroRule = Rules.files(
"periscope.event_namespaces_use_macro",
severity: .error,
summary: "Every namespace containing @LogEvent declarations uses @LogScope.",
) { file in
SyntaxQuery<EnumDeclSyntax>()
.filter { match in
!hasAttribute("LogScope", in: match.node.attributes)
&& match.node.memberBlock.members.contains { member in
guard let event = member.decl.as(StructDeclSyntax.self) else { return false }
return hasAttribute("LogEvent", in: event.attributes)
}
}
.matches(in: file)
.map { match in
match.failure(
message: "A Periscope event namespace does not use @LogScope.",
evidence: ViolationEvidence(
observed: match.node.name.text,
expectation: "an @LogScope namespace enum",
),
)
}
}

private let manualEventConformanceRule = manualConformanceRule(
protocolName: "LogEvent",
id: "periscope.manual_event_conformance",
summary: "Repository event declarations do not conform to LogEvent manually.",
)

private let manualScopeConformanceRule = manualConformanceRule(
protocolName: "LogScopeDefinition",
id: "periscope.manual_scope_conformance",
summary: "Repository scope declarations do not conform to LogScopeDefinition manually.",
)

private let legacyRemoteIdentifiers: Set<String> = [
"remoteMessage",
"remoteFields",
"RemoteLogField",
"RemoteLogFieldKey",
"RemoteLogFieldValue",
"RemoteLogCategory",
]

private let legacyRemoteAPIRule = Rules.files(
"periscope.legacy_remote_api",
severity: .error,
summary: "Legacy Periscope remote-field APIs stay removed.",
) { file in
SyntaxQuery<TokenSyntax>()
.filter { legacyRemoteIdentifiers.contains($0.node.text) }
.matches(in: file)
.map { match in
match.failure(
message: "Repository code uses a removed Periscope remote API.",
evidence: ViolationEvidence(
observed: match.node.text,
expectation: "@LogField classification and classifiedFields",
),
)
}
}

private let logFieldPlacementRule = Rules.files(
"periscope.log_field_placement",
severity: .error,
summary: "@LogField appears only on properties of direct @LogEvent structs.",
) { file in
SyntaxQuery<AttributeSyntax>()
.filter { match in
guard attributeBaseName(match.node) == "LogField" else { return false }
guard let event = nearestAncestor(of: match.node, as: StructDeclSyntax.self),
hasAttribute("LogEvent", in: event.attributes),
nearestNominalParent(of: event)?.is(EnumDeclSyntax.self) == true
else {
return true
}
return false
}
.matches(in: file)
.map { match in
match.failure(
message: "@LogField is outside a direct @LogEvent struct.",
evidence: ViolationEvidence(
observed: match.node.trimmedDescription,
expectation: "a stored property in a direct @LogEvent struct",
),
)
}
}

private func manualConformanceRule(
protocolName: String,
id: String,
summary: String,
) -> SyntaxRule {
Rules.files(id, severity: .error, summary: summary) { file in
SyntaxQuery<InheritedTypeSyntax>()
.filter { match in
match.node.type.trimmedDescription == protocolName
&& inheritanceDecl(of: match.node) != nil
}
.matches(in: file)
.map { match in
match.failure(
message: "Repository code conforms to \(protocolName) manually.",
evidence: ViolationEvidence(
observed: match.node.trimmedDescription,
expectation: protocolName == "LogEvent" ? "@LogEvent" : "@LogScope",
),
)
}
}
}

private func inheritanceDecl(of node: InheritedTypeSyntax) -> DeclSyntax? {
var ancestor = Syntax(node).parent
while let current = ancestor {
if current.is(AssociatedTypeDeclSyntax.self)
|| current.is(TypeAliasDeclSyntax.self)
|| current.is(FunctionDeclSyntax.self)
|| current.is(VariableDeclSyntax.self)
{
return nil
}
if current.is(StructDeclSyntax.self)
|| current.is(EnumDeclSyntax.self)
|| current.is(ClassDeclSyntax.self)
|| current.is(ActorDeclSyntax.self)
|| current.is(ProtocolDeclSyntax.self)
|| current.is(ExtensionDeclSyntax.self)
{
return current.as(DeclSyntax.self)
}
ancestor = current.parent
}
return nil
}

private func hasAttribute(_ name: String, in attributes: AttributeListSyntax) -> Bool {
attributes.contains { element in
guard let attribute = element.as(AttributeSyntax.self) else { return false }
return attributeBaseName(attribute) == name
}
}

private func attributeBaseName(_ attribute: AttributeSyntax) -> String {
attribute.attributeName.trimmedDescription.split(separator: ".").last.map(String.init) ?? ""
}

private func nearestNominalParent(of node: some SyntaxProtocol) -> DeclSyntax? {
var ancestor = Syntax(node).parent
while let current = ancestor {
if current.is(StructDeclSyntax.self)
|| current.is(EnumDeclSyntax.self)
|| current.is(ClassDeclSyntax.self)
|| current.is(ActorDeclSyntax.self)
|| current.is(ProtocolDeclSyntax.self)
|| current.is(ExtensionDeclSyntax.self)
{
return current.as(DeclSyntax.self)
}
ancestor = current.parent
}
return nil
}

private func nearestAncestor<Node: SyntaxProtocol>(
of node: some SyntaxProtocol,
as _: Node.Type,
) -> Node? {
var ancestor = Syntax(node).parent
while let current = ancestor {
if let match = current.as(Node.self) {
return match
}
ancestor = current.parent
}
return nil
}
21 changes: 18 additions & 3 deletions .bumper/Sources/WhereProjectRules.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,18 @@ import SwiftSyntax
let whereProjectRules = RuleSet {
Rules.constructionOwnership(
"WhereServices",
allowed: whereServicesConstructionScope,
allowed: whereServicesConstructionScope.union(nonWhereProductionScope),
id: "where.services_composition_ownership",
)
Rules.constructionOwnership(
"CoreLocationSource",
allowed: .files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"]),
allowed: RuleScope.files(["Where/WhereUI/Sources/Launch/WhereLaunch.swift"])
.union(nonWhereProductionScope),
id: "where.live_location_source_ownership",
)
Rules.singleNominalSpelling(
suffix: "Log",
owner: whereLoggingScope,
owner: whereLoggingScope.union(nonWhereProductionScope),
id: "where.logging_type_ownership",
)
productionStoreOpeningRule
Expand All @@ -26,6 +27,14 @@ let whereProjectRules = RuleSet {
previewCoverageRule
}

private let whereProductionScope = RuleScope { file in
file.path.rawValue.hasPrefix("Where/") && file.path.rawValue.contains("/Sources/")
}

private let nonWhereProductionScope = RuleScope { file in
!whereProductionScope.includes(file)
}

private let whereServicesConstructionScope = RuleScope
.component(WhereComponent.whereCore)
.union(.files(["Where/WhereUI/Sources/Preview/PreviewSupport.swift"]))
Expand All @@ -48,6 +57,7 @@ private let productionStoreOpeningRule = Rules.files(
"where.production_store_opening",
severity: .error,
summary: "Production SwiftData stores open only at the app and share-extension composition roots.",
scope: whereProductionScope,
) { file in
functionCalls()
.filter { match in
Expand Down Expand Up @@ -78,6 +88,7 @@ private let checkedConcurrencyBoundaryRule = Rules.files(
"where.checked_concurrency_boundaries",
severity: .error,
summary: "Unchecked concurrency escape hatches stay inside documented lifecycle boundaries.",
scope: whereProductionScope,
) { file in
let preconcurrencyFailures = SyntaxQuery<AttributeSyntax>()
.filter { match in
Expand Down Expand Up @@ -118,6 +129,7 @@ private let gregorianCalendarRule = Rules.files(
"where.gregorian_calendar",
severity: .error,
summary: "Where day and year calculations do not use the device's potentially non-Gregorian current calendar.",
scope: whereProductionScope,
) { file in
SyntaxQuery<MemberAccessExprSyntax>()
.filter { match in
Expand Down Expand Up @@ -153,6 +165,7 @@ private let storeTransactionBoundaryRule = Rules.files(
"where.store_transaction_boundary",
severity: .error,
summary: "WhereStore mutations occur inside a transaction helper on store.",
scope: whereProductionScope,
) { file in
functionCalls()
.filter { match in
Expand Down Expand Up @@ -197,6 +210,7 @@ private let appShortcutsProviderOwnershipRule = Rules.files(
"where.app_shortcuts_provider_ownership",
severity: .error,
summary: "AppShortcutsProvider conformances live in the Where app target.",
scope: whereProductionScope,
) { file in
guard file.component.rawValue != WhereComponent.app.rawValue else { return [] }
return SyntaxQuery<InheritedTypeSyntax>()
Expand All @@ -217,6 +231,7 @@ private let loggingFacadeRule = Rules.files(
"where.logging_facade",
severity: .error,
summary: "Where production logging goes through its typed Periscope facades.",
scope: whereProductionScope,
) { file in
let rawLoggingImports = SyntaxQuery<ImportDeclSyntax>()
.filter { $0.node.path.trimmedDescription == "OSLog" }
Expand Down
Loading
Loading