@design.estate/wcctools 6.0.0 → 6.1.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 (150) hide show
  1. package/changelog.md +13 -0
  2. package/dist_shell/bundle.js +3 -3
  3. package/dist_shell/bundle.js.map +1 -1
  4. package/dist_ts/00_commitinfo_data.js +1 -1
  5. package/dist_ts/cli.js +8 -2
  6. package/dist_ts/plugins.d.ts +7 -2
  7. package/dist_ts/plugins.js +9 -3
  8. package/dist_ts/standards/baselines.d.ts +27 -0
  9. package/dist_ts/standards/baselines.js +83 -0
  10. package/dist_ts/standards/checker.d.ts +21 -0
  11. package/dist_ts/standards/checker.js +88 -0
  12. package/dist_ts/standards/classes.gitrepo.d.ts +13 -0
  13. package/dist_ts/standards/classes.gitrepo.js +43 -0
  14. package/dist_ts/standards/classes.sourceedits.d.ts +15 -0
  15. package/dist_ts/standards/classes.sourceedits.js +54 -0
  16. package/dist_ts/standards/classes.standardfixer.d.ts +40 -0
  17. package/dist_ts/standards/classes.standardfixer.js +210 -0
  18. package/dist_ts/standards/classes.standardproject.d.ts +80 -0
  19. package/dist_ts/standards/classes.standardproject.js +195 -0
  20. package/dist_ts/standards/classes.standardtree.d.ts +56 -0
  21. package/dist_ts/standards/classes.standardtree.js +207 -0
  22. package/dist_ts/standards/cli.d.ts +7 -0
  23. package/dist_ts/standards/cli.js +89 -0
  24. package/dist_ts/standards/config.d.ts +33 -0
  25. package/dist_ts/standards/config.js +139 -0
  26. package/dist_ts/standards/errors.d.ts +9 -0
  27. package/dist_ts/standards/errors.js +17 -0
  28. package/dist_ts/standards/helpers.ast.d.ts +56 -0
  29. package/dist_ts/standards/helpers.ast.js +172 -0
  30. package/dist_ts/standards/helpers.paths.d.ts +36 -0
  31. package/dist_ts/standards/helpers.paths.js +86 -0
  32. package/dist_ts/standards/index.d.ts +14 -0
  33. package/dist_ts/standards/index.js +15 -0
  34. package/dist_ts/standards/output.d.ts +7 -0
  35. package/dist_ts/standards/output.js +57 -0
  36. package/dist_ts/standards/ratchet.d.ts +15 -0
  37. package/dist_ts/standards/ratchet.js +45 -0
  38. package/dist_ts/standards/rule.d.ts +50 -0
  39. package/dist_ts/standards/rule.js +9 -0
  40. package/dist_ts/standards/rules/barrel.element-index.d.ts +7 -0
  41. package/dist_ts/standards/rules/barrel.element-index.js +108 -0
  42. package/dist_ts/standards/rules/barrel.no-demo-export.d.ts +3 -0
  43. package/dist_ts/standards/rules/barrel.no-demo-export.js +29 -0
  44. package/dist_ts/standards/rules/demo.fixture-not-exported.d.ts +3 -0
  45. package/dist_ts/standards/rules/demo.fixture-not-exported.js +29 -0
  46. package/dist_ts/standards/rules/demo.groups.d.ts +6 -0
  47. package/dist_ts/standards/rules/demo.groups.js +72 -0
  48. package/dist_ts/standards/rules/demo.required.d.ts +3 -0
  49. package/dist_ts/standards/rules/demo.required.js +48 -0
  50. package/dist_ts/standards/rules/demo.separate-file.d.ts +3 -0
  51. package/dist_ts/standards/rules/demo.separate-file.js +38 -0
  52. package/dist_ts/standards/rules/demo.shape.d.ts +3 -0
  53. package/dist_ts/standards/rules/demo.shape.js +38 -0
  54. package/dist_ts/standards/rules/docs.component-index.d.ts +8 -0
  55. package/dist_ts/standards/rules/docs.component-index.js +84 -0
  56. package/dist_ts/standards/rules/element.exported.d.ts +3 -0
  57. package/dist_ts/standards/rules/element.exported.js +33 -0
  58. package/dist_ts/standards/rules/element.literal-tag.d.ts +3 -0
  59. package/dist_ts/standards/rules/element.literal-tag.js +30 -0
  60. package/dist_ts/standards/rules/element.reachable.d.ts +3 -0
  61. package/dist_ts/standards/rules/element.reachable.js +48 -0
  62. package/dist_ts/standards/rules/element.tag-map.d.ts +3 -0
  63. package/dist_ts/standards/rules/element.tag-map.js +83 -0
  64. package/dist_ts/standards/rules/i18n.literals.d.ts +3 -0
  65. package/dist_ts/standards/rules/i18n.literals.js +52 -0
  66. package/dist_ts/standards/rules/index.d.ts +12 -0
  67. package/dist_ts/standards/rules/index.js +83 -0
  68. package/dist_ts/standards/rules/layout.element-folder.d.ts +3 -0
  69. package/dist_ts/standards/rules/layout.element-folder.js +47 -0
  70. package/dist_ts/standards/rules/layout.entry-file-name.d.ts +3 -0
  71. package/dist_ts/standards/rules/layout.entry-file-name.js +71 -0
  72. package/dist_ts/standards/rules/layout.entry.d.ts +3 -0
  73. package/dist_ts/standards/rules/layout.entry.js +38 -0
  74. package/dist_ts/standards/rules/layout.group-folder.d.ts +3 -0
  75. package/dist_ts/standards/rules/layout.group-folder.js +22 -0
  76. package/dist_ts/standards/rules/layout.shared-00.d.ts +3 -0
  77. package/dist_ts/standards/rules/layout.shared-00.js +36 -0
  78. package/dist_ts/standards/rules/naming.class-matches-tag.d.ts +3 -0
  79. package/dist_ts/standards/rules/naming.class-matches-tag.js +26 -0
  80. package/dist_ts/standards/rules/naming.lowercase-files.d.ts +3 -0
  81. package/dist_ts/standards/rules/naming.lowercase-files.js +26 -0
  82. package/dist_ts/standards/rules/naming.tag-prefix.d.ts +3 -0
  83. package/dist_ts/standards/rules/naming.tag-prefix.js +23 -0
  84. package/dist_ts/standards/rules/props.accessor.d.ts +3 -0
  85. package/dist_ts/standards/rules/props.accessor.js +35 -0
  86. package/dist_ts/standards/rules/props.object-no-attribute.d.ts +3 -0
  87. package/dist_ts/standards/rules/props.object-no-attribute.js +42 -0
  88. package/dist_ts/standards/rules/props.reflect-primitive.d.ts +5 -0
  89. package/dist_ts/standards/rules/props.reflect-primitive.js +41 -0
  90. package/dist_ts/standards/rules/props.typed.d.ts +6 -0
  91. package/dist_ts/standards/rules/props.typed.js +64 -0
  92. package/dist_ts/standards/rules/shared.d.ts +38 -0
  93. package/dist_ts/standards/rules/shared.js +103 -0
  94. package/dist_ts/standards/rules/style.bdtheme.d.ts +5 -0
  95. package/dist_ts/standards/rules/style.bdtheme.js +28 -0
  96. package/dist_ts_interfaces/index.d.ts +1 -0
  97. package/dist_ts_interfaces/index.js +2 -1
  98. package/dist_ts_interfaces/standard.d.ts +93 -0
  99. package/dist_ts_interfaces/standard.js +6 -0
  100. package/dist_ts_web/00_commitinfo_data.js +1 -1
  101. package/package.json +4 -2
  102. package/readme.md +34 -1
  103. package/ts/00_commitinfo_data.ts +1 -1
  104. package/ts/cli.ts +8 -1
  105. package/ts/plugins.ts +10 -2
  106. package/ts/standards/baselines.ts +100 -0
  107. package/ts/standards/checker.ts +114 -0
  108. package/ts/standards/classes.gitrepo.ts +48 -0
  109. package/ts/standards/classes.sourceedits.ts +58 -0
  110. package/ts/standards/classes.standardfixer.ts +234 -0
  111. package/ts/standards/classes.standardproject.ts +250 -0
  112. package/ts/standards/classes.standardtree.ts +220 -0
  113. package/ts/standards/cli.ts +86 -0
  114. package/ts/standards/config.ts +160 -0
  115. package/ts/standards/errors.ts +18 -0
  116. package/ts/standards/helpers.ast.ts +208 -0
  117. package/ts/standards/helpers.paths.ts +119 -0
  118. package/ts/standards/index.ts +22 -0
  119. package/ts/standards/output.ts +65 -0
  120. package/ts/standards/ratchet.ts +63 -0
  121. package/ts/standards/rule.ts +58 -0
  122. package/ts/standards/rules/barrel.element-index.ts +112 -0
  123. package/ts/standards/rules/barrel.no-demo-export.ts +30 -0
  124. package/ts/standards/rules/demo.fixture-not-exported.ts +30 -0
  125. package/ts/standards/rules/demo.groups.ts +74 -0
  126. package/ts/standards/rules/demo.required.ts +49 -0
  127. package/ts/standards/rules/demo.separate-file.ts +40 -0
  128. package/ts/standards/rules/demo.shape.ts +42 -0
  129. package/ts/standards/rules/docs.component-index.ts +88 -0
  130. package/ts/standards/rules/element.exported.ts +35 -0
  131. package/ts/standards/rules/element.literal-tag.ts +30 -0
  132. package/ts/standards/rules/element.reachable.ts +49 -0
  133. package/ts/standards/rules/element.tag-map.ts +87 -0
  134. package/ts/standards/rules/i18n.literals.ts +58 -0
  135. package/ts/standards/rules/index.ts +87 -0
  136. package/ts/standards/rules/layout.element-folder.ts +49 -0
  137. package/ts/standards/rules/layout.entry-file-name.ts +73 -0
  138. package/ts/standards/rules/layout.entry.ts +40 -0
  139. package/ts/standards/rules/layout.group-folder.ts +23 -0
  140. package/ts/standards/rules/layout.shared-00.ts +37 -0
  141. package/ts/standards/rules/naming.class-matches-tag.ts +27 -0
  142. package/ts/standards/rules/naming.lowercase-files.ts +27 -0
  143. package/ts/standards/rules/naming.tag-prefix.ts +24 -0
  144. package/ts/standards/rules/props.accessor.ts +37 -0
  145. package/ts/standards/rules/props.object-no-attribute.ts +45 -0
  146. package/ts/standards/rules/props.reflect-primitive.ts +44 -0
  147. package/ts/standards/rules/props.typed.ts +70 -0
  148. package/ts/standards/rules/shared.ts +127 -0
  149. package/ts/standards/rules/style.bdtheme.ts +31 -0
  150. package/ts_web/00_commitinfo_data.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@design.estate/wcctools",
3
- "version": "6.0.0",
3
+ "version": "6.1.0",
4
4
  "private": false,
5
5
  "description": "A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.",
6
6
  "bin": {
@@ -20,9 +20,11 @@
20
20
  "@design.estate/dees-domtools": "^5.0.0",
21
21
  "@design.estate/dees-element": "^4.0.0",
22
22
  "@git.zone/tswatch": "^6.0.1",
23
+ "@push.rocks/smartconfig": "^6.3.0",
23
24
  "@push.rocks/smartconsole": "^2.1.0",
24
25
  "@push.rocks/smartexit": "^2.2.0",
25
- "lit": "^3.3.2"
26
+ "lit": "^3.3.2",
27
+ "typescript": "~6.0.3"
26
28
  },
27
29
  "devDependencies": {
28
30
  "@git.zone/tsbuild": "^5.1.2",
package/readme.md CHANGED
@@ -503,6 +503,39 @@ Bundles and watches the catalogue as the project's `@git.zone/tswatch` configura
503
503
 
504
504
  `--port` overrides the configured port; `0` picks a free port.
505
505
 
506
+ Every `wcctools` command exits with code 2 on a usage error (an unknown command or option, a missing or invalid option value) and with 1 on any other failure.
507
+
508
+ ### `wcctools check` and `wcctools fix`
509
+
510
+ Check a catalogue against the wcctools component standard ([docs/standard.md](https://code.foss.global/design.estate/wcctools/src/branch/main/docs/standard.md), a draft: rule ids, severities and defaults may still change until it is declared stable): file layout, naming, element registration, barrels, demos and the attribute surface of public properties, plus the theme, wording and component-index ratchets of the dees-catalog family. Both commands run in the repository root and read `.smartconfig.json`:
511
+
512
+ ```json
513
+ {
514
+ "@design.estate/wcctools": {
515
+ "standard": {
516
+ "profile": "base",
517
+ "tagPrefix": "my"
518
+ }
519
+ }
520
+ }
521
+ ```
522
+
523
+ `profile` is `base` or `dees`; `tagPrefix` is the tag prefix without its hyphen. Exceptions go into `standard.allowlist` (rule id → `'<file>#<member>'` → `'<category>: <reason>'`), accepted error counts of an existing codebase into `standard.baselines` (rule id → file → count, only shrinking).
524
+
525
+ ```bash
526
+ wcctools check # findings as text; exit 0 ok, 1 failing errors, 2 configuration or usage error
527
+ wcctools check --json # one IStandardReport on stdout
528
+ wcctools check --verbose # also info findings, covered findings and baseline slack
529
+ wcctools check --init-baseline # record the current error counts (only while no baselines exist)
530
+ wcctools check --update-baseline # lower the baselines to the current counts; refuses to raise any
531
+
532
+ wcctools fix --dry-run # show the moves and edits without writing
533
+ wcctools fix # apply every automatic fix
534
+ wcctools fix --rule props/reflect-primitive # also opt-in rules, one rule at a time
535
+ ```
536
+
537
+ `wcctools fix` needs a clean work tree and never commits; it validates every path it would write before writing anything and refuses (exit code 2) a plan that leaves the repository root. It edits source text by syntax-node spans, so formatting and comments stay as they are; it moves files with `git mv` and rewrites the relative imports that pointed at them across the repository, and it checks again at the end. Review the result with `git diff` and commit it yourself.
538
+
506
539
  ### `setupWccTools(config)`
507
540
 
508
541
  Sets up the catalogue's preview document with sections configuration. The shell reads a data-only description of the sections from it; element classes and template factories never leave the preview document.
@@ -540,7 +573,7 @@ The wrapper provides full DOM API access:
540
573
 
541
574
  ### `@design.estate/wcctools/interfaces`
542
575
 
543
- The contracts between the shell, the preview document and the dev server, for tools that drive a preview: the catalogue manifest (`IWccCatalogManifest`), the preview bridge the preview document installs as `window.wccPreview` (`IWccPreviewBridge`: render a demo, switch the theme, read and edit the rendered element's properties) and the dev server's typed requests.
576
+ The contracts between the shell, the preview document and the dev server, for tools that drive a preview: the catalogue manifest (`IWccCatalogManifest`), the preview bridge the preview document installs as `window.wccPreview` (`IWccPreviewBridge`: render a demo, switch the theme, read and edit the rendered element's properties) and the dev server's typed requests, and the component standard's configuration and reports (`IStandardConfig`, `IStandardReport`, `IStandardFixResult`).
544
577
 
545
578
  ```typescript
546
579
  import type { IWccPreviewBridge } from '@design.estate/wcctools/interfaces';
@@ -3,6 +3,6 @@
3
3
  */
4
4
  export const commitinfo = {
5
5
  name: '@design.estate/wcctools',
6
- version: '6.0.0',
6
+ version: '6.1.0',
7
7
  description: 'A set of web component tools for creating element catalogues, enabling the structured development and documentation of custom elements and pages.'
8
8
  }
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.
@@ -27,6 +28,8 @@ export const registerCommands = (out: plugins.smartconsole.SmartConsole): void =
27
28
  await out.log(`wcctools dev: ${address.url}`);
28
29
  });
29
30
 
31
+ registerStandardCommands(out);
32
+
30
33
  out.cli.default({}, async () => {
31
34
  await out.log(out.cli.renderHelp());
32
35
  });
@@ -39,7 +42,11 @@ export const runCli = async () => {
39
42
  await out.cli.run();
40
43
  } catch (error) {
41
44
  await out.error(error instanceof Error ? error.message : String(error));
42
- process.exitCode = 1;
45
+ // Configuration and usage errors exit with 2, every other failure with 1.
46
+ const usageError = error instanceof plugins.smartconsole.CliUsageError
47
+ || error instanceof StandardConfigError
48
+ || error instanceof StandardUsageError;
49
+ process.exitCode = usageError ? 2 : 1;
43
50
  } finally {
44
51
  await out.dispose();
45
52
  }
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
+ }