@ontrails/warden 0.2.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/CHANGELOG.md +1556 -0
- package/README.md +200 -0
- package/bin/warden.ts +51 -0
- package/package.json +51 -0
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +2159 -0
- package/src/command.ts +1167 -0
- package/src/config.ts +193 -0
- package/src/draft.ts +22 -0
- package/src/drift.ts +260 -0
- package/src/fix.ts +126 -0
- package/src/formatters.ts +178 -0
- package/src/guide.ts +245 -0
- package/src/index.ts +295 -0
- package/src/project-context.ts +446 -0
- package/src/project-rules.ts +290 -0
- package/src/regrade-history.ts +188 -0
- package/src/regrade-receipt.ts +484 -0
- package/src/resolve.ts +531 -0
- package/src/rules/activation-orphan.ts +97 -0
- package/src/rules/captured-kernel.ts +375 -0
- package/src/rules/circular-refs.ts +150 -0
- package/src/rules/cli-command-route-coherence.ts +177 -0
- package/src/rules/composes-declarations.ts +839 -0
- package/src/rules/context-no-surface-types.ts +214 -0
- package/src/rules/dead-internal-trail.ts +161 -0
- package/src/rules/dead-public-trail.ts +258 -0
- package/src/rules/draft-file-marking.ts +155 -0
- package/src/rules/draft-visible-debt.ts +83 -0
- package/src/rules/duplicate-exported-symbol.ts +172 -0
- package/src/rules/duplicate-public-contract.ts +137 -0
- package/src/rules/entity-exists.ts +254 -0
- package/src/rules/entity-ids.ts +15 -0
- package/src/rules/error-mapping-completeness.ts +290 -0
- package/src/rules/example-valid.ts +395 -0
- package/src/rules/fires-declarations.ts +740 -0
- package/src/rules/governed-symbol-residue.ts +535 -0
- package/src/rules/governed-vocabulary-permutation-watch.ts +77 -0
- package/src/rules/implementation-returns-result.ts +1733 -0
- package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
- package/src/rules/incomplete-crud.ts +583 -0
- package/src/rules/index.ts +328 -0
- package/src/rules/intent-propagation.ts +125 -0
- package/src/rules/layer-field-name-drift.ts +102 -0
- package/src/rules/library-render-coherence.ts +97 -0
- package/src/rules/metadata.ts +929 -0
- package/src/rules/missing-reconcile.ts +97 -0
- package/src/rules/missing-visibility.ts +111 -0
- package/src/rules/no-destructured-compose.ts +196 -0
- package/src/rules/no-dev-permit-in-source.ts +99 -0
- package/src/rules/no-direct-implementation-call.ts +52 -0
- package/src/rules/no-legacy-cli-alias-export.ts +247 -0
- package/src/rules/no-legacy-layer-imports.ts +211 -0
- package/src/rules/no-native-error-result.ts +118 -0
- package/src/rules/no-redundant-result-error-wrap.ts +382 -0
- package/src/rules/no-retired-cross-vocabulary.ts +203 -0
- package/src/rules/no-sync-result-assumption.ts +1199 -0
- package/src/rules/no-throw-in-detour-recover.ts +225 -0
- package/src/rules/no-throw-in-implementation.ts +48 -0
- package/src/rules/no-top-level-surface.ts +371 -0
- package/src/rules/on-references-exist.ts +194 -0
- package/src/rules/orphaned-signal.ts +149 -0
- package/src/rules/owner-render-parity.ts +143 -0
- package/src/rules/permit-governance.ts +25 -0
- package/src/rules/prefer-schema-inference.ts +141 -0
- package/src/rules/public-export-example-coverage.ts +561 -0
- package/src/rules/public-internal-deep-imports.ts +454 -0
- package/src/rules/public-output-schema.ts +29 -0
- package/src/rules/public-union-output-discriminants.ts +150 -0
- package/src/rules/read-intent-fires.ts +188 -0
- package/src/rules/reference-exists.ts +97 -0
- package/src/rules/registry-names.ts +171 -0
- package/src/rules/resolved-import-boundary.ts +146 -0
- package/src/rules/resource-declarations.ts +697 -0
- package/src/rules/resource-exists.ts +181 -0
- package/src/rules/resource-id-grammar.ts +65 -0
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/retired-vocabulary.ts +1407 -0
- package/src/rules/scan.ts +59 -0
- package/src/rules/scheduled-destroy-intent.ts +44 -0
- package/src/rules/signal-graph-coaching.ts +220 -0
- package/src/rules/source/composition.ts +165 -0
- package/src/rules/source/drafts.ts +164 -0
- package/src/rules/source/entities.ts +618 -0
- package/src/rules/source/pragmas.ts +45 -0
- package/src/rules/source/resources.ts +64 -0
- package/src/rules/source/signals.ts +397 -0
- package/src/rules/source/stores.ts +310 -0
- package/src/rules/specs.ts +388 -0
- package/src/rules/static-resource-accessor-preference.ts +654 -0
- package/src/rules/structure.ts +234 -0
- package/src/rules/surface-overlay-coherence.ts +262 -0
- package/src/rules/surface-trailhead-coherence.ts +366 -0
- package/src/rules/trail-fork-coaching.ts +625 -0
- package/src/rules/trail-versioning-source.ts +1076 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/trailhead-override-divergence.ts +356 -0
- package/src/rules/types.ts +464 -0
- package/src/rules/unmaterialized-activation-source.ts +85 -0
- package/src/rules/unreachable-detour-shadowing.ts +343 -0
- package/src/rules/valid-describe-refs.ts +224 -0
- package/src/rules/valid-detour-contract.ts +78 -0
- package/src/rules/warden-export-symmetry.ts +540 -0
- package/src/rules/warden-rules-use-ast.ts +1109 -0
- package/src/rules/webhook-route-collision.ts +306 -0
- package/src/rules/workspace-lock-ownership.ts +34 -0
- package/src/trails/activation-orphan.trail.ts +84 -0
- package/src/trails/captured-kernel.trail.ts +108 -0
- package/src/trails/circular-refs.trail.ts +29 -0
- package/src/trails/cli-command-route-coherence.trail.ts +47 -0
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/context-no-surface-types.trail.ts +21 -0
- package/src/trails/dead-internal-trail.trail.ts +26 -0
- package/src/trails/dead-public-trail.trail.ts +31 -0
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/draft-file-marking.trail.ts +16 -0
- package/src/trails/draft-visible-debt.trail.ts +16 -0
- package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
- package/src/trails/duplicate-public-contract.trail.ts +47 -0
- package/src/trails/entity-exists.trail.ts +21 -0
- package/src/trails/error-mapping-completeness.trail.ts +30 -0
- package/src/trails/example-valid.trail.ts +25 -0
- package/src/trails/fires-declarations.trail.ts +23 -0
- package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
- package/src/trails/governed-symbol-residue.trail.ts +24 -0
- package/src/trails/governed-vocabulary-permutation-watch.trail.ts +16 -0
- package/src/trails/implementation-returns-result.trail.ts +20 -0
- package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
- package/src/trails/incomplete-crud.trail.ts +39 -0
- package/src/trails/index.ts +91 -0
- package/src/trails/intent-propagation.trail.ts +30 -0
- package/src/trails/layer-field-name-drift.trail.ts +39 -0
- package/src/trails/library-render-coherence.trail.ts +43 -0
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-reconcile.trail.ts +33 -0
- package/src/trails/missing-visibility.trail.ts +22 -0
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
- package/src/trails/no-direct-implementation-call.trail.ts +16 -0
- package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
- package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
- package/src/trails/no-native-error-result.trail.ts +18 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
- package/src/trails/no-sync-result-assumption.trail.ts +19 -0
- package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
- package/src/trails/no-throw-in-implementation.trail.ts +20 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/on-references-exist.trail.ts +21 -0
- package/src/trails/orphaned-signal.trail.ts +36 -0
- package/src/trails/owner-render-parity.trail.ts +26 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/permit-governance.trail.ts +51 -0
- package/src/trails/prefer-schema-inference.trail.ts +21 -0
- package/src/trails/public-export-example-coverage.trail.ts +16 -0
- package/src/trails/public-internal-deep-imports.trail.ts +94 -0
- package/src/trails/public-output-schema.trail.ts +55 -0
- package/src/trails/public-union-output-discriminants.trail.ts +33 -0
- package/src/trails/read-intent-fires.trail.ts +20 -0
- package/src/trails/reference-exists.trail.ts +25 -0
- package/src/trails/resolved-import-boundary.trail.ts +109 -0
- package/src/trails/resource-declarations.trail.ts +25 -0
- package/src/trails/resource-exists.trail.ts +27 -0
- package/src/trails/resource-id-grammar.trail.ts +39 -0
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +205 -0
- package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
- package/src/trails/schema.ts +295 -0
- package/src/trails/signal-graph-coaching.trail.ts +77 -0
- package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
- package/src/trails/surface-overlay-coherence.trail.ts +24 -0
- package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
- package/src/trails/topo.ts +6 -0
- package/src/trails/trail-fork-coaching.trail.ts +42 -0
- package/src/trails/trailhead-override-divergence.trail.ts +47 -0
- package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
- package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
- package/src/trails/valid-describe-refs.trail.ts +18 -0
- package/src/trails/valid-detour-contract.trail.ts +71 -0
- package/src/trails/version-gap.trail.ts +35 -0
- package/src/trails/version-pinned-compose.trail.ts +23 -0
- package/src/trails/version-without-examples.trail.ts +38 -0
- package/src/trails/warden-export-symmetry.trail.ts +16 -0
- package/src/trails/warden-rules-use-ast.trail.ts +64 -0
- package/src/trails/webhook-route-collision.trail.ts +50 -0
- package/src/trails/workspace-lock-ownership.trail.ts +16 -0
- package/src/trails/wrap-rule.ts +269 -0
- package/src/workspaces.ts +264 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates that every signal id declared in a trail's `on:` array resolves
|
|
3
|
+
* to a known signal definition somewhere in the project.
|
|
4
|
+
*
|
|
5
|
+
* Mirrors `resource-exists` structurally — collects local signal definitions
|
|
6
|
+
* for the standalone `check()` path and accepts a project-wide
|
|
7
|
+
* `knownSignalIds` set via `checkWithContext()`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { isDraftId } from '@ontrails/core';
|
|
11
|
+
|
|
12
|
+
import { collectSignalDefinitionIds } from './source/signals.js';
|
|
13
|
+
import {
|
|
14
|
+
deriveConstString,
|
|
15
|
+
findConfigProperty,
|
|
16
|
+
findTrailDefinitions,
|
|
17
|
+
getStringValue,
|
|
18
|
+
identifierName,
|
|
19
|
+
isStringLiteral,
|
|
20
|
+
offsetToLine,
|
|
21
|
+
parse,
|
|
22
|
+
} from '@ontrails/source';
|
|
23
|
+
import type { AstNode } from '@ontrails/source';
|
|
24
|
+
import { isTestFile } from './scan.js';
|
|
25
|
+
import type {
|
|
26
|
+
ProjectAwareWardenRule,
|
|
27
|
+
ProjectContext,
|
|
28
|
+
WardenDiagnostic,
|
|
29
|
+
} from './types.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Declared `on:` extraction
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
const getOnElements = (config: AstNode): readonly AstNode[] => {
|
|
36
|
+
const onProp = findConfigProperty(config, 'on');
|
|
37
|
+
if (!onProp) {
|
|
38
|
+
return [];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const arrayNode = onProp.value;
|
|
42
|
+
if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
|
|
43
|
+
return [];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const elements = (arrayNode as AstNode)['elements'] as
|
|
47
|
+
| readonly AstNode[]
|
|
48
|
+
| undefined;
|
|
49
|
+
return elements ?? [];
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Resolve an `on:` array element to a signal id when possible.
|
|
54
|
+
*
|
|
55
|
+
* Handles string literals and `const NAME = 'id'` identifier references.
|
|
56
|
+
* Object-form entries (e.g. `on: [someSignal]` where `someSignal` is a
|
|
57
|
+
* `Signal` value) cannot be statically resolved here and are skipped — the
|
|
58
|
+
* runtime normalizes them inside `trail()`, so skipping is safe. The tradeoff
|
|
59
|
+
* is that typo'd Signal imports won't be caught at lint time; the TypeScript
|
|
60
|
+
* compiler catches those instead.
|
|
61
|
+
*/
|
|
62
|
+
const extractOnElementId = (
|
|
63
|
+
element: AstNode,
|
|
64
|
+
sourceCode: string
|
|
65
|
+
): string | null => {
|
|
66
|
+
if (element.type === 'Identifier') {
|
|
67
|
+
const name = identifierName(element);
|
|
68
|
+
return name ? deriveConstString(name, sourceCode) : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (isStringLiteral(element)) {
|
|
72
|
+
return getStringValue(element);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const extractDeclaredOnIds = (
|
|
79
|
+
config: AstNode,
|
|
80
|
+
sourceCode: string
|
|
81
|
+
): readonly string[] => [
|
|
82
|
+
...new Set(
|
|
83
|
+
getOnElements(config).flatMap((element) => {
|
|
84
|
+
const id = extractOnElementId(element, sourceCode);
|
|
85
|
+
return id ? [id] : [];
|
|
86
|
+
})
|
|
87
|
+
),
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// Diagnostics
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
|
|
94
|
+
const buildMissingSignalDiagnostic = (
|
|
95
|
+
trailId: string,
|
|
96
|
+
signalId: string,
|
|
97
|
+
filePath: string,
|
|
98
|
+
line: number
|
|
99
|
+
): WardenDiagnostic => ({
|
|
100
|
+
filePath,
|
|
101
|
+
line,
|
|
102
|
+
message: `Trail "${trailId}" declares on: "${signalId}" which is not a known signal in the project. Define it with signal('${signalId}', ...), include it in the topo, or fix the on: id if this is a typo.`,
|
|
103
|
+
rule: 'on-references-exist',
|
|
104
|
+
severity: 'error',
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const reportMissingSignals = (
|
|
108
|
+
def: { id: string; config: AstNode; start: number },
|
|
109
|
+
sourceCode: string,
|
|
110
|
+
filePath: string,
|
|
111
|
+
knownSignalIds: ReadonlySet<string>,
|
|
112
|
+
diagnostics: WardenDiagnostic[]
|
|
113
|
+
): void => {
|
|
114
|
+
const line = offsetToLine(sourceCode, def.start);
|
|
115
|
+
for (const signalId of extractDeclaredOnIds(def.config, sourceCode)) {
|
|
116
|
+
if (!knownSignalIds.has(signalId) && !isDraftId(signalId)) {
|
|
117
|
+
diagnostics.push(
|
|
118
|
+
buildMissingSignalDiagnostic(def.id, signalId, filePath, line)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const buildSignalDiagnostics = (
|
|
125
|
+
ast: AstNode,
|
|
126
|
+
sourceCode: string,
|
|
127
|
+
filePath: string,
|
|
128
|
+
knownSignalIds: ReadonlySet<string>
|
|
129
|
+
): readonly WardenDiagnostic[] => {
|
|
130
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
131
|
+
for (const def of findTrailDefinitions(ast)) {
|
|
132
|
+
if (def.kind !== 'trail') {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
reportMissingSignals(
|
|
136
|
+
def,
|
|
137
|
+
sourceCode,
|
|
138
|
+
filePath,
|
|
139
|
+
knownSignalIds,
|
|
140
|
+
diagnostics
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
return diagnostics;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const checkOnReferences = (
|
|
147
|
+
ast: AstNode | null,
|
|
148
|
+
sourceCode: string,
|
|
149
|
+
filePath: string,
|
|
150
|
+
knownSignalIds: ReadonlySet<string>
|
|
151
|
+
): readonly WardenDiagnostic[] => {
|
|
152
|
+
if (isTestFile(filePath) || !ast) {
|
|
153
|
+
return [];
|
|
154
|
+
}
|
|
155
|
+
return buildSignalDiagnostics(ast, sourceCode, filePath, knownSignalIds);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Checks that every `on:` reference resolves to a known signal definition.
|
|
160
|
+
*/
|
|
161
|
+
export const onReferencesExist: ProjectAwareWardenRule = {
|
|
162
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
163
|
+
const ast = parse(filePath, sourceCode);
|
|
164
|
+
if (!ast) {
|
|
165
|
+
return [];
|
|
166
|
+
}
|
|
167
|
+
return checkOnReferences(
|
|
168
|
+
ast,
|
|
169
|
+
sourceCode,
|
|
170
|
+
filePath,
|
|
171
|
+
collectSignalDefinitionIds(ast)
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
checkWithContext(
|
|
175
|
+
sourceCode: string,
|
|
176
|
+
filePath: string,
|
|
177
|
+
context: ProjectContext
|
|
178
|
+
): readonly WardenDiagnostic[] {
|
|
179
|
+
const ast = parse(filePath, sourceCode);
|
|
180
|
+
const localSignalIds = ast
|
|
181
|
+
? collectSignalDefinitionIds(ast)
|
|
182
|
+
: new Set<string>();
|
|
183
|
+
return checkOnReferences(
|
|
184
|
+
ast,
|
|
185
|
+
sourceCode,
|
|
186
|
+
filePath,
|
|
187
|
+
context.knownSignalIds ?? localSignalIds
|
|
188
|
+
);
|
|
189
|
+
},
|
|
190
|
+
description:
|
|
191
|
+
'Ensure every signal id declared in a trail on: array resolves to a known signal definition.',
|
|
192
|
+
name: 'on-references-exist',
|
|
193
|
+
severity: 'error',
|
|
194
|
+
};
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { collectOnTargetSignalIds } from './source/signals.js';
|
|
2
|
+
import {
|
|
3
|
+
collectCrudTableIds,
|
|
4
|
+
findStoreTableDefinitions,
|
|
5
|
+
} from './source/stores.js';
|
|
6
|
+
import { offsetToLine, parse } from '@ontrails/source';
|
|
7
|
+
import type { AstNode } from '@ontrails/source';
|
|
8
|
+
import { isTestFile } from './scan.js';
|
|
9
|
+
import type {
|
|
10
|
+
ProjectAwareWardenRule,
|
|
11
|
+
ProjectContext,
|
|
12
|
+
WardenDiagnostic,
|
|
13
|
+
} from './types.js';
|
|
14
|
+
|
|
15
|
+
const CHANGE_SIGNAL_OPERATIONS = ['created', 'updated', 'removed'] as const;
|
|
16
|
+
|
|
17
|
+
const buildOrphanedSignalDiagnostic = (
|
|
18
|
+
tableId: string,
|
|
19
|
+
missingSignalIds: readonly string[],
|
|
20
|
+
filePath: string,
|
|
21
|
+
line: number
|
|
22
|
+
): WardenDiagnostic => ({
|
|
23
|
+
filePath,
|
|
24
|
+
line,
|
|
25
|
+
message: `Store table "${tableId}" derives change signals with no trail on: consumers: ${missingSignalIds.join(', ')}. Consume them with trail on: declarations — prefer the table's typed signal handles (table.signals.created / .updated / .removed), which carry the store-bound identity; a string id must be fully scoped when the store is bound to a resource — or remove the unused reactive pattern if nothing should react to this table's changes.`,
|
|
26
|
+
rule: 'orphaned-signal',
|
|
27
|
+
severity: 'warn',
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const getMissingSignalIds = (
|
|
31
|
+
tableId: string,
|
|
32
|
+
onTargetSignalIds: ReadonlySet<string>
|
|
33
|
+
): readonly string[] =>
|
|
34
|
+
CHANGE_SIGNAL_OPERATIONS.map((operation) => `${tableId}.${operation}`).filter(
|
|
35
|
+
(signalId) =>
|
|
36
|
+
!onTargetSignalIds.has(signalId) &&
|
|
37
|
+
// Bare-name fallback: string-literal `on:` consumers store the signal
|
|
38
|
+
// without the composite `${storeBinding}:` prefix.
|
|
39
|
+
!onTargetSignalIds.has(signalId.replace(/^[^:]+:/, ''))
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Strip the `${storeBinding}:` prefix from a composite signal id for display.
|
|
44
|
+
* Keeps diagnostic messages readable while keeping keys composite internally.
|
|
45
|
+
*/
|
|
46
|
+
const stripStoreBinding = (
|
|
47
|
+
signalId: string,
|
|
48
|
+
storeBinding: string | null
|
|
49
|
+
): string => {
|
|
50
|
+
if (!storeBinding) {
|
|
51
|
+
return signalId;
|
|
52
|
+
}
|
|
53
|
+
const prefix = `${storeBinding}:`;
|
|
54
|
+
return signalId.startsWith(prefix) ? signalId.slice(prefix.length) : signalId;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const buildDefinitionDiagnostic = (
|
|
58
|
+
definition: ReturnType<typeof findStoreTableDefinitions>[number],
|
|
59
|
+
sourceCode: string,
|
|
60
|
+
filePath: string,
|
|
61
|
+
crudTableIds: ReadonlySet<string>,
|
|
62
|
+
onTargetSignalIds: ReadonlySet<string>
|
|
63
|
+
): WardenDiagnostic | null => {
|
|
64
|
+
if (!crudTableIds.has(definition.key)) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const missingSignalIds = getMissingSignalIds(
|
|
69
|
+
definition.key,
|
|
70
|
+
onTargetSignalIds
|
|
71
|
+
);
|
|
72
|
+
return missingSignalIds.length === 0
|
|
73
|
+
? null
|
|
74
|
+
: buildOrphanedSignalDiagnostic(
|
|
75
|
+
definition.name,
|
|
76
|
+
missingSignalIds.map((id) =>
|
|
77
|
+
stripStoreBinding(id, definition.storeBinding)
|
|
78
|
+
),
|
|
79
|
+
filePath,
|
|
80
|
+
offsetToLine(sourceCode, definition.start)
|
|
81
|
+
);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const checkOrphanedSignals = (
|
|
85
|
+
ast: AstNode | null,
|
|
86
|
+
sourceCode: string,
|
|
87
|
+
filePath: string,
|
|
88
|
+
crudTableIds: ReadonlySet<string>,
|
|
89
|
+
onTargetSignalIds: ReadonlySet<string>
|
|
90
|
+
): readonly WardenDiagnostic[] => {
|
|
91
|
+
if (isTestFile(filePath) || !ast) {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const diagnostics: WardenDiagnostic[] = [];
|
|
96
|
+
|
|
97
|
+
for (const definition of findStoreTableDefinitions(ast)) {
|
|
98
|
+
const diagnostic = buildDefinitionDiagnostic(
|
|
99
|
+
definition,
|
|
100
|
+
sourceCode,
|
|
101
|
+
filePath,
|
|
102
|
+
crudTableIds,
|
|
103
|
+
onTargetSignalIds
|
|
104
|
+
);
|
|
105
|
+
if (diagnostic) {
|
|
106
|
+
diagnostics.push(diagnostic);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return diagnostics;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const orphanedSignal: ProjectAwareWardenRule = {
|
|
114
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
115
|
+
const ast = parse(filePath, sourceCode);
|
|
116
|
+
return checkOrphanedSignals(
|
|
117
|
+
ast,
|
|
118
|
+
sourceCode,
|
|
119
|
+
filePath,
|
|
120
|
+
ast ? collectCrudTableIds(ast) : new Set<string>(),
|
|
121
|
+
ast ? collectOnTargetSignalIds(ast, sourceCode) : new Set<string>()
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
checkWithContext(
|
|
125
|
+
sourceCode: string,
|
|
126
|
+
filePath: string,
|
|
127
|
+
context: ProjectContext
|
|
128
|
+
): readonly WardenDiagnostic[] {
|
|
129
|
+
const ast = parse(filePath, sourceCode);
|
|
130
|
+
const localCrudTableIds = ast
|
|
131
|
+
? collectCrudTableIds(ast)
|
|
132
|
+
: new Set<string>();
|
|
133
|
+
const localOnTargetSignalIds = ast
|
|
134
|
+
? collectOnTargetSignalIds(ast, sourceCode)
|
|
135
|
+
: new Set<string>();
|
|
136
|
+
|
|
137
|
+
return checkOrphanedSignals(
|
|
138
|
+
ast,
|
|
139
|
+
sourceCode,
|
|
140
|
+
filePath,
|
|
141
|
+
context.crudTableIds ?? localCrudTableIds,
|
|
142
|
+
context.onTargetSignalIds ?? localOnTargetSignalIds
|
|
143
|
+
);
|
|
144
|
+
},
|
|
145
|
+
description:
|
|
146
|
+
'Warn when CRUD-backed store change signals are never consumed by trail on: declarations.',
|
|
147
|
+
name: 'orphaned-signal',
|
|
148
|
+
severity: 'warn',
|
|
149
|
+
};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { resolve } from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import { intentValues } from '@ontrails/core';
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
getNodeExpression,
|
|
8
|
+
getNodeId,
|
|
9
|
+
getNodeInit,
|
|
10
|
+
getNodeKey,
|
|
11
|
+
getNodeProperties,
|
|
12
|
+
getPropertyName,
|
|
13
|
+
identifierName,
|
|
14
|
+
offsetToLine,
|
|
15
|
+
parse,
|
|
16
|
+
walk,
|
|
17
|
+
} from '@ontrails/source';
|
|
18
|
+
import type { AstNode } from '@ontrails/source';
|
|
19
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
20
|
+
|
|
21
|
+
const RULE_NAME = 'owner-render-parity';
|
|
22
|
+
|
|
23
|
+
const HTTP_METHOD_DERIVATION_PATH = resolve(
|
|
24
|
+
fileURLToPath(new URL('../../../http/src/method.ts', import.meta.url))
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
const isTargetFile = (filePath: string): boolean =>
|
|
28
|
+
resolve(filePath) === HTTP_METHOD_DERIVATION_PATH;
|
|
29
|
+
|
|
30
|
+
const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
|
|
31
|
+
let current = node;
|
|
32
|
+
while (
|
|
33
|
+
current &&
|
|
34
|
+
[
|
|
35
|
+
'ParenthesizedExpression',
|
|
36
|
+
'TSAsExpression',
|
|
37
|
+
'TSNonNullExpression',
|
|
38
|
+
'TSSatisfiesExpression',
|
|
39
|
+
'TSTypeAssertion',
|
|
40
|
+
].includes(current.type)
|
|
41
|
+
) {
|
|
42
|
+
current = getNodeExpression(current);
|
|
43
|
+
}
|
|
44
|
+
return current;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
interface IntentKeyMap {
|
|
48
|
+
readonly keys: ReadonlySet<string>;
|
|
49
|
+
readonly node: AstNode;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const findHttpMethodByIntentMap = (ast: AstNode): IntentKeyMap | null => {
|
|
53
|
+
let found: IntentKeyMap | null = null;
|
|
54
|
+
|
|
55
|
+
walk(ast, (node) => {
|
|
56
|
+
if (found || node.type !== 'VariableDeclarator') {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const id = getNodeId(node);
|
|
61
|
+
const init = getNodeInit(node);
|
|
62
|
+
if (identifierName(id) !== 'httpMethodByIntent') {
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const objectExpression = unwrapExpression(init);
|
|
67
|
+
if (objectExpression?.type !== 'ObjectExpression') {
|
|
68
|
+
found = { keys: new Set(), node };
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const keys = new Set<string>();
|
|
73
|
+
for (const property of getNodeProperties(objectExpression) ?? []) {
|
|
74
|
+
if (property.type !== 'Property') {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const key = getPropertyName(getNodeKey(property));
|
|
78
|
+
if (key) {
|
|
79
|
+
keys.add(key);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
found = { keys, node: objectExpression };
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return found;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const buildMessage = (missing: string[], extra: string[]): string => {
|
|
90
|
+
const details = [
|
|
91
|
+
missing.length > 0 ? `missing owner intents: ${missing.join(', ')}` : '',
|
|
92
|
+
extra.length > 0 ? `unknown intentKeyMap keys: ${extra.join(', ')}` : '',
|
|
93
|
+
].filter(Boolean);
|
|
94
|
+
|
|
95
|
+
return [
|
|
96
|
+
'owner-render-parity: httpMethodByIntent must cover the core intentValues owner vocabulary.',
|
|
97
|
+
...details,
|
|
98
|
+
].join(' ');
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
export const ownerRenderParity: WardenRule = {
|
|
102
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
103
|
+
if (!isTargetFile(filePath)) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const ast = parse(filePath, sourceCode);
|
|
108
|
+
if (!ast) {
|
|
109
|
+
return [];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const intentKeyMap = findHttpMethodByIntentMap(ast);
|
|
113
|
+
const ownerKeys = new Set<string>(intentValues);
|
|
114
|
+
const derivedKeys = intentKeyMap?.keys ?? new Set<string>();
|
|
115
|
+
const missing = [...ownerKeys]
|
|
116
|
+
.filter((key) => !derivedKeys.has(key))
|
|
117
|
+
.toSorted();
|
|
118
|
+
const extra = [...derivedKeys]
|
|
119
|
+
.filter((key) => !ownerKeys.has(key))
|
|
120
|
+
.toSorted();
|
|
121
|
+
|
|
122
|
+
if (intentKeyMap && missing.length === 0 && extra.length === 0) {
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const node = intentKeyMap?.node ?? ast;
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
filePath,
|
|
130
|
+
line: offsetToLine(sourceCode, node.start),
|
|
131
|
+
message: intentKeyMap
|
|
132
|
+
? buildMessage(missing, extra)
|
|
133
|
+
: 'owner-render-parity: expected httpMethodByIntent to render core intentValues.',
|
|
134
|
+
rule: RULE_NAME,
|
|
135
|
+
severity: 'error',
|
|
136
|
+
},
|
|
137
|
+
];
|
|
138
|
+
},
|
|
139
|
+
description:
|
|
140
|
+
'Require owner-derived intentKeyMap maps to cover their authoritative owner vocabulary.',
|
|
141
|
+
name: RULE_NAME,
|
|
142
|
+
severity: 'error',
|
|
143
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Trail } from '@ontrails/core';
|
|
2
|
+
import { validatePermits } from '@ontrails/permits';
|
|
3
|
+
|
|
4
|
+
import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
|
|
5
|
+
|
|
6
|
+
const toWardenDiagnostic = (
|
|
7
|
+
diagnostic: ReturnType<typeof validatePermits>[number]
|
|
8
|
+
): WardenDiagnostic => ({
|
|
9
|
+
filePath: '<topo>',
|
|
10
|
+
line: 1,
|
|
11
|
+
message: diagnostic.message,
|
|
12
|
+
rule: `permit.${diagnostic.rule}`,
|
|
13
|
+
severity: diagnostic.severity,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
export const permitGovernance: TopoAwareWardenRule = {
|
|
17
|
+
checkTopo: (topo) =>
|
|
18
|
+
validatePermits(
|
|
19
|
+
topo.list() as readonly Trail<unknown, unknown, unknown>[]
|
|
20
|
+
).map(toWardenDiagnostic),
|
|
21
|
+
description:
|
|
22
|
+
'Enforces permit declarations and scope hygiene across the compiled topo',
|
|
23
|
+
name: 'permit-governance',
|
|
24
|
+
severity: 'warn',
|
|
25
|
+
};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { WardenDiagnostic, WardenRule } from './types.js';
|
|
2
|
+
import { isTestFile } from './scan.js';
|
|
3
|
+
import {
|
|
4
|
+
findTrailLikeSpecs,
|
|
5
|
+
parseArrayEntries,
|
|
6
|
+
parseObjectProperties,
|
|
7
|
+
parseStringLiteral,
|
|
8
|
+
parseZodObjectShape,
|
|
9
|
+
} from './specs.js';
|
|
10
|
+
|
|
11
|
+
const REDUNDANT_OVERRIDE_KEYS = new Set(['label', 'options']);
|
|
12
|
+
|
|
13
|
+
const hasOnlyRedundantKeys = (
|
|
14
|
+
properties: ReadonlyMap<string, { value: string }>
|
|
15
|
+
): boolean =>
|
|
16
|
+
[...properties.keys()].every((key) => REDUNDANT_OVERRIDE_KEYS.has(key));
|
|
17
|
+
|
|
18
|
+
const redundantLabelPart = (
|
|
19
|
+
derivedLabel: string,
|
|
20
|
+
properties: ReadonlyMap<string, { value: string }>
|
|
21
|
+
): string[] => {
|
|
22
|
+
const label = parseStringLiteral(properties.get('label')?.value ?? '');
|
|
23
|
+
return label !== null && label === derivedLabel ? ['label'] : [];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const optionsAreDerivedDefaults = (
|
|
27
|
+
optionsText: string,
|
|
28
|
+
schemaOptions: readonly string[]
|
|
29
|
+
): boolean => {
|
|
30
|
+
const entries = parseArrayEntries(optionsText, 0, optionsText);
|
|
31
|
+
if (entries.length !== schemaOptions.length) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return entries.every((entry, index) => {
|
|
36
|
+
if (!entry.text.startsWith('{')) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const properties = parseObjectProperties(entry.text, 0, entry.text);
|
|
41
|
+
if (properties.size !== 1) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const value = parseStringLiteral(properties.get('value')?.value ?? '');
|
|
46
|
+
return value !== null && value === schemaOptions[index];
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const redundantOptionsPart = (
|
|
51
|
+
options: { value: string } | undefined,
|
|
52
|
+
schemaOptions: readonly string[] | undefined
|
|
53
|
+
): string[] =>
|
|
54
|
+
options !== undefined &&
|
|
55
|
+
schemaOptions !== undefined &&
|
|
56
|
+
optionsAreDerivedDefaults(options.value, schemaOptions)
|
|
57
|
+
? ['options']
|
|
58
|
+
: [];
|
|
59
|
+
|
|
60
|
+
const findRedundantParts = (
|
|
61
|
+
fieldKey: string,
|
|
62
|
+
fieldOverride: string,
|
|
63
|
+
schemaText: string
|
|
64
|
+
): string[] => {
|
|
65
|
+
const fieldInfo = parseZodObjectShape(schemaText).get(fieldKey);
|
|
66
|
+
if (!fieldInfo) {
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const properties = parseObjectProperties(fieldOverride, 0, fieldOverride);
|
|
71
|
+
if (properties.size === 0) {
|
|
72
|
+
return ['field metadata'];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (!hasOnlyRedundantKeys(properties)) {
|
|
76
|
+
return [];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const redundant = [
|
|
80
|
+
...redundantLabelPart(fieldInfo.derivedLabel, properties),
|
|
81
|
+
...redundantOptionsPart(properties.get('options'), fieldInfo.options),
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
return redundant.length === properties.size ? redundant : [];
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const diagnosticsForSpec = (
|
|
88
|
+
sourceCode: string,
|
|
89
|
+
filePath: string,
|
|
90
|
+
spec: ReturnType<typeof findTrailLikeSpecs>[number]
|
|
91
|
+
): readonly WardenDiagnostic[] => {
|
|
92
|
+
const input = spec.properties.get('input');
|
|
93
|
+
const fields = spec.properties.get('fields');
|
|
94
|
+
if (!input || !fields) {
|
|
95
|
+
return [];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fieldEntries = parseObjectProperties(
|
|
99
|
+
fields.value,
|
|
100
|
+
fields.start,
|
|
101
|
+
sourceCode
|
|
102
|
+
);
|
|
103
|
+
return [...fieldEntries.entries()]
|
|
104
|
+
.map(([fieldKey, fieldEntry]) => ({
|
|
105
|
+
fieldEntry,
|
|
106
|
+
fieldKey,
|
|
107
|
+
redundantParts: findRedundantParts(
|
|
108
|
+
fieldKey,
|
|
109
|
+
fieldEntry.value,
|
|
110
|
+
input.value
|
|
111
|
+
),
|
|
112
|
+
}))
|
|
113
|
+
.filter(({ redundantParts }) => redundantParts.length > 0)
|
|
114
|
+
.map(({ fieldEntry, fieldKey, redundantParts }) => ({
|
|
115
|
+
filePath,
|
|
116
|
+
line: fieldEntry.line,
|
|
117
|
+
message: `Trail "${spec.id}" field "${fieldKey}" only repeats schema-derived ${redundantParts.join(' and ')}. Remove the override and let deriveFields() infer it.`,
|
|
118
|
+
rule: 'prefer-schema-inference',
|
|
119
|
+
severity: 'warn' as const,
|
|
120
|
+
}));
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Warns when a fields override only repeats metadata deriveFields() already gets from
|
|
125
|
+
* the schema.
|
|
126
|
+
*/
|
|
127
|
+
export const preferSchemaInference: WardenRule = {
|
|
128
|
+
check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
|
|
129
|
+
if (isTestFile(filePath)) {
|
|
130
|
+
return [];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return findTrailLikeSpecs(sourceCode).flatMap((spec) =>
|
|
134
|
+
diagnosticsForSpec(sourceCode, filePath, spec)
|
|
135
|
+
);
|
|
136
|
+
},
|
|
137
|
+
description:
|
|
138
|
+
'Warn when fields overrides only restate labels or enum options deriveFields() already infers from the Zod schema.',
|
|
139
|
+
name: 'prefer-schema-inference',
|
|
140
|
+
severity: 'warn',
|
|
141
|
+
};
|