@starci/hfs 4.2.17 → 4.3.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 (108) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +7 -0
  3. package/package.json +1 -1
  4. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  5. package/runtime/knowledge/hfs/rules.yaml +41 -13
  6. package/runtime/knowledge/hfs/slots.yaml +99 -43
  7. package/runtime/knowledge/patterns/be/api.yaml +64 -1
  8. package/runtime/knowledge/patterns/be/cli.yaml +23 -5
  9. package/runtime/knowledge/patterns/be/webhooks.yaml +187 -32
  10. package/runtime/knowledge/patterns/fe/folder.yaml +3 -0
  11. package/runtime/modules/kernel/failure-codes.yaml +15 -4
  12. package/runtime/scripts/api/git/cat-file.mjs +5 -0
  13. package/runtime/scripts/api/sops/resolve-sops.mjs +7 -0
  14. package/runtime/scripts/api/sops/seal.mjs +1 -1
  15. package/runtime/scripts/hfs/architecture/backend-app-files.mjs +110 -0
  16. package/runtime/scripts/hfs/architecture/backend.mjs +20 -3
  17. package/runtime/scripts/hfs/architecture/background-unowned.mjs +157 -41
  18. package/runtime/scripts/hfs/architecture/canon-clones.mjs +240 -0
  19. package/runtime/scripts/hfs/architecture/canon-jsx-clones.mjs +143 -0
  20. package/runtime/scripts/hfs/architecture/clones.mjs +17 -8
  21. package/runtime/scripts/hfs/architecture/connection-map.mjs +14 -12
  22. package/runtime/scripts/hfs/architecture/context-map.mjs +25 -14
  23. package/runtime/scripts/hfs/architecture/context-owner.mjs +1 -2
  24. package/runtime/scripts/hfs/architecture/dead-exports.mjs +14 -9
  25. package/runtime/scripts/hfs/architecture/frontend-block-render.mjs +104 -0
  26. package/runtime/scripts/hfs/architecture/frontend-world-render.mjs +30 -11
  27. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +16 -3
  28. package/runtime/scripts/hfs/architecture/machine-ast.mjs +17 -0
  29. package/runtime/scripts/hfs/architecture/module-per-transport.mjs +21 -12
  30. package/runtime/scripts/hfs/architecture/register-once.mjs +13 -5
  31. package/runtime/scripts/hfs/architecture/required-files.mjs +2 -1
  32. package/runtime/scripts/hfs/architecture/staged-consumers.mjs +37 -0
  33. package/runtime/scripts/hfs/architecture/supabase-ast.mjs +3 -6
  34. package/runtime/scripts/hfs/architecture/supabase-be.mjs +1 -6
  35. package/runtime/scripts/hfs/architecture/tiers.mjs +8 -1
  36. package/runtime/scripts/hfs/architecture/typescript.mjs +1 -8
  37. package/runtime/scripts/hfs/architecture/unit-spec-providers.mjs +1 -11
  38. package/runtime/scripts/hfs/check.mjs +18 -26
  39. package/runtime/scripts/hfs/manifest-shape.mjs +27 -2
  40. package/runtime/scripts/hfs/path-findings.mjs +32 -2
  41. package/runtime/scripts/hfs/rules/database-migrations.mjs +36 -35
  42. package/runtime/scripts/hfs/rules/database.mjs +2 -2
  43. package/runtime/scripts/hfs/rules/edition.mjs +5 -5
  44. package/runtime/scripts/hfs/rules/proof-commands.mjs +2 -0
  45. package/runtime/scripts/hfs/rules/secrets.mjs +13 -0
  46. package/runtime/scripts/hfs/rules/stacks-runtime.mjs +128 -0
  47. package/runtime/scripts/hfs/rules/stacks.mjs +13 -8
  48. package/runtime/scripts/hfs/secret-document.mjs +97 -0
  49. package/runtime/scripts/hfs/secret-errors.mjs +20 -0
  50. package/runtime/scripts/hfs/secret-files.mjs +124 -0
  51. package/runtime/scripts/hfs/secret-init.mjs +192 -0
  52. package/runtime/scripts/hfs/secret-output.mjs +100 -0
  53. package/runtime/scripts/hfs/secret-sops.mjs +98 -0
  54. package/runtime/scripts/hfs/secret-store.mjs +161 -0
  55. package/runtime/scripts/hfs/secret.mjs +135 -195
  56. package/runtime/scripts/hfs/slot-imports.mjs +3 -1
  57. package/runtime/scripts/hfs/slot-required.mjs +13 -0
  58. package/runtime/scripts/hfs/slot-semantic-problems.mjs +2 -1
  59. package/runtime/scripts/hfs/tree-findings.mjs +22 -0
  60. package/runtime/scripts/lib/roots.mjs +59 -0
  61. package/runtime/scripts/lib/swarm-stack.mjs +139 -0
  62. package/runtime/scripts/lib/ts-analysis.mjs +20 -0
  63. package/scaffold/add-cli-lite.mjs +10 -11
  64. package/scaffold/add-inbox-lite.mjs +15 -84
  65. package/scaffold/add-plan.mjs +183 -0
  66. package/scaffold/add-table.mjs +44 -5
  67. package/scaffold/add.mjs +33 -166
  68. package/scaffold/app.mjs +5 -5
  69. package/scaffold/image.mjs +2 -2
  70. package/src/main.mjs +12 -6
  71. package/sync/index.mjs +23 -16
  72. package/templates/app/gitignore +1 -0
  73. package/templates/app/gitignore-lite +1 -0
  74. package/templates/app/hooks/husky/pre-commit +7 -12
  75. package/templates/app/skeleton/.sops.yaml +1 -1
  76. package/templates/app/skeleton-lite/.sops.yaml +1 -1
  77. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +16 -0
  78. package/templates/app/skeleton-lite/.starciwork/workspace.yaml +9 -0
  79. package/templates/app/skeleton-lite/README.md +4 -2
  80. package/templates/app/starciwork.gitignore-lite +6 -0
  81. package/templates/be/inbox/database-client.spec.ts.tpl +15 -20
  82. package/templates/be/inbox/database-client.ts.tpl +7 -22
  83. package/templates/be/inbox/service.spec.ts.tpl +16 -20
  84. package/templates/be/inbox/service.ts.tpl +6 -10
  85. package/templates/be/inbox/sql.ts.tpl +2 -2
  86. package/templates/be/inbox/webhook-door.spec.ts.tpl +3 -3
  87. package/templates/be/inbox/webhook-door.ts.tpl +4 -3
  88. package/templates/be/patterns/webhooks/webhook.door.spec.ts.tpl +3 -3
  89. package/templates/be/patterns/webhooks/webhook.door.ts.tpl +3 -3
  90. package/templates/be/skeleton/apps/__app__/src/__app__.env.ts +21 -0
  91. package/templates/be/skeleton/apps/__app__/src/__app__.options.ts +1 -1
  92. package/templates/be/skeleton/apps/__app__/src/app.module.ts +7 -39
  93. package/templates/be/skeleton/apps/__app__/src/compose/platform.ts +42 -0
  94. package/templates/be/skeleton/apps/__app__/src/main.ts +3 -9
  95. package/templates/be/skeleton/apps/cli/src/cli.env.ts +13 -0
  96. package/templates/be/skeleton/apps/cli/src/cli.options.ts +6 -8
  97. package/templates/be/skeleton/apps/cli/src/main.ts +3 -2
  98. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +6 -6
  99. package/templates/be/skeleton-lite/apps/api/src/api.env.ts +25 -0
  100. package/templates/be/skeleton-lite/apps/api/src/api.options.ts +1 -1
  101. package/templates/be/skeleton-lite/apps/api/src/app.module.ts +6 -42
  102. package/templates/be/skeleton-lite/apps/api/src/compose/platform.ts +45 -0
  103. package/templates/be/skeleton-lite/apps/api/src/main.ts +4 -11
  104. package/templates/be/skeleton-lite/apps/cli/src/cli.env.ts +17 -0
  105. package/upgrade/file-state.mjs +68 -0
  106. package/upgrade/index.mjs +7 -56
  107. package/runtime/scripts/api/git/log.mjs +0 -5
  108. package/templates/app/skeleton-lite/.starciwork/shell/index.yaml +0 -55
@@ -16,7 +16,7 @@
16
16
  // HFS_SLOT_REQUIRED_MISSING a file or directory a required slot (or an instance of one) must contain
17
17
  // HFS_MIN_INSTANCES fewer instances of a slot than minInstances
18
18
  // HFS_PLAINTEXT_SECRET (R06, rules/secrets.mjs) a plaintext secret file or value in a tracked file; a `.enc` that is no sops envelope
19
- // HFS_STACKS_SHAPE (R10, rules/stacks.mjs) a `.starcistacks` path outside the standard shape, or a Sonar owner that is not the host
19
+ // HFS_STACKS_SHAPE (R10, rules/stacks.mjs) invalid stack shape, Swarm runtime policy, host crontab installation or lite backing duplication
20
20
  // HFS_CI_MISSING_CANON (R13, rules/pipeline.mjs) CI without the pinned `starci app check`, pre-push without typecheck or lint
21
21
  // HFS_DEP_VERSION_SKEW (R14, rules/deps.mjs) a dependency at two versions in the workspace, or a nested copy in the lockfile
22
22
  // HFS_CONTRACT_SNAPSHOT_DRIFT (R23, rules/contract.mjs) an uncommitted back-end snapshot, or a front-end copy that differs from it
@@ -25,7 +25,7 @@
25
25
  // FE_STACK_FORBIDDEN (rules/fe-stack.mjs) a front-end manifest (the fe side's, or the app root's minus the back end's own libraries)
26
26
  // declares a package knowledge/hfs/fe-stack.yaml forbids
27
27
  // HFS_LITE_SECRET_CUSTODY (R220, rules/supabase-secrets.mjs) under edition lite: secret material or a forbidden custody path; the R06 scan itself runs for every edition
28
- // HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, stack or browser script, or a
28
+ // HFS_EDITION_FORBIDDEN_PRESENT (R212, rules/edition.mjs) under edition lite: an e2e, contract, test-stack or browser script, or a
29
29
  // dependency of self-hosted infrastructure (test containers, fault proxy, BullMQ, Kafka, Redis)
30
30
  // DB_MIGRATION_SHAPE ... DB_TYPES_DRIFT (R213-R216, rules/database.mjs, async: passed in as extraFindings) the Supabase migrations, policies, definer functions, buckets, config.toml and generated types
31
31
  // BE_SPEC_PLACEMENT (R102, rules/spec-placement.mjs) a spec or test file outside the four test layers, scripts/ and tools/ included
@@ -67,14 +67,15 @@ import { diff as gitDiff } from '../api/git/diff.mjs';
67
67
  import { mergeBase } from '../api/git/merge-base.mjs';
68
68
  import { gitOutputOf } from '../lib/git.mjs';
69
69
  import { posixPath } from '../lib/path-key.mjs';
70
- import { readTree, treeFacts, untrackedEntries } from './tree.mjs';
70
+ import { treeFindings } from './tree-findings.mjs';
71
71
  import { contractFindings } from './rules/contract.mjs';
72
72
  import { appRootFindings } from './rules/app-root.mjs';
73
73
  import { frontendFindings } from './rules/frontend-tree.mjs';
74
74
  import { lintSuppressionFindings } from './rules/lint-suppression.mjs';
75
75
  import { repoLocalCheckFindings } from './rules/repo-local-checks.mjs';
76
76
  import { supabaseSecretFindings } from './rules/supabase-secrets.mjs';
77
- import { pathFindings } from './path-findings.mjs';
77
+ import { untrackedSecretFindings } from './rules/secrets.mjs';
78
+ import { liteStarciworkFinding, pathFindings } from './path-findings.mjs';
78
79
  import { onLintSurface } from './architecture/surface.mjs';
79
80
  import { checkAppRoot, trackedTreeView } from './architecture/hfs.mjs';
80
81
  import { testTopologyFindings } from './rules/test-topology.mjs';
@@ -83,6 +84,7 @@ import { editionFindings } from './rules/edition.mjs';
83
84
  import { feStackFindings } from './rules/fe-stack.mjs';
84
85
  import { byCodeUnit } from '../lib/list.mjs';
85
86
  import { pinFindings } from './pin-findings.mjs';
87
+ import { requiredFileApplies } from './slot-required.mjs';
86
88
  // The database rules (R213-R216) read SQL through a WASM parser, so they are async: `starci app check` calls them beside the other emitters and passes the findings in as `extraFindings`.
87
89
  export { checkDatabase } from './rules/database.mjs';
88
90
 
@@ -103,7 +105,7 @@ export const CHECK_CODES = Object.freeze([
103
105
  'HFS_MANAGED_FILE_DRIFT', 'HFS_TOOL_CONFIG_LOCAL', 'HFS_RULE_OFF_WITHOUT_REPLACEMENT', 'HFS_TS_STRICT',
104
106
  'HFS_PLAINTEXT_SECRET', 'HFS_STACKS_SHAPE', 'HFS_CI_MISSING_CANON', 'HFS_DEP_VERSION_SKEW', 'HFS_CONTRACT_SNAPSHOT_DRIFT',
105
107
  'BE_TEST_TOPOLOGY', 'BE_SPEC_PLACEMENT', 'HFS_REPO_LOCAL_CHECK', 'HFS_LINT_SUPPRESSION_FILE', 'HFS_PROOF_COMMAND_FILE_MISSING', 'HFS_PEER_INTEGRATION_MISSING', 'BE_INTEGRATION_SPEC_MISSING', 'FE_GRAPHQL_CONTRACT', 'FE_NO_TESTS', 'HFS_MONO_WORKSPACES', 'HFS_MONO_FE_WORKSPACE', 'HFS_MONO_NEST_PROJECTS', 'HFS_MONO_WORKSPACE_DEP', 'BE_CLI_REQUIRED', 'FE_WIRE_GENERATED', 'FE_I18N_PLACEMENT', 'FE_I18N_CATALOG', 'HFS_SERVICE_PLACEMENT', 'HFS_IMAGE_UNPINNED', 'HFS_SERVICE_STACK_DECLARATION', 'HFS_EVENT_CONTRACT', 'BE_ASYNC_SPEC_MISSING', 'BE_SAGA_STEP_COMPENSATION', 'BE_SAGA_STATE_VERSIONED', 'BE_SAGA_EVENT_CONTRACT', 'BE_SAGA_CONSUMER_DEDUPE', 'BE_EVENT_CLASS_CONTRACT', 'BE_PATTERN_SPEC_MISSING', 'BE_CONTRACT_BREAKING', 'BE_KIND_DECLARATION', 'BE_KIND_EMPTY', 'HFS_DOCKER_BUILD_CONTEXT', 'HFS_DOCKER_STAGES', 'HFS_DOCKER_ENTRY', 'HFS_DOCKER_BASE_PIN', 'HFS_DOCKER_SECRETS', 'BE_SAGA_E2E_MISSING',
106
- 'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY',
108
+ 'FE_STACK_FORBIDDEN', 'HFS_EDITION_FORBIDDEN_PRESENT', 'HFS_LITE_SECRET_CUSTODY', 'HFS_STARCIWORK_LITE_FORBIDDEN',
107
109
  'DB_MIGRATION_SHAPE', 'DB_RLS_REQUIRED', 'DB_DYNAMIC_DDL', 'DB_POLICY_SHAPE', 'DB_DEFINER_SAFE', 'DB_STORAGE_POLICY', 'DB_CONFIG_POLICY', 'DB_TYPES_DRIFT',
108
110
  'HFS_GITIGNORE_BLOCK_DRIFT', 'HFS_SONAR_CONFIG', 'HFS_COVERAGE_SCOPE_DRIFT', 'HFS_FORMAT',
109
111
  'HFS_EMPTY_DIR', 'HFS_GHOST_TREE', 'HFS_UNTRACKED_ROOT_ENTRY',
@@ -182,6 +184,7 @@ function summarize(findings) {
182
184
  }
183
185
  return {
184
186
  error: findings.filter((f) => f.level === 'error').length,
187
+ warning: findings.filter((f) => f.level === 'warning').length,
185
188
  info: findings.filter((f) => f.level === 'info').length,
186
189
  byCode,
187
190
  };
@@ -197,25 +200,6 @@ function judgedFindings(findings, edition, root) {
197
200
  return findings.filter((finding) => catalog.judgedIn(finding.code, edition));
198
201
  }
199
202
 
200
- /** The tree findings of R03: empty directories, ghost siblings, untracked entries outside an ignored slot. */
201
- function treeFindings({ repoRoot, resolver }) {
202
- const inIgnoredSlot = (rel) => resolver.classifyPath(`${rel}/.probe`).tracking === 'ignored';
203
- const findings = [];
204
- const facts = treeFacts(readTree(repoRoot, { isIgnored: inIgnoredSlot }));
205
- for (const { path: dir, below } of facts.empty) {
206
- findings.push({ code: 'HFS_EMPTY_DIR', level: 'error', path: dir, below, message: `${dir} has no file below it` + (below ? ` (nor in its ${below} sub-director${below === 1 && 'y' || 'ies'})` : '') + '; git tracks no empty directory, so it is a leftover' });
207
- }
208
- for (const { path: dir, of, distance } of facts.ghosts) {
209
- findings.push({ code: 'HFS_GHOST_TREE', level: 'error', path: dir, of, distance, message: `${dir} is empty and ${distance} edit${distance === 1 ? '' : 's'} from its sibling ${of}: a renamed or misspelt structure that was never removed` });
210
- }
211
- for (const entry of untrackedEntries(repoRoot)) {
212
- const bare = entry.replace(/\/$/, '');
213
- const ignored = entry.endsWith('/') ? inIgnoredSlot(bare) : resolver.classifyPath(bare).tracking === 'ignored';
214
- if (!ignored) findings.push({ code: 'HFS_UNTRACKED_ROOT_ENTRY', level: 'error', path: bare, message: `${entry} is neither tracked nor git-ignored, and no ignored slot owns it` });
215
- }
216
- return findings;
217
- }
218
-
219
203
  /** A side-relative path (or null) as an app-relative one. */
220
204
  const onSide = (side, p) => (p ? path.posix.normalize(`${side}/${p}`) : p);
221
205
 
@@ -223,7 +207,7 @@ function appendRequiredPathFindings(findings, resolver, files, required, present
223
207
  const missing = new Set();
224
208
  const missingFile = (slot, p, via) => {
225
209
  const key = `${slot}|${p}`;
226
- if (missing.has(key) || present(p)) return;
210
+ if (missing.has(key) || present(p) || !requiredFileApplies(slot, p, files)) return;
227
211
  missing.add(key);
228
212
  const app = appOf(p);
229
213
  const appNote = app ? ` (app ${app})` : '';
@@ -347,7 +331,9 @@ export function checkRepo({ repoRoot, root = skillRoot, declaration, files, only
347
331
  findings.push(...keep(scopeFindings({ repoRoot, root, repo, resolver, files: tracked, scoped, manifest }), repoRoot));
348
332
  }
349
333
  findings.push(...extraFindings);
350
- if (tree) findings.push(...treeFindings({ repoRoot, resolver }));
334
+ const untrackedSecrets = untrackedSecretFindings({ repoRoot, files: tracked, repo });
335
+ findings.push(...untrackedSecrets);
336
+ if (tree) findings.push(...treeFindings({ repoRoot, resolver, tracked, reportedUntracked: new Set(untrackedSecrets.map((finding) => finding.path)) }));
351
337
 
352
338
  // The app-root tree check reports machine codes: their why is read with the check's own.
353
339
  const judged = judgedFindings(findings, repo.edition ?? 'full', root);
@@ -461,6 +447,12 @@ export function explainPath({ repoRoot, input, root = skillRoot, declaration, ma
461
447
  const resolver = createSlotResolver(manifest, repo);
462
448
  const why = readWhy(root);
463
449
  const c = resolver.classifyPath(input);
450
+ const dir = /[\\/]$/.test(input) || (repoRoot && fs.existsSync(path.join(repoRoot, c.path)) && fs.statSync(path.join(repoRoot, c.path)).isDirectory());
451
+ const liteWork = liteStarciworkFinding(c.path, resolver, { dir });
452
+ if (liteWork) {
453
+ const entry = why[liteWork.code];
454
+ return { ...c, ...liteWork, status: 'forbidden', titleVi: entry.titleVi, whyVi: entry.whyVi };
455
+ }
464
456
  if (c.status === 'no-slot') {
465
457
  return { path: c.path, status: 'no-slot', code: 'HFS_SLOT_UNDECLARED', nearest: c.nearest, titleVi: why.HFS_SLOT_UNDECLARED.titleVi, whyVi: why.HFS_SLOT_UNDECLARED.whyVi };
466
458
  }
@@ -323,6 +323,12 @@ function runtimeParamProblems(rp) {
323
323
  ];
324
324
  }
325
325
 
326
+ const folderKindsValid = list => strList(list) && list.length > 0 && new Set(list).size === list.length && list.every(kind => NAME.test(kind));
327
+ const kindPairValid = pair => isPlainObject(pair) && Object.keys(pair).sort(byCodeUnit).join() === 'from,to'
328
+ && ['from', 'to'].every(side => folderKindsValid(pair[side]));
329
+ const kindRestrictionValid = kinds => isPlainObject(kinds) && Object.keys(kinds).length > 0
330
+ && Object.entries(kinds).every(([target, pair]) => NAME.test(target) && kindPairValid(pair));
331
+
326
332
  /** Shape problems of the tier map of one profile. */
327
333
  export function tierMapProblems(profile, tiers) {
328
334
  const bad = [];
@@ -330,14 +336,33 @@ export function tierMapProblems(profile, tiers) {
330
336
  for (const [tier, def] of Object.entries(tiers)) {
331
337
  if (!NAME.test(tier)) bad.push(`tiers.${profile}.${tier} is not a tier name`);
332
338
  if (!isPlainObject(def) || !Array.isArray(def.mayImport) || !def.mayImport.every((t) => NAME.test(String(t)))) bad.push(`tiers.${profile}.${tier}.mayImport must be a list of tier names`);
333
- for (const key of Object.keys(def ?? {})) if (!['mayImport', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
339
+ if (def?.mayImportKinds !== undefined && !kindRestrictionValid(def.mayImportKinds)) bad.push(`tiers.${profile}.${tier}.mayImportKinds must map tier names to {from, to} non-empty lists of unique folder kinds`);
340
+ for (const key of Object.keys(def ?? {})) if (!['mayImport', 'mayImportKinds', 'acyclic', 'lowerLayerOnly'].includes(key)) bad.push(`tiers.${profile}.${tier}.${key} is not a tier field`);
341
+ }
342
+ return bad;
343
+ }
344
+
345
+ function tierFolderKindProblems(manifest, profile, tier, kinds, at) {
346
+ const known = new Set(manifest.slots.filter((slot) => slot.profiles.includes(profile) && slot.tier === tier).flatMap((slot) => [...(slot.kinds ?? []), ...(slot.layers ?? [])]));
347
+ return kinds.filter(kind => !known.has(kind)).map(kind => `${at} names ${kind}, which is not a folder kind of the ${tier} tier`);
348
+ }
349
+
350
+ /** Kind restrictions narrow an allowed tier edge and name kinds that its source and target slots actually declare. */
351
+ export function tierKindProblems(manifest, profile) {
352
+ const bad = [];
353
+ for (const [tier, def] of Object.entries(manifest.tiers[profile])) {
354
+ for (const [target, kinds] of Object.entries(def.mayImportKinds ?? {})) {
355
+ const at = `tiers.${profile}.${tier}.mayImportKinds.${target}`;
356
+ if (!def.mayImport.includes(target)) bad.push(`${at} restricts a tier that mayImport does not allow`);
357
+ bad.push(...tierFolderKindProblems(manifest, profile, tier, kinds.from, `${at}.from`), ...tierFolderKindProblems(manifest, profile, target, kinds.to, `${at}.to`));
358
+ }
334
359
  }
335
360
  return bad;
336
361
  }
337
362
 
338
363
  /** The semantic rules of a runtime manifest beyond the slot rules both kinds share: tier names and the schema major. */
339
364
  export function runtimeSemanticProblems(m) {
340
- const bad = [];
365
+ const bad = tierKindProblems(m, RUNTIME_KIND);
341
366
  const tiers = m.tiers.runtime;
342
367
  for (const [tier, def] of Object.entries(tiers)) {
343
368
  for (const target of def.mayImport) if (!(target in tiers)) bad.push(`tiers.runtime.${tier}.mayImport names ${target}, which is not a runtime tier`);
@@ -3,6 +3,7 @@
3
3
  // (scripts/hfs/project-graph.mjs) runs it over the same tree and serves the findings on a TypeScript file as ESLint reports.
4
4
  // HFS_SLOT_UNDECLARED / HFS_SLOT_AMBIGUOUS / HFS_SLOT_NOT_ENABLED / HFS_FORBIDDEN_PRESENT / HFS_TOOL_CONFIG_LOCAL / HFS_TRACKED_MUST_BE_IGNORED (R01, R16)
5
5
  // BE_SOURCE_FORM (R89, the file name), BE_SPEC_PLACEMENT (R102)
6
+ import fs from 'node:fs';
6
7
  import path from 'node:path';
7
8
  import { allowsFile } from './allows.mjs';
8
9
  import { isFeTestPath } from './rules/fe-no-tests.mjs';
@@ -30,8 +31,11 @@ function sourceFormContext(file, resolver, suffixes, bannedSuffixes) {
30
31
  const slot = resolver.slot(c.slot);
31
32
  if ([...(slot?.requires ?? []), ...(slot?.allows ?? [])].includes(base)) return null;
32
33
  const admitted = allowsFile(resolver, file);
34
+ // Environment parsing is an app slot role, not a source suffix admitted outside that matching app file.
35
+ if (c.slot.startsWith('be.app.') && admitted?.allowed && admitted.entry === '<app>.env.ts') return null;
33
36
  if (admitted?.allowed && admitted.entry?.includes('/') && path.posix.basename(admitted.entry) === base) return null;
34
- if (admitted?.allowed && PLAIN_ENTRY.test(admitted.entry ?? '') && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
37
+ // A plain entry names its file by a variable only (`<x>.ts`, or `compose/<area>.ts` of an app slot): a kebab name is its form.
38
+ if (admitted?.allowed && PLAIN_ENTRY.test(path.posix.basename(admitted.entry ?? '')) && KEBAB.test(base.slice(0, -'.ts'.length))) return null;
35
39
  if (c.slot === 'be.persistence' && path.posix.basename(path.posix.dirname(file)) === 'migrations') return null;
36
40
  return { c, base, suffixes, bannedSuffixes };
37
41
  }
@@ -54,7 +58,7 @@ function sourceFormFinding(file, { c, base, suffixes, bannedSuffixes }, boundSuf
54
58
 
55
59
  /**
56
60
  * BE_SOURCE_FORM (R89): every tracked src/ or apps/ TypeScript file of a back end is index.ts, main.ts, a migration of
57
- * be.persistence, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
61
+ * be.persistence, the matching <app>.env.ts explicitly admitted by its app slot, or <kebab-name>.<suffix>.ts with <suffix> in the closed vocabulary ruleParams.be.suffixes (a name such
58
62
  * as order.service.spec.ts keeps its inner words kebab-case). A suffix of ruleParams.be.bannedSuffixes anywhere in the
59
63
  * name is refused by name. Paths no slot owns are HFS_SLOT_UNDECLARED's, not this code's.
60
64
  */
@@ -86,6 +90,8 @@ function forbiddenPathFinding(file, c, resolver) {
86
90
  }
87
91
 
88
92
  function pathStatusFinding(file, resolver) {
93
+ const liteWork = liteStarciworkFinding(file, resolver);
94
+ if (liteWork) return liteWork;
89
95
  const c = resolver.classifyPath(file);
90
96
  if (c.status === 'no-slot') {
91
97
  const nearestNote = c.nearest ? `; nearest slot ${c.nearest.slot} (${c.nearest.pattern}), matched ${c.nearest.matchedPrefix || '.'} then expected ${c.nearest.expectedNext ?? 'nothing'}` : '';
@@ -98,6 +104,30 @@ function pathStatusFinding(file, resolver) {
98
104
  return null;
99
105
  }
100
106
 
107
+ /** The lite Work boundary, read from app.starciwork's edition-specific admission list, including paths a narrower slot owns. */
108
+ export function liteStarciworkFinding(file, resolver, { dir = false } = {}) {
109
+ if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return null;
110
+ const p = resolver.classifyPath(file).path;
111
+ if (!p.startsWith('.starciwork/')) return null;
112
+ const admitted = allowsFile(resolver, dir ? `${p}/.probe` : p);
113
+ if (admitted?.slot === 'app.starciwork' && admitted.allowed) return null;
114
+ return { code: 'HFS_STARCIWORK_LITE_FORBIDDEN', level: 'error', path: p, slot: 'app.starciwork', message: `${p}: lite .starciwork holds only workspace.yaml and brand/ (plus .gitignore); remove this path or use the full edition` };
115
+ }
116
+
117
+ /** Local lite Work leftovers, including metadata ignored by an old full-edition .gitignore. */
118
+ export function liteStarciworkTreeFindings(repoRoot, resolver, tracked) {
119
+ if (resolver.repo?.edition !== 'lite' || !resolver.slot('app.starciwork')) return [];
120
+ let entries = [];
121
+ try { entries = fs.readdirSync(path.join(repoRoot, '.starciwork'), { withFileTypes: true }); } catch { /* Work is optional. */ }
122
+ const trackedSet = new Set(tracked);
123
+ return entries.flatMap(entry => {
124
+ const file = `.starciwork/${entry.name}`;
125
+ if (trackedSet.has(file)) return [];
126
+ const finding = liteStarciworkFinding(file, resolver, { dir: entry.isDirectory() });
127
+ return finding ? [finding] : [];
128
+ });
129
+ }
130
+
101
131
  /** The findings of the slot manifest over `files` (repository-relative tracked paths) of a repository of `profile`. */
102
132
  export function pathFindings({ files, resolver, profile }) {
103
133
  const findings = [];
@@ -1,7 +1,6 @@
1
1
  // database-migrations.mjs - L02 migration name, UTC ordering and base-branch immutability checks. Git reads go
2
2
  // through the runtime API call files, and specs may inject the same small runner contract used by database.mjs.
3
3
  import { lsTree } from '../../api/git/ls-tree.mjs';
4
- import { log } from '../../api/git/log.mjs';
5
4
  import { mergeBase } from '../../api/git/merge-base.mjs';
6
5
  import { show } from '../../api/git/show.mjs';
7
6
  import { withoutGitLocalEnv } from '../../lib/git.mjs';
@@ -27,7 +26,6 @@ export function defaultGit(args, { cwd }) {
27
26
  if (verb === 'merge-base') { const sha = mergeBase(cwd, rest[0], rest[1]); return { ok: sha !== null, stdout: sha ?? '' }; }
28
27
  let call = show;
29
28
  if (verb === 'ls-tree') call = lsTree;
30
- else if (verb === 'log') call = log;
31
29
  const result = call(rest, { cwd, env, maxBuffer: 64 * 1024 * 1024 });
32
30
  return { ok: !result.error && result.status === 0, stdout: result.stdout };
33
31
  }
@@ -45,11 +43,11 @@ async function baseShaOf(git, repoRoot, base) {
45
43
  return found;
46
44
  }
47
45
 
48
- async function baseMigrationNames(git, repoRoot, sha) {
46
+ async function migrationFilesAt(git, repoRoot, sha) {
49
47
  const result = await runGit(git, repoRoot, ['ls-tree', '-r', '--name-only', '-z', sha, '--', MIGRATIONS_DIR]);
50
48
  if (!result.ok) return [];
51
49
  const prefix = `${MIGRATIONS_DIR}/`;
52
- return result.stdout.split('\0').filter(Boolean).filter(isActiveMigration).map((entry) => entry.slice(prefix.length));
50
+ return result.stdout.split('\0').filter((entry) => entry.startsWith(prefix));
53
51
  }
54
52
 
55
53
  async function baseFileText(git, repoRoot, sha, file) {
@@ -58,46 +56,48 @@ async function baseFileText(git, repoRoot, sha, file) {
58
56
  return result.ok ? result.stdout : null;
59
57
  }
60
58
 
61
- async function migrationTimeLimit(git, repoRoot, file, now) {
62
- const result = await runGit(git, repoRoot, ['log', '-1', '--format=%ct', '--', file]);
63
- const seconds = Number(result.stdout.trim());
64
- if (result.ok && Number.isFinite(seconds) && seconds > 0) return { at: seconds * 1000, tracked: true };
65
- return { at: now(), tracked: false };
66
- }
67
-
68
- async function localMigrationFindings(local, repoRoot, git, now) {
59
+ function localMigrationFindings(local) {
69
60
  const findings = [];
70
61
  const stamps = new Map();
71
- await eachInOrder(local, async (entry) => {
62
+ for (const entry of local) {
72
63
  if (!entry.match) {
73
64
  findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is not named <ts14>_<kebab>.sql; a migration is created by \`npm run db:new\` (supabase migration new), never renamed by hand`, { expected: '<ts14>_<kebab>.sql' }));
74
- return;
65
+ continue;
75
66
  }
76
67
  const stamp = migrationStamp(entry.match[1]);
77
68
  if (stamp === null) {
78
- findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} carries ${entry.match[1]}, which is no valid UTC time; the stamp is the real creation time`, { stamp: entry.match[1] }));
79
- return;
80
- }
81
- const limit = await migrationTimeLimit(git, repoRoot, entry.file, now);
82
- if (stamp > limit.at) {
83
- const boundary = limit.tracked ? `the file's commit time ${new Date(limit.at).toISOString()}` : `the current time ${new Date(limit.at).toISOString()}`;
84
- findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, in the future relative to ${boundary}; a migration stamp is its real creation time, never a hand-picked date`, { stamp: entry.match[1], tracked: limit.tracked }));
69
+ findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} carries ${entry.match[1]}, which is no valid UTC time; a migration stamp must name a valid UTC instant`, { stamp: entry.match[1] }));
70
+ continue;
85
71
  }
86
72
  if (stamps.has(stamp)) findings.push(found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} shares stamp ${entry.match[1]} with ${stamps.get(stamp)}; stamps are strictly increasing`, { stamp: entry.match[1], other: stamps.get(stamp) }));
87
73
  stamps.set(stamp, entry.file);
88
- });
74
+ }
89
75
  return findings;
90
76
  }
91
77
 
92
- async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax) {
78
+ /** HEAD owns the append boundary. Archived names retire the old chain, so a replacement baseline starts afresh. */
79
+ function migrationHistory(files, retired, localNames) {
80
+ const prefix = `${MIGRATIONS_DIR}/`;
81
+ const onHead = new Set(files.filter(isActiveMigration).map((file) => file.slice(prefix.length)));
82
+ const retiredNames = new Set([...retired, ...files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`)).map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1))].filter((name) => !localNames.has(name)));
83
+ const stamps = [...onHead].filter((name) => !retiredNames.has(name)).map((name) => MIGRATION_NAME.exec(name)?.[1]).filter(Boolean).map(migrationStamp).filter((stamp) => stamp !== null);
84
+ return { onHead, latest: stamps.length ? Math.max(...stamps) : null };
85
+ }
86
+
87
+ function migrationOrderingFindings(local, history) {
88
+ return local.flatMap((entry) => {
89
+ const stamp = entry.match ? migrationStamp(entry.match[1]) : null;
90
+ if (history.onHead.has(entry.name) || stamp === null || history.latest === null || stamp > history.latest) return [];
91
+ return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration already in repository history (latest active ${new Date(history.latest).toISOString()}); a new migration sorts after the committed active chain`, { stamp: entry.match[1], latest: new Date(history.latest).toISOString() })];
92
+ });
93
+ }
94
+
95
+ async function baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase) {
93
96
  if (!entry.match) return [];
94
- const stamp = migrationStamp(entry.match[1]);
95
97
  if (onBase.has(entry.name)) {
96
98
  const committed = await baseFileText(git, repoRoot, baseSha.sha, entry.file);
97
99
  const current = readText(repoRoot, entry.file);
98
100
  if (!sameText(current, committed)) return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} differs from its content on ${baseSha.ref}; a migration on the base branch is immutable - write a new migration`, { base: baseSha.ref })];
99
- } else if (baseMax !== null && stamp !== null && stamp <= baseMax) {
100
- return [found(DB_MIGRATION_SHAPE, entry.file, `${entry.file} is stamped ${entry.match[1]}, not after every migration on ${baseSha.ref} (latest ${String(new Date(baseMax).toISOString())}); a new migration sorts after the base ones`, { stamp: entry.match[1], base: baseSha.ref })];
101
101
  }
102
102
  return [];
103
103
  }
@@ -112,26 +112,27 @@ function removedMigrationFindings(local, retired, baseNames, baseSha) {
112
112
 
113
113
  /**
114
114
  * L02 findings for names, stamps, ordering and immutability of supabase/migrations/*.sql. `archived` are the files of
115
- * supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a base migration moved there at a baseline
116
- * cutover is retired, not removed.
115
+ * supabase/migrations/_archive/ (app.supabase.migrations.archive): never judged, and a migration moved there at a baseline
116
+ * cutover is retired from the ordering chain, not removed. New files append after HEAD's active migrations; timestamps
117
+ * on archived migrations, remote branches and the wall clock do not constrain a replacement baseline.
117
118
  */
118
- export async function migrationShapeFindings({ repoRoot, migrations, archived = [], git, base, now }) {
119
+ export async function migrationShapeFindings({ repoRoot, migrations, archived = [], git, base }) {
119
120
  const prefix = `${MIGRATIONS_DIR}/`;
120
121
  const local = migrations.map((file) => {
121
122
  const name = file.startsWith(prefix) ? file.slice(prefix.length) : file;
122
123
  return { file, name, match: MIGRATION_NAME.exec(name) };
123
124
  });
124
- const findings = await localMigrationFindings(local, repoRoot, git, now);
125
+ const findings = localMigrationFindings(local);
126
+ const retired = new Set(archived.map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1)));
127
+ const history = migrationHistory(await migrationFilesAt(git, repoRoot, 'HEAD'), retired, new Set(local.map((entry) => entry.name)));
128
+ findings.push(...migrationOrderingFindings(local, history));
125
129
  const baseSha = await baseShaOf(git, repoRoot, base);
126
130
  if (!baseSha) return findings;
127
- const baseNames = await baseMigrationNames(git, repoRoot, baseSha.sha);
131
+ const baseNames = (await migrationFilesAt(git, repoRoot, baseSha.sha)).filter(isActiveMigration).map((file) => file.slice(prefix.length));
128
132
  const onBase = new Set(baseNames);
129
- const baseStamps = baseNames.map((name) => MIGRATION_NAME.exec(name)?.[1]).filter(Boolean).map(migrationStamp).filter((stamp) => stamp !== null);
130
- const baseMax = baseStamps.length ? Math.max(...baseStamps) : null;
131
133
  await eachInOrder(local, async (entry) => {
132
- findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase, baseMax));
134
+ findings.push(...await baseMigrationEntryFindings(entry, repoRoot, git, baseSha, onBase));
133
135
  });
134
- const retired = new Set(archived.map((file) => file.slice(MIGRATIONS_ARCHIVE_DIR.length + 1)));
135
136
  findings.push(...removedMigrationFindings(local, retired, baseNames, baseSha));
136
137
  return findings;
137
138
  }
@@ -51,7 +51,7 @@ async function migrationAnalyses({ repoRoot, migrations, exposed, forceRls, find
51
51
  * starts neither Docker nor the Supabase CLI. A full app is judged when it declares a Supabase connection, while
52
52
  * lite apps are judged even before their initial Supabase files have all been scaffolded.
53
53
  */
54
- export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes, now = () => Date.now() } = {}) {
54
+ export async function checkDatabase({ repoRoot, files, base, git, edition, supabase, emitTypes } = {}) {
55
55
  const findings = [];
56
56
  const migrations = files.filter(isActiveMigration).sort(byCodeUnit);
57
57
  const archived = files.filter((file) => file.startsWith(`${MIGRATIONS_ARCHIVE_DIR}/`));
@@ -71,7 +71,7 @@ export async function checkDatabase({ repoRoot, files, base, git, edition, supab
71
71
  const apiSchemas = (config?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
72
72
  const exposed = new Set(['public', ...apiSchemas]);
73
73
  const forceRls = new Set((declared?.forceRls ?? []).map((name) => (name.includes('.') ? name : `public.${name}`)));
74
- findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base, now }));
74
+ findings.push(...await migrationShapeFindings({ repoRoot, migrations, archived, git: git ?? defaultGit, base }));
75
75
 
76
76
  const analyses = await migrationAnalyses({ repoRoot, migrations, exposed, forceRls, findings });
77
77
  findings.push(
@@ -1,6 +1,6 @@
1
1
  // edition.mjs - L01, the edition rule. An hfs.json edition is `full` or `lite` (absent means full); under lite the standard is a
2
- // filter plus a few additions over the same manifest, never a second engine. Lite is the full architecture deployed without
3
- // self-hosted infrastructure (owner ruling 2026-10-12): the same slots, patterns and unit specs, Supabase adapters behind the same
2
+ // filter plus a few additions over the same manifest, never a second engine. Lite keeps the full code structure and swaps only
3
+ // backing infrastructure for Supabase (owner ruling 2026-10-13): the api and sidecars run on Swarm, with the same slots, patterns and unit specs behind the same
4
4
  // ports, and no e2e or contract world. The slot layer already answers the paths lite removed (slots lite excludes, or whose
5
5
  // litePresence is forbidden, come back HFS_FORBIDDEN_PRESENT with their goesTo); this rule reports what no path can state:
6
6
  // - inside a package.json: an e2e, contract, stack or browser script (`test:e2e`, `test:contract`, `test:stack`, `test:browser`,
@@ -9,7 +9,7 @@
9
9
  // while full owns them (BullMQ, Kafka, Redis: lite runs pgmq and Postgres locks instead);
10
10
  // - a declaration of what lite does not have: an app kind, pattern or trigger kind every slot of which is forbidden under
11
11
  // lite. The forbidden names are DERIVED from the manifest, so adding or restoring a kind is a manifest edit, not an edit here
12
- // (under the 2026-10-12 ruling no pattern, trigger or app kind is gone).
12
+ // (under the 2026-10-13 ruling no pattern, trigger or app kind is gone).
13
13
  // A full app answers []: the rule tests the edition once and a full view forbids none of these names.
14
14
  import { found, readJson } from './read.mjs';
15
15
  import { DEPENDENCY_SECTIONS } from './fe-no-tests.mjs';
@@ -18,7 +18,7 @@ import { editionRuleParams } from '../edition-slots.mjs';
18
18
  export const HFS_EDITION_FORBIDDEN_PRESENT = 'HFS_EDITION_FORBIDDEN_PRESENT';
19
19
 
20
20
  const PACKAGE_JSON = /(?:^|\/)package\.json$/;
21
- /** The scripts of a world lite does not have: the e2e and contract layers, the self-hosted stack and the browser journey. */
21
+ /** The scripts of a test world lite does not have: e2e, contract, test-stack and browser journeys. Production Swarm deployment is legal. */
22
22
  const LITE_FORBIDDEN_SCRIPT = /^(?:pre|post)?test:(?:e2e|contract|stack|browser)$/;
23
23
  /** The dependencies of a test infrastructure lite does not run: containers, fault proxies and browser runners. */
24
24
  const LITE_FORBIDDEN_TEST_INFRA = /^(?:testcontainers|@testcontainers\/.+|toxiproxy-node-client|playwright|playwright-core|@playwright\/.+|cypress)$/;
@@ -78,7 +78,7 @@ function dependencyFindings(rel, pkg, removed) {
78
78
  if (!isPlainObject(deps)) continue;
79
79
  for (const name of Object.keys(deps)) {
80
80
  if (LITE_FORBIDDEN_TEST_INFRA.test(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: a lite app runs no test containers, fault proxy or browser journey - its integration specs run against \`supabase start\``, { section, name }));
81
- else if (removed.has(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: lite runs no self-hosted infrastructure - the pattern's port is implemented on Supabase (pgmq, Postgres locks)`, { section, name }));
81
+ else if (removed.has(name)) findings.push(found(HFS_EDITION_FORBIDDEN_PRESENT, rel, `${rel} ${section} has ${name}: lite replaces backing infrastructure with Supabase - the pattern's port is implemented on Supabase (pgmq, Postgres locks)`, { section, name }));
82
82
  }
83
83
  }
84
84
  return findings;
@@ -12,6 +12,7 @@ import fs from 'node:fs';
12
12
  import path from 'node:path';
13
13
  import { parseYaml } from '../../../engine/yaml.mjs';
14
14
  import { found, readText } from './read.mjs';
15
+ import { liteStarciworkFinding } from '../path-findings.mjs';
15
16
 
16
17
  const PROOF_COMMAND_FILE_MISSING = 'HFS_PROOF_COMMAND_FILE_MISSING';
17
18
  const RECORD = /^\.starciwork\/.+\.ya?ml$/;
@@ -62,6 +63,7 @@ export function proofCommandFindings({ repoRoot, files, resolver, sides }) {
62
63
  const own = new Set(sides);
63
64
  for (const file of files) {
64
65
  if (!RECORD.test(file)) continue;
66
+ if (liteStarciworkFinding(file, resolver)) continue;
65
67
  const text = readText(repoRoot, file);
66
68
  if (text === null || !text.includes('requiresProof')) continue;
67
69
  let record;
@@ -5,6 +5,8 @@
5
5
  // - a tracked `.enc` that is not a sops envelope (an encrypted-looking file that holds plaintext);
6
6
  // - a line of any other tracked text file that matches a secret pattern.
7
7
  // A finding names the file, the line and the pattern, never the value.
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
8
10
  import { FORBIDDEN_FILES, secretHits } from '../../lib/secret-patterns.mjs';
9
11
  import { isSopsEnvelope } from '../../lib/sops-envelope.mjs';
10
12
  import { found, readText } from './read.mjs';
@@ -16,6 +18,17 @@ const NOT_SCANNED = /(^|\/)package-lock\.json$/;
16
18
  /** True when the slot `id` of the manifest reports HFS_PLAINTEXT_SECRET for the paths it forbids. */
17
19
  export const slotOwnsSecrets = (slot) => slot?.rules?.includes(PLAINTEXT_SECRET) === true;
18
20
 
21
+ /** A legacy lite root secrets.env is reported even when ignored; its presence is enough, so no value is read. */
22
+ export function untrackedSecretFindings({ repoRoot, files, repo }) {
23
+ if (repo.profile !== 'app' || repo.edition !== 'lite' || files.includes('secrets.env')) return [];
24
+ try {
25
+ if (!fs.statSync(path.join(repoRoot, 'secrets.env')).isFile()) return [];
26
+ } catch {
27
+ return [];
28
+ }
29
+ return [found(PLAINTEXT_SECRET, 'secrets.env', 'secrets.env is an untracked plaintext secret at the lite app root; seal it with starci app secret set into .starcistacks/<env>/secrets/<slug>.enc and remove the plaintext file; read materialized values by *_FILE', { level: 'warning', pattern: 'untracked-secrets-env' })];
30
+ }
31
+
19
32
  /**
20
33
  * The findings of R06 for one tracked file that no forbidden slot claims: a secret by being (an env file, a key file), an `.enc` that is
21
34
  * no sops envelope, or a line that matches a secret pattern. `text` is the file's text, or null when it cannot be read (binary, too
@@ -0,0 +1,128 @@
1
+ // R10 runtime consistency: app-owned scheduling, managed lite backing and Swarm deployment safety.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { isPlainObject as plain } from '../../../engine/plain-object.mjs';
5
+ import { readDeclaration } from '../../lib/stack-declaration.mjs';
6
+ import { isInside } from '../../lib/walk.mjs';
7
+ import { composeIncludePaths, swarmInventoryIssues, swarmModelIssues } from '../../lib/swarm-stack.mjs';
8
+ import { found, readText } from './read.mjs';
9
+
10
+ const CODE = 'HFS_STACKS_SHAPE';
11
+ const DECLARATION = '.starcistacks/application-stacks.yaml';
12
+ const HOST_CRON_PATH = /^(?:\.github\/workflows|deploy|scripts|\.starcistacks)\//;
13
+ const HOST_CRON = /\bcrontab\s+(?:-l\b|-(?=\s|[;|&)]|$))|(?:\/etc\/)?\bcron\.d(?:\/|\b)|@reboot\b/i;
14
+ const mapping = value => plain(value) ? value : {};
15
+ const issueFinding = (file, issue) => found(CODE, file, `${file}: ${issue.at}: ${issue.message}`);
16
+
17
+ /** The app's tracked automation sources; locations are reported without returning any source text. */
18
+ export function hostCrontabFindings({ repoRoot, files }) {
19
+ const findings = [];
20
+ for (const file of files.filter(file => HOST_CRON_PATH.test(file) && !file.endsWith('.enc'))) {
21
+ const text = readText(repoRoot, file);
22
+ if (text === null) continue;
23
+ for (const [index, line] of text.split(/\r?\n/).entries()) {
24
+ if (HOST_CRON.test(line)) findings.push(found(CODE, file,
25
+ `${file}:${index + 1}: host crontab installation is forbidden; recurring work must use an app scheduler, pgmq/pg_cron, or a declared Swarm scheduler and replicated-job`, { line: index + 1 }));
26
+ }
27
+ }
28
+ return findings;
29
+ }
30
+
31
+ function safeStackFile(repoRoot, envDir, relative) {
32
+ if (typeof relative !== 'string' || path.isAbsolute(relative) || relative.split(/[\\/]/).includes('..')) return null;
33
+ const target = path.resolve(envDir, relative);
34
+ const base = path.resolve(repoRoot);
35
+ if (!isInside(base, envDir) || !isInside(envDir, target)) return null;
36
+ let cursor = target;
37
+ while (true) {
38
+ if (fs.existsSync(cursor) && fs.lstatSync(cursor).isSymbolicLink()) return null;
39
+ if (cursor === base) break;
40
+ cursor = path.dirname(cursor);
41
+ }
42
+ return target;
43
+ }
44
+
45
+ function mergeMapping(current, incoming) {
46
+ const result = { ...mapping(current) };
47
+ for (const [key, value] of Object.entries(mapping(incoming))) {
48
+ result[key] = plain(value) ? mergeMapping(result[key], value) : value;
49
+ }
50
+ return result;
51
+ }
52
+
53
+ function includePaths(doc, relative, file, findings) {
54
+ const { paths, errors } = composeIncludePaths(doc);
55
+ for (const message of errors) findings.push(issueFinding(file, { at: 'include', message }));
56
+ return paths.map(value => path.isAbsolute(value) ? value : path.posix.join(path.posix.dirname(relative), value));
57
+ }
58
+
59
+ function loadSwarmModel({ repoRoot, environment, spec, files, findings }) {
60
+ const prefix = `.starcistacks/${environment}/`, envDir = path.resolve(repoRoot, prefix);
61
+ const queue = [...(Array.isArray(spec.composeFiles) ? spec.composeFiles : [])], seen = new Set();
62
+ let model = {};
63
+ if (!queue.length) findings.push(found(CODE, DECLARATION, `${DECLARATION}: environments.${environment}.composeFiles must declare the Swarm stack sources`));
64
+ while (queue.length) {
65
+ const relative = queue.shift();
66
+ if (seen.has(relative)) continue;
67
+ seen.add(relative);
68
+ const file = `${prefix}${relative}`, target = safeStackFile(repoRoot, envDir, relative);
69
+ const parsed = target && files.includes(file) ? readDeclaration(target) : { error: 'missing, untracked or unsafe stack source' };
70
+ if (parsed.error) {
71
+ findings.push(found(CODE, file, `${file}: Swarm stack cannot be read: ${parsed.error}`));
72
+ continue;
73
+ }
74
+ model = mergeMapping(model, parsed.doc);
75
+ queue.push(...includePaths(parsed.doc, relative, file, findings));
76
+ }
77
+ return { model, sources: new Set([...seen].map(relative => `${prefix}${relative}`)) };
78
+ }
79
+
80
+ function swarmDeploymentFiles(repoRoot, files) {
81
+ return files.filter(file => {
82
+ const environment = /^\.starcistacks\/([^/]+)\/infra\/swarm\//.exec(file)?.[1];
83
+ if (!environment || file.endsWith('.enc')) return false;
84
+ const target = safeStackFile(repoRoot, path.resolve(repoRoot, '.starcistacks', environment), file.split('/').slice(2).join('/'));
85
+ if (!target) return true;
86
+ const parsed = readDeclaration(target);
87
+ return path.basename(file) === 'stack.yaml' || plain(parsed.doc?.services) || Array.isArray(parsed.doc?.include);
88
+ });
89
+ }
90
+
91
+ function liteEnvironmentFindings({ doc, environments, deployments }) {
92
+ const findings = [];
93
+ const delivery = deployments.length || Object.values(environments).some(spec => Array.isArray(spec?.composeFiles) && spec.composeFiles.length);
94
+ if (delivery && (doc.backing?.supabase?.provider !== 'supabase' || doc.backing?.supabase?.managed !== true))
95
+ findings.push(found(CODE, DECLARATION, `${DECLARATION}: lite Swarm delivery must declare backing.supabase with provider: supabase and managed: true`));
96
+ for (const [environment, spec] of Object.entries(environments)) {
97
+ if (!Array.isArray(spec?.composeFiles) || !spec.composeFiles.length) continue;
98
+ if (spec.runtime !== 'docker-swarm' || spec.composeFiles.some(file => typeof file !== 'string' || !file.startsWith('infra/swarm/')))
99
+ findings.push(found(CODE, DECLARATION, `${DECLARATION}: environments.${environment} lite delivery must use runtime: docker-swarm and infra/swarm sources`));
100
+ }
101
+ return findings;
102
+ }
103
+
104
+ function undeclaredSwarmFindings(deployments, environments, sources) {
105
+ return deployments.filter(file => !sources.has(file)).map(file => {
106
+ const environment = file.split('/')[1], spec = environments[environment];
107
+ const reason = spec?.runtime === 'docker-swarm' ? 'is not declared or reachable from composeFiles' : 'has no declared docker-swarm environment';
108
+ return found(CODE, file, `${file}: Swarm deployment ${reason}`);
109
+ });
110
+ }
111
+
112
+ /** Declared Swarm sources are checked for both editions; lite additionally refuses local managed backing images. */
113
+ export function stacksRuntimeFindings({ repoRoot, files, doc, edition = 'full' }) {
114
+ const findings = [], lite = edition === 'lite', components = mapping(doc?.components), environments = mapping(doc?.environments);
115
+ const deployments = swarmDeploymentFiles(repoRoot, files), sources = new Set();
116
+ findings.push(...swarmInventoryIssues({ components, lite }).map(issue => issueFinding(DECLARATION, issue)));
117
+ if (lite) findings.push(...liteEnvironmentFindings({ doc, environments, deployments }));
118
+ for (const [environment, entry] of Object.entries(environments)) {
119
+ const spec = mapping(entry);
120
+ if (spec.runtime !== 'docker-swarm') continue;
121
+ const loaded = loadSwarmModel({ repoRoot, environment, spec, files, findings });
122
+ for (const file of loaded.sources) sources.add(file);
123
+ const file = `.starcistacks/${environment}/${spec.composeFiles?.[0] ?? 'infra/swarm/stack.yaml'}`;
124
+ findings.push(...swarmModelIssues({ model: loaded.model, components, bindings: spec.components, lite }).map(issue => issueFinding(file, issue)));
125
+ }
126
+ findings.push(...undeclaredSwarmFindings(deployments, environments, sources));
127
+ return findings;
128
+ }