@happyvertical/smrt-scanner 0.40.70 → 0.41.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/AGENTS.md +18 -2
- package/dist/index.d.ts +70 -0
- package/dist/index.js +136 -1
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/AGENTS.md
CHANGED
|
@@ -18,6 +18,18 @@ executes the source.
|
|
|
18
18
|
- `parseFile` / `parseSource` — parse a single file or a source string to a
|
|
19
19
|
`FileScanResult` (classes, errors, type aliases, SMRT imports).
|
|
20
20
|
- `extractSmrtImports` — pull SMRT-related imports from a parsed file.
|
|
21
|
+
- `lintNumericPrecision(classes, sourceText?)` — flags persisted `number` fields
|
|
22
|
+
whose declared precision contradicts their name (#2361), returning a `kind`
|
|
23
|
+
per finding. **Money is exact** and stored as integer minor units, so
|
|
24
|
+
`subtotal = 0.0` is a `money` finding; **rates are fractional**, so
|
|
25
|
+
`taxRate = 0` is a `rate` finding. `classifyNumericFieldName` does head-noun
|
|
26
|
+
matching and lets a rate word win outright, so `taxRate` is a rate even though
|
|
27
|
+
`tax` is money, while `amountCents` and `totalTokensUsed` classify as neither.
|
|
28
|
+
`weight`/`score`/`factor`/`percent` are deliberately unclassified — they are
|
|
29
|
+
commonly whole numbers. Explicit `@field({ type })`, `@meta`/`Meta<T>`,
|
|
30
|
+
transient, relationship, and static fields are exempt.
|
|
31
|
+
`sourceMayContainNumericPrecisionIssue(source)` is the cheap pre-filter
|
|
32
|
+
callers use to avoid parsing every file; `dev:knowledge-check` drives both.
|
|
21
33
|
- `verifyManifestCompleteness({ packageDir })` — publish guard: re-scans `src/`
|
|
22
34
|
and asserts every `@smrt()` object reached `dist/manifest.json` (issue #1483).
|
|
23
35
|
Returns `ok` / `incomplete` / `missing-manifest` / `scan-error` / `skipped`.
|
|
@@ -89,9 +101,13 @@ exhausts the heap when the scanner is pointed at an application root (#2275):
|
|
|
89
101
|
- **`ManifestBuilder` / `discoverBaseClasses` live in `@happyvertical/smrt-core`,
|
|
90
102
|
not here.** This package is the lower-level AST layer; core orchestrates
|
|
91
103
|
manifest generation and base-class discovery on top of it.
|
|
92
|
-
- **0 vs 0.0 heuristic**: `count = 0` → integer, `
|
|
104
|
+
- **0 vs 0.0 heuristic**: `count = 0` → integer, `ratio = 0.0` → decimal; the
|
|
93
105
|
raw initializer text (not the parsed value) decides, and negative defaults are
|
|
94
|
-
unwrapped from their `UnaryExpression` before the check.
|
|
106
|
+
unwrapped from their `UnaryExpression` before the check. The rule is silent
|
|
107
|
+
and SQLite's affinity masks the consequence, so money- and rate-shaped fields
|
|
108
|
+
are gated by `lintNumericPrecision` rather than left to review (#2361).
|
|
109
|
+
- **`RawFieldDefinition.line` is `0`**: the OXC AST nodes reaching this package
|
|
110
|
+
carry no `loc`. Pass the source text to a consumer that needs a real line.
|
|
95
111
|
- **Static property capture**: captures `uiSlots` and `adminRoutes` for agent
|
|
96
112
|
manifest generation.
|
|
97
113
|
- **`@smrt()` config spreads resolve only against unescaped same-file `const`s** (issue
|
package/dist/index.d.ts
CHANGED
|
@@ -52,6 +52,16 @@ declare interface ClassDeclaration extends BaseNode {
|
|
|
52
52
|
|
|
53
53
|
declare type ClassElement = PropertyDefinition | MethodDefinition_2;
|
|
54
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Classify an identifier as money, a rate, or neither.
|
|
57
|
+
*
|
|
58
|
+
* The head noun is the last word, or the last word before a trailing qualifier
|
|
59
|
+
* such as `Paid`. A rate word anywhere in the name wins outright: `taxRate` is
|
|
60
|
+
* a rate even though `tax` is money, and that precedence is what stops the two
|
|
61
|
+
* vocabularies from fighting over the same field.
|
|
62
|
+
*/
|
|
63
|
+
export declare function classifyNumericFieldName(name: string): NumericPrecisionKind | undefined;
|
|
64
|
+
|
|
55
65
|
declare interface Decorator extends BaseNode {
|
|
56
66
|
type: 'Decorator';
|
|
57
67
|
expression: Expression;
|
|
@@ -455,6 +465,17 @@ export declare class InheritanceResolver {
|
|
|
455
465
|
};
|
|
456
466
|
}
|
|
457
467
|
|
|
468
|
+
/**
|
|
469
|
+
* Report every persisted `number` field whose declared precision contradicts
|
|
470
|
+
* its name — money declared decimal, or a rate declared integer.
|
|
471
|
+
*
|
|
472
|
+
* @param classes - Raw class definitions from `parseFile` / `OxcScanner`.
|
|
473
|
+
* @param sourceText - Optional contents of the scanned file, used only to
|
|
474
|
+
* recover declaration line numbers the AST does not carry.
|
|
475
|
+
* @returns One finding per offending field, in declaration order.
|
|
476
|
+
*/
|
|
477
|
+
export declare function lintNumericPrecision(classes: RawClassDefinition[], sourceText?: string): NumericPrecisionFinding[];
|
|
478
|
+
|
|
458
479
|
declare interface Literal extends BaseNode {
|
|
459
480
|
type: 'Literal';
|
|
460
481
|
value: string | number | boolean | null | RegExp | bigint;
|
|
@@ -690,6 +711,34 @@ declare interface NewExpression extends BaseNode {
|
|
|
690
711
|
|
|
691
712
|
export declare function normalizeGlobSeparators(pattern: string, pathSeparator?: "\\" | "/"): string;
|
|
692
713
|
|
|
714
|
+
/** One field whose declared precision contradicts its name. */
|
|
715
|
+
export declare interface NumericPrecisionFinding {
|
|
716
|
+
/** `money` wants INTEGER minor units; `rate` wants DECIMAL. */
|
|
717
|
+
kind: NumericPrecisionKind;
|
|
718
|
+
/** Declaring class, e.g. `Invoice`. */
|
|
719
|
+
className: string;
|
|
720
|
+
/** Field name, e.g. `totalAmount`. */
|
|
721
|
+
fieldName: string;
|
|
722
|
+
/** File the class was scanned from. */
|
|
723
|
+
filePath: string;
|
|
724
|
+
/**
|
|
725
|
+
* 1-based line of the field declaration, or `0` when it could not be
|
|
726
|
+
* resolved. The OXC AST nodes this scanner consumes do not carry `loc`, so
|
|
727
|
+
* the line is recovered from the source text when a caller passes it to
|
|
728
|
+
* {@link lintNumericPrecision}.
|
|
729
|
+
*/
|
|
730
|
+
line: number;
|
|
731
|
+
/** The initializer that triggered the finding. */
|
|
732
|
+
initializer: string;
|
|
733
|
+
/** Human-readable explanation naming the rule. */
|
|
734
|
+
message: string;
|
|
735
|
+
/** The accepted fixes. */
|
|
736
|
+
remedy: string;
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/** Which rule a field falls under, if any. */
|
|
740
|
+
export declare type NumericPrecisionKind = 'money' | 'rate';
|
|
741
|
+
|
|
693
742
|
declare interface ObjectExpression extends BaseNode {
|
|
694
743
|
type: 'ObjectExpression';
|
|
695
744
|
properties: (Property | SpreadElement)[];
|
|
@@ -1312,6 +1361,27 @@ declare interface SourceLocation {
|
|
|
1312
1361
|
end: Position;
|
|
1313
1362
|
}
|
|
1314
1363
|
|
|
1364
|
+
/**
|
|
1365
|
+
* Cheap pre-filter so callers can skip parsing files that cannot produce a
|
|
1366
|
+
* finding.
|
|
1367
|
+
*
|
|
1368
|
+
* Conservative by construction: every condition here is textually implied by a
|
|
1369
|
+
* finding. A reported field lives in a class carrying `@smrt` or
|
|
1370
|
+
* `extends Smrt…`, is initialized to a number, and has a money or rate word at
|
|
1371
|
+
* one of the boundaries the head-noun splitter cuts on. It may still return
|
|
1372
|
+
* `true` for a file the AST pass then clears — that is the intended direction.
|
|
1373
|
+
*
|
|
1374
|
+
* @param source - Full file contents.
|
|
1375
|
+
* @returns `false` only when the file provably cannot produce a finding.
|
|
1376
|
+
*/
|
|
1377
|
+
export declare function sourceMayContainNumericPrecisionIssue(source: string): boolean;
|
|
1378
|
+
|
|
1379
|
+
/**
|
|
1380
|
+
* Split an identifier into lowercase words on camelCase, PascalCase, digits,
|
|
1381
|
+
* and underscores. `totalAmountCents` → `['total', 'amount', 'cents']`.
|
|
1382
|
+
*/
|
|
1383
|
+
export declare function splitIdentifierWords(name: string): string[];
|
|
1384
|
+
|
|
1315
1385
|
declare interface SpreadElement extends BaseNode {
|
|
1316
1386
|
type: 'SpreadElement';
|
|
1317
1387
|
argument: Expression;
|
package/dist/index.js
CHANGED
|
@@ -2,6 +2,141 @@ import { a as parseSource, c as normalizeGlobSeparators, i as parseFile, l as re
|
|
|
2
2
|
import "./types.js";
|
|
3
3
|
import { basename, resolve } from "node:path";
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
|
+
//#region src/numeric-precision-lint.ts
|
|
6
|
+
var MONEY_HEAD_WORDS = /* @__PURE__ */ new Set([
|
|
7
|
+
"amount",
|
|
8
|
+
"balance",
|
|
9
|
+
"cost",
|
|
10
|
+
"discount",
|
|
11
|
+
"due",
|
|
12
|
+
"fee",
|
|
13
|
+
"paid",
|
|
14
|
+
"price",
|
|
15
|
+
"subtotal",
|
|
16
|
+
"tax",
|
|
17
|
+
"total"
|
|
18
|
+
]);
|
|
19
|
+
var RATE_WORDS = /* @__PURE__ */ new Set([
|
|
20
|
+
"confidence",
|
|
21
|
+
"credibility",
|
|
22
|
+
"probability",
|
|
23
|
+
"rate",
|
|
24
|
+
"ratio"
|
|
25
|
+
]);
|
|
26
|
+
var TRAILING_QUALIFIERS = /* @__PURE__ */ new Set([
|
|
27
|
+
"applied",
|
|
28
|
+
"billed",
|
|
29
|
+
"charged",
|
|
30
|
+
"collected",
|
|
31
|
+
"earned",
|
|
32
|
+
"gross",
|
|
33
|
+
"net",
|
|
34
|
+
"outstanding",
|
|
35
|
+
"owed",
|
|
36
|
+
"refunded",
|
|
37
|
+
"remaining"
|
|
38
|
+
]);
|
|
39
|
+
var PERSISTED_BASE_CLASSES = /* @__PURE__ */ new Set([
|
|
40
|
+
"SmrtObject",
|
|
41
|
+
"SmrtJunction",
|
|
42
|
+
"SmrtHierarchical",
|
|
43
|
+
"SmrtPolymorphicAssociation"
|
|
44
|
+
]);
|
|
45
|
+
function splitIdentifierWords(name) {
|
|
46
|
+
return name.replace(/([a-z0-9])([A-Z])/g, "$1 $2").replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2").split(/[\s_$]+/).filter(Boolean).map((word) => word.toLowerCase());
|
|
47
|
+
}
|
|
48
|
+
function classifyNumericFieldName(name) {
|
|
49
|
+
const words = splitIdentifierWords(name);
|
|
50
|
+
if (words.some((word) => RATE_WORDS.has(word))) return "rate";
|
|
51
|
+
for (let index = words.length - 1; index >= 0; index -= 1) {
|
|
52
|
+
const word = words[index];
|
|
53
|
+
if (MONEY_HEAD_WORDS.has(word)) return "money";
|
|
54
|
+
if (!TRAILING_QUALIFIERS.has(word)) return void 0;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
var PERSISTED_CLASS_MARKER = /@smrt\b|extends\s+Smrt/;
|
|
58
|
+
var NUMERIC_INITIALIZER = /=\s*-?\d/;
|
|
59
|
+
var ALL_WORDS = [...MONEY_HEAD_WORDS, ...RATE_WORDS];
|
|
60
|
+
var WORD_AT_START = new RegExp(`(?<![A-Za-z])(?:${ALL_WORDS.join("|")})`, "i");
|
|
61
|
+
var WORD_AFTER_HUMP = new RegExp(`(?<=[A-Za-z0-9])(?:${ALL_WORDS.map((word) => word[0].toUpperCase() + word.slice(1)).join("|")})`);
|
|
62
|
+
function sourceMayContainNumericPrecisionIssue(source) {
|
|
63
|
+
return PERSISTED_CLASS_MARKER.test(source) && NUMERIC_INITIALIZER.test(source) && (WORD_AT_START.test(source) || WORD_AFTER_HUMP.test(source));
|
|
64
|
+
}
|
|
65
|
+
function blankStringLiterals(text) {
|
|
66
|
+
return text.replace(/(['"`])(?:\\.|(?!\1)[\s\S])*\1/g, (match) => match[0].repeat(match.length));
|
|
67
|
+
}
|
|
68
|
+
function hasExplicitTypeDecorator(field) {
|
|
69
|
+
for (const decorator of field.decorators) {
|
|
70
|
+
if (decorator.name === "foreignKey" || decorator.name === "crossPackageRef" || decorator.name === "oneToMany" || decorator.name === "manyToMany" || decorator.name === "tenantId") return true;
|
|
71
|
+
if (decorator.name !== "field") continue;
|
|
72
|
+
const args = blankStringLiterals(decorator.arguments.join(" "));
|
|
73
|
+
if (/\btype\s*:/.test(args)) return true;
|
|
74
|
+
if (/\btransient\s*:\s*true\b/.test(args)) return true;
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
function isMetaField(field) {
|
|
79
|
+
if (field.decorators.some((decorator) => decorator.name === "meta")) return true;
|
|
80
|
+
return /^Meta\s*</.test(field.typeAnnotation ?? "");
|
|
81
|
+
}
|
|
82
|
+
function usesLiteralInference(field) {
|
|
83
|
+
if (field.numericValue === null) return false;
|
|
84
|
+
const annotation = (field.typeAnnotation ?? "").replace(/\s/g, "");
|
|
85
|
+
if (annotation === "") return true;
|
|
86
|
+
return annotation === "number" || annotation === "number|null";
|
|
87
|
+
}
|
|
88
|
+
function isPersistedClass(cls) {
|
|
89
|
+
if (cls.hasSmartDecorator) return true;
|
|
90
|
+
return PERSISTED_BASE_CLASSES.has(cls.extendsClause ?? "");
|
|
91
|
+
}
|
|
92
|
+
function resolveDeclarationLine(sourceText, fieldName, fallback) {
|
|
93
|
+
if (fallback > 0 || !sourceText) return fallback;
|
|
94
|
+
const escapedName = fieldName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
95
|
+
const declaration = new RegExp(`^\\s*(?:(?:public|private|protected|readonly|declare|override)\\s+)*${escapedName}\\s*[?!]?\\s*(?::[^=;]+)?=\\s*-?\\d`);
|
|
96
|
+
const lines = sourceText.split("\n");
|
|
97
|
+
for (let index = 0; index < lines.length; index += 1) if (declaration.test(lines[index])) return index + 1;
|
|
98
|
+
return 0;
|
|
99
|
+
}
|
|
100
|
+
function describeMoneyFinding(cls, field) {
|
|
101
|
+
const whole = Math.trunc(field.numericValue ?? 0);
|
|
102
|
+
return {
|
|
103
|
+
message: `${cls.className}.${field.name} is a money field with a decimal initializer (= ${field.initializer ?? field.numericValue}), so SMRT compiles it to a floating-point column. Money is exact and is stored as integer minor units (cents, satoshis) \u2014 $19.99 is 1999 \u2014 so a float column reintroduces binary-fraction error into amounts that must balance.`,
|
|
104
|
+
remedy: `Write an integer initializer (\`${field.name} = ${whole}\`) and treat the value as minor units, or state the intent explicitly with \`@field({ type: 'decimal' })\` when the value really is fractional.`
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function describeRateFinding(cls, field) {
|
|
108
|
+
return {
|
|
109
|
+
message: `${cls.className}.${field.name} is a rate with an integer initializer (= ${field.numericValue}), so SMRT compiles it to an INTEGER column. A rate is inherently fractional, so every meaningful value truncates; PostgreSQL rejects the save with 22P02 while SQLite silently accepts it.`,
|
|
110
|
+
remedy: `Write a decimal initializer (\`${field.name} = ${field.numericValue}.0\`) to get a DECIMAL column, or state the intent explicitly with \`@field({ type: 'integer' })\` when the value really is a whole count.`
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
function lintNumericPrecision(classes, sourceText) {
|
|
114
|
+
const findings = [];
|
|
115
|
+
for (const cls of classes) {
|
|
116
|
+
if (!isPersistedClass(cls)) continue;
|
|
117
|
+
for (const field of cls.fields) {
|
|
118
|
+
if (field.isStatic) continue;
|
|
119
|
+
if (!usesLiteralInference(field)) continue;
|
|
120
|
+
const kind = classifyNumericFieldName(field.name);
|
|
121
|
+
if (!kind) continue;
|
|
122
|
+
if (kind === "money" && !field.hasDecimalPoint) continue;
|
|
123
|
+
if (kind === "rate" && field.hasDecimalPoint) continue;
|
|
124
|
+
if (isMetaField(field)) continue;
|
|
125
|
+
if (hasExplicitTypeDecorator(field)) continue;
|
|
126
|
+
findings.push({
|
|
127
|
+
kind,
|
|
128
|
+
className: cls.className,
|
|
129
|
+
fieldName: field.name,
|
|
130
|
+
filePath: cls.filePath,
|
|
131
|
+
line: resolveDeclarationLine(sourceText, field.name, field.line),
|
|
132
|
+
initializer: field.initializer ?? String(field.numericValue),
|
|
133
|
+
...kind === "money" ? describeMoneyFinding(cls, field) : describeRateFinding(cls, field)
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return findings;
|
|
138
|
+
}
|
|
139
|
+
//#endregion
|
|
5
140
|
//#region src/verify-completeness.ts
|
|
6
141
|
var DEFAULT_INCLUDE = ["src/**/*.ts"];
|
|
7
142
|
var DEFAULT_EXCLUDE = [
|
|
@@ -137,6 +272,6 @@ async function verifyManifestCompleteness(options) {
|
|
|
137
272
|
};
|
|
138
273
|
}
|
|
139
274
|
//#endregion
|
|
140
|
-
export { InheritanceResolver, ManifestAdapter, OxcScanner, discoverSourceFiles, extractSmrtImports, normalizeGlobSeparators, parseFile, parseSource, relativeGlobToCwd, verifyManifestCompleteness };
|
|
275
|
+
export { InheritanceResolver, ManifestAdapter, OxcScanner, classifyNumericFieldName, discoverSourceFiles, extractSmrtImports, lintNumericPrecision, normalizeGlobSeparators, parseFile, parseSource, relativeGlobToCwd, sourceMayContainNumericPrecisionIssue, splitIdentifierWords, verifyManifestCompleteness };
|
|
141
276
|
|
|
142
277
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/verify-completeness.ts"],"sourcesContent":["/**\n * Manifest completeness verification.\n *\n * Re-scans a package's `src/` with the same OXC scanner the build uses and\n * asserts that every `@smrt()`-decorated object (and its collection) is present\n * in the package's published `dist/manifest.json`. This is a publish-time guard:\n * downstream schema migration is manifest-driven, so a stale manifest that omits\n * an object means consumers can never create its table via `smrt db:migrate`.\n *\n * Root cause it guards against (issue #1483): `@happyvertical/smrt-jobs@0.27.41`\n * shipped a `dist/manifest.json` generated before `SmrtWorker` existed. The\n * source, exports, and `__smrt-register__` path were all correct, but the\n * published manifest had only 4 of 6 objects, so `_smrt_workers` was never\n * created and `TaskRunner.start()` threw on every consumer that upgraded.\n *\n * The check compares object-key SETS only. The downstream manifest-enrichment\n * passes (schema/validation/agent generation) mutate object entries but never\n * add or remove object keys, so the scanner + adapter object set is the source\n * of truth for \"which objects the published manifest must contain\". The\n * comparison is `expected ⊆ dist`: enrichment passes that add keys to `dist`\n * (e.g. STI children) never trigger a false failure.\n *\n * Parse errors are handled before the set comparison: a syntactically broken\n * source file drops its objects from `expected` AND `dist` symmetrically, which\n * a naive `expected ⊆ dist` would wave through as `ok`. The scan's\n * `severity:'error'` entries therefore short-circuit to a distinct `scan-error`\n * status so a broken source can never masquerade as a complete manifest.\n *\n * @see https://github.com/happyvertical/smrt/issues/1483\n */\n\nimport { existsSync, readFileSync } from 'node:fs';\nimport { basename, resolve } from 'node:path';\nimport { ManifestAdapter } from './manifest-adapter.js';\nimport { OxcScanner } from './scanner.js';\n\n/**\n * Source globs for the completeness scan. The build (`vite.config.base.ts`)\n * passes `include: ['src/**\\/*.ts']` and excludes `*.test.ts` / `*.spec.ts` (the\n * plugin also drops `*.svelte`); we reproduce that so the expected object set\n * matches what the build would publish. We additionally exclude\n * `**\\/__tests__\\/**` so any `@smrt()` fixtures in non-test helper modules under\n * `src/__tests__/` (e.g. `src/__tests__/helpers/*.ts`) are never required in the\n * published manifest. These extra exclusions can only shrink `expected` relative\n * to the build, so they can never cause a false failure — the check is\n * `expected ⊆ dist`.\n */\nconst DEFAULT_INCLUDE = ['src/**/*.ts'];\nconst DEFAULT_EXCLUDE = [\n '**/*.test.ts',\n '**/*.spec.ts',\n '**/__tests__/**',\n '**/*.svelte',\n '**/node_modules/**',\n '**/dist/**',\n '**/*.d.ts',\n];\n\n/**\n * Framework-infrastructure packages whose published manifest is NOT produced by\n * the OXC scanner path (e.g. `@happyvertical/smrt-core` ships a curated static\n * manifest). Mirrors `skipSmrtPlugin` in `vite.config.base.ts`. Keyed by package\n * directory basename.\n */\nconst DEFAULT_SKIP_PACKAGES = [\n 'core',\n 'types',\n 'config',\n 'scanner',\n 'vitest',\n 'smrt-playground',\n];\n\nexport type VerifyManifestStatus =\n | 'ok'\n | 'incomplete'\n | 'missing-manifest'\n | 'scan-error'\n | 'skipped';\n\nexport interface VerifyManifestCompletenessOptions {\n /** Absolute or relative path to the package directory to verify. */\n packageDir: string;\n /** Source include globs (default mirrors the build). */\n include?: string[];\n /** Source exclude globs (default mirrors the build). */\n exclude?: string[];\n /** Package directory basenames to skip (default: framework infrastructure). */\n skipPackages?: string[];\n}\n\nexport interface VerifyManifestCompletenessResult {\n status: VerifyManifestStatus;\n packageName?: string;\n manifestPath?: string;\n /** Qualified object keys present in source but missing from the manifest. */\n missing: string[];\n /** Number of objects the source is expected to contribute. */\n expectedCount: number;\n /** Number of objects present in the published manifest. */\n distCount: number;\n /** Human-readable explanation for `skipped` / `missing-manifest`. */\n reason?: string;\n}\n\ninterface MinimalPackageJson {\n name?: string;\n version?: string;\n exports?: Record<string, unknown>;\n}\n\n/**\n * Resolve the `./manifest` (or `./manifest.json`) export target to an absolute\n * path. Returns `null` when the package does not publish a manifest.\n */\nfunction resolveManifestPath(\n packageDir: string,\n pkgJson: MinimalPackageJson,\n): string | null {\n const exportsMap = pkgJson.exports ?? {};\n const entry = exportsMap['./manifest'] ?? exportsMap['./manifest.json'];\n\n let relativeTarget: string | undefined;\n if (typeof entry === 'string') {\n relativeTarget = entry;\n } else if (entry && typeof entry === 'object') {\n const conditional = entry as Record<string, unknown>;\n const candidate =\n conditional.default ?? conditional.import ?? conditional.types;\n if (typeof candidate === 'string') {\n relativeTarget = candidate;\n }\n }\n\n if (!relativeTarget) {\n return null;\n }\n\n return resolve(packageDir, relativeTarget);\n}\n\n/**\n * Verify that `dist/manifest.json` contains every `@smrt()` object declared in\n * the package source. See module docs for the rationale and guarantees.\n */\nexport async function verifyManifestCompleteness(\n options: VerifyManifestCompletenessOptions,\n): Promise<VerifyManifestCompletenessResult> {\n const packageDir = resolve(options.packageDir);\n const include = options.include ?? DEFAULT_INCLUDE;\n const exclude = options.exclude ?? DEFAULT_EXCLUDE;\n const skipPackages = options.skipPackages ?? DEFAULT_SKIP_PACKAGES;\n\n const packageJsonPath = resolve(packageDir, 'package.json');\n if (!existsSync(packageJsonPath)) {\n return {\n status: 'skipped',\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: `no package.json at ${packageDir}`,\n };\n }\n\n const pkgJson: MinimalPackageJson = JSON.parse(\n readFileSync(packageJsonPath, 'utf-8'),\n );\n const packageName = pkgJson.name;\n\n if (skipPackages.includes(basename(packageDir))) {\n return {\n status: 'skipped',\n packageName,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason:\n 'framework infrastructure package (does not use scanner manifest)',\n };\n }\n\n const manifestPath = resolveManifestPath(packageDir, pkgJson);\n if (!manifestPath) {\n return {\n status: 'skipped',\n packageName,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: 'package does not publish a ./manifest export',\n };\n }\n\n // Derive the object set the source SHOULD produce, using the same scanner and\n // adapter the build uses (vite-plugin scanWithOxc).\n const scanner = new OxcScanner({ cwd: packageDir, include, exclude });\n const { results, resolved } = await scanner.scanAndResolve();\n\n // A source file with a syntax error parses to `errors:[...], body:[]`, so any\n // @smrt() class in it is dropped from BOTH the re-scanned `expected` set and\n // (having never built) the published `dist`. Because the completeness check is\n // `expected ⊆ dist`, the object vanishes symmetrically and the guard would\n // return `ok` — silently defeating the #1483 guarantee for the exact case\n // (broken source) that most needs catching. Surface scan errors as a distinct\n // non-`ok` status instead of computing `expected` from a partial scan.\n const scanErrors = results.errors.filter((e) => e.severity === 'error');\n if (scanErrors.length > 0) {\n const detail = scanErrors\n .map((e) => {\n const where = e.line ? `${e.filePath}:${e.line}` : e.filePath;\n return `${where} — ${e.message}`;\n })\n .join('; ');\n return {\n status: 'scan-error',\n packageName,\n manifestPath,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: `source scan reported ${scanErrors.length} parse error(s): ${detail}`,\n };\n }\n\n const adapter = new ManifestAdapter();\n const expected = adapter.toManifest(resolved, {\n packageName,\n packageVersion: pkgJson.version,\n typeAliases: results.typeAliases,\n });\n const expectedKeys = Object.keys(expected.objects);\n\n // Nothing to verify if the package declares no SMRT objects (e.g. a package\n // that publishes ./manifest but only ships base classes).\n if (expectedKeys.length === 0) {\n return {\n status: 'skipped',\n packageName,\n manifestPath,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: 'no @smrt() objects found in source',\n };\n }\n\n if (!existsSync(manifestPath)) {\n return {\n status: 'missing-manifest',\n packageName,\n manifestPath,\n missing: expectedKeys,\n expectedCount: expectedKeys.length,\n distCount: 0,\n reason: `published manifest not found at ${manifestPath}`,\n };\n }\n\n let distObjects: Record<string, unknown> = {};\n try {\n const distManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));\n distObjects =\n distManifest && typeof distManifest.objects === 'object'\n ? distManifest.objects\n : {};\n } catch (error) {\n return {\n status: 'missing-manifest',\n packageName,\n manifestPath,\n missing: expectedKeys,\n expectedCount: expectedKeys.length,\n distCount: 0,\n reason: `published manifest is not valid JSON: ${(error as Error).message}`,\n };\n }\n\n const distKeys = new Set(Object.keys(distObjects));\n const missing = expectedKeys.filter((key) => !distKeys.has(key));\n\n return {\n status: missing.length > 0 ? 'incomplete' : 'ok',\n packageName,\n manifestPath,\n missing,\n expectedCount: expectedKeys.length,\n distCount: distKeys.size,\n };\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/numeric-precision-lint.ts","../src/verify-completeness.ts"],"sourcesContent":["/**\n * Numeric-precision lint for money and rate fields (#2361).\n *\n * SMRT infers a `number` field's column type from the *initializer literal*:\n * `= 0` compiles to INTEGER and `= 0.0` compiles to DECIMAL\n * (`manifest-adapter.ts`, inference steps 3 and 3.5). The rule is silent, and\n * SQLite's type affinity happily stores a REAL in an INTEGER column — so a\n * mistyped field passes every SQLite suite and then behaves differently in\n * production on PostgreSQL.\n *\n * Two vocabularies, opposite answers:\n *\n * - **Money is exact**, so it is stored as *integer minor units* — cents,\n * satoshis. `$19.99` is `1999`. A money field declared `= 0.0` becomes a\n * float column, which reintroduces binary-fraction error into amounts that\n * must balance, and invites callers to write major units.\n * - **Rates are inherently fractional** — `taxRate`, `confidence`, a percentage\n * — so they must be DECIMAL. INTEGER would truncate every meaningful value\n * (a confidence in `[0, 1]` collapses to 0 or 1) and PostgreSQL rejects the\n * write outright with `22P02`.\n *\n * Matching is head-noun based rather than substring based, which is what keeps\n * it usable as a repo-wide gate: `amountCents` and `totalTokensUsed` name a\n * unit or a count as their head and are left alone, while `totalAmount`,\n * `amountPaid` and a bare `total` are money. JSDoc prose is deliberately NOT\n * matched — the head-noun signal is precise, and scanning prose for the same\n * vocabulary would make the gate fire on documentation wording.\n */\n\nimport type { RawClassDefinition, RawFieldDefinition } from './types.js';\n\n/**\n * Head nouns that denote an exact monetary quantity, stored as integer minor\n * units.\n *\n * `tax` is money (a tax *amount*); `taxRate` is not, and the rate vocabulary\n * below wins whenever both appear, so the two never collide.\n */\nconst MONEY_HEAD_WORDS = new Set([\n 'amount',\n 'balance',\n 'cost',\n 'discount',\n 'due',\n 'fee',\n 'paid',\n 'price',\n 'subtotal',\n 'tax',\n 'total',\n]);\n\n/**\n * Head nouns that denote an inherently fractional quantity, stored as DECIMAL.\n *\n * A name carrying any of these is a rate even when it also carries a money word\n * (`taxRate`, `feePercentage`), because the rate is what the value *is*.\n */\nconst RATE_WORDS = new Set([\n 'confidence',\n 'credibility',\n 'probability',\n 'rate',\n 'ratio',\n]);\n\n/**\n * Deliberately NOT rate words: `weight`, `score`, `factor`, `percent`.\n *\n * Each is commonly a whole number — an ad-rotation `weight` of 1, a score out\n * of 100, a percent as 0–100 — so treating them as necessarily fractional\n * produces false positives on correct code. `AdVariation.weight = 1` is the\n * worked example. They are left unclassified rather than guessed at.\n */\n\n/**\n * Words that may trail a head noun without displacing it.\n *\n * `amountPaid` is still an amount; `amountCents` is not — its head noun already\n * names the exact unit — which is why this is an allowlist rather than a\n * blocklist of unit words.\n */\nconst TRAILING_QUALIFIERS = new Set([\n 'applied',\n 'billed',\n 'charged',\n 'collected',\n 'earned',\n 'gross',\n 'net',\n 'outstanding',\n 'owed',\n 'refunded',\n 'remaining',\n]);\n\n/** Base classes whose fields become table columns even without `@smrt()`. */\nconst PERSISTED_BASE_CLASSES = new Set([\n 'SmrtObject',\n 'SmrtJunction',\n 'SmrtHierarchical',\n 'SmrtPolymorphicAssociation',\n]);\n\n/** Which rule a field falls under, if any. */\nexport type NumericPrecisionKind = 'money' | 'rate';\n\n/** One field whose declared precision contradicts its name. */\nexport interface NumericPrecisionFinding {\n /** `money` wants INTEGER minor units; `rate` wants DECIMAL. */\n kind: NumericPrecisionKind;\n /** Declaring class, e.g. `Invoice`. */\n className: string;\n /** Field name, e.g. `totalAmount`. */\n fieldName: string;\n /** File the class was scanned from. */\n filePath: string;\n /**\n * 1-based line of the field declaration, or `0` when it could not be\n * resolved. The OXC AST nodes this scanner consumes do not carry `loc`, so\n * the line is recovered from the source text when a caller passes it to\n * {@link lintNumericPrecision}.\n */\n line: number;\n /** The initializer that triggered the finding. */\n initializer: string;\n /** Human-readable explanation naming the rule. */\n message: string;\n /** The accepted fixes. */\n remedy: string;\n}\n\n/**\n * Split an identifier into lowercase words on camelCase, PascalCase, digits,\n * and underscores. `totalAmountCents` → `['total', 'amount', 'cents']`.\n */\nexport function splitIdentifierWords(name: string): string[] {\n return name\n .replace(/([a-z0-9])([A-Z])/g, '$1 $2')\n .replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')\n .split(/[\\s_$]+/)\n .filter(Boolean)\n .map((word) => word.toLowerCase());\n}\n\n/**\n * Classify an identifier as money, a rate, or neither.\n *\n * The head noun is the last word, or the last word before a trailing qualifier\n * such as `Paid`. A rate word anywhere in the name wins outright: `taxRate` is\n * a rate even though `tax` is money, and that precedence is what stops the two\n * vocabularies from fighting over the same field.\n */\nexport function classifyNumericFieldName(\n name: string,\n): NumericPrecisionKind | undefined {\n const words = splitIdentifierWords(name);\n if (words.some((word) => RATE_WORDS.has(word))) return 'rate';\n for (let index = words.length - 1; index >= 0; index -= 1) {\n const word = words[index];\n if (MONEY_HEAD_WORDS.has(word)) return 'money';\n if (!TRAILING_QUALIFIERS.has(word)) return undefined;\n }\n return undefined;\n}\n\n/**\n * Text a file must contain before it can declare a field this lint reports.\n *\n * `@smrt` is matched without requiring parentheses because the parser accepts\n * the bare identifier form too, and the decorator name is never resolved\n * through an import alias — so the literal text is always present.\n */\nconst PERSISTED_CLASS_MARKER = /@smrt\\b|extends\\s+Smrt/;\n\n/** Any numeric initializer. Deliberately does not require a terminator. */\nconst NUMERIC_INITIALIZER = /=\\s*-?\\d/;\n\nconst ALL_WORDS = [...MONEY_HEAD_WORDS, ...RATE_WORDS];\n\n/**\n * A relevant word starting an identifier — any case, since `Price` and `price`\n * are both legal there. Case-insensitivity also matches prose in comments,\n * which costs a parse and nothing else.\n */\nconst WORD_AT_START = new RegExp(\n `(?<![A-Za-z])(?:${ALL_WORDS.join('|')})`,\n 'i',\n);\n\n/**\n * A relevant word after a camel hump (`unitPrice`, `USDPrice`), where it is\n * necessarily capitalized and necessarily preceded by an identifier character.\n * This arm cannot be case-insensitive: `generate` would then match on `rate`.\n *\n * Together the two arms cover exactly the boundaries {@link splitIdentifierWords}\n * cuts on, which is what makes the filter conservative rather than plausible.\n */\nconst WORD_AFTER_HUMP = new RegExp(\n `(?<=[A-Za-z0-9])(?:${ALL_WORDS.map(\n (word) => word[0].toUpperCase() + word.slice(1),\n ).join('|')})`,\n);\n\n/**\n * Cheap pre-filter so callers can skip parsing files that cannot produce a\n * finding.\n *\n * Conservative by construction: every condition here is textually implied by a\n * finding. A reported field lives in a class carrying `@smrt` or\n * `extends Smrt…`, is initialized to a number, and has a money or rate word at\n * one of the boundaries the head-noun splitter cuts on. It may still return\n * `true` for a file the AST pass then clears — that is the intended direction.\n *\n * @param source - Full file contents.\n * @returns `false` only when the file provably cannot produce a finding.\n */\nexport function sourceMayContainNumericPrecisionIssue(source: string): boolean {\n return (\n PERSISTED_CLASS_MARKER.test(source) &&\n NUMERIC_INITIALIZER.test(source) &&\n (WORD_AT_START.test(source) || WORD_AFTER_HUMP.test(source))\n );\n}\n\n/**\n * Blank out string-literal contents so option *keys* can be matched without\n * prose inside a value masquerading as one.\n *\n * `@field({ description: 'Discount type: percent or flat' })` otherwise reads\n * as an explicit `type:` and silently suppresses a real finding — and\n * `description` is a shipped option (#2046), so this is reachable, not\n * hypothetical. Quote characters are preserved to keep offsets stable.\n */\nfunction blankStringLiterals(text: string): string {\n return text.replace(/(['\"`])(?:\\\\.|(?!\\1)[\\s\\S])*\\1/g, (match) =>\n match[0].repeat(match.length),\n );\n}\n\n/** Does any decorator on this field state the column type explicitly? */\nfunction hasExplicitTypeDecorator(field: RawFieldDefinition): boolean {\n for (const decorator of field.decorators) {\n // Relationship decorators own the column type outright.\n if (\n decorator.name === 'foreignKey' ||\n decorator.name === 'crossPackageRef' ||\n decorator.name === 'oneToMany' ||\n decorator.name === 'manyToMany' ||\n decorator.name === 'tenantId'\n ) {\n return true;\n }\n if (decorator.name !== 'field') continue;\n const args = blankStringLiterals(decorator.arguments.join(' '));\n // An explicit declaration of intent is exactly what this lint asks for,\n // whichever type was chosen.\n if (/\\btype\\s*:/.test(args)) return true;\n // A transient field is never persisted, so no column type is inferred.\n if (/\\btransient\\s*:\\s*true\\b/.test(args)) return true;\n }\n return false;\n}\n\n/** Fields stored in the STI `_meta_data` JSON blob get no typed column. */\nfunction isMetaField(field: RawFieldDefinition): boolean {\n if (field.decorators.some((decorator) => decorator.name === 'meta')) {\n return true;\n }\n return /^Meta\\s*</.test(field.typeAnnotation ?? '');\n}\n\n/** Is this a plain `number` column whose type came from the literal? */\nfunction usesLiteralInference(field: RawFieldDefinition): boolean {\n if (field.numericValue === null) return false;\n const annotation = (field.typeAnnotation ?? '').replace(/\\s/g, '');\n if (annotation === '') return true; // `price = 0` — inference step 3.5\n return annotation === 'number' || annotation === 'number|null';\n}\n\n/** Are this class's fields materialized as table columns? */\nfunction isPersistedClass(cls: RawClassDefinition): boolean {\n if (cls.hasSmartDecorator) return true;\n return PERSISTED_BASE_CLASSES.has(cls.extendsClause ?? '');\n}\n\n/**\n * Recover a field's 1-based declaration line from source text.\n *\n * The scanner's raw field records carry `line: 0` because the OXC AST nodes\n * have no `loc`, and a finding that cannot point at a line is much harder to\n * act on — so callers that already hold the file contents get a real line.\n */\nfunction resolveDeclarationLine(\n sourceText: string | undefined,\n fieldName: string,\n fallback: number,\n): number {\n if (fallback > 0 || !sourceText) return fallback;\n // `$` is a legal identifier character and a regex anchor, so the name has to\n // be escaped rather than interpolated raw.\n const escapedName = fieldName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n const declaration = new RegExp(\n `^\\\\s*(?:(?:public|private|protected|readonly|declare|override)\\\\s+)*${escapedName}\\\\s*[?!]?\\\\s*(?::[^=;]+)?=\\\\s*-?\\\\d`,\n );\n const lines = sourceText.split('\\n');\n for (let index = 0; index < lines.length; index += 1) {\n if (declaration.test(lines[index])) return index + 1;\n }\n return 0;\n}\n\nfunction describeMoneyFinding(\n cls: RawClassDefinition,\n field: RawFieldDefinition,\n): Pick<NumericPrecisionFinding, 'message' | 'remedy'> {\n const whole = Math.trunc(field.numericValue ?? 0);\n return {\n message:\n `${cls.className}.${field.name} is a money field with a decimal ` +\n `initializer (= ${field.initializer ?? field.numericValue}), so SMRT ` +\n 'compiles it to a floating-point column. Money is exact and is stored ' +\n 'as integer minor units (cents, satoshis) — $19.99 is 1999 — so a float ' +\n 'column reintroduces binary-fraction error into amounts that must ' +\n 'balance.',\n remedy:\n `Write an integer initializer (\\`${field.name} = ${whole}\\`) and treat ` +\n 'the value as minor units, or state the intent explicitly with ' +\n \"`@field({ type: 'decimal' })` when the value really is fractional.\",\n };\n}\n\nfunction describeRateFinding(\n cls: RawClassDefinition,\n field: RawFieldDefinition,\n): Pick<NumericPrecisionFinding, 'message' | 'remedy'> {\n return {\n message:\n `${cls.className}.${field.name} is a rate with an integer initializer ` +\n `(= ${field.numericValue}), so SMRT compiles it to an INTEGER column. A ` +\n 'rate is inherently fractional, so every meaningful value truncates; ' +\n 'PostgreSQL rejects the save with 22P02 while SQLite silently accepts it.',\n remedy:\n `Write a decimal initializer (\\`${field.name} = ${field.numericValue}.0\\`) ` +\n 'to get a DECIMAL column, or state the intent explicitly with ' +\n \"`@field({ type: 'integer' })` when the value really is a whole count.\",\n };\n}\n\n/**\n * Report every persisted `number` field whose declared precision contradicts\n * its name — money declared decimal, or a rate declared integer.\n *\n * @param classes - Raw class definitions from `parseFile` / `OxcScanner`.\n * @param sourceText - Optional contents of the scanned file, used only to\n * recover declaration line numbers the AST does not carry.\n * @returns One finding per offending field, in declaration order.\n */\nexport function lintNumericPrecision(\n classes: RawClassDefinition[],\n sourceText?: string,\n): NumericPrecisionFinding[] {\n const findings: NumericPrecisionFinding[] = [];\n for (const cls of classes) {\n if (!isPersistedClass(cls)) continue;\n for (const field of cls.fields) {\n if (field.isStatic) continue;\n if (!usesLiteralInference(field)) continue;\n const kind = classifyNumericFieldName(field.name);\n if (!kind) continue;\n // Money wants an integer literal; a rate wants a decimal one. A field\n // already on the right side of its rule is fine.\n if (kind === 'money' && !field.hasDecimalPoint) continue;\n if (kind === 'rate' && field.hasDecimalPoint) continue;\n if (isMetaField(field)) continue;\n if (hasExplicitTypeDecorator(field)) continue;\n findings.push({\n kind,\n className: cls.className,\n fieldName: field.name,\n filePath: cls.filePath,\n line: resolveDeclarationLine(sourceText, field.name, field.line),\n initializer: field.initializer ?? String(field.numericValue),\n ...(kind === 'money'\n ? describeMoneyFinding(cls, field)\n : describeRateFinding(cls, field)),\n });\n }\n }\n return findings;\n}\n","/**\n * Manifest completeness verification.\n *\n * Re-scans a package's `src/` with the same OXC scanner the build uses and\n * asserts that every `@smrt()`-decorated object (and its collection) is present\n * in the package's published `dist/manifest.json`. This is a publish-time guard:\n * downstream schema migration is manifest-driven, so a stale manifest that omits\n * an object means consumers can never create its table via `smrt db:migrate`.\n *\n * Root cause it guards against (issue #1483): `@happyvertical/smrt-jobs@0.27.41`\n * shipped a `dist/manifest.json` generated before `SmrtWorker` existed. The\n * source, exports, and `__smrt-register__` path were all correct, but the\n * published manifest had only 4 of 6 objects, so `_smrt_workers` was never\n * created and `TaskRunner.start()` threw on every consumer that upgraded.\n *\n * The check compares object-key SETS only. The downstream manifest-enrichment\n * passes (schema/validation/agent generation) mutate object entries but never\n * add or remove object keys, so the scanner + adapter object set is the source\n * of truth for \"which objects the published manifest must contain\". The\n * comparison is `expected ⊆ dist`: enrichment passes that add keys to `dist`\n * (e.g. STI children) never trigger a false failure.\n *\n * Parse errors are handled before the set comparison: a syntactically broken\n * source file drops its objects from `expected` AND `dist` symmetrically, which\n * a naive `expected ⊆ dist` would wave through as `ok`. The scan's\n * `severity:'error'` entries therefore short-circuit to a distinct `scan-error`\n * status so a broken source can never masquerade as a complete manifest.\n *\n * @see https://github.com/happyvertical/smrt/issues/1483\n */\n\nimport { existsSync, readFileSync } from 'node:fs';\nimport { basename, resolve } from 'node:path';\nimport { ManifestAdapter } from './manifest-adapter.js';\nimport { OxcScanner } from './scanner.js';\n\n/**\n * Source globs for the completeness scan. The build (`vite.config.base.ts`)\n * passes `include: ['src/**\\/*.ts']` and excludes `*.test.ts` / `*.spec.ts` (the\n * plugin also drops `*.svelte`); we reproduce that so the expected object set\n * matches what the build would publish. We additionally exclude\n * `**\\/__tests__\\/**` so any `@smrt()` fixtures in non-test helper modules under\n * `src/__tests__/` (e.g. `src/__tests__/helpers/*.ts`) are never required in the\n * published manifest. These extra exclusions can only shrink `expected` relative\n * to the build, so they can never cause a false failure — the check is\n * `expected ⊆ dist`.\n */\nconst DEFAULT_INCLUDE = ['src/**/*.ts'];\nconst DEFAULT_EXCLUDE = [\n '**/*.test.ts',\n '**/*.spec.ts',\n '**/__tests__/**',\n '**/*.svelte',\n '**/node_modules/**',\n '**/dist/**',\n '**/*.d.ts',\n];\n\n/**\n * Framework-infrastructure packages whose published manifest is NOT produced by\n * the OXC scanner path (e.g. `@happyvertical/smrt-core` ships a curated static\n * manifest). Mirrors `skipSmrtPlugin` in `vite.config.base.ts`. Keyed by package\n * directory basename.\n */\nconst DEFAULT_SKIP_PACKAGES = [\n 'core',\n 'types',\n 'config',\n 'scanner',\n 'vitest',\n 'smrt-playground',\n];\n\nexport type VerifyManifestStatus =\n | 'ok'\n | 'incomplete'\n | 'missing-manifest'\n | 'scan-error'\n | 'skipped';\n\nexport interface VerifyManifestCompletenessOptions {\n /** Absolute or relative path to the package directory to verify. */\n packageDir: string;\n /** Source include globs (default mirrors the build). */\n include?: string[];\n /** Source exclude globs (default mirrors the build). */\n exclude?: string[];\n /** Package directory basenames to skip (default: framework infrastructure). */\n skipPackages?: string[];\n}\n\nexport interface VerifyManifestCompletenessResult {\n status: VerifyManifestStatus;\n packageName?: string;\n manifestPath?: string;\n /** Qualified object keys present in source but missing from the manifest. */\n missing: string[];\n /** Number of objects the source is expected to contribute. */\n expectedCount: number;\n /** Number of objects present in the published manifest. */\n distCount: number;\n /** Human-readable explanation for `skipped` / `missing-manifest`. */\n reason?: string;\n}\n\ninterface MinimalPackageJson {\n name?: string;\n version?: string;\n exports?: Record<string, unknown>;\n}\n\n/**\n * Resolve the `./manifest` (or `./manifest.json`) export target to an absolute\n * path. Returns `null` when the package does not publish a manifest.\n */\nfunction resolveManifestPath(\n packageDir: string,\n pkgJson: MinimalPackageJson,\n): string | null {\n const exportsMap = pkgJson.exports ?? {};\n const entry = exportsMap['./manifest'] ?? exportsMap['./manifest.json'];\n\n let relativeTarget: string | undefined;\n if (typeof entry === 'string') {\n relativeTarget = entry;\n } else if (entry && typeof entry === 'object') {\n const conditional = entry as Record<string, unknown>;\n const candidate =\n conditional.default ?? conditional.import ?? conditional.types;\n if (typeof candidate === 'string') {\n relativeTarget = candidate;\n }\n }\n\n if (!relativeTarget) {\n return null;\n }\n\n return resolve(packageDir, relativeTarget);\n}\n\n/**\n * Verify that `dist/manifest.json` contains every `@smrt()` object declared in\n * the package source. See module docs for the rationale and guarantees.\n */\nexport async function verifyManifestCompleteness(\n options: VerifyManifestCompletenessOptions,\n): Promise<VerifyManifestCompletenessResult> {\n const packageDir = resolve(options.packageDir);\n const include = options.include ?? DEFAULT_INCLUDE;\n const exclude = options.exclude ?? DEFAULT_EXCLUDE;\n const skipPackages = options.skipPackages ?? DEFAULT_SKIP_PACKAGES;\n\n const packageJsonPath = resolve(packageDir, 'package.json');\n if (!existsSync(packageJsonPath)) {\n return {\n status: 'skipped',\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: `no package.json at ${packageDir}`,\n };\n }\n\n const pkgJson: MinimalPackageJson = JSON.parse(\n readFileSync(packageJsonPath, 'utf-8'),\n );\n const packageName = pkgJson.name;\n\n if (skipPackages.includes(basename(packageDir))) {\n return {\n status: 'skipped',\n packageName,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason:\n 'framework infrastructure package (does not use scanner manifest)',\n };\n }\n\n const manifestPath = resolveManifestPath(packageDir, pkgJson);\n if (!manifestPath) {\n return {\n status: 'skipped',\n packageName,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: 'package does not publish a ./manifest export',\n };\n }\n\n // Derive the object set the source SHOULD produce, using the same scanner and\n // adapter the build uses (vite-plugin scanWithOxc).\n const scanner = new OxcScanner({ cwd: packageDir, include, exclude });\n const { results, resolved } = await scanner.scanAndResolve();\n\n // A source file with a syntax error parses to `errors:[...], body:[]`, so any\n // @smrt() class in it is dropped from BOTH the re-scanned `expected` set and\n // (having never built) the published `dist`. Because the completeness check is\n // `expected ⊆ dist`, the object vanishes symmetrically and the guard would\n // return `ok` — silently defeating the #1483 guarantee for the exact case\n // (broken source) that most needs catching. Surface scan errors as a distinct\n // non-`ok` status instead of computing `expected` from a partial scan.\n const scanErrors = results.errors.filter((e) => e.severity === 'error');\n if (scanErrors.length > 0) {\n const detail = scanErrors\n .map((e) => {\n const where = e.line ? `${e.filePath}:${e.line}` : e.filePath;\n return `${where} — ${e.message}`;\n })\n .join('; ');\n return {\n status: 'scan-error',\n packageName,\n manifestPath,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: `source scan reported ${scanErrors.length} parse error(s): ${detail}`,\n };\n }\n\n const adapter = new ManifestAdapter();\n const expected = adapter.toManifest(resolved, {\n packageName,\n packageVersion: pkgJson.version,\n typeAliases: results.typeAliases,\n });\n const expectedKeys = Object.keys(expected.objects);\n\n // Nothing to verify if the package declares no SMRT objects (e.g. a package\n // that publishes ./manifest but only ships base classes).\n if (expectedKeys.length === 0) {\n return {\n status: 'skipped',\n packageName,\n manifestPath,\n missing: [],\n expectedCount: 0,\n distCount: 0,\n reason: 'no @smrt() objects found in source',\n };\n }\n\n if (!existsSync(manifestPath)) {\n return {\n status: 'missing-manifest',\n packageName,\n manifestPath,\n missing: expectedKeys,\n expectedCount: expectedKeys.length,\n distCount: 0,\n reason: `published manifest not found at ${manifestPath}`,\n };\n }\n\n let distObjects: Record<string, unknown> = {};\n try {\n const distManifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));\n distObjects =\n distManifest && typeof distManifest.objects === 'object'\n ? distManifest.objects\n : {};\n } catch (error) {\n return {\n status: 'missing-manifest',\n packageName,\n manifestPath,\n missing: expectedKeys,\n expectedCount: expectedKeys.length,\n distCount: 0,\n reason: `published manifest is not valid JSON: ${(error as Error).message}`,\n };\n }\n\n const distKeys = new Set(Object.keys(distObjects));\n const missing = expectedKeys.filter((key) => !distKeys.has(key));\n\n return {\n status: missing.length > 0 ? 'incomplete' : 'ok',\n packageName,\n manifestPath,\n missing,\n expectedCount: expectedKeys.length,\n distCount: distKeys.size,\n };\n}\n"],"mappings":"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"}
|