Skip to content

ci(portability-lint): define and guard the stack/ecosystem token class (stage 3) - #1790

Merged
kyle-sexton merged 2 commits into
mainfrom
ci/714-portability-stage3-ecosystem-class
Jul 30, 2026
Merged

ci(portability-lint): define and guard the stack/ecosystem token class (stage 3)#1790
kyle-sexton merged 2 commits into
mainfrom
ci/714-portability-stage3-ecosystem-class

Conversation

@kyle-sexton

Copy link
Copy Markdown
Contributor

Stage 3 of #531's portability-lint lane: the stack/ecosystem opinion class — baked stack
defaults in skills that claim to work in any repository.

The operator ratification recorded on this issue fixed the shape: staged per-token rollout, no bulk
activation, and per-token activation only after (1) a POSIX-safe rewrite, (2) a green --all audit,
and (3) a class-scoped guard marker. This lands all three, plus the fixtures the acceptance criteria
ask for.

The staged patterns were inert, not merely dormant

\bdotnet\b and \bClean Arch\b could not have worked. POSIX ERE defines no word-boundary escape,
and on gawk as well as mawk/nawk a backslash-b matches a literal backspace byte — so those
patterns matched nothing. Uncommenting either would have turned the class green while missing every
violation, which is the worst outcome a gate has.

Both are replaced with POSIX-safe spellings rather than uncommented, and a fixture now fails if
a backslash-b ever reaches an ACTIVE token again.

One token activates, on measured evidence

Clean Arch(itecture)? is now ACTIVE. It measures 0 hits / 0 files through the gate, and a
raw grep finds the phrase nowhere in the scanned corpus. It carries no migration debt whatsoever —
the one member of this class that can be locked in before it arrives, which is exactly the
prevention #531 exists for ("prevention instead of hand-fixes plus indefinite recurrence"). A skill
whose subject genuinely is that architecture declares portability-scope, and a fixture pins that
escape so the active token cannot strand it.

The ecosystem literals stay staged, with real numbers

Residue measured through the gate itself at c7b0af78, so guards, per-site annotations, and
whole-file scope declarations are already applied to the counts:

Pattern Hits Files
[Dd]otnet 107 40
(^|[^a-zA-Z0-9_])\.NET([^a-zA-Z0-9_]|$) 63 37
\.csproj 26 17
(^|[^a-zA-Z0-9_])C#([^a-zA-Z0-9_]|$) 19 15
[Bb]lazor 13 5
\.sln([^a-zA-Z0-9_]|$) 7 7
\.razor 1 1
\*\*/\*\.cs([^a-zA-Z0-9_]|$) 1 1

Their shared blocker is now recorded in the token file rather than left implicit: these are not
stray hardcodes, they are skills that legitimately need to name an ecosystem and have no seam to
read one from. The fix is the userConfig seam work on #405 / #406 / #421 / #428 — the lint is the
forcing function, not the fix — or a portability-scope declaration on genuinely ecosystem-locked
files.

The class-scoped guard

The class is excused by consumer-seam evidence co-located on the line — never by the branch
class's evidence:

  1. a ${user_config.*} read (the seam this class exists to force), anchored to the read syntax so
    prose about a seam does not count; or
  2. a neutral <placeholder> and an explicit illustration lead (e.g., for example,
    such as) — the portable idiom already used across this corpus, where the ecosystem token
    illustrates the placeholder rather than being the value the skill ships.

Both halves of (2) are required. A placeholder alone would excuse run dotnet build in <repo-root>,
which ships the stack and parameterizes only the path; an illustration lead alone would excuse
e.g. run dotnet build, the bare hardcode wearing a hedge — precisely the generic hedge the token
file's staged-class preamble forbids as a guard.

Deliberately not a marker: "another ecosystem is named on the same line". That multi-ecosystem
enumeration shape is the dominant residual in the corpus and reads as correct, but a co-occurrence
test cannot distinguish it from a line that hardcodes one stack and merely mentions another. It is
recorded in the token file as the shape the next activator must handle, with portability-ok as its
escape.

A bug the fixtures caught, worth calling out

Class membership is tested with index() on literal fragments, never a regex match against the
pattern text. My first revision used p ~ /[Dd]otnet/ — copying the branch class's shape — which
asks for a D-or-d followed by otnet and therefore does not match the pattern string
[Dd]otnet, whose d is followed by ]otnet. It silently returned 0 for every member, leaving the
whole class unguarded while every flagging test still passed. It surfaced only because the
honored-case fixtures were written, which is why they are part of the deliverable rather than a
nice-to-have. index() removes the trap instead of re-solving the escaping per pattern.

Acceptance criteria

  • Same lane discipline as Stages 1–2 — patterns are data in skill-portability-tokens.txt,
    staged entries carry measured residue and a named blocker, assert_staged pins every staged
    pattern verbatim so an edit that stops catching its own defect fails loudly.
  • Fixtures proving the class is flagged where undeclared and honored where a userConfig seam
    declares it
    — both directions, plus prose-about-a-seam failing to stand in for a seam, each half
    of the placeholder guard failing alone, the per-site and whole-file escapes, and class scoping
    proven both ways (branch-resolution evidence does not excuse an ecosystem hardcode; a seam
    read does not excuse a branch default).

Verification

  • bash scripts/check-skill-portability.test.shPASS=65 FAIL=0 (was 44; 21 added)
  • bash scripts/check-skill-portability.sh --all — the only hit repo-wide is one pre-existing
    branch-class line in plugins/playbooks/skills/boris/reference/foundations.md; the newly active
    Clean-Architecture token contributes 0. CI is changed-file scoped, so this never red-lines
    main.
  • bash scripts/check-shell-portability.sh --paths <the two changed scripts> — clean. The \b
    assertion is written as the bracket expression [\]b so it does not trip the sibling gate that
    agrees with it.
  • bash scripts/check-shell-portability.test.sh — PASS=215 FAIL=0 (sibling gate unaffected)
  • shellcheck on both changed scripts — clean
  • scripts/check-changelog-parity.sh --check-bump origin/main, scripts/check-changed-skills.sh
    pass. No plugin version bump: the diff is repo tooling under scripts/, not a plugin.
  • typos scripts/ exit 0

Related

🤖 Generated with Claude Code

https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C

…s (stage 3)

Stage 3 of #531 owns the stack/ecosystem opinion class: baked stack defaults in
agnostic-flagged skills. The operator ratification on #714 fixed the shape —
staged per-token rollout, no bulk activation, and per-token activation only
after a POSIX-safe rewrite, a green `--all` audit, and a class-scoped guard
marker. This lands all three.

The previously staged spellings were INERT, not merely dormant. POSIX ERE
defines no word boundary, and on gawk as well as mawk/nawk a backslash-b matches
a literal backspace byte, so `\bdotnet\b` and `\bClean Arch\b` matched nothing:
uncommenting either would have gone green while missing every violation. Both
are replaced with POSIX-safe spellings rather than uncommented, and a fixture
now fails if a backslash-b ever reaches an ACTIVE token.

Clean-Architecture vocabulary is ACTIVATED. It measures 0 hits in 0 files
through the gate, and a raw grep finds the phrase nowhere in the scanned corpus,
so it carries no migration debt at all — the one member of this class that can
be locked in before it arrives, which is the prevention the umbrella issue asks
for. A skill whose subject genuinely is that architecture declares
portability-scope; a fixture pins that escape so the token cannot strand it.

The ecosystem literals stay staged with their residue measured through the gate
itself at c7b0af7, so guards, per-site annotations, and scope declarations are
already applied to the counts. Their shared blocker is recorded: these are not
stray hardcodes but skills that legitimately need to name an ecosystem and have
no seam to read it from, which is the userConfig seam work on #405/#406/#421/#428.

The class gets its own guard markers, scoped so they never leak between classes:
consumer-seam evidence co-located on the line, either a `${user_config.*}` read
or a neutral placeholder together with an explicit illustration lead. Both
halves of the second form are required — a placeholder alone excuses
`run dotnet build in <repo-root>`, which ships the stack and parameterizes only
the path, and an illustration lead alone excuses `e.g. run dotnet build`, the
bare hardcode wearing a hedge.

Class membership is tested with index() on literal fragments, never a regex
match against the pattern text. A regex reads the pattern as syntax, so
`p ~ /[Dd]otnet/` does not match the pattern STRING `[Dd]otnet` — it silently
returns 0 for every member and leaves the class unguarded while the
flagging tests still pass. That failure was caught by writing the honored-case
fixtures, which is why they are part of the deliverable.

Fixtures cover both directions per the acceptance criteria: flagged where
nothing declares the stack, honored where a userConfig seam supplies it, and
honored under the per-site and whole-file escapes — plus prose about a seam
failing to stand in for a seam, each half of the placeholder guard failing
alone, and class scoping proven both ways (branch-resolution evidence does not
excuse an ecosystem hardcode; a seam read does not excuse a branch default).

Refs #714

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RhS3T7ShwJgKTrvk2Mvd3C
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 35s —— View job


Code Review

  • Gather context and read changed files
  • Review skill-portability-tokens.txt
  • Review check-skill-portability.sh
  • Review check-skill-portability.test.sh
  • Post findings

Overall this is a well-engineered stage. The POSIX-safety fix (replacing \b with safe spellings before they could be activated), the class-scoped guard design, and the index() over regex decision all demonstrate careful reasoning. No blocking issues. Four observations below, roughly in severity order.


1. index(p, "NET") is a broad fragment — future tokens could accidentally inherit the ecosystem guard

File: scripts/check-skill-portability.sh

return index(p, "otnet") || index(p, "NET") || index(p, "Clean Arch") || ...

"NET" as a substring fragment is the broadest match here. Any future token pattern containing that uppercase trigram — NETRC, net_timeout, or a hypothetical NETWORK_CALL tracker token — would silently receive the consumer-seam guard instead of falling through to return 0. The comment explains the index() rationale (metacharacter-safety) but not the fragment choice itself.

The .NET pattern string on disk is (^|[^a-zA-Z0-9_])\.NET([^a-zA-Z0-9_]|$). A search for the literal substring \.NET (with the backslash) would still be metacharacter-free as an index() needle and would be considerably more precise — no plausible future portability token contains a literal \.NET fragment except the .NET ecosystem pattern.

Low risk today given the gate's narrow scope, but worth tightening before the next ecosystem class arrives.

Fix suggestion →


2. is_ecosystem_pattern and the token list can silently drift

File: scripts/check-skill-portability.sh

The assert_staged test discipline enforces that staged patterns don't silently change shape. There is no parallel discipline asserting that a staged pattern's fragment is covered by is_ecosystem_pattern. If someone adds a ninth ecosystem literal to skill-portability-tokens.txt without updating the function, the new token flags correctly but its guard is silently absent — activating it would produce false positives on legitimately guarded lines with ${user_config.*} reads.

A lightweight complement to assert_staged could assert that is_ecosystem_pattern returns true when called with each staged ecosystem pattern, using a one-token token file and a fixture line that carries a ${user_config.*} seam. This would bind the function and the token list at test time the same way assert_staged binds the pattern text and the file.

Not strictly required today (the comment in the script documents the maintenance obligation), but worth noting as the class grows.


3. Placeholder regex is lowercase-only; uppercase placeholders would not satisfy the guard

File: scripts/check-skill-portability.sh

(l ~ /<[a-z][a-z0-9-]*>/ && l ~ /(e\.g\.|for example|such as)/)

A line like Run `<BUILD-COMMAND>` — e.g. `dotnet build` would not satisfy the placeholder half of the guard because [a-z] does not match uppercase. If the corpus uses <SCREAMING-CASE> placeholders (common in some documentation traditions), those lines would flag where the lowercase variants would pass.

This is probably a deliberate or incidental convention match — the corpus appears to use lowercase-hyphenated placeholders consistently — but the constraint is undocumented. Adding a brief comment (# placeholder must be lowercase-hyphenated, per corpus convention) makes the choice explicit and prevents a contributor from concluding it's an oversight when they write their first uppercase placeholder.


4. Nit: test suite only exercises e.g. as an illustration lead; "for example" and "such as" go untested

File: scripts/check-skill-portability.test.sh

The guard accepts three forms (e.g., for example, such as), but only the e.g. form is exercised in a fixture. A future edit that drops for example or such as from the guard regex would pass the full test suite unchanged. One additional "for example" fixture (pass) and one "such as" fixture (pass) would close this gap at the cost of two tests.


Non-issues worth affirming

  • \b replacement rationale — replacing \bdotnet\b and \bClean Arch\b with POSIX-safe spellings rather than uncommentng them is the right call. The staged-inert-rather-than-dormant framing in the PR description is accurate and the fix is minimal.
  • index() over /[Dd]otnet/ for class membership — the PR description documents exactly why regex matching against the pattern text silently misfired. The documented failure (p ~ /[Dd]otnet/ not matching the string [Dd]otnet) is real and index() with a fragment that is metacharacter-free in the token string is the correct fix.
  • Both-halves-required for the placeholder guard — the reasoning is sound (placeholder alone still ships the stack; illustration lead alone is just a hedge) and both halves are independently tested in the fixture.
  • Class-scoped guards tested in both directions — branch-detection evidence not excusing an ecosystem hardcode, and a ${user_config.*} seam read not excusing a branch default, are both pinned. This is the right coverage shape for a scoping invariant.
  • assert_staged for the POSIX-safety check — using a bracket expression [\]b to search for a literal backslash-b in active tokens, specifically so the search itself doesn't trip the sibling gate, is a good self-referential correctness measure.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 3m 5s —— View job


Security Review — PR #1790

  • Load tools
  • Fetch PR diff
  • Read changed files
  • Analyze for security issues
  • Post findings

Scope: scripts/check-skill-portability.sh, scripts/check-skill-portability.test.sh, scripts/skill-portability-tokens.txt

No traditional security vulnerabilities were found — no command injection, path traversal, credential exposure, or dangerous GitHub Actions patterns. The AWK patterns from the token file are used as regex operands in line ~ patterns[i], which is not code execution; a malformed pattern causes awk to exit non-zero and the already-present fail-closed guard catches it. Shell expansions are correctly quoted. Temp files in the test suite use mktemp with no TOCTOU exposure.

Two logic-layer findings worth flagging:


SUGGESTION — Confidence: MEDIUM
"NET" fragment in is_ecosystem_pattern() is a forward-looking collision hazard

check-skill-portability.shis_ecosystem_pattern, line 241

return index(p, "otnet") || index(p, "NET") || index(p, "Clean Arch") ||
  index(p, "csproj") || index(p, "sln") || index(p, "razor") ||
  index(p, "lazor") || index(p, "C#") || index(p, ".cs(")

"NET" (3 characters) appears as a substring in many unrelated strings: NETRC, NETWORK, INTERNET, NETCONF, SOCKET, etc. Every other fragment chosen here is narrow enough to be unique within any plausible future token ("otnet", "Clean Arch", "csproj", "lazor", ".cs("). "NET" is the outlier.

If a future token class adds a pattern whose text contains "NET" — for instance a network-access class with NETRC_FILE or a socket class with CONNECT_TIMEOUT (hypothetically) — is_ecosystem_pattern() would misclassify it as an ecosystem pattern. The ecosystem guard would then apply, and a genuine violation of that future class could be silently excused by a ${user_config.anything} seam read or a <placeholder> e.g. ... illustration on the same line. The gate produces a false negative while appearing to pass.

The design contract is "more token classes will follow," so the collision risk grows. Narrowing to "\.NET" (the literal that actually appears in the token) would eliminate the hazard without changing current behavior.


SUGGESTION — Confidence: LOW-MEDIUM
Placeholder guard matches single-letter HTML tags

check-skill-portability.shis_guarded, line 252

l ~ /<[a-z][a-z0-9-]*>/ && l ~ /(e\.g\.|for example|such as)/

<[a-z][a-z0-9-]*> is intended to match <build-command>-style placeholder syntax, but it also matches single-letter HTML tags: <a>, <b>, <i>, <p>, <s>. Markdown skill files allow inline HTML. A skill line like:

See the <a> docs for details, e.g. run dotnet build

satisfies both conditions — <a> is a "placeholder" match and e.g. is the illustration lead — so the dotnet reference is excused without a real portability seam being present. In practice this is low-friction to trigger intentionally and unlikely to appear accidentally, but it is a detectable bypass path for the guard. Tightening the minimum tag length to two characters (<[a-z][a-z0-9-][a-z0-9-]*>) would exclude all single-letter tags while still matching every conventional placeholder shape in the corpus.


No further security concerns with this diff.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 28b1d4df54

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-skill-portability.sh Outdated
Comment thread scripts/skill-portability-tokens.txt Outdated
Phrase-delimit the active Clean-Architecture token. `Clean Arch(itecture)?`
had no outer boundaries, so it matched inside a preceding word
(`UnClean Architecture`) and where the optional suffix stopped short
(`Clean Archive directory`), reporting ordinary prose as a violation. Use the
explicit `[^a-zA-Z0-9_]` boundaries this file already uses for `.NET` and
`C#`, and drop the claim that a multi-word phrase is self-delimiting.

Give the architecture token its own guard. It inherited the class-wide
consumer-seam marker, so ANY `${user_config.*}` read on the line excused it
— `Read ${user_config.test_command}, then place handlers per Clean
Architecture` passed the gate. The token file names exactly two escapes for
this token, `portability-scope` and placeholder-plus-illustration, and a
userConfig read is neither; evidence has to parameterize the coupling it
excuses. This is the only ACTIVE token in the class, so it was the one live
false negative.

Narrow the `.NET` class-membership fragment from `NET` to `\.NET`. A bare
`NET` is a substring of `NETRC`, `CONNECT`, and `INTERNET`, so a future token
class carrying that trigram would silently inherit this class's guard.
`assert_staged` pins the pattern spelling verbatim, so the fragment and the
pattern it selects cannot drift apart.

Factor the placeholder-plus-illustration test into one helper shared by both
branches, and record the residual it knowingly admits: inline HTML shares the
placeholder spelling space, and one line of text cannot separate them here
because `<b>`, `<i>`, `<p>`, `<s>`, and `<n>` are all genuine CLI operands in
this corpus. A minimum length rejects real placeholders and a tag denylist
enumerates instances of an open-ended shape, so the per-site portability-ok
remains the remedy — the same posture this file records for the
multi-ecosystem enumeration.

Bind the token list to the membership test: every staged ecosystem literal is
now asserted to be RECOGNIZED by is_ecosystem_pattern, not just present
verbatim. Each is checked twice so a fixture that never matched its pattern
cannot pass vacuously. Cover both remaining illustration leads and the new
architecture boundaries.
@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 4m 8s —— View job


PR Review — Stage 3: Stack/Ecosystem Token Class

  • Read changed files and PR diff
  • Review skill-portability-tokens.txt
  • Review check-skill-portability.sh
  • Review check-skill-portability.test.sh
  • Assess prior review findings and post findings

This is a two-commit PR. The second commit (872d474) directly addresses every finding raised in the prior review round. I verified each resolution below, then did a fresh read of the shipped code.

Prior findings — disposition

Claude review finding #1index(p, "NET") too broad
Fixed → index(p, "\\.NET"). In AWK "\\.NET" is the four-character string \.NET, which matches precisely the literal substring as it appears in the staged pattern text (^|[^a-zA-Z0-9_])\.NET([^a-zA-Z0-9_]|$). The comment added around it explains why NET alone collides with NETRC, CONNECT, etc., making the fragment choice self-documenting.

Claude review finding #2is_ecosystem_pattern and the token list can drift silently
Fixed → the STAGED_COVERAGE loop asserts both directions for each of the eight staged literals: first that the control fixture actually triggers the pattern (proving the assertion is non-vacuous), then that the guarded fixture clears it (proving fragment coverage). This is the right shape — it binds is_ecosystem_pattern to the token list at test time the same way assert_staged binds the pattern text.

Claude review finding #3 — lowercase-only placeholder undocumented
Addressed in comments. The restriction is now explicit: uppercase <SCREAMING-CASE> is deliberately excluded because nothing in the corpus uses it. This is the correct disposition — a comment rather than a code change.

Claude review finding #4for example and such as untested
Fixed → two new fixtures added in 872d474. A future edit dropping either lead from the guard regex now fails the suite.

Codex finding #1 — architecture token inherits userConfig guard
Fixed → is_architecture_pattern is separated from is_ecosystem_pattern and dispatched first in is_guarded. The architecture path returns only is_illustrated_placeholder(l), never the userConfig branch. Verified against the new fixture: "Read \${user_config.test_command}`, then place handlers per Clean Architecture."` is expected to flag, and the code correctly produces that behavior.

Codex finding #2 — architecture token not phrase-delimited
Fixed → token is now (^|[^a-zA-Z0-9_])Clean Arch(itecture)?([^a-zA-Z0-9_]|$). I traced both boundary cases:

  • "UnClean Architecture": the character preceding "Clean" is n, which is in [a-zA-Z0-9_], so [^a-zA-Z0-9_] does not match → no false positive ✓
  • "Clean Archive": after matching "Clean Arch", the optional (itecture)? matches empty; then ([^a-zA-Z0-9_]|$) must match ii is alphanumeric and not end-of-string → no false positive ✓
    Both boundary cases now have pinning fixtures in the test suite.

Fresh read — one observation

is_architecture_pattern coverage is implicit, not asserted

The STAGED_COVERAGE loop asserts that is_ecosystem_pattern covers each of the eight staged literals. There is no equivalent assertion that is_architecture_pattern covers the active architecture pattern. The coverage does exist, but it is implicit: if is_architecture_pattern were broken, the illustration escape fixture ("Organize by '<architecture-style>' — e.g. Clean Architecture, vertical slices.") would fail — because without the architecture dispatch, is_guarded would fall through to return 0 and the line would flag instead of passing. So the fixture catches the failure as a side effect, but it is not labeled as a coverage assertion the way STAGED_COVERAGE is.

This is not a blocker — the coverage exists and the test will catch the regression — but it is worth noting for symmetry as the class grows. The cost of closing it explicitly would be a single short block (pattern text → is_architecture_pattern → flag on 0 return), following the same control-then-guarded shape.


Non-issues worth confirming

  • is_guarded dispatch order: is_architecture_pattern runs before is_ecosystem_pattern. The architecture pattern text contains none of the ecosystem fragments (otnet, \.NET, csproj, etc.), so the current ordering has no behavioral effect — but the intent is that architecture gets the stricter guard (illustration only, never userConfig), and placing it first is the safe expression of that. ✓
  • HTML tag residual: The known bypass via single-letter HTML tags (<a>, <b>, etc.) is now explicitly documented with reasoning for why the obvious fixes (minimum length, denylist) both have problems. Recording it as a deliberate known residual is the right call. ✓
  • STAGED_COVERAGE control fixtures: The samples for .razor ("Page.razor here") and \*\*/\*\.cs ("the **/*.cs glob") are syntactically awkward as prose, but the control step verifies they actually match their pattern before asserting the guarded case — so the awkwardness can't produce a vacuous test. ✓
  • assert_staged + STAGED_COVERAGE together: The two disciplines are complementary and non-overlapping. assert_staged prevents a pattern from changing shape without notice; STAGED_COVERAGE prevents a shape change in is_ecosystem_pattern from silently unguarding a valid pattern. Both are needed and both are present. ✓

No blocking issues. This is a clean, correct, well-documented stage.

@claude

claude Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Claude finished @kyle-sexton's task in 5m 55s —— View job


Security Review — PR #1790

  • Load tools
  • Fetch PR diff
  • Read changed files
  • Analyze for security issues
  • Post findings

Scope: scripts/check-skill-portability.sh, scripts/check-skill-portability.test.sh, scripts/skill-portability-tokens.txt

No GitHub Actions workflows were changed, so zizmor's supply-chain / trigger / injection advisory lane has nothing to assess here.


Prior security findings — both resolved in HEAD

The first security pass (on commit 28b1d4d) flagged two issues. Both are addressed in commit 872d474:

"NET" fragment → now "\\.NET"
The is_ecosystem_pattern function previously used index(p, "NET"), a three-character trigram broad enough to match future token patterns that happen to contain that substring (e.g. a network or NETRC class), silently granting those patterns the ecosystem guard and creating a false-negative path. The fix is at check-skill-portability.sh line 251: index(p, "\\.NET") matches only the literal \.NET as it appears in the staged pattern text and is narrow enough to exclude any plausible future token.

Architecture guard isolated from the userConfig seam
The initial Stage 3 design applied the consumer-seam guard (a ${user_config.*} read on the line) to the whole ecosystem class, which included the Clean Architecture token. An unrelated setting read on the same line — Read ${user_config.test_command}, then place handlers per Clean Architecture — would have excused the active architecture token, which is a false negative on the one currently-gated member of the class. is_guarded() now dispatches to is_architecture_pattern(p) before is_ecosystem_pattern(p) (lines 289–294), returning is_illustrated_placeholder only — no userConfig path. The test at line 855 pins this explicitly.


No injection surfaces

AWK patterns from the token file are loaded as strings (patterns[++np] = line) and evaluated with line ~ patterns[i]. AWK ERE matching cannot execute code. A crafted pattern can only:

  • Produce false positives (match everything) — the gate fails loudly, not silently
  • Produce false negatives (match nothing) — the \b case already documented and guarded by assert_staged
  • Be malformed — awk exits non-zero; the || { exit 2 } guard catches it and fails closed

No shell interpolation of token file content occurs. The fail-closed mechanism is confirmed by the test at line 143.


SUGGESTION — Confidence: LOW

index(p, "sln") is slightly broader than the "\.NET" bar the comment establishes

check-skill-portability.sh line 253

The in-code comment at lines 242–249 explains why "\\.NET" was chosen over the bare "NET" — narrower fragment, fewer collision candidates. The same rationale applies to "sln": the staged pattern is \.sln([^a-zA-Z0-9_]|$), and the literal text \.sln( (with the backslash and opening parenthesis) is a safe fragment that uniquely identifies it, the same way "\\.NET" uniquely identifies the .NET pattern. A future token whose pattern text happens to contain the three characters sln would silently inherit the consumer-seam guard. The risk is very low given the narrow portability-lint domain, but the comment's own rationale would support tightening it to "\\.sln(" for consistency.

No other security concerns with this diff.


@kyle-sexton

Copy link
Copy Markdown
Contributor Author

Classifying the six findings from the two automated reviews on 28b1d4df individually. Every claim was reproduced against the branch before acting on it; fixes are in 872d474c.

# Source Finding Classification Evidence
1 code review, item 1 index(p, "NET") is a broad fragment — a future token carrying that trigram silently inherits the consumer-seam guard VALID — fixed in 872d474 index("NETRC_FILE", "NET") returns 1; index("NETRC_FILE", "\\.NET") returns 0, and \.NET is present at offset 18 in the real pattern text. Narrowed as suggested.
2 code review, item 2 nothing binds a staged pattern to its is_ecosystem_pattern fragment, so the two can drift and a new literal goes silently unguarded VALID — fixed in 872d474 Built the coverage test you described, through the CLI surface. Each of the 8 staged literals is now asserted to be RECOGNIZED by the guard, not merely present verbatim.
3 code review, item 3 the placeholder regex is lowercase-only and the constraint is undocumented VALID — fixed in 872d474 Confirmed [a-z] rejects <BUILD-COMMAND>. Documented as a deliberate corpus-convention match rather than widened.
4 code review, item 4 only e.g. is exercised; dropping for example or such as from the regex would pass the suite VALID — fixed in 872d474 Added one passing fixture for each of the two untested leads.
5 security review, item 1 the same NET fragment collision hazard, reached independently VALID — fixed in 872d474 Same change as row 1. Two independent reviews converging on it is why I took it despite the narrow blast radius today.
6 security review, item 2 the placeholder half matches single-letter HTML tags, so See the <a> docs, e.g. run dotnet build satisfies both halves with no real seam VALID — bypass confirmed; the proposed remedy is refuted by the corpus, so the boundary is documented instead, in 872d474 The bypass reproduces exactly as described. The suggested minimum length does not hold here: see below.

Detail on the rows that did more than apply the suggestion.

Row 2. The two failures are genuinely separate, which is what made this worth building: assert_staged pins the pattern TEXT, and a literal staged with no matching fragment would flag correctly while being silently unguarded — so activating it would reject the very consumer seam the class exists to force. Each literal is asserted twice, because the guarded case alone is vacuous: a fixture that never matched its pattern also produces a clean scan. A control line proves the hit exists, then the guarded line proves the fragment covers it. The first version of this loop split records on |, which the patterns themselves contain; it reported four false failures and is now a flat pair array.

Row 6. The bypass is real and I am not disputing it, but the proposed fix does not survive the corpus. A minimum tag length of two would break genuine placeholders here: <n> appears 121 times, plus <s>, <p>, <b>, <i>, <r>, <w>, <x> and others as CLI operands — four of the five spellings the finding names as HTML tags are real placeholders in this corpus. It would also still admit <br>, <em>, and <td>, so it trades a deliberate-only bypass for a false-positive class without closing the hole.

I also considered and rejected excluding known HTML tag names. It answers a class of shape with a list of instances (<var>, <q>, <abbr> or any invented token walk straight through), its correctness rests on a single corpus measurement in a file that warns in its own voice that "a count is a measurement with a shelf life", and it leaves no principled answer to why <a> is excluded but <var> is not.

Inline HTML and placeholders share the same spelling space here, so one line of text cannot separate them. That is the identical situation this token file already records for the multi-ecosystem enumeration — "a co-occurrence test cannot tell it from a line that hardcodes one stack and merely mentions another. It uses the per-site portability-ok escape until someone brings evidence for a tighter rule." Rather than invent a second posture for the same problem, I followed that precedent: the residual is now documented at the guard with the reasoning and the measurements, and portability-ok is named as the remedy for a site that trips it. Nothing is postponed to a tracker — the design decision is made and lands in the diff as a stated boundary.

Row 3 is folded into that same comment block, since the shape the guard accepts and the residual it knowingly admits are one thing to explain, not two.

Verification: suite 79 pass / 0 fail (was 65 / 0); shellcheck clean; shfmt clean; the sibling shell-portability gate passes on both changed scripts. --all over the corpus is unchanged from the pre-change baseline at one pre-existing branch-class hit in an untouched file.

One correction to the affirmations in the code review: the note that the \b replacement needed no further work was not quite right. The token file also shipped the justification for the unbounded spelling, and codex independently found that the active token was not actually phrase-delimited — UnClean Architecture and Clean Archive directory both matched. Both the pattern and its rationale are fixed in 872d474c.

@kyle-sexton
kyle-sexton merged commit d7551dc into main Jul 30, 2026
31 checks passed
@kyle-sexton
kyle-sexton deleted the ci/714-portability-stage3-ecosystem-class branch July 30, 2026 13:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

portability lint stage 3: stack/ecosystem opinion token class (child of #531)

1 participant