@ontrails/warden 1.0.0-beta.18 → 1.0.0-beta.19
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 +79 -0
- package/README.md +12 -30
- package/bin/warden.ts +29 -1
- package/package.json +9 -8
- package/src/adapter-check.ts +136 -0
- package/src/cli.ts +238 -60
- package/src/command.ts +26 -0
- package/src/drift.ts +1 -1
- package/src/fix.ts +120 -0
- package/src/formatters.ts +14 -2
- package/src/guide.ts +11 -0
- package/src/index.ts +31 -1
- package/src/rules/ast.ts +84 -25
- package/src/rules/circular-refs.ts +1 -1
- package/src/rules/{cross-declarations.ts → composes-declarations.ts} +198 -89
- package/src/rules/context-no-surface-types.ts +4 -4
- package/src/rules/contour-exists.ts +1 -1
- package/src/rules/dead-internal-trail.ts +22 -9
- package/src/rules/fires-declarations.ts +3 -3
- package/src/rules/implementation-returns-result.ts +269 -76
- package/src/rules/index.ts +51 -3
- package/src/rules/intent-propagation.ts +6 -6
- package/src/rules/metadata.ts +117 -12
- package/src/rules/missing-visibility.ts +14 -14
- package/src/rules/no-destructured-compose.ts +192 -0
- package/src/rules/no-direct-implementation-call.ts +2 -2
- package/src/rules/no-legacy-layer-imports.ts +19 -1
- package/src/rules/no-redundant-result-error-wrap.ts +331 -0
- package/src/rules/no-sync-result-assumption.ts +2 -2
- package/src/rules/no-throw-in-implementation.ts +2 -3
- package/src/rules/no-top-level-surface.ts +389 -0
- package/src/rules/on-references-exist.ts +1 -1
- package/src/rules/reference-exists.ts +1 -1
- package/src/rules/registry-names.ts +28 -2
- package/src/rules/resolved-import-boundary.ts +2 -2
- package/src/rules/resource-declarations.ts +4 -4
- package/src/rules/resource-exists.ts +1 -1
- package/src/rules/resource-mock-coverage.ts +115 -0
- package/src/rules/scan.ts +39 -0
- package/src/rules/trail-versioning-source.ts +1094 -0
- package/src/rules/trail-versioning-topo.ts +172 -0
- package/src/rules/types.ts +87 -5
- package/src/rules/valid-detour-contract.ts +1 -1
- package/src/rules/warden-export-symmetry.ts +1 -1
- package/src/rules/warden-rules-use-ast.ts +2 -2
- package/src/trails/activation-orphan.trail.ts +4 -1
- package/src/trails/composes-declarations.trail.ts +22 -0
- package/src/trails/dead-internal-trail.trail.ts +4 -4
- package/src/trails/deprecation-without-guidance.trail.ts +21 -0
- package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
- package/src/trails/index.ts +12 -1
- package/src/trails/intent-propagation.trail.ts +3 -3
- package/src/trails/marker-schema-unsupported.trail.ts +23 -0
- package/src/trails/missing-visibility.trail.ts +2 -2
- package/src/trails/no-destructured-compose.trail.ts +44 -0
- package/src/trails/no-direct-implementation-call.trail.ts +2 -2
- package/src/trails/no-legacy-layer-imports.trail.ts +6 -0
- package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
- package/src/trails/no-top-level-surface.trail.ts +43 -0
- package/src/trails/pending-force.trail.ts +21 -0
- package/src/trails/public-internal-deep-imports.trail.ts +1 -1
- package/src/trails/resolved-import-boundary.trail.ts +4 -4
- package/src/trails/resource-mock-coverage.trail.ts +40 -0
- package/src/trails/run.ts +2 -2
- package/src/trails/schema.ts +32 -6
- package/src/trails/signal-graph-coaching.trail.ts +4 -1
- package/src/trails/unmaterialized-activation-source.trail.ts +4 -1
- package/src/trails/valid-detour-contract.trail.ts +1 -1
- 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/wrap-rule.ts +5 -3
- package/src/trails/cross-declarations.trail.ts +0 -22
package/src/fix.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe-fix execution for `warden --fix` (TRL-833).
|
|
3
|
+
*
|
|
4
|
+
* Consumes the structured {@link WardenFix} metadata a rule attaches to its
|
|
5
|
+
* diagnostics (TRL-831) and applies only the edits marked `safe`. Findings
|
|
6
|
+
* whose fix is `review`-required, or that carry no edits, are never applied —
|
|
7
|
+
* they stay reported so a human (or a downstream regrade) resolves them.
|
|
8
|
+
*
|
|
9
|
+
* The applicator is pure: it takes a file's source plus that file's
|
|
10
|
+
* diagnostics and returns the patched source plus which diagnostics were
|
|
11
|
+
* applied or skipped. The CLI layer owns reading and writing files.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { WardenDiagnostic, WardenFixEdit } from './rules/types.js';
|
|
15
|
+
|
|
16
|
+
/** A safe edit resolved from a diagnostic, ready to apply to a source string. */
|
|
17
|
+
interface ResolvedEdit {
|
|
18
|
+
readonly start: number;
|
|
19
|
+
readonly end: number;
|
|
20
|
+
readonly replacement: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Apply a set of edits to a source string, last-to-first.
|
|
25
|
+
*
|
|
26
|
+
* Edits are applied in descending start order so earlier offsets stay valid as
|
|
27
|
+
* later spans are spliced. Overlapping edits are a programming error in the
|
|
28
|
+
* rule that produced them; this throws rather than silently corrupt source.
|
|
29
|
+
*/
|
|
30
|
+
const applyEdits = (source: string, edits: readonly ResolvedEdit[]): string => {
|
|
31
|
+
for (const edit of edits) {
|
|
32
|
+
if (!Number.isSafeInteger(edit.start) || !Number.isSafeInteger(edit.end)) {
|
|
33
|
+
throw new RangeError(
|
|
34
|
+
`Fix edit [${String(edit.start)}, ${String(edit.end)}) must use safe integer offsets.`
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ordered = [...edits].toSorted(
|
|
40
|
+
(left, right) => right.start - left.start
|
|
41
|
+
);
|
|
42
|
+
let result = source;
|
|
43
|
+
let lastStart = Number.POSITIVE_INFINITY;
|
|
44
|
+
for (const edit of ordered) {
|
|
45
|
+
if (edit.start < 0 || edit.end > source.length || edit.start > edit.end) {
|
|
46
|
+
throw new RangeError(
|
|
47
|
+
`Fix edit [${edit.start}, ${edit.end}) is out of bounds for source of length ${source.length}.`
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
if (edit.end > lastStart) {
|
|
51
|
+
throw new RangeError(
|
|
52
|
+
`Fix edit [${edit.start}, ${edit.end}) overlaps a later edit starting at ${lastStart}.`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
result =
|
|
56
|
+
result.slice(0, edit.start) + edit.replacement + result.slice(edit.end);
|
|
57
|
+
lastStart = edit.start;
|
|
58
|
+
}
|
|
59
|
+
return result;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Whether a diagnostic carries an applicable safe fix with concrete edits. */
|
|
63
|
+
export const hasSafeFixEdits = (
|
|
64
|
+
diagnostic: WardenDiagnostic
|
|
65
|
+
): diagnostic is WardenDiagnostic & {
|
|
66
|
+
readonly fix: { readonly edits: readonly WardenFixEdit[] };
|
|
67
|
+
} =>
|
|
68
|
+
diagnostic.fix?.safety === 'safe' &&
|
|
69
|
+
diagnostic.fix.edits !== undefined &&
|
|
70
|
+
diagnostic.fix.edits.length > 0;
|
|
71
|
+
|
|
72
|
+
/** Result of applying safe fixes to a single file's source. */
|
|
73
|
+
export interface WardenFileFixResult {
|
|
74
|
+
/** Source after applying every safe edit; unchanged when none applied. */
|
|
75
|
+
readonly patched: string;
|
|
76
|
+
/** Whether any edit was applied (i.e. `patched` differs from input). */
|
|
77
|
+
readonly changed: boolean;
|
|
78
|
+
/** Diagnostics whose safe fix was applied. */
|
|
79
|
+
readonly applied: readonly WardenDiagnostic[];
|
|
80
|
+
/** Diagnostics left reported (review-required, or no safe edits). */
|
|
81
|
+
readonly skipped: readonly WardenDiagnostic[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Apply the safe fixes among a file's diagnostics to its source.
|
|
86
|
+
*
|
|
87
|
+
* Pure and filesystem-free. Only `safety: 'safe'` fixes with edits are applied;
|
|
88
|
+
* everything else is returned in `skipped`. Edits from all applicable
|
|
89
|
+
* diagnostics are pooled and applied last-to-first in one pass.
|
|
90
|
+
*/
|
|
91
|
+
export const applySafeFixesToSource = (
|
|
92
|
+
source: string,
|
|
93
|
+
diagnostics: readonly WardenDiagnostic[]
|
|
94
|
+
): WardenFileFixResult => {
|
|
95
|
+
const applied: WardenDiagnostic[] = [];
|
|
96
|
+
const skipped: WardenDiagnostic[] = [];
|
|
97
|
+
const edits: ResolvedEdit[] = [];
|
|
98
|
+
|
|
99
|
+
for (const diagnostic of diagnostics) {
|
|
100
|
+
if (hasSafeFixEdits(diagnostic)) {
|
|
101
|
+
applied.push(diagnostic);
|
|
102
|
+
for (const edit of diagnostic.fix.edits) {
|
|
103
|
+
edits.push({
|
|
104
|
+
end: edit.end,
|
|
105
|
+
replacement: edit.replacement,
|
|
106
|
+
start: edit.start,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
} else {
|
|
110
|
+
skipped.push(diagnostic);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (edits.length === 0) {
|
|
115
|
+
return { applied, changed: false, patched: source, skipped };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const patched = applyEdits(source, edits);
|
|
119
|
+
return { applied, changed: patched !== source, patched, skipped };
|
|
120
|
+
};
|
package/src/formatters.ts
CHANGED
|
@@ -36,7 +36,7 @@ export const formatGitHubAnnotations = (report: WardenReport): string => {
|
|
|
36
36
|
lines.push(`::error::drift: ${report.drift.blockedReason}`);
|
|
37
37
|
} else if (report.drift?.stale) {
|
|
38
38
|
lines.push(
|
|
39
|
-
'::error::drift: trails.lock is stale (regenerate with `trails
|
|
39
|
+
'::error::drift: trails.lock is stale (regenerate with `trails compile`)'
|
|
40
40
|
);
|
|
41
41
|
}
|
|
42
42
|
|
|
@@ -60,6 +60,7 @@ export const formatJson = (report: WardenReport): string => {
|
|
|
60
60
|
{
|
|
61
61
|
diagnostics: report.diagnostics,
|
|
62
62
|
drift: report.drift,
|
|
63
|
+
fixes: report.fixes,
|
|
63
64
|
passed: report.passed,
|
|
64
65
|
summary,
|
|
65
66
|
},
|
|
@@ -146,7 +147,17 @@ const driftSection = (drift: WardenReport['drift']): readonly string[] => {
|
|
|
146
147
|
return [
|
|
147
148
|
'',
|
|
148
149
|
'### Drift',
|
|
149
|
-
'- trails.lock is stale (regenerate with `trails
|
|
150
|
+
'- trails.lock is stale (regenerate with `trails compile`)',
|
|
151
|
+
];
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** Render safe-fix counts when a fix pass was requested. */
|
|
155
|
+
const fixSummaryLine = (fixes: WardenReport['fixes']): readonly string[] => {
|
|
156
|
+
if (fixes === undefined) {
|
|
157
|
+
return [];
|
|
158
|
+
}
|
|
159
|
+
return [
|
|
160
|
+
`**Fixes:** ${String(fixes.applied)} applied, ${String(fixes.filesChanged)} files changed, ${String(fixes.skipped)} skipped`,
|
|
150
161
|
];
|
|
151
162
|
};
|
|
152
163
|
|
|
@@ -164,6 +175,7 @@ export const formatSummary = (report: WardenReport): string => {
|
|
|
164
175
|
'## Warden Report',
|
|
165
176
|
'',
|
|
166
177
|
`**Result: ${result}** | ${String(report.errorCount)} errors, ${String(report.warnCount)} warnings`,
|
|
178
|
+
...fixSummaryLine(report.fixes),
|
|
167
179
|
...severitySection('Errors', errors),
|
|
168
180
|
...severitySection('Warnings', warnings),
|
|
169
181
|
...driftSection(report.drift),
|
package/src/guide.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
+
WardenFixCapability,
|
|
2
3
|
WardenGuidance,
|
|
3
4
|
WardenGuidanceLink,
|
|
4
5
|
WardenRuleConcern,
|
|
@@ -27,6 +28,7 @@ export interface WardenRuleGuideEntry {
|
|
|
27
28
|
readonly depth: WardenDepth;
|
|
28
29
|
readonly description: string;
|
|
29
30
|
readonly docs: readonly WardenGuidanceLink[];
|
|
31
|
+
readonly fix?: WardenFixCapability | undefined;
|
|
30
32
|
readonly guidance?: WardenGuidance | undefined;
|
|
31
33
|
readonly id: string;
|
|
32
34
|
readonly invariant: string;
|
|
@@ -55,6 +57,7 @@ interface WardenAgentRuleGuide {
|
|
|
55
57
|
readonly tier: WardenRuleTier;
|
|
56
58
|
};
|
|
57
59
|
readonly concern: WardenRuleConcern;
|
|
60
|
+
readonly fix?: WardenFixCapability | undefined;
|
|
58
61
|
readonly guidance?: WardenGuidance | undefined;
|
|
59
62
|
readonly id: string;
|
|
60
63
|
readonly invariant: string;
|
|
@@ -87,6 +90,7 @@ export const buildWardenGuideManifest = (): WardenGuideManifest => {
|
|
|
87
90
|
depth: metadata.depth,
|
|
88
91
|
description: rule?.description ?? '',
|
|
89
92
|
docs,
|
|
93
|
+
fix: metadata.fix,
|
|
90
94
|
guidance: metadata.guidance,
|
|
91
95
|
id,
|
|
92
96
|
invariant: metadata.invariant,
|
|
@@ -153,6 +157,12 @@ const renderRuleMarkdown = (rule: WardenRuleGuideEntry): readonly string[] => {
|
|
|
153
157
|
lines.push(`- Retire when: ${rule.lifecycle.retireWhen}`);
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
if (rule.fix) {
|
|
161
|
+
lines.push(
|
|
162
|
+
`- Fix: \`${rule.fix.class}\` (${rule.fix.safety === 'safe' ? 'safe, applied by `warden --fix`' : 'review-required'})`
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
|
|
156
166
|
if (rule.guidance) {
|
|
157
167
|
lines.push('', `Guidance: ${rule.guidance.summary}`);
|
|
158
168
|
renderOptionalList(lines, 'Steps', rule.guidance.steps);
|
|
@@ -210,6 +220,7 @@ export const buildWardenAgentGuide = (
|
|
|
210
220
|
tier: rule.tier,
|
|
211
221
|
},
|
|
212
222
|
concern: rule.concern,
|
|
223
|
+
fix: rule.fix,
|
|
213
224
|
guidance: rule.guidance,
|
|
214
225
|
id: rule.id,
|
|
215
226
|
invariant: rule.invariant,
|
package/src/index.ts
CHANGED
|
@@ -14,6 +14,11 @@ export type {
|
|
|
14
14
|
ProjectContext,
|
|
15
15
|
TopoAwareWardenRule,
|
|
16
16
|
WardenDiagnostic,
|
|
17
|
+
WardenFix,
|
|
18
|
+
WardenFixCapability,
|
|
19
|
+
WardenFixClass,
|
|
20
|
+
WardenFixEdit,
|
|
21
|
+
WardenFixSafety,
|
|
17
22
|
WardenGuidance,
|
|
18
23
|
WardenGuidanceLink,
|
|
19
24
|
WardenRule,
|
|
@@ -31,6 +36,8 @@ export {
|
|
|
31
36
|
builtinWardenRuleMetadata,
|
|
32
37
|
getWardenRuleMetadata,
|
|
33
38
|
listWardenRuleMetadata,
|
|
39
|
+
wardenFixClasses,
|
|
40
|
+
wardenFixSafeties,
|
|
34
41
|
wardenRuleConcerns,
|
|
35
42
|
wardenRuleLifecycleStates,
|
|
36
43
|
wardenRuleScopes,
|
|
@@ -41,6 +48,12 @@ export {
|
|
|
41
48
|
|
|
42
49
|
// Rule-scoped cache controls for long-lived consumers (watch mode, LSPs).
|
|
43
50
|
export { clearImplementationReturnsResultCache } from './rules/implementation-returns-result.js';
|
|
51
|
+
export {
|
|
52
|
+
isWardenDevPermitTestScanTarget,
|
|
53
|
+
isWardenInfrastructureScanTarget,
|
|
54
|
+
isWardenSourceScanTarget,
|
|
55
|
+
isWardenTestScanTarget,
|
|
56
|
+
} from './rules/scan.js';
|
|
44
57
|
|
|
45
58
|
// CLI runner
|
|
46
59
|
export type {
|
|
@@ -51,6 +64,12 @@ export type {
|
|
|
51
64
|
} from './cli.js';
|
|
52
65
|
export { formatWardenReport, runWarden } from './cli.js';
|
|
53
66
|
|
|
67
|
+
// Adapter authoring checks
|
|
68
|
+
export {
|
|
69
|
+
adapterCheckRuleName,
|
|
70
|
+
runWardenAdapterChecks,
|
|
71
|
+
} from './adapter-check.js';
|
|
72
|
+
|
|
54
73
|
// CLI command surface
|
|
55
74
|
export type {
|
|
56
75
|
ParsedWardenCommand,
|
|
@@ -145,31 +164,38 @@ export {
|
|
|
145
164
|
circularRefsTrail,
|
|
146
165
|
contourExistsTrail,
|
|
147
166
|
contextNoSurfaceTypesTrail,
|
|
148
|
-
|
|
167
|
+
composesDeclarationsTrail,
|
|
149
168
|
deadInternalTrailTrail,
|
|
169
|
+
deprecationWithoutGuidanceTrail,
|
|
150
170
|
diagnosticSchema,
|
|
151
171
|
draftFileMarkingTrail,
|
|
152
172
|
draftVisibleDebtTrail,
|
|
153
173
|
errorMappingCompletenessTrail,
|
|
154
174
|
exampleValidTrail,
|
|
155
175
|
firesDeclarationsTrail,
|
|
176
|
+
forkWithoutPreservedBlazeTrail,
|
|
156
177
|
implementationReturnsResultTrail,
|
|
157
178
|
incompleteAccessorForStandardOpTrail,
|
|
158
179
|
incompleteCrudTrail,
|
|
159
180
|
intentPropagationTrail,
|
|
160
181
|
layerFieldNameDriftTrail,
|
|
182
|
+
markerSchemaUnsupportedTrail,
|
|
161
183
|
missingVisibilityTrail,
|
|
162
184
|
missingReconcileTrail,
|
|
163
185
|
noDevPermitInSourceTrail,
|
|
186
|
+
noDestructuredComposeTrail,
|
|
164
187
|
noDirectImplementationCallTrail,
|
|
165
188
|
noLegacyLayerImportsTrail,
|
|
166
189
|
noNativeErrorResultTrail,
|
|
190
|
+
noRedundantResultErrorWrapTrail,
|
|
167
191
|
noSyncResultAssumptionTrail,
|
|
168
192
|
noThrowInDetourRecoverTrail,
|
|
169
193
|
noThrowInImplementationTrail,
|
|
194
|
+
noTopLevelSurfaceTrail,
|
|
170
195
|
onReferencesExistTrail,
|
|
171
196
|
orphanedSignalTrail,
|
|
172
197
|
ownerProjectionParityTrail,
|
|
198
|
+
pendingForceTrail,
|
|
173
199
|
permitGovernanceTrail,
|
|
174
200
|
preferSchemaInferenceTrail,
|
|
175
201
|
projectAwareRuleInput,
|
|
@@ -184,6 +210,7 @@ export {
|
|
|
184
210
|
resourceDeclarationsTrail,
|
|
185
211
|
resourceIdGrammarTrail,
|
|
186
212
|
resourceExistsTrail,
|
|
213
|
+
resourceMockCoverageTrail,
|
|
187
214
|
scheduledDestroyIntentTrail,
|
|
188
215
|
signalGraphCoachingTrail,
|
|
189
216
|
staticResourceAccessorPreferenceTrail,
|
|
@@ -192,6 +219,9 @@ export {
|
|
|
192
219
|
unreachableDetourShadowingTrail,
|
|
193
220
|
validDetourContractTrail,
|
|
194
221
|
validDescribeRefsTrail,
|
|
222
|
+
versionGapTrail,
|
|
223
|
+
versionPinnedComposeTrail,
|
|
224
|
+
versionWithoutExamplesTrail,
|
|
195
225
|
wardenExportSymmetryTrail,
|
|
196
226
|
wardenRulesUseAstTrail,
|
|
197
227
|
webhookRouteCollisionTrail,
|
package/src/rules/ast.ts
CHANGED
|
@@ -2,10 +2,11 @@
|
|
|
2
2
|
* Shared AST utilities for warden rules.
|
|
3
3
|
*
|
|
4
4
|
* Uses oxc-parser for native-speed TypeScript parsing. Provides a lightweight
|
|
5
|
-
* walker and helpers for finding
|
|
5
|
+
* walker and helpers for finding blaze bodies.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
9
|
+
import { basename, dirname, join, resolve } from 'node:path';
|
|
9
10
|
import { fileURLToPath } from 'node:url';
|
|
10
11
|
|
|
11
12
|
import { DRAFT_ID_PREFIX, intentValues } from '@ontrails/core';
|
|
@@ -260,6 +261,60 @@ export const FRAMEWORK_DRAFT_PREFIX_CONSTANT_NAMES: ReadonlySet<string> =
|
|
|
260
261
|
*/
|
|
261
262
|
const FRAMEWORK_DRAFT_PREFIX_LITERAL = DRAFT_ID_PREFIX;
|
|
262
263
|
|
|
264
|
+
interface PackageJsonWithName {
|
|
265
|
+
readonly name: string;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const FRAMEWORK_DRAFT_PREFIX_PACKAGES: ReadonlySet<string> = new Set([
|
|
269
|
+
'@ontrails/core',
|
|
270
|
+
'@ontrails/warden',
|
|
271
|
+
]);
|
|
272
|
+
|
|
273
|
+
const isPackageJsonWithName = (value: unknown): value is PackageJsonWithName =>
|
|
274
|
+
typeof value === 'object' &&
|
|
275
|
+
value !== null &&
|
|
276
|
+
typeof (value as { name?: unknown }).name === 'string';
|
|
277
|
+
|
|
278
|
+
const readPackageJsonName = (packageJsonPath: string): string | null => {
|
|
279
|
+
try {
|
|
280
|
+
const parsed = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
|
|
281
|
+
return isPackageJsonWithName(parsed) ? parsed.name : null;
|
|
282
|
+
} catch {
|
|
283
|
+
return null;
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const frameworkDraftPackageRoot = (filePath: string): string | null => {
|
|
288
|
+
const resolvedPath = resolve(filePath);
|
|
289
|
+
if (basename(resolvedPath) !== 'draft.ts') {
|
|
290
|
+
return null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const sourceDir = dirname(resolvedPath);
|
|
294
|
+
if (basename(sourceDir) !== 'src') {
|
|
295
|
+
return null;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const packageRoot = dirname(sourceDir);
|
|
299
|
+
if (!existsSync(join(packageRoot, 'package.json'))) {
|
|
300
|
+
return null;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return packageRoot;
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
/** Fallback exemption when framework files are consumed from a different install path. */
|
|
307
|
+
const isFrameworkDraftPrefixSourceFile = (filePath: string): boolean => {
|
|
308
|
+
const root = frameworkDraftPackageRoot(filePath);
|
|
309
|
+
if (!root) {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
const packageName = readPackageJsonName(join(root, 'package.json'));
|
|
313
|
+
return (
|
|
314
|
+
packageName !== null && FRAMEWORK_DRAFT_PREFIX_PACKAGES.has(packageName)
|
|
315
|
+
);
|
|
316
|
+
};
|
|
317
|
+
|
|
263
318
|
/**
|
|
264
319
|
* Absolute paths of the two framework files allowed to declare the
|
|
265
320
|
* draft-prefix constants. Anchored against the rule module's own URL so the
|
|
@@ -285,8 +340,8 @@ const FRAMEWORK_DRAFT_CONSTANT_FILES: ReadonlySet<string> = new Set([
|
|
|
285
340
|
* constants.
|
|
286
341
|
*
|
|
287
342
|
* Exemption is gated on all three of:
|
|
288
|
-
* 1. The file
|
|
289
|
-
*
|
|
343
|
+
* 1. The file is one of the two known framework draft files, or its package
|
|
344
|
+
* root `package.json` name is `@ontrails/core` or `@ontrails/warden`.
|
|
290
345
|
* 2. The declaration name is `DRAFT_ID_PREFIX` or `DRAFT_FILE_PREFIX`.
|
|
291
346
|
* 3. The string literal value is exactly `'_draft.'`.
|
|
292
347
|
*
|
|
@@ -299,7 +354,11 @@ export const collectFrameworkDraftPrefixConstantOffsets = (
|
|
|
299
354
|
): ReadonlySet<number> => {
|
|
300
355
|
const offsets = new Set<number>();
|
|
301
356
|
|
|
302
|
-
|
|
357
|
+
const resolvedPath = resolve(filePath);
|
|
358
|
+
if (
|
|
359
|
+
!FRAMEWORK_DRAFT_CONSTANT_FILES.has(resolvedPath) &&
|
|
360
|
+
!isFrameworkDraftPrefixSourceFile(resolvedPath)
|
|
361
|
+
) {
|
|
303
362
|
return offsets;
|
|
304
363
|
}
|
|
305
364
|
|
|
@@ -919,7 +978,7 @@ const visitForHoisted = (
|
|
|
919
978
|
|
|
920
979
|
/**
|
|
921
980
|
* Collect `var` declarations and `function` declarations hoisted to the
|
|
922
|
-
* nearest function scope from anywhere inside `root`, without
|
|
981
|
+
* nearest function scope from anywhere inside `root`, without composing a
|
|
923
982
|
* nested function or static-block boundary.
|
|
924
983
|
*/
|
|
925
984
|
const collectHoistedVarAndFunctionBindings = (
|
|
@@ -1100,7 +1159,7 @@ export const walkWithScopes = (
|
|
|
1100
1159
|
walkNode(root, true);
|
|
1101
1160
|
};
|
|
1102
1161
|
|
|
1103
|
-
const isShadowed = (
|
|
1162
|
+
export const isShadowed = (
|
|
1104
1163
|
receiverName: string,
|
|
1105
1164
|
scopeStack: readonly ReadonlySet<string>[]
|
|
1106
1165
|
): boolean => {
|
|
@@ -1296,7 +1355,7 @@ const isNamespacedCallAllowed = (
|
|
|
1296
1355
|
*
|
|
1297
1356
|
* When `context` is `undefined`, this falls back to permissive matching
|
|
1298
1357
|
* (any `ns.trail(...)` shape resolves). Inline resolution paths that do
|
|
1299
|
-
* not have the surrounding AST available (e.g. `
|
|
1358
|
+
* not have the surrounding AST available (e.g. `composes: [core.trail(...)]`
|
|
1300
1359
|
* or `on: [core.signal(...)]`) rely on this fallback. Scope-aware call
|
|
1301
1360
|
* sites always pass a context, so this only affects inline contexts where
|
|
1302
1361
|
* a best-effort name match is the intended behavior.
|
|
@@ -1790,7 +1849,7 @@ const extractImportSpecifierAlias = (
|
|
|
1790
1849
|
}
|
|
1791
1850
|
|
|
1792
1851
|
// Default imports bind the default export of the source module to the local
|
|
1793
|
-
// name. We cannot statically recover the exported name without
|
|
1852
|
+
// name. We cannot statically recover the exported name without compose-file
|
|
1794
1853
|
// analysis, so the local name is the best identifier we have for resolving
|
|
1795
1854
|
// against `knownContourIds`. Treat the alias as an identity mapping; the
|
|
1796
1855
|
// downstream resolver will fall through to `knownContourIds` on the binding
|
|
@@ -2655,14 +2714,14 @@ export const collectNamedTrailIds = (
|
|
|
2655
2714
|
return ids;
|
|
2656
2715
|
};
|
|
2657
2716
|
|
|
2658
|
-
/** Extract the raw `
|
|
2659
|
-
export const
|
|
2660
|
-
const
|
|
2661
|
-
if (!
|
|
2717
|
+
/** Extract the raw `composes: [...]` array elements from a trail config. */
|
|
2718
|
+
export const getComposeElements = (config: AstNode): readonly AstNode[] => {
|
|
2719
|
+
const composesProp = findConfigProperty(config, 'composes');
|
|
2720
|
+
if (!composesProp) {
|
|
2662
2721
|
return [];
|
|
2663
2722
|
}
|
|
2664
2723
|
|
|
2665
|
-
const arrayNode =
|
|
2724
|
+
const arrayNode = composesProp.value;
|
|
2666
2725
|
if (!arrayNode || (arrayNode as AstNode).type !== 'ArrayExpression') {
|
|
2667
2726
|
return [];
|
|
2668
2727
|
}
|
|
@@ -2674,12 +2733,12 @@ export const getCrossElements = (config: AstNode): readonly AstNode[] => {
|
|
|
2674
2733
|
};
|
|
2675
2734
|
|
|
2676
2735
|
/**
|
|
2677
|
-
* Resolve a single `
|
|
2736
|
+
* Resolve a single `composes: [...]` element to its target trail ID.
|
|
2678
2737
|
*
|
|
2679
2738
|
* Handles string literals, identifier references (via `namedTrailIds` map or
|
|
2680
2739
|
* `const NAME = '...'` resolution), and inline `trail(...)` call expressions.
|
|
2681
2740
|
*/
|
|
2682
|
-
export const
|
|
2741
|
+
export const deriveComposeElementId = (
|
|
2683
2742
|
element: AstNode,
|
|
2684
2743
|
sourceCode: string,
|
|
2685
2744
|
namedTrailIds: ReadonlyMap<string, string>
|
|
@@ -2701,23 +2760,23 @@ export const deriveCrossElementId = (
|
|
|
2701
2760
|
|
|
2702
2761
|
/**
|
|
2703
2762
|
* Collect all trail IDs referenced by a single trail definition's
|
|
2704
|
-
* `
|
|
2763
|
+
* `composes: [...]` array, deduplicated.
|
|
2705
2764
|
*/
|
|
2706
|
-
export const
|
|
2765
|
+
export const extractDefinitionComposeTargetIds = (
|
|
2707
2766
|
config: AstNode,
|
|
2708
2767
|
sourceCode: string,
|
|
2709
2768
|
namedTrailIds: ReadonlyMap<string, string>
|
|
2710
2769
|
): readonly string[] => [
|
|
2711
2770
|
...new Set(
|
|
2712
|
-
|
|
2713
|
-
const id =
|
|
2771
|
+
getComposeElements(config).flatMap((element) => {
|
|
2772
|
+
const id = deriveComposeElementId(element, sourceCode, namedTrailIds);
|
|
2714
2773
|
return id ? [id] : [];
|
|
2715
2774
|
})
|
|
2716
2775
|
),
|
|
2717
2776
|
];
|
|
2718
2777
|
|
|
2719
|
-
/** Collect all trail IDs referenced by declared `
|
|
2720
|
-
export const
|
|
2778
|
+
/** Collect all trail IDs referenced by declared `composes: [...]` arrays. */
|
|
2779
|
+
export const collectComposeTargetTrailIds = (
|
|
2721
2780
|
ast: AstNode,
|
|
2722
2781
|
sourceCode: string
|
|
2723
2782
|
): ReadonlySet<string> => {
|
|
@@ -2729,7 +2788,7 @@ export const collectCrossTargetTrailIds = (
|
|
|
2729
2788
|
continue;
|
|
2730
2789
|
}
|
|
2731
2790
|
|
|
2732
|
-
for (const id of
|
|
2791
|
+
for (const id of extractDefinitionComposeTargetIds(
|
|
2733
2792
|
def.config,
|
|
2734
2793
|
sourceCode,
|
|
2735
2794
|
namedTrailIds
|
|
@@ -2788,7 +2847,7 @@ export interface StoreTableDefinition {
|
|
|
2788
2847
|
/**
|
|
2789
2848
|
* Stable composite key for this table in the form `${storeBinding}:${name}`,
|
|
2790
2849
|
* falling back to the bare `name` when the store is anonymous. Use this for
|
|
2791
|
-
*
|
|
2850
|
+
* compose-rule / compose-file keying so two stores with the same table name
|
|
2792
2851
|
* never collide.
|
|
2793
2852
|
*/
|
|
2794
2853
|
readonly key: string;
|
|
@@ -2804,7 +2863,7 @@ export interface StoreTableDefinition {
|
|
|
2804
2863
|
* binding. Centralized so rule keying stays stable.
|
|
2805
2864
|
*
|
|
2806
2865
|
* @remarks
|
|
2807
|
-
* The key is intentionally file-local (no module path prefix).
|
|
2866
|
+
* The key is intentionally file-local (no module path prefix). Compose-file
|
|
2808
2867
|
* aggregation in `ProjectContext` merges keys from all files, so two files
|
|
2809
2868
|
* with `const db = store({ notes: ... })` both produce `db:notes` — this is
|
|
2810
2869
|
* the desired behavior because the warden checks for *pattern completeness*
|
|
@@ -82,7 +82,7 @@ const buildCircularReferenceDiagnostic = (
|
|
|
82
82
|
): WardenDiagnostic => ({
|
|
83
83
|
filePath,
|
|
84
84
|
line,
|
|
85
|
-
message: `Contour "${contourName}" participates in circular contour references: ${cyclePath.join(' -> ')}.`,
|
|
85
|
+
message: `Contour "${contourName}" participates in circular contour references: ${cyclePath.join(' -> ')}. Break the cycle by removing one contour reference, or extract the shared shape into a new contour neither side depends on.`,
|
|
86
86
|
rule: 'circular-refs',
|
|
87
87
|
severity: 'warn',
|
|
88
88
|
});
|