@starci/hfs 4.4.0 → 4.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/README.md +1 -1
  3. package/ci/cli.mjs +64 -0
  4. package/ci/github.mjs +41 -0
  5. package/ci/secret.mjs +28 -0
  6. package/ci/vars.mjs +33 -0
  7. package/ci/verify.mjs +36 -0
  8. package/db/backup-receipt.mjs +129 -0
  9. package/db/dump.mjs +66 -0
  10. package/db/migrate.mjs +8 -5
  11. package/db/post-cutover.mjs +4 -4
  12. package/db/reset.mjs +125 -0
  13. package/emit/contracts.mjs +2 -2
  14. package/emit/db-types.mjs +100 -7
  15. package/lint/run.mjs +30 -8
  16. package/package.json +2 -1
  17. package/release/ci-red.mjs +70 -0
  18. package/release/gate.mjs +53 -13
  19. package/release/index.mjs +8 -6
  20. package/release/push.mjs +1 -1
  21. package/report/sonar.mjs +1 -1
  22. package/runtime/config.example.yaml +7 -6
  23. package/runtime/engine/config.mjs +10 -18
  24. package/runtime/engine/removed-vocabulary.mjs +0 -2
  25. package/runtime/knowledge/hfs/canon-pins.yaml +4 -4
  26. package/runtime/knowledge/hfs/rules.yaml +505 -971
  27. package/runtime/knowledge/hfs/slots.yaml +13 -24
  28. package/runtime/modules/kernel/failure-codes.yaml +60 -22
  29. package/runtime/modules/kernel/removed-vocabulary.yaml +30 -0
  30. package/runtime/modules/models/runtimes.yaml +4 -6
  31. package/runtime/modules/schemas/application-stacks.schema.yaml +131 -1
  32. package/runtime/packages/cli/src/catalog.generated.mjs +286 -398
  33. package/runtime/scripts/api/git/lib.mjs +3 -3
  34. package/runtime/scripts/api/sops/lib.mjs +1 -1
  35. package/runtime/scripts/hfs/app-wiring.mjs +2 -2
  36. package/runtime/scripts/hfs/architecture/config.mjs +1 -1
  37. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  38. package/runtime/scripts/hfs/architecture/supabase.mjs +1 -1
  39. package/runtime/scripts/hfs/architecture/tiers.mjs +1 -1
  40. package/runtime/scripts/hfs/architecture.mjs +1 -1
  41. package/runtime/scripts/hfs/ci-declaration.mjs +102 -0
  42. package/runtime/scripts/hfs/coverage-from.mjs +1 -1
  43. package/runtime/scripts/hfs/coverage-scope.mjs +1 -1
  44. package/runtime/scripts/hfs/example-secrets.mjs +1 -1
  45. package/runtime/scripts/hfs/manifest-shape.mjs +3 -3
  46. package/runtime/scripts/hfs/param-names.mjs +1 -1
  47. package/runtime/scripts/hfs/rule-catalog.mjs +3 -1
  48. package/runtime/scripts/hfs/rules/a15-findings.mjs +10 -1
  49. package/runtime/scripts/hfs/rules/app-workflow-findings.mjs +10 -0
  50. package/runtime/scripts/hfs/rules/assets.mjs +1 -1
  51. package/runtime/scripts/hfs/rules/ci-custody.mjs +104 -0
  52. package/runtime/scripts/hfs/rules/cli.mjs +1 -1
  53. package/runtime/scripts/hfs/rules/contract-compat.mjs +1 -1
  54. package/runtime/scripts/hfs/rules/contract.mjs +1 -1
  55. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +6 -19
  56. package/runtime/scripts/hfs/rules/fe-build-args.mjs +142 -0
  57. package/runtime/scripts/hfs/rules/frontend-tree.mjs +2 -2
  58. package/runtime/scripts/hfs/rules/supabase-secrets.mjs +1 -1
  59. package/runtime/scripts/hfs/rules/supply-chain.mjs +40 -3
  60. package/runtime/scripts/hfs/rules/workflow-contexts.mjs +103 -0
  61. package/runtime/scripts/hfs/runtime-rules/workflow-source.mjs +2 -2
  62. package/runtime/scripts/hfs/slots.mjs +2 -2
  63. package/runtime/scripts/hfs/typescript-programs.mjs +0 -8
  64. package/runtime/scripts/lib/env.mjs +2 -2
  65. package/runtime/scripts/lib/git.mjs +1 -1
  66. package/runtime/scripts/lib/i18n.mjs +0 -2
  67. package/runtime/scripts/lib/language.mjs +1 -1
  68. package/runtime/scripts/lib/roots.mjs +2 -4
  69. package/runtime/scripts/lib/secret-patterns.mjs +2 -2
  70. package/scaffold/add-fe-overlay.mjs +0 -121
  71. package/scaffold/add-fe-page.mjs +0 -268
  72. package/scaffold/supabase-ports.mjs +1 -1
  73. package/src/main.mjs +17 -6
  74. package/stack/age-install.mjs +1 -1
  75. package/stack/cli.mjs +2 -2
  76. package/stack/context.mjs +14 -2
  77. package/stack/digest.mjs +1 -1
  78. package/stack/docker-transport.mjs +93 -15
  79. package/stack/edge-alias.mjs +1 -1
  80. package/stack/image-scan.mjs +1 -1
  81. package/stack/ingress-create.mjs +2 -2
  82. package/stack/ingress.mjs +3 -3
  83. package/stack/release-store.mjs +12 -7
  84. package/stack/remote.mjs +16 -8
  85. package/stack/sealed.mjs +5 -5
  86. package/stack/ssh-session.mjs +178 -0
  87. package/sync/ci-commands.mjs +103 -1
  88. package/sync/format.mjs +1 -1
  89. package/sync/hygiene.mjs +1 -1
  90. package/sync/index.mjs +43 -18
  91. package/sync/quality-services.mjs +61 -0
  92. package/sync/readme-badges.mjs +44 -0
  93. package/sync/stack-images.mjs +31 -5
  94. package/templates/app/ci-workflows/github/workflows/ci.yml +26 -24
  95. package/templates/app/ci-workflows/github/workflows/images.yml +24 -2
  96. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +8 -2
  97. package/templates/app/ci-workflows/install-steps.yml +21 -0
  98. package/templates/app/ci-workflows/sonar-steps.yml +3 -9
  99. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +28 -35
  100. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +2 -1
  101. package/templates/app/ci-workflows-lite/github/workflows/images.yml +24 -2
  102. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +8 -2
  103. package/templates/app/quality-config/codecov.yml +6 -6
  104. package/templates/app/quality-config/sonar-project.properties +1 -0
  105. package/templates/app/readme-badges/README.md +1 -0
  106. package/templates/app/skeleton/.github/dependabot.yml +1 -14
  107. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +61 -3
  108. package/templates/app/skeleton/README.md +6 -0
  109. package/templates/app/skeleton-lite/.github/dependabot.yml +1 -14
  110. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +57 -3
  111. package/templates/app/skeleton-lite/README.md +6 -0
  112. package/templates/app/upgrade-full/.starcistacks/application-stacks.yaml +52 -1
  113. package/upgrade/index.mjs +1 -1
@@ -20,7 +20,7 @@ import { withTempEnv } from '../../../engine/temp-root.mjs';
20
20
  * is a request for raw bytes, not an encoding: spawnSync encodes a string `input` with it and throws
21
21
  * ERR_UNKNOWN_ENCODING, so it reaches spawnSync as encoding:null (Buffer output either way).
22
22
  */
23
- export const gitSpawn = (file, args, options = {}) => {
23
+ const gitSpawn = (file, args, options = {}) => {
24
24
  assertMutationFence({ kind: 'git-effect', args });
25
25
  const spawn = { encoding: 'utf8', windowsHide: true, ...options };
26
26
  if (spawn.encoding === 'buffer') spawn.encoding = null;
@@ -76,5 +76,5 @@ export const gitExecutable = (options = null) => {
76
76
  return found;
77
77
  };
78
78
 
79
- /** The output ceiling of the whole-diff and `cat-file --batch` calls `test affected` batches (a release diff is tens of megabytes). */
80
- export const BATCH_MAX_BUFFER = 1024 * 1024 * 1024;
79
+ /** The output ceiling of the whole-diff and `cat-file --batch` calls batch (a release diff is tens of megabytes). */
80
+ const BATCH_MAX_BUFFER = 1024 * 1024 * 1024;
@@ -105,7 +105,7 @@ 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
107
  /** Construct only the child map; empty desktop roots make Go return directory errors, not fallback paths. */
108
- export function isolatedSopsEnv(env, inlineName = 'SOPS_AGE_KEY') {
108
+ function isolatedSopsEnv(env, inlineName = 'SOPS_AGE_KEY') {
109
109
  const out = Object.fromEntries(Object.entries(env).filter(([name]) => !CONTROLLED_ENV.has(name.toUpperCase())));
110
110
  out.HOME = ''; out.USERPROFILE = ''; out.APPDATA = ''; out.XDG_CONFIG_HOME = '';
111
111
  if (inlineName !== null) out.SOPS_AGE_KEY = env[inlineName];
@@ -24,7 +24,7 @@ const COMPILATION_FAILURE = 'module or proof could not be compiled';
24
24
  const WORKER_FAILURE = 'proof worker failed or timed out';
25
25
  const SAFE_REASONS = new Set([...DI_FAILURES.values(), COMPILATION_FAILURE]);
26
26
  const modulePath = app => `apps/${app}/src/app.module.ts`;
27
- export const wiringFixturePath = app => `src/tests/fixtures/builders/${app}.builder.ts`;
27
+ const wiringFixturePath = app => `src/tests/fixtures/builders/${app}.builder.ts`;
28
28
 
29
29
  const refusal = (file, reason) => ({ code: BOOT_PROOF_CODE, level: 'error', path: file,
30
30
  message: `${file}: lite boot proof failed (${reason}); compile the real AppModule with explicit fixture options and infrastructure doubles using starci app check --wiring`, reason });
@@ -177,7 +177,7 @@ export function appWiring({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), fil
177
177
  }
178
178
 
179
179
  /** Optional standalone entry; coordinator connects the same seam to app check --wiring. */
180
- export function appWiringMain(argv, { cwd = invocationDir(), stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
180
+ function appWiringMain(argv, { cwd = invocationDir(), stdout = text => process.stdout.write(text), stderr = text => process.stderr.write(text) } = {}) {
181
181
  let directory = cwd, json = false;
182
182
  for (let at = 0; at < argv.length; at += 1) {
183
183
  if (argv[at] === '--cwd' && argv[at + 1]) directory = path.resolve(cwd, argv[++at]);
@@ -346,4 +346,4 @@ export function loadArchitectureConfig(repositoryRoot, { hfs } = {}) {
346
346
  };
347
347
  }
348
348
 
349
- export { canonical, enclosingRepository, exactKeys, isInside, slash };
349
+ export { canonical, exactKeys, isInside, slash };
@@ -162,7 +162,7 @@ const machineIds = () => [
162
162
  * backend composition and data machine and frontend repository machine. A spec about another rule judges its own
163
163
  * report without these (they have their own specs); every one of them is in ARCHITECTURE_RULE_IDS.
164
164
  */
165
- export const HFS_MACHINE_RULE_IDS = Object.freeze((() => {
165
+ const HFS_MACHINE_RULE_IDS = Object.freeze((() => {
166
166
  const ordinary = new Set([...COMMON_RULE_IDS, ...BACKEND_RULE_IDS, ...FRONTEND_RULE_IDS, ...OWNER_RULE_IDS, ...GRAMMAR_RULE_IDS, ...REGISTRATION_RULE_IDS,
167
167
  ...SWR_DATA_RULE_IDS, SOURCE_LAYOUT_RULE_ID, SOURCE_NAME_RULE_ID, PUBLIC_CONTRACT_RULE_ID, READONLY_BOUNDARY_RULE_ID]);
168
168
  return [...new Set(machineIds().filter((id) => !ordinary.has(id)))].sort(byCodeUnit);
@@ -209,7 +209,7 @@ export function checkBackendSupabase(input) {
209
209
  };
210
210
  }
211
211
 
212
- export function checkJwtPolicy(input) {
212
+ function checkJwtPolicy(input) {
213
213
  const kit = machineKit(input);
214
214
  const violations = [];
215
215
  let verifyCalls = 0;
@@ -67,7 +67,7 @@ function finishFrame(state, frame) {
67
67
  if (state.low.get(frame.node) === state.index.get(frame.node)) closeComponent(state, frame);
68
68
  }
69
69
 
70
- export function stronglyConnected(adjacency) {
70
+ function stronglyConnected(adjacency) {
71
71
  const state = { counter: 0, index: new Map(), low: new Map(), onStack: new Set(), stack: [], result: [], work: [] };
72
72
  for (const start of adjacency.keys()) {
73
73
  if (state.index.has(start)) continue;
@@ -26,7 +26,7 @@ function parseArchitectureArgs(argv) {
26
26
  return {root, base};
27
27
  }
28
28
 
29
- export function architectureMain(argv, {check = checkArchitecture, write = (text) => process.stdout.write(text), fail = (text) => process.stderr.write(text)} = {}) {
29
+ function architectureMain(argv, {check = checkArchitecture, write = (text) => process.stdout.write(text), fail = (text) => process.stderr.write(text)} = {}) {
30
30
  let parsed;
31
31
  try { parsed = parseArchitectureArgs(argv); } catch (error) { fail(`${error.message}\n`); return 2; }
32
32
  let report;
@@ -0,0 +1,102 @@
1
+ import path from 'node:path';
2
+ import { list } from '../lib/list.mjs';
3
+ import { parseYaml } from '../../engine/yaml.mjs';
4
+
5
+ const nameOf = (item) => typeof item === 'string' ? item : item?.name;
6
+
7
+ /** Each CI name belongs to one class and scope; declarations contain names and public configuration only. */
8
+ export function readCiDeclaration(doc) {
9
+ const problems = [], seen = new Set();
10
+ const invalid = (at, message) => problems.push({ code: 'CI_DECLARATION_INVALID', at, message });
11
+ const normalize = (entries, at, dependabot = false, variable = false) => list(entries).map((item, index) => {
12
+ const where = `${at}[${index}]`, name = item?.name;
13
+ if (seen.has(name)) invalid(`${where}.name`, 'a name must appear once across all CI classes');
14
+ seen.add(name);
15
+ if (typeof name !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(name) || name.startsWith('GITHUB_')) invalid(`${where}.name`, 'use an uppercase CI name outside the reserved GITHUB_ namespace');
16
+ const scope = dependabot ? 'repository' : item?.scope;
17
+ if (!['repository', 'environment'].includes(scope)) invalid(`${where}.scope`, 'scope must be repository or environment');
18
+ if (scope === 'environment' && (typeof item?.environment !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.environment))) invalid(`${where}.environment`, 'environment scope requires an environment name');
19
+ if ((dependabot || scope !== 'environment') && item && Object.hasOwn(item, 'environment')) invalid(`${where}.environment`, 'only environment scope may name an environment');
20
+ return { name, scope, environment: item?.environment ?? null, purpose: item?.purpose ?? null,
21
+ optional: item?.optional === true, ...(variable ? { value: item?.value } : {}) };
22
+ });
23
+ const ci = doc?.ci;
24
+ const secrets = normalize(ci?.secrets, 'ci.secrets');
25
+ const vars = normalize(ci?.vars, 'ci.vars', false, true);
26
+ const dependabotSecrets = normalize(ci?.dependabot?.secrets, 'ci.dependabot.secrets', true);
27
+ return { secrets, vars, dependabotSecrets, problems };
28
+ }
29
+
30
+ /** Missing quality blocks preserve the existing rendered defaults; disabled services remain explicitly disabled. */
31
+ export function qualityServices(doc, { example = false } = {}) {
32
+ const services = doc?.services ?? {}, sonar = services.sonar, codecov = services.codecov ?? {};
33
+ const dependabot = services.dependabot ?? {}, audit = services.audit ?? {};
34
+ return {
35
+ sonar: !sonar || sonar.mode === 'disabled' ? null : {
36
+ provider: sonar.provider, mode: sonar.mode, host: sonar.host?.public || sonar.host?.local || null,
37
+ organization: sonar.organization ?? null,
38
+ organizationVar: sonar.ci?.organizationVar ?? (sonar.provider === 'sonarcloud' && (example || list(sonar.runtimeSecrets).length > 0) && !list(sonar.credentials).length ? 'SONAR_ORGANIZATION' : null), projectKey: sonar.projects?.[0]?.key ?? null,
39
+ qualityGate: sonar.qualityGate ?? (example ? 'starci-quality-lite' : 'starci-quality'), wiring: sonar.ci?.wiring ?? 'required',
40
+ token: sonar.ci?.token || nameOf(sonar.ci?.secrets?.[0]) || 'SONAR_TOKEN', hostVar: sonar.ci?.hostVar || 'SONAR_HOST_URL',
41
+ },
42
+ codecov: { enabled: codecov.mode !== 'disabled', auth: codecov.auth ?? 'oidc', token: codecov.ci?.token ?? null,
43
+ flag: codecov.flag ?? null, target: codecov.target ?? 100, components: codecov.components ?? 'auto', required: codecov.required ?? true, wiring: codecov.ci?.wiring ?? 'required' },
44
+ dependabot: { ecosystems: [...(dependabot.ecosystems ?? ['npm', 'github-actions', 'docker'])],
45
+ schedule: { interval: 'weekly', ...dependabot.schedule }, groups: { minorPatch: 'grouped', major: 'per-package', ...dependabot.groups },
46
+ ignore: [...(dependabot.ignore ?? ['@starci/*'])], security: dependabot.security ?? 'immediate' },
47
+ audit: { level: audit.level ?? 'high', omitDev: audit.omitDev ?? true, in: [...(audit.in ?? ['release-gate', 'ci'])] },
48
+ };
49
+ }
50
+
51
+ /** Enumerate a missing directory as empty; all filesystem access uses the caller's injected synchronous seam. */
52
+ function entries(dir, fs) {
53
+ try { return fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); }
54
+ catch (error) { if (error.code === 'ENOENT') return []; throw error; }
55
+ }
56
+
57
+ /** Read only encrypted envelope key names, never decrypting or returning any sealed value. */
58
+ export function sealedKeyNames(root, { fs }) {
59
+ const out = [], base = path.join(root, '.starcistacks');
60
+ for (const env of entries(base, fs).filter((entry) => entry.isDirectory())) {
61
+ for (const member of entries(path.join(base, env.name, 'secrets'), fs).filter((entry) => entry.isFile() && entry.name.endsWith('.enc'))) {
62
+ const file = `.starcistacks/${env.name}/secrets/${member.name}`;
63
+ const source = fs.readFileSync(path.join(root, file), 'utf8');
64
+ const dotenv = [...source.matchAll(/^([A-Za-z_][A-Za-z0-9_]*)=ENC\[/gm)].map((match) => match[1]).filter((key) => !key.startsWith('sops_'));
65
+ let keys = dotenv;
66
+ if (!dotenv.length) {
67
+ const doc = parseYaml(source);
68
+ keys = doc && typeof doc === 'object' && !Array.isArray(doc) ? Object.keys(doc).filter((key) => key !== 'sops' && !key.startsWith('sops_')) : [];
69
+ }
70
+ for (const key of keys) out.push({ env: env.name, member: member.name.slice(0, -4), key, file });
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+
76
+ /** Collect every static secrets and vars reference inside workflow expressions, excluding the automatic GitHub token. */
77
+ export function workflowReferences(root, { fs }) {
78
+ const out = [], dir = path.join(root, '.github', 'workflows');
79
+ for (const entry of entries(dir, fs).filter((item) => item.isFile() && /\.ya?ml$/i.test(item.name))) {
80
+ const file = `.github/workflows/${entry.name}`, source = fs.readFileSync(path.join(root, file), 'utf8');
81
+ for (const expression of source.matchAll(/\$\{\{([^}]*)/g)) {
82
+ for (const match of expression[1].matchAll(/\b(secrets|vars)\.([A-Z][A-Z0-9_]*)/g)) {
83
+ if (match[2] !== 'GITHUB_TOKEN') out.push({ file, kind: match[1], name: match[2] });
84
+ }
85
+ }
86
+ }
87
+ return out;
88
+ }
89
+
90
+ /** Service CI bindings name the shared declaration classes, including the readable legacy lists. */
91
+ export function ciNamesOfServices(doc) {
92
+ const out = [];
93
+ for (const [service, entry] of Object.entries(doc?.services ?? {})) {
94
+ const ci = entry?.ci ?? {};
95
+ if (ci.token) out.push({ service, name: ci.token, kind: 'secret' });
96
+ if (ci.hostVar) out.push({ service, name: ci.hostVar, kind: 'var' });
97
+ if (ci.organizationVar) out.push({ service, name: ci.organizationVar, kind: 'var' });
98
+ for (const item of list(ci.secrets)) out.push({ service, name: nameOf(item), kind: 'secret' });
99
+ for (const item of list(ci.vars)) out.push({ service, name: nameOf(item), kind: 'var' });
100
+ }
101
+ return out;
102
+ }
@@ -55,7 +55,7 @@ export function formatCoverageReport(report) {
55
55
  }
56
56
 
57
57
  /** Standalone coordinator entry point: exit 0 proof passed, 2 proof or invocation refused. */
58
- export function coverageFromMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), run = coverageFrom } = {}) {
58
+ function coverageFromMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text), run = coverageFrom } = {}) {
59
59
  try {
60
60
  const opts = parse(argv);
61
61
  const report = run({ repoRoot: path.resolve(cwd, opts.cwd ?? '.'), coverageFrom: opts.coverageFrom });
@@ -17,7 +17,7 @@ import { byCodeUnit } from '../lib/list.mjs';
17
17
  import { trimTrailingSlashes } from './trailing-slashes.mjs';
18
18
 
19
19
  /** The component that holds the shared infrastructure: the platform tier and every capability more than one service app composes. */
20
- export const PLATFORM_COMPONENT = 'platform';
20
+ const PLATFORM_COMPONENT = 'platform';
21
21
  const SERVICE_APP_KINDS = new Set(['api', 'worker']);
22
22
  /** `text` with every `<placeholder>` (a `<`, at least one character, the next `>`) replaced by `*`; a `<` with no closing `>` stays. */
23
23
  export const star = (text) => {
@@ -135,7 +135,7 @@ function commandEnvironment() {
135
135
  }
136
136
 
137
137
  /** Run an installed app command in a sealed temporary copy, returning its exit code and preserving report artifacts. */
138
- export async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
138
+ async function runMaterializedCommand(repoRoot, command, args = [], { run = runProgram } = {}) {
139
139
  return withMaterializedExample(repoRoot, '.', (app) => {
140
140
  const launch = commandLaunch(command, args);
141
141
  const result = run(launch.file, launch.args, { cwd: app, env: commandEnvironment(), stdio: 'inherit', windowsHide: true });
@@ -56,8 +56,6 @@ export const unitRolesOf = (manifest) => manifest.ruleParams.be.unitRoles;
56
56
  /** The roles that carry logic (ruleParams.be.logicRoles): measured at 100 per file inside a coverage-required slot. */
57
57
  export const logicRolesOf = (manifest) => manifest.ruleParams.be.logicRoles;
58
58
 
59
- /** The roles a feature-tier file is judged thin by (ruleParams.be.thinRoles, R203 BE_FEATURE_THIN). */
60
- export const thinRolesOf = (manifest) => manifest.ruleParams.be.thinRoles;
61
59
 
62
60
  /** Shape problems of ruleParams.be.unitRoles: unique roles of the suffix vocabulary, each spec `<role>.spec`. */
63
61
  export function unitRolesProblems(be) {
@@ -86,7 +84,7 @@ export const MANIFEST_KINDS = [APP_KIND, RUNTIME_KIND];
86
84
  export const manifestKind = (m) => (isPlainObject(m) && m.kind !== undefined ? m.kind : APP_KIND);
87
85
  const strList = (v) => stringList(v);
88
86
 
89
- const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exemplifiedBy'];
87
+ const APP_SLOT_KEYS = ['id', 'profiles', 'path', 'presence', 'tracked', 'tier', 'tests', 'owner', 'appKind', 'minInstances', 'requiredWhen', 'requiredInstances', 'requires', 'pattern', 'trigger', 'allows', 'forbids', 'layers', 'kinds', 'roles', 'composedBy', 'budget', 'managedBy', 'liteManagedBy', 'rules', 'goesTo', 'why', 'since', 'perConnection', 'parent', 'editions', 'editionWhy', 'litePresence', 'lite', 'provider', 'coverage', 'entries', 'outcomeHome', 'templateNa', 'exampleNa', 'exemplifiedBy'];
90
88
  /** The fields a slot's `lite` overlay may hold: the same keys it would carry in the slot body, resolved under edition lite. */
91
89
  const LITE_OVERLAY_KEYS = ['path', 'requires', 'allows', 'forbids', 'minInstances', 'requiredInstances', 'why'];
92
90
  /** A runtime slot has no app kind, side composition, layer or managed template; it may name the generator of a generated copy. */
@@ -220,6 +218,8 @@ function slotCoverageEvidenceProblems(slot, at) {
220
218
  const bad = [];
221
219
  if (slot.templateNa !== undefined && !(typeof slot.templateNa === 'string' && slot.templateNa.trim()))
222
220
  bad.push(`${at}: templateNa must be non-empty text`);
221
+ if (slot.exampleNa !== undefined && !(typeof slot.exampleNa === 'string' && slot.exampleNa.trim()))
222
+ bad.push(`${at}: exampleNa must be non-empty text`);
223
223
  if (slot.exemplifiedBy === undefined) return bad;
224
224
  const refs = slot.exemplifiedBy;
225
225
  const valid = Array.isArray(refs) && refs.length > 0 && refs.every(slotEvidenceRefValid)
@@ -1,6 +1,6 @@
1
1
  // param-names.mjs - the one shape of `ruleParams.be.paramNames` of knowledge/hfs/slots.yaml (R85 injected-param-name): a non-empty list of unique
2
2
  // entries `{type | typeSuffix, names, nameSuffix?}`. The loader of scripts/hfs/slots.mjs calls it; the schema (modules/schemas/hfs-slots.schema.yaml)
3
- // states the same shape and tests/hfs/hfs-slots.spec.mjs proves the two agree.
3
+ // states the same shape.
4
4
  import { isPlainObject } from '../../engine/plain-object.mjs';
5
5
 
6
6
  const UPPER = /^[A-Z][A-Za-z0-9]*$/u;
@@ -16,7 +16,7 @@ const RULE_KINDS = Object.freeze(['codemod', 'lint', 'check', 'design']);
16
16
  const FINDING_CODE = /^[A-Z][A-Z0-9]*(_[A-Z0-9]+)+$/;
17
17
  const ENFORCER_ID = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
18
18
  const FILE_ENFORCERS = new Set(['machine', 'hfs', 'work-validate', 'sonar', 'runtime']);
19
- const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa']);
19
+ const RULE_KEYS = new Set(['id', 'code', 'law', 'scope', 'kinds', 'gates', 'failureCodes', 'editions', 'codeEditions', 'enforcers', 'cases', 'docs', 'exemplifiedBy', 'proves', 'witness', 'editionWhy', 'amendedBy', 'templateNa', 'specNa', 'exampleNa']);
20
20
  const CASE_KEYS = new Set(['id', 'date', 'source', 'ruling', 'quote', 'supersedes', 'status', 'staleWhen', 'amendedBy']);
21
21
  const CASE_DATE = /^\d{4}-\d{2}-\d{2}$/;
22
22
  const ENFORCER_KEYS = new Set(['kind', 'id', 'status', 'at', 'editions', 'editionWhy']);
@@ -279,6 +279,8 @@ function checkRule(rule, index, catalog, codeOwner, bad) {
279
279
  if (rule.amendedBy !== undefined) caseReferenceProblems(rule.amendedBy, `${label}.amendedBy`, bad);
280
280
  optionalText(rule, 'editionWhy', label, bad);
281
281
  optionalText(rule, 'templateNa', label, bad);
282
+ optionalText(rule, 'specNa', label, bad);
283
+ optionalText(rule, 'exampleNa', label, bad);
282
284
  for (const key of ['docs', 'exemplifiedBy', 'proves']) evidenceProblems(rule, key, label, bad);
283
285
  witnessProblems(rule, label, bad);
284
286
  enumListProblems(rule, label, 'kinds', RULE_KINDS, bad);
@@ -32,17 +32,25 @@ import { stacksIngressFindings } from './stacks-ingress.mjs';
32
32
  import { stacksLiteBackingFindings } from './stacks-lite-backing.mjs';
33
33
  import { stackToolingFindings } from './stack-tooling.mjs';
34
34
  import { stacksDeployFindings } from './stacks-deploy.mjs';
35
+ import { ciCustodyFindings } from './ci-custody.mjs';
36
+ import { workflowContextsFindings } from './workflow-contexts.mjs';
37
+ import { feBuildArgsFindings } from './fe-build-args.mjs';
35
38
  // finders-import-end
36
39
 
37
40
  /** The failure codes the finders above emit, so `starci app check` ships their labels (check.mjs adds them to CHECK_CODES). Append-only; the coordinator regenerates it at merge time. */
38
41
  export const A15_CODES = [
39
42
  // codes-begin
43
+ 'FE_PUBLIC_BUILD_ARG_MISSING',
44
+ 'HFS_WORKFLOW_CONTEXT',
45
+ 'CI_SECRET_SEALED', 'APP_SECRET_IN_CI', 'CI_DECLARATION_DRIFT',
40
46
  'BACKUP_PROOF_MISSING', 'BACKUP_UNDECLARED', 'BE_APP_BOOT_PROOF_MISSING', 'BE_APP_ENV_REQUIRED', 'BE_APP_MODULE_LIST_INLINE', 'BE_LITE_PROOF_LANE_MISSING', 'DB_API_EXPOSED', 'DB_ARCHIVE_MUTATED', 'DB_BASELINE_SHAPE', 'DB_CRON_OWNED', 'DB_EXTENSION_ALLOWED', 'DB_MIGRATION_DESTRUCTIVE', 'DB_PGMQ_DECLARED', 'DB_VAULT_WRITE', 'ENV_DUPLICATE', 'ENV_EXAMPLE_DRIFT', 'ENV_PUBLIC_SEALED', 'ENV_REQUIRED_UNSET', 'ENV_SEALED_UNREAD', 'ENV_VALUE_SHAPE', 'FE_STACK_CORE_MISSING', 'FE_STACK_UNLISTED', 'HFS_AGE_KEY_CUSTODY', 'HFS_APP_LEXICON', 'HFS_APP_WORKFLOW_TRIGGER', 'HFS_ASSET_KIND', 'HFS_ASSET_OVERSIZE', 'HFS_AUDIT_STEP_MISSING', 'HFS_CANON_COPY_DRIFT', 'HFS_CI_TOOL_INSTALL', 'HFS_COVERAGE_BELOW', 'HFS_DB_DEPLOY_UNGUARDED', 'HFS_DEPENDABOT_MISSING', 'HFS_DEPLOY_COMPOSE_ON_VPS', 'HFS_DEPLOY_NO_LOGIC', 'HFS_DEPLOY_SCHEDULER', 'HFS_DEPLOY_WORKFLOW', 'HFS_DOC_COMMAND_UNKNOWN', 'HFS_DOC_ENV_UNKNOWN', 'HFS_DOC_PATH_MISSING', 'HFS_DOC_RULE_UNKNOWN', 'HFS_FEATURE_FILE_BUDGET', 'HFS_HOST_CRON', 'HFS_RELEASE_TAG_MISMATCH', 'HFS_SBOM_MISSING', 'HFS_SCHEDULE_UNDECLARED', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_SCRIPT_XTRACE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SEED_PII', 'HFS_SERVICE_ROLE_SCOPE', 'HFS_UNUSED_SYMBOL', 'HFS_WORKFLOW_PIN', 'STACKS_DEPLOY_CONFIG_INVALID', 'STACKS_DNS_UNDECLARED', 'STACKS_HEALTHCHECK_MISSING', 'STACKS_HOST_UNROUTED', 'STACKS_INGRESS_UNDECLARED', 'STACKS_INGRESS_UNKNOWN', 'STACKS_LITE_SELFHOSTED_DB', 'STACKS_MODEL_UNAVAILABLE', 'STACKS_PLACEMENT_MISSING', 'STACKS_PORT_PUBLISHED', 'STACKS_REALTIME_SCALED', 'STACKS_RELEASE_UNDECLARED', 'STACKS_SECOND_INGRESS', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'STACKS_SIDECAR_DB_UNSUPPORTED', 'STACKS_SIDECAR_VOLUME_UNDECLARED', 'STACKS_TLS_UNDECLARED', 'STACKS_UPDATE_POLICY_MISSING', 'STACK_TOOLING_REIMPLEMENTED',
41
47
  // codes-end
42
48
  ];
43
49
 
44
- export const FINDERS = [
50
+ const FINDERS = [
45
51
  // finders-begin
52
+ feBuildArgsFindings,
53
+ workflowContextsFindings,
46
54
  featureFileFindings,
47
55
  lexiconFindings,
48
56
  assetsFindings,
@@ -72,6 +80,7 @@ export const FINDERS = [
72
80
  stacksLiteBackingFindings,
73
81
  stackToolingFindings,
74
82
  stacksDeployFindings,
83
+ ciCustodyFindings,
75
84
  // finders-end
76
85
  ];
77
86
 
@@ -0,0 +1,10 @@
1
+ // app-workflow-findings.mjs - app-root workflow scope shared by deployment and expression-context rules.
2
+ import { workflowFindings } from '../runtime-rules/workflow-source.mjs';
3
+ import { readText } from './read.mjs';
4
+
5
+ /** Judge tracked workflows only at full/lite app roots, preserving each caller's duplicate-file policy. */
6
+ export function appWorkflowFindings({ repoRoot, files, all = files, repo, isRoot, inScope = () => true }, perWorkflow, { dedupe = false } = {}) {
7
+ if (isRoot === false || repo?.kind !== 'app' || !['lite', 'full'].includes(repo.edition)) return [];
8
+ const selected = dedupe ? [...new Set(all)] : all;
9
+ return workflowFindings({ files: selected.filter((file) => inScope(file)), read: (file) => readText(repoRoot, file) }, perWorkflow);
10
+ }
@@ -8,7 +8,7 @@ import { found } from './read.mjs';
8
8
 
9
9
  export const HFS_ASSET_OVERSIZE = 'HFS_ASSET_OVERSIZE';
10
10
  export const HFS_ASSET_KIND = 'HFS_ASSET_KIND';
11
- export const DEFAULT_ASSET_BYTES = 512000;
11
+ const DEFAULT_ASSET_BYTES = 512000;
12
12
  const BUILD_EXTENSIONS = ['.ttf', '.otf', '.woff', '.woff2', '.eot', '.png', '.svg', '.webp', '.jpg', '.jpeg', '.gif', '.ico', '.avif', '.bmp', '.tif', '.tiff'];
13
13
  const MEDIA_EXTENSIONS = new Set(['.mp3', '.mp4', '.wav', '.ogg', '.oga', '.opus', '.flac', '.aac', '.m4a', '.wma', '.aiff', '.aif', '.mid', '.midi', '.mov', '.m4v', '.webm', '.avi', '.mkv', '.mpeg', '.mpg', '.wmv', '.flv', '.3gp']);
14
14
  const IMAGE_EXTENSIONS = new Set(BUILD_EXTENSIONS.filter((ext) => !['.ttf', '.otf', '.woff', '.woff2', '.eot'].includes(ext)));
@@ -0,0 +1,104 @@
1
+ // ci-custody.mjs - R296-R298: CI declarations, sealed app keys and workflow reads agree by consumer, without decrypting or contacting GitHub.
2
+ import fs from 'node:fs';
3
+ import { list } from '../../lib/list.mjs';
4
+ import { findStackDeclaration, STACK_ROOT } from '../../lib/stack-declaration.mjs';
5
+ import { readCiDeclaration, sealedKeyNames, workflowReferences, ciNamesOfServices } from '../ci-declaration.mjs';
6
+ import { found } from './read.mjs';
7
+
8
+ const DECLARATION = `${STACK_ROOT}/application-stacks.yaml`;
9
+ const named = (entry) => typeof entry.name === 'string' && /^[A-Z][A-Z0-9_]*$/.test(entry.name);
10
+ const identity = (kind, name) => `${kind}:${name}`;
11
+ const atCi = (section) => `${DECLARATION}#ci.${section}`;
12
+
13
+ /** A CI or Dependabot secret must never also live in a sealed app member. */
14
+ function sealedFindings(ci, sealed) {
15
+ const names = new Set([...ci.secrets, ...ci.dependabotSecrets].filter(named).map((entry) => entry.name));
16
+ return sealed.filter(({ key }) => names.has(key)).map(({ key, file }) => found(
17
+ 'CI_SECRET_SEALED', file,
18
+ `${key} is a CI secret (GitHub reads it) but it is sealed in ${file} for app containers; one key, one class: remove it from the member (starci app secret unset ...) and set it with starci app ci secret set ${key}`,
19
+ ));
20
+ }
21
+
22
+ /** A source selects sealed keys only from its own member and environment. */
23
+ function sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen }) {
24
+ for (const entry of sealed) {
25
+ if (entry.env !== environment || entry.member !== source?.member || !names.has(entry.key)) continue;
26
+ if (source.keys !== undefined && !list(source.keys).includes(entry.key)) continue;
27
+ const id = `${entry.file}:${grant.service}:${entry.key}`;
28
+ if (seen.has(id)) continue;
29
+ seen.add(id);
30
+ findings.push(found('APP_SECRET_IN_CI', entry.file,
31
+ `${entry.key} is read by the ${grant.service} container at runtime through the ${environment} deploy grant; keep it in the .enc and drop it from ci`));
32
+ }
33
+ }
34
+
35
+ /** Only keys actually selected by a grant, in that grant's environment and member, are runtime consumers. */
36
+ function runtimeFindings(doc, ci, sealed) {
37
+ const names = new Set([...ci.secrets, ...ci.vars].filter(named).map((entry) => entry.name));
38
+ const findings = [], seen = new Set();
39
+ for (const [environment, spec] of Object.entries(doc.environments ?? {})) {
40
+ for (const grant of list(spec?.deploy?.secrets?.grants)) {
41
+ if (typeof grant?.service !== 'string') continue;
42
+ for (const source of list(grant.from)) sourceRuntimeFindings(source, grant, environment, { sealed, names, findings, seen });
43
+ }
44
+ }
45
+ return findings;
46
+ }
47
+
48
+ /** Workflow reads are class-sensitive; legacy lists remain readable, but an absent ci block always signals migration once per name. */
49
+ function workflowFindings(doc, declared, references) {
50
+ const hasCi = doc.ci !== undefined;
51
+ const allowed = new Set(declared.map(({ kind, name }) => identity(kind, name)));
52
+ if (hasCi) {
53
+ for (const entry of ciNamesOfServices(doc).filter(named)) {
54
+ const legacy = list(doc.services?.[entry.service]?.ci?.[`${entry.kind}s`]);
55
+ if (legacy.some((item) => (typeof item === 'string' ? item : item?.name) === entry.name)) allowed.add(identity(`${entry.kind}s`, entry.name));
56
+ }
57
+ }
58
+ const findings = [], seen = new Set();
59
+ for (const { file, kind, name } of references) {
60
+ if (allowed.has(identity(kind, name))) continue;
61
+ const id = hasCi ? `${file}:${kind}:${name}` : name;
62
+ if (seen.has(id)) continue;
63
+ seen.add(id);
64
+ findings.push(found('CI_DECLARATION_DRIFT', file,
65
+ `${kind}.${name} is read by this workflow but is not declared in ci.${kind}; declare its name, scope and consumer in ${DECLARATION}`));
66
+ }
67
+ return findings;
68
+ }
69
+
70
+ /** Dependabot has its own store and consumer, so only Actions secrets and vars require a workflow read. */
71
+ function driftFindings(doc, ci, references) {
72
+ const declared = [
73
+ ...ci.secrets.filter(named).map((entry) => ({ ...entry, kind: 'secrets' })),
74
+ ...ci.vars.filter(named).map((entry) => ({ ...entry, kind: 'vars' })),
75
+ ];
76
+ const findings = workflowFindings(doc, declared, references);
77
+ if (doc.ci === undefined) return findings;
78
+ const reads = new Set(references.map(({ kind, name }) => identity(kind, name)));
79
+ for (const { kind, name, optional } of declared) {
80
+ if (!optional && !reads.has(identity(kind, name))) findings.push(found('CI_DECLARATION_DRIFT', atCi(kind),
81
+ `${name} is declared in ci.${kind} as non-optional but no workflow reads it; remove the declaration, mark it optional or wire its workflow consumer`));
82
+ }
83
+ const names = new Set(declared.map(({ kind, name }) => identity(kind, name)));
84
+ for (const [service, entry] of Object.entries(doc.services ?? {})) {
85
+ for (const [field, kind] of [['token', 'secrets'], ['hostVar', 'vars']]) {
86
+ const name = entry?.ci?.[field];
87
+ if (typeof name !== 'string' || names.has(identity(kind, name))) continue;
88
+ findings.push(found('CI_DECLARATION_DRIFT', atCi(kind),
89
+ `services.${service}.ci.${field} names ${name}, which is not declared in ci.${kind}; declare the shared CI name`));
90
+ }
91
+ }
92
+ return findings;
93
+ }
94
+
95
+ /** Offline CI custody findings of an app root, in both lite and full editions. */
96
+ export function ciCustodyFindings({ repoRoot, repo, isRoot }) {
97
+ if (repo?.kind !== 'app' || !['lite', 'full'].includes(repo.edition) || isRoot === false) return [];
98
+ const { doc } = findStackDeclaration(repoRoot);
99
+ if (!doc) return [];
100
+ const ci = readCiDeclaration(doc);
101
+ const sealed = sealedKeyNames(repoRoot, { fs });
102
+ const references = workflowReferences(repoRoot, { fs });
103
+ return [...sealedFindings(ci, sealed), ...runtimeFindings(doc, ci, sealed), ...driftFindings(doc, ci, references)];
104
+ }
@@ -10,7 +10,7 @@ import { found } from './read.mjs';
10
10
  const CLI_REQUIRED = 'BE_CLI_REQUIRED';
11
11
 
12
12
  /** The one name of the cli app, its kind, and what its image is called below it. */
13
- export const CLI_APP = 'cli';
13
+ const CLI_APP = 'cli';
14
14
  const CLI_KIND = 'cli';
15
15
  const IMAGE = 'Dockerfile';
16
16
  const CLI_FEATURE_SLOT = 'be.cli';
@@ -14,7 +14,7 @@ import { isPlainObject } from '../../../engine/plain-object.mjs';
14
14
  import { found, readJson } from './read.mjs';
15
15
  import { byCodeUnit } from '../../lib/list.mjs';
16
16
 
17
- export const CONTRACT_BREAKING = 'BE_CONTRACT_BREAKING';
17
+ const CONTRACT_BREAKING = 'BE_CONTRACT_BREAKING';
18
18
 
19
19
  const CONTRACTS = /^be\/contracts\/([^/]+)\/events\.pin\.json$/;
20
20
 
@@ -15,7 +15,7 @@ import { safeRemove } from '../../api/fs/safe-remove.mjs';
15
15
  import { makeTempDir } from '../../api/fs/make-temp-dir.mjs';
16
16
  import { loadTypescript } from '../../lib/package-at.mjs';
17
17
 
18
- export const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
18
+ const CONTRACT_SNAPSHOT_DRIFT = 'HFS_CONTRACT_SNAPSHOT_DRIFT';
19
19
  const GRAPHQL_TRANSPORT_SLOT = 'be.transport.graphql';
20
20
  const SNAPSHOT_OF = { graphql: 'schema.graphql', json: 'openapi.json' };
21
21
  const GRAPHQL_PLATFORM = /^src\/modules\/platform\/graphql(?:\/|\.ts$|$)/;
@@ -1,11 +1,11 @@
1
1
  // R251: product workflow custody, immutable dependencies and release-only triggers, in both editions.
2
- import { parseWorkflow, workflowFindings } from '../runtime-rules/workflow-source.mjs';
3
- import { found, readText } from './read.mjs';
2
+ import { parseWorkflow } from '../runtime-rules/workflow-source.mjs';
3
+ import { found } from './read.mjs';
4
+ import { appWorkflowFindings } from './app-workflow-findings.mjs';
4
5
  import { isPlainObject as record } from '../../../engine/plain-object.mjs';
5
6
  import { workflowEventOptions } from '../../lib/workflow-events.mjs';
6
7
 
7
8
  const DEPLOY = 'HFS_DEPLOY_WORKFLOW';
8
- const PIN = 'HFS_WORKFLOW_PIN';
9
9
  const TOOL = 'HFS_CI_TOOL_INSTALL';
10
10
  const TRIGGER = 'HFS_APP_WORKFLOW_TRIGGER';
11
11
  // Only the age identity and SSH transport credentials belong in the CI secret store.
@@ -85,18 +85,6 @@ function triggerFindings(doc, refuse) {
85
85
  return findings;
86
86
  }
87
87
 
88
- function actionFindings(doc, refuse) {
89
- const findings = [];
90
- for (const { key, value } of entries(doc)) {
91
- if (key !== 'uses') continue;
92
- const local = typeof value === 'string' && /^\.\/[\w./-]+$/.test(value);
93
- const remote = typeof value === 'string' && /^[\w.-]+\/[\w.-]+(?:\/[\w./-]+)?@[a-f\d]{40}$/i.test(value);
94
- const container = typeof value === 'string' && /^docker:\/\/[^\s@]+@sha256:[a-f\d]{64}$/i.test(value);
95
- if (!local && !remote && !container) findings.push(refuse(PIN, 'every remote uses entry needs a full 40-hex commit SHA (a container action needs a sha256 digest)'));
96
- }
97
- return findings;
98
- }
99
-
100
88
  function secretProblems(doc) {
101
89
  const problems = [];
102
90
  if (entries(doc).some(({ key, value }) => key === 'secrets' && value === 'inherit')) problems.push('inherited secrets are forbidden');
@@ -209,11 +197,10 @@ function perWorkflow({ path: file, text }) {
209
197
  const refuse = (code, message) => found(code, file, `${code} ${file}: ${message}`);
210
198
  const doc = parseWorkflow(text);
211
199
  if (!record(doc)) return [refuse(TRIGGER, 'the workflow must be valid YAML with a release-only on block'), ...(deployName(file) ? [refuse(DEPLOY, 'the deploy workflow must be valid YAML')] : [])];
212
- return [...triggerFindings(doc, refuse), ...actionFindings(doc, refuse), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
200
+ return [...triggerFindings(doc, refuse), ...deploymentFindings(doc, file, refuse), ...toolFindings(doc, refuse)];
213
201
  }
214
202
 
215
203
  /** Synchronous R251 findings over the tracked workflows of a lite/full app. */
216
- export function deployWorkflowFindings({ repoRoot, files, all = files, repo, isRoot, inScope = () => true }) {
217
- if (isRoot === false || repo?.kind !== 'app' || !['lite', 'full'].includes(repo.edition)) return [];
218
- return workflowFindings({ files: all.filter((file) => inScope(file)), read: (file) => readText(repoRoot, file) }, perWorkflow);
204
+ export function deployWorkflowFindings(ctx) {
205
+ return appWorkflowFindings(ctx, perWorkflow);
219
206
  }