@design.estate/wcctools 5.1.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.
- package/.smartconfig.json +7 -3
- package/changelog.md +28 -0
- package/cli.js +3 -0
- package/dist_shell/bundle.js +2970 -0
- package/dist_shell/bundle.js.map +1 -0
- package/dist_shell/bundle.js.third-party-notices.json +1722 -0
- package/dist_shell/bundle.js.third-party-notices.txt +4195 -0
- package/dist_shell/index.html +21 -0
- package/dist_ts/00_commitinfo_data.d.ts +8 -0
- package/dist_ts/00_commitinfo_data.js +9 -0
- package/dist_ts/classes.devapi.d.ts +11 -0
- package/dist_ts/classes.devapi.js +21 -0
- package/dist_ts/classes.devserver.d.ts +49 -0
- package/dist_ts/classes.devserver.js +142 -0
- package/dist_ts/cli.d.ts +6 -0
- package/dist_ts/cli.js +51 -0
- package/dist_ts/index.d.ts +3 -0
- package/dist_ts/index.js +4 -0
- package/dist_ts/paths.d.ts +4 -0
- package/dist_ts/paths.js +6 -0
- package/dist_ts/plugins.d.ts +20 -0
- package/dist_ts/plugins.js +27 -0
- package/dist_ts/shellcontent.d.ts +9 -0
- package/dist_ts/shellcontent.js +42 -0
- package/dist_ts/standards/baselines.d.ts +27 -0
- package/dist_ts/standards/baselines.js +83 -0
- package/dist_ts/standards/checker.d.ts +21 -0
- package/dist_ts/standards/checker.js +88 -0
- package/dist_ts/standards/classes.gitrepo.d.ts +13 -0
- package/dist_ts/standards/classes.gitrepo.js +43 -0
- package/dist_ts/standards/classes.sourceedits.d.ts +15 -0
- package/dist_ts/standards/classes.sourceedits.js +54 -0
- package/dist_ts/standards/classes.standardfixer.d.ts +40 -0
- package/dist_ts/standards/classes.standardfixer.js +210 -0
- package/dist_ts/standards/classes.standardproject.d.ts +80 -0
- package/dist_ts/standards/classes.standardproject.js +195 -0
- package/dist_ts/standards/classes.standardtree.d.ts +56 -0
- package/dist_ts/standards/classes.standardtree.js +207 -0
- package/dist_ts/standards/cli.d.ts +7 -0
- package/dist_ts/standards/cli.js +89 -0
- package/dist_ts/standards/config.d.ts +33 -0
- package/dist_ts/standards/config.js +139 -0
- package/dist_ts/standards/errors.d.ts +9 -0
- package/dist_ts/standards/errors.js +17 -0
- package/dist_ts/standards/helpers.ast.d.ts +56 -0
- package/dist_ts/standards/helpers.ast.js +172 -0
- package/dist_ts/standards/helpers.paths.d.ts +36 -0
- package/dist_ts/standards/helpers.paths.js +86 -0
- package/dist_ts/standards/index.d.ts +14 -0
- package/dist_ts/standards/index.js +15 -0
- package/dist_ts/standards/output.d.ts +7 -0
- package/dist_ts/standards/output.js +57 -0
- package/dist_ts/standards/ratchet.d.ts +15 -0
- package/dist_ts/standards/ratchet.js +45 -0
- package/dist_ts/standards/rule.d.ts +50 -0
- package/dist_ts/standards/rule.js +9 -0
- package/dist_ts/standards/rules/barrel.element-index.d.ts +7 -0
- package/dist_ts/standards/rules/barrel.element-index.js +108 -0
- package/dist_ts/standards/rules/barrel.no-demo-export.d.ts +3 -0
- package/dist_ts/standards/rules/barrel.no-demo-export.js +29 -0
- package/dist_ts/standards/rules/demo.fixture-not-exported.d.ts +3 -0
- package/dist_ts/standards/rules/demo.fixture-not-exported.js +29 -0
- package/dist_ts/standards/rules/demo.groups.d.ts +6 -0
- package/dist_ts/standards/rules/demo.groups.js +72 -0
- package/dist_ts/standards/rules/demo.required.d.ts +3 -0
- package/dist_ts/standards/rules/demo.required.js +48 -0
- package/dist_ts/standards/rules/demo.separate-file.d.ts +3 -0
- package/dist_ts/standards/rules/demo.separate-file.js +38 -0
- package/dist_ts/standards/rules/demo.shape.d.ts +3 -0
- package/dist_ts/standards/rules/demo.shape.js +38 -0
- package/dist_ts/standards/rules/docs.component-index.d.ts +8 -0
- package/dist_ts/standards/rules/docs.component-index.js +84 -0
- package/dist_ts/standards/rules/element.exported.d.ts +3 -0
- package/dist_ts/standards/rules/element.exported.js +33 -0
- package/dist_ts/standards/rules/element.literal-tag.d.ts +3 -0
- package/dist_ts/standards/rules/element.literal-tag.js +30 -0
- package/dist_ts/standards/rules/element.reachable.d.ts +3 -0
- package/dist_ts/standards/rules/element.reachable.js +48 -0
- package/dist_ts/standards/rules/element.tag-map.d.ts +3 -0
- package/dist_ts/standards/rules/element.tag-map.js +83 -0
- package/dist_ts/standards/rules/i18n.literals.d.ts +3 -0
- package/dist_ts/standards/rules/i18n.literals.js +52 -0
- package/dist_ts/standards/rules/index.d.ts +12 -0
- package/dist_ts/standards/rules/index.js +83 -0
- package/dist_ts/standards/rules/layout.element-folder.d.ts +3 -0
- package/dist_ts/standards/rules/layout.element-folder.js +47 -0
- package/dist_ts/standards/rules/layout.entry-file-name.d.ts +3 -0
- package/dist_ts/standards/rules/layout.entry-file-name.js +71 -0
- package/dist_ts/standards/rules/layout.entry.d.ts +3 -0
- package/dist_ts/standards/rules/layout.entry.js +38 -0
- package/dist_ts/standards/rules/layout.group-folder.d.ts +3 -0
- package/dist_ts/standards/rules/layout.group-folder.js +22 -0
- package/dist_ts/standards/rules/layout.shared-00.d.ts +3 -0
- package/dist_ts/standards/rules/layout.shared-00.js +36 -0
- package/dist_ts/standards/rules/naming.class-matches-tag.d.ts +3 -0
- package/dist_ts/standards/rules/naming.class-matches-tag.js +26 -0
- package/dist_ts/standards/rules/naming.lowercase-files.d.ts +3 -0
- package/dist_ts/standards/rules/naming.lowercase-files.js +26 -0
- package/dist_ts/standards/rules/naming.tag-prefix.d.ts +3 -0
- package/dist_ts/standards/rules/naming.tag-prefix.js +23 -0
- package/dist_ts/standards/rules/props.accessor.d.ts +3 -0
- package/dist_ts/standards/rules/props.accessor.js +35 -0
- package/dist_ts/standards/rules/props.object-no-attribute.d.ts +3 -0
- package/dist_ts/standards/rules/props.object-no-attribute.js +42 -0
- package/dist_ts/standards/rules/props.reflect-primitive.d.ts +5 -0
- package/dist_ts/standards/rules/props.reflect-primitive.js +41 -0
- package/dist_ts/standards/rules/props.typed.d.ts +6 -0
- package/dist_ts/standards/rules/props.typed.js +64 -0
- package/dist_ts/standards/rules/shared.d.ts +38 -0
- package/dist_ts/standards/rules/shared.js +103 -0
- package/dist_ts/standards/rules/style.bdtheme.d.ts +5 -0
- package/dist_ts/standards/rules/style.bdtheme.js +28 -0
- package/dist_ts_interfaces/bridge.d.ts +88 -0
- package/dist_ts_interfaces/bridge.js +2 -0
- package/dist_ts_interfaces/catalog.d.ts +32 -0
- package/dist_ts_interfaces/catalog.js +6 -0
- package/dist_ts_interfaces/index.d.ts +4 -0
- package/dist_ts_interfaces/index.js +5 -0
- package/dist_ts_interfaces/plugins.d.ts +2 -0
- package/dist_ts_interfaces/plugins.js +3 -0
- package/dist_ts_interfaces/requests.d.ts +15 -0
- package/dist_ts_interfaces/requests.js +2 -0
- package/dist_ts_interfaces/standard.d.ts +93 -0
- package/dist_ts_interfaces/standard.js +6 -0
- package/dist_ts_shared/index.d.ts +1 -0
- package/dist_ts_shared/index.js +2 -0
- package/dist_ts_shared/plugins.d.ts +2 -0
- package/dist_ts_shared/plugins.js +3 -0
- package/dist_ts_shared/previewroute.d.ts +18 -0
- package/dist_ts_shared/previewroute.js +47 -0
- package/{dist_ts_web → dist_ts_shell}/elements/wcc-contextmenu.js +1 -1
- package/dist_ts_shell/elements/wcc-preview-frame.d.ts +23 -0
- package/dist_ts_shell/elements/wcc-preview-frame.js +197 -0
- package/dist_ts_shell/elements/wcc-properties.d.ts +49 -0
- package/{dist_ts_web → dist_ts_shell}/elements/wcc-properties.js +149 -216
- package/{dist_ts_web → dist_ts_shell}/elements/wcc-record-button.js +1 -1
- package/{dist_ts_web → dist_ts_shell}/elements/wcc-recording-panel.d.ts +2 -2
- package/{dist_ts_web → dist_ts_shell}/elements/wcc-recording-panel.js +12 -16
- package/dist_ts_shell/elements/wcc-shell.d.ts +87 -0
- package/dist_ts_shell/elements/wcc-shell.js +524 -0
- package/{dist_ts_web → dist_ts_shell}/elements/wcc-sidebar.d.ts +12 -10
- package/{dist_ts_web → dist_ts_shell}/elements/wcc-sidebar.js +137 -183
- package/dist_ts_shell/index.d.ts +4 -0
- package/dist_ts_shell/index.js +5 -0
- package/dist_ts_shell/plugins.d.ts +4 -0
- package/dist_ts_shell/plugins.js +5 -0
- package/dist_ts_shell/previewconnection.d.ts +30 -0
- package/dist_ts_shell/previewconnection.js +86 -0
- package/dist_ts_shell/services/recorder.service.js +353 -0
- package/dist_ts_web/00_commitinfo_data.js +2 -2
- package/dist_ts_web/catalog.d.ts +45 -0
- package/dist_ts_web/catalog.js +153 -0
- package/dist_ts_web/elementproperties.d.ts +15 -0
- package/dist_ts_web/elementproperties.js +89 -0
- package/dist_ts_web/elements/wcc-preview.d.ts +19 -0
- package/dist_ts_web/elements/wcc-preview.js +164 -0
- package/dist_ts_web/elements/wcc-standalone-notice.d.ts +14 -0
- package/dist_ts_web/elements/wcc-standalone-notice.js +110 -0
- package/dist_ts_web/elements/wcctools.helpers.d.ts +2 -6
- package/dist_ts_web/elements/wcctools.helpers.js +1 -7
- package/dist_ts_web/index.d.ts +2 -5
- package/dist_ts_web/index.js +38 -17
- package/dist_ts_web/previewbridge.d.ts +39 -0
- package/dist_ts_web/previewbridge.js +153 -0
- package/dist_ts_web/renderedtree.d.ts +5 -0
- package/dist_ts_web/renderedtree.js +37 -0
- package/dist_ts_web/wcctools.interfaces.d.ts +3 -6
- package/dist_ts_web/wcctools.interfaces.js +1 -1
- package/dist_ts_web/wcctools.plugins.d.ts +3 -1
- package/dist_ts_web/wcctools.plugins.js +4 -2
- package/package.json +17 -7
- package/readme.md +93 -28
- package/ts/00_commitinfo_data.ts +8 -0
- package/ts/classes.devapi.ts +25 -0
- package/ts/classes.devserver.ts +161 -0
- package/ts/cli.ts +53 -0
- package/ts/index.ts +3 -0
- package/ts/paths.ts +7 -0
- package/ts/plugins.ts +37 -0
- package/ts/shellcontent.ts +42 -0
- package/ts/standards/baselines.ts +100 -0
- package/ts/standards/checker.ts +114 -0
- package/ts/standards/classes.gitrepo.ts +48 -0
- package/ts/standards/classes.sourceedits.ts +58 -0
- package/ts/standards/classes.standardfixer.ts +234 -0
- package/ts/standards/classes.standardproject.ts +250 -0
- package/ts/standards/classes.standardtree.ts +220 -0
- package/ts/standards/cli.ts +86 -0
- package/ts/standards/config.ts +160 -0
- package/ts/standards/errors.ts +18 -0
- package/ts/standards/helpers.ast.ts +208 -0
- package/ts/standards/helpers.paths.ts +119 -0
- package/ts/standards/index.ts +22 -0
- package/ts/standards/output.ts +65 -0
- package/ts/standards/ratchet.ts +63 -0
- package/ts/standards/rule.ts +58 -0
- package/ts/standards/rules/barrel.element-index.ts +112 -0
- package/ts/standards/rules/barrel.no-demo-export.ts +30 -0
- package/ts/standards/rules/demo.fixture-not-exported.ts +30 -0
- package/ts/standards/rules/demo.groups.ts +74 -0
- package/ts/standards/rules/demo.required.ts +49 -0
- package/ts/standards/rules/demo.separate-file.ts +40 -0
- package/ts/standards/rules/demo.shape.ts +42 -0
- package/ts/standards/rules/docs.component-index.ts +88 -0
- package/ts/standards/rules/element.exported.ts +35 -0
- package/ts/standards/rules/element.literal-tag.ts +30 -0
- package/ts/standards/rules/element.reachable.ts +49 -0
- package/ts/standards/rules/element.tag-map.ts +87 -0
- package/ts/standards/rules/i18n.literals.ts +58 -0
- package/ts/standards/rules/index.ts +87 -0
- package/ts/standards/rules/layout.element-folder.ts +49 -0
- package/ts/standards/rules/layout.entry-file-name.ts +73 -0
- package/ts/standards/rules/layout.entry.ts +40 -0
- package/ts/standards/rules/layout.group-folder.ts +23 -0
- package/ts/standards/rules/layout.shared-00.ts +37 -0
- package/ts/standards/rules/naming.class-matches-tag.ts +27 -0
- package/ts/standards/rules/naming.lowercase-files.ts +27 -0
- package/ts/standards/rules/naming.tag-prefix.ts +24 -0
- package/ts/standards/rules/props.accessor.ts +37 -0
- package/ts/standards/rules/props.object-no-attribute.ts +45 -0
- package/ts/standards/rules/props.reflect-primitive.ts +44 -0
- package/ts/standards/rules/props.typed.ts +70 -0
- package/ts/standards/rules/shared.ts +127 -0
- package/ts/standards/rules/style.bdtheme.ts +31 -0
- package/ts_web/00_commitinfo_data.ts +1 -1
- package/ts_web/catalog.ts +194 -0
- package/ts_web/elementproperties.ts +96 -0
- package/ts_web/elements/wcc-preview.ts +111 -0
- package/ts_web/elements/wcc-standalone-notice.ts +65 -0
- package/ts_web/elements/wcctools.helpers.ts +2 -10
- package/ts_web/index.ts +38 -19
- package/ts_web/previewbridge.ts +189 -0
- package/ts_web/readme.md +26 -85
- package/ts_web/renderedtree.ts +43 -0
- package/ts_web/wcctools.interfaces.ts +4 -7
- package/ts_web/wcctools.plugins.ts +6 -1
- package/dist_bundle/bundle.js +0 -92419
- package/dist_bundle/bundle.js.map +0 -1
- package/dist_ts_web/elements/wcc-dashboard.d.ts +0 -119
- package/dist_ts_web/elements/wcc-dashboard.js +0 -700
- package/dist_ts_web/elements/wcc-frame.d.ts +0 -22
- package/dist_ts_web/elements/wcc-frame.js +0 -256
- package/dist_ts_web/elements/wcc-properties.d.ts +0 -43
- package/dist_ts_web/pages/index.d.ts +0 -1
- package/dist_ts_web/pages/index.js +0 -2
- package/dist_ts_web/services/recorder.service.js +0 -353
- package/ts_web/elements/wcc-contextmenu.ts +0 -291
- package/ts_web/elements/wcc-dashboard.ts +0 -672
- package/ts_web/elements/wcc-frame.ts +0 -189
- package/ts_web/elements/wcc-properties.ts +0 -1089
- package/ts_web/elements/wcc-record-button.ts +0 -108
- package/ts_web/elements/wcc-recording-panel.ts +0 -1010
- package/ts_web/elements/wcc-sidebar.ts +0 -1470
- package/ts_web/pages/index.ts +0 -1
- package/ts_web/services/recorder.service.ts +0 -451
- package/ts_web/types/dom-mediacapture-stub/index.d.ts +0 -12
- package/ts_web/types/dom-mediacapture-stub/package.json +0 -6
- package/ts_web/types/dom-webcodecs-stub/index.d.ts +0 -2
- package/ts_web/types/dom-webcodecs-stub/package.json +0 -6
- /package/{dist_ts_web → dist_ts_shell}/elements/wcc-contextmenu.d.ts +0 -0
- /package/{dist_ts_web → dist_ts_shell}/elements/wcc-record-button.d.ts +0 -0
- /package/{dist_ts_web → dist_ts_shell}/services/recorder.service.d.ts +0 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import * as plugins from '../plugins.js';
|
|
2
|
+
import { rekeyStandardConfig } from './baselines.js';
|
|
3
|
+
import { checkStandard, detectFindings, rulesOfProfile } from './checker.js';
|
|
4
|
+
import { GitRepo } from './classes.gitrepo.js';
|
|
5
|
+
import { loadStandardProject, standardSideFiles } from './classes.standardproject.js';
|
|
6
|
+
import { FixBatch, StandardTree } from './classes.standardtree.js';
|
|
7
|
+
import type { ILoadedStandardConfig, IStandardConfigWriter } from './config.js';
|
|
8
|
+
import { StandardUsageError } from './errors.js';
|
|
9
|
+
import { dirOf, isRepoPath, isTypeScriptSource } from './helpers.paths.js';
|
|
10
|
+
import { fixOrder, standardRules } from './rules/index.js';
|
|
11
|
+
|
|
12
|
+
export interface IStandardFixOptions {
|
|
13
|
+
rootDir: string;
|
|
14
|
+
loaded: ILoadedStandardConfig;
|
|
15
|
+
/** Rules to fix; opt-in rules run only when named here. Default: every `auto` rule of the profile. */
|
|
16
|
+
rules?: readonly string[];
|
|
17
|
+
dryRun?: boolean;
|
|
18
|
+
/** Persists the configuration when moves rename files the allowlist or baselines name; without it such moves are refused. */
|
|
19
|
+
configWriter?: IStandardConfigWriter;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How often one rule is re-run on the edited tree to apply edits that overlapped in an earlier run. */
|
|
23
|
+
const maxRunsPerRule = 5;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `wcctools fix`: applies the fixes of the selected rules to an in-memory copy of the repository,
|
|
27
|
+
* one rule after another on the re-parsed result, validates that every path of the resulting plan
|
|
28
|
+
* stays inside the repository root, then writes the configuration, moves files with `git mv`, writes
|
|
29
|
+
* the edited files and checks again. Refuses to run on a dirty work tree and never commits, so a
|
|
30
|
+
* failure part-way through the writes is undone with `git reset --hard` and `git clean -fd`.
|
|
31
|
+
*/
|
|
32
|
+
export class StandardFixer {
|
|
33
|
+
private readonly options: IStandardFixOptions;
|
|
34
|
+
|
|
35
|
+
constructor(optionsArg: IStandardFixOptions) {
|
|
36
|
+
this.options = optionsArg;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private selectRules(): string[] {
|
|
40
|
+
const { profile } = this.options.loaded;
|
|
41
|
+
const inProfile = new Set(rulesOfProfile(profile).map((ruleArg) => ruleArg.id));
|
|
42
|
+
if (!this.options.rules || this.options.rules.length === 0) {
|
|
43
|
+
return fixOrder.filter((idArg) => inProfile.has(idArg) && standardRules.find((ruleArg) => ruleArg.id === idArg)!.fixMode === 'auto');
|
|
44
|
+
}
|
|
45
|
+
for (const id of this.options.rules) {
|
|
46
|
+
const rule = standardRules.find((ruleArg) => ruleArg.id === id);
|
|
47
|
+
if (!rule) {
|
|
48
|
+
throw new StandardUsageError(`Unknown rule '${id}'`);
|
|
49
|
+
}
|
|
50
|
+
if (!inProfile.has(id)) {
|
|
51
|
+
throw new StandardUsageError(`Rule '${id}' is not part of the '${profile}' profile`);
|
|
52
|
+
}
|
|
53
|
+
if (rule.fixMode === 'none') {
|
|
54
|
+
throw new StandardUsageError(`Rule '${id}' has no fix`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return fixOrder.filter((idArg) => this.options.rules!.includes(idArg));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
public async run(): Promise<plugins.interfaces.IStandardFixResult> {
|
|
61
|
+
const { rootDir, loaded } = this.options;
|
|
62
|
+
const selected = this.selectRules();
|
|
63
|
+
const git = new GitRepo(rootDir);
|
|
64
|
+
const topLevel = await git.topLevel();
|
|
65
|
+
if (!topLevel || plugins.fsSync.realpathSync(topLevel) !== plugins.fsSync.realpathSync(rootDir)) {
|
|
66
|
+
throw new StandardUsageError(`wcctools fix runs at the root of a git repository; ${rootDir} is not one`);
|
|
67
|
+
}
|
|
68
|
+
const status = await git.statusLines();
|
|
69
|
+
if (status.length > 0) {
|
|
70
|
+
throw new StandardUsageError(`wcctools fix needs a clean work tree; git status reports ${status.length} change(s). Commit or stash them first.`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const trackedFiles = await git.trackedFiles();
|
|
74
|
+
const files = new Map<string, string>();
|
|
75
|
+
for (const file of trackedFiles) {
|
|
76
|
+
if (isTypeScriptSource(file) || standardSideFiles.includes(file)) {
|
|
77
|
+
files.set(file, await plugins.fs.readFile(plugins.path.join(rootDir, file), 'utf8'));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
const tree = new StandardTree(files, trackedFiles, (fileArg) => plugins.fsSync.existsSync(plugins.path.join(rootDir, fileArg)));
|
|
81
|
+
|
|
82
|
+
for (const ruleId of selected) {
|
|
83
|
+
const rule = standardRules.find((ruleArg) => ruleArg.id === ruleId)!;
|
|
84
|
+
for (let run = 0; run < maxRunsPerRule; run++) {
|
|
85
|
+
const fixable = detectFindings(tree.project(), loaded, [rule]).filter((detectedArg) => !detectedArg.allowlisted && detectedArg.finding.fix);
|
|
86
|
+
if (fixable.length === 0) {
|
|
87
|
+
break;
|
|
88
|
+
}
|
|
89
|
+
const batch = new FixBatch();
|
|
90
|
+
fixable.forEach((detectedArg) => detectedArg.finding.fix!(batch));
|
|
91
|
+
if (tree.apply(rule.id, batch) === 0) {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const plan = tree.plan();
|
|
98
|
+
await this.assertInsideRoot(plan);
|
|
99
|
+
const rekeyed = rekeyStandardConfig(loaded.config, plan.moves);
|
|
100
|
+
if (rekeyed.changed.length > 0 && !this.options.configWriter) {
|
|
101
|
+
throw new StandardUsageError(
|
|
102
|
+
`wcctools fix would move files that the standard configuration names (${rekeyed.changed.join(', ')}) but has no configuration writer`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const loadedAfter: ILoadedStandardConfig = { ...loaded, config: rekeyed.config };
|
|
106
|
+
|
|
107
|
+
if (this.options.dryRun) {
|
|
108
|
+
return { dryRun: true, plan, remaining: checkStandard(tree.project(), loadedAfter) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Every path is validated above. The configuration goes first: a refused write (the file changed
|
|
112
|
+
// since it was read) leaves the tree untouched. A failure after it leaves a partial result that the
|
|
113
|
+
// clean work tree fix started from restores (`git reset --hard`, `git clean -fd`).
|
|
114
|
+
if (rekeyed.changed.length > 0) {
|
|
115
|
+
await this.options.configWriter!.writeStandardConfig(rekeyed.config);
|
|
116
|
+
}
|
|
117
|
+
await this.commitMoves(git, plan.moves);
|
|
118
|
+
for (const file of [...plan.created, ...plan.edited.map((editedArg) => editedArg.file)]) {
|
|
119
|
+
await this.ensureDir(dirOf(file));
|
|
120
|
+
await plugins.fs.writeFile(plugins.path.join(rootDir, file), tree.files.get(file)!, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
await this.removeEmptyDirs(plan.moves.map((moveArg) => dirOf(moveArg.from)));
|
|
123
|
+
return { dryRun: false, plan, remaining: checkStandard(await loadStandardProject(rootDir), loadedAfter) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Refuses a plan with a path that leaves the repository root, lexically (an absolute path or a `..`
|
|
128
|
+
* segment) or physically (through a symbolic link that resolves outside the root or dangles).
|
|
129
|
+
* Runs before anything is written, so a refused plan leaves the repository and its surroundings as
|
|
130
|
+
* they were.
|
|
131
|
+
*/
|
|
132
|
+
private async assertInsideRoot(planArg: plugins.interfaces.IStandardFixPlan): Promise<void> {
|
|
133
|
+
const realRoot = await plugins.fs.realpath(this.options.rootDir);
|
|
134
|
+
const paths = [
|
|
135
|
+
...planArg.moves.flatMap((moveArg) => [moveArg.from, moveArg.to]),
|
|
136
|
+
...planArg.created,
|
|
137
|
+
...planArg.edited.map((editedArg) => editedArg.file),
|
|
138
|
+
];
|
|
139
|
+
const outside: string[] = [];
|
|
140
|
+
for (const file of new Set(paths)) {
|
|
141
|
+
if (!isRepoPath(file) || !(await this.resolvesInside(realRoot, file))) {
|
|
142
|
+
outside.push(file);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (outside.length > 0) {
|
|
146
|
+
throw new StandardUsageError(`wcctools fix refuses to write outside the repository root: ${outside.join(', ')}`);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** True when the file, or its nearest existing ancestor, resolves to a path inside the real root. */
|
|
151
|
+
private async resolvesInside(realRootArg: string, fileArg: string): Promise<boolean> {
|
|
152
|
+
let current = plugins.path.join(realRootArg, fileArg);
|
|
153
|
+
while (true) {
|
|
154
|
+
try {
|
|
155
|
+
const real = await plugins.fs.realpath(current);
|
|
156
|
+
return real === realRootArg || real.startsWith(`${realRootArg}${plugins.path.sep}`);
|
|
157
|
+
} catch (error) {
|
|
158
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
159
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
|
|
160
|
+
throw error;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (await this.isSymbolicLink(current)) {
|
|
164
|
+
// A dangling link: writing through it would create its target, wherever that is.
|
|
165
|
+
return false;
|
|
166
|
+
}
|
|
167
|
+
const parent = plugins.path.dirname(current);
|
|
168
|
+
if (parent === current) {
|
|
169
|
+
return false;
|
|
170
|
+
}
|
|
171
|
+
current = parent;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private async isSymbolicLink(pathArg: string): Promise<boolean> {
|
|
176
|
+
try {
|
|
177
|
+
return (await plugins.fs.lstat(pathArg)).isSymbolicLink();
|
|
178
|
+
} catch (error) {
|
|
179
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
180
|
+
if (code === 'ENOENT' || code === 'ENOTDIR') {
|
|
181
|
+
return false;
|
|
182
|
+
}
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** `git mv` in an order where no move overwrites a file that has not moved away yet. */
|
|
188
|
+
private async commitMoves(gitArg: GitRepo, movesArg: plugins.interfaces.IStandardFileMove[]): Promise<void> {
|
|
189
|
+
const pending = [...movesArg];
|
|
190
|
+
while (pending.length > 0) {
|
|
191
|
+
const index = pending.findIndex((moveArg) => !plugins.fsSync.existsSync(plugins.path.join(this.options.rootDir, moveArg.to)));
|
|
192
|
+
if (index === -1) {
|
|
193
|
+
throw new Error(`Cannot order the moves ${pending.map((moveArg) => `${moveArg.from} → ${moveArg.to}`).join(', ')}`);
|
|
194
|
+
}
|
|
195
|
+
const [move] = pending.splice(index, 1);
|
|
196
|
+
await this.ensureDir(dirOf(move.to));
|
|
197
|
+
await gitArg.move(move.from, move.to);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Creates a directory one path component at a time. */
|
|
202
|
+
private async ensureDir(dirArg: string): Promise<void> {
|
|
203
|
+
let current = this.options.rootDir;
|
|
204
|
+
for (const segment of dirArg.split('/').filter((segmentArg) => segmentArg && segmentArg !== '.')) {
|
|
205
|
+
current = plugins.path.join(current, segment);
|
|
206
|
+
try {
|
|
207
|
+
await plugins.fs.mkdir(current);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Removes directories that moves left empty, walking up towards the repository root. */
|
|
217
|
+
private async removeEmptyDirs(dirsArg: string[]): Promise<void> {
|
|
218
|
+
for (const start of [...new Set(dirsArg)].sort((a, b) => b.length - a.length)) {
|
|
219
|
+
let dir = start;
|
|
220
|
+
while (dir && dir !== '.') {
|
|
221
|
+
try {
|
|
222
|
+
await plugins.fs.rmdir(plugins.path.join(this.options.rootDir, dir));
|
|
223
|
+
} catch (error) {
|
|
224
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
225
|
+
if (code === 'ENOTEMPTY' || code === 'EEXIST' || code === 'ENOENT') {
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
throw error;
|
|
229
|
+
}
|
|
230
|
+
dir = dirOf(dir);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|