@davidvornholt/standards 0.17.3 → 0.19.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/README.md +2 -2
- package/package.json +10 -1
- package/src/cli.ts +307 -82
- package/src/creds-login-github-manifest.ts +14 -7
- package/src/dependabot-directory-character-class.ts +139 -0
- package/src/dependabot-directory-normalize.ts +33 -0
- package/src/dependabot-directory-overlap.ts +139 -0
- package/src/dependabot-update.ts +32 -4
- package/src/github-api.ts +0 -80
- package/src/github-apply.ts +3 -2
- package/src/github-bypass-actors.ts +56 -0
- package/src/github-command-shared.ts +9 -2
- package/src/github-diff.ts +2 -109
- package/src/github-graphql.ts +198 -0
- package/src/github-live-drift.ts +57 -6
- package/src/github-ruleset-api.ts +57 -0
- package/src/github-ruleset-diff.ts +181 -0
- package/src/github-ruleset-visibility.ts +100 -0
- package/src/github-settings-parse.ts +12 -10
- package/src/github-settings.ts +3 -2
- package/src/managed-files.ts +116 -14
- package/src/managed-path-ownership.ts +85 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Turns unverifiable ruleset fields into gate failures. This module owns both
|
|
2
|
+
// the wording of an invisible field and the test for which field it is, so the
|
|
3
|
+
// two cannot drift apart: an earlier version formatted the message in one
|
|
4
|
+
// module and recovered the signal by parsing the suffix in another, where a
|
|
5
|
+
// reformat would have silently stopped the bypass-actor fallback from firing.
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
ADMIN_VISIBILITY_ADVICE,
|
|
9
|
+
unverifiableProblem,
|
|
10
|
+
} from './github-command-shared';
|
|
11
|
+
import {
|
|
12
|
+
BYPASS_ACTORS_KEY,
|
|
13
|
+
type UnverifiableRulesetField,
|
|
14
|
+
} from './github-ruleset-diff';
|
|
15
|
+
|
|
16
|
+
const SCOPE = 'ruleset field(s)';
|
|
17
|
+
|
|
18
|
+
export const isHiddenBypassActors = (
|
|
19
|
+
field: UnverifiableRulesetField,
|
|
20
|
+
): boolean => field.key === BYPASS_ACTORS_KEY;
|
|
21
|
+
|
|
22
|
+
const fieldLabel = (field: UnverifiableRulesetField): string =>
|
|
23
|
+
`ruleset "${field.name}": ${field.key}`;
|
|
24
|
+
|
|
25
|
+
// What the GraphQL bypass-actor fallback established, so the advice can tell a
|
|
26
|
+
// failed request from a permission gap. `countedNames` holds the live rulesets
|
|
27
|
+
// GraphQL did answer a count for.
|
|
28
|
+
export type BypassCountOutcome = {
|
|
29
|
+
readonly countedNames: ReadonlySet<string>;
|
|
30
|
+
readonly failure: string | null;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
// Rulesets can be unverifiable for reasons other than a hidden bypass list, in
|
|
34
|
+
// which case the fallback is never called.
|
|
35
|
+
export const GRAPHQL_NOT_CONSULTED: BypassCountOutcome = {
|
|
36
|
+
countedNames: new Set(),
|
|
37
|
+
failure: null,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// The generic "read access to repository administration" remedy is
|
|
41
|
+
// unactionable for brokered CI here: REST withholds this field from a GitHub
|
|
42
|
+
// App installation token at every permission level, so widening the App only
|
|
43
|
+
// broadens the blast radius while the gate stays red.
|
|
44
|
+
const INSTALLATION_DEAD_END =
|
|
45
|
+
"A GitHub App installation token cannot read bypass_actors over REST at any permission level, so widening the App's permissions will not help";
|
|
46
|
+
|
|
47
|
+
const COUNT_MATCHED_ADVICE = `GitHub reports the declared number of bypass actors but withholds their identities, so the repository is probably fine and only a local run with admin auth can confirm it. ${INSTALLATION_DEAD_END}`;
|
|
48
|
+
|
|
49
|
+
const NOTHING_ANSWERED_ADVICE = `Neither REST nor the GraphQL bypass-actor count answered for the ruleset(s) listed, so nothing at all is known about who may bypass them; verify locally with admin auth. ${INSTALLATION_DEAD_END}`;
|
|
50
|
+
|
|
51
|
+
// The two no-failure remedies differ per ruleset, so the fields are partitioned
|
|
52
|
+
// rather than reported under whichever advice one of them happened to match: a
|
|
53
|
+
// ruleset GraphQL never answered for must never be described as probably fine
|
|
54
|
+
// because a sibling ruleset was counted.
|
|
55
|
+
const hiddenBypassActorsProblems = (
|
|
56
|
+
fields: ReadonlyArray<UnverifiableRulesetField>,
|
|
57
|
+
outcome: BypassCountOutcome,
|
|
58
|
+
): ReadonlyArray<string> => {
|
|
59
|
+
if (outcome.failure !== null) {
|
|
60
|
+
return unverifiableProblem(
|
|
61
|
+
SCOPE,
|
|
62
|
+
fields.map(fieldLabel),
|
|
63
|
+
`The GraphQL bypass-actor count did not answer (${outcome.failure}), so this is a failed request rather than a permission gap: re-run the check, and if it keeps failing verify locally with admin auth`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
const counted = fields.filter((field) =>
|
|
67
|
+
outcome.countedNames.has(field.name),
|
|
68
|
+
);
|
|
69
|
+
const unanswered = fields.filter(
|
|
70
|
+
(field) => !outcome.countedNames.has(field.name),
|
|
71
|
+
);
|
|
72
|
+
return [
|
|
73
|
+
...unverifiableProblem(
|
|
74
|
+
SCOPE,
|
|
75
|
+
counted.map(fieldLabel),
|
|
76
|
+
COUNT_MATCHED_ADVICE,
|
|
77
|
+
),
|
|
78
|
+
...unverifiableProblem(
|
|
79
|
+
SCOPE,
|
|
80
|
+
unanswered.map(fieldLabel),
|
|
81
|
+
NOTHING_ANSWERED_ADVICE,
|
|
82
|
+
),
|
|
83
|
+
];
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const rulesetVisibilityProblems = (
|
|
87
|
+
unverifiable: ReadonlyArray<UnverifiableRulesetField>,
|
|
88
|
+
outcome: BypassCountOutcome,
|
|
89
|
+
): ReadonlyArray<string> => {
|
|
90
|
+
const hidden = unverifiable.filter(isHiddenBypassActors);
|
|
91
|
+
const others = unverifiable.filter((field) => !isHiddenBypassActors(field));
|
|
92
|
+
return [
|
|
93
|
+
...unverifiableProblem(
|
|
94
|
+
SCOPE,
|
|
95
|
+
others.map(fieldLabel),
|
|
96
|
+
ADMIN_VISIBILITY_ADVICE,
|
|
97
|
+
),
|
|
98
|
+
...hiddenBypassActorsProblems(hidden, outcome),
|
|
99
|
+
];
|
|
100
|
+
};
|
|
@@ -1,8 +1,7 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// module is zero-dependency so `bunx` can execute the published package.
|
|
1
|
+
// Parse one GitHub settings file: canonical `.github/settings.json` or the
|
|
2
|
+
// repo-owned `.github/settings.local.json` extension. Seam merging, drift
|
|
3
|
+
// comparison, and API interaction live in dedicated modules. This module stays
|
|
4
|
+
// zero-dependency so `bunx` can execute the published package.
|
|
6
5
|
|
|
7
6
|
import { labelIdentity } from './github-label-identity';
|
|
8
7
|
|
|
@@ -139,13 +138,15 @@ const parseDeclarationList = (
|
|
|
139
138
|
options: ListField,
|
|
140
139
|
problems: Array<string>,
|
|
141
140
|
): ReadonlyArray<unknown> | null => {
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
if (raw === undefined) {
|
|
142
|
+
return [];
|
|
143
|
+
}
|
|
144
|
+
if (!Array.isArray(raw)) {
|
|
144
145
|
problems.push(`${options.label} "${options.field}" must be an array`);
|
|
145
146
|
return null;
|
|
146
147
|
}
|
|
147
|
-
problems.push(...options.listProblems(
|
|
148
|
-
return
|
|
148
|
+
problems.push(...options.listProblems(raw, options.label));
|
|
149
|
+
return raw;
|
|
149
150
|
};
|
|
150
151
|
|
|
151
152
|
export const parseSettings = (
|
|
@@ -162,7 +163,7 @@ export const parseSettings = (
|
|
|
162
163
|
problems.push(`${label} has unknown key "${key}"`);
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
|
-
const repository = raw.repository
|
|
166
|
+
const repository = 'repository' in raw ? raw.repository : {};
|
|
166
167
|
if (!isRecord(repository)) {
|
|
167
168
|
problems.push(`${label} "repository" must be an object`);
|
|
168
169
|
}
|
|
@@ -179,6 +180,7 @@ export const parseSettings = (
|
|
|
179
180
|
// Only the opt-out value is accepted: enforcement is the default, and an
|
|
180
181
|
// explicit "enforced" would be a second way to spell the same state.
|
|
181
182
|
if (
|
|
183
|
+
allowedKeys.has('rulesetEnforcement') &&
|
|
182
184
|
raw.rulesetEnforcement !== undefined &&
|
|
183
185
|
raw.rulesetEnforcement !== ENFORCEMENT_OPT_OUT
|
|
184
186
|
) {
|
package/src/github-settings.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Load and merge canonical GitHub settings with the repo-owned extension.
|
|
2
2
|
// Parsing lives in github-settings-parse.ts, merge policy in
|
|
3
|
-
// github-settings-merge.ts, drift in github-diff.ts
|
|
4
|
-
// github-api.ts; this stays
|
|
3
|
+
// github-settings-merge.ts, drift in github-diff.ts and
|
|
4
|
+
// github-ruleset-diff.ts, and API work in github-api.ts; this stays
|
|
5
|
+
// dependency-free.
|
|
5
6
|
|
|
6
7
|
import { mergeSettings } from './github-settings-merge';
|
|
7
8
|
import {
|
package/src/managed-files.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
lstat,
|
|
3
|
+
readdir,
|
|
4
|
+
readFile,
|
|
5
|
+
readlink,
|
|
6
|
+
rm,
|
|
7
|
+
symlink,
|
|
8
|
+
writeFile,
|
|
9
|
+
} from 'node:fs/promises';
|
|
2
10
|
import { join, relative, sep } from 'node:path';
|
|
3
11
|
|
|
4
12
|
// Never mirrored, even under a managed directory path: build output, VCS
|
|
@@ -16,34 +24,76 @@ const directiveToken = Buffer.from(['biome', 'ignore'].join('-'));
|
|
|
16
24
|
|
|
17
25
|
const toPosix = (path: string): string => path.split(sep).join('/');
|
|
18
26
|
|
|
27
|
+
// A managed path is a file or a symlink. A symlink's identity is its target,
|
|
28
|
+
// never the content it resolves to: mirroring must reproduce the link itself,
|
|
29
|
+
// and following it would copy the whole target tree into every consumer.
|
|
30
|
+
export type ManagedEntry =
|
|
31
|
+
| { readonly kind: 'file'; readonly absolutePath: string }
|
|
32
|
+
| {
|
|
33
|
+
readonly kind: 'symlink';
|
|
34
|
+
readonly absolutePath: string;
|
|
35
|
+
readonly target: string;
|
|
36
|
+
}
|
|
37
|
+
// Only ever observed when inspecting a destination a consumer replaced; the
|
|
38
|
+
// upstream walk descends into directories instead of recording them.
|
|
39
|
+
| { readonly kind: 'directory'; readonly absolutePath: string };
|
|
40
|
+
|
|
41
|
+
// Built rather than written as an escape so this source file stays plain ASCII
|
|
42
|
+
// and greppable. File digests remain plain content hashes, so existing consumer
|
|
43
|
+
// locks keep validating; the markers only need to be unreachable content for a
|
|
44
|
+
// canonical file, and no text file begins with a NUL byte.
|
|
45
|
+
const nul = String.fromCharCode(0);
|
|
46
|
+
const SYMLINK_DIGEST_PREFIX = `${nul}standards-symlink${nul}`;
|
|
47
|
+
const DIRECTORY_DIGEST = `${nul}standards-directory${nul}`;
|
|
48
|
+
|
|
49
|
+
export const inspectManagedPath = async (
|
|
50
|
+
path: string,
|
|
51
|
+
): Promise<ManagedEntry | null> => {
|
|
52
|
+
const info = await lstat(path).catch(() => null);
|
|
53
|
+
if (info === null) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
if (info.isSymbolicLink()) {
|
|
57
|
+
return {
|
|
58
|
+
kind: 'symlink',
|
|
59
|
+
absolutePath: path,
|
|
60
|
+
target: toPosix(await readlink(path)),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
if (info.isDirectory()) {
|
|
64
|
+
return { kind: 'directory', absolutePath: path };
|
|
65
|
+
}
|
|
66
|
+
return { kind: 'file', absolutePath: path };
|
|
67
|
+
};
|
|
68
|
+
|
|
19
69
|
const walkManagedFiles = async (
|
|
20
70
|
path: string,
|
|
21
71
|
repositoryRoot: string,
|
|
22
|
-
files: Map<string,
|
|
72
|
+
files: Map<string, ManagedEntry>,
|
|
23
73
|
): Promise<void> => {
|
|
24
|
-
const
|
|
25
|
-
if (
|
|
74
|
+
const entry = await inspectManagedPath(path);
|
|
75
|
+
if (entry === null) {
|
|
26
76
|
return;
|
|
27
77
|
}
|
|
28
|
-
if (
|
|
78
|
+
if (entry.kind === 'directory') {
|
|
29
79
|
const entries = await readdir(path);
|
|
30
80
|
await Promise.all(
|
|
31
81
|
entries
|
|
32
|
-
.filter((
|
|
33
|
-
.map((
|
|
34
|
-
walkManagedFiles(join(path,
|
|
82
|
+
.filter((name) => !IGNORED_DIRECTORIES.has(name))
|
|
83
|
+
.map((name) =>
|
|
84
|
+
walkManagedFiles(join(path, name), repositoryRoot, files),
|
|
35
85
|
),
|
|
36
86
|
);
|
|
37
87
|
return;
|
|
38
88
|
}
|
|
39
|
-
files.set(toPosix(relative(repositoryRoot, path)),
|
|
89
|
+
files.set(toPosix(relative(repositoryRoot, path)), entry);
|
|
40
90
|
};
|
|
41
91
|
|
|
42
92
|
export const listManagedFiles = async (
|
|
43
93
|
repositoryRoot: string,
|
|
44
94
|
paths: ReadonlyArray<string>,
|
|
45
|
-
): Promise<ReadonlyMap<string,
|
|
46
|
-
const files = new Map<string,
|
|
95
|
+
): Promise<ReadonlyMap<string, ManagedEntry>> => {
|
|
96
|
+
const files = new Map<string, ManagedEntry>();
|
|
47
97
|
await Promise.all(
|
|
48
98
|
paths.map((path) =>
|
|
49
99
|
walkManagedFiles(join(repositoryRoot, path), repositoryRoot, files),
|
|
@@ -52,12 +102,64 @@ export const listManagedFiles = async (
|
|
|
52
102
|
return files;
|
|
53
103
|
};
|
|
54
104
|
|
|
105
|
+
// What the lock hashes for an entry: file content, or the marker-prefixed link
|
|
106
|
+
// target. Retargeting a link therefore reads as drift the same way an edited
|
|
107
|
+
// file does.
|
|
108
|
+
export const managedEntryDigestInput = (
|
|
109
|
+
entry: ManagedEntry,
|
|
110
|
+
): Promise<Buffer> => {
|
|
111
|
+
if (entry.kind === 'symlink') {
|
|
112
|
+
return Promise.resolve(
|
|
113
|
+
Buffer.from(`${SYMLINK_DIGEST_PREFIX}${entry.target}`),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
if (entry.kind === 'directory') {
|
|
117
|
+
return Promise.resolve(Buffer.from(DIRECTORY_DIGEST));
|
|
118
|
+
}
|
|
119
|
+
return readFile(entry.absolutePath);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// Plant one managed entry, replacing a file or a link that occupies the
|
|
123
|
+
// destination. Never recursive: a directory at the destination is unbounded
|
|
124
|
+
// consumer content, so removing it is the caller's decision to make explicitly
|
|
125
|
+
// once it has proven the path was engine-owned.
|
|
126
|
+
export const writeManagedEntry = async (
|
|
127
|
+
dest: string,
|
|
128
|
+
entry: ManagedEntry,
|
|
129
|
+
): Promise<void> => {
|
|
130
|
+
if (entry.kind === 'directory') {
|
|
131
|
+
throw new Error(`cannot mirror a directory as a managed path: ${dest}`);
|
|
132
|
+
}
|
|
133
|
+
const existing = await inspectManagedPath(dest);
|
|
134
|
+
if (existing?.kind === 'directory') {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`refusing to replace the directory ${dest} with a managed ${entry.kind}; move or delete it first`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// `symlink` refuses an existing path, and writing a file through a stale link
|
|
140
|
+
// would clobber that link's target instead of replacing the managed path.
|
|
141
|
+
if (
|
|
142
|
+
existing !== null &&
|
|
143
|
+
(entry.kind === 'symlink' || existing.kind === 'symlink')
|
|
144
|
+
) {
|
|
145
|
+
await rm(dest, { force: true });
|
|
146
|
+
}
|
|
147
|
+
if (entry.kind === 'symlink') {
|
|
148
|
+
await symlink(entry.target, dest);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
await writeFile(dest, await readFile(entry.absolutePath));
|
|
152
|
+
};
|
|
153
|
+
|
|
55
154
|
export const findManagedFilesContainingBiomeDirectiveToken = async (
|
|
56
|
-
files: ReadonlyMap<string,
|
|
155
|
+
files: ReadonlyMap<string, ManagedEntry>,
|
|
57
156
|
): Promise<ReadonlyArray<string>> => {
|
|
58
157
|
const matches = await Promise.all(
|
|
59
|
-
[...files].map(async ([path,
|
|
60
|
-
|
|
158
|
+
[...files].map(async ([path, entry]) =>
|
|
159
|
+
entry.kind === 'file' &&
|
|
160
|
+
(await readFile(entry.absolutePath)).includes(directiveToken)
|
|
161
|
+
? path
|
|
162
|
+
: null,
|
|
61
163
|
),
|
|
62
164
|
);
|
|
63
165
|
return matches.filter((path): path is string => path !== null).sort();
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Ownership questions about a destination path, asked before init/sync writes
|
|
2
|
+
// to it or deletes it. Both answers come from the lock: it is the only record of
|
|
3
|
+
// what this engine put in a consumer, so anything absent from it is consumer
|
|
4
|
+
// work that a mirror must never destroy.
|
|
5
|
+
|
|
6
|
+
import { readdir } from 'node:fs/promises';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { inspectManagedPath, type ManagedEntry } from './managed-files';
|
|
9
|
+
|
|
10
|
+
const collectPaths = async (
|
|
11
|
+
root: string,
|
|
12
|
+
rel: string,
|
|
13
|
+
found: Array<string>,
|
|
14
|
+
): Promise<void> => {
|
|
15
|
+
const entry = await inspectManagedPath(join(root, rel));
|
|
16
|
+
if (entry === null) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
if (entry.kind === 'directory') {
|
|
20
|
+
const names = await readdir(join(root, rel));
|
|
21
|
+
await Promise.all(
|
|
22
|
+
names.map((name) => collectPaths(root, `${rel}/${name}`, found)),
|
|
23
|
+
);
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
found.push(rel);
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
// Paths inside `rel` that the lock never recorded, so a caller can tell an
|
|
30
|
+
// engine-owned tree from consumer work. An older CLI that followed a canonical
|
|
31
|
+
// link materialized its whole target tree and locked every path in it; a
|
|
32
|
+
// consumer's own file under the same directory is absent from the lock. Nothing
|
|
33
|
+
// here follows a symlink: every entry is judged where it sits.
|
|
34
|
+
export const unlockedPathsUnder = async (
|
|
35
|
+
root: string,
|
|
36
|
+
rel: string,
|
|
37
|
+
locked: ReadonlySet<string>,
|
|
38
|
+
): Promise<ReadonlyArray<string>> => {
|
|
39
|
+
const found: Array<string> = [];
|
|
40
|
+
await collectPaths(root, rel, found);
|
|
41
|
+
return found.filter((path) => !locked.has(path)).sort();
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// An ancestor that is not a directory, so nothing can sit below it under the
|
|
45
|
+
// name the lock recorded. The kind is part of the answer: below a symlink the
|
|
46
|
+
// name still resolves, to somebody else's content, while below a file it
|
|
47
|
+
// resolves to nothing at all.
|
|
48
|
+
export type InterposedAncestor = {
|
|
49
|
+
readonly rel: string;
|
|
50
|
+
readonly kind: 'file' | 'symlink';
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// The shallowest ancestor of `rel` inside `root` that is not a directory, or
|
|
54
|
+
// null when the whole parent chain is directories.
|
|
55
|
+
//
|
|
56
|
+
// `planned` is the payload this run mirrors, and it wins over the disk: every
|
|
57
|
+
// path in it has the kind upstream gives it once the mirror has finished, and
|
|
58
|
+
// the caller runs after the mirror. A dry run, which writes nothing, therefore
|
|
59
|
+
// reaches the same answer the matching real run does.
|
|
60
|
+
//
|
|
61
|
+
// A path whose parent chain crosses a link does not name what the caller thinks
|
|
62
|
+
// it names: reading or removing it reaches into the link's target tree, which
|
|
63
|
+
// is somebody else's content.
|
|
64
|
+
export const interposedAncestor = async (
|
|
65
|
+
root: string,
|
|
66
|
+
rel: string,
|
|
67
|
+
planned: ReadonlyMap<string, ManagedEntry>,
|
|
68
|
+
): Promise<InterposedAncestor | null> => {
|
|
69
|
+
const segments = rel.split('/').slice(0, -1);
|
|
70
|
+
const prefixes = segments.map((_, depth) =>
|
|
71
|
+
segments.slice(0, depth + 1).join('/'),
|
|
72
|
+
);
|
|
73
|
+
const entries = await Promise.all(
|
|
74
|
+
prefixes.map(
|
|
75
|
+
(prefix) => planned.get(prefix) ?? inspectManagedPath(join(root, prefix)),
|
|
76
|
+
),
|
|
77
|
+
);
|
|
78
|
+
for (const [index, prefix] of prefixes.entries()) {
|
|
79
|
+
const entry = entries[index] ?? null;
|
|
80
|
+
if (entry !== null && entry.kind !== 'directory') {
|
|
81
|
+
return { rel: prefix, kind: entry.kind };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return null;
|
|
85
|
+
};
|