@starci/hfs 5.0.4 → 5.0.6

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 (72) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/README.md +22 -2
  3. package/cache/app-families.d.mts +8 -5
  4. package/cache/app-families.mjs +11 -9
  5. package/cache/hfs-family.d.mts +2 -0
  6. package/cache/hfs-family.mjs +4 -4
  7. package/cache/run.d.mts +2 -0
  8. package/cache/run.mjs +2 -5
  9. package/cache/session.mjs +1 -1
  10. package/cache/test-family.d.mts +6 -4
  11. package/cache/test-family.mjs +22 -14
  12. package/cache/types-family.d.mts +5 -3
  13. package/cache/types-family.mjs +9 -8
  14. package/cache/wiring-family.d.mts +4 -2
  15. package/cache/wiring-family.mjs +5 -5
  16. package/ci/cli.d.mts +7 -1
  17. package/ci/cli.mjs +37 -6
  18. package/ci/github.d.mts +1 -0
  19. package/ci/secret.d.mts +10 -1
  20. package/ci/secret.mjs +26 -6
  21. package/ci/verify.d.mts +9 -1
  22. package/ci/verify.mjs +5 -3
  23. package/emit/contracts.d.mts +12 -9
  24. package/emit/contracts.mjs +29 -11
  25. package/lint/chunk.mjs +11 -2
  26. package/lint/run.d.mts +2 -1
  27. package/lint/run.mjs +26 -29
  28. package/package.json +1 -1
  29. package/release/candidate-ci.mjs +1 -1
  30. package/release/gate.d.mts +1 -0
  31. package/release/gate.mjs +76 -18
  32. package/release/index.d.mts +1 -1
  33. package/release/index.mjs +7 -3
  34. package/runtime/knowledge/grammars/index.yaml +11 -1
  35. package/runtime/knowledge/hfs/canon-pins.yaml +6 -6
  36. package/runtime/knowledge/hfs/rules.yaml +2 -2
  37. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  38. package/runtime/modules/schemas/application-stacks.schema.yaml +10 -7
  39. package/runtime/scripts/api/node/run-node.d.mts +29 -0
  40. package/runtime/scripts/api/node/run-node.mjs +19 -0
  41. package/runtime/scripts/gates/starcistacks-ci.mjs +3 -1
  42. package/runtime/scripts/gates/starcistacks-form.d.mts +1 -0
  43. package/runtime/scripts/gates/starcistacks.d.mts +1 -0
  44. package/runtime/scripts/gates/starcistacks.mjs +22 -10
  45. package/runtime/scripts/hfs/app-wiring.d.mts +8 -2
  46. package/runtime/scripts/hfs/app-wiring.mjs +27 -7
  47. package/runtime/scripts/hfs/architecture/contract-bounds.d.mts +3 -0
  48. package/runtime/scripts/hfs/architecture/contract-bounds.mjs +72 -0
  49. package/runtime/scripts/hfs/architecture/index.mjs +1 -1
  50. package/runtime/scripts/hfs/architecture/tiers.d.mts +2 -1
  51. package/runtime/scripts/hfs/architecture/tiers.mjs +45 -4
  52. package/runtime/scripts/hfs/ci-declaration.d.mts +47 -1
  53. package/runtime/scripts/hfs/ci-declaration.mjs +115 -3
  54. package/runtime/scripts/hfs/repo-identity.d.mts +5 -1
  55. package/runtime/scripts/hfs/repo-identity.mjs +15 -10
  56. package/runtime/scripts/hfs/rules/ci-custody.mjs +21 -32
  57. package/runtime/scripts/hfs/rules/contract.d.mts +11 -3
  58. package/runtime/scripts/hfs/rules/contract.mjs +50 -11
  59. package/runtime/scripts/hfs/rules/env-parity.mjs +15 -6
  60. package/runtime/scripts/hfs/secret-standard.d.mts +1 -0
  61. package/runtime/scripts/hfs/secret-standard.mjs +25 -4
  62. package/runtime/scripts/hfs/secret-store.d.mts +5 -0
  63. package/runtime/scripts/hfs/secret-store.mjs +31 -0
  64. package/src/main.d.mts +3 -1
  65. package/src/main.mjs +14 -11
  66. package/sync/ci-commands.mjs +2 -1
  67. package/sync/quality-services.mjs +3 -2
  68. package/sync/readme-badges.mjs +1 -1
  69. package/sync/sonar-key.d.mts +2 -3
  70. package/sync/sonar-key.mjs +7 -14
  71. package/templates/app/skeleton/.starcistacks/application-stacks.yaml +7 -3
  72. package/templates/app/skeleton-lite/.starcistacks/application-stacks.yaml +7 -3
package/release/gate.mjs CHANGED
@@ -7,7 +7,9 @@ import { createHash } from 'node:crypto';
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
10
- import { qualityServices } from '../runtime/scripts/hfs/ci-declaration.mjs';
10
+ import { qualityServices, serviceBindings, projectServiceCredential, serviceCredentialNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
11
+ import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
12
+ import { serviceCredentialFile, serviceCredentialValue } from '../runtime/scripts/hfs/secret-store.mjs';
11
13
  import { openHfs } from '../runtime/scripts/hfs/slots.mjs';
12
14
  import { firstLine, lines, tool } from '../stack/context.mjs';
13
15
  import { failure, refusal } from '../stack/errors.mjs';
@@ -74,21 +76,72 @@ function auditGate(audit) {
74
76
  throw refusal(RELEASE_CODE, 'services.audit.level must be an npm audit level');
75
77
  return { name: 'audit', command: ['npm', 'audit', ...(audit.omitDev ? ['--omit=dev'] : []), `--audit-level=${audit.level}`], summary: null, env: {} };
76
78
  }
77
- /** A requested local Sonar scan uses only the declared host and token variable; the token never enters argv. */
78
- function sonarGate(services, env) {
79
+ /** The CI execution copy is job-local; a native owner run opens the selected product credential only when executed. */
80
+ function sonarGate(services, ctx, execute) {
81
+ const env = ctx.env;
79
82
  const configured = services?.sonar;
80
- const sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: 'https://sonarcloud.io' } : configured;
83
+ let sonar = configured?.provider === 'sonarcloud' ? { ...configured, host: configured.host || 'https://sonarcloud.io' } : configured;
81
84
  const tokenName = sonar?.token;
82
- if (!sonar?.host || !ENV_KEY.test(tokenName) || !env[tokenName])
83
- throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and set its ci.token environment variable');
84
- const command = ['npx', '--yes', '@sonar/scan@4.3.0', `-Dsonar.host.url=${sonar.host}`];
85
+ if (!sonar || !ENV_KEY.test(tokenName))
86
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare an enabled services.sonar host and ci.token selector');
87
+ let token;
88
+ let privateHost;
89
+ if (env.GITHUB_ACTIONS === 'true') {
90
+ token = env[tokenName];
91
+ if (!token)
92
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the declared Sonar CI execution copy is missing');
93
+ if (sonar.hostCredential) {
94
+ privateHost = env[sonar.hostVar || 'SONAR_HOST_URL'];
95
+ sonar = { ...sonar, host: privateHost ?? null, privateHost: true };
96
+ }
97
+ }
98
+ else {
99
+ const doc = services.declaration;
100
+ const repository = repositoryName(ctx.cwd, { env: identityQueryEnvironment(env, serviceCredentialNames(doc)) });
101
+ const selected = serviceBindings(doc, repository);
102
+ const binding = selected.bindings.find((item) => item.service === 'sonar' && item.name === tokenName);
103
+ if (selected.problems.length || !binding)
104
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'declare one explicit Sonar project credential binding in this product');
105
+ try {
106
+ serviceCredentialFile(ctx.cwd, binding);
107
+ if (execute)
108
+ token = serviceCredentialValue(ctx.cwd, binding, ctx.sops);
109
+ if (sonar.hostCredential) {
110
+ const endpoint = projectServiceCredential(doc, 'sonar', sonar.hostCredential, repository);
111
+ if (!endpoint)
112
+ throw new Error('invalid endpoint reference');
113
+ serviceCredentialFile(ctx.cwd, endpoint);
114
+ if (execute)
115
+ privateHost = serviceCredentialValue(ctx.cwd, endpoint, ctx.sops);
116
+ sonar = { ...sonar, host: privateHost ?? null, privateHost: true };
117
+ }
118
+ }
119
+ catch {
120
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the selected Sonar project custody is missing or cannot be opened');
121
+ }
122
+ }
123
+ if (!sonar.host && !(sonar.hostCredential && !execute))
124
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the declared Sonar connection URL is missing');
125
+ if (sonar.host) {
126
+ try {
127
+ const url = new URL(sonar.host);
128
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password)
129
+ throw new Error('invalid URL');
130
+ }
131
+ catch {
132
+ throw refusal('RELEASE_SONAR_UNAVAILABLE', 'the Sonar connection must be HTTP(S), with login kept in separate sealed credential keys');
133
+ }
134
+ }
135
+ const command = ['npx', '--yes', '@sonar/scan@4.3.0', ...(sonar.privateHost ? [] : [`-Dsonar.host.url=${sonar.host}`])];
136
+ if (sonar.projectKey)
137
+ command.push(`-Dsonar.projectKey=${sonar.projectKey}`);
85
138
  const organization = sonar.organization ?? (sonar.organizationVar ? env[sonar.organizationVar] : null);
86
139
  if (organization)
87
140
  command.push(`-Dsonar.organization=${organization}`);
88
141
  if (!command.every((word) => TOKEN.test(word)))
89
142
  throw refusal('RELEASE_SONAR_UNAVAILABLE', 'Sonar host and organization must be plain command arguments');
90
- return { name: 'sonar', command, summary: null, env: { SONAR_TOKEN: env[tokenName] }, provider: sonar,
91
- credentialNames: [...new Set(['SONAR_TOKEN', tokenName])] };
143
+ return { name: 'sonar', command, summary: null, env: { ...(token === undefined ? {} : { SONAR_TOKEN: token }), ...(privateHost ? { SONAR_HOST_URL: privateHost } : {}) }, provider: sonar,
144
+ credentialNames: [...new Set(['SONAR_TOKEN', tokenName, ...serviceCredentialNames(services.declaration), ...(sonar.hostCredential ? ['SONAR_HOST_URL', sonar.hostVar] : [])].filter((name) => typeof name === 'string'))] };
92
145
  }
93
146
  function gateProblems(entry, at, seen) {
94
147
  const found = [];
@@ -261,7 +314,7 @@ async function sonarAvailability(ctx, gate) {
261
314
  }
262
315
  if (up)
263
316
  return null;
264
- return { pass: false, detail: `${SONAR_UNREACHABLE}: ${sonar.host} did not report UP within ten seconds` };
317
+ return { pass: false, detail: `${SONAR_UNREACHABLE}: ${sonar.privateHost ? 'the declared private host' : sonar.host} did not report UP within ten seconds` };
265
318
  }
266
319
  /** An unreachable Sonar host stays a red row with a real log and no scan invocation. */
267
320
  export async function runAvailableGate(ctx, gate, options) {
@@ -292,12 +345,18 @@ async function judgeQualityGate(ctx, gate, row) {
292
345
  return row;
293
346
  const started = ctx.now();
294
347
  const verdict = await sonarQualityGate(ctx, { cwd: ctx.cwd, host: gate.provider.host, token: gate.env.SONAR_TOKEN });
295
- const failed = (verdict.conditions ?? []).map((line) => ` failed: ${line}\n`).join('');
296
- fs.appendFileSync(row.log, `\nquality gate: ${verdict.detail}\n${failed}`);
297
- return { ...row, pass: verdict.pass, detail: verdict.detail, seconds: row.seconds + Math.round((ctx.now() - started) / 1000), logSha256: sha256(row.log) };
348
+ const detail = redactGateText(gate, verdict.detail);
349
+ const failed = (verdict.conditions ?? []).map((line) => ` failed: ${redactGateText(gate, line)}\n`).join('');
350
+ fs.appendFileSync(row.log, `\nquality gate: ${detail}\n${failed}`);
351
+ return { ...row, pass: verdict.pass, detail, seconds: row.seconds + Math.round((ctx.now() - started) / 1000), logSha256: sha256(row.log) };
352
+ }
353
+ /** Scanner output and the later server verdict share one redaction boundary. */
354
+ function redactGateText(gate, text) {
355
+ const values = gate.provider ? [gate.env.SONAR_TOKEN, ...(gate.provider.privateHost ? [gate.env.SONAR_HOST_URL] : [])].filter((value) => Boolean(value)) : [];
356
+ return values.reduce((redacted, value) => redacted.replaceAll(value, '[REDACTED]'), text);
298
357
  }
299
358
  export const withoutCredentials = (values, credentialNames) => {
300
- const selected = new Set(credentialNames);
359
+ const selected = new Set([...credentialNames, 'SOPS_AGE_KEY', 'SOPS_AGE_KEY_FILE'].map((name) => name.toUpperCase()));
301
360
  return Object.fromEntries(Object.entries(values ?? {}).filter(([key]) => !selected.has(key.toUpperCase())));
302
361
  };
303
362
  export function runGate(ctx, gate, { dir, platform, credentialNames, preface = '' }) {
@@ -308,11 +367,10 @@ export function runGate(ctx, gate, { dir, platform, credentialNames, preface = '
308
367
  const env = { ...ctx.env, ...(gate.provider ? gate.env : withoutCredentials(gate.env, credentialNames)), ...RELEASE_CACHE_ENV };
309
368
  const run = tool(ctx, command, args, { cwd: ctx.cwd, env });
310
369
  const output = `${run.stdout ?? ''}${run.stderr ?? ''}`;
311
- const text = gate.name === 'sonar' && gate.env.SONAR_TOKEN ? output.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]') : output;
370
+ const text = redactGateText(gate, output);
312
371
  fs.writeFileSync(log, preface + text);
313
372
  const verdict = judgeVerdict(run, gate, text);
314
- if (gate.name === 'sonar' && gate.env.SONAR_TOKEN)
315
- verdict.detail = verdict.detail.replaceAll(gate.env.SONAR_TOKEN, '[REDACTED]');
373
+ verdict.detail = redactGateText(gate, verdict.detail);
316
374
  const seconds = Math.round((ctx.now() - started) / 1000);
317
375
  return {
318
376
  name: gate.name, command: gate.command.join(' '), pass: verdict.pass, detail: verdict.detail, seconds,
@@ -459,7 +517,7 @@ function backfillRed(ctx, { common, urgent, failed, openTicket }) {
459
517
  export async function releaseGate(ctx, config, options = {}) {
460
518
  // Sonar is a row of the managed CI, so it is a row of the local gate whenever the app declares services.sonar; a missing token is a refusal, never a skip.
461
519
  if (config.services?.sonar)
462
- config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx.env)] };
520
+ config = { ...config, gates: [...config.gates.filter((gate) => gate.name !== 'sonar'), sonarGate(config.services, ctx, options.execute === true)] };
463
521
  const credentialNames = config.gates.find(gate => gate.provider)?.credentialNames ?? ['SONAR_TOKEN', config.services?.sonar?.token].filter(Boolean);
464
522
  ctx = Object.create(ctx, { env: { value: withoutCredentials(ctx.env, credentialNames) } });
465
523
  // The CI verdict of a release is the candidate run of its own sha, judged by `starci app release push` (candidate-ci.mjs, OWNER RULING candidate-ci 2026-10-18):
@@ -42,13 +42,13 @@ export declare function contextOf(io?: VerbIo): VerbContext;
42
42
  export declare function readDeclaration(root: string): Record<string, any>;
43
43
  /** The normalised `release:` block (tolerant of absence) with the package version: { root, version, gates, tagPrefix, defaultBranch, requireCi }. */
44
44
  export declare function loadReleaseConfig(root: string): {
45
+ services: any;
45
46
  root: string;
46
47
  version: any;
47
48
  gates: import("./gate.mjs").Gate[];
48
49
  tagPrefix: string;
49
50
  defaultBranch: string;
50
51
  requireCi: boolean;
51
- services: any;
52
52
  };
53
53
  /** Parses the flags, runs `run(ctx, flags, positional)` and prints its result; returns the exit code. Only StackError is mapped, anything else is a bug and propagates. */
54
54
  export declare function runVerb({ label, name, io, argv, spec, run, code }: {
package/release/index.mjs CHANGED
@@ -10,11 +10,13 @@ import path from 'node:path';
10
10
  import { isPlainObject as plain } from '../runtime/engine/plain-object.mjs';
11
11
  import { parseYaml } from '../runtime/engine/yaml.mjs';
12
12
  import { isRuntimeExampleApp } from '../runtime/scripts/lib/runtime-example.mjs';
13
+ import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
14
+ import { serviceCredentialNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
13
15
  import { hasEntityList } from '../db/entity-list.mjs';
14
16
  import { STACKS_DIR, STACKS_FILE } from '../stack/config.mjs';
15
17
  import { stackContext } from '../stack/context.mjs';
16
18
  import { EXIT, StackError, refusal } from '../stack/errors.mjs';
17
- import { RELEASE_CODE, VERSION, normalizeRelease, releaseGate } from './gate.mjs';
19
+ import { RELEASE_CODE, VERSION, normalizeRelease, releaseGate, withoutCredentials } from './gate.mjs';
18
20
  import { releasePush } from './push.mjs';
19
21
  import { releaseUrgentPush } from './urgent.mjs';
20
22
  const IO_KEYS = ['cwd', 'stdout', 'stderr', 'env', 'sops', 'hostIdentity'];
@@ -77,10 +79,11 @@ export function loadReleaseConfig(root) {
77
79
  if (typeof pkg.version !== 'string' || !VERSION.test(pkg.version))
78
80
  throw refusal(RELEASE_CODE, 'package.json needs a version like 1.2.3 (the release tag derives from it)');
79
81
  const declaration = readDeclaration(root);
80
- const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root), entities: hasEntityList(root) });
82
+ const { config, problems } = normalizeRelease(declaration.release, pkg.scripts, declaration.services, { example: isRuntimeExampleApp(root), entities: hasEntityList(root),
83
+ repository: repositoryName(root, { env: identityQueryEnvironment(process.env, serviceCredentialNames(declaration)) }) });
81
84
  if (!config)
82
85
  throw refusal(RELEASE_CODE, `${problems.length} problem(s) in release: ${problems.slice(0, 6).join('; ')}`);
83
- return { root, version: pkg.version, ...config };
86
+ return { root, version: pkg.version, ...config, services: { ...config.services, declaration } };
84
87
  }
85
88
  function print(ctx, name, flags, result) {
86
89
  if (flags.json)
@@ -119,6 +122,7 @@ const ACTIONS = {
119
122
  if (flags.reason !== undefined && flags.urgent !== true)
120
123
  throw refusal('RELEASE_USAGE', '--reason belongs to an urgent release (--urgent)');
121
124
  const config = loadReleaseConfig(ctx.cwd);
125
+ ctx = Object.create(ctx, { env: { value: withoutCredentials(ctx.env, serviceCredentialNames(config.services?.declaration)) } });
122
126
  if (flags.urgent === true)
123
127
  return releaseUrgentPush(ctx, config, { execute: flags.execute === true, remote: flags.remote, reason: flags.reason, role: harness?.role ?? ctx.env.STARCI_ROLE });
124
128
  return releasePush(ctx, config, { execute: flags.execute === true, remote: flags.remote });
@@ -69,7 +69,17 @@ families:
69
69
  entry: '@starci/grammar/common'
70
70
  styleEntry: '@starci/grammar/common.css'
71
71
  semanticRoles:
72
- page: { export: Page, entries: ['@starci/grammar/common', '@starci/grammar/<family>'], segment: { attribute: header, member: tabs }, bodyContainers: [PaneGrid, Pane] }
72
+ page:
73
+ export: Page
74
+ entries: ['@starci/grammar/common', '@starci/grammar/<family>']
75
+ segment: { attribute: header, member: tabs }
76
+ bodyContainers: [PaneGrid, Pane, Stack, PaneSection, Section]
77
+ # Native body transparency follows the renderer's exact context, status and intrinsic-element API.
78
+ bodyContainerConstraints:
79
+ Pane: { providesContext: pane }
80
+ Stack: { literalProps: { as: [div, header, footer] } }
81
+ PaneSection: { requiresContext: pane, absentProps: [error], literalProps: { as: [div, header, footer] } }
82
+ Section: { absentProps: [error], literalProps: { kind: [region] } }
73
83
  peers: [react, '@heroui/react', '@xyflow/react', react-aria-components]
74
84
  packageSnapshot: common/DNA.yaml
75
85
  scope: |
@@ -31,35 +31,35 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.21.8
34
+ version: 0.21.10
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  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.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.11.15
41
+ version: 3.11.17
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  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.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.9.8
48
+ version: 9.9.10
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe 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; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.21.8
55
+ version: 0.21.10
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.21.8
62
+ version: 0.21.10
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 5.0.4
109
+ version: 5.0.6
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -5107,7 +5107,7 @@ rules:
5107
5107
  - {path: packages/eslint/be/module-shape.spec.mjs, editions: [lite, full], codes: [BE_MODULE_DEFINITION_MISSING]}
5108
5108
  - id: R296
5109
5109
  code: "CI_SECRET_SEALED"
5110
- law: "A name declared in ci.secrets or ci.dependabot.secrets never names a key stored inside an app sealed .starcistacks/<env>/secrets/<member>.enc member. App runtime and CI custody are separate; checks inspect key names without decrypting or printing values."
5110
+ law: "A sealed CI credential copy names an explicit service credential bound to the selected product project, environment, member and key; required product Sonar analysis uses sonar.enc.yaml and an app runtime key never becomes a CI credential. Checks inspect names without decrypting or printing values."
5111
5111
  kinds: [check]
5112
5112
  gates: [settle, land, ci]
5113
5113
  failureCodes: ["CI_SECRET_SEALED"]
@@ -5129,7 +5129,7 @@ rules:
5129
5129
  - {path: packages/hfs/src/ci-custody.spec.mjs, editions: [lite, full], codes: [CI_SECRET_SEALED]}
5130
5130
  - id: R297
5131
5131
  code: "APP_SECRET_IN_CI"
5132
- law: "A sealed member key granted to an app container by a deploy grant never appears in ci.secrets or ci.vars. The grant environment, member and selected key determine app custody; online GitHub custody is verified by starci app ci verify."
5132
+ law: "A sealed member key selected by an app runtime secret declaration or deploy grant never appears in ci.secrets or ci.vars. The consumer environment, member and selected key determine app custody; online GitHub custody is verified by starci app ci verify."
5133
5133
  kinds: [check]
5134
5134
  gates: [settle, land, ci]
5135
5135
  failureCodes: ["APP_SECRET_IN_CI"]
@@ -1742,12 +1742,12 @@ CI_DECLARATION_DRIFT:
1742
1742
  kind: check-finding
1743
1743
 
1744
1744
  CI_SECRET_SEALED:
1745
- title: "CI name overlaps sealed app custody"
1745
+ title: "CI credential lacks exact project service custody"
1746
1746
  title_vi: "Tên CI trùng khóa bí mật đã niêm phong"
1747
- meaning_vi: "Tên CI hoặc Dependabot cũng là khóa trong thành viên .enc của ứng dụng."
1747
+ meaning_vi: "Bản sao khóa CI không trỏ đúng dịch vụ, dự án, môi trường, thành viên và khóa đã niêm phong; khóa chạy ứng dụng không thuộc CI."
1748
1748
  causes_vi:
1749
- - "Tên CI hoặc Dependabot cũng là khóa trong thành viên .enc của ứng dụng."
1750
- nextStep_vi: "Bỏ khóa ứng dụng khỏi khai báo CI; đưa bí mật CI vào kho GitHub tương ứng."
1749
+ - "Thiếu liên kết credential của dịch vụ, sai dự án hoặc dùng khóa được cấp cho container ứng dụng."
1750
+ nextStep_vi: "Giữ khóa runtime ngoài CI; khai báo đúng credential.project và liên kết services.<id>.ci.secrets cho bản sao khóa công cụ."
1751
1751
  owner: op-retry
1752
1752
  kind: check-finding
1753
1753
 
@@ -196,7 +196,7 @@ $defs:
196
196
  local: {type: string, pattern: '^https?://[^\s]+$'}
197
197
  public: {type: string, pattern: '^https://[^\s]+$'}
198
198
  fromCredential:
199
- description: A hosted endpoint carried inside a credential (a Sentry DSN) - the credential id, never the value.
199
+ description: A private connection URL carried inside a sealed credential - its credential id, never the value. Public local or hosted URLs use local or public.
200
200
  type: string
201
201
  pattern: '^[a-z][a-z0-9-]*$'
202
202
  stack: {$ref: '#/$defs/serviceStack'}
@@ -432,17 +432,19 @@ $defs:
432
432
  publishedBy: {type: string, minLength: 1}
433
433
  serviceCredential:
434
434
  description: >-
435
- A custody reference, never a value - the repository whose stack holds it and the plaintext
436
- member's path from that repository's root; the tracked <path>.enc twin must exist. The field env is the
437
- variable the consuming tool reads; `key` is the field inside a structured custody file (app.yaml)
438
- when the application reads the file itself. At least one of env and key is named. Sonar
439
- convention (scripts/gates/sonar-local.mjs reads it): id `admin` creates projects and mints
440
- tokens; a credential whose id is a project key is that project's analysis token.
435
+ A name-only connection or credential reference: its repository, actual sealed member path and key.
436
+ env names the consuming tool's variable. project references projects[].key for project-scoped tooling;
437
+ ids identify references independently of provider project-key syntax. Username, password and private
438
+ endpoint keys use separate references; operator/admin login lives in a separate authority member and
439
+ is never an implicit scanner or CI credential. Product Sonar analysis names exactly
440
+ .starcistacks/<declared-env>/secrets/sonar.enc.yaml; ci-declaration.mjs enforces that service-specific member.
441
+ Legacy plaintext paths with .enc twins require migration.
441
442
  type: object
442
443
  additionalProperties: false
443
444
  required: [id, custody]
444
445
  properties:
445
446
  id: {type: string, pattern: '^[a-z][a-z0-9-]*$'}
447
+ project: {type: string, minLength: 1, description: "The exact projects[].key this credential serves; execution selects the entry by the actual consumer repository."}
446
448
  purpose: {type: string, minLength: 1}
447
449
  env: {type: string, pattern: '^[A-Z][A-Z0-9_]*$'}
448
450
  key: {type: string, minLength: 1}
@@ -469,6 +471,7 @@ $defs:
469
471
  workflow: {type: string, minLength: 1}
470
472
  permissions: {type: array, items: {type: string, minLength: 1}}
471
473
  secrets:
474
+ description: Explicit credential-to-CI execution-copy links; names are declared in the top-level ci block. ci.token selects the analysis copy.
472
475
  type: array
473
476
  items:
474
477
  type: object
@@ -1,3 +1,32 @@
1
1
  import type { SpawnSyncOptions, SpawnSyncReturns } from 'node:child_process';
2
+ /** A concrete Node child whose admission belongs to the CLI's machine-wide governor. */
3
+ export interface HeavyNodeRequest {
4
+ readonly kind: 'tsc' | 'eslint' | 'test';
5
+ readonly label: string;
6
+ readonly args: readonly string[];
7
+ readonly cwd: string;
8
+ /** When supplied, this is the entire child environment, not an overlay. */
9
+ readonly env?: NodeJS.ProcessEnv | undefined;
10
+ readonly timeoutMs?: number | undefined;
11
+ readonly maxBuffer: number;
12
+ }
13
+ /** Normal red exits are measurements; an interrupted child is not one. */
14
+ export interface HeavyNodeResult {
15
+ readonly status: number | null;
16
+ readonly signal: NodeJS.Signals | null;
17
+ readonly stdout: string;
18
+ readonly stderr: string;
19
+ readonly error?: NodeJS.ErrnoException | undefined;
20
+ }
21
+ /** The CLI admits and captures each actual child, never the composite app command. */
22
+ export type HeavyNodeRun = (request: HeavyNodeRequest) => Promise<HeavyNodeResult>;
23
+ /** A real governor hold reached no verdict; transports preserve its CLI exit instead of treating it as linter output. */
24
+ export declare const isHostHeld: (error: unknown) => boolean;
25
+ /** Execution infrastructure reached no complete measurement; the contract finder cannot turn it into a cacheable source finding. */
26
+ export declare const isNodeExecutionRefusal: (error: unknown) => boolean;
27
+ /** Keeps the runtime's mutation fence and temporary-directory custody before crossing the CLI execution port. */
28
+ export declare function runHeavyNode(run: HeavyNodeRun | undefined, request: HeavyNodeRequest): Promise<HeavyNodeResult>;
29
+ /** A partial compiler/test report cannot be interpreted or stored as a completed measurement. */
30
+ export declare function completedNode(result: HeavyNodeResult, label: string): HeavyNodeResult;
2
31
  /** Runs `node <args>`; options (cwd, env, timeout, input, maxBuffer, stdio) pass through. */
3
32
  export declare const runNode: (args: readonly string[], options?: SpawnSyncOptions) => SpawnSyncReturns<string>;
@@ -1,3 +1,22 @@
1
1
  import { nodeSpawn } from './lib.mjs';
2
+ import { assertMutationFence } from '../../lib/mutation-fence.mjs';
3
+ import { withTempEnv } from '../../../engine/temp-root.mjs';
4
+ /** A real governor hold reached no verdict; transports preserve its CLI exit instead of treating it as linter output. */
5
+ export const isHostHeld = (error) => error instanceof Error && 'code' in error && error.code === 'HOST_HELD' && 'exitCode' in error && error.exitCode === 75;
6
+ /** Execution infrastructure reached no complete measurement; the contract finder cannot turn it into a cacheable source finding. */
7
+ export const isNodeExecutionRefusal = (error) => error instanceof Error && 'code' in error && typeof error.code === 'string' && ['HOST_HELD', 'HEAVY_DEFERRED', 'CLI_TOOL_INCOMPLETE', 'CLI_TOOL_MISSING', 'CLI_TOOL_NOT_STARTED', 'HFS_NODE_GOVERNOR_MISSING', 'HFS_NODE_INCOMPLETE'].includes(error.code);
8
+ /** Keeps the runtime's mutation fence and temporary-directory custody before crossing the CLI execution port. */
9
+ export async function runHeavyNode(run, request) {
10
+ if (run === undefined)
11
+ throw Object.assign(new Error('HFS_NODE_GOVERNOR_MISSING: run this check through starci-harness'), { code: 'HFS_NODE_GOVERNOR_MISSING' });
12
+ assertMutationFence({ kind: 'node-effect', args: request.args });
13
+ return run({ ...request, env: withTempEnv({ env: request.env ?? process.env }).env });
14
+ }
15
+ /** A partial compiler/test report cannot be interpreted or stored as a completed measurement. */
16
+ export function completedNode(result, label) {
17
+ if (result.error !== undefined || result.signal !== null || result.status === null)
18
+ throw Object.assign(new Error(`${label} did not complete`), { code: 'HFS_NODE_INCOMPLETE' });
19
+ return result;
20
+ }
2
21
  /** Runs `node <args>`; options (cwd, env, timeout, input, maxBuffer, stdio) pass through. */
3
22
  export const runNode = (args, options = {}) => nodeSpawn(args, options);
@@ -22,7 +22,7 @@ export const catalogOf = (id) => {
22
22
  export const checkServiceForm = (service, id, entry, at, kind, add, ambiguous) => {
23
23
  if (service.provider && !catalogOf(id).providers.includes(service.provider))
24
24
  add('refuse', 'STACKS_PROVIDER_UNKNOWN', `${at}.provider`, `${service.provider} is not a ${id} provider (${catalogOf(id).providers.join(', ')})`);
25
- if (Object.hasOwn(entry?.ci ?? {}, 'secrets') || Object.hasOwn(entry?.ci ?? {}, 'vars'))
25
+ if (Object.hasOwn(entry?.ci ?? {}, 'vars') || service.ci.secrets.some((item) => !item.credential))
26
26
  add('suspect', 'STACKS_CI_LEGACY_FORM', `${at}.ci`, 'move the names to the top-level ci block and name them by services.<id>.ci.token / hostVar');
27
27
  if (id === 'sonar' && service.provider === 'sonarcloud' && !service.organization
28
28
  && !(kind === 'example' && service.runtimeSecrets.length && !service.credentials.length && service.ci.organizationVar))
@@ -56,6 +56,8 @@ export function checkQualityService(service, id, at, context, checkServiceCi) {
56
56
  ambiguous('codecov auth must be oidc or token');
57
57
  if (id === 'codecov' && service.auth === 'token' && !service.ci.token)
58
58
  ambiguous('codecov auth token requires ci.token');
59
+ if (id === 'codecov' && service.auth === 'oidc' && (service.ci.token || service.credentials.length || service.ci.secrets.length))
60
+ ambiguous('codecov OIDC has no token, credential or sealed member');
59
61
  checkServiceCi(service, id, at, context);
60
62
  return true;
61
63
  }
@@ -6,6 +6,7 @@ export interface ServiceCredential {
6
6
  env: string | null;
7
7
  key: string | null;
8
8
  purpose: string | null;
9
+ project?: string | null;
9
10
  custody: {
10
11
  repository: string | null;
11
12
  path: string | null;
@@ -119,6 +119,7 @@ declare function summary(service: ServiceLike): {
119
119
  id: string | null;
120
120
  env: string | null;
121
121
  key: string | null;
122
+ project: string | null | undefined;
122
123
  custody: string;
123
124
  present: boolean | null;
124
125
  }[];
@@ -42,6 +42,8 @@ import { readProperties } from '../lib/properties.mjs';
42
42
  import { escapeRegExp } from '../lib/regex.mjs';
43
43
  import { isInfrastructureValueFile } from './starcistacks-value-file.mjs';
44
44
  import { STACK_DECLARATION_TEMPLATE } from '../lib/example-refs.mjs';
45
+ import { isSealedName } from '../lib/sealed-member.mjs';
46
+ import { serviceBindings } from '../hfs/ci-declaration.mjs';
45
47
  import { checkSecretForm, kindOf } from './starcistacks-form.mjs';
46
48
  import { SERVICE_CATALOG, catalogOf, checkServiceForm, checkCiDeclaration, checkQualityService } from './starcistacks-ci.mjs';
47
49
  import { applicationStackFindings } from './starcistacks-runtime.mjs';
@@ -165,9 +167,10 @@ const normalizeCredential = (credential, declaringRepo) => {
165
167
  const repo = resolveDeclaredRepository(custody.repository, { fromRepo: declaringRepo });
166
168
  const rel = text(custody.path);
167
169
  const abs = resolveCustodyFile(repo, rel);
168
- return { id: text(credential.id), env: text(credential.env), key: text(credential.key), purpose: text(credential.purpose),
170
+ const sealed = rel && isSealedName(rel) ? abs : abs ? `${abs}.enc` : null;
171
+ return { id: text(credential.id), env: text(credential.env), key: text(credential.key), purpose: text(credential.purpose), project: text(credential.project),
169
172
  custody: { repository: text(custody.repository), path: rel, resolved: Boolean(repo), file: abs,
170
- encPresent: abs ? isFile(`${abs}.enc`) : null, plainPresent: abs ? isFile(abs) : null } };
173
+ encPresent: sealed ? isFile(sealed) : null, plainPresent: abs && !isSealedName(abs) ? isFile(abs) : false } };
171
174
  };
172
175
  const ownerActionOf = (s) => {
173
176
  const noAction = s.ownerAction === 'none' ? 'none' : null;
@@ -239,12 +242,9 @@ export function resolveStackService(repoRoot, serviceId) {
239
242
  const listed = service.projects.some((project) => project.repository === name);
240
243
  if (declaration.sourceHost && !listed)
241
244
  continue;
242
- // Through the source host a product gets the shared server, its shared credentials and its own project
243
- // token (a credential whose id is a project key); other projects' tokens and the host's CI wiring are not its.
244
- const ownKey = service.projects.find((project) => project.repository === name)?.key;
245
- const projectKeys = new Set(service.projects.map((project) => project.key));
245
+ // A source host lends public server settings only; analysis credentials stay in the selected product's custody.
246
246
  if (declaration.sourceHost)
247
- service = { ...service, credentials: service.credentials.filter((credential) => !projectKeys.has(credential.id) || credential.id === ownKey),
247
+ service = { ...service, credentials: [],
248
248
  ci: { wiring: null, workflow: null, permissions: [], secrets: [], vars: [], provisioning: null } };
249
249
  return { declaration: declaration.file, declaringRepo: declaration.repo, sourceHost: Boolean(declaration.sourceHost),
250
250
  repository: name, projectKey: service.projects.find((project) => project.repository === name)?.key ?? null, ...service };
@@ -304,7 +304,7 @@ const checkServiceCredentials = (service, at, add, ambiguous) => {
304
304
  if (!credential.custody.resolved)
305
305
  add('suspect', 'STACKS_CUSTODY_UNVERIFIED', where, `custody repository ${credential.custody.repository} is not checked out on this machine`);
306
306
  else if (!credential.custody.encPresent)
307
- add('refuse', 'STACKS_CUSTODY_MISSING', where, `${credential.custody.repository}/${credential.custody.path}.enc is not in custody; repair the stack custody (secret:gen / stack-secret) - never ask the owner for a value the declaration says the runtime holds`);
307
+ add('refuse', 'STACKS_CUSTODY_MISSING', where, `${credential.custody.repository}/${credential.custody.path} is not sealed in custody; repair the declared member through starci app secret`);
308
308
  }
309
309
  return ids;
310
310
  };
@@ -371,6 +371,18 @@ const checkService = (id, entry, { schema, declFile, declaration, governing, nam
371
371
  checkServiceForm(service, id, entry, at, kind, add, ambiguous);
372
372
  if (checkDisabledService(service, id, at, ciUses, ambiguous, add))
373
373
  return;
374
+ let product = false;
375
+ try {
376
+ product = JSON.parse(fs.readFileSync(path.join(declaration?.repo ?? '', 'hfs.json'), 'utf8')).kind === 'app';
377
+ }
378
+ catch { /* other checks own a missing app declaration */ }
379
+ if (id === 'sonar' && product) {
380
+ const selected = serviceBindings(declaration?.doc, name);
381
+ for (const problem of selected.problems.filter((problem) => problem.at.startsWith('services.sonar')))
382
+ ambiguous(problem.message);
383
+ if (service.ci.wiring === 'required' && !selected.bindings.some((binding) => binding.service === 'sonar' && binding.name === service.ci.token))
384
+ ambiguous('required Sonar analysis needs this product project credential in its declared environment sonar.enc.yaml member');
385
+ }
374
386
  if (checkQualityService(service, id, at, { governing, name, ciUses, ciText, add, ambiguous }, checkServiceCi))
375
387
  return;
376
388
  checkEnabledService(service, id, at, { governing, name, ciUses, ciText, add, kind }, ambiguous);
@@ -546,7 +558,7 @@ export function checkStarciStacks(repoRoot, { newRepo = false, examplesRoot = pa
546
558
  function summary(service) {
547
559
  return { provider: service.provider, mode: service.mode, auth: service.auth, host: service.host,
548
560
  stack: service.stack ? { owner: service.stack.owner ?? null, repository: service.stack.repository, root: service.stack.root, environment: service.stack.environment, compose: service.stack.compose, container: service.stack.container } : null,
549
- projects: service.projects, credentials: service.credentials.map((credential) => ({ id: credential.id, env: credential.env, key: credential.key,
561
+ projects: service.projects, credentials: service.credentials.map((credential) => ({ id: credential.id, env: credential.env, key: credential.key, project: credential.project,
550
562
  custody: `${credential.custody.repository}/${credential.custody.path}`, present: credential.custody.encPresent ?? null })),
551
563
  ci: service.ci, ownerAction: service.ownerAction };
552
564
  }
@@ -575,7 +587,7 @@ export function ownerAskConflict({ repo, question } = {}) {
575
587
  continue;
576
588
  const custody = service.credentials.map((credential) => `${credential.custody.repository}/${credential.custody.path}`);
577
589
  const custodyNote = custody.length ? ` and its credentials in custody (${custody.join(', ')})` : ` and auth ${service.auth}`;
578
- const sonarNote = id === 'sonar' ? 'Run starci gate sonar (status, ensure-project, scan) - it reads host, project and token from the declaration and custody. ' : '';
590
+ const sonarNote = id === 'sonar' ? 'The native app release gate selects this product project credential; analysis never provisions projects or rotates credentials. ' : '';
579
591
  const provisioningNote = service.ci.provisioning ? ` (${service.ci.provisioning})` : '';
580
592
  return { service: id, provider: service.provider, declaration: slash(service.declaration), matched: byName ?? byWord,
581
593
  message: `${id} (${service.provider}, ${service.mode}) is declared in ${slash(service.declaration)} with ownerAction none${custodyNote}; the owner is never asked for it. ${sonarNote}CI wiring is ${service.ci.wiring ?? 'undeclared'}${provisioningNote}.` };
@@ -1,3 +1,4 @@
1
+ import { type HeavyNodeRun, type HeavyNodeResult } from '../api/node/run-node.mjs';
1
2
  export declare const BOOT_PROOF_CODE = "BE_APP_BOOT_PROOF_MISSING";
2
3
  export declare const BOOT_PROOF_TIMEOUT = "BOOT_PROOF_TIMEOUT";
3
4
  export declare const BOOT_PROOF_TIMEOUT_ENV = "STARCI_BOOT_PROOF_TIMEOUT_MS";
@@ -6,7 +7,7 @@ export declare const wiringFixturePath: (app: string) => string;
6
7
  export type WorkerResult = {
7
8
  status?: number | null;
8
9
  signal?: string | null;
9
- error?: NodeJS.ErrnoException;
10
+ error?: HeavyNodeResult['error'];
10
11
  stdout?: string;
11
12
  } | null | undefined;
12
13
  /** The injectable spawn seam of the worker. */
@@ -62,7 +63,12 @@ export interface AppWiringInput {
62
63
  timeout?: number;
63
64
  run?: WorkerRun;
64
65
  }
65
- export declare function appWiring({ repoRoot, repo, files, fixtures, timeout, run }: AppWiringInput): WiringResult;
66
+ /** The synchronous finder and its existing worker doubles drive the same proof program as app check. */
67
+ export declare function appWiring(input: AppWiringInput): WiringResult;
68
+ /** The cache's asynchronous execution seam admits each boot attempt, with the same validation, timeout retry and result judgement. */
69
+ export declare function appWiringAsync(input: Omit<AppWiringInput, 'run'> & {
70
+ execute?: HeavyNodeRun | undefined;
71
+ }): Promise<WiringResult>;
66
72
  /** Optional standalone entry; coordinator connects the same seam to app check --gate wiring. */
67
73
  export interface AppWiringMainIo {
68
74
  cwd?: string;