diff --git a/.dev/features/update-frozen-recheck/GRILL.md b/.dev/features/update-frozen-recheck/GRILL.md new file mode 100644 index 0000000..89caa16 --- /dev/null +++ b/.dev/features/update-frozen-recheck/GRILL.md @@ -0,0 +1,39 @@ +# GRILL — update-frozen-recheck + +Plan: `.dev/features/update-frozen-recheck/PLAN.md` · spec-hash `bca940a5…d729d3c4e` matches live +`ARCHITECTURE.md`. Registered grillers: `{"registered":0,"grillers":[]}` → inline axes only. + +## Findings + +```yaml +- type: FINDING + rule_id: 'P5' + severity: minor + file: '.dev/features/update-frozen-recheck/PLAN.md:38' + problem: 'Say which list the re-check keys are drawn from: it must be the NEXT (merged) capabilities, `configCapabilities` = `merged.capabilities`, so an entry the merge drops (e.g. an auto entry no longer selected once it parses) cannot hold the same-version gate open with nothing behind it.' + evidence: 'next `frozenCapabilities` = config entries still unparseable ∪ config entries listed in the PREVIOUS `frozenCapabilities`' +- type: FINDING + rule_id: 'P4' + severity: minor + file: '.dev/features/update-frozen-recheck/PLAN.md:47' + problem: 'The field''s meaning broadens from "kept because it could not be parsed" to "kept, or parsed again but its files not yet brought up to date", and init now writes it too (init-manual-carry). The docs are in Files, but the field''s own comment in `src/types.ts:124-127` is not, and would go stale.' + evidence: '`docs/reference/pharn-config.md` — the `frozenCapabilities` paragraph, same rule' +- type: FINDING + rule_id: 'P1' + severity: minor + file: '.dev/features/update-frozen-recheck/PLAN.md:42' + problem: 'Add the negative case the rule implies: a formerly-frozen entry that the merge DROPS on the re-check run leaves the list, even if files under its directory were skipped.' + evidence: 'a skip under ANOTHER capability does not keep the key' +``` + +## Summary + +The plan targets a reproduced defect with a minimal rule (a formerly-frozen key stays while any of its own +files is skipped), and its prefix test reuses the prefix `recordsUnderCapabilities` already uses. Skips +outside the capability's directory correctly do not keep the key: on a re-check run those files are +already at the recorded version, so the normal same-version behavior applies. The concerns are +precision, not direction: draw the keys from the merged list, keep the field's type comment true, and +pin the merge-drop case. + +ADVISORY VERDICT: 3 concerns raised (0 blocking-severity, 3 advisory) — for the human to weigh before +/pharn-dev-build. Finding 2 needs `src/types.ts` (comment only) added to the plan's `## Files`. diff --git a/.dev/features/update-frozen-recheck/PLAN.md b/.dev/features/update-frozen-recheck/PLAN.md new file mode 100644 index 0000000..d8f0ae0 --- /dev/null +++ b/.dev/features/update-frozen-recheck/PLAN.md @@ -0,0 +1,84 @@ +# PLAN — update-frozen-recheck (a frozen capability stays re-checked until its files actually land) + +- spec_content_hash: bca940a5ad247c120e6d8a3acba119d0d8df51dca275964d0e54c48d729d3c4e # fix #4 +- increment: `update` keeps a capability's key in `frozenCapabilities` after it parses again for as + long as any of ITS files was skipped in the run, so the same-version gate stays open and a later + run re-checks those files; it also stops writing a `pendingSkillsVersion` equal to the + `skillsVersion` it withheld. +- layer(s): the CLI (`src/commands/update.ts`) + user docs +- constitution_refs: [P1, P4, P5, P6] + +## Discovery — verified this run (P6) + +- `update.ts:207-208`: the same-version early return is skipped only while + `config.frozenCapabilities` is non-empty (PHARN-13, aec6d3e). +- `update.ts:586-589`: the next `frozenCapabilities` is exactly the config entries that are + unparseable THIS run — a capability that parses again is dropped from the list unconditionally. +- `update.ts:506-510`: a skip withholds the bump — but on a frozen re-check run the bump already + happened on the run that froze it (that run left the capability's files out of the plan entirely), + so `nextSkillsVersion = config.skillsVersion` withholds nothing. +- Net effect, reproduced by review with vitest on fake clones: installed 1.0.0, user edits a file + under lens:x; upstream 2.0.0 makes x unparseable → `update` keeps x, records 2.0.0, + `frozenCapabilities: [lens:x]`; x parses again at 2.0.0 → `update` reports MODIFIED and CLEARS the + field → every later `update` prints "Already up to date" with zero fetches; after the user reverts + the edit as advised, the file stays at v1 for good (only `--force` recovers). Same with an absent + store after a `--force` freeze run (files skipped as UNRECORDED). Control (never frozen): the bump + is held and the next run re-reports the skip. +- `update.ts:516-519`: on that same-version re-check with only user-edit skips, + `pendingSkillsVersion = installedVersion` equals the withheld `skillsVersion` — a no-op field. +- `plan.skipped` is `{ label, rels[] }[]` with project-relative rels at the clone's layout + (`update-decision.ts:174`); a capability's rels share the prefix + `layoutPaths(layout).{grillers|lenses}//` — the same prefix `recordsUnderCapabilities` + (`install-records.ts:363-378`) already uses. +- Docs: `docs/commands/update.md:101-105` ("that run updates its files as usual and clears the + field") and `docs/reference/pharn-config.md:77-81` ("the field is removed once none are left"). + +## Files + +- `src/commands/update.ts` — next `frozenCapabilities` = config entries still unparseable ∪ config + entries listed in the PREVIOUS `frozenCapabilities` that parse now but have ≥1 rel under their + capability dir in `plan.skipped` (sorted, de-duplicated); `pendingSkillsVersion` omitted when it + equals `nextSkillsVersion` — layer CLI/commands +- `tests/update.test.ts` — in the frozen block: a user-edited file under a formerly-frozen capability + → re-check run skips it AND keeps the key (FAILS on base); the next run fetches again (FAILS on + base); after reverting the edit the file upgrades and the key clears; a skip under ANOTHER + capability does not keep the key; the same-version re-check writes no `pendingSkillsVersion` + (FAILS on base); existing frozen tests unchanged +- `docs/commands/update.md` — the frozen paragraph: the field clears once the capability parses AND + none of its files had to be skipped +- `docs/reference/pharn-config.md` — the `frozenCapabilities` paragraph, same rule +- `src/types.ts` — the `frozenCapabilities` field comment only (no type change): it also lists a + capability that parses again while one of its files is still skipped, and a re-run `init` writes it + too (init-manual-carry) — added at grill (finding 2), comment-only, intent unchanged +- `CHANGELOG.md` — `[Unreleased]` → `### Fixed` + +## Contracts satisfied + +- CLAUDE.md's `update` invariant "a run that skipped anything withholds the … bump so … the next run + still has work" — restored for the frozen path (cited, not restated, P4). + +## Evals to write (P1) + +- listed under Files. + +## Guarantee audit (P0) + +- "a formerly-frozen capability with a skipped file keeps the gate open" → floor: prefix membership + of skipped rels + set membership of the previous `frozenCapabilities` keys (P5). +- Unchanged residual (named, not closed here): a NEW upstream capability this CLI could not parse + never reaches `frozenCapabilities` (it is not in the config), so after upgrading pharn it installs + only on the next `SKILLS_VERSION` bump or via `--force` / `pharn add` — pre-existing, outside + PHARN-13's scope. + +## Trust audit (P2) + +- No new input. `frozenCapabilities` is already validated at ingest (a non-`role:name` list is + ignored, `docs/reference/pharn-config.md:81`); only keys that match a config entry are re-emitted. + +## Determinism audit (P5) + +- Set membership and string-prefix tests only. + +## Open questions (HALT) + +- none diff --git a/.dev/features/update-frozen-recheck/REGRESSION.md b/.dev/features/update-frozen-recheck/REGRESSION.md new file mode 100644 index 0000000..bbf98ab --- /dev/null +++ b/.dev/features/update-frozen-recheck/REGRESSION.md @@ -0,0 +1,36 @@ +# REGRESSION — update-frozen-recheck + +The verdict below is computed by `.dev/floor/check-regress.mjs`, not by this stage's judgment. + +## Base and partition + +- **base:** `3581c867bc5ae6e809b20ef880e984487a84e240` (`HEAD` — `origin/main` after #216; the build is an + uncommitted working tree on top of it). +- **inside** (each declared in `PLAN.md` `## Files`): `src/commands/update.ts`, `src/types.ts`, + `tests/update.test.ts`, `docs/commands/update.md`, `docs/reference/pharn-config.md`, `CHANGELOG.md`. +- **scope partition:** `check-regress.mjs scope` exited **0**, `escaped: []`. `.pharn/` (hook scratch) and + this feature's own stage artifacts are not build output. +- **outside gates:** the 46 stdlib `*.test.mjs` / `*.test.cjs` files `scope` returned (754 tests) + + whole-repo `validate`; 0 committed eval pairs. +- **style-gate skip:** `inside` touches no shared style config, so `lint` / `format:check` / `lint:md` are + absent from both maps. +- **environment:** both sides ran with no proxy variables and as root **without** `CAP_DAC_OVERRIDE` / + `CAP_DAC_READ_SEARCH` / `CAP_FOWNER` (`setpriv`), the CI-equivalent of this root sandbox. + +## Per-gate exit codes + +| gate | base | head | flipped? | +| ---------- | ---- | ---- | -------- | +| `tests` | 0 | 0 | no | +| `validate` | 0 | 0 | no | + +- `regressions[]`: **empty** +- `pre_existing[]`: **empty** + +## Verdict + +**REGRESSIONS: none — no deterministically-detectable breakage outside the feature.** +(`regression-report.json` `.verdict` = `no-regressions`.) + +Residual (P0/P7): this catches exactly what its suite catches. The vitest suite exercising `src/**` is +owned by `/pharn-dev-build`'s floor and `/pharn-dev-verify`. This certifies the comparison, never the increment. diff --git a/.dev/features/update-frozen-recheck/REVIEW.md b/.dev/features/update-frozen-recheck/REVIEW.md new file mode 100644 index 0000000..005fa39 --- /dev/null +++ b/.dev/features/update-frozen-recheck/REVIEW.md @@ -0,0 +1,57 @@ +# REVIEW — update-frozen-recheck + +Increment: `src/commands/update.ts` (the next `frozenCapabilities` also keeps a key from the previous +list whose capability parses again but had a file skipped this run; no `pendingSkillsVersion` equal to +the withheld version), `src/types.ts` (the field comment only), `tests/update.test.ts` (three cases), +`docs/commands/update.md`, `docs/reference/pharn-config.md`, `CHANGELOG.md`. Treated as +`trust: untrusted`; nothing in it read as an instruction. + +## Floor first (P0) + +`node .dev/floor/validate.mjs .` → `FLOOR: GREEN` (exit 0). `/pharn-dev-build`'s `npm run check` exit 0 +(1498 tests), `/pharn-dev-regress` `no-regressions`, `/pharn-dev-verify` `PASS`. + +## Floor-gate findings (blocking) + +None. + +- L-floor (P0): "a formerly-frozen capability with a skipped file keeps the same-version gate open" → + set membership of the previous list's keys + a string-prefix test of the skipped rels, drawn from the + MERGED capabilities (grill finding 1). "No pending version equal to the recorded one" → a string + equality. Nothing is claimed beyond those two. +- L-eval (P1): the three-run case (skip → re-fetch → revert → upgrade and clear) fails on the base + `update.ts` (checked by stashing it); the two regression guards (a skip under another path; the + merge dropping the entry) pass on both, as intended. +- L-trust (P2): `frozenCapabilities` is validated at ingest (`isFrozenKeyList`); only keys matching a + merged config entry are re-emitted. +- L-axis (P3): no sibling reference; the change stays in the command that owns the config write. + +## Advisory findings (warn — severity is this reviewer's judgment, fix #3) + +```yaml +- type: FINDING + rule_id: 'P3' + severity: minor + file: 'src/commands/update.ts:601' + problem: 'This is the fifth copy of the role → subtree ternary (install-records, install-manifest twice, install-capabilities, and now update); a `capabilitySubtree(paths, role)` beside `layoutPaths` would single-source it. Kept inline here to match the existing pattern.' + evidence: 'const subtree = cap.role === ''griller'' ? paths.grillers : paths.lenses;' +- type: FINDING + rule_id: 'P7' + severity: minor + file: 'src/commands/update.ts:612' + problem: 'A user who deliberately keeps an edit in a formerly-frozen capability''s file now sees every `update` fetch again and never "Already up to date". That matches the non-frozen path (a user edit withholds the bump, so every run re-fetches) and is documented in update.md; noted, not a defect.' + evidence: '(previouslyFrozen.has(key) && hasSkippedFile(cap))' +``` + +## Proposed lesson for canon (NOT written — for a human-gated `/pharn-dev-memory-promote`) + +- **Candidate:** "Withholding a version bump only preserves pending work if the bump has not already + happened. When an earlier run advanced the version and deferred some files, that deferral needs its + own carrier into the next run (here `frozenCapabilities`), and the carrier may only be cleared once + the deferred files actually land." +- **Provenance:** increment `update-frozen-recheck`; the defect entered with PHARN-13 (aec6d3e, #207); + found by the 18-commit review on 2026-09-24; fixed by this diff (`src/commands/update.ts:596-617`). + +## Verdict + +**GREEN — 0 floor-gate findings, 2 advisory (minor).** The standing decision is the human's (GATE 2). diff --git a/.dev/features/update-frozen-recheck/SHIP.md b/.dev/features/update-frozen-recheck/SHIP.md new file mode 100644 index 0000000..3416ddc --- /dev/null +++ b/.dev/features/update-frozen-recheck/SHIP.md @@ -0,0 +1,19 @@ +# SHIP — update-frozen-recheck + +Stages run, in order: `/pharn-dev-plan` → GATE 1 (human: all four plans of this batch accepted — "deliver +all of them one by one using pharn-dev-ship") → `/pharn-dev-grill` → plan `## Files` amended with +`src/types.ts` (the field comment only, grill finding 2; intent unchanged) → `/pharn-dev-build` → +`/pharn-dev-regress` → `/pharn-dev-verify` → `/pharn-dev-review` → GATE 2. + +| stage | structural verdict (verbatim) | +| -------------------- | ------------------------------------------------------ | +| `/pharn-dev-build` | `node .dev/floor/validate.mjs .` exit `0` | +| `/pharn-dev-regress` | `regression-report.json` `.verdict` = `no-regressions` | +| `/pharn-dev-verify` | `verify-report.json` `.verdict` = `PASS` | + +- Review: [`REVIEW.md`](REVIEW.md) · Grill (advisory): [`GRILL.md`](GRILL.md) +- Run ended at **GATE 2**. The human's standing instruction for this batch: after each increment, open a + pull request and merge it once its checks are green, then start the next plan. + +chain ran; the named floor verdicts are as shown — this is NOT a judgment that the increment is good or +wise; that is the human's call at the post-review gate. diff --git a/.dev/features/update-frozen-recheck/VERIFY.md b/.dev/features/update-frozen-recheck/VERIFY.md new file mode 100644 index 0000000..5474583 --- /dev/null +++ b/.dev/features/update-frozen-recheck/VERIFY.md @@ -0,0 +1,31 @@ +# VERIFY — update-frozen-recheck + +## FLOOR layer (owns the verdict) + +Gates run over the whole repo with the feature present, on node 22 with the session proxy variables unset +and as root **without** `CAP_DAC_OVERRIDE` / `CAP_DAC_READ_SEARCH` / `CAP_FOWNER` (`setpriv`) — the +CI-equivalent of this root sandbox. + +| gate | exit | +| -------------- | ---- | +| `format:check` | 0 | +| `lint` | 0 | +| `lint:md` | 0 | +| `test` | 0 | +| `test:floor` | 0 | +| `typecheck` | 0 | +| `validate` | 0 | + +`test` is vitest (1498 tests), which collects this increment's own tests (`tests/update.test.ts`, the +"a formerly-frozen capability that parses again" block). `test:floor` is floor.yml's `node --test` run +(754 tests). No `structural:*` gate — the increment ships no eval-actual pair. + +**VERDICT: PASS** (`.dev/floor/check-verify.mjs`, `failing_gates: []`). + +## ADVISORY layer + +`node .dev/floor/count-verifiers.mjs .` → `{"registered":0,"verifiers":[]}` — no verifiers registered, floor +gates only. + +Residual (P0/P7): verified = the named gates passed; this is NOT a guarantee of correctness beyond what those +gates check — verifier concerns are advisory help, not assurance. diff --git a/.dev/features/update-frozen-recheck/regression-report.json b/.dev/features/update-frozen-recheck/regression-report.json new file mode 100644 index 0000000..25adab1 --- /dev/null +++ b/.dev/features/update-frozen-recheck/regression-report.json @@ -0,0 +1,24 @@ +{ + "base": "3581c867bc5ae6e809b20ef880e984487a84e240", + "inside": [ + "CHANGELOG.md", + "docs/commands/update.md", + "docs/reference/pharn-config.md", + "src/commands/update.ts", + "src/types.ts", + "tests/update.test.ts" + ], + "outside_gates": { + "tests": { + "base": 0, + "head": 0 + }, + "validate": { + "base": 0, + "head": 0 + } + }, + "regressions": [], + "pre_existing": [], + "verdict": "no-regressions" +} diff --git a/.dev/features/update-frozen-recheck/verify-report.json b/.dev/features/update-frozen-recheck/verify-report.json new file mode 100644 index 0000000..04efb3a --- /dev/null +++ b/.dev/features/update-frozen-recheck/verify-report.json @@ -0,0 +1,18 @@ +{ + "feature": "update-frozen-recheck", + "gates": { + "format:check": 0, + "lint": 0, + "lint:md": 0, + "test": 0, + "test:floor": 0, + "typecheck": 0, + "validate": 0 + }, + "verdict": "PASS", + "failing_gates": [], + "verifiers": { + "registered": 0, + "findings": [] + } +} diff --git a/.pharn/writes-scope.json b/.pharn/writes-scope.json index 769a1a3..bde7b08 100644 --- a/.pharn/writes-scope.json +++ b/.pharn/writes-scope.json @@ -1,7 +1,7 @@ { "scope": [ - ".dev/features/init-manual-carry/SHIP.md" + ".dev/features/update-frozen-recheck/SHIP.md" ], "set_by": ".claude/commands/pharn-dev-ship.md", - "set_at": "2026-09-24T19:16:48.445Z" + "set_at": "2026-09-24T19:24:04.227Z" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 6d20168..23a94a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Carried entries show as "added by hand" in the install summary, not also under SKIPPED. - **`pharn init` no longer overwrites a config another `pharn` command wrote while its prompts were open.** It now re-checks `pharn.config.json` under its lock, like `add`, `update` and `remove`. If the file changed after `init` read it, `init` refuses and writes nothing. - **A re-run `pharn init` dropped the `pharn.config.json` keys you added by hand.** Upstream PHARN reads top-level keys that users add themselves: `testResults` (without it `/pharn-loop` stops with `blocked: no-test-runner`) and `ship.requireAttestation`. `add`, `update` and `remove` kept them, but `init` rebuilt the config from its own fields alone. It now copies every top-level key pharn does not own across from the config it replaces, unchanged. It does so from any file that parses as a JSON object, including one the other commands refuse. Keys pharn owns are still written fresh. +- **`pharn update` could stop re-checking a capability whose files were still out of date.** When pharn cannot read a capability upstream, `update` keeps it, skips its files, and moves the skills version on. Once pharn could read it again, the next run removed it from `frozenCapabilities` even if one of its files had to be skipped, for example because you edited it. Every later run then said "Already up to date", and that file stayed at the old version even after you resolved your edit. The capability now stays listed until none of its files are skipped, so each run checks it again. `update` also no longer writes a `pendingSkillsVersion` equal to the recorded `skillsVersion`. ## [0.5.0] - 2026-09-10 diff --git a/docs/commands/update.md b/docs/commands/update.md index 7d2b2c7..0f503e2 100644 --- a/docs/commands/update.md +++ b/docs/commands/update.md @@ -101,9 +101,11 @@ upstream this run`. A capability you have is not dropped because one fetch could pharn records the kept capability in `frozenCapabilities` in `pharn.config.json`. While that field is set, `update` does not stop at "Already up to date": every run fetches again and re-checks it, so the message repeats while the situation lasts. Once the capability can be read (upstream -finishes the change, or you upgrade pharn with `npm install -g @pharn-dev/pharn@latest`), that run -updates its files as usual and clears the field. If you no longer want the capability at all, remove it -with [`pharn remove`](remove.md). +finishes the change, or you upgrade pharn with `npm install -g @pharn-dev/pharn@latest`), `update` +brings its files up to date as usual and clears the field — but only once none of that capability's +files had to be skipped. A file of it you edited is skipped as usual, and the capability stays listed, +so every later run checks it again until you resolve the edit or pass `--force`. If you no longer want +the capability at all, remove it with [`pharn remove`](remove.md). ## `pharn is too old for the current pharn-oss` diff --git a/docs/reference/pharn-config.md b/docs/reference/pharn-config.md index d99df81..97c8709 100644 --- a/docs/reference/pharn-config.md +++ b/docs/reference/pharn-config.md @@ -84,8 +84,10 @@ value that is not a `x.y.z` version is ignored. `frozenCapabilities` lists (`role:name`, sorted) the installed capabilities the last `pharn update` (or re-run `pharn init`) kept because it could not read them upstream. While it is non-empty, `pharn update` re-fetches even -at the same skills version, so they are re-checked on every run; the field is removed once none are -left. If you `pharn remove` one, the next update drops it from the list. A value that is not a list of +at the same skills version, so they are re-checked on every run. A capability that can be read again +stays listed while any of its files had to be skipped (one you edited, say), so the next run checks it +again; the field is removed once none are left. If you `pharn remove` one, the next update drops it +from the list. A value that is not a list of `role:name` keys is ignored. ## Example diff --git a/src/commands/update.ts b/src/commands/update.ts index bb4cb62..fb8c4e7 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -513,8 +513,13 @@ async function applyUpdate( // install at it. Any other skip (`unverifiable`, `unreadable`) can leave most // of the tree at the old version, so nothing is recorded; a complete run // clears it. Exact label membership, not "forceable" (P5). + // A pending version equal to the withheld one (a same-version run, e.g. the + // re-check of a formerly-frozen capability) tells `add` nothing it does not + // already accept, so it is not written. const pendingSkillsVersion = - versionWithheld && plan.skipped.every((g) => USER_EDIT_SKIPS.has(g.label)) + versionWithheld && + installedVersion !== nextSkillsVersion && + plan.skipped.every((g) => USER_EDIT_SKIPS.has(g.label)) ? installedVersion : undefined; @@ -582,11 +587,34 @@ async function applyUpdate( }, }); // Only KEPT entries count: an unparseable capability this project never had - // must not pin every future run to a re-fetch. - const frozenCapabilities = configCapabilities - .map((cap) => `${cap.role}:${cap.name}`) - .filter((key) => frozen.has(key)) - .sort(); + // must not pin every future run to a re-fetch. And one the LAST run listed + // that parses again stays listed while any of ITS files was skipped this run: + // the version bump already happened on the run that froze it, so here a skip + // withholds nothing — this list is the only thing that makes the next run + // re-check those files instead of returning "Already up to date". Drawn from + // the MERGED capabilities, so an entry the merge dropped cannot linger. + const previouslyFrozen = new Set(config.frozenCapabilities ?? []); + const skippedRels = plan.skipped.flatMap((group) => group.rels); + const paths = layoutPaths(layout); + const hasSkippedFile = (cap: InstalledCapability): boolean => { + // The same `//` prefix recordsUnderCapabilities keys on. + const subtree = cap.role === 'griller' ? paths.grillers : paths.lenses; + const prefix = `${subtree}/${cap.name}/`; + return skippedRels.some((rel) => rel.startsWith(prefix)); + }; + const frozenCapabilities = [ + ...new Set( + configCapabilities + .filter((cap) => { + const key = `${cap.role}:${cap.name}`; + return ( + frozen.has(key) || + (previouslyFrozen.has(key) && hasSkippedFile(cap)) + ); + }) + .map((cap) => `${cap.role}:${cap.name}`), + ), + ].sort(); const { pendingSkillsVersion: _previousPending, frozenCapabilities: _previousFrozen, diff --git a/src/types.ts b/src/types.ts index 4701895..79bfe7a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -128,9 +128,11 @@ export interface PharnConfig { // version. `add` accepts a clone at it; the next complete update clears it. pendingSkillsVersion?: string; // Additive (P7). `role:name` of every installed capability the last - // `pharn update` KEPT because it could not parse it upstream ("frozen"), - // sorted; omitted when none. While non-empty, `update` skips its same-version - // early return, so each run re-fetches and re-checks them. + // `pharn update` (or a re-run `pharn init`) KEPT because it could not parse it + // upstream ("frozen") — plus one `update` can parse again while any of its + // files was still skipped, since the version bump already happened on the run + // that froze it — sorted; omitted when none. While non-empty, `update` skips + // its same-version early return, so each run re-fetches and re-checks them. frozenCapabilities?: string[]; repo: string; commit: string | null; diff --git a/tests/update.test.ts b/tests/update.test.ts index 1c6add7..d332461 100644 --- a/tests/update.test.ts +++ b/tests/update.test.ts @@ -1536,6 +1536,105 @@ describe('runUpdate (drift-safe)', () => { expect(readPharnConfig(proj)!.frozenCapabilities).toBeUndefined(); }); + // update-frozen-recheck. The bump already happened on the run that froze the + // capability, so on the re-check run a skip withholds nothing: only the list + // keeps the next run from returning "Already up to date" over stale files. + describe('a formerly-frozen capability that parses again', () => { + // Installed at v1 and recorded at the (already bumped) 1.1.0 stamp, exactly + // what the run that froze it leaves behind; upstream now parses. + async function reCheckable(): Promise { + await installed({ + skillsVersion: '1.1.0', + capabilities: [CAP, { ...FROZEN, source: 'auto' }], + frozenCapabilities: ['griller:backwards-compat'], + }); + write(join(proj, FROZEN_FILE), 'installed at v1'); + const read = readRecords(proj); + if (read.kind !== 'ok') throw new Error('fixture: records unreadable'); + await writeRecords(proj, { + skillsVersion: '1.1.0', + commit: null, + files: { + ...read.store.files, + [FROZEN_FILE]: sha256File(join(proj, FROZEN_FILE)), + }, + }); + write(join(repo, FROZEN_FILE), 'parsed upstream content'); + resolveCapabilities.mockReturnValue({ + selected: [ + { ...CAP, matched: ['ssr'] }, + { ...FROZEN, matched: ['ssr'] }, + ], + skipped: [], + }); + } + // The next run reads the config the last one wrote. + const reload = (): void => { + loadArchetypeConfigOrExit.mockReturnValue(readPharnConfig(proj)!); + vi.mocked(fetchRepo).mockClear(); + }; + + it('stays listed while a file of its own is skipped — and is re-checked until that file lands', async () => { + await reCheckable(); + write(join(proj, FROZEN_FILE), 'my local edit'); + + await runUpdate(); + + // Skipped as the user's edit — and the key survives. + expect(body(FROZEN_FILE)).toBe('my local edit'); + let config = readPharnConfig(proj)!; + expect(config.frozenCapabilities).toEqual(['griller:backwards-compat']); + // Same-version run: a pending version equal to the recorded one says + // nothing `add` does not already accept. + expect(config.skillsVersion).toBe('1.1.0'); + expect(config.pendingSkillsVersion).toBeUndefined(); + + // The next run fetches again instead of "Already up to date". + reload(); + await runUpdate(); + expect(fetchRepo).toHaveBeenCalledTimes(1); + expect(readPharnConfig(proj)!.frozenCapabilities).toEqual([ + 'griller:backwards-compat', + ]); + + // The user resolves the edit as advised → the file finally lands. + write(join(proj, FROZEN_FILE), 'installed at v1'); + reload(); + await runUpdate(); + expect(body(FROZEN_FILE)).toBe('parsed upstream content'); + config = readPharnConfig(proj)!; + expect(config.frozenCapabilities).toBeUndefined(); + }); + + it('a skip under ANOTHER path does not keep it listed', async () => { + await reCheckable(); + write(join(proj, DOC), 'my edit of the constitution'); + + await runUpdate(); + + expect(body(DOC)).toBe('my edit of the constitution'); + expect(body(FROZEN_FILE)).toBe('parsed upstream content'); + expect(readPharnConfig(proj)!.frozenCapabilities).toBeUndefined(); + }); + + it('leaves the list when the merge drops the entry', async () => { + await reCheckable(); + write(join(proj, FROZEN_FILE), 'my local edit'); + // Parses again, but the archetypes no longer select it: an `auto` + // entry is dropped (merge row 5), so nothing may keep it listed. + resolveCapabilities.mockReturnValue({ + selected: [{ ...CAP, matched: ['ssr'] }], + skipped: [{ ...FROZEN, reason: 'applies to [backend]' }], + }); + + await runUpdate(); + + const config = readPharnConfig(proj)!; + expect(config.capabilities!.map((c) => c.name)).toEqual(['a11y']); + expect(config.frozenCapabilities).toBeUndefined(); + }); + }); + it('still returns early at the same version when nothing is frozen', async () => { await installed({ skillsVersion: '1.1.0' });