@estiva-app/ui 0.26.0 → 0.27.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/dist/gates/app-checks.d.ts.map +1 -1
- package/dist/gates/{chunk-GTQZEHPC.js → chunk-I7CJLUPM.js} +13 -1
- package/dist/gates/chunk-I7CJLUPM.js.map +7 -0
- package/dist/gates/cli.js +1 -1
- package/dist/gates/index.js +7 -3
- package/dist/gates/index.js.map +2 -2
- package/dist/gates/status.d.ts +2 -0
- package/dist/gates/status.d.ts.map +1 -1
- package/dist/registry/{app-DQI6WLHR.js → app-FXN4EI34.js} +2 -2
- package/dist/registry/app.d.ts.map +1 -1
- package/dist/registry/{chunk-W2B2G7OE.js → chunk-6OH3TWGF.js} +59 -1
- package/dist/registry/chunk-6OH3TWGF.js.map +7 -0
- package/dist/registry/{chunk-WMFF3MPP.js → chunk-XCBN2EKW.js} +3 -2
- package/dist/registry/{chunk-WMFF3MPP.js.map → chunk-XCBN2EKW.js.map} +2 -2
- package/dist/registry/cli.js +69 -8
- package/dist/registry/cli.js.map +3 -3
- package/dist/registry/contract.d.ts +29 -0
- package/dist/registry/contract.d.ts.map +1 -0
- package/dist/registry/gated-5CPIUQAW.js +230 -0
- package/dist/registry/gated-5CPIUQAW.js.map +7 -0
- package/dist/registry/gated.d.ts +31 -0
- package/dist/registry/gated.d.ts.map +1 -0
- package/dist/registry/index.d.ts +1 -0
- package/dist/registry/index.d.ts.map +1 -1
- package/dist/registry/index.js +15 -3
- package/package.json +1 -1
- package/registry.json +1 -1
- package/src/CollapsibleSection.stories.tsx +6 -0
- package/src/ListColumn.stories.tsx +6 -0
- package/src/MenuItem.stories.tsx +151 -135
- package/src/Person.stories.tsx +7 -0
- package/src/PersonTrigger.stories.tsx +7 -0
- package/src/SectionHeader.stories.tsx +5 -0
- package/src/gates/app-checks.ts +6 -2
- package/src/gates/status.ts +15 -0
- package/src/registry/app.test.ts +14 -1
- package/src/registry/app.ts +5 -1
- package/src/registry/cli.ts +79 -7
- package/src/registry/contract.test.ts +158 -0
- package/src/registry/contract.ts +108 -0
- package/src/registry/gated.test.ts +98 -0
- package/src/registry/gated.ts +308 -0
- package/src/registry/index.ts +1 -0
- package/dist/gates/chunk-GTQZEHPC.js.map +0 -7
- package/dist/registry/chunk-W2B2G7OE.js.map +0 -7
- /package/dist/registry/{app-DQI6WLHR.js.map → app-FXN4EI34.js.map} +0 -0
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* UIG-19: the usage-page contract every app's `gate` job runs, and the story
|
|
4
|
+
* links. Each case is one the three repos actually have, or one that got past
|
|
5
|
+
* the copies this replaces:
|
|
6
|
+
*
|
|
7
|
+
* - a page that lost its `When not` (the ticket's own example);
|
|
8
|
+
* - Peek's pages, which never checked the order or the code under `How`;
|
|
9
|
+
* - a reusable part drawn only inside another story, whose page says so on a
|
|
10
|
+
* **Seen in** line (Katerina, 21 September);
|
|
11
|
+
* - a story file named after a part whose stories draw its `…View` half, in the
|
|
12
|
+
* same file — FilesPanel and TopicDetailsDialog in Peek, which the catalogue
|
|
13
|
+
* used to call drawn nowhere;
|
|
14
|
+
* - a Windows checkout, read with CRLF.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { tmpdir } from 'node:os'
|
|
18
|
+
import { dirname, join } from 'node:path'
|
|
19
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
20
|
+
import { buildAppRegistry } from './app'
|
|
21
|
+
import { contractProblems, hasSeenIn, linkProblems, pageProblem } from './contract'
|
|
22
|
+
|
|
23
|
+
const made: string[] = []
|
|
24
|
+
afterAll(() => {
|
|
25
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true })
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
function app(files: Record<string, string>): string {
|
|
29
|
+
const dir = mkdtempSync(join(tmpdir(), 'uig19-'))
|
|
30
|
+
made.push(dir)
|
|
31
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '1.0.0' }))
|
|
32
|
+
for (const [path, text] of Object.entries(files)) {
|
|
33
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true })
|
|
34
|
+
writeFileSync(join(dir, path), text)
|
|
35
|
+
}
|
|
36
|
+
return dir
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const PAGE = [
|
|
40
|
+
"import { Meta } from '@storybook/addon-docs/blocks'",
|
|
41
|
+
'',
|
|
42
|
+
"<Meta title='Parts/Badge' />",
|
|
43
|
+
'',
|
|
44
|
+
'# Badge',
|
|
45
|
+
'',
|
|
46
|
+
'A small count beside a name.',
|
|
47
|
+
'',
|
|
48
|
+
'## When',
|
|
49
|
+
'',
|
|
50
|
+
'A count.',
|
|
51
|
+
'',
|
|
52
|
+
'## When not',
|
|
53
|
+
'',
|
|
54
|
+
'A word.',
|
|
55
|
+
'',
|
|
56
|
+
'## How',
|
|
57
|
+
'',
|
|
58
|
+
'```tsx',
|
|
59
|
+
'<Badge count={3} />',
|
|
60
|
+
'```',
|
|
61
|
+
'',
|
|
62
|
+
'## What it owns',
|
|
63
|
+
'',
|
|
64
|
+
'Nothing. It only draws.',
|
|
65
|
+
'',
|
|
66
|
+
].join('\n')
|
|
67
|
+
|
|
68
|
+
const part = (name: string, cls = 'reusable') =>
|
|
69
|
+
`/**\n * ${name}, for the fixture.\n * @registry ${cls}: the fixture says so\n */\nexport function ${name}() {\n return <span>${name}</span>\n}\n`
|
|
70
|
+
|
|
71
|
+
describe('a usage page', () => {
|
|
72
|
+
it('keeps the contract with an opening line and the four sections in order', () => {
|
|
73
|
+
expect(pageProblem(PAGE)).toBeNull()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('fails without "When not"', () => {
|
|
77
|
+
expect(pageProblem(PAGE.replace('## When not', '## Not when'))).toBe('no "When not" section')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('fails with its sections out of order', () => {
|
|
81
|
+
const swapped = PAGE.replace('## When not\n\nA word.\n', '').replace('## What it owns', '## When not\n\nA word.\n\n## What it owns')
|
|
82
|
+
expect(pageProblem(swapped)).toMatch(/^sections out of order/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('fails with no opening line', () => {
|
|
86
|
+
expect(pageProblem(PAGE.replace('A small count beside a name.\n', ''))).toMatch(/^no opening line/)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('fails with no code under How', () => {
|
|
90
|
+
expect(pageProblem(PAGE.replace('```tsx\n<Badge count={3} />\n```', 'Pass a count.'))).toBe('no code under "How"')
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('reads a Windows checkout', () => {
|
|
94
|
+
expect(pageProblem(PAGE.replace(/\n/g, '\r\n'))).toBeNull()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('knows a Seen in line, and only at the start of a line', () => {
|
|
98
|
+
expect(hasSeenIn('**Seen in** — *Messages/ConversationCard*.')).toBe(true)
|
|
99
|
+
expect(hasSeenIn('It is **Seen in** two places.')).toBe(false)
|
|
100
|
+
})
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
describe('an app against the contract', () => {
|
|
104
|
+
it('passes a reusable part with a page and a story, and asks nothing of a one-off', () => {
|
|
105
|
+
const dir = app({
|
|
106
|
+
'src/Badge.tsx': part('Badge'),
|
|
107
|
+
'src/Badge.mdx': PAGE,
|
|
108
|
+
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
|
|
109
|
+
'src/Once.tsx': part('Once', 'one-off'),
|
|
110
|
+
})
|
|
111
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('names a reusable part with no page, and the file to write', () => {
|
|
115
|
+
const dir = app({ 'src/Badge.tsx': part('Badge') })
|
|
116
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
|
|
117
|
+
'Badge (reusable, src/Badge.tsx) has no usage page: write src/Badge.mdx, with an opening line, then When, When not, How, What it owns',
|
|
118
|
+
])
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('names a page that lost a section', () => {
|
|
122
|
+
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE.replace('## When not', '## Not when') })
|
|
123
|
+
const problems = contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)
|
|
124
|
+
expect(problems).toContain('Badge (reusable, src/Badge.tsx): src/Badge.mdx has no "When not" section')
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('names a reusable part drawn nowhere, and passes it once its page says where it is seen', () => {
|
|
128
|
+
const dir = app({ 'src/Badge.tsx': part('Badge'), 'src/Badge.mdx': PAGE })
|
|
129
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([
|
|
130
|
+
'Badge (reusable, src/Badge.tsx) is drawn nowhere: give it a story, or write a "**Seen in**" line on src/Badge.mdx naming the stories that draw it, or why none can',
|
|
131
|
+
])
|
|
132
|
+
writeFileSync(join(dir, 'src/Badge.mdx'), PAGE.replace('A small count beside a name.\n', 'A small count beside a name.\n\n**Seen in** — *Parts/Card*, beside the title.\n'))
|
|
133
|
+
expect(contractProblems(buildAppRegistry({ root: dir, repo: 'fixture' }), dir)).toEqual([])
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it("counts a story file named after the part that draws its view, from the part's own file", () => {
|
|
137
|
+
const dir = app({
|
|
138
|
+
'src/Panel.tsx': `${part('Panel')}\n/** The half that only draws. */\nexport function PanelView() {\n return <div>view</div>\n}\n`,
|
|
139
|
+
'src/Panel.stories.tsx': "import { PanelView } from './Panel'\nconst meta = { title: 'Parts/Panel', component: PanelView }\nexport default meta\nexport const Open = {}\n",
|
|
140
|
+
})
|
|
141
|
+
const entry = buildAppRegistry({ root: dir, repo: 'fixture' }).entries.find((e) => e.name === 'Panel')
|
|
142
|
+
expect(entry?.storyId).toBe('parts-panel--open')
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
describe('story links', () => {
|
|
147
|
+
it('names a link its Storybook does not have, and passes the ones it has', () => {
|
|
148
|
+
const dir = app({
|
|
149
|
+
'src/Badge.tsx': part('Badge'),
|
|
150
|
+
'src/Badge.stories.tsx': "import { Badge } from './Badge'\nconst meta = { title: 'Parts/Badge', component: Badge }\nexport default meta\nexport const Default = {}\n",
|
|
151
|
+
})
|
|
152
|
+
const registry = buildAppRegistry({ root: dir, repo: 'fixture' })
|
|
153
|
+
expect(linkProblems(registry, new Set(['parts-badge--default', 'parts-badge--docs']))).toEqual([])
|
|
154
|
+
expect(linkProblems(registry, new Set(['parts-badge--docs']))).toEqual([
|
|
155
|
+
'Badge (src/Badge.tsx) links to a story its Storybook does not have: parts-badge--default',
|
|
156
|
+
])
|
|
157
|
+
})
|
|
158
|
+
})
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The usage-page contract, checked against an app's catalogue (UIG-19).
|
|
3
|
+
*
|
|
4
|
+
* Every part an app classifies **reusable** (or **promote-candidate**) has a
|
|
5
|
+
* page beside it — `<PartName>.mdx` in the component's own folder — that opens
|
|
6
|
+
* with a line saying what the part is, then `When`, `When not`, `How` (with
|
|
7
|
+
* code) and `What it owns`, in that order. And it is drawn somewhere: a story
|
|
8
|
+
* of its own, or a **Seen in** line on its page naming the stories that draw
|
|
9
|
+
* it, or saying why none can (Katerina, 21 and 23 September 2026). A one-off
|
|
10
|
+
* needs only its class and a purpose line, which the catalogue already refuses
|
|
11
|
+
* to build without.
|
|
12
|
+
*
|
|
13
|
+
* This is the one copy. Peek and Ship each used to carry their own, pasted into
|
|
14
|
+
* their `scripts/gates-checks.mjs` and run only by `gates:status`, so a page
|
|
15
|
+
* could lose its `When not` and still merge. `estiva-ui check` runs this in
|
|
16
|
+
* every app's `gate` job (docs/GATES.md §23: one copy, in the package).
|
|
17
|
+
*
|
|
18
|
+
* The package's own pages keep the fuller contract in `src/pages.test.ts`,
|
|
19
|
+
* which also reads `What it owns` back against the lint rule.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
22
|
+
import { join } from 'node:path'
|
|
23
|
+
import type { Registry } from './schema'
|
|
24
|
+
|
|
25
|
+
/** The four headings, in the order a page carries them. */
|
|
26
|
+
export const PAGE_SECTIONS = ['When', 'When not', 'How', 'What it owns'] as const
|
|
27
|
+
|
|
28
|
+
/** The classes that owe a page and a picture. */
|
|
29
|
+
export const CONTRACT_KINDS = ['reusable', 'promote-candidate'] as const
|
|
30
|
+
|
|
31
|
+
/** The line that stands in for a story of the part's own. */
|
|
32
|
+
const SEEN_IN = /^\*\*Seen in\*\*/m
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Why a page breaks the contract, or null when it keeps it.
|
|
36
|
+
*
|
|
37
|
+
* Read on normalised text: the apps are checked out with CRLF on Windows.
|
|
38
|
+
*/
|
|
39
|
+
export function pageProblem(source: string): string | null {
|
|
40
|
+
const text = source.replace(/\r\n/g, '\n')
|
|
41
|
+
const heads = [...text.matchAll(/^##+\s+(.+?)\s*$/gm)].map((m) => m[1])
|
|
42
|
+
const at = PAGE_SECTIONS.map((s) => heads.indexOf(s))
|
|
43
|
+
const missing = PAGE_SECTIONS.filter((_, i) => at[i] < 0)
|
|
44
|
+
if (missing.length) return `no ${missing.map((s) => `"${s}"`).join(', ')} section`
|
|
45
|
+
if (at.some((i, n) => n > 0 && i < at[n - 1])) return `sections out of order: they go ${PAGE_SECTIONS.join(', ')}`
|
|
46
|
+
// The opening line: the first paragraph under the title that is not markup.
|
|
47
|
+
const opening = text
|
|
48
|
+
.split(/^# .*$/m)[1]
|
|
49
|
+
?.split(/^##\s/m)[0]
|
|
50
|
+
?.split(/\n\s*\n/)
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.find((s) => s && !s.startsWith('<') && !s.startsWith('import '))
|
|
53
|
+
if (!opening) return 'no opening line under the title saying what the part is'
|
|
54
|
+
if (!/^##\s+How\s*\n[\s\S]*?```tsx?\n/m.test(text)) return 'no code under "How"'
|
|
55
|
+
return null
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Whether a page records where the part is drawn, in place of a story of its own. */
|
|
59
|
+
export function hasSeenIn(source: string): boolean {
|
|
60
|
+
return SEEN_IN.test(source.replace(/\r\n/g, '\n'))
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Every catalogue link that names a story or a docs page its Storybook does not
|
|
65
|
+
* have (UIG-19: a dead link is a build failure, not a dead end found six weeks
|
|
66
|
+
* later). `ids` is every entry id in the Storybook's own index — what
|
|
67
|
+
* `storybook index` writes — so this is Storybook's answer, not a guess at how
|
|
68
|
+
* it builds an id.
|
|
69
|
+
*/
|
|
70
|
+
export function linkProblems(registry: Registry, ids: ReadonlySet<string>): string[] {
|
|
71
|
+
const problems: string[] = []
|
|
72
|
+
for (const entry of registry.entries) {
|
|
73
|
+
for (const key of ['storyId', 'docsId'] as const) {
|
|
74
|
+
const id = entry[key]
|
|
75
|
+
if (id && !ids.has(id)) problems.push(`${entry.name} (${entry.sourceFile}) links to ${key === 'storyId' ? 'a story' : 'a docs page'} its Storybook does not have: ${id}`)
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return problems
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Every way an app's catalogue breaks the contract, one line each, naming the
|
|
83
|
+
* part, its file and what to do. Empty when it keeps it.
|
|
84
|
+
*
|
|
85
|
+
* `root` is the app's own folder: the one its catalogue's paths are relative to.
|
|
86
|
+
*/
|
|
87
|
+
export function contractProblems(registry: Registry, root: string): string[] {
|
|
88
|
+
const problems: string[] = []
|
|
89
|
+
for (const entry of registry.entries) {
|
|
90
|
+
const cls = entry.app?.class
|
|
91
|
+
if (!cls || !(CONTRACT_KINDS as readonly string[]).includes(cls)) continue
|
|
92
|
+
// Where the page must be: beside the component, named after the part —
|
|
93
|
+
// the same place the catalogue looks (src/registry/app.ts).
|
|
94
|
+
const page = join(entry.sourceFile.replace(/[^/\\]+$/, ''), `${entry.name}.mdx`).replace(/\\/g, '/')
|
|
95
|
+
const where = `${entry.name} (${cls}, ${entry.sourceFile})`
|
|
96
|
+
if (!existsSync(join(root, page))) {
|
|
97
|
+
problems.push(`${where} has no usage page: write ${page}, with an opening line, then ${PAGE_SECTIONS.join(', ')}`)
|
|
98
|
+
continue
|
|
99
|
+
}
|
|
100
|
+
const text = readFileSync(join(root, page), 'utf8')
|
|
101
|
+
const problem = pageProblem(text)
|
|
102
|
+
if (problem) problems.push(`${where}: ${page} has ${problem}`)
|
|
103
|
+
if (entry.storyId === null && !hasSeenIn(text)) {
|
|
104
|
+
problems.push(`${where} is drawn nowhere: give it a story, or write a "**Seen in**" line on ${page} naming the stories that draw it, or why none can`)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return problems
|
|
108
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/// <reference types="node" />
|
|
2
|
+
/**
|
|
3
|
+
* UIG-19: what a part draws only behind a prop, and no story passes. Each case
|
|
4
|
+
* is one the scan met in Peek or the package:
|
|
5
|
+
*
|
|
6
|
+
* - a gate on the part itself (SectionHeader's chevron);
|
|
7
|
+
* - a prop handed to a smaller part that gates on it (ConversationCard's
|
|
8
|
+
* `onReply`, which the strip draws — strict, by Katerina's ruling);
|
|
9
|
+
* - a prop handed to a dialog, shut in every story (skipped, her ruling);
|
|
10
|
+
* - a story that spreads another story's args (`...Topic.args`);
|
|
11
|
+
* - a spread it cannot open (a helper's `{...props}`), which keeps it quiet.
|
|
12
|
+
*/
|
|
13
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
|
14
|
+
import { tmpdir } from 'node:os'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { afterAll, describe, expect, it } from 'vitest'
|
|
17
|
+
import { buildAppRegistry } from './app'
|
|
18
|
+
import { gatedFindings } from './gated'
|
|
19
|
+
|
|
20
|
+
const made: string[] = []
|
|
21
|
+
afterAll(() => {
|
|
22
|
+
for (const dir of made) rmSync(dir, { recursive: true, force: true })
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
function app(files: Record<string, string>): string {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), 'uig19-gated-'))
|
|
27
|
+
made.push(dir)
|
|
28
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'fixture-app', version: '1.0.0' }))
|
|
29
|
+
for (const [path, text] of Object.entries(files)) {
|
|
30
|
+
mkdirSync(dirname(join(dir, path)), { recursive: true })
|
|
31
|
+
writeFileSync(join(dir, path), text)
|
|
32
|
+
}
|
|
33
|
+
return dir
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const find = (dir: string) => gatedFindings(buildAppRegistry({ root: dir, repo: 'fixture' }), dir).map((f) => `${f.part}.${f.prop}`)
|
|
37
|
+
|
|
38
|
+
const card = `/**
|
|
39
|
+
* A card.
|
|
40
|
+
* @registry reusable: the fixture says so
|
|
41
|
+
*/
|
|
42
|
+
export function Card({ title, onReply, onShare }: { title: string; onReply?: () => void; onShare?: () => void }) {
|
|
43
|
+
return (
|
|
44
|
+
<div>
|
|
45
|
+
{title}
|
|
46
|
+
{onReply && <button type="button">Reply</button>}
|
|
47
|
+
<Strip onShare={onShare} />
|
|
48
|
+
<ShareDialog onShare={onShare} />
|
|
49
|
+
</div>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The strip under a card. */
|
|
54
|
+
export function Strip({ onShare }: { onShare?: () => void }) {
|
|
55
|
+
return <div>{onShare ? <button type="button">Share</button> : null}</div>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** A dialog, shut until opened. */
|
|
59
|
+
export function ShareDialog({ onShare }: { onShare?: () => void }) {
|
|
60
|
+
return <div>{onShare && <button type="button">Share now</button>}</div>
|
|
61
|
+
}
|
|
62
|
+
`
|
|
63
|
+
|
|
64
|
+
const story = (body: string) => `import { Card } from './Card'\nconst meta = { title: 'Parts/Card', component: Card, args: { title: 'Hello' } }\nexport default meta\n${body}\n`
|
|
65
|
+
|
|
66
|
+
describe('what a part draws behind a prop', () => {
|
|
67
|
+
it('names a gate on the part, and one it hands to a smaller part, when no story passes either', () => {
|
|
68
|
+
const dir = app({ 'src/Card.tsx': card, 'src/Card.stories.tsx': story('export const Default = {}') })
|
|
69
|
+
expect(find(dir).sort()).toEqual(['Card.onReply', 'Card.onShare'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('is satisfied by a story that passes them, in args or through another story’s args', () => {
|
|
73
|
+
const dir = app({
|
|
74
|
+
'src/Card.tsx': card,
|
|
75
|
+
'src/Card.stories.tsx': story("export const Default = { args: { onReply: () => {} } }\nexport const Shared = { args: { ...Default.args, onShare: () => {} } }"),
|
|
76
|
+
})
|
|
77
|
+
expect(find(dir)).toEqual([])
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('skips a prop that reaches only a dialog', () => {
|
|
81
|
+
const only = card.replace(' <Strip onShare={onShare} />\n', '')
|
|
82
|
+
const dir = app({ 'src/Card.tsx': only, 'src/Card.stories.tsx': story("export const Default = { args: { onReply: () => {} } }") })
|
|
83
|
+
expect(find(dir)).toEqual([])
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('stays quiet when a story passes props it cannot read', () => {
|
|
87
|
+
const dir = app({
|
|
88
|
+
'src/Card.tsx': card,
|
|
89
|
+
'src/Card.stories.tsx': `import { Card } from './Card'\nimport { props } from './fixtures'\nconst meta = { title: 'Parts/Card', component: Card }\nexport default meta\nexport const Default = { render: () => <Card title="x" {...props} /> }\n`,
|
|
90
|
+
})
|
|
91
|
+
expect(find(dir)).toEqual([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('asks nothing of a part no story draws: the contract says so instead', () => {
|
|
95
|
+
const dir = app({ 'src/Card.tsx': card })
|
|
96
|
+
expect(find(dir)).toEqual([])
|
|
97
|
+
})
|
|
98
|
+
})
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a part draws only when a prop is passed, and no story passes it (UIG-19,
|
|
3
|
+
* Katerina's ruling 5).
|
|
4
|
+
*
|
|
5
|
+
* `{onReply && <IconButton … />}` works in the app, because the app passes
|
|
6
|
+
* `onReply`. In Storybook nothing does, so the button is simply not there. It
|
|
7
|
+
* reads as a broken behaviour rather than a missing fixture, and it hides the
|
|
8
|
+
* very state a reviewer came to see. UIG-17 found five in Peek by hand: Reply on
|
|
9
|
+
* ConversationCard, Edit and Delete on ReplyCard, Rename and Copy link on
|
|
10
|
+
* TopicMoreMenu, Open original on ThreadPanel.
|
|
11
|
+
*
|
|
12
|
+
* Read with TypeScript, part by part. The gate is looked for only inside the
|
|
13
|
+
* part's own function (and its `…View` half, when the stories draw that),
|
|
14
|
+
* because a file is not a component: UIG-17's first scan blamed ReplyCard for
|
|
15
|
+
* two props that belong to a menu declared beside it. A story passes a prop by
|
|
16
|
+
* naming it in an `args` object or as an attribute on the part's tag, anywhere
|
|
17
|
+
* in a story file that imports the part's file.
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
20
|
+
import { basename, dirname, join, relative } from 'node:path'
|
|
21
|
+
import ts from 'typescript'
|
|
22
|
+
import type { Registry } from './schema'
|
|
23
|
+
|
|
24
|
+
export interface GatedFinding {
|
|
25
|
+
part: string
|
|
26
|
+
file: string
|
|
27
|
+
prop: string
|
|
28
|
+
/** The part or view whose body holds the gate. */
|
|
29
|
+
in: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const parse = (file: string, text: string) => ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX)
|
|
33
|
+
|
|
34
|
+
/** The function a name is declared as: `function X`, `const X = (…) =>`, `memo(…)`, `forwardRef(…)`. */
|
|
35
|
+
function declared(sf: ts.SourceFile, name: string): ts.FunctionLikeDeclaration | null {
|
|
36
|
+
let found: ts.FunctionLikeDeclaration | null = null
|
|
37
|
+
const unwrap = (e: ts.Expression | undefined): ts.FunctionLikeDeclaration | null => {
|
|
38
|
+
if (!e) return null
|
|
39
|
+
if (ts.isArrowFunction(e) || ts.isFunctionExpression(e)) return e
|
|
40
|
+
if (ts.isCallExpression(e)) return unwrap(e.arguments[0])
|
|
41
|
+
if (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) return unwrap(e.expression)
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
for (const s of sf.statements) {
|
|
45
|
+
if (ts.isFunctionDeclaration(s) && s.name?.text === name) found = s
|
|
46
|
+
if (ts.isVariableStatement(s)) for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.name.text === name) found = unwrap(d.initializer)
|
|
47
|
+
}
|
|
48
|
+
return found
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The props a function takes, by the name its body uses: `{ onReply, owner: who }` → onReply→onReply, who→owner. */
|
|
52
|
+
function propsOf(fn: ts.FunctionLikeDeclaration): { locals: Map<string, string>; object: string | null } {
|
|
53
|
+
const locals = new Map<string, string>()
|
|
54
|
+
const first = fn.parameters[0]
|
|
55
|
+
if (!first) return { locals, object: null }
|
|
56
|
+
if (ts.isObjectBindingPattern(first.name)) {
|
|
57
|
+
for (const el of first.name.elements) {
|
|
58
|
+
if (el.dotDotDotToken || !ts.isIdentifier(el.name)) continue
|
|
59
|
+
const prop = el.propertyName && ts.isIdentifier(el.propertyName) ? el.propertyName.text : el.name.text
|
|
60
|
+
locals.set(el.name.text, prop)
|
|
61
|
+
}
|
|
62
|
+
return { locals, object: null }
|
|
63
|
+
}
|
|
64
|
+
return { locals, object: ts.isIdentifier(first.name) ? first.name.text : null }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const drawsJsx = (node: ts.Node): boolean => {
|
|
68
|
+
let yes = false
|
|
69
|
+
const walk = (n: ts.Node) => {
|
|
70
|
+
if (yes) return
|
|
71
|
+
if (ts.isJsxElement(n) || ts.isJsxSelfClosingElement(n) || ts.isJsxFragment(n)) yes = true
|
|
72
|
+
else ts.forEachChild(n, walk)
|
|
73
|
+
}
|
|
74
|
+
walk(node)
|
|
75
|
+
return yes
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Every prop that gates something drawn: `{p && <X/>}`, `{p ? <X/> : null}`, `{!!p && …}`, `{p != null && …}`. */
|
|
79
|
+
function gates(fn: ts.FunctionLikeDeclaration): Set<string> {
|
|
80
|
+
const { locals, object } = propsOf(fn)
|
|
81
|
+
const out = new Set<string>()
|
|
82
|
+
const propIn = (e: ts.Expression): string | null => {
|
|
83
|
+
while (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e)) e = e.expression
|
|
84
|
+
if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.ExclamationToken && ts.isPrefixUnaryExpression(e.operand)) return propIn(e.operand.operand)
|
|
85
|
+
if (ts.isBinaryExpression(e) && [ts.SyntaxKind.ExclamationEqualsToken, ts.SyntaxKind.ExclamationEqualsEqualsToken].includes(e.operatorToken.kind)) return propIn(e.left)
|
|
86
|
+
if (ts.isIdentifier(e)) return locals.get(e.text) ?? null
|
|
87
|
+
if (object && ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === object) return e.name.text
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
const walk = (n: ts.Node) => {
|
|
91
|
+
// A function declared inside is its own component or handler: not this part's markup.
|
|
92
|
+
if (n !== fn && ts.isFunctionDeclaration(n)) return
|
|
93
|
+
if (ts.isJsxExpression(n) && n.expression) {
|
|
94
|
+
const e = n.expression
|
|
95
|
+
if (ts.isBinaryExpression(e) && e.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && drawsJsx(e.right)) {
|
|
96
|
+
const p = propIn(e.left)
|
|
97
|
+
if (p) out.add(p)
|
|
98
|
+
}
|
|
99
|
+
if (ts.isConditionalExpression(e) && drawsJsx(e.whenTrue) && !drawsJsx(e.whenFalse)) {
|
|
100
|
+
const p = propIn(e.condition)
|
|
101
|
+
if (p) out.add(p)
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
ts.forEachChild(n, walk)
|
|
105
|
+
}
|
|
106
|
+
if (fn.body) walk(fn.body)
|
|
107
|
+
return out
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Stands for "every prop": a spread the reader cannot open might pass any of them. */
|
|
111
|
+
const ANY = '*'
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every prop name a story file passes: keys of any `args` object, and
|
|
115
|
+
* attributes on the named tags. A spread of an object declared in the same
|
|
116
|
+
* file is opened (`args: { replyCount: 3, ...replyRow }`); one it cannot open
|
|
117
|
+
* — an import, a call — counts as passing everything, so the check stays quiet
|
|
118
|
+
* rather than wrong.
|
|
119
|
+
*/
|
|
120
|
+
function passed(sf: ts.SourceFile, tags: string[]): Set<string> {
|
|
121
|
+
const out = new Set<string>()
|
|
122
|
+
const consts = new Map<string, ts.ObjectLiteralExpression>()
|
|
123
|
+
for (const s of sf.statements) {
|
|
124
|
+
if (!ts.isVariableStatement(s)) continue
|
|
125
|
+
for (const d of s.declarationList.declarations) if (ts.isIdentifier(d.name) && d.initializer && ts.isObjectLiteralExpression(d.initializer)) consts.set(d.name.text, d.initializer)
|
|
126
|
+
}
|
|
127
|
+
const keysOf = (o: ts.ObjectLiteralExpression, seen: Set<string>) => {
|
|
128
|
+
for (const p of o.properties) {
|
|
129
|
+
if (ts.isSpreadAssignment(p)) {
|
|
130
|
+
// `...replyRow`, or another story's args: `...Topic.args`.
|
|
131
|
+
const e = p.expression
|
|
132
|
+
const name = ts.isIdentifier(e) ? e.text : ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.name.text === 'args' ? `${e.expression.text}.args` : null
|
|
133
|
+
let target: ts.ObjectLiteralExpression | undefined
|
|
134
|
+
if (name && !seen.has(name)) {
|
|
135
|
+
if (!name.endsWith('.args')) target = consts.get(name)
|
|
136
|
+
else {
|
|
137
|
+
const story = consts.get(name.slice(0, -'.args'.length))
|
|
138
|
+
const args = story?.properties.find((q) => ts.isPropertyAssignment(q) && ts.isIdentifier(q.name) && q.name.text === 'args')
|
|
139
|
+
if (args && ts.isPropertyAssignment(args) && ts.isObjectLiteralExpression(args.initializer)) target = args.initializer
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (target) keysOf(target, new Set([...seen, name!]))
|
|
143
|
+
else out.add(ANY)
|
|
144
|
+
} else if (p.name && (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))) out.add(p.name.text)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const walk = (n: ts.Node) => {
|
|
148
|
+
if (ts.isPropertyAssignment(n) && ts.isIdentifier(n.name) && n.name.text === 'args') {
|
|
149
|
+
if (ts.isObjectLiteralExpression(n.initializer)) keysOf(n.initializer, new Set())
|
|
150
|
+
else out.add(ANY)
|
|
151
|
+
}
|
|
152
|
+
if ((ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) && tags.includes(n.tagName.getText(sf))) {
|
|
153
|
+
for (const a of n.attributes.properties) {
|
|
154
|
+
if (ts.isJsxAttribute(a)) out.add(a.name.getText(sf))
|
|
155
|
+
else if (ts.isJsxSpreadAttribute(a)) {
|
|
156
|
+
const target = ts.isIdentifier(a.expression) ? consts.get(a.expression.text) : undefined
|
|
157
|
+
if (target) keysOf(target, new Set())
|
|
158
|
+
// `{...args}` hands on what `args` holds, and those are read where they are written.
|
|
159
|
+
else if (!(ts.isIdentifier(a.expression) && a.expression.text === 'args')) out.add(ANY)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
ts.forEachChild(n, walk)
|
|
164
|
+
}
|
|
165
|
+
walk(sf)
|
|
166
|
+
return out
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function storyFiles(root: string): string[] {
|
|
170
|
+
const out: string[] = []
|
|
171
|
+
const walk = (dir: string) => {
|
|
172
|
+
for (const name of readdirSync(dir)) {
|
|
173
|
+
if (name === 'node_modules' || name.startsWith('.')) continue
|
|
174
|
+
const path = join(dir, name)
|
|
175
|
+
if (statSync(path).isDirectory()) walk(path)
|
|
176
|
+
else if (/\.stories\.tsx?$/.test(name)) out.push(path)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (existsSync(join(root, 'src'))) walk(join(root, 'src'))
|
|
180
|
+
return out
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The props a part hands straight on to another tag: `<QuickActions onReply={onReply} />` → QuickActions, onReply, onReply. */
|
|
184
|
+
function handsOn(fn: ts.FunctionLikeDeclaration, sf: ts.SourceFile): { tag: string; attr: string; prop: string }[] {
|
|
185
|
+
const { locals, object } = propsOf(fn)
|
|
186
|
+
const out: { tag: string; attr: string; prop: string }[] = []
|
|
187
|
+
const walk = (n: ts.Node) => {
|
|
188
|
+
if (n !== fn && ts.isFunctionDeclaration(n)) return
|
|
189
|
+
if (ts.isJsxSelfClosingElement(n) || ts.isJsxOpeningElement(n)) {
|
|
190
|
+
const tag = n.tagName.getText(sf)
|
|
191
|
+
if (/^[A-Z]/.test(tag)) {
|
|
192
|
+
for (const a of n.attributes.properties) {
|
|
193
|
+
if (!ts.isJsxAttribute(a) || !a.initializer || !ts.isJsxExpression(a.initializer) || !a.initializer.expression) continue
|
|
194
|
+
const e = a.initializer.expression
|
|
195
|
+
const prop = ts.isIdentifier(e) ? locals.get(e.text) : object && ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.expression) && e.expression.text === object ? e.name.text : undefined
|
|
196
|
+
if (prop) out.push({ tag, attr: a.name.getText(sf), prop })
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
ts.forEachChild(n, walk)
|
|
201
|
+
}
|
|
202
|
+
if (fn.body) walk(fn.body)
|
|
203
|
+
return out
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Where a module specifier points, without its extension: `./X`, or Vite's `@/` for `src/`. */
|
|
207
|
+
function target(from: string, spec: string, root: string): string | null {
|
|
208
|
+
if (spec.startsWith('.')) return join(dirname(from), spec).replace(/\.[jt]sx?$/, '')
|
|
209
|
+
if (spec.startsWith('@/')) return join(root, 'src', spec.slice(2)).replace(/\.[jt]sx?$/, '')
|
|
210
|
+
return null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Every prop that gates something a part draws, where some story draws the
|
|
215
|
+
* part and none of those stories passes the prop.
|
|
216
|
+
*
|
|
217
|
+
* "Draws the part" means a story file imports the part's own file — its own
|
|
218
|
+
* stories, or an umbrella sheet such as Peek's Overlays/Menus, which is how
|
|
219
|
+
* TopicMoreMenu lost Rename and Copy link. A gate a part only hands on counts
|
|
220
|
+
* as its own: ConversationCard hands `onReply` to the strip that draws Reply,
|
|
221
|
+
* so a ConversationCard story that never passes it shows no Reply. Followed
|
|
222
|
+
* three parts deep at most.
|
|
223
|
+
*/
|
|
224
|
+
export interface GatedOptions {
|
|
225
|
+
/** The classes checked. The contract's by default. */
|
|
226
|
+
kinds?: readonly string[]
|
|
227
|
+
/**
|
|
228
|
+
* `own`: a part's own stories show everything it can draw — ConversationCard's
|
|
229
|
+
* stories must show Reply. `anywhere`: a handed-on gate counts as shown when
|
|
230
|
+
* the part it is handed to has a story that shows it. Katerina rules which.
|
|
231
|
+
*/
|
|
232
|
+
pictured?: 'own' | 'anywhere'
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
export function gatedFindings(registry: Registry, root: string, { kinds = ['reusable', 'promote-candidate'], pictured = 'own' }: GatedOptions = {}): GatedFinding[] {
|
|
236
|
+
const findings: GatedFinding[] = []
|
|
237
|
+
const stories = storyFiles(root).map((file) => ({ file, sf: parse(file, readFileSync(file, 'utf8')) }))
|
|
238
|
+
const sources = new Map<string, ts.SourceFile>()
|
|
239
|
+
const sourceOf = (rel: string): ts.SourceFile | null => {
|
|
240
|
+
const path = join(root, rel)
|
|
241
|
+
if (!sources.has(path)) {
|
|
242
|
+
if (!existsSync(path)) return null
|
|
243
|
+
sources.set(path, parse(path, readFileSync(path, 'utf8')))
|
|
244
|
+
}
|
|
245
|
+
return sources.get(path)!
|
|
246
|
+
}
|
|
247
|
+
const fileOf = new Map(registry.entries.filter((e) => e.kind === 'component').map((e) => [e.name, e.sourceFile]))
|
|
248
|
+
|
|
249
|
+
/** The story files that import a file, and every prop they pass to the part or its view. */
|
|
250
|
+
const givenTo = new Map<string, { drawn: boolean; given: Set<string> }>()
|
|
251
|
+
const storiesOf = (name: string, rel: string) => {
|
|
252
|
+
const key = `${rel}#${name}`
|
|
253
|
+
if (!givenTo.has(key)) {
|
|
254
|
+
const own = join(root, rel).replace(/\.[jt]sx?$/, '')
|
|
255
|
+
const drawing = stories.filter(({ file, sf: s }) =>
|
|
256
|
+
s.statements.some((st) => ts.isImportDeclaration(st) && ts.isStringLiteral(st.moduleSpecifier) && target(file, st.moduleSpecifier.text, root) === own),
|
|
257
|
+
)
|
|
258
|
+
givenTo.set(key, { drawn: drawing.length > 0, given: new Set(drawing.flatMap(({ sf: s }) => [...passed(s, [name, `${name}View`])])) })
|
|
259
|
+
}
|
|
260
|
+
return givenTo.get(key)!
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const memo = new Map<string, Set<string>>()
|
|
264
|
+
/** The props a named part gates on, its own and those it hands to a part that gates. */
|
|
265
|
+
const gatesOf = (name: string, sf: ts.SourceFile, depth: number): Set<string> => {
|
|
266
|
+
const key = `${sf.fileName}#${name}`
|
|
267
|
+
if (memo.has(key)) return memo.get(key)!
|
|
268
|
+
memo.set(key, new Set())
|
|
269
|
+
const fn = declared(sf, name)
|
|
270
|
+
const out = fn ? gates(fn) : new Set<string>()
|
|
271
|
+
if (fn && depth < 3) {
|
|
272
|
+
for (const { tag, attr, prop } of handsOn(fn, sf)) {
|
|
273
|
+
// A dialog is shut in a story until someone opens it, so nothing it draws
|
|
274
|
+
// is missing from the picture (Katerina, 23 September).
|
|
275
|
+
if (out.has(prop) || /Dialog$/.test(tag)) continue
|
|
276
|
+
const here = declared(sf, tag) ? sf : fileOf.has(tag) ? sourceOf(fileOf.get(tag)!) : null
|
|
277
|
+
if (!here || !gatesOf(tag, here, depth + 1).has(attr)) continue
|
|
278
|
+
// Already pictured one level down: CollapsibleSection hands `trailing` to
|
|
279
|
+
// SectionHeader, and SectionHeader's own story shows the slot.
|
|
280
|
+
const theirs = fileOf.get(tag)
|
|
281
|
+
if (pictured === 'anywhere' && theirs && here !== sf) {
|
|
282
|
+
const { given } = storiesOf(tag, theirs)
|
|
283
|
+
if (given.has(attr) || given.has(ANY)) continue
|
|
284
|
+
}
|
|
285
|
+
out.add(prop)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
memo.set(key, out)
|
|
289
|
+
return out
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const entry of registry.entries) {
|
|
293
|
+
// The package's entries carry no app class: every one of its components is one.
|
|
294
|
+
const cls = entry.app?.class ?? 'component'
|
|
295
|
+
if (!kinds.includes(cls) || entry.kind !== 'component') continue
|
|
296
|
+
const sf = sourceOf(entry.sourceFile)
|
|
297
|
+
if (!sf) continue
|
|
298
|
+
const { drawn, given } = storiesOf(entry.name, entry.sourceFile)
|
|
299
|
+
// No story draws it at all: the contract's "drawn nowhere" says so, not this.
|
|
300
|
+
if (!drawn || given.has(ANY)) continue
|
|
301
|
+
for (const name of [entry.name, `${entry.name}View`]) {
|
|
302
|
+
for (const prop of gatesOf(name, sf, 0)) {
|
|
303
|
+
if (!given.has(prop)) findings.push({ part: entry.name, file: entry.sourceFile.split('\\').join('/'), prop, in: name })
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return findings
|
|
308
|
+
}
|