@shrkcrft/boundaries 0.1.0-alpha.26 → 0.1.0-alpha.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/baseline/canonicalize.d.ts +35 -0
- package/dist/baseline/canonicalize.d.ts.map +1 -0
- package/dist/baseline/canonicalize.js +87 -0
- package/dist/baseline/compute-baseline.d.ts +23 -0
- package/dist/baseline/compute-baseline.d.ts.map +1 -0
- package/dist/baseline/compute-baseline.js +30 -0
- package/dist/baseline/diff-baseline.d.ts +68 -0
- package/dist/baseline/diff-baseline.d.ts.map +1 -0
- package/dist/baseline/diff-baseline.js +110 -0
- package/dist/baseline/json-path-keys.d.ts +10 -0
- package/dist/baseline/json-path-keys.d.ts.map +1 -0
- package/dist/baseline/json-path-keys.js +13 -0
- package/dist/extract/code-zones.d.ts +32 -0
- package/dist/extract/code-zones.d.ts.map +1 -0
- package/dist/extract/code-zones.js +59 -0
- package/dist/extract/extract-tokens.d.ts +30 -0
- package/dist/extract/extract-tokens.d.ts.map +1 -0
- package/dist/extract/extract-tokens.js +328 -0
- package/dist/extract/inspect-source.d.ts +24 -0
- package/dist/extract/inspect-source.d.ts.map +1 -0
- package/dist/extract/inspect-source.js +27 -0
- package/dist/extract/scan-literals.d.ts +54 -0
- package/dist/extract/scan-literals.d.ts.map +1 -0
- package/dist/extract/scan-literals.js +177 -0
- package/dist/generated/check-provenance.d.ts +37 -0
- package/dist/generated/check-provenance.d.ts.map +1 -0
- package/dist/generated/check-provenance.js +89 -0
- package/dist/generated/compare-trees.d.ts +26 -0
- package/dist/generated/compare-trees.d.ts.map +1 -0
- package/dist/generated/compare-trees.js +41 -0
- package/dist/generated/scan-generated.d.ts +17 -0
- package/dist/generated/scan-generated.d.ts.map +1 -0
- package/dist/generated/scan-generated.js +27 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/policy/evaluate-policy.d.ts +41 -5
- package/dist/policy/evaluate-policy.d.ts.map +1 -1
- package/dist/policy/evaluate-policy.js +112 -10
- package/dist/policy/run-policy.d.ts.map +1 -1
- package/dist/policy/run-policy.js +24 -3
- package/dist/wiring/evaluate-wiring.d.ts +81 -28
- package/dist/wiring/evaluate-wiring.d.ts.map +1 -1
- package/dist/wiring/evaluate-wiring.js +0 -0
- package/dist/wiring/explain-wiring.d.ts +15 -7
- package/dist/wiring/explain-wiring.d.ts.map +1 -1
- package/dist/wiring/explain-wiring.js +28 -27
- package/dist/wiring/registration-graph.d.ts +27 -2
- package/dist/wiring/registration-graph.d.ts.map +1 -1
- package/dist/wiring/registration-graph.js +51 -2
- package/dist/wiring/registry-query.d.ts +9 -0
- package/dist/wiring/registry-query.d.ts.map +1 -1
- package/dist/wiring/registry-query.js +11 -0
- package/dist/wiring/scan-wiring-files.d.ts.map +1 -1
- package/dist/wiring/scan-wiring-files.js +4 -7
- package/dist/wiring/trace-literal.d.ts +6 -0
- package/dist/wiring/trace-literal.d.ts.map +1 -1
- package/dist/wiring/trace-literal.js +22 -0
- package/package.json +2 -2
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { safeCompile } from "../util/safe-regex.js";
|
|
2
|
+
/** The head of a file, as the header contract sees it. */
|
|
3
|
+
function headOf(content, withinLines) {
|
|
4
|
+
const lines = content.split('\n');
|
|
5
|
+
return lines.slice(0, Math.max(1, withinLines)).join('\n');
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Derive the globs to search for MISLABELED files when the rule doesn't name
|
|
9
|
+
* them: one `**\/*.<ext>` per distinct extension in `generatedGlob`. Bounded and
|
|
10
|
+
* deterministic — never a whole-tree read of every file type.
|
|
11
|
+
*/
|
|
12
|
+
export function deriveOutsideGlobs(generatedGlob) {
|
|
13
|
+
const exts = new Set();
|
|
14
|
+
for (const g of generatedGlob) {
|
|
15
|
+
const m = /\.([A-Za-z0-9]+)$/.exec(g);
|
|
16
|
+
if (m)
|
|
17
|
+
exts.add(m[1]);
|
|
18
|
+
}
|
|
19
|
+
return [...exts].sort().map((e) => `**/*.${e}`);
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check the "this file is generated" header contract.
|
|
23
|
+
*
|
|
24
|
+
* Pure: the caller supplies the file contents, so this runs with no regen
|
|
25
|
+
* command, no temp dir, and no spawn — a header-only rule is fully useful (and
|
|
26
|
+
* safe to ship from a pack) on its own.
|
|
27
|
+
*
|
|
28
|
+
* `outside` should contain candidate files NOT in `generatedGlob`; pass an empty
|
|
29
|
+
* map when `forbidOutside` is off.
|
|
30
|
+
*/
|
|
31
|
+
export function checkProvenanceHeaders(rule, generated, outside) {
|
|
32
|
+
const header = rule.provenanceHeader;
|
|
33
|
+
if (!header)
|
|
34
|
+
return { findings: [] };
|
|
35
|
+
const { re, error } = safeCompile(header.mustMatch, header.flags);
|
|
36
|
+
if (error || !re)
|
|
37
|
+
return { findings: [], error: `provenanceHeader ${error}` };
|
|
38
|
+
const severity = rule.severity ?? 'error';
|
|
39
|
+
const within = header.withinLines ?? 10;
|
|
40
|
+
const findings = [];
|
|
41
|
+
const test = (content) => {
|
|
42
|
+
re.lastIndex = 0;
|
|
43
|
+
return re.test(headOf(content, within));
|
|
44
|
+
};
|
|
45
|
+
for (const file of [...generated.keys()].sort()) {
|
|
46
|
+
const content = generated.get(file);
|
|
47
|
+
if (!test(content)) {
|
|
48
|
+
findings.push({
|
|
49
|
+
ruleId: rule.id,
|
|
50
|
+
file,
|
|
51
|
+
kind: 'missing-header',
|
|
52
|
+
severity,
|
|
53
|
+
message: `generated file carries no provenance header matching /${header.mustMatch}/`,
|
|
54
|
+
});
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
// Advisory only — a header that names its regen command saves the next
|
|
58
|
+
// editor a search, but its absence is not a correctness defect.
|
|
59
|
+
if (header.pointsToRegenCommand && rule.regen) {
|
|
60
|
+
const head = headOf(content, within);
|
|
61
|
+
const verb = rule.regen.trim().split(/\s+/)[0] ?? '';
|
|
62
|
+
if (verb !== '' && !head.includes(verb)) {
|
|
63
|
+
findings.push({
|
|
64
|
+
ruleId: rule.id,
|
|
65
|
+
file,
|
|
66
|
+
kind: 'no-regen-pointer',
|
|
67
|
+
severity: 'warning',
|
|
68
|
+
message: `provenance header does not name the regen command (\`${verb} …\`)`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (header.forbidOutside) {
|
|
74
|
+
for (const file of [...outside.keys()].sort()) {
|
|
75
|
+
if (generated.has(file))
|
|
76
|
+
continue;
|
|
77
|
+
if (!test(outside.get(file)))
|
|
78
|
+
continue;
|
|
79
|
+
findings.push({
|
|
80
|
+
ruleId: rule.id,
|
|
81
|
+
file,
|
|
82
|
+
kind: 'mislabeled',
|
|
83
|
+
severity,
|
|
84
|
+
message: 'hand-written file carries a generated-file header — either move it under the generated glob or drop the header',
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { findings };
|
|
89
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare a committed generated tree against a freshly regenerated one.
|
|
3
|
+
*
|
|
4
|
+
* The comparison is deliberately SYMMETRIC. A regen that writes a subset (a
|
|
5
|
+
* template dropped, an input renamed) leaves stale committed files behind, and
|
|
6
|
+
* a one-directional check would report that as clean — the same one-way blind
|
|
7
|
+
* spot that makes hand-rolled drift tests untrustworthy.
|
|
8
|
+
*/
|
|
9
|
+
/** One file that differs between the committed tree and the regenerated one. */
|
|
10
|
+
export interface IGeneratedFileDiff {
|
|
11
|
+
readonly file: string;
|
|
12
|
+
/**
|
|
13
|
+
* `content` — present on both sides, bytes differ (the hand-edit signal).
|
|
14
|
+
* `only-committed` — regen no longer produces it (stale committed file).
|
|
15
|
+
* `only-regenerated` — regen produces it but it was never committed.
|
|
16
|
+
*/
|
|
17
|
+
readonly kind: 'content' | 'only-committed' | 'only-regenerated';
|
|
18
|
+
}
|
|
19
|
+
export interface IGeneratedTreeDiff {
|
|
20
|
+
readonly differences: readonly IGeneratedFileDiff[];
|
|
21
|
+
readonly committedCount: number;
|
|
22
|
+
readonly regeneratedCount: number;
|
|
23
|
+
}
|
|
24
|
+
/** Compare two path→content maps. Pure — the caller does all the IO. */
|
|
25
|
+
export declare function compareGeneratedTrees(committed: ReadonlyMap<string, string>, regenerated: ReadonlyMap<string, string>, compare?: 'bytes' | 'normalized-whitespace'): IGeneratedTreeDiff;
|
|
26
|
+
//# sourceMappingURL=compare-trees.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"compare-trees.d.ts","sourceRoot":"","sources":["../../src/generated/compare-trees.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AAEH,gFAAgF;AAChF,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB;;;;OAIG;IACH,QAAQ,CAAC,IAAI,EAAE,SAAS,GAAG,gBAAgB,GAAG,kBAAkB,CAAC;CAClE;AAED,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,WAAW,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACpD,QAAQ,CAAC,cAAc,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,gBAAgB,EAAE,MAAM,CAAC;CACnC;AAYD,wEAAwE;AACxE,wBAAgB,qBAAqB,CACnC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACtC,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,EACxC,OAAO,GAAE,OAAO,GAAG,uBAAiC,GACnD,kBAAkB,CAqBpB"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compare a committed generated tree against a freshly regenerated one.
|
|
3
|
+
*
|
|
4
|
+
* The comparison is deliberately SYMMETRIC. A regen that writes a subset (a
|
|
5
|
+
* template dropped, an input renamed) leaves stale committed files behind, and
|
|
6
|
+
* a one-directional check would report that as clean — the same one-way blind
|
|
7
|
+
* spot that makes hand-rolled drift tests untrustworthy.
|
|
8
|
+
*/
|
|
9
|
+
/** Normalize line endings + trailing whitespace, and drop a trailing newline. */
|
|
10
|
+
function normalizeWhitespace(text) {
|
|
11
|
+
return text
|
|
12
|
+
.replace(/\r\n/g, '\n')
|
|
13
|
+
.split('\n')
|
|
14
|
+
.map((l) => l.replace(/[ \t]+$/, ''))
|
|
15
|
+
.join('\n')
|
|
16
|
+
.replace(/\n+$/, '');
|
|
17
|
+
}
|
|
18
|
+
/** Compare two path→content maps. Pure — the caller does all the IO. */
|
|
19
|
+
export function compareGeneratedTrees(committed, regenerated, compare = 'bytes') {
|
|
20
|
+
const norm = (s) => (compare === 'bytes' ? s : normalizeWhitespace(s));
|
|
21
|
+
const differences = [];
|
|
22
|
+
for (const file of [...committed.keys()].sort()) {
|
|
23
|
+
const regen = regenerated.get(file);
|
|
24
|
+
if (regen === undefined) {
|
|
25
|
+
differences.push({ file, kind: 'only-committed' });
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
if (norm(committed.get(file)) !== norm(regen)) {
|
|
29
|
+
differences.push({ file, kind: 'content' });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
for (const file of [...regenerated.keys()].sort()) {
|
|
33
|
+
if (!committed.has(file))
|
|
34
|
+
differences.push({ file, kind: 'only-regenerated' });
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
differences,
|
|
38
|
+
committedCount: committed.size,
|
|
39
|
+
regeneratedCount: regenerated.size,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IGeneratedArtifactRule } from '@shrkcrft/core';
|
|
2
|
+
/** The committed generated files plus the mislabel-candidate set. */
|
|
3
|
+
export interface IGeneratedScan {
|
|
4
|
+
/** Files matching `generatedGlob` (project-relative path → content). */
|
|
5
|
+
readonly generated: ReadonlyMap<string, string>;
|
|
6
|
+
/** Candidates for the `forbidOutside` mislabel check (empty when it is off). */
|
|
7
|
+
readonly outside: ReadonlyMap<string, string>;
|
|
8
|
+
/** Globs actually used for the outside scan (derived or configured). */
|
|
9
|
+
readonly outsideGlobs: readonly string[];
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Read the committed side of a generated-artifact rule. Pure filesystem reads —
|
|
13
|
+
* the regen command (if any) is orchestrated by the caller, which is also where
|
|
14
|
+
* the local-config-only trust decision is enforced.
|
|
15
|
+
*/
|
|
16
|
+
export declare function scanGeneratedFiles(projectRoot: string, rule: IGeneratedArtifactRule, excludeDirs?: readonly string[]): IGeneratedScan;
|
|
17
|
+
//# sourceMappingURL=scan-generated.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scan-generated.d.ts","sourceRoot":"","sources":["../../src/generated/scan-generated.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,gBAAgB,CAAC;AAK7D,qEAAqE;AACrE,MAAM,WAAW,cAAc;IAC7B,wEAAwE;IACxE,QAAQ,CAAC,SAAS,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAChD,gFAAgF;IAChF,QAAQ,CAAC,OAAO,EAAE,WAAW,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IAC9C,wEAAwE;IACxE,QAAQ,CAAC,YAAY,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAED;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,sBAAsB,EAC5B,WAAW,GAAE,SAAS,MAAM,EAAO,GAClC,cAAc,CAmBhB"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { matchesAny } from "../scan/glob.js";
|
|
2
|
+
import { readMatchingFiles } from "../util/walk-files.js";
|
|
3
|
+
import { deriveOutsideGlobs } from "./check-provenance.js";
|
|
4
|
+
/**
|
|
5
|
+
* Read the committed side of a generated-artifact rule. Pure filesystem reads —
|
|
6
|
+
* the regen command (if any) is orchestrated by the caller, which is also where
|
|
7
|
+
* the local-config-only trust decision is enforced.
|
|
8
|
+
*/
|
|
9
|
+
export function scanGeneratedFiles(projectRoot, rule, excludeDirs = []) {
|
|
10
|
+
const exclude = new Set(excludeDirs);
|
|
11
|
+
const generated = readMatchingFiles(projectRoot, rule.generatedGlob, exclude);
|
|
12
|
+
const wantOutside = rule.provenanceHeader?.forbidOutside === true;
|
|
13
|
+
if (!wantOutside) {
|
|
14
|
+
return { generated, outside: new Map(), outsideGlobs: [] };
|
|
15
|
+
}
|
|
16
|
+
const outsideGlobs = rule.provenanceHeader?.outsideGlob && rule.provenanceHeader.outsideGlob.length > 0
|
|
17
|
+
? [...rule.provenanceHeader.outsideGlob]
|
|
18
|
+
: deriveOutsideGlobs(rule.generatedGlob);
|
|
19
|
+
const all = readMatchingFiles(projectRoot, outsideGlobs, exclude);
|
|
20
|
+
const outside = new Map();
|
|
21
|
+
for (const [path, content] of all) {
|
|
22
|
+
if (matchesAny(path, rule.generatedGlob))
|
|
23
|
+
continue;
|
|
24
|
+
outside.set(path, content);
|
|
25
|
+
}
|
|
26
|
+
return { generated, outside, outsideGlobs };
|
|
27
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -14,6 +14,17 @@ export * from './wiring/trace-literal.js';
|
|
|
14
14
|
export * from './policy/extract-templates.js';
|
|
15
15
|
export * from './policy/evaluate-policy.js';
|
|
16
16
|
export * from './policy/run-policy.js';
|
|
17
|
+
export * from './extract/scan-literals.js';
|
|
18
|
+
export * from './extract/extract-tokens.js';
|
|
19
|
+
export * from './extract/code-zones.js';
|
|
20
|
+
export * from './extract/inspect-source.js';
|
|
21
|
+
export * from './baseline/canonicalize.js';
|
|
22
|
+
export * from './baseline/json-path-keys.js';
|
|
23
|
+
export * from './baseline/diff-baseline.js';
|
|
24
|
+
export * from './baseline/compute-baseline.js';
|
|
25
|
+
export * from './generated/check-provenance.js';
|
|
26
|
+
export * from './generated/compare-trees.js';
|
|
27
|
+
export * from './generated/scan-generated.js';
|
|
17
28
|
export * from './util/safe-regex.js';
|
|
18
29
|
export * from './util/walk-files.js';
|
|
19
30
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,mCAAmC,CAAC;AAClD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mCAAmC,CAAC;AAClD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,wBAAwB,CAAC;AACvC,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,0BAA0B,CAAC;AACzC,cAAc,iCAAiC,CAAC;AAChD,cAAc,mCAAmC,CAAC;AAClD,cAAc,gBAAgB,CAAC;AAC/B,cAAc,wBAAwB,CAAC;AACvC,cAAc,mCAAmC,CAAC;AAClD,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,2BAA2B,CAAC;AAC1C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,wBAAwB,CAAC;AACvC,cAAc,4BAA4B,CAAC;AAC3C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,yBAAyB,CAAC;AACxC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,4BAA4B,CAAC;AAC3C,cAAc,8BAA8B,CAAC;AAC7C,cAAc,6BAA6B,CAAC;AAC5C,cAAc,gCAAgC,CAAC;AAC/C,cAAc,iCAAiC,CAAC;AAChD,cAAc,8BAA8B,CAAC;AAC7C,cAAc,+BAA+B,CAAC;AAC9C,cAAc,sBAAsB,CAAC;AACrC,cAAc,sBAAsB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -14,5 +14,16 @@ export * from "./wiring/trace-literal.js";
|
|
|
14
14
|
export * from "./policy/extract-templates.js";
|
|
15
15
|
export * from "./policy/evaluate-policy.js";
|
|
16
16
|
export * from "./policy/run-policy.js";
|
|
17
|
+
export * from "./extract/scan-literals.js";
|
|
18
|
+
export * from "./extract/extract-tokens.js";
|
|
19
|
+
export * from "./extract/code-zones.js";
|
|
20
|
+
export * from "./extract/inspect-source.js";
|
|
21
|
+
export * from "./baseline/canonicalize.js";
|
|
22
|
+
export * from "./baseline/json-path-keys.js";
|
|
23
|
+
export * from "./baseline/diff-baseline.js";
|
|
24
|
+
export * from "./baseline/compute-baseline.js";
|
|
25
|
+
export * from "./generated/check-provenance.js";
|
|
26
|
+
export * from "./generated/compare-trees.js";
|
|
27
|
+
export * from "./generated/scan-generated.js";
|
|
17
28
|
export * from "./util/safe-regex.js";
|
|
18
29
|
export * from "./util/walk-files.js";
|
|
@@ -11,6 +11,8 @@ export interface IPolicyUnit {
|
|
|
11
11
|
readonly baseLine: number;
|
|
12
12
|
/** Marks an inline-template unit (for clearer reporting). */
|
|
13
13
|
readonly inlineTemplate?: boolean;
|
|
14
|
+
/** True when the file matched one of the rule's `exemptFiles` globs. */
|
|
15
|
+
readonly exemptFile?: boolean;
|
|
14
16
|
}
|
|
15
17
|
export interface IPolicyFinding {
|
|
16
18
|
readonly ruleId: string;
|
|
@@ -24,24 +26,55 @@ export interface IPolicyFinding {
|
|
|
24
26
|
readonly severity: 'error' | 'warning';
|
|
25
27
|
readonly inlineTemplate?: boolean;
|
|
26
28
|
}
|
|
29
|
+
/** A hit that WAS matched but dropped by an exemption — kept so it can be shown. */
|
|
30
|
+
export interface IPolicySuppression {
|
|
31
|
+
readonly ruleId: string;
|
|
32
|
+
readonly file: string;
|
|
33
|
+
readonly line: number;
|
|
34
|
+
readonly match: string;
|
|
35
|
+
/** Which exemption applied. */
|
|
36
|
+
readonly via: 'exemptFiles' | 'exemptLines' | 'scanZone';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Per-rule outcome. `skipped` is DISTINCT from `passed`: a rule that scanned no
|
|
40
|
+
* units checked nothing, and calling that a pass is exactly how a stale glob
|
|
41
|
+
* ships a real violation.
|
|
42
|
+
*/
|
|
43
|
+
export type PolicyRuleStatus = 'passed' | 'failed' | 'skipped' | 'error';
|
|
27
44
|
export interface IPolicyRuleResult {
|
|
28
45
|
readonly ruleId: string;
|
|
29
46
|
readonly surface: PolicySurface;
|
|
30
47
|
readonly severity: 'error' | 'warning';
|
|
48
|
+
readonly status: PolicyRuleStatus;
|
|
31
49
|
readonly findingCount: number;
|
|
50
|
+
/** Hits dropped by an exemption (still counted, never silent). */
|
|
51
|
+
readonly suppressedCount: number;
|
|
52
|
+
/** Content units actually scanned — the stale-glob signal. */
|
|
53
|
+
readonly unitsScanned: number;
|
|
32
54
|
readonly error?: string;
|
|
33
55
|
}
|
|
56
|
+
/** A rule that scanned nothing, reported loudly instead of as a green pass. */
|
|
57
|
+
export interface IPolicySkip {
|
|
58
|
+
readonly ruleId: string;
|
|
59
|
+
readonly reason: string;
|
|
60
|
+
/** True when the rule set `failOnEmpty` — the skip counts as a failure. */
|
|
61
|
+
readonly failed: boolean;
|
|
62
|
+
readonly severity: 'error' | 'warning';
|
|
63
|
+
}
|
|
34
64
|
export interface IPolicyReport {
|
|
35
65
|
readonly schema: typeof POLICY_LINT_SCHEMA;
|
|
36
66
|
readonly rules: readonly IPolicyRuleResult[];
|
|
37
67
|
readonly findings: readonly IPolicyFinding[];
|
|
38
68
|
readonly diagnostics: readonly string[];
|
|
69
|
+
/** Hits an exemption dropped — surfaced by `policy-lint explain`. */
|
|
70
|
+
readonly suppressed: readonly IPolicySuppression[];
|
|
71
|
+
/** Rules that scanned nothing. */
|
|
72
|
+
readonly skipped: readonly IPolicySkip[];
|
|
39
73
|
/**
|
|
40
74
|
* Count of rules that actually scanned ≥1 unit. A rule whose globs matched 0
|
|
41
75
|
* files (e.g. a `style` rule in a project with no stylesheets) is NOT
|
|
42
|
-
* evaluated — a
|
|
43
|
-
*
|
|
44
|
-
* swallowed by the gate's `evaluated === 0` skip path.
|
|
76
|
+
* evaluated — a loud skip rather than a green pass. Misconfigured rules count
|
|
77
|
+
* as evaluated so their error is not swallowed.
|
|
45
78
|
*/
|
|
46
79
|
readonly evaluated: number;
|
|
47
80
|
readonly verdict: 'pass' | 'errors' | 'warnings';
|
|
@@ -51,8 +84,11 @@ export type PolicyUnitResolver = (rule: IPolicyRule) => readonly IPolicyUnit[];
|
|
|
51
84
|
/**
|
|
52
85
|
* Pure policy evaluation. Each rule's regex is run over the units the resolver
|
|
53
86
|
* supplies; matches become findings (capture group 1 is the reported token when
|
|
54
|
-
* present).
|
|
55
|
-
*
|
|
87
|
+
* present). Exemptions (`exemptFiles` / `exemptLines`) and the lexical `scan`
|
|
88
|
+
* zone drop hits into {@link IPolicyReport.suppressed} rather than deleting
|
|
89
|
+
* them, so `explain` can show what was matched AND what was let through. A
|
|
90
|
+
* misconfigured rule (uncompilable regex) degrades to a diagnostic — never
|
|
91
|
+
* throws, so one bad rule cannot crash the check.
|
|
56
92
|
*/
|
|
57
93
|
export declare function evaluatePolicy(rules: readonly IPolicyRule[], resolve: PolicyUnitResolver): IPolicyReport;
|
|
58
94
|
//# sourceMappingURL=evaluate-policy.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"evaluate-policy.d.ts","sourceRoot":"","sources":["../../src/policy/evaluate-policy.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,
|
|
1
|
+
{"version":3,"file":"evaluate-policy.d.ts","sourceRoot":"","sources":["../../src/policy/evaluate-policy.ts"],"names":[],"mappings":"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"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { lexCodeZones, zoneAt } from "../extract/code-zones.js";
|
|
1
2
|
import { safeCompile } from "../util/safe-regex.js";
|
|
2
3
|
export const POLICY_LINT_SCHEMA = 'sharkcraft.policy-lint/v1';
|
|
3
4
|
function lineWithin(content, index) {
|
|
@@ -13,16 +14,44 @@ function truncate(s, max = 120) {
|
|
|
13
14
|
const oneLine = s.replace(/\s+/g, ' ').trim();
|
|
14
15
|
return oneLine.length > max ? oneLine.slice(0, max - 1) + '…' : oneLine;
|
|
15
16
|
}
|
|
17
|
+
/** Does the marker appear on `line` (1-based, within `lines`) or the one above it? */
|
|
18
|
+
function markerNear(lines, line, marker) {
|
|
19
|
+
const here = lines[line - 1];
|
|
20
|
+
if (here !== undefined && here.includes(marker))
|
|
21
|
+
return true;
|
|
22
|
+
const above = lines[line - 2];
|
|
23
|
+
return above !== undefined && above.includes(marker);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Whether the zone rule keeps a match at `index`. `all` keeps everything; the
|
|
27
|
+
* narrower zones require the match to START in the named zone. Not applied to
|
|
28
|
+
* inline-template units (their content is already a string body).
|
|
29
|
+
*/
|
|
30
|
+
function zoneKeeps(zone, unit, zones, index) {
|
|
31
|
+
if (zone === 'all' || unit.inlineTemplate || zones === undefined)
|
|
32
|
+
return true;
|
|
33
|
+
const kind = zoneAt(zones, index);
|
|
34
|
+
if (zone === 'code')
|
|
35
|
+
return kind === 'code';
|
|
36
|
+
if (zone === 'strings')
|
|
37
|
+
return kind === 'string';
|
|
38
|
+
return kind === 'comment';
|
|
39
|
+
}
|
|
16
40
|
/**
|
|
17
41
|
* Pure policy evaluation. Each rule's regex is run over the units the resolver
|
|
18
42
|
* supplies; matches become findings (capture group 1 is the reported token when
|
|
19
|
-
* present).
|
|
20
|
-
*
|
|
43
|
+
* present). Exemptions (`exemptFiles` / `exemptLines`) and the lexical `scan`
|
|
44
|
+
* zone drop hits into {@link IPolicyReport.suppressed} rather than deleting
|
|
45
|
+
* them, so `explain` can show what was matched AND what was let through. A
|
|
46
|
+
* misconfigured rule (uncompilable regex) degrades to a diagnostic — never
|
|
47
|
+
* throws, so one bad rule cannot crash the check.
|
|
21
48
|
*/
|
|
22
49
|
export function evaluatePolicy(rules, resolve) {
|
|
23
50
|
const ruleResults = [];
|
|
24
51
|
const findings = [];
|
|
52
|
+
const suppressed = [];
|
|
25
53
|
const diagnostics = [];
|
|
54
|
+
const skipped = [];
|
|
26
55
|
let evaluated = 0;
|
|
27
56
|
let misconfigError = false;
|
|
28
57
|
let misconfigWarn = false;
|
|
@@ -36,18 +65,29 @@ export function evaluatePolicy(rules, resolve) {
|
|
|
36
65
|
misconfigError = true;
|
|
37
66
|
else
|
|
38
67
|
misconfigWarn = true;
|
|
39
|
-
ruleResults.push({
|
|
68
|
+
ruleResults.push({
|
|
69
|
+
ruleId: rule.id,
|
|
70
|
+
surface: rule.surface,
|
|
71
|
+
severity,
|
|
72
|
+
status: 'error',
|
|
73
|
+
findingCount: 0,
|
|
74
|
+
suppressedCount: 0,
|
|
75
|
+
unitsScanned: 0,
|
|
76
|
+
error: msg,
|
|
77
|
+
});
|
|
40
78
|
// A misconfigured rule attempted to run — count it as evaluated so its
|
|
41
79
|
// error isn't swallowed by the gate's `evaluated === 0` skip path.
|
|
42
80
|
evaluated += 1;
|
|
43
81
|
continue;
|
|
44
82
|
}
|
|
45
83
|
const units = resolve(rule);
|
|
46
|
-
|
|
47
|
-
evaluated += 1;
|
|
84
|
+
const zone = rule.scan ?? 'all';
|
|
48
85
|
let count = 0;
|
|
86
|
+
let suppressedCount = 0;
|
|
49
87
|
let zeroWidth = false;
|
|
50
88
|
for (const unit of units) {
|
|
89
|
+
const lines = rule.exemptLines ? unit.content.split('\n') : undefined;
|
|
90
|
+
const zones = zone !== 'all' && !unit.inlineTemplate ? lexCodeZones(unit.content) : undefined;
|
|
51
91
|
re.lastIndex = 0;
|
|
52
92
|
let m;
|
|
53
93
|
while ((m = re.exec(unit.content)) !== null) {
|
|
@@ -59,13 +99,30 @@ export function evaluatePolicy(rules, resolve) {
|
|
|
59
99
|
continue;
|
|
60
100
|
}
|
|
61
101
|
const token = m[1] !== undefined ? m[1] : m[0];
|
|
62
|
-
const
|
|
102
|
+
const localLine = lineWithin(unit.content, m.index);
|
|
103
|
+
const line = unit.baseLine - 1 + localLine;
|
|
104
|
+
const match = truncate(token);
|
|
105
|
+
if (unit.exemptFile) {
|
|
106
|
+
suppressed.push({ ruleId: rule.id, file: unit.path, line, match, via: 'exemptFiles' });
|
|
107
|
+
suppressedCount += 1;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (!zoneKeeps(zone, unit, zones, m.index)) {
|
|
111
|
+
suppressed.push({ ruleId: rule.id, file: unit.path, line, match, via: 'scanZone' });
|
|
112
|
+
suppressedCount += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
if (lines && markerNear(lines, localLine, rule.exemptLines)) {
|
|
116
|
+
suppressed.push({ ruleId: rule.id, file: unit.path, line, match, via: 'exemptLines' });
|
|
117
|
+
suppressedCount += 1;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
63
120
|
findings.push({
|
|
64
121
|
ruleId: rule.id,
|
|
65
122
|
surface: rule.surface,
|
|
66
123
|
file: unit.path,
|
|
67
124
|
line,
|
|
68
|
-
match
|
|
125
|
+
match,
|
|
69
126
|
message: rule.message,
|
|
70
127
|
...(rule.suggest ? { suggest: rule.suggest } : {}),
|
|
71
128
|
severity,
|
|
@@ -81,11 +138,54 @@ export function evaluatePolicy(rules, resolve) {
|
|
|
81
138
|
misconfigError = true;
|
|
82
139
|
else
|
|
83
140
|
misconfigWarn = true;
|
|
84
|
-
ruleResults.push({
|
|
141
|
+
ruleResults.push({
|
|
142
|
+
ruleId: rule.id,
|
|
143
|
+
surface: rule.surface,
|
|
144
|
+
severity,
|
|
145
|
+
status: 'error',
|
|
146
|
+
findingCount: count,
|
|
147
|
+
suppressedCount,
|
|
148
|
+
unitsScanned: units.length,
|
|
149
|
+
error: msg,
|
|
150
|
+
});
|
|
151
|
+
evaluated += 1;
|
|
152
|
+
continue;
|
|
85
153
|
}
|
|
86
|
-
|
|
87
|
-
|
|
154
|
+
if (units.length === 0) {
|
|
155
|
+
const failed = rule.failOnEmpty === true;
|
|
156
|
+
skipped.push({
|
|
157
|
+
ruleId: rule.id,
|
|
158
|
+
reason: '0 content units matched the rule globs',
|
|
159
|
+
failed,
|
|
160
|
+
severity,
|
|
161
|
+
});
|
|
162
|
+
if (failed) {
|
|
163
|
+
if (severity === 'error')
|
|
164
|
+
misconfigError = true;
|
|
165
|
+
else
|
|
166
|
+
misconfigWarn = true;
|
|
167
|
+
}
|
|
168
|
+
ruleResults.push({
|
|
169
|
+
ruleId: rule.id,
|
|
170
|
+
surface: rule.surface,
|
|
171
|
+
severity,
|
|
172
|
+
status: failed ? 'failed' : 'skipped',
|
|
173
|
+
findingCount: 0,
|
|
174
|
+
suppressedCount: 0,
|
|
175
|
+
unitsScanned: 0,
|
|
176
|
+
});
|
|
177
|
+
continue;
|
|
88
178
|
}
|
|
179
|
+
evaluated += 1;
|
|
180
|
+
ruleResults.push({
|
|
181
|
+
ruleId: rule.id,
|
|
182
|
+
surface: rule.surface,
|
|
183
|
+
severity,
|
|
184
|
+
status: count > 0 ? 'failed' : 'passed',
|
|
185
|
+
findingCount: count,
|
|
186
|
+
suppressedCount,
|
|
187
|
+
unitsScanned: units.length,
|
|
188
|
+
});
|
|
89
189
|
}
|
|
90
190
|
const hasError = misconfigError || findings.some((f) => f.severity === 'error');
|
|
91
191
|
const hasWarn = misconfigWarn || findings.some((f) => f.severity === 'warning');
|
|
@@ -94,6 +194,8 @@ export function evaluatePolicy(rules, resolve) {
|
|
|
94
194
|
rules: ruleResults,
|
|
95
195
|
findings,
|
|
96
196
|
diagnostics,
|
|
197
|
+
suppressed,
|
|
198
|
+
skipped,
|
|
97
199
|
evaluated,
|
|
98
200
|
verdict: hasError ? 'errors' : hasWarn ? 'warnings' : 'pass',
|
|
99
201
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"run-policy.d.ts","sourceRoot":"","sources":["../../src/policy/run-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAIjE,OAAO,EAAkB,KAAK,aAAa,EAAoB,MAAM,sBAAsB,CAAC;AAY5F,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IAC7C,+BAA+B;IAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,2FAA2F;IAC3F,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAMD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,SAAS,WAAW,EAAE,EAC7B,OAAO,GAAE,iBAAsB,GAC9B,aAAa,
|
|
1
|
+
{"version":3,"file":"run-policy.d.ts","sourceRoot":"","sources":["../../src/policy/run-policy.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AAIjE,OAAO,EAAkB,KAAK,aAAa,EAAoB,MAAM,sBAAsB,CAAC;AAY5F,MAAM,WAAW,iBAAiB;IAChC,2CAA2C;IAC3C,QAAQ,CAAC,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;IAC7C,+BAA+B;IAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAClC,kEAAkE;IAClE,QAAQ,CAAC,WAAW,CAAC,EAAE,OAAO,CAAC;IAC/B,QAAQ,CAAC,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1C,2FAA2F;IAC3F,QAAQ,CAAC,WAAW,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1C;AAMD;;;;;GAKG;AACH,wBAAgB,aAAa,CAC3B,WAAW,EAAE,MAAM,EACnB,KAAK,EAAE,SAAS,WAAW,EAAE,EAC7B,OAAO,GAAE,iBAAsB,GAC9B,aAAa,CAkEf"}
|
|
@@ -35,7 +35,16 @@ export function runPolicyLint(projectRoot, rules, options = {}) {
|
|
|
35
35
|
selected = selected.filter((r) => changed.some((c) => matchesAny(c, globsFor(r))));
|
|
36
36
|
}
|
|
37
37
|
if (selected.length === 0) {
|
|
38
|
-
return {
|
|
38
|
+
return {
|
|
39
|
+
schema: 'sharkcraft.policy-lint/v1',
|
|
40
|
+
rules: [],
|
|
41
|
+
findings: [],
|
|
42
|
+
diagnostics: [],
|
|
43
|
+
suppressed: [],
|
|
44
|
+
skipped: [],
|
|
45
|
+
evaluated: 0,
|
|
46
|
+
verdict: 'pass',
|
|
47
|
+
};
|
|
39
48
|
}
|
|
40
49
|
// Under --changed-only, restrict the SCANNED files to the changed set too (not
|
|
41
50
|
// just rule selection). Per-file regex findings have no cross-file dependency,
|
|
@@ -47,21 +56,33 @@ export function runPolicyLint(projectRoot, rules, options = {}) {
|
|
|
47
56
|
const cache = readMatchingFiles(projectRoot, allGlobs, new Set(options.excludeDirs ?? []));
|
|
48
57
|
return evaluatePolicy(selected, (rule) => {
|
|
49
58
|
const globs = globsFor(rule);
|
|
59
|
+
// `exemptFiles` never removes the file from the scan — it MARKS it, so the
|
|
60
|
+
// hits it would have produced are reported as suppressed rather than
|
|
61
|
+
// vanishing. A silently-dropped exemption is indistinguishable from a stale
|
|
62
|
+
// glob, which is the failure mode this whole plane exists to prevent.
|
|
63
|
+
const exempt = rule.exemptFiles && rule.exemptFiles.length > 0 ? rule.exemptFiles : undefined;
|
|
50
64
|
const units = [];
|
|
51
65
|
for (const [path, content] of cache) {
|
|
52
66
|
if (changedSet && !changedSet.has(path))
|
|
53
67
|
continue;
|
|
54
68
|
if (!matchesAny(path, globs))
|
|
55
69
|
continue;
|
|
70
|
+
const exemptFile = exempt !== undefined && matchesAny(path, exempt);
|
|
56
71
|
const ext = nodePath.extname(path).toLowerCase();
|
|
57
72
|
if (rule.surface === 'template' && SOURCE_EXT.has(ext)) {
|
|
58
73
|
for (const tpl of extractInlineTemplates(content)) {
|
|
59
|
-
units.push({
|
|
74
|
+
units.push({
|
|
75
|
+
path,
|
|
76
|
+
content: tpl.body,
|
|
77
|
+
baseLine: tpl.startLine,
|
|
78
|
+
inlineTemplate: true,
|
|
79
|
+
...(exemptFile ? { exemptFile: true } : {}),
|
|
80
|
+
});
|
|
60
81
|
}
|
|
61
82
|
}
|
|
62
83
|
else {
|
|
63
84
|
// .html on the template surface, and all style/ts files: scan whole.
|
|
64
|
-
units.push({ path, content, baseLine: 1 });
|
|
85
|
+
units.push({ path, content, baseLine: 1, ...(exemptFile ? { exemptFile: true } : {}) });
|
|
65
86
|
}
|
|
66
87
|
}
|
|
67
88
|
return units;
|