@starci/hfs 4.12.2 → 4.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (152) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/README.md +8 -9
  3. package/cache/app-families.d.mts +30 -0
  4. package/cache/app-families.mjs +94 -0
  5. package/cache/content-index.d.mts +13 -0
  6. package/cache/content-index.mjs +37 -0
  7. package/cache/db-family.d.mts +13 -0
  8. package/cache/db-family.mjs +37 -0
  9. package/cache/fake-session.d.mts +42 -0
  10. package/cache/fake-session.mjs +57 -0
  11. package/cache/fingerprint.d.mts +28 -0
  12. package/cache/fingerprint.mjs +120 -0
  13. package/cache/format-family.d.mts +13 -0
  14. package/cache/format-family.mjs +49 -0
  15. package/cache/git.d.mts +11 -0
  16. package/cache/git.mjs +13 -0
  17. package/cache/hash.d.mts +15 -0
  18. package/cache/hash.mjs +58 -0
  19. package/cache/hfs-family.d.mts +35 -0
  20. package/cache/hfs-family.mjs +164 -0
  21. package/cache/import-graph.d.mts +75 -0
  22. package/cache/import-graph.mjs +204 -0
  23. package/cache/jsonl.d.mts +31 -0
  24. package/cache/jsonl.mjs +115 -0
  25. package/cache/lint-family.d.mts +162 -0
  26. package/cache/lint-family.mjs +433 -0
  27. package/cache/order.d.mts +1 -0
  28. package/cache/order.mjs +2 -0
  29. package/cache/progress.d.mts +19 -0
  30. package/cache/progress.mjs +68 -0
  31. package/cache/quality-baselines.d.mts +109 -0
  32. package/cache/quality-baselines.mjs +235 -0
  33. package/cache/records.d.mts +33 -0
  34. package/cache/records.mjs +54 -0
  35. package/cache/report.d.mts +19 -0
  36. package/cache/report.mjs +32 -0
  37. package/cache/run-log.d.mts +17 -0
  38. package/cache/run-log.mjs +37 -0
  39. package/cache/run.d.mts +33 -0
  40. package/cache/run.mjs +65 -0
  41. package/cache/session.d.mts +29 -0
  42. package/cache/session.mjs +197 -0
  43. package/cache/side-key.d.mts +10 -0
  44. package/cache/side-key.mjs +47 -0
  45. package/cache/test-family.d.mts +130 -0
  46. package/cache/test-family.mjs +258 -0
  47. package/cache/trust.d.mts +53 -0
  48. package/cache/trust.mjs +157 -0
  49. package/cache/types-family.d.mts +74 -0
  50. package/cache/types-family.mjs +154 -0
  51. package/cache/types.d.mts +99 -0
  52. package/cache/types.mjs +8 -0
  53. package/cache/wiring-family.d.mts +14 -0
  54. package/cache/wiring-family.mjs +43 -0
  55. package/coverage/cli.d.mts +4 -2
  56. package/coverage/cli.mjs +44 -5
  57. package/db/migrate.d.mts +16 -0
  58. package/db/migrate.mjs +21 -4
  59. package/db/postgres.d.mts +23 -0
  60. package/db/postgres.mjs +32 -1
  61. package/lint/run.d.mts +47 -1
  62. package/lint/run.mjs +157 -5
  63. package/package.json +2 -1
  64. package/release/gate.d.mts +2 -0
  65. package/release/gate.mjs +4 -2
  66. package/release/records.d.mts +15 -0
  67. package/release/records.mjs +25 -0
  68. package/release/urgent.mjs +23 -4
  69. package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
  70. package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
  71. package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
  72. package/runtime/knowledge/hfs/rules.yaml +74 -7
  73. package/runtime/knowledge/hfs/slots.yaml +19 -7
  74. package/runtime/modules/kernel/failure-codes.yaml +46 -4
  75. package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
  76. package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
  77. package/runtime/scripts/hfs/check.mjs +1 -1
  78. package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
  79. package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
  80. package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
  81. package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
  82. package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
  83. package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
  84. package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
  85. package/runtime/scripts/hfs/rules/database.mjs +2 -1
  86. package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
  87. package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
  88. package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
  89. package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
  90. package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
  91. package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
  92. package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
  93. package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
  94. package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
  95. package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
  96. package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
  97. package/runtime/scripts/hfs/tree-findings.mjs +3 -1
  98. package/scaffold/add-inbox-lite.mjs +2 -4
  99. package/scaffold/add-queue-lite.mjs +2 -3
  100. package/scaffold/add-table.mjs +2 -4
  101. package/scaffold/migration-stamp.d.mts +11 -0
  102. package/scaffold/migration-stamp.mjs +32 -0
  103. package/src/helpers/hfs-arch-fixture.d.mts +1 -1
  104. package/src/main.d.mts +4 -1
  105. package/src/main.mjs +189 -74
  106. package/src/stack-records-double.d.mts +31 -0
  107. package/stack/cli.d.mts +3 -1
  108. package/stack/cli.mjs +17 -11
  109. package/stack/config.d.mts +6 -0
  110. package/stack/converge.d.mts +2 -0
  111. package/stack/converge.mjs +1 -1
  112. package/stack/db-phases.d.mts +185 -0
  113. package/stack/db-phases.mjs +346 -0
  114. package/stack/deploy.d.mts +18 -4
  115. package/stack/deploy.mjs +235 -70
  116. package/stack/journal.d.mts +127 -0
  117. package/stack/journal.mjs +182 -0
  118. package/stack/manager-backup.d.mts +54 -0
  119. package/stack/manager-backup.mjs +136 -0
  120. package/stack/release-store.d.mts +44 -0
  121. package/stack/release-store.mjs +89 -6
  122. package/stack/remote.d.mts +2 -0
  123. package/stack/remote.mjs +31 -11
  124. package/stack/server-key.d.mts +2 -0
  125. package/stack/server-key.mjs +1 -1
  126. package/stack/status.d.mts +49 -0
  127. package/stack/status.mjs +114 -0
  128. package/stack/urgent-floor.d.mts +10 -61
  129. package/stack/urgent-floor.mjs +28 -123
  130. package/stack/verify.mjs +26 -0
  131. package/sync/ci-commands.mjs +10 -5
  132. package/sync/format-worker.d.mts +1 -0
  133. package/sync/format-worker.mjs +17 -0
  134. package/sync/format.d.mts +21 -3
  135. package/sync/format.mjs +99 -7
  136. package/sync/index.d.mts +3 -6
  137. package/sync/index.mjs +12 -12
  138. package/sync/stack-images.d.mts +25 -10
  139. package/sync/stack-images.mjs +215 -54
  140. package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
  141. package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
  142. package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
  143. package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
  144. package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
  145. package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
  146. package/templates/app/gitignore +1 -0
  147. package/templates/app/gitignore-lite +1 -0
  148. package/templates/app/package-scripts/db.json +1 -1
  149. package/templates/app/skeleton/.gitattributes +2 -0
  150. package/templates/app/skeleton-lite/.gitattributes +2 -0
  151. package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
  152. package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
@@ -0,0 +1,53 @@
1
+ import { type CacheRecord } from './types.mjs';
2
+ export declare const FORGED = "HFS_CHECK_CACHE_FORGED";
3
+ export declare const STALE = "HFS_CHECK_CACHE_STALE";
4
+ export declare const MISSING = "HFS_CHECK_CACHE_MISSING";
5
+ /** The cache files a trust source holds: path under `.starcistacks/cache/` to text. */
6
+ export interface TrustSource {
7
+ readonly kind: 'worktree' | 'base' | 'none';
8
+ readonly reason: string;
9
+ /** The commit the cache is read at, for `base`. */
10
+ readonly base?: string | undefined;
11
+ files(): ReadonlyMap<string, string>;
12
+ }
13
+ /** Every file under the cache folder of the work tree, keyed by its path under the folder. */
14
+ export declare function readCacheDir(root: string): Map<string, string>;
15
+ /** The cache folder of commit `rev`: one ls-tree and one cat-file --batch, however many files it holds. */
16
+ export declare function readCacheAt(root: string, rev: string): Map<string, string> | undefined;
17
+ export interface TrustInput {
18
+ readonly root: string;
19
+ readonly env: NodeJS.ProcessEnv;
20
+ }
21
+ /** Whether the run is in CI (the managed workflow, or any runner that sets CI). */
22
+ export declare const inCi: (env: NodeJS.ProcessEnv) => boolean;
23
+ /** The commit CI trusts the cache at, or why there is none. */
24
+ export declare function trustedBase(root: string, env: NodeJS.ProcessEnv): {
25
+ base: string;
26
+ } | {
27
+ reason: string;
28
+ };
29
+ /** The trust source of this run. */
30
+ export declare function resolveTrust({ root, env }: TrustInput): TrustSource;
31
+ /** One verdict of the comparison of the change's committed cache with what CI inherited or computed. */
32
+ export interface CacheVerdict {
33
+ readonly code: typeof FORGED | typeof STALE | typeof MISSING;
34
+ readonly level: 'error' | 'warning';
35
+ readonly path: string;
36
+ readonly folder: string;
37
+ readonly message: string;
38
+ }
39
+ export interface VerifyInput {
40
+ /** The records CI trusts (the base cache), by folder. */
41
+ readonly trusted: ReadonlyMap<string, readonly CacheRecord[]>;
42
+ /** The records of this run (inherited and computed), by folder. */
43
+ readonly computed: ReadonlyMap<string, readonly CacheRecord[]>;
44
+ /** The records the change commits, by folder. */
45
+ readonly committed: ReadonlyMap<string, readonly CacheRecord[]>;
46
+ }
47
+ /**
48
+ * Compares the change's committed cache with what this run inherited or computed. A committed record whose key matches the tree and
49
+ * whose findings differ from the computed one is FORGED (a hard blocker, whether it was edited by hand or a step is not deterministic
50
+ * across hosts). A key the change touched (its record differs from the trusted one, or the trusted cache has none) that the committed
51
+ * cache does not cover with a current record is STALE. A change that commits no cache at all while the run has computed one is MISSING.
52
+ */
53
+ export declare function verifyCommitted({ trusted, computed, committed }: VerifyInput): CacheVerdict[];
@@ -0,0 +1,157 @@
1
+ // trust.mts - where the cache is trusted from. On a host the cache in the working tree is trusted (a local run is the developer's own
2
+ // view). In CI the cache is trusted only as it stands at the trusted base: the merge-base with the default branch for a pull request,
3
+ // the first parent for a push to the default branch. The change's own edits to the cache are never trusted: CI judges everything the base
4
+ // cache does not cover and then compares the committed cache with what it inherited or computed (verifyCommitted).
5
+ // No trusted base (a tag build, a schedule, an unreadable base) is a full run.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { canonicalJson } from './jsonl.mjs';
9
+ import { git, gitBytes } from './git.mjs';
10
+ import { CACHE_DIR } from './types.mjs';
11
+ export const FORGED = 'HFS_CHECK_CACHE_FORGED';
12
+ export const STALE = 'HFS_CHECK_CACHE_STALE';
13
+ export const MISSING = 'HFS_CHECK_CACHE_MISSING';
14
+ /** Every file under the cache folder of the work tree, keyed by its path under the folder. */
15
+ export function readCacheDir(root) {
16
+ const files = new Map();
17
+ const top = path.join(root, ...CACHE_DIR.split('/'));
18
+ const walk = (dir, prefix) => {
19
+ let entries;
20
+ try {
21
+ entries = fs.readdirSync(dir, { withFileTypes: true });
22
+ }
23
+ catch {
24
+ return;
25
+ }
26
+ for (const entry of entries) {
27
+ const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
28
+ if (entry.isDirectory())
29
+ walk(path.join(dir, entry.name), rel);
30
+ else if (entry.isFile()) {
31
+ try {
32
+ files.set(rel, fs.readFileSync(path.join(dir, entry.name), 'utf8'));
33
+ }
34
+ catch { /* unreadable: absent */ }
35
+ }
36
+ }
37
+ };
38
+ walk(top, '');
39
+ return files;
40
+ }
41
+ /** The cache folder of commit `rev`: one ls-tree and one cat-file --batch, however many files it holds. */
42
+ export function readCacheAt(root, rev) {
43
+ const listed = git(root, ['ls-tree', '-r', '-z', rev, '--', CACHE_DIR]);
44
+ if (!listed.ok)
45
+ return undefined;
46
+ const entries = listed.stdout.split('\0').filter(Boolean).map((line) => {
47
+ const tab = line.indexOf('\t');
48
+ return { sha: line.slice(0, tab).split(' ')[2] ?? '', file: line.slice(tab + 1) };
49
+ }).filter((entry) => entry.sha !== '');
50
+ const files = new Map();
51
+ if (entries.length === 0)
52
+ return files;
53
+ const blobs = gitBytes(root, ['cat-file', '--batch'], `${entries.map((entry) => entry.sha).join('\n')}\n`);
54
+ if (!blobs.ok)
55
+ return undefined;
56
+ let at = 0;
57
+ for (const entry of entries) {
58
+ const eol = blobs.stdout.indexOf(0x0a, at);
59
+ if (eol < 0)
60
+ return undefined;
61
+ const [, type, size] = blobs.stdout.subarray(at, eol).toString('utf8').split(' ');
62
+ if (type !== 'blob' || size === undefined)
63
+ return undefined;
64
+ const start = eol + 1;
65
+ const end = start + Number(size);
66
+ files.set(entry.file.slice(CACHE_DIR.length + 1), blobs.stdout.subarray(start, end).toString('utf8'));
67
+ at = end + 1;
68
+ }
69
+ return files;
70
+ }
71
+ /** Whether the run is in CI (the managed workflow, or any runner that sets CI). */
72
+ export const inCi = (env) => env['CI'] === 'true' || env['GITHUB_ACTIONS'] === 'true';
73
+ const DEFAULT_BRANCHES = new Set(['main', 'master']);
74
+ /** The commit CI trusts the cache at, or why there is none. */
75
+ export function trustedBase(root, env) {
76
+ const event = env['GITHUB_EVENT_NAME'];
77
+ if (event === 'pull_request' || event === 'pull_request_target') {
78
+ const target = env['GITHUB_BASE_REF'];
79
+ if (!target)
80
+ return { reason: 'a pull request with no base ref' };
81
+ const merged = git(root, ['merge-base', 'HEAD', `origin/${target}`]);
82
+ return merged.ok && merged.stdout.trim() !== '' ? { base: merged.stdout.trim() } : { reason: `no merge-base with origin/${target} (fetch it)` };
83
+ }
84
+ if (event === 'push') {
85
+ const branch = (env['GITHUB_REF'] ?? '').replace(/^refs\/heads\//, '');
86
+ if (!(DEFAULT_BRANCHES.has(branch) || branch === env['STARCI_DEFAULT_BRANCH']))
87
+ return { reason: `a push to ${branch || 'a non-branch ref'}, not the default branch` };
88
+ const parent = git(root, ['rev-parse', '--verify', '-q', 'HEAD^1']);
89
+ return parent.ok && parent.stdout.trim() !== '' ? { base: parent.stdout.trim() } : { reason: 'no first parent (fetch depth 1)' };
90
+ }
91
+ return { reason: `${event ?? 'an unknown CI event'} has no trusted base` };
92
+ }
93
+ /** The trust source of this run. */
94
+ export function resolveTrust({ root, env }) {
95
+ if (!inCi(env)) {
96
+ let cached;
97
+ return { kind: 'worktree', reason: 'the working tree', files: () => (cached ??= readCacheDir(root)) };
98
+ }
99
+ const found = trustedBase(root, env);
100
+ if ('reason' in found)
101
+ return { kind: 'none', reason: found.reason, files: () => new Map() };
102
+ const files = readCacheAt(root, found.base);
103
+ if (files === undefined)
104
+ return { kind: 'none', reason: `the cache at ${found.base.slice(0, 12)} is unreadable`, files: () => new Map() };
105
+ return { kind: 'base', reason: `the cache at ${found.base.slice(0, 12)}`, base: found.base, files: () => files };
106
+ }
107
+ const keyOf = (record) => `${record.path}\0${record.step}`;
108
+ const sameFindings = (a, b) => canonicalJson(a) === canonicalJson(b);
109
+ /**
110
+ * Compares the change's committed cache with what this run inherited or computed. A committed record whose key matches the tree and
111
+ * whose findings differ from the computed one is FORGED (a hard blocker, whether it was edited by hand or a step is not deterministic
112
+ * across hosts). A key the change touched (its record differs from the trusted one, or the trusted cache has none) that the committed
113
+ * cache does not cover with a current record is STALE. A change that commits no cache at all while the run has computed one is MISSING.
114
+ */
115
+ export function verifyCommitted({ trusted, computed, committed }) {
116
+ const verdicts = [];
117
+ let anyCommitted = false;
118
+ let anyComputed = false;
119
+ for (const [folder, records] of computed) {
120
+ if (records.length === 0)
121
+ continue;
122
+ anyComputed = true;
123
+ const own = new Map();
124
+ for (const record of records)
125
+ own.set(keyOf(record), record);
126
+ const base = new Map((trusted.get(folder) ?? []).map((record) => [keyOf(record), record]));
127
+ const mine = committed.get(folder) ?? [];
128
+ if (mine.length > 0)
129
+ anyCommitted = true;
130
+ const byKey = new Map();
131
+ for (const record of mine)
132
+ byKey.set(keyOf(record), [...(byKey.get(keyOf(record)) ?? []), record]);
133
+ for (const record of mine) {
134
+ const truth = own.get(keyOf(record));
135
+ if (truth === undefined || truth.hash !== record.hash)
136
+ continue;
137
+ if (!sameFindings(truth.findings, record.findings)) {
138
+ verdicts.push({ code: FORGED, level: 'error', path: record.path, folder, message: `${folder}: the committed record of ${record.path} (${record.step}) matches the tree but its findings differ from what the check computes; regenerate the cache with starci app check` });
139
+ }
140
+ }
141
+ for (const [key, truth] of own) {
142
+ const before = base.get(key);
143
+ const touched = before === undefined || before.hash !== truth.hash || !sameFindings(before.findings, truth.findings);
144
+ if (!touched)
145
+ continue;
146
+ const held = (byKey.get(key) ?? []).some((record) => record.hash === truth.hash);
147
+ if (!held)
148
+ verdicts.push({ code: STALE, level: 'warning', path: truth.path, folder, message: `${folder}: ${truth.path} (${truth.step}) changed and the committed cache holds no current record of it; run starci app check and commit .starcistacks/cache` });
149
+ }
150
+ }
151
+ if (anyComputed && !anyCommitted) {
152
+ // One verdict for a change that commits no cache, not one stale key per file.
153
+ verdicts.length = 0;
154
+ verdicts.push({ code: MISSING, level: 'warning', path: CACHE_DIR, folder: '', message: `${CACHE_DIR} holds no records; run starci app check and commit the folder so later runs judge only what changed` });
155
+ }
156
+ return verdicts.sort((a, b) => (a.code + a.folder + a.path < b.code + b.folder + b.path ? -1 : 1));
157
+ }
@@ -0,0 +1,74 @@
1
+ import type { CheckSession, FamilyReport, Finding } from './types.mjs';
2
+ /** The folder of the session that holds the records of this family. */
3
+ export declare const TYPES_FOLDER = "check/types";
4
+ /** The step of a program's record. */
5
+ export declare const TSC_STEP = "tsc";
6
+ /** One workspace program: `tsc -p <tsconfig> --noEmit`. Paths are app-relative posix. */
7
+ export interface TypesProgram {
8
+ /** The record key, unique in the app (`be`, `fe/apps/web`). */
9
+ readonly name: string;
10
+ /** The tsconfig the program is built from. */
11
+ readonly tsconfig: string;
12
+ /** The folders (or files) the program reads, whose tracked paths make its key; defaults to the tsconfig's folder. */
13
+ readonly reads?: readonly string[];
14
+ }
15
+ /** What the program reads that the content index does not hold. */
16
+ export interface ProgramInputs {
17
+ /** Staged entries for the program's paths, as `path:blob` pairs (the machine reads the index). */
18
+ readonly staged?: readonly string[];
19
+ /** Untracked files its TypeScript program reads (what its tsconfig includes or an import resolves to that git does not track), app-relative. */
20
+ readonly untracked?: readonly string[];
21
+ }
22
+ /** What one `tsc` run came to. */
23
+ export interface TscOutcome {
24
+ readonly status: number;
25
+ /** tsc's output, `--pretty false`. */
26
+ readonly output: string;
27
+ }
28
+ export interface TypesFamilyOptions {
29
+ readonly session: CheckSession;
30
+ readonly programs: readonly TypesProgram[];
31
+ /** Runs one program's `tsc`; `buildInfo` is where its `.tsbuildinfo` goes (the untracked tool cache). */
32
+ readonly run: (program: TypesProgram, buildInfo: string) => TscOutcome;
33
+ /** The untracked tool cache of slot `app.tool-cache` (`<STARCI_CACHE_HOME>/<repo>/app-check`). */
34
+ readonly toolCacheDir: string;
35
+ readonly inputsOf?: (program: TypesProgram) => ProgramInputs;
36
+ }
37
+ export interface TypesFamilyResult {
38
+ readonly ok: boolean;
39
+ readonly ran: readonly string[];
40
+ readonly reused: readonly string[];
41
+ /** Programs whose `tsc` did not complete (no diagnostics and a failing status): nothing of theirs is stored. */
42
+ readonly incomplete: readonly string[];
43
+ readonly findings: readonly Finding[];
44
+ readonly report: FamilyReport;
45
+ }
46
+ /** The folders a program reads, app-relative: its `reads`, else the folder of its tsconfig. */
47
+ export declare function readsOf(program: TypesProgram): readonly string[];
48
+ /**
49
+ * The key of a program: its tsconfig and every tracked path under what it reads by content hash (`absent` for a tracked path the work tree no longer holds), the staged
50
+ * entries and the untracked inputs. Equal on every host for one commit; a program that reads nothing that changed keeps its key.
51
+ */
52
+ export declare function programKey(program: TypesProgram, session: CheckSession, inputs?: ProgramInputs): string;
53
+ /**
54
+ * Where a program's `.tsbuildinfo` goes. It must be outside the app's committed cache, so a tool-cache folder configured inside `.starcistacks/cache` is refused: a
55
+ * `.tsbuildinfo` holds versions and signatures, not results, and is never committed.
56
+ */
57
+ export declare function tsBuildInfoPath(toolCacheDir: string, appRoot: string, program: TypesProgram): string;
58
+ /** `text` with every occurrence of the app's absolute path removed, so a message carries app-relative posix paths only. */
59
+ export declare function relativise(text: string, appRoot: string): string;
60
+ /** The findings in tsc's `--pretty false` output: `file(line,col): error TS1234: message`, continuation lines joined to their message. Run from the app root. */
61
+ export declare function parseTscOutput(output: string, appRoot: string, fallbackPath: string): Finding[];
62
+ /**
63
+ * 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
+ * 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
+ export declare function runTypesFamily(options: TypesFamilyOptions): TypesFamilyResult;
67
+ /**
68
+ * 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
+ * tool cache, run from the app root so paths in the output are app-relative.
70
+ */
71
+ export declare function createTscRunner({ appRoot, typescriptFrom }: {
72
+ appRoot: string;
73
+ typescriptFrom?: string;
74
+ }): TypesFamilyOptions['run'];
@@ -0,0 +1,154 @@
1
+ // types-family.mts - the `--types` family of the app check cache (contexts/project/check-cache.md, "Which step reuses what"): each workspace's `tsc --noEmit` through the
2
+ // app's own TypeScript, one program at a time. A program's key is the sha256 of everything the program reads: the hash of each tracked path it covers (`absent` for one the
3
+ // work tree lost), the entries staged for it and the untracked files its TypeScript reads (generated code). An unchanged key reuses the program's recorded diagnostics; a
4
+ // changed one runs that program's `tsc` in full. TypeScript's `.tsbuildinfo` speeds that run but is host state, not a result: it lives in the untracked tool cache and is
5
+ // never written into the app, so never committed.
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { createRequire } from 'node:module';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { CACHE_DIR } from './types.mjs';
11
+ import { combineHashes, hashFile } from './hash.mjs';
12
+ import { sortFindings } from './jsonl.mjs';
13
+ /** The folder of the session that holds the records of this family. */
14
+ export const TYPES_FOLDER = 'check/types';
15
+ /** The step of a program's record. */
16
+ export const TSC_STEP = 'tsc';
17
+ const posix = (file) => file.split(path.sep).join('/');
18
+ const within = (file, prefix) => prefix === '.' || prefix === '' || file === prefix || file.startsWith(`${prefix}/`);
19
+ /** The folders a program reads, app-relative: its `reads`, else the folder of its tsconfig. */
20
+ export function readsOf(program) {
21
+ return program.reads ?? [path.posix.dirname(program.tsconfig)];
22
+ }
23
+ /**
24
+ * The key of a program: its tsconfig and every tracked path under what it reads by content hash (`absent` for a tracked path the work tree no longer holds), the staged
25
+ * entries and the untracked inputs. Equal on every host for one commit; a program that reads nothing that changed keeps its key.
26
+ */
27
+ export function programKey(program, session, inputs = {}) {
28
+ const parts = {};
29
+ const reads = readsOf(program);
30
+ for (const file of session.index.paths)
31
+ if (reads.some((prefix) => within(file, prefix)) || file === program.tsconfig)
32
+ parts[`tracked:${file}`] = session.index.hashOf(file) ?? 'absent';
33
+ for (const entry of inputs.staged ?? [])
34
+ parts[`staged:${entry}`] = '1';
35
+ for (const file of inputs.untracked ?? [])
36
+ parts[`untracked:${file}`] = hashFile(path.join(session.root, file)) ?? 'absent';
37
+ return combineHashes(parts);
38
+ }
39
+ /**
40
+ * Where a program's `.tsbuildinfo` goes. It must be outside the app's committed cache, so a tool-cache folder configured inside `.starcistacks/cache` is refused: a
41
+ * `.tsbuildinfo` holds versions and signatures, not results, and is never committed.
42
+ */
43
+ export function tsBuildInfoPath(toolCacheDir, appRoot, program) {
44
+ const file = path.join(path.resolve(toolCacheDir), 'tsbuildinfo', `${program.name.replaceAll('/', '__')}.tsbuildinfo`);
45
+ const committed = path.resolve(appRoot, CACHE_DIR);
46
+ if (file === committed || file.startsWith(`${committed}${path.sep}`))
47
+ throw new Error(`the tool cache ${toolCacheDir} is inside ${CACHE_DIR}: a .tsbuildinfo is host state and is never committed`);
48
+ return file;
49
+ }
50
+ /** `text` with every occurrence of the app's absolute path removed, so a message carries app-relative posix paths only. */
51
+ export function relativise(text, appRoot) {
52
+ const root = path.resolve(appRoot);
53
+ let out = text;
54
+ for (const variant of new Set([root, root.split(path.sep).join('/')]))
55
+ out = out.split(`${variant}/`).join('').split(`${variant}${path.sep}`).join('').split(variant).join('.');
56
+ return out;
57
+ }
58
+ const DIAGNOSTIC = /^(.+?)\((\d+),(\d+)\): (?:error|warning) (TS\d+): (.*)$/u;
59
+ const GLOBAL_DIAGNOSTIC = /^(?:error|warning) (TS\d+): (.*)$/u;
60
+ /** The findings in tsc's `--pretty false` output: `file(line,col): error TS1234: message`, continuation lines joined to their message. Run from the app root. */
61
+ export function parseTscOutput(output, appRoot, fallbackPath) {
62
+ const findings = [];
63
+ let last;
64
+ for (const raw of output.split(/\r?\n/u)) {
65
+ const line = relativise(raw, appRoot);
66
+ const match = DIAGNOSTIC.exec(line);
67
+ const global = match ? null : GLOBAL_DIAGNOSTIC.exec(line);
68
+ if (match) {
69
+ last = { path: (match[1] ?? '').replaceAll('\\', '/').replace(/^\.\//u, ''), line: Number(match[2]), column: Number(match[3]), rule: match[4] ?? '', message: match[5] ?? '' };
70
+ findings.push(last);
71
+ }
72
+ else if (global) {
73
+ last = { path: fallbackPath, line: 1, column: 1, rule: global[1] ?? '', message: global[2] ?? '' };
74
+ findings.push(last);
75
+ }
76
+ else if (last !== undefined && /^\s+\S/u.test(line)) {
77
+ last.message += `\n${line.trim()}`;
78
+ }
79
+ }
80
+ return sortFindings(findings);
81
+ }
82
+ const identity = (finding) => `${finding.path}\0${finding.rule}\0${finding.message}`;
83
+ /**
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
+ * 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
+ */
87
+ export function runTypesFamily(options) {
88
+ const { session, programs, run, toolCacheDir } = options;
89
+ const decision = session.decide('types');
90
+ const previous = new Map(session.records(TYPES_FOLDER).filter((record) => record.step === TSC_STEP).map((record) => [record.path, record]));
91
+ const progress = session.progress.step('types', 'tsc', programs.length);
92
+ const records = [];
93
+ const ran = [];
94
+ const reused = [];
95
+ const incomplete = [];
96
+ const findings = [];
97
+ let known = 0;
98
+ let added = 0;
99
+ let fixed = 0;
100
+ for (const program of programs) {
101
+ const inputs = options.inputsOf?.(program) ?? {};
102
+ const key = programKey(program, session, inputs);
103
+ const before = previous.get(program.name);
104
+ let current;
105
+ if (decision.mode === 'diff' && before !== undefined && before.hash === key) {
106
+ current = before.findings;
107
+ records.push(before);
108
+ reused.push(program.name);
109
+ }
110
+ else {
111
+ const outcome = run(program, tsBuildInfoPath(toolCacheDir, session.root, program));
112
+ ran.push(program.name);
113
+ current = parseTscOutput(outcome.output, session.root, program.tsconfig);
114
+ if (current.length === 0 && outcome.status !== 0)
115
+ incomplete.push(program.name);
116
+ else if (programKey(program, session, inputs) === key)
117
+ records.push({ path: program.name, step: TSC_STEP, hash: key, findings: current });
118
+ else
119
+ incomplete.push(program.name);
120
+ }
121
+ const had = new Set((before?.findings ?? []).map(identity));
122
+ const now = new Set(current.map(identity));
123
+ for (const finding of current)
124
+ if (had.has(identity(finding)))
125
+ known += 1;
126
+ else
127
+ added += 1;
128
+ for (const id of had)
129
+ if (!now.has(id))
130
+ fixed += 1;
131
+ findings.push(...current);
132
+ progress.tick(reused.length + ran.length, reused.length, ran.length);
133
+ }
134
+ progress.end();
135
+ session.replace(TYPES_FOLDER, records);
136
+ const report = { family: 'types', mode: decision.mode, reason: decision.reason, checked: ran.length, reused: reused.length, newFindings: added, knownFindings: known, fixedFindings: fixed };
137
+ session.report(report);
138
+ return { ok: findings.length === 0 && incomplete.length === 0, ran, reused, incomplete, findings: sortFindings(findings), report };
139
+ }
140
+ /**
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
+ * tool cache, run from the app root so paths in the output are app-relative.
143
+ */
144
+ export function createTscRunner({ appRoot, typescriptFrom }) {
145
+ return (program, buildInfo) => {
146
+ const folder = path.resolve(appRoot, path.posix.dirname(program.tsconfig));
147
+ const tsc = path.join(path.dirname(createRequire(typescriptFrom ?? path.join(folder, 'package.json')).resolve('typescript/package.json')), 'bin', 'tsc');
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
+ });
152
+ return { status: result.status ?? 1, output: `${result.stdout}${result.stderr}` };
153
+ };
154
+ }
@@ -0,0 +1,99 @@
1
+ /** The format of the records on disk; a cache of another version is replaced, never read. */
2
+ export declare const CACHE_FORMAT_VERSION = 1;
3
+ /** Where the committed cache lives, app-relative and posix. */
4
+ export declare const CACHE_DIR = ".starcistacks/cache";
5
+ /** The families a run selects (`app check --<family>`). */
6
+ export declare const FAMILIES: readonly ["hfs", "format", "db", "secrets", "wiring", "types", "lint", "test", "sonar"];
7
+ export type FamilyId = (typeof FAMILIES)[number];
8
+ /** Whether a family judged everything (`full`) or only what differs from the trusted cache (`diff`). */
9
+ export type RunMode = 'full' | 'diff';
10
+ /** Plain JSON, the only thing a record may carry: no undefined, no functions, no dates. */
11
+ export type JsonValue = null | boolean | number | string | readonly JsonValue[] | {
12
+ readonly [key: string]: JsonValue;
13
+ };
14
+ /** One finding of a step. Messages carry app-relative posix paths only. */
15
+ export interface Finding {
16
+ readonly path: string;
17
+ readonly line: number;
18
+ readonly column: number;
19
+ readonly rule: string;
20
+ readonly message: string;
21
+ }
22
+ /**
23
+ * One self-contained result: the `step` judged the `path` (or the input set a scoped step names) whose content was `hash`, and
24
+ * found `findings` (none for a clean file). `data` is the step's own extra payload (resolved imports, coverage items).
25
+ */
26
+ export interface CacheRecord {
27
+ readonly path: string;
28
+ readonly step: string;
29
+ readonly hash: string;
30
+ readonly findings: readonly Finding[];
31
+ readonly data?: JsonValue;
32
+ }
33
+ /** One part of the fingerprint: a name and the sha256 of what it stands for. */
34
+ export interface FingerprintPart {
35
+ readonly name: string;
36
+ readonly sha256: string;
37
+ }
38
+ /** What one family reports on its own line: `<family>: <full|diff> (<reason>), checked n, reused n; findings ...`. */
39
+ export interface FamilyReport {
40
+ readonly family: FamilyId;
41
+ readonly mode: RunMode;
42
+ readonly reason: string;
43
+ readonly checked: number;
44
+ readonly reused: number;
45
+ /** Findings this run produced that its records did not already hold. */
46
+ readonly newFindings: number;
47
+ /** Findings this run produced that its records already held. */
48
+ readonly knownFindings: number;
49
+ /** Findings the records held that this run no longer produces. */
50
+ readonly fixedFindings: number;
51
+ }
52
+ /** The tracked files of the app and their normalised content hashes. */
53
+ export interface ContentIndex {
54
+ readonly paths: readonly string[];
55
+ /** The hash of `path` now, or undefined when the work tree no longer holds it. */
56
+ hashOf(path: string): string | undefined;
57
+ }
58
+ /** A running step's progress: `tick` as files complete. */
59
+ export interface StepProgress {
60
+ tick(done: number, reused: number, checked: number): void;
61
+ end(): void;
62
+ }
63
+ /** Where progress goes (stderr in the CLI). Never stdout, which keeps the diagnosis. */
64
+ export interface ProgressSink {
65
+ step(family: FamilyId, name: string, total: number): StepProgress;
66
+ }
67
+ /** How a family decides to run: the global situation (no cache, `--full`, fingerprint, release) already folded in. */
68
+ export interface FamilyDecision {
69
+ readonly mode: RunMode;
70
+ readonly reason: string;
71
+ }
72
+ /** What the session wrote when it was flushed. */
73
+ export interface FlushResult {
74
+ readonly written: readonly string[];
75
+ readonly removed: readonly string[];
76
+ /** A disk error is a warning, never an exit code. */
77
+ readonly warnings: readonly string[];
78
+ }
79
+ /**
80
+ * The cache as one run sees it. A family asks it for its decision and its trusted records, judges, and replaces its records;
81
+ * the session writes them once at the end (`flush`), byte-stable.
82
+ */
83
+ export interface CheckSession {
84
+ readonly root: string;
85
+ readonly index: ContentIndex;
86
+ /** Whether the family may read the cache this run, and why not when it may not (no cache, `--full`, fingerprint, release). */
87
+ decide(family: FamilyId): FamilyDecision;
88
+ /** The trusted records of a folder (`lint/be`, `check/format`, `test-coverage`), unfiltered: the step compares hashes itself. */
89
+ records(folder: string): readonly CacheRecord[];
90
+ /** Replaces every record of a folder with `records`; written on `flush`. `depth` is how many folders of a path name its area file (default 2). */
91
+ replace(folder: string, records: readonly CacheRecord[], options?: {
92
+ readonly depth?: number;
93
+ }): void;
94
+ /** True when none of `paths` changed in the work tree since the run began: a scoped step stores nothing keyed on a file edited during the run. */
95
+ unchanged(paths: readonly string[]): boolean;
96
+ report(report: FamilyReport): void;
97
+ readonly progress: ProgressSink;
98
+ flush(): FlushResult;
99
+ }
@@ -0,0 +1,8 @@
1
+ // types.mts - the vocabulary of the committed app check cache (contexts/project/check-cache.md). Every family, step and spec of
2
+ // the cache speaks these types; nothing here reads the disk or the network.
3
+ /** The format of the records on disk; a cache of another version is replaced, never read. */
4
+ export const CACHE_FORMAT_VERSION = 1;
5
+ /** Where the committed cache lives, app-relative and posix. */
6
+ export const CACHE_DIR = '.starcistacks/cache';
7
+ /** The families a run selects (`app check --<family>`). */
8
+ export const FAMILIES = ['hfs', 'format', 'db', 'secrets', 'wiring', 'types', 'lint', 'test', 'sonar'];
@@ -0,0 +1,14 @@
1
+ import { type WiringResult } from '../runtime/scripts/hfs/app-wiring.mjs';
2
+ import type { CheckSession, FamilyReport } from './types.mjs';
3
+ export declare const WIRING_FOLDER = "check/wiring";
4
+ export interface WiringFamilyResult {
5
+ readonly wiring: WiringResult;
6
+ readonly report: FamilyReport;
7
+ }
8
+ /** 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
+ repoRoot: string;
11
+ edition?: string | undefined;
12
+ files?: readonly string[] | undefined;
13
+ run?: ((repoRoot: string) => WiringResult) | undefined;
14
+ }): Promise<WiringFamilyResult>;
@@ -0,0 +1,43 @@
1
+ // wiring-family.mts - `app check --wiring`: the boot proof of every app, a lite back end's composition with doubles. Scoped: reused when the
2
+ // back-end side key is unchanged. A timeout reached no verdict and is never stored.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import { appWiring, BOOT_PROOF_TIMEOUT } from '../runtime/scripts/hfs/app-wiring.mjs';
6
+ import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
7
+ import { trackedFiles } from '../runtime/scripts/hfs/check.mjs';
8
+ import { RecordSet, scopedStep } from './records.mjs';
9
+ import { makeReport } from './report.mjs';
10
+ import { sideKey } from './side-key.mjs';
11
+ export const WIRING_FOLDER = 'check/wiring';
12
+ const compact = (finding) => ({ path: finding.path, line: 0, column: 0, rule: finding.code, message: finding.message });
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 }) {
15
+ const decision = session.decide('wiring');
16
+ const records = new RecordSet(session.records(WIRING_FOLDER));
17
+ const key = sideKey(repoRoot, session.index, 'be');
18
+ const prove = () => {
19
+ if (run)
20
+ return run(repoRoot);
21
+ const declared = readRepoDeclaration(loadSlotManifest(), repoRoot);
22
+ const repo = { ...declared, edition: edition ?? declared.edition };
23
+ const proof = appWiring({ repoRoot, repo: repo, files: [...(files ?? trackedFiles(repoRoot))] });
24
+ const installed = fs.existsSync(path.join(repoRoot, 'node_modules')) || fs.existsSync(path.join(repoRoot, 'be', 'node_modules'));
25
+ const findings = proof.findings.filter((finding) => installed || finding.reason !== 'module or proof could not be compiled');
26
+ return { ...proof, findings, ok: findings.length === 0 };
27
+ };
28
+ const step = session.progress.step('wiring', 'boot proof', Math.max(1, key.inputs.length));
29
+ const { value, reused } = await scopedStep(session, records, {
30
+ path: 'be', step: 'boot-proof', key: key.key, reuse: decision.mode === 'diff', inputs: key.inputs,
31
+ compute: () => JSON.parse(JSON.stringify(prove())),
32
+ compact: (found) => found.findings.map(compact),
33
+ store: (found) => !found.findings.some((finding) => finding.code === BOOT_PROOF_TIMEOUT),
34
+ });
35
+ step.tick(key.inputs.length, reused ? key.inputs.length : 0, reused ? 0 : key.inputs.length);
36
+ step.end();
37
+ const wiring = value;
38
+ const previousRecords = session.records(WIRING_FOLDER).flatMap((record) => record.findings);
39
+ session.replace(WIRING_FOLDER, records.toArray());
40
+ const report = makeReport({ family: 'wiring', mode: decision.mode, reason: decision.reason, checked: reused ? 0 : 1, reused: reused ? 1 : 0, previous: previousRecords, current: wiring.findings.map(compact) });
41
+ session.report(report);
42
+ return { wiring, report };
43
+ }
@@ -1,3 +1,4 @@
1
+ import type { MergeSpecRecords } from '../cache/test-family.mjs';
1
2
  /** The preset's merge: shard coverage maps in, one lcov out. */
2
3
  type MergeCoverage = (args: {
3
4
  inputs: string[];
@@ -13,16 +14,17 @@ type Prove = (args: {
13
14
  }) => {
14
15
  ok: boolean;
15
16
  };
16
- export declare const USAGE = "usage: starci app coverage merge --out <lcov> <coverage-final.json-or-dir>... [--cwd <dir>] | starci app coverage verify <lcov> [--cwd <dir>]";
17
+ export declare const USAGE = "usage: starci app coverage merge --out <lcov> (<coverage-final.json-or-dir>... | --records <test-coverage-dir>) [--cwd <dir>] | starci app coverage verify <lcov> [--cwd <dir>]";
17
18
  /**
18
19
  * Exit 0 merged or proved, 1 a refused proof, 2 bad usage, no preset or no report found. `merge` replaces the installed preset merge and
19
20
  * `prove` the coverage proof in a spec.
20
21
  */
21
- export declare function coverageMain(argv: string[], { cwd: baseCwd, stdout, stderr, merge, prove }?: {
22
+ export declare function coverageMain(argv: string[], { cwd: baseCwd, stdout, stderr, merge, mergeSpecs, prove }?: {
22
23
  cwd?: string;
23
24
  stdout?: (text: string) => unknown;
24
25
  stderr?: (text: string) => unknown;
25
26
  merge?: MergeCoverage;
27
+ mergeSpecs?: MergeSpecRecords;
26
28
  prove?: Prove;
27
29
  }): Promise<number>;
28
30
  export {};