Module refinery.lib.scripts.ps1.analysis.naming
The names a script addresses as strings rather than as variables.
Set-Variable X 'v' writes $X, Get-Variable X reads it, Remove-Variable X unbinds it, and
Get-Process -OutVariable p fills $p — none of which contains a Ps1Variable occurrence of the
name at all. Every layer above reasons about a name through its occurrences, so a name only ever
addressed this way has no binding, no reads and no writes, and a value is folded straight across the
command that changed it.
This module recognises those commands and reports what each does to which name, so the semantic model
can create the binding and file the occurrence. It is therefore a definition source consulted while
the model is built, not a decoration applied afterwards: there is nowhere to hang a decoration when
Get-Process -OutVariable x is the only mention of x in the script.
The recognition is deny-side. A command spelling this cannot resolve is not a command this may
declare harmless — it is one whose effect is unknown, and the conservative answer is to record that
the enclosing scope writes a name nobody can read. That is the opposite polarity from a grant table
such as refinery.lib.scripts.ps1.analysis.effects's purity allow-list, and the two must not be
confused: an allow-list that misses an entry withholds a rewrite, and a deny-list that misses one
performs a corruption.
Roles come from Ps1OccurrenceRole, the same vocabulary a
variable occurrence uses, so a consumer asks one question of both kinds of reference.
Expand source code Browse git
"""
The names a script addresses as *strings* rather than as variables.
`Set-Variable X 'v'` writes `$X`, `Get-Variable X` reads it, `Remove-Variable X` unbinds it, and
`Get-Process -OutVariable p` fills `$p` — none of which contains a `Ps1Variable` occurrence of the
name at all. Every layer above reasons about a name through its occurrences, so a name only ever
addressed this way has no binding, no reads and no writes, and a value is folded straight across the
command that changed it.
This module recognises those commands and reports what each does to which name, so the semantic model
can create the binding and file the occurrence. It is therefore a *definition source* consulted while
the model is built, not a decoration applied afterwards: there is nowhere to hang a decoration when
`Get-Process -OutVariable x` is the only mention of `x` in the script.
**The recognition is deny-side.** A command spelling this cannot resolve is not a command this may
declare harmless — it is one whose effect is unknown, and the conservative answer is to record that
the enclosing scope writes a name nobody can read. That is the opposite polarity from a grant table
such as `refinery.lib.scripts.ps1.analysis.effects`'s purity allow-list, and the two must not be
confused: an allow-list that misses an entry withholds a rewrite, and a deny-list that misses one
performs a corruption.
Roles come from `refinery.lib.scripts.ps1.analysis.model.Ps1OccurrenceRole`, the same vocabulary a
variable occurrence uses, so a consumer asks one question of both kinds of reference.
"""
from __future__ import annotations
import enum
from dataclasses import dataclass
from typing import Iterator
from refinery.lib.scripts.ps1 import data
from refinery.lib.scripts.ps1.ast import (
argument_text,
bound_argument_value,
free_positional_values,
resolve_command_name,
string_value,
)
from refinery.lib.scripts.ps1.model import (
Ps1CommandInvocation,
Ps1ScopeModifier,
)
class Ps1NameRole(enum.Enum):
"""
What a command does to the name it addresses.
Kept apart from `refinery.lib.scripts.ps1.analysis.model.Ps1OccurrenceRole` by one member:
`UNBINDS` has no counterpart among variable occurrences, because no syntax removes a variable.
A consumer that only tracks values may read it as a replacing write — the value afterwards is
not the value before — but one that reasons about whether the name exists needs it apart.
"""
READS = enum.auto() # noqa
WRITES = enum.auto() # noqa
APPENDS = enum.auto() # noqa
UNBINDS = enum.auto() # noqa
class Ps1NameTarget(enum.Enum):
"""
Which scope the addressed name resolves in.
`LOCAL` is the scope the command itself is written in — measured, and the default: a bare
`Set-Variable x 'v'` inside a function writes that function's scope and leaves the caller's
binding alone. `SCRIPT` covers the explicitly script- or global-qualified forms. `UNREADABLE`
is every form this cannot place, of which `-Scope 1` is the one that matters: it writes the
*caller's* scope, which is not a lexical ancestor and which the scope chain cannot name.
"""
LOCAL = enum.auto() # noqa
SCRIPT = enum.auto() # noqa
UNREADABLE = enum.auto() # noqa
@dataclass(frozen=True)
class Ps1NamedReference:
"""
One command's reference to one name. `key` is keyed as
`refinery.lib.scripts.ps1.ast.binding_key` keys a variable occurrence, so a name reached both
ways lands on one binding. `node` is the command performing it.
"""
key: str
role: Ps1NameRole
target: Ps1NameTarget
node: Ps1CommandInvocation
#: Commands whose first argument is the *name* of a variable, mapped to what they do to it. Resolved
#: through `refinery.lib.scripts.ps1.ast.resolve_command_name`, so aliases (`sv`, `gv`, `rv`) and
#: case variants arrive here already canonical.
_VARIABLE_COMMANDS: dict[str, Ps1NameRole] = {
'clear-variable': Ps1NameRole.WRITES,
'get-variable': Ps1NameRole.READS,
'new-variable': Ps1NameRole.WRITES,
'remove-variable': Ps1NameRole.UNBINDS,
'set-variable': Ps1NameRole.WRITES,
}
#: Commands whose first argument is a *provider path*, which addresses a variable when it names the
#: `Variable:` or `Env:` drive. `Remove-Item Variable:x` is how `del variable:x` arrives.
_ITEM_COMMANDS: dict[str, Ps1NameRole] = {
'clear-item': Ps1NameRole.WRITES,
'get-item': Ps1NameRole.READS,
'new-item': Ps1NameRole.WRITES,
'remove-item': Ps1NameRole.UNBINDS,
'set-item': Ps1NameRole.WRITES,
}
#: Provider drives whose items are the names this reasons about, mapped to the prefix the key takes.
_NAME_DRIVES: dict[str, str] = {
'env': 'env:',
'variable': '',
}
#: Scope arguments that place the write somewhere the lexical chain can name. Anything else —
#: a number, an expression, a spelling not listed — is `Ps1NameTarget.UNREADABLE`.
_SCOPE_TARGETS: dict[str, Ps1NameTarget] = {
'global': Ps1NameTarget.SCRIPT,
'local': Ps1NameTarget.LOCAL,
'private': Ps1NameTarget.LOCAL,
'script': Ps1NameTarget.SCRIPT,
}
#: Name qualifiers written into the name string itself — `Set-Variable global:x` — which say the
#: same thing the `-Scope` argument does.
_QUALIFIER_TARGETS: dict[str, Ps1NameTarget] = {
Ps1ScopeModifier.GLOBAL.value: Ps1NameTarget.SCRIPT,
Ps1ScopeModifier.LOCAL.value: Ps1NameTarget.LOCAL,
Ps1ScopeModifier.PRIVATE.value: Ps1NameTarget.LOCAL,
Ps1ScopeModifier.SCRIPT.value: Ps1NameTarget.SCRIPT,
}
def named_references(cmd: Ps1CommandInvocation) -> list[Ps1NamedReference]:
"""
Every reference *cmd* makes to a name addressed as a string, or an empty list when it makes
none. A single command may make several: `Get-Variable x -OutVariable y` reads one and writes
another.
"""
found: list[Ps1NamedReference] = []
command = resolve_command_name(cmd)
if command is not None:
found.extend(_subject_references(cmd, command))
found.extend(_out_variable_references(cmd))
return found
def unreadable_name_target(cmd: Ps1CommandInvocation) -> Ps1NameTarget | None:
"""
Where *cmd* writes a variable whose name this cannot read — `Set-Variable $n 'v'`, where the
name is computed — or `None` when it writes no such name. The name is unknown, so *every*
binding in the scope it lands in is in doubt, which is a fact about that scope rather than
about any one binding.
Only the variable commands are reported. A `Set-Item` whose path is computed might address the
`Variable:` drive and might equally be writing a file, and answering for every one of them would
put most scripts permanently in doubt; that is left as a known hole rather than paid for
everywhere. A reading command is not reported either: not knowing which name was read costs
nothing, since a read changes no value.
"""
command = resolve_command_name(cmd)
if command is None:
return None
role = _VARIABLE_COMMANDS.get(command)
if role is None or role is Ps1NameRole.READS:
return None
if _subject_name(cmd, command, 'name') is not None:
return None
return _declared_target(cmd)
def addresses_unreadable_name(cmd: Ps1CommandInvocation) -> bool:
"""
Whether *cmd* writes a variable whose name this cannot read. `unreadable_name_target` says
where.
"""
return unreadable_name_target(cmd) is not None
def _subject_references(
cmd: Ps1CommandInvocation, command: str,
) -> Iterator[Ps1NamedReference]:
"""
The reference a variable or item command makes to the name it is *about*.
"""
role = _VARIABLE_COMMANDS.get(command)
if role is not None:
written = _subject_name(cmd, command, 'name')
if written is not None:
yield from _resolve(cmd, written, role, _declared_target(cmd))
return
role = _ITEM_COMMANDS.get(command)
if role is None:
return
written = _subject_name(cmd, command, 'path')
if written is None:
return
drive, _, rest = written.partition(':')
prefix = _NAME_DRIVES.get(drive.lower())
if prefix is None or not rest:
return
yield Ps1NamedReference(
key=F'{prefix}{rest.lower()}',
role=role,
target=Ps1NameTarget.SCRIPT if prefix else _declared_target(cmd),
node=cmd,
)
def _out_variable_references(cmd: Ps1CommandInvocation) -> Iterator[Ps1NamedReference]:
"""
The references a command's out-variable parameters make. `-OutVariable p` replaces `$p`;
`-OutVariable +p` keeps what was there and appends, which reads the name as well as writing it.
"""
for parameter in data.OUT_VARIABLE_PARAMETERS:
value = bound_argument_value(cmd, parameter)
if value is None:
continue
written = string_value(value)
if written is None:
continue
role = Ps1NameRole.WRITES
if written.startswith('+'):
role = Ps1NameRole.APPENDS
written = written[1:]
if written:
yield from _resolve(cmd, written, role, Ps1NameTarget.LOCAL)
def _subject_name(cmd: Ps1CommandInvocation, command: str, parameter: str) -> str | None:
"""
The literal name a command is about, written either as `-Name x` or as the first positional
argument, or `None` when it is not a literal this can read.
The positional fallback skips the arguments a preceding value-taking switch consumed, which is
what tells `Set-Variable -Scope Global x 5` — where the name is `x` — from a reading of the
argument list that would call it `Global`.
A name written as a number is a literal like any other and is read through
`refinery.lib.scripts.ps1.ast.argument_text`. Reading it with `string_value` answers `None`,
which says the name is *computed* and puts every binding in the enclosing scope in doubt over a
name that is sitting in the source.
"""
explicit = bound_argument_value(cmd, parameter)
if explicit is not None:
return argument_text(explicit)
for value in free_positional_values(cmd, command):
return argument_text(value)
return None
def _declared_target(cmd: Ps1CommandInvocation) -> Ps1NameTarget:
"""
The scope a command's `-Scope` argument names, `Ps1NameTarget.LOCAL` when it has none — the
measured default — and `Ps1NameTarget.UNREADABLE` for a spelling the lexical chain cannot place,
of which `-Scope 1` is the one that occurs.
"""
declared = bound_argument_value(cmd, 'scope')
if declared is None:
return Ps1NameTarget.LOCAL
written = string_value(declared)
if written is None:
return Ps1NameTarget.UNREADABLE
return _SCOPE_TARGETS.get(written.lower(), Ps1NameTarget.UNREADABLE)
def _resolve(
cmd: Ps1CommandInvocation,
written: str,
role: Ps1NameRole,
target: Ps1NameTarget,
) -> Iterator[Ps1NamedReference]:
"""
One reference, with a qualifier written into the name string resolved against *target*.
"""
qualifier, _, rest = written.partition(':')
if rest:
lowered = qualifier.lower()
prefix = _NAME_DRIVES.get(lowered)
if prefix is not None:
yield Ps1NamedReference(
key=F'{prefix}{rest.lower()}',
role=role,
target=Ps1NameTarget.SCRIPT if prefix else target,
node=cmd,
)
return
placed = _QUALIFIER_TARGETS.get(lowered)
if placed is None:
return
yield Ps1NamedReference(key=rest.lower(), role=role, target=placed, node=cmd)
return
yield Ps1NamedReference(key=written.lower(), role=role, target=target, node=cmd)
Functions
def named_references(cmd)-
Every reference cmd makes to a name addressed as a string, or an empty list when it makes none. A single command may make several:
Get-Variable x -OutVariable yreads one and writes another.Expand source code Browse git
def named_references(cmd: Ps1CommandInvocation) -> list[Ps1NamedReference]: """ Every reference *cmd* makes to a name addressed as a string, or an empty list when it makes none. A single command may make several: `Get-Variable x -OutVariable y` reads one and writes another. """ found: list[Ps1NamedReference] = [] command = resolve_command_name(cmd) if command is not None: found.extend(_subject_references(cmd, command)) found.extend(_out_variable_references(cmd)) return found def unreadable_name_target(cmd)-
Where cmd writes a variable whose name this cannot read —
Set-Variable $n 'v', where the name is computed — orNonewhen it writes no such name. The name is unknown, so every binding in the scope it lands in is in doubt, which is a fact about that scope rather than about any one binding.Only the variable commands are reported. A
Set-Itemwhose path is computed might address theVariable:drive and might equally be writing a file, and answering for every one of them would put most scripts permanently in doubt; that is left as a known hole rather than paid for everywhere. A reading command is not reported either: not knowing which name was read costs nothing, since a read changes no value.Expand source code Browse git
def unreadable_name_target(cmd: Ps1CommandInvocation) -> Ps1NameTarget | None: """ Where *cmd* writes a variable whose name this cannot read — `Set-Variable $n 'v'`, where the name is computed — or `None` when it writes no such name. The name is unknown, so *every* binding in the scope it lands in is in doubt, which is a fact about that scope rather than about any one binding. Only the variable commands are reported. A `Set-Item` whose path is computed might address the `Variable:` drive and might equally be writing a file, and answering for every one of them would put most scripts permanently in doubt; that is left as a known hole rather than paid for everywhere. A reading command is not reported either: not knowing which name was read costs nothing, since a read changes no value. """ command = resolve_command_name(cmd) if command is None: return None role = _VARIABLE_COMMANDS.get(command) if role is None or role is Ps1NameRole.READS: return None if _subject_name(cmd, command, 'name') is not None: return None return _declared_target(cmd) def addresses_unreadable_name(cmd)-
Whether cmd writes a variable whose name this cannot read.
unreadable_name_target()says where.Expand source code Browse git
def addresses_unreadable_name(cmd: Ps1CommandInvocation) -> bool: """ Whether *cmd* writes a variable whose name this cannot read. `unreadable_name_target` says where. """ return unreadable_name_target(cmd) is not None
Classes
class Ps1NameRole (*args, **kwds)-
What a command does to the name it addresses.
Kept apart from
Ps1OccurrenceRoleby one member:UNBINDShas no counterpart among variable occurrences, because no syntax removes a variable. A consumer that only tracks values may read it as a replacing write — the value afterwards is not the value before — but one that reasons about whether the name exists needs it apart.Expand source code Browse git
class Ps1NameRole(enum.Enum): """ What a command does to the name it addresses. Kept apart from `refinery.lib.scripts.ps1.analysis.model.Ps1OccurrenceRole` by one member: `UNBINDS` has no counterpart among variable occurrences, because no syntax removes a variable. A consumer that only tracks values may read it as a replacing write — the value afterwards is not the value before — but one that reasons about whether the name exists needs it apart. """ READS = enum.auto() # noqa WRITES = enum.auto() # noqa APPENDS = enum.auto() # noqa UNBINDS = enum.auto() # noqaAncestors
- enum.Enum
Class variables
var READS-
The type of the None singleton.
var WRITES-
The type of the None singleton.
var APPENDS-
The type of the None singleton.
var UNBINDS-
The type of the None singleton.
class Ps1NameTarget (*args, **kwds)-
Which scope the addressed name resolves in.
LOCALis the scope the command itself is written in — measured, and the default: a bareSet-Variable x 'v'inside a function writes that function's scope and leaves the caller's binding alone.SCRIPTcovers the explicitly script- or global-qualified forms.UNREADABLEis every form this cannot place, of which-Scope 1is the one that matters: it writes the caller's scope, which is not a lexical ancestor and which the scope chain cannot name.Expand source code Browse git
class Ps1NameTarget(enum.Enum): """ Which scope the addressed name resolves in. `LOCAL` is the scope the command itself is written in — measured, and the default: a bare `Set-Variable x 'v'` inside a function writes that function's scope and leaves the caller's binding alone. `SCRIPT` covers the explicitly script- or global-qualified forms. `UNREADABLE` is every form this cannot place, of which `-Scope 1` is the one that matters: it writes the *caller's* scope, which is not a lexical ancestor and which the scope chain cannot name. """ LOCAL = enum.auto() # noqa SCRIPT = enum.auto() # noqa UNREADABLE = enum.auto() # noqaAncestors
- enum.Enum
Class variables
var LOCAL-
The type of the None singleton.
var SCRIPT-
The type of the None singleton.
var UNREADABLE-
The type of the None singleton.
class Ps1NamedReference (key, role, target, node)-
One command's reference to one name.
keyis keyed asbinding_key()keys a variable occurrence, so a name reached both ways lands on one binding.nodeis the command performing it.Expand source code Browse git
@dataclass(frozen=True) class Ps1NamedReference: """ One command's reference to one name. `key` is keyed as `refinery.lib.scripts.ps1.ast.binding_key` keys a variable occurrence, so a name reached both ways lands on one binding. `node` is the command performing it. """ key: str role: Ps1NameRole target: Ps1NameTarget node: Ps1CommandInvocationInstance variables
var key-
The type of the None singleton.
var role-
The type of the None singleton.
var target-
The type of the None singleton.
var node-
The type of the None singleton.