Add knowledge-backed AL development

Add read-only planning and repository-changing development skills so BCQuality
knowledge can guide features, bug fixes, refactors, upgrades, and maintenance
before the existing AL review gate runs. Track Microsoft Learn ingestion and
add development and BCApps-shaped guidance evaluation fixtures.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 638b66d2-9f06-4f60-8781-808709e1485c
This commit is contained in:
Jesper Schulz-Wedde 2026-09-04 12:53:54 +02:00
parent 1a5afdc0eb
commit 56b80e6dcf
50 changed files with 11589 additions and 79 deletions

View file

@ -8,8 +8,8 @@
{
"name": "bcquality",
"source": "./",
"description": "Business Central AL quality knowledge base and review skills, packaged as an installable plugin. Exposes an AL review adapter while preserving BCQuality's internal Entry and action-skill protocols.",
"version": "0.2.0",
"description": "Business Central AL quality knowledge base and skills, packaged as an installable plugin. Exposes development and review adapters while preserving BCQuality's internal Entry and action-skill protocols.",
"version": "0.3.0",
"skills": [
"./skills/"
]

View file

@ -39,6 +39,7 @@ ACTION_SKILL_REQUIRED_KEYS = {
}
ACTION_SKILL_OPTIONAL_KEYS = {
"bc-version", "technologies", "countries", "application-area", "sub-skills",
"quality-skill", "guidance-skill",
}
META_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"}
ENTRY_SKILL_REQUIRED_KEYS = {"kind", "id", "version", "title"}
@ -46,8 +47,11 @@ HOST_SKILL_REQUIRED_KEYS = {"name", "description"}
STANDARD_INPUTS = {
"pr-diff", "object-list", "file-path", "repository", "telemetry-query",
"development-request", "development-plan",
}
ALLOWED_OUTPUTS = {
"findings-report", "implementation-report", "development-guidance-report",
}
ALLOWED_OUTPUTS = {"findings-report"}
VALID_SAMPLE_KINDS = {"good", "bad"}
ACTION_SKILL_SECTIONS = ["Source", "Relevance", "Worklist", "Action", "Output"]
@ -340,9 +344,11 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None:
if not is_non_empty_list_of_str(out):
report.error(path, "R18", "outputs must be a non-empty list of strings", 1)
else:
if len(out) != 1:
report.error(path, "R18", "outputs must contain exactly one output kind", 1)
bad = [x for x in out if x not in ALLOWED_OUTPUTS]
if bad:
report.error(path, "R18", f"outputs contains non-allowed values {bad}; currently only {sorted(ALLOWED_OUTPUTS)} is defined", 1)
report.error(path, "R18", f"outputs contains non-allowed values {bad}; allowed values are {sorted(ALLOWED_OUTPUTS)}", 1)
# R19 optional filter dimensions, if present
if "bc-version" in fm:
@ -382,6 +388,20 @@ def validate_action_skill(path: Path, parsed: Parsed, report: Report) -> None:
if bad:
report.error(path, "R20", f"sub-skills entries must end in '.md': {bad}", 1)
if "quality-skill" in fm:
quality_skill = fm["quality-skill"]
if not isinstance(quality_skill, str) or not quality_skill.endswith(".md"):
report.error(path, "R31", "quality-skill must be one repo-relative .md path", 1)
if fm.get("outputs") != ["implementation-report"]:
report.error(path, "R31", "quality-skill is valid only with outputs: [implementation-report]", 1)
if "guidance-skill" in fm:
guidance_skill = fm["guidance-skill"]
if not isinstance(guidance_skill, str) or not guidance_skill.endswith(".md"):
report.error(path, "R32", "guidance-skill must be one repo-relative .md path", 1)
if fm.get("outputs") != ["implementation-report"]:
report.error(path, "R32", "guidance-skill is valid only with outputs: [implementation-report]", 1)
# R21 five required sections, in order, each exactly once
heads = [h for h, _ in headings_in_order(parsed.body)]
indices: list[int] = []
@ -603,6 +623,58 @@ def validate_sub_skills_registry(path: Path, fm: dict[str, Any], root: Path, rep
report.error(path, "R26", f"leaf not registered in sub-skills: {leaf}", 1)
def validate_quality_skill(path: Path, fm: dict[str, Any], root: Path, report: Report) -> None:
"""R30: implementation quality-skill paths resolve to a findings producer."""
quality_skill = fm.get("quality-skill")
if not isinstance(quality_skill, str) or not quality_skill.endswith(".md"):
return
normalized = quality_skill.lstrip("./")
target = root / normalized
if not target.is_file():
report.error(path, "R30", f"quality-skill does not exist on disk: {normalized}", 1)
return
if target.resolve() == path.resolve():
report.error(path, "R30", "quality-skill must not reference the implementation skill itself", 1)
return
try:
target_parsed = parse_markdown(target.read_text(encoding="utf-8"))
except UnicodeDecodeError as e:
report.error(path, "R30", f"quality-skill is not valid UTF-8: {e}", 1)
return
target_outputs = (target_parsed.frontmatter or {}).get("outputs")
if target_outputs != ["findings-report"]:
report.error(path, "R30", f"quality-skill must emit findings-report: {normalized}", 1)
def validate_guidance_skill(path: Path, fm: dict[str, Any], root: Path, report: Report) -> None:
"""R33: implementation guidance-skill paths resolve to a read-only planner."""
guidance_skill = fm.get("guidance-skill")
if not isinstance(guidance_skill, str) or not guidance_skill.endswith(".md"):
return
normalized = guidance_skill.lstrip("./")
target = root / normalized
if not target.is_file():
report.error(path, "R33", f"guidance-skill does not exist on disk: {normalized}", 1)
return
if target.resolve() == path.resolve():
report.error(path, "R33", "guidance-skill must not reference the implementation skill itself", 1)
return
try:
target_parsed = parse_markdown(target.read_text(encoding="utf-8"))
except UnicodeDecodeError as e:
report.error(path, "R33", f"guidance-skill is not valid UTF-8: {e}", 1)
return
target_fm = target_parsed.frontmatter or {}
if target_fm.get("outputs") != ["development-guidance-report"]:
report.error(path, "R33", f"guidance-skill must emit development-guidance-report: {normalized}", 1)
if "development-plan" not in (target_fm.get("inputs") or []):
report.error(path, "R33", f"guidance-skill must accept development-plan: {normalized}", 1)
def run(root: Path) -> Report:
report = Report()
skill_records: list[SkillRecord] = []
@ -670,9 +742,11 @@ def run(root: Path) -> Report:
others = [q.relative_to(root).as_posix() for q in paths if q != p]
report.error(p, "R24", f"skill id '{sid}' ({kind}) is not unique; also defined in: {others}")
# Fourth pass: R26 sub-skills registry matches leaf files on disk
# Fourth pass: cross-skill references
for path, fm in action_skill_fms:
validate_sub_skills_registry(path, fm, root, report)
validate_quality_skill(path, fm, root, report)
validate_guidance_skill(path, fm, root, report)
return report

View file

@ -0,0 +1,26 @@
name: Validate development coverage
on:
pull_request:
branches: [main]
push:
branches: [main]
jobs:
validate-development-coverage:
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Validate Microsoft Learn coverage ledger
shell: pwsh
run: ./tools/Test-LearnCoverage.ps1 -Root .
- name: Validate and prepare development fixtures
shell: pwsh
run: ./tools/Test-DevelopmentFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-development-fixtures"
- name: Validate and prepare development-guidance fixtures
shell: pwsh
run: ./tools/Test-DevelopmentGuidanceFixtures.ps1 -Root . -PrepareDirectory "$env:RUNNER_TEMP/bcquality-development-guidance-fixtures"

View file

@ -56,7 +56,7 @@ Skills define how agents consume knowledge. They come in three flavors:
READ and DO are read on demand — typically when the first dispatched action skill runs. They are not prerequisites for invoking Entry. WRITE is only used when scaffolding new content.
- **Action skills** — concrete skills that follow the Action Skill template to do real work (review code, audit telemetry, etc.). Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). An action skill is either a **leaf** that evaluates knowledge files directly, or a **super-skill** that composes other action skills (declared via `sub-skills` in frontmatter). The canonical reference is [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) (super-skill), which composes the AL review leaf skills under [`microsoft/skills/review/`](microsoft/skills/review/) — one per knowledge domain.
- **Action skills** — concrete skills that follow the Action Skill template to do real work. Review skills emit findings reports; read-only planning skills emit development-guidance reports; implementation skills emit implementation reports. Action skills live inside the layers that own them (`/microsoft/skills/`, `/community/skills/`, `/custom/skills/`). [`microsoft/skills/development/al-development-plan.md`](microsoft/skills/development/al-development-plan.md) turns an existing plan into knowledge constraints, [`microsoft/skills/development/al-development.md`](microsoft/skills/development/al-development.md) consumes those constraints while implementing features, bugs, refactors, upgrades, and maintenance, and [`microsoft/skills/review/al-code-review.md`](microsoft/skills/review/al-code-review.md) provides the final quality gate.
### Agent bootstrapping
@ -64,10 +64,10 @@ An orchestrator (such as AL-Go) points the agent at BCQuality's URL and provides
### Standalone plugin installation
BCQuality can also be installed directly as a plugin. The plugin registers one
host-native skill,
[`al-code-review`](skills/al-code-review/SKILL.md), which adapts the caller's
request to the same Entry protocol used by orchestrators.
BCQuality can also be installed directly as a plugin. The plugin registers
host-native adapters for [`al-code-review`](skills/al-code-review/SKILL.md) and
[`al-development`](skills/al-development/SKILL.md). Both adapt
the caller's request to the same Entry protocol used by orchestrators.
For GitHub Copilot CLI:
@ -81,32 +81,37 @@ must be updated. The name remains distinct from BC-ALAgents' public
`al-review` skill because current hosts may load plugin skill names into one
shared inventory.
The adapter is intentionally not a second review implementation:
Plugin version `0.3.0` adds `al-development`, the knowledge-backed
implementation adapter for features, bugs, refactors, upgrades, and maintenance.
The adapters are intentionally not second implementations:
```text
standalone host skill: skills/al-code-review/SKILL.md
-> routing contract: skills/entry.md
-> review coordinator: microsoft/skills/review/al-code-review.md
-> domain review leaves
standalone host skill: skills/al-development/SKILL.md
-> routing contract: skills/entry.md
-> implementation skill: microsoft/skills/development/al-development.md
-> knowledge-guided implementation + AL review quality gate
```
Only the first file follows the host's `SKILL.md` packaging format. The
remaining files are BCQuality's internal protocol and layered action skills.
Entry remains the single owner of routing and index preparation;
`al-code-review.md` remains the single owner of broad-review composition. This
separation keeps standalone installation available without duplicating those
policies in the plugin adapter.
Only the files under `skills/*/SKILL.md` follow the host's packaging format.
The remaining files are BCQuality's internal protocol and layered action
skills. Entry remains the single owner of routing and index preparation. This
separation keeps standalone installation available without duplicating policy
in either adapter.
Note that a plugin install ships the entire tree, so `BCQUALITY_ENABLED_LAYERS`
narrows discovery without removing any files. Layer selection is a filter here,
not a deny mechanism — see [the adapter](skills/al-code-review/SKILL.md) for the
difference from the pruned-clone model.
The host adapter and internal action skill intentionally share the
`al-code-review` name: they expose the same operation in two different skill
formats. Their paths make the boundary explicit. The adapter lives under
`skills/al-code-review/SKILL.md`; the internal Microsoft-layer coordinator
lives at `microsoft/skills/review/al-code-review.md`.
Each host adapter and internal action skill intentionally share a name: they
expose the same operation in two different skill formats. Their paths make the
boundary explicit.
## Knowledge file format
@ -138,10 +143,29 @@ Code examples belong in separate files, not in the knowledge file itself. Knowle
## Scope
The current curated corpus is focused on **technical AL code review**: Agents, AppSource and compatibility, data modeling, error handling, events, interfaces, performance, privacy, Query objects, security, style, telemetry, testing, UI, upgrade, and web services. These are the domains backed by knowledge files and registered review leaves today.
The current curated corpus covers technical AL concerns across Agents, AppSource and compatibility, data modeling, error handling, events, interfaces, performance, privacy, Query objects, security, style, telemetry, testing, UI, upgrade, and web services. Review skills evaluate existing changes against those domains. The `al-development` skill applies them before and during implementation, then runs the review coordinator as a final gate.
Repository-specific orchestrators do not need to delegate implementation to
`al-development`. They can invoke `al-development-plan` with their existing
plan, feed its read-only guidance report into their own phases, and retain their
specialized environment, test, propagation, and delivery gates.
Business Central functional domains (Finance, Supply Chain Management, Manufacturing, Jobs, Warehousing, Service), PowerShell, pipelines, and Power Platform remain valid future repository scope, but they are **not current coverage claims** until corresponding knowledge and action skills exist. Consumers should derive supported review scope from the live knowledge index and dispatched skills, not from roadmap breadth.
## Tracking developer coverage
BCQuality tracks source ingestion and implementation capability separately:
- [`coverage/microsoft-learn-developer-catalog.json`](coverage/microsoft-learn-developer-catalog.json) is the generated inventory of Business Central developer training.
- [`coverage/learn-coverage.json`](coverage/learn-coverage.json) records editorial progress and the disposition of each extracted concern.
- [`coverage/development-capabilities.json`](coverage/development-capabilities.json) tracks representative development capabilities and their evaluation fixtures.
Run `pwsh ./tools/Test-LearnCoverage.ps1` for current source progress and
`pwsh ./tools/Test-DevelopmentFixtures.ps1` for capability coverage. Article
count alone is not a completion metric: a capability becomes `validated` only
after its generated implementation passes compilation, tests, and the review
quality gate.
## How agents consume BCQuality
Action skills follow a four-step pattern:
@ -151,7 +175,7 @@ Action skills follow a four-step pattern:
3. **Worklist** — narrow from N candidates to the M that apply to the current task
4. **Action** — apply the relevant knowledge and produce structured output
Every action skill produces output in a common format that orchestrators can consume without skill-specific parsing. The format is JSON and includes an `outcome` (so a clean run, a not-applicable skill, and a partial failure are all distinguishable), `findings` (what the skill observed), structured `references` back to the knowledge files that informed each finding, per-finding `confidence`, and a `suppressed` list recording any knowledge files overridden by layer precedence. This contract is defined in the Action Skill meta-skill so that orchestrators and action skills remain independently evolvable.
Every action skill declares one structured JSON output. Review skills emit a `findings-report`; planning skills emit a read-only `development-guidance-report`; implementation skills emit an `implementation-report` containing the plan, knowledge used, changed files, real validation results, and post-implementation review. All contracts are defined in the Action Skill meta-skill so orchestrators and action skills remain independently evolvable.
BCQuality is an **additive** knowledge layer: it augments the agent's review judgement, it does not replace it. Super-skills (such as `al-code-review`) run a self-review pass alongside their sub-skills and surface concerns the agent identified on its own, marked with `from-sub-skill: "agent"` and an empty `references: []` so consumers can render them distinctly from knowledge-backed findings. See [agent-consumption.md](agent-consumption.md) and [`skills/do.md`](skills/do.md) for the full contract.
@ -163,7 +187,8 @@ For the end-to-end flow — from orchestrator trigger through to how output reac
```
├── /skills/ # Global: entry-point skill + meta-skill contracts (READ, DO, WRITE)
├── /evaluation/ # Neutral good/bad review fixtures and scoring contract
├── /coverage/ # Source-ingestion ledger and development capability matrix
├── /evaluation/ # Review and development evaluation fixtures
├── /.github/ # Actions and workflows
├── /microsoft/ # Microsoft-endorsed layer
│ ├── /knowledge/ # Knowledge files by domain

View file

@ -13,8 +13,10 @@ For the high-level framing and repo structure, start with the [README](README.md
- **Layer content** in `/microsoft/`, `/community/`, and `/custom/` — knowledge files and action skills grouped by authority.
When BCQuality is installed as a standalone plugin, it additionally exposes
`skills/al-code-review/SKILL.md`. This is a host-format adapter, not another
action skill: it creates the task context and enters the same flow at Entry.
`skills/al-code-review/SKILL.md` and
`skills/al-development/SKILL.md`. These are host-format adapters, not
additional action skills: each creates the task context and enters the same
flow at Entry.
## The flow
@ -25,7 +27,7 @@ flowchart LR
E -->|3 dispatch record| A
A -->|4 invoke dispatched skill| S[Action skill<br/>e.g. al-code-review]
S -->|5 execute| P[Source → Relevance<br/>→ Worklist → Action<br/>reading READ · DO on demand]
P -->|6 emit| R[Findings · Domain labels<br/>· References · Confidence]
P -->|6 emit| R[Findings report<br/>or implementation report]
R -->|7 integrate| O
```
@ -35,11 +37,11 @@ The orchestrator has a URL setting that points at BCQuality (default: `github.co
### 2. Agent invokes Entry
The agent reads `/skills/entry.md` and runs it against the task context. Entry applies its Source → Relevance → Worklist → Action steps over the action skills under `*/skills/**/*.md` and returns a **dispatch record**: the set of action skills to invoke, plus a list of candidates it skipped (with reasons). Routing is a skill, not orchestrator logic.
For a standalone plugin installation, the host activates the
`skills/al-code-review/SKILL.md` adapter first. That adapter preserves the
caller's actual goal, constructs the task context, and invokes Entry. It does
not select the internal `microsoft/skills/review/al-code-review.md` action skill
itself or duplicate Entry's preparation, routing, and failure semantics.
For a standalone plugin installation, the host activates the matching adapter
first. The adapter preserves the caller's actual goal, constructs the task
context, and invokes Entry. It does not select the internal review or
development action skill itself or duplicate Entry's preparation, routing, and
failure semantics.
### 3. Agent consumes the dispatch record
The dispatch record names one or more action skills and the subset of inputs each should receive. If the outcome is `no-match` or `failed`, the agent returns the record to the orchestrator unchanged.
@ -71,19 +73,44 @@ The index is **owned and produced by BCQuality**, not by each consumer: its gene
The index changes only *how candidates are discovered*, never *which are selected*. The Worklist predicate is unchanged — `keywords` still drive selection — and the agent still opens each worklisted article **in full** to read its `## Best Practice` / `## Anti Pattern` rule bodies; the index is discovery metadata only and never substitutes for the article body. When no index is present, skills fall back to path-based discovery (collect by domain folder), so review still works.
### 6. Agent emits structured output
The output contract is defined in the DO meta-skill so that every action skill — today's and next year's — produces the same shape:
The output contracts are defined in the DO meta-skill:
- **Outcome** — `completed`, `not-applicable`, `no-knowledge`, `partial`, or `failed`. An orchestrator can distinguish a clean run from a no-op from a failure without guessing.
- **Findings** — what the skill observed (severity, message, optional location).
- **Domain** — the producer-owned, human-readable display label on each review finding.
- **References** — structured objects (`path` plus optional commit `sha`) pointing to the knowledge files that informed each finding.
- **Confidence** — per-finding evidence strength.
- **Suppressed** — knowledge files that were discarded by layer precedence or configuration, so reviewers can see what was overridden.
- A **findings report** carries review findings, domain labels, references, confidence, and suppressions.
- A **development guidance report** carries read-only knowledge constraints and validation considerations for an existing plan.
- An **implementation report** carries the development plan, classified mode, knowledge applied, changed files, validation results, final review, and remaining work.
The orchestrator parses this **without skill-specific logic**. This is the point of the contract: orchestrators and action skills evolve independently.
For development, the action happens before the report: the skill
first invokes the read-only planning skill to select applicable knowledge, then
changes the target repository, runs its native validation, and invokes the
configured review quality-skill over the resulting diff. A specialized
repository orchestrator may invoke only the planning skill and feed its
guidance report into its own implementation phases. The implementation report
is a machine-readable record of persisted work, not a code proposal for the
orchestrator to apply later.
### 7. Orchestrator integrates
The orchestrator turns findings into PR comments, build gates, or IDE diagnostics, and links the references back to the knowledge files so the PR author — human or agent — can read the guidance.
The orchestrator turns findings into PR comments, build gates, or IDE diagnostics. For implementation it presents the changed files and validation state, while the agent has already persisted the requested change in the target repository.
## Repository-specific development orchestrators
A repository-specific workflow can keep ownership of implementation and consume
BCQuality only for planning and review:
1. Produce its normal development plan after repository investigation.
2. Invoke Entry with `inputs-available: [development-plan, repository]` plus
the resolved applicability dimensions.
3. Execute the dispatched `al-development-plan` skill and preserve its
`development-guidance-report`.
4. Pass the selected article references, constraints, samples, and validation
considerations into its own test, implementation, and critique phases.
5. Run its existing BCQuality-backed review gate over the completed diff.
This is the integration model for specialized bug-fix or release workflows.
They keep environment provisioning, retries, state, commits, propagation, and
pull-request delivery; BCQuality supplies shared product knowledge before and
after the code change.
## Knowledge-backed and agent findings

44
coverage/README.md Normal file
View file

@ -0,0 +1,44 @@
# Developer knowledge coverage
This directory separates **source coverage** from the knowledge corpus itself.
Microsoft Learn units are inputs to editorial work, not articles to import
one-for-one.
## Files
- `microsoft-learn-developer-catalog.json` is generated source metadata for all
Microsoft Learn modules tagged with both `dynamics-business-central` and
`developer`.
- `learn-coverage.json` is the maintained editorial ledger. Units absent from
this file are unreviewed.
- `development-capabilities.json` tracks whether representative Business
Central development capabilities have implementation fixtures.
Each tracked unit has a `reviewStatus`:
- `in-progress` — at least one concern has been identified, but editorial
triage of the unit is not complete.
- `complete` — every relevant concern in the unit has a recorded outcome. A
complete unit may have no outcomes when it contains no remedial knowledge.
Each concern has one disposition:
- `candidate` — worth authoring or reconciling with existing knowledge.
- `authored` — produced one or more new knowledge articles.
- `covered-existing` — already represented by the linked article.
- `rejected` — fails BCQuality's remedial admission test.
- `deferred` — valid but intentionally postponed, with a rationale.
An authored article does not make its source unit complete automatically. One
unit can contain several independent concerns.
## Update and report
```powershell
pwsh ./tools/Update-LearnCatalog.ps1
pwsh ./tools/Test-LearnCoverage.ps1
```
The catalog updater also accepts `-CatalogPath` for an exported Microsoft Learn
Platform API response. CI validates the committed snapshot and editorial ledger
without network access.

View file

@ -0,0 +1,129 @@
{
"version": 1,
"capabilities": [
{
"id": "setup-and-master-data",
"title": "Setup and master data",
"status": "fixture",
"domains": [
"data-modeling",
"security",
"testing",
"ui"
],
"fixtureIds": [
"setup-backed-master-data"
]
},
{
"id": "document-workflows",
"title": "Document header and lines workflows",
"status": "fixture",
"domains": [
"data-modeling",
"events",
"performance",
"testing",
"ui"
],
"fixtureIds": [
"document-header-and-lines"
]
},
{
"id": "api-integrations",
"title": "Versioned API integrations",
"status": "fixture",
"domains": [
"security",
"testing",
"web-services"
],
"fixtureIds": [
"versioned-master-data-api"
]
},
{
"id": "bug-diagnosis-and-fix",
"title": "Bug diagnosis and surgical repair",
"status": "fixture",
"domains": [
"performance",
"testing"
],
"fixtureIds": [
"fix-filtered-batch-processing"
]
},
{
"id": "journals-and-posting",
"title": "Journals and posting routines",
"status": "planned",
"domains": [
"data-modeling",
"error-handling",
"events",
"performance",
"testing"
],
"fixtureIds": []
},
{
"id": "reports-and-documents",
"title": "Reports and document layouts",
"status": "planned",
"domains": [
"performance",
"testing",
"ui"
],
"fixtureIds": []
},
{
"id": "install-and-upgrade",
"title": "Installation and data upgrade",
"status": "planned",
"domains": [
"breaking-changes",
"testing",
"upgrade"
],
"fixtureIds": []
},
{
"id": "external-services",
"title": "Outbound services and authentication",
"status": "planned",
"domains": [
"error-handling",
"privacy",
"security",
"telemetry"
],
"fixtureIds": []
},
{
"id": "role-centers-and-onboarding",
"title": "Role Centers, setup, and onboarding",
"status": "planned",
"domains": [
"security",
"testing",
"ui"
],
"fixtureIds": []
},
{
"id": "appsource-lifecycle",
"title": "AppSource packaging and lifecycle",
"status": "planned",
"domains": [
"appsource",
"breaking-changes",
"testing",
"upgrade"
],
"fixtureIds": []
}
]
}

View file

@ -0,0 +1,456 @@
{
"version": 1,
"catalog": "coverage/microsoft-learn-developer-catalog.json",
"units": [
{
"uid": "learn-dynamics.use-document-standards-business-central.4a-use-round-function",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "round-direction-symbols-use-magnitude",
"title": "Round direction symbols use magnitude rather than mathematical ordering",
"disposition": "authored",
"domain": "data-modeling",
"articlePaths": [
"microsoft/knowledge/data-modeling/round-direction-symbols-use-magnitude.md"
]
}
]
},
{
"uid": "learn-dynamics.use-document-standards-business-central.3-use-initrecord-function",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "initialize-document-defaults-in-initrecord",
"title": "Initialize document defaults in InitRecord after assigning the number",
"disposition": "authored",
"domain": "data-modeling",
"articlePaths": [
"microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.md"
]
}
]
},
{
"uid": "learn-dynamics.business-central-interfaces.type-testing",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "guard-interface-casts-with-is",
"title": "Guard optional interface casts with is",
"disposition": "authored",
"domain": "interfaces",
"articlePaths": [
"microsoft/knowledge/interfaces/guard-interface-casts-with-is.md"
]
}
]
},
{
"uid": "learn-dynamics.extend-modify-existing-table.add-field-group",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "dropdown-fieldgroup-respects-lookup-page-visibility",
"title": "DropDown fields remain hidden when their lookup-page controls are hidden",
"disposition": "authored",
"domain": "ui",
"articlePaths": [
"microsoft/knowledge/ui/dropdown-fieldgroup-respects-lookup-page-visibility.md"
]
}
]
},
{
"uid": "learn-dynamics.work-with-pages.8-controls",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "updatepropagation-both-refreshes-main-page",
"title": "UpdatePropagation Both refreshes the main page after line edits",
"disposition": "authored",
"domain": "ui",
"articlePaths": [
"microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md"
]
},
{
"id": "applicationarea-parent-inheritance",
"title": "Page-level ApplicationArea inheritance excludes extension controls",
"disposition": "covered-existing",
"domain": "style",
"articlePaths": [
"microsoft/knowledge/style/applicationarea-required-on-page-controls.md"
]
}
]
},
{
"uid": "learn-dynamics.easy-application-upgrade.3-installation-upgrade-codeunits",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "install-and-upgrade-codeunits-have-no-order",
"title": "Separate install or upgrade codeunits have no execution order",
"disposition": "authored",
"domain": "upgrade",
"articlePaths": [
"microsoft/knowledge/upgrade/install-and-upgrade-codeunits-have-no-order.md"
]
},
{
"id": "appversion-meaning-depends-on-execution-context",
"title": "ModuleInfo AppVersion changes meaning with execution context",
"disposition": "authored",
"domain": "upgrade",
"articlePaths": [
"microsoft/knowledge/upgrade/appversion-meaning-depends-on-execution-context.md"
]
}
]
},
{
"uid": "learn-dynamics.work-with-tables.text-search",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "optimize-for-text-search-requires-double-ampersand-filter",
"title": "Optimized full-text search requires the double-ampersand filter operator",
"disposition": "candidate",
"domain": "query"
}
]
},
{
"uid": "learn-dynamics.extend-modify-existing-table.define-extension-objects",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "same-app-extension-references-follow-object-id-order",
"title": "Same-app extension objects can only reference lower-ID extension objects",
"disposition": "candidate",
"domain": "data-modeling"
}
]
},
{
"uid": "learn-dynamics.debug-deploy-extension.resource-policy-settings",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "resource-exposure-policy-is-not-nondebuggable",
"title": "Resource exposure policy and NonDebuggable protect different surfaces",
"disposition": "candidate",
"domain": "security"
}
]
},
{
"uid": "learn-dynamics.work-entitlements-permission-sets.3-create-entitlements-permission-sets",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "entitlement-objects-reference-same-app-permission-sets",
"title": "Entitlement objects are online-only and reference same-app permission sets",
"disposition": "candidate",
"domain": "security"
}
]
},
{
"uid": "learn-dynamics.work-entitlements-permission-sets.override-entitlements",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "inherent-permissions-cannot-elevate-other-extensions",
"title": "Inherent permissions cannot elevate access to another extension",
"disposition": "candidate",
"domain": "security"
}
]
},
{
"uid": "learn-dynamics.debug-deploy-extension.3a-snapshot",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "snapshot-debugging-captures-only-snappoints-and-exceptions",
"title": "Snapshot debugging captures state only at snappoints and exceptions",
"disposition": "candidate",
"domain": "testing"
}
]
},
{
"uid": "learn-dynamics.debug-deploy-extension.recovery-failures",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "publish-recovery-cannot-restore-every-extension-state",
"title": "Publish recovery cannot restore every upgrade or app-move failure",
"disposition": "candidate",
"domain": "upgrade"
}
]
},
{
"uid": "learn-dynamics.debug-deploy-extension.database-wait-statistics",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "database-wait-statistics-are-exposed-as-a-virtual-table",
"title": "Database wait statistics are exposed as a Business Central virtual table",
"disposition": "candidate",
"domain": "performance"
}
]
},
{
"uid": "learn-dynamics.work-with-pages.use-rich-text-editor",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "rich-text-controls-use-html-backed-blob-fields",
"title": "Rich text controls use HTML-backed Blob fields and isolated layout groups",
"disposition": "candidate",
"domain": "ui"
}
]
},
{
"uid": "learn-dynamics.work-with-pages.scan-barcodes",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "barcode-integration-scenarios-have-different-platform-support",
"title": "Barcode field, camera action, and hardware-scanner scenarios have different platform support",
"disposition": "candidate",
"domain": "ui"
}
]
},
{
"uid": "learn-dynamics.work-with-pages.9-search",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "card-pages-should-not-be-directly-searchable",
"title": "Card pages should be opened through their list rather than Tell Me",
"disposition": "candidate",
"domain": "ui"
}
]
},
{
"uid": "learn-dynamics.work-with-pages.hidden-fields",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "allowincustomizations-controls-the-add-field-pane",
"title": "AllowInCustomizations controls exposure through the Add field pane",
"disposition": "candidate",
"domain": "privacy"
}
]
},
{
"uid": "learn-dynamics.intro-development-environment.differentiate-apps",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "application-modules-depend-only-on-same-or-lower-layers",
"title": "Application modules depend only on the same or lower architectural layers",
"disposition": "candidate",
"domain": "data-modeling"
}
]
},
{
"uid": "learn-dynamics.manipulate-data-via-code.2-retrieve-data",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "legacy-find-directions-do-not-use-top-one",
"title": "Legacy Find direction calls do not have FindFirst or FindLast query behavior",
"disposition": "candidate",
"domain": "performance"
}
]
},
{
"uid": "learn-dynamics.easy-application-upgrade.2-upgrade-responsibilities",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "uninstall-preserves-extension-data",
"title": "Uninstall preserves extension data for reinstall or upgrade",
"disposition": "candidate",
"domain": "upgrade"
}
]
},
{
"uid": "learn-dynamics.easy-application-upgrade.consider-update-lifecycle",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "incompatible-cloud-extensions-have-a-remediation-window",
"title": "Incompatible cloud extensions have a fixed remediation window",
"disposition": "candidate",
"domain": "upgrade"
}
]
},
{
"uid": "learn-dynamics.easy-application-upgrade.6-answers-about-updates",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "minor-and-major-releases-update-apps-differently",
"title": "Minor and major Business Central releases update AppSource apps differently",
"disposition": "candidate",
"domain": "upgrade"
},
{
"id": "dependency-version-is-a-minimum",
"title": "An app.json dependency version is a minimum rather than an exact pin",
"disposition": "candidate",
"domain": "upgrade"
}
]
},
{
"uid": "learn-dynamics.application-types.3-library-dependency-applications",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "library-apps-install-through-the-dependency-chain",
"title": "Library apps install and update through the dependency chain",
"disposition": "candidate",
"domain": "appsource"
}
]
},
{
"uid": "learn-dynamics.business-central-interfaces.extending-interfaces",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "interfaces-can-compose-multiple-base-interfaces",
"title": "Interfaces can compose multiple base interfaces",
"disposition": "candidate",
"domain": "interfaces"
}
]
},
{
"uid": "learn-dynamics.test-automation.page-scripting-tool",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "page-scripting-cannot-drive-non-al-ui",
"title": "Page scripting cannot drive control add-ins or other non-AL UI",
"disposition": "candidate",
"domain": "testing"
}
]
},
{
"uid": "learn-dynamics.test-automation.2-test-automation-responsibilities",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "appsource-validation-must-use-the-current-build",
"title": "AppSource validation must use the current validation build",
"disposition": "candidate",
"domain": "appsource"
},
{
"id": "permission-testing-also-protects-the-essential-experience",
"title": "Permission testing must also protect the unextended Essential experience",
"disposition": "candidate",
"domain": "testing"
}
]
},
{
"uid": "learn-dynamics.test-automation.4-answers-about-testing",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "appsource-tests-run-for-every-supported-country",
"title": "AppSource tests must run separately for every supported country",
"disposition": "candidate",
"domain": "appsource"
},
{
"id": "upgrade-tests-cover-nonadjacent-versions",
"title": "Upgrade tests cover nonadjacent historical versions",
"disposition": "candidate",
"domain": "testing"
}
]
},
{
"uid": "learn-dynamics.bring-app-appsource.4-technical-validation",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "appsource-submissions-cannot-use-runtime-packages",
"title": "AppSource submissions cannot use runtime packages",
"disposition": "candidate",
"domain": "appsource"
},
{
"id": "per-tenant-and-marketplace-apps-need-distinct-identities",
"title": "Per-tenant and Marketplace variants need distinct app identities",
"disposition": "candidate",
"domain": "appsource"
},
{
"id": "profiles-are-declared-with-profile-objects",
"title": "Profiles are shipped with AL profile objects rather than table inserts",
"disposition": "candidate",
"domain": "appsource"
},
{
"id": "extension-layout-uses-relative-placement",
"title": "Extension layout uses named relative placement anchors",
"disposition": "candidate",
"domain": "breaking-changes"
}
]
},
{
"uid": "learn-dynamics.test-automation.3-documentation-examples",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "tests-use-reproducible-random-data",
"title": "Tests use reproducible randomized data instead of shared literals",
"disposition": "candidate",
"domain": "testing"
},
{
"id": "al-tests-run-through-vscode-test-explorer",
"title": "AL tests can run and debug through Visual Studio Code Test Explorer",
"disposition": "candidate",
"domain": "testing"
}
]
},
{
"uid": "learn-dynamics.easy-application-upgrade.4-manage-apps",
"reviewStatus": "in-progress",
"outcomes": [
{
"id": "manage-apps-separates-install-and-update-requirements",
"title": "Manage Apps separates missing dependencies from dependency updates",
"disposition": "candidate",
"domain": "upgrade"
}
]
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -54,3 +54,50 @@ This credential-free check proves every selected leaf maps to a same-named knowl
For a single combined stress-test result, use `-ResultsPath` instead.
The committed gate requires full expected recall, the exact convention-derived article ID, and no findings on clean controls.
## AL development evaluation
`development-fixtures.json` defines end-to-end development requests rather than
prewritten good/bad snippets. Each case declares its execution mode, the
Business Central capabilities it exercises, the knowledge that should shape
the implementation, acceptance criteria, and the real checks an external
runner must perform.
Validate the fixture and capability manifests and prepare opaque requests:
```powershell
pwsh ./tools/Test-DevelopmentFixtures.ps1 -Root . -PrepareDirectory ./.development-evaluation
```
Each request runs `al-development` in a fresh writable AL repository. Cases
may exercise feature, bug, refactor, upgrade, or maintenance mode.
The runner compiles the generated project, runs its tests, invokes the review
quality gate, and stores the resulting implementation report using the opaque
`caseId` from its request, for example `result-case-a1b2c3d4.json`. It keeps the
generated repository available at the wrapper's `workspaceRoot` so scoring can
verify reported changed paths. Score all results with:
```powershell
pwsh ./tools/Test-DevelopmentFixtures.ps1 -Root . -ResultsDirectory ./.development-evaluation
```
The initial fixtures cover setup-backed master data, document header/line
workflows, versioned API integrations, and surgical diagnosis and repair of a
batch-processing bug. The broader capability roadmap lives in
`coverage/development-capabilities.json`.
### Read-only plan guidance
`development-guidance-fixtures.json` evaluates the planning interface used by
specialized orchestrators. It supplies an existing development plan and expects
a referenced set of implementation constraints without any target-repository
changes.
```powershell
pwsh ./tools/Test-DevelopmentGuidanceFixtures.ps1 -Root . -PrepareDirectory ./.development-guidance-evaluation
```
An external runner stores `result-<case-id>.json` beside the generated request
and retains the clean fixture repository at `workspaceRoot`. Score the result
with `-ResultsDirectory`; the scorer verifies knowledge recall and precision
and fails if the planning pass changed the repository.

View file

@ -0,0 +1,188 @@
{
"version": 1,
"skill": "microsoft/skills/development/al-development.md",
"minimumKnowledgeRecall": 1.0,
"minimumKnowledgePrecision": 0.5,
"cases": [
{
"id": "setup-backed-master-data",
"title": "Build setup-backed loyalty member master data",
"capabilities": [
"setup-and-master-data"
],
"expectedKind": "feature",
"development-request": {
"kind": "feature",
"description": "Add a Loyalty Member feature to an existing Business Central AL app. Administrators configure the member number series on a singleton setup card. Users create members through list and card pages, the table assigns numbers, and blocked members cannot be selected by consuming records. Include least-privilege permission sets and automated tests.",
"acceptance-criteria": [
"The setup is a blank-key singleton surfaced by a Card page.",
"Member numbers use the current No. Series codeunit and support manual numbers according to setup.",
"Blocked validation occurs where a member is consumed, not only on the member table.",
"The feature includes assignable least-privilege permissions and automated tests."
]
},
"context": {
"technologies": [
"al"
],
"countries": [
"w1"
],
"application-area": [
"all"
]
},
"requiredKnowledge": [
"microsoft/knowledge/data-modeling/setup-table-is-a-singleton.md",
"microsoft/knowledge/data-modeling/master-table-no-from-number-series-in-oninsert.md",
"microsoft/knowledge/data-modeling/check-blocked-in-referencing-code-not-in-master.md",
"microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md",
"microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md"
],
"optionalKnowledge": [
"microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md",
"microsoft/knowledge/security/compose-permission-sets-with-included-sets.md",
"microsoft/knowledge/style/applicationarea-required-on-page-controls.md",
"microsoft/knowledge/style/tooltip-required-on-page-fields.md",
"microsoft/knowledge/ui/showmandatory-on-code-required-page-fields.md"
],
"requiredChecks": [
"compile",
"tests",
"review"
]
},
{
"id": "document-header-and-lines",
"title": "Build a document header and lines workflow",
"capabilities": [
"document-workflows"
],
"expectedKind": "feature",
"development-request": {
"kind": "feature",
"description": "Implement a Service Quote feature with a header, lines, document page, number series, posting and document dates, calculated totals, and tests. Line edits must refresh the total shown on the header. Structure initialization so API, UI, and test creation paths behave consistently.",
"acceptance-criteria": [
"The header assigns its number before InitRecord establishes document defaults.",
"The document page links lines correctly and refreshes parent totals after edits.",
"Tests exercise creation outside the UI as well as the document-page behavior.",
"The implementation contains no obsolete NoSeriesManagement dependency."
]
},
"context": {
"technologies": [
"al"
],
"countries": [
"w1"
],
"application-area": [
"service"
]
},
"requiredKnowledge": [
"microsoft/knowledge/data-modeling/initialize-document-defaults-in-initrecord.md",
"microsoft/knowledge/data-modeling/use-no-series-codeunit-not-noseriesmanagement.md",
"microsoft/knowledge/ui/updatepropagation-both-refreshes-main-page.md",
"microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md"
],
"optionalKnowledge": [
"microsoft/knowledge/events/publish-thin-onbefore-onafter-integration-events.md",
"microsoft/knowledge/style/applicationarea-required-on-page-controls.md",
"microsoft/knowledge/style/tooltip-required-on-page-fields.md"
],
"requiredChecks": [
"compile",
"tests",
"review"
]
},
{
"id": "versioned-master-data-api",
"title": "Expose master data through a versioned API",
"capabilities": [
"api-integrations"
],
"expectedKind": "feature",
"development-request": {
"kind": "auto",
"plan": "Expose Loyalty Member master data through a Business Central API page. Use a stable v1.0 contract, address records by SystemId, support insert and update, use conventional entity naming, and include permissions and automated API-oriented tests.",
"acceptance-criteria": [
"The API declares all routing properties and a stable APIVersion.",
"ODataKeyFields uses SystemId and the exposed SystemId field is not editable.",
"EntityName is singular, EntitySetName is plural, and both are lower camel case.",
"The API is covered by least-privilege permissions and automated tests."
]
},
"context": {
"technologies": [
"al"
],
"countries": [
"w1"
],
"application-area": [
"all"
]
},
"requiredKnowledge": [
"microsoft/knowledge/web-services/set-required-api-page-properties.md",
"microsoft/knowledge/web-services/expose-systemid-as-the-api-key.md",
"microsoft/knowledge/style/api-page-delayedinsert-true.md",
"microsoft/knowledge/style/api-page-entity-naming-singular-plural.md",
"microsoft/knowledge/security/permission-set-avoid-wildcard-grants.md"
],
"optionalKnowledge": [
"microsoft/knowledge/style/api-page-camelcase-properties.md",
"microsoft/knowledge/web-services/version-apis-by-adding-not-mutating-published-versions.md"
],
"requiredChecks": [
"compile",
"tests",
"review"
]
},
{
"id": "fix-filtered-batch-processing",
"title": "Fix a batch routine that processes only one record",
"capabilities": [
"bug-diagnosis-and-fix"
],
"expectedKind": "bug",
"development-request": {
"kind": "bug",
"description": "Users report that an existing filtered batch routine updates only the first matching record. Reproduce the defect, identify why iteration stops, make the smallest safe correction, and add a regression test that selects multiple records and proves every selected record is processed.",
"acceptance-criteria": [
"The defect is reproduced or demonstrated by a failing regression test before the fix.",
"The root cause is corrected without widening the supplied record filters.",
"The routine processes every selected record with the appropriate update locking behavior.",
"A regression test covers more than one selected record."
]
},
"context": {
"technologies": [
"al"
],
"countries": [
"w1"
],
"application-area": [
"all"
]
},
"requiredKnowledge": [
"microsoft/knowledge/performance/pair-findset-with-next-loop.md",
"microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md"
],
"optionalKnowledge": [
"microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md",
"microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md"
],
"requiredChecks": [
"compile",
"tests",
"review"
]
}
]
}

View file

@ -0,0 +1,50 @@
{
"version": 1,
"skill": "microsoft/skills/development/al-development-plan.md",
"minimumKnowledgeRecall": 1.0,
"minimumKnowledgePrecision": 0.5,
"cases": [
{
"id": "bcapps-filtered-batch-bug-plan",
"title": "Select guidance for a filtered batch bug fix",
"development-plan": {
"kind": "bug",
"request": "Fix a filtered batch routine that updates only the first matching record.",
"root-cause": "The routine calls FindFirst and updates the current record without entering an enumerator loop.",
"affected-files": [
"src/Batch/UpdateSelectedEntries.Codeunit.al",
"test/Batch/UpdateSelectedEntries.Codeunit.al"
],
"proposed-changes": [
"Iterate the supplied filtered record set and update every selected entry.",
"Add a regression test with multiple selected entries."
],
"test-strategy": "Establish a red test where only one of several selected records is updated, then require all selected records to be updated after the fix.",
"acceptance-criteria": [
"The supplied filters are preserved.",
"Every selected record is updated.",
"The regression test demonstrates red-to-green behavior."
]
},
"context": {
"technologies": [
"al"
],
"countries": [
"w1"
],
"application-area": [
"all"
]
},
"requiredKnowledge": [
"microsoft/knowledge/performance/pair-findset-with-next-loop.md",
"microsoft/knowledge/performance/findset-true-applies-updlock-on-read.md",
"microsoft/knowledge/testing/use-library-codeunits-for-test-fixtures.md"
],
"optionalKnowledge": [
"microsoft/knowledge/performance/pass-var-record-to-preserve-partial-load-enumerator.md"
]
}
]
}

View file

@ -0,0 +1,28 @@
table 50603 "Sample Order Header Bad"
{
fields
{
field(1; "No."; Code[20])
{
DataClassification = CustomerContent;
}
field(2; "Document Date"; Date)
{
DataClassification = CustomerContent;
}
}
trigger OnInsert()
var
SalesSetup: Record "Sales & Receivables Setup";
NoSeries: Codeunit "No. Series";
begin
"Document Date" := WorkDate();
if "No." = '' then begin
SalesSetup.Get();
SalesSetup.TestField("Order Nos.");
"No." := NoSeries.GetNextNo(SalesSetup."Order Nos.");
end;
end;
}

View file

@ -0,0 +1,45 @@
table 50602 "Sample Order Header Good"
{
fields
{
field(1; "No."; Code[20])
{
DataClassification = CustomerContent;
}
field(2; "Document Date"; Date)
{
DataClassification = CustomerContent;
}
}
trigger OnInsert()
var
SalesSetup: Record "Sales & Receivables Setup";
NoSeries: Codeunit "No. Series";
begin
if "No." = '' then begin
SalesSetup.Get();
SalesSetup.TestField("Order Nos.");
"No." := NoSeries.GetNextNo(SalesSetup."Order Nos.");
end;
InitRecord();
end;
procedure InitRecord()
begin
OnBeforeInitRecord(Rec);
"Document Date" := WorkDate();
OnAfterInitRecord(Rec);
end;
[IntegrationEvent(false, false)]
local procedure OnBeforeInitRecord(var SampleOrderHeader: Record "Sample Order Header Good")
begin
end;
[IntegrationEvent(false, false)]
local procedure OnAfterInitRecord(var SampleOrderHeader: Record "Sample Order Header Good")
begin
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: data-modeling
keywords: [document-header, initrecord, number-series, default-values, oninsert, initialization]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Initialize document defaults in `InitRecord` after assigning the number
## Description
Business Central document headers assign their number series first and then call an `InitRecord` procedure that owns the remaining business defaults, such as posting and document dates. Keeping that sequence and extensibility point makes initialization consistent for every creation path and lets extensions subscribe around one documented operation. Defaults scattered across page triggers or unrelated helpers can differ between UI, API, test, and background creation.
## Best Practice
In the document table's insert path, assign the document number and then call `InitRecord`. Keep the default assignments in that procedure and expose narrow before/after events when other extensions must participate.
See sample: `initialize-document-defaults-in-initrecord.good.al`.
## Anti Pattern
Assigning document defaults in a page trigger, or scattering them directly through `OnInsert` with no `InitRecord` boundary. Non-page creation paths can then miss the defaults, and extensions have no stable initialization hook.
See sample: `initialize-document-defaults-in-initrecord.bad.al`.
## Reference
[Use the InitRecord function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/3-use-initrecord-function)

View file

@ -0,0 +1,8 @@
codeunit 50601 "Directed Rounding Bad"
{
procedure FloorAmount(Value: Decimal; Precision: Decimal): Decimal
begin
// For negative values, '<' rounds toward zero rather than toward negative infinity.
exit(Round(Value, Precision, '<'));
end;
}

View file

@ -0,0 +1,10 @@
codeunit 50600 "Directed Rounding Good"
{
procedure RoundAmount(Value: Decimal; Precision: Decimal; IncreaseMagnitude: Boolean): Decimal
begin
if IncreaseMagnitude then
exit(Round(Value, Precision, '>'));
exit(Round(Value, Precision, '<'));
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: data-modeling
keywords: [round, rounding, direction, precision, negative-decimal, amount]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `Round` direction symbols follow magnitude, not mathematical ordering
## Description
AL's `Round(Number, Precision, Direction)` uses `'>'` to round away from zero and `'<'` to round toward zero. For a negative value this reverses mathematical ordering: `Round(-1234.56789, 0.001, '<')` returns `-1234.567`, while direction `'>'` returns `-1234.568`. Code that treats the symbols as mathematical ceiling and floor produces sign-dependent amount errors, commonly on credit documents and negative adjustments.
## Best Practice
Choose the direction from the business meaning: `'>'` increases absolute magnitude and `'<'` decreases absolute magnitude for both positive and negative values. Include positive and negative cases whenever a directed rounding rule is tested.
See sample: `round-direction-symbols-use-magnitude.good.al`.
## Anti Pattern
Using `'<'` as a mathematical floor or `'>'` as a mathematical ceiling. The result looks correct for positive amounts but moves in the opposite mathematical direction for negative amounts.
See sample: `round-direction-symbols-use-magnitude.bad.al`.
## Reference
[Use the Round function](https://learn.microsoft.com/en-us/training/modules/use-document-standards-business-central/4a-use-round-function)

View file

@ -0,0 +1,20 @@
interface "I Quote Amount Bad"
{
procedure GetAmount(): Decimal;
}
interface "I Quote Date Bad"
{
procedure GetDate(): Date;
}
codeunit 50611 "Quote Reader Bad"
{
procedure GetDate(Quote: Interface "I Quote Amount Bad"): Date
var
DatedQuote: Interface "I Quote Date Bad";
begin
DatedQuote := Quote as "I Quote Date Bad";
exit(DatedQuote.GetDate());
end;
}

View file

@ -0,0 +1,24 @@
interface "I Quote Amount Good"
{
procedure GetAmount(): Decimal;
}
interface "I Quote Date Good"
{
procedure GetDate(): Date;
}
codeunit 50610 "Quote Reader Good"
{
procedure TryGetDate(Quote: Interface "I Quote Amount Good"; var QuoteDate: Date): Boolean
var
DatedQuote: Interface "I Quote Date Good";
begin
if not (Quote is "I Quote Date Good") then
exit(false);
DatedQuote := Quote as "I Quote Date Good";
QuoteDate := DatedQuote.GetDate();
exit(true);
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [25..]
domain: interfaces
keywords: [interface, is-operator, as-operator, type-test, cast, variant, runtime-error]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Guard optional interface casts with `is`
## Description
From runtime 14.0, AL can type-test an interface or `Variant` with `is` and cast it to another interface with `as`. The test is non-throwing, but `as` raises a runtime error when the underlying codeunit does not implement the target interface. This matters when an extended capability is optional or implementations can come from other extensions.
## Best Practice
Use `is` to establish that the value supports the target interface before using `as`. Cast directly only where the target implementation is an invariant guaranteed by the surrounding contract.
See sample: `guard-interface-casts-with-is.good.al`.
## Anti Pattern
Using `as` unconditionally for an optional extended interface. An otherwise valid implementation of the base interface then fails at runtime merely because it does not implement the additional contract.
See sample: `guard-interface-casts-with-is.bad.al`.
## Reference
[Understand type testing and casting operators for interfaces](https://learn.microsoft.com/en-us/training/modules/business-central-interfaces/type-testing)

View file

@ -8,9 +8,6 @@ page 50375 "Sample App Area Bad"
{
group(General)
{
// Anti-pattern: no ApplicationArea. AS0062 flags this control,
// and it is silently hidden in the Web client for profiles whose
// enabled areas do not already cover it.
field("No."; Rec."No.")
{
ToolTip = 'Specifies the number that identifies the customer.';
@ -23,3 +20,18 @@ page 50375 "Sample App Area Bad"
}
}
}
pageextension 50377 "Customer App Area Bad" extends "Customer Card"
{
layout
{
addlast(General)
{
// Extension controls do not inherit ApplicationArea from the base page.
field("Language Code Sample"; Rec."Language Code")
{
ToolTip = 'Specifies the language used for the customer.';
}
}
}
}

View file

@ -2,6 +2,8 @@ page 50374 "Sample App Area Good"
{
PageType = Card;
SourceTable = Customer;
ApplicationArea = All;
layout
{
area(Content)
@ -10,12 +12,10 @@ page 50374 "Sample App Area Good"
{
field("No."; Rec."No.")
{
ApplicationArea = All;
ToolTip = 'Specifies the number that identifies the customer.';
}
field(Name; Rec.Name)
{
ApplicationArea = All;
ToolTip = 'Specifies the customer''s name.';
}
}
@ -27,7 +27,6 @@ page 50374 "Sample App Area Good"
{
action(Refresh)
{
ApplicationArea = All;
ToolTip = 'Reloads the current record.';
trigger OnAction()
@ -38,3 +37,18 @@ page 50374 "Sample App Area Good"
}
}
}
pageextension 50376 "Customer App Area Good" extends "Customer Card"
{
layout
{
addlast(General)
{
field("Language Code Sample"; Rec."Language Code")
{
ApplicationArea = All;
ToolTip = 'Specifies the language used for the customer.';
}
}
}
}

View file

@ -1,28 +1,32 @@
---
bc-version: [all]
domain: style
keywords: [application-area, page-control, as0062, appsourcecop, hidden-control, web-client]
keywords: [application-area, page-control, inheritance, as0062, appsourcecop, web-client]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Every page control needs an `ApplicationArea` (AppSourceCop AS0062)
# Page-level `ApplicationArea` inheritance does not apply to extensions
## Description
A field control on a page or pageextension that has no `ApplicationArea` property is silently hidden in the Web client for every profile whose enabled application areas do not cover it. There is no error and no warning at runtime — the field simply does not appear, which reads as data loss to the user. AppSourceCop AS0062 flags any page control or action that is missing the `ApplicationArea` property, and AppSource technical validation rejects the app until it is set.
A page control or action needs an effective `ApplicationArea` to appear in cloud experiences. From runtime 10.0, controls on a page object inherit the page-level value, so repeating it on every child is unnecessary when the parent defines a suitable default. This inheritance does not apply to controls added or modified by page and report extensions: extension controls must still set the property explicitly.
Set the property to an area the app actually enables. `All` makes the control visible under every profile and is the common default; if the app declares narrower areas in `app.json`, use one of those. The property applies to field controls and to actions. This is a sibling concern to `caption-required-on-page-fields.md` and `tooltip-required-on-page-fields.md`; note that the ToolTip requirement is the separate CodeCop rule AA0218, not AS0062.
For targets before runtime 10.0, child controls do not inherit and must also set the property. AppSourceCop AS0062 and PTE0008 account for page-level inheritance on runtime 10.0 and later but continue to require explicit values in extensions.
## Best Practice
Every field control and action carries `ApplicationArea = All;` (or a declared area of the app). The value is set once per control and keeps the control visible in the Web client.
On runtime 10.0 or later, set a suitable page-level default and override only controls that belong to a narrower area. Set `ApplicationArea` explicitly on every control or action introduced by a page or report extension.
See sample: `applicationarea-required-on-page-controls.good.al`.
## Anti Pattern
A field control with no `ApplicationArea`. AS0062 flags it, and the control is invisible in the Web client for any profile that does not already enable a matching area.
A page object that defines neither a parent nor child value, or an extension control that assumes it inherits from the base page. The control has no effective application area and can be hidden or rejected by analyzer validation.
See sample: `applicationarea-required-on-page-controls.bad.al`.
## Reference
[Set different control properties](https://learn.microsoft.com/en-us/training/modules/work-with-pages/8-controls)

View file

@ -0,0 +1,9 @@
tableextension 50622 "Ship-to Dropdown Bad" extends "Ship-to Address"
{
fieldgroups
{
addlast(DropDown; "Address 2")
{
}
}
}

View file

@ -0,0 +1,20 @@
tableextension 50620 "Ship-to Dropdown Good" extends "Ship-to Address"
{
fieldgroups
{
addlast(DropDown; "Address 2")
{
}
}
}
pageextension 50621 "Ship-to Lookup Good" extends "Ship-to Address List"
{
layout
{
modify("Address 2")
{
Visible = true;
}
}
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: ui
keywords: [fieldgroup, dropdown, addlast, lookup-page, visible, tableextension, pageextension]
technologies: [al]
countries: [w1]
application-area: [all]
---
# A `DropDown` field remains hidden when its lookup-page control is hidden
## Description
A tableextension can append a field to the `DropDown` field group with `addlast`, but the client still omits that field when its control on the underlying lookup page has `Visible = false`. Changing only the table field group therefore compiles while producing no visible UI change. The field-group name is case-sensitive and must be written as `DropDown`.
## Best Practice
When adding a hidden field to a `DropDown` field group, also extend the page used for the lookup and make that field control visible. Verify the actual lookup page rather than assuming the table definition alone controls the drop-down.
See sample: `dropdown-fieldgroup-respects-lookup-page-visibility.good.al`.
## Anti Pattern
Adding the field with `addlast(DropDown; ...)` while leaving its lookup-page control hidden, then expecting the field to appear in the drop-down.
See sample: `dropdown-fieldgroup-respects-lookup-page-visibility.bad.al`.
## Reference
[Add a new FieldGroup to an existing table](https://learn.microsoft.com/en-us/training/modules/extend-modify-existing-table/add-field-group)

View file

@ -0,0 +1,26 @@
page 50631 "Sample Order Bad"
{
PageType = Document;
SourceTable = "Sales Header";
layout
{
area(Content)
{
group(General)
{
field(Amount; Rec.Amount)
{
ApplicationArea = All;
ToolTip = 'Specifies the total amount of the order.';
}
}
part(Lines; "Sales Order Subform")
{
ApplicationArea = All;
SubPageLink = "Document Type" = field("Document Type"),
"Document No." = field("No.");
}
}
}
}

View file

@ -0,0 +1,27 @@
page 50630 "Sample Order Good"
{
PageType = Document;
SourceTable = "Sales Header";
layout
{
area(Content)
{
group(General)
{
field(Amount; Rec.Amount)
{
ApplicationArea = All;
ToolTip = 'Specifies the total amount of the order.';
}
}
part(Lines; "Sales Order Subform")
{
ApplicationArea = All;
SubPageLink = "Document Type" = field("Document Type"),
"Document No." = field("No.");
UpdatePropagation = Both;
}
}
}
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: ui
keywords: [updatepropagation, page-part, subpage, main-page, refresh, flowfield, document-lines]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Use `UpdatePropagation = Both` when line edits must refresh the main page
## Description
A page part does not automatically refresh its parent page when the subpage changes. `UpdatePropagation = Subpage` updates only the part; `Both` also refreshes the main page. Without `Both`, header totals, FlowFields, and FactBoxes that depend on edited lines can remain stale until another user action refreshes the page.
## Best Practice
Set `UpdatePropagation = Both` on a part when edits in that subpage must immediately update values rendered by the main page. Leave propagation at `Subpage` when the parent has no dependent presentation to avoid unnecessary refreshes.
See sample: `updatepropagation-both-refreshes-main-page.good.al`.
## Anti Pattern
Displaying a line-dependent total on the main page while the editable lines part updates only itself. The persisted values can be correct while the parent page continues to show an old total.
See sample: `updatepropagation-both-refreshes-main-page.bad.al`.
## Reference
[Set different control properties](https://learn.microsoft.com/en-us/training/modules/work-with-pages/8-controls)

View file

@ -0,0 +1,26 @@
---
bc-version: [all]
domain: upgrade
keywords: [appversion, dataversion, moduleinfo, install-codeunit, upgrade-codeunit, version-context]
technologies: [al]
countries: [w1]
application-area: [all]
---
# `ModuleInfo.AppVersion` changes meaning with execution context
## Description
`ModuleInfo.AppVersion()` is the installed version during normal operation, the version being installed inside install code, and the target version inside upgrade code. It is therefore not the source data version during an upgrade. In upgrade code, `DataVersion()` describes the version of the existing data, whether from the currently installed app or the version most recently uninstalled.
## Best Practice
Interpret `AppVersion()` as the code package entering the context and `DataVersion()` as the existing data state. Prefer upgrade tags for controlling individual migration steps; when version information is needed for diagnostics or preconditions, name variables so target app version and source data version cannot be confused.
## Anti Pattern
Reading `AppVersion()` from an upgrade codeunit and treating it as the version being upgraded from. The comparison actually observes the target package and can skip or misroute migration logic.
## Reference
[Create proper installation and upgrade codeunits](https://learn.microsoft.com/en-us/training/modules/easy-application-upgrade/3-installation-upgrade-codeunits)

View file

@ -0,0 +1,28 @@
codeunit 50641 "Sample Upgrade Part One"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
CreateUpgradeState();
end;
local procedure CreateUpgradeState()
begin
end;
}
codeunit 50642 "Sample Upgrade Part Two"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
// This can run before Part One; object IDs do not sequence upgrade codeunits.
MigrateDataThatRequiresUpgradeState();
end;
local procedure MigrateDataThatRequiresUpgradeState()
begin
end;
}

View file

@ -0,0 +1,18 @@
codeunit 50640 "Sample Upgrade Good"
{
Subtype = Upgrade;
trigger OnUpgradePerCompany()
begin
CreateUpgradeState();
MigrateDependentData();
end;
local procedure CreateUpgradeState()
begin
end;
local procedure MigrateDependentData()
begin
end;
}

View file

@ -0,0 +1,30 @@
---
bc-version: [all]
domain: upgrade
keywords: [install-codeunit, upgrade-codeunit, execution-order, subtype-install, subtype-upgrade, sequencing]
technologies: [al]
countries: [w1]
application-area: [all]
---
# Separate install or upgrade codeunits have no execution order
## Description
An extension can contain multiple `Install` or `Upgrade` codeunits, but Business Central does not guarantee the order in which codeunits of the same subtype execute. Upgrade trigger phases are ordered globally, yet one codeunit's `OnUpgradePerCompany` must not assume another codeunit's same-phase trigger already ran. Object ID and source-file order do not provide sequencing.
## Best Practice
Keep separate install or upgrade codeunits independent. When two steps have a real dependency, coordinate them from one owning trigger in the required order; use upgrade tags to make each completed step idempotent.
See sample: `install-and-upgrade-codeunits-have-no-order.good.al`.
## Anti Pattern
Splitting dependent steps into separate codeunits and relying on names, object IDs, or declaration order. The dependent codeunit can run first and fail or observe partially migrated data.
See sample: `install-and-upgrade-codeunits-have-no-order.bad.al`.
## Reference
[Create proper installation and upgrade codeunits](https://learn.microsoft.com/en-us/training/modules/easy-application-upgrade/3-installation-upgrade-codeunits)

View file

@ -0,0 +1,68 @@
---
kind: action-skill
id: al-development-plan
version: 1
title: AL development plan guidance
description: Produces a read-only BCQuality knowledge bundle for an existing Business Central AL development plan.
inputs: [development-plan, repository]
outputs: [development-guidance-report]
bc-version: [all]
technologies: [al]
countries: [w1]
application-area: [all]
---
# AL development plan guidance
Selects the BCQuality knowledge that should constrain an existing AL development plan. It does not implement, edit, stage, commit, or publish anything in the target repository. Repository-specific orchestrators can consume this skill before their own test and implementation phases while retaining ownership of workflow, tooling, and delivery.
Both a readable `repository` and a non-empty `development-plan` are required. The plan may be structured data or text, but it must identify the intended change. Return `not-applicable` without changing files when either input is absent or the repository is not an AL project.
## Source
Read the BCQuality knowledge index once. Use entries from every enabled layer and domain. The index supplies candidate paths, applicability dimensions, keywords, titles, and descriptions; it never substitutes for opening selected articles in full.
Inspect the target repository read-only for `app.json`, affected files and symbols named by the plan, relevant tests, permission sets, dependencies, target/runtime versions, countries, application areas, and repository conventions. Do not create scratch or generated files inside the target repository.
## Relevance
Apply READ's matching semantics using:
- `bc-version` from the plan, target application, or supplied context; for upgrades, distinguish source and target versions.
- `technologies` from the affected files, beginning with `[al]`.
- `countries` from the plan, `app.json`, or workspace configuration.
- `application-area` from the plan and affected objects.
When a dimension cannot be resolved, retain conditionally applicable candidates only when they can materially constrain the plan. Record the dimension in `context.unknown` and explain it in `unresolved`; do not silently treat it as a match.
## Worklist
1. Normalize the plan into: request summary, development kind, assumptions, root cause or design intent, affected files and symbols, proposed changes, test strategy, and acceptance criteria. When the plan has no normalized kind, apply the same categories as `al-development`: new or expanded behavior is `feature`, a defect correction is `bug`, behavior-preserving restructuring is `refactor`, migration is `upgrade`, and other bounded work is `maintenance`. A repository-specific additive event or extensibility request maps to `feature`; retain its original work-item type in the request summary. Do not redesign the repository-specific workflow.
2. Build retrieval vocabulary from the plan and confirmed repository symbols. Give exact object types, properties, methods, analyzers, errors, and affected domains more weight than broad business nouns.
3. Search the index in separate passes:
- data ownership, keys, setup, numbering, validation, transactions, and upgrade;
- behavior, events, interfaces, errors, permissions, privacy, and telemetry;
- pages, reports, APIs, integrations, localization, and accessibility;
- tests, analyzers, packaging, and deployment constraints.
4. Add an article when its keywords or indexed topic match a concrete planned change, affected symbol, acceptance criterion, or validation obligation. Applicability alone is not enough.
5. Open every selected article in full. Read any referenced `.good.*` and `.bad.*` sibling needed to make the constraint concrete. Never cite an index row that was not opened.
6. Resolve contradictory normative guidance with READ's layer precedence and record losing candidates in `suppressed`.
7. Check the resulting worklist across the whole plan. A bug fix may require testing, data, performance, and upgrade guidance at once; a feature plan may require security and lifecycle constraints that are not named in its title.
Keep the worklist focused. Do not include generic engineering advice, an entire domain, or an article that would not change implementation or validation.
## Action
For each worklist article:
1. Copy its exact path and optional commit SHA.
2. State `used-for` as the concrete plan decision or affected surface.
3. Translate its normative Best Practice and Anti Pattern into short implementation constraints without adding facts or weakening conditions.
4. Include only opened, existing sibling samples in `sample-paths`.
5. Derive validation considerations only where the plan or selected knowledge requires observable evidence. Describe the evidence to obtain; do not claim it already exists or passed.
Do not change the target repository. Before emitting, verify every knowledge and sample path exists in the live BCQuality checkout and was opened during this run. If reference integrity cannot be established, return `failed` rather than fabricating guidance.
## Output
Return one `development-guidance-report` conforming to DO. `completed` requires that every selected article was opened and faithfully converted into constraints. `no-knowledge` is valid when the plan is applicable but BCQuality contains no relevant article. `partial` names every unevaluated candidate or unresolved applicability gap.

View file

@ -0,0 +1,91 @@
---
kind: action-skill
id: al-development
version: 1
title: AL development
description: Implements Business Central AL features, bug fixes, refactors, upgrades, and maintenance changes using BCQuality knowledge.
inputs: [development-request, repository]
outputs: [implementation-report]
bc-version: [all]
technologies: [al]
countries: [w1]
application-area: [all]
guidance-skill: microsoft/skills/development/al-development-plan.md
quality-skill: microsoft/skills/review/al-code-review.md
---
# AL development
Implements a Business Central change in an existing AL repository. Feature work, bug fixing, refactoring, upgrades, and maintenance share one public contract and one quality pipeline; their different investigation disciplines are execution modes within this skill.
Both a writable `repository` and a `development-request` are required. A structured request has this shape:
```yaml
development-request:
kind: auto # feature | bug | refactor | upgrade | maintenance
description: string # optional when plan states the requested outcome
plan: string # optional
acceptance-criteria: [string] # optional
```
A plain-text request is normalized to `kind: auto` with the text as `description`. A plan-only request is valid when the plan states the requested outcome. Return `not-applicable` without changing files when either input is absent, both description and plan are empty, or the repository is not an AL project.
## Source
Read the frontmatter `guidance-skill`; it owns BCQuality discovery and returns the knowledge constraints for the implementation plan. Inspect the target repository for `app.json`, existing objects, tests, permission sets, analyzers, build scripts, naming and object-ID conventions, dependencies, target/runtime versions, localization layout, and uncommitted user changes. For bugs, refactors, and upgrades, inspect enough history and surrounding code to establish the behavior being changed.
## Relevance
Resolve and pass this context to the guidance-skill:
- `bc-version` from the target application's platform/application/runtime settings or supplied context. For an upgrade, distinguish source and target versions.
- `technologies: [al]`, plus any additional technology actually required by the request.
- `countries` from `app.json`, workspace configuration, or supplied context.
- `application-area` from the request and affected objects.
Record unresolved dimensions in the development plan rather than silently substituting broad values. The guidance-skill applies READ's matching semantics and returns any conditional applicability in its report.
## Worklist
1. Normalize the request, deriving a concise description from a plan-only input, and classify `kind: auto` as:
- `feature` for new or intentionally expanded behavior;
- `bug` for observed behavior that contradicts an expected result;
- `refactor` for structural change with no intended behavior change;
- `upgrade` for schema, data, dependency, runtime, or application-version migration;
- `maintenance` for bounded development work that fits none of the above.
Preserve an explicit valid kind. When repository evidence conflicts with it, record the mismatch and ask for clarification before changing files rather than silently switching disciplines.
2. Establish the mode-specific implementation contract:
- **Feature:** define user-visible behavior and cover data lifecycle, UI/API, permissions, extensibility, upgrade impact, telemetry, and tests where applicable.
- **Bug:** state expected versus actual behavior, reproduce or otherwise prove the defect, trace the root cause, and define a regression test that fails for that cause.
- **Refactor:** identify the behavior and public contracts that must remain invariant, plus the checks that establish a before/after baseline.
- **Upgrade:** identify source and target states, data migration, compatibility, idempotency, and validation requirements.
- **Maintenance:** define the bounded outcome and the behavior that must not change.
3. Treat a supplied plan as an input constraint, not as proof. Reconcile it with repository reality and BCQuality; preserve its intent, correct unsafe assumptions, and record consequential deviations.
4. Discover existing implementation patterns and reusable objects before proposing new ones. Preserve repository conventions and current user changes.
5. Materialize a `development-plan` containing the classified kind, request, assumptions, affected files and symbols, design or root cause, proposed changes, validation strategy, and acceptance criteria.
6. Invoke the frontmatter `guidance-skill` with that plan, the repository, and the resolved context. It performs Source, Relevance, and knowledge worklisting independently and read-only.
7. Require a complete guidance result before editing product code:
- `completed` — use every returned constraint and validation consideration.
- `no-knowledge` — return `no-knowledge` without implementing a Business Central-specific change.
- `not-applicable`, `partial`, or `failed` — return the corresponding non-completed outcome without editing product code; preserve its reason in `remaining`.
8. Copy the guidance report's selected paths into the eventual implementation report only when the corresponding constraint materially shaped the implementation. Carry its suppression records forward.
## Action
1. Record the starting working-tree state so unrelated changes are preserved and excluded from `changes`.
2. Apply the execution mode:
- **Feature:** implement the smallest complete vertical slice; do not leave placeholder surfaces.
- **Bug:** reproduce first when feasible, fix the root cause rather than the symptom, keep the patch surgical, and add a regression test.
- **Refactor:** capture a behavioral baseline, avoid unrelated behavior changes, and prove the declared invariants afterward.
- **Upgrade:** make migrations rerunnable where required, preserve data and compatibility, and validate both upgraded and fresh-install paths when applicable.
- **Maintenance:** make only the bounded requested change and preserve surrounding behavior.
3. Produce a coherent design that satisfies the implementation contract and every constraint returned by the guidance-skill. Reuse existing abstractions and object ranges. Do not hard-code a Business Central fact in this skill or invent a rule absent from both the repository and reliable platform knowledge.
4. Implement the request end to end. Include all surfaces required by the mode, acceptance criteria, and repository conventions. Do not create success-shaped stubs.
5. Treat the guidance report as design constraints throughout implementation. Adapt its referenced companion samples to the target codebase; never copy demonstration IDs or names blindly.
6. Run the smallest existing build, analyzer, and test commands that cover the change. Fix failures caused by the implementation. Record every command and real outcome in `validation`; unavailable checks are `not-run`, never `passed`.
7. Invoke the frontmatter `quality-skill` against the final implementation diff. Fix all justified knowledge-backed `blocker` and `major` findings and concrete defects introduced by this work, then rerun affected validation and review. Preserve the last findings-report in `review` and add a `validation` entry with `id: "review"`. If review is disabled or unavailable, record `not-run` and return `partial`.
8. Verify the persisted files against the implementation contract, acceptance criteria, and mode-specific evidence. If behavior, validation, or review remains incomplete, return `partial` and list the exact gap in `remaining`.
## Output
Return one `implementation-report` conforming to DO. Set `plan.kind` to the classified execution mode. `knowledge` lists only articles opened in full and materially used. `changes` lists only files changed by this skill. `completed` requires a persisted implementation, passing required validation, and no unresolved `blocker` or `major` finding in `review`.

View file

@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types — especially `* Setup` singleton tables and Card pages, custom master tables, tableextensions that add master-data fields, and document or journal lines that reference a master.
- The changed fields, keys, triggers, and procedures, weighted toward `Primary Key`, `No.`, `No. Series`, `Blocked`, `Last Date Modified`, `OnInsert`, `OnModify`, `OnRename`, reference-field `OnValidate`, and posting validation.
- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`).
- Tokens extracted from the diff that relate to data modeling (`setup`, `master`, `Primary Key`, `Code[10]`, `Code[20]`, `AutoIncrement`, `SystemId`, `No.`, `No. Series`, `NoSeriesManagement`, `Codeunit "No. Series"`, `GetNextNo`, `IsManual`, `TestManual`, `Blocked`, `TestField`, `Last Date Modified`, `Today`, `WorkDate`, `InsertAllowed`, `DeleteAllowed`, `PageType = Card`, `OnOpenPage`, `GetRecordOnce`, `OnInsert`, `OnModify`, `OnRename`, `InitRecord`, `Round`, `Precision`, `Direction`, `TableRelation`, `tableextension`, `enumextension`, `Media`, `MediaSet`, `Item`, `Count`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no data-modeling changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.
@ -52,6 +52,8 @@ The following targeted checks cover every current `data-modeling` article. Treat
- A master table adds or changes `Last Date Modified`, `OnModify`, or `OnRename`, but the non-editable field is not assigned `Today()` in both triggers — `set-last-date-modified-in-onmodify-and-onrename`.
- A `tableextension` appends a conditional `TableRelation` as if it overrides an earlier unconditional relation, or relation branches are otherwise designed without accounting for additive top-down evaluation — `table-relation-extensions-are-additive-and-top-down`.
- A `Media` or `MediaSet` field is assigned directly between different table types or different field IDs instead of registering each shared item with `MediaSet.Insert` — `share-mediaset-items-with-insert-not-field-assignment`.
- A custom document header assigns defaults outside an `InitRecord` boundary, calls `InitRecord` before assigning its number, or places UI-independent defaults only in a page trigger — `initialize-document-defaults-in-initrecord`.
- Directed `Round` calls use `'<'` as mathematical floor or `'>'` as mathematical ceiling, especially where negative amounts are possible — `round-direction-symbols-use-magnitude`.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ. Drop lower-precedence files whose normative guidance (`## Best Practice` or `## Anti Pattern`) directly contradicts a higher-precedence candidate, and record each dropped file in `suppressed` with `reason: "layer-precedence"`. Files that would have been candidates but are hidden because their layer is disabled in consumer configuration are recorded with `reason: "configuration"`. Files that never became candidates are NOT recorded in `suppressed`.

View file

@ -39,7 +39,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types — especially `interface` objects, codeunits and enums declared with the `implements` keyword, and consumers that declare or assign an `Interface` variable.
- The changed procedures and triggers, weighted toward factory or dispatch routines that resolve a variant to behaviour, setter-injection procedures that take an `Interface` parameter, and `case`-over-enum blocks that select between strategies.
- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `case`, and the `case <enum> of` anti-pattern signal — a `case` over an enum value whose branches choose between variant computations).
- Tokens extracted from the diff that relate to interfaces and enum-backed implementation (`interface`, `extends`, `implements`, `Implementation`, `DefaultImplementation`, `UnknownValueImplementation`, `enum`, `Extensible`, `Interface`, `Variant`, `is`, `as`, `case`, and the `case <enum> of` anti-pattern signal — a `case` over an enum value whose branches choose between variant computations).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.
@ -54,6 +54,7 @@ The following targeted checks map diff signals to specific `interfaces` articles
- `DefaultImplementation` used as the only fallback where a persisted ordinal may no longer match any declared enum value, or a persisted enum lacks `UnknownValueImplementation` on BC18 or later — `handle-unknown-enum-ordinals-with-unknownvalueimplementation`.
- A method added directly to an interface that exists in the baseline, instead of adding a BC25+ interface that `extends` it or a versioned sibling for older targets — `extend-published-interfaces-dont-edit-them`.
- A declared enum value with no `Implementation` and no enum-level `DefaultImplementation` — `set-defaultimplementation-on-enum`.
- An `Interface` or `Variant` is cast with `as` to an optional extended interface without first establishing support with `is` — `guard-interface-casts-with-is`.
For `set-defaultimplementation-on-enum`, inspect the complete containing enum before emitting. An enum-level `DefaultImplementation = <Interface> = <Codeunit>;` conclusively covers every declared value that omits its own `Implementation`; do not flag such a value and do not replace the intentional fallback with a per-value mapping.

View file

@ -41,7 +41,7 @@ Narrow the relevant files to the subset that applies to the changes under review
- Changed AL objects — especially API pages (`PageType = API`), tables and pages declaring Labels/TextConsts, codeunits issuing `Error`/`Message`/`Confirm`, and any file whose name violates the `<ObjectName>.<ObjectType>.al` convention.
- Changed declarations, weighted toward `: Label '...'`, `: TextConst '...'`, temporary record variables, option fields, error-handling call sites, and codeunit-internal method calls.
- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`).
- Tokens extracted from the diff (`Label`, `TextConst`, `Locked`, `Comment`, `MaxLength`, `temporary`, `OptionMembers`, `OptionCaption`, `ApplicationArea`, `APIPublisher`, `APIGroup`, `APIVersion`, `EntityName`, `EntitySetName`, `DelayedInsert`, `FieldCaption`, `TableCaption`, `FieldName`, `TableName`, `Page.RunModal`, `Report.Run`, `this.`, `StrSubstNo`).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object or declaration. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.
@ -51,6 +51,7 @@ Apply these high-signal mappings before fuzzy topic ranking:
- A `Label` or `TextConst` contains multiple or ambiguous placeholders but has no `Comment`, or its Comment does not explain every placeholder — `label-comment-explains-placeholders.md`. A single placeholder whose meaning is explicit in the text, such as `Customer %1`, is allowed without a Comment and must not be flagged.
- `function-call-parentheses-required.md` applies only to a zero-argument invocation written without `()`. Never worklist it from an invocation that already has parentheses or supplies arguments, including `Error(Label, Arg1, Arg2)`.
- On runtime 10.0 or later, a page child may inherit `ApplicationArea` from its page object; do not flag that shape. A control added by a page or report extension still requires an explicit value — `applicationarea-required-on-page-controls.md`.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.

View file

@ -41,10 +41,15 @@ Narrow the relevant files to the subset that applies to the changes under review
- **UI-file filter.** UI review applies to files declaring `page`, `pageextension`, or `pagecustomization`, and to JavaScript/CSS/HTML that implements a control add-in's rendering or Business Central communication. When the diff contains no such files, return `outcome: "not-applicable"` without evaluating knowledge files.
- For each relevant knowledge file, compute overlap against changed page declarations and control add-in files, weighted toward `Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `OptionCaption`, `ShowCaption`, `InstructionalText`, `GridLayout`, `Style`, `StyleExpr`, promoted action definitions, field importance, page background tasks, DOM creation, ARIA attributes, keyboard/focus handlers, packaged-resource AJAX, and calls from JavaScript into AL.
- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions).
- Tokens extracted from the diff (`Caption`, `ToolTip`, `AboutTitle`, `AboutText`, `PageType`, `ShowCaption`, `InstructionalText`, `grid`, `fixed`, `GridLayout`, `Style`, `StyleExpr`, `Importance`, `Promoted`, `Additional`, `area(Promoted)`, `actionref`, `PromotedCategory`, `PromotedOnly`, `PromotedIsBig`, `ShowAs`, `SplitButton`, `fieldgroups`, `DropDown`, `UpdatePropagation`, `EnqueueBackgroundTask`, `OnAfterGetCurrRecord`, `OnAfterGetRecord`, `OnPageBackgroundTaskCompleted`, `OnPageBackgroundTaskError`, `RunPageBackgroundTask`, `Favorable`, `Unfavorable`, `Ambiguous`, `cuegroup`, `controladdin`, `control-add-in`, `usercontrol`, `aria-`, `tabindex`, `keydown`, `focus`, `innerHTML`, `createElement`, `packaged-resource`, `ajax`, `$.get`, `$.ajax`, `XMLHttpRequest`, `xhrFields`, `withCredentials`, `withcredentials`, `InvokeExtensibilityMethod`, `invokeextensibilitymethod`, `skipIfBusy`, `successCallback`, `success-callback`, `errorCallback`, `setInterval`, `JSON.stringify`, `payload`, `throttling`, `reduced-functionality`, `ClientServicesMaxUploadSize`, `&`, `Specifies`, `Message(`, `Confirm(`, `Error(` in a page context, `Disabled`, `Invalid`, `Whitelist`, `Blacklist`, trailing punctuation patterns on captions).
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed page element. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone.
Apply these high-signal mappings before fuzzy topic ranking:
- A tableextension adds a field to `DropDown` while the corresponding lookup-page control remains `Visible = false` — `dropdown-fieldgroup-respects-lookup-page-visibility`.
- An editable page part affects a total, FlowField, or FactBox on the parent but does not set `UpdatePropagation = Both` — `updatepropagation-both-refreshes-main-page`.
Once the candidate worklist is known, resolve layer-precedence conflicts per READ and record suppressions.
When the post-conflict worklist is empty because no applicable UI knowledge exists, or because configuration suppressed every candidate, emit `outcome: "no-knowledge"`. When the worklist is empty because no applicable UI knowledge matched the page changes, emit `outcome: "completed"` with an empty `findings` array.

View file

@ -39,10 +39,12 @@ Narrow the relevant files to the subset that applies to the changes under review
- The changed AL object names and types — especially codeunits with `Subtype = Upgrade` or `Subtype = Install`, tables and tableextensions adding or changing fields, enums and enumextensions, and objects under `Hybrid*`/`Migration`/`Upgrade` namespaces.
- The changed triggers and procedures, weighted toward `OnCheckPreconditionsPerCompany`/`PerDatabase`, `OnUpgradePerCompany`/`PerDatabase`, `OnValidateUpgradePerCompany`/`PerDatabase`, `OnInstallAppPerCompany`/`PerDatabase`, the `OnGetPerCompanyUpgradeTags`/`OnGetPerDatabaseUpgradeTags` subscribers, and helper procedures transitively reachable from those entry points.
- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `DataVersion`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
- Tokens extracted from the diff that relate to upgrade concerns (`Subtype = Upgrade`, `Subtype = Install`, `Upgrade Tag`, `HasUpgradeTag`, `SetUpgradeTag`, `OnCheckPreconditions`, `OnUpgrade`, `OnValidateUpgrade`, `OnInstallApp`, `DataTransfer`, `CopyFields`, `Insert`, `Modify`, `Delete`, `Rename`, `InitValue`, `ObsoleteState`, `ObsoleteReason`, `ObsoleteTag`, `ModuleInfo`, `AppVersion`, `DataVersion`, `NavApp.GetCurrentModuleInfo`, `ExecutionContext`, `PrimaryKey`, `key(`, `field(`, `value(`, `enum`, `enumextension`, `HybridSL`, `HybridGP`, `HybridBC`, `HybridBaseDeployment`).
- For each `OnCheckPreconditions...` and `OnValidateUpgrade...` trigger, build the best available call graph from surrounding unchanged source as well as changed hunks, tracing resolved calls through reachable local or internal helpers. Worklist the check-only rule when a database write occurs either directly in the trigger or in any helper procedure reachable from it. Writes include `Insert`, `Modify`, `ModifyAll`, `Delete`, `DeleteAll`, `Rename`, and `DataTransfer`. Also perform the reverse check when a PR changes a writing helper body: worklist the rule when that helper is invoked directly or transitively by an unchanged check or validation trigger.
- Treat a direct write or a fully resolved call chain as high-confidence evidence. When cross-object dispatch, unavailable declarations, or an incomplete call graph prevents proving the complete chain, cap confidence at `medium`, name the unresolved edge in the finding, and do not claim a violation without a resolved path from a check or validation trigger to a write.
- Worklist the install-versus-upgrade rule when migration helpers are reachable only from an install codeunit.
- Worklist `install-and-upgrade-codeunits-have-no-order.md` when a change adds multiple install or upgrade codeunits whose same-phase triggers share state or depend on one another.
- Worklist `appversion-meaning-depends-on-execution-context.md` when install or upgrade code branches on `ModuleInfo.AppVersion()` or confuses it with `DataVersion()`.
A file enters the candidate worklist when its `keywords` intersect the extracted tokens or its topic (derived from the index entry's `path`, `title`, and `description`) matches a changed object type. Read an article's full file — its `## Best Practice` / `## Anti Pattern` bodies — only after it makes the worklist; candidate selection uses the index alone. When the diff contains no upgrade-related changes by any of the above signals, return `outcome: "not-applicable"` without evaluating files.

View file

@ -1,7 +1,7 @@
{
"name": "bcquality",
"description": "Quality skills and knowledge for Business Central development. Exposes a standalone AL review adapter backed by BCQuality's Entry protocol.",
"version": "0.2.0",
"description": "Quality skills and knowledge for Business Central development. Exposes AL development and code-review adapters backed by BCQuality's Entry protocol.",
"version": "0.3.0",
"author": {
"name": "microsoft/BCQuality",
"url": "https://github.com/microsoft/BCQuality"
@ -13,6 +13,7 @@
"al",
"business-central",
"code-review",
"development",
"quality"
],
"skills": [

View file

@ -1,7 +1,7 @@
# BCQuality global skills
This folder contains BCQuality's layer-independent protocol files and the
host-native adapter used by standalone plugin installations.
host-native adapters used by standalone plugin installations.
The protocol files have two kinds:
@ -31,8 +31,9 @@ READ and DO are read on demand — typically by the first action skill the agent
| Path | Role |
|---|---|
| [`al-code-review/SKILL.md`](al-code-review/SKILL.md) | Exposes BCQuality through the standard `SKILL.md` format when this repository is installed as a plugin. |
| [`al-development/SKILL.md`](al-development/SKILL.md) | Exposes knowledge-backed AL development through the standard `SKILL.md` format. |
The adapter is deliberately thin. It translates the caller's request into an
Each adapter is deliberately thin. It translates the caller's request into an
Entry task context, then follows Entry's dispatch without owning routing,
review, index, or output policy. It is not an action skill, is not considered
by Entry, and should not accumulate behavior already defined by `entry.md`,
@ -40,20 +41,23 @@ by Entry, and should not accumulate behavior already defined by `entry.md`,
This gives the two skill formats distinct roles:
- `skills/al-code-review/SKILL.md` is the public host integration surface for a
standalone plugin installation.
- `skills/al-code-review/SKILL.md` and
`skills/al-development/SKILL.md` are the public host integration
surfaces for a standalone plugin installation.
- `microsoft/skills/review/al-code-review.md` is BCQuality's internal
Microsoft-layer super-skill for coordinating a broad AL review.
- `microsoft/skills/development/al-development.md` is the internal
Microsoft-layer implementation skill for all supported development modes.
- `microsoft/skills/development/al-development-plan.md` is the read-only
planning interface for repository-specific orchestrators that retain
implementation ownership.
The host adapter and internal coordinator deliberately share the
`al-code-review` name because they represent the same user-facing operation in
their respective formats. Their locations distinguish their roles. The
adapter remains distinct from BC-ALAgents' separately installed `al-review`
skill, avoiding a collision in hosts that use one shared skill inventory. The
reference from the adapter to Entry, and from a dispatched super-skill to its
leaf skills, is intentional progressive disclosure. It avoids registering
every internal BCQuality protocol file as an ambient host skill while allowing
each review domain to run in an isolated context.
Each host adapter deliberately shares its name with the internal action skill
for the same operation. Their locations distinguish the host integration from
the layered policy. `al-code-review` remains distinct from BC-ALAgents'
separately installed `al-review` skill, avoiding a collision in hosts that use
one shared skill inventory. References from adapters to Entry, and from a
dispatched super-skill to its leaves, are intentional progressive disclosure.
These contracts are stable. Changes require a PR approved by both maintainers.

View file

@ -0,0 +1,29 @@
---
name: al-development
description: Implement Business Central AL features, bug fixes, refactors, upgrades, and maintenance changes using BCQuality's curated platform knowledge.
---
# AL development
This is BCQuality's host-native adapter for standalone plugin installations. It translates a coding request into Entry's task context; the internal action skill owns classification, investigation, design, implementation, validation, and review policy.
When the target repository exposes a more specific local workflow for the request, such as an end-to-end bug-fix skill with its own environment and delivery gates, prefer that repository workflow unless the caller explicitly asks to use BCQuality's generic development skill.
## Execute
1. Resolve `PLUGIN_ROOT` to the directory containing this plugin's root `plugin.json`. This file is `PLUGIN_ROOT/skills/al-development/SKILL.md`; when the host does not expose the plugin root, resolve it two levels above this file.
2. Build the `task-context` required by `PLUGIN_ROOT/skills/entry.md`:
- Copy the caller's request verbatim into `goal`.
- Set `inputs-available: [development-request, repository]`.
- Set `technologies: [al]` when the repository is an AL project.
- Pass `bc-version`, `countries`, and `application-area` only when supplied or reliably determined.
- Apply `BCQUALITY_ENABLED_LAYERS` and `BCQUALITY_DISABLED_SKILLS` exactly as the `al-code-review` adapter does.
3. Read and execute `PLUGIN_ROOT/skills/entry.md`, including Preparation. Resolve every path it names against `PLUGIN_ROOT`, not the user's repository. If knowledge-index generation is unavailable, use READ's path-based fallback.
4. Follow Entry's dispatch exactly. The normal result is `microsoft/skills/development/al-development.md`; do not select it directly or duplicate its behavior in this adapter.
5. Normalize the caller's input as `development-request`:
- Plain text becomes `{ kind: auto, description: <verbatim text> }`.
- Preserve an explicit `kind`, supplied plan, and `acceptance-criteria`.
- A plan-only input becomes `{ kind: auto, description: "Implement the supplied development plan.", plan: <verbatim plan> }`.
Pass the writable current workspace as `repository`, execute the dispatched skill, and return its `implementation-report` unchanged.
The adapter never edits BCQuality itself unless BCQuality is the caller's target repository. The target of implementation is the repository supplied by the caller.

View file

@ -19,7 +19,7 @@ An action skill is a single markdown file with YAML frontmatter. It lives inside
- `/community/skills/` — community-contributed action skills.
- `/custom/skills/` — partner or customer action skills (typically in a consumer repo, not in BCQuality itself).
Action skills do not live at the repo root. The files in `/skills/` — the three meta-skill contracts (READ, DO, WRITE) and the entry-point skill (`entry.md`, `kind: entry-point`) — are the only skills that sit outside a layer. The entry-point skill structurally follows this same four-step pattern but produces a dispatch record rather than a findings-report; see `skills/entry.md` for its contract.
Action skills do not live at the repo root. The files in `/skills/` — the three meta-skill contracts (READ, DO, WRITE) and the entry-point skill (`entry.md`, `kind: entry-point`) — are the only skills that sit outside a layer. The entry-point skill structurally follows this same four-step pattern but produces a dispatch record rather than an action-skill report; see `skills/entry.md` for its contract.
## Skills hold mechanics; knowledge files hold BC facts
@ -56,10 +56,20 @@ application-area: [all]
`bc-version`, `technologies`, `countries`, `application-area` are optional filters that let an orchestrator pre-select applicable skills for a task. They follow the same semantics as in READ.
`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`. `outputs` is always a single-element list naming the output kind; today only `findings-report` is defined.
`inputs` is a list of abstract input types the skill **accepts**. Standard values: `pr-diff`, `object-list`, `file-path`, `repository`, `telemetry-query`, `development-request`, `development-plan`. Semantics are any-of: the orchestrator supplies whichever listed input types it has, and the skill is invoked with a non-empty subset of its declared `inputs`. A skill that cannot proceed with the supplied subset MUST return `outcome: "not-applicable"`.
`outputs` is always a single-element list naming the output kind:
- `findings-report` — evaluates an input and reports defects or observations.
- `implementation-report` — changes a repository to satisfy a development request and reports the plan, knowledge used, changed files, validation, and post-implementation review.
- `development-guidance-report` — selects and summarizes applicable BCQuality knowledge for an existing development plan without changing the target repository.
`sub-skills` is an optional field. When present and non-empty, the skill is a **super-skill** that composes other action skills; see *Composition* below. Values are repo-relative paths to action-skill files.
`quality-skill` is optional on an action skill that emits an `implementation-report`. It names one repo-relative review action skill to run over the completed diff. It is a post-implementation gate, not a composed sub-skill: Entry does not route through it, and its complete findings-report is returned in `review`. Consumer configuration still applies; if the named quality skill is disabled or unavailable, record its validation as `not-run` and do not claim `completed`.
`guidance-skill` is optional on an action skill that emits an `implementation-report`. It names one repo-relative read-only action skill that accepts a `development-plan` and emits a `development-guidance-report`. The implementation skill invokes it after forming its plan and before editing product code. Consumer configuration still applies; when guidance is disabled or unavailable, the implementation skill must not claim knowledge-backed development.
## Required sections
Every action skill MUST contain these five sections, in order:
@ -80,7 +90,7 @@ Every action skill MUST contain these five sections, in order:
**Action.** Execute the skill's work against the worklist. Evaluate each item in the worklist against the task input and emit findings. The action step is where skill behavior differs; the preceding three steps are uniform.
## Output contract
## Findings-report contract
Every action skill emits a single JSON document that conforms to this schema:
@ -242,6 +252,136 @@ Severity taxonomy:
- `minor` — quality concern; worth flagging but not a gate.
- `info` — observation or context; not actionable on its own.
## Development-guidance-report contract
An action skill with `outputs: [development-guidance-report]` emits one JSON document:
```json
{
"skill": { "id": "string", "version": 1 },
"outcome": "completed | not-applicable | no-knowledge | partial | failed",
"outcome-reason": "string",
"summary": {
"request": "string",
"kind": "feature | bug | refactor | upgrade | maintenance",
"candidates": 0,
"selected": 0
},
"context": {
"bc-version": "string",
"technologies": ["string"],
"countries": ["string"],
"application-area": ["string"],
"unknown": ["bc-version | technologies | countries | application-area"]
},
"knowledge": [
{
"path": "string",
"sha": "string",
"used-for": "string",
"constraints": ["string"],
"sample-paths": ["string"]
}
],
"validation-considerations": [
{
"id": "string",
"reason": "string",
"evidence": "string"
}
],
"suppressed": [
{
"reference": { "path": "string", "sha": "string" },
"reason": "layer-precedence | configuration"
}
],
"unresolved": ["string"]
}
```
The skill is read-only with respect to the target repository. `completed` means every selected article was opened and converted into faithful implementation constraints. `no-knowledge` means no applicable article survived filtering; `knowledge` is empty. `partial` means candidate evaluation stopped early, with the gap named in `outcome-reason` and `unresolved`.
`knowledge[].constraints` summarizes only normative `## Best Practice` and `## Anti Pattern` content from the referenced article. It must not introduce a Business Central fact absent from that article. `sample-paths` contains only sibling samples that exist and were opened. Every path is subject to the reference-integrity gate.
`validation-considerations` states evidence the implementation workflow should obtain; it does not claim that a command or test has run. `unresolved` records missing repository context or plan decisions that prevent a reliable constraint. Unknown applicability dimensions must appear in both `context.unknown` and a relevant unresolved entry.
## Implementation-report contract
An action skill with `outputs: [implementation-report]` emits one JSON document:
```json
{
"skill": { "id": "string", "version": 1 },
"outcome": "completed | not-applicable | no-knowledge | partial | failed",
"outcome-reason": "string",
"summary": {
"request": "string",
"files-created": 0,
"files-modified": 0,
"files-deleted": 0
},
"plan": {
"kind": "feature | bug | refactor | upgrade | maintenance",
"assumptions": ["string"],
"decisions": ["string"],
"objects": ["string"]
},
"knowledge": [
{ "path": "string", "sha": "string", "used-for": "string" }
],
"changes": [
{
"path": "string",
"action": "created | modified | deleted",
"purpose": "string"
}
],
"validation": [
{
"id": "string",
"command": "string",
"status": "passed | failed | not-run",
"details": "string"
}
],
"review": { "...full findings-report from the post-implementation review..." : null },
"suppressed": [
{
"reference": { "path": "string", "sha": "string" },
"reason": "layer-precedence | configuration"
}
],
"remaining": ["string"]
}
```
### Implementation outcome semantics
- `completed` — the requested change is persisted in the repository, required validation passed, and the post-implementation review has no unresolved `blocker` or `major` finding.
- `not-applicable` — the request is not an implementation task accepted by the skill, or the supplied repository does not contain the required technology.
- `no-knowledge` — no applicable BCQuality knowledge survived filtering and the skill cannot safely implement the Business Central-specific request. No request changes are made.
- `partial` — useful changes were persisted, but part of the requested scope, validation, or post-implementation review could not be completed. `outcome-reason` and `remaining` identify the unfinished work.
- `failed` — the skill could not produce a reliable implementation. `outcome-reason` is required. Any working-tree changes remain visible and MUST still be listed in `changes`.
### Implementation field semantics
**`summary.request`** is a concise statement of the implemented change. File counts describe only changes made by this skill; pre-existing user changes are excluded.
**`plan`** records the implementation decisions needed to understand the result. `kind` is the classified development mode: `feature`, `bug`, `refactor`, `upgrade`, or `maintenance`. `assumptions` contains only assumptions actually made; `decisions` captures consequential design choices; `objects` names the Business Central objects or other artifacts created or changed.
**`knowledge`** lists every knowledge file whose normative guidance materially shaped the implementation. `path` and optional `sha` follow the same reference format as a findings-report. `used-for` briefly names the design or implementation decision. The reference-integrity gate applies: every path must exist in the live checkout, be copied verbatim from discovery, and have been opened in full. Applicability alone is not enough to list an article.
**`changes`** is an exhaustive list of files created, modified, or deleted by the skill. Paths are repository-relative and use forward slashes. Do not include unrelated pre-existing changes.
**`validation`** records commands actually run. `passed` and `failed` require a real command result; unavailable tooling or an intentionally skipped check is `not-run` with `details`. A skill MUST NOT manufacture a successful check or replace a failed command with a success-shaped fallback.
**`review`** is optional for generic implementation skills and required when a skill's instructions mandate post-implementation review. When present, it is the complete findings-report returned by that review skill, not a rewritten summary.
**`suppressed`** has the same semantics as in a findings-report and records applicable knowledge excluded by layer precedence or configuration.
**`remaining`** contains concrete unfinished work only. It is empty for `completed`.
## Composition (super-skills)
A **super-skill** is an action skill whose frontmatter declares a non-empty `sub-skills: [...]`. A super-skill does not evaluate knowledge files directly; it invokes other action skills and composes their output.
@ -316,4 +456,4 @@ Conforms to the DO output contract.
## How orchestrators consume output
An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs`, receives the JSON output, and maps findings to its delivery surface (PR comments, build gates, IDE diagnostics). The orchestrator MUST NOT interpret skill-specific fields beyond the schema above. Skills that need richer semantics MUST encode them within the schema (for example, by adding structured `message` text) rather than extending the output shape.
An orchestrator invokes an action skill with an input appropriate to the skill's declared `inputs` and uses the single output kind declared in frontmatter. It maps a `findings-report` to PR comments, build gates, or IDE diagnostics; a `development-guidance-report` to constraints for a downstream implementation workflow; and an `implementation-report` to a coding-session summary, changed-file view, validation status, and any remaining work. The orchestrator MUST NOT interpret fields beyond the three schemas above.

View file

@ -21,6 +21,9 @@ The agent invokes Entry with a **task context** supplied by the orchestrator:
task-context:
goal: string # free-text description of what needs doing
inputs-available: # values the orchestrator has ready to pass to a chosen skill
- development-request
- development-plan
- repository
- pr-diff
- file-path
technologies: [al]
@ -174,7 +177,7 @@ Populated example (PR review on a repo where only `al-performance-review` is ena
1. Invoke Entry with the orchestrator-supplied task context.
2. Receive the dispatch record.
3. For each entry in `dispatch[]`, read the referenced action skill, execute its Source → Relevance → Worklist → Action steps per DO, and produce a findings-report.
4. Return the findings-reports to the orchestrator. When `outcome` is `no-match` or `failed`, return the dispatch record itself so the orchestrator can log the reason.
3. For each entry in `dispatch[]`, read the referenced action skill, execute its Source → Relevance → Worklist → Action steps per DO, and produce the report kind declared by that skill's single `outputs` value.
4. Return the action-skill reports to the orchestrator. When Entry's `outcome` is `no-match` or `failed`, return the dispatch record itself so the orchestrator can log the reason.
READ and DO are the contracts that govern what the dispatched skills do. An agent that has not yet read READ and DO reads them when it executes the first dispatched skill — they are not prerequisites for invoking Entry.

View file

@ -0,0 +1,516 @@
<#
.SYNOPSIS
Validates and prepares BCQuality AL development fixtures.
.DESCRIPTION
Static validation checks fixture IDs, capability links, knowledge references,
and the development skill. -PrepareDirectory emits opaque requests for model
runs. -ResultsDirectory scores implementation reports produced by an
external runner that performed the declared compile, test, and review checks.
#>
[CmdletBinding()]
param(
[string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')),
[string] $ManifestPath,
[string] $CapabilitiesPath,
[string] $PrepareDirectory,
[string] $ResultsDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path -LiteralPath $Root).Path
if (-not $ManifestPath) {
$ManifestPath = Join-Path $Root 'evaluation/development-fixtures.json'
}
if (-not $CapabilitiesPath) {
$CapabilitiesPath = Join-Path $Root 'coverage/development-capabilities.json'
}
$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
$capabilityManifest = Get-Content -LiteralPath $CapabilitiesPath -Raw | ConvertFrom-Json
$problems = [System.Collections.Generic.List[string]]::new()
function Get-ModelCaseId {
param([string] $ManifestId)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestId)
$hash = $sha.ComputeHash($bytes)
$token = ([System.BitConverter]::ToString($hash) -replace '-', '').Substring(0, 8).ToLowerInvariant()
return "case-$token"
} finally {
$sha.Dispose()
}
}
if ($manifest.version -ne 1) {
$problems.Add("Unsupported development fixture version: $($manifest.version)") | Out-Null
}
if ($capabilityManifest.version -ne 1) {
$problems.Add("Unsupported capability manifest version: $($capabilityManifest.version)") | Out-Null
}
foreach ($thresholdName in @('minimumKnowledgeRecall', 'minimumKnowledgePrecision')) {
$threshold = [double]$manifest.$thresholdName
if ($threshold -lt 0 -or $threshold -gt 1) {
$problems.Add("$thresholdName must be between 0 and 1.") | Out-Null
}
}
$skillPath = [string]$manifest.skill
if (-not (Test-Path -LiteralPath (Join-Path $Root $skillPath) -PathType Leaf)) {
$problems.Add("Development skill does not exist: $skillPath") | Out-Null
}
$validChecks = @('compile', 'tests', 'review')
$validInputKinds = @('auto', 'feature', 'bug', 'refactor', 'upgrade', 'maintenance')
$validOutputKinds = @('feature', 'bug', 'refactor', 'upgrade', 'maintenance')
$caseById = @{}
foreach ($case in @($manifest.cases)) {
$id = [string]$case.id
if ($id -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') {
$problems.Add("Fixture id must be kebab-case: '$id'.") | Out-Null
} elseif ($caseById.ContainsKey($id)) {
$problems.Add("Duplicate fixture id: $id") | Out-Null
} else {
$caseById[$id] = $case
}
if ($case.PSObject.Properties.Name -notcontains 'development-request') {
$problems.Add("${id}: development-request is required.") | Out-Null
continue
}
$request = $case.'development-request'
if ($validInputKinds -notcontains [string]$request.kind) {
$problems.Add("${id}: development-request.kind must be one of $($validInputKinds -join ', ').") | Out-Null
}
$description = if ($request.PSObject.Properties.Name -contains 'description') {
[string]$request.description
} else {
''
}
$planText = if ($request.PSObject.Properties.Name -contains 'plan') {
[string]$request.plan
} else {
''
}
if ([string]::IsNullOrWhiteSpace($description) -and [string]::IsNullOrWhiteSpace($planText)) {
$problems.Add("${id}: development-request requires description or plan.") | Out-Null
}
if ($request.PSObject.Properties.Name -notcontains 'acceptance-criteria' -or
-not @($request.'acceptance-criteria').Count) {
$problems.Add("${id}: development-request.acceptance-criteria must not be empty.") | Out-Null
}
$expectedKind = if ($case.PSObject.Properties.Name -contains 'expectedKind') {
[string]$case.expectedKind
} else {
''
}
if ($validOutputKinds -notcontains $expectedKind) {
$problems.Add("${id}: expectedKind must be one of $($validOutputKinds -join ', ').") | Out-Null
}
if ([string]$request.kind -ne 'auto' -and [string]$request.kind -ne $expectedKind) {
$problems.Add("${id}: explicit request kind '$($request.kind)' must equal expectedKind '$expectedKind'.") | Out-Null
}
$expectedKnowledge = @($case.requiredKnowledge) + @($case.optionalKnowledge)
if (@($expectedKnowledge | Sort-Object -Unique).Count -ne $expectedKnowledge.Count) {
$problems.Add("${id}: requiredKnowledge and optionalKnowledge contain duplicates.") | Out-Null
}
foreach ($reference in $expectedKnowledge) {
$reference = [string]$reference
if ($reference.Contains('\') -or -not $reference.EndsWith('.md')) {
$problems.Add("${id}: invalid knowledge path: $reference") | Out-Null
} elseif (-not (Test-Path -LiteralPath (Join-Path $Root $reference) -PathType Leaf)) {
$problems.Add("${id}: knowledge article does not exist: $reference") | Out-Null
}
}
foreach ($check in @($case.requiredChecks)) {
if ($validChecks -notcontains [string]$check) {
$problems.Add("${id}: unsupported required check '$check'.") | Out-Null
}
}
}
$validCapabilityStatuses = @('planned', 'fixture', 'validated')
$capabilityIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($capability in @($capabilityManifest.capabilities)) {
$id = [string]$capability.id
if (-not $capabilityIds.Add($id)) {
$problems.Add("Duplicate capability id: $id") | Out-Null
}
if ($validCapabilityStatuses -notcontains [string]$capability.status) {
$problems.Add("${id}: invalid capability status '$($capability.status)'.") | Out-Null
}
$fixtureIds = @($capability.fixtureIds)
if ($capability.status -in @('fixture', 'validated') -and -not $fixtureIds.Count) {
$problems.Add("${id}: status '$($capability.status)' requires at least one fixture.") | Out-Null
}
foreach ($fixtureId in $fixtureIds) {
if (-not $caseById.ContainsKey([string]$fixtureId)) {
$problems.Add("${id}: unknown fixture id '$fixtureId'.") | Out-Null
} elseif (@($caseById[[string]$fixtureId].capabilities) -notcontains $id) {
$problems.Add("${id}: fixture '$fixtureId' does not link back to the capability.") | Out-Null
}
}
}
foreach ($case in @($manifest.cases)) {
foreach ($capabilityId in @($case.capabilities)) {
if (-not $capabilityIds.Contains([string]$capabilityId)) {
$problems.Add("$($case.id): unknown capability '$capabilityId'.") | Out-Null
continue
}
$capability = @(
$capabilityManifest.capabilities |
Where-Object id -eq ([string]$capabilityId)
)[0]
if (@($capability.fixtureIds) -notcontains [string]$case.id) {
$problems.Add("$($case.id): capability '$capabilityId' does not link back to the fixture.") | Out-Null
}
}
}
if ($problems.Count) {
Write-Host "Development fixture validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red
$problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
if ($PrepareDirectory) {
$markerPath = Join-Path $PrepareDirectory '.bcquality-development-evaluation'
if (Test-Path -LiteralPath $PrepareDirectory) {
$existing = @(Get-ChildItem -LiteralPath $PrepareDirectory -Force)
if ($existing.Count -and -not (Test-Path -LiteralPath $markerPath -PathType Leaf)) {
throw "PrepareDirectory is not empty and is not a BCQuality development evaluation directory: $PrepareDirectory"
}
if (Test-Path -LiteralPath $markerPath -PathType Leaf) {
Get-ChildItem -LiteralPath $PrepareDirectory -File |
Where-Object {
$_.Name -eq 'knowledge-index.json' -or
$_.Name -like 'request-*.json' -or
$_.Name -like 'result-*.json'
} |
Remove-Item -Force
}
} else {
New-Item -ItemType Directory -Force -Path $PrepareDirectory | Out-Null
}
Set-Content -LiteralPath $markerPath -Value 'BCQuality generated development evaluation directory' -Encoding UTF8
$indexPath = Join-Path $PrepareDirectory 'knowledge-index.json'
& (Join-Path $Root 'tools/Build-KnowledgeIndex.ps1') -BCQualityRoot $Root -IndexPath $indexPath | Out-Null
$skillInstructions = Get-Content -LiteralPath (Join-Path $Root $skillPath) -Raw
foreach ($case in @($manifest.cases)) {
$modelId = Get-ModelCaseId ([string]$case.id)
$request = [ordered]@{
protocol = 'Run the supplied AL development skill in a fresh writable fixture repository. Persist the implementation, run real checks, and return only implementationReport using the supplied schema.'
caseId = $modelId
skill = $skillPath
skillInstructions = $skillInstructions
knowledgeIndex = 'knowledge-index.json'
'task-context' = [ordered]@{
goal = if ($case.'development-request'.PSObject.Properties.Name -contains 'description') {
[string]$case.'development-request'.description
} else {
[string]$case.'development-request'.plan
}
'inputs-available' = @('development-request', 'repository')
technologies = @($case.context.technologies)
countries = @($case.context.countries)
'application-area' = @($case.context.'application-area')
}
'development-request' = [ordered]@{
kind = [string]$case.'development-request'.kind
description = if ($case.'development-request'.PSObject.Properties.Name -contains 'description') {
[string]$case.'development-request'.description
} else {
$null
}
plan = if ($case.'development-request'.PSObject.Properties.Name -contains 'plan') {
[string]$case.'development-request'.plan
} else {
$null
}
'acceptance-criteria' = @($case.'development-request'.'acceptance-criteria')
}
resultSchema = [ordered]@{
caseId = $modelId
workspaceRoot = 'absolute path to the retained fixture repository'
implementationReport = [ordered]@{
skill = [ordered]@{ id = 'al-development'; version = 1 }
outcome = 'completed | not-applicable | no-knowledge | partial | failed'
'outcome-reason' = 'required for partial or failed'
summary = [ordered]@{
request = 'implemented change'
'files-created' = 0
'files-modified' = 0
'files-deleted' = 0
}
plan = [ordered]@{
kind = 'feature | bug | refactor | upgrade | maintenance'
assumptions = @()
decisions = @()
objects = @()
}
knowledge = @([ordered]@{ path = 'repo-relative knowledge article path'; sha = 'optional commit sha'; 'used-for' = 'decision' })
changes = @([ordered]@{ path = 'repo-relative changed file'; action = 'created | modified | deleted'; purpose = 'reason' })
validation = @([ordered]@{ id = 'compile | tests | review'; command = 'command or quality-skill path'; status = 'passed | failed | not-run'; details = 'non-empty evidence' })
review = [ordered]@{
skill = [ordered]@{ id = 'al-code-review'; version = 1 }
outcome = 'completed | partial | failed'
summary = [ordered]@{
counts = [ordered]@{ blocker = 0; major = 0; minor = 0; info = 0 }
coverage = [ordered]@{ 'worklist-size' = 0; 'items-evaluated' = 0 }
}
findings = @()
suppressed = @()
}
suppressed = @()
remaining = @()
}
}
}
$request | ConvertTo-Json -Depth 12 |
Set-Content -LiteralPath (Join-Path $PrepareDirectory "request-$modelId.json") -Encoding UTF8
}
}
if ($ResultsDirectory) {
$failures = [System.Collections.Generic.List[string]]::new()
foreach ($case in @($manifest.cases)) {
$modelId = Get-ModelCaseId ([string]$case.id)
$resultPath = Join-Path $ResultsDirectory "result-$modelId.json"
if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) {
$failures.Add("$($case.id): missing result file.") | Out-Null
continue
}
try {
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
} catch {
$failures.Add("$($case.id): result is not valid JSON: $($_.Exception.Message)") | Out-Null
continue
}
if ($result.PSObject.Properties.Name -notcontains 'caseId' -or [string]$result.caseId -ne $modelId) {
$failures.Add("$($case.id): result caseId mismatch.") | Out-Null
continue
}
if ($result.PSObject.Properties.Name -notcontains 'implementationReport') {
$failures.Add("$($case.id): implementationReport is missing.") | Out-Null
continue
}
$report = $result.implementationReport
foreach ($requiredField in @('skill', 'outcome', 'summary', 'plan', 'knowledge', 'changes', 'validation', 'review', 'suppressed', 'remaining')) {
if ($report.PSObject.Properties.Name -notcontains $requiredField) {
$failures.Add("$($case.id): implementation report is missing '$requiredField'.") | Out-Null
}
}
$reportedSkillId = if (
$report.PSObject.Properties.Name -contains 'skill' -and
$report.skill.PSObject.Properties.Name -contains 'id'
) {
[string]$report.skill.id
} else {
''
}
if ($reportedSkillId -ne 'al-development') {
$failures.Add("$($case.id): implementation report skill is '$reportedSkillId'.") | Out-Null
}
$summaryRequest = if (
$report.PSObject.Properties.Name -contains 'summary' -and
$report.summary.PSObject.Properties.Name -contains 'request'
) {
[string]$report.summary.request
} else {
''
}
if ([string]::IsNullOrWhiteSpace($summaryRequest)) {
$failures.Add("$($case.id): summary.request is missing or empty.") | Out-Null
}
$reportedKind = if (
$report.PSObject.Properties.Name -contains 'plan' -and
$report.plan.PSObject.Properties.Name -contains 'kind'
) {
[string]$report.plan.kind
} else {
''
}
if ($reportedKind -ne [string]$case.expectedKind) {
$failures.Add("$($case.id): plan.kind '$reportedKind' does not match expected mode '$($case.expectedKind)'.") | Out-Null
}
$outcome = if ($report.PSObject.Properties.Name -contains 'outcome') { [string]$report.outcome } else { '' }
if ($outcome -ne 'completed') {
$failures.Add("$($case.id): implementation outcome is '$outcome'.") | Out-Null
}
[object[]]$knowledgeEntries = @()
if ($report.PSObject.Properties.Name -contains 'knowledge') {
$knowledgeEntries = @($report.knowledge)
}
if (-not $knowledgeEntries.Count) {
$failures.Add("$($case.id): implementation report contains no knowledge entries.") | Out-Null
}
$usedKnowledge = @(
$knowledgeEntries |
Where-Object { $_.PSObject.Properties.Name -contains 'path' } |
ForEach-Object { [string]$_.path }
)
if (@($usedKnowledge | Sort-Object -Unique).Count -ne $usedKnowledge.Count) {
$failures.Add("$($case.id): knowledge contains duplicate paths.") | Out-Null
}
foreach ($entry in $knowledgeEntries) {
$path = if ($entry.PSObject.Properties.Name -contains 'path') { [string]$entry.path } else { '' }
$usedFor = if ($entry.PSObject.Properties.Name -contains 'used-for') { [string]$entry.'used-for' } else { '' }
if ([string]::IsNullOrWhiteSpace($path) -or
[System.IO.Path]::IsPathRooted($path) -or
@($path -split '/|\\') -contains '..' -or
-not (Test-Path -LiteralPath (Join-Path $Root $path) -PathType Leaf)) {
$failures.Add("$($case.id): knowledge entry has a missing or invalid path '$path'.") | Out-Null
}
if ([string]::IsNullOrWhiteSpace($usedFor)) {
$failures.Add("$($case.id): knowledge entry '$path' has no used-for explanation.") | Out-Null
}
}
$requiredKnowledge = @($case.requiredKnowledge | ForEach-Object { [string]$_ })
$matched = @($requiredKnowledge | Where-Object { $usedKnowledge -contains $_ }).Count
$recall = if ($requiredKnowledge.Count) { $matched / $requiredKnowledge.Count } else { 1.0 }
if ($recall -lt [double]$manifest.minimumKnowledgeRecall) {
$failures.Add("$($case.id): knowledge recall $recall is below $($manifest.minimumKnowledgeRecall).") | Out-Null
}
$acceptedKnowledge = @(
@($case.requiredKnowledge) + @($case.optionalKnowledge) |
ForEach-Object { [string]$_ } |
Sort-Object -Unique
)
$acceptedUsed = @($usedKnowledge | Where-Object { $acceptedKnowledge -contains $_ }).Count
$precision = if ($usedKnowledge.Count) { $acceptedUsed / $usedKnowledge.Count } else { 0.0 }
if ($precision -lt [double]$manifest.minimumKnowledgePrecision) {
$failures.Add("$($case.id): knowledge precision $precision is below $($manifest.minimumKnowledgePrecision).") | Out-Null
}
[object[]]$validationEntries = @()
if ($report.PSObject.Properties.Name -contains 'validation') {
$validationEntries = @($report.validation)
}
foreach ($requiredCheck in @($case.requiredChecks)) {
$check = @($validationEntries | Where-Object {
$_.PSObject.Properties.Name -contains 'id' -and [string]$_.id -eq $requiredCheck
})
$checkStatus = if ($check.Count -eq 1 -and $check[0].PSObject.Properties.Name -contains 'status') {
[string]$check[0].status
} else {
''
}
if ($check.Count -ne 1 -or $checkStatus -ne 'passed') {
$failures.Add("$($case.id): required check '$requiredCheck' did not pass exactly once.") | Out-Null
continue
}
$command = if ($check[0].PSObject.Properties.Name -contains 'command') { [string]$check[0].command } else { '' }
$details = if ($check[0].PSObject.Properties.Name -contains 'details') { [string]$check[0].details } else { '' }
if ([string]::IsNullOrWhiteSpace($command) -or [string]::IsNullOrWhiteSpace($details)) {
$failures.Add("$($case.id): required check '$requiredCheck' lacks command or evidence details.") | Out-Null
}
}
[object[]]$changes = @()
if ($report.PSObject.Properties.Name -contains 'changes') {
$changes = @($report.changes)
}
if (-not $changes.Count) {
$failures.Add("$($case.id): implementation report contains no changed files.") | Out-Null
}
$workspaceRoot = if ($result.PSObject.Properties.Name -contains 'workspaceRoot') { [string]$result.workspaceRoot } else { '' }
if ([string]::IsNullOrWhiteSpace($workspaceRoot) -or
-not (Test-Path -LiteralPath $workspaceRoot -PathType Container)) {
$failures.Add("$($case.id): workspaceRoot is missing or unavailable.") | Out-Null
} else {
& git -C $workspaceRoot rev-parse --is-inside-work-tree 2>$null | Out-Null
if ($LASTEXITCODE -ne 0) {
$failures.Add("$($case.id): workspaceRoot is not a readable git worktree.") | Out-Null
continue
}
$actualChanges = @(
@(& git -C $workspaceRoot diff --name-only HEAD) +
@(& git -C $workspaceRoot ls-files --others --exclude-standard) |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } |
ForEach-Object { ([string]$_).Replace('\', '/') } |
Sort-Object -Unique
)
if ($LASTEXITCODE -ne 0) {
$failures.Add("$($case.id): unable to read the workspace diff.") | Out-Null
} else {
$reportedChanges = @(
$changes |
Where-Object { $_.PSObject.Properties.Name -contains 'path' } |
ForEach-Object {
$path = ([string]$_.path).Replace('\', '/')
if ([System.IO.Path]::IsPathRooted($path) -or @($path -split '/') -contains '..') {
$failures.Add("$($case.id): changed path is not repository-relative: $path") | Out-Null
}
$path
} |
Sort-Object -Unique
)
foreach ($path in @($reportedChanges | Where-Object { $actualChanges -notcontains $_ })) {
$failures.Add("$($case.id): reported change is absent from the worktree diff: $path") | Out-Null
}
foreach ($path in @($actualChanges | Where-Object { $reportedChanges -notcontains $_ })) {
$failures.Add("$($case.id): worktree change is absent from the implementation report: $path") | Out-Null
}
}
}
if ($report.PSObject.Properties.Name -notcontains 'review') {
$failures.Add("$($case.id): complete post-implementation review is missing.") | Out-Null
} else {
$review = $report.review
foreach ($requiredField in @('skill', 'outcome', 'summary', 'findings', 'suppressed')) {
if ($review.PSObject.Properties.Name -notcontains $requiredField) {
$failures.Add("$($case.id): review is missing '$requiredField'.") | Out-Null
}
}
[object[]]$reviewFindings = @()
if ($review.PSObject.Properties.Name -contains 'findings') {
$reviewFindings = @($review.findings)
}
$reviewOutcome = if ($review.PSObject.Properties.Name -contains 'outcome') { [string]$review.outcome } else { '' }
if ($reviewOutcome -ne 'completed') {
$failures.Add("$($case.id): post-implementation review outcome is '$reviewOutcome'.") | Out-Null
}
$gatingFindings = @(
$reviewFindings |
Where-Object {
$_.PSObject.Properties.Name -contains 'severity' -and
[string]$_.severity -in @('blocker', 'major')
}
)
if ($gatingFindings.Count) {
$failures.Add("$($case.id): post-implementation review has $($gatingFindings.Count) gating finding(s).") | Out-Null
}
}
[object[]]$remaining = @()
if ($report.PSObject.Properties.Name -contains 'remaining') {
$remaining = @($report.remaining)
}
if ($remaining.Count) {
$failures.Add("$($case.id): completed report still lists remaining work.") | Out-Null
}
}
if ($failures.Count) {
Write-Host "Development fixture scoring FAILED ($($failures.Count) problem(s)):" -ForegroundColor Red
$failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
Write-Host "Development fixture scoring PASSED: $(@($manifest.cases).Count) case(s)."
} else {
$fixtureBackedCapabilities = @(
$capabilityManifest.capabilities |
Where-Object status -in @('fixture', 'validated')
).Count
Write-Host "Development fixture validation PASSED: $(@($manifest.cases).Count) cases; $fixtureBackedCapabilities of $($capabilityIds.Count) capabilities have fixtures."
}

View file

@ -0,0 +1,298 @@
<#
.SYNOPSIS
Validates, prepares, and scores read-only AL development-guidance fixtures.
#>
[CmdletBinding()]
param(
[string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')),
[string] $ManifestPath,
[string] $PrepareDirectory,
[string] $ResultsDirectory
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path -LiteralPath $Root).Path
if (-not $ManifestPath) {
$ManifestPath = Join-Path $Root 'evaluation/development-guidance-fixtures.json'
}
$manifest = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
$problems = [System.Collections.Generic.List[string]]::new()
$validKinds = @('feature', 'bug', 'refactor', 'upgrade', 'maintenance')
function Get-ModelCaseId {
param([string] $ManifestId)
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.Text.Encoding]::UTF8.GetBytes($ManifestId)
$hash = $sha.ComputeHash($bytes)
$token = ([System.BitConverter]::ToString($hash) -replace '-', '').Substring(0, 8).ToLowerInvariant()
return "case-$token"
} finally {
$sha.Dispose()
}
}
if ($manifest.version -ne 1) {
$problems.Add("Unsupported guidance fixture version: $($manifest.version)") | Out-Null
}
foreach ($thresholdName in @('minimumKnowledgeRecall', 'minimumKnowledgePrecision')) {
$threshold = [double]$manifest.$thresholdName
if ($threshold -lt 0 -or $threshold -gt 1) {
$problems.Add("$thresholdName must be between 0 and 1.") | Out-Null
}
}
$skillPath = [string]$manifest.skill
if (-not (Test-Path -LiteralPath (Join-Path $Root $skillPath) -PathType Leaf)) {
$problems.Add("Guidance skill does not exist: $skillPath") | Out-Null
}
$seenIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($case in @($manifest.cases)) {
$id = [string]$case.id
if ($id -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') {
$problems.Add("Fixture id must be kebab-case: '$id'.") | Out-Null
} elseif (-not $seenIds.Add($id)) {
$problems.Add("Duplicate fixture id: $id") | Out-Null
}
if ($case.PSObject.Properties.Name -notcontains 'development-plan') {
$problems.Add("${id}: development-plan is required.") | Out-Null
continue
}
$plan = $case.'development-plan'
if ($validKinds -notcontains [string]$plan.kind) {
$problems.Add("${id}: development-plan.kind is invalid.") | Out-Null
}
if ([string]::IsNullOrWhiteSpace([string]$plan.request)) {
$problems.Add("${id}: development-plan.request is required.") | Out-Null
}
$expectedKnowledge = @($case.requiredKnowledge) + @($case.optionalKnowledge)
if (@($expectedKnowledge | Sort-Object -Unique).Count -ne $expectedKnowledge.Count) {
$problems.Add("${id}: requiredKnowledge and optionalKnowledge contain duplicates.") | Out-Null
}
foreach ($reference in $expectedKnowledge) {
$reference = [string]$reference
if ($reference.Contains('\') -or -not $reference.EndsWith('.md')) {
$problems.Add("${id}: invalid knowledge path: $reference") | Out-Null
} elseif (-not (Test-Path -LiteralPath (Join-Path $Root $reference) -PathType Leaf)) {
$problems.Add("${id}: knowledge article does not exist: $reference") | Out-Null
}
}
}
if ($problems.Count) {
Write-Host "Development guidance fixture validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red
$problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
if ($PrepareDirectory) {
$markerPath = Join-Path $PrepareDirectory '.bcquality-development-guidance-evaluation'
if (Test-Path -LiteralPath $PrepareDirectory) {
$existing = @(Get-ChildItem -LiteralPath $PrepareDirectory -Force)
if ($existing.Count -and -not (Test-Path -LiteralPath $markerPath -PathType Leaf)) {
throw "PrepareDirectory is not empty and is not a BCQuality development-guidance evaluation directory: $PrepareDirectory"
}
if (Test-Path -LiteralPath $markerPath -PathType Leaf) {
Get-ChildItem -LiteralPath $PrepareDirectory -File |
Where-Object {
$_.Name -eq 'knowledge-index.json' -or
$_.Name -like 'request-*.json' -or
$_.Name -like 'result-*.json'
} |
Remove-Item -Force
}
} else {
New-Item -ItemType Directory -Force -Path $PrepareDirectory | Out-Null
}
Set-Content -LiteralPath $markerPath -Value 'BCQuality generated development-guidance evaluation directory' -Encoding UTF8
$indexPath = Join-Path $PrepareDirectory 'knowledge-index.json'
& (Join-Path $Root 'tools/Build-KnowledgeIndex.ps1') -BCQualityRoot $Root -IndexPath $indexPath | Out-Null
$skillInstructions = Get-Content -LiteralPath (Join-Path $Root $skillPath) -Raw
foreach ($case in @($manifest.cases)) {
$modelId = Get-ModelCaseId ([string]$case.id)
[ordered]@{
protocol = 'Run the supplied AL development-plan skill read-only in a clean fixture repository. Return only guidanceReport using the supplied schema and retain the repository for read-only verification.'
caseId = $modelId
skill = $skillPath
skillInstructions = $skillInstructions
knowledgeIndex = 'knowledge-index.json'
'task-context' = [ordered]@{
goal = [string]$case.'development-plan'.request
'inputs-available' = @('development-plan', 'repository')
technologies = @($case.context.technologies)
countries = @($case.context.countries)
'application-area' = @($case.context.'application-area')
}
'development-plan' = $case.'development-plan'
resultSchema = [ordered]@{
caseId = $modelId
workspaceRoot = 'absolute path to the retained clean fixture repository'
guidanceReport = [ordered]@{
skill = [ordered]@{ id = 'al-development-plan'; version = 1 }
outcome = 'completed | not-applicable | no-knowledge | partial | failed'
'outcome-reason' = 'required for partial or failed'
summary = [ordered]@{
request = 'planned change'
kind = 'feature | bug | refactor | upgrade | maintenance'
candidates = 0
selected = 0
}
context = [ordered]@{
'bc-version' = 'resolved target or unknown'
technologies = @('al')
countries = @('w1')
'application-area' = @('all')
unknown = @()
}
knowledge = @([ordered]@{
path = 'repo-relative article path'
sha = 'optional commit sha'
'used-for' = 'plan decision'
constraints = @('faithful normative constraint')
'sample-paths' = @()
})
'validation-considerations' = @([ordered]@{
id = 'stable id'
reason = 'why evidence is needed'
evidence = 'evidence implementation should obtain'
})
suppressed = @()
unresolved = @()
}
}
} | ConvertTo-Json -Depth 15 |
Set-Content -LiteralPath (Join-Path $PrepareDirectory "request-$modelId.json") -Encoding UTF8
}
}
if ($ResultsDirectory) {
$failures = [System.Collections.Generic.List[string]]::new()
foreach ($case in @($manifest.cases)) {
$modelId = Get-ModelCaseId ([string]$case.id)
$resultPath = Join-Path $ResultsDirectory "result-$modelId.json"
if (-not (Test-Path -LiteralPath $resultPath -PathType Leaf)) {
$failures.Add("$($case.id): missing result file.") | Out-Null
continue
}
try {
$result = Get-Content -LiteralPath $resultPath -Raw | ConvertFrom-Json
} catch {
$failures.Add("$($case.id): result is not valid JSON: $($_.Exception.Message)") | Out-Null
continue
}
if ($result.PSObject.Properties.Name -notcontains 'caseId' -or [string]$result.caseId -ne $modelId) {
$failures.Add("$($case.id): result caseId mismatch.") | Out-Null
continue
}
if ($result.PSObject.Properties.Name -notcontains 'guidanceReport') {
$failures.Add("$($case.id): guidanceReport is missing.") | Out-Null
continue
}
$report = $result.guidanceReport
foreach ($requiredField in @('skill', 'outcome', 'summary', 'context', 'knowledge', 'validation-considerations', 'suppressed', 'unresolved')) {
if ($report.PSObject.Properties.Name -notcontains $requiredField) {
$failures.Add("$($case.id): guidance report is missing '$requiredField'.") | Out-Null
}
}
$skillId = if (
$report.PSObject.Properties.Name -contains 'skill' -and
$report.skill.PSObject.Properties.Name -contains 'id'
) { [string]$report.skill.id } else { '' }
if ($skillId -ne 'al-development-plan') {
$failures.Add("$($case.id): guidance skill is '$skillId'.") | Out-Null
}
if ($report.PSObject.Properties.Name -notcontains 'outcome' -or [string]$report.outcome -ne 'completed') {
$failures.Add("$($case.id): guidance outcome is not completed.") | Out-Null
}
$kind = if (
$report.PSObject.Properties.Name -contains 'summary' -and
$report.summary.PSObject.Properties.Name -contains 'kind'
) { [string]$report.summary.kind } else { '' }
if ($kind -ne [string]$case.'development-plan'.kind) {
$failures.Add("$($case.id): summary.kind '$kind' does not match the plan.") | Out-Null
}
[object[]]$knowledgeEntries = @()
if ($report.PSObject.Properties.Name -contains 'knowledge') {
$knowledgeEntries = @($report.knowledge)
}
$usedKnowledge = @()
foreach ($entry in $knowledgeEntries) {
$path = if ($entry.PSObject.Properties.Name -contains 'path') { [string]$entry.path } else { '' }
$usedFor = if ($entry.PSObject.Properties.Name -contains 'used-for') { [string]$entry.'used-for' } else { '' }
[object[]]$constraints = @()
if ($entry.PSObject.Properties.Name -contains 'constraints') {
$constraints = @($entry.constraints)
}
if ([string]::IsNullOrWhiteSpace($path) -or
-not (Test-Path -LiteralPath (Join-Path $Root $path) -PathType Leaf)) {
$failures.Add("$($case.id): invalid knowledge path '$path'.") | Out-Null
} else {
$usedKnowledge += $path
}
if ([string]::IsNullOrWhiteSpace($usedFor) -or -not $constraints.Count) {
$failures.Add("$($case.id): '$path' lacks used-for or constraints.") | Out-Null
}
if ($entry.PSObject.Properties.Name -contains 'sample-paths') {
foreach ($samplePath in @($entry.'sample-paths')) {
$samplePath = [string]$samplePath
if ([string]::IsNullOrWhiteSpace($samplePath) -or
-not (Test-Path -LiteralPath (Join-Path $Root $samplePath) -PathType Leaf)) {
$failures.Add("$($case.id): invalid sample path '$samplePath'.") | Out-Null
}
}
}
}
$required = @($case.requiredKnowledge | ForEach-Object { [string]$_ })
$matched = @($required | Where-Object { $usedKnowledge -contains $_ }).Count
$recall = if ($required.Count) { $matched / $required.Count } else { 1.0 }
if ($recall -lt [double]$manifest.minimumKnowledgeRecall) {
$failures.Add("$($case.id): knowledge recall $recall is below $($manifest.minimumKnowledgeRecall).") | Out-Null
}
$accepted = @(
@($case.requiredKnowledge) + @($case.optionalKnowledge) |
ForEach-Object { [string]$_ } |
Sort-Object -Unique
)
$acceptedUsed = @($usedKnowledge | Where-Object { $accepted -contains $_ }).Count
$precision = if ($usedKnowledge.Count) { $acceptedUsed / $usedKnowledge.Count } else { 0.0 }
if ($precision -lt [double]$manifest.minimumKnowledgePrecision) {
$failures.Add("$($case.id): knowledge precision $precision is below $($manifest.minimumKnowledgePrecision).") | Out-Null
}
$workspaceRoot = if ($result.PSObject.Properties.Name -contains 'workspaceRoot') {
[string]$result.workspaceRoot
} else {
''
}
if ([string]::IsNullOrWhiteSpace($workspaceRoot) -or
-not (Test-Path -LiteralPath $workspaceRoot -PathType Container)) {
$failures.Add("$($case.id): workspaceRoot is missing or unavailable.") | Out-Null
} else {
$status = @(& git -C $workspaceRoot status --porcelain)
if ($LASTEXITCODE -ne 0) {
$failures.Add("$($case.id): workspaceRoot is not a readable git worktree.") | Out-Null
} elseif ($status.Count) {
$failures.Add("$($case.id): guidance skill changed the target repository.") | Out-Null
}
}
}
if ($failures.Count) {
Write-Host "Development guidance scoring FAILED ($($failures.Count) problem(s)):" -ForegroundColor Red
$failures | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
Write-Host "Development guidance scoring PASSED: $(@($manifest.cases).Count) case(s)."
} else {
Write-Host "Development guidance fixture validation PASSED: $(@($manifest.cases).Count) case(s)."
}

View file

@ -0,0 +1,240 @@
<#
.SYNOPSIS
Validates Microsoft Learn ingestion coverage and reports progress.
#>
[CmdletBinding()]
param(
[string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')),
[string] $CatalogPath,
[string] $CoveragePath,
[switch] $Json
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path -LiteralPath $Root).Path
if (-not $CatalogPath) {
$CatalogPath = Join-Path $Root 'coverage/microsoft-learn-developer-catalog.json'
}
if (-not $CoveragePath) {
$CoveragePath = Join-Path $Root 'coverage/learn-coverage.json'
}
foreach ($path in @($CatalogPath, $CoveragePath)) {
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Coverage input not found: $path"
}
}
$catalog = Get-Content -LiteralPath $CatalogPath -Raw | ConvertFrom-Json
$coverage = Get-Content -LiteralPath $CoveragePath -Raw | ConvertFrom-Json
$problems = [System.Collections.Generic.List[string]]::new()
if ($catalog.version -ne 1) {
$problems.Add("Unsupported catalog version: $($catalog.version)") | Out-Null
}
if ($coverage.version -ne 1) {
$problems.Add("Unsupported coverage version: $($coverage.version)") | Out-Null
}
if ([string]$coverage.catalog -ne 'coverage/microsoft-learn-developer-catalog.json') {
$problems.Add("Coverage catalog path must be coverage/microsoft-learn-developer-catalog.json.") | Out-Null
}
$catalogUnits = @{}
foreach ($unit in @($catalog.units)) {
$uid = [string]$unit.uid
if ($catalogUnits.ContainsKey($uid)) {
$problems.Add("Duplicate catalog unit uid: $uid") | Out-Null
} else {
$catalogUnits[$uid] = $unit
}
if ([string]::IsNullOrWhiteSpace([string]$unit.title)) {
$problems.Add("${uid}: catalog unit title is empty.") | Out-Null
}
if ([string]::IsNullOrWhiteSpace([string]$unit.url) -or
-not ([string]$unit.url).StartsWith('https://learn.microsoft.com/', [System.StringComparison]::OrdinalIgnoreCase)) {
$problems.Add("${uid}: catalog unit URL is missing or not a Microsoft Learn URL.") | Out-Null
}
}
$catalogModules = @{}
foreach ($module in @($catalog.modules)) {
$uid = [string]$module.uid
if ($catalogModules.ContainsKey($uid)) {
$problems.Add("Duplicate catalog module uid: $uid") | Out-Null
} else {
$catalogModules[$uid] = $module
}
foreach ($unitUid in @($module.unitUids)) {
if (-not $catalogUnits.ContainsKey([string]$unitUid)) {
$problems.Add("${uid}: references unknown unit '$unitUid'.") | Out-Null
}
}
}
$catalogPaths = @{}
foreach ($path in @($catalog.learningPaths)) {
$uid = [string]$path.uid
if ($catalogPaths.ContainsKey($uid)) {
$problems.Add("Duplicate catalog learning-path uid: $uid") | Out-Null
} else {
$catalogPaths[$uid] = $path
}
foreach ($moduleUid in @($path.moduleUids)) {
if (-not $catalogModules.ContainsKey([string]$moduleUid)) {
$problems.Add("${uid}: references unknown module '$moduleUid'.") | Out-Null
}
}
}
if ([int]$catalog.counts.units -ne $catalogUnits.Count) {
$problems.Add("Catalog unit count does not match units array.") | Out-Null
}
if ([int]$catalog.counts.modules -ne $catalogModules.Count) {
$problems.Add("Catalog module count does not match modules array.") | Out-Null
}
if ([int]$catalog.counts.learningPaths -ne $catalogPaths.Count) {
$problems.Add("Catalog learning-path count does not match learningPaths array.") | Out-Null
}
foreach ($unit in @($catalog.units)) {
foreach ($moduleUid in @($unit.moduleUids)) {
if (-not $catalogModules.ContainsKey([string]$moduleUid)) {
$problems.Add("$($unit.uid): references unknown module '$moduleUid'.") | Out-Null
} elseif (@($catalogModules[[string]$moduleUid].unitUids) -notcontains [string]$unit.uid) {
$problems.Add("$($unit.uid): module '$moduleUid' does not link back to the unit.") | Out-Null
}
}
}
$validStatuses = @('in-progress', 'complete')
$validDispositions = @('candidate', 'authored', 'covered-existing', 'rejected', 'deferred')
$seenUnits = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$seenOutcomes = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$articlePaths = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
$dispositionCounts = @{}
foreach ($disposition in $validDispositions) {
$dispositionCounts[$disposition] = 0
}
foreach ($unitCoverage in @($coverage.units)) {
$uid = [string]$unitCoverage.uid
if (-not $seenUnits.Add($uid)) {
$problems.Add("Duplicate coverage unit uid: $uid") | Out-Null
}
if (-not $catalogUnits.ContainsKey($uid)) {
$problems.Add("Coverage unit is absent from the catalog: $uid") | Out-Null
}
$reviewStatus = [string]$unitCoverage.reviewStatus
if ($validStatuses -notcontains $reviewStatus) {
$problems.Add("${uid}: invalid reviewStatus '$reviewStatus'.") | Out-Null
}
[object[]]$outcomes = @()
if ($unitCoverage.PSObject.Properties.Name -contains 'outcomes') {
$outcomes = @($unitCoverage.outcomes)
}
if ($reviewStatus -eq 'in-progress' -and -not $outcomes.Count) {
$problems.Add("${uid}: an in-progress unit must contain at least one outcome.") | Out-Null
}
foreach ($outcome in $outcomes) {
$id = [string]$outcome.id
if ($id -notmatch '^[a-z0-9]+(?:-[a-z0-9]+)*$') {
$problems.Add("${uid}: outcome id must be kebab-case: '$id'.") | Out-Null
} elseif (-not $seenOutcomes.Add($id)) {
$problems.Add("Duplicate outcome id: $id") | Out-Null
}
$disposition = [string]$outcome.disposition
if ($validDispositions -notcontains $disposition) {
$problems.Add("${id}: invalid disposition '$disposition'.") | Out-Null
continue
}
$dispositionCounts[$disposition]++
if ([string]::IsNullOrWhiteSpace([string]$outcome.title)) {
$problems.Add("${id}: title is required.") | Out-Null
}
if ($disposition -in @('candidate', 'authored', 'covered-existing')) {
if ([string]::IsNullOrWhiteSpace([string]$outcome.domain)) {
$problems.Add("${id}: domain is required for disposition '$disposition'.") | Out-Null
}
}
[object[]]$paths = @()
if ($outcome.PSObject.Properties.Name -contains 'articlePaths') {
$paths = @($outcome.articlePaths)
}
if ($disposition -in @('authored', 'covered-existing')) {
if (-not $paths.Count) {
$problems.Add("${id}: articlePaths is required for disposition '$disposition'.") | Out-Null
}
foreach ($relativePath in $paths) {
$relativePath = [string]$relativePath
if ($relativePath.Contains('\') -or -not $relativePath.EndsWith('.md')) {
$problems.Add("${id}: article path must be a forward-slash .md path: $relativePath") | Out-Null
continue
}
if (-not (Test-Path -LiteralPath (Join-Path $Root $relativePath) -PathType Leaf)) {
$problems.Add("${id}: article does not exist: $relativePath") | Out-Null
}
$articlePaths.Add($relativePath) | Out-Null
}
} elseif ($paths.Count) {
$problems.Add("${id}: disposition '$disposition' must not set articlePaths.") | Out-Null
}
$rationale = if ($outcome.PSObject.Properties.Name -contains 'rationale') {
[string]$outcome.rationale
} else {
''
}
if ($disposition -in @('rejected', 'deferred') -and
[string]::IsNullOrWhiteSpace($rationale)) {
$problems.Add("${id}: rationale is required for disposition '$disposition'.") | Out-Null
}
}
}
if ($problems.Count) {
Write-Host "Learn coverage validation FAILED ($($problems.Count) problem(s)):" -ForegroundColor Red
$problems | ForEach-Object { Write-Host " - $_" -ForegroundColor Red }
exit 1
}
$complete = @($coverage.units | Where-Object reviewStatus -eq 'complete').Count
$inProgress = @($coverage.units | Where-Object reviewStatus -eq 'in-progress').Count
$summary = [ordered]@{
catalogUnits = $catalogUnits.Count
completeUnits = $complete
inProgressUnits = $inProgress
unreviewedUnits = $catalogUnits.Count - $complete - $inProgress
candidateOutcomes = $dispositionCounts.candidate
authoredOutcomes = $dispositionCounts.authored
coveredExistingOutcomes = $dispositionCounts.'covered-existing'
rejectedOutcomes = $dispositionCounts.rejected
deferredOutcomes = $dispositionCounts.deferred
referencedArticles = $articlePaths.Count
}
if ($Json) {
$summary | ConvertTo-Json
} else {
$message = (
'Learn coverage: {0} units; {1} complete, {2} in progress, {3} unreviewed; ' +
'{4} candidate outcomes, {5} authored outcomes, {6} existing-coverage outcomes.'
) -f @(
$summary.catalogUnits,
$summary.completeUnits,
$summary.inProgressUnits,
$summary.unreviewedUnits,
$summary.candidateOutcomes,
$summary.authoredOutcomes,
$summary.coveredExistingOutcomes
)
Write-Host $message
}

View file

@ -0,0 +1,262 @@
<#
.SYNOPSIS
Updates the committed Microsoft Learn Business Central developer catalog.
.DESCRIPTION
Filters a Microsoft Learn catalog payload to modules tagged for both
dynamics-business-central and developer. The output is intentionally only
source metadata; editorial dispositions live in coverage/learn-coverage.json.
The legacy unauthenticated catalog endpoint remains the default while it is
available. Use -CatalogPath with an export from the authenticated Learn
Platform API when the legacy endpoint is retired.
#>
[CmdletBinding(DefaultParameterSetName = 'Remote')]
param(
[string] $Root = (Resolve-Path (Join-Path $PSScriptRoot '..')),
[Parameter(ParameterSetName = 'Remote')]
[uri] $CatalogUri = 'https://learn.microsoft.com/api/catalog/?locale=en-us',
[Parameter(Mandatory, ParameterSetName = 'File')]
[string] $CatalogPath,
[string] $OutputPath
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$Root = (Resolve-Path -LiteralPath $Root).Path
if (-not $OutputPath) {
$OutputPath = Join-Path $Root 'coverage/microsoft-learn-developer-catalog.json'
}
function Get-CanonicalUrl {
param([string] $Url)
if ([string]::IsNullOrWhiteSpace($Url)) {
return $null
}
$parsed = [uri]$Url
return "$($parsed.Scheme)://$($parsed.Host)$($parsed.AbsolutePath)".TrimEnd('/')
}
function Get-CatalogProperty {
param(
[object] $Object,
[string] $Name,
[object] $Default = $null
)
$property = $Object.PSObject.Properties[$Name]
if ($property) {
return $property.Value
}
return $Default
}
function Get-LatestTimestamp {
param([object[]] $Values)
$timestamps = @(
$Values |
Where-Object { -not [string]::IsNullOrWhiteSpace([string]$_) } |
ForEach-Object { [datetimeoffset]::Parse([string]$_) } |
Sort-Object
)
if (-not $timestamps.Count) {
return $null
}
return $timestamps[-1].ToUniversalTime().ToString(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
[System.Globalization.CultureInfo]::InvariantCulture
)
}
function Convert-ToUtcTimestamp {
param([object] $Value)
return ([datetimeoffset]$Value).ToUniversalTime().ToString(
"yyyy-MM-dd'T'HH:mm:ss'Z'",
[System.Globalization.CultureInfo]::InvariantCulture
)
}
$catalog = if ($PSCmdlet.ParameterSetName -eq 'File') {
Get-Content -LiteralPath $CatalogPath -Raw | ConvertFrom-Json
} else {
Invoke-RestMethod -Uri $CatalogUri
}
$product = 'dynamics-business-central'
$role = 'developer'
$modules = @(
$catalog.modules |
Where-Object {
@(Get-CatalogProperty $_ 'products' @()) -contains $product -and
@(Get-CatalogProperty $_ 'roles' @()) -contains $role
} |
Sort-Object uid
)
$moduleIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($module in $modules) {
$moduleIds.Add([string]$module.uid) | Out-Null
}
$learningPaths = @(
$catalog.learningPaths |
Where-Object {
$path = $_
$pathModules = @(Get-CatalogProperty $path 'modules' @())
@($pathModules | Where-Object { $moduleIds.Contains([string]$_) }).Count -gt 0 -and
@(Get-CatalogProperty $path 'products' @()) -contains $product -and
@(Get-CatalogProperty $path 'roles' @()) -contains $role
} |
Sort-Object uid
)
$pathIdsByModule = @{}
foreach ($path in $learningPaths) {
foreach ($moduleId in @(Get-CatalogProperty $path 'modules' @())) {
if (-not $moduleIds.Contains([string]$moduleId)) {
continue
}
if (-not $pathIdsByModule.ContainsKey([string]$moduleId)) {
$pathIdsByModule[[string]$moduleId] = [System.Collections.Generic.List[string]]::new()
}
$pathIdsByModule[[string]$moduleId].Add([string]$path.uid)
}
}
$unitById = @{}
foreach ($unit in @(Get-CatalogProperty $catalog 'units' @())) {
$unitById[[string]$unit.uid] = $unit
}
$moduleIdsByUnit = @{}
$moduleById = @{}
$unitIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::Ordinal)
foreach ($module in $modules) {
$moduleById[[string]$module.uid] = $module
foreach ($unitId in @(Get-CatalogProperty $module 'units' @())) {
$unitId = [string]$unitId
$unitIds.Add($unitId) | Out-Null
if (-not $moduleIdsByUnit.ContainsKey($unitId)) {
$moduleIdsByUnit[$unitId] = [System.Collections.Generic.List[string]]::new()
}
$moduleIdsByUnit[$unitId].Add([string]$module.uid)
}
}
$pathRecords = @(
foreach ($path in $learningPaths) {
[ordered]@{
uid = [string]$path.uid
title = [string]$path.title
url = Get-CanonicalUrl ([string]$path.url)
lastModified = if (Get-CatalogProperty $path 'last_modified') {
Convert-ToUtcTimestamp (Get-CatalogProperty $path 'last_modified')
} else {
$null
}
durationInMinutes = [int](Get-CatalogProperty $path 'duration_in_minutes' 0)
moduleUids = @(
@(Get-CatalogProperty $path 'modules' @()) |
Where-Object { $moduleIds.Contains([string]$_) } |
ForEach-Object { [string]$_ } |
Sort-Object -Unique
)
}
}
)
$moduleRecords = @(
foreach ($module in $modules) {
[ordered]@{
uid = [string]$module.uid
title = [string]$module.title
summary = [string](Get-CatalogProperty $module 'summary' '')
url = Get-CanonicalUrl ([string]$module.url)
lastModified = Convert-ToUtcTimestamp (Get-CatalogProperty $module 'last_modified')
durationInMinutes = [int](Get-CatalogProperty $module 'duration_in_minutes' 0)
levels = @(@(Get-CatalogProperty $module 'levels' @()) | ForEach-Object { [string]$_ } | Sort-Object -Unique)
subjects = @(@(Get-CatalogProperty $module 'subjects' @()) | ForEach-Object { [string]$_ } | Sort-Object -Unique)
learningPathUids = if ($pathIdsByModule.ContainsKey([string]$module.uid)) {
@($pathIdsByModule[[string]$module.uid] | Sort-Object -Unique)
} else {
@()
}
unitUids = @(@(Get-CatalogProperty $module 'units' @()) | ForEach-Object { [string]$_ })
}
}
)
$unitRecords = @(
foreach ($unitId in @($unitIds | Sort-Object)) {
if (-not $unitById.ContainsKey($unitId)) {
throw "Catalog module references missing unit '$unitId'."
}
$unit = $unitById[$unitId]
$unitUrl = [string](Get-CatalogProperty $unit 'url' '')
if ([string]::IsNullOrWhiteSpace($unitUrl)) {
$moduleId = [string]@($moduleIdsByUnit[$unitId])[0]
$moduleUrl = Get-CanonicalUrl ([string]$moduleById[$moduleId].url)
$unitSlug = if ($unitId.StartsWith("$moduleId.", [System.StringComparison]::Ordinal)) {
$unitId.Substring($moduleId.Length + 1)
} else {
@($unitId -split '\.')[-1]
}
$unitUrl = "$moduleUrl/$unitSlug"
}
[ordered]@{
uid = $unitId
title = [string]$unit.title
url = Get-CanonicalUrl $unitUrl
lastModified = if (Get-CatalogProperty $unit 'last_modified') {
Convert-ToUtcTimestamp (Get-CatalogProperty $unit 'last_modified')
} else {
$null
}
durationInMinutes = [int](Get-CatalogProperty $unit 'duration_in_minutes' 0)
moduleUids = @($moduleIdsByUnit[$unitId] | Sort-Object -Unique)
}
}
)
$snapshot = [ordered]@{
version = 1
source = [ordered]@{
provider = 'Microsoft Learn'
catalogUri = if ($PSCmdlet.ParameterSetName -eq 'Remote') { [string]$CatalogUri } else { $null }
locale = 'en-us'
filters = [ordered]@{
product = $product
role = $role
}
catalogLastModified = Get-LatestTimestamp @(
@($moduleRecords | ForEach-Object lastModified) +
@($unitRecords | ForEach-Object lastModified) +
@($pathRecords | ForEach-Object lastModified)
)
}
counts = [ordered]@{
learningPaths = $pathRecords.Count
modules = $moduleRecords.Count
units = $unitRecords.Count
}
learningPaths = $pathRecords
modules = $moduleRecords
units = $unitRecords
}
$outputDirectory = Split-Path -Parent $OutputPath
if (-not (Test-Path -LiteralPath $outputDirectory -PathType Container)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
$snapshot | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $OutputPath -Encoding UTF8
Write-Host "Microsoft Learn developer catalog: $($pathRecords.Count) paths, $($moduleRecords.Count) modules, $($unitRecords.Count) units."
Write-Host "Catalog: $OutputPath"