Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,22 @@ All notable changes to **basou** are recorded here. The project follows
a measure is not measured, `not_found` names it as
`measures["site.pages"]`, as the difference from the previous record
already did, so it is not read as a measure `pages` inside `site`.
- **A registered component can name the files that mark it (`marker`, from
`board_version: 3`, experimental).** A directory no built-in marker finds,
such as one of content or of scripts, was gone at every measure and fired
the axis review's (a) each time. `components.<key>.marker` is a glob from
the component's directory, written and checked as an `include` is (`*`
does not cross `/`; an absolute path, a `..` segment, a leading `:` and a
control character are refused). When a file the working tree lists under
that directory matches it, the component is found, with `marker` among its
kinds; when none does, it is gone, as before. `**` takes any file under
it, `*.py` a Python file right in it. The directories the built-in markers
skip (such as `node_modules`) are not skipped for it. A key that is a repo
itself, with nothing after the repo's name, takes no marker, and a key
that names a file finds nothing. The `components` method stays 2: a
registry without a marker measures as before. A component the built-in
markers already found has `marker` added to its kinds when it is given
one, which the next measure reports as a change of kinds.

### Changed

Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/commands/board.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,6 +837,30 @@ describe("basou board measure", () => {
);
});

it("prints a component its own marker found with the kind marker", async () => {
const repo = await workspace([{ path: ".", visibility: "private" }]);
await mkdir(join(repo, "content"));
await writeFile(join(repo, "content", "a.md"), "\n");
const name = basename(repo);
const declared = JSON.parse(boardYaml([])) as Record<string, unknown>;
await placeBoard(
repo,
JSON.stringify({
...declared,
board_version: 3,
components: {
[`${name}/content`]: { lane: ["core"], marker: "*.md" },
[`${name}/tools`]: { lane: ["core"], marker: "*.py" },
},
}),
);
const { out } = capture();
await runBoardMeasure({}, ctx(repo));
expect(out.join("\n")).toContain(
`\nComponents:\n 1 found, 0 unacknowledged, 1 gone\n ${name}/content marker\n ${name}/tools (gone)\n`,
);
});

it("lists the components by code point, whatever order JSON gives their keys", async () => {
const repo = await workspace([
{ path: ".", visibility: "private" },
Expand Down
189 changes: 189 additions & 0 deletions packages/core/src/board/components.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,3 +138,192 @@ describe("measureComponents against the previous record", () => {
]);
});
});

describe("measureComponents: a registered component's own marker", () => {
const marked = (
paths: string[],
registered: Record<string, { marker?: string }>,
previous?: PreviousComponents,
) =>
measureComponents({
repos: ["."],
names: new Map([[".", "app"]]),
worktreeOf: tree(paths),
registered,
...(previous === undefined ? {} : { previous }),
});

it("finds a registered directory when a file under it matches its marker from there", async () => {
const { components, notFound } = await marked(
[
"content/a.md",
"content/deep/b.md",
"tools/run.py",
"nested/sub/x.py",
"lib/x.txt",
"docs/README",
"web/package.json",
],
{
"app/content": { marker: "**" },
"app/tools": { marker: "*.py" },
// '*' does not cross '/'.
"app/nested": { marker: "*.py" },
"app/lib": { marker: "*.md" },
"app/web": { marker: "package.json" },
"app/docs": {},
},
);
expect(components.found).toEqual({
"app/content": { kinds: ["marker"], status: "known" },
"app/tools": { kinds: ["marker"], status: "known" },
// Beside what a built-in marker finds there.
"app/web": { kinds: ["manifest", "marker"], status: "known" },
});
expect(components.unacknowledged).toEqual([]);
expect(components.gone).toEqual(["app/docs", "app/lib", "app/nested"]);
expect(notFound).toEqual([]);
});

it("matches as an include does, by byte, from the component's directory", async () => {
const { components } = await marked(
[
"a/deep/x.txt",
"b/x/y/z.md",
bytes("c/\u00e9.csv"),
bytes("\u30c7\u30fc\u30bf/rows.csv"),
"e/.hidden",
"f/[x].txt",
],
{
// A literal path takes in what is under it.
"app/a": { marker: "deep" },
"app/b": { marker: "x/**/*.md" },
// '?' is one byte, and the name is two.
"app/c": { marker: "?.csv" },
"app/\u30c7\u30fc\u30bf": { marker: "*.csv" },
"app/e": { marker: "*" },
"app/f": { marker: "\\[x\\].txt" },
},
);
expect(Object.keys(components.found ?? {})).toEqual([
"app/a",
"app/b",
"app/e",
"app/f",
"app/\u30c7\u30fc\u30bf",
]);
expect(components.gone).toEqual(["app/c"]);
const two = await marked([bytes("c/\u00e9.csv")], { "app/c": { marker: "??.csv" } });
expect(two.components.found).toEqual({ "app/c": { kinds: ["marker"], status: "known" } });
});

it("finds nothing for a key that names a file, a repo itself or no repo, or only starts like a directory", async () => {
const { components } = await marked(["README.md", "content/a.md", "contents.txt", "x/y/z.md"], {
"app/README.md": { marker: "**" },
"app/con": { marker: "**" },
"app/x/y/z.md": { marker: "**" },
app: { marker: "**" },
"app/": { marker: "**" },
"other/content": { marker: "**" },
});
expect(components.found).toEqual({});
expect(components.gone).toEqual([
"app",
"app/",
"app/README.md",
"app/con",
"app/x/y/z.md",
"other/content",
]);
});

it("does not skip the directories the built-in markers skip", async () => {
const { components } = await marked(
["vendor/lib/a.php", "web/node_modules/x/index.js", "web/dist/package.json"],
{ "app/vendor/lib": { marker: "*.php" }, "app/web": { marker: "**" } },
);
expect(components.found).toEqual({
"app/vendor/lib": { kinds: ["marker"], status: "known" },
"app/web": { kinds: ["marker"], status: "known" },
});
});

it("looks in the repo the key names", async () => {
const trees: Record<string, string[]> = { ".": ["data/a.md"], "../site": ["pages/b.md"] };
const { components } = await measureComponents({
repos: [".", "../site"],
names: new Map([
[".", "app"],
["../site", "site"],
]),
worktreeOf: (repo) => tree(trees[repo] ?? [])(),
registered: {
"app/data": { marker: "*.md" },
"app/pages": { marker: "*.md" },
"site/pages": { marker: "*.md" },
"site/data": { marker: "*.md" },
},
});
expect(Object.keys(components.found ?? {})).toEqual(["app/data", "site/pages"]);
expect(components.gone).toEqual(["app/pages", "site/data"]);
});

it("is a kind like any other against the previous record", async () => {
const { components } = await marked(
["web/package.json", "content/a.md", "tools/x.sh"],
{ "app/web": { marker: "**" }, "app/content": { marker: "*.md" }, "app/tools": {} },
{
status: "found",
found: {
"app/web": { kinds: ["manifest"] },
"app/content": { kinds: ["marker"] },
"app/tools": { kinds: ["marker"] },
},
},
);
expect(components.kind_changed).toEqual([
{ key: "app/web", before: ["manifest"], after: ["manifest", "marker"] },
]);
// Its marker taken off, a directory no built-in marker finds is gone.
expect(components.gone).toEqual(["app/tools"]);
});

it("matches a name that is not valid UTF-8 as its bytes are", async () => {
const { components } = await marked(["g/\xff.csv", "h/\xff.csv"], {
"app/g": { marker: "?.csv" },
"app/h": { marker: "??.csv" },
});
expect(Object.keys(components.found ?? {})).toEqual(["app/g"]);
expect(components.gone).toEqual(["app/h"]);
});

it("reads the path of a key as written, not as a glob", async () => {
const { components } = await marked(["[slug]/a.md", "x/a.md", "s/a.md"], {
"app/[slug]": { marker: "*.md" },
"app/*": { marker: "*.md" },
"app/[s]": { marker: "*.md" },
});
expect(Object.keys(components.found ?? {})).toEqual(["app/[slug]"]);
expect(components.gone).toEqual(["app/*", "app/[s]"]);
});

it("finds nothing for a key whose path git would not write, which is not normalized", async () => {
const { components } = await marked(["content/a.md"], {
"app/content/": { marker: "**" },
"app//content": { marker: "**" },
"app/./content": { marker: "**" },
});
expect(components.found).toEqual({});
expect(components.gone).toEqual(["app/./content", "app//content", "app/content/"]);
});

it("finds nothing for a key whose path is not well-formed text", async () => {
const { components } = await marked([bytes("\ufffd/a.md")], {
"app/\ud800": { marker: "**" },
"app/\ufffd": { marker: "**" },
});
expect(Object.keys(components.found ?? {})).toEqual(["app/\ufffd"]);
expect(components.gone).toEqual(["app/\ud800"]);
});
});
50 changes: 43 additions & 7 deletions packages/core/src/board/components.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { fromBytes } from "./glob.js";
import { compileGlob, fromBytes, toBytes } from "./glob.js";
import type { PreviousOutcome } from "./previous.js";
import type { RepoScopeResult } from "./scope.js";

/**
* The version of how the `components` section measures. Raised whenever a
* value of the section would change for the same repositories, declaration
* and previous record, as when a rule for a marker changes.
* and previous record, as when a rule for a marker changes. A component's
* own `marker` did not raise it: a registry without one measures as before.
*/
export const BOARD_COMPONENTS_METHOD = 2;

// The kind a component is found as when its own marker finds a file.
const MARKER_KIND = "marker";

// A file under one of these directories is not a marker.
const SKIP = new Set([
"node_modules",
Expand Down Expand Up @@ -62,6 +66,13 @@ export type BoardComponentChange = { key: string; before: string[]; after: strin
* `components`. A key is the name of the directory the repository is in,
* then the directory of the component inside it.
*
* A registered component with a `marker` is also found, with the kind
* `marker`, when a file the working tree lists under its directory matches
* the marker from there, as an `include` matches from the repository's root;
* the built-in markers' skipped directories (such as `node_modules`) are not
* skipped for it. A key that names a file, or no directory of a repository,
* finds nothing.
*
* Every value is null, with one entry at `components`, when a repository
* cannot be opened, git could not list all of its working tree, two
* repositories are in directories of the same name, or a component's
Expand Down Expand Up @@ -102,7 +113,7 @@ export type ComponentsInput = {
/** The working tree of a repository, by its path. */
worktreeOf: (repo: string) => Promise<RepoScopeResult>;
/** The declaration's components, by key. */
registered: Readonly<Record<string, unknown>>;
registered: Readonly<Record<string, { readonly marker?: string | undefined }>>;
/** The components of the previous record (default: there is none). */
previous?: PreviousComponents;
};
Expand Down Expand Up @@ -133,6 +144,13 @@ export async function measureComponents(input: ComponentsInput): Promise<{
}

const kinds = new Map<string, Set<string>>();
const mark = (key: string, kind: string) => {
const found = kinds.get(key) ?? new Set<string>();
found.add(kind);
kinds.set(key, found);
};
// The files of each repository's working tree, by the name of its directory.
const listed = new Map<string, ReadonlyMap<string, unknown>>();
let undecodable: string | undefined;
for (const [name, path] of byName) {
const opened = await input.worktreeOf(path);
Expand All @@ -145,15 +163,33 @@ export async function measureComponents(input: ComponentsInput): Promise<{
undecodable ??= `in the repo '${path}', the directory '${fromBytes(dir)}' is not a valid UTF-8 name, so its component cannot be told apart from others`;
return;
}
const key = text === "" ? name : `${name}/${text}`;
const found = kinds.get(key) ?? new Set<string>();
found.add(kind);
kinds.set(key, found);
mark(text === "" ? name : `${name}/${text}`, kind);
};
for (const file of opened.scope.entries.keys()) classify(file, add);
listed.set(name, opened.scope.entries);
}
if (undecodable !== undefined) return unmeasured(undecodable);

// A registered component's own marker, matched from its directory.
for (const [key, component] of Object.entries(input.registered)) {
const slash = key.indexOf("/");
if (component.marker === undefined || slash === -1) continue;
const files = listed.get(key.slice(0, slash));
if (files === undefined) continue;
const dir = key.slice(slash + 1);
// A path that is not well-formed text (a lone surrogate) names no
// directory: its bytes would be those of U+FFFD.
if (fromBytes(toBytes(dir)) !== dir) continue;
const under = `${toBytes(dir)}/`;
const matches = compileGlob(component.marker);
for (const file of files.keys()) {
if (file.startsWith(under) && matches(file.slice(under.length))) {
mark(key, MARKER_KIND);
break;
}
}
}

const registered = new Set(Object.keys(input.registered));
const previous = input.previous ?? { status: "none" };
const before = previous.status === "found" ? previous.found : {};
Expand Down
Loading
Loading