@ekanos/cli 0.1.10 → 0.2.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/dist/banner.d.ts +5 -1
- package/dist/banner.js +7 -2
- package/dist/banner.js.map +1 -1
- package/dist/commands/init.d.ts +13 -1
- package/dist/commands/init.js +70 -13
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/login.d.ts +13 -0
- package/dist/commands/login.js +41 -10
- package/dist/commands/login.js.map +1 -1
- package/dist/commands/publish.js +55 -3
- package/dist/commands/publish.js.map +1 -1
- package/dist/commands/status.d.ts +68 -2
- package/dist/commands/status.js +312 -43
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/test.d.ts +27 -5
- package/dist/commands/test.js +85 -9
- package/dist/commands/test.js.map +1 -1
- package/dist/commands/validate.js +115 -3
- package/dist/commands/validate.js.map +1 -1
- package/dist/context.d.ts +37 -1
- package/dist/context.js +27 -1
- package/dist/context.js.map +1 -1
- package/dist/did-you-mean.d.ts +36 -0
- package/dist/did-you-mean.js +192 -0
- package/dist/did-you-mean.js.map +1 -0
- package/dist/findings-renderer.d.ts +59 -0
- package/dist/findings-renderer.js +271 -0
- package/dist/findings-renderer.js.map +1 -0
- package/dist/index.d.ts +11 -4
- package/dist/index.js +23 -5
- package/dist/index.js.map +1 -1
- package/dist/project-checks.d.ts +30 -0
- package/dist/project-checks.js +162 -0
- package/dist/project-checks.js.map +1 -1
- package/dist/publish-api.d.ts +13 -0
- package/dist/publish-api.js.map +1 -1
- package/dist/source-locator.d.ts +67 -0
- package/dist/source-locator.js +284 -0
- package/dist/source-locator.js.map +1 -0
- package/dist/test-report.d.ts +32 -0
- package/dist/test-report.js +255 -0
- package/dist/test-report.js.map +1 -0
- package/dist/ui.d.ts +75 -0
- package/dist/ui.js +132 -0
- package/dist/ui.js.map +1 -0
- package/dist/validate-findings.d.ts +22 -0
- package/dist/validate-findings.js +57 -1
- package/dist/validate-findings.js.map +1 -1
- package/dist/wizard.d.ts +80 -0
- package/dist/wizard.js +74 -0
- package/dist/wizard.js.map +1 -0
- package/package.json +5 -3
- package/templates/package.json.tmpl +3 -1
- package/templates/scripts/setup-hooks.mjs.tmpl +45 -0
- package/templates/tsconfig.json.tmpl +2 -0
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import * as crypto from 'node:crypto';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as os from 'node:os';
|
|
4
|
+
import * as path from 'node:path';
|
|
5
|
+
/**
|
|
6
|
+
* Parses vitest's `--reporter=json` output into the curated-failures
|
|
7
|
+
* appendix `ekanos test` renders locally, giving a partner the same
|
|
8
|
+
* per-test intelligence the server-side gate already has.
|
|
9
|
+
*
|
|
10
|
+
* This is a DELIBERATE, small duplication of the parsing the host's own
|
|
11
|
+
* gate uses to grade a submission's test run — not an import. `@ekanos/
|
|
12
|
+
* checks` is never published and never a dependency of a partner's project
|
|
13
|
+
* or of this CLI; keep the two in sync by eye if vitest's JSON reporter
|
|
14
|
+
* shape ever changes.
|
|
15
|
+
*
|
|
16
|
+
* Every entry point here is defensive end to end: a missing report file,
|
|
17
|
+
* malformed JSON, or an unrecognized shape all resolve to `null` rather than
|
|
18
|
+
* throwing. A partner's toolchain misbehaving must never crash `ekanos
|
|
19
|
+
* test` — it can only mean the curated appendix is unavailable, with the
|
|
20
|
+
* command falling back to exactly today's raw-passthrough behavior.
|
|
21
|
+
*/
|
|
22
|
+
/** Cap on assertion-diff lines shown per finding — see `extractDiff`. */
|
|
23
|
+
const MAX_DIFF_LINES = 10;
|
|
24
|
+
function isRecord(value) {
|
|
25
|
+
return typeof value === 'object' && value !== null;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Whether the project's OWN `test` script names vitest — the one signal
|
|
29
|
+
* used to decide whether to inject the JSON reporter at all. Deliberately
|
|
30
|
+
* conservative: a jest project, a custom node runner, or a shell wrapper is
|
|
31
|
+
* left completely alone, with no reporter flags appended and no report
|
|
32
|
+
* parsed — `ekanos test` behaves exactly as it did before this feature.
|
|
33
|
+
*/
|
|
34
|
+
export function projectTestScriptUsesVitest(projectDir) {
|
|
35
|
+
const manifestPath = path.join(projectDir, 'package.json');
|
|
36
|
+
try {
|
|
37
|
+
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
38
|
+
if (!isRecord(manifest) || !isRecord(manifest.scripts))
|
|
39
|
+
return false;
|
|
40
|
+
const script = manifest.scripts.test;
|
|
41
|
+
return typeof script === 'string' && script.includes('vitest');
|
|
42
|
+
}
|
|
43
|
+
catch (_a) {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** A fresh, unique path for the JSON report — one per invocation. The caller owns cleanup. */
|
|
48
|
+
export function newReportPath() {
|
|
49
|
+
return path.join(os.tmpdir(), `ekanos-test-vitest-${crypto.randomUUID()}.json`);
|
|
50
|
+
}
|
|
51
|
+
/** The extra argv that makes vitest ALSO emit a JSON report alongside its normal terminal output. */
|
|
52
|
+
export function reporterArgs(outputFilePath) {
|
|
53
|
+
return [
|
|
54
|
+
'--reporter=default',
|
|
55
|
+
'--reporter=json',
|
|
56
|
+
`--outputFile=${outputFilePath}`,
|
|
57
|
+
];
|
|
58
|
+
}
|
|
59
|
+
/** The first non-empty line of a vitest failure message/stack — the
|
|
60
|
+
* assertion or thrown error itself, not the frames beneath it. */
|
|
61
|
+
function firstMeaningfulLine(text) {
|
|
62
|
+
const line = text
|
|
63
|
+
.split('\n')
|
|
64
|
+
.map((entry) => entry.trim())
|
|
65
|
+
.find((entry) => entry.length > 0);
|
|
66
|
+
return line !== null && line !== void 0 ? line : 'Test failed with no error message.';
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Vitest's failure stacks always include the absolute test file path at
|
|
70
|
+
* least once — at the failure site itself — with the frames beneath it
|
|
71
|
+
* being the runner's own internals. The FIRST occurrence is the line that
|
|
72
|
+
* matters.
|
|
73
|
+
*/
|
|
74
|
+
function lineForFile(stack, absoluteFile) {
|
|
75
|
+
const escaped = absoluteFile.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
76
|
+
const match = new RegExp(`${escaped}:(\\d+):\\d+`).exec(stack);
|
|
77
|
+
return match ? Number(match[1]) : undefined;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Up to `MAX_DIFF_LINES` lines of an expected/received diff block from one
|
|
81
|
+
* vitest failure message, or `null` when the message doesn't carry one.
|
|
82
|
+
*
|
|
83
|
+
* NOTE (found via manual end-to-end verification, not a hypothetical):
|
|
84
|
+
* vitest's OWN `toBe`/`toEqual`-family matchers never put this in the JSON
|
|
85
|
+
* reporter's `failureMessages` — the pretty "- Expected / + Received" block
|
|
86
|
+
* is a terminal-only rendering vitest's default reporter computes live from
|
|
87
|
+
* the error's `expected`/`actual`, and is never serialized into the report
|
|
88
|
+
* (checked vitest 3.2.7 and 4.1.10; `message`/`stack` is all that ships).
|
|
89
|
+
* This function still earns its place: a thrown `AssertionError` whose
|
|
90
|
+
* `.message` already embeds diff-formatted text — a custom matcher, a
|
|
91
|
+
* different assertion library layered on top of vitest, a hand-built
|
|
92
|
+
* `expect.extend` — DOES carry it verbatim in `failureMessages`, and this is
|
|
93
|
+
* what picks it up. It is intentionally a no-op for the common case rather
|
|
94
|
+
* than reconstructing a diff ourselves (a real diff needs the actual/expected
|
|
95
|
+
* values, which the JSON reporter doesn't expose, and computing one would
|
|
96
|
+
* mean a new dependency, and this package deliberately keeps its runtime
|
|
97
|
+
* dependency set to picocolors + @clack/prompts only).
|
|
98
|
+
*
|
|
99
|
+
* The diff sits between the failure's own title line and the stack frames
|
|
100
|
+
* (`at ...`) beneath it; within that span, only unified-diff-style `-`/`+`
|
|
101
|
+
* lines are kept — the jest-diff format such a message would use.
|
|
102
|
+
*/
|
|
103
|
+
function extractDiff(failureMessage) {
|
|
104
|
+
const lines = failureMessage
|
|
105
|
+
.split('\n')
|
|
106
|
+
.map((line) => line.replace(/\r$/, ''));
|
|
107
|
+
const stackStart = lines.findIndex((line) => /^\s*at\s/.test(line));
|
|
108
|
+
const body = stackStart === -1 ? lines.slice(1) : lines.slice(1, stackStart);
|
|
109
|
+
const diffLines = body.filter((line) => /^\s*[-+]/.test(line) &&
|
|
110
|
+
!line.startsWith('---') &&
|
|
111
|
+
!line.startsWith('+++'));
|
|
112
|
+
if (diffLines.length === 0)
|
|
113
|
+
return null;
|
|
114
|
+
const capped = diffLines.slice(0, MAX_DIFF_LINES);
|
|
115
|
+
const overflow = diffLines.length - capped.length;
|
|
116
|
+
if (overflow > 0) {
|
|
117
|
+
capped.push(`… and ${overflow} more diff line${overflow === 1 ? '' : 's'}`);
|
|
118
|
+
}
|
|
119
|
+
return capped.join('\n');
|
|
120
|
+
}
|
|
121
|
+
/** `path.relative` against the project root, falling back to the raw
|
|
122
|
+
* absolute path if the two disagree on realpath-vs-given-path (a symlinked
|
|
123
|
+
* temp dir resolving differently between this process and the child)
|
|
124
|
+
* rather than emitting a `../../../`-laden mess. */
|
|
125
|
+
function archiveRelative(projectDir, absoluteFile) {
|
|
126
|
+
const direct = path.relative(projectDir, absoluteFile);
|
|
127
|
+
if (!direct.startsWith('..'))
|
|
128
|
+
return direct;
|
|
129
|
+
try {
|
|
130
|
+
const realProjectDir = fs.realpathSync(projectDir);
|
|
131
|
+
const viaRealpath = path.relative(realProjectDir, absoluteFile);
|
|
132
|
+
if (!viaRealpath.startsWith('..'))
|
|
133
|
+
return viaRealpath;
|
|
134
|
+
}
|
|
135
|
+
catch (_a) {
|
|
136
|
+
// realpathSync failing (e.g. the directory was already torn down) just
|
|
137
|
+
// means we fall through to the direct (if imperfect) relative path.
|
|
138
|
+
}
|
|
139
|
+
return direct;
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Turns a parsed `--reporter=json` report into one `ParsedFailure` per
|
|
143
|
+
* failed test, across every test file in the run. Returns `null` — not
|
|
144
|
+
* `[]` — for anything that isn't recognizably a vitest JSON report at all,
|
|
145
|
+
* so the caller can tell "a real report with zero structured failures"
|
|
146
|
+
* apart from "not a report" (the latter falls back to the raw runner
|
|
147
|
+
* output; the former is a genuinely clean, if unexpected, run).
|
|
148
|
+
*/
|
|
149
|
+
function extractFailures(report) {
|
|
150
|
+
var _a;
|
|
151
|
+
if (!isRecord(report) || !Array.isArray(report.testResults))
|
|
152
|
+
return null;
|
|
153
|
+
const failures = [];
|
|
154
|
+
for (const fileResult of report.testResults) {
|
|
155
|
+
if (!isRecord(fileResult))
|
|
156
|
+
continue;
|
|
157
|
+
const file = fileResult.name;
|
|
158
|
+
const assertionResults = fileResult.assertionResults;
|
|
159
|
+
if (typeof file !== 'string' || !Array.isArray(assertionResults))
|
|
160
|
+
continue;
|
|
161
|
+
for (const assertion of assertionResults) {
|
|
162
|
+
if (!isRecord(assertion) || assertion.status !== 'failed')
|
|
163
|
+
continue;
|
|
164
|
+
const ancestorTitles = Array.isArray(assertion.ancestorTitles)
|
|
165
|
+
? assertion.ancestorTitles.filter((entry) => typeof entry === 'string')
|
|
166
|
+
: [];
|
|
167
|
+
const title = typeof assertion.title === 'string'
|
|
168
|
+
? assertion.title
|
|
169
|
+
: '(unnamed test)';
|
|
170
|
+
const failureMessages = Array.isArray(assertion.failureMessages)
|
|
171
|
+
? assertion.failureMessages.filter((entry) => typeof entry === 'string')
|
|
172
|
+
: [];
|
|
173
|
+
const firstError = (_a = failureMessages[0]) !== null && _a !== void 0 ? _a : '';
|
|
174
|
+
const failure = {
|
|
175
|
+
file,
|
|
176
|
+
suite: ancestorTitles.join(' > '),
|
|
177
|
+
title,
|
|
178
|
+
message: firstMeaningfulLine(firstError),
|
|
179
|
+
};
|
|
180
|
+
const line = lineForFile(firstError, file);
|
|
181
|
+
if (line !== undefined)
|
|
182
|
+
failure.line = line;
|
|
183
|
+
const detail = extractDiff(firstError);
|
|
184
|
+
if (detail !== null)
|
|
185
|
+
failure.detail = detail;
|
|
186
|
+
failures.push(failure);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return failures;
|
|
190
|
+
}
|
|
191
|
+
/** `null` unless the report carries vitest's own aggregate pass/fail counts. */
|
|
192
|
+
function extractSummary(report) {
|
|
193
|
+
if (!isRecord(report) || !Array.isArray(report.testResults))
|
|
194
|
+
return null;
|
|
195
|
+
const passed = report.numPassedTests;
|
|
196
|
+
const failed = report.numFailedTests;
|
|
197
|
+
if (typeof passed !== 'number' || typeof failed !== 'number')
|
|
198
|
+
return null;
|
|
199
|
+
return { passed, failed, files: report.testResults.length };
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Reads and parses `reportPath` end to end. `null` on ANY failure (missing
|
|
203
|
+
* file — vitest crashed before writing it; malformed JSON; a shape this
|
|
204
|
+
* function doesn't recognize) so the caller falls back to the raw runner
|
|
205
|
+
* output instead of the curated appendix, and never to a crash.
|
|
206
|
+
*/
|
|
207
|
+
export function parseTestReport(reportPath, projectDir) {
|
|
208
|
+
let raw;
|
|
209
|
+
try {
|
|
210
|
+
raw = fs.readFileSync(reportPath, 'utf8');
|
|
211
|
+
}
|
|
212
|
+
catch (_a) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
let report;
|
|
216
|
+
try {
|
|
217
|
+
report = JSON.parse(raw);
|
|
218
|
+
}
|
|
219
|
+
catch (_b) {
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
const failures = extractFailures(report);
|
|
223
|
+
if (failures === null)
|
|
224
|
+
return null;
|
|
225
|
+
const findings = [];
|
|
226
|
+
const failingFiles = [];
|
|
227
|
+
const seenFiles = new Set();
|
|
228
|
+
for (const failure of failures) {
|
|
229
|
+
const relFile = archiveRelative(projectDir, failure.file);
|
|
230
|
+
if (!seenFiles.has(relFile)) {
|
|
231
|
+
seenFiles.add(relFile);
|
|
232
|
+
failingFiles.push(relFile);
|
|
233
|
+
}
|
|
234
|
+
const label = failure.suite
|
|
235
|
+
? `${failure.suite} > ${failure.title}`
|
|
236
|
+
: failure.title;
|
|
237
|
+
const finding = {
|
|
238
|
+
// Grouping by test FILE (not by suite/title) is deliberate — the
|
|
239
|
+
// shared renderer groups by `check`, so this makes the group header
|
|
240
|
+
// show the file once with every failing test beneath it.
|
|
241
|
+
check: `tests/${relFile}`,
|
|
242
|
+
severity: 'error',
|
|
243
|
+
file: relFile,
|
|
244
|
+
message: `${label}: ${failure.message}`,
|
|
245
|
+
hint: 'Fix the failing assertion, then re-run "ekanos test".',
|
|
246
|
+
};
|
|
247
|
+
if (failure.line !== undefined)
|
|
248
|
+
finding.line = failure.line;
|
|
249
|
+
if (failure.detail !== undefined)
|
|
250
|
+
finding.detail = failure.detail;
|
|
251
|
+
findings.push(finding);
|
|
252
|
+
}
|
|
253
|
+
return { findings, failingFiles, summary: extractSummary(report) };
|
|
254
|
+
}
|
|
255
|
+
//# sourceMappingURL=test-report.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"test-report.js","sourceRoot":"","sources":["../src/test-report.ts"],"names":[],"mappings":"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","sourcesContent":["import * as crypto from 'node:crypto';\nimport * as fs from 'node:fs';\nimport * as os from 'node:os';\nimport * as path from 'node:path';\n\nimport type { RenderableFinding } from './findings-renderer';\n\n/**\n * Parses vitest's `--reporter=json` output into the curated-failures\n * appendix `ekanos test` renders locally, giving a partner the same\n * per-test intelligence the server-side gate already has.\n *\n * This is a DELIBERATE, small duplication of the parsing the host's own\n * gate uses to grade a submission's test run — not an import. `@ekanos/\n * checks` is never published and never a dependency of a partner's project\n * or of this CLI; keep the two in sync by eye if vitest's JSON reporter\n * shape ever changes.\n *\n * Every entry point here is defensive end to end: a missing report file,\n * malformed JSON, or an unrecognized shape all resolve to `null` rather than\n * throwing. A partner's toolchain misbehaving must never crash `ekanos\n * test` — it can only mean the curated appendix is unavailable, with the\n * command falling back to exactly today's raw-passthrough behavior.\n */\n\n/** Cap on assertion-diff lines shown per finding — see `extractDiff`. */\nconst MAX_DIFF_LINES = 10;\n\nexport interface TestSummary {\n passed: number;\n failed: number;\n files: number;\n}\n\nexport interface ParsedTestReport {\n /** One finding per failing test, grouped (via `check`) per test FILE. */\n findings: RenderableFinding[];\n /** Relative paths of files with at least one failure, first-seen order. */\n failingFiles: string[];\n summary: TestSummary | null;\n}\n\ninterface ParsedFailure {\n /** Absolute path, exactly as vitest reported it. */\n file: string;\n suite: string;\n title: string;\n message: string;\n line?: number;\n detail?: string;\n}\n\nfunction isRecord(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null;\n}\n\n/**\n * Whether the project's OWN `test` script names vitest — the one signal\n * used to decide whether to inject the JSON reporter at all. Deliberately\n * conservative: a jest project, a custom node runner, or a shell wrapper is\n * left completely alone, with no reporter flags appended and no report\n * parsed — `ekanos test` behaves exactly as it did before this feature.\n */\nexport function projectTestScriptUsesVitest(projectDir: string): boolean {\n const manifestPath = path.join(projectDir, 'package.json');\n\n try {\n const manifest: unknown = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));\n if (!isRecord(manifest) || !isRecord(manifest.scripts)) return false;\n\n const script = manifest.scripts.test;\n return typeof script === 'string' && script.includes('vitest');\n } catch {\n return false;\n }\n}\n\n/** A fresh, unique path for the JSON report — one per invocation. The caller owns cleanup. */\nexport function newReportPath(): string {\n return path.join(\n os.tmpdir(),\n `ekanos-test-vitest-${crypto.randomUUID()}.json`,\n );\n}\n\n/** The extra argv that makes vitest ALSO emit a JSON report alongside its normal terminal output. */\nexport function reporterArgs(outputFilePath: string): string[] {\n return [\n '--reporter=default',\n '--reporter=json',\n `--outputFile=${outputFilePath}`,\n ];\n}\n\n/** The first non-empty line of a vitest failure message/stack — the\n * assertion or thrown error itself, not the frames beneath it. */\nfunction firstMeaningfulLine(text: string): string {\n const line = text\n .split('\\n')\n .map((entry) => entry.trim())\n .find((entry) => entry.length > 0);\n return line ?? 'Test failed with no error message.';\n}\n\n/**\n * Vitest's failure stacks always include the absolute test file path at\n * least once — at the failure site itself — with the frames beneath it\n * being the runner's own internals. The FIRST occurrence is the line that\n * matters.\n */\nfunction lineForFile(stack: string, absoluteFile: string): number | undefined {\n const escaped = absoluteFile.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const match = new RegExp(`${escaped}:(\\\\d+):\\\\d+`).exec(stack);\n return match ? Number(match[1]) : undefined;\n}\n\n/**\n * Up to `MAX_DIFF_LINES` lines of an expected/received diff block from one\n * vitest failure message, or `null` when the message doesn't carry one.\n *\n * NOTE (found via manual end-to-end verification, not a hypothetical):\n * vitest's OWN `toBe`/`toEqual`-family matchers never put this in the JSON\n * reporter's `failureMessages` — the pretty \"- Expected / + Received\" block\n * is a terminal-only rendering vitest's default reporter computes live from\n * the error's `expected`/`actual`, and is never serialized into the report\n * (checked vitest 3.2.7 and 4.1.10; `message`/`stack` is all that ships).\n * This function still earns its place: a thrown `AssertionError` whose\n * `.message` already embeds diff-formatted text — a custom matcher, a\n * different assertion library layered on top of vitest, a hand-built\n * `expect.extend` — DOES carry it verbatim in `failureMessages`, and this is\n * what picks it up. It is intentionally a no-op for the common case rather\n * than reconstructing a diff ourselves (a real diff needs the actual/expected\n * values, which the JSON reporter doesn't expose, and computing one would\n * mean a new dependency, and this package deliberately keeps its runtime\n * dependency set to picocolors + @clack/prompts only).\n *\n * The diff sits between the failure's own title line and the stack frames\n * (`at ...`) beneath it; within that span, only unified-diff-style `-`/`+`\n * lines are kept — the jest-diff format such a message would use.\n */\nfunction extractDiff(failureMessage: string): string | null {\n const lines = failureMessage\n .split('\\n')\n .map((line) => line.replace(/\\r$/, ''));\n const stackStart = lines.findIndex((line) => /^\\s*at\\s/.test(line));\n const body = stackStart === -1 ? lines.slice(1) : lines.slice(1, stackStart);\n\n const diffLines = body.filter(\n (line) =>\n /^\\s*[-+]/.test(line) &&\n !line.startsWith('---') &&\n !line.startsWith('+++'),\n );\n if (diffLines.length === 0) return null;\n\n const capped = diffLines.slice(0, MAX_DIFF_LINES);\n const overflow = diffLines.length - capped.length;\n if (overflow > 0) {\n capped.push(`… and ${overflow} more diff line${overflow === 1 ? '' : 's'}`);\n }\n return capped.join('\\n');\n}\n\n/** `path.relative` against the project root, falling back to the raw\n * absolute path if the two disagree on realpath-vs-given-path (a symlinked\n * temp dir resolving differently between this process and the child)\n * rather than emitting a `../../../`-laden mess. */\nfunction archiveRelative(projectDir: string, absoluteFile: string): string {\n const direct = path.relative(projectDir, absoluteFile);\n if (!direct.startsWith('..')) return direct;\n try {\n const realProjectDir = fs.realpathSync(projectDir);\n const viaRealpath = path.relative(realProjectDir, absoluteFile);\n if (!viaRealpath.startsWith('..')) return viaRealpath;\n } catch {\n // realpathSync failing (e.g. the directory was already torn down) just\n // means we fall through to the direct (if imperfect) relative path.\n }\n return direct;\n}\n\n/**\n * Turns a parsed `--reporter=json` report into one `ParsedFailure` per\n * failed test, across every test file in the run. Returns `null` — not\n * `[]` — for anything that isn't recognizably a vitest JSON report at all,\n * so the caller can tell \"a real report with zero structured failures\"\n * apart from \"not a report\" (the latter falls back to the raw runner\n * output; the former is a genuinely clean, if unexpected, run).\n */\nfunction extractFailures(report: unknown): ParsedFailure[] | null {\n if (!isRecord(report) || !Array.isArray(report.testResults)) return null;\n\n const failures: ParsedFailure[] = [];\n for (const fileResult of report.testResults as unknown[]) {\n if (!isRecord(fileResult)) continue;\n const file = fileResult.name;\n const assertionResults = fileResult.assertionResults;\n if (typeof file !== 'string' || !Array.isArray(assertionResults)) continue;\n\n for (const assertion of assertionResults as unknown[]) {\n if (!isRecord(assertion) || assertion.status !== 'failed') continue;\n\n const ancestorTitles = Array.isArray(assertion.ancestorTitles)\n ? assertion.ancestorTitles.filter(\n (entry): entry is string => typeof entry === 'string',\n )\n : [];\n const title =\n typeof assertion.title === 'string'\n ? assertion.title\n : '(unnamed test)';\n const failureMessages = Array.isArray(assertion.failureMessages)\n ? assertion.failureMessages.filter(\n (entry): entry is string => typeof entry === 'string',\n )\n : [];\n const firstError = failureMessages[0] ?? '';\n\n const failure: ParsedFailure = {\n file,\n suite: ancestorTitles.join(' > '),\n title,\n message: firstMeaningfulLine(firstError),\n };\n const line = lineForFile(firstError, file);\n if (line !== undefined) failure.line = line;\n const detail = extractDiff(firstError);\n if (detail !== null) failure.detail = detail;\n\n failures.push(failure);\n }\n }\n return failures;\n}\n\n/** `null` unless the report carries vitest's own aggregate pass/fail counts. */\nfunction extractSummary(report: unknown): TestSummary | null {\n if (!isRecord(report) || !Array.isArray(report.testResults)) return null;\n\n const passed = report.numPassedTests;\n const failed = report.numFailedTests;\n if (typeof passed !== 'number' || typeof failed !== 'number') return null;\n\n return { passed, failed, files: report.testResults.length };\n}\n\n/**\n * Reads and parses `reportPath` end to end. `null` on ANY failure (missing\n * file — vitest crashed before writing it; malformed JSON; a shape this\n * function doesn't recognize) so the caller falls back to the raw runner\n * output instead of the curated appendix, and never to a crash.\n */\nexport function parseTestReport(\n reportPath: string,\n projectDir: string,\n): ParsedTestReport | null {\n let raw: string;\n try {\n raw = fs.readFileSync(reportPath, 'utf8');\n } catch {\n return null;\n }\n\n let report: unknown;\n try {\n report = JSON.parse(raw);\n } catch {\n return null;\n }\n\n const failures = extractFailures(report);\n if (failures === null) return null;\n\n const findings: RenderableFinding[] = [];\n const failingFiles: string[] = [];\n const seenFiles = new Set<string>();\n\n for (const failure of failures) {\n const relFile = archiveRelative(projectDir, failure.file);\n if (!seenFiles.has(relFile)) {\n seenFiles.add(relFile);\n failingFiles.push(relFile);\n }\n\n const label = failure.suite\n ? `${failure.suite} > ${failure.title}`\n : failure.title;\n const finding: RenderableFinding = {\n // Grouping by test FILE (not by suite/title) is deliberate — the\n // shared renderer groups by `check`, so this makes the group header\n // show the file once with every failing test beneath it.\n check: `tests/${relFile}`,\n severity: 'error',\n file: relFile,\n message: `${label}: ${failure.message}`,\n hint: 'Fix the failing assertion, then re-run \"ekanos test\".',\n };\n if (failure.line !== undefined) finding.line = failure.line;\n if (failure.detail !== undefined) finding.detail = failure.detail;\n findings.push(finding);\n }\n\n return { findings, failingFiles, summary: extractSummary(report) };\n}\n"]}
|
package/dist/ui.d.ts
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's sole style layer. Every color, glyph, and human-output component
|
|
3
|
+
* lives here — nothing else in this package may `import 'picocolors'` or
|
|
4
|
+
* write a raw ANSI escape (the gradient banner in `banner.ts` is the one
|
|
5
|
+
* documented exception: it paints a true-color gradient picocolors cannot
|
|
6
|
+
* express, but even it asks THIS module's `colorsEnabled` for the on/off
|
|
7
|
+
* decision, so there is exactly one gate).
|
|
8
|
+
*
|
|
9
|
+
* Everything here renders plain strings. Printing is still `CliContext`'s
|
|
10
|
+
* job (`ctx.log`) — this module never touches a stream directly, so the
|
|
11
|
+
* stdout-purity invariant in `context.ts` cannot be bypassed by a stray
|
|
12
|
+
* color call.
|
|
13
|
+
*/
|
|
14
|
+
/** What `colorsEnabled` needs to know to decide — never the real stream. */
|
|
15
|
+
export interface ColorProbe {
|
|
16
|
+
/** Whether the destination (stderr, always — see `context.ts`) is a TTY. */
|
|
17
|
+
isTTY: boolean;
|
|
18
|
+
env: Readonly<Record<string, string | undefined>>;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The one color-on/off decision for the whole CLI. True only when the
|
|
22
|
+
* stream is a real terminal AND `NO_COLOR` is unset AND `TERM` is not
|
|
23
|
+
* `"dumb"` — EXCEPT `FORCE_COLOR`, which overrides everything else on when
|
|
24
|
+
* set to anything other than `"0"` (matching the de facto convention: Node,
|
|
25
|
+
* chalk, and picocolors itself all treat a defined-and-nonzero `FORCE_COLOR`
|
|
26
|
+
* as "yes, even if you don't think you can").
|
|
27
|
+
*/
|
|
28
|
+
export declare function colorsEnabled(probe: ColorProbe): boolean;
|
|
29
|
+
export interface Glyphs {
|
|
30
|
+
ok: string;
|
|
31
|
+
err: string;
|
|
32
|
+
warn: string;
|
|
33
|
+
info: string;
|
|
34
|
+
}
|
|
35
|
+
/** One row of `ui.keyValue`. */
|
|
36
|
+
export type KeyValueRow = readonly [key: string, value: string];
|
|
37
|
+
export interface CodeframeOptions {
|
|
38
|
+
/** Not rendered directly — callers already print `file:line` above/around this. Kept for future use and self-documentation at call sites. */
|
|
39
|
+
file?: string;
|
|
40
|
+
/** 1-indexed line to highlight. */
|
|
41
|
+
line: number;
|
|
42
|
+
/** The full source text of `file`. */
|
|
43
|
+
source: string;
|
|
44
|
+
/** Lines of context above/below the target line. Defaults to 2. */
|
|
45
|
+
contextLines?: number;
|
|
46
|
+
}
|
|
47
|
+
export type BadgeKind = 'dim' | 'accent' | 'warn' | 'err';
|
|
48
|
+
export interface Ui {
|
|
49
|
+
readonly colors: boolean;
|
|
50
|
+
readonly glyphs: Glyphs;
|
|
51
|
+
err(text: string): string;
|
|
52
|
+
warn(text: string): string;
|
|
53
|
+
ok(text: string): string;
|
|
54
|
+
accent(text: string): string;
|
|
55
|
+
dim(text: string): string;
|
|
56
|
+
bold(text: string): string;
|
|
57
|
+
/** A dim, uppercased section header. */
|
|
58
|
+
section(title: string): string;
|
|
59
|
+
/** Aligned `key value` rows, 2-space gutter, keys dimmed. */
|
|
60
|
+
keyValue(rows: readonly KeyValueRow[]): string;
|
|
61
|
+
/** A header-having, left/right-aligned table with no box-drawing. */
|
|
62
|
+
table(headers: readonly string[], rows: readonly (readonly (string | number)[])[]): string;
|
|
63
|
+
/** A `→ ` prefixed, accent-colored hint line. */
|
|
64
|
+
hint(text: string): string;
|
|
65
|
+
/**
|
|
66
|
+
* THE closing line of any failure path: a blank line, then a dim
|
|
67
|
+
* `→ <why>: ` (or bare `→ `) followed by the bold remediation command.
|
|
68
|
+
*/
|
|
69
|
+
nextAction(cmd: string, why?: string): string;
|
|
70
|
+
/** A Prisma/Vite-style source snippet around one line. */
|
|
71
|
+
codeframe(opts: CodeframeOptions): string;
|
|
72
|
+
/** A bracketed, dim-by-default tag: `[gate · 2h ago]`. */
|
|
73
|
+
badge(text: string, kind?: BadgeKind): string;
|
|
74
|
+
}
|
|
75
|
+
export declare function createUi(probe: ColorProbe): Ui;
|
package/dist/ui.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import pc from 'picocolors';
|
|
2
|
+
/**
|
|
3
|
+
* The one color-on/off decision for the whole CLI. True only when the
|
|
4
|
+
* stream is a real terminal AND `NO_COLOR` is unset AND `TERM` is not
|
|
5
|
+
* `"dumb"` — EXCEPT `FORCE_COLOR`, which overrides everything else on when
|
|
6
|
+
* set to anything other than `"0"` (matching the de facto convention: Node,
|
|
7
|
+
* chalk, and picocolors itself all treat a defined-and-nonzero `FORCE_COLOR`
|
|
8
|
+
* as "yes, even if you don't think you can").
|
|
9
|
+
*/
|
|
10
|
+
export function colorsEnabled(probe) {
|
|
11
|
+
const { env } = probe;
|
|
12
|
+
if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0')
|
|
13
|
+
return true;
|
|
14
|
+
if (env.NO_COLOR !== undefined)
|
|
15
|
+
return false;
|
|
16
|
+
if (env.TERM === 'dumb')
|
|
17
|
+
return false;
|
|
18
|
+
return probe.isTTY;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Whether the destination can render the box-drawing/Unicode glyphs this
|
|
22
|
+
* module prefers. Deliberately simple: a "dumb" terminal never can, and a
|
|
23
|
+
* locale that names a non-UTF-8 charset can't be trusted to either. Anything
|
|
24
|
+
* else — including an unset locale, which is the common case for CI/agent
|
|
25
|
+
* environments — is assumed capable rather than guessed at further.
|
|
26
|
+
*/
|
|
27
|
+
function unicodeSupported(env) {
|
|
28
|
+
var _a, _b, _c;
|
|
29
|
+
if (env.TERM === 'dumb')
|
|
30
|
+
return false;
|
|
31
|
+
const locale = (_c = (_b = (_a = env.LANG) !== null && _a !== void 0 ? _a : env.LC_ALL) !== null && _b !== void 0 ? _b : env.LC_CTYPE) !== null && _c !== void 0 ? _c : '';
|
|
32
|
+
if (locale && !/utf-?8/i.test(locale))
|
|
33
|
+
return false;
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
const UNICODE_GLYPHS = { ok: '✓', err: '✖', warn: '▲', info: '●' };
|
|
37
|
+
const ASCII_GLYPHS = { ok: 'ok', err: 'x', warn: '!', info: '*' };
|
|
38
|
+
/** Right-pad (left-align) a cell to `width`, ignoring color codes in length math is the CALLER's job — pad before coloring, never after. */
|
|
39
|
+
function padCell(text, width, align) {
|
|
40
|
+
return align === 'right' ? text.padStart(width) : text.padEnd(width);
|
|
41
|
+
}
|
|
42
|
+
export function createUi(probe) {
|
|
43
|
+
const colors = colorsEnabled(probe);
|
|
44
|
+
const c = pc.createColors(colors);
|
|
45
|
+
const glyphs = unicodeSupported(probe.env) ? UNICODE_GLYPHS : ASCII_GLYPHS;
|
|
46
|
+
const err = (text) => c.red(text);
|
|
47
|
+
const warn = (text) => c.yellow(text);
|
|
48
|
+
const ok = (text) => c.green(text);
|
|
49
|
+
const accent = (text) => c.cyan(text);
|
|
50
|
+
const dim = (text) => c.dim(text);
|
|
51
|
+
const bold = (text) => c.bold(text);
|
|
52
|
+
return {
|
|
53
|
+
colors,
|
|
54
|
+
glyphs,
|
|
55
|
+
err,
|
|
56
|
+
warn,
|
|
57
|
+
ok,
|
|
58
|
+
accent,
|
|
59
|
+
dim,
|
|
60
|
+
bold,
|
|
61
|
+
section(title) {
|
|
62
|
+
return dim(title.toUpperCase());
|
|
63
|
+
},
|
|
64
|
+
keyValue(rows) {
|
|
65
|
+
const keyWidth = Math.max(0, ...rows.map(([key]) => key.length));
|
|
66
|
+
return rows
|
|
67
|
+
.map(([key, value]) => `${dim(key.padEnd(keyWidth))} ${value}`)
|
|
68
|
+
.join('\n');
|
|
69
|
+
},
|
|
70
|
+
table(headers, rows) {
|
|
71
|
+
const columnCount = headers.length;
|
|
72
|
+
const isNumeric = (value) => typeof value === 'number' ||
|
|
73
|
+
(value.trim() !== '' && !Number.isNaN(Number(value)));
|
|
74
|
+
const aligns = Array.from({ length: columnCount }, (_, col) => rows.length > 0 && rows.every((row) => { var _a; return isNumeric((_a = row[col]) !== null && _a !== void 0 ? _a : ''); })
|
|
75
|
+
? 'right'
|
|
76
|
+
: 'left');
|
|
77
|
+
const widths = Array.from({ length: columnCount }, (_, col) => {
|
|
78
|
+
var _a, _b;
|
|
79
|
+
return Math.max((_b = (_a = headers[col]) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : 0, ...rows.map((row) => { var _a; return String((_a = row[col]) !== null && _a !== void 0 ? _a : '').length; }));
|
|
80
|
+
});
|
|
81
|
+
const headerLine = headers
|
|
82
|
+
.map((header, col) => {
|
|
83
|
+
var _a, _b;
|
|
84
|
+
return dim(padCell(header.toUpperCase(), (_a = widths[col]) !== null && _a !== void 0 ? _a : 0, (_b = aligns[col]) !== null && _b !== void 0 ? _b : 'left'));
|
|
85
|
+
})
|
|
86
|
+
.join(' ');
|
|
87
|
+
const bodyLines = rows.map((row) => row
|
|
88
|
+
.map((cell, col) => { var _a, _b; return padCell(String(cell), (_a = widths[col]) !== null && _a !== void 0 ? _a : 0, (_b = aligns[col]) !== null && _b !== void 0 ? _b : 'left'); })
|
|
89
|
+
.join(' '));
|
|
90
|
+
return [headerLine, ...bodyLines].join('\n');
|
|
91
|
+
},
|
|
92
|
+
hint(text) {
|
|
93
|
+
return accent(`→ ${text}`);
|
|
94
|
+
},
|
|
95
|
+
nextAction(cmd, why) {
|
|
96
|
+
const prefix = why ? `→ ${why}: ` : '→ ';
|
|
97
|
+
return `\n${dim(prefix)}${bold(cmd)}`;
|
|
98
|
+
},
|
|
99
|
+
codeframe(opts) {
|
|
100
|
+
var _a, _b;
|
|
101
|
+
const contextLines = (_a = opts.contextLines) !== null && _a !== void 0 ? _a : 2;
|
|
102
|
+
const lines = opts.source.split('\n');
|
|
103
|
+
const start = Math.max(1, opts.line - contextLines);
|
|
104
|
+
const end = Math.min(lines.length, opts.line + contextLines);
|
|
105
|
+
const gutterWidth = String(end).length;
|
|
106
|
+
const rows = [];
|
|
107
|
+
for (let n = start; n <= end; n += 1) {
|
|
108
|
+
const isTarget = n === opts.line;
|
|
109
|
+
const text = (_b = lines[n - 1]) !== null && _b !== void 0 ? _b : '';
|
|
110
|
+
const marker = isTarget ? '>' : ' ';
|
|
111
|
+
const lineNo = String(n).padStart(gutterWidth);
|
|
112
|
+
const rendered = isTarget ? bold(text) : text;
|
|
113
|
+
rows.push(`${isTarget ? accent(marker) : marker} ${dim(lineNo)} ${dim('│')} ${rendered}`);
|
|
114
|
+
}
|
|
115
|
+
return rows.join('\n');
|
|
116
|
+
},
|
|
117
|
+
badge(text, kind = 'dim') {
|
|
118
|
+
const wrapped = `[${text}]`;
|
|
119
|
+
switch (kind) {
|
|
120
|
+
case 'err':
|
|
121
|
+
return err(wrapped);
|
|
122
|
+
case 'warn':
|
|
123
|
+
return warn(wrapped);
|
|
124
|
+
case 'accent':
|
|
125
|
+
return accent(wrapped);
|
|
126
|
+
default:
|
|
127
|
+
return dim(wrapped);
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
//# sourceMappingURL=ui.js.map
|
package/dist/ui.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"ui.js","sourceRoot":"","sources":["../src/ui.ts"],"names":[],"mappings":"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","sourcesContent":["import pc from 'picocolors';\n\n/**\n * The CLI's sole style layer. Every color, glyph, and human-output component\n * lives here — nothing else in this package may `import 'picocolors'` or\n * write a raw ANSI escape (the gradient banner in `banner.ts` is the one\n * documented exception: it paints a true-color gradient picocolors cannot\n * express, but even it asks THIS module's `colorsEnabled` for the on/off\n * decision, so there is exactly one gate).\n *\n * Everything here renders plain strings. Printing is still `CliContext`'s\n * job (`ctx.log`) — this module never touches a stream directly, so the\n * stdout-purity invariant in `context.ts` cannot be bypassed by a stray\n * color call.\n */\n\n/** What `colorsEnabled` needs to know to decide — never the real stream. */\nexport interface ColorProbe {\n /** Whether the destination (stderr, always — see `context.ts`) is a TTY. */\n isTTY: boolean;\n env: Readonly<Record<string, string | undefined>>;\n}\n\n/**\n * The one color-on/off decision for the whole CLI. True only when the\n * stream is a real terminal AND `NO_COLOR` is unset AND `TERM` is not\n * `\"dumb\"` — EXCEPT `FORCE_COLOR`, which overrides everything else on when\n * set to anything other than `\"0\"` (matching the de facto convention: Node,\n * chalk, and picocolors itself all treat a defined-and-nonzero `FORCE_COLOR`\n * as \"yes, even if you don't think you can\").\n */\nexport function colorsEnabled(probe: ColorProbe): boolean {\n const { env } = probe;\n\n if (env.FORCE_COLOR !== undefined && env.FORCE_COLOR !== '0') return true;\n if (env.NO_COLOR !== undefined) return false;\n if (env.TERM === 'dumb') return false;\n\n return probe.isTTY;\n}\n\n/**\n * Whether the destination can render the box-drawing/Unicode glyphs this\n * module prefers. Deliberately simple: a \"dumb\" terminal never can, and a\n * locale that names a non-UTF-8 charset can't be trusted to either. Anything\n * else — including an unset locale, which is the common case for CI/agent\n * environments — is assumed capable rather than guessed at further.\n */\nfunction unicodeSupported(\n env: Readonly<Record<string, string | undefined>>,\n): boolean {\n if (env.TERM === 'dumb') return false;\n\n const locale = env.LANG ?? env.LC_ALL ?? env.LC_CTYPE ?? '';\n if (locale && !/utf-?8/i.test(locale)) return false;\n\n return true;\n}\n\nexport interface Glyphs {\n ok: string;\n err: string;\n warn: string;\n info: string;\n}\n\nconst UNICODE_GLYPHS: Glyphs = { ok: '✓', err: '✖', warn: '▲', info: '●' };\nconst ASCII_GLYPHS: Glyphs = { ok: 'ok', err: 'x', warn: '!', info: '*' };\n\n/** One row of `ui.keyValue`. */\nexport type KeyValueRow = readonly [key: string, value: string];\n\nexport interface CodeframeOptions {\n /** Not rendered directly — callers already print `file:line` above/around this. Kept for future use and self-documentation at call sites. */\n file?: string;\n /** 1-indexed line to highlight. */\n line: number;\n /** The full source text of `file`. */\n source: string;\n /** Lines of context above/below the target line. Defaults to 2. */\n contextLines?: number;\n}\n\nexport type BadgeKind = 'dim' | 'accent' | 'warn' | 'err';\n\nexport interface Ui {\n readonly colors: boolean;\n readonly glyphs: Glyphs;\n\n err(text: string): string;\n warn(text: string): string;\n ok(text: string): string;\n accent(text: string): string;\n dim(text: string): string;\n bold(text: string): string;\n\n /** A dim, uppercased section header. */\n section(title: string): string;\n /** Aligned `key value` rows, 2-space gutter, keys dimmed. */\n keyValue(rows: readonly KeyValueRow[]): string;\n /** A header-having, left/right-aligned table with no box-drawing. */\n table(\n headers: readonly string[],\n rows: readonly (readonly (string | number)[])[],\n ): string;\n /** A `→ ` prefixed, accent-colored hint line. */\n hint(text: string): string;\n /**\n * THE closing line of any failure path: a blank line, then a dim\n * `→ <why>: ` (or bare `→ `) followed by the bold remediation command.\n */\n nextAction(cmd: string, why?: string): string;\n /** A Prisma/Vite-style source snippet around one line. */\n codeframe(opts: CodeframeOptions): string;\n /** A bracketed, dim-by-default tag: `[gate · 2h ago]`. */\n badge(text: string, kind?: BadgeKind): string;\n}\n\n/** Right-pad (left-align) a cell to `width`, ignoring color codes in length math is the CALLER's job — pad before coloring, never after. */\nfunction padCell(text: string, width: number, align: 'left' | 'right'): string {\n return align === 'right' ? text.padStart(width) : text.padEnd(width);\n}\n\nexport function createUi(probe: ColorProbe): Ui {\n const colors = colorsEnabled(probe);\n const c = pc.createColors(colors);\n const glyphs = unicodeSupported(probe.env) ? UNICODE_GLYPHS : ASCII_GLYPHS;\n\n const err = (text: string) => c.red(text);\n const warn = (text: string) => c.yellow(text);\n const ok = (text: string) => c.green(text);\n const accent = (text: string) => c.cyan(text);\n const dim = (text: string) => c.dim(text);\n const bold = (text: string) => c.bold(text);\n\n return {\n colors,\n glyphs,\n err,\n warn,\n ok,\n accent,\n dim,\n bold,\n\n section(title: string): string {\n return dim(title.toUpperCase());\n },\n\n keyValue(rows: readonly KeyValueRow[]): string {\n const keyWidth = Math.max(0, ...rows.map(([key]) => key.length));\n\n return rows\n .map(([key, value]) => `${dim(key.padEnd(keyWidth))} ${value}`)\n .join('\\n');\n },\n\n table(\n headers: readonly string[],\n rows: readonly (readonly (string | number)[])[],\n ): string {\n const columnCount = headers.length;\n const isNumeric = (value: string | number) =>\n typeof value === 'number' ||\n (value.trim() !== '' && !Number.isNaN(Number(value)));\n\n const aligns: Array<'left' | 'right'> = Array.from(\n { length: columnCount },\n (_, col) =>\n rows.length > 0 && rows.every((row) => isNumeric(row[col] ?? ''))\n ? 'right'\n : 'left',\n );\n\n const widths = Array.from({ length: columnCount }, (_, col) =>\n Math.max(\n headers[col]?.length ?? 0,\n ...rows.map((row) => String(row[col] ?? '').length),\n ),\n );\n\n const headerLine = headers\n .map((header, col) =>\n dim(\n padCell(\n header.toUpperCase(),\n widths[col] ?? 0,\n aligns[col] ?? 'left',\n ),\n ),\n )\n .join(' ');\n\n const bodyLines = rows.map((row) =>\n row\n .map((cell, col) =>\n padCell(String(cell), widths[col] ?? 0, aligns[col] ?? 'left'),\n )\n .join(' '),\n );\n\n return [headerLine, ...bodyLines].join('\\n');\n },\n\n hint(text: string): string {\n return accent(`→ ${text}`);\n },\n\n nextAction(cmd: string, why?: string): string {\n const prefix = why ? `→ ${why}: ` : '→ ';\n return `\\n${dim(prefix)}${bold(cmd)}`;\n },\n\n codeframe(opts: CodeframeOptions): string {\n const contextLines = opts.contextLines ?? 2;\n const lines = opts.source.split('\\n');\n const start = Math.max(1, opts.line - contextLines);\n const end = Math.min(lines.length, opts.line + contextLines);\n const gutterWidth = String(end).length;\n\n const rows: string[] = [];\n for (let n = start; n <= end; n += 1) {\n const isTarget = n === opts.line;\n const text = lines[n - 1] ?? '';\n const marker = isTarget ? '>' : ' ';\n const lineNo = String(n).padStart(gutterWidth);\n const rendered = isTarget ? bold(text) : text;\n\n rows.push(\n `${isTarget ? accent(marker) : marker} ${dim(lineNo)} ${dim('│')} ${rendered}`,\n );\n }\n\n return rows.join('\\n');\n },\n\n badge(text: string, kind: BadgeKind = 'dim'): string {\n const wrapped = `[${text}]`;\n\n switch (kind) {\n case 'err':\n return err(wrapped);\n case 'warn':\n return warn(wrapped);\n case 'accent':\n return accent(wrapped);\n default:\n return dim(wrapped);\n }\n },\n };\n}\n"]}
|
|
@@ -20,3 +20,25 @@ import type { LoadedProject } from './project.js';
|
|
|
20
20
|
* it, not tests that fake their own reachability.
|
|
21
21
|
*/
|
|
22
22
|
export declare function collectAllFindings(loaded: LoadedProject): Promise<Finding[]>;
|
|
23
|
+
/**
|
|
24
|
+
* `package.json`'s `version` field and the `version` `defineIntegration()`
|
|
25
|
+
* declares are two more independent sources of truth for the same fact —
|
|
26
|
+
* this project shipped weather@0.1.1 with a package.json bump, but the
|
|
27
|
+
* declaration itself still said 0.1.0. Generated seeds embed the
|
|
28
|
+
* DECLARATION's version in their header, so a stale declaration produces a
|
|
29
|
+
* seed that looks stale even though the package itself moved on.
|
|
30
|
+
*
|
|
31
|
+
* WARN, not error, unlike slug agreement: a mismatched version is a quality
|
|
32
|
+
* signal a partner should fix, not a broken cross-reference that breaks
|
|
33
|
+
* something else at runtime, so it must not block `publish`. And a project
|
|
34
|
+
* with no package.json, or a package.json with no `version` field, has
|
|
35
|
+
* nothing to compare the declaration against — skip rather than warn on
|
|
36
|
+
* absence.
|
|
37
|
+
*/
|
|
38
|
+
export declare function collectVersionAgreementFindings(integration: {
|
|
39
|
+
slug: string;
|
|
40
|
+
entry: string;
|
|
41
|
+
entryPath: string;
|
|
42
|
+
}, definition: {
|
|
43
|
+
version?: unknown;
|
|
44
|
+
}, projectDir: string): Finding[];
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { collectCollisionFindings, collectDefinitionFindings, } from '@ekanos/integration-schema';
|
|
2
|
+
import * as fs from 'node:fs';
|
|
3
|
+
import * as path from 'node:path';
|
|
2
4
|
import { preconditionError } from './errors.js';
|
|
3
5
|
import { collectIconFindings } from './icon-findings.js';
|
|
4
6
|
import { loadDefinition } from './load-definition.js';
|
|
5
|
-
import { collectDocsFindings, collectProjectFindings } from './project-checks.js';
|
|
7
|
+
import { collectDocsFindings, collectHostCompilerRulesFindings, collectProjectFindings, } from './project-checks.js';
|
|
6
8
|
/**
|
|
7
9
|
* The full findings pass shared by `validate` (which reports them) and
|
|
8
10
|
* `publish` (which refuses to submit on any error-severity finding). One
|
|
@@ -32,6 +34,7 @@ export async function collectAllFindings(loaded) {
|
|
|
32
34
|
file: integration.entryPath,
|
|
33
35
|
}));
|
|
34
36
|
findings.push(...collectSlugAgreementFindings(integration, result.definition));
|
|
37
|
+
findings.push(...collectVersionAgreementFindings(integration, result.definition, loaded.projectDir));
|
|
35
38
|
definitions.push(result.definition);
|
|
36
39
|
continue;
|
|
37
40
|
}
|
|
@@ -58,6 +61,7 @@ export async function collectAllFindings(loaded) {
|
|
|
58
61
|
findings.push(...collectProjectFindings(loaded.projectDir));
|
|
59
62
|
findings.push(...collectDocsFindings(loaded.project, loaded.projectDir));
|
|
60
63
|
findings.push(...collectIconFindings(loaded.projectDir));
|
|
64
|
+
findings.push(...collectHostCompilerRulesFindings(loaded.projectDir));
|
|
61
65
|
return findings;
|
|
62
66
|
}
|
|
63
67
|
/**
|
|
@@ -90,4 +94,56 @@ function collectSlugAgreementFindings(integration, definition) {
|
|
|
90
94
|
},
|
|
91
95
|
];
|
|
92
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* `package.json`'s `version` field and the `version` `defineIntegration()`
|
|
99
|
+
* declares are two more independent sources of truth for the same fact —
|
|
100
|
+
* this project shipped weather@0.1.1 with a package.json bump, but the
|
|
101
|
+
* declaration itself still said 0.1.0. Generated seeds embed the
|
|
102
|
+
* DECLARATION's version in their header, so a stale declaration produces a
|
|
103
|
+
* seed that looks stale even though the package itself moved on.
|
|
104
|
+
*
|
|
105
|
+
* WARN, not error, unlike slug agreement: a mismatched version is a quality
|
|
106
|
+
* signal a partner should fix, not a broken cross-reference that breaks
|
|
107
|
+
* something else at runtime, so it must not block `publish`. And a project
|
|
108
|
+
* with no package.json, or a package.json with no `version` field, has
|
|
109
|
+
* nothing to compare the declaration against — skip rather than warn on
|
|
110
|
+
* absence.
|
|
111
|
+
*/
|
|
112
|
+
export function collectVersionAgreementFindings(integration, definition, projectDir) {
|
|
113
|
+
const declared = definition.version;
|
|
114
|
+
if (typeof declared !== 'string')
|
|
115
|
+
return [];
|
|
116
|
+
const manifestPath = path.join(projectDir, 'package.json');
|
|
117
|
+
if (!fs.existsSync(manifestPath))
|
|
118
|
+
return [];
|
|
119
|
+
let manifest;
|
|
120
|
+
try {
|
|
121
|
+
manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
|
122
|
+
}
|
|
123
|
+
catch (_a) {
|
|
124
|
+
// Malformed package.json is npm's problem to report, not a validate
|
|
125
|
+
// finding — nothing here can compare against an unparseable manifest.
|
|
126
|
+
return [];
|
|
127
|
+
}
|
|
128
|
+
const packageVersion = typeof manifest === 'object' && manifest !== null
|
|
129
|
+
? manifest.version
|
|
130
|
+
: undefined;
|
|
131
|
+
if (typeof packageVersion !== 'string' || packageVersion === declared) {
|
|
132
|
+
return [];
|
|
133
|
+
}
|
|
134
|
+
return [
|
|
135
|
+
{
|
|
136
|
+
check: 'project.version-agreement',
|
|
137
|
+
severity: 'warn',
|
|
138
|
+
file: integration.entryPath,
|
|
139
|
+
message: `package.json declares version "${packageVersion}", but the ` +
|
|
140
|
+
`defineIntegration() declaration for "${integration.slug}" still ` +
|
|
141
|
+
`says "${declared}". Generated seed headers embed the ` +
|
|
142
|
+
"declaration's version, so a stale declaration ships stale-looking " +
|
|
143
|
+
'metadata even though the package itself moved on.',
|
|
144
|
+
hint: `Update the "version" passed to defineIntegration() in ` +
|
|
145
|
+
`${integration.entryPath} to match package.json's "${packageVersion}".`,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
}
|
|
93
149
|
//# sourceMappingURL=validate-findings.js.map
|