@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/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 5.0.6
4
+
5
+ - Changed: Support sealed per-project quality-service credentials and preserve valid composite cache entries and reused spec records.
6
+
7
+ - Fixed: Preserve generated-contract provenance in front-end tier and bounds checks.
8
+
9
+ ## 5.0.5
10
+
11
+ - 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.
12
+
3
13
  ## 5.0.4
4
14
 
5
15
  - 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.
package/README.md CHANGED
@@ -26,6 +26,7 @@ starci app slot <path> [--cwd <dir>] [--json]
26
26
  starci app emit [--cwd <dir>]
27
27
  starci app db drift [--db-url-env <NAME>] [--json] [--cwd <dir>]
28
28
  starci app secret init | <set|get|unset|rename|remove|materialize|list|verify|recipients|migrate|split|audit|server-key> --env <env> ... [--cwd <dir>]
29
+ starci app ci secret set <NAME> [--from-custody] [--env <env>] [--repo <owner/name>] [--execute]
29
30
  ```
30
31
 
31
32
  `starci app add` writes every front-end unit, in either edition, already in the shape of the harness's `contexts/code/fe`
@@ -227,8 +228,9 @@ read until `migrate` moves it (`runtime/scripts/lib/sealed-member.mjs` names bot
227
228
  seals every named key in one sops write (`runtime/scripts/hfs/secret.mjs`): on a terminal it prompts `<KEY>: ` for each with
228
229
  masked input (one `*` per character; a paste, backspace, Enter, Ctrl+C to cancel with nothing written), redirected stdin gives
229
230
  one key its whole text or several one `KEY=value` line each, `--from-file KEY=path` reads a key byte for byte
230
- (`secret-prompt.mjs`), and `--generate <bytes>` makes a random value per key. It then declares the keys in the environment's
231
- `secrets:` of a compose environment (`declareKeys`, `secret-standard.mjs`) and names a swarm environment's to wire by hand.
231
+ (`secret-prompt.mjs`), and `--generate <bytes>` makes a random value per key. Keys declared by exact service credential
232
+ references stay with that consumer; other keys are declared in the environment's `secrets:` of a compose environment
233
+ (`declareKeys`, `secret-standard.mjs`), while a swarm environment's are named to wire by hand.
232
234
  A member is sealed to its environment's rule of the managed `.sops.yaml`; `recipients --env <env> [--add|--remove <age1...|owner>]`
233
235
  rewrites that rule (`environmentRecipients`, `secret-init.mjs`), and `init` takes the owner's identity as the app's recipient.
234
236
  Re-sealing a member whose every key the set replaces needs no age identity; adding a key to one does, and the identity is found
@@ -244,6 +246,24 @@ except `get --reveal`.
244
246
 
245
247
  ## Sonar
246
248
 
249
+ The product's `.starcistacks/application-stacks.yaml` holds its connection profile: provider, local/hosted mode, public
250
+ URL and `projects` entries. Each stored username, password, private URL or token has a separate `credentials[]` reference
251
+ to its actual sealed member and `key`; a private endpoint uses `host.fromCredential`. A project analysis reference has
252
+ `project: <projects[].key>`, independently of its reference `id`. Operator/admin login keys live in a separate authority
253
+ member and are never selected implicitly by analysis or CI. Change the declared connection profile when moving to a
254
+ hosted/cloud server; usernames, passwords and tokens remain sealed.
255
+
256
+ The CI execution copy is explicit: `services.sonar.ci.secrets: [{name: <CI_NAME>, credential: <analysis-id>}]`, with
257
+ `ci.token: <CI_NAME>` and the name/scope declared in the top-level `ci.secrets`. Declare the exact product credential
258
+ first, seal it with `starci app secret set --env <declared-env> <provider> <KEY>`, then run
259
+ `starci app ci secret set <CI_NAME> --from-custody` to inspect the plan and add `--execute` to stream its selected value
260
+ to the CI provider. A dry run decrypts no credential, reads no stdin and reaches no remote. Custody input uses terminal or
261
+ closed/null stdin; a competing pipe/file channel, even empty, is refused without reading it. A private endpoint has its
262
+ own explicit secret-copy link selected by `ci.hostVar`; public endpoint selectors read CI variables. The native local release opens
263
+ the selected product token through the common SOPS identity loader in memory and passes it only to the scanner child.
264
+ Missing, ambiguous, unopenable, wrong-project or runtime-granted custody is a failure, with no host/global fallback.
265
+ Analysis never creates a project, replaces its history or rotates a token. OIDC Codecov keeps no synthetic token member.
266
+
247
267
  One mechanism for both sides: every finding of the canon is imported into Sonar, and the quality gate
248
268
  fails while any is open. Nothing is configured per app; the pieces are managed files (`starci app sync`) and this package.
249
269
 
@@ -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);
package/cache/session.mjs CHANGED
@@ -137,7 +137,7 @@ export function createDiskSession(options) {
137
137
  // A file edited while the run read it gets no record.
138
138
  const settled = new Map();
139
139
  for (const [folder, { records }] of replaced) {
140
- settled.set(folder, records.filter((record) => index.hashOf(record.path) === undefined || hashFile(path.join(root, record.path)) === record.hash));
140
+ settled.set(folder, records.filter((record) => index.hashOf(record.path) === undefined || hashFile(path.join(root, record.path)) === index.hashOf(record.path)));
141
141
  }
142
142
  if (trust.kind === 'base') {
143
143
  const committed = new Map();
@@ -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
  }
@@ -67,7 +68,7 @@ export interface TestFamilyResult {
67
68
  readonly ok: boolean;
68
69
  readonly ran: readonly string[];
69
70
  readonly reused: readonly string[];
70
- /** Specs that failed or did not report: nothing of theirs is stored. */
71
+ /** Specs that failed, did not report, or whose inputs changed: nothing of theirs is stored. */
71
72
  readonly failed: readonly string[];
72
73
  /** The merged lcov, or undefined when no spec has a record. */
73
74
  readonly lcov: string | undefined;
@@ -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,13 +79,12 @@ 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))));
86
- const key = specKey({ spec, closure: jest.closureOf(spec), configHash: jest.configHash, hashOf });
87
- // A spec that failed, did not report, or whose files changed while it ran stores nothing.
88
- if (file?.passed === true && key === keys.get(spec))
86
+ const key = keys.get(spec);
87
+ if (file?.passed === true && key !== undefined)
89
88
  fresh.push(recordOf(spec, key, file.sources, side));
90
89
  else
91
90
  failed.push(spec);
@@ -96,9 +95,18 @@ export function runTestFamily(options) {
96
95
  fs.rmSync(scratch, { recursive: true, force: true });
97
96
  }
98
97
  }
99
- progress.tick(jest.specs.length, kept.length, toRun.length);
98
+ // Recheck every candidate against disk, including a reused spec whose inputs another running spec could change.
99
+ const records = [...kept, ...fresh].filter((record) => {
100
+ const key = specKey({ spec: record.path, closure: jest.closureOf(record.path), configHash: jest.configHash, hashOf: (file) => hashFile(path.join(session.root, file)) });
101
+ if (key === keys.get(record.path))
102
+ return true;
103
+ failed.push(record.path);
104
+ return false;
105
+ });
106
+ const settled = new Set(records.map((record) => record.path));
107
+ const reused = kept.filter((record) => settled.has(record.path));
108
+ progress.tick(jest.specs.length, reused.length, toRun.length);
100
109
  progress.end();
101
- const records = [...kept, ...fresh];
102
110
  session.replace(TEST_FOLDER, records);
103
111
  let lcov;
104
112
  let threshold;
@@ -117,13 +125,13 @@ export function runTestFamily(options) {
117
125
  mode: decision.mode,
118
126
  reason: decision.reason,
119
127
  checked: toRun.length,
120
- reused: kept.length,
128
+ reused: reused.length,
121
129
  newFindings: failed.length + thresholdFindings,
122
130
  knownFindings: 0,
123
131
  fixedFindings: 0,
124
132
  };
125
133
  session.report(report);
126
- return { ok: failed.length === 0 && (threshold?.ok ?? records.length === 0), ran: toRun, reused: kept.map((record) => record.path), failed, lcov, threshold, report, output };
134
+ return { ok: failed.length === 0 && (threshold?.ok ?? records.length === 0), ran: toRun, reused: reused.map((record) => record.path), failed, lcov, threshold, report, output };
127
135
  }
128
136
  /** The digest of a project config with host paths turned into `<root>` and the keys that differ per host dropped. */
129
137
  export function projectConfigHash(config) {
@@ -144,7 +152,7 @@ export function projectConfigHash(config) {
144
152
  * 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
153
  * (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
154
  */
147
- export async function createJestEnvironment({ appRoot, side, jestFrom, project = 'unit' }) {
155
+ export async function createJestEnvironment({ appRoot, side, jestFrom, project = 'unit', execute }) {
148
156
  const root = path.resolve(appRoot, side);
149
157
  const fromApp = createRequire(jestFrom ?? path.join(root, 'package.json'));
150
158
  const jestPackage = fromApp.resolve('jest/package.json');
@@ -189,7 +197,7 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
189
197
  const relative = (file) => posix(path.relative(appRoot, file));
190
198
  const setup = (name) => [...(config[name] ?? [])];
191
199
  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 });
200
+ 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
201
  if (listed.status !== 0)
194
202
  throw new Error(`jest --listTests failed under ${side}: ${(listed.stderr || listed.stdout).split('\n')[0]}`);
195
203
  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 +214,7 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
206
214
  closures.set(spec, closure);
207
215
  return closure;
208
216
  },
209
- run(toRun, coverageDir) {
217
+ async run(toRun, coverageDir) {
210
218
  const base = [jestBin, '--selectProjects', project, '--coverage', '--ci'];
211
219
  const everything = toRun.length === specs.length;
212
220
  const sideRelative = toRun.map((spec) => posix(path.relative(root, path.resolve(appRoot, spec))));
@@ -223,7 +231,7 @@ export async function createJestEnvironment({ appRoot, side, jestFrom, project =
223
231
  let output = '';
224
232
  for (const chunk of chunks) {
225
233
  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 });
234
+ 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
235
  status = status || (result.status ?? 1);
228
236
  output += `${result.stdout}${result.stderr}`;
229
237
  }
@@ -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
  });
package/ci/cli.d.mts CHANGED
@@ -1,9 +1,11 @@
1
+ import { type SecretSops } from '../runtime/scripts/hfs/secret-sops.mjs';
2
+ import type { AgeHost } from '../runtime/scripts/hfs/secret-init.mjs';
1
3
  import type { GhSpawn, StdinSource } from './github.mjs';
2
4
  /** The commands of `starci app ci` (besides `local`) with the options each takes; the parser refuses every other. */
3
5
  export declare const COMMANDS: Record<string, string[]>;
4
6
  export declare const BOOLEAN: Set<string>;
5
7
  /** Manifest and sealed key reads remain offline; only github.mjs may invoke the GitHub CLI. */
6
- export declare function ciMain(argv: string[], { cwd, stdout, stderr, stdin, env, tools }?: {
8
+ export declare function ciMain(argv: string[], { cwd, stdout, stderr, stdin, env, tools, hostIdentity, sops, platform, inputRedirected }?: {
7
9
  cwd?: string | undefined;
8
10
  stdout?: ((text: string) => unknown) | undefined;
9
11
  stderr?: ((text: string) => unknown) | undefined;
@@ -12,4 +14,8 @@ export declare function ciMain(argv: string[], { cwd, stdout, stderr, stdin, env
12
14
  tools?: {
13
15
  spawn?: GhSpawn | undefined;
14
16
  } | undefined;
17
+ hostIdentity?: AgeHost | null | undefined;
18
+ sops?: Pick<SecretSops, 'decrypt'> | null | undefined;
19
+ platform?: NodeJS.Platform | undefined;
20
+ inputRedirected?: (() => boolean) | undefined;
15
21
  }): Promise<number>;