Testing
View as MarkdownChecking an App
On this page
Introduction
marmeon check answers "am I done?" in one command: it runs the checks below, names each result and ends with an exit code a
script or an agent can act on. The tools (types, lint, internal-imports, raw-sql, inner-html, agents, packages) need no app. The other checks load it, once
registered and once booted, so they need the .env and, for readiness, the services the app depends on.
pnpm marmeon check✓ types
- lint skipped: oxlint is not installed — pnpm add -D oxlint
✗ internal-imports — 1 error
modules/notes/Notes.ts:3:19 `@marmeon/*/internal` is the framework's own code, not API — … (marmeon(no-internal-imports))
docs: docs/check.md#imports-of-framework-internals
✓ raw-sql
✓ inner-html
✓ boot
✗ route-types — 1 error
bootstrap/routes.ts bootstrap/routes.ts is out of date
$ marmeon route:types
✓ modules
✓ throttle
✓ readiness
Failed: a check found errors (exit 1).A new app has the script: pnpm check runs marmeon check.
The checks
| Name | What it does | Needs |
|---|---|---|
types | Type-checks the app with its own TypeScript (tsc --noEmit), as marmeon build does. | TypeScript and a tsconfig.json |
lint | Runs the app's own oxlint with its .oxlintrc.json, and warns when a preset that make:lint-config wrote was written for other server modules than the app has now (marmeon(lint-preset)). A preset from an older version has no mark and gets that warning once; a preset of your own is left alone. A broken declaration in package.json is an error (H14) whenever the lint runs. | oxlint |
internal-imports | Finds imports of framework internals. | nothing |
raw-sql | Warns about sql.raw() and sql.lit() with an argument that is not a literal. | nothing |
inner-html | Warns about HTML that code sets from a value that is not a literal. | nothing |
agents | Warns when the marmeon block of AGENTS.md is missing or out of date. | nothing |
packages | Fails when a marmeon or @marmeon/* package is not in the app's version. | nothing |
The tools run side by side and are always listed in this order. --only=types,lint runs just those, --skip=lint runs all but
that one. A check you leave out is not in the report at all; it is not reported as skipped. A name that no check has, or options that leave no check to run (--only=lint --skip=lint), is a wrong call: the command lists the valid names and exits with 2. The app is loaded only when a selected check needs it, so --only=types,lint runs without an app.
The app checks
After the tools come the checks that need the app, in this order: boot, the checks that need the app registered only (the
build checks), the checks that need it booted (the run checks), and readiness. The checks of a package or a module come in the order they are registered.
| Name | What it does | Phase |
|---|---|---|
boot | Starts the app. When it cannot start, the finding holds the message, its error code and the fix the solutions of the core and of the packages know (APP_KEY missing: marmeon key:generate). | |
route-types | bootstrap/routes.ts matches the routes and views (a file of another Marmeon version is H2), and every route says who may call it (H3), and no route has two middleware that add the same key (H8). A missing file is an error: commit it. | build |
modules | Job and notification classes under modules/ that no module lists, as warnings. | build |
throttle | Warns at routes that anyone may call to change something and nothing limits. Reads the routes, no database. | build |
package-migrations | Every package your app publishes from has all its migrations in the app, the answer of marmeon publish --check: one that is missing is D6 with the fix marmeon publish <package>, one publish refuses is an error with the reason. Packages you have not published from are left out. A package whose declaration cannot be read is listed by marmeon publish, but checked only when publish made a module for it. A static migrations that cannot be read (it is no object, or from is no file: URL) belongs to the package that exports its provider (found among the packages that list providers in marmeon.providers) and is treated like any refusal of it; one no direct dependency exports, and a declaration a package does not claim, are warnings. A package taken into an existing module with --module is not checked here; use marmeon publish --check <package>. Reads files and imports the main entry of the direct dependencies a declaration points into; no database. | build |
hsts | Warns when production on an https APP_URL sends no Strict-Transport-Security. Asks the booted app once. | run |
db-types | bootstrap/database.d.ts matches the migrations (a file of another Marmeon version is D7); a missing file is an error. Reads the migration files, no database. | run |
readiness | Runs the readiness checks of the packages, the modules and the app, the same as /up/ready: a failing one is an error named after it, one that takes longer than its time limit says so. |
The build phase registers the app and boots nothing: it reads no secret and connects to nothing. The run phase boots it. If the
app does not load, boot fails and the checks that needed it are skipped with the reason "the app did not load" (or error, exit 3, with that reason when boot is not selected). Checks that
a package adds are known by the installed packages when the app does not load; those the app adds itself are not.
The default run needs the app's .env (or the variables in the environment) and, for readiness, the services it depends on.
readiness connects to them, such as the database and Redis. In CI without services, leave it out:
pnpm marmeon check --skip=readinessWhat the app checks print is masked by the app's redactor, which masks what looks like a secret (a password in a connection URL
becomes ***). It cannot know every secret: name none in a message. Nothing prints a stack, and the app's own directory is written
as .. While the checks run, a rejection or an exception that nobody handles is recorded the same way on the check that was
running, instead of ending the process.
Checks from packages
A package or a module adds its own checks to this list. See package development.
Status
Each check ends in one status:
| Status | Meaning |
|---|---|
passed | It ran and found nothing. |
warning | It found problems of the level warning only. |
failed | It found at least one error. |
skipped | It was not run, said in reason: no tsconfig.json, no AGENTS.md, oxlint is not installed (lint is the app's choice), or the app did not load ("the app did not load"). |
error | It could not run, said in reason: TypeScript is not installed, a tool crashed, a check of a package threw or took longer than its time limit (a minute unless the check says static timeout, in seconds), or the app did not load and boot was not selected. |
Exit codes
| Code | Meaning |
|---|---|
0 | Every check that ran passed. Warnings are allowed, and so is a skipped check. |
1 | At least one check found an error. |
2 | marmeon check was called wrongly, for example --only=bogus. See the console. |
3 | At least one check could not run (status error), or something failed outside every check (an unhandled rejection after a check ran out of time, said in warnings), and none found an error. |
A finding is more useful than a missing tool, so 1 wins over 3.
JSON output
--json prints one document, also when the exit code is 1 or 3, because the findings are the result. When the call itself is
wrong (exit 2), stdout stays empty and the message is on stderr. The format follows machine-readable output.
{
"format": 1,
"ok": false,
"exitCode": 1,
"checks": [
{
"name": "types",
"status": "failed",
"ms": 812,
"reason": null,
"problems": [
{ "severity": "error", "message": "Type 'string' is not assignable to type 'number'.", "file": "modules/notes/Notes.ts", "line": 4, "column": 7, "code": null, "rule": "TS2322", "fix": null, "docs": null }
],
"silenced": []
},
{ "name": "lint", "status": "skipped", "ms": 0, "reason": "oxlint is not installed — pnpm add -D oxlint", "problems": [], "silenced": [] },
{ "name": "internal-imports", "status": "passed", "ms": 14, "reason": null, "problems": [], "silenced": [] }
],
"warnings": []
}okis true exactly whenexitCodeis0.- Every key is always there; what is not known is
null. A problem hasseverity(errororwarning) andmessage, andfile(relative to the app, with/),lineandcolumn(from 1),code(a Marmeon error code),rule(the tool's name for it; forreadiness, the name of the health check),fix(a command to run, never run bycheck) anddocs. silencedlists the findings a comment silenced, each a problem with thereasonthe comment gives; it is[]for a check with none. They do not count towardsstatus.warningsis a list of texts: the app did not load so its own checks are not in the report, a name--only/--skipgave that nobody knows (only then, otherwise it is exit2), or an unhandled failure outside any check.reasonsays why a check wasskippedor ended inerror; it isnullotherwise. The app's own directory is written as..- A new field is no new format. In CI, run
marmeon checkand read the exit code; use the document when a tool needs the findings.
Imports of framework internals
Every package has a ./internal entry (@marmeon/core/internal) that holds the framework's own plumbing: transaction frames,
guard internals, injection analysis. It is not API and can change in any release. An app that imports it, or that reaches
into a package's dist or src, works today and breaks silently at the next minor, and it can skip checks that the public API
keeps. The fix is the package's public entry, or an issue when something is missing.
The lint preset blocks @marmeon/*/internal too (make:lint-config), but a lint rule can be removed or turned
off, so this check does not depend on .oxlintrc.json: it reads your files itself. It reports every place where one of these
appears as a string:
@marmeon/<package>/internalandmarmeon/internal,@marmeon/<package>/dist/…,@marmeon/<package>/src/…,marmeon/dist/…andmarmeon/src/…,- a path through
node_modulesto@marmeon/…ormarmeon/…(../node_modules/@marmeon/core/dist/x.js, also written with\).
It looks in every .ts, .tsx, .mts, .cts, .js, .jsx, .mjs and .cjs file below the app, tests included, and finds
the string wherever it is written: import, import type, export … from, import('…'), require('…'), vi.mock('…'), a
template literal without an expression, a /// <reference types="…" /> comment, a query or fragment after the subpath
('@marmeon/core/internal?raw'), and an escaped spelling such as '@\u006darmeon/core/internal'. It also reads
every string in tsconfig*.json (paths, but also extends, include and the rest) and the imports of package.json.
Any other line in a comment is no import and is not reported. A path below node_modules/.vite/ that mentions marmeon, the
pre-bundled copy of a package, counts as well.
It does not look into node_modules, a directory whose name starts with a dot (.storybook) or, at the root of the app,
dist, storage and coverage (a modules/storage/ folder is read). It does not follow symlinked files or directories, and it
does not find:
- a specifier that is put together from parts (
'@marmeon/' + 'core/internal'), - a package alias (
npm:@marmeon/core), - a relative path that leaves the app to a checkout of the framework (
../../packages/core/src/…), - a file type other than the ones above (
.mdx,.vue) or a config other thantsconfig*.json(jsconfig.json), - a file it cannot parse. The
typescheck reports it only when the file is part of thetsconfig.jsoninclude; an.mjsscript outside it is reported by neither.
The check guards against the honest mistake, not against code that hides what it imports.
marmeon build runs the same scan and only warns, one line per place, so that a build is not held up by it; the exit code of
marmeon build does not change. marmeon check is what fails.
Raw SQL
sql.raw(text) and sql.lit(value) put their argument into the SQL as written, so a value from a request becomes SQL: an
injection. raw-sql warns about every call whose argument is not a literal, at the argument. The fix is to put the value in a
sql template with ${value}, which sends it as a parameter (queries); a column or table
name comes from a list in the code, and sql.ref() and sql.table() take it as a name. If the argument is safe, say why in a
comment.
A literal is a string, a template without an expression, a + of literals, any of them in parentheses or with as const or
satisfies, and a name that the file declares exactly once as const NAME = <literal>. A constant from another file is not a
literal (move it into the file, or silence the call), and neither is a function call, whatever it is called: sanitize(name)
is no proof.
sql is the name sql, an import of it from @marmeon/database or kysely under another name (import { sql as q }),
sql of a namespace import (db.sql.raw(…)), or a const set to one of them (const s = sql). Parentheses, as and ! around
it, sql['raw'], const { raw } = sql and sql.raw.call(…) or .apply(…) do not hide a call. The check reads the sources below the app the way internal-imports
does, but not the tests (*.test.*, *.spec.*, *.type-test.*): what a test passes is no input. It is a warning, so the exit
code stays 0. It does not find sql.ref() and sql.table() (names, which the database quotes), a sql that your own module re-exports under
another name, a sql passed through a function or an object, a call through a variable that is not a plain const (let raw = sql.raw), or a file it cannot parse (the types check reports it).
HTML from code
HTML that code sets from a value is a cross-site scripting hole when the value comes from a request: the page runs what the
value holds. The content security policy limits what that script can do; it does not replace escaping. inner-html
warns, at the value, about:
- the JSX attribute
dangerouslySetInnerHTMLand a property of that name (createElement(…, { dangerouslySetInnerHTML })), unless the value is{ __html: <literal> }, - an assignment to
.innerHTMLor.outerHTML, with=or+=, and one to.__html, - the JSX attribute
srcDocand an assignment to.srcdoc, which make a document of the HTML an iframe shows, insertAdjacentHTML(position, html),createContextualFragment(html)andsetHTMLUnsafe(html),write(…)andwriteln(…)of a document:document,window.document,iframe.contentDocumentandel.ownerDocument.
The fix is to show the value as text: {value} in JSX, textContent in the DOM. If the HTML is safe, say why in a
comment; in JSX the comment goes in a {/* … */} on the line above the attribute.
A literal is the same as for raw-sql. A function call is none, whatever it is called: sanitize(html) is no proof,
because the check cannot know what the function does. Parentheses, as, ! and ?. around a value or an element, a const that
holds the { __html } object or the method (const p = { __html: html }, const add = el.insertAdjacentHTML), el['innerHTML'],
.call(), .apply() and .bind() do not hide a sink. It reads the sources like raw-sql, so the tests are not read.
It is a warning, so the exit code stays 0. It does not find:
- an object that reaches the attribute from elsewhere (
{...props}wherepropsholdsdangerouslySetInnerHTML, a function that returns the object, or one from another file), - a property whose key is a variable (
{ [key]: … }), - a method taken out of its object (
const { insertAdjacentHTML } = el), or one called throughReflectorObject.assign(el, { innerHTML }), - a setter of another name that parses HTML (
DOMParser,setAttribute('srcdoc', …), a library'shtml()), or a file it cannot parse.
Markup you build yourself
Some values are markup on purpose: a guest's note with bold text and a link, kept as a tree the app's editor wrote. The check cannot call a
sanitizer safe, so do not hand it HTML to clean. Build the markup from nodes instead: the tags and attributes you allow are the only ones that
can come out, and React escapes every string, so no dangerouslySetInnerHTML is involved.
import { createElement, Fragment, type ReactNode } from 'react';
const TAGS = new Set(['p', 'strong', 'em', 'ul', 'ol', 'li', 'br', 'a']);
const SCHEMES = new Set(['http:', 'https:', 'mailto:']);
const DEEPEST = 16;
/** The address to link to, or `undefined` for anything that is not an absolute `http:`, `https:` or `mailto:` address. */
function safeHref(value: unknown): string | undefined {
const url = typeof value === 'string' ? URL.parse(value) : null;
return url && SCHEMES.has(url.protocol) ? url.href : undefined;
}
/** A stored tree as elements. Whatever is not on the allowlist is left out: an unknown tag keeps its text, an attribute other than a safe `href` is gone. */
export function toElements(node: unknown, depth = 0): ReactNode {
if (typeof node === 'string') return node;
if (depth >= DEEPEST || typeof node !== 'object' || node === null) return null;
const { tag, href, children } = node as { tag?: unknown; href?: unknown; children?: unknown };
const inner = Array.isArray(children) ? children.map((child, index) => <Fragment key={index}>{toElements(child, depth + 1)}</Fragment>) : null;
if (typeof tag !== 'string' || !TAGS.has(tag)) return inner;
if (tag === 'br') return <br />;
if (tag === 'a') {
const safe = safeHref(href);
return safe ? <a href={safe} rel="noopener noreferrer">{inner}</a> : inner;
}
return createElement(tag, null, inner);
}Render {toElements(note.body)} in a view. A tree deeper than DEEPEST levels is cut off, so a stored value cannot exhaust the stack. Where a string is needed, in a mail or a feed, use renderToStaticMarkup() of
react-dom/server on the same elements: there is one allowlist, not a second writer of HTML. The link keeps url.href, the parsed
address, not the text that came in: the parser removes the tabs and line breaks a browser would also ignore, and encodes a quote.
Four tests show that the allowlist holds. Each one goes red when you take away the line it covers: make safeHref() return value, drop
the TAGS check, let the href through unparsed, or remove the DEEPEST check.
import { renderToStaticMarkup } from 'react-dom/server';
import { expect, it } from 'vitest';
import { toElements } from './Markup.tsx';
const html = (node: unknown) => renderToStaticMarkup(<>{toElements(node)}</>);
it('shows a script as text', () => {
expect(html('<script>alert(1)</script>')).toBe('<script>alert(1)</script>');
expect(html({ tag: 'script', children: ['alert(1)'] })).toBe('alert(1)');
expect(html({ tag: 'img', onerror: 'alert(1)', children: ['x'] })).toBe('x');
});
it('drops a link with another scheme', () => {
for (const href of ['javascript:alert(1)', ' JavaScript:alert(1)', 'java\tscript:alert(1)', 'data:text/html,x', '//evil.example', '/relative', 42, undefined]) {
expect(html({ tag: 'a', href, children: ['x'] })).toBe('x');
}
});
it('cuts a tree that is too deep', () => {
let node: unknown = 'deep';
for (let level = 0; level < 10_000; level++) node = { tag: 'em', children: [node] };
const out = html(node);
expect(out).not.toContain('deep');
expect(out.length).toBeLessThan(200);
});
it('keeps a value from leaving its attribute', () => {
const out = html({ tag: 'a', href: 'https://example.com/" onmouseover="alert(1)', onclick: 'alert(1)', children: ['x'] });
expect(out).toBe('<a href="https://example.com/%22%20onmouseover=%22alert(1)" rel="noopener noreferrer">x</a>');
});A relative link is dropped too. To allow one, resolve it against your own origin first with new URL(value, origin) and compare
the origin of the result with yours.
Routes without a limit
A route that anyone may call without signing in and that changes something is open to spam, to guessing (a password, a code) and to
cost (every request may send a mail or start a job). throttle warns, in the registered routes and without booting the app, at
every route that
- is the app's own (a route the framework itself registers is left out),
- allows
everyoneand nothing else: every access check of the route iseveryone, - answers a method other than
GET,HEADandOPTIONS(POST,PUT,PATCHorDELETE, also throughRoute.match()andRoute.any()), and - has no middleware that declares that it limits or authenticates: of the app, of the route's group, of a prefix group around it, or of the route itself.
! throttle — 1 warning
modules/contact/routes.ts:6 POST /contact (contact.send) can be called by anyone and nothing limits it — add `throttle('<name>')` (or `authenticate()`), or silence this with `// marmeon-check-ignore throttle: <why it needs no limit>` (marmeon(throttle))
docs: docs/rate-limiting.md#applying-limits-to-routesThe fix is throttle('<name>') on the route or its group (rate limiting), or
authenticate() when only signed-in users should call it. Where a limit is wrong, say why in a comment on the
line of the .post( call, or on the line above it; for a route whose chain runs over several lines, that is the line of the call
that names the method. A comment above a group covers nothing inside it, so each route of the group needs its own.
What counts as a limit or a sign-in is what a middleware says about itself in static ensures, not what it does. throttle() says
'rate-limited'; authenticate(), authenticate('api') and signed() say 'authenticated'. The check does not run the middleware:
- A middleware that declares it counts even if it does nothing, and so does a
throttle()whose limiter has no limits. Astatic ensuresthat is not an array of those names ('rate-limited',['trusted']) counts for nothing. - A route whose access check is more than
everyoneis not reported, though the check may let guests through: a policy throughcan()or a request's ownauthorize. The check cannot tell. A route that says nothing about who may call it isroute-types' error, not this finding. - It looks at who may call and how, not at what the action does: a
POSTthat only reads is reported, and so is a route that touches no record.{ public: true }on a binding is no finding either way: it concerns the records a route may read. - Middleware that a package appends to a group only in its
boot()is not seen: the check reads the routes as registered, and theglobal,webandapilists ofdefineApplication({ middleware }). - A middleware that limits by other means than a named limiter, such as a proxy's limit in front of the app, is invisible to it.
- A route that a package registers on behalf of the app is placed on the line in the app's own file that registered it; one with no such line has no file and cannot be silenced by a comment.
It is a warning, so the exit code stays 0.
HSTS
Without Strict-Transport-Security a browser's first visit over plain http:// can be kept
on HTTP by an attacker on the path. hsts warns when none is sent. It is only checked when the app runs as production
(NODE_ENV=production) and APP_URL starts with https://; in development and test, and with an http:// APP_URL, it is
skipped. So run it with your production environment, before going live:
NODE_ENV=production pnpm marmeon check --only=boot,hstsIt starts the app and runs its code once: a single GET of https://<APP_URL host>/_marmeon/hsts-probe, handed to the app
from the loopback address, with accept: application/json, no cookies and no body. The answer is thrown away. That path is
under /_marmeon/, which belongs to the framework, and the app's global middleware sees it like any request, which is where
securityHeaders() adds the header. A failing probe makes the check error.
! hsts — 1 warning
Responses over HTTPS carry no Strict-Transport-Security header. Turn it on with securityHeaders({ hsts: … }) in bootstrap/app.ts once the site answers over HTTPS only. If your proxy sets it, leave this check out there: --skip=hsts. (marmeon(hsts))
docs: docs/security.md#strict-transport-securityIt warns when the response has no header, a header without a valid max-age or with two of them (a browser ignores it), or max-age=0, which
turns HSTS off. It reads the header as a browser does: any letter case, and when several come, the first.
- The app is asked, not the proxy in front of it. Where the proxy sets the header, the check warns although visitors get it: run
it with
--skip=hststhere. - A header that the middleware of a group or of a route, or a controller, sets is not seen: the probe passes through the global
middleware only. That is a false alarm like the proxy's;
--skip=hststhere too. - The probe looks like a real request: the log has a line
GET /_marmeon/hsts-probe 404, a trace records it, and a global middleware with a side effect runs once. includeSubDomainsandpreloadare not looked at;securityHeaders()already refusespreloadwithoutincludeSubDomainsand amaxAgeof a year.- It is a warning, so the exit code stays
0, and it cannot be silenced by a comment.
Silencing a finding
A finding of raw-sql, inner-html or throttle can be silenced by a comment with a reason, on the line of the finding or the line above it:
// marmeon-check-ignore raw-sql: the column is one of SORTABLE, checked above
const order = sql.raw(column);The syntax is marmeon-check-ignore <check>[, <check>]: <reason>, as a // comment, a /* … */ comment or, in JSX,
{/* … */}. Only a real comment counts; the same text in a string does nothing.
- The reason is required. A comment without one silences nothing, and
checkwarns about the comment. - A silenced finding is not gone. It leaves
problemsandstatus, and shows in the report:✓ raw-sql (1 silenced)and a linesilenced file:line reasonunder it, and in--jsonas an entry ofsilencedwith itsreason(cut to 200 characters). A reviewer sees the reason in the diff and in the report. - A comment that silences nothing is a warning too: remove it. A name that is no check silences nothing and is not reported.
internal-importscannot be silenced. A framework internal is never an exception.- Only
raw-sql,inner-htmlandthrottlecan be silenced this way. - A comment at the end of a line covers that line only. A comment on its own line (in JSX,
{/* … */}) covers its last line and the next.
The agents block
agents reads AGENTS.md and compares the block between the marmeon:begin and marmeon:end marks with the one the installed
marmeon writes for this app (with the section on skills and tools when they are installed). It only warns and writes nothing, so it never changes the exit code: the file belongs to your app.
AGENTS.md | Result |
|---|---|
| Not there | skipped, with the reason "no AGENTS.md" (passed when skills or the entry are installed and current). |
| Its block is current | passed. |
| Its block is out of date, or it has none | warning, with the fix marmeon agents:sync. |
| Its marks are broken (a mark without its partner, two blocks) | warning with the reason; fix the marks, then run marmeon agents:sync. |
Its marks are of a newer version than your marmeon | warning that says to update marmeon. |
| A link that leads nowhere or out of the app, or something that is no file | warning; it is not read. |
A skill, a skill folder, the skills folder or .mcp.json that is there but cannot be read or inspected | One warning with the reason; no block is judged until it can be read. |
The check also looks at what marmeon agents:install wrote (see skills and the MCP server),
but only where something is installed: a skill with the mark of marmeon in .claude/skills/ or .agents/skills/, or an entry
marmeon in .mcp.json. A skill that is missing or out of date, or an existing entry that is out of date, is a warning with the fix
marmeon agents:install (an entry removed next to installed skills is taken as a choice, as with --no-mcp); a
link that leads out of the app, or a SKILL.md that is not marmeon's, is a warning with the reason and no fix. The check reads no
target of a link out of the app and never writes. An app with .mcp.json of its own and no skills gets no message, and an app
with skills but no AGENTS.md is checked too.
To keep the warning out of the report, run marmeon agents:sync and commit the result, or leave the check out with
--skip=agents. To make CI fail on a block that is out of date, use marmeon agents:sync --check. See
coding agents.
The packages check
Every @marmeon/* package pins the others it needs to one exact version, and marmeon and the packages are released together.
pnpm add @marmeon/storage takes the version latest names, so in an app on another version it installs a second @marmeon/core
next to the app's, and the package manager only warns about peers. packages reads the package.json of the app and the installed
packages (no app is loaded, no file is searched) and compares each marmeon and @marmeon/* it lists with @marmeon/core:
| Finding | Level |
|---|---|
A package is installed in another version than @marmeon/core. The message names the package, both versions and the command in your package manager, chosen by the lockfile (pnpm add -E @marmeon/storage@<the app's version>, or npm install -E … with a package-lock.json and no pnpm-lock.yaml). | error |
A package that is in the app's version, or a package of someone else the app lists, pulls an @marmeon/* in a different version, so a second copy is installed. | error |
A package is listed with a range (^1.0.0) or a tag instead of an exact version. | warning |
A package that is not looked up by version (workspace:*, file:, link:). | not reported |
No @marmeon/core installed. | skipped |
package.json cannot be read. | error, "could not run" |
Fix a finding with the command it shows; -E saves the version exactly, and -D is added for a package listed under
devDependencies. marmeon dev runs the same comparison at each start of the server, so after every restart, and warns without
stopping, and a K3 error of any command ends with it too, since a package that is read in another version is a usual reason for it. marmeon start does not look: the
production server starts what CI has checked. Leave the check out with --skip=packages.