Read project-wide settings from markfluence.yaml - #147
Conversation
Implements the design for #100: a project-wide default space and page_width, resolved flag > frontmatter > project file, plus the loader and the malformed-file handling every later key rests on. Five facts from the existing code shape it. open() is the single place a Root is built from a marker hit, so loading there gives Discover, Cache and FromPath one copy of the abort rule. internal/project cannot import internal/pagewidth -- pagewidth imports client and client holds a *project.Cache -- which is what decides where a vocabulary check can run. loadEnvFile swallows a discovery failure today, so a malformed project file would silently move .env resolution to the working directory. update resolves width before it resolves the root and has to be reordered. And update never reads space from the file at all, so a project-wide space: reaches only create until #10. Two questions the plan opened are settled in it. No url: key, and for a sharper reason than #100 gives: basic auth goes to whatever host the resolved URL names, so a committed, walked-up file naming one decides where the token is sent -- a worse version of the .env hole #136 documents. And no space: in the marker export plants, which is redundant by construction, since every exported file already carries its own space: in frontmatter.
…ents Move the reader -- the scalar node-kind whitelist, the single-line rule, the null spellings, the flat-mapping refusal -- into dialect.go, and add ReadMapping so a document with no "---" fences can be read through it. internal/project needs these rules to read markfluence.yaml (#100) and none of them were reasoned about: each turned up by probing the pinned goccy, so a second copy for a second file would be a second set of the same bugs. What this package really owns is markfluence's YAML dialect; the fenced block is one use of it. Two things stay frontmatter's own. The "---" position shift exists because a block's text excludes its opener, and a whole file needs no correction, so it is a fixup on the reader rather than part of the formatting. And scalarFields -- the keys that must not hold a sequence -- protects `parent: [x]` from reading as absent, which a project file has no equivalent of, and which #139's pages: entries will need to allow. The two nouns a diagnostic uses are now a parameter (Dialect), because `frontmatter must be a flat mapping` and `setting "space" must be a single scalar value` are both right and neither noun works in the other's sentence. Every message frontmatter itself emits is unchanged. ReadMapping reports items in source order with their line numbers rather than as a map: a caller rejecting an unknown key has to be able to say where it is, and a map would lose that.
…stand The project file has been a bare marker whose existence was its whole meaning. It now declares project-wide settings -- space and page_width -- and a file that cannot be understood is not a valid marker: discovery fails rather than walking on to an ancestor that happens to have a good one, and never falls back to the markdown file's own directory. The root decides every attachment name, bounds every read, and anchors the link index, so a file that cannot be understood means the project's boundary is unknown, and guessing is worse than stopping (#100). An unrecognized key is fatal, and that is the point rather than a cost. A silently ignored `spce: ENG` is wrong for every file in the project at once, and a project file written for a newer markfluence holds keys this binary would ignore -- so the error says an older binary is the likely cause. The file carries no schema version, deliberately. Loading happens in open(), the single place a Root is built from a marker hit: Discover, Cache.walkAndCache and FromPath all reach it, so the rule exists in one copy. It runs before os.OpenRoot so a refusal leaks no handle. --root loads the file too -- it overrides discovery, not the root's own settings. Structure is checked here; a vocabulary is not. internal/pagewidth imports internal/client and internal/client holds a *project.Cache, so this package cannot ask whether "huge" is a width -- pagewidth.Declared catches that where it already runs, and check gains an offline lint for it. Config holds no url or token, and the reason is sharper than "credentials are a different chain": basic auth goes to whatever host the resolved URL names, so a committed, walked-up file naming one would decide where the token is sent. That is a worse version of the .env hole #136 documents. An empty file and a comment-only one stay valid -- that is what ships, what the README documents, and what export plants. A test fixture that wrote the literal "bytes" into markfluence.yaml is now a real marker; the refusal found it.
loadEnvFile discovered the project root inside `if err == nil` and degraded to the working directory on any failure. That was harmless while the project file was never read. Now that it is parsed (#100), a file that cannot be understood makes discovery fail -- and degrading there would read a different .env than the project's, silently, and would leave a command with no per-file root of its own (read, search, info) never reporting the malformed file at all. #100 settles that as: abort immediately. --env-file still overrides discovery absolutely, which is how someone works around a project file they cannot fix; a test pins it. A missing .env, wherever it lands, is still fine.
#100's use case: a hundred files in one space, each repeating `space: ENG`. A project file declaring it once removes the duplication, and the chain is flag > frontmatter > project file -- the answer closer to the content wins. The project default is consulted only when both levels above it are silent, which is what keeps it from needing any new disagreement rule. --space and a frontmatter space both set and differing stays an error, unchanged: those are two answers about where a page goes with no reason to prefer one. A project default cannot be a third answer of that kind. The space chain moves into resolveSpace, beside resolveTitle and resolveWidth, because it was six inline lines with no way to test the precedence directly. A blank frontmatter page_width now falls through to the project default instead of short-circuiting to max, matching how every other field reads a blank value as unset. resolveWidth names the project file when the bad value came from there. internal/project cannot validate a width itself -- it would have to import internal/pagewidth, which imports internal/client, which holds a *project.Cache -- so this is the first place a project-wide one is checked, and "invalid page_width" pointing at a markdown file that never mentions one is the wrong file to send someone to.
Completes the width chain: --page-width, then the frontmatter page_width, then the project file's default. This is the one level that changes update's behavior. A project declaring page_width makes update assert a width on a file that declares none, where before the live width was left alone. That is deliberate -- it is what "declared means asserted" (L9) means one level up -- and a project that wants the live width untouched omits the key, which keeps "absent means no width request at all" intact. The root is now resolved above resolveWidth rather than beside the link index, because every local check has to stay ahead of the first request and the width chain now ends at a file on disk. The walk is cached, so asking early costs nothing. Building the *index* did not move, so a file the mtime check skips still never pays for one. A malformed project file is reported as VALIDATION rather than IO, via project.IsConfigError, and project.RootError leaves it without the "resolving the documentation root" heading: the root was found, and it is the file in it that is wrong. Reporting a file the author can open and fix as I/O sends the reader looking for a disk fault.
check is the one verb that can find a project-wide page_width Confluence does not accept without publishing. internal/project cannot validate its own value -- it would have to import internal/pagewidth, which imports internal/client, which holds a *project.Cache -- so the loader checks structure and this checks the vocabulary. Reported as Broken rather than a warning, because it fails every publish under that root, which is the severity an invalid frontmatter page_width already gets. And reported on each file under the root rather than once for the run, which is what keeps every diagnostic scoped to the files actually named: checking a file under a different project says nothing about this one. frontmatterBroken becomes localBroken -- it now holds defects found without the converter generally, not only frontmatter ones. A malformed markfluence.yaml fails a file as VALIDATION rather than IO, and without the "resolving the documentation root" heading, here and in create: the root was found, and it is the file in it that is wrong. A file the author can open and fix reported as I/O sends the reader looking for a disk fault.
A project-wide default is invisible by construction: it takes effect for a file that says nothing about it, so "why did this publish to ENG?" has no answer in the file the reader is looking at. --debug is where that answer goes. Only a file that declares something earns a line. The marker that ships declares nothing, and a line per root in a batch would be noise -- the root itself is already reported unconditionally. No --json or schema change: these are inputs, not results.
Two comments said nothing in markfluence.yaml is parsed, which #100 made false. The body export writes is unchanged: it declares no settings, and deliberately -- every file export writes already carries its own space: and page_width: in frontmatter, and frontmatter beats the project file, so a space: in the planted marker would be overridden by every file under it. It would matter only for a file somebody adds later, and even then inconsistently, since an existing marker is never overwritten (S3).
docs/root-model.md said the project file's existence was its whole meaning and that nothing in it is parsed. Both are now false. Its section is rewritten around the settings, the flag > frontmatter > project file chain, why an unrecognized key is fatal, and what the file deliberately does not hold -- and the CVE-2022-24765 framing below it is tightened rather than dropped: the file is read now, but still never executed, and nothing in it can redirect a credential. CLAUDE.md had no internal/project bullet at all, which is the same gap the last refresh found: a package doing real work with nothing written down about it. It now carries the two-starting-points reason the package returns a type, the cache's walk, and the loader -- including why loading lives in open(), why an unknown key is fatal, why the vocabulary check is somewhere else, and why Config holds no url. docs/markdown_file.md gains the chain beside the field table, and the page_width row is corrected while it is being touched: it claimed create and update both assert on every publish, where update makes no width request at all for a file that declares none. --help had two stale claims -- create's "the space comes from --space or frontmatter" and update's width sentence -- so docs/commands/ regenerates. docs/guarantees.md notes that a project-wide default sits inside L2's scope in the direction L2 wants: declared on disk, found by the same working-directory-independent walk, and strictly better than the --space flag it replaces. Status unchanged. Also silences errcheck on a few root.FS.Close() calls.
Found while testing #100 live: create writes the space it resolved back into the file, so a file create makes carries space: ENG even when the project file already said it, and the dedup pays off only for hand-written files that are only ever updated. Left alone. The target workflow is CI updating existing pages, not creating them -- a person creates the page and wires the repo up so a workflow can update it, which is the division #139 already records as "CI updates; humans create". create is the verb a human runs once with their own flags, and the write-back recording what it did is a feature there. Not persisting a project-supplied value would also make a file's frontmatter silently depend on where it sits, and #139 supersedes the problem by writing a pages: entry instead.
The refactor claimed to be behavior-preserving and was not. Threading Item into the flat-mapping message turned frontmatter must be a flat mapping of key: value pairs, found String into frontmatter must be a flat mapping of frontmatter: value pairs, found String a user-facing string that lands verbatim in check --json. Item names a *named* key -- `setting "space" must be ...` -- and reads as nonsense in a sentence about keys in general, so that word is literal again for both dialects. It survived review and the test suite because the existing assertion matched the substring "flat mapping", which both spellings contain. TestParseErrorWordingIsExact now pins every message frontmatter composes as a whole sentence. goccy's own parse errors stay out of it: their wording belongs to the dependency, and pinning it would make a goccy bump look like a markfluence regression.
The lint reported an invalid project-wide page_width as Broken for every file
under the root -- including a file declaring its own width, which wins, so
update publishes it perfectly well. The justifying comment ("fails every
publish under this root") was false against the very resolvers the lint
previews, and CLAUDE.md's rule for check is that a false positive is worse
than a miss.
Now reported only for a file whose own frontmatter declares no width, which
is exactly the set that would use the project default.
TestRunProjectDefectIsScopedToItsOwnRoot was also trivially true: it named
only the good project, so nothing would touch the bad tree under any
implementation. It now names both files in one run and asserts exactly one
fails and that the good file is never blamed for the other project's width.
…he load ui.Debug inside loadConfig described the wrong root and fired for commands that read no settings. Loading happens once per root for two unrelated reasons -- a markdown file's root, and the separate walk from the working directory that only locates .env -- so `info --debug` announced a project file it never consulted, and create described cwd's root rather than any file's. project.ReportSettings(cache) is now an explicit call beside where create, update and check already print `root:`, so the roots it describes are the ones the command actually used. Loading prints nothing, which a test pins. Two more things the review turned up in the same area: --root re-read the project file once per starting directory. Cache held the override Root under abs only, so each new directory re-ran FromPath -- a second os.OpenRoot, true before this branch, and now a second file read for a root that cannot differ. Cache.overrideRoot builds it once, and Close dedupes by *Root so a shared handle is not closed N times. A leading UTF-8 BOM was read as part of the first setting's name, so a file a Windows editor wrote reported an unknown setting and advised upgrading markfluence -- the wrong remedy for the wrong problem. Stripped, at the start of the file only: anywhere else it is content markfluence must not silently discard.
…fect It still wrapped the failure as "resolving the documentation root" and coded it IO, where create, update and check all use project.RootError and VALIDATION -- contradicting the CLAUDE.md bullet this branch added, which says those helpers exist so a caller reports a markfluence.yaml the author can open and fix as a local defect rather than sending them to look for a disk fault.
Nothing asserted that a project-wide page_width makes update actually *request* the width -- only resolveWidth's apply bool, which is one layer above where the behavior change lives. Three tests now assert on the content- property traffic: two writes for a file declaring none (both the published and draft appearance properties, or the reader and the editor disagree), no requests at all when the project declares no width, and the file's own value on the wire when it declares one. Sabotage-checked: flipping apply to false in resolveWidth's project branch fails the first of them.
CLAUDE.md and docs/guarantees.md still cited scalarValue and elementValue, which the dialect refactor renamed to reader.scalar and reader.element. CLAUDE.md's frontmatter bullet also gains the shared dialect: that Dialect.ReadMapping is how internal/project reads markfluence.yaml, and that a Dialect's nouns name a document and a *named* key only -- using Item for the word "key" in general is what silently rewrote a user-facing message, now pinned sentence-by-sentence. _plans/038 records all seven review findings with what each one broke, so the next change in this area starts from the evidence rather than from the claims.
|
Adversarial review found seven real findings; all are fixed in 6 follow-up commits. Two were merge blockers, and the first is a correction to this PR's own description. 1. The frontmatter refactor was not behavior-preserving, contrary to what I claimed above. Threading That lands verbatim in 2. 3. 4. 5. A UTF-8 BOM was diagnosed as "needs a newer markfluence." A file a Windows editor wrote reported an unknown setting whose name began U+FEFF. Stripped, at the start of the file only — anywhere else it is content markfluence must not silently discard. 6. 7. The reorder in Test qualityTwo findings there, both fixed.
Nothing pinned Also
What the review checked and found sound: the loader, the abort rule,
|
Closes #100. Unblocks #139, which builds
pages:on this loader.markfluence.yamlhas been a bare root marker whose existence was its whole meaning. It now declares project-wide defaults —spaceandpage_width— resolved flag > frontmatter > project file, so the answer closest to the content wins. The design is in_plans/038_project-file-settings.md.The keys are the small half.
spacefeeds one call site increate,page_widthfeedsresolveWidthincreateandupdate. Almost everything interesting is in the loader and in what it refuses.A file that cannot be understood stops the command
An unparseable file, or one holding an unrecognized key, is an error naming what is wrong — and it is specifically not treated as a valid root marker. Discovery does not walk on to an ancestor that happens to have a good one, and does not fall back to the markdown file's own directory. The root decides every attachment name, bounds every read, and anchors the link index, so a project file that cannot be understood means the project's boundary is unknown, and guessing is worse than stopping.
Refusing an unrecognized key is the point rather than a cost. A silently ignored
spce: ENGis wrong for every file in the project at once, and amarkfluence.yamlwritten for a newer markfluence holds keys an older binary would ignore — so the file carries no schema version, deliberately, and the error says an older binary is the likely cause:Loading happens in
open(), the single place aRootis built from a marker hit, soDiscover,Cache.walkAndCacheandFromPathcannot come to disagree about the rule. An empty file and a comment-only one stay valid — that is what ships, what the README documents, and whatexportplants.Five facts from the code that shaped it
open()is the single construction site for aRootfrom a marker, which is why the load lives there rather than in three places.internal/projectcannot importinternal/pagewidth—pagewidthimportsclient, andclient.ResolveOptions.Rootsis a*project.Cache. So the loader validates structure and a value's vocabulary is checked where it is consumed, plus a new offline lint incheck.loadEnvFileswallowed discovery failures. Harmless while the file was never read; now it would silently resolve.envfrom the working directory instead of the project root, and would leave a command with no per-file root of its own (read,search,info) never reporting the malformed file at all. Fixed.--env-filestill overrides absolutely, pinned by a test.updateresolved width before it resolved the root, so the root moves up — the walk is cached, and building the index did not move, so a file the mtime check skips still never pays for one.updatenever readsspacefrom the file (r.spacecomes from the live page), so a project-widespace:reaches onlycreateuntil update: enforce space/parent and support page moves #10.The one behavior change
A project-wide
page_width:makesupdateassert a width on a file that declares none, where before the live width was left alone. Deliberate — it is what "declared means asserted" (L9) means one level up — and a project that wants the live width untouched omits the key, which keeps "absent means no width request at all" intact.What it deliberately does not hold
No
url,username, or token, and the reason is sharper than "those are credentials" (which #100 gives, and which invites relitigating since a site URL is not secret — it is why--cloud-idmay be a flag). markfluence sends basic auth to whatever host the resolved URL names, so aurl:in a committed, walked-up file would decide whereCONFLUENCE_TOKENis sent: one line in a pull request would redirect a CI run's token to a host of the author's choosing. That is a worse version of the.envhole #136 records, worse because this file is committed, shared, reached from a subdirectory, and reviewed as content rather than as configuration.The asymmetry against the settings it does hold is the whole argument. A wrong
spacepublishes to the wrong place in your own instance — visible, andfixrecovers it. A wrongurlhands out the token; neither.One copy of the YAML dialect
internal/frontmatternow exposesDialect.ReadMappingfor a document with no---fences, andinternal/projectreads through it. Every rule there was found by probing the pinned goccy — the scalar node-kind whitelist, the single-line rule, the null spellings, the flat-mapping refusal — so a second parser for a second file would be a second set of the same bugs, which is the "third minimal parser" #100 warned about and declined. What the package really owns is markfluence's YAML dialect; the fenced block is one use of it.Two things stay frontmatter's own: the
---position shift (a whole file needs no correction) andscalarFields, the keys that must not hold a sequence — which a project file has no equivalent of and which #139'spages:entries will need to allow. The two nouns a diagnostic uses became a parameter, becausefrontmatter must be a flat mappingandsetting "space" must be a single scalar valueare both right and neither noun works in the other's sentence. Every message frontmatter itself emits is unchanged, pinned by its existing 800-line test file.Verified live
Against the personal space on
mozilla-hub.atlassian.net; the page was purged afterwards.createwith only a project-widespace:~60c36d07…,page_width: widetaken from the fileupdateon a file declaring no widthnarrowfrom the project filepage_widthcheck, namingmarkfluence.yamlrather than the markdown file--debugproject file …/markfluence.yaml: space=ENG, page_width=wideA project-wide default is invisible by construction — it takes effect for a file that says nothing about it, so "why did this publish to ENG?" has no answer in the file the reader is looking at.
--debugis where that answer goes. No--jsonor schema change: these are inputs, not results.Two things the work turned up
bytesintomarkfluence.yaml—cmd/attachmentupload's genericwriteFilehelper, reused for the marker. The new refusal found it.docs/markdown_file.md'spage_widthrow was already wrong, claimingcreateandupdateboth assert on every publish whereupdatemakes no width request at all for a file that declares none. Corrected while the row was being touched.Settled during the work
space:in the markerexportplants — redundant by construction, since every exported file already carries its ownspace:in frontmatter (pagedoc.Frontmatter) and frontmatter beats the project file. It would matter only for a file added later, and even then inconsistently, since an existing marker is never overwritten (S3).create --persiststill writes the resolvedspace:back into the file. Left alone: the target workflow is CI updating existing pages, not creating them — a person creates the page and wires the repo up so a workflow canupdateit, which is markfluence.yaml pages: page metadata outside the markdown file #139's "CI updates; humans create". Not persisting a project-supplied value would make a file's frontmatter silently depend on where it sits, and markfluence.yaml pages: page metadata outside the markdown file #139 supersedes the problem by writing apages:entry instead.url:cross-check — the safe direction of that key, where the project file declares the site and markfluence refuses to publish when the resolved URL disagrees, fencing the token rather than directing it. Noted in the plan, not filed; it waits for someone to want it.Docs
docs/root-model.md's project-file section is rewritten — its central claim was that nothing in the file is parsed — and the CVE-2022-24765 framing below it is tightened rather than dropped: the file is read now, still never executed, and nothing in it can redirect a credential.CLAUDE.mdgains aninternal/projectbullet, which it had none of at all — the same gap the last refresh found.docs/guarantees.mdnotes that an on-disk declared default sits inside L2's scope in the direction L2 wants, and is strictly better for it than the--spaceflag it replaces; status unchanged.--helphad two stale claims, sodocs/commands/regenerates.