jobcompat 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +15 -0
- data/README.md +32 -5
- data/docs/architecture.md +46 -6
- data/docs/implementation-plan-v0.2.1.md +17 -0
- data/docs/implementation-plan-v0.3.md +32 -0
- data/docs/release-checklist-v0.2.1.md +76 -0
- data/docs/release-checklist-v0.3.0.md +109 -0
- data/docs/release-notes-v0.2.1.md +61 -0
- data/docs/release-notes-v0.3.0.md +115 -0
- data/docs/spec-v0.2.1.md +65 -0
- data/docs/spec-v0.3.md +238 -0
- data/lib/jobcompat/aliases.rb +86 -0
- data/lib/jobcompat/analysis.rb +78 -38
- data/lib/jobcompat/canonical_worker_name.rb +35 -0
- data/lib/jobcompat/cli.rb +1 -1
- data/lib/jobcompat/config.rb +2 -6
- data/lib/jobcompat/engine.rb +104 -36
- data/lib/jobcompat/version.rb +1 -1
- data/lib/jobcompat.rb +1 -0
- metadata +11 -1
data/docs/spec-v0.2.1.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# jobcompat v0.2.1 normative delta specification
|
|
2
|
+
|
|
3
|
+
Status: normative for `0.2.1`
|
|
4
|
+
|
|
5
|
+
This document is a small delta from [`spec-v0.2.md`](spec-v0.2.md), which remains the normative `0.2.0` delta from [`spec-v0.1.md`](spec-v0.1.md). **All v0.1 and v0.2 rules remain normative unless explicitly overridden here.** v0.2.1 is a maintenance release: it adds no producer syntax, rule, finding field, uncertainty reason, or configuration key.
|
|
6
|
+
|
|
7
|
+
## 1. Versioned contracts
|
|
8
|
+
|
|
9
|
+
- Gem and CLI version: `0.2.1`.
|
|
10
|
+
- JSON `schema_version`: `2` (unchanged).
|
|
11
|
+
- `.jobcompat.yml` version: `1` (unchanged; `version: 2` remains a config error).
|
|
12
|
+
|
|
13
|
+
No user migration is required.
|
|
14
|
+
|
|
15
|
+
## 2. Canonical worker-name validation
|
|
16
|
+
|
|
17
|
+
A canonical String worker name is a String that:
|
|
18
|
+
|
|
19
|
+
1. converts safely to valid UTF-8;
|
|
20
|
+
2. parses with the supported Prism (`>= 1.9, < 2`) without diagnostics;
|
|
21
|
+
3. contains exactly one expression;
|
|
22
|
+
4. is a static `ConstantReadNode` or `ConstantPathNode` chain, not root-qualified with a leading `::`;
|
|
23
|
+
5. when flattened and joined with `::`, equals the complete String exactly.
|
|
24
|
+
|
|
25
|
+
Prism is the only grammar authority; jobcompat defines no handwritten identifier, case, combining-mark, or Unicode-range rules. Input is never trimmed. Whitespace, comments, newlines, parentheses, calls, operators, extra statements, `self::`, and any other syntax make the String non-canonical.
|
|
26
|
+
|
|
27
|
+
One internal validator applies this definition to both Client class Strings (`Sidekiq::Client.push` / `push_bulk`) and `.jobcompat.yml` `ignore[].worker`.
|
|
28
|
+
|
|
29
|
+
**Invariant:** any worker name accepted as an exact canonical String worker name by producer analysis MUST also be valid as `.jobcompat.yml` `ignore[].worker`, subject to the same canonical textual identity rules. Conversely, a configured worker name that producer analysis could not accept as a canonical String is invalid configuration.
|
|
30
|
+
|
|
31
|
+
Constant AST resolution (for example `"class" => ExportJob`, `ExportJob.perform_async`, and worker declarations) is unchanged and is not governed by this section.
|
|
32
|
+
|
|
33
|
+
## 3. Suppression semantics
|
|
34
|
+
|
|
35
|
+
Matching remains exact on `rule_id` plus the worker String. There is no Unicode normalization (no NFC, NFD, NFKC, or NFKD), case folding, namespace inference, partial, glob, or regex matching. Configured names are stored and reported exactly as written.
|
|
36
|
+
|
|
37
|
+
Visually similar but codepoint-distinct valid names remain different worker identities. For example, `ÁJob` written as `U+00C1 J o b` and as `U+0041 U+0301 J o b` are distinct: a suppression for one does not suppress a finding for the other, and the two entries are not duplicates of each other. Two entries with the same rule and exactly the same worker String remain a duplicate-entry config error.
|
|
38
|
+
|
|
39
|
+
A qualified name such as `Admin::ÁJob` suppresses only the finding whose worker is exactly that qualified name; `ÁJob` alone does not match it.
|
|
40
|
+
|
|
41
|
+
A matched suppression keeps the existing audit record (`rule_id`, exact `worker`, `reason`, `finding_count`) in JSON and text output, and a suppressed compatibility ERROR no longer contributes to exit 1.
|
|
42
|
+
|
|
43
|
+
## 4. Invalid worker-name behavior
|
|
44
|
+
|
|
45
|
+
The shared validator only decides validity. Each caller owns its failure surface:
|
|
46
|
+
|
|
47
|
+
- **Config:** a non-canonical, non-String, or invalid-encoding `ignore[].worker` is a `config_error` with the existing message `ignore[N].worker is invalid`, JSON `status: "failed"`, and exit 2. A document whose bytes Psych rejects before validation is also a `config_error` with exit 2. No raw `ArgumentError` or `EncodingError`, `internal_error`, or stack trace is produced.
|
|
48
|
+
- **Producer analysis:** a non-canonical or invalid-encoding Client class String remains `unsupported_client_payload` JC007 evidence, as in v0.2.0.
|
|
49
|
+
|
|
50
|
+
Inside the validator only expected encoding failures are treated as unsupported input; unexpected parser or implementation failures stay visible as tool failures.
|
|
51
|
+
|
|
52
|
+
Compatibility note: v0.2.0 also accepted some configured names that Ruby does not parse as constants, such as `管理::輸出` (segments that do not start with an uppercase letter). No finding could ever carry such a worker name, so those entries never suppressed anything; v0.2.1 reports them as `config_error`.
|
|
53
|
+
|
|
54
|
+
## 5. Compatibility invariants
|
|
55
|
+
|
|
56
|
+
- JC001–JC007 semantics, severities, and precedence are unchanged; there is no JC008.
|
|
57
|
+
- Exit codes are unchanged: 0 no unsuppressed compatibility ERROR, 1 compatibility ERROR, 2 tool/config/Git/parser/usage failure.
|
|
58
|
+
- JSON schema 2 structure and `unknown_reason` vocabulary are unchanged.
|
|
59
|
+
- Runtime dependencies remain `prism >= 1.9, < 2`; Ruby `>= 3.3`.
|
|
60
|
+
- Validation parses text only: no eval, `const_get`, constantize, application `require`, Rails or Sidekiq boot, Redis, source execution, or analysis-time network.
|
|
61
|
+
- Output remains byte-deterministic, including exact Unicode codepoints.
|
|
62
|
+
|
|
63
|
+
## 6. Non-goals
|
|
64
|
+
|
|
65
|
+
New Sidekiq producer APIs, SARIF, ActiveJob, keyword compatibility, payload type or Hash schema checking, Redis inspection, wrapper tracking, aliases, inheritance analysis, general dataflow, feature-flag analysis, new suppression syntax, wildcard suppressions, and regex suppressions.
|
data/docs/spec-v0.3.md
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
# jobcompat v0.3.0 delta specification
|
|
2
|
+
|
|
3
|
+
Status: normative for 0.3.0. This document extends `spec-v0.2.1.md`,
|
|
4
|
+
which retains the v0.2 and v0.1 contracts except where overridden here.
|
|
5
|
+
MUST and MUST NOT state requirements. No application Ruby is executed.
|
|
6
|
+
|
|
7
|
+
## Versioned and frozen contracts
|
|
8
|
+
|
|
9
|
+
Gem and CLI version: 0.3.0. Completed and failed JSON envelopes MUST use
|
|
10
|
+
`schema_version: 3`. Configuration version remains exactly 1; version 2 is
|
|
11
|
+
invalid. Ruby >= 3.3 and Prism >= 1.9, < 2 remain unchanged. No dependency
|
|
12
|
+
on Sidekiq is introduced. JC001-JC007 IDs, severities (JC001-JC005 ERROR,
|
|
13
|
+
JC006-JC007 WARNING), exit policy, suppression identity, and deployment
|
|
14
|
+
directions remain unchanged. ERROR requires provable structural
|
|
15
|
+
incompatibility. Unknown and unsupported evidence never establishes
|
|
16
|
+
compatibility; missing producers never establishes an empty queue.
|
|
17
|
+
Analysis reads committed Git objects and ignores working-tree edits.
|
|
18
|
+
|
|
19
|
+
## Terminology and serialization
|
|
20
|
+
|
|
21
|
+
**Serialized worker identity** is the exact String in a Sidekiq payload's
|
|
22
|
+
`"class"` field. All compatibility comparisons use that identity.
|
|
23
|
+
**Direct worker** is a canonical class recognized through the existing
|
|
24
|
+
direct `Sidekiq::Job` or legacy `Sidekiq::Worker` include and reopened-class
|
|
25
|
+
discovery. **Compatibility alias** is a statically provable constant
|
|
26
|
+
binding to another constant whose graph terminates in one recognized
|
|
27
|
+
direct worker in the same revision. **Alias terminal** is that final
|
|
28
|
+
direct worker after zero or more supported edges.
|
|
29
|
+
**Class-object producer** passes a Ruby Class object (worker APIs,
|
|
30
|
+
Setter APIs, or Client constant payloads). **Exact-string producer** passes
|
|
31
|
+
an already serialized identity through Client push or push_bulk.
|
|
32
|
+
|
|
33
|
+
For `OldJob = NewJob`, Ruby retains the Class object's canonical name
|
|
34
|
+
`NewJob`. Class-object producers through OldJob MUST therefore normalize
|
|
35
|
+
to NewJob. This applies to perform_async, perform_in, perform_at,
|
|
36
|
+
perform_bulk, all four `.set(...)` forms, and Client push/push_bulk with a
|
|
37
|
+
constant. A Client `"class" => "OldJob"` MUST retain exactly OldJob on the
|
|
38
|
+
producer side; consumer lookup MAY resolve OldJob through its alias.
|
|
39
|
+
There is no namespace prefixing or Unicode normalization of exact Strings.
|
|
40
|
+
|
|
41
|
+
The model freezes Sidekiq's Class-to-String normalization as observed in
|
|
42
|
+
[Sidekiq 8.1.6 JobUtil](https://github.com/sidekiq/sidekiq/blob/v8.1.6/lib/sidekiq/job_util.rb#L38-L55)
|
|
43
|
+
and its [Class-object producer path](https://github.com/sidekiq/sidekiq/blob/v8.1.6/lib/sidekiq/job.rb#L191-L196).
|
|
44
|
+
The [official rename FAQ](https://github.com/sidekiq/sidekiq/wiki/FAQ#how-do-i-safely-rename-a-worker)
|
|
45
|
+
describes a compatibility alias for retained old payloads. Client Strings
|
|
46
|
+
have different job-option/default semantics; their identity behavior is
|
|
47
|
+
documented here without recommending them as a migration strategy.
|
|
48
|
+
|
|
49
|
+
## Supported syntax and namespace semantics
|
|
50
|
+
|
|
51
|
+
Only Prism ConstantWriteNode and ConstantPathWriteNode with plain `=`
|
|
52
|
+
and a ConstantReadNode or static ConstantPathNode RHS after safe
|
|
53
|
+
single-expression parentheses unwrapping prove an alias. The assignment
|
|
54
|
+
MUST be a direct unconditional statement in the Program body or a valid
|
|
55
|
+
Class/Module body, with all enclosing contexts supported.
|
|
56
|
+
|
|
57
|
+
Supported examples are `OldJob = NewJob`, `OldJob = (NewJob)`,
|
|
58
|
+
`::OldJob = ::NewJob`, top-level `Admin::OldJob = Admin::NewJob`, and
|
|
59
|
+
`module Admin; OldJob = NewJob; end`. Existing declaration rules determine
|
|
60
|
+
the LHS canonical name. An unrooted qualified LHS in lexical nesting or
|
|
61
|
+
an unsupported namespace MUST NOT prove an alias. An unrooted qualified
|
|
62
|
+
RHS inside lexical nesting is unsupported_alias_assignment. Rooted RHS
|
|
63
|
+
references and qualified top-level RHS references are exact.
|
|
64
|
+
|
|
65
|
+
Unqualified RHS lookup uses the existing lexical namespace stack in
|
|
66
|
+
reverse order, then the root name. For nested Admin/Jobs, NewJob candidates
|
|
67
|
+
are Admin::Jobs::NewJob, Admin::NewJob, NewJob. The first selected-source
|
|
68
|
+
binding wins lookup; a binding with an unknown value MUST block fallback
|
|
69
|
+
to a farther known worker. No inherited-constant lookup is added.
|
|
70
|
+
|
|
71
|
+
Assignments below if/unless/case/loops/blocks/lambdas/begin/rescue/ensure,
|
|
72
|
+
modifier conditions, singleton classes, methods, or expression wrappers
|
|
73
|
+
are unsupported. Even an unconditional begin wrapper is unsupported.
|
|
74
|
+
Or/and/operator writes, multiple assignment targets, dynamic RHS,
|
|
75
|
+
ternary RHS, calls, and Class.new MUST NOT become resolved aliases.
|
|
76
|
+
Named unsupported writes enter the inventory so relevant identities can
|
|
77
|
+
warn and conflicts cannot be hidden. const_set/autoload/const_missing
|
|
78
|
+
remain outside alias discovery.
|
|
79
|
+
|
|
80
|
+
## Selected binding inventory and graph
|
|
81
|
+
|
|
82
|
+
During the existing Prism traversal, record selected-source class/module
|
|
83
|
+
declarations and named writes, plus alias candidates. Presence facts in
|
|
84
|
+
DefinedConstantIndex MUST remain separate from alias graph facts.
|
|
85
|
+
Exactly one supported alias binding and no other binding to its LHS is
|
|
86
|
+
required. Duplicate aliases, even identical ones, alias plus class/module,
|
|
87
|
+
or alias plus unsupported writes produce alias_binding_conflict. No
|
|
88
|
+
source-order winner is selected. Existing reopened direct class fragments
|
|
89
|
+
are allowed and merged as before; they are not conflicts by themselves.
|
|
90
|
+
An alias that reaches a conflicting binding MUST remain unknown.
|
|
91
|
+
Selected ambiguous constant paths also retain leaf-name conflict evidence:
|
|
92
|
+
an assignment or declaration such as `self::OldJob = OtherJob` cannot be
|
|
93
|
+
ignored when its leaf may affect an alias or a reached terminal. These
|
|
94
|
+
facts conservatively block resolution without evaluating the receiver.
|
|
95
|
+
Excluded or differently named ambiguous facts do not enter this proof.
|
|
96
|
+
|
|
97
|
+
Each revision resolves its own graph independently. Finite chains have
|
|
98
|
+
no arbitrary length cap. Iterative traversal detects cycles without
|
|
99
|
+
recursive stack overflow. Resolved chain order follows edges, for example
|
|
100
|
+
`[OldJob, MiddleJob, NewJob]`. A cycle chain closes with its repeated
|
|
101
|
+
identity, for example `[OldJob, MiddleJob, OldJob]`. A missing or
|
|
102
|
+
unrecognized terminal is alias_target_unresolved. Failed suffixes
|
|
103
|
+
propagate their reason and proof to aliases depending on them.
|
|
104
|
+
|
|
105
|
+
Only selected files supply trusted edges. Excluded declarations/writes
|
|
106
|
+
may block absence through the presence pass, but MUST NOT resolve aliases.
|
|
107
|
+
An excluded OldJob alias yields outside_analysis_scope for a removal
|
|
108
|
+
transition; a selected alias targeting an excluded worker yields
|
|
109
|
+
alias_target_unresolved. File movement alone does not alter identity
|
|
110
|
+
compatibility. All keys preserve Prism's exact UTF-8 codepoints.
|
|
111
|
+
|
|
112
|
+
## Effective consumers and relevant identities
|
|
113
|
+
|
|
114
|
+
Snapshot workers remain directly recognized workers. An effective
|
|
115
|
+
consumer map resolves serialized identity S to either direct worker S or
|
|
116
|
+
the terminal contract of supported alias S. A conflicting alias masks an
|
|
117
|
+
otherwise recognized direct consumer at the same identity. Resolved
|
|
118
|
+
aliases retain resolution status even when the terminal signature is
|
|
119
|
+
unknown (keyword_parameters, missing_perform, multiple_perform_definitions).
|
|
120
|
+
Effective alias contracts carry every edge location and the terminal's
|
|
121
|
+
declaration/include/perform proof. No new location role is introduced.
|
|
122
|
+
|
|
123
|
+
Comparison identities MUST include all direct workers in either revision,
|
|
124
|
+
all resolved BASE aliases (historical queues may retain their names),
|
|
125
|
+
aliases present in both revisions, aliases matching an opposite direct
|
|
126
|
+
worker, and aliases referenced by visible producers or needed to explain
|
|
127
|
+
unknown producer identity. A dormant HEAD-only alias is not automatically
|
|
128
|
+
a worker result or standalone warning. The full graph may still be built.
|
|
129
|
+
The common-alias criterion refers to constant-to-constant candidates,
|
|
130
|
+
including unsupported static forms; unrelated dynamic/literal writes such
|
|
131
|
+
as VERSION = "1" do not become relevant merely by existing in both
|
|
132
|
+
revisions. They still block conflicting bindings and are diagnosed when
|
|
133
|
+
referenced by a producer or matched to an opposite worker identity.
|
|
134
|
+
|
|
135
|
+
## Producer resolution
|
|
136
|
+
|
|
137
|
+
Class-object resolution first performs existing lexical/root lookup in
|
|
138
|
+
the producer's own snapshot, including alias bindings. A resolved alias
|
|
139
|
+
normalizes to its terminal direct identity. An unknown alias MUST NOT
|
|
140
|
+
guess its syntactic name as a serialized identity. Such a call retains a
|
|
141
|
+
separate attributed alias name for diagnostics and comparison relevance,
|
|
142
|
+
but cannot drive JC005. Existing cross-revision non-alias attribution
|
|
143
|
+
remains available only when no local alias/binding narrows that proof.
|
|
144
|
+
When alias lookup applies, a nearer selected non-worker class/module
|
|
145
|
+
binding stops fallback and leaves producer identity unknown. Opposite
|
|
146
|
+
revision names cannot override a selected local binding. Normalized
|
|
147
|
+
Class-object alias producers retain edge and terminal declaration,
|
|
148
|
+
include, and perform locations; unresolved targets retain any selected
|
|
149
|
+
declaration that prevents recognition.
|
|
150
|
+
|
|
151
|
+
Exact-string resolution remains exact and does not canonicalize aliases.
|
|
152
|
+
Producer identity certainty and arity certainty MUST be distinct:
|
|
153
|
+
resolved alias plus `*args` retains the terminal identity and splat_arguments;
|
|
154
|
+
unknown alias plus literal args retains unknown identity. Known identity
|
|
155
|
+
with unknown arity can still drive JC005. Existing Hash source-order,
|
|
156
|
+
dynamic-key, bulk-row, lexical producer, and Client String rules persist.
|
|
157
|
+
|
|
158
|
+
## Rules, unknown reasons, and proof
|
|
159
|
+
|
|
160
|
+
JC001: old serialized payload accepted by BASE but rejected by HEAD,
|
|
161
|
+
including alias consumers. JC002: HEAD accepted payload rejected by BASE,
|
|
162
|
+
under its serialized identity. JC003: HEAD producer/consumer mismatch.
|
|
163
|
+
JC001 owns a repeated HEAD mismatch at the same identity/arity before
|
|
164
|
+
JC003. JC001 OldJob and JC003 NewJob from a Class alias producer remain
|
|
165
|
+
distinct findings. JC006 narrowing/witness precedence remains unchanged.
|
|
166
|
+
|
|
167
|
+
JC004 requires absence of a BASE effective serialized identity in HEAD.
|
|
168
|
+
A resolved alias blocks removal; a relevant unknown alias warns rather
|
|
169
|
+
than passing or proving absence. Removing a BASE resolved alias can cause
|
|
170
|
+
JC004 even if its terminal still exists. JC005 requires HEAD known
|
|
171
|
+
serialized producer identity with an effective HEAD consumer and proven
|
|
172
|
+
BASE absence, conditional on old processes consuming that queue. An
|
|
173
|
+
exact-string alias producer can cause JC005 for the alias; a Class-object
|
|
174
|
+
alias producer can cause JC005 only for the terminal. BASE resolved aliases
|
|
175
|
+
block absence; unresolved aliases warn. Existing presence budget/error
|
|
176
|
+
behavior and conservative absence proof remain frozen.
|
|
177
|
+
|
|
178
|
+
JC007 adds exactly four reasons: alias_target_unresolved (supported edge
|
|
179
|
+
does not reach a recognized worker), alias_cycle, alias_binding_conflict,
|
|
180
|
+
and unsupported_alias_assignment. Existing contract/arity reasons retain
|
|
181
|
+
their meanings. Alias warning messages describe the unresolved binding
|
|
182
|
+
and risk to the persisted identity; remediation suggests an explicit
|
|
183
|
+
static alias only if it matches runtime behavior, or manual inspection.
|
|
184
|
+
|
|
185
|
+
BASE alias uncertainty affects head_to_base. HEAD alias uncertainty affects
|
|
186
|
+
base_to_head and additionally head_to_head only with a HEAD producer for
|
|
187
|
+
that same serialized identity. A Class call normalizing to another name
|
|
188
|
+
does not manufacture head_to_head for its source alias. Alias evidence
|
|
189
|
+
fingerprints contain reason, exact identity, lexical scope, normalized
|
|
190
|
+
assignment/enclosing tokens, target reference, and structural chain, but
|
|
191
|
+
exclude revision, paths, lines, and columns. Stable binding/occurrence
|
|
192
|
+
ordering preserves distinct evidence and cross-revision aggregation.
|
|
193
|
+
|
|
194
|
+
## JSON schema 3 and determinism
|
|
195
|
+
|
|
196
|
+
workers[] now represents compared serialized identities, sorted by exact
|
|
197
|
+
name. Every result has base_alias and head_alias, null for a direct or
|
|
198
|
+
absent binding, otherwise an object with exactly status, target, chain,
|
|
199
|
+
unknown_reason. Resolved status has terminal target and null reason;
|
|
200
|
+
unknown status has null target and its alias reason. Conflict or unsupported
|
|
201
|
+
assignment chains start with the alias alone; unresolved target chains
|
|
202
|
+
include the missing target, and cycle chains close the cycle.
|
|
203
|
+
|
|
204
|
+
Presence values are recognized_worker, resolved_alias,
|
|
205
|
+
defined_unrecognized, outside_scan_scope, absent, unverified, not_checked.
|
|
206
|
+
Unknown aliases use defined_unrecognized even in a direct binding conflict.
|
|
207
|
+
Effective contracts appear in base_contract/head_contract for resolved
|
|
208
|
+
aliases; unresolved aliases have null contracts. Producer arities and
|
|
209
|
+
matrix statuses use known serialized identity and effective consumers.
|
|
210
|
+
summary.workers continues to count direct worker facts only, never aliases.
|
|
211
|
+
Suppressions match the finding's exact serialized worker identity.
|
|
212
|
+
|
|
213
|
+
Inputs and graph processing use stable sorting. Chains follow structural
|
|
214
|
+
edge order. Repeated text/JSON for identical refs/config/toolchain MUST be
|
|
215
|
+
byte-identical, including cycles, conflicts, and Unicode names.
|
|
216
|
+
|
|
217
|
+
## Boundaries and acceptance
|
|
218
|
+
|
|
219
|
+
Recognition assumes normal successful application boot, as direct worker
|
|
220
|
+
recognition already does; it does not prove file load order or Zeitwerk
|
|
221
|
+
behavior. No general constant evaluator/dataflow, inheritance/concerns,
|
|
222
|
+
source execution, Rails/Sidekiq boot, Redis/network access, keyword
|
|
223
|
+
compatibility, value/Hash schema analysis, ActiveJob, Client instances,
|
|
224
|
+
wrappers, queue/feature-flag simulation, manual alias config, heuristic
|
|
225
|
+
rename detection, JC008, SARIF, --explain, or coverage report is added.
|
|
226
|
+
|
|
227
|
+
Acceptance requires supported AST/context and namespace tests, finite and
|
|
228
|
+
cross-file graph tests, cycles/conflicts/unknown terminals, reopened and
|
|
229
|
+
unknown-contract targets, all Class-object and exact-string producer
|
|
230
|
+
families, separate identity/arity uncertainty, every JC rule and precedence,
|
|
231
|
+
staged rename and alias removal, excluded-source presence proof, exact
|
|
232
|
+
Unicode/suppression behavior, schema 3 (success and failure), unchanged
|
|
233
|
+
config version 1, deterministic output, existing regressions, source
|
|
234
|
+
non-execution/worktree/index immutability, offline tests, gem packaging,
|
|
235
|
+
isolated CLI install/scenarios, and full validation in UTF-8 locale.
|
|
236
|
+
Locally unavailable Ruby 3.3/4.0 matrix entries remain official CI work.
|
|
237
|
+
The implementation phase MUST leave an uncommitted candidate and MUST NOT
|
|
238
|
+
commit, push, tag, release, publish, or alter historical v0.2.1 artifacts.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
module Jobcompat
|
|
2
|
+
ConstantBinding = Data.define(:name, :kind, :location, :fingerprint_parts)
|
|
3
|
+
AliasCandidate = Data.define(:name, :reference, :root, :namespace, :supported, :location, :fingerprint_parts)
|
|
4
|
+
AliasResolution = Data.define(:name, :status, :target, :chain, :unknown_reason, :locations, :fingerprint_parts, :static_reference) do
|
|
5
|
+
def resolved? = status == "resolved"
|
|
6
|
+
def as_json
|
|
7
|
+
{status: status, target: target, chain: chain, unknown_reason: unknown_reason}
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module ConstantLookup
|
|
12
|
+
def self.candidates(reference, root, namespace)
|
|
13
|
+
return [reference] if root
|
|
14
|
+
return [] if namespace.nil?
|
|
15
|
+
(namespace.reverse.map { |prefix| "#{prefix}::#{reference}" } + [reference]).uniq
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
class AliasResolver
|
|
20
|
+
def initialize(workers, bindings, candidates, ambiguous_bindings = [])
|
|
21
|
+
@workers = workers
|
|
22
|
+
@bindings = bindings.group_by(&:name)
|
|
23
|
+
@candidates = candidates.group_by(&:name)
|
|
24
|
+
@ambiguous_bindings = ambiguous_bindings.group_by(&:name)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def resolve
|
|
28
|
+
@candidates.keys.sort.to_h { |name| [name, resolve_identity(name)] }.freeze
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def resolve_identity(name)
|
|
34
|
+
chain, locations, parts, visited = [], [], [], {}
|
|
35
|
+
current = name
|
|
36
|
+
reason = nil
|
|
37
|
+
loop do
|
|
38
|
+
chain << current
|
|
39
|
+
if visited[current]
|
|
40
|
+
reason = "alias_cycle"
|
|
41
|
+
break
|
|
42
|
+
end
|
|
43
|
+
visited[current] = true
|
|
44
|
+
candidates = @candidates.fetch(current, [])
|
|
45
|
+
bindings = @bindings.fetch(current, []).sort_by { |item| [item.location.path, item.location.line, item.location.column] }
|
|
46
|
+
ambiguous = @ambiguous_bindings.fetch(current.split("::").last, []).reject { |item| bindings.any? { |binding| binding.location == item.location } }
|
|
47
|
+
if candidates.empty? && @workers.key?(current) && ambiguous.empty?
|
|
48
|
+
return resolution(name, current, chain, nil, locations, parts)
|
|
49
|
+
end
|
|
50
|
+
locations.concat((bindings + ambiguous).map(&:location))
|
|
51
|
+
parts << [current, (bindings + ambiguous).map(&:fingerprint_parts).sort_by(&:inspect)]
|
|
52
|
+
unless ambiguous.empty?
|
|
53
|
+
reason = "alias_binding_conflict"
|
|
54
|
+
break
|
|
55
|
+
end
|
|
56
|
+
if candidates.empty?
|
|
57
|
+
reason = "alias_target_unresolved"
|
|
58
|
+
break
|
|
59
|
+
end
|
|
60
|
+
if bindings.length != 1
|
|
61
|
+
reason = "alias_binding_conflict"
|
|
62
|
+
break
|
|
63
|
+
end
|
|
64
|
+
candidate = candidates.first
|
|
65
|
+
unless candidate.supported
|
|
66
|
+
reason = "unsupported_alias_assignment"
|
|
67
|
+
break
|
|
68
|
+
end
|
|
69
|
+
lookup = ConstantLookup.candidates(candidate.reference, candidate.root, candidate.namespace)
|
|
70
|
+
target = lookup.find { |identity| @bindings.key?(identity) || @workers.key?(identity) }
|
|
71
|
+
unless target
|
|
72
|
+
chain << lookup.first
|
|
73
|
+
reason = "alias_target_unresolved"
|
|
74
|
+
break
|
|
75
|
+
end
|
|
76
|
+
current = target
|
|
77
|
+
end
|
|
78
|
+
resolution(name, nil, chain, reason, locations, parts)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def resolution(name, target, chain, reason, locations, parts)
|
|
82
|
+
AliasResolution.new(name, reason ? "unknown" : "resolved", target, chain.freeze, reason,
|
|
83
|
+
locations.uniq.freeze, parts.freeze, @candidates.fetch(name).any? { |candidate| candidate.reference })
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
data/lib/jobcompat/analysis.rb
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
require "prism"
|
|
2
|
+
require_relative "canonical_worker_name"
|
|
3
|
+
require_relative "aliases"
|
|
2
4
|
|
|
3
5
|
module Jobcompat
|
|
4
6
|
Location = Data.define(:revision, :path, :line, :column, :role, :excerpt) do
|
|
@@ -55,14 +57,32 @@ module Jobcompat
|
|
|
55
57
|
end
|
|
56
58
|
|
|
57
59
|
class RevisionSnapshot
|
|
58
|
-
attr_reader :label, :ref, :sha, :workers, :calls, :unknowns, :index, :files_scanned, :tracked_ruby
|
|
60
|
+
attr_reader :label, :ref, :sha, :workers, :aliases, :bindings, :calls, :unknowns, :index, :files_scanned, :tracked_ruby
|
|
59
61
|
|
|
60
|
-
def initialize(label:, ref:, sha:, workers:, calls:, unknowns:, index:, files_scanned:, tracked_ruby:)
|
|
62
|
+
def initialize(label:, ref:, sha:, workers:, calls:, unknowns:, index:, files_scanned:, tracked_ruby:, aliases: {}, bindings: [])
|
|
61
63
|
@label, @ref, @sha, @workers, @calls, @unknowns, @index, @files_scanned, @tracked_ruby = label, ref, sha, workers, calls, unknowns, index, files_scanned, tracked_ruby
|
|
64
|
+
@aliases = aliases
|
|
65
|
+
@bindings = bindings.group_by(&:name).freeze
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def consumer(name)
|
|
69
|
+
binding = aliases[name]
|
|
70
|
+
return workers[name] unless binding
|
|
71
|
+
return unless binding.resolved?
|
|
72
|
+
terminal = workers.fetch(binding.target)
|
|
73
|
+
terminal.with(name: name, locations: (binding.locations + terminal.locations).uniq,
|
|
74
|
+
declaration_locations: (binding.locations + terminal.declaration_locations).uniq,
|
|
75
|
+
fingerprint_parts: [binding.fingerprint_parts, terminal.fingerprint_parts])
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def presence(name)
|
|
79
|
+
binding = aliases[name]
|
|
80
|
+
return binding.resolved? ? "resolved_alias" : "defined_unrecognized" if binding
|
|
81
|
+
index.status(name, recognized: workers.key?(name))
|
|
62
82
|
end
|
|
63
83
|
|
|
64
84
|
def check_presence(names, repository, config)
|
|
65
|
-
unresolved = names.
|
|
85
|
+
unresolved = names.select { |name| presence(name) == "absent" }
|
|
66
86
|
return if unresolved.empty?
|
|
67
87
|
consumed = 0
|
|
68
88
|
eligible = []
|
|
@@ -104,11 +124,12 @@ module Jobcompat
|
|
|
104
124
|
PATH_CONSTANT_BINDINGS = [Prism::ConstantPathWriteNode, Prism::ConstantPathOrWriteNode,
|
|
105
125
|
Prism::ConstantPathAndWriteNode, Prism::ConstantPathOperatorWriteNode,
|
|
106
126
|
Prism::ConstantPathTargetNode].freeze
|
|
107
|
-
attr_reader :fragments, :calls, :unknowns
|
|
127
|
+
attr_reader :fragments, :calls, :unknowns, :bindings, :alias_candidates, :ambiguous_bindings
|
|
108
128
|
|
|
109
129
|
def initialize(revision, path, source, index:, presence_only: false)
|
|
110
130
|
@revision, @path, @source, @index, @presence_only = revision, path, source, index, presence_only
|
|
111
131
|
@fragments, @calls, @unknowns = [], [], []
|
|
132
|
+
@bindings, @alias_candidates, @ambiguous_bindings = [], [], []
|
|
112
133
|
end
|
|
113
134
|
|
|
114
135
|
def analyze
|
|
@@ -121,7 +142,7 @@ module Jobcompat
|
|
|
121
142
|
end
|
|
122
143
|
raise Error.new(diagnostics.first[:message], category: "parse_error", location: diagnostics.first[:location], diagnostics: diagnostics)
|
|
123
144
|
end
|
|
124
|
-
walk(result.value, [], [], nil)
|
|
145
|
+
walk(result.value, [], [], nil, true)
|
|
125
146
|
self
|
|
126
147
|
end
|
|
127
148
|
|
|
@@ -155,19 +176,30 @@ module Jobcompat
|
|
|
155
176
|
|
|
156
177
|
private
|
|
157
178
|
|
|
158
|
-
def walk(node, namespace, scope, statement)
|
|
179
|
+
def walk(node, namespace, scope, statement, alias_context = false, alias_statement = nil)
|
|
159
180
|
return unless node.is_a?(Prism::Node)
|
|
160
181
|
case node
|
|
182
|
+
when Prism::ProgramNode
|
|
183
|
+
walk(node.statements, namespace, scope, nil, alias_context)
|
|
161
184
|
when Prism::StatementsNode
|
|
162
|
-
node.body.each { |child| walk(child, namespace, scope, child) }
|
|
185
|
+
node.body.each { |child| walk(child, namespace, scope, child, alias_context, alias_context ? child : (alias_statement || child)) }
|
|
163
186
|
when Prism::ClassNode, Prism::ModuleNode
|
|
164
187
|
constant = self.class.constant(node.constant_path)
|
|
165
188
|
name, valid = declaration_name(constant, namespace)
|
|
166
189
|
location = loc(node, "worker_declaration")
|
|
167
190
|
@index.add(name, location, selected: !@presence_only, ambiguous: !valid) if name
|
|
191
|
+
if name && !@presence_only
|
|
192
|
+
@bindings << ConstantBinding.new(name, "declaration", location,
|
|
193
|
+
[scope, self.class.normalize(node.constant_path.location.slice), node.class.name])
|
|
194
|
+
end
|
|
168
195
|
if !name && node.constant_path.is_a?(Prism::ConstantPathNode)
|
|
169
196
|
@index.add(node.constant_path.name.to_s.encode(Encoding::UTF_8), location, selected: !@presence_only, ambiguous: true)
|
|
170
197
|
end
|
|
198
|
+
unless valid || @presence_only
|
|
199
|
+
leaf = name&.split("::")&.last || node.constant_path.name.to_s.encode(Encoding::UTF_8)
|
|
200
|
+
@ambiguous_bindings << ConstantBinding.new(leaf, "declaration", location,
|
|
201
|
+
[scope, self.class.normalize(node.constant_path.location.slice), node.class.name])
|
|
202
|
+
end
|
|
171
203
|
new_scope = scope + [name || "<unsupported_class>"]
|
|
172
204
|
if node.is_a?(Prism::ClassNode) && valid && !@presence_only
|
|
173
205
|
body = node.body.is_a?(Prism::StatementsNode) ? node.body.body : []
|
|
@@ -184,24 +216,25 @@ module Jobcompat
|
|
|
184
216
|
[@path, new_scope, self.class.normalize(node.constant_path.location.slice), include_tokens])
|
|
185
217
|
end
|
|
186
218
|
child_namespace = valid && namespace ? namespace + [name] : nil
|
|
187
|
-
walk(node.superclass, namespace, scope, statement) if node.is_a?(Prism::ClassNode) && node.superclass
|
|
188
|
-
|
|
219
|
+
walk(node.superclass, namespace, scope, statement, false, alias_statement) if node.is_a?(Prism::ClassNode) && node.superclass
|
|
220
|
+
body_context = alias_context && node.equal?(statement) && valid
|
|
221
|
+
walk(node.body, child_namespace, new_scope, nil, body_context, body_context ? nil : alias_statement) if node.body
|
|
189
222
|
when Prism::DefNode
|
|
190
223
|
method_scope = node.receiver ? [self.class.normalize(node.receiver.location.slice), node.name.to_s] : node.name.to_s
|
|
191
224
|
child_scope = scope + [method_scope]
|
|
192
|
-
walk(node.receiver, namespace, scope, statement) if node.receiver
|
|
193
|
-
walk(node.parameters, namespace, child_scope, node.parameters) if node.parameters
|
|
194
|
-
walk(node.body, namespace, child_scope, nil) if node.body
|
|
225
|
+
walk(node.receiver, namespace, scope, statement, false, alias_statement) if node.receiver
|
|
226
|
+
walk(node.parameters, namespace, child_scope, node.parameters, false, alias_statement) if node.parameters
|
|
227
|
+
walk(node.body, namespace, child_scope, nil, false, alias_statement) if node.body
|
|
195
228
|
when Prism::SingletonClassNode
|
|
196
|
-
walk(node.expression, namespace, scope, statement)
|
|
229
|
+
walk(node.expression, namespace, scope, statement, false, alias_statement)
|
|
197
230
|
singleton_scope = scope + [["singleton_class", self.class.normalize(node.expression.location.slice)]]
|
|
198
|
-
walk(node.body, namespace, singleton_scope, nil) if node.body
|
|
231
|
+
walk(node.body, namespace, singleton_scope, nil, false, alias_statement) if node.body
|
|
199
232
|
when Prism::CallNode
|
|
200
233
|
extract_call(node, namespace, scope, statement) unless @presence_only
|
|
201
|
-
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
|
|
234
|
+
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement, false, alias_statement || statement) }
|
|
202
235
|
else
|
|
203
|
-
constant_write(node, namespace) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
|
|
204
|
-
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement) }
|
|
236
|
+
constant_write(node, namespace, scope, alias_statement || statement, alias_context && node.equal?(statement)) if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) } || PATH_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
|
|
237
|
+
node.compact_child_nodes.each { |child| walk(child, namespace, scope, statement, false, alias_statement || statement) }
|
|
205
238
|
end
|
|
206
239
|
end
|
|
207
240
|
|
|
@@ -213,10 +246,12 @@ module Jobcompat
|
|
|
213
246
|
[((rooted || segments.length > 1) ? segments : (namespace.empty? ? [] : namespace.last.split("::")) + segments).join("::"), true]
|
|
214
247
|
end
|
|
215
248
|
|
|
216
|
-
def constant_write(node, namespace)
|
|
249
|
+
def constant_write(node, namespace, scope, statement, alias_context)
|
|
217
250
|
if SIMPLE_CONSTANT_BINDINGS.any? { |type| node.is_a?(type) }
|
|
218
251
|
prefix = namespace.nil? || namespace.empty? ? [] : namespace.last.split("::")
|
|
219
|
-
|
|
252
|
+
name = (prefix + [node.name.to_s.encode(Encoding::UTF_8)]).join("::")
|
|
253
|
+
valid = !namespace.nil?
|
|
254
|
+
@index.add(name, loc(node, "worker_declaration"),
|
|
220
255
|
selected: !@presence_only, ambiguous: namespace.nil?)
|
|
221
256
|
else
|
|
222
257
|
target = node.is_a?(Prism::ConstantPathTargetNode) ? node : node.target
|
|
@@ -227,6 +262,22 @@ module Jobcompat
|
|
|
227
262
|
@index.add(target.name.to_s.encode(Encoding::UTF_8), loc(node, "worker_declaration"), selected: !@presence_only, ambiguous: true)
|
|
228
263
|
end
|
|
229
264
|
end
|
|
265
|
+
return if @presence_only
|
|
266
|
+
unless valid
|
|
267
|
+
leaf = name&.split("::")&.last || target.name.to_s.encode(Encoding::UTF_8)
|
|
268
|
+
@ambiguous_bindings << ConstantBinding.new(leaf, "write", loc(node, "worker_declaration"),
|
|
269
|
+
[scope, self.class.normalize(node.location.slice), self.class.normalize((statement || node).location.slice)])
|
|
270
|
+
end
|
|
271
|
+
return if name.nil?
|
|
272
|
+
plain = node.is_a?(Prism::ConstantWriteNode) || node.is_a?(Prism::ConstantPathWriteNode)
|
|
273
|
+
reference = self.class.constant(node.value) if node.respond_to?(:value)
|
|
274
|
+
supported = plain && valid && alias_context && reference &&
|
|
275
|
+
(reference.last || reference.first.length == 1 || namespace.empty?)
|
|
276
|
+
location = loc(node, "worker_declaration")
|
|
277
|
+
parts = [scope, self.class.normalize(node.location.slice), self.class.normalize((statement || node).location.slice), reference]
|
|
278
|
+
@bindings << ConstantBinding.new(name, "write", location, parts)
|
|
279
|
+
@alias_candidates << AliasCandidate.new(name, reference && reference.first.join("::"), reference && reference.last,
|
|
280
|
+
namespace, !!supported, location, parts)
|
|
230
281
|
end
|
|
231
282
|
|
|
232
283
|
def sidekiq_include?(node)
|
|
@@ -387,28 +438,13 @@ module Jobcompat
|
|
|
387
438
|
constant = self.class.constant(node)
|
|
388
439
|
return [constant.first.join("::"), "client_constant", constant.last, nil] if constant
|
|
389
440
|
if node.is_a?(Prism::StringNode)
|
|
390
|
-
name =
|
|
441
|
+
name = CanonicalWorkerName.parse(node.unescaped)
|
|
391
442
|
return [name, "exact_string", false, nil] if name
|
|
392
443
|
return [nil, nil, nil, "unsupported_client_payload"]
|
|
393
444
|
end
|
|
394
445
|
[nil, nil, nil, "dynamic_client_class"]
|
|
395
446
|
end
|
|
396
447
|
|
|
397
|
-
def canonical_client_string_worker_name(value)
|
|
398
|
-
name = value.encode(Encoding::UTF_8)
|
|
399
|
-
return unless name.valid_encoding?
|
|
400
|
-
result = Prism.parse(name)
|
|
401
|
-
return unless result.errors.empty? && result.value.statements.body.one?
|
|
402
|
-
node = result.value.statements.body.first
|
|
403
|
-
return unless node.is_a?(Prism::ConstantReadNode) || node.is_a?(Prism::ConstantPathNode)
|
|
404
|
-
constant = self.class.constant(node)
|
|
405
|
-
return unless constant && !constant.last
|
|
406
|
-
canonical_name = constant.first.join("::")
|
|
407
|
-
canonical_name if canonical_name == name
|
|
408
|
-
rescue EncodingError
|
|
409
|
-
nil
|
|
410
|
-
end
|
|
411
|
-
|
|
412
448
|
def emit_call(node, namespace, scope, statement, method, receiver, resolution_mode, root, arity, reason)
|
|
413
449
|
expression = self.class.normalize(node.location.slice)
|
|
414
450
|
enclosing = self.class.normalize((statement || node).location.slice)
|
|
@@ -435,7 +471,7 @@ module Jobcompat
|
|
|
435
471
|
selected = entries.select { |entry| @config.scan?(entry.path) }
|
|
436
472
|
tracked = entries.select { |entry| entry.path.end_with?(".rb") }
|
|
437
473
|
index = DefinedConstantIndex.new
|
|
438
|
-
fragments, calls, unknowns, errors = [], [], [], []
|
|
474
|
+
fragments, calls, unknowns, errors, bindings, alias_candidates, ambiguous_bindings = [], [], [], [], [], [], []
|
|
439
475
|
@repository.each_blob(selected) do |entry, bytes|
|
|
440
476
|
path = entry.path
|
|
441
477
|
begin
|
|
@@ -443,6 +479,9 @@ module Jobcompat
|
|
|
443
479
|
fragments.concat(result.fragments)
|
|
444
480
|
calls.concat(result.calls)
|
|
445
481
|
unknowns.concat(result.unknowns)
|
|
482
|
+
bindings.concat(result.bindings)
|
|
483
|
+
alias_candidates.concat(result.alias_candidates)
|
|
484
|
+
ambiguous_bindings.concat(result.ambiguous_bindings)
|
|
446
485
|
rescue Error => error
|
|
447
486
|
errors << error
|
|
448
487
|
end
|
|
@@ -484,8 +523,9 @@ module Jobcompat
|
|
|
484
523
|
workers[name] = Contract.new(name, required, rest ? nil : required + optional, forwarding ? "forwarding" : "positional", signature, nil, locations, declarations, fingerprint_parts)
|
|
485
524
|
end
|
|
486
525
|
end
|
|
487
|
-
|
|
488
|
-
|
|
526
|
+
aliases = AliasResolver.new(workers, bindings, alias_candidates, ambiguous_bindings).resolve
|
|
527
|
+
RevisionSnapshot.new(label: label, ref: ref, sha: sha, workers: workers, aliases: aliases, calls: calls, unknowns: unknowns, index: index,
|
|
528
|
+
files_scanned: selected.length, tracked_ruby: tracked, bindings: bindings)
|
|
489
529
|
end
|
|
490
530
|
end
|
|
491
531
|
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
require "prism"
|
|
2
|
+
|
|
3
|
+
module Jobcompat
|
|
4
|
+
# Internal: the single authority for canonical String worker names, shared by
|
|
5
|
+
# producer analysis (Client String "class" values) and Config (ignore[].worker).
|
|
6
|
+
# Prism decides what a Ruby constant path is; no handwritten identifier grammar.
|
|
7
|
+
module CanonicalWorkerName
|
|
8
|
+
# Returns the canonical UTF-8 name when value is exactly one complete,
|
|
9
|
+
# non-root-qualified static constant path, or nil otherwise. The input is
|
|
10
|
+
# never trimmed or Unicode-normalized.
|
|
11
|
+
def self.parse(value)
|
|
12
|
+
return unless value.is_a?(String)
|
|
13
|
+
name = value.encode(Encoding::UTF_8)
|
|
14
|
+
return unless name.valid_encoding?
|
|
15
|
+
result = Prism.parse(name)
|
|
16
|
+
return unless result.errors.empty? && result.value.statements.body.one?
|
|
17
|
+
segments = segments(result.value.statements.body.first)
|
|
18
|
+
return unless segments
|
|
19
|
+
canonical = segments.join("::")
|
|
20
|
+
canonical if canonical == name
|
|
21
|
+
rescue EncodingError
|
|
22
|
+
nil
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def self.segments(node)
|
|
26
|
+
case node
|
|
27
|
+
when Prism::ConstantReadNode then [node.name.to_s.encode(Encoding::UTF_8)]
|
|
28
|
+
when Prism::ConstantPathNode
|
|
29
|
+
parent = node.parent && segments(node.parent)
|
|
30
|
+
parent && parent + [node.name.to_s.encode(Encoding::UTF_8)]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
private_class_method :segments
|
|
34
|
+
end
|
|
35
|
+
end
|