@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
@@ -0,0 +1,115 @@
1
+ // status.mjs - what runs against what was accepted: `starci app stack status --env <env>` (read-only) compares the live services of the stack with the
2
+ // accepted release and prints the drift per service. A service is drifted when
3
+ // - Swarm rolled it back by itself (its update state is `rollback_*`): the release still reads as the new one, the service runs the old spec;
4
+ // - its live image digest is not the digest the release's stack.yaml names, or its task template is not the one observed when the release was accepted
5
+ // (a manual `docker service update`): `updated outside a release`;
6
+ // - it is missing from the swarm, or runs in the stack without being in the release.
7
+ // `observedServices(ctx, config)` is the one reader of the live services: the deploy snapshots it into its journal (after converge, after a rollback and on
8
+ // accept) and into release.json (`observed`), and `verifyStack` judges the digests it reads. Every call is a docker query; nothing here changes the swarm.
9
+ import { createHash } from 'node:crypto';
10
+ import { parseYaml } from '../runtime/engine/yaml.mjs';
11
+ import { canonical } from './converge.mjs';
12
+ import { lines as splitLines, toolOutput } from './context.mjs';
13
+ import { createReleaseStore } from './release-store.mjs';
14
+ import { gateIsOn } from './ssh-gate.mjs';
15
+ const DIGEST = /@(sha256:[0-9a-f]{64})$/u;
16
+ const IMAGE_FIELD = /^[A-Za-z0-9][A-Za-z0-9./_:@-]*$/u;
17
+ /** The `sha256:...` digest an image reference pins, or null. */
18
+ export const digestOf = (image) => (image ? (DIGEST.exec(image)?.[1] ?? null) : null);
19
+ /** The image of each service of a stack file, by service name (the pinned references of a rendered release). */
20
+ export function stackImages(stackYaml) {
21
+ // reason: the stack file, parsed YAML read defensively
22
+ const services = parseYaml(stackYaml)?.services ?? {};
23
+ const pairs = Object.entries(services).map(([name, entry]) => [name, entry?.image]);
24
+ return Object.fromEntries(pairs.filter((pair) => typeof pair[1] === 'string' && IMAGE_FIELD.test(pair[1])));
25
+ }
26
+ const shortDigest = (digest) => (digest ? digest.slice(0, 19) : 'no digest');
27
+ /** Reads one `docker service inspect` document into an ObservedService. */
28
+ function observe(stack, name, text) {
29
+ // reason: `docker service inspect` output, parsed JSON read defensively below
30
+ const inspect = JSON.parse(text);
31
+ const image = inspect?.Spec?.TaskTemplate?.ContainerSpec?.Image;
32
+ const template = inspect?.Spec?.TaskTemplate;
33
+ const state = inspect?.UpdateStatus?.State;
34
+ const reference = typeof image === 'string' && IMAGE_FIELD.test(image) ? image : null;
35
+ return {
36
+ service: name.startsWith(`${stack}_`) ? name.slice(stack.length + 1) : name,
37
+ image: reference,
38
+ digest: digestOf(reference),
39
+ updateState: typeof state === 'string' && /^[a-z_]+$/u.test(state) ? state : null,
40
+ taskTemplateHash: template === undefined ? null : createHash('sha256').update(canonical(template)).digest('hex').slice(0, 16),
41
+ };
42
+ }
43
+ /** The live services of the stack, sorted by name. Throws when docker cannot be asked. */
44
+ export function observedServices(ctx, config) {
45
+ const names = splitLines(toolOutput(ctx, 'docker', ['service', 'ls', '--filter', `label=com.docker.stack.namespace=${config.stack}`, '--format', '{{.Name}}'])).sort();
46
+ return names.map((name) => observe(config.stack, name, toolOutput(ctx, 'docker', ['service', 'inspect', name, '--format', '{{json .}}'])));
47
+ }
48
+ /** The `observed.accept` services of a release record, or [] when it carries none (an older release). */
49
+ function acceptedObservation(release) {
50
+ const observed = release.observed;
51
+ const list = observed?.accept;
52
+ return Array.isArray(list) ? list.filter((entry) => typeof entry === 'object' && entry !== null && typeof entry.service === 'string') : [];
53
+ }
54
+ /** Compares the live services with a release; an empty list means they run the release. */
55
+ export function compareWithRelease(expected, live) {
56
+ const images = stackImages(expected.stackYaml);
57
+ const accepted = new Map(acceptedObservation(expected.release).map((entry) => [entry.service, entry]));
58
+ const running = new Map(live.map((entry) => [entry.service, entry]));
59
+ const drift = [];
60
+ for (const [service, image] of Object.entries(images)) {
61
+ const now = running.get(service);
62
+ if (!now) {
63
+ drift.push({ service, kind: 'missing', detail: 'the release names it and no such service runs' });
64
+ continue;
65
+ }
66
+ const wanted = digestOf(image);
67
+ if (now.updateState?.startsWith('rollback'))
68
+ drift.push({ service, kind: 'rolled back by swarm', detail: `update state ${now.updateState}; runs ${shortDigest(now.digest)}, the release names ${shortDigest(wanted)}` });
69
+ else if (wanted && now.digest !== wanted)
70
+ drift.push({ service, kind: 'updated outside a release', detail: `runs ${shortDigest(now.digest)}, the release names ${shortDigest(wanted)}` });
71
+ else {
72
+ const seen = accepted.get(service)?.taskTemplateHash ?? null;
73
+ if (seen && now.taskTemplateHash && seen !== now.taskTemplateHash)
74
+ drift.push({ service, kind: 'updated outside a release', detail: 'its task template changed since the release was accepted' });
75
+ }
76
+ }
77
+ for (const service of running.keys())
78
+ if (!Object.hasOwn(images, service))
79
+ drift.push({ service, kind: 'not in the release', detail: 'it runs in the stack and the accepted release does not name it' });
80
+ return drift;
81
+ }
82
+ // reason: a dynamic import by string, resolved on first use so a spec that injects `ssh` never loads remote.mjs
83
+ const ownerSsh = async (ctx, config, options) => (await import('./remote.mjs')).ownerSshRunner(ctx, config, options);
84
+ const DEFAULT_DEPS = { ssh: ownerSsh, releaseStore: createReleaseStore, observedServices };
85
+ /**
86
+ * `stack status`: the accepted release against the live services. Returns { ok, lines, data: { release, sourceSha, drift, services } }; ok is false exactly
87
+ * when a service drifted. An environment with no accepted release reports that and is not a drift.
88
+ */
89
+ export async function statusStack(ctx, config, options = {}, deps = {}) {
90
+ const d = { ...DEFAULT_DEPS, ...deps };
91
+ const run = await d.ssh(ctx, config, { sshCmd: options.sshCmd ?? null });
92
+ try {
93
+ const store = d.releaseStore(config, run, { gated: gateIsOn(ctx.env) });
94
+ const current = await store.current();
95
+ const live = d.observedServices(ctx, config);
96
+ if (!current)
97
+ return { ok: true, lines: [`status: ${config.stack} has no accepted release${live.length > 0 ? `; ${live.length} service(s) run in the stack` : ''}`], data: { release: null, sourceSha: null, drift: [], services: live } };
98
+ const stored = await store.read(current);
99
+ const drift = compareWithRelease(stored, live);
100
+ const sourceSha = typeof stored.release.sourceSha === 'string' ? stored.release.sourceSha : null;
101
+ const drifted = new Map(drift.map((entry) => [entry.service, entry]));
102
+ const names = [...new Set([...Object.keys(stackImages(stored.stackYaml)), ...live.map((entry) => entry.service)])].sort();
103
+ const rows = names.map((name) => {
104
+ const found = drifted.get(name);
105
+ const now = live.find((entry) => entry.service === name);
106
+ return found ? `DRIFT ${name} ${found.kind}: ${found.detail}` : `PASS ${name} ${shortDigest(now?.digest ?? null)}`;
107
+ });
108
+ const head = `status: accepted release ${current}${sourceSha ? ` (commit ${sourceSha.slice(0, 10)})` : ''}`;
109
+ const tail = drift.length === 0 ? 'status: every service runs the accepted release' : `status: ${drift.length} service(s) differ from the accepted release`;
110
+ return { ok: drift.length === 0, lines: [head, ...rows, tail], data: { release: current, sourceSha, drift, services: live } };
111
+ }
112
+ finally {
113
+ run.cleanup?.();
114
+ }
115
+ }
@@ -1,7 +1,10 @@
1
- import { type DbTarget as PostgresTarget } from '../db/postgres.mjs';
2
1
  import type { DeployConfig } from './config.mjs';
3
2
  import type { StackContext } from './context.mjs';
3
+ import { FIRST_START_WAIT_SECONDS, FLOOR_DEPS } from './db-phases.mjs';
4
+ import type { BackupData, BackupReceipt, FirstStartStepResult, FloorDeps } from './db-phases.mjs';
4
5
  import type { ActionResult } from './remote.mjs';
6
+ export { FIRST_START_WAIT_SECONDS, FLOOR_DEPS };
7
+ export type { BackupData, BackupReceipt, FirstStartStepResult, FloorDeps };
5
8
  /** The options of an urgent deploy the floor reads. */
6
9
  export interface FloorOptions {
7
10
  skipPublic?: boolean | undefined;
@@ -12,56 +15,6 @@ export interface FloorOptions {
12
15
  backupOut?: string | undefined;
13
16
  execute?: boolean | undefined;
14
17
  }
15
- /** The receipt of a database backup (db/backup-receipt.mjs). */
16
- export interface BackupReceipt {
17
- schemaOnly: boolean;
18
- at: string;
19
- file: string;
20
- bytes: number;
21
- sha256: string;
22
- parts: string[];
23
- }
24
- /** What a backup hands back: where the receipt is and what it says. */
25
- export interface BackupData {
26
- receiptPath: string;
27
- receipt?: BackupReceipt | undefined;
28
- }
29
- /** The database a floor step reaches (db/postgres.mjs). */
30
- type DbTarget = Record<string, unknown>;
31
- /** The verdict of one migrate pass. */
32
- interface MigrateResult {
33
- ok: boolean;
34
- lines?: string[] | undefined;
35
- }
36
- /** Whether the database is empty, and why not. */
37
- interface Emptiness {
38
- empty: boolean;
39
- history: boolean;
40
- publicTables: number;
41
- }
42
- /** The collaborators of the floor, each defaulting to the app's own db verbs. */
43
- export interface FloorDeps {
44
- backup: (ctx: StackContext, args: {
45
- target: DbTarget;
46
- out: string;
47
- }) => Promise<BackupData>;
48
- decrypt: (ctx: StackContext, args: {
49
- archive: string;
50
- identity: string | null;
51
- parts: string[];
52
- }) => Promise<{
53
- parts: string[];
54
- }>;
55
- migrate: (ctx: StackContext, args: {
56
- target: DbTarget;
57
- execute: boolean;
58
- }) => Promise<MigrateResult>;
59
- dbContainer: (ctx: StackContext, config: DeployConfig, args: {
60
- service: string;
61
- seconds: number;
62
- }) => Promise<string | undefined>;
63
- emptiness: (ctx: StackContext, target: PostgresTarget) => Promise<Emptiness>;
64
- }
65
18
  /** The data a deploy result carries that the floor reads. */
66
19
  export interface DeployData {
67
20
  steps?: {
@@ -87,20 +40,16 @@ export interface UrgentRun {
87
40
  }): Promise<DeployOutcome>;
88
41
  dryRun(): Promise<ActionResult>;
89
42
  sha256(file: string): string;
43
+ /** Hands the urgent record (tag, role, reason) to the deploy journal once the floor has read it. */
44
+ note?(record: {
45
+ tag: string;
46
+ role: string;
47
+ reason: string;
48
+ }): void;
90
49
  }
91
- /** The outcome of the `first-start` step. */
92
- export interface FirstStartStepResult {
93
- ok: boolean;
94
- code?: string;
95
- lines: string[];
96
- }
97
- /** How long a first start waits for the database service's container to accept connections. */
98
- export declare const FIRST_START_WAIT_SECONDS = 180;
99
- export declare const FLOOR_DEPS: Readonly<FloorDeps>;
100
50
  /**
101
51
  * An urgent deploy: the floor's preconditions, then (with execute) its guard steps, then `deployExecute` (the release, its readiness verify and the
102
52
  * automatic rollback). `run` = { render(), openStore(), deployExecute(), dryRun(), sha256 }. Returns { ok, lines, data } or a refused result whose
103
53
  * data names the urgent release.
104
54
  */
105
55
  export declare function urgentDeploy(ctx: StackContext, config: DeployConfig, options: FloorOptions, run: UrgentRun, deps?: Partial<FloorDeps>): Promise<ActionResult>;
106
- export {};
@@ -1,12 +1,12 @@
1
1
  // urgent-floor.mjs - the safety floor of an urgent deploy (`starci app stack deploy --urgent`, owner ruling urgent-release 2026-10-29). An urgent
2
2
  // release skips CI and the full gate, never this floor; each step that fails is a refusal with its own code, never a warning:
3
3
  // record the commit carries a pushed URGENT record (release/urgent.mjs) STACK_URGENT_NO_RECORD, STACK_URGENT_NOT_PUSHED
4
- // backup a fresh full database backup, encrypted to the app's age recipients (db dump) STACK_URGENT_BACKUP_FAILED
5
- // verified its receipt names this run, its size, age header and sha256 match the archive STACK_URGENT_BACKUP_UNVERIFIED
6
- // decrypt a test decrypt with the operator's age identity (SOPS_AGE_KEY_FILE) lists every part STACK_URGENT_DECRYPT_UNAVAILABLE, STACK_URGENT_DECRYPT_FAILED
4
+ // backup a fresh full database backup, encrypted to the app's age recipients (db dump) STACK_DEPLOY_BACKUP_FAILED
5
+ // verified its receipt names this run, its size, age header and sha256 match the archive STACK_DEPLOY_BACKUP_UNVERIFIED
6
+ // decrypt a test decrypt with the operator's age identity (SOPS_AGE_KEY_FILE) lists every part STACK_DEPLOY_BACKUP_DECRYPT_UNAVAILABLE, STACK_DEPLOY_BACKUP_DECRYPT_FAILED
7
7
  // render the release renders locally (deploy.mjs inspectRendered) its own STACKS_/STACK_DEPLOY_ refusal
8
8
  // rollback an accepted previous release exists, so a red deploy has somewhere to go back to STACK_URGENT_NO_ROLLBACK_TARGET
9
- // migrate db migrate previews the plan, then executes it STACK_URGENT_MIGRATE_PREVIEW_RED, STACK_URGENT_MIGRATE_FAILED
9
+ // migrate db migrate previews the plan, then executes it STACK_DEPLOY_MIGRATE_PREVIEW_RED, STACK_DEPLOY_MIGRATE_FAILED
10
10
  // deploy the release with its readiness verify (deploy.verify); red rolls back by itself STACK_URGENT_RED_ROLLED_BACK, STACK_URGENT_ROLLBACK_FAILED,
11
11
  // STACK_URGENT_DEPLOY_NOT_STARTED (red before the stack deploy)
12
12
  // The public-host checks are never skipped (STACK_URGENT_SKIP_PUBLIC). A rollback cannot undo the migrations: the backup above is their way back.
@@ -20,101 +20,28 @@
20
20
  // STACK_FIRST_START_USAGE (no --db-service), STACK_FIRST_START_NO_DATABASE (no container accepted connections in time),
21
21
  // STACK_FIRST_START_NOT_EMPTY (a history or a public table: use the plain urgent floor), STACK_FIRST_START_MIGRATE_RED (preview or execute red);
22
22
  // each rolls the release back to the previous one; the migrations a red first start applied stay in a database no release used before.
23
- // `deps` are the collaborators (backup, decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
23
+ // The backup, decrypt, migrate and first-start steps are shared with the automatic deploy (db-phases.mjs), under the same STACK_DEPLOY_* codes; the
24
+ // urgent path keeps its operator-held --db-url-env and its always-on backup (the floor is never skipped). `deps` are the collaborators (backup,
25
+ // decrypt, migrate), each defaulting to the app's own db verbs; a spec injects stubs.
24
26
  import fs from 'node:fs';
25
27
  import os from 'node:os';
26
28
  import path from 'node:path';
27
29
  import { readDeclaration } from '../release/index.mjs';
28
30
  import { releaseDir } from '../release/gate.mjs';
29
- import { DEFAULT_DB_URL_ENV, dbTarget, inContainer, queryRows } from '../db/postgres.mjs';
31
+ import { DEFAULT_DB_URL_ENV, dbTarget, inContainer } from '../db/postgres.mjs';
30
32
  import { readUrgent } from '../release/records.mjs';
31
- import { lines as splitLines, tool } from './context.mjs';
32
33
  import { refusal } from './errors.mjs';
33
- const AGE_HEADER = 'age-encryption.org/v1\n';
34
- const SHA = /^[a-f0-9]{64}$/u;
35
- /** The code or message of a thrown value, for a refusal line. */
36
- const reasonOf = (error) => String(error?.code ?? error?.message ?? error);
37
- /** The message of a thrown value. */
38
- const messageOf = (error) => String(error?.message ?? error);
34
+ import { FIRST_START_WAIT_SECONDS, FLOOR_DEPS, backupProblems, firstStartStep as sharedFirstStartStep, messageOf, reasonOf, refused } from './db-phases.mjs';
35
+ import { localFacts } from './manager-backup.mjs';
36
+ export { FIRST_START_WAIT_SECONDS, FLOOR_DEPS };
39
37
  /** The database a floor step reaches: the connection named by --db-url-env (default DATABASE_URL). */
40
38
  const targetOf = (options) => (options.dbUrlEnv ? { urlEnv: options.dbUrlEnv } : {});
41
39
  /** The backups of an urgent deploy live outside every repository: --backup-out, else ~/.starci-backups/<project>. */
42
40
  const backupDir = (config, options) => options.backupOut ?? path.join(os.homedir(), '.starci-backups', config.project);
43
- async function defaultBackup(ctx, { target, out }) {
44
- const { dumpDatabase } = await import('../db/dump.mjs');
45
- return dumpDatabase(ctx, { ...target, out }).data;
46
- }
47
- async function defaultDecrypt(ctx, { archive, identity, parts }) {
48
- const { backupTool } = await import('../db/backup-receipt.mjs');
49
- const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-decrypt-'));
50
- try {
51
- backupTool(ctx, 'age', ['-d', '-i', identity, '-o', path.join(scratch, 'backup.tar'), archive]);
52
- const listed = String(backupTool(ctx, 'tar', ['-tf', 'backup.tar'], { cwd: scratch }).stdout ?? '').split(/\r?\n/u).map((line) => line.replace(/^\.\//u, '').trim());
53
- const missing = parts.filter((part) => !listed.includes(part));
54
- if (missing.length > 0)
55
- throw new Error(`the decrypted archive lacks ${missing.join(', ')}`);
56
- return { parts: listed.filter(Boolean) };
57
- }
58
- finally {
59
- fs.rmSync(scratch, { recursive: true, force: true });
60
- }
61
- }
62
- async function defaultMigrate(ctx, { target, execute }) {
63
- const { loadDatabase, migrateDatabase } = await import('../db/migrate.mjs');
64
- // The urgent deploy's --execute is the arming of this one migration: the floor runs it, it never waits for a second switch.
65
- const armed = Object.create(ctx, { env: { value: { ...ctx.env, STARCI_DB_ARMED: '1' } } });
66
- return migrateDatabase(armed, loadDatabase(ctx.cwd), { ...target, execute });
67
- }
68
- /** How long a first start waits for the database service's container to accept connections. */
69
- export const FIRST_START_WAIT_SECONDS = 180;
70
- /** The running container of `service` whose server accepts connections (pg_isready), polled until `seconds` pass; undefined when none did. */
71
- async function defaultDbContainer(ctx, config, { service, seconds }) {
72
- const deadline = ctx.now() + seconds * 1000;
73
- for (;;) {
74
- const found = tool(ctx, 'docker', ['ps', '-q', '--filter', `label=com.docker.swarm.service.name=${config.stack}_${service}`, '--filter', 'status=running']);
75
- const id = splitLines(found.stdout)[0];
76
- if (id && tool(ctx, 'docker', ['exec', id, 'pg_isready', '-q']).status === 0)
77
- return id;
78
- if (ctx.now() >= deadline)
79
- return undefined;
80
- await new Promise((resolve) => { setTimeout(resolve, 3000); });
81
- }
82
- }
83
- /** Whether the target database is empty: no migration history table and no table in public. */
84
- async function defaultEmptiness(ctx, target) {
85
- const [row] = queryRows(ctx, target, "select to_regclass('supabase_migrations.schema_migrations') is not null as history, (select count(*)::int from pg_tables where schemaname = 'public') as public_tables", { withhold: true });
86
- return { empty: row?.history === false && row?.public_tables === 0, history: row?.history === true, publicTables: Number(row?.public_tables ?? -1) };
87
- }
88
- export const FLOOR_DEPS = Object.freeze({ backup: defaultBackup, decrypt: defaultDecrypt, migrate: defaultMigrate, dbContainer: defaultDbContainer, emptiness: defaultEmptiness });
89
- /** A refused floor step: the lines so far and the code; the CLI prints both and exits 2. */
90
- const refused = (lines, code, message, data = {}) => ({ ok: false, refused: { code, message }, lines, data: { ...data, refused: code } });
91
41
  /** Problems of the backup receipt against its archive: written by this run, a full backup, its size, age header and sha256 equal to the file's. */
92
42
  function receiptProblems(receipt, archive, startedMs, sha256) {
93
- const problems = [];
94
- if (!receipt || typeof receipt !== 'object')
95
- return ['no receipt'];
96
- if (receipt.schemaOnly !== false)
97
- problems.push('the backup is not a full (data) backup');
98
- if (!Number.isFinite(Date.parse(receipt.at)) || Date.parse(receipt.at) < startedMs - 1000)
99
- problems.push('the receipt is not from this run');
100
- if (!fs.existsSync(archive))
101
- return [...problems, 'the archive is missing'];
102
- const size = fs.statSync(archive).size;
103
- const header = Buffer.alloc(AGE_HEADER.length);
104
- const fd = fs.openSync(archive, 'r');
105
- try {
106
- fs.readSync(fd, header, 0, header.length, 0);
107
- }
108
- finally {
109
- fs.closeSync(fd);
110
- }
111
- if (header.toString('ascii') !== AGE_HEADER)
112
- problems.push('the archive has no age header');
113
- if (size !== receipt.bytes)
114
- problems.push(`the archive is ${size} bytes, the receipt says ${receipt.bytes}`);
115
- if (!SHA.test(String(receipt.sha256 ?? '')) || sha256(archive) !== receipt.sha256)
116
- problems.push('the archive sha256 differs from the receipt');
117
- return problems;
43
+ const facts = localFacts(archive);
44
+ return backupProblems(receipt, facts.exists ? { ...facts, sha256: sha256(archive) } : facts, startedMs);
118
45
  }
119
46
  /** What the floor needs before anything runs: the pushed urgent record, a declared database, the identity of the test decrypt, the full public verify. */
120
47
  function preconditions(ctx, config, options) {
@@ -135,7 +62,7 @@ function preconditions(ctx, config, options) {
135
62
  }
136
63
  const identity = ctx.env.SOPS_AGE_KEY_FILE;
137
64
  if (!identity || !fs.existsSync(identity))
138
- throw refusal('STACK_URGENT_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
65
+ throw refusal('STACK_DEPLOY_BACKUP_DECRYPT_UNAVAILABLE', 'the test decrypt of the backup reads the age identity named by SOPS_AGE_KEY_FILE, which is not set or not a file');
139
66
  return { record, identity };
140
67
  }
141
68
  /**
@@ -150,19 +77,19 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
150
77
  backup = await f.backup(ctx, { target, out: backupDir(config, options) });
151
78
  }
152
79
  catch (error) {
153
- return refused(lines, 'STACK_URGENT_BACKUP_FAILED', `the fresh database backup failed (${reasonOf(error)}); nothing was migrated or deployed`);
80
+ return refused(lines, 'STACK_DEPLOY_BACKUP_FAILED', `the fresh database backup failed (${reasonOf(error)}); nothing was migrated or deployed`);
154
81
  }
155
82
  const archive = path.join(path.dirname(backup.receiptPath), backup.receipt?.file ?? '');
156
83
  const problems = receiptProblems(backup.receipt, archive, started, run.sha256);
157
84
  if (problems.length > 0)
158
- return refused(lines, 'STACK_URGENT_BACKUP_UNVERIFIED', `${problems.join('; ')}; nothing was migrated or deployed`);
85
+ return refused(lines, 'STACK_DEPLOY_BACKUP_UNVERIFIED', `${problems.join('; ')}; nothing was migrated or deployed`);
159
86
  const receipt = backup.receipt; // receiptProblems reports 'no receipt' when it is absent
160
87
  lines.push(`floor: backup ${path.basename(archive)} (${receipt.bytes} bytes, sha256 ${receipt.sha256.slice(0, 12)}) receipt ${backup.receiptPath}`);
161
88
  try {
162
89
  await f.decrypt(ctx, { archive, identity, parts: receipt.parts });
163
90
  }
164
91
  catch (error) {
165
- return refused(lines, 'STACK_URGENT_DECRYPT_FAILED', `the test decrypt of the backup failed (${reasonOf(error)}); nothing was migrated or deployed`);
92
+ return refused(lines, 'STACK_DEPLOY_BACKUP_DECRYPT_FAILED', `the test decrypt of the backup failed (${reasonOf(error)}); nothing was migrated or deployed`);
166
93
  }
167
94
  lines.push(`floor: test decrypt listed ${receipt.parts.length} part(s)`);
168
95
  const { releaseId } = await run.render();
@@ -179,7 +106,7 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
179
106
  }
180
107
  lines.push(...(preview.lines ?? []).map((line) => `floor: migrate preview: ${line}`));
181
108
  if (!preview.ok)
182
- return refused(lines, 'STACK_URGENT_MIGRATE_PREVIEW_RED', 'the migration preview is red; nothing was migrated or deployed');
109
+ return refused(lines, 'STACK_DEPLOY_MIGRATE_PREVIEW_RED', 'the migration preview is red; nothing was migrated or deployed');
183
110
  let applied;
184
111
  try {
185
112
  applied = await f.migrate(ctx, { target, execute: true });
@@ -189,42 +116,19 @@ async function guard(ctx, config, options, { f, run, identity, lines }) {
189
116
  }
190
117
  lines.push(...(applied.lines ?? []).map((line) => `floor: migrate: ${line}`));
191
118
  if (!applied.ok)
192
- return refused(lines, 'STACK_URGENT_MIGRATE_FAILED', `the migration failed; nothing was deployed (restore from ${backup.receiptPath} if the database is half migrated)`);
119
+ return refused(lines, 'STACK_DEPLOY_MIGRATE_FAILED', `the migration failed; nothing was deployed (restore from ${backup.receiptPath} if the database is half migrated)`);
193
120
  return null;
194
121
  }
195
122
  /**
196
- * The `first-start` step of a first start's deploy (between edge and converge): the database container, the proof it is empty, migrate preview
197
- * then execute inside it. Returns { ok, lines, code } where code names the failure.
123
+ * The `first-start` step of an urgent first start (between the hold deploy and the real one): the shared step (db-phases.mjs) against the database
124
+ * the operator's --db-url-env names, reached inside its container. Returns { ok, lines, code } where code names the failure.
198
125
  */
199
- async function firstStartStep(ctx, config, options, f) {
200
- const service = `${config.stack}_${options.dbService}`;
201
- const container = await f.dbContainer(ctx, config, { service: options.dbService, seconds: FIRST_START_WAIT_SECONDS }); // preconditions required --db-service
202
- if (!container)
203
- return { ok: false, code: 'STACK_FIRST_START_NO_DATABASE', lines: [`first start: no running container of ${service} accepted connections within ${FIRST_START_WAIT_SECONDS}s`] };
204
- const target = inContainer(dbTarget(ctx, options.dbUrlEnv ?? DEFAULT_DB_URL_ENV), container);
205
- const state = await f.emptiness(ctx, target);
206
- if (!state.empty) {
207
- const found = state.history ? 'a migration history' : `${state.publicTables < 0 ? 'an unreadable count of' : state.publicTables} table(s) in public`;
208
- return { ok: false, code: 'STACK_FIRST_START_NOT_EMPTY', lines: [
209
- `first start refused: the database of ${service} holds ${found}; a database with data takes the plain urgent floor (backup first)`,
210
- // A first start that migrated and then rolled back leaves exactly this: a migrated volume and no serving release.
211
- `if an earlier first start migrated this volume and then rolled back, and the owner confirms it holds no data, remove that volume (docker volume rm on the server) and run the first start again`,
212
- ] };
213
- }
214
- const lines = [`first start: the database of ${service} is empty (no migration history, no table in public): nothing to back up`];
215
- for (const execute of [false, true]) {
216
- let result;
217
- try {
218
- result = await f.migrate(ctx, { target: { target }, execute });
219
- }
220
- catch (error) {
221
- result = { ok: false, lines: [messageOf(error)] };
222
- }
223
- lines.push(...(result.lines ?? []).map((line) => `first start: migrate${execute ? '' : ' preview'}: ${line}`));
224
- if (!result.ok)
225
- return { ok: false, code: 'STACK_FIRST_START_MIGRATE_RED', lines: [...lines, `first start: the migration${execute ? '' : ' preview'} is red`] };
226
- }
227
- return { ok: true, lines };
126
+ function firstStartStep(ctx, config, options, f) {
127
+ return sharedFirstStartStep(ctx, config, {
128
+ service: options.dbService, // preconditions required --db-service
129
+ targetFor: (container) => inContainer(dbTarget(ctx, options.dbUrlEnv ?? DEFAULT_DB_URL_ENV), container),
130
+ hint: 'a database with data takes the plain urgent floor (backup first)',
131
+ }, f);
228
132
  }
229
133
  /**
230
134
  * A first start: the rollback target, then the deploy whose `first-start` step bootstraps and migrates the empty database. Returns the dry-run
@@ -262,6 +166,7 @@ async function firstStartDeploy(ctx, config, options, run, f, lines) {
262
166
  export async function urgentDeploy(ctx, config, options, run, deps = {}) {
263
167
  const f = { ...FLOOR_DEPS, ...deps };
264
168
  const { record, identity } = preconditions(ctx, config, options);
169
+ run.note?.(record);
265
170
  const lines = [`urgent ${record.tag} (${options.sourceSha.slice(0, 10)}) by ${record.role}: ${record.reason}`];
266
171
  let deployed;
267
172
  if (options.firstStart === true) {
package/stack/verify.mjs CHANGED
@@ -1,10 +1,12 @@
1
1
  // verify.mjs - functional verification after convergence: the declared HTTP journeys (config.verify.http) and the declared checks inside a
2
2
  // running task (config.verify.exec). Probes are bounded and read-only; a POST body comes only from the declared config and nothing here
3
3
  // mutates business data. HTTP goes through ctx.fetch, docker through ctx.spawn, waits through ctx.sleep and time through ctx.now.
4
+ import fs from 'node:fs';
4
5
  import { isDeepStrictEqual } from 'node:util';
5
6
  import { eachInOrder, repeatInOrder } from '../runtime/scripts/lib/in-order.mjs';
6
7
  import { lines as splitLines, tool } from './context.mjs';
7
8
  import { edgeAttachmentsOf, verifyEdgeAttachment } from './edge-alias.mjs';
9
+ import { compareWithRelease, digestOf, observedServices, stackImages } from './status.mjs';
8
10
  const RETRY_MS = 6000;
9
11
  const HTTP_TIMEOUT_MS = 8000;
10
12
  function expect(condition, message) {
@@ -87,6 +89,28 @@ async function verifyEdge(ctx, config, results, file) {
87
89
  }
88
90
  await eachInOrder(attachments, (item) => runCheck(ctx, results, `edge aliases of ${item.service} on ${item.network} are exactly the declared ones`, () => verifyEdgeAttachment(ctx, config, item)));
89
91
  }
92
+ /**
93
+ * Drift: when the stack file pins its images by digest (a rendered release), every service it names runs exactly that digest and none was rolled back by
94
+ * Swarm, so a release that Swarm quietly reverted never reads as verified. A stack file without pinned images (the source) has nothing to compare.
95
+ */
96
+ async function verifyDrift(ctx, config, results, file) {
97
+ let stackYaml;
98
+ try {
99
+ stackYaml = fs.readFileSync(file, 'utf8');
100
+ if (!Object.values(stackImages(stackYaml)).some((image) => digestOf(image) !== null))
101
+ return;
102
+ }
103
+ catch (error) {
104
+ results.push({ name: 'drift: the stack file is readable', ok: false, detail: String(error.message) });
105
+ return;
106
+ }
107
+ await runCheck(ctx, results, 'drift: the services run the digests of the stack file', () => {
108
+ const live = observedServices(ctx, config);
109
+ const drift = compareWithRelease({ stackYaml, release: {} }, live).filter((entry) => entry.kind !== 'not in the release');
110
+ expect(drift.length === 0, drift.map((entry) => `${entry.service}: ${entry.kind}`).join('; '));
111
+ return `${live.length} service(s)`;
112
+ });
113
+ }
90
114
  const verifyExec = (ctx, config, results) => eachInOrder([...new Set(config.verify.exec.map((check) => check.service))], (service) => verifyService(ctx, config, results, service));
91
115
  /**
92
116
  * Runs the declared verification. Returns { ok, lines, data: { checks: [{ name, ok }] } }; the lines read `PASS|FAIL name detail`.
@@ -99,6 +123,8 @@ export async function verifyStack(ctx, config, { retrySeconds = 180, skipPublic
99
123
  await eachInOrder(config.verify.http, (check) => runCheck(ctx, results, check.name, () => probeHttp(ctx, check), { retry: check.retry, retrySeconds }));
100
124
  if (!skipInternal && stackFile)
101
125
  await verifyEdge(ctx, config, results, stackFile);
126
+ if (!skipInternal && stackFile)
127
+ await verifyDrift(ctx, config, results, stackFile);
102
128
  if (!skipInternal)
103
129
  await verifyExec(ctx, config, results);
104
130
  return {
@@ -9,8 +9,10 @@ const exampleCommand = (source, command) => source?.runtimeExample
9
9
  : command;
10
10
  const UPLOAD_ARTIFACT = 'actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2';
11
11
  const DOWNLOAD_ARTIFACT = 'actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0';
12
- const SONAR_CONDITION = "${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}";
13
- /** Codecov uploads on release tags with its required authentication; a missing report, an unavailable endpoint or a rejected upload fails the job. */
12
+ // Sonar and the Codecov upload run on every full run (a release tag or a candidate), so a release is analysed before it ships; the plan job decides.
13
+ const FULL_RUN = "needs.plan.outputs.full == 'true'";
14
+ const SONAR_CONDITION = `\${{ !cancelled() && ${FULL_RUN} }}`;
15
+ /** Codecov uploads on every full run with its required authentication; a missing report, an unavailable endpoint or a rejected upload fails the job. */
14
16
  function codecovSteps(lcovReport, codecov) {
15
17
  if (codecov.enabled === false || codecov.wiring === 'not-used')
16
18
  return [
@@ -20,7 +22,7 @@ function codecovSteps(lcovReport, codecov) {
20
22
  return [
21
23
  ' - name: coverage upload',
22
24
  ' uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5.5.5',
23
- " if: ${{ !cancelled() && startsWith(github.ref, 'refs/tags/v') }}",
25
+ ` if: ${SONAR_CONDITION}`,
24
26
  ' with:',
25
27
  codecov.auth === 'token' ? ` token: \${{ secrets.${codecov.token} }}` : ' use_oidc: true',
26
28
  ` files: ${lcovReport}`,
@@ -28,7 +30,7 @@ function codecovSteps(lcovReport, codecov) {
28
30
  ' fail_ci_if_error: true',
29
31
  ].join('\n');
30
32
  }
31
- /** Sonar is required: on release tags it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
33
+ /** Sonar is required: on every full run it consumes the test coverage and lint artifacts, and a missing token fails the job with a clear refusal. */
32
34
  function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
33
35
  if (!sonar)
34
36
  return '';
@@ -36,7 +38,8 @@ function sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, timeout) {
36
38
  const host = sonar.provider === 'sonarcloud' ? 'https://sonarcloud.io' : `\${{ vars.${sonar.hostVar} }}`;
37
39
  const header = [
38
40
  ' sonar:',
39
- ' needs: [lint, coverage]',
41
+ ' needs: [plan, lint, coverage]',
42
+ ` if: \${{ ${FULL_RUN} }}`,
40
43
  ` timeout-minutes: ${timeout}`,
41
44
  ' runs-on: ubuntu-latest',
42
45
  ' env:',
@@ -219,6 +222,8 @@ export function ciWorkflowCommands(source, lcovReport = 'be/coverage/lcov.info',
219
222
  ` NODE_OPTIONS: ${settings.nodeOptions}`,
220
223
  ]).join('\n'),
221
224
  codecovSteps: codecovSteps(lcovReport, codecov),
225
+ verdictSonarNeed: sonar ? ', sonar' : '',
226
+ verdictSonarCheck: sonar ? `\n if [[ "$FULL" == true && "$CHECKS" == true ]]; then expect sonar success; else expect sonar idle; fi` : '',
222
227
  sonarJob: sonarJob(sonar, lcovReport, lcovArtifact, nodeMajor, settings.timeoutSonar),
223
228
  lcovArtifact,
224
229
  };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ // format-worker.mts - a worker thread of sync/format.mts: loads the repository's own prettier once and checks the batches it is sent.
2
+ import { parentPort } from 'node:worker_threads';
3
+ import { checkBatch, loadPrettier } from './format.mjs';
4
+ let loaded;
5
+ parentPort?.on('message', (message) => {
6
+ void (async () => {
7
+ try {
8
+ if (loaded?.repoRoot !== message.repoRoot)
9
+ loaded = { repoRoot: message.repoRoot, prettier: loadPrettier(message.repoRoot) };
10
+ const findings = await checkBatch({ repoRoot: message.repoRoot, files: message.files, prettier: loaded.prettier });
11
+ parentPort?.postMessage({ findings, count: message.files.length });
12
+ }
13
+ catch (error) {
14
+ parentPort?.postMessage({ error: String(error.message ?? error), code: error.code });
15
+ }
16
+ })();
17
+ });
package/sync/format.d.mts CHANGED
@@ -27,12 +27,30 @@ export type FormatFinding = {
27
27
  };
28
28
  /** The repository's own prettier, or a SyncError when it is not installed. */
29
29
  export declare function loadPrettier(repoRoot: string): Prettier;
30
+ /** Files per batch: one message to a worker, one progress step. */
31
+ export declare const FORMAT_BATCH = 64;
32
+ /** The formattable files of `files`: lockfiles are written by npm and never formatted. */
33
+ export declare const formattable: (files: readonly string[]) => string[];
30
34
  /**
31
- * The HFS_FORMAT findings over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse.
32
- * `prettier` is a seam for specs; the default is the repository's own.
35
+ * The HFS_FORMAT findings of one batch of `files`, in this thread. The ignore file is read by prettier once per call and the configuration is
36
+ * resolved with prettier's own cache, so a batch costs one lookup per directory.
33
37
  */
34
- export declare function formatFindings({ repoRoot, files, prettier }: {
38
+ export declare function checkBatch({ repoRoot, files, prettier }: {
39
+ repoRoot: string;
40
+ files: readonly string[];
41
+ prettier: Prettier;
42
+ }): Promise<FormatFinding[]>;
43
+ /** Cuts `files` into batches of `size`. */
44
+ export declare function batches<T>(files: readonly T[], size?: number): T[][];
45
+ /** How many worker threads a list of `count` files gets: none below WORKER_MIN_FILES, else up to the cores left over, at most 8. */
46
+ export declare function workerCount(count: number, cores?: number): number;
47
+ /**
48
+ * The HFS_FORMAT findings over the tracked `files` of `repoRoot`: one per file prettier would change, or cannot parse, sorted by path.
49
+ * `prettier` is a seam for specs (and keeps the run in this thread); the default is the repository's own, in worker threads for a long list.
50
+ */
51
+ export declare function formatFindings({ repoRoot, files, prettier, onProgress }: {
35
52
  repoRoot: string;
36
53
  files: readonly string[];
37
54
  prettier?: Prettier | undefined;
55
+ onProgress?: ((done: number) => void) | undefined;
38
56
  }): Promise<FormatFinding[]>;