@starci/hfs 4.7.11 → 4.7.13

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 (54) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/package.json +1 -1
  3. package/release/gate.mjs +388 -378
  4. package/release/lint-report.mjs +52 -0
  5. package/runtime/engine/digest.mjs +16 -0
  6. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  7. package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
  8. package/runtime/packages/cli/src/catalog.generated.mjs +7 -4
  9. package/runtime/scripts/api/fs/is-link-like.mjs +1 -0
  10. package/runtime/scripts/api/sops/lib.mjs +5 -1
  11. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  12. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
  13. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -2
  14. package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
  15. package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
  16. package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
  17. package/runtime/scripts/hfs/db-plan-verb.mjs +4 -5
  18. package/runtime/scripts/hfs/doc-commands.mjs +2 -1
  19. package/runtime/scripts/hfs/rule-catalog.mjs +8 -4
  20. package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
  21. package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
  22. package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
  23. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
  24. package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
  25. package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
  26. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
  27. package/runtime/scripts/hfs/rules/doc-fresh.mjs +19 -4
  28. package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
  29. package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
  30. package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
  31. package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
  32. package/runtime/scripts/hfs/rules/frontend-tree.mjs +1 -1
  33. package/runtime/scripts/hfs/rules/release-provenance.mjs +9 -6
  34. package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
  35. package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
  36. package/runtime/scripts/hfs/rules/stacks-release.mjs +1 -1
  37. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +6 -6
  38. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -3
  39. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  40. package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
  41. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +1 -1
  42. package/runtime/scripts/hfs/sealed-members.mjs +1 -1
  43. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +2 -2
  44. package/runtime/scripts/hfs/secret-files.mjs +1 -1
  45. package/runtime/scripts/hfs/secret-sops.mjs +1 -1
  46. package/runtime/scripts/hfs/stack-deploy-config.mjs +5 -2
  47. package/runtime/scripts/lib/env.mjs +2 -1
  48. package/runtime/scripts/lib/path-key.mjs +3 -1
  49. package/runtime/scripts/lib/roots.mjs +3 -3
  50. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  51. package/runtime/scripts/lib/swarm-stack.mjs +4 -2
  52. package/runtime/scripts/lib/ts-analysis.mjs +8 -4
  53. package/sync/ci-commands.mjs +275 -275
  54. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +2 -0
@@ -0,0 +1,52 @@
1
+ // lint-report.mjs - the external issues report the release gate's sonar row produces before it runs the scanner.
2
+ // The app declares `sonar.externalIssuesReportPaths=reports/lint.sonar.json`; the managed CI makes it with `lint --side <side> --sonar reports/lint.<side>.sonar.json`
3
+ // per lint matrix side and a "merge Sonar lint reports" step. This module does the same locally, through the same process seam as every gate
4
+ // (an argument array through ctx.spawn, never a shell). The merge below is the one in packages/hfs/sync/ci-commands.mjs (sonarMergeSteps): keep them identical.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { firstLine, tool } from '../stack/context.mjs';
8
+
9
+ const LINT_SIDES = ['be', 'fe'];
10
+ const MERGED_REPORT = 'reports/lint.sonar.json';
11
+ const sideReport = (side) => `reports/lint.${side}.sonar.json`;
12
+
13
+ /** CI merge: rules unique by id (last one wins, first position kept), issues concatenated in side order. */
14
+ function mergeSonarReports(reports) {
15
+ const rules = [...new Map(reports.flatMap((report) => report.rules).map((rule) => [rule.id, rule])).values()];
16
+ const issues = reports.flatMap((report) => report.issues);
17
+ return { rules, issues };
18
+ }
19
+
20
+ /** The sides the app has: a be/ or fe/ folder beside the app root. */
21
+ const sidesOf = (root) => LINT_SIDES.filter((side) => fs.existsSync(path.join(root, side)));
22
+
23
+ /**
24
+ * Runs `starci app lint --side <side> --sonar <file>` for every side the app has, then writes reports/lint.sonar.json.
25
+ * `launch` turns an argv into [command, args] for the platform. Returns { pass, text }: the honest log of every lint run and the verdict.
26
+ * A lint that exits non-zero (findings or error) or writes no report fails the row; nothing is skipped.
27
+ */
28
+ export function produceLintReports(ctx, { launch, platform, env }) {
29
+ const sides = sidesOf(ctx.cwd);
30
+ const out = [];
31
+ if (sides.length === 0) return { pass: false, text: `no be/ or fe/ side in ${ctx.cwd}: cannot produce ${MERGED_REPORT}\n` };
32
+ fs.mkdirSync(path.join(ctx.cwd, 'reports'), { recursive: true });
33
+ for (const side of sides) {
34
+ const file = path.join(ctx.cwd, sideReport(side));
35
+ fs.rmSync(file, { force: true });
36
+ const argv = ['npx', 'starci', 'app', 'lint', '--side', side, '--sonar', sideReport(side)];
37
+ const [command, args] = launch(argv, platform);
38
+ out.push(`$ ${argv.join(' ')}\n`);
39
+ const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
40
+ out.push(`${run.stdout ?? ''}${run.stderr ?? ''}`);
41
+ if (run.error) return { pass: false, text: `${out.join('')}\nlint ${side} cannot run (${run.error.message})\n` };
42
+ if (run.status !== 0) return { pass: false, text: `${out.join('')}\nlint ${side} failed: exit ${run.status}; the scanner was not run\n` };
43
+ if (!fs.existsSync(file)) return { pass: false, text: `${out.join('')}\nlint ${side} wrote no ${sideReport(side)}; the scanner was not run\n` };
44
+ }
45
+ try {
46
+ const reports = sides.map((side) => JSON.parse(fs.readFileSync(path.join(ctx.cwd, sideReport(side)), 'utf8')));
47
+ fs.writeFileSync(path.join(ctx.cwd, MERGED_REPORT), `${JSON.stringify(mergeSonarReports(reports), null, 2)}\n`);
48
+ } catch (error) {
49
+ return { pass: false, text: `${out.join('')}\ncannot merge the side reports: ${firstLine(error.message)}\n` };
50
+ }
51
+ return { pass: true, text: `${out.join('')}\nwrote ${MERGED_REPORT} from ${sides.map(sideReport).join(', ')}\n\n` };
52
+ }
@@ -0,0 +1,16 @@
1
+ // digest.mjs — the one SHA-256 the runtime hashes with. A leaf: every file's integrity digest,
2
+ // every ledger input ref and every stamped asset is this function, so a byte stream and a file
3
+ // never disagree about what their digest is.
4
+ import { createHash } from 'node:crypto';
5
+ import fs from 'node:fs';
6
+
7
+ /** The lowercase hex SHA-256 of `value` (string, Buffer or TypedArray). */
8
+ export const sha256 = (value) => createHash('sha256').update(value).digest('hex');
9
+ /** The SHA-256 of the file's exact bytes; throws when the file cannot be read. */
10
+ export function sha256File(file){
11
+ // A numeric descriptor belongs to the caller; positional reads preserve its offset.
12
+ const owned=typeof file!=='number',fd=owned?fs.openSync(file,'r'):file;
13
+ const hash=createHash('sha256'),buffer=Buffer.alloc(1024*1024);
14
+ try{let at=0,n;while((n=fs.readSync(fd,buffer,0,buffer.length,at))>0){hash.update(buffer.subarray(0,n));at+=n;}return hash.digest('hex');}
15
+ finally{if(owned)fs.closeSync(fd);}
16
+ }
@@ -29,14 +29,14 @@ pins:
29
29
  source: packages/grammar/package.json
30
30
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
31
31
  '@starci/eslint-canon-be':
32
- version: 3.4.17
32
+ version: 3.4.20
33
33
  group: starci
34
34
  install: registry
35
35
  side: be
36
36
  source: packages/eslint/be/package.json
37
37
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
38
38
  '@starci/eslint-canon-fe':
39
- version: 8.4.17
39
+ version: 8.4.19
40
40
  group: starci
41
41
  install: registry
42
42
  side: fe
@@ -76,14 +76,14 @@ pins:
76
76
  source: packages/test-world/package.json
77
77
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
78
78
  '@starci/cli':
79
- version: 1.7.11
79
+ version: 1.7.13
80
80
  group: starci
81
81
  install: registry
82
82
  side: both
83
83
  source: packages/cli/package.json
84
84
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
85
85
  '@starci/hfs':
86
- version: 4.7.11
86
+ version: 4.7.13
87
87
  group: starci
88
88
  install: registry
89
89
  side: both
@@ -27,7 +27,7 @@ defaults:
27
27
  # The steps that need a raised Node heap on the GitHub runner, matched on their `run` text, and the least heap they must declare in NODE_OPTIONS.
28
28
  heap:
29
29
  minMiB: 6144
30
- steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from']
30
+ steps: ['npm run typecheck', 'npm run lint', 'app lint', 'app check --coverage-from', 'npm run db:lint']
31
31
 
32
32
  items:
33
33
  - id: candidate-trigger
@@ -1,7 +1,7 @@
1
1
  // GENERATED by scripts/cli/gen-catalog.mjs — do not edit
2
2
  export const CATALOG = {
3
3
  "schema": "starci/cli-catalog@1",
4
- "hash": "c357c5a34782ec5039dba046ba98d9d3342881fb600501dd1b9d86bad5fb2cd2",
4
+ "hash": "8913c2eedad972f96410ed412622ca511f32f9a4f2b5fb8104b745e310007ac5",
5
5
  "global": [
6
6
  {
7
7
  "name": "json",
@@ -10894,7 +10894,8 @@ export const CATALOG = {
10894
10894
  "starci supabase start --cwd <app>"
10895
10895
  ],
10896
10896
  "editions": [
10897
- "full"
10897
+ "full",
10898
+ "lite"
10898
10899
  ],
10899
10900
  "impl": {
10900
10901
  "module": "scripts/machine/supabase-stack.mjs",
@@ -10931,7 +10932,8 @@ export const CATALOG = {
10931
10932
  "starci supabase status --cwd <app> --json"
10932
10933
  ],
10933
10934
  "editions": [
10934
- "full"
10935
+ "full",
10936
+ "lite"
10935
10937
  ],
10936
10938
  "impl": {
10937
10939
  "module": "scripts/machine/supabase-stack.mjs",
@@ -10973,7 +10975,8 @@ export const CATALOG = {
10973
10975
  "starci supabase stop --cwd <app> --no-backup"
10974
10976
  ],
10975
10977
  "editions": [
10976
- "full"
10978
+ "full",
10979
+ "lite"
10977
10980
  ],
10978
10981
  "impl": {
10979
10982
  "module": "scripts/machine/supabase-stack.mjs",
@@ -15,6 +15,7 @@ import { realpathOr } from '../../lib/fs-kind.mjs';
15
15
  export function isLinkLike(p, { parentReal = null, stat = null } = {}) {
16
16
  let st = stat;
17
17
  if (!st) { try { st = fs.lstatSync(p); } catch { return false; } }
18
+ if (!st) return false;
18
19
  if (st.isSymbolicLink()) return true;
19
20
  if (!st.isDirectory()) return false;
20
21
  try { fs.readlinkSync(p); return true; } catch { /* not a link readlink can read */ }
@@ -104,7 +104,11 @@ export const SELECTED_AGE_VERSIONS = new Set(['1.2.1', '1.3.1']);
104
104
  const IDENTITY_TIMEOUT_MS = 20_000;
105
105
  const CONTROLLED_ENV = new Set(['SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE', 'SOPS_AGE_KEY_CMD', 'SOPS_AGE_SSH_PRIVATE_KEY_FILE', 'SOPS_AGE_SSH_PRIVATE_KEY_CMD', 'SOPS_AGE_RECIPIENT', 'SOPS_KEYSERVICE', 'SOPS_ENABLE_LOCAL_KEYSERVICE', 'SOPS_DECRYPTION_ORDER', 'SOPS_KMS_ARN', 'SOPS_GCP_KMS_IDS', 'SOPS_HUAWEICLOUD_KMS_IDS', 'SOPS_AZURE_KEYVAULT_URLS', 'SOPS_VAULT_URIS', 'SOPS_PGP_FP', 'SOPS_AGE_RECIPIENTS', 'HOME', 'USERPROFILE', 'APPDATA', 'XDG_CONFIG_HOME']);
106
106
 
107
- /** Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths. */
107
+ /**
108
+ * Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths.
109
+ * @param {object} env The admitted caller environment.
110
+ * @param {string|null} inlineName Selected inline identity name, or null to omit every identity.
111
+ */
108
112
  function isolatedSopsEnv(env, inlineName = 'SOPS_AGE_KEY') {
109
113
  const out = Object.fromEntries(Object.entries(env).filter(([name]) => !CONTROLLED_ENV.has(name.toUpperCase())));
110
114
  out.HOME = ''; out.USERPROFILE = ''; out.APPDATA = ''; out.XDG_CONFIG_HOME = '';
@@ -106,7 +106,7 @@ function queueMethods(env, declaration, constructor, excluded) {
106
106
  && decoratorsHave(env, parameter, 'InjectQueueOutbox', '@modules/platform/queue'));
107
107
  if (!outbox || !env.ts.isIdentifier(outbox.name)) return;
108
108
  for (const method of declaration.members) {
109
- if (!env.ts.isMethodDeclaration(method) || !method.body || method.body.statements.length !== 1) continue;
109
+ if (!env.ts.isMethodDeclaration(method) || method.body?.statements.length !== 1) continue;
110
110
  const call = statementCall(env.ts, method.body.statements[0]);
111
111
  if (call && env.ts.isCallExpression(call) && receiverMember(env.ts, call) === outbox.name.text && call.expression.name.text === 'write'
112
112
  && call.arguments.length === 3 && call.arguments.every(argument => mapping(env, argument))) excluded.add(method);
@@ -62,11 +62,13 @@ function catalogsPerApp(config, graph) {
62
62
 
63
63
  /** Each interpolation can fill part or all of a segment; the template's static fragments keep their literal spelling. */
64
64
  function templatePatternOf(node) {
65
- return [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.').map(segment => {
66
- if (!segment.includes('\u0000')) return segment;
65
+ const pattern = [];
66
+ for (const segment of [node.head.text, ...node.templateSpans.map(span => span.literal.text)].join('\u0000').split('.')) {
67
+ if (!segment.includes('\u0000')) { pattern.push(segment); continue; }
67
68
  const parts = segment.split('\u0000');
68
- return parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map(escapeRegExp).join('.*')}$`, 'su');
69
- });
69
+ pattern.push(parts.every(part => part === '') ? '*' : new RegExp(`^${parts.map((part) => escapeRegExp(part)).join('.*')}$`, 'su'));
70
+ }
71
+ return pattern;
70
72
  }
71
73
 
72
74
  function patternsOf(ts, files) {
@@ -72,8 +72,7 @@ function stronglyConnected(adjacency) {
72
72
  for (const start of adjacency.keys()) {
73
73
  if (state.index.has(start)) continue;
74
74
  startTraversal(state, adjacency, start);
75
- while (state.work.length) {
76
- const frame = state.work.at(-1);
75
+ for (let frame = state.work.at(-1); frame; frame = state.work.at(-1)) {
77
76
  const next = frame.iterator.next();
78
77
  if (next.done) {
79
78
  finishFrame(state, frame);
@@ -94,7 +94,7 @@ function moduleSpecifierOf(ts, node) {
94
94
  function isApolloClientConstruction(ts, kit, checker, node) {
95
95
  if (!ts.isNewExpression(node)) return false;
96
96
  const binding = kit.importBinding(checker, node.expression);
97
- return Boolean(binding && binding.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
97
+ return Boolean(binding?.name === 'ApolloClient' && binding.module.startsWith('@apollo/client'));
98
98
  }
99
99
 
100
100
  function inspectLibraryImport({ ts, file, node, transport, report }) {
@@ -60,7 +60,10 @@ export function qualityServices(doc, { example = false } = {}) {
60
60
  /** Enumerate a missing directory as empty; all filesystem access uses the caller's injected synchronous seam. */
61
61
  function entries(dir, fs) {
62
62
  try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
63
- catch (error) { if (error.code === 'ENOENT') return []; throw error; }
63
+ catch (error) {
64
+ if (error.code === 'ENOENT') return [];
65
+ throw error;
66
+ }
64
67
  }
65
68
 
66
69
  /** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
@@ -70,7 +73,7 @@ export function sealedKeyNames(root, { fs }) {
70
73
  for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
71
74
  const file = `.starcistacks/${env.name}/secrets/${member.name}`;
72
75
  const source = fs.readFileSync(path.join(root, file), 'utf8');
73
- const dotenv = [...source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
76
+ const dotenv = [...source.matchAll(/^([A-Za-z_]\w*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
74
77
  let keys = dotenv;
75
78
  if (!dotenv.length) {
76
79
  const doc = parseYaml(source);
@@ -87,7 +87,7 @@ function baselinePlan(root, opts, now) {
87
87
  if (!active.length) throw new DbVerbError('baseline requires an existing active migration chain');
88
88
  const archived = dbSqlFiles(root, MIGRATIONS_ARCHIVE_DIR);
89
89
  const oldStamps = [...active, ...archived].map((entry) => entry.stamp);
90
- const last = oldStamps.sort((left, right) => left.localeCompare(right)).at(-1);
90
+ const last = oldStamps.toSorted((left, right) => left.localeCompare(right)).at(-1);
91
91
  const stamp = opts.stamp ?? now().toISOString().replace(/\D/g, '').slice(0, 14);
92
92
  if (!/^\d{14}$/.test(stamp) || migrationStamp(stamp) === null || stamp <= last) throw new DbVerbError('--stamp must be a valid UTC version after the archive boundary');
93
93
  const file = `${MIGRATIONS_DIR}/${stamp}_baseline.sql`;
@@ -225,7 +225,10 @@ function extensionDeclaration(stmt, sql, extensions) {
225
225
 
226
226
  function baselineStatement(state, stmt, sql) {
227
227
  if (roleDeclaration(stmt, sql, state.roles) || extensionDeclaration(stmt, sql, state.extensions)) return;
228
- if (stmt.DropRoleStmt) { for (const role of stmt.DropRoleStmt.roles ?? []) state.roles.delete(role.RoleSpec?.rolename); return; }
228
+ if (stmt.DropRoleStmt) {
229
+ for (const role of stmt.DropRoleStmt.roles ?? []) state.roles.delete(role.RoleSpec?.rolename);
230
+ return;
231
+ }
229
232
  if (stmt.GrantRoleStmt || stmt.AlterExtensionStmt || stmt.AlterExtensionContentsStmt || /^OBJECT_ROLE$/.test(stmt.RenameStmt?.renameType ?? '')) throw new DbVerbError('baseline cannot reconstruct role membership, role renames or extension alterations');
230
233
  if (dataMutation(stmt) || sourceExecution(stmt)) throw new DbVerbError('baseline cannot reconstruct migration-owned data changes or custom execution from a schema-only dump');
231
234
  const node = Object.values(stmt)[0];
@@ -1,7 +1,7 @@
1
1
  // app db plan: compare migration-owned state without fetching commands or Vault values.
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
- import { createHash } from 'node:crypto';
4
+ import { sha256 } from '../../engine/digest.mjs';
5
5
  import pg from 'libpg-query';
6
6
  import { parse as parseToml } from 'smol-toml';
7
7
  import { invocationDir } from '../lib/roots.mjs';
@@ -19,12 +19,11 @@ const PRESENCE_SQL = `select
19
19
  pg_catalog.to_regclass('vault.secrets') is not null as vault;`;
20
20
  const INVENTORIES = {
21
21
  migrations: 'select version from supabase_migrations.schema_migrations order by version;',
22
- cron: 'select jobname as name, schedule, pg_catalog.md5(command) as "commandHash", active from cron.job order by jobid;',
22
+ cron: 'select jobname as name, schedule, pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to(command, \'UTF8\')), \'hex\') as "commandHash", active from cron.job order by jobid;',
23
23
  queues: 'select queue_name as name from pgmq.meta order by queue_name;',
24
24
  vault: 'select name from vault.secrets order by name;',
25
25
  };
26
26
  const names = (nodes) => sqlStringParts(nodes).join('.');
27
- const hash = (command) => createHash('md5').update(command).digest('hex');
28
27
  const OWNERSHIP_CALLS = new Set(['pgmq.create', 'pgmq.drop_queue', 'cron.schedule', 'cron.unschedule', 'cron.schedule_in_database', 'cron.alter_job']);
29
28
 
30
29
  const parseSql = sqlParser(pg, () => { throw new DbVerbError('migration SQL could not be inventoried safely'); });
@@ -45,7 +44,7 @@ function inventoryCall(state, call) {
45
44
  const job = requiredLiteral(call, 0, ['job_name']);
46
45
  const schedule = requiredLiteral(call, 1, ['schedule']);
47
46
  const command = requiredLiteral(call, 2, ['command']);
48
- state.cron.set(job, { schedule, commandHash: hash(command), active: true });
47
+ state.cron.set(job, { schedule, commandHash: sha256(command), active: true });
49
48
  } else if (name === 'cron.unschedule') {
50
49
  state.cron.delete(requiredLiteral(call, 0, ['job_name']));
51
50
  } else if (name === 'cron.schedule_in_database' || name === 'cron.alter_job') {
@@ -224,7 +223,7 @@ function compare(expected, actual, kind) {
224
223
  for (const row of actual) {
225
224
  const key = kind === 'migrations' ? row.version : row.name;
226
225
  if (key !== null && (typeof key !== 'string' || key.length === 0)) throw new DbVerbError('Supabase returned an invalid inventory identifier');
227
- if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{32}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('Supabase returned invalid cron metadata');
226
+ if (kind === 'cron' && (typeof row.schedule !== 'string' || !/^[a-f0-9]{64}$/.test(row.commandHash ?? '') || typeof row.active !== 'boolean')) throw new DbVerbError('Supabase returned invalid cron metadata');
228
227
  if (!unmatched.delete(key)) unexpected += 1;
229
228
  else if (kind === 'cron') {
230
229
  const desired = expected.get(key);
@@ -29,7 +29,8 @@ const inner = (t) => t.replace(/^<|>$/g, '');
29
29
 
30
30
  // ---- extraction -----------------------------------------------------------------------------------------------
31
31
 
32
- const COMMAND_AT = /(?<![\w@./:-])(?:npx\s+starci|npm\s+run\s+starci(?:\s+--silent)?(?:\s+--)?|starci)(?![\w@./-])(?=\s|$)/g;
32
+ const COMMAND_FORM = /npx\s+starci|npm\s+run\s+starci(?:\s+--silent)?(?:\s+--)?|starci/;
33
+ const COMMAND_AT = new RegExp(String.raw`(?<![\w@./:-])(?:${COMMAND_FORM.source})(?![\w@./-])(?=\s|$)`, 'g');
33
34
  const FENCE = /^\s*(?:[-*>|]\s*)?```/;
34
35
  const SCRIPT_COMMENT = /^\s*(?:\/\/|\*|\/\*)/;
35
36
  const CONTINUED = /\\\s*$/;
@@ -4,6 +4,7 @@ import path from 'node:path';
4
4
  import { skillRoot } from '../../engine/runtime-root.mjs';
5
5
  import { parseYamlCached } from '../lib/yaml-cached.mjs';
6
6
  import { jsonClone } from '../lib/json-clone.mjs';
7
+ import { byCodeUnit } from '../lib/list.mjs';
7
8
  import { isPlainObject } from '../../engine/plain-object.mjs';
8
9
  import { SEMVER } from './manifest-shape.mjs';
9
10
  import { enforcerJudgedInEdition, judgedInEdition, ruleEditionProblems } from './edition-slots.mjs';
@@ -20,7 +21,10 @@ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'fail
20
21
  const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
21
22
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
22
23
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
23
- const CASE_REFERENCE = /^(?:case-[1-9][0-9]*|(?:ruling:)?R[0-9]{2,3}(?:\/case-[1-9][0-9]*)?|owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*)$/;
24
+ const CASE_ID = /case-[1-9]\d*/;
25
+ const RULING_REFERENCE = new RegExp(String.raw`(?:ruling:)?R\d{2,3}(?:/${CASE_ID.source})?`);
26
+ const OWNER_REFERENCE = /owner:[a-z][a-z0-9]*(?:-[a-z0-9]+)*/;
27
+ const CASE_REFERENCE = new RegExp(`^(?:${CASE_ID.source}|${RULING_REFERENCE.source}|${OWNER_REFERENCE.source})$`);
24
28
  const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
25
29
  const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
26
30
  const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
@@ -39,7 +43,7 @@ function relativePathProblem(value, label, bad, anchor = false) {
39
43
  const parts = typeof value === 'string' ? value.split('#') : [];
40
44
  const file = parts[0];
41
45
  const valid = typeof value === 'string' && value.trim() === value && value.length > 0
42
- && !/[\r\n\u0000\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
46
+ && !value.includes('\0') && !/[\r\n\\]/.test(value) && !/^(?:\/|[a-z][a-z0-9+.-]*:)/i.test(value) && !file.includes(':')
43
47
  && !file.split('/').some((part) => ['', '.', '..'].includes(part))
44
48
  && (parts.length === 1 || (anchor && parts.length === 2 && /^[A-Za-z0-9][A-Za-z0-9_.:-]*$/.test(parts[1])));
45
49
  if (!valid) bad.push(`${label} must be a repository-relative path${anchor ? ' with an optional #anchor' : ''}`);
@@ -51,7 +55,7 @@ function unknownKeys(item, allowed, label, bad) {
51
55
 
52
56
  function nonEmptyList(value, label, bad) {
53
57
  if (!Array.isArray(value) || !value.length) { bad.push(`${label} must be a non-empty list`); return false; }
54
- const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort().map((key) => [key, item[key]])) : item));
58
+ const normalized = value.map((value) => JSON.stringify(value, (_key, item) => isPlainObject(item) ? Object.fromEntries(Object.keys(item).sort(byCodeUnit).map((key) => [key, item[key]])) : item));
55
59
  if (new Set(normalized).size !== value.length) bad.push(`${label} repeats a value`);
56
60
  return true;
57
61
  }
@@ -190,7 +194,7 @@ function checkRuleCases(rule, label, bad) {
190
194
  const at = `${label}.cases[${index}]`;
191
195
  if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
192
196
  for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
193
- if (typeof item.id !== 'string' || !/^case-[1-9][0-9]*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
197
+ if (typeof item.id !== 'string' || !/^case-[1-9]\d*$/.test(item.id) || ids.has(item.id)) bad.push(`${at}.id must be a unique case-N`);
194
198
  ids.add(item.id);
195
199
  if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
196
200
  if (item.source !== 'owner') bad.push(`${at}.source must be owner`);
@@ -12,7 +12,6 @@ import { found } from './read.mjs';
12
12
  export const HFS_COVERAGE_BELOW = 'HFS_COVERAGE_BELOW';
13
13
  const METRICS = ['lines', 'functions', 'branches'];
14
14
  const SUMMARY = { LF: ['lines', 'found'], LH: ['lines', 'hit'], FNF: ['functions', 'found'], FNH: ['functions', 'hit'], BRF: ['branches', 'found'], BRH: ['branches', 'hit'] };
15
- const SOURCE = /\.ts$/;
16
15
  const SPEC = /(?:\.(?:spec|test)|-spec)\.ts$/;
17
16
  const countOf = (value) => /^\d+$/.test(value) && Number.isSafeInteger(Number(value)) ? Number(value) : null;
18
17
  const slash = (value) => path.posix.normalize(String(value).replaceAll('\\', '/'));
@@ -66,7 +65,10 @@ function parseLcov(text) {
66
65
  const records = [];
67
66
  const problems = [];
68
67
  let current = null;
69
- const flush = () => { if (current) records.push(current); current = null; };
68
+ const flush = () => {
69
+ if (current) records.push(current);
70
+ current = null;
71
+ };
70
72
  for (const [index, raw] of String(text).replace(/^\uFEFF/, '').split(/\r?\n/).entries()) {
71
73
  const line = raw.trim();
72
74
  if (!line) continue;
@@ -102,7 +104,7 @@ function inventory({ files, all, repo, manifest }) {
102
104
  const scope = coverageScope(effective, repo.providers ?? repo.sides?.be?.providers ?? []);
103
105
  const roots = scope.roots.map((dir) => globExpression(`${dir}**`));
104
106
  const excludes = scope.excludes.map((dir) => globExpression(`${dir}**`));
105
- const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && SOURCE.test(file) && !SPEC.test(file)).sort(byCodeUnit);
107
+ const candidates = [...new Set((all ?? files ?? []).map(slash))].filter((file) => file.startsWith(prefix) && file.endsWith('.ts') && !SPEC.test(file)).sort(byCodeUnit);
106
108
  const measured = [];
107
109
  const unmeasured = [];
108
110
  for (const file of candidates) {
@@ -74,7 +74,7 @@ async function principalHelpers(analyses) {
74
74
  try { bodies.push({ name: fn.name, ast: await parseSql(body) }); } catch { /* PL/pgSQL safety is checked separately. */ }
75
75
  });
76
76
  const helpers = new Set();
77
- for (let pass = 0; pass < bodies.length; pass += 1) {
77
+ for (let remaining = bodies.length; remaining > 0; remaining -= 1) {
78
78
  for (const body of bodies) if (hasPrincipal(body.ast, helpers)) helpers.add(body.name);
79
79
  }
80
80
  return helpers;
@@ -13,7 +13,10 @@ import { fileURLToPath } from 'node:url';
13
13
  import { loadTypescript } from '../../lib/package-at.mjs';
14
14
 
15
15
  const HERE = path.dirname(fileURLToPath(import.meta.url));
16
- const MIGRATION_NAME = /^(\d{14})_([a-z0-9]+(?:-[a-z0-9]+)*|contract_[a-z0-9]+(?:[-_][a-z0-9]+)*|[a-z0-9]+(?:-[a-z0-9]+)*_contract_[a-z0-9]+(?:[-_][a-z0-9]+)*)\.sql$/;
16
+ const MIGRATION_PLAIN = /[a-z0-9]+(?:-[a-z0-9]+)*/;
17
+ const MIGRATION_CONTRACT_TAIL = /contract_[a-z0-9]+(?:[-_][a-z0-9]+)*/;
18
+ const MIGRATION_SLUG = [MIGRATION_PLAIN.source, MIGRATION_CONTRACT_TAIL.source, `${MIGRATION_PLAIN.source}_${MIGRATION_CONTRACT_TAIL.source}`].join('|');
19
+ const MIGRATION_NAME = new RegExp(String.raw`^(\d{14})_(${MIGRATION_SLUG})\.sql$`);
17
20
  export const isMigrationName = (name) => MIGRATION_NAME.test(name);
18
21
  const RESTAMP_HEADER = /^-- starci:restamp (\d{14}) (\d{14})(?:\r?\n|$)/;
19
22
  const withoutRestampHeader = (text) => text?.replace(RESTAMP_HEADER, '') ?? null;
@@ -227,7 +230,11 @@ export function databaseSqlTokens(text) {
227
230
  let index = 0;
228
231
  let line = 1;
229
232
  while (index < text.length) {
230
- if (/\s/.test(text[index])) { if (text[index] === '\n') line += 1; index += 1; continue; }
233
+ if (/\s/.test(text[index])) {
234
+ if (text[index] === '\n') line += 1;
235
+ index += 1;
236
+ continue;
237
+ }
231
238
  const end = commentEnd(text, index);
232
239
  if (end !== null) { line += text.slice(index, end).split('\n').length - 1; index = end; continue; }
233
240
  const token = sqlTokenAt(text, index);
@@ -243,7 +250,10 @@ export function databaseSqlStatements(tokens) {
243
250
  const statements = [];
244
251
  let current = [];
245
252
  for (const token of tokens) {
246
- if (token.t === 'punct' && token.v === ';') { if (current.length) statements.push(current); current = []; }
253
+ if (token.t === 'punct' && token.v === ';') {
254
+ if (current.length) statements.push(current);
255
+ current = [];
256
+ }
247
257
  else current.push(token);
248
258
  }
249
259
  if (current.length) statements.push(current);
@@ -290,7 +290,7 @@ async function functionFindings(file, fn, facts, exposed) {
290
290
 
291
291
  function alteredDefinerFindings(file, alter, facts, exposed) {
292
292
  if (!alter.securityDefiner) return [];
293
- const created = facts.functions.filter((fn) => fn.index < alter.index && matchesAlter(fn, alter)).at(-1);
293
+ const created = facts.functions.findLast((fn) => fn.index < alter.index && matchesAlter(fn, alter));
294
294
  const node = created?.node ?? { func: alter.node.func };
295
295
  const fn = { ...alter, node, altered: true, originIndex: created?.index ?? -1 };
296
296
  return definerDeclarationFindings(file, fn, facts, exposed, true);
@@ -104,7 +104,7 @@ function inspectDo(state, node, file) {
104
104
  const option = (key) => (node.args ?? []).find((item) => item.DefElem?.defname === key)?.DefElem?.arg?.String?.sval;
105
105
  const tree = plpgsqlTree(option('as') ?? '');
106
106
  walk(tree, (item) => {
107
- if (item.PLpgSQL_expr && item.PLpgSQL_expr.parseMode === 0) inspectCalls(state, sqlTree(item.PLpgSQL_expr.query), file, true);
107
+ if (item.PLpgSQL_expr?.parseMode === 0) inspectCalls(state, sqlTree(item.PLpgSQL_expr.query), file, true);
108
108
  if (item.PLpgSQL_stmt_dynexecute) {
109
109
  const expression = sqlTree(`select ${item.PLpgSQL_stmt_dynexecute.query?.PLpgSQL_expr?.query ?? ''}`);
110
110
  dynamicOwnership(state, expression, file, true);
@@ -128,7 +128,7 @@ function grantCheck(state, node, file) {
128
128
  function recordGrant(state, node, file, executed) {
129
129
  state.grants.push({ node, file, created: new Map(state.created), executed });
130
130
  const grant = node.GrantStmt;
131
- if (!executed || !grant || grant.objtype !== 'OBJECT_TABLE' || grant.targtype !== 'ACL_TARGET_OBJECT') return;
131
+ if (!executed || grant?.objtype !== 'OBJECT_TABLE' || grant.targtype !== 'ACL_TARGET_OBJECT') return;
132
132
  if (!(grant.grantees ?? []).some((item) => item.RoleSpec?.rolename === state.appRole)) return;
133
133
  const privileges = grant.privileges?.map((item) => item.AccessPriv?.priv_name).filter(Boolean) ?? TABLE_PRIVILEGES;
134
134
  for (const object of grant.objects ?? []) {
@@ -8,17 +8,61 @@ const DEPLOY = /(?:^|\/)deploy\//;
8
8
  const SCRIPT = /(?:^|\/)scripts\//;
9
9
  const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml$/;
10
10
  const DOCKERFILE = /(?:^|\/)Dockerfile(?:\.[^/]*)?$/;
11
- const CREDENTIAL_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/)|\.enc$/i;
12
- const HOST_CRON = /\b(?:crontab|anacron|crond|supercronic)\b|\/etc\/cron(?:\b|[./])|\bcron\.d\b|\/var\/spool\/cron\b|@(?:reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)\b|\b[\w.@-]+\.timer\b|\bOn(?:Calendar|BootSec|UnitActiveSec|ActiveSec)\s*=|(?:^|[\s;|&"'])at\s+(?:(?:-[a-z]+\s+)*)((?:-[ft]\b)|now\b|tomorrow\b|\d{1,2}:\d{2}\b|midnight\b|noon\b)/im;
11
+ const CREDENTIAL_FILE_PARTS = [/(?:^|\/)secrets?\.env(?:\.[^/]*)?/, /(?:^|\/)\.env(?:\.[^/]*)?/, /(?:^|\/)secrets\//, /\.enc$/];
12
+ const CREDENTIAL_FILE = new RegExp(CREDENTIAL_FILE_PARTS.map((part) => part.source).join('|'), 'i');
13
+ const AT_JOB_ARGUMENT = /-[ft]\b|now\b|tomorrow\b|\d{1,2}:\d{2}\b|midnight\b|noon\b/.source;
14
+ const HOST_CRON_PARTS = [
15
+ /\b(?:crontab|anacron|crond|supercronic)\b/,
16
+ /\/etc\/cron(?:\b|[./])/,
17
+ /\bcron\.d\b/,
18
+ /\/var\/spool\/cron\b/,
19
+ /@(?:reboot|yearly|annually|monthly|weekly|daily|midnight|hourly)\b/,
20
+ /\b[\w.@-]+\.timer\b/,
21
+ /\bOn(?:Calendar|BootSec|UnitActiveSec|ActiveSec)\s*=/,
22
+ new RegExp(String.raw`(?:^|[\s;|&"'])at\s+(?:-[a-z]+\s+)*(?:${AT_JOB_ARGUMENT})`),
23
+ ];
24
+ const HOST_CRON = new RegExp(HOST_CRON_PARTS.map((part) => part.source).join('|'), 'im');
13
25
  const HOST_CRON_PATH = /(?:^|\/)(?:crontab(?:$|\/)|cron\.d(?:\/|$)|[^/]+\.(?:timer|crontab)$)/;
14
26
  const CRON_NUMERIC_FIELD = /^[\d*,/-]+$/;
15
27
  const CRON_NAMED_FIELD = /^[\w*,/-]+$/;
16
- const CRON_CONTAINER = /\bimage\s*:\s*["']?(?:[^\s"']*\/)?(?:cron|crond|supercronic|ofelia)(?=[:@\s"']|$)|\b(?:command|entrypoint)\s*:[^\n]*\b(?:cron|crond|supercronic)\b/i;
17
- const SLEEP_LOOP = /\b(?:while\s+(?:true|:|\[\s*1\s*\])|for\s*\(\(\s*;\s*;\s*\)\))\s*[;\n][\s\S]{0,4000}?\bsleep\s+/i;
28
+ const CRON_CONTAINER_PARTS = [
29
+ /\bimage\s*:\s*["']?(?:[^\s"']*\/)?(?:cron|crond|supercronic|ofelia)(?=[:@\s"']|$)/,
30
+ /\b(?:command|entrypoint)\s*:[^\n]*\b(?:cron|crond|supercronic)\b/,
31
+ ];
32
+ const CRON_CONTAINER = new RegExp(CRON_CONTAINER_PARTS.map((part) => part.source).join('|'), 'i');
33
+ const SLEEP_LOOP_HEAD_PARTS = [/while\s+(?:true|:|\[\s*1\s*\])/, /for\s*\(\(\s*;\s*;\s*\)\)/];
34
+ const SLEEP_LOOP_HEAD = new RegExp(String.raw`\b(?:${SLEEP_LOOP_HEAD_PARTS.map((part) => part.source).join('|')})`, 'gi');
35
+ const SLEEP_LOOP_BREAK = /[^\S\n]*[;\n]/y;
36
+ const SLEEP_LOOP_SLEEP = /\bsleep\s+/gi;
37
+ const SLEEP_LOOP_REACH = 4000;
38
+ /** An endless-loop head, a statement break, then a `sleep` within SLEEP_LOOP_REACH characters: searched in steps so no regex backtracks across the pair. */
39
+ const SLEEP_LOOP = {
40
+ test(text) {
41
+ for (const head of text.matchAll(SLEEP_LOOP_HEAD)) {
42
+ SLEEP_LOOP_BREAK.lastIndex = head.index + head[0].length;
43
+ if (!SLEEP_LOOP_BREAK.test(text)) continue;
44
+ SLEEP_LOOP_SLEEP.lastIndex = SLEEP_LOOP_BREAK.lastIndex;
45
+ const sleep = SLEEP_LOOP_SLEEP.exec(text);
46
+ if (sleep && sleep.index - SLEEP_LOOP_BREAK.lastIndex <= SLEEP_LOOP_REACH) return true;
47
+ }
48
+ return false;
49
+ },
50
+ };
18
51
  const INLINE_NODE = /\bnode\b[^\n;&|]*?\s(?:-(?:e|p|pe)\b|--(?:eval|print)(?:\s|=))/;
19
- const INLINE_SQL_ACTION = /\b(?:insert\s+into|update\s+[\w."']+\s+set|delete\s+from|create\s+(?:table|function|view)|alter\s+table|truncate\s+(?:table\s+)?[\w."'])\b/i;
52
+ const INLINE_SQL_ACTION_PARTS = [
53
+ /insert\s+into/,
54
+ /update\s+[\w."']+\s+set/,
55
+ /delete\s+from/,
56
+ /create\s+(?:table|function|view)/,
57
+ /alter\s+table/,
58
+ /truncate\s+(?:table\s+)?[\w."']/,
59
+ ];
60
+ const INLINE_SQL_ACTION = new RegExp(String.raw`\b(?:${INLINE_SQL_ACTION_PARTS.map((part) => part.source).join('|')})\b`, 'i');
20
61
  const REMOTE_SHELL = /\b(?:curl|wget)\b[^|\n]*\|\s*(?:sudo\s+)?(?:ba|da|z)?sh\b/i;
21
- const COMPOSE_COMMAND = /\bdocker(?:\s+(?:--(?:context|host)(?:\s+|=)\S+|-H\s+\S+))*\s+compose\b[^\n;&|]*|\bdocker-compose\b[^\n;&|]*/g;
62
+ const DOCKER_GLOBAL_OPTION = /(?:--(?:context|host)(?:\s+|=)|-H\s+)\S+/.source;
63
+ const DOCKER_COMPOSE_COMMAND = new RegExp(String.raw`\bdocker(?:\s+${DOCKER_GLOBAL_OPTION})*\s+compose\b[^\n;&|]*`).source;
64
+ const DOCKER_COMPOSE_V1_COMMAND = /\bdocker-compose\b[^\n;&|]*/.source;
65
+ const COMPOSE_COMMAND = new RegExp(`${DOCKER_COMPOSE_COMMAND}|${DOCKER_COMPOSE_V1_COMMAND}`, 'g');
22
66
  const COMPOSE_OPTIONS = new Set(['-f', '--file', '-p', '--project-name', '--project-directory', '--env-file', '--profile', '--parallel', '--ansi', '--progress']);
23
67
  const DEV = /(?:^|[\s/._="'-])(?:dev|development|local)(?=$|[\s/._"'-])/i;
24
68
  const NON_DEV = /(?:^|[\s/._="'-])(?:prod|production|staging|stage|vps|preview)(?=$|[\s/._"'-])/i;
@@ -59,7 +59,17 @@ const validPermissions = (value) => record(value) || value === 'read-all' || val
59
59
  const validConcurrency = (value) => nonempty(value) || (record(value) && nonempty(value.group));
60
60
  const deployName = (value) => !/authorize[\s_.-]+deploy[\s_.-]+key/i.test(value) && /(?:^|[\s/_.-])deploy(?:[\s/_.-]|$)/i.test(value);
61
61
  const materializeCommand = (script) => commands(script).some(({ text }) => /^starci\s+app\s+secret\s+materialize\b/.test(commandBody(text)));
62
- const deploymentCommand = (script) => commands(script).some(({ text }) => /^(?:starci\s+(?:app\s+)?(?:deploy|secret\s+materialize)\b|docker\s+stack\s+deploy\b|(?:ba?sh\s+)?(?:\.\/)?deploy\/|(?:ba?sh\s+)?(?:\.\/)?(?:scripts\/)?[\w.-]*deploy[\w.-]*\.sh\b|npm\s+run\s+deploy(?=[:\s]|$))/.test(commandBody(text)));
62
+ const DEPLOYMENT_COMMANDS = [
63
+ /^starci\s+(?:app\s+)?(?:deploy|secret\s+materialize)\b/,
64
+ /^docker\s+stack\s+deploy\b/,
65
+ /^(?:ba?sh\s+)?(?:\.\/)?deploy\//,
66
+ /^(?:ba?sh\s+)?(?:\.\/)?(?:scripts\/)?[\w.-]*deploy[\w.-]*\.sh\b/,
67
+ /^npm\s+run\s+deploy(?=[:\s]|$)/,
68
+ ];
69
+ const deploymentCommand = (script) => commands(script).some(({ text }) => {
70
+ const body = commandBody(text);
71
+ return DEPLOYMENT_COMMANDS.some((pattern) => pattern.test(body));
72
+ });
63
73
 
64
74
  function hasAgeIdentity(job, doc) {
65
75
  const steps = activeSteps(job);