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
23 changes: 23 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// swift-tools-version: 6.2
import CompilerPluginSupport
import PackageDescription

let package = Package(
Expand Down Expand Up @@ -43,8 +44,20 @@ let package = Package(
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.18.0"),
.package(url: "https://github.com/cashapp/AccessibilitySnapshot", from: "0.12.0"),
.package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "7.0.0"),
.package(url: "https://github.com/swiftlang/swift-syntax", exact: "603.0.2"),
],
targets: [
.macro(
name: "PeriscopeMacros",
dependencies: [
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
.product(name: "SwiftDiagnostics", package: "swift-syntax"),
.product(name: "SwiftSyntax", package: "swift-syntax"),
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
],
path: "Shared/Periscope/PeriscopeMacros/Sources",
),
.target(
name: "CreditKit",
path: "Shared/CreditKit/Sources",
Expand Down Expand Up @@ -79,6 +92,7 @@ let package = Package(
name: "PeriscopeCore",
dependencies: [
.target(name: "JournalKit"),
.target(name: "PeriscopeMacros"),
],
path: "Shared/Periscope/PeriscopeCore/Sources",
),
Expand Down Expand Up @@ -218,5 +232,14 @@ let package = Package(
],
path: "Shared/Broadway/BroadwayUI/Sources",
),
.testTarget(
name: "PeriscopeMacrosTests",
dependencies: [
.target(name: "PeriscopeMacros"),
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
],
path: "Shared/Periscope/PeriscopeMacros/Tests",
),
],
)
3 changes: 2 additions & 1 deletion Shared/CreditKit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ CreditKit provides tools and types for working out what an app owes attribution
- **`SoftwareCredit` is `Identifiable` by `name`.** A library's name is its repo basename.
- **Read notices at the pinned revision.** Never read the default branch. HEAD's text may not govern the code in the binary.
- **The generator keys off `.product(name:package:)`, not `dependencies:`.**
- **That keeps tooling-only packages (BumperBowling, swift-syntax) out of a report by construction.**
- **Include packages that a target links.** A macro-linked package is a development tool.
- **`kind` is derived from reachability, not declared.** `shippedFrom` names the app's root package targets.
- **Anything inside that closure is a `library`.** Any other linked package is a `developmentTool`. Linking is not shipping.
- **Stop shipping reachability at macro targets.** Macro implementations run on the build host.
- **`shippedFrom` is the only hand-set part for SPM packages.**
- **`agentSkills` and `developmentTools` declare `kind` in config** — both are development tools in Where today.

Expand Down
2 changes: 2 additions & 0 deletions Shared/CreditKit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,8 @@ The generator walks the manifest's target graph out from them.
A package inside that closure is a `library`.
Any other linked package is a `developmentTool`.
Linking is not shipping.
A macro target stops shipping reachability because its implementation runs on the build host.
Packages that the macro links remain credited as development tools.
A snapshot-testing engine linked by a test-support target is credited (the repo depends on it) but must not be described as being in the binary.
`shippedFrom` is the only part set by hand.
Adding a dependency cannot quietly land under the wrong kind.
Expand Down
25 changes: 25 additions & 0 deletions Shared/CreditKit/Tools/Tests/Fixtures/MacroPackage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import CompilerPluginSupport
import PackageDescription

let package = Package(
name: "Fixture",
dependencies: [
.package(url: "https://example.com/fake-runtime", exact: "1.0.0"),
.package(url: "https://example.com/fake-syntax", exact: "1.0.0"),
],
targets: [
.target(
name: "Shipping",
dependencies: [
.target(name: "FixtureMacro"),
.product(name: "FakeRuntime", package: "fake-runtime"),
],
),
.macro(
name: "FixtureMacro",
dependencies: [
.product(name: "FakeSyntax", package: "fake-syntax"),
],
),
],
)
18 changes: 18 additions & 0 deletions Shared/CreditKit/Tools/Tests/generate_attribution_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# frozen_string_literal: true

require "minitest/autorun"
require_relative "../generate-attribution"

class GenerateAttributionTest < Minitest::Test
FIXTURE = "Shared/CreditKit/Tools/Tests/Fixtures/MacroPackage.swift"

def test_macro_packages_are_linked_but_not_shipped
targets = package_targets(FIXTURE)
linked = targets.values.flat_map { |target| target["packages"] }.uniq
shipped = shipped_package_identities(targets, ["Shipping"])

assert_equal %w[fake-runtime fake-syntax], linked
assert_equal ["fake-runtime"], shipped
assert_equal "macro", targets.fetch("FixtureMacro").fetch("kind")
end
end
6 changes: 4 additions & 2 deletions Shared/CreditKit/Tools/generate-attribution.rb
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ def read_json(relative_path, source_type)
# A target declaration, as distinct from a `.target(name:)` *dependency* entry:
# only the declaration puts `name:` on its own line. Keying off that rather than
# indentation keeps the parse independent of how deeply the array is nested.
TARGET_DECLARATION = /\.(?:target|testTarget|executableTarget)\(\s*\n\s*name:\s*"([^"]+)"/
TARGET_DECLARATION = /\.(target|testTarget|executableTarget|macro)\(\s*\n\s*name:\s*"([^"]+)"/
TARGET_DEPENDENCY = /\.target\(name:\s*"([^"]+)"/
PRODUCT_DEPENDENCY = /\.product\(\s*name:\s*"[^"]+",\s*package:\s*"([^"]+)"/

Expand All @@ -148,8 +148,9 @@ def package_targets(manifest_path, root: ROOT)
# Everything up to the next declaration is this target's body.
body = text[declaration.end(0)...(declarations[index + 1]&.begin(0) || text.length)]
[
declaration[1],
declaration[2],
{
"kind" => declaration[1],
"targets" => body.scan(TARGET_DEPENDENCY).flatten,
"packages" => body.scan(PRODUCT_DEPENDENCY).flatten.map(&:downcase),
},
Expand All @@ -172,6 +173,7 @@ def shipped_package_identities(targets, roots)
visited << name
target = targets[name]
next unless target
next if target["kind"] == "macro"
shipped.concat(target["packages"])
queue.concat(target["targets"])
end
Expand Down
1 change: 1 addition & 0 deletions Shared/Periscope/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format

## Modules & dependencies

- **PeriscopeMacros** — compile-time event generation. Depends on SwiftSyntax and runs only on the build host.
- **PeriscopeCore** — the model and machinery. No SwiftUI, no app code.
- **PeriscopeUI** — SwiftUI integration. Depends on PeriscopeCore.
- **PeriscopeTools** — developer surfaces. Depends on PeriscopeCore, PeriscopeUI, BroadwayCore/BroadwayUI, and SFSafeSymbols.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,16 @@
@MainActor
private static func summaryEvent() -> AmbientEvent {
AmbientEvent(
kind: .accessibility,
value: Dictionary(
uniqueKeysWithValues: settings
.map { ($0.name, AmbientValue.bool($0.isEnabled())) },
kind: .restricted(.technicalState, .accessibility),
value: .restricted(
.domainValue,
Dictionary(
uniqueKeysWithValues: settings
.map { ($0.name, AmbientValue.bool($0.isEnabled())) },
),
),
level: .restricted(.technicalState, .info),
reporting: .restricted(.technicalState, .state),
)
}
}
Expand Down
104 changes: 59 additions & 45 deletions Shared/Periscope/PeriscopeCore/Sources/Ambient/AmbientEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -111,51 +111,65 @@ extension [String: AmbientValue] {
}
}

/// The standard event ambient sources emit: environmental context —
/// backgrounding, memory pressure, connectivity, thermal state — that helps
/// diagnose what the system was doing around an error.
public struct AmbientEvent: LogEvent, Hashable {
public static let eventName = "ambient"

/// Whether an event announces a lasting condition or a passing moment.
///
/// Only `state` folds into the ``AmbientSnapshot`` every later record is
/// stamped with. A memory warning describes an instant, not a condition
/// the app stays in, so it must not stick to everything after it.
///
/// The case names are the persisted wire values — renaming one rewrites
/// the format for stored rows.
public enum Reporting: String, Hashable, Sendable, Codable {
/// A lasting condition: the newest value replaces the previous one
/// and describes the app until it changes again.
case state
/// A momentary occurrence, meaningful only at its own timestamp.
case occurrence
}

public var kind: AmbientKind
/// The state as named fields (`["level": "serious"]`,
/// `["voiceover": false]`) — a JSON object in the payload, not a
/// formatted sentence the tooling would have to parse back apart.
public var value: [String: AmbientValue]
public var level: LogLevel
/// Defaults to ``Reporting/state`` — "ambient" means a surrounding
/// condition, and a source that reports moments is the exception.
public var reporting: Reporting

public var message: String {
"\(kind): \(value.ambientDescription)"
}
/// The built-in scope for environmental state and occurrence events.
@LogScope("ambient")
public enum AmbientLog {
/// The standard event ambient sources emit: environmental context —
/// backgrounding, memory pressure, connectivity, thermal state — that helps
/// diagnose what the system was doing around an error.
@LogEvent("event")
public struct Event: Hashable {
/// Whether an event announces a lasting condition or a passing moment.
///
/// Only `state` folds into the ``AmbientSnapshot`` every later record is
/// stamped with. A memory warning describes an instant, not a condition
/// the app stays in, so it must not stick to everything after it.
///
/// The case names are the persisted wire values — renaming one rewrites
/// the format for stored rows.
public enum Reporting: String, Hashable, Sendable, Codable {
/// A lasting condition: the newest value replaces the previous one
/// and describes the app until it changes again.
case state
/// A momentary occurrence, meaningful only at its own timestamp.
case occurrence
}

public init(
kind: AmbientKind,
value: [String: AmbientValue],
level: LogLevel = .info,
reporting: Reporting = .state,
) {
self.kind = kind
self.value = value
self.level = level
self.reporting = reporting
@LogField(
"kind",
exposure: .restricted,
kind: .technicalState,
)
public var kind: AmbientKind

/// The state as named fields (`["level": "serious"]`,
/// `["voiceover": false]`) — a JSON object in the payload, not a
/// formatted sentence the tooling would have to parse back apart.
@LogField(
"value",
exposure: .restricted,
kind: .domainValue,
)
public var value: [String: AmbientValue]

@LogField(
"level",
exposure: .restricted,
kind: .technicalState,
)
public var level: LogLevel

@LogField(
"reporting",
exposure: .restricted,
kind: .technicalState,
)
public var reporting: AmbientLog.Event.Reporting

public var message: String {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(important) Is this useful to keep at all if the macro gives us access to the JSON and we can format it on ask?

"\(kind): \(value.ambientDescription)"
}
}
}

public typealias AmbientEvent = AmbientLog.Event

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO not useful, let's remove and just use the full type qualification.

Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@

override public func event(for notification: Notification) -> AmbientEvent? {
Self.values[notification.name].map {
AmbientEvent(kind: .appLifecycle, value: ["phase": .string($0)])
AmbientEvent(
kind: .restricted(.technicalState, .appLifecycle),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be restricted, it's not PII or otherwise identifiable?

value: .restricted(.domainValue, ["phase": .string($0)]),
level: .restricted(.technicalState, .info),
reporting: .restricted(.technicalState, .state),
)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// Logs Low Power Mode at start and on every transition — background work
/// behaves differently under it, which matters when diagnosing "it only
/// breaks sometimes".
public final class LowPowerModeAmbientSource: NotificationAmbientSource {

Check warning on line 6 in Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macOS)

class 'LowPowerModeAmbientSource' must restate inherited '@unchecked Sendable' conformance

Check warning on line 6 in Shared/Periscope/PeriscopeCore/Sources/Ambient/LowPowerModeAmbientSource.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macOS)

class 'LowPowerModeAmbientSource' must restate inherited '@unchecked Sendable' conformance
override public var observedNames: [Notification.Name] {
[.NSProcessInfoPowerStateDidChange]
}
Expand All @@ -22,6 +22,11 @@

private static func currentEvent() -> AmbientEvent {
let enabled = ProcessInfo.processInfo.isLowPowerModeEnabled
return AmbientEvent(kind: .powerMode, value: ["low-power": .bool(enabled)])
return AmbientEvent(
kind: .restricted(.technicalState, .powerMode),
value: .restricted(.domainValue, ["low-power": .bool(enabled)]),
level: .restricted(.technicalState, .info),
reporting: .restricted(.technicalState, .state),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@
// `.occurrence`: the app isn't "in a memory warning" afterwards,
// so this must not stick to every later record's snapshot.
AmbientEvent(
kind: .memory,
value: ["pressure": .string("warning")],
level: .warning,
reporting: .occurrence,
kind: .restricted(.technicalState, .memory),
value: .restricted(.domainValue, ["pressure": .string("warning")]),
level: .restricted(.technicalState, .warning),
reporting: .restricted(.technicalState, .occurrence),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,14 @@ public final class NetworkPathAmbientSource: AmbientEventSource {
return true
}
guard changed else { return }
log { AmbientEvent(kind: .network, value: value) }
log {
AmbientEvent(
kind: .restricted(.technicalState, .network),
value: .restricted(.domainValue, value),
level: .restricted(.technicalState, .info),
reporting: .restricted(.technicalState, .state),
)
}
}

private static func describe(_ path: NWPath) -> [String: AmbientValue] {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
/// Logs the thermal state at start and on every change; `serious` and
/// `critical` log at `.warning` since the system is about to start
/// throttling.
public final class ThermalStateAmbientSource: NotificationAmbientSource {

Check warning on line 6 in Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macOS)

class 'ThermalStateAmbientSource' must restate inherited '@unchecked Sendable' conformance

Check warning on line 6 in Shared/Periscope/PeriscopeCore/Sources/Ambient/ThermalStateAmbientSource.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macOS)

class 'ThermalStateAmbientSource' must restate inherited '@unchecked Sendable' conformance
override public var observedNames: [Notification.Name] {
[ProcessInfo.thermalStateDidChangeNotification]
}
Expand Down Expand Up @@ -43,9 +43,10 @@
logLevel = .info
}
return AmbientEvent(
kind: .thermalState,
value: ["level": .string(level)],
level: logLevel,
kind: .restricted(.technicalState, .thermalState),
value: .restricted(.domainValue, ["level": .string(level)]),
level: .restricted(.technicalState, logLevel),
reporting: .restricted(.technicalState, .state),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,11 @@
}

extension Log {
/// The ambient logger, typed to `Event`: the context bound by the
/// The ambient logger, typed to `Scope`: the context bound by the
/// nearest enclosing ``withContext(isolation:_:)``, or a root logger on
/// ``Periscope/shared`` when none is bound. Freeform helpers use
/// `Log<Message>.current`.
public static var current: Log<Event> {
public static var current: Log<Scope> {
guard let context = AmbientLogContext.current else {
return Log()
}
Expand All @@ -34,7 +34,7 @@
isolation: isolated (any Actor)? = #isolation,
_ body: () async throws -> R,
) async rethrows -> R {
try await AmbientLogContext.$current.withValue(

Check warning on line 37 in Shared/Periscope/PeriscopeCore/Sources/Context/AmbientLogContext.swift

View workflow job for this annotation

GitHub Actions / Build & Test (macOS)

'withValue(_:operation:isolation:file:line:)' is deprecated: Prefer the 'nonisolated(nonsending)' overload with stricter execution on caller context semantics: withValue(_:operation:file:line:) [#DeprecatedDeclaration]
ambientContext(),
operation: body,
isolation: isolation,
Expand Down
Loading
Loading