@starci/hfs 4.12.0 → 4.13.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 (152) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +97 -7
  73. package/runtime/knowledge/hfs/slots.yaml +19 -7
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +31 -0
  107. package/stack/cli.d.mts +3 -1
  108. package/stack/cli.mjs +17 -11
  109. package/stack/config.d.mts +6 -0
  110. package/stack/converge.d.mts +2 -0
  111. package/stack/converge.mjs +1 -1
  112. package/stack/db-phases.d.mts +185 -0
  113. package/stack/db-phases.mjs +346 -0
  114. package/stack/deploy.d.mts +18 -4
  115. package/stack/deploy.mjs +235 -70
  116. package/stack/journal.d.mts +127 -0
  117. package/stack/journal.mjs +182 -0
  118. package/stack/manager-backup.d.mts +54 -0
  119. package/stack/manager-backup.mjs +136 -0
  120. package/stack/release-store.d.mts +44 -0
  121. package/stack/release-store.mjs +89 -6
  122. package/stack/remote.d.mts +2 -0
  123. package/stack/remote.mjs +31 -11
  124. package/stack/server-key.d.mts +2 -0
  125. package/stack/server-key.mjs +1 -1
  126. package/stack/status.d.mts +49 -0
  127. package/stack/status.mjs +114 -0
  128. package/stack/urgent-floor.d.mts +10 -61
  129. package/stack/urgent-floor.mjs +28 -123
  130. package/stack/verify.mjs +26 -0
  131. package/sync/ci-commands.mjs +10 -5
  132. package/sync/format-worker.d.mts +1 -0
  133. package/sync/format-worker.mjs +17 -0
  134. package/sync/format.d.mts +21 -3
  135. package/sync/format.mjs +99 -7
  136. package/sync/index.d.mts +3 -6
  137. package/sync/index.mjs +12 -12
  138. package/sync/stack-images.d.mts +25 -10
  139. package/sync/stack-images.mjs +215 -54
  140. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  141. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  142. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  143. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  144. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  145. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  146. package/templates/app/gitignore +1 -0
  147. package/templates/app/gitignore-lite +1 -0
  148. package/templates/app/package-scripts/db.json +1 -1
  149. package/templates/app/skeleton/.gitattributes +2 -0
  150. package/templates/app/skeleton-lite/.gitattributes +2 -0
  151. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  152. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -126,21 +126,110 @@ function bindTranslator(state, name, initializer, factories) {
126
126
  bindTranslator(state, element.name, elements[index], factories); });
127
127
  }
128
128
  }
129
+ /**
130
+ * The key a translator helper's member is known by in its declared type: the app's own type alias the member is typed with
131
+ * (`readonly t: OfficeText`), else the member itself. An alias with type arguments or one declared outside the app's sources is no key.
132
+ */
133
+ function declaredTranslatorKey(state, type, member) {
134
+ const alias = type?.aliasSymbol;
135
+ if (alias) {
136
+ const declarations = alias.declarations ?? [];
137
+ const own = declarations.length > 0 && declarations.every((declaration) => state.sourceNames?.has(declaration.getSourceFile().fileName));
138
+ return own && !type.aliasTypeArguments?.length ? alias : null;
139
+ }
140
+ return member ?? null;
141
+ }
142
+ /**
143
+ * The declared object type an object literal is written for: its contextual type, or, when the literal is what `useMemo(() => literal)` makes
144
+ * and React's own types are not part of the program, the type argument of that useMemo or the type the call itself is written for.
145
+ */
146
+ function literalType(state, literal) {
147
+ const { ts, checker } = state;
148
+ const known = (type) => type && !(type.flags & ts.TypeFlags.Any) && type.getProperties().length > 0 ? type : null;
149
+ const contextual = known(checker.getContextualType(literal));
150
+ if (contextual)
151
+ return contextual;
152
+ let body = literal;
153
+ while (ts.isParenthesizedExpression(body.parent))
154
+ body = body.parent;
155
+ let fn = body.parent;
156
+ if (fn && ts.isReturnStatement(fn)) {
157
+ while (fn && !ts.isFunctionLike(fn))
158
+ fn = fn.parent;
159
+ }
160
+ else if (!fn || !ts.isArrowFunction(fn) || fn.body !== body)
161
+ return null;
162
+ if (!fn || !ts.isArrowFunction(fn) && !ts.isFunctionExpression(fn))
163
+ return null;
164
+ const call = fn.parent;
165
+ if (!call || !ts.isCallExpression(call) || call.arguments[0] !== fn || leafName(ts, call.expression) !== 'useMemo')
166
+ return null;
167
+ const [argument] = call.typeArguments ?? [];
168
+ return known(argument ? checker.getTypeFromTypeNode(argument) : checker.getContextualType(call));
169
+ }
170
+ /**
171
+ * A helper that exposes a bound translator as a member of a declared object type (`useMemo<OfficeWords>(() => ({ t: (key, values) => t(key, values) }))`)
172
+ * binds that member's declared type: every value of that type, wherever the helper is handed on, reads in the namespaces bound to it.
173
+ */
174
+ function bindTypedMember(state, property, factories) {
175
+ const { ts, checker } = state;
176
+ const literal = property.parent;
177
+ if (!ts.isObjectLiteralExpression(literal))
178
+ return;
179
+ const name = nameText(ts, property.name, { numbers: true });
180
+ if (name === null)
181
+ return;
182
+ const initializer = ts.isShorthandPropertyAssignment(property) ? property.name : property.initializer;
183
+ const value = unwrap(ts, initializer);
184
+ const namespaces = value && ts.isIdentifier(value)
185
+ ? state.translators.get(symbolAt(state, value)) ?? wrapperNamespaces(state, value)
186
+ : translatorNamespace(state, initializer, factories) ?? wrapperNamespaces(state, initializer);
187
+ if (!namespaces?.length)
188
+ return;
189
+ const member = literalType(state, literal)?.getProperty(name);
190
+ if (!member)
191
+ return;
192
+ const key = declaredTranslatorKey(state, checker.getTypeOfSymbolAtLocation(member, literal), member);
193
+ if (!key)
194
+ return;
195
+ const bound = state.typedTranslators.get(key) ?? new Set();
196
+ for (const namespace of namespaces)
197
+ bound.add(namespace);
198
+ state.typedTranslators.set(key, bound);
199
+ }
129
200
  /** Wrappers depend on translators bound in other modules, so bind until nothing new appears. */
130
201
  export function translationBindings(state, sources) {
202
+ const { ts } = state;
131
203
  const factories = new Map([...sources.values()].map(source => [source, translationFactories(state, source)]));
132
- for (let pass = 0, size = -1; pass < 4 && size !== state.translators.size; pass++) {
133
- size = state.translators.size;
204
+ state.typedTranslators ??= new Map();
205
+ state.sourceNames ??= new Set([...sources.values()].map((source) => source.fileName));
206
+ const typedSize = () => [...state.typedTranslators.values()].reduce((sum, bound) => sum + bound.size, 0);
207
+ for (let pass = 0, size = -1; pass < 4 && size !== state.translators.size + typedSize(); pass++) {
208
+ size = state.translators.size + typedSize();
134
209
  for (const source of sources.values()) {
135
210
  const visit = (node) => {
136
- if (state.ts.isVariableDeclaration(node) && node.initializer)
211
+ if (ts.isVariableDeclaration(node) && node.initializer)
137
212
  bindTranslator(state, node.name, node.initializer, factories.get(source));
138
- state.ts.forEachChild(node, visit);
213
+ if (ts.isPropertyAssignment(node) || ts.isShorthandPropertyAssignment(node))
214
+ bindTypedMember(state, node, factories.get(source));
215
+ ts.forEachChild(node, visit);
139
216
  };
140
217
  visit(source);
141
218
  }
142
219
  }
143
220
  }
221
+ /** The namespaces bound to the declared type of a receiver (its own type alias, or the member it reads), if a translator helper bound it. */
222
+ function typedNamespaces(state, receiver) {
223
+ const { ts, checker } = state;
224
+ if (!state.typedTranslators?.size)
225
+ return [];
226
+ const node = unwrap(ts, receiver);
227
+ if (!node)
228
+ return [];
229
+ const member = ts.isPropertyAccessExpression(node) ? symbolAt(state, node.name) : null;
230
+ const keys = [declaredTranslatorKey(state, checker.getTypeAtLocation(node), null), member];
231
+ return [...new Set(keys.flatMap(key => key ? [...state.typedTranslators.get(key) ?? []] : []))];
232
+ }
144
233
  /** Namespaces of the translators a receiver can be: declared here, or handed in through arguments and props. */
145
234
  function receiverNamespaces(state, receiver) {
146
235
  const { ts, checker } = state;
@@ -201,11 +290,13 @@ export function catalogNamespace(state, callee) {
201
290
  const resolved = receiverNamespaces(state, receiver);
202
291
  if (resolved.length)
203
292
  return resolved;
293
+ // A value of a type a translator helper bound reads in that helper's namespaces, on top of what the fallbacks below allow.
294
+ const typed = typedNamespaces(state, receiver);
204
295
  const binding = receiverBinding(state, receiver);
205
296
  // Observed unresolved producers cannot use the generic namespace fallback.
206
297
  if (binding.handedIn && (TRANSLATOR_NAME.test(leafName(ts, receiver)) || translatorShaped(state, receiver)))
207
- return binding.observed ? [] : [null];
298
+ return binding.observed ? typed : [...typed, null];
208
299
  if (ts.isIdentifier(callee) && TRANSLATOR_NAME.test(callee.text))
209
- return binding.observed ? [] : [''];
210
- return [];
300
+ return binding.observed ? typed : [...typed, ''];
301
+ return typed;
211
302
  }
@@ -1,11 +1,18 @@
1
1
  import type { Parsed, Repo } from './rule-types.mjs';
2
2
  export declare const HFS_UNUSED_SYMBOL = "HFS_UNUSED_SYMBOL";
3
3
  /** The same rule applies to lite and full. Analyze all tracked consumers before filtering findings to a fast-check scope. */
4
- export declare function unusedSymbolFindings({ repoRoot, files, repo, isRoot, inScope, ts: supplied }: {
4
+ type UnusedSymbolInput = {
5
5
  repoRoot: string;
6
6
  files: string[];
7
7
  repo?: Repo;
8
8
  isRoot?: boolean;
9
9
  inScope?: (file: string) => boolean;
10
10
  ts: Parsed;
11
- }): any[];
11
+ };
12
+ /** A seam for the app check cache: the whole-program analysis of a side is a scoped step keyed on the side, so the cache runs it only when the side changed. */
13
+ export interface UnusedSymbolMemo {
14
+ run(repoRoot: string, compute: () => Parsed[]): Parsed[];
15
+ }
16
+ export declare function withUnusedSymbolMemo<T>(seam: UnusedSymbolMemo, work: () => T): T;
17
+ export declare function unusedSymbolFindings(input: UnusedSymbolInput): any[];
18
+ export {};
@@ -339,8 +339,21 @@ function catalogRead(state, candidate, liveCodes) {
339
339
  });
340
340
  });
341
341
  }
342
- /** The same rule applies to lite and full. Analyze all tracked consumers before filtering findings to a fast-check scope. */
343
- export function unusedSymbolFindings({ repoRoot, files, repo = {}, isRoot = false, inScope = () => true, ts: supplied }) {
342
+ let memo = null;
343
+ export function withUnusedSymbolMemo(seam, work) {
344
+ const previous = memo;
345
+ memo = seam;
346
+ try {
347
+ return work();
348
+ }
349
+ finally {
350
+ memo = previous;
351
+ }
352
+ }
353
+ export function unusedSymbolFindings(input) {
354
+ return memo === null ? computeUnusedSymbols(input) : memo.run(input.repoRoot, () => computeUnusedSymbols(input));
355
+ }
356
+ function computeUnusedSymbols({ repoRoot, files, repo = {}, isRoot = false, inScope = () => true, ts: supplied }) {
344
357
  const edition = repo.edition ?? 'full';
345
358
  if (isRoot || repo.kind === 'runtime' || !['lite', 'full'].includes(edition))
346
359
  return [];
@@ -1,4 +1,5 @@
1
- // CI_TRIGGERS_RELEASE_ONLY: runtime workflows and app ci.yml may judge main; release candidates, v* tags and manual dispatch retain their scope.
1
+ // CI_TRIGGERS_RELEASE_ONLY: runtime workflows and app ci.yml may judge main; release candidates, v* tags and manual dispatch retain their scope. ci.yml also judges pull requests
2
+ // to main (pull_request only: no secret reaches it; pull_request_target, workflow_run and schedules stay refused); a reusable workflow (workflow_call) has no trigger of its own.
2
3
  import { parseWorkflow, workflowFindings } from './workflow-source.mjs';
3
4
  import { workflowEventOptions } from '../../lib/workflow-events.mjs';
4
5
  export const CODE = 'CI_TRIGGERS_RELEASE_ONLY';
@@ -36,11 +37,26 @@ function pushFindings({ ownBranch, config, refuse, release }) {
36
37
  found.push(refuse(`\`push.${other}\` is not allowed beside the trigger filters`));
37
38
  return found;
38
39
  }
39
- function eventFindings({ event, config, ownBranch, refuse, release }) {
40
- if (event === 'workflow_dispatch')
40
+ /** A pull request to main, in ci.yml only: the checks of a change, with no filter beyond the base branch. */
41
+ function pullRequestFindings({ file, config, refuse }) {
42
+ if (!RELEASE_WORKFLOW.test(file))
43
+ return [refuse('`pull_request` may start only ci.yml: no other workflow runs the code of a pull request')];
44
+ const filters = config && typeof config === 'object' && !Array.isArray(config) ? config : {};
45
+ const found = [];
46
+ const branches = filters.branches;
47
+ if (!Array.isArray(branches) || branches.length !== 1 || String(branches[0]) !== RUNTIME_BRANCH)
48
+ found.push(refuse(`\`pull_request.branches\` must be exactly ['${RUNTIME_BRANCH}']`));
49
+ for (const other of Object.keys(filters).filter((key) => key !== 'branches'))
50
+ found.push(refuse(`\`pull_request.${other}\` is not allowed beside the base branch`));
51
+ return found;
52
+ }
53
+ function eventFindings({ file, event, config, ownBranch, refuse, release }) {
54
+ if (event === 'workflow_dispatch' || event === 'workflow_call')
41
55
  return [];
56
+ if (event === 'pull_request')
57
+ return pullRequestFindings({ file, config, refuse });
42
58
  if (event !== 'push')
43
- return [refuse(`the trigger \`${event}\` is not allowed: only a push of release tags [${RELEASE_TAGS}], of '${CANDIDATE_BRANCHES}' (and, in runtime workflows or app ci.yml, of branch ${RUNTIME_BRANCH}) and workflow_dispatch may start CI`)];
59
+ return [refuse(`the trigger \`${event}\` is not allowed: only a push of release tags [${RELEASE_TAGS}], of '${CANDIDATE_BRANCHES}' (and, in runtime workflows or app ci.yml, of branch ${RUNTIME_BRANCH}), a pull request to ${RUNTIME_BRANCH} in ci.yml, workflow_call and workflow_dispatch may start CI`)];
44
60
  return pushFindings({ ownBranch, config, refuse, release });
45
61
  }
46
62
  /** The CI_TRIGGERS_RELEASE_ONLY findings of one workflow text; a text that is not YAML yields none (the other checks name it). Runtime workflows and app ci.yml may also push on main. */
@@ -56,7 +72,7 @@ function workflowTriggerFindings({ path: file, text }) {
56
72
  const events = workflowEventOptions(on, String);
57
73
  const release = !file.startsWith('.github/workflows/') && RELEASE_WORKFLOW.test(file);
58
74
  const missingPush = release && !Object.hasOwn(events, 'push') ? [refuse(`the app release workflow must run on a push of '${CANDIDATE_BRANCHES}' and of tags [${RELEASE_TAGS}]`)] : [];
59
- return [...missingPush, ...Object.entries(events).flatMap(([event, config]) => eventFindings({ event, config, ownBranch, refuse, release }))];
75
+ return [...missingPush, ...Object.entries(events).flatMap(([event, config]) => eventFindings({ file, event, config, ownBranch, refuse, release }))];
60
76
  }
61
77
  /** CI_TRIGGERS_RELEASE_ONLY over every tracked workflow (ctx of scripts/hfs/runtime-check.mjs). */
62
78
  export const gitTriggerFindings = (ctx) => workflowFindings(ctx, workflowTriggerFindings);
@@ -113,6 +113,12 @@ export interface DeployConfig {
113
113
  environment: Record<string, ServiceEnvironment>;
114
114
  configs: ConfigEntry[];
115
115
  passEnv: string[];
116
+ /** deploy.auto: a green release tag deploys here by itself. */
117
+ auto: boolean;
118
+ /** deploy.approval: none, or reviewers set on the GitHub Environment. */
119
+ approval: 'none' | 'reviewers';
120
+ /** deploy.platforms: the platforms the images are built for (default linux/amd64). */
121
+ platforms: string[];
116
122
  verify: VerifyConfig;
117
123
  jobs: JobsConfig | null;
118
124
  tasks: Record<string, TaskConfig>;
@@ -17,7 +17,7 @@ const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}
17
17
  const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
18
18
  const UPPER_ENV = KEY;
19
19
  const FIELDS = {
20
- deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
20
+ deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
21
21
  remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
22
22
  image: ['app', 'buildArgs'],
23
23
  buildArgs: ['values', 'sealed'],
@@ -38,6 +38,34 @@ const FIELDS = {
38
38
  registry: ['host', 'visibility'],
39
39
  };
40
40
  const text = (value) => typeof value === 'string' && value.length > 0;
41
+ const APPROVALS = ['none', 'reviewers'];
42
+ const PLATFORMS = ['linux/amd64', 'linux/arm64'];
43
+ function flag(c, at, value, fallback) {
44
+ if (value === undefined)
45
+ return fallback;
46
+ if (typeof value !== 'boolean')
47
+ c.problem(at, 'must be true or false');
48
+ return value === true;
49
+ }
50
+ function choice(c, at, value, allowed, fallback) {
51
+ if (value === undefined)
52
+ return fallback;
53
+ if (typeof value !== 'string' || !allowed.includes(value)) {
54
+ c.problem(at, `must be one of ${allowed.join(', ')}`);
55
+ return fallback;
56
+ }
57
+ return value;
58
+ }
59
+ /** `deploy.platforms`: the platforms the images are built for, default linux/amd64 (the VPS's architecture). */
60
+ function platforms(c, value) {
61
+ if (value === undefined)
62
+ return ['linux/amd64'];
63
+ if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length || value.some((item) => typeof item !== 'string' || !PLATFORMS.includes(item))) {
64
+ c.problem('deploy.platforms', `must be a list of ${PLATFORMS.join(' and ')}, each once`);
65
+ return ['linux/amd64'];
66
+ }
67
+ return value;
68
+ }
41
69
  const nameField = (c, at, value, pattern = NAME) => patternField(c, at, value, pattern);
42
70
  function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
43
71
  if (!Array.isArray(value) || (nonEmpty && value.length === 0)) {
@@ -414,6 +442,9 @@ export function normalizeDeploy(block, { project, probe }) {
414
442
  environment: environmentBlock(c, block.environment, services, probe),
415
443
  configs: configs(c, block.configs, probe),
416
444
  passEnv: block.passEnv === undefined ? [] : stringList(c, 'deploy.passEnv', block.passEnv, KEY),
445
+ auto: flag(c, 'deploy.auto', block.auto, false),
446
+ approval: choice(c, 'deploy.approval', block.approval, APPROVALS, 'none'),
447
+ platforms: platforms(c, block.platforms),
417
448
  verify: verify(c, block.verify, services),
418
449
  jobs: jobs(c, block.jobs, services),
419
450
  tasks,
@@ -308,6 +308,8 @@ const checkStackEntry = (entry, environmentNames, add, { stacksRoot, lite }) =>
308
308
  }
309
309
  if (entry.name === 'k8s')
310
310
  return;
311
+ if (entry.name === 'cache')
312
+ return; // the committed check cache (slot app.starcistacks.cache): reserved, never an environment
311
313
  if (lite && entry.name === 'images')
312
314
  return; // the managed db image (slot app.db.image, ruling lite-db-image-in-stacks), no environment
313
315
  if (lite && liteCustodyDirectory(path.join(stacksRoot, entry.name)))
@@ -1,5 +1,6 @@
1
1
  import { readTree, treeFacts, untrackedEntries } from './tree.mjs';
2
2
  import { liteStarciworkTreeFindings } from './path-findings.mjs';
3
+ const CHECK_CACHE_DIR = '.starcistacks/cache';
3
4
  /** The tree findings of R03: empty directories, ghost siblings, untracked entries outside an ignored slot. */
4
5
  export function treeFindings({ repoRoot, resolver, tracked = [], reportedUntracked = new Set() }) {
5
6
  const inIgnoredSlot = (rel) => resolver.classifyPath(`${rel}/.probe`).tracking === 'ignored';
@@ -19,5 +20,6 @@ export function treeFindings({ repoRoot, resolver, tracked = [], reportedUntrack
19
20
  if (!ignored)
20
21
  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` });
21
22
  }
22
- return findings;
23
+ // The committed check cache is written by the CLI and committed with the change: before that commit its files are untracked, and a run's own output is no finding.
24
+ return findings.filter((finding) => !(finding.path === CHECK_CACHE_DIR || String(finding.path).startsWith(`${CHECK_CACHE_DIR}/`)));
23
25
  }
@@ -1,3 +1,4 @@
1
+ import { nextMigrationStamp } from "./migration-stamp.mjs";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { render, TEMPLATES_DIR } from "../sync/index.mjs";
@@ -8,7 +9,6 @@ import { byCodeUnit } from "../report/order.mjs";
8
9
  import { withSqlIdentifierError } from "./add-database-lite.mjs";
9
10
  const MIGRATION = /^(\d{14})_[a-z][a-z0-9]*(?:-[a-z0-9]+)*\.sql$/;
10
11
  const PLACEHOLDER = /@@([A-Za-z][A-Za-z0-9]*)@@/g;
11
- const timestampOf = (now) => new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
12
12
  const upperOf = (name) => name.replaceAll("-", "_").toUpperCase();
13
13
  const camelOf = (name) => {
14
14
  const pascal = pascalOf(name);
@@ -162,10 +162,8 @@ export function addLiteWebhookInbox({ root, repo, noun, provider, values, now =
162
162
  const suffix = `${provider}-inbox.sql`;
163
163
  if (migrations.some((file) => file.endsWith(`_${suffix}`)))
164
164
  throw new ScaffoldError("HFS_ADD_EXISTS", `webhook ${provider} already has an inbox migration`);
165
- const stamp = timestampOf(now);
166
165
  const latest = migrations.at(-1)?.slice(0, 14);
167
- if (latest && stamp <= latest)
168
- throw new ScaffoldError("HFS_ADD_MIGRATION_ORDER", `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
166
+ const stamp = nextMigrationStamp(now, latest);
169
167
  const renderValues = {
170
168
  ...values,
171
169
  provider,
@@ -4,6 +4,7 @@
4
4
  // its sealed secret. The verb also declares redis in hfs.json sides.be.backing with its bullmq client, and renders the one
5
5
  // declared Redis into every docker-swarm environment of the stack: the stateful component, its backup and restore record, the
6
6
  // sealed redis-password member and its grants, the AOF service on a named volume and an internal network, and the images lock.
7
+ import { nextMigrationStamp } from './migration-stamp.mjs';
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
  import { parseYaml } from '../runtime/engine/yaml.mjs';
@@ -104,10 +105,8 @@ function migrationFiles({ repoRoot, beRoot, patterns, now }) {
104
105
  return [];
105
106
  const folder = path.join(repoRoot, 'db', 'migrations');
106
107
  const migrations = fs.existsSync(folder) ? fs.readdirSync(folder).filter((file) => MIGRATION.test(file)).sort(byCodeUnit) : [];
107
- const stamp = new Date(now()).toISOString().replace(/\D/g, '').slice(0, 14);
108
108
  const latest = migrations.at(-1)?.slice(0, 14);
109
- if (latest && stamp <= latest)
110
- throw new ScaffoldError('HFS_ADD_MIGRATION_ORDER', `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
109
+ const stamp = nextMigrationStamp(now, latest);
111
110
  const text = sections.map(([, , source]) => template(`be/patterns/${source}`)).join('\n');
112
111
  return [{ relative: `db/migrations/${stamp}_${sections.map(([name]) => name).join('-')}.sql`, text }];
113
112
  }
@@ -1,3 +1,4 @@
1
+ import { nextMigrationStamp } from "./migration-stamp.mjs";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
3
4
  import { loadSlotManifest, readRepoDeclaration, } from "../runtime/scripts/hfs/slots.mjs";
@@ -114,7 +115,6 @@ const POSTGRES_RESERVED = new Set([
114
115
  "window",
115
116
  "with",
116
117
  ]);
117
- const timestampOf = (now) => new Date(now()).toISOString().replace(/\D/g, "").slice(0, 14);
118
118
  const snakeOf = (name) => name.replaceAll("-", "_");
119
119
  const featureOf = (name) => name.replaceAll("_", "-");
120
120
  const sqlIdentifierOf = (name) => POSTGRES_RESERVED.has(name) ? `"${name}"` : name;
@@ -328,10 +328,8 @@ function nextMigration({ root, feature, requestedName, now }) {
328
328
  : [];
329
329
  if (migrations.some((file) => file.endsWith(`_${feature}.sql`)))
330
330
  throw new ScaffoldError("HFS_ADD_EXISTS", `table ${requestedName} already has a migration`);
331
- const stamp = timestampOf(now);
332
331
  const latest = migrations.at(-1)?.slice(0, 14);
333
- if (latest && stamp <= latest)
334
- throw new ScaffoldError("HFS_ADD_MIGRATION_ORDER", `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
332
+ const stamp = nextMigrationStamp(now, latest);
335
333
  return stamp;
336
334
  }
337
335
  function tableValues({ feature, table }) {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The UTC timestamp (YYYYMMDDHHMMSS) of the next migration: the clock's second, strictly later than the latest migration.
3
+ * Two migrations written within one second (the scaffold of an app followed at once by `add table`) would collide, so the
4
+ * stamp then moves to the second after the latest; a latest more than a few seconds ahead of the clock is a clock that is
5
+ * really behind, and is refused (HFS_ADD_MIGRATION_ORDER).
6
+ *
7
+ * @param now - The clock, in milliseconds.
8
+ * @param latest - The timestamp of the latest migration of the folder, if any.
9
+ * @returns The 14-digit stamp for the new migration.
10
+ */
11
+ export declare const nextMigrationStamp: (now: () => number, latest: string | undefined) => string;
@@ -0,0 +1,32 @@
1
+ import { ScaffoldError } from "./service.mjs";
2
+ /** How far ahead of the clock the latest migration may be before the clock counts as behind (a collision, not a wrong clock). */
3
+ const CLOCK_SKEW_SECONDS = 5;
4
+ const STAMP = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/u;
5
+ const secondsOf = (stamp) => {
6
+ const parts = STAMP.exec(stamp);
7
+ if (!parts)
8
+ return null;
9
+ const [, year, month, day, hour, minute, second] = parts.map(Number);
10
+ return Date.UTC(year ?? 0, (month ?? 1) - 1, day, hour, minute, second) / 1000;
11
+ };
12
+ const stampOfSeconds = (seconds) => new Date(seconds * 1000).toISOString().replace(/\D/gu, "").slice(0, 14);
13
+ /**
14
+ * The UTC timestamp (YYYYMMDDHHMMSS) of the next migration: the clock's second, strictly later than the latest migration.
15
+ * Two migrations written within one second (the scaffold of an app followed at once by `add table`) would collide, so the
16
+ * stamp then moves to the second after the latest; a latest more than a few seconds ahead of the clock is a clock that is
17
+ * really behind, and is refused (HFS_ADD_MIGRATION_ORDER).
18
+ *
19
+ * @param now - The clock, in milliseconds.
20
+ * @param latest - The timestamp of the latest migration of the folder, if any.
21
+ * @returns The 14-digit stamp for the new migration.
22
+ */
23
+ export const nextMigrationStamp = (now, latest) => {
24
+ const stamp = stampOfSeconds(Math.floor(now() / 1000));
25
+ if (!latest || stamp > latest)
26
+ return stamp;
27
+ const ahead = secondsOf(latest);
28
+ const current = secondsOf(stamp);
29
+ if (ahead === null || current === null || ahead - current > CLOCK_SKEW_SECONDS)
30
+ throw new ScaffoldError("HFS_ADD_MIGRATION_ORDER", `the current UTC timestamp ${stamp} is not later than the latest migration ${latest}; fix the clock or wait for the next second`);
31
+ return stampOfSeconds(ahead + 1);
32
+ };
@@ -17,8 +17,8 @@ export declare function appDeclaration(side: Side, fields: object, project?: str
17
17
  kind: string;
18
18
  project: string;
19
19
  sides: {
20
- fe: object;
21
20
  be: object;
21
+ fe: object;
22
22
  };
23
23
  };
24
24
  /**
package/src/main.d.mts CHANGED
@@ -39,6 +39,9 @@ interface AppIo {
39
39
  stackIo?: Record<string, unknown> | undefined;
40
40
  runLinter?: LinterRunner | undefined;
41
41
  checkApp?: ((args: Dynamic) => Dynamic) | undefined;
42
+ /** --quiet: no progress on stderr (the diagnosis stays). */
43
+ quiet?: boolean | undefined;
44
+ stderrIsTty?: boolean | undefined;
42
45
  }
43
46
  /** The options of main: every seam of the app IO, each optional. */
44
47
  type MainOptions = {
@@ -54,5 +57,5 @@ export declare function appRootAbove(dir: string): string;
54
57
  * the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
55
58
  * harness CLI's own secret.env SOPS_AGE_KEY, which every verb that opens a sealed member (secret, stack, release, db) reads last for the age identity (decryptionIdentity); `prompt` replaces its masked prompt.
56
59
  */
57
- export declare function main(argv: string[], { cwd: baseCwd, harness, stdout, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, }?: MainOptions): Promise<number>;
60
+ export declare function main(argv: string[], { cwd: baseCwd, harness, stdout, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, stderrIsTty, }?: MainOptions): Promise<number>;
58
61
  export {};