@design.estate/wcctools 6.0.0 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (202) hide show
  1. package/changelog.md +33 -0
  2. package/dist_shell/bundle.js +405 -368
  3. package/dist_shell/bundle.js.map +1 -1
  4. package/dist_shell/bundle.js.third-party-notices.json +52 -0
  5. package/dist_shell/bundle.js.third-party-notices.txt +107 -1
  6. package/dist_shell/index.html +1 -1
  7. package/dist_ts/00_commitinfo_data.js +1 -1
  8. package/dist_ts/classes.bundlestatus.d.ts +36 -0
  9. package/dist_ts/classes.bundlestatus.js +110 -0
  10. package/dist_ts/classes.devapi.d.ts +3 -0
  11. package/dist_ts/classes.devapi.js +8 -1
  12. package/dist_ts/classes.devserver.d.ts +45 -10
  13. package/dist_ts/classes.devserver.js +123 -49
  14. package/dist_ts/cli.js +12 -3
  15. package/dist_ts/index.d.ts +0 -2
  16. package/dist_ts/index.js +1 -3
  17. package/dist_ts/plugins.d.ts +7 -2
  18. package/dist_ts/plugins.js +9 -3
  19. package/dist_ts/standards/baselines.d.ts +27 -0
  20. package/dist_ts/standards/baselines.js +83 -0
  21. package/dist_ts/standards/checker.d.ts +21 -0
  22. package/dist_ts/standards/checker.js +88 -0
  23. package/dist_ts/standards/classes.gitrepo.d.ts +13 -0
  24. package/dist_ts/standards/classes.gitrepo.js +43 -0
  25. package/dist_ts/standards/classes.sourceedits.d.ts +15 -0
  26. package/dist_ts/standards/classes.sourceedits.js +54 -0
  27. package/dist_ts/standards/classes.standardfixer.d.ts +40 -0
  28. package/dist_ts/standards/classes.standardfixer.js +210 -0
  29. package/dist_ts/standards/classes.standardproject.d.ts +80 -0
  30. package/dist_ts/standards/classes.standardproject.js +195 -0
  31. package/dist_ts/standards/classes.standardtree.d.ts +56 -0
  32. package/dist_ts/standards/classes.standardtree.js +207 -0
  33. package/dist_ts/standards/cli.d.ts +7 -0
  34. package/dist_ts/standards/cli.js +89 -0
  35. package/dist_ts/standards/config.d.ts +33 -0
  36. package/dist_ts/standards/config.js +139 -0
  37. package/dist_ts/standards/errors.d.ts +9 -0
  38. package/dist_ts/standards/errors.js +17 -0
  39. package/dist_ts/standards/helpers.ast.d.ts +56 -0
  40. package/dist_ts/standards/helpers.ast.js +172 -0
  41. package/dist_ts/standards/helpers.paths.d.ts +36 -0
  42. package/dist_ts/standards/helpers.paths.js +86 -0
  43. package/dist_ts/standards/index.d.ts +14 -0
  44. package/dist_ts/standards/index.js +15 -0
  45. package/dist_ts/standards/output.d.ts +7 -0
  46. package/dist_ts/standards/output.js +57 -0
  47. package/dist_ts/standards/ratchet.d.ts +15 -0
  48. package/dist_ts/standards/ratchet.js +45 -0
  49. package/dist_ts/standards/rule.d.ts +50 -0
  50. package/dist_ts/standards/rule.js +9 -0
  51. package/dist_ts/standards/rules/barrel.element-index.d.ts +7 -0
  52. package/dist_ts/standards/rules/barrel.element-index.js +108 -0
  53. package/dist_ts/standards/rules/barrel.no-demo-export.d.ts +3 -0
  54. package/dist_ts/standards/rules/barrel.no-demo-export.js +29 -0
  55. package/dist_ts/standards/rules/demo.fixture-not-exported.d.ts +3 -0
  56. package/dist_ts/standards/rules/demo.fixture-not-exported.js +29 -0
  57. package/dist_ts/standards/rules/demo.groups.d.ts +6 -0
  58. package/dist_ts/standards/rules/demo.groups.js +72 -0
  59. package/dist_ts/standards/rules/demo.required.d.ts +3 -0
  60. package/dist_ts/standards/rules/demo.required.js +48 -0
  61. package/dist_ts/standards/rules/demo.separate-file.d.ts +3 -0
  62. package/dist_ts/standards/rules/demo.separate-file.js +38 -0
  63. package/dist_ts/standards/rules/demo.shape.d.ts +3 -0
  64. package/dist_ts/standards/rules/demo.shape.js +38 -0
  65. package/dist_ts/standards/rules/docs.component-index.d.ts +8 -0
  66. package/dist_ts/standards/rules/docs.component-index.js +84 -0
  67. package/dist_ts/standards/rules/element.exported.d.ts +3 -0
  68. package/dist_ts/standards/rules/element.exported.js +33 -0
  69. package/dist_ts/standards/rules/element.literal-tag.d.ts +3 -0
  70. package/dist_ts/standards/rules/element.literal-tag.js +30 -0
  71. package/dist_ts/standards/rules/element.reachable.d.ts +3 -0
  72. package/dist_ts/standards/rules/element.reachable.js +48 -0
  73. package/dist_ts/standards/rules/element.tag-map.d.ts +3 -0
  74. package/dist_ts/standards/rules/element.tag-map.js +83 -0
  75. package/dist_ts/standards/rules/i18n.literals.d.ts +3 -0
  76. package/dist_ts/standards/rules/i18n.literals.js +52 -0
  77. package/dist_ts/standards/rules/index.d.ts +12 -0
  78. package/dist_ts/standards/rules/index.js +83 -0
  79. package/dist_ts/standards/rules/layout.element-folder.d.ts +3 -0
  80. package/dist_ts/standards/rules/layout.element-folder.js +47 -0
  81. package/dist_ts/standards/rules/layout.entry-file-name.d.ts +3 -0
  82. package/dist_ts/standards/rules/layout.entry-file-name.js +71 -0
  83. package/dist_ts/standards/rules/layout.entry.d.ts +3 -0
  84. package/dist_ts/standards/rules/layout.entry.js +38 -0
  85. package/dist_ts/standards/rules/layout.group-folder.d.ts +3 -0
  86. package/dist_ts/standards/rules/layout.group-folder.js +22 -0
  87. package/dist_ts/standards/rules/layout.shared-00.d.ts +3 -0
  88. package/dist_ts/standards/rules/layout.shared-00.js +36 -0
  89. package/dist_ts/standards/rules/naming.class-matches-tag.d.ts +3 -0
  90. package/dist_ts/standards/rules/naming.class-matches-tag.js +26 -0
  91. package/dist_ts/standards/rules/naming.lowercase-files.d.ts +3 -0
  92. package/dist_ts/standards/rules/naming.lowercase-files.js +26 -0
  93. package/dist_ts/standards/rules/naming.tag-prefix.d.ts +3 -0
  94. package/dist_ts/standards/rules/naming.tag-prefix.js +23 -0
  95. package/dist_ts/standards/rules/props.accessor.d.ts +3 -0
  96. package/dist_ts/standards/rules/props.accessor.js +35 -0
  97. package/dist_ts/standards/rules/props.object-no-attribute.d.ts +3 -0
  98. package/dist_ts/standards/rules/props.object-no-attribute.js +42 -0
  99. package/dist_ts/standards/rules/props.reflect-primitive.d.ts +5 -0
  100. package/dist_ts/standards/rules/props.reflect-primitive.js +41 -0
  101. package/dist_ts/standards/rules/props.typed.d.ts +6 -0
  102. package/dist_ts/standards/rules/props.typed.js +64 -0
  103. package/dist_ts/standards/rules/shared.d.ts +38 -0
  104. package/dist_ts/standards/rules/shared.js +103 -0
  105. package/dist_ts/standards/rules/style.bdtheme.d.ts +5 -0
  106. package/dist_ts/standards/rules/style.bdtheme.js +28 -0
  107. package/dist_ts_interfaces/index.d.ts +1 -0
  108. package/dist_ts_interfaces/index.js +2 -1
  109. package/dist_ts_interfaces/requests.d.ts +35 -0
  110. package/dist_ts_interfaces/standard.d.ts +93 -0
  111. package/dist_ts_interfaces/standard.js +6 -0
  112. package/dist_ts_shell/bundlestatus.d.ts +15 -0
  113. package/dist_ts_shell/bundlestatus.js +64 -0
  114. package/dist_ts_shell/elements/wcc-contextmenu.d.ts +3 -0
  115. package/dist_ts_shell/elements/wcc-contextmenu.js +18 -3
  116. package/dist_ts_shell/elements/wcc-preview-frame.d.ts +6 -0
  117. package/dist_ts_shell/elements/wcc-preview-frame.js +50 -2
  118. package/dist_ts_shell/elements/wcc-shell.d.ts +15 -0
  119. package/dist_ts_shell/elements/wcc-shell.js +45 -3
  120. package/dist_ts_shell/plugins.d.ts +2 -1
  121. package/dist_ts_shell/plugins.js +3 -2
  122. package/dist_ts_web/00_commitinfo_data.js +1 -1
  123. package/package.json +8 -3
  124. package/readme.md +40 -3
  125. package/ts/00_commitinfo_data.ts +1 -1
  126. package/ts/classes.bundlestatus.ts +123 -0
  127. package/ts/classes.devapi.ts +9 -0
  128. package/ts/classes.devserver.ts +127 -47
  129. package/ts/cli.ts +12 -2
  130. package/ts/index.ts +0 -2
  131. package/ts/plugins.ts +10 -2
  132. package/ts/standards/baselines.ts +100 -0
  133. package/ts/standards/checker.ts +114 -0
  134. package/ts/standards/classes.gitrepo.ts +48 -0
  135. package/ts/standards/classes.sourceedits.ts +58 -0
  136. package/ts/standards/classes.standardfixer.ts +234 -0
  137. package/ts/standards/classes.standardproject.ts +250 -0
  138. package/ts/standards/classes.standardtree.ts +220 -0
  139. package/ts/standards/cli.ts +86 -0
  140. package/ts/standards/config.ts +160 -0
  141. package/ts/standards/errors.ts +18 -0
  142. package/ts/standards/helpers.ast.ts +208 -0
  143. package/ts/standards/helpers.paths.ts +119 -0
  144. package/ts/standards/index.ts +22 -0
  145. package/ts/standards/output.ts +65 -0
  146. package/ts/standards/ratchet.ts +63 -0
  147. package/ts/standards/rule.ts +58 -0
  148. package/ts/standards/rules/barrel.element-index.ts +112 -0
  149. package/ts/standards/rules/barrel.no-demo-export.ts +30 -0
  150. package/ts/standards/rules/demo.fixture-not-exported.ts +30 -0
  151. package/ts/standards/rules/demo.groups.ts +74 -0
  152. package/ts/standards/rules/demo.required.ts +49 -0
  153. package/ts/standards/rules/demo.separate-file.ts +40 -0
  154. package/ts/standards/rules/demo.shape.ts +42 -0
  155. package/ts/standards/rules/docs.component-index.ts +88 -0
  156. package/ts/standards/rules/element.exported.ts +35 -0
  157. package/ts/standards/rules/element.literal-tag.ts +30 -0
  158. package/ts/standards/rules/element.reachable.ts +49 -0
  159. package/ts/standards/rules/element.tag-map.ts +87 -0
  160. package/ts/standards/rules/i18n.literals.ts +58 -0
  161. package/ts/standards/rules/index.ts +87 -0
  162. package/ts/standards/rules/layout.element-folder.ts +49 -0
  163. package/ts/standards/rules/layout.entry-file-name.ts +73 -0
  164. package/ts/standards/rules/layout.entry.ts +40 -0
  165. package/ts/standards/rules/layout.group-folder.ts +23 -0
  166. package/ts/standards/rules/layout.shared-00.ts +37 -0
  167. package/ts/standards/rules/naming.class-matches-tag.ts +27 -0
  168. package/ts/standards/rules/naming.lowercase-files.ts +27 -0
  169. package/ts/standards/rules/naming.tag-prefix.ts +24 -0
  170. package/ts/standards/rules/props.accessor.ts +37 -0
  171. package/ts/standards/rules/props.object-no-attribute.ts +45 -0
  172. package/ts/standards/rules/props.reflect-primitive.ts +44 -0
  173. package/ts/standards/rules/props.typed.ts +70 -0
  174. package/ts/standards/rules/shared.ts +127 -0
  175. package/ts/standards/rules/style.bdtheme.ts +31 -0
  176. package/ts_interfaces/bridge.ts +95 -0
  177. package/ts_interfaces/catalog.ts +37 -0
  178. package/ts_interfaces/index.ts +4 -0
  179. package/ts_interfaces/plugins.ts +3 -0
  180. package/ts_interfaces/requests.ts +65 -0
  181. package/ts_interfaces/standard.ts +103 -0
  182. package/ts_shared/index.ts +1 -0
  183. package/ts_shared/plugins.ts +3 -0
  184. package/ts_shared/previewroute.ts +58 -0
  185. package/ts_shell/bundlestatus.ts +68 -0
  186. package/ts_shell/elements/wcc-contextmenu.ts +306 -0
  187. package/ts_shell/elements/wcc-preview-frame.ts +167 -0
  188. package/ts_shell/elements/wcc-properties.ts +998 -0
  189. package/ts_shell/elements/wcc-record-button.ts +108 -0
  190. package/ts_shell/elements/wcc-recording-panel.ts +1005 -0
  191. package/ts_shell/elements/wcc-shell.ts +485 -0
  192. package/ts_shell/elements/wcc-sidebar.ts +1419 -0
  193. package/ts_shell/index.html +21 -0
  194. package/ts_shell/index.ts +4 -0
  195. package/ts_shell/plugins.ts +12 -0
  196. package/ts_shell/previewconnection.ts +103 -0
  197. package/ts_shell/services/recorder.service.ts +451 -0
  198. package/ts_shell/types/dom-mediacapture-stub/index.d.ts +12 -0
  199. package/ts_shell/types/dom-mediacapture-stub/package.json +6 -0
  200. package/ts_shell/types/dom-webcodecs-stub/index.d.ts +2 -0
  201. package/ts_shell/types/dom-webcodecs-stub/package.json +6 -0
  202. package/ts_web/00_commitinfo_data.ts +1 -1
@@ -7,6 +7,11 @@ import { loadShellContent, shellPathPrefix } from './shellcontent.js';
7
7
  export interface IWccDevServerOptions {
8
8
  /** Port to listen on; overrides the tswatch configuration. 0 picks a free port. */
9
9
  port?: number;
10
+ /**
11
+ * The project directory: its tswatch configuration, package.json and catalog are served, and
12
+ * tswatch bundles and watches in it (default: `process.cwd()` when the server is constructed).
13
+ */
14
+ cwd?: string;
10
15
  }
11
16
 
12
17
  export interface IWccDevServerAddress {
@@ -28,84 +33,159 @@ const securityHeaders: plugins.typedserver.ISecurityHeaders = {
28
33
  const acceptAnyHostname = () => true;
29
34
 
30
35
  /**
31
- * `wcctools dev`: bundles the catalog of the current project with tswatch, as its
32
- * `@git.zone/tswatch` configuration (or the `element` preset) describes, and serves on one port:
36
+ * `wcctools dev`: bundles the catalog of a project with tswatch, as its `@git.zone/tswatch`
37
+ * configuration (or the `element` preset) describes, and serves on one port:
33
38
  *
34
39
  * - the shell on `/wcctools/` (`/` redirects there) and its routes `/wcctools-route/...`,
35
- * - the typed API on `/wcctools/typedrequest`,
40
+ * - the typed API on `/wcctools/typedrequest`, including the bundles' status,
36
41
  * - the catalog's preview document and bundle from the tswatch serve directory on every other
37
42
  * path, with live reload that reloads only the preview document.
38
43
  *
39
- * tswatch resolves its paths against the process's working directory, so the server serves the
40
- * project the process runs in.
44
+ * The server owns no process lifecycle: while it runs, a process shutdown through smartexit's
45
+ * `ProcessLifecycle` stops it. The entry point installs that lifecycle with
46
+ * `getShutdownTimeoutMs()`.
41
47
  */
42
48
  export class WccDevServer {
43
49
  /** The typed API on `/wcctools/typedrequest`; register further handlers before `start()`. */
44
50
  public readonly api = new WccDevApi();
45
- private tsWatch: plugins.tswatch.TsWatch | null = null;
51
+ /** The project directory everything resolves against. */
52
+ public readonly cwd: string;
53
+ /** The project's tswatch configuration, or the `element` preset; read once, on construction. */
54
+ private readonly config: plugins.tswatch.ITswatchConfig;
55
+ /** Bundling and watching stay tswatch's; its own server stays off. */
56
+ private readonly tsWatch: plugins.tswatch.TsWatch;
46
57
  private typedServer: plugins.typedserver.TypedServer | null = null;
47
- private readonly smartExit = new plugins.smartexit.SmartExit({ silent: true });
58
+ /** Stops the server on a process shutdown; registered by start(), released by stop(). */
59
+ private smartExit: plugins.smartexit.SmartExit | null = null;
60
+ private unsubscribeBundleEvents: (() => void) | null = null;
61
+ private running = false;
62
+ private starting: Promise<IWccDevServerAddress> | null = null;
63
+ private stopping: Promise<void> | null = null;
48
64
 
49
65
  constructor(private readonly options: IWccDevServerOptions = {}) {
50
- this.smartExit.addCleanupFunction(() => this.stop());
66
+ this.cwd = plugins.path.resolve(options.cwd ?? process.cwd());
67
+ const configHandler = new plugins.tswatch.ConfigHandler(this.cwd);
68
+ this.config = configHandler.loadConfig() ?? this.getElementPreset(configHandler);
69
+ this.tsWatch = new plugins.tswatch.TsWatch({ ...this.config, server: { enabled: false } }, { cwd: this.cwd });
51
70
  }
52
71
 
72
+ /**
73
+ * The shutdown deadline the server needs, tswatch's: the entry point installs smartexit's
74
+ * `ProcessLifecycle` with it, so every watcher command keeps its full grace period.
75
+ */
76
+ public getShutdownTimeoutMs(): number {
77
+ return this.tsWatch.getShutdownTimeoutMs();
78
+ }
79
+
80
+ /**
81
+ * Bundles the catalog, starts the watchers and serves. Refused while the server runs or stops;
82
+ * the server starts again after stop().
83
+ */
53
84
  public async start(): Promise<IWccDevServerAddress> {
54
- if (this.typedServer) {
55
- throw new Error('wcctools: the dev server is already started.');
85
+ if (this.running || this.stopping) {
86
+ throw new Error('wcctools: the dev server is already started or still stopping.');
87
+ }
88
+ this.running = true;
89
+ // A process shutdown stops the server from here on, also while it is still starting
90
+ const smartExit = new plugins.smartexit.SmartExit({ silent: true });
91
+ smartExit.addCleanupFunction(() => this.stop());
92
+ this.smartExit = smartExit;
93
+ const starting = this.startServing();
94
+ this.starting = starting;
95
+ try {
96
+ const address = await starting;
97
+ if (this.stopping) {
98
+ throw new Error('wcctools: the dev server was stopped while starting.');
99
+ }
100
+ return address;
101
+ } catch (error) {
102
+ // A start that failed halfway releases what it opened
103
+ await this.stop();
104
+ throw error;
105
+ } finally {
106
+ if (this.starting === starting) {
107
+ this.starting = null;
108
+ }
56
109
  }
57
- const cwd = process.cwd();
58
- const configHandler = new plugins.tswatch.ConfigHandler(cwd);
59
- const config = configHandler.loadConfig() ?? configHandler.getPreset('element');
60
- const serveDir = plugins.path.resolve(cwd, config.server?.serveDir ?? defaultServeDir);
61
- const port = this.options.port ?? config.server?.port ?? defaultPort;
110
+ }
111
+
112
+ /**
113
+ * Stops the server and tswatch's bundling and watching, and releases everything start()
114
+ * registered. A stop during start() waits for the start to settle first; concurrent and
115
+ * repeated calls share one stop.
116
+ */
117
+ public stop(): Promise<void> {
118
+ this.stopping ??= this.release().finally(() => {
119
+ this.stopping = null;
120
+ });
121
+ return this.stopping;
122
+ }
123
+
124
+ private async release(): Promise<void> {
125
+ await this.starting?.catch(() => undefined);
126
+ const smartExit = this.smartExit;
127
+ const typedServer = this.typedServer;
128
+ const unsubscribeBundleEvents = this.unsubscribeBundleEvents;
129
+ this.smartExit = null;
130
+ this.typedServer = null;
131
+ this.unsubscribeBundleEvents = null;
132
+ smartExit?.deregister();
133
+ // Status requests waiting for a change are refused now, so none holds the server's stop, and
134
+ // requests until the server stops listening are refused too: the shell backs off
135
+ this.api.bundleStatus.close();
136
+ try {
137
+ await typedServer?.stop();
138
+ } finally {
139
+ try {
140
+ await this.tsWatch.stop();
141
+ } finally {
142
+ unsubscribeBundleEvents?.();
143
+ this.running = false;
144
+ }
145
+ }
146
+ }
147
+
148
+ private async startServing(): Promise<IWccDevServerAddress> {
149
+ const serveDir = plugins.path.resolve(this.cwd, this.config.server?.serveDir ?? defaultServeDir);
150
+ const port = this.options.port ?? this.config.server?.port ?? defaultPort;
62
151
 
63
152
  // The preview surface watches the serve directory, which must exist before the first bundle
64
153
  await plugins.fs.mkdir(serveDir, { recursive: true });
65
154
  this.api.setServerInfo({
66
155
  wcctoolsVersion: commitinfo.version,
67
- projectName: await this.readProjectName(cwd),
68
- });
69
- const typedServer = new plugins.typedserver.TypedServer({
70
- cors: false,
71
- port,
72
- surfaces: [
73
- await this.createShellSurface(),
74
- this.createPreviewSurface(serveDir),
75
- ],
156
+ projectName: await this.readProjectName(),
76
157
  });
158
+ const surfaces = [await this.createShellSurface(), this.createPreviewSurface(serveDir)];
159
+ const typedServer = new plugins.typedserver.TypedServer({ cors: false, port, surfaces });
77
160
  this.typedServer = typedServer;
78
161
 
79
- // Bundling and watching stay tswatch's; its own server stays off
80
- this.tsWatch = new plugins.tswatch.TsWatch({ ...config, server: { enabled: false } });
81
- try {
82
- await this.tsWatch.start();
83
- await typedServer.start();
84
- } catch (error) {
85
- await this.stop();
86
- throw error;
87
- }
162
+ this.api.bundleStatus.open();
163
+ const bundleStatus = this.api.bundleStatus;
164
+ this.unsubscribeBundleEvents = this.tsWatch.onBundleEvent((eventArg) => bundleStatus.record(eventArg));
165
+ // The initial bundles run here; a failed one is reported in the bundle status
166
+ await this.tsWatch.start();
167
+ await typedServer.start();
88
168
  const boundPort = typedServer.listeningPort ?? port;
89
169
  return { port: boundPort, url: `http://localhost:${boundPort}${shellPathPrefix}/` };
90
170
  }
91
171
 
92
- /** Stops the server, then tswatch's bundling and watching. */
93
- public async stop(): Promise<void> {
94
- const typedServer = this.typedServer;
95
- const tsWatch = this.tsWatch;
96
- this.typedServer = null;
97
- this.tsWatch = null;
98
- this.smartExit.deregister();
99
- await typedServer?.stop();
100
- await tsWatch?.stop();
172
+ private getElementPreset(configHandlerArg: plugins.tswatch.ConfigHandler): plugins.tswatch.ITswatchConfig {
173
+ const preset = configHandlerArg.getPreset('element');
174
+ if (!preset) {
175
+ throw new Error('wcctools: tswatch provides no `element` preset.');
176
+ }
177
+ return preset;
101
178
  }
102
179
 
103
180
  /**
104
181
  * The shell: the prebuilt shell bundle and the typed API.
105
182
  *
106
183
  * Seam for side-effecting APIs (assistant, capture, standards fixes): before any handler with
107
- * side effects is registered here, add a `websocketAdmission`/`requestAdmission` that accepts
108
- * only the shell's own origin, and keep surface mode's strict authority validation.
184
+ * side effects is registered here, the server binds to localhost by default (another interface
185
+ * only on explicit request), validates the Host header against the addresses it serves instead
186
+ * of accepting any hostname, and admits typedsocket connections and typed requests only from the
187
+ * shell's own origin (a `websocketAdmission`/`requestAdmission`), keeping surface mode's strict
188
+ * authority validation. Until then every handler here is read-only.
109
189
  */
110
190
  private async createShellSurface(): Promise<plugins.typedserver.ITypedServerSurface> {
111
191
  return {
@@ -154,8 +234,8 @@ export class WccDevServer {
154
234
  };
155
235
  }
156
236
 
157
- private async readProjectName(cwdArg: string): Promise<string> {
158
- const packageJson = JSON.parse(await plugins.fs.readFile(plugins.path.join(cwdArg, 'package.json'), 'utf8')) as { name?: string };
159
- return packageJson.name ?? plugins.path.basename(cwdArg);
237
+ private async readProjectName(): Promise<string> {
238
+ const packageJson = JSON.parse(await plugins.fs.readFile(plugins.path.join(this.cwd, 'package.json'), 'utf8')) as { name?: string };
239
+ return packageJson.name ?? plugins.path.basename(this.cwd);
160
240
  }
161
241
  }
package/ts/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import * as plugins from './plugins.js';
2
2
  import { commitinfo } from './00_commitinfo_data.js';
3
3
  import { WccDevServer } from './classes.devserver.js';
4
+ import { StandardConfigError, StandardUsageError, registerStandardCommands } from './standards/index.js';
4
5
 
5
6
  /**
6
7
  * Registers the wcctools commands on the given console.
@@ -22,11 +23,16 @@ export const registerCommands = (out: plugins.smartconsole.SmartConsole): void =
22
23
  },
23
24
  },
24
25
  }, async ({ options }) => {
25
- const devServer = new WccDevServer({ port: options.port });
26
+ const devServer = new WccDevServer({ port: options.port, cwd: process.cwd() });
27
+ // The entry point owns process signals: SIGINT and SIGTERM run every registered cleanup, the
28
+ // dev server's stop among them, once within tswatch's shutdown deadline, then exit.
29
+ plugins.smartexit.ProcessLifecycle.install({ shutdownTimeoutMs: devServer.getShutdownTimeoutMs() });
26
30
  const address = await devServer.start();
27
31
  await out.log(`wcctools dev: ${address.url}`);
28
32
  });
29
33
 
34
+ registerStandardCommands(out);
35
+
30
36
  out.cli.default({}, async () => {
31
37
  await out.log(out.cli.renderHelp());
32
38
  });
@@ -39,7 +45,11 @@ export const runCli = async () => {
39
45
  await out.cli.run();
40
46
  } catch (error) {
41
47
  await out.error(error instanceof Error ? error.message : String(error));
42
- process.exitCode = 1;
48
+ // Configuration and usage errors exit with 2, every other failure with 1.
49
+ const usageError = error instanceof plugins.smartconsole.CliUsageError
50
+ || error instanceof StandardConfigError
51
+ || error instanceof StandardUsageError;
52
+ process.exitCode = usageError ? 2 : 1;
43
53
  } finally {
44
54
  await out.dispose();
45
55
  }
package/ts/index.ts CHANGED
@@ -1,3 +1 @@
1
1
  export { runCli } from './cli.js';
2
- export { WccDevServer, type IWccDevServerOptions, type IWccDevServerAddress } from './classes.devserver.js';
3
- export { WccDevApi } from './classes.devapi.js';
package/ts/plugins.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  // node native scope
2
+ import * as childProcess from 'node:child_process';
2
3
  import * as fs from 'node:fs/promises';
4
+ import * as fsSync from 'node:fs';
3
5
  import * as path from 'node:path';
4
6
  import * as url from 'node:url';
5
7
 
6
- export { fs, path, url };
8
+ export { childProcess, fs, fsSync, path, url };
7
9
 
8
10
  // @api.global scope
9
11
  import * as typedrequest from '@api.global/typedrequest';
@@ -17,10 +19,16 @@ import * as tswatch from '@git.zone/tswatch';
17
19
  export { tswatch };
18
20
 
19
21
  // @push.rocks scope
22
+ import * as smartconfig from '@push.rocks/smartconfig';
20
23
  import * as smartconsole from '@push.rocks/smartconsole';
21
24
  import * as smartexit from '@push.rocks/smartexit';
22
25
 
23
- export { smartconsole, smartexit };
26
+ export { smartconfig, smartconsole, smartexit };
27
+
28
+ // third party scope
29
+ import typescript from 'typescript';
30
+
31
+ export { typescript };
24
32
 
25
33
  // project scope
26
34
  import * as interfaces from '../ts_interfaces/index.js';
@@ -0,0 +1,100 @@
1
+ import * as plugins from '../plugins.js';
2
+ import { allowlistStaleRuleId } from './rules/index.js';
3
+
4
+ type IStandardConfig = plugins.interfaces.IStandardConfig;
5
+ type IStandardReport = plugins.interfaces.IStandardReport;
6
+ type TBaselines = NonNullable<IStandardConfig['baselines']>;
7
+
8
+ /**
9
+ * Baseline arithmetic of the ratchet. Pure functions: persisting the result goes through
10
+ * IStandardConfigWriter (see config.ts).
11
+ */
12
+
13
+ /** Per rule and file, the error findings a baseline would have to accept (allowlisted findings excluded). */
14
+ const errorCounts = (reportArg: IStandardReport): TBaselines => {
15
+ const counts: TBaselines = {};
16
+ for (const finding of reportArg.findings) {
17
+ if (finding.severity !== 'error' || finding.covered === 'allowlist' || finding.ruleId === allowlistStaleRuleId) {
18
+ continue;
19
+ }
20
+ counts[finding.ruleId] ??= {};
21
+ counts[finding.ruleId][finding.file] = (counts[finding.ruleId][finding.file] ?? 0) + 1;
22
+ }
23
+ return counts;
24
+ };
25
+
26
+ const sortBaselines = (baselinesArg: TBaselines): TBaselines => {
27
+ const sorted: TBaselines = {};
28
+ for (const ruleId of Object.keys(baselinesArg).sort()) {
29
+ const files = Object.keys(baselinesArg[ruleId]).sort();
30
+ if (files.length > 0) {
31
+ sorted[ruleId] = Object.fromEntries(files.map((fileArg) => [fileArg, baselinesArg[ruleId][fileArg]]));
32
+ }
33
+ }
34
+ return sorted;
35
+ };
36
+
37
+ /** `--init-baseline`: the current error counts of every rule. Only valid while the configuration has no baselines. */
38
+ export const initialBaselines = (reportArg: IStandardReport): TBaselines => sortBaselines(errorCounts(reportArg));
39
+
40
+ export interface IBaselineRaise {
41
+ ruleId: string;
42
+ file: string;
43
+ baseline: number;
44
+ current: number;
45
+ }
46
+
47
+ /**
48
+ * `--update-baseline`: lowers every count to the current one and removes entries that reach 0.
49
+ * `raised` lists every file whose count would have to grow, including files without an entry;
50
+ * when it is not empty the update is refused.
51
+ */
52
+ export const updatedBaselines = (reportArg: IStandardReport, baselinesArg: TBaselines): { baselines: TBaselines; raised: IBaselineRaise[] } => {
53
+ const current = errorCounts(reportArg);
54
+ const raised: IBaselineRaise[] = [];
55
+ const baselines: TBaselines = {};
56
+ for (const [ruleId, counts] of Object.entries(baselinesArg)) {
57
+ baselines[ruleId] = {};
58
+ for (const [file, baseline] of Object.entries(counts)) {
59
+ const count = current[ruleId]?.[file] ?? 0;
60
+ if (count > baseline) {
61
+ raised.push({ ruleId, file, baseline, current: count });
62
+ } else if (count > 0) {
63
+ baselines[ruleId][file] = count;
64
+ }
65
+ }
66
+ }
67
+ for (const [ruleId, counts] of Object.entries(current)) {
68
+ for (const [file, count] of Object.entries(counts)) {
69
+ if (baselinesArg[ruleId]?.[file] === undefined) {
70
+ raised.push({ ruleId, file, baseline: 0, current: count });
71
+ }
72
+ }
73
+ }
74
+ return { baselines: sortBaselines(baselines), raised };
75
+ };
76
+
77
+ /** The configuration with allowlist and baseline file paths moved along with `wcctools fix` moves. */
78
+ export const rekeyStandardConfig = (configArg: IStandardConfig, movesArg: readonly plugins.interfaces.IStandardFileMove[]): { config: IStandardConfig; changed: string[] } => {
79
+ const moved = new Map(movesArg.map((moveArg) => [moveArg.from, moveArg.to]));
80
+ const changed: string[] = [];
81
+ const rekey = (keyArg: string): string => {
82
+ const [file, ...member] = keyArg.split('#');
83
+ const target = moved.get(file);
84
+ if (!target) {
85
+ return keyArg;
86
+ }
87
+ changed.push(keyArg);
88
+ return [target, ...member].join('#');
89
+ };
90
+ const config: IStandardConfig = { ...configArg };
91
+ if (configArg.allowlist) {
92
+ config.allowlist = Object.fromEntries(Object.entries(configArg.allowlist).map(([ruleId, entries]) =>
93
+ [ruleId, Object.fromEntries(Object.entries(entries).map(([key, value]) => [rekey(key), value]))]));
94
+ }
95
+ if (configArg.baselines) {
96
+ config.baselines = Object.fromEntries(Object.entries(configArg.baselines).map(([ruleId, counts]) =>
97
+ [ruleId, Object.fromEntries(Object.entries(counts).map(([file, count]) => [rekey(file), count]))]));
98
+ }
99
+ return { config, changed };
100
+ };
@@ -0,0 +1,114 @@
1
+ import * as plugins from '../plugins.js';
2
+ import type { StandardProject } from './classes.standardproject.js';
3
+ import type { ILoadedStandardConfig } from './config.js';
4
+ import { severityIn, type IRuleFinding, type IStandardRule } from './rule.js';
5
+ import { allowlistStaleRuleId, standardRules } from './rules/index.js';
6
+
7
+ type IStandardFinding = plugins.interfaces.IStandardFinding;
8
+ type IStandardReport = plugins.interfaces.IStandardReport;
9
+ type TStandardProfile = plugins.interfaces.TStandardProfile;
10
+
11
+ /** The rules of a profile, in the order of docs/standard.md. */
12
+ export const rulesOfProfile = (profileArg: TStandardProfile): IStandardRule[] =>
13
+ standardRules.filter((ruleArg) => severityIn(ruleArg, profileArg) !== null);
14
+
15
+ /** The rules of a profile as the shell and the AI agent see them. */
16
+ export const describeStandardRules = (profileArg: TStandardProfile): plugins.interfaces.IStandardRuleInfo[] =>
17
+ rulesOfProfile(profileArg).map((ruleArg) => ({
18
+ id: ruleArg.id,
19
+ severity: severityIn(ruleArg, profileArg)!,
20
+ fixMode: ruleArg.fixMode,
21
+ allowlistCategories: [...(ruleArg.allowlistCategories ?? [])],
22
+ }));
23
+
24
+ /** A finding of one rule with its allowlist state. */
25
+ export interface IDetectedFinding {
26
+ rule: IStandardRule;
27
+ finding: IRuleFinding;
28
+ allowlisted: boolean;
29
+ }
30
+
31
+ /** Runs the rules of the configured profile; findings carry their fix actions. */
32
+ export const detectFindings = (
33
+ projectArg: StandardProject,
34
+ loadedArg: ILoadedStandardConfig,
35
+ rulesArg: readonly IStandardRule[] = rulesOfProfile(loadedArg.profile),
36
+ ): IDetectedFinding[] => {
37
+ const context = { project: projectArg, config: loadedArg.config, profile: loadedArg.profile };
38
+ const detected: IDetectedFinding[] = [];
39
+ for (const rule of rulesArg) {
40
+ const allowlist = loadedArg.config.allowlist?.[rule.id] ?? {};
41
+ for (const finding of rule.detect(context)) {
42
+ detected.push({ rule, finding, allowlisted: Object.hasOwn(allowlist, finding.key) });
43
+ }
44
+ }
45
+ return detected;
46
+ };
47
+
48
+ const locate = (projectArg: StandardProject, fileArg: string, positionArg: number): { line: number; column: number } => {
49
+ const text = projectArg.files.get(fileArg);
50
+ if (text === undefined) {
51
+ return { line: 1, column: 1 };
52
+ }
53
+ if (/\.(ts|tsx|mts|cts)$/.test(fileArg)) {
54
+ return projectArg.lineAndColumn(fileArg, positionArg);
55
+ }
56
+ const before = text.slice(0, positionArg);
57
+ return { line: before.split('\n').length, column: positionArg - before.lastIndexOf('\n') };
58
+ };
59
+
60
+ /** Checks a repository against the standard: findings, allowlist, baselines. */
61
+ export const checkStandard = (projectArg: StandardProject, loadedArg: ILoadedStandardConfig): IStandardReport => {
62
+ const { config, profile } = loadedArg;
63
+ const detected = detectFindings(projectArg, loadedArg);
64
+ const findings: IStandardFinding[] = detected.map(({ rule, finding, allowlisted }) => ({
65
+ ruleId: rule.id,
66
+ severity: severityIn(rule, profile)!,
67
+ file: finding.file,
68
+ ...locate(projectArg, finding.file, finding.position),
69
+ message: finding.message,
70
+ fixable: !!finding.fix,
71
+ ...(allowlisted ? { covered: 'allowlist' as const } : {}),
72
+ }));
73
+
74
+ // An allowlist entry without its finding is stale: the list only shrinks.
75
+ for (const [ruleId, entries] of Object.entries(config.allowlist ?? {})) {
76
+ for (const key of Object.keys(entries)) {
77
+ if (!detected.some(({ rule, finding }) => rule.id === ruleId && finding.key === key)) {
78
+ findings.push({
79
+ ruleId: allowlistStaleRuleId,
80
+ severity: 'error',
81
+ file: '.smartconfig.json',
82
+ line: 1,
83
+ column: 1,
84
+ message: `the ${ruleId} allowlist entry '${key}' matches no finding; remove it`,
85
+ fixable: false,
86
+ });
87
+ }
88
+ }
89
+ }
90
+
91
+ // Baselines: per rule and file, a count at or below the baseline is covered; above it nothing is.
92
+ const baselineSlack: plugins.interfaces.IStandardBaselineSlack[] = [];
93
+ for (const [ruleId, counts] of Object.entries(config.baselines ?? {})) {
94
+ for (const [file, baseline] of Object.entries(counts)) {
95
+ const inFile = findings.filter((findingArg) => findingArg.ruleId === ruleId && findingArg.file === file && !findingArg.covered);
96
+ if (inFile.length <= baseline) {
97
+ inFile.forEach((findingArg) => {
98
+ findingArg.covered = 'baseline';
99
+ });
100
+ }
101
+ if (inFile.length < baseline) {
102
+ baselineSlack.push({ ruleId, file, baseline, current: inFile.length });
103
+ }
104
+ }
105
+ }
106
+
107
+ findings.sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.column - b.column || a.ruleId.localeCompare(b.ruleId));
108
+ return {
109
+ profile,
110
+ findings,
111
+ baselineSlack,
112
+ ok: !findings.some((findingArg) => findingArg.severity === 'error' && !findingArg.covered),
113
+ };
114
+ };
@@ -0,0 +1,48 @@
1
+ import * as plugins from '../plugins.js';
2
+
3
+ /** The git operations `wcctools fix` needs, run with an argument vector and no shell. */
4
+ export class GitRepo {
5
+ public readonly rootDir: string;
6
+
7
+ constructor(rootDirArg: string) {
8
+ this.rootDir = rootDirArg;
9
+ }
10
+
11
+ public run(argsArg: string[]): Promise<string> {
12
+ return new Promise((resolve, reject) => {
13
+ plugins.childProcess.execFile('git', argsArg, { cwd: this.rootDir, maxBuffer: 64 * 1024 * 1024, encoding: 'utf8' }, (error, stdout, stderr) => {
14
+ if (error) {
15
+ reject(new Error(`git ${argsArg.join(' ')} failed: ${stderr.trim() || error.message}`));
16
+ return;
17
+ }
18
+ resolve(stdout);
19
+ });
20
+ });
21
+ }
22
+
23
+ /** The repository's top-level directory, or null when the directory is not inside a git work tree. */
24
+ public async topLevel(): Promise<string | null> {
25
+ try {
26
+ return (await this.run(['rev-parse', '--show-toplevel'])).trim();
27
+ } catch (error) {
28
+ if (error instanceof Error && /not a git repository/i.test(error.message)) {
29
+ return null;
30
+ }
31
+ throw error;
32
+ }
33
+ }
34
+
35
+ /** `git status --porcelain` lines; empty for a clean tree (untracked files count as changes). */
36
+ public async statusLines(): Promise<string[]> {
37
+ return (await this.run(['status', '--porcelain'])).split('\n').filter((lineArg) => lineArg.length > 0);
38
+ }
39
+
40
+ /** Tracked files, relative to the repository root. */
41
+ public async trackedFiles(): Promise<string[]> {
42
+ return (await this.run(['ls-files', '-z'])).split('\0').filter((fileArg) => fileArg.length > 0);
43
+ }
44
+
45
+ public async move(fromArg: string, toArg: string): Promise<void> {
46
+ await this.run(['mv', '--', fromArg, toArg]);
47
+ }
48
+ }
@@ -0,0 +1,58 @@
1
+ import type { ITextEdit } from './helpers.ast.js';
2
+
3
+ /**
4
+ * Span edits of one file's text. Edits never re-print the file: each replaces [start, end) of the
5
+ * original text. Identical edits count once; an edit overlapping an earlier accepted one is
6
+ * dropped (the fixer retries it on the edited text). Insertions at the same offset keep the order
7
+ * they were added in.
8
+ */
9
+ export class SourceEdits {
10
+ private readonly accepted: Array<ITextEdit & { order: number }> = [];
11
+ public dropped = 0;
12
+
13
+ public add(editArg: ITextEdit): boolean {
14
+ if (editArg.start > editArg.end) {
15
+ throw new Error(`Invalid edit span ${editArg.start}-${editArg.end}`);
16
+ }
17
+ if (this.accepted.some((otherArg) => otherArg.start === editArg.start && otherArg.end === editArg.end && otherArg.text === editArg.text)) {
18
+ return true;
19
+ }
20
+ const overlaps = this.accepted.some((otherArg) => {
21
+ const otherIsInsert = otherArg.start === otherArg.end;
22
+ const isInsert = editArg.start === editArg.end;
23
+ if (otherIsInsert && isInsert) {
24
+ return false;
25
+ }
26
+ if (isInsert) {
27
+ return editArg.start > otherArg.start && editArg.start < otherArg.end;
28
+ }
29
+ if (otherIsInsert) {
30
+ return otherArg.start > editArg.start && otherArg.start < editArg.end;
31
+ }
32
+ return editArg.start < otherArg.end && otherArg.start < editArg.end;
33
+ });
34
+ if (overlaps) {
35
+ this.dropped++;
36
+ return false;
37
+ }
38
+ this.accepted.push({ ...editArg, order: this.accepted.length });
39
+ return true;
40
+ }
41
+
42
+ public get size(): number {
43
+ return this.accepted.length;
44
+ }
45
+
46
+ /** Applies the accepted edits from the end of the text backwards. */
47
+ public apply(textArg: string): string {
48
+ const ordered = [...this.accepted].sort((a, b) => b.start - a.start || b.end - a.end || b.order - a.order);
49
+ let text = textArg;
50
+ for (const edit of ordered) {
51
+ if (edit.end > text.length) {
52
+ throw new Error(`Edit span ${edit.start}-${edit.end} exceeds the text length ${text.length}`);
53
+ }
54
+ text = text.slice(0, edit.start) + edit.text + text.slice(edit.end);
55
+ }
56
+ return text;
57
+ }
58
+ }