@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +7 -8
  3. package/cache/fake-session.mjs +1 -1
  4. package/cache/hfs-family.mjs +22 -9
  5. package/cache/import-graph.d.mts +32 -4
  6. package/cache/import-graph.mjs +143 -11
  7. package/cache/lint-family.d.mts +0 -2
  8. package/cache/lint-family.mjs +54 -33
  9. package/cache/records.d.mts +7 -0
  10. package/cache/records.mjs +17 -1
  11. package/cache/run.d.mts +1 -1
  12. package/cache/session.mjs +6 -2
  13. package/cache/side-key.d.mts +2 -0
  14. package/cache/side-key.mjs +5 -1
  15. package/cache/structure-memo.d.mts +34 -0
  16. package/cache/structure-memo.mjs +150 -0
  17. package/cache/types.d.mts +6 -2
  18. package/cache/wiring-family.mjs +1 -1
  19. package/codemod/small-renames.mjs +22 -2
  20. package/lint/eslint-list.cjs +26 -0
  21. package/lint/run.d.mts +18 -58
  22. package/lint/run.mjs +54 -284
  23. package/package.json +1 -1
  24. package/release/lint-report.d.mts +1 -1
  25. package/release/lint-report.mjs +3 -3
  26. package/report/sonar.d.mts +1 -1
  27. package/report/sonar.mjs +3 -3
  28. package/report/stylelint-findings.d.mts +23 -0
  29. package/report/stylelint-findings.mjs +9 -0
  30. package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
  31. package/runtime/knowledge/hfs/ci-standard.yaml +2 -2
  32. package/runtime/knowledge/hfs/rules.yaml +4 -4
  33. package/runtime/knowledge/hfs/slots.yaml +1 -1
  34. package/runtime/knowledge/sonar-gate.yaml +2 -2
  35. package/runtime/modules/kernel/failure-codes.yaml +4 -4
  36. package/runtime/scripts/api/node/run-node.d.mts +5 -0
  37. package/runtime/scripts/hfs/check.d.mts +16 -1
  38. package/runtime/scripts/hfs/check.mjs +18 -7
  39. package/runtime/scripts/hfs/gate-threshold.mjs +1 -1
  40. package/runtime/scripts/hfs/rule-memo.d.mts +30 -0
  41. package/runtime/scripts/hfs/rule-memo.mjs +24 -0
  42. package/runtime/scripts/hfs/rules/a15-findings.d.mts +6 -0
  43. package/runtime/scripts/hfs/rules/a15-findings.mjs +11 -2
  44. package/runtime/scripts/hfs/rules/ci-standard.mjs +2 -2
  45. package/runtime/scripts/hfs/rules/monorepo.d.mts +1 -3
  46. package/runtime/scripts/hfs/rules/monorepo.mjs +9 -11
  47. package/runtime/scripts/hfs/rules/pipeline.mjs +13 -9
  48. package/runtime/scripts/hfs/rules/read.d.mts +5 -0
  49. package/runtime/scripts/hfs/rules/read.mjs +13 -0
  50. package/runtime/scripts/hfs/rules/secrets.d.mts +2 -0
  51. package/runtime/scripts/hfs/rules/secrets.mjs +4 -1
  52. package/runtime/scripts/hfs/rules/stacks-release.mjs +35 -21
  53. package/runtime/scripts/lib/worktree-exclude.mjs +1 -1
  54. package/scaffold/app.mjs +3 -3
  55. package/src/check-cache-structure-support.d.mts +52 -0
  56. package/src/main.d.mts +3 -2
  57. package/src/main.mjs +123 -109
  58. package/sync/ci-commands.d.mts +2 -1
  59. package/sync/ci-commands.mjs +19 -20
  60. package/sync/index.mjs +2 -2
  61. package/templates/app/package-scripts/core.json +2 -2
  62. package/templates/app/task-graph/turbo.json +0 -3
@@ -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)].filter((record) => inReport(record.path)).flatMap((record) => record.findings.map(keyOf)));
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({ ts, sideRoot, readText, fileExists: isFile, hashOf: (file) => hashOf(file) ?? null }, scope, recordedGraph, { pathsChanged: metaData['paths'] !== pathsDigest });
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].filter(inReport).sort();
310
- const styleList = [...dirtyStyle].filter(inReport).sort();
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 = [...scope, ...stylesheets].filter(inReport).length;
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 && input.report === null && !sideUnchanged;
395
+ const complete = errors.length === 0 && input.write && !sideUnchanged;
377
396
  if (complete) {
378
- const stable = [...freshEslint.keys(), ...freshStyle.keys()].every((file) => hashFile(abs(file)) === hashOf(file));
379
- if (stable) {
380
- const records = [...eslintRecords, ...styleRecords];
381
- // without an import graph or a project graph the next run lints the side whole; the results themselves are still stored
382
- try {
383
- if (build === null) {
384
- const ts = input.typescript();
385
- build = buildImportGraph({ ts, sideRoot, readText, fileExists: isFile, hashOf: (file) => hashOf(file) ?? null }, scope, null);
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
- catch {
396
- records.splice(0, records.length, ...records.filter((record) => record.step === LINT_STEPS.eslint || record.step === LINT_STEPS.stylelint));
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
- records.push({ path: SIDE_RECORD, step: LINT_STEPS.meta, hash: sideKey, findings: [], data: { extra: input.extraKey, files: scope.length, paths: pathsDigest, stylesheets: stylesheets.length } });
399
- session.replace(folder, records);
400
- written = true;
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;
@@ -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 (`app lint --changed`). */
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` and `starci app lint`. It decides per family whether a run is full or diff
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) => hashFile(path.join(root, file)) === index.hashOf(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() {
@@ -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[];
@@ -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
- return { key: combineHashes(parts), inputs: [...trackedPaths, ...untracked] };
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
- /** 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;
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;
@@ -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
+ });