@starci/hfs 4.7.12 → 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 (44) hide show
  1. package/CHANGELOG.md +5 -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/knowledge/hfs/canon-pins.yaml +4 -4
  6. package/runtime/knowledge/hfs/ci-standard.yaml +1 -1
  7. package/runtime/scripts/hfs/architecture/canon-clones.mjs +1 -1
  8. package/runtime/scripts/hfs/architecture/i18n-keys.mjs +6 -4
  9. package/runtime/scripts/hfs/architecture/transport-owner.mjs +1 -1
  10. package/runtime/scripts/hfs/ci-declaration.mjs +5 -2
  11. package/runtime/scripts/hfs/db-baseline-verb.mjs +5 -2
  12. package/runtime/scripts/hfs/doc-commands.mjs +2 -1
  13. package/runtime/scripts/hfs/rule-catalog.mjs +5 -2
  14. package/runtime/scripts/hfs/rules/coverage-below.mjs +5 -3
  15. package/runtime/scripts/hfs/rules/database-access.mjs +1 -1
  16. package/runtime/scripts/hfs/rules/database-migrations.mjs +13 -3
  17. package/runtime/scripts/hfs/rules/database-plpgsql.mjs +1 -1
  18. package/runtime/scripts/hfs/rules/database-queues.mjs +2 -2
  19. package/runtime/scripts/hfs/rules/deploy-scan.mjs +50 -6
  20. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +11 -1
  21. package/runtime/scripts/hfs/rules/doc-fresh.mjs +18 -3
  22. package/runtime/scripts/hfs/rules/docker-health.mjs +4 -1
  23. package/runtime/scripts/hfs/rules/env-parity.mjs +8 -7
  24. package/runtime/scripts/hfs/rules/env-value-shape.mjs +2 -4
  25. package/runtime/scripts/hfs/rules/fe-build-args.mjs +1 -1
  26. package/runtime/scripts/hfs/rules/release-provenance.mjs +8 -5
  27. package/runtime/scripts/hfs/rules/script-hygiene.mjs +23 -7
  28. package/runtime/scripts/hfs/rules/stack-tooling.mjs +5 -2
  29. package/runtime/scripts/hfs/rules/stacks-swarm.mjs +5 -5
  30. package/runtime/scripts/hfs/rules/supply-chain.mjs +3 -3
  31. package/runtime/scripts/hfs/rules/unused-symbol-properties.mjs +1 -1
  32. package/runtime/scripts/hfs/rules/unused-symbol.mjs +8 -4
  33. package/runtime/scripts/hfs/sealed-members.mjs +1 -1
  34. package/runtime/scripts/hfs/secret-bootstrap-crypto.mjs +1 -1
  35. package/runtime/scripts/hfs/secret-files.mjs +1 -1
  36. package/runtime/scripts/hfs/secret-sops.mjs +1 -1
  37. package/runtime/scripts/hfs/stack-deploy-config.mjs +5 -2
  38. package/runtime/scripts/lib/env.mjs +2 -1
  39. package/runtime/scripts/lib/path-key.mjs +1 -1
  40. package/runtime/scripts/lib/roots.mjs +3 -3
  41. package/runtime/scripts/lib/secret-patterns.mjs +1 -1
  42. package/runtime/scripts/lib/swarm-stack.mjs +4 -2
  43. package/sync/ci-commands.mjs +275 -275
  44. 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
+ }
@@ -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.18
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.18
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.12
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.12
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
@@ -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((part) => escapeRegExp(part)).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) {
@@ -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];
@@ -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*$/;
@@ -21,7 +21,10 @@ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'fail
21
21
  const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
22
22
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
23
23
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
24
- 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})$`);
25
28
  const EVIDENCE_KEYS = new Set(['path', 'edition', 'editions']);
26
29
  const PROOF_KEYS = new Set([...EVIDENCE_KEYS, 'codes', 'fixtures']);
27
30
  const WITNESS_KEYS = new Set(['edition', 'base', 'mutate', 'expect']);
@@ -191,7 +194,7 @@ function checkRuleCases(rule, label, bad) {
191
194
  const at = `${label}.cases[${index}]`;
192
195
  if (!isPlainObject(item)) { bad.push(`${at} is not a map`); return; }
193
196
  for (const key of Object.keys(item)) if (!CASE_KEYS.has(key)) bad.push(`${at} has unknown key ${key}`);
194
- 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`);
195
198
  ids.add(item.id);
196
199
  if (typeof item.date !== 'string' || !CASE_DATE.test(item.date)) bad.push(`${at}.date must be YYYY-MM-DD`);
197
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);
@@ -52,10 +52,15 @@ function resolvedPath(raw, file, tracked, link, generated, roots) {
52
52
  return candidates.some((candidate) => !candidate.startsWith('../') && (trackedPath(candidate, tracked) || generated.has(candidate)));
53
53
  }
54
54
 
55
+ const ENV_NAME = /[A-Z][A-Z0-9_]*/.source;
56
+ const INLINE_LINK = /\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+['"][^)]*)?\)/.source;
57
+ const REFERENCE_LINK = /^\s*\[[^\]]+\]:\s*(?:<([^>]+)>|(\S+))/.source;
58
+ const MARKDOWN_LINK = new RegExp(`${INLINE_LINK}|${REFERENCE_LINK}`, 'g');
59
+
55
60
  function pathReferences(line) {
56
61
  const refs = [];
57
62
  // Markdown inline and reference links, including image links. URLs and anchors are excluded later.
58
- for (const match of line.matchAll(/\]\(\s*(?:<([^>]+)>|([^\s)]+))(?:\s+['"][^)]*)?\)|^\s*\[[^\]]+\]:\s*(?:<([^>]+)>|(\S+))/g)) {
63
+ for (const match of line.matchAll(MARKDOWN_LINK)) {
59
64
  refs.push({ target: match[1] ?? match[2] ?? match[3] ?? match[4], link: true });
60
65
  }
61
66
  const masked = line.replace(/\]\([^)]*\)/g, ']');
@@ -171,9 +176,14 @@ function importedFiles(file, text, tracked) {
171
176
  });
172
177
  }
173
178
 
179
+ const READER_CALL = /\b(\w+)\s*\.\s*\w+\s*\(\s*['"]([A-Z][A-Z0-9_]*)['"]/.source;
180
+ const PROCESS_ENV_DOT = /\bprocess\.env\.([A-Z][A-Z0-9_]*)/.source;
181
+ const PROCESS_ENV_INDEX = /\bprocess\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/.source;
182
+ const ENV_READ = new RegExp(`${READER_CALL}|${PROCESS_ENV_DOT}|${PROCESS_ENV_INDEX}`, 'g');
183
+
174
184
  function addReaderKeys(text, keys) {
175
185
  const readers = new Set(['env', 'source', ...[...text.matchAll(/\b(\w+)\s*:\s*EnvSource\b/g)].map((match) => match[1])]);
176
- for (const match of text.matchAll(/\b(\w+)\s*\.\s*\w+\s*\(\s*['"]([A-Z][A-Z0-9_]*)['"]|\bprocess\.env\.([A-Z][A-Z0-9_]*)|\bprocess\.env\[['"]([A-Z][A-Z0-9_]*)['"]\]/g)) {
186
+ for (const match of text.matchAll(ENV_READ)) {
177
187
  if (match[3] || match[4]) keys.add(match[3] ?? match[4]);
178
188
  else if (readers.has(match[1])) keys.add(match[2]);
179
189
  }
@@ -242,6 +252,11 @@ function unknownEnvironmentName(name, line, keys, codes, tokens, explicit) {
242
252
  return !keys.has(name) && !(name.endsWith('_FILE') && keys.has(name.slice(0, -5)));
243
253
  }
244
254
 
255
+ const QUOTED_ENV_NAME = `\`(${ENV_NAME})\``;
256
+ const REFERENCED_ENV_NAME = String.raw`\b(?:process\.env\.|\$\{?)(${ENV_NAME})`;
257
+ const ASSIGNED_ENV_NAME = String.raw`\b(${ENV_NAME})\s*=`;
258
+ const ENV_NAME_CITATION = new RegExp(`${QUOTED_ENV_NAME}|${REFERENCED_ENV_NAME}|${ASSIGNED_ENV_NAME}`, 'g');
259
+
245
260
  function nameFindings(file, lines, keys, rules, tokens) {
246
261
  const findings = [];
247
262
  const codes = new Set(rules.rules.flatMap((rule) => rule.failureCodes));
@@ -249,7 +264,7 @@ function nameFindings(file, lines, keys, rules, tokens) {
249
264
  for (const match of line.matchAll(/\bR\d+\b/g)) {
250
265
  if (!rules.rule(match[0])) findings.push(found(RULE_UNKNOWN, file, `${file} cites unknown rule ${match[0]}; use an id from the HFS rule catalog`, { line: index + 1, reference: match[0] }));
251
266
  }
252
- for (const match of line.matchAll(/`([A-Z][A-Z0-9_]*)`|\b(?:process\.env\.|\$\{?)([A-Z][A-Z0-9_]*)|\b([A-Z][A-Z0-9_]*)\s*=/g)) {
267
+ for (const match of line.matchAll(ENV_NAME_CITATION)) {
253
268
  const name = match[1] ?? match[2] ?? match[3];
254
269
  if (!unknownEnvironmentName(name, line, keys, codes, tokens, !match[1])) continue;
255
270
  findings.push(found(ENV_UNKNOWN, file, `${file} cites unknown environment key ${name}; declare it in a typed environment reader or KEYS.md, or update the documentation`, { line: index + 1, reference: name }));
@@ -222,7 +222,10 @@ function masksProbeFailure(body) {
222
222
  for (let index = 0; index < body.length; index += 1) {
223
223
  const char = body[index];
224
224
  if (char === '\\') { index += 1; continue; }
225
- if (quote) { if (char === quote) quote = null; continue; }
225
+ if (quote) {
226
+ if (char === quote) quote = null;
227
+ continue;
228
+ }
226
229
  if (char === '"' || char === "'") { quote = char; continue; }
227
230
  if (char === ';' || char === '|') return true;
228
231
  }
@@ -35,12 +35,10 @@ const APP_MAIN = /(?:^|\/)apps\/[^/]+\/src\/main\.ts$/u;
35
35
  const SEALED_MEMBER = /^\.starcistacks\/[^/]+\/secrets\/[^/]+\.enc$/u;
36
36
  const KEYS_ROSTER = /^\.starcistacks\/[^/]+\/runtime\/env\/KEYS\.md$/u;
37
37
  const STACK_YAML = /^\.starcistacks\/.+\.ya?ml$/u;
38
- const ENV_EXAMPLE = /\.env\.example$/u;
39
- const PUBLIC_PREFIX = /^NEXT_PUBLIC_/u;
40
38
  const KEY_NAME = /^[A-Za-z_]\w*$/u;
41
39
  const FILE_SUFFIX = /_FILE$/u;
42
40
  const EXAMPLE_LINE = /^[ \t]*(?:export[ \t]+)?([A-Za-z_]\w*)[ \t]*=(.*)$/u;
43
- const ROSTER_KEY = /`([A-Za-z_][A-Za-z0-9_]*)`/gu;
41
+ const ROSTER_KEY = /`([A-Za-z_]\w*)`/gu;
44
42
 
45
43
  const REQUIRED_ALWAYS = new Set(['string', 'url', 'secret', 'enum']);
46
44
  const FALLBACK_OPTIONAL = new Set(['int', 'bool', 'duration']);
@@ -274,14 +272,17 @@ function declaredYamlEntry(key, value, add) {
274
272
  /** The env names one yaml node declares: `environment:`/`env:` keys or `K=V` entries, `runtimeSecrets`, `secrets` and `vars[].name`. */
275
273
  function declaredFromYaml(node, add) {
276
274
  if (!node || typeof node !== 'object') return;
277
- if (Array.isArray(node)) { for (const item of node) declaredFromYaml(item, add); return; }
275
+ if (Array.isArray(node)) {
276
+ for (const item of node) declaredFromYaml(item, add);
277
+ return;
278
+ }
278
279
  for (const [key, value] of Object.entries(node)) declaredYamlEntry(key, value, add);
279
280
  }
280
281
 
281
282
  function addDeclaredKey(declared, name) {
282
283
  if (!KEY_NAME.test(name)) return;
283
284
  declared.add(name);
284
- if (FILE_SUFFIX.test(name)) declared.add(name.replace(FILE_SUFFIX, ''));
285
+ if (name.endsWith('_FILE')) declared.add(name.replace(FILE_SUFFIX, ''));
285
286
  }
286
287
 
287
288
  function declareKeysFromText(file, text, add) {
@@ -314,7 +315,7 @@ function addRequiredFindings(reads, sealed, declared, findings) {
314
315
 
315
316
  function sealedMemberFinding(key, member, reads, declared) {
316
317
  // The specific public-custody code wins over the generic unread one for a NEXT_PUBLIC_* key.
317
- if (PUBLIC_PREFIX.test(key)) {
318
+ if (key.startsWith('NEXT_PUBLIC_')) {
318
319
  return found(ENV_PUBLIC_SEALED, member, `${member} seals the public key ${key}; NEXT_PUBLIC_* values are public configuration and never enter custody`);
319
320
  }
320
321
  if (!reads.has(key) && !declared.has(key)) {
@@ -347,7 +348,7 @@ function addDuplicateFindings(key, members, findings) {
347
348
  }
348
349
 
349
350
  function addExampleFindings(repoRoot, all, reads, findings) {
350
- for (const file of all.filter((rel) => ENV_EXAMPLE.test(rel))) {
351
+ for (const file of all.filter((rel) => rel.endsWith('.env.example'))) {
351
352
  const text = readText(repoRoot, file);
352
353
  if (text === null) continue;
353
354
  for (const { key } of envExampleEntries(text)) {
@@ -19,8 +19,6 @@ import { found, readText } from './read.mjs';
19
19
  export const ENV_VALUE_SHAPE = 'ENV_VALUE_SHAPE';
20
20
 
21
21
  const HERE = path.dirname(fileURLToPath(import.meta.url));
22
- const FILE_SUFFIX = /_FILE$/u;
23
- const ENV_EXAMPLE = /\.env\.example$/u;
24
22
  const SKIP_DIRECTORY = new Set(['node_modules', 'dist', 'coverage', '.git']);
25
23
 
26
24
  const SHAPES = Object.freeze({
@@ -61,7 +59,7 @@ function listEnvSourceFiles(repoRoot) {
61
59
  function shapeViolations(reads, map) {
62
60
  const violations = [];
63
61
  for (const [key, value] of Object.entries(map)) {
64
- if (FILE_SUFFIX.test(key)) continue;
62
+ if (key.endsWith('_FILE')) continue;
65
63
  const shape = reads.get(key)?.shape;
66
64
  if (shape && !shapeOk(shape, value)) violations.push({ key, expected: shapeText(shape) });
67
65
  }
@@ -71,7 +69,7 @@ function shapeViolations(reads, map) {
71
69
  /** The findings of R249 over the `*.env.example` files of an app judged at its root scope (both editions). */
72
70
  export function envValueShapeFindings({ repoRoot, all = [], isRoot }) {
73
71
  if (!isRoot) return [];
74
- const examples = all.filter((file) => ENV_EXAMPLE.test(file));
72
+ const examples = all.filter((file) => file.endsWith('.env.example'));
75
73
  if (!examples.length) return [];
76
74
  const reads = collectEnvReads(repoRoot, all);
77
75
  if (reads === null) return [];
@@ -91,7 +91,7 @@ function requiredNames(root, files, app, ts) {
91
91
  const names = new Set();
92
92
  for (const file of files.filter(file => file.startsWith(prefix))) {
93
93
  const local = file.slice(prefix.length);
94
- const source = SOURCE.test(local) && (local.startsWith('src/') || /^next\.config\./u.test(local) || SCHEMA.test(local));
94
+ const source = SOURCE.test(local) && (local.startsWith('src/') || local.startsWith('next.config.') || SCHEMA.test(local));
95
95
  const schema = SCHEMA.test(local) && /\.(?:json|ya?ml)$/u.test(local);
96
96
  if (!source && !schema) continue;
97
97
  const text = readText(root, file);
@@ -13,7 +13,10 @@ import { found, readJson, readText } from './read.mjs';
13
13
 
14
14
  export const HFS_RELEASE_TAG_MISMATCH = 'HFS_RELEASE_TAG_MISMATCH';
15
15
  export const HFS_DB_DEPLOY_UNGUARDED = 'HFS_DB_DEPLOY_UNGUARDED';
16
- const SEMVER = /^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-(?:0|[1-9]\d*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
16
+ const SEMVER_NUMBER = String.raw`(?:0|[1-9]\d*)`;
17
+ const SEMVER_PRERELEASE_ID = String.raw`(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)`;
18
+ const SEMVER_BUILD_ID = '[0-9A-Za-z-]+';
19
+ const SEMVER = new RegExp(String.raw`^${SEMVER_NUMBER}\.${SEMVER_NUMBER}\.${SEMVER_NUMBER}(?:-${SEMVER_PRERELEASE_ID}(?:\.${SEMVER_PRERELEASE_ID})*)?(?:\+${SEMVER_BUILD_ID}(?:\.${SEMVER_BUILD_ID})*)?$`);
17
20
  const WORKFLOW = /^\.github\/workflows\/(images|db-deploy)\.ya?ml$/;
18
21
  const TAG = 1;
19
22
  const VERSION_PREFIX = 2;
@@ -42,7 +45,7 @@ function splitBoolean(expression, operator) {
42
45
  let start = 0, depth = 0, quote = '';
43
46
  for (let i = 0; i < expression.length; i += 1) {
44
47
  const char = expression[i];
45
- if (quote) { if (char === quote) quote = ''; continue; }
48
+ if (quote) { if (char === quote) { quote = ''; } continue; }
46
49
  if (char === "'" || char === '"') { quote = char; continue; }
47
50
  if (char === '(') depth += 1;
48
51
  if (char === ')') depth -= 1;
@@ -56,7 +59,7 @@ function enclosingParentheses(text) {
56
59
  let depth = 0, quote = '';
57
60
  for (let i = 0; i < text.length - 1; i += 1) {
58
61
  const char = text[i];
59
- if (quote) { if (char === quote) quote = ''; continue; }
62
+ if (quote) { if (char === quote) { quote = ''; } continue; }
60
63
  if (char === "'" || char === '"') { quote = char; continue; }
61
64
  if (char === '(') depth += 1;
62
65
  if (char === ')' && --depth === 0) return false;
@@ -109,7 +112,7 @@ function shellTagGuard(step) {
109
112
  }
110
113
 
111
114
  function checkoutPreservesRef(job) {
112
- return (job.steps ?? []).filter((step) => /^actions\/checkout@/.test(step.uses ?? '')).every((step) => {
115
+ return (job.steps ?? []).filter((step) => (step.uses ?? '').startsWith('actions/checkout@')).every((step) => {
113
116
  const ref = step.with?.ref;
114
117
  return ref === undefined || /^\$\{\{\s*github\.(?:ref|ref_name|sha)\s*\}\}$/.test(String(ref));
115
118
  });
@@ -174,7 +177,7 @@ function imageFindings(file, workflow, version) {
174
177
  let builds = 0;
175
178
  for (const [name, job] of Object.entries(workflow.jobs ?? {})) {
176
179
  for (const step of job?.steps ?? []) {
177
- if (!/^docker\/build-push-action@/.test(step.uses ?? '')) continue;
180
+ if (!(step.uses ?? '').startsWith('docker/build-push-action@')) continue;
178
181
  builds += 1;
179
182
  const guarded = tagOnlyTrigger(workflow) || refProof(job.if) === RELEASE_REF || refProof(step.if) === RELEASE_REF;
180
183
  if (!guarded || !checkoutPreservesRef(job) || !imageTagIsRelease(step.with?.tags, version)) findings.push(found(HFS_RELEASE_TAG_MISMATCH, file, `${file} job ${name} must build the checked-out release ref and tag every image with v<package.json version> or github.ref_name under a release-tag guard`, { job: name }));