ReviewOS

stacks/stx

RFC: silent failures and ergonomics gaps (evidence: a 6.5k-line stx app that never engaged the framework)

#1792
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

RFC: silent failures and ergonomics gaps in stx

Companion to #1791. That issue documents how agents should write stx. This one argues that a sizeable share of what they get wrong is a framework affordance problem, not only a user problem.

Evidence base: bughq on stx 0.2.113 — ~6,500 lines of stx written largely by AI agents — plus a 60-finding audit (33 confirmed under adversarial review).

The case

bughq is a working app that never engaged the framework. No shell, so no hydration. No layouts, so 24 duplicated documents. No strict, so 68 prohibited DOM calls. No plugin registration, so 45 shipped components sat unused. Every one of those is a default that pointed the wrong way, or a failure that happened in silence.

None of them produced an error. Not one warning, across months of work.

Headline proposals

#ProposalBreaking
1A <!DOCTYPE> in a page should warn — it silently disables layout resolution (process.js:311)no
2Script extraction must not terminate on a closing tag inside a comment (non-greedy regex)no
3generateDocumentShell must escape the title — it is a stored XSS vector when the title carries user datano
4const meta = { title } in a server script should warn; it is a silent no-op on fragment pagesno
5The fragment/SPA path is gated on a shell existing (serve-app.js:427) — an app with no app.stx never hydrates, silentlyno
6Directory options are double-prefixed by root resolution, resolving to paths that do not existyes
7:attr bindings are not resolved during SSR, so each needs a hand-written static fallbackno
8interceptAllLinks defaults disagree between stx-router (false) and bun-plugin-stx serve (hardcoded true)yes
9strict should default on for new projects — it would have blocked all 68 violations mechanicallyyes
10@stacksjs/components should auto-register rather than needing manual plugin wiringno
11The server→client data bridge already exists; make the safe path the only pathno
12stx new should scaffold a project that cannot start in bughq's stateno

A note on the shipped agent skill

.claude/skills/stacks-stx/SKILL.md ships with Stacks and is auto-loaded by agents. In the version bughq has, it names config/stx.ts (does not exist — it is config/ui.ts) and resources/layouts/ (exists but is empty; the real layouts are in resources/views/layouts/), and its recommended config block is character-for-character the one that produces the double-prefix defect in proposal 6.

It is silent on doctype, layouts, StxLink, useHead, stores and app.stx.

The codebase in the evidence base is a faithful implementation of its own instructions. If stx ships an agent skill, its paths should be generated from the config resolution rather than hand-written — a stale skill manufactures broken projects at scale, and there is now a 60-finding codebase demonstrating it.


Full detail in the comments: Part one — silent failures that should be loud, and Part two — ergonomics and syntax proposals.

9 days ago

Part one — silent failures that should be loud

Nine framework behaviours that produce wrong output with no error, no warning, and no log line. Each one cost hours in this codebase. They are ordered by how long they took to find, not by severity.

#FailureFramework siteObservable signal today
1<!DOCTYPE> in a page disables the layout systemprocess.js:311none
2</script> in a comment or string truncates the blockvariable-extractor.js:1086, expressions.js:327, utils.js:54none in prod; a misleading tag-balance warning in dev
3<html> anywhere — including a comment — skips the document shelldocument-shell.js:65none
4Page title interpolated into <title> unescapeddocument-shell.js:39none (stored XSS)
5const meta = { title } is a no-op on fragment pagesseo.js:253none
6site.config.ts title can never set <title>site-builder/seo.js:22none
7No app.stx ⇒ SPA fragments never serveddev-server/serve-app.js:427a deprecation notice pointing the wrong way
8Directory options double-prefixed by root resolutionconfig.js:411-418none
9:attr bindings unresolved during SSR and uncloakedsignals.js:1784, process.js:604-605none

1. A DOCTYPE in a page silently disables the entire layout system — MUST fix

Mechanism. Auto-layout resolution is gated on the page not looking like a document:

// node_modules/@stacksjs/stx/dist/process.js:310-312
if (!layoutPath && !hasNoLayout && opts.defaultLayout) {
  const hasDoctype = /<!DOCTYPE\s/i.test(output), hasSections = /@section\s*\(/.test(output);
  if (!hasDoctype || hasSections) {

hasDoctype is a whole-template regex, not an anchored one — a DOCTYPE inside a code sample, a docs page, or an install snippet trips it. When it trips, layoutPath stays "", the _layout.stx walk (process.js:313-322) and the layoutsDir/${defaultLayout}.stx lookup (process.js:327-331) are both skipped, and the page is returned as-is. No branch in that block logs anything.

There is already an explicit opt-out — @nolayout, handled at process.js:302-304 — which makes the DOCTYPE heuristic redundant as well as silent.

Repro.

layouts/default.stx:  <div class="LAYOUT-CHROME">@yield('content')</div>
const opts = { ...defaultConfig, autoShell: false, defaultLayout: 'default', layoutsDir: `${import.meta.dir}/layouts` }
await processDirectives('<main>page body</main>', {}, page, opts, new Set())
await processDirectives('<!DOCTYPE html>\n<main>page body</main>', {}, page, opts, new Set())
--- no doctype ---
<!-- stx-layout: .../layouts/default.stx -->
<div class="LAYOUT-CHROME"><main>page body</main></div>

--- with doctype ---
<!DOCTYPE html>
<main>page body</main>

Same options, same layout on disk, one line of difference — and the second render has no layout, no stx-layout comment, and therefore no <meta name="stx-layout"> for the router (process.js:227-232).

Damage in this codebase. /Users/glennmichaeltorregosa/Documents/Stacks/bughq/resources/views/layouts/marketing.stx is a complete, correct marketing layout. grep -rn '@layout(\|@extends(' resources/ returns zero hits, and 25 .stx files carry their own <!DOCTYPE html>. Every one of the 24 marketing pages hand-rolls the full document instead: resources/views/index.stx:31, resources/views/compare/sentry.stx:1, resources/views/features/alerts.stx, and so on — 137-153 lines each, with the <head>, the nav include, the footer include and the <link rel="stylesheet" href="/marketing.css"> (index.stx:40, compare.stx:25, docs.stx:11) duplicated per page. The layout file is dead code and nobody noticed, because opting out of the layout system looks identical to never having had one.

Proposed fix. Delete the DOCTYPE heuristic. @nolayout is the opt-out. If it must stay for compatibility:

  1. Anchor it: /^\s*<!DOCTYPE\s/i — a DOCTYPE inside page body content is not a document declaration.
  2. Emit a warning whenever it fires and a layout was resolvable: [stx] {file}: layout '{name}' skipped because the page starts with a DOCTYPE. Use @nolayout to opt out explicitly, or remove the DOCTYPE.
  3. Longer term, make it an error under strict.

2. A closing script tag inside a comment or a string truncates the enclosing block — MUST fix

Mechanism. Every script extractor in the pipeline is a non-greedy regex that stops at the first </script> in the raw text, with no awareness of JS string, template-literal or comment context:

SitePattern
variable-extractor.js:1086/<script\b[^>]*>([\s\S]*?)<\/script>/i
script-classifier.js:15/<script\b([^>]*)>([\s\S]*?)<\/script>/gi
expressions.js:327/<script\b([^>]*)>([\s\S]*?)<\/script>/gi
process.js:484/<script\b([^>]*)>([\s\S]*?)<\/script>/gi
utils.js:552, utils.js:639same shape
signal-processing.js:13html.toLowerCase().indexOf("</script>", bodyStart) — different code, identical semantics
template-tag-balance.js:41same regex, used to strip scripts before counting tags

There is a second, worse half. Writing <\/script> in the source — the documented JS workaround — does not survive, because transpileTypeScript runs the block through Bun's transpiler:

// node_modules/@stacksjs/stx/dist/utils.js:54-58
let result = new Bun.Transpiler({ loader: "ts", target: "browser", define: getPublicEnvDefine() })
  .transformSync(processedCode);

called from signal-processing.js:660-661 for every non-server script. The transpiler normalises the escape away:

in:  const s = `<script src="/sdk.js"><\/script>`
out: const s = `<script src="/sdk.js"></script>`;

So the escape protects the source from stx's regex and then gets stripped before the browser sees it, where the HTML parser applies exactly the same rule.

Repro. Any of these three lines inside a <script client> block:

// closing tag: </script>          ← a comment
const s = '</script>'              ← a string
const s = `<\/script>`             ← an "escaped" template literal, un-escaped by the transpiler

Damage in this codebase — hit twice.

First hit, resources/views/projects/new.stx, fixed in 7846262. The install-snippet builder returned a literal sdk.js tag; the client block truncated at the assignment, the browser reported Unexpected end of input, and the remainder of the script was painted onto the page as text. The fix in production today is to assemble the tag name from a variable so the byte sequence never exists:

const tag = `scr${'ipt'}`
return `<${tag} src="${window.location.origin}/sdk.js" ...></${tag}>`

Second hit, resources/views/settings.stx, fixed in 52763c8. The JavaScript install snippet is an sdk.js tag (settings.stx:100), so on any project whose platform was javascript, browser, or the HTML default, the <script client> block truncated at that assignment and every control defined below it — reveal, rotate, copy, members, alerts, archive, delete — silently did not exist. It shipped in 3a8ac75 and stayed invisible for weeks because every project used for verification happened to be platform node. The current mitigation base64-encodes every value crossing into the client block (settings.stx:144-146), because base64's alphabet contains no <.

And the comment documenting the trap hit the trap. settings.stx:142-143:

// The same trap applies to this comment, which is why it never spells the tag // out — doing so truncates the server block instead.

Why the existing warning does not help. warnUnbalancedTags (template-tag-balance.js:73) would catch a stray </script> — it strips scripts with the truncating regex at line 41, leaving the residue to be counted. But it is gated to development at process.js:198-199:

if ((options.debug || !isProduction() && !isTest()) && filePath && filePath.endsWith(".stx"))
  warnUnbalancedTags(template, filePath);

and it only sees the source template. Neither bughq case had a literal </script> in source — one was <\/script> un-escaped by the transpiler, the other was a runtime value. Both produced a broken document with a clean dev console.

Proposed fix.

  1. One tokenizer. Replace all seven extraction sites with a single string-aware scanner that tracks ', ", `, // and /* */ while looking for </script. signal-processing.js:8-31 (scanScriptTags) is the natural home; it already returns {fullMatch, attrs, body, start, end}.
  2. Escape on emit, not on input. Wherever a script body is re-serialised — expressions.js:337, signal-processing.js setup emission, utils.js:58 output — replace </script with <\/script in the output. This is the standard fix and it survives the transpiler because it is applied after it.
  3. Fail loudly when neither is possible. If a script body still contains </script at emit time, throw StxSyntaxError with the offending line. A truncated script is never the intent.
  4. Ungate the tag-balance warning from isProduction(), or at minimum add a dedicated stray-</script> check that runs in every mode.

3. A literal html tag anywhere in a fragment, including in a comment, skips the document shell — MUST fix

Mechanism. Shell detection is an unanchored, comment-blind regex over the fully-rendered output:

// node_modules/@stacksjs/stx/dist/document-shell.js:64-66
export function hasDocumentShell(html) {
  return /<!DOCTYPE\b/i.test(html) || /<html[\s>]/i.test(html);
}
// :101-104
export function ensureDocumentShell(html, headConfig = {}, options = {}) {
  if (hasDocumentShell(html)) return html;
  return generateDocumentShell(html, headConfig, options);
}

HTML comments are masked during processing (process.js:289-291) but restored at process.js:449 / process.js:454, before processDirectives reaches the autoShell block at process.js:205 and calls ensureDocumentShell at process.js:224. So the comment text is live when the test runs.

Repro.

const opts = { ...defaultConfig, autoShell: true }
const body = '<main class="grid gap-3"><p x-show="false">boo</p></main>'
processDirectives(body, ...)                                   // clean
processDirectives('<!-- see the root <html> element -->\n' + body, ...)  // one comment
clean    {"doctype":true,  "head":true,  "cloakStyle":true,  "crosswind":true}
comment  {"doctype":false, "head":false, "cloakStyle":false, "crosswind":true}

Damage. Three cascading losses, none reported:

  1. No <head>. injectCloakStyle bails at document-shell.js:5-7 when there is no </head>, so CLOAK_STYLE never ships — and stx has already added x-cloak to conditional elements at process.js:605. Every element the framework deliberately hid renders visible.
  2. No <meta name="stx-layout">. process.js:228 requires result.includes("<head").
  3. Native navigation. With no stx-layout meta, no data-stx-content and no __stxRouterConfig, the router's document sniff fails and it hard-navigates:
// node_modules/stx-router/dist/client.js:383-392
function isStxDocument(html){
  return html.indexOf('name="stx-layout"')!==-1
    ||html.indexOf('__stxRouterConfig')!==-1
    ||html.indexOf('data-stx-content')!==-1;
}
function swap(html,url,pushState,hash){
  var isFragment=html.indexOf('<!--stx-fragment-->')===0;
  if(isFragment)html=html.slice('<!--stx-fragment-->'.length);
  if(!isFragment&&!isStxDocument(html)){log('[router] non-stx document — full navigation to:',url);location.href=url;return}

In this codebase one comment mentioning the root element turned every app-to-app navigation into a full page reload. It was found by accident while debugging something else and written up as a house rule (342f52e, .claude/skills/stacks-browse/SKILL.md:182-186): "Write it as 'the root html element'." A framework that requires prose workarounds in comments has a bug, not a convention.

Proposed fix.

  1. Strip comments and CDATA before the test, and anchor the <html probe to the start of the trimmed document:
    const stripped = html.replace(/<!--[\s\S]*?-->/g, '')
    return /^\s*<!DOCTYPE\b/i.test(stripped) || /^\s*<html[\s>]/i.test(stripped)
    app-shell.js:119 already anchors its equivalent test (/^<html[\s>]/i); document-shell.js:65 is the outlier.
  2. When autoShell is on and hasDocumentShell returns true for a file under pagesDir, log once: [stx] {file}: document shell skipped — page already declares <html>/<!DOCTYPE>. Fragment pages must not.

4. generateDocumentShell interpolates the page title unescaped — MUST fix (stored XSS)

Mechanism.

// node_modules/@stacksjs/stx/dist/document-shell.js:21
} = headConfig, pageTitle = options.title || configTitle, metaTags = [...
// :39
    `  <title>${pageTitle}</title>`,

No escaping. pageTitle is fed from headConfig.title, which process.js:212-214 derives from useHead({ title }), the page's own <title>, or @section('title') — all of which routinely carry runtime data.

The same function interpolates meta and link attributes raw at document-shell.js:25 and :28 (${k}="${v}"), so app.head.meta and app.head.link are equally exposed.

This is not a missing capability. The sibling function in seo.js escapes every field it writes (seo.js:296-303) using a helper defined 60 lines away:

// node_modules/@stacksjs/stx/dist/seo.js:309-311
function escapeHtml(str) {
  return str.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");
}

Repro.

await processDirectives('<main>hi</main>', {}, page,
  { ...defaultConfig, autoShell: true, app: { head: { title: '</title><script>alert(1)</script>' } } }, new Set())
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title></title><script>alert(1)</script></title>

Damage. bughq's issue page titles itself from the error message reported by the customer's application — i.e. from attacker-influenced text. resources/views/issue/[id].stx:106-107:

const issueHeadline = String((latest && latest.message) || issue?.title || '').replace(/[<>]/g, '').trim()
useHead({ title: issueHeadline ? `${...}` : 'Issue - bughq' })

The comment above it (issue/[id].stx:95-98) records that this was verified against a real event: a message that closed the title element and opened an inline script executed. The mitigation is to drop angle brackets rather than entity-escape them, because escaping would leak &lt; through the X-STX-Title header on SPA arrival (dev-server/serve-app.js:440) where entities do not decode. Every stx application that puts user data in a title has this hole and no reason to suspect it.

Proposed fix.

  1. Escape pageTitle at document-shell.js:39 and every ${v} at :25/:28/:31-33. Export the existing escapeHtml from seo.js rather than adding a fourth copy (there are already three: seo.js:309, site-builder/seo.js:46, site-builder/seo.js:49).
  2. Escape the X-STX-Title header value at dev-server/serve-app.js:440 and decode it in the router before assigning document.title, so escaping is safe on both paths and applications stop having to choose between correct-on-load and correct-on-arrival.
  3. Ship this as a security patch, not a minor.

5. const meta = { title } in a server script is a silent no-op on fragment pages — SHOULD fix

Mechanism. Ordering. injectSeoTags runs inside processDirectivesInternal:

// node_modules/@stacksjs/stx/dist/process.js:616-617
if (opts.seo?.enabled)
  output = injectSeoTags(output, context, opts);

The document shell is created after it returns, at process.js:224. And injectSeoTags requires a head to already exist:

// node_modules/@stacksjs/stx/dist/seo.js:253-260
if (!html.includes("<head>") && !html.includes("<head "))
  return html;
const hasTitle = html.includes("<title>") || html.includes("</title>");
let title = "";
if (context.title) title = context.title;
else if (context.meta && context.meta.title) title = context.meta.title;

So on a fragment page — the whole point of the layouts/SPA architecture — the only code that reads context.meta returns two lines before reading it. The meta const is evaluated, stored on the context, and discarded.

Repro.

const frag = `<script server>
const meta = { title: 'Issues - bughq', description: 'x' }
</script>
<main>hi</main>`
<title>stx App</title>          <!-- fragment page: meta ignored -->

versus the same script on a page that owns a <head>:

<!-- stx SEO Tags -->
<meta name="title" content="Issues - bughq">
<meta property="og:title" content="Issues - bughq">

Identical source, opposite outcomes, no diagnostic either way.

Damage. Four bughq pages carried a dead meta const — resources/views/dashboard.stx, settings.stx, projects/index.stx, account.stx — and production served <title>stx App</title> on all eleven app and auth pages while the og:title beside it was correct. Fixed in f03f9b6 by replacing each with useHead(), which writes context.__stx_runtime_head and is therefore read by the shell at process.js:206. The precedence chain had to be reverse-engineered from process.js and is now written down in config/ui.ts:49-63 because it is documented nowhere else.

Proposed fix.

  1. Move the injectSeoTags call after ensureDocumentShell, so the same source produces the same head regardless of whether the page is a fragment.
  2. Failing that: at seo.js:253, before returning, check whether the context actually supplied SEO data and warn — [stx] {file}: context.meta/title ignored — no <head> at SEO-injection time. Use useHead() on fragment pages.
  3. Better: deprecate the context.meta channel entirely in favour of useHead(). Two mechanisms that look equivalent and differ by page shape is the defect.

6. site.config.ts title can never set a document title — SHOULD fix

Mechanism. injectSeo builds a <title> tag, then filters it out because one is always already present:

// node_modules/@stacksjs/stx/dist/site-builder/seo.js:21-32
const headEnd = html.search(/<\/head>/i), head = headEnd === -1 ? html : html.slice(0, headEnd), declared = (tag) => {
  if (/^<title[\s>]/i.test(tag))
    return /<title[\s>]/i.test(head);
  ...
}, missing = tags.filter((tag) => !declared(tag));
if (missing.length === 0)
  return html;

By the time injectSeo runs (site-builder/build.js:57), the shell has unconditionally written a <title> at document-shell.js:39 — the framework's own "stx App" default if nothing else supplied one. declared() cannot distinguish a deliberate page title from the framework's placeholder, so it always suppresses the site title.

site-builder/seo.js:20 already special-cases and strips the framework's other placeholders by exact string (content="stx Project", content="A website built with stx…"). <title> is the one it misses.

Repro. Set seo.title in site.config.ts, render any page that does not declare its own title. Expected: the site title. Actual: <title>stx App</title>, alongside a correct <meta property="og:title"> generated from the same value on the line above.

Damage. bughq shipped exactly that: the browser tab read "stx App" while the Open Graph card read "bughq — Error tracking for people who ship". site.config.ts still carries eleven pages.*.title entries (site.config.ts:28-40+) that feed only og:title/twitter:title, and lines 4-11 of that file exist solely to tell the next reader that the title keys do not do what they say:

// It does NOT set <title>, despite the `title` keys below — injectSeo skips any
// tag the head already declares, and by the time it runs every page has one.

Proposed fix.

  1. Mark the shell's fallback title so it is distinguishable: emit <title data-stx-default> at document-shell.js:39 when options.title was not supplied. Then declared() at site-builder/seo.js:22-23 tests /<title(?![^>]*data-stx-default)[\s>]/i and the site title wins over the placeholder but still loses to a real page title.
  2. Alternatively, have injectSeo replace the title rather than skip it when the existing one equals the framework default — consistent with the placeholder stripping it already does at site-builder/seo.js:20.
  3. Either way, document the precedence in config-types.d.ts next to SeoConfig. Two config surfaces (site.config.ts and app.head.title) that both accept title and only one of which works is the underlying problem.

Mechanism. The fragment response is the only consumer of isSpaNavigation, and it is behind shell &&:

// node_modules/@stacksjs/stx/dist/dev-server/serve-app.js:427
if (shell && isSpaNavigation(request)) {
  const layoutMetadata = extractLayoutMetadata(content), ...
  return new Response(content, { headers: { "X-STX-Fragment": "true", ... } });
}

grep -rn isSpaNavigation across the dist returns exactly three lines: the definition (app-shell.js:160), the import (serve-app.js:4), and that one call site.

shell is non-null only when app.stx exists:

// node_modules/@stacksjs/stx/dist/app-shell.js:26-29
const defaultShellPath = path.join(appDir, "app.stx");
if (await Bun.file(defaultShellPath).exists()) return defaultShellPath;
return null;

And when it does exist, the server prints:

// node_modules/@stacksjs/stx/dist/dev-server/serve-app.js:103
console.log(`... Using app shell: ${...} (deprecated: use layouts/default.stx instead)`);

The deprecation notice points at the convention that turns the feature off. Following it costs you fragment navigation and prints nothing.

Repro. An app with layouts/default.stx and no app.stx. The router sends X-STX-Router: true (stx-router/dist/client.js:342), the server ignores it, returns the full document, and the client falls through to isStxDocument (client.js:392). If the page happens to carry <meta name="stx-layout"> it does a full-document swap; if not, a hard navigation. Either way the fragment fast path is dead and X-STX-Fragment is never sent.

Damage. bughq has no app.stx anywhere (find resources -name app.stx → empty) and uses resources/views/layouts/default.stx, whose header comment (lines 1-21) documents the fragment contract in detail — one <main>, runtime mounts inside it, data-no-router on boundary links. Every navigation pays full-document transfer and parse anyway. Nothing in the server output says so; the only way to find out is to curl -H 'X-STX-Router: true' and notice the missing response header.

Proposed fix.

  1. Decouple the fragment path from shell. The requirement is a router container, not a shell file — projectConfig?.router?.container is already read three lines later at serve-app.js:432 and extractContainerContent (app-shell.js:163) works on any full document. Change the gate to if (isSpaNavigation(request)).
  2. Invert the notice: when a request arrives with X-STX-Router: true and no fragment path is available, warn once — [stx] SPA navigation requested but no fragment path is configured; serving the full document. Set router.container or add app.stx.
  3. Remove the deprecation text from serve-app.js:103 until the replacement is at parity.

8. Directory options are double-prefixed by root resolution and resolve to non-existent paths — MUST fix

Mechanism. root is inferred by sniffing the filesystem, then prepended to three of the directory options with no idempotence check:

// node_modules/@stacksjs/stx/dist/config.js:363-374
function resolveStxRoot(configRoot, configPagesDir) {
  const defaultPagesDir = configPagesDir || "pages";
  if (configRoot) return { root: configRoot, pagesDir: defaultPagesDir };
  const resourcesViews = path.join(process.cwd(), "resources", "views"),
        resourcesLayouts = path.join(process.cwd(), "resources", "layouts");
  if (fs.existsSync(resourcesViews) && fs.existsSync(resourcesLayouts))
    return { root: "resources", pagesDir: "views" };
  ...
}
// :411-418
if (loaded.root && loaded.root !== ".") {
  const rootPrefix = loaded.root;
  if (loaded.partialsDir && !path.isAbsolute(loaded.partialsDir))
    loaded.partialsDir = path.join(rootPrefix, loaded.partialsDir);
  if (loaded.componentsDir && !path.isAbsolute(loaded.componentsDir))
    loaded.componentsDir = path.join(rootPrefix, loaded.componentsDir);
  if (loaded.layoutsDir && !path.isAbsolute(loaded.layoutsDir))
    loaded.layoutsDir = path.join(rootPrefix, loaded.layoutsDir);
}

Three compounding problems:

  • Not idempotent. A config that writes componentsDir: 'resources/components' — the value every scaffold and every doc example uses, and the one that is correct when root is . — becomes resources/resources/components.
  • Root is sniffed, not declared. An empty, untracked resources/layouts/ directory is enough to flip root from . to resources and silently change the meaning of every relative path in the config.
  • Uses process.cwd(), not the cwd argument. loadStxConfig(cwd) honours cwd for config discovery (config.js:397) but resolveStxRoot hardcodes process.cwd() at config.js:367/:370, so the two disagree whenever the argument is passed.

storesDir, templatesDir, publicDir, outputDir and pagesDir are not prefixed at all — so half the directory surface is root-relative and half is cwd-relative, with nothing in config-types.d.ts:255-280 saying which is which.

Nothing anywhere checks that the resulting directories exist. stx doctor (doctor.js) does not look at them.

Repro. In this repository, unmodified:

import { loadStxConfig } from '@stacksjs/stx'
const c = await loadStxConfig()
{ "root": "resources", "pagesDir": "views",
  "componentsDir": "resources/resources/components",
  "layoutsDir":    "resources/resources/layouts",
  "partialsDir":   "resources/resources/partials" }
resources/resources/components exists=false
resources/resources/layouts    exists=false
resources/resources/partials   exists=false

The config that produced this (config/ui.ts:10-16) says componentsDir: 'resources/components', and resources/components/ exists with twelve files in it.

Damage. This is invisible because a hardcoded fallback list happens to cover the mistake. Component resolution tries six directories before giving up:

// node_modules/@stacksjs/stx/dist/utils.js:326-339
if (originalFilePath) push(path.join(path.dirname(originalFilePath), "components"));
push(componentsDir);
push(path.join(path.dirname(parentFilePath), "components"));
push(options.componentsDir);
...
if (configuredRoot) {
  push(path.resolve(configuredRoot, "resources/views/components"));
  push(path.resolve(configuredRoot, "resources/components"));
  push(path.resolve(configuredRoot, "src/components"));
  push(path.resolve(configuredRoot, "components"));
}

<AutofixPanel /> at resources/views/issue/[id].stx:564 resolves through the last entry — path.resolve('<cwd>/resources', 'components') — not through componentsDir, which points at a directory that has never existed. Everything works, the config is meaningless, and the first person to rely on componentsDir for anything the fallback list does not cover gets an unexplainable miss.

Proposed fix.

  1. Make prefixing idempotent. Skip when the value already begins with root + path.sep. One line, fixes every existing config.
  2. Apply it to all directory options or none. Prefixing three of eight is worse than prefixing zero.
  3. Stop sniffing. Default root to . and require it to be declared. If sniffing must stay, log the inferred value and what it was inferred from: [stx] root inferred as "resources" (resources/views + resources/layouts present).
  4. Honour the cwd argument in resolveStxRoot — pass it through from config.js:408 instead of reading process.cwd().
  5. Validate on load. After resolution, fs.existsSync each configured directory and warn per miss: [stx] componentsDir "resources/resources/components" does not exist — components will fall back to heuristic search. Add the same check to stx doctor.

9. :attr bindings are unresolved during SSR and not cloaked — SHOULD fix

Mechanism. :attr is handled exclusively by the client runtime:

// node_modules/@stacksjs/stx/dist/signals.js:1784-1801
if (name.startsWith('@bind:') || name.startsWith('x-bind:')
    || (name.startsWith(':') && !name.startsWith('::') && ...)) {
  let attrName = ... name.slice(1);
  effect(() => { const v = evalAttrExpr(value); ... el.setAttribute(attrName, v); });
  el.removeAttribute(name);
}

No server pass evaluates it. Meanwhile SSR does protect the two neighbouring cases:

// node_modules/@stacksjs/stx/dist/process.js:604-605
output = addCloakToUnresolvedExpressions(output);   //  in text          → misc-directives.js:141
output = addCloakToConditionalDirectives(output);   // :if/:show/x-if/x-show  → misc-directives.js:178-179

CONDITIONAL_DIRECTIVE_RE at misc-directives.js:178 lists :else-if|:if|:else|:show|x-else-if|x-if|x-else|x-show|@if|@show. :class and :style are not in it. So an unresolved text expression is hidden, an unresolved x-show is hidden, and an unresolved :class ships as a classless element that paints immediately.

Repro.

<script>
const loading = state(false)
function loadingClass() { return loading() ? 'grid gap-3' : 'autofix-hidden' }
</script>
<div :class="loadingClass()" aria-busy="true">skeleton</div>
<div x-show="loading()">also hidden</div>

SSR output:

<div :class="loadingClass()" aria-busy="true" data-stx="__stx_setup_...">skeleton</div>
<div x-show="loading()" x-cloak>also hidden</div>

The x-show element is cloaked. The :class element has no class attribute at all.

Damage. resources/components/AutofixPanel.stx drives eleven mutually-exclusive UI states through :class helpers that return 'autofix-hidden' for the inactive branch (AutofixPanel.stx:89-99, used at lines 309, 315, 317, 337, 347, 390, 396…). On first paint every branch is visible simultaneously — loading skeleton, "Autofix is disabled", the repository setup form, the ready state, the workflow, the retry panel and the error alert, stacked — until hydration runs. The only defence available is a hand-written static duplicate, which the codebase does in some places and not others:

AutofixPanel.stx:315   <div :class="disabledClass()" class="text-muted">…    ← static fallback
AutofixPanel.stx:309   <div :class="loadingClass()" aria-busy="true">…       ← none

That is a per-element manual obligation with no check and no failure mode other than a visual one.

Proposed fix.

  1. Evaluate server-side when possible. :attr expressions that resolve purely from server context should be rendered as static attributes at SSR time, exactly as already is at expressions.js:380-399. processTemplateBindings (process.js:606) is the place.
  2. Cloak when not. Extend CONDITIONAL_DIRECTIVE_RE at misc-directives.js:178 to include :class, :style, x-class, x-style so an unresolvable binding hides the element instead of showing it wrong. Hidden-then-shown is a flash; wrong-then-right is a bug report.
  3. Document the contract on :attr in config-types.d.ts: which bindings are SSR-resolved, which are client-only, and what the SSR fallback is. Right now :class and x-show look symmetric in a template and behave completely differently in a document.
9 days ago

Part two — ergonomics and syntax proposals

Part one described what bughq must do. Part two argues that most of what bughq did wrong, it was invited to do. The audit contains 60 findings. 22 of them — 21, 22, 29, 30, 31, 34, 36, 39, 40, 42, 44, 45, 46, 47, 48, 49, 53, 54, 0, 15, 19, 20 — are fixed by editing one 79-line file, config/ui.ts, which today sets five keys. A framework where a third of a codebase's defects are one file's missing keys has a config-surface problem, not a user problem.

The proposals below are ordered by how many findings they retire. Each is marked MUST / SHOULD / MAY and breaking / non-breaking.

The affordance ledger

Finding clusterCountFramework affordance that permitted it
34, 36, 42, 44, 45, 57 — directory keys resolve to resources/resources/*6Two different root-prefixing implementations (config.js:411-418 vs store-loader.js:11), plus root inferred from directory existence (config.js:367-369)
0, 1–11 — 35 shipped UI components re-implemented by hand12_pluginComponentDirs only fills when plugins is non-empty (config.js:420); no auto-registration
21, 26, 47 — 68 prohibited DOM calls ship silently3strict ?? { enabled: false } (script-validation.js:129) makes the validator a no-op, including the warning
12, 29, 38, 43 — 24 DOCTYPE pages, zero layouts, useSeoMeta unreachable4if (!hasDoctype || hasSections) (process.js:311-312) silently disables layout resolution; process.js:226 passes baseHeadConfig where headConfig belongs
22, 24, 25 — zero stores, zero onMount/onDestroy, a runaway poll3storesDir has no default in defaultConfig (config.js:16-27); the scaffolder writes stores to a directory the loader never scans
30, 31, 32, 35 — 263 plain anchors, interceptAllLinks: true, 15 data-no-router4Three packages ship three different defaults for interceptAllLinks
51 — a hand-rolled base64 server→client bridge1The unsafe path () has syntax; the safe path (generateServerDataBridge) has none

P1. Resolve every directory key in exactly one function — MUST / non-breaking now, breaking at 0.3

Why. loadStxConfig prefixes partialsDir, componentsDir and layoutsDir with root at config.js:413-418 — but not storesDir, which is prefixed later and elsewhere by store-loader.js:11 (path.resolve(config.root || process.cwd(), config.storesDir || 'stores')). root itself is not declared by the user; it is sniffed from the filesystem at config.js:363-374, where the mere existence of resources/layouts — empty or not — flips root from '.' to 'resources' and pagesDir from 'pages' to 'views'.

Today. bughq wrote the obvious thing:

// config/ui.ts:10,13,16
componentsDir: 'resources/components',
layoutsDir: 'resources/layouts',
partialsDir: 'resources/partials',

loadStxConfig() returns resources/resources/components, resources/resources/layouts, resources/resources/partials — three paths that do not exist. Nothing errors. includes.js:245-247 has no fallback, so dist/layouts/marketing.html ships an include error: string containing absolute filesystem paths and raw ANSI escapes as page content (finding 36). The only reason the dev server works is that @stacksjs/actions/dist/dev/views.js:59-73 and @stacksjs/buddy/dist/production-server.js:114-125 override all three, and bun-plugin-stx/dist/serve.js:8935-8937 gives passed options priority.

Proposed API. One dirs block, root-relative, resolved once:

// config/ui.ts
import { defineStxConfig } from '@stacksjs/stx'   // config.js:493 — already exported, correctly typed at config.d.ts:17

export default defineStxConfig({
  root: 'resources',          // WARN when omitted in 0.2.x, ERROR in 0.3
  dirs: {
    pages:       'views',
    layouts:     'views/layouts',
    partials:    'partials',
    components:  'components',
    stores:      'stores',
    composables: 'composables',   // new — see P5
  },
})

Contract, enforced by one function:

export function resolveDirs(cfg: StxConfig, cwd: string): ResolvedDirs  // every value absolute
// loadStxConfig() returns cfg & { _dirs: ResolvedDirs, _diagnostics: DoctorCheck[] }

Three hard rules: (a) no consumer may re-resolve a _dirs value — store-loader.js:11, process.js:329, utils.js:395-413 all read _dirs; (b) a declared directory that does not exist produces a _diagnostics error, printed once at boot, not silence; (c) root is never inferred from the presence of a directory. Keep the four flat keys for one minor with a deprecation warning that prints the dirs equivalent.

Also delete the two dead cross-package options. @stacksjs/actions/dist/dev/views.js:74-75 and @stacksjs/buddy/dist/production-server.js:124-125 both pass fallbackLayoutsDir and fallbackPartialsDir. grep -c fallbackLayoutsDir node_modules/bun-plugin-stx/dist/serve.js returns 0, and ServeOptions (serve.d.ts:99-105) declares only componentsDir, layoutsDir, partialsDir, publicDir. Two first-party packages have been passing options nothing reads.

Check.

bun -e "const {loadStxConfig}=await import('@stacksjs/stx/dist/config.js');
const c=await loadStxConfig(); const fs=require('fs');
for (const [k,v] of Object.entries(c._dirs ?? {})) if(!fs.existsSync(v)) console.error('MISSING',k,v)"

This must exit clean. Today, run against the four flat keys, it prints three misses.


Why. Four packages disagree about the router, and two of the disagreements are silent.

Key@stacksjs/stx defaultConfigstx-router clientbun-plugin-stx servestx cli/ssg
router.container'#app-content' (config.js:106)'main' (client.js:9)'main' (serve.js:9765)'main' (cli.js:16558)
router.interceptAllLinksunset (config.js:104-119)false (client.js:9)true (serve.js:9764)true (cli.js:16558)

runtime-injection.js:86-113 emits window.__stxRouterConfig only for keys the user explicitly set; if router is absent it emits nothing at all, so the SSG/compile path silently lands on false while the dev/serve path lands on true. Same project, same source, opposite navigation behaviour depending on which binary rendered it.

Today. bughq pinned the value and wrote 17 lines of comment explaining why (config/ui.ts:31-34: "Pinned here rather than inherited because the defaults disagree"). A project should not have to audit three packages' dist files to know whether clicking a link reloads the page.

Proposal.

  1. stx-router/dist/client.js:9 is the single source of truth for every router default. Delete the interceptAllLinks: true, container: 'main' base object at serve.js:9762-9768 and the identical one at cli.js:16558; both become ...siteConfig?.router, ...stxConfig.router only.
  2. Fix config.js:106 to container: 'main' — '#app-content' matches no bughq page and no scaffolded layout (scaffolding.js:622 emits <main class="container">).
  3. Change the router default to interceptAllLinks: true, and make it harmless: today interception and affordances are coupled, so an intercepted plain <a> gets SPA navigation but no hover prefetch (client.js:830-833 requires [data-stx-link]) and no active class (client.js:858, :867-880). Decouple them — when interceptAllLinks is on, treat every intercepted same-origin anchor as a link for prefetch and active-state purposes too. That removes the reason finding 30 frames StxLink and interceptAllLinks as a trade-off.
  4. Delete the five router.* keys that are declared in config-types.d.ts:316-333 and read by nothing: enabled, linkSelector, skipSelectors, viewTransitionCSS, cacheTTL. grep -c linkSelector returns 0 in both stx-router/dist/client.js and runtime-injection.js; enabled appears once, inside a comment at client.js:887. A type that advertises sixteen options and honours eleven is worse than a type with eleven.

Check.

grep -rn "interceptAllLinks" node_modules/stx-router/dist/client.js \
  node_modules/bun-plugin-stx/dist/serve.js node_modules/@stacksjs/stx/dist/{cli,ssg,pwa}.js

Exactly one literal assignment should survive, in stx-router.


P3. Strict mode defaults to warn; new projects get error — MUST / non-breaking (warn) + breaking (rule API)

Why. validateClientScript(content, filePath, options.strict) runs on every client script (process.js:704). With strict unset it becomes { enabled: false } at script-validation.js:129, and the guard at :148 — if (errors.length > 0 && strictConfig.enabled) — suppresses even the console.warn. The rule table at script-validation.js:1-127 already carries the exact replacement for each violation (document.cookie → useCookie(), setTimeout → useTimeout(), location.assign → navigate()). All of that work exists and fires for nobody.

Today. 68 prohibited calls across 10 .stx files in a project whose own CLAUDE.md says "never write document.*/addEventListener in stx" (finding 47). Turning the key on is a one-line change that would have produced the whole work list on day one.

And the table has holes. Enabling strict today still sees none of this:

PatternIn PROHIBITED_DOM_PATTERNS?bughq hits
bare localStorage.*no — only /window\.localStorage/ (script-validation.js:63)47
fetch(no — despite useQuery/useFetch being auto-imported (client-script.js:161-162)23
document.documentElementno19
document.bodyno1

That is 90 further violations, invisible even at failOnViolation: true. The bare-localStorage gap is the one that matters most: it is exactly the theme-boot script duplicated byte-for-byte into 11 files (finding 54), and useColorMode (signals.js:3429, global at :4658) replaces it outright.

Proposal.

type StrictLevel = 'off' | 'warn' | 'error'

strict?: StrictLevel | {
  level?: StrictLevel                                   // default 'warn'
  rules?: Partial<Record<StxRuleId, StrictLevel>>       // per-rule, not substring
}
  1. defaultConfig.strict = 'warn' (config.js:16-27). Non-breaking: warnings only, and they name the file, the line, and the replacement composable.
  2. stx new writes strict: 'error'.
  3. Give every pattern an id (stx/no-document-cookie, stx/no-raw-fetch, stx/no-bare-localstorage, stx/no-location-replace) and delete allowPatterns. Its current semantics at script-validation.js:131 — allowPatterns.some(a => message.includes(a) || pattern.source.includes(a)) — is a substring match against both the human message and the regex source, so allowPatterns: ['location'] silently disables four rules and allowPatterns: ['('] disables almost all of them. Replace with per-rule levels plus a line-scoped escape hatch the reviewer can see:
    // stx-allow stx/no-location-replace -- pre-paint auth guard, must not be a soft nav
    location.replace('/login')
  4. Add the four missing patterns above.
  5. Wire the linter that already exists. strict-lint.d.ts ships lintStxStrict(source, { filePath, fileKind }) with five detection rules for footguns the DOM table cannot catch (stx/no-bare-function-ref-in-event, stx/no-view-level-script-client, stx/store-value-imports-must-be-local, …), and its own doc comment says "wiring it into pickier / the CLI / the editor is a thin follow-up". It has zero call sites outside its own module. Route it through the same strict switch, and add the stx lint command — grep -o 'command("[a-z:]*"' node_modules/@stacksjs/stx/dist/cli.js lists 23 commands and lint is not among them.

Check. Until this lands, every project can run the framework's own validator by hand:

bun -e "const {validateClientScript}=await import('@stacksjs/stx/dist/script-validation.js');
for await (const f of new Bun.Glob('resources/**/*.stx').scan()) {
  const src = await Bun.file(f).text();
  for (const m of src.matchAll(/<script(?![^>]*\bserver\b)[^>]*>([\s\S]*?)<\/script>/g))
    validateClientScript(m[1], f, { enabled: true, failOnViolation: false });
}"

That command is how finding 47 was produced. It should be stx lint.


P4. Auto-register @stacksjs/components — MUST / non-breaking

Why. config.js:420 gates the entire plugin-component-directory mechanism on if (loaded.plugins && loaded.plugins.length > 0). With no plugins key, _pluginComponentDirs is undefined, and the lookup chain at utils.js:395-413 — which already probes resources/views/components, resources/components, src/components and components as last-resort fallbacks — never learns about the 45 component directories under node_modules/@stacksjs/components/src/ui.

Today. Finding 0 calls itself "the root cause of every duplication below", and findings 1 through 11 are that list: a hand-rolled confirm modal in settings.stx two hundred lines after the same file calls the built-in stxConfirm; a <div role="switch"> with a click handler that is not focusable and not keyboard-operable, against a shipped Switch; five separate .badge/.pill CSS systems; twelve files each defining .btn/.btn-primary/.btn-danger. The package ships a shim built for exactly this — @stacksjs/components/stx-plugin.ts:16-19 is four lines — and its own doc block tells you to paste one line into config/ui.ts. Nobody did, and nothing said anything.

Proposal.

components?: {
  library?: boolean | string[]   // default: true when @stacksjs/components resolves
  validateProps?: boolean        // existing, component-types.d.ts:38
}

At config.js:420, before the plugins loop: if @stacksjs/components/stx-plugin resolves and components.library !== false, push its src/ui directory into _pluginComponentDirs. This is safe by construction — utils.js:392-398 pushes the page-local and user componentsDir entries first, so a project component named Button.stx still wins. Print the count at boot next to the existing [stx] Plugin loaded: line (config.js:463).

While in this file: serve.js:8932 calls stxMod.preloadIconCollection('lucide') unconditionally on every boot, and warns on every boot in any project that has not installed @iconify-json/lucide — which is every project that never asked for lucide (finding 50). Drive that from components.icons or from the collections actually referenced in <Icon name="…">, not from a hardcode.

Check. <Badge variant="danger">x</Badge> on any page renders a badge, and boot logs the library. Regression guard for a project: grep -rn 'class="badge\|class="btn\|class="pill' resources --include='*.stx' trends to zero.


P5. storesDir exists and is mis-scaffolded; composablesDir does not exist — MUST / non-breaking

Verified first, as asked. storesDir is in the config surface: config-types.d.ts:275, consumed by store-loader.js:5-12, injected into every rendered page at process.js:245. composablesDir does not exist — grep -n composablesDir across config.js and config-types.d.ts returns nothing.

Why storesDir fails anyway. Three separate defects:

  1. It is absent from defaultConfig (config.js:16-27) while its three sibling directory keys are present at :19-21, so it is invisible to anyone reading the defaults.
  2. It is not root-prefixed at config.js:411-418 like its siblings; store-loader.js:11 prefixes it independently. So storesDir: 'resources/stores' — the shape a user copies from the sibling keys — resolves to resources/resources/stores, the same trap as P1, via a different code path.
  3. The scaffolder and the loader disagree. scaffolding.js:100 creates src/stores, :151 writes src/stores/app.ts, and addStore defaults to src/stores (:196). store-loader.js:11 scans <root>/stores. getStxConfig (scaffolding.js:438-460) writes no storesDir key. stx's own default project template ships a store that is never loaded. getStoreScript returns null at store-loader.js:26-31 and process.js:246 injects nothing.

Today in bughq. Zero stores, no storesDir, and the consequence is finding 22: effectiveTheme() + toggleTheme() duplicated in 4 pages, an 11-way byte-identical pre-paint script, and — the part that is not just duplication — 13 marketing pages that include SiteNav and have no theme handling at all, because the fix was per-page copy-paste rather than one store.

Proposal.

dirs: {
  stores:      'stores',        // default; scaffolder must write here
  composables: 'composables',   // new
}

composablesDir gets the same treatment storesDir already has: store-loader.js's transpile-and-concat pipeline (store-loader.js:34-70) generalises unchanged — glob *.ts, skip index/types, strip imports and export, transpile for browser, concat, inject alongside the store bundle at process.js:245. That gives the Nuxt-shaped answer to requirement 2 for the third source kind, and gives requirement 11 something to auto-import from beyond the framework's own globals.

Add dirs.types as an emit target: generateRouteTypes already exists (exported from routes.js) and nothing writes its output anywhere a project can import type from. Emit <root>/<dirs.types>/routes.d.ts and <root>/<dirs.types>/stores.d.ts on boot. That is requirement 8 satisfied by the framework rather than by convention.

Check.

bun -e "const {getStoreScript}=await import('@stacksjs/stx/dist/store-loader.js');
console.log((await getStoreScript()) ? 'stores loaded' : 'NO STORES INJECTED')"

In a fresh stx new my-app, this prints NO STORES INJECTED today. It must print stores loaded.


P6. The server→client bridge: give the safe path syntax, make the unsafe path an error — MUST / additive non-breaking, enforcement breaking under strict

Why. There are two ways to move a value from <script server> to <script client>, and the dangerous one is the discoverable one.

Path A, discoverable: / inside the client script body. Same syntax as templates. Breaks the moment x contains a closing script tag.

Path B, correct, invisible: generateServerDataBridge (client-script.js:377-399), called unconditionally at client-script.js:731, seeded from extractBridgeData(context) at process.js:609. It emits var <name> = <json>; for every server-scope value the client script references but does not redeclare (client-script.js:384-386), and line 396 escapes < to \u003c — the exact protection Path A lacks. Path B has no syntax at all. You use a bare identifier and it works. There is nothing to grep for, nothing to read in a template, and no error when it silently doesn't fire.

Today. resources/views/settings.stx:130-143 is a 14-line comment explaining why base64 was necessary, followed by b64() and seven *B64 constants (:144-157), a fromB64 using atob + Uint8Array + TextDecoder (:181-185), and seven fromB64() call sites. The comment states the failure mode precisely — "stx re-serializes these values on its way into the client block and drops the escape" — and records that on any project with a javascript platform the install snippet truncated the client block, so every control defined after it (reveal, rotate, members, alerts, archive, delete) silently did not exist. That is a production defect caused by choosing the visible path.

Proposal.

<script server>
const guide = installGuide(project)     // contains a </script> in its payload
const keyFull = key.value
</script>

<script client props="guide, keyFull, projectId">
const install = state(guide.install)    // pre-escaped, typed, greppable
</script>

props is a declaration, not a transport: it narrows serverData to the named keys, errors at render when a name is not in server scope, and errors when a name is redeclared in the client body — today client-script.js:386 skips redeclared names silently, so the bridge quietly does nothing and the page reads undefined. For a typed variant, defineServerProps<{ guide: Guide, keyFull: string }>() emitting into dirs.types.

Then close Path A: under strict: 'error', or appearing inside a <script client> body is a build error naming the props form. Under 'warn', a warning.

Check.

grep -rn '{!!' resources --include='*.stx' | grep -v '@' 

Any hit inside a script body is a bridge bypass. bughq has eight.


P7. definePageMeta({ layout }) is documented, auto-imported, and a no-op — MUST / non-breaking

Why. This is the single largest gap between what stx claims and what it does, and it is exactly the Nuxt-shaped API the owner is asking for.

  • head.d.ts:215-225 declares PageMeta with layout?: string | false, middleware?: string | string[], transition, keepAlive, key, validate.
  • head.d.ts:52-61 documents it with the example definePageMeta({ title: 'Dashboard', layout: 'admin', middleware: ['auth', 'admin'], keepAlive: true }).
  • client-script.js:172 auto-imports it, so it needs no import.
  • head.js:92-99 forwards only title and description to useSeoMeta, and stashes the rest in a module-local currentPageMeta.
  • getPageMeta() (head.js:7-9) has zero consumers anywhere in the dist.
  • process.js:302-338, the entire layout-resolution block, reads @nolayout, @layout(...), @extends(...) and opts.defaultLayout. It never reads page meta.
  • On the client, signals.js:4185-4190 registers it explicitly as definePageMeta: function() {} — a no-op with a comment saying the real implementation lives on the server. It does not.

So a page can call the documented API, get no error, and get no layout.

Proposal. In process.js, immediately before the @layout|@extends match at :305, read getPageMeta():

const meta = getPageMeta()
if (meta.layout === false) hasNoLayout = true          // equivalent to @nolayout
else if (typeof meta.layout === 'string') layoutPath = meta.layout

That makes finding 38's fix a one-liner per page instead of an @extends + three @section blocks:

<script server>
definePageMeta({ layout: 'marketing' })
useSeoMeta({ title: 'bughq vs Sentry', description: '…', canonical: '…' })
</script>

<section class="hero">…</section>

Ship middleware and keepAlive in the same pass or delete them from PageMeta. A type that describes behaviour the runtime does not have is worse than no type.

Check. A page using definePageMeta({ layout: 'marketing' }) must render with the <!-- stx-layout: …/marketing.stx --> marker (process.js:447) and therefore the <meta name="stx-layout-group"> the router keys on (process.js:227-232). Today it renders without either.


P8. Delete the DOCTYPE gate; fix the head-config variable it hides — MUST / breaking (gate) + non-breaking (bug fix)

Why. Two lines cause four findings.

// process.js:311-312
const hasDoctype = /<!DOCTYPE\s/i.test(output), hasSections = /@section\s*\(/.test(output);
if (!hasDoctype || hasSections) {

A <!DOCTYPE> in a page is an implicit, unnamed, silent opt-out of the entire layout system. @nolayout — the explicit opt-out, implemented right above at process.js:302-304 — is used zero times in bughq (finding 43), because nobody needs it: the DOCTYPE already did it by accident. Worse, the gate is an ||, so adding a single @section( to any of the 24 DOCTYPE pages re-enables layout resolution and process.js:333-335 then wraps that page's entire document, DOCTYPE and all into @section('content') and injects it into the layout's @yield.

The second half is a plain bug. At process.js:223-226:

const alreadyShelled = hasDocumentShell(result)
result = ensureDocumentShell(result, headConfig)          // headConfig — the merged one
if (alreadyShelled) result = injectConfigHeadTags(result, baseHeadConfig)   // baseHeadConfig — config only

headConfig (built at :213-222 from app.head plus runtimeHead, i.e. every useHead/useSeoMeta call) is computed and then discarded for shelled pages, which get baseHeadConfig instead. And injectConfigHeadTags destructures only meta, link, script, headRaw (document-shell.js:67-71) — never title. That is why 23 marketing pages hand-write <title> and none of them can use useSeoMeta (finding 12), and why five public indexable routes ship with no description and no og tags (finding 17).

Proposal.

  1. Pass headConfig at process.js:226, and teach injectConfigHeadTags to replace or insert <title>. Non-breaking, ~5 lines, and it makes useSeoMeta work on every page shape immediately — before anyone touches a template.
  2. Make <!DOCTYPE inside dirs.pages a build error naming @nolayout and definePageMeta({ layout: false }). Breaking, and it should be: AppHeadConfig's own doc comment already asserts the invariant — "auto-generates the document shell. No .stx file needs to write <!DOCTYPE>, <html>, <head>, or <body>" (config-types.d.ts:234-236). Enforce the claim.
  3. Exclude non-page directories from route discovery everywhere, not just in one builder. production-builder.js:61 excludes ['components','layouts','partials']; SSGConfig (ssg.d.ts:24-43) has no such keys and no exclusion — which is why dist/layouts/marketing.html, dist/layouts/default.html and dist/components/FeatureCard.html exist and appear in dist/sitemap.xml (finding 37), one of them publishing absolute filesystem paths as page content. Route discovery must consume _dirs from P1 and skip every non-pages directory unconditionally.

Check.

grep -rln '<!DOCTYPE' resources/views --include='*.stx'    # must be empty
grep -c '<loc>' dist/sitemap.xml                            # must equal the page count, not page count + 3

P9. stx new must not generate bughq's starting state — MUST / non-breaking

Why. createProject and PROJECT_TEMPLATES are exported from scaffolding.js:30 and :24, and there is no CLI command that calls them. grep -o 'command("[a-z:]*"' node_modules/@stacksjs/stx/dist/cli.js lists 23 commands — docs, dev, build, init, doctor, deploy, story, … — and neither new nor create is among them. The scaffolder is reachable only by importing it.

That would be a small problem if the templates were good. They are not; they teach the audit's findings.

Scaffolder outputfile:lineFinding it reproduces
getStxConfig writes partialsDir/componentsDir/layoutsDir/defaultLayout, and nothing else — no strict, no plugins, no storesDir, no router, no app.headscaffolding.js:438-46021, 46, 47 (config sets only the keys the serve path overrides)
getMinimalPage emits <!DOCTYPE html> + <head> + <title> + inline <style> as a pagescaffolding.js:539-55612, 38, 43
stx init --template app does the same, with <title> - stx App</title>init.js:279-30015, 19
getHeaderComponent uses plain <a href="/"> / <a href="/about">scaffolding.js:634-63730 (263 plain anchors)
Every scaffolded component ships <style scoped> with hand-written CSSscaffolding.js:642-648, :251-269Owner requirement 5
Stores written to src/stores, loaded from <root>/storesscaffolding.js:100,151,196 vs store-loader.js:1122 (zero stores)
import { defineConfig } from 'stx' — but stx's defineConfig is typed ProductionBuildConfig (production-build.d.ts:12); the correct helper is defineStxConfig (config.d.ts:17)scaffolding.js:439— (the generated config does not type-check against the config it configures)

Proposal. Add the stx new <name> command, and make the default template emit exactly this:

config/ui.ts          root + dirs + strict:'error' + components.library + router (P1–P4)
resources/
  layouts/default.stx   the only file in the project containing <!DOCTYPE>, with <main> and @stack('styles')/@stack('scripts')
  views/index.stx       <script server> definePageMeta + useSeoMeta </script>, no doctype, no <head>
  partials/SiteNav.stx  <StxLink to="/" activeClass="…"> — never a bare <a>
  components/           empty, with a README pointing at @stacksjs/components
  stores/theme.ts       useColorMode({ attribute: 'data-theme' }) — one store, wired
  composables/          empty
  types/                .gitkeep; routes.d.ts and stores.d.ts emitted on boot
crosswind.config.ts     so the first styling instinct is a utility class, not a <style> block

Three invariants the template must satisfy, and a stx new acceptance test must assert:

  1. grep -rl '<!DOCTYPE' resources/views is empty.
  2. grep -rc '<a href' resources is 0.
  3. getStoreScript() returns non-null.

Any scaffold that fails these is shipping the bughq starting state.


P10. stx doctor checks the config; validateConfig is dead and must be replaced — SHOULD / non-breaking

Why. validateConfig (config.js:496) has zero call sites — grep -rn 'validateConfig(' node_modules/@stacksjs/stx/dist/*.js node_modules/bun-plugin-stx/dist/*.js returns nothing outside its own definition. And what it checks is typeof config.partialsDir !== 'string', typeof config.cache !== 'boolean' — assertions TypeScript already makes at the satisfies site. It validates the one class of error that cannot happen and none of the errors bughq actually made.

runDoctor (doctor.js) checks module resolution and pantry staleness (doctor.js:40-108): where @stacksjs/stx resolves, whether dist is newer than src, whether the signals runtime generates. Useful, and orthogonal to every finding in the audit.

Note the shape of bughq's config: import type { StxOptions } from '@stacksjs/stx' + satisfies UiOptions, where StxOptions = Partial<StxConfig> (config-types.d.ts:392). Every key is optional, so satisfies catches a typo and nothing else. Every bughq config defect was a valid type with a wrong value.

Proposal. Delete validateConfig. Add diagnoseConfig(resolved): DoctorCheck[], run inside loadStxConfig (printing only errors, once) and surfaced in full by stx doctor:

CheckError conditionFinding
dirs.* existany resolved path missing34, 36, 44, 45, 57
dirs.layouts non-emptycontains zero .stx34, 42
dirs.pages has no <!DOCTYPE>any page does12, 38, 43
layouts/components/partials outside dirs.pagesoverlap37
component library registered@stacksjs/components resolvable but _pluginComponentDirs empty0
strict setabsent → warn with the violation count21, 47
app.head.title setabsent → warn 'stx App'/'stx Project' will ship15, 19
stores discoverabledirs.stores exists but getStoreScript() returns null22
router single-sourced__stxRouterConfig differs between the serve and build renderers29, 30
icon collectionsa preloaded collection is not installed50

Every one of those is mechanically checkable from _dirs plus a glob. Run it in stx new too, so a fresh project is green on day zero and any drift shows up as red.


P11. Ceremony reductions — SHOULD

Three changes, each grounded in a specific duplication count.

(a) Hoist orphan <style>/<script> into stacks, not into content. Non-breaking-ish (changes placement only when a layout applies).

process.js:372-385 already strips every top-level <script> and <style> from a page that resolved a layout and appends them to sections.content. So the machinery exists — it just puts them in the wrong place. Route <style> into the styles stack and <script> into the scripts stack instead. resources/views/layouts/marketing.stx:13 and layouts/default.stx:44 both already declare @stack('scripts'), and grep -rn '@push|@prepend' resources returns zero — two declared stacks collecting from nothing (finding 54). With this change they collect automatically, and a page's <style> lands in <head> instead of mid-body without the author writing @push('styles').

(b) One classification for a bare <script>. Breaking; deprecate first.

script-classifier.js:2-12 classifies an attribute-less <script> with no signals call as server. bun-plugin-stx's onLoad classifies the same tag as client (if (/\bserver\b/i.test(attrs)) …; else clientScripts.push(tag)). process.js:697-700 scans with skipAttrs: /\bserver\b|…/, so it also treats it as client and runs validateClientScript + processClientScript on it. Two answers in one package. bughq has 26 <script server>, 12 <script client>, and 22 bare <script> whose behaviour depends on which code path renders the file. Pick client (it is what two of three paths already do and what the browser assumes), and warn on a bare <script> for one minor before enforcing.

(c) Compile internal <a href> to the StxLink builtin. Non-breaking.

builtins/index.js:24 registers StxLinkBuiltin; it needs no import; it renders <a … data-stx-link data-stx-active-class="…">. The router's prefetch (client.js:830-833), updateNav (client.js:853-864) and updateActiveLinks (client.js:865-880) all key on [data-stx-link] exclusively. bughq has 263 internal anchors and 29 StxLink uses, and its marketing nav has no current-page state at all because public/marketing.css has no aria-current or .active rule to hang one on. The tag distinction buys nothing a compiler cannot infer: when interceptAllLinks is on, rewrite same-origin <a href="/…"> (excluding target, download, data-no-router) into the builtin at render time. StxLink then remains the explicit form for activeClass/prefetch overrides rather than the price of admission for basic SPA behaviour.

Check. (a) grep -rn '@stack(' resources and confirm the stacks receive content; (b) grep -rhoE '<script[^>]*>' resources --include='*.stx' | sort | uniq -c — the bare <script> count must go to zero; (c) grep -rc 'data-stx-link' dist/*.html must be non-zero on pages whose source contains only plain anchors.


What Part Three depends on

P1, P4 and P7 are prerequisites for the migration plan: the directory keys must resolve before layouts can be moved, the component library must register before the twelve hand-rolled button systems can be deleted, and definePageMeta({ layout }) must work before 23 marketing pages can drop their DOCTYPE without gaining an @extends + three @section blocks each. P3 and P10 are prerequisites for keeping the result: without a strict default and a config doctor, the next 60 findings accumulate exactly as silently as these did.

9 days ago

Went through all 12 proposals against HEAD before cutting a release. Five landed, including one you didn't file that was worse than the one you did.

Landed

Item 4 — the XSS. Confirmed real at HEAD, not stale. A title of Hi</title><script>alert(document.cookie)</script> was emitted verbatim, breaking out of the element.

Blast radius was wider than filed: every meta, link and script attribute in both emission paths (a fix covering only generateDocumentShell would miss every page that brings its own <head>), plus bodyClass/bodyAttrs, plus the two dev-server frontmatter title sinks. headRaw and inline script content stay raw, with tests asserting it — they're documented escape hatches.

One wrinkle worth knowing: the first cut double-encoded titles from @head/@section('title'), which are already-escaped markup (Caf&eacute; &amp; Bar became Caf&amp;eacute; &amp;amp; Bar). Fixed with an explicit titleIsHtml, because a security fix that visibly mangles ordinary titles is a security fix that gets reverted.

Not filed, same class, arguably worse: interpolateScriptExpressions emitted JSON.stringify(value) raw into client scripts, so a server value containing markup closed the <script> and injected into the document. JSON quoting does nothing there — the parser ends the element at the first closing tag regardless of JS string context. The codebase already knew the rule; generateServerDataBridge does exactly this escape 200 lines away with a comment explaining why. This sibling was missed.

Item 2 — escape on emit. Your claim (b) is the crux and it's fully confirmed: Bun's transpiler normalises the escaped closing tag back to a raw one, so the documented workaround protects the source from stx's scanners and is then stripped before the browser sees it. Escaping at emit is the only place it survives. Your settings.stx case now round-trips with the declarations below the snippet intact.

I did not throw StxSyntaxError as proposed — a hard throw could brick a production build over a vendor bundle inlined via stx-inline, which is worse than the reported failure. It warns. I also left warnUnbalancedTags gated: it's a source-time check that structurally cannot see an emit-time corruption, and neither of your incidents had a literal closing tag in source.

Claim (a) is stale — html-masking.ts centralised the element scanner across ten call sites since you filed. What's left is a behaviour-changing migration of the remaining extraction sites; deferred deliberately.

Item 3 — shell detection anchored. Confirmed, and your framing was exactly right: app-shell.ts already anchored (#1787) and document-shell.ts was the outlier, 200 lines away with a doc comment justifying the asymmetry. Now shares the scanner. Two notes: the cascade is a FOUC window rather than permanent visibility (the runtime injects the cloak rule at boot), and the hard-reload symptom only bites with an external runtime — with the inlined runtime the sniff accidentally passes, because the runtime's own source text contains the literals it searches for.

Item 1 — auto-layout gate anchored. Also closes the opposite hole you didn't mention: the gate had no <html> arm, so a document that merely omitted its doctype was wrapped, splicing its own <html> into the layout's <main>. Anchored rather than deleted — layouts.test.ts pins the implicit opt-out and @nolayout is documented nowhere outside process.ts.

Item 8 / P1 — partial. Landed the diagnostic: a configured directory that doesn't exist now names its resolved path, which is what makes the doubling obvious. Verified silent across the 8,891-test suite.

Also fixed a bug underneath it that isn't in the issue: resolveStxRoot read process.cwd() instead of the cwd passed to loadStxConfig — whose own docstring says the parameter exists so stx <app-dir> from outside the app works. So root inference ran against the wrong tree entirely.

I did not un-prefix. That's a breaking change to how every consumer resolves paths and your dirs block is the right end state — but it wants its own change with a migration, not a release week.

Corrections to the issue

  • Item 1 is not comment-blind. Comments are masked before that gate, so only item 3 has that property.
  • Item 7's headline is refuted. A no-app.stx app does hydrate — verified in a real browser via CDP, every binding correct after an SPA navigation. What's real is a 34.5x payload regression per navigation (164KB vs 4.7KB), plus the startup message telling you to delete the file that enables the fast path. Worth fixing; not what you described.
  • Item 9 has a much worse sub-case than filed. Not just "needs a static fallback": on a page whose data comes only from <script server>, :if="serverName" deletes the element and :for="r in rows" renders nothing — permanently, no console error, no build warning.

Needs your call

Four items are breaking or change a default, so I've deliberately not touched them:

  1. P2 — which side of interceptAllLinks is canonical? Confirmed: bun-plugin serve hardcodes true, the router client defaults false, and SSG forwards nothing — so the same source navigates differently depending on which binary rendered it. (Also confirmed: five declared router.* keys are read by nothing.)
  2. P3 — should strict default to warn? Confirmed it's a no-op today, but it's documented opt-in, not an accident. Separately: the rule table misses bare localStorage, fetch(, document.body; allowPatterns is a substring match against the regex source too, so ['location'] silently disables four rules; and strict-lint.ts has five more rules and zero call sites.
  3. P1 full — the single dirs resolver.
  4. Item 9 — extend the compile-time diagnostic to template bindings? I shipped exactly this shape for server-only composables in 2e5630511d; same failure class, same silence. Consistent answer, but it's a new warning class.

Happy to take any of these on your word.

9 days ago

Second pass — items 5, 6, P2 and P3 landed. That's everything actionable in the RFC; closing with a note on the three I'm deliberately not doing.

Landed since the last update

Item 5 — definePageMeta({ title }). The cause is not the no-op stub the issue points at. The call is stripped from the script before execution, so the stub was never even reached.

I kept the stripping rather than executing the call — its object legitimately holds Nuxt-style entries (validate(){}, middleware: [...]) that mean nothing here, and evaluating arbitrary identifiers would throw where it previously didn't. Instead title and description are lifted out statically and re-emitted as the useSeoMeta call the sandbox already implements.

Item 6 — site.seo.title. Your mechanism analysis was exact. A placeholder title now counts as absent, and it replaces rather than appends — a second <title> achieves nothing, because browsers take the first, which is the placeholder being displaced. There's a test for the opposite direction too, since the failure mode of an over-eager fix here is overwriting a title someone actually wrote.

P2 — router defaults. container is now 'main' everywhere; '#app-content' matched no scaffolded layout and no real page, so a project inheriting it silently had no container and every navigation was a full load. And both the SSG and CLI dev-server paths called injectRouterScript() with no options, so they emitted no __stxRouterConfig at all — the mechanism behind your "same source, opposite behaviour" observation. Both now forward the resolved config.

P3 — allowPatterns. Fixed the fail-open. It matched against the regex source as well as the message, so ['('] disabled almost every rule.

Nobody writes ['('] deliberately, which is what makes it dangerous — a value like ['\.'] or ['\s'], which reads as escaping something in a filename, silently switched the whole validator off. It now matches the message only and rejects an entry with no word character, since punctuation names no rule and can only disable rules by accident.

I kept substring matching rather than going exact: exact would break allowPatterns: ['querySelector'] for no gain against the actual failure. Also broadened the localStorage rule to the bare form — the most-hit gap you identified, and the one useColorMode replaces outright.

Not doing, with reasons

  • strict defaulting to 'warn'. It's documented opt-in rather than an accident, and flipping it makes every existing project's build noisy on upgrade. That's a maintainer's call about the upgrade experience, not a bug fix. The rules are in much better shape for whenever you want to flip it — two false-positive rules removed in #1815, the localStorage gap closed here.
  • P1's full dirs block. The diagnostic landed (a directory that doesn't exist now names its resolved path); un-prefixing is a breaking change to how every consumer resolves paths and wants its own migration.
  • Item 7 (app.stx gating). Refuted as filed — a no-app.stx app does hydrate, verified in a real browser. What's real is a 34.5× per-navigation payload regression, which deserves its own issue with that as the subject.
  • Item 9 (:attr SSR). The sub-case underneath it is the serious one: a : binding over a <script server> variable silently deletes content. That's a distinct defect from "needs a static fallback" and shouldn't be closed as part of an RFC.

Happy to file 7 and 9 as standalone issues if you want them tracked — say the word.

Worth recording

Two security issues came out of verifying this RFC that weren't in it: the <title> XSS (item 4, confirmed live) and an unescaped JSON.stringify emitting server values into client scripts. Both fixed. The second one existed 200 lines from a sibling function doing exactly the right escape, with a comment explaining why — which is the strongest evidence for your central argument that these are affordance problems rather than user problems.

Sign in to comment on this issue.