@stardeck-customer-apps/eslint-plugin 1.5.0 → 1.6.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/SKILL.md CHANGED
@@ -116,6 +116,28 @@ install rail. Only the direct specifier is checked — it does not prove the
116
116
  entry's full import graph never reaches server code (that's the real Next
117
117
  client-build seam's job) — so do not add exemptions here to work around it.
118
118
 
119
+ ### `rail-marker-congruent`
120
+
121
+ A `// @stardeck-module-rail-generated ...` first line means the module install
122
+ rail owns the file. The marker's kind must match the file's path shape:
123
+ `scope=root` / `scope=enhancement:<peer>` only on `src/app/api/**/route.ts`,
124
+ `scope=route path=<route>` only on `src/app/<route>/page.tsx` with a matching
125
+ `path=`, and the ownerless scopes only on their `src/*.gen.ts` file. The five `src/*.gen.ts`
126
+ files must always carry their marker; a marker-less one is reported too.
127
+
128
+ ```ts
129
+ // src/app/(admin)/admin/finance/assets/page.tsx — Bad
130
+ // @stardeck-module-rail-generated owner=finance scope=root
131
+
132
+ // src/app/(admin)/admin/finance/assets/page.tsx — Good
133
+ // @stardeck-module-rail-generated owner=finance scope=route path=(admin)/admin/finance/assets
134
+ ```
135
+
136
+ **Why?** Blueprint publish regenerates module artifacts and fails closed on a
137
+ swapped marker ("incongruent marker at ... refusing to reconcile"). Catching it
138
+ at lint time is faster than a failed publish. Fix by deleting the marker line if
139
+ the file is hand-written, or restoring the generated stub.
140
+
119
141
  ## Disable for a Line
120
142
 
121
143
  ```tsx
package/dist/index.cjs CHANGED
@@ -560,12 +560,157 @@ var rule4 = {
560
560
  };
561
561
  var no_server_import_in_conventional_entry_default = rule4;
562
562
 
563
+ // src/rules/rail-marker-congruent.ts
564
+ var import_node_path3 = __toESM(require("path"), 1);
565
+ var MARKER = "@stardeck-module-rail-generated";
566
+ var MARKER_RE = new RegExp(
567
+ `^//\\s*${MARKER}\\s+(?:owner=([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\\s+)?scope=(\\S+)(?:\\s+path=(.+))?$`
568
+ );
569
+ var OWNERLESS_SCOPES = {
570
+ registry: "registry",
571
+ contributions: "contributions",
572
+ datastores: "datastores",
573
+ i18n: "i18n",
574
+ init: "init"
575
+ };
576
+ var GEN_FILE_KINDS = {
577
+ "modules.gen.ts": "registry",
578
+ "module-contributions.gen.ts": "contributions",
579
+ "module-datastores.gen.ts": "datastores",
580
+ "module-i18n.gen.ts": "i18n",
581
+ "module-init.server.gen.ts": "init"
582
+ };
583
+ var METHODS = "GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS";
584
+ var OWNER = "[a-z][a-z0-9]*(?:-[a-z0-9]+)*";
585
+ function isLegacyApiAdapterBody(source, owner) {
586
+ const lines = source.replace(/\r\n/g, "\n").split("\n").slice(1).map((line) => line.trim()).filter(Boolean);
587
+ const importMatch = lines[0]?.match(
588
+ new RegExp(
589
+ `^import \\{ (endpointHandlers|production(?:[A-Z][A-Za-z0-9]*)?Handlers) \\} from "(@/modules/${owner}/server|@/modules/${owner}/enhancements/${OWNER}|@/lib/${owner}-wiring)";$`
590
+ )
591
+ );
592
+ if (!importMatch) return false;
593
+ const binding = importMatch[1];
594
+ const exports2 = lines.slice(1);
595
+ return exports2.length > 0 && exports2.every(
596
+ (line) => new RegExp(`^export const (${METHODS}) = ${binding}\\.\\1_[A-Za-z0-9_]+;$`).test(line)
597
+ );
598
+ }
599
+ function isLegacyRouteAdapterBody(source, routePath) {
600
+ return new RegExp(
601
+ `^// ${MARKER} scope=routes
602
+ /\\*\\* DO NOT EDIT \u2014 owned by the module install rail\\. \\*/
603
+ export \\{ default \\} from "@/modules/${OWNER}/routes/${routePath.replace(/[.*+?^$()[\]{}|]/g, "\\$&")}/page";
604
+ ?$`
605
+ ).test(source.replace(/\r\n/g, "\n"));
606
+ }
607
+ function parseMarker(source) {
608
+ const firstLine = source.split(/\r?\n/, 1)[0]?.trim() ?? "";
609
+ const match = firstLine.match(MARKER_RE);
610
+ if (!match) return null;
611
+ const [, owner, scope, routePath] = match;
612
+ const ownerless = OWNERLESS_SCOPES[scope];
613
+ if (ownerless) return owner ? null : { kind: ownerless };
614
+ if (scope === "routes") return owner ? null : { kind: "route", legacyRoute: true };
615
+ if (!owner) return null;
616
+ if (scope === "route") {
617
+ const trimmed = routePath?.trim();
618
+ return trimmed ? { kind: "route", routePath: trimmed, owner } : null;
619
+ }
620
+ if (scope === "root" || scope.startsWith("enhancement:")) return { kind: "endpoint", owner };
621
+ return null;
622
+ }
623
+ function shapeForPath(filename) {
624
+ const posix = filename.split(import_node_path3.default.sep).join("/");
625
+ const gen = posix.match(/\/src\/([^/]+\.gen\.ts)$/);
626
+ if (gen) {
627
+ const kind = GEN_FILE_KINDS[gen[1]];
628
+ return kind ? { kind } : null;
629
+ }
630
+ const idx = posix.lastIndexOf("/src/app/");
631
+ if (idx < 0) return null;
632
+ const rest = posix.slice(idx + "/src/app/".length);
633
+ if (rest.startsWith("api/")) {
634
+ const m2 = rest.match(/^(.+)\/route\.ts$/);
635
+ return m2 ? { kind: "endpoint", routePath: m2[1] } : null;
636
+ }
637
+ const m = rest.match(/^(?:(.*)\/)?page\.(?:tsx|ts|jsx|js)$/);
638
+ return m ? { kind: "route", routePath: m[1] ?? "" } : null;
639
+ }
640
+ var rule5 = {
641
+ meta: {
642
+ type: "problem",
643
+ docs: {
644
+ description: "Require a @stardeck-module-rail-generated marker to match the file's path shape, so Blueprint publish can reconcile the file",
645
+ recommended: true
646
+ },
647
+ messages: {
648
+ wrongKind: "Found a {{found}} rail marker on a path shaped for {{expected}} artifacts. Blueprint publish will refuse to reconcile a swapped marker. Delete the marker line if this file is hand-written, or restore the generated stub.",
649
+ missingMarker: "Generated file {{file}} has no @stardeck-module-rail-generated marker on line 1. Blueprint publish will refuse to overwrite it as unmanaged. Restore the marker line (or the whole generated file) rather than editing it by hand.",
650
+ legacyRouteBody: "Rail marker scope=routes is only valid with its original body: the DO NOT EDIT line and a single default re-export of this route's page from its Module. Blueprint publish treats anything else as unmanaged. Delete the marker line if this file is hand-written, or restore the generated stub.",
651
+ wrongRoutePath: 'Rail route marker claims path="{{claimed}}" but this file serves "{{actual}}". Blueprint publish will refuse to reconcile it. Delete the marker line if this file is hand-written, or restore the generated stub.'
652
+ },
653
+ schema: []
654
+ },
655
+ create(context) {
656
+ const filename = context.filename;
657
+ if (!filename || filename === "<input>" || filename === "<text>") return {};
658
+ const shape = shapeForPath(filename);
659
+ if (!shape) return {};
660
+ return {
661
+ Program(node) {
662
+ const marker = parseMarker(context.sourceCode.text);
663
+ const loc = { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } };
664
+ if (!marker) {
665
+ if (GEN_FILE_KINDS[import_node_path3.default.basename(filename)]) {
666
+ context.report({
667
+ node,
668
+ loc,
669
+ messageId: "missingMarker",
670
+ data: { file: import_node_path3.default.basename(filename) }
671
+ });
672
+ }
673
+ return;
674
+ }
675
+ if (marker.kind !== shape.kind) {
676
+ if (shape.kind === "endpoint" && marker.kind === "route" && marker.routePath === shape.routePath && isLegacyApiAdapterBody(context.sourceCode.text, marker.owner)) {
677
+ return;
678
+ }
679
+ context.report({
680
+ node,
681
+ loc,
682
+ messageId: "wrongKind",
683
+ data: { found: marker.kind, expected: shape.kind }
684
+ });
685
+ return;
686
+ }
687
+ if (marker.legacyRoute) {
688
+ if (!isLegacyRouteAdapterBody(context.sourceCode.text, shape.routePath ?? "")) {
689
+ context.report({ node, loc, messageId: "legacyRouteBody" });
690
+ }
691
+ return;
692
+ }
693
+ if (marker.kind === "route" && marker.routePath !== shape.routePath) {
694
+ context.report({
695
+ node,
696
+ loc,
697
+ messageId: "wrongRoutePath",
698
+ data: { claimed: marker.routePath ?? "", actual: shape.routePath ?? "" }
699
+ });
700
+ }
701
+ }
702
+ };
703
+ }
704
+ };
705
+ var rail_marker_congruent_default = rule5;
706
+
563
707
  // src/rules/index.ts
564
708
  var rules = {
565
709
  "no-template-literal-classname": no_template_literal_classname_default,
566
710
  "no-kysely-interactive-transaction": no_kysely_interactive_transaction_default,
567
711
  "no-module-internal-import": no_module_internal_import_default,
568
- "no-server-import-in-conventional-entry": no_server_import_in_conventional_entry_default
712
+ "no-server-import-in-conventional-entry": no_server_import_in_conventional_entry_default,
713
+ "rail-marker-congruent": rail_marker_congruent_default
569
714
  };
570
715
 
571
716
  // src/configs/recommended.ts
@@ -580,7 +725,8 @@ var recommended = {
580
725
  [`${pluginName}/no-template-literal-classname`]: "error",
581
726
  [`${pluginName}/no-kysely-interactive-transaction`]: "error",
582
727
  [`${pluginName}/no-module-internal-import`]: "error",
583
- [`${pluginName}/no-server-import-in-conventional-entry`]: "error"
728
+ [`${pluginName}/no-server-import-in-conventional-entry`]: "error",
729
+ [`${pluginName}/rail-marker-congruent`]: "error"
584
730
  }
585
731
  };
586
732
 
package/dist/index.d.cts CHANGED
@@ -6,6 +6,7 @@ declare const rules: {
6
6
  "no-kysely-interactive-transaction": eslint.Rule.RuleModule;
7
7
  "no-module-internal-import": eslint.Rule.RuleModule;
8
8
  "no-server-import-in-conventional-entry": eslint.Rule.RuleModule;
9
+ "rail-marker-congruent": eslint.Rule.RuleModule;
9
10
  };
10
11
 
11
12
  declare const configs: Record<string, Linter.Config>;
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ declare const rules: {
6
6
  "no-kysely-interactive-transaction": eslint.Rule.RuleModule;
7
7
  "no-module-internal-import": eslint.Rule.RuleModule;
8
8
  "no-server-import-in-conventional-entry": eslint.Rule.RuleModule;
9
+ "rail-marker-congruent": eslint.Rule.RuleModule;
9
10
  };
10
11
 
11
12
  declare const configs: Record<string, Linter.Config>;
package/dist/index.js CHANGED
@@ -522,12 +522,157 @@ var rule4 = {
522
522
  };
523
523
  var no_server_import_in_conventional_entry_default = rule4;
524
524
 
525
+ // src/rules/rail-marker-congruent.ts
526
+ import path3 from "path";
527
+ var MARKER = "@stardeck-module-rail-generated";
528
+ var MARKER_RE = new RegExp(
529
+ `^//\\s*${MARKER}\\s+(?:owner=([a-z][a-z0-9]*(?:-[a-z0-9]+)*)\\s+)?scope=(\\S+)(?:\\s+path=(.+))?$`
530
+ );
531
+ var OWNERLESS_SCOPES = {
532
+ registry: "registry",
533
+ contributions: "contributions",
534
+ datastores: "datastores",
535
+ i18n: "i18n",
536
+ init: "init"
537
+ };
538
+ var GEN_FILE_KINDS = {
539
+ "modules.gen.ts": "registry",
540
+ "module-contributions.gen.ts": "contributions",
541
+ "module-datastores.gen.ts": "datastores",
542
+ "module-i18n.gen.ts": "i18n",
543
+ "module-init.server.gen.ts": "init"
544
+ };
545
+ var METHODS = "GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS";
546
+ var OWNER = "[a-z][a-z0-9]*(?:-[a-z0-9]+)*";
547
+ function isLegacyApiAdapterBody(source, owner) {
548
+ const lines = source.replace(/\r\n/g, "\n").split("\n").slice(1).map((line) => line.trim()).filter(Boolean);
549
+ const importMatch = lines[0]?.match(
550
+ new RegExp(
551
+ `^import \\{ (endpointHandlers|production(?:[A-Z][A-Za-z0-9]*)?Handlers) \\} from "(@/modules/${owner}/server|@/modules/${owner}/enhancements/${OWNER}|@/lib/${owner}-wiring)";$`
552
+ )
553
+ );
554
+ if (!importMatch) return false;
555
+ const binding = importMatch[1];
556
+ const exports = lines.slice(1);
557
+ return exports.length > 0 && exports.every(
558
+ (line) => new RegExp(`^export const (${METHODS}) = ${binding}\\.\\1_[A-Za-z0-9_]+;$`).test(line)
559
+ );
560
+ }
561
+ function isLegacyRouteAdapterBody(source, routePath) {
562
+ return new RegExp(
563
+ `^// ${MARKER} scope=routes
564
+ /\\*\\* DO NOT EDIT \u2014 owned by the module install rail\\. \\*/
565
+ export \\{ default \\} from "@/modules/${OWNER}/routes/${routePath.replace(/[.*+?^$()[\]{}|]/g, "\\$&")}/page";
566
+ ?$`
567
+ ).test(source.replace(/\r\n/g, "\n"));
568
+ }
569
+ function parseMarker(source) {
570
+ const firstLine = source.split(/\r?\n/, 1)[0]?.trim() ?? "";
571
+ const match = firstLine.match(MARKER_RE);
572
+ if (!match) return null;
573
+ const [, owner, scope, routePath] = match;
574
+ const ownerless = OWNERLESS_SCOPES[scope];
575
+ if (ownerless) return owner ? null : { kind: ownerless };
576
+ if (scope === "routes") return owner ? null : { kind: "route", legacyRoute: true };
577
+ if (!owner) return null;
578
+ if (scope === "route") {
579
+ const trimmed = routePath?.trim();
580
+ return trimmed ? { kind: "route", routePath: trimmed, owner } : null;
581
+ }
582
+ if (scope === "root" || scope.startsWith("enhancement:")) return { kind: "endpoint", owner };
583
+ return null;
584
+ }
585
+ function shapeForPath(filename) {
586
+ const posix = filename.split(path3.sep).join("/");
587
+ const gen = posix.match(/\/src\/([^/]+\.gen\.ts)$/);
588
+ if (gen) {
589
+ const kind = GEN_FILE_KINDS[gen[1]];
590
+ return kind ? { kind } : null;
591
+ }
592
+ const idx = posix.lastIndexOf("/src/app/");
593
+ if (idx < 0) return null;
594
+ const rest = posix.slice(idx + "/src/app/".length);
595
+ if (rest.startsWith("api/")) {
596
+ const m2 = rest.match(/^(.+)\/route\.ts$/);
597
+ return m2 ? { kind: "endpoint", routePath: m2[1] } : null;
598
+ }
599
+ const m = rest.match(/^(?:(.*)\/)?page\.(?:tsx|ts|jsx|js)$/);
600
+ return m ? { kind: "route", routePath: m[1] ?? "" } : null;
601
+ }
602
+ var rule5 = {
603
+ meta: {
604
+ type: "problem",
605
+ docs: {
606
+ description: "Require a @stardeck-module-rail-generated marker to match the file's path shape, so Blueprint publish can reconcile the file",
607
+ recommended: true
608
+ },
609
+ messages: {
610
+ wrongKind: "Found a {{found}} rail marker on a path shaped for {{expected}} artifacts. Blueprint publish will refuse to reconcile a swapped marker. Delete the marker line if this file is hand-written, or restore the generated stub.",
611
+ missingMarker: "Generated file {{file}} has no @stardeck-module-rail-generated marker on line 1. Blueprint publish will refuse to overwrite it as unmanaged. Restore the marker line (or the whole generated file) rather than editing it by hand.",
612
+ legacyRouteBody: "Rail marker scope=routes is only valid with its original body: the DO NOT EDIT line and a single default re-export of this route's page from its Module. Blueprint publish treats anything else as unmanaged. Delete the marker line if this file is hand-written, or restore the generated stub.",
613
+ wrongRoutePath: 'Rail route marker claims path="{{claimed}}" but this file serves "{{actual}}". Blueprint publish will refuse to reconcile it. Delete the marker line if this file is hand-written, or restore the generated stub.'
614
+ },
615
+ schema: []
616
+ },
617
+ create(context) {
618
+ const filename = context.filename;
619
+ if (!filename || filename === "<input>" || filename === "<text>") return {};
620
+ const shape = shapeForPath(filename);
621
+ if (!shape) return {};
622
+ return {
623
+ Program(node) {
624
+ const marker = parseMarker(context.sourceCode.text);
625
+ const loc = { start: { line: 1, column: 0 }, end: { line: 1, column: 0 } };
626
+ if (!marker) {
627
+ if (GEN_FILE_KINDS[path3.basename(filename)]) {
628
+ context.report({
629
+ node,
630
+ loc,
631
+ messageId: "missingMarker",
632
+ data: { file: path3.basename(filename) }
633
+ });
634
+ }
635
+ return;
636
+ }
637
+ if (marker.kind !== shape.kind) {
638
+ if (shape.kind === "endpoint" && marker.kind === "route" && marker.routePath === shape.routePath && isLegacyApiAdapterBody(context.sourceCode.text, marker.owner)) {
639
+ return;
640
+ }
641
+ context.report({
642
+ node,
643
+ loc,
644
+ messageId: "wrongKind",
645
+ data: { found: marker.kind, expected: shape.kind }
646
+ });
647
+ return;
648
+ }
649
+ if (marker.legacyRoute) {
650
+ if (!isLegacyRouteAdapterBody(context.sourceCode.text, shape.routePath ?? "")) {
651
+ context.report({ node, loc, messageId: "legacyRouteBody" });
652
+ }
653
+ return;
654
+ }
655
+ if (marker.kind === "route" && marker.routePath !== shape.routePath) {
656
+ context.report({
657
+ node,
658
+ loc,
659
+ messageId: "wrongRoutePath",
660
+ data: { claimed: marker.routePath ?? "", actual: shape.routePath ?? "" }
661
+ });
662
+ }
663
+ }
664
+ };
665
+ }
666
+ };
667
+ var rail_marker_congruent_default = rule5;
668
+
525
669
  // src/rules/index.ts
526
670
  var rules = {
527
671
  "no-template-literal-classname": no_template_literal_classname_default,
528
672
  "no-kysely-interactive-transaction": no_kysely_interactive_transaction_default,
529
673
  "no-module-internal-import": no_module_internal_import_default,
530
- "no-server-import-in-conventional-entry": no_server_import_in_conventional_entry_default
674
+ "no-server-import-in-conventional-entry": no_server_import_in_conventional_entry_default,
675
+ "rail-marker-congruent": rail_marker_congruent_default
531
676
  };
532
677
 
533
678
  // src/configs/recommended.ts
@@ -542,7 +687,8 @@ var recommended = {
542
687
  [`${pluginName}/no-template-literal-classname`]: "error",
543
688
  [`${pluginName}/no-kysely-interactive-transaction`]: "error",
544
689
  [`${pluginName}/no-module-internal-import`]: "error",
545
- [`${pluginName}/no-server-import-in-conventional-entry`]: "error"
690
+ [`${pluginName}/no-server-import-in-conventional-entry`]: "error",
691
+ [`${pluginName}/rail-marker-congruent`]: "error"
546
692
  }
547
693
  };
548
694
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stardeck-customer-apps/eslint-plugin",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Custom ESLint rules for Stardeck customer apps",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",