Skip to content

Read project-wide settings from markfluence.yaml - #147

Merged
willkg merged 17 commits into
mainfrom
project-file-settings
Sep 12, 2026
Merged

Read project-wide settings from markfluence.yaml#147
willkg merged 17 commits into
mainfrom
project-file-settings

Conversation

@willkg

@willkg willkg commented Sep 12, 2026

Copy link
Copy Markdown
Member

Closes #100. Unblocks #139, which builds pages: on this loader.

markfluence.yaml has been a bare root marker whose existence was its whole meaning. It now declares project-wide defaults — space and page_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. space feeds one call site in create, page_width feeds resolveWidth in create and update. 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: ENG is wrong for every file in the project at once, and a markfluence.yaml written 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:

markfluence.yaml:1: unknown setting "spce" (known: page_width, space) -- an unrecognized setting may mean this project needs a newer markfluence

Loading happens in open(), the single place a Root is built from a marker hit, so Discover, Cache.walkAndCache and FromPath cannot 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 what export plants.

Five facts from the code that shaped it

  • open() is the single construction site for a Root from a marker, which is why the load lives there rather than in three places.
  • internal/project cannot import internal/pagewidthpagewidth imports client, and client.ResolveOptions.Roots is a *project.Cache. So the loader validates structure and a value's vocabulary is checked where it is consumed, plus a new offline lint in check.
  • loadEnvFile swallowed discovery failures. Harmless while the file was never read; now it would silently resolve .env from 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-file still overrides absolutely, pinned by a test.
  • update resolved 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.
  • update never reads space from the file (r.space comes from the live page), so a project-wide space: reaches only create until update: enforce space/parent and support page moves #10.

The one behavior change

A project-wide page_width: makes update assert 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-id may be a flag). markfluence sends basic auth to whatever host the resolved URL names, so a url: in a committed, walked-up file would decide where CONFLUENCE_TOKEN is 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 .env hole #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 space publishes to the wrong place in your own instance — visible, and fix recovers it. A wrong url hands out the token; neither.

One copy of the YAML dialect

internal/frontmatter now exposes Dialect.ReadMapping for a document with no --- fences, and internal/project reads 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) and scalarFields, the keys that must not hold a sequence — which a project file has no equivalent of and which #139's pages: entries will need to allow. The two nouns a diagnostic uses became a parameter, 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, pinned by its existing 800-line test file.

Verified live

Against the personal space on mozilla-hub.atlassian.net; the page was purged afterwards.

create with only a project-wide space: published to ~60c36d07…, page_width: wide taken from the file
update on a file declaring no width asserted narrow from the project file
unknown key the message above, exit 1
invalid project page_width caught offline by check, naming markfluence.yaml rather than the markdown file
--debug project file …/markfluence.yaml: space=ENG, page_width=wide

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. No --json or schema change: these are inputs, not results.

Two things the work turned up

  • A test fixture was writing the literal bytes into markfluence.yamlcmd/attachmentupload's generic writeFile helper, reused for the marker. The new refusal found it.
  • docs/markdown_file.md's page_width row was already wrong, claiming create and update both assert on every publish where update makes no width request at all for a file that declares none. Corrected while the row was being touched.

Settled during the work

  • No space: in the marker export plants — redundant by construction, since every exported file already carries its own space: 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 --persist still writes the resolved space: 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 can update it, 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 a pages: entry instead.
  • A 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.md gains an internal/project bullet, which it had none of at all — the same gap the last refresh found. docs/guarantees.md notes that an on-disk declared default sits inside L2's scope in the direction L2 wants, and is strictly better for it than the --space flag it replaces; status unchanged. --help had two stale claims, so docs/commands/ regenerates.

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.
@willkg

willkg commented Sep 12, 2026

Copy link
Copy Markdown
Member Author

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 Item into the flat-mapping refusal changed a user-facing string:

-frontmatter must be a flat mapping of key: value pairs, found String
+frontmatter must be a flat mapping of frontmatter: value pairs, found String

That lands verbatim in check --json. It survived because the existing assertion matched the substring "flat mapping", which both spellings contain. 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 — byte-identical to main. TestParseErrorWordingIsExact now pins every message frontmatter composes as a whole sentence; goccy's own parse errors stay out of it, since pinning a dependency's wording would make a goccy bump look like a markfluence regression.

2. check's new lint was a false positive. It 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 fine. The comment justifying the severity ("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 frontmatter declares no width — exactly the set that would use the default.

3. --debug reported the wrong root, for commands that read no settings. ui.Debug fired inside loadConfig, and 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. Reporting moved to project.ReportSettings(cache), an explicit call beside where each command already prints root:. Loading prints nothing, pinned by a test. This also answers the layering objection: a low-level package no longer prints as a side effect of doing its job.

4. --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 (already 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.

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. attachment-upload reported a ConfigError the old way — wrapped as resolving the documentation root, coded IO — contradicting the CLAUDE.md bullet this branch adds. Now badInput/VALIDATION, matching create, update and check.

7. The reorder in update changed roots reporting for an all-skipped batch. It used to report none, since no file reached the root resolve. Defensible either way — roots means every distinct root the command resolved, and it did — but it was unrecorded, and now it is.

Test quality

Two findings there, both fixed.

TestRunProjectDefectIsScopedToItsOwnRoot was 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.

Nothing pinned update making an actual width request from a project default — only resolveWidth's apply bool, 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 fails the first.

Also

CLAUDE.md and docs/guarantees.md still cited scalarValue/elementValue, renamed by the refactor. _plans/038 records all seven findings with what each one broke, so the next change here starts from the evidence rather than from the claims.

What the review checked and found sound: the loader, the abort rule, walkAndCache's backfill past a malformed marker, items/maps against the old toMaps (identical partitioning, List nil-vs-empty preserved, a key in exactly one map), Item.Line, the precedence chain in all three resolvers including blank/whitespace/null at each level, apply on every error branch, --env-file overriding absolutely, and D4's empty/comment-only/declared-but-empty cases.

make check clean.

@willkg
willkg merged commit 0ea1ee1 into main Sep 12, 2026
1 check passed
@willkg
willkg deleted the project-file-settings branch September 12, 2026 21:35
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.

markfluence.yaml: support project-wide settings

1 participant