@starci/hfs 5.0.7 → 5.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/README.md +7 -8
- package/cache/fake-session.mjs +1 -1
- package/cache/hfs-family.mjs +22 -9
- package/cache/import-graph.d.mts +32 -4
- package/cache/import-graph.mjs +143 -11
- package/cache/lint-family.d.mts +0 -2
- package/cache/lint-family.mjs +54 -33
- package/cache/records.d.mts +7 -0
- package/cache/records.mjs +17 -1
- package/cache/run.d.mts +1 -1
- package/cache/session.mjs +6 -2
- package/cache/side-key.d.mts +2 -0
- package/cache/side-key.mjs +5 -1
- package/cache/structure-memo.d.mts +34 -0
- package/cache/structure-memo.mjs +150 -0
- package/cache/types.d.mts +6 -2
- package/cache/wiring-family.mjs +1 -1
- package/codemod/small-renames.mjs +22 -2
- package/lint/eslint-list.cjs +26 -0
- package/lint/run.d.mts +18 -58
- package/lint/run.mjs +54 -284
- package/package.json +1 -1
- package/release/lint-report.d.mts +1 -1
- package/release/lint-report.mjs +3 -3
- package/report/sonar.d.mts +1 -1
- package/report/sonar.mjs +3 -3
- package/report/stylelint-findings.d.mts +23 -0
- package/report/stylelint-findings.mjs +9 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
- package/runtime/knowledge/hfs/rules.yaml +4 -4
- package/runtime/knowledge/hfs/slots.yaml +1 -1
- package/runtime/knowledge/sonar-gate.yaml +2 -2
- package/runtime/modules/kernel/failure-codes.yaml +4 -4
- package/runtime/scripts/api/node/run-node.d.mts +5 -0
- package/runtime/scripts/hfs/check.d.mts +16 -1
- package/runtime/scripts/hfs/check.mjs +18 -7
- package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
- package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
- package/runtime/scripts/hfs/rule-memo.mjs +24 -0
- package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
- package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
- package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
- package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
- package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
- package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
- package/runtime/scripts/hfs/rules/read.d.mts +5 -0
- package/runtime/scripts/hfs/rules/read.mjs +13 -0
- package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
- package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
- package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
- package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
- package/scaffold/app.mjs +3 -3
- package/src/check-cache-structure-support.d.mts +52 -0
- package/src/main.d.mts +3 -2
- package/src/main.mjs +123 -109
- package/sync/ci-commands.d.mts +2 -1
- package/sync/ci-commands.mjs +19 -20
- package/sync/index.mjs +2 -2
- package/templates/app/package-scripts/core.json +2 -2
- package/templates/app/task-graph/turbo.json +0 -3
package/cache/lint-family.mjs
CHANGED
|
@@ -138,6 +138,30 @@ const isFile = (file) => {
|
|
|
138
138
|
return false;
|
|
139
139
|
}
|
|
140
140
|
};
|
|
141
|
+
const isDirectory = (folder) => {
|
|
142
|
+
try {
|
|
143
|
+
return fs.statSync(folder).isDirectory();
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
/**
|
|
150
|
+
* What the import graph reads of the disk, remembered for the build: a side's thousands of files probe the same few hundred folders and
|
|
151
|
+
* candidate paths while resolving their imports, and the tree does not change while the graph is built.
|
|
152
|
+
*/
|
|
153
|
+
function graphHost(ts, sideRoot, hashOf) {
|
|
154
|
+
const files = new Map(), folders = new Map();
|
|
155
|
+
const remembered = (known, ask) => (path) => {
|
|
156
|
+
let answer = known.get(path);
|
|
157
|
+
if (answer === undefined) {
|
|
158
|
+
answer = ask(path);
|
|
159
|
+
known.set(path, answer);
|
|
160
|
+
}
|
|
161
|
+
return answer;
|
|
162
|
+
};
|
|
163
|
+
return { ts, sideRoot, readText, fileExists: remembered(files, isFile), directoryExists: remembered(folders, isDirectory), hashOf };
|
|
164
|
+
}
|
|
141
165
|
/** A record's `data` object, or an empty one. */
|
|
142
166
|
const dataOf = (record) => (record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data) ? record.data : {});
|
|
143
167
|
/** The graph findings of every canon that publishes graph rules, merged by file and sorted. */
|
|
@@ -162,7 +186,6 @@ const graphRecordFindings = (side, file, findings) => sortFindings(findings.map(
|
|
|
162
186
|
*/
|
|
163
187
|
export async function lintSide(input) {
|
|
164
188
|
const { session, side, sideRoot, scope, stylesheets } = input;
|
|
165
|
-
const inReport = input.report ?? (() => true);
|
|
166
189
|
const scopeSet = new Set(scope);
|
|
167
190
|
const styleSet = new Set(stylesheets);
|
|
168
191
|
const folder = `lint/${side}`;
|
|
@@ -189,7 +212,7 @@ export async function lintSide(input) {
|
|
|
189
212
|
const validStyle = new Map(reconcile(ofStep(LINT_STEPS.stylelint), hashOf).map((record) => [record.path, record]));
|
|
190
213
|
const meta = ofStep(LINT_STEPS.meta).find((record) => record.path === SIDE_RECORD);
|
|
191
214
|
const metaData = meta === undefined ? {} : dataOf(meta);
|
|
192
|
-
const previousFindings = new Set([...ofStep(LINT_STEPS.eslint), ...ofStep(LINT_STEPS.stylelint)].
|
|
215
|
+
const previousFindings = new Set([...ofStep(LINT_STEPS.eslint), ...ofStep(LINT_STEPS.stylelint)].flatMap((record) => record.findings.map(keyOf)));
|
|
193
216
|
const progress = session.progress.step('lint', `lint ${side}`, scope.length + stylesheets.length);
|
|
194
217
|
// ------------------------------------------------------------------ the plan
|
|
195
218
|
let whole = input.full;
|
|
@@ -242,10 +265,10 @@ export async function lintSide(input) {
|
|
|
242
265
|
const imports = Array.isArray(data['imports']) ? data['imports'].filter((value) => typeof value === 'string') : [];
|
|
243
266
|
const kept = previous.get(record.path);
|
|
244
267
|
if (kept === undefined || record.hash === hashOf(record.path))
|
|
245
|
-
previous.set(record.path, { hash: record.hash, imports, declaresGlobals: data['globals'] === true });
|
|
268
|
+
previous.set(record.path, { hash: record.hash, imports, declaresGlobals: data['globals'] === true, api: typeof data['api'] === 'string' ? data['api'] : undefined, open: typeof data['open'] === 'boolean' ? data['open'] : undefined, refs: Array.isArray(data['refs']) ? data['refs'].filter((value) => typeof value === 'string') : undefined });
|
|
246
269
|
}
|
|
247
270
|
const recordedGraph = previous.size === 0 ? null : previous;
|
|
248
|
-
build = buildImportGraph(
|
|
271
|
+
build = buildImportGraph(graphHost(ts, sideRoot, (file) => hashOf(file) ?? null), scope, recordedGraph, { pathsChanged: metaData['paths'] !== pathsDigest });
|
|
249
272
|
const dirty = dirtySetOf(recordedGraph, build.graph);
|
|
250
273
|
if (dirty.wholeSide !== null)
|
|
251
274
|
whole = dirty.wholeSide;
|
|
@@ -306,8 +329,8 @@ export async function lintSide(input) {
|
|
|
306
329
|
dirtyStyle = new Set(stylesheets);
|
|
307
330
|
}
|
|
308
331
|
// ------------------------------------------------------------------ the run
|
|
309
|
-
const eslintList = [...dirtyEslint].
|
|
310
|
-
const styleList = [...dirtyStyle].
|
|
332
|
+
const eslintList = [...dirtyEslint].sort();
|
|
333
|
+
const styleList = [...dirtyStyle].sort();
|
|
311
334
|
const freshEslint = new Map();
|
|
312
335
|
const freshStyle = new Map();
|
|
313
336
|
const rootOf = (file) => path.relative(sideRoot, file).replace(/\\/g, '/');
|
|
@@ -331,8 +354,6 @@ export async function lintSide(input) {
|
|
|
331
354
|
const styleRecords = [];
|
|
332
355
|
const planHash = (file) => hashOf(file) ?? 'absent';
|
|
333
356
|
for (const file of scope) {
|
|
334
|
-
if (!inReport(file))
|
|
335
|
-
continue;
|
|
336
357
|
const fresh = freshEslint.get(file);
|
|
337
358
|
const reuse = fresh === undefined ? validEslint.get(file) : undefined;
|
|
338
359
|
if (fresh !== undefined) {
|
|
@@ -349,8 +370,6 @@ export async function lintSide(input) {
|
|
|
349
370
|
}
|
|
350
371
|
}
|
|
351
372
|
for (const file of stylesheets) {
|
|
352
|
-
if (!inReport(file))
|
|
353
|
-
continue;
|
|
354
373
|
const fresh = freshStyle.get(file);
|
|
355
374
|
const reuse = fresh === undefined ? validStyle.get(file) : undefined;
|
|
356
375
|
if (fresh !== undefined) {
|
|
@@ -367,38 +386,40 @@ export async function lintSide(input) {
|
|
|
367
386
|
}
|
|
368
387
|
}
|
|
369
388
|
const checked = freshEslint.size + freshStyle.size;
|
|
370
|
-
const units =
|
|
389
|
+
const units = scope.length + stylesheets.length;
|
|
371
390
|
const reused = Math.max(0, units - checked);
|
|
372
391
|
progress.tick(units, reused, checked);
|
|
373
392
|
progress.end();
|
|
374
393
|
// ------------------------------------------------------------------ the records
|
|
375
394
|
let written = false;
|
|
376
|
-
const complete = errors.length === 0 && input.write &&
|
|
395
|
+
const complete = errors.length === 0 && input.write && !sideUnchanged;
|
|
377
396
|
if (complete) {
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
for (const [file, node] of build.graph)
|
|
388
|
-
records.push({ path: file, step: LINT_STEPS.imports, hash: node.hash, findings: [], data: { globals: node.declaresGlobals, imports: node.imports } });
|
|
389
|
-
snapshot ??= mergedSnapshot(sideRoot, input.canons);
|
|
390
|
-
for (const [file, findings] of Object.entries(snapshot ?? {})) {
|
|
391
|
-
if (scopeSet.has(file) && findings.length > 0)
|
|
392
|
-
records.push({ path: file, step: LINT_STEPS.graph, hash: planHash(file), findings: graphRecordFindings(side, file, findings) });
|
|
393
|
-
}
|
|
397
|
+
// A file edited while the run read it gets no record of any step, and the others keep theirs: their hashes are those of the tree the run
|
|
398
|
+
// planned from, so the edited file is then added (no recorded graph entry) and its dependents are judged again, whatever they read of it.
|
|
399
|
+
const moved = new Set([...freshEslint.keys(), ...freshStyle.keys()].filter((file) => hashFile(abs(file)) !== hashOf(file)));
|
|
400
|
+
const records = [...eslintRecords, ...styleRecords].filter((record) => !moved.has(record.path));
|
|
401
|
+
// without an import graph or a project graph the next run lints the side whole; the results themselves are still stored
|
|
402
|
+
try {
|
|
403
|
+
if (build === null) {
|
|
404
|
+
const ts = input.typescript();
|
|
405
|
+
build = buildImportGraph(graphHost(ts, sideRoot, (file) => hashOf(file) ?? null), scope, null);
|
|
394
406
|
}
|
|
395
|
-
|
|
396
|
-
|
|
407
|
+
for (const [file, node] of build.graph) {
|
|
408
|
+
if (!moved.has(file))
|
|
409
|
+
records.push({ path: file, step: LINT_STEPS.imports, hash: node.hash, findings: [], data: { globals: node.declaresGlobals, imports: node.imports, ...(node.api === undefined ? {} : { api: node.api }), open: node.open !== false, ...(node.refs === undefined ? {} : { refs: node.refs }) } });
|
|
397
410
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
411
|
+
snapshot ??= mergedSnapshot(sideRoot, input.canons);
|
|
412
|
+
for (const [file, findings] of Object.entries(snapshot ?? {})) {
|
|
413
|
+
if (scopeSet.has(file) && !moved.has(file) && findings.length > 0)
|
|
414
|
+
records.push({ path: file, step: LINT_STEPS.graph, hash: planHash(file), findings: graphRecordFindings(side, file, findings) });
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
catch {
|
|
418
|
+
records.splice(0, records.length, ...records.filter((record) => record.step === LINT_STEPS.eslint || record.step === LINT_STEPS.stylelint));
|
|
401
419
|
}
|
|
420
|
+
records.push({ path: SIDE_RECORD, step: LINT_STEPS.meta, hash: sideKey, findings: [], data: { extra: input.extraKey, files: scope.length, paths: pathsDigest, stylesheets: stylesheets.length } });
|
|
421
|
+
session.replace(folder, records);
|
|
422
|
+
written = true;
|
|
402
423
|
}
|
|
403
424
|
const currentKeys = new Set(current.map(keyOf));
|
|
404
425
|
let knownFindings = 0;
|
package/cache/records.d.mts
CHANGED
|
@@ -4,9 +4,15 @@ export declare class RecordSet {
|
|
|
4
4
|
constructor(initial?: readonly CacheRecord[]);
|
|
5
5
|
/** The record of (path, step) whose hash is `hash`, else undefined: a record of other content is no answer. */
|
|
6
6
|
matching(path: string, step: string, hash: string): CacheRecord | undefined;
|
|
7
|
+
/** The record of (path, step) whatever its hash, for a step that validates the record's own inputs. */
|
|
8
|
+
get(path: string, step: string): CacheRecord | undefined;
|
|
7
9
|
put(record: CacheRecord): void;
|
|
8
10
|
/** Drops every record of `step` whose path is not in `keep`: a deleted path drops every record it owns. */
|
|
9
11
|
retain(step: string, keep: ReadonlySet<string>): void;
|
|
12
|
+
/** Drops every record whose step starts with `prefix` and whose path is not in `keep`. */
|
|
13
|
+
retainPrefix(prefix: string, keep: ReadonlySet<string>): void;
|
|
14
|
+
/** Drops every record whose step starts with `prefix` and is not one of `keep`: a unit step no rule asked for this run belongs to no rule any more. */
|
|
15
|
+
dropPrefixExcept(prefix: string, keep: ReadonlySet<string>): void;
|
|
10
16
|
/** Every finding the records of `step` hold. */
|
|
11
17
|
findings(step: string): Finding[];
|
|
12
18
|
toArray(): CacheRecord[];
|
|
@@ -24,6 +30,7 @@ export declare function scopedStep<T extends JsonValue>(session: CheckSession, r
|
|
|
24
30
|
readonly key: string;
|
|
25
31
|
readonly reuse: boolean;
|
|
26
32
|
readonly inputs: readonly string[];
|
|
33
|
+
readonly captured?: Readonly<Record<string, string>>;
|
|
27
34
|
compute(): T | Promise<T>;
|
|
28
35
|
compact(value: T): Finding[];
|
|
29
36
|
store?(value: T): boolean;
|
package/cache/records.mjs
CHANGED
|
@@ -14,6 +14,10 @@ export class RecordSet {
|
|
|
14
14
|
const record = this.records.get(keyOf(path, step));
|
|
15
15
|
return record?.hash === hash ? record : undefined;
|
|
16
16
|
}
|
|
17
|
+
/** The record of (path, step) whatever its hash, for a step that validates the record's own inputs. */
|
|
18
|
+
get(path, step) {
|
|
19
|
+
return this.records.get(keyOf(path, step));
|
|
20
|
+
}
|
|
17
21
|
put(record) {
|
|
18
22
|
this.records.set(keyOf(record.path, record.step), record);
|
|
19
23
|
}
|
|
@@ -23,6 +27,18 @@ export class RecordSet {
|
|
|
23
27
|
if (record.step === step && !keep.has(record.path))
|
|
24
28
|
this.records.delete(key);
|
|
25
29
|
}
|
|
30
|
+
/** Drops every record whose step starts with `prefix` and whose path is not in `keep`. */
|
|
31
|
+
retainPrefix(prefix, keep) {
|
|
32
|
+
for (const [key, record] of this.records)
|
|
33
|
+
if (record.step.startsWith(prefix) && !keep.has(record.path))
|
|
34
|
+
this.records.delete(key);
|
|
35
|
+
}
|
|
36
|
+
/** Drops every record whose step starts with `prefix` and is not one of `keep`: a unit step no rule asked for this run belongs to no rule any more. */
|
|
37
|
+
dropPrefixExcept(prefix, keep) {
|
|
38
|
+
for (const [key, record] of this.records)
|
|
39
|
+
if (record.step.startsWith(prefix) && !keep.has(record.step))
|
|
40
|
+
this.records.delete(key);
|
|
41
|
+
}
|
|
26
42
|
/** Every finding the records of `step` hold. */
|
|
27
43
|
findings(step) {
|
|
28
44
|
return [...this.records.values()].filter((record) => record.step === step).flatMap((record) => record.findings);
|
|
@@ -48,7 +64,7 @@ export async function scopedStep(session, records, step) {
|
|
|
48
64
|
if (held?.data !== undefined)
|
|
49
65
|
return { value: held.data, reused: true };
|
|
50
66
|
const value = await step.compute();
|
|
51
|
-
if ((step.store?.(value) ?? true) && session.unchanged(step.inputs))
|
|
67
|
+
if ((step.store?.(value) ?? true) && session.unchanged(step.inputs, step.captured))
|
|
52
68
|
records.put({ path: step.path, step: step.step, hash: step.key, findings: step.compact(value), data: value });
|
|
53
69
|
return { value, reused: false };
|
|
54
70
|
}
|
package/cache/run.d.mts
CHANGED
|
@@ -17,7 +17,7 @@ export interface AppRunInput {
|
|
|
17
17
|
readonly presets?: Presets | undefined;
|
|
18
18
|
readonly prettier?: Prettier | undefined;
|
|
19
19
|
readonly edition?: string | undefined;
|
|
20
|
-
/** Limits the formatter to these app-relative files
|
|
20
|
+
/** Limits the formatter to these app-relative files. */
|
|
21
21
|
readonly only?: readonly string[] | undefined;
|
|
22
22
|
/** The ref the baseline database rules compare with, when the caller names one. */
|
|
23
23
|
readonly base?: string | undefined;
|
package/cache/session.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// session.mts - the disk-backed CheckSession of `starci app check
|
|
1
|
+
// session.mts - the disk-backed CheckSession of `starci app check`. It decides per family whether a run is full or diff
|
|
2
2
|
// (the rules of contexts/project/check-cache.md "When a run is full"), serves the trusted records of a folder, takes the records a family
|
|
3
3
|
// replaces and writes them once on flush: one JSON Lines file per area, byte-stable, only the files whose bytes changed. In CI it writes
|
|
4
4
|
// nothing and compares the committed cache with what it inherited or computed instead.
|
|
@@ -127,7 +127,11 @@ export function createDiskSession(options) {
|
|
|
127
127
|
replace(folder, records, replaceOptions) {
|
|
128
128
|
replaced.set(folder, { records, depth: replaceOptions?.depth ?? 2 });
|
|
129
129
|
},
|
|
130
|
-
unchanged: (paths) => paths.every((file) =>
|
|
130
|
+
unchanged: (paths, captured) => paths.every((file) => {
|
|
131
|
+
const now = hashFile(path.join(root, file));
|
|
132
|
+
const taken = captured?.[file];
|
|
133
|
+
return taken === undefined ? now === index.hashOf(file) : (now ?? 'absent') === taken;
|
|
134
|
+
}),
|
|
131
135
|
report: (report) => void reports.push(report),
|
|
132
136
|
verdicts: () => verdictList,
|
|
133
137
|
flush() {
|
package/cache/side-key.d.mts
CHANGED
|
@@ -3,6 +3,8 @@ export interface SideKey {
|
|
|
3
3
|
readonly key: string;
|
|
4
4
|
/** Every path the key covers: tracked paths of the side and the untracked program inputs. */
|
|
5
5
|
readonly inputs: readonly string[];
|
|
6
|
+
/** The hash each input had when the key was computed (`absent` for a missing one): the baseline `session.unchanged` compares an edit-during-the-run against. */
|
|
7
|
+
readonly hashes: Readonly<Record<string, string>>;
|
|
6
8
|
}
|
|
7
9
|
/** The untracked files the side's TypeScript program includes (generated code), side-relative paths prefixed with `side/`. */
|
|
8
10
|
export declare function untrackedProgramInputs(root: string, side: string, tracked: ReadonlySet<string>): string[];
|
package/cache/side-key.mjs
CHANGED
|
@@ -43,5 +43,9 @@ export function sideKey(root, index, side) {
|
|
|
43
43
|
const untracked = untrackedProgramInputs(root, side, new Set(index.paths));
|
|
44
44
|
for (const file of untracked)
|
|
45
45
|
parts[`untracked:${file}`] = hashFile(path.join(root, file)) ?? 'absent';
|
|
46
|
-
|
|
46
|
+
const hashes = {};
|
|
47
|
+
for (const [name, hash] of Object.entries(parts))
|
|
48
|
+
if (name.startsWith('tracked:') || name.startsWith('untracked:'))
|
|
49
|
+
hashes[name.slice(name.indexOf(':') + 1)] = hash;
|
|
50
|
+
return { key: combineHashes(parts), inputs: [...trackedPaths, ...untracked], hashes };
|
|
47
51
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { RuleMemo } from '../runtime/scripts/hfs/rule-memo.mjs';
|
|
2
|
+
import type { RecordSet } from './records.mjs';
|
|
3
|
+
import type { CheckSession } from './types.mjs';
|
|
4
|
+
export declare const FILE_RULES_STEP = "file-rules";
|
|
5
|
+
export declare const RULE_STEP_PREFIX = "rule:";
|
|
6
|
+
export declare const UNIT_STEP_PREFIX = "unit:";
|
|
7
|
+
/** What the memo did, for the family's diagnosis line. */
|
|
8
|
+
export interface MemoCounts {
|
|
9
|
+
/** Files whose per-file rules were judged afresh / taken from a record. */
|
|
10
|
+
filesChecked: number;
|
|
11
|
+
filesReused: number;
|
|
12
|
+
/** Cross-file rules run afresh / taken from a record. */
|
|
13
|
+
keyedChecked: number;
|
|
14
|
+
keyedReused: number;
|
|
15
|
+
/** Per-file units of cross-file rules judged afresh / taken from a record. */
|
|
16
|
+
unitsChecked: number;
|
|
17
|
+
unitsReused: number;
|
|
18
|
+
}
|
|
19
|
+
export interface StructureMemo {
|
|
20
|
+
readonly memo: RuleMemo;
|
|
21
|
+
readonly counts: MemoCounts;
|
|
22
|
+
/** The app paths whose per-file records this run holds (the rest are dropped with their files). */
|
|
23
|
+
readonly files: Set<string>;
|
|
24
|
+
/** The app paths whose unit records this run holds, by unit step. */
|
|
25
|
+
readonly units: Map<string, Set<string>>;
|
|
26
|
+
}
|
|
27
|
+
/** The memo of one structure pass over the app at `root`. `reuse` is false when the family runs in full: the records are then written and never read. */
|
|
28
|
+
export declare function createStructureMemo(input: {
|
|
29
|
+
session: CheckSession;
|
|
30
|
+
records: RecordSet;
|
|
31
|
+
reuse: boolean;
|
|
32
|
+
root: string;
|
|
33
|
+
tracked: readonly string[];
|
|
34
|
+
}): StructureMemo;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
// structure-memo.mts - the result cache of the content rules of the structure pass (`--gate hfs`, `--gate secrets`). The rules of the pass used to run over every tracked
|
|
2
|
+
// file on every run. Two kinds are now reused (contexts/project/check-cache.md "Which step reuses what"):
|
|
3
|
+
// - a per-file rule (`FileRule`: plaintext secrets, bypass markers, soft size): one record per file, `file-rules`, keyed on the file's content hash, the hashes of the
|
|
4
|
+
// other files the rule declares it reads (`deps`) and the names of the rules; it holds each rule's findings for the file. The cache folder is the only place a verdict is kept.
|
|
5
|
+
// - a cross-file rule (`memoKeyed`: the documentation freshness): one record per scope, `rule:<id>`, keyed on the exact set of files the rule read through `readText` /
|
|
6
|
+
// `readJson` (recorded when it ran, each with the hash it had then) and on the digest of the tracked path list, which every such rule derives its file sets from.
|
|
7
|
+
// Everything else the pass runs (the slots, the tree, the pins, the managed files, the rules that stat or walk the work tree, git state, untracked entries) stays always-run.
|
|
8
|
+
// A rule that must be judged on the work tree is never given to this memo; a finding that does not carry the path of the file it judges makes the whole batch uncacheable.
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
|
|
12
|
+
import { recordReads } from '../runtime/scripts/hfs/rules/read.mjs';
|
|
13
|
+
import { inCacheDir } from './content-index.mjs';
|
|
14
|
+
import { combineHashes, hashFile, hashText } from './hash.mjs';
|
|
15
|
+
export const FILE_RULES_STEP = 'file-rules';
|
|
16
|
+
export const RULE_STEP_PREFIX = 'rule:';
|
|
17
|
+
export const UNIT_STEP_PREFIX = 'unit:';
|
|
18
|
+
const posix = (value) => value.split(path.sep).join('/');
|
|
19
|
+
const compact = (finding) => ({
|
|
20
|
+
path: finding.path ?? '.', line: finding.line ?? 0, column: finding.column ?? 0, rule: finding.code, message: String(finding.message ?? ''),
|
|
21
|
+
});
|
|
22
|
+
const plain = (value) => JSON.parse(JSON.stringify(value));
|
|
23
|
+
/** The memo of one structure pass over the app at `root`. `reuse` is false when the family runs in full: the records are then written and never read. */
|
|
24
|
+
export function createStructureMemo(input) {
|
|
25
|
+
const { session, records, reuse, root } = input;
|
|
26
|
+
const counts = { filesChecked: 0, filesReused: 0, keyedChecked: 0, keyedReused: 0, unitsChecked: 0, unitsReused: 0 };
|
|
27
|
+
const files = new Set();
|
|
28
|
+
const units = new Map();
|
|
29
|
+
// What this run already judged, so a rule asked twice about one file in one run (the schedule scan is) answers from the first judgement.
|
|
30
|
+
const judged = new Map();
|
|
31
|
+
const textHashes = new Map();
|
|
32
|
+
// The hash of an app path now: the index's for a tracked file (the snapshot of this run), the work tree's for anything else, `absent` for a missing one.
|
|
33
|
+
const hashNow = (appPath) => session.index.hashOf(appPath) ?? hashFile(path.join(root, appPath)) ?? 'absent';
|
|
34
|
+
// What every cross-file rule derives its file sets and its parsing from: the tracked path list, and the TypeScript the rules parse with (the lockfile is a fingerprint part, but an app
|
|
35
|
+
// with no install resolves the compiler from the harness instead).
|
|
36
|
+
let trackedDigest;
|
|
37
|
+
const typescriptVersion = () => loadTypescript(root, path.dirname(fileURLToPath(import.meta.url)))?.version ?? 'none';
|
|
38
|
+
const digestOfTracked = () => (trackedDigest ??= hashText(`${[...input.tracked].sort().join('\n')}\0typescript=${typescriptVersion()}`));
|
|
39
|
+
const prefixOf = (scopeRoot) => {
|
|
40
|
+
const relative = posix(path.relative(root, scopeRoot));
|
|
41
|
+
return relative === '' ? '' : `${relative}/`;
|
|
42
|
+
};
|
|
43
|
+
const filesStep = (scopeRoot, list, rules) => {
|
|
44
|
+
const prefix = prefixOf(scopeRoot);
|
|
45
|
+
const names = rules.map((rule) => rule.id).join('+');
|
|
46
|
+
// The cache folder is rewritten by every run and is outside every step's file set: its files are judged live and never keyed.
|
|
47
|
+
const live = list.filter((file) => inCacheDir(`${prefix}${file}`));
|
|
48
|
+
const liveOut = live.length === 0 ? [] : rules.map((rule) => rule.run([...live]));
|
|
49
|
+
const entries = list.filter((file) => !inCacheDir(`${prefix}${file}`)).map((file) => {
|
|
50
|
+
const deps = [...new Set(rules.flatMap((rule) => rule.deps?.(file) ?? []))].sort().map((dep) => `${prefix}${dep}`);
|
|
51
|
+
const parts = { rules: names, self: hashNow(`${prefix}${file}`) };
|
|
52
|
+
const captured = { [`${prefix}${file}`]: parts['self'] };
|
|
53
|
+
for (const dep of deps) {
|
|
54
|
+
parts[`dep:${dep}`] = hashNow(dep);
|
|
55
|
+
captured[dep] = parts[`dep:${dep}`];
|
|
56
|
+
}
|
|
57
|
+
return { file, app: `${prefix}${file}`, deps, captured, key: combineHashes(parts) };
|
|
58
|
+
});
|
|
59
|
+
const held = new Map();
|
|
60
|
+
for (const entry of entries) {
|
|
61
|
+
const record = reuse ? records.matching(entry.app, FILE_RULES_STEP, entry.key) : undefined;
|
|
62
|
+
if (record?.data !== undefined)
|
|
63
|
+
held.set(entry.file, record.data);
|
|
64
|
+
}
|
|
65
|
+
const missing = entries.filter((entry) => !held.has(entry.file));
|
|
66
|
+
counts.filesReused += entries.length - missing.length;
|
|
67
|
+
counts.filesChecked += missing.length;
|
|
68
|
+
const computed = new Map(missing.map((entry) => [entry.file, {}]));
|
|
69
|
+
if (missing.length > 0) {
|
|
70
|
+
const names = new Set(missing.map((entry) => entry.file));
|
|
71
|
+
for (const rule of rules) {
|
|
72
|
+
for (const finding of rule.run(missing.map((entry) => entry.file))) {
|
|
73
|
+
const own = finding.path !== undefined && names.has(finding.path) ? computed.get(finding.path) : undefined;
|
|
74
|
+
// A finding on a file the batch did not hold is a rule that is not per-file: nothing of it can be kept per file.
|
|
75
|
+
if (own === undefined)
|
|
76
|
+
return rules.flatMap((each) => each.run([...list]));
|
|
77
|
+
(own[rule.id] ??= []).push(finding);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
for (const entry of missing) {
|
|
81
|
+
const result = computed.get(entry.file);
|
|
82
|
+
if (!session.unchanged(Object.keys(entry.captured), entry.captured))
|
|
83
|
+
continue;
|
|
84
|
+
records.put({ path: entry.app, step: FILE_RULES_STEP, hash: entry.key, findings: Object.values(result).flat().map(compact), data: plain(result) });
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const entry of entries)
|
|
88
|
+
files.add(entry.app);
|
|
89
|
+
const out = [];
|
|
90
|
+
rules.forEach((rule, at) => {
|
|
91
|
+
for (const entry of entries)
|
|
92
|
+
out.push(...((held.get(entry.file) ?? computed.get(entry.file))?.[rule.id] ?? []));
|
|
93
|
+
out.push(...(liveOut[at] ?? []));
|
|
94
|
+
});
|
|
95
|
+
return out;
|
|
96
|
+
};
|
|
97
|
+
const keyedStep = (scopeRoot, id, run) => {
|
|
98
|
+
const at = prefixOf(scopeRoot).replace(/\/$/, '') || '.';
|
|
99
|
+
const step = `${RULE_STEP_PREFIX}${id}`;
|
|
100
|
+
const digest = digestOfTracked();
|
|
101
|
+
const record = reuse ? records.get(at, step) : undefined;
|
|
102
|
+
const data = record?.data;
|
|
103
|
+
if (data?.tracked === digest && data.reads !== undefined && data.findings !== undefined && Object.entries(data.reads).every(([file, hash]) => hashNow(file) === hash)) {
|
|
104
|
+
counts.keyedReused += 1;
|
|
105
|
+
return data.findings;
|
|
106
|
+
}
|
|
107
|
+
counts.keyedChecked += 1;
|
|
108
|
+
const reads = {};
|
|
109
|
+
const findings = recordReads((from, rel) => {
|
|
110
|
+
const app = posix(path.relative(root, path.join(from, rel)));
|
|
111
|
+
if (app.startsWith('..') || path.isAbsolute(app) || app in reads)
|
|
112
|
+
return;
|
|
113
|
+
reads[app] = hashNow(app);
|
|
114
|
+
}, run);
|
|
115
|
+
if (session.unchanged(Object.keys(reads), reads)) {
|
|
116
|
+
const stored = { path: at, step, hash: combineHashes({ tracked: digest, ...Object.fromEntries(Object.entries(reads).map(([file, hash]) => [`read:${file}`, hash])) }), findings: findings.map(compact), data: plain({ tracked: digest, reads, findings }) };
|
|
117
|
+
records.put(stored);
|
|
118
|
+
}
|
|
119
|
+
return findings;
|
|
120
|
+
};
|
|
121
|
+
const unitStep = (scopeRoot, id, file, extra, empty, compute) => {
|
|
122
|
+
const app = `${prefixOf(scopeRoot)}${file}`;
|
|
123
|
+
const step = `${UNIT_STEP_PREFIX}${id}`;
|
|
124
|
+
let extraHash = textHashes.get(extra);
|
|
125
|
+
if (extraHash === undefined)
|
|
126
|
+
textHashes.set(extra, (extraHash = hashText(extra)));
|
|
127
|
+
const self = hashNow(app);
|
|
128
|
+
const key = combineHashes({ unit: id, extra: extraHash, self, typescript: typescriptVersion() });
|
|
129
|
+
(units.get(step) ?? units.set(step, new Set()).get(step))?.add(app);
|
|
130
|
+
const memory = judged.get(JSON.stringify([step, app, key]));
|
|
131
|
+
if (memory !== undefined)
|
|
132
|
+
return memory;
|
|
133
|
+
const record = reuse ? records.matching(app, step, key) : undefined;
|
|
134
|
+
let value;
|
|
135
|
+
if (record !== undefined) {
|
|
136
|
+
counts.unitsReused += 1;
|
|
137
|
+
value = (record.data ?? empty);
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
counts.unitsChecked += 1;
|
|
141
|
+
value = compute();
|
|
142
|
+
const empties = JSON.stringify(empty) === JSON.stringify(value);
|
|
143
|
+
if (session.unchanged([app], { [app]: self }))
|
|
144
|
+
records.put({ path: app, step, hash: key, findings: [], ...(empties ? {} : { data: plain(value) }) });
|
|
145
|
+
}
|
|
146
|
+
judged.set(JSON.stringify([step, app, key]), value);
|
|
147
|
+
return value;
|
|
148
|
+
};
|
|
149
|
+
return { memo: { files: filesStep, keyed: keyedStep, unit: unitStep }, counts, files, units };
|
|
150
|
+
}
|
package/cache/types.d.mts
CHANGED
|
@@ -91,8 +91,12 @@ export interface CheckSession {
|
|
|
91
91
|
replace(folder: string, records: readonly CacheRecord[], options?: {
|
|
92
92
|
readonly depth?: number;
|
|
93
93
|
}): void;
|
|
94
|
-
/**
|
|
95
|
-
|
|
94
|
+
/**
|
|
95
|
+
* 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.
|
|
96
|
+
* A path the content index does not hold (an untracked program input, generated code) is compared with `captured`, the hash the step's key took
|
|
97
|
+
* of it (`absent` for a missing file); without a captured hash it is compared with the index, so an untracked path is never unchanged.
|
|
98
|
+
*/
|
|
99
|
+
unchanged(paths: readonly string[], captured?: Readonly<Record<string, string>>): boolean;
|
|
96
100
|
report(report: FamilyReport): void;
|
|
97
101
|
readonly progress: ProgressSink;
|
|
98
102
|
flush(): FlushResult;
|
package/cache/wiring-family.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export async function runWiringFamily(session, { repoRoot, edition, files, run,
|
|
|
27
27
|
};
|
|
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
|
-
path: 'be', step: 'boot-proof', key: key.key, reuse: decision.mode === 'diff', inputs: key.inputs,
|
|
30
|
+
path: 'be', step: 'boot-proof', key: key.key, reuse: decision.mode === 'diff', inputs: key.inputs, captured: key.hashes,
|
|
31
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),
|
|
@@ -32,7 +32,7 @@ const VARIANT_REMOVED = new Set(['Input', 'Textarea', 'SearchField', 'NumberFiel
|
|
|
32
32
|
'Select', 'ComboBox', 'DatePicker', 'DateRangePicker', 'CopyField', 'OtpInput', 'Tabs']);
|
|
33
33
|
/** Components that lost `size="control"`: a bar sets it from context. */
|
|
34
34
|
const CONTROL_SIZED = new Set(['Button', 'IconButton', 'Input', 'SearchField', 'SegmentedControl']);
|
|
35
|
-
/** Components whose `control` step was their `lg`: the attribute becomes `size="lg"`, which draws the same. */
|
|
35
|
+
/** Components whose `control` step was their `lg`: the attribute becomes `size="lg"`, which draws the same, except in a navbar band, where `control` stays. */
|
|
36
36
|
const CONTROL_IS_LG = new Set(['Switch', 'Avatar', 'AvatarButton']);
|
|
37
37
|
const TONES = new Map([['live', 'success'], ['reconnecting', 'warning'], ['lost', 'danger']]);
|
|
38
38
|
const OTP_RENAMES = new Map([['disabled', 'isDisabled'], ['invalid', 'isInvalid'], ['onChange', 'onValueChange']]);
|
|
@@ -188,6 +188,26 @@ export function transformSmallRenames({ ts, text, file = 'component.tsx' }) {
|
|
|
188
188
|
return { name: tag.name.text, namespace: tag.expression.text, binding: null };
|
|
189
189
|
return null;
|
|
190
190
|
};
|
|
191
|
+
/**
|
|
192
|
+
* Whether `node` sits in a navbar band: the children of a TopBar, the `header` or `compactHeader` of a WorkspaceShell, or the `trigger` of a Drawer.
|
|
193
|
+
* A bar control keeps `size="control"`: `starci-grammar/navbar-control-size` requires it there and `starci-fe/control-height` accepts it,
|
|
194
|
+
* while `size="lg"` fails both.
|
|
195
|
+
*/
|
|
196
|
+
const inBar = (node) => {
|
|
197
|
+
for (let at = node.parent; at && !ts.isSourceFile(at); at = at.parent) {
|
|
198
|
+
const bar = ts.isJsxElement(at) ? at.openingElement : ts.isJsxSelfClosingElement(at) ? at : null;
|
|
199
|
+
if (bar !== null && resolve(bar.tagName)?.name === 'TopBar')
|
|
200
|
+
return true;
|
|
201
|
+
if (ts.isJsxAttribute(at)) {
|
|
202
|
+
const owner = at.parent.parent;
|
|
203
|
+
const tag = ts.isJsxOpeningElement(owner) || ts.isJsxSelfClosingElement(owner) ? resolve(owner.tagName)?.name : undefined;
|
|
204
|
+
const prop = at.name.getText(source);
|
|
205
|
+
if ((tag === 'WorkspaceShell' && (prop === 'header' || prop === 'compactHeader')) || (tag === 'Drawer' && prop === 'trigger'))
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false;
|
|
210
|
+
};
|
|
191
211
|
const conflict = (node, name, component) => { flag(node, 'IMPORT_NAME_CONFLICT', `${name} is already bound to something that is not the grammar export; rewrite by hand`, component); };
|
|
192
212
|
// Exports renamed one to one.
|
|
193
213
|
for (const binding of bindings.values()) {
|
|
@@ -235,7 +255,7 @@ export function transformSmallRenames({ ts, text, file = 'component.tsx' }) {
|
|
|
235
255
|
flag(props.get('variant') ?? opening, 'BUTTON_VARIANT', 'run `starci app codemod button-intent` first: that codemod owns variant', component);
|
|
236
256
|
if (CONTROL_SIZED.has(name) && sizeValue === 'control')
|
|
237
257
|
drop('size', 'SIZE_CONTROL_REMOVED');
|
|
238
|
-
if (CONTROL_IS_LG.has(name) && sizeValue === 'control') {
|
|
258
|
+
if (CONTROL_IS_LG.has(name) && sizeValue === 'control' && !inBar(opening)) {
|
|
239
259
|
const attribute = props.get('size');
|
|
240
260
|
if (attribute) {
|
|
241
261
|
change(attribute, 'SIZE_CONTROL_LG', attribute.getText(source), 'size="lg"');
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
// eslint-list.cts - ESLint over a list of files in ONE process, through the app's own install (the Node API of the ESLint the side's folder resolves).
|
|
4
|
+
//
|
|
5
|
+
// The CLI of ESLint takes its files as arguments, and a diff of hundreds of long paths outgrows the Windows command line (32,767 characters), which
|
|
6
|
+
// used to cut the list into chunks that each built the whole TypeScript program again. This runner reads the list from a JSON file named by its one
|
|
7
|
+
// argument ({ files, fix }), lints it once from its working directory (the side folder) and prints the results as the `--format json` of the CLI
|
|
8
|
+
// does (the list of result objects), so the launcher validates them the same way. Its exit code is the CLI's: 0 clean of errors, 1 at least one
|
|
9
|
+
// error, 2 the run could not complete (the message is on stderr). It stays CommonJS like bound-sys.cts, which may be preloaded in front of it.
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { createRequire } = require('node:module');
|
|
13
|
+
async function main() {
|
|
14
|
+
const job = JSON.parse(fs.readFileSync(String(process.argv[2]), 'utf8'));
|
|
15
|
+
const cwd = process.cwd();
|
|
16
|
+
const { ESLint } = createRequire(path.join(cwd, 'package.json'))('eslint');
|
|
17
|
+
const results = await new ESLint({ cwd, fix: job.fix }).lintFiles(job.files);
|
|
18
|
+
if (job.fix)
|
|
19
|
+
await ESLint.outputFixes(results);
|
|
20
|
+
process.stdout.write(JSON.stringify(results));
|
|
21
|
+
return results.some((result) => result.errorCount > 0 || result.fatalErrorCount > 0) ? 1 : 0;
|
|
22
|
+
}
|
|
23
|
+
main().then((code) => { process.exitCode = code; }, (error) => {
|
|
24
|
+
process.stderr.write(`${String(error?.stack ?? error)}\n`);
|
|
25
|
+
process.exitCode = 2;
|
|
26
|
+
});
|