Skip to content

Closes the backlog filed in FRAMEWORK-FIXES-qa-framework{,-v2,-v3,-v4,-v5}.md - five documents written while applying this framework in real projects, each pairing a real pain point with a proposed upstream fix. - #2

Merged
keber merged 42 commits into
mainfrom
dev
Sep 9, 2026

Conversation

@keber

@keber keber commented Sep 9, 2026

Copy link
Copy Markdown
Owner

Promotes to main everything merged into dev in PR #1. Same diff, no additional
commits: 42 commits, 96 files, +5539/-820.

This closes the backlog filed across five FRAMEWORK-FIXES-qa-framework*.md
documents, each written while applying this framework to real projects. Three of
the defects were shipping into consuming projects.

Defects that propagated. All four login call sites typed the QA password with
locator().fill(), whose argument Playwright records - with retain-on-failure,
a failed CI run persisted the plaintext password in its artifact. The generators
wrote em-dashes into every project's files, violating the encoding rule this
package itself declares BLOCKING. Both .env.example files carried a BOM, which
makes some parsers miss the first key.

Spec paths converged. init.js scaffolded specs where validate.js did not
look and the skills did not declare. init followed by validate exited 1 on a
clean install and reported 150 errors across three live projects - every one a
scan artifact. Both now exit 0.

Parallel lanes generalized. From two hardcoded accounts to N driven by config,
with per-lane capabilities and a lock whose staleness predicate a live heartbeat
overrides. Lanes exist because the applications under test invalidate a session
when the same account logs in twice.

Gates added. tsc --noEmit and a smoke run before Stage 5 completes; the
official suite as the only source of truth for defect status; a known-issues
lookup before filing; language-aware accent and character checkers.

CI now runs. Both steps were broken in dev and had never passed - the test
glob never expanded on the runner, and validate checked a gitignored directory.
It now validates a freshly scaffolded project, which is the contract that matters.

118 tests passing, up from 23.

CHANGELOG.md keeps its forbidden characters deliberately - rewriting a
historical log to satisfy a rule adopted later would falsify it.

keber added 30 commits September 2, 2026 11:59
- Created `qa-asesoria.md` for providing QA advisory support during sprints, focusing on specific queries related to manual testing.
- Introduced `qa-informe-resultados.md` to generate and update test result reports from Azure DevOps execution data, summarizing key metrics and findings.
- Developed `qa-plan.md` to generate detailed test plans for sprints, including test case traceability and prioritization based on project requirements.
- Established `qa-framework.rules.md` to outline agent behavior, formatting rules, and Azure DevOps integration guidelines for the QA framework.
Both qa-framework.instructions.md and qa-framework.rules.md referenced
integrations.ado.enabled, a path that never existed in qa-framework.config.json.
The real schema uses integrations.azureDevOps.enabled, matching what init.js
bootstraps and what scripts/lib/claude-agents.js reads.
…tomation

qa-spec-generation/SKILL.md pointed at references/spec-file-formats.md, but that
file only exists under qa-module-analysis/references/ - its own header declares it
shared by both skills. The relative path was never corrected for the second
consumer, so Stage 2 could not find the template it is told to use. The empty
qa-spec-generation/references/ directory is removed with it.

qa-automation/SKILL.md referenced references/pom-template.md, which did not exist.
Created from the page-object pattern in real project use: an abstract per-module
CRUD base plus thin per-submodule classes, with the accent-insensitive label
matching that pattern needs against Spanish-language UIs.
The Origin field allowed only UI-OBSERVED, PENDING-CODE and BLOCKED-PERMISSIONS.
A common real case fits none of them: the element was observed and confirmed to
exist, but its behavior could not be exercised end to end in the session - a
disabled control, or a precondition that was never reached.

Without a fourth value agents either overclaim with UI-OBSERVED, which implies the
behavior was verified, or misuse PENDING-CODE, which implies the feature is absent
and pollutes gap tracking downstream. In one session roughly 70 percent of a
submodule's generated TCs were mistagged before an independent review caught it.

Added to all five places the vocabulary appears: the definition in
qa-module-analysis, both enum tables, and the two sites that branch on it.
The four agent templates are Spanish-language content that shipped entirely
accent-stripped: 144, 190, 88 and 52 Spanish function-word markers respectively,
with 0 to 2 accented characters between them. They are published to npm and copied
into every consuming project.

This is a functional defect, not a style preference. Page objects locate fields by
their visible label, so a spec written as Codigo never matches a UI that renders
Codigo with its accent. In one project that cost 24 of 39 failing smoke tests and
two wrong mitigations - raising a grid timeout, then forcing HTTP/1.1 - because the
selector failure presents as a timeout rather than as a text mismatch.

Orthography only: line counts unchanged, no BOM, no forbidden characters, and
identifiers, paths, placeholders and code spans left untouched.
The template hardcoded DEF-{{NNN}} while defectIdPattern in the config carried
DEF-{NUM}, neither of which matches how projects actually name defects - real files
use a project prefix, e.g. DEF-SIS-014. Hardcoding one project's prefix upstream is
the wrong fix; the pattern now takes the prefix as a placeholder so each project
supplies its own.
Stage 5 had no tsc --noEmit step and no instruction to run a newly written spec
even once before marking the submodule complete. The only execution-adjacent step
was the pre-inspection script, which runs before any test code exists.

A real ReferenceError - a variable referenced but never declared, left over from a
half-applied edit - survived into a spec and surfaced only when the full module
suite ran later. A static check costs seconds and catches that class of error
before any Playwright cycle is spent on it.

Both gates run before the completion checklist. The smoke run targets execution-time
errors only; a full green suite remains Stage 5b's job.
The skill pointed Step 1 at the official runner but said nothing about what to do
when a standalone diagnostic script disagreed with it, and nothing stopped a
standalone result from closing a defect on its own.

That gap produced a real close-and-reopen cycle: a polling diagnostic script saw a
toast 4 of 4 times while the official suite saw none, 6 of 6 across two independent
runs. The defect had already been closed on the standalone evidence.

Standalone output is now diagnostic input, never a verdict - stated in Step 1, in
the Category I assertion-polarity rule, and as a row in Key Rules.
Nothing in the pipeline required checking whether a defect had already been filed
before creating a new one. Four defects were filed in a single session without that
check; a later manual pass found one of them sharing its exact symptom with three
already-closed tracker items - unresolved whether that is a thrice-closed regression
or a distinct submodule carrying the same defect. Either way it should have been
caught at filing time, not months later.

The gate is written against the project's known-issues record generically rather
than one project's file layout, and distinguishes the two outcomes that matter: an
open match means reference it instead of refiling, a closed match means file it as
a regression. A defect closed three times and refiled as new each time reads as
three unrelated bugs.

The defect template gains the section the gate requires, so the search is recorded
in the artifact rather than living only in the session that ran it.
Spanish content must carry its accents, but nothing enforced it for .md files, and
compliance depended on the executor honoring the rule. In one session a sub-agent
wrote six accent-stripped Spanish files and then reported explicitly that it
complied; the defect surfaced only because someone ran the count by hand.

The hard part is classification, not detection. A naive rule fails every English
artifact the framework ships, and counting Spanish marker words in raw text does not
separate them either - an English document about a Spanish-language project quotes
enough Spanish paths and identifiers to cross any absolute threshold that still
catches short Spanish files.

So this measures the density of Spanish function words in prose only, after
stripping fenced code, inline code, link targets, tags, placeholders and paths.
Against this package's own corpus the separation is two orders of magnitude:
English lands at 0.000-0.001, Spanish templates at 0.125-0.165, threshold at 0.07.
A test asserts the gap stays wide so a future edit cannot erode it silently.

Exposed as npm run check-accents; --staged suits a pre-commit hook.
Every stage that writes Markdown ended on a compliance claim rather than evidence.
The difference is not academic: asked whether its output carried correct accents, a
sub-agent answered yes and was wrong; asked for the count instead, the same agent
produced the real per-file numbers and corrected itself.

The shared reference gives the three commands that close a stage - accent count,
forbidden characters, BOM - and states the language boundary, so English artifacts
are not asked for accents they should not have. Hooked into the closing checklist
of all four skills that generate documentation.

This is the cheap half of enforcing the orthography rule. The other half is the
gate in scripts/check-spanish-accents.js, which this reference points at.
Both scaffolds typed the QA password with locator().fill(password).
Playwright records fill() argument values in the trace, and the
automation scaffold ships trace and video set to 'retain-on-failure',
so any failed CI run persisted an artifact containing the plaintext
password. The integration scaffold has no capture configured today but
carried the same unsafe call, so it breaks the moment a project turns
tracing on.

Both call sites now set the value through page.evaluate(), which is the
mechanism already documented in this package as "Pattern 7: Password
Injection (Trace Safety)" in skills/qa-automation/references/patterns.md
- the scaffolds simply were not using their own pattern. The selector
and password are passed in as evaluate arguments rather than re-read
from process.env inside the scaffolds.

A bare input.value assignment does not fire input events, and the
Blazor/Radzen-style apps these scaffolds target bind on them, so the
handler dispatches bubbling input and change events after setting the
value. evaluate() also has no auto-waiting, so an explicit visible
waitFor replaces the wait that locator().fill() performed implicitly,
keeping the automation scaffold's 3-attempt retry loop and the
integration scaffold's waitFor sequence behaving as before.

Email fill() calls are left untouched: emails are not secret and
keeping them in the trace helps debugging. No trace or video settings
were changed.
Follow-up to the global-setup fix. Two further call sites still typed the
QA password with locator().fill(password):

  fixtures/base.ts  - TTL-aware session refresh (reauth)
  fixtures/auth.ts  - per-role loginAs helper

These carry more exposure than the global-setup sites, not less.
global-setup runs once per run, whereas reauth runs for every test whose
session exceeded the TTL and loginAs runs for every test that logs in as
a role. With trace and video set to 'retain-on-failure', each of those
executions could persist an artifact containing the plaintext password.

Both sites now use the same evaluate()-based injection as global-setup,
matching "Pattern 7: Password Injection (Trace Safety)" in
skills/qa-automation/references/patterns.md: selector and password passed
in as evaluate arguments, a null-check throw when the selector matches
nothing, and bubbling input and change events dispatched after the
assignment because a bare .value write does not notify the Blazor/Radzen
style frameworks these scaffolds target. An explicit visible waitFor
replaces the auto-wait that locator().fill() provided.

Surrounding behavior is unchanged: the existing post-login waits keep
their own timeouts (30s in base.ts, 15s in auth.ts), and the email
fill() calls are left in place since emails are not secret and aid
debugging. No trace or video settings were changed.

There are now no remaining fill(password) call sites in templates/.
init.js wrote per-submodule spec templates to qa/{module}/{submodule}/, but
the authoritative spec home is qa/01-specifications/{module}/{submodule}/.

Evidence for the authoritative layout:
- all 8 skills in skills/ declare it
- docs/folder-structure-guide.md, docs/architecture.md and
  docs/usage-with-agent.md document it
- qa-framework.config.json specPath is
  "qa/01-specifications/module-{{module-name}}"
- upgrade.js treats 01-specifications/ as the spec home
- the three live consuming projects (QA_Sispro_Exportadora,
  QA_PortalProductores, QA_PortalProveedores) keep their real specs there
  and have zero top-level module directories

init.js already created qa/01-specifications/ as a top-level folder, so the
old behavior left that folder permanently empty while scattering specs as
sibling directories beside the numbered ones.

The existing init tests ran with no config, so the module loop iterated zero
times and no spec path was ever asserted. Adds a test that runs init with a
real module/submodule config and asserts the 6 spec files land under
01-specifications/, that nothing is created at qa/{module}/, and that the
e2e stub lands at e2e/tests/{module}/. It fails against the old path join.
The --strict automation-spec check looked for
qa/07-automation/e2e/{module}/{submodule}.spec.ts, a path nothing creates.

init.js writes the stub to e2e/tests/{module}/, and upgrade.js documents a
v1.6.0 migration that moved e2e/{module}/ -> e2e/tests/{module}/. validate.js
was never updated for that migration, so --strict emitted a permanently false
warning for every submodule; all three live consuming projects use e2e/tests/.

The old test asserted the buggy e2e/{module}/ path as expected output, and its
fixture hand-built qa/suppliers/create/ to match validate.js's code rather than
any documented contract. The fixture now uses the authoritative
qa/01-specifications/module-suppliers/submodule-create/ layout and the warning
assertion pins the e2e/tests/ segment, with a negative assertion guarding
against a regression to the pre-v1.6.0 layout.

Moving the fixture to the real layout exposes a separate, out-of-scope defect:
validate.js's scan treats every top-level qa/ directory as a module, so it now
reads 01-specifications/ as the module and demands the 6 spec files one level
too high. A correctly scaffolded project therefore fails validation. The test
covering that path documents the current behavior explicitly instead of
padding the fixture with module-level spec files no real project has. Fixing
the scan is deliberately left to its own change.
Check 2 walked all of qa/, treating every top-level directory as a module
and every directory inside it as a submodule. Since specs live at
qa/01-specifications/{module}/{submodule}/, that scan was off by one level:
it read 01-specifications/ as the module and module-* as the submodule, then
demanded the 6 spec files one level above where they exist.

Rooting the scan at qa/01-specifications/ fixes two failure families at once.
The traversal now starts at the correct depth, and sibling folders such as
02-test-plans/, 03-test-cases/, 04-test-data/ and memory/ are never visited,
so their subdirectories can no longer be mistaken for submodules.

SKIP_DIRS is removed rather than extended. It was a denylist that had to
enumerate every non-module folder by hand and had already fallen behind by
four entries; once the scan is rooted correctly it can never match anything,
so extending it would preserve a maintenance burden that no longer has a
purpose. Module directories are accepted without a name-prefix filter,
because docs/folder-structure-guide.md documents shared/ as a legitimate
non-module directory at this level.

Errors now report the full qa/01-specifications/... path.

Tests: the spec-layout test that pinned the off-by-one now asserts exit 0,
and three tests are added - an init-to-validate round trip, a populated
sibling-folder case, and a shared/ directory case. All six spec-scan tests
fail without this change.
Both scaffolds and both auth fixtures typed the password with locator().fill(),
whose argument values Playwright records in traces. With trace and video set to
'retain-on-failure', a failed CI run persisted an artifact containing the
plaintext password.

All four call sites now inject through page.evaluate() - the mechanism this
package already documented as Pattern 7 in qa-automation/references/patterns.md
and did not use in its own scaffolds.
Three components disagreed on where specs live. init.js scaffolded them to
qa/{module}/{sub}/, validate.js scanned every top-level qa/ directory as a
module, and the 8 skills, 3 docs, the config's specPath and upgrade.js all
declared qa/01-specifications/{module}/{sub}/. All three live consuming projects
use the latter exclusively, with zero top-level module directories.

init.js now scaffolds there; validate.js roots its scan there, which corrects an
off-by-one depth and removes the need for SKIP_DIRS; and --strict looks for
automation specs under e2e/tests/, where the v1.6.0 migration moved them.

Before this, validate exited 1 on a clean install and reported 150 errors across
the three live projects, every one of them a scan artifact. The round-trip test
that would have caught all of it is added here.
The generators embed em-dashes and arrows in template literals and console
output that propagate into every consuming project: qa/memory/INDEX.md and
qa/03-test-cases/README.md are written with an em-dash in their heading.

Replaces all 30 occurrences across cli.js, generate.js, init.js, upgrade.js
and validate.js with the ASCII equivalents required by the character-safety
rule in .github/copilot-instructions.md. Characters only - no wording changes,
no reflowed lines, column-aligned comment blocks keep their alignment, and
emoji are untouched since they are outside the forbidden set.
Enforces the BLOCKING character-safety rule from .github/copilot-instructions.md
that the package previously stated but never checked: em-dash, en-dash, ellipsis,
smart quotes and arrows are flagged, Latin Extended and emoji are not.

Scans .md, .js and .ts - unlike check-spanish-accents.js, which is .md-only -
because the generators under scripts/ are the occurrences that propagate into
consuming projects. Reports file:line:column with the code point and its ASCII
replacement. Exit 0 clean, 1 on violations, 2 on usage error; --staged and
--report mirror the accent checker's CLI.

The suite pins scripts/ and test/ as clean, which is the regression guard for
the generators. templates/ and skills/ still carry 486 occurrences and are a
deliberate follow-up, so they are not asserted.
copilot-instructions.md declares a BLOCKING rule against em-dash, en-dash,
ellipsis, smart quotes and arrows, and the package violated it in its own
generators - init.js and upgrade.js wrote em-dashes straight into every
consuming project's qa/memory/INDEX.md and 03-test-cases README. The framework
was propagating the defect it forbids.

All 30 occurrences in scripts/ are now ASCII, and check-forbidden-chars.js
gates .md, .js and .ts against the whole set while leaving Latin Extended
alone, so Spanish accents are unaffected. A test pins scripts/ and test/ clean.

The prose in templates/, skills/, docs/ and meta/ still carries the legacy
occurrences; those are a separate cleanup now that the gate exists to verify it.
Decision-tree and flow diagrams used U+2192, U+2190, U+2194, U+2193 and
U+21FF, which the character-safety rule forbids.

Right/left/bidirectional arrows map to ->, <- and <->. The down arrow
U+2193 has no ASCII spelling, so it becomes "v": it is one column wide
like the original, which preserves the diagrams' existing alignment, and
it reads as flow-down in a left-aligned decision tree.

output-verification.md is a special case: its rg pattern is a character
class enumerating the forbidden characters themselves, so substituting
ASCII would destroy the pattern. It now uses $'\uXXXX' escapes, which
bash expands to the same code points while keeping the file ASCII-clean.
Em-dashes, en-dashes, remaining arrows, smart quotes and an ellipsis are
replaced with the ASCII equivalents required by the character-safety rule
in .github/copilot-instructions.md. These files are copied into consuming
projects, so the characters propagated into user specs and test code.

Substitution is context-sensitive rather than literal:
- padded prose dashes become " - "
- numeric and label ranges (50-85, P0-P3) keep a tight hyphen so the
  meaning and the column width are unchanged
- standalone dash table cells become a bare "-"
- the horizontal rule in agent-next-steps.md becomes the same number of
  ASCII hyphens, preserving its 68-column width

Wording, structure and line counts are unchanged, and Spanish accented
characters are preserved exactly.
…ters

Applies the same context-sensitive substitution to the remaining prose:
padded dashes become " - ", ranges keep a tight hyphen, and the last
arrows become -> and <-.

Line counts, wording and Spanish accents are unchanged.
791 occurrences across skills/, templates/, docs/, meta/, examples/ and
integrations/, closing the debt the character gate exposed. scripts/ and test/
were cleaned when the gate landed.

Not a blind substitution: numeric ranges (50-85 TCs, P0-P3) keep a tight hyphen
rather than becoming ' - ', which would read as subtraction and widen table
columns; standalone table cells take a bare '-'; and the decision trees in
classification-protocol.md use 'v' for flow-down, which holds one column like
the character it replaces and does not collide with Markdown's table delimiter.

One edit is semantic rather than character-level: output-verification.md
documents a command whose regex enumerates these very characters, so replacing
them would have made it match hyphens. It now uses \u escapes, which bash
expands to the same code points while leaving the file clean.

Spanish accents verified unchanged: 449 in templates/agents, 566 repo-wide.
…ration notes

Closes the last of the prose debt outside CHANGELOG.md. The numeric range
22957-23034 takes a tight hyphen rather than ' - ', which would read as
subtraction.

CHANGELOG.md keeps its 87 occurrences deliberately: it is a historical record,
and rewriting past entries to satisfy a rule adopted later would falsify the
log rather than fix anything.
The gate covered .md, .js and .ts, so sync-ado-titles.ps1 was never scanned -
and it carried an em-dash and an arrow in two console messages. PowerShell
scripts under integrations/ are copied into consuming projects like any other
template, so the same propagation argument applies.

Adding .ps1 to the extension list closes the blind spot.
…figs

Both playwright.config.ts files called dotenv.config(), which injects the entire
.env into process.env. In a real project that file holds every QA account
password plus ADO tokens - in one, 82 lines of them - while these configs need a
handful of non-secret values. Any dependency, crash dump or child process
inherited the rest for no benefit.

They now read an explicit key list with dotenv.parse(), which returns a plain
object and never touches process.env, and only fill keys the environment has not
already set so shell and CI variables still win.

The integration scaffold's list includes QA_USER_PASSWORD because its
credentials check reads it; the value is only tested for presence. The three
remaining dotenv.config() calls are in global-setup and the session fixture,
which genuinely need credentials to log in.
The dispatcher calls heartbeat, not the executor. Written down because the
decision was made in conversation and the N-lane work it governs is not started
yet - a later session would otherwise rediscover the fork from scratch, or ship
the staleness sweep without an answer and reproduce the 2026-08-22 incident it
came from.
keber added 12 commits September 8, 2026 21:33
A lane is four coupled values - account env prefix, storageState, MCP
namespace and runner project - none of which can be inferred from the
others, so all four come from config.

Capabilities are modelled per-lane rather than as a property of the lane
set. A project can have six accounts but only three MCP servers, and
handing a lane to a consumer needing tools that lane lacks is a real
failure, not a hypothetical.

A project with no parallelLanes block, or with enabled:false, resolves the
single implicit default lane and behaves exactly as before.
Ports the reference implementation, replacing its hardcoded six-entry
LANE_INFO with the configured lane table.

Keeps the behaviors the reference earned in production use:

- acquire (random free lane, queues) and reserve (exact lane, no queue,
  throws when busy) stay two verbs. Collapsing them is how a CI job lands
  on a random lane.
- The composite staleness predicate: stale only when the hold is past the
  threshold AND no recent heartbeat proves the holder alive.
- QA_LANE_LOCKS_DIR with shell > .env > per-checkout precedence. A fixed
  per-checkout .locks/ gives separate worktrees zero contention while
  every command still reports success.
- The stderr line naming the resolved lock dir and its source. The
  underlying failure is silent, so this is part of the fix.
- mkdir mutex, FIFO queue capped at the lane count, stale-release logging.

Reads the .env key with dotenv.parse(), never config(), so a script whose
only job is choosing a directory never gains ambient access to the QA
account passwords in the same file. Falls back to a narrow single-key
parser when dotenv is absent rather than silently ignoring .env.
A lock nothing consults is decoration. global-setup performs the only
login, so it is the last point at which a run about to drive an account it
does not hold can be stopped.

- assertColdSetupIsIntentional throws before any login when neither
  QA_LANE_ONLY nor QA_COLD_SETUP=1 is set, since a cold setup re-logs in
  every lane and invalidates other running sessions.
- assertLaneIsLocked verifies outside CI that the lane is actually held.
- assertProjectsMatchLane catches a --project whose storageState points at
  a different lane than the one reserved.

Plain CommonJS so the logic is testable with node --test without adding
ts-node. global-setup never acquires or reserves a lane itself; reserving
is the caller's job, and a global-setup that reserved its own lane would
make every accidental run look legitimate.
Replaces the commented-out chromium-user1/chromium-user2 block, which sat
outside the projects array and could not have worked as written, with
projects generated from parallelLanes.lanes.

playwright.config.ts derives every storageState through lane-lock rather
than hardcoding one per project. A parallel hardcoded mapping caused real
evidence corruption once: a run recorded under the wrong account because
the project was pinned to one lane while QA_LANE_ONLY named another.

global-setup's resolveAuthUsers returned Set<'1'|'2'> and picked accounts
with storageState.includes('user-2'). That substring match is what breaks
first past two lanes - 'user-2' matches 'user-20', and no branch existed
for a third lane at all. It now resolves lanes by exact match against the
lane table and reads credentials from each lane's declared account prefix.

init.js scaffolds the lane scripts. A project with no parallelLanes config
still gets exactly one 'chromium' project on .auth/user-default.json.
Shipping the lock without a rule requiring its use repeats an established
mistake: the capability exists but nothing makes an agent reach for it.

States the requirement only - acquire before dispatching, release when
done, dispatcher keeps the lane alive, queue or reject at the cap, never
force past it. Agent-agnostic, so no Claude Code primitives appear here;
each agent's adaptation prescribes its own mechanism.
Both .env.example files carried a UTF-8 BOM and three em-dashes. The gate never
saw them because it scanned .md, .js, .ts and .ps1 only - the same blind-spot
class as the PowerShell script fixed earlier.

A BOM in a .env is not cosmetic: some parsers read the first key as
\ufeffQA_BASE_URL and silently fail to find it, and these files ship to every
consuming project as the template developers copy to .env.

Adding '.example' to the extension list closes the blind spot.
Lanes exist to work around a constraint of the applications under test: one
account cannot hold two sessions, so a second login silently invalidates the
first. The scaffold supported exactly two, through a commented-out project block
and a resolveAuthUsers that picked accounts by matching 'user-2' inside a
storageState string - the part that breaks first past two lanes.

A lane is now a config entry declaring account, storageState, MCP namespace,
runner project and its own capabilities. Capabilities are per-lane rather than a
property of the set, so a project with six accounts and three MCP servers cannot
hand an MCP consumer a CLI-only lane - a failure that happened in practice.

lane-lock.js ships with the behaviors its reference implementation earned the
hard way: acquire and reserve kept distinct, a composite staleness predicate that
a live heartbeat overrides, and a relocatable QA_LANE_LOCKS_DIR. That last one
matters most: with a fixed per-checkout .locks/, parallel worktrees get zero
contention while every command reports success. Verified both directions - two
checkouts sharing a locks dir contend correctly, and without it both reserve the
same lane, reproducing the bug.

storageState is derived from the lane table alone, never a parallel copy; a
duplicated mapping previously recorded a run under the wrong account.

Projects without parallelLanes behave exactly as before: init and validate exit
0, and the resolver reports one lane on the default account.
The test script used test/**/*.test.js, which needs globstar to match files at
the root of test/ - and the CI runner's shell does not enable it. Every test
file in this package sits directly in test/, with no subdirectories, so the
pattern expanded to nothing and CI failed with 'Could not find'.

The defect predates this branch: it arrived with the commit that added the
runner, which is the current head of dev, so no PR had exercised it yet. It
passed locally because Git Bash on Windows expands the pattern differently.

Verified under plain sh, which is what the runner uses: 118 tests, 0 failures.
The previous fix swapped the pattern but kept it quoted, so the shell passed
'test/*.test.js' to node as a literal path and it failed the same way. The
quoting was the actual defect, not the globstar - node received a string it
tried to resolve as a file.

Dropping the quotes lets the shell expand the glob to the eight test files
before node sees them. Chosen over a bare 'node --test', which relies on
automatic test discovery whose behavior differs across the Node 18 and 20
versions this CI runs and which I cannot verify from Node 22 here.

Verified under plain sh: the glob expands to 8 files, 118 tests, 0 failures.
The matrix tested Node 18 and 20, both out of maintenance - 18 since April 2025,
20 since April 2026 - so CI was verifying two unsupported runtimes and none of
the supported ones.

Adding 24 also closes a gap: publish.yml releases on Node 24, which no job
tested. The package was published on a runtime CI never exercised.

engines moves from >=18 to >=20 so the declared floor matches what is actually
verified. Users on 18 will now get an npm warning, which is the point - that
version was never being tested.
The validate step ran against this repository's own qa/ directory, which is
gitignored - it is the framework's live instance for this repo's QA work, not
package content. The runner checks out without it, so validate found nothing
and failed with four missing-folder errors.

Like the test glob, this predates the branch: the step came from dev and had
never passed, because no PR had exercised the workflow.

Scaffolding a throwaway project and validating that exercises the contract that
actually matters - what init.js generates must pass validate.js. That round trip
is what three spec-path defects slipped through for want of a test, so it earns
its place in CI. Both plain and --strict run.

Verified locally under sh with GITHUB_WORKSPACE set: init 0, validate 0,
--strict 0.
Closes the backlog filed in `FRAMEWORK-FIXES-qa-framework{,-v2,-v3,-v4,-v5}.md` - five documents written while applying this framework to real projects, each pairing a real pain point with a proposed upstream fix.
@keber
keber marked this pull request as ready for review September 9, 2026 02:03
@keber
keber merged commit e8f24eb into main Sep 9, 2026
3 checks passed
@keber keber changed the title Dev Closes the backlog filed in FRAMEWORK-FIXES-qa-framework{,-v2,-v3,-v4,-v5}.md - five documents written while applying this framework in real projects, each pairing a real pain point with a proposed upstream fix. Sep 9, 2026
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.

1 participant