@starci/hfs 4.12.2 → 4.13.2

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 (160) hide show
  1. package/CHANGELOG.md +50 -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 +20 -8
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +48 -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 +18 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +53 -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 +33 -0
  107. package/stack/ci-key.d.mts +11 -0
  108. package/stack/ci-key.mjs +44 -0
  109. package/stack/cli.d.mts +7 -1
  110. package/stack/cli.mjs +22 -13
  111. package/stack/config.d.mts +15 -0
  112. package/stack/converge.d.mts +2 -0
  113. package/stack/converge.mjs +1 -1
  114. package/stack/db-phases.d.mts +185 -0
  115. package/stack/db-phases.mjs +346 -0
  116. package/stack/deploy.d.mts +22 -5
  117. package/stack/deploy.mjs +242 -72
  118. package/stack/journal.d.mts +146 -0
  119. package/stack/journal.mjs +191 -0
  120. package/stack/manager-backup.d.mts +54 -0
  121. package/stack/manager-backup.mjs +136 -0
  122. package/stack/notify.d.mts +9 -0
  123. package/stack/notify.mjs +32 -0
  124. package/stack/release-store.d.mts +49 -1
  125. package/stack/release-store.mjs +102 -16
  126. package/stack/remote.d.mts +2 -0
  127. package/stack/remote.mjs +32 -11
  128. package/stack/rollback-guard.d.mts +10 -0
  129. package/stack/rollback-guard.mjs +41 -0
  130. package/stack/server-key.d.mts +2 -0
  131. package/stack/server-key.mjs +1 -1
  132. package/stack/ssh-gate.d.mts +35 -0
  133. package/stack/ssh-gate.mjs +145 -0
  134. package/stack/status.d.mts +51 -0
  135. package/stack/status.mjs +115 -0
  136. package/stack/urgent-floor.d.mts +10 -61
  137. package/stack/urgent-floor.mjs +28 -123
  138. package/stack/verify.mjs +26 -0
  139. package/sync/ci-commands.mjs +10 -5
  140. package/sync/format-worker.d.mts +1 -0
  141. package/sync/format-worker.mjs +17 -0
  142. package/sync/format.d.mts +21 -3
  143. package/sync/format.mjs +99 -7
  144. package/sync/index.d.mts +3 -6
  145. package/sync/index.mjs +12 -12
  146. package/sync/stack-images.d.mts +25 -10
  147. package/sync/stack-images.mjs +215 -54
  148. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  149. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  150. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  151. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  152. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  153. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  154. package/templates/app/gitignore +1 -0
  155. package/templates/app/gitignore-lite +1 -0
  156. package/templates/app/package-scripts/db.json +1 -1
  157. package/templates/app/skeleton/.gitattributes +2 -0
  158. package/templates/app/skeleton-lite/.gitattributes +2 -0
  159. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  160. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -1,4 +1,5 @@
1
- // R271: a product release keeps its v<semver> tag, root package version, image tags and database deployment ref together.
1
+ // R271: a product release keeps its v<semver> tag, root package version, image tags and database deployment ref together. The pushed images are named by the
2
+ // commit (sha-<sha>); the release name v<version> is a retag of that digest under a release guard. A migration runs in the stack deploy after the release tag.
2
3
  // Ordinary untagged development trees have no release version to compare. Workflow checks still run there in both editions.
3
4
  // YAML is parsed, so comments and unrelated jobs cannot supply a guard. Shell guards are deliberately a small, explicit form;
4
5
  // opaque helper scripts and computed image metadata cannot prove provenance in this static check.
@@ -171,10 +172,12 @@ function dbJobConstrained(job, guarded) {
171
172
  constrained = false;
172
173
  return constrained;
173
174
  }
175
+ // The database deploy workflow is retired in a lite app: its migrations are the migrate phase of the stack deploy (starci app stack deploy, tag-gated by
176
+ // stack-deploy.yml). A full app that keeps its own db-deploy.yml, and a lite app that has not synced yet, are still judged here.
174
177
  function dbFindings(file, workflow) {
175
178
  const jobs = Object.entries(workflow.jobs ?? {});
176
179
  if (!jobs.length)
177
- return [found(HFS_DB_DEPLOY_UNGUARDED, file, `${file} has no verifiable database deploy job; require a release tag before migration`)];
180
+ return [found(HFS_DB_DEPLOY_UNGUARDED, file, `${file} has no verifiable database deploy job; require a release tag before migration (a lite app's migrations are the migrate phase of the stack deploy: run starci app sync --execute to retire this file)`)];
178
181
  const findings = [];
179
182
  for (const [name, job] of jobs) {
180
183
  if (!job || typeof job !== 'object')
@@ -203,17 +206,42 @@ function imageTagIsRelease(value, version) {
203
206
  return SEMVER.test(String(version)) && ref.endsWith(`:v${version}`);
204
207
  });
205
208
  }
209
+ /** A commit tag: `<repo>:sha-${{ github.sha }}`, immutable and per commit, so any ref may push it. */
210
+ const COMMIT_TAG = /^.+:sha-\$\{\{\s*github\.sha\s*\}\}$/;
211
+ function imageTagsAreCommit(value) {
212
+ if (typeof value !== 'string')
213
+ return false;
214
+ const tags = value.split(/[\n,]+/).map((tag) => tag.trim()).filter(Boolean);
215
+ return tags.length > 0 && tags.every((tag) => COMMIT_TAG.test(tag));
216
+ }
217
+ /** The release guard of a reusable images workflow: a step under `if: ${{ inputs.release }}` whose script rejects a ref that is not refs/tags/v*. */
218
+ function inputReleaseGuard(step) {
219
+ if (typeof step.run !== 'string' || step['continue-on-error'] || !/^\$\{\{\s*inputs\.release\s*\}\}$/.test(String(step.if ?? '').trim()))
220
+ return false;
221
+ const first = step.run.trim().split(/\r?\n/)[0].trim();
222
+ return /^\[\[\s*["']?\$(?:GITHUB_REF|\{GITHUB_REF\})["']?\s*==\s*refs\/tags\/v\*\s*\]\]\s*\|\|\s*exit\s+[1-9]\d*\s*;?$/.test(first);
223
+ }
224
+ /** The release name v<version> is a retag of the commit digest (`imagetools create --tag <repo>:$GITHUB_REF_NAME <repo>:sha-...`): only after the guard, only on a release run. */
225
+ function retagIsGuarded(job, step) {
226
+ const steps = job.steps ?? [];
227
+ const guard = steps.findIndex(inputReleaseGuard);
228
+ return guard >= 0 && guard < steps.indexOf(step) && /^\$\{\{\s*inputs\.release\s*&&/.test(String(step.if ?? '').trim());
229
+ }
206
230
  function imageFindings(file, workflow, version) {
207
231
  const findings = [];
208
232
  let builds = 0;
209
233
  for (const [name, job] of Object.entries(workflow.jobs ?? {})) {
210
234
  for (const step of job?.steps ?? []) {
235
+ if (typeof step.run === 'string' && /\bimagetools\s+create\b/.test(step.run) && !retagIsGuarded(job, step)) {
236
+ findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} retags an image outside a release guard; put it after a step \`if: \${{ inputs.release }}\` that rejects a ref other than refs/tags/v*, and under \`if: \${{ inputs.release && ... }}\``, { job: name }));
237
+ }
211
238
  if (!(step.uses ?? '').startsWith('docker/build-push-action@'))
212
239
  continue;
213
240
  builds += 1;
214
241
  const guarded = tagOnlyTrigger(workflow) || refProof(job.if) === RELEASE_REF || refProof(step.if) === RELEASE_REF;
215
- if (!guarded || !checkoutPreservesRef(job) || !imageTagIsRelease(step.with?.tags, version))
216
- findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} must build the checked-out release ref and tag every image with v<package.json version> or github.ref_name under a release-tag guard`, { job: name }));
242
+ // An immutable commit tag (sha-<sha>) is pushed by any ref; a release name must come from the checked-out release ref under a guard.
243
+ if (!checkoutPreservesRef(job) || !(imageTagsAreCommit(step.with?.tags) || (guarded && imageTagIsRelease(step.with?.tags, version))))
244
+ findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} must build the checked-out ref and tag every image with sha-\${{ github.sha }} (the release name v<package.json version> is a guarded retag of that digest), or tag with v<version> or github.ref_name under a release-tag guard`, { job: name }));
217
245
  }
218
246
  }
219
247
  if (!builds)
@@ -20,7 +20,8 @@ const allowedExpressions = (allows) => allows.flatMap((entry) => braceVariants(e
20
20
  const shapeFinding = (file, message, extra) => found(STACKS_SHAPE, file, message, extra);
21
21
  function treeFindings(files, allowed, prefix) {
22
22
  const findings = [];
23
- for (const file of files.filter((f) => f.startsWith(prefix))) {
23
+ // `cache` is reserved: .starcistacks/cache/ is the committed check cache (slot app.starcistacks.cache), written by the CLI, not an environment.
24
+ for (const file of files.filter((f) => f.startsWith(prefix) && !f.startsWith(`${prefix}cache/`))) {
24
25
  const rel = file.slice(prefix.length);
25
26
  if (/\.enc(?:\.yaml)?$/.test(rel) && !INSIDE_SECRETS.test(rel))
26
27
  findings.push(shapeFinding(file, `${file} is a sealed secret outside <env>/secrets/<slug>.enc.yaml; move it to .starcistacks/<env>/secrets/`));
@@ -34,6 +35,9 @@ function declarationFindings(files, prefix, declaration) {
34
35
  if (!files.includes(declared))
35
36
  return [];
36
37
  const findings = [];
38
+ const environments = declaration.doc?.environments;
39
+ if (environments !== null && typeof environments === 'object' && Object.hasOwn(environments, 'cache'))
40
+ findings.push(shapeFinding(declared, `${declared} declares an environment named cache; the name is reserved for the committed check cache (.starcistacks/cache/)`));
37
41
  if (declaration.error)
38
42
  return [shapeFinding(declared, `${declared} cannot be read: ${declaration.error}`)];
39
43
  const services = declaration.doc?.services;
@@ -5,7 +5,10 @@ const DEPENDABOT = '.github/dependabot.yml';
5
5
  const CI = '.github/workflows/ci.yml';
6
6
  const IMAGES = '.github/workflows/images.yml';
7
7
  const ECOSYSTEMS = ['npm', 'github-actions', 'docker'];
8
- const enabled = (entry) => entry && (entry.if === undefined || entry.if === true || entry.if === 'true') && !entry['continue-on-error'];
8
+ // A job the plan or the caller decides is no disabled job: a reusable workflow runs a job on `if: ${{ inputs.release }}` whenever its caller asks, and the CI jobs
9
+ // run on `if: ${{ needs.plan.outputs.checks == 'true' }}` on every run that checks (the plan leaves them out only where a green run of the same commit stands in).
10
+ const DECIDED_CONDITION = /^\$\{\{\s*(?:inputs\.[A-Za-z_][\w-]*|needs\.plan\.outputs\.[A-Za-z_]\w*\s*==\s*'true')\s*\}\}$/;
11
+ const enabled = (entry) => entry && (entry.if === undefined || entry.if === true || entry.if === 'true' || (typeof entry.if === 'string' && DECIDED_CONDITION.test(entry.if.trim()))) && !entry['continue-on-error'];
9
12
  function shellSeparator(line, index, quote) {
10
13
  if (quote)
11
14
  return null;
@@ -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);
@@ -99,6 +99,16 @@ export interface BackupConfig {
99
99
  volume: string;
100
100
  }>;
101
101
  }
102
+ /** The outcomes of a deploy attempt a notification can be sent for. */
103
+ export declare const NOTIFY_OUTCOMES: readonly ["accepted", "rolled-back", "rollback-failed", "failed", "refused"];
104
+ /** `deploy.notify`: the sealed member and key that hold the webhook URL (https), and the outcomes that are announced. */
105
+ export interface NotifyConfig {
106
+ webhook: {
107
+ member: string;
108
+ key: string;
109
+ };
110
+ on: string[];
111
+ }
102
112
  export interface RegistryConfig {
103
113
  host: string;
104
114
  visibility: string;
@@ -113,11 +123,19 @@ export interface DeployConfig {
113
123
  environment: Record<string, ServiceEnvironment>;
114
124
  configs: ConfigEntry[];
115
125
  passEnv: string[];
126
+ /** deploy.auto: a green release tag deploys here by itself. */
127
+ auto: boolean;
128
+ /** deploy.approval: none, or reviewers set on the GitHub Environment. */
129
+ approval: 'none' | 'reviewers';
130
+ /** deploy.platforms: the platforms the images are built for (default linux/amd64). */
131
+ platforms: string[];
116
132
  verify: VerifyConfig;
117
133
  jobs: JobsConfig | null;
118
134
  tasks: Record<string, TaskConfig>;
119
135
  postDeploy: string[];
120
136
  backup: BackupConfig | null;
137
+ /** deploy.notify: one webhook message per deploy outcome; null (off) unless declared. */
138
+ notify: NotifyConfig | null;
121
139
  ingress: IngressConfig | null;
122
140
  registry: RegistryConfig;
123
141
  }
@@ -5,6 +5,8 @@ import { isPlainObject as plain } from '../../engine/plain-object.mjs';
5
5
  import { closedFields, collector, patternField, treeFile } from './stack-config-kit.mjs';
6
6
  import { list } from '../lib/list.mjs';
7
7
  import { normalizeIngress } from './stack-ingress-config.mjs';
8
+ /** The outcomes of a deploy attempt a notification can be sent for. */
9
+ export const NOTIFY_OUTCOMES = ['accepted', 'rolled-back', 'rollback-failed', 'failed', 'refused'];
8
10
  export const DEPLOY_CONFIG_CODE = 'STACKS_DEPLOY_CONFIG_INVALID';
9
11
  export const NAME = /^[a-z][a-z0-9-]*$/u;
10
12
  export const KEY = /^[A-Z][A-Z0-9_]*$/u;
@@ -17,7 +19,7 @@ const PINNED_HELPER_IMAGE = /^[a-z0-9./_-]+(?::[A-Za-z0-9._-]+)?@sha256:(?!0{64}
17
19
  const ARGUMENT = /^[A-Za-z0-9_@%+=:,./-]+$/u;
18
20
  const UPPER_ENV = KEY;
19
21
  const FIELDS = {
20
- deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'ingress', 'registry'],
22
+ deploy: ['stack', 'remote', 'images', 'volumes', 'secrets', 'environment', 'configs', 'passEnv', 'auto', 'approval', 'platforms', 'postDeploy', 'verify', 'jobs', 'tasks', 'backup', 'notify', 'ingress', 'registry'],
21
23
  remote: ['host', 'releasesBase', 'sealed', 'serverRecipient'],
22
24
  image: ['app', 'buildArgs'],
23
25
  buildArgs: ['values', 'sealed'],
@@ -36,8 +38,38 @@ const FIELDS = {
36
38
  backup: ['image', 'keepDays', 'dir', 'volumes'],
37
39
  backupVolume: ['name', 'volume'],
38
40
  registry: ['host', 'visibility'],
41
+ notify: ['webhook', 'on'],
42
+ webhook: ['member', 'key'],
39
43
  };
40
44
  const text = (value) => typeof value === 'string' && value.length > 0;
45
+ const APPROVALS = ['none', 'reviewers'];
46
+ const PLATFORMS = ['linux/amd64', 'linux/arm64'];
47
+ function flag(c, at, value, fallback) {
48
+ if (value === undefined)
49
+ return fallback;
50
+ if (typeof value !== 'boolean')
51
+ c.problem(at, 'must be true or false');
52
+ return value === true;
53
+ }
54
+ function choice(c, at, value, allowed, fallback) {
55
+ if (value === undefined)
56
+ return fallback;
57
+ if (typeof value !== 'string' || !allowed.includes(value)) {
58
+ c.problem(at, `must be one of ${allowed.join(', ')}`);
59
+ return fallback;
60
+ }
61
+ return value;
62
+ }
63
+ /** `deploy.platforms`: the platforms the images are built for, default linux/amd64 (the VPS's architecture). */
64
+ function platforms(c, value) {
65
+ if (value === undefined)
66
+ return ['linux/amd64'];
67
+ if (!Array.isArray(value) || value.length === 0 || new Set(value).size !== value.length || value.some((item) => typeof item !== 'string' || !PLATFORMS.includes(item))) {
68
+ c.problem('deploy.platforms', `must be a list of ${PLATFORMS.join(' and ')}, each once`);
69
+ return ['linux/amd64'];
70
+ }
71
+ return value;
72
+ }
41
73
  const nameField = (c, at, value, pattern = NAME) => patternField(c, at, value, pattern);
42
74
  function stringList(c, at, value, itemPattern, { nonEmpty = false } = {}) {
43
75
  if (!Array.isArray(value) || (nonEmpty && value.length === 0)) {
@@ -381,6 +413,22 @@ function backup(c, block) {
381
413
  c.problem('deploy.backup.volumes', 'must list at least one volume');
382
414
  return { image: block.image, keepDays: block.keepDays ?? 14, dir: block.dir ?? null, volumes };
383
415
  }
416
+ /**
417
+ * `deploy.notify`: off unless declared. The webhook URL is a sealed value (never in the declaration, the log or the journal); the default announces every
418
+ * outcome that changed or tried to change the services (accepted, rolled-back, rollback-failed, failed), and `on` narrows or adds `refused`.
419
+ */
420
+ function notify(c, block, members) {
421
+ if (block === undefined)
422
+ return null;
423
+ if (!closedFields(c, 'deploy.notify', block, FIELDS.notify))
424
+ return null;
425
+ if (!closedFields(c, 'deploy.notify.webhook', block.webhook, FIELDS.webhook))
426
+ return null;
427
+ memberRef(c, 'deploy.notify.webhook.member', block.webhook.member, members);
428
+ patternField(c, 'deploy.notify.webhook.key', block.webhook.key, KEY);
429
+ const on = block.on === undefined ? ['accepted', 'rolled-back', 'rollback-failed', 'failed'] : stringList(c, 'deploy.notify.on', block.on, new RegExp(`^(?:${NOTIFY_OUTCOMES.join('|')})$`, 'u'), { nonEmpty: true });
430
+ return { webhook: { member: block.webhook.member, key: block.webhook.key }, on };
431
+ }
384
432
  function ingress(c, block, probe) {
385
433
  if (block === undefined)
386
434
  return null;
@@ -414,11 +462,15 @@ export function normalizeDeploy(block, { project, probe }) {
414
462
  environment: environmentBlock(c, block.environment, services, probe),
415
463
  configs: configs(c, block.configs, probe),
416
464
  passEnv: block.passEnv === undefined ? [] : stringList(c, 'deploy.passEnv', block.passEnv, KEY),
465
+ auto: flag(c, 'deploy.auto', block.auto, false),
466
+ approval: choice(c, 'deploy.approval', block.approval, APPROVALS, 'none'),
467
+ platforms: platforms(c, block.platforms),
417
468
  verify: verify(c, block.verify, services),
418
469
  jobs: jobs(c, block.jobs, services),
419
470
  tasks,
420
471
  postDeploy: postDeploy(c, block.postDeploy, tasks),
421
472
  backup: backup(c, block.backup),
473
+ notify: notify(c, block.notify, members),
422
474
  ingress: ingress(c, block.ingress, probe),
423
475
  registry: registry(c, block.registry),
424
476
  };
@@ -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 {};