@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/ci/cli.mjs CHANGED
@@ -1,19 +1,22 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { parseYaml } from '../runtime/engine/yaml.mjs';
4
- import { isValidCiDeclaration, readCiDeclaration, sealedKeyNames } from '../runtime/scripts/hfs/ci-declaration.mjs';
4
+ import { isValidCiDeclaration, readCiDeclaration, sealedKeyNames, serviceBindings, serviceCredentialNames, runtimeGranted } from '../runtime/scripts/hfs/ci-declaration.mjs';
5
+ import { repositoryName, identityQueryEnvironment } from '../runtime/scripts/hfs/repo-identity.mjs';
6
+ import { serviceCredentialFile, serviceCredentialValue } from '../runtime/scripts/hfs/secret-store.mjs';
7
+ import { defaultSops } from '../runtime/scripts/hfs/secret-sops.mjs';
5
8
  import { github, refuse } from './github.mjs';
6
9
  import { setSecret } from './secret.mjs';
7
10
  import { setVariable, syncVariables } from './vars.mjs';
8
11
  import { badgeVisibilityNotices, verifyCi } from './verify.mjs';
9
12
  /** The commands of `starci app ci` (besides `local`) with the options each takes; the parser refuses every other. */
10
13
  export const COMMANDS = {
11
- 'secret set': ['env', 'dependabot', 'repo', 'execute'],
14
+ 'secret set': ['env', 'dependabot', 'repo', 'execute', 'from-custody'],
12
15
  'var set': ['env', 'value', 'repo', 'execute'],
13
16
  'var sync': ['execute', 'repo'],
14
17
  verify: ['json', 'repo'],
15
18
  };
16
- export const BOOLEAN = new Set(['dependabot', 'execute', 'json']);
19
+ export const BOOLEAN = new Set(['dependabot', 'execute', 'json', 'from-custody']);
17
20
  /** Invalid options never echo argv, because an extra argument may itself contain a secret. */
18
21
  function parse(argv) {
19
22
  const command = argv[0] === 'verify' ? 'verify' : argv.slice(0, 2).join(' ');
@@ -52,7 +55,7 @@ function parse(argv) {
52
55
  return { command, options: options, name: positional[0] };
53
56
  }
54
57
  /** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
55
- export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {} } = {}) {
58
+ export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), stdin, env = process.env, tools = {}, hostIdentity, sops, platform = process.platform, inputRedirected } = {}) {
56
59
  // `ci local` runs the rendered CI on this machine (local.mjs): no manifest custody and no GitHub.
57
60
  if (argv[0] === 'local')
58
61
  return (await import('./local.mjs')).ciLocalMain(argv.slice(1), { cwd, stdout, stderr, env });
@@ -69,8 +72,36 @@ export async function ciMain(argv, { cwd = process.cwd(), stdout = (s) => proces
69
72
  }
70
73
  if (!isValidCiDeclaration(declaration))
71
74
  throw refuse('CI_DECLARATION_INVALID', 'correct the CI declaration before using GitHub');
72
- const gh = github({ cwd, env, spawn: tools.spawn });
73
- const context = { ...parsed, declaration, sealed, gh, stdin, stdout };
75
+ const childEnv = identityQueryEnvironment(env, serviceCredentialNames(doc));
76
+ const repository = repositoryName(cwd, { env: childEnv }), selected = serviceBindings(doc, repository);
77
+ if (selected.problems.length)
78
+ throw refuse('CI_CUSTODY_INVALID', 'correct the explicit project service credential bindings before using CI');
79
+ const runtime = sealed.filter((entry) => runtimeGranted(doc, entry.env, entry.member, entry.key));
80
+ if (parsed.command.endsWith(' set') && runtime.some((entry) => entry.key === parsed.name))
81
+ throw refuse('APP_SECRET_IN_CI', 'the selected key belongs to app runtime authority');
82
+ if (parsed.options['from-custody']) {
83
+ const binding = selected.bindings.find((item) => item.name === parsed.name);
84
+ if (!binding || parsed.options.dependabot)
85
+ throw refuse('CI_CUSTODY_INVALID', 'one project service binding is required');
86
+ try {
87
+ serviceCredentialFile(cwd, binding);
88
+ }
89
+ catch {
90
+ throw refuse('CI_CUSTODY_INVALID', 'the selected project member is missing, ambiguous or outside this product');
91
+ }
92
+ if (parsed.options.repo && (parsed.options.repo.split('/')[1] !== repository || (doc.ci?.github?.repository && parsed.options.repo !== doc.ci.github.repository)))
93
+ throw refuse('CI_CUSTODY_INVALID', 'the destination repository differs from this project');
94
+ }
95
+ const gh = github({ cwd, env: childEnv, spawn: tools.spawn });
96
+ const custody = (binding) => {
97
+ try {
98
+ return serviceCredentialValue(cwd, binding, sops ?? defaultSops(env, cwd, platform, undefined, hostIdentity));
99
+ }
100
+ catch {
101
+ throw refuse('CI_CUSTODY_UNAVAILABLE', 'the selected service member could not be opened with the owner identity');
102
+ }
103
+ };
104
+ const context = { ...parsed, declaration, sealed, runtime, gh, stdin, stdout, bindings: selected.bindings, custody, inputRedirected };
74
105
  if (parsed.command === 'secret set')
75
106
  return await setSecret(context);
76
107
  if (parsed.command === 'var set')
package/ci/github.d.mts CHANGED
@@ -11,6 +11,7 @@ export interface CiOptions extends CiScope {
11
11
  value?: string | undefined;
12
12
  execute?: boolean | undefined;
13
13
  json?: boolean | undefined;
14
+ 'from-custody'?: boolean | undefined;
14
15
  }
15
16
  /** One declared CI secret or variable of a declaration without problems (runtime/scripts/hfs/ci-declaration.mjs readCiDeclaration). */
16
17
  export type CiEntry = ValidCiEntry;
package/ci/secret.d.mts CHANGED
@@ -1,10 +1,16 @@
1
1
  import type { CiDeclaration, CiEntry, CiOptions, Gh, StdinSource } from './github.mjs';
2
+ import { type ServiceBinding } from '../runtime/scripts/hfs/ci-declaration.mjs';
3
+ /** An input pipe or file is an input source even before its bytes arrive; fstat never consumes its value. */
4
+ export declare function redirectedSecretInput(): boolean;
2
5
  /** A declared name must belong to the requested store before a secret body is read. */
3
6
  export declare function declaredEntry(entries: readonly CiEntry[], name: string, options: CiOptions): CiEntry;
4
7
  /** Secret input is read once, stripped of one trailing newline, and passed only to the child stdin; without options.execute nothing is read or written. */
5
- export declare function setSecret({ declaration, sealed, gh, options, name, stdin, stdout }: {
8
+ export declare function setSecret({ declaration, sealed, gh, options, name, stdin, stdout, bindings, custody, inputRedirected }: {
6
9
  declaration: CiDeclaration;
7
10
  sealed: readonly {
11
+ env?: string;
12
+ member?: string;
13
+ file?: string;
8
14
  key: string;
9
15
  }[];
10
16
  gh: Gh;
@@ -12,4 +18,7 @@ export declare function setSecret({ declaration, sealed, gh, options, name, stdi
12
18
  name: string;
13
19
  stdin: StdinSource;
14
20
  stdout: (text: string) => unknown;
21
+ bindings?: ServiceBinding[];
22
+ custody?: ((binding: ServiceBinding) => string) | undefined;
23
+ inputRedirected?: (() => boolean) | undefined;
15
24
  }): Promise<number>;
package/ci/secret.mjs CHANGED
@@ -1,5 +1,16 @@
1
1
  import fs from 'node:fs';
2
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
3
+ import { boundSealedKey } from '../runtime/scripts/hfs/ci-declaration.mjs';
4
+ /** An input pipe or file is an input source even before its bytes arrive; fstat never consumes its value. */
5
+ export function redirectedSecretInput() {
6
+ try {
7
+ const input = fs.fstatSync(0);
8
+ return !input.isCharacterDevice();
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
3
14
  /** A declared name must belong to the requested store before a secret body is read. */
4
15
  export function declaredEntry(entries, name, options) {
5
16
  const entry = entries.find((item) => item.name === name);
@@ -11,24 +22,33 @@ export function declaredEntry(entries, name, options) {
11
22
  return entry;
12
23
  }
13
24
  /** Secret input is read once, stripped of one trailing newline, and passed only to the child stdin; without options.execute nothing is read or written. */
14
- export async function setSecret({ declaration, sealed, gh, options, name, stdin, stdout }) {
15
- if (sealed.some((item) => item.key === name))
16
- throw refuse('CI_SECRET_SEALED', 'the name belongs to a sealed app secret');
25
+ export async function setSecret({ declaration, sealed, gh, options, name, stdin, stdout, bindings = [], custody, inputRedirected = redirectedSecretInput }) {
17
26
  declaredEntry(options.dependabot ? declaration.dependabotSecrets : declaration.secrets, name, options);
27
+ const binding = options.dependabot ? undefined : bindings.find((item) => item.name === name);
28
+ if (sealed.some((item) => (item.key === name || (binding && item.key === binding.key)) && !(binding && boundSealedKey(binding, item))))
29
+ throw refuse('CI_SECRET_SEALED', 'the name or selected key belongs to unrelated sealed app custody');
30
+ if (binding && options['from-custody'] !== true)
31
+ throw refuse('CI_CUSTODY_REQUIRED', 'this service secret is an execution copy; use --from-custody');
32
+ if (options['from-custody'] && (!binding || !custody))
33
+ throw refuse('CI_CUSTODY_INVALID', 'one explicit project service credential binding is required');
34
+ if (options['from-custody'] && (stdin !== undefined || inputRedirected()))
35
+ throw refuse('CI_VALUE_AMBIGUOUS', 'choose custody or stdin, never both');
18
36
  // Dry until --execute: the name is judged, but no value is read and GitHub is not reached; the line never carries a value.
19
37
  if (options.execute !== true) {
20
- stdout(`dry run: would set secret ${name} (${scopeName(options)}) from the value on stdin; add --execute\n`);
38
+ stdout(`dry run: would set secret ${name} (${scopeName(options)}) from ${binding ? 'its declared service custody' : 'the value on stdin'}; add --execute\n`);
21
39
  return 0;
22
40
  }
23
41
  let value;
24
- if (stdin === undefined) {
42
+ if (binding && custody)
43
+ value = custody(binding);
44
+ else if (stdin === undefined) {
25
45
  if (process.stdin.isTTY)
26
46
  throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
27
47
  value = fs.readFileSync(0, 'utf8');
28
48
  }
29
49
  else
30
50
  value = typeof stdin === 'function' ? await stdin() : stdin;
31
- value = String(value ?? '').replace(/\r?\n$/, '');
51
+ value = binding ? value : String(value ?? '').replace(/\r?\n$/, '');
32
52
  if (!value.length)
33
53
  throw refuse('CI_VALUE_EMPTY', 'a secret value is required on stdin');
34
54
  const repo = gh.repository(options.repo);
package/ci/verify.d.mts CHANGED
@@ -1,14 +1,22 @@
1
1
  import type { CiDeclaration, CiOptions, Gh } from './github.mjs';
2
+ import { type ServiceBinding } from '../runtime/scripts/hfs/ci-declaration.mjs';
2
3
  /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
3
- export declare function verifyCi({ declaration, sealed, gh, options, stdout, notices }: {
4
+ export declare function verifyCi({ declaration, sealed, gh, options, stdout, notices, bindings, runtime }: {
4
5
  declaration: CiDeclaration;
5
6
  sealed: readonly {
7
+ env?: string;
8
+ member?: string;
9
+ file?: string;
6
10
  key: string;
7
11
  }[];
8
12
  gh: Gh;
9
13
  options: CiOptions;
10
14
  stdout: (text: string) => unknown;
11
15
  notices?: string[];
16
+ bindings?: ServiceBinding[];
17
+ runtime?: readonly {
18
+ key: string;
19
+ }[];
12
20
  }): number;
13
21
  /** Manifest visibility wins; optional GitHub visibility discovery never changes verification findings or errors. */
14
22
  export declare function badgeVisibilityNotices({ doc, gh, repo }: {
package/ci/verify.mjs CHANGED
@@ -1,7 +1,8 @@
1
1
  import { badgeTokenNotices } from '../sync/readme-badges.mjs';
2
2
  import { refuse, scopeArgs, scopeName } from './github.mjs';
3
+ import { boundSealedKey } from '../runtime/scripts/hfs/ci-declaration.mjs';
3
4
  /** Verification reports names and scopes only; values are compared in memory and excluded from findings. */
4
- export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [] }) {
5
+ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [], bindings = [], runtime = [] }) {
5
6
  const repo = gh.repository(options.repo), stores = [];
6
7
  const environments = [...new Set([...declaration.secrets, ...declaration.vars].filter((item) => item.scope === 'environment').map((item) => item.environment))].sort();
7
8
  for (const scope of [{}, ...environments.map((env) => ({ env })), { dependabot: true }]) {
@@ -12,7 +13,7 @@ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [
12
13
  stores.push({ scope, kind, rows });
13
14
  }
14
15
  }
15
- const findings = [], sealedNames = new Set(sealed.map((item) => item.key));
16
+ const findings = [];
16
17
  const declarations = (store) => store.scope.dependabot ? declaration.dependabotSecrets : store.kind === 'secret' ? declaration.secrets : declaration.vars;
17
18
  const matches = (entry, scope) => (entry.scope === 'environment' ? entry.environment : undefined) === scope.env;
18
19
  const add = (code, name, scope) => { findings.push({ code, name, scope: scopeName(scope) }); };
@@ -26,7 +27,8 @@ export function verifyCi({ declaration, sealed, gh, options, stdout, notices = [
26
27
  add('CI_VAR_DRIFT', entry.name, store.scope);
27
28
  }
28
29
  for (const row of store.rows) {
29
- if (store.kind === 'secret' && sealedNames.has(row.name))
30
+ const binding = store.scope.dependabot ? undefined : bindings.find((item) => item.name === row.name);
31
+ if (runtime.some((item) => item.key === row.name) || (store.kind === 'secret' && sealed.some((item) => (item.key === row.name || (binding && item.key === binding.key)) && !(binding && boundSealedKey(binding, item)))))
30
32
  add('APP_SECRET_IN_CI', row.name, store.scope);
31
33
  if (entries.some((entry) => entry.name === row.name && matches(entry, store.scope)))
32
34
  continue;
@@ -1,3 +1,4 @@
1
+ import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  /** The parts of the parsed hfs.json this module reads. */
2
3
  export interface HfsDeclaration {
3
4
  readonly apps?: ReadonlyArray<{
@@ -18,18 +19,20 @@ export declare function aliasesOf(paths: Readonly<Record<string, readonly string
18
19
  export declare function aliasTarget(aliases: readonly PathAlias[], specifier: string): string | null;
19
20
  /** The text written to a snapshot file: the printed schema and one final newline. Pure. */
20
21
  export declare const snapshotText: (printed: string) => string;
21
- /**
22
- * Writes the snapshots of every api app: `contracts/<app>/schema.graphql` when it serves GraphQL and `contracts/<app>/openapi.json`
23
- * when it has an operation table. Answers `{ written: [paths], skipped: [apps], standIns: { app: [lines] } }`, `skipped`
24
- * being the apps with neither. `declaration` is the parsed hfs.json. `outDir` (absolute) redirects the snapshots to another
25
- * root, keeping their relative paths.
26
- */
27
- export declare function emitContracts({ repoRoot, declaration, outDir }: {
22
+ interface EmitInput {
28
23
  repoRoot: string;
29
24
  declaration: HfsDeclaration;
30
25
  outDir?: string;
31
- }): {
26
+ }
27
+ interface EmitResult {
32
28
  written: string[];
33
29
  skipped: string[];
34
30
  standIns: Record<string, string[]>;
35
- };
31
+ }
32
+ /** Existing synchronous emitter for source finders and their worker proofs. */
33
+ export declare function emitContracts(input: EmitInput): EmitResult;
34
+ /** App-check/emit execution: each actual compiler worker is admitted separately, with the original isolated environment. */
35
+ export declare function emitContractsAsync(input: EmitInput & {
36
+ execute?: HeavyNodeRun | undefined;
37
+ }): Promise<EmitResult>;
38
+ export {};
@@ -21,6 +21,7 @@
21
21
  * * artifact, so a redirected `outDir` (the R23 check mode, which emits into a scratch root) does not get them.
22
22
  */
23
23
  import { spawnSync } from 'node:child_process';
24
+ import { completedNode, runHeavyNode } from '../runtime/scripts/api/node/run-node.mjs';
24
25
  import fs from 'node:fs';
25
26
  import path from 'node:path';
26
27
  import { fileURLToPath } from 'node:url';
@@ -50,9 +51,8 @@ export function aliasTarget(aliases, specifier) {
50
51
  const apiApps = (declaration) => (declaration.apps ?? []).filter((app) => app.kind === 'api').map((app) => app.name);
51
52
  /** The text written to a snapshot file: the printed schema and one final newline. Pure. */
52
53
  export const snapshotText = (printed) => `${printed.replace(/\n+$/, '')}\n`;
53
- /** Runs one worker of one app; answers its stdout, or null when the app has nothing of that kind (exit 3). */
54
- function runWorker(worker, repoRoot, app) {
55
- const result = spawnSync(process.execPath, [worker, repoRoot, app], { encoding: 'utf8', cwd: repoRoot, env: { PATH: readEnv('PATH') ?? '' }, maxBuffer: 256 * 1024 * 1024 });
54
+ /** Both transports judge the same native worker status and exact text. Exit 3 means the app serves no such contract. */
55
+ function workerOutput(result, app) {
56
56
  if (result.status === 3)
57
57
  return { text: null, lines: [] };
58
58
  const lines = (result.stderr ?? '').split(/\r?\n/).filter(Boolean);
@@ -60,24 +60,20 @@ function runWorker(worker, repoRoot, app) {
60
60
  throw new Error(`starci app emit: ${app} failed (exit ${result.status}): ${(lines.join('\n') || result.stdout).trim()}`);
61
61
  return { text: result.stdout, lines: lines.filter((line) => line.startsWith(STAND_IN)) };
62
62
  }
63
- /** Emits the GraphQL schema and the operations of one app: `{ graphql, openapi, standIns }`, each text or null. */
64
- function emitApp(repoRoot, app) {
65
- const graphql = runWorker(WORKER, repoRoot, app);
66
- const operations = runWorker(OPERATIONS_WORKER, repoRoot, app);
67
- return { graphql: graphql.text, openapi: operations.text, standIns: [...graphql.lines, ...operations.lines] };
68
- }
69
63
  /**
70
64
  * Writes the snapshots of every api app: `contracts/<app>/schema.graphql` when it serves GraphQL and `contracts/<app>/openapi.json`
71
65
  * when it has an operation table. Answers `{ written: [paths], skipped: [apps], standIns: { app: [lines] } }`, `skipped`
72
66
  * being the apps with neither. `declaration` is the parsed hfs.json. `outDir` (absolute) redirects the snapshots to another
73
67
  * root, keeping their relative paths.
74
68
  */
75
- export function emitContracts({ repoRoot, declaration, outDir = repoRoot }) {
69
+ function* emissionProgram({ repoRoot, declaration, outDir = repoRoot }) {
76
70
  const written = [];
77
71
  const skipped = [];
78
72
  const standIns = {};
79
73
  for (const app of apiApps(declaration)) {
80
- const emitted = emitApp(repoRoot, app);
74
+ const graphql = yield { worker: WORKER, repoRoot, app };
75
+ const operations = yield { worker: OPERATIONS_WORKER, repoRoot, app };
76
+ const emitted = { graphql: graphql.text, openapi: operations.text, standIns: [...graphql.lines, ...operations.lines] };
81
77
  if (emitted.graphql === null && emitted.openapi === null) {
82
78
  skipped.push(app);
83
79
  continue;
@@ -106,3 +102,25 @@ export function emitContracts({ repoRoot, declaration, outDir = repoRoot }) {
106
102
  }
107
103
  return { written, skipped, standIns };
108
104
  }
105
+ /** Existing synchronous emitter for source finders and their worker proofs. */
106
+ export function emitContracts(input) {
107
+ const program = emissionProgram(input);
108
+ let step = program.next();
109
+ while (!step.done) {
110
+ const { worker, repoRoot, app } = step.value;
111
+ const result = spawnSync(process.execPath, [worker, repoRoot, app], { encoding: 'utf8', cwd: repoRoot, env: { PATH: readEnv('PATH') ?? '' }, maxBuffer: 256 * 1024 * 1024 });
112
+ step = program.next(workerOutput(result, app));
113
+ }
114
+ return step.value;
115
+ }
116
+ /** App-check/emit execution: each actual compiler worker is admitted separately, with the original isolated environment. */
117
+ export async function emitContractsAsync(input) {
118
+ const program = emissionProgram(input);
119
+ let step = program.next();
120
+ while (!step.done) {
121
+ const { worker, repoRoot, app } = step.value;
122
+ const result = completedNode(await runHeavyNode(input.execute, { kind: 'tsc', label: `app emit ${app} ${path.basename(worker)}`, args: [worker, repoRoot, app], cwd: repoRoot, env: { PATH: readEnv('PATH') ?? '' }, maxBuffer: 256 * 1024 * 1024 }), 'contract compiler');
123
+ step = program.next(workerOutput(result, app));
124
+ }
125
+ return step.value;
126
+ }
package/lint/chunk.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // chunk.mjs - split a long argument list under the command-line limit (Windows allows 32,767 characters) and run the parts with bounded concurrency.
2
+ import { isHostHeld, isNodeExecutionRefusal } from '../runtime/scripts/api/node/run-node.mjs';
2
3
  /** The command-line characters one argument costs: its length, a separating space and a pair of quotes. */
3
4
  const cost = (arg) => String(arg).length + 3;
4
5
  /**
@@ -26,18 +27,26 @@ export function chunkByChars(items, budget = 24_000) {
26
27
  export async function mapBounded(chunks, task, limit = 3) {
27
28
  const out = new Array(chunks.length);
28
29
  let next = 0;
30
+ let held;
29
31
  const worker = async () => {
30
- while (next < chunks.length) {
32
+ while (next < chunks.length && held === undefined) {
31
33
  const at = next++;
32
34
  try {
33
35
  out[at] = await task(chunks[at], at);
34
36
  }
35
37
  catch (error) {
36
- out[at] = { error: `could not start a lint child: ${String(error?.message ?? error)}` };
38
+ if (isNodeExecutionRefusal(error)) {
39
+ if (held === undefined || isHostHeld(error))
40
+ held = error;
41
+ }
42
+ else
43
+ out[at] = { error: `could not start a lint child: ${String(error?.message ?? error)}` };
37
44
  }
38
45
  }
39
46
  };
40
47
  await Promise.all(Array.from({ length: Math.min(limit, chunks.length) }, worker));
48
+ if (held !== undefined)
49
+ throw held;
41
50
  return out;
42
51
  }
43
52
  /** Merge the `{ results, error }` records of the chunks, in chunk order: every result kept, every distinct error reported. */
package/lint/run.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import { type CanonDeclarations, type LintSideStats } from '../cache/lint-family.mjs';
2
3
  import type { CheckSession } from '../cache/types.mjs';
3
4
  type Dynamic = any;
@@ -62,7 +63,7 @@ export declare function runLinter({ cwd, pkg, bin, args, bound }: {
62
63
  bin: string;
63
64
  args: string[];
64
65
  bound?: string | null;
65
- }): Promise<LinterRun>;
66
+ }, execute?: HeavyNodeRun): Promise<LinterRun>;
66
67
  /**
67
68
  * The lint scope a flat config declares: `ignored(file)` for its global ignores (a block holding only `ignores` and `name`), which prune
68
69
  * the walk, and `source(file)` for a file some block's `files` select while that block's own `ignores` do not (standard flat-config
package/lint/run.mjs CHANGED
@@ -30,7 +30,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
30
30
  import fs from 'node:fs';
31
31
  import path from 'node:path';
32
32
  import { createRequire } from 'node:module';
33
- import { spawn } from 'node:child_process';
33
+ import { completedNode, isHostHeld, isNodeExecutionRefusal, runHeavyNode } from '../runtime/scripts/api/node/run-node.mjs';
34
34
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
35
  import { linterReport, mergeReports, sonarReport, sourceRootsOf } from '../report/sonar.mjs';
36
36
  import { SIDES, appRelativeMessages, loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
@@ -119,28 +119,8 @@ export function eslintHeapArgs(env = process.env) {
119
119
  const mb = Number(env[ESLINT_HEAP_ENV]);
120
120
  return Number.isInteger(mb) && mb > 0 ? [`--max-old-space-size=${mb}`] : [];
121
121
  }
122
- /** The child `node` ran with `args` from `cwd`: its exit status, signal, spawn error and the text of both streams, once it closes. */
123
- function runNode(args, options) {
124
- return new Promise((resolve) => {
125
- let child;
126
- try {
127
- child = spawn(process.execPath, args, { ...options, windowsHide: true, stdio: ['ignore', 'pipe', 'pipe'] });
128
- }
129
- catch (error) {
130
- resolve({ status: 2, signal: null, error: error, stdout: '', stderr: String(error?.message ?? error) });
131
- return;
132
- }
133
- const out = [];
134
- const err = [];
135
- let failure;
136
- child.stdout.on('data', (chunk) => out.push(chunk));
137
- child.stderr.on('data', (chunk) => err.push(chunk));
138
- child.on('error', (error) => { failure = error; });
139
- child.on('close', (status, signal) => resolve({ status, signal, error: failure, stdout: Buffer.concat(out).toString('utf8'), stderr: Buffer.concat(err).toString('utf8') }));
140
- });
141
- }
142
122
  /** A linter's json results through the app's own install, run from `cwd` (a side folder): `{ results }` or `{ error }`. The child runs while the caller goes on. */
143
- export async function runLinter({ cwd, pkg, bin, args, bound = null }) {
123
+ export async function runLinter({ cwd, pkg, bin, args, bound = null }, execute) {
144
124
  let entry;
145
125
  try {
146
126
  const manifest = createRequire(path.join(cwd, 'package.json')).resolve(`${pkg}/package.json`);
@@ -151,7 +131,7 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null }) {
151
131
  return { error: `${pkg} is not installed for ${cwd}` };
152
132
  }
153
133
  const heap = pkg === 'eslint' ? eslintHeapArgs() : [];
154
- const run = await runNode([...heap, ...(bound ? linterBoundArgs() : []), entry, ...args], { cwd, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) });
134
+ const run = completedNode(await runHeavyNode(execute, { kind: 'eslint', label: `app lint ${pkg} ${path.basename(cwd)}`, args: [...heap, ...(bound ? linterBoundArgs() : []), entry, ...args], cwd, maxBuffer: 64 * 1024 * 1024, ...(bound ? { env: { ...process.env, [BOUND_ENV]: bound } } : {}) }), pkg);
155
135
  // ESLint prints its report on stdout; stylelint uses stderr. JSON does not prove a completed child: only exits 0/1
156
136
  // without a spawn error or signal are measurable, and exit 1 must carry findings.
157
137
  let results;
@@ -348,7 +328,11 @@ function checkedEslintResults({ results, expected, cwd, scope, changed }) {
348
328
  const ESLINT_ARGV_BUDGET = 24_000;
349
329
  const ESLINT_CONCURRENCY = 3;
350
330
  /** A launch that threw instead of returning a record is a tool error of the run (exit 2), not a crash of the CLI. */
351
- const spawnFailure = (error) => ({ error: `could not start eslint: ${String(error?.message ?? error)}` });
331
+ const spawnFailure = (error) => {
332
+ if (isNodeExecutionRefusal(error))
333
+ throw error;
334
+ return { error: `could not start eslint: ${String(error?.message ?? error)}` };
335
+ };
352
336
  /** Starts the ESLint child of every side that has work and returns one record per side: skipped, a scope error, or the running child. */
353
337
  async function launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run = runLinter }) {
354
338
  const launches = [];
@@ -386,7 +370,12 @@ export async function eslintRepository({ repoRoot, app = readRepoDeclaration(loa
386
370
  if (!app.sides)
387
371
  throw new Error('ESLint runs at the app root, the folder of hfs.json');
388
372
  const launches = await launchEslint({ app, repoRoot, opts, changed, existing, workspace, onFe, run });
389
- const runs = await Promise.all(launches.map(launch => launch.run ?? null));
373
+ const settled = await Promise.allSettled(launches.map(launch => launch.run ?? null));
374
+ const rejected = settled.filter((run) => run.status === 'rejected');
375
+ const failed = rejected.find(run => isHostHeld(run.reason)) ?? rejected[0];
376
+ if (failed !== undefined)
377
+ throw failed.reason;
378
+ const runs = settled.map(run => run.status === 'fulfilled' ? run.value : null);
390
379
  launches.forEach((launch, at) => {
391
380
  const { side } = launch;
392
381
  if (launch.skipped) {
@@ -528,7 +517,7 @@ export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run,
528
517
  return { results: sub.raw, errors: sub.errors };
529
518
  },
530
519
  stylelint: styled ? async (files) => {
531
- const linted = await runLinter({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json'] });
520
+ const linted = await (run ?? runLinter)({ cwd, pkg: 'stylelint', bin: 'stylelint', args: [...(files ?? [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json'] });
532
521
  const appRelative = appRelativeMessages(STYLE_SIDE, cwd);
533
522
  for (const result of linted.results ?? [])
534
523
  for (const warning of result.warnings ?? [])
@@ -551,13 +540,13 @@ export async function cachedLinters({ repoRoot, app, opts, workspace, onFe, run,
551
540
  return { eslint, stylelint: style, sides };
552
541
  }
553
542
  /** Starts stylelint over the fe stylesheets (the running child), or returns null when no stylesheet is in scope. */
554
- function launchStylelint({ repoRoot, opts, changed, existing, onFe }) {
543
+ function launchStylelint({ repoRoot, opts, changed, existing, onFe, run }) {
555
544
  if (opts.side === 'be')
556
545
  return null;
557
546
  const styles = onSide(existing, STYLE_SIDE).filter((file) => file.endsWith('.css'));
558
547
  if (changed !== null && !styles.length)
559
548
  return null;
560
- return runLinter({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
549
+ return (run ?? runLinter)({ cwd: path.join(repoRoot, STYLE_SIDE), pkg: 'stylelint', bin: 'stylelint', args: [...(changed ? styles : [onFe ? `${onFe}/src/**/*.css` : STYLE_GLOB]), '--formatter', 'json', ...(opts.fix ? ['--fix'] : [])] });
561
550
  }
562
551
  /**
563
552
  * Run the whole lint of the app at `repoRoot`. `hfsCheck(repoRoot)` returns the `starci app check` result (`{ findings, tracked }`); the CLI
@@ -613,8 +602,14 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
613
602
  const cached = cache !== undefined && changed === null && !opts.fix ? cache : null;
614
603
  const cachedRun = app && cached ? cachedLinters({ app, repoRoot, opts, workspace, onFe, run: linter, cache: cached }) : null;
615
604
  const eslintRun = app ? (cachedRun ? cachedRun.then((answer) => answer.eslint) : eslintRepository({ app, repoRoot, opts, changed: changed ? [...changed] : null, existing, workspace, onFe, run: linter })) : null;
616
- const styleLaunch = app && !cachedRun ? launchStylelint({ repoRoot, opts, changed, existing, onFe }) : null;
605
+ const styleLaunch = app && !cachedRun ? launchStylelint({ repoRoot, opts, changed, existing, onFe, run: linter }) : null;
617
606
  const checkRun = app ? Promise.resolve().then(() => hfsCheck(repoRoot)).then((value) => ({ value, error: undefined }), (error) => ({ value: undefined, error })) : null;
607
+ // A transport refusal does not abandon a sibling child or let its rejection escape unobserved.
608
+ const completed = await Promise.allSettled([eslintRun, styleLaunch, checkRun]);
609
+ const refused = completed.filter((run) => run.status === 'rejected');
610
+ const failure = refused.find(run => isHostHeld(run.reason)) ?? refused[0];
611
+ if (failure !== undefined)
612
+ throw failure.reason;
618
613
  // 1. ESLint, once per side, from the side folder with that side's config.
619
614
  const eslint = eslintRun ? await eslintRun : { engine: { sides: {} }, errors: [], raw: [], findings: [] };
620
615
  engines.eslint = eslint.engine;
@@ -649,6 +644,8 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
649
644
  let checked = { findings: [] };
650
645
  if (checkRun) {
651
646
  const settled = await checkRun;
647
+ if (isNodeExecutionRefusal(settled.error))
648
+ throw settled.error;
652
649
  if (settled.error)
653
650
  errors.push(`starci app check could not run: ${String(settled.error?.message ?? settled.error)}`);
654
651
  else
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "5.0.4",
3
+ "version": "5.0.6",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -8,7 +8,7 @@ const PASSING = new Set(['success', 'neutral']);
8
8
  export const candidateRef = (tag, prefix = 'v') => `${CANDIDATE_PREFIX}${String(tag).startsWith(prefix) ? String(tag).slice(prefix.length) : String(tag)}`;
9
9
  function run(ctx, command, args) {
10
10
  try {
11
- return ctx.spawn(command, args, { cwd: ctx.cwd });
11
+ return ctx.spawn(command, args, { cwd: ctx.cwd, env: ctx.env });
12
12
  }
13
13
  catch {
14
14
  return { status: null };
@@ -72,6 +72,7 @@ export declare const refName: (value: unknown) => value is string;
72
72
  export declare function normalizeRelease(block: unknown, scripts?: Record<string, unknown> | undefined, services?: Services, options?: {
73
73
  example?: boolean;
74
74
  entities?: boolean;
75
+ repository?: string;
75
76
  }): {
76
77
  config: ReleaseConfig;
77
78
  problems?: undefined;