-
Notifications
You must be signed in to change notification settings - Fork 41
Expand file tree
/
Copy pathpin-corpus-lint.py
More file actions
5941 lines (5487 loc) · 241 KB
/
Copy pathpin-corpus-lint.py
File metadata and controls
5941 lines (5487 loc) · 241 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env python3
# SPDX-FileCopyrightText: 2026 Daniel Radman
# SPDX-License-Identifier: MIT
"""Static self-scan of `lib/test/run.sh`'s own pin corpus (issue #375).
Three mechanical guards over the suite's pin-helper call sites, so a defect the
parents (#370, #371) had to rediscover in a later shadow instead fails RED at
authoring time:
* ``lint`` — the **pin-in-comment lint.** A pin literal that also appears inside
a *comment* of its own target file inflates the occurrence count the pin reads
(issue #370's evidence: a ``pin_count`` expecting 2 read 3 because the phase
file's own comment quoted the literal, so collapsing a real call site brought
the count *down* to the expected 2 — the pin passed on the regression it
guards). This scan enumerates every statically-resolvable ``(literal, target)``
pair from the four pin helpers and FAILs when the literal sits in a ``#``
comment (``.sh``/``.py``/``.jq``/``.yml``) or an ``<!-- … -->`` region
(``.md``) of its target.
* ``wrapped`` — the **wrapped-literal meta-guard.** A contract phrase assembled
from wrapped adjacent string literals (``'… OLD does '`` then ``'not) …'`` in
an argparse ``help=``) lives on *no single line*, so a line-based ``grep`` /
``pin_count`` finds nothing even though the rendered ``--help`` text contains
it (issue #371's evidence). This scan flags any source-grep pin whose phrase
occurs on no single line of its target, distinguishing *absent* from *present
only in the whitespace-normalized rendering* (``tr -s '[:space:]' ' '``), and
additionally FAILs any pin into a multi-literal argparse ``help=`` string,
requiring the pin to target the rendered surface (captured ``--help`` output,
real stderr) instead.
**Relocation diagnosis (issue #661, opt-in via ``--reloc``).** A bare
``ABSENT`` reads identically for a pin literal that was *relocated* into a
different file and one that was genuinely *deleted*. When ``--reloc`` is
passed and a pin literal is ABSENT from its named target (whitespace-normalized
and rendered-surface, so a wrapped literal still counts), the guard searches a
scoped tracked-file set — from ``--reloc-search-set`` when supplied (the
git-free path the self-tests use) else ``git ls-files`` — **minus** the
pin-source file(s) that declare the literal (auto-excluded plus any
``--reloc-exclude`` substring token) and the non-source trees ``.prflow/vendor/`` /
``.prflow/tmp/``, and reports every other file where the literal resolves as
``RELOCATED … relocated to <file>; update the pin target``. Only when the set
was enumerated successfully **and** the literal resolves nowhere in it does it
read ``deleted (not found anywhere)`` — a failed/empty enumeration is reported
``relocation diagnosis unavailable`` on stderr and is **never** collapsed to
``deleted`` (fail-closed). Without ``--reloc`` the ABSENT emit is unchanged.
* ``mutation-routing-worktree`` — the required worktree gate over the committed
audited test-source population (issues #666 and #810). It runs **two** subgates
and concatenates their findings:
1. the retired-helper zero-population census, which builds the opaque
mutation-call census and requires both the census and the checked-in
inventory to be empty — every supported mutation-helper definition or
invocation is prohibited; and
2. the static pin classifier over the worktree's changes against the merge base
with ``origin/main``, scanning ``AUDITED_PIN_SOURCES`` plus the tracked and
untracked ``lib/test/test_*.py`` leaves, so a newly added undeclared
wording-only pin fails RED.
Neither subgate executes or interprets mutations, classifies effects, or infers
assignment dependencies.
Infrastructure failures exit 2, policy findings exit 3, and a clean established
scan exits 0. The lower-level ``mutation-routing`` synthetic-fixture command
remains for legacy self-tests.
**The static classifier ROUTES; it does not judge (issue #948).** For a changed
site whose declaration grammar is valid, it walks an ordered three-step ladder:
(1) a program in ``scripts/**``, ``lib/**`` (non-test) or ``.github/**``
demonstrably reads the literal or a distinctive token it names — pass, no human
needed; (2) otherwise, the delta-gated ledger
``lib/test/pin-corpus-adjudications.tsv`` already records this literal as
``boundary`` **and** the site carries a valid ``# structural-pin-ok:``
declaration — pass, honouring the tag as a *pointer to an authorized decision*;
(3) neither — report the finding. Step 1 can only ever route a site to step 2
(a grep-shaped consumer search misses a generic consumer by construction, so
"found none" means "ask the ledger", never "reject"), and step 2 fails closed:
an absent, unestablished or non-``boundary`` ledger row never satisfies it, and
an unreadable ledger is an infrastructure failure long before this ladder runs.
The real control over step 2 is therefore not this classifier but the review of
ledger changes, which is separately delta-gated and needs an exact branch
manifest — do not read the ladder as the gate getting smarter. The ladder is
scoped to the RETAINED population: a *retired* wording literal's revival keeps
its pre-#948 contract (deliberate authorization plus a genuinely machine-shaped
target), since both ladder steps rest on the very boundary row that contract
says cannot on its own make a revival valid.
**Fail-closed:** a call site the scanner cannot resolve statically (the literal
interpolates a variable it cannot resolve, or the target file is a variable with
no ``--var`` binding and no ``$LIB``-relative assignment) is COUNTED and reported
on stderr, never silently skipped.
The three legacy pin-source commands preserve their existing output contracts:
without ``--strict``, ``lint`` and ``wrapped`` exit 0 even on findings, and the
synthetic-fixture ``mutation-routing`` command always exits 0. Findings go to
stdout (one per line, tab-separated); unresolvable counts and per-site details go
to stderr. The required ``mutation-routing-worktree`` command instead carries its
0/2/3 clean/infrastructure/finding contract directly.
**``--strict`` exit-code mode (issue #687, opt-in, applies to ``lint`` and
``wrapped``; ``mutation-routing`` keeps its own always-exit-0 contract).** With
``--strict`` a run that writes at least one line to stdout exits **3**, and a run
that writes none exits 0; the stdout and stderr bytes are byte-for-byte what they
are without the flag — ``--strict`` changes only the exit code. The rule is
defined over **whether any line was written to stdout**, not over a list of
finding tokens, so a finding arm added later is covered the day it lands. Every
stdout write on a covered path routes through the single ``_emit`` helper (defined
just above ``run_lint``); ``lib/test/run.sh``'s issue-#687 emit-helper guard,
anchored from ``run_lint`` to the end of ``_emit_wrapped_or_absent``, goes RED if
a raw stdout write is introduced inside that range — so a future arm printing
*informational* output on a covered path must route it to ``sys.stderr`` instead.
**What ``--strict`` rc 0 does and does not assert:** it asserts only that no line
was written to stdout; it does **not** assert that any pin was resolved. The
fail-closed accounting (``UNRESOLVED-COUNT`` / ``RESOLVED-COUNT``) is a stderr
channel that never moves the exit code, so a corpus in which every pin failed to
resolve prints nothing and exits 0 under ``--strict`` — a caller keying on the
exit code still owes the separate ``RESOLVED-COUNT`` floor.
CLI::
pin-corpus-lint.py lint PIN_SOURCE [--strict] [--lib DIR] [--var NAME=PATH ...]
pin-corpus-lint.py wrapped PIN_SOURCE [--strict] [--lib DIR] [--var NAME=PATH ...]
[--reloc] [--reloc-search-set FILE]
[--reloc-exclude SUBSTR ...]
pin-corpus-lint.py mutation-routing PIN_SOURCE --diff-file FILE
[--lib DIR] [--var NAME=PATH ...]
pin-corpus-lint.py mutation-routing-worktree REPO_ROOT
``PIN_SOURCE`` is the shell file whose pin call sites are scanned (``run.sh``
itself for the real corpus, a synthetic fixture for the self-tests). ``--var``
supplies the runtime value of a target-file variable the helper cannot resolve
statically (e.g. ``DEF_SKILL``, the mktemp'd implement-skill bundle); ``--lib``
binds ``$LIB`` so ``VAR="$LIB/../skills/…"`` assignments resolve on their own.
``--reloc`` enables the issue-#661 relocation diagnosis on the ``wrapped``
guard's ABSENT branch; ``--reloc-search-set FILE`` supplies the search set as a
newline-delimited file (git-free, for the self-tests) instead of ``git
ls-files``; ``--reloc-exclude SUBSTR`` (repeatable) drops any tracked path
containing SUBSTR anywhere in it -- a substring test, not an anchored prefix --
from the search set (the pin-source file(s) that declare the literal); a token
that resolves to the same file as a candidate (abspath-equal) is dropped too.
``--diff-file FILE`` (``mutation-routing`` only, required) supplies the unified
diff whose added/deleted lines scope the declaration gate.
Known limitation: the search set is read as UTF-8, so a non-UTF-8 tracked file
(an image, a binary fixture) is an UNREADABLE candidate. That direction is safe
-- it downgrades a would-be ``deleted`` verdict to ``diagnosis INCOMPLETE`` and
never claims a false deletion -- but it does mean a genuine deletion in a corpus
containing binary tracked files reports INCOMPLETE rather than ``deleted``.
"""
from __future__ import annotations
import ast
import bisect
import csv
import difflib
import fnmatch
import functools
import glob
import hashlib
import importlib.util
import io
import json
import os
import re
import stat
import subprocess
import sys
from pathlib import Path
from types import MappingProxyType
from typing import NamedTuple
# Non-source trees always excluded from the relocation search set (issue #661): a
# committed vendored plugin copy and the run's own draft/derivation artifacts both
# quote pin literals and would otherwise be reported as spurious destinations.
RELOC_DEFAULT_EXCLUDES = (".prflow/vendor/", ".prflow/tmp/")
# Machine-consumed sentinel (issue #967): written to stderr by
# ``scan_static_pin_changes`` only after both static-classifier passes have
# completed, so a caller can tell "the gate ran and was clean" from "a
# precondition raised and the gate never ran". Coupled to the assertion in
# ``lib/test/run.sh``; change both together.
STATIC_SCAN_COMPLETED_MARKER = "MUTATION-ROUTING-STATIC-SCAN-COMPLETED"
# (literal_arg_index, file_arg_index, default_file_var). Indices are 0-based
# over the call's arguments AFTER the helper name. A file index past the actual
# arg list means the optional file arg was omitted -> use default_file_var.
HELPERS = {
"assert_pin_unique": (1, 2, None),
"pin_count": (0, 1, None),
"assert_pin_red_on_removal": (1, 2, "MAXI_SKILL"),
# Retired helpers remain parseable only so maintainer tooling can reproduce
# historical frozen inventories. The zero-population census rejects every live
# definition or invocation before the authoring classifier runs.
"assert_pin_red_under": (1, 3, "MAXI_SKILL"),
# Namespaced module pin API (module-harness.sh, issue #577) so the meta-lints
# cover pins that extraction moves out of run.sh into lib/test/modules/*.sh
# (issue #591). Module pins always pass the target file explicitly — no default.
"devflow_module_pin_count": (0, 1, None),
"devflow_module_pin_unique": (1, 2, None),
"devflow_module_pin_present": (1, 2, None),
"devflow_module_pin_red_under": (1, 3, None),
}
# Naming convention for a module-private static presence wrapper implemented
# through a lower-level counter rather than by forwarding to a known helper (for
# example review-and-fix-contract.sh's ``_raf_pin_unique``, whose body calls
# ``assert_eq`` on a ``_raf_pin_count`` substitution). The wrapper inference in
# ``helper_specs_for_source`` falls back to this suffix set when no body-derived
# forwarding form is recognized; naming it keeps that convention in one place so
# a second reader (pin-corpus-classifier.py's existence-helper set) shares the
# definition instead of restating the literals.
STATIC_PRESENCE_WRAPPER_SUFFIXES = ("_pin_unique", "_pin_present")
COMMENT_HASH_EXTS = {".sh", ".py", ".jq", ".yml", ".yaml"}
COMMENT_MD_EXTS = {".md"}
# ── shell tokenizing ────────────────────────────────────────────────────────
def join_logical_lines(text):
"""Yield (start_lineno, logical_line) joining backslash-continued lines."""
physical = text.split("\n")
i = 0
while i < len(physical):
start = i + 1
line = physical[i]
while line.endswith("\\") and not line.endswith("\\\\") and i + 1 < len(physical):
line = line[:-1] + "\n" + physical[i + 1]
i += 1
yield start, line
i += 1
def tokenize(s, *, split_shell_operators=False, include_spans=False):
"""Split a shell fragment into argument tokens, quote-aware.
Returns a list of tokens, each a list of (kind, value) segments where kind
is 'sq' (single-quoted, literal), 'dq' (double-quoted), 'bare', or — in
shell-operator mode — 'escaped'. Adjacent segments with no separating
whitespace belong to one token (shell concatenation, e.g. `'a'"$B"`).
When ``split_shell_operators`` is true, unquoted, unescaped command
operators are emitted as separate bare tokens. When ``include_spans`` is
true, each item is ``(token, start, end)`` with offsets into ``s``.
"""
tokens = []
cur = [] # list of (kind, value) segments for the current token
cur_start = None
def emit(token, start, end):
tokens.append((token, start, end) if include_spans else token)
i, n = 0, len(s)
while i < n:
c = s[i]
if (
split_shell_operators
and c == "("
and i + 1 < n
and s[i + 1] == ")"
and cur
and all(kind == "bare" for kind, _ in cur)
and re.fullmatch(r"[A-Za-z_]\w*", _token_value(cur))
):
# Keep a Bash function-definition name (`name()`) opaque. Its body
# is scanned independently; treating `name` as an invocation would
# double-count inferred wrappers or trigger the multi-call guard.
cur.append(("bare", "()"))
i += 2
continue
if split_shell_operators and c in ";&|()":
if cur:
emit(cur, cur_start, i)
cur = []
cur_start = None
operator = (
s[i : i + 2]
if s[i : i + 2] in {"&&", "||", "|&"}
else c
)
emit([("bare", operator)], i, i + len(operator))
i += len(operator)
continue
if c in " \t\n":
if cur:
emit(cur, cur_start, i)
cur = []
cur_start = None
i += 1
continue
if c == "#" and not cur:
# A '#' starting a token begins a comment (only outside a token, so
# `foo#bar` bare words are unaffected — none occur in pin calls).
break
if c == "'":
if cur_start is None:
cur_start = i
j = s.index("'", i + 1) if "'" in s[i + 1 :] else n
cur.append(("sq", s[i + 1 : j]))
i = j + 1
continue
if c == '"':
if cur_start is None:
cur_start = i
j = i + 1
buf = []
while j < n and s[j] != '"':
if s[j] == "\\" and j + 1 < n:
buf.append(s[j : j + 2])
j += 1
else:
buf.append(s[j])
j += 1
cur.append(("dq", "".join(buf)))
i = j + 1
continue
# bare run up to next whitespace/quote
if cur_start is None:
cur_start = i
j = i
buf = []
while j < n and s[j] not in " \t\n'\"":
if split_shell_operators and s[j] in ";&|()":
break
if s[j] == "\\" and j + 1 < n:
if split_shell_operators:
if buf:
cur.append(("bare", "".join(buf)))
buf = []
cur.append(("escaped", s[j + 1]))
else:
buf.append(s[j + 1])
j += 1
else:
buf.append(s[j])
j += 1
if buf:
cur.append(("bare", "".join(buf)))
i = j
if cur:
emit(cur, cur_start, n)
return tokens
# ── variable resolution ─────────────────────────────────────────────────────
_VARREF = re.compile(r"^\$\{?(\w+)\}?$")
_ASSIGNMENT_RE = re.compile(r"^\s*(?:local\s+)?([A-Za-z_]\w*)=(.*)$")
def build_var_maps(text, lib, overrides):
"""Return (path_vars, literal_vars).
path_vars: NAME -> resolved filesystem path (from `--var` overrides and from
`VAR="$LIB/..."` / `VAR=$OTHER` assignments).
literal_vars: NAME -> literal string value (from `VAR='single-quoted'`).
This intentionally models only sequential top-level assignments. Each
right-hand side is resolved against the values available at that point; it
does not attempt to evaluate conditional shell control flow.
"""
path_vars = dict(overrides)
literal_vars = {}
for _, line in join_logical_lines(text):
m = _ASSIGNMENT_RE.match(line)
if not m:
continue
name, rhs = m.group(1), m.group(2).strip()
_apply_assignment(
name, rhs, path_vars, literal_vars, lib, protected=set(overrides)
)
return path_vars, literal_vars
def _apply_assignment(name, rhs, path_vars, literal_vars, lib, protected=()):
"""Apply one supported assignment using the values visible before it."""
if name in protected:
return
path_vars.pop(name, None)
literal_vars.pop(name, None)
if (
len(rhs) >= 2
and rhs[0] == "'"
and rhs.endswith("'")
and "'" not in rhs[1:-1]
):
literal_vars[name] = rhs[1:-1]
return
value = _resolve_path_rhs(rhs, lib, path_vars)
if value is not None:
path_vars[name] = value
def variable_maps_by_line(text, lib, overrides):
"""Return sequential assignment maps before each logical line.
Every line between two assignments sees the same values, so one read-only
view is shared across that whole run and a fresh pair is taken only where
an assignment could have changed them — on a source whose lines mostly
carry no assignment that is far fewer copies than one pair per line. The
views are ``MappingProxyType`` so a caller that tried to write through one
fails at the write instead of silently altering every line sharing it;
every reader today only looks values up.
"""
maps = {}
path_vars = dict(overrides)
literal_vars = {}
protected = set(overrides)
snapshot = (
MappingProxyType(dict(path_vars)),
MappingProxyType(dict(literal_vars)),
)
for lineno, line in join_logical_lines(text):
maps[lineno] = snapshot
match = _ASSIGNMENT_RE.match(line)
if match is None:
continue
_apply_assignment(
match.group(1),
match.group(2).strip(),
path_vars,
literal_vars,
lib,
protected=protected,
)
snapshot = (
MappingProxyType(dict(path_vars)),
MappingProxyType(dict(literal_vars)),
)
return maps
def _resolve_path_rhs(rhs, lib, path_vars):
# Strip surrounding quotes if the whole RHS is quoted.
r = rhs
if len(r) >= 2 and r[0] == '"' and r.endswith('"'):
r = r[1:-1]
elif len(r) >= 2 and r[0] == "'" and r.endswith("'"):
return None # single-quoted -> a literal var, not a path
# `$OTHER` alone
m = _VARREF.match(r)
if m:
return path_vars.get(m.group(1))
# `$LIB/rel` / `${LIB}/rel` / `$OTHER/rel` — the shared inline var-prefixed
# path grammar, so this and resolve_arg's inline target resolution stay one
# owner (issue #757).
inline = _resolve_inline_var_path(r, lib, path_vars)
if inline is not None:
return inline
# A bare literal path (no `$`).
if "$" not in r and "(" not in r and r:
# Only treat as a path if it looks like one (has a slash or extension).
if "/" in r or "." in r:
return r if os.path.isabs(r) else os.path.normpath(os.path.join(lib or ".", r))
return None
_INLINE_LIB = re.compile(r"^\$\{?LIB\}?/(.*)$")
_INLINE_VAR = re.compile(r"^\$\{?(\w+)\}?/(.*)$")
def _resolve_inline_var_path(s, lib, path_vars):
"""Resolve an inline var-prefixed path reference — ``$LIB/rel`` / ``${LIB}/rel``,
or ``$OTHER/rel`` / ``${OTHER}/rel`` where OTHER is a known path var — to a
filesystem path, or None when it is neither shape (or the referenced var is
unknown).
This is the inline counterpart of the whole-``$VAR`` resolution ``resolve_arg``
already performs. A pin's target file argument is frequently written inline —
``devflow_module_pin_unique "…" '…' "$LIB/../CLAUDE.md"`` — rather than as a
pre-assigned whole-``$VAR`` token, and without this an inline target stays
unresolved: surfaced on stderr but never asserted, i.e. silently exempt from the
wrapped / pin-in-comment meta-guards while the guards still read rc 0 (issue
#757). Applied only for ``want_path`` targets, never for pinned literals, so
literal resolution is unchanged."""
m = _INLINE_LIB.match(s)
if m and lib is not None:
return os.path.normpath(os.path.join(lib, m.group(1)))
m = _INLINE_VAR.match(s)
if m and m.group(1) in path_vars:
return os.path.normpath(os.path.join(path_vars[m.group(1)], m.group(2)))
return None
def resolve_arg(segments, literal_vars, path_vars, want_path, lib=None):
"""Resolve one argument's segments to a string, or None if unresolvable.
want_path=True resolves against path_vars (target file); otherwise against
literal_vars (the pinned literal). ``lib`` enables inline ``$LIB/rel`` /
``$VAR/rel`` path resolution for ``want_path`` targets (issue #757).
"""
out = []
for kind, val in segments:
if kind == "sq":
out.append(val)
elif kind == "dq":
# Neutralize backslash-escaped metacharacters first: `\$`, `` \` ``, `\"`,
# `\\` are literal, not interpolation. Only an UNescaped `$`/backtick that
# remains is real interpolation (a whole `$VAR`, or — for a path target —
# an inline `$VAR/rel` prefix).
NUL, TCK = "\x00d", "\x00t"
neutral = (
val.replace("\\\\", "\x00b")
.replace("\\$", NUL)
.replace("\\`", TCK)
.replace('\\"', '"')
)
if "$" in neutral or "`" in neutral:
m = _VARREF.match(neutral)
if m:
repl = (path_vars if want_path else literal_vars).get(m.group(1))
if repl is None:
return None
out.append(repl)
continue
inline = _resolve_inline_var_path(neutral, lib, path_vars) if want_path else None
if inline is None:
return None
out.append(inline)
else:
out.append(neutral.replace(NUL, "$").replace(TCK, "`").replace("\x00b", "\\"))
else: # bare
m = _VARREF.match(val)
if m:
repl = (path_vars if want_path else literal_vars).get(m.group(1))
if repl is None:
return None
out.append(repl)
elif "$" in val:
inline = _resolve_inline_var_path(val, lib, path_vars) if want_path else None
if inline is None:
return None
out.append(inline)
else:
out.append(val)
return "".join(out)
# ── call-site extraction ────────────────────────────────────────────────────
def extract_pins(text, lib, overrides, helper_specs=None):
"""Yield dicts for each pin call site: resolved (literal, file) or unresolved.
``helper_specs`` defaults to the built-in ``HELPERS`` table. A caller that
also wants a source's own pin wrappers in the population passes the specs
``helper_specs_for_source`` inferred for that exact text; only entries whose
literal selector is a positional index are usable here, so a fixed-literal
wrapper spec is skipped rather than yielding a synthetic site.
"""
specs = HELPERS if helper_specs is None else helper_specs
maps_by_line = variable_maps_by_line(text, lib, overrides)
for lineno, line in join_logical_lines(text):
stripped = line.lstrip()
if stripped.startswith("#"):
continue
first = stripped.split(None, 1)
if not first or first[0] not in specs:
continue
toks = tokenize(stripped)
if not toks or "".join(v for _, v in toks[0]) != first[0]:
continue
path_vars, literal_vars = maps_by_line[lineno]
args = toks[1:]
lit_idx, file_idx, default_file = specs[first[0]]
if not isinstance(lit_idx, int):
continue
if lit_idx >= len(args):
# A pin call with too few args to carry its literal — malformed, but still
# surfaced as unresolved (literal=None) rather than silently dropped, honoring
# the "never silently skipped" contract.
yield {"lineno": lineno, "helper": first[0], "literal": None, "file": None}
continue
literal = resolve_arg(args[lit_idx], literal_vars, path_vars, want_path=False, lib=lib)
if file_idx < len(args):
fpath = resolve_arg(args[file_idx], literal_vars, path_vars, want_path=True, lib=lib)
elif default_file is not None:
fpath = path_vars.get(default_file)
else:
fpath = None
yield {
"lineno": lineno,
"helper": first[0],
"literal": literal,
"file": fpath,
}
# ── comment / rendering analysis of a target file ───────────────────────────
def hash_comment_regions(lines):
"""Return list of (lineno, comment_text) for #-comment regions, quote-aware."""
out = []
for i, line in enumerate(lines, 1):
insq = indq = False
start = None
j = 0
while j < len(line):
c = line[j]
if c == "\\" and (insq or indq):
j += 2
continue
if c == "'" and not indq:
insq = not insq
elif c == '"' and not insq:
indq = not indq
elif (
c == "#"
and not insq
and not indq
and (j == 0 or line[j - 1] in " \t")
):
# A `#` starts a shell/py comment only at a word boundary (line start
# or after whitespace) — mirroring tokenize()'s `not cur` rule. Keying
# on any unquoted `#` misclassified a mid-word `#` (e.g. `url#anchor`)
# as a comment start, moving operative text into the "comment" region
# and making a real collision go UNFLAGGED (a fail-open in the guard
# direction).
start = j
break
j += 1
if start is not None:
out.append((i, line[start:]))
return out
def md_comment_text(text):
return "\n".join(re.findall(r"<!--(.*?)-->", text, flags=re.DOTALL))
def md_fenced_hash_comment_spans(text):
"""Return {lineno: comment_text} for #-comment regions inside fenced code
blocks (``` / ~~~, language-tagged or indented) of a markdown target.
The #375 .md arm scanned only HTML ``<!-- … -->`` regions; a pin literal
quoted in a ``#`` comment inside a ```` ```bash ```` fence of a skill bundle
was folded into the operative "outside" text, so a #370-class count-inflation
collision there went unflagged (issue #394). Extracting these fenced ``#``
comments lets the .md arm subtract them from "outside" symmetrically with the
.sh/.py arm, so such a collision is flagged while a literal living ONLY in a
fenced comment (the ``lit in outside`` conjunct) still is not.
Fence tracking mirrors CommonMark's opener/closer rules enough for this use:
an opening fence is a line whose first non-space run is >=3 backticks or
tildes (a backtick opener's info string may not itself contain a backtick);
the matching closer is the same marker char, at least as long, with only
whitespace after it. Language-tagged fences and fences indented up to 3
spaces are handled; a run indented >=4 spaces is CommonMark *indented code*,
NOT a fence, so it is deliberately not treated as a fence marker — otherwise
a deeply-indented ``` in prose would spuriously open a never-closed fence and
fold every following operative ``#``-line into the comment region, a
fail-open that could hide a real #370-class collision (issue #394 review).
The fence markers themselves are never treated as content.
An UNTERMINATED fence fails closed (issue #394 review): a fence opener that
never meets a matching closer before EOF is suspect (a stray/unbalanced ```
in a malformed target), so its content lines are discarded rather than folded
into the comment region — otherwise every following operative ``#``-line (an
ATX heading, say) would be stripped out of "outside", masking a real
#370-class collision. Only lines inside a PROPERLY CLOSED fence are trusted.
"""
lines = text.split("\n")
fence = None # (char, length) while inside a fence, else None
inside = [] # (lineno, line) content lines strictly inside fences
committed = 0 # inside[:committed] are lines from PROPERLY CLOSED fences
for i, line in enumerate(lines, 1):
# 0-3 leading spaces only (>=4 is indented code, not a fence marker).
m = re.match(r"^ {0,3}(`{3,}|~{3,})(.*)$", line)
if fence is None:
# A backtick opener's info string must not contain a backtick.
if m and not (m.group(1)[0] == "`" and "`" in m.group(2)):
fence = (m.group(1)[0], len(m.group(1)))
continue
if (
m
and m.group(1)[0] == fence[0]
and len(m.group(1)) >= fence[1]
and m.group(2).strip() == ""
):
fence = None
committed = len(inside) # this fence closed cleanly — trust its lines
continue
inside.append((i, line))
# Fail closed on an UNTERMINATED trailing fence (issue #394 review): a stray or
# unbalanced opener that never meets a closer is suspect, so drop its content
# rather than fold every following operative `#`-line out of "outside" and mask a
# real #370-class collision. Only PROPERLY CLOSED fences' lines are trusted.
if fence is not None:
inside = inside[:committed]
spans = {}
for idx, ctext in hash_comment_regions([ln for _, ln in inside]):
spans[inside[idx - 1][0]] = ctext
return spans
def normalize_ws(s):
return " ".join(s.split())
def multiliteral_help_renderings(text):
"""Yield the concatenated rendering of each multi-literal argparse help=.
Detects `help=` followed by two or more adjacent string literals (Python's
implicit string concatenation, optionally parenthesized / across lines).
"""
out = []
for m in re.finditer(r"help\s*=\s*\(?", text):
i = m.end()
lits = []
while True:
# skip whitespace and line continuations
while i < len(text) and text[i] in " \t\r\n\\":
i += 1
if i >= len(text) or text[i] not in "'\"":
break
q = text[i]
# handle triple quotes
if text[i : i + 3] == q * 3:
end = text.find(q * 3, i + 3)
if end == -1:
break
lits.append(text[i + 3 : end])
i = end + 3
else:
j = i + 1
buf = []
while j < len(text) and text[j] != q:
if text[j] == "\\" and j + 1 < len(text):
buf.append(text[j + 1])
j += 1
else:
buf.append(text[j])
j += 1
lits.append("".join(buf))
i = j + 1
if len(lits) >= 2:
out.append("".join(lits))
return out
# ── the two guards ──────────────────────────────────────────────────────────
def _target_ext(path, md_targets):
"""Extension used to pick the comment syntax; a `--md`-flagged target (e.g. the
extensionless mktemp'd skill bundle, which is markdown) is treated as `.md`."""
if path in md_targets:
return ".md"
return os.path.splitext(path)[1]
def _strip_line_spans(lines, spans):
"""Remove each line-keyed comment suffix from `lines`, returning the joined
"outside-comments" text. Shared by the hash arm and the .md fenced-#-comment
arm (issue #394) so the two subtractions stay in lockstep rather than being
two hand-maintained copies of the same off-by-one-prone slice."""
return "\n".join(
(line[: len(line) - len(spans[i])] if i in spans else line)
for i, line in enumerate(lines, 1)
)
def _lint_view(path, ext, cache):
"""Memoized per-target-file comment analysis (read + comment regions + the
outside-comments text). Many pins share a target, so this is derived once per
file rather than once per pin."""
v = cache.get(path)
if v is not None:
return v
ftext, err = _read_target(path)
if err is not None:
v = ("unreadable", err, None)
cache[path] = v
return v
if ext in COMMENT_HASH_EXTS:
lines = ftext.split("\n")
comment_spans = {cln: ctext for cln, ctext in hash_comment_regions(lines)}
outside = _strip_line_spans(lines, comment_spans)
v = ("hash", comment_spans, outside)
elif ext in COMMENT_MD_EXTS:
# Comment regions of a .md target are BOTH its HTML <!-- … --> spans AND
# the #-comments inside its fenced code blocks (issue #394). Union them
# into `comments`, and subtract both from `outside` symmetrically so a
# literal living only in a fenced # comment is removed from "outside"
# (preserving the `lit in outside` conjunct) exactly as the .sh/.py arm.
fenced_spans = md_fenced_hash_comment_spans(ftext)
comment_text = md_comment_text(ftext)
if fenced_spans:
comment_text = comment_text + "\n" + "\n".join(fenced_spans.values())
without_fenced = _strip_line_spans(ftext.split("\n"), fenced_spans)
outside = re.sub(r"<!--.*?-->", "", without_fenced, flags=re.DOTALL)
v = ("md", comment_text, outside)
else:
v = ("none", None, None)
cache[path] = v
return v
def _wrapped_view(path, cache):
"""Memoized per-target-file wrapped-literal analysis (lines + whitespace-normalized
whole file + normalized multi-literal help= renderings). Derived once per file."""
v = cache.get(path)
if v is not None:
return v
ftext, err = _read_target(path)
if err is not None:
v = ("unreadable", err, None)
cache[path] = v
return v
helps = [normalize_ws(r) for r in multiliteral_help_renderings(ftext)] if path.endswith(".py") else []
v = (ftext.split("\n"), normalize_ws(ftext), helps)
cache[path] = v
return v
def _emit(sink, line):
"""The single stdout chokepoint for every finding line on a ``--strict``-covered
path (issue #687). Appends to ``sink`` — so ``--strict`` can key rc 3 on
"at least one line was written to stdout" — and prints the line unchanged, so
the stdout/stderr bytes are byte-identical with and without ``--strict``.
Defined OUTSIDE the ``run_lint`` … end-of-``_emit_wrapped_or_absent`` guard
range that ``lib/test/run.sh``'s issue-#687 emit-helper guard anchors over, so
the guard's ``grep -cE`` count of raw stdout-writing forms inside that range
stays 0. A future finding arm on a covered path MUST route through this helper
(never a bare ``print(`` / ``sys.stdout.write`` / ``os.write(1``) or the guard
goes RED; informational output on a covered path must go to ``sys.stderr``."""
sink.append(line)
print(line)
def run_lint(pin_source, lib, overrides, md_targets, strict=False):
text = _read(pin_source)
unresolved = 0
resolved = 0
collisions = []
view_cache = {}
sink = []
for pin in extract_pins(text, lib, overrides):
if pin["literal"] is None or pin["file"] is None:
unresolved += 1
sys.stderr.write(
f"UNRESOLVED\t{pin_source}:{pin['lineno']}\t{pin['helper']}\t"
f"literal={'?' if pin['literal'] is None else 'ok'}\t"
f"file={'?' if pin['file'] is None else pin['file']}\n"
)
continue
if not os.path.isfile(pin["file"]):
unresolved += 1
sys.stderr.write(
f"UNRESOLVED\t{pin_source}:{pin['lineno']}\t{pin['helper']}\t"
f"target-missing={pin['file']}\n"
)
continue
ext = _target_ext(pin["file"], md_targets)
kind, comments, outside = _lint_view(pin["file"], ext, view_cache)
if kind == "unreadable":
unresolved += 1
sys.stderr.write(
f"UNRESOLVED\t{pin_source}:{pin['lineno']}\t{pin['helper']}\t"
f"target-unreadable={pin['file']} ({comments})\n"
)
continue
resolved += 1
lit = pin["literal"]
# The defect (#370): a comment occurrence that COEXISTS with an operative
# occurrence — it inflates the count / can mask a refactored-away operative
# site. A literal that lives ONLY in a comment (an SPDX-header pin, a
# deliberately comment-targeted contract) is the pin's intended home, not the
# count-inflation defect, so it is NOT flagged. Hence: flag only when the
# literal appears in a comment AND ALSO outside every comment region.
if kind == "hash":
in_comment_line = next((cln for cln, ctext in comments.items() if lit in ctext), None)
if in_comment_line is not None and lit in outside:
collisions.append((pin, in_comment_line))
elif kind == "md":
if lit in comments and lit in outside:
collisions.append((pin, None))
for pin, cln in collisions:
loc = f":{cln}" if cln else ""
_emit(sink, f"COLLISION\t{pin['file']}{loc}\t{pin['helper']}@{pin_source}:{pin['lineno']}\t{pin['literal']}")
sys.stderr.write(f"UNRESOLVED-COUNT\t{unresolved}\n")
sys.stderr.write(f"RESOLVED-COUNT\t{resolved}\n")
return 3 if strict and sink else 0
# ── #661 relocation diagnosis ───────────────────────────────────────────────
def _git_ls_files():
"""Enumerate tracked files with the granted ``git ls-files``. Returns
(paths, None) on success or (None, reason) fail-closed on any error / empty
output — the caller must NOT collapse a failed enumeration to "deleted"."""
try:
res = subprocess.run(
["git", "ls-files", "-z"], capture_output=True, text=True, check=False
)
except (OSError, UnicodeDecodeError) as exc:
# UnicodeDecodeError (a ValueError, NOT an OSError) can surface from text=True
# eager decoding of a non-UTF-8 tracked filename; catch it too so the docstring's
# "fail-closed on any error" holds rather than crashing the scan.
return None, f"git-ls-files-error:{type(exc).__name__}"
if res.returncode != 0:
return None, f"git-ls-files-rc:{res.returncode}"
paths = [p for p in res.stdout.split("\0") if p]
if not paths:
return None, "git-ls-files-empty"
return paths, None
def resolve_reloc_search_set(explicit_file):
"""Resolve the relocation search set. An explicit ``--reloc-search-set`` file
(the git-free self-test path) wins; otherwise ``git ls-files``. A file that is
unreadable, or a raw enumeration that fails or is empty, returns (None, reason)
so the ABSENT branch fails closed rather than reporting a false deletion."""
if explicit_file is not None:
# Read through _read_target, which catches (OSError, UnicodeDecodeError):
# a non-UTF-8 --reloc-search-set file raises UnicodeDecodeError (a ValueError,
# NOT an OSError), and a bare `except OSError` would let it escape and crash
# the scan instead of taking this docstring's fail-closed (None, reason) arm.
raw, reason = _read_target(explicit_file)
if reason is not None:
return None, f"search-set-unreadable:{reason}"
paths = [ln.strip() for ln in raw.splitlines() if ln.strip()]
if not paths:
return None, "search-set-empty"
return paths, None
return _git_ls_files()
def _reloc_excluded(path, exclude_tokens):
"""A search-set path is excluded when any exclude token is a substring of it
(the distinctive ``.prflow/vendor/`` / ``.prflow/tmp/`` trees, or a
pin-source path/prefix) OR resolves to the same file (abspath-equal). Substring
matches a temp-dir stand-in like ``/tmp/xxx/.prflow/vendor/copy.md`` against the
same token a repo-relative ``.prflow/vendor/…`` path does; the abspath-equality
arm is load-bearing for the pin-source auto-exclude, because ``git ls-files``
emits **repo-relative** paths (``lib/test/run.sh``) while the pin-source token is
the **absolute** ``$LIB/test/run.sh`` — a substring test alone never matches those
two spellings, so without abspath-equality the auto-exclude would silently no-op
and a deleted pin's literal would self-match its own declaration in run.sh."""
apath = os.path.abspath(path)
for tok in exclude_tokens:
if not tok:
continue
if tok in path or apath == os.path.abspath(tok):
return True
return False
def _literal_resolves_in(lit, nlit, path, cache):
"""Tri-state: ``True`` when the pin literal resolves in a candidate file (on a
single line, in the whitespace-normalized rendering — a wrapped-adjacent-literal
destination, #375 — or in a multi-literal argparse help= rendering), ``False``
when the file was read but does not contain it, and ``None`` when the candidate
is UNREADABLE. The None arm is load-bearing: a swallowed read error on the very
file a literal moved into would otherwise let ``diagnose_relocation`` report a
false ``deleted`` — the AC5 masquerade at per-candidate granularity — so the
caller must surface unreadable candidates rather than treat them as 'not here'."""
view = _wrapped_view(path, cache)
if view[0] == "unreadable":
return None
lines, nfile, helps = view
if any(lit in ln for ln in lines):
return True
if nlit and nlit in nfile:
return True
return bool(nlit and any(nlit in h for h in helps))
def diagnose_relocation(lit, nlit, target, search_paths, exclude_tokens, cache):
"""Given the resolved (non-None) search set, return
``(sorted_dests, unreadable_paths)``: the files (excluding the
pin-source/vendor/tmp set and the target itself) where the literal resolves, and
the candidates that could not be read. An empty ``dests`` with an empty
``unreadable`` means a genuine deletion; an empty ``dests`` with a non-empty
``unreadable`` means the diagnosis is INCOMPLETE — the caller must not claim a
clean deletion over swallowed read errors (fail-closed, AC5 spirit)."""
dests = []
unreadable = []
for path in search_paths:
if path == target or _reloc_excluded(path, exclude_tokens):
continue
resolved = _literal_resolves_in(lit, nlit, path, cache)
if resolved is None:
unreadable.append(path)
elif resolved:
dests.append(path)
return sorted(set(dests)), sorted(set(unreadable))
def run_wrapped(pin_source, lib, overrides, md_targets,
reloc=False, reloc_search_file=None, reloc_exclude=None,
strict=False):
text = _read(pin_source)
unresolved = 0
resolved = 0
view_cache = {}
sink = []
# Resolve the relocation search set ONCE (issue #661) — only when --reloc is on.
# A resolution failure is carried as (None, reason): the ABSENT branch then reports
# "relocation diagnosis unavailable" and never a false "deleted". The pin-source file
# is auto-excluded (a pin literal is present in its own declaration by construction),
# alongside the always-on vendor/tmp trees and any --reloc-exclude substring token.
reloc_paths, reloc_err = (None, None)
reloc_excludes = ()
if reloc:
reloc_paths, reloc_err = resolve_reloc_search_set(reloc_search_file)
reloc_excludes = (
(pin_source,) + tuple(RELOC_DEFAULT_EXCLUDES) + tuple(reloc_exclude or ())