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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions .github/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ A single task with 5+ TFM detection modes was confusing. Separate tasks:
azure-devops-extension/
├── shared/ ← Shared TypeScript modules
│ ├── types.ts Constants, types, interfaces
│ ├── logger.ts Logger interface + ADO pipeline logger
│ ├── log-inputs.ts Task input logging helper
│ ├── http-client.ts Gzip-aware HTTPS client with User-Agent
│ ├── nuget-registration.ts NuGet V3 Registration API client
│ ├── version-threshold.ts .NET runtime version → TFM mapping
│ ├── http-range.ts HTTP Range requests + remote ZIP parsing
│ ├── zip-local.ts In-memory ZIP extraction from Buffer
Expand Down Expand Up @@ -138,13 +142,18 @@ Constants and interfaces used across all tasks:
| Export | Value | Used By |
|--------|-------|---------|
| `AL_COMPILER_DLL` | `'Microsoft.Dynamics.Nav.CodeAnalysis.dll'` | compiler-path |
| `NUGET_PACKAGE_NAME` | `'ALCops.Analyzers'` | nuget-api |
| `NUGET_PACKAGE_NAME` | `'ALCops.Analyzers'` | nuget-api, nuget-registration |
| `NUGET_FLAT_CONTAINER` | `'https://api.nuget.org/v3-flatcontainer'` | nuget-api, nuget-devtools |
| `NUGET_REGISTRATION_BASE` | `'https://api.nuget.org/v3/registration5-gz-semver2'` | nuget-registration |
| `VS_MARKETPLACE_API` | VS Marketplace gallery endpoint | marketplace |
| `AL_EXTENSION_ID` | `'ms-dynamics-smb.al'` | marketplace |
| `VSIX_DLL_PATH` | `'extension/bin/Analyzers/...'` | vsix-tfm |
| `TFM_PREFERENCE` | `['net10.0', ..., 'netstandard2.0']` | nuget-extractor, nuget-devtools |
| `TfmDetectionResult` | Interface: `{ tfm, source, details? }` | All detection modules |
| `RegistrationIndex` | Interface: Registration API index response | nuget-registration |
| `RegistrationPage` | Interface: Registration API page | nuget-registration |
| `RegistrationLeaf` | Interface: Registration API leaf (version entry) | nuget-registration |
| `RegistrationVersion` | Interface: `{ version, listed, packageContent }` | nuget-api, nuget-registration |

### binary-tfm.ts

Expand All @@ -154,6 +163,22 @@ Binary search for TFM and assembly version directly from .NET assembly DLL buffe
- `detectAssemblyVersionFromBuffer(buffer)` — Searches for `AssemblyFileVersionAttribute` then extracts the version using blob format validation (`\x01\x00` prolog + length byte + version string)
- `toShortTfm(longTfm)` — Converts long-form TFM to short form (e.g., `.NETCoreApp,Version=v8.0` → `net8.0`)

### http-client.ts

Gzip-aware HTTPS client with `User-Agent` header support. Used by `nuget-registration.ts` for the gzip-compressed Registration API and by `nuget-api.ts` for binary package downloads.

- `httpsGetBuffer(url, userAgent?)` — Fetches a URL, decompresses gzip if `Content-Encoding: gzip`, follows redirects (up to 5 hops)
- `httpsGetJson<T>(url, userAgent?)` — Fetches and JSON-parses in one step

### nuget-registration.ts

NuGet V3 Registration API client. Queries the `registration5-gz-semver2` hive (gzip-compressed, SemVer 2.0.0 inclusive) to get version metadata including listing status and download URLs.

- `parseRegistrationIndex(index)` — Pure function: extracts `RegistrationVersion[]` from a `RegistrationIndex`. Defaults `listed` to `true` when absent. Skips pages without inlined items.
- `queryNuGetRegistration(packageName, userAgent?, logger?)` — Full pipeline: fetches the registration index, resolves external pages in parallel (`Promise.all`), returns all versions.

Pagination: NuGet.org inlines page items for packages with < 128 versions. For >= 128 versions, pages are external references (no `items` array). The module detects this and fetches external pages in parallel.

### version-threshold.ts

Pure logic — no I/O, no dependencies beyond `types.ts`.
Expand Down Expand Up @@ -339,12 +364,13 @@ Dev dependencies: TypeScript, esbuild, vitest, eslint, tfx-cli.
| `shared/zip-local.test.ts` | In-memory ZIP extraction | 11 |
| `shared/vsix-tfm.test.ts` | VSIX → DLL → binary search → TFM chain | 5 |
| `shared/bc-artifact-url.test.ts` | Artifact URL parsing + variant construction | 7 |
| `install-analyzers/nuget-api.test.ts` | NuGet API client | 9 |
| `install-analyzers/nuget-api.test.ts` | NuGet API client | 11 |
| `install-analyzers/nuget-extractor.test.ts` | ZIP extraction + TFM compat matching | 17 |
| `install-analyzers/compiler-path.test.ts` | Binary TFM detection from real fixture DLLs | 15 |
| `install-analyzers/task-runner.test.ts` | Core task orchestration | 4 |
| `detect-tfm-bc-artifact/*.test.ts` | BC Artifact 3-step waterfall + task-runner | 16 |
| `detect-tfm-nuget-devtools/*.test.ts` | NuGet DevTools HTTP Range detection + task-runner | 14 |
| `detect-tfm-marketplace/*.test.ts` | Marketplace detection + task-runner | 17 |
| `shared/log-inputs.test.ts` | Task input logging | 9 |
| **Total** | | **182** |
| `shared/nuget-registration.test.ts` | NuGet V3 Registration API parsing + HTTP | 13 |
| **Total** | | **199** |
23 changes: 22 additions & 1 deletion .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,28 @@ npm run package:dev # Bundle + tfx → dev .vsix in ./out/
- `tests/fixtures/` — Real minimal .NET assemblies for PE parsing tests
- `scripts/` — CI/CD scripts (version stamping)

### NuGet API Architecture

The install-analyzers task interacts with NuGet via two APIs:

1. **V3 Registration API** (`registration5-gz-semver2` hive) for version queries
- Responses are gzip-compressed (handled by `shared/http-client.ts`)
- Returns version metadata including `listed` status and `packageContent` download URLs
- Pagination: pages with < 128 versions have inlined items; >= 128 versions use external page references fetched in parallel
- Module: `shared/nuget-registration.ts`

2. **V3 Flat Container** for package downloads
- Direct download from `api.nuget.org` CDN (tracked for NuGet.org download statistics)
- Both package ID and version must be lowercased in URLs
- Module: `tasks/install-analyzers/src/nuget-api.ts`

Key design decisions:
- `parseRegistrationIndex()` is a pure function (no I/O) for easy testing
- `queryNuGetRegistration()` is a shared module usable by any task needing NuGet version info
- `User-Agent: ALCops-AzureDevOps` is set on all HTTP requests for NuGet.org statistics tracking
- Unlisted versions are filtered out during version resolution
- `resolveVersion()` returns a `ResolvedVersion` with both the version string and the `packageContentUrl` from the Registration API (avoids redundant URL construction)

### Entry point pattern

Every task follows the same pattern:
Expand Down Expand Up @@ -136,7 +158,6 @@ TypeScript and vitest both use the `@shared/*` alias for imports from `shared/`:
- **Output variables need `isOutput: true`**: the 4th argument to `tl.setVariable()` must be `true` for downstream tasks to read the value
- **Don't commit `tasks/*/dist/`**: these are gitignored build artifacts
- **PE fixtures are real binaries**: `tests/fixtures/` contains .NET assemblies with embedded TFM and version attributes. Don't manually edit them.
- **PE fixtures are real binaries**: `tests/fixtures/` contains .NET assemblies. Don't manually edit them.

## Documentation

Expand Down
54 changes: 54 additions & 0 deletions shared/http-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as https from 'https';
import * as zlib from 'zlib';

/**
* Fetch a URL over HTTPS and return the response body as a Buffer.
* Handles gzip Content-Encoding transparently.
* Follows redirects (up to 5 hops).
*/
export function httpsGetBuffer(url: string, userAgent?: string, redirectCount = 0): Promise<Buffer> {
if (redirectCount > 5) {
return Promise.reject(new Error('Too many redirects'));
}

return new Promise((resolve, reject) => {
const headers: Record<string, string> = {};
if (userAgent) {
headers['User-Agent'] = userAgent;
}

const req = https.request(url, { method: 'GET', headers }, (res) => {
if (res.statusCode && res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
resolve(httpsGetBuffer(res.headers.location, userAgent, redirectCount + 1));
return;
}

if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
res.resume();
reject(new Error(`HTTP ${res.statusCode} for ${url}`));
return;
}

const chunks: Buffer[] = [];
res.on('data', (chunk: Buffer) => chunks.push(chunk));
res.on('end', () => {
const raw = Buffer.concat(chunks);
const isGzip = res.headers['content-encoding'] === 'gzip';
resolve(isGzip ? zlib.gunzipSync(raw) : raw);
});
res.on('error', reject);
});

req.on('error', reject);
req.end();
});
}

/**
* Fetch a URL and parse the response as JSON.
* Handles gzip Content-Encoding transparently.
*/
export async function httpsGetJson<T>(url: string, userAgent?: string): Promise<T> {
const buffer = await httpsGetBuffer(url, userAgent);
return JSON.parse(buffer.toString('utf-8')) as T;
}
71 changes: 71 additions & 0 deletions shared/nuget-registration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import {
NUGET_REGISTRATION_BASE,
RegistrationIndex,
RegistrationPage,
RegistrationVersion,
} from './types';
import { httpsGetJson } from './http-client';
import { Logger, nullLogger } from './logger';

/**
* Parse a RegistrationIndex into a flat array of RegistrationVersion objects.
* Only processes pages that have inlined items; pages without items are skipped.
* Call resolveExternalPages first if the index may contain external page references.
*/
export function parseRegistrationIndex(index: RegistrationIndex): RegistrationVersion[] {
const versions: RegistrationVersion[] = [];

for (const page of index.items) {
if (!page.items) continue;
for (const leaf of page.items) {
versions.push({
version: leaf.catalogEntry.version,
listed: leaf.catalogEntry.listed ?? true,
packageContent: leaf.packageContent,
});
}
}

return versions;
}

/**
* Fetch any external pages (those without inlined items) in parallel,
* mutating the index in place to populate their items arrays.
*/
async function resolveExternalPages(index: RegistrationIndex, userAgent?: string): Promise<void> {
const externalPages = index.items.filter((page) => !page.items);
if (externalPages.length === 0) return;

const fetched = await Promise.all(
externalPages.map((page) => httpsGetJson<RegistrationPage>(page['@id'], userAgent)),
);

for (let i = 0; i < externalPages.length; i++) {
externalPages[i].items = fetched[i].items;
}
}

/**
* Query the NuGet V3 Registration API for all versions of a package.
* Returns the full list of versions with listing status and download URLs.
* Handles pagination (external pages) transparently.
*/
export async function queryNuGetRegistration(
packageName: string,
userAgent?: string,
logger: Logger = nullLogger,
): Promise<RegistrationVersion[]> {
const lowerId = packageName.toLowerCase();
const url = `${NUGET_REGISTRATION_BASE}/${lowerId}/index.json`;
logger.debug(`NuGet Registration URL: ${url}`);

const index = await httpsGetJson<RegistrationIndex>(url, userAgent);

await resolveExternalPages(index, userAgent);

const versions = parseRegistrationIndex(index);
logger.debug(`Found ${versions.length} total versions (${versions.filter((v) => v.listed).length} listed)`);

return versions;
}
31 changes: 31 additions & 0 deletions shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const NUGET_PACKAGE_NAME = 'ALCops.Analyzers';
/** NuGet v3 flat container base URL */
export const NUGET_FLAT_CONTAINER = 'https://api.nuget.org/v3-flatcontainer';

/** NuGet v3 Registration API base URL (gzip + SemVer 2.0.0 hive) */
export const NUGET_REGISTRATION_BASE = 'https://api.nuget.org/v3/registration5-gz-semver2';

/** VS Marketplace API endpoint */
export const VS_MARKETPLACE_API =
'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery?api-version=3.0-preview.1';
Expand All @@ -35,3 +38,31 @@ export interface TfmDetectionResult {
source: string;
details?: string;
}

// ── NuGet V3 Registration API types ──

export interface RegistrationCatalogEntry {
version: string;
listed?: boolean;
}

export interface RegistrationLeaf {
catalogEntry: RegistrationCatalogEntry;
packageContent: string;
}

export interface RegistrationPage {
'@id': string;
items?: RegistrationLeaf[];
}

export interface RegistrationIndex {
items: RegistrationPage[];
}

/** Parsed version info from the Registration API */
export interface RegistrationVersion {
version: string;
listed: boolean;
packageContent: string;
}
Loading