Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(api-markdown-documenter): Add {@link} tag reference validation to linter #22224

Merged
merged 69 commits into from
Aug 16, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
69 commits
Select commit Hold shift + click to select a range
5efe339
improvement: Add options type for `loadModel` and fix logger pattern
Josmithr Aug 5, 2024
e6c54c6
test: Update test suite with invalid `@inheritDoc` tag example
Josmithr Aug 6, 2024
8ea4657
test: Add loadModel unit tests
Josmithr Aug 6, 2024
41fbade
refactor: Add noopLogger
Josmithr Aug 6, 2024
b794944
fix: API report file name
Josmithr Aug 6, 2024
331131c
test: Fix incorrect test
Josmithr Aug 6, 2024
6f0a8c2
refactor: Move reference resolution utilities
Josmithr Aug 6, 2024
f3d1256
improvement: Quote item name in error
Josmithr Aug 6, 2024
599ad7d
feat: Add initial linter logic
Josmithr Aug 6, 2024
6a8aabd
refactor: Update `loadModel` to take an options object
Josmithr Aug 6, 2024
dd64318
test: Add `loadModel` unit tests
Josmithr Aug 6, 2024
32d9b3f
test: Fix API report name
Josmithr Aug 6, 2024
3d39059
refactor: Update tests to use `loadModel`
Josmithr Aug 6, 2024
c139bd0
test: Fix test
Josmithr Aug 6, 2024
563b38a
test: Add "empty model" test
Josmithr Aug 6, 2024
9fa3d16
test: Simplify test logic
Josmithr Aug 6, 2024
10a3175
docs: Update tense
Josmithr Aug 6, 2024
960c6e1
refactor: Make `modelDirectoryPath` readonly
Josmithr Aug 6, 2024
02d2621
Merge branch 'api-markdown-documenter/load-model-cleanup' of https://…
Josmithr Aug 6, 2024
8a69234
Merge branch 'api-markdown-documenter/load-model-cleanup' into api-ma…
Josmithr Aug 6, 2024
7fb940a
docs: `@throws` comment
Josmithr Aug 6, 2024
7cc7897
test: Add invalid model tests for the linter.
Josmithr Aug 6, 2024
6a77728
docs: `@throws` comments
Josmithr Aug 6, 2024
eacd6ab
docs: Formatting
Josmithr Aug 6, 2024
c14592e
improvement: Quote type in error
Josmithr Aug 6, 2024
e038e38
docs: Fix typo
Josmithr Aug 6, 2024
e233357
[WIP] Add base TSDoc node transformation infra
Josmithr Aug 6, 2024
7fcfc5d
style: Formatting
Josmithr Aug 6, 2024
ca6c8dc
tools: Add fence
Josmithr Aug 6, 2024
b054333
feat: Implement `@link` validation
Josmithr Aug 7, 2024
40dbb3b
docs: TODOs
Josmithr Aug 7, 2024
e83694f
docs: TODO
Josmithr Aug 7, 2024
cdf9fcb
Merge branch 'main' into api-markdown-documenter/inheritdoc-linting
Josmithr Aug 7, 2024
ef85188
docs: Remove duplicated changelog entry
Josmithr Aug 7, 2024
91d96f0
refactor: Rename modules
Josmithr Aug 7, 2024
fa4dc4a
test: Update test name
Josmithr Aug 7, 2024
90e2e23
remove: No-op function (will be added in a future PR)
Josmithr Aug 7, 2024
82bd1b2
Merge branch 'main' into api-markdown-documenter/inheritdoc-linting
Josmithr Aug 7, 2024
457a2aa
Merge branch 'api-markdown-documenter/inheritdoc-linting' into api-ma…
Josmithr Aug 7, 2024
a349263
docs: Expand `@throws` documentation
Josmithr Aug 9, 2024
46748f8
docs: Add documentation
Josmithr Aug 9, 2024
9dce26f
refactor: Update API to return error objects rather than throwing.
Josmithr Aug 9, 2024
af4498a
docs: Expand comment
Josmithr Aug 9, 2024
f92a668
Merge branch 'main' into api-markdown-documenter/inheritdoc-linting
Josmithr Aug 14, 2024
501a9c5
Merge branch 'api-markdown-documenter/inheritdoc-linting' into api-ma…
Josmithr Aug 14, 2024
09a7085
refactor: Update linting pattern
Josmithr Aug 14, 2024
d220998
Merge branch 'main' into api-markdown-documenter/inheritdoc-linting
Josmithr Aug 14, 2024
60bfdae
Merge branch 'api-markdown-documenter/inheritdoc-linting' of https://…
Josmithr Aug 14, 2024
4d212f8
refactor: Take API model directly
Josmithr Aug 14, 2024
5597973
refactor: Less nesting
Josmithr Aug 14, 2024
705e546
revert: Version change
Josmithr Aug 14, 2024
291eac7
refactor: Rename param
Josmithr Aug 15, 2024
8a40181
Merge branch 'api-markdown-documenter/inheritdoc-linting' of https://…
Josmithr Aug 15, 2024
4be377c
Merge branch 'api-markdown-documenter/inheritdoc-linting' into api-ma…
Josmithr Aug 15, 2024
3617054
fix: Imports
Josmithr Aug 15, 2024
22e3ca0
test: Simplify test pattern
Josmithr Aug 15, 2024
227d1a8
docs: Fix comment
Josmithr Aug 15, 2024
40d519a
Squashed commit of the following:
Josmithr Aug 15, 2024
5f3b3ba
test: Empty model test case
Josmithr Aug 15, 2024
177b663
Merge branch 'main' into api-markdown-documenter/link-linter
Josmithr Aug 15, 2024
4ee483c
remove: Malformed tag errors (will add in a separate PR)
Josmithr Aug 15, 2024
91ad3d6
remove: TSDoc transformation domain
Josmithr Aug 15, 2024
e38a1e8
docs: Update comment
Josmithr Aug 15, 2024
b51f088
remove: Duplicated test
Josmithr Aug 15, 2024
d7b6f02
docs: Restore comment
Josmithr Aug 15, 2024
a2023dd
improvement: Remove unneeded assert
Josmithr Aug 16, 2024
4dacb5f
Merge branch 'main' into api-markdown-documenter/link-linter
Josmithr Aug 16, 2024
debedd0
remove: Error param
Josmithr Aug 16, 2024
6b2f9f7
docs: Add docs :)
Josmithr Aug 16, 2024
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
172 changes: 169 additions & 3 deletions tools/api-markdown-documenter/src/LintApiModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,23 @@
* Licensed under the MIT License.
*/

import { fail } from "node:assert";
import { fail, strict as assert } from "node:assert";
import {
ApiDocumentedItem,
type ApiItem,
ApiItemContainerMixin,
type ApiModel,
} from "@microsoft/api-extractor-model";
import type { DocInheritDocTag } from "@microsoft/tsdoc";
import {
DocBlock,
type DocComment,
type DocInheritDocTag,
DocInlineTag,
type DocLinkTag,
type DocNode,
DocNodeContainer,
DocNodeKind,
} from "@microsoft/tsdoc";
import { defaultConsoleLogger } from "./Logging.js";
import { resolveSymbolicReference } from "./utilities/index.js";
import type { ConfigurationBase } from "./ConfigurationBase.js";
Expand Down Expand Up @@ -76,6 +85,8 @@ interface MutableLinterErrors {
* Errors found during linting.
*/
export interface LinterErrors {
// TODO: malformed tag errors
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future PR


/**
* Errors related to reference tags (e.g., `link` or `inheritDoc` tags) with invalid targets.
*/
Expand Down Expand Up @@ -119,6 +130,8 @@ export async function lintApiModel(

/**
* Recursively validates the given API item and all its descendants within the API model.
*
* @remarks Populates `errors` with any errors encountered during validation.
*/
function lintApiItem(
apiItem: ApiItem,
Expand All @@ -145,7 +158,8 @@ function lintApiItem(
}
}

// TODO: Check other TSDoc contents
// Check TSDoc contents
lintComment(apiItem.tsdocComment, apiItem, apiModel, errors);
}

// If the item has children, recursively validate them.
Expand All @@ -156,6 +170,158 @@ function lintApiItem(
}
}

/**
* Validates a TSDoc comment associated with an API item.
*
* @remarks Populates `errors` with any errors encountered during validation.
*/
function lintComment(
comment: DocComment,
associatedItem: ApiDocumentedItem,
apiModel: ApiModel,
errors: MutableLinterErrors,
): void {
checkTagsUnderTsdocNode(comment.summarySection, associatedItem, apiModel, errors);

if (comment.deprecatedBlock !== undefined) {
checkTagsUnderTsdocNode(comment.deprecatedBlock, associatedItem, apiModel, errors);
}

if (comment.remarksBlock !== undefined) {
checkTagsUnderTsdocNode(comment.remarksBlock, associatedItem, apiModel, errors);
}

if (comment.privateRemarks !== undefined) {
checkTagsUnderTsdocNode(comment.privateRemarks, associatedItem, apiModel, errors);
}

checkTagsUnderTsdocNodes(comment.params.blocks, associatedItem, apiModel, errors);

checkTagsUnderTsdocNodes(comment.typeParams.blocks, associatedItem, apiModel, errors);

checkTagsUnderTsdocNodes(comment.customBlocks, associatedItem, apiModel, errors);
}

/**
* Validates the provided TSDoc node and its children.
*
* @remarks Populates `errors` with any errors encountered during validation.
* Co-recursive with {@link checkTagsUnderTsdocNodes}.
*/
function checkTagsUnderTsdocNode(
node: DocNode,
associatedItem: ApiDocumentedItem,
apiModel: ApiModel,
errors: MutableLinterErrors,
): void {
switch (node.kind) {
// Nodes under which links cannot occur
case DocNodeKind.CodeSpan:
case DocNodeKind.BlockTag:
case DocNodeKind.EscapedText:
case DocNodeKind.FencedCode:
case DocNodeKind.HtmlStartTag:
case DocNodeKind.HtmlEndTag:
case DocNodeKind.PlainText:
case DocNodeKind.SoftBreak: {
break;
}
// Nodes with children ("content")
case DocNodeKind.Block:
case DocNodeKind.ParamBlock: {
assert(node instanceof DocBlock, 'Expected a "DocBlock" node.');
checkTagsUnderTsdocNode(node.content, associatedItem, apiModel, errors);
break;
}
// Nodes with children ("nodes")
case DocNodeKind.Paragraph:
case DocNodeKind.Section: {
assert(node instanceof DocNodeContainer, 'Expected a "DocNodeContainer" node.');
checkTagsUnderTsdocNodes(node.nodes, associatedItem, apiModel, errors);
break;
}
case DocNodeKind.InlineTag: {
assert(node instanceof DocInlineTag, 'Expected a "DocInlineTag" node.');
// TODO: malformed tag errors
break;
}
case DocNodeKind.LinkTag: {
const result = checkLinkTag(node as DocLinkTag, associatedItem, apiModel);
if (result !== undefined) {
errors.referenceErrors.add(result);
}
break;
}
case DocNodeKind.InheritDocTag: {
// See notes in `lintApiItem` for why we handle `@inheritDoc` tags are not expected or handled here.
fail(
"Encountered an @inheritDoc tag while walking a TSDoc tree. API-Extractor resolves such tags at a higher level, so this is unexpected.",
TommyBrosman marked this conversation as resolved.
Show resolved Hide resolved
);
}
default: {
throw new Error(`Unsupported DocNode kind: "${node.kind}".`);
}
}
}

/**
* Validates the provided TSDoc nodes and their children.
*
* @remarks Populates `errors` with any errors encountered during validation.
* Co-recursive with {@link checkTagsUnderTsdocNode}.
*/
function checkTagsUnderTsdocNodes(
nodes: readonly DocNode[],
associatedItem: ApiDocumentedItem,
apiModel: ApiModel,
errors: MutableLinterErrors,
): void {
for (const node of nodes) {
checkTagsUnderTsdocNode(node, associatedItem, apiModel, errors);
}
}

/**
* Validates the provided link tag, ensuring that the target reference is valid within the API model.
*
* @returns An error, if the link tag's target reference is invalid.
* Otherwise, `undefined`.
*/
function checkLinkTag(
linkTag: DocLinkTag,
apiItem: ApiItem,
apiModel: ApiModel,
): ReferenceError | undefined {
Josmithr marked this conversation as resolved.
Show resolved Hide resolved
// If the link tag was parsed correctly (which we know it was in this case, because we have a `DocLinkTag`), then we don't have to worry about syntax validation.

// If the link points to some external URL, no-op.
// In the future, we could potentially leverage some sort of URL validator here,
// but for now our primary concern is validating symbolic links.
if (linkTag.urlDestination !== undefined) {
return undefined;
}

assert(
linkTag.codeDestination !== undefined,
"Expected a `codeDestination` or `urlDestination` to be defined, but neither was.",
);

// If the link is a symbolic reference, validate it.
try {
resolveSymbolicReference(apiItem, linkTag.codeDestination, apiModel);
} catch {
return {
tagName: "@link",
sourceItem: apiItem.getScopedNameWithinPackage(),
packageName: apiItem.getAssociatedPackage()?.name ?? fail("Package name not found"),
referenceTarget: linkTag.codeDestination.emitAsTsdoc(),
linkText: linkTag.linkText,
};
}

return undefined;
}

/**
* Checks the provided API item's `{@inheritDoc}` tag, ensuring that the target reference is valid within the API model.
*/
Expand Down
29 changes: 25 additions & 4 deletions tools/api-markdown-documenter/src/test/LintApiModel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,45 @@
import * as Path from "node:path";
import { fileURLToPath } from "node:url";

import { ApiModel } from "@microsoft/api-extractor-model";
import { expect } from "chai";

import { lintApiModel, type ReferenceError, type LinterErrors } from "../LintApiModel.js";
import { lintApiModel, type LinterErrors } from "../LintApiModel.js";
import { loadModel } from "../LoadModel.js";

const dirname = Path.dirname(fileURLToPath(import.meta.url));
const testModelsDirectoryPath = Path.resolve(dirname, "..", "..", "src", "test", "test-data");

describe("lintApiModel", () => {
// TODO: add case with no errors
it("Empty API Model yields no errors", async () => {
const apiModel = new ApiModel();
const result = await lintApiModel({ apiModel });

expect(result).to.be.undefined;
});

it("API Model with invalid links yields the expected errors", async () => {
const modelDirectoryPath = Path.resolve(testModelsDirectoryPath, "simple-suite-test");

const apiModel = await loadModel({ modelDirectoryPath });

const expected: LinterErrors = {
referenceErrors: new Set<ReferenceError>([
// TODO: add other expected errors once they are validated
referenceErrors: new Set([
{
tagName: "@link",
sourceItem: "", // link appears in package documentation
packageName: "simple-suite-test",
referenceTarget: "InvalidItem",
linkText: undefined,
},
{
tagName: "@link",
sourceItem: "", // link appears in package documentation
packageName: "simple-suite-test",
referenceTarget: "InvalidItem",
linkText:
"even though I link to an invalid item, I would still like this text to be rendered",
},
{
tagName: "@inheritDoc",
sourceItem: "TestInterface.propertyWithBadInheritDocTarget",
Expand Down
Loading