@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
@@ -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
  }
@@ -228,7 +238,7 @@ function injectionTokens(repoRoot, tracked) {
228
238
  const ts = loadTypescript(repoRoot, path.dirname(fileURLToPath(import.meta.url)));
229
239
  if (!ts) return tokens;
230
240
  for (const file of tracked) {
231
- if (!/\.[cm]?[jt]sx?$/.test(file) || /(?:^|\/)(?:tests?|__tests__)\/|(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/.test(file)) continue;
241
+ if (!/\.[cm]?[jt]sx?$/.test(file) || /(?:(?:^|\/)(?:tests?|__tests__)\/)|(?:(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$)/.test(file)) continue;
232
242
  const text = readText(repoRoot, file) ?? '';
233
243
  declaredTokens(ts, ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true), tokens);
234
244
  }
@@ -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);
@@ -37,7 +37,7 @@ function wireSchemaDigest(repoRoot, snapshots) {
37
37
  for (const snapshot of [...snapshots].sort(byCodeUnit)) {
38
38
  let source;
39
39
  try { source = fs.readFileSync(path.join(repoRoot, snapshot), 'utf8'); } catch { return null; }
40
- hash.update(`${snapshot}\0${source.replace(/\r\n/g, '\n')}\0`);
40
+ hash.update(`${snapshot}\0${source.replaceAll('\r\n', '\n')}\0`);
41
41
  }
42
42
  return hash.digest('hex');
43
43
  }
@@ -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;
@@ -79,7 +82,7 @@ function refProof(value) {
79
82
  if (typeof value !== 'string') return 0;
80
83
  const expression = unwrap(workflowExpression(value));
81
84
  const alternatives = splitBoolean(expression, '||');
82
- if (alternatives.length > 1) return alternatives.map(refProof).reduce((a, b) => a & b);
85
+ if (alternatives.length > 1) return alternatives.map(refProof).reduce((a, b) => a & b, RELEASE_REF);
83
86
  const conjuncts = splitBoolean(expression, '&&');
84
87
  if (conjuncts.length > 1) return conjuncts.map(refProof).reduce((a, b) => a | b, 0);
85
88
  if (/^startsWith\(\s*github\.ref\s*,\s*(['"])refs\/tags\/v\1\s*\)$/.test(expression)) return RELEASE_REF;
@@ -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 }));
@@ -7,16 +7,32 @@ const PLAINTEXT_LOAD = 'HFS_SCRIPT_PLAINTEXT_LOAD';
7
7
  const XTRACE = 'HFS_SCRIPT_XTRACE';
8
8
  const SERVICE_ROLE = 'HFS_SERVICE_ROLE_SCOPE';
9
9
  const SUPABASE_OWNER = 'be.integrations.supabase';
10
- const SECRET_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|\.nivo-lite(?:\/|$))|\.enc$/iu;
10
+ const SECRET_ENV_FILE = /(?:^|\/)(?:secrets?\.env|\.env)(?:\.[^/]*)?/iu;
11
+ const SECRET_NIVO_FILE = /(?:^|\/)\.nivo-lite(?:\/|$)/iu;
12
+ const SECRET_ENC_FILE = /\.enc$/iu;
13
+ const isSecretFile = (file) => SECRET_ENV_FILE.test(file) || SECRET_NIVO_FILE.test(file) || SECRET_ENC_FILE.test(file);
11
14
  const EXCLUDED_TREE = /(?:^|\/)(?:node_modules|\.git|\.claude|\.secrets|secrets|tests?|__tests__|docs|coverage|dist|build)(?:\/|$)/u;
12
15
  const TEST_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/u;
13
16
  const SOURCE = /\.(?:[cm]?[jt]sx?|sh|bash|zsh|ksh|ps1|psm1|bat|cmd|py|rb|pl)$/iu;
14
17
  const WORKFLOW = /(?:^|\/)\.?(?:github\/)?workflows\/[^/]+\.ya?ml$/u;
15
18
  const DELIVERY = /(?:^|\/)(?:deploy|scripts|bin)\//u;
16
19
  const DOCKER = /(?:^|\/)(?:Dockerfile(?:\.[^/]*)?|(?:docker-)?compose(?:[.-][^/]*)?\.ya?ml)$/iu;
17
- const SECRET_REFERENCE = /(?:^|[\s/\\'"`(=])(?:secrets?\.env(?:\.[\w.-]+)?|\.env(?:\.[\w.-]+)?|\.nivo-lite(?:[/\\][\w./\\-]*)?)(?=$|[\s/\\'"`),;])/iu;
20
+ const SECRET_REFERENCE_NAME = /(?:secrets?\.env|\.env)(?:\.[\w.-]+)?/;
21
+ const SECRET_REFERENCE_NIVO = /\.nivo-lite(?:[/\\][\w./\\-]*)?/;
22
+ const SECRET_REFERENCE_BEFORE = /(?:^|[\s/\\'"`(=])/;
23
+ const SECRET_REFERENCE_AFTER = /(?=$|[\s/\\'"`),;])/;
24
+ const SECRET_REFERENCE_SOURCE = `${SECRET_REFERENCE_BEFORE.source}(?:${SECRET_REFERENCE_NAME.source}|${SECRET_REFERENCE_NIVO.source})${SECRET_REFERENCE_AFTER.source}`;
25
+ const SECRET_REFERENCE = new RegExp(SECRET_REFERENCE_SOURCE, 'iu');
18
26
  const EXAMPLE_REFERENCE = /(?:secrets?\.env|\.env)\.(?:example|sample|template)(?=$|[^\w.-])/giu;
19
- const LOAD = /(?:^|[\s;&|('"`])(?:source|cat|tac|less|more|head|tail|grep|egrep|fgrep|awk|gawk|sed|base64|Get-Content|gc|Import-DotEnv|env-cmd|load_dotenv|loadEnvFile|readFile(?:Sync)?|readTextFile(?:Sync)?|ReadAllText|ReadAllLines|dotenv\s*\.\s*(?:config|parse))\b|\b(?:File|IO)\s*\.\s*(?:read|readlines)\b|\.\s*read_(?:text|bytes)\s*\(|(?:^|[\s;&|])\.[\t ]+|(?<!<)<(?!<)/iu;
27
+ const LOAD_SHELL_COMMAND = /(?:^|[\s;&|('"`])(?:source|cat|tac|less|more|head|tail|grep|egrep|fgrep|awk|gawk|sed|base64)\b/iu;
28
+ const LOAD_WINDOWS_COMMAND = /(?:^|[\s;&|('"`])(?:Get-Content|gc|Import-DotEnv|env-cmd|load_dotenv|loadEnvFile)\b/iu;
29
+ const LOAD_READ_CALL = /(?:^|[\s;&|('"`])(?:readFile(?:Sync)?|readTextFile(?:Sync)?|ReadAllText|ReadAllLines|dotenv\s*\.\s*(?:config|parse))\b/iu;
30
+ const LOAD_FILE_READ = /\b(?:File|IO)\s*\.\s*(?:read|readlines)\b/iu;
31
+ const LOAD_PYTHON_READ = /\.\s*read_(?:text|bytes)\s*\(/iu;
32
+ const LOAD_DOT_SOURCE = /(?:^|[\s;&|])\.[\t ]+/iu;
33
+ const LOAD_REDIRECT = /(?<!<)<(?!<)/iu;
34
+ const LOAD_PATTERNS = [LOAD_SHELL_COMMAND, LOAD_WINDOWS_COMMAND, LOAD_READ_CALL, LOAD_FILE_READ, LOAD_PYTHON_READ, LOAD_DOT_SOURCE, LOAD_REDIRECT];
35
+ const isLoad = (line) => LOAD_PATTERNS.some((pattern) => pattern.test(line));
20
36
  const READ_CALL = /\b(?:readFile(?:Sync)?|readTextFile(?:Sync)?|ReadAllText|ReadAllLines|load_dotenv|loadEnvFile|dotenv\s*\.\s*(?:config|parse))\s*\(/giu;
21
37
  const TRACE_COMMAND = /(?:^|[\s;&|('"`]|#!)(?:set|\/?(?:[\w.-]+\/)*(?:bash|sh|dash|zsh|ksh))\s+/giu;
22
38
  const SHELL_OPTIONS = /\bSHELLOPTS\s*=\s*['"]?([^\s;'"]*)/giu;
@@ -26,7 +42,7 @@ const SERVICE_ROLE_NAME = /(?:^|[^a-z0-9])(?:[a-z0-9]+[_-])*service[_-]?role(?:[
26
42
  function executableText(text) {
27
43
  return text.replace(/\/\*[\s\S]*?\*\//gu, '').split(/\r?\n/u)
28
44
  .filter((line) => !/^\s*(?:#(?!!)|\/\/)/u.test(line)).join('\n')
29
- .replace(/(?:\\|`)\r?\n/gu, ' ');
45
+ .replace(/[\\`]\r?\n/gu, ' ');
30
46
  }
31
47
 
32
48
  const hasSecretReference = (text) => SECRET_REFERENCE.test(text.replace(EXAMPLE_REFERENCE, 'example-file'));
@@ -37,7 +53,7 @@ function callArguments(text, from) {
37
53
  for (let at = from; at < Math.min(text.length, from + 1024); at += 1) {
38
54
  const character = text[at];
39
55
  if (character === '\\') { at += 1; continue; }
40
- if (quote) { if (character === quote) quote = ''; continue; }
56
+ if (quote) { if (character === quote) { quote = ''; } continue; }
41
57
  if (['\'', '"', '`'].includes(character)) { quote = character; continue; }
42
58
  if (character === '(') depth += 1;
43
59
  if (character === ')' && --depth === 0) return text.slice(from, at);
@@ -81,7 +97,7 @@ function plaintextLoad(text) {
81
97
  const lines = text.split(/\r?\n/u);
82
98
  const aliases = secretAliases(lines);
83
99
  const secretReference = (value) => hasSecretReference(value) || aliases.some((alias) => alias.test(value));
84
- return lines.some((line) => LOAD.test(line) && secretReference(line)) || multilineLoad(text, secretReference) || pythonLoad(text);
100
+ return lines.some((line) => isLoad(line) && secretReference(line)) || multilineLoad(text, secretReference) || pythonLoad(text);
85
101
  }
86
102
 
87
103
  function tracingOptions(source) {
@@ -120,7 +136,7 @@ function integrationOwns(file, resolver) {
120
136
  }
121
137
 
122
138
  function isSource(file) {
123
- if (SECRET_FILE.test(file) || EXCLUDED_TREE.test(file) || TEST_FILE.test(file)) return false;
139
+ if (isSecretFile(file) || EXCLUDED_TREE.test(file) || TEST_FILE.test(file)) return false;
124
140
  return SOURCE.test(file) || WORKFLOW.test(file) || DOCKER.test(file)
125
141
  || file.endsWith('/package.json') || file === 'package.json'
126
142
  || (DELIVERY.test(file) && !/\.[a-z0-9]+$/iu.test(file));
@@ -13,7 +13,10 @@ const SCRIPTS_DIR = /(?:^|\/)scripts\//;
13
13
  const PACKAGE_JSON = /(?:^|\/)package\.json$/;
14
14
  const SOURCE_FILE = /(?:^|\/)[^/.]+$|\.(?:sh|bash|zsh|mjs|cjs|js|ts|mts|py|ya?ml)$/;
15
15
  const TEST_FILE = /\.(?:spec|test)\.[cm]?[jt]s$/;
16
- const CREDENTIAL_FILE = /(?:^|\/)(?:secrets?\.env(?:\.[^/]*)?|\.env(?:\.[^/]*)?|secrets\/)|\.enc$/i;
16
+ const CREDENTIAL_ENV_FILE = /(?:^|\/)(?:secrets?\.env|\.env)(?:\.[^/]*)?/i;
17
+ const CREDENTIAL_SECRETS_DIR = /(?:^|\/)secrets\//i;
18
+ const CREDENTIAL_ENC_FILE = /\.enc$/i;
19
+ const isCredentialFile = (file) => CREDENTIAL_ENV_FILE.test(file) || CREDENTIAL_SECRETS_DIR.test(file) || CREDENTIAL_ENC_FILE.test(file);
17
20
  const VERB_FILE = /^(render|provision|converge|verify|prune|rollback|release|backup|prepare|doctor|jobs|public)\.(?:mjs|js|cjs|sh)$/;
18
21
  const COMMENT_LINE = /^[ \t]*(?:#|\/\/).*$/gm;
19
22
  const LINE_CONTINUATION = /\\\r?\n\s*/g;
@@ -57,7 +60,7 @@ function sourceText(file, raw) {
57
60
  }
58
61
 
59
62
  function deploySource(file) {
60
- if (MANAGED_WORKFLOW.test(file) || CREDENTIAL_FILE.test(file) || TEST_FILE.test(file)) return false;
63
+ if (MANAGED_WORKFLOW.test(file) || isCredentialFile(file) || TEST_FILE.test(file)) return false;
61
64
  if (PACKAGE_JSON.test(file) || WORKFLOW.test(file)) return true;
62
65
  return (DEPLOY_DIR.test(file) || SCRIPTS_DIR.test(file)) && SOURCE_FILE.test(file);
63
66
  }
@@ -26,7 +26,7 @@ const DECLARATION = '.starcistacks/application-stacks.yaml';
26
26
  const MAX_BYTES = 1024 * 1024;
27
27
  const SOURCE = /^(?:be\/)?(?:src|apps)\/.*\.[cm]?[jt]s$/;
28
28
  const TEST = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]s$|\/(?:tests|__tests__)\//;
29
- const SECRET = /(?:^|\/)(?:secrets|\.claude|\.aws|\.codex)(?:\/|$)|(?:^|\/)\.env(?:\.|$)|\.(?:enc|pem|key|p12|pfx)$/i;
29
+ const SECRET = /(?:(?:^|\/)(?:secrets|\.claude|\.aws|\.codex)(?:\/|$))|(?:(?:^|\/)\.env(?:\.|$))|(?:\.(?:enc|pem|key|p12|pfx)$)/i;
30
30
  const CLI_ENTRY = /^(?:be\/)?dist\/apps\/cli\/[A-Za-z0-9_./-]+\.(?:js|mjs|cjs)$/;
31
31
  const mapping = value => plain(value) ? value : {};
32
32
  const present = value => typeof value === 'string' && Boolean(value.trim());
@@ -14,9 +14,9 @@ import { stacksDeploymentFindings } from './stacks-runtime.mjs';
14
14
 
15
15
  const mapping = value => plain(value) ? value : {};
16
16
  const present = value => typeof value === 'string' && Boolean(value.trim());
17
- const VERSION = /^[1-9][0-9]*$/;
18
- const VERSIONED_SECRET = /-v[1-9][0-9]*$/;
19
- const SENSITIVE_KEY = /password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|service[_-]?role|(?:^|_)key$/i;
17
+ const VERSION = /^[1-9]\d*$/;
18
+ const VERSIONED_SECRET = /-v[1-9]\d*$/;
19
+ const SENSITIVE_KEY = /password|passwd|secret|token|credential|private[_-]?key|api[_-]?key|service[_-]?role|(?:(?:^|_)key$)/i;
20
20
  const SOURCE = /^be\/(?:apps|src)\/.*\.[cm]?[jt]sx?$/;
21
21
  const TEST_SOURCE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$|\/tests\//;
22
22
  const REALTIME_SOURCE = /^be\/src\/features\/realtime\/[^/]+\//;
@@ -124,7 +124,7 @@ function traefikHosts(rule) {
124
124
  function positiveHostCall(prefix) {
125
125
  const scopes = [false];
126
126
  let pending = false;
127
- for (const token of prefix.match(/"[^"]*"|'[^']*'|`[^`]*`|[()!]|[A-Za-z_][A-Za-z_0-9]*|\S/g) ?? []) {
127
+ for (const token of prefix.match(/"[^"]*"|'[^']*'|`[^`]*`|[()!]|[A-Za-z_]\w*|\S/g) ?? []) {
128
128
  if (token === '!') { pending = !pending; continue; }
129
129
  if (token === '(') scopes.push(scopes.at(-1) !== pending);
130
130
  if (token === ')' && scopes.length > 1) scopes.pop();
@@ -139,7 +139,7 @@ function labelHosts(service, family) {
139
139
  if (family === 'traefik' && labels.some(([key, value]) => key === 'traefik.enable' && String(value) === 'false')) return hosts;
140
140
  for (const [key, value] of labels) {
141
141
  if (family === 'traefik' && key.startsWith('traefik.http.routers.') && key.endsWith('.rule')) hosts.push(...traefikHosts(value));
142
- if (family === 'caddy-docker-proxy' && /^caddy(?:_[0-9]+)?$/.test(key)) hosts.push(...String(value).split(/[\s,]+/).map(hostOf).filter(Boolean));
142
+ if (family === 'caddy-docker-proxy' && /^caddy(?:_\d+)?$/.test(key)) hosts.push(...String(value).split(/[\s,]+/).map(hostOf).filter(Boolean));
143
143
  }
144
144
  return hosts;
145
145
  }
@@ -282,7 +282,7 @@ function positiveMonitor(value) {
282
282
  const units = value.match(/[a-z]+/g) ?? [], quantities = value.split(/[a-z]+/);
283
283
  if (quantities.pop() !== '' || !units.length) return false;
284
284
  return units.every(unit => ['ns', 'us', 'ms', 's', 'm', 'h'].includes(unit))
285
- && quantities.every(quantity => /^[0-9]+(?:\.[0-9]+)?$/.test(quantity)) && /[1-9]/.test(value);
285
+ && quantities.every(quantity => /^\d+(?:\.\d+)?$/.test(quantity)) && /[1-9]/.test(value);
286
286
  }
287
287
 
288
288
  function updatePolicyIssues(id, service, add) {
@@ -32,6 +32,7 @@ function appendShellCommand(commands, state, next) {
32
32
  }
33
33
 
34
34
  function shellLineCommands(line, commands) {
35
+ /** @type {{quote: string | null, command: string, separator: string | null}} */
35
36
  const state = { quote: null, command: '', separator: null };
36
37
  for (let i = 0; i < line.length; i++) {
37
38
  const char = line[i];
@@ -140,12 +141,12 @@ const provenanceEnabled = (value) => asserted(value) || typeof value === 'string
140
141
 
141
142
  function imageActionEvidence(step) {
142
143
  const action = step.uses ?? '';
143
- if (/^docker\/build-push-action@/.test(action)) {
144
+ if (action.startsWith('docker/build-push-action@')) {
144
145
  return { sbom: asserted(step.with?.sbom), provenance: provenanceEnabled(step.with?.provenance) };
145
146
  }
146
147
  return {
147
- sbom: /^actions\/attest-sbom@/.test(action) && Boolean(step.with?.['sbom-path']),
148
- provenance: /^actions\/attest-build-provenance@/.test(action) && Boolean(step.with?.['subject-path'] || step.with?.['subject-digest']),
148
+ sbom: action.startsWith('actions/attest-sbom@') && Boolean(step.with?.['sbom-path']),
149
+ provenance: action.startsWith('actions/attest-build-provenance@') && Boolean(step.with?.['subject-path'] || step.with?.['subject-digest']),
149
150
  };
150
151
  }
151
152
 
@@ -14,7 +14,7 @@ function mergeProperty(before, after) {
14
14
  /** Every possible spread owner must be known before its property provenance is admitted. */
15
15
  function spreadProperty(state, expression, name, seen, resolve) {
16
16
  const choices = resolve(state, expression, seen).map(owner => ownerProperty(state, owner, name, seen, resolve));
17
- if (!choices.length || choices.some(choice => choice === null)) return null;
17
+ if (!choices.length || choices.includes(null)) return null;
18
18
  return { values: choices.flatMap(choice => choice.values), missing: choices.some(choice => choice.missing) };
19
19
  }
20
20
 
@@ -12,7 +12,10 @@ import { returnedExpressions, valuesOf } from './unused-symbol-flow.mjs';
12
12
 
13
13
  export const HFS_UNUSED_SYMBOL = 'HFS_UNUSED_SYMBOL';
14
14
  const SOURCE = /\.[cm]?[jt]sx?$/u;
15
- const EXCLUDED = /(?:^|\/)(?:node_modules|__generated__|_generated|dist|tests?|__tests__|test-support)\/|(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$|\.d\.[cm]?ts$/u;
15
+ const EXCLUDED_DIR = /(?:^|\/)(?:node_modules|__generated__|_generated|dist|tests?|__tests__|test-support)\//u;
16
+ const EXCLUDED_TEST_FILE = /(?:\.(?:spec|test)|-spec)\.[cm]?[jt]sx?$/u;
17
+ const EXCLUDED_DECLARATION_FILE = /\.d\.[cm]?ts$/u;
18
+ const isExcluded = (file) => EXCLUDED_DIR.test(file) || EXCLUDED_TEST_FILE.test(file) || EXCLUDED_DECLARATION_FILE.test(file);
16
19
  const CATALOG = /(?:\.messages|\.catalog)\.[cm]?[jt]s$/u;
17
20
  const JSON_CATALOG = /(?:^|\/)(?:messages|locales)\/[^/]+\.json$|\.catalog\.json$/u;
18
21
  const QUEUE_NAME = /(?:^QUEUE(?:_NAME)?$|_QUEUE(?:_NAME)?$)/u;
@@ -29,7 +32,7 @@ function compilerAt(repoRoot, supplied) {
29
32
  function sourceFiles(ts, repoRoot, files) {
30
33
  const sources = new Map();
31
34
  for (const file of files) {
32
- if (!SOURCE.test(file) || EXCLUDED.test(file) || !/(?:^|\/)src\//u.test(file)) continue;
35
+ if (!SOURCE.test(file) || isExcluded(file) || !/(?:^|\/)src\//u.test(file)) continue;
33
36
  const text = readText(repoRoot, file);
34
37
  if (text !== null) sources.set(slash(path.resolve(repoRoot, file)), ts.createSourceFile(path.resolve(repoRoot, file), text, ts.ScriptTarget.Latest, true));
35
38
  }
@@ -259,7 +262,7 @@ function sourceReads(state, sources) {
259
262
  }
260
263
 
261
264
  function jsonCandidates(state, files) {
262
- for (const file of files.filter(file => JSON_CATALOG.test(file) && !EXCLUDED.test(file))) {
265
+ for (const file of files.filter(file => JSON_CATALOG.test(file) && !isExcluded(file))) {
263
266
  let json;
264
267
  try { json = JSON.parse(readText(state.repoRoot, file)); } catch { continue; }
265
268
  const leaves = (value, prefix = '') => {
@@ -316,7 +319,8 @@ export function unusedSymbolFindings({ repoRoot, files, repo = {}, isRoot = fals
316
319
  const findings = new Map();
317
320
  for (const candidate of state.candidates) {
318
321
  if (candidate.used || candidate.kind === 'catalog key' && catalogRead(state, candidate, liveCodes) || !inScope(candidate.path)) continue;
319
- const { used: _used, ...extra } = candidate;
322
+ const extra = { ...candidate };
323
+ delete extra.used;
320
324
  findings.set(`${candidate.path}:${candidate.name}`, found(HFS_UNUSED_SYMBOL, candidate.path, `${candidate.path} declares unused ${candidate.kind} ${candidate.name}; remove it or read it in production source (declarations, metadata maps and tests do not count).`, extra));
321
325
  }
322
326
  return [...findings.values(), ...ledgerFindings.filter(finding => inScope(finding.path))].sort((a, b) => a.path.localeCompare(b.path) || a.line - b.line || a.name.localeCompare(b.name));
@@ -76,7 +76,7 @@ function expressions(value) {
76
76
 
77
77
  /** Source markers travel inside parsed scalar expressions, preserving flow maps, quoted keys and repeated values without matching comments. */
78
78
  export function workflowContextFindings({ path: file, text }) {
79
- const marked = text.replace(/\$\{\{/g, (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
79
+ const marked = text.replaceAll('${{', (match, offset) => `${match} __hfs_context_line_${text.slice(0, offset).split('\n').length}__ `);
80
80
  const findings = [];
81
81
  function inspect(value, keys) {
82
82
  const allowed = allowedContexts(keys);
@@ -75,7 +75,7 @@ export function declaredSealedMembers(repoRoot) {
75
75
 
76
76
  function placeholderValue(key) {
77
77
  const generated = randomUUID();
78
- if (/DB_URL$/u.test(key)) return `postgresql://app_be:${generated}@db.invalid:5432/app`;
78
+ if (key.endsWith('_URL') && key.slice(0, -4).endsWith('DB')) return `postgresql://app_be:${generated}@db.invalid:5432/app`;
79
79
  if (key.endsWith('_URL')) return `https://example.invalid/${generated}`;
80
80
  return generated;
81
81
  }
@@ -22,7 +22,7 @@ function checksumStep(checksum, value) {
22
22
  function checksumWords(prefix, words) {
23
23
  const codes = [...prefix].map((character) => character.codePointAt(0));
24
24
  const expanded = [...codes.map((code) => code >>> 5), 0, ...codes.map((code) => code & 31)];
25
- return [...expanded, ...words].reduce(checksumStep, 1);
25
+ return [...expanded, ...words].reduce((checksum, value) => checksumStep(checksum, value), 1);
26
26
  }
27
27
 
28
28
  function convertBits(input, from, to, pad) {
@@ -163,7 +163,7 @@ function bootstrapEntries(values) {
163
163
  const entries = Object.entries(values);
164
164
  if (!entries.length) throw new Error('Bootstrap requires at least one value');
165
165
  for (const [name, value] of entries) {
166
- if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.startsWith('sops_') || name.endsWith('_unencrypted')) {
166
+ if (!/^[A-Za-z_]\w*$/.test(name) || name.startsWith('sops_') || name.endsWith('_unencrypted')) {
167
167
  throw new Error('Bootstrap contains an invalid or reserved dotenv key');
168
168
  }
169
169
  if (typeof value !== 'string' || !value.length) throw new Error('Bootstrap values must be nonempty strings');
@@ -61,7 +61,7 @@ export function privateDirectory(repoRoot, directory) {
61
61
  const missing = [];
62
62
  for (let at = directory; !fs.existsSync(at); at = path.dirname(at))
63
63
  missing.push(at);
64
- for (const at of missing.reverse()) fs.mkdirSync(at, { mode: 0o700 });
64
+ for (const at of missing.toReversed()) fs.mkdirSync(at, { mode: 0o700 });
65
65
  for (let at = directory; ; at = path.dirname(at)) {
66
66
  const stat = fs.lstatSync(at);
67
67
  if (!stat.isDirectory() || stat.isSymbolicLink())
@@ -19,7 +19,7 @@ export function defaultSops(
19
19
  env = process.env,
20
20
  repoRoot = invocationDir(),
21
21
  platform = process.platform,
22
- tools,
22
+ tools = undefined,
23
23
  ) {
24
24
  const { sops: binary } = publicStorage(() => secretTools(env, tools));
25
25
  const selectedEnv = ageIdentityEnv(repoRoot, env, tools);
@@ -176,7 +176,10 @@ function configs(c, block, probe) {
176
176
  out.push({ name: entry.name, file: entry.file, env: entry.env });
177
177
  });
178
178
  const seen = new Set();
179
- for (const entry of out) { if (seen.has(entry.name)) c.problem('deploy.configs', `config name ${entry.name} is declared twice`); seen.add(entry.name); }
179
+ for (const entry of out) {
180
+ if (seen.has(entry.name)) c.problem('deploy.configs', `config name ${entry.name} is declared twice`);
181
+ seen.add(entry.name);
182
+ }
180
183
  return out;
181
184
  }
182
185
 
@@ -242,7 +245,7 @@ function tasks(c, block, services) {
242
245
  return out;
243
246
  }
244
247
 
245
- const REGISTRY_HOST = /^[a-z0-9][a-z0-9.-]*(?::[0-9]{1,5})?$/u;
248
+ const REGISTRY_HOST = /^[a-z0-9][a-z0-9.-]*(?::\d{1,5})?$/u;
246
249
  const VISIBILITY = ['public', 'private'];
247
250
 
248
251
  /** The image registry: host (default ghcr.io) and the visibility the app's packages are meant to have (default public, the canon scaffold writes it and the app owns it afterwards). */
@@ -15,6 +15,7 @@ export const isSpecRun = (env = process.env) => Boolean(env.NODE_TEST_CONTEXT);
15
15
  * would pass red specs from inside a test process. The runner marks the spec files it starts itself, so isSpecRun stays true inside them.
16
16
  */
17
17
  export function withoutTestRunner(env = process.env) {
18
- const { NODE_TEST_CONTEXT: _enclosing, ...rest } = env;
18
+ const rest = { ...env };
19
+ delete rest.NODE_TEST_CONTEXT;
19
20
  return rest;
20
21
  }
@@ -40,9 +40,11 @@ export const pathsOverlap = (a, b) => sameOrUnder(a, b) || sameOrUnder(b, a);
40
40
  * `trim` blanks at the ends, `collapse` folds '//' runs to '/', `dot` drops a leading './',
41
41
  * `glob` strips a trailing glob ('star': '*'/'**' with their slash, 'double': a '/**' only),
42
42
  * `fold` lowercases. A trailing '/' always goes. Null reads as ''.
43
+ * @param {unknown} p
44
+ * @param {{trim?: boolean, collapse?: boolean, dot?: boolean, glob?: string|null, fold?: boolean}} [options]
43
45
  */
44
46
  export const normPath = (p, { trim = false, collapse = false, dot = false, glob = null, fold = false } = {}) => {
45
- let s = String(p ?? '');
47
+ let s = p == null ? '' : String(p);
46
48
  if (trim) s = s.trim();
47
49
  s = slash(s);
48
50
  if (collapse) s = s.replace(/\/{2,}/g, '/');
@@ -17,10 +17,10 @@
17
17
  // directory or from the Work directory name outside this module.
18
18
  import fs from 'node:fs';
19
19
  import path from 'node:path';
20
- import { skillRoot, starciLocalRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
21
- import { tempRoot } from '../../engine/temp-root.mjs';
20
+ import { skillRoot } from '../../engine/runtime-root.mjs';
22
21
 
23
- export { tempRoot, starciLocalRoot, starciSourceRoot };
22
+ export { tempRoot } from '../../engine/temp-root.mjs';
23
+ export { starciLocalRoot, starciSourceRoot } from '../../engine/runtime-root.mjs';
24
24
 
25
25
  /** The runtime tree this code runs from. */
26
26
  const runtimeTree = skillRoot;
@@ -161,7 +161,7 @@ function readmeBadgeText(text, manifestText) {
161
161
  let url;
162
162
  try { url = new URL(address); } catch { return image; }
163
163
  if (!hosts.has(url.host) || url.username || url.password) return image;
164
- const badgePath = url.host === 'codecov.io' ? /\/graph\/badge\.svg$/.test(url.pathname) : url.pathname === '/api/project_badges/measure';
164
+ const badgePath = url.host === 'codecov.io' ? url.pathname.endsWith('/graph/badge.svg') : url.pathname === '/api/project_badges/measure';
165
165
  if (!badgePath) return image;
166
166
  const fragment = address.indexOf('#');
167
167
  const queryEnd = fragment < 0 ? address.length : fragment;
@@ -173,7 +173,7 @@ function readmeBadgeText(text, manifestText) {
173
173
 
174
174
  /** Whole-file secret judgement exempts only declared public badge values and preserves original line numbers and every provider shape. */
175
175
  export function secretHitsInFile(file, fullText, { manifestText } = {}) {
176
- const normalized = file.replace(/\\/g, '/');
176
+ const normalized = file.replaceAll('\\', '/');
177
177
  let text = fullText;
178
178
  if (/(?:^|\/)\.starcistacks\/application-stacks\.yaml$/.test(normalized)) text = manifestBadgeText(text);
179
179
  if (/(?:^|\/)README\.md$/.test(normalized)) text = readmeBadgeText(text, manifestText);
@@ -4,8 +4,10 @@ import { composeEntries } from './compose-values.mjs';
4
4
  import { repositoryOf } from './stack-services.mjs';
5
5
 
6
6
  const PINNED_IMAGE = /@sha256:[a-f0-9]{64}$/i;
7
- const VERSIONED_SECRET = /-v[1-9][0-9]*$/;
8
- const BACKING_IMAGE = /^(?:postgres(?:ql)?|postgresql-repmgr|redis(?:-stack(?:-server)?)?|(?:cp-)?kafka(?:-native)?|keycloak|minio|rabbitmq|mysql(?:-server)?|mongo(?:db)?|mongodb-community-server)$/i;
7
+ const VERSIONED_SECRET = /-v[1-9]\d*$/;
8
+ const BACKING_IMAGE_SQL_AND_CACHE = 'postgres(?:ql)?|postgresql-repmgr|redis(?:-stack(?:-server)?)?|mysql(?:-server)?|mongo(?:db)?|mongodb-community-server';
9
+ const BACKING_IMAGE_BROKERS_AND_OTHERS = '(?:cp-)?kafka(?:-native)?|keycloak|minio|rabbitmq';
10
+ const BACKING_IMAGE = new RegExp(`^(?:${BACKING_IMAGE_SQL_AND_CACHE}|${BACKING_IMAGE_BROKERS_AND_OTHERS})$`, 'i');
9
11
  const mapping = value => plain(value) ? value : {};
10
12
  const present = value => typeof value === 'string' && Boolean(value.trim());
11
13
  const configured = value => Object.keys(mapping(value)).length > 0;