Skip to content

Commit 10bbc19

Browse files
os-zhuangclaude
andauthored
fix(cli): init scaffolds pass the author-time rules dev runs (OWD + scaffold rule set) (#9736)
* fix(cli): make init templates pass the author-time rules dev runs `objectstack init my-app -t app --install` printed `Scaffold validated` and the next documented command, `npm run dev`, failed to compile: the shipped template declared no `sharingModel`, which the shipped `security-owd-unset` rule refuses (ADR-0090 D1 — absence is not a decision). The CLI's own template was rejected by the CLI's own rule set, and the developer on-ramp was dead. Two halves, both needed: 1. The `app` and `plugin` templates now author `sharingModel: 'private'` — the rule's own recommended default. A per-template sweep found `plugin` in the same state as the reported `app`; `empty` emits no objects and was clean. 2. `init`'s scaffold self-test now runs the author-time rule registry instead of only checking that the rendered config loads. It runs the `build` command's rule set — the same set `os dev` reaches by spawning `os compile` — so this is a shift-left, not a new bar: nothing that compiles today stops compiling, and a template that cannot compile fails at generation instead of at a user's first `dev`. The loader and the file emitter are now shared with the pin test (`validateScaffold`, `writeTemplateSrcFiles`), so the test drives the real command path rather than a copy that could drift from it. The pin sweeps `TEMPLATES` rather than asserting on `app`, so a template added later is covered the day it lands. Co-Authored-By: Claude <noreply@anthropic.com> * chore(changeset): init scaffold OWD + author-time rule self-test Co-Authored-By: Claude <noreply@anthropic.com> * fix(cli): spell the pin test's relative imports with .js (NodeNext) The TEST_DEBT ratchet measured +4 raw tsc errors from the new test file: two TS2835 (relative imports need explicit extensions under moduleResolution NodeNext) and two more — TS7006 and TS18046 — that were downstream of those imports resolving to `any`. Fixing the two extensions cleared all four, so the @objectstack/cli entry sits back at its recorded 146 rather than being raised. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent e43b211 commit 10bbc19

4 files changed

Lines changed: 391 additions & 30 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): `objectstack init` scaffolds now compile — templates author an OWD, and the scaffold self-test runs the author-time rules (#9666)
6+
7+
`objectstack init my-app -t app --install` reported `✓ Scaffold validated`, and
8+
the next command in the documented on-ramp, `npm run dev`, failed to compile:
9+
10+
```
11+
✗ Author-time rules failed (1 issue)
12+
• object "my_app_item": custom object "my_app_item" declares no sharingModel (OWD)…
13+
rule: security-owd-unset at objects[0].sharingModel
14+
```
15+
16+
The CLI's own shipped template was refused by the CLI's own shipped rule set, so
17+
the dev server never started on a freshly generated project.
18+
19+
Two halves:
20+
21+
- **Templates author an OWD.** The `app` and `plugin` templates now declare
22+
`sharingModel: 'private'` on the object they emit — the rule's own recommended
23+
default and the ADR-0090 D1 baseline (absence is not a decision). A sweep of
24+
every built-in template found `plugin` in the same state as the reported `app`;
25+
`empty` emits no objects and was already clean.
26+
- **`init`'s self-test got teeth.** It used to check only that the rendered config
27+
loaded and carried a `manifest.namespace`, which is why a template that could
28+
not compile shipped. It now runs the author-time rule registry over the
29+
generated project and refuses to report success when any rule rejects it. The
30+
rule set is the `build` one — the same set `os dev` reaches by spawning
31+
`os compile` — so this is a shift-left, not a stricter bar: nothing that
32+
compiles today stops compiling, and a broken template now fails at generation
33+
time instead of at a user's first `dev`.
34+
35+
`✓ Scaffold validated` still prints, and now names how many author-time rules
36+
passed.

packages/cli/src/commands/init.ts

Lines changed: 97 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ import chalk from 'chalk';
66
import fs from 'fs';
77
import path from 'path';
88
import { fileURLToPath } from 'url';
9-
import { printHeader, printSuccess, printError, printStep, printKV, printInfo } from '../utils/format.js';
9+
import { printHeader, printSuccess, printError, printStep, printKV, printInfo, formatZodErrors } from '../utils/format.js';
10+
import { validateScaffold } from '../utils/scaffold-validate.js';
1011

1112
// ─── Version resolution ──────────────────────────────────────────────
1213
//
@@ -180,6 +181,12 @@ const ${toCamelCase(namespace)}Item: Data.Object = {
180181
defaultValue: 'draft',
181182
},
182183
},
184+
// Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
185+
// requires this to be an authored decision rather than an accident — the
186+
// \`security-owd-unset\` author-time rule refuses an object without it, so a
187+
// scaffold that omitted it could not compile. 'private' is the rule's own
188+
// recommended default: owner + explicit shares.
189+
sharingModel: 'private',
183190
};
184191
185192
export default ${toCamelCase(namespace)}Item;
@@ -240,6 +247,12 @@ const ${toCamelCase(namespace)}Item: Data.Object = {
240247
required: true,
241248
},
242249
},
250+
// Org-wide default (OWD): who can see records they do NOT own. ADR-0090 D1
251+
// requires this to be an authored decision rather than an accident — the
252+
// \`security-owd-unset\` author-time rule refuses an object without it, so a
253+
// scaffold that omitted it could not compile. 'private' is the rule's own
254+
// recommended default: owner + explicit shares.
255+
sharingModel: 'private',
243256
};
244257
245258
export default ${toCamelCase(namespace)}Item;
@@ -296,6 +309,41 @@ function printWarning(msg: string) {
296309
console.log(chalk.yellow(` ⚠ ${msg}`));
297310
}
298311

312+
/**
313+
* Write a template's `srcFiles` into `targetDir` and return the relative paths
314+
* written, in creation order.
315+
*
316+
* File paths use `__name__` as a placeholder for the NAMESPACE (not the npm
317+
* name) so generated identifiers stay snake_case even when the project name
318+
* contains hyphens (`my-app` → namespace `my_app` → `src/objects/my_app_item.ts`).
319+
*
320+
* Exported so the scaffold pin test generates projects through the real
321+
* emitter instead of a copy of it. A test that re-implemented this loop could
322+
* drift from it silently, and the drift would land in exactly the class the
323+
* pin exists to catch: a shipped template the CLI's own rules refuse.
324+
*/
325+
export function writeTemplateSrcFiles(
326+
srcFiles: Record<string, (name: string, namespace: string) => string>,
327+
targetDir: string,
328+
projectName: string,
329+
namespace: string,
330+
): string[] {
331+
const written: string[] = [];
332+
for (const [filePath, contentFn] of Object.entries(srcFiles)) {
333+
const resolvedPath = filePath.replace(/__name__/g, namespace);
334+
const fullPath = path.join(targetDir, resolvedPath);
335+
const dir = path.dirname(fullPath);
336+
337+
if (!fs.existsSync(dir)) {
338+
fs.mkdirSync(dir, { recursive: true });
339+
}
340+
341+
fs.writeFileSync(fullPath, contentFn(projectName, namespace));
342+
written.push(resolvedPath);
343+
}
344+
return written;
345+
}
346+
299347
/**
300348
* Detect the package manager that invoked this CLI by inspecting
301349
* `npm_config_user_agent` (set by every modern PM). Falls back to `npm`,
@@ -488,22 +536,9 @@ export default class Init extends Command {
488536
createdFiles.push('tsconfig.json');
489537
}
490538

491-
// 4. Create src files. File paths use `__name__` as a placeholder for
492-
// the namespace (NOT the npm name) so generated identifiers stay snake
493-
// _case even when the project name contains hyphens (e.g. `my-app` →
494-
// namespace `my_app` → `src/objects/my_app_item.ts`).
495-
for (const [filePath, contentFn] of Object.entries(template.srcFiles)) {
496-
const resolvedPath = filePath.replace(/__name__/g, namespace);
497-
const fullPath = path.join(targetDir, resolvedPath);
498-
const dir = path.dirname(fullPath);
499-
500-
if (!fs.existsSync(dir)) {
501-
fs.mkdirSync(dir, { recursive: true });
502-
}
503-
504-
fs.writeFileSync(fullPath, contentFn(projectName, namespace));
505-
createdFiles.push(resolvedPath);
506-
}
539+
// 4. Create src files (see `writeTemplateSrcFiles` for the `__name__`
540+
// placeholder rule and why the loop is exported).
541+
createdFiles.push(...writeTemplateSrcFiles(template.srcFiles, targetDir, projectName, namespace));
507542

508543
// 5. Create .gitignore if missing
509544
const gitignorePath = path.join(targetDir, '.gitignore');
@@ -533,25 +568,57 @@ export default class Init extends Command {
533568
}
534569
}
535570

536-
// Self-test the scaffold so we catch template regressions (e.g. an
537-
// invalid namespace or object name) before the user discovers them by
538-
// running `objectstack dev`. Only runs when deps are present —
539-
// `defineStack()` validation lives in `@objectstack/spec`.
571+
// Self-test the scaffold so we catch template regressions before the
572+
// user discovers them by running `objectstack dev`. Only runs when deps
573+
// are present — `defineStack()` validation lives in `@objectstack/spec`.
574+
//
575+
// This used to check only that the rendered config LOADED and carried a
576+
// `manifest.namespace`, which is how the CLI shipped a `-t app` template
577+
// its own author-time rules refused: `init` printed `✓ Scaffold
578+
// validated`, and the documented next command — `npm run dev` — died on
579+
// `security-owd-unset` before the dev server ever started. The self-test
580+
// now runs the same rule set `dev` reaches through `os compile`
581+
// (`SCAFFOLD_RULE_COMMAND`), so a template that cannot compile fails
582+
// HERE, at generation time, in CI, instead of at a user's first `dev`.
583+
// It is a shift-left, not a new bar: same registry, same command tier.
540584
if (installSucceeded) {
541585
printStep('Validating scaffold...');
586+
let scaffoldRejected = false;
542587
try {
543-
const { bundleRequire } = await import('bundle-require');
544-
const { mod } = await bundleRequire({
545-
filepath: path.join(targetDir, 'objectstack.config.ts'),
546-
cwd: targetDir,
547-
});
548-
const stack = mod.default ?? mod;
549-
if (!stack?.manifest?.namespace) {
550-
throw new Error('Rendered config has no manifest.namespace');
588+
const report = await validateScaffold(targetDir);
589+
590+
for (const f of report.advisories.slice(0, 50)) {
591+
printWarning(`${f.where}: ${f.message}`);
592+
if (f.hint) console.log(chalk.dim(` ${f.hint}`));
593+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
594+
}
595+
596+
if (report.schemaError) {
597+
printError('Scaffold validation failed: rendered config does not satisfy the protocol schema');
598+
formatZodErrors(report.schemaError);
599+
scaffoldRejected = true;
600+
} else if (report.errors.length > 0) {
601+
// Report every failing rule at once, like `os validate` / `os build`.
602+
printError(
603+
`Scaffold validation failed: author-time rules rejected the generated project (${report.errors.length} issue${report.errors.length > 1 ? 's' : ''})`,
604+
);
605+
for (const f of report.errors.slice(0, 50)) {
606+
console.log(` • ${f.where}: ${f.message}`);
607+
if (f.hint) console.log(chalk.dim(` ${f.hint}`));
608+
console.log(chalk.dim(` rule: ${f.rule} at ${f.path}`));
609+
}
610+
scaffoldRejected = true;
611+
} else {
612+
printSuccess(
613+
`Scaffold validated (namespace: ${report.namespace}; ${report.ruleCount} author-time rules passed)`,
614+
);
551615
}
552-
printSuccess(`Scaffold validated (namespace: ${stack.manifest.namespace})`);
553616
} catch (err: any) {
554617
printError(`Scaffold validation failed: ${err.message || err}`);
618+
scaffoldRejected = true;
619+
}
620+
621+
if (scaffoldRejected) {
555622
console.log(chalk.dim(' This is a CLI bug — please report it at https://github.com/objectstack-ai/objectstack/issues'));
556623
this.error('Scaffold validation failed');
557624
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* Hold a freshly generated scaffold to the SAME author-time bar the user's
5+
* very next command holds it to.
6+
*
7+
* ## The defect this exists to close
8+
*
9+
* `objectstack init my-app -t app --install` printed `✓ Scaffold validated`
10+
* and the next command, `npm run dev`, failed to compile — the CLI's own
11+
* shipped template was refused by the CLI's own shipped rule set
12+
* (`security-owd-unset`: the template's object declared no `sharingModel`).
13+
* `init`'s self-test only checked that the rendered config *loaded* and
14+
* carried a `manifest.namespace`, so every author-time rule was unread at the
15+
* one moment the CLI is generating the metadata itself. The documented
16+
* on-ramp was dead and nothing in the CLI noticed.
17+
*
18+
* ## Why `'build'` and not `'validate'`
19+
*
20+
* `os dev` auto-compiles by spawning `os compile`, and `compile` runs
21+
* `authoringRulesFor('build')`. Running the *same* command's rule set here is
22+
* what keeps this a shift-left rather than a new, stricter gate: everything
23+
* that survives `init` is exactly what survives the `dev` the user runs
24+
* moments later. Picking a different command from the registry would let
25+
* `init` refuse a scaffold `dev` accepts (or the reverse) — a second bar, the
26+
* drift class `authoring-rules.ts` exists to prevent.
27+
*
28+
* The pipeline below mirrors `compile.ts` step-for-step for the same reason:
29+
* normalize → lower callables → Zod parse → registry. A rule reading a
30+
* differently-prepared stack is the same drift wearing a different hat.
31+
*/
32+
33+
import { join } from 'node:path';
34+
import { ObjectStackDefinitionSchema, normalizeStackInput } from '@objectstack/spec';
35+
import type { ZodError } from 'zod';
36+
import {
37+
runAuthoringRules,
38+
splitBySeverity,
39+
authoringRulesFor,
40+
type AuthoringCommand,
41+
type AuthoringFinding,
42+
} from '@objectstack/lint';
43+
import { lowerCallables } from './lower-callables.js';
44+
import { resolveSduiManifest } from './sdui-manifest.js';
45+
46+
/**
47+
* The registry command whose rule set a generated scaffold is held to.
48+
*
49+
* Pinned to what `os dev` reaches through `os compile`. Exported so the pin
50+
* test asserts the coupling instead of restating the string.
51+
*/
52+
export const SCAFFOLD_RULE_COMMAND: AuthoringCommand = 'build';
53+
54+
export interface ScaffoldRuleReport {
55+
/** How many registry rules ran (for the progress line). */
56+
ruleCount: number;
57+
/** Protocol-schema failure, if the stack did not parse at all. */
58+
schemaError: ZodError | null;
59+
/** Gating findings — a non-empty list means `dev` would refuse this scaffold. */
60+
errors: AuthoringFinding[];
61+
/** `warning` / `info` findings — reported, never gating. */
62+
advisories: AuthoringFinding[];
63+
}
64+
65+
/**
66+
* Run the author-time rule set over an already-loaded stack config.
67+
*
68+
* Takes the config *object* rather than a path so the caller owns module
69+
* loading (`init` bundle-requires the rendered config from the target dir,
70+
* which is not `process.cwd()`), and so the pin test can drive real template
71+
* output through the real rules without spawning a CLI.
72+
*/
73+
export function runScaffoldAuthoringRules(config: unknown): ScaffoldRuleReport {
74+
const normalized = normalizeStackInput(config as Record<string, unknown>);
75+
const lowering = lowerCallables(normalized as Record<string, unknown>);
76+
const result = ObjectStackDefinitionSchema.safeParse(lowering.lowered);
77+
78+
if (!result.success) {
79+
return {
80+
ruleCount: authoringRulesFor(SCAFFOLD_RULE_COMMAND).length,
81+
schemaError: result.error as unknown as ZodError,
82+
errors: [],
83+
advisories: [],
84+
};
85+
}
86+
87+
const findings = runAuthoringRules(SCAFFOLD_RULE_COMMAND, {
88+
normalized: normalized as Record<string, unknown>,
89+
parsed: result.data as Record<string, unknown>,
90+
sduiManifest: resolveSduiManifest(),
91+
});
92+
const { errors, advisories } = splitBySeverity(findings);
93+
94+
return {
95+
ruleCount: authoringRulesFor(SCAFFOLD_RULE_COMMAND).length,
96+
schemaError: null,
97+
errors,
98+
advisories,
99+
};
100+
}
101+
102+
/**
103+
* Load a generated scaffold's `objectstack.config.ts` and run the author-time
104+
* rule set over it.
105+
*
106+
* Module loading lives here — rather than in `init.ts` — so the pin test that
107+
* sweeps every built-in template drives the SAME loader the command does. A
108+
* test that re-implemented the load would be free to drift from it, and the
109+
* drift would land precisely in the "the CLI's own template does not compile"
110+
* class this whole file exists to close.
111+
*
112+
* The load is deliberately unchanged from what `init`'s self-test always did
113+
* (no `external` list): only the checking after it is stronger.
114+
*
115+
* Note on `resolveSduiManifest()`: it reads `process.cwd()`, which for `init`
116+
* is the directory the user invoked from, not `targetDir`. A freshly generated
117+
* scaffold has no `sdui.manifest.json` either way, so both resolve to the copy
118+
* shipped in `@objectstack/console` — the same input `os compile` gets when the
119+
* user runs `dev` inside the new project.
120+
*/
121+
export async function validateScaffold(targetDir: string): Promise<ScaffoldRuleReport & { namespace: string }> {
122+
const { bundleRequire } = await import('bundle-require');
123+
const { mod } = await bundleRequire({
124+
filepath: join(targetDir, 'objectstack.config.ts'),
125+
cwd: targetDir,
126+
});
127+
const stack = mod.default ?? mod;
128+
if (!stack?.manifest?.namespace) {
129+
throw new Error('Rendered config has no manifest.namespace');
130+
}
131+
return { namespace: String(stack.manifest.namespace), ...runScaffoldAuthoringRules(stack) };
132+
}

0 commit comments

Comments
 (0)