Skip to content

Commit d44594a

Browse files
heiskrdocs-botCopilot
authored
Detect and remove orphaned data/tables files and schemas (#61738)
Co-authored-by: docs-bot <77750099+docs-bot@users.noreply.github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 288a586 commit d44594a

4 files changed

Lines changed: 279 additions & 4 deletions

File tree

.github/workflows/orphaned-files-check.yml

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: 'Orphaned files check'
22

3-
# **What it does**: Checks that there are no files in ./assets/ and ./data/reusables that aren't mentioned in any source file.
3+
# **What it does**: Checks that there are no files in ./assets/, ./data/reusables, or ./data/tables that aren't mentioned in any source file.
44
# **Why we have it**: To avoid orphans into the repo.
55
# **Who does it impact**: Docs content.
66

@@ -16,6 +16,7 @@ on:
1616
- 'package*.json'
1717
- src/assets/scripts/find-orphaned-assets.ts
1818
- src/content-render/scripts/reusables-cli/find/unused.ts
19+
- src/data-directory/scripts/find-orphaned-tables.ts
1920
- src/workflows/walk-files.ts
2021
- src/languages/lib/languages.ts
2122
- .github/actions/clone-translations/action.yml
@@ -67,14 +68,18 @@ jobs:
6768
# information about the npm script alias.
6869
assetFilesToRemove=$(npm run -s find-orphaned-assets)
6970
reusableFilesToRemove=$(npm run -s reusables -- find unused | grep '^data/reusables' || true)
70-
[ -z "$assetFilesToRemove" ] && [ -z "$reusableFilesToRemove" ] && exit 0
71+
tableFilesToRemove=$(npm run -s find-orphaned-tables)
72+
[ -z "$assetFilesToRemove" ] && [ -z "$reusableFilesToRemove" ] && [ -z "$tableFilesToRemove" ] && exit 0
7173
7274
if [ -n "$assetFilesToRemove" ]; then
7375
echo $assetFilesToRemove | xargs git rm
7476
fi
7577
if [ -n "$reusableFilesToRemove" ]; then
7678
echo $reusableFilesToRemove | xargs git rm
7779
fi
80+
if [ -n "$tableFilesToRemove" ]; then
81+
echo $tableFilesToRemove | xargs git rm
82+
fi
7883
7984
git status
8085
@@ -100,11 +105,11 @@ jobs:
100105
git push origin $branchname
101106
102107
body=$(cat <<-EOM
103-
Found with the `npm run find-orphaned-assets` and `npm run -s reusables -- find unused` scripts.
108+
Found with the `npm run find-orphaned-assets`, `npm run -s reusables -- find unused`, and `npm run find-orphaned-tables` scripts.
104109
105110
The orphaned files workflow file .github/workflows/orphaned-files-check.yml runs every Monday at 16:20 UTC / 8:20 PST.
106111
107-
If you are the first responder, please spot check some of the unused assets to make sure they aren't referenced anywhere. Then, approve and merge the pull request.
112+
If you are the first responder, please spot check some of the unused assets, reusables, and tables to make sure they aren't referenced anywhere. Then, approve and merge the pull request.
108113
109114
For more information, see [Doc: Orphaned Assets](https://github.com/github/docs-engineering/blob/main/docs/orphaned-assets.md) and [Doc: Reusables CLI](https://github.com/github/docs-internal/tree/main/src/content-render/scripts/reusables-cli).
110115

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@
4141
"enable-automerge": "tsx src/workflows/enable-automerge.ts",
4242
"find-orphaned-assets": "tsx src/assets/scripts/find-orphaned-assets.ts",
4343
"find-orphaned-features": "tsx src/data-directory/scripts/find-orphaned-features/index.ts",
44+
"find-orphaned-tables": "tsx src/data-directory/scripts/find-orphaned-tables.ts",
4445
"find-past-built-pr": "tsx src/workflows/find-past-built-pr.ts",
4546
"find-unused-variables": "tsx src/content-linter/scripts/find-unsed-variables.ts",
4647
"fixture-dev": "cross-env ROOT=src/fixtures/fixtures npm start",
Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
// [start-readme]
2+
//
3+
// Print a list of all the YAML-powered table files in ./data/tables/ that
4+
// can't be found mentioned in any source file (content, data & code), along
5+
// with their paired schema files. Mirrors find-orphaned-assets.ts.
6+
//
7+
// Tables are referenced from Liquid like:
8+
//
9+
// {% data tables.<group>.<name> %}
10+
// {% for entry in tables.<group>.<name> %}
11+
//
12+
// so a table file `data/tables/<group>/<name>.yml` is "used" if the string
13+
// `tables.<group>.<name>` appears anywhere. A deeper reference such as
14+
// `tables.<group>.<name>.<subkey>` also counts, because the file key is a
15+
// prefix of it.
16+
//
17+
// [end-readme]
18+
19+
import fs from 'fs'
20+
import path from 'path'
21+
import { pathToFileURL } from 'url'
22+
import { program } from 'commander'
23+
import walk from 'walk-sync'
24+
25+
import walkFiles from '@/workflows/walk-files'
26+
import languages from '@/languages/lib/languages-server'
27+
28+
const TABLES_DIR = 'data/tables'
29+
const SCHEMAS_DIR = 'src/data-directory/lib/data-schemas/tables'
30+
31+
// Tables that are referenced dynamically (not via Liquid) and must never be
32+
// flagged as orphans. Add an entry here (the dotted key, e.g. `copilot.foo`)
33+
// if a table is loaded by code rather than mentioned in content.
34+
const EXCEPTIONS = new Set<string>([])
35+
36+
export type TableFile = {
37+
// Repo-relative path to the YAML file, e.g. data/tables/copilot/model-multipliers.yml
38+
yml: string
39+
// Repo-relative path to the paired schema, if it exists on disk.
40+
schema?: string
41+
// Dotted key used in Liquid, e.g. copilot.model-multipliers
42+
key: string
43+
}
44+
45+
function getTableFiles(): TableFile[] {
46+
if (!fs.existsSync(TABLES_DIR)) return []
47+
return walk(TABLES_DIR, { includeBasePath: true, directories: false })
48+
.filter((filePath) => filePath.endsWith('.yml'))
49+
.map((ymlPath) => {
50+
const relative = path.relative(TABLES_DIR, ymlPath)
51+
const key = relative.slice(0, -'.yml'.length).split(path.sep).join('.')
52+
const schemaPath = path.join(SCHEMAS_DIR, relative.replace(/\.yml$/, '.ts'))
53+
return {
54+
yml: ymlPath,
55+
schema: fs.existsSync(schemaPath) ? schemaPath : undefined,
56+
key,
57+
}
58+
})
59+
}
60+
61+
program
62+
.description('Print all tables in ./data/tables/ not found in any source file')
63+
.option('-e, --exit', 'Exit script by count of orphans (useful for CI)')
64+
.option('-v, --verbose', 'Verbose outputs')
65+
.option('--json', 'Output in JSON format')
66+
.option('--exclude-translations', "Don't search in translations/")
67+
68+
type MainOptions = {
69+
json: boolean
70+
verbose: boolean
71+
exit: boolean
72+
excludeTranslations: boolean
73+
}
74+
75+
// Given the table files and the contents of every source file, return the
76+
// tables whose Liquid key is never mentioned. Pulled out of main() so it can
77+
// be unit tested without touching the filesystem.
78+
export function getOrphanedTables(
79+
tables: TableFile[],
80+
sourceContents: Iterable<string>,
81+
): TableFile[] {
82+
const orphans = new Map(tables.map((table) => [table.key, table]))
83+
for (const content of sourceContents) {
84+
if (orphans.size === 0) break
85+
for (const [key] of orphans) {
86+
if (EXCEPTIONS.has(key) || content.includes(`tables.${key}`)) {
87+
orphans.delete(key)
88+
}
89+
}
90+
}
91+
return [...orphans.values()].sort((a, b) => a.yml.localeCompare(b.yml))
92+
}
93+
94+
// Only parse argv and run when invoked directly (e.g. via `npm run
95+
// find-orphaned-tables`), not when imported by a test.
96+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
97+
program.parse(process.argv)
98+
main(program.opts())
99+
}
100+
101+
async function main(opts: MainOptions) {
102+
const { json, verbose, exit, excludeTranslations } = opts
103+
104+
const englishFiles: string[] = []
105+
englishFiles.push(...walkFiles(path.join(languages.en.dir, 'content'), ['.md']))
106+
englishFiles.push(...walkFiles(path.join(languages.en.dir, 'data'), ['.md', '.yml']))
107+
108+
const sourceFiles: string[] = [...englishFiles]
109+
110+
if (!excludeTranslations) {
111+
// Translations are often behind English. A table can still be referenced
112+
// in a translation even when no English content references it, so we must
113+
// search translations too. We only look at files that also exist in
114+
// English, because translations rarely delete renamed/removed files.
115+
const englishRelativeFiles = new Set(
116+
englishFiles.map((englishFile) => path.relative(languages.en.dir, englishFile)),
117+
)
118+
for (const [language, { dir }] of Object.entries(languages)) {
119+
if (language === 'en') continue
120+
if (!fs.existsSync(dir)) {
121+
throw new Error(
122+
`${dir} does not exist. Get around this by using the flag \`--exclude-translations\`.`,
123+
)
124+
}
125+
const languageFiles: string[] = []
126+
languageFiles.push(...walkFiles(path.join(dir, 'content'), ['.md']))
127+
languageFiles.push(...walkFiles(path.join(dir, 'data'), ['.md', '.yml']))
128+
sourceFiles.push(
129+
...languageFiles.filter((languageFile) =>
130+
englishRelativeFiles.has(path.relative(dir, languageFile)),
131+
),
132+
)
133+
}
134+
}
135+
136+
// Tables can also be referenced from code (e.g. table-rendering helpers), so
137+
// search src and contributing as well. Searching more files only ever marks
138+
// a table as used, never as an orphan, so it errs on the safe side.
139+
for (const root of ['contributing', 'src']) {
140+
if (!fs.existsSync(root)) continue
141+
sourceFiles.push(
142+
...walk(root, {
143+
includeBasePath: true,
144+
directories: false,
145+
globs: ['!**/*.+(png|jpe?g|csv|graphql|json|svg)'],
146+
}),
147+
)
148+
}
149+
150+
if (verbose) {
151+
console.error(`${sourceFiles.length.toLocaleString()} source files found in total.`)
152+
}
153+
154+
const tables = getTableFiles()
155+
if (verbose) {
156+
console.error(`${tables.length.toLocaleString()} table files found in total.`)
157+
}
158+
159+
// Read files lazily so we can stop early once every table is accounted for.
160+
function* readContents(): Generator<string> {
161+
for (const sourceFile of sourceFiles) {
162+
yield fs.readFileSync(sourceFile, 'utf-8')
163+
}
164+
}
165+
166+
const orphanTables = getOrphanedTables(tables, readContents())
167+
168+
// Safety net: if every table looks orphaned, the detection is almost
169+
// certainly broken (e.g. content wasn't checked out). Refuse to suggest
170+
// deleting everything.
171+
if (tables.length > 0 && orphanTables.length === tables.length) {
172+
console.error(
173+
'Every table was flagged as orphaned, which is almost certainly a bug. ' +
174+
'Refusing to output anything. Was the content checked out?',
175+
)
176+
process.exit(1)
177+
}
178+
179+
if (verbose && orphanTables.length) {
180+
console.error('The following tables are not mentioned anywhere in any source file:')
181+
}
182+
183+
if (json) {
184+
console.log(JSON.stringify(orphanTables, undefined, 2))
185+
} else {
186+
const filesToRemove: string[] = []
187+
for (const table of orphanTables) {
188+
filesToRemove.push(table.yml)
189+
if (table.schema) filesToRemove.push(table.schema)
190+
}
191+
for (const filePath of filesToRemove) {
192+
console.log(filePath)
193+
}
194+
}
195+
196+
if (verbose) {
197+
console.error(`${orphanTables.length.toLocaleString()} orphan tables left.`)
198+
}
199+
200+
if (exit) {
201+
process.exit(orphanTables.length)
202+
}
203+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { describe, expect, test } from 'vitest'
2+
3+
import { getOrphanedTables, type TableFile } from '@/data-directory/scripts/find-orphaned-tables'
4+
5+
function table(key: string): TableFile {
6+
const relative = key.split('.').join('/')
7+
return {
8+
key,
9+
yml: `data/tables/${relative}.yml`,
10+
schema: `src/data-directory/lib/data-schemas/tables/${relative}.ts`,
11+
}
12+
}
13+
14+
describe('getOrphanedTables', () => {
15+
const tables = [
16+
table('copilot.model-multipliers'),
17+
table('copilot.copilot-matrix'),
18+
table('rest-api-versions'),
19+
]
20+
21+
test('flags a table that is never referenced', () => {
22+
const sources = ['{% for entry in tables.copilot.copilot-matrix %}', 'tables.rest-api-versions']
23+
const orphans = getOrphanedTables(tables, sources)
24+
expect(orphans.map((t) => t.key)).toEqual(['copilot.model-multipliers'])
25+
})
26+
27+
test('returns the paired yml and schema paths for an orphan', () => {
28+
const orphans = getOrphanedTables([table('copilot.model-multipliers')], ['nothing here'])
29+
expect(orphans[0].yml).toBe('data/tables/copilot/model-multipliers.yml')
30+
expect(orphans[0].schema).toBe(
31+
'src/data-directory/lib/data-schemas/tables/copilot/model-multipliers.ts',
32+
)
33+
})
34+
35+
test('counts the `{% data tables.X %}` form as a reference', () => {
36+
const orphans = getOrphanedTables(
37+
[table('rest-api-versions')],
38+
['see {% data tables.rest-api-versions %} below'],
39+
)
40+
expect(orphans).toHaveLength(0)
41+
})
42+
43+
test('counts a deeper sub-key reference as using the table file', () => {
44+
// A reference to `tables.copilot.copilot-matrix.ides` should mark the
45+
// `copilot.copilot-matrix` file as used.
46+
const orphans = getOrphanedTables(
47+
[table('copilot.copilot-matrix')],
48+
['{% for row in tables.copilot.copilot-matrix.ides %}'],
49+
)
50+
expect(orphans).toHaveLength(0)
51+
})
52+
53+
test('does not let a longer key falsely mark a shorter, unrelated table', () => {
54+
// `tables.copilot.annual-subscriber-model-multipliers` must NOT mark
55+
// `copilot.model-multipliers` as used.
56+
const orphans = getOrphanedTables(
57+
[table('copilot.model-multipliers')],
58+
['{% data tables.copilot.annual-subscriber-model-multipliers %}'],
59+
)
60+
expect(orphans.map((t) => t.key)).toEqual(['copilot.model-multipliers'])
61+
})
62+
63+
test('returns nothing when there are no tables', () => {
64+
expect(getOrphanedTables([], ['anything'])).toEqual([])
65+
})
66+
})

0 commit comments

Comments
 (0)