@starci/hfs 5.3.0 → 5.5.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 (137) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/README.md +5 -3
  3. package/cache/test-family.d.mts +1 -1
  4. package/cache/test-family.mjs +20 -15
  5. package/codemod/account-doors.mjs +7 -1
  6. package/codemod/app-prettier.d.mts +5 -0
  7. package/codemod/app-prettier.mjs +27 -0
  8. package/codemod/explicit-surface.mjs +1 -25
  9. package/codemod/main.mjs +14 -5
  10. package/codemod/module-definition.d.mts +10 -3
  11. package/codemod/module-definition.mjs +32 -17
  12. package/codemod/seeds-layout.d.mts +36 -0
  13. package/codemod/seeds-layout.mjs +113 -0
  14. package/codemod/sops-rules.d.mts +1 -1
  15. package/codemod/sops-rules.mjs +8 -3
  16. package/lint/run.d.mts +5 -4
  17. package/lint/run.mjs +53 -27
  18. package/package.json +1 -1
  19. package/release/lint-report.mjs +4 -3
  20. package/runtime/knowledge/hfs/canon-pins.yaml +12 -12
  21. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  22. package/runtime/knowledge/hfs/rules.yaml +15 -2
  23. package/runtime/knowledge/hfs/slots.yaml +15 -18
  24. package/runtime/modules/kernel/failure-codes.yaml +64 -3
  25. package/runtime/scripts/hfs/architecture/root-packages.mjs +1 -1
  26. package/runtime/scripts/hfs/check.mjs +4 -1
  27. package/runtime/scripts/hfs/coverage-scope.d.mts +15 -0
  28. package/runtime/scripts/hfs/coverage-scope.mjs +26 -0
  29. package/runtime/scripts/hfs/db-squash.mjs +9 -2
  30. package/runtime/scripts/hfs/declaration-shape.mjs +3 -1
  31. package/runtime/scripts/hfs/rules/app-root.mjs +4 -1
  32. package/runtime/scripts/hfs/rules/ci-standard.mjs +5 -4
  33. package/runtime/scripts/hfs/rules/coverage-below.mjs +12 -4
  34. package/runtime/scripts/hfs/rules/database-config.mjs +8 -7
  35. package/runtime/scripts/hfs/rules/database-constants.d.mts +2 -1
  36. package/runtime/scripts/hfs/rules/database-constants.mjs +2 -1
  37. package/runtime/scripts/hfs/rules/database.d.mts +2 -2
  38. package/runtime/scripts/hfs/rules/database.mjs +7 -6
  39. package/runtime/scripts/hfs/rules/docker.mjs +24 -7
  40. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -1
  41. package/runtime/scripts/hfs/rules/monorepo.mjs +1 -1
  42. package/runtime/scripts/hfs/rules/seeds.d.mts +21 -0
  43. package/runtime/scripts/hfs/rules/seeds.mjs +194 -0
  44. package/runtime/scripts/hfs/rules/stacks.mjs +8 -4
  45. package/runtime/scripts/hfs/slots.d.mts +2 -0
  46. package/runtime/scripts/hfs/slots.mjs +1 -0
  47. package/runtime/scripts/hfs/stack-seeds-config.d.mts +88 -0
  48. package/runtime/scripts/hfs/stack-seeds-config.mjs +239 -0
  49. package/runtime/scripts/hfs/view.d.mts +25 -0
  50. package/runtime/scripts/hfs/view.mjs +41 -0
  51. package/scaffold/add-app.mjs +2 -2
  52. package/scaffold/add-cli-lite.mjs +41 -14
  53. package/scaffold/add-module.mjs +6 -3
  54. package/scaffold/lite-exports.mjs +1 -1
  55. package/scaffold/module-registry.mjs +10 -8
  56. package/src/main.mjs +8 -8
  57. package/stack/deploy.d.mts +3 -0
  58. package/stack/deploy.mjs +4 -1
  59. package/stack/local-up.mjs +25 -3
  60. package/stack/seeds.d.mts +29 -0
  61. package/stack/seeds.mjs +197 -0
  62. package/sync/canon-layout.d.mts +38 -0
  63. package/sync/canon-layout.mjs +62 -0
  64. package/sync/ci-commands.d.mts +2 -1
  65. package/sync/ci-commands.mjs +10 -7
  66. package/sync/compile-guard.d.mts +36 -0
  67. package/sync/compile-guard.mjs +189 -0
  68. package/sync/index.d.mts +22 -3
  69. package/sync/index.mjs +80 -32
  70. package/templates/app/ci-workflows/github/workflows/ci.yml +2 -2
  71. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -2
  72. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +26 -0
  73. package/templates/app/skeleton/.starcistacks/dev/README.md +21 -10
  74. package/templates/app/skeleton/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  75. package/templates/app/skeleton/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  76. package/templates/app/skeleton/.starcistacks/dev/seeds/seeds.yaml +3 -0
  77. package/templates/app/skeleton/.starcistacks/production/seeds/data/.gitkeep +0 -0
  78. package/templates/app/skeleton/.starcistacks/production/seeds/files/.gitkeep +0 -0
  79. package/templates/app/skeleton/.starcistacks/production/seeds/seeds.yaml +2 -0
  80. package/templates/app/skeleton/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  81. package/templates/app/skeleton/.starcistacks/seeds/data/.gitkeep +0 -0
  82. package/templates/app/skeleton/.starcistacks/seeds/files/.gitkeep +0 -0
  83. package/templates/app/skeleton/.starcistacks/seeds/seeds.yaml +2 -0
  84. package/templates/app/skeleton/.starcistacks/seeds/sql/.gitkeep +0 -0
  85. package/templates/app/skeleton/README.md +1 -1
  86. package/templates/app/skeleton-lite/.starcistacks/dev/README.md +2 -2
  87. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/data/.gitkeep +0 -0
  88. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/files/.gitkeep +0 -0
  89. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/seeds.yaml +2 -0
  90. package/templates/app/skeleton-lite/.starcistacks/dev/seeds/sql/.gitkeep +0 -0
  91. package/templates/app/skeleton-lite/.starcistacks/production/seeds/data/.gitkeep +0 -0
  92. package/templates/app/skeleton-lite/.starcistacks/production/seeds/files/.gitkeep +0 -0
  93. package/templates/app/skeleton-lite/.starcistacks/production/seeds/seeds.yaml +2 -0
  94. package/templates/app/skeleton-lite/.starcistacks/production/seeds/sql/.gitkeep +0 -0
  95. package/templates/app/skeleton-lite/.starcistacks/seeds/data/.gitkeep +0 -0
  96. package/templates/app/skeleton-lite/.starcistacks/seeds/files/.gitkeep +0 -0
  97. package/templates/app/skeleton-lite/.starcistacks/seeds/seeds.yaml +2 -0
  98. package/templates/app/skeleton-lite/.starcistacks/seeds/sql/.gitkeep +0 -0
  99. package/templates/app/skeleton-lite/README.md +2 -2
  100. package/templates/app/skeleton-lite/db/migrations/__timestamp___baseline.sql +3 -1
  101. package/templates/be/auth-lite/src/features/api/auth/application/auth.contracts.ts +2 -0
  102. package/templates/be/auth-lite/src/features/api/auth/application/signup.handler.ts +6 -1
  103. package/templates/be/auth-lite/src/features/api/auth/auth.module.ts +14 -1
  104. package/templates/be/auth-lite/src/features/api/auth/transport/http/auth.controller.ts +5 -1
  105. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/reset.request.ts +0 -1
  106. package/templates/be/auth-lite/src/features/api/auth/transport/http/dto/signup.request.ts +7 -0
  107. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.spec.ts +15 -0
  108. package/templates/be/auth-lite/src/modules/domain/identity/account-directory.service.ts +9 -3
  109. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.spec.ts +79 -16
  110. package/templates/be/auth-lite/src/modules/domain/identity/authenticator.service.ts +39 -9
  111. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.spec.ts +108 -20
  112. package/templates/be/auth-lite/src/modules/domain/identity/identity-email.service.ts +34 -7
  113. package/templates/be/auth-lite/src/modules/domain/identity/persistence/account-doors.port.ts +28 -4
  114. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/identity-refresh-token.entity.ts +4 -0
  115. package/templates/be/auth-lite/src/modules/domain/identity/persistence/entities/index.ts +6 -1
  116. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.spec.ts +9 -2
  117. package/templates/be/auth-lite/src/modules/domain/identity/rate-counter.service.ts +11 -2
  118. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.spec.ts +63 -10
  119. package/templates/be/auth-lite/src/modules/domain/identity/session-store.service.ts +10 -2
  120. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.spec.ts +25 -4
  121. package/templates/be/auth-lite/src/modules/domain/identity/sign-in.service.ts +8 -1
  122. package/templates/be/auth-lite/src/tests/fixtures/builders/identity.builder.ts +3 -1
  123. package/templates/be/skeleton/src/features/cli/seed/subs/run.cli.ts +3 -3
  124. package/templates/be/skeleton/src/modules/platform/database/database.sql.ts +1 -1
  125. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.spec.ts +3 -2
  126. package/templates/be/skeleton/src/modules/platform/database/seed-connections.client.ts +6 -2
  127. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.spec.ts +14 -9
  128. package/templates/be/skeleton/src/modules/platform/database/seed-runner.service.ts +3 -3
  129. package/templates/be/skeleton-lite/src/modules/platform/database/seed-runner.service.ts +32 -7
  130. package/templates/be/tool-config/jest.config.js +1 -1
  131. package/templates/fe/auth-lite/apps/web/src/modules/auth/email-doors.ts +4 -1
  132. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/component.tsx +3 -0
  133. package/templates/fe/skeleton-lite/apps/web/src/components/blocks/EmailDoorForm/index.tsx +14 -6
  134. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/en.json +1 -0
  135. package/templates/fe/skeleton-lite/apps/web/src/modules/i18n/messages/vi.json +1 -0
  136. package/templates/app/module/jest.config.js.tpl +0 -8
  137. /package/templates/app/skeleton/.starcistacks/dev/seeds/{primary-notes.sql → sql/primary-notes.sql} +0 -0
@@ -243,3 +243,29 @@ export function measuredBy({ roots, roles, files, excludes }) {
243
243
  }
244
244
  /** True when the be-relative `file` is measured by the scope of the whole manifest (measuredBy). */
245
245
  export const isMeasured = (manifest, file) => measuredBy(coverageScope(manifest))(file);
246
+ /**
247
+ * The parts of a root workspace package (`packages/<name>` at the app root, PROJECT-3) that are never measured: its workbench and its public face (front
248
+ * ends, judged by the front-end canon and the Grammar lint) and its barrels. Everything else below its `src/` is its logic.
249
+ */
250
+ const PACKAGE_UNMEASURED = Object.freeze(['src/workbench/**', 'src/public/**', 'src/**/index.ts']);
251
+ /**
252
+ * THE derivation of what the root packages measure (BE-TEST-13): every TypeScript file below a package's `src/` but its front-end subtrees and barrels, at
253
+ * 100 per file in the back end's unit run. The rendered be/jest.config.js, Sonar's coverage exclusions and sources, Codecov's paths and R273 read it.
254
+ */
255
+ export function packagesCoverage(folders) {
256
+ const sorted = [...new Set(folders)].sort(byCodeUnit);
257
+ return { folders: sorted, files: sorted.map((folder) => `${folder}/src/**/*.ts`), excludes: sorted.flatMap((folder) => PACKAGE_UNMEASURED.map((glob) => `${folder}/${glob}`)) };
258
+ }
259
+ /** The registry of the module packages (slot app.module-registry): managed, it lists the modules and carries no logic of its own. */
260
+ const PACKAGE_REGISTRY = 'modules';
261
+ /** The app-relative folders of the root packages a tracked file list holds: each `packages/<name>/` with a `package.json` and a `src/`, the registry aside. */
262
+ export function rootPackageFoldersOf(files) {
263
+ const names = new Set(files.map((file) => /^packages\/([^/]+)\/package\.json$/.exec(file)?.[1]).filter((name) => name !== undefined && name !== PACKAGE_REGISTRY));
264
+ return [...names].filter((name) => files.some((file) => file.startsWith(`packages/${name}/src/`))).map((name) => `packages/${name}`).sort(byCodeUnit);
265
+ }
266
+ /** The one test of whether an app-relative file is measured by a packages scope (a spec never is): R273 asks it. */
267
+ export function packageMeasuredBy({ files, excludes }) {
268
+ const measured = files.map((glob) => globExpression(glob));
269
+ const unmeasured = excludes.map((glob) => globExpression(glob));
270
+ return (file) => !/\.spec\.ts$/.test(file) && measured.some((glob) => glob.test(file)) && !unmeasured.some((glob) => glob.test(file));
271
+ }
@@ -2,7 +2,7 @@
2
2
  // cutover whose production starts on an empty database. Nothing is rebuilt from migration SQL: the source's schema is dumped with its
3
3
  // privileges, the objects of the extensions the target image lacks (--drop-extension) and every function or view whose body reaches their
4
4
  // schemas are left out by name, the role facts a per-database dump loses go to a marked section of db/bootstrap.sql, and the reference rows
5
- // of --seed-tables go to db/seed.sql. The verb proves its own output before it writes anything: a scratch container of the app's db image
5
+ // of --seed-tables go to .starcistacks/seeds/sql/reference.sql, an entry of the shared seed level (every environment). The verb proves its own output before it writes anything: a scratch container of the app's db image
6
6
  // replays bootstrap + baseline + seed (the seed twice) and the same projection read back from the replay must equal the source's, or it
7
7
  // refuses with the differences. The source is only read; its migration history is never changed.
8
8
  import fs from 'node:fs';
@@ -12,9 +12,12 @@ import { randomBytes } from 'node:crypto';
12
12
  import { DB_DOCKERFILE, DB_IMAGE_CONTEXT } from '../../../db/image.mjs';
13
13
  import { DbVerbError } from './db-errors.mjs';
14
14
  import { BOOTSTRAP_FILE, BOOTSTRAP_SQUASH_BEGIN, BOOTSTRAP_SQUASH_END, SEED_FILE, bootstrapSection } from './rules/database-constants.mjs';
15
+ import { addSeedEntries } from './stack-seeds-config.mjs';
15
16
  /** The extensions a lite app's db image does not ship, dropped unless --drop-extension names others (pgmq stays: it is the lite event bus). */
16
17
  export const DEFAULT_DROPPED_EXTENSIONS = Object.freeze(['pg_net', 'pg_cron']);
17
18
  export const SEED_HEADER = '-- Generated by starci app db baseline --squash';
19
+ /** The entry of the shared seed level that declares the reference rows the squash writes (upserted on every change of them). */
20
+ const SQUASH_SEED_ENTRY = { id: 'reference', kind: 'sql', path: 'sql/reference.sql', target: 'primary', mode: 'upsert' };
18
21
  // Roles of a hosted platform (Supabase) that a self-hosted lite database never has; a grant naming one is platform state, not the app's.
19
22
  const PLATFORM_ROLE = /^(?:supabase_\w+|authenticator|dashboard_user|pgbouncer)$/;
20
23
  // Role-level settings carried to the bootstrap; any other setting of a carried role is refused, so no secret travels.
@@ -566,8 +569,12 @@ export function squashBaseline({ root, opts, plan, urlEnv, connection, commandOf
566
569
  stdout(`proof: bootstrap + baseline + seed (twice) replayed on ${image}; schema, grants, roles and seed rows equal the source minus ${config.dropped.join(', ')}\n`);
567
570
  if (!opts.execute)
568
571
  return 0;
569
- for (const [file, text] of files)
572
+ for (const [file, text] of files) {
573
+ fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
570
574
  fs.writeFileSync(path.join(root, file), text, { flag: file === plan.file ? 'wx' : 'w' });
575
+ }
576
+ if (projected.seed)
577
+ addSeedEntries(root, null, [SQUASH_SEED_ENTRY]);
571
578
  archive();
572
579
  return 0;
573
580
  }
@@ -140,7 +140,7 @@ function observeBlockProblems(observe) {
140
140
  bad.push('observe.reporter must name the optional error reporter');
141
141
  return bad;
142
142
  }
143
- const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'db', 'sides', 'browser', 'lexicon', 'observe']);
143
+ const DECLARATION_KEYS = new Set(['hfs', 'kind', 'project', 'edition', 'db', 'sides', 'browser', 'modules', 'lexicon', 'observe']);
144
144
  const RUNTIME_DECLARATION_KEYS = new Set(['hfs', 'kind', 'project']);
145
145
  function productDeclarationProblems(declaration, profiles) {
146
146
  const bad = [];
@@ -150,6 +150,8 @@ function productDeclarationProblems(declaration, profiles) {
150
150
  bad.push(...dbBlockProblems(declaration.db), ...lexiconBlockProblems(declaration.lexicon), ...observeBlockProblems(declaration.observe));
151
151
  if (declaration.browser !== undefined && declaration.browser !== true)
152
152
  bad.push('browser is `true` when the app has a browser journey (slot app.browser), and is left out otherwise');
153
+ if (declaration.modules !== undefined && declaration.modules !== 'data')
154
+ bad.push('modules is `"data"` when the app keeps its modules as data (seeds, no rendered registry package), and is left out otherwise');
153
155
  if (declaration.kind !== APP_KIND)
154
156
  bad.push(`kind must be ${APP_KIND} (a product is one app repository with a be and an fe side) or ${RUNTIME_KIND} (the StarCi runtime repository)`);
155
157
  if (!isPlainObject(declaration.sides) || Object.keys(declaration.sides).sort(byCodeUnit).join() !== profiles.join()) {
@@ -4,7 +4,8 @@
4
4
  // (HFS_MONO_*, monorepo.mjs), the one cli app (BE_CLI_REQUIRED, cli.mjs), BE_INTEGRATION_SPEC_MISSING (R112,
5
5
  // integration-specs.mjs), FE_GRAPHQL_CONTRACT (R113, fe-contract-documents.mjs), HFS_CI_MISSING_CANON (R13, pipeline.mjs),
6
6
  // BE_TEST_TOPOLOGY (R47, test-topology.mjs), HFS_PROOF_COMMAND_FILE_MISSING (R105, proof-commands.mjs), the app front-end
7
- // tree (FE_WIRE_GENERATED, FE_I18N_PLACEMENT, frontend-tree.mjs) and HFS_STACKS_SHAPE (R10, stacks.mjs).
7
+ // tree (FE_WIRE_GENERATED, FE_I18N_PLACEMENT, frontend-tree.mjs), HFS_STACKS_SHAPE (R10, stacks.mjs) and the root seed data
8
+ // (HFS_SEED_*: R375, seeds.mjs).
8
9
  import { cliFindings } from './cli.mjs';
9
10
  import { contractCompatFindings } from './contract-compat.mjs';
10
11
  import { depFindings } from './deps.mjs';
@@ -19,6 +20,7 @@ import { peerIntegrationFindings } from './peer-integrations.mjs';
19
20
  import { pipelineFindings } from './pipeline.mjs';
20
21
  import { proofCommandFindings } from './proof-commands.mjs';
21
22
  import { sagaFindings } from './saga.mjs';
23
+ import { seedsFindings } from './seeds.mjs';
22
24
  import { serviceFindings } from './services.mjs';
23
25
  import { stacksFindings } from './stacks.mjs';
24
26
  import { testTopologyFindings } from './test-topology.mjs';
@@ -46,5 +48,6 @@ export function appRootFindings({ repoRoot, files, all, repo, resolver, pins, ph
46
48
  ...proofCommandFindings({ repoRoot, files: all, resolver, sides: Object.keys(repo.sides ?? {}) }),
47
49
  ...appFrontendFindings({ repoRoot, files: all, repo, phase }),
48
50
  ...stacksFindings({ repoRoot, files, resolver }),
51
+ ...seedsFindings({ repoRoot, files }),
49
52
  ];
50
53
  }
@@ -6,7 +6,7 @@
6
6
  // sonar-lint-per-side a lint with --out names --side
7
7
  // required-providers no continue-on-error; Codecov fail_ci_if_error true; no availability probe, no secret-gated Codecov or Sonar step
8
8
  // test-sharding the test job is a shard matrix; each shard runs --shard=i/N with STARCI_COVERAGE_SHARD and no threshold or pass-empty flag; the coverage job merges, then proves
9
- // lint-parallel the lint job is a matrix over side [be, fe]
9
+ // lint-parallel the lint job is a matrix over side [be, fe], or [be, fe, packages] in an app with root packages
10
10
  // cache-saved-always no cache keyed by the commit sha; actions/cache/save runs under if: always()
11
11
  // timeouts every job declares timeout-minutes
12
12
  // sha-pinned-actions every remote `uses:` names a 40-hex commit sha (docker:// a sha256 digest; ./ local actions allowed)
@@ -123,7 +123,8 @@ function testSharding(doc, say) {
123
123
  }
124
124
  function lintParallel(doc, say) {
125
125
  const sides = record(doc.jobs?.lint) ? doc.jobs.lint.strategy?.matrix?.side : null;
126
- return Array.isArray(sides) && sides.map(String).sort(byCodeUnit).join() === 'be,fe' ? [] : [say('lint-parallel', 'the lint job must be a matrix over side [be, fe]')];
126
+ const joined = Array.isArray(sides) ? sides.map(String).sort(byCodeUnit).join() : '';
127
+ return joined === 'be,fe' || joined === 'be,fe,packages' ? [] : [say('lint-parallel', 'the lint job must be a matrix over side [be, fe], with packages when the app has root packages')];
127
128
  }
128
129
  function cacheSavedAlways(doc, say) {
129
130
  const out = [];
@@ -174,8 +175,8 @@ function sonarLintPerSide(doc, say) {
174
175
  for (const [id, job] of jobsOf(doc)) {
175
176
  for (const step of stepsOf(job)) {
176
177
  const run = runOf(step);
177
- if (/\b(?:npm run lint|app check)\b/.test(run) && /\s--out\b/.test(run) && !/\s--side\s+(?:be\b|fe\b|\$\{\{\s*matrix\.side\s*\}\})/.test(run)) {
178
- out.push(say('sonar-lint-per-side', `job ${id}, step ${step.name ?? 'lint'}: a lint with a Sonar import must name its side (--side be|fe) and the reports are merged afterwards; a combined lint with Sonar breaks the standard`));
178
+ if (/\b(?:npm run lint|app check)\b/.test(run) && /\s--out\b/.test(run) && !/\s--side\s+(?:be\b|fe\b|packages\b|\$\{\{\s*matrix\.side\s*\}\})/.test(run)) {
179
+ out.push(say('sonar-lint-per-side', `job ${id}, step ${step.name ?? 'lint'}: a lint with a Sonar import must name its side (--side be|fe|packages) and the reports are merged afterwards; a combined lint with Sonar breaks the standard`));
179
180
  }
180
181
  }
181
182
  }
@@ -3,7 +3,7 @@
3
3
  // lcov carries lines, functions and branches; the unit runner also enforces its statement threshold.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
- import { coverageScope, measuredBy } from '../coverage-scope.mjs';
6
+ import { coverageScope, measuredBy, packageMeasuredBy, packagesCoverage, rootPackageFoldersOf } from '../coverage-scope.mjs';
7
7
  import { editionRuleParams, effectiveSlot, slotInEdition } from '../edition-slots.mjs';
8
8
  import { byCodeUnit } from '../../lib/list.mjs';
9
9
  import { found } from './read.mjs';
@@ -120,12 +120,17 @@ function inventory({ files, all, repo, manifest }) {
120
120
  const effective = manifestOfEdition(manifest, repo.edition ?? 'full');
121
121
  const scope = coverageScope(effective, repo.providers ?? repo.sides?.be?.providers ?? []);
122
122
  const measures = measuredBy(scope);
123
- const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && file.endsWith('.ts') && !SPEC.test(file)).sort(byCodeUnit);
123
+ const tracked = [...new Set((all ?? files ?? []).map(slash))];
124
+ // At the app root the root packages are measured too, by the same unit run as the back end (packagesCoverage, BE-TEST-13).
125
+ const packages = packagesCoverage(prefix ? rootPackageFoldersOf(tracked) : []);
126
+ const measuresPackage = packageMeasuredBy(packages);
127
+ const inPackage = (file) => packages.folders.some((folder) => file.startsWith(`${folder}/src/`));
128
+ const candidates = tracked.filter((file) => (file.startsWith(prefix) || inPackage(file)) && file.endsWith('.ts') && !SPEC.test(file)).sort(byCodeUnit);
124
129
  const measured = [];
125
130
  const unmeasured = [];
126
131
  for (const file of candidates)
127
- (measures(file.slice(prefix.length)) ? measured : unmeasured).push(file);
128
- return { measured, unmeasured, unmeasuredScopes: scope.none.map((entry) => `${prefix}${entry}`), scope, prefix };
132
+ ((inPackage(file) ? measuresPackage(file) : measures(file.slice(prefix.length))) ? measured : unmeasured).push(file);
133
+ return { measured, unmeasured, unmeasuredScopes: [...scope.none.map((entry) => `${prefix}${entry}`), ...packages.excludes], scope, prefix };
129
134
  }
130
135
  function sourcePath(source) {
131
136
  if (source.startsWith('file:')) {
@@ -144,6 +149,9 @@ function matchesSource(source, file, prefix) {
144
149
  const choices = [file, relative, ...(prefix ? [] : [`be/${file}`])];
145
150
  const windows = /^[a-z]:\//i.test(source) || source.startsWith('//');
146
151
  const absolute = source.startsWith('/') || windows;
152
+ // A relative source is relative to the jest root, the back end: a root package's file is `../packages/<name>/...` there.
153
+ if (!absolute && prefix && slash(`${prefix}${source}`) === file)
154
+ return true;
147
155
  const key = windows ? source.toLowerCase() : source;
148
156
  return choices.some((choice) => {
149
157
  const candidate = windows ? choice.toLowerCase() : choice;
@@ -1,8 +1,9 @@
1
- // database-config.mjs - the database content checks of db/ (R259: extensions, custody SQL, destructive migrations, seed data). It starts no
1
+ // database-config.mjs - the database content checks of db/ and seeds/ (R259: extensions, custody SQL, destructive migrations, seed data). It starts no
2
2
  // database and no Docker process.
3
3
  import { found, readText } from './read.mjs';
4
- import { DB_EXTENSION_ALLOWED, DB_VAULT_WRITE, HFS_SEED_PII, isActiveMigration, SEED_FILE, } from './database-constants.mjs';
5
- import { databaseExecutableTokens, databaseSqlStatements, databaseSqlTokens, destructiveMigrationFindings } from './database-migrations.mjs';
4
+ import { DB_EXTENSION_ALLOWED, DB_VAULT_WRITE, HFS_SEED_PII, isActiveMigration, } from './database-constants.mjs';
5
+ import { databaseExecutableTokens, databaseSqlStatements, destructiveMigrationFindings } from './database-migrations.mjs';
6
+ import { isSeedFile, seedLiterals } from './seeds.mjs';
6
7
  import { byCodeUnit } from '../../lib/list.mjs';
7
8
  const DEFAULT_EXTENSIONS = ['pgcrypto', 'pg_trgm', 'vector', 'uuid-ossp', 'unaccent', 'btree_gist', 'pgmq'];
8
9
  const word = (token, value) => token?.t === 'word' && token.v === value;
@@ -108,10 +109,10 @@ function seedPiiFindings(repoRoot, seeds) {
108
109
  const text = readText(repoRoot, file);
109
110
  if (text === null)
110
111
  continue;
111
- for (const token of databaseSqlTokens(text)) {
112
- if (!['string', 'body'].includes(token.t) || !piiLiteral(token.v))
112
+ for (const literal of seedLiterals(file, text)) {
113
+ if (!piiLiteral(literal.v))
113
114
  continue;
114
- findings.push(found(HFS_SEED_PII, file, `${file}:${token.line} contains an email or phone literal; seed data uses synthetic identities and the example.test domain`, { line: token.line }));
115
+ findings.push(found(HFS_SEED_PII, file, `${file}:${literal.line} contains an email or phone literal; seed data uses synthetic identities and the example.test domain`, { line: literal.line }));
115
116
  }
116
117
  }
117
118
  return findings;
@@ -127,7 +128,7 @@ export function databaseConfigFindings({ repoRoot, files = [], all = files, repo
127
128
  for (const { file, tokens } of sources)
128
129
  findings.push(...extensionFindings(file, tokens, new Set(parameters.extensions)), ...custodySqlFindings(file, tokens));
129
130
  findings.push(...destructiveMigrationFindings({ repoRoot, migrations, files: all }));
130
- const seeds = all.filter((file) => file === SEED_FILE || /^\.starcistacks\/[^/]+\/seeds\/.+\.sql$/.test(file));
131
+ const seeds = all.filter((file) => isSeedFile(file) && /\/(?:sql|data)\/.+\.(?:sql|json|ya?ml)$/.test(file));
131
132
  findings.push(...seedPiiFindings(repoRoot, seeds));
132
133
  return findings.filter((finding) => inScope(finding.path));
133
134
  }
@@ -18,7 +18,8 @@ export declare const MIGRATIONS_DIR = "db/migrations";
18
18
  export declare const MIGRATIONS_ARCHIVE_DIR = "db/migrations/archive";
19
19
  /** True for a migration `starci app db migrate` applies: a path under db/migrations/ outside the archive. */
20
20
  export declare const isActiveMigration: (file: string) => boolean;
21
- export declare const SEED_FILE = "db/seed.sql";
21
+ /** The shared SQL seed `db baseline --squash --seed-tables` writes the reference rows to, an entry of the shared seed level (R375). */
22
+ export declare const SEED_FILE = ".starcistacks/seeds/sql/reference.sql";
22
23
  /** The managed pre-migration bootstrap (slot app.db.bootstrap): `starci app sync` renders it, and `db baseline --squash` owns one marked section. */
23
24
  export declare const BOOTSTRAP_FILE = "db/bootstrap.sql";
24
25
  export declare const BOOTSTRAP_SQUASH_BEGIN = "-- >>> starci app db baseline --squash: roles, role settings and grants a per-database dump loses (regenerate with the verb; do not edit) >>>";
@@ -19,7 +19,8 @@ export const MIGRATIONS_DIR = `${DB_DIR}/migrations`;
19
19
  export const MIGRATIONS_ARCHIVE_DIR = `${MIGRATIONS_DIR}/archive`;
20
20
  /** True for a migration `starci app db migrate` applies: a path under db/migrations/ outside the archive. */
21
21
  export const isActiveMigration = (file) => file.startsWith(`${MIGRATIONS_DIR}/`) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`);
22
- export const SEED_FILE = `${DB_DIR}/seed.sql`;
22
+ /** The shared SQL seed `db baseline --squash --seed-tables` writes the reference rows to, an entry of the shared seed level (R375). */
23
+ export const SEED_FILE = '.starcistacks/seeds/sql/reference.sql';
23
24
  /** The managed pre-migration bootstrap (slot app.db.bootstrap): `starci app sync` renders it, and `db baseline --squash` owns one marked section. */
24
25
  export const BOOTSTRAP_FILE = `${DB_DIR}/bootstrap.sql`;
25
26
  export const BOOTSTRAP_SQUASH_BEGIN = '-- >>> starci app db baseline --squash: roles, role settings and grants a per-database dump loses (regenerate with the verb; do not edit) >>>';
@@ -4,8 +4,8 @@ export declare const DB_DEFINER_REVOKE = "DB_DEFINER_REVOKE";
4
4
  export { DB_DEFINER_SAFE, DB_DYNAMIC_DDL, DB_MIGRATION_SHAPE, DB_POLICY_SHAPE, DB_RLS_REQUIRED, DB_STORAGE_POLICY, LITE_DB_MIGRATION, } from './database-constants.mjs';
5
5
  import type { Finding, Parsed } from './rule-types.mjs';
6
6
  /**
7
- * LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/ (but a
8
- * .starcistacks seed), and every SQL file under db/ is a migration, the archive, the seed or nothing.
7
+ * LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/ but its seed data (the seed
8
+ * levels of .starcistacks/, judged by R375), and every SQL file under db/ is a migration, the archive, the bootstrap or nothing.
9
9
  */
10
10
  export declare function liteDbPathFindings(files: string[]): Finding[];
11
11
  /**
@@ -5,7 +5,8 @@ import { contractReleaseFindings, defaultGit, migrationShapeFindings } from './d
5
5
  import { migrationSetPolicyFindings, sqlAnalysis } from './database-sql.mjs';
6
6
  import { migrationSetDefinerFindings } from './database-plpgsql.mjs';
7
7
  import { databaseAccessFindings } from './database-access.mjs';
8
- import { DB_DIR, DB_MIGRATION_SHAPE, isActiveMigration, LITE_DB_MIGRATION, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
8
+ import { BOOTSTRAP_FILE, DB_DIR, DB_MIGRATION_SHAPE, isActiveMigration, LITE_DB_MIGRATION, MIGRATIONS_ARCHIVE_DIR, MIGRATIONS_DIR } from './database-constants.mjs';
9
+ import { isSeedFile } from './seeds.mjs';
9
10
  import { byCodeUnit } from '../../lib/list.mjs';
10
11
  import { eachInOrder } from '../../lib/in-order.mjs';
11
12
  export { migrationStamp } from './database-migrations.mjs';
@@ -14,11 +15,9 @@ export const DB_DEFINER_REVOKE = 'DB_DEFINER_REVOKE';
14
15
  export { DB_DEFINER_SAFE, DB_DYNAMIC_DDL, DB_MIGRATION_SHAPE, DB_POLICY_SHAPE, DB_RLS_REQUIRED, DB_STORAGE_POLICY, LITE_DB_MIGRATION, } from './database-constants.mjs';
15
16
  /** The schemas the app's tables live in; no schema is exposed to a browser, which reaches data only through the back end. */
16
17
  const APP_SCHEMAS = new Set(['public']);
17
- /** A .starcistacks seed is the one SQL file a lite app keeps outside db/. */
18
- const STACK_SEED = /^\.starcistacks\/[^/]+\/seeds\/.+\.sql$/;
19
18
  /**
20
- * LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/ (but a
21
- * .starcistacks seed), and every SQL file under db/ is a migration, the archive, the seed or nothing.
19
+ * LITE_DB_MIGRATION: a lite app's schema lives only under db/: no supabase/ tree and no SQL file outside db/ but its seed data (the seed
20
+ * levels of .starcistacks/, judged by R375), and every SQL file under db/ is a migration, the archive, the bootstrap or nothing.
22
21
  */
23
22
  export function liteDbPathFindings(files) {
24
23
  const findings = [];
@@ -27,10 +26,12 @@ export function liteDbPathFindings(files) {
27
26
  findings.push(found(LITE_DB_MIGRATION, file, `${file} is a Supabase project file; a lite app keeps its schema in ${MIGRATIONS_DIR}/<ts14>_<kebab>.sql (move the migrations, delete supabase/)`, {}));
28
27
  continue;
29
28
  }
30
- if (!file.endsWith('.sql') || STACK_SEED.test(file))
29
+ if (!file.endsWith('.sql') || isSeedFile(file))
31
30
  continue;
32
31
  if (!file.startsWith(`${DB_DIR}/`))
33
32
  findings.push(found(LITE_DB_MIGRATION, file, `${file} is SQL outside ${DB_DIR}/; a schema change is one ${MIGRATIONS_DIR}/<ts14>_<kebab>.sql`, {}));
33
+ else if (!isActiveMigration(file) && !file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`) && file !== BOOTSTRAP_FILE)
34
+ findings.push(found(LITE_DB_MIGRATION, file, `${file} is SQL under ${DB_DIR}/ that is no migration, archive or bootstrap; seed data is an entry of a seed level, .starcistacks/seeds/sql/ or .starcistacks/<env>/seeds/sql/ (starci app codemod seeds-layout moves it)`, {}));
34
35
  }
35
36
  return findings;
36
37
  }
@@ -3,7 +3,8 @@
3
3
  // The Dockerfile is app-owned (starci app scaffold writes it once from templates/<side>/image) and judged here, structurally, from its parsed
4
4
  // instructions (scripts/lib/dockerfile.mjs), never by matching the text:
5
5
  // HFS_DOCKER_BUILD_CONTEXT the header comment states the app's own build command (`starci docker build <app>` or `docker build -f <side>/apps/<app>/Dockerfile ... .`, the
6
- // context the app root) and no COPY or ADD source leaves the context (`..`, an absolute path)
6
+ // context the app root) and no COPY or ADD source leaves the context (`..`, an absolute path); a Next image of a front end
7
+ // that reads be/contracts/ copies that folder exactly when the app holds it (its codegen input; a COPY of a missing folder fails the build)
7
8
  // HFS_DOCKER_STAGES a `build` stage and a last `runtime` stage; the runtime is unprivileged (`USER node`) and runs no build; every install
8
9
  // is `npm ci` (never `npm install`); a back-end runtime installs `--omit=dev --ignore-scripts`, a front-end runtime installs nothing
9
10
  // HFS_DOCKER_ENTRY the runtime starts the app's own built entry (be: node be/dist/apps/<app>/src/main.js; fe: node fe/apps/<app>/server.js); an api or
@@ -40,6 +41,8 @@ const SECRET_FOLDERS = new Set(['.starcistacks', '.secrets', '.volume', '.git',
40
41
  const SECRET_SUFFIXES = ['.key', '.pem', '.p12', '.pfx', '.crt'];
41
42
  const BUILD_TOOLS = new Set(['tsc', 'tsc-alias', 'next', 'turbo', 'nest', 'webpack', 'vite', 'esbuild']);
42
43
  const DIGEST = /@sha256:[0-9a-f]{64}$/;
44
+ /** The back-end contracts a front end reads in place (`sides.fe.reads`), its codegen input. */
45
+ const CONTRACTS = 'be/contracts';
43
46
  /** The path of the Dockerfile of a be or fe app, app-relative. */
44
47
  export const dockerfilePath = (side, app) => `${side}/apps/${app}/Dockerfile`;
45
48
  /** The entry a built be app starts (from the app root). */
@@ -93,14 +96,16 @@ const statesBuildCommand = (parsed, wanted, app) => parsed.comments.some((commen
93
96
  const flag = parts.indexOf('-f', build);
94
97
  return flag > 0 && parts[flag + 1] === wanted && parts.at(-1) === '.';
95
98
  });
99
+ /** The context sources of a COPY or ADD (none for a COPY from a stage). */
100
+ const contextSources = (item) => {
101
+ const parts = words(item.text);
102
+ if (parts.some((part) => part.startsWith('--from=')))
103
+ return [];
104
+ return parts.filter((part) => !part.startsWith('--')).slice(0, -1);
105
+ };
96
106
  function copySourceFindings(file, item) {
97
107
  const findings = [];
98
- const parts = words(item.text).filter((part) => !part.startsWith('--'));
99
- const sources = parts.slice(0, -1);
100
- const fromStage = words(item.text).some((part) => part.startsWith('--from='));
101
- if (fromStage)
102
- return findings;
103
- for (const source of sources) {
108
+ for (const source of contextSources(item)) {
104
109
  if (/^[a-z]+:\/\//i.test(source))
105
110
  continue;
106
111
  const segments = source.split('/');
@@ -119,6 +124,15 @@ function contextFindings(file, parsed, side, app) {
119
124
  findings.push(...copySourceFindings(file, item));
120
125
  return findings;
121
126
  }
127
+ /** R187: a Next image copies be/contracts, the contract its codegen reads, when the app holds it, and never when it does not. */
128
+ function contractCopyFindings(file, parsed, holdsContracts) {
129
+ const copies = parsed.instructions.filter((entry) => entry.keyword === 'COPY' || entry.keyword === 'ADD').find((entry) => contextSources(entry).some((source) => source.replace(/\/$/u, '') === CONTRACTS));
130
+ if (copies && !holdsContracts)
131
+ return [found(DOCKER_BUILD_CONTEXT, file, `${file}:${copies.line} copies ${CONTRACTS}, which the app does not hold (its back end emits no contract), so the image build fails; drop the COPY until a contract is emitted.`, { line: copies.line, source: CONTRACTS })];
132
+ if (!copies && holdsContracts)
133
+ return [found(DOCKER_BUILD_CONTEXT, file, `${file} does not copy ${CONTRACTS}, the contract the front end's codegen reads; add \`COPY ${CONTRACTS} ${CONTRACTS}\` before the codegen.`, { source: CONTRACTS })];
134
+ return [];
135
+ }
122
136
  /** R188: the stages and the install, the user and the build discipline of each. */
123
137
  function stageInstallFindings(file, stages) {
124
138
  const findings = [];
@@ -330,6 +344,8 @@ export function dockerFindings({ repoRoot, files, repo }) {
330
344
  ...repo.sides.fe.apps.map((entry) => ({ side: 'fe', app: entry.name, kind: entry.kind })),
331
345
  ];
332
346
  const ts = apps.some((entry) => entry.side === 'fe' || entry.kind === 'api') ? typescriptFor(repoRoot) : null;
347
+ const readsContracts = (repo.sides.fe.reads ?? []).includes(`${CONTRACTS}/`);
348
+ const holdsContracts = files.some((name) => name.startsWith(`${CONTRACTS}/`));
333
349
  return apps.flatMap(({ side, app, kind }) => {
334
350
  const file = dockerfilePath(side, app);
335
351
  if (!tracked.has(file))
@@ -340,6 +356,7 @@ export function dockerFindings({ repoRoot, files, repo }) {
340
356
  const parsed = parseDockerfile(text);
341
357
  return [
342
358
  ...contextFindings(file, parsed, side, app),
359
+ ...(side === 'fe' && kind === 'next' && readsContracts ? contractCopyFindings(file, parsed, holdsContracts) : []),
343
360
  ...stageFindings(file, parsed, side, kind),
344
361
  ...entryFindings({ repoRoot, file, parsed, side, app, kind, project: repo.project, ts }),
345
362
  ...healthReachabilityFindings({ repoRoot, file, parsed, side, app, kind, ts }),
@@ -1,7 +1,7 @@
1
1
  import type { Parsed, Repo } from './rule-types.mjs';
2
2
  /** The npm workspaces of every app, in this order. */
3
3
  export declare const WORKSPACES: readonly string[];
4
- /** The workspace of the app's own root packages (slot app.packages: packages/agent-sdk, packages/module-<name>), added after the fe ones once one is tracked. */
4
+ /** The workspace of the app's own root packages (slot app.packages: packages/<name>, the agent SDK, a block library or a module package), added after the fe ones once one is tracked. */
5
5
  export declare const ROOT_PACKAGES_WORKSPACE = "packages/*";
6
6
  /** The workspaces the tracked tree `files` calls for: the fe ones, then `packages/*` when a root package tracks its package.json. */
7
7
  export declare const workspacesFor: (files: readonly string[]) => readonly string[];
@@ -22,7 +22,7 @@ const MONO_NEST_PROJECTS = 'HFS_MONO_NEST_PROJECTS';
22
22
  const MONO_WORKSPACE_DEP = 'HFS_MONO_WORKSPACE_DEP';
23
23
  /** The npm workspaces of every app, in this order. */
24
24
  export const WORKSPACES = Object.freeze(['fe/apps/*', 'fe/packages/*']);
25
- /** The workspace of the app's own root packages (slot app.packages: packages/agent-sdk, packages/module-<name>), added after the fe ones once one is tracked. */
25
+ /** The workspace of the app's own root packages (slot app.packages: packages/<name>, the agent SDK, a block library or a module package), added after the fe ones once one is tracked. */
26
26
  export const ROOT_PACKAGES_WORKSPACE = 'packages/*';
27
27
  /** The workspaces the tracked tree `files` calls for: the fe ones, then `packages/*` when a root package tracks its package.json. */
28
28
  export const workspacesFor = (files) => (files.some((file) => /^packages\/[^/]+\/package\.json$/.test(file)) ? [...WORKSPACES, ROOT_PACKAGES_WORKSPACE] : WORKSPACES);
@@ -0,0 +1,21 @@
1
+ import type { Finding } from './rule-types.mjs';
2
+ export declare const HFS_SEEDS_SHAPE = "HFS_SEEDS_SHAPE";
3
+ export declare const HFS_SEED_MANIFEST = "HFS_SEED_MANIFEST";
4
+ export declare const HFS_SEED_DDL = "HFS_SEED_DDL";
5
+ export declare const HFS_SEED_DATA_SCHEMA = "HFS_SEED_DATA_SCHEMA";
6
+ export declare const HFS_SEED_TEST_DATA = "HFS_SEED_TEST_DATA";
7
+ export declare const HFS_SEED_SECRET_REF = "HFS_SEED_SECRET_REF";
8
+ /** True for a tracked file of a seed level (the `.gitkeep` placeholders aside). */
9
+ export declare const isSeedFile: (file: string) => boolean;
10
+ /** The environments of the stack declaration of `repoRoot` with their runtime, or null when it declares none that parses to a mapping. */
11
+ export declare function declaredEnvironments(repoRoot: string): Map<string, string> | null;
12
+ /** The literals of a seed document or SQL file, each with its line: SQL strings and bodies, or every string value of a JSON or YAML document. */
13
+ export declare function seedLiterals(file: string, text: string): Array<{
14
+ v: string;
15
+ line: number;
16
+ }>;
17
+ /** The findings of R375 over the tracked files of an app root. */
18
+ export declare function seedsFindings({ repoRoot, files }: {
19
+ repoRoot: string;
20
+ files: string[];
21
+ }): Finding[];
@@ -0,0 +1,194 @@
1
+ // seeds.mjs - R375, the seed data of an app (contexts/project/stacks.md PROJECT-STACKS-12). It lives in two levels of `.starcistacks/`, the
2
+ // shared `seeds/` and `<env>/seeds/` of an environment the stack declaration names, each a manifest (`seeds.yaml`, stack-seeds-config.mjs) and
3
+ // the folders `sql/`, `data/` and `files/`:
4
+ // HFS_SEEDS_SHAPE a seed file outside those folders, a level of an undeclared environment, custody (a sealed member, a key) in a level;
5
+ // HFS_SEED_MANIFEST a manifest that is missing, does not parse or names a path that is not there, a file no entry covers, an after cycle;
6
+ // HFS_SEED_DDL a statement of a sql entry that changes the schema or its grants (seed data is never a migration);
7
+ // HFS_SEED_DATA_SCHEMA a data document that does not validate against the JSON schema its entry names;
8
+ // HFS_SEED_TEST_DATA a test marker (an example.test identity, a uat- or test- prefix) in a level a production environment applies (the
9
+ // shared level and the level of every environment that is not docker-compose), outside its one test-account entry;
10
+ // HFS_SEED_SECRET_REF a document's secret reference `{ "secret": "<member>" }` to a name that is no seeds-<name>, or to a member the
11
+ // environment does not seal (`.starcistacks/<env>/secrets/<member>.enc.yaml`; the shared level: every environment).
12
+ // A plaintext secret on any seed line is R06's and a real identity R259's (HFS_SEED_PII). A finding names the file and line, never a value.
13
+ import path from 'node:path';
14
+ import { parseYaml } from '../../../engine/yaml.mjs';
15
+ import { validateAgainstSchema } from '../../lib/json-schema.mjs';
16
+ import { findStackDeclaration } from '../../lib/stack-declaration.mjs';
17
+ import { databaseExecutableTokens, databaseSqlStatements, databaseSqlTokens } from './database-migrations.mjs';
18
+ import { envelopeOf } from '../secret-document.mjs';
19
+ import { entryFiles, isDocumentEntry, readSeedLevel, resolveSecrets, SEED_SECRET_MEMBER, secretReferences, seedDocuments, seedMemberFile, SEEDS_DIR, SEEDS_KEEP, SEEDS_MANIFEST } from '../stack-seeds-config.mjs';
20
+ import { found, readText } from './read.mjs';
21
+ export const HFS_SEEDS_SHAPE = 'HFS_SEEDS_SHAPE';
22
+ export const HFS_SEED_MANIFEST = 'HFS_SEED_MANIFEST';
23
+ export const HFS_SEED_DDL = 'HFS_SEED_DDL';
24
+ export const HFS_SEED_DATA_SCHEMA = 'HFS_SEED_DATA_SCHEMA';
25
+ export const HFS_SEED_TEST_DATA = 'HFS_SEED_TEST_DATA';
26
+ export const HFS_SEED_SECRET_REF = 'HFS_SEED_SECRET_REF';
27
+ /** What a secret's value reads as where no value is opened: the schema check and the dry run. */
28
+ const SEALED = '[sealed]';
29
+ /** A seed file: `.starcistacks/seeds/<rest>` or `.starcistacks/<env>/seeds/<rest>`, captured as [level folder, rest]. */
30
+ const SEED_PATH = /^(\.starcistacks\/(?:[^/]+\/)?seeds)\/(.+)$/u;
31
+ /** Where a file of a level may sit: the manifest, or under one of the kind folders. */
32
+ const LEVEL_FILE = /^(?:seeds\.yaml|(?:sql|data|files)\/.+)$/u;
33
+ /** Custody by its shape: a sealed member, a key or certificate, an env file. */
34
+ const CUSTODY = /(?:\.enc(?:\.yaml)?|\.pem|\.key|\.p12|\.pfx|(?:^|\/)\.env(?:\..*)?)$/u;
35
+ /** The statements a seed may not hold: each one changes the schema or its grants, which only a migration does. */
36
+ const SCHEMA_WORDS = new Set(['create', 'alter', 'drop', 'truncate', 'grant', 'revoke', 'comment']);
37
+ /** The words that open a block around statements (a DO block's body is read as statements, so `do begin drop ...` is a DROP). */
38
+ const BLOCK_WORDS = new Set(['do', 'begin']);
39
+ /** A test marker: an identity of the example.test domain, or a value that starts uat- or test-. */
40
+ const TEST_MARKER = /@example\.test\b|^(?:uat|test)[-_]/iu;
41
+ const DOCUMENT = /\.(?:json|ya?ml)$/u;
42
+ /** True for a tracked file of a seed level (the `.gitkeep` placeholders aside). */
43
+ export const isSeedFile = (file) => SEED_PATH.test(file) && !file.endsWith(`/${SEEDS_KEEP}`);
44
+ /** The environments of the stack declaration of `repoRoot` with their runtime, or null when it declares none that parses to a mapping. */
45
+ export function declaredEnvironments(repoRoot) {
46
+ const environments = findStackDeclaration(repoRoot).doc?.environments;
47
+ if (environments === null || typeof environments !== 'object' || Array.isArray(environments))
48
+ return null;
49
+ return new Map(Object.entries(environments).map(([name, entry]) => [name, String(entry?.runtime ?? '')]));
50
+ }
51
+ /** The literals of a seed document or SQL file, each with its line: SQL strings and bodies, or every string value of a JSON or YAML document. */
52
+ export function seedLiterals(file, text) {
53
+ if (!DOCUMENT.test(file))
54
+ return databaseSqlTokens(text).flatMap((token) => (['string', 'body'].includes(token.t) ? [{ v: String(token.v), line: token.line }] : []));
55
+ let doc;
56
+ try {
57
+ doc = file.endsWith('.json') ? JSON.parse(text) : parseYaml(text);
58
+ }
59
+ catch {
60
+ return [];
61
+ }
62
+ const out = [];
63
+ const visit = (value) => {
64
+ if (typeof value === 'string')
65
+ out.push({ v: value, line: Math.max(1, text.slice(0, text.indexOf(value)).split('\n').length) });
66
+ else if (value !== null && typeof value === 'object')
67
+ for (const item of Object.values(value))
68
+ visit(item);
69
+ };
70
+ visit(doc);
71
+ return out;
72
+ }
73
+ /** The schema statements of one SQL seed: the line of each statement whose first word (past a block opener) changes the schema. */
74
+ function schemaStatements(text) {
75
+ return databaseSqlStatements(databaseExecutableTokens(text)).flatMap((statement) => {
76
+ const first = statement.find((token) => !(token.t === 'word' && BLOCK_WORDS.has(token.v)));
77
+ return first?.t === 'word' && SCHEMA_WORDS.has(first.v) ? [{ line: first.line, word: String(first.v).toUpperCase() }] : [];
78
+ });
79
+ }
80
+ /** Where one seed file sits: the shape finding of a file outside the kind folders, of an undeclared environment's level, or of custody. */
81
+ function shapeFinding(file, environments) {
82
+ const [, level = '', rest = ''] = SEED_PATH.exec(file) ?? [];
83
+ const env = level.split('/')[1];
84
+ if (CUSTODY.test(file))
85
+ return found(HFS_SEEDS_SHAPE, file, `${file} is custody, not seed data; a secret is sealed with sops at .starcistacks/<env>/secrets/<slug>.enc.yaml and read by *_FILE`);
86
+ if (env !== SEEDS_DIR && environments !== null && !environments.has(String(env)))
87
+ return found(HFS_SEEDS_SHAPE, file, `${file} is in the seed level of ${String(env)}, which is no environment of .starcistacks/application-stacks.yaml`);
88
+ if (!LEVEL_FILE.test(rest))
89
+ return found(HFS_SEEDS_SHAPE, file, `${file} is no part of a seed level (${SEEDS_MANIFEST}, sql/, data/, files/); starci app codemod seeds-layout moves a loose seed into it`);
90
+ return null;
91
+ }
92
+ /** The manifest findings of one level: its problems, and every file of its kind folders that no entry covers. */
93
+ function manifestFindings(repoRoot, level, files) {
94
+ const manifest = `${level.dir}/${SEEDS_MANIFEST}`;
95
+ const own = files.filter((file) => file.startsWith(`${level.dir}/`) && file !== manifest);
96
+ if (own.length > 0 && !files.includes(manifest))
97
+ return [found(HFS_SEED_MANIFEST, manifest, `${level.dir} holds seed files but no ${SEEDS_MANIFEST}; every seed is an entry of its level's manifest`)];
98
+ const covered = new Set(level.entries.flatMap((entry) => entryFiles(path.join(repoRoot, level.dir), entry).map((file) => `${level.dir}/${file}`)));
99
+ return [
100
+ ...level.problems.map((problem) => found(HFS_SEED_MANIFEST, manifest, `${manifest} ${problem.at}: ${problem.message}`)),
101
+ ...own.filter((file) => !covered.has(file)).map((file) => found(HFS_SEED_MANIFEST, file, `${file} is listed by no entry of ${manifest}`)),
102
+ ];
103
+ }
104
+ /** The parsed JSON schema an entry names, or null when it names none or it cannot be read. */
105
+ function entrySchema(repoRoot, entry) {
106
+ const text = entry.schema === null ? null : readText(repoRoot, entry.schema);
107
+ try {
108
+ return text === null ? null : parseYaml(text);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ /** The findings of the secret references of one document: a name that is no seed member, or a member an environment of the level lacks. */
115
+ function referenceFindings(file, doc, environments, files) {
116
+ return secretReferences(doc).flatMap((member) => {
117
+ if (!SEED_SECRET_MEMBER.test(member))
118
+ return [found(HFS_SEED_SECRET_REF, file, `${file} references the secret ${member}; a seed references a sealed member named seeds-<name> of its environment`)];
119
+ const missing = environments.filter((env) => !files.has(seedMemberFile(env, member)));
120
+ return missing.map((env) => found(HFS_SEED_SECRET_REF, file, `${file} references ${member}, which ${seedMemberFile(env, member)} does not hold; seal it with starci app secret set --env ${env}`));
121
+ });
122
+ }
123
+ /** What the schema sees for a secret reference: the member's keys, each a sealed placeholder (the envelope names them; no value is opened). */
124
+ function sealedShape(repoRoot, environments, member) {
125
+ const text = environments.map((env) => readText(repoRoot, seedMemberFile(env, member))).find((item) => item !== null) ?? null;
126
+ let keys = [];
127
+ try {
128
+ keys = text === null ? [] : envelopeOf(text).keys;
129
+ }
130
+ catch {
131
+ keys = [];
132
+ }
133
+ return Object.fromEntries(keys.map((key) => [key, SEALED]));
134
+ }
135
+ /** The findings of a document entry: a document off its schema (secret references read as their sealed keys) and its secret references. */
136
+ function documentFindings(repoRoot, level, entry, scope) {
137
+ const manifest = `${level.dir}/${SEEDS_MANIFEST}`;
138
+ const schema = entrySchema(repoRoot, entry);
139
+ if (schema === null)
140
+ return [found(HFS_SEED_DATA_SCHEMA, manifest, `entry ${entry.id} names the schema ${String(entry.schema)}, which is not a readable JSON or YAML schema`)];
141
+ return seedDocuments(path.join(repoRoot, level.dir), entry).flatMap((document) => {
142
+ const file = `${level.dir}/${document.path}`;
143
+ if (document.error !== undefined)
144
+ return [found(HFS_SEED_DATA_SCHEMA, file, `${file} does not parse: ${document.error}`)];
145
+ const errors = validateAgainstSchema(resolveSecrets(document.doc, (member) => sealedShape(repoRoot, scope.environments, member)), schema);
146
+ return [
147
+ ...(errors.length > 0 ? [found(HFS_SEED_DATA_SCHEMA, file, `${file} does not validate against ${String(entry.schema)}: ${errors[0] ?? ''}`)] : []),
148
+ ...referenceFindings(file, document.doc, scope.environments, scope.files),
149
+ ];
150
+ });
151
+ }
152
+ /** The findings of one entry's files: DDL in SQL, documents and secret references, test markers where production applies the level. */
153
+ function entryFindings(repoRoot, level, entry, scope) {
154
+ const findings = isDocumentEntry(entry) ? documentFindings(repoRoot, level, entry, scope) : [];
155
+ // The one test account a level declares holds test data by design; every other entry of a production level holds none.
156
+ const judged = scope.production && entry.kind !== 'test-account';
157
+ for (const file of entryFiles(path.join(repoRoot, level.dir), entry).map((name) => `${level.dir}/${name}`)) {
158
+ if (judged && TEST_MARKER.test(path.posix.basename(file)))
159
+ findings.push(found(HFS_SEED_TEST_DATA, file, `${file} is named as test data in a level a production environment applies`));
160
+ const text = entry.kind === 'files' ? null : readText(repoRoot, file);
161
+ if (text === null)
162
+ continue;
163
+ if (entry.kind === 'sql')
164
+ for (const { line, word } of schemaStatements(text))
165
+ findings.push(found(HFS_SEED_DDL, file, `${file}:${line} runs ${word}, a schema change; seed data is never a migration: the schema change is a migration (BE-PERSISTENCE-1) and a seed only writes rows`, { line }));
166
+ const marker = judged ? seedLiterals(file, text).find((literal) => TEST_MARKER.test(literal.v)) : undefined;
167
+ if (marker)
168
+ findings.push(found(HFS_SEED_TEST_DATA, file, `${file}:${marker.line} holds test data (an example.test identity or a uat- or test- prefix) in a level a production environment applies; the one test account of an environment is its test-account entry`, { line: marker.line }));
169
+ }
170
+ return findings;
171
+ }
172
+ /** The findings of R375 over the tracked files of an app root. */
173
+ export function seedsFindings({ repoRoot, files }) {
174
+ const seeds = files.filter((file) => SEED_PATH.test(file));
175
+ if (seeds.length === 0)
176
+ return [];
177
+ const environments = declaredEnvironments(repoRoot);
178
+ const findings = seeds.flatMap((file) => shapeFinding(file, environments) ?? []);
179
+ const placed = seeds.filter((file) => !findings.some((finding) => finding.path === file) && !file.endsWith(`/${SEEDS_KEEP}`));
180
+ const shared = readSeedLevel(repoRoot, null);
181
+ const levels = [shared, ...[...(environments ?? new Map()).keys()].map((env) => readSeedLevel(repoRoot, env, new Set(shared.entries.map((entry) => entry.id))))];
182
+ const tracked = new Set(files);
183
+ for (const level of levels) {
184
+ findings.push(...manifestFindings(repoRoot, level, placed));
185
+ const scope = {
186
+ environments: level.env === null ? [...(environments?.keys() ?? [])] : [level.env],
187
+ files: tracked,
188
+ production: level.env === null || environments?.get(level.env) !== 'docker-compose',
189
+ };
190
+ for (const entry of level.entries)
191
+ findings.push(...entryFindings(repoRoot, level, entry, scope));
192
+ }
193
+ return findings;
194
+ }