@zaaxch/tailframe 3.0.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,27 @@
1
1
  export const OWNED_GUIDANCE_START = "<!-- tailframe:owned:start -->";
2
2
  export const OWNED_GUIDANCE_END = "<!-- tailframe:owned:end -->";
3
3
 
4
- export function ownedGuidance(config) {
5
- const profileList = config.profiles.length ? config.profiles.join(", ") : "none";
6
- const client = config.kind !== "service";
4
+ export function productGuidance(config) {
5
+ const apps = config.apps.map((app) => `\`${app.kind}\` at \`${app.path}\` (${app.profiles.join(", ") || "no optional profiles"})`).join("; ");
7
6
  return `${OWNED_GUIDANCE_START}
8
- ## Tailframe-owned contract
7
+ ## Tailframe-owned product contract
9
8
 
10
- - Contract: \`${config.contractVersion}\`; kind: \`${config.kind}\`; profiles: ${profileList}.
11
- - Run \`npm run sync:architecture\` and \`npm run validate:architecture\` after architectural changes.
9
+ - Contract: \`${config.contractVersion}\`; kind: \`product\`.
10
+ - Applications: ${apps}.
11
+ - Run \`pnpm sync:architecture\` and \`pnpm validate:architecture\` from the product root after architectural changes.
12
+ - The product root is the only Git, package-manager, lockfile, Tailframe-manifest, and agent-context boundary.
12
13
  - Files written by \`tailframe sync --write\` are generated sources. Change their canonical templates in Tailframe, not in this product.
13
- - Keep product capabilities under ${client ? "`src/modules/<module>` (or `lib/modules/<module>` for Flutter)" : "`src/modules/<module>`"}; keep application assembly, technology-neutral contracts, and provider adapters in their canonical app/core/platform roots.
14
- ${client ? "- Notifications use the Tailframe-owned application-shell queue and host. Product copy remains in the owning capability module.\n" : "- Every use case exposes `execute(context, input)`. Entry points construct a request or system context and pass an explicit input object.\n- Resident processes perform no DDL. `schema:apply` is the only schema lifecycle entry point.\n- RPC operations use `<owning-module>.<operation>` with no compatibility aliases.\n"}${OWNED_GUIDANCE_END}`;
14
+ ${OWNED_GUIDANCE_END}`;
15
+ }
16
+
17
+ export function appGuidance(productConfig, app) {
18
+ const profileList = app.profiles.length ? app.profiles.join(", ") : "none";
19
+ const client = app.kind !== "service";
20
+ return `${OWNED_GUIDANCE_START}
21
+ ## Tailframe-owned application contract
22
+
23
+ - Product contract: \`${productConfig.contractVersion}\`; kind: \`${app.kind}\`; path: \`${app.path}\`; profiles: ${profileList}.
24
+ - Run product-level validation and sync from the repository root; use \`tailframe validate --app ${app.kind}\` for a focused architecture check.
25
+ - Keep product capabilities under \`src/modules/<module>\`; keep application assembly, technology-neutral contracts, and provider adapters in their canonical app/core/platform roots.
26
+ ${client ? "- Notifications use the Tailframe-owned application-shell queue and host. Product copy remains in the owning capability module.\n" : "- Every use case exposes \`execute(context, input)\`. Entry points construct a request or system context and pass an explicit input object.\n- Resident processes perform no DDL. \`schema:apply\` is the only schema lifecycle entry point.\n- RPC operations use \`<owning-module>.<operation>\` with no compatibility aliases.\n"}${OWNED_GUIDANCE_END}`;
15
27
  }
@@ -15,7 +15,6 @@ import {
15
15
  useCaseSource
16
16
  } from "./service-templates.mjs";
17
17
  import {
18
- extensionAuthStoreSource,
19
18
  notificationHostSource,
20
19
  notificationStoreSource,
21
20
  uiErrorMessagesSource,
@@ -179,247 +178,8 @@ describe("notification shell", () => {
179
178
  });
180
179
  `;
181
180
 
182
- const flutterErrorsSource = `enum FailureKind {
183
- invalid,
184
- unauthenticated,
185
- forbidden,
186
- notFound,
187
- conflict,
188
- unprocessable,
189
- rateLimited,
190
- unavailable,
191
- internal,
192
- network,
193
- }
194
-
195
- class ServiceException implements Exception {
196
- const ServiceException({
197
- required this.code,
198
- required this.message,
199
- required this.kind,
200
- this.statusCode,
201
- this.details,
202
- });
203
-
204
- final String code;
205
- final String message;
206
- final FailureKind kind;
207
- final int? statusCode;
208
- final Object? details;
209
-
210
- @override
211
- String toString() => message;
212
- }
213
-
214
- FailureKind failureKindForStatus(int? statusCode) {
215
- if (statusCode == null) return FailureKind.network;
216
- if (statusCode == 401) return FailureKind.unauthenticated;
217
- if (statusCode == 403) return FailureKind.forbidden;
218
- if (statusCode == 404) return FailureKind.notFound;
219
- if (statusCode == 409) return FailureKind.conflict;
220
- if (statusCode == 422) return FailureKind.unprocessable;
221
- if (statusCode == 429) return FailureKind.rateLimited;
222
- if (statusCode == 503) return FailureKind.unavailable;
223
- if (statusCode >= 500) return FailureKind.internal;
224
- return FailureKind.invalid;
225
- }
226
- `;
227
-
228
- const flutterRpcSource = `typedef JsonObject = Map<String, dynamic>;
229
-
230
- JsonObject? rpcErrorFrom(Object? value) {
231
- if (value is! JsonObject) return null;
232
- final error = value['error'];
233
- return error is JsonObject ? error : null;
234
- }
235
-
236
- T rpcResult<T>(Object? value) {
237
- if (value is! JsonObject || !value.containsKey('result')) {
238
- throw const FormatException('The server returned an invalid response.');
239
- }
240
- return value['result'] as T;
241
- }
242
- `;
243
-
244
- const flutterNotificationSource = `import 'dart:async';
245
-
246
- import 'package:flutter_riverpod/flutter_riverpod.dart';
247
-
248
- enum NotificationKind { info, success, warning, error }
249
-
250
- class AppNotification {
251
- const AppNotification({
252
- required this.id,
253
- required this.message,
254
- required this.kind,
255
- required this.duration,
256
- });
257
-
258
- final int id;
259
- final String message;
260
- final NotificationKind kind;
261
- final Duration duration;
262
- }
263
-
264
- class NotificationState {
265
- const NotificationState(this.queue);
266
-
267
- final List<AppNotification> queue;
268
- AppNotification? get active => queue.isEmpty ? null : queue.first;
269
- }
270
-
271
- class NotificationNotifier extends Notifier<NotificationState> {
272
- Timer? _timer;
273
- int _nextId = 1;
274
-
275
- @override
276
- NotificationState build() => const NotificationState([]);
277
-
278
- void notify(
279
- String message, {
280
- NotificationKind kind = NotificationKind.info,
281
- Duration duration = const Duration(seconds: 4),
282
- }) {
283
- final notification = AppNotification(
284
- id: _nextId++,
285
- message: message,
286
- kind: kind,
287
- duration: duration,
288
- );
289
- final wasEmpty = state.queue.isEmpty;
290
- state = NotificationState([...state.queue, notification]);
291
- if (wasEmpty) _scheduleActive();
292
- }
293
-
294
- void dismiss(int id) {
295
- final wasActive = state.active?.id == id;
296
- state = NotificationState(
297
- state.queue.where((notification) => notification.id != id).toList(),
298
- );
299
- if (wasActive) _scheduleActive();
300
- }
301
-
302
- void clear() {
303
- _timer?.cancel();
304
- _timer = null;
305
- state = const NotificationState([]);
306
- }
307
-
308
- void _scheduleActive() {
309
- _timer?.cancel();
310
- _timer = null;
311
- final notification = state.active;
312
- if (notification != null && notification.duration > Duration.zero) {
313
- _timer = Timer(notification.duration, () => dismiss(notification.id));
314
- }
315
- }
316
- }
317
-
318
- final notificationProvider =
319
- NotifierProvider<NotificationNotifier, NotificationState>(
320
- NotificationNotifier.new,
321
- );
322
- `;
323
-
324
- const flutterNotificationHostSource = `import 'package:flutter/material.dart';
325
- import 'package:flutter_riverpod/flutter_riverpod.dart';
326
-
327
- import '../state/notification_notifier.dart';
328
-
329
- class NotificationHost extends ConsumerWidget {
330
- const NotificationHost({super.key});
331
-
332
- @override
333
- Widget build(BuildContext context, WidgetRef ref) {
334
- final notification = ref.watch(notificationProvider).active;
335
- if (notification == null) return const SizedBox.shrink();
336
- final colors = Theme.of(context).colorScheme;
337
- final background = switch (notification.kind) {
338
- NotificationKind.info => colors.primary,
339
- NotificationKind.success => Colors.green.shade800,
340
- NotificationKind.warning => Colors.orange.shade900,
341
- NotificationKind.error => colors.error,
342
- };
343
- return Semantics(
344
- liveRegion: true,
345
- child: Material(
346
- color: background,
347
- borderRadius: BorderRadius.circular(12),
348
- elevation: 6,
349
- child: Padding(
350
- padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
351
- child: Row(
352
- mainAxisSize: MainAxisSize.min,
353
- children: [
354
- Flexible(
355
- child: Text(
356
- notification.message,
357
- style: const TextStyle(color: Colors.white),
358
- ),
359
- ),
360
- IconButton(
361
- tooltip: 'Dismiss notification',
362
- color: Colors.white,
363
- onPressed: () => ref
364
- .read(notificationProvider.notifier)
365
- .dismiss(notification.id),
366
- icon: const Icon(Icons.close),
367
- ),
368
- ],
369
- ),
370
- ),
371
- ),
372
- );
373
- }
374
- }
375
- `;
376
-
377
- const flutterNotificationsPublicSource = `export '../state/notification_notifier.dart'
378
- show
379
- NotificationKind,
380
- NotificationNotifier,
381
- NotificationState,
382
- notificationProvider;
383
- `;
384
-
385
- const flutterNotificationTestSource = (packageName) => `import 'package:${packageName}/app/public/notifications.dart';
386
- import 'package:flutter_test/flutter_test.dart';
387
- import 'package:flutter_riverpod/flutter_riverpod.dart';
388
-
389
- void main() {
390
- testWidgets('orders, dismisses, times, and clears notifications', (
391
- tester,
392
- ) async {
393
- final container = ProviderContainer();
394
- addTearDown(container.dispose);
395
- final notifier = container.read(notificationProvider.notifier);
396
-
397
- notifier.notify(
398
- 'first',
399
- kind: NotificationKind.warning,
400
- duration: const Duration(milliseconds: 10),
401
- );
402
- notifier.notify(
403
- 'second',
404
- kind: NotificationKind.success,
405
- duration: Duration.zero,
406
- );
407
- expect(container.read(notificationProvider).active?.message, 'first');
408
-
409
- await tester.pump(const Duration(milliseconds: 11));
410
- expect(container.read(notificationProvider).active?.message, 'second');
411
- final second = container.read(notificationProvider).active!;
412
- notifier.dismiss(second.id);
413
- expect(container.read(notificationProvider).active, isNull);
414
-
415
- notifier.notify('third', duration: Duration.zero);
416
- notifier.clear();
417
- expect(container.read(notificationProvider).queue, isEmpty);
418
- });
419
- }
420
- `;
421
-
422
- export function ownedSources(config, { flutterPackageName } = {}) {
181
+ export function ownedSources(config) {
182
+ if (!["service", "ui"].includes(config.kind)) throw new Error("Tailframe 4 owns only service and UI sources");
423
183
  const profiles = new Set(config.profiles);
424
184
  if (config.kind === "service") {
425
185
  const files = {
@@ -447,33 +207,16 @@ export function ownedSources(config, { flutterPackageName } = {}) {
447
207
  if (profiles.has("mongo")) files["deploy/mongo/10-create-users.js"] = mongoUsersSource;
448
208
  return files;
449
209
  }
450
- if (config.kind === "ui" || config.kind === "extension") {
451
- const files = {
452
- "src/core/errors.ts": uiErrorsSource,
453
- "src/core/rpc.ts": uiRpcSource,
454
- "src/platform/errors.ts": uiErrorMessagesSource,
455
- "src/platform/http.ts": uiHttpSource
456
- };
457
- if (config.kind === "extension" && profiles.has("firebase")) {
458
- files["src/app/stores/auth.store.ts"] = extensionAuthStoreSource;
459
- }
460
- if (profiles.has("notifications")) {
461
- files["src/app/stores/notification.store.ts"] = notificationStoreSource;
462
- files["src/app/components/NotificationHost.vue"] = notificationHostSource;
463
- files["src/app/__tests__/notificationStore.test.ts"] = notificationTestSource;
464
- }
465
- return files;
466
- }
467
210
  const files = {
468
- "lib/core/errors.dart": flutterErrorsSource,
469
- "lib/core/rpc.dart": flutterRpcSource
211
+ "src/core/errors.ts": uiErrorsSource,
212
+ "src/core/rpc.ts": uiRpcSource,
213
+ "src/platform/errors.ts": uiErrorMessagesSource,
214
+ "src/platform/http.ts": uiHttpSource
470
215
  };
471
216
  if (profiles.has("notifications")) {
472
- if (!flutterPackageName) throw new Error("Flutter notification sources require a package name");
473
- files["lib/app/state/notification_notifier.dart"] = flutterNotificationSource;
474
- files["lib/app/components/notification_host.dart"] = flutterNotificationHostSource;
475
- files["lib/app/public/notifications.dart"] = flutterNotificationsPublicSource;
476
- files["test/notification_notifier_test.dart"] = flutterNotificationTestSource(flutterPackageName);
217
+ files["src/app/stores/notification.store.ts"] = notificationStoreSource;
218
+ files["src/app/components/NotificationHost.vue"] = notificationHostSource;
219
+ files["src/app/__tests__/notificationStore.test.ts"] = notificationTestSource;
477
220
  }
478
221
  return files;
479
222
  }
package/src/sync.mjs CHANGED
@@ -1,26 +1,11 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
- import { loadConfig } from "./config.mjs";
4
- import { readFlutterPackageName } from "./flutter.mjs";
5
- import { OWNED_GUIDANCE_END, OWNED_GUIDANCE_START, ownedGuidance } from "./owned-guidance.mjs";
3
+ import { appRoot, loadConfig } from "./config.mjs";
4
+ import { appGuidance, OWNED_GUIDANCE_END, OWNED_GUIDANCE_START, productGuidance } from "./owned-guidance.mjs";
6
5
  import { ownedSources } from "./owned-sources.mjs";
7
6
 
8
- export function runSync(rootArgument, mode, runningVersion) {
9
- const loaded = loadConfig(rootArgument);
10
- if (loaded.errors.length) return { changed: [], errors: loaded.errors };
11
- if (loaded.config.contractVersion !== runningVersion) {
12
- return { changed: [], errors: [`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`] };
13
- }
14
- const changed = [];
15
- const errors = [];
16
- const sourceOptions = {};
17
- if (loaded.config.kind === "flutter") {
18
- const packageName = readFlutterPackageName(loaded.root);
19
- if (!packageName) return { changed, errors: ["pubspec.yaml must declare a package name"] };
20
- sourceOptions.flutterPackageName = packageName;
21
- }
22
- const guidanceFile = path.join(loaded.root, "AGENTS.md");
23
- const guidance = ownedGuidance(loaded.config);
7
+ function syncGuidance(productRoot, root, guidance, mode, changed, errors, label) {
8
+ const guidanceFile = path.join(root, "AGENTS.md");
24
9
  const existingGuidance = fs.existsSync(guidanceFile) ? fs.readFileSync(guidanceFile, "utf8") : "";
25
10
  const start = existingGuidance.indexOf(OWNED_GUIDANCE_START);
26
11
  const end = existingGuidance.indexOf(OWNED_GUIDANCE_END);
@@ -28,29 +13,51 @@ export function runSync(rootArgument, mode, runningVersion) {
28
13
  const currentSection = validSentinels
29
14
  ? existingGuidance.slice(start, end + OWNED_GUIDANCE_END.length)
30
15
  : undefined;
31
- if (currentSection !== guidance) {
32
- if (mode === "check") errors.push("AGENTS.md Tailframe-owned section differs from the canonical guidance");
33
- else {
34
- let appendix = validSentinels
35
- ? `${existingGuidance.slice(0, start)}${existingGuidance.slice(end + OWNED_GUIDANCE_END.length)}`.trim()
36
- : existingGuidance.trim();
37
- appendix = appendix.replace(/^## Product-specific appendix\s*/u, "");
38
- const next = appendix
39
- ? `${guidance}\n\n## Product-specific appendix\n\n${appendix}\n`
40
- : `${guidance}\n`;
41
- fs.writeFileSync(guidanceFile, next);
42
- changed.push("AGENTS.md");
43
- }
16
+ if (currentSection === guidance) return;
17
+ if (mode === "check") {
18
+ errors.push(`${label} AGENTS.md Tailframe-owned section differs from the canonical guidance`);
19
+ return;
20
+ }
21
+ let appendix = validSentinels
22
+ ? `${existingGuidance.slice(0, start)}${existingGuidance.slice(end + OWNED_GUIDANCE_END.length)}`.trim()
23
+ : existingGuidance.trim();
24
+ appendix = appendix.replace(/^## Product-specific appendix\s*/u, "");
25
+ const next = appendix
26
+ ? `${guidance}\n\n## Product-specific appendix\n\n${appendix}\n`
27
+ : `${guidance}\n`;
28
+ fs.mkdirSync(root, { recursive: true });
29
+ fs.writeFileSync(guidanceFile, next);
30
+ changed.push(path.relative(productRoot, guidanceFile));
31
+ }
32
+
33
+ export function runSync(rootArgument, mode, runningVersion, selectedKind) {
34
+ const loaded = loadConfig(rootArgument);
35
+ if (loaded.errors.length) return { changed: [], errors: loaded.errors };
36
+ if (loaded.config.contractVersion !== runningVersion) {
37
+ return { changed: [], errors: [`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`] };
44
38
  }
45
- for (const [relative, expected] of Object.entries(ownedSources(loaded.config, sourceOptions))) {
46
- const absolute = path.join(loaded.root, relative);
47
- const actual = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : undefined;
48
- if (actual === expected) continue;
49
- if (mode === "check") errors.push(`${relative} differs from the Tailframe ${runningVersion} canonical source`);
50
- else {
51
- fs.mkdirSync(path.dirname(absolute), { recursive: true });
52
- fs.writeFileSync(absolute, expected);
53
- changed.push(relative);
39
+ const changed = [];
40
+ const errors = [];
41
+ syncGuidance(loaded.root, loaded.root, productGuidance(loaded.config), mode, changed, errors, "root");
42
+
43
+ for (const app of loaded.config.apps.filter((candidate) => !selectedKind || candidate.kind === selectedKind)) {
44
+ const root = appRoot(loaded.root, app);
45
+ if (!fs.existsSync(root)) {
46
+ errors.push(`Missing configured application root: ${app.path}`);
47
+ continue;
48
+ }
49
+ syncGuidance(loaded.root, root, appGuidance(loaded.config, app), mode, changed, errors, app.path);
50
+ for (const [relative, expected] of Object.entries(ownedSources(app))) {
51
+ const absolute = path.join(root, relative);
52
+ const productRelative = path.join(app.path, relative);
53
+ const actual = fs.existsSync(absolute) ? fs.readFileSync(absolute, "utf8") : undefined;
54
+ if (actual === expected) continue;
55
+ if (mode === "check") errors.push(`${productRelative} differs from the Tailframe ${runningVersion} canonical source`);
56
+ else {
57
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
58
+ fs.writeFileSync(absolute, expected);
59
+ changed.push(productRelative);
60
+ }
54
61
  }
55
62
  }
56
63
  return { changed, errors };
@@ -232,51 +232,6 @@ onBeforeUnmount(() => {
232
232
  </style>
233
233
  `;
234
234
 
235
- export const extensionAuthStoreSource = `import { auth, signInWithGoogleAccessToken } from "@/platform/firebase";
236
- import { invalidateGoogleAccessToken, requestGoogleAccessToken, signOutGoogleSession } from "@/platform/extensionAuth";
237
- import { onIdTokenChanged, signOut, type User } from "firebase/auth/web-extension";
238
- import { defineStore } from "pinia";
239
- import { ref } from "vue";
240
-
241
- export const useAuthStore = defineStore("auth", () => {
242
- const firebaseUser = ref<User | null>(null);
243
- const ready = ref(false);
244
-
245
- onIdTokenChanged(auth, (value) => {
246
- firebaseUser.value = value;
247
- ready.value = true;
248
- });
249
-
250
- async function signInWithGoogle(interactive = true) {
251
- let accessToken = await requestGoogleAccessToken(interactive);
252
- try {
253
- return await signInWithGoogleAccessToken(accessToken);
254
- } catch (reason: unknown) {
255
- if (
256
- !interactive ||
257
- typeof reason !== "object" ||
258
- reason === null ||
259
- !("code" in reason) ||
260
- reason.code !== "auth/invalid-credential"
261
- )
262
- throw reason;
263
- await invalidateGoogleAccessToken(accessToken);
264
- accessToken = await requestGoogleAccessToken(true);
265
- return signInWithGoogleAccessToken(accessToken);
266
- }
267
- }
268
-
269
- async function logout() {
270
- await signOut(auth);
271
- await signOutGoogleSession();
272
- }
273
-
274
- const getIdToken = () => firebaseUser.value?.getIdToken();
275
-
276
- return { firebaseUser, ready, signInWithGoogle, logout, getIdToken };
277
- });
278
- `;
279
-
280
235
  export const configureHttpSource = `import type { Pinia } from "pinia";
281
236
  import router from "@/app/router";
282
237
  import { useAuthStore } from "@/app/stores/auth.store";
package/src/validate.mjs CHANGED
@@ -1,16 +1,15 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
3
  import { validateArchitecture } from "./architecture.mjs";
4
+ import { appRoot, loadConfig } from "./config.mjs";
4
5
  import { validateConventions } from "./conventions.mjs";
5
- import { loadConfig } from "./config.mjs";
6
6
  import { isExcepted, loadExceptions } from "./exceptions.mjs";
7
- import { validateFlutter } from "./flutter.mjs";
8
7
  import { runSync } from "./sync.mjs";
9
8
 
10
9
  const NON_EXCEPTABLE_RULES = new Set(["S8", "S9", "U5", "U6", "U7"]);
10
+ const PNPM_VERSION = "11.22.0";
11
11
 
12
12
  export function runValidate(root, kind) {
13
- if (kind === "flutter") return validateFlutter(root);
14
13
  const structural = validateArchitecture(root, kind);
15
14
  if (structural.some((error) => error.startsWith("Architecture root") || error.startsWith("Architecture kind"))) {
16
15
  return structural;
@@ -22,40 +21,87 @@ export function runValidate(root, kind) {
22
21
  return [...structural, ...conventions];
23
22
  }
24
23
 
24
+ function escapeRegex(value) {
25
+ return value.replace(/[.*+?^$()|[\]\\{}]/g, "\\$&");
26
+ }
27
+
25
28
  function validateVersionMetadata(root, runningVersion) {
26
29
  const errors = [];
27
30
  const packageFile = path.join(root, "package.json");
28
- const lockFile = path.join(root, "package-lock.json");
29
- if (!fs.existsSync(packageFile)) return ["package.json is required to pin the Tailframe build-time contract"];
31
+ const lockFile = path.join(root, "pnpm-lock.yaml");
32
+ const workspaceFile = path.join(root, "pnpm-workspace.yaml");
33
+ if (!fs.existsSync(packageFile)) return ["root package.json is required to pin the Tailframe build-time contract"];
30
34
  let manifest;
31
35
  try { manifest = JSON.parse(fs.readFileSync(packageFile, "utf8")); }
32
- catch { return ["package.json is not valid JSON"]; }
36
+ catch { return ["root package.json is not valid JSON"]; }
37
+ if (manifest.private !== true) errors.push("root package.json must be private");
38
+ if (manifest.packageManager !== `pnpm@${PNPM_VERSION}`) {
39
+ errors.push(`root package.json packageManager must be pnpm@${PNPM_VERSION}`);
40
+ }
33
41
  if (manifest.devDependencies?.["@zaaxch/tailframe"] !== runningVersion) {
34
- errors.push(`package.json must pin @zaaxch/tailframe exactly to ${runningVersion}`);
35
- }
36
- if (!fs.existsSync(lockFile)) return [...errors, "package-lock.json is required to lock the Tailframe contract"];
37
- try {
38
- const lock = JSON.parse(fs.readFileSync(lockFile, "utf8"));
39
- const rootPin = lock.packages?.[""]?.devDependencies?.["@zaaxch/tailframe"];
40
- const installed = lock.packages?.["node_modules/@zaaxch/tailframe"]?.version;
41
- if (rootPin !== runningVersion) errors.push(`package-lock.json root must pin @zaaxch/tailframe exactly to ${runningVersion}`);
42
- if (installed !== runningVersion) errors.push(`package-lock.json resolves @zaaxch/tailframe ${installed ?? "nowhere"}, expected ${runningVersion}`);
43
- } catch {
44
- errors.push("package-lock.json is not valid JSON");
42
+ errors.push(`root package.json must pin @zaaxch/tailframe exactly to ${runningVersion}`);
43
+ }
44
+ if (!fs.existsSync(workspaceFile)) errors.push("pnpm-workspace.yaml is required at the product root");
45
+ else {
46
+ const workspace = fs.readFileSync(workspaceFile, "utf8");
47
+ if (!/^\s*-\s*["']?apps\/\*["']?\s*$/mu.test(workspace)) {
48
+ errors.push("pnpm-workspace.yaml must include apps/*");
49
+ }
50
+ if (!/^injectWorkspacePackages:\s*true\s*$/mu.test(workspace)) {
51
+ errors.push("pnpm-workspace.yaml must enable injectWorkspacePackages");
52
+ }
45
53
  }
54
+ if (!fs.existsSync(lockFile)) return [...errors, "pnpm-lock.yaml is required to lock the Tailframe contract"];
55
+ const lock = fs.readFileSync(lockFile, "utf8");
56
+ const version = escapeRegex(runningVersion);
57
+ const pin = new RegExp(`['"]?@zaaxch/tailframe['"]?:\\s*\\n\\s*specifier:\\s*['"]?${version}['"]?\\s*\\n\\s*version:\\s*['"]?${version}['"]?`, "u");
58
+ const resolution = new RegExp(`['"]?@zaaxch/tailframe@${version}['"]?:`, "u");
59
+ if (!pin.test(lock)) errors.push(`pnpm-lock.yaml root importer must pin @zaaxch/tailframe exactly to ${runningVersion}`);
60
+ if (!resolution.test(lock)) errors.push(`pnpm-lock.yaml does not resolve @zaaxch/tailframe ${runningVersion}`);
46
61
  return errors;
47
62
  }
48
63
 
49
- export function runConfiguredValidate(rootArgument, runningVersion) {
64
+ function validateAppMetadata(productRoot, app) {
65
+ const errors = [];
66
+ const root = appRoot(productRoot, app);
67
+ if (!fs.existsSync(root)) return [`Missing configured application root: ${app.path}`];
68
+ if (fs.existsSync(path.join(root, "tailframe.json"))) {
69
+ errors.push(`${app.path}/tailframe.json is forbidden; the product root owns Tailframe metadata`);
70
+ }
71
+ if (fs.existsSync(path.join(root, "package-lock.json")) || fs.existsSync(path.join(root, "pnpm-lock.yaml"))) {
72
+ errors.push(`${app.path} must not contain an application lockfile`);
73
+ }
74
+ const packageFile = path.join(root, "package.json");
75
+ if (!fs.existsSync(packageFile)) errors.push(`${app.path}/package.json is required`);
76
+ else {
77
+ try {
78
+ const manifest = JSON.parse(fs.readFileSync(packageFile, "utf8"));
79
+ if (manifest.devDependencies?.["@zaaxch/tailframe"] || manifest.dependencies?.["@zaaxch/tailframe"]) {
80
+ errors.push(`${app.path}/package.json must not depend on @zaaxch/tailframe; pin it at the product root`);
81
+ }
82
+ } catch {
83
+ errors.push(`${app.path}/package.json is not valid JSON`);
84
+ }
85
+ }
86
+ return errors;
87
+ }
88
+ export function runConfiguredValidate(rootArgument, runningVersion, selectedKind) {
50
89
  const loaded = loadConfig(rootArgument);
51
90
  if (loaded.errors.length) return loaded.errors;
52
91
  const errors = [];
53
92
  if (loaded.config.contractVersion !== runningVersion) {
54
93
  errors.push(`tailframe.json requires ${loaded.config.contractVersion}, but this CLI is ${runningVersion}`);
55
94
  }
95
+ if (selectedKind && !loaded.config.apps.some((app) => app.kind === selectedKind)) {
96
+ errors.push(`tailframe.json does not declare a ${selectedKind} app`);
97
+ }
56
98
  errors.push(...validateVersionMetadata(loaded.root, runningVersion));
99
+ for (const app of loaded.config.apps) errors.push(...validateAppMetadata(loaded.root, app));
57
100
  if (errors.length) return errors;
58
- errors.push(...runValidate(loaded.root, loaded.config.kind));
59
- errors.push(...runSync(loaded.root, "check", runningVersion).errors);
101
+ const apps = selectedKind ? loaded.config.apps.filter((app) => app.kind === selectedKind) : loaded.config.apps;
102
+ for (const app of apps) {
103
+ for (const error of runValidate(appRoot(loaded.root, app), app.kind)) errors.push(`${app.path}: ${error}`);
104
+ }
105
+ errors.push(...runSync(loaded.root, "check", runningVersion, selectedKind).errors);
60
106
  return errors;
61
107
  }