@lunora/advisor 1.0.0-alpha.7 → 1.0.0-alpha.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -214,6 +214,28 @@ interface AdvisorMaskProcedure {
214
214
  visibility: "internal" | "public";
215
215
  }
216
216
  /**
217
+ * One whole-row `ctx.db.replace(id, document)` write discovered inside a custom
218
+ * mutator's authoritative `server` impl (`lunora/mutators.ts`) — the input the
219
+ * `mutator_full_row_replace` lint consumes.
220
+ *
221
+ * In the local-first sync engine a `replace` overwrites the entire row, so a
222
+ * concurrent edit to a *different* column (committed between this mutator's read
223
+ * and its write) is silently clobbered. `patch(id, { onlyTheField })` merges at
224
+ * the column level instead, letting independent field edits coexist — the
225
+ * blessed pattern for mutators on a synced (poke-live) table. Produced by the
226
+ * codegen feeder, which attributes each `replace` to the mutator export
227
+ * performing it; runtime callers don't supply it, so the lint finds nothing
228
+ * there.
229
+ */
230
+ interface AdvisorMutatorWrite {
231
+ /** The mutator export whose `server` impl performs the replace (e.g. `renameChannel`). */
232
+ exportName: string;
233
+ /** Openable source path the replace appears in — always `lunora/mutators.ts`. */
234
+ file: string;
235
+ /** 1-based line of the `replace(...)` call, or `0` when unknown. */
236
+ line: number;
237
+ }
238
+ /**
217
239
  * One non-deterministic API call discovered lexically inside a `query(...)` or
218
240
  * `mutation(...)` handler body — the input the `nondeterministic_query_mutation`
219
241
  * lint consumes. Produced by the codegen feeder, which walks each exported
@@ -384,6 +406,16 @@ interface AdvisorTable {
384
406
  optionalFields?: ReadonlySet<string>;
385
407
  /** Declared relations (`.relations((r) => …)`). */
386
408
  relations: ReadonlyArray<AdvisorRelation>;
409
+ /**
410
+ * Storage tier the table is declared in: `"global"` (a `.global()` table,
411
+ * lives in D1 — the cross-shard tier), `"shardBy"` (partitioned across
412
+ * shard DOs by a key), or `"root"` (the default single-DO table). Read by
413
+ * the `shape_*` lints to flag replication shapes targeting a `.global()`
414
+ * table (poll-refreshed/latency-tiered, not poke-live). Optional — the
415
+ * codegen feeder always supplies it, the runtime feeder derives it; a feeder
416
+ * that omits it leaves tier-sensitive lints to treat the table as local.
417
+ */
418
+ shardKind?: "global" | "root" | "shardBy";
387
419
  }
388
420
  /**
389
421
  * One declared index, flattened across Lunora's index kinds so a single lint can
@@ -440,6 +472,28 @@ interface AdvisorSecretLiteral {
440
472
  preview: string;
441
473
  }
442
474
  /**
475
+ * A replication shape declared via `defineShape({ table, where, columns? })` in
476
+ * `lunora/shapes.ts` (the local-first sync engine's partial-replication unit).
477
+ * The `shape_*` lints cross-reference each shape's {@link AdvisorShape.table}
478
+ * against the declared schema to flag a shape targeting an unknown table or a
479
+ * `.global()` table (which replicates through the latency-tiered D1 poll path,
480
+ * not the poke-live op-log). Supplied by the codegen feeder, which lifts only
481
+ * the export name + the static `table` literal; absent for runtime callers,
482
+ * where the shape lints find nothing.
483
+ */
484
+ interface AdvisorShape {
485
+ /** Export binding name — the shape's registry key (e.g. `channelMessages`). */
486
+ exportName: string;
487
+ /** File the shape is declared in (relative, for the operator to open). */
488
+ file: string;
489
+ /**
490
+ * The `table` string literal the shape replicates from, or `undefined` when
491
+ * the feeder could not read it as a plain string literal — tier-sensitive
492
+ * lints skip a shape with no resolvable table rather than guessing.
493
+ */
494
+ table?: string;
495
+ }
496
+ /**
443
497
  * One shard's observed traffic share — the input the `hot_shard` runtime lint
444
498
  * consumes. Produced by the studio backend, which fans out over a sharded
445
499
  * function's shards and reads each shard's recorded request volume from the
@@ -523,10 +577,27 @@ interface AdvisorTableSample {
523
577
  * how `AdvisorContainer` tracks `ContainerIR` and `AdvisorInsertWrite` tracks
524
578
  * `InsertWriteIR`).
525
579
  */
580
+ /** One durable step call lifted from a workflow handler body — the input the duplicate-step-name lint compares. Structural subset of codegen's `WorkflowStepIR`. */
581
+ interface AdvisorWorkflowStep {
582
+ /** 1-based line of the durable step call, or `0` when unknown. */
583
+ line: number;
584
+ /** The native step method invoked: `do` / `sleep` / `sleepUntil` / `waitForEvent`. */
585
+ method: string;
586
+ /** The step's static label (the first string-literal argument). */
587
+ name: string;
588
+ }
526
589
  /** One workflow declared via a `defineWorkflow()` export in `lunora/workflows.ts`. */
527
590
  interface AdvisorWorkflow {
528
591
  /** The `lunora/workflows.ts` export name, e.g. `orderPipeline`. */
529
592
  exportName: string;
593
+ /**
594
+ * The durable step labels discovered in the handler body, in source order —
595
+ * the duplicate-step-name input. Cloudflare memoizes a step by its name, so a
596
+ * name used twice makes the second call silently return the first's cached
597
+ * result. Supplied by the codegen feeder; `undefined` for runtime callers,
598
+ * where the lint finds nothing.
599
+ */
600
+ steps?: ReadonlyArray<AdvisorWorkflowStep>;
530
601
  }
531
602
  /** One `ctx.workflows.get("name")` call discovered in a function body. */
532
603
  interface AdvisorWorkflowCall {
@@ -660,6 +731,14 @@ interface LintContext {
660
731
  */
661
732
  maskProcedures?: ReadonlyArray<AdvisorMaskProcedure>;
662
733
  /**
734
+ * Whole-row `ctx.db.replace(id, document)` writes lifted from custom
735
+ * mutators' authoritative `server` impls (the `mutator_full_row_replace`
736
+ * input). Each `replace` overwrites the entire row, clobbering a concurrent
737
+ * edit to a different column on a synced table. Supplied by the codegen
738
+ * feeder; absent for runtime callers, where the lint finds nothing.
739
+ */
740
+ mutatorWrites?: ReadonlyArray<AdvisorMutatorWrite>;
741
+ /**
663
742
  * Non-deterministic API calls (`Date.now`, `Math.random`,
664
743
  * `crypto.randomUUID`, `crypto.getRandomValues`, `fetch`) discovered lexically
665
744
  * inside `query`/`mutation` handler bodies — the `nondeterministic_query_mutation`
@@ -708,6 +787,14 @@ interface LintContext {
708
787
  */
709
788
  secretLiterals?: ReadonlyArray<AdvisorSecretLiteral>;
710
789
  /**
790
+ * Replication shapes declared via `defineShape` in `lunora/shapes.ts` — the
791
+ * `shape_unknown_table` and `shape_targets_global_table` lint input. Each
792
+ * carries the export name and its static `table` literal, cross-referenced
793
+ * against {@link LintContext.schema}. Supplied by the codegen feeder; absent
794
+ * for runtime callers, where the shape lints find nothing.
795
+ */
796
+ shapes?: ReadonlyArray<AdvisorShape>;
797
+ /**
711
798
  * Per-shard observed traffic — the `hot_shard` lint input. Supplied by the
712
799
  * studio backend, which fans out over a sharded function's shards and reads
713
800
  * each shard's recorded request volume from the durable `__lunora_metrics`
@@ -1133,6 +1220,30 @@ declare const indexReferencesUnknownField: Lint;
1133
1220
  */
1134
1221
  declare const maskUncoveredPiiColumn: Lint;
1135
1222
  /**
1223
+ * Flags a custom mutator whose authoritative `server` impl writes a row with
1224
+ * `ctx.db.replace(id, document)` — a whole-document overwrite.
1225
+ *
1226
+ * The local-first sync engine serializes mutators in the shard DO, so two
1227
+ * mutators that touch the *same row* but *different columns* both run to
1228
+ * completion — but only if each writes its own column. A `replace` overwrites
1229
+ * the entire row from the document the mutator assembled, so a concurrent edit
1230
+ * to another column (committed between this mutator's read and its write, or
1231
+ * carried as a pending optimistic overlay on a client) is silently clobbered:
1232
+ * the kind of "two offline edits to different fields fight each other" data loss
1233
+ * a column-level merge avoids. `ctx.db.patch(id, { onlyTheChangedField })`
1234
+ * merges at the column level instead, so independent field edits coexist.
1235
+ *
1236
+ * `WARN`, not `ERROR`: `replace` is legitimate when the mutator genuinely owns
1237
+ * the whole row (a full-form save, a state-machine transition that rewrites
1238
+ * every field). The lint just surfaces the column-clobber risk so a developer
1239
+ * reaches for `patch` by default on a synced table.
1240
+ *
1241
+ * **Evidence supply**: runs only when the codegen feeder supplies
1242
+ * `context.mutatorWrites` (each a `replace` call lifted from a mutator's inline
1243
+ * `server` body); absent for runtime callers, where the lint finds nothing.
1244
+ */
1245
+ declare const mutatorFullRowReplace: Lint;
1246
+ /**
1136
1247
  * Flags a non-deterministic API call inside a `query(...)` or `mutation(...)`
1137
1248
  * handler body.
1138
1249
  *
@@ -1285,6 +1396,48 @@ declare const relationReferencesUnknownTable: Lint;
1285
1396
  */
1286
1397
  declare const rlsUncoveredTable: Lint;
1287
1398
  /**
1399
+ * Flags a replication shape whose `table` is a `.global()` table.
1400
+ *
1401
+ * Poke-live replication is a per-shard-DO property: the shard owns its SQLite
1402
+ * and a monotonic `__cdc_log`, so a write produces an ordered op the DO pokes to
1403
+ * every subscriber at the next flush. A `.global()` table lives outside the
1404
+ * shard DO's SQLite op-log (in a global backend — D1, or Hyperdrive-fronted
1405
+ * Postgres/MySQL) — so a shape over a global table cannot be poke-live. It is
1406
+ * served through the cross-shard tier: **coordinator/poll-refreshed, latency-
1407
+ * tiered**, not live. That is a real and supported tier (it is the recommended
1408
+ * answer for cross-shard reads — denormalize, or move the joined table to
1409
+ * `.global()` and read through the global backend), but its freshness semantics
1410
+ * differ from a sharded shape's, so the boundary is surfaced rather than hidden.
1411
+ *
1412
+ * `WARN`, not `ERROR`: a global-table shape is a legitimate design once you
1413
+ * accept the poll-refresh latency; the lint just makes the tier explicit so a
1414
+ * developer does not assume poke-live freshness.
1415
+ *
1416
+ * **Evidence supply**: runs only when the codegen feeder supplies
1417
+ * `context.shapes`; the table's tier comes from the schema's `shardKind`. A
1418
+ * shape whose table is unknown (caught by `shape_unknown_table`) or whose tier
1419
+ * the feeder didn't supply is skipped.
1420
+ */
1421
+ declare const shapeTargetsGlobalTable: Lint;
1422
+ /**
1423
+ * Flags a replication shape whose `table` names a table that does not exist in
1424
+ * the schema.
1425
+ *
1426
+ * `defineShape({ table: "messages", … })` binds a shape to a table by a plain
1427
+ * string. A live `subscribeShape("…")` resolves that shape server-side and runs
1428
+ * its membership query against the named table — so a typo, a stale name after a
1429
+ * rename, or a copy-paste mistake produces a shape that can never resolve a
1430
+ * rowset: the subscription seeds empty and then errors at the first flush
1431
+ * (`no such table`). This is a definite, build-time-detectable break, so it is
1432
+ * an `ERROR` — surfaced before the broken shape ever ships.
1433
+ *
1434
+ * **Evidence supply**: runs only when the codegen feeder supplies
1435
+ * `context.shapes`. A shape whose `table` wasn't a static string literal (no
1436
+ * resolvable name) is skipped rather than guessed at, so the lint under-reports
1437
+ * rather than raising false alarms.
1438
+ */
1439
+ declare const shapeUnknownTable: Lint;
1440
+ /**
1288
1441
  * Flags a `ctx.sql` tagged-template that splices an unparameterized
1289
1442
  * string-building expression into the query.
1290
1443
  *
@@ -1387,6 +1540,25 @@ declare const unindexedRelationTarget: Lint;
1387
1540
  */
1388
1541
  declare const userCreatingMutationWithoutCaptcha: Lint;
1389
1542
  /**
1543
+ * Flags a durable step name reused within one workflow.
1544
+ *
1545
+ * Cloudflare Workflows memoizes every `step.do` / `step.sleep` / `step.sleepUntil`
1546
+ * / `step.waitForEvent` call by its name: on replay the runtime returns the cached
1547
+ * result for a name it has already seen. Two distinct steps that share a name are
1548
+ * therefore a silent bug — the second call never runs its body and instead yields
1549
+ * the first's result, skipping the work (a charge, a write, an external wait)
1550
+ * without error. Hence `ERROR`/`INTERNAL`: it is a developer-facing correctness
1551
+ * defect in the workflow's own code, not a runtime-data nit.
1552
+ *
1553
+ * Only the first string-literal argument of each step call is compared; a step
1554
+ * named dynamically (`step.do(\`load-${id}\`, …)`) is omitted by the feeder, so a
1555
+ * deliberately-parameterized fan-out is never flagged. `ctx.runStep(stepDef, …)`
1556
+ * names (which come from `defineStep` in another file) are out of scope here.
1557
+ * Only runs when the declaration feeder supplied step evidence
1558
+ * (`workflow.steps` present); a runtime caller flags nothing.
1559
+ */
1560
+ declare const workflowDuplicateStepName: Lint;
1561
+ /**
1390
1562
  * A correctness lint: every `ctx.workflows.get("name")` call must reference a
1391
1563
  * workflow that exists — i.e. a `defineWorkflow` export in `lunora/workflows.ts`.
1392
1564
  * A `.get("x")` whose `"x"` resolves to no declared workflow is a typo or a
@@ -1448,4 +1620,4 @@ interface RunAdvisorOptions {
1448
1620
  * `static` lints at build time and defer `runtime` lints to a live shard.
1449
1621
  */
1450
1622
  declare const runAdvisor: (context: LintContext, options?: RunAdvisorOptions) => Finding[];
1451
- export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorContainer, type AdvisorHyperdriveCall, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorMaskProcedure, type AdvisorNondeterministicCall, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorR2sqlCall, type AdvisorRelation, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShardTraffic, type AdvisorSqlInterpolation, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorWorkflow, type AdvisorWorkflowCall, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, adminRouteWithoutGuard, authApiCallWithoutHeaders, circularFk, constraintValidator, containerOversizedInstance, containerPublicInternet, duplicateIndex, emptyIndex, filterWithoutIndex, fromServerSchema, hardcodedSecret, hotShard, hyperdriveOutsideAction, indexReferencesUnknownField, indexUtilization, loadAnalyticsRuntimeMetrics, maskUncoveredPiiColumn, nondeterministicQueryMutation, policyReferencesUnknownTable, publicArgumentUsesAny, publicMutationWithoutRatelimit, r2sqlOutsideAction, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, sqlInjectionRisk, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, workflowUnknownTarget, workflowUnused };
1623
+ export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorContainer, type AdvisorHyperdriveCall, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorMaskProcedure, type AdvisorMutatorWrite, type AdvisorNondeterministicCall, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorR2sqlCall, type AdvisorRelation, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShape, type AdvisorShardTraffic, type AdvisorSqlInterpolation, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorWorkflow, type AdvisorWorkflowCall, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, adminRouteWithoutGuard, authApiCallWithoutHeaders, circularFk, constraintValidator, containerOversizedInstance, containerPublicInternet, duplicateIndex, emptyIndex, filterWithoutIndex, fromServerSchema, hardcodedSecret, hotShard, hyperdriveOutsideAction, indexReferencesUnknownField, indexUtilization, loadAnalyticsRuntimeMetrics, maskUncoveredPiiColumn, mutatorFullRowReplace, nondeterministicQueryMutation, policyReferencesUnknownTable, publicArgumentUsesAny, publicMutationWithoutRatelimit, r2sqlOutsideAction, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, sqlInjectionRisk, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
package/dist/index.d.ts CHANGED
@@ -214,6 +214,28 @@ interface AdvisorMaskProcedure {
214
214
  visibility: "internal" | "public";
215
215
  }
216
216
  /**
217
+ * One whole-row `ctx.db.replace(id, document)` write discovered inside a custom
218
+ * mutator's authoritative `server` impl (`lunora/mutators.ts`) — the input the
219
+ * `mutator_full_row_replace` lint consumes.
220
+ *
221
+ * In the local-first sync engine a `replace` overwrites the entire row, so a
222
+ * concurrent edit to a *different* column (committed between this mutator's read
223
+ * and its write) is silently clobbered. `patch(id, { onlyTheField })` merges at
224
+ * the column level instead, letting independent field edits coexist — the
225
+ * blessed pattern for mutators on a synced (poke-live) table. Produced by the
226
+ * codegen feeder, which attributes each `replace` to the mutator export
227
+ * performing it; runtime callers don't supply it, so the lint finds nothing
228
+ * there.
229
+ */
230
+ interface AdvisorMutatorWrite {
231
+ /** The mutator export whose `server` impl performs the replace (e.g. `renameChannel`). */
232
+ exportName: string;
233
+ /** Openable source path the replace appears in — always `lunora/mutators.ts`. */
234
+ file: string;
235
+ /** 1-based line of the `replace(...)` call, or `0` when unknown. */
236
+ line: number;
237
+ }
238
+ /**
217
239
  * One non-deterministic API call discovered lexically inside a `query(...)` or
218
240
  * `mutation(...)` handler body — the input the `nondeterministic_query_mutation`
219
241
  * lint consumes. Produced by the codegen feeder, which walks each exported
@@ -384,6 +406,16 @@ interface AdvisorTable {
384
406
  optionalFields?: ReadonlySet<string>;
385
407
  /** Declared relations (`.relations((r) => …)`). */
386
408
  relations: ReadonlyArray<AdvisorRelation>;
409
+ /**
410
+ * Storage tier the table is declared in: `"global"` (a `.global()` table,
411
+ * lives in D1 — the cross-shard tier), `"shardBy"` (partitioned across
412
+ * shard DOs by a key), or `"root"` (the default single-DO table). Read by
413
+ * the `shape_*` lints to flag replication shapes targeting a `.global()`
414
+ * table (poll-refreshed/latency-tiered, not poke-live). Optional — the
415
+ * codegen feeder always supplies it, the runtime feeder derives it; a feeder
416
+ * that omits it leaves tier-sensitive lints to treat the table as local.
417
+ */
418
+ shardKind?: "global" | "root" | "shardBy";
387
419
  }
388
420
  /**
389
421
  * One declared index, flattened across Lunora's index kinds so a single lint can
@@ -440,6 +472,28 @@ interface AdvisorSecretLiteral {
440
472
  preview: string;
441
473
  }
442
474
  /**
475
+ * A replication shape declared via `defineShape({ table, where, columns? })` in
476
+ * `lunora/shapes.ts` (the local-first sync engine's partial-replication unit).
477
+ * The `shape_*` lints cross-reference each shape's {@link AdvisorShape.table}
478
+ * against the declared schema to flag a shape targeting an unknown table or a
479
+ * `.global()` table (which replicates through the latency-tiered D1 poll path,
480
+ * not the poke-live op-log). Supplied by the codegen feeder, which lifts only
481
+ * the export name + the static `table` literal; absent for runtime callers,
482
+ * where the shape lints find nothing.
483
+ */
484
+ interface AdvisorShape {
485
+ /** Export binding name — the shape's registry key (e.g. `channelMessages`). */
486
+ exportName: string;
487
+ /** File the shape is declared in (relative, for the operator to open). */
488
+ file: string;
489
+ /**
490
+ * The `table` string literal the shape replicates from, or `undefined` when
491
+ * the feeder could not read it as a plain string literal — tier-sensitive
492
+ * lints skip a shape with no resolvable table rather than guessing.
493
+ */
494
+ table?: string;
495
+ }
496
+ /**
443
497
  * One shard's observed traffic share — the input the `hot_shard` runtime lint
444
498
  * consumes. Produced by the studio backend, which fans out over a sharded
445
499
  * function's shards and reads each shard's recorded request volume from the
@@ -523,10 +577,27 @@ interface AdvisorTableSample {
523
577
  * how `AdvisorContainer` tracks `ContainerIR` and `AdvisorInsertWrite` tracks
524
578
  * `InsertWriteIR`).
525
579
  */
580
+ /** One durable step call lifted from a workflow handler body — the input the duplicate-step-name lint compares. Structural subset of codegen's `WorkflowStepIR`. */
581
+ interface AdvisorWorkflowStep {
582
+ /** 1-based line of the durable step call, or `0` when unknown. */
583
+ line: number;
584
+ /** The native step method invoked: `do` / `sleep` / `sleepUntil` / `waitForEvent`. */
585
+ method: string;
586
+ /** The step's static label (the first string-literal argument). */
587
+ name: string;
588
+ }
526
589
  /** One workflow declared via a `defineWorkflow()` export in `lunora/workflows.ts`. */
527
590
  interface AdvisorWorkflow {
528
591
  /** The `lunora/workflows.ts` export name, e.g. `orderPipeline`. */
529
592
  exportName: string;
593
+ /**
594
+ * The durable step labels discovered in the handler body, in source order —
595
+ * the duplicate-step-name input. Cloudflare memoizes a step by its name, so a
596
+ * name used twice makes the second call silently return the first's cached
597
+ * result. Supplied by the codegen feeder; `undefined` for runtime callers,
598
+ * where the lint finds nothing.
599
+ */
600
+ steps?: ReadonlyArray<AdvisorWorkflowStep>;
530
601
  }
531
602
  /** One `ctx.workflows.get("name")` call discovered in a function body. */
532
603
  interface AdvisorWorkflowCall {
@@ -660,6 +731,14 @@ interface LintContext {
660
731
  */
661
732
  maskProcedures?: ReadonlyArray<AdvisorMaskProcedure>;
662
733
  /**
734
+ * Whole-row `ctx.db.replace(id, document)` writes lifted from custom
735
+ * mutators' authoritative `server` impls (the `mutator_full_row_replace`
736
+ * input). Each `replace` overwrites the entire row, clobbering a concurrent
737
+ * edit to a different column on a synced table. Supplied by the codegen
738
+ * feeder; absent for runtime callers, where the lint finds nothing.
739
+ */
740
+ mutatorWrites?: ReadonlyArray<AdvisorMutatorWrite>;
741
+ /**
663
742
  * Non-deterministic API calls (`Date.now`, `Math.random`,
664
743
  * `crypto.randomUUID`, `crypto.getRandomValues`, `fetch`) discovered lexically
665
744
  * inside `query`/`mutation` handler bodies — the `nondeterministic_query_mutation`
@@ -708,6 +787,14 @@ interface LintContext {
708
787
  */
709
788
  secretLiterals?: ReadonlyArray<AdvisorSecretLiteral>;
710
789
  /**
790
+ * Replication shapes declared via `defineShape` in `lunora/shapes.ts` — the
791
+ * `shape_unknown_table` and `shape_targets_global_table` lint input. Each
792
+ * carries the export name and its static `table` literal, cross-referenced
793
+ * against {@link LintContext.schema}. Supplied by the codegen feeder; absent
794
+ * for runtime callers, where the shape lints find nothing.
795
+ */
796
+ shapes?: ReadonlyArray<AdvisorShape>;
797
+ /**
711
798
  * Per-shard observed traffic — the `hot_shard` lint input. Supplied by the
712
799
  * studio backend, which fans out over a sharded function's shards and reads
713
800
  * each shard's recorded request volume from the durable `__lunora_metrics`
@@ -1133,6 +1220,30 @@ declare const indexReferencesUnknownField: Lint;
1133
1220
  */
1134
1221
  declare const maskUncoveredPiiColumn: Lint;
1135
1222
  /**
1223
+ * Flags a custom mutator whose authoritative `server` impl writes a row with
1224
+ * `ctx.db.replace(id, document)` — a whole-document overwrite.
1225
+ *
1226
+ * The local-first sync engine serializes mutators in the shard DO, so two
1227
+ * mutators that touch the *same row* but *different columns* both run to
1228
+ * completion — but only if each writes its own column. A `replace` overwrites
1229
+ * the entire row from the document the mutator assembled, so a concurrent edit
1230
+ * to another column (committed between this mutator's read and its write, or
1231
+ * carried as a pending optimistic overlay on a client) is silently clobbered:
1232
+ * the kind of "two offline edits to different fields fight each other" data loss
1233
+ * a column-level merge avoids. `ctx.db.patch(id, { onlyTheChangedField })`
1234
+ * merges at the column level instead, so independent field edits coexist.
1235
+ *
1236
+ * `WARN`, not `ERROR`: `replace` is legitimate when the mutator genuinely owns
1237
+ * the whole row (a full-form save, a state-machine transition that rewrites
1238
+ * every field). The lint just surfaces the column-clobber risk so a developer
1239
+ * reaches for `patch` by default on a synced table.
1240
+ *
1241
+ * **Evidence supply**: runs only when the codegen feeder supplies
1242
+ * `context.mutatorWrites` (each a `replace` call lifted from a mutator's inline
1243
+ * `server` body); absent for runtime callers, where the lint finds nothing.
1244
+ */
1245
+ declare const mutatorFullRowReplace: Lint;
1246
+ /**
1136
1247
  * Flags a non-deterministic API call inside a `query(...)` or `mutation(...)`
1137
1248
  * handler body.
1138
1249
  *
@@ -1285,6 +1396,48 @@ declare const relationReferencesUnknownTable: Lint;
1285
1396
  */
1286
1397
  declare const rlsUncoveredTable: Lint;
1287
1398
  /**
1399
+ * Flags a replication shape whose `table` is a `.global()` table.
1400
+ *
1401
+ * Poke-live replication is a per-shard-DO property: the shard owns its SQLite
1402
+ * and a monotonic `__cdc_log`, so a write produces an ordered op the DO pokes to
1403
+ * every subscriber at the next flush. A `.global()` table lives outside the
1404
+ * shard DO's SQLite op-log (in a global backend — D1, or Hyperdrive-fronted
1405
+ * Postgres/MySQL) — so a shape over a global table cannot be poke-live. It is
1406
+ * served through the cross-shard tier: **coordinator/poll-refreshed, latency-
1407
+ * tiered**, not live. That is a real and supported tier (it is the recommended
1408
+ * answer for cross-shard reads — denormalize, or move the joined table to
1409
+ * `.global()` and read through the global backend), but its freshness semantics
1410
+ * differ from a sharded shape's, so the boundary is surfaced rather than hidden.
1411
+ *
1412
+ * `WARN`, not `ERROR`: a global-table shape is a legitimate design once you
1413
+ * accept the poll-refresh latency; the lint just makes the tier explicit so a
1414
+ * developer does not assume poke-live freshness.
1415
+ *
1416
+ * **Evidence supply**: runs only when the codegen feeder supplies
1417
+ * `context.shapes`; the table's tier comes from the schema's `shardKind`. A
1418
+ * shape whose table is unknown (caught by `shape_unknown_table`) or whose tier
1419
+ * the feeder didn't supply is skipped.
1420
+ */
1421
+ declare const shapeTargetsGlobalTable: Lint;
1422
+ /**
1423
+ * Flags a replication shape whose `table` names a table that does not exist in
1424
+ * the schema.
1425
+ *
1426
+ * `defineShape({ table: "messages", … })` binds a shape to a table by a plain
1427
+ * string. A live `subscribeShape("…")` resolves that shape server-side and runs
1428
+ * its membership query against the named table — so a typo, a stale name after a
1429
+ * rename, or a copy-paste mistake produces a shape that can never resolve a
1430
+ * rowset: the subscription seeds empty and then errors at the first flush
1431
+ * (`no such table`). This is a definite, build-time-detectable break, so it is
1432
+ * an `ERROR` — surfaced before the broken shape ever ships.
1433
+ *
1434
+ * **Evidence supply**: runs only when the codegen feeder supplies
1435
+ * `context.shapes`. A shape whose `table` wasn't a static string literal (no
1436
+ * resolvable name) is skipped rather than guessed at, so the lint under-reports
1437
+ * rather than raising false alarms.
1438
+ */
1439
+ declare const shapeUnknownTable: Lint;
1440
+ /**
1288
1441
  * Flags a `ctx.sql` tagged-template that splices an unparameterized
1289
1442
  * string-building expression into the query.
1290
1443
  *
@@ -1387,6 +1540,25 @@ declare const unindexedRelationTarget: Lint;
1387
1540
  */
1388
1541
  declare const userCreatingMutationWithoutCaptcha: Lint;
1389
1542
  /**
1543
+ * Flags a durable step name reused within one workflow.
1544
+ *
1545
+ * Cloudflare Workflows memoizes every `step.do` / `step.sleep` / `step.sleepUntil`
1546
+ * / `step.waitForEvent` call by its name: on replay the runtime returns the cached
1547
+ * result for a name it has already seen. Two distinct steps that share a name are
1548
+ * therefore a silent bug — the second call never runs its body and instead yields
1549
+ * the first's result, skipping the work (a charge, a write, an external wait)
1550
+ * without error. Hence `ERROR`/`INTERNAL`: it is a developer-facing correctness
1551
+ * defect in the workflow's own code, not a runtime-data nit.
1552
+ *
1553
+ * Only the first string-literal argument of each step call is compared; a step
1554
+ * named dynamically (`step.do(\`load-${id}\`, …)`) is omitted by the feeder, so a
1555
+ * deliberately-parameterized fan-out is never flagged. `ctx.runStep(stepDef, …)`
1556
+ * names (which come from `defineStep` in another file) are out of scope here.
1557
+ * Only runs when the declaration feeder supplied step evidence
1558
+ * (`workflow.steps` present); a runtime caller flags nothing.
1559
+ */
1560
+ declare const workflowDuplicateStepName: Lint;
1561
+ /**
1390
1562
  * A correctness lint: every `ctx.workflows.get("name")` call must reference a
1391
1563
  * workflow that exists — i.e. a `defineWorkflow` export in `lunora/workflows.ts`.
1392
1564
  * A `.get("x")` whose `"x"` resolves to no declared workflow is a typo or a
@@ -1448,4 +1620,4 @@ interface RunAdvisorOptions {
1448
1620
  * `static` lints at build time and defer `runtime` lints to a live shard.
1449
1621
  */
1450
1622
  declare const runAdvisor: (context: LintContext, options?: RunAdvisorOptions) => Finding[];
1451
- export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorContainer, type AdvisorHyperdriveCall, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorMaskProcedure, type AdvisorNondeterministicCall, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorR2sqlCall, type AdvisorRelation, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShardTraffic, type AdvisorSqlInterpolation, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorWorkflow, type AdvisorWorkflowCall, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, adminRouteWithoutGuard, authApiCallWithoutHeaders, circularFk, constraintValidator, containerOversizedInstance, containerPublicInternet, duplicateIndex, emptyIndex, filterWithoutIndex, fromServerSchema, hardcodedSecret, hotShard, hyperdriveOutsideAction, indexReferencesUnknownField, indexUtilization, loadAnalyticsRuntimeMetrics, maskUncoveredPiiColumn, nondeterministicQueryMutation, policyReferencesUnknownTable, publicArgumentUsesAny, publicMutationWithoutRatelimit, r2sqlOutsideAction, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, sqlInjectionRisk, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, workflowUnknownTarget, workflowUnused };
1623
+ export { AE_METRIC_EVENTS, ALL_LINTS, type AdvisorAdminRoute, type AdvisorArgumentValidator, type AdvisorAuthApiCall, type AdvisorContainer, type AdvisorHyperdriveCall, type AdvisorIndex, type AdvisorIndexHit, type AdvisorInsertWrite, type AdvisorMaskProcedure, type AdvisorMutatorWrite, type AdvisorNondeterministicCall, type AdvisorProcedureProtection, type AdvisorQueryRead, type AdvisorR2sqlCall, type AdvisorRelation, type AdvisorRlsProcedure, type AdvisorSchema, type AdvisorSecretLiteral, type AdvisorShape, type AdvisorShardTraffic, type AdvisorSqlInterpolation, type AdvisorTable, type AdvisorTableSample, type AdvisorTableScan, type AdvisorWorkflow, type AdvisorWorkflowCall, type AnalyticsMetricsOptions, type AnalyticsMetricsSource, type AnalyticsRuntimeMetrics, type Category, type Facing, type Finding, type Level, type Lint, type LintContext, type LintSource, RUNTIME_LINTS, RunAdvisorOptions, STATIC_LINTS, adminRouteWithoutGuard, authApiCallWithoutHeaders, circularFk, constraintValidator, containerOversizedInstance, containerPublicInternet, duplicateIndex, emptyIndex, filterWithoutIndex, fromServerSchema, hardcodedSecret, hotShard, hyperdriveOutsideAction, indexReferencesUnknownField, indexUtilization, loadAnalyticsRuntimeMetrics, maskUncoveredPiiColumn, mutatorFullRowReplace, nondeterministicQueryMutation, policyReferencesUnknownTable, publicArgumentUsesAny, publicMutationWithoutRatelimit, r2sqlOutsideAction, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, sqlInjectionRisk, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
package/dist/index.mjs CHANGED
@@ -13,6 +13,7 @@ import hardcodedSecret from './packem_shared/hardcodedSecret-W2pz1UZB.mjs';
13
13
  import hyperdriveOutsideAction from './packem_shared/hyperdriveOutsideAction-BgZqX7Xg.mjs';
14
14
  import indexReferencesUnknownField from './packem_shared/indexReferencesUnknownField-DH0_dbUY.mjs';
15
15
  import maskUncoveredPiiColumn from './packem_shared/maskUncoveredPiiColumn-DjGIPG6M.mjs';
16
+ import mutatorFullRowReplace from './packem_shared/mutatorFullRowReplace-BJnNDaIV.mjs';
16
17
  import nondeterministicQueryMutation from './packem_shared/nondeterministicQueryMutation-GXES1fLp.mjs';
17
18
  import policyReferencesUnknownTable from './packem_shared/policyReferencesUnknownTable-DtaIEovd.mjs';
18
19
  import publicArgumentUsesAny from './packem_shared/publicArgumentUsesAny-C71b2NCf.mjs';
@@ -21,22 +22,27 @@ import r2sqlOutsideAction from './packem_shared/r2sqlOutsideAction-CtqxvMuV.mjs'
21
22
  import relationReferencesUnknownField from './packem_shared/relationReferencesUnknownField-YznyXt_7.mjs';
22
23
  import relationReferencesUnknownTable from './packem_shared/relationReferencesUnknownTable-DrorpKYe.mjs';
23
24
  import rlsUncoveredTable from './packem_shared/rlsUncoveredTable-CxEfZ5eZ.mjs';
25
+ import shapeTargetsGlobalTable from './packem_shared/shapeTargetsGlobalTable-DHrf4Koi.mjs';
26
+ import shapeUnknownTable from './packem_shared/shapeUnknownTable-C8aDWFoe.mjs';
24
27
  import sqlInjectionRisk from './packem_shared/sqlInjectionRisk-zwytYGLt.mjs';
25
28
  import tableWithoutInsert from './packem_shared/tableWithoutInsert-CbbaYIP4.mjs';
26
29
  import unboundedStringArgument from './packem_shared/unboundedStringArgument-DThg2-wt.mjs';
27
30
  import unindexedForeignKey from './packem_shared/unindexedForeignKey-BgJbKyqK.mjs';
28
31
  import unindexedRelationTarget from './packem_shared/unindexedRelationTarget-D6eyj6Xx.mjs';
29
32
  import userCreatingMutationWithoutCaptcha from './packem_shared/userCreatingMutationWithoutCaptcha-CH31YsUZ.mjs';
33
+ import workflowDuplicateStepName from './packem_shared/workflowDuplicateStepName-ioBxPBCy.mjs';
30
34
  import workflowUnknownTarget from './packem_shared/workflowUnknownTarget-Cdd7WhKQ.mjs';
31
35
  import workflowUnused from './packem_shared/workflowUnused-D0jHxdz9.mjs';
32
36
  export { AE_METRIC_EVENTS, loadAnalyticsRuntimeMetrics } from './packem_shared/AE_METRIC_EVENTS-DexctYv6.mjs';
33
- export { fromServerSchema } from './packem_shared/fromServerSchema-DinF1nph.mjs';
37
+ export { fromServerSchema } from './packem_shared/fromServerSchema-TWb7dTHC.mjs';
34
38
 
35
39
  const STATIC_LINTS = [
36
40
  indexReferencesUnknownField,
37
41
  relationReferencesUnknownTable,
38
42
  relationReferencesUnknownField,
39
43
  workflowUnknownTarget,
44
+ workflowDuplicateStepName,
45
+ shapeUnknownTable,
40
46
  emptyIndex,
41
47
  circularFk,
42
48
  unindexedForeignKey,
@@ -45,6 +51,8 @@ const STATIC_LINTS = [
45
51
  tableWithoutInsert,
46
52
  workflowUnused,
47
53
  filterWithoutIndex,
54
+ shapeTargetsGlobalTable,
55
+ mutatorFullRowReplace,
48
56
  nondeterministicQueryMutation,
49
57
  hyperdriveOutsideAction,
50
58
  r2sqlOutsideAction,
@@ -76,4 +84,4 @@ const runAdvisor = (context, options = {}) => {
76
84
  return findings;
77
85
  };
78
86
 
79
- export { ALL_LINTS, RUNTIME_LINTS, STATIC_LINTS, adminRouteWithoutGuard, authApiCallWithoutHeaders, circularFk, constraintValidator, containerOversizedInstance, containerPublicInternet, duplicateIndex, emptyIndex, filterWithoutIndex, hardcodedSecret, hotShard, hyperdriveOutsideAction, indexReferencesUnknownField, indexUtilization, maskUncoveredPiiColumn, nondeterministicQueryMutation, policyReferencesUnknownTable, publicArgumentUsesAny, publicMutationWithoutRatelimit, r2sqlOutsideAction, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, sqlInjectionRisk, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, workflowUnknownTarget, workflowUnused };
87
+ export { ALL_LINTS, RUNTIME_LINTS, STATIC_LINTS, adminRouteWithoutGuard, authApiCallWithoutHeaders, circularFk, constraintValidator, containerOversizedInstance, containerPublicInternet, duplicateIndex, emptyIndex, filterWithoutIndex, hardcodedSecret, hotShard, hyperdriveOutsideAction, indexReferencesUnknownField, indexUtilization, maskUncoveredPiiColumn, mutatorFullRowReplace, nondeterministicQueryMutation, policyReferencesUnknownTable, publicArgumentUsesAny, publicMutationWithoutRatelimit, r2sqlOutsideAction, relationReferencesUnknownField, relationReferencesUnknownTable, rlsUncoveredTable, runAdvisor, shapeTargetsGlobalTable, shapeUnknownTable, sqlInjectionRisk, tableWithoutInsert, unboundedStringArgument, unindexedForeignKey, unindexedRelationTarget, userCreatingMutationWithoutCaptcha, workflowDuplicateStepName, workflowUnknownTarget, workflowUnused };
@@ -32,6 +32,7 @@ const fromServerSchema = (schema) => {
32
32
  indexes,
33
33
  name,
34
34
  optionalFields,
35
+ shardKind: table.shardMode.kind,
35
36
  relations: Object.entries(table.relationMap).map(([accessor, relation]) => {
36
37
  return {
37
38
  field: relation.field,
@@ -0,0 +1,26 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const mutatorFullRowReplace = {
4
+ categories: ["SCHEMA"],
5
+ description: "A custom mutator's `server` impl writes with `ctx.db.replace(id, document)` — a whole-row overwrite. On a synced table this clobbers a concurrent edit to a different column; `ctx.db.patch(id, { field })` merges at the column level so independent field edits coexist.",
6
+ facing: "EXTERNAL",
7
+ level: "WARN",
8
+ name: "mutator_full_row_replace",
9
+ remediation: "Prefer `ctx.db.patch(id, { onlyTheChangedField })` so a concurrent edit to another column isn't lost. Keep `replace` only when the mutator genuinely owns the entire row (e.g. a full-form save).",
10
+ run: (context) => {
11
+ if (context.mutatorWrites === void 0) {
12
+ return [];
13
+ }
14
+ return context.mutatorWrites.map(
15
+ (write) => emit(mutatorFullRowReplace, {
16
+ cacheKey: `mutator_full_row_replace:${write.exportName}:${String(write.line)}`,
17
+ detail: `Mutator \`${write.exportName}\` (${write.file}:${String(write.line)}) writes with \`ctx.db.replace(...)\`, overwriting the whole row. A concurrent edit to a different column is clobbered — use \`ctx.db.patch(id, { field })\` to merge at the column level.`,
18
+ metadata: { exportName: write.exportName, file: write.file, line: write.line }
19
+ })
20
+ );
21
+ },
22
+ source: "static",
23
+ title: "Mutator overwrites the whole row (clobbers concurrent column edits)"
24
+ };
25
+
26
+ export { mutatorFullRowReplace as default };
@@ -0,0 +1,34 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const shapeTargetsGlobalTable = {
4
+ categories: ["PERFORMANCE"],
5
+ description: "A `defineShape` replicates from a `.global()` table. Global tables live outside the shard DO's SQLite op-log, so the shape is served through the cross-shard tier — coordinator/poll-refreshed and latency-tiered, not poke-live like a sharded shape.",
6
+ facing: "EXTERNAL",
7
+ level: "WARN",
8
+ name: "shape_targets_global_table",
9
+ remediation: "Expected if you want the cross-shard tier — just don't assume poke-live freshness; global shapes refresh on a poll. For live updates, replicate from a sharded table instead (denormalize the columns you need into the shard).",
10
+ run: (context) => {
11
+ if (context.shapes === void 0) {
12
+ return [];
13
+ }
14
+ const globalTables = new Set(context.schema.tables.filter((table) => table.shardKind === "global").map((table) => table.name));
15
+ const findings = [];
16
+ for (const shape of context.shapes) {
17
+ if (shape.table === void 0 || !globalTables.has(shape.table)) {
18
+ continue;
19
+ }
20
+ findings.push(
21
+ emit(shapeTargetsGlobalTable, {
22
+ cacheKey: `shape_targets_global_table:${shape.exportName}`,
23
+ detail: `Shape \`${shape.exportName}\` (${shape.file}) replicates from the \`.global()\` table \`${shape.table}\`. It is served through the cross-shard global tier — poll-refreshed and latency-tiered, not poke-live.`,
24
+ metadata: { exportName: shape.exportName, file: shape.file, table: shape.table }
25
+ })
26
+ );
27
+ }
28
+ return findings;
29
+ },
30
+ source: "static",
31
+ title: "Shape replicates from a global (cross-shard) table"
32
+ };
33
+
34
+ export { shapeTargetsGlobalTable as default };
@@ -0,0 +1,34 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const shapeUnknownTable = {
4
+ categories: ["SCHEMA"],
5
+ description: "A `defineShape` is bound to a `table` name that does not exist in the schema. The shape can never resolve a rowset — its subscription seeds empty and errors at the first flush.",
6
+ facing: "INTERNAL",
7
+ level: "ERROR",
8
+ name: "shape_unknown_table",
9
+ remediation: "Fix the shape's `table` to a real table name (check for a typo or a table that was renamed/removed).",
10
+ run: (context) => {
11
+ if (context.shapes === void 0) {
12
+ return [];
13
+ }
14
+ const knownTables = new Set(context.schema.tables.map((table) => table.name));
15
+ const findings = [];
16
+ for (const shape of context.shapes) {
17
+ if (shape.table === void 0 || knownTables.has(shape.table)) {
18
+ continue;
19
+ }
20
+ findings.push(
21
+ emit(shapeUnknownTable, {
22
+ cacheKey: `shape_unknown_table:${shape.exportName}`,
23
+ detail: `Shape \`${shape.exportName}\` (${shape.file}) replicates from table \`${shape.table}\`, which is not declared in the schema. The shape can never resolve a rowset.`,
24
+ metadata: { exportName: shape.exportName, file: shape.file, table: shape.table }
25
+ })
26
+ );
27
+ }
28
+ return findings;
29
+ },
30
+ source: "static",
31
+ title: "Shape bound to an unknown table"
32
+ };
33
+
34
+ export { shapeUnknownTable as default };
@@ -0,0 +1,48 @@
1
+ import { e as emit } from './finding-Dm_zvzS1.mjs';
2
+
3
+ const workflowDuplicateStepName = {
4
+ categories: ["SCHEMA"],
5
+ description: "Two durable steps in this workflow share a name. Cloudflare memoizes a step by its name, so on replay the second call returns the first step's cached result instead of running its body — silently skipping the work without an error.",
6
+ facing: "INTERNAL",
7
+ level: "ERROR",
8
+ // eslint-disable-next-line no-secrets/no-secrets -- the lint's rule id, not a credential
9
+ name: "workflow_duplicate_step_name",
10
+ remediation: "Give every `step.do` / `step.sleep` / `step.sleepUntil` / `step.waitForEvent` call in the workflow a unique name. If a step legitimately repeats (e.g. a loop), make the name distinct per iteration by interpolating the item id into the step name.",
11
+ run: (context) => {
12
+ if (context.workflows === void 0) {
13
+ return [];
14
+ }
15
+ const findings = [];
16
+ for (const workflow of context.workflows) {
17
+ const { steps } = workflow;
18
+ if (steps === void 0 || steps.length === 0) {
19
+ continue;
20
+ }
21
+ const firstLineByName = /* @__PURE__ */ new Map();
22
+ const reported = /* @__PURE__ */ new Set();
23
+ for (const step of steps) {
24
+ const firstLine = firstLineByName.get(step.name);
25
+ if (firstLine === void 0) {
26
+ firstLineByName.set(step.name, step.line);
27
+ continue;
28
+ }
29
+ if (reported.has(step.name)) {
30
+ continue;
31
+ }
32
+ reported.add(step.name);
33
+ findings.push(
34
+ emit(workflowDuplicateStepName, {
35
+ cacheKey: `workflow_duplicate_step_name:${workflow.exportName}:${step.name}`,
36
+ detail: `Workflow "${workflow.exportName}" reuses the durable step name "${step.name}" (first at line ${String(firstLine)}, again at line ${String(step.line)}). The second call returns the first's cached result instead of running.`,
37
+ metadata: { firstLine, line: step.line, stepName: step.name, workflow: workflow.exportName }
38
+ })
39
+ );
40
+ }
41
+ }
42
+ return findings;
43
+ },
44
+ source: "static",
45
+ title: "Duplicate durable step name in workflow"
46
+ };
47
+
48
+ export { workflowDuplicateStepName as default };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/advisor",
3
- "version": "1.0.0-alpha.7",
3
+ "version": "1.0.0-alpha.9",
4
4
  "description": "Schema & query lints (splinter-style advisors) for Lunora, feeding the Studio Advisors view",
5
5
  "keywords": [
6
6
  "advisor",
@@ -46,7 +46,7 @@
46
46
  "access": "public"
47
47
  },
48
48
  "dependencies": {
49
- "@lunora/server": "1.0.0-alpha.6"
49
+ "@lunora/server": "1.0.0-alpha.7"
50
50
  },
51
51
  "engines": {
52
52
  "node": "^22.15.0 || >=24.11.0"