@lariocpt/flawless-react 0.0.0-stage → 0.3.1
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/.agents/skills/flawless-a11y/SKILL.md +113 -0
- package/.agents/skills/flawless-api/SKILL.md +159 -0
- package/.agents/skills/flawless-api-fixtures/SKILL.md +144 -0
- package/.agents/skills/flawless-e2e/SKILL.md +155 -0
- package/.agents/skills/flawless-expo/SKILL.md +283 -0
- package/.agents/skills/flawless-findings/SKILL.md +116 -0
- package/.agents/skills/flawless-load/SKILL.md +112 -0
- package/.agents/skills/flawless-nextjs/SKILL.md +198 -0
- package/.agents/skills/flawless-perf-harness/SKILL.md +158 -0
- package/.agents/skills/flawless-perf-static/SKILL.md +131 -0
- package/.agents/skills/flawless-react/SKILL.md +403 -0
- package/.agents/skills/flawless-react-rules/SKILL.md +96 -0
- package/.agents/skills/flawless-regen/SKILL.md +135 -0
- package/.agents/skills/flawless-unit-browser/SKILL.md +178 -0
- package/LICENSE +21 -0
- package/README.md +611 -2
- package/bin/flawless-react +4 -0
- package/package.json +67 -4
- package/scripts/postinstall.d.mts +46 -0
- package/scripts/postinstall.mjs +127 -0
- package/src/analyse/authShims.ts +220 -0
- package/src/analyse/bootability.ts +257 -0
- package/src/analyse/childBridge.ts +302 -0
- package/src/analyse/childrenIntoSlot.ts +66 -0
- package/src/analyse/compoundFamily.ts +100 -0
- package/src/analyse/dataExports.ts +200 -0
- package/src/analyse/extractChildAttrs.ts +112 -0
- package/src/analyse/extractDataDeps.ts +857 -0
- package/src/analyse/extractDisclosures.ts +186 -0
- package/src/analyse/extractFetchCalls.ts +81 -0
- package/src/analyse/extractFlows.ts +239 -0
- package/src/analyse/extractGuards.ts +270 -0
- package/src/analyse/extractHooks.ts +237 -0
- package/src/analyse/extractInteractions.ts +1421 -0
- package/src/analyse/extractLogging.ts +45 -0
- package/src/analyse/extractNativeA11y.ts +138 -0
- package/src/analyse/extractPredicates.ts +784 -0
- package/src/analyse/extractPropUsage.ts +162 -0
- package/src/analyse/extractProps.ts +1212 -0
- package/src/analyse/extractRenderMode.ts +165 -0
- package/src/analyse/extractRootTag.ts +117 -0
- package/src/analyse/extractState.ts +361 -0
- package/src/analyse/extractStaticText.ts +212 -0
- package/src/analyse/extractVariants.ts +186 -0
- package/src/analyse/harvestApiEndpoints.ts +284 -0
- package/src/analyse/hostProps.ts +185 -0
- package/src/analyse/hostTag.ts +72 -0
- package/src/analyse/htmlContentModel.ts +94 -0
- package/src/analyse/index.ts +744 -0
- package/src/analyse/mountPathCalls.ts +442 -0
- package/src/analyse/nativeContentModel.ts +129 -0
- package/src/analyse/nativeDialect.ts +83 -0
- package/src/analyse/nativeProps.ts +339 -0
- package/src/analyse/ownReturns.ts +31 -0
- package/src/analyse/parseFile.ts +919 -0
- package/src/analyse/pos.ts +8 -0
- package/src/analyse/providerWraps.ts +1977 -0
- package/src/analyse/pureModule.ts +88 -0
- package/src/analyse/reexportTable.ts +148 -0
- package/src/analyse/refs.ts +195 -0
- package/src/analyse/renderPath.ts +441 -0
- package/src/analyse/resolveDepReturns.ts +370 -0
- package/src/analyse/resolveIndexKeys.ts +98 -0
- package/src/analyse/resolveModule.ts +431 -0
- package/src/analyse/resolveTagRootTags.ts +103 -0
- package/src/analyse/serverActionReturns.ts +213 -0
- package/src/analyse/sourceCache.ts +263 -0
- package/src/analyse/spreadSiblings.ts +98 -0
- package/src/analyse/walkChildren.ts +98 -0
- package/src/analyse/zodSchema.ts +424 -0
- package/src/bin.ts +23 -0
- package/src/changed.ts +221 -0
- package/src/cli.ts +560 -0
- package/src/cliFlags.ts +281 -0
- package/src/core.ts +1229 -0
- package/src/docLine.ts +122 -0
- package/src/emitPaths.ts +51 -0
- package/src/expo/detect.ts +184 -0
- package/src/expo/refusal.ts +62 -0
- package/src/expo/routes.ts +145 -0
- package/src/index.ts +95 -0
- package/src/mcp/analysisTools.ts +720 -0
- package/src/mcp/infoTools.ts +135 -0
- package/src/mcp/protocol.ts +130 -0
- package/src/mcp/run.ts +269 -0
- package/src/mcp/runTools.ts +362 -0
- package/src/mcp/server.ts +279 -0
- package/src/mcp/spec.ts +86 -0
- package/src/mcp/support.ts +268 -0
- package/src/mcp/tools.ts +75 -0
- package/src/nextjs/apiRoutes.ts +116 -0
- package/src/nextjs/detect.ts +397 -0
- package/src/nextjs/emit.ts +106 -0
- package/src/nextjs/liveSpec.ts +79 -0
- package/src/nextjs/mswHandlers.ts +55 -0
- package/src/nextjs/providerWrapper.ts +603 -0
- package/src/nextjs/routes.ts +86 -0
- package/src/nextjs/swagger.ts +32 -0
- package/src/packageJson.ts +414 -0
- package/src/platform.ts +18 -0
- package/src/prompt.ts +132 -0
- package/src/reactCompiler.ts +165 -0
- package/src/reporters/findings.ts +156 -0
- package/src/reporters/report.ts +463 -0
- package/src/runner/runTests.ts +402 -0
- package/src/scaffold/adoptStyle.ts +181 -0
- package/src/scaffold/index.ts +253 -0
- package/src/scaffold/openapi.ts +183 -0
- package/src/scaffold/templates.ts +2379 -0
- package/src/scaffold-tests/index.ts +463 -0
- package/src/scaffold-tests/mockValues.ts +208 -0
- package/src/scaffold-tests/mocks.ts +794 -0
- package/src/scaffold-tests/nativeIndex.ts +52 -0
- package/src/scaffold-tests/nativeTemplates.ts +1528 -0
- package/src/scaffold-tests/postDispatch.ts +152 -0
- package/src/scaffold-tests/registry.ts +443 -0
- package/src/scaffold-tests/selectors.ts +493 -0
- package/src/scaffold-tests/selectorsNative.ts +273 -0
- package/src/scaffold-tests/stamp.ts +79 -0
- package/src/scaffold-tests/templates.ts +3578 -0
- package/src/skills/cli.ts +248 -0
- package/src/skills/compat.ts +166 -0
- package/src/skills/data.ts +192 -0
- package/src/skills/md.d.ts +7 -0
- package/src/skills/menu.ts +55 -0
- package/src/skills/spec.ts +227 -0
- package/src/suiteScripts.ts +110 -0
- package/src/tasks/a11yNative.ts +281 -0
- package/src/tasks/a11yScan.ts +67 -0
- package/src/tasks/biomeRunner.ts +301 -0
- package/src/tasks/biomeSweep.ts +166 -0
- package/src/tasks/e2eExpo.ts +436 -0
- package/src/tasks/e2eRoutes.ts +756 -0
- package/src/tasks/eslintReactRules.ts +67 -0
- package/src/tasks/index.ts +24 -0
- package/src/tasks/loadExpo.ts +273 -0
- package/src/tasks/loadPipes.ts +750 -0
- package/src/tasks/perfHarness.ts +145 -0
- package/src/tasks/staticPerfAnalysis.ts +510 -0
- package/src/tasks/types.ts +1 -0
- package/src/tasks/unitAndBrowser.ts +462 -0
- package/src/types.ts +1419 -0
- package/src/version.ts +12 -0
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import { TEXTLESS_ELEMENTS } from "../analyse/nativeProps.ts";
|
|
2
|
+
import type { ClickHandler, LabelSource } from "../types.ts";
|
|
3
|
+
import { propNameRegex } from "./selectors.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @testing-library/react-native locators, as a priority ladder.
|
|
7
|
+
*
|
|
8
|
+
* A separate file rather than a `flavour` parameter on selectors.ts. Every rung
|
|
9
|
+
* of the DOM ladder is DOM-shaped — TAG_TO_ROLE, `#id`, container.querySelector,
|
|
10
|
+
* SUBMIT_WITHIN_FORM, the lowercase isHostTag, awaitSelector's `as HTMLElement`
|
|
11
|
+
* string surgery — and threading a flag through fourteen exported functions
|
|
12
|
+
* would leave every one of those branches reachable from a native call site by
|
|
13
|
+
* accident. The ladder STRUCTURE transfers; the expressions do not.
|
|
14
|
+
*
|
|
15
|
+
* `undefined` is load-bearing here exactly as it is there: it makes the caller
|
|
16
|
+
* emit a test.skip that NAMES the gap, rather than a locator that matches
|
|
17
|
+
* nothing and passes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** Rung the target was found by — the emitter needs it to pick the dispatch. */
|
|
21
|
+
export type NativeRung =
|
|
22
|
+
| "testID"
|
|
23
|
+
| "label"
|
|
24
|
+
| "role"
|
|
25
|
+
| "placeholder"
|
|
26
|
+
| "displayValue"
|
|
27
|
+
| "text"
|
|
28
|
+
| "text-partial";
|
|
29
|
+
|
|
30
|
+
export interface NativeTarget {
|
|
31
|
+
/** An RNTL expression resolving to the element. */
|
|
32
|
+
locator: string;
|
|
33
|
+
rung: NativeRung;
|
|
34
|
+
/**
|
|
35
|
+
* The located element IS the pressable itself (a rung read off its own props),
|
|
36
|
+
* rather than a descendant `<Text>` that merely sits inside it.
|
|
37
|
+
*
|
|
38
|
+
* Decides the dispatch: RNTL's userEvent.press requires a pressable host
|
|
39
|
+
* element and rejects a Text that is not one, while fireEvent.press walks UP
|
|
40
|
+
* to the nearest press responder — which is exactly what a text rung hands it.
|
|
41
|
+
* So the rung the target came from is the evidence for the choice, rather than
|
|
42
|
+
* a preference.
|
|
43
|
+
*/
|
|
44
|
+
self: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const j = (v: string): string => JSON.stringify(v);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A regex matching a fragment of an element's text, anchored at neither end.
|
|
51
|
+
*
|
|
52
|
+
* RNTL's getByText matches the element's WHOLE composed text content, so a
|
|
53
|
+
* partial label — `<Pressable><Icon/><Text>Add</Text></Pressable>`, where the
|
|
54
|
+
* icon contributes nothing knowable — can only be matched as a substring.
|
|
55
|
+
*/
|
|
56
|
+
function partialRegex(value: string): string {
|
|
57
|
+
// The class includes `/`, which the DOM twin (selectors.ts) has had all along
|
|
58
|
+
// and this copy did not. An unescaped `/` CLOSES the regex literal, so copy
|
|
59
|
+
// like "24/7" or "and/or" emitted `screen.getAllByText(/Save 50/50 split/)` —
|
|
60
|
+
// a syntax error that takes the WHOLE SPEC FILE out of the run, not just that
|
|
61
|
+
// one assertion.
|
|
62
|
+
return `/${value.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&")}/`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The ladder. Returns undefined when nothing in the source identifies the
|
|
67
|
+
* element — see describeUnlocatableNative for what the caller says then.
|
|
68
|
+
*
|
|
69
|
+
* There is NO last-resort rung, and that is a decision rather than an omission.
|
|
70
|
+
* The DOM ladder bottoms out at `container.querySelector("button")` when the tag
|
|
71
|
+
* is unambiguous; the two candidates here are both unsound:
|
|
72
|
+
*
|
|
73
|
+
* - `UNSAFE_getByType(Pressable)` — the prefix is the library warning you it is
|
|
74
|
+
* coupled to implementation, and under NativeWind's `jsxImportSource` the
|
|
75
|
+
* Pressable the SOURCE rendered went through cssInterop's wrapper while the
|
|
76
|
+
* one a spec imports from "react-native" is the bare component. Different
|
|
77
|
+
* component references, so it matches zero elements, and fireEvent.press on
|
|
78
|
+
* nothing is a green test that touched nothing.
|
|
79
|
+
* - "the first pressable in the tree" — wrong for the same reason the DOM
|
|
80
|
+
* ladder refuses a bare-tag locator on an ambiguous tag, and wrong more
|
|
81
|
+
* often, because every React Native screen is a stack of Pressables.
|
|
82
|
+
*/
|
|
83
|
+
export function emitNativeSelector(h: ClickHandler): NativeTarget | undefined {
|
|
84
|
+
if (h.hints.testid) {
|
|
85
|
+
return { locator: `screen.getByTestId(${j(h.hints.testid)})`, rung: "testID", self: true };
|
|
86
|
+
}
|
|
87
|
+
if (h.hints.ariaLabel) {
|
|
88
|
+
return {
|
|
89
|
+
locator: `screen.getAllByLabelText(${j(h.hints.ariaLabel)})[0]`,
|
|
90
|
+
rung: "label",
|
|
91
|
+
self: true,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
// Only an EXPLICIT accessibilityRole. RNTL's getRole() reads that prop alone —
|
|
95
|
+
// there is no Pressable -> button mapping — so inferring one from the tag would
|
|
96
|
+
// emit a query that matches nothing while reporting coverage.
|
|
97
|
+
if (h.hints.role) {
|
|
98
|
+
const name = nameOption(h.label);
|
|
99
|
+
return {
|
|
100
|
+
locator: `screen.getAllByRole(${j(h.hints.role)}${name})[0]`,
|
|
101
|
+
rung: "role",
|
|
102
|
+
self: true,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// Above text, deliberately: a placeholder names THE ELEMENT, where text names a
|
|
106
|
+
// child. On the dogfood app that ordering is the difference between 25
|
|
107
|
+
// addressable TextInputs and none.
|
|
108
|
+
if (h.hints.placeholder) {
|
|
109
|
+
return {
|
|
110
|
+
locator: `screen.getAllByPlaceholderText(${j(h.hints.placeholder)})[0]`,
|
|
111
|
+
rung: "placeholder",
|
|
112
|
+
self: true,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
if (h.hints.displayValue) {
|
|
116
|
+
return {
|
|
117
|
+
locator: `screen.getAllByDisplayValue(${j(h.hints.displayValue)})[0]`,
|
|
118
|
+
rung: "displayValue",
|
|
119
|
+
self: true,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
// RN's <Button title="Save"/> renders no children, so no text rung can see it;
|
|
123
|
+
// its role and its name both come off its props.
|
|
124
|
+
if (h.hints.buttonTitle) {
|
|
125
|
+
return {
|
|
126
|
+
locator: `screen.getAllByText(${j(h.hints.buttonTitle)})[0]`,
|
|
127
|
+
rung: "text",
|
|
128
|
+
self: true,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (h.label.kind === "text") {
|
|
132
|
+
return { locator: `screen.getAllByText(${j(h.label.value)})[0]`, rung: "text", self: false };
|
|
133
|
+
}
|
|
134
|
+
if (h.label.kind === "text-partial") {
|
|
135
|
+
return {
|
|
136
|
+
locator: `screen.getAllByText(${partialRegex(h.label.value)})[0]`,
|
|
137
|
+
rung: "text-partial",
|
|
138
|
+
self: false,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
if (h.label.kind === "prop") {
|
|
142
|
+
if (h.label.before || h.label.after) {
|
|
143
|
+
return {
|
|
144
|
+
locator: `screen.getAllByText(${propNameRegex(h.label)})[0]`,
|
|
145
|
+
rung: "text",
|
|
146
|
+
self: false,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
locator: `screen.getAllByText(String(DEFAULTS[${j(h.label.propName)}] ?? ""))[0]`,
|
|
151
|
+
rung: "text",
|
|
152
|
+
self: false,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function nameOption(label: LabelSource): string {
|
|
159
|
+
if (label.kind === "text") return `, { name: ${j(label.value)} }`;
|
|
160
|
+
if (label.kind === "text-partial") return `, { name: ${partialRegex(label.value)} }`;
|
|
161
|
+
if (label.kind === "prop") {
|
|
162
|
+
if (label.before || label.after) return `, { name: ${propNameRegex(label, "i")} }`;
|
|
163
|
+
return `, { name: String(DEFAULTS[${j(label.propName)}] ?? "") }`;
|
|
164
|
+
}
|
|
165
|
+
return "";
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Why a handler could not be addressed, in words the emitted skip prints.
|
|
170
|
+
*
|
|
171
|
+
* Three wordings, because unlike the DOM there is no "give it text" escape for an
|
|
172
|
+
* icon button — and because this string is also the remediation the native a11y
|
|
173
|
+
* task will report on the same element. One accessibilityLabel retires both.
|
|
174
|
+
*/
|
|
175
|
+
export function describeUnlocatableNative(h: ClickHandler): string {
|
|
176
|
+
// icon-only is checked FIRST even when the tag is also ambiguous. Both are
|
|
177
|
+
// true of the same element, but one accessibilityLabel fixes both — it names
|
|
178
|
+
// the control for a screen reader AND distinguishes it from its siblings — so
|
|
179
|
+
// the message that leads to that fix is the more useful one.
|
|
180
|
+
if (h.iconOnly) {
|
|
181
|
+
return (
|
|
182
|
+
`<${h.tag}> renders only an icon, so no text names it — add an accessibilityLabel, ` +
|
|
183
|
+
`which makes it announceable as well as addressable`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
if (h.ambiguousTag) {
|
|
187
|
+
return (
|
|
188
|
+
`more than one <${h.tag}> renders here and nothing distinguishes this one — ` +
|
|
189
|
+
`add a testID, or an accessibilityLabel (which also names it for a screen reader)`
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
return (
|
|
193
|
+
`nothing on the <${h.tag}> identifies it: no testID, no accessibilityLabel, ` +
|
|
194
|
+
`no accessibilityRole, and no literal text under it`
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* How to drive an event on a located element.
|
|
200
|
+
*
|
|
201
|
+
* A CLOSED switch with no permissive default, unlike the DOM emitter's
|
|
202
|
+
* `default: userEvent.click(...)`. A click on a React Native tree is nonsense
|
|
203
|
+
* that would pass silently, so an event with no honest dispatch returns
|
|
204
|
+
* undefined and the caller skips instead.
|
|
205
|
+
*
|
|
206
|
+
* `useUserEvent` is false for route specs: renderRouter calls jest.useFakeTimers()
|
|
207
|
+
* internally, and userEvent's press timing under fake timers is not something
|
|
208
|
+
* this emitter can vouch for. fireEvent has no timers in it.
|
|
209
|
+
*/
|
|
210
|
+
export function emitNativeDispatch(
|
|
211
|
+
event: string,
|
|
212
|
+
target: NativeTarget,
|
|
213
|
+
el: string,
|
|
214
|
+
useUserEvent: boolean,
|
|
215
|
+
): string | undefined {
|
|
216
|
+
const press = (kind: "press" | "longPress"): string =>
|
|
217
|
+
useUserEvent && target.self ? `await user.${kind}(${el})` : `fireEvent(${el}, ${j(kind)})`;
|
|
218
|
+
switch (event) {
|
|
219
|
+
case "onPress":
|
|
220
|
+
return press("press");
|
|
221
|
+
case "onLongPress":
|
|
222
|
+
return press("longPress");
|
|
223
|
+
case "onPressIn":
|
|
224
|
+
return `fireEvent(${el}, "pressIn")`;
|
|
225
|
+
case "onPressOut":
|
|
226
|
+
return `fireEvent(${el}, "pressOut")`;
|
|
227
|
+
case "onChangeText":
|
|
228
|
+
return `fireEvent.changeText(${el}, SAMPLE_TEXT)`;
|
|
229
|
+
case "onChange":
|
|
230
|
+
// RN's TextInput onChange payload is a DOCUMENTED shape, so this is a
|
|
231
|
+
// specification rather than an invented event object.
|
|
232
|
+
return `fireEvent(${el}, "change", { nativeEvent: { text: SAMPLE_TEXT } })`;
|
|
233
|
+
case "onSubmitEditing":
|
|
234
|
+
return `fireEvent(${el}, "submitEditing")`;
|
|
235
|
+
case "onEndEditing":
|
|
236
|
+
return `fireEvent(${el}, "endEditing")`;
|
|
237
|
+
case "onValueChange":
|
|
238
|
+
return `fireEvent(${el}, "valueChange", true)`;
|
|
239
|
+
case "onFocus":
|
|
240
|
+
return `fireEvent(${el}, "focus")`;
|
|
241
|
+
case "onBlur":
|
|
242
|
+
return `fireEvent(${el}, "blur")`;
|
|
243
|
+
default:
|
|
244
|
+
// onRefresh and onScroll land here: neither is reachable by any rung of the
|
|
245
|
+
// ladder above, so there is no element to drive them on.
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** Elements that render no text, re-exported so the emitter can explain a skip. */
|
|
251
|
+
export { TEXTLESS_ELEMENTS };
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The async form of a locator: `getAllBy*` -> `findAllBy*`.
|
|
255
|
+
*
|
|
256
|
+
* A screen that loads in a focus effect renders its empty state first and its
|
|
257
|
+
* rows only after the awaited fetch resolves. Querying synchronously at mount
|
|
258
|
+
* then fails with "Unable to find an element with text: …" — which reads as a
|
|
259
|
+
* missing element and is really a missing await. Three of the dogfood app's
|
|
260
|
+
* screens failed exactly this way.
|
|
261
|
+
*
|
|
262
|
+
* Applied only where the component actually awaits something, so a screen with
|
|
263
|
+
* no data loading keeps the synchronous query and its faster failure.
|
|
264
|
+
*/
|
|
265
|
+
export function awaitNativeSelector(locator: string): string {
|
|
266
|
+
// The parentheses are the whole point. `await screen.findAllByText(x)[0]`
|
|
267
|
+
// indexes the PROMISE, not the resolved array, so el is undefined and every
|
|
268
|
+
// assertion after it fails — 30 tests, from a rewrite that looked right.
|
|
269
|
+
const async = locator.replace(/screen\.get(All)?By/, (_m, all) => `screen.find${all ?? ""}By`);
|
|
270
|
+
const suffix = /\)(\[\d+\])$/.exec(async)?.[1];
|
|
271
|
+
if (suffix !== undefined) return `(await ${async.slice(0, -suffix.length)})${suffix}`;
|
|
272
|
+
return `await ${async}`;
|
|
273
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { VERSION } from "../version.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Provenance stamps for colocated specs — the files written beside the user's
|
|
8
|
+
* sources, where hand-hardening is the whole point. The first line records a
|
|
9
|
+
* hash of everything BELOW it, so `--force` can tell "still exactly what we
|
|
10
|
+
* generated" (safe to rewrite) from "someone worked on this" (never clobber).
|
|
11
|
+
*
|
|
12
|
+
* The hash excludes the stamp line for the obvious fixed-point reason, and is
|
|
13
|
+
* recomputed after the host project's formatter runs (restampGenerated below):
|
|
14
|
+
* a stamp of the raw render would mismatch the formatted bytes on disk, and
|
|
15
|
+
* every untouched spec would read as hand-edited on the next --force.
|
|
16
|
+
*
|
|
17
|
+
* Deleting the stamp line demotes a file to "pre-stamp": kept by --force and
|
|
18
|
+
* reported separately. That is deliberate — the escape hatch for both states
|
|
19
|
+
* is the same: delete the file to regenerate it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** First line only; the terminator tolerates files converted to CRLF. */
|
|
23
|
+
export const STAMP_RE = /^\/\/ @flawless-generated sha256:([0-9a-f]{16}) flawless-react@(\S+)\r?\n/;
|
|
24
|
+
|
|
25
|
+
/** First 16 sha256 hex chars — collision-proof enough for "did a human touch this". */
|
|
26
|
+
export function hashBody(body: string): string {
|
|
27
|
+
return createHash("sha256").update(body).digest("hex").slice(0, 16);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function stamp(body: string, version: string = VERSION): string {
|
|
31
|
+
return `// @flawless-generated sha256:${hashBody(body)} flawless-react@${version}\n${body}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SplitStamp {
|
|
35
|
+
hash?: string;
|
|
36
|
+
version?: string;
|
|
37
|
+
/** Everything below the stamp line; the whole text when there is none. */
|
|
38
|
+
body: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function splitStamp(text: string): SplitStamp {
|
|
42
|
+
const m = text.match(STAMP_RE);
|
|
43
|
+
if (!m) return { body: text };
|
|
44
|
+
return { hash: m[1], version: m[2], body: text.slice(m[0].length) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function classifyStamped(text: string): "untouched" | "edited" | "stampless" {
|
|
48
|
+
const { hash, body } = splitStamp(text);
|
|
49
|
+
if (hash === undefined) return "stampless";
|
|
50
|
+
return hashBody(body) === hash ? "untouched" : "edited";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Re-align stamps with what is actually on disk, after a formatter may have
|
|
55
|
+
* rewritten the freshly written files. Only files whose FIRST line is a stamp
|
|
56
|
+
* are touched, so callers pass the whole written set unfiltered; the stamp's
|
|
57
|
+
* own version field is preserved because the restamp is bookkeeping, not a
|
|
58
|
+
* regeneration. Returns how many files were rewritten.
|
|
59
|
+
*/
|
|
60
|
+
export async function restampGenerated(paths: string[]): Promise<number> {
|
|
61
|
+
let restamped = 0;
|
|
62
|
+
for (const p of paths) {
|
|
63
|
+
if (!existsSync(p)) continue;
|
|
64
|
+
const { hash, version, body } = splitStamp(await readFile(p, "utf8"));
|
|
65
|
+
if (hash === undefined || version === undefined) continue;
|
|
66
|
+
const fresh = hashBody(body);
|
|
67
|
+
if (fresh === hash) continue;
|
|
68
|
+
// The stamp line follows the body's own line-ending convention: a host
|
|
69
|
+
// formatter set to CRLF converted the whole file, and writing "\n" here
|
|
70
|
+
// would hand that formatter a mixed-EOL file to churn straight back.
|
|
71
|
+
const eol = body.includes("\r\n") ? "\r\n" : "\n";
|
|
72
|
+
await writeFile(
|
|
73
|
+
p,
|
|
74
|
+
`// @flawless-generated sha256:${fresh} flawless-react@${version}${eol}${body}`,
|
|
75
|
+
);
|
|
76
|
+
restamped++;
|
|
77
|
+
}
|
|
78
|
+
return restamped;
|
|
79
|
+
}
|