@facilis.dev/analyze 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +18 -2
- package/dist/analyze.d.ts +17 -0
- package/dist/analyze.js +99 -0
- package/dist/ast/node.d.ts +29 -0
- package/dist/ast/node.js +85 -0
- package/dist/ast/walk.d.ts +8 -0
- package/dist/ast/walk.js +40 -0
- package/dist/diagnostics.d.ts +5 -0
- package/dist/diagnostics.js +12 -0
- package/dist/fs.d.ts +13 -0
- package/dist/fs.js +51 -0
- package/dist/graph-edges.d.ts +3 -0
- package/dist/graph-edges.js +148 -0
- package/dist/graph-ids.d.ts +6 -0
- package/dist/graph-ids.js +22 -0
- package/dist/graph.d.ts +15 -0
- package/dist/graph.js +162 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.js +17 -0
- package/dist/json.d.ts +8 -0
- package/dist/json.js +31 -0
- package/dist/module-graph.d.ts +33 -0
- package/dist/module-graph.js +86 -0
- package/dist/module-info.d.ts +27 -0
- package/dist/module-info.js +143 -0
- package/dist/node.d.ts +16 -0
- package/dist/node.js +41 -0
- package/dist/parser.d.ts +16 -0
- package/dist/parser.js +1 -0
- package/dist/passes/clients.d.ts +22 -0
- package/dist/passes/clients.js +167 -0
- package/dist/passes/config.d.ts +16 -0
- package/dist/passes/config.js +229 -0
- package/dist/passes/file-facts.d.ts +13 -0
- package/dist/passes/file-facts.js +130 -0
- package/dist/passes/i18n.d.ts +14 -0
- package/dist/passes/i18n.js +55 -0
- package/dist/passes/job-relations-diagnostics.d.ts +9 -0
- package/dist/passes/job-relations-diagnostics.js +38 -0
- package/dist/passes/job-relations.d.ts +18 -0
- package/dist/passes/job-relations.js +52 -0
- package/dist/passes/jobs.d.ts +20 -0
- package/dist/passes/jobs.js +133 -0
- package/dist/passes/jsx-text.d.ts +28 -0
- package/dist/passes/jsx-text.js +121 -0
- package/dist/passes/jsx.d.ts +24 -0
- package/dist/passes/jsx.js +135 -0
- package/dist/passes/path-expression.d.ts +26 -0
- package/dist/passes/path-expression.js +265 -0
- package/dist/passes/route-config.d.ts +24 -0
- package/dist/passes/route-config.js +152 -0
- package/dist/passes/route-description.d.ts +13 -0
- package/dist/passes/route-description.js +23 -0
- package/dist/passes/route-elevation.d.ts +18 -0
- package/dist/passes/route-elevation.js +136 -0
- package/dist/passes/route-handler.d.ts +27 -0
- package/dist/passes/route-handler.js +87 -0
- package/dist/passes/route-location.d.ts +16 -0
- package/dist/passes/route-location.js +58 -0
- package/dist/passes/route-relation-resolve.d.ts +51 -0
- package/dist/passes/route-relation-resolve.js +172 -0
- package/dist/passes/route-relations-diagnostics.d.ts +15 -0
- package/dist/passes/route-relations-diagnostics.js +79 -0
- package/dist/passes/route-relations-follow.d.ts +16 -0
- package/dist/passes/route-relations-follow.js +116 -0
- package/dist/passes/route-relations-usage.d.ts +50 -0
- package/dist/passes/route-relations-usage.js +44 -0
- package/dist/passes/route-relations-walk.d.ts +25 -0
- package/dist/passes/route-relations-walk.js +199 -0
- package/dist/passes/route-relations.d.ts +17 -0
- package/dist/passes/route-relations.js +43 -0
- package/dist/passes/route-table.d.ts +21 -0
- package/dist/passes/route-table.js +104 -0
- package/dist/passes/route-tables.d.ts +10 -0
- package/dist/passes/route-tables.js +42 -0
- package/dist/passes/routes.d.ts +18 -0
- package/dist/passes/routes.js +152 -0
- package/dist/passes/schema-column-types.d.ts +9 -0
- package/dist/passes/schema-column-types.js +55 -0
- package/dist/passes/schema-columns.d.ts +35 -0
- package/dist/passes/schema-columns.js +197 -0
- package/dist/passes/schema-dsl.d.ts +24 -0
- package/dist/passes/schema-dsl.js +117 -0
- package/dist/passes/schema-file-links-ddl.d.ts +18 -0
- package/dist/passes/schema-file-links-ddl.js +106 -0
- package/dist/passes/schema-file-links.d.ts +19 -0
- package/dist/passes/schema-file-links.js +144 -0
- package/dist/passes/schema-foreign-keys.d.ts +21 -0
- package/dist/passes/schema-foreign-keys.js +80 -0
- package/dist/passes/schema-policies.d.ts +15 -0
- package/dist/passes/schema-policies.js +201 -0
- package/dist/passes/schema-re-exports.d.ts +5 -0
- package/dist/passes/schema-re-exports.js +77 -0
- package/dist/passes/schema-relations.d.ts +8 -0
- package/dist/passes/schema-relations.js +182 -0
- package/dist/passes/schema-scope.d.ts +7 -0
- package/dist/passes/schema-scope.js +20 -0
- package/dist/passes/schema-tables.d.ts +13 -0
- package/dist/passes/schema-tables.js +149 -0
- package/dist/passes/schema.d.ts +9 -0
- package/dist/passes/schema.js +55 -0
- package/dist/passes/screen-components.d.ts +22 -0
- package/dist/passes/screen-components.js +143 -0
- package/dist/passes/screen-intrinsics.d.ts +18 -0
- package/dist/passes/screen-intrinsics.js +21 -0
- package/dist/passes/screen-preview.d.ts +29 -0
- package/dist/passes/screen-preview.js +174 -0
- package/dist/passes/screens.d.ts +12 -0
- package/dist/passes/screens.js +278 -0
- package/dist/passes/static-read.d.ts +47 -0
- package/dist/passes/static-read.js +151 -0
- package/dist/passes/views.d.ts +20 -0
- package/dist/passes/views.js +101 -0
- package/dist/passes/workflow-tables.d.ts +18 -0
- package/dist/passes/workflow-tables.js +71 -0
- package/dist/passes/workflows.d.ts +8 -0
- package/dist/passes/workflows.js +160 -0
- package/dist/path-resolver.d.ts +20 -0
- package/dist/path-resolver.js +134 -0
- package/dist/revision.d.ts +2 -0
- package/dist/revision.js +23 -0
- package/dist/source.d.ts +4 -0
- package/dist/source.js +13 -0
- package/dist/static-eval.d.ts +36 -0
- package/dist/static-eval.js +215 -0
- package/dist/types-graph.d.ts +144 -0
- package/dist/types-graph.js +4 -0
- package/dist/types-jobs.d.ts +28 -0
- package/dist/types-jobs.js +14 -0
- package/dist/types-relations.d.ts +49 -0
- package/dist/types-relations.js +11 -0
- package/dist/types-workflows.d.ts +36 -0
- package/dist/types-workflows.js +3 -0
- package/dist/types.d.ts +226 -0
- package/dist/types.js +1 -0
- package/package.json +49 -4
- package/src/analyze.ts +119 -0
- package/src/ast/node.ts +102 -0
- package/src/ast/walk.ts +37 -0
- package/src/diagnostics.ts +22 -0
- package/src/fs.ts +59 -0
- package/src/graph-edges.ts +167 -0
- package/src/graph-ids.ts +31 -0
- package/src/graph.ts +194 -0
- package/src/index.ts +26 -0
- package/src/json.ts +34 -0
- package/src/module-graph.ts +117 -0
- package/src/module-info.ts +162 -0
- package/src/node.ts +54 -0
- package/src/parser.ts +19 -0
- package/src/passes/clients.ts +233 -0
- package/src/passes/config.ts +278 -0
- package/src/passes/file-facts.ts +142 -0
- package/src/passes/i18n.ts +66 -0
- package/src/passes/job-relations-diagnostics.ts +60 -0
- package/src/passes/job-relations.ts +76 -0
- package/src/passes/jobs.ts +181 -0
- package/src/passes/jsx-text.ts +126 -0
- package/src/passes/jsx.ts +142 -0
- package/src/passes/path-expression.ts +297 -0
- package/src/passes/route-config.ts +188 -0
- package/src/passes/route-description.ts +33 -0
- package/src/passes/route-elevation.ts +161 -0
- package/src/passes/route-handler.ts +105 -0
- package/src/passes/route-location.ts +62 -0
- package/src/passes/route-relation-resolve.ts +228 -0
- package/src/passes/route-relations-diagnostics.ts +115 -0
- package/src/passes/route-relations-follow.ts +146 -0
- package/src/passes/route-relations-usage.ts +98 -0
- package/src/passes/route-relations-walk.ts +255 -0
- package/src/passes/route-relations.ts +62 -0
- package/src/passes/route-table.ts +123 -0
- package/src/passes/route-tables.ts +50 -0
- package/src/passes/routes.ts +188 -0
- package/src/passes/schema-column-types.ts +62 -0
- package/src/passes/schema-columns.ts +287 -0
- package/src/passes/schema-dsl.ts +136 -0
- package/src/passes/schema-file-links-ddl.ts +125 -0
- package/src/passes/schema-file-links.ts +213 -0
- package/src/passes/schema-foreign-keys.ts +121 -0
- package/src/passes/schema-policies.ts +270 -0
- package/src/passes/schema-re-exports.ts +95 -0
- package/src/passes/schema-relations.ts +239 -0
- package/src/passes/schema-scope.ts +29 -0
- package/src/passes/schema-tables.ts +216 -0
- package/src/passes/schema.ts +67 -0
- package/src/passes/screen-components.ts +162 -0
- package/src/passes/screen-intrinsics.ts +29 -0
- package/src/passes/screen-preview.ts +295 -0
- package/src/passes/screens.ts +291 -0
- package/src/passes/static-read.ts +196 -0
- package/src/passes/views.ts +125 -0
- package/src/passes/workflow-tables.ts +87 -0
- package/src/passes/workflows.ts +213 -0
- package/src/path-resolver.ts +146 -0
- package/src/revision.ts +25 -0
- package/src/source.ts +15 -0
- package/src/static-eval.ts +242 -0
- package/src/types-graph.ts +176 -0
- package/src/types-jobs.ts +40 -0
- package/src/types-relations.ts +64 -0
- package/src/types-workflows.ts +42 -0
- package/src/types.ts +299 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job `run(ctx)` -> relation usage (D-157 applied to jobs). A job's `db` is the
|
|
3
|
+
* system-scoped client, so its usage has no elevation bucket: it is the route walk with the
|
|
4
|
+
* `{ db }` seed on the first parameter and elevations disabled. The walk and the name
|
|
5
|
+
* resolution are the route ones (`route-relations-walk.ts` / `route-relation-resolve.ts`),
|
|
6
|
+
* so the two passes cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
import type { ModuleGraph } from "../module-graph.js";
|
|
9
|
+
import type { Diagnostic, TableFact, ViewFact } from "../types.js";
|
|
10
|
+
import type { JobFact, JobRelationsFact } from "../types-jobs.js";
|
|
11
|
+
export interface JobRelationsScan {
|
|
12
|
+
readonly jobs: readonly JobFact[];
|
|
13
|
+
/** Usage per job, keyed by the job name (unique across the project). */
|
|
14
|
+
readonly relations: Readonly<Record<string, JobRelationsFact>>;
|
|
15
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
16
|
+
}
|
|
17
|
+
/** Attach relation-usage facts to every scannable job and derive the D-157 diagnostics. */
|
|
18
|
+
export declare function buildJobRelationFacts(graph: ModuleGraph, jobs: readonly JobFact[], tables: readonly TableFact[], views: readonly ViewFact[]): JobRelationsScan;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Job `run(ctx)` -> relation usage (D-157 applied to jobs). A job's `db` is the
|
|
3
|
+
* system-scoped client, so its usage has no elevation bucket: it is the route walk with the
|
|
4
|
+
* `{ db }` seed on the first parameter and elevations disabled. The walk and the name
|
|
5
|
+
* resolution are the route ones (`route-relations-walk.ts` / `route-relation-resolve.ts`),
|
|
6
|
+
* so the two passes cannot drift.
|
|
7
|
+
*/
|
|
8
|
+
import { callArguments, unwrapExpression } from "../ast/node.js";
|
|
9
|
+
import { EMPTY_JOB_RELATIONS } from "../types-jobs.js";
|
|
10
|
+
import { createRelationIndex } from "./route-relation-resolve.js";
|
|
11
|
+
import { scanFunctionUsage } from "./route-relations-walk.js";
|
|
12
|
+
import { jobRelationDiagnostics } from "./job-relations-diagnostics.js";
|
|
13
|
+
import { resolveJobCall } from "./jobs.js";
|
|
14
|
+
import { readStaticObject, sortDiagnostics } from "./static-read.js";
|
|
15
|
+
/** A job runs as `facilis_system`: `run`'s first parameter is the context, no elevations. */
|
|
16
|
+
const JOB_SEED = { contextParam: 0, elevations: false };
|
|
17
|
+
/** The function expression of the definition's `run` property, when inspectable. */
|
|
18
|
+
function runFunction(graph, file, call) {
|
|
19
|
+
const object = readStaticObject(callArguments(call)[0], { graph, file: file.path }, { code: "job-definition-static", report: false });
|
|
20
|
+
const value = unwrapExpression(object.fields.get("run")?.node);
|
|
21
|
+
return value?.type === "ArrowFunctionExpression" || value?.type === "FunctionExpression"
|
|
22
|
+
? value
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|
|
25
|
+
function toJobRelations(usage) {
|
|
26
|
+
return {
|
|
27
|
+
used: usage.used,
|
|
28
|
+
unresolved: usage.unresolved,
|
|
29
|
+
rawSql: usage.rawSql,
|
|
30
|
+
opaque: usage.opaque,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Attach relation-usage facts to every scannable job and derive the D-157 diagnostics. */
|
|
34
|
+
export function buildJobRelationFacts(graph, jobs, tables, views) {
|
|
35
|
+
const index = createRelationIndex(tables, views);
|
|
36
|
+
const relations = {};
|
|
37
|
+
const diagnostics = [];
|
|
38
|
+
for (const job of jobs) {
|
|
39
|
+
const file = graph.file(job.sourceFile);
|
|
40
|
+
const call = file === undefined ? null : resolveJobCall(file);
|
|
41
|
+
const run = file !== undefined && call !== null ? runFunction(graph, file, call) : undefined;
|
|
42
|
+
if (file === undefined || run === undefined) {
|
|
43
|
+
// The definition warning is the finding; an empty usage avoids a false unused warning.
|
|
44
|
+
relations[job.name] = EMPTY_JOB_RELATIONS;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
const usage = toJobRelations(scanFunctionUsage(graph, file, run, index, JOB_SEED));
|
|
48
|
+
relations[job.name] = usage;
|
|
49
|
+
diagnostics.push(...jobRelationDiagnostics(job, usage));
|
|
50
|
+
}
|
|
51
|
+
return { jobs, relations, diagnostics: sortDiagnostics(diagnostics) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `server/jobs/**` definitions -> JobFacts (D-157 applied to jobs).
|
|
3
|
+
*
|
|
4
|
+
* One default export per file: a `defineJob()` call from `@facilis.dev/core`. A file that is not
|
|
5
|
+
* a valid definition is a warning, not an error: jobs are optional, so a broken file must not
|
|
6
|
+
* hide the rest of the project. `dbTables` is the declaration the runtime guard enforces, so
|
|
7
|
+
* an unreadable one is explicit (null) and its warning keeps the jobs gate red.
|
|
8
|
+
*/
|
|
9
|
+
import { type AstNode } from "../ast/node.js";
|
|
10
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.js";
|
|
11
|
+
import type { Diagnostic } from "../types.js";
|
|
12
|
+
import type { JobFact } from "../types-jobs.js";
|
|
13
|
+
export interface JobResult {
|
|
14
|
+
readonly jobs: readonly JobFact[];
|
|
15
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
16
|
+
}
|
|
17
|
+
/** The `defineJob(...)` call of a valid definition, for later passes (D-157). */
|
|
18
|
+
export declare function resolveJobCall(file: ProjectFile): AstNode | null;
|
|
19
|
+
/** Read every job module under `server/jobs/` into facts (test files are skipped). */
|
|
20
|
+
export declare function buildJobFacts(graph: ModuleGraph): JobResult;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `server/jobs/**` definitions -> JobFacts (D-157 applied to jobs).
|
|
3
|
+
*
|
|
4
|
+
* One default export per file: a `defineJob()` call from `@facilis.dev/core`. A file that is not
|
|
5
|
+
* a valid definition is a warning, not an error: jobs are optional, so a broken file must not
|
|
6
|
+
* hide the rest of the project. `dbTables` is the declaration the runtime guard enforces, so
|
|
7
|
+
* an unreadable one is explicit (null) and its warning keeps the jobs gate red.
|
|
8
|
+
*/
|
|
9
|
+
import { callArguments, identifierName, unwrapExpression } from "../ast/node.js";
|
|
10
|
+
import { warning } from "../diagnostics.js";
|
|
11
|
+
import { defaultExportExpression, isLocallyDeclared, readStaticObject, refOf, relativePath, sortDiagnostics, } from "./static-read.js";
|
|
12
|
+
const CORE_SPECIFIER = "@facilis.dev/core";
|
|
13
|
+
const JOBS_DIR = "server/jobs/";
|
|
14
|
+
const STATIC_CODE = "job-definition-static";
|
|
15
|
+
/** The default export must be a `defineJob(...)` call of the core import. */
|
|
16
|
+
function resolveDefinition(file) {
|
|
17
|
+
const expression = defaultExportExpression(file);
|
|
18
|
+
if (expression === undefined) {
|
|
19
|
+
return { call: null, reason: "the job file has no default export" };
|
|
20
|
+
}
|
|
21
|
+
if (expression.type !== "CallExpression") {
|
|
22
|
+
return { call: null, reason: "the default export is not a defineJob() call" };
|
|
23
|
+
}
|
|
24
|
+
const callee = unwrapExpression(expression.callee);
|
|
25
|
+
const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
|
|
26
|
+
if (name === undefined)
|
|
27
|
+
return { call: null, reason: "the defineJob callee cannot be resolved" };
|
|
28
|
+
if (isLocallyDeclared(file, name)) {
|
|
29
|
+
return { call: null, reason: '"' + name + '" is shadowed by a local declaration' };
|
|
30
|
+
}
|
|
31
|
+
const imported = file.module.imports.find((entry) => entry.local === name);
|
|
32
|
+
if (imported === undefined ||
|
|
33
|
+
imported.specifier !== CORE_SPECIFIER ||
|
|
34
|
+
imported.imported !== "defineJob") {
|
|
35
|
+
return {
|
|
36
|
+
call: null,
|
|
37
|
+
reason: "the default export is not the defineJob import from @facilis.dev/core",
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
return { call: expression, reason: "" };
|
|
41
|
+
}
|
|
42
|
+
/** The `defineJob(...)` call of a valid definition, for later passes (D-157). */
|
|
43
|
+
export function resolveJobCall(file) {
|
|
44
|
+
return resolveDefinition(file).call;
|
|
45
|
+
}
|
|
46
|
+
function stringArray(value) {
|
|
47
|
+
if (!Array.isArray(value))
|
|
48
|
+
return null;
|
|
49
|
+
return value.every((entry) => typeof entry === "string") ? [...value] : null;
|
|
50
|
+
}
|
|
51
|
+
function scanFile(graph, file) {
|
|
52
|
+
const context = { graph, file: file.path };
|
|
53
|
+
const definition = resolveDefinition(file);
|
|
54
|
+
const ref = refOf(context, definition.call ?? defaultExportExpression(file));
|
|
55
|
+
if (definition.call === null) {
|
|
56
|
+
return { job: null, diagnostics: [warning("job-definition", definition.reason, ref)] };
|
|
57
|
+
}
|
|
58
|
+
const object = readStaticObject(callArguments(definition.call)[0], context, {
|
|
59
|
+
code: STATIC_CODE,
|
|
60
|
+
report: false,
|
|
61
|
+
});
|
|
62
|
+
// A structural read failure (spread/computed key) makes the whole definition suspect: keep
|
|
63
|
+
// it visible as an unknown and as a warning, as the route config reader does.
|
|
64
|
+
const unknown = [...object.unknown];
|
|
65
|
+
const diagnostics = object.unknown.map((item) => warning(item.code, item.reason, item.source));
|
|
66
|
+
const note = (reason, at) => {
|
|
67
|
+
const source = refOf(context, at);
|
|
68
|
+
unknown.push({ code: STATIC_CODE, reason, source });
|
|
69
|
+
diagnostics.push(warning(STATIC_CODE, reason, source));
|
|
70
|
+
};
|
|
71
|
+
const nameField = object.fields.get("name");
|
|
72
|
+
let name = null;
|
|
73
|
+
if (nameField !== undefined &&
|
|
74
|
+
nameField.ok &&
|
|
75
|
+
typeof nameField.value === "string" &&
|
|
76
|
+
nameField.value.length > 0) {
|
|
77
|
+
name = nameField.value;
|
|
78
|
+
}
|
|
79
|
+
if (name === null) {
|
|
80
|
+
diagnostics.push(warning("job-definition", "the job name cannot be read statically", refOf(context, nameField?.node)));
|
|
81
|
+
return { job: null, diagnostics };
|
|
82
|
+
}
|
|
83
|
+
const tablesField = object.fields.get("dbTables");
|
|
84
|
+
let dbTables = null;
|
|
85
|
+
if (tablesField === undefined) {
|
|
86
|
+
note("dbTables is missing; declare the relations the job touches ([] means none)", undefined);
|
|
87
|
+
}
|
|
88
|
+
else if (!tablesField.ok) {
|
|
89
|
+
note("dbTables cannot be read statically: " + (tablesField.reason ?? "unsupported"), tablesField.node);
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
dbTables = stringArray(tablesField.value);
|
|
93
|
+
if (dbTables === null)
|
|
94
|
+
note("dbTables must be a string[]", tablesField.node);
|
|
95
|
+
}
|
|
96
|
+
const rawSqlField = object.fields.get("rawSql");
|
|
97
|
+
let rawSql = false;
|
|
98
|
+
if (rawSqlField !== undefined && !rawSqlField.ok) {
|
|
99
|
+
rawSql = null;
|
|
100
|
+
note("rawSql cannot be read statically: " + (rawSqlField.reason ?? "unsupported"), rawSqlField.node);
|
|
101
|
+
}
|
|
102
|
+
else if (rawSqlField !== undefined) {
|
|
103
|
+
if (typeof rawSqlField.value === "boolean")
|
|
104
|
+
rawSql = rawSqlField.value;
|
|
105
|
+
else {
|
|
106
|
+
// An unreadable derogation is never assumed: db.execute then fails closed (D-157).
|
|
107
|
+
rawSql = null;
|
|
108
|
+
note("rawSql must be a boolean", rawSqlField.node);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
job: { name, sourceFile: file.path, source: ref, dbTables, rawSql, unknown },
|
|
113
|
+
diagnostics,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** Read every job module under `server/jobs/` into facts (test files are skipped). */
|
|
117
|
+
export function buildJobFacts(graph) {
|
|
118
|
+
const files = [...graph.files.values()]
|
|
119
|
+
.filter((file) => {
|
|
120
|
+
const relative = relativePath(graph.root, file.path);
|
|
121
|
+
return relative.startsWith(JOBS_DIR) && !relative.endsWith(".test.ts");
|
|
122
|
+
})
|
|
123
|
+
.toSorted((left, right) => left.path.localeCompare(right.path));
|
|
124
|
+
const jobs = [];
|
|
125
|
+
const diagnostics = [];
|
|
126
|
+
for (const file of files) {
|
|
127
|
+
const scanned = scanFile(graph, file);
|
|
128
|
+
if (scanned.job !== null)
|
|
129
|
+
jobs.push(scanned.job);
|
|
130
|
+
diagnostics.push(...scanned.diagnostics);
|
|
131
|
+
}
|
|
132
|
+
return { jobs, diagnostics: sortDiagnostics(diagnostics) };
|
|
133
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct text/i18n facts for one page-preview node.
|
|
3
|
+
*
|
|
4
|
+
* The preview is schematic, not a render: reading only an element's own JSX children keeps a
|
|
5
|
+
* section's label without pulling in what nested elements render. A `t("key")` child becomes
|
|
6
|
+
* `tKey` (D-043) and literal text stays alongside it as the UI's fallback, because the UI
|
|
7
|
+
* prefers the translated key. Dynamic expressions are skipped rather than guessed. Content
|
|
8
|
+
* intrinsics (<input>, <img>, ...) carry their copy in an attribute instead of a child, so
|
|
9
|
+
* `jsxContentFields` falls back to that attribute.
|
|
10
|
+
*/
|
|
11
|
+
import { type AstNode } from "../ast/node.js";
|
|
12
|
+
/** The optional `PagePreviewNode` text fields; a field is omitted, never empty. */
|
|
13
|
+
export interface JsxTextFields {
|
|
14
|
+
readonly text?: string;
|
|
15
|
+
readonly tKey?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The first direct non-empty text child and the first direct `t("key")` child of an element.
|
|
19
|
+
* Both are kept when the element renders one of each: the UI shows `tKey`, falling back to
|
|
20
|
+
* `text`. Returns undefined when the element has neither, so callers emit no empty field.
|
|
21
|
+
*/
|
|
22
|
+
export declare function jsxTextFields(node: AstNode): JsxTextFields | undefined;
|
|
23
|
+
/**
|
|
24
|
+
* The copy a content intrinsic renders: its direct JSX text first (where `t("key")` becomes
|
|
25
|
+
* `tKey`), then the first static placeholder/alt/aria-label attribute it carries. A dynamic
|
|
26
|
+
* attribute never becomes copy, but does not hide a later static fallback either.
|
|
27
|
+
*/
|
|
28
|
+
export declare function jsxContentFields(node: AstNode, name: string): JsxTextFields | undefined;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Direct text/i18n facts for one page-preview node.
|
|
3
|
+
*
|
|
4
|
+
* The preview is schematic, not a render: reading only an element's own JSX children keeps a
|
|
5
|
+
* section's label without pulling in what nested elements render. A `t("key")` child becomes
|
|
6
|
+
* `tKey` (D-043) and literal text stays alongside it as the UI's fallback, because the UI
|
|
7
|
+
* prefers the translated key. Dynamic expressions are skipped rather than guessed. Content
|
|
8
|
+
* intrinsics (<input>, <img>, ...) carry their copy in an attribute instead of a child, so
|
|
9
|
+
* `jsxContentFields` falls back to that attribute.
|
|
10
|
+
*/
|
|
11
|
+
import { asNode, memberName, nodeArray, unwrapExpression } from "../ast/node.js";
|
|
12
|
+
import { jsxAttribute, jsxAttributeValue, jsxChildren } from "./jsx.js";
|
|
13
|
+
/** JSX collapses source indentation into spaces; mirror it so labels carry no layout trivia. */
|
|
14
|
+
function collapseJsxText(value) {
|
|
15
|
+
const collapsed = value.replace(/\s+/g, " ").trim();
|
|
16
|
+
return collapsed === "" ? undefined : collapsed;
|
|
17
|
+
}
|
|
18
|
+
/** A string literal value, or undefined for any other expression. */
|
|
19
|
+
function literalText(node) {
|
|
20
|
+
// oxc emits ESTree `Literal`; `StringLiteral` is accepted for parser independence.
|
|
21
|
+
if (node.type !== "Literal" && node.type !== "StringLiteral")
|
|
22
|
+
return undefined;
|
|
23
|
+
return typeof node.value === "string" ? node.value : undefined;
|
|
24
|
+
}
|
|
25
|
+
/** A template literal without substitutions keeps its static text. */
|
|
26
|
+
function templateText(node) {
|
|
27
|
+
if (nodeArray(node, "expressions").length > 0)
|
|
28
|
+
return undefined;
|
|
29
|
+
const quasi = nodeArray(node, "quasis")[0];
|
|
30
|
+
const value = quasi?.value;
|
|
31
|
+
if (typeof value !== "object" || value === null)
|
|
32
|
+
return undefined;
|
|
33
|
+
const cooked = value.cooked;
|
|
34
|
+
if (typeof cooked === "string")
|
|
35
|
+
return cooked;
|
|
36
|
+
const raw = value.raw;
|
|
37
|
+
return typeof raw === "string" ? raw : undefined;
|
|
38
|
+
}
|
|
39
|
+
/** A direct text child: JSX text, or a static string/template expression. */
|
|
40
|
+
function textChild(child) {
|
|
41
|
+
if (child.type === "JSXText") {
|
|
42
|
+
return typeof child.value === "string" ? collapseJsxText(child.value) : undefined;
|
|
43
|
+
}
|
|
44
|
+
if (child.type !== "JSXExpressionContainer")
|
|
45
|
+
return undefined;
|
|
46
|
+
const expression = unwrapExpression(child.expression);
|
|
47
|
+
if (expression === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
const value = expression.type === "TemplateLiteral" ? templateText(expression) : literalText(expression);
|
|
50
|
+
// A literal was authored as written, so only surrounding whitespace is dropped here.
|
|
51
|
+
const trimmed = value?.trim();
|
|
52
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
53
|
+
}
|
|
54
|
+
/** A `t("key")` / `i18n.t("key")` child; the callee may be a member, the key may not be dynamic. */
|
|
55
|
+
function translationChild(child) {
|
|
56
|
+
if (child.type !== "JSXExpressionContainer")
|
|
57
|
+
return undefined;
|
|
58
|
+
const call = unwrapExpression(child.expression);
|
|
59
|
+
if (call === undefined || call.type !== "CallExpression")
|
|
60
|
+
return undefined;
|
|
61
|
+
const callee = asNode(call.callee);
|
|
62
|
+
if (callee === undefined)
|
|
63
|
+
return undefined;
|
|
64
|
+
const calleeName = callee.type === "Identifier" ? callee.name : memberName(callee);
|
|
65
|
+
if (calleeName !== "t")
|
|
66
|
+
return undefined;
|
|
67
|
+
const argument = nodeArray(call, "arguments")[0];
|
|
68
|
+
const key = argument === undefined ? undefined : literalText(argument);
|
|
69
|
+
return key === undefined || key.trim() === "" ? undefined : key;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The first direct non-empty text child and the first direct `t("key")` child of an element.
|
|
73
|
+
* Both are kept when the element renders one of each: the UI shows `tKey`, falling back to
|
|
74
|
+
* `text`. Returns undefined when the element has neither, so callers emit no empty field.
|
|
75
|
+
*/
|
|
76
|
+
export function jsxTextFields(node) {
|
|
77
|
+
let text;
|
|
78
|
+
let tKey;
|
|
79
|
+
for (const child of jsxChildren(node)) {
|
|
80
|
+
text ??= textChild(child);
|
|
81
|
+
tKey ??= translationChild(child);
|
|
82
|
+
}
|
|
83
|
+
if (text === undefined && tKey === undefined)
|
|
84
|
+
return undefined;
|
|
85
|
+
return { ...(text === undefined ? {} : { text }), ...(tKey === undefined ? {} : { tKey }) };
|
|
86
|
+
}
|
|
87
|
+
/** The attributes that stand in for missing JSX copy, in reading order per intrinsic. */
|
|
88
|
+
const COPY_ATTRIBUTES = {
|
|
89
|
+
input: ["placeholder", "aria-label", "value"],
|
|
90
|
+
textarea: ["placeholder", "aria-label"],
|
|
91
|
+
select: ["aria-label"],
|
|
92
|
+
button: ["aria-label"],
|
|
93
|
+
a: ["aria-label"],
|
|
94
|
+
img: ["alt"],
|
|
95
|
+
};
|
|
96
|
+
const DEFAULT_COPY_ATTRIBUTES = ["aria-label"];
|
|
97
|
+
/** An attribute value as static text: a string literal or a substitution-free template. */
|
|
98
|
+
function attributeText(attribute) {
|
|
99
|
+
const expression = jsxAttributeValue(attribute);
|
|
100
|
+
if (expression === undefined)
|
|
101
|
+
return undefined;
|
|
102
|
+
const value = expression.type === "TemplateLiteral" ? templateText(expression) : literalText(expression);
|
|
103
|
+
const trimmed = value?.trim();
|
|
104
|
+
return trimmed === undefined || trimmed === "" ? undefined : trimmed;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The copy a content intrinsic renders: its direct JSX text first (where `t("key")` becomes
|
|
108
|
+
* `tKey`), then the first static placeholder/alt/aria-label attribute it carries. A dynamic
|
|
109
|
+
* attribute never becomes copy, but does not hide a later static fallback either.
|
|
110
|
+
*/
|
|
111
|
+
export function jsxContentFields(node, name) {
|
|
112
|
+
const fields = jsxTextFields(node);
|
|
113
|
+
if (fields !== undefined)
|
|
114
|
+
return fields;
|
|
115
|
+
for (const attributeName of COPY_ATTRIBUTES[name] ?? DEFAULT_COPY_ATTRIBUTES) {
|
|
116
|
+
const text = attributeText(jsxAttribute(node, attributeName));
|
|
117
|
+
if (text !== undefined)
|
|
118
|
+
return { text };
|
|
119
|
+
}
|
|
120
|
+
return undefined;
|
|
121
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small ESTree/JSX helpers shared by the route-table and page-preview passes.
|
|
3
|
+
*
|
|
4
|
+
* The analyzer's parser contract is parser-independent ESTree, so these helpers only read
|
|
5
|
+
* structural keys (`openingElement.name`, `attributes`, `children`, `expression`); they never
|
|
6
|
+
* execute project code and never depend on oxc-specific node types.
|
|
7
|
+
*/
|
|
8
|
+
import { type AstNode } from "../ast/node.js";
|
|
9
|
+
/** `JSXIdentifier` / `JSXMemberExpression` / `JSXNamespacedName` -> display name. */
|
|
10
|
+
export declare function jsxName(node: AstNode | undefined): string | null;
|
|
11
|
+
export declare function openingElement(node: AstNode): AstNode | undefined;
|
|
12
|
+
export declare function elementName(node: AstNode): string | null;
|
|
13
|
+
export declare function jsxChildren(node: AstNode): AstNode[];
|
|
14
|
+
export declare function jsxAttributes(node: AstNode): AstNode[];
|
|
15
|
+
/** The first attribute with this name, or undefined. */
|
|
16
|
+
export declare function jsxAttribute(node: AstNode, name: string): AstNode | undefined;
|
|
17
|
+
/** The value expression of an attribute, unwrapped from `JSXExpressionContainer`. */
|
|
18
|
+
export declare function jsxAttributeValue(attribute: AstNode | undefined): AstNode | undefined;
|
|
19
|
+
/** A JSX element/fragment value, when the expression is one. */
|
|
20
|
+
export declare function jsxElementValue(value: AstNode | undefined): AstNode | undefined;
|
|
21
|
+
export declare function isJsxElement(node: AstNode | undefined): node is AstNode;
|
|
22
|
+
export declare function isSectionIntrinsic(name: string): boolean;
|
|
23
|
+
export declare function isContentIntrinsic(name: string): boolean;
|
|
24
|
+
export declare function isRoleIntrinsic(name: string): boolean;
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small ESTree/JSX helpers shared by the route-table and page-preview passes.
|
|
3
|
+
*
|
|
4
|
+
* The analyzer's parser contract is parser-independent ESTree, so these helpers only read
|
|
5
|
+
* structural keys (`openingElement.name`, `attributes`, `children`, `expression`); they never
|
|
6
|
+
* execute project code and never depend on oxc-specific node types.
|
|
7
|
+
*/
|
|
8
|
+
import { asNode, nodeArray } from "../ast/node.js";
|
|
9
|
+
/** `JSXIdentifier` / `JSXMemberExpression` / `JSXNamespacedName` -> display name. */
|
|
10
|
+
export function jsxName(node) {
|
|
11
|
+
if (node === undefined)
|
|
12
|
+
return null;
|
|
13
|
+
if (node.type === "JSXIdentifier")
|
|
14
|
+
return typeof node.name === "string" ? node.name : null;
|
|
15
|
+
if (node.type === "JSXMemberExpression") {
|
|
16
|
+
const object = jsxName(asNode(node.object));
|
|
17
|
+
const property = jsxName(asNode(node.property));
|
|
18
|
+
return object === null || property === null ? null : object + "." + property;
|
|
19
|
+
}
|
|
20
|
+
if (node.type === "JSXNamespacedName") {
|
|
21
|
+
const namespace = jsxName(asNode(node.namespace));
|
|
22
|
+
const name = jsxName(asNode(node.name));
|
|
23
|
+
return namespace === null || name === null ? null : namespace + ":" + name;
|
|
24
|
+
}
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function openingElement(node) {
|
|
28
|
+
return node.type === "JSXElement" ? asNode(node.openingElement) : undefined;
|
|
29
|
+
}
|
|
30
|
+
export function elementName(node) {
|
|
31
|
+
return jsxName(asNode(openingElement(node)?.name));
|
|
32
|
+
}
|
|
33
|
+
export function jsxChildren(node) {
|
|
34
|
+
return nodeArray(node, "children");
|
|
35
|
+
}
|
|
36
|
+
export function jsxAttributes(node) {
|
|
37
|
+
const opening = openingElement(node);
|
|
38
|
+
return opening === undefined ? [] : nodeArray(opening, "attributes");
|
|
39
|
+
}
|
|
40
|
+
/** The first attribute with this name, or undefined. */
|
|
41
|
+
export function jsxAttribute(node, name) {
|
|
42
|
+
for (const attribute of jsxAttributes(node)) {
|
|
43
|
+
if (attribute.type !== "JSXAttribute")
|
|
44
|
+
continue;
|
|
45
|
+
if (jsxName(asNode(attribute.name)) === name)
|
|
46
|
+
return attribute;
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
/** The value expression of an attribute, unwrapped from `JSXExpressionContainer`. */
|
|
51
|
+
export function jsxAttributeValue(attribute) {
|
|
52
|
+
if (attribute === undefined)
|
|
53
|
+
return undefined;
|
|
54
|
+
const value = asNode(attribute.value);
|
|
55
|
+
if (value === undefined)
|
|
56
|
+
return undefined;
|
|
57
|
+
if (value.type === "JSXExpressionContainer")
|
|
58
|
+
return asNode(value.expression);
|
|
59
|
+
return value;
|
|
60
|
+
}
|
|
61
|
+
/** A JSX element/fragment value, when the expression is one. */
|
|
62
|
+
export function jsxElementValue(value) {
|
|
63
|
+
if (value === undefined)
|
|
64
|
+
return undefined;
|
|
65
|
+
if (value.type === "JSXElement" || value.type === "JSXFragment")
|
|
66
|
+
return value;
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
export function isJsxElement(node) {
|
|
70
|
+
return node !== undefined && (node.type === "JSXElement" || node.type === "JSXFragment");
|
|
71
|
+
}
|
|
72
|
+
/** JSX elements that carry a page section (layout-only `<div>` stays flattened away). */
|
|
73
|
+
const SECTION_INTRINSICS = new Set([
|
|
74
|
+
"header",
|
|
75
|
+
"main",
|
|
76
|
+
"section",
|
|
77
|
+
"nav",
|
|
78
|
+
"aside",
|
|
79
|
+
"footer",
|
|
80
|
+
"form",
|
|
81
|
+
"table",
|
|
82
|
+
"ul",
|
|
83
|
+
"h1",
|
|
84
|
+
"h2",
|
|
85
|
+
"h3",
|
|
86
|
+
]);
|
|
87
|
+
/**
|
|
88
|
+
* Text-bearing intrinsics: they get a preview node when they carry copy, so the copy is not
|
|
89
|
+
* lost inside a flattened layout wrapper.
|
|
90
|
+
*/
|
|
91
|
+
const CONTENT_INTRINSICS = new Set([
|
|
92
|
+
"p",
|
|
93
|
+
"span",
|
|
94
|
+
"strong",
|
|
95
|
+
"em",
|
|
96
|
+
"small",
|
|
97
|
+
"b",
|
|
98
|
+
"i",
|
|
99
|
+
"code",
|
|
100
|
+
"pre",
|
|
101
|
+
"blockquote",
|
|
102
|
+
"button",
|
|
103
|
+
"a",
|
|
104
|
+
"input",
|
|
105
|
+
"select",
|
|
106
|
+
"textarea",
|
|
107
|
+
"label",
|
|
108
|
+
"img",
|
|
109
|
+
"video",
|
|
110
|
+
"audio",
|
|
111
|
+
"h4",
|
|
112
|
+
"h5",
|
|
113
|
+
"h6",
|
|
114
|
+
]);
|
|
115
|
+
/** Content intrinsics with a control role: drawn even without readable copy (a bare input, media). */
|
|
116
|
+
const ROLE_INTRINSICS = new Set([
|
|
117
|
+
"button",
|
|
118
|
+
"a",
|
|
119
|
+
"input",
|
|
120
|
+
"select",
|
|
121
|
+
"textarea",
|
|
122
|
+
"label",
|
|
123
|
+
"img",
|
|
124
|
+
"video",
|
|
125
|
+
"audio",
|
|
126
|
+
]);
|
|
127
|
+
export function isSectionIntrinsic(name) {
|
|
128
|
+
return SECTION_INTRINSICS.has(name);
|
|
129
|
+
}
|
|
130
|
+
export function isContentIntrinsic(name) {
|
|
131
|
+
return CONTENT_INTRINSICS.has(name);
|
|
132
|
+
}
|
|
133
|
+
export function isRoleIntrinsic(name) {
|
|
134
|
+
return ROLE_INTRINSICS.has(name);
|
|
135
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client path expressions -> static prefixes and route matches (§3.4 of the AST DSL
|
|
3
|
+
* contract).
|
|
4
|
+
*
|
|
5
|
+
* A path is read as a static prefix plus a dynamic tail: literal strings and const strings
|
|
6
|
+
* are exact, while concatenations and templates keep the leading static segments. One hop of
|
|
7
|
+
* a path-builder function is inlined (the builder's return expression); anything deeper is
|
|
8
|
+
* dynamic, not guessed.
|
|
9
|
+
*/
|
|
10
|
+
import { type AstNode } from "../ast/node.js";
|
|
11
|
+
import { type StaticContext } from "../static-eval.js";
|
|
12
|
+
import type { HttpMethod, PathKind, RouteFact, UnknownFact } from "../types.js";
|
|
13
|
+
export interface ExtractedPath {
|
|
14
|
+
readonly kind: PathKind;
|
|
15
|
+
/** Source text of the argument, for the inspector. */
|
|
16
|
+
readonly expression: string;
|
|
17
|
+
/** Leading static text; null when the path has no readable static part. */
|
|
18
|
+
readonly staticPrefix: string | null;
|
|
19
|
+
/** Fully static path, when the whole expression is known. */
|
|
20
|
+
readonly path: string | null;
|
|
21
|
+
readonly unknown: readonly UnknownFact[];
|
|
22
|
+
}
|
|
23
|
+
/** Extract the path of one client call; failures stay in the unknown channel. */
|
|
24
|
+
export declare function extractPathExpression(argument: AstNode | undefined, context: StaticContext, call: AstNode, code: string): ExtractedPath;
|
|
25
|
+
/** Route ids whose template can produce the call path for the given method. */
|
|
26
|
+
export declare function matchRouteIds(routes: readonly RouteFact[], path: string | null, staticPrefix: string | null, method: HttpMethod): string[];
|