@triflare/fx 2.0.0 → 2.1.0-beta.2
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/README.md +27 -0
- package/package.json +6 -4
- package/product.yml +1 -1
- package/src/build.js +23 -6
- package/src/catalog.js +103 -0
- package/src/cli/commands/check.js +3 -1
- package/src/cli/commands/feature.js +163 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +8 -0
- package/src/cli/commands/lint.js +274 -0
- package/src/cli/commands/test.js +186 -0
- package/src/cli/context.js +16 -1
- package/src/cli/define-command.js +8 -1
- package/src/cli/reporter.js +9 -0
- package/src/compiler.js +133 -0
- package/src/config-edit.js +197 -0
- package/src/config.js +114 -1
- package/src/decompiler.js +45 -0
- package/src/features.js +425 -0
- package/src/formatter.js +519 -0
- package/src/index.js +31 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1434 -0
- package/src/lint/config-file.js +111 -0
- package/src/lint/config.js +174 -0
- package/src/lint/rules.js +461 -0
- package/src/linter.js +220 -0
- package/src/merge.js +116 -0
- package/src/parser.js +131 -16
- package/src/project.js +21 -90
- package/src/test/discover.js +82 -0
- package/src/test/parse.js +72 -0
- package/src/test/pattern.js +84 -0
- package/src/test/reporters.js +332 -0
- package/src/test/runner.js +1457 -0
- package/src/vm.js +325 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sourceExtension } from "../../brand.js";
|
|
4
|
+
import { discoverSources } from "../../project.js";
|
|
5
|
+
import { DEFAULT_TEST_PATTERN } from "../../test/discover.js";
|
|
6
|
+
import { applyFixes, lintSources } from "../../linter.js";
|
|
7
|
+
import {
|
|
8
|
+
effectivePreset,
|
|
9
|
+
normalizeLintConfig,
|
|
10
|
+
parseRuleOption,
|
|
11
|
+
} from "../../lint/config.js";
|
|
12
|
+
import { PRESET_NAMES, ruleById } from "../../lint/rules.js";
|
|
13
|
+
import { loadProjectState, resolveInput } from "../context.js";
|
|
14
|
+
import { outcome } from "../define-command.js";
|
|
15
|
+
import { EXIT, UsageError } from "../errors.js";
|
|
16
|
+
import { plural } from "../format.js";
|
|
17
|
+
|
|
18
|
+
const EXTENSION = sourceExtension();
|
|
19
|
+
|
|
20
|
+
/** Repeatable `--rule` values are collected into an array. */
|
|
21
|
+
function collect(value, previous = []) {
|
|
22
|
+
return [...previous, value];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function displayPath(cwd, file) {
|
|
26
|
+
const relativePath = path.relative(cwd, file);
|
|
27
|
+
return relativePath.startsWith("..") || path.isAbsolute(relativePath)
|
|
28
|
+
? file
|
|
29
|
+
: relativePath;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Resolve `--preset` and `--rule` on top of the project's `fx.lint` block.
|
|
34
|
+
* A bare `--rule FX201` enables the rule at its default severity; `off`
|
|
35
|
+
* disables it, and `error`/`warn` override the severity.
|
|
36
|
+
*/
|
|
37
|
+
function resolveOptions(options, loaded, product) {
|
|
38
|
+
const lintConfig = normalizeLintConfig(loaded.fx?.lint ?? {}, (message) => {
|
|
39
|
+
throw new UsageError(message, {
|
|
40
|
+
hint: "check the lint block in fx.yml",
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
if (options.preset != null) {
|
|
45
|
+
const preset = String(options.preset).trim().toLowerCase();
|
|
46
|
+
if (!PRESET_NAMES.includes(preset))
|
|
47
|
+
throw new UsageError(`unknown lint preset: ${options.preset}`, {
|
|
48
|
+
hint: `use one of: ${PRESET_NAMES.join(", ")}`,
|
|
49
|
+
});
|
|
50
|
+
lintConfig.extends = preset;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const overrides = {};
|
|
54
|
+
for (const raw of options.rule ?? []) {
|
|
55
|
+
const parsed = parseRuleOption(raw);
|
|
56
|
+
const preset = parsed.id.toLowerCase();
|
|
57
|
+
if (!parsed.severity && PRESET_NAMES.includes(preset)) {
|
|
58
|
+
lintConfig.extends = preset;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
const rule = ruleById(parsed.id);
|
|
62
|
+
if (!rule)
|
|
63
|
+
throw new UsageError(`unknown lint rule: ${parsed.id}`, {
|
|
64
|
+
hint: `run "${product.command} lint --help" for rule selection`,
|
|
65
|
+
});
|
|
66
|
+
if (!parsed.severity) {
|
|
67
|
+
overrides[rule.id] = rule.severity;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (!["error", "warning", "off"].includes(parsed.severity))
|
|
71
|
+
throw new UsageError(
|
|
72
|
+
`invalid severity for ${rule.id}: ${String(parsed.severity)}`,
|
|
73
|
+
{ hint: "use error, warn or off" },
|
|
74
|
+
);
|
|
75
|
+
overrides[rule.id] = parsed.severity;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { lintConfig, overrides };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Strip fix internals and display paths relative to the working directory. */
|
|
82
|
+
function toJsonDiagnostic(diagnostic, cwd) {
|
|
83
|
+
return {
|
|
84
|
+
ruleId: diagnostic.ruleId,
|
|
85
|
+
severity: diagnostic.severity,
|
|
86
|
+
category: diagnostic.category,
|
|
87
|
+
message: diagnostic.message,
|
|
88
|
+
file: diagnostic.file ? displayPath(cwd, diagnostic.file) : null,
|
|
89
|
+
line: diagnostic.line,
|
|
90
|
+
column: diagnostic.column,
|
|
91
|
+
endLine: diagnostic.endLine,
|
|
92
|
+
endColumn: diagnostic.endColumn,
|
|
93
|
+
explanation: diagnostic.explanation,
|
|
94
|
+
fixable: diagnostic.fixable,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function reportDiagnostics(reporter, diagnostics, cwd) {
|
|
99
|
+
let current = null;
|
|
100
|
+
for (const diagnostic of diagnostics) {
|
|
101
|
+
const file = diagnostic.file
|
|
102
|
+
? displayPath(cwd, diagnostic.file)
|
|
103
|
+
: "<input>";
|
|
104
|
+
if (file !== current) {
|
|
105
|
+
current = file;
|
|
106
|
+
reporter.text(reporter.bold(file));
|
|
107
|
+
}
|
|
108
|
+
const mark =
|
|
109
|
+
diagnostic.severity === "error"
|
|
110
|
+
? "error"
|
|
111
|
+
: diagnostic.severity === "warning"
|
|
112
|
+
? "warn "
|
|
113
|
+
: "info ";
|
|
114
|
+
reporter.text(
|
|
115
|
+
` ${mark} ${diagnostic.line}:${diagnostic.column} ${diagnostic.ruleId} ${diagnostic.message}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const lintCommand = {
|
|
121
|
+
name: "lint",
|
|
122
|
+
group: "Core Commands:",
|
|
123
|
+
summary: "Report source and project problems",
|
|
124
|
+
description: `Statically analyse a file, or every ${EXTENSION} file in a directory
|
|
125
|
+
(defaults to the working directory), plus the merged project. Rules come from
|
|
126
|
+
the "lint" block under "fx" in fx.yml; --preset and --rule override them.
|
|
127
|
+
|
|
128
|
+
Exit status is 1 when errors are reported, or when warnings are reported with
|
|
129
|
+
--strict. Linting never changes a file unless --fix is passed.`,
|
|
130
|
+
arguments: [`[input${EXTENSION}|dir]`],
|
|
131
|
+
options: [
|
|
132
|
+
{ flags: "--fix", description: "Apply safe, explicit fixes" },
|
|
133
|
+
{
|
|
134
|
+
flags: "--strict",
|
|
135
|
+
description: "Use the strict preset and fail on warnings",
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
flags: "--preset <name>",
|
|
139
|
+
description: `Use a preset: ${PRESET_NAMES.join(", ")}`,
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
flags: "--rule <id[=severity]>",
|
|
143
|
+
description: "Enable, disable or set one rule (repeatable)",
|
|
144
|
+
parser: collect,
|
|
145
|
+
defaultValue: [],
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
examples: [
|
|
149
|
+
"fx lint",
|
|
150
|
+
"fx lint src/main.tfx",
|
|
151
|
+
"fx lint --strict",
|
|
152
|
+
"fx lint --json",
|
|
153
|
+
"fx lint --rule FX201=off",
|
|
154
|
+
"fx lint --fix",
|
|
155
|
+
],
|
|
156
|
+
async run({ args, cwd, options, reporter, product }) {
|
|
157
|
+
const target = resolveInput(args[0], cwd);
|
|
158
|
+
const files = target.file ? [target.file] : discoverSources(target.dir);
|
|
159
|
+
if (files.length === 0)
|
|
160
|
+
throw new UsageError(`No ${EXTENSION} files found under ${target.dir}`);
|
|
161
|
+
|
|
162
|
+
const state = await loadProjectState({
|
|
163
|
+
input: args[0],
|
|
164
|
+
cwd,
|
|
165
|
+
options,
|
|
166
|
+
reporter,
|
|
167
|
+
includeExtensions: true,
|
|
168
|
+
migrateLegacy: false,
|
|
169
|
+
});
|
|
170
|
+
const { lintConfig, overrides } = resolveOptions(
|
|
171
|
+
options,
|
|
172
|
+
state.loaded,
|
|
173
|
+
product,
|
|
174
|
+
);
|
|
175
|
+
const preset = effectivePreset(lintConfig, Boolean(options.strict));
|
|
176
|
+
|
|
177
|
+
const lintOptions = {
|
|
178
|
+
config: state.loaded,
|
|
179
|
+
configPath: state.loaded.path,
|
|
180
|
+
configDir: state.configDir,
|
|
181
|
+
rootDir: state.configDir ?? cwd,
|
|
182
|
+
testPattern: state.loaded.fx?.tests
|
|
183
|
+
? state.loaded.fx.tests.pattern
|
|
184
|
+
: DEFAULT_TEST_PATTERN,
|
|
185
|
+
extensionCatalog: state.catalog,
|
|
186
|
+
lintConfig,
|
|
187
|
+
strict: Boolean(options.strict),
|
|
188
|
+
overrides,
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
// Analyse the whole project so cross-file declarations resolve, but keep
|
|
192
|
+
// the selected files separate for reporting, counts and --fix writes.
|
|
193
|
+
// Diagnostics whose file is not a source (assets, fx.yml) stay visible.
|
|
194
|
+
const projectDir = state.configDir ?? target.dir;
|
|
195
|
+
const analysisFiles = discoverSources(projectDir);
|
|
196
|
+
for (const file of files)
|
|
197
|
+
if (!analysisFiles.includes(file)) analysisFiles.push(file);
|
|
198
|
+
const analysisSet = new Set(analysisFiles);
|
|
199
|
+
const selected = new Set(files);
|
|
200
|
+
const inScope = (diagnostic) =>
|
|
201
|
+
diagnostic.file == null ||
|
|
202
|
+
selected.has(diagnostic.file) ||
|
|
203
|
+
!analysisSet.has(diagnostic.file);
|
|
204
|
+
|
|
205
|
+
let sources = analysisFiles.map((file) => ({
|
|
206
|
+
file,
|
|
207
|
+
source: fs.readFileSync(file, "utf8"),
|
|
208
|
+
}));
|
|
209
|
+
let result = lintSources({ sources, ...lintOptions });
|
|
210
|
+
let diagnostics = result.diagnostics.filter(inScope);
|
|
211
|
+
let fixed = 0;
|
|
212
|
+
|
|
213
|
+
if (options.fix) {
|
|
214
|
+
const applied = applyFixes(sources, diagnostics);
|
|
215
|
+
fixed = applied.fixed;
|
|
216
|
+
if (fixed > 0) {
|
|
217
|
+
for (const file of applied.files)
|
|
218
|
+
fs.writeFileSync(file, applied.sources.get(file));
|
|
219
|
+
sources = sources.map(({ file, source }) => ({
|
|
220
|
+
file,
|
|
221
|
+
source: applied.sources.get(file) ?? source,
|
|
222
|
+
}));
|
|
223
|
+
result = lintSources({ sources, ...lintOptions });
|
|
224
|
+
diagnostics = result.diagnostics.filter(inScope);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const summary = {
|
|
229
|
+
errors: diagnostics.filter((entry) => entry.severity === "error").length,
|
|
230
|
+
warnings: diagnostics.filter((entry) => entry.severity === "warning")
|
|
231
|
+
.length,
|
|
232
|
+
suppressed: result.summary.suppressed,
|
|
233
|
+
files: files.length,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
if (!reporter.json) {
|
|
237
|
+
reportDiagnostics(reporter, diagnostics, cwd);
|
|
238
|
+
if (fixed > 0) reporter.info(`Fixed ${plural(fixed, "problem")}.`);
|
|
239
|
+
const { errors, warnings } = summary;
|
|
240
|
+
if (errors > 0 || warnings > 0) {
|
|
241
|
+
const parts = [];
|
|
242
|
+
if (errors > 0) parts.push(plural(errors, "error"));
|
|
243
|
+
if (warnings > 0) parts.push(plural(warnings, "warning"));
|
|
244
|
+
reporter.error(
|
|
245
|
+
`${parts.join(", ")} in ${plural(files.length, "file")} (${preset} preset)`,
|
|
246
|
+
);
|
|
247
|
+
} else {
|
|
248
|
+
reporter.success(
|
|
249
|
+
`Linted ${plural(files.length, "file")}: no problems (${preset} preset)`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const failsOnWarnings = Boolean(options.strict);
|
|
255
|
+
const exitCode =
|
|
256
|
+
summary.errors > 0 || (failsOnWarnings && summary.warnings > 0)
|
|
257
|
+
? EXIT.FAILURE
|
|
258
|
+
: EXIT.OK;
|
|
259
|
+
|
|
260
|
+
return outcome({
|
|
261
|
+
exitCode,
|
|
262
|
+
data: {
|
|
263
|
+
preset,
|
|
264
|
+
strict: failsOnWarnings,
|
|
265
|
+
fixed,
|
|
266
|
+
files: files.map((file) => displayPath(cwd, file)),
|
|
267
|
+
summary,
|
|
268
|
+
diagnostics: diagnostics.map((diagnostic) =>
|
|
269
|
+
toJsonDiagnostic(diagnostic, cwd),
|
|
270
|
+
),
|
|
271
|
+
},
|
|
272
|
+
});
|
|
273
|
+
},
|
|
274
|
+
};
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { loadProjectState } from "../context.js";
|
|
3
|
+
import { CliError } from "../errors.js";
|
|
4
|
+
import { outcome } from "../define-command.js";
|
|
5
|
+
import { createTestReporter, REPORTER_NAMES } from "../../test/reporters.js";
|
|
6
|
+
import { runTests, TestError, TEST_EXIT } from "../../test/runner.js";
|
|
7
|
+
|
|
8
|
+
const WATCH_IGNORED = /(^|\/)(node_modules|build|dist|\.git|doc_build)(\/|$)/;
|
|
9
|
+
|
|
10
|
+
/** Resolve a `--reporter` value, mapping the global `--json` flag onto it. */
|
|
11
|
+
function reporterName(options) {
|
|
12
|
+
const name = String(options.reporter ?? "default");
|
|
13
|
+
if (options.json && name === "default") return "json";
|
|
14
|
+
if (!REPORTER_NAMES.includes(name))
|
|
15
|
+
throw new CliError(`Unknown reporter ${JSON.stringify(name)}.`, {
|
|
16
|
+
code: "TEST_USAGE",
|
|
17
|
+
exitCode: TEST_EXIT.USAGE,
|
|
18
|
+
hint: `use one of: ${REPORTER_NAMES.join(", ")}`,
|
|
19
|
+
});
|
|
20
|
+
return name;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Parse a positive numeric option, reporting usage errors with its label. */
|
|
24
|
+
function validateNumber(value, label) {
|
|
25
|
+
if (value === undefined) return undefined;
|
|
26
|
+
const number = Number(value);
|
|
27
|
+
if (!Number.isFinite(number) || number <= 0)
|
|
28
|
+
throw new CliError(`${label} must be a positive number`, {
|
|
29
|
+
code: "TEST_USAGE",
|
|
30
|
+
exitCode: TEST_EXIT.USAGE,
|
|
31
|
+
});
|
|
32
|
+
return number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Wait for the next source or config change. */
|
|
36
|
+
function nextChange(dir, signal) {
|
|
37
|
+
return new Promise((resolve, reject) => {
|
|
38
|
+
let watcher;
|
|
39
|
+
let timer = null;
|
|
40
|
+
const done = () => {
|
|
41
|
+
if (timer) clearTimeout(timer);
|
|
42
|
+
watcher?.close();
|
|
43
|
+
signal.removeEventListener("abort", done);
|
|
44
|
+
resolve();
|
|
45
|
+
};
|
|
46
|
+
if (signal.aborted) return done();
|
|
47
|
+
signal.addEventListener("abort", done, { once: true });
|
|
48
|
+
try {
|
|
49
|
+
watcher = fs.watch(dir, { recursive: true }, (event, filename) => {
|
|
50
|
+
if (!filename) return;
|
|
51
|
+
const name = String(filename).split("\\").join("/");
|
|
52
|
+
if (WATCH_IGNORED.test(name)) return;
|
|
53
|
+
if (!/\.(tfx|ya?ml)$/i.test(name)) return;
|
|
54
|
+
if (timer) clearTimeout(timer);
|
|
55
|
+
timer = setTimeout(done, 80);
|
|
56
|
+
});
|
|
57
|
+
} catch (error) {
|
|
58
|
+
signal.removeEventListener("abort", done);
|
|
59
|
+
reject(error);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const testCommand = {
|
|
65
|
+
name: "test",
|
|
66
|
+
group: "Core Commands:",
|
|
67
|
+
summary: "Run project tests in a headless PenguinMod VM",
|
|
68
|
+
description: `Discover test files (test/**/*.test.tfx by default), build the project
|
|
69
|
+
once, and run every test in a fresh VM. Testing is a language feature: enable
|
|
70
|
+
it with "fx feature tests enable".`,
|
|
71
|
+
arguments: ["[files...]"],
|
|
72
|
+
options: [
|
|
73
|
+
{
|
|
74
|
+
flags: "-t, --filter <text>",
|
|
75
|
+
description: "Only run tests whose name or file contains <text>",
|
|
76
|
+
},
|
|
77
|
+
{ flags: "-w, --watch", description: "Re-run when sources change" },
|
|
78
|
+
{ flags: "--run", description: "Run once and exit (the default)" },
|
|
79
|
+
{
|
|
80
|
+
flags: "--reporter <name>",
|
|
81
|
+
description: `Output format: ${REPORTER_NAMES.join(", ")}`,
|
|
82
|
+
defaultValue: "default",
|
|
83
|
+
},
|
|
84
|
+
{ flags: "--seed <number>", description: "Seed the random generator" },
|
|
85
|
+
{
|
|
86
|
+
flags: "--timeout <ms>",
|
|
87
|
+
description: "Per-test timeout in milliseconds",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
flags: "--allow-project-extensions",
|
|
91
|
+
description: "Trust and load this project's extensions for this run",
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
flags: "--allow-external-io",
|
|
95
|
+
description: "Allow network access (fetch and WebSocket) during tests",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
examples: [
|
|
99
|
+
"fx test",
|
|
100
|
+
"fx test test/score.test.tfx",
|
|
101
|
+
"fx test --filter score --reporter verbose",
|
|
102
|
+
"fx test --reporter junit",
|
|
103
|
+
"fx test --allow-project-extensions",
|
|
104
|
+
],
|
|
105
|
+
jsonEvents: true,
|
|
106
|
+
async run({ args, options, cwd, reporter, prompts }) {
|
|
107
|
+
// Commander hands a variadic argument back as one array.
|
|
108
|
+
const files = (Array.isArray(args[0]) ? args[0] : args).filter(Boolean);
|
|
109
|
+
const name = reporterName(options);
|
|
110
|
+
const color =
|
|
111
|
+
options.color === "always" ||
|
|
112
|
+
(options.color !== "never" &&
|
|
113
|
+
options.color !== false &&
|
|
114
|
+
Boolean(process.stdout.isTTY) &&
|
|
115
|
+
!process.env.NO_COLOR);
|
|
116
|
+
const testReporter = createTestReporter({
|
|
117
|
+
name,
|
|
118
|
+
stream: process.stdout,
|
|
119
|
+
color,
|
|
120
|
+
tty: Boolean(process.stdout.isTTY),
|
|
121
|
+
dir: cwd,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
const runOnce = async () => {
|
|
125
|
+
const state = await loadProjectState({
|
|
126
|
+
input: undefined,
|
|
127
|
+
cwd,
|
|
128
|
+
options,
|
|
129
|
+
reporter,
|
|
130
|
+
prompts,
|
|
131
|
+
});
|
|
132
|
+
return runTests({
|
|
133
|
+
dir: state.configDir,
|
|
134
|
+
files,
|
|
135
|
+
cwd,
|
|
136
|
+
filter: options.filter ?? "",
|
|
137
|
+
loaded: state.loaded,
|
|
138
|
+
catalog: state.catalog,
|
|
139
|
+
extensionUrls: state.extensionUrls,
|
|
140
|
+
seed: validateNumber(options.seed, "--seed"),
|
|
141
|
+
timeout: validateNumber(options.timeout, "--timeout"),
|
|
142
|
+
allowProjectExtensions: Boolean(options.allowProjectExtensions),
|
|
143
|
+
allowExternalIO: Boolean(options.allowExternalIO),
|
|
144
|
+
onEvent: testReporter.onEvent,
|
|
145
|
+
});
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
if (!options.watch) {
|
|
149
|
+
try {
|
|
150
|
+
const result = await runOnce();
|
|
151
|
+
return outcome({ data: result, exitCode: result.exitCode });
|
|
152
|
+
} catch (error) {
|
|
153
|
+
if (error instanceof TestError)
|
|
154
|
+
throw new CliError(error.message, {
|
|
155
|
+
code: "TEST_ERROR",
|
|
156
|
+
exitCode: error.exitCode,
|
|
157
|
+
hint: error.hint,
|
|
158
|
+
cause: error,
|
|
159
|
+
});
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
let interrupted = false;
|
|
166
|
+
const onInterrupt = () => {
|
|
167
|
+
interrupted = true;
|
|
168
|
+
controller.abort();
|
|
169
|
+
};
|
|
170
|
+
process.once("SIGINT", onInterrupt);
|
|
171
|
+
try {
|
|
172
|
+
for (;;) {
|
|
173
|
+
const result = await runOnce();
|
|
174
|
+
if (interrupted)
|
|
175
|
+
return outcome({ data: result, exitCode: TEST_EXIT.INTERRUPTED });
|
|
176
|
+
reporter.info("Watching for changes... (Ctrl+C to stop)");
|
|
177
|
+
await nextChange(result.dir, controller.signal);
|
|
178
|
+
if (interrupted)
|
|
179
|
+
return outcome({ data: result, exitCode: TEST_EXIT.INTERRUPTED });
|
|
180
|
+
}
|
|
181
|
+
} finally {
|
|
182
|
+
controller.abort();
|
|
183
|
+
process.off("SIGINT", onInterrupt);
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
};
|
package/src/cli/context.js
CHANGED
|
@@ -11,6 +11,7 @@ import path from "node:path";
|
|
|
11
11
|
import { configFilename, outputDirectory, sourceExtension } from "../brand.js";
|
|
12
12
|
import { loadConfigFrom, parseConfig } from "../config.js";
|
|
13
13
|
import { loadExtensions } from "../extensions.js";
|
|
14
|
+
import { loadFeatureExtensions } from "../features.js";
|
|
14
15
|
import { loadVendorExtensions, migrateProjectExtensions } from "../vendor.js";
|
|
15
16
|
import { UsageError } from "./errors.js";
|
|
16
17
|
|
|
@@ -25,8 +26,10 @@ export function resolveInput(input, cwd = process.cwd()) {
|
|
|
25
26
|
try {
|
|
26
27
|
stat = fs.statSync(resolved);
|
|
27
28
|
} catch {
|
|
29
|
+
// Name the absolute path that was checked: relative inputs are resolved
|
|
30
|
+
// from the working directory, not from the project root.
|
|
28
31
|
throw new UsageError(`input not found: ${input}`, {
|
|
29
|
-
hint: `check the path, or run from the project directory`,
|
|
32
|
+
hint: `looked for ${resolved}; check the path, or run from the project directory`,
|
|
30
33
|
});
|
|
31
34
|
}
|
|
32
35
|
return stat.isDirectory() ? { dir: resolved } : { file: resolved };
|
|
@@ -49,6 +52,7 @@ export function loadProjectConfig({ cwd = process.cwd(), searchDir, options }) {
|
|
|
49
52
|
fx: null,
|
|
50
53
|
package: null,
|
|
51
54
|
vendor: [],
|
|
55
|
+
features: [],
|
|
52
56
|
vendorDir: "extensions",
|
|
53
57
|
path: null,
|
|
54
58
|
};
|
|
@@ -166,6 +170,17 @@ export async function loadProjectExtensions({ configDir, loaded, reporter }) {
|
|
|
166
170
|
for (const warning of vendored.warnings) reporter.warn(warning);
|
|
167
171
|
}
|
|
168
172
|
|
|
173
|
+
if (loaded?.features && loaded.features.length > 0) {
|
|
174
|
+
const featureExtensions = await loadFeatureExtensions({
|
|
175
|
+
configDir,
|
|
176
|
+
config: loaded,
|
|
177
|
+
});
|
|
178
|
+
Object.assign(catalog, featureExtensions.catalog);
|
|
179
|
+
Object.assign(extensionUrls, featureExtensions.extensionUrls);
|
|
180
|
+
unsandboxed.push(...featureExtensions.unsandboxed);
|
|
181
|
+
for (const warning of featureExtensions.warnings) reporter.warn(warning);
|
|
182
|
+
}
|
|
183
|
+
|
|
169
184
|
if (config.extensions && config.extensions.length > 0) {
|
|
170
185
|
warnLegacyExtensions(loaded, reporter);
|
|
171
186
|
const legacy = await loadExtensions(config.extensions, {
|
|
@@ -70,7 +70,14 @@ function configure(command, spec) {
|
|
|
70
70
|
if (spec.group) command.helpGroup(spec.group);
|
|
71
71
|
for (const argument of spec.arguments ?? []) command.argument(argument);
|
|
72
72
|
for (const option of spec.options ?? []) {
|
|
73
|
-
if (option.
|
|
73
|
+
if (option.parser)
|
|
74
|
+
command.option(
|
|
75
|
+
option.flags,
|
|
76
|
+
option.description,
|
|
77
|
+
option.parser,
|
|
78
|
+
option.defaultValue,
|
|
79
|
+
);
|
|
80
|
+
else if (option.defaultValue === undefined)
|
|
74
81
|
command.option(option.flags, option.description);
|
|
75
82
|
else command.option(option.flags, option.description, option.defaultValue);
|
|
76
83
|
}
|
package/src/cli/reporter.js
CHANGED
|
@@ -151,6 +151,15 @@ export function createReporter({
|
|
|
151
151
|
stdout.write(`${message}\n`);
|
|
152
152
|
},
|
|
153
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Unmodified product output, such as formatted source. Quiet does not
|
|
156
|
+
* suppress it because it is the command's result; JSON mode does.
|
|
157
|
+
*/
|
|
158
|
+
raw(text) {
|
|
159
|
+
if (json) return;
|
|
160
|
+
stdout.write(String(text));
|
|
161
|
+
},
|
|
162
|
+
|
|
154
163
|
/** Quiet, de-emphasized line for incidental notes. */
|
|
155
164
|
note(message) {
|
|
156
165
|
if (!human) return;
|