fix(cli): config set resolves keys against the generator's schema instead of guessing them - #974
Conversation
… transform Run dir for the #955 fix. Research re-derives the issue against main and finds the defect is wider than filed: the documented camelCase spelling is broken too, because the key mapper capitalizes record keys as well as schema fields. Plan locks the fix at the contract level — resolve every path against the JSON Schema the Aspire generator's own parser is built from — so the CLI and the generator cannot disagree about which keys exist. Refs #955 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ing why `netscript config set NetScript.Databases.postgres.Persistent false` exited 0 and reported success while writing `NetScript.NetScript.Databases.Postgres.Persistent` — a key nothing reads. The mapper prefixed `NetScript` unconditionally and capitalized the first letter of every segment, so it also corrupted record keys: the documented camelCase spelling `databases.postgres.persistent` landed on `Databases.Postgres` and was equally dead. No spelling of that setting worked. Paths now resolve against the JSON Schema `@netscript/aspire` generates from the same Zod definitions `parseAppSettings()` validates with, so "the generator knows this key" is true by construction rather than by a hand-kept table. Closed objects canonicalize case-insensitively; records keep the developer's own key. An unresolvable path fails with suggestions and writes nothing; `--force` writes it with a warning, for host-side keys like `Parameters.*` that sit outside the NetScript schema. The regression guard no longer asserts a string mapping — it asserts the property that actually failed: after `set`, `parseAppSettings()` reads the value back. The shipped test only covered the one hardcoded alias, which is why this reached a release. Refs #955 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…reads `config list` enumerates the case-sensitive appsettings paths the generator reads, expanding records over the project's own keys and templating the ones it has not populated yet. It also lists keys the document contains but the schema does not model, marked "(not read by the generator)" — which is how a developer discovers that an earlier `config set` changed nothing. `config get` moves onto the same resolution, so the read and write paths can no longer disagree. Refs #955 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
[PHASE: RESEARCH] The reported doubled prefix is one symptom of two; the root cause is that the key mapper never consulted a schema. Findings
Full detail: |
|
[PHASE: PLAN] Archetype 6 (CLI / Tooling). Fix the contract, not the string transform. Locked decisions
Open-decision sweepAll decisions that would force rework were resolved before implementation. Deferred and marked safe: modelling Risk register (top 3)
Design checkpointRecorded in Contributor path: adding a settable key requires no CLI change at all — add the field to the Zod schema in Plan-Gate
|
|
[PHASE: IMPL] Five slices landed; 30 tests across three files; every named gate run. Slices
Reproduction, then proofBefore ( After — the same command sets A misspelling now costs one line instead of an afternoon: Gate results
Root Structural gates (Archetype 6)
F-CLI-2…31 remain Not run, and why
Next
|
Summary
netscript config set NetScript.Databases.postgres.Persistent falseexited 0 and printedSet …while writing
NetScript.NetScript.Databases.Postgres.Persistent— a key nothing reads. Thesetting silently did nothing.
The root cause is not the doubled prefix.
appsettingsPath()was a blind text transform — onehardcoded alias, then an unconditional
NetScriptprefix and capitalize-first-letter on everysegment — applied with no knowledge of the appsettings schema, and
setProjectConfigValue()createdwhatever intermediate objects that transform implied. So it also corrupted record keys: the
documented camelCase spelling
databases.postgres.persistentlanded onNetScript.Databases.Postgres.Persistent(right prefix, wrong record key) and was equally dead.There was no spelling of that setting that worked, and every one of them exited 0.
Paths now resolve against the JSON Schema
@netscript/aspiregenerates from the same Zoddefinitions
parseAppSettings()validates with. "The generator knows this key" is true byconstruction, not by a hand-kept table that can drift.
Scope
packages/cli/src/public/features/config/What changed
Resolution is schema-derived.
read-appsettings-schema.tsreadsgenerateAppSettingsJsonSchema()and exposes it as a walkable tree. The JSON Schema distinguishesthe two node kinds that matter, and the resolver honours the distinction:
properties+additionalProperties:false)NetScript.DatabasesadditionalProperties: <schema>)…Databases.postgresCapitalizing record keys was the half of the bug the issue did not report.
Three spellings, one canonical key. The full path, the section-relative shorthand
(
Databases.postgres.Persistent), and any casing of either all resolve toNetScript.Databases.postgres.Persistent. The shorthand is only accepted when the section it namesactually resolves, so a full path is never re-prefixed and
Parameters.xis not silently re-homedas
NetScript.Parameters.x.Unknown paths fail; they do not succeed quietly.
Suggestions are ranked by edit distance (
levenshteinDistancefrom@std/text).--forcewritesan off-schema key with a warning — kept because the scaffold legitimately emits a top-level
Parametersblock thatAppSettingsSchemadoes not model (see Drift D3).Values are validated too, scoped to the written path, so an
appsettings.jsonthat is alreadyinvalid elsewhere does not block an unrelated correct write.
config list(new) prints the canonical case-sensitive paths, expanding records over theproject's own keys and templating unpopulated ones as
NetScript.Databases.<key>.Engine. It alsolists keys present in the document that the schema does not model, marked
(not read by the generator)— which is how a developer discovers that an earlierconfig setchanged nothing:
config getmoves onto the same resolution, so the read and write paths can no longer disagree.What I deliberately did not change
config override/config runtime— a different (KV-backed) store with a different key space.@netscript/config(netscript.config.ts) surfaceconfig inspectreads.AppSettingsSchemaitself. The scaffold's top-levelParametersblock is genuinely off-schema;modelling it is a cross-package change and belongs in its own issue (Drift D3).
packages/cliRestructure verdict in doctrine file 10. No new oversized file, no new flatfolder:
config/project/is at 8 children (cap 12), the command file is 111 LOC (cap 150), thelargest new module is 236 LOC (cap 250).
The regression guard
This is the part that matters more than the fix. The shipped test asserted one string mapping —
the single hardcoded
telemetry.otlpEndpointalias — and never a nested or record path. That isexactly why the defect reached
0.0.1-beta.11.The guard now asserts the property that actually failed, not a transform:
A future refactor of the key mapping cannot pass that test while writing a key the generator
ignores. 30 tests across three files cover: the reported path, the doubled prefix, record-key
preservation, hyphenated service keys, misspellings, scalar descent, the alias, off-schema
--forcewrites, value rejection, pre-existing-damage tolerance, and
config listcanonicality.No new
deno taskwas needed — the checks are unit tests, already wired intodeno task test.Slices
d2bf826,2ad29e32ad29e3set/getonto the resolver; unknown path errors,--forcewarns —2ad29e3netscript config list—edabab7d2bf826,edabab7Validation
Reproduced first, on
8e0bcef39, with the real command:— original value untouched, dead key created, exit 0. After the fix the same command sets
Persistent = falsein place and noNetScript.NetScriptkey exists.deno task fmt:checkfindings: 0over 1869 filesdeno task linttotalOccurrences: 0over 1724 filesdeno task checktotalOccurrences: 0over 2462 files, 21 batchesdeno task testdeno task arch:checkFAIL=rows, no new findingsdeno test packages/cli/src/packages/clideno task checkquality:scan(config feature)findings: []run-deno-lint/run-deno-fmtscoped to the config featurelint/fmt:checkexcludepackages/cli)Note on the scoped wrappers: root
deno task lintandfmt:checkexcludepackages/cli, sotheir green is not evidence for this change. Both wrappers were re-run scoped to
packages/cli/src/public/features/configand are clean; that is the evidence for the changed files.e2e:cli scaffold.runtimewas not run: this change touches no scaffold output, template, orgenerated artifact — only CLI-side key resolution. Called out rather than silently skipped.
Harness
.llm/runs/fix-config-set-schema-aware-keys--955/.llm/harness/gates/archetype-gate-matrix.mdIMPL— see the phase comments below.Drift / Debt
distinct from the generator. This was dispatched as a one-shot non-interactive fix with no second
session available, so
plan-eval.mdandevaluate.mddo not exist and the PR carriesstatus:impl, notstatus:ready-merge. An independent IMPL-EVAL is still owed before merge.cause; recorded in
drift.md.Parametersis off-schema.generateAppsettings()emits a top-levelParametersblock(MSSQL SA password) that
AppSettingsZoddoes not model, soparseAppSettings()strips it. Thisis why
--forceexists. Deserves its own issue: model the section, or document it as host-side.NET configuration outside the NetScript schema.
config getcallsloadConfig()before its appsettingsfallback, so it needs
netscript.config.tsto exist even for an appsettings-only path. Noted soit is not read as a regression from this PR.