mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
192 lines
15 KiB
Markdown
192 lines
15 KiB
Markdown
---
|
||
issue: 356
|
||
issue_title: "Harden config pipeline against silently-dropped fields (follow-up to #332)"
|
||
---
|
||
|
||
# Harden the config pipeline against silently-dropped fields
|
||
|
||
## Problem Statement
|
||
|
||
Issue [#332] fixed a specific bug — `toolInputPreviewMaxLength` / `toolTextSummaryMaxLength` were declared on `PermissionSystemExtensionConfig` and read by `ToolPreviewFormatter`, but silently dropped by the `UnifiedPermissionConfig` intermediate in `config-loader.ts`.
|
||
The fix wired those two fields through the loader, but it did not close the *class* of bug that let the omission ship undetected.
|
||
|
||
Two structural gaps remain:
|
||
|
||
1. `normalizePermissionSystemConfig` accepts `unknown` and re-parses its argument through `toRecord(raw)`.
|
||
Its sole production caller (`ConfigStore.refresh`) feeds it `mergeResult.merged`, a `UnifiedPermissionConfig`.
|
||
Because the parameter is `unknown`, TypeScript never objected that the merged object lacked the two fields.
|
||
A field declared on the runtime type but missing from the merge intermediate is a silent runtime drop, not a compile error.
|
||
2. Every existing test injects config at a single layer in isolation.
|
||
Nothing exercises the full seam — temp `config.json` → `loadAndMergeConfigs` → `normalizePermissionSystemConfig` — so a field dropped in the middle is never observed end to end.
|
||
|
||
This plan attacks the class of bug rather than the single instance.
|
||
|
||
## Goals
|
||
|
||
- Retype `normalizePermissionSystemConfig`'s parameter from `unknown` to `UnifiedPermissionConfig` and read fields directly, so a future field declared on the runtime type but absent from the merge intermediate becomes a compile error.
|
||
- Drop the now-redundant defensive coercion (`toRecord`, `normalizeOptionalPositiveInt`, `normalizeOptionalStringArray`) from `normalizePermissionSystemConfig` — that work already happens at the JSON boundary in `normalizeUnifiedConfig`.
|
||
- Add one full-pipeline seam test in a new `test/config-pipeline.test.ts` that writes a temp `config.json`, runs it through `loadAndMergeConfigs` → `normalizePermissionSystemConfig`, and asserts a runtime knob and a preview-length field both survive end to end.
|
||
|
||
This change is **not breaking**: observable runtime behavior is unchanged.
|
||
The two production call sites already feed typed objects through `normalizeUnifiedConfig` first, so the removed coercion is dead code for production; only test-only garbage injection is affected.
|
||
|
||
## Non-Goals
|
||
|
||
- The secondary `saveExtensionConfig` behavior from [#332] (length fields not written back) is out of scope — `ConfigStore.save` already preserves existing file fields via `...existing.config`, and the modal only edits the three booleans.
|
||
`config-store.ts` is not modified by this plan.
|
||
- No changes to `normalizeUnifiedConfig`, `mergeUnifiedConfigs`, or the boundary parsing — they already do the defensive work correctly.
|
||
- No new config fields, schema entries, or example-config changes.
|
||
- No narrowing of the parameter to `Omit<UnifiedPermissionConfig, "permission">` (see Design Overview — accepted minor ISP slack).
|
||
|
||
## Background
|
||
|
||
Relevant modules:
|
||
|
||
- `src/extension-config.ts` — declares `PermissionSystemExtensionConfig`, `DEFAULT_EXTENSION_CONFIG`, and `normalizePermissionSystemConfig`.
|
||
The function currently does `toRecord(raw)` then reads each field defensively (`record.debugLog === true`, `normalizeOptionalPositiveInt(record.toolInputPreviewMaxLength)`, etc.).
|
||
- `src/config-loader.ts` — declares `UnifiedPermissionConfig` (all-optional intermediate) and `normalizeUnifiedConfig`, which already does the full defensive parse at the JSON boundary: `normalizeOptionalBoolean` for the three booleans, `normalizeOptionalPositiveInt` for the two length fields, `normalizeOptionalStringArray` for `piInfrastructureReadPaths`.
|
||
- `src/config-store.ts` — the two production call sites.
|
||
`refresh` passes `mergeResult.merged` (a `UnifiedPermissionConfig`); `save` passes `next` (a `PermissionSystemExtensionConfig`).
|
||
- `src/common.ts` — exports `toRecord`, `normalizeOptionalPositiveInt`, `normalizeOptionalStringArray`.
|
||
|
||
Applicable constraint from the package skill (`package-pi-permission-system`): "Treat any declared config field not read at runtime as a maintenance trap" and "A field on the runtime type but not the merge intermediate is silently dropped before runtime (the [#332] / [#347] bug class)."
|
||
This plan makes that trap a compile error.
|
||
|
||
Existing boundary coverage in `test/config-loader.test.ts` already exercises the defensive parse the retype removes from `normalizePermissionSystemConfig`:
|
||
|
||
- Lines 188–199 — non-boolean values (`debugLog: "yes"`, `permissionReviewLog: 1`, `yoloMode: null`) are dropped to `undefined`.
|
||
- Lines 156–172 — `debugLog` present/false/missing.
|
||
- Lines 262–325 — both length fields: valid positive integer parsed, absent omitted, and invalid values (`0`, `-1`, fractional, string, boolean) dropped.
|
||
|
||
This is why the redundant cases in `test/extension-config.test.ts` can be deleted rather than relocated.
|
||
|
||
## Design Overview
|
||
|
||
### Type model
|
||
|
||
After the change, `normalizePermissionSystemConfig` converts a typed `UnifiedPermissionConfig` into a `PermissionSystemExtensionConfig` by applying defaults to the three required booleans and passing through the optional fields:
|
||
|
||
```typescript
|
||
export function normalizePermissionSystemConfig(
|
||
raw: UnifiedPermissionConfig,
|
||
): PermissionSystemExtensionConfig {
|
||
const result: PermissionSystemExtensionConfig = {
|
||
debugLog: raw.debugLog === true,
|
||
permissionReviewLog: raw.permissionReviewLog !== false,
|
||
yoloMode: raw.yoloMode === true,
|
||
};
|
||
if (raw.piInfrastructureReadPaths !== undefined) {
|
||
result.piInfrastructureReadPaths = raw.piInfrastructureReadPaths;
|
||
}
|
||
if (raw.toolInputPreviewMaxLength !== undefined) {
|
||
result.toolInputPreviewMaxLength = raw.toolInputPreviewMaxLength;
|
||
}
|
||
if (raw.toolTextSummaryMaxLength !== undefined) {
|
||
result.toolTextSummaryMaxLength = raw.toolTextSummaryMaxLength;
|
||
}
|
||
return result;
|
||
}
|
||
```
|
||
|
||
Behavior is preserved exactly: undefined booleans default the same way (`=== true` → `false`, `!== false` → `true`), and the optional fields are passed through unchanged.
|
||
The function no longer imports `toRecord`, `normalizeOptionalPositiveInt`, or `normalizeOptionalStringArray` — those imports become unused and are removed.
|
||
|
||
### Why this achieves the safety goal
|
||
|
||
The point of the retype is the omission-becomes-compile-error property.
|
||
Keeping `toRecord` would defeat it: `toRecord` returns `Record<string, unknown>`, so `record.toolInputPreviewMaxLength` is `unknown` and the type checker cannot see a missing field on the input.
|
||
Reading `raw.toolInputPreviewMaxLength` directly against a typed `UnifiedPermissionConfig` means that if a future field is declared on `PermissionSystemExtensionConfig`, read here, but never added to `UnifiedPermissionConfig`, the field access is a compile error.
|
||
|
||
### Call-site compatibility
|
||
|
||
Both production call sites already feed assignable types — no `config-store.ts` change is needed:
|
||
|
||
```typescript
|
||
// ConfigStore.refresh — mergeResult.merged is already UnifiedPermissionConfig
|
||
const runtimeConfig = normalizePermissionSystemConfig(mergeResult.merged);
|
||
|
||
// ConfigStore.save — next is PermissionSystemExtensionConfig, structurally
|
||
// assignable to UnifiedPermissionConfig (every field present, all optional there)
|
||
const normalized = normalizePermissionSystemConfig(next);
|
||
```
|
||
|
||
`PermissionSystemExtensionConfig` (required booleans + optional extras) is assignable to `UnifiedPermissionConfig` (all-optional superset including `permission?`), so `save` compiles unchanged.
|
||
|
||
### ISP note (accepted slack)
|
||
|
||
`normalizePermissionSystemConfig` reads 6 of `UnifiedPermissionConfig`'s 7 fields — it never reads `permission`.
|
||
A strict ISP reading would narrow the parameter to `Omit<UnifiedPermissionConfig, "permission">`.
|
||
We accept the single unused optional field instead: `UnifiedPermissionConfig` is the natural merged-config domain object and the exact type of `mergeResult.merged`, the issue prescribes typing the parameter as `UnifiedPermissionConfig`, and the compile-error safety property holds either way.
|
||
Adding a narrowing alias would be speculative surface for negligible benefit.
|
||
|
||
### Design-review checklist result
|
||
|
||
| Check | Finding |
|
||
| ---------------- | ---------------------------------------------------------------------------------- |
|
||
| Dependency width | Param reads 6/7 fields; one unused optional (`permission`) accepted (see ISP note) |
|
||
| Law of Demeter | No chained access introduced |
|
||
| Output arguments | None — function returns a fresh object |
|
||
| Scattered resets | N/A |
|
||
| Parameter relay | N/A |
|
||
| Test mock depth | Removes test-only `as unknown` casts in `config-modal.test.ts` (improvement) |
|
||
|
||
No structural smells introduced; the change removes redundant code and a test-only cast.
|
||
|
||
## Module-Level Changes
|
||
|
||
- `src/extension-config.ts`
|
||
- Change `normalizePermissionSystemConfig(raw: unknown)` to `(raw: UnifiedPermissionConfig)`; read fields directly; drop the `toRecord` call and the `normalizeOptionalPositiveInt` / `normalizeOptionalStringArray` coercion.
|
||
- Add `import type { UnifiedPermissionConfig } from "./config-loader";`.
|
||
- Remove the now-unused imports `normalizeOptionalPositiveInt`, `normalizeOptionalStringArray`, `toRecord` from the `./common` import (verify `toRecord` has no other use in the file — `detectMisplacedPermissionKeys` takes a `Record` directly, so it does not).
|
||
- `test/extension-config.test.ts`
|
||
- Delete the four redundant garbage-input cases (now uncompilable against the typed parameter and already covered at the boundary in `config-loader.test.ts`): "coerces non-boolean values to their defaults", "handles null/undefined input gracefully", "omits toolInputPreviewMaxLength for invalid values", "omits toolTextSummaryMaxLength for invalid values".
|
||
- Keep the valid-input cases (valid config, the three boolean defaults via `{}`, includes/omits for both length fields) — they pass typed objects and still compile.
|
||
- `test/config-modal.test.ts`
|
||
- Fix the two call sites that pass `JSON.parse(readFileSync(configPath, "utf-8")) as unknown` to `normalizePermissionSystemConfig` (lines ~147 and ~156): route them through `loadUnifiedConfig(configPath).config` (reads + normalizes the file into a `UnifiedPermissionConfig`, mirroring production) and import `loadUnifiedConfig` from `#src/config-loader`.
|
||
- The other two call sites pass typed values (`config`, `next`) and need no change.
|
||
- `test/config-pipeline.test.ts` (new)
|
||
- Add the full-pipeline seam test (change 2).
|
||
|
||
No `docs/architecture/`, schema, example-config, README, or `docs/configuration.md` updates — no config surface changes.
|
||
The package skill's bug-class note already describes this trap; no skill edit required.
|
||
|
||
## Test Impact Analysis
|
||
|
||
What the change enables and affects:
|
||
|
||
1. New coverage enabled — the seam test in `test/config-pipeline.test.ts` is the first test that runs config through the real `loadAndMergeConfigs` → `normalizePermissionSystemConfig` path on disk, catching any future mid-pipeline drop (not just the two length fields).
|
||
2. Tests that become redundant — the four garbage-input cases in `test/extension-config.test.ts` duplicate boundary coverage already present in `test/config-loader.test.ts` (lines 188–199 for booleans, 296–325 for invalid length values).
|
||
They are deleted, not relocated, because the boundary already tests the identical behavior.
|
||
3. Tests that must stay as-is — the valid-input cases in `test/extension-config.test.ts` (default application, length-field passthrough) genuinely exercise `normalizePermissionSystemConfig`'s remaining responsibility (defaults + passthrough) and are kept.
|
||
All of `test/config-loader.test.ts` stays — it owns the boundary defensive-parse contract.
|
||
|
||
## TDD Order
|
||
|
||
1. Add the full-pipeline seam test (change 2).
|
||
Surface: new `test/config-pipeline.test.ts`.
|
||
Covers: write a temp `config.json` with a runtime knob (e.g. `debugLog: true`) and a preview-length field (e.g. `toolInputPreviewMaxLength: 1000`); run `loadAndMergeConfigs(agentDir, cwd, extensionRoot)`; pass `.merged` to `normalizePermissionSystemConfig`; assert both values survive end to end.
|
||
This passes immediately (the [#332] loader fix is already in place) — it is a regression guard documenting the seam, established before the refactor as a safety net.
|
||
Commit: `test: add full-pipeline config seam regression test (#356)`.
|
||
2. Retype `normalizePermissionSystemConfig` and migrate its tests (change 1).
|
||
Surface: `src/extension-config.ts`, `test/extension-config.test.ts`, `test/config-modal.test.ts`.
|
||
This is a single atomic commit: changing the parameter type breaks the four garbage-input test cases and the two `as unknown` call sites at the type level, so the production change, the test deletions, and the `config-modal.test.ts` call-site updates must land together.
|
||
Run `pnpm run check` immediately after — the retype is the one interface-shape change in this plan, and `pnpm --filter @gotgenes/pi-permission-system exec vitest run` to confirm the seam test (step 1) and the surviving unit tests stay green.
|
||
Commit: `refactor: type normalizePermissionSystemConfig parameter as UnifiedPermissionConfig (#356)`.
|
||
|
||
## Risks and Mitigations
|
||
|
||
- Risk: a hidden production caller passes genuinely untrusted `unknown` to `normalizePermissionSystemConfig`, relying on the defensive coercion.
|
||
Mitigation: confirmed via grep — only two production call sites (`ConfigStore.refresh`, `ConfigStore.save`), both feeding typed objects already normalized upstream; no untrusted path exists.
|
||
- Risk: deleting the garbage-input tests loses coverage of invalid-value handling.
|
||
Mitigation: that behavior is fully covered at the JSON boundary in `test/config-loader.test.ts` (booleans lines 188–199, length fields 296–325); the deletions are duplicates, not unique coverage.
|
||
- Risk: `config-modal.test.ts` call-site rewrite changes what the test asserts.
|
||
Mitigation: `loadUnifiedConfig(configPath).config` reads and normalizes the same file the test wrote, mirroring the production load path more faithfully than the previous `JSON.parse(...) as unknown` cast — the asserted round-trip is preserved.
|
||
|
||
## Open Questions
|
||
|
||
None blocking.
|
||
A future follow-up could narrow the parameter to `Omit<UnifiedPermissionConfig, "permission">` if more unused fields accumulate, but that is not warranted now.
|
||
|
||
[#332]: https://github.com/gotgenes/pi-packages/issues/332
|
||
[#347]: https://github.com/gotgenes/pi-packages/issues/347
|