diff --git a/.gds/bundle.lock.yaml b/.gds/bundle.lock.yaml index 644b6ae..8b570df 100644 --- a/.gds/bundle.lock.yaml +++ b/.gds/bundle.lock.yaml @@ -5,14 +5,14 @@ bundle: version: "0.4.0-dev" release_sequence: 0 channel: "development" - source_tree_digest: "sha256:4983ada020ddf83bc417830cd692d0abc0c804bb00cd4da7e0bd3539699d3c6f" - digest: "sha256:3198ff9cf30f09b88230e9e5772e658760d1bce3f374914ba8cf80e0ca4b2c22" + source_tree_digest: "sha256:16b3bc2c9e627bc1068197e652c41ecc3529385e8c97356c4d21262621e4be23" + digest: "sha256:7bbf0c6aad8a1628d64d4ded7f5bda6fea8600a0f858de255e43188a9cd88849" projection: - input_digest: "sha256:70a05ee495fb5fc571db71d52f16739575e468def2c5bf13781438806b882c84" - output_digest: "sha256:58f8443e4b6dced0eb87255b17a207039b1bcc8dd86569c4cbfb0a14faa2ab37" + input_digest: "sha256:4779d11659c59942a16e3169848fac07866524eaf312a2d33316ffe62acf1374" + output_digest: "sha256:39450ad1421046ffd3807d8c72cf4ab25d08f4f29e70ed11e6919bd7f6c57789" files: - path: ".gds/compiled-policy.json" digest: "sha256:78d09606bb4168d74bce1f50ab62b46a7ded34652c6b23af1badfd26dd060e94" - path: ".github/workflows/gds-ci.yml" - digest: "sha256:57f868a1172afeaf4844d95fb9b0eece46bf07c3d3101cd9158f82e0692d3a37" + digest: "sha256:1481d86ea53eb7f9ef7ff77fbfb79ced779be7897b6190d14d98e30d66674801" diff --git a/.github/workflows/gds-ci.yml b/.github/workflows/gds-ci.yml index 76b4dff..c8f9baf 100644 --- a/.github/workflows/gds-ci.yml +++ b/.github/workflows/gds-ci.yml @@ -1,8 +1,8 @@ # GENERATED FILE - DO NOT EDIT DIRECTLY # generator: gds # bundle: 0.4.0-dev -# source-tree-digest: sha256:4983ada020ddf83bc417830cd692d0abc0c804bb00cd4da7e0bd3539699d3c6f -# input-digest: sha256:70a05ee495fb5fc571db71d52f16739575e468def2c5bf13781438806b882c84 +# source-tree-digest: sha256:16b3bc2c9e627bc1068197e652c41ecc3529385e8c97356c4d21262621e4be23 +# input-digest: sha256:4779d11659c59942a16e3169848fac07866524eaf312a2d33316ffe62acf1374 # output-digest: sha256:01fb4854784be9e4564bcc84e70786484b370879be5e5ab1dd49f8b73ea2dea4 # edit-source: # - .gds/repository.yaml diff --git a/core/estate/index_test.go b/core/estate/index_test.go index 0aacec6..c678d90 100644 --- a/core/estate/index_test.go +++ b/core/estate/index_test.go @@ -182,3 +182,42 @@ func TestIdentityIndexAcceptsDeclaredPackageConsumption(t *testing.T) { t.Fatalf("declared package consumption must pass, got %+v", findings) } } + +// A generated-from edge names the repository that renders this one. It is not a +// dependency: nothing is vendored, pinned or executed from the generator, so it +// must never appear as a consumer edge and must not demand a module.consumption +// declaration the way the three consumer types do. +func TestBuildIdentityIndexRecordsGeneratedFromWithoutDerivingAConsumer(t *testing.T) { + generatorID := "repo_01JEXAMPZ0000000000000000G" + renderedID := "repo_01JEXAMPZ0000000000000000H" + generator := indexedAnchor(generatorID, 10, "owner", "setup-systems") + rendered := indexedAnchor(renderedID, 11, "owner", "claude-setup-system") + rendered.Relationships = []domain.Relationship{{ + Type: "generated-from", Target: generatorID, + }} + input := []IndexedRepository{{Path: "/rendered", Anchor: rendered}, {Path: "/generator", Anchor: generator}} + index, findings := BuildIdentityIndex(input, true) + if len(findings) != 0 { + t.Fatalf("generated-from must not produce findings: %+v", findings) + } + if len(index.Consumers) != 0 { + t.Fatalf("generated-from must not derive a consumer edge: %+v", index.Consumers) + } + if len(index.Relationships) != 1 || index.Relationships[0].Type != "generated-from" || + index.Relationships[0].Source != renderedID || index.Relationships[0].Target != generatorID { + t.Fatalf("generated-from edge not recorded: %+v", index.Relationships) + } +} + +// The edge is declared on the rendered repository, so an absent generator is a +// missing target like any other typed relationship rather than a silent pass. +func TestBuildIdentityIndexRejectsGeneratedFromWithAbsentGenerator(t *testing.T) { + renderedID := "repo_01JEXAMPZ0000000000000000H" + missingID := "repo_01JEXAMPZ0000000000000000G" + rendered := indexedAnchor(renderedID, 11, "owner", "claude-setup-system") + rendered.Relationships = []domain.Relationship{{Type: "generated-from", Target: missingID}} + _, findings := BuildIdentityIndex([]IndexedRepository{{Path: "/rendered", Anchor: rendered}}, true) + if len(findings) != 1 || findings[0].Code != "GDS_IDENTITY_INDEX_TARGET_MISSING" { + t.Fatalf("expected one missing-target finding, got %+v", findings) + } +} diff --git a/docs/adr/0005-typed-relationships.md b/docs/adr/0005-typed-relationships.md index a9663dd..7cd3e8f 100644 --- a/docs/adr/0005-typed-relationships.md +++ b/docs/adr/0005-typed-relationships.md @@ -18,12 +18,26 @@ Relationships are explicit typed edges, including: - portfolio membership; - fork-of; +- generated-from; - git-submodule consumer; - package consumer; - device checkout; - worktree; - embedded context source. +`generated-from` is declared on the rendered repository and names the repository +that renders it. It is not a dependency edge: nothing is vendored, pinned or +executed from the generator, so it derives no consumer and requires no +`module.consumption` declaration. Declaring it in the other direction was +rejected for the same reason a parent field was: one generator would carry a +list of every tree it renders, and that list is a second copy of a fact the +renderer already owns. + +The direction also decides whether the fact can be checked. A rendered tree +asserting "I came from X" can be verified against the generator's own output; a +generator asserting "I produce these seven" has nothing local to check it +against, and drifts exactly when a tree is added or removed. + Provider owner/name and local paths are mutable locators with alias/history records. diff --git a/schemas/v1/repository.schema.json b/schemas/v1/repository.schema.json index 1ed8a54..34cb6d2 100644 --- a/schemas/v1/repository.schema.json +++ b/schemas/v1/repository.schema.json @@ -581,6 +581,7 @@ "package-consumer", "workflow-module-consumer", "fork-of", + "generated-from", "device-checkout", "worktree", "embedded-context-source" diff --git a/tests/fixtures/schemas/v1/cases.json b/tests/fixtures/schemas/v1/cases.json index 4d2a6ef..d72a310 100644 --- a/tests/fixtures/schemas/v1/cases.json +++ b/tests/fixtures/schemas/v1/cases.json @@ -23,6 +23,12 @@ "path": "valid-audit-snapshot.json", "valid": true }, + { + "id": "valid-generated-repository", + "schema": "repository", + "path": "valid-generated-repository.yaml", + "valid": true + }, { "id": "valid-repository", "schema": "repository", diff --git a/tests/fixtures/schemas/v1/valid-generated-repository.yaml b/tests/fixtures/schemas/v1/valid-generated-repository.yaml new file mode 100644 index 0000000..e125d8d --- /dev/null +++ b/tests/fixtures/schemas/v1/valid-generated-repository.yaml @@ -0,0 +1,57 @@ +schema_version: 1 + +repository: + id: "repo_01JEXAMPZ0000000000000000C" + display_name: "example-rendered-tree" + roles: + - "project" + lifecycle: "active" + +provider: + type: "github" + installation: "installation:github-personal" + repository_id: 123456789 + owner: "example-user" + name: "example-rendered-tree" + +classification: + portfolios: + - "portfolio:personal-projects" + visibility_contract: "private" + data_classification: "private" + +policy: + profiles: + - "repository-default" + rollout_ring: "standard" + +git: + default_branch: "main" + integration: "pull-request" + branch_model: "task-branches" + handoff_pr: "preferred" + cleanup: "merged-only" + +verification: + commands: + test: + - "python -m unittest" + required: + - "test" + +agent: + context_profile: "project-default" + generated_agents: true + serena: + enabled: true + provenance_required: true + +release: + mode: "none" + +# A rendered tree names the repository that renders it. The edge is declared +# here rather than on the generator so that adding or removing a tree never +# requires a commit to the generator's own anchor. +relationships: + - type: "generated-from" + target: "repo_01JEXAMPZ0000000000000000D"