tsc-ts 0.1.0 → 0.1.1
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/README.md +1 -1
- package/dist/api/compiler/ast.d.ts +9 -3
- package/dist/api/compiler/ast.js +17 -12
- package/dist/api/compiler/checker-classes.js +2 -1
- package/dist/api/compiler/checker-contextual.d.ts +20 -0
- package/dist/api/compiler/checker-contextual.js +111 -1
- package/dist/api/compiler/checker-expressions.js +14 -1
- package/dist/api/compiler/checker-jsx.js +2 -1
- package/dist/api/compiler/checker-type-factory.js +53 -12
- package/dist/api/compiler/checker.d.ts +5 -1
- package/dist/api/compiler/checker.js +10 -4
- package/dist/api/compiler/compile-program.d.ts +5 -0
- package/dist/api/compiler/compile-program.js +8 -0
- package/dist/api/compiler/expr-record.d.ts +11 -0
- package/dist/api/compiler/expr-record.js +53 -0
- package/dist/api/compiler/identifier-name.d.ts +9 -0
- package/dist/api/compiler/identifier-name.js +10 -0
- package/dist/api/compiler/jsdoc.js +4 -2
- package/dist/api/compiler/parser.d.ts +2 -1
- package/dist/api/compiler/parser.js +10 -8
- package/dist/api/compiler/printer/factory.js +3 -2
- package/dist/api/compiler/printer/printer.js +3 -2
- package/dist/api/compiler/reparser.js +4 -3
- package/dist/api/compiler/scanner.d.ts +8 -0
- package/dist/api/compiler/scanner.js +58 -42
- package/dist/api/compiler/shared-results.d.ts +376 -143
- package/dist/api/compiler/shared-results.js +1550 -773
- package/dist/api/compiler/transformers/declarations.js +2 -1
- package/package.json +9 -9
- package/src/compiler/ast.ts +19 -12
- package/src/compiler/cgroup.ts +157 -0
- package/src/compiler/checker-classes.ts +2 -1
- package/src/compiler/checker-contextual.ts +104 -0
- package/src/compiler/checker-expressions.ts +16 -1
- package/src/compiler/checker-jsx.ts +2 -1
- package/src/compiler/checker-type-factory.ts +53 -14
- package/src/compiler/checker.ts +11 -4
- package/src/compiler/compile-program.ts +8 -0
- package/src/compiler/decoder.ts +2 -1
- package/src/compiler/doctor-report.ts +133 -0
- package/src/compiler/expr-record.ts +59 -0
- package/src/compiler/identifier-name.ts +13 -0
- package/src/compiler/jsdoc.ts +4 -2
- package/src/compiler/parallel-check.ts +353 -146
- package/src/compiler/parallel-emit.ts +3 -3
- package/src/compiler/parser.ts +10 -8
- package/src/compiler/printer/factory.ts +3 -2
- package/src/compiler/printer/printer.ts +4 -2
- package/src/compiler/reparser.ts +7 -3
- package/src/compiler/run-config.ts +1383 -0
- package/src/compiler/run-record-read.ts +87 -0
- package/src/compiler/run-records.ts +575 -0
- package/src/compiler/scanner.ts +57 -41
- package/src/compiler/shared-results.ts +1626 -896
- package/src/compiler/thread-entry.ts +3 -1
- package/src/compiler/transformers/declarations.ts +2 -1
- package/src/compiler/tsc-build.ts +64 -6
- package/src/compiler/tsc-cli.ts +33 -4
- package/src/compiler/tsc-compile.ts +213 -8
- package/src/compiler/tsc-watch.ts +25 -4
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Opening a project's run record before its files are parsed: the directory the records live in, the key
|
|
3
|
+
* that names this project's, and the record itself. `preParseConfig` needs the record before the first
|
|
4
|
+
* file (its chosen parse threads apply there), and `saveRunRecord` writes the same file afterwards.
|
|
5
|
+
*
|
|
6
|
+
* The key covers the executable (`process.execPath`), so a run reads only records a run of the same build
|
|
7
|
+
* wrote, and it needs the root files only where no config file names the project. Best effort throughout:
|
|
8
|
+
* a record that cannot be read is absent, and a run without a cache directory reads nothing.
|
|
9
|
+
*/
|
|
10
|
+
import { version } from './help.js';
|
|
11
|
+
import type { CompilerOptions } from './options.js';
|
|
12
|
+
import { memoryLimit } from './parallel-check.js';
|
|
13
|
+
import { availableCpus } from './cgroup.js';
|
|
14
|
+
import { learns, pinsOf } from './run-config.js';
|
|
15
|
+
import {
|
|
16
|
+
readRecord,
|
|
17
|
+
recordEnvironment,
|
|
18
|
+
recordFile,
|
|
19
|
+
recordKey,
|
|
20
|
+
recordsDirectory,
|
|
21
|
+
type RecordEnvironment,
|
|
22
|
+
type RecordOptions,
|
|
23
|
+
type RunRecord,
|
|
24
|
+
} from './run-records.js';
|
|
25
|
+
|
|
26
|
+
/** Where a run's record lives, and what it held before this run: `record` is `undefined` where there is none. */
|
|
27
|
+
export interface OpenedRecord {
|
|
28
|
+
readonly directory: string;
|
|
29
|
+
readonly key: string;
|
|
30
|
+
readonly file: string;
|
|
31
|
+
readonly record: RunRecord | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The project a record is kept for: its config file, or the root files where there is none. */
|
|
35
|
+
export interface RecordProject {
|
|
36
|
+
readonly configFile: string | undefined;
|
|
37
|
+
readonly rootNames: readonly string[];
|
|
38
|
+
readonly options: CompilerOptions;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Whether a run may keep and read a record: `TSC_TS_CACHE=0` and `TSC_TS_AUTO=0|static` turn them off. */
|
|
42
|
+
export function recordsAllowed(env: Record<string, string | undefined> = process.env): boolean {
|
|
43
|
+
return env.TSC_TS_CACHE !== '0' && learns(pinsOf(env).automaticity);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The record `project`'s runs share, read now, or `undefined` where the run keeps no records
|
|
48
|
+
* (`recordsAllowed`) or the cache directory is off. Reading is one small file: a missing or malformed
|
|
49
|
+
* record is absent, not an error.
|
|
50
|
+
*/
|
|
51
|
+
export function openRecord(
|
|
52
|
+
project: RecordProject,
|
|
53
|
+
env: Record<string, string | undefined> = process.env,
|
|
54
|
+
recordEnv: RecordEnvironment = recordEnvironment(),
|
|
55
|
+
): OpenedRecord | undefined {
|
|
56
|
+
if (!recordsAllowed(env)) return undefined;
|
|
57
|
+
const directory = recordsDirectory(recordEnv);
|
|
58
|
+
if (directory === undefined) return undefined;
|
|
59
|
+
const key = recordKey({
|
|
60
|
+
version,
|
|
61
|
+
executable: process.execPath,
|
|
62
|
+
configFile: project.configFile,
|
|
63
|
+
rootNames: project.rootNames,
|
|
64
|
+
options: recordOptionsOf(project.options),
|
|
65
|
+
cpus: Math.max(1, availableCpus()),
|
|
66
|
+
memoryBytes: memoryLimit(),
|
|
67
|
+
});
|
|
68
|
+
const file = recordFile(directory, key);
|
|
69
|
+
return { directory, key, file, record: readRecord(file) };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The options the record's key covers (`run-records.ts`: those that change the work a run does). */
|
|
73
|
+
export function recordOptionsOf(options: CompilerOptions): RecordOptions {
|
|
74
|
+
return {
|
|
75
|
+
noEmit: options.noEmit,
|
|
76
|
+
emitDeclarationOnly: options.emitDeclarationOnly,
|
|
77
|
+
declaration: options.declaration,
|
|
78
|
+
composite: options.composite,
|
|
79
|
+
incremental: options.incremental,
|
|
80
|
+
outFile: options.outFile,
|
|
81
|
+
noEmitOnError: options.noEmitOnError,
|
|
82
|
+
skipLibCheck: options.skipLibCheck,
|
|
83
|
+
checkJs: options.checkJs,
|
|
84
|
+
isolatedDeclarations: options.isolatedDeclarations,
|
|
85
|
+
configFilePath: options.configFilePath,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,575 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run records: what each eligible run measured, in a per-user cache directory, one file per project key.
|
|
3
|
+
* This step writes a sample after every eligible run and lets the `--extendedDiagnostics` section name the
|
|
4
|
+
* file; no decision reads a record yet (a later step's `decideCheck` does).
|
|
5
|
+
*
|
|
6
|
+
* Records do not go in the `.tsbuildinfo`: that file is tsgo's format and part of the output, and
|
|
7
|
+
* non-incremental runs do not write it. A record lives at `<cache>/runs-v1/<key>.json`, where the key
|
|
8
|
+
* covers what can change the best configuration: the build (the reported version and the executable's size
|
|
9
|
+
* and mtime), the project (its config file, or its root files), the options that change the work, and the
|
|
10
|
+
* machine's CPUs and memory. Reads and writes are best effort: a record that cannot be read or parsed is
|
|
11
|
+
* treated as absent and replaced, an unwritable directory turns writing off for the run, and nothing is
|
|
12
|
+
* printed except the section's `Records:` line.
|
|
13
|
+
*/
|
|
14
|
+
import {
|
|
15
|
+
mkdirSync,
|
|
16
|
+
readFileSync,
|
|
17
|
+
readdirSync,
|
|
18
|
+
realpathSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
statSync,
|
|
21
|
+
unlinkSync,
|
|
22
|
+
writeFileSync,
|
|
23
|
+
} from 'node:fs';
|
|
24
|
+
import { homedir } from 'node:os';
|
|
25
|
+
import { join as joinPath } from 'node:path';
|
|
26
|
+
import { hash128String, toHex } from './xxh3.js';
|
|
27
|
+
|
|
28
|
+
/** The format this build writes and accepts; another format is treated as absent. */
|
|
29
|
+
export const recordFormat = 1;
|
|
30
|
+
|
|
31
|
+
/** The most record files the cache directory keeps (`capDirectory`). */
|
|
32
|
+
const maxRecordFiles = 512;
|
|
33
|
+
|
|
34
|
+
/** The most samples a record keeps, oldest dropped first. */
|
|
35
|
+
const maxSamples = 32;
|
|
36
|
+
|
|
37
|
+
/** Samples older than this many days are dropped when a record is read or written. */
|
|
38
|
+
const maxSampleAgeDays = 30;
|
|
39
|
+
|
|
40
|
+
/** What a bound program shows about the project, and the last run's numbers for it. */
|
|
41
|
+
export interface RecordShape {
|
|
42
|
+
/** The program's files. */
|
|
43
|
+
files: number;
|
|
44
|
+
/** Characters of all of them. */
|
|
45
|
+
chars: number;
|
|
46
|
+
/** Characters of its declaration files. */
|
|
47
|
+
declarationChars: number;
|
|
48
|
+
/** Characters of its other files. */
|
|
49
|
+
checkChars: number;
|
|
50
|
+
/** Characters of the libraries a checker's range reached, on average (`CheckSignals.library`). */
|
|
51
|
+
library: number;
|
|
52
|
+
/** Characters of the files a checker's range checked, on average (`CheckSignals.own`). */
|
|
53
|
+
own: number;
|
|
54
|
+
/** The share of declarations two ranges reach (`CheckSignals.redundancy`). */
|
|
55
|
+
redundancy: number;
|
|
56
|
+
/**
|
|
57
|
+
* The share of the program's files that are declarations or under `node_modules` (the larger of
|
|
58
|
+
* `CheckSignals.declarationShare` and `CheckSignals.nodeModulesShare`), which the sharing rule reads
|
|
59
|
+
* as `F`. Records written before this field lack it, and the parse threads' choice reads it as 0.
|
|
60
|
+
*/
|
|
61
|
+
fileShare?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** What one run measured. */
|
|
65
|
+
export interface RecordSample {
|
|
66
|
+
/** 1-based, counted in the record (`runs`). */
|
|
67
|
+
run: number;
|
|
68
|
+
/** Seconds since the epoch, when the run finished. */
|
|
69
|
+
at: number;
|
|
70
|
+
/** `chosen` for a run that used the configuration it would have used anyway. */
|
|
71
|
+
role: 'chosen' | 'control' | 'explore' | 'doctor';
|
|
72
|
+
checkers: number;
|
|
73
|
+
share: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* Whether the run's posts were cost-bounded (`RunConfig.bounded`). Records written before the run
|
|
76
|
+
* configuration chose it lack the field, and are unbounded ones.
|
|
77
|
+
*/
|
|
78
|
+
bounded?: boolean;
|
|
79
|
+
parseThreads: number;
|
|
80
|
+
/** Parse and bind, milliseconds. */
|
|
81
|
+
parseMs: number;
|
|
82
|
+
/** Check, milliseconds. */
|
|
83
|
+
checkMs: number;
|
|
84
|
+
/** The process's wall time, milliseconds. */
|
|
85
|
+
totalMs: number;
|
|
86
|
+
/** User plus system CPU time of the process, milliseconds. */
|
|
87
|
+
cpuMs: number;
|
|
88
|
+
/** VmHWM at exit, MB; 0 where unavailable. */
|
|
89
|
+
peakMB: number;
|
|
90
|
+
/** The 1-minute load average at the start divided by the CPUs; samples above 0.5 are kept, not used. */
|
|
91
|
+
load: number;
|
|
92
|
+
/** The duplicate-work fraction (DESIGN 2.3) where this run measured it. */
|
|
93
|
+
dup?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The configuration a run chose, recorded so a later run knows what the samples compare. */
|
|
97
|
+
export interface RecordChoice {
|
|
98
|
+
checkers: number;
|
|
99
|
+
share: boolean;
|
|
100
|
+
/** Whether the chosen configuration posts cost-bounded; absent in records written before the choice existed. */
|
|
101
|
+
bounded?: boolean;
|
|
102
|
+
parseThreads: number;
|
|
103
|
+
/** `default` or `pinned` today; a later step also records `learned`. */
|
|
104
|
+
source: string;
|
|
105
|
+
/** The run that chose it. */
|
|
106
|
+
since: number;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* A configuration a run fast-reverted, and the run that did it: the samples taken before it no longer
|
|
111
|
+
* show the configuration faster, so an adoption reads only the runs after it (`fastRevert`).
|
|
112
|
+
*/
|
|
113
|
+
export interface RecordRejection {
|
|
114
|
+
checkers: number;
|
|
115
|
+
share: boolean;
|
|
116
|
+
/** Whether the rejected configuration posted cost-bounded; absent in records written before the choice existed. */
|
|
117
|
+
bounded?: boolean;
|
|
118
|
+
/** The run that rejected it. */
|
|
119
|
+
run: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** One record file. */
|
|
123
|
+
export interface RunRecord {
|
|
124
|
+
format: number;
|
|
125
|
+
shape: RecordShape;
|
|
126
|
+
/** The runs of this key, samples or not. */
|
|
127
|
+
runs: number;
|
|
128
|
+
lastExplore: number;
|
|
129
|
+
lastControl: number;
|
|
130
|
+
chosen: RecordChoice;
|
|
131
|
+
/** The last configuration a fast revert rejected, where one did. */
|
|
132
|
+
rejected?: RecordRejection;
|
|
133
|
+
samples: RecordSample[];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The options the key covers: those that change the work a run does (`RecordKeyInput.options`). */
|
|
137
|
+
export interface RecordOptions {
|
|
138
|
+
noEmit?: boolean;
|
|
139
|
+
emitDeclarationOnly?: boolean;
|
|
140
|
+
declaration?: boolean;
|
|
141
|
+
composite?: boolean;
|
|
142
|
+
incremental?: boolean;
|
|
143
|
+
outFile?: string;
|
|
144
|
+
noEmitOnError?: boolean;
|
|
145
|
+
skipLibCheck?: boolean;
|
|
146
|
+
checkJs?: boolean;
|
|
147
|
+
isolatedDeclarations?: boolean;
|
|
148
|
+
/** The config file the run reads, whose real path is part of the key. */
|
|
149
|
+
configFilePath?: string;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** What identifies a record: the build, the project, the options and the machine. */
|
|
153
|
+
export interface RecordKeyInput {
|
|
154
|
+
/** The version the executable reports (`help.ts`). */
|
|
155
|
+
version: string;
|
|
156
|
+
/** The executable, whose size and mtime separate builds that share a version. */
|
|
157
|
+
executable: string;
|
|
158
|
+
/** The project's config file, or the root files when there is none. */
|
|
159
|
+
configFile: string | undefined;
|
|
160
|
+
rootNames: readonly string[];
|
|
161
|
+
options: RecordOptions;
|
|
162
|
+
cpus: number;
|
|
163
|
+
memoryBytes: number;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** The environment the records read: `TSC_TS_CACHE`, `TSC_TS_CACHE_DIR` and the platform's home. */
|
|
167
|
+
export interface RecordEnvironment {
|
|
168
|
+
TSC_TS_CACHE?: string;
|
|
169
|
+
TSC_TS_CACHE_DIR?: string;
|
|
170
|
+
XDG_CACHE_HOME?: string;
|
|
171
|
+
LOCALAPPDATA?: string;
|
|
172
|
+
HOME?: string;
|
|
173
|
+
USERPROFILE?: string;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The directory the records live in, or `undefined` when `TSC_TS_CACHE=0` turns them off. Else
|
|
178
|
+
* `TSC_TS_CACHE_DIR`; else the platform's cache directory for this user.
|
|
179
|
+
*/
|
|
180
|
+
export function recordsDirectory(
|
|
181
|
+
env: RecordEnvironment,
|
|
182
|
+
platform: string = process.platform,
|
|
183
|
+
home: string = homedir(),
|
|
184
|
+
): string | undefined {
|
|
185
|
+
if (env.TSC_TS_CACHE === '0') return undefined;
|
|
186
|
+
if (env.TSC_TS_CACHE_DIR !== undefined && env.TSC_TS_CACHE_DIR !== '') return env.TSC_TS_CACHE_DIR;
|
|
187
|
+
const userHome = env.HOME ?? env.USERPROFILE ?? home;
|
|
188
|
+
if (platform === 'win32') {
|
|
189
|
+
const local = env.LOCALAPPDATA ?? joinPath(userHome, 'AppData', 'Local');
|
|
190
|
+
return joinPath(local, 'tsc-ts', 'cache');
|
|
191
|
+
}
|
|
192
|
+
if (platform === 'darwin') return joinPath(userHome, 'Library', 'Caches', 'tsc-ts');
|
|
193
|
+
const cache =
|
|
194
|
+
env.XDG_CACHE_HOME !== undefined && env.XDG_CACHE_HOME !== '' ? env.XDG_CACHE_HOME : joinPath(userHome, '.cache');
|
|
195
|
+
return joinPath(cache, 'tsc-ts');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The path of `key`'s record in `directory`. */
|
|
199
|
+
export function recordFile(directory: string, key: string): string {
|
|
200
|
+
return joinPath(directory, 'runs-v1', key + '.json');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The record-related environment, read by name (a scriptc build holds `process.env` as an index signature). */
|
|
204
|
+
export function recordEnvironment(): RecordEnvironment {
|
|
205
|
+
return {
|
|
206
|
+
TSC_TS_CACHE: process.env.TSC_TS_CACHE,
|
|
207
|
+
TSC_TS_CACHE_DIR: process.env.TSC_TS_CACHE_DIR,
|
|
208
|
+
XDG_CACHE_HOME: process.env.XDG_CACHE_HOME,
|
|
209
|
+
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
210
|
+
HOME: process.env.HOME,
|
|
211
|
+
USERPROFILE: process.env.USERPROFILE,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** The key of a record: the xxh3-128 hash of what identifies the build, project, options and machine. */
|
|
216
|
+
export function recordKey(input: RecordKeyInput): string {
|
|
217
|
+
return toHex(hash128String(recordKeyText(input)));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The text `recordKey` hashes; exported so a test can read it. */
|
|
221
|
+
export function recordKeyText(input: RecordKeyInput): string {
|
|
222
|
+
let build = input.version;
|
|
223
|
+
try {
|
|
224
|
+
const stat = statSync(input.executable);
|
|
225
|
+
build += ' ' + stat.size + ' ' + Math.floor(stat.mtimeMs);
|
|
226
|
+
} catch {
|
|
227
|
+
// No executable to stat (a dev build): the version alone identifies it.
|
|
228
|
+
}
|
|
229
|
+
let project: string;
|
|
230
|
+
if (input.configFile !== undefined && input.configFile !== '') {
|
|
231
|
+
try {
|
|
232
|
+
project = realpathSync(input.configFile);
|
|
233
|
+
} catch {
|
|
234
|
+
project = input.configFile;
|
|
235
|
+
}
|
|
236
|
+
} else project = [...input.rootNames].sort().join('\n');
|
|
237
|
+
const memoryGiB = Math.floor(input.memoryBytes / 1073741824);
|
|
238
|
+
return [
|
|
239
|
+
'format ' + recordFormat,
|
|
240
|
+
'build ' + build,
|
|
241
|
+
'project ' + project,
|
|
242
|
+
optionsText(input.options),
|
|
243
|
+
'cpus ' + input.cpus,
|
|
244
|
+
'memory ' + memoryGiB,
|
|
245
|
+
].join('\n');
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** The options line of the key: each named option's value, so a change gives a different key. */
|
|
249
|
+
function optionsText(options: RecordOptions): string {
|
|
250
|
+
const flags: [string, boolean | undefined][] = [
|
|
251
|
+
['noEmit', options.noEmit],
|
|
252
|
+
['emitDeclarationOnly', options.emitDeclarationOnly],
|
|
253
|
+
['declaration', options.declaration],
|
|
254
|
+
['composite', options.composite],
|
|
255
|
+
['incremental', options.incremental],
|
|
256
|
+
['noEmitOnError', options.noEmitOnError],
|
|
257
|
+
['skipLibCheck', options.skipLibCheck],
|
|
258
|
+
['checkJs', options.checkJs],
|
|
259
|
+
['isolatedDeclarations', options.isolatedDeclarations],
|
|
260
|
+
];
|
|
261
|
+
let text = 'options';
|
|
262
|
+
for (const [name, value] of flags) text += ' ' + name + '=' + (value === true ? '1' : '0');
|
|
263
|
+
return text + ' outFile=' + (options.outFile ?? '');
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** A record as read from a file, or `undefined` where the file is missing, unreadable or malformed. */
|
|
267
|
+
export function readRecord(file: string, now: number = Math.floor(Date.now() / 1000)): RunRecord | undefined {
|
|
268
|
+
let text: string;
|
|
269
|
+
try {
|
|
270
|
+
text = readFileSync(file, 'utf8');
|
|
271
|
+
} catch {
|
|
272
|
+
return undefined;
|
|
273
|
+
}
|
|
274
|
+
let parsed: unknown;
|
|
275
|
+
try {
|
|
276
|
+
parsed = JSON.parse(text);
|
|
277
|
+
} catch {
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
const record = recordOf(parsed);
|
|
281
|
+
if (record === undefined) return undefined;
|
|
282
|
+
return dropOldSamples(record, now);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** `parsed` as a record, or `undefined` where it is not one this build writes. */
|
|
286
|
+
function recordOf(parsed: unknown): RunRecord | undefined {
|
|
287
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
288
|
+
const r = parsed as Partial<RunRecord>;
|
|
289
|
+
if (r.format !== recordFormat || typeof r.runs !== 'number' || !isShape(r.shape) || !isChoice(r.chosen))
|
|
290
|
+
return undefined;
|
|
291
|
+
const samples = Array.isArray(r.samples) ? r.samples.filter(isSample) : [];
|
|
292
|
+
const rejected = isRejection(r.rejected) ? r.rejected : undefined;
|
|
293
|
+
return {
|
|
294
|
+
format: recordFormat,
|
|
295
|
+
shape: r.shape,
|
|
296
|
+
runs: r.runs,
|
|
297
|
+
lastExplore: typeof r.lastExplore === 'number' ? r.lastExplore : 0,
|
|
298
|
+
lastControl: typeof r.lastControl === 'number' ? r.lastControl : 0,
|
|
299
|
+
chosen: r.chosen,
|
|
300
|
+
rejected,
|
|
301
|
+
samples,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
function isShape(value: unknown): value is RecordShape {
|
|
306
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
307
|
+
const s = value as Partial<RecordShape>;
|
|
308
|
+
return (
|
|
309
|
+
isNumber(s.files) &&
|
|
310
|
+
isNumber(s.chars) &&
|
|
311
|
+
isNumber(s.declarationChars) &&
|
|
312
|
+
isNumber(s.checkChars) &&
|
|
313
|
+
isNumber(s.library) &&
|
|
314
|
+
isNumber(s.own) &&
|
|
315
|
+
isNumber(s.redundancy)
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function isChoice(value: unknown): value is RecordChoice {
|
|
320
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
321
|
+
const c = value as Partial<RecordChoice>;
|
|
322
|
+
return (
|
|
323
|
+
isNumber(c.checkers) &&
|
|
324
|
+
typeof c.share === 'boolean' &&
|
|
325
|
+
(c.bounded === undefined || typeof c.bounded === 'boolean') &&
|
|
326
|
+
isNumber(c.parseThreads) &&
|
|
327
|
+
typeof c.source === 'string' &&
|
|
328
|
+
isNumber(c.since)
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function isRejection(value: unknown): value is RecordRejection {
|
|
333
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
334
|
+
const r = value as Partial<RecordRejection>;
|
|
335
|
+
return (
|
|
336
|
+
isNumber(r.checkers) &&
|
|
337
|
+
typeof r.share === 'boolean' &&
|
|
338
|
+
(r.bounded === undefined || typeof r.bounded === 'boolean') &&
|
|
339
|
+
isNumber(r.run)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function isSample(value: unknown): value is RecordSample {
|
|
344
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
345
|
+
const s = value as Partial<RecordSample>;
|
|
346
|
+
return (
|
|
347
|
+
isNumber(s.run) &&
|
|
348
|
+
isNumber(s.at) &&
|
|
349
|
+
typeof s.role === 'string' &&
|
|
350
|
+
isNumber(s.checkers) &&
|
|
351
|
+
typeof s.share === 'boolean' &&
|
|
352
|
+
(s.bounded === undefined || typeof s.bounded === 'boolean') &&
|
|
353
|
+
isNumber(s.parseThreads) &&
|
|
354
|
+
isNumber(s.parseMs) &&
|
|
355
|
+
isNumber(s.checkMs) &&
|
|
356
|
+
isNumber(s.totalMs) &&
|
|
357
|
+
isNumber(s.cpuMs) &&
|
|
358
|
+
isNumber(s.peakMB) &&
|
|
359
|
+
isNumber(s.load)
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function isNumber(value: unknown): value is number {
|
|
364
|
+
return typeof value === 'number' && isFinite(value);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `record` without the samples older than the age limit. */
|
|
368
|
+
function dropOldSamples(record: RunRecord, now: number): RunRecord {
|
|
369
|
+
const oldest = now - maxSampleAgeDays * 86400;
|
|
370
|
+
const samples = record.samples.filter((s) => s.at >= oldest);
|
|
371
|
+
if (samples.length === record.samples.length) return record;
|
|
372
|
+
return {
|
|
373
|
+
format: record.format,
|
|
374
|
+
shape: record.shape,
|
|
375
|
+
runs: record.runs,
|
|
376
|
+
lastExplore: record.lastExplore,
|
|
377
|
+
lastControl: record.lastControl,
|
|
378
|
+
chosen: record.chosen,
|
|
379
|
+
rejected: record.rejected,
|
|
380
|
+
samples,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* The record `previous` becomes after `sample`: the run counted, the sample appended (the oldest dropped
|
|
386
|
+
* past the cap), and, where the project's shape changed by more than the limits below, the samples
|
|
387
|
+
* discarded. `runs` is kept through a shape change, so an exploration schedule continues.
|
|
388
|
+
*
|
|
389
|
+
* `chosen` is the configuration the run chose for the next one, and the record's choice follows it: a
|
|
390
|
+
* choice that differs from the one the samples describe (another count, sharing, parse threads or source)
|
|
391
|
+
* starts at this run, and one that is unchanged keeps its `since`. A run recorded with role `explore` or
|
|
392
|
+
* `control` moves the schedule those roles keep (`lastExplore`, `lastControl`); so does adopting a learned
|
|
393
|
+
* choice, so that its runs are re-measured one in `controlRuns` after it was adopted.
|
|
394
|
+
*
|
|
395
|
+
* `rejected` is the configuration this run's `fastRevert` discarded, which the record keeps past this run
|
|
396
|
+
* so the samples it rejected are not read as evidence for that configuration again; it survives later
|
|
397
|
+
* runs (a shape change starts over, like the samples).
|
|
398
|
+
*/
|
|
399
|
+
export function nextRecord(
|
|
400
|
+
previous: RunRecord | undefined,
|
|
401
|
+
sample: Omit<RecordSample, 'run'>,
|
|
402
|
+
shape: RecordShape,
|
|
403
|
+
chosen: RecordChoice,
|
|
404
|
+
rejected: RecordRejection | undefined = undefined,
|
|
405
|
+
): RunRecord {
|
|
406
|
+
const runs = (previous?.runs ?? 0) + 1;
|
|
407
|
+
const changed = previous === undefined || shapeChanged(previous.shape, shape);
|
|
408
|
+
const samples: RecordSample[] = changed ? [] : (previous?.samples ?? []).map((s) => sampleOf(s, s.run));
|
|
409
|
+
samples.push(sampleOf(sample, runs));
|
|
410
|
+
if (samples.length > maxSamples) samples.splice(0, samples.length - maxSamples);
|
|
411
|
+
const choiceChanged = previous !== undefined && !sameChoice(previous.chosen, chosen);
|
|
412
|
+
const adopted =
|
|
413
|
+
chosen.source === 'learned' &&
|
|
414
|
+
(changed || previous === undefined || previous.chosen.source !== 'learned' || choiceChanged);
|
|
415
|
+
return {
|
|
416
|
+
format: recordFormat,
|
|
417
|
+
shape,
|
|
418
|
+
runs,
|
|
419
|
+
lastExplore: sample.role === 'explore' ? runs : (previous?.lastExplore ?? 0),
|
|
420
|
+
lastControl: sample.role === 'control' || adopted ? runs : (previous?.lastControl ?? 0),
|
|
421
|
+
chosen: changed || choiceChanged ? choiceOf(chosen, runs) : previous!.chosen,
|
|
422
|
+
rejected: changed ? rejected : (rejected ?? previous?.rejected),
|
|
423
|
+
samples,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/** Whether two choices choose the same configuration, whatever run chose each. */
|
|
428
|
+
function sameChoice(previous: RecordChoice, chosen: RecordChoice): boolean {
|
|
429
|
+
return (
|
|
430
|
+
previous.checkers === chosen.checkers &&
|
|
431
|
+
previous.share === chosen.share &&
|
|
432
|
+
(previous.bounded === true) === (chosen.bounded === true) &&
|
|
433
|
+
previous.parseThreads === chosen.parseThreads &&
|
|
434
|
+
previous.source === chosen.source
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** `sample` as a record keeps it, with the run number the record gives it. */
|
|
439
|
+
function sampleOf(sample: Omit<RecordSample, 'run'>, run: number): RecordSample {
|
|
440
|
+
return {
|
|
441
|
+
run,
|
|
442
|
+
at: sample.at,
|
|
443
|
+
role: sample.role,
|
|
444
|
+
checkers: sample.checkers,
|
|
445
|
+
share: sample.share,
|
|
446
|
+
bounded: sample.bounded,
|
|
447
|
+
parseThreads: sample.parseThreads,
|
|
448
|
+
parseMs: sample.parseMs,
|
|
449
|
+
checkMs: sample.checkMs,
|
|
450
|
+
totalMs: sample.totalMs,
|
|
451
|
+
cpuMs: sample.cpuMs,
|
|
452
|
+
peakMB: sample.peakMB,
|
|
453
|
+
load: sample.load,
|
|
454
|
+
dup: sample.dup,
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/** `choice` as a record keeps it, with the run that chose it. */
|
|
459
|
+
function choiceOf(choice: RecordChoice, since: number): RecordChoice {
|
|
460
|
+
return {
|
|
461
|
+
checkers: choice.checkers,
|
|
462
|
+
share: choice.share,
|
|
463
|
+
bounded: choice.bounded,
|
|
464
|
+
parseThreads: choice.parseThreads,
|
|
465
|
+
source: choice.source,
|
|
466
|
+
since,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Whether `next` differs from `shape` enough that `shape`'s samples describe another program: files,
|
|
472
|
+
* declaration characters or check characters by more than 15%, or the redundancy by more than 0.1.
|
|
473
|
+
*/
|
|
474
|
+
export function shapeChanged(shape: RecordShape, next: RecordShape): boolean {
|
|
475
|
+
return (
|
|
476
|
+
beyond(shape.files, next.files) ||
|
|
477
|
+
beyond(shape.declarationChars, next.declarationChars) ||
|
|
478
|
+
beyond(shape.checkChars, next.checkChars) ||
|
|
479
|
+
Math.abs(shape.redundancy - next.redundancy) > 0.1
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Whether `next` differs from `previous` by more than 15% of `previous` (and from 0 by any amount). */
|
|
484
|
+
function beyond(previous: number, next: number): boolean {
|
|
485
|
+
if (previous === 0) return next !== 0;
|
|
486
|
+
return Math.abs(next - previous) > previous * 0.15;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** What a run's record write did, for the section's `Records:` line. */
|
|
490
|
+
export interface RecordWrite {
|
|
491
|
+
/** The record's path, where it was written. */
|
|
492
|
+
readonly path?: string;
|
|
493
|
+
/** Why the record could not be written. */
|
|
494
|
+
readonly reason?: string;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Writes `sample` to the record of `key` in `directory`: the record is read (a missing or malformed one is
|
|
499
|
+
* replaced), the sample appended (`nextRecord`), the file written under a temporary name and renamed, and
|
|
500
|
+
* the directory capped. Failures are reported, not thrown.
|
|
501
|
+
*/
|
|
502
|
+
export function writeRecord(
|
|
503
|
+
directory: string,
|
|
504
|
+
key: string,
|
|
505
|
+
sample: Omit<RecordSample, 'run'>,
|
|
506
|
+
shape: RecordShape,
|
|
507
|
+
chosen: RecordChoice,
|
|
508
|
+
rejected: RecordRejection | undefined = undefined,
|
|
509
|
+
): RecordWrite {
|
|
510
|
+
const file = recordFile(directory, key);
|
|
511
|
+
try {
|
|
512
|
+
mkdirSync(joinPath(directory, 'runs-v1'), { recursive: true });
|
|
513
|
+
} catch (e) {
|
|
514
|
+
return { reason: messageOf(e) };
|
|
515
|
+
}
|
|
516
|
+
const record = nextRecord(readRecord(file, sample.at), sample, shape, chosen, rejected);
|
|
517
|
+
const temporary = file + '.' + process.pid + '.tmp';
|
|
518
|
+
try {
|
|
519
|
+
writeFileSync(temporary, JSON.stringify(record) + '\n');
|
|
520
|
+
renameSync(temporary, file);
|
|
521
|
+
} catch (e) {
|
|
522
|
+
try {
|
|
523
|
+
unlinkSync(temporary);
|
|
524
|
+
} catch {
|
|
525
|
+
// Nothing to remove.
|
|
526
|
+
}
|
|
527
|
+
return { reason: messageOf(e) };
|
|
528
|
+
}
|
|
529
|
+
capDirectory(joinPath(directory, 'runs-v1'));
|
|
530
|
+
return { path: file };
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/** An error's message for the section: its first line, without the stack. */
|
|
534
|
+
function messageOf(error: unknown): string {
|
|
535
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
536
|
+
const line = text.split('\n')[0] ?? '';
|
|
537
|
+
return line === '' ? 'write failed' : line;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Keeps the cache directory under `maxRecordFiles` records: past the cap, the oldest tenth by mtime is
|
|
542
|
+
* removed. Best effort, like the writes.
|
|
543
|
+
*/
|
|
544
|
+
function capDirectory(directory: string): void {
|
|
545
|
+
let names: string[];
|
|
546
|
+
try {
|
|
547
|
+
names = readdirSync(directory).filter((name) => name.endsWith('.json'));
|
|
548
|
+
} catch {
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
if (names.length <= maxRecordFiles) return;
|
|
552
|
+
const entries: { name: string; mtime: number }[] = [];
|
|
553
|
+
for (const name of names) {
|
|
554
|
+
try {
|
|
555
|
+
entries.push({ name, mtime: statSync(joinPath(directory, name)).mtimeMs });
|
|
556
|
+
} catch {
|
|
557
|
+
// Gone already.
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
entries.sort((a, b) => a.mtime - b.mtime);
|
|
561
|
+
const remove = Math.ceil(entries.length / 10);
|
|
562
|
+
for (let i = 0; i < remove; i++) {
|
|
563
|
+
try {
|
|
564
|
+
unlinkSync(joinPath(directory, entries[i]!.name));
|
|
565
|
+
} catch {
|
|
566
|
+
// Someone else removed it.
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** `path` as the section prints it: the home directory as `~`. */
|
|
572
|
+
export function displayPath(path: string, home: string = homedir()): string {
|
|
573
|
+
if (home !== '' && path.startsWith(home + '/')) return '~' + path.slice(home.length);
|
|
574
|
+
return path;
|
|
575
|
+
}
|