@starci/hfs 4.12.0 → 4.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/README.md +8 -9
- package/cache/app-families.d.mts +30 -0
- package/cache/app-families.mjs +94 -0
- package/cache/content-index.d.mts +13 -0
- package/cache/content-index.mjs +37 -0
- package/cache/db-family.d.mts +13 -0
- package/cache/db-family.mjs +37 -0
- package/cache/fake-session.d.mts +42 -0
- package/cache/fake-session.mjs +57 -0
- package/cache/fingerprint.d.mts +28 -0
- package/cache/fingerprint.mjs +120 -0
- package/cache/format-family.d.mts +13 -0
- package/cache/format-family.mjs +49 -0
- package/cache/git.d.mts +11 -0
- package/cache/git.mjs +13 -0
- package/cache/hash.d.mts +15 -0
- package/cache/hash.mjs +58 -0
- package/cache/hfs-family.d.mts +35 -0
- package/cache/hfs-family.mjs +164 -0
- package/cache/import-graph.d.mts +75 -0
- package/cache/import-graph.mjs +204 -0
- package/cache/jsonl.d.mts +31 -0
- package/cache/jsonl.mjs +115 -0
- package/cache/lint-family.d.mts +162 -0
- package/cache/lint-family.mjs +433 -0
- package/cache/order.d.mts +1 -0
- package/cache/order.mjs +2 -0
- package/cache/progress.d.mts +19 -0
- package/cache/progress.mjs +68 -0
- package/cache/quality-baselines.d.mts +109 -0
- package/cache/quality-baselines.mjs +235 -0
- package/cache/records.d.mts +33 -0
- package/cache/records.mjs +54 -0
- package/cache/report.d.mts +19 -0
- package/cache/report.mjs +32 -0
- package/cache/run-log.d.mts +17 -0
- package/cache/run-log.mjs +37 -0
- package/cache/run.d.mts +33 -0
- package/cache/run.mjs +65 -0
- package/cache/session.d.mts +29 -0
- package/cache/session.mjs +197 -0
- package/cache/side-key.d.mts +10 -0
- package/cache/side-key.mjs +47 -0
- package/cache/test-family.d.mts +130 -0
- package/cache/test-family.mjs +258 -0
- package/cache/trust.d.mts +53 -0
- package/cache/trust.mjs +157 -0
- package/cache/types-family.d.mts +74 -0
- package/cache/types-family.mjs +154 -0
- package/cache/types.d.mts +99 -0
- package/cache/types.mjs +8 -0
- package/cache/wiring-family.d.mts +14 -0
- package/cache/wiring-family.mjs +43 -0
- package/coverage/cli.d.mts +4 -2
- package/coverage/cli.mjs +44 -5
- package/db/migrate.d.mts +16 -0
- package/db/migrate.mjs +21 -4
- package/db/postgres.d.mts +23 -0
- package/db/postgres.mjs +32 -1
- package/lint/run.d.mts +47 -1
- package/lint/run.mjs +157 -5
- package/package.json +2 -1
- package/release/gate.d.mts +2 -0
- package/release/gate.mjs +4 -2
- package/release/records.d.mts +15 -0
- package/release/records.mjs +25 -0
- package/release/urgent.mjs +23 -4
- package/runtime/knowledge/grammars/core/playbook.yaml +12 -18
- package/runtime/knowledge/hfs/canon-pins.yaml +8 -8
- package/runtime/knowledge/hfs/ci-standard.yaml +29 -6
- package/runtime/knowledge/hfs/rules.yaml +97 -7
- package/runtime/knowledge/hfs/slots.yaml +19 -7
- package/runtime/modules/kernel/failure-codes.yaml +46 -4
- package/runtime/modules/schemas/application-stacks.schema.yaml +26 -0
- package/runtime/scripts/hfs/architecture/hfs.d.mts +6 -6
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/gate-threshold.mjs +2 -1
- package/runtime/scripts/hfs/rules/boot-proof.d.mts +5 -0
- package/runtime/scripts/hfs/rules/boot-proof.mjs +15 -1
- package/runtime/scripts/hfs/rules/ci-custody.mjs +3 -1
- package/runtime/scripts/hfs/rules/ci-standard.mjs +32 -3
- package/runtime/scripts/hfs/rules/database-migrations.d.mts +14 -0
- package/runtime/scripts/hfs/rules/database-migrations.mjs +55 -0
- package/runtime/scripts/hfs/rules/database.mjs +2 -1
- package/runtime/scripts/hfs/rules/deploy-workflow.mjs +53 -6
- package/runtime/scripts/hfs/rules/release-provenance.mjs +32 -4
- package/runtime/scripts/hfs/rules/stacks.mjs +5 -1
- package/runtime/scripts/hfs/rules/supply-chain.mjs +4 -1
- package/runtime/scripts/hfs/rules/unused-symbol-translators.mjs +98 -7
- package/runtime/scripts/hfs/rules/unused-symbol.d.mts +9 -2
- package/runtime/scripts/hfs/rules/unused-symbol.mjs +15 -2
- package/runtime/scripts/hfs/runtime-rules/git-triggers.mjs +21 -5
- package/runtime/scripts/hfs/stack-deploy-config.d.mts +6 -0
- package/runtime/scripts/hfs/stack-deploy-config.mjs +32 -1
- package/runtime/scripts/hfs/stacks-gate.mjs +2 -0
- package/runtime/scripts/hfs/tree-findings.mjs +3 -1
- package/scaffold/add-inbox-lite.mjs +2 -4
- package/scaffold/add-queue-lite.mjs +2 -3
- package/scaffold/add-table.mjs +2 -4
- package/scaffold/migration-stamp.d.mts +11 -0
- package/scaffold/migration-stamp.mjs +32 -0
- package/src/helpers/hfs-arch-fixture.d.mts +1 -1
- package/src/main.d.mts +4 -1
- package/src/main.mjs +189 -74
- package/src/stack-records-double.d.mts +31 -0
- package/stack/cli.d.mts +3 -1
- package/stack/cli.mjs +17 -11
- package/stack/config.d.mts +6 -0
- package/stack/converge.d.mts +2 -0
- package/stack/converge.mjs +1 -1
- package/stack/db-phases.d.mts +185 -0
- package/stack/db-phases.mjs +346 -0
- package/stack/deploy.d.mts +18 -4
- package/stack/deploy.mjs +235 -70
- package/stack/journal.d.mts +127 -0
- package/stack/journal.mjs +182 -0
- package/stack/manager-backup.d.mts +54 -0
- package/stack/manager-backup.mjs +136 -0
- package/stack/release-store.d.mts +44 -0
- package/stack/release-store.mjs +89 -6
- package/stack/remote.d.mts +2 -0
- package/stack/remote.mjs +31 -11
- package/stack/server-key.d.mts +2 -0
- package/stack/server-key.mjs +1 -1
- package/stack/status.d.mts +49 -0
- package/stack/status.mjs +114 -0
- package/stack/urgent-floor.d.mts +10 -61
- package/stack/urgent-floor.mjs +28 -123
- package/stack/verify.mjs +26 -0
- package/sync/ci-commands.mjs +10 -5
- package/sync/format-worker.d.mts +1 -0
- package/sync/format-worker.mjs +17 -0
- package/sync/format.d.mts +21 -3
- package/sync/format.mjs +99 -7
- package/sync/index.d.mts +3 -6
- package/sync/index.mjs +12 -12
- package/sync/stack-images.d.mts +25 -10
- package/sync/stack-images.mjs +215 -54
- package/templates/app/ci-workflows/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/ci-workflows-lite/github/workflows/ci.yml +114 -7
- package/templates/app/ci-workflows-lite/github/workflows/images.yml +21 -65
- package/templates/app/ci-workflows-lite/github/workflows/stack-deploy.yml +130 -37
- package/templates/app/gitignore +1 -0
- package/templates/app/gitignore-lite +1 -0
- package/templates/app/package-scripts/db.json +1 -1
- package/templates/app/skeleton/.gitattributes +2 -0
- package/templates/app/skeleton-lite/.gitattributes +2 -0
- package/templates/be/skeleton/src/modules/platform/config/env-source.config.ts +2 -1
- package/templates/app/ci-workflows-lite/github/workflows/db-deploy.yml +0 -63
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// format-family.mts - `app check --format`: Prettier (HFS_FORMAT) over every tracked file, reused per file. A path whose content hash is
|
|
2
|
+
// unchanged keeps its recorded verdict; changed and added paths are formatted again, in batches (sync/format.mts). The records are
|
|
3
|
+
// `check/format/`, sparse: a clean file is a record with its hash and no finding.
|
|
4
|
+
import { formatFindings, formattable, FORMAT } from '../sync/format.mjs';
|
|
5
|
+
import { reconcile } from './jsonl.mjs';
|
|
6
|
+
import { makeReport } from './report.mjs';
|
|
7
|
+
export const FORMAT_FOLDER = 'check/format';
|
|
8
|
+
const STEP = 'prettier';
|
|
9
|
+
/** Area files of the format records: app-relative paths, so `fe/apps/web` is the third folder. */
|
|
10
|
+
const FORMAT_DEPTH = 3;
|
|
11
|
+
const asFinding = (found) => ({ path: found.path, line: 0, column: 0, rule: found.code, message: found.message });
|
|
12
|
+
const fromFinding = (found) => ({ code: found.rule, level: 'error', path: found.path, message: found.message });
|
|
13
|
+
/** Runs the format family against `session`: decides, formats what must be formatted, replaces the records, reports. */
|
|
14
|
+
export async function runFormatFamily(session, { files, prettier }) {
|
|
15
|
+
const decision = session.decide('format');
|
|
16
|
+
const candidates = formattable(files).filter((file) => session.index.hashOf(file) !== undefined);
|
|
17
|
+
const previous = session.records(FORMAT_FOLDER);
|
|
18
|
+
const reusable = decision.mode === 'diff'
|
|
19
|
+
? new Map(reconcile(previous.filter((record) => record.step === STEP), (file) => session.index.hashOf(file)).map((record) => [record.path, record]))
|
|
20
|
+
: new Map();
|
|
21
|
+
const todo = candidates.filter((file) => !reusable.has(file));
|
|
22
|
+
const step = session.progress.step('format', STEP, candidates.length);
|
|
23
|
+
step.tick(candidates.length - todo.length, candidates.length - todo.length, 0);
|
|
24
|
+
const judged = await formatFindings({
|
|
25
|
+
repoRoot: session.root, files: todo, prettier,
|
|
26
|
+
onProgress: (done) => step.tick(candidates.length - todo.length + done, candidates.length - todo.length, done),
|
|
27
|
+
});
|
|
28
|
+
step.end();
|
|
29
|
+
const byPath = new Map();
|
|
30
|
+
for (const found of judged)
|
|
31
|
+
byPath.set(found.path, [...(byPath.get(found.path) ?? []), found]);
|
|
32
|
+
const records = [];
|
|
33
|
+
for (const file of candidates) {
|
|
34
|
+
const kept = reusable.get(file);
|
|
35
|
+
if (kept !== undefined)
|
|
36
|
+
records.push(kept);
|
|
37
|
+
else
|
|
38
|
+
records.push({ path: file, step: STEP, hash: session.index.hashOf(file) ?? '', findings: (byPath.get(file) ?? []).map(asFinding) });
|
|
39
|
+
}
|
|
40
|
+
const findings = records.flatMap((record) => record.findings.map(fromFinding));
|
|
41
|
+
session.replace(FORMAT_FOLDER, records, { depth: FORMAT_DEPTH });
|
|
42
|
+
const report = makeReport({
|
|
43
|
+
family: 'format', mode: decision.mode, reason: decision.reason, checked: todo.length, reused: candidates.length - todo.length,
|
|
44
|
+
previous: previous.flatMap((record) => record.findings), current: records.flatMap((record) => record.findings),
|
|
45
|
+
});
|
|
46
|
+
session.report(report);
|
|
47
|
+
return { findings, report };
|
|
48
|
+
}
|
|
49
|
+
export { FORMAT };
|
package/cache/git.d.mts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface GitAnswer {
|
|
2
|
+
readonly ok: boolean;
|
|
3
|
+
readonly stdout: string;
|
|
4
|
+
}
|
|
5
|
+
/** `git args` in `root`: ok only on a clean exit; stdout is text. */
|
|
6
|
+
export declare function git(root: string, args: readonly string[], input?: string): GitAnswer;
|
|
7
|
+
/** `git args` in `root` as raw bytes. */
|
|
8
|
+
export declare function gitBytes(root: string, args: readonly string[], input?: string): {
|
|
9
|
+
ok: boolean;
|
|
10
|
+
stdout: Buffer;
|
|
11
|
+
};
|
package/cache/git.mjs
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// git.mts - the few git questions the cache asks, through the runtime's one git owner (runtime/scripts/api/git/lib.mts).
|
|
2
|
+
import { runGit } from '../runtime/scripts/api/git/lib.mjs';
|
|
3
|
+
/** `git args` in `root`: ok only on a clean exit; stdout is text. */
|
|
4
|
+
export function git(root, args, input) {
|
|
5
|
+
const run = runGit(args, { dir: root, maxBuffer: 256 * 1024 * 1024, ...(input === undefined ? {} : { input }) });
|
|
6
|
+
return { ok: !run.error && run.status === 0, stdout: run.stdout ?? '' };
|
|
7
|
+
}
|
|
8
|
+
/** `git args` in `root` as raw bytes. */
|
|
9
|
+
export function gitBytes(root, args, input) {
|
|
10
|
+
const run = runGit(args, { dir: root, maxBuffer: 1024 * 1024 * 1024, encoding: 'buffer', ...(input === undefined ? {} : { input }) });
|
|
11
|
+
const out = run.stdout;
|
|
12
|
+
return { ok: !run.error && run.status === 0, stdout: Buffer.isBuffer(out) ? out : Buffer.from(String(out ?? '')) };
|
|
13
|
+
}
|
package/cache/hash.d.mts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Whether `bytes` is text by git's test: no NUL in the first 8000 bytes. */
|
|
2
|
+
export declare function isText(bytes: Uint8Array): boolean;
|
|
3
|
+
/** `bytes` with every CRLF turned into LF; a lone CR is kept. */
|
|
4
|
+
export declare function normaliseNewlines(bytes: Uint8Array): Uint8Array;
|
|
5
|
+
/** The sha256 (hex) of `bytes`, newline-normalised when they are text. */
|
|
6
|
+
export declare function hashBytes(bytes: Uint8Array): string;
|
|
7
|
+
/** The sha256 (hex) of a text, newline-normalised. */
|
|
8
|
+
export declare function hashText(text: string): string;
|
|
9
|
+
/** The hash of the file at `absolute`, or undefined when it cannot be read (absent, a directory, a race). */
|
|
10
|
+
export declare function hashFile(absolute: string): string | undefined;
|
|
11
|
+
/**
|
|
12
|
+
* One digest over named parts, order-independent and unambiguous: the parts are sorted by name and each contributes
|
|
13
|
+
* `name NUL hash NUL`, so no two different part sets share a digest by concatenation.
|
|
14
|
+
*/
|
|
15
|
+
export declare function combineHashes(parts: Readonly<Record<string, string>>): string;
|
package/cache/hash.mjs
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// hash.mts - content hashes of the cache. A hash is the sha256 of the bytes with CRLF turned into LF for text files, so a
|
|
2
|
+
// Windows checkout and a Linux CI runner hash one commit alike. Text is git's own test: no NUL in the first 8000 bytes.
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
const BINARY_PROBE = 8000;
|
|
6
|
+
/** Whether `bytes` is text by git's test: no NUL in the first 8000 bytes. */
|
|
7
|
+
export function isText(bytes) {
|
|
8
|
+
const end = Math.min(bytes.length, BINARY_PROBE);
|
|
9
|
+
for (let i = 0; i < end; i += 1)
|
|
10
|
+
if (bytes[i] === 0)
|
|
11
|
+
return false;
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
/** `bytes` with every CRLF turned into LF; a lone CR is kept. */
|
|
15
|
+
export function normaliseNewlines(bytes) {
|
|
16
|
+
let crlf = 0;
|
|
17
|
+
for (let i = 0; i + 1 < bytes.length; i += 1)
|
|
18
|
+
if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a)
|
|
19
|
+
crlf += 1;
|
|
20
|
+
if (crlf === 0)
|
|
21
|
+
return bytes;
|
|
22
|
+
const out = new Uint8Array(bytes.length - crlf);
|
|
23
|
+
let at = 0;
|
|
24
|
+
for (let i = 0; i < bytes.length; i += 1) {
|
|
25
|
+
if (bytes[i] === 0x0d && bytes[i + 1] === 0x0a)
|
|
26
|
+
continue;
|
|
27
|
+
out[at] = bytes[i] ?? 0;
|
|
28
|
+
at += 1;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/** The sha256 (hex) of `bytes`, newline-normalised when they are text. */
|
|
33
|
+
export function hashBytes(bytes) {
|
|
34
|
+
return createHash('sha256').update(isText(bytes) ? normaliseNewlines(bytes) : bytes).digest('hex');
|
|
35
|
+
}
|
|
36
|
+
/** The sha256 (hex) of a text, newline-normalised. */
|
|
37
|
+
export function hashText(text) {
|
|
38
|
+
return hashBytes(Buffer.from(text, 'utf8'));
|
|
39
|
+
}
|
|
40
|
+
/** The hash of the file at `absolute`, or undefined when it cannot be read (absent, a directory, a race). */
|
|
41
|
+
export function hashFile(absolute) {
|
|
42
|
+
try {
|
|
43
|
+
return hashBytes(fs.readFileSync(absolute));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One digest over named parts, order-independent and unambiguous: the parts are sorted by name and each contributes
|
|
51
|
+
* `name NUL hash NUL`, so no two different part sets share a digest by concatenation.
|
|
52
|
+
*/
|
|
53
|
+
export function combineHashes(parts) {
|
|
54
|
+
const h = createHash('sha256');
|
|
55
|
+
for (const name of Object.keys(parts).sort())
|
|
56
|
+
h.update(`${name}\0${parts[name] ?? ''}\0`);
|
|
57
|
+
return h.digest('hex');
|
|
58
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { type MachineFn, type RepositoryResult } from '../runtime/scripts/hfs/check.mjs';
|
|
2
|
+
import type { Presets } from '../sync/index.mjs';
|
|
3
|
+
import type { CheckSession, FamilyReport } from './types.mjs';
|
|
4
|
+
export declare const HFS_FOLDER = "check/hfs";
|
|
5
|
+
export declare const SECRETS_FOLDER = "check/secrets";
|
|
6
|
+
/** The finding codes of the secrets family: plaintext secrets, sealed custody, untracked secret files. */
|
|
7
|
+
export declare const SECRET_CODES: ReadonlySet<string>;
|
|
8
|
+
/** What the structure pass answers. */
|
|
9
|
+
export interface PassResult {
|
|
10
|
+
/** The whole-app check result of the pass (the shape `printCheck` reads), or the slot result when only secrets were asked for. */
|
|
11
|
+
readonly result: RepositoryResult;
|
|
12
|
+
readonly contracts: {
|
|
13
|
+
status: string;
|
|
14
|
+
reason?: string;
|
|
15
|
+
apps?: unknown[];
|
|
16
|
+
} | null;
|
|
17
|
+
readonly machineReused: Record<string, boolean>;
|
|
18
|
+
readonly report: FamilyReport | null;
|
|
19
|
+
readonly secretsReport: FamilyReport | null;
|
|
20
|
+
}
|
|
21
|
+
export interface PassInput {
|
|
22
|
+
readonly repoRoot: string;
|
|
23
|
+
readonly harness?: {
|
|
24
|
+
version?: string;
|
|
25
|
+
} | undefined;
|
|
26
|
+
readonly presets?: Presets | undefined;
|
|
27
|
+
readonly tracked?: readonly string[] | undefined;
|
|
28
|
+
/** Which families the run selected: the pass runs whole for hfs, light (slots and rules, no machine) for secrets alone. */
|
|
29
|
+
readonly hfs: boolean;
|
|
30
|
+
readonly secrets: boolean;
|
|
31
|
+
/** A seam for specs. */
|
|
32
|
+
readonly machine?: MachineFn | undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The structure pass of `starci app check`, with the architecture machine and the contract emit reused by side key. */
|
|
35
|
+
export declare function runStructurePass(session: CheckSession, input: PassInput): Promise<PassResult>;
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// hfs-family.mts - the families that read the structure of the app: `--hfs` (slots, tree, pins, managed files against their templates, the content
|
|
2
|
+
// rules of the app pass, the architecture machine's check surface, the contract emit) and `--secrets` (plaintext secrets, sealed custody, untracked
|
|
3
|
+
// secret files, which the same pass finds). The cheap rules always run: they read untracked entries and git state the index does not hold. The
|
|
4
|
+
// costly whole-program parts are scoped steps keyed on the side: the architecture machine per side, the contract emit on the back-end side.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { checkRepo, checkRepository, trackedFiles } from '../runtime/scripts/hfs/check.mjs';
|
|
7
|
+
import { checkArchitecture } from '../runtime/scripts/hfs/architecture/index.mjs';
|
|
8
|
+
import { withoutBootProof } from '../runtime/scripts/hfs/rules/boot-proof.mjs';
|
|
9
|
+
import { withUnusedSymbolMemo } from '../runtime/scripts/hfs/rules/unused-symbol.mjs';
|
|
10
|
+
import { contractEmitFindings } from '../runtime/scripts/hfs/rules/contract.mjs';
|
|
11
|
+
import { loadSlotManifest, readRepoDeclaration } from '../runtime/scripts/hfs/slots.mjs';
|
|
12
|
+
import { emitContracts } from '../emit/contracts.mjs';
|
|
13
|
+
import { summarize } from '../runtime/scripts/hfs/check-results.mjs';
|
|
14
|
+
import { managedFindings } from '../sync/managed.mjs';
|
|
15
|
+
import { RecordSet, scopedStep } from './records.mjs';
|
|
16
|
+
import { makeReport } from './report.mjs';
|
|
17
|
+
import { sideKey } from './side-key.mjs';
|
|
18
|
+
export const HFS_FOLDER = 'check/hfs';
|
|
19
|
+
export const SECRETS_FOLDER = 'check/secrets';
|
|
20
|
+
/** The finding codes of the secrets family: plaintext secrets, sealed custody, untracked secret files. */
|
|
21
|
+
export const SECRET_CODES = new Set([
|
|
22
|
+
'HFS_PLAINTEXT_SECRET', 'HFS_SECRET_PLAINTEXT_FILE', 'HFS_SECRET_OUTSIDE_ENC', 'HFS_SCRIPT_PLAINTEXT_LOAD', 'HFS_LITE_SECRET_CUSTODY', 'HFS_IDENTITY_CUSTODY',
|
|
23
|
+
'HFS_AGE_KEY_CUSTODY', 'RT_SECRET_TRACKED', 'STACKS_SECRET_ENV_LITERAL', 'STACKS_SECRET_NOT_EXTERNAL', 'STACKS_SECRET_UNVERSIONED', 'SEALED_CUSTODY_LOCATION',
|
|
24
|
+
'APP_SECRET_IN_CI', 'CI_SECRET_SEALED', 'BE_LOG_SECRET', 'BE_SECRET_DEFAULT', 'HFS_DOCKER_SECRETS',
|
|
25
|
+
]);
|
|
26
|
+
const compact = (finding) => ({
|
|
27
|
+
path: finding.path ?? '.', line: finding.line ?? 0, column: finding.column ?? 0, rule: finding.code, message: String(finding.message ?? ''),
|
|
28
|
+
});
|
|
29
|
+
/** The findings of a pass as records grouped by path, one per path, sparse. */
|
|
30
|
+
function findingRecords(findings, hashOf, step) {
|
|
31
|
+
const byPath = new Map();
|
|
32
|
+
for (const finding of findings)
|
|
33
|
+
byPath.set(finding.path ?? '.', [...(byPath.get(finding.path ?? '.') ?? []), compact(finding)]);
|
|
34
|
+
return [...byPath].map(([file, list]) => ({ path: file, step, hash: hashOf(file) ?? 'absent', findings: list }));
|
|
35
|
+
}
|
|
36
|
+
const beDeclaration = (repoRoot) => {
|
|
37
|
+
try {
|
|
38
|
+
return readRepoDeclaration(loadSlotManifest(), repoRoot).sides?.be ?? null;
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
/** The structure pass of `starci app check`, with the architecture machine and the contract emit reused by side key. */
|
|
45
|
+
export async function runStructurePass(session, input) {
|
|
46
|
+
const { repoRoot, harness, presets } = input;
|
|
47
|
+
const tracked = [...(input.tracked ?? trackedFiles(repoRoot))];
|
|
48
|
+
const decision = session.decide('hfs');
|
|
49
|
+
const records = new RecordSet(session.records(HFS_FOLDER));
|
|
50
|
+
const previous = records.toArray();
|
|
51
|
+
const reuse = decision.mode === 'diff';
|
|
52
|
+
const sideOf = (side) => sideKey(repoRoot, session.index, side);
|
|
53
|
+
const keys = new Map(['be', 'fe'].map((side) => [side, sideOf(side)]));
|
|
54
|
+
const extra = [...await managedFindings({ repoRoot, tracked, presets })];
|
|
55
|
+
let checked = 0;
|
|
56
|
+
let reused = 0;
|
|
57
|
+
let contracts = null;
|
|
58
|
+
const be = beDeclaration(repoRoot);
|
|
59
|
+
if (input.hfs && be !== null) {
|
|
60
|
+
const bePrefix = 'be/';
|
|
61
|
+
const key = keys.get('be');
|
|
62
|
+
const emit = await scopedStep(session, records, {
|
|
63
|
+
path: 'be', step: 'contract-emit', key: key?.key ?? 'none', reuse, inputs: key?.inputs ?? [],
|
|
64
|
+
compute: () => {
|
|
65
|
+
const files = tracked.filter((file) => file.startsWith(bePrefix)).map((file) => file.slice(bePrefix.length));
|
|
66
|
+
const emitted = contractEmitFindings({ repoRoot: path.join(repoRoot, 'be'), files, repo: be, emit: emitContracts });
|
|
67
|
+
return JSON.parse(JSON.stringify({ findings: emitted.findings, apps: emitted.apps }));
|
|
68
|
+
},
|
|
69
|
+
compact: (value) => (value.findings).map(compact),
|
|
70
|
+
});
|
|
71
|
+
if (emit.reused)
|
|
72
|
+
reused += 1;
|
|
73
|
+
else
|
|
74
|
+
checked += 1;
|
|
75
|
+
const value = emit.value;
|
|
76
|
+
extra.push(...value.findings.map((finding) => ({ ...finding, side: 'be', path: `be/${finding.path}` })));
|
|
77
|
+
contracts = { status: 'checked', apps: value.apps };
|
|
78
|
+
}
|
|
79
|
+
const reasons = {};
|
|
80
|
+
const baseMachine = input.machine ?? checkArchitecture;
|
|
81
|
+
const cachedMachine = (call) => {
|
|
82
|
+
const side = path.basename(call.repositoryRoot);
|
|
83
|
+
const key = keys.get(side);
|
|
84
|
+
if (key === undefined || call.fast === true || call.paths !== undefined)
|
|
85
|
+
return baseMachine(call);
|
|
86
|
+
const held = reuse ? records.matching(side, 'machine', key.key) : undefined;
|
|
87
|
+
if (held?.data !== undefined) {
|
|
88
|
+
reasons[side] = true;
|
|
89
|
+
reused += 1;
|
|
90
|
+
return held.data;
|
|
91
|
+
}
|
|
92
|
+
const report = baseMachine(call);
|
|
93
|
+
checked += 1;
|
|
94
|
+
if (session.unchanged(key.inputs)) {
|
|
95
|
+
records.put({ path: side, step: 'machine', hash: key.key, findings: [...report.violations, ...report.errors].map((item) => ({ path: item.path ?? side, line: item.line ?? 0, column: item.column ?? 0, rule: item.ruleId, message: item.message })), data: JSON.parse(JSON.stringify(report)) });
|
|
96
|
+
}
|
|
97
|
+
return report;
|
|
98
|
+
};
|
|
99
|
+
// The whole-program unused-symbol analysis is the costliest rule of the pass (92 of 141 seconds on a 13.7k-file app): a scoped step keyed on the side.
|
|
100
|
+
const unusedMemo = {
|
|
101
|
+
run: (root, compute) => {
|
|
102
|
+
const side = path.basename(root);
|
|
103
|
+
const key = keys.get(side);
|
|
104
|
+
if (key === undefined)
|
|
105
|
+
return compute();
|
|
106
|
+
const held = reuse ? records.matching(side, 'unused-symbol', key.key) : undefined;
|
|
107
|
+
if (held?.data !== undefined) {
|
|
108
|
+
reused += 1;
|
|
109
|
+
return held.data;
|
|
110
|
+
}
|
|
111
|
+
const found = compute();
|
|
112
|
+
checked += 1;
|
|
113
|
+
if (session.unchanged(key.inputs)) {
|
|
114
|
+
const list = found;
|
|
115
|
+
records.put({ path: side, step: 'unused-symbol', hash: key.key, findings: list.map(compact), data: JSON.parse(JSON.stringify(found)) });
|
|
116
|
+
}
|
|
117
|
+
return found;
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
const step = session.progress.step('hfs', 'structure', tracked.length);
|
|
121
|
+
const result = input.hfs
|
|
122
|
+
? withUnusedSymbolMemo(unusedMemo, () => withoutBootProof(() => checkRepository({ repoRoot, harness, extraFindings: extra, machine: cachedMachine })))
|
|
123
|
+
: (() => {
|
|
124
|
+
const slot = withUnusedSymbolMemo(unusedMemo, () => withoutBootProof(() => checkRepo({ repoRoot, harness, extraFindings: [], surface: 'check' })));
|
|
125
|
+
return { ...slot, machine: { status: 'skipped', reason: '--secrets alone runs the rules, not the machine' } };
|
|
126
|
+
})();
|
|
127
|
+
step.tick(tracked.length, reused, checked);
|
|
128
|
+
step.end();
|
|
129
|
+
if (contracts)
|
|
130
|
+
result.contracts = contracts;
|
|
131
|
+
if (!input.hfs) {
|
|
132
|
+
result.findings = result.findings.filter((finding) => SECRET_CODES.has(finding.code));
|
|
133
|
+
result.counts = summarize(result.findings);
|
|
134
|
+
result.ok = result.counts.error === 0;
|
|
135
|
+
}
|
|
136
|
+
if (input.hfs) {
|
|
137
|
+
const live = new Set(['be', 'fe']);
|
|
138
|
+
records.retain('machine', live);
|
|
139
|
+
records.retain('unused-symbol', live);
|
|
140
|
+
records.retain('contract-emit', new Set(['be']));
|
|
141
|
+
// The always-run rules still record their findings, so the next run can say what is new and what is known.
|
|
142
|
+
for (const record of findingRecords(result.findings.filter((finding) => !SECRET_CODES.has(finding.code)), session.index.hashOf.bind(session.index), 'hfs'))
|
|
143
|
+
records.put(record);
|
|
144
|
+
const seen = new Set(result.findings.map((finding) => finding.path ?? '.'));
|
|
145
|
+
records.retain('hfs', seen);
|
|
146
|
+
session.replace(HFS_FOLDER, records.toArray());
|
|
147
|
+
}
|
|
148
|
+
const hfsFindings = result.findings.filter((finding) => !SECRET_CODES.has(finding.code));
|
|
149
|
+
const report = input.hfs
|
|
150
|
+
? makeReport({ family: 'hfs', mode: decision.mode, reason: decision.reason, checked, reused, previous: previous.flatMap((record) => record.step === 'hfs' ? record.findings : []), current: hfsFindings.map(compact) })
|
|
151
|
+
: null;
|
|
152
|
+
if (report)
|
|
153
|
+
session.report(report);
|
|
154
|
+
let secretsReport = null;
|
|
155
|
+
if (input.secrets) {
|
|
156
|
+
const secretDecision = session.decide('secrets');
|
|
157
|
+
const prior = session.records(SECRETS_FOLDER);
|
|
158
|
+
const secretFindings = result.findings.filter((finding) => SECRET_CODES.has(finding.code));
|
|
159
|
+
session.replace(SECRETS_FOLDER, findingRecords(secretFindings, session.index.hashOf.bind(session.index), 'secrets'));
|
|
160
|
+
secretsReport = makeReport({ family: 'secrets', mode: secretDecision.mode, reason: secretDecision.reason, checked: tracked.length, reused: 0, previous: prior.flatMap((record) => record.findings), current: secretFindings.map(compact) });
|
|
161
|
+
session.report(secretsReport);
|
|
162
|
+
}
|
|
163
|
+
return { result, contracts, machineReused: reasons, report, secretsReport };
|
|
164
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type TypeScript from 'typescript';
|
|
2
|
+
type Ts = typeof TypeScript;
|
|
3
|
+
/** One file of the graph: its content hash, the side-relative posix paths of the local files it imports, and whether it declares globals. */
|
|
4
|
+
export interface ImportNode {
|
|
5
|
+
hash: string;
|
|
6
|
+
imports: string[];
|
|
7
|
+
declaresGlobals: boolean;
|
|
8
|
+
}
|
|
9
|
+
/** The graph of a side: side-relative posix path to node. Leaves (a JSON file, a declaration file out of the lint scope) are nodes with no imports. */
|
|
10
|
+
export type ImportGraph = ReadonlyMap<string, ImportNode>;
|
|
11
|
+
/** What the graph builder reads of the world: the compiler of the app, file text and existence, and the content hash of a path. */
|
|
12
|
+
export interface GraphHost {
|
|
13
|
+
ts: Ts;
|
|
14
|
+
/** The absolute folder of the side. */
|
|
15
|
+
sideRoot: string;
|
|
16
|
+
/** The text of an absolute file, or null when it cannot be read. */
|
|
17
|
+
readText: (file: string) => string | null;
|
|
18
|
+
fileExists: (file: string) => boolean;
|
|
19
|
+
directoryExists?: (directory: string) => boolean;
|
|
20
|
+
/** The content hash of a side-relative path the graph reaches (the scope's files and any resolved leaf), or null when it is not a file. */
|
|
21
|
+
hashOf: (relative: string) => string | null;
|
|
22
|
+
}
|
|
23
|
+
export declare const posix: (file: string) => string;
|
|
24
|
+
/** The compiler options of the side's own tsconfig (the file the typed ESLint run reads), or the permissive defaults when the side holds none. */
|
|
25
|
+
export declare function compilerOptionsOf(ts: Ts, sideRoot: string, readText: (file: string) => string | null, fileExists: (file: string) => boolean): TypeScript.CompilerOptions;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a source file declares globals: a script (a file with no import or export that has statements), a `declare global` block, or a module
|
|
28
|
+
* augmentation (`declare module 'x'` inside a module). A change to such a file can alter the types of any file of the side.
|
|
29
|
+
*/
|
|
30
|
+
export declare function declaresGlobals(ts: Ts, fileName: string, text: string): boolean;
|
|
31
|
+
/**
|
|
32
|
+
* The module specifiers and path references a file's text names: static, type-only and dynamic imports, re-exports, `require`, import types and
|
|
33
|
+
* triple-slash paths. TypeScript's preprocessor finds most of them without a full parse; the syntax tree adds the forms it misses (a dynamic
|
|
34
|
+
* import of a plain template literal, an `import('x').T` type), so the graph holds every edge the compiler's program would.
|
|
35
|
+
*/
|
|
36
|
+
export declare function referencesOf(ts: Ts, text: string): {
|
|
37
|
+
specifiers: string[];
|
|
38
|
+
paths: string[];
|
|
39
|
+
};
|
|
40
|
+
/** The local files `file` (side-relative) imports, resolved with the side's compiler options. Unresolved and installed-package imports are not edges. */
|
|
41
|
+
export declare function resolveImports(host: GraphHost, options: TypeScript.CompilerOptions, file: string, text: string): string[];
|
|
42
|
+
/** What a build reports of its work. */
|
|
43
|
+
export interface GraphBuild {
|
|
44
|
+
graph: Map<string, ImportNode>;
|
|
45
|
+
reused: number;
|
|
46
|
+
scanned: number;
|
|
47
|
+
resolvedAgain: boolean;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* The import graph of `files` (the side's lint scope, side-relative posix) and the leaves they reach.
|
|
51
|
+
*
|
|
52
|
+
* A file whose recorded hash equals its hash now reuses its recorded imports, unless a path of the side was added or removed since the record:
|
|
53
|
+
* a new file can change where an unchanged import resolves, so every file is then resolved again. `pathsChanged` lets the caller report a change in
|
|
54
|
+
* the side's paths the scope does not list (a JSON file, a generated declaration).
|
|
55
|
+
*/
|
|
56
|
+
export declare function buildImportGraph(host: GraphHost, files: readonly string[], recorded: ImportGraph | null, options?: {
|
|
57
|
+
pathsChanged?: boolean;
|
|
58
|
+
}): GraphBuild;
|
|
59
|
+
/** Every path whose node differs between two graphs: added, removed, or with another hash. */
|
|
60
|
+
export declare function changedPaths(previous: ImportGraph | null, next: ImportGraph): Set<string>;
|
|
61
|
+
/** The files that reach any of `seeds` through imports in `graph` (the seeds themselves are not added unless a cycle reaches them). */
|
|
62
|
+
export declare function dependentsOf(graph: ImportGraph, seeds: Iterable<string>): Set<string>;
|
|
63
|
+
/** The dirty set of a side: what must be linted again, or the whole side with the reason. */
|
|
64
|
+
export interface DirtySet {
|
|
65
|
+
dirty: Set<string>;
|
|
66
|
+
wholeSide: string | null;
|
|
67
|
+
changed: Set<string>;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* The files to lint again after a change from `previous` (the recorded graph, null when none) to `next`: every changed path, and every file that
|
|
71
|
+
* reaches one through imports in either graph (a file that stopped importing a changed file still holds a result from the old types), and every
|
|
72
|
+
* file whose imports resolve to other files than recorded. A changed file that declares globals, now or before, dirties the whole side.
|
|
73
|
+
*/
|
|
74
|
+
export declare function dirtySetOf(previous: ImportGraph | null, next: ImportGraph): DirtySet;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// import-graph.mts - the resolved local import graph of one lint side, and the dirty set a change in it implies (check-cache.md, ESLint, kind 1).
|
|
2
|
+
//
|
|
3
|
+
// A type-aware rule's result on a file depends on the files whose types it reads, so ESLint's own per-file cache cannot be trusted. The cache records
|
|
4
|
+
// for every file of a side its content hash and the local files it imports (TypeScript `preProcessFile` and `resolveModuleName` under the side's
|
|
5
|
+
// tsconfig: static, type-only and dynamic imports, re-exports, `require`, triple-slash references, JSON). The dirty set of a change is every changed,
|
|
6
|
+
// added or removed file and every file that reaches one through imports, in the new graph and in the recorded one. Everything here is pure over
|
|
7
|
+
// its inputs (the compiler, a file reader, the hashes), so a spec drives it with fixtures and the cache itself stays in lint-family.mts.
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
export const posix = (file) => file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
10
|
+
/** The compiler options of the side's own tsconfig (the file the typed ESLint run reads), or the permissive defaults when the side holds none. */
|
|
11
|
+
export function compilerOptionsOf(ts, sideRoot, readText, fileExists) {
|
|
12
|
+
const configPath = path.join(sideRoot, 'tsconfig.json');
|
|
13
|
+
if (!fileExists(configPath))
|
|
14
|
+
return { allowJs: true, resolveJsonModule: true };
|
|
15
|
+
const read = ts.readConfigFile(configPath, (file) => readText(file) ?? undefined);
|
|
16
|
+
if (read.error || read.config === undefined)
|
|
17
|
+
return { allowJs: true, resolveJsonModule: true };
|
|
18
|
+
const host = {
|
|
19
|
+
useCaseSensitiveFileNames: true,
|
|
20
|
+
readDirectory: () => [],
|
|
21
|
+
fileExists,
|
|
22
|
+
readFile: (file) => readText(file) ?? undefined,
|
|
23
|
+
};
|
|
24
|
+
return { ...ts.parseJsonConfigFileContent(read.config, host, sideRoot).options, resolveJsonModule: true };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Whether a source file declares globals: a script (a file with no import or export that has statements), a `declare global` block, or a module
|
|
28
|
+
* augmentation (`declare module 'x'` inside a module). A change to such a file can alter the types of any file of the side.
|
|
29
|
+
*/
|
|
30
|
+
export function declaresGlobals(ts, fileName, text) {
|
|
31
|
+
const source = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, false);
|
|
32
|
+
let isModule = false;
|
|
33
|
+
let declaring = false;
|
|
34
|
+
for (const statement of source.statements) {
|
|
35
|
+
if (ts.isImportDeclaration(statement) || ts.isExportDeclaration(statement) || ts.isExportAssignment(statement))
|
|
36
|
+
isModule = true;
|
|
37
|
+
else if (ts.isImportEqualsDeclaration(statement) && ts.isExternalModuleReference(statement.moduleReference))
|
|
38
|
+
isModule = true;
|
|
39
|
+
else if (ts.canHaveModifiers(statement) && ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword))
|
|
40
|
+
isModule = true;
|
|
41
|
+
else if (ts.isModuleDeclaration(statement) && (statement.flags & ts.NodeFlags.GlobalAugmentation) !== 0)
|
|
42
|
+
declaring = true;
|
|
43
|
+
else if (ts.isModuleDeclaration(statement) && ts.isStringLiteral(statement.name))
|
|
44
|
+
declaring = true;
|
|
45
|
+
}
|
|
46
|
+
if (isModule) {
|
|
47
|
+
// inside a module a `declare module 'x'` or `declare global` augments; a plain exported declaration does not
|
|
48
|
+
return source.statements.some((statement) => ts.isModuleDeclaration(statement) && ((statement.flags & ts.NodeFlags.GlobalAugmentation) !== 0 || ts.isStringLiteral(statement.name)));
|
|
49
|
+
}
|
|
50
|
+
return declaring || source.statements.length > 0;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The module specifiers and path references a file's text names: static, type-only and dynamic imports, re-exports, `require`, import types and
|
|
54
|
+
* triple-slash paths. TypeScript's preprocessor finds most of them without a full parse; the syntax tree adds the forms it misses (a dynamic
|
|
55
|
+
* import of a plain template literal, an `import('x').T` type), so the graph holds every edge the compiler's program would.
|
|
56
|
+
*/
|
|
57
|
+
export function referencesOf(ts, text) {
|
|
58
|
+
const info = ts.preProcessFile(text, true, true);
|
|
59
|
+
const found = new Set([...info.importedFiles.map((imported) => imported.fileName), ...info.typeReferenceDirectives.map((directive) => directive.fileName)]);
|
|
60
|
+
const source = ts.createSourceFile('references.ts', text, ts.ScriptTarget.Latest, false);
|
|
61
|
+
const literal = (node) => (node !== undefined && ts.isStringLiteralLike(node) ? node.text : null);
|
|
62
|
+
const visit = (node) => {
|
|
63
|
+
if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === 'require'))) {
|
|
64
|
+
const name = literal(node.arguments[0]);
|
|
65
|
+
if (name !== null)
|
|
66
|
+
found.add(name);
|
|
67
|
+
}
|
|
68
|
+
else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
69
|
+
const name = literal(node.argument.literal);
|
|
70
|
+
if (name !== null)
|
|
71
|
+
found.add(name);
|
|
72
|
+
}
|
|
73
|
+
ts.forEachChild(node, visit);
|
|
74
|
+
};
|
|
75
|
+
visit(source);
|
|
76
|
+
return { specifiers: [...found], paths: info.referencedFiles.map((reference) => reference.fileName) };
|
|
77
|
+
}
|
|
78
|
+
/** The side-relative posix path of an absolute file below the side, or null when it lies outside it or in an installed package. */
|
|
79
|
+
function localPath(sideRoot, absolute) {
|
|
80
|
+
const relative = posix(path.relative(sideRoot, absolute));
|
|
81
|
+
if (relative === '' || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative))
|
|
82
|
+
return null;
|
|
83
|
+
return relative.split('/').includes('node_modules') ? null : relative;
|
|
84
|
+
}
|
|
85
|
+
/** The local files `file` (side-relative) imports, resolved with the side's compiler options. Unresolved and installed-package imports are not edges. */
|
|
86
|
+
export function resolveImports(host, options, file, text) {
|
|
87
|
+
const { ts, sideRoot } = host;
|
|
88
|
+
const absolute = path.join(sideRoot, file);
|
|
89
|
+
const { specifiers, paths } = referencesOf(ts, text);
|
|
90
|
+
const moduleHost = {
|
|
91
|
+
fileExists: host.fileExists,
|
|
92
|
+
readFile: (name) => host.readText(name) ?? undefined,
|
|
93
|
+
directoryExists: host.directoryExists ?? (() => true),
|
|
94
|
+
getCurrentDirectory: () => sideRoot,
|
|
95
|
+
};
|
|
96
|
+
const found = new Set();
|
|
97
|
+
for (const specifier of specifiers) {
|
|
98
|
+
const resolved = ts.resolveModuleName(specifier, absolute, options, moduleHost).resolvedModule;
|
|
99
|
+
if (!resolved || resolved.isExternalLibraryImport)
|
|
100
|
+
continue;
|
|
101
|
+
const local = localPath(sideRoot, resolved.resolvedFileName);
|
|
102
|
+
if (local !== null && local !== file)
|
|
103
|
+
found.add(local);
|
|
104
|
+
}
|
|
105
|
+
for (const reference of paths) {
|
|
106
|
+
const local = localPath(sideRoot, path.resolve(path.dirname(absolute), reference));
|
|
107
|
+
if (local !== null && host.fileExists(path.join(sideRoot, local)))
|
|
108
|
+
found.add(local);
|
|
109
|
+
}
|
|
110
|
+
return [...found].sort();
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* The import graph of `files` (the side's lint scope, side-relative posix) and the leaves they reach.
|
|
114
|
+
*
|
|
115
|
+
* A file whose recorded hash equals its hash now reuses its recorded imports, unless a path of the side was added or removed since the record:
|
|
116
|
+
* a new file can change where an unchanged import resolves, so every file is then resolved again. `pathsChanged` lets the caller report a change in
|
|
117
|
+
* the side's paths the scope does not list (a JSON file, a generated declaration).
|
|
118
|
+
*/
|
|
119
|
+
export function buildImportGraph(host, files, recorded, options = {}) {
|
|
120
|
+
const compiler = compilerOptionsOf(host.ts, host.sideRoot, host.readText, host.fileExists);
|
|
121
|
+
const known = new Set(files);
|
|
122
|
+
const pathsChanged = options.pathsChanged === true || recorded === null || recorded.size === 0
|
|
123
|
+
|| files.some((file) => !recorded.has(file))
|
|
124
|
+
|| [...recorded.keys()].some((file) => !known.has(file) && host.hashOf(file) === null);
|
|
125
|
+
const graph = new Map();
|
|
126
|
+
let reused = 0, scanned = 0;
|
|
127
|
+
const visit = (file) => {
|
|
128
|
+
if (graph.has(file))
|
|
129
|
+
return;
|
|
130
|
+
const hash = host.hashOf(file);
|
|
131
|
+
if (hash === null)
|
|
132
|
+
return;
|
|
133
|
+
const before = recorded?.get(file);
|
|
134
|
+
if (!pathsChanged && before !== undefined && before.hash === hash) {
|
|
135
|
+
graph.set(file, before);
|
|
136
|
+
reused += 1;
|
|
137
|
+
}
|
|
138
|
+
else if (/\.(?:[cm]?[jt]sx?)$/.test(file)) {
|
|
139
|
+
const text = host.readText(path.join(host.sideRoot, file));
|
|
140
|
+
scanned += 1;
|
|
141
|
+
graph.set(file, text === null
|
|
142
|
+
? { hash, imports: [], declaresGlobals: false }
|
|
143
|
+
: { hash, imports: resolveImports(host, compiler, file, text), declaresGlobals: declaresGlobals(host.ts, file, text) });
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
graph.set(file, { hash, imports: [], declaresGlobals: false });
|
|
147
|
+
for (const next of graph.get(file)?.imports ?? [])
|
|
148
|
+
visit(next);
|
|
149
|
+
};
|
|
150
|
+
for (const file of files)
|
|
151
|
+
visit(file);
|
|
152
|
+
return { graph, reused, scanned, resolvedAgain: pathsChanged };
|
|
153
|
+
}
|
|
154
|
+
/** Every path whose node differs between two graphs: added, removed, or with another hash. */
|
|
155
|
+
export function changedPaths(previous, next) {
|
|
156
|
+
const changed = new Set();
|
|
157
|
+
for (const [file, node] of next)
|
|
158
|
+
if (previous?.get(file)?.hash !== node.hash)
|
|
159
|
+
changed.add(file);
|
|
160
|
+
for (const file of previous?.keys() ?? [])
|
|
161
|
+
if (!next.has(file))
|
|
162
|
+
changed.add(file);
|
|
163
|
+
return changed;
|
|
164
|
+
}
|
|
165
|
+
/** The files that reach any of `seeds` through imports in `graph` (the seeds themselves are not added unless a cycle reaches them). */
|
|
166
|
+
export function dependentsOf(graph, seeds) {
|
|
167
|
+
const importers = new Map();
|
|
168
|
+
for (const [file, node] of graph)
|
|
169
|
+
for (const target of node.imports)
|
|
170
|
+
importers.set(target, [...(importers.get(target) ?? []), file]);
|
|
171
|
+
const reached = new Set();
|
|
172
|
+
const queue = [...seeds];
|
|
173
|
+
while (queue.length) {
|
|
174
|
+
for (const importer of importers.get(queue.pop()) ?? []) {
|
|
175
|
+
if (reached.has(importer))
|
|
176
|
+
continue;
|
|
177
|
+
reached.add(importer);
|
|
178
|
+
queue.push(importer);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
return reached;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The files to lint again after a change from `previous` (the recorded graph, null when none) to `next`: every changed path, and every file that
|
|
185
|
+
* reaches one through imports in either graph (a file that stopped importing a changed file still holds a result from the old types), and every
|
|
186
|
+
* file whose imports resolve to other files than recorded. A changed file that declares globals, now or before, dirties the whole side.
|
|
187
|
+
*/
|
|
188
|
+
export function dirtySetOf(previous, next) {
|
|
189
|
+
const changed = changedPaths(previous, next);
|
|
190
|
+
if (previous === null)
|
|
191
|
+
return { dirty: new Set(next.keys()), wholeSide: 'no recorded import graph', changed };
|
|
192
|
+
for (const file of changed) {
|
|
193
|
+
if (next.get(file)?.declaresGlobals === true || previous.get(file)?.declaresGlobals === true) {
|
|
194
|
+
return { dirty: new Set(next.keys()), wholeSide: `${file} declares globals`, changed };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// a file whose imports resolve elsewhere now (a new file shadows a module, a missing one appeared) reads other types than before
|
|
198
|
+
const rewired = [...next].filter(([file, node]) => {
|
|
199
|
+
const before = previous.get(file);
|
|
200
|
+
return before !== undefined && before.hash === node.hash && JSON.stringify(before.imports) !== JSON.stringify(node.imports);
|
|
201
|
+
}).map(([file]) => file);
|
|
202
|
+
const dirty = new Set([...changed, ...rewired, ...dependentsOf(next, [...changed, ...rewired]), ...dependentsOf(previous, changed)]);
|
|
203
|
+
return { dirty, wholeSide: null, changed };
|
|
204
|
+
}
|