@facilis.dev/analyze 0.0.0-stage → 0.1.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/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 +158 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +15 -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.d.ts +22 -0
- package/dist/passes/jsx.js +89 -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-preview.d.ts +29 -0
- package/dist/passes/screen-preview.js +159 -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 +130 -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 +190 -0
- package/src/index.ts +24 -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.ts +92 -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-preview.ts +268 -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 +161 -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,213 @@
|
|
|
1
|
+
// `server/workflows/**` definitions -> WorkflowFacts (docs/WORKFLOWS.md §6, D-135).
|
|
2
|
+
//
|
|
3
|
+
// The default export is the only source: a `defineWorkflow()` call from `@facilis.dev/core`
|
|
4
|
+
// whose argument is an object literal. Resolvers (`input`, `params`) are functions, so the
|
|
5
|
+
// reader takes the static fields one by one instead of evaluating the whole definition.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
callArguments,
|
|
9
|
+
identifierName,
|
|
10
|
+
nodeArray,
|
|
11
|
+
objectEntries,
|
|
12
|
+
propertyName,
|
|
13
|
+
propertyValue,
|
|
14
|
+
unwrapExpression,
|
|
15
|
+
type AstNode,
|
|
16
|
+
} from "../ast/node.ts";
|
|
17
|
+
import { warning } from "../diagnostics.ts";
|
|
18
|
+
import type { ModuleGraph, ProjectFile } from "../module-graph.ts";
|
|
19
|
+
import { sourceRef } from "../source.ts";
|
|
20
|
+
import { evaluateStatic, type StaticContext } from "../static-eval.ts";
|
|
21
|
+
import type { Diagnostic, UnknownFact, WorkflowFact, WorkflowStepFact } from "../types.ts";
|
|
22
|
+
import {
|
|
23
|
+
defaultExportExpression,
|
|
24
|
+
isLocallyDeclared,
|
|
25
|
+
isRecord,
|
|
26
|
+
relativePath,
|
|
27
|
+
sortDiagnostics,
|
|
28
|
+
} from "./static-read.ts";
|
|
29
|
+
|
|
30
|
+
const CORE_SPECIFIER = "@facilis.dev/core";
|
|
31
|
+
const WORKFLOW_DIR = "server/workflows/";
|
|
32
|
+
const STATIC_CODE = "workflow-definition-static";
|
|
33
|
+
|
|
34
|
+
export interface WorkflowResult {
|
|
35
|
+
readonly workflows: readonly WorkflowFact[];
|
|
36
|
+
readonly diagnostics: readonly Diagnostic[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read every workflow definition the project declares; invalid files become warnings. */
|
|
40
|
+
export function buildWorkflowFacts(graph: ModuleGraph): WorkflowResult {
|
|
41
|
+
const workflows: WorkflowFact[] = [];
|
|
42
|
+
const diagnostics: Diagnostic[] = [];
|
|
43
|
+
for (const file of graph.files.values()) {
|
|
44
|
+
const relative = relativePath(graph.root, file.path);
|
|
45
|
+
if (!relative.startsWith(WORKFLOW_DIR) || relative.endsWith(".test.ts")) continue;
|
|
46
|
+
scanFile(graph, file, workflows, diagnostics);
|
|
47
|
+
}
|
|
48
|
+
return { workflows, diagnostics: sortDiagnostics(diagnostics) };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function scanFile(
|
|
52
|
+
graph: ModuleGraph,
|
|
53
|
+
file: ProjectFile,
|
|
54
|
+
workflows: WorkflowFact[],
|
|
55
|
+
diagnostics: Diagnostic[],
|
|
56
|
+
): void {
|
|
57
|
+
const context: StaticContext = { graph, file: file.path };
|
|
58
|
+
const expression = defaultExportExpression(file);
|
|
59
|
+
const ref = sourceRef(file.path, file.source, expression?.start ?? 0);
|
|
60
|
+
const problem = definitionProblem(file, expression);
|
|
61
|
+
if (problem !== null || expression === undefined || expression.type !== "CallExpression") {
|
|
62
|
+
diagnostics.push(
|
|
63
|
+
warning(
|
|
64
|
+
"workflow-definition",
|
|
65
|
+
problem ?? "the default export is not a defineWorkflow() call",
|
|
66
|
+
ref,
|
|
67
|
+
),
|
|
68
|
+
);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const literal = objectLiteral(callArguments(expression)[0]);
|
|
72
|
+
if (literal === undefined) {
|
|
73
|
+
diagnostics.push(
|
|
74
|
+
warning("workflow-definition", "the definition argument is not an object literal", ref),
|
|
75
|
+
);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const fields = fieldMap(literal);
|
|
79
|
+
const name = staticString(fields.get("name"), context);
|
|
80
|
+
if (name === null) {
|
|
81
|
+
diagnostics.push(
|
|
82
|
+
warning("workflow-definition", "the workflow name cannot be read statically", ref),
|
|
83
|
+
);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const unknown: UnknownFact[] = [];
|
|
87
|
+
const steps = readSteps(fields.get("steps"), context, file.path, file.source, unknown);
|
|
88
|
+
workflows.push({ name, sourceFile: file.path, source: ref, steps, unknown });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The default export must be the `defineWorkflow` import from `@facilis.dev/core`. */
|
|
92
|
+
function definitionProblem(file: ProjectFile, expression: AstNode | undefined): string | null {
|
|
93
|
+
if (expression === undefined) return "the workflow file has no default export";
|
|
94
|
+
if (expression.type !== "CallExpression") {
|
|
95
|
+
return "the default export is not a defineWorkflow() call";
|
|
96
|
+
}
|
|
97
|
+
const callee = unwrapExpression(expression.callee);
|
|
98
|
+
const name = callee?.type === "Identifier" ? identifierName(callee) : undefined;
|
|
99
|
+
if (name === undefined) return "the defineWorkflow callee cannot be resolved";
|
|
100
|
+
if (isLocallyDeclared(file, name)) return '"' + name + '" is shadowed by a local declaration';
|
|
101
|
+
const imported = file.module.imports.find((entry) => entry.local === name);
|
|
102
|
+
if (
|
|
103
|
+
imported === undefined ||
|
|
104
|
+
imported.specifier !== CORE_SPECIFIER ||
|
|
105
|
+
imported.imported !== "defineWorkflow"
|
|
106
|
+
) {
|
|
107
|
+
return "the default export is not the defineWorkflow import from @facilis.dev/core";
|
|
108
|
+
}
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readSteps(
|
|
113
|
+
node: AstNode | undefined,
|
|
114
|
+
context: StaticContext,
|
|
115
|
+
file: string,
|
|
116
|
+
source: string,
|
|
117
|
+
unknown: UnknownFact[],
|
|
118
|
+
): WorkflowStepFact[] {
|
|
119
|
+
const array = node === undefined ? undefined : unwrapExpression(node);
|
|
120
|
+
if (array === undefined || array.type !== "ArrayExpression") {
|
|
121
|
+
unknown.push({
|
|
122
|
+
code: STATIC_CODE,
|
|
123
|
+
reason: "steps must be an array literal",
|
|
124
|
+
source: sourceRef(file, source, array?.start ?? 0),
|
|
125
|
+
});
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const steps: WorkflowStepFact[] = [];
|
|
129
|
+
nodeArray(array, "elements").forEach((element, index) => {
|
|
130
|
+
const ref = sourceRef(file, source, element.start ?? 0);
|
|
131
|
+
const literal = objectLiteral(element);
|
|
132
|
+
const fields = literal === undefined ? new Map<string, AstNode>() : fieldMap(literal);
|
|
133
|
+
const job = fields.get("job");
|
|
134
|
+
const approval = fields.get("approval");
|
|
135
|
+
if (job !== undefined) {
|
|
136
|
+
const name = staticString(job, context);
|
|
137
|
+
unknownIfNull(unknown, ref, index, "job name", name);
|
|
138
|
+
steps.push({ index, kind: "job", name, permission: null, onRejectStep: null, source: ref });
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
if (approval !== undefined) {
|
|
142
|
+
const approvalLiteral = objectLiteral(approval);
|
|
143
|
+
const approvalFields =
|
|
144
|
+
approvalLiteral === undefined ? new Map<string, AstNode>() : fieldMap(approvalLiteral);
|
|
145
|
+
const titleKey = staticString(approvalFields.get("titleKey"), context);
|
|
146
|
+
const permission = staticString(approvalFields.get("permission"), context);
|
|
147
|
+
const onRejectStep = rejectStepOf(approvalFields.get("onReject"), context);
|
|
148
|
+
unknownIfNull(unknown, ref, index, "approval title key", titleKey);
|
|
149
|
+
steps.push({
|
|
150
|
+
index,
|
|
151
|
+
kind: "approval",
|
|
152
|
+
name: titleKey,
|
|
153
|
+
permission,
|
|
154
|
+
onRejectStep,
|
|
155
|
+
source: ref,
|
|
156
|
+
});
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
unknown.push({
|
|
160
|
+
code: STATIC_CODE,
|
|
161
|
+
reason: "step " + index + " is neither a job nor an approval",
|
|
162
|
+
source: ref,
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
return steps;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function rejectStepOf(node: AstNode | undefined, context: StaticContext): number | null {
|
|
169
|
+
if (node === undefined) return null;
|
|
170
|
+
const evaluated = evaluateStatic(node, context);
|
|
171
|
+
if (!evaluated.ok || !isRecord(evaluated.value)) return null;
|
|
172
|
+
const step = evaluated.value["step"];
|
|
173
|
+
return typeof step === "number" && Number.isInteger(step) && step >= 0 ? step : null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function unknownIfNull(
|
|
177
|
+
unknown: UnknownFact[],
|
|
178
|
+
ref: WorkflowFact["source"],
|
|
179
|
+
index: number,
|
|
180
|
+
label: string,
|
|
181
|
+
value: string | null,
|
|
182
|
+
): void {
|
|
183
|
+
if (value !== null) return;
|
|
184
|
+
unknown.push({
|
|
185
|
+
code: STATIC_CODE,
|
|
186
|
+
reason: "step " + index + ": " + label + " cannot be read statically",
|
|
187
|
+
source: ref,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function staticString(node: AstNode | undefined, context: StaticContext): string | null {
|
|
192
|
+
if (node === undefined) return null;
|
|
193
|
+
const evaluated = evaluateStatic(node, context);
|
|
194
|
+
return evaluated.ok && typeof evaluated.value === "string" && evaluated.value.length > 0
|
|
195
|
+
? evaluated.value
|
|
196
|
+
: null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function objectLiteral(node: AstNode | undefined): AstNode | undefined {
|
|
200
|
+
const value = node === undefined ? undefined : unwrapExpression(node);
|
|
201
|
+
return value?.type === "ObjectExpression" ? value : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function fieldMap(literal: AstNode): Map<string, AstNode> {
|
|
205
|
+
const fields = new Map<string, AstNode>();
|
|
206
|
+
for (const entry of objectEntries(literal)) {
|
|
207
|
+
if (entry.type === "SpreadElement") continue;
|
|
208
|
+
const key = propertyName(entry);
|
|
209
|
+
const value = propertyValue(entry);
|
|
210
|
+
if (key !== undefined && value !== undefined) fields.set(key, value);
|
|
211
|
+
}
|
|
212
|
+
return fields;
|
|
213
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/** Project-relative path handling and module specifier resolution (no Node APIs). */
|
|
2
|
+
|
|
3
|
+
export interface PathAlias {
|
|
4
|
+
readonly prefix: string;
|
|
5
|
+
readonly target: string;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface PathResolverInput {
|
|
9
|
+
readonly exists: (path: string) => boolean;
|
|
10
|
+
readonly readJson: (path: string) => unknown;
|
|
11
|
+
readonly root: string;
|
|
12
|
+
readonly aliases?: readonly PathAlias[];
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface PathResolver {
|
|
16
|
+
resolve(from: string, specifier: string): string | null;
|
|
17
|
+
tryFile(path: string): string | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const SOURCE_EXTENSIONS = [
|
|
21
|
+
".ts",
|
|
22
|
+
".tsx",
|
|
23
|
+
".js",
|
|
24
|
+
".jsx",
|
|
25
|
+
".mjs",
|
|
26
|
+
".cjs",
|
|
27
|
+
".mts",
|
|
28
|
+
".cts",
|
|
29
|
+
] as const;
|
|
30
|
+
|
|
31
|
+
export function normalizePath(path: string): string {
|
|
32
|
+
const parts = path.replace(/\\/g, "/").split("/");
|
|
33
|
+
const out: string[] = [];
|
|
34
|
+
for (const part of parts) {
|
|
35
|
+
if (part === "" || part === ".") continue;
|
|
36
|
+
if (part === "..") {
|
|
37
|
+
out.pop();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
out.push(part);
|
|
41
|
+
}
|
|
42
|
+
return out.join("/");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function dirname(path: string): string {
|
|
46
|
+
const index = path.lastIndexOf("/");
|
|
47
|
+
return index === -1 ? "" : path.slice(0, index);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function joinPath(base: string, relative: string): string {
|
|
51
|
+
return normalizePath(base.length === 0 ? relative : base + "/" + relative);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function packageSpecifier(specifier: string): { pkg: string; subpath: string } {
|
|
55
|
+
const parts = specifier.split("/");
|
|
56
|
+
if (specifier.startsWith("@")) {
|
|
57
|
+
return { pkg: parts.slice(0, 2).join("/"), subpath: parts.slice(2).join("/") };
|
|
58
|
+
}
|
|
59
|
+
return { pkg: parts[0] ?? specifier, subpath: parts.slice(1).join("/") };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function exportTargets(exports: unknown): string[] {
|
|
63
|
+
if (typeof exports === "string") return [exports];
|
|
64
|
+
if (Array.isArray(exports)) return exports.flatMap(exportTargets);
|
|
65
|
+
if (typeof exports !== "object" || exports === null) return [];
|
|
66
|
+
const record = exports as Record<string, unknown>;
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (const key of ["import", "default", "browser", "node"]) {
|
|
69
|
+
if (key in record) out.push(...exportTargets(record[key]));
|
|
70
|
+
}
|
|
71
|
+
return out;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function createPathResolver(input: PathResolverInput): PathResolver {
|
|
75
|
+
const root = normalizePath(input.root);
|
|
76
|
+
const aliases: PathAlias[] = [
|
|
77
|
+
{ prefix: "~/", target: root },
|
|
78
|
+
{ prefix: "~", target: root },
|
|
79
|
+
...(input.aliases ?? []),
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const tryFile = (path: string): string | null => {
|
|
83
|
+
if (input.exists(path)) return path;
|
|
84
|
+
for (const extension of SOURCE_EXTENSIONS) {
|
|
85
|
+
if (input.exists(path + extension)) return path + extension;
|
|
86
|
+
}
|
|
87
|
+
for (const extension of SOURCE_EXTENSIONS) {
|
|
88
|
+
const index = joinPath(path, "index" + extension);
|
|
89
|
+
if (input.exists(index)) return index;
|
|
90
|
+
}
|
|
91
|
+
return null;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
const resolvePackage = (specifier: string): string | null => {
|
|
95
|
+
const { pkg, subpath } = packageSpecifier(specifier);
|
|
96
|
+
const prefix = "node_modules/" + pkg;
|
|
97
|
+
const packageJson = input.readJson(prefix + "/package.json");
|
|
98
|
+
if (typeof packageJson !== "object" || packageJson === null) return null;
|
|
99
|
+
const record = packageJson as Record<string, unknown>;
|
|
100
|
+
if (subpath.length > 0) {
|
|
101
|
+
const exports = record["exports"];
|
|
102
|
+
if (typeof exports === "object" && exports !== null) {
|
|
103
|
+
const entry = (exports as Record<string, unknown>)["./" + subpath];
|
|
104
|
+
for (const target of exportTargets(entry)) {
|
|
105
|
+
const resolved = tryFile(joinPath(prefix, target));
|
|
106
|
+
if (resolved !== null) return resolved;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return tryFile(joinPath(prefix, subpath));
|
|
110
|
+
}
|
|
111
|
+
const exports = record["exports"];
|
|
112
|
+
if (typeof exports === "object" && exports !== null) {
|
|
113
|
+
const entry = (exports as Record<string, unknown>)["."];
|
|
114
|
+
for (const target of exportTargets(entry)) {
|
|
115
|
+
const resolved = tryFile(joinPath(prefix, target));
|
|
116
|
+
if (resolved !== null) return resolved;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
for (const target of ["main", "module"]) {
|
|
120
|
+
const value = record[target];
|
|
121
|
+
if (typeof value === "string") {
|
|
122
|
+
const resolved = tryFile(joinPath(prefix, value));
|
|
123
|
+
if (resolved !== null) return resolved;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return tryFile(joinPath(prefix, "src/index.ts")) ?? tryFile(joinPath(prefix, "index.ts"));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const resolve = (from: string, rawSpecifier: string): string | null => {
|
|
130
|
+
const specifier = rawSpecifier.trim();
|
|
131
|
+
if (specifier.length === 0) return null;
|
|
132
|
+
for (const alias of aliases) {
|
|
133
|
+
if (specifier === alias.prefix || specifier.startsWith(alias.prefix)) {
|
|
134
|
+
const rest = specifier.slice(alias.prefix.length);
|
|
135
|
+
const target = alias.target.length === 0 ? rest : joinPath(alias.target, rest);
|
|
136
|
+
const resolved = tryFile(target);
|
|
137
|
+
if (resolved !== null) return resolved;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
if (specifier.startsWith(".")) return tryFile(joinPath(dirname(from), specifier));
|
|
141
|
+
if (specifier.startsWith("/")) return tryFile(normalizePath(specifier));
|
|
142
|
+
return resolvePackage(specifier);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { resolve, tryFile };
|
|
146
|
+
}
|
package/src/revision.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { normalizePath } from "./path-resolver.ts";
|
|
2
|
+
|
|
3
|
+
/** Deterministic, browser-safe revision of the analyzed file set. */
|
|
4
|
+
export function computeRevision(files: ReadonlyMap<string, string>): string {
|
|
5
|
+
const entries = [...files]
|
|
6
|
+
.map(([path, source]) => [normalizePath(path), source] as const)
|
|
7
|
+
.toSorted(([left], [right]) => left.localeCompare(right));
|
|
8
|
+
let hash = 0x811c9dc5;
|
|
9
|
+
for (const [path, source] of entries) {
|
|
10
|
+
hash = mix(hash, path);
|
|
11
|
+
hash = mix(hash, "\u0000");
|
|
12
|
+
hash = mix(hash, source);
|
|
13
|
+
hash = mix(hash, "\u0001");
|
|
14
|
+
}
|
|
15
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function mix(hash: number, value: string): number {
|
|
19
|
+
let next = hash;
|
|
20
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
21
|
+
next ^= value.charCodeAt(index);
|
|
22
|
+
next = Math.imul(next, 0x01000193) >>> 0;
|
|
23
|
+
}
|
|
24
|
+
return next;
|
|
25
|
+
}
|
package/src/source.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { SourceRef } from "./types.ts";
|
|
2
|
+
|
|
3
|
+
/** 1-based line number of a source offset. */
|
|
4
|
+
export function lineOf(source: string, offset: number): number {
|
|
5
|
+
let line = 1;
|
|
6
|
+
const end = Math.min(Math.max(offset, 0), source.length);
|
|
7
|
+
for (let index = 0; index < end; index += 1) {
|
|
8
|
+
if (source.charCodeAt(index) === 10) line += 1;
|
|
9
|
+
}
|
|
10
|
+
return line;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function sourceRef(file: string, source: string, offset: number): SourceRef {
|
|
14
|
+
return { file, line: lineOf(source, offset) };
|
|
15
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import {
|
|
2
|
+
asNode,
|
|
3
|
+
identifierName,
|
|
4
|
+
literalString,
|
|
5
|
+
memberName,
|
|
6
|
+
nodeArray,
|
|
7
|
+
objectEntries,
|
|
8
|
+
propertyName,
|
|
9
|
+
propertyValue,
|
|
10
|
+
unwrapExpression,
|
|
11
|
+
type AstNode,
|
|
12
|
+
} from "./ast/node.ts";
|
|
13
|
+
import { defaultExportDeclaration, localInitializer } from "./module-info.ts";
|
|
14
|
+
import type { ModuleGraph } from "./module-graph.ts";
|
|
15
|
+
|
|
16
|
+
export type StaticEvaluation =
|
|
17
|
+
{ readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly reason: string };
|
|
18
|
+
|
|
19
|
+
export interface StaticContext {
|
|
20
|
+
readonly graph: ModuleGraph;
|
|
21
|
+
readonly file: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_DEPTH = 16;
|
|
25
|
+
|
|
26
|
+
function fail(reason: string): StaticEvaluation {
|
|
27
|
+
return { ok: false, reason };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function fromValue(value: unknown): StaticEvaluation {
|
|
31
|
+
return { ok: true, value };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Evaluate the static subset (docs/findings/ast-dsl-contract.md §4): literals, arrays,
|
|
36
|
+
* objects, templates without dynamic parts, same-file consts and one-hop project imports.
|
|
37
|
+
* Never executes code; anything else fails and the caller records an unknown fact.
|
|
38
|
+
*/
|
|
39
|
+
export function evaluateStatic(
|
|
40
|
+
node: AstNode | undefined,
|
|
41
|
+
context: StaticContext,
|
|
42
|
+
depth = 0,
|
|
43
|
+
): StaticEvaluation {
|
|
44
|
+
if (node === undefined) return fail("missing value");
|
|
45
|
+
if (depth > MAX_DEPTH) return fail("static evaluation is too deep or cyclic");
|
|
46
|
+
const unwrapped = unwrapExpression(node) ?? node;
|
|
47
|
+
switch (unwrapped.type) {
|
|
48
|
+
case "Literal":
|
|
49
|
+
return "value" in unwrapped ? fromValue(unwrapped.value) : fail("unsupported literal");
|
|
50
|
+
case "TemplateLiteral":
|
|
51
|
+
return evaluateTemplate(unwrapped, context, depth);
|
|
52
|
+
case "ArrayExpression":
|
|
53
|
+
return evaluateArray(unwrapped, context, depth);
|
|
54
|
+
case "ObjectExpression":
|
|
55
|
+
return evaluateObject(unwrapped, context, depth);
|
|
56
|
+
case "UnaryExpression":
|
|
57
|
+
return evaluateUnary(unwrapped, context, depth);
|
|
58
|
+
case "BinaryExpression":
|
|
59
|
+
return evaluateBinary(unwrapped, context, depth);
|
|
60
|
+
case "MemberExpression":
|
|
61
|
+
return evaluateMember(unwrapped, context, depth);
|
|
62
|
+
case "Identifier":
|
|
63
|
+
return evaluateIdentifier(unwrapped, context, depth);
|
|
64
|
+
default:
|
|
65
|
+
return fail('"' + unwrapped.type + '" is not in the static subset');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function evaluateIdentifier(
|
|
70
|
+
node: AstNode,
|
|
71
|
+
context: StaticContext,
|
|
72
|
+
depth: number,
|
|
73
|
+
): StaticEvaluation {
|
|
74
|
+
const name = identifierName(node);
|
|
75
|
+
if (name === undefined) return fail("missing identifier name");
|
|
76
|
+
if (name === "undefined") return fromValue(undefined);
|
|
77
|
+
const current = context.graph.file(context.file);
|
|
78
|
+
if (current === undefined) return fail("unknown file " + context.file);
|
|
79
|
+
const local = localInitializer(current.program, name);
|
|
80
|
+
if (local !== undefined) return evaluateStatic(local, context, depth + 1);
|
|
81
|
+
const binding = context.graph.resolveBinding(context.file, name);
|
|
82
|
+
if (binding === null) return fail('cannot resolve "' + name + '" statically');
|
|
83
|
+
const target = context.graph.file(binding.file);
|
|
84
|
+
if (target === undefined) return fail("unknown module " + binding.file);
|
|
85
|
+
if (binding.name === "default") {
|
|
86
|
+
const declaration = defaultExportDeclaration(target.program);
|
|
87
|
+
if (declaration === undefined) return fail("module has no default export");
|
|
88
|
+
return evaluateStatic(declaration, { graph: context.graph, file: target.path }, depth + 1);
|
|
89
|
+
}
|
|
90
|
+
const initializer = localInitializer(target.program, binding.name);
|
|
91
|
+
if (initializer === undefined) return fail('cannot resolve "' + binding.name + '" statically');
|
|
92
|
+
return evaluateStatic(initializer, { graph: context.graph, file: target.path }, depth + 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function evaluateTemplate(node: AstNode, context: StaticContext, depth: number): StaticEvaluation {
|
|
96
|
+
const quasis = nodeArray(node, "quasis");
|
|
97
|
+
const expressions = nodeArray(node, "expressions");
|
|
98
|
+
let out = "";
|
|
99
|
+
for (const [index, quasi] of quasis.entries()) {
|
|
100
|
+
// TemplateElement.value is a plain { raw, cooked } record, not an AST node.
|
|
101
|
+
const value = quasi?.value as { cooked?: unknown } | undefined;
|
|
102
|
+
const cooked = value?.cooked;
|
|
103
|
+
out += typeof cooked === "string" ? cooked : "";
|
|
104
|
+
const expression = expressions[index];
|
|
105
|
+
if (expression === undefined) continue;
|
|
106
|
+
const evaluated = evaluateStatic(expression, context, depth + 1);
|
|
107
|
+
if (!evaluated.ok) return evaluated;
|
|
108
|
+
if (
|
|
109
|
+
typeof evaluated.value !== "string" &&
|
|
110
|
+
typeof evaluated.value !== "number" &&
|
|
111
|
+
typeof evaluated.value !== "boolean"
|
|
112
|
+
) {
|
|
113
|
+
return fail("template interpolation is not a primitive");
|
|
114
|
+
}
|
|
115
|
+
out += String(evaluated.value);
|
|
116
|
+
}
|
|
117
|
+
return fromValue(out);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function evaluateArray(node: AstNode, context: StaticContext, depth: number): StaticEvaluation {
|
|
121
|
+
const out: unknown[] = [];
|
|
122
|
+
for (const element of nodeArray(node, "elements")) {
|
|
123
|
+
const evaluated = evaluateStatic(element, context, depth + 1);
|
|
124
|
+
if (!evaluated.ok) return evaluated;
|
|
125
|
+
out.push(evaluated.value);
|
|
126
|
+
}
|
|
127
|
+
return fromValue(out);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function evaluateObject(node: AstNode, context: StaticContext, depth: number): StaticEvaluation {
|
|
131
|
+
const out: Record<string, unknown> = {};
|
|
132
|
+
for (const entry of objectEntries(node)) {
|
|
133
|
+
if (entry.type === "SpreadElement") {
|
|
134
|
+
const argument = asNode(entry.argument);
|
|
135
|
+
const spread = evaluateStatic(argument, context, depth + 1);
|
|
136
|
+
if (!spread.ok) return spread;
|
|
137
|
+
if (typeof spread.value !== "object" || spread.value === null) {
|
|
138
|
+
return fail("object spread is not an object literal");
|
|
139
|
+
}
|
|
140
|
+
Object.assign(out, spread.value);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
const key = propertyName(entry);
|
|
144
|
+
if (key === undefined) return fail("computed object key is not in the static subset");
|
|
145
|
+
const evaluated = evaluateStatic(propertyValue(entry), context, depth + 1);
|
|
146
|
+
if (!evaluated.ok) return evaluated;
|
|
147
|
+
out[key] = evaluated.value;
|
|
148
|
+
}
|
|
149
|
+
return fromValue(out);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function evaluateUnary(node: AstNode, context: StaticContext, depth: number): StaticEvaluation {
|
|
153
|
+
const argument = asNode(node.argument);
|
|
154
|
+
const evaluated = evaluateStatic(argument, context, depth + 1);
|
|
155
|
+
if (!evaluated.ok) return evaluated;
|
|
156
|
+
if (node.operator === "-" && typeof evaluated.value === "number") {
|
|
157
|
+
return fromValue(-evaluated.value);
|
|
158
|
+
}
|
|
159
|
+
if (node.operator === "!" && typeof evaluated.value === "boolean") {
|
|
160
|
+
return fromValue(!evaluated.value);
|
|
161
|
+
}
|
|
162
|
+
return fail("unsupported unary expression");
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const NUMERIC_OPERATORS = new Set(["+", "-", "*", "/", "%"]);
|
|
166
|
+
|
|
167
|
+
function evaluateBinary(node: AstNode, context: StaticContext, depth: number): StaticEvaluation {
|
|
168
|
+
const operator = node.operator;
|
|
169
|
+
if (typeof operator !== "string" || !NUMERIC_OPERATORS.has(operator)) {
|
|
170
|
+
return fail("unsupported binary expression");
|
|
171
|
+
}
|
|
172
|
+
const left = evaluateStatic(asNode(node.left), context, depth + 1);
|
|
173
|
+
if (!left.ok) return left;
|
|
174
|
+
const right = evaluateStatic(asNode(node.right), context, depth + 1);
|
|
175
|
+
if (!right.ok) return right;
|
|
176
|
+
if (operator === "+" && (typeof left.value === "string" || typeof right.value === "string")) {
|
|
177
|
+
if (typeof left.value !== "string" && typeof left.value !== "number") {
|
|
178
|
+
return fail("binary + operands are not primitives");
|
|
179
|
+
}
|
|
180
|
+
if (typeof right.value !== "string" && typeof right.value !== "number") {
|
|
181
|
+
return fail("binary + operands are not primitives");
|
|
182
|
+
}
|
|
183
|
+
return fromValue(String(left.value) + String(right.value));
|
|
184
|
+
}
|
|
185
|
+
if (typeof left.value !== "number" || typeof right.value !== "number") {
|
|
186
|
+
return fail("binary " + operator + " operands are not numbers");
|
|
187
|
+
}
|
|
188
|
+
switch (operator) {
|
|
189
|
+
case "+":
|
|
190
|
+
return fromValue(left.value + right.value);
|
|
191
|
+
case "-":
|
|
192
|
+
return fromValue(left.value - right.value);
|
|
193
|
+
case "*":
|
|
194
|
+
return fromValue(left.value * right.value);
|
|
195
|
+
case "/":
|
|
196
|
+
return fromValue(left.value / right.value);
|
|
197
|
+
case "%":
|
|
198
|
+
return fromValue(left.value % right.value);
|
|
199
|
+
default:
|
|
200
|
+
return fail("unsupported binary expression");
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function evaluateMember(node: AstNode, context: StaticContext, depth: number): StaticEvaluation {
|
|
205
|
+
const name = memberName(node);
|
|
206
|
+
if (name === undefined) return fail("unsupported member expression");
|
|
207
|
+
const object = evaluateStatic(asNode(node.object), context, depth + 1);
|
|
208
|
+
if (!object.ok) return object;
|
|
209
|
+
if (typeof object.value !== "object" || object.value === null) {
|
|
210
|
+
return fail("member access on a non-object");
|
|
211
|
+
}
|
|
212
|
+
const record = object.value as Record<string, unknown>;
|
|
213
|
+
return Object.hasOwn(record, name) ? fromValue(record[name]) : fail("missing property " + name);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Convenience: a string or undefined, with the failure reason. */
|
|
217
|
+
export function staticString(
|
|
218
|
+
node: AstNode | undefined,
|
|
219
|
+
context: StaticContext,
|
|
220
|
+
): { ok: true; value: string } | { ok: false; reason: string } {
|
|
221
|
+
const evaluated = evaluateStatic(node, context);
|
|
222
|
+
if (!evaluated.ok) return evaluated;
|
|
223
|
+
return typeof evaluated.value === "string"
|
|
224
|
+
? { ok: true, value: evaluated.value }
|
|
225
|
+
: { ok: false, reason: "expected a string literal" };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Convenience: a string[] or undefined, with the failure reason. */
|
|
229
|
+
export function staticStringArray(
|
|
230
|
+
node: AstNode | undefined,
|
|
231
|
+
context: StaticContext,
|
|
232
|
+
): { ok: true; value: string[] } | { ok: false; reason: string } {
|
|
233
|
+
const evaluated = evaluateStatic(node, context);
|
|
234
|
+
if (!evaluated.ok) return evaluated;
|
|
235
|
+
if (!Array.isArray(evaluated.value)) return { ok: false, reason: "expected a string[] literal" };
|
|
236
|
+
if (!evaluated.value.every((item): item is string => typeof item === "string")) {
|
|
237
|
+
return { ok: false, reason: "expected a string[] literal" };
|
|
238
|
+
}
|
|
239
|
+
return { ok: true, value: [...evaluated.value] };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
export { literalString };
|