@starci/hfs 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/ci/cli.mjs +64 -0
  4. package/ci/github.mjs +41 -0
  5. package/ci/secret.mjs +28 -0
  6. package/ci/vars.mjs +33 -0
  7. package/ci/verify.mjs +36 -0
  8. package/db/backup-receipt.mjs +129 -0
  9. package/db/dump.mjs +66 -0
  10. package/db/migrate.mjs +8 -5
  11. package/db/post-cutover.mjs +4 -4
  12. package/db/reset.mjs +125 -0
  13. package/emit/contracts.mjs +2 -2
  14. package/emit/db-types.mjs +100 -7
  15. package/lint/run.mjs +30 -8
  16. package/package.json +2 -1
  17. package/release/ci-red.mjs +70 -0
  18. package/release/gate.mjs +53 -13
  19. package/release/index.mjs +8 -6
  20. package/release/push.mjs +1 -1
  21. package/report/sonar.mjs +1 -1
  22. package/runtime/config.example.yaml +7 -6
  23. package/runtime/engine/config.mjs +10 -18
  24. package/runtime/engine/removed-vocabulary.mjs +0 -2
  25. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  26. package/runtime/knowledge/hfs/rules.yaml +505 -971
  27. package/runtime/knowledge/hfs/slots.yaml +13 -24
  28. package/runtime/modules/kernel/failure-codes.yaml +60 -22
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  30. package/runtime/modules/models/runtimes.yaml +4 -6
  31. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  32. package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
  33. package/runtime/scripts/api/git/lib.mjs +3 -3
  34. package/runtime/scripts/api/sops/lib.mjs +1 -1
  35. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  36. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  37. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture.mjs +1 -1
  41. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  42. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  46. package/runtime/scripts/hfs/param-names.mjs +1 -1
  47. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  48. package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
  49. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  50. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  52. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  53. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  56. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  57. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  58. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  60. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  61. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  62. package/runtime/scripts/hfs/slots.mjs +2 -2
  63. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  64. package/runtime/scripts/lib/env.mjs +2 -2
  65. package/runtime/scripts/lib/git.mjs +1 -1
  66. package/runtime/scripts/lib/i18n.mjs +0 -2
  67. package/runtime/scripts/lib/language.mjs +1 -1
  68. package/runtime/scripts/lib/roots.mjs +2 -4
  69. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  70. package/scaffold/add-fe-overlay.mjs +0 -121
  71. package/scaffold/add-fe-page.mjs +0 -268
  72. package/scaffold/supabase-ports.mjs +1 -1
  73. package/src/main.mjs +17 -6
  74. package/stack/age-install.mjs +1 -1
  75. package/stack/cli.mjs +2 -2
  76. package/stack/context.mjs +14 -2
  77. package/stack/digest.mjs +1 -1
  78. package/stack/docker-transport.mjs +93 -15
  79. package/stack/edge-alias.mjs +1 -1
  80. package/stack/image-scan.mjs +1 -1
  81. package/stack/ingress-create.mjs +2 -2
  82. package/stack/ingress.mjs +3 -3
  83. package/stack/release-store.mjs +12 -7
  84. package/stack/remote.mjs +16 -8
  85. package/stack/sealed.mjs +5 -5
  86. package/stack/ssh-session.mjs +178 -0
  87. package/sync/ci-commands.mjs +103 -1
  88. package/sync/format.mjs +1 -1
  89. package/sync/hygiene.mjs +1 -1
  90. package/sync/index.mjs +43 -18
  91. package/sync/quality-services.mjs +61 -0
  92. package/sync/readme-badges.mjs +44 -0
  93. package/sync/stack-images.mjs +31 -5
  94. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  95. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  96. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  97. package/templates/app/ci-workflows/install-steps.yml +21 -0
  98. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  99. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  100. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  101. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  102. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  103. package/templates/app/quality-config/codecov.yml +6 -6
  104. package/templates/app/quality-config/sonar-project.properties +1 -0
  105. package/templates/app/readme-badges/README.md +1 -0
  106. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  107. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  108. package/templates/app/skeleton/README.md +6 -0
  109. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  110. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  111. package/templates/app/skeleton-lite/README.md +6 -0
  112. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
  113. package/upgrade/index.mjs +1 -1
package/db/reset.mjs ADDED
@@ -0,0 +1,125 @@
1
+ // reset.mjs - linked reset requires explicit custody choices, a recent verified encrypted backup and the migration arming rule.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { runVerb } from '../release/index.mjs';
6
+ import { requireArmed } from './migrate.mjs';
7
+ import { failure, refusal } from '../stack/errors.mjs';
8
+ import { backupSession, backupArgs, backupRecipients, backupTool, encryptFile, queryRows, validateReceipt, runCli } from './backup-receipt.mjs';
9
+
10
+ /** Metadata fingerprints allow verification without printing or retaining rows, object keys or auth identities in the result. */
11
+ function inventory(session, schema) {
12
+ const tables = queryRows(session, `select tablename from pg_catalog.pg_tables where schemaname = '${schema}' and tablename not in ('schema_migrations', 'migrations') order by tablename;`);
13
+ return tables.map(({ tablename }) => {
14
+ if (typeof tablename !== 'string' || !/^[a-z_][a-z0-9_]*$/u.test(tablename)) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation table inventory');
15
+ const rows = queryRows(session, `begin read only; select count(*)::text as count, md5(coalesce(string_agg(md5(row_to_json(t)::text), '' order by md5(row_to_json(t)::text)), '')) as hash from "${schema}"."${tablename}" t; commit;`);
16
+ if (rows.length !== 1 || !/^\d+$/u.test(String(rows[0].count)) || !/^[a-f0-9]{32}$/u.test(rows[0].hash ?? '')) throw failure('DB_RESET_VERIFY_FAILED', 'invalid preservation fingerprint');
17
+ return { table: tablename, count: String(rows[0].count), hash: rows[0].hash };
18
+ });
19
+ }
20
+
21
+ /** age reads identity bytes on stdin or the configured identity file; neither identity nor SQL is placed in argv or diagnostics. */
22
+ function decryptPart(ctx, encrypted, plaintext) {
23
+ const key = ctx.env.SOPS_AGE_KEY;
24
+ const identity = key ? '-' : ctx.env.SOPS_AGE_KEY_FILE;
25
+ if (!identity) throw refusal('DB_RESET_IDENTITY_MISSING', '--auth/--storage keep needs SOPS_AGE_KEY or SOPS_AGE_KEY_FILE to restore the temporary encrypted part');
26
+ backupTool(ctx, 'age', ['--decrypt', '--identity', identity, '-o', plaintext, encrypted], key ? { input: `${key}\n` } : {});
27
+ }
28
+
29
+ function preservePart(session, scratch, schema, recipients) {
30
+ const before = inventory(session, schema);
31
+ const plain = path.join(scratch, `${schema}.sql`);
32
+ const encrypted = `${plain}.age`;
33
+ runCli(session, ['db', 'dump', '--data-only', '--schema', schema, '--exclude', `${schema}.schema_migrations,${schema}.migrations`, '--file', plain, ...backupArgs(session)]);
34
+ encryptFile(session.ctx, plain, encrypted, recipients);
35
+ fs.rmSync(plain, { force: true });
36
+ // Prove the selected identity can restore this exact part before the destructive call.
37
+ decryptPart(session.ctx, encrypted, plain);
38
+ fs.rmSync(plain, { force: true });
39
+ return { schema, before, encrypted, plain };
40
+ }
41
+
42
+ function restorePart(session, part) {
43
+ // Supabase retains some managed tables. Replace their data to avoid duplicate users, buckets or migration-independent metadata.
44
+ decryptPart(session.ctx, part.encrypted, part.plain);
45
+ const tables = part.before.map((row) => `"${part.schema}"."${row.table}"`);
46
+ const source = fs.readFileSync(part.plain, 'utf8');
47
+ fs.writeFileSync(part.plain, `begin;\nset constraints all deferred;\n${tables.length ? `truncate ${tables.join(', ')} cascade;\n` : ''}${source}\ncommit;\n`, { mode: 0o600 });
48
+ try { runCli(session, ['db', 'query', '--file', part.plain, '--output', 'json', '--agent', 'no', ...backupArgs(session)]); }
49
+ finally { fs.rmSync(part.plain, { force: true }); }
50
+ if (JSON.stringify(inventory(session, part.schema)) !== JSON.stringify(part.before)) throw failure('DB_RESET_VERIFY_FAILED', `${part.schema} data differs after restore; use the full backup receipt for recovery`);
51
+ }
52
+
53
+ function expectedHead(session) {
54
+ const dir = path.join(session.ctx.cwd, session.database.migrationsDir ?? 'supabase/migrations');
55
+ let expected;
56
+ try { expected = fs.readdirSync(dir).filter((name) => /^\d{14}_[A-Za-z0-9_-]+\.sql$/u.test(name)).map((name) => name.slice(0, 14)).sort(); }
57
+ catch { throw refusal('DB_MIGRATIONS_MISSING', 'migration directory is not readable'); }
58
+ if (!expected.length || new Set(expected).size !== expected.length) throw refusal('DB_MIGRATIONS_MISSING', 'migration versions must be present and unique');
59
+ return expected;
60
+ }
61
+
62
+ function verifyHead(session) {
63
+ const expected = expectedHead(session);
64
+ const actual = queryRows(session, 'select version from supabase_migrations.schema_migrations order by version;').map((row) => row.version);
65
+ if (JSON.stringify(expected) !== JSON.stringify(actual)) throw failure('DB_RESET_HEAD_MISMATCH', 'reset migration history differs from repository migration versions');
66
+ return expected;
67
+ }
68
+
69
+ /** Dry run validates custody and prints a plan; execute preserves chosen managed data, resets, restores and verifies the exact migration head. */
70
+ function resetDatabase(ctx, options = {}) {
71
+ if (options.linked !== true || options.local || options.urlEnv !== undefined) throw refusal('DB_RESET_LINKED_REQUIRED', 'reset requires --linked; use npm run db:reset for a local database');
72
+ if (!['keep', 'drop'].includes(options.auth) || !['keep', 'drop'].includes(options.storage)) throw refusal('DB_RESET_CHOICE_MISSING', 'explicit --auth keep|drop and --storage keep|drop are required');
73
+ const session = backupSession(ctx, options);
74
+ const receipt = validateReceipt(session, options.backup ? path.resolve(ctx.cwd, options.backup) : undefined);
75
+ const lines = [`target: linked project ${session.target.ref}`, `backup: verified encrypted receipt from ${receipt.at}`,
76
+ options.auth === 'keep' ? 'auth: encrypt all auth data tables before reset, restore in a transaction and verify' : 'auth: users and identities are deleted',
77
+ options.storage === 'keep' ? 'storage: preserve and verify buckets and objects metadata' : 'storage: empty storage.objects metadata; files in the storage backend are not deleted by SQL',
78
+ 'reset: supabase db reset --linked; verify migration versions against repository'];
79
+ if (!options.execute) return { ok: true, lines: [...lines, 'dry run: nothing changed'], data: { executed: false, auth: options.auth, storage: options.storage, ref: session.target.ref } };
80
+ requireArmed(ctx);
81
+ // Refuse unsupported CLI file execution before resetting: SQL containing identity data must never travel in argv.
82
+ if (options.auth === 'keep' || options.storage === 'keep') {
83
+ const help = runCli(session, ['db', 'query', '--help']).text;
84
+ if (!help.includes('--file')) throw refusal('DB_RESET_QUERY_FILE_UNSUPPORTED', 'the Supabase CLI must support db query --file for transactional restore');
85
+ }
86
+ expectedHead(session);
87
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'starci-db-reset-'));
88
+ const parts = [];
89
+ let resetStarted = false;
90
+ let complete = false;
91
+ try {
92
+ const keep = ['auth', 'storage'].filter((schema) => options[schema] === 'keep');
93
+ const recipients = keep.length ? backupRecipients(ctx.cwd) : [];
94
+ for (const schema of keep) parts.push(preservePart(session, scratch, schema, recipients));
95
+ resetStarted = true;
96
+ runCli(session, ['db', 'reset', '--yes', ...backupArgs(session)]);
97
+ for (const part of parts) restorePart(session, part);
98
+ if (options.auth === 'drop') {
99
+ const rows = queryRows(session, 'select (select count(*) from auth.users)::text as users, (select count(*) from auth.identities)::text as identities;');
100
+ if (rows.length !== 1 || String(rows[0].users) !== '0' || String(rows[0].identities) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'auth users or identities remain after reset');
101
+ }
102
+ if (options.storage === 'drop') {
103
+ runCli(session, ['db', 'query', 'begin; delete from storage.objects; commit;', '--output', 'json', '--agent', 'no', ...backupArgs(session)]);
104
+ const rows = queryRows(session, 'select count(*)::text as count from storage.objects;');
105
+ if (rows.length !== 1 || String(rows[0].count) !== '0') throw failure('DB_RESET_VERIFY_FAILED', 'storage.objects metadata was not emptied');
106
+ }
107
+ const versions = verifyHead(session);
108
+ complete = true;
109
+ return { ok: true, lines: [...lines, 'reset completed; managed data choices and migration head verified'], data: { executed: true, auth: options.auth, storage: options.storage, ref: session.target.ref, versions } };
110
+ } finally {
111
+ // Preserve encrypted recovery parts after a reset/restore failure; remove every plaintext file on every exit.
112
+ for (const name of fs.readdirSync(scratch)) if (!name.endsWith('.age')) fs.rmSync(path.join(scratch, name), { force: true });
113
+ if (complete || !resetStarted) fs.rmSync(scratch, { recursive: true, force: true });
114
+ else ctx.stderr(`DB_RESET_RECOVERY_PARTS: encrypted recovery parts retained at ${scratch}\n`);
115
+ }
116
+ }
117
+
118
+ export function dbResetMain(argv, io = {}) {
119
+ return runVerb({ label: 'starci app db reset', name: 'db reset', code: 'DB_USAGE', io, argv,
120
+ spec: { boolean: ['--linked', '--execute', '--json', '--local'], value: ['--auth', '--storage', '--backup', '--url-env'] },
121
+ run: (ctx, flags, positional) => {
122
+ if (positional.length) throw refusal('DB_USAGE', 'reset takes flags only');
123
+ return resetDatabase(ctx, { linked: flags.linked, local: flags.local, urlEnv: flags['url-env'], execute: flags.execute, auth: flags.auth, storage: flags.storage, backup: flags.backup });
124
+ } });
125
+ }
@@ -41,7 +41,7 @@ const OPERATIONS_WORKER = path.join(path.dirname(fileURLToPath(import.meta.url))
41
41
  export const appModulePath = (app) => `apps/${app}/src/app.module.ts`;
42
42
 
43
43
  /** The repository-relative path of an app's snapshot. */
44
- export const snapshotPath = (app) => `contracts/${app}/schema.graphql`;
44
+ const snapshotPath = (app) => `contracts/${app}/schema.graphql`;
45
45
 
46
46
  /** The `paths` aliases of a tsconfig as [{ prefix, targets }], `@modules/*` giving prefix `@modules/`. Pure. */
47
47
  export function aliasesOf(paths, baseDir) {
@@ -57,7 +57,7 @@ export function aliasTarget(aliases, specifier) {
57
57
  }
58
58
 
59
59
  /** The api apps of a declaration that can serve GraphQL: kind `api`. Pure. */
60
- export const apiApps = (declaration) => (declaration.apps ?? []).filter((app) => app.kind === 'api').map((app) => app.name);
60
+ const apiApps = (declaration) => (declaration.apps ?? []).filter((app) => app.kind === 'api').map((app) => app.name);
61
61
 
62
62
  /** The text written to a snapshot file: the printed schema and one final newline. Pure. */
63
63
  export const snapshotText = (printed) => `${printed.replace(/\n+$/, '')}\n`;
package/emit/db-types.mjs CHANGED
@@ -8,7 +8,8 @@
8
8
  * schemas `supabase/config.toml` declares under [api]; the CLI reaches Docker, so nothing here runs inside a check that has
9
9
  * no stack. `run` is the command runner - `(file, args, {cwd})` answering the spawnSync result `{status, stdout, stderr}` -
10
10
  * the default spawns the `supabase` binary with an argument array, never a shell string and never printing env; a spec's fake
11
- * keeps Docker out of the suite. A run that cannot produce the text throws DbTypesError (HFS_EMIT_DB_TYPES_FAILED).
11
+ * keeps Docker out of the suite. Before generation, `migration list --local` must prove that the database's applied versions
12
+ * equal the migration filename stamps (HFS_EMIT_DB_TYPES_STALE otherwise). Unreadable history or failed generation throws HFS_EMIT_DB_TYPES_FAILED.
12
13
  */
13
14
  import { createHash } from 'node:crypto';
14
15
  import { spawnSync } from 'node:child_process';
@@ -22,13 +23,13 @@ export const dbTypesPath = 'supabase/types/database.types.ts';
22
23
 
23
24
  /** The folder of the app's migrations, and the first line of the committed types: the digest of the migrations they were generated from. */
24
25
  const MIGRATIONS_DIR = 'supabase/migrations';
25
- export const TYPES_STAMP = '// migrations-sha256: ';
26
+ const TYPES_STAMP = '// migrations-sha256: ';
26
27
 
27
28
  /**
28
29
  * The sha256 of the app's migrations, hex: every `supabase/migrations/*.sql` by name, each as name, NUL, its text with LF line ends,
29
30
  * NUL. The offline codegen hook of a lite app (templates/app/codegen-lite) computes the same digest and refuses types stamped with another.
30
31
  */
31
- export function migrationsDigest(root) {
32
+ function migrationsDigest(root) {
32
33
  const dir = path.join(root, MIGRATIONS_DIR);
33
34
  const hash = createHash('sha256');
34
35
  for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql')).sort()) {
@@ -44,12 +45,12 @@ const DB_TYPES_SLOT = 'app.supabase.types';
44
45
  const CONFIG_FILE = 'supabase/config.toml';
45
46
  const SUPABASE_CLI = 'supabase';
46
47
 
47
- /** A types emit that could not run: the code modules/kernel/failure-codes.yaml catalogues as HFS_EMIT_DB_TYPES_FAILED. */
48
+ /** A types emit refuses unreadable history or failed generation with HFS_EMIT_DB_TYPES_FAILED, and unequal applied versions with HFS_EMIT_DB_TYPES_STALE. */
48
49
  class DbTypesError extends Error {
49
- constructor(message, details = {}) {
50
- super(`HFS_EMIT_DB_TYPES_FAILED: ${message}`);
50
+ constructor(message, details = {}, code = 'HFS_EMIT_DB_TYPES_FAILED') {
51
+ super(`${code}: ${message}`);
51
52
  this.name = 'DbTypesError';
52
- this.code = 'HFS_EMIT_DB_TYPES_FAILED';
53
+ this.code = code;
53
54
  this.details = details;
54
55
  }
55
56
  }
@@ -97,6 +98,97 @@ function apiSchemas(root) {
97
98
  return (toml?.api?.schemas ?? []).filter((schema) => typeof schema === 'string');
98
99
  }
99
100
 
101
+ /** CLI JSON and table rows use the remote field for the database history even with --local; local only describes files. */
102
+ function historyVersion(value) {
103
+ if (value == null || value === '') return null;
104
+ if (typeof value !== 'string' || !/^\d+$/.test(value)) {
105
+ throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
106
+ }
107
+ return value;
108
+ }
109
+
110
+ /** JSON must explicitly carry a migrations array; an empty array proves an empty database history. */
111
+ function jsonAppliedVersions(text) {
112
+ let history;
113
+ try { history = JSON.parse(text); } catch {
114
+ throw new DbTypesError('the local migration history contains unreadable JSON', { file: dbTypesPath });
115
+ }
116
+ if (!Array.isArray(history?.migrations)) {
117
+ throw new DbTypesError('the local migration history has no migrations array', { file: dbTypesPath });
118
+ }
119
+ const versions = new Set();
120
+ for (const row of history.migrations) {
121
+ if (!row || !Object.hasOwn(row, 'local') || !Object.hasOwn(row, 'remote')) {
122
+ throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
123
+ }
124
+ historyVersion(row.local);
125
+ const applied = historyVersion(row.remote);
126
+ if (applied) versions.add(applied);
127
+ }
128
+ return versions;
129
+ }
130
+
131
+ /** Optional outer borders do not add columns; pretty output may wrap each table cell in Markdown backticks. */
132
+ const historyCells = (line, bordered) => {
133
+ const row = bordered ? line.trim().replace(/^\|/, '').replace(/\|$/, '') : line;
134
+ return row.split('|').map((cell) => cell.trim().replace(/^`(.*)`$/, '$1'));
135
+ };
136
+
137
+ /** A recognized header, including a header without rows, proves a table history; malformed rows still refuse. */
138
+ function tableAppliedVersions(lines, header) {
139
+ const versions = new Set();
140
+ const bordered = /^\s*\|/.test(lines[header]);
141
+ const width = historyCells(lines[header], bordered).length;
142
+ for (const line of lines.slice(header + 1)) {
143
+ if (/^[\s|+-]+$/.test(line)) continue;
144
+ if (!line.includes('|')) continue;
145
+ const cells = historyCells(line, bordered);
146
+ if (cells.length !== width) {
147
+ throw new DbTypesError('the local migration history contains an unreadable row', { file: dbTypesPath });
148
+ }
149
+ historyVersion(cells[0]);
150
+ const applied = historyVersion(cells[1]);
151
+ if (applied) versions.add(applied);
152
+ }
153
+ return versions;
154
+ }
155
+
156
+ /** Read either CLI stream, stripping terminal colors, and require an explicit JSON history or Local | Remote [| Time] header. */
157
+ function appliedVersions(result) {
158
+ for (const output of [result.stdout, result.stderr]) {
159
+ const text = String(output ?? '').replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '').replaceAll('\u2502', '|').trim();
160
+ const lines = text.split(/\r?\n/);
161
+ const json = lines.findIndex((line) => line.trimStart().startsWith('{'));
162
+ if (json >= 0) return jsonAppliedVersions(lines.slice(json).join('\n'));
163
+ const header = lines.findIndex((line) => /^\s*\|?\s*Local\s*\|\s*Remote\s*(?:\|\s*Time(?:\s*\(UTC\))?\s*)?\|?\s*$/i.test(line));
164
+ if (header >= 0) return tableAppliedVersions(lines, header);
165
+ }
166
+ throw new DbTypesError('the local migration history is unreadable; expected migrations JSON or the Local | Remote migration list', { file: dbTypesPath });
167
+ }
168
+
169
+ /** Every applied local version must equal a migration filename stamp, including gaps and extra versions even when the heads match. */
170
+ function requireCurrentDatabase(root, run) {
171
+ const dir = path.join(root, MIGRATIONS_DIR);
172
+ const expected = new Set();
173
+ for (const name of (fs.existsSync(dir) ? fs.readdirSync(dir) : []).filter((file) => file.endsWith('.sql'))) {
174
+ const stamp = /^(\d+)(?:_|\.sql$)/.exec(name)?.[1];
175
+ if (!stamp) throw new DbTypesError(`${MIGRATIONS_DIR} contains a SQL file without a migration version`, { file: dbTypesPath });
176
+ expected.add(stamp);
177
+ }
178
+ let result;
179
+ try { result = run(SUPABASE_CLI, ['migration', 'list', '--local'], { cwd: root }); } catch {
180
+ throw new DbTypesError('supabase migration list --local could not run; the local migration history must be readable before emitting types', { file: dbTypesPath });
181
+ }
182
+ if (result?.error || result?.signal || result?.status !== 0) {
183
+ throw new DbTypesError('supabase migration list --local failed; the local migration history must be readable before emitting types', { file: dbTypesPath });
184
+ }
185
+ const applied = appliedVersions(result);
186
+ if (applied.size !== expected.size || [...expected].some((version) => !applied.has(version))) {
187
+ const head = (versions) => [...versions].sort().at(-1) ?? 'none';
188
+ throw new DbTypesError(`the local database is at ${head(applied)}, the migrations folder at ${head(expected)}; run "npm run db:reset" then emit again`, { file: dbTypesPath }, 'HFS_EMIT_DB_TYPES_STALE');
189
+ }
190
+ }
191
+
100
192
  /**
101
193
  * The freshly generated text of `dbTypesPath` for the app at `root`: `supabase gen types typescript --local` (the running
102
194
  * local stack), narrowed to the [api] schemas of supabase/config.toml. A failing CLI, a spawn error and an empty answer all
@@ -104,6 +196,7 @@ function apiSchemas(root) {
104
196
  */
105
197
  function emitDbTypesWithRunner({ root, run }) {
106
198
  const schemas = apiSchemas(root);
199
+ requireCurrentDatabase(root, run);
107
200
  const args = ['gen', 'types', 'typescript', '--local', ...(schemas.length ? ['--schema', schemas.join(',')] : [])];
108
201
  const command = `${SUPABASE_CLI} ${args.join(' ')}`;
109
202
  let result;
package/lint/run.mjs CHANGED
@@ -1,6 +1,6 @@
1
1
  // starci app lint - the ONE lint entry and the ONE report of a StarCi app.
2
2
  //
3
- // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--fix] [--format text|json] [--sonar <file>]
3
+ // starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
4
4
  //
5
5
  // It runs at the app root (the folder of hfs.json), over the app (or over the app-relative files named by --changed):
6
6
  // 1. ESLint once per side, from the side folder, through the app's own install and that side's eslint.config.mjs (be/ the be canon,
@@ -26,6 +26,7 @@ import { STYLE_GLOB } from '../sync/index.mjs';
26
26
  import { byCodeUnit } from '../report/order.mjs';
27
27
  import { braceVariants, globExpression } from '../runtime/scripts/lib/glob.mjs';
28
28
 
29
+ // --side judges only that side; root HFS findings belong to be, and Sonar always requires the full lint.
29
30
  export const LINT_SCHEMA = 'starci/lint@1';
30
31
  /** The side whose stylesheets stylelint judges. */
31
32
  const STYLE_SIDE = 'fe';
@@ -41,6 +42,22 @@ export function workspaceOf(workspace) {
41
42
  return rel;
42
43
  }
43
44
 
45
+ /** Side scopes partition the full lint; Sonar requires the complete report and a frontend workspace cannot belong to be. */
46
+ function validateSide(opts) {
47
+ if (opts.side === undefined) return;
48
+ if (!SIDES.includes(opts.side)) throw new Error('LINT_SIDE_INVALID: --side is be or fe');
49
+ if (opts.sonar !== undefined) throw new Error('LINT_SIDE_SONAR_CONFLICT: --side and --sonar do not combine');
50
+ if (opts.side === 'be' && opts.workspace !== undefined) throw new Error('LINT_SIDE_WORKSPACE_CONFLICT: --side be cannot judge a frontend workspace');
51
+ }
52
+
53
+ /** Side findings stay with their side; findings outside both sides belong to the backend lint. */
54
+ function findingOnSide(finding, side) {
55
+ if (side === undefined) return true;
56
+ const file = posix(finding.path ?? '');
57
+ const owner = SIDES.find((candidate) => file === candidate || file.startsWith(`${candidate}/`));
58
+ return (owner ?? 'be') === side;
59
+ }
60
+
44
61
  /** The flags of `starci app lint`; `--changed` takes every argument up to the next flag, and a repeated `--changed` adds to the list (the
45
62
  * catalog's list flag, `--changed a --changed b`, as the CLI dispatcher passes it through). */
46
63
  export function parseLintArgs(argv) {
@@ -52,19 +69,21 @@ export function parseLintArgs(argv) {
52
69
  while (i + 1 < argv.length && !argv[i + 1].startsWith('--')) opts.changed.push(posix(argv[++i]));
53
70
  } else if (arg === '--fix') opts.fix = true;
54
71
  else if (arg === '--json') opts.format = 'json';
55
- else if (['--format', '--sonar', '--workspace'].includes(arg)) {
72
+ else if (['--format', '--sonar', '--workspace', '--side'].includes(arg)) {
73
+ if (arg === '--side' && (argv[i + 1] === undefined || argv[i + 1].startsWith('--'))) throw new Error('LINT_SIDE_INVALID: --side needs be or fe');
56
74
  if (argv[i + 1] === undefined) throw new Error(`${arg} needs a value`);
57
75
  opts[arg.slice(2)] = argv[++i];
58
76
  } else throw new Error(`unknown argument ${arg}`);
59
77
  }
60
78
  if (!['text', 'json'].includes(opts.format)) throw new Error('--format is text or json');
61
79
  if (opts.workspace !== undefined && opts.changed !== null) throw new Error('--workspace and --changed do not combine: a workspace lint judges the whole workspace');
80
+ validateSide(opts);
62
81
  return opts;
63
82
  }
64
83
 
65
84
  /** The typed ESLint run reads nothing above the app root: its TypeScript `sys` is bound by this preload (3.3, bound-sys.cjs). */
66
- export const BOUND_ENV = 'STARCI_LINT_BOUND';
67
- export const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
85
+ const BOUND_ENV = 'STARCI_LINT_BOUND';
86
+ const linterBoundArgs = () => ['--require', fileURLToPath(new URL('./bound-sys.cjs', import.meta.url))];
68
87
 
69
88
  /** The child `node` ran with `args` from `cwd`: its exit status, signal, spawn error and the text of both streams, once it closes. */
70
89
  function runNode(args, options) {
@@ -182,7 +201,7 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
182
201
  /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
183
202
  async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe }) {
184
203
  const launches = [];
185
- for (const side of app ? SIDES : []) {
204
+ for (const side of app ? SIDES.filter((candidate) => opts.side === undefined || opts.side === candidate) : []) {
186
205
  if (workspace !== null && side !== STYLE_SIDE) { launches.push({ side, skipped: `outside --workspace ${workspace}` }); continue; }
187
206
  const sources = onSide(existing, side).filter((file) => /\.(?:[cm]?[jt]sx?)$/.test(file));
188
207
  if (changed !== null && !sources.length) { launches.push({ side, skipped: 'no changed source file' }); continue; }
@@ -197,6 +216,7 @@ async function launchEslint({ app, repoRoot, opts, changed, existing, workspace,
197
216
 
198
217
  /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
199
218
  function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
219
+ if (opts.side === 'be') return null;
200
220
  const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
201
221
  if (changed !== null && !styles.length) return null;
202
222
  return runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
@@ -210,6 +230,8 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
210
230
  const changed = opts.changed === null ? null : new Set(opts.changed);
211
231
  const errors = [];
212
232
  let workspace = null;
233
+ let validSide = true;
234
+ try { validateSide(opts); } catch (error) { errors.push(String(error?.message ?? error)); validSide = false; }
213
235
  if (opts.workspace !== undefined) {
214
236
  try { workspace = workspaceOf(opts.workspace); } catch (error) { errors.push(String(error?.message ?? error)); }
215
237
  }
@@ -223,7 +245,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
223
245
  let app = null;
224
246
  try { app = readRepoDeclaration(loadSlotManifest(), repoRoot); } catch (error) { errors.push(`starci app lint runs at the app root: ${String(error?.message ?? error)}`); }
225
247
  if (app && !app.sides) { errors.push(`${repoRoot} is the ${app.side} side of an app; starci app lint runs at the app root, the folder of hfs.json`); app = null; }
226
- if (opts.workspace !== undefined && workspace === null) app = null;
248
+ if (!validSide || (opts.workspace !== undefined && workspace === null)) app = null;
227
249
  if (workspace !== null && !fs.existsSync(path.join(repoRoot, workspace, 'package.json'))) { errors.push(`--workspace ${workspace} holds no package.json; it is not a workspace of this app`); app = null; }
228
250
  /** The workspace folder relative to the fe side folder (apps/<app> or packages/<pkg>). */
229
251
  const onFe = workspace === null ? null : workspace.slice(STYLE_SIDE.length + 1);
@@ -277,7 +299,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
277
299
  if (settled.error) errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
278
300
  else checked = settled.value;
279
301
  }
280
- const repoErrors = checked.findings.filter((finding) => finding.level === 'error');
302
+ const repoErrors = checked.findings.filter((finding) => finding.level === 'error' && findingOnSide(finding, opts.side));
281
303
  // A workspace lint keeps only the app findings inside the workspace; the rest belong to the root lint of the whole app.
282
304
  const kept = workspace !== null
283
305
  ? repoErrors.filter((finding) => finding.path && inWorkspace(finding.path))
@@ -297,7 +319,7 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
297
319
  linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
298
320
  linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
299
321
  ]);
300
- const report = { schema: LINT_SCHEMA, ok: findings.length === 0 && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, counts: { error: findings.length }, engines, errors, findings };
322
+ const report = { schema: LINT_SCHEMA, ok: findings.length === 0 && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: findings.length }, engines, errors, findings };
301
323
  return { report, sonar, exit: errors.length ? 2 : findings.length ? 1 : 0 };
302
324
  }
303
325
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.4.0",
3
+ "version": "4.5.0",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,6 +21,7 @@
21
21
  "stack/**",
22
22
  "release/**",
23
23
  "db/**",
24
+ "ci/**",
24
25
  "lint/**",
25
26
  "scaffold/**",
26
27
  "report/**",
@@ -0,0 +1,70 @@
1
+ // ci-red.mjs - the latest completed default-branch CI is a release precondition; unreadable GitHub warns, a red run needs the owner's SHA-bound override.
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+ import { refusal } from '../stack/errors.mjs';
5
+
6
+ const FIELDS = 'databaseId,headSha,status,conclusion,url,event,createdAt';
7
+ const SHA = /^[a-f0-9]{40}$/u;
8
+ const RED = new Set(['failure', 'cancelled', 'timed_out', 'startup_failure', 'action_required']);
9
+
10
+ /** Every process uses the supplied seam; an unavailable executable is an unreadable result. */
11
+ function run(spawn, cwd, command, args) {
12
+ try { return spawn(command, args, { cwd }); } catch { return { status: null }; }
13
+ }
14
+
15
+ function runsOf(result) {
16
+ if (result.error || result.status !== 0) return null;
17
+ try {
18
+ const rows = JSON.parse(result.stdout);
19
+ return Array.isArray(rows) ? rows : null;
20
+ } catch { return null; }
21
+ }
22
+
23
+ /** Reads branch runs plus push/tag runs whose commits are reachable from the default branch; the newest completed run decides, even if a newer run is pending. */
24
+ export function latestDefaultBranchCi({ spawn, cwd, defaultBranch = 'main' }) {
25
+ const args = ['run', 'list', '--workflow', 'ci.yml'];
26
+ const tail = ['--limit', '20', '--json', FIELDS];
27
+ const branch = runsOf(run(spawn, cwd, 'gh', [...args, '--branch', defaultBranch, ...tail]));
28
+ if (!branch) return { state: 'unknown', why: 'gh branch runs unavailable (missing gh, authentication, or unreadable response)' };
29
+ const pushes = runsOf(run(spawn, cwd, 'gh', [...args, '--event', 'push', ...tail]));
30
+ if (!pushes) return { state: 'unknown', why: 'gh push runs unavailable (missing gh, authentication, or unreadable response)' };
31
+ const candidates = branch.filter((row) => row && SHA.test(row.headSha));
32
+ let ancestryUnknown = false;
33
+ for (const row of pushes) {
34
+ if (!row || !SHA.test(row.headSha) || row.event !== 'push') continue;
35
+ const ancestor = run(spawn, cwd, 'git', ['merge-base', '--is-ancestor', row.headSha, defaultBranch]);
36
+ if (!ancestor.error && ancestor.status === 0) candidates.push(row);
37
+ else if (ancestor.error || ancestor.status !== 1) ancestryUnknown = true;
38
+ }
39
+ const completed = candidates.filter((row) => row.status === 'completed' && Number.isFinite(Date.parse(row.createdAt)));
40
+ completed.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt) || Number(b.databaseId) - Number(a.databaseId));
41
+ const latest = completed[0];
42
+ if (!latest) return { state: 'unknown', why: ancestryUnknown ? 'cannot establish push run ancestry on the default branch' : 'no completed CI run on the default branch' };
43
+ if (latest.conclusion === 'success') return { state: 'green', run: latest };
44
+ if (RED.has(latest.conclusion)) return { state: 'red', run: latest };
45
+ return { state: 'unknown', why: 'latest completed CI run has no success or red conclusion', run: latest };
46
+ }
47
+
48
+ /** Only a complete starci/ci-override@1 record for this exact commit can excuse a red run. */
49
+ function overrideOf(ctx, sha) {
50
+ const common = run(ctx.spawn, ctx.cwd, 'git', ['rev-parse', '--git-common-dir']);
51
+ if (common.error || common.status !== 0 || !String(common.stdout ?? '').trim()) return null;
52
+ const file = path.join(path.resolve(ctx.cwd, String(common.stdout).trim()), 'starci-release', `${sha}.ci-override.json`);
53
+ try {
54
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
55
+ if (record.schema !== 'starci/ci-override@1' || record.sha !== sha || typeof record.reason !== 'string' || !record.reason.trim() || typeof record.by !== 'string' || !record.by.trim() || typeof record.at !== 'string' || !Number.isFinite(Date.parse(record.at))) return null;
56
+ return record;
57
+ } catch { return null; }
58
+ }
59
+
60
+ /** Warns on unknown CI and refuses red CI unless the owner left a valid override beside the release records. */
61
+ export function requireGreenCi(ctx, ci) {
62
+ if (ci.state === 'unknown') {
63
+ ctx.stderr(`RELEASE_CI_UNKNOWN: ${ci.why}\n`);
64
+ return [];
65
+ }
66
+ if (ci.state !== 'red') return [];
67
+ const record = overrideOf(ctx, ci.run.headSha);
68
+ if (record) return [`RELEASE_CI_RED overridden by the owner: ${record.reason}`];
69
+ throw refusal('RELEASE_CI_RED', `CI run ${ci.run.url} for ${ci.run.headSha} concluded ${ci.run.conclusion}; the owner must use starci release ci-override --sha ${ci.run.headSha} --reason <text>`);
70
+ }
package/release/gate.mjs CHANGED
@@ -7,8 +7,10 @@ import { createHash } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
10
+ import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
10
11
  import { firstLine, lines, tool } from '../stack/context.mjs';
11
12
  import { failure, refusal } from '../stack/errors.mjs';
13
+ import { latestDefaultBranchCi, requireGreenCi } from './ci-red.mjs';
12
14
 
13
15
  export const RELEASE_CODE = 'RELEASE_CONFIG_INVALID';
14
16
 
@@ -35,10 +37,40 @@ const scriptGate = (script) => ({
35
37
  env: {},
36
38
  });
37
39
 
38
- /** The gates derived from the managed package scripts: the app check first, then every script the sync renders that the app has. */
39
- export function defaultGates(scripts) {
40
+ /** Default gates run app-check, the enabled audit, then every managed script present in the app. */
41
+ function defaultGates(scripts, audit) {
40
42
  const present = SCRIPT_GATES.filter((script) => typeof scripts?.[script] === 'string');
41
- return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...present.map(scriptGate)];
43
+ return [{ name: 'app-check', command: ['npx', 'starci', 'app', 'check'], summary: null, env: {} }, ...(audit ? [audit] : []), ...present.map(scriptGate)];
44
+ }
45
+
46
+ /** Shared quality settings retain the release reader's audit validation, disabled mode and legacy string host compatibility. */
47
+ function releaseServices(services, options) {
48
+ const audit = services?.audit ?? {};
49
+ if (!plain(audit) || (audit.in !== undefined && (!Array.isArray(audit.in) || !audit.in.every((scope) => typeof scope === 'string'))) || (audit.omitDev !== undefined && typeof audit.omitDev !== 'boolean')) throw refusal(RELEASE_CODE, 'services.audit must be a mapping with a string-list in and boolean omitDev');
50
+ const sonar = services?.sonar;
51
+ const compatible = typeof sonar?.host === 'string' ? { ...sonar, host: { public: sonar.host } } : sonar;
52
+ const quality = qualityServices({ services: { ...services, sonar: compatible } }, options);
53
+ if (audit.mode === 'disabled') quality.audit = null;
54
+ return quality;
55
+ }
56
+
57
+ /** The shared audit settings preserve release scope, level and development-dependency selection. */
58
+ function auditGate(audit) {
59
+ if (!audit || !audit.in.includes('release-gate')) return null;
60
+ if (!['info', 'low', 'moderate', 'high', 'critical'].includes(audit.level)) throw refusal(RELEASE_CODE, 'services.audit.level must be an npm audit level');
61
+ return { name: 'audit', command: ['npm', 'audit', ...(audit.omitDev ? ['--omit=dev'] : []), `--audit-level=${audit.level}`], summary: null, env: {} };
62
+ }
63
+
64
+ /** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
65
+ function sonarGate(services, env) {
66
+ const sonar = services?.sonar;
67
+ const tokenName = sonar?.token;
68
+ if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName]) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
69
+ const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
70
+ const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
71
+ if (organization) command.push(`-Dsonar.organization=${organization}`);
72
+ if (!command.every((word) => TOKEN.test(word))) throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
73
+ return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] } };
42
74
  }
43
75
 
44
76
  function gateProblems(entry, at, seen) {
@@ -74,7 +106,7 @@ function gatesOf(list, problems) {
74
106
  }
75
107
 
76
108
  /** Judges the optional `release:` block. Absence is fine (defaults). Returns { config } or { problems }. */
77
- export function normalizeRelease(block, scripts = {}) {
109
+ export function normalizeRelease(block, scripts = {}, services = {}, options = {}) {
78
110
  const raw = block ?? {};
79
111
  if (!plain(raw)) return { problems: ['release: must be a mapping'] };
80
112
  const problems = Object.keys(raw).filter((key) => !FIELDS.has(key)).map((key) => `release.${key}: is not a field of the release block`);
@@ -84,8 +116,11 @@ export function normalizeRelease(block, scripts = {}) {
84
116
  if (typeof tagPrefix !== 'string' || !PREFIX.test(tagPrefix)) problems.push(`release.tagPrefix: must match ${PREFIX}`);
85
117
  if (!refName(defaultBranch)) problems.push(`release.defaultBranch: must match ${REF_NAME}`);
86
118
  if (typeof tagMatchesVersion !== 'boolean') problems.push('release.tagMatchesVersion: must be true or false');
87
- const gates = raw.gates === undefined ? defaultGates(scripts) : gatesOf(raw.gates, problems);
88
- return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion } } : { problems };
119
+ const quality = releaseServices(services, options);
120
+ const audit = auditGate(quality.audit);
121
+ const gates = raw.gates === undefined ? defaultGates(scripts, audit) : gatesOf(raw.gates, problems);
122
+ if (raw.gates !== undefined && audit && !gates.some((gate) => gate.name === 'audit')) gates.push(audit);
123
+ return problems.length === 0 ? { config: { gates, tagPrefix, defaultBranch, tagMatchesVersion, services: quality } } : { problems };
89
124
  }
90
125
 
91
126
  /* ---- git through the seam */
@@ -128,7 +163,7 @@ function nextFree(prefix, version, taken) {
128
163
  }
129
164
 
130
165
  /** The tag this release uses and its state: on-head (reused), free (will be created) or taken (exists on another commit). */
131
- export function chooseTag(ctx, config, { prefix, remote }) {
166
+ function chooseTag(ctx, config, { prefix, remote }) {
132
167
  const onHead = annotatedOnHead(ctx, prefix);
133
168
  const taken = takenTags(ctx, prefix, remote);
134
169
  const exact = `${prefix}${config.version}`;
@@ -147,7 +182,7 @@ const lineAfter = (text, label) => {
147
182
  };
148
183
 
149
184
  /** The jest summary lines of a log: { suites, failedSuites, tests, failed, passed } or null when jest printed none. */
150
- export function jestSummary(text) {
185
+ function jestSummary(text) {
151
186
  const suites = lineAfter(text, 'Test Suites:');
152
187
  const tests = lineAfter(text, 'Tests:');
153
188
  if (suites === null || tests === null) return null;
@@ -159,7 +194,7 @@ export function jestSummary(text) {
159
194
  }
160
195
 
161
196
  /** Judges a jest-style gate from its log: green needs suites that all passed, every test passed and no coverage threshold miss. */
162
- export function judgeJest(text) {
197
+ function judgeJest(text) {
163
198
  const summary = jestSummary(text);
164
199
  const misses = (text.match(THRESHOLD_MISS) ?? []).length;
165
200
  if (!summary) return { pass: false, detail: 'no jest summary in the log', summary: null, misses };
@@ -179,9 +214,11 @@ function runGate(ctx, gate, { dir, platform }) {
179
214
  const started = ctx.now();
180
215
  const [command, args] = launch(gate.command, platform);
181
216
  const run = tool(ctx, command, args, { cwd: ctx.cwd, env: { ...ctx.env, ...gate.env } });
182
- const text = `${run.stdout ?? ''}${run.stderr ?? ''}`;
217
+ const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
218
+ const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
183
219
  fs.writeFileSync(log, text);
184
220
  const verdict = judgeVerdict(run, gate, text);
221
+ if (gate.name === 'sonar' && gate.env.SONAR_TOKEN) verdict.detail = verdict.detail.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]');
185
222
  const seconds = Math.round((ctx.now() - started) / 1000);
186
223
  return {
187
224
  name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
@@ -274,15 +311,18 @@ const summaryOf = (result) => `${result.pass ? 'PASS' : 'FAIL'} ${result.name} (
274
311
 
275
312
  /**
276
313
  * `starci app release gate`: dry run prints the preconditions, the gate list, the head and the tag; --execute runs every gate and, only if all are green,
277
- * writes the L4 record and creates the annotated tag. options: { execute, source, tagPrefix, platform }.
314
+ * writes the L4 record and creates the annotated tag. options: { execute, sonar, source, tagPrefix, platform }.
278
315
  */
279
316
  export async function releaseGate(ctx, config, options = {}) {
317
+ if (options.sonar) config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
318
+ const ci = latestDefaultBranchCi({ spawn: ctx.spawn, cwd: ctx.cwd, defaultBranch: config.defaultBranch });
319
+ const ciLines = requireGreenCi(ctx, ci);
280
320
  const facts = inspect(ctx, config, options);
281
321
  const common = releaseDir(ctx);
282
322
  const recordPath = path.join(common, `${facts.head}.l4.json`);
283
323
  const dir = path.join(common, facts.head.slice(0, 12));
284
- const plan = [...facts.out, ...planLines(config, facts, dir, recordPath)];
285
- const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name) };
324
+ const plan = [...ciLines, ...facts.out, ...planLines(config, facts, dir, recordPath)];
325
+ const base = { head: facts.head, tag: facts.chosen.tag, record: recordPath, gates: config.gates.map((gate) => gate.name), ci: { state: ci.state, notices: ciLines } };
286
326
  if (facts.red.length > 0) return { ok: false, lines: [...plan, ...facts.red.map((item) => `RED: ${item}`), 'nothing was run.'], data: { ...base, red: facts.red, executed: false } };
287
327
  if (options.execute !== true) return { ok: true, lines: [...plan, '', 'dry run: no gate was run, nothing written. preconditions OK.'], data: { ...base, red: [], executed: false } };
288
328
  const results = runAll(ctx, config, { dir, platform: options.platform ?? process.platform });