Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9198ded
feat(temporal): detect Go in-workflow query/signal/update handler dec…
avfirsov Jun 11, 2026
2fc888f
feat(temporal): resolve activity/workflow names from env-var-with-def…
avfirsov Jun 12, 2026
87c4baf
feat(temporal): also detect SetQueryHandlerWithOptions / GetSignalCha…
avfirsov Jun 12, 2026
82e3140
feat(temporal): detect outbound signal-send / query-call against runn…
avfirsov Jun 12, 2026
c7e0597
Merge branch 'pr79-review' into feat/temporal-cluster
zzet Jun 12, 2026
0e00c0a
Merge branch 'pr78-review' into feat/temporal-cluster
zzet Jun 12, 2026
586af38
Merge branch 'pr81-review' into feat/temporal-cluster
zzet Jun 12, 2026
e88a864
fix(temporal): correct env-default name resolution data-flow
zzet Jun 12, 2026
770d0ac
test(temporal): add indexer e2e for outbound signal-send / query-call
zzet Jun 12, 2026
1434e2a
fix(temporal): gate stub-call resolution by caller language
zzet Jun 12, 2026
da00bb9
perf(temporal): single-scan resolve, early-out, conditional role writ…
zzet Jun 12, 2026
3a73c1d
feat(temporal): honor RegisterActivityWithOptions Name override
zzet Jun 12, 2026
fec4346
feat(temporal): promote RegisterActivities struct methods to activities
zzet Jun 12, 2026
59086ea
feat(temporal): detect and resolve the service-side workflow-start fa…
zzet Jun 12, 2026
f063d90
feat(temporal): recognise an aliased workflow-package import
zzet Jun 12, 2026
25d13f7
docs(temporal): document the via=temporal.* edge taxonomy
zzet Jun 12, 2026
89e7aa4
feat(temporal): retain constant values + dereference const-named disp…
zzet Jun 12, 2026
c32c9c7
feat(temporal): compute Java canonical Temporal names (G2)
zzet Jun 12, 2026
7e451a1
feat(temporal): cross-language Java->Go workflow join (G1)
zzet Jun 12, 2026
4e28372
feat(temporal): Java consumer-side signal-send / query-call edges
zzet Jun 12, 2026
83ef21e
feat(temporal): detect dispatch wrappers, suppress parameter-named stubs
zzet Jun 12, 2026
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
17 changes: 16 additions & 1 deletion docs/contracts.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ contracts {action: "check"} # find mismatches and orphans
| **WebSocket** | Event emit/listen patterns | `emit()` | `on()` |
| **Env vars** | `os.Getenv`, `process.env`, `.env` files | `Setenv` / `.env` | `Getenv` / `process.env` |
| **OpenAPI** | Swagger/OpenAPI spec files | Spec paths | (linked to HTTP routes) |
| **Temporal workflows** | Go SDK `worker.RegisterActivity` / Java `@ActivityInterface` / `@WorkflowInterface` annotations | Activity / workflow function (carries `temporal_role` Meta) | `workflow.ExecuteActivity` / `ExecuteChildWorkflow` / `newActivityStub` calls |
| **Temporal workflows** | Go SDK `worker.RegisterActivity(WithOptions)` / `RegisterActivities` / Java `@ActivityInterface` / `@WorkflowInterface` annotations | Activity / workflow function (carries `temporal_role` Meta) | `workflow.ExecuteActivity` / `ExecuteChildWorkflow` / `client.ExecuteWorkflow` / handler & signal/query calls |

Contracts are normalized to canonical IDs (e.g., `http::GET::/api/users/{id}`) and matched across repos to detect orphan providers/consumers and mismatches.

## Temporal edge taxonomy

The Go and Java extractors tag Temporal call sites with a `via` Meta value on the `EdgeCalls` edge (plus `temporal_kind` and `temporal_name`); `ResolveTemporalCalls` rewrites the resolvable ones (`temporal.stub` / `temporal.start`) to the registered handler / workflow node. Because they are ordinary `EdgeCalls`, `find_usages` / `get_callers` / `explain_change_impact` traverse them with no temporal-specific code.

| `via` | Direction | Emitted from | `temporal_kind` | Resolved? |
|-------|-----------|--------------|-----------------|-----------|
| `temporal.register` | provider tag | `worker.RegisterActivity(WithOptions)` / `RegisterWorkflow(WithOptions)` / `RegisterActivities` | `activity` / `workflow` | indexed, not rewritten |
| `temporal.stub` | workflow → activity / child-workflow | `workflow.ExecuteActivity` / `ExecuteLocalActivity` / `ExecuteChildWorkflow` | `activity` / `workflow` | yes → registered handler |
| `temporal.start` | service → workflow | `client.ExecuteWorkflow` / `SignalWithStartWorkflow` | `workflow` | yes → registered workflow |
| `temporal.handler` | workflow exposes | `workflow.SetQueryHandler` / `GetSignalChannel` / `SetUpdateHandler` (+`WithOptions`) | `query` / `signal` / `update` | provider edge |
| `temporal.signal-send` | sender → running workflow | `workflow.SignalExternalWorkflow` / `client.SignalWorkflow` | `signal` | consumer edge |
| `temporal.query-call` | caller → running workflow | `client.QueryWorkflow` | `query` | consumer edge |

Extra Meta on these edges: `temporal_registered_name` (the `RegisterOptions{Name}` override that is the actual dispatch key), `temporal_register_plural` (a `RegisterActivities(&Struct{})` registration whose exported methods are each promoted), and `temporal_name_origin=env_default` (a dispatch name resolved from an env-var-with-literal-default, landed at the speculative tier). Node roles are stamped as `temporal_role` (`activity` / `workflow` / `activity_interface` / `workflow_interface` / `signal` / `query` / `update`). Aliased `import wf "go.temporal.io/sdk/workflow"` receivers are canonicalised before detection.
80 changes: 80 additions & 0 deletions internal/graph/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,13 @@ type Graph struct {
// blameEnrich is the in-memory blame-enrichment sidecar.
blameEnrichMu sync.Mutex
blameEnrich map[string]BlameEnrichment

// constValues is the in-memory implementation of the ConstantValue*
// capability: a KindConstant node's literal value keyed by node id,
// alongside its owning file (for file-scoped eviction) and repo
// prefix. Guarded by constValuesMu.
constValuesMu sync.Mutex
constValues map[string]constValueEntry
}

// cloneShingleEntry is one in-memory clone_shingles row: the owning
Expand All @@ -503,6 +510,14 @@ type cloneShingleEntry struct {
shingles []uint64
}

// constValueEntry is one in-memory constant_values row: the owning repo
// prefix and file (for file-scoped eviction) plus the literal value.
type constValueEntry struct {
repoPrefix string
filePath string
value string
}

// Compile-time assertions that the in-memory *Graph satisfies the
// optional per-symbol clone-shingle persistence capabilities, so the
// conformance suite exercises the same code path against both backends.
Expand All @@ -519,6 +534,8 @@ var (
_ BlameEnrichmentReader = (*Graph)(nil)
_ ReleaseEnrichmentWriter = (*Graph)(nil)
_ ReleaseEnrichmentReader = (*Graph)(nil)
_ ConstantValueWriter = (*Graph)(nil)
_ ConstantValueReader = (*Graph)(nil)
)

// New creates an empty graph.
Expand Down Expand Up @@ -611,6 +628,69 @@ func (g *Graph) LoadCloneShingles(repoPrefix string) (map[string][]uint64, error
return out, nil
}

// BulkSetConstantValues is the in-memory ConstantValueWriter. It records
// every (nodeID -> value) row for one repo prefix, replacing any prior
// value in place. Empty input is a no-op.
func (g *Graph) BulkSetConstantValues(repoPrefix string, rows []ConstantValueRow) error {
if len(rows) == 0 {
return nil
}
g.constValuesMu.Lock()
defer g.constValuesMu.Unlock()
if g.constValues == nil {
g.constValues = make(map[string]constValueEntry, len(rows))
}
for _, r := range rows {
if r.NodeID == "" {
continue
}
g.constValues[r.NodeID] = constValueEntry{repoPrefix: repoPrefix, filePath: r.FilePath, value: r.Value}
}
return nil
}

// DeleteConstantValuesByFiles is the in-memory ConstantValueWriter delete
// side: it drops every row whose file is in the supplied set for the given
// repo prefix, so a reindex of those files replaces their values cleanly.
func (g *Graph) DeleteConstantValuesByFiles(repoPrefix string, files []string) error {
if len(files) == 0 {
return nil
}
fileSet := make(map[string]struct{}, len(files))
for _, f := range files {
fileSet[f] = struct{}{}
}
g.constValuesMu.Lock()
defer g.constValuesMu.Unlock()
for id, entry := range g.constValues {
if entry.repoPrefix != repoPrefix {
continue
}
if _, ok := fileSet[entry.filePath]; ok {
delete(g.constValues, id)
}
}
return nil
}

// ConstantValuesByNodeIDs is the in-memory ConstantValueReader. It returns
// the recorded values for the supplied node ids (omitting ids with no
// recorded value). Always returns a non-nil map and never an error.
func (g *Graph) ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error) {
out := make(map[string]string, len(nodeIDs))
if len(nodeIDs) == 0 {
return out, nil
}
g.constValuesMu.Lock()
defer g.constValuesMu.Unlock()
for _, id := range nodeIDs {
if entry, ok := g.constValues[id]; ok {
out[id] = entry.value
}
}
return out, nil
}

// BulkSetChurn is the in-memory ChurnEnrichmentWriter. ChurnEnrichment
// is a flat value type, so a map store needs no deep copy.
func (g *Graph) BulkSetChurn(repoPrefix string, rows []ChurnEnrichment) error {
Expand Down
33 changes: 33 additions & 0 deletions internal/graph/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -986,6 +986,39 @@ type CloneShingleReader interface {
LoadCloneShingles(repoPrefix string) (map[string][]uint64, error)
}

// ConstantValueWriter is an optional capability backends MAY implement
// to persist a KindConstant node's literal value (string / numeric)
// keyed by node id, in a queryable sidecar rather than the gob-encoded
// Meta blob (which is unindexable and decoded on every node load). The
// resolver reads these to dereference a const-identifier dispatch name
// (e.g. `const ChargeCardActivity = "ChargeCard"`) to its value across
// files. It is the const-value sibling of CloneShingleWriter.
//
// rows is keyed on the const node id; the value is the literal text.
// Empty input is a no-op. DeleteConstantValuesByFiles drops the rows
// for a set of re-indexed / evicted files so the snapshot stays in step
// with the live graph.
type ConstantValueWriter interface {
BulkSetConstantValues(repoPrefix string, rows []ConstantValueRow) error
DeleteConstantValuesByFiles(repoPrefix string, files []string) error
}

// ConstantValueReader is the read side of ConstantValueWriter. Returns
// the recorded constant values for the supplied node ids as a fresh map
// (node id → value); ids with no recorded value are omitted. A nil /
// empty ids slice returns an empty map.
type ConstantValueReader interface {
ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error)
}

// ConstantValueRow is one persisted constant value: the const node id,
// its owning file (for file-scoped eviction), and the literal value.
type ConstantValueRow struct {
NodeID string
FilePath string
Value string
}

// RefFact is one durable resolved-reference fact: a reference edge from
// FromID resolved TO ToID with the provenance tier that resolved it. Persisted
// per source file so a reference's resolution is an auditable, diffable record
Expand Down
17 changes: 17 additions & 0 deletions internal/graph/store_sqlite/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,23 @@ CREATE TABLE IF NOT EXISTS clone_shingles (
shingles BLOB
) WITHOUT ROWID;

-- constant_values is the per-KindConstant literal-value sidecar: one row
-- per constant whose RHS is a string / numeric literal, keyed by node_id
-- (the join key back to nodes.id). Lifting the value out of the gob Meta
-- blob keeps it queryable (and out of the every-node-load decode path) so
-- the resolver can dereference a const-identifier dispatch name to its
-- value across files. file_path scopes per-file eviction on reindex;
-- repo_prefix scopes per-repo wipes. WITHOUT ROWID — the PK index IS the
-- table, like file_mtimes / clone_shingles.
CREATE TABLE IF NOT EXISTS constant_values (
node_id TEXT PRIMARY KEY,
repo_prefix TEXT NOT NULL DEFAULT '',
file_path TEXT NOT NULL DEFAULT '',
value TEXT NOT NULL DEFAULT ''
) WITHOUT ROWID;

CREATE INDEX IF NOT EXISTS constant_values_by_file ON constant_values(repo_prefix, file_path);

-- ref_facts is the resolved-reference sidecar: one row per reference edge
-- that resolved to a concrete target, recording the target + the provenance
-- tier that resolved it. Denormalized file_path + lang make "all reference
Expand Down
144 changes: 144 additions & 0 deletions internal/graph/store_sqlite/store_constvalues.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
package store_sqlite

import (
"github.com/zzet/gortex/internal/graph"
)

// Compile-time assertions that the SQLite Store satisfies the optional
// constant-value persistence capability. A KindConstant node's literal
// value lives in this queryable sidecar (not the gob-encoded Meta blob)
// so the resolver can dereference a const-identifier dispatch name across
// files without an unindexable per-node blob decode.
var (
_ graph.ConstantValueWriter = (*Store)(nil)
_ graph.ConstantValueReader = (*Store)(nil)
)

// constValueChunk bounds rows per multi-row INSERT (4 params/row; 80 rows
// = 320 host params, well under SQLite's 999 default).
const constValueChunk = 80

// BulkSetConstantValues persists constant values for one repo prefix in a
// single transaction, chunked under the host-parameter limit. Idempotent
// on the node_id primary key. Empty input is a no-op.
func (s *Store) BulkSetConstantValues(repoPrefix string, rows []graph.ConstantValueRow) error {
if len(rows) == 0 {
return nil
}
s.writeMu.Lock()
defer s.writeMu.Unlock()

tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op

for start := 0; start < len(rows); start += constValueChunk {
end := start + constValueChunk
if end > len(rows) {
end = len(rows)
}
batch := rows[start:end]
args := make([]any, 0, len(batch)*4)
stmt := make([]byte, 0, 96+len(batch)*16)
stmt = append(stmt, "INSERT OR REPLACE INTO constant_values (node_id, repo_prefix, file_path, value) VALUES "...)
for i, r := range batch {
if i > 0 {
stmt = append(stmt, ',')
}
stmt = append(stmt, "(?, ?, ?, ?)"...)
args = append(args, r.NodeID, repoPrefix, r.FilePath, r.Value)
}
if _, err := tx.Exec(string(stmt), args...); err != nil {
return err
}
}
return tx.Commit()
}

// DeleteConstantValuesByFiles drops all constant values sourced in the
// supplied files for one repo prefix, chunked into `file_path IN (…)`
// DELETEs. Empty input is a no-op.
func (s *Store) DeleteConstantValuesByFiles(repoPrefix string, files []string) error {
if len(files) == 0 {
return nil
}
s.writeMu.Lock()
defer s.writeMu.Unlock()

tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback() //nolint:errcheck // rollback after Commit is a no-op

for start := 0; start < len(files); start += constValueChunk {
end := start + constValueChunk
if end > len(files) {
end = len(files)
}
chunk := files[start:end]
args := make([]any, 0, len(chunk)+1)
args = append(args, repoPrefix)
stmt := make([]byte, 0, 64+len(chunk)*2)
stmt = append(stmt, "DELETE FROM constant_values WHERE repo_prefix = ? AND file_path IN ("...)
for i, f := range chunk {
if i > 0 {
stmt = append(stmt, ',')
}
stmt = append(stmt, '?')
args = append(args, f)
}
stmt = append(stmt, ')')
if _, err := tx.Exec(string(stmt), args...); err != nil {
return err
}
}
return tx.Commit()
}

// ConstantValuesByNodeIDs returns the persisted values for the supplied
// node ids (omitting ids with no recorded value). Always non-nil.
func (s *Store) ConstantValuesByNodeIDs(nodeIDs []string) (map[string]string, error) {
out := make(map[string]string, len(nodeIDs))
if len(nodeIDs) == 0 {
return out, nil
}
for start := 0; start < len(nodeIDs); start += constValueChunk {
end := start + constValueChunk
if end > len(nodeIDs) {
end = len(nodeIDs)
}
chunk := nodeIDs[start:end]
args := make([]any, 0, len(chunk))
stmt := make([]byte, 0, 64+len(chunk)*2)
stmt = append(stmt, "SELECT node_id, value FROM constant_values WHERE node_id IN ("...)
for i, id := range chunk {
if i > 0 {
stmt = append(stmt, ',')
}
stmt = append(stmt, '?')
args = append(args, id)
}
stmt = append(stmt, ')')
rows, err := s.db.Query(string(stmt), args...)
if err != nil {
return nil, err
}
for rows.Next() {
var id, val string
if err := rows.Scan(&id, &val); err != nil {
_ = rows.Close()
return nil, err
}
out[id] = val
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
_ = rows.Close()
}
return out, nil
}
Loading
Loading