Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changeset/starter-comments-self-contained.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
---
"create-objectstack": patch
---

Rewrite the scaffolded project's starter comments so a newcomer can actually
follow them (#10324). `objectstack.config.ts` and `src/objects/note.object.ts`
are the first two files opened after scaffolding, and between them they cited
four ADR identifiers, one bare issue number and the path of a release-time
script in this monorepo — none of which ship in, or are linked from, a
scaffolded project. `// per ADR-0097` read as a reference the reader was
failing to follow rather than as the context it was meant to be.

The explanations are kept and made self-contained; only the dead ends are
gone. Each now states the fact the identifier stood for — the protocol range
is checked before anything loads and was stamped to match the installed
version rather than hand-tuned; `automation` must stay whenever `plugins:`
lists a connector or the executors have nowhere to register; a declarative
`mcp` stdio transport is denied by default; the org-wide default is required
so the baseline is an authored decision — and points at the public docs page
that covers it in full. The blank `Dockerfile` likewise stops pointing at a
file in this repo and points at the self-hosting guide it already links.

A pin (`starter-comments-self-contained.test.ts`) keeps it that way from both
sides: no shipped template file may cite an ADR identifier, a bare issue
number or a repo script path, and the facts those references carried must
still be stated — so the comments cannot be "fixed" by deleting them. It also
resolves every canonical-origin docs URL in the shipped tree against
`content/docs`, because a link that 404s is the same defect one level out.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license.
//
// Every comment that ships INTO a scaffolded project must be followable by the
// person reading it — someone who has this project and nothing else.
//
// ## The defect
//
// The two files a newcomer opens first after scaffolding, objectstack.config.ts
// and src/objects/note.object.ts, carried six references addressed to a reader
// with this monorepo open: four ADR identifiers, one bare issue number, and the
// path of a release-time script. None of docs/adr, the issue tracker, or that
// script ships in a scaffolded project, so "// per ADR-0097" was a reference the
// reader could not resolve — it read as an instruction they were failing to
// follow rather than as the context it was meant to be.
//
// ## Why this pin has TWO halves, and why the second is the load-bearing one
//
// The cheap way to make the references disappear is to delete the comments. That
// would be a worse project than the one with the dead references: the comments
// explain WHY each setting is the way it is, which is exactly what a newcomer
// deciding whether to change it needs. So a one-way "no ADR identifiers" grep
// would rot in the one direction that matters — it stays green while the
// rationale is deleted out from under it.
//
// Hence: no unfollowable reference (assertion 1) AND the fact each comment
// carries still stated (assertion 2). A future edit can reword freely; it cannot
// quietly strip the explanation, and it cannot re-introduce a dead end.
//
// ## The third half: a public link is only a fix while it resolves
//
// Replacing an internal identifier with a docs URL moves the same defect one
// level out if the URL 404s — a reference that looks authoritative and lands
// nowhere. Assertion 3 resolves every canonical-origin docs URL in the shipped
// tree against content/docs the way Fumadocs routes it: baseUrl /docs over
// content/docs, and a directory that exists but carries no index page is a 404.
// That candidate list is check-docs-redirects' pageCandidates, restated in six
// lines rather than imported, because importing a root script into this package
// would widen this suite's declared cross-package read radius to buy nothing.
//
// Host CONVERGENCE is deliberately not asserted here — the tree still carries
// two non-canonical docs hostnames and they are another card's (#10990). This
// pin only judges URLs already on the canonical origin, so the two cards cannot
// collide.

import { describe, it, expect } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const HERE = path.dirname(fileURLToPath(import.meta.url));
const templateRoot = path.resolve(HERE, 'templates');
const contentDocs = path.resolve(HERE, '..', '..', '..', 'content', 'docs');

/**
* The blank template's README is scanned by nothing here yet: it still carries
* an ADR identifier of its own, and it is owned by other cards in the same
* family (a scaffolding-guidance fix was in flight over it while this landed).
*
* The exclusion is SELF-RETIRING rather than permanent — the last assertion
* fails the moment the README stops needing it, so whoever cleans that file is
* told, in their own run, to delete this entry and let the file be scanned.
* A silent exemption over the most-read file in the tree is the failure this
* shape exists to avoid.
*/
const EXCLUDED = new Map([['blank/README.md', 'still carries an ADR identifier; owned by another card']]);

/** Text files the scaffolder copies into the user's project. */
function shippedFiles(): string[] {
const out: string[] = [];
const walk = (dir: string) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const abs = path.join(dir, entry.name);
if (entry.isDirectory()) walk(abs);
else out.push(path.relative(templateRoot, abs).split(path.sep).join('/'));
}
};
walk(templateRoot);
return out.sort();
}

/**
* References a reader who has only their own scaffolded project cannot follow.
* Each is spelled to match the identifier, not any particular sentence, so the
* prose around it stays free to change.
*/
const MONOREPO_ONLY = [
{ label: 'an ADR identifier', re: /\bADR-\d{3,4}\b/ },
{ label: 'a bare issue number', re: /(^|[^\w/])#\d{3,6}\b/ },
{ label: 'a repo build-script path', re: /\bscripts\/[\w.-]+\.(?:mjs|mts|cjs|ts|js)\b/ },
{ label: 'a monorepo package path', re: /\bpackages\/[a-z0-9][\w-]*\//i },
];

const read = (rel: string) => fs.readFileSync(path.join(templateRoot, rel), 'utf8');

describe('shipped template comments are followable by a stranger', () => {
const files = shippedFiles();

it('reads a real template tree (vacuity guard)', () => {
expect(files).toContain('blank/objectstack.config.ts');
expect(files).toContain('blank/src/objects/note.object.ts');
expect(files.length).toBeGreaterThan(8);
});

// ── assertion 1: nothing unfollowable ────────────────────────────────────
it.each(shippedFiles().filter((f) => !EXCLUDED.has(f)))(
'%s cites nothing that only exists in this monorepo',
(rel) => {
const text = read(rel);
for (const { label, re } of MONOREPO_ONLY) {
const hit = re.exec(text);
expect(
hit,
`${rel} cites ${label} (${JSON.stringify(hit?.[0])}). A scaffolded project ` +
'ships no ADRs, no issue tracker and none of this repo\'s scripts, so this ' +
'reads as a reference the newcomer is failing to follow. State the fact ' +
'self-contained, or link a public docs page — do not delete the rationale.',
).toBeNull();
}
},
);

// ── assertion 2: the rationale survives ──────────────────────────────────
// Each entry is the FACT the removed reference was carrying, matched loosely
// enough that rewording is free and deletion is not.
const RATIONALE: { file: string; facts: { what: string; re: RegExp }[] }[] = [
{
file: 'blank/objectstack.config.ts',
facts: [
{ what: 'why the protocol range exists (an incompatible runtime refuses the app)', re: /refuses? this app|refuse this package|incompatible runtime/i },
{ what: 'that the protocol range is stamped for you, not hand-tuned', re: /stamped|scaffold(ing|ed)/i },
{ what: 'why `automation` must stay when a connector is listed', re: /nowhere to register|boot fails/i },
{ what: 'that a declarative mcp stdio transport is denied by default', re: /denied by default/i },
],
},
{
file: 'blank/src/objects/note.object.ts',
facts: [
{ what: 'what the org-wide default means', re: /org-wide default|OWD/i },
{ what: 'that declaring it is required rather than optional', re: /required|refuses/i },
],
},
];

for (const { file, facts } of RATIONALE) {
describe(file, () => {
for (const { what, re } of facts) {
it(`still explains ${what}`, () => {
expect(
read(file),
`${file} no longer explains ${what}. These comments were rewritten to drop ` +
'monorepo-only references while KEEPING what they explain; deleting the ' +
'explanation is not the same fix.',
).toMatch(re);
});
}
});
}

// ── assertion 3: canonical docs links resolve ────────────────────────────
it('every canonical docs URL in the shipped tree resolves to a real page', () => {
// baseUrl '/docs' is mounted over content/docs, so the route path is the
// file path minus the extension; a directory resolves only via an index page.
const candidates = (route: string) => [
`${route}.mdx`,
`${route}.md`,
`${route}/index.mdx`,
`${route}/index.md`,
];
const urls: { rel: string; url: string; route: string }[] = [];
for (const rel of shippedFiles()) {
const text = read(rel);
for (const m of text.matchAll(/https:\/\/objectstack\.ai\/docs\/([\w./-]*[\w-])/g)) {
urls.push({ rel, url: m[0], route: m[1] });
}
}
// Non-vacuity: the rewritten starter comments put docs links in this tree on
// purpose. Zero matches means the extractor broke, not that the tree is clean.
expect(urls.length, 'no canonical docs URLs found — the extractor is broken').toBeGreaterThan(0);

for (const { rel, url, route } of urls) {
const found = candidates(route).some((c) => fs.existsSync(path.join(contentDocs, c)));
expect(
found,
`${rel} links ${url}, which content/docs serves from none of ` +
`${candidates(route).join(', ')}. A link that 404s is the same defect one ` +
'level out — repoint it, or make the comment self-contained instead.',
).toBe(true);
}
});

// ── the exclusion is live, or it is gone ─────────────────────────────────
it.each([...EXCLUDED.keys()])('%s still needs its exclusion', (rel) => {
const text = read(rel);
const hits = MONOREPO_ONLY.filter(({ re }) => re.test(text));
expect(
hits.length,
`${rel} no longer cites anything monorepo-only — remove it from EXCLUDED in ` +
'this file so it is scanned like every other shipped file. An exclusion kept ' +
'past its cause is how a file stops being checked without anyone deciding to ' +
'stop checking it.',
).toBeGreaterThan(0);
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -20,7 +20,7 @@ RUN npx os build # → dist/objectstack.json
# ── Runtime: the official ObjectStack runtime image ──────────────────
# Ships Node + @objectstack/cli with `os start`, a non-root user, the
# /api/v1/health HEALTHCHECK, and OS_ARTIFACT_PATH/OS_PORT preset (port 8080)
# — see docker/README.md in the framework repo.
# — see the self-hosting guide linked above.
#
# Dependencies were not installed while scaffolding, so the tag below could
# not be resolved for you. `latest` floats to whatever release is newest,
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,27 +12,31 @@ export default defineStack({
type: 'app',
name: 'Blank Starter',
description: 'Minimal ObjectStack environment — a clean slate for building.',
// Protocol compatibility range (ADR-0087 D1): lets an incompatible runtime
// refuse this package at the boundary with the exact migration command,
// instead of crashing later. Kept in lockstep with releases by
// scripts/sync-template-versions.mjs.
// Protocol compatibility range: the metadata-protocol major this app is
// authored against. The runtime checks it before it loads anything, so a
// runtime outside the range refuses this app at the boundary with the exact
// migration command instead of crashing later. Scaffolding stamped it to
// match the ObjectStack version you installed — change it when you
// deliberately move to a new protocol major, not to silence a mismatch.
// Guide: https://objectstack.ai/docs/upgrading
engines: { protocol: '^17' },
},

// `automation` backs flow execution and, per ADR-0097, materializes any
// declarative `connectors:` entry into a live, dispatchable connector at boot.
// The connector executors below register their provider factories with it —
// without `automation` loaded they have nowhere to register and boot fails, so
// keep this capability whenever `plugins:` lists a connector.
// `automation` backs flow execution andmaterializes any declarative
// `connectors:` entry into a live, dispatchable connector at boot. The
// connector executors below register their provider factories with it —
// without `automation` loaded they have nowhere to register and boot fails,
// so keep this capability whenever `plugins:` lists a connector.
requires: ['automation'],

// Generic connector executors (ADR-0022/0023/0024 + ADR-0097), default-present
// so you can add a `connectors:` entry naming `provider: 'rest' | 'openapi' |
// 'mcp'` and have it materialize with zero host code. Zero-arg = contribute the
// provider factory only. Brand connectors (Slack, …) stay marketplace/opt-in.
// Security (#3055): a declarative `mcp` stdio transport spawns a local process
// from metadata and is denied by default — opt in per host with
// Generic connector executors, default-present so you can add a `connectors:`
// entry naming `provider: 'rest' | 'openapi' | 'mcp'` and have it materialize
// with zero host code. Zero-arg = contribute the provider factory only. Brand
// connectors (Slack, …) stay marketplace/opt-in.
// Security: a declarative `mcp` stdio transport spawns a local process from
// metadata, so it is denied by default — opt in per host with
// `new ConnectorMcpPlugin({ declarativeStdio: ['<trusted-command>'] })`.
// Authoring guide: https://objectstack.ai/docs/automation/connectors
plugins: [
new ConnectorRestPlugin(),
new ConnectorOpenApiPlugin(),
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -21,8 +21,12 @@ export const Note = ObjectSchema.create({
}),
},

// Org-wide default (OWD): who can see records they don't own. The security
// posture gate (ADR-0090) requires an explicit, authored decision here.
// Org-wide default (OWD): who can see records they don't own. `private` is
// owner-only until access is widened by a permission grant or a sharing rule.
// Declaring it is required, deliberately: `objectstack build` refuses an
// object that declares no OWD, so the baseline is always an authored decision
// rather than an accident. The other values, and how to widen access safely:
// https://objectstack.ai/docs/permissions/sharing-rules
sharingModel: 'private',

enable: {
Expand Down
Loading