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
14 changes: 14 additions & 0 deletions examples/demo/mapture.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,20 @@ tags:
- customer-facing
- pci

facets:
event.type:
label: Event Type
values:
- sync
- async
- queue
- event-bus
db.type:
label: Database Type
values:
- tenant
- shared

teams:
- id: team-commerce
name: Commerce Team
Expand Down
1 change: 1 addition & 0 deletions examples/demo/src/go/ordersdb/ordersdb.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@
// @arch.name Orders Database
// @arch.domain orders
// @arch.owner team-commerce
// @arch.db.type tenant
package ordersdb
1 change: 1 addition & 0 deletions examples/demo/src/php/CheckoutService.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ public function placeOrder(int $orderId): void
* @event.owner team-commerce
* @event.producer App\Orders\CheckoutService::placeOrder
* @event.phase post-commit
* @event.event.type async
* @event.tags pci
*/
// $bus->dispatch(new OrderPlaced($orderId));
Expand Down
1 change: 1 addition & 0 deletions examples/demo/src/ts/PaymentApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export class PaymentApiClient {}
* @event.role listener
* @event.domain billing
* @event.consumer capture_payment
* @event.event.type async
* @event.tags customer-facing
*/
export function handleCapturePayment(): void {}
14 changes: 14 additions & 0 deletions src/internal/bootstrap/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,20 @@ func renderConfig(config initConfig) string {
b.WriteString("# tags:\n")
b.WriteString("# - critical-path\n")
b.WriteString("# - customer-facing\n\n")
b.WriteString("# Optional direct-only categorical facets for nodes and events.\n")
b.WriteString("# facets:\n")
b.WriteString("# event.type:\n")
b.WriteString("# label: Event Type\n")
b.WriteString("# values:\n")
b.WriteString("# - sync\n")
b.WriteString("# - async\n")
b.WriteString("# - queue\n")
b.WriteString("# - event-bus\n")
b.WriteString("# db.type:\n")
b.WriteString("# label: Database Type\n")
b.WriteString("# values:\n")
b.WriteString("# - tenant\n")
b.WriteString("# - shared\n\n")
b.WriteString("teams:\n")
b.WriteString(" - id: team-commerce\n")
b.WriteString(" name: Commerce Team\n")
Expand Down
55 changes: 55 additions & 0 deletions src/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type Config struct {
Version int `json:"version"`
Catalog Catalog `json:"catalog"`
Tags []string `json:"tags,omitempty"`
Facets Facets `json:"facets,omitempty"`
Teams []Team `json:"teams,omitempty"`
Domains []Domain `json:"domains,omitempty"`
Scan Scan `json:"scan"`
Expand All @@ -33,6 +34,15 @@ type Catalog struct {
Dir string `json:"dir"`
}

// Facets is a repo-wide registry of direct-only categorical metadata.
type Facets map[string]FacetDefinition

// FacetDefinition defines one allowed categorical dimension.
type FacetDefinition struct {
Label string `json:"label"`
Values []string `json:"values"`
}

// Team is an inline team catalog entry defined in mapture.yaml.
type Team struct {
ID string `json:"id"`
Expand Down Expand Up @@ -151,6 +161,9 @@ func Load(path string) (*Config, error) {
if err := validateTagVocabulary(cfg.Tags); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if err := validateFacetDefinitions(cfg.Facets); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
if !cfg.Languages.PHP && !cfg.Languages.Go && !cfg.Languages.TypeScript && !cfg.Languages.JavaScript {
return nil, fmt.Errorf("%s: at least one language must be enabled", path)
}
Expand Down Expand Up @@ -196,6 +209,11 @@ func (c *Config) applyDefaults() {
for index := range c.Domains {
c.Domains[index].Tags = normalizeTags(c.Domains[index].Tags)
}
for id, definition := range c.Facets {
definition.Label = strings.TrimSpace(definition.Label)
definition.Values = normalizeFacetValues(definition.Values)
c.Facets[id] = definition
}
}

func validateTagVocabulary(tags []string) error {
Expand All @@ -209,6 +227,27 @@ func validateTagVocabulary(tags []string) error {
return nil
}

func validateFacetDefinitions(facets Facets) error {
for id, definition := range facets {
if strings.TrimSpace(definition.Label) == "" {
return fmt.Errorf("facet %q is missing label", id)
}
if len(definition.Values) == 0 {
return fmt.Errorf("facet %q must define at least one value", id)
}

seen := make(map[string]struct{}, len(definition.Values))
for _, value := range definition.Values {
if _, exists := seen[value]; exists {
return fmt.Errorf("facet %q has duplicate value %q", id, value)
}
seen[value] = struct{}{}
}
}

return nil
}

func normalizeTags(tags []string) []string {
if len(tags) == 0 {
return nil
Expand All @@ -230,3 +269,19 @@ func normalizeTags(tags []string) []string {
sort.Strings(normalized)
return normalized
}

func normalizeFacetValues(values []string) []string {
if len(values) == 0 {
return nil
}

normalized := make([]string, 0, len(values))
for _, value := range values {
value = strings.TrimSpace(strings.ToLower(value))
if value == "" {
continue
}
normalized = append(normalized, value)
}
return normalized
}
101 changes: 101 additions & 0 deletions src/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,104 @@ languages:
t.Fatalf("expected malformed tag error to mention invalid value, got %v", err)
}
}

func TestLoadAcceptsFacetDefinitions(t *testing.T) {
t.Parallel()

root := t.TempDir()
path := filepath.Join(root, "mapture.yaml")
content := `version: 1
facets:
event.type:
label: Event Type
values:
- sync
- async
db.type:
label: Database Type
values:
- tenant
- shared
scan:
include:
- ./src
languages:
go: true
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

cfg, err := Load(path)
if err != nil {
t.Fatalf("Load returned error: %v", err)
}

if cfg.Facets["event.type"].Label != "Event Type" {
t.Fatalf("unexpected facet label: %+v", cfg.Facets)
}
if got := strings.Join(cfg.Facets["db.type"].Values, ","); got != "tenant,shared" {
t.Fatalf("unexpected facet values: %q", got)
}
}

func TestLoadRejectsDuplicateFacetValues(t *testing.T) {
t.Parallel()

root := t.TempDir()
path := filepath.Join(root, "mapture.yaml")
content := `version: 1
facets:
event.type:
label: Event Type
values:
- async
- async
scan:
include:
- ./src
languages:
go: true
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

_, err := Load(path)
if err == nil {
t.Fatal("expected duplicate facet values to fail")
}
if !strings.Contains(err.Error(), `facet "event.type" has duplicate value "async"`) {
t.Fatalf("unexpected error: %v", err)
}
}

func TestLoadRejectsMalformedFacetKey(t *testing.T) {
t.Parallel()

root := t.TempDir()
path := filepath.Join(root, "mapture.yaml")
content := `version: 1
facets:
EventType:
label: Event Type
values:
- async
scan:
include:
- ./src
languages:
go: true
`
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}

_, err := Load(path)
if err == nil {
t.Fatal("expected malformed facet key to fail")
}
if !strings.Contains(err.Error(), "EventType") {
t.Fatalf("expected malformed facet key error to mention invalid value, got %v", err)
}
}
51 changes: 41 additions & 10 deletions src/internal/exporter/jgf/jgf.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,17 @@ type Node struct {

// NodeMetadata carries Mapture-specific node details inside JGF.
type NodeMetadata struct {
ID string `json:"id"`
Type string `json:"type"`
Domain string `json:"domain,omitempty"`
Owner string `json:"owner,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Symbol string `json:"symbol,omitempty"`
Summary string `json:"summary,omitempty"`
Tags []string `json:"tags,omitempty"`
EffectiveTags []string `json:"effectiveTags,omitempty"`
ID string `json:"id"`
Type string `json:"type"`
Domain string `json:"domain,omitempty"`
Owner string `json:"owner,omitempty"`
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Symbol string `json:"symbol,omitempty"`
Summary string `json:"summary,omitempty"`
Tags []string `json:"tags,omitempty"`
EffectiveTags []string `json:"effectiveTags,omitempty"`
Facets map[string]string `json:"facets,omitempty"`
}

// Edge is a single directed JGF edge entry.
Expand Down Expand Up @@ -106,6 +107,7 @@ type Source struct {
// Catalog contains the team/domain metadata needed by downstream tools.
type Catalog struct {
Tags []string `json:"tags,omitempty"`
Facets config.Facets `json:"facets,omitempty"`
Teams []catalog.Team `json:"teams"`
Domains []catalog.Domain `json:"domains"`
}
Expand Down Expand Up @@ -215,6 +217,7 @@ func Build(opts BuildOptions) (*Document, error) {
Summary: node.Summary,
Tags: append([]string(nil), node.Tags...),
EffectiveTags: append([]string(nil), node.EffectiveTags...),
Facets: cloneFacetAssignments(node.Facets),
},
}
}
Expand Down Expand Up @@ -279,6 +282,7 @@ func Build(opts BuildOptions) (*Document, error) {
},
Catalog: Catalog{
Tags: append([]string(nil), opts.Config.Tags...),
Facets: cloneFacetDefinitions(opts.Config.Facets),
Teams: teams,
Domains: domains,
},
Expand All @@ -302,6 +306,33 @@ func Build(opts BuildOptions) (*Document, error) {
}, nil
}

func cloneFacetAssignments(values map[string]string) map[string]string {
if len(values) == 0 {
return nil
}

cloned := make(map[string]string, len(values))
for key, value := range values {
cloned[key] = value
}
return cloned
}

func cloneFacetDefinitions(facets config.Facets) config.Facets {
if len(facets) == 0 {
return nil
}

cloned := make(config.Facets, len(facets))
for id, definition := range facets {
cloned[id] = config.FacetDefinition{
Label: definition.Label,
Values: append([]string(nil), definition.Values...),
}
}
return cloned
}

// BuildProject runs the config/catalog/scan/validate pipeline and returns a JGF export.
func BuildProject(configPath string, opts ProjectOptions) (*Document, error) {
cfg, err := config.Load(configPath)
Expand Down
28 changes: 26 additions & 2 deletions src/internal/exporter/jgf/testdata/demo.golden.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,10 @@
"effectiveTags": [
"critical-path",
"customer-facing"
]
],
"facets": {
"db.type": "tenant"
}
}
},
"event:order.placed": {
Expand All @@ -48,7 +51,10 @@
"critical-path",
"customer-facing",
"pci"
]
],
"facets": {
"event.type": "async"
}
}
},
"service:checkout-service": {
Expand Down Expand Up @@ -125,6 +131,24 @@
"customer-facing",
"pci"
],
"facets": {
"db.type": {
"label": "Database Type",
"values": [
"tenant",
"shared"
]
},
"event.type": {
"label": "Event Type",
"values": [
"sync",
"async",
"queue",
"event-bus"
]
}
},
"teams": [
{
"id": "team-billing",
Expand Down
Loading