create-ailk 0.20.0 → 0.22.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.
Files changed (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -1,4 +1,6 @@
1
1
  import type { WorkspaceVersionMap } from "./substitute.js";
2
+ /** The site test that fails a route neither rendered through `renderPage` nor in its `NO_STRUCTURED_DATA_ROUTES` (#8704). */
3
+ export declare const STRUCTURED_DATA_TEST_PATH = "apps/web/lib/__tests__/structured-data.test.tsx";
2
4
  export interface SyncRoutesOptions {
3
5
  /**
4
6
  * The already-scaffolded repo (absolute or relative). Must exist, be a
@@ -65,7 +67,32 @@ export interface SyncRoutesResult {
65
67
  missingMessageNamespaces: string[];
66
68
  /** Gateable surface ids the target does not carry, sorted. */
67
69
  skippedSurfaces: string[];
70
+ /**
71
+ * When `apply` added a route the template exempts in its structured-data
72
+ * test and the site's copy of that test is site-edited (its bytes differ
73
+ * from the recorded hash), each template `NO_STRUCTURED_DATA_ROUTES` entry
74
+ * the site's test lacks, verbatim, with the added routes it covers (#8704).
75
+ * Sorted by entry; empty otherwise. The site's test is never written.
76
+ */
77
+ structuredDataEntries: Array<{
78
+ routes: string[];
79
+ entry: string;
80
+ }>;
81
+ }
82
+ /** One `NO_STRUCTURED_DATA_ROUTES` element: its source text, dedented, and the routes it exempts. */
83
+ interface StructuredDataExemption {
84
+ route: string;
85
+ subtree: boolean;
86
+ text: string;
68
87
  }
88
+ /**
89
+ * The elements of `source`'s `NO_STRUCTURED_DATA_ROUTES` array literal, by a
90
+ * brace scan that skips string literals and comments. Empty when the
91
+ * file declares no such array. It assumes what the template's array holds:
92
+ * object literals of string, boolean and identifier-array values — no regex
93
+ * literal or `${…}` interpolation.
94
+ */
95
+ export declare function parseStructuredDataExemptions(source: string): StructuredDataExemption[];
69
96
  /**
70
97
  * The template's route directories, ROUTE_ROOT-relative and sorted.
71
98
  *
@@ -99,3 +126,4 @@ export declare const SYNC_ROUTES_HELP = "create-ailk sync-routes \u2014 copy tem
99
126
  * like `parseCliArgs`; at most one positional.
100
127
  */
101
128
  export declare function parseSyncRoutesArgs(argv: string[]): SyncRoutesArgs;
129
+ export {};
@@ -21,7 +21,10 @@
21
21
  * route root is copied once into a scratch dir and compared from there.
22
22
  * - Never overwrite, never delete. A route dir the site has changed is
23
23
  * reported as modified and left alone; `--apply` only creates directories
24
- * that do not exist, so a second run is a no-op.
24
+ * that do not exist, so a second run is a no-op. When the site has edited
25
+ * its structured-data test, a route `--apply` adds that the template
26
+ * exempts there is reported with the exact `NO_STRUCTURED_DATA_ROUTES`
27
+ * entry to add — the test itself is never written (#8704).
25
28
  *
26
29
  * Pure module: no `import.meta.url`, so jest can import it directly. The
27
30
  * package's own `templates/` dir is supplied by programmatic.ts.
@@ -35,9 +38,12 @@ import { appendEnvRows } from "./lib/declared-env.js";
35
38
  import { ENV_CATALOG } from "./lib/env-catalog.js";
36
39
  import { substituteMarkers } from "./substitute.js";
37
40
  import { GATEABLE_SURFACES, NON_ROUTE_DIRS, ROUTE_DEPENDENCIES, ROUTE_MESSAGE_NAMESPACES, ROUTE_SINCE, surfaceForRouteDir, } from "./surfaces.js";
41
+ import { TEMPLATE_RECORD_PATH, parseTemplateRecord, sha256Hex } from "./template-record.js";
38
42
  import { SPOKE_SEAM_FILES, isSpokeExcluded, readSpokeShape, } from "./topology.js";
39
43
  /** Template-relative prefix every `apps/web` route directory sits under. */
40
44
  const ROUTE_ROOT = "apps/web/app/[locale]";
45
+ /** The site test that fails a route neither rendered through `renderPage` nor in its `NO_STRUCTURED_DATA_ROUTES` (#8704). */
46
+ export const STRUCTURED_DATA_TEST_PATH = "apps/web/lib/__tests__/structured-data.test.tsx";
41
47
  /**
42
48
  * `<surface id>/<entry name>` pairs that live directly under a GATEABLE
43
49
  * surface's top-level directory but belong to a DIFFERENT module (#5366).
@@ -50,6 +56,115 @@ const ROUTE_ROOT = "apps/web/app/[locale]";
50
56
  const NESTED_FOREIGN_MODULE_ENTRIES = new Set([
51
57
  "projects/apply",
52
58
  ]);
59
+ /**
60
+ * The elements of `source`'s `NO_STRUCTURED_DATA_ROUTES` array literal, by a
61
+ * brace scan that skips string literals and comments. Empty when the
62
+ * file declares no such array. It assumes what the template's array holds:
63
+ * object literals of string, boolean and identifier-array values — no regex
64
+ * literal or `${…}` interpolation.
65
+ */
66
+ export function parseStructuredDataExemptions(source) {
67
+ const decl = source.indexOf("const NO_STRUCTURED_DATA_ROUTES");
68
+ if (decl === -1)
69
+ return [];
70
+ const open = /=\s*\[/.exec(source.slice(decl));
71
+ if (open === null)
72
+ return [];
73
+ const out = [];
74
+ let depth = 0;
75
+ let start = -1;
76
+ for (let i = decl + open.index + open[0].length; i < source.length; i++) {
77
+ const ch = source[i];
78
+ if (ch === '"' || ch === "'" || ch === "`") {
79
+ for (i++; i < source.length && source[i] !== ch; i++) {
80
+ if (source[i] === "\\")
81
+ i++;
82
+ }
83
+ continue;
84
+ }
85
+ if (ch === "/" && source[i + 1] === "/") {
86
+ const eol = source.indexOf("\n", i);
87
+ i = eol === -1 ? source.length : eol;
88
+ continue;
89
+ }
90
+ if (ch === "/" && source[i + 1] === "*") {
91
+ const end = source.indexOf("*/", i + 2);
92
+ i = end === -1 ? source.length : end + 1;
93
+ continue;
94
+ }
95
+ if (ch === "{") {
96
+ if (depth === 0)
97
+ start = i;
98
+ depth++;
99
+ }
100
+ else if (ch === "}") {
101
+ depth--;
102
+ if (depth === 0 && start !== -1) {
103
+ const raw = source.slice(start, i + 1) + (source[i + 1] === "," ? "," : "");
104
+ const route = /\broute:\s*["']([^"']+)["']/.exec(raw)?.[1];
105
+ if (route !== undefined) {
106
+ const indent = /[ \t]*$/.exec(source.slice(source.lastIndexOf("\n", start) + 1, start))?.[0] ?? "";
107
+ const text = raw
108
+ .split("\n")
109
+ .map((line) => (line.startsWith(indent) ? line.slice(indent.length) : line))
110
+ .join("\n");
111
+ out.push({ route, subtree: /\bsubtree:\s*true\b/.test(raw), text });
112
+ }
113
+ start = -1;
114
+ }
115
+ }
116
+ else if (ch === "]" && depth === 0) {
117
+ break;
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+ /**
123
+ * The first exemption covering `route` by the structured-data test's route
124
+ * rule (`route` itself, or under a `subtree` entry). An entry's `requires`
125
+ * strings are not checked: they key only the standard spoke checkout pages
126
+ * (#8748), which `surfaceForRouteDir` leaves unclassified, so no `--apply`
127
+ * ever adds them.
128
+ */
129
+ function exemptionFor(exemptions, route) {
130
+ return exemptions.find((e) => route === e.route || (e.subtree && route.startsWith(`${e.route}/`)));
131
+ }
132
+ /**
133
+ * The template `NO_STRUCTURED_DATA_ROUTES` entries the site's structured-data
134
+ * test must gain for `added` routes (#8704). Empty unless that test is
135
+ * recorded and site-edited — an untouched copy is `create-ailk update`'s to
136
+ * bring forward, and an unrecorded one cannot be told apart from an edit.
137
+ */
138
+ function structuredDataEntriesFor(targetAbs, templatesDir, added) {
139
+ const sitePath = join(targetAbs, STRUCTURED_DATA_TEST_PATH);
140
+ const templatePath = join(templatesDir, STRUCTURED_DATA_TEST_PATH);
141
+ const recordPath = join(targetAbs, TEMPLATE_RECORD_PATH);
142
+ if (added.length === 0 || !existsSync(sitePath) || !existsSync(templatePath) || !existsSync(recordPath)) {
143
+ return [];
144
+ }
145
+ let recordedHash;
146
+ try {
147
+ recordedHash = parseTemplateRecord(readFileSync(recordPath, "utf8")).files[STRUCTURED_DATA_TEST_PATH]?.sha256;
148
+ }
149
+ catch {
150
+ return [];
151
+ }
152
+ const siteBytes = readFileSync(sitePath);
153
+ if (recordedHash === undefined || sha256Hex(siteBytes) === recordedHash)
154
+ return [];
155
+ const templateExemptions = parseStructuredDataExemptions(readFileSync(templatePath, "utf8"));
156
+ const siteExemptions = parseStructuredDataExemptions(siteBytes.toString("utf8"));
157
+ const byEntry = new Map();
158
+ for (const route of [...added].sort()) {
159
+ const needed = exemptionFor(templateExemptions, route);
160
+ if (needed === undefined || exemptionFor(siteExemptions, route) !== undefined)
161
+ continue;
162
+ byEntry.set(needed.text, [...(byEntry.get(needed.text) ?? []), route]);
163
+ }
164
+ return [...byEntry.entries()]
165
+ .sort(([a], [b]) => a.localeCompare(b))
166
+ .map(([entry, routes]) => ({ routes, entry }));
167
+ }
53
168
  /**
54
169
  * The template's route directories, ROUTE_ROOT-relative and sorted.
55
170
  *
@@ -276,6 +391,7 @@ export function syncRoutes(opts) {
276
391
  appliedDependencies,
277
392
  missingMessageNamespaces,
278
393
  skippedSurfaces,
394
+ structuredDataEntries: structuredDataEntriesFor(targetAbs, opts.templatesDir, applied),
279
395
  };
280
396
  }
281
397
  /**
@@ -355,6 +471,13 @@ export function formatSyncRoutesReport(result, apply) {
355
471
  lines.push(`[create-ailk] Also required — message namespace(s) not found in apps/web/messages/en.json (add manually, not auto-merged):`);
356
472
  lines.push(...result.missingMessageNamespaces.map((ns) => ` ${ns}`));
357
473
  }
474
+ if (result.structuredDataEntries.length > 0) {
475
+ lines.push(`[create-ailk] Also required — ${STRUCTURED_DATA_TEST_PATH} is edited here, so add these NO_STRUCTURED_DATA_ROUTES entries to it by hand (not written):`);
476
+ for (const { routes, entry } of result.structuredDataEntries) {
477
+ lines.push(` for ${routes.join(", ")}:`);
478
+ lines.push(...entry.split("\n").map((line) => ` ${line}`));
479
+ }
480
+ }
358
481
  if (result.missing.length > 0 && !apply) {
359
482
  lines.push("Run `create-ailk sync-routes <target> --apply` to copy the missing routes.");
360
483
  }
@@ -3,9 +3,10 @@ export declare const TEMPLATE_RECORD_PATH = ".ailk-template-files.json";
3
3
  /**
4
4
  * The app paths a generator rewrites after `copyTemplate` lands its copy
5
5
  * (§1): the nav, the site chrome, the site fonts, the paid-sections
6
- * registry, and the four spoke seams. Composed from each writer's own
7
- * exported path constant, so a renamed constant carries its path along
8
- * here too (#8522 D2).
6
+ * registry, the four spoke seams, and a commerce spoke's checkout config,
7
+ * which the checkout writer stamps and the site owns from then on (#8748
8
+ * D2). Composed from each writer's own exported path constant, so a renamed
9
+ * constant carries its path along here too (#8522 D2).
9
10
  */
10
11
  export declare const REGENERATED_APP_PATHS: readonly string[];
11
12
  /**
@@ -21,6 +21,7 @@ import { join } from "node:path";
21
21
  import { PAID_SECTIONS_PATH } from "./paid-sections.js";
22
22
  import { SITE_CHROME_PATH } from "./site-chrome.js";
23
23
  import { SITE_FONTS_PATH } from "./site-fonts.js";
24
+ import { SPOKE_CHECKOUT_CONFIG_PATH } from "./spoke-checkout.js";
24
25
  import { NAV_CONFIG_PATH } from "./surfaces.js";
25
26
  import { SPOKE_SEAM_FILES } from "./topology.js";
26
27
  /** Where the per-file record lives, beside `.ailk-site-manifest.json`. */
@@ -28,9 +29,10 @@ export const TEMPLATE_RECORD_PATH = ".ailk-template-files.json";
28
29
  /**
29
30
  * The app paths a generator rewrites after `copyTemplate` lands its copy
30
31
  * (§1): the nav, the site chrome, the site fonts, the paid-sections
31
- * registry, and the four spoke seams. Composed from each writer's own
32
- * exported path constant, so a renamed constant carries its path along
33
- * here too (#8522 D2).
32
+ * registry, the four spoke seams, and a commerce spoke's checkout config,
33
+ * which the checkout writer stamps and the site owns from then on (#8748
34
+ * D2). Composed from each writer's own exported path constant, so a renamed
35
+ * constant carries its path along here too (#8522 D2).
34
36
  */
35
37
  export const REGENERATED_APP_PATHS = [
36
38
  NAV_CONFIG_PATH,
@@ -38,6 +40,7 @@ export const REGENERATED_APP_PATHS = [
38
40
  SITE_FONTS_PATH,
39
41
  PAID_SECTIONS_PATH,
40
42
  ...SPOKE_SEAM_FILES,
43
+ SPOKE_CHECKOUT_CONFIG_PATH,
41
44
  ];
42
45
  const REGENERATED_SET = new Set(REGENERATED_APP_PATHS);
43
46
  const RECORDABLE_APP_ROOT = /^apps\/(web|api|mcp)\//;
package/dist/topology.js CHANGED
@@ -174,6 +174,29 @@ export function isRemovableClosureMember(path, opts = {}) {
174
174
  return (p.split("/").includes("__tests__") ||
175
175
  prefixes.some((prefix) => underPrefix(p, prefix)));
176
176
  }
177
+ /**
178
+ * The relative module specifiers a file reaches that are NOT static imports:
179
+ * a jest module reference (`jest.mock("../auth")`, `doMock`, `unmock`,
180
+ * `setMock`, `requireActual`, `requireMock`, `createMockFromModule`) or a
181
+ * `require(...)`. A suite that mocks a seed by path ships a dangling
182
+ * reference on a spoke exactly as one that imports it would (#8758), yet the
183
+ * shared import walk (`relativeImportsOf`) sees only `import`/`export ... from`
184
+ * and `import()` — it is shared with the module-slice lints, so the extra
185
+ * forms are read here rather than widening it for them.
186
+ */
187
+ function referencedModulesOf(absPath) {
188
+ const specs = relativeImportsOf(absPath);
189
+ let src;
190
+ try {
191
+ src = readFileSync(absPath, "utf8");
192
+ }
193
+ catch {
194
+ return specs;
195
+ }
196
+ for (const m of src.matchAll(/\b(?:jest\s*\.\s*(?:mock|doMock|unmock|setMock|requireActual|requireMock|createMockFromModule)|require)\s*\(\s*["'`](\.[^"'`]*)["'`]/g))
197
+ specs.push(m[1]);
198
+ return specs;
199
+ }
177
200
  /**
178
201
  * Every path a spoke does not carry, computed over the built `templates/`
179
202
  * tree: the removed roots, the seeds that apply, and every source file under
@@ -209,7 +232,7 @@ export function spokeExclusions(templatesDir, opts) {
209
232
  for (const file of candidates) {
210
233
  if (closed.has(file))
211
234
  continue;
212
- for (const spec of relativeImportsOf(join(templatesDir, file))) {
235
+ for (const spec of referencedModulesOf(join(templatesDir, file))) {
213
236
  const target = resolveRelativeImport(file, spec, candidates);
214
237
  if (target !== undefined && closed.has(target)) {
215
238
  closed.add(file);
@@ -899,6 +922,7 @@ export function renderSpokeHardFloors(opts) {
899
922
  " reason: Stripe webhook route — payment state requires human review",
900
923
  " - paths:",
901
924
  ' - "apps/web/app/api/checkout/**"',
925
+ ' - "apps/web/lib/checkout.ts"',
902
926
  ' - "apps/web/lib/checkout-config.ts"',
903
927
  ' - ".github/workflows/checkout-e2e.yml"',
904
928
  " reason: Spoke checkout — the buy route, the price map and the checkout e2e workflow require human review",
package/dist/update.d.ts CHANGED
@@ -31,6 +31,11 @@ export interface UpdateResult {
31
31
  path: string;
32
32
  reason: string;
33
33
  }>;
34
+ /** Per file left under review or skipped, the template tests importing it that stay at the site's version (#8704). */
35
+ heldBack: Array<{
36
+ file: string;
37
+ tests: string[];
38
+ }>;
34
39
  backfilled?: {
35
40
  version: string;
36
41
  files: number;
@@ -43,6 +48,6 @@ export interface UpdateArgs {
43
48
  target: string | undefined;
44
49
  help: boolean;
45
50
  }
46
- export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file whose template copy changed is\noverwritten. An edited file whose template copy also changed is three-way\nmerged with `git` (git must be on PATH); a conflict leaves the file alone\nand is listed under \"Needs review\" \u2014 nothing else in the run is held back.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. Commit before running, and review\nthe diff after.\n";
51
+ export declare const UPDATE_HELP = "create-ailk update \u2014 bring template fixes into a site's copied app code.\n\nUsage:\n create-ailk update [<target-dir>]\n\nArguments:\n <target-dir> The existing site. Defaults to the current dir.\n\nOptions:\n --help, -h Print this message.\n\nReads .ailk-template-files.json (written beside .ailk-site-manifest.json by\nevery template scaffold) to tell an untouched copy from an edit. An\nuntouched apps/web, apps/api or apps/mcp file whose template copy changed is\noverwritten. An edited file whose template copy also changed is three-way\nmerged with `git` (git must be on PATH); a conflict leaves the file alone\nand is listed under \"Needs review\". Every template test that imports a file\nleft under \"Needs review\" or \"Skipped\" (by a relative import) is held at the\nsite's version with it, its record entry is not advanced, and it is listed\nunder \"Held back with <file>\" \u2014 reconcile the file, then run update again to\nbring its tests forward. Every other file in the run still updates.\nA site with no record gets one on its first run, from the version its\n.ailk-site-manifest.json already records. Commit before running, and review\nthe diff after.\n";
47
52
  /** Parse the argv AFTER the `update` word. Strict `node:util` parseArgs, like `parseAdoptArgs`. */
48
53
  export declare function parseUpdateArgs(argv: string[]): UpdateArgs;
package/dist/update.js CHANGED
@@ -9,10 +9,12 @@
9
9
  * version's copy — read from this package's own `templates/` when that is
10
10
  * the running version, else re-materialised from the published npm tarball
11
11
  * (`lib/fetch-template-snapshot.ts`) — and the running version's copy. A
12
- * conflict leaves the file untouched and is listed under "Needs review". A
13
- * site with no record gets one backfilled from `.ailk-site-manifest.json`'s
14
- * `patternVersion` on its first run, then updates normally. Every refusal
15
- * throws before the first write.
12
+ * conflict leaves the file untouched and is listed under "Needs review". Every
13
+ * template test that imports a file left under review or skipped is held at
14
+ * the site's version with it, so a run never leaves a test newer than the
15
+ * code it exercises (#8704). A site with no record gets one backfilled from
16
+ * `.ailk-site-manifest.json`'s `patternVersion` on its first run, then
17
+ * updates normally. Every refusal throws before the first write.
16
18
  *
17
19
  * Pure module: no `import.meta.url` — the CLI supplies `templatesDir`, the
18
20
  * `adopt.ts` / `add-route.ts` split.
@@ -26,6 +28,7 @@ import { fetchTemplateSnapshot } from "./lib/fetch-template-snapshot.js";
26
28
  import { mergeThreeWay } from "./lib/merge-three-way.js";
27
29
  import { ParseError } from "./parse-args.js";
28
30
  import { SITE_ROSTER_PATH } from "./site-roster.js";
31
+ import { SPOKE_CHECKOUT_TEMPLATE_PATHS } from "./spoke-checkout.js";
29
32
  import { excludedRoutePaths } from "./surfaces.js";
30
33
  import { TEMPLATE_RECORD_PATH, isRecordable, parseTemplateRecord, serializeTemplateRecord, sha256Hex, } from "./template-record.js";
31
34
  import { TopologyError, isSpokeExcluded, readSpokeShape, spokeExclusions, } from "./topology.js";
@@ -132,6 +135,50 @@ function containsKnownPath(prefix, knownPaths) {
132
135
  }
133
136
  return false;
134
137
  }
138
+ /** A template test file: a `*.test.*` / `*.spec.*` module, or anything under `__tests__/`. */
139
+ const TEST_FILE = /(^|\/)__tests__\/|\.(test|spec)\.[cm]?[jt]sx?$/;
140
+ /** Module specifiers `import`/`export … from`, `import()`, `require()` and `jest.mock()` name. */
141
+ const IMPORT_SPECIFIER = /\bfrom\s*["']([^"']+)["']|\bimport\s*["']([^"']+)["']|\b(?:import|require|jest\.mock|jest\.requireActual)\s*\(\s*["']([^"']+)["']/g;
142
+ const RESOLVE_EXTENSIONS = [".ts", ".tsx", ".js", ".jsx", ".mts", ".cts", ".mjs", ".cjs"];
143
+ /**
144
+ * The record-style paths a relative specifier in `fromPath` can resolve to:
145
+ * the path as written, with each source extension, a `.js`-family suffix
146
+ * swapped for its TypeScript source, and as a directory's `index.*`.
147
+ */
148
+ function relativeImportCandidates(fromPath, specifier) {
149
+ const parts = fromPath.split("/").slice(0, -1);
150
+ for (const segment of specifier.split("/")) {
151
+ if (segment === "" || segment === ".")
152
+ continue;
153
+ if (segment === "..")
154
+ parts.pop();
155
+ else
156
+ parts.push(segment);
157
+ }
158
+ const target = parts.join("/");
159
+ const out = [target, ...RESOLVE_EXTENSIONS.map((ext) => `${target}${ext}`)];
160
+ const jsSuffix = /\.([cm]?)jsx?$/.exec(target);
161
+ if (jsSuffix !== null) {
162
+ const stem = target.slice(0, -jsSuffix[0].length);
163
+ out.push(`${stem}.${jsSuffix[1]}ts`, `${stem}.tsx`);
164
+ }
165
+ out.push(...RESOLVE_EXTENSIONS.map((ext) => `${target}/index${ext}`));
166
+ return out;
167
+ }
168
+ /** The files of `held` that `text`, the source of `fromPath`, imports by relative specifier. */
169
+ function importedHeldFiles(fromPath, text, held) {
170
+ const out = new Set();
171
+ for (const match of text.matchAll(IMPORT_SPECIFIER)) {
172
+ const specifier = match[1] ?? match[2] ?? match[3];
173
+ if (specifier === undefined || !specifier.startsWith("."))
174
+ continue;
175
+ for (const candidate of relativeImportCandidates(fromPath, specifier)) {
176
+ if (held.has(candidate))
177
+ out.add(candidate);
178
+ }
179
+ }
180
+ return [...out];
181
+ }
135
182
  export async function updateSite(opts) {
136
183
  const targetDir = resolve(opts.targetDir);
137
184
  const version = opts.version;
@@ -202,9 +249,13 @@ export async function updateSite(opts) {
202
249
  }
203
250
  const excludedRoutes = excludedRoutePaths(header.surfaces);
204
251
  const spokeExclusionList = spoke !== undefined ? spokeExclusions(opts.templatesDir, spoke) : undefined;
252
+ // #8748 D3 — the standard spoke checkout is a commerce spoke's alone.
253
+ const sellsAsSpoke = spoke !== undefined && header.modules.includes("commerce");
205
254
  const outOfScope = (destRel) => {
206
255
  if (isExcluded(destRel, excludedRoutes))
207
256
  return true;
257
+ if (!sellsAsSpoke && isExcluded(destRel, SPOKE_CHECKOUT_TEMPLATE_PATHS))
258
+ return true;
208
259
  if (spoke !== undefined && spokeExclusionList !== undefined) {
209
260
  return isSpokeExcluded(destRel, { ...spoke, exclusions: spokeExclusionList });
210
261
  }
@@ -391,37 +442,85 @@ export async function updateSite(opts) {
391
442
  nextFiles[path] = { sha256: sha256Hex(newBytes), version };
392
443
  added.push(path);
393
444
  }
445
+ // ── Hold back the template tests of every file left alone (#8704) ────
446
+ // A test this run would move forward (update, merge, add, remove, or
447
+ // advance as current) that imports a file under review or skipped stays
448
+ // exactly as the site has it, and its row keeps its recorded version — so
449
+ // the tests on disk always match the code they exercise. The imports are
450
+ // read from both the site's copy and the running template's: either one
451
+ // exercising the held file is enough. A held test-support file
452
+ // (`__tests__/fixtures.ts`) holds the tests importing it in turn, until no
453
+ // further test is held.
454
+ const heldFiles = new Set([...review, ...skipped].map((row) => row.path));
455
+ const heldBackByFile = new Map();
456
+ const heldTests = new Set();
457
+ const moving = [...updated, ...merged, ...added, ...removed, ...current]
458
+ .filter((path) => TEST_FILE.test(path))
459
+ .sort();
460
+ for (let grew = heldFiles.size > 0; grew;) {
461
+ grew = false;
462
+ for (const path of moving) {
463
+ if (heldTests.has(path))
464
+ continue;
465
+ const sources = [];
466
+ const abs = join(targetDir, ...path.split("/"));
467
+ if (existsSync(abs))
468
+ sources.push(readFileSync(abs, "utf8"));
469
+ const newBytes = newMap.get(path);
470
+ if (newBytes !== undefined)
471
+ sources.push(Buffer.from(newBytes).toString("utf8"));
472
+ const imported = new Set(sources.flatMap((text) => importedHeldFiles(path, text, heldFiles)));
473
+ if (imported.size === 0)
474
+ continue;
475
+ heldTests.add(path);
476
+ heldFiles.add(path);
477
+ grew = true;
478
+ for (const file of imported) {
479
+ heldBackByFile.set(file, [...(heldBackByFile.get(file) ?? []), path]);
480
+ }
481
+ }
482
+ }
483
+ for (const path of heldTests) {
484
+ const recorded = record.files[path];
485
+ if (recorded !== undefined)
486
+ nextFiles[path] = recorded;
487
+ else
488
+ delete nextFiles[path];
489
+ }
490
+ const notHeld = (path) => !heldTests.has(path);
491
+ const heldBack = [...heldBackByFile.entries()]
492
+ .sort(([a], [b]) => a.localeCompare(b))
493
+ .map(([file, tests]) => ({ file, tests }));
394
494
  // ── Apply: writes and removals, then the record (D4 Order) ───────────
395
- for (const write of writes) {
495
+ for (const write of writes.filter((w) => notHeld(w.path))) {
396
496
  const abs = join(targetDir, ...write.path.split("/"));
397
497
  mkdirSync(dirname(abs), { recursive: true });
398
498
  writeFileSync(abs, write.bytes);
399
499
  }
400
- for (const path of deletes) {
500
+ for (const path of deletes.filter(notHeld)) {
401
501
  rmSync(join(targetDir, ...path.split("/")));
402
502
  }
403
503
  const backfilled = backfillPatternVersion !== undefined
404
504
  ? { version: backfillPatternVersion, files: Object.keys(record.files).length }
405
505
  : undefined;
406
- const recordChanged = backfilled !== undefined ||
407
- updated.length > 0 ||
408
- merged.length > 0 ||
409
- added.length > 0 ||
410
- removed.length > 0 ||
411
- current.length > 0;
506
+ const moved = {
507
+ updated: updated.filter(notHeld),
508
+ merged: merged.filter(notHeld),
509
+ added: added.filter(notHeld),
510
+ removed: removed.filter(notHeld),
511
+ current: current.filter(notHeld),
512
+ };
513
+ const recordChanged = backfilled !== undefined || Object.values(moved).some((paths) => paths.length > 0);
412
514
  if (recordChanged) {
413
515
  writeFileSync(recordPath, serializeTemplateRecord({ schema: 1, projectName: record.projectName, files: nextFiles }));
414
516
  }
415
517
  return {
416
518
  targetDir,
417
519
  version,
418
- updated,
419
- merged,
420
- added,
421
- removed,
422
- current,
520
+ ...moved,
423
521
  review,
424
522
  skipped,
523
+ heldBack,
425
524
  ...(backfilled !== undefined ? { backfilled } : {}),
426
525
  };
427
526
  }
@@ -448,6 +547,10 @@ export function formatUpdateReport(result) {
448
547
  lines.push(`Needs review (${result.review.length}):`);
449
548
  lines.push(...result.review.map((row) => ` ${row.path} — ${row.reason}`));
450
549
  }
550
+ for (const { file, tests } of result.heldBack) {
551
+ lines.push(`Held back with ${file} (${tests.length}):`);
552
+ lines.push(...tests.map((path) => ` ${path}`));
553
+ }
451
554
  if (result.skipped.length > 0) {
452
555
  lines.push(`Skipped (${result.skipped.length}):`);
453
556
  lines.push(...result.skipped.map((row) => ` ${row.path} — ${row.reason}`));
@@ -481,7 +584,11 @@ every template scaffold) to tell an untouched copy from an edit. An
481
584
  untouched apps/web, apps/api or apps/mcp file whose template copy changed is
482
585
  overwritten. An edited file whose template copy also changed is three-way
483
586
  merged with \`git\` (git must be on PATH); a conflict leaves the file alone
484
- and is listed under "Needs review" — nothing else in the run is held back.
587
+ and is listed under "Needs review". Every template test that imports a file
588
+ left under "Needs review" or "Skipped" (by a relative import) is held at the
589
+ site's version with it, its record entry is not advanced, and it is listed
590
+ under "Held back with <file>" — reconcile the file, then run update again to
591
+ bring its tests forward. Every other file in the run still updates.
485
592
  A site with no record gets one on its first run, from the version its
486
593
  .ailk-site-manifest.json already records. Commit before running, and review
487
594
  the diff after.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-ailk",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Scaffolder for AI Launch Kit — `pnpm create ailk@latest <dir>` produces a starter repo consuming @working-theory/* from npm (per ADR 0007).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -28,8 +28,8 @@
28
28
  "tar": "^7.5.21"
29
29
  },
30
30
  "devDependencies": {
31
- "@working-theory/schema": "0.41.0",
32
- "@working-theory/templates": "0.41.0"
31
+ "@working-theory/schema": "0.43.0",
32
+ "@working-theory/templates": "0.43.0"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "_base",
3
3
  "requires": [],
4
- "$comment": "The BASE LAYER (#5618 D1) — not a template. Every declared template reaches this tree by `extends: \"_base\"`; scaffold() installs it through installBundleTree before any module bundle and before the template's own tree, only when --template is named. It carries the seed content every template brings itself and no module owns: /pricing, /privacy and /terms (spec 5462 D16). No installMap: every file here is unmapped and lands at its own path under the scaffold root's content/, so a template overrides a base page by shipping the same path. Never published as a registry bundle; the leading underscore is the skip signal for the bundle producer and the site-templates/ directory scans.",
5
- "postInstall": "Base layer installed: seed copy for /pricing, /privacy and /terms is in content/en/ - replace the placeholder copy before you launch."
4
+ "$comment": "The BASE LAYER (#5618 D1) — not a template. Every declared template reaches this tree by `extends: \"_base\"`; scaffold() installs it through installBundleTree before any module bundle and before the template's own tree, only when --template is named. It carries the seed content every template brings itself and no module owns: /pricing, /privacy, /terms (spec 5462 D16) and /working-agreement (#8661 D10). No installMap: every file here is unmapped and lands at its own path under the scaffold root's content/, so a template overrides a base page by shipping the same path. Never published as a registry bundle; the leading underscore is the skip signal for the bundle producer and the site-templates/ directory scans.",
5
+ "postInstall": "Base layer installed: seed copy for /pricing, /privacy, /terms and /working-agreement is in content/en/ - replace the placeholder copy before you launch."
6
6
  }
@@ -0,0 +1,78 @@
1
+ ---
2
+ type: "WorkingAgreementPage"
3
+ slug: "working-agreement"
4
+ title: "Working Agreement"
5
+ description: "Placeholder working agreement for Your Company. Replace every section with how you actually work with a client before you launch."
6
+ sections:
7
+ [
8
+ {
9
+ "id": "working-agreement-document",
10
+ "type": "document-chassis",
11
+ "data":
12
+ {
13
+ "headline": "Working Agreement",
14
+ "layout": "article",
15
+ "updated": "Last updated {Month Day, Year}",
16
+ "navGroupLabel": "Contents",
17
+ "sections":
18
+ [
19
+ {
20
+ "id": "placeholder-notice",
21
+ "heading": "This is placeholder text",
22
+ "blocks":
23
+ [
24
+ {
25
+ "kind": "paragraph",
26
+ "text": "This page is starter content that shipped with your site. It is not a contract, it binds nobody, and it is not legal advice. Replace every section below with how Your Company actually works with a client, and have someone qualified read it before you publish.",
27
+ },
28
+ {
29
+ "kind": "paragraph",
30
+ "text": "Until you do, leave this notice in place so nobody mistakes the page for a finished agreement.",
31
+ },
32
+ ],
33
+ },
34
+ {
35
+ "id": "what-to-write-here",
36
+ "heading": "What belongs on this page",
37
+ "blocks":
38
+ [
39
+ {
40
+ "kind": "paragraph",
41
+ "text": "A working agreement sets out how an engagement runs day to day. Write each part about the work you actually do, and keep the sentences short enough that a client can follow them.",
42
+ },
43
+ {
44
+ "kind": "list",
45
+ "listStyle": "number",
46
+ "items":
47
+ [
48
+ "How you work with a client, from the first conversation to the last delivery.",
49
+ "What each side provides, and when.",
50
+ "Timelines, and how and how often you communicate.",
51
+ "How changes and cancellations are handled.",
52
+ "How the agreement ends, and what happens to the work when it does.",
53
+ ],
54
+ },
55
+ ],
56
+ },
57
+ {
58
+ "id": "contact",
59
+ "heading": "Questions about this agreement",
60
+ "blocks":
61
+ [
62
+ {
63
+ "kind": "paragraph",
64
+ "text": "Replace this with the address you actually monitor — for example hello@example.com — so a client with a question about the agreement has somewhere to send it.",
65
+ },
66
+ {
67
+ "kind": "paragraph",
68
+ "text": "Replace this with the date this agreement last changed, and say what happens when it changes again.",
69
+ },
70
+ ],
71
+ },
72
+ ],
73
+ },
74
+ },
75
+ ]
76
+ ---
77
+
78
+ {/* Rendered from frontmatter `sections[]`. Placeholder seed content — this is not a working agreement and not legal advice. Replace every section before you launch. */}