@starci/hfs 5.0.3 → 5.0.5

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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.5
4
+
5
+ - Changed: Govern each app-check linter, compiler, test discovery and coverage, contract emitter and boot child through the CLI execution port with actual worker limits and complete output capture. Preserve cache identities, closure, flush behavior and coverage thresholds. Record source-proven native Page body layouts with their contextual constraints.
6
+
7
+ ## 5.0.4
8
+
9
+ - Changed: Record the existing shared Page semantic role in the selected canonical Grammar component contract, so universal composition proof does not infer concrete API names or accept an app-controlled switch.
10
+ - Fixed: Run the package export proof's normal pack lifecycle in a private source copy, so prepack never rewrites emit modules that concurrent worker proofs import.
11
+
3
12
  ## 5.0.3
4
13
 
5
14
  - Changed: Bundled canon pins name the builder-provenance BE canon release and its coherent dependency cohort. This package changes no runtime behavior or rule severity.
@@ -1,3 +1,4 @@
1
+ import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import { type CheckFinding } from '../runtime/scripts/hfs/check.mjs';
2
3
  import { type TestFamilyResult } from './test-family.mjs';
3
4
  import { type TypesFamilyResult, type TypesProgram } from './types-family.mjs';
@@ -6,24 +7,26 @@ import type { CheckSession } from './types.mjs';
6
7
  /** The tsc programs of the app: the back end and each front-end workspace, from the tracked tsconfigs. */
7
8
  export declare function programsOf(tracked: readonly string[]): TypesProgram[];
8
9
  /** `--types`: `tsc --noEmit` per workspace program through the app's TypeScript, reused by program key. */
9
- export declare function runTypesForApp(session: CheckSession, { repoRoot, tracked, env }: {
10
+ export declare function runTypesForApp(session: CheckSession, { repoRoot, tracked, env, execute }: {
10
11
  repoRoot: string;
11
12
  tracked: readonly string[];
12
13
  env: NodeJS.ProcessEnv;
13
- }): {
14
+ execute?: HeavyNodeRun | undefined;
15
+ }): Promise<{
14
16
  result: TypesFamilyResult;
15
17
  findings: CheckFinding[];
16
- };
18
+ }>;
17
19
  /** `--test`: the unit specs of the back end through the app's own Jest, reused by closure key; the merged total is judged by `@starci/hfs` (the coverage proof). */
18
- export declare function runTestForApp(session: CheckSession, { repoRoot, tracked }: {
20
+ export declare function runTestForApp(session: CheckSession, { repoRoot, tracked, execute }: {
19
21
  repoRoot: string;
20
22
  tracked: readonly string[];
23
+ execute?: HeavyNodeRun | undefined;
21
24
  }): Promise<{
22
25
  result: TestFamilyResult | null;
23
26
  findings: CheckFinding[];
24
27
  }>;
25
28
  /** `--test --full --shard i/n`: one shard of the full unit run in the shape the shard merge reads; nothing is recorded. */
26
- export declare function runTestShard(repoRoot: string, index: number, total: number): number;
29
+ export declare function runTestShard(repoRoot: string, index: number, total: number, execute?: HeavyNodeRun): Promise<number>;
27
30
  /** `--sonar`: the Sonar and Codecov baselines and the predictions from the merged coverage records; they never pass or fail a run. */
28
31
  export declare function runSonarForApp(session: CheckSession, { repoRoot }: {
29
32
  repoRoot: string;
@@ -5,7 +5,7 @@
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
7
  import { createRequire } from 'node:module';
8
- import { spawnSync } from 'node:child_process';
8
+ import { completedNode, runHeavyNode } from '../runtime/scripts/api/node/run-node.mjs';
9
9
  import { skillRoot } from '../runtime/engine/runtime-root.mjs';
10
10
  import { coverageFrom } from '../runtime/scripts/hfs/coverage-from.mjs';
11
11
  import { readWhy } from '../runtime/scripts/hfs/check.mjs';
@@ -30,11 +30,11 @@ export function programsOf(tracked) {
30
30
  return programs.sort((a, b) => (a.name < b.name ? -1 : 1));
31
31
  }
32
32
  /** `--types`: `tsc --noEmit` per workspace program through the app's TypeScript, reused by program key. */
33
- export function runTypesForApp(session, { repoRoot, tracked, env }) {
33
+ export async function runTypesForApp(session, { repoRoot, tracked, env, execute }) {
34
34
  const programs = programsOf(tracked);
35
35
  const trackedSet = new Set(tracked);
36
- const result = runTypesFamily({
37
- session, programs, run: createTscRunner({ appRoot: repoRoot }), toolCacheDir: toolCacheDir(repoRoot, env),
36
+ const result = await runTypesFamily({
37
+ session, programs, run: createTscRunner({ appRoot: repoRoot, execute }), toolCacheDir: toolCacheDir(repoRoot, env),
38
38
  inputsOf: (program) => {
39
39
  const side = program.name.startsWith('be') ? 'be' : 'fe';
40
40
  const prefix = `${program.reads?.[0] ?? side}/`;
@@ -45,12 +45,12 @@ export function runTypesForApp(session, { repoRoot, tracked, env }) {
45
45
  return { result, findings: result.findings.map((found) => asFinding(found, 'TS_DIAGNOSTIC', 'error', 'tsc')) };
46
46
  }
47
47
  /** `--test`: the unit specs of the back end through the app's own Jest, reused by closure key; the merged total is judged by `@starci/hfs` (the coverage proof). */
48
- export async function runTestForApp(session, { repoRoot, tracked }) {
48
+ export async function runTestForApp(session, { repoRoot, tracked, execute }) {
49
49
  if (!tracked.includes('be/package.json') && !tracked.some((file) => file.startsWith('be/')))
50
50
  return { result: null, findings: [] };
51
- const jest = await createJestEnvironment({ appRoot: repoRoot, side: 'be' });
51
+ const jest = await createJestEnvironment({ appRoot: repoRoot, side: 'be', execute });
52
52
  let judged = [];
53
- const result = runTestFamily({
53
+ const result = await runTestFamily({
54
54
  session, jest, side: 'be', merge: installedSpecMerge(repoRoot),
55
55
  judge: (lcovPath) => {
56
56
  const report = coverageFrom({ repoRoot, lcov: fs.readFileSync(lcovPath, 'utf8'), files: [...tracked] });
@@ -64,10 +64,12 @@ export async function runTestForApp(session, { repoRoot, tracked }) {
64
64
  return { result, findings };
65
65
  }
66
66
  /** `--test --full --shard i/n`: one shard of the full unit run in the shape the shard merge reads; nothing is recorded. */
67
- export function runTestShard(repoRoot, index, total) {
67
+ export async function runTestShard(repoRoot, index, total, execute) {
68
68
  const shard = shardArgs(index, total);
69
69
  const jestBin = createRequire(path.join(repoRoot, 'be', 'package.json')).resolve('jest/bin/jest');
70
- const run = spawnSync(process.execPath, [jestBin, ...shard.args], { cwd: path.join(repoRoot, 'be'), stdio: 'inherit', env: { ...process.env, ...shard.env } });
70
+ const run = completedNode(await runHeavyNode(execute, { kind: 'test', label: `app check jest shard ${index}/${total}`, args: [jestBin, ...shard.args], cwd: path.join(repoRoot, 'be'), env: { ...process.env, ...shard.env }, maxBuffer: 256 * 1024 * 1024 }), 'jest shard');
71
+ process.stdout.write(run.stdout);
72
+ process.stderr.write(run.stderr);
71
73
  return run.status ?? 1;
72
74
  }
73
75
  /** `--sonar`: the Sonar and Codecov baselines and the predictions from the merged coverage records; they never pass or fail a run. */
@@ -1,3 +1,4 @@
1
+ import type { HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import { type MachineFn, type RepositoryResult } from '../runtime/scripts/hfs/check.mjs';
2
3
  import type { Presets } from '../sync/index.mjs';
3
4
  import type { CheckSession, FamilyReport } from './types.mjs';
@@ -20,6 +21,7 @@ export interface PassResult {
20
21
  }
21
22
  export interface PassInput {
22
23
  readonly repoRoot: string;
24
+ readonly execute?: HeavyNodeRun | undefined;
23
25
  readonly harness?: {
24
26
  version?: string;
25
27
  } | undefined;
@@ -7,9 +7,9 @@ import { checkRepo, checkRepository, trackedFiles } from '../runtime/scripts/hfs
7
7
  import { checkArchitecture } from '../runtime/scripts/hfs/architecture/index.mjs';
8
8
  import { withoutBootProof } from '../runtime/scripts/hfs/rules/boot-proof.mjs';
9
9
  import { withUnusedSymbolMemo } from '../runtime/scripts/hfs/rules/unused-symbol.mjs';
10
- import { contractEmitFindings } from '../runtime/scripts/hfs/rules/contract.mjs';
10
+ import { contractEmitFindingsAsync } from '../runtime/scripts/hfs/rules/contract.mjs';
11
11
  import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
12
- import { emitContracts } from '../emit/contracts.mjs';
12
+ import { emitContractsAsync } from '../emit/contracts.mjs';
13
13
  import { summarize } from '../runtime/scripts/hfs/check-results.mjs';
14
14
  import { managedFindings } from '../sync/managed.mjs';
15
15
  import { RecordSet, scopedStep } from './records.mjs';
@@ -61,9 +61,9 @@ export async function runStructurePass(session, input) {
61
61
  const key = keys.get('be');
62
62
  const emit = await scopedStep(session, records, {
63
63
  path: 'be', step: 'contract-emit', key: key?.key ?? 'none', reuse, inputs: key?.inputs ?? [],
64
- compute: () => {
64
+ compute: async () => {
65
65
  const files = tracked.filter((file) => file.startsWith(bePrefix)).map((file) => file.slice(bePrefix.length));
66
- const emitted = contractEmitFindings({ repoRoot: path.join(repoRoot, 'be'), files, repo: be, emit: emitContracts });
66
+ const emitted = await contractEmitFindingsAsync({ repoRoot: path.join(repoRoot, 'be'), files, repo: be, emit: (request) => emitContractsAsync({ ...request, execute: input.execute }) });
67
67
  return JSON.parse(JSON.stringify({ findings: emitted.findings, apps: emitted.apps }));
68
68
  },
69
69
  compact: (value) => (value.findings).map(compact),
package/cache/run.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import { type CheckFinding, type RepositoryResult } from '../runtime/scripts/hfs/check.mjs';
2
3
  import type { Presets } from '../sync/index.mjs';
3
4
  import type { Prettier } from '../sync/format.mjs';
@@ -6,6 +7,7 @@ import { type CheckSession, type FamilyId, type FamilyReport } from './types.mjs
6
7
  export declare const DEFAULT_FAMILIES: readonly FamilyId[];
7
8
  export interface AppRunInput {
8
9
  readonly repoRoot: string;
10
+ readonly execute?: HeavyNodeRun | undefined;
9
11
  readonly tracked: readonly string[];
10
12
  readonly session: CheckSession;
11
13
  readonly selected: ReadonlySet<FamilyId>;
package/cache/run.mjs CHANGED
@@ -1,6 +1,3 @@
1
- // run.mts - one `starci app check` / `starci app lint` pass over the selected families, against one session. It owns the order of the families,
2
- // the judging of their findings (edition and the Vietnamese why of every code) and the merge into the one result the verb prints and the gate
3
- // judges, so the two verbs never hold two answers for one tree.
4
1
  import { skillRoot } from '../runtime/engine/runtime-root.mjs';
5
2
  import { readWhy } from '../runtime/scripts/hfs/check.mjs';
6
3
  import { judgedFindings, summarize, withWhy } from '../runtime/scripts/hfs/check-results.mjs';
@@ -23,7 +20,7 @@ export async function runAppFamilies(input) {
23
20
  const track = (report) => void (report && reports.push(report));
24
21
  const structure = selected.has('hfs') || selected.has('secrets');
25
22
  const pass = structure
26
- ? await runStructurePass(session, { repoRoot, harness: input.harness, presets: input.presets, tracked: input.tracked, hfs: selected.has('hfs'), secrets: selected.has('secrets') })
23
+ ? await runStructurePass(session, { repoRoot, harness: input.harness, presets: input.presets, tracked: input.tracked, hfs: selected.has('hfs'), secrets: selected.has('secrets'), execute: input.execute })
27
24
  : null;
28
25
  if (pass) {
29
26
  track(pass.report);
@@ -43,7 +40,7 @@ export async function runAppFamilies(input) {
43
40
  }
44
41
  let wiring;
45
42
  if (selected.has('wiring')) {
46
- const proved = await runWiringFamily(session, { repoRoot, edition: input.edition, files: input.tracked });
43
+ const proved = await runWiringFamily(session, { repoRoot, edition: input.edition, files: input.tracked, execute: input.execute });
47
44
  track(proved.report);
48
45
  wiring = { status: proved.wiring.status, apps: proved.wiring.apps, findings: proved.wiring.findings };
49
46
  extra.push(...proved.wiring.findings);
@@ -1,3 +1,4 @@
1
+ import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import type { CacheRecord, CheckSession, FamilyReport, JsonValue } from './types.mjs';
2
3
  /** The folder of the session that holds the records of this family. */
3
4
  export declare const TEST_FOLDER = "test-coverage";
@@ -33,7 +34,7 @@ export interface JestEnvironment {
33
34
  /** The digest of the unit project's configuration, free of host paths. */
34
35
  readonly configHash: string;
35
36
  /** Runs `specs` (all of them when `specs` is every spec) writing the per-spec coverage into `coverageDir`. */
36
- run(specs: readonly string[], coverageDir: string): RunOutcome;
37
+ run(specs: readonly string[], coverageDir: string): RunOutcome | Promise<RunOutcome>;
37
38
  /** Reads what the reporter wrote into `coverageDir`. */
38
39
  readCoverage(coverageDir: string): readonly SpecCoverageOutput[];
39
40
  }
@@ -94,7 +95,7 @@ export declare function sourcesOf(record: CacheRecord): Record<string, SourceIte
94
95
  * Runs the unit specs against the cache. Diff mode runs the specs with no current record, full mode all of them; a passing spec's coverage is stored under its key,
95
96
  * a failing one's is not, and the folder is replaced with exactly the current specs' records. The merged total is written to `<side>/coverage/lcov.info` and judged.
96
97
  */
97
- export declare function runTestFamily(options: TestFamilyOptions): TestFamilyResult;
98
+ export declare function runTestFamily(options: TestFamilyOptions): Promise<TestFamilyResult>;
98
99
  /** The part of a Jest project config the closure needs. */
99
100
  interface ProjectConfigLike {
100
101
  readonly rootDir: string;
@@ -111,11 +112,12 @@ export declare function projectConfigHash(config: ProjectConfigLike): string;
111
112
  * The app's own Jest, resolved from `jestFrom` (a package.json or folder inside the app's install): its unit project's configuration, its haste map and resolver
112
113
  * (so `moduleNameMapper`, `jest.mock` targets and setup files count as Jest sees them), and its binary to run specs. `side` is the Jest root, app-relative.
113
114
  */
114
- export declare function createJestEnvironment({ appRoot, side, jestFrom, project }: {
115
+ export declare function createJestEnvironment({ appRoot, side, jestFrom, project, execute }: {
115
116
  appRoot: string;
116
117
  side: string;
117
118
  jestFrom?: string;
118
119
  project?: string;
120
+ execute?: HeavyNodeRun | undefined;
119
121
  }): Promise<JestEnvironment>;
120
122
  /** The installed preset's union merge (resolved like the shard merge of `app coverage`): the records in, the lcov and coverage-final.json out. */
121
123
  export declare function installedSpecMerge(appRoot: string, from?: string): MergeSpecRecords;
@@ -8,7 +8,7 @@ import fs from 'node:fs';
8
8
  import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
- import { spawnSync } from 'node:child_process';
11
+ import { completedNode, runHeavyNode } from '../runtime/scripts/api/node/run-node.mjs';
12
12
  import { combineHashes, hashFile } from './hash.mjs';
13
13
  import { canonicalJson } from './jsonl.mjs';
14
14
  /** The folder of the session that holds the records of this family. */
@@ -57,7 +57,7 @@ function recordOf(spec, key, sources, side) {
57
57
  * Runs the unit specs against the cache. Diff mode runs the specs with no current record, full mode all of them; a passing spec's coverage is stored under its key,
58
58
  * a failing one's is not, and the folder is replaced with exactly the current specs' records. The merged total is written to `<side>/coverage/lcov.info` and judged.
59
59
  */
60
- export function runTestFamily(options) {
60
+ export async function runTestFamily(options) {
61
61
  const { session, jest, side, merge, judge } = options;
62
62
  const decision = session.decide('test');
63
63
  const hashOf = (file) => session.index.hashOf(file) ?? hashFile(path.join(session.root, file));
@@ -79,7 +79,7 @@ export function runTestFamily(options) {
79
79
  try {
80
80
  fs.rmSync(scratch, { recursive: true, force: true });
81
81
  fs.mkdirSync(scratch, { recursive: true });
82
- output = jest.run(toRun, scratch).output;
82
+ output = (await jest.run(toRun, scratch)).output;
83
83
  const reported = new Map(jest.readCoverage(scratch).map((file) => [file.spec, file]));
84
84
  for (const spec of toRun) {
85
85
  const file = reported.get(posix(path.relative(path.join(session.root, side), path.join(session.root, spec))));
@@ -144,7 +144,7 @@ export function projectConfigHash(config) {
144
144
  * The app's own Jest, resolved from `jestFrom` (a package.json or folder inside the app's install): its unit project's configuration, its haste map and resolver
145
145
  * (so `moduleNameMapper`, `jest.mock` targets and setup files count as Jest sees them), and its binary to run specs. `side` is the Jest root, app-relative.
146
146
  */
147
- export async function createJestEnvironment({ appRoot, side, jestFrom, project = 'unit' }) {
147
+ export async function createJestEnvironment({ appRoot, side, jestFrom, project = 'unit', execute }) {
148
148
  const root = path.resolve(appRoot, side);
149
149
  const fromApp = createRequire(jestFrom ?? path.join(root, 'package.json'));
150
150
  const jestPackage = fromApp.resolve('jest/package.json');
@@ -189,7 +189,7 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
189
189
  const relative = (file) => posix(path.relative(appRoot, file));
190
190
  const setup = (name) => [...(config[name] ?? [])];
191
191
  const setupClosure = closureFrom([...setup('setupFiles'), ...setup('setupFilesAfterEnv')]).map(relative);
192
- const listed = spawnSync(process.execPath, [jestBin, '--selectProjects', project, '--listTests'], { cwd: root, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
192
+ const listed = completedNode(await runHeavyNode(execute, { kind: 'test', label: `app check jest ${project} discovery`, args: [jestBin, '--selectProjects', project, '--listTests'], cwd: root, maxBuffer: 64 * 1024 * 1024 }), 'jest discovery');
193
193
  if (listed.status !== 0)
194
194
  throw new Error(`jest --listTests failed under ${side}: ${(listed.stderr || listed.stdout).split('\n')[0]}`);
195
195
  const specs = listed.stdout.split(/\r?\n/u).map((line) => line.trim()).filter((line) => path.isAbsolute(line)).map((line) => relative(path.resolve(line))).sort();
@@ -206,7 +206,7 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
206
206
  closures.set(spec, closure);
207
207
  return closure;
208
208
  },
209
- run(toRun, coverageDir) {
209
+ async run(toRun, coverageDir) {
210
210
  const base = [jestBin, '--selectProjects', project, '--coverage', '--ci'];
211
211
  const everything = toRun.length === specs.length;
212
212
  const sideRelative = toRun.map((spec) => posix(path.relative(root, path.resolve(appRoot, spec))));
@@ -223,7 +223,7 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
223
223
  let output = '';
224
224
  for (const chunk of chunks) {
225
225
  const args = chunk.length > 0 ? [...base, '--runTestsByPath', ...chunk] : base;
226
- const result = spawnSync(process.execPath, args, { cwd: root, encoding: 'utf8', env: { ...process.env, STARCI_SPEC_COVERAGE_DIR: coverageDir }, maxBuffer: 256 * 1024 * 1024 });
226
+ const result = completedNode(await runHeavyNode(execute, { kind: 'test', label: `app check jest ${project} coverage`, args, cwd: root, env: { ...process.env, STARCI_SPEC_COVERAGE_DIR: coverageDir }, maxBuffer: 256 * 1024 * 1024 }), 'jest coverage');
227
227
  status = status || (result.status ?? 1);
228
228
  output += `${result.stdout}${result.stderr}`;
229
229
  }
@@ -1,3 +1,4 @@
1
+ import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import type { CheckSession, FamilyReport, Finding } from './types.mjs';
2
3
  /** The folder of the session that holds the records of this family. */
3
4
  export declare const TYPES_FOLDER = "check/types";
@@ -29,7 +30,7 @@ export interface TypesFamilyOptions {
29
30
  readonly session: CheckSession;
30
31
  readonly programs: readonly TypesProgram[];
31
32
  /** Runs one program's `tsc`; `buildInfo` is where its `.tsbuildinfo` goes (the untracked tool cache). */
32
- readonly run: (program: TypesProgram, buildInfo: string) => TscOutcome;
33
+ readonly run: (program: TypesProgram, buildInfo: string) => TscOutcome | Promise<TscOutcome>;
33
34
  /** The untracked tool cache of slot `app.tool-cache` (`<STARCI_CACHE_HOME>/<repo>/app-check`). */
34
35
  readonly toolCacheDir: string;
35
36
  readonly inputsOf?: (program: TypesProgram) => ProgramInputs;
@@ -63,12 +64,13 @@ export declare function parseTscOutput(output: string, appRoot: string, fallback
63
64
  * Runs the type check of every program against the cache. A program whose key is unchanged takes its recorded diagnostics; a changed one runs in full (all of them
64
65
  * under `--full`). A run that produced no diagnostics and failed (tsc itself could not complete) stores nothing, and so does a program whose files changed while it ran.
65
66
  */
66
- export declare function runTypesFamily(options: TypesFamilyOptions): TypesFamilyResult;
67
+ export declare function runTypesFamily(options: TypesFamilyOptions): Promise<TypesFamilyResult>;
67
68
  /**
68
69
  * The real `tsc` of an app: the program's own TypeScript (resolved from its folder, so the version the lockfile pins), `--noEmit` with an incremental build info in the
69
70
  * tool cache, run from the app root so paths in the output are app-relative.
70
71
  */
71
- export declare function createTscRunner({ appRoot, typescriptFrom }: {
72
+ export declare function createTscRunner({ appRoot, typescriptFrom, execute }: {
72
73
  appRoot: string;
73
74
  typescriptFrom?: string;
75
+ execute?: HeavyNodeRun | undefined;
74
76
  }): TypesFamilyOptions['run'];
@@ -6,7 +6,7 @@
6
6
  import fs from 'node:fs';
7
7
  import path from 'node:path';
8
8
  import { createRequire } from 'node:module';
9
- import { spawnSync } from 'node:child_process';
9
+ import { completedNode, runHeavyNode } from '../runtime/scripts/api/node/run-node.mjs';
10
10
  import { CACHE_DIR } from './types.mjs';
11
11
  import { combineHashes, hashFile } from './hash.mjs';
12
12
  import { sortFindings } from './jsonl.mjs';
@@ -84,7 +84,7 @@ const identity = (finding) => `${finding.path}\0${finding.rule}\0${finding.messa
84
84
  * Runs the type check of every program against the cache. A program whose key is unchanged takes its recorded diagnostics; a changed one runs in full (all of them
85
85
  * under `--full`). A run that produced no diagnostics and failed (tsc itself could not complete) stores nothing, and so does a program whose files changed while it ran.
86
86
  */
87
- export function runTypesFamily(options) {
87
+ export async function runTypesFamily(options) {
88
88
  const { session, programs, run, toolCacheDir } = options;
89
89
  const decision = session.decide('types');
90
90
  const previous = new Map(session.records(TYPES_FOLDER).filter((record) => record.step === TSC_STEP).map((record) => [record.path, record]));
@@ -108,7 +108,7 @@ export function runTypesFamily(options) {
108
108
  reused.push(program.name);
109
109
  }
110
110
  else {
111
- const outcome = run(program, tsBuildInfoPath(toolCacheDir, session.root, program));
111
+ const outcome = await run(program, tsBuildInfoPath(toolCacheDir, session.root, program));
112
112
  ran.push(program.name);
113
113
  current = parseTscOutput(outcome.output, session.root, program.tsconfig);
114
114
  if (current.length === 0 && outcome.status !== 0)
@@ -141,14 +141,15 @@ export function runTypesFamily(options) {
141
141
  * The real `tsc` of an app: the program's own TypeScript (resolved from its folder, so the version the lockfile pins), `--noEmit` with an incremental build info in the
142
142
  * tool cache, run from the app root so paths in the output are app-relative.
143
143
  */
144
- export function createTscRunner({ appRoot, typescriptFrom }) {
145
- return (program, buildInfo) => {
144
+ export function createTscRunner({ appRoot, typescriptFrom, execute }) {
145
+ return async (program, buildInfo) => {
146
146
  const folder = path.resolve(appRoot, path.posix.dirname(program.tsconfig));
147
147
  const tsc = path.join(path.dirname(createRequire(typescriptFrom ?? path.join(folder, 'package.json')).resolve('typescript/package.json')), 'bin', 'tsc');
148
148
  fs.mkdirSync(path.dirname(buildInfo), { recursive: true });
149
- const result = spawnSync(process.execPath, [tsc, '-p', posix(path.relative(appRoot, path.resolve(appRoot, program.tsconfig))), '--noEmit', '--incremental', '--tsBuildInfoFile', buildInfo, '--pretty', 'false'], {
150
- cwd: appRoot, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024,
151
- });
149
+ const result = completedNode(await runHeavyNode(execute, {
150
+ kind: 'tsc', label: `app check tsc ${program.name}`, args: [tsc, '-p', posix(path.relative(appRoot, path.resolve(appRoot, program.tsconfig))), '--noEmit', '--incremental', '--tsBuildInfoFile', buildInfo, '--pretty', 'false'],
151
+ cwd: appRoot, maxBuffer: 256 * 1024 * 1024,
152
+ }), 'tsc');
152
153
  return { status: result.status ?? 1, output: `${result.stdout}${result.stderr}` };
153
154
  };
154
155
  }
@@ -1,3 +1,4 @@
1
+ import type { HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import { type WiringResult } from '../runtime/scripts/hfs/app-wiring.mjs';
2
3
  import type { CheckSession, FamilyReport } from './types.mjs';
3
4
  export declare const WIRING_FOLDER = "check/wiring";
@@ -6,9 +7,10 @@ export interface WiringFamilyResult {
6
7
  readonly report: FamilyReport;
7
8
  }
8
9
  /** The boot proof family. `explicit` is the whole-tree proof of `--wiring`; without it a missing install leaves the compile unjudged, as the content finder does. */
9
- export declare function runWiringFamily(session: CheckSession, { repoRoot, edition, files, run }: {
10
+ export declare function runWiringFamily(session: CheckSession, { repoRoot, edition, files, run, execute }: {
10
11
  repoRoot: string;
11
12
  edition?: string | undefined;
12
13
  files?: readonly string[] | undefined;
13
- run?: ((repoRoot: string) => WiringResult) | undefined;
14
+ run?: ((repoRoot: string) => WiringResult | Promise<WiringResult>) | undefined;
15
+ execute?: HeavyNodeRun | undefined;
14
16
  }): Promise<WiringFamilyResult>;
@@ -2,7 +2,7 @@
2
2
  // back-end side key is unchanged. A timeout reached no verdict and is never stored.
3
3
  import fs from 'node:fs';
4
4
  import path from 'node:path';
5
- import { appWiring, BOOT_PROOF_TIMEOUT } from '../runtime/scripts/hfs/app-wiring.mjs';
5
+ import { appWiringAsync, BOOT_PROOF_TIMEOUT } from '../runtime/scripts/hfs/app-wiring.mjs';
6
6
  import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
7
7
  import { trackedFiles } from '../runtime/scripts/hfs/check.mjs';
8
8
  import { RecordSet, scopedStep } from './records.mjs';
@@ -11,16 +11,16 @@ import { sideKey } from './side-key.mjs';
11
11
  export const WIRING_FOLDER = 'check/wiring';
12
12
  const compact = (finding) => ({ path: finding.path, line: 0, column: 0, rule: finding.code, message: finding.message });
13
13
  /** The boot proof family. `explicit` is the whole-tree proof of `--wiring`; without it a missing install leaves the compile unjudged, as the content finder does. */
14
- export async function runWiringFamily(session, { repoRoot, edition, files, run }) {
14
+ export async function runWiringFamily(session, { repoRoot, edition, files, run, execute }) {
15
15
  const decision = session.decide('wiring');
16
16
  const records = new RecordSet(session.records(WIRING_FOLDER));
17
17
  const key = sideKey(repoRoot, session.index, 'be');
18
- const prove = () => {
18
+ const prove = async () => {
19
19
  if (run)
20
20
  return run(repoRoot);
21
21
  const declared = readRepoDeclaration(loadSlotManifest(), repoRoot);
22
22
  const repo = { ...declared, edition: edition ?? declared.edition };
23
- const proof = appWiring({ repoRoot, repo: repo, files: [...(files ?? trackedFiles(repoRoot))] });
23
+ const proof = await appWiringAsync({ repoRoot, repo: repo, files: [...(files ?? trackedFiles(repoRoot))], execute });
24
24
  const installed = fs.existsSync(path.join(repoRoot, 'node_modules')) || fs.existsSync(path.join(repoRoot, 'be', 'node_modules'));
25
25
  const findings = proof.findings.filter((finding) => installed || finding.reason !== 'module or proof could not be compiled');
26
26
  return { ...proof, findings, ok: findings.length === 0 };
@@ -28,7 +28,7 @@ export async function runWiringFamily(session, { repoRoot, edition, files, run }
28
28
  const step = session.progress.step('wiring', 'boot proof', Math.max(1, key.inputs.length));
29
29
  const { value, reused } = await scopedStep(session, records, {
30
30
  path: 'be', step: 'boot-proof', key: key.key, reuse: decision.mode === 'diff', inputs: key.inputs,
31
- compute: () => JSON.parse(JSON.stringify(prove())),
31
+ compute: async () => JSON.parse(JSON.stringify(await prove())),
32
32
  compact: (found) => found.findings.map(compact),
33
33
  store: (found) => !found.findings.some((finding) => finding.code === BOOT_PROOF_TIMEOUT),
34
34
  });
@@ -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.3",
3
+ "version": "5.0.5",
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",
@@ -68,6 +68,18 @@ families:
68
68
  package: '@starci/grammar'
69
69
  entry: '@starci/grammar/common'
70
70
  styleEntry: '@starci/grammar/common.css'
71
+ semanticRoles:
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] } }
71
83
  peers: [react, '@heroui/react', '@xyflow/react', react-aria-components]
72
84
  packageSnapshot: common/DNA.yaml
73
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.7
34
+ version: 0.21.9
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.14
41
+ version: 3.11.16
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.7
48
+ version: 9.9.9
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.7
55
+ version: 0.21.9
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.7
62
+ version: 0.21.9
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.3
109
+ version: 5.0.5
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -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);
@@ -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;
@@ -8,7 +8,7 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { createRequire } from 'node:module';
11
- import { runNode } from '../api/node/run-node.mjs';
11
+ import { runNode, runHeavyNode } from '../api/node/run-node.mjs';
12
12
  import { fileURLToPath } from 'node:url';
13
13
  import { invocationDir } from '../lib/roots.mjs';
14
14
  import { isMain } from '../lib/is-main.mjs';
@@ -174,7 +174,7 @@ function wiringApps(repo, files, prefix) {
174
174
  });
175
175
  return [...new Set([...declared, ...observed])];
176
176
  }
177
- function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout, run }) {
177
+ function* appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout }) {
178
178
  const file = `${prefix}${modulePath(app)}`;
179
179
  if (!/^[a-z][a-z0-9-]*$/.test(app) || !fs.existsSync(path.join(repoRoot, file)) || (files && !files.includes(file))) {
180
180
  return refusal(file, 'app module is missing');
@@ -184,10 +184,9 @@ function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, time
184
184
  const args = [WORKER, '--worker', JSON.stringify({ repoRoot: beRoot, app, fixture: fixtureExists ? fixture : null, timeout })];
185
185
  const budget = workerBudget(timeout);
186
186
  const options = { cwd: beRoot, encoding: 'utf8', env: {}, windowsHide: true, timeout: budget, maxBuffer: 1024 * 1024 };
187
- const spawnWorker = () => (run ? run(process.execPath, args, options) : runNode(args, options));
188
- let result = spawnWorker();
187
+ let result = yield { args, options };
189
188
  if (isTimeout(result))
190
- result = spawnWorker();
189
+ result = yield { args, options };
191
190
  if (isTimeout(result))
192
191
  return timedOut(file, budget);
193
192
  let proof;
@@ -199,7 +198,7 @@ function appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, time
199
198
  return refusal(file, SAFE_REASONS.has(proof?.reason) ? proof?.reason : WORKER_FAILURE);
200
199
  return null;
201
200
  }
202
- export function appWiring({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), files, fixtures = {}, timeout = DEFAULT_TIMEOUT, run }) {
201
+ function* wiringProgram({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), files, fixtures = {}, timeout = DEFAULT_TIMEOUT }) {
203
202
  if (repo?.edition !== 'lite' || repo?.profile === 'fe' || repo?.kind === 'fe')
204
203
  return { ok: true, status: 'skipped', findings: [], apps: [] };
205
204
  const appRoot = Boolean(repo.sides);
@@ -208,12 +207,33 @@ export function appWiring({ repoRoot, repo = readJson(repoRoot, 'hfs.json'), fil
208
207
  const apps = wiringApps(repo, files, prefix);
209
208
  const findings = [];
210
209
  for (const app of apps) {
211
- const finding = appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout, run });
210
+ const finding = yield* appWiringFinding({ repoRoot, beRoot, prefix, app, files, fixtures, timeout });
212
211
  if (finding)
213
212
  findings.push(finding);
214
213
  }
215
214
  return { ok: findings.length === 0, status: 'checked', apps, findings };
216
215
  }
216
+ /** The synchronous finder and its existing worker doubles drive the same proof program as app check. */
217
+ export function appWiring(input) {
218
+ const program = wiringProgram(input);
219
+ let step = program.next();
220
+ while (!step.done) {
221
+ const { args, options } = step.value;
222
+ step = program.next(input.run ? input.run(process.execPath, args, options) : runNode(args, options));
223
+ }
224
+ return step.value;
225
+ }
226
+ /** The cache's asynchronous execution seam admits each boot attempt, with the same validation, timeout retry and result judgement. */
227
+ export async function appWiringAsync(input) {
228
+ const program = wiringProgram(input);
229
+ let step = program.next();
230
+ while (!step.done) {
231
+ const { args, options } = step.value;
232
+ const result = await runHeavyNode(input.execute, { kind: 'test', label: 'app check wiring boot proof', args, cwd: options.cwd, env: options.env, timeoutMs: options.timeout, maxBuffer: options.maxBuffer });
233
+ step = program.next(result);
234
+ }
235
+ return step.value;
236
+ }
217
237
  export function appWiringMain(argv, { cwd = invocationDir(), stdout = (text) => process.stdout.write(text), stderr = (text) => process.stderr.write(text) } = {}) {
218
238
  let directory = cwd, json = false;
219
239
  for (let at = 0; at < argv.length; at += 1) {
@@ -1,5 +1,7 @@
1
1
  /** Imported JSX components receive repeated data contracts; executable prop values remain compared. */
2
2
  import type { Node, SourceFile } from 'typescript';
3
3
  import type { TypeScriptApi } from './config.mjs';
4
+ /** The one app status-owner binding of FE-ERROR-1, shared by clone and body-flow proofs. */
5
+ export declare function canonicalStatusOwnerBinding(module: string | null, name: string | null): boolean;
4
6
  /** Canon data prop objects qualify through an imported component binding, never its chosen name or file path. */
5
7
  export declare function canonJsxCloneNodes(ts: TypeScriptApi, sourceFile: SourceFile): Set<Node>;
@@ -152,12 +152,16 @@ function nestedDataObjects(ts, expression, bindingOf, excluded) {
152
152
  nestedDataObjects(ts, element, bindingOf, excluded);
153
153
  }
154
154
  }
155
+ /** The one app status-owner binding of FE-ERROR-1, shared by clone and body-flow proofs. */
156
+ export function canonicalStatusOwnerBinding(module, name) {
157
+ return name === 'SlotView' && module !== null && /(?:^|\/)components\/composites\/SlotView(?:\/index)?(?:\.[cm]?tsx?)?$/.test(module);
158
+ }
155
159
  /** Resolve the canonical status owner through its import binding, including aliases and namespaces. */
156
160
  function statusOwner(ts, tag, bindingOf) {
157
161
  const binding = ts.isIdentifier(tag) ? bindingOf(tag) : ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression) ? bindingOf(tag.expression) : null;
158
162
  const module = binding?.kind === 'component' || binding?.kind === 'namespace' ? binding.module : null;
159
163
  const name = binding?.kind === 'component' ? binding.name : ts.isPropertyAccessExpression(tag) ? tag.name.text : null;
160
- return name === 'SlotView' && module !== null && /(?:^|\/)components\/composites\/SlotView(?:\/index)?(?:\.[cm]?tsx?)?$/.test(module);
164
+ return canonicalStatusOwnerBinding(module, name);
161
165
  }
162
166
  /** The mandated status wiring breaks clone windows; its render body remains ordinary compared code. */
163
167
  function statusRecipe(ts, opening, bindingOf, excluded) {
@@ -15,12 +15,20 @@ export declare function contractFindings({ repoRoot, files, repo, resolver }: {
15
15
  * `openapi.json` and `status` one of `fresh`, `stale`, `not-committed`, `none` (nothing emitted, nothing committed; GraphQL only),
16
16
  * `left-behind` (committed, nothing emitted) or `emit-failed`.
17
17
  */
18
- export declare function contractEmitFindings({ repoRoot, files, repo, emit }: {
18
+ interface ContractEmitInput {
19
19
  repoRoot: string;
20
20
  files: string[];
21
21
  repo: Repo;
22
22
  emit: Parsed;
23
- }): {
23
+ }
24
+ /** The synchronous source finder and asynchronous app cache share every emission comparison and failure rule. */
25
+ export declare function contractEmitFindings(input: ContractEmitInput): {
24
26
  findings: Finding[];
25
- apps: any[];
27
+ apps: Parsed[];
26
28
  };
29
+ /** Awaits actual governed compiler children before the existing drift proof can be recorded. */
30
+ export declare function contractEmitFindingsAsync(input: ContractEmitInput): Promise<{
31
+ findings: Finding[];
32
+ apps: Parsed[];
33
+ }>;
34
+ export {};
@@ -6,6 +6,7 @@
6
6
  // resolvers the app root composes, and the OpenAPI 3.1 document of the typed operation table `apps/<app>/src/operations.ts`.
7
7
  // That the committed snapshot equals what the app emits now is contractEmitFindings (full pass only, never --fast): it emits every
8
8
  // api app into a temp directory through the injected `emit` (packages/hfs/emit) and compares hashes; an emit that cannot run is a finding.
9
+ import { isNodeExecutionRefusal } from '../../api/node/run-node.mjs';
9
10
  import { createHash } from 'node:crypto';
10
11
  import fs from 'node:fs';
11
12
  import path from 'node:path';
@@ -145,16 +146,7 @@ function artifactFindings({ repoRoot, tracked, scratch, app, artifact, what, emi
145
146
  findings.push(found(CONTRACT_SNAPSHOT_DRIFT, snapshot, `${snapshot} (${committed.slice(0, 12)}) differs from what ${app.name} emits now (${String(fresh).slice(0, 12)}); run \`npm run contract:emit\` and commit the result`, { app: app.name, drift: 'stale' }));
146
147
  }
147
148
  }
148
- /**
149
- * R23, full pass: each committed `contracts/<app>/schema.graphql` and `contracts/<app>/openapi.json` equals what `emit` writes for
150
- * the app now. `emit({ repoRoot, declaration, outDir })` is `emitContracts` of packages/hfs/emit and answers `{ written, skipped }`;
151
- * it is injected because the runtime scripts do not import the hfs package. Every api app is emitted on its own into a temp
152
- * directory, so one app that cannot emit does not hide the others; an uncommitted emitted snapshot is a finding here too. Answers
153
- * `{ findings, apps }`, `apps` being what was judged: `[{ app, artifact, status }]`, `artifact` being `schema.graphql` or
154
- * `openapi.json` and `status` one of `fresh`, `stale`, `not-committed`, `none` (nothing emitted, nothing committed; GraphQL only),
155
- * `left-behind` (committed, nothing emitted) or `emit-failed`.
156
- */
157
- export function contractEmitFindings({ repoRoot, files, repo, emit }) {
149
+ function* contractProgram({ repoRoot, files, repo }) {
158
150
  const findings = [];
159
151
  const apps = [];
160
152
  if (repo.profile !== 'be')
@@ -166,9 +158,14 @@ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
166
158
  const artifacts = [{ artifact: SNAPSHOT_OF.graphql, what: 'serves no GraphQL any more' }, { artifact: SNAPSHOT_OF.json, what: 'declares no operations any more' }];
167
159
  let emitted;
168
160
  try {
169
- emitted = emit({ repoRoot, declaration: { apps: [app] }, outDir: scratch });
161
+ const response = yield { repoRoot, declaration: { apps: [app] }, outDir: scratch };
162
+ if ('error' in response)
163
+ throw response.error;
164
+ emitted = response.value;
170
165
  }
171
166
  catch (error) {
167
+ if (isNodeExecutionRefusal(error))
168
+ throw error;
172
169
  emitFailure(app, artifacts, error, apps, findings);
173
170
  continue;
174
171
  }
@@ -183,3 +180,45 @@ export function contractEmitFindings({ repoRoot, files, repo, emit }) {
183
180
  }
184
181
  return { findings, apps };
185
182
  }
183
+ /** The synchronous source finder and asynchronous app cache share every emission comparison and failure rule. */
184
+ export function contractEmitFindings(input) {
185
+ const program = contractProgram(input);
186
+ let step = program.next();
187
+ try {
188
+ while (!step.done) {
189
+ let response;
190
+ try {
191
+ response = { value: input.emit(step.value) };
192
+ }
193
+ catch (error) {
194
+ response = { error };
195
+ }
196
+ step = program.next(response);
197
+ }
198
+ return step.value;
199
+ }
200
+ finally {
201
+ program.return({ findings: [], apps: [] });
202
+ }
203
+ }
204
+ /** Awaits actual governed compiler children before the existing drift proof can be recorded. */
205
+ export async function contractEmitFindingsAsync(input) {
206
+ const program = contractProgram(input);
207
+ let step = program.next();
208
+ try {
209
+ while (!step.done) {
210
+ let response;
211
+ try {
212
+ response = { value: await input.emit(step.value) };
213
+ }
214
+ catch (error) {
215
+ response = { error };
216
+ }
217
+ step = program.next(response);
218
+ }
219
+ return step.value;
220
+ }
221
+ finally {
222
+ program.return({ findings: [], apps: [] });
223
+ }
224
+ }
package/src/main.d.mts CHANGED
@@ -1,3 +1,4 @@
1
+ import { type HeavyNodeRun } from '../runtime/scripts/api/node/run-node.mjs';
1
2
  import { type LockResult } from '../scaffold/app.mjs';
2
3
  import type { GhSpawn } from '../ci/github.mjs';
3
4
  import type { Prompt } from '../runtime/scripts/hfs/secret-prompt.mjs';
@@ -39,6 +40,7 @@ interface AppIo {
39
40
  stackIo?: Record<string, unknown> | undefined;
40
41
  runLinter?: LinterRunner | undefined;
41
42
  checkApp?: ((args: Dynamic) => Dynamic) | undefined;
43
+ runHeavyNode?: HeavyNodeRun | undefined;
42
44
  /** --quiet: no progress on stderr (the diagnosis stays). */
43
45
  quiet?: boolean | undefined;
44
46
  stderrIsTty?: boolean | undefined;
@@ -57,5 +59,5 @@ export declare function appRootAbove(dir: string): string;
57
59
  * the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
58
60
  * harness CLI's own secret.env SOPS_AGE_KEY, which every verb that opens a sealed member (secret, stack, release, db) reads last for the age identity (decryptionIdentity); `prompt` replaces its masked prompt.
59
61
  */
60
- export declare function main(argv: string[], { cwd: baseCwd, harness, stdout, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, stderrIsTty, }?: MainOptions): Promise<number>;
62
+ export declare function main(argv: string[], { cwd: baseCwd, harness, stdout, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, runHeavyNode, checkApp, stderrIsTty, }?: MainOptions): Promise<number>;
61
63
  export {};
package/src/main.mjs CHANGED
@@ -83,6 +83,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
83
83
  // Every finding names a why code and carries its Vietnamese text. check, lint and slot read the app, never write to it. Exit codes:
84
84
  // 0 clean, 1 error findings, 2 a refusal or bad usage.
85
85
  import fs from 'node:fs';
86
+ import { isHostHeld } from '../runtime/scripts/api/node/run-node.mjs';
86
87
  import { createRequire } from 'node:module';
87
88
  import path from 'node:path';
88
89
  import { pathToFileURL } from 'node:url';
@@ -99,13 +100,13 @@ import { HFS_DECLARATION_FILE, HfsSlotsError, loadSlotManifest, readRepoDeclarat
99
100
  import { main as syncMain } from '../sync/cli.mjs';
100
101
  import { runWorkHygiene } from '../sync/hygiene.mjs';
101
102
  import { SyncError, bundledPresets } from '../sync/index.mjs';
102
- import { emitContracts } from '../emit/contracts.mjs';
103
+ import { emitContractsAsync } from '../emit/contracts.mjs';
103
104
  import { ScaffoldError, newService, newSpec } from '../scaffold/service.mjs';
104
105
  import { EditionRefusal } from '../scaffold/edition-gate.mjs';
105
106
  import { addKind } from '../scaffold/add.mjs';
106
107
  import { scaffoldApp } from '../scaffold/app.mjs';
107
108
  import { newImages } from '../scaffold/image.mjs';
108
- import { cachedLinters, eslintRepository, lintRepository, parseLintArgs, printLintText } from '../lint/run.mjs';
109
+ import { cachedLinters, eslintRepository, lintRepository, parseLintArgs, printLintText, runLinter as nativeLinter } from '../lint/run.mjs';
109
110
  import { runSonarForApp, runTestForApp, runTestShard, runTypesForApp } from '../cache/app-families.mjs';
110
111
  import { CACHE_ENV, createDiskSession } from '../cache/session.mjs';
111
112
  import { DEFAULT_FAMILIES, runAppFamilies } from '../cache/run.mjs';
@@ -359,7 +360,7 @@ function openSession({ repoRoot, tracked, io, full, use, planned }) {
359
360
  async function appPass({ repoRoot, harness, only, presets, prettier, io, selected, full, use = 'normal', session: given }) {
360
361
  const tracked = trackedFiles(repoRoot);
361
362
  const session = given ?? openSession({ repoRoot, tracked, io, full, use: only === undefined ? use : 'bypass', planned: tracked.length * Math.max(1, selected.size) });
362
- const result = await runAppFamilies({ repoRoot, tracked, session, selected, harness, presets, prettier, only, edition: undefined });
363
+ const result = await runAppFamilies({ repoRoot, tracked, session, selected, harness, presets, prettier, only, edition: undefined, execute: io.runHeavyNode });
363
364
  if (given !== undefined)
364
365
  return result;
365
366
  const flushed = session.flush();
@@ -529,9 +530,9 @@ async function checkRun({ io, selected, full, use, coverageFrom }) {
529
530
  eslint = await eslintRepository({ repoRoot, changed: null, existing: [], run: runLinter });
530
531
  }
531
532
  if (session !== undefined && selected.has('types'))
532
- extra.push(...runTypesForApp(session, { repoRoot, tracked, env: io.env }).findings);
533
+ extra.push(...(await runTypesForApp(session, { repoRoot, tracked, env: io.env, execute: io.runHeavyNode })).findings);
533
534
  if (session !== undefined && selected.has('test')) {
534
- const tests = await runTestForApp(session, { repoRoot, tracked });
535
+ const tests = await runTestForApp(session, { repoRoot, tracked, execute: io.runHeavyNode });
535
536
  extra.push(...tests.findings);
536
537
  }
537
538
  if (session !== undefined && selected.has('sonar'))
@@ -589,7 +590,7 @@ async function checkMain(opts, io) {
589
590
  const total = Number(match?.[2]);
590
591
  if (match === null || !Number.isSafeInteger(index) || !Number.isSafeInteger(total) || index < 1 || total < index)
591
592
  throw new Error('--shard needs <i>/<n> with 1 <= i <= n');
592
- return runTestShard(repoRoot, index, total);
593
+ return runTestShard(repoRoot, index, total, io.runHeavyNode);
593
594
  }
594
595
  // A coverage report judged on its own (the merge job of a sharded run) runs no family; with selectors it comes on top of them.
595
596
  const selected = explicit.size > 0 ? explicit : coverageFrom === undefined ? new Set(DEFAULT_FAMILIES) : new Set();
@@ -609,13 +610,13 @@ async function checkMain(opts, io) {
609
610
  writeRunLog(repoRoot, { command: `check${[...new Set(listFlag(opts, 'gate') ?? [])].map((gate) => ` --gate ${gate}`).join('')}${opts['no-cache'] === true ? ' --no-cache' : ''}${coverageFrom === undefined ? '' : ` --coverage-from ${JSON.stringify(coverageFrom)}`}`, seconds, exit, families: result.families.map((report) => ({ ...report, seconds: 0 })) }, io.env);
610
611
  return exit;
611
612
  }
612
- function emitMain(opts, { cwd: repoRoot, stdout: out }) {
613
+ async function emitMain(opts, { cwd: repoRoot, stdout: out, runHeavyNode }) {
613
614
  if (opts.positional.length)
614
615
  throw new Error('starci app emit takes no path');
615
616
  const be = readRepoDeclaration(loadSlotManifest(), repoRoot).sides?.be;
616
617
  if (!be)
617
618
  throw new Error('starci app emit runs at the app root (the folder of hfs.json)');
618
- const { written, skipped, standIns } = emitContracts({ repoRoot: path.join(repoRoot, 'be'), declaration: be });
619
+ const { written, skipped, standIns } = await emitContractsAsync({ repoRoot: path.join(repoRoot, 'be'), declaration: be, execute: runHeavyNode });
619
620
  for (const file of written)
620
621
  out(`wrote be/${file}\n`);
621
622
  const skipSummary = skipped.length ? `, ${skipped.join(', ')} serve no GraphQL` : '';
@@ -737,7 +738,7 @@ function errorMessage(error) {
737
738
  * the HFS check and `stackIo` Swarm tools; production defaults execute every check. `hostIdentity` ({ label, read }) is the
738
739
  * harness CLI's own secret.env SOPS_AGE_KEY, which every verb that opens a sealed member (secret, stack, release, db) reads last for the age identity (decryptionIdentity); `prompt` replaces its masked prompt.
739
740
  */
740
- export async function main(argv, { cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain, env = process.env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp, stderrIsTty = process.stderr.isTTY === true, } = {}) {
741
+ export async function main(argv, { cwd: baseCwd = process.cwd(), harness, stdout = (s) => process.stdout.write(s), stderr = (s) => process.stderr.write(s), presets, prettier, lock, run, stackMain, env = process.env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, runHeavyNode, checkApp, stderrIsTty = process.stderr.isTTY === true, } = {}) {
741
742
  try {
742
743
  const global = globalOptions(argv, path.resolve(baseCwd));
743
744
  const [verb, ...given] = global.argv;
@@ -752,10 +753,12 @@ export async function main(argv, { cwd: baseCwd = process.cwd(), harness, stdout
752
753
  return 2;
753
754
  }
754
755
  const { edition, argv: rest } = editionOption(verb, given);
755
- return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition, quiet: global.quiet, stderrIsTty, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runLinter, checkApp });
756
+ return await dispatchMain(verb, rest, { cwd: global.cwd, harness, edition, quiet: global.quiet, stderrIsTty, stdout: out, stderr, presets, prettier, lock, run, stackMain, env, stdin, prompt, hostIdentity, sops, tools, platform, stackIo, runHeavyNode, runLinter: runLinter ?? ((spec) => nativeLinter(spec, runHeavyNode)), checkApp });
756
757
  }
757
758
  catch (error) {
758
759
  stderr(errorMessage(error));
760
+ if (isHostHeld(error))
761
+ return 75;
759
762
  return 2;
760
763
  }
761
764
  }